Rendering goes through RenderingHelper, which owns the frame structure. DrawingManager.Draw(SKCanvas) is a one-line forwarder, and the same helper serves the export path with a different configuration.

Frame Structure

Every frame runs the same ordered passes:

  1. Update and partition — each component's Update(DrawingContext) runs once, then it is partitioned into dynamic (world space) or static (screen space, IsStatic) lists.
  2. World transform — canvas.Translate(PanOffset) followed by canvas.Scale(Zoom).
  3. Grid — drawn in world space when ShowGrid is on.
  4. Components — each dynamic component's Draw(canvas, drawingContext) runs with a context whose Bounds is canvas.DeviceClipBounds.
  5. Connection lines — every line draws; invisible lines are skipped. A line being dragged is drawn last as a preview.
  6. Selection adorners — marquee rectangle, resize handles, then the host's WorldOverlay callback (comment pins use this).
  7. Static overlays — after canvas.Restore(), screen-space components (palette, property editor, minimap) draw without pan/zoom.

Overlay Hook

DrawingManager.WorldOverlay is an Action<SKCanvas> invoked inside the world transform after selection adorners. Use it for world-space annotations that must not appear in exports.

World vs. Screen Space

The split is explicit and drives correctness:

SpaceContentsTransform
WorldDiagram components, connection lines, grid, selection adorners, world overlayPan offset + zoom applied
ScreenPalette, property editors, minimap, context menus (components with IsStatic = true)No transform; drawn after restore

Input follows the same convention: the host converts pointer coordinates into world space before calling HandleMouseDown/Move/Up, so hit testing never deals with zoom.

Culling and Update

The DrawingContext passed to components carries Zoom, PanOffset and Bounds (the device clip rectangle). Components use Bounds to skip work that cannot be visible — that is why a component drawn with an empty viewport produces no ink, and why tests draw through a manager with a real surface.

Exceptions during Update or Draw are caught per component and written to the debug trace: one misbehaving component cannot take down the frame.

// The frame entry point
public void Draw(SKCanvas canvas) => _renderingHelper.DrawAll(canvas);

// What a component receives
protected override void DrawContent(SKCanvas canvas, DrawingContext context)
{
    if (!context.Bounds.IntersectsWith(Bounds)) return;   // culled
    // ... draw
}

Export Path

Exports reuse the pipeline with everything interactive disabled: no pan/zoom, no grid, no selection adorners and no static overlays. That produces a clean diagram at 1:1 scale regardless of the current viewport.

CallConfiguration
DrawAll(canvas)applyPanZoom: true, grid, selection and static overlays on.
DrawForExport(canvas)All off, zoom: 1, pan: (0,0).

The host wraps this for files: ExportToPng(path, scale) renders to an offscreen surface at the requested scale, ExportToSvg and ExportToPdf use the same draw pass with their respective Skia backends.

Measured Cost

The repository tracks render cost in RenderPerformanceTests (median of five runs on the CI machine):

ScenarioFrame timeAllocations
200 components~3.1 ms~40.7 KB
200 components + 199 connection lines~4.5 ms~40.8 KB

The line-heavy case is nearly allocation-neutral because ConnectionLine reuses its SKPaint across frames (the paint-reuse pass removed ~17 KB per frame and roughly a millisecond). Text goes through TypefaceCache, whose cached lookups are about 13× faster than resolving typefaces per draw.

// Reuse paints across frames; never allocate in DrawContent
private readonly SKPaint _fill = new SKPaint { IsAntialias = true };

// Cache typefaces instead of resolving them per frame
var typeface = TypefaceCache.Get("Segoe UI", SKFontStyle.Normal);
Console.WriteLine(TypefaceCache.CachedCount);   // entries currently cached

See the Performance Guide for the full optimization checklist.