← Commit history
bridge.json+2−1
@@ -300,7 +300,8 @@     "kicad_test_board",     "kicad_length_tune",     "kicad_silkscreen_prep",-    "kicad_component_classes"+    "kicad_component_classes",+    "kicad_build_schematic"   ],   "statusVerb": "kicad_status",   "timeouts": {
rust/Cargo.lock+6−4
@@ -10,9 +10,11 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-eda-checks"-version = "0.1.4"+version = "0.2.1" dependencies = [+ "roxmltree",  "serde_json",+ "svgtypes", ]  [[package]]@@ -317,7 +319,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.69"+version = "1.0.70" dependencies = [  "adom-eda-checks",  "kicad-core",@@ -329,7 +331,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.69"+version = "1.0.70" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -356,7 +358,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.69"+version = "1.0.70" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.69"+version = "1.0.70" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_build.rs+148
@@ -18,6 +18,16 @@ use kicad_core::board_build::{self, Placed}; use kicad_core::{board_model, pcb, project};  pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_build_schematic",+        summary: "Draw a design's schematic from its netlist with library symbols, as a new sheet (and project): one column per block, every part placed, a wire stub and a net label on every connected pin, the title block filled; then KiCad's netlist of it checked against the nets given, pin by pin, and ERC run.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 600,+        input: "{\"schPath\": \"C:/.../widget/widget.kicad_sch\", \"parts\": [{\"ref\", \"symbol\": \"Device:R\" | \"Name\" (with libraryPath), \"block\"?: \"input\", \"comment\"?: \"10k\", \"footprint\"?: \"Resistor_SMD:R_0603_1608Metric\"}], \"nets\": {\"VIN\": [\"J1.1\", \"R1.1\"]}, \"libraryPath\"?: \"C:/.../Lib.kicad_sym\", \"title\"?, \"revision\"?, \"number\"?, \"drawnBy\"?, \"overwrite\"?: false}",+        example: "kicad_build_schematic {\"schPath\":\"C:/p/divider/divider.kicad_sch\",\"parts\":[{\"ref\":\"R1\",\"symbol\":\"Device:R\",\"comment\":\"10k\"},{\"ref\":\"R2\",\"symbol\":\"Device:R\",\"comment\":\"10k\"}],\"nets\":{\"VIN\":[\"R1.1\"],\"MID\":[\"R1.2\",\"R2.1\"],\"GND\":[\"R2.2\"]}}",+        hint: "mismatches [{net, missing, extra}] compares KiCad's own netlist of the drawn sheet with nets (empty: it matches); pinsWithoutNet lists the parts' pins no net named; compileErrors are ERC's errors. Then kicad_update_pcb_from_schematic brings the parts to a board. Labels are local net labels on the root sheet: KiCad names those nets /<net>, which the comparison reads as <net>.",+        related: &["kicad_sch_place_symbol", "kicad_sch_connect_to_net", "kicad_netlist", "kicad_update_pcb_from_schematic"],+        pitfalls: &["Parts are placed unrotated, one column per block in the order given; the layout is readable, not pretty.", "A new sheet each call: an existing schPath is refused unless overwrite:true (kept as .bak).", "Every symbol must resolve: Library:Symbol through the sym-lib-tables, or a name in libraryPath."],+    },     Verb {         name: "kicad_build_board",         summary: "Build a NEW board from a parts list and a netlist: the outline (a rectangle with rounded corners allowed, or a polygon), every footprint from its library placed by its own origin, pad nets assigned, saved; then read back pin by pin.",@@ -43,6 +53,7 @@ pub static VERBS: &[Verb] = &[ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {     Some(match command {         "kicad_build_board" => build_board(state, args),+        "kicad_build_schematic" => build_schematic(state, args),         "kicad_test_board" => test_board(state, args),         _ => return None,     })@@ -217,6 +228,143 @@ fn test_board(state: &mut State, args: &Value) -> Value {         "_hint": "Look with kicad_render_board {filePath: pcb} or kicad_show_3d_board; run again with the same pcbPath to add more parts."}) } +/// kicad_build_schematic: sch_create, sch_place_symbol per part (stacked by each one's pin extent),+/// sch_connect_to_net per net, then kicad_netlist compared with the nets and kicad_run_erc.+fn build_schematic(state: &mut State, args: &Value) -> Value {+    use crate::verbs::call;+    let Some(sch) = arg_any(args, &["schPath", "filePath"]).map(PathBuf::from) else {+        return fail("missing_arg", "schPath is required", "The .kicad_sch to write; its folder becomes the project when no .kicad_pro is there.");+    };+    let sch_s = norm(&sch);+    let parts: Vec<Value> = args.get("parts").and_then(Value::as_array).cloned().unwrap_or_default();+    if parts.is_empty() {+        return fail("bad_args", "parts is empty", "parts: [{ref, symbol, block?, comment?, footprint?}]");+    }+    let nets: BTreeMap<String, Vec<String>> = args.get("nets").and_then(Value::as_object).map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_array().into_iter().flatten().filter_map(|p| p.as_str().map(String::from)).collect())).collect()).unwrap_or_default();+    if sch.exists() {+        if args.get("overwrite") != Some(&json!(true)) {+            return fail("file_exists", format!("{sch_s} already exists"), "Pass overwrite:true to replace it (it is kept as .bak), or another schPath.");+        }+        let _ = std::fs::copy(&sch, sch.with_extension("kicad_sch.bak"));+        let _ = std::fs::remove_file(&sch);+    }+    let has_pro = sch.with_extension("kicad_pro").is_file();+    let mut comments = Vec::new();+    if let Some(d) = arg_str(args, "drawnBy") {+        comments.push(format!("Drawn by {d}"));+    }+    if let Some(n) = arg_str(args, "number") {+        comments.push(format!("Number {n}"));+    }+    let mut create = json!({"filePath": sch_s, "paper": "A3", "createProject": !has_pro, "comments": comments});+    if let Some(t) = arg_str(args, "title") {+        create["title"] = json!(t);+    }+    if let Some(r) = arg_str(args, "revision") {+        create["rev"] = json!(r);+    }+    let c = call(state, "kicad_sch_create", &create);+    if c["success"] != true {+        return c;+    }+    let library = arg_str(args, "libraryPath");+    let lib_nick = library.map(|l| Path::new(l).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default());+    // one column per block, in the order the blocks first appear+    let mut blocks: Vec<String> = Vec::new();+    for p in &parts {+        let b = p["block"].as_str().unwrap_or("").to_string();+        if !blocks.contains(&b) {+            blocks.push(b);+        }+    }+    let mut next_y: Vec<f64> = vec![40.0; blocks.len()];+    // a label is about 1.27 mm a character at KiCad's default size, plus its flag+    let longest = nets.keys().map(|n| n.chars().count()).max().unwrap_or(4) as f64;+    let gap_below = 2.0 * (2.54 + longest * 1.27 + 1.27) + 2.54 + 7.62;+    let mut placed = 0;+    let mut problems = Vec::new();+    for p in &parts {+        let (Some(r), Some(sym)) = (p["ref"].as_str(), p["symbol"].as_str()) else {+            problems.push(json!({"part": p, "why": "needs ref and symbol"}));+            continue;+        };+        let col = blocks.iter().position(|b| b == p["block"].as_str().unwrap_or("")).unwrap_or(0);+        let x = 50.8 + col as f64 * 63.5;+        let y = next_y[col];+        let lib_id = if sym.contains(':') { sym.to_string() } else { format!("{}:{sym}", lib_nick.clone().unwrap_or_default()) };+        let mut a = json!({"filePath": sch_s, "libId": lib_id, "reference": r, "at": [x, y]});+        if let Some(v) = p["comment"].as_str() {+            a["value"] = json!(v);+        }+        if let Some(f) = p["footprint"].as_str() {+            a["footprint"] = json!(f);+        }+        if let (Some(l), false) = (library, sym.contains(':')) {+            a["libraryPath"] = json!(l);+        }+        let pl = call(state, "kicad_sch_place_symbol", &a);+        if pl["success"] != true {+            problems.push(json!({"ref": r, "why": pl["error"]}));+            continue;+        }+        placed += 1;+        // the next part in the column goes below this one's lowest pin with room for two stubs and two+        // labels between them (one reading down from this part, one up from the next), on the 2.54 grid+        let pins = call(state, "kicad_sch_pins", &json!({"filePath": sch_s, "references": [r]}));+        let low = pins["pins"].as_array().into_iter().flatten().filter_map(|q| q["at"][1].as_f64()).fold(y, f64::max);+        next_y[col] = ((low + gap_below) / 2.54).ceil() * 2.54;+    }+    if placed == 0 {+        let mut v = fail("nothing_placed", "no part could be placed", "Check the symbol names: Library:Symbol as KiCad's tables list them, or a name in libraryPath.");+        v["problems"] = json!(problems);+        return v;+    }+    let (mut wires, mut labels) = (0, 0);+    for (net, pins) in &nets {+        let c = call(state, "kicad_sch_connect_to_net", &json!({"filePath": sch_s, "pins": pins, "net": net, "kind": "local"}));+        if c["success"] == true {+            let n = c["connected"].as_array().map_or(0, |a| a.len());+            wires += n;+            labels += n;+        } else {+            problems.push(json!({"net": net, "why": c["error"]}));+        }+    }+    // KiCad's own netlist of the sheet against the nets given (root-sheet local labels are /<net>)+    let nl = call(state, "kicad_netlist", &json!({"filePath": sch_s}));+    let got: BTreeMap<String, Vec<String>> = nl["nets"].as_object().map(|m| m.iter().map(|(k, v)| (k.trim_start_matches('/').to_string(), v.as_array().into_iter().flatten().filter_map(|p| p.as_str().map(String::from)).collect())).collect()).unwrap_or_default();+    let mut mismatches = Vec::new();+    for (net, want) in &nets {+        let have = got.iter().find(|(k, _)| k.eq_ignore_ascii_case(net)).map(|(_, v)| v.clone()).unwrap_or_default();+        let missing: Vec<&String> = want.iter().filter(|p| !have.contains(p)).collect();+        let extra: Vec<&String> = have.iter().filter(|p| !want.contains(p)).collect();+        if !missing.is_empty() || !extra.is_empty() {+            mismatches.push(json!({"net": net, "missing": missing, "extra": extra}));+        }+    }+    let wired: Vec<&String> = nets.values().flatten().collect();+    let mut without = Vec::new();+    for p in &parts {+        let Some(r) = p["ref"].as_str() else { continue };+        let pins = call(state, "kicad_sch_pins", &json!({"filePath": sch_s, "references": [r]}));+        for q in pins["pins"].as_array().into_iter().flatten() {+            let id = format!("{r}.{}", q["number"].as_str().unwrap_or(""));+            if !wired.contains(&&id) && q["type"] != "no_connect" && q["hidden"] != true {+                without.push(id);+            }+        }+    }+    let erc = call(state, "kicad_run_erc", &json!({"filePath": sch_s}));+    let errors: Vec<Value> = erc["data"]["violations"].as_array().into_iter().flatten().filter(|v| v["severity"] == "error").take(20).cloned().collect();+    let ok = mismatches.is_empty() && problems.is_empty();+    json!({"success": ok, "sch": sch_s, "project": norm(&sch.with_extension("kicad_pro")), "placed": placed, "wires": wires, "labels": labels,+        "mismatches": mismatches, "pinsWithoutNet": without.join(";"), "compileErrors": errors, "problems": problems, "netlistNets": got.len(),+        "errorCode": if ok { Value::Null } else { json!("schematic_mismatch") },+        "output": format!("{placed} part(s), {labels} label(s); {} net mismatch(es)", mismatches.len()),+        "_hint": if ok { "The drawn sheet's netlist matches the nets. kicad_update_pcb_from_schematic brings it to a board; kicad_render_schematic shows it." }+                 else { "Read mismatches and problems: a pin number the symbol does not have, or a symbol that did not resolve. Fix and run again with overwrite:true." }})+}+ #[cfg(test)] mod tests {     use super::*;
rust/crates/kicad-bridge/src/verbs_checks.rs+9−1
@@ -15,7 +15,7 @@ use crate::util::*; use crate::verbs_standard::{file_note, opts, read, stamp}; use kicad_core::board_model; -const ENGINE: &str = "adom-eda-checks 0.1.2 (EDA Bridge Standard 0.4.3, vendored)";+const ENGINE: &str = "adom-eda-checks 0.2.1 (EDA Bridge Standard 0.5.1, vendored)";  pub static VERBS: &[Verb] = &[     Verb {@@ -226,6 +226,14 @@ fn rules_plan(apply: &Value, file: &str) -> Value { mod tests {     use super::*; +    #[test]+    fn the_engine_string_names_the_vendored_copy() {+        // VENDOR.md records what was copied; the replies must say the same (it drifted once)+        let vendor = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../vendor/adom-eda-checks/VENDOR.md")).unwrap();+        let field = |k: &str| vendor.lines().find_map(|l| l.strip_prefix(&format!("- {k}: "))).unwrap().trim().to_string();+        assert_eq!(ENGINE, format!("adom-eda-checks {} (EDA Bridge Standard {}, vendored)", field("Crate version"), field("Standard version")));+    }+     fn board(rel: &str) -> (String, String) {         let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel);         (std::fs::read_to_string(&p).unwrap(), p.to_string_lossy().into_owned())
rust/crates/kicad-bridge/src/verbs_install.rs+2−2
@@ -88,9 +88,9 @@ pub static VERBS: &[Verb] = &[         name: "kicad_install_library_bundle",         summary: "Install an Adom wiki Cloud Library bundle zip (symbols + footprints + 3D models) in one call.",         mechanism: Mechanism::File, risk: "write", timeout_sec: 130,-        input: "{\"zipPath\": \"C:/.../MyLib.zip\", \"libraryName\"?: \"MyLib\", \"capture\"?: false}",+        input: "{\"zipPath\": \"C:/.../MyLib.zip\", \"libraryName\"?: \"MyLib\", \"capture\"?: false, \"planOnly\"?: false}",         example: "kicad_install_library_bundle {\"zipPath\":\"C:/Users/john/Downloads/MyLib.zip\"}",-        hint: "Pass zipPath (staged on THIS machine via write_file / write_file_chunk) and optionally libraryName (defaults to the zip stem). Unpacks the bundle (<Lib>.kicad_sym + <Lib>.pretty/ + 3dmodels/), installs symbols and footprints, carries the 3D models with ref rewriting, and registers both lib tables. Returns {symbols, footprints, modelsInstalled, modelRefsRewritten, kicadRunning, restartNeeded}.",+        hint: "Pass zipPath (staged on THIS machine via write_file / write_file_chunk) and optionally libraryName (defaults to the zip stem). Unpacks the bundle (<Lib>.kicad_sym + <Lib>.pretty/ + 3dmodels/), installs symbols and footprints, carries the 3D models with ref rewriting, and registers both lib tables. Returns {symbols, footprints, modelsInstalled, modelRefsRewritten, kicadRunning, restartNeeded, steps}. planOnly:true installs nothing and answers plan {steps [{id, label, etaSec}], totalEtaSec, expectedEvidence [{part, slots}], libraryFrame}: slots are symbol always, footprint when the bundle has the symbol's footprint, 3d when that footprint's model is in it; the real run's steps[] use the same ids, so a progress bar can be laid out first.",         related: &["kicad_install_library", "kicad_lint_library", "kicad_show_library", "kicad_pcm_install"],         pitfalls: &[             "a RUNNING KiCad reads lib tables at startup only: the user must restart KiCad themselves (never close it for them)",
rust/crates/kicad-core/src/install.rs+92−2
@@ -1346,6 +1346,16 @@ fn bundle_inner(ctx: &LibCtx, args: &Value, source: &Path, lib_name: &str, extra         return json!({"success": false, "error": "No .kicad_sym found in the bundle",             "_hint": "Expected an Adom wiki Cloud Library bundle: <Lib>.kicad_sym + <Lib>.pretty/ + 3dmodels/."});     };+    let capture = args.get("capture").map(|v| !matches!(v, Value::Bool(false) | Value::Null)).unwrap_or(false);+    if args.get("planOnly") == Some(&json!(true)) {+        // wiki #106: the listing only, nothing installed, no lib-table writes+        let mut plan = bundle_plan(&sym, pretty.as_deref(), models.as_deref(), capture);+        plan["libraryName"] = json!(lib_name);+        return json!({"success": true, "planOnly": true, "plan": plan, "mutated": false,+            "_hint": "Nothing installed. Lay out the progress from plan.steps (the real run answers steps[] with the same ids) and the evidence slots from plan.expectedEvidence; then call again without planOnly."});+    }+    let started = std::time::Instant::now();+    let mut done: Vec<Value> = vec![json!({"id": "unpack", "done": true, "elapsedMs": started.elapsed().as_millis() as u64})];     let top_names = top_symbol_names(&sym);     let mut report = json!({"libraryName": lib_name, "symbols": top_names.len(),                             "footprints": 0, "modelsInstalled": 0, "modelRefsRewritten": 0});@@ -1356,8 +1366,9 @@ fn bundle_inner(ctx: &LibCtx, args: &Value, source: &Path, lib_name: &str, extra     }));     if !sym_res["success"].as_bool().unwrap_or(false) {         return json!({"success": false, "error": format!("Symbol install failed: {}", sym_res["error"].as_str().unwrap_or("")),-            "step": "symbols", "detail": sym_res});+            "step": "symbols", "detail": sym_res, "steps": done});     }+    done.push(json!({"id": "symbols", "done": true, "elapsedMs": started.elapsed().as_millis() as u64}));     if let Some(pretty) = &pretty {         let fp_res = install_footprint_library(ctx, &json!({             "libraryPath": norm_path(pretty), "libraryName": lib_name,@@ -1371,7 +1382,13 @@ fn bundle_inner(ctx: &LibCtx, args: &Value, source: &Path, lib_name: &str, extra         report["footprints"] = json!(kicad_mods(pretty).len());         report["modelsInstalled"] = fp_res["data"].get("modelsInstalled").cloned().unwrap_or(json!(0));         report["modelRefsRewritten"] = fp_res["data"].get("modelRefsRewritten").cloned().unwrap_or(json!(0));+        done.push(json!({"id": "footprints", "done": true, "elapsedMs": started.elapsed().as_millis() as u64}));+        if models.is_some() {+            done.push(json!({"id": "models", "done": true, "elapsedMs": started.elapsed().as_millis() as u64}));+        }     }+    done.push(json!({"id": "register", "done": true, "elapsedMs": started.elapsed().as_millis() as u64}));+    report["steps"] = json!(done);      // Whether the user has to restart KiCad is a fact about their machine, not a guess:     // the process scan answers it where the OS layer has one (wiki #63).@@ -1401,12 +1418,63 @@ fn bundle_inner(ctx: &LibCtx, args: &Value, source: &Path, lib_name: &str, extra         "data": report,         "_hint": format!("The bundle is installed and registered in both lib tables. {restart_line}"),     });-    if args.get("capture").map(|v| !matches!(v, Value::Bool(false) | Value::Null)).unwrap_or(false) {+    if capture {         result["proofSkipped"] = json!("capture is window-phase: this build opens no Symbol Editor and takes no frame; call kicad_show_library once the window verbs land");     }     result } +/// The plan for a bundle install (wiki #106): the steps the real run reports with the same ids, a+/// rough time for each, and per part the evidence the install produces: its symbol always, its+/// footprint when the bundle's .pretty has the symbol's footprint, 3d when that footprint's model is+/// in the bundle. One library frame at the end (the Symbol Editor), only with capture: never one+/// image per part.+pub fn bundle_plan(sym: &Path, pretty: Option<&Path>, models: Option<&Path>, capture: bool) -> Value {+    let text = std::fs::read_to_string(sym).unwrap_or_default();+    let fps: Vec<PathBuf> = pretty.map(kicad_mods).unwrap_or_default();+    let model_files: Vec<String> = models.and_then(|m| std::fs::read_dir(m).ok()).map(|rd| rd.flatten().filter_map(|e| e.path().file_stem().map(|s| s.to_string_lossy().to_lowercase())).collect()).unwrap_or_default();+    let model_bytes: u64 = models.and_then(|m| std::fs::read_dir(m).ok()).map(|rd| rd.flatten().filter_map(|e| e.metadata().ok().map(|m| m.len())).sum()).unwrap_or(0);+    let fp_text = |name: &str| fps.iter().find(|p| p.file_stem().map(|s| s.to_string_lossy() == name).unwrap_or(false)).and_then(|p| std::fs::read_to_string(p).ok());+    let mut evidence = Vec::new();+    if let Ok(root) = crate::sxedit::parse(&text) {+        for sy in root.kids_tagged("symbol") {+            let Some(name) = sy.atom(1) else { continue };+            let fp_prop = sy.kids_tagged("property").find(|p| p.atom(1) == Some("Footprint")).and_then(|p| p.atom(2)).unwrap_or("");+            let fp_name = fp_prop.rsplit(':').next().unwrap_or("");+            let mut slots = vec!["symbol"];+            if let Some(ft) = (!fp_name.is_empty()).then(|| fp_text(fp_name)).flatten() {+                slots.push("footprint");+                let has_model = ft.match_indices("(model ").any(|(i, _)| {+                    let rest = &ft[i + 7..];+                    let path = rest.trim_start().trim_start_matches('"').split('"').next().unwrap_or("");+                    let stem = path.rsplit(['/', '\\']).next().unwrap_or("").rsplit_once('.').map(|x| x.0).unwrap_or("").to_lowercase();+                    !stem.is_empty() && model_files.contains(&stem)+                });+                if has_model {+                    slots.push("3d");+                }+            }+            evidence.push(json!({"part": name, "footprint": fp_prop, "slots": slots}));+        }+    }+    let n = evidence.len() as f64;+    let mut steps = vec![json!({"id": "unpack", "label": "Unpack the bundle", "etaSec": 1})];+    steps.push(json!({"id": "symbols", "label": format!("Install {} symbol(s)", evidence.len()), "etaSec": (1.0 + n * 0.05).ceil()}));+    if !fps.is_empty() {+        steps.push(json!({"id": "footprints", "label": format!("Install {} footprint(s)", fps.len()), "etaSec": (1.0 + fps.len() as f64 * 0.05).ceil()}));+    }+    if !model_files.is_empty() {+        steps.push(json!({"id": "models", "label": format!("Carry {} 3D model(s)", model_files.len()), "etaSec": (1.0 + model_bytes as f64 / 50e6).ceil()}));+    }+    steps.push(json!({"id": "register", "label": "Register both library tables", "etaSec": 1}));+    if capture {+        steps.push(json!({"id": "frame", "label": "One frame of the library in the Symbol Editor", "etaSec": 15}));+    }+    let total: f64 = steps.iter().filter_map(|s| s["etaSec"].as_f64()).sum();+    json!({"steps": steps, "totalEtaSec": total, "expectedEvidence": evidence, "libraryFrame": if capture { 1 } else { 0 },+        "counts": {"symbols": evidence.len(), "footprints": fps.len(), "models": model_files.len()}})+}+ // --------------------------------------------------------------------------- // kicad_adom_library_heal (adom_library.heal_split) // ---------------------------------------------------------------------------@@ -2671,6 +2739,28 @@ mod tests {         assert_eq!(export_part(&bx.ctx, &json!({}))["error"], json!("partName required"));     } +    #[test]+    fn a_bundle_plan_lists_steps_and_slots_without_installing() {+        let d = std::env::temp_dir().join(format!("adom-bundle-plan-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(d.join("Lib.pretty")).unwrap();+        std::fs::create_dir_all(d.join("3dmodels")).unwrap();+        std::fs::write(d.join("Lib.kicad_sym"), "(kicad_symbol_lib (version 20241209)\n (symbol \"AAA\" (property \"Footprint\" \"Lib:FP_A\" (at 0 0 0)))\n (symbol \"BBB\" (property \"Footprint\" \"Lib:FP_B\" (at 0 0 0)))\n (symbol \"CCC\" (property \"Footprint\" \"\" (at 0 0 0))))").unwrap();+        std::fs::write(d.join("Lib.pretty/FP_A.kicad_mod"), "(footprint \"FP_A\" (model \"${ADOM}/3dmodels/fp_a.step\"))").unwrap();+        std::fs::write(d.join("Lib.pretty/FP_B.kicad_mod"), "(footprint \"FP_B\")").unwrap();+        std::fs::write(d.join("3dmodels/fp_a.step"), "ISO-10303-21;").unwrap();+        let p = bundle_plan(&d.join("Lib.kicad_sym"), Some(&d.join("Lib.pretty")), Some(&d.join("3dmodels")), true);+        let ev = p["expectedEvidence"].as_array().unwrap();+        assert_eq!(ev[0]["slots"], json!(["symbol", "footprint", "3d"]), "{p}");+        assert_eq!(ev[1]["slots"], json!(["symbol", "footprint"]));+        assert_eq!(ev[2]["slots"], json!(["symbol"]));+        let ids: Vec<&str> = p["steps"].as_array().unwrap().iter().filter_map(|s| s["id"].as_str()).collect();+        assert_eq!(ids, vec!["unpack", "symbols", "footprints", "models", "register", "frame"]);+        assert_eq!(p["libraryFrame"], 1);+        assert!(p["totalEtaSec"].as_f64().unwrap() > 0.0);+        let _ = std::fs::remove_dir_all(&d);+    }+     #[test]     fn footprint_text_helpers() {         assert_eq!(footprint_name_in(MOD_R).as_deref(), Some("R_0805_Adom"));
rust/vendor/adom-eda-checks/Cargo.toml+5−2
@@ -1,8 +1,8 @@ [package] name = "adom-eda-checks"-version = "0.1.4"+version = "0.2.1" edition = "2021"-description = "The EDA Bridge Standard's checks, computed from the board model JSON: no bridge calls, no tool."+description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"  [lib]@@ -14,6 +14,9 @@ path = "src/main.rs"  [dependencies] serde_json = "1"+# the SVG parser behind writers::svgart (pure Rust)+roxmltree = "0.20"+svgtypes = "0.15"  [profile.release] opt-level = 3
rust/vendor/adom-eda-checks/README.md+15−1
@@ -1,4 +1,4 @@-# adom-eda-checks: the shared checks engine+# adom-eda-checks: the shared checks engine and board-file writers  The checks tier of the EDA Bridge Standard, computed from the board model and its companions as plain JSON. It makes no bridge calls, runs no tool and does no I/O. A bridge links the crate (the Altium, KiCad and Fusion bridges are all@@ -20,6 +20,18 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. The other checks-tier verbs (assembly, 3D heights, placement, schematic review, pair routing, length tuning, designators, via fences, panels, the board report) come over from the Altium bridge a module at a time. +## Writers++For a bridge whose tool has no native exporter (`writers`, from altium-lane, #16). They stay native-tier verbs in the+standard: a bridge whose tool can write the file keeps its own exporter. No I/O: each returns the file text or the+regions, with `writtenFrom`, and the bridge writes the file or places the regions.++| Verb | Reads | What it answers |+|---|---|---|+| `export_gencad` | board | GenCAD 1.4 text, in inches: outline, pads and padstacks, shapes, components (a bottom part `MIRRORX FLIP`, as KiCad writes it), devices, signals, routes |+| `export_dxf` | board, pours? | one DXF (R12) per layer in `args.layers`: Edge.Cuts as lines and arcs or one closed polyline (`contours`), copper as tracks, arcs, pads, vias and pours. With `pours` a copper layer shows the copper actually poured; without, `poursAsDrawn` names the layers that show the pour as drawn |+| `import_svg` | svg (text) | `regions` in board mm (y up) for a layer: each shape with its holes as one contour reaching the holes through zero-width slits |+ ## Input  Every check takes one JSON object:@@ -61,6 +73,8 @@ As a command: adom-eda-checks list adom-eda-checks return_path --board board.json --stack stack.json --pours pours.json --args '{"resolutionMm": 0.1}' adom-eda-checks fab_check bundle.json        # or - for stdin+adom-eda-checks export_dxf --board board.json --pours pours.json --args '{"layers": ["Edge.Cuts", "F.Cu"]}'+adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20, "layer": "F.SilkS"}' ```  Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix
rust/vendor/adom-eda-checks/VENDOR.md+4−4
@@ -3,10 +3,10 @@ The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki has no git protocol, so Cargo cannot fetch it). -- Standard version: 0.4.5-- Standard commit: cfaaac1006c7f7b2186ce7824f2b5390599f739a-- Crate version: 0.1.4-- Copied: 2026-10-27, by kicad-lane+- Standard version: 0.5.1+- Standard commit: e8a33ccecec060707f651b3956aeaa5492645290+- Crate version: 0.2.1+- Copied: 2026-10-28, by kicad-lane  To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`, `engine/Cargo.toml` and `engine/README.md` here, record the new version and commit above, and run the bridge's tests.
rust/vendor/adom-eda-checks/src/lib.rs+7−4
@@ -1,6 +1,7 @@ //! adom-eda-checks: the EDA Bridge Standard's checks tier, computed from the board model and its companions as plain-//! JSON. No bridge calls, no tool, no I/O: a bridge links this crate (or runs the `adom-eda-checks` command) and-//! answers the check under its own prefix (`kicad_return_path`). Replies keep the standard envelope (`success`,+//! JSON, plus board-file writers (`writers`: GenCAD, DXF, SVG to regions) for a bridge whose tool has no native+//! exporter. No bridge calls, no tool, no I/O: a bridge links this crate (or runs the `adom-eda-checks` command) and+//! answers the verb under its own prefix (`kicad_return_path`). Replies keep the standard envelope (`success`, //! `errorCode`, `error`, `_hint`) and name verbs without a prefix. //! //! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, sheetParts?, netlist?, args?}` (see `input`).@@ -12,18 +13,20 @@ pub mod fab; pub mod geom; pub mod impedance; pub mod input;+pub mod writers;  /// The checks this build answers, by standard verb name. pub const CHECKS: &[&str] = &["copper_balance", "current_capacity", "fab_check", "impedance", "return_path"]; -/// Run one check by its standard name.+/// Run one check or writer by its standard name. pub fn run(check: &str, input: &Value) -> Value {+    if let Some(out) = writers::run(check, input) { return out; }     match check {         "copper_balance" => coppermap::copper_balance(input),         "current_capacity" => current::current_capacity(input),         "fab_check" => fab::fab_check(input),         "impedance" => impedance::impedance(input),         "return_path" => coppermap::return_path(input),-        _ => input::refuse("not_supported", &format!("{check} is not a check this engine answers (it answers {})", CHECKS.join(", "))),+        _ => input::refuse("not_supported", &format!("{check} is not a verb this engine answers (checks: {}; writers: {})", CHECKS.join(", "), writers::WRITERS.join(", "))),     } }
rust/vendor/adom-eda-checks/src/main.rs+5−2
@@ -1,5 +1,6 @@ //! adom-eda-checks <check> [bundle.json | -] //! adom-eda-checks <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']+//! adom-eda-checks import_svg --svg art.svg --args '{"at": [10, 10], "widthMm": 20}' //! Prints the check's reply as JSON. `adom-eda-checks list` names the checks. use serde_json::{json, Value}; use std::io::Read;@@ -27,6 +28,8 @@ fn bundle(rest: &[String]) -> Result<Value, String> {                 b[key] = read(val)?;             }             "--args" => b["args"] = serde_json::from_str(val).map_err(|e| format!("--args: {e}"))?,+            // SVG artwork is text, not JSON+            "--svg" => b["svg"] = Value::String(std::fs::read_to_string(val).map_err(|e| format!("{val}: {e}"))?),             _ => return Err(format!("unknown flag {flag}")),         }     }@@ -36,8 +39,8 @@ fn bundle(rest: &[String]) -> Result<Value, String> { fn main() {     let argv: Vec<String> = std::env::args().skip(1).collect();     let out = match argv.first().map(String::as_str) {-        None | Some("-h") | Some("--help") => json!({"success": true, "usage": "adom-eda-checks <check> [bundle.json | -] | <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']", "checks": adom_eda_checks::CHECKS}),-        Some("list") => json!({"success": true, "checks": adom_eda_checks::CHECKS}),+        None | Some("-h") | Some("--help") => json!({"success": true, "usage": "adom-eda-checks <check> [bundle.json | -] | <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']", "checks": adom_eda_checks::CHECKS, "writers": adom_eda_checks::writers::WRITERS}),+        Some("list") => json!({"success": true, "checks": adom_eda_checks::CHECKS, "writers": adom_eda_checks::writers::WRITERS}),         Some(check) => match bundle(&argv[1..]) {             Ok(b) => adom_eda_checks::run(check, &b),             Err(e) => json!({"success": false, "errorCode": "bad_args", "error": e, "_hint": "Pass one bundle file {board, stack, pours, args}, or --board, --stack, --pours and --args."}),
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); }+    }+}
skills/kicad-bridge-test/run_verb_tests.py+15−1
@@ -195,7 +195,7 @@ def matrix(w: dict) -> dict:         **{v: dict(phase=10, scenario=True) for v in ("fab_check", "copper_balance", "return_path", "impedance", "current_capacity", "net_lengths",             "check_orientation", "find_space", "height_check", "pin_pad_check", "auto_classes", "thermal_vias", "via_fence", "board_report",             "bom", "assembly_check", "jlcpcb_order", "footprint_from_land_pattern", "silk_designators",-            "read_sheet_file", "build_board", "test_board", "length_tune", "silkscreen_prep", "component_classes")},+            "read_sheet_file", "build_board", "test_board", "length_tune", "silkscreen_prep", "component_classes", "build_schematic")},         "panelize": dict(phase=8, scenario=True),         # ---- phase 8: the 1.0.61 verbs (Konnect parity), run in order by k161_phase() on scratch files         "add_footprint": dict(phase=8, scenario=True),@@ -854,6 +854,15 @@ def main():                          "response": r}         print(f"  kicad_{verb:24} {'PASS' if passed else 'FAIL'}")         if special == "install_bundle":+            # wiki #106: planOnly lists the steps and evidence slots, installs nothing; the real run used the same step ids+            pl = ab(a.target, "kicad_install_library_bundle", dict(args, planOnly=True), timeout=90)+            plan_ids = [s.get("id") for s in ((pl.get("plan") or {}).get("steps") or [])]+            run_ids = [s.get("id") for s in (((r.get("data") or {}).get("steps")) or [])]+            plan_ok = ok(pl) and pl.get("mutated") is False and bool(plan_ids) and set(run_ids) <= set(plan_ids) and ((pl.get("plan") or {}).get("expectedEvidence") or [{}])[0].get("slots", [None])[0] == "symbol"+            # (record() is defined after the phase loop: write the result the way it does)+            results["install_library_bundle_plan"] = {"result": "PASS" if plan_ok else "FAIL", "elapsed": None, "response_head": json.dumps(pl)[:400],+                                                      "response": {"plan": pl, "runSteps": run_ids}, **({} if plan_ok else {"reason": f"plan steps {plan_ids}, run steps {run_ids}"})}+            print(f"  kicad_{'install_library_bundle_plan':24} {'PASS' if plan_ok else 'FAIL'}")             # leave nothing behind: the bundle library comes out again whether or not the install passed             cr = ab(a.target, "kicad_uninstall_library", {"libraryName": "AdomVerbBundle", "deleteFiles": True}, timeout=90)             results[verb]["cleanup"] = {k: cr.get(k) for k in ("success", "errorCode", "removed", "filesDeleted")}@@ -1549,6 +1558,11 @@ def main():             and not asserts and ok(gone) and not gone.get("classes"), "the class and its room were not made, held in DRC, or removed")         r = C("silkscreen_prep", {"filePath": RT, "source": "file"}, 600)         own("silkscreen_prep", r, ok(r) and r.get("gerbersClipSilk") is True and (r.get("after") or {}).get("silkToSolderMask") is not None, "no prep, or no DRC counts after")+        # 1.0.70: a schematic drawn from a netlist, checked against KiCad's own netlist of it+        r = C("build_schematic", {"schPath": w["out"] + "/divider/divider.kicad_sch", "overwrite": True, "title": "Divider",+              "parts": [{"ref": "R1", "symbol": "Device:R", "comment": "10k"}, {"ref": "R2", "symbol": "Device:R", "comment": "10k"}],+              "nets": {"VIN": ["R1.1"], "MID": ["R1.2", "R2.1"], "GND": ["R2.2"]}}, 400)+        own("build_schematic", r, ok(r) and r.get("placed") == 2 and r.get("mismatches") == [], "the drawn sheet's netlist does not match the nets given")         r = C("silk_designators", dict(fb, size=1.0), 400)         own("silk_designators", r, ok(r) and (r.get("placed") or 0) > 0 and r.get("onPadsAfter") == [] and (r.get("drc") or {}).get("newErrors") == 0,             "the labels were not written into the closed board after a clean DRC, or one is on copper")
skills/kicad-schematic-and-board-edits/SKILL.md+5
@@ -58,6 +58,11 @@ Every verb here edits files or the live board the way KiCad would, answers what `sym_create`, `sym_info`, `sym_delete`, `fp_create`, `fp_info`, `fp_edit`, `search_library` (every registered library, PCM ones included), `project_create`, `project_info`, `project_rename`, `project_snapshot`. +From 1.0.70, `build_schematic {schPath, parts, nets}` draws a new sheet (and project) from a netlist with library+symbols: one column per block, a stub and a net label on every connected pin, the title block filled, then KiCad's+own netlist of the sheet compared with the nets pin by pin (`mismatches`), and ERC. `read_sheet_file` (1.0.69) reads a+sheet's parts, fields and pins straight from the file.+ From 1.0.68, `footprint_from_land_pattern` builds a footprint from the land pattern a datasheet prints (family chip, dual, sot, quad or bga; pitch, pad size, span; an exposed pad; the body): pads numbered the IPC way, silkscreen clear of the copper with a pin-1 dot, a fab outline with a pin-1 chamfer, the courtyard, and a 3D body box (a VRML