← All Pull Requests

kicad-lane: 1.0.88, kicad_variants and kicad_sch_zoom #50

Merged opened by Colby Knox 2026-10-30

1.0.88: KiCad 10's design variants (the standard's variants).

  • kicad_variants (new): which parts are fitted on which build, on the project's own files.
    • Lists every variant with its not-fitted parts, its changed fields (alternates) and its description.
    • Makes or changes a variant: marks parts not fitted, or puts them back. Describes or removes one.
    • It writes what KiCad 10 writes: the variant list in the .kicad_pro, and each part's variation inside its instance on whichever sheet holds it. A .bak is kept beside every file written.
    • KiCad reads the result back: kicad-cli's netlist for the variant must mark exactly those parts DNP, or the call fails with verify_failed.
    • Unlike Fusion's read-only list, KiCad's verb edits variants. Field changes (another Value in a variant) are listed but made in KiCad's own variant editor.
    • Checked on a copy of KiCad's pic_programmer demo (two sheets):
      • Lite with P1, C1 and R1 not fitted: KiCad marks the same three DNP.
      • R1 put back: C1 and P1 are left.
      • A second variant with D1: matches.
      • Lite removed: only Pro is left in the project.
      • kicad-cli's BOM for Lite leaves out C1 and P1.
  • kicad_sch_zoom (new; the standard's sch_zoom): finds parts on a schematic sheet and frames them. It's the schematic half of cross-probing; kicad_zoom_to is the board half.
    • Answers where each part sits (mm) and which ones the sheet doesn't place.
    • The proof frame is KiCad's own plot of the sheet, cropped to the parts with a margin and each part outlined.
    • KiCad 10's Schematic Editor has no API, so the frame is a plot of the saved sheet and the open editor's view isn't moved.
    • Checked: C1 on the pic_programmer root sheet, and U1, P2, C6 and C7 together on its pic_sockets sub-sheet, each outlined in the plot.

Battery 316/0/1.

🤖 Generated with Claude Code

Diff Skip to comments

--- a/bridge.json+++ b/bridge.json@@ -1,425 +1,428 @@⋯ 267 unchanged lines ⋯     "kicad_set_params",     "kicad_set_visibility",     "kicad_sheet_params",+    "kicad_variants",+    "kicad_sch_zoom",     "kicad_silk_state",     "kicad_symbol_prims",     "kicad_xcopy_footprint",⋯ 149 unchanged lines ⋯   "releasedAt": "2026-06-26T16:00:00Z",   "uninstall": "kicad_uninstall" }+--- a/rust/Cargo.lock+++ b/rust/Cargo.lock@@ -1,1307 +1,1308 @@⋯ 318 unchanged lines ⋯  [[package]] name = "kicad-bridge"-version = "1.0.87"+version = "1.0.88" dependencies = [  "adom-eda-checks",  "kicad-core",⋯ 5 unchanged lines ⋯  [[package]] name = "kicad-core"-version = "1.0.87"+version = "1.0.88" dependencies = [  "kicad-ipc-rs",  "kicad-platform",⋯ 20 unchanged lines ⋯  [[package]] name = "kicad-platform"-version = "1.0.87"+version = "1.0.88" 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.87"+version = "1.0.88" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+--- a/skills/kicad-bridge-test/run_verb_tests.py+++ b/skills/kicad-bridge-test/run_verb_tests.py@@ -1,1836 +1,1855 @@⋯ 196 unchanged lines ⋯             "bom", "assembly_check", "jlcpcb_order", "footprint_from_land_pattern", "silk_designators",             "read_sheet_file", "build_board", "test_board", "length_tune", "silkscreen_prep", "component_classes", "build_schematic",             "library_views", "generate_body", "plan_route", "export_spice", "simulate",-            "license_status", "eval", "run_script", "dialog_log", "foreground", "schematic_review", "review_board")},+            "license_status", "eval", "run_script", "dialog_log", "foreground", "schematic_review", "review_board", "variants", "sch_zoom")},         "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),⋯ 1442 unchanged lines ⋯         rv2 = C("schematic_review", {"filePath": w["sch"]}, 200)         own("schematic_review", {"rc": rv1, "rp2040": rv2}, ok(rv1) and rv1.get("ok") is True and ok(rv2) and rv2.get("ok") is False             and any(e.get("check") == "unconnected_pin" and e.get("type") == "power" for e in rv2.get("errors") or []), "the RC was not clean, or the unwired sample's power pins were not caught")+        # 1.0.88: KiCad 10 design variants on a copy of the rp2040 sample: make Lite with two parts not fitted, put one back,+        # KiCad's own netlist for the variant agreeing each time, then remove it+        vp = base + "/variants/rp2040-breakout.kicad_pro"+        ab(a.target, "shell_execute", {"command": "rmdir /s /q \"" + (base + "/variants").replace("/", "\\") + "\" 2>nul & xcopy /i /q /y \"" + base.replace("/", "\\") + "\\rp2040-breakout.*\" \"" + (base + "/variants").replace("/", "\\") + "\"",+                                       "reason": "kicad-bridge verb test: a scratch copy of the rp2040 sample for kicad_variants"}, timeout=120)+        v1 = C("variants", {"projectPath": vp, "name": "Lite", "notFitted": ["C1", "C2"], "description": "verb test"}, 200)+        v2 = C("variants", {"projectPath": vp, "name": "Lite", "fitted": ["C2"]}, 200)+        v3 = C("variants", {"projectPath": vp, "name": "Lite", "remove": True}, 200)+        vl = C("variants", {"projectPath": vp}, 200)+        own("variants", {"make": v1, "fit": v2, "remove": v3, "list": vl},+            ok(v1) and (v1.get("verify") or {}).get("kicadNotFitted") == ["C1", "C2"] and ok(v2) and (v2.get("verify") or {}).get("kicadNotFitted") == ["C1"]+            and ok(v3) and ok(vl) and not any(x.get("name") == "Lite" for x in vl.get("variants") or []),+            "KiCad's netlist for the variant did not match the parts marked not fitted, or the variant was not removed")+        # 1.0.88: the standard's sch_zoom: where parts sit on a sheet and KiCad's plot framed on them+        sz = C("sch_zoom", {"sheet": w["sch"], "designators": ["U1", "C1", "ZZ9"]}, 240)+        own("sch_zoom", sz, ok(sz) and {p.get("designator") for p in sz.get("parts") or []} == {"U1", "C1"} and sz.get("notOnSheet") == ["ZZ9"]+            and str((sz.get("shot") or {}).get("png") or "").endswith(".png") and 1000 <= ((sz.get("shot") or {}).get("width") or 0) <= 1400,+            "the parts were not found, ZZ9 was not reported missing, or no framed plot came back")         # 1.0.84: the standard's review_board (engine 0.17.2): Board Setup's rules and the sheet go in, no rule class reads as         # missing on a KiCad board, and the rp2040 sample's 12 PIN** test points are a real duplicate designator         rb = C("review_board", {"filePath": w["board"]}, 300)⋯ 182 unchanged lines ⋯  if __name__ == "__main__":     sys.exit(main())+--- a/rust/crates/kicad-bridge/src/verbs_sch_hier.rs+++ b/rust/crates/kicad-bridge/src/verbs_sch_hier.rs@@ -1,1491 +1,1692 @@⋯ 148 unchanged lines ⋯         pitfalls: &["the edit is on disk: an open Schematic Editor does not see it until reloaded", "Reference is not set here: rename a part with kicad_sch_set_property"],     },     Verb {+        name: "kicad_sch_zoom",+        summary: "Find parts on a schematic sheet and frame them: where each sits (mm), which are not on the sheet, and a proof frame: KiCad's own plot of the sheet cropped to the parts, each outlined. The schematic half of cross-probing (kicad_zoom_to is the board half).",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 240,+        input: "{\"designators\": [\"U1\", \"L1\"] | \"U1,L1\", \"sheet\" | \"filePath\": \"C:/.../x.kicad_sch\", \"shot\"?: true, \"marginMm\"?: 10, \"width\"?: 1200, \"outputDir\"?: \"C:/...\"}",+        example: "kicad_sch_zoom {\"sheet\":\"C:/Users/john/proj/board.kicad_sch\",\"designators\":[\"U1\",\"L1\"]}",+        hint: "parts[{designator, xMm, yMm, regionMm}] are sheet millimetres (origin top-left, y down). shot.png is the framed plot (regionMm the part of the sheet it shows); read it to see the parts. notOnSheet names designators this sheet does not place: kicad_bom lists every designator with its sheet.",+        related: &["kicad_zoom_to", "kicad_render_schematic", "kicad_cross_probe"],+        pitfalls: &["KiCad 10's Schematic Editor has no API: the frame is a plot of the saved sheet, and the open editor's selection and view are not changed.", "The sheet is read as saved: save it in KiCad first."],+    },+    Verb {+        name: "kicad_variants",+        summary: "KiCad 10's design variants (which parts are fitted on which build): list every variant with its not-fitted parts and changed fields, make or change one with not-fitted parts (or put parts back), or remove one, on the project's schematic files; KiCad's own netlist for the variant reads the result back.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"projectPath\": \"C:/.../p.kicad_pro\", \"name\"?: \"Lite\" (omit to list), \"notFitted\"? | \"dnp\"?: [\"R5\", \"C9\"], \"fitted\"?: [\"R5\"], \"description\"?: \"no USB\", \"remove\"?: bool, \"dryRun\"?: bool}",+        example: "kicad_variants {\"projectPath\":\"C:/Users/john/proj/board.kicad_pro\",\"name\":\"Lite\",\"notFitted\":[\"R5\",\"C9\",\"J2\"]}",+        hint: "variants[{name, description, notFitted, alternates[{designator, fields}], flags}] is the standard's answer. KiCad edits what Fusion's read-only list cannot: make a variant, mark parts not fitted or put them back, describe or remove it. verify comes from KiCad itself (kicad-cli's netlist for that variant): kicadNotFitted is what KiCad marks DNP, and it must equal notFitted. kicad_bom and kicad-cli's --variant then give that build's BOM and placement file.",+        related: &["kicad_bom", "kicad_export_fab_package", "kicad_sheet_params"],+        pitfalls: &["Edits are on the schematic files: close the Schematic Editor first, or its next save overwrites them.", "Field changes (another Value or Footprint in a variant) are listed in alternates but made in KiCad's own variant editor.", "A part is named by its reference: on a sheet used twice, each use has its own reference and its own variation."],+    },+    Verb {         name: "kicad_part_parameters",         summary: "The parameter (field) names a project's placed parts carry, with how many parts carry each; or, with name, that parameter's value on every part. Read only.",         mechanism: Mechanism::File, risk: "read", timeout_sec: 60,⋯ 29 unchanged lines ⋯         "kicad_design_review" => design_review(state, args),         "kicad_doc_params" => doc_params(args),         "kicad_sheet_params" => sheet_params(args),+        "kicad_variants" => variants(state, args),+        "kicad_sch_zoom" => sch_zoom(state, args),         "kicad_part_parameters" => part_parameters(args),         "kicad_place_on_sch" => place_on_sch(state, args),         _ => return None,⋯ 1013 unchanged lines ⋯     } } +/// `kicad_sch_zoom`: where parts sit on a sheet, and KiCad's plot of the sheet framed on them.+fn sch_zoom(state: &mut State, args: &Value) -> Value {+    let designators = list_arg(args.get("designators"));+    if designators.is_empty() {+        return fail("missing_arg", "designators is required", "Pass [\"U1\", \"L1\"] or \"U1,L1\".");+    }+    let Some(path) = arg_any(args, &["sheet", "filePath", "schPath"]).map(|p| PathBuf::from(p.trim())) else {+        return fail("missing_arg", "sheet is required", "The .kicad_sch that holds the parts.");+    };+    let sch = match Schematic::load(&path) {+        Ok(s) => s,+        Err(e) => return fail("parse_failed", e, "Not a schematic this bridge can read; run kicad_lint_schematic."),+    };+    let mut parts = Vec::new();+    let mut regions: Vec<[f64; 4]> = Vec::new();+    let mut not_on = Vec::new();+    for d in &designators {+        let Some(i) = sch.find_symbol_index(d) else {+            not_on.push(d.clone());+            continue;+        };+        let at = sch.top()[i].child("at");+        let (x, y) = (at.and_then(|a| a.num(0)).unwrap_or(0.0), at.and_then(|a| a.num(1)).unwrap_or(0.0));+        // the part's extent from its pins and its anchor, at least 2.54 mm round the anchor+        let mut r = [x - 2.54, y - 2.54, x + 2.54, y + 2.54];+        for (_, (px, py)) in sch.pin_points(d).unwrap_or_default() {+            r = [r[0].min(px), r[1].min(py), r[2].max(px), r[3].max(py)];+        }+        let r2 = |v: f64| (v * 100.0).round() / 100.0;+        parts.push(json!({"designator": d, "xMm": r2(x), "yMm": r2(y), "regionMm": r.map(r2)}));+        regions.push(r);+    }+    let mut shot = Value::Null;+    if args.get("shot") != Some(&json!(false)) && !regions.is_empty() {+        let out_dir = arg_str(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| path.parent().unwrap_or(Path::new(".")).join(".adom").join("render"));+        let plotted = render(state, &json!({"filePath": norm(&path), "width": 400, "outputDir": norm(&out_dir)}));+        let Some(svg) = plotted["svg"].as_str().map(PathBuf::from) else {+            return plotted;+        };+        let bytes = match std::fs::read(&svg) {+            Ok(b) => b,+            Err(e) => return fail("read_failed", format!("cannot read {}: {e}", norm(&svg)), "The plot folder must be readable."),+        };+        let width = args.get("width").and_then(Value::as_u64).unwrap_or(1200).clamp(200, 4000) as u32;+        let margin = args.get("marginMm").and_then(Value::as_f64).unwrap_or(10.0);+        match sch_render::frame(&bytes, &regions, margin, width) {+            Ok((img, region)) => {+                let stem = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "sheet".into());+                let tag: String = designators.iter().filter(|d| !not_on.contains(*d)).take(4).cloned().collect::<Vec<_>>().join("-");+                let png = out_dir.join(format!("{stem}-zoom-{tag}.png"));+                if let Err(e) = sch_render::save_png(&img, &png) {+                    return fail("write_failed", e, "Pass an outputDir the desktop user can write.");+                }+                shot = json!({"png": norm(&png), "regionMm": region, "width": img.width(), "height": img.height(), "plot": norm(&svg)});+            }+            Err(e) => return fail("render_failed", e, "kicad_render_schematic plots the whole sheet."),+        }+    }+    let found = parts.len();+    json!({+        "success": found > 0, "errorCode": if found > 0 { Value::Null } else { json!("no_such_part") },+        "error": if found > 0 { Value::Null } else { json!(format!("none of {} is on {}", designators.join(", "), norm(&path))) },+        "sheet": norm(&path), "parts": parts, "notOnSheet": not_on, "shot": shot,+        "output": format!("{found} part(s) framed on {}{}", path.file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(),+            if not_on.is_empty() { String::new() } else { format!("; not on this sheet: {}", not_on.join(", ")) }),+        "_hint": "Read shot.png to see the parts (outlined). KiCad 10's Schematic Editor has no API, so the open editor's view is not moved: this is a plot of the saved sheet. kicad_bom lists which sheet holds each designator.",+    })+}++/// `kicad_variants`: list, make or change, or remove a design variant (KiCad 10), read back through KiCad's netlist.+fn variants(state: &mut State, args: &Value) -> Value {+    use kicad_core::variants as kv;+    let Some(pro_path) = arg_str(args, "projectPath").map(|p| PathBuf::from(p.trim())) else {+        return fail("missing_arg", "projectPath is required", "The .kicad_pro: KiCad keeps the variant list there and the parts' variations on its sheets.");+    };+    let mut pro = match kicad_core::project_rules::read_json(&pro_path) {+        Ok(v) => v,+        Err(e) => return fail(&e.code, e.message, "projectPath names the .kicad_pro beside the root sheet."),+    };+    let pro_text = std::fs::read_to_string(&pro_path).unwrap_or_default();+    let mut docs = match param_sheets(&json!({"projectPath": norm(&pro_path)})) {+        Ok(d) => d,+        Err(v) => return v,+    };+    let name = arg_str(args, "name").map(str::trim).filter(|n| !n.is_empty()).map(String::from);+    let not_fitted = { let mut v = list_arg(args.get("notFitted")); v.extend(list_arg(args.get("dnp"))); v };+    let fitted = list_arg(args.get("fitted"));+    let remove = args.get("remove") == Some(&json!(true));+    let description = arg_str(args, "description");+    let dry_run = dry(args, false);+    let listing = |pro: &Value, docs: &[(PathBuf, Schematic)]| kv::summary(pro, &docs.iter().map(|(_, s)| s).collect::<Vec<_>>());+    let Some(name) = name else {+        let vs = listing(&pro, &docs);+        return json!({"success": true, "variants": vs, "output": format!("{} variant(s)", vs.len()),+            "_hint": "name with notFitted / fitted / remove edits one. kicad_bom and kicad-cli's --variant give a build's BOM and placement file."});+    };+    if remove && (!not_fitted.is_empty() || !fitted.is_empty()) {+        return fail("bad_args", "remove takes no notFitted or fitted", "Remove the variant on its own.");+    }+    let all_refs: std::collections::BTreeSet<String> = docs.iter().flat_map(|(_, s)| kv::references(s)).collect();+    let unknown: Vec<&String> = not_fitted.iter().chain(&fitted).filter(|r| !all_refs.contains(*r)).collect();+    if !unknown.is_empty() {+        return fail("no_such_part", format!("no sheet of the project places {}", unknown.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")), "Designators as the schematic names them (kicad_list_designators).");+    }+    let mut changes = 0usize;+    let mut touched: Vec<usize> = Vec::new();+    for (i, (_, sch)) in docs.iter_mut().enumerate() {+        let before = changes;+        if remove {+            changes += kv::remove(sch, &name);+        } else {+            for r in &not_fitted {+                changes += kv::set_not_fitted(sch, r, &name);+            }+            for r in &fitted {+                changes += kv::set_fitted(sch, r, &name);+            }+        }+        if changes > before {+            touched.push(i);+        }+    }+    let pro_changed = if remove { kv::project_remove(&mut pro, &name) } else { kv::project_add(&mut pro, &name, description) };+    if !remove && !kv::project_variants(&pro).iter().any(|(n, _)| *n == name) {+        return fail("internal", "the variant did not land in the project list", "Report this with the request.");+    }+    let mut files = Vec::new();+    if !dry_run {+        for &i in &touched {+            let (path, sch) = &docs[i];+            match sch.save_with_backup(path) {+                Ok(b) => files.push(json!({"path": norm(path), "backup": norm(&b)})),+                Err(e) => return fail("write_failed", e, "Check the files are writable; .bak files hold the previous content of any already written."),+            }+        }+        if pro_changed {+            if let Err(e) = kicad_core::project_rules::write_atomic(&pro_path, &kicad_core::project_rules::project_text(&pro), Some(&pro_text)) {+                return fail(&e.code, e.message, "The sheets were written; the .kicad_pro could not be (its .adom-bak holds the old one).");+            }+            files.push(json!({"path": norm(&pro_path), "backup": format!("{}.adom-bak", norm(&pro_path))}));+        }+        let paths: Vec<PathBuf> = touched.iter().map(|&i| docs[i].0.clone()).collect();+        if let Err(v) = reparse(&paths) {+            return v;+        }+    }+    let vs = listing(&pro, &docs);+    let mine = vs.iter().find(|v| v["name"] == json!(name)).cloned().unwrap_or(Value::Null);+    // KiCad's own reading of the variant: its netlist for the variant marks the not-fitted parts dnp+    let mut verify = Value::Null;+    if !dry_run && !remove {+        let root = pro_path.with_extension("kicad_sch");+        if let Some(cli) = state.kicad_info().primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()) {+            let out = std::env::temp_dir().join(format!("adom-variant-{}.xml", std::process::id()));+            let r = kicad_core::cli::run(&cli, &["sch", "export", "netlist", "--format", "kicadxml", "--variant", &name, "--output", &norm(&out), &norm(&root)], std::time::Duration::from_secs(100));+            let parsed = std::fs::read_to_string(&out).ok();+            let _ = std::fs::remove_file(&out);+            verify = match parsed {+                Some(xml) => {+                    let dnp = kv::netlist_not_fitted(&xml, &name);+                    let want: Vec<String> = mine["notFitted"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(String::from)).collect();+                    // a part DNP on its own symbol is DNP in every variant, so KiCad's list may be longer than the variant's+                    json!({"kicadNotFitted": dnp, "matches": want.iter().all(|w| dnp.contains(w)), "rc": r.exit_code})+                }+                None => json!({"error": format!("kicad-cli could not export the variant's netlist (rc {}): {}", r.exit_code, r.stderr.trim())}),+            };+        }+    }+    let ok = dry_run || remove || verify.is_null() || verify["matches"] == json!(true);+    json!({+        "success": ok, "errorCode": if ok { Value::Null } else { json!("verify_failed") },+        "dryRun": dry_run, "name": name, "variant": mine, "variants": vs, "changes": changes, "files": files, "verify": verify,+        "output": if remove { format!("variant {name} removed ({changes} variation(s) dropped)") } else { format!("variant {name}: {} not fitted, {changes} change(s)", mine["notFitted"].as_array().map_or(0, |a| a.len())) },+        "reloadHint": RELOAD,+        "_hint": "verify.kicadNotFitted is KiCad's own netlist for the variant: its not-fitted parts are DNP there. kicad_bom and kicad-cli's --variant give that build's BOM and placement file.",+    })+}+ /// `kicad_sheet_params`: parameters onto placed parts, every unit, on whichever sheet holds each. fn sheet_params(args: &Value) -> Value {     let designators = list_arg(args.get("designators"));⋯ 280 unchanged lines ⋯         assert_eq!(dispatch(&mut State::new(), "kicad_place_on_sch", &json!({"libraryPath": norm(&lib), "symbolName": names[0]})).unwrap()["success"], false, "an existing sheet is refused");     } }+--- a/rust/crates/kicad-core/src/sch_render.rs+++ b/rust/crates/kicad-core/src/sch_render.rs@@ -1,505 +1,561 @@⋯ 138 unchanged lines ⋯     Ok(Raster { pixmap, width_mm: w_px * 25.4 / 96.0, height_mm: h_px * 25.4 / 96.0 }) } +/// A proof frame of parts on a sheet: the plotted page cropped to `regions` (sheet mm, y down, [x0, y0, x1, y1]) plus+/// `margin_mm`, each region outlined, about `width` px wide. Returns the image and the framed region in sheet mm.+pub fn frame(svg: &[u8], regions: &[[f64; 4]], margin_mm: f64, width: u32) -> Result<(Pixmap, [f64; 4]), String> {+    if regions.is_empty() {+        return Err("nothing to frame".into());+    }+    let mut u = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];+    for r in regions {+        u = [u[0].min(r[0]), u[1].min(r[1]), u[2].max(r[2]), u[3].max(r[3])];+    }+    let m = margin_mm.max(0.0);+    let mut box_mm = [u[0] - m, u[1] - m, u[2] + m, u[3] + m];+    // the page at a scale that makes the framed region `width` px wide, within the rasteriser's limit+    let page = rasterize(svg, 64)?;+    let crop_w = (box_mm[2] - box_mm[0]).max(1.0);+    let full = ((width as f64) * page.width_mm / crop_w).round().clamp(64.0, 16384.0) as u32;+    let raster = rasterize(svg, full)?;+    let ppm = raster.pixmap.width() as f64 / raster.width_mm;+    box_mm = [box_mm[0].max(0.0), box_mm[1].max(0.0), box_mm[2].min(raster.width_mm), box_mm[3].min(raster.height_mm)];+    let px = |v: f64| (v * ppm).round().max(0.0) as i32;+    let (x0, y0) = (px(box_mm[0]), px(box_mm[1]));+    let (w, h) = ((px(box_mm[2]) - x0).max(1) as u32, (px(box_mm[3]) - y0).max(1) as u32);+    let rect = tiny_skia::IntRect::from_xywh(x0, y0, w, h).ok_or("the framed region is empty")?;+    let mut crop = raster.pixmap.clone_rect(rect).ok_or("the framed region is off the page")?;+    let mut paint = tiny_skia::Paint::default();+    paint.set_color_rgba8(220, 30, 30, 255);+    paint.anti_alias = true;+    let stroke = tiny_skia::Stroke { width: (0.15 * ppm as f32).max(2.0), ..Default::default() };+    for r in regions {+        let pad = 1.0;+        let (rx, ry) = (((r[0] - pad) * ppm) as f32 - x0 as f32, ((r[1] - pad) * ppm) as f32 - y0 as f32);+        if let Some(rr) = tiny_skia::Rect::from_xywh(rx, ry, ((r[2] - r[0] + 2.0 * pad) * ppm) as f32, ((r[3] - r[1] + 2.0 * pad) * ppm) as f32) {+            let path = tiny_skia::PathBuilder::from_rect(rr);+            crop.stroke_path(&path, &paint, &stroke, tiny_skia::Transform::identity(), None);+        }+    }+    let r2 = |v: f64| (v * 100.0).round() / 100.0;+    Ok((crop, [r2(box_mm[0]), r2(box_mm[1]), r2(box_mm[2]), r2(box_mm[3])]))+}+ pub struct Diff {     pub changed: u64,     pub total: u64,⋯ 357 unchanged lines ⋯         assert_eq!(baseline_key("hier", "/Power/Filter/"), "hier-Power-Filter");         assert_eq!(baseline_key("hier", "/A B:C/"), "hier-A_B_C");     }++    #[test]+    fn a_frame_crops_the_page_to_the_parts_and_outlines_them() {+        // an A4-sized page (297 x 210 mm) with one black box at 100..110, 50..60 mm+        let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="297mm" height="210mm" viewBox="0 0 297 210"><rect x="100" y="50" width="10" height="10" fill="black"/></svg>"#;+        let (img, region) = super::frame(svg, &[[100.0, 50.0, 110.0, 60.0]], 10.0, 600).unwrap();+        assert_eq!(region, [90.0, 40.0, 120.0, 70.0]);+        assert!((img.width() as i32 - 600).abs() <= 2, "{}", img.width());+        // the box sits in the middle of the frame, and the outline is red+        let mid = img.pixel(img.width() / 2, img.height() / 2).unwrap();+        assert!(mid.red() < 40 && mid.green() < 40);+        let reds = img.pixels().iter().filter(|p| p.red() > 180 && p.green() < 80).count();+        assert!(reds > 100, "{reds}");+        assert!(super::frame(svg, &[], 5.0, 600).is_err());+    } }+--- a/rust/crates/kicad-core/src/lib.rs+++ b/rust/crates/kicad-core/src/lib.rs@@ -1,73 +1,75 @@⋯ 65 unchanged lines ⋯ pub mod board_edit; // Schematic hierarchy, analysis, render and design review (kicad_sch_hierarchy and friends). pub mod sch_model;+pub mod variants; pub mod sch_hier; pub mod sch_check; pub mod sch_review; pub mod sch_render; pub mod sch_sheets;+--- a/rust/crates/kicad-bridge/src/verbs_sch_hier/tests.rs+++ b/rust/crates/kicad-bridge/src/verbs_sch_hier/tests.rs@@ -1,492 +1,493 @@⋯ 38 unchanged lines ⋯  #[test] fn catalog_entries_are_complete_and_dash_free() {-    assert_eq!(VERBS.len(), 14);+    assert_eq!(VERBS.len(), 16);     for v in VERBS {         assert!(v.name.starts_with("kicad_"));         for t in [v.summary, v.input, v.example, v.hint] {⋯ 444 unchanged lines ⋯     assert_eq!(v["errorCode"], "bad_arg");     let _ = std::fs::remove_dir_all(&d); }+--- a/rust/crates/kicad-core/src/variants.rs+++ b/rust/crates/kicad-core/src/variants.rs@@ -0,0 +1,292 @@+//! KiCad 10's design variants (the standard's `variants`): which parts are fitted on which build.+//! The project names them (`.kicad_pro` `schematic.variants: [{name, description?}]`), and each placed symbol says how it+//! differs in a variant inside its instance path, writing only what differs from the symbol itself:+//! `(instances (project "p" (path "/…" (reference "R5") (unit 1) (variant (name "Lite") (dnp yes) (field (name "Value") (value "22k"))))))`.+//! A part is named by the reference its path gives it, so a sheet used twice has a reference (and a variation) per use.+use crate::schematic::{pretty, Node, Schematic};+use serde_json::{json, Value};+use std::collections::BTreeMap;++/// One part's variation in one variant.+#[derive(Clone, Debug, Default, PartialEq)]+pub struct Variation {+    pub reference: String,+    pub dnp: Option<bool>,+    pub in_bom: Option<bool>,+    pub on_board: Option<bool>,+    pub in_pos_files: Option<bool>,+    pub exclude_from_sim: Option<bool>,+    pub fields: Vec<(String, String)>,+}++fn flag(n: &Node, k: &str) -> Option<bool> {+    n.child_value(k).map(|v| v == "yes")+}++fn is_list(n: &Node, head: &str) -> bool {+    n.is_list() && n.head() == Some(head)+}++/// Every symbol instance path on the sheet: (top-level item index, path node index path) visited mutably.+fn for_each_path(sch: &mut Schematic, f: &mut dyn FnMut(&mut Node)) {+    for sym in sch.top_mut().iter_mut().filter(|n| crate::schematic::is_symbol_instance(n)) {+        let Some(inst) = sym.child_mut("instances") else { continue };+        for proj in inst.children_mut().into_iter().flatten().filter(|n| is_list(n, "project")) {+            for path in proj.children_mut().into_iter().flatten().filter(|n| is_list(n, "path")) {+                f(path);+            }+        }+    }+}++/// Every variation on the sheet, by variant name.+pub fn read(sch: &Schematic) -> BTreeMap<String, Vec<Variation>> {+    let mut out: BTreeMap<String, Vec<Variation>> = BTreeMap::new();+    for sym in sch.top().iter().filter(|n| crate::schematic::is_symbol_instance(n)) {+        let Some(inst) = sym.child("instances") else { continue };+        for proj in inst.children().iter().filter(|n| is_list(n, "project")) {+            for path in proj.children().iter().filter(|n| is_list(n, "path")) {+                let reference = path.child_value("reference").unwrap_or_default();+                for v in path.children().iter().filter(|n| is_list(n, "variant")) {+                    let Some(name) = v.child_value("name") else { continue };+                    let fields = v.children().iter().filter(|n| is_list(n, "field"))+                        .filter_map(|f| Some((f.child_value("name")?, f.child_value("value").unwrap_or_default()))).collect();+                    out.entry(name).or_default().push(Variation {+                        reference: reference.clone(), dnp: flag(v, "dnp"), in_bom: flag(v, "in_bom"), on_board: flag(v, "on_board"),+                        in_pos_files: flag(v, "in_pos_files"), exclude_from_sim: flag(v, "exclude_from_sim"), fields,+                    });+                }+            }+        }+    }+    out+}++/// Mark `reference` not fitted (DNP) in `variant`, adding the variation when the part has none there. Returns how many+/// instance paths changed (0 when the part is not on this sheet).+pub fn set_not_fitted(sch: &mut Schematic, reference: &str, variant: &str) -> usize {+    let mut n = 0;+    for_each_path(sch, &mut |path| {+        if path.child_value("reference").as_deref() != Some(reference) {+            return;+        }+        let Some(children) = path.children_mut() else { return };+        match children.iter_mut().find(|c| is_list(c, "variant") && c.child_value("name").as_deref() == Some(variant)) {+            Some(v) => {+                if let Some(d) = v.child_mut("dnp") {+                    d.set_arg(0, Node::atom("yes"));+                } else {+                    let mut d = Node::list("dnp", vec![Node::atom("yes")]);+                    pretty(&mut d, 6);+                    let at = v.children().iter().position(|c| is_list(c, "name")).map(|i| i + 1).unwrap_or(1);+                    if let Some(vc) = v.children_mut() {+                        vc.insert(at, d);+                    }+                }+            }+            None => {+                let mut v = Node::list("variant", vec![Node::list("name", vec![Node::quoted(variant)]), Node::list("dnp", vec![Node::atom("yes")])]);+                pretty(&mut v, 5);+                children.push(v);+            }+        }+        n += 1;+    });+    n+}++/// Put `reference` back in `variant`: its whole variation there is dropped (fitted as the symbol is). Returns how many+/// instance paths changed.+pub fn set_fitted(sch: &mut Schematic, reference: &str, variant: &str) -> usize {+    let mut n = 0;+    for_each_path(sch, &mut |path| {+        if path.child_value("reference").as_deref() != Some(reference) {+            return;+        }+        if let Some(children) = path.children_mut() {+            let before = children.len();+            children.retain(|c| !(is_list(c, "variant") && c.child_value("name").as_deref() == Some(variant)));+            n += before - children.len();+        }+    });+    n+}++/// Drop every part's variation in `variant`. Returns how many were dropped.+pub fn remove(sch: &mut Schematic, variant: &str) -> usize {+    let mut n = 0;+    for_each_path(sch, &mut |path| {+        if let Some(children) = path.children_mut() {+            let before = children.len();+            children.retain(|c| !(is_list(c, "variant") && c.child_value("name").as_deref() == Some(variant)));+            n += before - children.len();+        }+    });+    n+}++/// Every reference placed on the sheet (one per instance path).+pub fn references(sch: &Schematic) -> Vec<String> {+    let mut out = Vec::new();+    for sym in sch.top().iter().filter(|n| crate::schematic::is_symbol_instance(n)) {+        for proj in sym.child("instances").map(|i| i.children()).unwrap_or(&[]).iter().filter(|n| is_list(n, "project")) {+            out.extend(proj.children().iter().filter(|n| is_list(n, "path")).filter_map(|p| p.child_value("reference")));+        }+    }+    out+}++/// The project's variant list (`schematic.variants`).+pub fn project_variants(pro: &Value) -> Vec<(String, String)> {+    pro.pointer("/schematic/variants").and_then(Value::as_array).into_iter().flatten()+        .filter_map(|e| Some((e["name"].as_str()?.to_string(), e["description"].as_str().unwrap_or("").to_string()))).collect()+}++/// Add (or describe) a variant in the project; returns true when the project changed.+pub fn project_add(pro: &mut Value, name: &str, description: Option<&str>) -> bool {+    if !pro["schematic"].is_object() {+        pro["schematic"] = json!({});+    }+    if !pro["schematic"]["variants"].is_array() {+        pro["schematic"]["variants"] = json!([]);+    }+    let list = pro["schematic"]["variants"].as_array_mut().expect("array");+    match list.iter_mut().find(|e| e["name"].as_str() == Some(name)) {+        Some(e) => match description {+            Some(d) if e["description"].as_str().unwrap_or("") != d => {+                if d.is_empty() { e.as_object_mut().map(|o| o.remove("description")); } else { e["description"] = json!(d); }+                true+            }+            _ => false,+        },+        None => {+            let mut e = json!({"name": name});+            if let Some(d) = description.filter(|d| !d.is_empty()) {+                e["description"] = json!(d);+            }+            list.push(e);+            true+        }+    }+}++/// Remove a variant from the project; returns true when it was there.+pub fn project_remove(pro: &mut Value, name: &str) -> bool {+    let Some(list) = pro.pointer_mut("/schematic/variants").and_then(Value::as_array_mut) else { return false };+    let before = list.len();+    list.retain(|e| e["name"].as_str() != Some(name));+    before != list.len()+}++/// The standard's answer for every variant: the project's list joined with the sheets' variations.+pub fn summary(pro: &Value, sheets: &[&Schematic]) -> Vec<Value> {+    let mut by: BTreeMap<String, Vec<Variation>> = BTreeMap::new();+    for s in sheets {+        for (k, v) in read(s) {+            by.entry(k).or_default().extend(v);+        }+    }+    let mut names: Vec<(String, String)> = project_variants(pro);+    for k in by.keys() {+        if !names.iter().any(|(n, _)| n == k) {+            names.push((k.clone(), String::new()));+        }+    }+    names.into_iter().map(|(name, description)| {+        let vs = by.remove(&name).unwrap_or_default();+        let mut not_fitted: Vec<&str> = vs.iter().filter(|v| v.dnp == Some(true)).map(|v| v.reference.as_str()).collect();+        not_fitted.sort();+        not_fitted.dedup();+        let alternates: Vec<Value> = vs.iter().filter(|v| !v.fields.is_empty())+            .map(|v| json!({"designator": v.reference, "fields": v.fields.iter().map(|(k, x)| (k.clone(), json!(x))).collect::<serde_json::Map<_, _>>()})).collect();+        let other: Vec<Value> = vs.iter().filter(|v| v.in_bom.is_some() || v.on_board.is_some() || v.in_pos_files.is_some() || v.exclude_from_sim.is_some())+            .map(|v| json!({"designator": v.reference, "inBom": v.in_bom, "onBoard": v.on_board, "inPosFiles": v.in_pos_files, "excludeFromSim": v.exclude_from_sim})).collect();+        json!({"name": name, "description": description, "notFitted": not_fitted, "alternates": alternates, "flags": other,+               "inProject": project_variants(pro).iter().any(|(n, _)| *n == name)})+    }).collect()+}++/// The parts KiCad's own XML netlist marks not fitted in `variant`: DNP on the symbol itself, or a+/// `<variants><variant name="…"><property name="dnp" value="1"/>` block in the part (kicad-cli 10 writes variant+/// differences that way).+pub fn netlist_not_fitted(xml: &str, variant: &str) -> Vec<String> {+    let mut out = Vec::new();+    let mut rest = xml;+    while let Some(i) = rest.find("<comp ref=\"") {+        let after = &rest[i + 11..];+        let Some(q) = after.find('"') else { break };+        let reference = &after[..q];+        let end = after.find("</comp>").unwrap_or(after.len());+        let body = &after[..end];+        let (own, vars) = match body.find("<variants>") {+            Some(k) => (&body[..k], &body[k..body.find("</variants>").unwrap_or(body.len())]),+            None => (body, ""),+        };+        let base = own.contains("<property name=\"dnp\"/>") || own.contains("<property name=\"dnp\" value=\"1\"/>");+        let tag = format!("<variant name=\"{}\">", variant.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;"));+        let in_variant = vars.find(&tag).map(|k| {+            let v = &vars[k..];+            let v = &v[..v.find("</variant>").unwrap_or(v.len())];+            if v.contains("<property name=\"dnp\" value=\"1\"/>") { Some(true) } else if v.contains("<property name=\"dnp\" value=\"0\"/>") { Some(false) } else { None }+        }).flatten();+        if in_variant.unwrap_or(base) {+            out.push(reference.to_string());+        }+        rest = &after[end..];+    }+    out.sort();+    out.dedup();+    out+}++#[cfg(test)]+mod tests {+    #[test]+    fn kicads_netlist_says_which_parts_a_variant_leaves_out() {+        // as kicad-cli 10.0.7 writes it for the pic_programmer demo with C1 not fitted in Lite+        let xml = "<export><components><comp ref=\"C1\">\n<value>100uF</value>\n<property name=\"Sheetname\" value=\"p\"/>\n<variants>\n<variant name=\"Lite\">\n<property name=\"dnp\" value=\"1\"/>\n</variant>\n</variants>\n<property name=\"ki_fp_filters\" value=\"C*\"/>\n</comp>\n<comp ref=\"C2\">\n<value>1n</value>\n</comp>\n<comp ref=\"J9\">\n<property name=\"dnp\"/>\n</comp></components></export>";+        assert_eq!(super::netlist_not_fitted(xml, "Lite"), vec!["C1".to_string(), "J9".to_string()]);+        assert_eq!(super::netlist_not_fitted(xml, "Pro"), vec!["J9".to_string()]);+    }++    use super::*;++    const SCH: &str = "(kicad_sch\n\t(version 20260306)\n\t(generator \"eeschema\")\n\t(uuid \"aaaa\")\n\t(symbol\n\t\t(lib_id \"Device:R\")\n\t\t(at 10 10 0)\n\t\t(uuid \"r1\")\n\t\t(property \"Reference\" \"R1\"\n\t\t\t(at 0 0 0)\n\t\t)\n\t\t(instances\n\t\t\t(project \"p\"\n\t\t\t\t(path \"/aaaa\"\n\t\t\t\t\t(reference \"R1\")\n\t\t\t\t\t(unit 1)\n\t\t\t\t\t(variant\n\t\t\t\t\t\t(name \"Pro\")\n\t\t\t\t\t\t(field\n\t\t\t\t\t\t\t(name \"Value\")\n\t\t\t\t\t\t\t(value \"22k\")\n\t\t\t\t\t\t)\n\t\t\t\t\t)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n\t)\n\t(symbol\n\t\t(lib_id \"Device:C\")\n\t\t(at 20 10 0)\n\t\t(uuid \"c1\")\n\t\t(property \"Reference\" \"C1\"\n\t\t\t(at 0 0 0)\n\t\t)\n\t\t(instances\n\t\t\t(project \"p\"\n\t\t\t\t(path \"/aaaa\"\n\t\t\t\t\t(reference \"C1\")\n\t\t\t\t\t(unit 1)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n\t)\n)\n";++    #[test]+    fn variations_read_mark_put_back_and_remove() {+        let mut s = Schematic::parse(SCH).unwrap();+        let r = read(&s);+        assert_eq!(r["Pro"][0].fields, vec![("Value".to_string(), "22k".to_string())]);+        // C1 not fitted in Lite: a new variation, laid out the way eeschema writes it+        assert_eq!(set_not_fitted(&mut s, "C1", "Lite"), 1);+        let t = s.to_text();+        assert!(t.contains("\t\t\t\t\t(variant\n\t\t\t\t\t\t(name \"Lite\")\n\t\t\t\t\t\t(dnp yes)\n\t\t\t\t\t)"), "{t}");+        // R1 not fitted in Pro too: the existing variation gains dnp, keeping its field+        assert_eq!(set_not_fitted(&mut s, "R1", "Pro"), 1);+        let r = read(&Schematic::parse(&s.to_text()).unwrap());+        assert_eq!((r["Pro"][0].dnp, r["Pro"][0].fields.len()), (Some(true), 1));+        assert_eq!(r["Lite"][0].reference, "C1");+        assert_eq!(set_not_fitted(&mut s, "R9", "Lite"), 0);+        // putting C1 back drops its Lite variation+        assert_eq!(set_fitted(&mut s, "C1", "Lite"), 1);+        assert!(!read(&s).contains_key("Lite"));+        assert_eq!(remove(&mut s, "Pro"), 1);+        assert!(read(&s).is_empty());+        assert_eq!(references(&s), vec!["R1".to_string(), "C1".to_string()]);+    }++    #[test]+    fn the_project_lists_variants_by_name() {+        let mut pro = json!({"schematic": {"variants": []}, "meta": {}});+        assert!(project_add(&mut pro, "Lite", Some("no USB")));+        assert!(!project_add(&mut pro, "Lite", Some("no USB")));+        assert!(project_add(&mut pro, "Pro", None));+        assert_eq!(project_variants(&pro), vec![("Lite".into(), "no USB".into()), ("Pro".into(), String::new())]);+        let s = Schematic::parse(SCH).unwrap();+        let sum = summary(&pro, &[&s]);+        assert_eq!(sum[1]["alternates"], json!([{"designator": "R1", "fields": {"Value": "22k"}}]));+        assert!(project_remove(&mut pro, "Lite") && !project_remove(&mut pro, "Lite"));+    }+}+

Comments

No comments yet.

Log in to comment.