← Commit history

standard 0.14.0: engine 0.11.0 adds the IPC-2581C writer; simulate and export_spice proposed (#23)

Colby Knox ·2421e66b3b ·12d ago ·parent aeddf5e
10 files changed +525−16
README.md+8−2
@@ -63,9 +63,9 @@ verb by verb: [docs/coverage.md](docs/coverage.md). | **core** | 93 | 93 | 90 | 79 | | **native** | 46 | 46 | 43 | 33 | | **checks** | 37 | 33 | 35 | 19 |-| **app** | 60 | 60 | 36 | 37 |+| **app** | 62 | 60 | 36 | 37 | | **platform** | 19 | 19 | 0 | 0 |-| standard (all but platform) | 236 | 232 | 204 | 168 |+| standard (all but platform) | 238 | 232 | 204 | 168 | <!-- coverage:end -->  The **checks** tier is where the other bridges are furthest behind, and it is the cheapest to close: those verbs are@@ -158,6 +158,12 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue  ## Status +Version 0.14.0:+- The engine (0.11.0) writes IPC-2581 revision C (`export_ipc2581`, from the Fusion bridge's writer, #16). The Bluetooth+  Sentinel, Altium's lane board and a board with an oval pad, an arc outline and a pour with a hole all validate against+  the IPC-2581C schema.+- The standard carries kicad-lane's `simulate` and `export_spice` proposals (#23).+ Version 0.13.4: the engine (0.10.4)'s `plan_route` (fusion-lane #21): - A pour holding the route's own end pad is exempt from `avoidPours`. - `planner` echoes the pour settings.
docs/coverage.md+5−3
@@ -1,6 +1,6 @@ # Coverage: every standard verb on each bridge -Altium bridge 0.2.79 (the reference), KiCad bridge 1.0.77, Fusion 360 bridge 1.13.7. 54 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*.+Altium bridge 0.2.79 (the reference), KiCad bridge 1.0.77, Fusion 360 bridge 1.13.7. 56 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*. `exact` is the same job under the same or an equivalent name; `partial` does part of it or does it differently (`verbs/coverage.json` names what in `note`); `unsupported` answers `not_supported` for the whole job and is not counted; blank is missing. Generated by `tools/generate.py` from `verbs/coverage.json`; do not edit by hand. @@ -9,9 +9,9 @@ Generated by `tools/generate.py` from `verbs/coverage.json`; do not edit by hand | **core** | 93 | 93 | 90 | 79 | | **native** | 46 | 46 | 43 | 33 | | **checks** | 37 | 33 | 35 | 19 |-| **app** | 60 | 60 | 36 | 37 |+| **app** | 62 | 60 | 36 | 37 | | **platform** | 19 | 19 | 0 | 0 |-| standard (all but platform) | 236 | 232 | 204 | 168 |+| standard (all but platform) | 238 | 232 | 204 | 168 |  ## core @@ -236,6 +236,7 @@ Driving the application itself (launch, licence, dialogs, windows, views). Each | `document_info` *proposed* | The active document: its name, what kind it is, whether it has unsaved changes, where it is saved and which version it is. Read only; opens nothing. | `altium_document_info` | `kicad_state` (partial) | `fusion_document_info` | | `ensure_licensed` | One call to get a usable Altium: launch if needed, then claim a seat (Use License via UIA). Optional escalations: sign out to force a seat release, or start... | `altium_ensure_licensed` |  |  | | `eval` | Run a DelphiScript snippet inside Altium (optional Var block, then Begin ... End;) on the CURRENT document, or on filePath opened first. | `altium_eval` |  |  |+| `export_spice` *proposed* | Write the design's SPICE netlist with the tool's own exporter, and say what it holds. |  |  |  | | `extension_ping` | Does the AdomBridge .NET extension answer? Sends AdomBridge:Ping through the -R lane and waits up to 30 s for its result file. | `altium_extension_ping` |  |  | | `foreground` | Bring Altium in front of the user - the sanctioned exception to the never-foreground rule. | `altium_foreground` |  |  | | `install` | Install Altium Designer on this machine, STAGED, with the rules in the verb (from 0.2.29). stage: preflight / get_installer / launch / wait / license, or... | `altium_install` | `kicad_upgrade` (partial) | `fusion_install_fusion` |@@ -271,6 +272,7 @@ Driving the application itself (launch, licence, dialogs, windows, views). Each | `show_symbol` | Open a SchLib member in the Symbol editor and capture a background proof. | `altium_show_symbol` | `kicad_show_symbol` | `fusion_show_symbol` | | `signin` | Report-only alias of altium_signin_status: it does NOT sign anyone in. Signing in to Altium is a browser flow only the human can complete. | `altium_signin` |  | `fusion_signin` | | `signin_status` | Account and Altium 365 workspace connection as Altium's own UI reports it: licence state from the title, and whether the workspace menu reads 'Not Connected'. | `altium_signin_status` |  | `fusion_signin_status` |+| `simulate` *proposed* | Run a circuit simulation of the design's schematic and return the signals as data: an operating point, a DC sweep, an AC sweep or a transient, with the nets... |  |  |  | | `state` | The whole picture in one call: licence state, windows, dialogs, a screenshot of every main/dialog window, and the agent's logical state (focused document,... | `altium_state` | `kicad_state` | `fusion_state` | | `status` | Is Altium running, licensed, and is the resident agent alive? | `altium_status` | `kicad_status` | `fusion_status` | | `uninstall` | Remove the bridge's own cache directory (agent workspaces, rendered agent scripts, the Altium 365 token file). Altium Designer and the user's designs are... | `altium_uninstall` | `kicad_uninstall` | `fusion_uninstall` |
engine/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-eda-checks"-version = "0.10.4"+version = "0.11.0" dependencies = [  "roxmltree",  "serde_json",
engine/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"
engine/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 
engine/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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;") }++/// 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}");+    }+}
engine/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,
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "eda-bridge-standard",-  "version": "0.13.4",+  "version": "0.14.0",   "type": "skill",   "title": "EDA Bridge Standard: one way for an AI to drive every PCB tool",   "description": "The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.",
verbs/coverage.json+18−2
@@ -1,10 +1,10 @@ {- "standard": "0.13.4",+ "standard": "0.14.0",  "reference": {   "bridge": "altium",   "version": "0.2.79"  },- "proposed": 54,+ "proposed": 56,  "compared": {   "kicad": "1.0.77",   "fusion360": "1.13.7"@@ -3161,6 +3161,14 @@    "kicad": null,    "fusion360": null   },+  {+   "name": "export_spice",+   "tier": "app",+   "source": "proposed",+   "altium": null,+   "kicad": null,+   "fusion360": null+  },   {    "name": "extension_ping",    "tier": "app",@@ -3683,6 +3691,14 @@     "match": "exact"    }   },+  {+   "name": "simulate",+   "tier": "app",+   "source": "proposed",+   "altium": null,+   "kicad": null,+   "fusion360": null+  },   {    "name": "state",    "tier": "app",
verbs/standard.json+86−2
@@ -1,10 +1,10 @@ {- "standard": "0.13.4",+ "standard": "0.14.0",  "reference": {   "bridge": "altium",   "version": "0.2.79"  },- "proposed": 54,+ "proposed": 56,  "compared": {   "kicad": "1.0.77",   "fusion360": "1.13.7"@@ -7550,6 +7550,40 @@    "longRunning": false,    "timeoutSeconds": 30   },+  {+   "name": "export_spice",+   "tier": "app",+   "source": "proposed",+   "summary": "Write the design's SPICE netlist with the tool's own exporter, and say what it holds.",+   "input": {+    "schematic": "the design or sheet (required)",+    "outputPath": "path for the netlist (default beside the design)"+   },+   "output": {+    "path": "the netlist file",+    "dialect": "ngspice | pspice | ltspice | xspice",+    "elements": "int: parts in the netlist",+    "analyses": "[the analysis lines the design carries]",+    "includes": "[the model files it includes]"+   },+   "hint": "A part without a simulation model is left out or becomes a comment: give it one before simulating.",+   "pitfalls": [+    "Model files are included by the path the part's model field gives: keep them where every machine finds them."+   ],+   "related": [+    "simulate"+   ],+   "example": {+    "schematic": "C:/proj/filter.kicad_sch"+   },+   "longRunning": false,+   "timeoutSeconds": null,+   "proposedBy": "kicad-lane",+   "since": "0.14.0",+   "agreedBy": [+    "standard-lane"+   ]+  },   {    "name": "extension_ping",    "tier": "app",@@ -8642,6 +8676,56 @@    "longRunning": false,    "timeoutSeconds": 60   },+  {+   "name": "simulate",+   "tier": "app",+   "source": "proposed",+   "summary": "Run a circuit simulation of the design's schematic and return the signals as data: an operating point, a DC sweep, an AC sweep or a transient, with the nets and source currents asked for.",+   "input": {+    "schematic": "the design or sheet to simulate (the bridge's own path or id) (required)",+    "analysis": "{type: op} | {type: dc, source, start, stop, step} | {type: ac, sweep: dec|oct|lin, points, start, stop} (Hz) | {type: tran, step, stop, start?} (s) | {type: raw, text: \".ac dec 20 10 1meg\"} (a bridge that cannot run raw SPICE refuses it with not_supported); default the analysis the design carries",+    "probes": "[{net: \"OUT\"} | {current: \"V1\"}]; default every net (a bridge may cap the count)",+    "points": "int, default 200: a ceiling on the points returned per signal; decimation keeps the first and last points and every extremum (the run itself is not coarsened)"+   },+   "output": {+    "signals": "[{signal: \"V(OUT)\", x: [..], y: [..], xUnit: s|Hz|V, yUnit: V|A|dB, phase?: [..] (AC, degrees, unwrapped)}]; an op gives one point per signal",+    "missing": "[probe]: probes the run did not have",+    "available": "[signal name]",+    "engine": "the simulator and its version, e.g. \"ngspice 46 (KiCad 10.0.7)\""+   },+   "hint": "AC signals are the magnitude in dB with phase in degrees beside it. Every part needs a simulation model: passives work from their values, sources come from the tool's simulation library, ICs need a model file.",+   "pitfalls": [+    "simulation_failed carries the simulator's own message: a part without a model, a node with no DC path to ground, or a bad analysis.",+    "points thins the reply, not the run: a long transient with a tiny step still takes its time."+   ],+   "related": [+    "export_spice",+    "run_erc",+    "sch_place_part"+   ],+   "example": {+    "schematic": "C:/proj/filter.kicad_sch",+    "analysis": {+     "type": "ac",+     "sweep": "dec",+     "points": 20,+     "start": 10,+     "stop": 1000000+    },+    "probes": [+     {+      "net": "OUT"+     }+    ]+   },+   "longRunning": false,+   "timeoutSeconds": null,+   "proposedBy": "kicad-lane",+   "since": "0.14.0",+   "agreedBy": [+    "standard-lane"+   ]+  },   {    "name": "state",    "tier": "app",