app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
John Lauer
rust: phase 5 demo tour (six beats, panel callback, narration, recording, exact progress projection); 96 verbs, 199 tes…
120a639
2mo ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777
//! The six-beat demo tour, phase 5 of docs/rust-port-plan.md: the OS-neutral half of
//! `handlers/demo.py` and `handlers/demo_motion.py`.
//!
//! What lives here: the beat table (order, titles, captions, narration clips, what each
//! beat opens and verifies), the tour's window-title expectations, the pure text work on
//! the wiki board (lift the MCU symbol out of the schematic's `lib_symbols`, lift the
//! placed LQFP-48 out of the board as a library footprint, attach KiCad's own 3D bodies
//! to footprints that have none, find where the featured part sits so motion aims at
//! it), the window layout math, the front-window rule, the transport state machine
//! (play, pause, next, back, mute, stop from the ab demo panel, the toast or the
//! dashboard), the background job record that `kicad_demo {progress:true}` reads, the
//! canvas measurements the motion is gated on, the motion plans themselves as data, and
//! the narration clip timing (the clips are constant-bitrate mp3s, so their length is
//! read from the frame header without a decoder).
//!
//! What does not live here: anything that touches a window. The verb group
//! (`kicad-bridge/src/verbs_demo.rs`) drives KiCad through the window verbs and ab, and
//! synthesises input only through `kicad_platform::native().tour_*`, which refuse unless
//! the window is the measured foreground.
//!
//! Beat ORDER is a reliability decision as much as a story decision (2026-08-16): the
//! part beats (symbol, footprint, chip in 3D) come first because the story goes from one
//! part to the finished board; the design beats reuse the schematic and board that
//! `warm_kicad` opened up front. The tour foregrounds each beat's window exactly once,
//! with a reason, through ab, and reports only what the z-order says afterwards.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde_json::{json, Map, Value};
use kicad_platform::native;
// ── The board and the parts ───────────────────────────────────────────────────
pub const DEMO_LIB: &str = "Adom";
/// John, 2026-09-03: "make it a nice sexy cool board, not a lame board". The tour walks a
/// REAL Adom board from the wiki, adom/esc-g431 (a brushless motor controller: 149
/// footprints, 227 schematic symbols, an STM32G431 MCU and six MOSFETs), and the chip
/// beats show that board's own MCU: the symbol is lifted from the schematic's embedded
/// lib_symbols and the footprint from the board's own U5, complete with KiCad's standard
/// LQFP-48 3D body.
pub const BOARD_PAGE: &str = "adom/esc-g431";
pub const BOARD_NAME: &str = "Adom ESC G431";
pub const BOARD_BLURB: &str = "a brushless motor controller";
pub const BOARD_FILES: &[(&str, &str)] = &[("schematic", "esc-g431.kicad_sch"), ("board", "esc-g431.kicad_pcb"), ("project", "src/esc-g431.kicad_pro")];
pub const CHIP_LIB_ID: &str = "MCU_ST_STM32G4:STM32G431C_6-8-B_Tx";
pub const CHIP_REF: &str = "U5";
pub const CHIP_FOOTPRINT: &str = "Package_QFP:LQFP-48_7x7mm_P0.5mm";
pub const IC_SYM: &str = "STM32G431C_6-8-B_Tx";
pub const IC_FP: &str = "LQFP-48_STM32G431";
pub const WIKI_FILES: &str = "https://wiki.adom.inc/api/pages/{page}/files/{path}";
pub const DEMO_DIR_NAME: &str = "adom-kicad-demo";
/// A fetched copy younger than this is reused (the page is public, no token needed).
pub const FETCH_FRESH_SECS: u64 = 86400;
/// Footprint id -> KiCad standard body when the name does not match a KiCad model.
pub const MODEL_ALIASES: &[(&str, &str)] = &[("MOSFET_CDT:TDSON-8-1_BSC016N06NS", "Package_TO_SOT_SMD.3dshapes/TDSON-8-1")];
/// GET a wiki file (rustls with bundled roots: no OS CA store involved).
#[cfg(feature = "net")]
pub fn fetch(url: &str) -> Result<Vec<u8>, String> {
let config = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(60))).http_status_as_error(true).build();
let agent = ureq::Agent::new_with_config(config);
let mut resp = agent.get(url).header("User-Agent", "adom-bridge-kicad demo").call().map_err(|e| e.to_string())?;
resp.body_mut().with_config().limit(50 * 1024 * 1024).read_to_vec().map_err(|e| e.to_string())
}
#[cfg(not(feature = "net"))]
pub fn fetch(_url: &str) -> Result<Vec<u8>, String> {
Err("the net feature is off in this build: no HTTPS client".into())
}
// ── Beats ─────────────────────────────────────────────────────────────────────
pub const STEPS: &[&str] = &["symbol", "footprint", "footprint3d", "schematic", "board", "board3d"];
/// Screenshot labels. `schematic` / `board_2d` / `board_3d` deliberately match the labels
/// fusion_demo returns, so an AI driving BOTH bridges sees one vocabulary.
pub fn step_label(step: &str) -> &'static str {
match step {
"symbol" => "symbol",
"footprint" => "footprint",
"footprint3d" => "part_3d",
"schematic" => "schematic",
"board" => "board_2d",
"board3d" => "board_3d",
_ => "step",
}
}
/// Seconds per beat, the ESC is a real board: bigger files (Python `_BEAT_EST`).
pub const BEAT_EST: &[(&str, u64)] = &[("prepare", 25), ("symbol", 12), ("footprint", 12), ("part_3d", 16), ("schematic", 15), ("board_2d", 15), ("board_3d", 30)];
pub fn beat_est(step: &str) -> u64 {
let key = if step == "prepare" { "prepare" } else { step_label(step) };
BEAT_EST.iter().find(|(k, _)| *k == key).map(|(_, s)| *s).unwrap_or(10)
}
pub fn total_est() -> u64 {
BEAT_EST.iter().map(|(_, s)| *s).sum()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MotionKind {
TwoD,
ThreeD,
}
/// One beat of the tour: everything the job needs to know about it that is not a window.
#[derive(Clone, Debug)]
pub struct Beat {
pub step: &'static str,
/// "1/6 · The MCU symbol" (the `title` on the step record and in the staged reply).
pub title: &'static str,
/// The on-screen caption for part A (loading), without the "KiCad tour n/7:" prefix.
pub caption_a: &'static str,
pub caption_a_ms: u64,
/// Part B: the window is verified on screen; this line explains what is shown.
pub caption_b: &'static str,
/// The panel stop name.
pub stop: &'static str,
pub motion: MotionKind,
/// Wheel clicks for the 2D zoom (first zoom, then the zoom after centring).
pub zoom: (i32, i32),
}
pub static BEATS: &[Beat] = &[
Beat { step: "symbol", title: "1/6 \u{b7} The MCU symbol", caption_a: "the STM32G431 symbol, lifted from the board's schematic", caption_a_ms: 30000, caption_b: "Zooming in on the STM32 pins, then back out to the whole symbol", stop: "The MCU symbol", motion: MotionKind::TwoD, zoom: (1, 0) },
Beat { step: "footprint", title: "2/6 \u{b7} Its footprint", caption_a: "its LQFP-48 footprint", caption_a_ms: 30000, caption_b: "48 pads on 0.5 mm pitch: zooming in, then back out", stop: "Its footprint", motion: MotionKind::TwoD, zoom: (1, 0) },
Beat { step: "footprint3d", title: "3/6 \u{b7} The chip in 3D", caption_a: "the chip in 3D", caption_a_ms: 35000, caption_b: "The LQFP body on its pads, tilted to isometric and orbited", stop: "The chip in 3D", motion: MotionKind::ThreeD, zoom: (2, 1) },
Beat { step: "schematic", title: "4/6 \u{b7} The ESC schematic", caption_a: "the ESC schematic, 227 symbols", caption_a_ms: 25000, caption_b: "Zooming in on the microcontroller block, then back out to the sheet", stop: "The ESC schematic", motion: MotionKind::TwoD, zoom: (2, 1) },
Beat { step: "board", title: "5/6 \u{b7} The board in 2D", caption_a: "the board in 2D, 149 footprints", caption_a_ms: 25000, caption_b: "Zooming in on the MCU and its decoupling, then back out", stop: "The board in 2D", motion: MotionKind::TwoD, zoom: (2, 1) },
Beat { step: "board3d", title: "6/6 \u{b7} The board in 3D", caption_a: "the board in 3D", caption_a_ms: 40000, caption_b: "Isometric orbit around the MOSFETs and the MCU", stop: "The board in 3D", motion: MotionKind::ThreeD, zoom: (2, 1) },
];
pub const PREPARE_CAPTION: &str = "fetching the Adom ESC G431 board and installing its STM32 into your library";
pub const PREPARE_CAPTION_MS: u64 = 35000;
pub const PREPARE_STOP: &str = "Fetching the ESC board";
pub fn beat(step: &str) -> Option<&'static Beat> {
BEATS.iter().find(|b| b.step == step)
}
/// The beats to run, in tour order. `steps` None or empty = all six; otherwise the subset
/// named (unknown names are an error naming the valid ones). Order is always the tour's,
/// because the design beats reuse what the earlier ones opened.
pub fn beat_plan(steps: Option<&[String]>) -> Result<Vec<&'static Beat>, String> {
let Some(want) = steps.filter(|s| !s.is_empty()) else { return Ok(BEATS.iter().collect()) };
for s in want {
if !STEPS.contains(&s.as_str()) {
return Err(format!("unknown step '{s}'; valid steps: {}", STEPS.join(", ")));
}
}
Ok(BEATS.iter().filter(|b| want.iter().any(|w| w == b.step)).collect())
}
/// The caption for part A of beat number `n` of `count` beats (prepare is 1/N).
pub fn caption_a(n: usize, count: usize, text: &str) -> String {
format!("KiCad tour {n}/{count}: {text}")
}
/// The line the AI can speak for a completed beat.
pub fn say(step: &str, has3d: bool) -> String {
match step {
"symbol" => format!("This is the STM32G431, the microcontroller on the board you are about to see, in your own KiCad Symbol Editor. The bridge lifted it from the board's schematic and installed it into your {DEMO_LIB} library a moment ago."),
"footprint" => "Same chip, now its land pattern: a 48 pin LQFP on half millimetre pitch, taken from the board itself. The symbol is the idea, the footprint is the copper.".into(),
"footprint3d" => format!("And the same chip in three dimensions{}", if has3d { ", the LQFP-48 body from KiCad's own 3D library sitting on its pads." } else { ". Pads only here: KiCad's 3D model library is not installed on this machine." }),
"schematic" => "Now the real schematic: the Adom ESC G431, a brushless motor controller, straight from its wiki page. Over two hundred symbols: the STM32 you just saw, six power MOSFETs, gate drivers, sensing and the connectors.".into(),
"board" => "The layout of that board: one hundred and forty nine footprints, the MOSFETs around the edge, the MCU in the middle, copper, silkscreen and courtyards all real.".into(),
"board3d" => "And the finished ESC in 3D. This is the payoff: from one symbol to a rendered assembly, every step driven on your own machine, and each window brought to you once, then left alone.".into(),
_ => String::new(),
}
}
/// What is actually on screen worth mentioning.
pub fn point_out(step: &str, has3d: bool) -> Vec<String> {
let v: Vec<&str> = match step {
"symbol" => vec!["48 pins across the MCU's units", "the body and pin numbering", "registered in your 'Adom' library, not a scratch file"],
"footprint" => vec!["48 pads on 0.5 mm pitch", "silkscreen outline and the pin-1 mark", "the courtyard (the assembler's keep-out)"],
"footprint3d" => if has3d { vec!["the LQFP-48 body on its 48 pads", "gold pad plating"] } else { vec!["the bare pads (KiCad's 3D library is not installed here)"] },
"schematic" => vec!["U5, the STM32G431 from the first beats", "the six MOSFET half-bridges", "the whole sheet came from wiki.adom.inc/adom/esc-g431"],
"board" => vec!["the six TDSON MOSFET land patterns", "the LQFP-48 in the middle", "the pad count in the status bar"],
"board3d" => {
let mut v = if has3d { vec!["the STM32, capacitors, resistors and connectors with real bodies", "the MOSFET pads (their vendor bodies are not in KiCad's library)"] } else { vec!["the board and copper (component bodies need KiCad's 3D model library)"] };
v.push("orbit it with the mouse: it is your real KiCad");
v
}
_ => vec![],
};
v.into_iter().map(String::from).collect()
}
// ── Window titles ─────────────────────────────────────────────────────────────
/// The exact window titles this tour drives. Generic titles ("PCB Editor", "3D Viewer")
/// matched the wrong window on John's laptop (an empty untitled PCB Editor, an old demo
/// board); every beat targets these instead. KiCad separates document and editor with an
/// em dash in its titles (`\u{2014}`), so the expectation carries one; `title_matches`
/// also accepts a plain hyphen so an older KiCad still matches.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TourTitles {
pub schematic: String,
pub board: String,
pub symbol: String,
pub footprint: String,
}
pub const VIEWER_TITLE: &str = "3D Viewer";
pub fn tour_titles(stem: &str) -> TourTitles {
let stem = if stem.trim().is_empty() { "esc-g431" } else { stem };
TourTitles {
schematic: format!("{stem} \u{2014} Schematic Editor"),
board: format!("{stem} \u{2014} PCB Editor"),
symbol: format!("{DEMO_LIB}:{IC_SYM} \u{2014} Symbol Editor"),
footprint: format!("{DEMO_LIB}:{IC_FP} \u{2014} Footprint Editor"),
}
}
fn norm_title(t: &str) -> String {
t.to_lowercase().replace('\u{2014}', "-").replace('\u{2013}', "-").split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Case-insensitive substring match with the dash spelling normalised.
pub fn title_matches(live: &str, expected: &str) -> bool {
if expected.trim().is_empty() {
return !live.trim().is_empty();
}
norm_title(live).contains(&norm_title(expected))
}
/// The stem the titles are built from: the board or schematic file name without extension.
pub fn doc_stem(board: Option<&str>, schematic: Option<&str>) -> String {
board.or(schematic).and_then(|p| Path::new(p).file_stem().map(|s| s.to_string_lossy().to_string())).unwrap_or_else(|| "esc-g431".into())
}
/// The window the tour is to close before it starts: every 3D Viewer and Footprint Editor
/// (they sit on last time's part with a stale library tree), any adom-demo window, and an
/// untitled PCB Editor.
pub fn is_stale_tour_window(title: &str) -> bool {
let t = title.to_lowercase();
t.contains("3d viewer") || t.contains("footprint editor") || t.starts_with("adom-demo") || t.trim() == "pcb editor"
}
// ── S-expression text work ────────────────────────────────────────────────────
/// The balanced s-expression starting at `text[start] == '('`. Quote-aware.
pub fn sexpr_block(text: &str, start: usize) -> &str {
let bytes = text.as_bytes();
let mut depth = 0i32;
let mut inq = false;
let mut k = start;
while k < bytes.len() {
let c = bytes[k];
if c == b'"' && (k == 0 || bytes[k - 1] != b'\\') {
inq = !inq;
} else if !inq {
if c == b'(' {
depth += 1;
} else if c == b')' {
depth -= 1;
if depth == 0 {
return &text[start..k + 1];
}
}
}
k += 1;
}
&text[start..]
}
/// One embedded library symbol (lib_symbols block) as a standalone .kicad_sym.
pub fn extract_symbol(sch_text: &str, lib_id: &str) -> Result<String, String> {
let i = sch_text.find("(lib_symbols").ok_or("no lib_symbols block in the schematic")?;
let lib = sexpr_block(sch_text, i);
let needle = format!("(symbol \"{lib_id}\"");
let j = lib.find(&needle).ok_or_else(|| format!("{lib_id} not in lib_symbols"))?;
let block = sexpr_block(lib, j);
let bare = lib_id.split_once(':').map(|(_, b)| b).unwrap_or(lib_id);
let block = block.replacen(&needle, &format!("(symbol \"{bare}\""), 1);
Ok(format!("(kicad_symbol_lib (version 20231120) (generator \"adom-demo\")\n{block}\n)\n"))
}
fn line_is(line: &str, indent: &str, head: &str) -> bool {
line.strip_prefix(indent).map(|rest| rest.starts_with(head) && !rest.starts_with('\t')).unwrap_or(false)
}
/// The board's placed footprint `reference` as a library .kicad_mod (placement, uuid,
/// net assignments and sheet paths stripped; pads, silk, courtyard and the 3D model kept).
pub fn extract_footprint(pcb_text: &str, fp_id: &str, reference: &str, new_name: &str) -> Result<String, String> {
let head = format!("(footprint \"{fp_id}\"");
let ref_prop = format!("(property \"Reference\" \"{reference}\"");
let mut pos = 0;
let block = loop {
let j = pcb_text[pos..].find(&head).map(|k| k + pos).ok_or_else(|| format!("{fp_id} {reference} not on the board"))?;
let block = sexpr_block(pcb_text, j);
if block.contains(&ref_prop) {
break block;
}
pos = j + 10;
};
let block = block.replacen(&head, &format!("(footprint \"{new_name}\""), 1);
let mut out: Vec<String> = Vec::new();
let mut placement_dropped = false;
let mut ref_done = false;
let mut val_done = false;
for line in block.split('\n') {
let trimmed = line.trim_start();
if !placement_dropped && line_is(line, "\t\t", "(at ") {
placement_dropped = true;
continue;
}
if line_is(line, "\t\t", "(uuid \"") || line_is(line, "\t\t", "(path \"") || line_is(line, "\t\t", "(sheetname \"") || line_is(line, "\t\t", "(sheetfile \"") {
continue;
}
if trimmed.starts_with("(net ") || trimmed.starts_with("(pinfunction \"") || trimmed.starts_with("(pintype \"") {
continue;
}
let mut l = line.to_string();
if !ref_done && l.contains("(property \"Reference\" \"") {
l = replace_prop_value(&l, "Reference", "REF**");
ref_done = true;
}
if !val_done && l.contains("(property \"Value\" \"") {
l = replace_prop_value(&l, "Value", new_name);
val_done = true;
}
out.push(l);
}
let mut text = out.join("\n");
let header = format!("(footprint \"{new_name}\"\n\t(version 20240108)\n\t(generator \"adom-demo\")");
text = text.replacen(&format!("(footprint \"{new_name}\""), &header, 1);
text.push('\n');
Ok(text)
}
fn replace_prop_value(line: &str, prop: &str, value: &str) -> String {
let head = format!("(property \"{prop}\" \"");
let Some(i) = line.find(&head) else { return line.to_string() };
let rest = &line[i + head.len()..];
let Some(end) = rest.find('"') else { return line.to_string() };
format!("{}{head}{value}{}", &line[..i], &rest[end..])
}
/// A model path KiCad cannot load on THIS box: the author's own disk (C:/..., ../, a
/// private ${VAR}) or a ${KICAD10_3DMODEL_DIR} file that is not installed here.
/// `exists` answers for a path relative to the 3dmodels root.
pub fn model_is_dead(path: &str, exists: &dyn Fn(&str) -> bool) -> bool {
let b = path.as_bytes();
if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b.get(2) == Some(&b'/') || b.get(2) == Some(&b'\\')) {
return true;
}
if path.starts_with("../") || path.starts_with("${ADOM_") {
return true;
}
if let Some(rel) = path.strip_prefix("${KICAD10_3DMODEL_DIR}/") {
return !exists(rel);
}
false
}
/// Report of `attach_standard_models`.
#[derive(Clone, Debug, Default)]
pub struct ModelReport {
pub attached: usize,
pub skipped: usize,
pub dead_dropped: usize,
pub missing_models: Vec<String>,
}
impl ModelReport {
pub fn to_json(&self, root: &str) -> Value {
json!({"attached": self.attached, "skipped": self.skipped, "deadDropped": self.dead_dropped, "missingModels": self.missing_models, "root": root})
}
}
/// John, 2026-09-05: "why don't you have all the chips loaded in that final 3d board?
/// there's no mosfets or microcontroller". The wiki board's author left many footprints
/// with NO (model ...) at all, and a few with bodies from his own disk. For each
/// footprint: drop every dead model block, then, when none is left, attach KiCad's own
/// body when one exists on this box for the same Lib:Name (or an alias), so the 3D beat
/// shows chips instead of bare pads. Only the tour's fetched copy is touched.
pub fn attach_standard_models(pcb: &str, exists: &dyn Fn(&str) -> bool) -> (String, ModelReport) {
let mut rep = ModelReport::default();
let mut out = String::with_capacity(pcb.len() + 4096);
let mut pos = 0usize;
let mut search = 0usize;
while let Some(k) = pcb[search..].find("(footprint \"") {
let start = search + k;
let block_src = sexpr_block(pcb, start);
let end = start + block_src.len();
let fp_id = block_src["(footprint \"".len()..].split('"').next().unwrap_or("").to_string();
let mut block = block_src.to_string();
// Drop dead model blocks (balanced, with the whitespace before them).
let mut pos_m = 0usize;
while let Some(dm) = block[pos_m..].find("(model \"") {
let at = pos_m + dm;
let path = block[at + "(model \"".len()..].split('"').next().unwrap_or("").to_string();
if model_is_dead(&path, exists) {
let mb_len = sexpr_block(&block, at).len();
let mut cut0 = at;
while cut0 > 0 && matches!(block.as_bytes()[cut0 - 1], b' ' | b'\t' | b'\r' | b'\n') {
cut0 -= 1;
}
block.replace_range(cut0..at + mb_len, "");
rep.dead_dropped += 1;
pos_m = cut0;
} else {
pos_m = at + "(model \"".len();
}
}
if !block.contains("(model ") && fp_id.contains(':') {
let (lib, name) = fp_id.split_once(':').unwrap();
let cand = MODEL_ALIASES.iter().find(|(k, _)| *k == fp_id).map(|(_, v)| v.to_string()).unwrap_or_else(|| format!("{lib}.3dshapes/{name}"));
let rel = [".step", ".wrl"].iter().map(|ext| format!("{cand}{ext}")).find(|r| exists(r));
if let Some(rel) = rel {
let model = format!("\n\t\t(model \"${{KICAD10_3DMODEL_DIR}}/{rel}\"\n\t\t\t(offset (xyz 0 0 0))\n\t\t\t(scale (xyz 1 1 1))\n\t\t\t(rotate (xyz 0 0 0))\n\t\t)");
let body = block[..block.len() - 1].trim_end().to_string();
block = format!("{body}{model}\n\t)");
rep.attached += 1;
} else {
rep.skipped += 1;
if rep.missing_models.len() < 12 && !rep.missing_models.contains(&fp_id) {
rep.missing_models.push(fp_id.clone());
}
}
}
out.push_str(&pcb[pos..start]);
out.push_str(&block);
pos = end;
search = end;
}
out.push_str(&pcb[pos..]);
(out, rep)
}
fn two_numbers_after(text: &str, head: &str) -> Option<(f64, f64)> {
let i = text.find(head)?;
let rest = &text[i + head.len()..];
let mut it = rest.split(|c: char| c.is_whitespace() || c == ')').filter(|s| !s.is_empty());
let x = it.next()?.parse::<f64>().ok()?;
let y = it.next()?.parse::<f64>().ok()?;
Some((x, y))
}
/// The lib_id and footprint id of the part referenced `reference` in a schematic and its
/// board, so a project other than the ESC can be toured (its own U5, or whatever ref the
/// caller names).
pub fn chip_ids_for_ref(sch_text: &str, pcb_text: &str, reference: &str) -> (Option<String>, Option<String>) {
let ref_prop = format!("(property \"Reference\" \"{reference}\"");
let mut lib_id = None;
let mut pos = 0;
while let Some(k) = sch_text[pos..].find("(symbol") {
let start = pos + k;
// Only placed symbols carry a lib_id; library definitions carry a quoted name.
let head = &sch_text[start..];
if head.starts_with("(symbol\n") || head.starts_with("(symbol (lib_id") || head.starts_with("(symbol\r\n") {
let block = sexpr_block(sch_text, start);
if block.contains(&ref_prop) {
if let Some(i) = block.find("(lib_id \"") {
lib_id = block[i + "(lib_id \"".len()..].split('"').next().map(String::from);
break;
}
}
}
pos = start + 7;
}
let mut fp_id = None;
let mut pos = 0;
while let Some(k) = pcb_text[pos..].find("(footprint \"") {
let start = pos + k;
let block = sexpr_block(pcb_text, start);
if block.contains(&ref_prop) {
fp_id = block["(footprint \"".len()..].split('"').next().map(String::from);
break;
}
pos = start + block.len().max(12);
}
(lib_id, fp_id)
}
/// Where the featured part sits, as fractions of the drawn content: the sheet for the
/// schematic, the Edge.Cuts box for the board. Motion aims its zoom there instead of the
/// canvas centre (John, 2026-09-05: "you still don't end up showing important stuff").
/// Measured for esc-g431: schematic (0.20, 0.54) on A3, board (0.23, 0.66).
pub fn focus_fractions(sch_text: &str, pcb_text: &str, chip_lib_id: &str, chip_ref: &str) -> BTreeMap<String, (f64, f64)> {
let mut out = BTreeMap::new();
// Schematic: the placed symbol with this lib_id, its (at x y).
let needle = format!("(lib_id \"{chip_lib_id}\")");
if let Some(i) = sch_text.find(&needle) {
if let Some((x, y)) = two_numbers_after(&sch_text[i..], "(at ") {
let paper = sch_text.find("(paper \"").map(|p| sch_text[p + 8..].split('"').next().unwrap_or("A4").to_string()).unwrap_or_else(|| "A4".into());
let (mut pw, mut ph) = match paper.as_str() {
"A4" => (297.0, 210.0),
"A3" => (420.0, 297.0),
"A2" => (594.0, 420.0),
"A1" => (841.0, 594.0),
"A0" => (1189.0, 841.0),
"A" => (279.4, 215.9),
"B" => (431.8, 279.4),
"C" => (558.8, 431.8),
"D" => (863.6, 558.8),
"E" => (1117.6, 863.6),
_ => (420.0, 297.0),
};
if sch_text.contains(&format!("(paper \"{paper}\" portrait)")) {
std::mem::swap(&mut pw, &mut ph);
}
out.insert("schematic".into(), (round3(x / pw), round3(y / ph)));
}
}
// Board: the footprint with this reference against the Edge.Cuts bounding box.
let ref_prop = format!("(property \"Reference\" \"{chip_ref}\"");
if let Some(i) = pcb_text.find(&ref_prop) {
if let Some(j) = pcb_text[..i].rfind("(footprint ") {
if let Some((x, y)) = two_numbers_after(&pcb_text[j..i], "(at ") {
let (mut xs, mut ys): (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());
for head in ["(gr_line", "(gr_arc", "(gr_rect", "(gr_circle"] {
let mut pos = 0;
while let Some(k) = pcb_text[pos..].find(head) {
let start = pos + k;
let block = sexpr_block(pcb_text, start);
if block.contains("(layer \"Edge.Cuts\")") {
for key in ["(start ", "(end ", "(mid "] {
let mut p = 0;
while let Some(q) = block[p..].find(key) {
if let Some((px, py)) = two_numbers_after(&block[p + q..], key) {
xs.push(px);
ys.push(py);
}
p += q + key.len();
}
}
}
pos = start + block.len().max(head.len());
}
}
let (minx, maxx) = (xs.iter().cloned().fold(f64::INFINITY, f64::min), xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max));
let (miny, maxy) = (ys.iter().cloned().fold(f64::INFINITY, f64::min), ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max));
if !xs.is_empty() && maxx > minx && maxy > miny {
let fx = ((x - minx) / (maxx - minx)).clamp(0.0, 1.0);
let fy = ((y - miny) / (maxy - miny)).clamp(0.0, 1.0);
out.insert("board".into(), (round3(fx), round3(fy)));
}
}
}
}
out
}
fn round3(v: f64) -> f64 {
(v * 1000.0).round() / 1000.0
}
// ── Layout ────────────────────────────────────────────────────────────────────
/// John, 2026-09-05: "make all of them be 95% covering the entire display and center it",
/// then "go 98% sizing". Every editor and viewer window lands here, centred on the
/// primary monitor's work area; the KiCad manager stays where KiCad opens it.
pub const LAYOUT_FRACTION: f64 = 0.98;
/// Where a beat's window goes: `mon` is (x, y, width, height) of the primary work area,
/// `slot` the anchor fractions (0.5, 0.5 = centred). Returns (x, y, w, h).
pub fn place_rect(mon: (i32, i32, i32, i32), slot: (f64, f64), fraction: f64) -> (i32, i32, i32, i32) {
let (mx, my, mw, mh) = mon;
let w = (mw as f64 * fraction) as i32;
let h = (mh as f64 * fraction) as i32;
let x = mx + (slot.0 * (mw - w) as f64) as i32;
let y = my + (slot.1 * (mh - h) as f64) as i32;
(x, y, w, h)
}
/// The primary monitor's work area (or bounds) from a `desktop_list_monitors` payload.
pub fn primary_monitor(monitors: &Value) -> Option<(i32, i32, i32, i32)> {
let mons = monitors.as_array()?;
let prim = mons.iter().find(|m| m.get("primary").and_then(Value::as_bool).unwrap_or(false)).or_else(|| mons.first())?;
let area = prim.get("workArea").or_else(|| prim.get("bounds"))?;
let n = |k: &str| area.get(k).and_then(Value::as_f64).map(|f| f as i32);
Some((n("x").or_else(|| n("left")).unwrap_or(0), n("y").or_else(|| n("top")).unwrap_or(0), n("width")?, n("height")?))
}
// ── The front window ──────────────────────────────────────────────────────────
pub const FRONT_IGNORE_TITLES: &[&str] = &["popuphost", "adom bridge recorder", "adom demo panel"];
pub const FRONT_IGNORE_IMAGES: &[&str] = &["explorer.exe", "shellexperiencehost.exe", "searchhost.exe", "startmenuexperiencehost.exe", "textinputhost.exe", "applicationframehost.exe"];
/// One z-ordered row for `front_hwnd`.
#[derive(Clone, Debug, Default)]
pub struct ZRow {
pub hwnd: u64,
pub z: Option<i64>,
pub minimized: bool,
pub title: String,
pub image: String,
}
/// The hwnd a user would call "in front": lowest z among windows that are not minimized,
/// not Windows shell popups, not our own overlays.
pub fn front_hwnd(rows: &[ZRow]) -> u64 {
rows.iter()
.filter(|r| r.z.is_some() && !r.minimized)
.filter(|r| {
let t = r.title.to_lowercase();
let im = r.image.to_lowercase();
!FRONT_IGNORE_TITLES.iter().any(|k| t.contains(k)) && !FRONT_IGNORE_IMAGES.contains(&im.as_str())
})
.min_by_key(|r| r.z.unwrap_or(i64::MAX))
.map(|r| r.hwnd)
.unwrap_or(0)
}
// ── Transport ─────────────────────────────────────────────────────────────────
/// "to have a good demo you need a play/pause/next/prev/exit" (John, 2026-08-24). Two ways
/// in, ONE control channel: ab's demo panel (callback plus poll), the toast buttons, and
/// `kicad_demo {control}` from the dashboard. Commands are applied between beats (a beat
/// handler cannot be interrupted mid-verb), except that `pending_interrupt` lets the
/// motion and the narration wait cut short at their next gesture.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cmd {
Play,
Pause,
Next,
Prev,
Exit,
}
impl Cmd {
pub fn parse(s: &str) -> Option<Cmd> {
Some(match s.trim().to_lowercase().as_str() {
"play" => Cmd::Play,
"pause" => Cmd::Pause,
"next" => Cmd::Next,
"prev" => Cmd::Prev,
"exit" => Cmd::Exit,
_ => return None,
})
}
pub fn as_str(self) -> &'static str {
match self {
Cmd::Play => "play",
Cmd::Pause => "pause",
Cmd::Next => "next",
Cmd::Prev => "prev",
Cmd::Exit => "exit",
}
}
}
/// A panel button (back/play/pause/next/stop) or a toast label onto a transport command.
pub fn map_button(button: &str) -> Option<Cmd> {
Some(match button.trim().to_lowercase().as_str() {
"back" | "prev" => Cmd::Prev,
"play" => Cmd::Play,
"pause" => Cmd::Pause,
"next" => Cmd::Next,
"stop" | "exit" => Cmd::Exit,
_ => return None,
})
}
/// The acknowledgement caption for a remote press.
pub fn ack_text(button: &str) -> Option<&'static str> {
Some(match button.trim().to_lowercase().as_str() {
"mute" => "Remote: muted",
"unmute" => "Remote: narration back on",
"pause" => "Remote: pausing",
"play" => "Remote: playing",
"next" => "Remote: next",
"back" | "prev" => "Remote: back",
"stop" | "exit" => "Remote: stopping the tour",
_ => return None,
})
}
/// FIFO of presses not yet honoured (measured on ConfRoomROG 2026-09-04: a single slot
/// lost a 'next' when 'back' arrived during the same beat's un-interruptible window
/// open). 'exit' jumps the queue. Capacity 8.
#[derive(Clone, Debug, Default)]
pub struct Transport {
queue: Vec<Cmd>,
/// The toast event stream cursor (`notify_events sinceSeq`).
pub seq: i64,
}
impl Transport {
pub fn push(&mut self, cmd: Cmd) {
if cmd == Cmd::Exit {
self.queue.clear();
self.queue.push(Cmd::Exit);
} else if self.queue.len() < 8 {
self.queue.push(cmd);
}
}
/// Put a command back at the head (kept for the between-beat check).
pub fn push_front(&mut self, cmd: Cmd) {
self.queue.insert(0, cmd);
}
pub fn pop(&mut self) -> Option<Cmd> {
if self.queue.is_empty() { None } else { Some(self.queue.remove(0)) }
}
pub fn head(&self) -> Option<Cmd> {
self.queue.first().copied()
}
pub fn clear(&mut self) {
self.queue.clear();
}
/// A press that must cut the beat in flight (John, 2026-09-04: "i would expect your
/// prev/next to be instant, not wait for next beat").
pub fn pending_interrupt(&self) -> bool {
matches!(self.head(), Some(Cmd::Prev | Cmd::Next | Cmd::Pause | Cmd::Exit))
}
}
pub static CTRL: Mutex<Transport> = Mutex::new(Transport { queue: Vec::new(), seq: 0 });
/// Set by a STOP press; polled by the beat loop, the narration wait and the motion.
pub static EXIT: AtomicBool = AtomicBool::new(false);
pub fn ctrl() -> std::sync::MutexGuard<'static, Transport> {
CTRL.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn exit_requested() -> bool {
EXIT.load(Ordering::SeqCst)
}
pub fn set_exit(v: bool) {
EXIT.store(v, Ordering::SeqCst);
}
/// Queue a transport command (`set_demo_control`).
pub fn set_control(cmd: Cmd) {
ctrl().push(cmd);
}
/// The beat-cutting check: exit, or a prev/next/pause/exit at the head of the queue.
pub fn pending_interrupt() -> bool {
exit_requested() || ctrl().pending_interrupt()
}
/// Where the beat loop lands after a between-beat command. `i` is the index of the NEXT
/// beat (it advanced at the bottom of the loop), so "back" is two steps: measured on
/// John's laptop 2026-09-04, i - 1 replayed the same footprint beat twice.
pub fn apply_between_beats(i: usize, cmd: Option<Cmd>) -> (usize, bool) {
match cmd {
Some(Cmd::Exit) => (i, true),
Some(Cmd::Prev) => (i.saturating_sub(2), false),
_ => (i, false),
}
}
// ── The panel ─────────────────────────────────────────────────────────────────
pub const PANEL_ID: &str = "kicad-demo";
pub const PANEL_TITLE: &str = "KiCad bridge tour";
pub const PANEL_REASON: &str = "Transport for the KiCad tour the user started: play, pause, next, back, mute, stop";
pub const CAPTION_ID: &str = "kicad-demo";
pub const NOTE_CAPTION_ID: &str = "kicad-demo-note";
pub const CTL_TOAST_ID: &str = "kicad-demo-ctl";
/// The panel's stops for a plan: prepare first, then one per beat.
pub fn panel_stops(plan: &[&Beat]) -> Vec<String> {
let mut v = vec![PREPARE_STOP.to_string()];
v.extend(plan.iter().map(|b| b.stop.to_string()));
v
}
/// ab's demo panel bookkeeping. `ok`: None = untried, Some(true) = panel shown,
/// Some(false) = this ab predates the panel, use the toast for the rest of the tour.
#[derive(Clone, Debug, Default)]
pub struct PanelState {
pub ok: Option<bool>,
pub shown_once: bool,
pub script_state: Option<String>,
pub callback_accepted: Option<bool>,
/// `at` stamps already honoured via the instant callback.
pub seen: BTreeSet<String>,
}
pub static PANEL: Mutex<PanelState> = Mutex::new(PanelState { ok: None, shown_once: false, script_state: None, callback_accepted: None, seen: BTreeSet::new() });
pub fn panel() -> std::sync::MutexGuard<'static, PanelState> {
PANEL.lock().unwrap_or_else(|e| e.into_inner())
}
// ── The job record ────────────────────────────────────────────────────────────
/// One job at a time. The record is a JSON map, exactly the Python `_JOB` dict, so the
/// progress reply is a projection of it.
pub static JOB: Mutex<Option<Map<String, Value>>> = Mutex::new(None);
pub fn job_snapshot() -> Map<String, Value> {
JOB.lock().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_default()
}
pub fn job_get(key: &str) -> Value {
JOB.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|j| j.get(key).cloned()).unwrap_or(Value::Null)
}
pub fn job_update(fields: Value) {
let mut g = JOB.lock().unwrap_or_else(|e| e.into_inner());
let j = g.get_or_insert_with(Map::new);
if let Some(m) = fields.as_object() {
for (k, v) in m {
j.insert(k.clone(), v.clone());
}
}
}
/// Append to an array field (capped, oldest dropped).
pub fn job_push(key: &str, item: Value, cap: usize) {
let mut g = JOB.lock().unwrap_or_else(|e| e.into_inner());
let j = g.get_or_insert_with(Map::new);
let arr = j.entry(key).or_insert_with(|| json!([]));
if let Some(a) = arr.as_array_mut() {
a.push(item);
if a.len() > cap {
let n = a.len() - cap;
a.drain(..n);
}
}
}
/// Pop the first pending evidence shot (one rides each progress poll).
pub fn job_pop_shot() -> Option<Value> {
let mut g = JOB.lock().unwrap_or_else(|e| e.into_inner());
let a = g.as_mut()?.get_mut("pendingShots")?.as_array_mut()?;
if a.is_empty() { None } else { Some(a.remove(0)) }
}
pub fn job_active() -> bool {
job_get("active").as_bool().unwrap_or(false)
}
pub fn epoch_secs() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
pub fn epoch_f64() -> f64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0)
}
/// Claim the job slot for a new tour. Err carries the running job's progress triple.
pub fn job_start(muted: bool, est: u64, beat_count: usize, caller: Value, opts: Value) -> Result<String, Value> {
let mut g = JOB.lock().unwrap_or_else(|e| e.into_inner());
if let Some(j) = g.as_ref() {
if j.get("active").and_then(Value::as_bool).unwrap_or(false) {
return Err(json!({"percent": j.get("percent"), "step": j.get("step"), "elapsedSec": j.get("elapsedSec")}));
}
}
let job_id = format!("demo-{}", SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0));
let mut j = Map::new();
for (k, v) in [
("active", json!(true)), ("done", json!(false)), ("step", json!("starting")), ("stepLabel", json!("Starting the tour")),
("beatIndex", json!(0)), ("beatCount", json!(beat_count)), ("percent", json!(0)), ("elapsedSec", json!(0)), ("estimatedSec", json!(est)),
("mutedRequested", json!(muted)), ("jobId", json!(job_id)), ("callerFwd", caller), ("options", opts), ("startedAt", json!(epoch_f64())),
] {
j.insert(k.into(), v);
}
*g = Some(j);
Ok(job_id)
}
/// The `kicad_demo {progress:true}` reply from a snapshot (pure; the shot pop is separate).
pub fn progress_reply(snap: &Map<String, Value>, shot: Option<Value>) -> Value {
let mut out = json!({"success": true});
for k in ["done", "step", "stepLabel", "beatIndex", "beatCount", "percent", "elapsedSec", "estimatedSec", "error", "panel", "muted", "muteSource", "lastPress", "presses", "paused", "stopping", "verified", "captions", "windowsRestored", "prepSummary", "jobId", "recording", "exited"] {
out[k] = snap.get(k).cloned().unwrap_or(Value::Null);
}
// 0.9.199 (wiki issue #39, John): `active` used to be a BOOLEAN here and an ARRAY in
// kicad_progress, the same key with two types from one bridge. `active` is the canonical
// ARRAY of progress blocks everywhere; the boolean moved to `running`.
let running = snap.get("active").and_then(Value::as_bool).unwrap_or(false);
out["running"] = json!(running);
let est = snap.get("estimatedSec").and_then(Value::as_f64);
let el = snap.get("elapsedSec").and_then(Value::as_f64);
out["active"] = if running {
json!([{
"phase": "kicad.demo",
"stepLabel": snap.get("stepLabel").and_then(Value::as_str).or_else(|| snap.get("step").and_then(Value::as_str)).unwrap_or("running"),
"percent": snap.get("percent"),
"elapsedSec": snap.get("elapsedSec"),
"estimatedSec": snap.get("estimatedSec"),
"etaSec": match (est, el) { (Some(e), Some(l)) => json!(((e - l).max(0.0) * 10.0).round() / 10.0), _ => Value::Null },
"confidence": "typical",
"startKind": "cold",
"beatIndex": snap.get("beatIndex"),
"beatCount": snap.get("beatCount"),
}])
} else {
json!([])
};
if let Some(s) = shot {
out["stepShot"] = s;
}
let done = snap.get("done").and_then(Value::as_bool).unwrap_or(false);
if done && snap.get("result").map(|r| !r.is_null()).unwrap_or(false) {
out["result"] = snap["result"].clone();
out["_hint"] = json!("Demo complete: result.state.shots[] is every KiCad window as base64 PNG (render them all); speak result.narrate. Tear down with kicad_close {\"force\":true} when the user is done looking.");
} else {
out["_hint"] = json!("Long-running job: render a real progress bar from active[0] (percent/elapsedSec/estimatedSec/stepLabel). Poll every 2-3s. `active` is an ARRAY (canonical contract); the boolean still-running flag is `running`. stepShot (when present) is the completed step's own window as base64 PNG: pin it as that step's evidence.");
}
out
}
/// Percent of the whole tour done, capped at 95 until the end.
pub fn percent_for(done_weight: u64, total: u64) -> i64 {
if total == 0 {
return 0;
}
((100 * done_weight / total) as i64).min(95)
}
// ── Narration ─────────────────────────────────────────────────────────────────
/// The clips are synthesized at RELEASE BUILD time with adom-tts and ship in
/// resources/demo-audio/, so a beat's voice line starts instantly on the box. Playback
/// goes through the platform's audio method (no window, no PowerShell).
#[derive(Debug, Default)]
pub struct Narration {
pub muted: bool,
pub step: Option<String>,
pub handle: Option<u64>,
pub started_at: f64,
pub ends_at: Option<Instant>,
pub last_error: Option<String>,
}
pub static NARR: Mutex<Narration> = Mutex::new(Narration { muted: false, step: None, handle: None, started_at: 0.0, ends_at: None, last_error: None });
pub fn narr() -> std::sync::MutexGuard<'static, Narration> {
NARR.lock().unwrap_or_else(|e| e.into_inner())
}
/// Where the clips are: `resources/demo-audio` next to the exe (the native zip lays the
/// binary at the root), else walking up from the exe for a source checkout.
pub fn audio_dir() -> Option<PathBuf> {
if let Ok(env) = std::env::var("ADOM_KICAD_DEMO_AUDIO") {
let p = PathBuf::from(env);
if p.is_dir() {
return Some(p);
}
}
let exe = std::env::current_exe().ok()?;
let mut dir = exe.parent();
for _ in 0..6 {
let d = dir?;
let cand = d.join("resources").join("demo-audio");
if cand.join("manifest.json").is_file() || cand.join("prepare.mp3").is_file() {
return Some(cand);
}
dir = d.parent();
}
None
}
pub fn narration_file(step: &str) -> Option<PathBuf> {
let p = audio_dir()?.join(format!("{step}.mp3"));
if p.is_file() { Some(p) } else { None }
}
/// Every clip the native zip must ship, from the manifest (else the directory listing).
pub fn narration_files() -> Vec<String> {
let Some(dir) = audio_dir() else { return Vec::new() };
let mut v: Vec<String> = std::fs::read_dir(&dir).map(|rd| rd.flatten().filter(|e| e.path().extension().map(|x| x == "mp3").unwrap_or(false)).map(|e| e.file_name().to_string_lossy().to_string()).collect()).unwrap_or_default();
v.sort();
v
}
const MP3_BITRATES_V1_L3: [u32; 16] = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0];
const MP3_BITRATES_V2_L3: [u32; 16] = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0];
/// Length of a constant-bitrate MPEG layer III file from its first frame header (the
/// adom-tts clips are CBR 48 kbps at 24 kHz; measured against ffprobe to within 10 ms).
/// None when no frame header is found.
pub fn mp3_duration_secs(bytes: &[u8]) -> Option<f64> {
let mut i = 0usize;
if bytes.len() > 10 && &bytes[0..3] == b"ID3" {
let size = ((bytes[6] as usize & 0x7f) << 21) | ((bytes[7] as usize & 0x7f) << 14) | ((bytes[8] as usize & 0x7f) << 7) | (bytes[9] as usize & 0x7f);
i = 10 + size;
}
while i + 4 <= bytes.len() {
if bytes[i] == 0xff && (bytes[i + 1] & 0xe0) == 0xe0 {
let version = (bytes[i + 1] >> 3) & 0x3; // 3 = MPEG1, 2 = MPEG2, 0 = MPEG2.5
let layer = (bytes[i + 1] >> 1) & 0x3; // 1 = layer III
let br_idx = (bytes[i + 2] >> 4) as usize;
let sr_idx = ((bytes[i + 2] >> 2) & 0x3) as usize;
if layer == 1 && br_idx != 0 && br_idx != 15 && sr_idx != 3 && version != 1 {
let kbps = if version == 3 { MP3_BITRATES_V1_L3[br_idx] } else { MP3_BITRATES_V2_L3[br_idx] };
if kbps > 0 {
let audio_bytes = (bytes.len() - i) as f64;
return Some(audio_bytes * 8.0 / (kbps as f64 * 1000.0));
}
}
}
i += 1;
}
None
}
/// Start a clip (unless muted); remembers the step so an unmute can replay it.
pub fn play_narration(step: &str) -> Option<f64> {
let mut n = narr();
n.started_at = epoch_f64();
n.step = Some(step.to_string());
if n.muted {
return None;
}
let Some(path) = narration_file(step) else { return None };
stop_narration_locked(&mut n);
let secs = std::fs::read(&path).ok().and_then(|b| mp3_duration_secs(&b)).unwrap_or(8.0);
match native().tour_audio_play(&path, 0.9) {
Ok(h) => {
n.handle = Some(h);
n.ends_at = Some(Instant::now() + Duration::from_secs_f64(secs + 0.3));
n.last_error = None;
Some(secs)
}
Err(e) => {
n.handle = None;
n.ends_at = None;
n.last_error = Some(e);
None
}
}
}
fn stop_narration_locked(n: &mut Narration) {
if let Some(h) = n.handle.take() {
let _ = native().tour_audio_stop(h);
}
n.ends_at = None;
}
pub fn stop_narration() {
stop_narration_locked(&mut narr());
}
pub fn narration_playing() -> bool {
let n = narr();
n.handle.is_some() && n.ends_at.map(|t| Instant::now() < t).unwrap_or(false)
}
/// Let the current line finish (bounded), honouring exit and pending presses.
pub fn wait_narration(max: Duration) {
let t0 = Instant::now();
while t0.elapsed() < max {
if !narration_playing() || pending_interrupt() {
return;
}
std::thread::sleep(Duration::from_millis(200));
}
}
/// The user's mute is the law: stop the sound within the beat, keep the show and captions
/// running; on unmute, replay the current beat's line.
pub fn apply_mute(muted: bool, source: &str) {
let (was, step) = {
let n = narr();
(n.muted, n.step.clone())
};
narr().muted = muted;
if muted && !was {
stop_narration();
} else if was && !muted {
if let Some(s) = step {
play_narration(&s);
}
}
job_update(json!({"muted": muted, "muteSource": source}));
}
pub fn is_muted() -> bool {
narr().muted
}
// ── Canvas measurements ───────────────────────────────────────────────────────
/// Where the DRAWN content sits on the GL canvas, in SCREEN pixels: background = the
/// most common sampled colour, content = samples that differ from it. `touches` says
/// which canvas edges the content reaches, so a pan or rotation that pushed the content
/// off the view is noticed (John, 2026-09-05: "don't be dumb about your zooming").
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CanvasContent {
pub canvas: (i32, i32, i32, i32),
pub bbox: Option<(i32, i32, i32, i32)>,
pub coverage: f64,
pub touches: Vec<&'static str>,
}
impl CanvasContent {
pub fn to_json(&self) -> Value {
json!({"canvas": self.canvas, "bbox": self.bbox, "coverage": self.coverage, "touches": self.touches})
}
}
/// `rgb` is the window capture (top-down RGB8), `gl` the wxGLCanvas rect in window pixels
/// (left, top, right, bottom), `origin` the window's screen position.
pub fn canvas_content(rgb: &[u8], width: u32, height: u32, gl: (i32, i32, i32, i32), origin: (i32, i32)) -> Option<CanvasContent> {
let (w, h) = (width as i64, height as i64);
if w <= 0 || h <= 0 || rgb.len() < (w * h * 3) as usize {
return None;
}
let (l, t, r, b) = (gl.0 as i64, gl.1 as i64, gl.2 as i64, gl.3 as i64);
let (ins_x, ins_y) = ((r - l) * 3 / 100, (b - t) * 3 / 100);
let (x0, x1, y0, y1) = ((l + ins_x).max(0), (r - ins_x).min(w), (t + ins_y).max(0), (b - ins_y).min(h));
if x1 <= x0 || y1 <= y0 {
return None;
}
let n = 140;
let (sx, sy) = (((x1 - x0) / n).max(1), ((y1 - y0) / n).max(1));
let mut samples: Vec<(i64, i64, [u8; 3])> = Vec::new();
let mut y = y0;
while y < y1 {
let mut x = x0;
while x < x1 {
let o = ((y * w + x) * 3) as usize;
samples.push((x, y, [rgb[o] >> 4, rgb[o + 1] >> 4, rgb[o + 2] >> 4]));
x += sx;
}
y += sy;
}
if samples.is_empty() {
return None;
}
let mut counts: BTreeMap<[u8; 3], usize> = BTreeMap::new();
for (_, _, q) in &samples {
*counts.entry(*q).or_insert(0) += 1;
}
let bg = *counts.iter().max_by_key(|(_, c)| **c).map(|(k, _)| k).unwrap();
let is_bg = |q: &[u8; 3]| (q[0] as i32 - bg[0] as i32).abs() <= 1 && (q[1] as i32 - bg[1] as i32).abs() <= 1 && (q[2] as i32 - bg[2] as i32).abs() <= 1;
let cont: Vec<(i64, i64)> = samples.iter().filter(|(_, _, q)| !is_bg(q)).map(|(x, y, _)| (*x, *y)).collect();
let cov = cont.len() as f64 / samples.len() as f64;
let (wl, wt) = (origin.0 as i64, origin.1 as i64);
let canvas = ((wl + l) as i32, (wt + t) as i32, (wl + r) as i32, (wt + b) as i32);
if cont.is_empty() {
return Some(CanvasContent { canvas, bbox: None, coverage: 0.0, touches: vec![] });
}
let mut xs: Vec<i64> = cont.iter().map(|c| c.0).collect();
let mut ys: Vec<i64> = cont.iter().map(|c| c.1).collect();
xs.sort_unstable();
ys.sort_unstable();
// trim 2% outliers (stray grid/axis pixels) before taking the box
let k = (xs.len() as f64 * 0.02) as usize;
let (bx0, bx1, by0, by1) = (xs[k], xs[xs.len() - 1 - k], ys[k], ys[ys.len() - 1 - k]);
let mut touches = Vec::new();
if bx0 <= x0 + 2 * sx { touches.push("left") }
if bx1 >= x1 - 2 * sx { touches.push("right") }
if by0 <= y0 + 2 * sy { touches.push("top") }
if by1 >= y1 - 2 * sy { touches.push("bottom") }
Some(CanvasContent { canvas, bbox: Some(((wl + bx0) as i32, (wt + by0) as i32, (wl + bx1) as i32, (wt + by1) as i32)), coverage: round3(cov), touches })
}
/// FNV-1a over a capture, for the "two captures 0.8 s apart are identical" settle test.
pub fn fnv1a(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf29ce484222325;
for b in bytes {
h ^= *b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
// ── Motion plans (demo_motion.py) ─────────────────────────────────────────────
pub const VK_HOME: u16 = 0x24;
/// The 3D viewer's View-menu commands, resolved from the live menu bar: labels are stable
/// across KiCad 8/9/10, ids are not. Input: every leaf (label, id) of the menu tree.
pub fn menu_ids_from(items: &[(String, u32)]) -> BTreeMap<&'static str, u32> {
let wants: [(&str, [&str; 2]); 7] = [
("fit", ["zoom", "fit"]), ("zoom_in", ["zoom", "in"]), ("zoom_out", ["zoom", "out"]),
("rotx_cw", ["rotate x", "clockwise"]), ("rotx_ccw", ["rotate x", "counter"]),
("rotz_cw", ["rotate z", "clockwise"]), ("rotz_ccw", ["rotate z", "counter"]),
];
let mut ids = BTreeMap::new();
for (key, words) in wants {
let hit = items.iter().find(|(label, id)| {
let low = label.replace('&', "").to_lowercase();
*id != 0 && words.iter().all(|w| low.contains(w))
});
if let Some((_, id)) = hit {
ids.insert(key, *id);
}
}
// "clockwise" is a substring of "counterclockwise": guard the pairs.
if ids.get("rotx_cw").is_some() && ids.get("rotx_cw") == ids.get("rotx_ccw") {
ids.remove("rotx_ccw");
}
if ids.get("rotz_cw").is_some() && ids.get("rotz_cw") == ids.get("rotz_ccw") {
ids.remove("rotz_ccw");
}
ids
}
/// One 3D step through the menu: (name, command id, repetitions, dwell after each).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MenuStep {
pub name: &'static str,
pub id: u32,
pub reps: u32,
pub dwell_ms: u64,
}
/// Deterministic 3D motion through KiCad's own View menu (WM_COMMAND, 10 degrees per
/// rotate, no mouse, no foreground needed): mouse drags over-rotated the chip viewer and
/// did nothing in the board viewer on 2026-09-03. NO trailing Zoom to Fit: in the 3D
/// viewer it also RESETS the orientation, so the beat ended top-down on John's laptop.
/// Zoom in/out net to zero, so the board is still framed at the opening fit, now
/// isometric. None when the menu lacks the four commands the plan needs.
pub fn plan_3d_menu(ids: &BTreeMap<&'static str, u32>) -> Option<Vec<MenuStep>> {
let need = ["rotx_cw", "rotz_cw", "zoom_in", "zoom_out"];
if !need.iter().all(|k| ids.contains_key(k)) {
return None;
}
let g = |k: &str| ids[k];
let mut plan = Vec::new();
if let Some(fit) = ids.get("fit") {
plan.push(MenuStep { name: "extents", id: *fit, reps: 1, dwell_ms: 600 });
}
plan.extend([
MenuStep { name: "tilt", id: g("rotx_cw"), reps: 3, dwell_ms: 280 },
MenuStep { name: "turn", id: g("rotz_cw"), reps: 4, dwell_ms: 280 },
MenuStep { name: "zoom-in", id: g("zoom_in"), reps: 1, dwell_ms: 500 },
MenuStep { name: "orbit", id: g("rotz_cw"), reps: 6, dwell_ms: 320 },
MenuStep { name: "zoom-out", id: g("zoom_out"), reps: 1, dwell_ms: 500 },
MenuStep { name: "orbit-back", id: *ids.get("rotz_ccw").unwrap_or(&g("rotz_cw")), reps: 4, dwell_ms: 320 },
]);
Some(plan)
}
/// A framing correction after a 3D step (John, 2026-09-05): board clipped on two opposite
/// edges -> one zoom-out; board tiny -> one zoom-in. Returns the menu key to fire.
pub fn framing_correction(m: &CanvasContent) -> Option<&'static str> {
let t: BTreeSet<&str> = m.touches.iter().copied().collect();
if (t.contains("left") && t.contains("right")) || (t.contains("top") && t.contains("bottom")) {
return Some("zoom_out");
}
if m.coverage < 0.10 && m.bbox.is_some() {
return Some("zoom_in");
}
None
}
/// A real-input gesture (the mouse fallback and every 2D beat).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Gesture {
Key(u16),
/// clicks > 0 zooms in (wheel up), < 0 out, at screen (x, y).
Wheel { clicks: i32, x: i32, y: i32 },
Drag { button: &'static str, x0: i32, y0: i32, x1: i32, y1: i32, steps: u32, dt_ms: u64 },
}
/// The drawing canvas sits right of the editors' left panels: aim at 62% across and 55%
/// down the window, which is canvas in every KiCad editor and in the 3D viewer.
pub fn canvas_center(rect: (i32, i32, i32, i32)) -> (i32, i32) {
let (l, t, w, h) = rect;
(l + (w as f64 * 0.62) as i32, t + (h as f64 * 0.55) as i32)
}
/// John, 2026-09-03: "your zooming is too close ... zoom extents, then be smart about how
/// close". After extents (Home, done by the caller, who measures the drawing to find
/// `aim`), wheel-zoom AT the featured part (KiCad zooms about the cursor), pan it to the
/// centre, one more step in for a whole sheet or board, a short pan and back, zoom back
/// out, and END on extents so the frame the user is left with is the whole thing.
pub fn plan_2d(cx: i32, cy: i32, w: i32, h: i32, aim: (i32, i32), zoom: (i32, i32)) -> Vec<(&'static str, Gesture)> {
let (dx, dy) = ((w as f64 * 0.10) as i32, (h as f64 * 0.08) as i32);
let (z1, z2) = zoom;
let mut plan = vec![
("zoom-in", Gesture::Wheel { clicks: z1, x: aim.0, y: aim.1 }),
("centre", Gesture::Drag { button: "middle", x0: aim.0, y0: aim.1, x1: cx, y1: cy, steps: 18, dt_ms: 30 }),
];
if z2 != 0 {
plan.push(("zoom-in-2", Gesture::Wheel { clicks: z2, x: cx, y: cy }));
}
plan.extend([
("pan-look", Gesture::Drag { button: "middle", x0: cx, y0: cy, x1: cx - dx, y1: cy - dy, steps: 14, dt_ms: 30 }),
("pan-back", Gesture::Drag { button: "middle", x0: cx - dx, y0: cy - dy, x1: cx, y1: cy, steps: 14, dt_ms: 30 }),
("zoom-out", Gesture::Wheel { clicks: -(z1 + z2), x: cx, y: cy }),
("extents-2", Gesture::Key(VK_HOME)),
]);
plan
}
/// The mouse fallback for a 3D viewer whose menu could not be resolved. Measured
/// 2026-09-03 on a 3840 px wide viewer: KiCad's trackball is roughly 0.1 degree per pixel;
/// isometric wants about 30 degrees of tilt and 40 of yaw, then gentle orbits.
pub fn plan_3d_mouse(cx: i32, cy: i32, w: i32, h: i32) -> Vec<(&'static str, Gesture)> {
let tilt = (h as f64 * 0.12) as i32;
let yaw = (w as f64 * 0.03) as i32;
vec![
("fit", Gesture::Key(VK_HOME)),
("isometric", Gesture::Drag { button: "left", x0: cx, y0: cy, x1: cx - (w as f64 * 0.035) as i32, y1: cy - tilt, steps: 28, dt_ms: 35 }),
("orbit-1", Gesture::Drag { button: "left", x0: cx, y0: cy, x1: cx + yaw, y1: cy, steps: 30, dt_ms: 40 }),
("zoom-in", Gesture::Wheel { clicks: 2, x: cx, y: cy }),
("orbit-2", Gesture::Drag { button: "left", x0: cx, y0: cy, x1: cx + yaw, y1: cy, steps: 30, dt_ms: 40 }),
("zoom-out", Gesture::Wheel { clicks: -2, x: cx, y: cy }),
("orbit-3", Gesture::Drag { button: "left", x0: cx, y0: cy, x1: cx - yaw, y1: cy, steps: 30, dt_ms: 40 }),
]
}
/// The aim point for the 2D zoom: the featured part's fraction mapped onto the measured
/// drawing box, else the canvas centre.
pub fn aim_point(bbox: Option<(i32, i32, i32, i32)>, focus: Option<(f64, f64)>, center: (i32, i32)) -> (i32, i32) {
match (bbox, focus) {
(Some((l, t, r, b)), Some((fx, fy))) => (l + (fx * (r - l) as f64) as i32, t + (fy * (b - t) as f64) as i32),
_ => center,
}
}
// ── The final result ──────────────────────────────────────────────────────────
/// The `result.steps[]` row for one beat record (a JSON object with the per-beat fields).
pub fn step_row(r: &Value) -> Value {
let ok = r["ok"].as_bool().unwrap_or(false);
let raw = &r["raw"];
json!({
"step": r["step"], "title": r["title"], "ok": ok, "pointOut": r["pointOut"],
"foregrounded": r["foregrounded"], "motion": r["motion"], "hwnd": r["hwnd"], "window": r["window"],
"verified": r["verified"], "placed": r["placed"],
"startedAt": r["startedAt"], "narrationAt": r["narrationAt"],
"partAAt": r["partAAt"], "partBAt": r["partBAt"], "partBEndAt": r["partBEndAt"],
"failReason": if ok { Value::Null } else { json!({
"handlerSuccess": raw.get("success").and_then(Value::as_bool).unwrap_or(false),
"error": raw.get("error").map(|e| e.to_string().trim_matches('"').chars().take(200).collect::<String>()).unwrap_or_default(),
"output": raw.get("output").map(|e| e.to_string().trim_matches('"').chars().take(200).collect::<String>()).unwrap_or_default(),
}) },
})
}
/// `result` for a finished (or exited) background tour.
pub fn final_result(results: &[Value], total_beats: usize, exited: bool, state: Value) -> Value {
let ok_ct = results.iter().filter(|r| r["ok"].as_bool().unwrap_or(false)).count();
let failed: Vec<Value> = results.iter().filter(|r| !r["ok"].as_bool().unwrap_or(false)).map(|r| r["step"].clone()).collect();
if exited {
return json!({"openedBeats": ok_ct, "totalBeats": total_beats, "exited": true, "failedSteps": failed});
}
json!({
"openedBeats": ok_ct, "totalBeats": total_beats, "failedSteps": failed,
"steps": results.iter().map(step_row).collect::<Vec<_>>(),
"narrate": results.iter().filter(|r| r["ok"].as_bool().unwrap_or(false)).map(|r| r["say"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>().join(" "),
"state": state,
})
}
/// The staged reply's narration: a failed beat must STILL say something useful and hand
/// the user forward ("never end a demo on an error screen").
pub fn staged_narration(results: &[Value], installed_now: bool) -> String {
let mut lines: Vec<String> = Vec::new();
if installed_now {
lines.push("I installed KiCad for you first: that is the bridge doing real work on your machine, not a video.".into());
}
for r in results {
if r["ok"].as_bool().unwrap_or(false) {
lines.push(r["say"].as_str().unwrap_or("").to_string());
} else {
lines.push(format!("{} didn't open on this machine, so I'll skip it rather than leave you staring at nothing; the rest of the tour still works.", r["title"].as_str().unwrap_or(r["step"].as_str().unwrap_or("that beat"))));
}
}
lines.into_iter().filter(|l| !l.is_empty()).collect::<Vec<_>>().join(" ")
}
/// The step after `last` in the tour, if any.
pub fn next_step(last: &str) -> Option<&'static str> {
let i = STEPS.iter().position(|s| *s == last)?;
STEPS.get(i + 1).copied()
}
pub fn remaining_steps(last: &str) -> Vec<&'static str> {
match STEPS.iter().position(|s| *s == last) {
Some(i) => STEPS[i + 1..].to_vec(),
None => Vec::new(),
}
}
/// Reset every piece of process-wide tour state before a run (a mute, a queued press or a
/// failed panel show from an EARLIER tour must not steer this one).
pub fn reset_for_run(muted: bool) {
set_exit(false);
{
let mut p = panel();
p.ok = None;
p.shown_once = false;
p.script_state = None;
p.callback_accepted = None;
p.seen.clear();
}
{
let mut n = narr();
stop_narration_locked(&mut n);
n.muted = muted;
n.step = None;
}
ctrl().clear();
}
#[cfg(test)]
mod tests {
use super::*;
const SCH: &str = r#"(kicad_sch (version 20231120) (generator "eeschema")
(paper "A3")
(lib_symbols
(symbol "MCU_ST_STM32G4:STM32G431C_6-8-B_Tx"
(pin_names (offset 1.016))
(property "Reference" "U" (at 0 0 0))
(symbol "STM32G431C_6-8-B_Tx_1_1"
(pin bidirectional line (at -12.7 5.08 0) (length 2.54) (name "VDD") (number "1"))
)
)
(symbol "Device:R" (property "Reference" "R" (at 0 0 0)))
)
(symbol
(lib_id "MCU_ST_STM32G4:STM32G431C_6-8-B_Tx")
(at 84 160.38 0)
(property "Reference" "U5" (at 1 2 0))
)
(symbol (lib_id "Device:R") (at 10 10 0) (property "Reference" "R1" (at 1 2 0)))
)"#;
const PCB: &str = "(kicad_pcb (version 20240108)\n\t(gr_line\n\t\t(start 100 60)\n\t\t(end 170 60)\n\t\t(layer \"Edge.Cuts\")\n\t)\n\t(gr_line\n\t\t(start 170 100)\n\t\t(end 100 100)\n\t\t(layer \"Edge.Cuts\")\n\t)\n\t(footprint \"Package_QFP:LQFP-48_7x7mm_P0.5mm\"\n\t\t(layer \"F.Cu\")\n\t\t(uuid \"aaaa\")\n\t\t(at 121 86.4 90)\n\t\t(path \"/x/y\")\n\t\t(sheetname \"Root\")\n\t\t(property \"Reference\" \"U5\"\n\t\t\t(at 0 -4 0)\n\t\t)\n\t\t(property \"Value\" \"STM32G431C_6-8-B_Tx\"\n\t\t\t(at 0 4 0)\n\t\t)\n\t\t(pad \"1\" smd roundrect\n\t\t\t(at -3.75 -2.75)\n\t\t\t(net 12 \"GND\")\n\t\t\t(pinfunction \"VBAT\")\n\t\t\t(pintype \"power_in\")\n\t\t\t(uuid \"pad1\")\n\t\t)\n\t\t(model \"C:/Users/caleb/parts/lqfp.step\"\n\t\t\t(offset (xyz 0 0 0))\n\t\t)\n\t)\n\t(footprint \"MOSFET_CDT:TDSON-8-1_BSC016N06NS\"\n\t\t(at 150 70)\n\t\t(property \"Reference\" \"Q1\"\n\t\t\t(at 0 0 0)\n\t\t)\n\t\t(model \"${KICAD10_3DMODEL_DIR}/Nope.3dshapes/missing.step\"\n\t\t\t(offset (xyz 0 0 0))\n\t\t)\n\t)\n\t(footprint \"Capacitor_SMD:C_0402\"\n\t\t(at 1 1)\n\t\t(property \"Reference\" \"C1\"\n\t\t\t(at 0 0 0)\n\t\t)\n\t\t(model \"${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0402.wrl\"\n\t\t\t(offset (xyz 0 0 0))\n\t\t)\n\t)\n\t(footprint \"Weird:NoModelAnywhere\"\n\t\t(at 2 2)\n\t\t(property \"Reference\" \"J9\"\n\t\t\t(at 0 0 0)\n\t\t)\n\t)\n)\n";
#[test]
fn beat_table_and_plan() {
assert_eq!(BEATS.len(), 6);
assert_eq!(BEATS.iter().map(|b| b.step).collect::<Vec<_>>(), STEPS);
assert_eq!(beat_plan(None).unwrap().len(), 6);
let sub = beat_plan(Some(&["board3d".to_string(), "symbol".to_string()])).unwrap();
assert_eq!(sub.iter().map(|b| b.step).collect::<Vec<_>>(), vec!["symbol", "board3d"], "tour order, not caller order");
assert!(beat_plan(Some(&["nope".to_string()])).unwrap_err().contains("valid steps"));
assert_eq!(beat_est("prepare"), 25);
assert_eq!(beat_est("board3d"), 30);
assert_eq!(beat_est("footprint3d"), 16);
assert_eq!(total_est(), 125);
assert_eq!(step_label("footprint3d"), "part_3d");
assert_eq!(caption_a(2, 7, BEATS[0].caption_a), "KiCad tour 2/7: the STM32G431 symbol, lifted from the board's schematic");
assert!(say("footprint3d", false).contains("Pads only"));
assert!(say("footprint3d", true).contains("LQFP-48"));
assert_eq!(point_out("board3d", false).len(), 2);
assert_eq!(point_out("board3d", true).len(), 3);
assert_eq!(panel_stops(&beat_plan(None).unwrap()).len(), 7);
assert_eq!(next_step("symbol"), Some("footprint"));
assert_eq!(next_step("board3d"), None);
assert_eq!(remaining_steps("board"), vec!["board3d"]);
for b in BEATS {
for t in [b.title, b.caption_a, b.caption_b, b.stop] {
assert!(!t.contains('\u{2014}'), "{} carries an em dash", b.step);
}
}
}
#[test]
fn titles_match_with_either_dash() {
let t = tour_titles("esc-g431");
assert_eq!(t.board, "esc-g431 \u{2014} PCB Editor");
assert!(title_matches("esc-g431 \u{2014} PCB Editor", &t.board));
assert!(title_matches("*esc-g431 - PCB Editor", &t.board), "an older KiCad spells the separator with a hyphen");
assert!(title_matches("ADOM:STM32G431C_6-8-B_Tx \u{2014} Symbol Editor", &t.symbol));
assert!(!title_matches("PCB Editor", &t.board));
assert!(title_matches("anything", ""));
assert_eq!(doc_stem(Some("C:/x/esc-g431.kicad_pcb"), None), "esc-g431");
assert_eq!(doc_stem(None, None), "esc-g431");
assert!(is_stale_tour_window("3D Viewer"));
assert!(is_stale_tour_window("Adom:X \u{2014} Footprint Editor"));
assert!(is_stale_tour_window(" PCB Editor "));
assert!(!is_stale_tour_window("esc-g431 \u{2014} PCB Editor"));
}
#[test]
fn extracts_symbol_and_footprint() {
let sym = extract_symbol(SCH, CHIP_LIB_ID).unwrap();
assert!(sym.starts_with("(kicad_symbol_lib (version 20231120) (generator \"adom-demo\")"));
assert!(sym.contains("(symbol \"STM32G431C_6-8-B_Tx\"\n"));
assert!(!sym.contains("MCU_ST_STM32G4:"));
assert!(sym.contains("(number \"1\")"));
assert!(!sym.contains("Device:R"));
assert!(extract_symbol(SCH, "Nope:X").unwrap_err().contains("not in lib_symbols"));
let fp = extract_footprint(PCB, CHIP_FOOTPRINT, CHIP_REF, IC_FP).unwrap();
assert!(fp.starts_with("(footprint \"LQFP-48_STM32G431\"\n\t(version 20240108)\n\t(generator \"adom-demo\")"));
assert!(!fp.contains("(at 121 86.4 90)"), "placement stripped");
assert!(fp.contains("(at -3.75 -2.75)"), "pad positions kept");
assert!(!fp.contains("(net "));
assert!(!fp.contains("(pinfunction"));
assert!(!fp.contains("(path "));
assert!(!fp.contains("(uuid \"aaaa\")"));
assert!(fp.contains("(uuid \"pad1\")"), "pad uuids stay (only the two-tab footprint uuid goes)");
assert!(fp.contains("(property \"Reference\" \"REF**\""));
assert!(fp.contains("(property \"Value\" \"LQFP-48_STM32G431\""));
assert!(fp.contains("(model "), "the 3D model reference is kept");
assert!(extract_footprint(PCB, CHIP_FOOTPRINT, "U99", IC_FP).is_err());
let (lib, fpid) = chip_ids_for_ref(SCH, PCB, "U5");
assert_eq!(lib.as_deref(), Some(CHIP_LIB_ID));
assert_eq!(fpid.as_deref(), Some(CHIP_FOOTPRINT));
assert_eq!(chip_ids_for_ref(SCH, PCB, "R1"), (Some("Device:R".into()), None));
}
#[test]
fn attaches_standard_models_and_drops_dead_ones() {
let have = ["Package_QFP.3dshapes/LQFP-48_7x7mm_P0.5mm.step", "Package_TO_SOT_SMD.3dshapes/TDSON-8-1.step", "Capacitor_SMD.3dshapes/C_0402.wrl"];
let exists = |rel: &str| have.contains(&rel);
let (out, rep) = attach_standard_models(PCB, &exists);
assert_eq!(rep.dead_dropped, 2, "the author's disk path and the missing standard body");
assert_eq!(rep.attached, 2, "the LQFP and the aliased MOSFET");
assert_eq!(rep.skipped, 1);
assert_eq!(rep.missing_models, vec!["Weird:NoModelAnywhere".to_string()]);
assert!(!out.contains("caleb"));
assert!(out.contains("${KICAD10_3DMODEL_DIR}/Package_QFP.3dshapes/LQFP-48_7x7mm_P0.5mm.step"));
assert!(out.contains("${KICAD10_3DMODEL_DIR}/Package_TO_SOT_SMD.3dshapes/TDSON-8-1.step"));
assert!(out.contains("Capacitor_SMD.3dshapes/C_0402.wrl"), "a resolvable standard body is left alone");
assert_eq!(out.matches("(model ").count(), 3);
// Balanced: every footprint block still closes.
for h in ["Package_QFP", "MOSFET_CDT", "Capacitor_SMD", "Weird:"] {
let i = out.find(&format!("(footprint \"{h}")).unwrap();
assert!(sexpr_block(&out, i).ends_with(')'));
}
// Idempotent on the patched text.
let (again, rep2) = attach_standard_models(&out, &exists);
assert_eq!(again, out);
assert_eq!(rep2.attached, 0);
assert_eq!(rep2.dead_dropped, 0);
assert!(model_is_dead("D:\\parts\\x.step", &exists));
assert!(model_is_dead("../x.step", &exists));
assert!(model_is_dead("${ADOM_MODELS}/x.step", &exists));
assert!(!model_is_dead("${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0402.wrl", &exists));
assert!(!model_is_dead("${KIPRJMOD}/x.step", &exists));
}
#[test]
fn focus_fractions_find_the_mcu() {
let f = focus_fractions(SCH, PCB, CHIP_LIB_ID, CHIP_REF);
assert_eq!(f["schematic"], (0.2, 0.54));
assert_eq!(f["board"], (0.3, 0.66));
let portrait = SCH.replace("(paper \"A3\")", "(paper \"A3\" portrait)");
let f = focus_fractions(&portrait, PCB, CHIP_LIB_ID, CHIP_REF);
assert_eq!(f["schematic"], (0.283, 0.382));
assert!(focus_fractions("", "", CHIP_LIB_ID, CHIP_REF).is_empty());
}
#[test]
fn layout_and_front_window() {
assert_eq!(place_rect((0, 0, 1000, 500), (0.5, 0.5), 0.98), (10, 5, 980, 490));
assert_eq!(place_rect((100, 50, 1000, 500), (0.0, 1.0), 0.8), (100, 150, 800, 400));
let mons = json!([{"primary": false, "bounds": {"x": -1920, "y": 0, "width": 1920, "height": 1080}}, {"primary": true, "workArea": {"x": 0, "y": 0, "width": 3840, "height": 2100}, "bounds": {"x": 0, "y": 0, "width": 3840, "height": 2160}}]);
assert_eq!(primary_monitor(&mons), Some((0, 0, 3840, 2100)), "workArea keeps the windows off the taskbar");
let one = json!([{"bounds": {"left": 5, "top": 6, "width": 100, "height": 50}}]);
assert_eq!(primary_monitor(&one), Some((5, 6, 100, 50)));
assert_eq!(primary_monitor(&json!([])), None);
let rows = vec![
ZRow { hwnd: 1, z: Some(0), minimized: false, title: "PopupHost".into(), image: "x.exe".into() },
ZRow { hwnd: 2, z: Some(1), minimized: false, title: "Program Manager".into(), image: "explorer.exe".into() },
ZRow { hwnd: 3, z: Some(2), minimized: true, title: "esc-g431 - PCB Editor".into(), image: "pcbnew.exe".into() },
ZRow { hwnd: 4, z: Some(3), minimized: false, title: "3D Viewer".into(), image: "pcbnew.exe".into() },
ZRow { hwnd: 5, z: None, minimized: false, title: "no z".into(), image: "".into() },
];
assert_eq!(front_hwnd(&rows), 4);
assert_eq!(front_hwnd(&[]), 0);
}
#[test]
fn transport_is_fifo_and_exit_jumps_the_queue() {
let mut t = Transport::default();
t.push(Cmd::Next);
t.push(Cmd::Prev);
assert!(t.pending_interrupt());
assert_eq!(t.pop(), Some(Cmd::Next));
assert_eq!(t.pop(), Some(Cmd::Prev));
assert_eq!(t.pop(), None);
for _ in 0..20 {
t.push(Cmd::Play);
}
assert_eq!(t.queue.len(), 8, "capped");
t.push(Cmd::Exit);
assert_eq!(t.queue, vec![Cmd::Exit]);
t.clear();
t.push(Cmd::Play);
assert!(!t.pending_interrupt(), "play does not cut a beat");
t.push_front(Cmd::Pause);
assert_eq!(t.head(), Some(Cmd::Pause));
assert_eq!(map_button("Back"), Some(Cmd::Prev));
assert_eq!(map_button("stop"), Some(Cmd::Exit));
assert_eq!(map_button("mute"), None);
assert_eq!(Cmd::parse("PREV"), Some(Cmd::Prev));
assert_eq!(Cmd::parse("mute"), None);
assert_eq!(ack_text("unmute"), Some("Remote: narration back on"));
assert_eq!(apply_between_beats(3, Some(Cmd::Prev)), (1, false));
assert_eq!(apply_between_beats(1, Some(Cmd::Prev)), (0, false));
assert_eq!(apply_between_beats(2, Some(Cmd::Exit)), (2, true));
assert_eq!(apply_between_beats(2, Some(Cmd::Next)), (2, false));
assert_eq!(apply_between_beats(2, None), (2, false));
// The process-wide queue.
reset_for_run(false);
set_control(Cmd::Next);
assert!(pending_interrupt());
assert_eq!(ctrl().pop(), Some(Cmd::Next));
assert!(!pending_interrupt());
set_exit(true);
assert!(pending_interrupt());
reset_for_run(true);
assert!(!exit_requested());
assert!(is_muted());
reset_for_run(false);
}
#[test]
fn job_record_and_progress_reply() {
{
let mut g = JOB.lock().unwrap_or_else(|e| e.into_inner());
*g = None;
}
let id = job_start(true, 125, 6, json!({}), json!({"record": false})).unwrap();
assert!(id.starts_with("demo-"));
assert!(job_active());
assert!(job_start(false, 125, 6, json!({}), json!({})).is_err(), "one job at a time");
job_update(json!({"step": "symbol", "stepLabel": "Opening symbol", "beatIndex": 1, "percent": 20, "elapsedSec": 30}));
job_push("pendingShots", json!({"step": "a"}), 10);
job_push("pendingShots", json!({"step": "b"}), 10);
for i in 0..30 {
job_push("presses", json!({"n": i}), 8);
}
assert_eq!(job_get("presses").as_array().unwrap().len(), 8);
let shot = job_pop_shot();
let r = progress_reply(&job_snapshot(), shot);
assert_eq!(r["running"], json!(true));
assert_eq!(r["active"][0]["phase"], json!("kicad.demo"));
assert_eq!(r["active"][0]["etaSec"], json!(95.0));
assert_eq!(r["active"][0]["beatIndex"], json!(1));
assert_eq!(r["stepShot"]["step"], json!("a"));
assert_eq!(r["muted"], Value::Null, "the thread sets muted when it applies it");
assert_eq!(r["jobId"], json!(id));
assert!(r["_hint"].as_str().unwrap().contains("progress bar"));
job_update(json!({"active": false, "done": true, "percent": 100, "result": {"openedBeats": 6}}));
let r = progress_reply(&job_snapshot(), job_pop_shot());
assert_eq!(r["running"], json!(false));
assert_eq!(r["active"], json!([]));
assert_eq!(r["stepShot"]["step"], json!("b"));
assert_eq!(r["result"]["openedBeats"], json!(6));
assert!(r["_hint"].as_str().unwrap().contains("Demo complete"));
assert!(job_pop_shot().is_none());
assert_eq!(percent_for(25, 125), 20);
assert_eq!(percent_for(125, 125), 95);
assert_eq!(percent_for(0, 0), 0);
}
#[test]
fn mp3_duration_from_the_frame_header() {
// A 48 kbps MPEG-2 layer III header (24 kHz): 0xFF 0xF3 0x64 0xC4, no ID3 tag.
let mut b = vec![0xffu8, 0xf3, 0x64, 0xc4];
b.resize(48_000 / 8 * 10, 0);
let d = mp3_duration_secs(&b).unwrap();
assert!((d - 10.0).abs() < 0.01, "{d}");
// With an ID3v2 tag of 100 bytes in front.
let mut tagged = vec![b'I', b'D', b'3', 3, 0, 0, 0, 0, 0, 100];
tagged.resize(110, 0);
tagged.extend_from_slice(&b);
let d = mp3_duration_secs(&tagged).unwrap();
assert!((d - 10.0).abs() < 0.01, "{d}");
// MPEG-1 128 kbps at 44.1 kHz: 0xFF 0xFB 0x90 0x00.
let mut m1 = vec![0xffu8, 0xfb, 0x90, 0x00];
m1.resize(128_000 / 8 * 3, 0);
assert!((mp3_duration_secs(&m1).unwrap() - 3.0).abs() < 0.01);
assert_eq!(mp3_duration_secs(b"not audio at all"), None);
// The shipped clips, when the checkout is at hand: symbol-a is 9.9 s per ffprobe.
let here = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../resources/demo-audio/symbol-a.mp3");
if let Ok(bytes) = std::fs::read(&here) {
let d = mp3_duration_secs(&bytes).unwrap();
assert!((d - 9.9).abs() < 0.2, "{d}");
}
}
#[test]
fn canvas_content_finds_the_drawing_and_its_edges() {
// 400x300 window, canvas at (100, 0)-(400, 300), grey background, a red box in
// the lower-left quarter of the canvas.
let (w, h) = (400u32, 300u32);
let mut rgb = vec![60u8; (w * h * 3) as usize];
for y in 150..290 {
for x in 110..250 {
let o = ((y * w + x) * 3) as usize;
rgb[o] = 220;
rgb[o + 1] = 30;
rgb[o + 2] = 30;
}
}
let c = canvas_content(&rgb, w, h, (100, 0, 400, 300), (1000, 500)).unwrap();
assert_eq!(c.canvas, (1100, 500, 1400, 800));
let bb = c.bbox.unwrap();
assert!(bb.0 >= 1105 && bb.0 <= 1115, "{bb:?}");
assert!(bb.2 >= 1240 && bb.2 <= 1252, "{bb:?}");
assert!(bb.1 >= 648 && bb.1 <= 660, "{bb:?}");
assert!(bb.3 >= 780 && bb.3 <= 790, "{bb:?}");
assert!(c.coverage > 0.15 && c.coverage < 0.30, "{}", c.coverage);
assert_eq!(c.touches, vec!["left", "bottom"]);
assert_eq!(framing_correction(&c), None);
let blank = canvas_content(&vec![60u8; (w * h * 3) as usize], w, h, (100, 0, 400, 300), (0, 0)).unwrap();
assert_eq!(blank.bbox, None);
assert_eq!(blank.coverage, 0.0);
let clipped = CanvasContent { canvas: (0, 0, 1, 1), bbox: Some((0, 0, 1, 1)), coverage: 0.9, touches: vec!["left", "right"] };
assert_eq!(framing_correction(&clipped), Some("zoom_out"));
let tiny = CanvasContent { canvas: (0, 0, 1, 1), bbox: Some((0, 0, 1, 1)), coverage: 0.05, touches: vec![] };
assert_eq!(framing_correction(&tiny), Some("zoom_in"));
assert!(canvas_content(&rgb, w, h, (395, 0, 390, 5), (0, 0)).is_none(), "an inverted canvas rect answers None");
assert_ne!(fnv1a(b"a"), fnv1a(b"b"));
assert_eq!(fnv1a(b""), 0xcbf29ce484222325);
}
#[test]
fn motion_plans_are_data() {
let items: Vec<(String, u32)> = vec![
("Zoom to &Fit".into(), 100), ("Zoom &In".into(), 101), ("Zoom &Out".into(), 102),
("Rotate X Clockwise".into(), 110), ("Rotate X Counterclockwise".into(), 111),
("Rotate Z Clockwise".into(), 120), ("Rotate Z Counterclockwise".into(), 121), ("Dead".into(), 0),
];
let ids = menu_ids_from(&items);
assert_eq!(ids["fit"], 100);
assert_eq!(ids["rotx_ccw"], 111);
assert_eq!(ids["rotz_ccw"], 121);
let plan = plan_3d_menu(&ids).unwrap();
assert_eq!(plan[0].name, "extents");
assert_eq!(plan.last().unwrap().id, 121, "orbit-back uses the counterclockwise id when present");
assert_eq!(plan.iter().filter(|s| s.name == "zoom-in").count(), 1);
assert_eq!(plan.iter().map(|s| s.reps).sum::<u32>(), 20);
// KiCad 8 spells only "Rotate X Clockwise" with the ccw id colliding: the pair guard.
let dup: Vec<(String, u32)> = vec![("Zoom In".into(), 1), ("Zoom Out".into(), 2), ("Rotate X Clockwise / Counterclockwise".into(), 5), ("Rotate Z Clockwise / Counterclockwise".into(), 6)];
let ids = menu_ids_from(&dup);
assert!(!ids.contains_key("rotx_ccw"));
let plan = plan_3d_menu(&ids).unwrap();
assert_eq!(plan[0].name, "tilt", "no fit item, no extents step");
assert_eq!(plan.last().unwrap().id, 6, "orbit-back falls back to the clockwise id");
assert!(plan_3d_menu(&menu_ids_from(&[("Zoom In".to_string(), 1)])).is_none());
let p = plan_2d(500, 400, 1000, 800, (300, 200), (2, 1));
assert_eq!(p[0], ("zoom-in", Gesture::Wheel { clicks: 2, x: 300, y: 200 }));
assert!(matches!(p[1].1, Gesture::Drag { button: "middle", x0: 300, y0: 200, x1: 500, y1: 400, .. }));
assert_eq!(p[2].0, "zoom-in-2");
assert_eq!(p.last().unwrap(), &("extents-2", Gesture::Key(VK_HOME)));
assert!(matches!(p[p.len() - 2].1, Gesture::Wheel { clicks: -3, .. }));
let p1 = plan_2d(500, 400, 1000, 800, (500, 400), (1, 0));
assert!(p1.iter().all(|(n, _)| *n != "zoom-in-2"), "a single part gets one zoom step");
assert_eq!(plan_3d_mouse(500, 400, 1000, 800).len(), 7);
assert_eq!(canvas_center((100, 100, 1000, 1000)), (720, 650));
assert_eq!(aim_point(Some((0, 0, 200, 100)), Some((0.25, 0.5)), (9, 9)), (50, 50));
assert_eq!(aim_point(None, Some((0.25, 0.5)), (9, 9)), (9, 9));
}
#[test]
fn results_and_narration_text() {
let results = vec![
json!({"step": "symbol", "title": "1/6 \u{b7} The MCU symbol", "ok": true, "say": "one.", "pointOut": ["a"], "hwnd": 5, "window": "x", "raw": {"success": true}}),
json!({"step": "footprint", "title": "2/6 \u{b7} Its footprint", "ok": false, "say": "two.", "raw": {"success": false, "error": "boom", "output": ""}}),
];
let r = final_result(&results, 6, false, json!({"running": true}));
assert_eq!(r["openedBeats"], json!(1));
assert_eq!(r["failedSteps"], json!(["footprint"]));
assert_eq!(r["narrate"], json!("one."));
assert_eq!(r["steps"][1]["failReason"]["error"], json!("boom"));
assert_eq!(r["steps"][0]["failReason"], Value::Null);
assert_eq!(r["state"]["running"], json!(true));
let e = final_result(&results, 6, true, Value::Null);
assert_eq!(e["exited"], json!(true));
assert!(e.get("steps").is_none());
let n = staged_narration(&results, true);
assert!(n.starts_with("I installed KiCad for you first"));
assert!(n.contains("one."));
assert!(n.contains("2/6 \u{b7} Its footprint didn't open"));
assert!(!n.contains('\u{2014}'));
}
#[test]
fn sexpr_block_is_quote_aware() {
let t = r#"(a (b "x)y") (c))(d)"#;
assert_eq!(sexpr_block(t, 0), r#"(a (b "x)y") (c))"#);
assert_eq!(sexpr_block("(unbalanced (x", 0), "(unbalanced (x");
}
}