Skip to content
Shiny.NET
Shiny MAUI Shell v7 - App Links, App Shortcuts, & Navigation Interception!Shortcut me to it

Floor Plan

FloorPlanView draws a floor plan — rooms, walls, doors, cubicles, outlets, furniture and custom SVG stencils — on a pan-and-zoom canvas, and lets you edit it. It has two modes, and in practice they are two quite different controls:

  • Edit — the active tool has the pointer. Draw rooms and walls, drop furniture, drag things around, grab a handle to resize, rubber-band a selection.

  • View — the whole surface pans, nothing can be moved, and a tap tells you what it landed on. That is the seating chart, and it needs no toolbar at all.

  • NuGet downloads for Shiny.Maui.Controls.FloorPlan
  • NuGet downloads for Shiny.Blazor.Controls.FloorPlan
  • NuGet downloads for Shiny.Controls.FloorPlan.Shared
Frameworks
.NET MAUI
Blazor

MAUI

Edit mode View mode — the seating chart
The floor plan editor with its tool strip on .NET MAUI The same plan in view mode as a seating chart on .NET MAUI

Blazor

Edit mode View mode — the seating chart
The floor plan editor with named desks on Blazor The same plan in view mode as a seating chart on Blazor

The plan is painted rather than laid out. Renderers, hit-testing, a camera, snapping and the tools are all arithmetic over a document, and none of it wants to know whether the surface underneath is an SKCanvasView in a MAUI page or one in a Blazor component. So it does not know.

Shiny.Controls.FloorPlan.Shared owns the document model, the renderers, the hit-tester, the camera and every tool. Both controls are a surface and an event pump around one FloorPlanEngine.

That is also why this is an add-on package rather than part of the core ones. The engine is SkiaSharp all the way down, and putting SkiaSharp in Shiny.Maui.Controls / Shiny.Blazor.Controls would hand the native binaries to every consumer of those — including every Blazor WebAssembly consumer, who would then need the WebAssembly native build toolchain to publish anything at all.

Terminal window
dotnet add package Shiny.Maui.Controls.FloorPlan
builder.UseShinyFloorPlan();

That registers SkiaSharp, which is all the control needs — and it is not optional. MAUI will not hand an SKCanvasView a platform view without it, so a plan in an app that forgot the call is a blank rectangle with nothing in the log to say why.

<ContentPage xmlns:fp="http://shiny.net/maui/floorplan">
<fp:FloorPlanView x:Name="Plan"
Document="{Binding Plan}"
ActiveTool="{Binding ActiveTool}"
ShowGrid="{Binding ShowGrid}"
SnapToGrid="True"
SelectedElement="{Binding Selected, Mode=TwoWay}" />
</ContentPage>
Terminal window
dotnet add package Shiny.Blazor.Controls.FloorPlan

No registration call — but the component does need a sized container. A drawn surface has no intrinsic height, and without one the canvas collapses and the control looks like it failed to load.

<div style="height: 60vh">
<FloorPlanView Document="plan"
ActiveTool="tool"
ShowGrid="true"
@bind-SelectedElement="selected" />
</div>

On Blazor WebAssembly the package fails your build (SHINY0001) when the WebAssembly native build toolchain is missing, rather than letting you publish an app that throws DllNotFoundException: libSkiaSharp on its first frame.

using Shiny.Controls.FloorPlan;
var plan = new FloorPlanDocument { Width = 1200, Height = 900, GridSize = 20 };
plan.Elements.Add(new RoomElement
{
Name = "Main Office",
Label = "Main Office",
Transform = { X = 100, Y = 100 },
Width = 500,
Height = 400
});
plan.Elements.Add(new CubicleElement
{
Name = "Desk 1",
Occupant = "Ada L.",
Transform = { X = 120, Y = 150 }
});

Sizes are in plan units. The engine never assumes what one is — pixels, centimetres, inches — and the camera is the only thing that turns them into screen coordinates.

Element
RoomElement A rectangular room with an optional Label drawn in the middle
WallElement A Start/End segment of a given Thickness
DoorElement An opening with a swing arc; Single, Double or Sliding
CubicleElement A partitioned bay with a desk in it and an optional Occupant
OutletElement A power or data outlet — Standard, Floor or Data
FurnitureElement Desk, chair, table, bookshelf, sofa or file cabinet
CustomElement An instance of one of the document’s own ShapeDefinition stencils

Everything carries Name (what a tap reports), Transform, Style, ZIndex, IsVisible, IsLocked and a Metadata dictionary the engine never looks at — hang a desk booking id or a room’s capacity off it and it round-trips with the rest of the document.

Building and Floor cover the multi-storey case: a stack of plans saved as one file, so a floor switcher has something to switch between.

Colours belong to the document, and default to the theme

Section titled “Colours belong to the document, and default to the theme”

ElementStyle.FillColor and StrokeColor are nullable and default to null, meaning “whatever the current theme says”. That is what lets one saved plan read correctly in a light app and a dark one — a document that hard-codes #CCCCCC for every room can only ever be viewed on white.

Set a colour explicitly and it is honoured exactly, in both schemes, which is what you want for a plan that genuinely colour-codes its rooms:

new RoomElement { Label = "Open plan" } // follows the theme
new RoomElement { Label = "Boardroom", Style = { FillColor = "#E8F5E9" } } // green in both schemes

IsLocked is enforced in the editor state rather than in each tool, so a locked element cannot be selected, dragged, resized or deleted — including by a rubber band dragged across it, which is the accident locking mostly exists to prevent.

var json = FloorPlanSerializer.SerializeDocument(plan);
var back = FloorPlanSerializer.DeserializeDocument(json);

Source-generated, so it survives trimming and Native AOT. The type discriminators on FloorPlanElement ("room", "wall", …) are part of the saved format: renaming one breaks every document already written, so add new element types with new discriminators rather than reusing old ones.

One tool is active at a time and the engine hands it every pointer event.

Tool
SelectTool The default. Pick, drag, resize from eight handles, rubber-band. Shift adds to the selection
PanTool Drags the camera
DrawRoomTool Drag out a rectangular room
DrawWallTool One click per end. Chained (the default) starts the next wall where the last one ended; right-click ends the run
PlaceElementTool Drops an element wherever you click, with a ghost of the real thing under the pointer first

The middle button pans from any tool, the way every drawing application behaves.

<fp:FloorPlanView.ActiveTool>
<fp:DrawRoomTool />
</fp:FloorPlanView.ActiveTool>
this.ActiveTool = new PlaceElementTool(FurnitureKind.Chair);

PlaceElementTool configures two ways. Kind and its companions (FurnitureKind, OutletType, DoorType, ShapeDefinitionId) cover the built-in elements and are settable from XAML or a Blazor parameter; Factory takes over completely when set, which is how you place an element type of your own or one carrying pre-filled metadata. Repeat = false falls back to SelectTool after one drop.

Implement IFloorPlanTool. Render draws a preview in plan coordinates (the camera transform is already applied) and context.DrawElement paints a real element for you — which is why the placement ghost is the actual thing you are about to create rather than a generic marker. Only OnPointerReleased should change the document.

SnapToGrid snaps to the document’s own GridSize; set GridSize to 0 to turn both the grid and snapping off.

Dragging snaps on release, not on every move. Snapping mid-drag makes the element jump ahead of the pointer, and a nudge smaller than one grid cell does nothing at all.

The plan is painted, so the theme arrives as a value rather than as inherited colour. Leave Theme unset and it follows the app: MAUI reads Application.Current.Resources, Blazor reads the --shiny-color-* custom properties off the element, and both end at FloorPlanSurface so the two hosts cannot drift into theming this differently. A light/dark flip repaints on its own.

Only the neutrals follow the app. The selection blue means this is what you have hold of and the furniture colours mean this is wood, that is upholstery; restating either in an app’s accent is not theming, it is a different control. Same rule the Office surfaces follow.

For a fixed palette, hand it a FloorPlanTheme:

plan.Theme = FloorPlanTheme.Dark with { Selection = PlanColor.Rgb(0xFF, 0x6B, 0x00) };

ZoomToFit(), ZoomIn(), ZoomOut() and ScrollTo(element) are methods on the control rather than bindable properties: they all need the surface size, and a view model has no business knowing it.

On MAUI, ZoomToFit() is safe to call before the view has ever painted — which is the usual case, since the natural place to call it is the page’s constructor. The surface has no size until the first frame, so the request is held and applied there. On Blazor the same thing happens automatically: FitOnLoad (on by default) fits the plan on the first painted frame.

SelectedElement is two-way. Reading it tells a view model what the user picked; writing it selects that element in the plan, so a list beside the plan drives it both ways. It is null when nothing — or more than one thing — is selected; SelectionChanged / OnSelectionChanged hands over the whole set.

plan.Engine.RegisterRenderer(new MyRoomRenderer());

Renderers are keyed on ElementType exactly — one registered for a base type will not pick up its subclasses. GetOutlinePath is what a pointer is tested against, so return the silhouette you actually drew: a renderer that draws a circle but returns its bounding box makes the corners clickable, and the control feels wrong for a reason nobody can put their finger on.

MAUI iOS, Android, Mac Catalyst, Windows
Blazor WebAssembly, Server, Hybrid