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.
dotnet run→Program.Main(src/SimulatorApp/Program.cs:15,[STAThread]).- Raw-ABI smoke check (Simulator-only):
NpsAbi.GetAbiVersion()(Program.cs:22→EH/NpsAbi.cs:241→ C exportEC:2816returnsNPS_ABI_VERSION= 1,nps_c_api.h:234); mismatch throws (Program.cs:23–28). SafeEngineHandlewrapsNpsAbi.CreateEngine()(Program.cs:31→EC:2789→new nps::Engine(), ctorEC:469);InitializeEngine+GetEngineVersion(Program.cs:33–34→EC:2800/2806→Engine::InitializeEC:486); version printed to stdout (:35).- Fixture probe:
FixtureLocator.FindPrimaryBenchy3Mf()(Program.cs:41, walk orderSH/FixtureLocator.cs:60–90, filename const:41) →NpsAbi.LoadModelFromFile(Program.cs:43→EC:2820→Engine::LoadModelFromFileEC:540). Success prints plates/verts/tris/palette/unit (Program.cs:50–71); failure printsGetLastErrorMessageto stdout+stderr (:73–82). Exceptions are caught — the UI starts regardless (:88–99). BuildAvaloniaApp().StartWithClassicDesktopLifetime(args)(Program.cs:98–99):ShellTrace.InstallStderrSink()(:108),UsePlatformDetect().WithInterFont().LogToTrace()(:103–117).App.OnFrameworkInitializationCompleted→new MainWindow()(App.axaml.cs:14–22).MainWindowctor (MW:77–81) →InitializeShell(MW:83–142): wires all subscriptions (:85–137), creates_playTimer(DispatcherTimer, 80 ms,:105–106),_session = EngineSession.CreateAndInitialize()(:108→EH/EngineSession.cs:165→Initialize:177–193→NpsAbi.CreateEngine:184),_mesh = new MeshController(_session)(:109),_viewport = ViewportController.CreateAndInitialize()(:110→SH/ViewportController.cs:203→NpsAbi.CreateGcodeViewport:222→EC:5266), initial status (:102),Focusable = true; Focus()(:140–141).- Shell self-composition: panel discovery + registration (
MainWindowShell.axaml.cs:212–254), playback tab registered becauseIsPlaybackTabVisibleis default-true (:172–188),Panels.Show("overlays")(:250), viewport pointer forwarding attached (:256–276). - First paint: chrome + empty canvas;
RenderViewportwould early-out (no moves). Camera defaults rotX 0.6 / rotZ 0.4 / zoom 1.0 (ViewportController.cs:139–144); overlay mode defaultmotion-type(:162);_maxLayer = 10; playback initial pushMoveCount=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).
- Gesture: click "Load + Gen + Analyze (Benchy)" (
MainWindow.axaml:28) →OnLoadAnalyzeClick(MW:205). - Fixture resolve (
MW:212→FixtureLocator.cs:60–90); missing → error status + return (MW:213–217, engine never called). LoadResult load = _session.LoadModel(benchy)(MW:219→EH/EngineSession.cs:202→NpsAbi.LoadModelFromFile:211→EC:2820→Engine::LoadModelFromFileEC:550— clears prior mesh + all three gcode buffersEC:567–577, ZIP/OPC extractEC:657–684, XML parseEC:718–745, paletteEC:800–809, resolveEC:871, plate pushEC:961)._mesh?.Reload()(MW:226— always, success or fail, because step 3 cleared engine state;MeshController.cs:131–176copies positions/indices/colorIndices/palette into managed arrays).- Failure →
Status($"Load failed: …")+ return (MW:228–232); message came fromNpsAbi.GetLastErrorMessage(EngineSession.cs:214→EC:2855). - Success → status +
MeshInfoLabel.Textplates/palette/unit (MW:234–242). GenerateResult gr = _session.GenerateDemoToolpath(new GcodeGenOptions(false, 0.2f, 1.0f, 1800, 5))(MW:245–250→EngineSession.cs:253–307:AllocHGlobal+StructureToPtr:277–280→NpsAbi.GenerateGcode:281→EC:2947→Engine::GenerateGcodeEC:1215— DEMO toolpath, not a production slice, headerEC:1314–1317; move snapshotGetLastGcodeMoveCount/GetLastGcodeMovesEngineSession.cs:295–305). Failure / no-moves statuses (MW:251–261).- Viewport feed (
MW:263–269):_viewport.SetMoves(gr.MovesPointer, gr.MovesCount)(:265→ViewportController.cs:240–246→NpsAbi.ViewportSetGcodeMoves— exact export name —EC:5274→GcodeViewport::SetGcodeMovesEC:5714, which copies intoownedMoves_);SetLayerVisibility(:266→EC:5280);SetCamera(0.6, 0.4, 0, 0, 1.0)(:267→EC:5286);BuildBuffers(:268→EC:5293→BuildRenderBuffersEC:5861). AnalyzeResult ar = _session.Analyze(new MaterialProfileOptions("PLA", 210, 60))(MW:272→EngineSession.cs:332–369→NpsAbi.AnalyzeGcode:361→EC:5421–5459→Engine::AnalyzeGcodeEC:1479→AnalysisManager::analyzeEC:4909→ six analyzersEC:4916–4919). Failure status (MW:273–277).LoadFindings()(MW:280→:316–336) → workflow 3, steps 1–3.- Force thermal overlay (
MW:288–295→ApplyCurrentOverlay:465) → workflow 4. _simMoveIndex = 0(:297);UpdateLayerLabel()(:298); playback pushMoveCount / SimIndex = 0 / SetMarkers(_findings)(:304–310).- 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). NOT COVERED: OnLoadAnalyzeClick itself (existence-only in headless smoke); the status/MeshInfoLabel strings; MeshCanvasPainter happy-path rendering.
3. 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.
LoadFindingsrewiresFindingSelected(unsubscribe-first,MW:322–323) →_session.GetFindingsSummary()(MW:332→EngineSession.cs:376–401→ count probeGetAnalysisFindingsSummaryCountEC:5498→ fillGetAnalysisFindingsSummaryEC:5501–5525— 16-byte summary rows,findingId= source indexEC:1531–1534; first read lazily ensures analysisEC:4958).UpdateFindingsList(MW:338–357) prefetches the 300-byte detail per row via_session.GetFindingDetail(s.findingId)(MW:353→EngineSession.cs:407→EC:5530) and stampsFindingItems viaFindingFormatter.FromSummaryAndDetail(MW:354).panel.SetFindings(viewItems)(MW:356→FindingsPanel.axaml.cs:94) → prefetch cache_byFindingId(:100–107),ClearDetail(:110),RebuildList(:342–351).- Chip click (hop 1, shell-only):
OnChipAllClick/OnChipSeverityClick/OnChipCategoryClick(FindingsPanel.axaml.cs:268/276/305) → immutableFindingsFilterState(FindingsFilterState.cs:113–135) →RebuildList— pure in-memory, no engine call, no repaint. - Row select:
OnFindingSelected(FindingsPanel.axaml.cs:328) →UpdateDetail(:142) →FindingSelected?.Invoke(:332) → appOnFindingSelected(MW:359): detail from panel cacheTryGetCachedItem(MW:376–379), fallback re-fetch (:384–391);panel?.UpdateDetail(stamped)(:400) + status (:402). - Navigation:
_maxLayerbump (MW:405–408) →SetLayerVisibility(:412) →_simMoveIndex = stamped.Data.moveIndex(:415) →_viewport.SetSimulationState(true, idx)(:416→ViewportController.cs:493→EC:5334) →BuildBuffers(:418) → scrubber mirrorpp.SimIndex(:423–427, echo suppressedPlaybackPanel.axaml.cs:231–249) →UpdateLayerLabel(:429) →RenderViewport(:430— draw capped at_simMoveIndex + 1,MW:742; yellow ellipse markers at finding moves:803–816). - Timeline markers (set at load):
playbackPanel.SetMarkers(_findings)(MW:309→PlaybackPanel.axaml.cs:303–308→TimelineMarkerHelper.BuildMarkers— dropsmoveIndex == -1— →RebuildMarkerBorders:543→PositionMarkers: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). NOT COVERED: app-side OnFindingSelected (cache tiers, layer bump, SetSimulationState, scrubber mirror); marker border placement on the canvas.
4. Overlays¶
Goal: recolor extrudes by an analysis scalar; update legend/stats.
Preconditions: moves + analysis loaded (guard MW:467).
- Gesture: radio click (
OverlayPanel.axaml:10–14; "None" = tagmotion-type, default) →OnRadioButtonChecked(OverlayPanel.axaml.cs:98–108) →ModeChanged(modeId)(:66). - Hop 2:
OnOverlayModeChanged(MW:459–463) →_currentOverlayMode = modeId→ApplyCurrentOverlay()(:465). stats = _viewport.ApplyOverlay(_session, mode, false)(MW:473→ViewportController.cs:564–640): per-movesession.GetOverlayValue(mode, layer, i)(:588→EngineSession.cs:486→NpsAbi.GetOverlayValue→EC:5560→AnalysisManager::getOverlayValueEC:4992); stats excludev == 0except Thermal (:593); one-shotNpsAbi.ViewportApplyOverlay(:547–554→EC:5363–5376→GcodeViewport::ApplyOverlayEC:5829); warp-risk auto-enablesSetWarpParams(true, 5.0f)(:627–634→EC:5344).BuildBuffers(MW:474); legend/stats written (:477–497:LegendLow/High,StatsText,IsOverlayActive).RenderViewport(:499) — per extrude move, heatmap viaNpsAbi.ViewportGetHeatmapColorwhen overlay active and mode !=motion-type(MW:776–781→EC:5381→ rampEC: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). NOT COVERED: the radio → ModeChanged → app chain (incl. "None" → motion-type) at UI level; legend/stats text updates; warp-risk SetWarpParams path.
5. 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).
- Play/Pause: button click →
OnPlayPauseClick(PlaybackPanel.axaml.cs:428) →PlayPauseClicked(:91) → subMW:124→TogglePlay(MW:510–536): flip_isPlaying(:517); play → interval +Start()(:520–521) + status "Playback started (P to pause)…" (:522); pause →Stop()+ "Playback paused." (:526–527); mirrorpanel.IsPlaying(:531–535→PlaybackPanel.axaml.cs:256–269→UpdatePlayPauseIcon:518–536swapsIcon.Pause/Icon.Play). - Step buttons:
OnStepBackClick/OnStepForwardClick(PlaybackPanel.axaml.cs:433/438) →StepClicked(-1/+1)(:94) → subMW:125→StepPlayback(dir)(MW:616–658). - Speed slider (0.1–10×):
OnSpeedValueChanged(PlaybackPanel.axaml.cs:443) →SpeedChanged(:97) → subMW:126→OnPanelSpeedChanged(MW:540–547) →_playSpeed = speed; live interval recompute if playing. - Scrubber press (thumb or track):
BeginUserScrub(PlaybackPanel.axaml.cs:365–374) →ScrubberPointerPressedonce (:109) → subMW:132→OnScrubberPressPause(MW:567–590): latch_wasPlayingBeforeScrub(idempotent,:574–579); if playing →_isPlaying=false, timer stop,panel.IsPlaying=false(:580–589). - Scrubber drag: intermediate
Valuechanges parked by_isUserScrubbing(PlaybackPanel.axaml.cs:478–481) — no engine traffic per pixel. - Scrub commit: drag end →
EndDrag(:405–422) → ONEScrubberChanged(rounded)(:418–420); track click commits viaOnScrubberPropertyChanged(:483–484) → subMW:128→OnScrubberChanged(MW:554–565): clamp (:561) →SetSimulationState(true, idx)(:562) →BuildBuffers(:563) →RenderViewport(:564). No auto-pause on scrub (:549–553). - Scrub release:
EndUserScrub(PlaybackPanel.axaml.cs:376–392) →ScrubberPointerReleased(:114) → subMW:133→OnScrubberReleaseMaybeResume(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). - Auto-pause checkbox:
AutoPauseChanged(:100) → subMW: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: ShellChromeTests.ShellKeybindings_* (dispatch only — see workflow 7); TimelineMarkerHelperTests (marker math). NOT COVERED (zero tests): TogglePlay, StepPlayback, the auto-pause rule |moveIndex − simIndex| < 2 && _autoPause (MW:645), the scrub press/drag/release latch, speed → interval recompute, and the play/pause icon swap.
6. Camera and navigation¶
Goal: orbit / pan / snap / fit the viewport. There is no wheel zoom
(zero PointerWheelChanged anywhere in apps or shell).
- Press:
ViewportPointerPressed(MainWindowShell.axaml.cs:35, forwarded:269–281) → subMW:87→_isDragging = true,_lastPointer(MW:661–665). - Left-drag (orbit):
ViewportPointerMoved→OnViewportPointerMove(MW:666–691):RotationZRadians += dx*0.01,RotationXRadiansclamped ±1.5 (:678–683) →_viewport.SetCamera(cam)(:689→ViewportController.cs:262–273→NpsAbi.ViewportSetCamera→EC:5286→GcodeViewport::SetCameraEC:5761) →RenderViewport(:690). - Other-button drag (pan): same handler,
PanX/Y += d * 0.5 / max(0.1, zoom)(:684–688). - Release:
_isDragging = false(MW:692). - Preset buttons: toolbar event (e.g.
ResetClicked,ViewportToolbar.cs:18, raised:70) → lambdaMW:91→SetCameraPreset(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. - Zoom to Fit:
ZoomFitClicked→ZoomFitCamera()(MW:96→:827–849) →_viewport.ComputeZoomFit(w, h)(ViewportController.cs:432–482, projects viaNpsAbi.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).
7. Keyboard¶
Goal: keyboard control of playback and the inspector. Exactly four keys
exist (ShellKeybindings.cs:82–113).
- Window
KeyDown(MainWindow.axaml:8) →OnKeyDown(MW:188–201) →ShellKeybindings.Handle(e, Actions)withTogglePlay = TogglePlay(:192),StepForward = () => StepPlayback(1)(:193),StepBackward = () => StepPlayback(-1)(:194),ToggleInspectorlambda (:195–199). - P →
TogglePlay(ShellKeybindings.cs:84–89) → workflow 5 step 1. - Right / Left →
StepPlayback(±1)(:98–110) → workflow 5 step 2. - Ctrl+L →
Shell.IsRightExpanded = !Shell.IsRightExpanded+ status (MW:195–199→ shell layoutMainWindowShell.axaml.cs:319–322, 382). - Unhandled keys fall through (
ShellKeybindings.cs:112). The "Overlays (T/S/C/W)" tooltip (MainWindowShell.axaml:86) is stale — those keys do not exist.
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 ToggleInspector → Shell.IsRightExpanded in OnKeyDown.
8. Chrome: collapse, splitters, tabs, status¶
Goal: sidebar layout, inspector tab switching, status reporting.
- 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. - 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. - Activity tab click: button →
Activity.HandleClick(MainWindowShell.axaml.cs:154–155, 185) →TabClicked(tag)(ActivityBarHost.cs:58, 80–86) → subMW:85→OnActivityTabClicked(MW:144–153) →Shell.Panels.Show(key, onShown)(PanelHost.cs:73–87flipsIsVisible, then callback) —"findings"re-runsLoadFindings()(MW:148–151). - Status paths (two independent writes per
Status()call): visible bar viaShell.StatusText = msg(MW:853→ styled propMainWindowShell.axaml.cs:90–97→ boundStatusBarTextBlockMainWindow.axaml:63–66); process output viaStatusSink.Emit(msg)(MW:857→StatusSink.cs:57–62writes"[SlicerUI] {msg}"to both stdout and stderr). The render loop's coords line (MW:820–824) writes onlyStatusText, 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 stale "(T/S/C/W)" tooltip text.
Related pages¶
- Simulator — interaction map — the element inventory behind these chains.
- Simulator — how it is — pipeline narrative and limitations.
- Slicer — how it is — the sibling app (no playback; slice params instead).
- Codebase — user flows — engine-side depth for the same calls.