Skip to content

Slicer — UI workflows

The complete click-level map of everything a user can do in SlicerApp. Each workflow lists its goal, preconditions, and the full hop chain — gesture → handler → panel event → app subscription → facade → NpsAbi export → nps::Engine / L0 internals → return path → exact UI updates — with file:line at every hop, then the tests that cover it. All wiring is code-behind events; there is no MVVM/ICommand anywhere. Path shorthand: MW = src/SlicerApp/MainWindow.axaml(.cs), SH = src/Nps.Shell, EH = src/Nps.EngineHost, EC = src/Engine/engine.cpp.

1. Boot & first paint

Goal: dotnet run → composed window with chrome, sidebars, empty viewport, ready status — before any user click. Preconditions: libNPSEngine.so locatable; the repo-root fixture matters only for the CLI preflight (the GUI boots even when preflight fails).

  1. Program.Main (src/SlicerApp/Program.cs:14, [STAThread] :13).
  2. ABI gate: EngineSession.EnsureAbiMatchesHost() (Program.cs:22; body EH/EngineSession.cs:104–113) → GetRuntimeAbiVersion (EH/EngineSession.cs:93–96) → NpsAbi.GetAbiVersion (EH/NpsAbi.cs:241) → C export GetAbiVersion (EC:2816) returns NPS_ABI_VERSION = 1. Mismatch → throw, caught at Program.cs:70–78 and dual-written to stdout+stderr.
  3. CLI preflight session: EngineSession.CreateAndInitialize() (Program.cs:27) → NpsAbi.CreateEngine (EH/EngineSession.cs:184) → CreateEngine (EC:2789) → new nps::Engine() (EC:2790; ctor EC:469); NpsAbi.InitializeEngine (EH/EngineSession.cs:191) → EC:2800.
  4. Preflight load + demo gen: FixtureLocator.FindPrimaryBenchy3Mf() (Program.cs:34, resolver SH/FixtureLocator.cs:60–90) → session.LoadModel(benchy) (Program.cs:35) → GenerateDemoToolpath(new GcodeGenOptions(false, 0.2f, 1.0f, 1800f, 0)) (Program.cs:59–64 — note NumLayers: 0 here vs GUI default 5) → prints stats; session disposed (DestroyEngine, EC:2793).
  5. Avalonia boot: BuildAvaloniaApp().StartWithClassicDesktopLifetime(args) (Program.cs:80–81); ShellTrace.InstallStderrSink() (Program.cs:90); App.Initialize() (src/SlicerApp/App.axaml.cs:9–12); desktop.MainWindow = new MainWindow() (App.axaml.cs:14–19).
  6. MainWindow() ctor: InitializeComponent() + InitializeShell() (MW.axaml.cs:61–62). The shell self-composes: MainGrid rows/cols (SH/Controls/MainWindowShell.axaml:13–25), registers "overlays"/"findings" tabs (MainWindowShell.axaml.cs:148–155), hides TabPlayback because IsPlaybackTabVisible="False" (:172–188, set at MW.axaml:20).
  7. InitializeShell (MW.axaml.cs:65–104): left sidebar DataContext = _sliceParams (:68–72); subscribes activity tab (:74), viewport pointer events (:76–78), 10 toolbar events (:80–89), OverlayPanel.ModeChanged (:96–100); Status("Slicer shell ready.") (:91); _session = EngineSession.CreateAndInitialize() (:93 — a second engine instance); _viewport = ViewportController.CreateAndInitialize() (:94) → NpsAbi.CreateGcodeViewport (SH/ViewportController.cs:222) → new nps::GcodeViewport() (EC:5266–5268).
  8. Shell OnLoaded (MainWindowShell.axaml.cs:212–254): auto-discovers PanelOverlays/PanelFindings, shows "overlays" by default (:250), attaches viewport pointer forwarders (AttachViewportHandlers, :256–276).
  9. First paint: chrome + empty ViewportCanvas. Initial state: _maxLayer=10 (MW.axaml.cs:44), _currentOverlayMode="motion-type" (:57), _modelLoaded=false (:51), camera rotX 0.6 / rotZ 0.4 / zoom 1.0 (SH/ViewportController.cs:139–144).

Tested by: AppHeadlessSmokeTests.SlicerApp_MainWindow_Loads_Layout_And_Bindings (tests/NPSEngine.Tests/AppHeadlessSmokeTests.cs:58 — title, ready status, controls, IsPlaybackTabVisible false, PanelPlayback null); EngineBridgeTests.GetAbiVersion_MatchesExpectedAtStartup (EngineBridgeTests.cs:194); EngineSessionTests.CreateAndInitialize_ProducesValidSession (EngineSessionTests.cs:48); ViewportControllerTests.CreateAndInitialize_ProducesValidViewport (ViewportControllerTests.cs:31). NOT COVERED: the CLI preflight in Program.cs:27–67 (no headless test runs Program.Main); StatusSink.Emit stderr mirroring (only the disabled scripts/smoke-apps.sh exercised it).

2. Load model via "Load + Gen + Analyze (Benchy primary)"

Goal: resolve the fixture, load the 3MF through the engine, show mesh counts/plate/palette/unit. Preconditions: workflow 1 done; _session valid.

  1. Click "Load + Gen + Analyze (Benchy primary)" (MW.axaml:58–60) → OnLoadAnalyzeClick (MW.axaml.cs:155).
  2. Session guard — silent return (MW.axaml.cs:157–160).
  3. Fixture resolve: FixtureLocator.FindPrimaryBenchy3Mf() (MW.axaml.cs:162) → walk up to 12 levels probing 4-colors+Benchy+AMS+test-v2.3mf (SH/FixtureLocator.cs:60–90, filename const :41).
  4. Existence gate: !File.ExistsStatus("ERROR: primary … required. Place at repo root.") (MW.axaml.cs:180–184) → status bar via Shell.StatusText (:684) + stderr/stdout mirror via StatusSink.Emit (:685) → return.
  5. Facade load: LoadResult load = _session.LoadModel(benchy) (MW.axaml.cs:186) → EngineSession.LoadModel (EH/EngineSession.cs:202) clears the move snapshot (:207–208) → NpsAbi.LoadModelFromFile (:211; DllImport EH/NpsAbi.cs:252) → C export (EC:2820) → nps::Engine::LoadModelFromFile (EC:540 → options overload EC:550): clears prior mesh + all three gcode buffers (EC:567–577), preflight (exists/regular/≤512 MiB, EC:597–618), magic sniff PK\3\4 (EC:641–644), detail::ZipArchiveReader.open (EC:657), extract 3D/3dmodel.model + 3D/Objects/object_1.model (EC:666–684), metadata configs (EC:690–714), ParseModelXmlObjects root+geo (EC:718–726), base-extruder scan (EC:748–793), palette via ColorParser (EC:800–809), MeshResolver::resolve (EC:871), single plate "plate_1" (EC:883–885), AABB (EC:908–940), thumbnail (EC:953–958), plate pushed (EC:961).
  6. Facade return-path queries (EH/EngineSession.cs:219–244): GetPlateCount, per-plate GetTriangleCountForPlate/GetVertexPositions, GetPaletteColorCount/GetPaletteColor, GetModelUnit (empty → "mm" :239) → LoadResult.Success.
  7. Failure branch: Status($"Load failed: {code} {message}") (MW.axaml.cs:187–191), message read back via GetLastErrorMessage (EH/EngineSession.cs:214–216EC:2855EC:971); return.
  8. Success: _modelLoaded = true (MW.axaml.cs:193); status line (:198); MeshInfoLabel.Text = "Plates: … | Pal: … | V=16562 T=33097 | millimeter" (:199–203); then RunGenerate() (:205) — workflow 3.
sequenceDiagram
  autonumber
  actor U as User
  participant B as "Load button (MW.axaml:58)"
  participant H as "OnLoadAnalyzeClick (MW.axaml.cs:155)"
  participant F as "EngineSession (EH)"
  participant A as "NpsAbi (EH)"
  participant E as "nps::Engine (EC)"
  participant V as "ViewportController (SH)"
  U->>B: "click: Load + Gen + Analyze"
  B->>H: Click event
  H->>H: "guard session, FixtureLocator (MW.axaml.cs:157-167)"
  H->>F: "LoadModel(benchy) (MW.axaml.cs:169)"
  F->>A: "LoadModelFromFile (EngineSession.cs:211)"
  A->>E: "C export LoadModelFromFile (EC:2820)"
  E-->>F: "LoadResult: plates/palette/unit (EC:968)"
  F-->>H: LoadResult
  H->>H: "MeshInfoLabel + status (MW.axaml.cs:198-203)"
  H->>F: "GenerateDemoToolpath(options) (MW.axaml.cs:278)"
  F->>A: "GenerateGcode (EngineSession.cs:281)"
  A->>E: "demo toolpath, shared moves (EC:2947 -> EC:1215)"
  E-->>F: "moves ptr+count (EC:3031/3037)"
  F-->>H: GenerateResult
  H->>V: "SetMoves / SetLayerVisibility / SetCamera (MW.axaml.cs:293-303)"
  V->>A: "ViewportSetGcodeMoves (ViewportController.cs:245)"
  A->>E: "viewport copies moves (EC:5274 -> EC:5714)"
  H->>F: "Analyze(PLA 210/60) (MW.axaml.cs:306)"
  F->>A: "AnalyzeGcode (EngineSession.cs:361)"
  A->>E: "6 analyzers (EC:5421 -> EC:1479 -> EC:4909)"
  H->>F: "findings summary + warp grids (MW.axaml.cs:313-314)"
  H->>V: "force thermal overlay + BuildBuffers (MW.axaml.cs:322-329)"
  V->>A: "ViewportApplyOverlay (ViewportController.cs:547)"
  A->>E: "EC:5363"
  H->>H: "RenderViewport + final status (MW.axaml.cs:330-334)"

Tested by: EngineSessionTests.LoadModel_OnBenchy3Mf_ReturnsStructuredPlatesAndPalette (EngineSessionTests.cs:95); LoadModel_ClearsPriorMoveBuffer (:142); EngineBridgeTests.LoadModelFromFile_OnBenchy3Mf_SucceedsWithAssertion (EngineBridgeTests.cs:292); error codes EngineSessionTests.LoadModel_MissingFile_ReturnsFailureWithMessage (:127), EngineBridgeTests.cs:446/:465/:555; C++ Catch2 src/Engine/tests/engine_tests.cpp:287/:621/:1092 (golden 16562 v / 33097 t / 4 palette colors); lefthook fixture-present gate (lefthook.yml:139–140). NOT COVERED: the FixtureLocator walk permutations (no walk-order unit test); the exact MeshInfoLabel text (only the "Plates:" prefix is asserted). COVERED (nps-oro.3): AppHeadlessSmokeTests.cs:367 (missing-fixture → "ERROR: primary 4-colors+Benchy+AMS+test-v2.3mf required...", engine never called), :420 (invalid-file → "Load failed: <code> <msg>", _modelLoaded stays false, engine session stays valid), :479 (Benchy success → "Slicer parity loaded ...", _modelLoaded==true, viewport.MoveCount>0, MeshInfoLabel populated). LoadAndAnalyzeFromPath is the production seam for the test surface; the on-click handler still resolves the fixture.

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

Goal: load a user-picked G-code file directly (text or binary) — bypasses the slice-from-mesh path so a real production slice can be opened without needing the source 3MF. Parse failures surface as a status-bar error. Preconditions: workflow 1 done; _session valid.

  1. Click "Load G-code" (MW.axaml:61–64) → OnLoadGcodeClick (MW.axaml.cs:272).
  2. Session guard — silent return (MW.axaml.cs:274–277).
  3. Picker resolve: ResolveGcodePicker() (MW.axaml.cs:88–98) — 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.axaml.cs:283) → 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.axaml.cs:316). .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.axaml.cs:562 def; called at MW.axaml.cs:330) clears _findings / _findingMoveIndices / _warpGridDims, 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 (success or fail), so the stale pointer must be evicted pre-parse.
  7. 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) — pointer + count + HasMoves all captured identically to GenerateDemoToolpath.
  8. Failure branch: !pr.SucceededStatus("Load G-code failed: code=... <engine message>") (MW.axaml.cs:348–351); engine diagnostic read back via GetLastErrorMessage (EH/EngineSession.cs:201–207). Return without presenting.
  9. No-moves branch: pr.Succeeded && !pr.HasMovesStatus("Load G-code: '<name>' parsed but produced no moves (empty or non-G-code text).") (MW.axaml.cs:354–357). The tolerant text parser returns success for empty / commandless input; the UI distinguishes "parse error" from "parsed but no moves".
  10. Success: _modelLoaded = false (MW.axaml.cs:363) — direct G-code replaces any prior model; Apply must not regenerate a stale Benchy toolpath from a model that is no longer in the engine.
  11. Present: PresentCurrentToolpath(movesPtr, movesCount, isDemo:false, demoLabel) (MW.axaml.cs:465 def; called at MW.axaml.cs:366): same chain as the demo-gen path — _viewport.SetMoves / SetCamera / SetSimulationState / BuildBuffers, derive _maxLayer from actual moves (ResolveMaxLayerFromViewport MW.axaml.cs:538) and grow LayerSlider.Maximum past the legacy hard-coded 10, Analyze, LoadFindings, LoadWarpGrids, default to thermal overlay, repaint. The isDemo:false flag suppresses the demo status line; the picker-supplied demoLabel is the user-visible "G-code loaded (text 'name'): N moves" string instead.
  12. Final status: Status("G-code loaded: '<name>' N moves (text|binary). Overlay + findings ready.") (MW.axaml.cs:371).

Tested by: AppHeadlessSmokeTests.SlicerApp_LoadGcodeButton_* (AppHeadlessSmokeTests.cs): …_ScriptedPicker_Parses_Analyzes_Presents (text payload → status "G-code loaded", viewport + HasMoves > 0, _modelLoaded==false), …_CancelledPicker_StatusesCancellation (picker returns null → status "cancelled", HasMoves==false). EngineSessionTests.ParseGcode_AsciiText_ReturnsSuccessWithMoves, …_EmptyText_…, …_CommandlessText_…, …_AfterDispose_… cover the session-shape facade. ParseBgcode_InvalidBytes_ReturnsFailureWithParseError and …_AfterDispose_… cover the binary path. Text + bgcode extension dispatch is exercised in the headless app tests. NOT COVERED: the real Avalonia StorageProvider.OpenFilePickerAsync UI under Avalonia.Headless (the platform is not deterministic there); manual smoke only.

5. Generate demo toolpath

Goal: produce a demo toolpath (square perimeter + diagonal infill per layer, sized from mesh bounds — not a production slice) and paint it. Preconditions: _modelLoaded == true (workflow 2 step 8).

  1. Entry: RunGenerate() (MW.axaml.cs:268) — reached from the Load button (:205) or Apply (workflow 6).
  2. Options: _sliceParams.ToGcodeGenOptions() (MW.axaml.cs:275; SliceParams.cs:88–96).
  3. Facade: _session.GenerateDemoToolpath(options) (MW.axaml.cs:278) → EngineSession.GenerateDemoToolpath (EH/EngineSession.cs:253): packs NpsGcodeGenParams via AllocHGlobal+StructureToPtr (:271–290) → NpsAbi.GenerateGcode (:281; DllImport EH/NpsAbi.cs:333) → C export GenerateGcode (EC:2947) → nps::Engine::GenerateGcode (EC:1215): clears lastGeneratedGcode + shared lastGcodeMoves (EC:1216–1223), auto layer count clamp 2..12 when NumLayers ≤ 0 (EC:1236–1246), emits moves via appendMove (EC:1151), publishes text + shared move array (EC:1432, single return NPS_RESULT_SUCCESS;). Emitted header says "demo toolpath … NOT a production slice" (EC:1314–1317).
  4. Return: GetLastGcodeMoveCount (EH/EngineSession.cs:295EC:3031EC:1460) + GetLastGcodeMoves (:303EC:3037EC:1464) snapshot into LastMovesPointer/Count. Gen-failed branch unreachable today: Engine::GenerateGcode returns NPS_RESULT_SUCCESS unconditionally (EC:1433), so if (!gr.Succeeded) (MW.axaml.cs:279-283) is dead code; covered engine-side by EngineSessionTests.GenerateDemoToolpath_AfterBenchyLoad_ReturnsMoves (EH/EngineSession.Tests/EngineSessionTests.cs:166). Zero-moves branch reachable: status MW.axaml.cs:287.
  5. Viewport feed: _viewport.SetMoves(gr.MovesPointer, gr.MovesCount) (MW.axaml.cs:293) → ViewportController.SetMoves (SH/ViewportController.cs:240–246) → NpsAbi.ViewportSetGcodeMoves (exact export name; EH/NpsAbi.cs:395) → C export (EC:5274) → GcodeViewport::SetGcodeMoves (EC:5714) which copies the moves into ownedMoves_ (EC:5733–5736).
  6. SetLayerVisibility(_maxLayer) (MW.axaml.cs:294), SetCamera(new CameraState(0.6f, 0.4f, 0f, 0f, 1.0f)) (:295 — field order RotationXRadians, RotationZRadians, PanX, PanY, Zoom, SH/ViewportController.cs:72–77), SetSimulationState(false, movesCount-1) (:298–301), BuildBuffers() (:303NpsAbi.ViewportBuildBuffersEC:5293GcodeViewport::BuildRenderBuffers EC:5861).
  7. Paint (return path): RenderViewport() (MW.axaml.cs:614–655): clears the canvas (:623), reads toolbar toggles (:636–637), calls _toolpathPainter.RenderTo(canvas, vp, w, h, _maxLayer, showTravels, showExtrudes, maxMoveIndexExclusive: vp.MoveCount, _findingMoveIndices) (:639–648) → GcodeToolpathPainter.RenderTo (SH/Controls/GcodeToolpathPainter.cs:85–227): per move pair NpsAbi.ProjectOrbitXY (:160–163EC:5400), heatmap when overlay active (:171–184 via NpsAbi.ViewportGetHeatmapColorEC:5381EC:6186), green extrude / grey travel otherwise (:188–196), one Line per segment (:199–210), yellow 8×8 ellipses at finding moves (:212–225). Status coords line (MW.axaml.cs:650–654 via TryGetLastMovePosition, SH/ViewportController.cs:694–710).

Tested by: engine gen Catch2 engine_tests.cpp:2538/:2637/:2700/:2780; xUnit EngineBridgeTests.cs:744, EngineSessionTests.GenerateDemoToolpath_AfterBenchyLoad_ReturnsMoves (EngineSessionTests.cs:166); viewport copy Catch2 engine_tests.cpp:4164/:4275, xUnit ViewportControllerTests.cs:138; painter no-op guards GcodeToolpathPainterTests.cs:19/:42. NOT COVERED: painter happy-path drawing (lines/markers on a real canvas). COVERED (nps-oro.3): the Apply click → RunGenerate chain via the Benchy-success path AppHeadlessSmokeTests.cs:572 (ApplySliceButton.RaiseEvent(...) after load → "Slicer parity loaded...") and the Apply-before-load gate :531 ("Load a model first...", _modelLoaded==false).

6. Analyze & findings browse/filter/select

Goal: run the 6-analyzer suite over the shared moves; browse, filter, and select findings with detail + viewport highlight. Preconditions: workflow 3 produced moves.

  1. Analyze: _session.Analyze(new MaterialProfileOptions("PLA", 210, 60)) (MW.axaml.cs:306) → EngineSession.Analyze (EH/EngineSession.cs:332): builds NpsMaterialProfile with only Type/MeltTemp/GlassTransition set (:337–347), AllocHGlobal+StructureToPtr (:353–356) → NpsAbi.AnalyzeGcode (:361; DllImport EH/NpsAbi.cs:443) → C export (EC:5421–5459): flat 108-byte POD → grouped MaterialProfile (EC:5427–5454) → nps::Engine::AnalyzeGcode (EC:1479, void) → AnalysisManager::analyze (EC:4909): clears prior findings (EC:4911), runs six analyzers — Thermal (EC:4320), Structural (EC:4650), Motion (EC:4803), Hole (EC:6606), Retraction (EC:6839), Flow (EC:6923). Analyze-failed branch unreachable today: EngineSession.Analyze unconditionally returns AnalyzeResult.Success() (EH/EngineSession.cs:368) because the C entry point is void; if (!ar.Succeeded) (MW.axaml.cs:307-311) is dead code on the current build. Covered engine-side by EngineSessionTests.Findings_AreEmptyBeforeAnalyze_AndPopulatedAfter (EngineSessionTests.cs:185).
  2. Load findings: LoadFindings() (MW.axaml.cs:313:383–402) rewires panel.FindingSelected (unsubscribe-then-subscribe, :390–391) → UpdateFindingsFromSession() (:405–427) → FindingFormatter.ProjectFromSession(_session, gridLookup, moveIndices) (:417–422): 16-byte summary rows first via session.GetFindingsSummary() (EH/EngineSession.cs:376–401GetAnalysisFindingsSummaryCount/GetAnalysisFindingsSummary, EC:5498/EC:5501EC:1513/EC:1520, findingId = source index EC:1531–1534), then a per-row 300-byte detail prefetch via GetFindingDetail (EH/EngineSession.cs:407–413EC:5530EC:1538); stamps FindingItems, fills _findings + _findingMoveIndices, panel.SetFindings(viewItems) (MW.axaml.cs:426FindingsPanel.axaml.cs:94–112 — also keys the prefetch cache _byFindingId, :100–107).
  3. Show the tab: click TabFindings (MainWindowShell.axaml:89) → Activity.HandleClickTabClicked("findings") (ActivityBarHost.cs:58) → app subscription MW.axaml.cs:74OnActivityTabClicked (:106–115) → Shell.Panels.Show("findings", onShown: LoadFindings)PanelHost.Show flips IsVisible (SH/Controls/PanelHost.cs:73–87).
  4. Filter (UI-only, no engine): chips in FindingsPanel.axaml:38–64OnChipAllClick (FindingsPanel.axaml.cs:268–274) / OnChipSeverityClick (:276–303) / OnChipCategoryClick (:305–322) → immutable FindingsFilterState (FindingsFilterState.cs:95–135, set semantics) → RebuildList single ItemsSource reassign (FindingsPanel.axaml.cs:342–351). No search TextBox exists.
  5. Select a row: FindingsList (FindingsPanel.axaml:70) → OnFindingSelected (FindingsPanel.axaml.cs:328–332) → hop 1 FindingSelected(item) (:165) + panel UpdateDetail (:142–159) → hop 2 app OnFindingSelected (MW.axaml.cs:438–498): prefetch-cache hit via panel.TryGetCachedItem (:448–451FindingsPanel.axaml.cs:123–133, no engine call), else keep stamped row (:452–457), else one-shot FindingFormatter.RefreshDetail (:458–463); genuine miss → status + keep row visible (:465–472).
  6. Navigate + highlight: _maxLayer = Math.Max(_maxLayer, stamped.Layer + 1) (MW.axaml.cs:478–481); SetLayerVisibility (:485); _viewport.SetSimulationState(true, stamped.MoveIndex) (:486–491NpsAbi.ViewportSetSimulationStateEC:5334) — highlight only, no playback mirror (comment :436); BuildBuffers (:493); UpdateLayerLabel() (:496); RenderViewport() (:497) — painter draws the full path plus yellow markers at _findingMoveIndices.

Tested by: analyzers Catch2 engine_tests.cpp:1550/:1570/:2896/:2915/:2940; facade EngineSessionTests.cs:185/:211/:233; summary↔detail contract EngineBridgeTests.TestAnalysisFindingsSummary_RoundTripsThroughDetail (EngineBridgeTests.cs:1468) + :1548; panel FindingsPanelTests.cs:43/:73/:94/:130/:156/:189/:218/:248. NOT COVERED: painter marker rendering. COVERED (nps-oro.3): app-level OnFindingSelected hop-2 (cache tiers, layer bump, viewport.SetSimulationState) via AppHeadlessSmokeTests.cs:727 — drives FindingsPanel.FindingSelected for a pinned move (layer=3, moveIndex=99) and asserts _maxLayer>=4 + viewport.SimMoveIndex==99. The OnFindingSelected RenderViewport call overwrites StatusText after the "Finding ..." line, so the sim-index mirror is the observable contract.

7. Inspect overlays (None=motion-type, Thermal/Heat, Warp Risk)

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

  1. Click a radio (OverlayPanel.axaml:10–14) → OnRadioButtonChecked (OverlayPanel.axaml.cs:98–108) → sets SelectedMode styled prop (:102–105) → hop 1 ModeChanged(modeId) (:66).
  2. Hop 2: app subscription MW.axaml.cs:99OnOverlayModeChanged (:526–530): _currentOverlayMode = modeId (:528); ApplyCurrentOverlay() (:529:533–575).
  3. Facade: _viewport.ApplyOverlay(_session, mode, false) (MW.axaml.cs:541) → ViewportController.ApplyOverlay(session, mode, invert) (SH/ViewportController.cs:564–640): per move session.GetOverlayValue(mode, m.layer, i) (:588EH/EngineSession.cs:486–490NpsAbi.GetOverlayValueEC:5560Engine::GetLastOverlayValue EC:1550AnalysisManager::getOverlayValue EC:4992); stats exclude zeros except Thermal (:593); IsActive = mode != MotionType (:576).
  4. One-shot push: NpsAbi.ViewportApplyOverlay (SH/ViewportController.cs:547–554; DllImport EH/NpsAbi.cs:432–435) → C export (EC:5363–5376) → GcodeViewport::ApplyOverlay (EC:5829).
  5. Warp Risk extra: SetWarpParams(true, 5.0f) (SH/ViewportController.cs:627–629) → NpsAbi.ViewportSetWarpParamsEC:5344; other modes SetWarpParams(false, 0) (:633).
  6. Return: _viewport.BuildBuffers() (MW.axaml.cs:542); legend LegendLow/High, StatsText, IsOverlayActive (:547–563); RenderViewport() (:566) recolors via NpsAbi.ViewportGetHeatmapColor (GcodeToolpathPainter.cs:171–184EC:5381 → blue→red ramp EC:6186); status "Overlay '{mode}' applied (Slicer parity)." / "Overlay cleared…" (:567–574).
  7. "None" semantics: the None radio maps to mode id "motion-type" (OverlayPanel.axaml:10 Tag) — an active engine mode, not a no-op; the controller marks it inactive and the painter draws flat type colors. After every Load/Apply the app forces "thermal" (MW.axaml.cs:322–327).

Tested by: ViewportControllerTests.ApplyOverlay_FromSession_ComputesStatsAndAppliesOverlay (ViewportControllerTests.cs:460), …_NonThermal_…SampleCountExcludesZeros (:509); aggregate parity Catch2 engine_tests.cpp:4065/:4107; heatmap EngineBridgeTests.cs:1642/:1661/:1675/:1686, Catch2 engine_tests.cpp:2047. COVERED (nps-oro.3): the radio → ModeChanged → app chain incl. "None" → motion-type at UI level via AppHeadlessSmokeTests.cs:657 — 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'". Slicer twin of AppHeadlessSmokeTests.cs:998 (Simulator). NOT COVERED: legend/stats text; warp-risk SetWarpParams path.

8. Slice params edit + ApplySliceButton + WarpGridLabel

Goal: change generation parameters, regenerate the demo toolpath, refresh the warp-grid summary. Preconditions: _modelLoaded == true for Apply (MW.axaml.cs:217–221).

  1. Edit a control (MW.axaml:34–56) → TwoWay binding into SliceParams (SliceParams.cs:50–82, INPC SetField :107–116). UI-only — nothing leaves the process; range enforcement is control min/max only.
  2. Click "Apply / Regenerate" (MW.axaml:61–64) → OnApplySliceClick (MW.axaml.cs:209–228): session guard → status "Engine session not ready." (:211–215); _modelLoaded guard → status "Load a model first (Load + Gen + Analyze)." (:217–221).
  3. HarvestSliceParamsFromUi() (MW.axaml.cs:226:232–263): re-reads each NumericUpDown/CheckBox via FindControl into _sliceParams (belt-and-braces against decimal/float binding lag).
  4. RunGenerate() (MW.axaml.cs:227) — the full workflow-¾/5 chain with the new options (gen → viewport feed → analyze → findings → warp grids → forced thermal → repaint → status echo with LH=.. FR=.. FS=.. NP=.. N=.., :332–334).
  5. Warp grid: LoadWarpGrids() (MW.axaml.cs:314:338–380): loops layers 0..5 calling _session.GetWarpGrid(l) (:353EH/EngineSession.cs:421–443NpsAbi.GetWarpGridInfo (:426EC:5594EC:1572AnalysisManager::getWarpGridInfo EC:5048, NO_DATA when no grid) + NpsAbi.GetWarpGridRisk (:433EC:5600EC:1578EC:5072, engine-owned floats copied via Marshal.Copy EH/EngineSession.cs:437–440); caches dims into _warpGridDims (:358) for the findings grid[x,y] lookup; risk floats deliberately discarded (:367).
  6. UI: WarpGridLabel.Text = "WarpGrid: layers={N} e.g. L{l} {W}x{H} res={R:F1}" or "WarpGrid: none populated (check thermal)" (MW.axaml.cs:377–379).

Tested by: VM SliceParamsTests.cs:17/:30/:49/:64; warp ABI Catch2 engine_tests.cpp:1674 (shape only); EngineSessionTests.GetWarpGrid_BeforeAnalyze_ReturnsNull (EngineSessionTests.cs:241). NOT COVERED: the Apply click chain end-to-end; HarvestSliceParamsFromUi; WarpGridLabel text; warp-grid risk values (never content-asserted).

9. Camera & navigation (orbit/pan, presets, Zoom to Fit — no wheel zoom)

Goal: orbit/pan/zoom the toolpath view. Preconditions: _viewport valid; visible change only when moves exist (RenderViewport early-out MW.axaml.cs:618).

  1. Press on ViewportHost (MW.axaml:72) → shell-attached OnViewportPointerPressed (MainWindowShell.axaml.cs:278–281, attached :256–276) → hop 1 ViewportPointerPressed (:35) → app sub MW.axaml.cs:76OnViewportPointer (:577–581): _isDragging=true, cache pointer.
  2. Drag → ViewportPointerMoved (MainWindowShell.axaml.cs:36) → OnViewportPointerMove (MW.axaml.cs:583–608): left button → orbit (RotationZRadians += dx*0.01, RotationXRadians clamp ±1.5, :595–600); other button → pan scaled 0.5 / max(0.1, Zoom) (:601–605); _viewport.SetCamera(cam) (:606SH/ViewportController.cs:262–273NpsAbi.ViewportSetCamera (EH/NpsAbi.cs:401) → EC:5286GcodeViewport::SetCamera EC:5761).
  3. Return: RenderViewport() (MW.axaml.cs:607) — the painter re-projects every move via NpsAbi.ProjectOrbitXY (the engine-side camera feeds only ComputeMVP, which nothing calls).
  4. Release → ViewportPointerReleased (MainWindowShell.axaml.cs:37) → OnViewportPointerRelease (MW.axaml.cs:610).
  5. Presets (toolbar, code-built ViewportToolbar.cs:53–58): hop 1 events :18–23 (raised :70–75) → app subs MW.axaml.cs:80–85SetCameraPreset (SH/ViewportController.cs:294–328 — Reset 0.6/0.4/zoom 1.0; Isometric 0.615/0.785; Front 1.5/0; Top 0/0; Right 1.5/−1.571; pan zeroed) or ZoomFitCamera() (MW.axaml.cs:657–679ComputeZoomFit(w,h) SH/ViewportController.cs:432–482, ~90% fill via NpsAbi.ProjectOrbitXY :456) → SetCamera + RenderViewport.
  6. No wheel zoom — zero PointerWheelChanged handlers in app, shell, or controller. Zoom changes only via Reset preset and Zoom to Fit.

Tested by: projection Catch2 engine_tests.cpp:2078, xUnit EngineBridgeTests.cs:1697/:1741/:1753; camera ViewportControllerTests.cs:84/:253/:305; ZoomFit math ViewportControllerTests.cs:376/:415/:439; engine MVP Catch2 engine_tests.cpp:1976. NOT COVERED: the pointer-drag handlers and toolbar → preset/ZoomFit wiring (ViewportToolbar has zero tests); engine-side ViewportSetCamera visible effect.

10. Keyboard (Ctrl+L works; P/Left/Right report playback unavailable)

Goal: keyboard shortcuts. Preconditions: window focused (Focusable = true; Focus(), MW.axaml.cs:102–103).

  1. Key press → window KeyDown="OnKeyDown" (MW.axaml:8) → OnKeyDown (MW.axaml.cs:136–151) → ShellKeybindings.Handle(e, actions) (SH/Controls/ShellKeybindings.cs:82–113; Actions readonly struct :58–71).
  2. P (ShellKeybindings.cs:84–89) → TogglePlayStatus("Playback unavailable in Slicer.") (MW.axaml.cs:142).
  3. Right (:98–103) / Left (:105–110) → same status (:143–144).
  4. Ctrl+L (:91–96) → ToggleInspectorShell.IsRightExpanded = !Shell.IsRightExpanded + status (:145–149).
  5. Every handled key sets e.Handled = true; others fall through (ShellKeybindings.cs:112). No other keys exist — bead nps-ai2.4 simplified the overlay tab tooltip to "Overlays" (MainWindowShell.axaml:86).

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-level OnKeyDown → status/Ctrl+L wiring.

11. Chrome: collapse buttons, GridSplitters, activity tabs, status bar

Goal: layout chrome — collapse/expand sidebars, resize, switch inspector tabs, read status. Preconditions: workflow 1 done.

  1. Collapse left: "◀" (MainWindowShell.axaml:44) → OnCollapseLeftClick (MainWindowShell.axaml.cs:294–297) → IsLeftExpanded=falseUpdateLeftLayout(false) (:324–380): caches pixel width _lastLeftWidth (:359–362), hides splitter + expanded content, shows collapsed strip.
  2. Expand left: LeftCollapsedContent "▶" (MainWindowShell.axaml:52) → OnExpandLeftClick (:299–302) → restores _lastLeftWidth (:354).
  3. Collapse/expand right: "▶" (MainWindowShell.axaml:82) → OnCollapseRightClick (:304–307) → UpdateRightLayout(false) (:382–438, caches _lastRightWidth :417–420); RightCollapsedContent "◀" (:105) → OnExpandRightClick (:309–312) → restore (:412).
  4. GridSplitters (MainWindowShell.axaml:61/:70, width 4): Avalonia built-in drag resizes column pairs 0/2 and 2/4; the dragged width is what the next collapse caches. UI-only, no app subscription.
  5. Activity tabs: TabOverlays/TabFindings (MainWindowShell.axaml:86/:89) wired in the shell ctor to Activity.HandleClick (MainWindowShell.axaml.cs:148–155) → hop 1 TabClicked(tag) (ActivityBarHost.cs:58, :80–86) → hop 2 app sub MW.axaml.cs:74OnActivityTabClicked (:106–115) → Shell.Panels.Show(key, onShown)PanelHost.Show flips IsVisible (SH/Controls/PanelHost.cs:73–87); "findings" triggers LoadFindings() (MW.axaml.cs:110–113). TabPlayback is hidden and unregistered (MainWindowShell.axaml.cs:172–188). Dead twin removed by bead nps-ai2.4: the previous OnActivityTabClick(object?, RoutedEventArgs) (MW.axaml.cs:504–507) had no callers and no XAML hookup; only OnActivityTabClicked (the live Shell.Activity.TabClicked subscriber) remains.
  6. Status bar: StatusBar TextBlock (MW.axaml:95–98) bound {Binding StatusText, ElementName=Shell} → styled property (MainWindowShell.axaml.cs:90–97). Every app Status() call writes both Shell.StatusText (MW.axaml.cs:684) and StatusSink.Emit (:685SH/StatusSink.cs:57–62, prefix "[SlicerUI]" :36, to both stdout and stderr) — two independent paths.

Tested by: AppHeadlessSmokeTests.MainWindowShell_Collapse_Width_Memory_Smoke (AppHeadlessSmokeTests.cs:152, incl. playback-tab hide); ShellChromeTests.ActivityBarHost_FiresTabClicked_ForRegisteredKey (:146), …_NoTabClicked_When_Button_Tag_Unregistered (:130), PanelHost_Show_FlipsExactlyOneVisible_And_FiresOnShown (:164), …_UnknownKey_NoOp (:188). NOT COVERED: GridSplitter drag behavior; StatusSink.Emit dual-write (disabled smoke script only); Ctrl+L → IsRightExpanded wiring in the app.

See also