← Commit history

Delete rust/crates/kicad-core/src/ipc.rs.orig

Colby Knox ·bda3f5acc1 ·14d ago ·parent ad7a3f5
1 file changed −1921
rust/crates/kicad-core/src/ipc.rs.origdeleted−1921
@@ -1,1921 +0,0 @@-//! 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;--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.-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 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()-}--/// `_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 (`drc_incomplete`).-pub fn new_errors(before: &Value, after: &Value) -> RResult<Vec<Value>> {-    let truncated = |r: &Value| r.get("violationReportMayBeTruncated").and_then(Value::as_bool).unwrap_or(false);-    if truncated(before) || truncated(after) {-        return Err(RoutingError::new("drc_incomplete", "DRC violation lists may be capped; cannot certify the error delta. No live copper was changed").with("mutated", json!(false)));-    }-    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)-}--/// `_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());-        }-    }-    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_send_key {hwnd, menu:\"Zoom to Selection\"} on the PCB editor; clear it with kicad_select_clear. Selection is not an edit: nothing to undo, the revision is unchanged.",-    }))-}--/// `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 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();-    Ok(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.",-    }))-}--/// `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)?;-    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, &format!("Adom route {}", plan.net_name), || {-        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 (_, _, 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"));-    }-    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)-}--/// `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).-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.-    if args.get("refill") != Some(&json!(false)) && !ids.is_empty() {-        match s.client.refill_zones(Vec::new()) {-            Ok(()) => result["refilled"] = json!(true),-            Err(e) => result["postCommitError"] = json!(format!("zones created but not filled: {e}")),-        }-    }-    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)?;-    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 || (plan.remove.is_empty() && plan.create.is_empty()) {-        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)-}--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();-        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), (nm(m.after.x), nm(m.after.y), m.after.rotation));-        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", m.reference, e.type_url, e.reason))-                        .with("typeUrl", json!(e.type_url))-                        .with("ref", json!(m.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 = nm(m.after.x);-            pos.y_nm = nm(m.after.y);-        }-        p.orientation = Some(Default::default());-        if let Some(o) = p.orientation.as_mut() {-            o.value_degrees = m.after.rotation;-        }-        p.layer = layer_id(&m.after.side)?;-        out.push(EditablePcbItem::Footprint(fp));-    }-    Ok(out)-}--/// `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> {-    let s = connect(ctx, args)?;-    let (_text, 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((_, after, rev)) => {-            result["revision"] = json!(rev);-            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 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 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);-        }-    }--    #[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,-        };-        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("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)-}-