Creating Custom Diagram Families
Step-by-step guide to building your own domain-specific diagram family
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.
Table of Contents
Overview
Every diagram family in Beep.Skia follows the same architectural pattern:
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.
| Layer | Responsibility | Example |
|---|---|---|
| Project | Class library multi-targeting net8.0;net9.0;net10.0, references Beep.Skia | Beep.Skia.Chart.PnID.csproj |
| Base Control | Inherits MaterialControl, overrides template methods, manages ports | PnIDNodeBase |
| Concrete Nodes | Inherit from base control, implement DrawContent(), define connection points | ValveNode, PumpNode |
| Extension | Implement ISkiaExtension; register types with categories through ISkiaExtensionContext | PnIDExtension |
| Palette | Category entry in ComponentPalette for toolbox visibility | Piping & 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:
| Member | Purpose | Override? |
|---|---|---|
DrawContent(SKCanvas, DrawingContext) | Core visual rendering of the node | Required — it is abstract on SkiaComponent |
LayoutPorts() | Recompute port positions from the current bounds | Yes — define where ports appear |
InitializeComponent() | Set up NodeProperties, default size and state | Yes — call base.InitializeComponent() |
OnBoundsChanged(SKRect) | React to moves and resizes | Optional — 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
.beepkgpackage 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:
| Component | Class | Shape | Ports | Use Case |
|---|---|---|---|---|
| Valve | ValveNode | Bowtie (two opposing triangles) | 2 in (left, top), 2 out (right, bottom) | Control valves, isolation valves, check valves |
| Pump | PumpNode | Circle with direction triangle | 2 in (left, top), 2 out (right, bottom) | Centrifugal pumps, positive-displacement pumps |
| Tank | TankNode | Rectangular body with rounded bottom | 2 in (left, top), 2 out (right, bottom) | Storage tanks, pressure vessels, reactors |
| Pipe | PipeNode | Horizontal line with direction arrow | 1 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));
}