app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
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);
}
}