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.
master
Colby Knox
Merge PR #447: Reject unknown kicad_move_footprint arguments before native edits
3174890
2d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647
//! Live, undoable copper edits through the official KiCad IPC API (KiCad 10.0.1+), on
//! `kicad-ipc-rs`. Port of `handlers/live_routing.py`: the caller plans the path, KiCad
//! checks candidate copper on a disposable snapshot before we commit it, and every
//! mutation is one native Undo step. No File > Revert, no clicks, no writes to the open
//! board file.
//!
//! Every IPC round trip carries the crate's send/receive deadline, so a KiCad whose UI
//! thread is blocked produces `ipc_timeout` instead of a hung HTTP server. The first
//! call after connecting is the responsiveness probe (`GetVersion`).
//!
//! How the Python's kipy concepts map onto the crate:
//! - `Board.get_as_string()` is `get_board_as_string()`; the revision is the SHA-256 of
//! that text, exactly as the Python derives it (`_snapshot`).
//! - `begin_commit / push_commit / drop_commit` are `begin_commit()` and
//! `end_commit(session, Commit | Drop, message)`; the message is the Undo entry.
//! - `create_items(Track | Via)` is `create_editable_items(vec![EditablePcbItem::...])`.
//! - `DeleteItems` is `delete_items(ids)`, whose per-row check matches the Python's
//! `_delete_items` (KiCad 10.0.5 returns no rows; the board is re-read after push).
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde_json::{json, Map, Value};
use kicad_ipc_rs::{BoardLayerInfo, BoardNet, DocumentSpecifier, DocumentType, EditablePcbItem, KiCadClientBlocking, KiCadError, PcbItem, PcbObjectTypeCode, TrackItem, Vector2Nm, VersionInfo, ViaItem};
use crate::cli;
use crate::pcb::{self, Board, Plan, RoutingError};
use crate::placement;
use crate::zones;
/// The ipc cargo feature is compiled in. The verb group asks this before promising live
/// routing; a build with the feature off has no `ipc` module at all.
pub fn available() -> bool {
true
}
pub const DEFAULT_TIMEOUT_MS: u64 = 5000;
pub const DRC_TIMEOUT: Duration = Duration::from_secs(90);
/// Connectivity walks stop here (`board_too_large`).
pub const MAX_WALK: usize = 100_000;
mod silk_graphics;
pub use silk_graphics::silk_graphics_batch;
mod route_edit;
pub use route_edit::{apply_track_change, copy_routing, modify_track, route_diff_pair};
mod board_live;
mod live_create;
pub use board_live::*;
type RResult<T> = Result<T, RoutingError>;
/// What the verbs know that the operations need: where kicad-cli is (for the DRC
/// snapshot) and KiCad's config dir (to say `api_server_off` honestly).
#[derive(Clone, Debug, Default)]
pub struct Ctx {
pub kicad_cli: Option<PathBuf>,
pub config_dir: Option<PathBuf>,
}
// ---------------------------------------------------------------------------
// Errors and the response shape
// ---------------------------------------------------------------------------
const HINT: &str = "Read kicad_routing_state with the exact filePath and socketPath. Enable Preferences > Plugins > Enable IPC API server in KiCad 10.0.1+ (kicad_ipc_api {\"enable\":true} writes the switch; KiCad must be closed and relaunched). After a mutation timeout inspect the live board; never blindly replay it.";
/// The Python `_error`: `success:false`, `errorCode`, `error`, every detail key, `_hint`.
pub fn error_response(e: &RoutingError) -> Value {
let mut out = Map::new();
out.insert("success".into(), json!(false));
out.insert("errorCode".into(), json!(e.code));
out.insert("error".into(), json!(e.message));
for (k, v) in &e.detail {
out.insert(k.clone(), v.clone());
}
out.entry("_hint").or_insert(json!(HINT));
Value::Object(out)
}
/// Map a crate error to the routing contract. Timeouts become `ipc_timeout` (nothing of
/// ours is still running; KiCad may answer nobody later), transport failures
/// `ipc_unavailable`, no open board `no_board_open`, an ambiguous board `board_mismatch`,
/// and any KiCad status or decode failure `ipc_error`.
fn map_err(e: KiCadError) -> RoutingError {
match e {
KiCadError::Timeout { timeout } => RoutingError::new("ipc_timeout", format!("KiCad did not answer within {} ms; its UI thread may be blocked by a dialog or a long operation", timeout.as_millis()))
.with("ipcTimeoutMs", json!(timeout.as_millis() as u64))
.with("stillRunning", json!(false))
.with("mutated", json!(false)),
KiCadError::SocketUnavailable { socket_uri } => RoutingError::new("ipc_unavailable", format!("KiCad IPC socket not available at {socket_uri}; open the board in the PCB editor with the API server enabled")).with("socket", json!(socket_uri)),
KiCadError::Connection { socket_uri, reason } => RoutingError::new("ipc_unavailable", format!("connection failed for {socket_uri}: {reason}")).with("socket", json!(socket_uri)),
KiCadError::TransportSend { .. } | KiCadError::TransportReceive { .. } | KiCadError::TransportClosed | KiCadError::BlockingRuntimeClosed | KiCadError::RuntimeJoin(_) => RoutingError::new("ipc_unavailable", e.to_string()),
KiCadError::BoardNotOpen => RoutingError::new("no_board_open", "No board is open in the PCB editor").with("openBoards", json!([])),
KiCadError::AmbiguousBoardSelection { boards } => RoutingError::new("board_mismatch", "This IPC endpoint exposes more than one board").with("openBoards", json!(boards)),
// KiCad runs ONE API server per machine (the first KiCad process takes api.sock; a
// later standalone pcbnew.exe gets none, measured on arav-rog 2026-09-11). When that
// first process is the project manager with no PCB frame, board commands come back
// AS_UNHANDLED. Name the situation instead of a bare status code.
KiCadError::ApiStatus { code, message } if code == "AS_UNHANDLED" => RoutingError::new(
"no_pcb_frame",
"The KiCad process that owns the API socket has no PCB editor frame (a project manager alone, or the board is open in a second, standalone pcbnew.exe that got no API server)",
)
.with("apiStatus", json!({"code": code, "message": message}))
.with("_hint", json!("KiCad serves one API socket per machine and the first KiCad process wins. Either close the project manager and open the board with kicad_open_board (standalone pcbnew then owns the socket), or open the board from within the project manager (kicad_launch with the project, then the PCB Editor button) so the frame lives in the process that has the socket. Then read kicad_routing_state again.")),
other => RoutingError::new("ipc_error", other.to_string()),
}
}
/// A failure inside a commit: the message plus whether it was a deadline, so the
/// transaction can say `mutation_outcome_unknown` with `stillRunning`.
#[derive(Clone, Debug, PartialEq)]
pub struct IpcFailure {
pub message: String,
pub timed_out: bool,
}
impl From<KiCadError> for IpcFailure {
fn from(e: KiCadError) -> Self {
IpcFailure { timed_out: matches!(e, KiCadError::Timeout { .. }), message: e.to_string() }
}
}
impl From<String> for IpcFailure {
fn from(message: String) -> Self {
IpcFailure { message, timed_out: false }
}
}
// ---------------------------------------------------------------------------
// Pure helpers (tested without KiCad)
// ---------------------------------------------------------------------------
/// The board revision: SHA-256 of the editor's own serialisation of the board, the way
/// `live_routing._snapshot` derives it. Any edit (ours or the user's) changes it.
/// Wiki #104: what to do about a silk refusal. A text_height / text_thickness finding comes from
/// the BOARD's own minimum, not from the bridge: say where that minimum lives.
pub fn silk_drc_hint(added: &[Value]) -> String {
let types: Vec<&str> = added.iter().filter_map(|v| v.get("type").and_then(Value::as_str)).collect();
if types.iter().any(|t| *t == "text_height" || *t == "text_thickness") {
"Refused by the BOARD's minimum text size, not by the bridge: KiCad's DRC finds the new text smaller than Board Setup > Design Rules > Constraints (Minimum text height / thickness), or than a (constraint text_height (min ...)) rule in the board's .kicad_dru. Use text at or above that minimum, or lower the board minimum first if smaller text is intended; nothing was changed.".to_string()
} else {
"Each violation names the rule and the items involved: move the candidate (a dry run per spot is cheap), then submit again; nothing was changed.".to_string()
}
}
pub fn revision_of(text: &str) -> String {
crate::pcm::sha256_hex(text.as_bytes())
}
/// `_revision`: `expectedRevision` must match the live board (`stale_board`).
pub fn check_revision(args: &Value, revision: &str) -> RResult<()> {
if args.get("expectedRevision").and_then(Value::as_str) != Some(revision) {
return Err(RoutingError::new("stale_board", "expectedRevision must match the current live board").with("currentRevision", json!(revision)).with("mutated", json!(false)));
}
Ok(())
}
/// `_path`: an absolute `.kicad_pcb` path, normalised for comparison (forward slashes,
/// no `\\?\` prefix, case folded, resolved through the filesystem when it exists).
pub fn normalize_board_path(value: Option<&Value>) -> RResult<String> {
let Some(s) = value.and_then(Value::as_str) else {
return Err(RoutingError::new("invalid_board_path", "filePath must be an absolute .kicad_pcb path"));
};
let looks_absolute = Path::new(s).is_absolute() || s.as_bytes().get(1) == Some(&b':') && s.as_bytes().get(2).map(|c| *c == b'/' || *c == b'\\').unwrap_or(false);
if !looks_absolute {
return Err(RoutingError::new("invalid_board_path", "filePath must be an absolute .kicad_pcb path"));
}
if !s.to_ascii_lowercase().ends_with(".kicad_pcb") {
return Err(RoutingError::new("invalid_board_path", "filePath must name a .kicad_pcb"));
}
Ok(normalize_existing(s))
}
fn normalize_existing(s: &str) -> String {
let resolved = std::fs::canonicalize(s).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| s.to_string());
let resolved = resolved.replace('\\', "/");
let resolved = resolved.strip_prefix("//?/").unwrap_or(&resolved).to_string();
resolved.to_lowercase()
}
/// The absolute paths of every board open in the PCB editor that answers on the IPC
/// socket, or Err when no editor answers (API off, schematic-only, KiCad not running).
/// Wiki #122: the window title shows only the file name, so two boards with the same
/// basename in different folders are told apart by asking KiCad, never by the title.
pub fn open_board_paths(timeout: std::time::Duration) -> Result<Vec<String>, String> {
let name = format!("adom-openpaths-{}", std::process::id());
let client = KiCadClientBlocking::builder().client_name(name).timeout(timeout).connect().map_err(|e| format!("{e:?}"))?;
let docs = client.get_open_documents(DocumentType::Pcb).map_err(|e| format!("{e:?}"))?;
Ok(docs.iter().map(document_path).collect())
}
/// True when two board paths name the same file (case, separators and symlinks folded).
pub fn same_board_path(a: &str, b: &str) -> bool {
normalize_existing(a) == normalize_existing(b)
}
/// The absolute path of an open document (`document_path` in the Python).
pub fn document_path(d: &DocumentSpecifier) -> String {
let filename = d.board_filename.clone().unwrap_or_default();
let p = Path::new(&filename);
let absolute = p.is_absolute() || filename.as_bytes().get(1) == Some(&b':');
if absolute {
filename
} else {
match &d.project.path {
Some(dir) => dir.join(&filename).to_string_lossy().to_string(),
None => filename,
}
}
}
/// `_report_limits`: KiCad 10.0.3 DRC_ENGINE caps 199 markers per type, 499 for
/// clearance and unconnected items. At the boundary completeness is unknown.
pub fn report_limits(violations: &[Value], unconnected_len: usize) -> Value {
let mut counts: Vec<(String, usize)> = Vec::new();
for v in violations {
let kind = match v.get("type") {
Some(Value::String(s)) => s.clone(),
Some(Value::Null) | None => "None".to_string(),
Some(other) => other.to_string(),
};
match counts.iter_mut().find(|(k, _)| *k == kind) {
Some(c) => c.1 += 1,
None => counts.push((kind, 1)),
}
}
let mut capped: Vec<String> = counts.into_iter().filter(|(k, n)| *n >= if k == "clearance" { 499 } else { 199 }).map(|(k, _)| k).collect();
capped.sort();
json!({
"unconnectedReportMayBeTruncated": unconnected_len >= 499,
"unconnectedCountIsLowerBound": unconnected_len >= 499,
"violationReportMayBeTruncated": !capped.is_empty(),
"possiblyTruncatedViolationTypes": capped,
})
}
fn fingerprint(v: &Value) -> String {
let mut positions: Vec<String> = v.get("items").and_then(Value::as_array).map(|a| a.iter().map(|i| i.get("pos").cloned().unwrap_or(Value::Null).to_string()).collect()).unwrap_or_default();
positions.sort();
json!([v.get("type"), v.get("severity"), v.get("description"), positions]).to_string()
}
/// The capped violation types in a report that hold errors (wiki #140). KiCad caps each type on
/// its own and grades every row of a type alike, so a warning type at the cap can hide neither
/// a new error of another type nor one of its own. The grade is read from the rows here, not
/// when the limits were computed, so a caller that re-grades rows (the silkscreen check grades
/// every finding an error) keeps those types. A report that says it is truncated without naming
/// the types counts as truncated.
pub fn capped_error_types(r: &Value) -> Vec<String> {
if !r.get("violationReportMayBeTruncated").and_then(Value::as_bool).unwrap_or(false) {
return Vec::new();
}
let Some(types) = r.get("possiblyTruncatedViolationTypes").and_then(Value::as_array) else {
return vec!["(unnamed)".into()];
};
let rows = r.get("violations").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]);
let mut out: Vec<String> = types
.iter()
.filter(|t| rows.iter().any(|v| v.get("type") == Some(*t) && v.get("severity").and_then(Value::as_str) == Some("error")))
.map(|t| t.as_str().map(str::to_string).unwrap_or_else(|| t.to_string()))
.collect();
out.sort();
out.dedup();
out
}
/// `_new_errors`: the error-severity violations in `after` that `before` does not
/// account for, one to one (an existing error never masks a second identical one).
/// Refuses to certify a delta when either list may be capped in a type that holds errors
/// (`drc_incomplete`); warning types at the cap are left to violationReportMayBeTruncated.
pub fn new_errors(before: &Value, after: &Value) -> RResult<Vec<Value>> {
let mut capped = capped_error_types(before);
capped.extend(capped_error_types(after));
capped.sort();
capped.dedup();
if !capped.is_empty() {
return Err(RoutingError::new("drc_incomplete", format!("DRC lists of error types may be capped ({}); cannot certify the error delta. No live copper was changed", capped.join(", ")))
.with("mutated", json!(false))
.with("cappedErrorTypes", json!(capped)));
}
let errors = |r: &Value| -> Vec<Value> { r.get("violations").and_then(Value::as_array).map(|a| a.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some("error")).cloned().collect()).unwrap_or_default() };
let mut existing: Vec<(String, usize)> = Vec::new();
for v in errors(before) {
let key = fingerprint(&v);
match existing.iter_mut().find(|(k, _)| *k == key) {
Some(c) => c.1 += 1,
None => existing.push((key, 1)),
}
}
let mut added = Vec::new();
for v in errors(after) {
let key = fingerprint(&v);
match existing.iter_mut().find(|(k, n)| *k == key && *n > 0) {
Some(c) => c.1 -= 1,
None => added.push(v),
}
}
Ok(added)
}
/// Copy a KiCad library table, replacing ${KIPRJMOD} with the original project folder so
/// the copy resolves the same libraries from wherever it is read.
pub fn copy_lib_table(src: &Path, dst: &Path, project_dir: &Path) -> std::io::Result<()> {
let text = std::fs::read_to_string(src)?;
let dir = project_dir.to_string_lossy().replace('\\', "/");
let dir = dir.trim_end_matches('/');
std::fs::write(dst, text.replace("${KIPRJMOD}", dir))
}
/// `_drc`: KiCad DRC on a disposable snapshot of the live board text, with the project's
/// own rules copied alongside and zones refilled so connectivity reflects the proposed
/// copper. The live document and the source file are never touched.
pub fn drc_snapshot(kicad_cli: Option<&Path>, source: &Path, text: &str) -> RResult<Value> {
let Some(exe) = kicad_cli.filter(|p| p.is_file()) else {
return Err(RoutingError::new("drc_unavailable", "kicad-cli is required for routing validation"));
};
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp = std::env::temp_dir().join(format!("adom-routing-drc-{}-{n}", std::process::id()));
let result = drc_in(exe, source, text, &tmp);
let _ = std::fs::remove_dir_all(&tmp);
result
}
fn drc_in(exe: &Path, source: &Path, text: &str, tmp: &Path) -> RResult<Value> {
std::fs::create_dir_all(tmp).map_err(|e| RoutingError::new("drc_failed", format!("cannot create snapshot dir: {e}")))?;
let name = source.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "board.kicad_pcb".into());
let candidate = tmp.join(&name);
std::fs::write(&candidate, text).map_err(|e| RoutingError::new("drc_failed", format!("cannot write snapshot: {e}")))?;
// Preserve the actual project's rules and exclusions; never save/revert the live
// document merely to get a headless DRC snapshot.
let mut copied: Vec<String> = Vec::new();
for ext in ["kicad_pro", "kicad_dru"] {
let sibling = source.with_extension(ext);
if sibling.exists() {
std::fs::copy(&sibling, candidate.with_extension(ext)).map_err(|e|RoutingError::new("drc_rules_copy_failed",format!("Cannot preserve project rules {}: {e}",sibling.display())).with("mutated",json!(false)))?;
copied.push(sibling.to_string_lossy().to_string());
}
}
// Wiki #124: the project's library tables travel with the snapshot too, with ${KIPRJMOD}
// pointed back at the real project folder, so project-local footprint libraries resolve
// and DRC does not report them missing.
if let Some(project_dir) = source.parent() {
for table in ["fp-lib-table", "sym-lib-table"] {
let src = project_dir.join(table);
if src.is_file() {
copy_lib_table(&src, &tmp.join(table), project_dir).map_err(|e| RoutingError::new("drc_rules_copy_failed", format!("Cannot preserve library table {}: {e}", src.display())).with("mutated", json!(false)))?;
copied.push(src.to_string_lossy().to_string());
}
}
}
let report = tmp.join("drc.json");
let report_s = report.to_string_lossy().to_string();
let candidate_s = candidate.to_string_lossy().to_string();
let r = cli::run(exe, &["pcb", "drc", "--format", "json", "--severity-all", "--all-track-errors", "--refill-zones", "--output", &report_s, &candidate_s], DRC_TIMEOUT);
if r.timed_out {
return Err(RoutingError::new("drc_timeout", "kicad-cli DRC on the snapshot timed out").with("cliElapsedMs", json!(r.elapsed_ms)));
}
let parsed = std::fs::read_to_string(&report).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok());
let Some(result) = parsed.filter(|_| r.exit_code == 0) else {
let msg = if !r.stderr.trim().is_empty() { r.stderr.trim().to_string() } else if !r.stdout.trim().is_empty() { r.stdout.trim().to_string() } else { "No DRC report".into() };
return Err(RoutingError::new("drc_failed", msg).with("cliExitCode", json!(r.exit_code)));
};
Ok(drc_result(&result, copied))
}
/// Shape the raw kicad-cli report the way `_drc` returns it, plus `summary`: the same
/// `kicad_run_drc` summary (which counts unconnected items among its errors).
pub fn drc_result(report: &Value, project_rules_copied: Vec<String>) -> Value {
let violations: Vec<Value> = report.get("violations").and_then(Value::as_array).cloned().unwrap_or_default();
let unconnected: Vec<Value> = report.get("unconnected_items").and_then(Value::as_array).cloned().unwrap_or_default();
let sev = |s: &str| violations.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some(s)).count();
let mut out = json!({
"errors": sev("error"),
"warnings": sev("warning"),
"unconnected": unconnected.len(),
"violations": violations,
"unconnectedItems": unconnected,
"clean": violations.is_empty() && unconnected.is_empty(),
"source": "live-editor-snapshot",
"projectRulesCopied": project_rules_copied,
"zonesRefilled": true,
"summary": cli::summarize_report(report),
});
if let (Some(o), Some(limits)) = (out.as_object_mut(), report_limits(&violations, unconnected.len()).as_object()) {
for (k, v) in limits {
o.insert(k.clone(), v.clone());
}
}
out
}
/// A KiCad commit session, abstracted so the transaction logic is testable without KiCad.
pub trait CommitTarget {
fn begin(&self) -> Result<String, IpcFailure>;
fn push(&self, id: &str, message: &str) -> Result<(), IpcFailure>;
fn drop_commit(&self, id: &str) -> Result<(), IpcFailure>;
}
/// `_transaction`: begin, run the operation, push. On any failure drop the commit and say
/// exactly what is known: `mutation_rolled_back` (nothing landed), or
/// `mutation_outcome_unknown` (`mutated: null`) when the push or the rollback itself
/// failed, with `stillRunning: true` when a deadline expired mid-commit.
pub fn transaction<T>(target: &dyn CommitTarget, message: &str, op: impl FnOnce() -> Result<T, IpcFailure>) -> RResult<T> {
let id = target.begin().map_err(|e| RoutingError::new("mutation_rolled_back", e.message).with("mutated", json!(false)))?;
let (failure, pushing) = match op() {
Ok(result) => match target.push(&id, message) {
Ok(()) => return Ok(result),
Err(e) => (e, true),
},
Err(e) => (e, false),
};
let base = |code: &str| {
let mut err = RoutingError::new(code, failure.message.clone());
if failure.timed_out {
err = err.with("ipcTimeout", json!(true));
}
err
};
match target.drop_commit(&id) {
Err(rollback) => Err(base("mutation_outcome_unknown").with("rollbackError", json!(rollback.message)).with("mutated", Value::Null).with("stillRunning", json!(failure.timed_out || rollback.timed_out))),
Ok(()) if pushing => Err(base("mutation_outcome_unknown").with("mutated", Value::Null).with("rollbackAttempted", json!(true)).with("stillRunning", json!(failure.timed_out))),
Ok(()) => Err(base("mutation_rolled_back").with("mutated", json!(false))),
}
}
// ---------------------------------------------------------------------------
// Connection
// ---------------------------------------------------------------------------
/// One connection to the PCB editor showing the requested board.
pub struct Session {
client: KiCadClientBlocking,
pub file_path: String,
pub version: VersionInfo,
pub socket: String,
pub timeout: Duration,
}
impl CommitTarget for Session {
fn begin(&self) -> Result<String, IpcFailure> {
Ok(self.client.begin_commit()?.id)
}
fn push(&self, id: &str, message: &str) -> Result<(), IpcFailure> {
Ok(self.client.end_commit(kicad_ipc_rs::CommitSession { id: id.to_string() }, kicad_ipc_rs::CommitAction::Commit, message)?)
}
fn drop_commit(&self, id: &str) -> Result<(), IpcFailure> {
Ok(self.client.end_commit(kicad_ipc_rs::CommitSession { id: id.to_string() }, kicad_ipc_rs::CommitAction::Drop, "")?)
}
}
fn timeout_of(args: &Value) -> Duration {
let ms = args.get("ipcTimeoutMs").and_then(Value::as_u64).unwrap_or(DEFAULT_TIMEOUT_MS).clamp(1000, 60_000);
Duration::from_millis(ms)
}
/// `_connect`: validate the arguments, connect with a deadline, probe responsiveness
/// (`GetVersion`), require KiCad 10.0.1+, and require that the endpoint exposes exactly
/// the requested board.
pub fn connect(ctx: &Ctx, args: &Value) -> RResult<Session> {
let wanted = normalize_board_path(args.get("filePath"))?;
let file_path = args.get("filePath").and_then(Value::as_str).unwrap_or_default().to_string();
let socket = match args.get("socketPath") {
None | Some(Value::Null) => None,
Some(Value::String(s)) if s.starts_with("ipc://") => Some(s.clone()),
Some(_) => return Err(RoutingError::new("invalid_socket", "socketPath must be a local ipc:// socket")),
};
let timeout = timeout_of(args);
let name = format!("adom-routing-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0));
let mut builder = KiCadClientBlocking::builder().client_name(name).timeout(timeout);
if let Some(s) = &socket {
builder = builder.socket_path(s.clone());
}
let client = match builder.connect() {
Ok(c) => c,
Err(e) => {
let mut err = map_err(e);
if err.code == "ipc_unavailable" {
if crate::kicad_common::api_server_enabled(ctx.config_dir.as_deref()) == Some(false) {
err.code = "api_server_off".into();
err.message = format!("{}. KiCad's IPC API server switch is off in kicad_common.json", err.message);
err = err.with("_hint", json!("Save work and kicad_close, then kicad_ipc_api {\"enable\":true} (backup kept), then relaunch KiCad and open the board in the PCB editor. A running KiCad rewrites the file on exit, so close it first."));
} else {
err = err.with("_hint", json!("Open the board in the PCB editor (KiCad 10.0.1+) with Preferences > Plugins > Enable IPC API server on. kicad_ipc_api {} reports the switch; the socket appears only while the PCB editor is running."));
}
}
return Err(err);
}
};
let socket_uri = client.socket_uri().to_string();
// Responsiveness probe: the first round trip with the deadline.
let version = client.get_version().map_err(map_err)?;
if (version.major, version.minor, version.patch) < (10, 0, 1) {
return Err(RoutingError::new("unsupported_kicad", "Live routing requires KiCad 10.0.1 or newer").with("kicadVersion", json!(version.full_version)));
}
let docs = client.get_open_documents(DocumentType::Pcb).map_err(map_err)?;
let open: Vec<String> = docs.iter().map(document_path).collect();
if docs.is_empty() {
return Err(RoutingError::new("no_board_open", "No board is open in the PCB editor").with("openBoards", json!([])).with("kicadVersion", json!(version.full_version)));
}
let matches = open.iter().filter(|p| normalize_existing(p) == wanted).count();
if matches != 1 {
return Err(RoutingError::new("board_mismatch", "This IPC endpoint does not expose exactly the requested board").with("openBoards", json!(open)).with("kicadVersion", json!(version.full_version)));
}
Ok(Session { client, file_path, version, socket: socket_uri, timeout })
}
impl Session {
/// `_snapshot`: the editor's own serialisation, its parse, and the revision.
pub fn snapshot(&self) -> RResult<(String, Board, String)> {
let text = self.client.get_board_as_string().map_err(map_err)?;
let board = pcb::parse_pcb_text(&text, &self.file_path).map_err(|e| RoutingError::new("board_unparsable", format!("could not parse the live board: {e}")))?;
let revision = revision_of(&text);
Ok((text, board, revision))
}
fn revision(&self) -> RResult<String> {
Ok(revision_of(&self.client.get_board_as_string().map_err(map_err)?))
}
fn items_of(&self, codes: &[PcbObjectTypeCode]) -> RResult<Vec<PcbItem>> {
self.client.get_items_by_type_codes(codes.iter().map(|c| c.code).collect()).map_err(map_err)
}
pub fn source_path(&self) -> PathBuf {
PathBuf::from(&self.file_path)
}
}
fn item_id(item: &PcbItem) -> Option<&str> {
match item {
PcbItem::Track(t) => t.id.as_deref(),
PcbItem::Arc(a) => a.id.as_deref(),
PcbItem::Via(v) => v.id.as_deref(),
PcbItem::Footprint(f) => f.id.as_deref(),
PcbItem::Pad(p) => p.id.as_deref(),
PcbItem::BoardGraphicShape(s) => s.id.as_deref(),
PcbItem::BoardText(t) => t.id.as_deref(),
PcbItem::BoardTextBox(t) => t.id.as_deref(),
PcbItem::Zone(z) => z.id.as_deref(),
PcbItem::Dimension(d) => d.id.as_deref(),
PcbItem::ReferenceImage(r) => r.id.as_deref(),
PcbItem::Barcode(b) => b.id.as_deref(),
PcbItem::Group(g) => g.id.as_deref(),
PcbItem::Field(_) | PcbItem::Unknown(_) => None,
}
}
fn item_locked(item: &PcbItem) -> bool {
match item {
PcbItem::Track(t) => t.locked.is_locked(),
PcbItem::Arc(a) => a.locked.is_locked(),
PcbItem::Via(v) => v.locked.is_locked(),
_ => false,
}
}
// ---------------------------------------------------------------------------
// Items
// ---------------------------------------------------------------------------
fn nm(mm: f64) -> i64 {
(mm * 1_000_000.0).round() as i64
}
fn vector(p: [f64; 2]) -> Vector2Nm {
Vector2Nm { x_nm: nm(p[0]), y_nm: nm(p[1]) }
}
/// `_items`: the plan as protobuf Track and Via items on the live net. Layer ids come
/// from the crate's canonical name table; via padstacks are what kipy builds (through,
/// normal padstack, one circular copper entry, F.Cu to B.Cu drill).
pub fn build_items(plan: &Plan, net: &BoardNet) -> RResult<Vec<EditablePcbItem>> {
let layer_id = |name: &str| BoardLayerInfo::id_from_name(name).ok_or_else(|| RoutingError::new("unknown_layer", format!("KiCad has no layer named {name}")));
let f_cu = layer_id("F.Cu")?;
let b_cu = layer_id("B.Cu")?;
let mut items = Vec::with_capacity(plan.segments.len() + plan.vias.len());
for seg in &plan.segments {
let mut track = TrackItem::from_proto(Default::default());
track.set_start_nm(vector(seg.start));
track.set_end_nm(vector(seg.end));
track.set_layer_id(layer_id(&seg.layer)?);
let p = track.proto_mut();
p.width = Some(Default::default());
if let Some(w) = p.width.as_mut() {
w.value_nm = nm(seg.width);
}
p.net = Some(Default::default());
if let Some(n) = p.net.as_mut() {
n.name = net.name.clone();
n.code = Some(Default::default());
if let Some(c) = n.code.as_mut() {
c.value = net.code;
}
}
items.push(EditablePcbItem::Track(track));
}
for v in &plan.vias {
let mut via = ViaItem::from_proto(Default::default());
via.set_position_nm(vector(v.at));
let p = via.proto_mut();
p.r#type = 1; // ViaType::VT_THROUGH
p.net = Some(Default::default());
if let Some(n) = p.net.as_mut() {
n.name = net.name.clone();
n.code = Some(Default::default());
if let Some(c) = n.code.as_mut() {
c.value = net.code;
}
}
p.pad_stack = Some(Default::default());
if let Some(ps) = p.pad_stack.as_mut() {
ps.r#type = 1; // PadStackType::PST_NORMAL
ps.copper_layers.push(Default::default());
if let Some(cl) = ps.copper_layers.last_mut() {
cl.layer = f_cu;
cl.shape = 1; // PadStackShape::PSS_CIRCLE
cl.size = Some(Default::default());
if let Some(s) = cl.size.as_mut() {
s.x_nm = nm(v.size);
s.y_nm = nm(v.size);
}
}
ps.drill = Some(Default::default());
if let Some(d) = ps.drill.as_mut() {
d.start_layer = f_cu;
d.end_layer = b_cu;
d.diameter = Some(Default::default());
if let Some(s) = d.diameter.as_mut() {
s.x_nm = nm(v.drill);
s.y_nm = nm(v.drill);
}
}
}
items.push(EditablePcbItem::Via(via));
}
Ok(items)
}
// ---------------------------------------------------------------------------
// The four operations
// ---------------------------------------------------------------------------
/// `kicad_select_net`: select every copper item of one net on the live board (tracks, arcs, vias,
/// zones and pads), so the editor shows the net's pours and traces together, highlighted, on
/// every layer; no cursor, no hotkey. Returns the ids selected per kind.
pub fn select_net(ctx: &Ctx, args: &Value) -> RResult<Value> {
let s = connect(ctx, args)?;
let net = args.get("net").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| RoutingError::new("missing_arg", "Pass net: the net name as KiCad writes it (e.g. \"+VBAT\", \"/DRV_SHA\")"))?;
let items = s.items_of(&[PcbObjectTypeCode::new_trace(), PcbObjectTypeCode::new_arc(), PcbObjectTypeCode::new_via(), PcbObjectTypeCode::new_pad()])?;
let mut ids: Vec<String> = Vec::new();
let mut kinds: std::collections::BTreeMap<&str, usize> = Default::default();
let mut nets_seen: std::collections::BTreeSet<String> = Default::default();
for it in &items {
let (kind, item_net, id): (&str, Option<String>, Option<String>) = match it {
PcbItem::Track(t) => ("tracks", t.net.as_ref().map(|n| n.name.clone()), t.id.clone()),
PcbItem::Arc(a) => ("tracks", a.net.as_ref().map(|n| n.name.clone()), a.id.clone()),
PcbItem::Via(v) => ("vias", v.net.as_ref().map(|n| n.name.clone()), v.id.clone()),
PcbItem::Pad(p) => ("pads", p.net.as_ref().map(|n| n.name.clone()), p.id.clone()),
_ => continue,
};
if let Some(n) = &item_net { if !n.is_empty() { nets_seen.insert(n.clone()); } }
if item_net.as_deref() == Some(net) {
if let Some(id) = id { ids.push(id); *kinds.entry(kind).or_insert(0) += 1; }
}
}
// zones: the model has no net field; the raw proto does
let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?;
for a in &raws {
if let Ok(z) = zones::decode_zone(&a.value) {
if !z.net.is_empty() { nets_seen.insert(z.net.clone()); }
if z.net == net && !z.rule_area { ids.push(z.id.clone()); *kinds.entry("zones").or_insert(0) += 1; }
}
}
if ids.is_empty() {
return Err(RoutingError::new("unknown_net", format!("No copper item on the live board carries net '{net}'")).with("netsOnBoard", json!(nets_seen.into_iter().take(200).collect::<Vec<_>>())));
}
if args.get("add").and_then(Value::as_bool) != Some(true) {
s.client.clear_selection().map_err(map_err)?;
}
let count = ids.len();
s.client.add_to_selection(ids.clone()).map_err(map_err)?;
Ok(json!({
"success": true, "mutated": false, "source": "live-editor", "net": net, "selected": count, "byKind": kinds, "itemIds": ids,
"_hint": "The net's pours, tracks, vias and pads are now the editor's selection (highlighted on every layer). Frame it with kicad_zoom_to {net} (KiCad's own Zoom to Selected Objects over the API, which works in the background); clear it with kicad_select_clear. Selection is not an edit: nothing to undo, the revision is unchanged.",
}))
}
/// `kicad_zoom_to` (the standard's zoom_to): frame the PCB editor's view on parts (`refs`) or a whole `net`
/// through KiCad's own action (`common.Control.zoomFitSelection`, run over the API, so it acts on a background
/// window, where a posted menu command is acknowledged but the view does not move: wiki #121); without a
/// target, the whole board (`common.Control.zoomFitScreen`). The selection it makes is cleared afterwards
/// unless `keepSelection`.
pub fn zoom_to(ctx: &Ctx, args: &Value) -> RResult<Value> {
let has = |k: &str| args.get(k).map(|v| !v.is_null()).unwrap_or(false);
if has("rect") {
return Err(RoutingError::new("not_supported", "KiCad's API frames the selection, not a rectangle: pass refs (the parts in it) or net"));
}
let mut picked = Value::Null;
let action = if has("net") {
picked = select_net(ctx, &json!({"filePath": args.get("filePath"), "socketPath": args.get("socketPath"), "net": args["net"]}))?;
"common.Control.zoomFitSelection"
} else if has("refs") || has("ref") {
let refs = args.get("refs").cloned().unwrap_or_else(|| json!([args["ref"]]));
picked = board_live::select_items(ctx, &json!({"filePath": args.get("filePath"), "socketPath": args.get("socketPath"), "refs": refs, "mode": "replace"}))?;
"common.Control.zoomFitSelection"
} else {
"common.Control.zoomFitScreen"
};
let s = connect(ctx, args)?;
let status = s.client.run_action(action).map_err(map_err)?;
let keep = args.get("keepSelection").and_then(Value::as_bool).unwrap_or(false);
if !keep && !picked.is_null() {
s.client.clear_selection().map_err(map_err)?;
}
let ok = format!("{status:?}").to_lowercase().contains("ok");
Ok(json!({
"success": ok, "mutated": false, "source": "live-editor", "action": action, "actionStatus": format!("{status:?}"),
"framed": if has("net") { json!({"net": args["net"], "items": picked["selected"]}) } else if picked.is_null() { json!("board") } else { json!({"refs": args.get("refs").or(args.get("ref"))}) },
"selectionKept": keep && !picked.is_null(),
"_hint": if ok { "The view is framed (KiCad's own Zoom to Selected Objects). kicad_screenshot or kicad_state shows it; nothing on the board changed." } else { "KiCad did not run the action: the PCB editor may be busy with an interactive tool (press Escape in it) or not the editor serving the API." },
}))
}
/// KiCad actions run on the live PCB editor over one API connection, each `count` times with `pause_ms` between
/// (camera steps for animate_view: run in the bridge, so the timing is tight enough to narrate over). Stops at the
/// first action KiCad does not run.
pub fn run_actions(ctx: &Ctx, args: &Value, steps: &[(String, u32)], pause_ms: u64) -> RResult<Value> {
let s = connect(ctx, args)?;
let mut ran = Vec::new();
for (action, count) in steps {
for _ in 0..(*count).max(1) {
let status = s.client.run_action(action).map_err(map_err)?;
let st = format!("{status:?}");
let ok = st.to_lowercase().contains("ok");
ran.push(json!({"action": action, "status": st}));
if !ok {
return Ok(json!({"success": false, "errorCode": "action_failed", "error": format!("KiCad did not run {action}: {st}"), "ran": ran,
"_hint": "The PCB editor may be busy with an interactive tool (press Escape in it), or not the editor serving the API."}));
}
std::thread::sleep(std::time::Duration::from_millis(pause_ms));
}
}
Ok(json!({"success": true, "mutated": false, "source": "live-editor", "ran": ran}))
}
/// `kicad_select_clear`: clear the live editor's selection.
pub fn select_clear(ctx: &Ctx, args: &Value) -> RResult<Value> {
let s = connect(ctx, args)?;
s.client.clear_selection().map_err(map_err)?;
Ok(json!({"success": true, "mutated": false, "source": "live-editor", "_hint": "Selection cleared."}))
}
/// `kicad_routing_state`: pads, copper, revision and KiCad-measured connectivity.
pub fn routing_state(ctx: &Ctx, args: &Value) -> RResult<Value> {
let s = connect(ctx, args)?;
let (_text, data, revision) = s.snapshot()?;
let pads = pcb::pads(&data);
let front_pads = pcb::back_footprints_with_front_pads(&data);
let live_pads: Vec<(String, String)> = s
.items_of(&[PcbObjectTypeCode::new_pad()])?
.into_iter()
.filter_map(|i| match i {
PcbItem::Pad(p) => Some((p.id?, p.net.map(|n| n.name).unwrap_or_default())),
_ => None,
})
.collect();
let mut names: Vec<String> = data.nets.iter().map(|n| n.name.clone()).filter(|n| !n.is_empty()).collect();
names.sort();
names.dedup();
let mut nets = Vec::new();
for name in names {
let net_pads: Vec<&str> = live_pads.iter().filter(|(_, n)| *n == name).map(|(id, _)| id.as_str()).collect();
let mut remaining: Vec<&str> = net_pads.clone();
let mut groups: Vec<Vec<&str>> = Vec::new();
while let Some(seed) = remaining.first().copied() {
let mut seen: HashSet<String> = HashSet::new();
seen.insert(seed.to_string());
let mut frontier: Vec<String> = vec![seed.to_string()];
while !frontier.is_empty() {
let neighbors = s.client.get_connected_items(frontier.clone(), Vec::new()).map_err(map_err)?;
frontier = neighbors.iter().filter_map(item_id).filter(|id| !seen.contains(*id)).map(str::to_string).collect();
seen.extend(frontier.iter().cloned());
if seen.len() > MAX_WALK {
return Err(RoutingError::new("board_too_large", format!("Connectivity walk exceeds {MAX_WALK} items")));
}
}
let group: Vec<&str> = remaining.iter().copied().filter(|id| seen.contains(*id)).collect();
remaining.retain(|id| !seen.contains(*id));
groups.push(group);
}
nets.push(json!({
"name": name, "padCount": net_pads.len(), "padGroups": groups,
"connected": groups.len() <= 1, "missingConnections": groups.len().saturating_sub(1),
}));
}
let current = s.revision()?;
if current != revision {
return Err(RoutingError::new("stale_board", "Board changed during connectivity inspection; read again").with("currentRevision", json!(current)));
}
let remaining: Vec<Value> = nets.iter().filter(|n| n["connected"] == json!(false)).map(|n| n["name"].clone()).collect();
let mut out = json!({
"success": true, "boardPath": s.file_path, "revision": revision, "source": "live-editor",
"kicadVersion": s.version.full_version, "socket": s.socket,
"pads": pads, "nets": nets, "netsRemaining": remaining,
"segments": data.segments, "vias": data.vias, "copperLayers": pcb::copper_layers(&data),
"_hint": "Use this revision as expectedRevision in kicad_route_net. Coordinates are mm, +y down. Connectivity is measured by KiCad; kicad_routing_validate checks clearance and unconnected items.",
});
if !front_pads.is_empty() {
out["backFootprintsWithFrontPads"] = json!(front_pads);
out["_frontPadsHint"] = json!(pcb::FRONT_PADS_HINT);
}
Ok(out)
}
/// The one board open in the PCB editor, when the caller named none (the standard's routing_state
/// reads the current board, as altium_routing_state does).
fn with_open_board(args: &Value) -> RResult<Value> {
if args.get("filePath").and_then(Value::as_str).is_some() {
return Ok(args.clone());
}
let mut builder = KiCadClientBlocking::builder().client_name(format!("adom-model-{}", std::process::id())).timeout(timeout_of(args));
if let Some(sock) = args.get("socketPath").and_then(Value::as_str) {
builder = builder.socket_path(sock.to_string());
}
let client = builder.connect().map_err(map_err)?;
let docs = client.get_open_documents(DocumentType::Pcb).map_err(map_err)?;
let open: Vec<String> = docs.iter().map(document_path).collect();
match open.as_slice() {
[one] => {
let mut a = args.clone();
a["filePath"] = json!(one);
Ok(a)
}
[] => Err(RoutingError::new("no_board_open", "No board is open in the PCB editor").with("openBoards", json!([]))),
_ => Err(RoutingError::new("board_mismatch", "More than one board is open: name one with filePath").with("openBoards", json!(open))),
}
}
/// The live board's text as the editor serialises it, with its path and KiCad's version: what the
/// standard verbs (model, stack-up, pours) build from. `filePath` may be left out when one board is open.
pub fn board_text(_ctx: &Ctx, args: &Value) -> RResult<(String, String, String)> {
let args = with_open_board(args)?;
let s = connect(_ctx, &args)?;
let text = s.client.get_board_as_string().map_err(map_err)?;
Ok((text, s.file_path.clone(), s.version.full_version.clone()))
}
/// A standard revision (16 hex digits, `board_model::revision_of_text`) as opposed to the editor's
/// text hash (64) the edit verbs compare.
pub fn is_standard_revision(r: &str) -> bool {
r.len() == 16 && r.bytes().all(|b| b.is_ascii_hexdigit())
}
/// Let an edit verb take the standard revision: when `expectedRevision` is one, read the live board;
/// if its copper, parts and outline are what the caller read, hand the verb the editor's current text
/// hash (its own guard then runs unchanged), else refuse with `stale_board`. Returns the arguments to
/// use and whether they were translated.
pub fn accept_standard_revision(ctx: &Ctx, args: &Value) -> RResult<(Value, bool)> {
let Some(want) = args.get("expectedRevision").and_then(Value::as_str).filter(|r| is_standard_revision(r)) else {
return Ok((args.clone(), false));
};
let s = connect(ctx, args)?;
let text = s.client.get_board_as_string().map_err(map_err)?;
let now = crate::board_model::revision_of_text(&text).map_err(|e| RoutingError::new("board_unparsable", e))?;
if now != want {
return Err(RoutingError::new("stale_board", "expectedRevision must match the current live board: its copper, parts or outline changed since it was read")
.with("currentRevision", json!(now)).with("mutated", json!(false)));
}
let mut a = args.clone();
a["expectedRevision"] = json!(revision_of(&text));
Ok((a, true))
}
/// Run an edit verb so it accepts either revision; after a translated call the reply's `revision` is
/// the standard one (the editor's text hash stays as `kicadRevision`).
pub fn with_standard_revision(ctx: &Ctx, args: &Value, run: impl FnOnce(&Value) -> RResult<Value>) -> RResult<Value> {
let (args, translated) = accept_standard_revision(ctx, args)?;
let mut v = run(&args)?;
if translated && v["success"] == json!(true) {
if let Ok(s) = connect(ctx, &args) {
if let Ok(text) = s.client.get_board_as_string() {
if let Ok(r) = crate::board_model::revision_of_text(&text) {
v["kicadRevision"] = json!(revision_of(&text));
v["revision"] = json!(r);
}
}
}
}
Ok(v)
}
/// `kicad_route_net`: DRC preflight on a snapshot, then one commit of the planned copper.
pub fn route_net(ctx: &Ctx, args: &Value) -> RResult<Value> {
let s = connect(ctx, args)?;
let (text, data, revision) = s.snapshot()?;
check_revision(args, &revision)?;
let plan = pcb::plan(&data, args)?;
commit_checked(ctx, &s, args, &text, &revision, &plan, &format!("Adom route {}", plan.net_name))
}
/// `kicad_thermal_vias` and the other via planners: vias of one net at given KiCad-frame points,
/// with route_net's DRC preflight, dryRun and one native Undo step. `vias: [{at: [x, y], size,
/// drill}]`, `net`.
pub fn place_vias(ctx: &Ctx, args: &Value, message: &str) -> RResult<Value> {
let s = connect(ctx, args)?;
let (text, data, revision) = s.snapshot()?;
check_revision(args, &revision)?;
let net = pcb::resolve_net(&data, args.get("net")).map_err(|e| RoutingError::new("unknown_net", e["error"].as_str().unwrap_or("no such net").to_string()))?;
let copper = pcb::copper_layers(&data);
let (top, bottom) = (copper.first().cloned().unwrap_or_else(|| "F.Cu".into()), copper.last().cloned().unwrap_or_else(|| "B.Cu".into()));
let mut plan = pcb::Plan { net_name: net.name.clone(), segments: Vec::new(), vias: Vec::new(), blocks: Vec::new() };
for v in args["vias"].as_array().into_iter().flatten() {
let at = (v["at"][0].as_f64().unwrap_or(f64::NAN), v["at"][1].as_f64().unwrap_or(f64::NAN));
let (size, drill) = (v["size"].as_f64().unwrap_or(pcb::DEFAULT_VIA_SIZE), v["drill"].as_f64().unwrap_or(pcb::DEFAULT_VIA_DRILL));
if !(at.0.is_finite() && at.1.is_finite()) || drill <= 0.0 || drill >= size {
return Err(RoutingError::new("bad_via", format!("a via needs a finite at and 0 < drill < size: {v}")).with("mutated", json!(false)));
}
plan.blocks.push(pcb::via_sexpr(at, size, drill, (&top, &bottom), &net));
plan.vias.push(pcb::PlanVia { at: [at.0, at.1], layers: [top.clone(), bottom.clone()], size, drill });
}
if plan.vias.is_empty() {
return Err(RoutingError::new("nothing_to_place", "vias is empty").with("mutated", json!(false)));
}
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> {
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(text, &plan.blocks))?;
let added = new_errors(&before, &after)?;
if !added.is_empty() {
return Err(RoutingError::new("drc_rejected", "Candidate route adds DRC errors; no live copper was changed").with("violations", json!(added)).with("mutated", json!(false)));
}
check = after;
}
let public = plan.public();
if args.get("dryRun") == Some(&json!(true)) {
let mut out = json!({"success": true, "dryRun": true, "revision": revision, "drc": check, "_hint": "Plan validated without editing. Submit with dryRun:false and the same revision."});
merge(&mut out, &public);
return Ok(out);
}
// DRC runs outside KiCad. Re-read immediately before begin_commit so another
// caller's or the user's edits during validation invalidate this plan.
check_revision(args, &s.revision()?)?;
let nets = s.client.get_nets().map_err(map_err)?;
let Some(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, &net)?;
let count = items.len();
let created = transaction(s, message, || {
let created = s.client.create_editable_items(items, None)?;
if created.len() != count || created.iter().any(|i| i.id().map(str::is_empty).unwrap_or(true)) {
return Err(IpcFailure::from("KiCad did not create every requested copper item".to_string()));
}
Ok(created)
})?;
let ids: Vec<String> = created.iter().filter_map(|i| i.id().map(str::to_string)).collect();
let mut result = json!({
"success": true, "mutated": true, "source": "live-editor",
"itemIds": ids, "undoSteps": 1, "drc": check, "saved": false,
"_hint": "Copper committed live as one KiCad Undo step. Inspect routing_state before the next route. The file is saved only with save:true; routing_validate checks the unsaved live board.",
});
merge(&mut result, &public);
// Commit succeeded. A subsequent read/save failure must NEVER be reported as a failed
// route inviting the caller to add it twice.
match s.revision() {
Ok(r) => {
result["revision"] = json!(r);
if args.get("save") == Some(&json!(true)) {
match s.client.save_document() {
Ok(()) => result["saved"] = json!(true),
Err(e) => result["postCommitError"] = json!(e.to_string()),
}
}
}
Err(e) => result["postCommitError"] = json!(e.message),
}
Ok(result)
}
/// Commit copper the caller already planned (a Freerouting session's wires and vias for
/// one net) as ONE native Undo step named `message`, with the same revision guard as
/// `route_net` (`expectedRevision` must match the live board). No DRC preflight: the
/// caller checks the whole result afterwards with `routing_validate`. Used by
/// `kicad_autoroute {engine:"freerouting", apply:"ipc"}`; `route_net` is untouched.
pub fn commit_copper(ctx: &Ctx, args: &Value, plan: &Plan, message: &str) -> RResult<Value> {
let s = connect(ctx, args)?;
check_revision(args, &s.revision()?)?;
let nets = s.client.get_nets().map_err(map_err)?;
let Some(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, &net)?;
let count = items.len();
let created = transaction(&s, message, || {
let created = s.client.create_editable_items(items, None)?;
if created.len() != count || created.iter().any(|i| i.id().map(str::is_empty).unwrap_or(true)) {
return Err(IpcFailure::from("KiCad did not create every requested copper item".to_string()));
}
Ok(created)
})?;
let ids: Vec<String> = created.iter().filter_map(|i| i.id().map(str::to_string)).collect();
let mut result = json!({
"success": true, "mutated": true, "source": "live-editor", "netName": plan.net_name,
"itemIds": ids, "undoSteps": 1, "undoLabel": message,
"segmentCount": plan.segments.len(), "viaCount": plan.vias.len(), "saved": false,
});
match s.revision() {
Ok(r) => result["revision"] = json!(r),
Err(e) => result["postCommitError"] = json!(e.message),
}
Ok(result)
}
/// `kicad_remove_route`: delete specific unlocked copper items as one Undo step.
pub fn remove_route(ctx: &Ctx, args: &Value) -> RResult<Value> {
let s = connect(ctx, args)?;
let (text, data, revision) = s.snapshot()?;
check_revision(args, &revision)?;
let ids: Vec<String> = match args.get("itemIds").and_then(Value::as_array) {
Some(a) if !a.is_empty() && a.len() <= 1024 && a.iter().all(Value::is_string) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
_ => return Err(RoutingError::new("invalid_item_ids", "Supply 1..1024 copper itemIds returned by route_net")),
};
let wanted: HashSet<String> = ids.iter().cloned().collect();
let copper = s.items_of(&[PcbObjectTypeCode::new_trace(), PcbObjectTypeCode::new_arc(), PcbObjectTypeCode::new_via()])?;
let items: Vec<&PcbItem> = copper.iter().filter(|i| item_id(i).map(|id| wanted.contains(id)).unwrap_or(false)).collect();
if items.len() != wanted.len() || items.iter().any(|i| item_locked(i)) {
return Err(RoutingError::new("invalid_item_ids", "Some IDs are missing, locked, or are not copper; nothing removed"));
}
if args.get("dryRun") == Some(&json!(true)) {
return Ok(remove_preview(ctx, &s, &text, &data, &revision, &ids, args));
}
check_revision(args, &s.revision()?)?;
let to_delete: Vec<String> = wanted.iter().cloned().collect();
transaction(&s, "Adom remove route", || {
let deleted: HashSet<String> = s.client.delete_items(to_delete.clone())?.into_iter().collect();
if deleted != wanted {
return Err(IpcFailure::from("KiCad did not accept every requested copper deletion".to_string()));
}
Ok(())
})?;
let mut result = json!({
"success": true, "mutated": true, "removedIds": ids, "undoSteps": 1,
"_hint": "Copper removed live. Use KiCad Undo to restore it, or inspect routing_state and route again.",
});
match s.snapshot() {
Ok((_, after, rev)) => {
result["revision"] = json!(rev);
let present: HashSet<String> = after.segments.iter().filter_map(|x| x.uuid.clone()).chain(after.vias.iter().filter_map(|x| x.uuid.clone())).collect();
let remaining: Vec<String> = wanted.iter().filter(|id| present.contains(*id)).cloned().collect();
if !remaining.is_empty() {
let mut removed: Vec<String> = wanted.iter().filter(|id| !present.contains(*id)).cloned().collect();
removed.sort();
result["removedIds"] = json!(removed);
result["postCommitError"] = json!("Some requested copper remains after commit; inspect the live board");
}
result["verified"] = json!(remaining.is_empty());
}
Err(e) => result["postCommitError"] = json!(e.message),
}
Ok(result)
}
/// The layers the live PCB editor shows, by name (`kicad_layers`' displayed flags).
pub fn visible_layer_names(ctx: &Ctx, args: &Value) -> RResult<Vec<String>> {
let s = connect(ctx, args)?;
Ok(s.client.get_visible_layers().map_err(map_err)?.into_iter().map(|l| l.name).collect())
}
/// `kicad_set_layer_visibility`: show or hide one layer in the open PCB editor (KiCad's own visible
/// set, through the API), read back.
pub fn set_layer_visibility(ctx: &Ctx, args: &Value) -> RResult<Value> {
let name = args["layer"].as_str().filter(|n| !n.is_empty()).ok_or_else(|| RoutingError::new("missing_arg", "layer names the layer (F.SilkS, In1.Cu ...)"))?;
let shown = args.get("displayed").and_then(Value::as_bool).unwrap_or(true);
let id = BoardLayerInfo::id_from_name(name).ok_or_else(|| RoutingError::new("unknown_layer", format!("KiCad has no layer named {name}")))?;
let s = connect(ctx, args)?;
let mut ids: Vec<i32> = s.client.get_visible_layers().map_err(map_err)?.into_iter().map(|l| l.id).collect();
let before = ids.contains(&id);
ids.retain(|i| *i != id);
if shown {
ids.push(id);
}
s.client.set_visible_layers(ids).map_err(map_err)?;
let now = s.client.get_visible_layers().map_err(map_err)?.iter().any(|l| l.id == id);
if now != shown {
return Err(RoutingError::new("not_applied", format!("KiCad still {} {name}", if now { "shows" } else { "hides" })).with("displayed", json!(now)));
}
Ok(json!({"success": true, "data": {"name": name, "displayed": now, "was": before}, "changed": before != now}))
}
/// `kicad_ripup_nets`: every unlocked track, arc and via of the named nets, removed live as one
/// Undo step through remove_route (its dry run, DRC preview and read-back included).
pub fn ripup_nets(ctx: &Ctx, args: &Value) -> RResult<Value> {
let nets: Vec<String> = match args.get("nets") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
Some(Value::String(s)) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),
_ => Vec::new(),
};
if nets.is_empty() {
return Err(RoutingError::new("missing_arg", "nets is required: [\"GND\", \"/SDA\"]").with("mutated", json!(false)));
}
let s = connect(ctx, args)?;
let (_text, _data, revision) = s.snapshot()?;
check_revision(args, &revision)?;
let copper = s.items_of(&[PcbObjectTypeCode::new_trace(), PcbObjectTypeCode::new_arc(), PcbObjectTypeCode::new_via()])?;
let net_of = |i: &PcbItem| -> Option<String> {
match i {
PcbItem::Track(t) => t.net.as_ref().map(|n| n.name.clone()),
PcbItem::Arc(a) => a.net.as_ref().map(|n| n.name.clone()),
PcbItem::Via(v) => v.net.as_ref().map(|n| n.name.clone()),
_ => None,
}
};
let mut per_net: std::collections::BTreeMap<String, usize> = nets.iter().map(|n| (n.clone(), 0)).collect();
let mut locked = 0usize;
let mut ids = Vec::new();
for i in &copper {
let Some(n) = net_of(i) else { continue };
let Some(key) = nets.iter().find(|w| w.eq_ignore_ascii_case(&n)) else { continue };
if item_locked(i) {
locked += 1;
continue;
}
if let Some(id) = item_id(i) {
ids.push(id.to_string());
*per_net.entry(key.clone()).or_default() += 1;
}
}
if ids.is_empty() {
return Ok(json!({"success": true, "mutated": false, "undoSteps": 0, "revision": revision, "perNet": per_net, "lockedKept": locked,
"_hint": "No unlocked copper on those nets: nothing to rip up. Net names match case-insensitively, as kicad_list_nets spells them."}));
}
let mut a = args.clone();
a["itemIds"] = json!(ids);
let mut out = remove_route(ctx, &a)?;
out["nets"] = json!(nets);
out["perNet"] = json!(per_net);
out["lockedKept"] = json!(locked);
Ok(out)
}
/// `kicad_remove_route {dryRun:true}` (wiki #134): what would go and KiCad's DRC of the snapshot
/// without it. Nothing is written.
fn remove_preview(ctx: &Ctx, s: &Session, text: &str, data: &Board, revision: &str, ids: &[String], args: &Value) -> Value {
let items: Vec<Value> = ids.iter().map(|id| {
if let Some(t) = data.segments.iter().find(|x| x.uuid.as_deref() == Some(id.as_str())) {
json!({"id": id, "kind": "track", "net": t.net_name, "layer": t.layer, "start": t.start, "end": t.end, "width": t.width})
} else if let Some(v) = data.vias.iter().find(|x| x.uuid.as_deref() == Some(id.as_str())) {
json!({"id": id, "kind": "via", "net": v.net_name, "position": v.position, "size": v.size, "drill": v.drill})
} else {
json!({"id": id, "kind": "arc"})
}
}).collect();
let (without, cut) = strip_items(text, ids);
let mut out = json!({"success": true, "dryRun": true, "mutated": false, "revision": revision, "wouldRemove": items,
"_hint": "Nothing was removed. drc is KiCad's DRC of the board without these items: unconnectedAfter is what removing them leaves to route. Submit with dryRun:false and the same revision to remove them as one Undo step."});
if args.get("validate") == Some(&json!(false)) {
return out;
}
if cut != ids.len() {
out["drc"] = json!({"available": false, "why": format!("{} of {} items were found in the board text", cut, ids.len())});
return out;
}
let source = s.source_path();
match (drc_snapshot(ctx.kicad_cli.as_deref(), &source, text), drc_snapshot(ctx.kicad_cli.as_deref(), &source, &without)) {
(Ok(before), Ok(after)) => {
out["unconnectedBefore"] = before["unconnected"].clone();
out["unconnectedAfter"] = after["unconnected"].clone();
out["newErrors"] = json!(new_errors(&before, &after).unwrap_or_default());
out["drc"] = after;
}
(Err(e), _) | (_, Err(e)) => out["drc"] = json!({"available": false, "errorCode": e.code, "why": e.message}),
}
out
}
/// The board text with the top-level items carrying these UUIDs cut out, and how many were cut.
pub fn strip_items(text: &str, ids: &[String]) -> (String, usize) {
// the spans of the root list's direct children, quotes respected
let mut spans: Vec<(usize, usize)> = Vec::new();
let (mut depth, mut quoted, mut escaped, mut start) = (0i32, false, false, 0usize);
for (i, c) in text.char_indices() {
if quoted {
if escaped { escaped = false } else if c == '\\' { escaped = true } else if c == '"' { quoted = false }
continue;
}
match c {
'"' => quoted = true,
'(' => {
depth += 1;
if depth == 2 { start = i; }
}
')' => {
if depth == 2 { spans.push((start, i + 1)); }
depth -= 1;
}
_ => {}
}
}
let mut out = String::with_capacity(text.len());
let (mut last, mut cut) = (0, 0);
for (a, b) in spans {
let item = &text[a..b];
let kind_ok = item.starts_with("(segment") || item.starts_with("(arc") || item.starts_with("(via");
if kind_ok && ids.iter().any(|id| item.contains(&format!("(uuid \"{id}\")")) || item.contains(&format!("(tstamp \"{id}\")"))) {
out.push_str(&text[last..a]);
last = b;
cut += 1;
}
}
out.push_str(&text[last..]);
(out, cut)
}
/// `kicad_routing_validate`: KiCad DRC on the live, unsaved board snapshot.
pub fn routing_validate(ctx: &Ctx, args: &Value) -> RResult<Value> {
let s = connect(ctx, args)?;
let (text, _, revision) = s.snapshot()?;
let drc = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &text)?;
let current = s.revision()?;
let mut out = json!({
"success": true, "revision": revision, "currentRevision": current, "stale": current != revision,
"_hint": "This is KiCad DRC on the live editor snapshot with sibling project rules. A stale result is superseded by newer edits. clean requires no violations or unconnected items.",
});
merge(&mut out, &drc);
Ok(out)
}
// ---------------------------------------------------------------------------
// Zones: kicad_add_zone, kicad_zone_state
// ---------------------------------------------------------------------------
/// `kicad_add_zone`: one or more copper zones (pours) as ONE native Undo step, with the
/// same revision guard and DRC preflight as `route_net`: the zones' s-expressions are
/// spliced into the editor's snapshot, KiCad's DRC (zones refilled) must add no error, or
/// nothing lands. After the commit the new zones are filled (`refill:false` skips that).
/// `kicad_edit_pours` on the board open in the PCB editor: each zone KiCad holds is sent back through `UpdateItems`
/// with only the requested settings changed (its id, outline and everything else kept), as ONE native Undo step,
/// then refilled and read back. KiCad 10 creates no item from text, so the file path's delete-and-create cannot
/// serve a live board; this does.
pub fn edit_pours(ctx: &Ctx, args: &Value) -> RResult<Value> {
let s = connect(ctx, args)?;
let (text, _board, revision) = s.snapshot()?;
// expectedRevision is optional here (the standard's edit_pours), and pour_state's standard revision is accepted
// beside KiCad's own
let expected = args.get("expectedRevision").and_then(Value::as_str);
let std_rev = crate::board_model::revision_of_text(&text).ok();
if let Some(e) = expected {
if e != revision && Some(e) != std_rev.as_deref() {
return Err(RoutingError::new("stale_board", "expectedRevision must match the current live board (KiCad's revision or pour_state's)").with("currentRevision", json!(revision)).with("standardRevision", json!(std_rev)).with("mutated", json!(false)));
}
}
// the same validation, selection and before/after as the file edit
let change = crate::board_edit::edit_pours(&text, args)?;
let rows = crate::board_edit::pour_rows(args);
let edited = change.report["edited"].as_array().cloned().unwrap_or_default();
if args.get("dryRun") == Some(&json!(true)) {
return Ok(json!({"success": true, "source": "live-editor", "dryRun": true, "mutated": false, "undoSteps": 0, "revision": revision, "edited": edited,
"_hint": "Checked without editing. Call again without dryRun (and the same expectedRevision) to apply it as one Undo step."}));
}
let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?;
let nets = s.client.get_nets().map_err(map_err)?;
let mut items = Vec::new();
for e in &edited {
if e["before"] == e["after"] {
continue;
}
let id = e["id"].as_str().unwrap_or("");
let p = &rows[e["entry"].as_u64().unwrap_or(0) as usize];
let Some(raw) = raws.iter().find(|a| zones::zone_id(&a.value).as_deref() == Some(id)) else {
return Err(RoutingError::new("pour_not_found", format!("the PCB editor holds no zone {id}")).with("mutated", json!(false)));
};
let code = match p.get("net").and_then(Value::as_str) {
Some(n) if !n.is_empty() => Some(nets.iter().find(|x| x.name == n).map(|x| x.code).ok_or_else(|| RoutingError::new("unknown_net", format!("The live board has no net named '{n}'")).with("mutated", json!(false)))?),
_ => None,
};
let value = zones::rewrite_zone(&raw.value, p, code).map_err(|e| RoutingError::new("zone_undecodable", e).with("mutated", json!(false)))?;
items.push(prost_types::Any { type_url: raw.type_url.clone(), value });
}
if items.is_empty() {
return Ok(json!({"success": true, "source": "live-editor", "mutated": false, "undoSteps": 0, "revision": revision, "edited": edited, "_hint": "Nothing to change: the pours already have these settings."}));
}
if s.revision()? != revision {
return Err(RoutingError::new("stale_board", "the board changed while the edit was prepared; nothing was changed").with("mutated", json!(false)));
}
let count = items.len();
transaction(&s, &change.label, || {
let updated = s.client.update_items(items)?;
if updated.len() != count {
return Err(IpcFailure::from(format!("KiCad updated {} of {count} zones; rollback requested", updated.len())));
}
Ok(())
})?;
let mut out = json!({"success": true, "source": "live-editor", "boardPath": s.file_path, "mutated": true, "undoSteps": 1, "edited": edited, "refilled": false});
if args.get("refill") != Some(&json!(false)) {
let before = s.revision().ok();
match s.client.refill_zones(Vec::new()) {
Ok(()) => out["refilled"] = json!(settle_revision(before.as_deref(), || s.revision().map_err(|e| e.message)).1),
Err(e) => out["refillError"] = json!(format!("edited but not refilled: {e}")),
}
}
// read back what KiCad now holds: the settings and the copper
let (after_text, _, after_rev) = s.snapshot()?;
out["revision"] = json!(after_rev);
let now = crate::board_edit::all_pour_settings(&after_text).unwrap_or_default();
let verified = edited.iter().all(|e| now.iter().any(|z| z["id"] == e["id"] && z == &e["after"]));
out["verified"] = json!(verified);
if !verified {
out["readback"] = json!(now);
}
if let Ok(p) = crate::board_model::pours(&after_text, &crate::board_model::Opts { y_up: true }, false) {
out["pourState"] = p;
}
out["_hint"] = json!(if verified { "One Undo step in the PCB editor, refilled; pourState is the copper now and KiCad's board shows the new settings. The file is saved only by kicad_save_board. Run kicad_run_drc after a net change." } else { "Applied, but the read-back differs from the request: readback lists what KiCad holds now." });
Ok(out)
}
pub fn add_zone(ctx: &Ctx, args: &Value) -> RResult<Value> {
let s = connect(ctx, args)?;
let (text, data, revision) = s.snapshot()?;
check_revision(args, &revision)?;
let specs = zones::parse_specs(&data, args)?;
let blocks: Vec<String> = specs.iter().map(zones::zone_sexpr).collect();
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(&text, &blocks))?;
let added = new_errors(&before, &after)?;
if !added.is_empty() {
return Err(RoutingError::new("drc_rejected", "The zone adds DRC errors once filled; no live copper was changed").with("violations", json!(added)).with("mutated", json!(false)));
}
check = after;
}
let public: Vec<Value> = specs.iter().map(zones::ZoneSpec::public).collect();
if args.get("dryRun") == Some(&json!(true)) {
return Ok(json!({"success": true, "dryRun": true, "revision": revision, "drc": check, "zones": public,
"_hint": "Zones validated (filled and DRC-checked on a snapshot) without editing. Submit with dryRun:false and the same revision."}));
}
check_revision(args, &s.revision()?)?;
let nets = s.client.get_nets().map_err(map_err)?;
let mut items = Vec::with_capacity(specs.len());
for z in &specs {
let Some(net) = nets.iter().find(|n| n.name == z.net.name) else {
return Err(RoutingError::new("unknown_net", format!("The live board has no net named '{}'", z.net.name)).with("mutated", json!(false)));
};
let mut ids = Vec::with_capacity(z.layers.len());
for l in &z.layers {
ids.push(BoardLayerInfo::id_from_name(l).ok_or_else(|| RoutingError::new("unknown_layer", format!("KiCad has no layer named {l}")))?);
}
items.push(prost_types::Any { type_url: zones::ZONE_TYPE_URL.to_string(), value: zones::zone_bytes(z, &ids, net.code) });
}
let count = items.len();
let label = format!("Adom zone {}", specs.iter().map(|z| z.net.name.clone()).collect::<Vec<_>>().join(", "));
let created = transaction(&s, &label, || {
let created = s.client.create_items(items, None)?;
if created.len() != count {
return Err(IpcFailure::from("KiCad did not create every requested zone".to_string()));
}
Ok(created)
})?;
let ids: Vec<String> = created.iter().filter_map(|a| zones::zone_id(&a.value)).collect();
let mut result = json!({
"success": true, "mutated": true, "source": "live-editor",
"itemIds": ids, "undoSteps": 1, "zones": public, "drc": check, "saved": false, "refilled": false,
"_hint": "Zones committed live as one KiCad Undo step and filled. kicad_zone_state reads the filled copper per layer; kicad_routing_validate is the DRC on the live board. The file is saved only with save:true.",
});
if ids.len() != count {
result["postCommitError"] = json!(format!("KiCad answered with {} zone ids for {} zones", ids.len(), count));
}
// KiCad 10.0.x answers AS_UNIMPLEMENTED to RefillZones with a list of zone ids; an empty
// list means "every zone" and works, so that is what the bridge sends.
let mut settled = true;
if args.get("refill") != Some(&json!(false)) && !ids.is_empty() {
let before = s.revision().ok();
match s.client.refill_zones(Vec::new()) {
Ok(()) => {
// Wiki #115: KiCad fills ASYNCHRONOUSLY. Reading the revision straight after the
// request returned the pre-fill board, the fill then changed it, and the caller's
// next edit was refused as stale_board. Wait for the fill to land and the revision
// to hold still, bounded; say so honestly if it never does.
let (_, ok) = settle_revision(before.as_deref(), || s.revision().map_err(|e| e.message));
settled = ok;
result["refilled"] = json!(ok);
}
Err(e) => result["postCommitError"] = json!(format!("zones created but not filled: {e}")),
}
}
result["revisionSettled"] = json!(settled);
if !settled {
result["_hint"] = json!("Zones committed as one Undo step, but KiCad was still filling when this reply was written, so the revision below may already be stale. Call kicad_zone_state (refill:false) for a settled revision before the next edit; a stale one is refused with stale_board and changes nothing.");
}
match s.revision() {
Ok(r) => {
result["revision"] = json!(r);
if args.get("save") == Some(&json!(true)) {
match s.client.save_document() {
Ok(()) => result["saved"] = json!(true),
Err(e) => result["postCommitError"] = json!(e.to_string()),
}
}
}
Err(e) => result["postCommitError"] = json!(e.message),
}
Ok(result)
}
/// Reconcile only explicitly owned zones in one native undo transaction. Always DRC
/// the complete candidate first; preserve unrelated zones and require a revision.
pub fn apply_zone_plan(ctx: &Ctx, args: &Value) -> RResult<Value> {
if args["expectedRevision"]
.as_str()
.filter(|s| !s.is_empty())
.is_none()
{
return Err(RoutingError::new(
"revision_required",
"Read kicad_zone_state first and pass its expectedRevision",
));
}
// Refill is a separate asynchronous native edit and Undo step. Keep this
// transaction's revision usable by leaving refill to an explicit caller step.
if args["refill"] == true {
return Err(RoutingError::new("separate_refill_required", "Apply with refill:false (the default), then call kicad_zone_state with refill:true and read its final revision").with("mutated", json!(false)));
}
let s = connect(ctx, args)?;
let (text, board, revision) = s.snapshot()?;
check_revision(args, &revision)?;
let plan = crate::zone_plan::prepare(&text, &board, args)?;
if plan.remove.is_empty() && plan.create.is_empty() {
// No candidate differs from the current board. A capped inherited DRC
// report must not turn an idempotent read into a failed mutation.
return Ok(json!({
"success":true,"mutated":false,"undoSteps":0,"revision":revision,
"dryRun":args["dryRun"]==true,"unchangedIds":plan.unchanged,
"ownedItemIds":plan.unchanged,"itemIds":[],"removedIds":[],
"drcRerun":false,"_hint":"Native zone definitions already match; no edit or DRC rerun. This is not a DRC-clean certification."
}));
}
let before = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &text)?;
let check = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &plan.candidate)?;
let added = new_errors(&before, &check)?;
if !added.is_empty() {
return Err(RoutingError::new(
"drc_rejected",
"Full zone plan adds native DRC errors; nothing changed",
)
.with("violations", json!(added))
.with("mutated", json!(false)));
}
if args["dryRun"] == true {
return Ok(
json!({"success":true,"mutated":false,"dryRun":args["dryRun"]==true,"revision":revision,"unchangedIds":plan.unchanged,"wouldRemoveIds":plan.remove,"wouldCreate":plan.create.iter().map(zones::ZoneSpec::public).collect::<Vec<_>>(),"drc":check,"undoSteps":0}),
);
}
let nets = s.client.get_nets().map_err(map_err)?;
let mut items = Vec::new();
for z in &plan.create {
let net_code = if z.keepout.is_some() {
0
} else {
nets.iter()
.find(|n| n.name == z.net.name)
.ok_or_else(|| {
RoutingError::new("unknown_net", format!("No native net {:?}", z.net.name))
})?
.code
};
let layers = z
.layers
.iter()
.map(|l| {
BoardLayerInfo::id_from_name(l)
.ok_or_else(|| RoutingError::new("unknown_layer", l.clone()))
})
.collect::<RResult<Vec<_>>>()?;
items.push(prost_types::Any {
type_url: zones::ZONE_TYPE_URL.into(),
value: zones::zone_bytes(z, &layers, net_code),
});
}
check_revision(args, &s.revision()?)?;
let created = transaction(&s, "Adom apply zone plan", || {
if !plan.remove.is_empty() {
s.client.delete_items(plan.remove.clone())?;
// KiCad applies deletions at commit. Readback here sees the old board.
}
let created = if items.is_empty() {
Vec::new()
} else {
s.client.create_items(items.clone(), None)?
};
if created.len() != items.len() {
return Err(IpcFailure::from(
"Not every planned zone was created; rollback requested".to_string(),
));
}
Ok(created)
})?;
let ids: Vec<String> = created
.iter()
.filter_map(|a| zones::zone_id(&a.value))
.collect();
let mut owned = plan.unchanged.clone();
owned.extend(ids.clone());
let mut out = json!({"success":true,"committed":true,"mutated":true,"undoSteps":1,"itemIds":ids,"ownedItemIds":owned,"unchangedIds":plan.unchanged,"removedIds":plan.remove,"drc":check,"saved":false,"refilled":false,"_hint":"Full plan committed as one native Undo step. Inspect postCommitError and kicad_zone_state before retrying. Ownership is only the returned ownedItemIds; never infer it from a zone name or age."});
if ids.len() != plan.create.len() {
out["postCommitError"] = json!(
"Native creation returned incomplete zone IDs; inspect the board before any retry"
);
}
out["refillRequired"] = json!(true);
if !plan.remove.is_empty() {
match s
.client
.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code])
{
Ok(rows) => {
let remaining: Vec<_> = rows
.iter()
.filter_map(|a| zones::zone_id(&a.value))
.filter(|id| plan.remove.contains(id))
.collect();
out["deletionVerified"] = json!(remaining.is_empty());
if !remaining.is_empty() {
out["postCommitDeletionError"] = json!({"remainingIds": remaining});
out["success"] = json!(false);
}
}
Err(e) => {
out["postCommitDeletionError"] = json!(e.to_string());
out["success"] = json!(false);
}
}
}
match s.revision() {
Ok(r) => out["revision"] = json!(r),
Err(e) => out["postCommitError"] = json!(e.message),
}
if args["save"] == true {
match s.client.save_document() {
Ok(()) => out["saved"] = json!(true),
Err(e) => out["postCommitError"] = json!(e.to_string()),
}
}
Ok(out)
}
/// `kicad_remove_zone`: delete zones (copper pours or keepouts) by id as one Undo step. Only
/// zone items are accepted, so a pad, track or footprint id is refused with nothing removed.
pub fn remove_zone(ctx: &Ctx, args: &Value) -> RResult<Value> {
let s = connect(ctx, args)?;
check_revision(args, &s.revision()?)?;
let ids: Vec<String> = match args.get("itemIds").or_else(|| args.get("zoneIds")) {
Some(Value::Array(a)) if !a.is_empty() && a.len() <= 256 => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
_ => return Err(RoutingError::new("invalid_item_ids", "Supply 1..256 zone itemIds returned by kicad_add_zone or kicad_zone_state")),
};
let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?;
let mut found = Vec::new();
for a in &raws {
if let Ok(z) = zones::decode_zone(&a.value) {
if ids.contains(&z.id) {
found.push(z);
}
}
}
if found.len() != ids.len() {
let have: Vec<&str> = found.iter().map(|z| z.id.as_str()).collect();
let missing: Vec<&String> = ids.iter().filter(|i| !have.contains(&i.as_str())).collect();
return Err(RoutingError::new("invalid_item_ids", "Some IDs are not zones on the live board; nothing removed").with("missing", json!(missing)).with("mutated", json!(false)));
}
let count = ids.len();
let label = format!("Adom remove {} zone(s)", count);
let removed = transaction(&s, &label, || {
let removed = s.client.delete_items(ids.clone())?;
if removed.len() != count {
return Err(IpcFailure::from(format!("KiCad removed {} of {} zones", removed.len(), count)));
}
Ok(removed)
})?;
let mut result = json!({
"success": true, "mutated": true, "source": "live-editor", "removed": removed, "undoSteps": 1, "saved": false,
"zones": found.iter().map(|z| json!({"id": z.id, "name": z.name, "net": z.net, "type": if z.rule_area { "keepout" } else { "copper" }})).collect::<Vec<_>>(),
"_hint": "Zones removed as one KiCad Undo step. Other zones' fills may be stale: kicad_zone_state {refill:true} refills them.",
});
match s.revision() {
Ok(r) => result["revision"] = json!(r),
Err(e) => result["postCommitError"] = json!(e.message),
}
Ok(result)
}
/// Wait for an asynchronous zone fill to land: the revision must first move away from `before`
/// (a new zone always changes the board when it fills), then read identically three times in a
/// row. A fill that changes nothing is accepted once the revision has stayed at `before` for 3 s.
/// Read errors (KiCad busy mid-fill) count as "not yet". Bounded at 12 s; returns
/// (last revision, settled).
pub fn settle_revision(before: Option<&str>, mut read: impl FnMut() -> Result<String, String>) -> (Option<String>, bool) {
settle_revision_with(before, &mut read, std::time::Duration::from_millis(300), std::time::Duration::from_secs(3), std::time::Duration::from_secs(12))
}
fn settle_revision_with(before: Option<&str>, read: &mut dyn FnMut() -> Result<String, String>, step: std::time::Duration, quiet: std::time::Duration, limit: std::time::Duration) -> (Option<String>, bool) {
let start = std::time::Instant::now();
let (mut last, mut same, mut moved): (Option<String>, u32, bool) = (None, 0, false);
loop {
if let Ok(r) = read() {
moved |= before != Some(r.as_str());
if last.as_deref() == Some(r.as_str()) { same += 1 } else { same = 0 }
last = Some(r);
if moved && same >= 2 {
return (last, true);
}
if !moved && same >= 2 && start.elapsed() >= quiet {
return (last, true);
}
}
if start.elapsed() >= limit {
return (last, false);
}
std::thread::sleep(step);
}
}
fn refill_requested(board_path: &str) -> Value {
json!({
"success": true, "boardPath": board_path, "source": "live-editor",
"refillRequested": true, "refilled": false, "revisionUsable": false,
"_hint": "Refill was requested, not confirmed complete. KiCad fills asynchronously. Wait and call kicad_zone_state with refill:false; retry read-only calls while busy. Use the later snapshot's revision, never replay refill after a timeout. No revision or copper measurements are returned in this acknowledgement."
})
}
/// `kicad_zone_state`: every zone on the live board with its filled copper area per layer
/// (KiCad's own fill polygons), the sum per layer and the coverage against the outline
/// box. `refill:true` refills every zone first (the fill can be stale after routing).
pub fn zone_state(ctx: &Ctx, args: &Value) -> RResult<Value> {
let s = connect(ctx, args)?;
if args.get("refill") == Some(&json!(true)) {
s.client.refill_zones(Vec::new()).map_err(map_err)?;
// Acknowledgement precedes asynchronous fill; no stale snapshot escapes.
return Ok(refill_requested(&s.file_path));
}
let (text, data, revision) = s.snapshot()?;
let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?;
let mut read = Vec::with_capacity(raws.len());
for a in &raws {
read.push(zones::decode_zone(&a.value).map_err(|e| RoutingError::new("zone_undecodable", format!("KiCad returned a zone the bridge could not decode: {e}")))?);
}
let mut out = json!({
"success": true, "boardPath": s.file_path, "revision": revision, "source": "live-editor",
"kicadVersion": s.version.full_version, "socket": s.socket, "refilled": false,
"_hint": "filledAreaByLayerMm2 is the copper KiCad's zones keep on each layer: for a copper-ablation process, what does not have to be milled away. Add pours with kicad_add_zone; kicad_routing_validate is the DRC.",
});
merge(&mut out, &zones::state_json(&read, &data));
out["definitions"] = crate::zone_plan::readback(&text).map_err(|e|RoutingError::new("zone_undecodable",e))?;
Ok(out)
}
// ---------------------------------------------------------------------------
// Placement: kicad_placement_state, kicad_move_footprint, kicad_placement_validate
// ---------------------------------------------------------------------------
pub const PLACEMENT_STATE_HINT: &str = "Use this revision as expectedRevision in kicad_move_footprint. Coordinates are mm, +y down; courtyard boxes are board-frame and per side. Cluster by nets[].refs, place decoupling caps beside their IC, keep the power stage together, and watch ratsnest.totalMm fall. kicad_placement_validate is the check.";
/// `kicad_placement_state`, live: the editor's own board text parsed for footprints,
/// courtyards, nets and the ratsnest estimate. Read-only.
pub fn placement_state(ctx: &Ctx, args: &Value) -> RResult<Value> {
let s = connect(ctx, args)?;
let (_text, board, revision) = s.snapshot()?;
let detail = args.get("detail") == Some(&json!(true));
let mut out = json!({
"success": true, "boardPath": s.file_path, "revision": revision, "source": "live-editor",
"kicadVersion": s.version.full_version, "socket": s.socket,
"_hint": PLACEMENT_STATE_HINT,
});
merge(&mut out, &placement::state(&board, detail));
if let Some(exp) = args.get("expectedRevision").and_then(Value::as_str) {
out["stale"] = json!(exp != revision);
}
Ok(out)
}
/// The moved footprints as KiCad will store them. KiCad's `UpdateItems` does not edit a
/// footprint in place: it builds a new one from this proto (`FOOTPRINT::Deserialize`),
/// removes the old one and adds the new one, and Deserialize takes every child (pads,
/// fields, shapes, texts, zones, ...) at the ABSOLUTE board coordinates the proto
/// carries. So besides `position` and `orientation` this moves every child with the
/// same rigid-body transform (`placement::ChildTransform`): the four mandatory fields
/// through the typed proto, every `definition.items` payload on the wire
/// (`placement::transform_child`). A child type without a transform rule refuses the
/// whole move (`unsupported_child_item`); a side change is refused
/// (`side_change_not_supported`), so `layer` is only ever re-set to what it was. Every
/// move must have its item, or the editor no longer holds that footprint
/// (`footprint_not_found`).
pub fn build_footprint_updates(items: Vec<EditablePcbItem>, moves: &[placement::Move]) -> RResult<Vec<EditablePcbItem>> {
let layer_id = |name: &str| BoardLayerInfo::id_from_name(name).ok_or_else(|| RoutingError::new("unknown_layer", format!("KiCad has no layer named {name}")));
let mut out = Vec::with_capacity(moves.len());
for m in moves {
if m.flipped || m.after.side != m.before.side {
return Err(placement::side_change_error(&m.reference, &m.before.side, &m.after.side));
}
let found = items.iter().find(|i| i.id() == Some(m.uuid.as_str()));
let Some(EditablePcbItem::Footprint(fp)) = found else {
return Err(RoutingError::new("footprint_not_found", format!("The live board has no footprint with id {} ({}); read kicad_placement_state again", m.uuid, m.reference)).with("mutated", json!(false)));
};
let mut fp = fp.clone();
place_footprint_proto(&mut fp, (nm(m.after.x), nm(m.after.y), m.after.rotation), &m.reference)?;
let p = fp.proto_mut();
p.layer = layer_id(&m.after.side)?;
out.push(EditablePcbItem::Footprint(fp));
}
Ok(out)
}
/// Put a footprint proto at a new pose (`to`: x and y in nm, rotation in degrees) the way KiCad's
/// `UpdateItems` and `CreateItems` need it: KiCad rebuilds the footprint from the proto and takes
/// every child at the ABSOLUTE board coordinates it carries, so the children move with the same
/// rigid-body transform. The layer is left as it is.
pub(crate) fn place_footprint_proto(fp: &mut kicad_ipc_rs::FootprintItem, to: (i64, i64, f64), reference: &str) -> RResult<()> {
let p = fp.proto_mut();
// The old pose is the proto's own: that is the frame the children are in.
let (ox, oy) = p.position.as_ref().map(|v| (v.x_nm, v.y_nm)).unwrap_or((0, 0));
let orot = p.orientation.as_ref().map(|a| a.value_degrees).unwrap_or(0.0);
let xf = placement::ChildTransform::new((ox, oy, orot), to);
for field in [&mut p.reference_field, &mut p.value_field, &mut p.datasheet_field, &mut p.description_field].into_iter().flatten() {
if let Some(text) = field.text.as_mut().and_then(|t| t.text.as_mut()) {
if let Some(v) = text.position.as_mut() {
let (x, y) = xf.point(v.x_nm, v.y_nm);
v.x_nm = x;
v.y_nm = y;
}
if let Some(a) = text.attributes.as_mut().and_then(|a| a.angle.as_mut()) {
a.value_degrees = xf.angle(a.value_degrees);
}
}
}
if let Some(def) = p.definition.as_mut() {
for item in def.items.iter_mut() {
item.value = placement::transform_child(&xf, &item.type_url, &item.value).map_err(|e| {
RoutingError::new("unsupported_child_item", format!("Footprint {} carries a {} item the bridge cannot move ({}); nothing was moved", reference, e.type_url, e.reason))
.with("typeUrl", json!(e.type_url))
.with("ref", json!(reference))
.with("supportedTypes", json!(placement::CHILD_TYPES))
.with("mutated", json!(false))
.with("_hint", json!("KiCad replaces the whole footprint on an IPC update, so the bridge moves every child of the proto itself and refuses rather than move one wrongly. Move this footprint in the PCB editor, or rotate by a multiple of 90 degrees when the reason names one."))
})?;
}
}
p.position = Some(Default::default());
if let Some(pos) = p.position.as_mut() {
pos.x_nm = to.0;
pos.y_nm = to.1;
}
p.orientation = Some(Default::default());
if let Some(o) = p.orientation.as_mut() {
o.value_degrees = to.2;
}
Ok(())
}
/// `kicad_move_footprint`: one footprint or a batch, checked (revision, courtyards,
/// outline) then committed through `update_items` as ONE native Undo step. `dryRun`
/// runs the checks and reports without touching the editor.
pub fn move_footprint(ctx: &Ctx, args: &Value) -> RResult<Value> {
// Fail unknown/typoed fields before even contacting the editor, not just
// before committing. The same validator runs for file-based dry runs.
placement::validate_move_arguments(args)?;
let s = connect(ctx, args)?;
let (text_before, board, revision) = s.snapshot()?;
check_revision(args, &revision)?;
let plan = placement::plan_moves(&board, args)?;
let public = placement::plan_json(&plan);
if args.get("dryRun") == Some(&json!(true)) {
let mut out = json!({
"success": true, "dryRun": true, "mutated": false, "revision": revision, "source": "live-editor", "undoSteps": 0,
"_hint": "Checks passed without editing. Submit with dryRun:false and the same revision.",
});
merge(&mut out, &public);
return Ok(out);
}
// The checks ran on a snapshot; re-read right before the commit so an edit in between
// (the user's, or another caller's) invalidates this plan instead of racing it.
check_revision(args, &s.revision()?)?;
let ids: Vec<String> = plan.moves.iter().map(|m| m.uuid.clone()).collect();
let items = s.client.get_editable_items_by_id(ids).map_err(map_err)?;
let updates = build_footprint_updates(items, &plan.moves)?;
let count = updates.len();
transaction(&s, &plan.undo_label, || {
let updated = s.client.update_editable_items(updates)?;
if updated.len() != count {
return Err(IpcFailure::from("KiCad did not update every requested footprint".to_string()));
}
Ok(())
})?;
let mut result = json!({
"success": true, "mutated": true, "source": "live-editor", "undoSteps": 1, "saved": false,
"_hint": "Footprint(s) moved live as one KiCad Undo step; verified means the read-back courtyard (or first pad) moved with the anchor, not just the anchor. Read kicad_placement_state for the new revision before the next move; kicad_placement_validate checks courtyards, the outline and KiCad DRC. The file is saved only with save:true.",
});
merge(&mut result, &public);
// Commit succeeded. A read or save failure after it is reported as postCommitError,
// never as a failed move that invites a replay.
match s.snapshot() {
Ok((text_after, after, rev)) => {
result["revision"] = json!(rev);
let moved: Vec<String> = plan.moves.iter().map(|m| m.uuid.clone()).collect();
let dropped: Vec<Value> = crate::silk_fields::dropped_closing_vertices(&text_before, &text_after, &moved)
.into_iter()
.map(|mut d| {
let r = plan.moves.iter().find(|m| Some(m.uuid.as_str()) == d["id"].as_str()).map(|m| m.reference.clone());
d["ref"] = json!(r);
d
})
.collect();
if !dropped.is_empty() {
result["closingVerticesDroppedByKiCad"] = json!(dropped);
result["_libraryParityHint"] = json!(crate::silk_fields::CLOSING_VERTEX_HINT);
}
let places = placement::placements(&after);
let close = |a: f64, b: f64| (a - b).abs() < 0.0005;
let mut all_match = true;
let mut stayed: Vec<String> = Vec::new();
let verified: Vec<Value> = plan
.moves
.iter()
.map(|m| match places.iter().zip(after.footprints.iter()).find(|(p, _)| p.reference == m.reference) {
Some((p, after_fp)) => {
let pose_ok = close(p.x, m.after.x) && close(p.y, m.after.y) && close(placement_angle(p.rotation), placement_angle(m.after.rotation)) && p.side == m.after.side;
// The anchor alone proves nothing (KiCad rebuilds the footprint from
// the proto): the courtyard or first pad must have moved with it.
let children = board.footprints.iter().find(|f| f.reference == m.reference).map(|before_fp| placement::check_children(m, before_fp, after_fp));
let children_ok = children.as_ref().map(|c| c.ok).unwrap_or(false);
if !children_ok {
stayed.push(children.as_ref().map(|c| c.describe(&m.reference)).unwrap_or_else(|| format!("{} was not on the board before the move", m.reference)));
}
let ok = pose_ok && children_ok;
all_match &= ok;
json!({"ref": m.reference, "x": p.x, "y": p.y, "rotation": p.rotation, "side": p.side, "courtyard": p.courtyard, "insideOutline": p.inside_outline, "matches": ok, "poseMatches": pose_ok, "children": children})
}
None => {
all_match = false;
json!({"ref": m.reference, "matches": false, "missing": true})
}
})
.collect();
result["after"] = json!(verified);
result["verified"] = json!(all_match);
if !stayed.is_empty() {
result["postCommitError"] = json!(format!("children did not move: {}", stayed.join("; ")));
} else if !all_match {
result["postCommitError"] = json!("The live board does not show every footprint at its requested pose; inspect kicad_placement_state");
}
let (_, total) = placement::nets(&after);
result["ratsnest"] = json!({"totalMm": total});
if args.get("save") == Some(&json!(true)) {
match s.client.save_document() {
Ok(()) => result["saved"] = json!(true),
Err(e) => result["postCommitError"] = json!(e.to_string()),
}
}
}
Err(e) => result["postCommitError"] = json!(e.message),
}
Ok(result)
}
fn placement_angle(d: f64) -> f64 {
let mut r = d % 360.0;
if r < 0.0 {
r += 360.0;
}
r
}
/// `kicad_placement_validate`, live: courtyard overlaps, footprints outside the outline
/// or parked, the ratsnest total, and KiCad DRC on the live snapshot filtered to the
/// placement-class violations.
pub fn placement_validate(ctx: &Ctx, args: &Value) -> RResult<Value> {
let s = connect(ctx, args)?;
let (text, board, revision) = s.snapshot()?;
let mut out = json!({
"success": true, "revision": revision, "source": "live-editor",
"_hint": "placed requires zero courtyard overlaps and nothing outside the outline; drc.placementViolations is KiCad's own verdict on courtyards, edge clearance, silk and pad clearance. A stale result is superseded by newer edits.",
});
merge(&mut out, &placement::validate(&board));
out["drc"] = match drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &text) {
Ok(d) => placement::drc_filter(&d),
Err(e) => json!({"available": false, "errorCode": e.code, "reason": e.message}),
};
let current = s.revision()?;
out["currentRevision"] = json!(current);
out["stale"] = json!(current != revision);
Ok(out)
}
fn merge(into: &mut Value, from: &Value) {
if let (Some(a), Some(b)) = (into.as_object_mut(), from.as_object()) {
for (k, v) in b {
a.insert(k.clone(), v.clone());
}
}
}
// ---------------------------------------------------------------------------
// Tests (no live KiCad)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
#[test]
fn a_fill_that_lands_late_is_waited_for_and_a_stuck_one_is_reported() {
use std::time::Duration;
let ms = Duration::from_millis;
// the pre-fill revision twice, a busy read, then the filled board that holds still
let mut seq = vec![Ok("A".to_string()), Ok("A".to_string()), Err("busy".to_string()),
Ok("B".to_string()), Ok("B".to_string()), Ok("B".to_string())].into_iter();
let (r, ok) = settle_revision_with(Some("A"), &mut || seq.next().unwrap_or(Ok("B".into())), ms(1), ms(50), ms(500));
assert_eq!((r.as_deref(), ok), (Some("B"), true)); // never the stale "A"
// a fill that changes nothing: accepted once quiet long enough
let (r, ok) = settle_revision_with(Some("A"), &mut || Ok("A".to_string()), ms(1), ms(20), ms(500));
assert_eq!((r.as_deref(), ok), (Some("A"), true));
// a board that never stops changing: reported unsettled at the limit
let mut n = 0;
let (_, ok) = settle_revision_with(Some("A"), &mut || { n += 1; Ok(format!("R{n}")) }, ms(1), ms(20), ms(60));
assert!(!ok);
}
#[test]
fn refill_acknowledgement_never_certifies_a_stale_snapshot() {
let reply = super::refill_requested("board.kicad_pcb");
assert_eq!(reply["refillRequested"], true);
assert_eq!(reply["refilled"], false);
assert_eq!(reply["revisionUsable"], false);
assert!(reply.get("revision").is_none());
assert!(reply.get("filledAreaByLayerMm2").is_none());
}
#[test]
fn zone_plan_refill_refuses_before_ipc_or_mutation() {
let error = super::apply_zone_plan(&super::Ctx::default(), &serde_json::json!({
"expectedRevision": "test", "refill": true
})).unwrap_err();
assert_eq!(error.code, "separate_refill_required");
assert_eq!(super::error_response(&error)["mutated"], false);
}
use super::*;
use std::cell::RefCell;
#[test]
fn strip_items_cuts_only_the_named_copper() {
let text = "(kicad_pcb (version 1)\n\t(segment (start 0 0) (end 1 0) (uuid \"a\"))\n\t(via (at 1 0) (uuid \"b\"))\n\t(gr_text \"a (uuid \\\"a\\\")\" (uuid \"c\"))\n\t(segment (start 2 0) (end 3 0) (uuid \"d\"))\n)";
let (out, n) = strip_items(text, &["a".to_string(), "b".to_string()]);
assert_eq!(n, 2);
assert!(!out.contains("(uuid \"a\"))") && !out.contains("(uuid \"b\")"));
assert!(out.contains("(uuid \"c\")") && out.contains("(uuid \"d\")"));
}
#[test]
fn revision_is_sha256_of_the_board_text() {
assert_eq!(revision_of("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
assert_ne!(revision_of("(kicad_pcb)"), revision_of("(kicad_pcb)\n"));
assert!(check_revision(&json!({"expectedRevision": "x"}), "x").is_ok());
for rev in [Value::Null, json!("old"), json!(1)] {
let e = check_revision(&json!({"expectedRevision": rev}), "new").unwrap_err();
assert_eq!(e.code, "stale_board");
assert_eq!(e.detail["currentRevision"], "new");
assert_eq!(e.detail["mutated"], false);
}
assert_eq!(check_revision(&json!({}), "new").unwrap_err().code, "stale_board");
}
#[test]
fn board_path_validation_and_normalisation() {
assert_eq!(normalize_board_path(None).unwrap_err().code, "invalid_board_path");
assert_eq!(normalize_board_path(Some(&json!("relative/x.kicad_pcb"))).unwrap_err().code, "invalid_board_path");
assert_eq!(normalize_board_path(Some(&json!("C:/x/y.kicad_sch"))).unwrap_err().code, "invalid_board_path");
assert_eq!(normalize_board_path(Some(&json!("C:\\Users\\J\\Board.kicad_pcb"))).unwrap(), "c:/users/j/board.kicad_pcb");
assert_eq!(normalize_board_path(Some(&json!("/tmp/nope/Board.kicad_pcb"))).unwrap(), "/tmp/nope/board.kicad_pcb");
let d = DocumentSpecifier { document_type: DocumentType::Pcb, board_filename: Some("live.kicad_pcb".into()), project: kicad_ipc_rs::model::common::ProjectInfo { name: None, path: Some(PathBuf::from("/tmp/proj")) } };
assert_eq!(normalize_existing(&document_path(&d)), "/tmp/proj/live.kicad_pcb");
let abs = DocumentSpecifier { document_type: DocumentType::Pcb, board_filename: Some("C:/p/live.kicad_pcb".into()), project: kicad_ipc_rs::model::common::ProjectInfo { name: None, path: Some(PathBuf::from("/tmp/proj")) } };
assert_eq!(document_path(&abs), "C:/p/live.kicad_pcb");
}
#[test]
fn capped_opens_are_lower_bound_without_blocking_error_comparison() {
let limits = report_limits(&[], 499);
assert_eq!(limits["unconnectedCountIsLowerBound"], true);
assert_eq!(limits["violationReportMayBeTruncated"], false);
let mut before = json!({"violations": []});
merge(&mut before, &limits);
assert_eq!(new_errors(&before, &before).unwrap(), Vec::<Value>::new());
assert_eq!(report_limits(&[], 498)["unconnectedCountIsLowerBound"], false);
}
#[test]
fn per_type_caps_block_error_delta_even_when_reports_match() {
for (kind, cap) in [("clearance", 499usize), ("shorting_items", 199)] {
let v = json!({"type": kind, "severity": "error", "items": []});
let under: Vec<Value> = vec![v.clone(); cap - 1];
assert_eq!(report_limits(&under, 0)["violationReportMayBeTruncated"], false);
let at: Vec<Value> = vec![v.clone(); cap];
let mut report = json!({"violations": at});
let limits = report_limits(report["violations"].as_array().unwrap(), 0);
assert_eq!(limits["possiblyTruncatedViolationTypes"], json!([kind]));
merge(&mut report, &limits);
let e = new_errors(&report, &report).unwrap_err();
assert_eq!(e.code, "drc_incomplete");
assert_eq!(e.detail["mutated"], false);
}
}
/// Wiki #140: 199 silk_overlap and 199 silk_over_copper warnings on a dense LED board blocked
/// every live route although the board had no errors.
#[test]
fn warning_types_at_the_cap_do_not_block_the_error_delta() {
let warn = |kind: &str| json!({"type": kind, "severity": "warning", "description": "silk", "items": []});
let mut rows: Vec<Value> = vec![warn("silk_overlap"); 199];
rows.extend(vec![warn("silk_over_copper"); 199]);
let mut report = json!({"violations": rows});
let limits = report_limits(report["violations"].as_array().unwrap(), 499);
assert_eq!(limits["violationReportMayBeTruncated"], true, "still reported for information");
assert_eq!(limits["possiblyTruncatedViolationTypes"], json!(["silk_over_copper", "silk_overlap"]));
merge(&mut report, &limits);
assert!(capped_error_types(&report).is_empty());
assert_eq!(new_errors(&report, &report).unwrap(), Vec::<Value>::new());
// a new error of another type is still seen
let mut after = report.clone();
let short = json!({"type": "shorting_items", "severity": "error", "description": "short", "items": []});
after["violations"].as_array_mut().unwrap().push(short.clone());
assert_eq!(new_errors(&report, &after).unwrap(), vec![short]);
// re-graded as errors (the silkscreen check does this), the same capped types block again
let regraded = |mut v: Value| {
for r in v["violations"].as_array_mut().unwrap() {
r["severity"] = json!("error");
}
v
};
let e = new_errors(®raded(report.clone()), ®raded(report.clone())).unwrap_err();
assert_eq!(e.code, "drc_incomplete");
assert_eq!(e.detail["cappedErrorTypes"], json!(["silk_over_copper", "silk_overlap"]));
// a report that is truncated without naming its types stays refused
assert!(new_errors(&json!({"violations": [], "violationReportMayBeTruncated": true}), &json!({"violations": []})).is_err());
}
#[test]
fn existing_error_does_not_mask_additional_same_error() {
let v = json!({"type": "clearance", "severity": "error", "description": "collision", "items": [{"pos": {"x": 1, "y": 2}}]});
let w = json!({"type": "clearance", "severity": "warning", "description": "collision", "items": []});
let added = new_errors(&json!({"violations": [v, w]}), &json!({"violations": [v, v, w, w]})).unwrap();
assert_eq!(added, vec![v.clone()]);
// Item order inside a violation does not change its identity.
let a = json!({"type": "t", "severity": "error", "description": "d", "items": [{"pos": {"x": 1}}, {"pos": {"x": 2}}]});
let b = json!({"type": "t", "severity": "error", "description": "d", "items": [{"pos": {"x": 2}}, {"pos": {"x": 1}}]});
assert!(new_errors(&json!({"violations": [a]}), &json!({"violations": [b]})).unwrap().is_empty());
}
#[test]
fn drc_result_has_the_python_shape() {
let report = json!({
"$schema": "x", "kicad_version": "10.0.5", "source": "board.kicad_pcb",
"violations": [
{"type": "clearance", "severity": "error", "description": "Clearance violation", "items": [{"description": "Track", "pos": {"x": 1.0, "y": 2.0}}]},
{"type": "lib_footprint_issues", "severity": "warning", "description": "Footprint not found in libraries", "items": []},
],
"unconnected_items": [{"type": "unconnected_items", "severity": "error", "description": "Missing connection", "items": []}],
});
let r = drc_result(&report, vec!["/p/board.kicad_pro".into()]);
assert_eq!(r["errors"], 1);
assert_eq!(r["warnings"], 1);
assert_eq!(r["unconnected"], 1);
assert_eq!(r["clean"], false);
assert_eq!(r["zonesRefilled"], true);
assert_eq!(r["source"], "live-editor-snapshot");
assert_eq!(r["projectRulesCopied"], json!(["/p/board.kicad_pro"]));
assert_eq!(r["violationReportMayBeTruncated"], false);
assert_eq!(r["unconnectedItems"].as_array().unwrap().len(), 1);
// `summary` is the bridge's kicad_run_drc summary, which counts unconnected items as errors too.
assert_eq!(r["summary"]["errors"], 2);
assert_eq!(r["summary"]["unconnected"], 1);
assert_eq!(drc_result(&json!({"violations": [], "unconnected_items": []}), vec![])["clean"], true);
}
#[test]
fn drc_snapshot_needs_kicad_cli() {
let e = drc_snapshot(None, Path::new("/tmp/x.kicad_pcb"), "(kicad_pcb)").unwrap_err();
assert_eq!(e.code, "drc_unavailable");
let e = drc_snapshot(Some(Path::new("/definitely/not/kicad-cli")), Path::new("/tmp/x.kicad_pcb"), "(kicad_pcb)").unwrap_err();
assert_eq!(e.code, "drc_unavailable");
}
/// A fake commit session recording begin/push/drop like tests/test_live_routing.py.
struct Fake {
ops: RefCell<Vec<&'static str>>,
push_fails: Option<IpcFailure>,
drop_fails: Option<IpcFailure>,
}
impl Fake {
fn new() -> Self {
Fake { ops: RefCell::new(Vec::new()), push_fails: None, drop_fails: None }
}
}
impl CommitTarget for Fake {
fn begin(&self) -> Result<String, IpcFailure> {
self.ops.borrow_mut().push("begin");
Ok("commit".into())
}
fn push(&self, _id: &str, _message: &str) -> Result<(), IpcFailure> {
self.ops.borrow_mut().push("push");
self.push_fails.clone().map(Err).unwrap_or(Ok(()))
}
fn drop_commit(&self, _id: &str) -> Result<(), IpcFailure> {
self.ops.borrow_mut().push("drop");
self.drop_fails.clone().map(Err).unwrap_or(Ok(()))
}
}
#[test]
fn success_is_one_undo_step() {
let b = Fake::new();
assert_eq!(transaction(&b, "route", || Ok::<_, IpcFailure>(42)).unwrap(), 42);
assert_eq!(*b.ops.borrow(), vec!["begin", "push"]);
}
#[test]
fn partial_create_failure_rolls_back() {
let b = Fake::new();
let e = transaction(&b, "route", || Err::<(), _>(IpcFailure::from("partial create".to_string()))).unwrap_err();
assert_eq!(e.code, "mutation_rolled_back");
assert_eq!(e.detail["mutated"], false);
assert_eq!(*b.ops.borrow(), vec!["begin", "drop"]);
}
#[test]
fn push_timeout_is_unknown_even_if_drop_acknowledged() {
let mut b = Fake::new();
b.push_fails = Some(IpcFailure { message: "commit response lost".into(), timed_out: true });
let e = transaction(&b, "route", || Ok::<_, IpcFailure>(42)).unwrap_err();
assert_eq!(e.code, "mutation_outcome_unknown");
assert_eq!(e.detail["mutated"], Value::Null);
assert_eq!(e.detail["rollbackAttempted"], true);
assert_eq!(e.detail["stillRunning"], true);
assert_eq!(e.detail["ipcTimeout"], true);
assert_eq!(*b.ops.borrow(), vec!["begin", "push", "drop"]);
}
#[test]
fn failed_rollback_is_unknown_not_safe_to_retry() {
let mut b = Fake::new();
b.drop_fails = Some(IpcFailure::from("lost IPC".to_string()));
let e = transaction(&b, "route", || Err::<(), _>(IpcFailure::from("create failed".to_string()))).unwrap_err();
assert_eq!(e.code, "mutation_outcome_unknown");
assert_eq!(e.detail["mutated"], Value::Null);
assert_eq!(e.detail["rollbackError"], "lost IPC");
assert_eq!(e.detail["stillRunning"], false);
}
#[test]
fn error_response_carries_detail_and_hint() {
let e = RoutingError::new("stale_board", "old").with("currentRevision", json!("abc"));
let v = error_response(&e);
assert_eq!(v["success"], false);
assert_eq!(v["errorCode"], "stale_board");
assert_eq!(v["currentRevision"], "abc");
assert!(v["_hint"].as_str().unwrap().contains("kicad_routing_state"));
let custom = error_response(&RoutingError::new("api_server_off", "x").with("_hint", json!("enable it")));
assert_eq!(custom["_hint"], "enable it");
}
#[test]
fn timeout_maps_to_ipc_timeout_with_no_mutation() {
let e = map_err(KiCadError::Timeout { timeout: Duration::from_millis(5000) });
assert_eq!(e.code, "ipc_timeout");
assert_eq!(e.detail["ipcTimeoutMs"], 5000);
assert_eq!(e.detail["stillRunning"], false);
assert_eq!(e.detail["mutated"], false);
assert_eq!(map_err(KiCadError::BoardNotOpen).code, "no_board_open");
assert_eq!(map_err(KiCadError::SocketUnavailable { socket_uri: "ipc:///x".into() }).code, "ipc_unavailable");
assert_eq!(map_err(KiCadError::ApiStatus { code: "AS_BAD_REQUEST".into(), message: "nope".into() }).code, "ipc_error");
assert_eq!(map_err(KiCadError::ApiStatus { code: "AS_UNHANDLED".into(), message: "no handler".into() }).code, "no_pcb_frame");
assert_eq!(timeout_of(&json!({})), Duration::from_millis(5000));
assert_eq!(timeout_of(&json!({"ipcTimeoutMs": 10})), Duration::from_millis(1000));
assert_eq!(timeout_of(&json!({"ipcTimeoutMs": 999999})), Duration::from_millis(60000));
}
#[test]
fn footprint_updates_move_the_children_with_the_anchor() {
use kicad_ipc_rs::FootprintItem;
use placement::{angle_bytes, vector2_bytes, wire_message, wire_varint};
const MM: i64 = 1_000_000;
// An `Any` child without naming prost's type: the crate's own into_any builds one.
let any = |name: &str, value: Vec<u8>| {
let mut a = EditablePcbItem::Group(kicad_ipc_rs::GroupItem::new("x", vec![])).into_any();
a.type_url = format!("type.googleapis.com/kiapi.board.types.{name}");
a.value = value;
a
};
// U1 at (10, 20) mm, unrotated, with its reference text 2 mm above the anchor,
// pad 1 one millimetre to the left (padstack angle 0) and a 4 x 2 mm courtyard.
let mut fp = FootprintItem::from_proto(Default::default());
{
let p = fp.proto_mut();
p.id = Some(Default::default());
if let Some(id) = p.id.as_mut() {
id.value = "u1-uuid".into();
}
p.position = Some(Default::default());
if let Some(v) = p.position.as_mut() {
v.x_nm = 10 * MM;
v.y_nm = 20 * MM;
}
p.layer = BoardLayerInfo::id_from_name("F.Cu").unwrap();
p.locked = 1;
p.symbol_sheet_name = "Root".into();
p.reference_field = Some(Default::default());
if let Some(f) = p.reference_field.as_mut() {
f.name = "Reference".into();
f.text = Some(Default::default());
if let Some(bt) = f.text.as_mut() {
bt.text = Some(Default::default());
if let Some(t) = bt.text.as_mut() {
t.text = "U1".into();
t.position = Some(Default::default());
if let Some(v) = t.position.as_mut() {
v.x_nm = 10 * MM;
v.y_nm = 18 * MM;
}
t.attributes = Some(Default::default());
if let Some(a) = t.attributes.as_mut() {
a.angle = Some(Default::default());
a.keep_upright = true;
}
}
}
}
let mut pad = Vec::new();
wire_message(&mut pad, 3, b"1");
let mut stack = Vec::new();
wire_message(&mut stack, 6, &angle_bytes(0.0));
wire_message(&mut pad, 6, &stack);
wire_message(&mut pad, 7, &vector2_bytes(9 * MM, 20 * MM));
let mut rect = Vec::new();
wire_message(&mut rect, 1, &vector2_bytes(8 * MM, 19 * MM));
wire_message(&mut rect, 2, &vector2_bytes(12 * MM, 21 * MM));
let mut shape = Vec::new();
wire_message(&mut shape, 5, &rect);
let mut courtyard = Vec::new();
wire_message(&mut courtyard, 1, &shape);
wire_varint(&mut courtyard, 2, BoardLayerInfo::id_from_name("F.CrtYd").unwrap() as u64);
p.definition = Some(Default::default());
if let Some(def) = p.definition.as_mut() {
def.items.push(any("Pad", pad));
def.items.push(any("BoardGraphicShape", courtyard));
def.items.push(any("Group", vec![0x12, 0x01, b'g']));
}
}
let mv = placement::Move {
reference: "U1".into(),
uuid: "u1-uuid".into(),
before: placement::Pose { x: 10.0, y: 20.0, rotation: 0.0, side: "F.Cu".into() },
after: placement::Pose { x: 30.0, y: 40.0, rotation: 90.0, side: "F.Cu".into() },
courtyard_before: placement::Rect { min_x: 8.0, min_y: 19.0, max_x: 12.0, max_y: 21.0 },
courtyard_after: placement::Rect { min_x: 29.0, min_y: 38.0, max_x: 31.0, max_y: 42.0 },
courtyard_source: "courtyard",
flipped: false,
outline_after: Vec::new(),
};
let out = build_footprint_updates(vec![EditablePcbItem::Footprint(fp.clone())], &[mv.clone()]).unwrap();
assert_eq!(out.len(), 1);
let EditablePcbItem::Footprint(f) = &out[0] else { panic!("expected a footprint, got {:?}", out[0]) };
let p = f.proto();
assert_eq!(p.id.as_ref().unwrap().value, "u1-uuid");
let pos = p.position.as_ref().unwrap();
assert_eq!((pos.x_nm, pos.y_nm), (30 * MM, 40 * MM));
assert_eq!(p.orientation.as_ref().unwrap().value_degrees, 90.0);
assert_eq!(p.layer, BoardLayerInfo::id_from_name("F.Cu").unwrap());
// Everything else the editor sent rides along untouched.
assert_eq!(p.locked, 1);
assert_eq!(p.symbol_sheet_name, "Root");
// The reference text turned about the anchor: 2 mm above becomes 2 mm to the left.
let t = p.reference_field.as_ref().unwrap().text.as_ref().unwrap().text.as_ref().unwrap();
let tp = t.position.as_ref().unwrap();
assert_eq!((tp.x_nm, tp.y_nm), (28 * MM, 40 * MM));
assert_eq!(t.attributes.as_ref().unwrap().angle.as_ref().unwrap().value_degrees, 90.0);
assert!(t.attributes.as_ref().unwrap().keep_upright);
assert_eq!(t.text, "U1");
// The children, decoded by the IPC crate's own protobuf code: the pad 1 mm left of
// the anchor is now 1 mm below it and turned by 90; the courtyard corners turned.
let items = &p.definition.as_ref().unwrap().items;
assert_eq!(items.len(), 3);
match EditablePcbItem::from_any(items[0].clone()).unwrap() {
EditablePcbItem::Pad(pad) => {
let pp = pad.proto();
assert_eq!(pp.number, "1");
let v = pp.position.as_ref().unwrap();
assert_eq!((v.x_nm, v.y_nm), (30 * MM, 41 * MM));
assert_eq!(pp.pad_stack.as_ref().unwrap().angle.as_ref().unwrap().value_degrees, 90.0);
}
other => panic!("expected a pad, got {other:?}"),
}
match EditablePcbItem::from_any(items[1].clone()).unwrap() {
EditablePcbItem::BoardGraphicShape(shape) => {
assert_eq!(shape.proto().layer, BoardLayerInfo::id_from_name("F.CrtYd").unwrap());
let corners: Vec<(i64, i64)> = format!("{:?}", shape.proto().shape.as_ref().unwrap().geometry)
.split("Vector2 { x_nm: ")
.skip(1)
.map(|s| {
let (x, rest) = s.split_once(", y_nm: ").unwrap();
(x.parse().unwrap(), rest.split(' ').next().unwrap().trim_end_matches(',').parse().unwrap())
})
.collect();
assert_eq!(corners, vec![(29 * MM, 42 * MM), (31 * MM, 38 * MM)]);
}
other => panic!("expected a shape, got {other:?}"),
}
assert_eq!(items[2].value, vec![0x12, 0x01, b'g'], "a group passes through");
// A child the bridge cannot move refuses the whole batch before any commit.
let mut odd = fp.clone();
odd.proto_mut().definition.as_mut().unwrap().items.push(any("Track", vec![]));
let e = build_footprint_updates(vec![EditablePcbItem::Footprint(odd)], &[mv.clone()]).unwrap_err();
assert_eq!(e.code, "unsupported_child_item");
assert_eq!(e.detail["typeUrl"], "type.googleapis.com/kiapi.board.types.Track");
assert_eq!(e.detail["mutated"], false);
// A flip is refused too.
let mut flip = mv.clone();
flip.after.side = "B.Cu".into();
flip.flipped = true;
assert_eq!(build_footprint_updates(vec![EditablePcbItem::Footprint(fp.clone())], &[flip]).unwrap_err().code, "side_change_not_supported");
// A move whose footprint the editor no longer has is refused before any commit.
let mut gone = mv.clone();
gone.uuid = "nope".into();
assert_eq!(build_footprint_updates(vec![EditablePcbItem::Footprint(fp)], &[gone]).unwrap_err().code, "footprint_not_found");
}
#[test]
fn items_carry_kipy_units_net_and_layers() {
let text = crate::pcb::tests::fixture_text();
let board = pcb::parse_pcb_text(&text, "live-routing.kicad_pcb").unwrap();
let plan = pcb::plan(&board, &json!({"net": "NET_1", "points": ["J1.1", {"x": 112, "y": 85, "layer": "B.Cu"}, "J2.1"]})).unwrap();
let items = build_items(&plan, &BoardNet { code: 7, name: "NET_1".into() }).unwrap();
assert_eq!(items.len(), 3);
match &items[0] {
EditablePcbItem::Track(t) => {
assert_eq!(t.start_nm().unwrap(), Vector2Nm { x_nm: 105_000_000, y_nm: 85_000_000 });
assert_eq!(t.proto().width.as_ref().unwrap().value_nm, 250_000);
assert_eq!(t.layer_id(), BoardLayerInfo::id_from_name("F.Cu").unwrap());
assert_eq!(t.proto().net.as_ref().unwrap().name, "NET_1");
}
other => panic!("expected a track, got {other:?}"),
}
match &items[1] {
EditablePcbItem::Track(t) => assert_eq!(t.layer_id(), BoardLayerInfo::id_from_name("B.Cu").unwrap()),
other => panic!("expected a track, got {other:?}"),
}
match &items[2] {
EditablePcbItem::Via(v) => {
assert_eq!(v.position_nm().unwrap(), Vector2Nm { x_nm: 112_000_000, y_nm: 85_000_000 });
let ps = v.proto().pad_stack.as_ref().unwrap();
assert_eq!(ps.copper_layers[0].size.as_ref().unwrap().x_nm, 600_000);
assert_eq!(ps.drill.as_ref().unwrap().diameter.as_ref().unwrap().x_nm, 300_000);
assert_eq!(v.proto().net.as_ref().unwrap().name, "NET_1");
}
other => panic!("expected a via, got {other:?}"),
}
// The Python contract: a KiCad 10 board with an unknown layer name is refused before any IPC.
let bad = Plan { net_name: "NET_1".into(), segments: vec![pcb::PlanSegment { start: [0.0, 0.0], end: [1.0, 0.0], layer: "Nope.Cu".into(), width: 0.2 }], vias: vec![], blocks: vec![] };
assert_eq!(build_items(&bad, &BoardNet { code: 1, name: "NET_1".into() }).unwrap_err().code, "unknown_layer");
}
}
/// Native rendered text bounds without changing the board.
fn text_spec_from_json(v:&Value)->RResult<kicad_ipc_rs::TextSpec> {
use kicad_ipc_rs::{TextSpec,TextAttributesSpec,TextHorizontalAlignment as H,TextVerticalAlignment as V};
let text=v["text"].as_str().ok_or_else(||RoutingError::new("invalid_text","text is required"))?;
let number=|k:&str,d:f64|->RResult<f64>{let n=v.get(k).map(|x|x.as_f64().unwrap_or(f64::NAN)).unwrap_or(d);if n.is_finite(){Ok(n)}else{Err(RoutingError::new("invalid_text",format!("{k} must be finite")))}};
let height=number("height",1.0)?;let width=number("width",height)?;let stroke=number("stroke",height*0.15)?;
if height<=0.0 || width<=0.0 || stroke<=0.0 || height>1000.0 || width>1000.0{return Err(RoutingError::new("invalid_text","positive dimensions in mm required; maximum text size 1000 mm"))}
let align=match v["align"].as_str().unwrap_or("center"){"left"=>H::Left,"center"=>H::Center,"right"=>H::Right,_=>return Err(RoutingError::new("invalid_text","align must be left, center or right"))};
let vertical=match v["verticalAlign"].as_str().unwrap_or("center"){"top"=>V::Top,"center"=>V::Center,"bottom"=>V::Bottom,_=>return Err(RoutingError::new("invalid_text","verticalAlign must be top, center or bottom"))};
Ok(TextSpec{text:text.into(),position_nm:Some(Vector2Nm{x_nm:(number("x",0.0)?*1e6).round() as i64,y_nm:(number("y",0.0)?*1e6).round() as i64}),attributes:Some(TextAttributesSpec{font_name:v["font"].as_str().map(str::to_string),horizontal_alignment:align,vertical_alignment:vertical,angle_degrees:Some(number("rotation",0.0)?),stroke_width_nm:Some((stroke*1e6).round() as i64),size_nm:Some(Vector2Nm{x_nm:(width*1e6).round() as i64,y_nm:(height*1e6).round() as i64}),mirrored:v["mirrored"].as_bool().unwrap_or(false),bold:v["bold"].as_bool().unwrap_or(false),italic:v["italic"].as_bool().unwrap_or(false),multiline:text.contains('\n'),..Default::default()}),hyperlink:None})
}
pub fn text_bounds(ctx:&Ctx,args:&Value)->RResult<Value>{
let rows=args["texts"].as_array().ok_or_else(||RoutingError::new("invalid_text","texts must be a nonempty array"))?;
if rows.is_empty() || rows.len()>2000{return Err(RoutingError::new("invalid_text","texts must contain 1..2000 entries"))}
let specs=rows.iter().map(text_spec_from_json).collect::<RResult<Vec<_>>>()?;
let s=connect(ctx,args)?;let revision=s.revision()?;if args.get("expectedRevision").is_some(){check_revision(args,&revision)?;}
let mut bounds=Vec::new();
for (row,spec) in rows.iter().zip(specs){let b=s.client.get_text_extents(spec).map_err(map_err)?;bounds.push(json!({"id":row["id"],"text":row["text"],"box":[b.x_nm as f64/1e6,b.y_nm as f64/1e6,(b.x_nm+b.width_nm) as f64/1e6,(b.y_nm+b.height_nm) as f64/1e6]}));}
Ok(json!({"success":true,"mutated":false,"revision":revision,"source":"native-GetTextExtents","units":"mm","texts":bounds,"_hint":"Native axis-aligned rendered bounds at the requested rotation and justification. Keep holes, exposed pads and component bodies as separate obstacles. This does not place text or certify readability."}))
}
#[cfg(test)] mod text_metric_tests {
use super::*;
#[test] fn alignment_and_bottom_mirroring(){
let s=text_spec_from_json(&json!({"text":"MC10\nDSHOT","x":12.5,"height":0.3,"stroke":0.05,"align":"right","mirrored":true,"rotation":90})).unwrap();assert_eq!(s.position_nm.unwrap().x_nm,12_500_000);let a=s.attributes.unwrap();assert_eq!(a.horizontal_alignment,kicad_ipc_rs::TextHorizontalAlignment::Right);assert!(a.mirrored&&a.multiline);assert_eq!(a.size_nm.unwrap().y_nm,300_000);
assert!(text_spec_from_json(&json!({"text":"x","height":0})).is_err());assert!(text_spec_from_json(&json!({"text":"x","align":"random"})).is_err());
}
}
/// Update standalone silk text by stable UUID, in one native Undo transaction.
pub fn silk_text_batch(ctx: &Ctx, args: &Value) -> RResult<Value> {
use kicad_ipc_rs::BoardTextItem;
let s = connect(ctx, args)?;
let (text, _, revision) = s.snapshot()?;
check_revision(args, &revision)?;
let plan = crate::silk_text::prepare(&text, args)?;
// Silkscreen clearance findings are often warnings. Compare all severities, not only errors.
let all_severities = |mut v: Value| {
if let Some(rows) = v["violations"].as_array_mut() {
for row in rows {
row["severity"] = json!("error");
}
}
v
};
let before = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &text)?;
let after = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &plan.candidate)?;
let added = new_errors(&all_severities(before), &all_severities(after.clone()))?;
if !added.is_empty() {
return Err(RoutingError::new(
"drc_rejected",
"Silkscreen candidate adds native DRC findings; no live text changed",
)
.with("_hint", json!(silk_drc_hint(&added)))
.with("violations", json!(added))
.with("mutated", json!(false)));
}
if args["dryRun"] == true {
return Ok(
json!({"success":true,"mutated":false,"dryRun":true,"revision":revision,"drc":after}),
);
}
let item = |t: &crate::silk_text::Text| {
let mut item = BoardTextItem::from_proto(Default::default());
item.set_layer_id(BoardLayerInfo::id_from_name(&t.layer).unwrap());
let p = item.proto_mut();
p.id = Some(Default::default());
p.id.as_mut().unwrap().value = t.id.clone();
p.locked = 1;
p.knockout = false;
p.text = Some(Default::default());
let text = p.text.as_mut().unwrap();
text.text = t.text.clone();
text.position = Some(Default::default());
let pos = text.position.as_mut().unwrap();
pos.x_nm = nm(t.x);
pos.y_nm = nm(t.y);
text.attributes = Some(Default::default());
let a = text.attributes.as_mut().unwrap();
a.horizontal_alignment = match t.align.as_str() {
"left" => 1,
"right" => 3,
_ => 2,
};
a.vertical_alignment = 2;
a.angle = Some(Default::default());
a.angle.as_mut().unwrap().value_degrees = t.rotation;
a.line_spacing = 1.;
a.stroke_width = Some(Default::default());
a.stroke_width.as_mut().unwrap().value_nm = nm(t.stroke);
a.size = Some(Default::default());
a.size.as_mut().unwrap().x_nm = nm(t.width);
a.size.as_mut().unwrap().y_nm = nm(t.height);
a.mirrored = t.mirrored;
a.multiline = t.text.contains('\n');
a.visible = true;
EditablePcbItem::BoardText(item)
};
let created_ids: Vec<String> = plan.create.iter().map(|t| t.id.clone()).collect();
let updated_ids: Vec<String> = plan.update.iter().map(|t| t.id.clone()).collect();
let validate_reply =
|actual: Vec<EditablePcbItem>, wanted: &[String]| -> Result<(), IpcFailure> {
let actual: HashSet<_> = actual
.iter()
.filter_map(|t| t.id().map(str::to_string))
.collect();
if actual != wanted.iter().cloned().collect() {
return Err(IpcFailure::from(
"KiCad reply did not preserve every requested text UUID".to_string(),
));
}
Ok(())
};
check_revision(args, &s.revision()?)?;
transaction(&s, "Adom silkscreen text", || {
if !plan.remove.is_empty() {
s.client.delete_items(plan.remove.clone())?;
}
if !plan.update.is_empty() {
validate_reply(
s.client
.update_editable_items(plan.update.iter().map(&item).collect())?,
&updated_ids,
)?;
}
if !plan.create.is_empty() {
validate_reply(
s.client
.create_editable_items(plan.create.iter().map(&item).collect(), None)?,
&created_ids,
)?;
}
Ok(())
})?;
let mut result = json!({"success":true,"mutated":true,"committed":true,"undoSteps":1,"createdIds":created_ids,"updatedIds":updated_ids,"removedIds":plan.remove,"drc":after,"saved":false,"source":"live-editor","_hint":"One native Undo step. Updates require full text definitions and preserve UUIDs; no content-based replacement. Inspect returned revision/bounds and refresh the linked 3D viewer. A postCommitError means committed: inspect state, never blindly replay."});
if !plan.remove.is_empty() {
match s
.client
.get_items_by_type_codes(vec![PcbObjectTypeCode::new_text().code])
{
Ok(rows) => {
let remaining: Vec<_> = rows
.iter()
.filter_map(item_id)
.filter(|id| plan.remove.iter().any(|wanted| wanted == id))
.collect();
result["deletionVerified"] = json!(remaining.is_empty());
if !remaining.is_empty() {
result["postCommitDeletionError"] = json!({"remainingIds": remaining});
result["success"] = json!(false);
}
}
Err(e) => {
result["postCommitDeletionError"] = json!(e.to_string());
result["success"] = json!(false);
}
}
}
match s.revision() {
Ok(rev) => result["revision"] = json!(rev),
Err(e) => result["postCommitError"] = json!(e.message),
}
let ids: Vec<_> = created_ids.iter().chain(&updated_ids).cloned().collect();
if !ids.is_empty() {
match s.client.get_item_bounding_boxes(ids,false){Ok(boxes)=>result["bounds"]=json!(boxes.iter().map(|b|json!({"id":b.item_id,"boxMm":[b.x_nm as f64/1e6,b.y_nm as f64/1e6,(b.x_nm+b.width_nm) as f64/1e6,(b.y_nm+b.height_nm) as f64/1e6]})).collect::<Vec<_>>()),Err(e)=>result["postCommitBoundsError"]=json!(e.to_string())}
}
if args["save"] == true {
match s.client.save_document() {
Ok(()) => result["saved"] = json!(true),
Err(e) => result["postCommitSaveError"] = json!(e.to_string()),
}
}
Ok(result)
}
mod silk_fields;
pub use silk_fields::silk_fields_batch;
#[cfg(test)]
mod lib_table_tests {
#[test]
fn a_copied_library_table_points_kiprjmod_back_at_the_real_project() {
let base = std::env::temp_dir().join(format!("adom-libtable-test-{}", std::process::id()));
let proj = base.join("proj");
let snap = base.join("snap");
std::fs::create_dir_all(&proj).unwrap();
std::fs::create_dir_all(&snap).unwrap();
let table = "(fp_lib_table\n (version 7)\n (lib (name \"Load\")(type \"KiCad\")(uri \"${KIPRJMOD}/lib/Load.pretty\")(options \"\")(descr \"\"))\n)\n";
std::fs::write(proj.join("fp-lib-table"), table).unwrap();
super::copy_lib_table(&proj.join("fp-lib-table"), &snap.join("fp-lib-table"), &proj).unwrap();
let out = std::fs::read_to_string(snap.join("fp-lib-table")).unwrap();
let expect = format!("{}/lib/Load.pretty", proj.to_string_lossy().replace('\\', "/"));
assert!(out.contains(&expect), "{out}");
assert!(!out.contains("${KIPRJMOD}"));
let _ = std::fs::remove_dir_all(&base);
}
}