← All Pull Requests

kicad-lane: 1.0.68, BOM, JLCPCB assembly and order, land-pattern footprints, designators #29

Merged opened by Colby Knox 2026-10-28

From kicad-lane. KiCad bridge 1.0.68.

  • kicad_bom: the schematic's parts merged with the board, one line per MPN, checked for what an order lacks.
    • Writes bom.csv, JLCPCB's BOM and CPL, the Mouser and DigiKey BOM-tool files and bom.json.
    • The grouping and the field names matched across libraries are the Altium bridge's, so the files read the same in both tools.
  • kicad_assembly_check: the board against JLCPCB's Economic and Standard assembly tiers, and the cheapest one it passes. Parts the line never places (excluded from BOM or position files, DNP, board-only) are left out.
  • kicad_jlcpcb_order: in one folder:
    • the fab check;
    • the Gerbers and drill, zipped;
    • the BOM and CPL;
    • the assembly tiers;
    • the order-form values;
    • every issue in one list, with ready.
  • kicad_footprint_from_land_pattern: a footprint from a datasheet's land pattern (chip, SOIC/DFN, SOT, QFP/QFN, BGA, exposed pad), with silkscreen clear of copper, pin-1 marks, the courtyard and a 3D body box.
  • kicad_silk_designators: every reference designator placed upright beside its part, clear of pads, bodies, existing silkscreen and the other labels, never below the board's minimum text height.
    • A closed board gets the labels written into the file. KiCad's DRC checks the result first, and a label it flags is reported, not written.
    • A board open in KiCad is moved live as one Undo step, unless its footprints carry KiCad 10's units block: then it refuses, nothing changed (see below).
  • Fixes:
    • kicad_silk_fields_batch no longer drops footprints' units blocks. KiCad 10.0.x moves a field only by sending its whole footprint back, and loses the footprint's units block (pin groups) doing so. Such a move now refuses with field_move_drops_units, nothing changed, unless allowUnitsDrop:true.
    • kicad_silk_state gives a part with a round courtyard its real body box, not a flat line.
    • The netlist reader keeps symbol fields and DNP flags for the BOM.

Where the code is:

  • kicad_core::order (new): BOM grouping, files and JLCPCB's assembly tiers, ported from altium-bridge bom.rs/assembly.rs 0.2.77. The netlist parser's Component now keeps fields and properties; both are serde-skipped, so the netlist verbs' replies don't change.
  • kicad_core::land_pattern (new): altium-bridge footprint.rs's pads and layout, unchanged, plus fp_create_args (KiCad's frame, y down) and vrml_box. fpedit::create takes optional explicit graphics and a courtyardRect.
  • board_checks::silk_designators: the label planner. fp_text::set_field_pose writes a field into a closed board.
  • ipc::silk_fields: the units guard (field_move_drops_units).
  • board_reads: a round courtyard's real extent (body box only).
  • The bridge's new modules are verbs_order.rs and verbs_land_pattern.rs; verbs_checks_native.rs gains kicad_silk_designators. The composites are Mechanism::File.

Tests:

  • cargo test: kicad-bridge 97 pass. kicad-core passes except the 7 known schematic_edit tests, whose fixture tour-pack/template/tour-demo.kicad_sch was never pushed.
  • Full verb battery on DESKTOP-BAR6S0F (KiCad 10.0.7): 289 pass, 0 fail, 1 skip; coverage complete.
  • By hand on the laptop:
    • the pic_programmer BOM and JLCPCB order;
    • SOIC-8 and QFN-16+EP land patterns;
    • 43 designators written into a closed ESC board: 0 new DRC errors, all 149 units blocks kept, checked in a render;
    • the live refusals (live_move_drops_units, field_move_drops_units) change nothing.

🤖 Generated with Claude Code

Diff Skip to comments

--- a/SKILL.md+++ b/SKILL.md@@ -1,532 +1,534 @@⋯ 170 unchanged lines ⋯ Return paths, copper balance, impedance widths, current capacity, net lengths and pair skew, 3D heights, part orientation, free space, symbols against footprints and net classes from names, each from the file or live; thermal vias and via fences placed after a DRC check; and `kicad_board_report`, one HTML page of every check with a picture.+From 1.0.68: designators placed clear of the pads, the BOM, JLCPCB's assembly tiers and a JLCPCB order in one call. All of it is the `kicad-board-checks` skill in this package.  ## Live routing (from 0.9.340)⋯ 353 unchanged lines ⋯ - Never quote a wiki page you have not fetched: `aravk/in-s42ata`, an older example, no longer exists.  - **kicad-bridge-contributing**: you hit a bug or a missing verb: file it with the verbatim reply, disclose any workaround, and how to clone this page's repo, build, test and send a branch PR that gets merged within minutes.+--- a/bridge.json+++ b/bridge.json@@ -1,394 +1,400 @@⋯ 288 unchanged lines ⋯     "kicad_auto_classes",     "kicad_pin_pad_check",     "kicad_via_fence",-    "kicad_board_report"+    "kicad_board_report",+    "kicad_bom",+    "kicad_assembly_check",+    "kicad_jlcpcb_order",+    "kicad_footprint_from_land_pattern",+    "kicad_silk_designators"   ],   "statusVerb": "kicad_status",   "timeouts": {⋯ 96 unchanged lines ⋯   "releasedAt": "2026-06-26T16:00:00Z",   "uninstall": "kicad_uninstall" }+--- a/rust/Cargo.lock+++ b/rust/Cargo.lock@@ -1,1305 +1,1306 @@⋯ 316 unchanged lines ⋯  [[package]] name = "kicad-bridge"-version = "1.0.67"+version = "1.0.68" dependencies = [  "adom-eda-checks",  "kicad-core",⋯ 5 unchanged lines ⋯  [[package]] name = "kicad-core"-version = "1.0.67"+version = "1.0.68" dependencies = [  "kicad-ipc-rs",  "kicad-platform",⋯ 20 unchanged lines ⋯  [[package]] name = "kicad-platform"-version = "1.0.67"+version = "1.0.68" dependencies = [  "serde",  "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+--- a/rust/Cargo.toml+++ b/rust/Cargo.toml@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"]  [workspace.package]-version = "1.0.67"+version = "1.0.68" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+--- a/rust/crates/kicad-bridge/src/groups.rs+++ b/rust/crates/kicad-bridge/src/groups.rs@@ -1,54 +1,57 @@⋯ 41 unchanged lines ⋯     Group { name: "checks", verbs: || crate::verbs_checks::VERBS, dispatch: crate::verbs_checks::dispatch },     Group { name: "checks_native", verbs: || crate::verbs_checks_native::VERBS, dispatch: crate::verbs_checks_native::dispatch },     Group { name: "report", verbs: || crate::verbs_report::VERBS, dispatch: crate::verbs_report::dispatch },+    Group { name: "order", verbs: || crate::verbs_order::VERBS, dispatch: crate::verbs_order::dispatch },+    Group { name: "land_pattern", verbs: || crate::verbs_land_pattern::VERBS, dispatch: crate::verbs_land_pattern::dispatch }, ];  pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {⋯ 4 unchanged lines ⋯     }     None }+--- a/rust/crates/kicad-bridge/src/main.rs+++ b/rust/crates/kicad-bridge/src/main.rs@@ -1,233 +1,236 @@⋯ 38 unchanged lines ⋯ mod verbs_checks; mod verbs_checks_native; mod verbs_report;+mod verbs_order;+mod verbs_land_pattern; mod verbs_windows;  use std::sync::atomic::{AtomicU64, Ordering};⋯ 186 unchanged lines ⋯     };     let _ = req.respond(json_response(body, code)); }+--- a/rust/crates/kicad-bridge/src/verbs_checks_native.rs+++ b/rust/crates/kicad-bridge/src/verbs_checks_native.rs@@ -1,486 +1,708 @@⋯ 79 unchanged lines ⋯         pitfalls: &["Spots too close to other copper, pads, vias or the edge are skipped, not forced (skipped says how many and why).", "Size and drill default to the project's Default net class via."],     },     Verb {+        name: "kicad_silk_designators",+        summary: "Place every reference designator the way assembly houses want: one size, upright, beside its own part, clear of every pad (JLCPCB: 0.15 mm), every other part's body and the other designators, inside the board, then read back from KiCad. Parts with no clear spot are reported, not forced onto pads.",+        // File: it moves the labels through kicad_silk_fields_batch (verbs::call), which takes the lock itself+        mechanism: Mechanism::File, risk: "write", timeout_sec: 300,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"size\"?: 1.0, \"minSize\"?: 1.0, \"gap\"?: 0.2, \"padClearance\"?: 0.15, \"refs\"?: [\"U1\"], \"dryRun\"?: false, \"save\"?: false, \"frame\"?: \"standard|kicad\"}",+        example: "kicad_silk_designators {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"size\":1.0,\"dryRun\":true}",+        hint: "planned [{ref, x, y, size, rotation, side, at}] (at: above, below, right, left or a corner); onPadsBefore and onPadsAfter list labels on copper (After must be empty); unplaced parts had no clear spot, or KiCad's DRC flagged them: allow a smaller minSize, or move the part. A closed board gets its labels written into the file after KiCad's DRC (a .adom-bak beside it); a board open in the PCB editor is moved live as one Undo step, unless its footprints carry KiCad 10's units block, which KiCad would drop: then save, close it and run again.",+        related: &["kicad_silk_state", "kicad_silk_fields_batch", "kicad_fab_check", "kicad_text_bounds"],+        pitfalls: &["size and minSize never go below the board's own minimum silk text height (Board Setup > Constraints; boardMinTextHeight): KiCad's DRC would refuse the move.", "Bigger parts choose first; labels are tried above, below, right, left, then at the corners.", "Text width is estimated from the stroke font (0.9 of the height per character): kicad_text_bounds measures it exactly."],+    },+    Verb {         name: "kicad_auto_classes",         summary: "Net classes from the nets' own names: GND, Power (VCC, VDD, 3V3, VBAT...), Clock and HighSpeed (USB, Ethernet, HDMI, PCIe, DDR, MIPI, LVDS, SATA), and the differential pairs their names spell; a proposal first, written into the project's net classes with apply.",         mechanism: Mechanism::File, risk: "write", timeout_sec: 60,⋯ 24 unchanged lines ⋯         "kicad_thermal_vias" => via_job(state, args, ViaJob::Thermal),         "kicad_via_fence" => via_job(state, args, ViaJob::Fence),         "kicad_auto_classes" => auto_classes(state, args),+        "kicad_silk_designators" => silk_designators(state, args),         "kicad_pin_pad_check" => pin_pad_check(state, args),         _ => return None,     })⋯ 75 unchanged lines ⋯     v } +/// kicad_silk_designators: the plan from one read (KiCad's frame), then the labels moved live by+/// kicad_silk_fields_batch and read back.+fn silk_designators(state: &mut State, args: &Value) -> Value {+    let o = match opts(args) { Ok(o) => o, Err(e) => return e };+    let r = match read(state, args) { Ok(r) => r, Err(e) => return e };+    // never below the board's own minimum silk text height (Board Setup > Constraints): KiCad's DRC+    // would refuse the move+    let floor = std::fs::read_to_string(PathBuf::from(&r.file).with_extension("kicad_pro")).ok()+        .and_then(|t| serde_json::from_str::<Value>(&t).ok())+        .and_then(|d| d.pointer("/board/design_settings/rules/min_text_height").and_then(Value::as_f64))+        // KiCad's own default when the project does not set it (or there is no .kicad_pro)+        .or(Some(0.8));+    let mut args = args.clone();+    if let Some(fl) = floor {+        for k in ["size", "minSize"] {+            if let Some(v) = args.get(k).and_then(Value::as_f64).filter(|v| *v < fl) {+                let _ = v;+                args[k] = json!(fl);+            }+        }+    }+    let args = &args;+    let plan_of = |text: &str, file: &str| -> Result<(Value, Value), String> {+        let silk = kicad_core::board_reads::silk_state(text, false)?;+        let model = board_model::build(text, Some(file), &board_model::Opts { y_up: false })?;+        let outline: Vec<(f64, f64)> = model["outline"].as_array().into_iter().flatten().map(|p| (p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0))).collect();+        Ok((board_checks::silk_designators(&silk, &model, &outline, args), model))+    };+    let (mut plan, model) = match plan_of(&r.text, &r.file) {+        Ok(x) => x,+        Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+    };+    let updates: Vec<Value> = plan["planned"].as_array().into_iter().flatten().map(|p| {+        let s = p["size"].as_f64().unwrap_or(1.0);+        // a designator goes on its side's silkscreen, wherever the footprint kept it (some keep it on Fab)+        let layer = if p["side"] == "bottom" { "B.SilkS" } else { "F.SilkS" };+        json!({"id": p["id"], "x": p["x"], "y": p["y"], "rotation": 0.0, "layer": layer, "height": s, "width": s, "stroke": (s * 0.15 * 1000.0).round() / 1000.0, "visible": true})+    }).collect();+    // the reply's frame: the standard's (y up) unless frame:"kicad"+    let show = |plan: &mut Value| if o.y_up {+        for p in plan["planned"].as_array_mut().into_iter().flatten() {+            p["y"] = json!(-p["y"].as_f64().unwrap_or(0.0) + 0.0);+        }+    };+    let mut out = json!({"success": true, "frame": if o.y_up { "standard" } else { "kicad" }, "revision": model["revision"], "placed": 0, "onPadsAfter": Value::Null, "boardMinTextHeight": floor});+    if args.get("dryRun") == Some(&json!(true)) || updates.is_empty() {+        show(&mut plan);+        for (k, v) in plan.as_object().cloned().unwrap_or_default() {+            out[k] = v;+        }+        stamp(&mut out, &r);+        out["dryRun"] = json!(args.get("dryRun") == Some(&json!(true)));+        out["_hint"] = json!(if updates.is_empty() { "Nothing to place: see unplaced." } else { "A plan only: run again without dryRun to move the labels." });+        return out;+    }+    // KiCad 10.0.x moves a field over IPC only by sending its whole footprint back, and drops the+    // footprint's units block (its pin groups) on the way: never lose that for a label+    let with_units: Vec<String> = if r.live {+        plan["planned"].as_array().into_iter().flatten().filter_map(|p| {+            let rf = p["ref"].as_str()?;+            let (a, b) = kicad_core::pcb_sync::footprint_span(&r.text, rf)?;+            r.text[a..b].contains("(units").then(|| rf.to_string())+        }).collect()+    } else {+        Vec::new()+    };+    if !with_units.is_empty() {+        show(&mut plan);+        let mut v = fail("live_move_drops_units", format!("{} of the parts carry KiCad 10's units block (pin groups), which KiCad drops when a field is moved over its API", with_units.len()),+            "Nothing changed. Save the board and close it in KiCad, then run this again: the labels are written into the file (after the same DRC check, with a backup) and the units stay. planned is what would move.");+        v["mutated"] = json!(false);+        v["planned"] = plan["planned"].clone();+        v["unplaced"] = plan["unplaced"].clone();+        v["partsWithUnits"] = json!(with_units.len());+        return v;+    }+    if !r.live {+        return match designators_into_file(state, args, &r, &mut plan) {+            Ok(mut v) => {+                let placed = plan["planned"].as_array().map_or(0, |a| a.len());+                let after = std::fs::read_to_string(&r.file).ok().and_then(|t| plan_of(&t, &r.file).ok()).map(|(p, _)| p["onPadsBefore"].clone()).unwrap_or(Value::Null);+                show(&mut plan);+                for (k, val) in plan.as_object().cloned().unwrap_or_default() {+                    v[k] = val;+                }+                v["placed"] = json!(placed);+                v["onPadsAfter"] = after;+                v["frame"] = out["frame"].clone();+                v["boardMinTextHeight"] = json!(floor);+                stamp(&mut v, &r);+                v["_hint"] = json!("Written into the closed board after KiCad's DRC found no new error (a .adom-bak beside it). Open it in KiCad to look; onPadsAfter must be empty.");+                v+            }+            Err(e) => e,+        };+    }+    // KiCad's DRC has the last word: a label it still flags (a mask opening the estimate missed, a+    // rule this board sets) leaves the batch, goes to unplaced with KiCad's reason, and the rest retry+    let mut updates = updates;+    let mut applied = Value::Null;+    for _ in 0..4 {+        let mut a = json!({"filePath": r.file, "expectedRevision": model["revision"], "update": updates});+        if let Some(s) = args.get("save") {+            a["save"] = s.clone();+        }+        applied = call(state, "kicad_silk_fields_batch", &a);+        if applied["success"] == true || applied["errorCode"] != "drc_rejected" {+            break;+        }+        let mut flagged: Vec<(String, String)> = Vec::new();+        for v in applied["violations"].as_array().into_iter().flatten() {+            for it in v["items"].as_array().into_iter().flatten() {+                if let Some(rf) = it["description"].as_str().and_then(|d| d.strip_prefix("Reference field of ")) {+                    flagged.push((rf.trim().to_string(), v["description"].as_str().unwrap_or("").to_string()));+                }+            }+        }+        let ids: Vec<Value> = plan["planned"].as_array().into_iter().flatten().filter(|p| flagged.iter().any(|f| p["ref"] == json!(f.0))).map(|p| p["id"].clone()).collect();+        if ids.is_empty() {+            break;+        }+        updates.retain(|u| !ids.contains(&u["id"]));+        let (keep, drop): (Vec<Value>, Vec<Value>) = plan["planned"].as_array().cloned().unwrap_or_default().into_iter().partition(|p| !ids.contains(&p["id"]));+        plan["planned"] = json!(keep);+        let mut unplaced = plan["unplaced"].as_array().cloned().unwrap_or_default();+        for d in drop {+            let why = flagged.iter().find(|f| d["ref"] == json!(f.0)).map(|f| f.1.clone()).unwrap_or_default();+            unplaced.push(json!({"ref": d["ref"], "why": format!("KiCad's DRC: {why}")}));+        }+        plan["unplaced"] = json!(unplaced);+        if updates.is_empty() {+            break;+        }+    }+    if applied["success"] != true {+        let mut e = applied;+        e["planned"] = plan["planned"].clone();+        e["unplaced"] = plan["unplaced"].clone();+        return e;+    }+    // read back what KiCad now has, and check it against the pads the same way+    let after = read(state, &json!({"filePath": r.file, "source": "live"})).ok().and_then(|r2| plan_of(&r2.text, &r2.file).ok());+    let on_pads_after = after.as_ref().map(|(p, _)| p["onPadsBefore"].clone()).unwrap_or(Value::Null);+    show(&mut plan);+    for (k, v) in plan.as_object().cloned().unwrap_or_default() {+        out[k] = v;+    }+    out["placed"] = json!(updates.len());+    out["onPadsAfter"] = on_pads_after.clone();+    out["revision"] = applied["revision"].clone();+    out["undoSteps"] = applied["undoSteps"].clone();+    stamp(&mut out, &r);+    out["_hint"] = json!(if on_pads_after.as_array().map_or(false, |a| a.is_empty()) { "Every placed label is clear of the copper (onPadsAfter is empty). One Undo step in KiCad restores them." }+        else { "onPadsAfter lists labels still on copper: read it (a part's label may need a smaller size or the part moved)." });+    out+}++/// The planned labels written into the closed board: each part's Reference field moved, sized and+/// shown, then KiCad's DRC on the result; a label it flags leaves the plan (unplaced, with KiCad's+/// reason) and the rest retry. Written with a backup.+fn designators_into_file(state: &mut State, args: &Value, r: &Read, plan: &mut Value) -> Result<Value, Value> {+    for _ in 0..4 {+        let mut text = r.text.clone();+        for p in plan["planned"].as_array().cloned().unwrap_or_default() {+            let rf = p["ref"].as_str().unwrap_or("");+            let Some((a, b)) = kicad_core::pcb_sync::footprint_span(&text, rf) else { continue };+            let s = p["size"].as_f64().unwrap_or(1.0);+            let layer = if p["side"] == "bottom" { "B.SilkS" } else { "F.SilkS" };+            let block = kicad_core::fp_text::set_field_pose(&text[a..b], "Reference", (p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0)), 0.0, layer, s, (s * 0.15 * 1000.0).round() / 1000.0)+                .map_err(|e| fail("edit_failed", format!("{rf}: {e}"), "kicad_fp_info shows the footprint's fields."))?;+            text.replace_range(a..b, &block);+        }+        match file_preflight(state, args, r, &text) {+            Ok(drc) => {+                let backup = args.get("backup") != Some(&json!(false));+                let bak = pcb::write_board(std::path::Path::new(&r.file), &text, backup, Some(&r.text))?;+                return Ok(json!({"success": true, "mutated": true, "boardPath": r.file, "backupPath": bak, "drc": drc, "source": "file",+                    "revision": board_model::revision_of_text(&text).unwrap_or_default(), "reloaded": false}));+            }+            Err(e) if e["errorCode"] == "drc_rejected" => {+                let mut flagged: Vec<(String, String)> = Vec::new();+                for v in e["violations"].as_array().into_iter().flatten() {+                    for it in v["items"].as_array().into_iter().flatten() {+                        if let Some(rf) = it["description"].as_str().and_then(|d| d.strip_prefix("Reference field of ")) {+                            flagged.push((rf.trim().to_string(), v["description"].as_str().unwrap_or("").to_string()));+                        }+                    }+                }+                if flagged.is_empty() {+                    return Err(e);+                }+                let (keep, drop): (Vec<Value>, Vec<Value>) = plan["planned"].as_array().cloned().unwrap_or_default().into_iter().partition(|p| !flagged.iter().any(|f| p["ref"] == json!(f.0)));+                plan["planned"] = json!(keep);+                let mut unplaced = plan["unplaced"].as_array().cloned().unwrap_or_default();+                for d in drop {+                    let why = flagged.iter().find(|f| d["ref"] == json!(f.0)).map(|f| f.1.clone()).unwrap_or_default();+                    unplaced.push(json!({"ref": d["ref"], "why": format!("KiCad's DRC: {why}")}));+                }+                plan["unplaced"] = json!(unplaced);+                if plan["planned"].as_array().map_or(true, |a| a.is_empty()) {+                    return Err(fail("nothing_placed", "KiCad's DRC flagged every planned label", "Read unplaced: a smaller minSize, more silkClearance, or moving parts may help."));+                }+            }+            Err(e) => return Err(e),+        }+    }+    Err(fail("drc_rejected", "KiCad's DRC kept flagging labels after four rounds", "Run with dryRun and read the plan; try a smaller minSize or a refs list."))+}+ /// The project's Default net class via (diameter, drill), else the bridge's defaults. fn default_via(board_file: &str) -> (f64, f64) {     let pro = PathBuf::from(board_file).with_extension("kicad_pro");⋯ 287 unchanged lines ⋯         assert!(o["checked"].as_u64().unwrap() > 10, "{o}");     } }+--- a/rust/crates/kicad-bridge/src/verbs_land_pattern.rs+++ b/rust/crates/kicad-bridge/src/verbs_land_pattern.rs@@ -0,0 +1,95 @@+//! Verb group "land_pattern": `kicad_footprint_from_land_pattern`, the EDA Bridge Standard's+//! footprint_from_land_pattern. kicad_core::land_pattern lays the footprint out from the+//! datasheet's numbers (the Altium bridge's layout, so both tools draw the same footprint); the+//! file is written by kicad_fp_create (verbs::call), and a body height adds a VRML box beside the+//! library as its 3D model. Catalogued File: it holds no GUI_LOCK itself.++use std::path::PathBuf;++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use crate::verbs::call;+use kicad_core::land_pattern;++pub static VERBS: &[Verb] = &[Verb {+    name: "kicad_footprint_from_land_pattern",+    summary: "Build a complete footprint from the land pattern a datasheet prints: pads numbered the standard way (chip, dual SOIC/TSSOP/DFN, SOT, quad QFP/QFN, BGA), an exposed pad, the courtyard, silkscreen kept clear of the copper with a pin-1 dot, a fab outline with a pin-1 chamfer, and a 3D body box when the height is given.",+    mechanism: Mechanism::File, risk: "write", timeout_sec: 60,+    input: "{\"libraryPath\": \"C:/.../Mine.pretty\", \"name\": \"SOIC-8_3.9x4.9mm_P1.27mm\", \"family\": \"chip|dual|sot|quad|bga\", \"pins\"?, \"pitch\"?, \"span\"?, \"spanY\"?, \"padLength\"?, \"padWidth\"?, \"padShape\"?: \"roundrect|rect|round|oval\", \"pinsPerSideX\"?, \"pinsPerSideY\"?, \"leftPins\"?, \"rightPins\"?, \"rows\"?, \"cols\"?, \"ballPad\"?, \"missing\"?: [\"B2\"], \"exposedPad\"?: {\"w\", \"h\", \"x\"?, \"y\"?, \"number\"?}, \"body\"?: {\"w\", \"l\", \"height\"?, \"standoff\"?}, \"polarized\"?, \"courtyard\"?: 0.25, \"silkWidth\"?: 0.15, \"silkClearance\"?: 0.2, \"description\"?, \"overwrite\"?: false, \"dryRun\"?: false}",+    example: "kicad_footprint_from_land_pattern {\"libraryPath\":\"C:/Users/john/kicad/Mine.pretty\",\"name\":\"SOIC-8_3.9x4.9mm_P1.27mm\",\"family\":\"dual\",\"pins\":8,\"pitch\":1.27,\"span\":5.4,\"padLength\":1.55,\"padWidth\":0.6,\"body\":{\"w\":3.9,\"l\":4.9,\"height\":1.75},\"dryRun\":true}",+    hint: "dryRun first and compare summary.padTable (name, x, y, w, h in mm, Y up, pin 1 top-left) with the datasheet's drawing; then build it. The footprint lands in libraryPath as <name>.kicad_mod (the .pretty folder is created); register the library with kicad_install_library, look at it with kicad_fp_info or kicad_show_footprint.",+    related: &["kicad_fp_create", "kicad_fp_info", "kicad_fp_edit", "kicad_install_library"],+    pitfalls: &["The numbers are the datasheet's RECOMMENDED LAND PATTERN, not the package outline: pad size and the pad-centre span.", "Pin 1 is top-left, numbered counter-clockwise (IPC). The file itself is in KiCad's frame (y down).", "The 3D body is a plain box (a VRML file beside the library), the size of body {w, l, height}."],+}];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    match command {+        "kicad_footprint_from_land_pattern" => Some(from_land_pattern(state, args)),+        _ => None,+    }+}++fn from_land_pattern(state: &mut State, args: &Value) -> Value {+    let Some(name) = arg_str(args, "name").filter(|n| !n.trim().is_empty()) else {+        return fail("missing_arg", "name is required", "The footprint's name, e.g. SOIC-8_3.9x4.9mm_P1.27mm.");+    };+    let fp = match land_pattern::layout(args) {+        Ok(f) => f,+        Err(e) => return fail("bad_args", e, "Give the datasheet's RECOMMENDED LAND PATTERN numbers (mm): chip {span, padLength, padWidth}; dual/sot {pins | leftPins+rightPins, pitch, span (pad centre to pad centre across), padLength, padWidth}; quad {pins | pinsPerSideX+pinsPerSideY, pitch, span, spanY?, padLength, padWidth}; bga {rows, cols, pitch, ballPad, missing?}; plus exposedPad {w, h} and body {w, l, height?}."),+    };+    let desc = arg_str(args, "description").map(str::to_string).unwrap_or_else(|| land_pattern::describe(args, &fp));+    let silk = fp.lines.iter().filter(|l| l.0 == "overlay").count();+    let summary = json!({"pads": fp.pads.len(), "courtyardMm": [fp.courtyard.0, fp.courtyard.1, fp.courtyard.2, fp.courtyard.3], "silkLines": silk,+        "padTable": fp.pads.iter().take(12).map(|p| json!([p.name, p.x, p.y, p.w, p.h])).collect::<Vec<_>>(), "frame": "standard (mm, y up, pin 1 top-left)"});+    if args.get("dryRun") == Some(&json!(true)) {+        return json!({"success": true, "dryRun": true, "name": name, "description": desc, "summary": summary,+            "_hint": "Nothing written. Check padTable (name, x, y, w, h in mm, Y up, pin 1 top-left) against the datasheet, then call again without dryRun."});+    }+    let Some(lib) = arg_str(args, "libraryPath").filter(|p| p.trim_end_matches(['/', '\\']).to_ascii_lowercase().ends_with(".pretty")) else {+        return fail("missing_arg", "libraryPath (a .pretty folder) is required", "Pass the KiCad footprint library folder, e.g. C:/Users/you/kicad/Mine.pretty (created when missing).");+    };+    let mut fa = land_pattern::fp_create_args(args, &fp);+    fa["libraryPath"] = json!(lib);+    fa["name"] = json!(name);+    fa["description"] = json!(desc);+    if let Some(o) = args.get("overwrite") {+        fa["overwrite"] = o.clone();+    }+    // the body box as a VRML file beside the library: <lib>.3dshapes/<name>.wrl+    let body = args.get("body").cloned().unwrap_or(json!({}));+    let mut body3d = Value::Null;+    if let (Some(w), Some(l), Some(h)) = (body["w"].as_f64(), body["l"].as_f64(), body["height"].as_f64()) {+        let libdir = PathBuf::from(lib.trim_end_matches(['/', '\\']));+        let shapes = libdir.with_extension("3dshapes");+        let wrl = shapes.join(format!("{name}.wrl"));+        match std::fs::create_dir_all(&shapes).and_then(|_| std::fs::write(&wrl, land_pattern::vrml_box(w, l, h, body["standoff"].as_f64().unwrap_or(0.0)))) {+            Ok(()) => {+                fa["model"] = json!({"path": norm(&wrl)});+                body3d = json!({"success": true, "path": norm(&wrl)});+            }+            Err(e) => body3d = json!({"success": false, "error": format!("could not write {}: {e}", norm(&wrl))}),+        }+    }+    let mut r = call(state, "kicad_fp_create", &fa);+    r["summary"] = summary;+    r["body3d"] = body3d;+    r["description"] = json!(desc);+    if r["success"] == true {+        r["spec"] = json!(format!("{}/{name}.kicad_mod", lib.trim_end_matches(['/', '\\'])));+        r["_hint"] = json!("Written. Register the library with kicad_install_library to place it, kicad_fp_info reads it back, kicad_show_footprint shows it in KiCad.");+    }+    r+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn it_holds_no_gui_lock() {+        assert!(matches!(VERBS[0].mechanism, Mechanism::File));+    }+}+--- a/rust/crates/kicad-bridge/src/verbs_order.rs+++ b/rust/crates/kicad-bridge/src/verbs_order.rs@@ -0,0 +1,318 @@+//! Verb group "order": the EDA Bridge Standard's bom, assembly_check and jlcpcb_order+//! (kicad_core::order, ported from the Altium bridge so the files read the same).+//! - `kicad_bom`: the schematic's parts (kicad-cli's netlist: fields, dnp, exclude_from_bom) merged+//!   with the board (footprint, placement), one line per MPN, checked, written as bom.csv, JLCPCB's+//!   BOM and CPL, the Mouser and DigiKey BOM-tool files and bom.json;+//! - `kicad_assembly_check`: the board against JLCPCB's Economic and Standard assembly tiers;+//! - `kicad_jlcpcb_order`: fab_check, the Gerbers and drill zipped, the BOM and CPL, the tiers and+//!   the order form, in one folder with every issue in one list. A composite (verbs::call):+//!   catalogued File, it holds no GUI_LOCK itself.++use std::collections::HashMap;+use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use crate::verbs::call;+use crate::verbs_standard::{read, stamp, Read};+use kicad_core::{board_model, fab, order};++const TIERS_SOURCE: &str = "https://jlcpcb.com/capabilities/pcb-assembly-capabilities";+const TIERS_READ: &str = "2026-10-26";++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_bom",+        summary: "The project's bill of materials: every schematic part (fields, DNP, exclude from BOM) merged with the board (footprint, position, side), one line per manufacturer part number, checked for what an order needs, and written as bom.csv, bom-jlcpcb.csv + cpl-jlcpcb.csv, bom-mouser.csv and bom-digikey.csv (+ bom.json).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 300,+        input: "{\"projectPath\": \"C:/.../x.kicad_pro\" | \"filePath\" | \"pcbPath\": \"C:/.../x.kicad_pcb\", \"schematicPath\"?, \"netlistPath\"?, \"boards\"?: 1, \"outputDir\"?: \"<project folder>/BOM\", \"write\"?: true, \"source\"?: \"auto|live|file\"}",+        example: "kicad_bom {\"projectPath\":\"C:/Users/john/proj/proj.kicad_pro\",\"boards\":10}",+        hint: "lines[] {quantity, designators, value, footprint, package, mpn, manufacturer, mouser, digikey, lcsc, purchase}; checks name what an order lacks (missingMpn, missingPartNumbers {mouser, digikey, lcsc}, inconsistent fields, dnp, excludedKinds, notPurchased, sheetNotOnBoard, boardNotOnSheet, jlcpcb.linesWithoutLcsc). Field names are matched across libraries (MPN = Manufacturer Part Number; LCSC = LCSC Part # = JLCPCB Part #). Look missing numbers up (adom-parts-search search <MPN>), write them on the symbols (kicad_sch_set_property), update the board, run this again.",+        related: &["kicad_assembly_check", "kicad_jlcpcb_order", "kicad_export_bom_csv", "kicad_sch_set_property"],+        pitfalls: &["The schematic is read saved (kicad-cli); without one the board's footprint fields are the BOM.", "CPL positions are KiCad's position-file numbers (mm, y up) and its rotations: check every part in JLCPCB's preview.", "KiCad has no assembly variants here: variants is empty."],+    },+    Verb {+        name: "kicad_assembly_check",+        summary: "Ready for JLCPCB assembly? The board against both JLCPCB PCBA tiers as published (Economic and Standard): board or panel size, thickness and layers, parts on one or both sides, the smallest package and the finest pin pitch, fiducials, and the purchased parts with no LCSC number; answers the cheapest tier every rule allows.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 300,+        input: "{\"filePath\" | \"pcbPath\": \"C:/.../x.kicad_pcb\" | \"projectPath\", \"panelMm\"?: [w, h], \"source\"?: \"auto|live|file\"}",+        example: "kicad_assembly_check {\"projectPath\":\"C:/Users/john/proj/proj.kicad_pro\"}",+        hint: "service is the cheapest tier every rule allows (null: fix what the rules mark false). A small board ships in a panel: kicad_panelize, then panelMm. LCSC numbers come from kicad_bom (the schematic). Then kicad_jlcpcb_order for the files.",+        related: &["kicad_bom", "kicad_jlcpcb_order", "kicad_panelize", "kicad_fab_check"],+        pitfalls: &["Pitch is the closest pad centres within one part of 3 or more SMD pads; package size is read from footprint names and two-pad parts' pad spacing.", "Rotations are not checked: JLCPCB's preview is the authority on orientation."],+    },+    Verb {+        name: "kicad_jlcpcb_order",+        summary: "A JLCPCB order in one call: the board checked against JLCPCB's fab limits, the Gerbers and drill zipped the way JLCPCB's order page takes them, the JLCPCB BOM and pick-and-place (CPL), the assembly-tier check and the order-form values, in one folder with every issue in one list.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 900,+        input: "{\"projectPath\": \"C:/.../x.kicad_pro\" | \"filePath\" | \"pcbPath\": \"C:/.../x.kicad_pcb\", \"outputDir\"?: \"<board folder>/JLCPCB order <board>\", \"boards\"?: 5, \"panelMm\"?: [w, h]}",+        example: "kicad_jlcpcb_order {\"projectPath\":\"C:/Users/john/proj/proj.kicad_pro\"}",+        hint: "ready is true when the board fits JLCPCB's limits, an assembly tier passes and issues is empty. Upload gerberZip.path on the order page and fill it from orderForm; for assembly upload bom-jlcpcb.csv and cpl-jlcpcb.csv and check every part's rotation in JLCPCB's preview.",+        related: &["kicad_fab_check", "kicad_assembly_check", "kicad_bom", "kicad_export_gerber", "kicad_panelize"],+        pitfalls: &["The order folder is written fresh each time (its old files are removed).", "Reads the saved board for the Gerbers: save the board first."],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_bom" => bom(state, args),+        "kicad_assembly_check" => assembly(state, args),+        "kicad_jlcpcb_order" => jlcpcb_order(state, args),+        _ => return None,+    })+}++/// filePath for the board from filePath, pcbPath or projectPath.+fn board_args(args: &Value) -> Result<Value, Value> {+    let mut a = args.clone();+    let board = arg_any(args, &["filePath", "pcbPath"]).map(PathBuf::from)+        .or_else(|| arg_str(args, "projectPath").map(|p| PathBuf::from(p).with_extension("kicad_pcb")));+    match board {+        Some(b) => {+            a["filePath"] = json!(norm(&b));+            Ok(a)+        }+        None => Err(fail("missing_arg", "projectPath or filePath is required", "Pass the .kicad_pro, or the .kicad_pcb beside it.")),+    }+}++/// The board's parts placed, by reference: (x, y in the standard's frame, rotation, side).+fn placements(model: &Value) -> HashMap<String, (f64, f64, f64, String)> {+    model["components"].as_array().into_iter().flatten().filter_map(|c| {+        Some((c["ref"].as_str()?.to_string(), (c["x"].as_f64()?, c["y"].as_f64()?, c["rotation"].as_f64().unwrap_or(0.0), c["side"].as_str().unwrap_or("top").to_string())))+    }).collect()+}++/// The BOM computed (nothing written): lines, checks, parts, where the parts came from.+fn compute(state: &mut State, a: &Value, r: &Read) -> Result<(Vec<Value>, Value, Vec<order::Part>, String), Value> {+    let model = board_model::build(&r.text, Some(&r.file), &board_model::Opts { y_up: true }).map_err(|e| fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."))?;+    let board = fab::parts_from_board(&r.text).map_err(|e| fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."))?;+    let board_path = PathBuf::from(&r.file);+    let has_sheet = a.get("netlistPath").is_some() || a.get("schematicPath").is_some() || board_path.with_extension("kicad_sch").is_file();+    let (nl, from) = if has_sheet {+        let (nl, from) = crate::verbs_pcb_sync::netlist_for(state, a, &board_path)?;+        (Some(nl), from)+    } else {+        (None, "the board's footprints (no schematic beside it)".to_string())+    };+    let (parts, cross) = order::parts(nl.as_ref(), &board, &placements(&model));+    let (lines, mut checks) = order::group(&parts);+    checks["sheetNotOnBoard"] = cross["sheetNotOnBoard"].clone();+    checks["boardNotOnSheet"] = cross["boardNotOnSheet"].clone();+    Ok((lines, checks, parts, from))+}++fn bom(state: &mut State, args: &Value) -> Value {+    let a = match board_args(args) { Ok(a) => a, Err(e) => return e };+    let r = match read(state, &a) { Ok(r) => r, Err(e) => return e };+    let (lines, checks, parts, from) = match compute(state, &a, &r) { Ok(x) => x, Err(e) => return e };+    let boards = args.get("boards").and_then(Value::as_u64).unwrap_or(1);+    let fitted = parts.iter().filter(|p| !p.dnp && !p.excluded).count();+    let mut written = Vec::new();+    if args.get("write") != Some(&json!(false)) {+        let dir = arg_str(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| Path::new(&r.file).parent().unwrap_or(Path::new(".")).join("BOM"));+        if let Err(e) = std::fs::create_dir_all(&dir) {+            return fail("output_dir", format!("cannot create {}: {e}", norm(&dir)), "Pass an outputDir the desktop user can write.");+        }+        let mut files = order::files(&lines, &parts, boards);+        files.push(("bom.json".into(), serde_json::to_string_pretty(&json!({"lines": lines, "checks": checks, "boards": boards})).unwrap_or_default()));+        for (name, text) in files {+            let p = dir.join(&name);+            if let Err(e) = std::fs::write(&p, text) {+                return fail("write_failed", format!("could not write {}: {e}", norm(&p)), "Close the file if a spreadsheet holds it open, or pass another outputDir.");+            }+            written.push(norm(&p));+        }+    }+    let gaps = checks["jlcpcb"]["linesWithoutLcsc"].as_array().map_or(0, |a| a.len());+    let mut v = json!({"success": true, "lines": lines, "checks": checks, "files": written, "fittedParts": fitted, "alternates": [], "variants": [], "partsFrom": from, "boards": boards,+        "output": format!("{} line(s), {fitted} fitted part(s){}", lines.len(), if written.is_empty() { String::new() } else { format!(", {} file(s) written", written.len()) }),+        "_hint": format!("{} Missing numbers: look them up (adom-parts-search search <MPN>), write them on the symbols (kicad_sch_set_property), update the board, run this again.",+            if gaps == 0 { "Every purchased line has an LCSC number." } else { "Some purchased lines have no LCSC number (checks.jlcpcb.linesWithoutLcsc): JLCPCB assembly needs one or consigned parts." })});+    stamp(&mut v, &r);+    v+}++/// The assembly answer, shared with jlcpcb_order: the tiers, the board's facts and the LCSC gaps.+fn assembly_answer(state: &mut State, a: &Value, r: &Read) -> Result<Value, Value> {+    let model = board_model::build(&r.text, Some(&r.file), &board_model::Opts { y_up: true }).map_err(|e| fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."))?;+    let pts: Vec<(f64, f64)> = model["outline"].as_array().into_iter().flatten().map(|p| (p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0))).collect();+    if pts.len() < 3 {+        return Err(fail("no_outline", "the board has no outline on Edge.Cuts", "Draw the board outline first (kicad_board_outline)."));+    }+    let (x0, x1) = pts.iter().fold((f64::MAX, f64::MIN), |m, p| (m.0.min(p.0), m.1.max(p.0)));+    let (y0, y1) = pts.iter().fold((f64::MAX, f64::MIN), |m, p| (m.0.min(p.1), m.1.max(p.1)));+    let panel = a.get("panelMm").and_then(Value::as_array).filter(|p| p.len() == 2).map(|p| (p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0)));+    let size = panel.unwrap_or((x1 - x0, y1 - y0));+    let stack = board_model::stackup(&r.text).unwrap_or(Value::Null);+    let thick = stack["totalUm"].as_f64().map(|u| u / 1000.0);+    let layers = stack["copperLayers"].as_i64();+    let (lcsc_missing, lcsc_lines) = match compute(state, a, r) {+        Ok((_, checks, _, from)) if !from.starts_with("the board's") => {+            let l = checks["jlcpcb"]["linesWithoutLcsc"].clone();+            (Some(l.as_array().map_or(0, |a| a.len())), l)+        }+        Ok(_) => (None, json!({"note": "no schematic beside the board: LCSC numbers not checked"})),+        Err(e) => (None, json!({"bomError": e.get("error").cloned().unwrap_or(Value::Null)})),+    };+    let fa = order::facts(&placed_only(&model));+    let eco = order::tier("economic", &fa, size, thick, layers, lcsc_missing, panel.is_some());+    let std_ = order::tier("standard", &fa, size, thick, layers, lcsc_missing, panel.is_some());+    let service = if eco["ok"] == json!(true) { json!("economic") } else if std_["ok"] == json!(true) { json!("standard") } else { Value::Null };+    Ok(json!({"success": true, "fab": {"name": "JLCPCB", "source": TIERS_SOURCE, "read": TIERS_READ}, "service": service, "economic": eco, "standard": std_,+        "board": fa, "boardMm": [(size.0 * 1000.0).round() / 1000.0, (size.1 * 1000.0).round() / 1000.0], "linesWithoutLcsc": lcsc_lines,+        "_hint": "service is the cheapest JLCPCB assembly tier every rule allows (null: fix what the rules mark false). Check every part's rotation in JLCPCB's placement preview after uploading the BOM and CPL (kicad_bom writes both)."}))+}++/// The board model without the parts the assembly line never places: those excluded from the BOM+/// or the position files, DNP and board-only footprints (solder jumpers, logos, test pads).+fn placed_only(model: &Value) -> Value {+    let skip: Vec<String> = model["components"].as_array().into_iter().flatten()+        .filter(|c| c["attributes"].as_array().map_or(false, |a| a.iter().any(|x| matches!(x.as_str(), Some("exclude_from_bom" | "exclude_from_pos_files" | "dnp" | "board_only")))))+        .filter_map(|c| c["ref"].as_str().map(String::from)).collect();+    let mut m = model.clone();+    for k in ["components", "pads"] {+        if let Some(a) = m[k].as_array_mut() {+            a.retain(|x| !x["ref"].as_str().map_or(false, |r| skip.iter().any(|s| s == r)));+        }+    }+    m+}++fn assembly(state: &mut State, args: &Value) -> Value {+    let a = match board_args(args) { Ok(a) => a, Err(e) => return e };+    let r = match read(state, &a) { Ok(r) => r, Err(e) => return e };+    match assembly_answer(state, &a, &r) {+        Ok(mut v) => {+            stamp(&mut v, &r);+            v+        }+        Err(e) => e,+    }+}++fn jlcpcb_order(state: &mut State, args: &Value) -> Value {+    let a = match board_args(args) { Ok(a) => a, Err(e) => return e };+    let file = a["filePath"].as_str().unwrap_or("").to_string();+    let board = PathBuf::from(&file);+    if !board.is_file() {+        return fail("board_not_found", format!("no board at {file}"), "Pass the .kicad_pro or the .kicad_pcb beside it.");+    }+    let stem = board.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "board".into());+    let folder = arg_str(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| board.with_file_name(format!("JLCPCB order {stem}")));+    // written fresh each time: never an old Gerber beside a new one+    let _ = std::fs::remove_dir_all(&folder);+    if let Err(e) = std::fs::create_dir_all(&folder) {+        return fail("output_dir", format!("cannot create {}: {e}", norm(&folder)), "Pass an outputDir the desktop user can write.");+    }+    let mut issues: Vec<Value> = Vec::new();++    let fab = call(state, "kicad_fab_check", &json!({"filePath": file, "fab": "jlcpcb", "source": "file"}));+    if fab["success"] == true {+        for o in fab["outsideLimits"].as_array().into_iter().flatten() {+            issues.push(json!({"from": "fab_check", "what": format!("{} is {} mm, JLCPCB's limit {} mm", o["limit"].as_str().unwrap_or(""), o["boardMm"], o["limitMm"]), "at": o["at"]}));+        }+    } else {+        issues.push(json!({"from": "fab_check", "what": format!("not run: {}", fab["error"].as_str().unwrap_or("no answer"))}));+    }++    let gdir = folder.join("gerbers");+    let g = call(state, "kicad_export_gerber", &json!({"filePath": file, "outputDir": norm(&gdir), "drill": true}));+    let mut gerber_zip = Value::Null;+    if g["success"] == true {+        let files = fab::collect_tree(&gdir);+        match fab::zip_stored(&files) {+            Ok(bytes) => {+                let zp = folder.join(format!("{stem}-gerbers.zip"));+                match std::fs::write(&zp, bytes) {+                    Ok(()) => gerber_zip = json!({"path": norm(&zp), "files": files.iter().map(|f| f.0.clone()).collect::<Vec<_>>()}),+                    Err(e) => issues.push(json!({"from": "gerbers", "what": format!("could not write the zip: {e}")})),+                }+            }+            Err(e) => issues.push(json!({"from": "gerbers", "what": format!("could not zip the Gerbers: {e}")})),+        }+        if g["drillError"].is_string() {+            issues.push(json!({"from": "gerbers", "what": g["drillError"]}));+        }+    } else {+        issues.push(json!({"from": "gerbers", "what": format!("not exported: {}", g["error"].as_str().unwrap_or("no answer"))}));+    }++    let mut bargs = json!({"filePath": file, "outputDir": norm(&folder), "boards": args.get("boards").cloned().unwrap_or(json!(5)), "source": "file"});+    for k in ["schematicPath", "netlistPath"] {+        if let Some(v) = args.get(k) {+            bargs[k] = v.clone();+        }+    }+    let b = call(state, "kicad_bom", &bargs);+    if b["success"] == true {+        for l in b["checks"]["jlcpcb"]["linesWithoutLcsc"].as_array().into_iter().flatten() {+            issues.push(json!({"from": "bom", "what": format!("no LCSC number for {} ({})", l["designators"].as_array().map(|d| d.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", ")).unwrap_or_default(), l["mpn"].as_str().filter(|m| !m.is_empty()).or(l["value"].as_str()).unwrap_or(""))}));+        }+    } else {+        issues.push(json!({"from": "bom", "what": format!("not written: {}", b["error"].as_str().unwrap_or("no answer"))}));+    }++    let mut aargs = json!({"filePath": file, "source": "file"});+    if let Some(p) = args.get("panelMm") {+        aargs["panelMm"] = p.clone();+    }+    let asm = call(state, "kicad_assembly_check", &aargs);+    let service = asm["service"].clone();+    if asm["success"] == true && service.is_null() {+        for rule in asm["economic"]["rules"].as_array().into_iter().flatten().filter(|r| r["ok"] == false) {+            issues.push(json!({"from": "assembly_check", "what": format!("economic: {} ({})", rule["rule"].as_str().unwrap_or(""), rule["detail"].as_str().unwrap_or(""))}));+        }+    } else if asm["success"] != true {+        issues.push(json!({"from": "assembly_check", "what": format!("not run: {}", asm["error"].as_str().unwrap_or("no answer"))}));+    }++    let text = std::fs::read_to_string(&board).unwrap_or_default();+    let stack = board_model::stackup(&text).unwrap_or(Value::Null);+    let m = &fab["measured"];+    let min = |keys: &[&str]| keys.iter().filter_map(|k| m[*k]["mm"].as_f64()).fold(None, |a: Option<f64>, v| Some(a.map_or(v, |a| a.min(v))));+    let order_form = json!({+        "layers": stack["copperLayers"], "dimensionsMm": asm["boardMm"], "thicknessMm": stack["totalUm"].as_f64().map(|u| (u / 10.0).round() / 100.0),+        "outerCopperOz": stack["layers"][0]["copperUm"].as_f64().map(|u| (u / 35.0 * 100.0).round() / 100.0),+        "minTrackSpaceMm": min(&["track", "clearanceRule"]), "minHoleMm": min(&["viaDrill", "padHole"]),+    });+    let files: Vec<Value> = b["files"].as_array().cloned().unwrap_or_default();+    let ready = fab["fits"] == true && !service.is_null() && issues.is_empty() && !gerber_zip.is_null();+    json!({"success": true, "ready": ready, "folder": norm(&folder), "gerberZip": gerber_zip, "bomFiles": files, "assemblyService": service, "orderForm": order_form, "issues": issues,+        "fab": fab["fab"], "output": format!("{} issue(s); {}", issues.len(), if ready { "ready to order" } else { "not ready yet" }),+        "_hint": if ready { "Upload gerberZip.path on JLCPCB's order page and fill it from orderForm; for assembly upload bom-jlcpcb.csv and cpl-jlcpcb.csv and check every part's rotation in the preview." }+                 else { "Fix what issues lists (each names the check it came from) and run this again: fab_check findings need the board changed, bom ones need LCSC numbers on the symbols, assembly_check ones may need a panel (kicad_panelize, then panelMm)." }})+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn composites_hold_no_gui_lock() {+        for v in VERBS {+            assert!(matches!(v.mechanism, Mechanism::File), "{}", v.name);+        }+    }++    #[test]+    fn the_route_fixture_has_a_bom_and_assembly_facts() {+        let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures/konnect-route/route-fixture.kicad_pcb");+        let text = std::fs::read_to_string(&p).unwrap();+        let model = board_model::build(&text, Some(&p.to_string_lossy()), &board_model::Opts { y_up: true }).unwrap();+        let board = fab::parts_from_board(&text).unwrap();+        let (parts, cross) = order::parts(None, &board, &placements(&model));+        assert_eq!(cross["from"], "board");+        assert!(parts.iter().all(|p| p.placed.is_some()), "every footprint is placed");+        let (lines, checks) = order::group(&parts);+        assert!(!lines.is_empty() && checks["missingMpn"].is_array());+        let fa = order::facts(&model);+        assert!(fa["parts"].as_u64().unwrap() > 0);+        let jumper = json!({"components": [{"ref": "JP1", "side": "bottom", "attributes": ["exclude_from_bom"]}, {"ref": "R1", "side": "top", "attributes": ["smd"]}],+            "pads": [{"ref": "JP1", "drill": 0.0}, {"ref": "R1", "drill": 0.0}]});+        assert_eq!(order::facts(&placed_only(&jumper))["bottomSideParts"], json!([]));+    }+}+--- a/rust/crates/kicad-bridge/src/verbs_routing.rs+++ b/rust/crates/kicad-bridge/src/verbs_routing.rs@@ -1,1590 +1,1591 @@⋯ 20 unchanged lines ⋯         name: "kicad_silk_fields_batch",         summary: "Update native footprint reference/value field geometry by stable field UUID.",         mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,-        input: "{filePath,expectedRevision,update:[{id,x,y,rotation?,layer,height,width?,stroke,align?,mirrored?,visible?}],dryRun?,save?}",+        input: "{filePath,expectedRevision,update:[{id,x,y,rotation?,layer,height,width?,stroke,align?,mirrored?,visible?}],dryRun?,save?,allowUnitsDrop?:false}",         example: "kicad_silk_fields_batch {filePath,expectedRevision,update:[{id,x:10,y:20,layer:F.SilkS,height:0.8,stroke:0.12}]}",         hint: "Board-frame mm on both faces. Preserves reference/value content, uses nested field updates, never replaces the whole footprint. Full candidate DRC includes warnings. One Undo, then field and unrelated-board-data readback; inspect postCommit errors before retrying.",         related: &["kicad_text_bounds", "kicad_silk_text_batch", "kicad_refresh_board_views"],-        pitfalls: &["Only plain stroke-font Reference/Value properties. No field creation/removal or content changes. Refuses if native field IDs cannot be queried. Exact masks and 3D body obstacles remain separate."],+        pitfalls: &["KiCad 10.0.x moves a field only through its whole footprint and drops the footprint's units block (pin groups) doing so: such a footprint refuses with field_move_drops_units, nothing changed, unless allowUnitsDrop:true. kicad_silk_designators writes labels into the closed board instead.", "Only plain stroke-font Reference/Value properties. No field creation/removal or content changes. Refuses if native field IDs cannot be queried. Exact masks and 3D body obstacles remain separate."],     },     Verb {         name: "kicad_silk_graphics_batch",⋯ 1556 unchanged lines ⋯         assert!(v["jobs"].is_array());     } }+--- a/rust/crates/kicad-core/src/board_checks.rs+++ b/rust/crates/kicad-core/src/board_checks.rs@@ -1,922 +1,1072 @@⋯ 683 unchanged lines ⋯ }  // ---------------------------------------------------------------------------+// silk_designators+// ---------------------------------------------------------------------------++/// A label's box: KiCad's stroke font is about 0.9 of the height wide per character, plus the stroke.+fn label_box(c: (f64, f64), text: &str, size: f64) -> Rect {+    let stroke = size * 0.15;+    let (w, h) = (text.chars().count().max(1) as f64 * size * 0.9 + stroke, size + stroke);+    Rect { min_x: c.0 - w / 2.0, min_y: c.1 - h / 2.0, max_x: c.0 + w / 2.0, max_y: c.1 + h / 2.0 }+}++/// A pad's bounding box, turned by its rotation.+fn pad_box(p: &Value) -> Rect {+    let (w, h) = (f(&p["sizeX"]), f(&p["sizeY"]));+    let (s, c) = f(&p["rotation"]).to_radians().sin_cos();+    let (hx, hy) = ((w / 2.0 * c).abs() + (h / 2.0 * s).abs(), (w / 2.0 * s).abs() + (h / 2.0 * c).abs());+    let (x, y) = (f(&p["x"]), f(&p["y"]));+    Rect { min_x: x - hx, min_y: y - hy, max_x: x + hx, max_y: y + hy }+}++fn rect_of(v: &Value) -> Option<Rect> {+    let a = v.as_array()?;+    (a.len() == 4).then(|| Rect { min_x: f(&a[0]).min(f(&a[2])), min_y: f(&a[1]).min(f(&a[3])), max_x: f(&a[0]).max(f(&a[2])), max_y: f(&a[1]).max(f(&a[3])) })+}++/// Where every reference designator goes: one size, upright, beside its own part (above, below,+/// right, left, slid along each side, then the corners, last on its own body when it fits there), clear of every pad by `padClearance`, of every other part's body+/// and of the labels already placed, inside the board. Bigger parts choose first. `silk` is+/// kicad_silk_state and `model` the board model, both in KiCad's frame (y down); `outline` likewise.+pub fn silk_designators(silk: &Value, model: &Value, outline: &[(f64, f64)], args: &Value) -> Value {+    let size = args.get("size").and_then(Value::as_f64).unwrap_or(1.0);+    let min_size = args.get("minSize").and_then(Value::as_f64).unwrap_or(size).min(size);+    let gap = args.get("gap").and_then(Value::as_f64).unwrap_or(0.2);+    let pad_clear = args.get("padClearance").and_then(Value::as_f64).unwrap_or(0.15);+    let only = wanted(args, "refs");+    let pads: Vec<(String, String, Rect)> = arr(model, "pads").map(|p| (txt(&p["ref"]).to_string(), txt(&p["layer"]).to_string(), pad_box(p))).collect();+    let on_side = |layer: &str, side: &str| layer == "*.Cu" || (side == "bottom") == layer.starts_with("B.");+    let parts: Vec<&Value> = arr(silk, "parts").filter(|p| p["designator"]["id"].is_string()).collect();+    // a part's box: its body (fab drawing, else courtyard), else its pads+    let body = |p: &Value| -> Option<Rect> {+        rect_of(&p["bodyBox"]).or_else(|| {+            let r = txt(&p["ref"]);+            pads.iter().filter(|q| q.0 == r).map(|q| q.2).reduce(|a, b| Rect { min_x: a.min_x.min(b.min_x), min_y: a.min_y.min(b.min_y), max_x: a.max_x.max(b.max_x), max_y: a.max_y.max(b.max_y) })+        })+    };+    let bodies: Vec<(String, String, Rect)> = parts.iter().filter_map(|p| Some((txt(&p["ref"]).to_string(), txt(&p["side"]).to_string(), body(p)?))).collect();+    let on_pads = |r: &Rect, side: &str| -> Vec<Value> {+        let grown = r.expand(pad_clear - 1e-9);+        pads.iter().filter(|q| on_side(&q.1, side) && q.2.overlaps(&grown)).map(|q| json!({"ref": q.0})).collect()+    };+    // labels as they stand, on pads or not+    let mut before = Vec::new();+    for p in &parts {+        let d = &p["designator"];+        if d["shown"] == false {+            continue;+        }+        let (x, y) = xy(&d["at"]);+        let hits = on_pads(&label_box((x, y), txt(&d["text"]), d["heightMm"].as_f64().unwrap_or(1.0)), txt(&p["side"]));+        if !hits.is_empty() {+            before.push(json!({"ref": p["ref"], "pads": hits.len()}));+        }+    }+    let edges: Vec<((f64, f64), (f64, f64))> = (0..outline.len()).map(|i| (outline[i], outline[(i + 1) % outline.len().max(1)])).collect();+    let inside = |r: &Rect| outline.len() < 3 || ([(r.min_x, r.min_y), (r.max_x, r.min_y), (r.max_x, r.max_y), (r.min_x, r.max_y)].iter().all(|c| inside_polygon(*c, outline))+        && edges.iter().all(|(a, b)| seg_rect(*a, *b, r) >= 0.2));+    let mut order: Vec<(&Value, Rect)> = parts.iter().filter(|p| only.as_ref().map_or(true, |o| o.contains(&txt(&p["ref"]).to_ascii_lowercase()))).filter_map(|p| Some((*p, body(p)?))).collect();+    order.sort_by(|a, b| (b.1.width() * b.1.height()).partial_cmp(&(a.1.width() * a.1.height())).unwrap_or(std::cmp::Ordering::Equal));+    // the silkscreen already there: every line (footprint outlines included) and the board's own texts+    let silk_clear = args.get("silkClearance").and_then(Value::as_f64).unwrap_or(0.15);+    let lines: Vec<(String, (f64, f64), (f64, f64), f64)> = arr(silk, "lines").map(|l| (txt(&l["side"]).to_string(), (f(&l["x1"]), f(&l["y1"])), (f(&l["x2"]), f(&l["y2"])), f(&l["width"]))).collect();+    let texts: Vec<(String, Rect)> = arr(silk, "texts").map(|t| (txt(&t["side"]).to_string(), label_box((f(&t["x"]), f(&t["y"])), txt(&t["text"]), t["size"].as_f64().unwrap_or(1.0)))).collect();+    let clear_of_silk = |r: &Rect, side: &str| lines.iter().all(|l| l.0 != side || seg_rect(l.1, l.2, r) >= l.3 / 2.0 + silk_clear)+        && texts.iter().all(|t| t.0 != side || !t.1.expand(silk_clear).overlaps(r));+    let mut placed_boxes: Vec<(String, Rect)> = Vec::new();+    let (mut planned, mut unplaced) = (Vec::new(), Vec::new());+    for (p, b) in order {+        let (r, side, text) = (txt(&p["ref"]), txt(&p["side"]), txt(&p["designator"]["text"]));+        let (cx, cy) = ((b.min_x + b.max_x) / 2.0, (b.min_y + b.max_y) / 2.0);+        let mut found = None;+        let mut s = size;+        while found.is_none() && s >= min_size - 1e-9 {+            let lb = label_box((0.0, 0.0), text, s);+            let (hw, hh) = (lb.width() / 2.0, lb.height() / 2.0);+            // KiCad's frame is y down: "above" is the smaller y+            // centred on each side first, then slid along it to either end, then the corners, then on+            // the part's own body (a big IC or connector) when no pad is under it+            let (ax, ay) = ((b.width() / 2.0 - hw).max(0.0), (b.height() / 2.0 - hh).max(0.0));+            let spots = [("above", cx, b.min_y - gap - hh), ("below", cx, b.max_y + gap + hh), ("right", b.max_x + gap + hw, cy), ("left", b.min_x - gap - hw, cy),+                ("above", cx - ax, b.min_y - gap - hh), ("above", cx + ax, b.min_y - gap - hh), ("below", cx - ax, b.max_y + gap + hh), ("below", cx + ax, b.max_y + gap + hh),+                ("right", b.max_x + gap + hw, cy - ay), ("right", b.max_x + gap + hw, cy + ay), ("left", b.min_x - gap - hw, cy - ay), ("left", b.min_x - gap - hw, cy + ay),+                ("above right", b.max_x + gap + hw, b.min_y - gap - hh), ("above left", b.min_x - gap - hw, b.min_y - gap - hh),+                ("below right", b.max_x + gap + hw, b.max_y + gap + hh), ("below left", b.min_x - gap - hw, b.max_y + gap + hh),+                ("on the body", cx, cy)];+            for (at, x, y) in spots {+                let rect = label_box((x, y), text, s);+                if at == "on the body" && !b.contains(&rect) {+                    continue;+                }+                let clear = on_pads(&rect, side).is_empty()+                    && bodies.iter().all(|o| o.0 == r || o.1 != side || !o.2.overlaps(&rect))+                    && placed_boxes.iter().all(|q| !q.1.expand(silk_clear).overlaps(&rect))+                    && clear_of_silk(&rect, side)+                    && inside(&rect);+                if clear {+                    found = Some((at, x, y, s, rect));+                    break;+                }+            }+            s = ((s - 0.1) * 1000.0).round() / 1000.0;+        }+        match found {+            Some((at, x, y, s, rect)) => {+                placed_boxes.push((r.to_string(), rect));+                planned.push(json!({"ref": r, "id": p["designator"]["id"], "layer": p["designator"]["layer"], "x": r3(x), "y": r3(y), "size": s, "rotation": 0.0, "side": side, "at": at}));+            }+            None => unplaced.push(json!({"ref": r, "why": format!("no spot beside it at {min_size} to {size} mm clear of pads, other parts, labels and the silkscreen")})),+        }+    }+    planned.sort_by_key(|p| placement::ref_key(txt(&p["ref"])));+    json!({"planned": planned, "unplaced": unplaced, "onPadsBefore": before, "size": size, "minSize": min_size, "gap": gap, "padClearance": pad_clear, "silkClearance": silk_clear})+}++// --------------------------------------------------------------------------- // auto_classes // --------------------------------------------------------------------------- ⋯ 223 unchanged lines ⋯     }      #[test]+    fn designators_land_beside_their_part_and_off_every_pad() {+        // R1 is a 2-pad part at the origin; C1 sits right above it, so R1's label goes below+        let silk = json!({"parts": [+            {"ref": "R1", "side": "top", "bodyBox": [-1.0, -0.5, 1.0, 0.5], "designator": {"id": "u1", "text": "R1", "at": [0.0, 0.0], "heightMm": 1.0, "layer": "F.SilkS", "shown": true}},+            {"ref": "C1", "side": "top", "bodyBox": [-1.0, -2.6, 1.0, -1.6], "designator": {"id": "u2", "text": "C1", "at": [0.0, -2.1], "heightMm": 1.0, "layer": "F.SilkS", "shown": true}}]});+        let model = json!({"pads": [+            {"ref": "R1", "x": -0.8, "y": 0.0, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0, "layer": "F.Cu"}, {"ref": "R1", "x": 0.8, "y": 0.0, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0, "layer": "F.Cu"},+            {"ref": "C1", "x": -0.8, "y": -2.1, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0, "layer": "F.Cu"}, {"ref": "C1", "x": 0.8, "y": -2.1, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0, "layer": "F.Cu"}]});+        let outline = [(-10.0, -10.0), (10.0, -10.0), (10.0, 10.0), (-10.0, 10.0)];+        let v = silk_designators(&silk, &model, &outline, &json!({}));+        // both labels sit on their own parts' pads now+        assert_eq!(v["onPadsBefore"].as_array().unwrap().len(), 2, "{v}");+        let r1 = v["planned"].as_array().unwrap().iter().find(|p| p["ref"] == "R1").unwrap().clone();+        assert_eq!(r1["at"], "below", "{v}");+        assert!(f(&r1["y"]) > 0.5 + 0.2, "{v}");+        let c1 = v["planned"].as_array().unwrap().iter().find(|p| p["ref"] == "C1").unwrap().clone();+        assert_eq!(c1["at"], "above", "{v}");+        assert!(v["unplaced"].as_array().unwrap().is_empty());+        // a board that hugs the parts: R1's label still fits, smaller, in the 1.1 mm between the two+        // parts; C1's has nowhere to go and is reported, never forced onto pads+        let tight = silk_designators(&silk, &model, &[(-1.2, -2.8), (1.2, -2.8), (1.2, 0.7), (-1.2, 0.7)], &json!({"minSize": 0.6}));+        assert_eq!((tight["planned"][0]["ref"].as_str(), tight["planned"][0]["size"].as_f64()), (Some("R1"), Some(0.7)), "{tight}");+        assert_eq!(tight["unplaced"][0]["ref"], "C1", "{tight}");+    }++    #[test]     fn rect_and_segment_distance() {         let r = Rect { min_x: 0.0, min_y: 0.0, max_x: 2.0, max_y: 1.0 };         assert_eq!(seg_rect((-1.0, 0.5), (3.0, 0.5), &r), 0.0);⋯ 1 unchanged line ⋯         assert!(inside_polygon((1.0, 1.0), &[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]));     } }+--- a/rust/crates/kicad-core/src/board_reads.rs+++ b/rust/crates/kicad-core/src/board_reads.rs@@ -1,271 +1,291 @@⋯ 87 unchanged lines ⋯     p } +/// The points that bound a graphic: its own points, except that a circle (its centre and one point+/// on it) is bounded by the centre plus and minus the radius.+fn extent_points(g: &Node) -> Vec<(f64, f64)> {+    if g.head().map_or(false, |h| h.ends_with("circle")) {+        if let (Some(c), Some(e)) = (pt(g, "center"), pt(g, "end")) {+            let r = ((e.0 - c.0).powi(2) + (e.1 - c.1).powi(2)).sqrt();+            return vec![(c.0 - r, c.1 - r), (c.0 + r, c.1 + r), (c.0 - r, c.1 + r), (c.0 + r, c.1 - r)];+        }+    }+    graphic_points(g)+}+ fn bbox(points: &[(f64, f64)]) -> Option<[f64; 4]> {     let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);     for (x, y) in points {⋯ 24 unchanged lines ⋯             fp.children()                 .iter()                 .filter(|c| c.head().map(|h| h.starts_with("fp_")).unwrap_or(false) && c.head() != Some("fp_text") && c.child_value("layer").as_deref() == Some(layer))-                .flat_map(graphic_points)+                .flat_map(extent_points)                 .map(|p| to_board((x, y), rot, p))                 .collect()         };⋯ 134 unchanged lines ⋯     }      #[test]+    fn a_round_courtyard_is_a_square_body_box() {+        let text = "(kicad_pcb (footprint \"TestPoint:TP\" (layer \"F.Cu\") (at 10 10 0) (property \"Reference\" \"TP1\" (at 0 -2 0) (layer \"F.SilkS\"))\n (fp_circle (center 0 0) (end 1 0) (layer \"F.CrtYd\"))))";+        let v = silk_state(text, false).unwrap();+        assert_eq!(v["parts"][0]["bodyBox"], json!([9.0, 9.0, 11.0, 11.0]), "{v}");+    }++    #[test]     fn silk_state_also_answers_the_standards_shape() {         // the standard's silk_state: components with name {text, x, y, size, shown}, lines with a width         let up = silk_state(&board(), true).unwrap();⋯ 4 unchanged lines ⋯         assert!(lines.iter().all(|l| l["x1"].is_number() && l["y2"].is_number() && (l["side"] == "top" || l["side"] == "bottom")), "{lines:?}");     } }+--- a/rust/crates/kicad-core/src/fp_text.rs+++ b/rust/crates/kicad-core/src/fp_text.rs@@ -1,813 +1,882 @@⋯ 128 unchanged lines ⋯     e.apply(block) } +/// Put one of a footprint's fields (`Reference`, `Value`) at a board position (KiCad's frame) at an+/// absolute angle, on `layer`, with a `size` mm font and its stroke, and shown. A field's `at` is+/// in the footprint's own (turned) frame and its angle absolute, as KiCad writes them.+pub fn set_field_pose(block: &str, field: &str, board: (f64, f64), angle: f64, layer: &str, size: f64, thickness: f64) -> FResult<String> {+    let fp = sxedit::parse(block)?;+    let (ox, oy, orot) = pose(&fp);+    let prop = fp.kids_tagged("property").find(|p| p.atom(1) == Some(field)).ok_or_else(|| format!("the footprint has no {field} field"))?;+    // board = origin + R(rot) local, with KiCad's y-down turn (x' = x cos + y sin, y' = -x sin + y cos)+    let (s, c) = orot.to_radians().sin_cos();+    let (dx, dy) = (board.0 - ox, board.1 - oy);+    let local = (dx * c - dy * s, dx * s + dy * c);+    let mut e = Edits::new();+    match prop.kid("at") {+        Some(at) => e.replace_node(at, at_text(local.0, local.1, angle)),+        None => e.insert(prop.kids[2].end, format!(" {}", at_text(local.0, local.1, angle))),+    }+    match prop.kid("layer") {+        Some(l) => e.replace_node(l, format!("(layer {})", sxedit::q(layer))),+        None => e.insert(prop.kids[2].end, format!(" (layer {})", sxedit::q(layer))),+    }+    if let Some(h) = prop.kid("hide") {+        e.remove_node(block, h);+    }+    let font = prop.kid("effects").and_then(|ef| ef.kid("font"));+    match font {+        Some(f) => {+            match f.kid("size") {+                Some(sz) => e.replace_node(sz, format!("(size {} {})", n(size), n(size))),+                None => e.insert(f.kids[0].end, format!(" (size {} {})", n(size), n(size))),+            }+            match f.kid("thickness") {+                Some(t) => e.replace_node(t, format!("(thickness {})", n(thickness))),+                None => e.insert(f.kids[0].end, format!(" (thickness {})", n(thickness))),+            }+        }+        None => match prop.kid("effects") {+            Some(ef) => e.insert(ef.kids[0].end, format!(" (font (size {} {}) (thickness {}))", n(size), n(size), n(thickness))),+            None => e.insert(prop.end - 1, format!(" (effects (font (size {} {}) (thickness {})))", n(size), n(size), n(thickness))),+        },+    }+    // an older (effects ... hide) word+    if let Some(ef) = prop.kid("effects") {+        if let Some(w) = ef.kids.iter().skip(1).find(|k| !k.quoted && k.atom.as_deref() == Some("hide")) {+            e.remove_node(block, w);+        }+    }+    e.apply(block)+}+ /// Child kinds a client-side flip handles; anything else refuses the flip. pub const FLIP_CHILDREN: &[&str] = &[     "layer", "uuid", "tstamp", "at", "descr", "tags", "property", "path", "sheetname", "sheetfile", "attr", "units", "fp_text", "fp_line", "fp_rect", "fp_circle", "fp_arc",⋯ 563 unchanged lines ⋯ }  #[cfg(test)]+mod field_pose_tests {+    use super::*;++    #[test]+    fn a_field_lands_where_asked_on_a_turned_footprint() {+        let block = "(footprint \"R:R_0603\" (layer \"F.Cu\") (at 100 50 90) (property \"Reference\" \"R1\" (at 0 -1.43 90) (layer \"F.Fab\") (hide yes) (uuid \"u\") (effects (font (size 0.5 0.5) (thickness 0.08)))))";+        let out = set_field_pose(block, "Reference", (100.0, 47.0), 0.0, "F.SilkS", 1.0, 0.15).unwrap();+        // 3 mm above the origin on the board is (3, 0) in a footprint turned 90 (KiCad's y down)+        assert!(out.contains("(at 3 0)") && out.contains("(layer \"F.SilkS\")") && out.contains("(size 1 1)") && out.contains("(thickness 0.15)") && !out.contains("hide"), "{out}");+        let fp = sxedit::parse(&out).unwrap();+        let p = fp.kids_tagged("property").next().unwrap();+        let at = p.kid("at").unwrap();+        let (lx, ly) = (at.f64_at(1).unwrap(), at.f64_at(2).unwrap());+        let (s, c) = 90f64.to_radians().sin_cos();+        assert!(((100.0 + lx * c + ly * s) - 100.0).abs() < 1e-9 && ((50.0 - lx * s + ly * c) - 47.0).abs() < 1e-9);+    }+}++#[cfg(test)] mod tests {     use super::*; ⋯ 107 unchanged lines ⋯         assert_eq!(b.footprints[0].reference, "R7");     } }+--- a/rust/crates/kicad-core/src/fpedit.rs+++ b/rust/crates/kicad-core/src/fpedit.rs@@ -1,830 +1,858 @@⋯ 256 unchanged lines ⋯         crt = union(crt, (silk_box.0 - 0.5, silk_box.1 - 0.12, silk_box.2 + 0.12, silk_box.3 + 0.12));     }     let crt = snap_out(crt, 0.01);+    // explicit graphics (footprint_from_land_pattern's layout): they replace the generated silk box,+    // fab rectangle and courtyard; layer is silk | fab | courtyard+    let given = args.get("graphics").and_then(Value::as_array).filter(|g| !g.is_empty());+    let crt = match (given, args.get("courtyardRect").and_then(Value::as_array)) {+        (Some(_), Some(c)) if c.len() == 4 => (c[0].as_f64().unwrap_or(crt.0), c[1].as_f64().unwrap_or(crt.1), c[2].as_f64().unwrap_or(crt.2), c[3].as_f64().unwrap_or(crt.3)),+        _ => crt,+    };     let f = if back { "B" } else { "F" };     let smd = pads.iter().all(|p| p.kind == "smd" || p.kind == "connect");     let attr = match args.get("attr").and_then(Value::as_str) {⋯ 26 unchanged lines ⋯     parts.push(property_text("Description", args.get("description").and_then(Value::as_str).unwrap_or(""), (0.0, 0.0), &format!("{f}.Fab"), true, text_size));     parts.push(format!("(attr {attr})"));     parts.push("(duplicate_pad_numbers_are_jumpers no)".into());+    if let Some(g) = given {+        for e in g {+            let layer = match e["layer"].as_str().unwrap_or("silk") {+                "fab" => format!("{f}.Fab"),+                "courtyard" => format!("{f}.CrtYd"),+                _ => format!("{f}.SilkS"),+            };+            let p = |k: &str| -> Result<(f64, f64), String> { xy(e.get(k)).ok_or_else(|| format!("a graphic needs {k}: [x, y]")) };+            if e.get("r").is_some() {+                let (c, r) = (p("center")?, e["r"].as_f64().unwrap_or(0.15));+                parts.push(format!("(fp_circle (center {} {}) (end {} {}) (stroke (width {}) (type solid)) (fill yes) (layer {}) (uuid \"{}\"))", num(c.0), num(c.1), num(c.0 + r / 2.0), num(c.1), num(r), quote(&layer), new_uuid()));+            } else {+                let (a, b) = (p("start")?, p("end")?);+                parts.push(format!("(fp_line (start {} {}) (end {} {}) (stroke (width {}) (type solid)) (layer {}) (uuid \"{}\"))", num(a.0), num(a.1), num(b.0), num(b.1), num(e["width"].as_f64().unwrap_or(0.12)), quote(&layer), new_uuid()));+            }+        }+    }     // Silkscreen: a box clear of every pad by 0.2 mm (never over copper), and a pin-1 dot.-    if silk_on {+    if silk_on && given.is_none() {         let sb = silk_box;         parts.push(rect_text(sb, &format!("{f}.SilkS"), 0.12));         if let Some(p1) = pads.iter().find(|p| p.number == "1") {⋯ 1 unchanged line ⋯             parts.push(format!("(fp_circle (center {} {}) (end {} {}) (stroke (width 0.12) (type solid)) (fill yes) (layer \"{f}.SilkS\") (uuid \"{}\"))", num(cx), num(cy), num(cx + 0.1), num(cy), new_uuid()));         }     }-    parts.push(rect_text(body, &format!("{f}.Fab"), 0.1));-    parts.push(rect_text(crt, &format!("{f}.CrtYd"), 0.05));+    if given.is_none() {+        parts.push(rect_text(body, &format!("{f}.Fab"), 0.1));+        parts.push(rect_text(crt, &format!("{f}.CrtYd"), 0.05));+    }     parts.push(format!("(fp_text user \"${{REFERENCE}}\" (at 0 0 0) (layer \"{f}.Fab\") (uuid \"{}\") (effects (font (size 0.5 0.5) (thickness 0.08))))", new_uuid()));     for p in &pads {         parts.push(pad_text(p));⋯ 522 unchanged lines ⋯         assert!(info("(kicad_symbol_lib)").is_err());     } }++--- a/rust/crates/kicad-core/src/ipc/silk_fields.rs+++ b/rust/crates/kicad-core/src/ipc/silk_fields.rs@@ -1,361 +1,378 @@⋯ 116 unchanged lines ⋯     Ok(out) } +/// The plan's parent footprints (by uuid) whose block in `text` carries a `units` list.+fn footprints_with_units(text: &str, plan: &fields::Plan) -> Vec<String> {+    let Ok(root) = crate::sxedit::parse(text) else { return Vec::new() };+    let wanted: HashSet<&str> = plan.edits.iter().map(|e| e.footprint_id.as_str()).collect();+    root.kids_tagged("footprint").filter(|f| f.kid("units").is_some()).filter_map(|f| f.uuid().filter(|u| wanted.contains(u)).map(str::to_string)).collect()+}+ pub fn silk_fields_batch(ctx: &Ctx, args: &Value) -> RResult<Value> {     if args["expectedRevision"]         .as_str()⋯ 74 unchanged lines ⋯     let mechanism = match direct {         Ok(()) => "field",         Err(e) if e.code == "native_field_update_unsupported" => {+            // KiCad 10.0.x drops a footprint's units block (its pin groups) when the footprint is sent+            // back over the API: refuse rather than lose it, unless the caller accepts that+            let with_units = footprints_with_units(&text, &plan);+            if !with_units.is_empty() && args["allowUnitsDrop"] != true {+                return Err(RoutingError::new("field_move_drops_units", format!("{} parent footprint(s) carry KiCad 10's units block, which KiCad drops when a field is moved through its footprint", with_units.len()))+                    .with("_hint", json!("Nothing changed. Edit the closed board instead (save, close the board in KiCad; kicad_silk_designators writes labels into the file), or pass allowUnitsDrop:true to accept losing the pin groups."))+                    .with("footprintIds", json!(with_units))+                    .with("mutated", json!(false)));+            }             let fps = footprint_updates(&s, &plan)?;             let fp_ids: HashSet<String> = plan.edits.iter().map(|e| e.footprint_id.clone()).collect();             transaction(&s, "Adom reference/value fields", || {⋯ 156 unchanged lines ⋯         assert!(apply_geometry(bold, &e).is_err());     } }+--- a/rust/crates/kicad-core/src/land_pattern.rs+++ b/rust/crates/kicad-core/src/land_pattern.rs@@ -0,0 +1,289 @@+//! Footprints from a datasheet's land pattern, the EDA Bridge Standard's footprint_from_land_pattern.+//! The caller gives the numbers the datasheet prints (pitch, pad size, row span, body); `layout`+//! makes a complete footprint: pads numbered the standard way, an exposed pad, the courtyard,+//! silkscreen kept clear of the copper with a pin-1 dot, and a fab outline with a pin-1 chamfer.+//! `pads` and `layout` are the Altium bridge's (bridge-rs/src/footprint.rs, 0.2.77) unchanged, so the+//! same numbers give the same footprint in both tools: mm, Y up, origin at the centre, pin 1 top-left.+//! `fp_create_args` turns the layout into `fpedit::create`'s arguments in KiCad's frame (y down), and+//! `vrml_box` is the extruded 3D body.+use serde_json::{json, Value};++#[derive(Debug, Clone, PartialEq)]+pub struct Pad { pub name: String, pub x: f64, pub y: f64, pub w: f64, pub h: f64, pub shape: String, pub hole: f64 }++#[derive(Debug, Default)]+pub struct Fp { pub pads: Vec<Pad>, pub lines: Vec<(&'static str, f64, f64, f64, f64, f64)>, pub discs: Vec<(&'static str, f64, f64, f64)>, pub courtyard: (f64, f64, f64, f64) }++fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+fn num(a: &Value, k: &str) -> Option<f64> { a.get(k).and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) }++/// BGA row letters: A..Z without I, O, Q, S, X, Z, then AA, AB, ...+pub fn bga_row(i: usize) -> String {+    const L: &[u8] = b"ABCDEFGHJKLMNPRTUVWY";+    if i < L.len() { (L[i] as char).to_string() } else { format!("{}{}", L[i / L.len() - 1] as char, L[i % L.len()] as char) }+}++/// The pads of a land pattern. Errors name the missing number in the words the caller used.+pub fn pads(a: &Value) -> Result<Vec<Pad>, String> {+    let family = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual").to_lowercase();+    let need = |k: &str| num(a, k).ok_or_else(|| format!("{family} needs {k} (mm)"));+    let shape = a.get("padShape").and_then(|s| s.as_str()).unwrap_or(if family == "bga" { "round" } else { "roundrect" }).to_string();+    let mut out = Vec::new();+    let mut push = |name: String, x: f64, y: f64, w: f64, h: f64| out.push(Pad { name, x: r3(x), y: r3(y), w: r3(w), h: r3(h), shape: shape.clone(), hole: 0.0 });+    match family.as_str() {+        "chip" | "two" | "2pad" => {+            let (span, pl, pw) = (need("span")?, need("padLength")?, need("padWidth")?);+            push("1".into(), -span / 2.0, 0.0, pl, pw);+            push("2".into(), span / 2.0, 0.0, pl, pw);+        }+        "dual" | "sot" => {+            let (span, pitch, pl, pw) = (need("span")?, need("pitch")?, need("padLength")?, need("padWidth")?);+            let (left, right) = if family == "sot" {+                (num(a, "leftPins").ok_or("sot needs leftPins and rightPins")? as usize, num(a, "rightPins").ok_or("sot needs leftPins and rightPins")? as usize)+            } else {+                let n = need("pins")? as usize;+                if n % 2 != 0 { return Err(format!("dual needs an even pin count, got {n}")); }+                (n / 2, n / 2)+            };+            // left column top to bottom, then right column bottom to top (counter-clockwise from pin 1 top-left)+            for i in 0..left { push((i + 1).to_string(), -span / 2.0, ((left as f64 - 1.0) / 2.0 - i as f64) * pitch, pl, pw); }+            for j in 0..right { push((left + j + 1).to_string(), span / 2.0, (j as f64 - (right as f64 - 1.0) / 2.0) * pitch, pl, pw); }+        }+        "quad" => {+            let (pitch, pl, pw) = (need("pitch")?, need("padLength")?, need("padWidth")?);+            let span_x = need("span")?;+            let span_y = num(a, "spanY").unwrap_or(span_x);+            let (ny, nx) = match (num(a, "pinsPerSideY"), num(a, "pinsPerSideX")) {+                (Some(y), Some(x)) => (y as usize, x as usize),+                _ => {+                    let n = need("pins")? as usize;+                    if n % 4 != 0 { return Err(format!("quad needs pins divisible by 4, or pinsPerSideX and pinsPerSideY; got {n}")); }+                    (n / 4, n / 4)+                }+            };+            let mut k = 1;+            // left top->bottom, bottom left->right, right bottom->top, top right->left+            for i in 0..ny { push(k.to_string(), -span_x / 2.0, ((ny as f64 - 1.0) / 2.0 - i as f64) * pitch, pl, pw); k += 1; }+            for i in 0..nx { push(k.to_string(), (i as f64 - (nx as f64 - 1.0) / 2.0) * pitch, -span_y / 2.0, pw, pl); k += 1; }+            for i in 0..ny { push(k.to_string(), span_x / 2.0, (i as f64 - (ny as f64 - 1.0) / 2.0) * pitch, pl, pw); k += 1; }+            for i in 0..nx { push(k.to_string(), ((nx as f64 - 1.0) / 2.0 - i as f64) * pitch, span_y / 2.0, pw, pl); k += 1; }+        }+        "bga" => {+            let (pitch, d) = (need("pitch")?, need("ballPad")?);+            let (rows, cols) = (need("rows")? as usize, need("cols")? as usize);+            let missing: Vec<String> = a.get("missing").and_then(|m| m.as_array()).cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(|s| s.to_uppercase())).collect();+            for r in 0..rows {+                for c in 0..cols {+                    let name = format!("{}{}", bga_row(r), c + 1);+                    if missing.contains(&name) { continue; }+                    push(name, (c as f64 - (cols as f64 - 1.0) / 2.0) * pitch, ((rows as f64 - 1.0) / 2.0 - r as f64) * pitch, d, d);+                }+            }+        }+        f => return Err(format!("family '{f}' is not one of chip, dual, sot, quad, bga")),+    }+    // the exposed (thermal) pad+    if let Some(ep) = a.get("exposedPad").filter(|e| e.is_object()) {+        let (w, h) = (num(ep, "w").ok_or("exposedPad needs w and h (mm)")?, num(ep, "h").ok_or("exposedPad needs w and h (mm)")?);+        let name = ep.get("number").map(|v| match v { Value::String(s) => s.clone(), o => o.to_string() }).unwrap_or_else(|| (out.len() + 1).to_string());+        out.push(Pad { name, x: r3(num(ep, "x").unwrap_or(0.0)), y: r3(num(ep, "y").unwrap_or(0.0)), w: r3(w), h: r3(h), shape: "rect".into(), hole: 0.0 });+    }+    Ok(out)+}++/// The parts of [a0, a1] that stay outside every blocked interval, kept when at least `min` long.+fn subtract(a0: f64, a1: f64, blocked: &mut [(f64, f64)], min: f64) -> Vec<(f64, f64)> {+    blocked.sort_by(|p, q| p.0.partial_cmp(&q.0).unwrap());+    let mut out = Vec::new();+    let mut cur = a0;+    for &(b0, b1) in blocked.iter() {+        if b1 <= cur || b0 >= a1 { continue; }+        if b0 > cur && b0 - cur >= min { out.push((cur, b0.min(a1))); }+        cur = cur.max(b1);+        if cur >= a1 { break; }+    }+    if a1 - cur >= min { out.push((cur, a1)); }+    out+}++/// The whole footprint: pads, then silkscreen (the body outline minus `clear` mm around every pad, and a pin-1 dot),+/// the fab outline with a pin-1 chamfer, and the courtyard (pads and body grown by `margin`).+pub fn layout(a: &Value) -> Result<Fp, String> {+    let family = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual").to_lowercase();+    let pads = pads(a)?;+    if pads.is_empty() { return Err("no pads".into()); }+    let body = a.get("body").cloned().unwrap_or(json!({}));+    let (bw, bh) = (num(&body, "w"), num(&body, "l").or_else(|| num(&body, "h")));+    let silk_w = num(a, "silkWidth").unwrap_or(0.15);+    let clear = num(a, "silkClearance").unwrap_or(0.2);+    let margin = num(a, "courtyard").unwrap_or(0.25);+    let ext = |p: &Pad| (p.x - p.w / 2.0, p.y - p.h / 2.0, p.x + p.w / 2.0, p.y + p.h / 2.0);+    let (mut x0, mut y0, mut x1, mut y1) = pads.iter().map(ext).fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, e| (a.0.min(e.0), a.1.min(e.1), a.2.max(e.2), a.3.max(e.3)));+    let mut fp = Fp { pads: pads.clone(), ..Default::default() };+    if let (Some(w), Some(h)) = (bw, bh) {+        let (bx0, by0, bx1, by1) = (-w / 2.0, -h / 2.0, w / 2.0, h / 2.0);+        x0 = x0.min(bx0); y0 = y0.min(by0); x1 = x1.max(bx1); y1 = y1.max(by1);+        // silkscreen: each body edge minus the stretches that come within `clear` of copper+        let keep = clear + silk_w / 2.0;+        let mut edge = |horizontal: bool, c: f64, a0: f64, a1: f64| {+            let mut blocked: Vec<(f64, f64)> = pads.iter().filter_map(|p| {+                let (px0, py0, px1, py1) = ext(p);+                if horizontal { (c > py0 - keep && c < py1 + keep).then_some((px0 - keep, px1 + keep)) } else { (c > px0 - keep && c < px1 + keep).then_some((py0 - keep, py1 + keep)) }+            }).collect();+            for (s0, s1) in subtract(a0, a1, &mut blocked, 0.2) {+                if horizontal { fp.lines.push(("overlay", r3(s0), r3(c), r3(s1), r3(c), silk_w)); } else { fp.lines.push(("overlay", r3(c), r3(s0), r3(c), r3(s1), silk_w)); }+            }+        };+        // the outline is drawn just outside the body so it is not hidden under it+        let o = silk_w / 2.0;+        edge(true, by1 + o, bx0 - o, bx1 + o);+        edge(true, by0 - o, bx0 - o, bx1 + o);+        edge(false, bx0 - o, by0 - o, by1 + o);+        edge(false, bx1 + o, by0 - o, by1 + o);+        // fab: the body with its pin-1 corner (top-left) cut+        let ch = (w.min(h) * 0.25).min(1.0);+        let fab = [(bx0 + ch, by1), (bx1, by1), (bx1, by0), (bx0, by0), (bx0, by1 - ch), (bx0 + ch, by1)];+        for k in 0..fab.len() - 1 { fp.lines.push(("fab", r3(fab[k].0), r3(fab[k].1), r3(fab[k + 1].0), r3(fab[k + 1].1), 0.1)); }+    }+    // pin-1 dot: beside pin 1 on the outside, clear of its pad; a two-pad part only when polarized+    let polar = a.get("polarized").and_then(|b| b.as_bool()).unwrap_or(false);+    if family != "chip" || polar {+        let p1 = &pads[0];+        let r = 0.15;+        let (dx, dy) = if family == "bga" { (x0 - clear - r, y1 + clear + r) } else { (p1.x - p1.w / 2.0 - clear - r - silk_w, p1.y) };+        fp.discs.push(("overlay", r3(dx), r3(dy), r));+        x0 = x0.min(dx - r);+        y1 = y1.max(dy + r);+    }+    // courtyard: everything grown by the margin, on a 0.01 mm grid+    let g = |v: f64, up: bool| if up { (v * 100.0).ceil() / 100.0 } else { (v * 100.0).floor() / 100.0 };+    let c = (g(x0 - margin, false), g(y0 - margin, false), g(x1 + margin, true), g(y1 + margin, true));+    fp.courtyard = c;+    for (a0, b0, a1, b1) in [(c.0, c.1, c.2, c.1), (c.2, c.1, c.2, c.3), (c.2, c.3, c.0, c.3), (c.0, c.3, c.0, c.1)] {+        fp.lines.push(("courtyard", a0, b0, a1, b1, 0.05));+    }+    Ok(fp)+}++/// A plain description: "SOIC-8 dual, 1.27 mm pitch, 8 pads (from the datasheet land pattern)".+pub fn describe(a: &Value, fp: &Fp) -> String {+    let fam = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual");+    let pitch = num(a, "pitch").map(|p| format!(", {p} mm pitch")).unwrap_or_default();+    format!("{} {}{}, {} pads (from the datasheet land pattern)", a.get("name").and_then(|n| n.as_str()).unwrap_or(""), fam, pitch, fp.pads.len())+}++/// `fpedit::create`'s arguments for the layout: KiCad's footprint frame (y down), pads with their+/// numbers and shapes, the layout's own silkscreen, fab and courtyard as explicit graphics.+pub fn fp_create_args(a: &Value, fp: &Fp) -> Value {+    let shape = |s: &str| match s { "round" | "circle" => "circle", "oval" => "oval", "rect" => "rect", _ => "roundrect" };+    let pads: Vec<Value> = fp.pads.iter().map(|p| json!({"number": p.name, "type": "smd", "shape": shape(&p.shape), "size": [p.w, p.h], "at": [p.x, r3(-p.y)]})).collect();+    fn layer(l: &str) -> &str { if l == "overlay" { "silk" } else { l } }+    let mut graphics: Vec<Value> = fp.lines.iter().map(|(l, x0, y0, x1, y1, w)| json!({"layer": layer(l), "start": [x0, r3(-y0)], "end": [x1, r3(-y1)], "width": w})).collect();+    graphics.extend(fp.discs.iter().map(|(l, x, y, r)| json!({"layer": layer(l), "center": [x, r3(-y)], "r": r})));+    let c = fp.courtyard;+    let body = a.get("body").cloned().unwrap_or(json!({}));+    let mut out = json!({"pads": pads, "graphics": graphics, "courtyardRect": [c.0, r3(-c.3), c.2, r3(-c.1)], "attr": "smd"});+    if let (Some(w), Some(l)) = (num(&body, "w"), num(&body, "l").or_else(|| num(&body, "h"))) {+        out["bodySize"] = json!([w, l]);+    }+    out+}++/// A box the size of the body, `standoff` above the board, as a VRML 2.0 file KiCad shows (mm, in+/// KiCad's 3D units of 2.54 mm per unit; z up from the board).+pub fn vrml_box(w: f64, l: f64, height: f64, standoff: f64) -> String {+    let u = |mm: f64| mm / 2.54;+    let h = (height - standoff).max(0.01);+    format!("#VRML V2.0 utf8\n# body box from the datasheet land pattern: {w} x {l} x {height} mm\nTransform {{\n  translation 0 0 {}\n  children [\n    Shape {{\n      appearance Appearance {{ material Material {{ diffuseColor 0.15 0.15 0.15 }} }}\n      geometry Box {{ size {} {} {} }}\n    }}\n  ]\n}}\n",+        r6(u(standoff + h / 2.0)), r6(u(w)), r6(u(l)), r6(u(h)))+}++fn r6(v: f64) -> f64 {+    (v * 1e6).round() / 1e6+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn soic8_numbers_down_the_left_and_up_the_right() {+        let p = pads(&json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6})).unwrap();+        assert_eq!((p[0].name.as_str(), p[0].x, p[0].y), ("1", -2.7, 1.905));+        assert_eq!((p[3].name.as_str(), p[3].y), ("4", -1.905));+        assert_eq!((p[4].name.as_str(), p[4].x, p[4].y), ("5", 2.7, -1.905));+        assert_eq!((p[7].name.as_str(), p[7].y), ("8", 1.905));+        assert_eq!((p[0].w, p[0].h), (1.55, 0.6));+    }++    #[test]+    fn quad_goes_counter_clockwise_and_turns_the_top_and_bottom_pads() {+        let p = pads(&json!({"family": "quad", "pins": 16, "pitch": 0.5, "span": 3.0, "padLength": 0.8, "padWidth": 0.25, "exposedPad": {"w": 1.7, "h": 1.7}})).unwrap();+        assert_eq!(p.len(), 17);+        assert_eq!((p[0].x, p[0].y), (-1.5, 0.75));      // 1: left, top+        assert_eq!((p[4].x, p[4].y, p[4].w), (-0.75, -1.5, 0.25)); // 5: bottom, left end, turned+        assert_eq!((p[8].x, p[8].y), (1.5, -0.75));      // 9: right, bottom+        assert_eq!((p[12].x, p[12].y), (0.75, 1.5));     // 13: top, right end+        assert_eq!((p[16].name.as_str(), p[16].shape.as_str()), ("17", "rect"));+    }++    #[test]+    fn sot23_has_two_pins_left_and_one_right() {+        let p = pads(&json!({"family": "sot", "leftPins": 2, "rightPins": 1, "pitch": 1.9, "span": 2.2, "padLength": 1.0, "padWidth": 0.6})).unwrap();+        assert_eq!(p.iter().map(|q| (q.name.clone(), q.x, q.y)).collect::<Vec<_>>(), vec![("1".into(), -1.1, 0.95), ("2".into(), -1.1, -0.95), ("3".into(), 1.1, 0.0)]);+    }++    #[test]+    fn bga_rows_skip_confusable_letters() {+        assert_eq!((bga_row(0), bga_row(7), bga_row(8), bga_row(19), bga_row(20)), ("A".into(), "H".into(), "J".into(), "Y".into(), "AA".into()));+        let p = pads(&json!({"family": "bga", "rows": 3, "cols": 3, "pitch": 0.8, "ballPad": 0.4, "missing": ["b2"]})).unwrap();+        assert_eq!(p.len(), 8);+        assert_eq!((p[0].name.as_str(), p[0].x, p[0].y), ("A1", -0.8, 0.8));+    }++    #[test]+    fn silkscreen_keeps_clear_of_copper_and_the_courtyard_holds_everything() {+        let a = json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9}});+        let fp = layout(&a).unwrap();+        let ext = |p: &Pad| (p.x - p.w / 2.0, p.y - p.h / 2.0, p.x + p.w / 2.0, p.y + p.h / 2.0);+        for &(l, x0, y0, x1, y1, w) in fp.lines.iter().filter(|l| l.0 == "overlay") {+            let _ = l;+            for p in &fp.pads {+                let (px0, py0, px1, py1) = ext(p);+                // distance from the segment (axis-aligned) to the pad box, minus half the stroke+                let dx = (px0 - x0.max(x1)).max(x0.min(x1) - px1).max(0.0);+                let dy = (py0 - y0.max(y1)).max(y0.min(y1) - py1).max(0.0);+                assert!((dx * dx + dy * dy).sqrt() - w / 2.0 >= 0.199, "silk {x0},{y0}-{x1},{y1} too close to pad {}", p.name);+            }+        }+        // a SOIC's body sides sit under its pads: only the top and bottom edges survive+        assert_eq!(fp.lines.iter().filter(|l| l.0 == "overlay").count(), 2);+        let c = fp.courtyard;+        assert!(fp.pads.iter().all(|p| { let (a0, b0, a1, b1) = ext(p); a0 >= c.0 && b0 >= c.1 && a1 <= c.2 && b1 <= c.3 }));+        assert_eq!(fp.discs.len(), 1);+        assert!(fp.discs[0].1 < -2.7 - 0.775);+    }++    #[test]+    fn a_missing_number_is_named() {+        let e = pads(&json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padWidth": 0.6})).unwrap_err();+        assert!(e.contains("padLength"));+    }++    #[test]+    fn the_kicad_footprint_is_y_down_with_the_layouts_own_graphics() {+        let a = json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9, "height": 1.75}});+        let fp = layout(&a).unwrap();+        let k = fp_create_args(&a, &fp);+        // pin 1 top-left: y up 1.905 is KiCad's y down -1.905+        assert_eq!(k["pads"][0], json!({"number": "1", "type": "smd", "shape": "roundrect", "size": [1.55, 0.6], "at": [-2.7, -1.905]}));+        let c = k["courtyardRect"].as_array().unwrap();+        assert!(c[1].as_f64().unwrap() < c[3].as_f64().unwrap());+        assert!(k["graphics"].as_array().unwrap().iter().any(|g| g["layer"] == "fab") && k["graphics"].as_array().unwrap().iter().any(|g| g["r"].is_number()));+        let (text, info) = crate::fpedit::create("SOIC-8_test", &k).unwrap();+        assert_eq!(info["pads"], 8);+        assert!(text.contains("(fp_line") && text.contains("F.CrtYd") && text.contains("F.Fab") && !text.contains("fp_rect"), "{text}");+        assert!(vrml_box(3.9, 4.9, 1.75, 0.1).contains("geometry Box { size 1.535433 1.929134 0.649606 }"));+    }+}+--- a/rust/crates/kicad-core/src/lib.rs+++ b/rust/crates/kicad-core/src/lib.rs@@ -1,67 +1,70 @@⋯ 1 unchanged line ⋯ pub mod cli; pub mod detect; pub mod netlist;+pub mod order; pub mod lint; pub mod libraries; pub mod settings;⋯ 41 unchanged lines ⋯ pub mod project; pub mod fpedit; pub mod symedit;+pub mod land_pattern; pub mod libops; pub mod board_checks; pub mod board_reads;⋯ 10 unchanged lines ⋯ pub mod sch_review; pub mod sch_render; pub mod sch_sheets;+--- a/rust/crates/kicad-core/src/netlist.rs+++ b/rust/crates/kicad-core/src/netlist.rs@@ -1,781 +1,811 @@⋯ 39 unchanged lines ⋯     pub tstamp: String,     #[serde(skip)]     pub sheet_tstamps: String,+    /// The symbol's fields (`<fields><field name="MPN">...`) and flags (`<property name="dnp"/>`,+    /// `exclude_from_bom`), for the BOM; not part of the netlist verbs' replies.+    #[serde(skip)]+    pub fields: Vec<(String, String)>,+    #[serde(skip)]+    pub properties: Vec<(String, String)>, }  #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]⋯ 148 unchanged lines ⋯                 *text_target = Some((name.clone(), String::new()));             }         }+        // a field is a grandchild (comp > fields > field); a property is a child+        "field" if parent == "fields" && comp.as_ref().map(|c| c.0 + 2 == depth).unwrap_or(false) => {+            *text_target = Some((format!("field:{}", attr(e, "name")), String::new()));+        }+        "property" if comp_child => {+            if let Some((_, c, _)) = comp.as_mut() {+                c.properties.push((attr(e, "name"), attr(e, "value")));+            }+        }         "net" if net.is_none() => {             *net = Some((depth, Net { name: attr(e, "name"), code: attr(e, "code"), nodes: Vec::new() }));         }⋯ 16 unchanged lines ⋯     let depth = stack.len();     let Some(name) = stack.pop() else { return };     if let Some((child, text)) = text_target.take() {-        if child == name {+        if child == name || (name == "field" && child.starts_with("field:")) {             if let Some((_, c, _)) = comp.as_mut() {                 let t = text.trim().to_string();                 match child.as_str() {⋯ 1 unchanged line ⋯                     "footprint" => c.footprint = t,                     "description" => c.description = t,                     "tstamps" => c.tstamp = t,+                    f if f.starts_with("field:") => c.fields.push((f["field:".len()..].to_string(), t)),                     _ => {}                 }             }⋯ 423 unchanged lines ⋯     }      #[test]+    fn keeps_fields_and_flags_for_the_bom() {+        let xml = r#"<export><components><comp ref="C1"><value>100n</value><footprint>Capacitor_SMD:C_0402_1005Metric</footprint>+            <fields><field name="Footprint">Capacitor_SMD:C_0402_1005Metric</field><field name="MPN">GRM155R71C104KA88D</field><field name="LCSC">C1525</field><field name="Empty"/></fields>+            <libsource lib="Device" part="C" description="Unpolarized capacitor"/><property name="dnp"/><property name="Sheetname" value="Root"/></comp></components><nets/></export>"#;+        let c = parse_kicadxml(xml).unwrap().components.remove(0);+        assert!(c.fields.contains(&("MPN".to_string(), "GRM155R71C104KA88D".to_string())), "{:?}", c.fields);+        assert!(c.fields.contains(&("LCSC".to_string(), "C1525".to_string())));+        assert!(c.fields.contains(&("Empty".to_string(), String::new())));+        assert!(c.properties.iter().any(|(k, _)| k == "dnp"));+        assert!(c.properties.contains(&("Sheetname".to_string(), "Root".to_string())));+    }++    #[test]     fn parses_nets_and_nodes() {         let m = model();         assert_eq!(m.nets.len(), 5);⋯ 120 unchanged lines ⋯         assert!(RAW_FORMATS.windows(2).all(|w| w[0] < w[1]), "RAW_FORMATS is sorted, matching Python's allowed list");     } }+--- a/rust/crates/kicad-core/src/order.rs+++ b/rust/crates/kicad-core/src/order.rs@@ -0,0 +1,413 @@+//! The EDA Bridge Standard's bom, assembly_check and jlcpcb_order, for KiCad. The BOM's grouping,+//! the parameter names matched across libraries, the files written and JLCPCB's two assembly+//! tiers are ported from the Altium bridge (bridge-rs/src/bom.rs and assembly.rs, 0.2.77), so a+//! KiCad BOM and an Altium BOM read the same. Pure: the verbs read the netlist and the board.+//!+//! A KiCad part is its schematic symbol (the netlist's fields, `dnp` and `exclude_from_bom`+//! flags) merged with its board footprint (footprint, placement, the board's own fields and+//! attributes). Without a schematic the board's footprints are the parts.++use std::collections::{BTreeMap, HashMap};++use serde_json::{json, Map, Value};++use crate::fab;+use crate::netlist::Netlist;++pub const MPN: &[&str] = &["MPN", "Manufacturer Part Number", "Manufacturer Part Number 1", "Mfr Part #", "Mfr. Part Number", "Mfr. Part #", "MFR PN", "MFR_PN", "MANUFACTURER_PART_NUMBER", "Manufacturer_Part_Number", "Part Number", "PartNumber", "Part_Number"];+pub const MFR: &[&str] = &["Manufacturer", "Manufacturer 1", "Manufacturer Name", "Manufacturer_Name", "Mfr", "MFR", "MANUFACTURER"];+pub const MOUSER: &[&str] = &["Mouser Part #", "Mouser Part Number", "Mouser PN", "MOUSER_PN", "Mouser"];+pub const DIGIKEY: &[&str] = &["DigiKey Part #", "Digi-Key Part Number", "DigiKey Part Number", "Digikey PN", "DIGIKEY_PN", "DigiKey", "Digi-Key_PN"];+pub const LCSC: &[&str] = &["LCSC Part #", "LCSC", "LCSC Part", "LCSC_PN", "LCSC#", "JLCPCB Part #", "JLC Part #", "JLCPCB Part", "JLCPCB", "JLC"];+const PACKAGE: &[&str] = &["Package", "Case/Package", "PACKAGE"];+const DESCRIPTION: &[&str] = &["Description", "DESCRIPTION"];+const DNP: &[&str] = &["DNP", "Do Not Populate", "DNF", "Do Not Fit"];++fn param(params: &[(String, String)], names: &[&str]) -> String {+    for n in names {+        if let Some((_, v)) = params.iter().find(|(k, v)| k.eq_ignore_ascii_case(n) && !v.trim().is_empty() && !v.trim().starts_with('=')) {+            return v.trim().to_string();+        }+    }+    String::new()+}++fn truthy(v: &str) -> bool {+    matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "y" | "dnp" | "x")+}++/// R1 < R2 < R10 < R10A.+pub fn natural_key(d: &str) -> (String, u64, String) {+    fab::ref_key(&d.to_ascii_uppercase())+}++/// One placed part: its symbol's fields and its footprint's placement.+#[derive(Debug, Clone, Default)]+pub struct Part {+    pub designator: String,+    pub value: String,+    pub description: String,+    pub lib_ref: String,+    pub params: Vec<(String, String)>,+    pub footprint: String,+    /// (x, y in the standard's frame, rotation, "top" | "bottom")+    pub placed: Option<(f64, f64, f64, String)>,+    pub dnp: bool,+    /// exclude_from_bom (schematic or board) or board_only: not a bought part+    pub excluded: bool,+}++impl Part {+    pub fn get(&self, names: &[&str]) -> String {+        param(&self.params, names)+    }+    pub fn mpn(&self) -> String {+        self.get(MPN)+    }+    /// Test pads, fiducials and logos carry an MPN or manufacturer that says nothing is bought.+    pub fn not_purchased(&self) -> bool {+        [self.mpn(), self.get(MFR)].iter().any(|v| {+            let m = v.trim().to_ascii_lowercase();+            m.starts_with("none") || m == "n/a" || m == "na" || m == "-" || m.contains("pcb copper") || m.contains("not purchased") || m.contains("no part")+        })+    }+}++/// The parts, from the schematic's netlist merged with the board's footprints (`placed` by+/// reference, from the board model), or the board alone. Returns the parts and which designators+/// are only on the sheets or only on the board.+pub fn parts(nl: Option<&Netlist>, board: &[fab::Part], placed: &HashMap<String, (f64, f64, f64, String)>) -> (Vec<Part>, Value) {+    let on_board: HashMap<&str, &fab::Part> = board.iter().filter(|b| !b.reference.is_empty()).map(|b| (b.reference.as_str(), b)).collect();+    let board_part = |b: &fab::Part| -> Part {+        let params: Vec<(String, String)> = b.props.iter().map(|(k, v)| (k.clone(), v.clone())).collect();+        let dnp = b.dnp() || DNP.iter().any(|n| truthy(&param(&params, &[n])));+        Part { designator: b.reference.clone(), value: b.value.clone(), description: param(&params, DESCRIPTION), lib_ref: String::new(), footprint: b.footprint.clone(),+            placed: placed.get(&b.reference).cloned(), dnp, excluded: !b.in_bom(), params }+    };+    let Some(nl) = nl else {+        let mut out: Vec<Part> = board.iter().filter(|b| !b.reference.is_empty() && !b.reference.starts_with('#')).map(board_part).collect();+        out.sort_by_key(|p| natural_key(&p.designator));+        return (out, json!({"sheetNotOnBoard": [], "boardNotOnSheet": [], "from": "board"}));+    };+    let mut out = Vec::new();+    let mut sheet_only = Vec::new();+    for c in &nl.components {+        if c.reference.starts_with('#') {+            continue;+        }+        let flag = |k: &str| c.properties.iter().any(|(n, _)| n.eq_ignore_ascii_case(k));+        let mut params: Vec<(String, String)> = c.fields.clone();+        params.push(("Value".into(), c.value.clone()));+        let b = on_board.get(c.reference.as_str());+        if let Some(b) = b {+            // the board's fields fill what the sheet left empty+            for (k, v) in &b.props {+                if param(&params, &[k]).is_empty() {+                    params.push((k.clone(), v.clone()));+                }+            }+        } else if !flag("exclude_from_board") {+            sheet_only.push(json!(c.reference));+        }+        let dnp = flag("dnp") || b.map(|b| b.dnp()).unwrap_or(false) || DNP.iter().any(|n| truthy(&param(&params, &[n])));+        let excluded = flag("exclude_from_bom") || b.map(|b| !b.in_bom()).unwrap_or(false);+        out.push(Part {+            designator: c.reference.clone(), value: c.value.clone(), description: c.description.clone(), lib_ref: c.libsource.clone(),+            footprint: b.map(|b| b.footprint.clone()).filter(|f| !f.is_empty()).unwrap_or_else(|| c.footprint.clone()),+            placed: placed.get(&c.reference).cloned(), dnp, excluded, params,+        });+    }+    let on_sheet: Vec<&str> = nl.components.iter().map(|c| c.reference.as_str()).collect();+    let mut board_only: Vec<String> = board.iter().filter(|b| !b.reference.is_empty() && !b.reference.starts_with('#') && !on_sheet.contains(&b.reference.as_str()) && b.in_bom())+        .map(|b| b.reference.clone()).collect();+    board_only.sort_by_key(|d| natural_key(d));+    out.sort_by_key(|p| natural_key(&p.designator));+    (out, json!({"sheetNotOnBoard": sheet_only, "boardNotOnSheet": board_only, "from": "schematic"}))+}++const FIELDS: &[(&str, &[&str])] = &[("mpn", MPN), ("manufacturer", MFR), ("mouser", MOUSER), ("digikey", DIGIKEY), ("lcsc", LCSC)];++/// The footprint's name without its library (`Capacitor_SMD:C_0402_1005Metric` is `C_0402_1005Metric`).+fn fp_name(f: &str) -> &str {+    f.rsplit(':').next().unwrap_or(f)+}++/// Group the fitted parts: one line per manufacturer part number, else per value and footprint.+/// Returns (lines, checks).+pub fn group(parts: &[Part]) -> (Vec<Value>, Value) {+    let mut groups: BTreeMap<String, Vec<&Part>> = BTreeMap::new();+    for p in parts.iter().filter(|p| !p.dnp && !p.excluded) {+        let m = p.mpn();+        let key = if m.is_empty() { format!("~{}|{}", p.value.to_ascii_uppercase(), p.footprint.to_ascii_uppercase()) } else { format!("{}|{}", m.to_ascii_uppercase(), p.get(MFR).to_ascii_uppercase()) };+        groups.entry(key).or_default().push(p);+    }+    let mut lines = Vec::new();+    let mut inconsistent = Vec::new();+    for (_, mut g) in groups {+        g.sort_by_key(|p| natural_key(&p.designator));+        let refs: Vec<String> = g.iter().map(|p| p.designator.clone()).collect();+        let mut line = Map::new();+        line.insert("quantity".into(), json!(g.len()));+        line.insert("designators".into(), json!(refs));+        for (name, names) in FIELDS {+            let vals: Vec<String> = g.iter().map(|p| p.get(names)).collect();+            let mut distinct: Vec<&String> = vals.iter().filter(|v| !v.is_empty()).collect();+            distinct.sort();+            distinct.dedup();+            if distinct.len() > 1 {+                inconsistent.push(json!({"field": name, "designators": refs, "values": distinct}));+            }+            line.insert((*name).into(), json!(distinct.first().map(|s| s.as_str()).unwrap_or("")));+        }+        let first = g[0];+        let mut fps: Vec<&str> = g.iter().map(|p| p.footprint.as_str()).filter(|f| !f.is_empty()).collect();+        fps.sort();+        fps.dedup();+        if fps.len() > 1 {+            inconsistent.push(json!({"field": "footprint", "designators": refs, "values": fps}));+        }+        let fp = fps.first().copied().unwrap_or("");+        let package = first.get(PACKAGE);+        line.insert("value".into(), json!(first.value));+        line.insert("comment".into(), json!(first.value));+        line.insert("description".into(), json!(if first.description.is_empty() { first.get(DESCRIPTION) } else { first.description.clone() }));+        line.insert("footprint".into(), json!(fp));+        line.insert("package".into(), json!(if package.is_empty() { fp_name(fp).to_string() } else { package }));+        line.insert("libReference".into(), json!(first.lib_ref));+        line.insert("purchase".into(), json!(!first.not_purchased()));+        lines.push(Value::Object(line));+    }+    lines.sort_by_key(|l| natural_key(l["designators"][0].as_str().unwrap_or("")));+    let fitted: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !p.excluded && !p.not_purchased()).collect();+    let sorted = |mut v: Vec<String>| { v.sort_by_key(|d| natural_key(d)); v };+    let missing = |names: &[&str]| sorted(fitted.iter().filter(|p| p.get(names).is_empty()).map(|p| p.designator.clone()).collect());+    let dnp = sorted(parts.iter().filter(|p| p.dnp).map(|p| p.designator.clone()).collect());+    let excluded = sorted(parts.iter().filter(|p| !p.dnp && p.excluded).map(|p| p.designator.clone()).collect());+    let not_purchased = sorted(parts.iter().filter(|p| !p.dnp && !p.excluded && p.not_purchased()).map(|p| p.designator.clone()).collect());+    // JLCPCB places only what it can source by LCSC number: every other purchased line is consigned or blocks+    let no_lcsc: Vec<Value> = lines.iter().filter(|l| l["purchase"] == json!(true) && l["lcsc"].as_str().unwrap_or("").is_empty())+        .map(|l| json!({"designators": l["designators"], "mpn": l["mpn"], "value": l["value"]})).collect();+    let checks = json!({+        "missingMpn": missing(MPN), "missingManufacturer": missing(MFR),+        "missingPartNumbers": {"mouser": missing(MOUSER), "digikey": missing(DIGIKEY), "lcsc": missing(LCSC)},+        "inconsistent": inconsistent, "dnp": dnp, "excludedKinds": excluded, "notPurchased": not_purchased,+        "jlcpcb": {"ready": no_lcsc.is_empty(), "linesWithoutLcsc": no_lcsc},+    });+    (lines, checks)+}++fn csv_cell(s: &str) -> String {+    if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }+}++fn csv(rows: &[Vec<String>]) -> String {+    rows.iter().map(|r| r.iter().map(|c| csv_cell(c)).collect::<Vec<_>>().join(",")).collect::<Vec<_>>().join("\r\n") + "\r\n"+}++fn st(v: &Value) -> String {+    v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())+}++/// The files, as (name, text): bom.csv, JLCPCB's BOM and CPL, the Mouser and DigiKey BOM-tool+/// files. `boards` multiplies the quantities of the ordering files.+pub fn files(lines: &[Value], parts: &[Part], boards: u64) -> Vec<(String, String)> {+    let refs = |l: &Value| l["designators"].as_array().map(|a| a.iter().map(st).collect::<Vec<_>>().join(",")).unwrap_or_default();+    let mut out = Vec::new();+    let mut rows = vec![["Line", "Quantity", "Designators", "Value", "Comment", "Description", "Footprint", "Package", "MPN", "Manufacturer", "Mouser Part #", "DigiKey Part #", "LCSC Part #"].map(String::from).to_vec()];+    for (i, l) in lines.iter().enumerate() {+        rows.push(vec![(i + 1).to_string(), st(&l["quantity"]), refs(l), st(&l["value"]), st(&l["comment"]), st(&l["description"]), st(&l["footprint"]), st(&l["package"]), st(&l["mpn"]), st(&l["manufacturer"]), st(&l["mouser"]), st(&l["digikey"]), st(&l["lcsc"])]);+    }+    out.push(("bom.csv".to_string(), csv(&rows)));+    // JLCPCB assembly: their BOM and CPL columns; positions as KiCad's own position file gives them (mm, y up)+    let mut j = vec![["Comment", "Designator", "Footprint", "LCSC Part #"].map(String::from).to_vec()];+    for l in lines.iter().filter(|l| l["purchase"] != json!(false)) {+        j.push(vec![st(&l["value"]), refs(l), fp_name(&st(&l["footprint"])).to_string(), st(&l["lcsc"])]);+    }+    out.push(("bom-jlcpcb.csv".to_string(), csv(&j)));+    let mut c = vec![["Designator", "Mid X", "Mid Y", "Layer", "Rotation"].map(String::from).to_vec()];+    let mut placed: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !p.excluded && !p.not_purchased() && p.placed.is_some()).collect();+    placed.sort_by_key(|p| natural_key(&p.designator));+    for p in placed {+        let (x, y, r, side) = p.placed.clone().unwrap();+        c.push(vec![p.designator.clone(), format!("{x:.4}mm"), format!("{y:.4}mm"), if side == "bottom" { "Bottom".into() } else { "Top".into() }, format!("{}", (r.rem_euclid(360.0) * 1000.0).round() / 1000.0)]);+    }+    out.push(("cpl-jlcpcb.csv".to_string(), csv(&c)));+    for (name, field, header) in [("bom-mouser.csv", "mouser", "Mouser Part Number"), ("bom-digikey.csv", "digikey", "Digi-Key Part Number")] {+        let mut m = vec![vec![header.to_string(), "Manufacturer Part Number".into(), "Manufacturer".into(), "Quantity".into(), "Customer Reference".into()]];+        for l in lines.iter().filter(|l| l["purchase"] != json!(false) && (!st(&l[field]).is_empty() || !st(&l["mpn"]).is_empty())) {+            m.push(vec![st(&l[field]), st(&l["mpn"]), st(&l["manufacturer"]), (l["quantity"].as_u64().unwrap_or(0) * boards.max(1)).to_string(), refs(l)]);+        }+        out.push((name.to_string(), csv(&m)));+    }+    out+}++// ---------------------------------------------------------------------------+// assembly_check: JLCPCB's PCBA tiers (jlcpcb.com/capabilities/pcb-assembly-capabilities, read 2026-10-26)+// ---------------------------------------------------------------------------++fn f(v: &Value) -> f64 {+    v.as_f64().unwrap_or(0.0)+}++fn r3(v: f64) -> f64 {+    (v * 1000.0).round() / 1000.0+}++/// What the board offers the assembly line, from the board model. Pure.+pub fn facts(st: &Value) -> Value {+    let pads = st["pads"].as_array().cloned().unwrap_or_default();+    let comps = st["components"].as_array().cloned().unwrap_or_default();+    let mut by: BTreeMap<String, Vec<(f64, f64)>> = BTreeMap::new();+    for p in pads.iter().filter(|p| f(&p["drill"]) == 0.0) {+        if let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) {+            by.entry(r.to_string()).or_default().push((f(&p["x"]), f(&p["y"])));+        }+    }+    // the finest pitch: the closest pad centres within one part with 3 or more SMD pads;+    // the smallest two-pad part: its pad centres (0402 about 1.0 mm, 0201 0.5, 01005 0.35)+    let mut pitch: Option<(f64, String)> = None;+    let mut chip: Option<(f64, String)> = None;+    for (r, ps) in &by {+        let mut d = f64::MAX;+        for i in 0..ps.len() {+            for j in i + 1..ps.len() {+                d = d.min(((ps[i].0 - ps[j].0).powi(2) + (ps[i].1 - ps[j].1).powi(2)).sqrt());+            }+        }+        if ps.len() >= 3 && pitch.as_ref().map(|p| d < p.0).unwrap_or(true) {+            pitch = Some((d, r.clone()));+        }+        if ps.len() == 2 && chip.as_ref().map(|p| d < p.0).unwrap_or(true) {+            chip = Some((d, r.clone()));+        }+    }+    let named = |c: &Value, pat: &[&str]| { let p = c["pattern"].as_str().or(c["footprint"].as_str()).unwrap_or("").to_ascii_uppercase(); pat.iter().any(|x| p.contains(x)) };+    let tiny: Vec<Value> = comps.iter().filter(|c| named(c, &["0201", "01005", "0603M", "0402M"])).map(|c| json!({"ref": c["ref"], "footprint": c["footprint"]})).collect();+    let fid = |s: &str| s.to_ascii_uppercase().starts_with("FID");+    let fiducials = comps.iter().filter(|c| c["ref"].as_str().map(fid).unwrap_or(false) || named(c, &["FIDUCIAL"])).count();+    let bottom: Vec<Value> = comps.iter().filter(|c| c["side"] == "bottom").map(|c| c["ref"].clone()).collect();+    let tht: std::collections::BTreeSet<String> = pads.iter().filter(|p| f(&p["drill"]) > 0.0 && p["ref"].as_str().map(|r| !r.is_empty()).unwrap_or(false))+        .filter_map(|p| p["ref"].as_str().map(String::from)).collect();+    json!({"parts": comps.len(), "bottomSideParts": bottom, "finestPitch": pitch.map(|(d, r)| json!({"mm": r3(d), "ref": r})),+           "smallestChip": chip.map(|(d, r)| json!({"padCentresMm": r3(d), "ref": r})), "tinyFootprints": tiny, "fiducials": fiducials,+           "throughHoleParts": tht.into_iter().collect::<Vec<_>>()})+}++fn rule(name: &str, ok: Option<bool>, detail: String) -> Value {+    json!({"rule": name, "ok": ok, "detail": detail})+}++/// The board against one tier ("economic" or "standard"). `size` is [w, h] mm (the board, or the+/// panel it ships in), `lcsc_missing` the purchased lines with no LCSC number (None: not checked).+pub fn tier(name: &str, fa: &Value, size: (f64, f64), thick: Option<f64>, layers: Option<i64>, lcsc_missing: Option<usize>, panel: bool) -> Value {+    let eco = name == "economic";+    let mut rs = Vec::new();+    let (w, h) = (size.0.min(size.1), size.0.max(size.1));+    let (lo, hi): ((f64, f64), (f64, f64)) = match (eco, panel) {+        (true, false) => ((10.0, 10.0), (470.0, 500.0)),+        (true, true) => ((10.0, 10.0), (250.0, 250.0)),+        (false, false) => ((70.0, 70.0), (460.0, 500.0)),+        (false, true) => ((70.0, 70.0), (250.0, 250.0)),+    };+    let ok = w >= lo.0 && h >= lo.1 && w <= hi.0.min(hi.1) && h <= hi.0.max(hi.1);+    rs.push(rule(&format!("{} {} x {} to {} x {} mm", if panel { "panel" } else { "board" }, lo.0, lo.1, hi.0, hi.1), Some(ok),+        format!("{} x {} mm{}", r3(size.0), r3(size.1), if !ok && !eco && !panel { ": a panel (kicad_panelize) can bring it to 70 x 70" } else { "" })));+    if eco {+        rs.push(match thick { Some(t) => rule("board 0.8 to 1.6 mm thick", Some((0.8 - 1e-6..=1.6 + 1e-6).contains(&t)), format!("{} mm", r3(t))), None => rule("board 0.8 to 1.6 mm thick", None, "thickness not read".into()) });+        rs.push(match layers { Some(n) => rule("2, 4 or 6 layers", Some([2, 4, 6].contains(&n)), format!("{n} layers")), None => rule("2, 4 or 6 layers", None, "layers not read".into()) });+        let bot = fa["bottomSideParts"].as_array().map(|a| a.len()).unwrap_or(0);+        rs.push(rule("parts on one side only", Some(bot == 0), if bot == 0 { "all on top".into() } else { format!("{bot} part(s) on the bottom: {}", fa["bottomSideParts"]) }));+    }+    let (chip_min, pitch_min) = if eco { (0.8, 0.4) } else { (0.3, 0.35) };+    let tiny = fa["tinyFootprints"].as_array().map(|a| a.len()).unwrap_or(0);+    let chip = fa["smallestChip"]["padCentresMm"].as_f64();+    let chip_ok = (eco && tiny == 0 || !eco) && chip.map(|c| c >= chip_min).unwrap_or(true);+    rs.push(rule(if eco { "smallest package 0402" } else { "smallest package 01005" }, Some(chip_ok),+        format!("smallest two-pad part {} (pad centres {} mm){}", fa["smallestChip"]["ref"].as_str().unwrap_or("-"), chip.map(r3).unwrap_or(0.0), if tiny > 0 { format!("; footprints named under 0402: {}", fa["tinyFootprints"]) } else { String::new() })));+    let pitch = fa["finestPitch"]["mm"].as_f64();+    rs.push(rule(&format!("IC pin pitch {pitch_min} mm or more (BGA {} mm)", if eco { 0.5 } else { 0.3 }), Some(pitch.map(|p| p + 1e-6 >= pitch_min).unwrap_or(true)),+        format!("finest {} mm on {}", pitch.map(r3).unwrap_or(0.0), fa["finestPitch"]["ref"].as_str().unwrap_or("-"))));+    if !eco {+        let n = fa["fiducials"].as_u64().unwrap_or(0);+        rs.push(rule("fiducials and edge rails", Some(n >= 2 || panel), if panel { "on the panel rails (kicad_panelize adds three)".into() } else { format!("{n} fiducial(s) on the board: add 3 or ship it in a panel with rails (kicad_panelize)") }));+    }+    rs.push(match lcsc_missing {+        Some(0) => rule("every purchased part has an LCSC number", Some(true), "all lines".into()),+        Some(n) => rule("every purchased part has an LCSC number", Some(false), format!("{n} line(s) without one: find them (adom-parts-search, adom-jlcpcb) or consign them")),+        None => rule("every purchased part has an LCSC number", None, "not checked: no schematic or BOM read".into()),+    });+    let ok = rs.iter().all(|r| r["ok"] != json!(false));+    json!({"tier": name, "ok": ok, "rules": rs})+}++#[cfg(test)]+mod tests {+    use super::*;++    fn part(d: &str, value: &str, fp: &str, params: &[(&str, &str)]) -> Part {+        Part { designator: d.into(), value: value.into(), footprint: fp.into(), params: params.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),+            placed: Some((1.0, -2.0, 90.0, "top".into())), ..Default::default() }+    }++    #[test]+    fn lines_group_by_mpn_and_say_what_an_order_lacks() {+        let ps = vec![+            part("C2", "100n", "C:C_0402", &[("MPN", "GRM155"), ("LCSC", "C1525"), ("Manufacturer", "Murata")]),+            part("C10", "100n", "C:C_0402", &[("Manufacturer Part Number", "GRM155"), ("LCSC Part #", "C1525"), ("Manufacturer", "Murata")]),+            part("R1", "10k", "R:R_0402", &[]),+            Part { dnp: true, ..part("R2", "10k", "R:R_0402", &[]) },+            Part { excluded: true, ..part("H1", "MountingHole", "MH:M3", &[]) },+            part("TP1", "TP", "TP:Pad", &[("MPN", "none (PCB copper)")]),+        ];+        let (lines, checks) = group(&ps);+        let c = lines.iter().find(|l| l["mpn"] == "GRM155").unwrap();+        assert_eq!(c["designators"], json!(["C2", "C10"]));+        assert_eq!((c["quantity"].as_u64(), c["lcsc"].as_str(), c["package"].as_str()), (Some(2), Some("C1525"), Some("C_0402")));+        assert_eq!(checks["missingMpn"], json!(["R1"]));+        assert_eq!(checks["dnp"], json!(["R2"]));+        assert_eq!(checks["excludedKinds"], json!(["H1"]));+        assert_eq!(checks["notPurchased"], json!(["TP1"]));+        assert_eq!(checks["jlcpcb"]["linesWithoutLcsc"][0]["designators"], json!(["R1"]));+        let fs = files(&lines, &ps, 10);+        let cpl = &fs.iter().find(|f| f.0 == "cpl-jlcpcb.csv").unwrap().1;+        assert!(cpl.contains("C2,1.0000mm,-2.0000mm,Top,90") && !cpl.contains("R2,") && !cpl.contains("TP1,"), "{cpl}");+        let mouser = &fs.iter().find(|f| f.0 == "bom-mouser.csv").unwrap().1;+        assert!(mouser.contains(",GRM155,Murata,20,"), "{mouser}");+    }++    #[test]+    fn a_small_two_sided_board_needs_standard_in_a_panel() {+        let st = json!({"components": [{"ref": "U1", "side": "top", "pattern": "QFN-20"}, {"ref": "C1", "side": "top", "pattern": "C_0402"}, {"ref": "R9", "side": "bottom", "pattern": "R_0603"}],+            "pads": [{"ref": "U1", "x": 0.0, "y": 0.0, "drill": 0.0}, {"ref": "U1", "x": 0.5, "y": 0.0, "drill": 0.0}, {"ref": "U1", "x": 1.0, "y": 0.0, "drill": 0.0},+                     {"ref": "C1", "x": 5.0, "y": 0.0, "drill": 0.0}, {"ref": "C1", "x": 6.0, "y": 0.0, "drill": 0.0},+                     {"ref": "R9", "x": 9.0, "y": 0.0, "drill": 0.0}, {"ref": "R9", "x": 10.5, "y": 0.0, "drill": 0.0}]});+        let fa = facts(&st);+        assert_eq!(fa["finestPitch"], json!({"mm": 0.5, "ref": "U1"}));+        assert_eq!(fa["bottomSideParts"], json!(["R9"]));+        assert_eq!(tier("economic", &fa, (40.0, 20.0), Some(1.6), Some(2), Some(0), false)["ok"], false);+        assert_eq!(tier("standard", &fa, (40.0, 20.0), Some(1.6), Some(2), Some(0), false)["ok"], false);+        assert_eq!(tier("standard", &fa, (90.0, 80.0), Some(1.6), Some(2), Some(0), true)["ok"], true);+    }++    #[test]+    fn sheet_fields_merge_with_the_board() {+        let xml = r#"<export><components><comp ref="C1"><value>100n</value><footprint>C:C_0402</footprint><fields><field name="MPN">GRM155</field></fields></comp>+            <comp ref="R5"><value>1k</value><property name="dnp"/></comp></components><nets/></export>"#;+        let nl = crate::netlist::parse_kicadxml(xml).unwrap();+        let mut props = BTreeMap::new();+        props.insert("Reference".to_string(), "C1".to_string());+        props.insert("LCSC".to_string(), "C1525".to_string());+        let board = vec![fab::Part { reference: "C1".into(), value: "100n".into(), footprint: "Capacitor_SMD:C_0402_1005Metric".into(), props, ..Default::default() },+                         fab::Part { reference: "J9".into(), value: "CONN".into(), ..Default::default() }];+        let mut placed = HashMap::new();+        placed.insert("C1".to_string(), (3.0, -4.0, 0.0, "top".to_string()));+        let (ps, cross) = parts(Some(&nl), &board, &placed);+        let c1 = ps.iter().find(|p| p.designator == "C1").unwrap();+        assert_eq!((c1.mpn().as_str(), c1.get(LCSC).as_str(), c1.footprint.as_str()), ("GRM155", "C1525", "Capacitor_SMD:C_0402_1005Metric"));+        assert!(ps.iter().find(|p| p.designator == "R5").unwrap().dnp);+        assert_eq!(cross["sheetNotOnBoard"], json!(["R5"]));+        assert_eq!(cross["boardNotOnSheet"], json!(["J9"]));+    }+}+--- a/skills/kicad-board-checks/SKILL.md+++ b/skills/kicad-board-checks/SKILL.md@@ -1,76 +1,100 @@ --- name: kicad-board-checks description: >--  Check a KiCad board the way the EDA Bridge Standard checks every board, and fix what the checks find: return paths over gaps in the reference copper, copper balance (bow and twist), impedance track widths from the stack-up, current capacity (IPC-2221), net lengths and differential-pair skew, matched-length groups, 3D heights and enclosure fit, part orientation, free space for a part, symbols against footprints, net classes from net names, thermal vias in an exposed pad, a via fence for EMC, and one HTML board report with a picture. Works from the .kicad_pcb file without KiCad running, or live from the PCB editor. Trigger words: board review, check my board, design review, return path, ground plane gap, split plane, copper balance, board warp, bow and twist, impedance, 50 ohm trace width, 90 ohm USB pair, trace width for current, how many amps, can jlcpcb make it, fab limits, fab check, design rules check against a fab, IPC-2221, net length, length matching, pair skew, DDR length, height check, will it fit the enclosure, part too tall, 3D collision, rotate parts, find space, where can this part go, pin pad mismatch, symbol footprint mismatch, net classes, auto classes, power class, thermal vias, exposed pad vias, via stitching, via fence, EMC fence, RF fence, board report, report html.+  Check a KiCad board the way the EDA Bridge Standard checks every board, and fix what the checks find: return paths over gaps in the reference copper, copper balance (bow and twist), impedance track widths from the stack-up, current capacity (IPC-2221), net lengths and differential-pair skew, matched-length groups, 3D heights and enclosure fit, part orientation, free space for a part, symbols against footprints, net classes from net names, thermal vias in an exposed pad, a via fence for EMC, reference designators placed clear of the pads, the BOM (one line per MPN, JLCPCB, Mouser and DigiKey files), JLCPCB assembly tiers, a JLCPCB order in one call, and one HTML board report with a picture. Works from the .kicad_pcb file without KiCad running, or live from the PCB editor. Trigger words: board review, check my board, design review, return path, ground plane gap, split plane, copper balance, board warp, bow and twist, impedance, 50 ohm trace width, 90 ohm USB pair, trace width for current, how many amps, can jlcpcb make it, fab limits, fab check, design rules check against a fab, IPC-2221, net length, length matching, pair skew, DDR length, height check, will it fit the enclosure, part too tall, 3D collision, rotate parts, find space, where can this part go, pin pad mismatch, symbol footprint mismatch, net classes, auto classes, power class, thermal vias, exposed pad vias, via stitching, via fence, EMC fence, RF fence, board report, report html, bom, bill of materials, jlcpcb bom, cpl, pick and place, lcsc numbers, missing mpn, jlcpcb assembly, economic or standard assembly, order from jlcpcb, gerber zip, order the board, designators, silkscreen labels, reference designators on pads, label placement. ---  # Board checks⋯ 50 unchanged lines ⋯ (`kicad_zone_state {refill:true}`) so they join the vias: until then a file DRC shows the old fill overlapping them, and thermal vias read as dangling until the far side has copper of their net. +## Designators++`kicad_silk_designators {size: 1.0}` places every reference designator upright beside its own part (above,+below, right, left, slid along a side, a corner, last on its own body), clear of every pad by `padClearance`+(JLCPCB: 0.15 mm), of other parts' bodies and of the other labels, inside the board; bigger parts choose first.+Parts with no clear spot are listed in `unplaced`, never forced onto pads: allow a smaller `minSize`. The labels+move in the open PCB editor through `kicad_silk_fields_batch` (one Undo step after a DRC check) and are read+back: `onPadsAfter` must be empty. On a closed board the reply is the plan.++## Ordering from JLCPCB++| Verb | Answers |+|---|---|+| `kicad_bom {projectPath, boards?}` | the schematic's parts (fields, DNP, exclude from BOM) merged with the board, one line per MPN; `checks` say what an order lacks; writes bom.csv, bom-jlcpcb.csv + cpl-jlcpcb.csv, bom-mouser.csv, bom-digikey.csv and bom.json into `<project>/BOM` |+| `kicad_assembly_check {projectPath, panelMm?}` | the board against JLCPCB's Economic and Standard assembly tiers (size, thickness, layers, sides, smallest package, finest pitch, fiducials, LCSC numbers): `service` is the cheapest tier every rule allows |+| `kicad_jlcpcb_order {projectPath}` | fab_check, the Gerbers and drill zipped, the BOM and CPL, the tiers and the order-form values (layers, size, thickness, copper, smallest track/space and hole) in one folder, `issues` from every step, `ready` |++Field names are matched across libraries: MPN = Manufacturer Part Number; LCSC = LCSC Part # = JLCPCB Part #;+Mouser Part #; DigiKey Part #. Parts with an MPN or manufacturer of "none" (test pads, logos) stay in the BOM+and out of the ordering files. Parts excluded from the BOM or the position files, DNP and board-only ones are+not counted as placed parts. CPL positions are KiCad's position-file numbers (mm, y up): check every part's+rotation in JLCPCB's preview.+ ## The report  `kicad_board_report {projectPath}` runs DRC, ERC, the board against the schematic, symbols against⋯ 10 unchanged lines ⋯ - `kicad_pin_pad_check` expects mounting, shield, exposed and thermal pads (MH*, MP*, SH*, EP*, PAD*, TAB*,   unnamed or 0) to have no pin. - `kicad_auto_classes` reads names only: a clock on a net called SIG7 is not found.+--- a/skills/kicad-bridge-test/run_verb_tests.py+++ b/skills/kicad-bridge-test/run_verb_tests.py@@ -1,1567 +1,1591 @@⋯ 192 unchanged lines ⋯         "teardrops": dict(phase=8, scenario=True),         # ---- phase 10: the checks tier added in 1.0.67, run in order by checks_phase() on a scratch board         **{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")},+            "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")},         "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),⋯ 1063 unchanged lines ⋯         record("thermal_vias_live", r, ok(r) and r.get("source") == "live-editor" and (r.get("applied") or 0) > 0 and r.get("undoSteps") == 1, "thermal vias were not committed live")         r = E("via_fence", {"viaNet": "GND", "edge": True, "dryRun": True}, 300, chain=False)         record("via_fence_live_plan", r, ok(r) and (r.get("planned") or {}).get("vias", 0) > 0 and r.get("mutated") is not True, "no live fence plan")+        # 1.0.68: designators moved live clear of the pads, then read back+        # KiCad 10.0.x drops a footprint's units block when a field moves over its API: on such a board the+        # live move must refuse cleanly with nothing changed; on any other it moves the labels and reads them back+        r = E("silk_designators", {"size": 1.0}, 300, chain=False)+        record("silk_designators_live", r, (ok(r) and (r.get("placed") or 0) > 0 and r.get("onPadsAfter") == [])+               or (r.get("errorCode") == "live_move_drops_units" and r.get("mutated") is False and r.get("planned")), "designators were neither moved live nor refused cleanly")         r = E("add_mounting_hole", {"x": round(cx, 2), "y": round(cy, 2), "drill": 2.2, "dryRun": True}, chain=False)         rec("add_mounting_hole", r, ok(r) or refused_cleanly(r), "no answer")         r = E("board_outline", {"shape": "rectangle", "width": 70, "height": 60, "x": 100, "y": 125, "replace": True, "dryRun": True}, chain=False)⋯ 224 unchanged lines ⋯         own("via_fence", r, ok(r) and (r.get("applied") or 0) > 0 and (r.get("drc") or {}).get("newErrors") == 0, "no fence written after a clean DRC")         r = C("board_report", {"filePath": CK}, 600)         own("board_report", r, ok(r) and r.get("report") and r.get("picture") is True and len(r.get("sections") or []) >= 8, "no report with its picture")+        # 1.0.68: ordering, land patterns, designators+        r = C("bom", {"projectPath": w["pro"], "outputDir": w["out"] + "/bom"}, 300)+        own("bom", r, ok(r) and isinstance(r.get("lines"), list) and len(r.get("files") or []) == 6 and "missingMpn" in (r.get("checks") or {}), "no BOM lines or not all six files")+        r = C("assembly_check", {"filePath": CK, "source": "file"}, 300)+        own("assembly_check", r, ok(r) and (r.get("economic") or {}).get("rules") and (r.get("standard") or {}).get("rules") and "service" in r)+        r = C("jlcpcb_order", {"filePath": CK, "outputDir": w["out"] + "/jlcpcb-order"}, 600)+        own("jlcpcb_order", r, ok(r) and (r.get("gerberZip") or {}).get("path") and isinstance(r.get("issues"), list) and (r.get("orderForm") or {}).get("layers") == 4, "no Gerber zip or order form")+        lp = {"libraryPath": w["out"] + "/LandTest.pretty", "name": "SOIC-8_test", "family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6,+              "body": {"w": 3.9, "l": 4.9, "height": 1.75}, "overwrite": True}+        r0 = C("footprint_from_land_pattern", dict(lp, dryRun=True))+        r = C("footprint_from_land_pattern", lp)+        own("footprint_from_land_pattern", {"plan": r0, "write": r}, ok(r0) and (r0.get("summary") or {}).get("padTable", [[None]])[0][0] == "1" and ok(r) and (r.get("body3d") or {}).get("success") is True,+            "no pad table, or the footprint or its body was not written")+        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")      live_phase()     k161_phase()⋯ 66 unchanged lines ⋯  if __name__ == "__main__":     sys.exit(main())+--- a/skills/kicad-schematic-and-board-edits/SKILL.md+++ b/skills/kicad-schematic-and-board-edits/SKILL.md@@ -1,116 +1,123 @@⋯ 57 unchanged lines ⋯ `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.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+file beside the library) when the height is given. The layout is the Altium bridge's, so both tools draw the same+footprint. `dryRun` returns the pad table (y up, pin 1 top-left) to compare with the datasheet first.+ From 1.0.64, the EDA Bridge Standard's library housekeeping: - **Footprint libraries:** `copy_footprint`, `rename_footprint`, `remove_footprint`, `xcopy_footprint` (across   libraries), `add_body` (a STEP or VRML model onto a library footprint, replacing its models unless⋯ 50 unchanged lines ⋯   `prices` per quantity.  Every verb here runs in the verb battery on a real Windows box before each release.+

Comments

No comments yet.

Log in to comment.