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
John Lauer
v0.1.15: dump_board primitive, rust cold bootstrap proven, dump views with recorded fixtures
955dfe9
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
//! Dump primitives → typed views.
//!
//! The point of the Rust bridge: the DelphiScript agent stops shaping JSON per verb and emits a few
//! generic dumps; the bridge derives every view from them. 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.
//!
//! `dump_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 `dump_board` payload (the agent's `data` object).
#[derive(Clone, Debug)]
pub struct BoardDump(pub Value);
impl BoardDump {
pub fn new(data: Value) -> Self {
BoardDump(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)
}
/// 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 dump yet, so this is a capability count, not a layer count. Do not report it to
// a user as "this board has N layers".
"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),
})
}
}
/// 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() -> BoardDump {
// Recorded from a live board; see fixtures/README.md for how to re-record.
let raw = include_str!("../fixtures/dump_board.json");
let v: Value = serde_json::from_str(raw).expect("fixture parses");
BoardDump::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_dump() {
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 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 = BoardDump::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 = BoardDump::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));
}
}