Skip to content

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-eff
  • FindingCategoryId (: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 ReleaseHandleNpsAbi.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)

  1. Unmanaged-heap struct marshal instead of pinning — structs with ByValArray/ByValTStr reference fields cannot be GCHandle.Alloc(Pinned) ("Object contains references"), so the session uses Marshal.AllocHGlobal + StructureToPtr + finally { FreeHGlobal }: gen params EngineSession.cs:271-290, material profile :348-366. Contrast: strictly-blittable NpsLoadOptions MAY be pinned or passed IntPtr.Zero.
  2. Engine-owned string pointersIntPtr return + 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).
  3. 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).
  4. [Out] array fills with count probe — probe, allocate, fill, trim: findings summary EngineSession.cs:381-400.
  5. out / ref structsout NpsAnalysisFinding (NpsAbi.cs:461); ref NpsWarpGrid (:480).
  6. ref UIntPtr outCount pointer queries — engine returns IntPtr + element count: mesh arrays (NpsAbi.cs:310-316), GetWarpGridRisk (:483); consumed via Marshal.Copy (MeshController.cs:210, 228, 242).
  7. Fixed-size LPArray out-buffers — MVP 16 (NpsAbi.cs:416), heatmap RGB 3 (:497), orbit XY 2 (:503).
  8. LPArray inputs — overlay values (:426, :434), bgcode bytes (:380).
  9. LPStr strings in — paths (:252), gcode text (:377), overlay ids (:422, :464).
  10. I1 bools[MarshalAs(UnmanagedType.I1)] for simulating (:420), warp active (:424), invert (:428).
  11. ByValTStr / ByValArray inline in PODs — finding category/message/ suggestion (NpsAbi.cs:164-171), reserved tails int[4] (:122, :139).
  12. 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); DisposeDestroyEngine.
  • 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 via ReadOnlySpan/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.3mf loads 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.

See also