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 7b3b597abc
commit 8fd8987b70
184 changed files with 29421 additions and 669 deletions
+411
View File
@@ -0,0 +1,411 @@
// Geometrie-Helfer für Treppen (Stairs) — reine Funktionen über `Vec2`, kein
// externer Kernel. Aus einer `Stair` (gerade/L/Wendel) werden abgeleitet:
// • die Tritt-Rechtecke im Grundriss (jeweils mit ihrer Z-Höhe der Setzstufe),
// • das Zwischenpodest-Polygon (L / Wendel-Auge),
// • die Lauflinie (Polyline) + der Auf-/Abpfeil,
// • die Schnittposition (welche Tritte unter/über der Grundriss-Schnitthöhe
// liegen → im Plan durchgezogen vs. gestrichelt) samt SIA-Bruchlinie.
//
// Die 3D-Darstellung baut aus denselben Tritt-Rechtecken + Setzstufen-Höhen die
// Stufenboxen (siehe Viewport3D). So bleiben Plan und 3D konsistent aus EINER
// Quelle abgeleitet.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Stair, Vec2 } from "../model/types";
const DEG = Math.PI / 180;
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y });
const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s });
const lenOf = (a: Vec2): number => Math.hypot(a.x, a.y);
const norm = (a: Vec2): Vec2 => {
const l = lenOf(a) || 1e-9;
return { x: a.x / l, y: a.y / l };
};
/** Linke Normale (n = (u.y, u.x)) — quer zur Laufrichtung. */
const leftN = (u: Vec2): Vec2 => ({ x: -u.y, y: u.x });
/** SIA-nahe Schrittregel: gute Steigungshöhe ≈ 0.17 m, Auftritt ≈ 0.29 m. */
export const IDEAL_RISER = 0.17;
export const IDEAL_TREAD = 0.29;
export const MIN_STEPS = 2;
/**
* Sinnvolle Default-Stufenzahl aus Steighöhe + verfügbarer Lauflänge: möglichst
* nahe an der idealen Steigungshöhe, gedeckelt durch die Lauflänge (min.
* Auftritt ~0.24 m). Immer ≥ MIN_STEPS.
*/
export function defaultStepCount(totalRise: number, runLength: number): number {
const byRise = Math.max(MIN_STEPS, Math.round(totalRise / IDEAL_RISER));
// Auftrittstiefe = runLength / (steps 1); mind. 0.24 m halten.
const maxByRun = runLength > 0 ? Math.max(MIN_STEPS, Math.floor(runLength / 0.24) + 1) : byRise;
return Math.max(MIN_STEPS, Math.min(byRise, maxByRun));
}
/** Ein Tritt im Grundriss (Rechteck-Polygon) samt Setzstufen-Z (Oberkante). */
export interface TreadRect {
/** Vier Eckpunkte (CCW), Grundriss-Meter. */
pts: Vec2[];
/** Index der Stufe (0 = unterste). */
index: number;
/** Oberkante dieses Tritts relativ zur Treppen-UK (Meter). */
topRise: number;
/** Unterkante-Höhe (Setzstufe darunter) relativ zur UK (Meter). */
baseRise: number;
}
/** Vollständige, abgeleitete Treppengeometrie (Plan + 3D + Schnitt). */
export interface StairGeometry {
/** Alle Tritt-Rechtecke (untere → obere Stufe). */
treads: TreadRect[];
/** Zwischenpodest-Polygon (L/Wendel) oder null (gerade). */
landing: Vec2[] | null;
/** Setzstufen-Höhe (Meter). */
riserHeight: number;
/** Auftrittstiefe (Meter) des geraden Laufs. */
treadDepth: number;
/** Lauflinie als Polyline (Meter) — Mitte des Laufs, Start → Austritt. */
runLine: Vec2[];
/** Auf-/Abpfeil-Geometrie (Schaft + zwei Spitzenflügel). */
arrow: { shaft: [Vec2, Vec2]; head: [Vec2, Vec2, Vec2] };
/** Gesamt-Steighöhe (Meter). */
totalRise: number;
}
/** Rechteck-Tritt zwischen Achsparametern a0..a1 entlang `dir`, Breite `width`. */
function treadRect(
origin: Vec2,
dir: Vec2,
n: Vec2,
a0: number,
a1: number,
halfW: number,
): Vec2[] {
const p0 = add(origin, scale(dir, a0));
const p1 = add(origin, scale(dir, a1));
return [
add(p0, scale(n, halfW)),
add(p1, scale(n, halfW)),
add(p1, scale(n, -halfW)),
add(p0, scale(n, -halfW)),
];
}
/**
* Leitet die vollständige Geometrie einer Treppe ab. `totalRise` MUSS vom
* Aufrufer aufgelöst übergeben werden (Geschosshöhe-Default liegt in
* `stairVerticalExtent`), damit dieses Modul three-/projektfrei bleibt.
*/
export function stairGeometry(stair: Stair, totalRise: number): StairGeometry {
const steps = Math.max(MIN_STEPS, Math.round(stair.stepCount));
const riserHeight = totalRise / steps;
const halfW = Math.max(0.05, stair.width / 2);
const u = norm(stair.dir);
const n = leftN(u);
if (stair.shape === "spiral") {
return spiralGeometry(stair, totalRise, steps, riserHeight, halfW);
}
if (stair.shape === "L") {
return lGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
}
return straightGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
}
/** Gerade Treppe: gleich breite Tritte über die Lauflänge. */
function straightGeometry(
stair: Stair,
totalRise: number,
steps: number,
riserHeight: number,
halfW: number,
u: Vec2,
n: Vec2,
): StairGeometry {
const runLength = Math.max(0.1, stair.runLength);
const treadDepth = runLength / steps;
const treads: TreadRect[] = [];
for (let i = 0; i < steps; i++) {
treads.push({
pts: treadRect(stair.start, u, n, i * treadDepth, (i + 1) * treadDepth, halfW),
index: i,
baseRise: i * riserHeight,
topRise: (i + 1) * riserHeight,
});
}
const runLine: Vec2[] = [
add(stair.start, scale(u, treadDepth * 0.5)),
add(stair.start, scale(u, runLength - treadDepth * 0.15)),
];
return {
treads,
landing: null,
riserHeight,
treadDepth,
runLine,
arrow: arrowFor(runLine, stair.up !== false),
totalRise,
};
}
/** L-Treppe: zwei Läufe + quadratisches Zwischenpodest an der Ecke. */
function lGeometry(
stair: Stair,
totalRise: number,
steps: number,
riserHeight: number,
halfW: number,
u: Vec2,
n: Vec2,
): StairGeometry {
const run1 = Math.max(0.1, stair.runLength);
const run2 = Math.max(0.1, stair.run2Length ?? stair.runLength);
const turn = stair.turn ?? 1;
// Podest quadratisch (Kantenlänge = Laufbreite) am Ende des ersten Laufs.
const podest = 2 * halfW;
// Stufen auf beide Läufe verteilen (grob proportional zur Lauflänge), ein Tritt
// fällt aufs Podest.
const bodySteps = steps - 1; // ein Tritt = Podest
const s1 = Math.max(1, Math.round((bodySteps * run1) / (run1 + run2)));
const s2 = Math.max(1, bodySteps - s1);
const td1 = run1 / s1;
const td2 = run2 / s2;
const treads: TreadRect[] = [];
let rise = 0;
for (let i = 0; i < s1; i++) {
treads.push({
pts: treadRect(stair.start, u, n, i * td1, (i + 1) * td1, halfW),
index: i,
baseRise: rise,
topRise: rise + riserHeight,
});
rise += riserHeight;
}
// Zweiter Lauf beginnt am Ende des Podests. Richtung um 90° gedreht (turn).
const cornerCenter = add(stair.start, scale(u, run1 + halfW));
const u2: Vec2 = turn > 0 ? n : scale(n, -1); // links/rechts abbiegen
const n2 = leftN(u2);
// Podest-Tritt (quadratisch um cornerCenter, an u/u2 ausgerichtet).
const landing: Vec2[] = [
add(add(cornerCenter, scale(u, -halfW)), scale(n, halfW)),
add(add(cornerCenter, scale(u, halfW)), scale(n, halfW)),
add(add(cornerCenter, scale(u, halfW)), scale(n, -halfW)),
add(add(cornerCenter, scale(u, -halfW)), scale(n, -halfW)),
];
const podestTopRise = rise + riserHeight;
rise += riserHeight;
// Startpunkt des zweiten Laufs: Podest-Kante in Richtung u2.
const run2Start = add(cornerCenter, scale(u2, halfW));
for (let i = 0; i < s2; i++) {
treads.push({
pts: treadRect(run2Start, u2, n2, i * td2, (i + 1) * td2, halfW),
index: s1 + 1 + i,
baseRise: rise,
topRise: rise + riserHeight,
});
rise += riserHeight;
}
void podest;
void podestTopRise;
const runLine: Vec2[] = [
add(stair.start, scale(u, td1 * 0.5)),
cornerCenter,
add(run2Start, scale(u2, run2 - td2 * 0.15)),
];
return {
treads,
landing,
riserHeight,
treadDepth: td1,
runLine,
arrow: arrowFor(runLine, stair.up !== false),
totalRise,
};
}
/** Wendeltreppe: keilförmige Tritte um ein Zentrum (vereinfachtes Modell). */
function spiralGeometry(
stair: Stair,
totalRise: number,
steps: number,
riserHeight: number,
halfW: number,
): StairGeometry {
const center = stair.center ?? stair.start;
const radius = Math.max(halfW + 0.1, stair.radius ?? stair.width);
const sweep = (stair.sweep ?? 270) * DEG;
// Startwinkel aus start→center (Lauflinie beginnt am Startpunkt).
const startVec = sub(stair.start, center);
const a0 = Math.atan2(startVec.y, startVec.x);
const dA = sweep / steps;
const rIn = Math.max(0.02, radius - halfW);
const rOut = radius + halfW;
const treads: TreadRect[] = [];
for (let i = 0; i < steps; i++) {
const t0 = a0 + i * dA;
const t1 = a0 + (i + 1) * dA;
// Keil-Viereck: innen(t0) → außen(t0) → außen(t1) → innen(t1).
const pts: Vec2[] = [
{ x: center.x + rIn * Math.cos(t0), y: center.y + rIn * Math.sin(t0) },
{ x: center.x + rOut * Math.cos(t0), y: center.y + rOut * Math.sin(t0) },
{ x: center.x + rOut * Math.cos(t1), y: center.y + rOut * Math.sin(t1) },
{ x: center.x + rIn * Math.cos(t1), y: center.y + rIn * Math.sin(t1) },
];
treads.push({
pts,
index: i,
baseRise: i * riserHeight,
topRise: (i + 1) * riserHeight,
});
}
// Lauflinie = Bogen auf Radius `radius` (mittlere Lauflinie).
const runLine: Vec2[] = [];
const N = Math.max(2, steps);
for (let i = 0; i <= N; i++) {
const tt = a0 + (sweep * i) / N;
runLine.push({ x: center.x + radius * Math.cos(tt), y: center.y + radius * Math.sin(tt) });
}
return {
treads,
// Wendel-Auge (Innenkreis) als „Podest"-Polygon.
landing: eyePolygon(center, rIn),
riserHeight,
treadDepth: (2 * Math.PI * radius * (sweep / (2 * Math.PI))) / steps,
runLine,
arrow: arrowFor(runLine, stair.up !== false),
totalRise,
};
}
/** Achteck-Approximation des Wendel-Auges (Innenkreis). */
function eyePolygon(center: Vec2, r: number): Vec2[] {
const pts: Vec2[] = [];
for (let i = 0; i < 12; i++) {
const t = (i / 12) * Math.PI * 2;
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
}
return pts;
}
/**
* Auf-/Abpfeil aus der Lauflinie: Schaft = erstes→letztes Segment, Spitze am
* oberen (bzw. bei `up=false` am unteren) Ende. `up` kehrt die Richtung um.
*/
function arrowFor(
runLine: Vec2[],
up: boolean,
): { shaft: [Vec2, Vec2]; head: [Vec2, Vec2, Vec2] } {
const a = runLine[0];
const b = runLine[runLine.length - 1];
const tip = up ? b : a;
const from = up ? runLine[runLine.length - 2] : runLine[1];
const dir = norm(sub(tip, from));
const n = leftN(dir);
const s = 0.22; // Flügellänge (Meter)
const back = add(tip, scale(dir, -s));
const w1 = add(back, scale(n, s * 0.55));
const w2 = add(back, scale(n, -s * 0.55));
return { shaft: [a, b], head: [w1, tip, w2] };
}
/**
* Schnitt-Aufteilung im Grundriss: DOSSIER schneidet die Treppe auf `cutRise`
* (Schnitthöhe über OKFF). Tritte mit Oberkante ≤ cutRise werden durchgezogen
* gezeichnet (unterhalb der Schnittebene), darüberliegende gestrichelt (oberhalb,
* verdeckt). Der ERSTE Tritt oberhalb markiert die SIA-Bruchlinie (diagonaler
* Doppelstrich über die Laufbreite).
*/
export interface StairCut {
/** Indizes der Tritte UNTER der Schnittebene (durchgezogen). */
belowIndices: number[];
/** Indizes der Tritte ÜBER der Schnittebene (gestrichelt/verdeckt). */
aboveIndices: number[];
/** Bruchlinie (SIA): zwei parallele Diagonalstriche über die Laufbreite. */
breakLine: [Vec2, Vec2][] | null;
}
/**
* Bestimmt die Schnittaufteilung. `cutRise` = Schnitthöhe über der Treppen-UK.
* Findet den ersten Tritt, dessen Oberkante die Schnittebene überschreitet, und
* legt dort die Bruchlinie quer über den Lauf.
*/
export function stairCut(geo: StairGeometry, cutRise: number): StairCut {
const below: number[] = [];
const above: number[] = [];
let breakIdx = -1;
for (const tr of geo.treads) {
if (tr.topRise <= cutRise + 1e-6) {
below.push(tr.index);
} else {
if (breakIdx < 0) breakIdx = tr.index;
above.push(tr.index);
}
}
let breakLine: [Vec2, Vec2][] | null = null;
if (breakIdx >= 0) {
const tr = geo.treads.find((t) => t.index === breakIdx);
if (tr) {
// Diagonale Bruchlinie über den Tritt: von einer Ecke zur gegenüberliegenden,
// als Doppelstrich (leicht versetzt) — SIA-Treppen-Schnittsymbol.
const [c0, c1, c2, c3] = tr.pts;
const mid01 = scale(add(c0, c1), 0.5);
const mid23 = scale(add(c2, c3), 0.5);
const off = scale(norm(sub(c1, c0)), 0.06);
breakLine = [
[add(mid01, off), add(mid23, off)],
[sub(mid01, off), sub(mid23, off)],
];
}
}
return { belowIndices: below, aboveIndices: above, breakLine };
}
/** Achsparallele Bounding-Box aller Tritte (+ Podest) einer Treppengeometrie. */
export function stairBBox(geo: StairGeometry): {
minX: number;
minY: number;
maxX: number;
maxY: number;
} {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
const acc = (p: Vec2) => {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
};
for (const tr of geo.treads) for (const p of tr.pts) acc(p);
if (geo.landing) for (const p of geo.landing) acc(p);
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
return { minX, minY, maxX, maxY };
}
/** Punkt-in-Polygon (Ray-Casting) — für die Body-Auswahl im Grundriss/3D. */
export function pointInPolygon(p: Vec2, poly: Vec2[]): boolean {
let inside = false;
const nn = poly.length;
for (let i = 0, j = nn - 1; i < nn; j = i++) {
const a = poly[i];
const b = poly[j];
const intersect =
a.y > p.y !== b.y > p.y &&
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-12) + a.x;
if (intersect) inside = !inside;
}
return inside;
}
/** Ob ein Modellpunkt IRGENDEINEN Tritt (oder das Podest) einer Treppe trifft. */
export function pointHitsStair(p: Vec2, geo: StairGeometry): boolean {
for (const tr of geo.treads) if (pointInPolygon(p, tr.pts)) return true;
if (geo.landing && pointInPolygon(p, geo.landing)) return true;
return false;
}