app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← All Pull Requests
kicad-lane: 1.0.69, length tuning, mouse-bite panels, silkscreen prep, component classes, build/test boards, sheet reads #30
Merged opened by Colby Knox 2026-10-28
From kicad-lane. KiCad bridge 1.0.69.
- kicad_panelize separation:"tabs": the copies stand gapMm apart (2 mm default) inside a frame with rails all round.
- Each board's edges are broken at its tabs on Edge.Cuts (the rout path), and every tab carries five 0.6 mm mouse-bite holes on the board side.
- The tab layout and JLCPCB's panel rules (for tabs and for V-score) are the Altium bridge's, and the reply now carries them as jlcpcb.rules, with boardsAt and nearestCopperToEdge.
- kicad_build_board: a new board from a parts list and a netlist: the outline (a rectangle with rounded corners, or a polygon), every footprint from its library by its own origin, pad nets set, read back pin by pin.
- kicad_test_board: one footprint or a ';' list from a library on a new board, or added to an existing one.
- kicad_read_sheet_file: a .kicad_sch's parts read from the file (designator, symbol, value, uuid, unit, position, fields and pins), one entry per placed unit.
- kicad_length_tune: lengthens a net to targetMm, or to matchTo's length (pair skew, a byte lane). It draws a serpentine of square bumps in its longest straight track with room on one side, sized to add exactly the missing length, clear of other copper and the edge. Live as one Undo step, or in the closed file, after KiCad's DRC shows no new error.
- kicad_silkscreen_prep: on a closed board, in one call:
- turns on KiCad's own clipping: the Gerbers subtract the solder mask from the silkscreen;
- deletes silkscreen lying outside the board;
- moves designators that sit on pads;
- reports KiCad's silk DRC counts before and after.
- kicad_component_classes: lists, makes or removes KiCad 9+ component classes. An optional room is a rule area plus a custom DRC rule that flags any member outside it. Both were checked against KiCad 10.0.7's DRC.
Where the code is:
kicad_core::panel: the tab plan,jlc_tabs,jlc_vscoreand edge clearance, ported from altium-bridgepanel.rs0.2.77.Optsgainstabsandgap_mm.kicad_core::board_build(new): outline items, footprints placed from library text, pad nets set and read back.kicad_core::component_classes(new): footprintcomponent_classes, rooms as a rule area plus anenclosedByAreaassertion. Probed with KiCad 10.0.7's DRC before writing.board_checks:length_tune,subtract_mask_from_silk,silk_outside_board.ipc::replace_track: delete and create in one transaction after a DRC preflight.pcb::text_without_itemis shared with the file path.- Bridge:
verbs_build.rs(new).verbs_checks_native.rsadds length_tune, silkscreen_prep and component_classes.verbs_composite.rsadds read_sheet_file.verbs_fab.rsadds the tabs separation.
Tests:
cargo test: kicad-bridge 98 pass. kicad-core passes except the 7 knownschematic_edittests, whose fixture was never pushed.- Full verb battery on DESKTOP-BAR6S0F (KiCad 10.0.7): 295 pass, 1 fail, 1 skip. The failure was component_classes on a board with no .kicad_pro; it now writes
<board>.kicad_drudirectly, and the checks phase rerun gave 26 pass, 0 fail. - By hand:
- a 2x2 mouse-bite panel: the render shows the rout path, tabs and holes; JLCPCB's rules all pass;
- live length_tune: SIG_A 55.55 to 57.55 mm, one Undo step, visible in the editor;
- a class room enforced in DRC.
🤖 Generated with Claude Code
Diff Skip to comments
@@ -1,399 +1,406 @@⋯ 293 unchanged lines ⋯ "kicad_assembly_check", "kicad_jlcpcb_order", "kicad_footprint_from_land_pattern",- "kicad_silk_designators"+ "kicad_silk_designators",+ "kicad_read_sheet_file",+ "kicad_build_board",+ "kicad_test_board",+ "kicad_length_tune",+ "kicad_silkscreen_prep",+ "kicad_component_classes" ], "statusVerb": "kicad_status", "timeouts": {⋯ 96 unchanged lines ⋯ "releasedAt": "2026-06-26T16:00:00Z", "uninstall": "kicad_uninstall" }+@@ -1,1305 +1,1306 @@⋯ 316 unchanged lines ⋯ [[package]] name = "kicad-bridge"-version = "1.0.68"+version = "1.0.69" dependencies = [ "adom-eda-checks", "kicad-core",⋯ 5 unchanged lines ⋯ [[package]] name = "kicad-core"-version = "1.0.68"+version = "1.0.69" dependencies = [ "kicad-ipc-rs", "kicad-platform",⋯ 20 unchanged lines ⋯ [[package]] name = "kicad-platform"-version = "1.0.68"+version = "1.0.69" dependencies = [ "serde", "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"] [workspace.package]-version = "1.0.68"+version = "1.0.69" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+@@ -1,56 +1,58 @@⋯ 43 unchanged lines ⋯ 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 },+ Group { name: "build", verbs: || crate::verbs_build::VERBS, dispatch: crate::verbs_build::dispatch }, ]; pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {⋯ 4 unchanged lines ⋯ } None }+@@ -1,235 +1,237 @@⋯ 40 unchanged lines ⋯ mod verbs_report; mod verbs_order; mod verbs_land_pattern;+mod verbs_build; mod verbs_windows; use std::sync::atomic::{AtomicU64, Ordering};⋯ 186 unchanged lines ⋯ }; let _ = req.respond(json_response(body, code)); }+@@ -0,0 +1,231 @@+//! Verb group "build": new boards from footprints (kicad_core::board_build), the EDA Bridge+//! Standard's build_board and test_board.+//! - `kicad_build_board`: a new board from a parts list and a netlist: outline, every footprint by its+//! own origin, pad nets, read back pin by pin.+//! - `kicad_test_board`: one footprint, or a ';' list, from a library on one board: a new board of+//! wMm x hMm, or appended right of the parts already on an existing one.+//! Footprints come from `libraryPath` (a .pretty folder, by name) or from KiCad's library tables+//! (`Library:Footprint`). The closed board file is written; nothing is opened.++use std::collections::BTreeMap;+use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::board_build::{self, Placed};+use kicad_core::{board_model, pcb, project};++pub static VERBS: &[Verb] = &[+ Verb {+ name: "kicad_build_board",+ summary: "Build a NEW board from a parts list and a netlist: the outline (a rectangle with rounded corners allowed, or a polygon), every footprint from its library placed by its own origin, pad nets assigned, saved; then read back pin by pin.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+ input: "{\"pcbPath\": \"C:/.../x.kicad_pcb\", \"parts\": [{\"ref\", \"footprint\": \"Lib:Name\" | \"Name\" (with libraryPath), \"x\", \"y\", \"rotation\"?, \"side\"?: \"top|bottom\", \"comment\"?}], \"nets\"?: {\"GND\": [\"R1.2\", \"U1.3\"]}, \"outline\": {\"rect\": [x0, y0, w, h], \"cornerRadius\"?} | {\"points\": [[x, y], ...]}, \"libraryPath\"?: \"C:/.../Mine.pretty\", \"layers\"?: 2, \"frame\"?: \"standard|kicad\"}",+ example: "kicad_build_board {\"pcbPath\":\"C:/p/b.kicad_pcb\",\"outline\":{\"rect\":[0,0,30,20],\"cornerRadius\":1},\"parts\":[{\"ref\":\"R1\",\"footprint\":\"Resistor_SMD:R_0603_1608Metric\",\"x\":10,\"y\":10}],\"nets\":{\"VIN\":[\"R1.1\"]}}",+ hint: "Coordinates are the standard's (mm, y up) unless frame:\"kicad\"; x, y is the footprint's own origin. Pad names must match the netlist's pin numbers (R1.1 = pad 1 of R1). wrongPins lists any pin that did not get its net; unconnected is the ratsnest left to route (kicad_route_net, kicad_autoroute).",+ related: &["kicad_test_board", "kicad_route_net", "kicad_update_pcb_from_schematic", "kicad_routing_state"],+ pitfalls: &["A footprint missing from the library refuses the whole build (footprint_not_found), nothing written.", "A new board each call: pcbPath is replaced (a .adom-bak is kept of an existing file)."],+ },+ Verb {+ name: "kicad_test_board",+ summary: "Place one footprint, or a ';' list, from a library on ONE board: a NEW board (wMm x hMm outline) when pcbPath does not exist, APPENDED right of the parts already there when it does; optionally pad nets on the last part.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+ input: "{\"libraryPath\": \"C:/.../Mine.pretty\", \"footprintName\": \"A\" | \"A;B;C\", \"pcbPath\": \"C:/.../test.kicad_pcb\", \"wMm\"?: 30, \"hMm\"?: 20, \"assigns\"?: \"1=GND;2=VCC\"}",+ example: "kicad_test_board {\"libraryPath\":\"C:/Users/john/kicad/Mine.pretty\",\"footprintName\":\"SOIC-8_test;QFN-16_test\",\"pcbPath\":\"C:/Users/john/kicad/test.kicad_pcb\"}",+ hint: "The parts land in a row, 3 mm apart; designators continue from those on the board (U1, U2, ...). Look with kicad_show_3d_board or kicad_render_board. Run again with the same pcbPath to add more.",+ related: &["kicad_footprint_from_land_pattern", "kicad_build_board", "kicad_render_board", "kicad_show_3d_board"],+ pitfalls: &["assigns apply to the LAST part placed (pad=NET).", "Appending does not grow the outline: parts can land outside it (kicad_board_outline resizes it).", "stubs (Altium's) are not drawn here."],+ },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+ Some(match command {+ "kicad_build_board" => build_board(state, args),+ "kicad_test_board" => test_board(state, args),+ _ => return None,+ })+}++/// A footprint's library text and its Library:Name id: by name in `library` (a .pretty folder), or+/// by Library:Name through KiCad's tables.+fn footprint(state: &mut State, name: &str, library: Option<&str>, board_dir: &Path) -> Result<(String, String), String> {+ if let Some(lib) = library.filter(|_| !name.contains(':')) {+ let dir = PathBuf::from(lib.trim_end_matches(['/', '\\']));+ let f = dir.join(format!("{name}.kicad_mod"));+ let nick = dir.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();+ return std::fs::read_to_string(&f).map(|t| (format!("{nick}:{name}"), t)).map_err(|_| format!("{name}.kicad_mod is not in {}", norm(&dir)));+ }+ if !name.contains(':') {+ return Err(format!("{name}: give Library:Footprint, or pass libraryPath (a .pretty folder)"));+ }+ let info = state.kicad_info();+ let ctx = crate::verbs_pcb_sync::lib_ctx(&info);+ let f = kicad_core::pcb_sync::resolve_footprint(name, board_dir, &ctx)?;+ std::fs::read_to_string(&f).map(|t| (name.to_string(), t)).map_err(|e| format!("{}: {e}", norm(&f)))+}++fn write(path: &Path, text: &str, old: Option<&str>) -> Result<Option<String>, Value> {+ if let Some(dir) = path.parent() {+ let _ = std::fs::create_dir_all(dir);+ }+ match old {+ Some(o) => pcb::write_board(path, text, true, Some(o)),+ None => kicad_core::libraries::write_atomic(path, text.as_bytes()).map(|_| None).map_err(|e| fail("write_failed", e, "Check the folder is writable.")),+ }+}++fn readback(text: &str, file: &str) -> Value {+ let m = board_model::build(text, Some(file), &board_model::Opts { y_up: true }).unwrap_or(Value::Null);+ json!({"counts": m["counts"], "unconnected": m["unconnected"], "revision": m["revision"]})+}++fn build_board(state: &mut State, args: &Value) -> Value {+ let Some(pcb_path) = arg_any(args, &["pcbPath", "filePath"]).map(PathBuf::from) else {+ return fail("missing_arg", "pcbPath is required", "The .kicad_pcb to write (a new board each call).");+ };+ let y_up = args.get("frame").and_then(Value::as_str) != Some("kicad");+ let fy = |y: f64| if y_up { -y } else { y };+ let (outline, _) = match board_build::outline_items(args.get("outline").unwrap_or(&Value::Null), y_up) {+ Ok(x) => x,+ Err(e) => return fail("bad_args", e, "outline: {rect: [x0, y0, w, h], cornerRadius?} or {points: [[x, y], ...]} in mm."),+ };+ let layers = args.get("layers").and_then(Value::as_u64).unwrap_or(2) as u32;+ let blank = match project::blank_board(None, layers) {+ Ok(b) => b,+ Err(e) => return fail("bad_args", e, "layers is an even number from 2 to 32."),+ };+ let dir = pcb_path.parent().map(Path::to_path_buf).unwrap_or_default();+ let library = arg_str(args, "libraryPath");+ let mut placed = Vec::new();+ let mut missing = Vec::new();+ for p in args.get("parts").and_then(Value::as_array).into_iter().flatten() {+ let (Some(r), Some(fp)) = (p["ref"].as_str(), p["footprint"].as_str()) else {+ return fail("bad_args", "every part needs ref and footprint", "parts: [{ref, footprint, x, y, rotation?}]");+ };+ match footprint(state, fp, library, &dir) {+ Ok((lib_id, text)) => placed.push(Placed { reference: r.to_string(), lib_id, text, x: p["x"].as_f64().unwrap_or(0.0), y: fy(p["y"].as_f64().unwrap_or(0.0)),+ rotation: p["rotation"].as_f64().unwrap_or(0.0), back: p["side"] == "bottom", value: p["comment"].as_str().map(String::from) }),+ Err(e) => missing.push(json!({"ref": r, "footprint": fp, "why": e})),+ }+ }+ if !missing.is_empty() {+ let mut v = fail("footprint_not_found", format!("{} footprint(s) not found; nothing written", missing.len()), "Check the names: Library:Footprint as KiCad's table lists them (kicad_search_library), or a name in libraryPath.");+ v["missingParts"] = json!(missing);+ return v;+ }+ if placed.is_empty() {+ return fail("bad_args", "parts is empty", "parts: [{ref, footprint, x, y}]");+ }+ let nets: BTreeMap<String, Vec<String>> = args.get("nets").and_then(Value::as_object).map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_array().into_iter().flatten().filter_map(|p| p.as_str().map(String::from)).collect())).collect()).unwrap_or_default();+ let (text, mut rep) = match board_build::build(&blank, &outline, &placed, &nets) {+ Ok(x) => x,+ Err(e) => return fail("build_failed", e, "A footprint file KiCad cannot parse refuses the build: kicad_lint_library checks one."),+ };+ // an existing file is replaced with a backup beside it+ let old = std::fs::read_to_string(&pcb_path).ok();+ let bak = match write(&pcb_path, &text, old.as_deref()) {+ Ok(b) => b,+ Err(e) => return e,+ };+ let back = readback(&text, &norm(&pcb_path));+ rep["success"] = json!(true);+ rep["pcbPath"] = json!(norm(&pcb_path));+ rep["backupPath"] = json!(bak);+ rep["missingParts"] = json!([]);+ for k in ["counts", "unconnected", "revision"] {+ rep[k] = back[k].clone();+ }+ rep["output"] = json!(format!("{} part(s), {} pad(s) wired, {} unconnected: {}", rep["placed"], rep["padsWired"], rep["unconnected"], norm(&pcb_path)));+ rep["_hint"] = json!("Route the ratsnest (kicad_autoroute, kicad_route_net on the open board), look at it (kicad_render_board), or open it in KiCad (kicad_open_board).");+ rep+}++fn test_board(state: &mut State, args: &Value) -> Value {+ let Some(pcb_path) = arg_any(args, &["pcbPath", "filePath"]).map(PathBuf::from) else {+ return fail("missing_arg", "pcbPath is required", "The board to make, or to add the parts to.");+ };+ let names: Vec<String> = arg_any(args, &["footprintName", "footprint"]).unwrap_or("").split(';').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();+ if names.is_empty() {+ return fail("missing_arg", "footprintName is required", "One name, or several separated by ';'.");+ }+ let dir = pcb_path.parent().map(Path::to_path_buf).unwrap_or_default();+ let existing = std::fs::read_to_string(&pcb_path).ok();+ let appended = existing.is_some();+ let (board, outline, mut x, y, mut next) = match &existing {+ Some(t) => {+ let m = board_model::build(t, None, &board_model::Opts { y_up: false }).unwrap_or(Value::Null);+ let comps = m["components"].as_array().cloned().unwrap_or_default();+ let max_x = m["pads"].as_array().into_iter().flatten().filter_map(|p| p["x"].as_f64()).fold(f64::MIN, f64::max);+ let ys: Vec<f64> = m["outline"].as_array().into_iter().flatten().filter_map(|p| p[1].as_f64()).collect();+ let cy = if ys.is_empty() { 0.0 } else { (ys.iter().cloned().fold(f64::MAX, f64::min) + ys.iter().cloned().fold(f64::MIN, f64::max)) / 2.0 };+ let n = comps.iter().filter_map(|c| c["ref"].as_str()?.strip_prefix('U')?.parse::<u64>().ok()).max().unwrap_or(0);+ (t.clone(), Vec::new(), if max_x > f64::MIN { max_x + 5.0 } else { 5.0 }, cy, n + 1)+ }+ None => {+ let (w, h) = (args.get("wMm").and_then(Value::as_f64).unwrap_or(30.0), args.get("hMm").and_then(Value::as_f64).unwrap_or(20.0));+ let blank = match project::blank_board(None, 2) {+ Ok(b) => b,+ Err(e) => return fail("build_failed", e, ""),+ };+ let (items, _) = match board_build::outline_items(&json!({"rect": [0, 0, w, h]}), false) {+ Ok(x) => x,+ Err(e) => return fail("bad_args", e, "wMm and hMm are the new board's size in mm."),+ };+ (blank, items, 3.0, h / 2.0, 1)+ }+ };+ let library = arg_str(args, "libraryPath");+ let mut placed = Vec::new();+ let mut refs = Vec::new();+ for name in &names {+ let (lib_id, text) = match footprint(state, name, library, &dir) {+ Ok(x) => x,+ Err(e) => return fail("footprint_not_found", e, "libraryPath is the .pretty folder holding it; kicad_fp_info lists one."),+ };+ let width = kicad_core::fpedit::info(&text).ok().and_then(|i| i["courtyard"]["widthMm"].as_f64()).unwrap_or(5.0);+ let r = format!("U{next}");+ next += 1;+ placed.push(Placed { reference: r.clone(), lib_id, text, x: x + width / 2.0, y, rotation: 0.0, back: false, value: Some(name.clone()) });+ x += width + 3.0;+ refs.push(r);+ }+ // assigns: pad=NET on the last part+ let mut nets: BTreeMap<String, Vec<String>> = BTreeMap::new();+ if let (Some(a), Some(last)) = (arg_str(args, "assigns"), refs.last()) {+ for pair in a.split(';').map(str::trim).filter(|s| !s.is_empty()) {+ if let Some((pad, net)) = pair.split_once('=') {+ nets.entry(net.trim().to_string()).or_default().push(format!("{last}.{}", pad.trim()));+ }+ }+ }+ let (text, rep) = match board_build::build(&board, &outline, &placed, &nets) {+ Ok(x) => x,+ Err(e) => return fail("build_failed", e, "kicad_lint_library checks a footprint file."),+ };+ let bak = match write(&pcb_path, &text, existing.as_deref()) {+ Ok(b) => b,+ Err(e) => return e,+ };+ let saved = std::fs::read_to_string(&pcb_path).map(|t| t == text).unwrap_or(false);+ let last = placed.last().map(|p| (p.text.matches("(pad ").count(), p.text.matches("(model ").count())).unwrap_or((0, 0));+ let existing_parts = existing.as_deref().and_then(|t| kicad_core::fab::parts_from_board(t).ok()).map_or(0, |p| p.len());+ json!({"success": true, "pcb": norm(&pcb_path), "appended": appended, "count": placed.len(), "existingParts": existing_parts, "placed": names.join(";"), "designators": refs,+ "padsOnComp": last.0, "bodiesOnComp": last.1, "saved": saved, "backupPath": bak, "wrongPins": rep["wrongPins"],+ "output": format!("{} part(s) {} {}", placed.len(), if appended { "added to" } else { "on a new board" }, norm(&pcb_path)),+ "_hint": "Look with kicad_render_board {filePath: pcb} or kicad_show_3d_board; run again with the same pcbPath to add more parts."})+}++#[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);+ }+ }+}+@@ -1,707 +1,1011 @@⋯ 90 unchanged lines ⋯ 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_length_tune",+ summary: "Lengthen a routed net to a target length, or to match another net (a pair's skew, a DDR byte lane): a serpentine in its longest straight track that has room beside it, the bumps sized to add exactly the length missing, every bump clear of other copper and inside the board. Live as one Undo step after a DRC check, else in the closed file.",+ mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 180,+ input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"net\": \"DQ3\", \"targetMm\"?: 42.5 | \"matchTo\"?: \"DQS\", \"clearance\"?: 0.2, \"maxAmplitudeMm\"?: 3, \"pitchMm\"?: \"4 x width\", \"dryRun\"?: false, \"expectedRevision\"?, \"save\"?: false, \"backup\"?: true}",+ example: "kicad_length_tune {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"net\":\"USB_D-\",\"matchTo\":\"USB_D+\",\"dryRun\":true}",+ hint: "plan {segmentId, layer, width, bumps, amplitudeMm, pitchMm, addedMm, side, points}; lengthBeforeMm, lengthMm and errorMm (target minus the new length, about 0). kicad_net_lengths reads the result back. A net already long enough answers changed:false: shorten it by rerouting.",+ related: &["kicad_net_lengths", "kicad_route_net", "kicad_diff_pairs", "kicad_routing_validate"],+ pitfalls: &["One track holds the whole serpentine: a net with no straight run long enough, or no room beside it, answers no_room (a bigger maxAmplitudeMm or a smaller pitchMm may fit).", "Square bumps: KiCad's interactive tuner (Route > Tune Length) draws rounded ones."],+ },+ Verb {+ name: "kicad_silkscreen_prep",+ summary: "Get the silkscreen off the copper for fab: the Gerbers set to subtract the solder mask from the silkscreen (KiCad's own clipping), silkscreen lying outside the board deleted, designators that sit on pads moved beside their parts, and KiCad's silkscreen DRC findings counted before and after.",+ // File: it runs kicad_run_drc and kicad_silk_designators (verbs::call), which take the lock themselves+ mechanism: Mechanism::File, risk: "write", timeout_sec: 600,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"check\"?: true, \"deleteOutsideBoard\"?: true, \"moveText\"?: true, \"textSize\"?: \"the board's usual designator height\", \"backup\"?: true}",+ example: "kicad_silkscreen_prep {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+ hint: "before/after {silkToSolderMask, silkToSilk, silkToBoardRegion, maskSliver} are KiCad's DRC counts (silk_over_copper, silk_overlap, silk_edge_clearance, solder_mask_bridge); steps says what changed. gerbersClipSilk:true means the fab output carries no ink on pads even where the board drawing still overlaps (KiCad clips at plot time). Run it before kicad_export_manufacturing or kicad_jlcpcb_order.",+ related: &["kicad_silk_designators", "kicad_silk_state", "kicad_export_gerber", "kicad_fab_check"],+ pitfalls: &["The board must be closed in KiCad (saved): the edits go into the file, with a backup.", "Footprint silkscreen that overlaps its own pads is clipped in the Gerbers, not redrawn: the DRC still counts it in after.silkToSolderMask.", "Designators with no clear spot stay where they are (moved.unplaced)."],+ },+ Verb {+ name: "kicad_component_classes",+ summary: "List, make or remove component classes on the board (KiCad 9+), each optionally with a room: a rule area that keeps the class's parts together (fitted around their pads or a rectangle), enforced by a custom DRC rule. The classes scope rules (A.hasComponentClass('<name>')) and group a channel for kicad_replicate_layout.",+ // File: the room's rule goes through kicad_custom_rules (verbs::call)+ mechanism: Mechanism::File, risk: "write", timeout_sec: 60,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"name\"?: \"Channel A\" (omit to list), \"members\"?: [\"U1\", \"R1\"], \"room\"?: true | {\"marginMm\": 1} | {\"rect\": [x1, y1, x2, y2]}, \"remove\"?: false, \"frame\"?: \"standard|kicad\", \"backup\"?: true}",+ example: "kicad_component_classes {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"name\":\"Channel A\",\"members\":[\"U1\",\"R1\",\"C1\"],\"room\":true}",+ hint: "Without name: classes [{name, members}] and rooms [{class, rect}]. With name and members: the class is (re)made, missing lists designators not on the board; room adds the rule area 'Adom room <name>' and the rule of that name in the project's .kicad_dru, so a member outside it is a DRC violation (assertion_failure), not a move. remove:true takes the class, its room and its rule away.",+ related: &["kicad_replicate_layout", "kicad_custom_rules", "kicad_run_drc", "kicad_netclasses"],+ pitfalls: &["Edits need the board closed in KiCad (saved): they go into the file, with a backup.", "KiCad's Update PCB from Schematic can reassign classes set on the schematic side (a symbol's Component Class field); classes made here live on the board."],+ },+ 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,⋯ 25 unchanged lines ⋯ "kicad_via_fence" => via_job(state, args, ViaJob::Fence), "kicad_auto_classes" => auto_classes(state, args), "kicad_silk_designators" => silk_designators(state, args),+ "kicad_length_tune" => length_tune(state, args),+ "kicad_silkscreen_prep" => silkscreen_prep(state, args),+ "kicad_component_classes" => component_classes(state, args), "kicad_pin_pad_check" => pin_pad_check(state, args), _ => return None, })⋯ 284 unchanged lines ⋯ 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.")) } +/// kicad_length_tune: the plan from one read in KiCad's frame, then the track swapped live (one Undo+/// step after KiCad's DRC) or in the closed file (same check, with a backup).+fn length_tune(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 };+ let (board, model) = match (pcb::parse_pcb_text(&r.text, &r.file), board_model::build(&r.text, Some(&r.file), &board_model::Opts { y_up: false })) {+ (Ok(b), Ok(m)) => (b, m),+ (Err(e), _) | (_, Err(e)) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+ };+ let mut v = match board_checks::length_tune(&board, &model, args) {+ Ok(v) => v,+ Err((code, msg)) => return fail(&code, msg, "kicad_net_lengths shows the net's tracks; a larger maxAmplitudeMm, a smaller pitchMm or a longer straight run gives the serpentine room."),+ };+ let standard_rev = board_model::revision_of_text(&r.text).unwrap_or_default();+ v["success"] = json!(true);+ v["revision"] = json!(standard_rev);+ let show = |v: &mut Value| if o.y_up {+ if let Some(pts) = v["plan"]["points"].as_array_mut() {+ for p in pts {+ p[1] = json!(-p[1].as_f64().unwrap_or(0.0) + 0.0);+ }+ }+ v["frame"] = json!("standard");+ } else {+ v["frame"] = json!("kicad");+ };+ if v["changed"] != true || args.get("dryRun") == Some(&json!(true)) {+ if v["changed"] == true {+ v["dryRun"] = json!(true);+ }+ show(&mut v);+ stamp(&mut v, &r);+ v["_hint"] = json!(if v["changed"] == true { "A plan only: run again without dryRun to draw the serpentine." } else { "Nothing to add." });+ return v;+ }+ let plan = v["plan"].clone();+ let applied = if r.live {+ place_track_live(state, args, &standard_rev, &plan)+ } else {+ track_into_file(state, args, &r, &standard_rev, &plan)+ };+ match applied {+ Ok(done) => {+ for k in ["mutated", "removedIds", "itemIds", "undoSteps", "drc", "saved", "backupPath", "postCommitError"] {+ if !done[k].is_null() {+ v[k] = done[k].clone();+ }+ }+ if !done["revision"].is_null() {+ v["revision"] = done["revision"].clone();+ }+ show(&mut v);+ stamp(&mut v, &r);+ v["_hint"] = json!(format!("{} bump(s) of {} mm added {} mm to {}. kicad_net_lengths reads it back.", plan["bumps"], plan["amplitudeMm"], plan["addedMm"], v["net"].as_str().unwrap_or("")));+ v+ }+ Err(e) => e,+ }+}++#[cfg(feature = "ipc")]+fn place_track_live(state: &mut State, args: &Value, standard_rev: &str, plan: &Value) -> Result<Value, Value> {+ use kicad_core::{detect, ipc};+ let info = state.kicad_info();+ let ctx = ipc::Ctx {+ kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+ config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),+ };+ let mut a = json!({"segmentId": plan["segmentId"], "points": plan["points"], "expectedRevision": args.get("expectedRevision").cloned().unwrap_or(json!(standard_rev))});+ for k in ["filePath", "socketPath", "save", "validate"] {+ if let Some(v) = args.get(k) {+ a[k] = v.clone();+ }+ }+ ipc::with_standard_revision(&ctx, &a, |a| ipc::replace_track(&ctx, a, "Adom length tune")).map_err(|e| ipc::error_response(&e))+}++#[cfg(not(feature = "ipc"))]+fn place_track_live(_state: &mut State, _args: &Value, _rev: &str, _plan: &Value) -> Result<Value, Value> {+ Err(fail("ipc_feature_off", "This build of the bridge has the ipc feature off", "Close the board in KiCad and run again to write the file."))+}++/// The closed board: the track out, the serpentine in, KiCad's DRC first, a backup kept.+fn track_into_file(state: &mut State, args: &Value, r: &Read, standard_rev: &str, plan: &Value) -> Result<Value, Value> {+ if let Some(want) = args.get("expectedRevision").and_then(Value::as_str) {+ if want != standard_rev {+ return Err(fail("stale_board", "expectedRevision does not match the board file", "Read it again and pass its revision."));+ }+ }+ let board = pcb::parse_pcb_text(&r.text, &r.file).map_err(|e| fail("board_unparsable", e, "Open and save it in KiCad to repair it."))?;+ let id = plan["segmentId"].as_str().unwrap_or("");+ let seg = board.segments.iter().find(|g| g.uuid.as_deref() == Some(id)).ok_or_else(|| fail("track_not_found", format!("no track {id}"), "Read the board again."))?;+ let name = seg.net_name.clone().or_else(|| board.nets.iter().find(|n| n.number.is_some() && n.number == seg.net).map(|n| n.name.clone())).unwrap_or_default();+ let net = pcb::resolve_net(&board, Some(&json!(name)))?;+ let pts: Vec<(f64, f64)> = plan["points"].as_array().into_iter().flatten().filter_map(|p| Some((p[0].as_f64()?, p[1].as_f64()?))).collect();+ let (layer, width) = (seg.layer.clone().unwrap_or_default(), seg.width.unwrap_or(0.2));+ let blocks: Vec<String> = pts.windows(2).map(|w| pcb::segment_sexpr(w[0], w[1], width, &layer, &net)).collect();+ let without = pcb::text_without_item(&r.text, id).ok_or_else(|| fail("track_not_found", format!("track {id} is not in the file"), "Read the board again."))?;+ let new_text = pcb::splice(&without, &blocks);+ let drc = file_preflight(state, args, r, &new_text)?;+ let backup = args.get("backup") != Some(&json!(false));+ let bak = pcb::write_board(std::path::Path::new(&r.file), &new_text, backup, Some(&r.text))?;+ Ok(json!({"mutated": true, "removedIds": [id], "drc": drc, "backupPath": bak, "revision": board_model::revision_of_text(&new_text).unwrap_or_default()}))+}++/// KiCad's silkscreen DRC findings by the standard's names.+fn silk_counts(state: &mut State, file: &str) -> Value {+ let r = call(state, "kicad_run_drc", &json!({"filePath": file}));+ if r["success"] != true {+ return json!({"ran": false, "error": r["error"]});+ }+ let count = |t: &str| r["data"]["violations"].as_array().map_or(0, |a| a.iter().filter(|v| v["type"] == t).count());+ json!({"silkToSolderMask": count("silk_over_copper"), "silkToSilk": count("silk_overlap"), "silkToBoardRegion": count("silk_edge_clearance"), "maskSliver": count("solder_mask_bridge")})+}++/// kicad_silkscreen_prep: KiCad's plot-time clipping turned on, off-board silk deleted, designators on+/// pads moved (kicad_silk_designators), DRC counts before and after. The closed board only.+fn silkscreen_prep(state: &mut State, args: &Value) -> Value {+ let r = match read(state, args) { Ok(r) => r, Err(e) => return e };+ if r.live {+ return fail("needs_closed_board", "the board is open in the PCB editor: the prep edits the file", "Save the board, close it in KiCad, and run this again.");+ }+ let check = args.get("check") != Some(&json!(false));+ let before = if check { silk_counts(state, &r.file) } else { Value::Null };+ let mut steps: Vec<Value> = Vec::new();+ let (mut text, clip) = board_checks::subtract_mask_from_silk(&r.text);+ steps.push(json!({"step": "Gerbers subtract the solder mask from the silkscreen", "changed": clip}));+ if args.get("deleteOutsideBoard") != Some(&json!(false)) {+ let model = board_model::build(&text, Some(&r.file), &board_model::Opts { y_up: false }).unwrap_or(Value::Null);+ 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();+ let (t, removed) = board_checks::silk_outside_board(&text, &outline);+ text = t;+ steps.push(json!({"step": "silkscreen outside the board deleted", "removed": removed.len()}));+ }+ let mut bak = Value::Null;+ if text != r.text {+ match pcb::write_board(std::path::Path::new(&r.file), &text, args.get("backup") != Some(&json!(false)), Some(&r.text)) {+ Ok(b) => bak = json!(b),+ Err(e) => return e,+ }+ }+ let mut moved = Value::Null;+ if args.get("moveText") != Some(&json!(false)) {+ // which labels sit on pads, and the size the board's designators mostly have+ let silk = kicad_core::board_reads::silk_state(&text, false).unwrap_or(Value::Null);+ let model = board_model::build(&text, Some(&r.file), &board_model::Opts { y_up: false }).unwrap_or(Value::Null);+ let plan = board_checks::silk_designators(&silk, &model, &[], &json!({}));+ let on: Vec<Value> = plan["onPadsBefore"].as_array().cloned().unwrap_or_default().iter().map(|o| o["ref"].clone()).collect();+ let size = args.get("textSize").and_then(Value::as_f64).unwrap_or_else(|| {+ let mut hs: Vec<i64> = silk["parts"].as_array().into_iter().flatten().filter_map(|p| p["designator"]["heightMm"].as_f64()).map(|h| (h * 100.0).round() as i64).collect();+ hs.sort();+ let mode = hs.chunk_by(|a, b| a == b).max_by_key(|c| c.len()).and_then(|c| c.first().copied()).unwrap_or(100);+ mode as f64 / 100.0+ });+ if on.is_empty() {+ steps.push(json!({"step": "designators on pads moved", "moved": 0}));+ } else {+ let m = call(state, "kicad_silk_designators", &json!({"filePath": r.file, "source": "file", "refs": on, "size": size, "minSize": size}));+ steps.push(json!({"step": "designators on pads moved", "moved": m["placed"], "success": m["success"], "error": m.get("error")}));+ moved = json!({"placed": m["planned"], "unplaced": m["unplaced"], "onPadsAfter": m["onPadsAfter"], "size": size});+ }+ }+ let after = if check { silk_counts(state, &r.file) } else { Value::Null };+ let mut out = json!({"success": true, "before": before, "after": after, "steps": steps, "moved": moved, "backupPath": bak,+ "gerbersClipSilk": true, "silkOffCopper": after["silkToSolderMask"] == json!(0), "dialogsLeft": [], "file": r.file, "source": "file",+ "_hint": "The Gerbers now clip silk off every mask opening. after.silkToSolderMask counts drawing that still overlaps pads on the board itself (footprint art); the fab output carries none of it. Next: kicad_fab_check, kicad_jlcpcb_order."});+ stamp(&mut out, &r);+ out+}++/// kicad_component_classes: list, (re)make with an optional room, or remove (kicad_core::component_classes).+fn component_classes(state: &mut State, args: &Value) -> Value {+ use kicad_core::component_classes as cc;+ 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 };+ // rooms answer in the caller's frame+ let r4 = |v: f64| (v * 1e4).round() / 1e4 + 0.0;+ let show = |rect: [f64; 4]| if o.y_up { json!([r4(rect[0]), r4(-rect[3]), r4(rect[2]), r4(-rect[1])]) } else { json!(rect.map(r4)) };+ let listing = |text: &str| -> Value {+ let mut l = cc::list(text).unwrap_or(json!({"classes": [], "rooms": []}));+ if let Some(rooms) = l["rooms"].as_array_mut() {+ for rm in rooms {+ let a: Vec<f64> = rm["rect"].as_array().into_iter().flatten().filter_map(Value::as_f64).collect();+ if a.len() == 4 {+ rm["rect"] = show([a[0], a[1], a[2], a[3]]);+ }+ }+ }+ l+ };+ let Some(name) = arg_str(args, "name").filter(|n| !n.trim().is_empty()) else {+ let mut v = listing(&r.text);+ v["success"] = json!(true);+ v["frame"] = json!(if o.y_up { "standard" } else { "kicad" });+ stamp(&mut v, &r);+ v["_hint"] = json!("Make one with name and members (room:true keeps them together); scope rules with A.hasComponentClass('<name>').");+ return v;+ };+ if name.contains(['\'', '"']) {+ return fail("bad_args", "a class name cannot hold quotes", "Use letters, digits, spaces, - and _.");+ }+ if r.live {+ return fail("needs_closed_board", "the board is open in the PCB editor: classes and rooms are written into the file", "Save the board, close it in KiCad, and run this again (listing works live).");+ }+ let file = r.file.clone();+ let mut text = r.text.clone();+ let mut out = json!({"success": true, "name": name});+ let rule_name = cc::room_name(name);+ // the board's own rules file (KiCad reads <board>.kicad_dru, with or without a .kicad_pro)+ let dru = norm(&PathBuf::from(&file).with_extension("kicad_dru"));+ if args.get("remove") == Some(&json!(true)) {+ text = match cc::remove_class(&text, name).and_then(|t| cc::remove_room(&t, name)) { Ok(t) => t, Err(e) => return fail("edit_failed", e, "") };+ let rr = call(state, "kicad_custom_rules", &json!({"rulesPath": dru, "remove": [rule_name]}));+ out["ruleRemoved"] = json!(rr["success"] == true);+ out["removed"] = json!(true);+ } else {+ let members: Vec<String> = args.get("members").and_then(Value::as_array).into_iter().flatten().filter_map(|m| m.as_str().map(String::from)).collect();+ if members.is_empty() {+ return fail("bad_args", "members is required to make a class", "members: [\"U1\", \"R1\"]; leave name out to list.");+ }+ let (t, missing) = match cc::set_class(&text, name, &members) { Ok(x) => x, Err(e) => return fail("edit_failed", e, "") };+ text = t;+ out["members"] = json!(members.iter().filter(|m| !missing.contains(m)).collect::<Vec<_>>());+ out["missing"] = json!(missing);+ if let Some(room) = args.get("room").filter(|v| **v != json!(false) && !v.is_null()) {+ let rect = match room.get("rect").and_then(Value::as_array) {+ Some(a) if a.len() == 4 => {+ let v: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();+ let fy = |y: f64| if o.y_up { -y } else { y };+ let (ya, yb) = (fy(v[1]), fy(v[3]));+ [v[0].min(v[2]), ya.min(yb), v[0].max(v[2]), ya.max(yb)]+ }+ _ => {+ let margin = room.get("marginMm").and_then(Value::as_f64).unwrap_or(1.0);+ let model = board_model::build(&text, Some(&file), &board_model::Opts { y_up: false }).unwrap_or(Value::Null);+ let mine: Vec<(f64, f64, f64, f64)> = model["pads"].as_array().into_iter().flatten()+ .filter(|p| members.iter().any(|m| p["ref"].as_str().map_or(false, |r| r.eq_ignore_ascii_case(m))))+ .map(|p| { let (x, y) = (p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0)); let h = p["sizeX"].as_f64().unwrap_or(0.0).max(p["sizeY"].as_f64().unwrap_or(0.0)) / 2.0; (x - h, y - h, x + h, y + h) }).collect();+ if mine.is_empty() {+ return fail("no_pads", "none of the members has pads on the board to fit a room around", "Pass room: {rect: [x1, y1, x2, y2]}.");+ }+ let b = mine.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.2), a.3.max(p.3)));+ [b.0 - margin, b.1 - margin, b.2 + margin, b.3 + margin]+ }+ };+ text = match cc::remove_room(&text, name) { Ok(t) => t, Err(e) => return fail("edit_failed", e, "") };+ text = pcb::splice(&text, &[cc::room_zone(name, rect)]);+ out["room"] = json!(cc::room_rule(name));+ out["roomMm"] = show(rect);+ let rr = call(state, "kicad_custom_rules", &json!({"rulesPath": dru, "add": {"text": cc::room_rule(name)}, "replace": true}));+ if rr["success"] != true {+ return rr;+ }+ }+ }+ match pcb::write_board(std::path::Path::new(&file), &text, args.get("backup") != Some(&json!(false)), Some(&r.text)) {+ Ok(b) => out["backupPath"] = json!(b),+ Err(e) => return e,+ }+ let l = listing(&text);+ out["classes"] = l["classes"].clone();+ out["rooms"] = l["rooms"].clone();+ out["frame"] = json!(if o.y_up { "standard" } else { "kicad" });+ stamp(&mut out, &r);+ out["_hint"] = json!("Written into the closed board (a .adom-bak beside it). kicad_run_drc checks the room: a member outside it is an assertion_failure. Scope other rules with A.hasComponentClass('<name>').");+ out+}+ /// 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");⋯ 233 unchanged lines ⋯ #[test] fn composite_and_live_mechanisms_are_safe() { // height_check and auto_classes call other verbs (verbs::call): they must not hold the GUI lock- for n in ["kicad_height_check", "kicad_auto_classes", "kicad_pin_pad_check"] {+ for n in ["kicad_height_check", "kicad_auto_classes", "kicad_pin_pad_check", "kicad_silk_designators", "kicad_silkscreen_prep", "kicad_component_classes"] { assert!(matches!(VERBS.iter().find(|v| v.name == n).unwrap().mechanism, Mechanism::File), "{n}"); } }⋯ 47 unchanged lines ⋯ assert!(o["checked"].as_u64().unwrap() > 10, "{o}"); } }+@@ -1,382 +1,441 @@⋯ 18 unchanged lines ⋯ pub static VERBS: &[Verb] = &[ Verb {+ name: "kicad_read_sheet_file",+ summary: "Read a schematic sheet straight from its .kicad_sch file, with no KiCad running: every placed part unit with its designator, library symbol, value, description, uuid, unit and unit count, position, rotation, flags, and optionally its fields and pins.",+ mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+ input: "{\"path\" | \"filePath\": \"C:/.../x.kicad_sch\", \"params\"?: false, \"pins\"?: true}",+ example: "kicad_read_sheet_file {\"path\":\"C:/Users/john/proj/proj.kicad_sch\",\"params\":true}",+ hint: "components[] {designator, libReference, sourceLibrary, comment (the value), description, uniqueId (the unit's uuid), partId (unit), partCount, xMm, yMm (KiCad's sheet frame, y down), xMil, yMil, orientation, mirror, footprint, dnp, inBom, onBoard, params?, pins?}. One sheet: kicad_sch_hierarchy lists the others. The saved file: an open editor's unsaved edits are not in it.",+ related: &["kicad_sch_components", "kicad_sch_pins", "kicad_sch_hierarchy", "kicad_netlist"],+ pitfalls: &["No connectivity: nets come from kicad_netlist.", "Power symbols (#PWR, #FLG) are listed like any part."],+ },+ Verb { name: "kicad_compile", summary: "Check a project's schematic the way a compile does: KiCad's ERC on the root sheet (hierarchy followed) and the netlist, with the messages counted by class and the net and part counts.", mechanism: Mechanism::File, risk: "read", timeout_sec: 300,⋯ 38 unchanged lines ⋯ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> { Some(match command {+ "kicad_read_sheet_file" => read_sheet_file(state, args), "kicad_compile" => compile(state, args), "kicad_list_installed_libraries" => installed_libraries(state, args), "kicad_replicate_layout" => replicate_layout(state, args),⋯ 2 unchanged lines ⋯ }) } +/// `kicad_read_sheet_file`: kicad_sch_components and kicad_sch_pins on one sheet, as the standard's+/// components (one per placed unit).+fn read_sheet_file(state: &mut State, args: &Value) -> Value {+ let Some(path) = arg_any(args, &["path", "filePath", "sheet"]) else {+ return fail("missing_arg", "path is required", "The .kicad_sch file to read.");+ };+ let comps = call(state, "kicad_sch_components", &json!({"filePath": path}));+ if comps["success"] != true {+ return comps;+ }+ let want_pins = args.get("pins") != Some(&json!(false));+ let want_params = args.get("params") == Some(&json!(true));+ let pins = if want_pins { call(state, "kicad_sch_pins", &json!({"filePath": path})) } else { Value::Null };+ let mil = |mm: f64| (mm / 0.0254 * 100.0).round() / 100.0;+ let mut out = Vec::new();+ for c in comps["components"].as_array().into_iter().flatten() {+ let rf = c["reference"].as_str().unwrap_or("");+ let lib_id = c["libId"].as_str().unwrap_or("");+ let (lib, sym) = lib_id.split_once(':').unwrap_or(("", lib_id));+ let field = |name: &str| c["fields"].as_array().into_iter().flatten().find(|f| f["name"] == name).and_then(|f| f["value"].as_str()).unwrap_or("").to_string();+ for u in c["units"].as_array().into_iter().flatten() {+ let (x, y) = (u["at"][0].as_f64().unwrap_or(0.0), u["at"][1].as_f64().unwrap_or(0.0));+ let mut row = json!({"designator": rf, "libReference": sym, "sourceLibrary": lib, "comment": c["value"], "description": field("Description"),+ "uniqueId": u["uuid"], "partId": u["unit"], "partCount": c["unitCount"], "xMm": x, "yMm": y, "xMil": mil(x), "yMil": mil(y),+ "orientation": u["rotation"], "mirror": u["mirror"], "footprint": c["footprint"], "dnp": u["dnp"], "inBom": u["inBom"], "onBoard": u["onBoard"]});+ if want_params {+ row["params"] = json!(c["fields"].as_array().into_iter().flatten().map(|f| (f["name"].as_str().unwrap_or("").to_string(), f["value"].clone())).collect::<serde_json::Map<_, _>>());+ }+ if want_pins {+ let unit = u["unit"].as_i64().unwrap_or(1);+ // per symbol: kicad_sch_pins lists every pin under its symbol (the flat list is not all of them)+ row["pins"] = json!(pins["symbols"].as_array().into_iter().flatten().filter(|s| s["reference"] == rf)+ .flat_map(|s| s["pins"].as_array().cloned().unwrap_or_default())+ .filter(|p| p["unit"].as_i64() == Some(unit) || p["unit"].as_i64() == Some(0))+ .map(|p| json!({"number": p["number"], "name": p["name"], "type": p["type"], "at": p["at"], "hidden": p["hidden"]})).collect::<Vec<_>>());+ }+ out.push(row);+ }+ }+ let format = std::fs::read_to_string(path).ok().and_then(|t| {+ let i = t.find("(version ")?;+ Some(format!("kicad_sch {}", t[i + 9..].split(')').next()?.trim()))+ });+ json!({"success": true, "components": out, "count": out.len(), "format": format, "path": path, "frame": "kicad (sheet mm, y down)",+ "_hint": "One entry per placed unit. Nets: kicad_netlist; the other sheets: kicad_sch_hierarchy."})+}+ fn root_sheet(args: &Value) -> Result<String, Value> { if let Some(f) = arg_str(args, "filePath").filter(|f| f.to_ascii_lowercase().ends_with(".kicad_sch")) { return Ok(f.to_string());⋯ 303 unchanged lines ⋯ } } }+@@ -1,573 +1,580 @@⋯ 91 unchanged lines ⋯ }, Verb { name: "kicad_panelize",- summary: "Step a rectangular board into a manufacturing panel on V-score: rows x cols copies abutting, each copy's nets renamed B<n>-<net>, one panel outline with rails above and below, V-cut lines on Cmts.User along every seam, three tooling holes and three fiducials on the rails; written as a new board and checked with KiCad's DRC.",+ summary: "Step a rectangular board into a manufacturing panel: rows x cols copies, each copy's nets renamed B<n>-<net>, rails, three tooling holes and three fiducials; separated by V-score (copies abutting, V-cut lines on Cmts.User) or mouse-bite tabs (copies a gap apart, the rout path on Edge.Cuts broken at the tabs, five 0.6 mm holes a tab), with JLCPCB's panel rules checked; written as a new board and checked with KiCad's DRC.", mechanism: Mechanism::File, risk: "write", timeout_sec: 300,- input: "{\"pcbPath\": \"C:/.../x.kicad_pcb\" (or filePath), \"panelPath\"?: \"C:/.../x-panel.kicad_pcb\", \"rows\"?: 1, \"cols\"?: 2, \"separation\"?: \"vscore\", \"gapMm\"?: 0, \"railMm\"?: 5, \"toolingHoles\"?: true, \"fiducials\"?: true, \"validate\"?: true, \"overwrite\"?: false}",+ input: "{\"pcbPath\": \"C:/.../x.kicad_pcb\" (or filePath), \"panelPath\"?: \"C:/.../x-panel.kicad_pcb\", \"rows\"?: 1, \"cols\"?: 2, \"separation\"?: \"vscore|tabs\", \"gapMm\"?: 2 (tabs; 0 for vscore), \"railMm\"?: 5, \"toolingHoles\"?: true, \"fiducials\"?: true, \"validate\"?: true, \"overwrite\"?: false}", example: "kicad_panelize {\"pcbPath\":\"C:/Users/john/proj/board.kicad_pcb\",\"rows\":2,\"cols\":3}", hint: "The panel is a new .kicad_pcb (the board itself is untouched) with the project's rules copied beside it. drc is KiCad's DRC of the panel with its zones refilled; open it with kicad_open_board and export with kicad_export_manufacturing. Tell the fab the V-cut layer is Cmts.User.", related: &["kicad_export_manufacturing", "kicad_run_drc", "kicad_open_board", "kicad_validate_for_fab"],- pitfalls: &["V-score only, and only for a rectangular board: a board with curves or notches is refused (it needs tabs and mouse bites; KiKit makes those).", "Zone fills are not copied: the panel's zones are refilled when KiCad opens it (kicad_refill_zones) and in the DRC here.", "Designators repeat across the copies, as on most panels; the BOM and placement files of the panel are per board, not per panel."],+ pitfalls: &["Only a rectangular board: a board with curves or notches is refused (KiKit routes round shaped outlines).", "tabs: JLCPCB wants a gap of 1.2 mm or more and copper 0.5 mm clear of a bitten edge; vscore: copper 0.4 mm (1.0 recommended) from a cut, a panel of 70 mm or more.", "Zone fills are not copied: the panel's zones are refilled when KiCad opens it (kicad_refill_zones) and in the DRC here.", "Designators repeat across the copies, as on most panels; the BOM and placement files of the panel are per board, not per panel."], }, ]; ⋯ 329 unchanged lines ⋯ let Some(src) = arg_any(args, &["pcbPath", "filePath"]).map(std::path::PathBuf::from) else { return fail("missing_arg", "pcbPath is required", "The board to panelise (.kicad_pcb)."); };- if args.get("separation").and_then(Value::as_str).map(|s| s != "vscore").unwrap_or(false) || args.get("gapMm").and_then(Value::as_f64).map(|g| g > 0.0).unwrap_or(false) {- return fail("not_supported", "Only V-score panels (boards abutting, gapMm 0) are made here", "Mouse bites need tabs cut into each outline: use KiKit for those, or separation:\"vscore\".");+ let sep = args.get("separation").and_then(Value::as_str).unwrap_or("vscore");+ if !matches!(sep, "vscore" | "tabs") {+ return fail("bad_args", format!("separation is vscore or tabs, not {sep}"), "vscore: boards edge to edge, V-cut lines; tabs: boards a gap apart, held by mouse-bite tabs."); }+ let tabs = sep == "tabs"; let text = match std::fs::read_to_string(&src) { Ok(t) => t, Err(e) => return fail("board_not_found", format!("cannot read {}: {e}", norm(&src)), "Pass the board's absolute path."),⋯ 5 unchanged lines ⋯ } let num = |k: &str, d: u64| args.get(k).and_then(Value::as_u64).unwrap_or(d) as usize; let flag = |k: &str| args.get(k).and_then(Value::as_bool).unwrap_or(true);- let o = kicad_core::panel::Opts { rows: num("rows", 1), cols: num("cols", 2), rail_mm: args.get("railMm").and_then(Value::as_f64).unwrap_or(5.0), holes: flag("toolingHoles"), fiducials: flag("fiducials") };+ let o = kicad_core::panel::Opts { rows: num("rows", 1), cols: num("cols", 2), rail_mm: args.get("railMm").and_then(Value::as_f64).unwrap_or(5.0), holes: flag("toolingHoles"), fiducials: flag("fiducials"),+ tabs, gap_mm: args.get("gapMm").and_then(Value::as_f64).unwrap_or(if tabs { 2.0 } else { 0.0 }) }; let (panel, mut report) = match kicad_core::panel::panelize(&text, &o) { Ok(x) => x,- Err(e) => return fail(if e.contains("not a rectangle") || e.contains("curves") || e.contains("mouse bites") { "not_rectangular" } else { "panel_failed" }, e, "V-score suits rectangular boards; a shaped board needs tabs and mouse bites (KiKit)."),+ Err(e) if e.contains("gap of 1.2") => return fail("bad_args", e, "Pass gapMm 1.2 or more (2.0 is the default for tabs)."),+ Err(e) => return fail(if e.contains("not a rectangle") || e.contains("curves") || e.contains("mouse bites") { "not_rectangular" } else { "panel_failed" }, e, "Both separations here need a rectangular board outline; a shaped board needs routing round its own outline (KiKit makes those)."), }; if let Some(dir) = out.parent() { let _ = std::fs::create_dir_all(dir);⋯ 12 unchanged lines ⋯ report["success"] = json!(true); report["panel"] = json!(norm(&out)); report["copied"] = json!(copied);- report["output"] = json!(format!("{} boards ({} x {}) on a {} x {} mm V-score panel: {}", report["boards"], o.rows, o.cols, report["panelSizeMm"][0], report["panelSizeMm"][1], norm(&out)));+ report["output"] = json!(format!("{} boards ({} x {}) on a {} x {} mm {} panel: {}", report["boards"], o.rows, o.cols, report["panelSizeMm"][0], report["panelSizeMm"][1], if tabs { "mouse-bite" } else { "V-score" }, norm(&out)));+ report["panelPath"] = json!(norm(&out)); if args.get("validate").and_then(Value::as_bool).unwrap_or(true) { // the panel against its copies: only what the panel adds beyond boards x the board's own findings is new let drc = crate::verbs::call(state, "kicad_run_drc", &json!({"filePath": norm(&out), "refillZones": true}));⋯ 9 unchanged lines ⋯ json!({"ran": false, "why": if drc["success"] == true { base["error"].clone() } else { drc["error"].clone() }}) }; }- report["_hint"] = json!("Open the panel with kicad_open_board (its zones refill there), check kicad_run_drc, then kicad_export_manufacturing. V-cut lines are on Cmts.User: name that layer to the fab.");+ report["_hint"] = json!(format!("Open the panel with kicad_open_board (its zones refill there), check kicad_run_drc, then kicad_export_manufacturing. {} jlcpcb.rules lists each JLCPCB panel rule with ok true, false or null (a note): fix what is false and run again with overwrite:true.",+ if tabs { "The rout path and the mouse-bite holes are on Edge.Cuts: the fab routes along it and the tabs hold." } else { "V-cut lines are on Cmts.User: name that layer to the fab." })); report } ⋯ 78 unchanged lines ⋯ let _ = std::fs::remove_dir_all(&d); } }+@@ -1,171 +1,172 @@⋯ 35 unchanged lines ⋯ }) } -fn lib_ctx(info: &KicadInfo) -> LibCtx {+pub(crate) fn lib_ctx(info: &KicadInfo) -> LibCtx { let inst = info.primary(); let version = inst.map(|i| i.version.clone()).unwrap_or_default(); let mut ctx = LibCtx {⋯ 126 unchanged lines ⋯ let _ = std::fs::remove_dir_all(&d); } }+@@ -0,0 +1,174 @@+//! A new board from a parts list and a netlist: the EDA Bridge Standard's build_board, and+//! test_board's row of library parts. The blank KiCad 10 board (`project::blank_board`), an outline+//! on Edge.Cuts (a rectangle with optional rounded corners, or a polygon), every footprint placed+//! from its library text by its own origin (`fp_text::place_library`), and every listed pad given+//! its net (KiCad 10 names nets on the pads; there is no net table). Then read back pin by pin.+//! Pure: the verbs resolve the library files and write the board.++use std::collections::BTreeMap;++use serde_json::{json, Value};++use crate::pcb::NetFormat;++/// One part to place, in KiCad's frame (y down): its library text and where its origin goes.+pub struct Placed {+ pub reference: String,+ pub lib_id: String,+ pub text: String,+ pub x: f64,+ pub y: f64,+ pub rotation: f64,+ pub back: bool,+ pub value: Option<String>,+}++fn n(v: f64) -> String {+ let r = (v * 1e6).round() / 1e6 + 0.0;+ format!("{r}")+}++fn edge_line(a: (f64, f64), b: (f64, f64)) -> String {+ format!("\t(gr_line (start {} {}) (end {} {}) (stroke (width 0.1) (type default)) (layer \"Edge.Cuts\") (uuid \"{}\"))", n(a.0), n(a.1), n(b.0), n(b.1), crate::schematic::new_uuid())+}++fn edge_arc(a: (f64, f64), m: (f64, f64), b: (f64, f64)) -> String {+ format!("\t(gr_arc (start {} {}) (mid {} {}) (end {} {}) (stroke (width 0.1) (type default)) (layer \"Edge.Cuts\") (uuid \"{}\"))", n(a.0), n(a.1), n(m.0), n(m.1), n(b.0), n(b.1), crate::schematic::new_uuid())+}++/// The outline as Edge.Cuts items: `{rect: [x0, y0, w, h], cornerRadius?}` or `{points: [[x, y], ...]}`,+/// in the standard's frame (y up) when `y_up`, else KiCad's.+pub fn outline_items(outline: &Value, y_up: bool) -> Result<(Vec<String>, [f64; 4]), String> {+ let fy = |y: f64| if y_up { -y } else { y };+ let num = |v: &Value| v.as_f64().ok_or("outline numbers must be numbers (mm)");+ if let Some(r) = outline.get("rect").and_then(Value::as_array) {+ if r.len() != 4 {+ return Err("outline.rect is [x0, y0, width, height] in mm".into());+ }+ let (x0, y0, w, h) = (num(&r[0])?, num(&r[1])?, num(&r[2])?, num(&r[3])?);+ if w <= 0.0 || h <= 0.0 {+ return Err("the outline's width and height must be positive".into());+ }+ let (ya, yb) = (fy(y0), fy(y0 + h));+ let (top, bot) = (ya.min(yb), ya.max(yb));+ let (l, rt) = (x0, x0 + w);+ let rad = outline.get("cornerRadius").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, w.min(h) / 2.0);+ let mut items = Vec::new();+ if rad <= 0.0 {+ for (a, b) in [((l, top), (rt, top)), ((rt, top), (rt, bot)), ((rt, bot), (l, bot)), ((l, bot), (l, top))] {+ items.push(edge_line(a, b));+ }+ } else {+ let k = rad * (1.0 - std::f64::consts::FRAC_1_SQRT_2);+ items.push(edge_line((l + rad, top), (rt - rad, top)));+ items.push(edge_arc((rt - rad, top), (rt - k, top + k), (rt, top + rad)));+ items.push(edge_line((rt, top + rad), (rt, bot - rad)));+ items.push(edge_arc((rt, bot - rad), (rt - k, bot - k), (rt - rad, bot)));+ items.push(edge_line((rt - rad, bot), (l + rad, bot)));+ items.push(edge_arc((l + rad, bot), (l + k, bot - k), (l, bot - rad)));+ items.push(edge_line((l, bot - rad), (l, top + rad)));+ items.push(edge_arc((l, top + rad), (l + k, top + k), (l + rad, top)));+ }+ return Ok((items, [l, top, rt, bot]));+ }+ if let Some(p) = outline.get("points").and_then(Value::as_array) {+ let pts: Vec<(f64, f64)> = p.iter().map(|q| Ok::<_, String>((num(&q[0])?, fy(num(&q[1])?)))).collect::<Result<_, _>>()?;+ if pts.len() < 3 {+ return Err("outline.points needs 3 points or more".into());+ }+ let items = (0..pts.len()).map(|i| edge_line(pts[i], pts[(i + 1) % pts.len()])).collect();+ let (x0, x1) = pts.iter().fold((f64::MAX, f64::MIN), |m, q| (m.0.min(q.0), m.1.max(q.0)));+ let (y0, y1) = pts.iter().fold((f64::MAX, f64::MIN), |m, q| (m.0.min(q.1), m.1.max(q.1)));+ return Ok((items, [x0, y0, x1, y1]));+ }+ Err("outline is {rect: [x0, y0, w, h], cornerRadius?} or {points: [[x, y], ...]} (mm)".into())+}++/// The board text: `board` (a blank board or one to append to), the outline items, the parts placed+/// and the nets set on their pads (`nets`: net -> ["R1.1", ...]). Returns the text and a report of+/// what was placed and wired, and the pins that could not be (no such part on the build, no such pad).+pub fn build(board: &str, outline: &[String], parts: &[Placed], nets: &BTreeMap<String, Vec<String>>) -> Result<(String, Value), String> {+ let copper = crate::fp_text::copper_count(&crate::sxedit::parse(board)?);+ let mut blocks: BTreeMap<String, String> = BTreeMap::new();+ for p in parts {+ let block = crate::fp_text::place_library(&p.text, &p.lib_id, p.x, p.y, p.rotation, p.back, &p.reference, p.value.as_deref(), copper)+ .map_err(|e| format!("{}: {e}", p.reference))?;+ blocks.insert(p.reference.clone(), block);+ }+ let empty = BTreeMap::new();+ let mut wired = 0;+ let mut wrong = Vec::new();+ for (net, pins) in nets {+ for pin in pins {+ let Some((r, pad)) = pin.rsplit_once('.') else {+ wrong.push(json!({"pin": pin, "want": net, "got": "not Ref.Pad"}));+ continue;+ };+ let Some(block) = blocks.get_mut(r) else {+ wrong.push(json!({"pin": pin, "want": net, "got": "no such part on the board"}));+ continue;+ };+ if !block.contains(&format!("(pad \"{pad}\" ")) {+ wrong.push(json!({"pin": pin, "want": net, "got": "the footprint has no such pad"}));+ continue;+ }+ *block = crate::pcb_sync::set_pad_net(block, pad, Some(net), NetFormat::Name, &empty);+ wired += 1;+ }+ }+ let mut items: Vec<String> = outline.to_vec();+ items.extend(blocks.into_values());+ let text = crate::pcb::splice(board, &items);+ // read back: every requested pin carries its net+ let parsed = crate::pcb::parse_pcb_text(&text, "board.kicad_pcb")?;+ let pads = crate::pcb::pads(&parsed);+ for (net, pins) in nets {+ for pin in pins {+ let Some((r, pad)) = pin.rsplit_once('.') else { continue };+ if let Some(p) = pads.iter().find(|q| q.reference == r && q.pad == pad) {+ if p.net_name != *net {+ wrong.push(json!({"pin": pin, "want": net, "got": p.net_name}));+ }+ }+ }+ }+ wrong.dedup();+ Ok((text, json!({"placed": parts.len(), "padsWired": wired, "wrongPins": wrong})))+}++#[cfg(test)]+mod tests {+ use super::*;++ const R0603: &str = "(footprint \"R_0603\" (layer \"F.Cu\") (property \"Reference\" \"REF**\" (at 0 -1.4 0) (layer \"F.SilkS\") (uuid \"a\") (effects (font (size 1 1) (thickness 0.15))))+ (property \"Value\" \"R\" (at 0 1.4 0) (layer \"F.Fab\") (uuid \"b\") (effects (font (size 1 1) (thickness 0.15))))+ (pad \"1\" smd roundrect (at -0.8 0) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"c\"))+ (pad \"2\" smd roundrect (at 0.8 0) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"d\")))";++ #[test]+ fn a_board_from_parts_and_nets_reads_back_pin_by_pin() {+ let blank = crate::project::blank_board(None, 2).unwrap();+ let (outline, bb) = outline_items(&json!({"rect": [0, 0, 20, 10], "cornerRadius": 1}), true).unwrap();+ assert_eq!(bb, [0.0, -10.0, 20.0, 0.0]);+ assert_eq!(outline.len(), 8, "four sides and four rounded corners");+ let parts = vec![+ Placed { reference: "R1".into(), lib_id: "Lib:R_0603".into(), text: R0603.into(), x: 5.0, y: -5.0, rotation: 0.0, back: false, value: Some("10k".into()) },+ Placed { reference: "R2".into(), lib_id: "Lib:R_0603".into(), text: R0603.into(), x: 15.0, y: -5.0, rotation: 90.0, back: false, value: None },+ ];+ let mut nets = BTreeMap::new();+ nets.insert("VIN".to_string(), vec!["R1.1".to_string()]);+ nets.insert("MID".to_string(), vec!["R1.2".to_string(), "R2.1".to_string()]);+ nets.insert("GND".to_string(), vec!["R2.2".to_string(), "R3.1".to_string(), "R2.9".to_string()]);+ let (text, rep) = build(&blank, &outline, &parts, &nets).unwrap();+ assert_eq!(rep["placed"], 2);+ assert_eq!(rep["padsWired"], 4);+ let wrong = rep["wrongPins"].as_array().unwrap();+ assert_eq!(wrong.len(), 2, "{rep}");+ assert!(wrong.iter().any(|w| w["pin"] == "R3.1") && wrong.iter().any(|w| w["pin"] == "R2.9"));+ let parsed = crate::pcb::parse_pcb_text(&text, "b.kicad_pcb").unwrap();+ let pads = crate::pcb::pads(&parsed);+ assert_eq!(pads.iter().find(|p| p.reference == "R2" && p.pad == "1").unwrap().net_name, "MID");+ assert!(text.contains("(property \"Value\" \"10k\"") && text.contains("gr_arc"));+ }+}+@@ -1,1071 +1,1272 @@⋯ 806 unchanged lines ⋯ } // ---------------------------------------------------------------------------+// length_tune+// ---------------------------------------------------------------------------++fn routed_length(model: &Value, net: &str) -> Option<f64> {+ let v = net_lengths(model, &json!({"nets": [net]}), &[]);+ v["nets"][0]["lengthMm"].as_f64().or_else(|| v["unroutedNets"].as_array().filter(|a| !a.is_empty()).map(|_| 0.0))+}++/// The plan to lengthen `net` to `targetMm` (or to `matchTo`'s length): a serpentine of square bumps+/// in its longest straight track that has room on one side, the bumps sized to add exactly the+/// length missing (each adds twice its amplitude), clear of other nets' copper, pads, vias and the+/// edge by `clearance`. `board` gives the tracks' ids, `model` (KiCad's frame) the obstacles.+pub fn length_tune(board: &Board, model: &Value, args: &Value) -> Result<Value, (String, String)> {+ let bad = |c: &str, m: String| (c.to_string(), m);+ let net = args.get("net").and_then(Value::as_str).ok_or_else(|| bad("missing_arg", "net names the net to lengthen".into()))?;+ let before = routed_length(model, net).ok_or_else(|| bad("no_such_net", format!("{net} is not a net on this board")))?;+ let target = match (args.get("targetMm").and_then(Value::as_f64), args.get("matchTo").and_then(Value::as_str)) {+ (Some(t), _) => t,+ (None, Some(m)) => routed_length(model, m).ok_or_else(|| bad("no_such_net", format!("{m} is not a net on this board")))?,+ _ => return Err(bad("missing_arg", "targetMm (the length it should have) or matchTo (a net whose length to match)".into())),+ };+ let missing = target - before;+ let base = json!({"net": net, "lengthBeforeMm": r3(before), "targetMm": r3(target)});+ if missing <= 0.01 {+ let mut v = base;+ v["changed"] = json!(false);+ v["lengthMm"] = json!(r3(before));+ v["errorMm"] = json!(r3(target - before));+ v["note"] = json!(if missing < -0.01 { "already longer than the target: shorten it by rerouting (this verb only adds length)" } else { "already at the target" });+ return Ok(v);+ }+ let clr = args.get("clearance").and_then(Value::as_f64).unwrap_or(0.2);+ let max_amp = args.get("maxAmplitudeMm").and_then(Value::as_f64).unwrap_or(3.0);+ // obstacles: other nets' tracks (by layer), every pad, every via, the outline+ let tracks: Vec<(String, String, (f64, f64), (f64, f64), f64)> = arr(model, "tracks").map(|t| (txt(&t["net"]).to_string(), txt(&t["layer"]).to_string(), xy(&t["start"]), xy(&t["end"]), f(&t["width"]))).collect();+ let pads: Vec<&Value> = arr(model, "pads").collect();+ let vias: Vec<((f64, f64), f64)> = arr(model, "vias").map(|v| (xy(&v["position"]), f(&v["size"]) / 2.0)).collect();+ let outline: Vec<(f64, f64)> = arr(model, "outline").map(xy).collect();+ let edges: Vec<((f64, f64), (f64, f64))> = (0..outline.len()).map(|i| (outline[i], outline[(i + 1) % outline.len()])).collect();+ let mut segs: Vec<&crate::pcb::Segment> = board.segments.iter().filter(|s| s.net_name.as_deref() == Some(net) || board.nets.iter().any(|n| n.number.is_some() && n.number == s.net && n.name == net)).collect();+ let len = |s: &&crate::pcb::Segment| match (&s.start, &s.end) { (Some(a), Some(b)) => dist((a.x, a.y), (b.x, b.y)), _ => 0.0 };+ segs.sort_by(|a, b| len(b).partial_cmp(&len(a)).unwrap_or(std::cmp::Ordering::Equal));+ let mut tried = 0;+ for s in segs {+ let (Some(a), Some(b), Some(w), Some(layer), Some(id)) = (&s.start, &s.end, s.width, s.layer.as_deref(), s.uuid.as_deref()) else { continue };+ let (a, b) = ((a.x, a.y), (b.x, b.y));+ let l = dist(a, b);+ let pitch = args.get("pitchMm").and_then(Value::as_f64).unwrap_or((4.0 * w).max(4.0 * clr + 2.0 * w));+ let margin = (pitch / 2.0).max(2.0 * w);+ let usable = l - 2.0 * margin;+ let n_max = (usable / pitch).floor() as usize;+ if n_max == 0 {+ continue;+ }+ // the fewest bumps whose amplitude stays under the cap (and at least a width, so the legs read as bumps)+ let Some(n) = (1..=n_max).find(|n| missing / (2.0 * *n as f64) <= max_amp) else { continue };+ let amp = missing / (2.0 * n as f64);+ let u = ((b.0 - a.0) / l, (b.1 - a.1) / l);+ let s0 = margin + (usable - n as f64 * pitch) / 2.0;+ for side in [1.0, -1.0] {+ tried += 1;+ let nrm = (-u.1 * side, u.0 * side);+ let at = |along: f64, up: f64| (a.0 + u.0 * along + nrm.0 * up, a.1 + u.1 * along + nrm.1 * up);+ // the swept area: every bump's three new legs, kept clear of other copper by clr+ let mut legs: Vec<((f64, f64), (f64, f64))> = Vec::new();+ let mut pts = vec![a];+ for i in 0..n {+ let (xa, xb) = (s0 + i as f64 * pitch + pitch / 4.0, s0 + i as f64 * pitch + 3.0 * pitch / 4.0);+ let (p1, p2, p3, p4) = (at(xa, 0.0), at(xa, amp), at(xb, amp), at(xb, 0.0));+ legs.extend([(p1, p2), (p2, p3), (p3, p4)]);+ pts.extend([p1, p2, p3, p4]);+ }+ pts.push(b);+ let keep = w / 2.0 + clr;+ let clear = legs.iter().all(|(p, q)| {+ tracks.iter().all(|t| t.0 == net || t.1 != layer || seg_seg(*p, *q, t.2, t.3) >= keep + t.4 / 2.0)+ && pads.iter().all(|pd| txt(&pd["net"]) == net || seg_pad(*p, *q, pd) >= keep)+ && vias.iter().all(|(c, r)| point_seg(*c, *p, *q) >= keep + r)+ && (outline.len() < 3 || (inside_polygon(*p, &outline) && inside_polygon(*q, &outline) && edges.iter().all(|(e0, e1)| seg_seg(*p, *q, *e0, *e1) >= keep)))+ });+ if clear {+ let added = 2.0 * amp * n as f64;+ let mut v = base;+ v["changed"] = json!(true);+ v["lengthMm"] = json!(r3(before + added));+ v["errorMm"] = json!(r3(target - before - added));+ v["plan"] = json!({"segmentId": id, "layer": layer, "width": w, "bumps": n, "amplitudeMm": r3(amp), "pitchMm": r3(pitch), "addedMm": r3(added),+ "side": if side > 0.0 { "left" } else { "right" }, "points": pts.iter().map(|p| json!([r3(p.0), r3(p.1)])).collect::<Vec<_>>()});+ return Ok(v);+ }+ }+ }+ Err(bad("no_room", format!("no straight track of {net} has room for {} mm of serpentine (tried {tried} side(s); amplitude at most {max_amp} mm, {clr} mm clearance)", r3(missing))))+}++/// Distance between two segments (0 when they cross).+fn seg_seg(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)) -> f64 {+ if segments_cross(a, b, c, d) {+ return 0.0;+ }+ point_seg(a, c, d).min(point_seg(b, c, d)).min(point_seg(c, a, b)).min(point_seg(d, a, b))+}++/// Distance from a segment to a pad's copper (its turned rectangle, sampled along the segment).+fn seg_pad(a: (f64, f64), b: (f64, f64), pad: &Value) -> f64 {+ let steps = ((dist(a, b) / 0.05).ceil() as usize).clamp(1, 400);+ (0..=steps).map(|i| { let t = i as f64 / steps as f64; pad_distance((a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t), pad, true) }).fold(f64::INFINITY, f64::min)+}++// ---------------------------------------------------------------------------+// silkscreen_prep (the text steps)+// ---------------------------------------------------------------------------++/// The board with its Gerbers set to subtract the solder mask from the silkscreen (KiCad's plot+/// option `subtractmaskfromsilk`): the fab output carries no ink on pads. Returns the text and+/// whether it changed.+pub fn subtract_mask_from_silk(text: &str) -> (String, bool) {+ if text.contains("(subtractmaskfromsilk yes)") {+ return (text.to_string(), false);+ }+ if text.contains("(subtractmaskfromsilk no)") {+ return (text.replacen("(subtractmaskfromsilk no)", "(subtractmaskfromsilk yes)", 1), true);+ }+ match text.find("(pcbplotparams") {+ Some(i) => {+ let at = i + "(pcbplotparams".len();+ (format!("{} (subtractmaskfromsilk yes){}", &text[..at], &text[at..]), true)+ }+ None => (text.to_string(), false),+ }+}++/// The board without its own silkscreen items (gr_line, gr_rect, gr_text ... on F/B.SilkS) that lie+/// wholly outside the outline. Returns the text and the removed items' uuids.+pub fn silk_outside_board(text: &str, outline: &[(f64, f64)]) -> (String, Vec<String>) {+ let Ok(root) = crate::sxedit::parse(text) else { return (text.to_string(), Vec::new()) };+ if outline.len() < 3 {+ return (text.to_string(), Vec::new());+ }+ let mut cut: Vec<(usize, usize, String)> = Vec::new();+ for k in root.kids.iter().filter(|k| k.tag().map_or(false, |t| t.starts_with("gr_")) && matches!(k.value_of("layer"), Some("F.SilkS" | "B.SilkS"))) {+ let mut pts: Vec<(f64, f64)> = Vec::new();+ k.walk(&mut |n| {+ if matches!(n.tag(), Some("start" | "end" | "mid" | "center" | "at" | "xy")) {+ if let (Some(x), Some(y)) = (n.f64_at(1), n.f64_at(2)) {+ pts.push((x, y));+ }+ }+ });+ if !pts.is_empty() && pts.iter().all(|p| !inside_polygon(*p, outline)) {+ cut.push((k.start, k.end, k.uuid().unwrap_or("").to_string()));+ }+ }+ let mut out = text.to_string();+ for (a, b, _) in cut.iter().rev() {+ let mut a2 = *a;+ while a2 > 0 && matches!(out.as_bytes()[a2 - 1], b' ' | b'\t') {+ a2 -= 1;+ }+ if a2 > 0 && out.as_bytes()[a2 - 1] == b'\n' {+ a2 -= 1;+ }+ out.replace_range(a2..*b, "");+ }+ (out, cut.into_iter().map(|c| c.2).collect())+}++// --------------------------------------------------------------------------- // auto_classes // --------------------------------------------------------------------------- ⋯ 249 unchanged lines ⋯ } #[test]+ fn a_serpentine_adds_exactly_the_missing_length_clear_of_other_copper() {+ let text = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/konnect-route/route-fixture.kicad_pcb")).unwrap();+ let board = crate::pcb::parse_pcb_text(&text, "f.kicad_pcb").unwrap();+ let model = crate::board_model::build(&text, None, &crate::board_model::Opts { y_up: false }).unwrap();+ let net = board.segments.iter().filter_map(|s| s.net_name.clone()).next().expect("the fixture has a routed net");+ let before = routed_length(&model, &net).unwrap();+ let v = length_tune(&board, &model, &json!({"net": net, "targetMm": before + 3.0})).unwrap();+ assert_eq!(v["changed"], true, "{v}");+ assert!(f(&v["errorMm"]).abs() < 0.01, "{v}");+ let pts = v["plan"]["points"].as_array().unwrap();+ let drawn: f64 = pts.windows(2).map(|w| dist((f(&w[0][0]), f(&w[0][1])), (f(&w[1][0]), f(&w[1][1])))).sum();+ let seg = board.segments.iter().find(|s| s.uuid.as_deref() == v["plan"]["segmentId"].as_str()).unwrap();+ let orig = dist((seg.start.as_ref().unwrap().x, seg.start.as_ref().unwrap().y), (seg.end.as_ref().unwrap().x, seg.end.as_ref().unwrap().y));+ assert!((drawn - orig - 3.0).abs() < 0.01, "the polyline is the old track plus 3 mm: {drawn} vs {orig}");+ assert!(f(&v["plan"]["amplitudeMm"]) <= 3.0);+ // already long enough: nothing to do+ assert_eq!(length_tune(&board, &model, &json!({"net": net, "targetMm": before - 1.0})).unwrap()["changed"], false);+ assert_eq!(length_tune(&board, &model, &json!({"net": "NOPE", "targetMm": 5})).unwrap_err().0, "no_such_net");+ }++ #[test]+ fn silk_prep_sets_the_plot_option_and_drops_silk_off_the_board() {+ let text = "(kicad_pcb (setup (pcbplotparams (layerselection 1) (subtractmaskfromsilk no)))\n\t(gr_line (start 0 0) (end 5 0) (layer \"F.SilkS\") (uuid \"in\"))\n\t(gr_text \"x\" (at 50 50) (layer \"F.SilkS\") (uuid \"out\"))\n\t(gr_line (start 60 60) (end 70 60) (layer \"Dwgs.User\") (uuid \"other\"))\n)";+ let (t, changed) = subtract_mask_from_silk(text);+ assert!(changed && t.contains("(subtractmaskfromsilk yes)"));+ assert!(!subtract_mask_from_silk(&t).1, "already set");+ let (t2, removed) = silk_outside_board(&t, &[(-1.0, -1.0), (10.0, -1.0), (10.0, 10.0), (-1.0, 10.0)]);+ assert_eq!(removed, vec!["out".to_string()]);+ assert!(t2.contains("\"in\"") && t2.contains("\"other\"") && !t2.contains("\"out\""));+ }++ #[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)])); } }+@@ -0,0 +1,170 @@+//! Component classes on a board (the EDA Bridge Standard's component_classes), as KiCad 9 and 10+//! keep them: each member footprint carries `(component_classes (class "<name>"))`, which DRC rules+//! match with `A.hasComponentClass('<name>')`. A class's room is a rule area named+//! `Adom room <name>` (everything allowed inside it) plus a custom rule asserting that every member+//! footprint lies within it (`A.enclosedByArea('Adom room <name>')`): a member outside is a DRC+//! violation, not a move. Both checked with KiCad 10.0.7's DRC. Pure text edits on the board.++use std::collections::BTreeMap;++use serde_json::{json, Value};++use crate::sxedit::{self, Edits, Node};++pub fn room_name(class: &str) -> String {+ format!("Adom room {class}")+}++/// The rule (for the project's .kicad_dru) that keeps a class's members inside its room.+pub fn room_rule(class: &str) -> String {+ let n = room_name(class);+ format!("(rule \"{n}\"\n\t(condition \"A.Type == 'Footprint' && A.hasComponentClass('{class}')\")\n\t(constraint assertion \"A.enclosedByArea('{n}')\"))")+}++fn reference(fp: &Node) -> Option<&str> {+ fp.kids_tagged("property").find(|p| p.atom(1) == Some("Reference")).and_then(|p| p.atom(2))+}++fn classes_of(fp: &Node) -> Vec<String> {+ fp.kid("component_classes").map(|c| c.kids_tagged("class").filter_map(|k| k.atom(1).map(String::from)).collect()).unwrap_or_default()+}++/// Every class with its members, and every room (its rule area's box, KiCad's frame).+pub fn list(text: &str) -> Result<Value, String> {+ let root = sxedit::parse(text)?;+ let mut classes: BTreeMap<String, Vec<String>> = BTreeMap::new();+ for fp in root.kids_tagged("footprint") {+ let r = reference(fp).unwrap_or("").to_string();+ for c in classes_of(fp) {+ classes.entry(c).or_default().push(r.clone());+ }+ }+ let mut rooms = Vec::new();+ for z in root.kids_tagged("zone") {+ if let Some(name) = z.value_of("name").and_then(|n| n.strip_prefix("Adom room ")) {+ let mut pts: Vec<(f64, f64)> = Vec::new();+ z.walk(&mut |n| if n.tag() == Some("xy") { if let (Some(x), Some(y)) = (n.f64_at(1), n.f64_at(2)) { pts.push((x, y)); } });+ 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)));+ rooms.push(json!({"class": name, "rect": [x0, y0, x1, y1]}));+ }+ }+ for m in classes.values_mut() {+ m.sort_by_key(|r| crate::placement::ref_key(r));+ }+ Ok(json!({"classes": classes.iter().map(|(n, m)| json!({"name": n, "members": m})).collect::<Vec<_>>(), "rooms": rooms}))+}++/// The board with `class` taken off every footprint, then put on `members` (the same name replaces+/// the class). Returns the text and the members not on the board.+pub fn set_class(text: &str, class: &str, members: &[String]) -> Result<(String, Vec<String>), String> {+ let text = remove_class(text, class)?;+ let root = sxedit::parse(&text)?;+ let mut e = Edits::new();+ let mut found: Vec<String> = Vec::new();+ for fp in root.kids_tagged("footprint") {+ let Some(r) = reference(fp) else { continue };+ if !members.iter().any(|m| m.eq_ignore_ascii_case(r)) {+ continue;+ }+ found.push(r.to_ascii_uppercase());+ let entry = format!("(class {})", sxedit::q(class));+ match fp.kid("component_classes") {+ Some(cc) => e.insert(cc.end - 1, format!(" {entry}")),+ None => {+ let after = fp.kid("layer").map(|l| l.end).unwrap_or(fp.kids[1].end);+ e.insert(after, format!("\n\t\t(component_classes\n\t\t\t{entry}\n\t\t)"));+ }+ }+ }+ let missing = members.iter().filter(|m| !found.contains(&m.to_ascii_uppercase())).cloned().collect();+ Ok((e.apply(&text)?, missing))+}++/// The board with `class` taken off every footprint (an emptied `component_classes` list goes too).+pub fn remove_class(text: &str, class: &str) -> Result<String, String> {+ let root = sxedit::parse(text)?;+ let mut e = Edits::new();+ for fp in root.kids_tagged("footprint") {+ let Some(cc) = fp.kid("component_classes") else { continue };+ let ours: Vec<&Node> = cc.kids_tagged("class").filter(|k| k.atom(1) == Some(class)).collect();+ if ours.is_empty() {+ continue;+ }+ if ours.len() == cc.kids_tagged("class").count() {+ e.remove_node(text, cc);+ } else {+ for k in ours {+ e.remove_node(text, k);+ }+ }+ }+ if e.is_empty() {+ return Ok(text.to_string());+ }+ e.apply(text)+}++/// The board without the class's room (its rule area).+pub fn remove_room(text: &str, class: &str) -> Result<String, String> {+ let root = sxedit::parse(text)?;+ let name = room_name(class);+ let mut e = Edits::new();+ for z in root.kids_tagged("zone").filter(|z| z.value_of("name") == Some(name.as_str())) {+ e.remove_node(text, z);+ }+ if e.is_empty() {+ return Ok(text.to_string());+ }+ e.apply(text)+}++/// The room's rule area over `rect` [x0, y0, x1, y1] (KiCad's frame) on both outer copper layers,+/// everything allowed inside it: it only marks the area the assertion rule checks.+pub fn room_zone(class: &str, rect: [f64; 4]) -> String {+ let n = |v: f64| format!("{}", (v * 1e4).round() / 1e4 + 0.0);+ let [x0, y0, x1, y1] = rect;+ format!("\t(zone\n\t\t(layers \"F.Cu\" \"B.Cu\")\n\t\t(uuid \"{}\")\n\t\t(name {})\n\t\t(hatch edge 0.5)\n\t\t(connect_pads\n\t\t\t(clearance 0)\n\t\t)\n\t\t(min_thickness 0.25)\n\t\t(keepout\n\t\t\t(tracks allowed)\n\t\t\t(vias allowed)\n\t\t\t(pads allowed)\n\t\t\t(copperpour allowed)\n\t\t\t(footprints allowed)\n\t\t)\n\t\t(placement\n\t\t\t(enabled no)\n\t\t\t(sheetname \"\")\n\t\t)\n\t\t(fill\n\t\t\t(thermal_gap 0.5)\n\t\t\t(thermal_bridge_width 0.5)\n\t\t\t(island_removal_mode 0)\n\t\t)\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy {} {}) (xy {} {}) (xy {} {}) (xy {} {})\n\t\t\t)\n\t\t)\n\t)",+ crate::schematic::new_uuid(), sxedit::q(&room_name(class)), n(x0), n(y0), n(x1), n(y0), n(x1), n(y1), n(x0), n(y1))+}++#[cfg(test)]+mod tests {+ use super::*;++ fn fixture() -> String {+ std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/konnect-route/route-fixture.kicad_pcb")).unwrap()+ }++ #[test]+ fn a_class_is_set_listed_replaced_and_removed() {+ let t = fixture();+ let (t1, missing) = set_class(&t, "Power", &["R1".into(), "u1".into(), "Q9".into()]).unwrap();+ assert_eq!(missing, vec!["Q9".to_string()]);+ let l = list(&t1).unwrap();+ assert_eq!(l["classes"][0], json!({"name": "Power", "members": ["R1", "U1"]}));+ // a second class on the same footprint keeps the first+ let (t2, _) = set_class(&t1, "Analog", &["R1".into()]).unwrap();+ assert_eq!(list(&t2).unwrap()["classes"].as_array().unwrap().len(), 2);+ // the same name replaces the members+ let (t3, _) = set_class(&t2, "Power", &["C1".into()]).unwrap();+ let l3 = list(&t3).unwrap();+ assert!(l3["classes"].as_array().unwrap().iter().any(|c| c == &json!({"name": "Power", "members": ["C1"]})), "{l3}");+ let t4 = remove_class(&remove_class(&t3, "Power").unwrap(), "Analog").unwrap();+ assert_eq!(list(&t4).unwrap()["classes"], json!([]));+ assert!(!t4.contains("component_classes"), "an emptied list is removed");+ }++ #[test]+ fn a_room_is_a_named_rule_area() {+ let t = fixture();+ let k = t.trim_end().rfind(')').unwrap();+ let with = format!("{}{}\n)\n", &t[..k], room_zone("Power", [10.0, 20.0, 30.0, 40.0]));+ assert_eq!(list(&with).unwrap()["rooms"][0], json!({"class": "Power", "rect": [10.0, 20.0, 30.0, 40.0]}));+ assert!(crate::pcb::parse_pcb_text(&with, "b.kicad_pcb").is_ok());+ let gone = remove_room(&with, "Power").unwrap();+ assert_eq!(list(&gone).unwrap()["rooms"], json!([]));+ assert!(room_rule("Power").contains("A.enclosedByArea('Adom room Power')"));+ }+}+@@ -1,2353 +1,2422 @@⋯ 838 unchanged lines ⋯ commit_checked(ctx, &s, args, &text, &revision, &plan, message) } +/// `kicad_length_tune`'s edit: one track replaced by the polyline `points` (KiCad's frame), same net,+/// layer and width, after KiCad's DRC shows the replacement adds no error; delete and create in one+/// native Undo step.+pub fn replace_track(ctx: &Ctx, args: &Value, message: &str) -> RResult<Value> {+ let s = connect(ctx, args)?;+ let (text, data, revision) = s.snapshot()?;+ check_revision(args, &revision)?;+ let id = args["segmentId"].as_str().ok_or_else(|| RoutingError::new("missing_arg", "segmentId names the track to replace"))?;+ let seg = data.segments.iter().find(|g| g.uuid.as_deref() == Some(id)).ok_or_else(|| RoutingError::new("track_not_found", format!("no track {id} on the live board")).with("mutated", json!(false)))?;+ let net_name = seg.net_name.clone().or_else(|| data.nets.iter().find(|n| n.number.is_some() && n.number == seg.net).map(|n| n.name.clone())).unwrap_or_default();+ let net = pcb::resolve_net(&data, Some(&json!(net_name))).map_err(|e| RoutingError::new("unknown_net", e["error"].as_str().unwrap_or("no such net").to_string()))?;+ let (layer, width) = (seg.layer.clone().unwrap_or_default(), seg.width.unwrap_or(0.2));+ let pts: Vec<(f64, f64)> = args["points"].as_array().into_iter().flatten().filter_map(|p| Some((p[0].as_f64()?, p[1].as_f64()?))).collect();+ if pts.len() < 2 {+ return Err(RoutingError::new("bad_args", "points is the new polyline, two points or more").with("mutated", json!(false)));+ }+ let mut plan = pcb::Plan { net_name: net.name.clone(), segments: Vec::new(), vias: Vec::new(), blocks: Vec::new() };+ for w in pts.windows(2) {+ plan.blocks.push(pcb::segment_sexpr(w[0], w[1], width, &layer, &net));+ plan.segments.push(pcb::PlanSegment { start: [w[0].0, w[0].1], end: [w[1].0, w[1].1], layer: layer.clone(), width });+ }+ let without = pcb::text_without_item(&text, id).ok_or_else(|| RoutingError::new("track_not_found", format!("track {id} is not in the board text")))?;+ let mut check = Value::Null;+ if args.get("validate") != Some(&json!(false)) {+ let source = s.source_path();+ let before = drc_snapshot(ctx.kicad_cli.as_deref(), &source, &text)?;+ let after = drc_snapshot(ctx.kicad_cli.as_deref(), &source, &pcb::splice(&without, &plan.blocks))?;+ let added = new_errors(&before, &after)?;+ if !added.is_empty() {+ return Err(RoutingError::new("drc_rejected", "The serpentine adds DRC errors; no live copper was changed").with("violations", json!(added)).with("mutated", json!(false)));+ }+ check = after;+ }+ if args.get("dryRun") == Some(&json!(true)) {+ return Ok(json!({"success": true, "dryRun": true, "revision": revision, "drc": check}));+ }+ check_revision(args, &s.revision()?)?;+ let nets = s.client.get_nets().map_err(map_err)?;+ let Some(live_net) = nets.into_iter().find(|n| n.name == plan.net_name) else {+ return Err(RoutingError::new("unknown_net", format!("The live board has no net named '{}'", plan.net_name)).with("mutated", json!(false)));+ };+ let items = build_items(&plan, &live_net)?;+ let count = items.len();+ let created = transaction(&s, message, || {+ s.client.delete_items(vec![id.to_string()])?;+ let created = s.client.create_editable_items(items, None)?;+ if created.len() != count {+ return Err(IpcFailure::from("KiCad did not create every segment of the serpentine".to_string()));+ }+ Ok(created)+ })?;+ let ids: Vec<String> = created.iter().filter_map(|i| i.id().map(str::to_string)).collect();+ let mut out = json!({"success": true, "mutated": true, "source": "live-editor", "removedIds": [id], "itemIds": ids, "undoSteps": 1, "drc": check, "saved": false});+ match s.revision() {+ Ok(r) => {+ out["revision"] = json!(r);+ if args.get("save") == Some(&json!(true)) {+ match s.client.save_document() {+ Ok(()) => out["saved"] = json!(true),+ Err(e) => out["postCommitError"] = json!(e.to_string()),+ }+ }+ }+ Err(e) => out["postCommitError"] = json!(e.message),+ }+ Ok(out)+}+ /// DRC the planned copper against the board as it is (no new errors allowed, unless validate is /// false), then commit it live as one Undo step: route_net's tail, shared with place_vias. fn commit_checked(ctx: &Ctx, s: &Session, args: &Value, text: &str, revision: &str, plan: &pcb::Plan, message: &str) -> RResult<Value> {⋯ 1506 unchanged lines ⋯ let _ = std::fs::remove_dir_all(&base); } }+@@ -1,69 +1,72 @@⋯ 51 unchanged lines ⋯ pub mod symedit; pub mod land_pattern; pub mod libops;+pub mod board_build; pub mod board_checks; pub mod board_reads;+pub mod component_classes; pub mod panel; pub mod libsearch; pub mod sxedit;⋯ 7 unchanged lines ⋯ pub mod sch_review; pub mod sch_render; pub mod sch_sheets;+@@ -1,373 +1,648 @@⋯ 2 unchanged lines ⋯ //! every copy's own outline replaced by one panel outline with rails above and below, V-cut lines on //! Cmts.User along every seam, three tooling holes and three fiducials on the rails. //!-//! Only a rectangular board (its Edge.Cuts a rectangle) is panelised on V-score here: a board with-//! curves or notches needs tabs and mouse bites (KiKit does that).+//! With `tabs` the copies stand `gap_mm` apart inside a frame with rails on all four sides, and come+//! apart at mouse-bite tabs: each board's edges are broken at its tabs on Edge.Cuts (the rout path),+//! the tab sides cross the gap, and every tab carries five 0.6 mm holes on the board side. The tab+//! plan and JLCPCB's rules for both separations are the Altium bridge's (bridge-rs/src/panel.rs,+//! 0.2.77; jlcpcb.com/blog/v-cut-panelization-standards and /blog/mouse-bite-panelization-guide, read+//! 2026-10-23), so both tools cut a panel the same way.+//!+//! Only a rectangular board (its Edge.Cuts a rectangle) is panelised here. use std::collections::HashMap; ⋯ 7 unchanged lines ⋯ pub rail_mm: f64, pub holes: bool, pub fiducials: bool,+ /// mouse-bite tabs instead of V-score+ pub tabs: bool,+ /// the gap between boards with tabs (JLCPCB: 1.2 mm or more)+ pub gap_mm: f64, } +/// A mouse-bite tab: five 0.6 mm holes 0.4 mm apart edge to edge (JLCPCB: 5 to 8 a set, 0.35 to 0.4 mm).+const BITE_HOLE: f64 = 0.6;+const BITE_PITCH: f64 = 1.0;+const BITES: i64 = 5;+const TAB: f64 = 5.0;++fn r3(v: f64) -> f64 {+ (v * 1000.0).round() / 1000.0 + 0.0+}++fn rule(name: &str, ok: Option<bool>, detail: String) -> Value {+ json!({"rule": name, "ok": ok, "detail": detail})+}++/// Where the tabs go along an edge of length `l`: their centres from the edge's start.+fn tab_centres(l: f64) -> Vec<f64> {+ if l < TAB + 1.0 {+ return vec![];+ }+ let n = if l < 15.0 { 1 } else { ((l / 55.0).ceil() as usize).max(2) };+ (0..n).map(|k| l * (k as f64 + 0.5) / n as f64).collect()+}++/// The span [a, b] with the `cuts` spans taken out.+fn broken(a: f64, b: f64, cuts: &[(f64, f64)]) -> Vec<(f64, f64)> {+ let mut out = Vec::new();+ let mut s = a;+ let mut cs = cuts.to_vec();+ cs.sort_by(|p, q| p.0.partial_cmp(&q.0).unwrap_or(std::cmp::Ordering::Equal));+ for (c0, c1) in cs {+ if c0 > s {+ out.push((s, c0.min(b)));+ }+ s = s.max(c1);+ }+ if s < b {+ out.push((s, b));+ }+ out+}++/// Mouse-bite tabs in panel mm from the panel's corner: the rout path (each board's edges broken at+/// its tabs, the tab sides across the gap, the rails' inner edge broken where tabs meet it), the+/// holes, and the tab count. Boards stand `rail + gap` in from the panel's edge and `gap` apart.+pub fn tab_plan(w: f64, h: f64, rows: usize, cols: usize, gap: f64, rail: f64) -> (Vec<[f64; 4]>, Vec<[f64; 2]>, usize) {+ let m = rail + if rail > 0.0 { gap } else { 0.0 };+ let pw = 2.0 * m + cols as f64 * w + (cols as f64 - 1.0) * gap;+ let ph = 2.0 * m + rows as f64 * h + (rows as f64 - 1.0) * gap;+ let (mut lines, mut holes, mut tabs) = (Vec::new(), Vec::new(), 0usize);+ let mut rail_cuts: [Vec<(f64, f64)>; 4] = Default::default();+ let off = BITE_HOLE / 2.0 - BITE_HOLE / 3.0;+ for r in 0..rows {+ for c in 0..cols {+ let (bx, by) = (m + c as f64 * (w + gap), m + r as f64 * (h + gap));+ // (side, outer, along x?, fixed coordinate, start, length, outward sign)+ for (side, outer, horiz, fixed, start, len, out) in [+ (0usize, r == 0, true, by, bx, w, -1.0), (1, r == rows - 1, true, by + h, bx, w, 1.0),+ (2, c == 0, false, bx, by, h, -1.0), (3, c == cols - 1, false, bx + w, by, h, 1.0)] {+ if outer && rail <= 0.0 {+ continue; // the panel's own edge: the frame covers it+ }+ let spans: Vec<(f64, f64)> = tab_centres(len).iter().map(|t| (start + t - TAB / 2.0, start + t + TAB / 2.0)).collect();+ for (a, z) in broken(start, start + len, &spans) {+ lines.push(if horiz { [a, fixed, z, fixed] } else { [fixed, a, fixed, z] });+ }+ let far = fixed + out * gap;+ for &(a, z) in &spans {+ tabs += 1;+ // the tab's two sides across the gap: drawn from the far-side edges (the neighbour's near+ // edges are the same lines) and from every edge that faces a rail+ if side == 1 || side == 3 || outer {+ for p in [a, z] {+ lines.push(if horiz { [p, fixed, p, far] } else { [fixed, p, far, p] });+ }+ }+ if outer {+ rail_cuts[side].push((a, z));+ }+ let mid = (a + z) / 2.0;+ for k in 0..BITES {+ let along = mid + (k - BITES / 2) as f64 * BITE_PITCH;+ let across = fixed + out * off;+ holes.push(if horiz { [r3(along), r3(across)] } else { [r3(across), r3(along)] });+ }+ }+ }+ }+ }+ if rail > 0.0 {+ let (ix0, iy0, ix1, iy1) = (rail, rail, pw - rail, ph - rail);+ for (side, fixed, a, z, horiz) in [(0usize, iy0, ix0, ix1, true), (1, iy1, ix0, ix1, true), (2, ix0, iy0, iy1, false), (3, ix1, iy0, iy1, false)] {+ for (p, q) in broken(a, z, &rail_cuts[side]) {+ lines.push(if horiz { [p, fixed, q, fixed] } else { [fixed, p, fixed, q] });+ }+ }+ }+ (lines.into_iter().map(|l| [r3(l[0]), r3(l[1]), r3(l[2]), r3(l[3])]).collect(), holes, tabs)+}++/// JLCPCB's mouse-bite rules against this panel.+pub fn jlc_tabs(gap: f64, pw: f64, ph: f64, clear: Option<f64>) -> Vec<Value> {+ let mut out = vec![+ rule("gap between boards 1.2 mm or more (1.6 or 2.0 typical)", Some(gap >= 1.2), format!("{gap} mm")),+ rule("0.6 mm holes, 5 to a set, 0.35 to 0.4 mm apart", Some(true), "five 0.6 mm holes at 1.0 mm pitch (0.4 mm between them), centred a third of a hole into the board".into()),+ rule("at least 2 sets an edge, one every 50 to 60 mm", Some(true), "2 tabs on edges of 15 mm and longer, one more each 55 mm; 1 on shorter edges".into()),+ rule("no mouse bites on the rail side", Some(true), "holes on the board side only".into()),+ rule("longest panel side under 250 mm (recommended)", Some(pw.max(ph) < 250.0), format!("{} mm", r3(pw.max(ph)))),+ ];+ if let Some(c) = clear {+ out.push(rule("copper clear of the bitten edge", Some(c >= 0.5), format!("nearest copper {c} mm from the board edge (mouse bites leave about 0.5 mm of rough edge)")));+ }+ out+}++/// JLCPCB's V-cut rules against this panel. `ok: null` is a note, not a pass or a fail.+#[allow(clippy::too_many_arguments)]+pub fn jlc_vscore(pw: f64, ph: f64, thick: Option<f64>, clear: Option<f64>, cuts: (usize, usize), rail: f64, w: f64, h: f64) -> Vec<Value> {+ let mut out = Vec::new();+ let size_ok = pw >= 70.0 && ph >= 70.0 && pw <= 475.0 && ph <= 475.0;+ out.push(rule("panel 70 x 70 mm to 475 x 475 mm", Some(size_ok), if size_ok { format!("{} x {} mm", r3(pw), r3(ph)) } else {+ format!("{} x {} mm: add rows or columns, or widen the rails (railMm), to reach 70 mm each way", r3(pw), r3(ph)) }));+ out.push(match thick {+ Some(t) => rule("board 0.6 mm thick or more", Some(t >= 0.6), format!("{} mm (V-cut is not offered on 0.4 mm boards)", r3(t))),+ None => rule("board 0.6 mm thick or more", None, "the stack-up's thickness could not be read".into()),+ });+ out.push(match clear {+ Some(c) => rule("copper 0.4 mm or more from every cut (1.0 mm recommended, 2.0 mm by tall parts)", Some(c >= 0.4),+ format!("nearest copper {c} mm from the board edge{}", if (0.4..1.0).contains(&c) { ": passes, under the 1.0 mm JLCPCB recommends" } else { "" })),+ None => rule("copper 0.4 mm or more from every cut", None, "no copper on the board".into()),+ });+ out.push(rule("at most 25 cuts each way", Some(cuts.0 <= 25 && cuts.1 <= 25), format!("{} vertical, {} horizontal", cuts.0, cuts.1)));+ let span = if rail > 0.0 { w.min(h).min(rail) } else { w.min(h) };+ out.push(rule("parallel cuts 2 mm apart or more", Some(span >= 2.0), format!("closest pair {} mm apart", r3(span))));+ let need = if thick.map(|t| t <= 0.8).unwrap_or(false) { 5.0 } else { 3.0 };+ out.push(if rail > 0.0 { rule("rails 3 mm wide or more (5 mm on 0.8 mm boards and thinner)", Some(rail >= need), format!("{rail} mm, {need} mm needed")) }+ else { rule("rails", None, "none: fine for bare boards, assembly needs rails (JLCPCB adds tooling holes and fiducials on them)".into()) });+ if w.min(h) < 15.0 {+ out.push(rule("pieces under 15 mm on a side cost extra", None, format!("{} x {} mm boards", r3(w), r3(h))));+ }+ out.push(rule("V-cut is not offered with Economic Assembly", None, "order Standard Assembly for an assembled V-cut panel".into()));+ out+}++/// The nearest copper (tracks, arcs, vias, pads, pours) to the board's outline box, in mm, with where.+fn edge_clearance(model: &Value, bb: [f64; 4]) -> Option<(f64, Value)> {+ let f = |v: &Value| v.as_f64().unwrap_or(0.0);+ let copper = |l: &Value| l.as_str().map(|s| s.ends_with(".Cu")).unwrap_or(false);+ let arr = |k: &str| model[k].as_array().cloned().unwrap_or_default();+ let xy = |p: &Value| match p { Value::Array(a) => (a.first().map(f).unwrap_or(0.0), a.get(1).map(f).unwrap_or(0.0)), _ => (f(&p["x"]), f(&p["y"])) };+ let mut pts: Vec<((f64, f64), f64, Value)> = Vec::new();+ for t in arr("tracks").iter().filter(|t| copper(&t["layer"])) {+ for p in [&t["start"], &t["end"]] {+ pts.push((xy(p), f(&t["width"]) / 2.0, json!({"track": t["net"], "layer": t["layer"]})));+ }+ }+ for v in arr("vias") {+ pts.push((xy(&v["position"]), f(&v["size"]) / 2.0, json!({"via": v["net"]})));+ }+ for p in arr("pads") {+ pts.push(((f(&p["x"]), f(&p["y"])), f(&p["sizeX"]).max(f(&p["sizeY"])) / 2.0, json!({"pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""))})));+ }+ for g in arr("polygons").iter().filter(|g| copper(&g["layer"])) {+ for p in g["points"].as_array().cloned().unwrap_or_default() {+ pts.push((xy(&p), 0.0, json!({"pour": g["net"], "layer": g["layer"]})));+ }+ }+ let [x0, y0, x1, y1] = bb;+ pts.into_iter().filter(|((x, y), _, _)| *x >= x0 && *x <= x1 && *y >= y0 && *y <= y1)+ .map(|((x, y), half, mut what)| {+ what["at"] = json!([r3(x), r3(y)]);+ (r3((x - x0).min(x1 - x).min(y - y0).min(y1 - y) - half), what)+ })+ .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))+}+ const HEADER: &[&str] = &["version", "generator", "generator_version", "general", "paper", "title_block", "layers", "setup", "property", "net", "embedded_fonts", "embedded_files"]; fn fmt(v: f64) -> String {⋯ 155 unchanged lines ⋯ } let [x0, y0, x1, y1] = rect_outline(&doc.root)?; let (w, h) = (x1 - x0, y1 - y0);+ if o.tabs && o.gap_mm < 1.2 {+ return Err("mouse bites need a gap of 1.2 mm or more between boards (JLCPCB: 1.6 or 2.0 typical)".into());+ }+ let gap = if o.tabs { o.gap_mm } else { 0.0 };+ // what JLCPCB's rules need from the board itself: its thickness and its copper nearest the edge+ let thick = crate::board_model::stackup(text).ok().and_then(|s| s["totalUm"].as_f64()).map(|u| u / 1000.0);+ let clear = crate::board_model::build(text, None, &crate::board_model::Opts { y_up: false }).ok().and_then(|m| edge_clearance(&m, [x0, y0, x1, y1])); let kids: Vec<Node> = doc.root.children().to_vec(); // numbered nets: the declaration table let decls: Vec<(i64, String)> = kids.iter().filter(|c| c.head() == Some("net")).filter_map(|c| Some((c.arg(0)?.parse().ok()?, c.arg(1).unwrap_or_default()))).collect();⋯ 24 unchanged lines ⋯ for r in 0..o.rows { for c in 0..o.cols { n += 1;- let (dx, dy) = (c as f64 * w, r as f64 * h);+ let (dx, dy) = (c as f64 * (w + gap), r as f64 * (h + gap)); let names: HashMap<String, String> = all_names.iter().filter(|x| !x.is_empty()).map(|x| (x.clone(), format!("B{n}-{x}"))).collect(); let numbers: HashMap<i64, i64> = decls.iter().map(|(code, _)| (*code, if *code == 0 { 0 } else { code + span * (n as i64 - 1) })).collect(); for (code, name) in &decls {⋯ 27 unchanged lines ⋯ } } }- // the panel: outline with rails, V-cuts, tooling holes, fiducials- let (pw, ph) = (o.cols as f64 * w, o.rows as f64 * h);+ // the panel: outline with rails, the separation (V-cuts or tabs), tooling holes, fiducials let rail = o.rail_mm.max(0.0);- let (top, bottom) = (y0 - rail, y0 + ph + rail); let mut panel_items: Vec<Node> = Vec::new();- for (a, b) in [((x0, top), (x0 + pw, top)), ((x0 + pw, top), (x0 + pw, bottom)), ((x0 + pw, bottom), (x0, bottom)), ((x0, bottom), (x0, top))] {- panel_items.push(line("Edge.Cuts", a, b, 0.1)?);- }- let mut vcuts = 0;- for r in 0..=o.rows {- let y = y0 + r as f64 * h;- if rail <= 0.0 && (r == 0 || r == o.rows) {- continue;+ let report;+ if o.tabs {+ let m = rail + if rail > 0.0 { gap } else { 0.0 };+ let (pw, ph) = (2.0 * m + o.cols as f64 * w + (o.cols as f64 - 1.0) * gap, 2.0 * m + o.rows as f64 * h + (o.rows as f64 - 1.0) * gap);+ let (ox, oy) = (x0 - m, y0 - m);+ for (a, b) in [((ox, oy), (ox + pw, oy)), ((ox + pw, oy), (ox + pw, oy + ph)), ((ox + pw, oy + ph), (ox, oy + ph)), ((ox, oy + ph), (ox, oy))] {+ panel_items.push(line("Edge.Cuts", a, b, 0.1)?); }- panel_items.push(line("Cmts.User", (x0 - 2.0, y), (x0 + pw + 2.0, y), 0.1)?);- vcuts += 1;- }- for c in 1..o.cols {- let x = x0 + c as f64 * w;- panel_items.push(line("Cmts.User", (x, top - 2.0), (x, bottom + 2.0), 0.1)?);- vcuts += 1;- }- panel_items.push(parse(&format!("(gr_text \"V-CUT\" (at {} {} 0) (layer \"Cmts.User\") (uuid \"{}\") (effects (font (size 1 1) (thickness 0.15)) (justify left)))", fmt(x0 + pw + 3.0), fmt(y0), new_uuid()))?);- let (mut holes, mut fids) = (0, 0);- if rail >= 3.0 {- let hole = if rail >= 6.0 { 3.0 } else { 2.0 };- let inset = (rail / 2.0).max(2.5);- let mid_top = top + rail / 2.0;- let mid_bot = bottom - rail / 2.0;- if o.holes {- for (i, (x, y)) in [(x0 + inset, mid_top), (x0 + pw - inset, mid_top), (x0 + inset, mid_bot)].into_iter().enumerate() {- panel_items.push(tool_footprint(&format!("TH{}", i + 1), x, y, false, hole)?);- holes += 1;+ let (rout, bites, tabs) = tab_plan(w, h, o.rows, o.cols, gap, rail);+ for l in &rout {+ panel_items.push(line("Edge.Cuts", (ox + l[0], oy + l[1]), (ox + l[2], oy + l[3]), 0.1)?);+ }+ for (i, hpt) in bites.iter().enumerate() {+ panel_items.push(tool_footprint(&format!("MB{}", i + 1), ox + hpt[0], oy + hpt[1], false, BITE_HOLE)?);+ }+ let (mut holes, mut fids) = (0, 0);+ if rail >= 5.0 {+ // 3 tooling holes (the fourth corner bare so the panel loads one way) and 3 fiducials 5 mm in from them+ let k = rail / 2.0;+ if o.holes {+ for (i, (x, y)) in [(k, k), (pw - k, k), (k, ph - k)].into_iter().enumerate() {+ panel_items.push(tool_footprint(&format!("TH{}", i + 1), ox + x, oy + y, false, 2.0)?);+ holes += 1;+ } }+ if o.fiducials {+ for (i, (x, y)) in [(k + 5.0, k), (pw - k - 5.0, k), (k + 5.0, ph - k)].into_iter().enumerate() {+ panel_items.push(tool_footprint(&format!("FID{}", i + 1), ox + x, oy + y, true, 0.0)?);+ fids += 1;+ }+ } }- if o.fiducials && pw > 4.0 * inset + 6.0 {- for (i, (x, y)) in [(x0 + 3.0 * inset, mid_top), (x0 + pw - 3.0 * inset, mid_top), (x0 + 3.0 * inset, mid_bot)].into_iter().enumerate() {- panel_items.push(tool_footprint(&format!("FID{}", i + 1), x, y, true, 0.0)?);- fids += 1;+ let rules = jlc_tabs(gap, pw, ph, clear.as_ref().map(|c| c.0));+ let fits = rules.iter().all(|r| r["ok"] != false);+ let boards_at: Vec<Value> = (0..o.rows).flat_map(|r| (0..o.cols).map(move |c| (r, c))).map(|(r, c)| json!([r3(m + c as f64 * (w + gap)), r3(m + r as f64 * (h + gap))])).collect();+ report = json!({"panelSizeMm": [r3(pw), r3(ph)], "panelMm": [r3(pw), r3(ph)], "boardsAt": boards_at, "toolingHoles": holes, "fiducials": fids, "gapMm": gap,+ "separation": {"kind": "tabs", "tabs": tabs, "holes": bites.len(), "routLines": rout.len(), "layer": "Edge.Cuts"}, "jlcpcb": {"rules": rules, "fits": fits}});+ } else {+ let (pw, ph) = (o.cols as f64 * w, o.rows as f64 * h);+ let (top, bottom) = (y0 - rail, y0 + ph + rail);+ for (a, b) in [((x0, top), (x0 + pw, top)), ((x0 + pw, top), (x0 + pw, bottom)), ((x0 + pw, bottom), (x0, bottom)), ((x0, bottom), (x0, top))] {+ panel_items.push(line("Edge.Cuts", a, b, 0.1)?);+ }+ let mut vcuts: usize = 0;+ for r in 0..=o.rows {+ let y = y0 + r as f64 * h;+ if rail <= 0.0 && (r == 0 || r == o.rows) {+ continue; }+ panel_items.push(line("Cmts.User", (x0 - 2.0, y), (x0 + pw + 2.0, y), 0.1)?);+ vcuts += 1; }+ for c in 1..o.cols {+ let x = x0 + c as f64 * w;+ panel_items.push(line("Cmts.User", (x, top - 2.0), (x, bottom + 2.0), 0.1)?);+ vcuts += 1;+ }+ panel_items.push(parse(&format!("(gr_text \"V-CUT\" (at {} {} 0) (layer \"Cmts.User\") (uuid \"{}\") (effects (font (size 1 1) (thickness 0.15)) (justify left)))", fmt(x0 + pw + 3.0), fmt(y0), new_uuid()))?);+ let (mut holes, mut fids) = (0, 0);+ if rail >= 3.0 {+ let hole = if rail >= 6.0 { 3.0 } else { 2.0 };+ let inset = (rail / 2.0).max(2.5);+ let mid_top = top + rail / 2.0;+ let mid_bot = bottom - rail / 2.0;+ if o.holes {+ for (i, (x, y)) in [(x0 + inset, mid_top), (x0 + pw - inset, mid_top), (x0 + inset, mid_bot)].into_iter().enumerate() {+ panel_items.push(tool_footprint(&format!("TH{}", i + 1), x, y, false, hole)?);+ holes += 1;+ }+ }+ if o.fiducials && pw > 4.0 * inset + 6.0 {+ for (i, (x, y)) in [(x0 + 3.0 * inset, mid_top), (x0 + pw - 3.0 * inset, mid_top), (x0 + 3.0 * inset, mid_bot)].into_iter().enumerate() {+ panel_items.push(tool_footprint(&format!("FID{}", i + 1), x, y, true, 0.0)?);+ fids += 1;+ }+ }+ }+ let rules = jlc_vscore(pw, ph + 2.0 * rail, thick, clear.as_ref().map(|c| c.0), (o.cols.saturating_sub(1), vcuts.saturating_sub(o.cols.saturating_sub(1))), rail, w, h);+ let fits = rules.iter().all(|r| r["ok"] != false);+ let boards_at: Vec<Value> = (0..o.rows).flat_map(|r| (0..o.cols).map(move |c| (r, c))).map(|(r, c)| json!([r3(c as f64 * w), r3(rail + r as f64 * h)])).collect();+ report = json!({"panelSizeMm": [pw, ph + 2.0 * rail], "panelMm": [r3(pw), r3(ph + 2.0 * rail)], "boardsAt": boards_at, "toolingHoles": holes, "fiducials": fids,+ "separation": {"kind": "vscore", "cutsX": o.cols.saturating_sub(1), "cutsY": vcuts.saturating_sub(o.cols.saturating_sub(1)), "layer": "Cmts.User"}, "vCuts": vcuts, "vCutLayer": "Cmts.User",+ "jlcpcb": {"rules": rules, "fits": fits}}); } // rebuild: the header, the renamed net table, the copies, the panel let mut root_kids: Vec<Node> = Vec::new();⋯ 24 unchanged lines ⋯ *r = root_kids; } let text_out = doc.to_text();- Ok((text_out, json!({- "boards": o.rows * o.cols, "rows": o.rows, "cols": o.cols,- "boardSizeMm": [w, h], "panelSizeMm": [pw, ph + 2.0 * rail], "railMm": rail,- "separation": "vscore", "vCuts": vcuts, "vCutLayer": "Cmts.User", "toolingHoles": holes, "fiducials": fids,+ let mut out = json!({+ "boards": o.rows * o.cols, "rows": o.rows, "cols": o.cols, "boardSizeMm": [w, h], "boardMm": [r3(w), r3(h)], "railMm": rail,+ "nearestCopperToEdge": clear.map(|(mm, at)| json!({"mm": mm, "at": at})), "netsRenamed": all_names.iter().filter(|x| !x.is_empty()).count() * o.rows * o.cols,- })))+ });+ for (k, v) in report.as_object().cloned().unwrap_or_default() {+ out[k] = v;+ }+ Ok((text_out, out)) } #[cfg(test)]⋯ 5 unchanged lines ⋯ } #[test]+ fn tab_plan_follows_jlcpcb() {+ // the Altium bridge's case: two 40 x 20 boards, 2 mm apart, 5 mm rails+ let (lines, holes, tabs) = tab_plan(40.0, 20.0, 1, 2, 2.0, 5.0);+ assert_eq!(tabs, 16, "4 edges each with 2 tabs, two boards");+ assert_eq!(holes.len(), 16 * 5);+ // the tab between the boards is bridged: no rout line runs along x 47..49 at the tab's height+ assert!(!lines.iter().any(|l| l[1] == l[3] && l[0].min(l[2]) < 48.0 && l[0].max(l[2]) > 48.0 && (l[1] - 7.0 - 5.0).abs() < 1.0), "{lines:?}");+ assert_eq!(tab_centres(40.0), vec![10.0, 30.0]);+ assert_eq!(tab_centres(120.0).len(), 3);+ assert!(tab_centres(5.0).is_empty());+ }++ #[test]+ fn a_mouse_bite_panel_spaces_the_copies_and_routes_round_them() {+ let t = fixture("tests/fixtures/konnect-route/route-fixture.kicad_pcb");+ let (out, rep) = panelize(&t, &Opts { rows: 2, cols: 2, rail_mm: 5.0, holes: true, fiducials: true, tabs: true, gap_mm: 2.0 }).unwrap();+ assert_eq!(rep["separation"]["kind"], "tabs");+ let bites = rep["separation"]["holes"].as_u64().unwrap() as usize;+ assert!(bites > 0 && bites % 5 == 0, "{rep}");+ let doc = Document::parse(&out).unwrap();+ let fps = doc.root.children().iter().filter(|c| c.head() == Some("footprint")).count();+ let src = Document::parse(&t).unwrap().root.children().iter().filter(|c| c.head() == Some("footprint")).count();+ assert_eq!(fps, src * 4 + 6 + bites, "four copies, tooling holes and fiducials, and every bite hole");+ // the frame plus the rout path, all on Edge.Cuts+ let edges = doc.root.children().iter().filter(|c| c.child_value("layer").as_deref() == Some("Edge.Cuts")).count();+ assert_eq!(edges, 4 + rep["separation"]["routLines"].as_u64().unwrap() as usize);+ // the second column stands one board width plus the gap from the first+ let w = rep["boardMm"][0].as_f64().unwrap();+ assert!((rep["boardsAt"][1][0].as_f64().unwrap() - rep["boardsAt"][0][0].as_f64().unwrap() - (w + 2.0)).abs() < 1e-6, "{rep}");+ assert!(rep["jlcpcb"]["rules"].as_array().unwrap().iter().any(|r| r["rule"].as_str().unwrap().starts_with("gap between boards")));+ assert!(panelize(&t, &Opts { rows: 1, cols: 2, rail_mm: 5.0, holes: true, fiducials: true, tabs: true, gap_mm: 0.5 }).unwrap_err().contains("1.2 mm"));+ }++ #[test] fn a_rectangular_board_panelises_with_separate_nets() { let t = fixture("tests/fixtures/konnect-route/route-fixture.kicad_pcb");- let (out, rep) = panelize(&t, &Opts { rows: 2, cols: 3, rail_mm: 5.0, holes: true, fiducials: true }).unwrap();+ let (out, rep) = panelize(&t, &Opts { rows: 2, cols: 3, rail_mm: 5.0, holes: true, fiducials: true, tabs: false, gap_mm: 0.0 }).unwrap(); assert_eq!(rep["boards"], 6); let doc = Document::parse(&out).unwrap(); let fps = doc.root.children().iter().filter(|c| c.head() == Some("footprint")).count();⋯ 14 unchanged lines ⋯ #[test] fn a_shaped_board_is_refused() { let t = "(kicad_pcb (version 20250907) (gr_line (start 0 0) (end 10 0) (layer \"Edge.Cuts\")) (gr_arc (start 10 0) (mid 12 5) (end 10 10) (layer \"Edge.Cuts\")) (gr_line (start 10 10) (end 0 10) (layer \"Edge.Cuts\")) (gr_line (start 0 10) (end 0 0) (layer \"Edge.Cuts\")))";- let e = panelize(t, &Opts { rows: 1, cols: 2, rail_mm: 5.0, holes: true, fiducials: true }).unwrap_err();+ let e = panelize(t, &Opts { rows: 1, cols: 2, rail_mm: 5.0, holes: true, fiducials: true, tabs: false, gap_mm: 0.0 }).unwrap_err(); assert!(e.contains("arc"), "{e}"); let notched = "(kicad_pcb (version 20250907) (gr_line (start 0 0) (end 10 0) (layer \"Edge.Cuts\")) (gr_line (start 10 0) (end 10 10) (layer \"Edge.Cuts\")) (gr_line (start 10 10) (end 5 10) (layer \"Edge.Cuts\")) (gr_line (start 5 10) (end 5 8) (layer \"Edge.Cuts\")) (gr_line (start 5 8) (end 0 8) (layer \"Edge.Cuts\")) (gr_line (start 0 8) (end 0 0) (layer \"Edge.Cuts\")))";- assert!(panelize(notched, &Opts { rows: 1, cols: 2, rail_mm: 5.0, holes: true, fiducials: true }).is_err());+ assert!(panelize(notched, &Opts { rows: 1, cols: 2, rail_mm: 5.0, holes: true, fiducials: true, tabs: false, gap_mm: 0.0 }).is_err()); } #[test] fn numbered_nets_get_a_table_entry_per_copy() { let t = "(kicad_pcb (version 20250907)\n\t(net 0 \"\")\n\t(net 1 \"GND\")\n\t(net 2 \"VCC\")\n\t(footprint \"L:R\" (layer \"F.Cu\") (uuid \"f1\") (at 5 5) (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net 1 \"GND\") (uuid \"p1\")))\n\t(segment (start 1 1) (end 2 1) (width 0.2) (layer \"F.Cu\") (net 2) (uuid \"s1\"))\n\t(gr_rect (start 0 0) (end 10 10) (layer \"Edge.Cuts\") (uuid \"e1\"))\n)\n";- let (out, _) = panelize(t, &Opts { rows: 1, cols: 2, rail_mm: 0.0, holes: false, fiducials: false }).unwrap();+ let (out, _) = panelize(t, &Opts { rows: 1, cols: 2, rail_mm: 0.0, holes: false, fiducials: false, tabs: false, gap_mm: 0.0 }).unwrap(); assert!(out.contains("(net 1 \"B1-GND\")") && out.contains("(net 4 \"B2-GND\")") && out.contains("(net 5 \"B2-VCC\")"), "{out}"); assert!(out.contains("(net 4 \"B2-GND\"))") || out.contains("(net 4 \"B2-GND\") (uuid"), "the second copy's pad uses the new number: {out}"); assert!(out.contains("(start 11 1)"), "the second copy moved 10 mm right: {out}"); } }+@@ -1,1760 +1,1775 @@⋯ 1121 unchanged lines ⋯ } /// Insert board items before the closing paren (where KiCad keeps copper).+/// The board text without the top-level item whose uuid is `id` (a segment or an arc).+pub fn text_without_item(text: &str, id: &str) -> Option<String> {+ let root = crate::sxedit::parse(text).ok()?;+ let item = root.kids.iter().find(|k| matches!(k.tag(), Some("segment" | "arc")) && k.uuid() == Some(id))?;+ let mut start = item.start;+ while start > 0 && matches!(text.as_bytes()[start - 1], b' ' | b'\t') {+ start -= 1;+ }+ if start > 0 && text.as_bytes()[start - 1] == b'\n' {+ start -= 1;+ }+ Some(format!("{}{}", &text[..start], &text[item.end..]))+}+ pub fn splice(text: &str, blocks: &[String]) -> String { let trimmed = text.trim_end(); let idx = trimmed.rfind(')').unwrap_or(trimmed.len());⋯ 630 unchanged lines ⋯ assert_eq!(err["candidates"].as_array().unwrap().len(), 2); } }+@@ -1,563 +1,564 @@⋯ 203 unchanged lines ⋯ } /// Set (or clear, with `None`) the net of every pad numbered `pad` in the footprint block.-fn set_pad_net(block: &str, pad: &str, net: Option<&str>, fmt: NetFormat, numbers: &BTreeMap<String, i64>) -> String {+pub fn set_pad_net(block: &str, pad: &str, net: Option<&str>, fmt: NetFormat, numbers: &BTreeMap<String, i64>) -> String { let mut out = block.to_string(); let head = format!("(pad {} ", q(pad)); // walk from the end so earlier spans stay valid⋯ 350 unchanged lines ⋯ assert_eq!(symbol_path(&c).as_deref(), Some("/s1/abc")); } }+@@ -1,99 +1,132 @@ --- 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, 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.+ 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, panelize, mouse bites, tabs, v-score, panel, build a board, test board, read schematic file, length tune, length matching, serpentine, meander, match length, skew, silkscreen prep, silk on pads, component class, room, keep parts together. --- # Board checks⋯ 59 unchanged lines ⋯ 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. +`kicad_silkscreen_prep` readies the silkscreen for fab on a closed board:+- it sets the Gerbers to subtract the solder mask from the silkscreen (KiCad's own clipping);+- it deletes silkscreen lying outside the board;+- it moves designators that sit on pads, using `kicad_silk_designators`;+- it counts KiCad's silk DRC findings before and after.++## Component classes++`kicad_component_classes {name, members, room?}` puts parts in a class (KiCad 9+: each member footprint carries the+class, and rules match it with `A.hasComponentClass('<name>')`). `room: true` (or `{marginMm}`, or `{rect}`) adds a rule+area `Adom room <name>` round them and a rule in the project's .kicad_dru that flags any member outside it+(`assertion_failure`). Without `name` it lists the classes and rooms; `remove: true` takes a class, its room and its+rule away. Edits need the board closed.+ ## Ordering from JLCPCB | Verb | Answers |⋯ 8 unchanged lines ⋯ 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. +## Length tuning++`kicad_length_tune {net, targetMm | matchTo}` adds the missing length as a serpentine of square bumps in the+net's longest straight track that has room on one side (each bump adds twice its amplitude, at most+`maxAmplitudeMm`), clear of other copper, pads, vias and the edge. It swaps the track live as one Undo step,+or in the closed file, only after KiCad's DRC finds no new error. `kicad_net_lengths {groups}` says which nets+of a matched group are short and by how much.++## Panels and new boards++`kicad_panelize {pcbPath, rows, cols, separation}` steps a rectangular board into a panel: `vscore` (copies+abutting, V-cut lines on Cmts.User) or `tabs` (copies `gapMm` apart, 2 mm by default, the rout path on Edge.Cuts+broken at mouse-bite tabs with five 0.6 mm holes each, rails all round). `jlcpcb.rules` checks JLCPCB's panel+rules for either. `kicad_build_board {pcbPath, outline, parts, nets}` makes a new board from a parts list and a+netlist (footprints by Library:Name or from a .pretty `libraryPath`), read back pin by pin; `kicad_test_board`+lays one footprint or a `;` list on a new or existing board to look at. `kicad_read_sheet_file` reads a+schematic sheet's parts, fields and pins straight from the file.+ ## 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.+@@ -1,1590 +1,1627 @@⋯ 193 unchanged lines ⋯ # ---- 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",- "bom", "assembly_check", "jlcpcb_order", "footprint_from_land_pattern", "silk_designators")},+ "bom", "assembly_check", "jlcpcb_order", "footprint_from_land_pattern", "silk_designators",+ "read_sheet_file", "build_board", "test_board", "length_tune", "silkscreen_prep", "component_classes")}, "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),⋯ 430 unchanged lines ⋯ ab(a.target, "send_files", {"filePaths": [live_src], "dest": base + "/live2"}, timeout=200) # the checks phase's scratch board: the ESC with its planes, closed, vias written into it ab(a.target, "send_files", {"filePaths": [str(REPO / "demo" / "routing" / "esc" / "esc-g431-fable-placed-planes.kicad_pcb")], "dest": base + "/checks"}, timeout=200)+ # and a small routed board for length tuning+ ab(a.target, "send_files", {"filePaths": [str(REPO / "rust" / "crates" / "kicad-core" / "tests" / "fixtures" / "konnect-route" / "route-fixture.kicad_pcb")], "dest": base + "/checks"}, timeout=200) # scratch symbol library for install_library (written locally, pushed over) import tempfile as _tf tl = Path(_tf.gettempdir()) / "AdomVerbTest.kicad_sym"⋯ 876 unchanged lines ⋯ 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")+ # 1.0.69: a sheet read from its file, boards built from footprints, a mouse-bite panel+ r = C("read_sheet_file", {"path": w["sch"], "params": True})+ own("read_sheet_file", r, ok(r) and r.get("count", 0) > 0 and any(c.get("pins") for c in r.get("components") or []), "no parts with pins read from the sheet")+ r = C("build_board", {"pcbPath": w["out"] + "/built/built.kicad_pcb", "outline": {"rect": [0, 0, 30, 20], "cornerRadius": 1.5},+ "parts": [{"ref": "R1", "footprint": "Resistor_SMD:R_0603_1608Metric", "x": 8, "y": 10}, {"ref": "C1", "footprint": "Capacitor_SMD:C_0603_1608Metric", "x": 16, "y": 10, "rotation": 90}],+ "nets": {"VIN": ["R1.1"], "OUT": ["R1.2", "C1.1"], "GND": ["C1.2"]}})+ own("build_board", r, ok(r) and r.get("padsWired") == 4 and r.get("wrongPins") == [] and (r.get("counts") or {}).get("components") == 2, "the board was not built and wired")+ r = C("test_board", {"libraryPath": lp["libraryPath"], "footprintName": "SOIC-8_test", "pcbPath": w["out"] + "/built/test.kicad_pcb", "assigns": "1=GND"})+ own("test_board", r, ok(r) and r.get("count") == 1 and r.get("saved") is True and r.get("wrongPins") == [], "the footprint was not placed on a test board")+ r = C("panelize", {"pcbPath": w["board"], "panelPath": w["out"] + "/panel/panel-tabs.kicad_pcb", "rows": 2, "cols": 2, "separation": "tabs", "overwrite": True}, 300)+ record("panelize_tabs", r, (ok(r) and (r.get("separation") or {}).get("kind") == "tabs" and (r.get("separation") or {}).get("holes", 0) > 0) or r.get("errorCode") == "not_rectangular",+ "no mouse-bite panel")+ RT = base + "/checks/route-fixture.kicad_pcb"+ lens = C("net_lengths", {"filePath": RT, "source": "file"})+ routed = next((n for n in (lens.get("nets") or []) if (n.get("lengthMm") or 0) > 5), None)+ if routed:+ r = C("length_tune", {"filePath": RT, "source": "file", "net": routed["net"], "targetMm": routed["lengthMm"] + 2.0}, 300)+ after = C("net_lengths", {"filePath": RT, "source": "file", "nets": [routed["net"]]})+ got = ((after.get("nets") or [{}])[0]).get("lengthMm") or 0+ own("length_tune", {"tune": r, "after": after}, ok(r) and r.get("changed") is True and abs(got - routed["lengthMm"] - 2.0) < 0.05 and (r.get("drc") or {}).get("newErrors") == 0,+ f"the net is {got} mm after tuning to {routed['lengthMm'] + 2.0}")+ else:+ own("length_tune", lens, False, "the route fixture has no routed net over 5 mm")+ # 1.0.69: component classes with a room, and the silkscreen prep, both on the closed route fixture+ r = C("component_classes", {"filePath": RT, "source": "file", "name": "Probe", "members": ["R1", "U1"], "room": True})+ lst = C("component_classes", {"filePath": RT, "source": "file"})+ drc = ab(a.target, "kicad_run_drc", {"filePath": RT}, timeout=200)+ asserts = [v for v in ((drc.get("data") or {}).get("violations") or []) if v.get("type") == "assertion_failure"]+ gone = C("component_classes", {"filePath": RT, "source": "file", "name": "Probe", "remove": True})+ own("component_classes", {"make": r, "list": lst, "remove": gone}, ok(r) and r.get("missing") == [] and r.get("roomMm") and any(c.get("name") == "Probe" and len(c.get("members") or []) == 2 for c in lst.get("classes") or [])+ and not asserts and ok(gone) and not gone.get("classes"), "the class and its room were not made, held in DRC, or removed")+ r = C("silkscreen_prep", {"filePath": RT, "source": "file"}, 600)+ own("silkscreen_prep", r, ok(r) and r.get("gerbersClipSilk") is True and (r.get("after") or {}).get("silkToSolderMask") is not None, "no prep, or no DRC counts after") 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")⋯ 69 unchanged lines ⋯ if __name__ == "__main__": sys.exit(main())+
Comments
Log in to comment.
No comments yet.