app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
Merge PR #338: kicad-lane: 1.0.80, kicad_zoom_to (#121), engine 0.11.0
12 files changed
+502−17
bridge.json+2−1rust/Cargo.lock+4−4rust/Cargo.toml+1−1rust/crates/kicad-bridge/src/verbs_routing.rs+1−1rust/crates/kicad-bridge/src/verbs_standard.rs+39rust/crates/kicad-core/src/ipc.rs+37−1rust/vendor/adom-eda-checks/Cargo.toml+1−1rust/vendor/adom-eda-checks/README.md+2−1rust/vendor/adom-eda-checks/VENDOR.md+3−3rust/vendor/adom-eda-checks/src/writers/ipc2581.rs+398rust/vendor/adom-eda-checks/src/writers/mod.rs+5−3skills/kicad-bridge-test/run_verb_tests.py+9−1bridge.json+2−1@@ -308,7 +308,8 @@ "kicad_plan_route", "kicad_generate_body", "kicad_export_spice",- "kicad_simulate"+ "kicad_simulate",+ "kicad_zoom_to" ], "statusVerb": "kicad_status", "timeouts": {
rust/Cargo.lock+4−4@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-eda-checks"-version = "0.10.4"+version = "0.11.0" dependencies = [ "roxmltree", "serde_json",@@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.79"+version = "1.0.80" dependencies = [ "adom-eda-checks", "kicad-core",@@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.79"+version = "1.0.80" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.79"+version = "1.0.80" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.79"+version = "1.0.80" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_routing.rs+1−1@@ -132,7 +132,7 @@ pub static VERBS: &[Verb] = &[ mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60, input: "{\"filePath\", \"net\": \"+VBAT\", \"add\"?: false}", example: "kicad_select_net {\"filePath\":\"C:/p/b.kicad_pcb\",\"net\":\"/DRV_SHA\"}",- hint: "The way to walk a board net by net on camera: select the net, then kicad_send_key {hwnd, menu:\"Zoom to Selected Objects\"} on the PCB Editor frames it; kicad_select_clear afterwards. Selection is not an edit (no undo step, revision unchanged). add:true keeps the previous selection.",+ hint: "The way to walk a board net by net on camera: kicad_zoom_to {net} selects it and frames it in one call, in the background (wiki #121). Selection is not an edit (no undo step, revision unchanged). add:true keeps the previous selection.", related: &["kicad_select_clear", "kicad_send_key", "kicad_zone_state", "kicad_routing_state"], pitfalls: &["net names are exact (KiCad's spelling, with the leading slash for local labels)", "a net with no copper yet has nothing to select"], },
rust/crates/kicad-bridge/src/verbs_standard.rs+39@@ -132,6 +132,16 @@ pub static VERBS: &[Verb] = &[ related: &["kicad_layers", "kicad_refresh_board_views", "kicad_screenshot_all"], pitfalls: &["Needs the board open in the PCB editor with the IPC API on."], },+ Verb {+ name: "kicad_zoom_to",+ summary: "Frame the open PCB editor's view on parts or a whole net (or the whole board), with KiCad's own Zoom to Selected Objects run through its API, so it works while KiCad stays in the background.",+ mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 30,+ input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"refs\"?: [\"U1\", \"C3\"] | \"ref\"?: \"U1\", \"net\"?: \"/SDA\", \"keepSelection\"?: false}",+ example: "kicad_zoom_to {\"refs\":[\"U3\"]}",+ hint: "Without refs or net the whole board is fitted. The parts or the net are selected for the zoom, then the selection is cleared (keepSelection:true leaves it highlighted). A view change only: nothing on the board changes and there is nothing to undo. kicad_screenshot_all shows the result; for a picture without the editor, kicad_render_board {ref|refs} frames parts in KiCad's renderer.",+ related: &["kicad_select_net", "kicad_select_items", "kicad_render_board", "kicad_screenshot_all"],+ pitfalls: &["KiCad frames the selection with its own margin; there is no margin to set.", "A rectangle is refused (not_supported): KiCad's API zooms to a selection, so name the parts in it."],+ }, Verb { name: "kicad_silk_state", summary: "The silkscreen as it stands: every part's body box, its designator and value as placed (text, position, size, rotation, shown), and the free texts and graphics on both silkscreen layers.",@@ -159,6 +169,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_layers" => layers(state, args), "kicad_silk_state" => silk(state, args), "kicad_set_layer_visibility" => set_layer_visibility(state, args),+ "kicad_zoom_to" => zoom_to(state, args), _ => return None, }) }@@ -345,6 +356,34 @@ fn set_layer_visibility(state: &mut State, args: &Value) -> Value { } } +/// kicad_zoom_to: the one open board when no filePath is given, then kicad_core::ipc::zoom_to.+#[cfg(feature = "ipc")]+fn zoom_to(state: &mut State, args: &Value) -> Value {+ use kicad_core::{detect, ipc};+ let mut a = args.clone();+ if arg_str(args, "filePath").is_none() {+ match ipc::open_board_paths(std::time::Duration::from_secs(3)) {+ Ok(open) if open.len() == 1 => a["filePath"] = json!(open[0]),+ Ok(open) if open.len() > 1 => return fail("ambiguous_board", format!("{} boards are open: {}", open.len(), open.join(", ")), "Pass filePath: the board to frame."),+ _ => return fail("no_open_board", "no board is open in KiCad's PCB editor", "Open one with kicad_open_board, or pass filePath."),+ }+ }+ let info = state.kicad_info();+ let ctx = ipc::Ctx {+ kicad_cli: info.primary().map(|p| std::path::PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+ config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),+ };+ match ipc::zoom_to(&ctx, &a) {+ Ok(v) => v,+ Err(e) => ipc::error_response(&e),+ }+}++#[cfg(not(feature = "ipc"))]+fn zoom_to(_state: &mut State, _args: &Value) -> Value {+ fail("ipc_feature_off", "This build of the bridge has the ipc feature off", "Zooming needs the open PCB editor's API.")+}+ #[cfg(not(feature = "ipc"))] fn set_layer_visibility(_state: &mut State, _args: &Value) -> Value { fail("ipc_feature_off", "This build of the bridge has the ipc feature off", "Layer visibility needs the open PCB editor's API.")
rust/crates/kicad-core/src/ipc.rs+37−1@@ -654,7 +654,43 @@ pub fn select_net(ctx: &Ctx, args: &Value) -> RResult<Value> { s.client.add_to_selection(ids.clone()).map_err(map_err)?; Ok(json!({ "success": true, "mutated": false, "source": "live-editor", "net": net, "selected": count, "byKind": kinds, "itemIds": ids,- "_hint": "The net's pours, tracks, vias and pads are now the editor's selection (highlighted on every layer). Frame it with kicad_send_key {hwnd, menu:\"Zoom to Selected Objects\"} on the PCB editor; clear it with kicad_select_clear. Selection is not an edit: nothing to undo, the revision is unchanged.",+ "_hint": "The net's pours, tracks, vias and pads are now the editor's selection (highlighted on every layer). Frame it with kicad_zoom_to {net} (KiCad's own Zoom to Selected Objects over the API, which works in the background); clear it with kicad_select_clear. Selection is not an edit: nothing to undo, the revision is unchanged.",+ }))+}++/// `kicad_zoom_to` (the standard's zoom_to): frame the PCB editor's view on parts (`refs`) or a whole `net`+/// through KiCad's own action (`common.Control.zoomFitSelection`, run over the API, so it acts on a background+/// window, where a posted menu command is acknowledged but the view does not move: wiki #121); without a+/// target, the whole board (`common.Control.zoomFitScreen`). The selection it makes is cleared afterwards+/// unless `keepSelection`.+pub fn zoom_to(ctx: &Ctx, args: &Value) -> RResult<Value> {+ let has = |k: &str| args.get(k).map(|v| !v.is_null()).unwrap_or(false);+ if has("rect") {+ return Err(RoutingError::new("not_supported", "KiCad's API frames the selection, not a rectangle: pass refs (the parts in it) or net"));+ }+ let mut picked = Value::Null;+ let action = if has("net") {+ picked = select_net(ctx, &json!({"filePath": args.get("filePath"), "socketPath": args.get("socketPath"), "net": args["net"]}))?;+ "common.Control.zoomFitSelection"+ } else if has("refs") || has("ref") {+ let refs = args.get("refs").cloned().unwrap_or_else(|| json!([args["ref"]]));+ picked = board_live::select_items(ctx, &json!({"filePath": args.get("filePath"), "socketPath": args.get("socketPath"), "refs": refs, "mode": "replace"}))?;+ "common.Control.zoomFitSelection"+ } else {+ "common.Control.zoomFitScreen"+ };+ let s = connect(ctx, args)?;+ let status = s.client.run_action(action).map_err(map_err)?;+ let keep = args.get("keepSelection").and_then(Value::as_bool).unwrap_or(false);+ if !keep && !picked.is_null() {+ s.client.clear_selection().map_err(map_err)?;+ }+ let ok = format!("{status:?}").to_lowercase().contains("ok");+ Ok(json!({+ "success": ok, "mutated": false, "source": "live-editor", "action": action, "actionStatus": format!("{status:?}"),+ "framed": if has("net") { json!({"net": args["net"], "items": picked["selected"]}) } else if picked.is_null() { json!("board") } else { json!({"refs": args.get("refs").or(args.get("ref"))}) },+ "selectionKept": keep && !picked.is_null(),+ "_hint": if ok { "The view is framed (KiCad's own Zoom to Selected Objects). kicad_screenshot or kicad_state shows it; nothing on the board changed." } else { "KiCad did not run the action: the PCB editor may be busy with an interactive tool (press Escape in it) or not the editor serving the API." }, })) }
rust/vendor/adom-eda-checks/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.10.4"+version = "0.11.0" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
rust/vendor/adom-eda-checks/README.md+2−1@@ -43,6 +43,7 @@ regions, with `writtenFrom`, and the bridge writes the file or places the region |---|---|---| | `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 |+| `export_ipc2581` | board, stack?, pours? | IPC-2581 revision C (mm) for assembly and test, valid against the IPC-2581C schema: layers and stack-up, outline (arcs as curves), one package per footprint with its pins in the footprint's frame, components (a bottom part mirrored) and BOM, nets with their pins, and on each copper layer the pads, traces, arcs, vias and, with `pours`, the poured copper island by island with its holes, plus the drills (a hole with no ring is non-plated). `args.date` sets the history date (default now). No mask, paste or silkscreen. From the Fusion bridge's routing_script.py | | `generate_body` | args only | a part's 3D body as STEP AP214 text from its package family and JEDEC letters (`family`, `dimensionsMm` with `pins` the total pin count and `D2`/`E2` an exposed pad, `marking?`), plus its box in the bodies companion's form; families chip, soic, qfn, qfp, sot23, dfn, bga, melf, ecap, crystal, header (#20) | | `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 | @@ -95,7 +96,7 @@ adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20 Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix (`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`-(`"adom-eda-checks 0.10.4"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.11.0"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
rust/vendor/adom-eda-checks/VENDOR.md+3−3@@ -3,9 +3,9 @@ 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.13.4-- Standard commit: bca24e18b1d8ef250edcb32683c7a4d20144ded6-- Crate version: 0.10.4+- Standard version: 0.14.0+- Standard commit: 2421e66b3bf499df02e9771db51dd56103b5c2a8+- Crate version: 0.11.0 - Copied: 2026-10-28, by kicad-lane To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`,
rust/vendor/adom-eda-checks/src/writers/ipc2581.rsadded+398@@ -0,0 +1,398 @@+//! IPC-2581 revision C (mm) for assembly and test, from the board model: the layers and stack-up, the outline, one+//! package per footprint (pins in the footprint's own frame), the components and BOM, the nets with their pins, and on+//! each copper layer the pads (with their pin), traces, arcs, vias and, with pour_state, the poured copper island by+//! island, plus the drills. From the Fusion bridge's routing_script.py `ipc2581_xml`, which validates against the+//! IPC-2581C schema; the element order follows it.+use super::dxf::f;+use serde_json::{json, Value};++/// A number as IPC-2581 wants it: up to 4 decimals, no trailing zeros.+fn n(v: f64) -> String {+ let s = format!("{:.4}", if v.abs() < 5e-5 { 0.0 } else { v });+ let s = s.trim_end_matches('0').trim_end_matches('.');+ if s.is_empty() || s == "-" { "0".into() } else { s.to_string() }+}++/// Fab formats are read by ASCII tools: the usual unit signs by name (10kΩ -> 10kohm), '?' for the rest.+fn ascii(t: &str) -> String {+ t.chars().map(|c| match c {+ c if (c as u32) < 128 => c.to_string(),+ 'Ω' | 'Ω' => "ohm".into(), 'µ' | 'μ' => "u".into(), '±' => "+/-".into(), '°' => "deg".into(), '–' | '—' => "-".into(),+ _ => "?".into(),+ }).collect()+}++/// An attribute value: ASCII, XML-escaped.+fn a(t: &str) -> String { ascii(t).replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) }++/// The pad primitives used, by id, in the order first used.+#[derive(Default)]+struct Dict { ids: Vec<(String, String)> }++impl Dict {+ /// A pad's primitive: a circle, an oval (a stadium) or a centred rectangle.+ fn pad(&mut self, shape: &str, w: f64, h: f64) -> String {+ let round = matches!(shape, "round" | "circle");+ let (id, el) = if round && (w - h).abs() < 1e-6 {+ (format!("CIRCLE_{}", n(w)), format!("<Circle diameter=\"{}\"/>", n(w)))+ } else if round {+ (format!("OVAL_{}x{}", n(w), n(h)), format!("<Oval width=\"{}\" height=\"{}\"/>", n(w), n(h)))+ } else {+ (format!("RECT_{}x{}", n(w), n(h)), format!("<RectCenter width=\"{}\" height=\"{}\"/>", n(w), n(h)))+ };+ let id = id.replace('.', "_").replace('-', "m");+ if !self.ids.iter().any(|(i, _)| *i == id) { self.ids.push((id.clone(), el)); }+ id+ }+ fn xml(&self) -> String {+ let mut o = String::from(" <DictionaryStandard units=\"MILLIMETER\">\n");+ for (id, el) in &self.ids { o += &format!(" <EntryStandard id=\"{id}\">{el}</EntryStandard>\n"); }+ o + " </DictionaryStandard>\n"+ }+}++/// A closed outline from the board model's outline edges (lines and arcs), or its point list.+fn profile_polygon(st: &Value) -> String {+ let edges = st["outlineEdges"].as_array().cloned().unwrap_or_default();+ let pt = |v: &Value| (f(&v[0]), f(&v[1]));+ let mut o = String::from(" <Polygon>\n");+ if !edges.is_empty() {+ let first = pt(&edges[0]["start"]);+ o += &format!(" <PolyBegin x=\"{}\" y=\"{}\"/>\n", n(first.0), n(first.1));+ for (i, e) in edges.iter().enumerate() {+ // an edge ends where the next begins (the last closes the ring)+ let end = if e["end"].is_array() { pt(&e["end"]) } else { pt(&edges[(i + 1) % edges.len()]["start"]) };+ if e["kind"] == "arc" {+ let cw = super::dxf::bulge(e) < 0.0;+ o += &format!(" <PolyStepCurve x=\"{}\" y=\"{}\" centerX=\"{}\" centerY=\"{}\" clockwise=\"{cw}\"/>\n", n(end.0), n(end.1), n(f(&e["center"][0])), n(f(&e["center"][1])));+ } else {+ o += &format!(" <PolyStepSegment x=\"{}\" y=\"{}\"/>\n", n(end.0), n(end.1));+ }+ }+ } else {+ let ring: Vec<(f64, f64)> = st["outline"].as_array().cloned().unwrap_or_default().iter().map(pt).collect();+ if let Some(first) = ring.first() {+ o += &format!(" <PolyBegin x=\"{}\" y=\"{}\"/>\n", n(first.0), n(first.1));+ for p in ring.iter().skip(1).chain(std::iter::once(first)) { o += &format!(" <PolyStepSegment x=\"{}\" y=\"{}\"/>\n", n(p.0), n(p.1)); }+ }+ }+ o + " </Polygon>\n"+}++/// A ring of [x, y] as an IPC-2581 Polygon or Cutout (closed).+fn ring_xml(tag: &str, ring: &[(f64, f64)], ind: &str) -> String {+ let mut o = format!("{ind}<{tag}>\n{ind} <PolyBegin x=\"{}\" y=\"{}\"/>\n", n(ring[0].0), n(ring[0].1));+ for p in ring.iter().skip(1).chain(std::iter::once(&ring[0])) { o += &format!("{ind} <PolyStepSegment x=\"{}\" y=\"{}\"/>\n", n(p.0), n(p.1)); }+ o + &format!("{ind}</{tag}>\n")+}++/// The board as IPC-2581C text and what it holds.+pub fn ipc2581_text(st: &Value, input: &Value, title: &str, date: &str) -> (String, Value) {+ let arr = |v: &Value, k: &str| v[k].as_array().cloned().unwrap_or_default();+ let name = { let s: String = ascii(title).chars().map(|c| if c.is_ascii_alphanumeric() || "_.-".contains(c) { c } else { '_' }).collect(); if s.is_empty() { "board".into() } else { s } };+ let copper = crate::placetools::copper_layers(st, input);+ let lname = |i: usize| if i == 0 { "TOP".to_string() } else if i + 1 == copper.len() { "BOTTOM".to_string() } else { format!("INNER{i}") };+ let idx = |l: &str| copper.iter().position(|c| c == l);+ let mut dict = Dict::default();+ let comps = arr(st, "components");+ let pads = arr(st, "pads");+ let vias = arr(st, "vias");+ let stack = crate::input::stack(input);++ // the BOM: one item per pattern (and value, when the model carries one)+ let mut groups: Vec<((String, String), Vec<&Value>)> = Vec::new();+ for c in &comps {+ if !pads.iter().any(|p| p["ref"] == c["ref"]) { continue; }+ let k = (c["pattern"].as_str().unwrap_or("").to_string(), c["value"].as_str().unwrap_or("").to_string());+ match groups.iter_mut().find(|g| g.0 == k) { Some(g) => g.1.push(c), None => groups.push((k, vec![c])) }+ }+ groups.sort_by(|x, y| x.0.cmp(&y.0));+ let part_id = |p: &str, v: &str| { let s = a(&format!("{p}_{v}")); let s = s.trim_matches('_').to_string(); if s.is_empty() { "PART".to_string() } else { s } };+ let mut bom = format!(" <Bom name=\"{name}_BOM\">\n <BomHeader assembly=\"{name}\" revision=\"1\">\n <StepRef name=\"{name}\"/>\n </BomHeader>\n");+ for ((pat, val), cs) in &groups {+ bom += &format!(" <BomItem OEMDesignNumberRef=\"{}\" quantity=\"{}\" category=\"ELECTRICAL\" description=\"{}\">\n", part_id(pat, val), cs.len(), a(val));+ for c in cs {+ bom += &format!(" <RefDes name=\"{}\" packageRef=\"{}\" populate=\"true\" layerRef=\"{}\"/>\n", a(c["ref"].as_str().unwrap_or("")), a(pat), if c["side"] == "bottom" { "BOTTOM" } else { "TOP" });+ }+ bom += " <Characteristics category=\"ELECTRICAL\">\n";+ if !val.is_empty() { bom += &format!(" <Textual definitionSource=\"board model\" textualCharacteristicName=\"Value\" textualCharacteristicValue=\"{}\"/>\n", a(val)); }+ bom += " </Characteristics>\n </BomItem>\n";+ }+ bom += " </Bom>\n";++ // layers and the stack-up+ let mut cad = String::from(" <CadData>\n");+ for (i, _) in copper.iter().enumerate() {+ let side = if i == 0 { "TOP" } else if i + 1 == copper.len() { "BOTTOM" } else { "INTERNAL" };+ cad += &format!(" <Layer name=\"{}\" layerFunction=\"SIGNAL\" side=\"{side}\" polarity=\"POSITIVE\"/>\n", lname(i));+ }+ cad += " <Layer name=\"DRILL_TOP_BOTTOM\" layerFunction=\"DRILL\" side=\"ALL\" polarity=\"POSITIVE\"/>\n";+ let total = stack.as_ref().and_then(|s| s["totalUm"].as_f64()).filter(|t| *t > 0.0).unwrap_or(1600.0) / 1000.0;+ let cu: Vec<f64> = stack.as_ref().map(|s| arr(s, "layers").iter().map(|l| f(&l["copperUm"])).filter(|c| *c > 0.0).collect()).unwrap_or_default();+ cad += &format!(" <Stackup name=\"PRIMARY\" overallThickness=\"{}\" tolPlus=\"0\" tolMinus=\"0\" whereMeasured=\"METAL\" stackupStatus=\"PROPOSED\">\n <StackupGroup name=\"PRIMARY\" thickness=\"{}\" tolPlus=\"0\" tolMinus=\"0\">\n", n(total), n(total));+ for i in 0..copper.len() {+ cad += &format!(" <StackupLayer layerOrGroupRef=\"{}\" thickness=\"{}\" tolPlus=\"0\" tolMinus=\"0\" sequence=\"{}\"/>\n", lname(i), n(cu.get(i).copied().unwrap_or(35.0) / 1000.0), i + 1);+ }+ cad += " </StackupGroup>\n </Stackup>\n";+ let mut step = format!(" <Step name=\"{name}\">\n");++ // via padstacks, one per size and drill+ let mut vstacks: Vec<((String, String), String)> = Vec::new();+ for v in &vias {+ let key = (n(f(&v["size"])), n(f(&v["drill"])));+ if vstacks.iter().any(|x| x.0 == key) { continue; }+ let vn = format!("VIA_{}_{}", key.0, key.1).replace('.', "_");+ let prim = dict.pad("round", f(&v["size"]), f(&v["size"]));+ step += &format!(" <PadStackDef name=\"{vn}\">\n <PadstackHoleDef name=\"H{vn}\" diameter=\"{}\" platingStatus=\"VIA\" plusTol=\"0\" minusTol=\"0\" x=\"0\" y=\"0\"/>\n", key.1);+ for i in 0..copper.len() {+ step += &format!(" <PadstackPadDef layerRef=\"{}\" padUse=\"REGULAR\">\n <Location x=\"0\" y=\"0\"/>\n <StandardPrimitiveRef id=\"{prim}\"/>\n </PadstackPadDef>\n", lname(i));+ }+ step += " </PadStackDef>\n";+ vstacks.push((key, vn));+ }+ step += " <Datum x=\"0\" y=\"0\"/>\n <Profile>\n";+ step += &profile_polygon(st).replace(" <Polygon>", " <Polygon>").replace("\n <Poly", "\n <Poly").replace(" </Polygon>", " </Polygon>");+ step += " </Profile>\n";++ // packages: one per pattern, pins in the footprint's own frame (mirrored back for a bottom part)+ let mut made: Vec<(String, bool)> = Vec::new();+ let mut pkg_xml = String::new();+ for c in &comps {+ let rf = c["ref"].as_str().unwrap_or("");+ let own: Vec<&Value> = pads.iter().filter(|p| p["ref"].as_str() == Some(rf)).collect();+ let pat = { let p = c["pattern"].as_str().unwrap_or(""); if p.is_empty() { format!("PKG_{rf}") } else { p.to_string() } };+ if own.is_empty() || made.iter().any(|m| m.0 == pat) { continue; }+ let (cx, cy, th) = (f(&c["x"]), f(&c["y"]), f(&c["rotation"]).to_radians());+ let bottom = c["side"] == "bottom";+ let th_part = own.iter().any(|p| f(&p["drill"]) > 0.0);+ let mut pins = String::new();+ let mut bb = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ for p in &own {+ 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 (w, h) = (f(&p["sizeX"]), f(&p["sizeY"]));+ let half = w.max(h).max(f(&p["drill"])) / 2.0;+ bb = (bb.0.min(lx - half), bb.1.min(ly - half), bb.2.max(lx + half), bb.3.max(ly + half));+ let prim = dict.pad(p["shape"].as_str().unwrap_or("rect"), w, h);+ pins += &format!(" <Pin number=\"{}\" type=\"{}\">\n", a(p["name"].as_str().unwrap_or("")), if f(&p["drill"]) > 0.0 { "THRU" } else { "SURFACE" });+ if rot.abs() > 1e-6 && (rot - 360.0).abs() > 1e-6 { pins += &format!(" <Xform rotation=\"{}\"/>\n", n(rot)); }+ pins += &format!(" <Location x=\"{}\" y=\"{}\"/>\n <StandardPrimitiveRef id=\"{prim}\"/>\n </Pin>\n", n(lx), n(ly));+ }+ pkg_xml += &format!(" <Package name=\"{}\" type=\"OTHER\" pinOneOrientation=\"OTHER\">\n <Outline>\n{} <LineDesc lineEnd=\"ROUND\" lineWidth=\"0.1\"/>\n </Outline>\n{pins} </Package>\n",+ a(&pat), ring_xml("Polygon", &[(bb.0, bb.1), (bb.2, bb.1), (bb.2, bb.3), (bb.0, bb.3)], " "));+ made.push((pat, th_part));+ }+ step += &pkg_xml;+ for c in &comps {+ let rf = c["ref"].as_str().unwrap_or("");+ let pat = { let p = c["pattern"].as_str().unwrap_or(""); if p.is_empty() { format!("PKG_{rf}") } else { p.to_string() } };+ let Some((_, th_part)) = made.iter().find(|m| m.0 == pat) else { continue };+ if !pads.iter().any(|p| p["ref"].as_str() == Some(rf)) { continue; }+ let bottom = c["side"] == "bottom";+ step += &format!(" <Component refDes=\"{}\" packageRef=\"{}\" part=\"{}\" layerRef=\"{}\" mountType=\"{}\">\n", a(rf), a(&pat),+ part_id(c["pattern"].as_str().unwrap_or(""), c["value"].as_str().unwrap_or("")), if bottom { "BOTTOM" } else { "TOP" }, if *th_part { "THMT" } else { "SMT" });+ let rot = f(&c["rotation"]).rem_euclid(360.0);+ let has_rot = rot.abs() > 1e-6 && (rot - 360.0).abs() > 1e-6;+ if has_rot || bottom {+ step += &format!(" <Xform{}{}/>\n", if has_rot { format!(" rotation=\"{}\"", n(rot)) } else { String::new() }, if bottom { " mirror=\"true\"" } else { "" });+ }+ step += &format!(" <Location x=\"{}\" y=\"{}\"/>\n </Component>\n", n(f(&c["x"])), n(f(&c["y"])));+ }+ // nets with their pins+ let mut nets: Vec<(String, Vec<&Value>)> = Vec::new();+ for p in &pads {+ let (Some(net), Some(r)) = (p["net"].as_str().filter(|s| !s.is_empty()), p["ref"].as_str().filter(|s| !s.is_empty())) else { continue };+ let _ = r;+ match nets.iter_mut().find(|x| x.0 == net) { Some(x) => x.1.push(p), None => nets.push((net.to_string(), vec![p])) }+ }+ nets.sort_by(|x, y| x.0.cmp(&y.0));+ for (net, ps) in &nets {+ step += &format!(" <LogicalNet name=\"{}\">\n", a(net));+ for p in ps { step += &format!(" <PinRef componentRef=\"{}\" pin=\"{}\"/>\n", a(p["ref"].as_str().unwrap_or("")), a(p["name"].as_str().unwrap_or(""))); }+ step += " </LogicalNet>\n";+ }++ // copper, layer by layer: sets of features by net and use+ let zones = crate::input::pours(input);+ let (mut n_pads, mut n_traces, mut n_arcs, mut n_vias, mut n_islands) = (0, 0, 0, 0, 0);+ for (li, layer) in copper.iter().enumerate() {+ let mut sets: Vec<((String, &str), String)> = Vec::new();+ let mut put = |net: &str, kind: &'static str, body: String| {+ match sets.iter_mut().find(|s| s.0 .0 == net && s.0 .1 == kind) { Some(s) => s.1 += &body, None => sets.push(((net.to_string(), kind), body)) }+ };+ for p in &pads {+ let on = p["layer"].as_str() == Some(layer.as_str()) || p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0;+ if !on { continue; }+ let rot = f(&p["rotation"]).rem_euclid(360.0);+ let prim = dict.pad(p["shape"].as_str().unwrap_or("rect"), f(&p["sizeX"]), f(&p["sizeY"]));+ let mut b = String::from(" <Pad>\n");+ if rot.abs() > 1e-6 && (rot - 360.0).abs() > 1e-6 { b += &format!(" <Xform rotation=\"{}\"/>\n", n(rot)); }+ b += &format!(" <Location x=\"{}\" y=\"{}\"/>\n <StandardPrimitiveRef id=\"{prim}\"/>\n", n(f(&p["x"])), n(f(&p["y"])));+ if let Some(r) = p["ref"].as_str().filter(|s| !s.is_empty()) { b += &format!(" <PinRef componentRef=\"{}\" pin=\"{}\"/>\n", a(r), a(p["name"].as_str().unwrap_or(""))); }+ b += " </Pad>\n";+ put(p["net"].as_str().unwrap_or(""), "pad", b);+ n_pads += 1;+ }+ for v in &vias {+ let ls: Vec<usize> = v["layers"].as_array().map(|x| x.iter().filter_map(|l| l.as_str().and_then(idx)).collect()).unwrap_or_default();+ let (lo, hi) = if ls.len() == 2 { (ls[0].min(ls[1]), ls[0].max(ls[1])) } else { (0, copper.len() - 1) };+ if li < lo || li > hi { continue; }+ let key = (n(f(&v["size"])), n(f(&v["drill"])));+ let vn = &vstacks.iter().find(|x| x.0 == key).unwrap().1;+ let prim = dict.pad("round", f(&v["size"]), f(&v["size"]));+ put(v["net"].as_str().unwrap_or(""), "via", format!(" <Pad padstackDefRef=\"{vn}\">\n <Location x=\"{}\" y=\"{}\"/>\n <StandardPrimitiveRef id=\"{prim}\"/>\n </Pad>\n", n(f(&v["position"]["x"])), n(f(&v["position"]["y"]))));+ n_vias += 1;+ }+ for t in arr(st, "tracks").iter().filter(|t| t["layer"].as_str() == Some(layer.as_str())) {+ put(t["net"].as_str().unwrap_or(""), "trace", format!(" <Features>\n <Line startX=\"{}\" startY=\"{}\" endX=\"{}\" endY=\"{}\">\n <LineDesc lineEnd=\"ROUND\" lineWidth=\"{}\"/>\n </Line>\n </Features>\n",+ n(f(&t["start"]["x"])), n(f(&t["start"]["y"])), n(f(&t["end"]["x"])), n(f(&t["end"]["y"])), n(f(&t["width"]))));+ n_traces += 1;+ }+ for r in arr(st, "arcs").iter().filter(|r| r["layer"].as_str() == Some(layer.as_str())) {+ let (cx, cy, rad) = (f(&r["center"]["x"]), f(&r["center"]["y"]), f(&r["radius"]));+ let (s0, s1) = (f(&r["startAngle"]).to_radians(), f(&r["endAngle"]).to_radians());+ put(r["net"].as_str().unwrap_or(""), "trace", format!(" <Features>\n <Arc startX=\"{}\" startY=\"{}\" endX=\"{}\" endY=\"{}\" centerX=\"{}\" centerY=\"{}\" clockwise=\"false\">\n <LineDesc lineEnd=\"ROUND\" lineWidth=\"{}\"/>\n </Arc>\n </Features>\n",+ n(cx + rad * s0.cos()), n(cy + rad * s0.sin()), n(cx + rad * s1.cos()), n(cy + rad * s1.sin()), n(cx), n(cy), n(f(&r["width"]))));+ n_arcs += 1;+ }+ for z in zones.iter().filter(|z| z["layer"].as_str() == Some(layer.as_str())) {+ for fill in arr(z, "fills") {+ let ring = |v: &Value| -> Vec<(f64, f64)> { v.as_array().cloned().unwrap_or_default().iter().map(|q| (f(&q[0]), f(&q[1]))).collect() };+ let outer = ring(&fill["outline"]);+ if outer.len() < 3 { continue; }+ let mut b = String::from(" <Features>\n <Contour>\n");+ b += &ring_xml("Polygon", &outer, " ");+ for h in arr(&fill, "holes") { let h = ring(&h); if h.len() >= 3 { b += &ring_xml("Cutout", &h, " "); } }+ b += " </Contour>\n </Features>\n";+ put(z["net"].as_str().unwrap_or(""), "pour", b);+ n_islands += 1;+ }+ }+ step += &format!(" <LayerFeature layerRef=\"{}\">\n", lname(li));+ for ((net, kind), body) in &sets {+ let mut attrs = if net.is_empty() { String::new() } else { format!(" net=\"{}\"", a(net)) };+ if *kind == "pad" { attrs += " padUsage=\"TERMINATION\""; } else if *kind == "via" { attrs += " padUsage=\"VIA\""; }+ step += &format!(" <Set{attrs}>\n{body} </Set>\n");+ }+ step += " </LayerFeature>\n";+ }+ // the drills: plated pad holes (a hole with no copper ring round it is non-plated), vias+ let mut holes: Vec<(String, f64, f64, f64, &str)> = pads.iter().filter(|p| f(&p["drill"]) > 0.0)+ .map(|p| (p["net"].as_str().unwrap_or("").to_string(), f(&p["x"]), f(&p["y"]), f(&p["drill"]), if f(&p["sizeX"]).min(f(&p["sizeY"])) <= f(&p["drill"]) + 1e-6 { "NONPLATED" } else { "PLATED" })).collect();+ holes.extend(vias.iter().map(|v| (v["net"].as_str().unwrap_or("").to_string(), f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["drill"]), "VIA")));+ step += " <LayerFeature layerRef=\"DRILL_TOP_BOTTOM\">\n";+ let mut hnets: Vec<&String> = holes.iter().map(|h| &h.0).collect();+ hnets.sort();+ hnets.dedup();+ let mut k = 0;+ for net in hnets {+ step += &format!(" <Set{}>\n", if net.is_empty() { String::new() } else { format!(" net=\"{}\"", a(net)) });+ for h in holes.iter().filter(|h| &h.0 == net) {+ k += 1;+ step += &format!(" <Hole name=\"H{k}\" diameter=\"{}\" platingStatus=\"{}\" plusTol=\"0\" minusTol=\"0\" x=\"{}\" y=\"{}\"/>\n", n(h.3), h.4, n(h.1), n(h.2));+ }+ step += " </Set>\n";+ }+ step += " </LayerFeature>\n </Step>\n";+ cad += &step;+ cad += " </CadData>\n";++ let soft = format!("adom-eda-checks {}", env!("CARGO_PKG_VERSION"));+ let mut o = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<IPC-2581 revision=\"C\" xmlns=\"http://webstds.ipc.org/2581\">\n <Content roleRef=\"Owner\">\n <FunctionMode mode=\"ASSEMBLY\"/>\n");+ o += &format!(" <StepRef name=\"{name}\"/>\n");+ for i in 0..copper.len() { o += &format!(" <LayerRef name=\"{}\"/>\n", lname(i)); }+ o += " <LayerRef name=\"DRILL_TOP_BOTTOM\"/>\n";+ o += &format!(" <BomRef name=\"{name}_BOM\"/>\n");+ o += &dict.xml();+ o += " </Content>\n <LogisticHeader>\n <Role id=\"Owner\" roleFunction=\"SENDER\"/>\n <Enterprise id=\"Adom\" code=\"NONE\"/>\n <Person name=\"adom-eda-checks\" enterpriseRef=\"Adom\" roleRef=\"Owner\"/>\n </LogisticHeader>\n";+ o += &format!(" <HistoryRecord number=\"1\" origination=\"{date}\" software=\"{soft}\" lastChange=\"{date}\">\n <FileRevision fileRevisionId=\"1\" comment=\"from the board model\">\n <SoftwarePackage name=\"adom-eda-checks\" revision=\"{}\" vendor=\"Adom\">\n <Certification certificationStatus=\"SELFTEST\"/>\n </SoftwarePackage>\n </FileRevision>\n </HistoryRecord>\n", env!("CARGO_PKG_VERSION"));+ o += &bom;+ o += " <Ecad name=\"";+ o += &name;+ o += "\">\n <CadHeader units=\"MILLIMETER\"/>\n";+ o += &cad;+ o += " </Ecad>\n</IPC-2581>\n";+ let counts = json!({"layers": copper.len() + 1, "packages": made.len(), "components": comps.iter().filter(|c| pads.iter().any(|p| p["ref"] == c["ref"])).count(), "nets": nets.len(),+ "pads": n_pads, "traces": n_traces, "arcs": n_arcs, "vias": n_vias, "pourIslands": n_islands, "holes": k});+ (o, counts)+}++/// The current UTC time as an xsd:dateTime, for the history record when the caller gives no `date`.+fn now() -> String {+ let s = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);+ let (days, rem) = (s.div_euclid(86400), s.rem_euclid(86400));+ // civil from days (Howard Hinnant)+ let z = days + 719468;+ let era = z.div_euclid(146097);+ let doe = z - era * 146097;+ let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);+ let mp = (5 * doy + 2) / 153;+ let d = doy - (153 * mp + 2) / 5 + 1;+ let m = if mp < 10 { mp + 3 } else { mp - 9 };+ let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };+ format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)+}++/// writers::ipc2581: `{board, stack?, pours?, args: {title?, date?}}` -> the IPC-2581C text and what it holds.+pub fn export_ipc2581(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["title"].as_str().unwrap_or("board");+ let date = args["date"].as_str().map(String::from).unwrap_or_else(now);+ let (text, counts) = ipc2581_text(&board, input, title, &date);+ let poured = !crate::input::pours(input).is_empty();+ json!({"success": true, "text": text, "counts": counts, "format": "IPC-2581 revision C", "mode": "ASSEMBLY", "units": "mm", "writtenFrom": "board model",+ "_hint": if poured { "IPC-2581C written from the board model (valid against the IPC-2581C schema): layers and stack-up, outline, packages and components, BOM, nets, and on each copper layer the pads, traces, arcs, vias and the poured copper island by island, plus the drills. Solder mask, paste and silkscreen are not in it: a fab order goes through the tool's Gerbers or ODB++." }+ else { "IPC-2581C written from the board model, without pours: pass pour_state as pours to add the poured copper island by island. Solder mask, paste and silkscreen are not in it: a fab order goes through the tool's Gerbers or ODB++." }})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn board() -> Value {+ json!({"outlineEdges": [{"kind": "line", "start": [0.0, 0.0]}, {"kind": "line", "start": [30.0, 0.0]}, {"kind": "arc", "start": [30.0, 20.0], "end": [0.0, 20.0], "center": [15.0, 20.0], "radius": 15.0, "angle1": 0.0, "angle2": 180.0}, {"kind": "line", "start": [0.0, 20.0]}],+ "components": [{"ref": "U1", "x": 10.0, "y": 10.0, "rotation": 90.0, "side": "top", "pattern": "SOT23", "value": "LDO 3.3V"}, {"ref": "R1", "x": 20.0, "y": 5.0, "rotation": 0.0, "side": "bottom", "pattern": "0603", "value": "10kΩ"},+ {"ref": "LOGO", "x": 2.0, "y": 2.0, "rotation": 0.0, "side": "top", "pattern": "LOGO"}],+ "pads": [{"ref": "U1", "name": "1", "x": 9.05, "y": 8.9, "sizeX": 0.6, "sizeY": 0.7, "rotation": 90.0, "shape": "rect", "layer": "F.Cu", "drill": 0.0, "net": "VIN"},+ {"ref": "U1", "name": "2", "x": 10.95, "y": 8.9, "sizeX": 0.6, "sizeY": 0.7, "rotation": 90.0, "shape": "rect", "layer": "F.Cu", "drill": 0.0, "net": "GND"},+ {"ref": "U1", "name": "3", "x": 10.0, "y": 11.1, "sizeX": 0.6, "sizeY": 0.7, "rotation": 90.0, "shape": "rect", "layer": "F.Cu", "drill": 0.0, "net": "VOUT"},+ {"ref": "R1", "name": "1", "x": 19.2, "y": 5.0, "sizeX": 0.8, "sizeY": 0.9, "rotation": 0.0, "shape": "rect", "layer": "B.Cu", "drill": 0.0, "net": "VOUT"},+ {"ref": "R1", "name": "2", "x": 20.8, "y": 5.0, "sizeX": 0.8, "sizeY": 0.9, "rotation": 0.0, "shape": "rect", "layer": "B.Cu", "drill": 0.0, "net": "GND"},+ {"ref": "", "name": "", "x": 3.0, "y": 17.0, "sizeX": 3.2, "sizeY": 3.2, "rotation": 0.0, "shape": "round", "layer": "*.Cu", "drill": 3.2, "net": ""}],+ "tracks": [{"layer": "F.Cu", "width": 0.25, "net": "VOUT", "start": {"x": 10.0, "y": 11.1}, "end": {"x": 15.0, "y": 11.1}}],+ "arcs": [{"layer": "B.Cu", "width": 0.25, "net": "VOUT", "center": {"x": 17.0, "y": 8.0}, "radius": 2.0, "startAngle": 0.0, "endAngle": 90.0}],+ "vias": [{"position": {"x": 15.0, "y": 11.1}, "size": 0.6, "drill": 0.3, "layers": ["F.Cu", "B.Cu"], "net": "VOUT"}]})+ }++ #[test]+ fn the_board_comes_out_as_ipc_2581c() {+ let pours = json!({"zones": [{"net": "GND", "layer": "B.Cu", "fills": [{"outline": [[1.0, 1.0], [29.0, 1.0], [29.0, 9.0], [1.0, 9.0]], "holes": [[[18.5, 4.0], [21.5, 4.0], [21.5, 6.0], [18.5, 6.0]]]}]}]});+ let r = export_ipc2581(&json!({"board": board(), "pours": pours, "args": {"title": "Test Board", "date": "2026-10-29T00:00:00Z"}}));+ let t = r["text"].as_str().unwrap();+ assert!(t.starts_with("<?xml") && t.contains("<IPC-2581 revision=\"C\""));+ assert_eq!(r["counts"], json!({"layers": 3, "packages": 2, "components": 2, "nets": 3, "pads": 7, "traces": 1, "arcs": 1, "vias": 2, "pourIslands": 1, "holes": 2}), "{}", r["counts"]);+ // U1's pin 1 back in the footprint's frame: (9.05, 8.9) less (10, 10), turned back 90 degrees -> (-1.1, 0.95)+ assert!(t.contains("<Pin number=\"1\" type=\"SURFACE\">\n <Location x=\"-1.1\" y=\"0.95\"/>"), "{t}");+ // the bottom resistor is mirrored, the mounting hole has no ring so it is non-plated, the ohm sign is spelled out+ assert!(t.contains("refDes=\"R1\" packageRef=\"0603\" part=\"0603_10kohm\" layerRef=\"BOTTOM\" mountType=\"SMT\">\n <Xform mirror=\"true\"/>"));+ assert!(t.contains("platingStatus=\"NONPLATED\"") && t.contains("platingStatus=\"VIA\""));+ assert!(t.contains("<PolyStepCurve x=\"0\" y=\"20\" centerX=\"15\" centerY=\"20\" clockwise=\"false\"/>"), "{t}");+ assert!(t.contains("<Cutout>") && t.contains("<Oval") == false && t.contains("<Circle diameter=\"3.2\"/>"));+ assert!(!t.contains("LOGO"), "a component with no pads is no part");+ assert_eq!(n(0.10000001), "0.1");+ assert_eq!(n(-0.00001), "0");+ assert_eq!(ascii("10kΩ ±5%"), "10kohm +/-5%");+ }++ #[test]+ fn the_history_date_is_an_xsd_date_time() {+ let d = now();+ assert_eq!(d.len(), 20);+ assert!(d.starts_with("20") && d.ends_with('Z') && &d[10..11] == "T", "{d}");+ }+}
rust/vendor/adom-eda-checks/src/writers/mod.rs+5−3@@ -1,5 +1,5 @@-//! Board-file writers for a bridge whose tool has no native exporter (EDA Bridge Standard #16): GenCAD and DXF from the-//! board model, SVG artwork as regions for a layer, and a part's 3D body as STEP from its package dimensions (#20). Like the checks, no I/O and no tool: each returns the file text+//! Board-file writers for a bridge whose tool has no native exporter (EDA Bridge Standard #16): GenCAD, DXF and IPC-2581C+//! from the board model, SVG artwork as regions for a layer, and a part's 3D body as STEP from its package dimensions (#20). 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;@@ -7,10 +7,11 @@ use serde_json::Value; pub mod body3d; pub mod dxf; pub mod gencad;+pub mod ipc2581; pub mod svgart; /// The verbs these writers answer, by standard name.-pub const WRITERS: &[&str] = &["export_dxf", "export_gencad", "generate_body", "import_svg"];+pub const WRITERS: &[&str] = &["export_dxf", "export_gencad", "export_ipc2581", "generate_body", "import_svg"]; pub use dxf::dxf_layers; pub use gencad::gencad;@@ -21,6 +22,7 @@ pub fn run(verb: &str, input: &Value) -> Option<Value> { Some(match verb { "export_dxf" => dxf_layers(input), "export_gencad" => gencad(input),+ "export_ipc2581" => ipc2581::export_ipc2581(input), "generate_body" => body3d::generate_body(input), "import_svg" => svg_to_regions(input), _ => return None,
skills/kicad-bridge-test/run_verb_tests.py+9−1@@ -390,7 +390,7 @@ def matrix(w: dict) -> dict: "routing_state", "board_pads", "routing_validate", "route_net", "remove_route", "add_zone", "zone_state", "remove_zone", "apply_zone_plan", "select_net", "select_clear", "text_bounds", "silk_text_batch", "silk_graphics_batch", "silk_fields_batch",- "menu_items", "3d_view", "refresh_board_views", "set_layer_visibility")},+ "menu_items", "3d_view", "refresh_board_views", "set_layer_visibility", "zoom_to")}, # ---- phase 6.6: offline copper editing on a second copy that no editor has open **{verb: dict(phase=6.6, live=True) for verb in ("add_track", "add_via", "route")}, "install_library_bundle": dict(phase=4, args={"__special": "install_bundle"}, timeout=200,@@ -963,6 +963,14 @@ def main(): # view state only: hide the front silkscreen, read it back, show it again off = step("set_layer_visibility", {"layer": "F.SilkS", "displayed": False}, chain=False) on = step("set_layer_visibility", {"layer": "F.SilkS", "displayed": True}, chain=False)+ # the view framed on a part, a net and the board through KiCad's own action over the API (wiki #121), and the+ # API answers right after (the menu route left an interactive tool running: AS_BUSY)+ z1 = step("zoom_to", {"filePath": L, "refs": ["U5"]}, chain=False)+ z2 = step("zoom_to", {"filePath": L, "net": (st.get("netsRemaining") or ["GND"])[0]}, chain=False)+ z3 = step("zoom_to", {"filePath": L}, chain=False)+ after = ab(a.target, "kicad_routing_state", {"filePath": L}, timeout=150)+ record("zoom_to", {"part": z1, "net": z2, "board": z3}, all(ok(z) and z.get("actionStatus") == "Ok" for z in (z1, z2, z3)) and ok(after),+ "the view was not framed on a part, a net and the board, or the API was left busy") record("set_layer_visibility", {"off": off, "on": on}, ok(off) and (off.get("data") or {}).get("displayed") is False and ok(on) and (on.get("data") or {}).get("displayed") is True, "F.SilkS was not hidden and shown again")