2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand

Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung
(konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im
Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text-
Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback,
falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform)
fuer fluessige Interaktion ohne React-Re-Render je Frame.

Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM
(Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext-
Import, Tauri-Compute-Boundary-PoC).
This commit is contained in:
2026-07-02 00:12:39 +02:00
parent cfe5249440
commit 3d2d4d6321
184 changed files with 29421 additions and 669 deletions
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect } from "vitest";
import {
defaultRoomStamp,
roomStampToDoc,
roomStampExtraLines,
roomDisplayName,
} from "./roomStamp";
import type { Room } from "./types";
function makeRoom(over: Partial<Room> = {}): Room {
return {
id: "r1",
type: "room",
floorId: "f1",
categoryCode: "45",
siaCategory: "HNF",
name: "Wohnen",
boundary: [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 4, y: 3 },
{ x: 0, y: 3 },
],
color: "#123456",
...over,
};
}
describe("defaultRoomStamp", () => {
it("leitet Name/Flags aus dem Raum ab", () => {
const stamp = defaultRoomStamp(makeRoom());
expect(stamp.name).toBe("Wohnen");
expect(stamp.showFloorArea).toBe(true);
expect(stamp.showUsage).toBe(true);
expect(stamp.number).toBeUndefined();
});
});
describe("roomStampToDoc", () => {
it("eine Zeile ohne nameLine2", () => {
const doc = roomStampToDoc({ name: "Bad", showFloorArea: true, showUsage: true });
expect(doc.paragraphs).toHaveLength(1);
});
it("zwei Zeilen mit nameLine2", () => {
const doc = roomStampToDoc({
name: "Bad",
nameLine2: "WC",
showFloorArea: true,
showUsage: true,
});
expect(doc.paragraphs).toHaveLength(2);
});
it("Nummer als Präfix-Run in Zeile 1", () => {
const doc = roomStampToDoc({
number: "1.01",
name: "Bad",
showFloorArea: true,
showUsage: true,
});
expect(doc.paragraphs[0].runs).toHaveLength(2);
expect(doc.paragraphs[0].runs[0].text).toBe("1.01 ");
expect(doc.paragraphs[0].runs[1].text).toBe("Bad");
});
});
describe("roomStampExtraLines", () => {
it("beachtet Flags und Präfix", () => {
const lines = roomStampExtraLines(
{ name: "Bad", showFloorArea: true, showUsage: true, floorAreaPrefix: "BF " },
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
);
expect(lines[0]).toBe("BF 12.35 m²");
expect(lines[1]).toBe("HNF · Hauptnutzfläche");
});
it("blendet Zeilen aus, wenn Flags falsch", () => {
const lines = roomStampExtraLines(
{ name: "Bad", showFloorArea: false, showUsage: false },
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
);
expect(lines).toHaveLength(0);
});
});
describe("roomDisplayName", () => {
it("faellt auf room.name zurück ohne Stempel", () => {
expect(roomDisplayName(makeRoom())).toBe("Wohnen");
});
it("zieht name + nameLine2 zusammen", () => {
const room = makeRoom({
stamp: { name: "Bad", nameLine2: "WC", showFloorArea: true, showUsage: true },
});
expect(roomDisplayName(room)).toBe("Bad WC");
});
it("nutzt nur name ohne nameLine2", () => {
const room = makeRoom({
stamp: { name: "Küche", showFloorArea: true, showUsage: true },
});
expect(roomDisplayName(room)).toBe("Küche");
});
});
+86
View File
@@ -0,0 +1,86 @@
// Reines Modul für den strukturierten Raum-Stempel (Feldmodell). App-frei und
// unit-getestet: baut aus einem RoomStamp das gerenderte Rich-Text-Dokument
// (roomStampToDoc) und die Live-Zeilen (roomStampExtraLines), und liefert den
// Anzeigenamen für Listen (roomDisplayName). Keine React-/Store-Abhängigkeit.
//
// Folge-Arbeit: Fensterfläche (braucht Öffnung-in-Raum-Geometrie).
import type { Room, RoomStamp } from "./types";
import type { SiaCategory } from "../geometry/roomArea";
import type { RichTextDoc, Paragraph, TextRun } from "../text/richText";
import { makeRun } from "../text/richText";
/** Grösse (pt) des Raumnamens (Zeile 1) relativ zur Stempel-Basis. */
const NAME_PT = 12;
/** Grösse (pt) der kleinen Raumnummer als Präfix. */
const NUMBER_PT = 9;
/** Grösse (pt) der zweiten Namenszeile. */
const LINE2_PT = 9;
/**
* Standard-Stempel aus einem Raum ableiten: Name = Raum-Name, Bodenfläche und
* Nutzung angezeigt. Nummer/Zeile 2 bleiben leer (frei ergänzbar).
*/
export function defaultRoomStamp(room: Room): RoomStamp {
return {
name: room.name,
showFloorArea: true,
showUsage: true,
};
}
/**
* Baut das Rich-Text-Dokument des Stempels:
* • Zeile 1: optionale Raumnummer (kleiner Präfix) + Raumname (grösser, fett).
* • Zeile 2 (optional): nameLine2 (kursiv, kleiner).
* Die Live-Zeilen (Fläche/Nutzung) werden separat als extraLines gesetzt.
*/
export function roomStampToDoc(stamp: RoomStamp): RichTextDoc {
const line1: TextRun[] = [];
const number = (stamp.number ?? "").trim();
if (number) {
line1.push(makeRun(`${number} `, { sizePt: NUMBER_PT }));
}
line1.push(makeRun(stamp.name, { bold: true, sizePt: NAME_PT }));
const paragraphs: Paragraph[] = [{ runs: line1 }];
const line2 = (stamp.nameLine2 ?? "").trim();
if (line2) {
paragraphs.push({ runs: [makeRun(line2, { italic: true, sizePt: LINE2_PT })] });
}
return { version: 1, paragraphs };
}
/** Kontext für die Live-Zeilen (nicht gespeichert, bei jedem Rendern frisch). */
export interface RoomStampContext {
netArea: number;
siaCategory: SiaCategory;
siaLabel: string;
}
/**
* Die Live-Zeilen unter dem Stempel: Bodenfläche (mit Präfix) wenn showFloorArea,
* Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage.
*/
export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): string[] {
const lines: string[] = [];
if (stamp.showFloorArea) {
const prefix = stamp.floorAreaPrefix ?? "";
lines.push(`${prefix}${ctx.netArea.toFixed(2)}`);
}
if (stamp.showUsage) {
lines.push(`${ctx.siaCategory} · ${ctx.siaLabel}`);
}
return lines;
}
/**
* Anzeigename für Listen: strukturierter Name (name + optional nameLine2 zu
* einem Namen zusammengezogen), sonst der Alt-Raumname.
*/
export function roomDisplayName(room: Room): string {
const stamp = room.stamp;
if (!stamp) return room.name;
const line2 = (stamp.nameLine2 ?? "").trim();
return line2 ? `${stamp.name} ${line2}` : stamp.name;
}
+69 -1
View File
@@ -133,9 +133,10 @@ export const sampleProject: Project = {
},
{ code: "30", name: "Decken", color: "#605850", lw: 0.35, visible: true, locked: false },
{ code: "31", name: "Dächer", color: "#7a4a3a", lw: 0.35, visible: true, locked: false },
{ code: "40", name: "Treppen", color: "#4a6a58", lw: 0.35, visible: true, locked: false },
{ code: "35", name: "Träger", color: "#a87858", lw: 0.5, visible: true, locked: false },
{ code: "50", name: "Text", color: "#d0d0d0", lw: 0.13, visible: true, locked: false },
{ code: "60", name: "Plangrafik", color: "#c0a040", lw: 0.13, visible: true, locked: false },
{ code: "60", name: "Räume", color: "#5a7a9a", lw: 0.13, visible: true, locked: false },
{ code: "90", name: "Referenzen", color: "#585860", lw: 0.13, visible: true, locked: false },
{ code: "99", name: "Konstruktion", color: "#404048", lw: 0.13, visible: true, locked: false },
],
@@ -173,6 +174,73 @@ export const sampleProject: Project = {
hinge: "start",
},
],
// Öffnungen (Fenster/Türen) — ein Beispiel-Fenster in der EG-Südwand (W1,
// frei von der Tür) und in der EG-Nordwand (W3). Kategorie „21" (Türen/Fenster).
openings: [
{
id: "O1",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "window",
position: 3.6, // hinter der Tür (2.02.9), noch innerhalb der 5-m-Wand
width: 1.0,
height: 1.2,
sillHeight: 0.9,
},
{
id: "O2",
type: "opening",
hostWallId: "W3",
categoryCode: "21",
kind: "window",
position: 1.8,
width: 1.4,
height: 1.3,
sillHeight: 0.9,
},
],
// Decken (Slabs) — anfangs leer; werden mit dem Decken-Werkzeug gefüllt.
ceilings: [],
// Treppen — eine gerade Beispieltreppe im EG entlang der Ostwand, steigt ins OG
// (totalRise = Geschosshöhe). Kategorie „40 Treppen".
stairs: [
{
id: "ST1",
type: "stair",
floorId: "eg",
categoryCode: "40",
shape: "straight",
start: { x: 3.7, y: 0.4 },
dir: { x: 0, y: 1 },
runLength: 3.2,
width: 1.0,
totalRise: 2.6,
stepCount: 15,
up: true,
},
],
// Räume (SIA-416-Flächen) — ein Beispielraum im EG (lichte Innenkontur des
// 5×4-m-Umrisses, Aussenwände 0.2 m → Innenmaß 4.8×3.8 m ≈ 18.24 m²).
// Kategorie „45 Räume", SIA-Blatt HNF (Hauptnutzfläche).
rooms: [
{
id: "R1",
type: "room",
floorId: "eg",
categoryCode: "60",
siaCategory: "HNF",
name: "Wohnen",
boundary: [
{ x: 0.1, y: 0.1 },
{ x: 4.9, y: 0.1 },
{ x: 4.9, y: 3.9 },
{ x: 0.1, y: 3.9 },
],
color: "#5a7a9a",
stampAnchor: { x: 2.5, y: 2.0 },
},
],
// Freie 2D-Zeichengeometrie — anfangs leer; wird mit den Zeichenwerkzeugen
// gefüllt (docs/design/drawing-tools.md).
drawings2d: [],
+29
View File
@@ -635,6 +635,35 @@ export interface Room {
* Text. Die LIVE-Flächenzeile wird beim Rendern separat darunter gesetzt.
*/
stampDoc?: RichTextDoc;
/**
* Strukturierter Raum-Stempel (Feldmodell). Ist er gesetzt, hat er Vorrang vor
* `stampDoc`/`name`: der Stempel-Text wird aus den Feldern gebaut, die Live-
* Zeilen (Bodenfläche/Nutzung) aus den Flags. Fehlt er, gilt der Alt-Pfad.
*/
stamp?: RoomStamp;
}
/**
* Feldmodell des Raum-Stempels. Ersetzt den freien Text durch benannte Felder;
* daraus baut roomStamp.ts sowohl das gerenderte Rich-Text-Dokument als auch die
* Live-Zeilen. Minimal gehalten (MVP).
*
* Folge-Arbeit: Fensterfläche (braucht Öffnung-in-Raum-Geometrie) — hier bewusst
* NICHT enthalten.
*/
export interface RoomStamp {
/** Raumnummer (kleiner Präfix in Zeile 1). */
number?: string;
/** Raumname (Zeile 1). */
name: string;
/** Raumname Zeile 2 (in Listen mit `name` zu einem Namen zusammengezogen). */
nameLine2?: string;
/** Bodenfläche anzeigen (Live-Zeile). */
showFloorArea: boolean;
/** Präfix vor der Bodenfläche, z. B. "BF " (Default leer). */
floorAreaPrefix?: string;
/** Nutzung (HNF/… · Bezeichnung) anzeigen (Live-Zeile). */
showUsage: boolean;
}
/** Eine Tür, gehostet in einer Wand. Ihr Geschoss ergibt sich aus der Wand. */
+57 -3
View File
@@ -3,10 +3,10 @@
// und die vertikale Ausdehnung (UK..OK). Alle Defaults entsprechen exakt dem
// bisherigen Verhalten, sodass bestehende Wände unverändert rendern.
//
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Project, VerticalAnchor, Wall } from "./types";
import { getFloor } from "./types";
import type { Ceiling, Project, Stair, VerticalAnchor, Wall } from "./types";
import { ceilingThickness, getFloor } from "./types";
/**
* Versatz der Schichtstapelung entlang der linken Normalen `+n` (leftNormal),
@@ -70,6 +70,60 @@ export function wallVerticalExtent(
return { zBottom, zTop };
}
/**
* Vertikale Ausdehnung einer Decke (absolute Z-Werte in Metern).
* • zTop: aus `ceiling.top`, sonst Oberkante des Geschosses
* (baseElevation + floorHeight) — also bündig mit dem Wandkopf.
* • zBottom: zTop Deckendicke (die Decke wächst nach UNTEN).
* Fehlt das Geschoss, dient 0 als sicherer Rückfall für die Oberkante.
*/
export function ceilingVerticalExtent(
project: Project,
ceiling: Ceiling,
): { zBottom: number; zTop: number } {
let floorTop = 0;
try {
const floor = getFloor(project, ceiling.floorId);
floorTop = (floor.baseElevation ?? 0) + (floor.floorHeight ?? 0);
} catch {
floorTop = 0;
}
const zTop = resolveAnchor(project, ceiling.top) ?? floorTop;
const zBottom = zTop - ceilingThickness(project, ceiling);
return { zBottom, zTop };
}
/**
* Vertikale Ausdehnung einer Treppe (absolute Z-Werte in Metern).
* • zBottom: OKFF (baseElevation) des zugehörigen Geschosses.
* • zTop: zBottom + Gesamt-Steighöhe. Fehlt `stair.totalRise`, gilt die
* Geschosshöhe (floorHeight) des Geschosses — so steigt die Treppe genau ins
* nächste Geschoss (OKFF → OKFF).
* Fehlt das Geschoss, dient 0 als sicherer Rückfall für die Unterkante.
*/
export function stairVerticalExtent(
project: Project,
stair: Stair,
): { zBottom: number; zTop: number } {
let base = 0;
let floorHeight = 0;
try {
const floor = getFloor(project, stair.floorId);
base = floor.baseElevation ?? 0;
floorHeight = floor.floorHeight ?? 0;
} catch {
base = 0;
floorHeight = 0;
}
const rise =
stair.totalRise != null && stair.totalRise > 0
? stair.totalRise
: floorHeight > 0
? floorHeight
: 2.6;
return { zBottom: base, zTop: base + rise };
}
/**
* Das Geschoss DIREKT ÜBER dem Wand-Geschoss (in Dokumentreihenfolge der
* "floor"-Ebenen) oder `undefined`, wenn die Wand im obersten Geschoss liegt.