app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 171 file(s)
15 files changed
+287−78
BRIDGE_VERSION+1−1addin/AdomBridge/commands/activity_palette.py+63−22bridge.json+1−1dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1dashboard/ui/index.html+10−7install.sh+1−1package.json+1−1page.json+10−26rust/Cargo.toml+1−1rust/bridge/src/tour.rs+197−16tools/check-naming.shtools/check-portable.shtools/promote.shtools/release.shBRIDGE_VERSION+1−1@@ -1 +1 @@-1.11.40+1.11.41
addin/AdomBridge/commands/activity_palette.py+63−22@@ -254,28 +254,10 @@ def handle_activity_banner(app: adsk.core.Application, args: dict) -> dict: except Exception: pass # Fusion's electronics editors leave it FLOATING (dockingState reads 0 after the- # set, 2026-09-05) and drop it over the top right of the canvas, where it hid the- # symbol's right pins in the tour. A floating palette is kept small and parked over- # the right-hand panel column, off the drawing.- # John, 2026-10-08: on a screen recording it still sat over the board ("Switching to the 2D- # board" covering the silkscreen being drawn), so it is smaller again and parked low on the- # right, over the layer list, where nothing is being drawn.- try:- if int(_palette.dockingState) == 0:- W_, H_ = 260, 120- _palette.setSize(W_, H_)- import ctypes- sw = int(ctypes.windll.user32.GetSystemMetrics(0))- sh = int(ctypes.windll.user32.GetSystemMetrics(1))- try:- scale = float(ctypes.windll.shcore.GetScaleFactorForDevice(0)) / 100.0- except Exception:- scale = 1.0- if scale <= 0:- scale = 1.0- _palette.setPosition(int(sw / scale) - W_ - 24, int(sh / scale) - H_ - 140)- except Exception:- pass+ # set, 2026-09-05) and drop it over the canvas, where it hid the symbol's right pins+ # in the tour and, on a 2026-10-08 recording, the silkscreen being drawn. Park it in+ # the bottom-right corner of Fusion's own window, off the drawing.+ _park_floating(_palette) else: if _user_closed[0]: # The user closed it during this run: honour that, keep the content current@@ -283,6 +265,8 @@ def handle_activity_banner(app: adsk.core.Application, args: dict) -> dict: pass elif not _palette.isVisible: _palette.isVisible = True+ if action == "show":+ _park_floating(_palette) # a new run: back to the corner, even if dragged last run # DATA, NOT A RELOAD: the page applies the payload in place. Right after creation # the page may not have run its script yet; retry briefly, and only if the message # never lands fall back to one reload of the shell (never per update).@@ -327,6 +311,63 @@ def handle_activity_banner(app: adsk.core.Application, args: dict) -> dict: "advisory": True} +def _park_floating(palette):+ """Move a FLOATING palette to the bottom-right corner of the Fusion window that owns it.+ Never raises.++ Measured on hardware (arav-rog, 1.11.40, 2026-10-17), the two API calls the old code relied+ on do not work for a floating electronics palette:+ - setSize is ignored, and so are setMinimumSize/setMaximumSize: the window stays 380x410+ logical whatever is asked, and a Win32 resize snaps back at once. So the size is a given+ and only the position is ours.+ - setPosition throws InternalValidationError (setAreaPosition) for a negative X, which is+ every position on a monitor left of the primary one. The old math also aimed at the+ PRIMARY screen (GetSystemMetrics), not the screen Fusion is on.+ So: find the palette's own top-level window (titled ADOM, in this process), read its owner's+ rect, and SetWindowPos it there without resizing. Both rects come from the same Win32 calls+ in the same process, so they share one coordinate space whatever the DPI setup."""+ try:+ if int(palette.dockingState) != 0:+ return+ palette.setSize(260, 120) # harmless where ignored; honoured by builds that allow it+ import ctypes+ from ctypes import wintypes+ u = ctypes.windll.user32+ pid = ctypes.windll.kernel32.GetCurrentProcessId()+ found = []++ @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)+ def _cb(h, _l):+ p = wintypes.DWORD()+ u.GetWindowThreadProcessId(h, ctypes.byref(p))+ if p.value == pid and u.IsWindowVisible(h):+ b = ctypes.create_unicode_buffer(64)+ u.GetWindowTextW(h, b, 64)+ if b.value.strip().lower() == "adom":+ found.append(h)+ return True+ u.EnumWindows(_cb, 0)+ if not found:+ return+ h = found[0]+ owner = u.GetWindow(h, 4) # GW_OWNER: the Fusion main window+ if not owner:+ return++ def _rect(w):+ r = wintypes.RECT()+ u.GetWindowRect(w, ctypes.byref(r))+ return r.left, r.top, r.right, r.bottom+ pl, pt, pr, pb = _rect(h)+ ol, ot, orr, ob = _rect(owner)+ w, hh = pr - pl, pb - pt+ x = max(ol, orr - w - 16)+ y = max(ot, ob - hh - 48) # clear of Fusion's bottom toolbar+ u.SetWindowPos(h, 0, x, y, 0, 0, 0x0001 | 0x0004 | 0x0010) # NOSIZE | NOZORDER | NOACTIVATE+ except Exception:+ pass++ def teardown(): """Called from the add-in's stop(): remove the palette so a reload does not stack them.""" global _palette
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.11.40",+ "version": "1.11.41", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/fusion-bridge", "author": "Adom Inc.",
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.11.40"+version = "1.11.41" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.11.40"+version = "1.11.41" edition = "2021" [dependencies]
dashboard/ui/index.html+10−7@@ -450,13 +450,14 @@ if(REMOTE_ID){ '</div><div class="btnrow" style="margin-top:8px">'+ '<button class="btn" id="rExit" style="border-color:rgba(248,81,73,.5);color:var(--red)" data-tip="End the tour">⏹ Exit tour</button>'+ '</div></div>';- let rp=false;- const rctl=async(a)=>{try{+ let rp=false, rBusy=false, rExited=false;+ const rctl=async(a)=>{if(rBusy||rExited)return;rBusy=true;try{ let ok=await act('tour_control',{demoId:REMOTE_ID,action:a}).catch(()=>null); if(!ok)await act('tour_control',{demoId:REMOTE_ID,action:a});+ if(a==='exit')rExited=true; if(a==='pause'){rp=true;$('#rPause').innerHTML='▶';} if(a==='resume'){rp=false;$('#rPause').innerHTML='⏸';}- }catch(e){}};+ }catch(e){}finally{rBusy=false;}}; $('#rPause').onclick=()=>rctl(rp?'resume':'pause'); $('#rNext').onclick=()=>rctl('next'); $('#rPrev').onclick=()=>rctl('prev');@@ -591,8 +592,10 @@ async function startFusionDemo(){ const tt=$('#tourTransport');tt.style.display='flex'; tourSetMuted(tourMuted()); // paint the mute button from the persisted state $('#ttMute').onclick=()=>tourSetMuted(!tourMuted());- let ttPaused=false;- const ctl=async(action)=>{try{+ let ttPaused=false, ctlBusy=false, ctlExited=false;+ // one control in flight at a time, nothing after Exit (issue #121: repeated Back/Exit clicks+ // queued a replay loop); the bridge debounces too+ const ctl=async(action)=>{if(ctlBusy||ctlExited)return;ctlBusy=true;try{ let r=await act('tour_control',{demoId:id,action}).catch(()=>null); if(!r)r=await act('tour_control',{demoId:id,action}); // one silent retry - a slow relay hop must not eat a pause click const x=r.data||r;@@ -601,8 +604,8 @@ async function startFusionDemo(){ if(action==='resume'){ttPaused=false;$('#ttPause').innerHTML='⏸';if(tourAudioEl&&!tourMuted())tourAudioEl.play().catch(()=>{});toast('resuming');} if(action==='next')toast('skipping the upcoming step'); if(action==='prev')toast('replaying the previous view');- if(action==='exit'){if(tourAudioEl)tourAudioEl.pause();tourAudioQueue=[];toast('exiting the demo (documents will be cleaned up)');}- }catch(e){toast('control failed: '+e,'err');}};+ if(action==='exit'){ctlExited=true;if(tourAudioEl)tourAudioEl.pause();tourAudioQueue=[];toast('exiting the demo (documents will be cleaned up)');}+ }catch(e){toast('control failed: '+e,'err');}finally{ctlBusy=false;}}; $('#ttPause').onclick=()=>ctl(ttPaused?'resume':'pause'); $('#ttNext').onclick=()=>ctl('next'); $('#ttPrev').onclick=()=>ctl('prev');
install.sh+1−1@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.11.40"+DASH_VERSION="1.11.41" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.11.40",+ "version": "1.11.41", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
page.json+10−26@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.11.40",+ "version": "1.11.41", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {@@ -11,6 +11,10 @@ "uninstall": "./uninstall.sh" }, "tags": [+ "ab",+ "bridge",+ "fusion",+ "desktop-app", "fusion360", "autodesk", "cad",@@ -76,6 +80,9 @@ "add a 3d model to a component", "make a fusion 360 library with 3d", "set up the fusion bridge",+ "fusion dashboard",+ "fusion status page",+ "open the fusion launch page", "aps not configured", "share aps client id across my org", "set up fusion cloud search for my company",@@ -108,28 +115,5 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ],- "agent_permissions": {- "allow_bash": [],- "allow": [- "adom-desktop-fusion-bridge read-only verbs (via the adom-bridge CLI): fusion_readiness, fusion_get_app_state, fusion_addin_status, fusion_check_dialogs - state queries; they change nothing.",- "adom-desktop-fusion-bridge read-only verbs: fusion_alerts (reads Fusion's own log for toasts), fusion_document_info, fusion_window_info, fusion_board_info, fusion_library_parts - inspect only.",- "adom-desktop-fusion-bridge read-only verbs: fusion_prove_result (a persisted run report), fusion_plan_library_import (report-only preflight), fusion_list_cloud_projects, fusion_describe.",- "adom-desktop-fusion-bridge evidence captures fusion_screenshot_fusion and fusion_capture_library_views read pixels and write local image files only."- ],- "soft_deny": [- "adom-desktop-fusion-bridge lifecycle verbs fusion_stop, fusion_kill, fusion_restart_for_addin close or kill the Fusion application - keep the human prompt.",- "adom-desktop-fusion-bridge fusion_install_fusion silently installs Autodesk Fusion on the user's machine.",- "adom-desktop-fusion-bridge fusion_cleanup_cloud_files deletes files from the user's Autodesk cloud hub.",- "adom-desktop-fusion-bridge managed-library verbs (fusion_managed_library_push/pull/create/link/unlink) publish to or overwrite the user's library.io libraries."- ],- "environment": [- "adom-desktop-fusion-bridge is the Fusion 360 bridge for Adom Bridge; its fusion_* verbs are invoked through the adom-bridge CLI and drive Autodesk Fusion on the user's own desktop.",- "adom-desktop-fusion-bridge desktop actions also pass Adom Bridge's own human-approval gate (caller identity, per-call reason, Activity Log) - a container approval is the first of two gates."- ]- },- "author": {- "name": "John Lauer",- "email": "[email protected]"- }-}+ ]+}
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"] # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.11.40"+version = "1.11.41" edition = "2021" license = "MIT"
rust/bridge/src/tour.rs+197−16@@ -58,6 +58,14 @@ static PAUSED: AtomicBool = AtomicBool::new(false); static GENERIC_USED: Mutex<Vec<String>> = Mutex::new(Vec::new()); /// The tour's state dir, for detached helpers (the in-Fusion palette) that need their own Ctx. static STATE_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);+/// Set once this run's remote is stopped or hidden (by the tour, the user or an agent): nothing+/// shows the panel again until the next run (issue #121: it came back after a hide).+static PANEL_CLOSED: AtomicBool = AtomicBool::new(false);+/// When each transport command was last accepted or taken, for the debounce below.+static LAST_CMD: Mutex<Vec<(String, Instant)>> = Mutex::new(Vec::new());+/// A repeat of a transport command inside this window of the last one is dropped (issue #121:+/// eleven Back presses in 15 s replayed beats in a loop).+const DEBOUNCE: Duration = Duration::from_millis(3000); /// One timestamped event in the record (post-production cuts on these). fn event(name: &str, detail: Value) { let at = since_start(); rec(|s| push(s, "events", json!({"at": at, "event": name, "beat": s["step"].clone(), "detail": detail}))); }@@ -173,6 +181,7 @@ fn narrator_loop(pass: &dyn Fn(&str, Value) -> Value) { let q = *quiet_since.get_or_insert_with(Instant::now); if q.elapsed() < Duration::from_millis(900) { continue; } // a breath between sentences let Some((beat, n)) = NARR.lock().unwrap().clone() else { continue };+ if !BEAT_BUSY.load(Ordering::Relaxed) { continue; } // a failed beat cut the narration meanwhile let candidate = format!("{beat}-f{}", n + 1); if audio_dir().join(format!("{candidate}.mp3")).is_file() { *NARR.lock().unwrap() = Some((beat.clone(), n + 1));@@ -193,24 +202,52 @@ fn apply_mute(m: bool, source: &str) { // ── the remote: one FIFO for every source ──────────────────────────────────── fn queue(cmd: &str) { let mut q = CTRL.lock().unwrap(); if cmd == "exit" { q.clear(); q.push_back("exit".into()); } else if q.len() < 8 { q.push_back(cmd.to_string()); } }+/// A press repeats one being handled: the same command is still queued, or was accepted or+/// taken less than DEBOUNCE ago.+fn is_repeat(cmd: &str, queued: &VecDeque<String>, last: &[(String, Instant)], now: Instant) -> bool {+ queued.iter().any(|c| c == cmd) || last.iter().any(|(c, t)| c == cmd && now.saturating_duration_since(*t) < DEBOUNCE)+}+fn stamp_cmd(cmd: &str) { let mut l = LAST_CMD.lock().unwrap(); l.retain(|(c, _)| c != cmd); l.push((cmd.to_string(), Instant::now())); } fn pending_interrupt() -> bool { EXIT.load(Ordering::Relaxed) || CTRL.lock().unwrap().iter().any(|c| matches!(c.as_str(), "prev" | "next" | "pause" | "exit")) } /// One press from any source: record, ack, apply mute at once, queue the transport command. pub fn on_press(button: &str, at: Value, via: &str) -> Value { let b = button.to_lowercase(); let beat = snap(); rec(|s| { push(s, "presses", json!({"button": b, "at": at, "via": via, "beat": beat["beatIndex"], "step": beat["step"]})); s["lastPress"] = json!({"button": b, "at": at, "via": via}); });+ let cmd = match b.as_str() { "back" | "prev" => "prev", "play" | "resume" => "play", "pause" => "pause", "next" => "next", "stop" | "exit" => "exit", _ => "" };+ // a press for a run that is over (or stopping) is dropped, and so is a repeat of one in hand+ if !cmd.is_empty() {+ let over = beat["done"].as_bool() != Some(false) || beat["stopping"].as_bool() == Some(true) || EXIT.load(Ordering::Relaxed);+ if over || is_repeat(cmd, &CTRL.lock().unwrap(), &LAST_CMD.lock().unwrap(), Instant::now()) {+ rec(|s| s["debouncedPresses"] = json!(s["debouncedPresses"].as_u64().unwrap_or(0) + 1));+ return json!({"debounced": cmd, "tourOver": over});+ }+ } let ack_text = match b.as_str() { "mute" => "Remote: muted", "unmute" => "Remote: narration back on", "pause" => "Remote: pausing", "play" | "resume" => "Remote: playing", "next" => "Remote: next", "back" | "prev" => "Remote: back", "stop" | "exit" => "Remote: stopping the tour", _ => "" }; if !ack_text.is_empty() { ack(ack_text); } match b.as_str() { "mute" | "unmute" => { apply_mute(b == "mute", via); return json!({"applied": b}); } _ => {} }- let cmd = match b.as_str() { "back" | "prev" => "prev", "play" | "resume" => "play", "pause" => "pause", "next" => "next", "stop" | "exit" => "exit", _ => return json!({"ignored": b}) };+ if cmd.is_empty() { return json!({"ignored": b}); } if matches!(cmd, "pause" | "exit" | "next" | "prev") { audio_stop(); }- if cmd == "exit" { EXIT.store(true, Ordering::Relaxed); caption_clear(); rec(|s| { s["stopping"] = json!(true); s["stepLabel"] = json!("Closing the demo tour"); }); }+ if cmd == "exit" { stop_now(); }+ stamp_cmd(cmd); queue(cmd); json!({"queued": cmd}) }+/// Exit ends the tour NOW (issue #121: eleven Exit presses over 28 s did not): the narration and+/// caption go, the remote comes down for good, and the record reads stopped at once. The tour+/// thread's `pass` refuses every verb from here, so the step in flight returns at its next call;+/// its teardown then closes the documents (tourDocsClosed "closing" holds a new tour off until then).+fn stop_now() {+ EXIT.store(true, Ordering::Relaxed);+ BEAT_BUSY.store(false, Ordering::Relaxed); *NARR.lock().unwrap() = None;+ audio_stop(); caption_clear();+ PANEL_CLOSED.store(true, Ordering::Relaxed);+ rec(|s| { s["stopping"] = json!(true); s["done"] = json!(true); s["exited"] = json!(true); s["step"] = json!("done"); s["stepLabel"] = json!("Tour stopped"); s["tourDocsClosed"] = json!("closing"); });+ std::thread::spawn(panel_hide);+} /// ab's instant callback (POST /demo/event): {id, button, at, state, stage, muted}. pub fn on_panel_event(ev: &Value) -> Value { if ev["id"].as_str().map(|i| i != PANEL_ID).unwrap_or(false) { return json!({"ignored": true}); }@@ -219,7 +256,12 @@ pub fn on_panel_event(ev: &Value) -> Value { if let Some(m) = ev["muted"].as_bool() { if m != MUTED.load(Ordering::Relaxed) && !matches!(b, "mute" | "unmute") { apply_mute(m, "panel"); } } on_press(b, ev["at"].clone(), if ev["synthetic"].as_bool() == Some(true) { "callback-synthetic" } else { "callback" }) }-fn panel_show(stage: usize, state: Option<&str>) -> bool {+/// Only the live, running run `run_id` may show its remote, and never once it was stopped or hidden.+fn panel_may_show(run_id: &str, live: &Value, closed: bool) -> bool {+ !closed && !run_id.is_empty() && live["demoId"].as_str() == Some(run_id) && live["done"].as_bool() == Some(false) && live["stopping"].as_bool() != Some(true)+}+fn panel_show(run_id: &str, stage: usize, state: Option<&str>) -> bool {+ if !panel_may_show(run_id, &snap(), PANEL_CLOSED.load(Ordering::Relaxed) || EXIT.load(Ordering::Relaxed)) { return false; } let mut args = json!({"action": "show", "id": PANEL_ID, "stage": stage, "reason": PANEL_REASON}); if !PANEL_SHOWN.load(Ordering::Relaxed) { args["title"] = json!("Fusion bridge tour"); args["stops"] = json!(BEATS.iter().map(|b| b.1).collect::<Vec<_>>()); args["state"] = json!(state.unwrap_or("playing")); args["position"] = json!("bottom-right"); args["muted"] = json!(MUTED.load(Ordering::Relaxed));@@ -234,13 +276,15 @@ fn panel_show(stage: usize, state: Option<&str>) -> bool { /// Backstop drain of the panel's own queue; presses the callback already delivered are skipped by `at`. fn panel_poll() { let r = ab("desktop_demo_panel", json!({"action": "poll", "id": PANEL_ID}), 6);- if r["state"].as_str() == Some("gone") { PANEL_SHOWN.store(false, Ordering::Relaxed); let st = snap(); if st["done"].as_bool() == Some(false) && !EXIT.load(Ordering::Relaxed) { panel_show(st["panel"]["stage"].as_u64().unwrap_or(0) as usize, None); } return; } if let Some(m) = r["muted"].as_bool() { if m != MUTED.load(Ordering::Relaxed) { apply_mute(m, "panel-poll"); } } for ev in r["events"].as_array().cloned().unwrap_or_default() { let at = ev["at"].as_str().map(str::to_string).or_else(|| ev["at"].as_f64().map(|f| f.to_string())).unwrap_or_default(); if !at.is_empty() { let mut seen = SEEN_AT.lock().unwrap(); if seen.contains(&at) { continue; } seen.push(at.clone()); } on_press(ev["button"].as_str().unwrap_or(""), ev["at"].clone(), if ev["synthetic"].as_bool() == Some(true) { "poll-synthetic" } else { "poll" }); }+ // gone or stopped after this run showed it means someone took the remote down: it stays+ // down for this run (issue #121). Before the first show, the state is the last run's.+ if matches!(r["state"].as_str(), Some("gone") | Some("stopped")) && PANEL_SHOWN.load(Ordering::Relaxed) { PANEL_SHOWN.store(false, Ordering::Relaxed); PANEL_CLOSED.store(true, Ordering::Relaxed); rec(|s| s["panel"]["closedBy"] = json!(r["state"])); } } fn drain_stale_presses() { for _ in 0..3 { let r = ab("desktop_demo_panel", json!({"action": "poll", "id": PANEL_ID}), 6); if r["events"].as_array().map(|a| a.is_empty()).unwrap_or(true) { break; } } } /// Hide the remote and PROVE it: poll once (the tour is over, so the drain is harmless), hide again@@ -259,10 +303,10 @@ fn panel_hide() { rep["stateAfter"] = json!(st); if st.is_empty() || st == "stopped" || st == "gone" { break; } }- PANEL_SHOWN.store(false, Ordering::Relaxed);+ PANEL_SHOWN.store(false, Ordering::Relaxed); PANEL_CLOSED.store(true, Ordering::Relaxed); rec(|s| s["panelHidden"] = rep.clone()); }-fn take_cmd() -> Option<String> { panel_poll(); CTRL.lock().unwrap().pop_front() }+fn take_cmd() -> Option<String> { panel_poll(); let c = CTRL.lock().unwrap().pop_front(); if let Some(c) = &c { stamp_cmd(c); } c } // ── layout ──────────────────────────────────────────────────────────────────── fn primary_work_area() -> Option<Value> {@@ -582,15 +626,19 @@ fn pass_patient(pass: &dyn Fn(&str, Value) -> Value, verb: &str, args: Value) -> } } fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env: &Env) {- let pass_owned = |verb: &str, a: Value| pass_patient(pass_raw, verb, a);+ // once the user exits, every beat verb is refused so the step in flight unwinds at its next+ // call (issue #121); the teardown lifts this to close the tour's documents+ let teardown = AtomicBool::new(false);+ let pass_owned = |verb: &str, a: Value| if EXIT.load(Ordering::Relaxed) && !teardown.load(Ordering::Relaxed) { json!({"success": false, "error": "the user stopped the tour"}) } else { pass_patient(pass_raw, verb, a) }; let pass: &dyn Fn(&str, Value) -> Value = &pass_owned;+ let run_id = snap()["demoId"].as_str().unwrap_or("").to_string(); rec(|s| s["bridgeVersion"] = json!(env.version)); let sd = ctx.state_dir.clone(); let t0 = Instant::now(); let total_est: u64 = BEATS.iter().map(|b| b.2).sum(); let mut done_est: u64 = 0; // clean slate per tour- EXIT.store(false, Ordering::Relaxed); PANEL_SHOWN.store(false, Ordering::Relaxed); SEEN_AT.lock().unwrap().clear(); CTRL.lock().unwrap().clear();+ EXIT.store(false, Ordering::Relaxed); PANEL_SHOWN.store(false, Ordering::Relaxed); PANEL_CLOSED.store(false, Ordering::Relaxed); SEEN_AT.lock().unwrap().clear(); CTRL.lock().unwrap().clear(); LAST_CMD.lock().unwrap().clear(); MUTED.store(args["muted"].as_bool().unwrap_or(false), Ordering::Relaxed); BEAT_BUSY.store(false, Ordering::Relaxed); PAUSED.store(false, Ordering::Relaxed); *NARR.lock().unwrap() = None; GENERIC_USED.lock().unwrap().clear(); drain_stale_presses();@@ -619,6 +667,7 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env let mut fusion: Option<(i64, String)> = None; let mut bound_urn = String::new(); let mut bound = false;+ let mut chip_cache: Option<Value> = None; let mut project_base = String::new(); let mut hub_board = false; let board_name = args["board"].as_str().unwrap_or(DEMO_BOARD).to_string();@@ -630,7 +679,7 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env // transport between beats: pause holds, exit leaves, prev is TWO back (i is past the beat on screen) let mut cmd = take_cmd(); if cmd.as_deref() == Some("pause") {- audio_stop(); PAUSED.store(true, Ordering::Relaxed); event("paused", json!({})); panel_show(i.max(1), Some("paused")); caption("Fusion tour paused", 300000); rec(|s| { s["paused"] = json!(true); s["stepLabel"] = json!("Paused"); });+ audio_stop(); PAUSED.store(true, Ordering::Relaxed); event("paused", json!({})); panel_show(&run_id, i.max(1), Some("paused")); caption("Fusion tour paused", 300000); rec(|s| { s["paused"] = json!(true); s["stepLabel"] = json!("Paused"); }); loop { sleep_s(1.0); cmd = take_cmd(); if matches!(cmd.as_deref(), Some("play") | Some("next") | Some("prev") | Some("exit")) { break; } } PAUSED.store(false, Ordering::Relaxed); event("resumed", json!({})); rec(|s| s["paused"] = json!(false)); }@@ -639,7 +688,7 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env let (key, label, dur) = BEATS[i]; rec(|s| { s["step"] = json!(key); s["stepLabel"] = json!(label); s["beatIndex"] = json!(i + 1); s["beatCount"] = json!(n); }); update_pct(done_est, 0);- panel_show(i, None);+ panel_show(&run_id, i, None); BEAT_BUSY.store(true, Ordering::Relaxed); event("beat-start", json!({"index": i + 1, "key": key})); event("part-a", json!({"key": key}));@@ -676,16 +725,17 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env expected = "AdomDemoSample".into(); kind = "2d"; } "chip" => {- caption(&format!("Fusion tour 4/{n}: minting the 3D chip from its STEP and binding it (about two minutes)"), 60000);- audio_play("chip");+ // a replay (Back) shows the chip this run already bound; the minting lines would narrate work not being done+ if chip_cache.is_some() { caption(&format!("Fusion tour 4/{n}: the 3D chip this tour already minted and bound"), 30000); *NARR.lock().unwrap() = None; }+ else { caption(&format!("Fusion tour 4/{n}: minting the 3D chip from its STEP and binding it (about two minutes)"), 60000); audio_play("chip"); } let was = active_doc(pass); match (&sample.step, sample.package.is_empty()) { (Some(step), false) => { let on_part = |_p: &Value, k: usize, total: usize| caption(&format!("3D chip: part {k} of {total} minted and bound"), 20000);- let br = crate::mint::build_library_3d_core(ctx, &json!({"lbrPath": sample.lbr, "parts": [{"package": sample.package, "lbrPath": sample.lbr, "modelPath": step}], "outLbrPath": sample.lbr, "capture": false}), pass, &on_part);+ let (br, reused) = chip_bind_once(&mut chip_cache, || crate::mint::build_library_3d_core(ctx, &json!({"lbrPath": sample.lbr, "parts": [{"package": sample.package, "lbrPath": sample.lbr, "modelPath": step}], "outLbrPath": sample.lbr, "capture": false}), pass, &on_part)); bound = br["success"].as_bool() == Some(true) && br["partsBound"].as_u64().unwrap_or(0) >= 1; bound_urn = br["parts"][0]["wip_urn"].as_str().unwrap_or("").to_string();- r["bind"] = json!({"success": bound, "partsBound": br["partsBound"], "error": br["error"], "urn": bound_urn, "dims_mm": br["parts"][0]["dims_mm"]});+ r["bind"] = json!({"success": bound, "partsBound": br["partsBound"], "error": br["error"], "urn": bound_urn, "dims_mm": br["parts"][0]["dims_mm"], "reused": reused}); rec(|s| s["bound3d"] = json!(bound)); if bound && !bound_urn.is_empty() && !EXIT.load(Ordering::Relaxed) { let o = pass("open_by_urn", json!({"urn": bound_urn}));@@ -780,6 +830,8 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env } _ => {} }+ // a failed action narrates nothing more of its beat: the clip and fillers stop now+ if r["ok"].as_bool() != Some(true) { cut_narration(); } if key == "library" && r["ok"].as_bool() != Some(true) { let why = r["error"].as_str().unwrap_or("the sample library did not open").to_string(); caption(&format!("Fusion tour stopped: {why}"), 8000);@@ -812,13 +864,31 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env event("motion-start", json!({"kind": kind, "plan": plan.iter().map(|p| p.0).collect::<Vec<_>>(), "parts": parts.len()})); idle_boxes = plan.iter().filter(|p| p.1["action"] == "pan").map(|p| p.1.clone()).collect(); idle_kind = kind.to_string();- r["motion"] = motion(*h, kind, pass, plan);+ r["motion"] = motion(*h, kind, pass, plan.clone()); event("motion-done", json!({"gestures": r["motion"]["gestures"], "viewChanged": r["motion"]["viewChanged"], "aborted": r["motion"]["aborted"]}));+ // a motion that never moved the view gets one more pass (issue #121), then the beat fails below+ if motion_needs_retry(&r["motion"]) && !EXIT.load(Ordering::Relaxed) {+ let first = r["motion"].clone();+ r["motion"] = motion(*h, kind, pass, plan);+ r["motion"]["firstPass"] = first;+ event("motion-retry", json!({"gestures": r["motion"]["gestures"], "viewChanged": r["motion"]["viewChanged"], "aborted": r["motion"]["aborted"]}));+ } } else if kind != "none" { r["motion"] = json!({"skipped": format!("not verified: front={} title={} painted={} (top window: {})", v["front"], v["title"], v["painted"], v["frontTitle"])}); } else if v["ok"].as_bool() == Some(true) { event("part-b", json!({"key": key})); audio_play_b(key); } } else { r["verified"] = json!({"why": "no Fusion window"}); } }+ // issue #121: a beat that failed, was not on screen, or never moved says so briefly and+ // moves on, instead of narrating over a view that does not show it+ if !EXIT.load(Ordering::Relaxed) { if let Some(why) = beat_failure(&r) {+ if r["ok"].as_bool() == Some(true) { r["ok"] = json!(false); r["error"] = json!(why.clone()); }+ cut_narration();+ caption(&failed_caption(i + 1, n, label, &why), 6000);+ r["failedNarration"] = json!(true);+ event("beat-failed", json!({"key": key, "why": why}));+ let hold = Instant::now();+ while hold.elapsed() < Duration::from_secs(3) && !pending_interrupt() { sleep_s(0.3); }+ } } // evidence frame at the moment the beat completes if let Some((h, _)) = &fusion { if let Some(p) = shot_window(*h) { if let Ok(b) = std::fs::read(&p) { if b.len() <= 4 * 1024 * 1024 { let img = crate::etiquette::b64_pub(&b); rec(|s| { push(s, "shots", json!({"label": label, "title": label, "path": p, "image": img})); }); } } } } r["seconds"] = json!(beat_t0.elapsed().as_secs_f64());@@ -848,6 +918,7 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env i += 1; } BEAT_BUSY.store(false, Ordering::Relaxed);+ teardown.store(true, Ordering::Relaxed); event("tour-end", json!({"exited": exited})); // ---- teardown: audio off, panel down, windows back, the tour marked done; THEN the tour's // documents close (closing takes a minute and must not read as dead air) ----@@ -980,6 +1051,9 @@ fn idle_step(kind: &str, tick: u64, boxes: &[Value], pass: &dyn Fn(&str, Value) fn motion(hwnd: i64, kind: &str, pass: &dyn Fn(&str, Value) -> Value, plan: Vec<(&str, Value)>) -> Value { let mut rep = json!({"kind": kind, "gestures": 0, "aborted": null, "plan": plan.iter().map(|p| p.0).collect::<Vec<_>>()}); let before = shot_window(hwnd).and_then(|p| digest(&p));+ // a plan may end where it began (iso, fit), so the view counts as changed once ANY frame+ // after a gesture differs from the first, not only the last one+ let mut changed = false; for (name, body) in plan { if pending_interrupt() { rep["aborted"] = json!(format!("stop requested before {name}")); break; } if !front_ok(hwnd) { rep["aborted"] = json!(format!("window not in front before {name} (user switched away)")); break; }@@ -1001,12 +1075,45 @@ fn motion(hwnd: i64, kind: &str, pass: &dyn Fn(&str, Value) -> Value, plan: Vec< // let the eye land before the next move; a 2D fit needs longer, the editor is still // laying it out and a box sent inside that window is dropped (2026-09-05) sleep_s(if kind == "2d" && body["action"] == "fit" { 3.0 } else if kind == "2d" { 1.2 } else { 1.0 });+ if !changed && before.is_some() { changed = shot_window(hwnd).and_then(|p| digest(&p)).map(|d| Some(d) != before).unwrap_or(false); } } let after = shot_window(hwnd).and_then(|p| digest(&p));- rep["viewChanged"] = json!(before.is_some() && after.is_some() && before != after);+ rep["viewChanged"] = json!(changed || (before.is_some() && after.is_some() && before != after));+ rep["viewMeasured"] = json!(before.is_some()); rep } +// ── failed beats (issue #121) ─────────────────────────────────────────────────+/// Stop the beat's narration at once: no clip on air, no next filler.+fn cut_narration() { BEAT_BUSY.store(false, Ordering::Relaxed); *NARR.lock().unwrap() = None; audio_stop(); }+/// A motion that ran, was measured, was not cut short by the remote, and never moved the view.+fn motion_needs_retry(m: &Value) -> bool {+ m["viewChanged"].as_bool() == Some(false) && m["viewMeasured"].as_bool() != Some(false) && m["gestures"].as_u64().unwrap_or(0) > 0 && m["aborted"].is_null()+}+/// Why the beat must not be narrated as shown, or None when its view is on screen: the action+/// failed, the view could not be proven on screen, or its motion never moved the view (after the retry).+fn beat_failure(r: &Value) -> Option<String> {+ if r["ok"].as_bool() != Some(true) { return Some(short_why(r["error"].as_str().unwrap_or("the step did not complete"))); }+ if r["verified"]["ok"].as_bool() == Some(false) { return Some("its view could not be confirmed on screen".into()); }+ if motion_needs_retry(&r["motion"]) { return Some("the view did not move".into()); }+ None+}+/// The first sentence of an error, at most 80 characters, for a one-line caption.+fn short_why(e: &str) -> String {+ let e = e.trim();+ let first = e.split(". ").next().unwrap_or(e).trim_end_matches('.');+ if first.chars().count() <= 80 { first.to_string() } else { format!("{}...", first.chars().take(77).collect::<String>().trim_end()) }+}+fn failed_caption(index: usize, n: usize, label: &str, why: &str) -> String { format!("Fusion tour {index}/{n}: {label} - this step failed ({why}); moving on") }+/// The chip beat mints at most once per tour run (issue #121: Back over it minted a second STEP+/// binding with a new URN). A replay reuses the first successful bind; a failed mint is not kept.+fn chip_bind_once(cache: &mut Option<Value>, mint: impl FnOnce() -> Value) -> (Value, bool) {+ if let Some(br) = cache.as_ref() { return (br.clone(), true); }+ let br = mint();+ if br["success"].as_bool() == Some(true) && br["partsBound"].as_u64().unwrap_or(0) >= 1 && br["parts"][0]["wip_urn"].as_str().map(|u| !u.is_empty()).unwrap_or(false) { *cache = Some(br.clone()); }+ (br, false)+}+ /// `fusion_demo {mode:"tour"}`: start the show detached; poll fusion_demo_progress {demoId}. pub fn start(ctx: &Ctx, args: &Value, env: Env) -> Value { if snap()["done"].as_bool() == Some(false) { let s = snap(); return json!({"success": false, "error": "a tour is already running", "demoId": s["demoId"], "_hint": "Poll fusion_demo_progress with that demoId, or fusion_demo_control {action:'exit'} to stop it."}); }@@ -1039,6 +1146,8 @@ pub fn start(ctx: &Ctx, args: &Value, env: Env) -> Value { // only THIS tour's record: a later tour may own the live record by now let live = snap(); if live["demoId"].as_str() == Some(my_id.as_str()) && live["done"].as_bool() != Some(true) { rec(|s| { s["done"] = json!(true); s["failed"] = json!(true); s["error"] = json!("the tour thread ended unexpectedly"); }); panel_hide(); }+ // an Exit marks the record closing before the teardown runs; a thread that died first must not hold the next tour off+ if snap()["demoId"].as_str() == Some(my_id.as_str()) && snap()["tourDocsClosed"].as_str() == Some("closing") { rec(|s| s["tourDocsClosed"] = json!([])); } }); json!({"success": true, "demoId": id, "estimatedSec": est, "steps": BEATS.iter().map(|b| json!({"step": b.0, "label": b.1, "estimatedSec": b.2})).collect::<Vec<_>>(), "statusVerb": "fusion_demo_progress", "_hint": format!("Tour started in the background. Poll fusion_demo_progress {{\"demoId\": \"{id}\"}} every 2-3 s: percent, stepLabel, per-step verified/placed/motion, presses[], captions[], panel, and a per-beat evidence frame in shots[]. Drive it with fusion_demo_control {{demoId, action: play|pause|next|prev|exit|mute|unmute}} or the glass remote on screen.")})@@ -1061,7 +1170,79 @@ pub fn progress(args: &Value) -> Value { pub fn control(args: &Value) -> Value { let a = args["action"].as_str().unwrap_or("").to_lowercase(); if !matches!(a.as_str(), "play" | "resume" | "pause" | "next" | "prev" | "back" | "exit" | "stop" | "mute" | "unmute") { return json!({"success": false, "error": "action must be play|pause|next|prev|exit|mute|unmute"}); }+ // a repeat Exit while the first one winds the tour down is already handled, not an error+ if matches!(a.as_str(), "exit" | "stop") && snap()["stopping"].as_bool() == Some(true) && snap()["tourDocsClosed"].as_str() == Some("closing") { return json!({"success": true, "action": a, "alreadyStopping": true, "_hint": "The tour is already stopped; its documents are closing."}); } if snap()["done"].as_bool() != Some(false) && !matches!(a.as_str(), "mute" | "unmute") { return json!({"success": false, "error": "no running tour", "_hint": "Controls only apply while a tour is running."}); } let r = on_press(&a, json!(now()), "dashboard"); json!({"success": true, "action": a, "result": r, "muted": MUTED.load(Ordering::Relaxed), "paused": snap()["paused"], "_hint": "Honoured at the running beat's next gesture (a document mid-open finishes first); exit stops the tour, silences narration and clears the captions."}) }++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn repeat_presses_are_debounced() {+ let t0 = Instant::now();+ let mut q = VecDeque::new();+ assert!(!is_repeat("prev", &q, &[], t0), "a first Back is taken");+ q.push_back("prev".to_string());+ assert!(is_repeat("prev", &q, &[], t0), "a Back while one is still queued is dropped");+ assert!(!is_repeat("next", &q, &[], t0), "another command is not a repeat");+ let q = VecDeque::new();+ let last = vec![("prev".to_string(), t0)];+ assert!(is_repeat("prev", &q, &last, t0 + Duration::from_millis(1500)), "a Back right after one was taken is dropped");+ assert!(!is_repeat("prev", &q, &last, t0 + DEBOUNCE + Duration::from_millis(1)), "a Back after the window is taken");+ assert!(!is_repeat("exit", &q, &last, t0 + Duration::from_millis(100)));+ }++ #[test]+ fn stopped_or_stale_runs_never_show_the_panel() {+ let live = json!({"demoId": "show-1", "done": false, "stopping": false});+ assert!(panel_may_show("show-1", &live, false));+ assert!(!panel_may_show("show-1", &live, true), "hidden or stopped: closed for this run");+ assert!(!panel_may_show("show-0", &live, false), "a late update from an older run is dropped");+ assert!(!panel_may_show("", &live, false));+ assert!(!panel_may_show("show-1", &json!({"demoId": "show-1", "done": true}), false), "a finished run");+ assert!(!panel_may_show("show-1", &json!({"demoId": "show-1", "done": false, "stopping": true}), false), "a run the user exited");+ assert!(!panel_may_show("show-1", &Value::Null, false));+ }++ #[test]+ fn chip_is_minted_once_per_run() {+ let mints = std::cell::Cell::new(0);+ let mint = || { mints.set(mints.get() + 1); json!({"success": true, "partsBound": 1, "parts": [{"wip_urn": format!("urn:vf.{}", mints.get())}]}) };+ let mut cache = None;+ let (a, reused_a) = chip_bind_once(&mut cache, mint);+ let (b, reused_b) = chip_bind_once(&mut cache, || panic!("a replay must not mint again"));+ assert_eq!(mints.get(), 1);+ assert!(!reused_a && reused_b);+ assert_eq!(a["parts"][0]["wip_urn"], b["parts"][0]["wip_urn"], "the replay reuses the first URN");+ // a failed mint is not kept, so the next pass may try again+ let mut cache = None;+ let (f, _) = chip_bind_once(&mut cache, || json!({"success": false, "partsBound": 0, "error": "no cloud"}));+ assert_eq!(f["success"], json!(false));+ assert!(cache.is_none());+ }++ #[test]+ fn failed_beats_are_not_narrated() {+ let ok = json!({"ok": true, "verified": {"ok": true}, "motion": {"gestures": 6, "viewChanged": true, "viewMeasured": true, "aborted": null}});+ assert_eq!(beat_failure(&ok), None);+ let failed = json!({"ok": false, "error": "Could not make AdomDemoSample.lbr the ACTIVE document (active: 'None'), so EDIT was not issued. Retry later."});+ let why = beat_failure(&failed).unwrap();+ assert!(why.starts_with("Could not make AdomDemoSample.lbr") && why.chars().count() <= 80, "{why}");+ assert!(failed_caption(2, 8, "Symbol: the full pinout", &why).contains("2/8: Symbol: the full pinout - this step failed"));+ assert_eq!(beat_failure(&json!({"ok": false})).as_deref(), Some("the step did not complete"));+ assert!(beat_failure(&json!({"ok": true, "verified": {"ok": false}, "motion": {"skipped": "not verified"}})).is_some(), "not on screen");+ // a still view after the motion: retried once, then failed+ let still = json!({"ok": true, "verified": {"ok": true}, "motion": {"gestures": 9, "viewChanged": false, "viewMeasured": true, "aborted": null}});+ assert!(motion_needs_retry(&still["motion"]));+ assert_eq!(beat_failure(&still).as_deref(), Some("the view did not move"));+ // the remote cutting the motion short, or no capture to measure with, is not a failure+ assert!(!motion_needs_retry(&json!({"gestures": 2, "viewChanged": false, "viewMeasured": true, "aborted": "stop requested before orbit-2"})));+ assert!(!motion_needs_retry(&json!({"gestures": 9, "viewChanged": false, "viewMeasured": false, "aborted": null})));+ assert!(!motion_needs_retry(&json!({"gestures": 0, "viewChanged": false, "viewMeasured": true, "aborted": null})));+ assert_eq!(beat_failure(&json!({"ok": true})), None, "the cloud-search beat has no view to check");+ }+}
tools/check-naming.shtools/check-portable.shtools/promote.shtools/release.sh