04 — EngineHost (L3)¶
Sole C# host border. Project: src/Nps.EngineHost/. Every
[DllImport] in the repo lives in NpsAbi.cs (DllName = "NPSEngine",
NpsAbi.cs:43); the csproj header (Nps.EngineHost.csproj:62-66)
documents this as an enforced rule — apps and shell never redeclare ABI.
| File | Role |
|---|---|
NpsAbi.cs |
Only P/Invoke decls + POD mirrors + wire-id constants |
SafeEngineHandle.cs |
SafeHandle for engine session (:46) |
SafeGcodeViewportHandle.cs |
SafeHandle for viewport (:29) |
EngineSession.cs |
Session-shaped façade for apps/tests (:67) |
MeshController.cs |
Read-only mesh + palette snapshot façade (:71) |
NpsAbi¶
public static class NpsAbi (NpsAbi.cs:41), all exports
CallingConvention.Cdecl. Enums: NpsResult (:49), FindingSeverity
(:72), LoadMode (:75), GcodeMoveType (:78).
ExpectedAbiVersion = 1 (:238) — hosts assert it at startup.
Wire ids (string constants, not enums):
OverlayModeId(:88-95):motion-type(:90 — the overlay-panel "None" radio maps here, it is an active mode, not a no-op),thermal,warp-risk,bond-strength,cooling-effFindingCategoryId(:101-108):thermal,structural,motion,hole,retraction,flow(6 wire ids)
POD mirrors (lockstep with nps_c_api.h, static_assert-frozen on the C side)¶
| Struct | file:line | Notes |
|---|---|---|
NpsLoadOptions |
NpsAbi.cs:118 |
Strictly blittable — may be pinned or IntPtr.Zero |
NpsGcodeGenParams |
:132 |
int[4] reserved ByValArray; volumetricFlowScale |
NpsGcodeMove |
:149 |
x/y/z/e/f floats, type/layer ints, reserved0 = per-move M204 accel |
NpsAnalysisFinding |
:161 |
~300B detail row; ByValTStr category[32] / message[128] / suggestion[128] |
NpsAnalysisFindingSummary |
:180 |
16B row; findingId == index into the full findings array |
NpsMaterialProfile |
:195 |
108B; ByValTStr type[16] / chamberType[16] |
NpsWarpGrid |
:212 |
layer, gridW, gridH, minX, minY, gridRes |
Export map by domain (managed decl → C entry point)¶
| Domain | file:line | Members |
|---|---|---|
| Lifecycle | :223-241 |
CreateEngine :224, DestroyEngine :227, InitializeEngine :230, GetEngineVersion :233, GetAbiVersion :241 (no handle needed) |
| Model loader | :251-285 |
LoadModelFromFile :252, LoadModelFromFileWithOptions :258, GetLastErrorMessage :265 (engine-owned, do not free), GetInferredMaterialType :277, GetInferredSlicerType :280, GetInferredPrinter :285 |
| Mesh query | :290-322 |
GetPlateCount :295, GetModelUnit :298, GetTotalVertexCount :301, GetTotalTriangleCount :304, GetTriangleCountForPlate :307, GetVertexPositions :310, GetTriangleIndices :313, GetTriangleColorIndices :316, GetPaletteColorCount :319, GetPaletteColor :322 |
| G-code gen/parse | :332-380 |
GenerateGcode :333 (demo toolpath), GetLastGeneratedGcode :336, GetLastParsedGcode :345 (split text buffers; move array shared), GenerateGcodeToBuffer :352, WriteGcodeToFile :358, GetLastGcodeMoveCount :366, GetLastGcodeMoves :369, ParseGcode :377, ParseBgcode :380 |
| Viewport | :388-435 |
CreateGcodeViewport :389, DestroyGcodeViewport :392, ViewportSetGcodeMoves :395 (exact name), ViewportSetLayerVisibility :398, ViewportSetCamera :401, ViewportBuildBuffers :404, ViewportGetLayerCount :407, ViewportGetLayerExtrudeVerts :410, ViewportGetLayerTravelVerts :413, ViewportComputeMVP :416, ViewportSetSimulationState :420, ViewportSetOverlayMode :422, ViewportSetWarpParams :424, ViewportSetOverlayValues :426, ViewportSetOverlayRange :428, ViewportApplyOverlay :432 (one-shot, preferred) |
| Analysis | :442-483 |
AnalyzeGcode :443, GetAnalysisFindingsCount :446, GetAnalysisFindings :449, GetAnalysisFindingsSummaryCount :455, GetAnalysisFindingsSummary :458, GetAnalysisFindingsDetail :461, GetOverlayValue :464, GetSupportedOverlayCount :467, GetSupportedOverlayIds :472, GetWarpGridInfo :480, GetWarpGridRisk :483 |
| Viewport math (no handle) | :493-503 |
ViewportGetHeatmapColor :494, ProjectOrbitXY :500 |
| Test-only hooks | :505-529 |
TestGcodeParseForHarness :517, TestThermalGridForHarness :522, TestStructuralScoreForHarness :527 — gated by NPS_ENABLE_TEST_HOOKS on the C++ side; only tests/NPSEngine.Tests calls them |
Apps and tests call NpsAbi.* only through this assembly — never
redeclare DllImport.
SafeHandles¶
| Member | file:line | Behavior |
|---|---|---|
SafeEngineHandle() |
SafeEngineHandle.cs:48 |
Empty, handle Zero |
SafeEngineHandle(IntPtr, bool ownsHandle) |
:51 |
Wrap pre-existing handle via SetHandle |
IsInvalid |
:56 |
handle == IntPtr.Zero |
ReleaseHandle() |
:58-65 |
Calls NpsAbi.DestroyEngine when valid |
SafeGcodeViewportHandle (same 4 members) |
SafeGcodeViewportHandle.cs:31-47 |
ReleaseHandle → NpsAbi.DestroyGcodeViewport |
Invariant: every engine + viewport handle reaches C# through a
SafeHandle; never raw IntPtr + manual Destroy for owned resources
(SafeEngineHandle.cs:29-32). Viewport handles are owned by Nps.Shell
ViewportController, not by EngineSession.
EngineSession public surface¶
public sealed class EngineSession : IDisposable
(EngineSession.cs:67). Sole owner of its SafeEngineHandle (:73);
single-threaded UI-thread session. Raw-handle access is private IntPtr
DangerousHandle (:463) plus internal IntPtr RawEngineHandle (:500) —
there is no public DangerousGetEngineHandle; only MeshController
(same assembly) uses the internal accessor.
| Member | file:line | What it does | NpsAbi exports called |
|---|---|---|---|
ExpectedAbiVersion (static) |
:87 |
Host-side expected ABI (== 1) | reads NpsAbi.ExpectedAbiVersion |
GetRuntimeAbiVersion() (static) |
:93 |
Runtime ABI from loaded binary | GetAbiVersion |
EnsureAbiMatchesHost() (static) |
:104-113 |
Throws InvalidOperationException on mismatch; call before any handle alloc |
GetAbiVersion |
IsValid |
:80 |
Handle allocated + initialized | — |
EngineVersion |
:120 |
Engine-reported version; throws if invalid | GetEngineVersion |
GetLastGeneratedGcodeText() |
:135-140 |
Managed copy of engine-owned generated-text buffer; null when none | GetLastGeneratedGcode + PtrToStringAnsi |
HasMoves |
:149 |
Move snapshot non-zero | — |
EngineSession() |
:155 |
Empty ctor; call Initialize before any op |
— |
CreateAndInitialize() (static) |
:165-170 |
new + Initialize; throws if native lib missing | via Initialize |
Initialize() |
:177-193 |
Idempotent; wraps handle ownsHandle: true (:190) |
CreateEngine (:184), InitializeEngine (:191) |
LoadModel(filePath) |
:202-245 |
Clears move snapshot (:207-208); on failure returns code + last-error text (:214-216); on success builds PlateSummary list (verts = npos/3, :226-228), palette, unit (default "mm", :239) |
LoadModelFromFile, GetLastErrorMessage, GetPlateCount, GetTriangleCountForPlate, GetVertexPositions, GetPaletteColorCount, GetPaletteColor, GetModelUnit |
GenerateDemoToolpath(GcodeGenOptions) |
:253-307 |
DEMO toolpath, NOT production slicing (:248-250). Marshals params via AllocHGlobal+StructureToPtr (:277-280), frees in finally (:289); snapshots engine-owned move pointer + count (:303-305); count==0 → NoMoves (:296-301) |
GenerateGcode (:281), GetLastGcodeMoveCount (:295), GetLastGcodeMoves (:303) |
LastMovesPointer |
:319 |
Snapshot of engine-owned NpsGcodeMove[]; valid until next LoadModel/Generate (G7 rule, :309-318) |
— |
LastMovesCount |
:325 |
Move count of snapshot | — |
Analyze(MaterialProfileOptions) |
:332-369 |
Builds NpsMaterialProfile with only Type/MeltTemp/GlassTransition set (:337-347); same AllocHGlobal pattern (:353-365). AnalyzeGcode is void-returning — engine swallows analysis errors into findings; host treats "ran" as success (:357-360) |
AnalyzeGcode (:361) |
GetFindingsSummary() |
:376-401 |
16B summary rows; count probe then fill, trimmed to returned count (:395-400) | GetAnalysisFindingsSummaryCount (:381), GetAnalysisFindingsSummary (:388) |
GetFindingDetail(findingId) |
:407-413 |
300B detail fetched only on selection, by stable findingId; null on stale id / no analysis | GetAnalysisFindingsDetail (:411) |
GetWarpGrid(layer) |
:421-443 |
Info via ref; null on NoData / degenerate dims (:427-430); risk floats copied via Marshal.Copy (:437-440) |
GetWarpGridInfo (:426), GetWarpGridRisk (:433) |
GetOverlayValue(overlayId, layer, moveIndex) |
:486-490 |
Per-move overlay scalar; keeps render loop off P/Invoke | GetOverlayValue (:489) |
Dispose() |
:450-457 |
Idempotent; nulls field, clears snapshot, disposes handle → DestroyEngine |
via SafeEngineHandle.ReleaseHandle |
All members throw ObjectDisposedException after Dispose via
ThrowIfInvalid/DangerousHandle (:463-473).
Result records (all readonly record struct, same file)¶
| Record | file:line | Members / factories |
|---|---|---|
GcodeGenOptions |
:508 |
(EnableNonPlanar, LayerHeight, VolumetricFlowScale, Feedrate, NumLayers) |
MaterialProfileOptions |
:521 |
(Type, MeltTemp, GlassTransition) |
PlateSummary |
:531 |
(Id, Vertices, Triangles) |
LoadResult |
:538-577 |
(Succeeded, ErrorCode, ErrorMessage, Plates, Palette, Unit); computed TotalVertices/TotalTriangles; internal Success/Failure (:568-576) |
GenerateResult |
:585-606 |
(Succeeded, ErrorCode, HasMoves, MovesPointer, MovesCount); internal Success/NoMoves/Failure (:592-605) |
AnalyzeResult |
:612-625 |
(Succeeded, ErrorCode) |
WarpGridData |
:637-650 |
(NpsWarpGrid Info, float[]? Risk); Layer/GridW/GridH/GridRes accessors so apps avoid naming the ABI POD |
MeshController public surface¶
public sealed class MeshController (MeshController.cs:71). Read-only
projection of engine mesh + palette. Holds a reference to
EngineSession — does NOT own its lifetime (:79); disposing the session
under a live controller is a programming error (:28-33). Snapshot copies
engine pointers into stable managed arrays so the render loop never
touches engine-owned memory (no pinning, no DangerousGetHandle, no
unsafe, :36-40).
| Member | file:line | What it does | NpsAbi exports called |
|---|---|---|---|
MeshController(EngineSession) |
:88-92 |
Ctor; does NOT call Reload | — |
PlateCount |
:98 |
Plates.Count; 0 before first Reload |
— |
Palette |
:105 |
ARGB palette indexed by per-triangle color index | — |
Plates |
:115 |
Per-plate geometry snapshot | — |
Reload() |
:131-176 |
Re-queries engine into fresh managed arrays; safe to repeat (old arrays stay valid, :121-125); invalid session → empty snapshot (:133-138). Call after every LoadModel attempt, success or fail (:127-129) | GetPlateCount (:159), GetPaletteColorCount/GetPaletteColor (:167-171); per plate GetVertexPositions (:186), GetTriangleIndices (:188), GetTriangleColorIndices (:190) |
Private copy helpers: LoadPlate (:183), CopyFloats (:202,
Marshal.Copy), CopyUInt32 (:214 — via int[] + Buffer.BlockCopy
because no IntPtr→uint[] overload exists, :221-226), CopyBytes (:234).
PlateMesh (MeshController.cs:253)¶
| Member | file:line | Contract |
|---|---|---|
PlateMesh(positions, indices, colorIndices) |
:265-273 |
Null-checked; for MeshController use only |
Positions |
:280 |
ReadOnlySpan<float>, length VertexCount * 3 |
Indices |
:288 |
ReadOnlySpan<uint>, length TriangleCount * 3 |
ColorIndices |
:297 |
ReadOnlySpan<byte>, length TriangleCount; may be empty → renderer falls back to default color |
VertexCount / TriangleCount |
:303 / :310 |
Array length / 3 |
Apps and MeshCanvasPainter iterate spans only — no mesh-geometry
P/Invoke on the hot path.
Marshalling patterns (with examples)¶
- Unmanaged-heap struct marshal instead of pinning — structs with
ByValArray/ByValTStr reference fields cannot be
GCHandle.Alloc(Pinned)("Object contains references"), so the session usesMarshal.AllocHGlobal+StructureToPtr+finally { FreeHGlobal }: gen paramsEngineSession.cs:271-290, material profile:348-366. Contrast: strictly-blittableNpsLoadOptionsMAY be pinned or passedIntPtr.Zero. - Engine-owned string pointers —
IntPtrreturn + null-check +Marshal.PtrToStringAnsi, never freed by host, valid until next Load/Parse/Destroy:EngineSession.cs:139(generated gcode),:215(last error),:239(model unit). - Byte-buffer string-out —
[Out] byte[] outBuf, UIntPtr maxLen, UTF-8 NUL-terminated, truncation = cap-1 bytes + NUL (NpsAbi.cs:267-274):GetInferredMaterialType(:277),GetInferredSlicerType(:280),GetInferredPrinter(:285),GetSupportedOverlayIds(:472). [Out]array fills with count probe — probe, allocate, fill, trim: findings summaryEngineSession.cs:381-400.out/refstructs —out NpsAnalysisFinding(NpsAbi.cs:461);ref NpsWarpGrid(:480).ref UIntPtr outCountpointer queries — engine returns IntPtr + element count: mesh arrays (NpsAbi.cs:310-316),GetWarpGridRisk(:483); consumed viaMarshal.Copy(MeshController.cs:210, 228, 242).- Fixed-size LPArray out-buffers — MVP 16 (
NpsAbi.cs:416), heatmap RGB 3 (:497), orbit XY 2 (:503). - LPArray inputs — overlay values (:426, :434), bgcode bytes (:380).
- LPStr strings in — paths (:252), gcode text (:377), overlay ids (:422, :464).
- I1 bools —
[MarshalAs(UnmanagedType.I1)]for simulating (:420), warp active (:424), invert (:428). - ByValTStr / ByValArray inline in PODs — finding category/message/
suggestion (
NpsAbi.cs:164-171), reserved tails int[4] (:122, :139). - UIntPtr for size_t counts throughout (e.g.
:366,:407).
Resource ownership¶
- EngineSession is the sole owner of its
SafeEngineHandle(ownsHandle: true,EngineSession.cs:190);Dispose→DestroyEngine. - SafeGcodeViewportHandle ownership lives in Nps.Shell
ViewportController— not in EngineSession. - MeshController holds a non-owning session reference; the app owns the session.
- Move buffer (
LastMovesPointer) is engine-owned; the session only snapshots pointer + count; valid until next LoadModel/Generate. UI iterates viaReadOnlySpan/unsafe without copying. - Mesh snapshot: MeshController copies into owned managed arrays on Reload, so render-loop spans never alias engine memory.
- Golden-fixture caveat:
4-colors+Benchy+AMS+test-v2.3mfloads as 1 plate / 16562 verts / 33097 tris / 4 palette colors through the LoadModel → Reload path above.
Mapping to C ABI¶
| EngineSession | ABI |
|---|---|
| ctor/Create | CreateEngine + InitializeEngine |
| LoadModel | LoadModelFromFile + mesh getters |
| GenerateDemoToolpath | GenerateGcode + move getters |
| Analyze | AnalyzeGcode + findings APIs |
| Dispose | DestroyEngine |
Forbidden¶
- No second P/Invoke surface in apps or shell.
- No domain parsing in C#.
- No Test* imports in production host code.