Beep.Skia ships with 18 built-in diagram families covering business processes, data modeling, engineering, and analytics. When your domain requires visual representations not covered by the built-in families, you can create a custom diagram family that plugs directly into the Skia rendering pipeline, the component palette, and the serialization system.

Overview

Every diagram family in Beep.Skia follows the same architectural pattern:

IDrawableComponent
↓
SkiaComponent
Position, size, NodeProperties, ConnectionPoints
↓
MaterialControl
Material Design 3.0 theming, elevation, state layers
↓
FamilyBaseControl
Your custom base class — shared rendering, ports, behavior
↓
Concrete Node Classes
ValveNode, PumpNode, TankNode, PipeNode, etc.

Each family lives in its own .csproj project, typically named Beep.Skia.Chart.<FamilyName>. This isolation allows families to be loaded independently via the AssemblyHandler plugin system, keeping application startup lean and enabling NuGet distribution.

LayerResponsibilityExample
ProjectClass library multi-targeting net8.0;net9.0;net10.0, references Beep.SkiaBeep.Skia.Chart.PnID.csproj
Base ControlInherits MaterialControl, overrides template methods, manages portsPnIDNodeBase
Concrete NodesInherit from base control, implement DrawContent(), define connection pointsValveNode, PumpNode
ExtensionImplement ISkiaExtension; register types with categories through ISkiaExtensionContextPnIDExtension
PaletteCategory entry in ComponentPalette for toolbox visibilityPiping & Instrumentation category

Step 1: Create the Project

Create a new class library project that targets .NET 8.0 and .NET 9.0, and add a reference to the Beep.Skia package:

Project File: Beep.Skia.Chart.PnID.csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFrameworks>net8.0;net9.0</TargetFrameworks>
    <RootNamespace>Beep.Skia.Chart.PnID</RootNamespace>
    <AssemblyName>Beep.Skia.Chart.PnID</AssemblyName>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Beep.Skia" Version="3.119.0" />
    <PackageReference Include="SkiaSharp" Version="2.88.*" />
  </ItemGroup>

</Project>

Tip

Use the naming convention Beep.Skia.Chart.<FamilyName> for your project. This aligns with the existing 18 families and ensures the AssemblyHandler can resolve type names predictably.

Create the project directory structure:

Recommended Directory Layout

Beep.Skia.Chart.PnID/
├── Beep.Skia.Chart.PnID.csproj
├── Nodes/
│   ├── PnIDNodeBase.cs
│   ├── ValveNode.cs
│   ├── PumpNode.cs
│   ├── TankNode.cs
│   └── PipeNode.cs
├── Connections/
│   └── PnIDConnectionLine.cs  (optional custom connection)
└── Palette/
    └── PnIDPaletteProvider.cs

Step 2: Create the Base Control

The base control is the heart of your diagram family. It inherits from MaterialControl and provides shared rendering, port management, and property defaults for all concrete nodes in the family.

Your base class should override these key members:

MemberPurposeOverride?
DrawContent(SKCanvas, DrawingContext)Core visual rendering of the nodeRequired — it is abstract on SkiaComponent
LayoutPorts()Recompute port positions from the current boundsYes — define where ports appear
InitializeComponent()Set up NodeProperties, default size and stateYes — call base.InitializeComponent()
OnBoundsChanged(SKRect)React to moves and resizesOptional — call MarkPortsDirty() when geometry changes

Base Control: PnIDNodeBase.cs

using Beep.Skia;
using Beep.Skia.Model;
using SkiaSharp;

namespace Beep.Skia.Chart.PnID.Nodes;

public abstract class PnIDNodeBase : MaterialControl
{
    protected string NodeLabel { get; set; } = "";

    protected PnIDNodeBase()
    {
        Width = 80f;
        Height = 80f;
    }

    protected override void InitializeComponent()
    {
        base.InitializeComponent();

        NodeProperties["Label"] = new ParameterInfo
        {
            ParameterName = "Label",
            ParameterCurrentValue = "",
            DefaultParameterValue = "",
            ParameterType = typeof(string),
            Description = "Node label"
        };

        NodeProperties["EquipmentTag"] = new ParameterInfo
        {
            ParameterName = "EquipmentTag",
            ParameterCurrentValue = "V-001",
            DefaultParameterValue = "V-001",
            ParameterType = typeof(string),
            Description = "Equipment tag"
        };
    }

    protected override void LayoutPorts()
    {
        var b = Bounds;

        for (int i = 0; i < InConnectionPoints.Count; i++)
        {
            float y = b.Top + b.Height * (i + 1) / (InConnectionPoints.Count + 1);
            var cp = InConnectionPoints[i];
            cp.Center = new SKPoint(b.Left, y);
            cp.Position = cp.Center;
            cp.Type = ConnectionPointType.In;
            cp.DataType = "object";
            cp.Component = this;
            cp.IsAvailable = true;
        }

        for (int i = 0; i < OutConnectionPoints.Count; i++)
        {
            float y = b.Top + b.Height * (i + 1) / (OutConnectionPoints.Count + 1);
            var cp = OutConnectionPoints[i];
            cp.Center = new SKPoint(b.Right, y);
            cp.Position = cp.Center;
            cp.Type = ConnectionPointType.Out;
            cp.DataType = "object";
            cp.Component = this;
            cp.IsAvailable = true;
        }
    }

    protected override void DrawContent(SKCanvas canvas, DrawingContext context)
    {
        var theme = ThemeManager.Current;

        using var fillPaint = new SKPaint
        {
            Style = SKPaintStyle.Fill,
            IsAntialias = true,
            Color = theme.SurfaceContainer
        };

        using var strokePaint = new SKPaint
        {
            Style = SKPaintStyle.Stroke,
            IsAntialias = true,
            StrokeWidth = 2f,
            Color = theme.Outline
        };

        canvas.DrawRoundRect(Bounds, 4f, 4f, fillPaint);
        canvas.DrawRoundRect(Bounds, 4f, 4f, strokePaint);

        DrawLabel(canvas);
    }

    protected void DrawLabel(SKCanvas canvas)
    {
        if (string.IsNullOrEmpty(NodeLabel)) return;

        using var textPaint = new SKPaint
        {
            IsAntialias = true,
            TextSize = 11f,
            TextAlign = SKTextAlign.Center,
            Color = ThemeManager.Current.OnSurface
        };

        canvas.DrawText(NodeLabel, Bounds.MidX,
            Bounds.MidY + textPaint.TextSize / 3, textPaint);
    }
}

Port Management Best Practice

Ports live in the component's InConnectionPoints / OutConnectionPoints collections. When bounds-affecting properties change, call MarkPortsDirty() (inherited from SkiaComponent) so the layout is recomputed on the next draw instead of every frame.

Step 3: Create Node Classes

Concrete node classes inherit from your base control and override DrawContent() for their family-specific visual representation. Each node type should define its own default size, visual shape, and port count.

Concrete Node: ValveNode.cs

using Beep.Skia.Model;
using SkiaSharp;

namespace Beep.Skia.Chart.PnID.Nodes;

public class ValveNode : PnIDNodeBase
{
    public ValveNode()
    {
        NodeLabel = "Valve";
        Width = 60f;
        Height = 80f;
    }

    protected override void DrawContent(SKCanvas canvas, DrawingContext context)
    {
        // Bowtie shape: two opposing triangles
        float cx = Bounds.MidX;
        float cy = Bounds.MidY;

        using var path = new SKPath();
        path.MoveTo(cx, Bounds.Top);
        path.LineTo(Bounds.Right, cy);
        path.LineTo(cx, Bounds.Bottom);
        path.LineTo(Bounds.Left, cy);
        path.Close();

        using var fillPaint = new SKPaint
        {
            Style = SKPaintStyle.Fill,
            IsAntialias = true,
            Color = ThemeManager.Current.SurfaceContainer
        };

        using var strokePaint = new SKPaint
        {
            Style = SKPaintStyle.Stroke,
            IsAntialias = true,
            StrokeWidth = 2f,
            Color = ThemeManager.Current.Outline
        };

        canvas.DrawPath(path, fillPaint);
        canvas.DrawPath(path, strokePaint);

        DrawLabel(canvas);
    }
}

Concrete Node: PumpNode.cs

using Beep.Skia.Model;
using SkiaSharp;

namespace Beep.Skia.Chart.PnID.Nodes;

public class PumpNode : PnIDNodeBase
{
    public PumpNode()
    {
        NodeLabel = "Pump";
        Width = 70f;
        Height = 70f;
    }

    protected override void DrawContent(SKCanvas canvas, DrawingContext context)
    {
        float cx = Bounds.MidX;
        float cy = Bounds.MidY;
        float radius = Math.Min(Bounds.Width, Bounds.Height) / 2 - 4f;

        using var fillPaint = new SKPaint
        {
            Style = SKPaintStyle.Fill,
            IsAntialias = true,
            Color = ThemeManager.Current.SurfaceContainer
        };

        using var strokePaint = new SKPaint
        {
            Style = SKPaintStyle.Stroke,
            IsAntialias = true,
            StrokeWidth = 2f,
            Color = ThemeManager.Current.Outline
        };

        canvas.DrawCircle(cx, cy, radius, fillPaint);
        canvas.DrawCircle(cx, cy, radius, strokePaint);

        // Direction triangle
        float r = radius * 0.5f;
        using var triangle = new SKPath();
        triangle.MoveTo(cx + r, cy);
        triangle.LineTo(cx - r * 0.5f, cy - r * 0.866f);
        triangle.LineTo(cx - r * 0.5f, cy + r * 0.866f);
        triangle.Close();
        canvas.DrawPath(triangle, fillPaint);

        DrawLabel(canvas);
    }
}

Concrete Node: TankNode.cs

using Beep.Skia.Model;
using SkiaSharp;

namespace Beep.Skia.Chart.PnID.Nodes;

public class TankNode : PnIDNodeBase
{
    public TankNode()
    {
        NodeLabel = "Tank";
        Width = 100f;
        Height = 120f;
    }

    protected override void DrawContent(SKCanvas canvas, DrawingContext context)
    {
        float bx = Bounds.Left + 4f;
        float bw = Bounds.Width - 8f;
        float topSection = Bounds.Top + Bounds.Height * 0.15f;
        float bottomSection = Bounds.Top + Bounds.Height * 0.85f;

        using var path = new SKPath();
        path.MoveTo(bx, topSection);
        path.LineTo(bx, bottomSection);

        var bottomRect = new SKRect(bx, bottomSection - bw / 2, bx + bw, bottomSection + bw / 2);
        path.ArcTo(bottomRect, 180f, 180f, false);

        path.LineTo(bx + bw, topSection);
        path.Close();

        using var fillPaint = new SKPaint
        {
            Style = SKPaintStyle.Fill,
            IsAntialias = true,
            Color = ThemeManager.Current.SurfaceContainer
        };

        using var strokePaint = new SKPaint
        {
            Style = SKPaintStyle.Stroke,
            IsAntialias = true,
            StrokeWidth = 2f,
            Color = ThemeManager.Current.Outline
        };

        canvas.DrawPath(path, fillPaint);
        canvas.DrawPath(path, strokePaint);
        canvas.DrawLine(bx - 4f, topSection, bx + bw + 4f, topSection, strokePaint);

        DrawLabel(canvas);
    }
}

Concrete Node: PipeNode.cs

using Beep.Skia.Model;
using SkiaSharp;

namespace Beep.Skia.Chart.PnID.Nodes;

public class PipeNode : PnIDNodeBase
{
    public float PipeDiameter { get; set; } = 8f;

    public PipeNode()
    {
        NodeLabel = "Pipe";
        Width = 120f;
        Height = PipeDiameter + 4f;
    }

    protected override void DrawContent(SKCanvas canvas, DrawingContext context)
    {
        float midY = Bounds.MidY;

        using var thickPaint = new SKPaint
        {
            Style = SKPaintStyle.Stroke,
            StrokeWidth = PipeDiameter,
            IsAntialias = true,
            StrokeCap = SKStrokeCap.Round,
            Color = ThemeManager.Current.Outline
        };

        canvas.DrawLine(Bounds.Left + 4f, midY, Bounds.Right - 4f, midY, thickPaint);

        // Flow direction arrow
        float arrowX = Bounds.Left + Bounds.Width * 0.65f;
        using var arrowPath = new SKPath();
        arrowPath.MoveTo(arrowX + 8f, midY);
        arrowPath.LineTo(arrowX - 4f, midY - 6f);
        arrowPath.LineTo(arrowX - 4f, midY + 6f);
        arrowPath.Close();

        using var arrowPaint = new SKPaint
        {
            Style = SKPaintStyle.Fill,
            IsAntialias = true,
            Color = ThemeManager.Current.OnSurface
        };

        canvas.DrawPath(arrowPath, arrowPaint);
    }
}

Step 4: Package as an Extension

Custom components are discovered through the extension SDK: implement ISkiaExtension in your family assembly, declare the component types, and register them with a palette category. The host loads extensions from assemblies or a directory and wires them into the registry.

Extension Entry Point: PnIDExtension.cs

using Beep.Skia.Extensions;

namespace Beep.Skia.Chart.PnID;

public sealed class PnIDExtension : ISkiaExtension
{
    public string Id => "com.acme.beep.pnid";
    public string Name => "Piping & Instrumentation";
    public string Version => "1.0.0";
    public string Description => "ISA S5.1 P&ID node family";

    public IEnumerable<Type> GetComponentTypes() => new[]
    {
        typeof(ValveNode),
        typeof(PumpNode),
        typeof(TankNode),
        typeof(PipeNode)
    };

    public void Initialize(ISkiaExtensionContext context)
    {
        context.RegisterComponent(typeof(ValveNode), "P&ID", "Valve");
        context.RegisterComponent(typeof(PumpNode), "P&ID", "Pump");
        context.RegisterComponent(typeof(TankNode), "P&ID", "Tank");
        context.RegisterComponent(typeof(PipeNode), "P&ID", "Pipe");
        context.Log("P&ID family registered");
    }
}

Loading the Extension

var host = new SkiaExtensionHost();

// Load every extension in an assembly, or scan a plugin folder
host.LoadFromAssemblies(new[] { typeof(PnIDExtension).Assembly });
host.LoadFromDirectory(@"C:\plugins\beep-skia", "*.dll");

Once loaded, components are available for:

  • Palette Registration: Appear as draggable items in ComponentPalette, grouped by category
  • Serialization: Full type-name resolution for save/load round-trips
  • Dynamic Instantiation: Created by the host when dropped from the palette
  • Packaging: Ship the assembly as a .beepkg package through the marketplace

Type Resolution

The serializer stores each component's assembly-qualified type name. Keep your assembly name and type names stable across versions; renaming node classes in a released version breaks existing saved diagrams.

Step 5: Add to ComponentPalette

ComponentPalette builds its categories from the registry, so an extension's RegisterComponent(type, category, displayName) calls are all that is needed. Rebuild the palette after loading extensions:

Showing the Family in the Palette

var palette = new ComponentPalette
{
    X = 10, Y = 50, Width = 320, Height = 600
};

palette.LoadComponentsFromRegistry();

// The palette now shows a "P&ID" category with Valve, Pump, Tank and Pipe.
drawingManager.AddComponent(palette);
drawingManager.RequestRedraw();

Palette Organization

Group related node types under one extension to create a single collapsible category in the toolbox. Consistent category names keep the palette predictable as the library grows.

Complete Example: P&ID Diagram Family

The following table summarizes the full Piping & Instrumentation Diagram (P&ID) family we just built. This family enables process engineers to create instrumentation diagrams with standard ISA S5.1 symbols:

ComponentClassShapePortsUse Case
ValveValveNodeBowtie (two opposing triangles)2 in (left, top), 2 out (right, bottom)Control valves, isolation valves, check valves
PumpPumpNodeCircle with direction triangle2 in (left, top), 2 out (right, bottom)Centrifugal pumps, positive-displacement pumps
TankTankNodeRectangular body with rounded bottom2 in (left, top), 2 out (right, bottom)Storage tanks, pressure vessels, reactors
PipePipeNodeHorizontal line with direction arrow1 in (left), 1 out (right)Process piping, flow direction indicators

With these four node types, a process engineer can compose complete P&ID diagrams by connecting pipes between equipment nodes, creating a directed graph that represents material flow through a process plant.

Extending the Family

This example shows the minimum viable product. A production P&ID family would include additional nodes (heat exchangers, compressors, distillation columns, instruments) and custom connection validation rules (e.g., pipes can only connect valves to tanks, not valves to valves). See Extensibility & Plugins for advanced extension patterns.

Serialization Support

Your family automatically supports serialization because all nodes extend SkiaComponent and the serializer persists each component's type name plus its NodeProperties. No additional code is required — just populate NodeProperties in InitializeComponent() and keep the class names stable.

Testing Your Family

Smoke Test

[Test]
public void PnIDFamily_RenderSmokeTest()
{
    using var surface = SKSurface.Create(new SKImageInfo(800, 600));
    var manager = new DrawingManager();

    manager.AddComponent(new TankNode { X = 50, Y = 200, Name = "TK-101" });
    manager.AddComponent(new PumpNode { X = 250, Y = 220 });
    manager.AddComponent(new ValveNode { X = 420, Y = 200 });
    manager.AddComponent(new PipeNode { X = 160, Y = 235 });

    manager.Draw(surface.Canvas);

    Assert.That(manager.GetComponents().Count, Is.EqualTo(4));
}