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:163–167) → status bar via Shell.StatusText (:667) + stderr/stdout mirror via StatusSink.Emit (:668) → return.
  5. Facade load: LoadResult load = _session.LoadModel(benchy) (MW.axaml.cs:169) → 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:170–174), message read back via GetLastErrorMessage (EH/EngineSession.cs:214–216EC:2855EC:971); return.
  8. Success: _modelLoaded = true (MW.axaml.cs:176); status line (:181); MeshInfoLabel.Text = "Plates: … | Pal: … | V=16562 T=33097 | millimeter" (:182–186); then RunGenerate() (:188) — 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:181-186)"
  H->>F: "GenerateDemoToolpath(options) (MW.axaml.cs:261)"
  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:276-286)"
  V->>A: "ViewportSetGcodeMoves (ViewportController.cs:245)"
  A->>E: "viewport copies moves (EC:5274 -> EC:5714)"
  H->>F: "Analyze(PLA 210/60) (MW.axaml.cs:289)"
  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:296-297)"
  H->>V: "force thermal overlay + BuildBuffers (MW.axaml.cs:305-312)"
  V->>A: "ViewportApplyOverlay (ViewportController.cs:547)"
  A->>E: "EC:5363"
  H->>H: "RenderViewport + final status (MW.axaml.cs:313-317)"

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: OnLoadAnalyzeClick itself (existence-only in AppHeadlessSmokeTests.cs:58); the File.Exists gate message; the exact status/MeshInfoLabel strings; the FixtureLocator walk permutations.

3. 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:251) — reached from the Load button (:188) or Apply (workflow 6).
  2. Options: _sliceParams.ToGcodeGenOptions() (MW.axaml.cs:258; SliceParams.cs:88–96).
  3. Facade: _session.GenerateDemoToolpath(options) (MW.axaml.cs:261) → 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). 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. Failure → status MW.axaml.cs:264; zero moves → status :270.
  5. Viewport feed: _viewport.SetMoves(gr.MovesPointer, gr.MovesCount) (MW.axaml.cs:276) → 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:277), SetCamera(new CameraState(0.6f, 0.4f, 0f, 0f, 1.0f)) (:278 — field order RotationXRadians, RotationZRadians, PanX, PanY, Zoom, SH/ViewportController.cs:72–77), SetSimulationState(false, movesCount-1) (:281–284), BuildBuffers() (:286NpsAbi.ViewportBuildBuffersEC:5293GcodeViewport::BuildRenderBuffers EC:5861).
  7. Paint (return path): RenderViewport() (MW.axaml.cs:597–638): clears the canvas (:606), reads toolbar toggles (:619–620), calls _toolpathPainter.RenderTo(canvas, vp, w, h, _maxLayer, showTravels, showExtrudes, maxMoveIndexExclusive: vp.MoveCount, _findingMoveIndices) (:622–631) → 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:633–637 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: the full click → RunGenerate chain; painter happy-path drawing (lines/markers on a real canvas).

4. 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:289) → 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) → 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).
  2. Load findings: LoadFindings() (MW.axaml.cs:296:366–385) rewires panel.FindingSelected (unsubscribe-then-subscribe, :373–374) → UpdateFindingsFromSession() (:388–410) → FindingFormatter.ProjectFromSession(_session, gridLookup, moveIndices) (:400–405): 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:409FindingsPanel.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:421–481): prefetch-cache hit via panel.TryGetCachedItem (:431–434FindingsPanel.axaml.cs:123–133, no engine call), else keep stamped row (:435–440), else one-shot FindingFormatter.RefreshDetail (:441–446); genuine miss → status + keep row visible (:448–455).
  6. Navigate + highlight: _maxLayer = Math.Max(_maxLayer, stamped.Layer + 1) (MW.axaml.cs:461–464); SetLayerVisibility (:468); _viewport.SetSimulationState(true, stamped.MoveIndex) (:469–474NpsAbi.ViewportSetSimulationStateEC:5334) — highlight only, no playback mirror (comment :419); BuildBuffers (:476); UpdateLayerLabel() (:479); RenderViewport() (:480) — 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: FindingFormatter.ProjectFromSession itself; the app-level LoadFindings rewiring and tab-shown reload; app OnFindingSelected hop-2 (cache tiers, layer bump, highlight); painter marker rendering.

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

  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 (:509–513): _currentOverlayMode = modeId (:511); ApplyCurrentOverlay() (:512:516–558).
  3. Facade: _viewport.ApplyOverlay(_session, mode, false) (MW.axaml.cs:524) → 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:525); legend LegendLow/High, StatsText, IsOverlayActive (:530–546); RenderViewport() (:549) recolors via NpsAbi.ViewportGetHeatmapColor (GcodeToolpathPainter.cs:171–184EC:5381 → blue→red ramp EC:6186); status "Overlay '{mode}' applied (Slicer parity)." / "Overlay cleared…" (:550–557).
  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:305–310).

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. NOT COVERED: the radio → ModeChanged → app chain incl. the None→motion-type mapping at UI level; legend/stats text; warp-risk auto SetWarpParams(true, 5) path.

6. 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:200–204).

  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:192–211): session guard → status "Engine session not ready." (:194–198); _modelLoaded guard → status "Load a model first (Load + Gen + Analyze)." (:200–204).
  3. HarvestSliceParamsFromUi() (MW.axaml.cs:209:215–246): re-reads each NumericUpDown/CheckBox via FindControl into _sliceParams (belt-and-braces against decimal/float binding lag).
  4. RunGenerate() (MW.axaml.cs:210) — 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=.., :315–317).
  5. Warp grid: LoadWarpGrids() (MW.axaml.cs:297:321–363): loops layers 0..5 calling _session.GetWarpGrid(l) (:336EH/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 (:341) for the findings grid[x,y] lookup; risk floats deliberately discarded (:350).
  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:360–362).

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

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

  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 (:560–564): _isDragging=true, cache pointer.
  2. Drag → ViewportPointerMoved (MainWindowShell.axaml.cs:36) → OnViewportPointerMove (MW.axaml.cs:566–591): left button → orbit (RotationZRadians += dx*0.01, RotationXRadians clamp ±1.5, :578–583); other button → pan scaled 0.5 / max(0.1, Zoom) (:584–588); _viewport.SetCamera(cam) (:589SH/ViewportController.cs:262–273NpsAbi.ViewportSetCamera (EH/NpsAbi.cs:401) → EC:5286GcodeViewport::SetCamera EC:5761).
  3. Return: RenderViewport() (MW.axaml.cs:590) — 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:593).
  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:640–662ComputeZoomFit(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.

8. 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 — the overlay tab tooltip "Overlays (T/S/C/W)" (MainWindowShell.axaml:86) is misleading.

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.

9. 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 code: OnActivityTabClick(object?, RoutedEventArgs) (MW.axaml.cs:504–507) has no callers.
  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:667) and StatusSink.Emit (:668SH/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