app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
main
Colby Knox
0.2.9: the dedicated freeze-fix release (#6, #7, #8) - workspace-union teardown/status, bootstrap refuses legacy fallba…
9f4e1af
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
//! Reading the board: one primitive, many typed views.
//!
//! The point of the Rust bridge: the DelphiScript agent stops shaping JSON per verb and emits a few
//! one generic read; the bridge derives every view from it. That moves the fragile part (parsing,
//! tallies, filters, sorting) out of a language where a typo halts the engine behind a modal on the
//! user's screen, into one that is typed, unit-tested, and changeable without restarting Altium.
//!
//! `read_board` is ONE agent round trip for components + nets + layers + copper + rules. Five verbs
//! that used to cost five round trips (~50 ms each, plus Altium's own work) now cost one, and the
//! views below are pure functions over the result — which is why they can be tested against a
//! recorded fixture with no Altium anywhere (see `fixtures/`).
use serde_json::{json, Value};
/// A recorded or live `read_board` payload (the agent's `data` object).
#[derive(Clone, Debug)]
pub struct BoardData(pub Value);
impl BoardData {
pub fn new(data: Value) -> Self {
BoardData(data)
}
/// The agent composes sub-envelopes (`{ok, ...}`) per section; unwrap one by name.
fn section(&self, name: &str) -> Option<&Value> {
self.0.get(name).filter(|s| s.get("ok").and_then(|b| b.as_bool()).unwrap_or(false))
}
pub fn board_path(&self) -> Option<&str> {
self.0.get("board").and_then(|v| v.as_str())
}
fn array(&self, section: &str, key: &str) -> Vec<Value> {
self.section(section)
.and_then(|s| s.get(key))
.and_then(|a| a.as_array())
.cloned()
.unwrap_or_default()
}
// ---- views ---------------------------------------------------------------
pub fn components(&self) -> Vec<Value> {
self.array("components", "components")
}
/// Designators, sorted naturally (U2 before U10 — plain string order gets this wrong, and a
/// BOM or a review list read in the wrong order is the kind of thing nobody notices).
pub fn designators(&self) -> Vec<String> {
let mut d: Vec<String> = self
.components()
.iter()
.filter_map(|c| c.get("designator").or_else(|| c.get("name")).and_then(|v| v.as_str()).map(str::to_string))
.collect();
d.sort_by(|a, b| natural_cmp(a, b));
d
}
pub fn nets(&self) -> Vec<Value> {
self.array("nets", "nets")
}
pub fn layers(&self) -> Vec<Value> {
self.array("layers", "layers")
}
/// Copper layers only, in stack order, ignoring the ones Altium reports as not present.
pub fn copper_layers(&self) -> Vec<Value> {
self.array("copper", "layers")
.into_iter()
.filter(|l| l.get("used").and_then(|b| b.as_bool()).unwrap_or(true))
.collect()
}
pub fn rules(&self) -> Vec<Value> {
self.array("rules", "rules")
}
/// Enabled rules grouped by kind NAME, each group sorted by priority — the shape a design
/// review wants. The agent emits Altium's numeric `TRuleKind` ordinal; the mapping below was
/// read off a live default rule set (each default rule is named after its own kind), so it is
/// derived from data rather than guessed. A user-RENAMED rule keeps its ordinal, so the group
/// label stays correct even when the rule name no longer matches.
pub fn rules_by_kind(&self) -> Value {
let mut kinds: std::collections::BTreeMap<String, Vec<Value>> = Default::default();
for r in self.rules() {
if r.get("enabled").and_then(|b| b.as_bool()) == Some(false) {
continue;
}
let label = r
.get("kind")
.and_then(|k| k.as_i64())
.map(rule_kind_name)
.unwrap_or_else(|| "Unknown".to_string());
kinds.entry(label).or_default().push(r);
}
for v in kinds.values_mut() {
v.sort_by_key(|r| r.get("priority").and_then(|p| p.as_i64()).unwrap_or(i64::MAX));
}
json!(kinds)
}
/// Every layer carrying a pad/track/arc, densest first — copper AND silkscreen AND mechanical.
/// The agent tallies primitives by layer in one pass; this is the raw occupancy.
pub fn layers_with_primitives(&self) -> Vec<Value> {
let mut v = self.array("occupancy", "occupancy");
v.sort_by_key(|l| std::cmp::Reverse(l.get("primitives").and_then(|p| p.as_i64()).unwrap_or(0)));
v
}
/// The layer ids Altium reports as COPPER (top + mid + internal planes + bottom). Taken from
/// the dump's own `copper` section rather than assumed from id ranges — the ranges are an
/// implementation detail of the enum and not something to hardcode.
fn copper_layer_ids(&self) -> std::collections::BTreeSet<i64> {
self.copper_layers().iter().filter_map(|l| l.get("id").and_then(|i| i.as_i64())).collect()
}
/// COPPER layers that actually carry copper, densest first — the honest answer to "how many
/// layers is this board".
///
/// Two caveats the caller must not lose: an inner plane with no primitives is invisible here
/// (so this is a floor, not the stack), and the first cut of this view forgot to filter by
/// copper at all and cheerfully reported silkscreen and mechanical layers as board layers.
pub fn copper_layers_in_use(&self) -> Vec<Value> {
let copper = self.copper_layer_ids();
self.layers_with_primitives()
.into_iter()
.filter(|l| l.get("layerId").and_then(|i| i.as_i64()).map(|i| copper.contains(&i)).unwrap_or(false))
.collect()
}
/// One-screen summary: what is on this board.
pub fn summary(&self) -> Value {
let comps = self.components();
let (mut top, mut bottom) = (0usize, 0usize);
for c in &comps {
if c.get("bottom").and_then(|b| b.as_bool()).unwrap_or(false) { bottom += 1 } else { top += 1 }
}
let rules = self.rules();
json!({
"board": self.board_path(),
"components": comps.len(),
"componentsTop": top,
"componentsBottom": bottom,
"nets": self.nets().len(),
// NOTE: the agent enumerates every copper layer Altium CAN address (top + 30 mid +
// 16 planes + bottom), not the stack actually in use — there is no "in stack" flag in
// the read yet, so this is a capability count, not a layer count. Do not report it to
// a user as "this board has N layers".
// layers actually carrying copper (a floor — an empty inner plane is invisible here);
// `copperLayersAddressable` is what Altium CAN address, which is not a board property
"copperLayersInUse": self.copper_layers_in_use().len(),
"copperLayersInUseNames": self.copper_layers_in_use().iter()
.filter_map(|l| l.get("name").and_then(|n| n.as_str()).map(str::to_string)).collect::<Vec<_>>(),
"layersWithPrimitives": self.layers_with_primitives().len(),
"copperLayersAddressable": self.copper_layers().len(),
"rules": rules.len(),
"rulesEnabled": rules.iter().filter(|r| r.get("enabled").and_then(|b| b.as_bool()) != Some(false)).count(),
"ruleKinds": self.rules_by_kind().as_object().map(|o| o.len()).unwrap_or(0),
})
}
}
/// A recorded or live `read_schematic` payload (the agent's `data` object): the CURRENT sheet's
/// components with pins and params. Same envelope rules as `BoardData` — the agent nests the
/// section reader's own `{ok, count, components}` under the section name.
#[derive(Clone, Debug)]
pub struct SchematicData(pub Value);
impl SchematicData {
pub fn new(data: Value) -> Self {
SchematicData(data)
}
pub fn document(&self) -> Option<&str> {
self.0.get("document").and_then(|v| v.as_str())
}
pub fn components(&self) -> Vec<Value> {
self.0
.get("components")
.filter(|s| s.get("ok").and_then(|b| b.as_bool()).unwrap_or(false))
.and_then(|s| s.get("components"))
.and_then(|a| a.as_array())
.cloned()
.unwrap_or_default()
}
/// Designators, naturally sorted and de-duplicated (a multi-part symbol appears once per part).
pub fn designators(&self) -> Vec<String> {
let mut d: Vec<String> = self
.components()
.iter()
.filter_map(|c| c.get("designator").and_then(|v| v.as_str()).map(str::to_string))
.collect();
d.sort_by(|a, b| natural_cmp(a, b));
d.dedup();
d
}
}
/// Windows folder of a document path, lowercased — `sameProject` compares folders because the
/// SchServer and PCBServer each hold their own "current document" and the two can genuinely
/// belong to different projects.
fn folder_of(p: Option<&str>) -> Option<String> {
let q = p?.replace('/', "\\");
q.rsplit_once('\\').map(|(dir, _)| dir.to_lowercase())
}
/// Schematic sheet vs board: what is drawn but not placed, and placed but not drawn — which
/// neither read answers alone. Pure function over the two reads (the verb does the activation
/// dance, because Altium cannot hold both documents current at once). Two caveats travel WITH
/// the answer because both make it wrong to act on silently: the schematic side is ONE sheet,
/// and the two currents can belong to different projects (`sameProject: false`).
pub fn cross_check(sheet: &SchematicData, board: &BoardData) -> Value {
let sch: std::collections::BTreeSet<String> = sheet.designators().into_iter().collect();
let pcb: std::collections::BTreeSet<String> = board.designators().into_iter().collect();
let sort = |s: Vec<String>| {
let mut v = s;
v.sort_by(|a, b| natural_cmp(a, b));
v
};
let on_sheet_only = sort(sch.difference(&pcb).cloned().collect());
let on_board_only = sort(pcb.difference(&sch).cloned().collect());
let same = match (folder_of(sheet.document()), folder_of(board.board_path())) {
(Some(a), Some(b)) => json!(a == b),
_ => Value::Null,
};
let mut out = json!({
"sheet": sheet.document(),
"board": board.board_path(),
"sameProject": same,
"matched": sch.intersection(&pcb).count(),
"onSheetNotOnBoard": on_sheet_only,
"onBoardNotOnSheet": on_board_only,
"schematicComponents": sheet.components().len(),
"boardComponents": board.components().len(),
"_scope": "The schematic side is ONE sheet. On a hierarchical design, run this per leaf sheet before calling anything 'missing from the schematic'.",
});
if out.get("sameProject") == Some(&json!(false)) {
out["_warning"] = json!(
"The sheet and the board are in DIFFERENT folders, so they are probably not the same project — which makes every difference above meaningless."
);
}
out
}
/// Altium `TRuleKind` ordinals, read off a live AD26 default rule set (2026-09-01): every default
/// rule is named after its own kind, which makes the mapping observable rather than guessed.
/// Unknown ordinals are rendered as `kind <n>` — never invented.
pub fn rule_kind_name(k: i64) -> String {
let name = match k {
0 => "Clearance", 2 => "Width", 6 => "PlaneConnect", 7 => "RoutingTopology",
8 => "RoutingPriority", 9 => "RoutingLayers", 10 => "RoutingCorners", 11 => "RoutingVias",
12 => "PlaneClearance", 13 => "SolderMaskExpansion", 14 => "PasteMaskExpansion",
15 => "ShortCircuit", 16 => "UnRoutedNet", 20 => "PolygonConnect",
24 => "ComponentClearance", 42 => "HoleSize", 43 => "FabricationTestpoint",
44 => "FabricationTestPointUsage", 48 => "LayerPairs", 49 => "Fanout", 50 => "Height",
51 => "DiffPairsRouting", 52 => "HoleToHoleClearance", 53 => "MinimumSolderMaskSliver",
54 => "SilkToSolderMaskClearance", 55 => "SilkToSilkClearance", 56 => "NetAntennae",
57 => "AssemblyTestpoint", 58 => "AssemblyTestPointUsage",
59 => "SilkToBoardRegionClearance", 62 => "UnpouredPolygon",
_ => "",
};
if name.is_empty() { format!("kind {k}") } else { name.to_string() }
}
/// U2 before U10, R1 before R2 before R10 — compare digit runs numerically.
pub fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
let (mut x, mut y) = (a.chars().peekable(), b.chars().peekable());
loop {
match (x.peek().copied(), y.peek().copied()) {
(None, None) => return std::cmp::Ordering::Equal,
(None, _) => return std::cmp::Ordering::Less,
(_, None) => return std::cmp::Ordering::Greater,
(Some(ca), Some(cb)) => {
if ca.is_ascii_digit() && cb.is_ascii_digit() {
let na: String = std::iter::from_fn(|| x.next_if(|c| c.is_ascii_digit())).collect();
let nb: String = std::iter::from_fn(|| y.next_if(|c| c.is_ascii_digit())).collect();
let (va, vb) = (na.parse::<u64>().unwrap_or(0), nb.parse::<u64>().unwrap_or(0));
if va != vb {
return va.cmp(&vb);
}
} else {
if ca != cb {
return ca.cmp(&cb);
}
x.next();
y.next();
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> BoardData {
// Recorded from a live board; see fixtures/README.md for how to re-record.
let raw = include_str!("../fixtures/read_board.json");
let v: Value = serde_json::from_str(raw).expect("fixture parses");
BoardData::new(v.get("data").cloned().unwrap_or(v))
}
#[test]
fn natural_order_beats_string_order() {
let mut v = vec!["U10", "U2", "R1", "C10", "C9"];
v.sort_by(|a, b| natural_cmp(a, b));
assert_eq!(v, vec!["C9", "C10", "R1", "U2", "U10"]);
}
#[test]
fn views_read_the_recorded_board() {
let d = fixture();
assert!(d.board_path().unwrap().ends_with(".PcbDoc"));
let s = d.summary();
assert_eq!(s.get("rules").and_then(|v| v.as_u64()), Some(d.rules().len() as u64));
assert_eq!(
s.get("components").and_then(|v| v.as_u64()).unwrap(),
s.get("componentsTop").and_then(|v| v.as_u64()).unwrap()
+ s.get("componentsBottom").and_then(|v| v.as_u64()).unwrap(),
"every component lands on exactly one side"
);
}
#[test]
fn every_rule_in_the_fixture_maps_to_a_named_kind() {
// 35 rules / 31 ordinals on a live AD26 default rule set — if Altium adds a kind we do not
// know, this fails loudly instead of silently labelling it "Unknown".
let d = fixture();
let by = d.rules_by_kind();
let unnamed: Vec<&String> = by.as_object().unwrap().keys().filter(|k| k.starts_with("kind ")).collect();
assert!(unnamed.is_empty(), "unmapped TRuleKind ordinals: {unnamed:?}");
// the five Fanout defaults (BGA/LCC/SOIC/Small/Default) all carry ordinal 49 — the one
// many-to-one group in the default set, and the reason grouping by ordinal beats by name
assert_eq!(by.get("Fanout").map(|f| f.as_array().unwrap().len()), Some(5));
assert!(by.get("Clearance").is_some());
}
#[test]
fn copper_layers_in_use_excludes_silkscreen_and_mechanical() {
// measured on a live board: the raw tally is dominated by a MECHANICAL layer
// (Adom.SolderBalls, 296 prims) — reporting that as a board layer would be wrong
let d = BoardData::new(json!({
"copper": {"ok": true, "layers": [{"id": 1, "name": "Top Layer"}, {"id": 32, "name": "Bottom Layer"}]},
"occupancy": {"ok": true, "occupancy": [
{"layerId": 65, "name": "Adom.SolderBalls", "primitives": 296},
{"layerId": 1, "name": "Top Layer", "primitives": 51},
{"layerId": 33, "name": "Top Overlay", "primitives": 9},
{"layerId": 32, "name": "Bottom Layer", "primitives": 4}
]}
}));
assert_eq!(d.layers_with_primitives().len(), 4, "raw tally keeps everything");
let cu = d.copper_layers_in_use();
assert_eq!(cu.len(), 2, "only copper survives");
assert_eq!(cu[0].get("name").unwrap(), "Top Layer", "densest copper first");
let s = d.summary();
assert_eq!(s.get("copperLayersInUse").and_then(|v| v.as_u64()), Some(2));
assert_eq!(s.get("layersWithPrimitives").and_then(|v| v.as_u64()), Some(4));
}
#[test]
fn an_unknown_ordinal_is_labelled_not_invented() {
assert_eq!(rule_kind_name(0), "Clearance");
assert_eq!(rule_kind_name(999), "kind 999");
}
#[test]
fn rules_by_kind_drops_disabled_and_sorts_by_priority() {
let d = BoardData::new(json!({
"rules": {"ok": true, "rules": [
{"name": "b", "kind": 0, "priority": 2, "enabled": true},
{"name": "a", "kind": 0, "priority": 1, "enabled": true},
{"name": "off", "kind": 0, "priority": 0, "enabled": false},
{"name": "w", "kind": 2, "priority": 1, "enabled": true}
]}
}));
let by = d.rules_by_kind();
let cl = by.get("Clearance").unwrap().as_array().unwrap();
assert_eq!(cl.len(), 2, "disabled rule dropped");
assert_eq!(cl[0].get("name").unwrap(), "a", "sorted by priority");
assert!(by.get("Width").is_some());
}
#[test]
fn a_failed_section_yields_an_empty_view_not_a_panic() {
// the agent reports {ok:false, error} per section when e.g. no board is current
let d = BoardData::new(json!({"components": {"ok": false, "error": "no current PCB board"}}));
assert!(d.designators().is_empty());
assert_eq!(d.summary().get("components").and_then(|v| v.as_u64()), Some(0));
}
fn sheet(des: &[&str], doc: &str) -> SchematicData {
SchematicData::new(json!({
"ok": true, "document": doc,
"components": {"ok": true,
"components": des.iter().map(|d| json!({"designator": d})).collect::<Vec<_>>()}
}))
}
fn small_board(des: &[&str], path: &str) -> BoardData {
BoardData::new(json!({
"board": path,
"components": {"ok": true,
"components": des.iter().map(|d| json!({"designator": d})).collect::<Vec<_>>()}
}))
}
#[test]
fn cross_check_reports_both_directions_naturally_sorted() {
let s = sheet(&["U1", "U10", "U2", "R1"], "C:\\proj\\main.SchDoc");
let b = small_board(&["U1", "U2", "C5"], "C:\\proj\\main.PcbDoc");
let out = cross_check(&s, &b);
assert_eq!(out["matched"], json!(2));
assert_eq!(out["onSheetNotOnBoard"], json!(["R1", "U10"]));
assert_eq!(out["onBoardNotOnSheet"], json!(["C5"]));
assert_eq!(out["sameProject"], json!(true));
assert!(out.get("_warning").is_none());
}
#[test]
fn cross_check_warns_on_different_folders() {
let s = sheet(&["U1"], "C:\\projA\\main.SchDoc");
let b = small_board(&["U1"], "C:\\projB\\main.PcbDoc");
let out = cross_check(&s, &b);
assert_eq!(out["sameProject"], json!(false));
assert!(out.get("_warning").is_some(), "different folders must warn loudly");
}
#[test]
fn schematic_failed_section_is_empty_not_a_panic() {
let s = SchematicData::new(json!({"components": {"ok": false, "error": "no current schematic"}}));
assert!(s.components().is_empty());
assert!(s.designators().is_empty());
// and sameProject stays null when either path is missing
let out = cross_check(&s, &small_board(&["U1"], "C:\\p\\b.PcbDoc"));
assert_eq!(out["sameProject"], Value::Null);
}
}