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
Merge PR #278: 0.2.12: tour fields stays on the container's host; Altium board reads survive the schematic in front
a49d0ad
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
//! Altium Designer through the Altium bridge (`altium_*` verbs, Altium bridge 0.2.47+).
//!
//! The board of record is the `.PcbDoc` open in Altium on the box (`altium:<path>`). Every read is a
//! fresh `altium_routing_state` / `altium_pour_state`; every edit is one Altium undo step, guarded by
//! the revision the flow last read and read back by the bridge. Altium is y up; the plan frame is y
//! down, so y is negated here and nowhere else. Rotations are the same number in both (counter-
//! clockwise as seen on screen). Layer names are KiCad's on both sides (the bridge speaks F.Cu/B.Cu).
//!
//! Item ids name the geometry in Altium's frame (`altium:t:x1,y1,x2,y2,layerId`, `altium:v:x,y`), so
//! a single track or via can be taken back: Altium deletes item by item.
use serde_json::{json, Value};
use crate::eda::{BoardRef, Caps, Eda, Transport};
pub struct AltiumEda {
/// The absolute `.PcbDoc` path on the box.
pub path: String,
}
fn ok(v: &Value) -> bool { v["success"].as_bool() == Some(true) }
/// the net the walkthrough selected last, for its "Zoom to Selected Objects"
static LAST_NET: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }
fn flip(p: &Value) -> Value { json!([f(&p[0]), -f(&p[1])]) }
/// A flow waypoint ("R1.1", [x,y], {x,y,layer} or {pad,layer}) as the Altium bridge's (Altium frame).
fn waypoint(w: &Value) -> Value {
if let Some(pad) = w.as_str() { return json!({"pad": pad}); }
if w.is_array() { return flip(w); }
let mut o = serde_json::Map::new();
if let Some(pad) = w["pad"].as_str() { o.insert("pad".into(), json!(pad)); }
if let (Some(x), Some(y)) = (w["x"].as_f64(), w["y"].as_f64()) { o.insert("x".into(), json!(x)); o.insert("y".into(), json!(-y)); }
if let Some(l) = w["layer"].as_str() { o.insert("layer".into(), json!(l)); }
Value::Object(o)
}
fn is_copper(layer: &str) -> bool { layer == "F.Cu" || layer == "B.Cu" || (layer.starts_with("In") && layer.ends_with(".Cu")) }
fn track_id(t: &Value) -> String {
format!("altium:t:{},{},{},{},{}", f(&t["start"]["x"]), f(&t["start"]["y"]), f(&t["end"]["x"]), f(&t["end"]["y"]), t["layerId"].as_i64().unwrap_or(1))
}
fn via_id(v: &Value) -> String { format!("altium:v:{},{}", f(&v["position"]["x"]), f(&v["position"]["y"])) }
/// The Altium bridge's routing state in the KiCad bridge's kicad_routing_state shape (plan frame).
pub fn to_kicad_state(a: &Value) -> Value {
if !ok(a) { return a.clone(); }
let segs: Vec<Value> = a["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| is_copper(t["layer"].as_str().unwrap_or(""))).map(|t| json!({
"start": {"x": t["start"]["x"], "y": -f(&t["start"]["y"])}, "end": {"x": t["end"]["x"], "y": -f(&t["end"]["y"])},
"layer": t["layer"], "net_name": t["net"], "width": t["width"], "uuid": track_id(t)})).collect();
let vias: Vec<Value> = a["vias"].as_array().cloned().unwrap_or_default().iter().map(|v| json!({
"position": {"x": v["position"]["x"], "y": -f(&v["position"]["y"])}, "net_name": v["net"], "size": v["size"], "drill": v["drill"], "uuid": via_id(v)})).collect();
let pads: Vec<Value> = a["pads"].as_array().cloned().unwrap_or_default().iter().map(|p| json!({
"name": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")), "netName": p["net"].as_str().unwrap_or(""), "x": p["x"], "y": -f(&p["y"])})).collect();
let elements: Vec<Value> = a["components"].as_array().cloned().unwrap_or_default().iter().map(|c| json!({
"name": c["ref"], "package": c["pattern"], "has3d": c["has3d"]})).collect();
json!({"success": true, "revision": a["revision"], "boardId": a["boardId"], "segments": segs, "vias": vias, "pads": pads,
"unconnected": a["unconnected"], "elements": elements, "source": "altium"})
}
impl AltiumEda {
fn read(&self, t: &dyn Transport) -> Value {
let r = t.send("altium_routing_state", &json!({"expectPath": self.path}));
// the schematic step leaves its sheet in front, and Altium reads only the current PCB: the
// first placement read failed this way for 2 minutes of retries (2026-10-20). Open the board, read again.
let not_current = r["errorCode"].as_str() == Some("no_current_board")
|| r["data"]["error"].as_str().map_or(false, |e| e.contains("no current PCB board"));
if !not_current { return r; }
let _ = t.send("altium_show_2d_board", &json!({"filePath": self.path}));
t.send("altium_routing_state", &json!({"expectPath": self.path}))
}
/// The guard every edit carries: the board, and the revision the flow last read.
fn guard(&self, args: &Value) -> Value {
let mut g = json!({"expectPath": self.path});
if let Some(r) = args["expectedRevision"].as_str().filter(|r| !r.is_empty()) { g["expectedRevision"] = json!(r); }
g
}
}
impl Eda for AltiumEda {
fn id(&self) -> &'static str { "altium" }
fn name(&self) -> &'static str { "Altium" }
fn board(&self) -> BoardRef { BoardRef::Altium(self.path.clone()) }
fn caps(&self) -> Caps {
Caps { full_drc: true, item_removal: true, net_highlight: false, ..Caps::default() }
}
fn routing_state(&self, t: &dyn Transport, _a: &Value) -> Value { to_kicad_state(&self.read(t)) }
fn zone_state(&self, t: &dyn Transport, a: &Value) -> Value {
if a["refill"].as_bool() == Some(true) {
let r = t.send("altium_repour", &json!({}));
if !ok(&r) { return r; }
}
let p = t.send("altium_pour_state", &json!({}));
if !ok(&p) { return p; }
let st = self.read(t);
let by = &p["filledAreaByLayerMm2"];
let zones: Vec<Value> = p["zones"].as_array().cloned().unwrap_or_default().iter().enumerate().map(|(i, z)| {
let l = z["layer"].as_str().unwrap_or("F.Cu");
json!({"id": format!("altium-pour-{i}"), "name": format!("{} pour", z["net"].as_str().unwrap_or("")), "net": z["net"], "layer": l, "layers": [l],
"type": "copper", "filledMm2": z["filledMm2"], "filled": z["filled"], "fills": [{"layer": l, "areaMm2": z["filledMm2"]}]})
}).collect();
json!({"success": true, "revision": st["revision"], "revisionUsable": st["revision"].is_string(),
"filledAreaByLayerMm2": {"F.Cu": by["F.Cu"].as_f64().unwrap_or(0.0), "B.Cu": by["B.Cu"].as_f64().unwrap_or(0.0)},
"zones": zones, "unfilled": p["unfilled"], "readbackEvidence": "Altium's own poured regions (altium_pour_state)"})
}
fn model_check(&self, t: &dyn Transport, _a: &Value) -> Value {
let st = self.read(t);
if !ok(&st) { return st; }
let comps = st["components"].as_array().cloned().unwrap_or_default();
let without: Vec<Value> = comps.iter().filter(|c| c["has3d"].as_bool() != Some(true)).map(|c| json!({"footprint": c["pattern"], "references": [c["ref"]], "models": [{"reference": "(no 3D body on the footprint)", "exists": false}]})).collect();
json!({"success": true, "data": {"counts": {"withModel": comps.len() - without.len(), "missingModel": without.len(), "unresolvedModel": 0, "noModelExpected": 0},
"footprintsWithWarnings": without, "source": "Altium: each component's 3D bodies"}})
}
fn status(&self, t: &dyn Transport) -> Value { t.send("altium_readiness", &json!({})) }
fn move_parts(&self, t: &dyn Transport, a: &Value) -> Value {
let parts: Vec<Value> = a["refs"].as_array().cloned().unwrap_or_default().iter()
.map(|m| json!({"ref": m["ref"], "x": m["x"], "y": -f(&m["y"]), "rotation": f(&m["rotation"])})).collect();
let mut g = self.guard(a);
g["parts"] = json!(parts);
let r = t.send("altium_move_parts", &g);
json!({"success": r["success"], "revision": r["revision"], "errorCode": r["errorCode"], "error": r["error"], "results": r["results"], "mismatches": r["mismatches"]})
}
fn route_net(&self, t: &dyn Transport, a: &Value) -> Value {
let mut g = self.guard(a);
g["net"] = a["net"].clone();
g["width"] = json!(a["width"].as_f64().unwrap_or(0.25));
if let Some(l) = a["layer"].as_str() { g["layer"] = json!(l); }
if let Some(v) = a["viaSize"].as_f64() { g["viaSize"] = json!(v); }
if let Some(v) = a["viaDrill"].as_f64() { g["viaDrill"] = json!(v); }
if let Some(ps) = a["paths"].as_array() { g["paths"] = json!(ps.iter().map(|p| json!(p.as_array().cloned().unwrap_or_default().iter().map(waypoint).collect::<Vec<_>>())).collect::<Vec<_>>()); }
else if let Some(ps) = a["points"].as_array() { g["paths"] = json!([ps.iter().map(waypoint).collect::<Vec<_>>()]); }
let r = t.send("altium_route_net", &g);
// the ids of what this edit added: the copper on the net after, less what was there before
let ids: Vec<String> = if ok(&r) {
let before = r["state"].is_object();
let after = to_kicad_state(&r["state"]);
let _ = before;
after["segments"].as_array().into_iter().flatten().chain(after["vias"].as_array().into_iter().flatten())
.filter(|o| o["net_name"] == a["net"]).filter_map(|o| o["uuid"].as_str().map(str::to_string)).collect()
} else { Vec::new() };
json!({"success": r["success"], "revision": r["revision"], "errorCode": r["errorCode"], "error": r["error"], "message": r["_hint"],
"net": a["net"], "itemIds": ids, "results": r["results"], "drc": {"unconnected": r["unconnected"]}})
}
fn remove_items(&self, t: &dyn Transport, a: &Value) -> Value {
let mut tracks = Vec::new();
let mut vias = Vec::new();
for id in a["itemIds"].as_array().cloned().unwrap_or_default().iter().filter_map(|v| v.as_str()) {
if let Some(r) = id.strip_prefix("altium:t:") {
let v: Vec<f64> = r.split(',').filter_map(|x| x.parse().ok()).collect();
if v.len() == 5 { tracks.push(json!({"start": {"x": v[0], "y": v[1]}, "end": {"x": v[2], "y": v[3]}, "layerId": v[4] as i64})); }
} else if let Some(r) = id.strip_prefix("altium:v:") {
let v: Vec<f64> = r.split(',').filter_map(|x| x.parse().ok()).collect();
if v.len() == 2 { vias.push(json!({"x": v[0], "y": v[1]})); }
}
}
if tracks.is_empty() && vias.is_empty() { return json!({"success": false, "errorCode": "no_item_ids", "error": "no Altium item ids (altium:t:... / altium:v:...) to remove"}); }
let mut g = self.guard(a);
g["tracks"] = json!(tracks);
g["vias"] = json!(vias);
let r = t.send("altium_remove_items", &g);
json!({"success": r["success"], "removed": r["applied"], "revision": r["revision"], "errorCode": r["errorCode"], "error": r["error"], "results": r["results"]})
}
fn apply_zone_plan(&self, t: &dyn Transport, a: &Value) -> Value {
let mut pours = Vec::new();
let mut cutouts = Vec::new();
let mut skipped = Vec::new();
let mut replace: Vec<Value> = Vec::new();
for z in a["zones"].as_array().cloned().unwrap_or_default() {
let pts: Vec<Value> = z["polygon"].as_array().or(z["outline"].as_array()).cloned().unwrap_or_default().iter().map(flip).collect();
let name = z["name"].as_str().unwrap_or("").to_string();
if z["type"].as_str() == Some("keepout") {
// a pour keepout is a polygon cutout on each of its copper layers; route and via keepouts
// are the router's to honour and are not landed
if z["keepout"]["copperPour"].as_bool() != Some(true) { skipped.push(json!({"name": name, "why": "only pour keepouts are landed in Altium (as polygon cutouts)"})); continue; }
if pts.len() < 3 { skipped.push(json!({"name": name, "why": "keepout without a polygon"})); continue; }
for l in z["layers"].as_array().cloned().unwrap_or_default() {
if let Some(ln) = l.as_str().filter(|l| is_copper(l)) { cutouts.push(json!({"layer": ln, "points": pts, "name": name})); }
}
continue;
}
if pts.len() < 3 { skipped.push(json!({"name": name, "why": "no polygon (an outline pour must be resolved to points before landing)"})); continue; }
let prio = z["priority"].as_i64().unwrap_or(0);
// KiCad's zone connection: solid joins pads full width, thermal with spokes (none = no connection)
let connect = match z["connection"].as_str() { Some("solid") => Some("direct"), Some("none") => Some("none"), Some("thermal") => Some("relief"), _ => None };
for l in z["layers"].as_array().cloned().unwrap_or_default() {
let Some(ln) = l.as_str().filter(|l| is_copper(l)) else { continue };
// Altium pours lower order first; KiCad's higher priority wins
let mut p = json!({"net": z["net"], "layer": ln, "points": pts, "order": (10 - prio).max(1), "name": format!("{name} {ln}")});
if let Some(c) = connect { p["connect"] = json!(c); }
if let Some(w) = z["thermalBridge"].as_f64() { p["reliefWidth"] = json!(w); }
if let Some(g) = z["thermalGap"].as_f64() { p["reliefGap"] = json!(g); }
if let Some(m) = z["minThickness"].as_f64() { p["minNeck"] = json!(m); }
pours.push(p);
let key = json!({"net": z["net"], "layer": ln});
if !replace.contains(&key) { replace.push(key); }
}
}
// A solid zone inside a larger same-net pour on its layer is KiCad's joint: its pads join that
// pour full width. Altium pours nothing for a same-net polygon inside another, so the joint
// rides on the host pour as solidPads (a direct-connect rule for the pads in its box).
let mut joints = Vec::new();
let mut keep = Vec::new();
for (i, p) in pours.iter().enumerate() {
let host = (p["connect"].as_str() == Some("direct")).then(|| pours.iter().enumerate().find(|(j, q)| *j != i
&& q["net"] == p["net"] && q["layer"] == p["layer"] && area(&q["points"]) > area(&p["points"])
&& p["points"].as_array().map(|a| a.iter().all(|v| inside(v, &q["points"]))).unwrap_or(false))).flatten();
match host { Some((j, _)) => joints.push((j, bbox(&p["points"]), p["name"].clone())), None => keep.push(i) }
}
for (j, b, _) in &joints {
let sp = pours[*j]["solidPads"].as_array().cloned().unwrap_or_default();
let mut sp = sp; sp.push(json!(b)); pours[*j]["solidPads"] = json!(sp);
}
let folded: Vec<Value> = joints.iter().map(|(j, _, n)| json!({"joint": n, "into": pours[*j]["name"]})).collect();
let pours: Vec<Value> = keep.iter().map(|&i| pours[i].clone()).collect();
if pours.is_empty() && cutouts.is_empty() { return json!({"success": true, "mutated": false, "skipped": skipped, "note": "nothing to pour in Altium"}); }
// the plan replaces the flow's pours on the same net and layer, and its keepouts (a refine pass pours again)
let mut g = self.guard(a);
if !replace.is_empty() { g["pours"] = json!(replace); }
g["cutouts"] = json!("all");
let rm = t.send("altium_remove_pours", &g);
if !ok(&rm) { return rm; }
let r = t.send("altium_apply_pours", &json!({"expectPath": self.path, "pours": pours, "cutouts": cutouts}));
json!({"success": r["success"], "mutated": true, "revision": r["revision"], "errorCode": r["errorCode"], "error": r["error"],
"itemIds": (0..pours.len()).map(|i| format!("altium-pour-{i}")).collect::<Vec<_>>(), "removedIds": [], "deletionVerified": true,
"zones": r["zones"], "unfilled": r["unfilled"], "skipped": skipped, "cutouts": cutouts.len(), "jointsFolded": folded, "notes": r["notes"], "results": r["results"]})
}
fn routing_validate(&self, t: &dyn Transport, _a: &Value) -> Value {
let r = t.send("altium_routing_drc", &json!({}));
let (errors, warnings) = split_severity(&r);
json!({"success": r["success"], "unconnected": r["unconnected"], "errors": errors.len(), "warnings": warnings.len(),
"clean": errors.is_empty() && r["unconnected"].as_u64().unwrap_or(0) == 0, "drc": r, "warningRules": rule_counts(&warnings), "source": "Altium online DRC"})
}
fn full_drc(&self, t: &dyn Transport, _a: &Value) -> Value {
let r = t.send("altium_routing_drc", &json!({"full": true}));
let (errors, warnings) = split_severity(&r);
let v: Vec<Value> = errors.iter().map(|x| json!({"type": x["rule"], "items": [x["description"]], "at": x["at"]})).collect();
json!({"success": r["success"], "violations": v, "unconnected": r["unconnected"], "errors": errors.len(), "warnings": warnings.len(),
"warningRules": rule_counts(&warnings), "source": "Altium batch DRC"})
}
fn open_board(&self, t: &dyn Transport, _a: &Value) -> Value {
let r = t.send("altium_show_2d_board", &json!({"filePath": self.path}));
let h = self.main_window(t);
json!({"success": ok(&r) && h.is_some(), "hwnd": h, "error": r["error"]})
}
fn window_info(&self, t: &dyn Transport, _a: &Value) -> Value {
let h = self.main_window(t);
json!({"success": true, "data": {"editors": h.map(|h| vec![json!({"kind": "pcb_editor", "hwnd": h, "title": self.path}), json!({"kind": "3d_viewer", "hwnd": h})]).unwrap_or_default()}})
}
fn send_key(&self, t: &dyn Transport, a: &Value) -> Value {
if a["menu"].as_str() == Some("Zoom to Selected Objects") {
let net = LAST_NET.lock().map(|n| n.clone()).unwrap_or_default();
if !net.is_empty() { return t.send("altium_zoom_to", &json!({"net": net, "margin": 1.0})); }
}
json!({"success": self.fit_board(t)})
}
fn select_net(&self, t: &dyn Transport, a: &Value) -> Value {
let net = a["net"].as_str().unwrap_or("").to_string();
let r = t.send("altium_select_net", &json!({"net": net}));
if ok(&r) { if let Ok(mut n) = LAST_NET.lock() { *n = net; } }
json!({"success": r["success"], "selected": r["selected"], "byKind": r["byKind"], "errorCode": r["errorCode"], "error": r["error"]})
}
fn select_clear(&self, t: &dyn Transport, _a: &Value) -> Value {
if let Ok(mut n) = LAST_NET.lock() { n.clear(); }
t.send("altium_select_net", &json!({}))
}
fn open_3d(&self, t: &dyn Transport, _a: &Value) -> Value {
let r = t.send("altium_show_3d_board", &json!({"filePath": self.path}));
json!({"success": r["success"], "hwnd": self.main_window(t)})
}
fn close_3d(&self, t: &dyn Transport, _a: &Value) -> Value { t.send("altium_show_2d_board", &json!({"filePath": self.path})) }
fn tour_3d(&self, t: &dyn Transport, a: &Value) -> Value {
t.send("altium_animate_view", &json!({"preset": "showcase3d", "pauseMs": a["pauseMs"].as_u64().unwrap_or(700)}))
}
fn screenshot_all(&self, t: &dyn Transport, _a: &Value) -> Value {
// Altium's view paints whenever it is shown; the main window is reported as rendered and marked as assumed
let h = self.main_window(t);
json!({"success": true, "data": {"screenshots": h.map(|h| vec![json!({"hwnd": h, "canvas": {"rendered": true, "distinctColors": 64, "assumed": true}})]).unwrap_or_default()}})
}
/// Round a rectangular board's corners: the same rectangle with arcs (altium_set_outline). A board
/// whose outline is not an axis-aligned rectangle refuses; round it in Altium.
fn round_corners(&self, t: &dyn Transport, corners: &[(f64, f64)], radius: f64) -> Value {
let st = self.read(t);
if !ok(&st) { return st; }
let edges = st["outlineEdges"].as_array().cloned().unwrap_or_default();
let straight = edges.iter().all(|e| e["kind"].as_str() == Some("line"));
let pts: Vec<(f64, f64)> = edges.iter().map(|e| (f(&e["start"][0]), f(&e["start"][1]))).collect();
let axis = edges.iter().all(|e| (f(&e["start"][0]) - f(&e["end"][0])).abs() < 1e-3 || (f(&e["start"][1]) - f(&e["end"][1])).abs() < 1e-3);
if !straight || edges.len() != 4 || !axis {
return json!({"success": false, "errorCode": "unsupported_in_altium", "error": "outline round lands live in Altium for a rectangular outline only; this one is not a plain rectangle"});
}
let (x0, x1) = (pts.iter().map(|p| p.0).fold(f64::MAX, f64::min), pts.iter().map(|p| p.0).fold(f64::MIN, f64::max));
let (y0, y1) = (pts.iter().map(|p| p.1).fold(f64::MAX, f64::min), pts.iter().map(|p| p.1).fold(f64::MIN, f64::max));
let r = t.send("altium_set_outline", &json!({"expectPath": self.path, "expectedRevision": st["revision"], "outline": {"rect": [x0, y0, x1 - x0, y1 - y0], "cornerRadius": radius}}));
json!({"success": r["success"], "rounded": if ok(&r) { corners.len() } else { 0 }, "error": r["error"], "errorCode": r["errorCode"]})
}
/// The fab pack's floors as Altium design rules (altium_set_design_rules).
fn load_design_rules(&self, t: &dyn Transport, _fab: &str, _layers: u32, dru: &str) -> Option<Value> {
let mut a = crate::eda::fab_numbers(dru);
if a.as_object().map(|o| o.is_empty()).unwrap_or(true) { return Some(json!({"success": false, "errorCode": "no_rules", "error": "the fab rule pack names no constraints Altium can load"})); }
let _ = t.send("altium_show_2d_board", &json!({"filePath": self.path}));
a["expectPath"] = json!(self.path);
Some(t.send("altium_set_design_rules", &a))
}
fn main_window(&self, t: &dyn Transport) -> Option<i64> {
let w = t.send("desktop_list_windows", &json!({}));
let out = w.get("output").and_then(|o| o.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok()).unwrap_or(w.clone());
let list = out["data"]["windows"].as_array().or(out["windows"].as_array()).cloned().unwrap_or_default();
list.iter().find(|x| x["image"].as_str().map(|i| i.eq_ignore_ascii_case("X2.EXE")).unwrap_or(false) && x["kind"].as_str() == Some("normal")
&& x["title"].as_str().map(|t| t.contains("Altium")).unwrap_or(false)).and_then(|x| x["hwnd"].as_i64())
}
fn fit_board(&self, t: &dyn Transport) -> bool {
// Action=Board left the view on one corner (AD 26.11.1, 2026-10-15); All frames the board
ok(&t.send("altium_run_command", &json!({"command": "PCB:Zoom", "params": "Action=All"})))
}
/// `board-live.altium.json` (the bridge's own read, fills included) and `board-live.kicad_pcb`.
fn live_snapshot(&self, t: &dyn Transport, dir: &std::path::Path) -> Option<Result<(String, String), String>> {
let st = self.read(t);
if !ok(&st) { return Some(Err(format!("could not read the live Altium board {}: {} (open it: altium_show_2d_board {{filePath}})", self.path, st["error"]))); }
let pours = t.send("altium_pour_state", &json!({}));
// the silkscreen too: finish counts the labels and the field app draws them
let silk = t.send("altium_silk_state", &json!({}));
// projectDir: the run's Altium project, where each part's source KiCad footprint gives the
// plan board its courtyard and fab outline (the mechanical checks need them)
let project = dir.join("altium");
let project = if project.join("project.json").is_file() { json!(std::fs::canonicalize(&project).unwrap_or(project).display().to_string()) } else { Value::Null };
let src = json!({"source": "altium-bridge", "state": st, "pours": if ok(&pours) { pours } else { Value::Null }, "silk": if ok(&silk) { silk } else { Value::Null }, "projectDir": project});
let native = dir.join("board-live.altium.json");
let kicad = dir.join("board-live.kicad_pcb");
let text = src.to_string();
let plan = match render_plan_board(&text) { Ok(p) => p, Err(e) => return Some(Err(format!("live Altium board: {e}"))) };
if let Err(e) = std::fs::write(&native, text).and_then(|_| std::fs::write(&kicad, plan)) { return Some(Err(e.to_string())); }
// the file the field solver reads is the plan board (adom-fields reads KiCad and EAGLE boards, not
// the bridge's JSON); the JSON stays beside it as the native record
Some(Ok((kicad.display().to_string(), kicad.display().to_string())))
}
}
/// The plan board renderer lives with the board model (aiflow_board::altium).
pub use aiflow_board::altium::{is_altium, render_plan_board};
/// Altium reports every violation alike; KiCad grades them. The silkscreen and mask-sliver rules are
/// KiCad's warnings (silk_overlap, silk_over_copper, ...): reported, not a failed board. Everything else
/// (clearance, short, width, holes, annular ring, edge) is an error.
const WARNING_RULES: [&str; 5] = ["SilkToSolderMaskClearance", "SilkToSilkClearance", "SilkToBoardRegionClearance", "MinimumSolderMaskSliver", "Height"];
fn split_severity(r: &Value) -> (Vec<Value>, Vec<Value>) {
r["violations"].as_array().cloned().unwrap_or_default().into_iter()
.partition(|x| !WARNING_RULES.contains(&x["rule"].as_str().unwrap_or("")))
}
fn rule_counts(v: &[Value]) -> Value {
let mut m = serde_json::Map::new();
for x in v { let k = x["rule"].as_str().unwrap_or("?").to_string(); let n = m.get(&k).and_then(|c| c.as_u64()).unwrap_or(0); m.insert(k, json!(n + 1)); }
Value::Object(m)
}
fn pts(v: &Value) -> Vec<(f64, f64)> {
v.as_array().cloned().unwrap_or_default().iter().map(|p| (p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0))).collect()
}
fn area(v: &Value) -> f64 {
let p = pts(v);
(0..p.len()).map(|i| { let (a, b) = (p[i], p[(i + 1) % p.len()]); a.0 * b.1 - b.0 * a.1 }).sum::<f64>().abs() / 2.0
}
/// a point inside (or on the edge of) a polygon
fn inside(pt: &Value, poly: &Value) -> bool {
let (x, y) = (pt[0].as_f64().unwrap_or(0.0), pt[1].as_f64().unwrap_or(0.0));
let p = pts(poly);
let mut c = false;
for i in 0..p.len() {
let (a, b) = (p[i], p[(i + 1) % p.len()]);
// on the edge counts as inside
let cross = (b.0 - a.0) * (y - a.1) - (b.1 - a.1) * (x - a.0);
if cross.abs() < 1e-9 && x >= a.0.min(b.0) - 1e-9 && x <= a.0.max(b.0) + 1e-9 && y >= a.1.min(b.1) - 1e-9 && y <= a.1.max(b.1) + 1e-9 { return true; }
if (a.1 > y) != (b.1 > y) && x < (b.0 - a.0) * (y - a.1) / (b.1 - a.1) + a.0 { c = !c; }
}
c
}
fn bbox(v: &Value) -> [f64; 4] {
let p = pts(v);
let f = |g: fn(f64, f64) -> f64, s: f64, k: usize| p.iter().map(|q| if k == 0 { q.0 } else { q.1 }).fold(s, g);
[f(f64::min, f64::MAX, 0), f(f64::min, f64::MAX, 1), f(f64::max, f64::MIN, 0), f(f64::max, f64::MIN, 1)]
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
fn board() -> Value {
json!({"source": "altium-bridge", "state": {"success": true, "revision": "r1", "boardId": "C:/b.PcbDoc", "nets": ["GND", "VIN"], "unconnected": 1,
"components": [{"ref": "R1", "x": 10.0, "y": 5.0, "rotation": 90.0, "side": "top", "pattern": "R0402", "has3d": true}],
"pads": [{"ref": "R1", "name": "1", "x": 10.0, "y": 4.5, "sizeX": 0.5, "sizeY": 0.6, "rotation": 90.0, "layer": "F.Cu", "drill": 0.0, "net": "GND", "shape": "rect"},
{"ref": "R1", "name": "2", "x": 10.0, "y": 5.5, "sizeX": 0.5, "sizeY": 0.6, "rotation": 90.0, "layer": "F.Cu", "drill": 0.0, "net": "VIN", "shape": "roundrect"}],
"tracks": [{"start": {"x": 10.0, "y": 5.5}, "end": {"x": 15.0, "y": 5.5}, "width": 0.3, "layer": "F.Cu", "layerId": 1, "net": "VIN"},
{"start": {"x": 0.0, "y": 0.0}, "end": {"x": 1.0, "y": 0.0}, "width": 0.1, "layer": "L57", "layerId": 57, "net": ""}],
"arcs": [], "vias": [{"position": {"x": 12.0, "y": 3.0}, "size": 0.6, "drill": 0.3, "net": "GND"}], "polygons": [],
"outlineEdges": [{"kind": "line", "start": [0, 0], "end": [20, 0]}, {"kind": "line", "start": [20, 0], "end": [20, 10]}, {"kind": "line", "start": [20, 10], "end": [0, 10]}, {"kind": "line", "start": [0, 10], "end": [0, 0]}]},
"pours": {"zones": [{"net": "GND", "layer": "B.Cu", "outline": [[0, 0], [20, 0], [20, 10], [0, 10]], "fills": [{"outline": [[0.5, 0.5], [19.5, 0.5], [19.5, 9.5], [0.5, 9.5]], "holes": [[[5, 5], [6, 5], [6, 6], [5, 6]]]}]}]}})
}
#[test]
fn renders_a_plan_board_the_board_model_reads() {
let text = render_plan_board(&board().to_string()).unwrap();
let b = aiflow_board::Board::from_text("altium-test.kicad_pcb", text).expect("the plan board parses");
// pads land where Altium has them, y negated
let p = b.pads.iter().find(|p| p.key == "R1.1").unwrap();
assert!((p.x - 10.0).abs() < 1e-6 && (p.y + 4.5).abs() < 1e-6, "pad at {},{}", p.x, p.y);
assert_eq!(p.net, "GND");
// only copper tracks come along; the mechanical-layer line does not
assert_eq!(b.tracks().len(), 1);
assert_eq!(b.vias().len(), 1);
assert_eq!(b.zones().len(), 1);
}
#[test]
fn a_read_with_the_schematic_in_front_opens_the_board() {
let sent = RefCell::new(Vec::new());
let t = |v: &str, a: &Value| {
sent.borrow_mut().push(v.to_string());
let shown = sent.borrow().iter().any(|s| s == "altium_show_2d_board");
if v == "altium_routing_state" && !shown { return json!({"status": "error", "error": "", "data": {"ok": false, "error": "no current PCB board"}}); }
json!({"success": true, "revision": "r3", "path": a["filePath"]})
};
let e = AltiumEda { path: "C:/b.PcbDoc".into() };
assert_eq!(e.read(&t)["revision"], json!("r3"));
assert_eq!(*sent.borrow(), vec!["altium_routing_state", "altium_show_2d_board", "altium_routing_state"]);
// any other failure is answered as is, without touching the documents
let other = |v: &str, _a: &Value| { sent.borrow_mut().push(v.to_string()); json!({"status": "error", "errorCode": "agent_busy"}) };
sent.borrow_mut().clear();
assert_eq!(e.read(&other)["errorCode"], json!("agent_busy"));
assert_eq!(sent.borrow().len(), 1);
}
#[test]
fn edits_go_to_altium_in_its_own_frame() {
let sent = RefCell::new(Vec::new());
let t = |v: &str, a: &Value| { sent.borrow_mut().push((v.to_string(), a.clone())); json!({"success": true, "revision": "r2", "state": {"success": true, "tracks": [], "vias": []}}) };
let e = AltiumEda { path: "C:/b.PcbDoc".into() };
e.move_parts(&t, &json!({"expectedRevision": "r1", "refs": [{"ref": "R1", "x": 10.0, "y": -5.0, "rotation": 90.0}]}));
e.route_net(&t, &json!({"expectedRevision": "r1", "net": "VIN", "width": 0.3, "paths": [["R1.2", [15.0, -5.5], {"x": 15.0, "y": -7.0, "layer": "B.Cu"}]]}));
let s = sent.borrow();
assert_eq!(s[0].0, "altium_move_parts");
assert_eq!(s[0].1["parts"][0]["y"], json!(5.0));
assert_eq!(s[0].1["expectPath"], json!("C:/b.PcbDoc"));
assert_eq!(s[1].0, "altium_route_net");
assert_eq!(s[1].1["paths"][0][0], json!({"pad": "R1.2"}));
assert_eq!(s[1].1["paths"][0][1], json!([15.0, 5.5]));
assert_eq!(s[1].1["paths"][0][2], json!({"x": 15.0, "y": 7.0, "layer": "B.Cu"}));
}
#[test]
fn item_ids_take_back_single_tracks_and_vias() {
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 e = AltiumEda { path: "C:/b.PcbDoc".into() };
let st = to_kicad_state(&json!({"success": true, "tracks": [{"start": {"x": 1.0, "y": 2.0}, "end": {"x": 3.0, "y": 2.0}, "width": 0.2, "layer": "F.Cu", "layerId": 1, "net": "A"}],
"vias": [{"position": {"x": 3.0, "y": 2.0}, "size": 0.6, "drill": 0.3, "net": "A"}], "pads": [], "components": []}));
let ids: Vec<Value> = st["segments"].as_array().unwrap().iter().chain(st["vias"].as_array().unwrap()).map(|o| o["uuid"].clone()).collect();
e.remove_items(&t, &json!({"itemIds": ids}));
let s = sent.borrow();
assert_eq!(s[0].1["tracks"][0], json!({"start": {"x": 1.0, "y": 2.0}, "end": {"x": 3.0, "y": 2.0}, "layerId": 1}));
assert_eq!(s[0].1["vias"][0], json!({"x": 3.0, "y": 2.0}));
}
#[test]
fn joint_folds_into_its_host_pour() {
let sq = |x0: f64, y0: f64, x1: f64, y1: f64| json!([[x0, y0], [x1, y0], [x1, y1], [x0, y1]]);
assert!(inside(&json!([1.0, 1.0]), &sq(0.0, 0.0, 2.0, 2.0)));
assert!(inside(&json!([2.0, 1.0]), &sq(0.0, 0.0, 2.0, 2.0)));
assert!(!inside(&json!([3.0, 1.0]), &sq(0.0, 0.0, 2.0, 2.0)));
assert_eq!(bbox(&sq(1.0, 2.0, 3.0, 5.0)), [1.0, 2.0, 3.0, 5.0]);
assert_eq!(area(&sq(0.0, 0.0, 2.0, 3.0)), 6.0);
}
}