main
Colby Knox AI Flow 0.2.77: Fusion layer-304 bottom copper (#203) d6d2719
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
//! EDA backends: the only code that knows which tool a run's board lives in (see docs/eda-backends.md).
//!
//! A run names its board with a [`BoardRef`]: a `.kicad_pcb` path (KiCad), `fusion:<document>` or
//! `altium:<path>.PcbDoc`. The run's backend answers every board operation the flow makes: live reads,
//! guarded edits, checks, the view and capture verbs. Every call is in the plan frame (mm, y down,
//! F.Cu/B.Cu); a backend converts to its tool's frame itself.
//!
//! The flow still names its operations with the historical `kicad_*` verb names at the call sites;
//! [`dispatch`] maps each one to the backend method, so a call site never branches on the tool.
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};

/// One adom-bridge verb, sent as is (the Adom Bridge CLI).
pub trait Transport {
    fn send(&self, verb: &str, args: &Value) -> Value;
}

impl<F: Fn(&str, &Value) -> Value> Transport for F {
    fn send(&self, verb: &str, args: &Value) -> Value { self(verb, args) }
}

/// Where a run's board lives.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BoardRef {
    /// An absolute `.kicad_pcb` path on the box (or none yet).
    Kicad(String),
    /// The exact Fusion board document name.
    Fusion(String),
    /// The absolute `.PcbDoc` path on the box.
    Altium(String),
}

impl BoardRef {
    pub fn parse(s: &str) -> BoardRef {
        if let Some(d) = s.strip_prefix(crate::fusion::PREFIX) { return BoardRef::Fusion(d.to_string()); }
        if let Some(p) = s.strip_prefix(ALTIUM_PREFIX) { return BoardRef::Altium(p.to_string()); }
        BoardRef::Kicad(s.to_string())
    }
    /// The run's stored form (`remoteBoard`).
    pub fn to_ref_string(&self) -> String {
        match self {
            BoardRef::Kicad(p) => p.clone(),
            BoardRef::Fusion(d) => format!("{}{d}", crate::fusion::PREFIX),
            BoardRef::Altium(p) => format!("{ALTIUM_PREFIX}{p}"),
        }
    }
}

pub const ALTIUM_PREFIX: &str = "altium:";

/// What a backend can do. A step that needs a capability the backend lacks records the gap in the
/// run; it never passes silently.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Caps {
    pub live_placement_state: bool,
    pub live_placement_validate: bool,
    /// The tool's own full design-rule check (beyond the live routing check).
    pub full_drc: bool,
    /// Copper can be removed item by item (not only whole nets).
    pub item_removal: bool,
    pub keepout_zones: bool,
    /// The 3D view is its own window (KiCad) rather than a mode of the main window.
    pub separate_3d_window: bool,
    /// The bridge can tell whether a window's canvas has painted.
    pub canvas_paint_probe: bool,
    pub net_highlight: bool,
    /// The native board is a file the flow can pull and hash (remote binding, provenance, verify-pours).
    pub file_backed_board: bool,
}

/// The board operations every backend answers. Defaults refuse with `unsupported_in_<id>`, naming the
/// operation, so a missing piece is loud.
pub trait Eda: Send + Sync {
    fn id(&self) -> &'static str;
    /// The tool's name for the timeline and reports.
    fn name(&self) -> &'static str;
    fn board(&self) -> BoardRef;
    fn caps(&self) -> Caps;

    fn unsupported(&self, op: &str) -> Value {
        json!({"success": false, "errorCode": format!("unsupported_in_{}", self.id()),
               "error": format!("{op} has no {} backend equivalent yet", self.name()),
               "_hint": "File it on adom/adom-aiflow with the step that needed it (docs/eda-backends.md: adding to a backend)."})
    }

    // live reads
    fn routing_state(&self, t: &dyn Transport, args: &Value) -> Value;
    fn placement_state(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("placement_state") }
    fn placement_validate(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("placement_validate") }
    fn zone_state(&self, t: &dyn Transport, args: &Value) -> Value;
    fn model_check(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("model_check") }
    fn status(&self, t: &dyn Transport) -> Value;

    // guarded edits
    fn move_parts(&self, t: &dyn Transport, args: &Value) -> Value;
    fn route_net(&self, t: &dyn Transport, args: &Value) -> Value;
    fn remove_items(&self, t: &dyn Transport, args: &Value) -> Value;
    fn apply_zone_plan(&self, t: &dyn Transport, args: &Value) -> Value;

    // checks
    fn routing_validate(&self, t: &dyn Transport, args: &Value) -> Value;
    fn full_drc(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("full_drc") }

    // view and capture
    fn open_board(&self, t: &dyn Transport, args: &Value) -> Value;
    fn window_info(&self, t: &dyn Transport, args: &Value) -> Value;
    fn send_key(&self, t: &dyn Transport, args: &Value) -> Value;
    fn select_net(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("select_net") }
    fn select_clear(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("select_clear") }
    fn open_3d(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("open_3d") }
    fn close_3d(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("close_3d") }
    fn tour_3d(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("tour_3d") }
    fn screenshot_all(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("screenshot_all") }
    /// The tool's main window on the box, when it has one to film.
    fn main_window(&self, _t: &dyn Transport) -> Option<i64> { None }
    /// Frame the board in the main window.
    fn fit_board(&self, _t: &dyn Transport) -> bool { false }
    /// The live board, pulled fresh into `dir`: (the tool's native file, the plan board rendered from
    /// it). None when the plan board in the run IS the board of record (KiCad), Some(Err) when the read
    /// failed.
    fn live_snapshot(&self, _t: &dyn Transport, _dir: &std::path::Path) -> Option<Result<(String, String), String>> { None }
    /// Round these sharp outline corners (plan frame) live in the tool.
    fn round_corners(&self, _t: &dyn Transport, _corners: &[(f64, f64)], _radius: f64) -> Value { self.unsupported("round_corners") }
    /// Load a fab's design rules into the open design (the plan board's .kicad_dru is written by the flow
    /// for every backend: the offline gate reads it). None when the tool has nothing to load them into.
    fn load_design_rules(&self, _t: &dyn Transport, _fab: &str, _layers: u32, _dru: &str) -> Option<Value> { None }
    /// The window to film for a step, the board framed in it. `cached` is the last filmed window.
    fn camera_window(&self, t: &dyn Transport, _step: &str, _cached: Option<i64>) -> Option<i64> {
        let h = self.main_window(t);
        self.fit_board(t);
        h
    }
    /// Whether this step's window is Hydrogen (the chat and the webviews) rather than the tool: a tool
    /// that is one main window (Fusion, Altium) yields the screen to Hydrogen for the human-facing steps.
    fn hydrogen_for_step(&self, step: &str) -> Option<bool> {
        const HYDROGEN_STEPS: [&str; 10] = ["intake", "design", "simulation", "test-plan", "sourcing", "library", "current", "thermal", "fields", "export"];
        Some(HYDROGEN_STEPS.contains(&step))
    }
    /// A flow verb this trait does not name yet.
    fn passthrough(&self, _t: &dyn Transport, verb: &str, _args: &Value) -> Value { self.unsupported(verb) }
}

/// Answer one of the flow's board verbs (historically named `kicad_*`) through a backend.
pub fn dispatch(eda: &dyn Eda, t: &dyn Transport, verb: &str, args: &Value) -> Value {
    match verb {
        "kicad_routing_state" => eda.routing_state(t, args),
        "kicad_placement_state" => eda.placement_state(t, args),
        "kicad_placement_validate" => eda.placement_validate(t, args),
        "kicad_zone_state" => eda.zone_state(t, args),
        "kicad_model_check" => eda.model_check(t, args),
        "kicad_status" | "kicad_version" => eda.status(t),
        "kicad_move_footprint" => eda.move_parts(t, args),
        "kicad_route_net" => eda.route_net(t, args),
        "kicad_remove_route" => eda.remove_items(t, args),
        "kicad_apply_zone_plan" => eda.apply_zone_plan(t, args),
        "kicad_routing_validate" => eda.routing_validate(t, args),
        "kicad_run_drc" => eda.full_drc(t, args),
        "kicad_open_board" => eda.open_board(t, args),
        "kicad_window_info" => eda.window_info(t, args),
        "kicad_send_key" => eda.send_key(t, args),
        "kicad_select_net" => eda.select_net(t, args),
        "kicad_select_clear" => eda.select_clear(t, args),
        "kicad_open_3d_viewer" => eda.open_3d(t, args),
        "kicad_close_3d_viewer" => eda.close_3d(t, args),
        "kicad_3d_view" => eda.tour_3d(t, args),
        "kicad_screenshot_all" => eda.screenshot_all(t, args),
        other => eda.passthrough(t, other, args),
    }
}

// -- KiCad ------------------------------------------------------------------------------------

/// KiCad through the KiCad bridge: the flow's verbs are its own, sent as they are.
pub struct KicadEda {
    pub board: String,
}

impl Eda for KicadEda {
    fn id(&self) -> &'static str { "kicad" }
    fn name(&self) -> &'static str { "KiCad" }
    fn board(&self) -> BoardRef { BoardRef::Kicad(self.board.clone()) }
    fn caps(&self) -> Caps {
        Caps { live_placement_state: true, live_placement_validate: true, full_drc: true, item_removal: true, keepout_zones: true,
               separate_3d_window: true, canvas_paint_probe: true, net_highlight: true, file_backed_board: true }
    }
    fn routing_state(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_routing_state", a) }
    fn placement_state(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_placement_state", a) }
    fn placement_validate(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_placement_validate", a) }
    fn zone_state(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_zone_state", a) }
    fn model_check(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_model_check", a) }
    fn status(&self, t: &dyn Transport) -> Value { t.send("kicad_status", &json!({})) }
    fn move_parts(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_move_footprint", a) }
    fn route_net(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_route_net", a) }
    fn remove_items(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_remove_route", a) }
    fn apply_zone_plan(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_apply_zone_plan", a) }
    fn routing_validate(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_routing_validate", a) }
    fn full_drc(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_run_drc", a) }
    fn open_board(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_open_board", a) }
    fn window_info(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_window_info", a) }
    fn send_key(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_send_key", a) }
    fn select_net(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_select_net", a) }
    fn select_clear(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_select_clear", a) }
    fn open_3d(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_open_3d_viewer", a) }
    fn close_3d(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_close_3d_viewer", a) }
    fn tour_3d(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_3d_view", a) }
    fn screenshot_all(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_screenshot_all", a) }
    fn passthrough(&self, t: &dyn Transport, verb: &str, a: &Value) -> Value { t.send(verb, a) }
    /// The PCB editor showing this board (its title names the board's file stem as a whole name); a
    /// cached window that is a PCB editor of another board is replaced by it. Other cached windows (3D
    /// viewer, Fields) are kept. Issue #155: with the run's board known and not open, another project's
    /// PCB editor is never the camera (None: the step says no editor of this board is open).
    fn camera_window(&self, t: &dyn Transport, _step: &str, cached: Option<i64>) -> Option<i64> {
        let r = t.send("kicad_window_info", &json!({}));
        let d = r.get("data").cloned().unwrap_or(r.clone());
        let eds: Vec<Value> = d.get("editors").and_then(|e| e.as_array()).cloned().unwrap_or_default();
        let pcbs: Vec<&Value> = eds.iter().filter(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor")).collect();
        let stem = file_stem(&self.board);
        let title_of = |w: &Value| w.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();
        let matching = if stem.is_empty() { None } else { pcbs.iter().find(|w| names_board(&title_of(w), &stem)).and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64()) };
        match cached {
            Some(h) => {
                let other_board = pcbs.iter().any(|w| w.get("hwnd").and_then(|x| x.as_i64()) == Some(h) && !stem.is_empty() && !names_board(&title_of(w), &stem));
                if other_board { matching } else { Some(h) }
            }
            None if stem.is_empty() => pcbs.first().and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64()),
            None => matching,
        }
    }
    /// KiCad keeps its own windows in front; the flow never swaps to Hydrogen for it.
    fn hydrogen_for_step(&self, _step: &str) -> Option<bool> { None }
}

// -- Fusion -----------------------------------------------------------------------------------

/// Autodesk Fusion through the Fusion bridge (the translation lives in fusion.rs).
pub struct FusionEda {
    pub doc: String,
}

impl FusionEda {
    fn tr(&self, t: &dyn Transport, verb: &str, a: &Value) -> Value {
        crate::fusion::translate(verb, a, &self.doc, &|v: &str, x: &Value| t.send(v, x))
    }
}

impl Eda for FusionEda {
    fn id(&self) -> &'static str { "fusion" }
    fn name(&self) -> &'static str { "Fusion" }
    fn board(&self) -> BoardRef { BoardRef::Fusion(self.doc.clone()) }
    fn caps(&self) -> Caps {
        // Fusion removes copper a whole net at a time, its 3D is a mode of the main window, and a
        // SHOW highlight lasts only while the command stays open
        Caps { net_highlight: true, ..Caps::default() }
    }
    fn routing_state(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_routing_state", a) }
    fn zone_state(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_zone_state", a) }
    fn model_check(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_model_check", a) }
    fn status(&self, t: &dyn Transport) -> Value { self.tr(t, "kicad_status", &json!({})) }
    fn move_parts(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_move_footprint", a) }
    fn route_net(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_route_net", a) }
    fn remove_items(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_remove_route", a) }
    fn apply_zone_plan(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_apply_zone_plan", a) }
    fn routing_validate(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_routing_validate", a) }
    fn open_board(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_open_board", a) }
    fn window_info(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_window_info", a) }
    fn send_key(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_send_key", a) }
    fn select_net(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_select_net", a) }
    fn select_clear(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_select_clear", a) }
    fn open_3d(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_open_3d_viewer", a) }
    fn close_3d(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_close_3d_viewer", a) }
    fn tour_3d(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_3d_view", a) }
    fn screenshot_all(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_screenshot_all", a) }
    fn main_window(&self, t: &dyn Transport) -> Option<i64> { crate::fusion::main_hwnd(&|v: &str, x: &Value| t.send(v, x)) }
    fn fit_board(&self, t: &dyn Transport) -> bool { crate::fusion::fit_board(&self.doc, &|v: &str, x: &Value| t.send(v, x)) }
    /// `board-live.brd` (Fusion's own XML, fills included, what adom-fields reads) and
    /// `board-live.kicad_pcb` (the same board as the plan board).
    fn live_snapshot(&self, t: &dyn Transport, dir: &std::path::Path) -> Option<Result<(String, String), String>> {
        let ask = || t.send("fusion_board_source", &json!({"expectDocument": self.doc}));
        let mut reply = ask();
        for _ in 0..3 {
            if reply["errorCode"] != "main_thread_busy" && !reply["error"].as_str().unwrap_or("").contains("main thread busy") { break; }
            std::thread::sleep(std::time::Duration::from_secs(5));
            reply = ask();
        }
        let Some(text) = reply["source"].as_str().filter(|_| reply["success"].as_bool() == Some(true)) else {
            return Some(Err(format!("could not read the live Fusion board {}: {} (activate it in Fusion with fusion_show_2d_board; fusion_board_source needs bridge 1.11.17+)", self.doc, reply["error"])));
        };
        let brd = dir.join("board-live.brd");
        let kicad = dir.join("board-live.kicad_pcb");
        crate::fusion::note_board_source(text);   // issue #203: the board's bottom copper number
        let plan = match aiflow_board::eagle::to_kicad(text) { Ok(p) => p, Err(e) => return Some(Err(format!("live Fusion board: {e}"))) };
        if let Err(e) = std::fs::write(&brd, text).and_then(|_| std::fs::write(&kicad, plan)) { return Some(Err(e.to_string())); }
        Some(Ok((brd.display().to_string(), kicad.display().to_string())))
    }
    /// MITER each corner (Fusion is y up: the plan frame's y is negated).
    fn round_corners(&self, t: &dyn Transport, corners: &[(f64, f64)], radius: f64) -> Value {
        let _ = t.send("fusion_show_2d_board", &json!({"documentName": self.doc}));
        let mut done = 0;
        for p in corners {
            let rep = t.send("fusion_electron_run", &json!({"command": format!("GRID MM; MITER {radius} ({:.4} {:.4});", p.0, -p.1)}));
            if rep["success"].as_bool() == Some(true) || (rep["status"].as_str() == Some("ok") && rep.get("success").is_none()) { done += 1; }
            std::thread::sleep(std::time::Duration::from_millis(500));
        }
        json!({"success": done == corners.len(), "rounded": done})
    }
    fn load_design_rules(&self, t: &dyn Transport, fab: &str, layers: u32, _dru: &str) -> Option<Value> {
        Some(t.send("fusion_set_design_rules", &json!({"fab": fab, "layers": layers})))
    }
    fn camera_window(&self, t: &dyn Transport, _step: &str, _cached: Option<i64>) -> Option<i64> {
        let h = self.main_window(t);
        // a project before its board exists has no board document to frame
        if !self.doc.is_empty() { self.fit_board(t); }
        h
    }
}

// -- the run's backend ------------------------------------------------------------------------

/// The backend for a board reference.
pub fn for_board(r: &BoardRef) -> Arc<dyn Eda> {
    match r {
        BoardRef::Kicad(p) => Arc::new(KicadEda { board: p.clone() }),
        BoardRef::Fusion(d) => Arc::new(FusionEda { doc: d.clone() }),
        BoardRef::Altium(p) => Arc::new(crate::altium::AltiumEda { path: p.clone() }),
    }
}

/// A fab rule pack (`.kicad_dru`, the flow's one source of fab numbers) as plain numbers, each
/// constraint's floor (the smallest minimum any of its rules names), in mm, for a backend that loads
/// rules as values: {clearance, trackWidth{min}, hole{min}, via{diameter{min}, drill{min}},
/// annularRing, holeToHole, edgeClearance}.
pub fn fab_numbers(dru: &str) -> Value {
    let mut floor: std::collections::BTreeMap<String, f64> = std::collections::BTreeMap::new();
    for part in dru.split("(constraint ").skip(1) {
        let kind: String = part.chars().take_while(|c| !c.is_whitespace() && *c != ')').collect();
        // a pack may write the hole floor as `hole` or `hole_size`
        let kind = if kind == "hole" { "hole_size".to_string() } else { kind };
        let Some(min) = part.split("(min ").nth(1).and_then(|r| r.split(')').next()) else { continue };
        let v = min.trim();
        let mm = if let Some(m) = v.strip_suffix("mil") { m.parse::<f64>().ok().map(|x| x * 0.0254) } else { v.trim_end_matches("mm").parse::<f64>().ok() };
        if let Some(mm) = mm { let e = floor.entry(kind).or_insert(mm); if mm < *e { *e = mm; } }
    }
    let g = |k: &str| floor.get(k).copied();
    let mut o = serde_json::Map::new();
    if let Some(v) = g("clearance") { o.insert("clearance".into(), json!(v)); }
    if let Some(v) = g("track_width") { o.insert("trackWidth".into(), json!({"min": v})); }
    if let Some(v) = g("hole_size") { o.insert("hole".into(), json!({"min": v})); }
    if g("via_diameter").is_some() || g("hole_size").is_some() {
        o.insert("via".into(), json!({"diameter": {"min": g("via_diameter").unwrap_or(0.45)}, "drill": {"min": g("hole_size").unwrap_or(0.2)}}));
    }
    if let Some(v) = g("annular_width") { o.insert("annularRing".into(), json!(v)); }
    // the floor AI Flow plans holes to when a pack names none (aiflow_pours::hole_to_hole): the tool's rule
    // must agree, or Altium's own 0.254 mm default flags vias the plan placed 0.2 mm apart
    o.insert("holeToHole".into(), json!(g("hole_to_hole").unwrap_or(0.2)));
    if let Some(v) = g("edge_clearance") { o.insert("edgeClearance".into(), json!(v)); }
    Value::Object(o)
}

/// A KiCad project file for a plan board that has none (a Fusion or Altium board rendered as the
/// plan): the fab's floors as KiCad's board-setup minimums and the Default netclass. Without it KiCad's
/// DRC on the plan judges by KiCad's own defaults (0.2 mm clearance, 0.5 mm board edge) while the router
/// and the tool's live rules use the fab's (measured 2026-10-15: an Altium run's gate refused 0.186 mm
/// via-to-track and 0.4 mm via-to-edge copper the fab allows).
pub fn plan_project_json(dru: &str, file_name: &str) -> String {
    let n = fab_numbers(dru);
    let f = |v: &Value, d: f64| v.as_f64().unwrap_or(d);
    let clearance = f(&n["clearance"], 0.2);
    let track = f(&n["trackWidth"]["min"], 0.2);
    let via_d = f(&n["via"]["diameter"]["min"], 0.6);
    let via_h = f(&n["via"]["drill"]["min"], 0.3);
    let edge = f(&n["edgeClearance"], 0.5);
    let hole = f(&n["hole"]["min"], 0.3);
    let h2h = f(&n["holeToHole"], 0.25);
    let ring = f(&n["annularRing"], 0.1);
    let v = json!({
        "board": {"design_settings": {"rules": {
            "min_clearance": clearance, "min_copper_edge_clearance": edge, "min_track_width": track,
            "min_via_diameter": via_d, "min_through_hole_diameter": hole, "min_hole_to_hole": h2h, "min_via_annular_width": ring}}},
        "meta": {"filename": file_name, "version": 1},
        "net_settings": {"classes": [{"name": "Default", "clearance": clearance, "track_width": track.max(0.2), "via_diameter": via_d.max(0.6), "via_drill": via_h.max(0.3),
            "diff_pair_gap": 0.25, "diff_pair_width": 0.2, "diff_pair_via_gap": 0.25, "microvia_diameter": 0.3, "microvia_drill": 0.1,
            "wire_width": 6, "bus_width": 12, "line_style": 0, "priority": 2147483647,
            "pcb_color": "rgba(0, 0, 0, 0.000)", "schematic_color": "rgba(0, 0, 0, 0.000)"}], "meta": {"version": 3}}
    });
    serde_json::to_string_pretty(&v).unwrap_or_default()
}

/// The plan board for `start`: the board file the human gave, rendered by the backend its
/// `--remote-board` names. Ok(None) = a KiCad board, copied as it is.
pub fn import_board(remote: Option<&str>, src_text: &str) -> Result<Option<String>, String> {
    let r = remote.map(BoardRef::parse);
    let eagle = aiflow_board::eagle::is_eagle(src_text);
    match r {
        Some(BoardRef::Fusion(_)) => aiflow_board::eagle::to_kicad(src_text).map(Some),
        _ if eagle => Err("a Fusion board needs --remote-board fusion:<exact board document name> (and --target <box>); export it with fusion_export_eagle_source {outputPath:\"...brd\"}, pull it here, and name the open board document: --remote-board fusion:Buck-brd".into()),
        Some(BoardRef::Altium(_)) => aiflow_board::altium::render_plan_board(src_text).map(Some),
        _ => Ok(None),
    }
}

/// The backend a run's project directory says it is building in before any board exists
/// (`<run>/<backend>/project.json`, written by that backend's project workflow).
pub fn project_backend(dir: &std::path::Path) -> Option<&'static str> {
    ["fusion", "altium"].into_iter().find(|b| dir.join(b).join("project.json").is_file())
}

static RUN: Mutex<Option<Arc<dyn Eda>>> = Mutex::new(None);

/// Set the run's backend from its board reference (`remoteBoard`). A KiCad run with no board yet gets
/// the KiCad backend.
pub fn set_run_board(board: &str) {
    let r = BoardRef::parse(board);
    if let BoardRef::Fusion(_) = r { crate::fusion::set_run_board(board); }
    *RUN.lock().unwrap() = Some(for_board(&r));
}

/// The run's backend: the one set from the run's board, else KiCad.
pub fn active() -> Arc<dyn Eda> {
    RUN.lock().unwrap().clone().unwrap_or_else(|| Arc::new(KicadEda { board: String::new() }))
}

/// The file stem of the run's KiCad board ("" when the run has none yet). KiCad's editor titles carry
/// the board's name, so the flow can tell this run's windows from another project open on the same
/// desktop (issue #155).
pub fn run_kicad_stem() -> String {
    match active().board() { BoardRef::Kicad(p) => file_stem(&p), _ => String::new() }
}

/// The file name without its extension, for a Windows or a Unix path.
pub fn file_stem(path: &str) -> String {
    let name = path.rsplit(['/', '\\']).next().unwrap_or("");
    name.rsplit_once('.').map(|(s, _)| s).unwrap_or(name).to_string()
}

/// Whether a window title names this board: the stem as a whole name ("esc" is not named by
/// "esc-v1 — PCB Editor", nor "esc" by "esc_v2"), so a sibling project's editor is never taken for
/// the run's. A prefix such as KiCad's `PCM_` or an unsaved board's `*` still counts.
pub fn names_board(title: &str, stem: &str) -> bool {
    if stem.is_empty() { return false; }
    let before = |c: Option<char>| c.map_or(false, |c| c.is_alphanumeric() || c == '-');
    let after = |c: Option<char>| c.map_or(false, |c| c.is_alphanumeric() || c == '-' || c == '_');
    title.match_indices(stem).any(|(i, _)| !before(title[..i].chars().next_back()) && !after(title[i + stem.len()..].chars().next()))
}

/// The backend a single call is addressed to: a `filePath` naming another tool's board wins (a
/// verb may name its board explicitly), else the run's.
pub fn for_call(args: &Value) -> Arc<dyn Eda> {
    match args["filePath"].as_str().map(BoardRef::parse) {
        Some(r @ BoardRef::Fusion(_)) | Some(r @ BoardRef::Altium(_)) => for_board(&r),
        _ => active(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    #[test]
    fn a_title_names_a_board_only_as_a_whole_name() {
        assert_eq!(file_stem("C:/Users/me/KiCad/esc/esc-2s6s.kicad_pcb"), "esc-2s6s");
        assert_eq!(file_stem("C:\\work\\esc\\esc-2s6s.kicad_pcb"), "esc-2s6s");
        assert_eq!(file_stem(""), "");
        assert!(names_board("esc-2s6s — PCB Editor", "esc-2s6s"));
        assert!(names_board("*esc-2s6s — PCB Editor", "esc-2s6s"));
        assert!(names_board("top [esc-2s6s/] — Schematic Editor", "esc-2s6s"));
        assert!(names_board("PCM_esc-2s6s:R — Symbol Editor", "esc-2s6s"));
        assert!(!names_board("esc-2s6s-v2 — PCB Editor", "esc-2s6s"));
        assert!(!names_board("esc-2s6s_v2 — PCB Editor", "esc-2s6s"));
        assert!(!names_board("other-esc-v1 — PCB Editor", "esc"));
        assert!(!names_board("anything", ""));
    }

    /// Issue #155: led-strip-500's run started its components clip on another project's editor.
    #[test]
    fn the_camera_never_falls_back_to_another_boards_editor() {
        let t = |reply: Value| move |_v: &str, _a: &Value| reply.clone();
        let eds = json!({"data": {"editors": [
            {"kind": "pcb_editor", "hwnd": 1, "title": "other-board — PCB Editor"},
            {"kind": "pcb_editor", "hwnd": 2, "title": "led-strip-500 — PCB Editor"}]}});
        let k = KicadEda { board: "C:/p/led-strip-500/led-strip-500.kicad_pcb".into() };
        assert_eq!(k.camera_window(&t(eds.clone()), "components", None), Some(2));
        assert_eq!(k.camera_window(&t(eds.clone()), "components", Some(1)), Some(2));
        let only_other = json!({"data": {"editors": [{"kind": "pcb_editor", "hwnd": 7472288, "title": "other-board — PCB Editor"}]}});
        assert_eq!(k.camera_window(&t(only_other.clone()), "components", None), None);
        assert_eq!(k.camera_window(&t(only_other.clone()), "models", Some(7472288)), None);
        // a sibling board whose name only starts with the run's is another board
        let sibling = json!({"data": {"editors": [{"kind": "pcb_editor", "hwnd": 3, "title": "led-strip-500-v2 — PCB Editor"}]}});
        assert_eq!(k.camera_window(&t(sibling), "components", None), None);
        // a cached window that is not a PCB editor (the 3D viewer) is kept
        assert_eq!(k.camera_window(&t(only_other.clone()), "3d", Some(99)), Some(99));
        // no board known yet: the first editor, as before
        assert_eq!(KicadEda { board: String::new() }.camera_window(&t(only_other), "placement", None), Some(7472288));
    }

    #[test]
    fn board_refs_parse_and_round_trip() {
        for s in ["C:/b/buck.kicad_pcb", "fusion:Buck-brd", "altium:C:/b/buck.PcbDoc"] {
            assert_eq!(BoardRef::parse(s).to_ref_string(), s);
        }
        assert_eq!(BoardRef::parse("fusion:X"), BoardRef::Fusion("X".into()));
        assert_eq!(BoardRef::parse("altium:C:/x.PcbDoc"), BoardRef::Altium("C:/x.PcbDoc".into()));
    }

    #[test]
    fn kicad_backend_sends_the_verbs_unchanged() {
        let sent = RefCell::new(Vec::new());
        let t = |v: &str, a: &Value| { sent.borrow_mut().push((v.to_string(), a.clone())); json!({"success": true}) };
        let k = KicadEda { board: "C:/b.kicad_pcb".into() };
        for verb in ["kicad_routing_state", "kicad_move_footprint", "kicad_route_net", "kicad_remove_route", "kicad_apply_zone_plan", "kicad_run_drc", "kicad_select_net"] {
            dispatch(&k, &t, verb, &json!({"filePath": "C:/b.kicad_pcb"}));
        }
        let verbs: Vec<String> = sent.borrow().iter().map(|(v, _)| v.clone()).collect();
        assert_eq!(verbs, ["kicad_routing_state", "kicad_move_footprint", "kicad_route_net", "kicad_remove_route", "kicad_apply_zone_plan", "kicad_run_drc", "kicad_select_net"]);
    }

    #[test]
    fn a_fab_pack_reads_as_its_floors() {
        let dru = "(rule a (constraint track_width (min 0.127mm)))\n(rule b (constraint hole_size (min 0.3mm)))\n(rule c (constraint hole_size (min 0.5mm)))\n(rule d (constraint edge_clearance (min 0.3mm)))\n(rule e (constraint clearance (min 5mil)))";
        let n = fab_numbers(dru);
        assert_eq!(n["trackWidth"]["min"], json!(0.127));
        assert_eq!(n["hole"]["min"], json!(0.3));
        assert_eq!(n["edgeClearance"], json!(0.3));
        assert!((n["clearance"].as_f64().unwrap() - 0.127).abs() < 1e-9);
    }

    #[test]
    fn a_missing_operation_is_loud() {
        let t = |_: &str, _: &Value| json!({"success": true});
        let f = FusionEda { doc: "Buck-brd".into() };
        let r = dispatch(&f, &t, "kicad_run_drc", &json!({}));
        assert_eq!(r["success"], json!(false));
        assert_eq!(r["errorCode"], json!("unsupported_in_fusion"));
        assert!(!f.caps().full_drc);
    }
}