All projects Planum · Write-up
Sloped ceilings from two tape measurementsDachschrägen aus zwei Zollstock-Maßen
Planum is a floor planner for real homes, attic rooms included. The interesting part is how little it takes to model a sloped ceiling, and how much of the app never has to know about it.Planum ist ein Grundrissplaner für echte Wohnungen, Dachzimmer inklusive. Spannend ist, wie wenig es braucht, um eine Dachschräge abzubilden, und wie viel der App nie davon erfahren muss.
- Type
- Web appWeb-App
- Stack
- React · Three.js · TanStack Start
- Year
- 2025
- Code
- Public on GitHubÖffentlich auf GitHub
Why attics
A flat floor plan can tell you that a wardrobe fits between two walls. It can’t tell you that the ceiling above that spot is 1.20 m high. In an attic room that is the whole question, and it’s why Planum exists: lay out a room in 2D, walk through it in 3D, and see which pieces no longer fit under the ceiling.
Two numbers per wall
A slope isn’t modelled as roof geometry. It is attached to a wall and described the way you’d measure it: the height of the knee wall, where the ceiling meets that wall, and the horizontal run over which the ceiling rises to full height. Two numbers, both measurable with a tape.
interface WallSlope {
kneeHeight: number; // cm, where the ceiling meets this wall
run: number; // cm into the room until full height
}
From those, one function answers the only question that matters: how tall can something be at this spot? availableHeightAt takes the minimum over all slopes, which makes the model compose. A gabled attic is just two opposite walls sloping toward the middle, and where two slopes overlap, the lower one wins without a special case. It also degrades: a room without slopes is a plain box.
The floor polygon is never touched. Footprint, collision, room adjacency and wall openings work exactly as they do for a flat room; nothing that existed before slopes had to learn about them.
Sample, don’t solve
The true ceiling is piecewise linear. Each slope is a plane, the height is the minimum over them, and the surface folds wherever one slope overtakes another or a run ends. Computing those folds exactly means intersecting every pair of planes and clipping the result to the room outline, which is easy to get subtly wrong at the inner corners of an L- or U-shaped room.
So the 3D ceiling, and the profiles cut into the walls beside a slope, both sample availableHeightAt on a grid of about 15 cm. Every fold lands within one step of where it belongs, which is invisible at furniture scale. One rule makes it safe: always include the exact endpoints, so neighbouring walls meet at the same height in the corner. And since walls, ceiling and the 2D contour lines all read the same function, they can’t drift apart.
Collision that doesn’t tunnel
Furniture is a set of rotated rectangles, and collision is the separating axis theorem on four axes, the edge normals of both shapes. Dragging a whole room in the floor overview is different: at low zoom a single pointer event can carry a big jump, and testing only the end position lets a room pass straight through a thin neighbour. The drag binary-searches along the path from the last good position instead, and when the direct move is blocked it slides along one axis, so a diagonal drag ends flush against the wall it hit.
Push two rooms together and Planum works out which span of wall they share, then offers to open exactly that span on both sides. A short room touching the end of a long one only opens the part it touches, so a complicated floor can be put together from simple rooms.
In the browser, on a NAS
There is no account and no backend. Homes, floors and rooms live in localStorage, every import is validated with Zod, and JSON exports move a layout between devices. The models come from the Kenney Furniture Kit, and the hand-written procedural geometry for the rest keeps the 3D view from turning into a field of boxes.
The app is built with TanStack Start and the Cloudflare Vite plugin, but on the NAS the Worker bundle runs under Bun.serve instead of workerd. Consumer NAS CPUs often lack instruction sets workerd needs, and the default preview server simply crashes on them.
Warum Dachzimmer
Ein flacher Grundriss kann dir sagen, dass ein Schrank zwischen zwei Wände passt. Er kann dir nicht sagen, dass die Decke an dieser Stelle 1,20 m hoch ist. Im Dachzimmer ist genau das die Frage, und deshalb gibt es Planum: einen Raum in 2D anlegen, in 3D durchlaufen und sehen, welche Möbel nicht mehr unter die Decke passen.
Zwei Zahlen pro Wand
Eine Schräge wird nicht als Dachgeometrie modelliert. Sie hängt an einer Wand und wird so beschrieben, wie man sie misst: die Höhe des Kniestocks, wo die Decke auf diese Wand trifft, und die waagerechte Tiefe, über die die Decke bis zur vollen Höhe ansteigt. Zwei Zahlen, beide mit dem Zollstock messbar.
interface WallSlope {
kneeHeight: number; // cm, wo die Decke auf diese Wand trifft
run: number; // cm in den Raum hinein bis zur vollen Höhe
}
Daraus beantwortet eine Funktion die einzige Frage, die zählt: Wie hoch darf etwas an dieser Stelle sein? availableHeightAt nimmt das Minimum über alle Schrägen, und dadurch lässt sich das Modell kombinieren. Ein Satteldach sind einfach zwei gegenüberliegende Wände, deren Schrägen zur Mitte hin ansteigen, und wo sich zwei Schrägen überlagern, gewinnt ohne Sonderfall die niedrigere. Es fällt auch sauber zurück: Ein Raum ohne Schrägen ist ein einfacher Quader.
Das Bodenpolygon bleibt unberührt. Grundfläche, Kollision, Nachbarräume und Wandöffnungen funktionieren genau wie im flachen Raum; nichts, was es vor den Schrägen gab, musste etwas über sie lernen.
Abtasten statt ausrechnen
Die echte Decke ist stückweise linear. Jede Schräge ist eine Ebene, die Höhe ist das Minimum darüber, und die Fläche knickt überall dort, wo eine Schräge eine andere überholt oder eine Tiefe endet. Diese Knicke exakt zu berechnen heißt, jedes Ebenenpaar zu schneiden und das Ergebnis auf den Raumumriss zuzuschneiden, und das geht an den Innenecken eines L- oder U-förmigen Raums leicht subtil schief.
Deshalb tasten die 3D-Decke und die Profile, die in die Wände neben einer Schräge geschnitten werden, availableHeightAt auf einem Raster von etwa 15 cm ab. Jeder Knick liegt höchstens einen Schritt neben seiner richtigen Stelle, was in Möbelgröße unsichtbar ist. Eine Regel macht das sicher: Die exakten Endpunkte werden immer mit abgetastet, damit benachbarte Wände sich in der Ecke auf gleicher Höhe treffen. Und weil Wände, Decke und die 2D-Höhenlinien dieselbe Funktion lesen, können sie nicht auseinanderlaufen.
Kollision ohne Durchtunneln
Möbel sind gedrehte Rechtecke, und die Kollision ist der Trennachsensatz auf vier Achsen, den Kantennormalen beider Formen. Einen ganzen Raum in der Etagenübersicht zu ziehen ist etwas anderes: Bei kleinem Zoom kann ein einziges Pointer-Event einen großen Sprung enthalten, und wer nur die Endposition prüft, lässt einen Raum glatt durch einen schmalen Nachbarn wandern. Deshalb sucht das Ziehen binär entlang des Wegs ab der letzten gültigen Position, und ist der direkte Weg blockiert, gleitet es entlang einer Achse, sodass ein diagonales Ziehen bündig an der getroffenen Wand endet.
Schiebt man zwei Räume aneinander, ermittelt Planum, welches Wandstück sie teilen, und bietet an, genau dieses Stück auf beiden Seiten zu öffnen. Ein kurzer Raum am Ende eines langen öffnet nur den Teil, den er berührt. So lässt sich eine komplizierte Etage aus einfachen Räumen zusammensetzen.
Im Browser, auf einem NAS
Es gibt kein Konto und kein Backend. Häuser, Etagen und Räume liegen im localStorage, jeder Import wird mit Zod geprüft, und JSON-Exporte bringen ein Layout auf andere Geräte. Die Modelle stammen aus dem Kenney Furniture Kit, und die handgeschriebene prozedurale Geometrie für den Rest sorgt dafür, dass die 3D-Ansicht kein Feld aus Kisten wird.
Gebaut wird die App mit TanStack Start und dem Cloudflare-Vite-Plugin, aber auf dem NAS läuft das Worker-Bundle unter Bun.serve statt unter workerd. Consumer-NAS-CPUs fehlen oft Befehlssätze, die workerd braucht, und der Standard-Preview-Server stürzt dort einfach ab.