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). 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.

  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). 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).

  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). 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).

  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: 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).

  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).

7. 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). 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 ToggleInspectorShell.IsRightExpanded in OnKeyDown.

8. 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 stale "(T/S/C/W)" tooltip text.