Skip to content

Simulator — UI workflows

The complete click-level map of SimulatorApp: each workflow is traced from the user gesture through the shell event, the app handler, the facade, the NpsAbi export, the C ABI (nps_c_api.h), nps::Engine, and back to the exact UI updates. Status is HOW IT IS — every hop carries file:line. For the element-by-element inventory see the interaction map; for the pipeline narrative see how it is.

Path abbreviations: MW = src/SimulatorApp/MainWindow.axaml.cs, SH = src/Nps.Shell, EH = src/Nps.EngineHost, EC = src/Engine/engine.cpp.

1. Boot and first paint

Goal: process start → composed window with shell chrome, empty viewport, ready status — before any click. Preconditions: libNPSEngine.so locatable; the repo-root Benchy fixture matters only for the pre-flight probe — the GUI starts even when it fails.

  1. dotnet runProgram.Main (src/SimulatorApp/Program.cs:15, [STAThread]).
  2. Raw-ABI smoke check (Simulator-only): NpsAbi.GetAbiVersion() (Program.cs:22EH/NpsAbi.cs:241 → C export EC:2816 returns NPS_ABI_VERSION = 1, nps_c_api.h:234); mismatch throws (Program.cs:23–28).
  3. SafeEngineHandle wraps NpsAbi.CreateEngine() (Program.cs:31EC:2789new nps::Engine(), ctor EC:469); InitializeEngine + GetEngineVersion (Program.cs:33–34EC:2800/2806Engine::Initialize EC:486); version printed to stdout (:35).
  4. Fixture probe: FixtureLocator.FindPrimaryBenchy3Mf() (Program.cs:41, walk order SH/FixtureLocator.cs:60–90, filename const :41) → NpsAbi.LoadModelFromFile (Program.cs:43EC:2820Engine::LoadModelFromFile EC:540). Success prints plates/verts/tris/palette/unit (Program.cs:50–71); failure prints GetLastErrorMessage to stdout+stderr (:73–82). Exceptions are caught — the UI starts regardless (:88–99).
  5. BuildAvaloniaApp().StartWithClassicDesktopLifetime(args) (Program.cs:98–99): ShellTrace.InstallStderrSink() (:108), UsePlatformDetect().WithInterFont().LogToTrace() (:103–117).
  6. App.OnFrameworkInitializationCompletednew MainWindow() (App.axaml.cs:14–22).
  7. MainWindow ctor (MW:77–81) → InitializeShell (MW:83–142): wires all subscriptions (:85–137), creates _playTimer (DispatcherTimer, 80 ms, :105–106), _session = EngineSession.CreateAndInitialize() (:108EH/EngineSession.cs:165Initialize :177–193NpsAbi.CreateEngine :184), _mesh = new MeshController(_session) (:109), _viewport = ViewportController.CreateAndInitialize() (:110SH/ViewportController.cs:203NpsAbi.CreateGcodeViewport :222EC:5266), initial status (:102), Focusable = true; Focus() (:140–141).
  8. Shell self-composition: panel discovery + registration (MainWindowShell.axaml.cs:212–254), playback tab registered because IsPlaybackTabVisible is default-true (:172–188), Panels.Show("overlays") (:250), viewport pointer forwarding attached (:256–276).
  9. First paint: chrome + empty canvas; RenderViewport would early-out (no moves). Camera defaults rotX 0.6 / rotZ 0.4 / zoom 1.0 (ViewportController.cs:139–144); overlay mode default motion-type (:162); _maxLayer = 10; playback initial push MoveCount=0, SimIndex=0, AutoPause=true (MW:134–136).

Tested by: AppHeadlessSmokeTests.SimulatorApp_MainWindow_Loads_Layout_And_Bindings (tests/NPSEngine.Tests/AppHeadlessSmokeTests.cs:141); EngineBridgeTests.GetAbiVersion_MatchesExpectedAtStartup (EngineBridgeTests.cs:194); EngineSessionTests.CreateAndInitialize_ProducesValidSession (EngineSessionTests.cs:48); ViewportControllerTests.CreateAndInitialize_ProducesValidViewport (ViewportControllerTests.cs:31). NOT COVERED: the raw-ABI smoke check in Program.cs:22–96 (only the disabled scripts/smoke-apps.sh exercised it); StatusSink stderr mirroring.

2. Load + Gen + Analyze (Benchy)

Goal: one click → mesh loaded, demo toolpath generated and painted, analysis findings listed, thermal overlay applied, playback armed. Preconditions: boot done; _session valid (guard MW:207–210); fixture at repo root (golden contract: 1 plate / 16562 v / 33097 t / 4 palette colors).

  1. Gesture: click "Load + Gen + Analyze (Benchy)" (MainWindow.axaml:28) → OnLoadAnalyzeClick (MW:205).
  2. Fixture resolve (MW:212FixtureLocator.cs:60–90); missing → error status + return (MW:213–217, engine never called).
  3. LoadResult load = _session.LoadModel(benchy) (MW:219EH/EngineSession.cs:202NpsAbi.LoadModelFromFile :211EC:2820Engine::LoadModelFromFile EC:550 — clears prior mesh + all three gcode buffers EC:567–577, ZIP/OPC extract EC:657–684, XML parse EC:718–745, palette EC:800–809, resolve EC:871, plate push EC:961).
  4. _mesh?.Reload() (MW:226always, success or fail, because step 3 cleared engine state; MeshController.cs:131–176 copies positions/indices/colorIndices/palette into managed arrays).
  5. Failure → Status($"Load failed: …") + return (MW:228–232); message came from NpsAbi.GetLastErrorMessage (EngineSession.cs:214EC:2855).
  6. Success → status + MeshInfoLabel.Text plates/palette/unit (MW:234–242).
  7. GenerateResult gr = _session.GenerateDemoToolpath(new GcodeGenOptions(false, 0.2f, 1.0f, 1800, 5)) (MW:245–250EngineSession.cs:253–307: AllocHGlobal+StructureToPtr :277–280NpsAbi.GenerateGcode :281EC:2947Engine::GenerateGcode EC:1215DEMO toolpath, not a production slice, header EC:1314–1317; move snapshot GetLastGcodeMoveCount/GetLastGcodeMoves EngineSession.cs:295–305). Failure / no-moves statuses (MW:251–261).
  8. Viewport feed (MW:263–269): _viewport.SetMoves(gr.MovesPointer, gr.MovesCount) (:265ViewportController.cs:240–246NpsAbi.ViewportSetGcodeMoves — exact export name — EC:5274GcodeViewport::SetGcodeMoves EC:5714, which copies into ownedMoves_); SetLayerVisibility (:266EC:5280); SetCamera(0.6, 0.4, 0, 0, 1.0) (:267EC:5286); BuildBuffers (:268EC:5293BuildRenderBuffers EC:5861).
  9. AnalyzeResult ar = _session.Analyze(new MaterialProfileOptions("PLA", 210, 60)) (MW:272EngineSession.cs:332–369NpsAbi.AnalyzeGcode :361EC:5421–5459Engine::AnalyzeGcode EC:1479AnalysisManager::analyze EC:4909 → six analyzers EC:4916–4919). Failure status (MW:273–277).
  10. LoadFindings() (MW:280:316–336) → workflow 3, steps 1–3.
  11. Force thermal overlay (MW:288–295ApplyCurrentOverlay :465) → workflow 4.
  12. _simMoveIndex = 0 (:297); UpdateLayerLabel() (:298); playback push MoveCount / SimIndex = 0 / SetMarkers(_findings) (:304–310).
  13. UI updates: RenderViewport() (:312) paints mesh underlay + toolpath; final status "Loaded Benchy (DEMO TOOLPATH - not a production slice): … moves, … findings…" (:313).

Tested by: EngineSessionTests.LoadModel_OnBenchy3Mf_ReturnsStructuredPlatesAndPalette (EngineSessionTests.cs:95), LoadModel_ClearsPriorMoveBuffer (:142), GenerateDemoToolpath_AfterBenchyLoad_ReturnsMoves (:166); MeshControllerTests.Reload_OnBenchy_SnapshotsOnePlateWithGoldenCounts (MeshControllerTests.cs:51); ViewportControllerTests.SetMoves_MirrorsCountAndPointer (ViewportControllerTests.cs:138); Catch2 engine_tests.cpp:287 (ZIP load), :621 (golden counts), :2538 (gen), :1674 (viewport feed chain). COVERED (nps-oro.3): AppHeadlessSmokeTests.cs:791 (missing-fixture → "ERROR: primary 4-colors+Benchy+AMS+test-v2.3mf required...", engine never called, _session.HasMoves==false), :843 (invalid-file → "Load failed: <code> <msg>", _mesh?.Reload() ran → mesh.PlateCount==0), :902 (Benchy success → "Loaded Benchy...", viewport.MoveCount>0, playback.MoveCount>0, _simMoveIndex==0, mesh.PlateCount==1). LoadAndAnalyzeFromPath is the production seam. NOT COVERED: MeshCanvasPainter happy-path rendering.

3. Load real G-code (.gcode / .gco / .nc / .bgcode)

Goal: load a user-picked G-code file directly — bypasses the slice-from-mesh path so a real production slice can be opened without needing the source 3MF. The mesh underlay is dropped so loading G-code after Benchy does not paint the old model underneath; playback is reset and armed at index 0. Parse failures surface as a status-bar error. Preconditions: boot done; _session valid.

  1. Click "Load G-code" (MainWindow.axaml:29–32) → OnLoadGcodeClick (MW:259).
  2. Session guard — silent return (MW:261–264).
  3. Picker resolve: ResolveGcodePicker() (MW:107–117) — first call constructs AvaloniaGcodeFilePicker(this) (SH/Controls/GcodeFilePicker.cs:191–265) wrapping the window's StorageProvider; subsequent calls reuse the cached instance.
  4. Picker call: await picker.PickGcodeAsync() (MW:281) → AvaloniaGcodeFilePicker.PickGcodeAsync (GcodeFilePicker.cs:206–264): builds FilePickerOpenOptions { AllowMultiple=false, FileTypeFilter= [G-code: *.gcode/*.gco/*.nc, Binary G-code: *.bgcode] }, calls provider.OpenFilePickerAsync(options); cancellation returns null; I/O failure returns GcodePickResult.Failure(message).
  5. Dispatch by extension: LoadGcodeFromBytes(displayName, extension, bytes) (MW:314). .gcode / .gco / .nc / .txtEncoding.UTF8.GetString(bytes)EngineSession.ParseGcode(text) (EH/EngineSession.cs:124–147); .bgcodeEngineSession.ParseBgcode(bytes) (:158–176); unknown extension → Status("Load G-code: unsupported extension '...'") + return.
  6. UI mirror invalidation BEFORE parse: InvalidateToolpathUiState() (MW:473–485 def; called at MW:328) clears _findings, pushes SetMoves(IntPtr.Zero, UIntPtr.Zero) to the viewport, clears the canvas, and clears the findings panel — the engine drops the move buffer on every parse call.
  7. Drop the mesh underlay: _mesh = null (MW:332). The render loop iterates _meshPainter.RenderTo(canvas, _mesh, …) (MW:1038), and the painter is a no-op when _mesh == null (SH/Controls/MeshCanvasPainter.cs:126). Without this, loading G-code after Benchy misleadingly paints the old model underneath the new toolpath.
  8. Parse facade (EH/EngineSession.cs:124–197): clears the move mirror, routes bytes/text to NpsAbi.ParseGcode / NpsAbi.ParseBgcode (EH/NpsAbi.cs:379, 382EC:3621 / EC:3407), then snapshots the engine-owned move buffer via the shared SnapshotMoves helper (EngineSession.cs:183–197).
  9. Failure branch: !pr.SucceededStatus("Load G-code failed: code=... <engine message>") (MW:350–353); engine diagnostic read back via GetLastErrorMessage (EH/EngineSession.cs:201–207). Return without presenting.
  10. No-moves branch: pr.Succeeded && !pr.HasMovesStatus("Load G-code: '<name>' parsed but produced no moves (empty or non-G-code text).") (MW:356–359).
  11. Success: PresentParsedToolpath(movesPtr, movesCount, displayName, ext) (MW:369–443):
    • _viewport.SetMoves / SetLayerVisibility / SetCamera / BuildBuffers (MW:373–376).
    • ResolveMaxLayerFromViewport() (MW:449–467) walks the engine-owned MovesSpan (no copy) to find the max layer, then _maxLayer = Math.Max(_maxLayer, layerMax) so a 250-layer real .bgcode grows the slider past the legacy hard-coded 10.
    • LayerSlider.Maximum = _maxLayer; Value = _maxLayer (MW:391–396).
    • Playback reset: if _isPlaying → stop the timer (MW:400–404); _simMoveIndex = 0 (MW:406).
    • Analyze (MW:408) → LoadFindings (MW:415).
    • Default thermal overlay (MW:417–423) → ApplyCurrentOverlay (MW:425).
    • Playback arm: PlaybackPanel.MoveCount = movesCount; SimIndex = 0; SetMarkers(_findings) (MW:431–437) so the scrubber range + thumb + markers track the freshly parsed toolpath.
    • RenderViewport() (MW:439); final status "G-code loaded: '<name>' N moves (text|binary), ... findings. Overlays+playback ready. Press P or step." (MW:442).

Tested by: AppHeadlessSmokeTests.SimulatorApp_LoadGcodeButton_* (AppHeadlessSmokeTests.cs): …_ScriptedPicker_Parses_Analyzes_ArmsPlayback (text payload → status "G-code loaded", viewport.MoveCount > 0, HasMoves > 0, playback.MoveCount > 0, playback.SimIndex == 0, _mesh == null, _simMoveIndex == 0), …_BgcodeBytes_DispatchToParseBgcode (junk .bgcode → status "Load G-code failed: code=4 ...", HasMoves == false). EngineSessionTests.ParseGcode_* and ParseBgcode_* cover the session-shape facade. NOT COVERED: the real Avalonia StorageProvider.OpenFilePickerAsync UI under Avalonia.Headless (the platform is not deterministic there); manual smoke only.

4. Findings browse, filter, select + timeline markers

Goal: fill the findings list, filter by chips, select a row → detail pane, layer/sim navigation, viewport marker. Preconditions: workflow 2 step 9 ran; PanelFindings exists.

  1. LoadFindings rewires FindingSelected (unsubscribe-first, MW:322–323) → _session.GetFindingsSummary() (MW:332EngineSession.cs:376–401 → count probe GetAnalysisFindingsSummaryCount EC:5498 → fill GetAnalysisFindingsSummary EC:5501–552516-byte summary rows, findingId = source index EC:1531–1534; first read lazily ensures analysis EC:4958).
  2. UpdateFindingsList (MW:338–357) prefetches the 300-byte detail per row via _session.GetFindingDetail(s.findingId) (MW:353EngineSession.cs:407EC:5530) and stamps FindingItems via FindingFormatter.FromSummaryAndDetail (MW:354).
  3. panel.SetFindings(viewItems) (MW:356FindingsPanel.axaml.cs:94) → prefetch cache _byFindingId (:100–107), ClearDetail (:110), RebuildList (:342–351).
  4. Chip click (hop 1, shell-only): OnChipAllClick / OnChipSeverityClick / OnChipCategoryClick (FindingsPanel.axaml.cs:268/276/305) → immutable FindingsFilterState (FindingsFilterState.cs:113–135) → RebuildList — pure in-memory, no engine call, no repaint.
  5. Row select: OnFindingSelected (FindingsPanel.axaml.cs:328) → UpdateDetail (:142) → FindingSelected?.Invoke (:332) → app OnFindingSelected (MW:359): detail from panel cache TryGetCachedItem (MW:376–379), fallback re-fetch (:384–391); panel?.UpdateDetail(stamped) (:400) + status (:402).
  6. Navigation: _maxLayer bump (MW:405–408) → SetLayerVisibility (:412) → _simMoveIndex = stamped.Data.moveIndex (:415) → _viewport.SetSimulationState(true, idx) (:416ViewportController.cs:493EC:5334) → BuildBuffers (:418) → scrubber mirror pp.SimIndex (:423–427, echo suppressed PlaybackPanel.axaml.cs:231–249) → UpdateLayerLabel (:429) → RenderViewport (:430 — draw capped at _simMoveIndex + 1, MW:742; yellow ellipse markers at finding moves :803–816).
  7. Timeline markers (set at load): playbackPanel.SetMarkers(_findings) (MW:309PlaybackPanel.axaml.cs:303–308TimelineMarkerHelper.BuildMarkers — drops moveIndex == -1 — → RebuildMarkerBorders :543PositionMarkers :580).

Tested by: EngineBridgeTests.TestAnalysisFindingsSummary_RoundTripsThroughDetail (EngineBridgeTests.cs:1468), ..._BeforeAnalyze_ReportsNoData (:1548); EngineSessionTests.GetFindingDetail_ReturnsFullRecordForKnownId (EngineSessionTests.cs:211), ..._StaleId_ReturnsNull (:233); FindingsPanelTests.* (FindingsPanelTests.cs:43–248 — chips, filter, selection event, detail props, cache miss); TimelineMarkerHelperTests (fraction/clamps/-1 exclusion :138). COVERED (nps-oro.3): app-side OnFindingSelected (cache tiers, layer bump, SetSimulationState, scrubber mirror) via AppHeadlessSmokeTests.cs:1052 (pinned move layer=3, moveIndex=25 → _maxLayer>=4, _simMoveIndex==25, playback.SimIndex==25) and :1115 (unpinned move layer=2, moveIndex=-1 → _maxLayer>=3 bump but _simMoveIndex==0 unchanged — proves the moveIndex>=0 gate at MW:430-433 and the PlaybackPanel.SimIndex mirror at MW:441). NOT COVERED: marker border placement on the canvas.

5. Overlays

Goal: recolor extrudes by an analysis scalar; update legend/stats. Preconditions: moves + analysis loaded (guard MW:467).

  1. Gesture: radio click (OverlayPanel.axaml:10–14; "None" = tag motion-type, default) → OnRadioButtonChecked (OverlayPanel.axaml.cs:98–108) → ModeChanged(modeId) (:66).
  2. Hop 2: OnOverlayModeChanged (MW:459–463) → _currentOverlayMode = modeIdApplyCurrentOverlay() (:465).
  3. stats = _viewport.ApplyOverlay(_session, mode, false) (MW:473ViewportController.cs:564–640): per-move session.GetOverlayValue(mode, layer, i) (:588EngineSession.cs:486NpsAbi.GetOverlayValueEC:5560AnalysisManager::getOverlayValue EC:4992); stats exclude v == 0 except Thermal (:593); one-shot NpsAbi.ViewportApplyOverlay (:547–554EC:5363–5376GcodeViewport::ApplyOverlay EC:5829); warp-risk auto-enables SetWarpParams(true, 5.0f) (:627–634EC:5344).
  4. BuildBuffers (MW:474); legend/stats written (:477–497: LegendLow/High, StatsText, IsOverlayActive).
  5. RenderViewport (:499) — per extrude move, heatmap via NpsAbi.ViewportGetHeatmapColor when overlay active and mode != motion-type (MW:776–781EC:5381 → ramp EC:6186), else flat green; status (:500–507).

Tested by: ViewportControllerTests.ApplyOverlay_FromSession_ComputesStatsAndAppliesOverlay (ViewportControllerTests.cs:460), ApplyOverlay_NonThermal_..._SampleCountExcludesZeros (:509); EngineBridgeTests.ViewportGetHeatmapColor_* (EngineBridgeTests.cs:1642/1661/1675/1686); Catch2 engine_tests.cpp:4065/4107 (aggregate = three-setter parity). COVERED (nps-oro.3): the radio → ModeChanged → app chain incl. "None" → motion-type at UI level via AppHeadlessSmokeTests.cs:998 — after Benchy success, reflects OverlayPanel.ModeChanged with modeId="motion-type", asserts _currentOverlayMode=="motion-type", overlayPanel.IsOverlayActive==false, and that the status reflects "Overlay cleared" / "Overlay 'motion-type'". NOT COVERED: legend/stats text updates; warp-risk SetWarpParams path.

6. Playback

Goal: advance a playhead through the moves — play/pause, step, speed, scrub, auto-pause near findings. Preconditions: moves loaded (TogglePlay guard MW:512; StepPlayback guard MW:618–621). _playTimer ticks StepPlayback(1) (MW:105–106); live interval 120 / max(0.1, _playSpeed) ms (MW:520, 543–546, 601).

  1. Play/Pause: button click → OnPlayPauseClick (PlaybackPanel.axaml.cs:428) → PlayPauseClicked (:91) → sub MW:124TogglePlay (MW:510–536): flip _isPlaying (:517); play → interval + Start() (:520–521) + status "Playback started (P to pause)…" (:522); pause → Stop() + "Playback paused." (:526–527); mirror panel.IsPlaying (:531–535PlaybackPanel.axaml.cs:256–269UpdatePlayPauseIcon :518–536 swaps Icon.Pause/Icon.Play).
  2. Step buttons: OnStepBackClick/OnStepForwardClick (PlaybackPanel.axaml.cs:433/438) → StepClicked(-1/+1) (:94) → sub MW:125StepPlayback(dir) (MW:616–658).
  3. Speed slider (0.1–10×): OnSpeedValueChanged (PlaybackPanel.axaml.cs:443) → SpeedChanged (:97) → sub MW:126OnPanelSpeedChanged (MW:540–547) → _playSpeed = speed; live interval recompute if playing.
  4. Scrubber press (thumb or track): BeginUserScrub (PlaybackPanel.axaml.cs:365–374) → ScrubberPointerPressed once (:109) → sub MW:132OnScrubberPressPause (MW:567–590): latch _wasPlayingBeforeScrub (idempotent, :574–579); if playing → _isPlaying=false, timer stop, panel.IsPlaying=false (:580–589).
  5. Scrubber drag: intermediate Value changes parked by _isUserScrubbing (PlaybackPanel.axaml.cs:478–481) — no engine traffic per pixel.
  6. Scrub commit: drag end → EndDrag (:405–422) → ONE ScrubberChanged(rounded) (:418–420); track click commits via OnScrubberPropertyChanged (:483–484) → sub MW:128OnScrubberChanged (MW:554–565): clamp (:561) → SetSimulationState(true, idx) (:562) → BuildBuffers (:563) → RenderViewport (:564). No auto-pause on scrub (:549–553).
  7. Scrub release: EndUserScrub (PlaybackPanel.axaml.cs:376–392) → ScrubberPointerReleased (:114) → sub MW:133OnScrubberReleaseMaybeResume (MW:592–614): resume (interval reset, timer start, icon, "Playback resumed after scrub.") only if playing before press (:598–609); latch always cleared (:613). Capture-lost takes the same path (PlaybackPanel.axaml.cs:357–363).
  8. Auto-pause checkbox: AutoPauseChanged (:100) → sub MW:127_autoPause = enabled (default true, MW:64, 136).
sequenceDiagram
    participant T as "_playTimer (DispatcherTimer)"
    participant W as "MainWindow (SimulatorApp)"
    participant VC as "ViewportController"
    participant E as "NpsAbi / nps_c_api / engine"
    participant P as "PlaybackPanel"
    T->>W: "Tick → StepPlayback(1) (MainWindow.axaml.cs:106)"
    W->>W: "clamp _simMoveIndex (:623)"
    W->>VC: "SetSimulationState(true, idx) (:624)"
    VC->>E: "ViewportSetSimulationState (:493 → engine.cpp:5334 → :5777)"
    W->>VC: "BuildBuffers (:625 → :660 → engine.cpp:5293)"
    W->>P: "SimIndex = idx (:629–633, echo suppressed)"
    W->>W: "RenderViewport (:635, draw cap :742)"
    alt "delta>0 AND _autoPause AND |moveIndex - simIndex| < 2 (:645)"
        W->>T: "Stop (:650)"
        W->>P: "IsPlaying = false — icon swap (:653)"
        W->>W: "Status Auto-paused at finding. (:655)"
    end

Tested by (nps-oro.1, all 6 Facts green): AppHeadlessSmokeTests.SimulatorApp_PlaybackRuntimeTests (AppHeadlessSmokeTests.cs:1391–1873) drives every public panel event (PlayPauseClicked, StepClicked, AutoPause, ScrubberPointerPressed/Released, SpeedChanged, ScrubberChanged) through the real MainWindow handlers (:124–133) inside a shared LoadedSimulatorWindow AvaloniaHeadless class fixture: - PlayPauseClicked_Starts_And_Stops_Timer_With_IconSwap (:1410) — panel.IsPlaying flip + status "Playback started" / "Playback paused"; observable contract for the icon swap (PlaybackPanel.axaml.cs:518–536). - StepClicked_ClampsAtBounds (:1449) — panel.SimIndex clamps at 0 (backward) and MoveCount-1 (forward, even after MoveCount + 5 steps) via MainWindow.axaml.cs:640. - AutoPause_FiresWithinTwoMoves_OnlyWhenEnabled (:1497) — drives Step into a finding's |moveIndex − simIndex| < 2 neighborhood and asserts status "Auto-paused at finding." (:672) when _autoPause=true; the same step path leaves IsPlaying=true when _autoPause=false. Reflection on _findings + a seeded moveIndex=5 row covers both real and empty-analyzer-result cases. - ScrubberPointer_PausesOnPress_ResumesOnReleaseOnlyIfWasPlaying (:1634) — press-while-playing pauses; release resumes; press-while-paused + release stays paused (MainWindow.axaml.cs:584–631). - SpeedChanged_RecomputesTimerInterval_Live (:1697) — reads _playTimer.Interval directly: 5.0× → 24 ms, 0.5× → 240 ms, 0.05× → 1200 ms (the Math.Max(0.1, speed) floor at MainWindow.axaml.cs:520, 562). - ScrubberChanged_ClampsAndMirrorsSimIndex (:1758) — mid-range land as-is; over-range high clamps to MoveCount-1; negative clamps to 0; mirrored via _simMoveIndex (MainWindow.axaml.cs:578).

Also tested: ShellChromeTests.ShellKeybindings_* (dispatch only — see workflow 7); TimelineMarkerHelperTests (marker math).

7. Camera and navigation

Goal: orbit / pan / snap / fit the viewport. There is no wheel zoom (zero PointerWheelChanged anywhere in apps or shell).

  1. Press: ViewportPointerPressed (MainWindowShell.axaml.cs:35, forwarded :269–281) → sub MW:87_isDragging = true, _lastPointer (MW:661–665).
  2. Left-drag (orbit): ViewportPointerMovedOnViewportPointerMove (MW:666–691): RotationZRadians += dx*0.01, RotationXRadians clamped ±1.5 (:678–683) → _viewport.SetCamera(cam) (:689ViewportController.cs:262–273NpsAbi.ViewportSetCameraEC:5286GcodeViewport::SetCamera EC:5761) → RenderViewport (:690).
  3. Other-button drag (pan): same handler, PanX/Y += d * 0.5 / max(0.1, zoom) (:684–688).
  4. Release: _isDragging = false (MW:692).
  5. Preset buttons: toolbar event (e.g. ResetClicked, ViewportToolbar.cs:18, raised :70) → lambda MW:91SetCameraPreset (ViewportController.cs:294–328: Reset 0.6/0.4/zoom 1.0; Isometric 0.6154797/0.7853982; Front 1.5/0; Top 0/0; Right 1.5/−1.5707963; pan zeroed :327) → RenderViewport.
  6. Zoom to Fit: ZoomFitClickedZoomFitCamera() (MW:96:827–849) → _viewport.ComputeZoomFit(w, h) (ViewportController.cs:432–482, projects via NpsAbi.ProjectOrbitXY :456, ~90% fill) → SetCamera(fitCam) (MW:847) → RenderViewport (:848).

Tested by: ViewportControllerTests.SetCameraPreset_UpdatesCameraRotationAndZeroPan (ViewportControllerTests.cs:253), CameraPresets_ProduceNonCollapsedProjectedBounds... (:305), ComputeZoomFit_* (:376/:415/:439); EngineBridgeTests.ProjectOrbitXY_MatchesLegacyUiMath (EngineBridgeTests.cs:1697) + identity/null rows (:1741/:1753); Catch2 engine_tests.cpp:2078 (projection parity), :1976 (engine MVP). NOT COVERED: the pointer-drag handlers and every toolbar button → lambda wiring (zero tests).

8. Keyboard

Goal: keyboard control of playback and the inspector. Exactly four keys exist (ShellKeybindings.cs:82–113).

  1. Window KeyDown (MainWindow.axaml:8) → OnKeyDown (MW:188–201) → ShellKeybindings.Handle(e, Actions) with TogglePlay = TogglePlay (:192), StepForward = () => StepPlayback(1) (:193), StepBackward = () => StepPlayback(-1) (:194), ToggleInspector lambda (:195–199).
  2. PTogglePlay (ShellKeybindings.cs:84–89) → workflow 5 step 1.
  3. Right / LeftStepPlayback(±1) (:98–110) → workflow 5 step 2.
  4. Ctrl+LShell.IsRightExpanded = !Shell.IsRightExpanded + status (MW:195–199 → shell layout MainWindowShell.axaml.cs:319–322, 382).
  5. Unhandled keys fall through (ShellKeybindings.cs:112). Bead nps-ai2.4 simplified the overlay tab tooltip to "Overlays" (MainWindowShell.axaml:86) — the stale "(T/S/C/W)" suffix is gone, because those keys never existed.

Tested by: ShellChromeTests.ShellKeybindings_P_TogglesPlay_And_Handles (ShellChromeTests.cs:50), _Mod_L_TogglesInspector_Only_With_Ctrl (:69), _Left_Right_Step_And_Handle (:89), _Unhandled_Key_FallsThrough (:114). NOT COVERED: the app-side wiring of ToggleInspectorShell.IsRightExpanded in OnKeyDown.

9. Chrome: collapse, splitters, tabs, status

Goal: sidebar layout, inspector tab switching, status reporting.

  1. Collapse/expand (shell-internal, no app code): / buttons (MainWindowShell.axaml:44/52/82/105) → OnCollapseLeftClick/OnExpandLeftClick/OnCollapseRightClick/OnExpandRightClick (MainWindowShell.axaml.cs:294–312) → UpdateLeftLayout/UpdateRightLayout (:324–438) — pixel width cached in _lastLeftWidth/_lastRightWidth (:26–27, defaults 200/320) and restored on expand.
  2. GridSplitters (MainWindowShell.axaml:61, 70): Avalonia built-in drag resizes column pairs 0/2 and 2/4; hidden while collapsed (MainWindowShell.axaml.cs:363–366, 421–424). No app or engine involvement.
  3. Activity tab click: button → Activity.HandleClick (MainWindowShell.axaml.cs:154–155, 185) → TabClicked(tag) (ActivityBarHost.cs:58, 80–86) → sub MW:85OnActivityTabClicked (MW:144–153) → Shell.Panels.Show(key, onShown) (PanelHost.cs:73–87 flips IsVisible, then callback) — "findings" re-runs LoadFindings() (MW:148–151).
  4. Status paths (two independent writes per Status() call): visible bar via Shell.StatusText = msg (MW:853 → styled prop MainWindowShell.axaml.cs:90–97 → bound StatusBar TextBlock MainWindow.axaml:63–66); process output via StatusSink.Emit(msg) (MW:857StatusSink.cs:57–62 writes "[SlicerUI] {msg}" to both stdout and stderr). The render loop's coords line (MW:820–824) writes only StatusText, never stderr.

Tested by: AppHeadlessSmokeTests.MainWindowShell_Collapse_Width_Memory_Smoke (AppHeadlessSmokeTests.cs:152); ShellChromeTests.ActivityBarHost_FiresTabClicked_ForRegisteredKey (ShellChromeTests.cs:146) + unregistered-tag row (:130); PanelHost_Show_FlipsExactlyOneVisible_And_FiresOnShown (:164) + unknown-key row (:188). NOT COVERED: StatusSink.Emit dual-write (only the disabled smoke script); GridSplitter drags. The misleading "(T/S/C/W)" tooltip text was removed in bead nps-ai2.4.