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 bar —
MainWindowShell.StatusTextstyled property (MainWindowShell.axaml.cs:90-97), presented in theStatusRegion. - Process output —
StatusSink.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:
Program.Main→ ABI gate + CLI preflight → Avalonia bootstrapMainWindowctor → shell chrome +EngineSession.CreateAndInitialize()+ViewportController.CreateAndInitialize()(Simulator also constructsMeshControllerover the session —SI/MainWindow.axaml.cs:109— and aMeshCanvasPainter)- 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):
- Start at
AppContext.BaseDirectory(:62); walk up toMaxWalkDepth = 12levels (:51, :63): - probe
dir + filename(:65-69) — return onFile.Exists; - probe the level's parent directory (:76-80) — return on exists;
dir = parent(:82).- Fallback:
BaseDirectory + "../../../../../../" + filename(:89) — thesrc/<App>/bin/Debug/net8.0/→ repo-root hop; may not exist, callers must checkFile.Exists(SA:163/SI:213).
Slicer vs Simulator¶
| Concern | SlicerApp | SimulatorApp |
|---|---|---|
| Toolpath + overlays + findings | yes | yes |
Slice params UI + ApplySliceButton |
yes — SliceParams 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 | yes — MeshCanvasPainter 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.HandleClick → TabClicked instead.
User interactions (both)¶
- Load + Gen + Analyze button → full pipeline above
- Finding click → cached/
GetFindingDetail→ status + highlight (Sim: seek) - Overlay mode →
ApplyOverlay(session, mode)→ re-render - Playback (P, Sim only) → timer advances
SimMoveIndex→SetSimulationState→ 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¶
user-flows.md— sequence diagramsengine-host.md— the L3 façade these apps calldocs/ui-shell-and-viewport.md