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.48 source (not yet released, needs signing): list_library and the show_* surfaces refuse a document under an unread…
5aeb190
5d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695
//! The show_* surfaces: open a document/library member through the agent, run follow-up
//! processes, capture a BACKGROUND proof frame. A surface verb exists to produce PROOF — if the
//! capture failed, saying success:true would hand the caller (and the wiki front-end) a confident
//! nothing, so a missing image is `proof_unavailable`, not success.
use crate::support::{self, s, s_or, timeout_of};
use crate::{agent, dialogs, instance, shot};
use serde_json::{json, Value};
use std::time::Duration;
/// An Altium document path given under an argument this verb does not read (`path` for
/// `libraryPath`, say) used to fall through to the bundled demo, which then opened in the
/// user's Altium and answered for the wrong file (adom/altium-bridge#20). Refuse instead.
pub fn stray_path(args: &Value, reads: &[&str]) -> Option<Value> {
const EXT: [&str; 8] = [".schlib", ".pcblib", ".schdoc", ".pcbdoc", ".prjpcb", ".intlib", ".outjob", ".prjscr"];
let obj = args.as_object()?;
for (k, v) in obj {
let Some(t) = v.as_str() else { continue };
let low = t.to_lowercase();
if !reads.contains(&k.as_str()) && EXT.iter().any(|e| low.ends_with(e)) {
return Some(json!({"success": false, "errorCode": "unknown_argument",
"error": format!("'{k}' is not an argument of this verb, so {t} would have been ignored and the bundled demo opened instead"),
"_hint": format!("Pass the document as {}.", reads.iter().map(|r| format!("'{r}'")).collect::<Vec<_>>().join(" or "))}));
}
}
None
}
/// Tag a reply that answered for the bundled demo document, so a caller never mistakes it for its own.
fn mark_demo(mut out: Value, used_demo: bool) -> Value {
out["demoFallback"] = json!(used_demo);
out
}
/// Open a document through the agent, run follow-up processes, return the frame.
pub fn open_then(path: &str, commands: &[(&str, &str)], timeout: Duration, label: &str) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let t0 = support::now();
let mut steps: Vec<Value> = Vec::new();
let r = agent::call("open", &[("path", path.to_string())], None, timeout);
let r = support::open_verdict(path, r, std::path::Path::new(path).is_file(), support::workspace_unavailable_seen_pub());
steps.push(json!({"open": path, "result": r.get("data"), "ms": r.get("roundtripMs")}));
if r.get("success").and_then(|b| b.as_bool()) != Some(true) {
let mut out = r;
out["steps"] = json!(steps);
return out;
}
for (cmd, params) in commands {
let rr = agent::call("run_process", &[("process", cmd.to_string()), ("params", params.to_string())], None, timeout);
steps.push(json!({"process": cmd, "params": params, "ok": rr.get("success"), "ms": rr.get("roundtripMs")}));
}
std::thread::sleep(Duration::from_millis(600));
let dl = dialogs::scan_scoped(true);
let frame = shot::shot_of_main();
let mw = instance::main_window().unwrap_or(json!({}));
let ok = dl.is_empty();
json!({"success": ok, "surface": label, "path": path, "steps": steps,
"title": mw.get("title"), "document": mw.get("document"),
"dialogsDetected": dl.len(), "dialogs": dl, "shot": frame,
"windowEtiquette": "background", "elapsedSec": support::elapsed_round(t0),
"_hint": if ok {
format!("{label} is on screen (title: {:?}). shot.image is the frame; altium_state for every window.", mw.get("title").and_then(|t| t.as_str()).unwrap_or(""))
} else {
"A dialog came up — read dialogs[] before doing anything else.".into()
}})
}
/// Open a library, select a member, run view processes, capture proof.
pub fn lib_surface(args: &Value, kind: &str, default_path: &str, label: &str, after: &[(&str, &str)]) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
if let Some(e) = stray_path(args, &["libraryPath", "filePath"]) {
return e;
}
let t0 = support::now();
let demo = s(args, "libraryPath").or_else(|| s(args, "filePath")).is_none();
let path = s(args, "libraryPath").or_else(|| s(args, "filePath")).unwrap_or_else(|| default_path.to_string());
let name = s(args, "symbolName").or_else(|| s(args, "footprintName")).or_else(|| s(args, "name")).unwrap_or_default();
let timeout = timeout_of(args, 60);
let mut steps: Vec<Value> = Vec::new();
let r = agent::call("open", &[("path", path.clone())], None, timeout);
steps.push(json!({"open": path, "result": r.get("data"), "ms": r.get("roundtripMs")}));
if r.get("success").and_then(|b| b.as_bool()) != Some(true) {
let mut out = r;
out["steps"] = json!(steps);
return out;
}
let sel = agent::call("lib_select", &[("kind", kind.to_string()), ("name", name.clone())], None, timeout);
steps.push(json!({"lib_select": if name.is_empty() { "(first)" } else { &name }, "result": sel.get("data"), "ms": sel.get("roundtripMs")}));
if sel.get("success").and_then(|b| b.as_bool()) != Some(true) {
let lst = agent::call("lib_list", &[("kind", kind.to_string()), ("limit", "50".into())], None, Duration::from_secs(30));
let mut out = sel;
out["steps"] = json!(steps);
out["available"] = lst.get("data").and_then(|d| d.get("names")).cloned().unwrap_or(Value::Null);
out["_hint"] = json!("Component not found in that library — pick one of available[] (altium_list_library shows all).");
return out;
}
for (cmd, params) in after {
let rr = agent::call("run_process", &[("process", cmd.to_string()), ("params", params.to_string())], None, Duration::from_secs(60));
steps.push(json!({"process": cmd, "params": params, "ok": rr.get("success"), "ms": rr.get("roundtripMs")}));
}
std::thread::sleep(Duration::from_millis(600));
let mut dl = dialogs::scan_scoped(true);
shot::attach_dialog_shots(&mut dl);
let frame = shot::shot_of_main();
let mw = instance::main_window().unwrap_or(json!({}));
let no_proof = frame.get("proofUnavailable").and_then(|b| b.as_bool()) == Some(true);
let selected = sel.get("data").and_then(|d| d.get("selected")).cloned().unwrap_or(Value::Null);
let mut out = json!({"success": dl.is_empty() && !no_proof, "surface": label, "path": path, "demoFallback": demo,
"selected": selected,
"steps": steps, "title": mw.get("title"), "dialogsDetected": dl.len(), "dialogs": dl, "shot": frame,
"windowEtiquette": "background", "elapsedSec": support::elapsed_round(t0),
"_hint": format!("{label}: {:?} in {:?}. shot.image is the frame.",
out_str(&out_sel(&sel)), mw.get("document").and_then(|d| d.as_str()).unwrap_or(""))});
if no_proof {
out["errorCode"] = json!("proof_unavailable");
out["error"] = out["shot"].get("reason").cloned().unwrap_or(Value::Null);
out["_hint"] = json!("The surface was opened and selected correctly — only the CAPTURE failed, because the Altium window is minimized. The bridge restores it into the background automatically; if it is still minimized, something re-minimized it mid-call.");
}
out
}
fn out_sel(sel: &Value) -> Value {
sel.get("data").and_then(|d| d.get("selected")).cloned().unwrap_or(Value::Null)
}
fn out_str(v: &Value) -> String {
v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())
}
// ---- the eight surfaces ------------------------------------------------------
pub fn show_project(args: &Value) -> Value {
if let Some(e) = stray_path(args, &["filePath"]) { return e; }
let demo = s(args, "filePath").is_none();
let p = s(args, "filePath").unwrap_or_else(support::demo_project);
mark_demo(open_then(&p, &[], timeout_of(args, 60), "project"), demo)
}
pub fn show_schematic(args: &Value) -> Value {
if let Some(e) = stray_path(args, &["filePath"]) { return e; }
let demo = s(args, "filePath").is_none();
let p = s(args, "filePath").unwrap_or_else(support::demo_sch);
mark_demo(open_then(&p, &[("Sch:Zoom", "Action=All")], timeout_of(args, 60), "schematic"), demo)
}
pub fn show_2d_board(args: &Value) -> Value {
if let Some(e) = stray_path(args, &["filePath"]) { return e; }
let demo = s(args, "filePath").is_none();
let p = s(args, "filePath").unwrap_or_else(support::demo_pcb);
mark_demo(open_then(&p, &[("PCB:SwitchTo2D", ""), ("PCB:Zoom", "Action=All")], timeout_of(args, 120), "2d_board"), demo)
}
pub fn show_3d_board(args: &Value) -> Value {
if let Some(e) = stray_path(args, &["filePath"]) { return e; }
let demo = s(args, "filePath").is_none();
let p = s(args, "filePath").unwrap_or_else(support::demo_pcb);
mark_demo(open_then(&p, &[("PCB:SwitchTo3D", ""), ("PCB:Zoom", "Action=All")], timeout_of(args, 120), "3d_board"), demo)
}
pub fn show_symbol(args: &Value) -> Value {
lib_surface(args, "sch", &support::demo_schlib(), "symbol", &[])
}
fn with_demo_footprint(args: &Value) -> Value {
let mut a = args.clone();
let custom = args.get("libraryPath").is_some() || args.get("filePath").is_some();
if a.get("footprintName").is_none() {
a["footprintName"] = json!(if custom { "" } else { support::DEMO_FOOTPRINT });
}
a
}
pub fn show_footprint(args: &Value) -> Value {
let a = with_demo_footprint(args);
lib_surface(&a, "pcb", &support::demo_pcblib(), "footprint", &[("PCB:SwitchTo2D", ""), ("PCB:Zoom", "Action=All")])
}
pub fn show_3d_chip(args: &Value) -> Value {
let a = with_demo_footprint(args);
lib_surface(&a, "pcb", &support::demo_pcblib(), "3d_chip", &[("PCB:SwitchTo3D", ""), ("PCB:Zoom", "Action=All")])
}
fn lib_kind_and_path(args: &Value) -> (String, String) {
let path = s(args, "libraryPath").or_else(|| s(args, "filePath")).unwrap_or_else(support::demo_schlib);
let kind = if path.to_lowercase().ends_with("pcblib") { "pcb" } else { "sch" };
(kind.into(), path)
}
pub fn show_library(args: &Value) -> Value {
if let Some(e) = stray_path(args, &["libraryPath", "filePath"]) {
return e;
}
let demo = s(args, "libraryPath").or_else(|| s(args, "filePath")).is_none();
let (kind, path) = lib_kind_and_path(args);
let mut a = args.clone();
a["libraryPath"] = json!(path);
a["name"] = json!("");
let mut r = lib_surface(&a, &kind, &path, "library", &[]);
if r.get("success").and_then(|b| b.as_bool()) == Some(true) {
let lst = agent::call("lib_list", &[("kind", kind), ("limit", "200".into())], None, Duration::from_secs(30));
r["components"] = lst.get("data").and_then(|d| d.get("names")).cloned().unwrap_or(Value::Null);
r["componentCount"] = lst.get("data").and_then(|d| d.get("count")).cloned().unwrap_or(Value::Null);
}
r["demoFallback"] = json!(demo);
r
}
pub fn list_library(args: &Value) -> Value {
if let Some(e) = stray_path(args, &["libraryPath", "filePath"]) {
return e;
}
let demo = s(args, "libraryPath").or_else(|| s(args, "filePath")).is_none();
let (kind, path) = lib_kind_and_path(args);
if let Some(g) = support::need_licensed() {
return g;
}
let r = support::agent_open(&path, 60);
if r.get("success").and_then(|b| b.as_bool()) != Some(true) {
return r;
}
let limit = s_or(args, "limit", "500");
let mut lst = agent::call("lib_list", &[("kind", kind.clone()), ("limit", limit)], None, Duration::from_secs(60));
lst["path"] = json!(path);
lst["kind"] = json!(kind);
mark_demo(lst, demo)
}
// ---- window / document verbs -------------------------------------------------
pub fn window_info(_args: &Value) -> Value {
let wins = instance::altium_windows();
let dl = dialogs::scan();
let has_modal = dl.iter().any(|d| d.get("ownedByMainInstance").and_then(|b| b.as_bool()) == Some(true));
let mut out = json!({"success": true, "windows": wins, "dialogs": dl, "hasModalDialogs": has_modal});
if has_modal {
out["_hint"] = json!("A dialog is open on Altium's main window: the agent loop and every -R request are frozen behind it. Read dialogs[].body + category, then altium_dismiss_dialogs {hwnd, button} with the work-preserving choice.");
}
out
}
/// Make an ALREADY-OPEN document current — the quiet lane. The AGENT is the real quiet lane
/// (a whole-window UIA search takes >100 s on the doc bar): one in-process call, no cursor,
/// no OS foreground; Altium ACTIVATES an already-open document rather than re-reading it.
pub fn activate_document(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let path = match s(args, "path").or_else(|| s(args, "document")).or_else(|| s(args, "name")) {
Some(p) => p,
None => return json!({"success": false, "error": "path is required (the document's full path)",
"_hint": "altium_window_info lists what is open; board verbs need a .PcbDoc current."}),
};
if let Some(bad) = support::validate_openable(&path) {
return bad;
}
let wait = timeout_of(args, 60).as_secs_f64().min(support::OPEN_BUDGET_SEC);
let r0 = agent::call("open", &[("path", path.clone())], None, Duration::from_secs_f64(wait));
let mut r = support::open_verdict(&path, r0, std::path::Path::new(&path).is_file(), support::workspace_unavailable_seen_pub());
if r.get("errorCode").and_then(|c| c.as_str()) == Some("agent_timeout") {
return json!({"success": true, "status": "in_progress", "path": path, "lane": "agent",
"windowEtiquette": "background", "waitedSec": wait,
"_hint": "Still opening — poll altium_status until the document is current."});
}
r["lane"] = json!("agent");
r["windowEtiquette"] = json!("background");
if r.get("_hint").is_none() {
r["_hint"] = json!("The document is now current. An already-open document is activated, not reloaded.");
}
r
}
pub fn open_document(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let path = match s(args, "filePath").or_else(|| s(args, "path")) {
Some(p) => p,
None => return json!({"success": false, "error": "filePath is required (absolute Windows path)"}),
};
if let Some(bad) = support::validate_openable(&path) {
return bad;
}
let wait = timeout_of(args, 60).as_secs_f64().min(support::OPEN_BUDGET_SEC);
let mut r = agent::call("open", &[("path", path.clone())], None, Duration::from_secs_f64(wait));
if r.get("errorCode").and_then(|c| c.as_str()) == Some("agent_timeout") {
// Altium is still opening it — that is not a failure, and ab's 60 s budget must not turn
// it into one. Report progress and let the caller poll.
return json!({"success": true, "status": "in_progress", "path": path, "waitedSec": wait,
"roundtripMs": r.get("roundtripMs"),
"_hint": "Altium is still opening the document. Poll altium_status (or altium_window_info) until it is current; the agent answers again once the open completes."});
}
r["windowEtiquette"] = json!("background");
r
}
/// Save the focused document (or the one at filePath) — WorkspaceManager:SaveObject.
pub fn save(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
if let Some(p) = s(args, "filePath") {
if let Some(e) = support::must_open(&p, 60) { return e; }
}
// pre-empts "a command is currently active" (save-a-copy Warning)
agent::call("tx_clear", &[], None, Duration::from_secs(30));
let mut r = agent::call("run_process",
&[("process", "WorkspaceManager:SaveObject".into()), ("params", "ObjectKind=FocusedDocument".into())],
None, Duration::from_secs(60));
r["_hint"] = json!("Saved the focused document. A 'Save As' dialog means the document never had a path — altium_check_dialogs.");
r
}
pub fn board_shape(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
if let Some(p) = s(args, "pcbPath") {
if let Some(e) = support::must_open(&p, 60) { return e; }
}
let mech = if args.get("mechOutline").and_then(|b| b.as_bool()) == Some(true) { "1" } else { "0" };
agent::call("board_shape",
&[("x0Mm", s_or(args, "x0Mm", "0")), ("y0Mm", s_or(args, "y0Mm", "0")),
("wMm", s_or(args, "wMm", "15")), ("hMm", s_or(args, "hMm", "15")), ("mechOutline", mech.into())],
None, timeout_of(args, 120))
}
pub fn pcb_report(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
if let Some(p) = s(args, "path") {
if let Some(e) = support::must_open(&p, 60) { return e; }
}
agent::call("pcb_report",
&[("name", s_or(args, "name", "")), ("want", s_or(args, "want", ""))],
None, timeout_of(args, 60))
}
// ---- demo tour ---------------------------------------------------------------
const DEMO_BEATS: &[(&str, &str)] = &[
("symbol", "A schematic symbol from the SpiritLevel example library, in the Symbol editor."),
("footprint", "Its land pattern in the footprint editor: pads, silkscreen, courtyard."),
("3d_chip", "The same footprint rendered in 3D."),
("schematic", "The Bluetooth Sentinel top-level schematic — six sheets wired by ports."),
("2d_board", "The layout in 2D: a two-board rigid-flex, four copper layers."),
("3d_board", "The populated board in 3D."),
];
/// Same shape as kicad_demo/fusion_demo: one beat per call, `done` when finished.
pub fn demo(args: &Value) -> Value {
let stage = args.get("stage").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
if stage >= DEMO_BEATS.len() {
return json!({"success": true, "done": true, "stage": stage,
"narrate": "That is the whole tour: symbol, footprint, 3D part, schematic, 2D and 3D board — all driven through the resident agent while Altium stayed in the background.",
"_hint": "Tour complete."});
}
let (label, narrate) = DEMO_BEATS[stage];
let beat_args = json!({"timeout": 120});
let r = match label {
"symbol" => show_symbol(&beat_args),
"footprint" => show_footprint(&beat_args),
"3d_chip" => show_3d_chip(&beat_args),
"schematic" => show_schematic(&beat_args),
"2d_board" => show_2d_board(&beat_args),
_ => show_3d_board(&beat_args),
};
let ok = r.get("success").and_then(|b| b.as_bool()) == Some(true);
let shots = match r.get("shot").and_then(|s| s.get("image")) {
Some(img) => json!([{"label": label, "image": img}]),
None => json!([]),
};
let mut out = json!({"success": ok, "done": false, "stage": stage, "label": label, "narrate": narrate,
"screenshots": shots, "steps": r.get("steps"), "dialogs": r.get("dialogs"),
"failedSteps": if ok { json!([]) } else { json!([label]) },
"nextCall": {"verb": "altium_demo", "args": {"stage": stage + 1}},
"_hint": format!("Beat {}/{} ({label}). Narrate, show the screenshot, then call altium_demo {{stage:{}}}.",
stage + 1, DEMO_BEATS.len(), stage + 1)});
if !ok {
// A failed beat used to come back as a bare success:false (seen on Altium Designer
// Develop, whose install has no AD26 example projects): carry the beat's own answer.
for k in ["errorCode", "error", "_hint"] {
if let Some(v) = r.get(k) {
out[k] = v.clone();
}
}
let missing = r.get("errorCode").and_then(|c| c.as_str()) == Some("file_not_found")
|| r.get("error").and_then(|e| e.as_str()).map(|e| e.contains("file not found")).unwrap_or(false);
if missing {
out["errorCode"] = json!("demo_examples_missing");
out["_hint"] = json!(format!("The demo tours Altium's bundled example projects ({}), which this install does not have (Altium Designer Develop ships without them). Point show_* verbs at your own documents instead.", crate::support::EXAMPLES_DIR));
}
out.as_object_mut().map(|o| o.entry("errorCode").or_insert(json!("demo_beat_failed")));
}
out
}
// ---- the live show lanes (John, 2026-09-02: "for all the stuff you need for the demo, you
// better make verbs") ----------------------------------------------------------
//
// The hand-driven show proved the model and exposed why it must live IN the bridge: driven from
// a container, every camera step pays the full CLI→ab→bridge→agent round trip (~1.4 s, jittery);
// driven here, a step is one agent file-drop (~40 ms), so choreography holds its timing and the
// narration a caller plays over it stays in sync.
/// Bring Altium to the FOREGROUND — the sanctioned exception to the never-foreground rule.
/// Requires a human-readable `reason` (>=10 chars), mirroring ab's caption rule: the user can
/// always see who is taking their screen and why.
pub fn foreground(args: &Value) -> Value {
let reason = s_or(args, "reason", "");
if reason.trim().len() < 10 {
return json!({"success": false, "errorCode": "reason_required",
"error": "foregrounding takes the USER'S screen — pass reason (>=10 chars) saying why, written for the human (e.g. 'John asked to watch the import live')",
"_hint": "The default etiquette is background-only (captures restore-into-background and hand focus straight back). Foreground is for a human who asked to WATCH."});
}
let mw = match instance::main_window() {
Some(w) => w,
None => return json!({"success": false, "errorCode": "altium_not_running", "_hint": "altium_launch first."}),
};
let hwnd = mw.get("hwnd").cloned().unwrap_or(Value::Null);
let r = crate::ab::call("desktop_raise_window", json!({"hwnd": hwnd, "reason": reason}), &[], 15);
// VERIFY, don't assume: a raise can "succeed" while another fullscreen window (the user's
// chat window, a remote-desktop layer) still covers Altium — John watched a whole show that
// never became visible because his Claude window was fullscreen on top (2026-09-02).
let top = crate::ab::call("desktop_list_windows", json!({}), &[], 10);
let top_hwnd = top.get("data").and_then(|d| d.get("windows")).or_else(|| top.get("windows"))
.and_then(|w| w.as_array()).and_then(|a| a.first())
.and_then(|w| w.get("hwnd")).cloned().unwrap_or(Value::Null);
let visibly_on_top = top_hwnd == hwnd;
json!({"success": r.get("success").and_then(|b| b.as_bool()).unwrap_or(true),
"hwnd": hwnd, "windowEtiquette": "foreground", "visiblyOnTop": visibly_on_top,
"_hint": if visibly_on_top {
"Altium is now visibly in front of the user. Hand their screen back when the show ends (their own window regains focus when they click; the bridge never needs to force it)."
} else {
"The raise was issued but another window still covers Altium (often the user's own fullscreen chat window — if they are typing to YOU, your raise loses to their activity, correctly). Do not re-raise in a loop; TELL the user Altium is ready behind their current window."
}})
}
/// One choreography step -> (process, params) for the given lane, or None for an unknown step.
fn view_step(lane: &str, step: &str) -> Option<(&'static str, &'static str)> {
if lane == "sch" {
return Some(match step {
"zoom_all" => ("Sch:Zoom", "Action=All"),
// Sch's token is ZoomIn/ZoomOut — Action=In is silently IGNORED (John watched a
// whole stop where "pushing in" never moved, 2026-09-02)
"zoom_in" => ("Sch:Zoom", "Action=ZoomIn"),
"zoom_out" => ("Sch:Zoom", "Action=ZoomOut"),
"zoom_level_2" => ("Sch:Zoom", "ZoomLevel=2"),
"zoom_level_4" => ("Sch:Zoom", "ZoomLevel=4"),
_ => return None,
});
}
Some(match step {
"zoom_all" => ("PCB:Zoom", "Action=All"),
"zoom_in" => ("PCB:Zoom", "Action=In"),
"zoom_out" => ("PCB:Zoom", "Action=Out"),
"micro_in" => ("PCB:Zoom", "Action=MicroIn"),
"micro_out" => ("PCB:Zoom", "Action=MicroOut"),
"view_2d" => ("PCB:SwitchTo2D", ""),
"view_3d" => ("PCB:SwitchTo3D", ""),
"isometric" => ("PCB:ResetCamera", "To=ISOMETRIC"),
"top" => ("PCB:ResetCamera", "To=Top"),
"bottom" => ("PCB:ResetCamera", "To=Bottom"),
"front" => ("PCB:ResetCamera", "To=Front"),
"back" => ("PCB:ResetCamera", "To=Back"),
"left" => ("PCB:ResetCamera", "To=Left"),
"right" => ("PCB:ResetCamera", "To=Right"),
"rotate90" => ("PCB:ResetCamera", "To=Rotate90"),
"ortho" => ("PCB:ResetCamera", "To=RotateOrtho"),
"orbit_left" => ("PCB:SetCamera", "To=30DegreeLeft"),
"orbit_right" => ("PCB:SetCamera", "To=30DegreeRight"),
"orbit_up" => ("PCB:SetCamera", "To=30DegreeUp"),
"orbit_down" => ("PCB:SetCamera", "To=30DegreeDown"),
"rotate_fit_left" => ("PCB:SetCamera", "To=RotateFit_Left"),
"rotate_fit_right" => ("PCB:SetCamera", "To=RotateFit_Right"),
"rotate_fit_up" => ("PCB:SetCamera", "To=RotateFit_Up"),
"rotate_fit_down" => ("PCB:SetCamera", "To=RotateFit_Down"),
"roll_left" => ("PCB:SetCamera", "To=RollFit_Left"),
"roll_right" => ("PCB:SetCamera", "To=RollFit_Right"),
"pan_left" => ("PCB:SetCamera", "To=500MilLeft"),
"pan_right" => ("PCB:SetCamera", "To=500MilRight"),
"pan_up" => ("PCB:SetCamera", "To=500MilUp"),
"pan_down" => ("PCB:SetCamera", "To=500MilDown"),
"flip" => ("PCB:FlipBoard", ""),
_ => return None,
})
}
fn preset_steps(preset: &str) -> Option<Vec<(&'static str, u64)>> {
Some(match preset {
// ONE gentle breath, not a dive: Altium zooms TOWARD THE CURSOR, so repeated zoom_in
// steps compound the drift and fly the view into blank canvas (John, watching take
// three: "you are like scrolling the content off the screen"). A single in/out keeps
// the drift invisible, and zoom_all reframes at the end regardless.
"punch_in" => vec![("zoom_all", 1000), ("zoom_in", 1800), ("zoom_out", 1400), ("zoom_all", 800)],
"showcase3d" => vec![("view_3d", 1400), ("zoom_all", 900), ("isometric", 1500),
("orbit_left", 1000), ("orbit_left", 1000), ("orbit_left", 1000),
("orbit_up", 1100), ("isometric", 1000)],
"orbit" => vec![("isometric", 1400), ("orbit_left", 1000), ("orbit_left", 1000),
("orbit_left", 1000), ("orbit_left", 1000)],
"flip" => vec![("flip", 2000), ("flip", 1800)],
"finale" => vec![("view_3d", 1400), ("zoom_all", 900), ("isometric", 1500),
("orbit_left", 1000), ("orbit_left", 1000), ("orbit_left", 1000), ("orbit_left", 1000),
("orbit_down", 1100), ("flip", 2000), ("flip", 1800), ("isometric", 1200), ("zoom_all", 600)],
_ => return None,
})
}
/// Camera/zoom choreography on the CURRENT document, executed in-bridge (~40 ms per step to the
/// agent) so the timing is tight enough to narrate over. Every move is a NATIVE Altium camera
/// command — Altium animates the transitions itself; no synthetic mouse, no cursor movement.
pub fn animate_view(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let lane = s_or(args, "lane", "pcb");
let default_pause = args.get("pauseMs").and_then(|v| v.as_u64()).unwrap_or(1000);
let mut plan: Vec<(String, u64)> = Vec::new();
if let Some(p) = s(args, "preset").filter(|p| !p.is_empty()) {
match preset_steps(&p) {
Some(steps) => plan.extend(steps.into_iter().map(|(st, ms)| (st.to_string(), ms))),
None => return json!({"success": false, "error": format!("unknown preset {p:?}"),
"presets": ["punch_in", "showcase3d", "orbit", "flip", "finale"]}),
}
}
if let Some(arr) = args.get("steps").and_then(|st| st.as_array()) {
for x in arr {
match x {
Value::String(st) => plan.push((st.clone(), default_pause)),
Value::Object(o) => plan.push((
o.get("step").and_then(|v| v.as_str()).unwrap_or("").to_string(),
o.get("pauseMs").and_then(|v| v.as_u64()).unwrap_or(default_pause),
)),
_ => {}
}
}
}
if plan.is_empty() {
return json!({"success": false, "error": "pass preset and/or steps[]",
"presets": ["punch_in", "showcase3d", "orbit", "flip", "finale"],
"steps": ["zoom_all", "zoom_in", "zoom_out", "view_2d", "view_3d", "isometric", "top", "bottom",
"front", "back", "left", "right", "rotate90", "ortho", "orbit_left", "orbit_right",
"orbit_up", "orbit_down", "roll_left", "roll_right", "pan_left", "pan_right",
"pan_up", "pan_down", "flip"],
"_hint": "lane:'sch' for schematic docs (zoom steps only). Steps are strings or {step, pauseMs}. Altium animates each camera transition itself."});
}
let t0 = support::now();
let mut ran = 0usize;
let mut unknown: Vec<String> = Vec::new();
for (st, pause) in &plan {
match view_step(&lane, st) {
Some((proc_, params)) => {
agent::call("run_process", &[("process", proc_.to_string()), ("params", params.to_string())], None, Duration::from_secs(30));
ran += 1;
std::thread::sleep(Duration::from_millis(*pause));
}
None => unknown.push(st.clone()),
}
}
let mut out = json!({"success": unknown.is_empty(), "lane": lane, "stepsRun": ran,
"elapsedSec": support::elapsed_round(t0), "windowEtiquette": "unchanged",
"_hint": "Choreography ran on the CURRENT document. For a show the user watches: altium_foreground first, and play narration over the motion (the whole call takes ~elapsedSec)."});
if !unknown.is_empty() {
out["unknownSteps"] = json!(unknown);
out["error"] = json!(format!("unknown steps for lane {lane:?}: {unknown:?} — every OTHER step still ran"));
}
out
}
// ---- the live, narrated demo (demo {live:true}) ------------------------------
static LAUNCHING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn demo_caption(text: &str, dur_ms: u64) {
crate::ab::call("desktop_caption", json!({"text": text, "id": "altium-demo", "position": "bottom",
"duration": dur_ms,
"reason": "Narrating the live Altium tour the user asked to watch"}), &[], 15);
}
struct LiveBeat {
title: &'static str,
doc: fn() -> String,
lane: &'static str,
setup: &'static [(&'static str, &'static str)],
preset: &'static str,
narrate: &'static str,
}
fn live_beats() -> [LiveBeat; 6] {
[
LiveBeat { title: "Stop 1/6 — The symbol", doc: support::demo_schlib, lane: "sch",
setup: &[("Sch:Zoom", "Action=All")], preset: "punch_in",
narrate: "Stop one, the symbol, in the Symbol editor. Pushing in for a closer look at the pins, then back out." },
LiveBeat { title: "Stop 2/6 — The footprint", doc: support::demo_pcblib, lane: "pcb",
setup: &[("PCB:SwitchTo2D", ""), ("PCB:Zoom", "Action=All")], preset: "punch_in",
narrate: "Stop two, the land pattern — pads, silkscreen, courtyard. Zooming down to the pads." },
LiveBeat { title: "Stop 3/6 — The part in 3D", doc: support::demo_pcblib, lane: "pcb",
setup: &[], preset: "showcase3d",
narrate: "Stop three: the same part in three D. Watch it swing to isometric and orbit around." },
LiveBeat { title: "Stop 4/6 — The schematic", doc: support::demo_sch, lane: "sch",
setup: &[("Sch:Zoom", "Action=All")], preset: "punch_in",
narrate: "Stop four, the Bluetooth Sentinel top level schematic — six sheets wired together by ports." },
LiveBeat { title: "Stop 5/6 — The board in 2D", doc: support::demo_pcb, lane: "pcb",
setup: &[("PCB:SwitchTo2D", ""), ("PCB:Zoom", "Action=All")], preset: "punch_in",
narrate: "Stop five, the layout in two D — a two board rigid flex, four copper layers." },
LiveBeat { title: "Stop 6/6 — The 3D finale", doc: support::demo_pcb, lane: "pcb",
setup: &[], preset: "finale",
narrate: "The finale: the populated board in three D — isometric, a full orbit, a flip to the bottom side, and back." },
]
}
/// The narrated, animated, FOREGROUND demo — one beat per call, choreography in-bridge.
/// The caller owns the VOICE (adom-tts): speak `nextNarrate` (detached) right before making
/// `nextCall`, and the words play over exactly the motion they describe.
pub fn demo_live(args: &Value) -> Value {
let lic = instance::licence_state();
if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {
// Narrate the wait instead of dying: paint the caption, start the launch in the
// background, and hand the caller the line to SPEAK (John: "if you have to launch
// altium, play another tts clip and ab caption explaining you have to open it").
demo_caption("Altium isn't running — launching it first (about a minute)…", 90000);
if !LAUNCHING.swap(true, std::sync::atomic::Ordering::SeqCst) {
std::thread::spawn(|| {
let _ = crate::misc::launch(&json!({"waitSec": 240}));
LAUNCHING.store(false, std::sync::atomic::Ordering::SeqCst);
});
}
return json!({"success": true, "status": "launching", "stage": args.get("stage").cloned().unwrap_or(json!(0)),
"narrate": "One thing before the tour: Altium isn't running, so I'm launching it now. Cold start takes about a minute — watch the progress bar on the taskbar icon.",
"nextCall": {"verb": "altium_demo", "args": {"live": true, "stage": args.get("stage").cloned().unwrap_or(json!(0))}},
"_hint": "SPEAK narrate now (adom-tts say --play, or cue a pre-loaded marker), wait ~20s, then repeat the SAME call — it proceeds once Altium is up."});
}
let stage = args.get("stage").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let beats = live_beats();
if stage >= beats.len() {
demo_caption("Tour complete.", 8000);
crate::ab::call("desktop_caption", json!({"action": "force-clear",
"reason": "the live Altium tour is over — clearing the tour captions"}), &[], 15);
return json!({"success": true, "done": true, "stage": stage,
"narrate": "That is the whole tour — every zoom, orbit and flip was a native Altium camera command through the bridge.",
"_hint": "SPEAK narrate, then hand the user's document back (altium_activate_document their file) if you changed it."});
}
let b = &beats[stage];
demo_caption(b.title, 60000);
if let Some(g) = support::need_licensed() {
return g;
}
let t0 = support::now();
let doc = (b.doc)();
if let Some(e) = support::must_open(&doc, 90) { return e; }
for (proc_, params) in b.setup {
agent::call("run_process", &[("process", proc_.to_string()), ("params", params.to_string())], None, Duration::from_secs(60));
}
// Foreground ONCE, at the first stop (John, 2026-09-02: "just foreground altium once. then
// don't try to foreground again for the rest of the demo"). The opening raise is an
// invitation; re-raising every stop would drag back a user who deliberately switched away
// mid-show. Documents keep opening in Altium either way.
let mut fg_note = Value::Null;
if stage == 0 {
let fg = foreground(&json!({"reason": "The user asked to WATCH this tour live — bringing Altium in front once, at the start"}));
if fg.get("visiblyOnTop").and_then(|b| b.as_bool()) == Some(false) {
fg_note = json!("Altium was raised but another window still covers it (often the user's own fullscreen chat window). Do NOT re-raise; tell the user the show is running behind their current window.");
}
}
let anim = animate_view(&json!({"lane": b.lane, "preset": b.preset}));
let next_stage = stage + 1;
let next_narrate = beats.get(next_stage).map(|nb| nb.narrate);
json!({"success": anim.get("success").cloned().unwrap_or(json!(true)), "done": false,
"stage": stage, "label": b.title, "narrate": b.narrate,
"nextNarrate": next_narrate,
"animation": {"preset": b.preset, "stepsRun": anim.get("stepsRun"), "elapsedSec": anim.get("elapsedSec")},
"elapsedSec": support::elapsed_round(t0), "windowEtiquette": "foreground",
"foregroundNote": fg_note,
"nextCall": {"verb": "altium_demo", "args": {"live": true, "stage": next_stage}},
"_hint": "SYNC MODEL (best): pre-cache the whole narration once with `adom-tts load` (one marker per stop), then per stop fire `adom-tts cue <id> <n> --confirm` in the background and IMMEDIATELY make nextCall — the prefetched cue starts instantly and this verb's in-bridge choreography (~5-17s) runs under the voice; advance when BOTH finish. Fallback without 1.9.47: say --play detached per stop. On the last call (done:true) restore the user's own document."})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stray_path_refuses_a_document_under_an_unread_argument() {
let e = stray_path(&json!({"path": "C:/x/Lib.SchLib"}), &["libraryPath", "filePath"]).expect("refused");
assert_eq!(e["errorCode"], "unknown_argument");
assert!(e["_hint"].as_str().unwrap().contains("libraryPath"));
assert!(stray_path(&json!({"libraryPath": "C:/x/Lib.SchLib", "symbolName": "R1"}), &["libraryPath", "filePath"]).is_none());
assert!(stray_path(&json!({}), &["filePath"]).is_none());
assert!(stray_path(&json!({"reason": "show it", "timeout": 30}), &["filePath"]).is_none());
assert!(stray_path(&json!({"board": "C:/x/B.PcbDoc"}), &["filePath"]).is_some());
}
}