Skip to content

05 — Shell and apps (L4 / L5)

L4 — src/Nps.Shell

Shared Avalonia-adjacent helpers. No [DllImport] (the only mentions in SH/Controls/MainWindowShell.axaml.cs:6 are comments). Calls only Nps.EngineHost (NpsAbi, handles via controllers).

Type Path Role
FixtureLocator FixtureLocator.cs Resolve primary 4-colors+Benchy+AMS+test-v2.3mf from base dir + parents
StatusSink StatusSink.cs Prefix + dual-write status lines to stdout AND stderr (StatusSink.cs:57-62)
ShellTrace ShellTrace.cs InstallStderrSink() (:45) routes Avalonia LogToTrace warnings to stderr
NonClosingTextWriter NonClosingTextWriter.cs Stream wrapper that does not close shared stderr
ViewportController ViewportController.cs Managed façade over SafeGcodeViewportHandle + viewport ABI
MainWindowShell Controls/MainWindowShell.axaml Shared outer chrome (sidebars, title, status, activity tabs)
ActivityBarHost Controls/ActivityBarHost.cs Activity tab click routing — event Action<string> TabClicked (:58)
PanelHost Controls/PanelHost.cs Panel registry — bool Show(string key, Action<string>? onShown) (:73)
FindingItem Controls/FindingItem.cs Finding list row model
FindingFormatter Controls/FindingFormatter.cs Summary+detail → styled FindingItem projector (ProjectFromSession :175, RefreshDetail :206, FromSummaryAndDetail :222)
FindingsFilterState Controls/FindingsFilterState.cs Immutable severity/category chip state (Matches :95)
FindingsPanel Controls/FindingsPanel.axaml(.cs) Chips + list + detail pane — FindingSelected (:165)
OverlayPanel Controls/OverlayPanel.axaml(.cs) Overlay radios — ModeChanged (:66)
PlaybackPanel Controls/PlaybackPanel.axaml(.cs) Transport + scrubber — events at :91-121
ViewportToolbar Controls/ViewportToolbar.cs CODE-BUILT, no .axaml — 10 buttons, events at :18-27
ShellKeybindings Controls/ShellKeybindings.cs Exactly 4 keys (see below)
TimelineMarkerHelper Controls/TimelineMarkerHelper.cs Scrubber marker math — Fraction (:58), BuildMarkers (:87)
GcodeToolpathPainter Controls/GcodeToolpathPainter.cs Toolpath ribbon painter (RenderTo :85)
MeshCanvasPainter Controls/MeshCanvasPainter.cs Simulator background mesh painter (RenderTo :112)

MainWindowShell region composition

3-row × 5-column MainGrid (RowDefinitions="Auto,*,Auto", MainWindowShell.axaml:13):

Region Slot file:line
Title bar (TitleText binding) Row 0, ColSpan 5 MainWindowShell.axaml:28-34
Left sidebar (LeftExpandedContent :43, expand/collapse) Row 1, Col 0 :37-58
Left GridSplitter Row 1, Col 1 :61-63
Viewport host (ViewportPresenter) Row 1, Col 2 (*) :66-67
Right GridSplitter Row 1, Col 3 :70-72
Right sidebar (RightSidebar :75, RightExpandedContent :79): activity bar (TabOverlays :86 / TabFindings :89 / TabPlayback :92) + InspectorPresenter :100 Row 1, Col 4 :75-111
Status bar (StatusRegion presenter) Row 2, ColSpan 5 :114

Shell-owned routing objects: Activity (MainWindowShell.axaml.cs:40) and Panels (:41). Pointer events on the viewport host are re-raised verbatim as ViewportPointerPressed/Moved/Released (:35-37) — the shell does no camera math. IsPlaybackTabVisible (default true, :123-131) is set to False by Slicer, which hides and never registers the playback tab.

ViewportController public API

All methods call NpsAbi exports; the shell never declares ABI itself.

Method / prop file:line NpsAbi export
ViewportController() :194 — (empty ctor; call Initialize)
CreateAndInitialize() / Initialize() ViewportController.cs:203 / :215 CreateGcodeViewport (NpsAbi.cs:389)
IsValid :133
SetMoves(ptr, count) :240 ViewportSetGcodeMoves (:395) — exact export name
SetLayerVisibility(maxLayer) :252 ViewportSetLayerVisibility (:398)
SetCamera(...) / SetCameraPreset(...) :262 / :294 ViewportSetCamera (:401)
ComputeZoomFit(...) (2 overloads) :380 / :432 projection math via ProjectOrbitXY (:500)
SetSimulationState(simulating, index) :489 ViewportSetSimulationState (:420)
ApplyOverlay(mode, values, …) / ApplyOverlay(session, mode) :517 / :564 ViewportApplyOverlay (:432) — returns OverlayStats
OverlayStats (readonly record struct: IsActive, Mode, Min, Max, Mean, SampleCount, MoveCount) :99-106 — (managed return type of ApplyOverlay; session overload excludes zeros)
SetWarpParams(active, scale) :646 ViewportSetWarpParams (:424)
BuildBuffers() :657 ViewportBuildBuffers (:404)
MovesSpan (engine-pointer mirror, unsafe) :672 — (reads the pointer set by SetMoves)
TryGetLastMovePosition(out x, y, z) :694 — (reads MovesSpan; Slicer status line)
Camera :139 UI binding (mirror of last SetCamera)
CurrentOverlayValues / LastOverlayMin / LastOverlayMax / LastOverlayMean :167 / :172 / :177 / :182 — (mirror of last ApplyOverlay; read by the painters / Simulator render)
SimMoveIndex / MaxLayer / OverlayMode / MoveCount :150 / :156 / :162 / :188 UI binding
Dispose() :717 DestroyGcodeViewport via SafeGcodeViewportHandle

CameraState field order is RotationXRadians, RotationZRadians, PanX, PanY, Zoom (ViewportController.cs:72-77). Overlay-mode wire ids come from NpsAbi.OverlayModeId.*; the default is motion-type (:162).

Painters (stateless sinks)

Both painters append to the canvas and never clear it — the app's RenderViewport clears and re-invokes them after any camera / layer / overlay / sim-index change.

Painter Draws Key facts
GcodeToolpathPainter.RenderTo (GcodeToolpathPainter.cs:85) One Line per move pair from MovesSpan; heatmap colors via NpsAbi.ViewportGetHeatmapColor when overlay active; yellow 8×8 ellipse markers at finding moves (:212-225) Painter-side layer filter m1.layer > maxLayer (:144) is independent of the engine bake clamp; zero-alloc scratch buffers (:54-59)
MeshCanvasPainter.RenderTo (MeshCanvasPainter.cs:112) One translucent Polygon per mesh triangle from the MeshController snapshot; palette colors, fill alpha 30% (:218-223) Subsamples at MaxTrianglesPerPlate = 2000 (:87, stride :147); per-vertex NpsAbi.ProjectOrbitXY (:174-179); ignores camera pan

Status paths: StatusSink vs StatusText

Two fully separate paths, written independently by each app's Status() helper (SA/MainWindow.axaml.cs:665-669 / SI/MainWindow.axaml.cs:851-858):

  • Visible status barMainWindowShell.StatusText styled property (MainWindowShell.axaml.cs:90-97), presented in the StatusRegion.
  • Process outputStatusSink.Emit (StatusSink.cs:57-62) writes "[SlicerUI] {message}" (Prefix :36) to both stdout and stderr; the smoke hook greps this stream.

L5 — Apps

Both apps: thin hosts. Pattern:

  1. Program.Main → ABI gate + CLI preflight → Avalonia bootstrap
  2. MainWindow ctor → shell chrome + EngineSession.CreateAndInitialize() + ViewportController.CreateAndInitialize() (Simulator also constructs MeshController over the session — SI/MainWindow.axaml.cs:109 — and a MeshCanvasPainter)
  3. Button Load + Gen + Analyze → primary user path

App boot differences

Boot step SlicerApp SimulatorApp
ABI gate Facade: EngineSession.EnsureAbiMatchesHost() (SA/Program.cs:22) Raw-ABI smoke check: direct NpsAbi.GetAbiVersion() (SI/Program.cs:22-28) + raw SafeEngineHandle + raw LoadModelFromFile probe (:31-83)
CLI preflight gen GenerateDemoToolpath(..., NumLayers: 0) (SA/Program.cs:59-64) none (load probe only)
Playback at boot IsPlaybackTabVisible="False" (SA/MainWindow.axaml:20); no PlaybackPanel, no timer Playback tab registered; DispatcherTimer 80 ms (SI/MainWindow.axaml.cs:105-106); 7 panel event groups wired (:121-137)
Mesh controller none new MeshController(_session) (:109)

Shared primary path (OnLoadAnalyzeClick)

Code: SA/MainWindow.axaml.cs:155 / SI/MainWindow.axaml.cs:205.

FixtureLocator.FindPrimaryBenchy3Mf()           // FixtureLocator.cs:60
  → EngineSession.LoadModel(path)               // EngineSession.cs:202
  → [Simulator] MeshController.Reload()         // MeshController.cs:131 — snapshot plates + palette
  → present plates/palette/counts in Status + MeshInfoLabel
  → GenerateDemoToolpath(GcodeGenOptions)       // EngineSession.cs:253 — DEMO TOOLPATH
  → ViewportController.SetMoves(LastMovesPointer, LastMovesCount)   // ViewportController.cs:240
  → SetLayerVisibility + SetCamera + BuildBuffers
  → Analyze(MaterialProfileOptions("PLA", 210, 60))                 // EngineSession.cs:332
  → LoadFindings()                              // 16B summary rows first
  → force thermal overlay + ApplyCurrentOverlay()
  → RenderViewport()                            // Simulator: MeshCanvasPainter underlay first (SI:732)

Status text explicitly says DEMO TOOLPATH — not a production slice (SA:315-317 / SI:313).

FixtureLocator.FindPrimaryBenchy3Mf resolution order

SH/FixtureLocator.cs:60-90; target PrimaryBenchyFileName = "4-colors+Benchy+AMS+test-v2.3mf" (:41), the repo-root golden fixture (1 plate / 16562 verts / 33097 tris / 4 palette colors):

  1. Start at AppContext.BaseDirectory (:62); walk up to MaxWalkDepth = 12 levels (:51, :63):
  2. probe dir + filename (:65-69) — return on File.Exists;
  3. probe the level's parent directory (:76-80) — return on exists;
  4. dir = parent (:82).
  5. Fallback: BaseDirectory + "../../../../../../" + filename (:89) — the src/<App>/bin/Debug/net8.0/ → repo-root hop; may not exist, callers must check File.Exists (SA:163 / SI:213).

Slicer vs Simulator

Concern SlicerApp SimulatorApp
Toolpath + overlays + findings yes yes
Slice params UI + ApplySliceButton yesSliceParams VM, harvest at SA:215-246, gen options from it (SA:258) none — hardcoded GcodeGenOptions(false, 0.2f, 1.0f, 1800, 5) (SI:245-250)
WarpGridLabel (warp grid dims) yes (SA/MainWindow.axaml:67, populated SA:321-363) absent
Mesh polygon underlay no — toolpath only yesMeshCanvasPainter before toolpath lines (SI:732)
Playback (tab, timer, scrubber, auto-pause) no — IsPlaybackTabVisible="False"; P/Left/Right only status "Playback unavailable in Slicer." (SA:142-144) yes — full transport (SI:510-658)
Finding click behavior highlight only via SetSimulationState (SA:469-474) seek + scrubber mirror pp.SimIndex (SI:423-427)
Toolpath draw extent full path (maxMoveIndexExclusive: vp.MoveCount, SA:630) capped at playhead (SI:742)
Load button label "Load + Gen + Analyze (Benchy primary)" "Load + Gen + Analyze (Benchy)"
Status copy "Slicer shell ready." "UI shell ready (activity bar, inspector tabs, viewport canvas, playback, findings)."

Shared identically: LayerSlider + LayerLabel, all 10 ViewportToolbar event lambdas, OverlayPanel/FindingsPanel wiring + findings cache contract, camera orbit/pan handlers, Ctrl+L inspector toggle, thermal overlay forced after each load, StatusSink dual-write, shell chrome.

Neither app declares DllImport. No MVVM / no ICommand / no RelayCommand anywhere — all interaction is code-behind events. The per-app OnActivityTabClick(object?, RoutedEventArgs) handlers (SA:504 / SI:454) are dead code — tab clicks route through Activity.HandleClickTabClicked instead.

User interactions (both)

  • Load + Gen + Analyze button → full pipeline above
  • Finding click → cached/GetFindingDetail → status + highlight (Sim: seek)
  • Overlay modeApplyOverlay(session, mode) → re-render
  • Playback (P, Sim only) → timer advances SimMoveIndexSetSimulationState → re-render
  • Pointer drag → orbit (left) / pan (other) → SetCamera → re-render. No mouse-wheel zoom anywhere — zoom is camera presets + Zoom to Fit only.

Keybindings — exact set

ShellKeybindings.Handle(e, actions) (ShellKeybindings.cs:82-113) handles exactly four keys, each setting e.Handled = true:

Key Action
P TogglePlay (:84-89)
Ctrl+L ToggleInspector (:91-96)
Right StepForward (:98-103)
Left StepBackward (:105-110)

Actions is a public readonly struct (ShellKeybindings.cs:58). Anything unhandled returns false so apps can chain. The TabOverlays tooltip "Overlays (T/S/C/W)" (MainWindowShell.axaml:86) is misleading text — T/S/C/W hotkeys do not exist.

Forbidden in apps

  • No mesh/G-code parsing
  • No second ABI
  • No fake golden counts — fixture file must exist

See also