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
+1108 -146
View File
File diff suppressed because it is too large Load Diff
+498 -11
View File
@@ -6,22 +6,36 @@
// Öffnungen) — exakt, schnell, sauber.
import type {
Ceiling,
Drawing2D,
HatchPattern,
LayerCategory,
Opening,
Project,
Room,
Stair,
Vec2,
Wall,
} from "../model/types";
import {
flattenCategories,
getCeilingType,
getComponent,
getHatch,
getLineStyle,
getWallType,
openingsOfWall,
roomsOfFloor,
stairsOfFloor,
wallTypeThickness,
} from "../model/types";
import { wallReferenceOffset } from "../model/wall";
import { evaluateRoom, siaLabel } from "../geometry/roomArea";
import type { RichTextDoc } from "../text/richText";
import { docFromText } from "../text/richText";
import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp";
import { stairVerticalExtent, wallReferenceOffset } from "../model/wall";
import { stairGeometry, stairCut } from "../geometry/stair";
import { doorSymbol, openingInterval, windowSymbol } from "../geometry/opening";
import {
add,
along,
@@ -140,6 +154,17 @@ export type Primitive =
* geschlossene 2D-Form ist (für die Links-Klick-Auswahl der Fläche).
*/
drawingId?: string;
/**
* ID der Decke (Ceiling), falls dieses Polygon eine Decken-Fläche/-Umriss
* ist (für die Links-Klick-Auswahl der Decke).
*/
ceilingId?: string;
/** ID der Öffnung (Fenster/Tür), z. B. der Fensterrahmen (Auswahl). */
openingId?: string;
/** ID der Treppe (Stair), falls dieses Polygon ein Tritt/Podest ist (Auswahl). */
stairId?: string;
/** ID des Raums (Room), falls dieses Polygon eine Raum-Fläche ist (Auswahl). */
roomId?: string;
}
| {
kind: "line";
@@ -154,6 +179,10 @@ export type Primitive =
color?: string;
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl von Drawing2D). */
drawingId?: string;
/** ID der Öffnung (für Links-Klick-Auswahl von Fenster/Tür-Symbolen). */
openingId?: string;
/** ID der Treppe (für Links-Klick-Auswahl von Treppen-Symbollinien). */
stairId?: string;
greyed?: boolean;
}
| {
@@ -167,6 +196,30 @@ export type Primitive =
weightMm: number;
/** Strichmuster in mm Papier; null/undefined = durchgezogen. */
dash?: number[] | null;
/** ID der Öffnung (für Links-Klick-Auswahl von Tür-Schwenkbögen). */
openingId?: string;
greyed?: boolean;
}
| {
kind: "text";
/** Ankerpunkt im Modell (Meter). */
at: Vec2;
/**
* Frei editierbarer Stempel-Text (Rich-Text). Wird zuerst gezeichnet;
* `align` je Absatz steuert die Ausrichtung.
*/
doc: RichTextDoc;
/**
* LIVE-Zusatzzeilen (z. B. Fläche „24.30 m²" + SIA-Tag), UNTER dem Doc
* gezeichnet — unabhängig vom editierten Text, stets aktuell.
*/
extraLines: string[];
/** Basis-Schriftgrösse in PUNKT (für Runs ohne eigene Grösse + Zusatzzeilen). */
basePt: number;
/** Textfarbe (hex) als Default. */
color: string;
/** ID des Raums (Room), zu dem dieser Stempel gehört (Auswahl). */
roomId?: string;
greyed?: boolean;
};
@@ -211,6 +264,8 @@ export function generatePlan(
mono = false,
): Plan {
const primitives: Primitive[] = [];
// Aktives Geschoss (für die Grundriss-Schnitthöhe der Treppen).
const floor = project.drawingLevels.find((z) => z.id === floorId);
// Sichtbarkeit wie bisher; der Darstellungsmodus verfeinert sie zusätzlich:
// render=false blendet die Wand aus, greyed=true dimmt sie.
const walls = project.walls.filter(
@@ -235,20 +290,52 @@ export function generatePlan(
categoryDisplay(d.categoryCode).render,
);
// Decken dieses Geschosses (wie Wände nach sichtbaren Kategorien +
// Darstellungsmodus gefiltert). Eine Decke liegt (als Slab) über der
// Schnittebene → im Grundriss als gefüllter/schraffierter Umriss dargestellt,
// UNTER der Wand-Poché (damit die Wände darüber lesbar bleiben).
const ceilings = (project.ceilings ?? []).filter(
(c) =>
c.floorId === floorId &&
visibleCodes.has(c.categoryCode) &&
categoryDisplay(c.categoryCode).render,
);
for (const ceiling of ceilings) {
const greyed = categoryDisplay(ceiling.categoryCode).greyed;
const lwMm = lwByCode.get(ceiling.categoryCode) ?? WALL_FALLBACK_MM;
addCeilingPoche(primitives, project, ceiling, greyed, detail, lwMm);
}
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
// (Name + Fläche + SIA-Tag). UNTER der Wand-Poché gezeichnet, damit die Wände
// lesbar bleiben. Der Stempel (Text) entfällt beim Detailgrad „grob".
const rooms = roomsOfFloor(project, floorId).filter(
(r) =>
visibleCodes.has(r.categoryCode) && categoryDisplay(r.categoryCode).render,
);
for (const room of rooms) {
const greyed = categoryDisplay(room.categoryCode).greyed;
addRoomArea(primitives, room, greyed, detail);
}
// Gehrungs-Schnittlinien einmal pro Plan berechnen (auf der gefilterten Menge).
const joins = computeJoins(project, walls);
for (const wall of walls) {
const wallGreyed = categoryDisplay(wall.categoryCode).greyed;
const wallLwMm = lwByCode.get(wall.categoryCode) ?? WALL_FALLBACK_MM;
// Türen am Wandhost: die Öffnung bestimmt immer die Wandgeometrie.
// Türen (Legacy) + Öffnungen (Fenster/Türen) am Wandhost: BEIDE sparen die
// Wand-Poché aus. Aus beiden wird eine gemeinsame Lücken-Intervall-Liste.
const doors = project.doors.filter((d) => d.hostWallId === wall.id);
const openings = openingsOfWall(project, wall.id);
const gaps = wallGaps(wall, doors, openings);
const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null };
addWallPoche(primitives, project, wall, doors, cuts, wallGreyed, detail, wallLwMm);
addWallPoche(primitives, project, wall, gaps, cuts, wallGreyed, detail, wallLwMm);
// Referenzlinie: dünne gestrichelte Wand-Achslinie (von der Oberleiste
// umschaltbar). Wird über der Poché gezeichnet, damit sie sichtbar bleibt.
if (showReferenceLines) addReferenceLine(primitives, wall, wallGreyed);
// Tür-Symbol nur, wenn die Tür-Kategorie sichtbar ist und der Modus sie zeigt.
// Tür-Symbol (Legacy-Door) nur, wenn die Kategorie sichtbar ist und der Modus
// sie zeigt.
for (const door of doors) {
const d = categoryDisplay(door.categoryCode);
if (visibleCodes.has(door.categoryCode) && d.render) {
@@ -256,6 +343,28 @@ export function generatePlan(
addDoorSymbol(primitives, wall, door, d.greyed, detail, doorLwMm);
}
}
// Öffnungs-Symbole (Fenster/Tür) je Detailgrad + Auswahl-taggbar.
for (const o of openings) {
const d = categoryDisplay(o.categoryCode);
if (visibleCodes.has(o.categoryCode) && d.render) {
const oLwMm = lwByCode.get(o.categoryCode) ?? LAYER_LINE_MM;
addOpeningSymbol(primitives, project, wall, o, d.greyed, detail, oLwMm);
}
}
}
// Treppen dieses Geschosses: Tritte + Lauflinie + Auf-/Abpfeil, mit dem
// Grundriss-Schnitt (Tritte unter der Schnitthöhe durchgezogen, darüber
// gestrichelt/verdeckt + SIA-Bruchlinie). Über der Wand-Poché gezeichnet.
const cutHeight = floor?.cutHeight ?? 1.0;
const stairs = stairsOfFloor(project, floorId).filter(
(s) =>
visibleCodes.has(s.categoryCode) && categoryDisplay(s.categoryCode).render,
);
for (const stair of stairs) {
const greyed = categoryDisplay(stair.categoryCode).greyed;
const lwMm = lwByCode.get(stair.categoryCode) ?? WALL_FALLBACK_MM;
addStairSymbol(primitives, project, stair, greyed, lwMm, cutHeight);
}
// 2D-Zeichengeometrie zuletzt (über der Poché).
@@ -274,7 +383,10 @@ export function generatePlan(
// Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan.
if (mono) toMono(primitives);
return { primitives, bounds: computeBounds(project, walls, drawings) };
return {
primitives,
bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms),
};
}
/** Dezente Plan-Farbe der Kontext-Konturen (gedämpftes Grau). */
@@ -332,6 +444,8 @@ function toMono(primitives: Primitive[]): void {
p.stroke = MONO_INK;
} else if (p.kind === "line") {
p.color = MONO_INK;
} else if (p.kind === "text") {
p.color = MONO_INK;
}
}
}
@@ -452,11 +566,40 @@ function addDrawing2D(
* joinPriority) mit dicker Umrisslinie. Bewusst vereinfacht (≙ DOSSIER
* „Einfach").
*/
/**
* Ein Öffnungs-Intervall entlang der Wandachse (from..to in Metern). `headHeight`
* = Sturzhöhe (OK der Öffnung über der Wand-UK) für die 3D-Aussparung; im Plan
* ungenutzt, aber Teil des gemeinsamen Vertrags mit dem 3D-Pfad.
*/
export interface WallGap {
from: number;
to: number;
}
/**
* Vereint Legacy-Türen (`Door`) und Öffnungen (`Opening`) einer Wand zu einer
* sortierten Lücken-Intervall-Liste entlang der Wandachse. Entartete Intervalle
* (Breite ≤ 0 oder außerhalb der Achse) werden verworfen.
*/
export function wallGaps(
wall: Wall,
doors: Project["doors"],
openings: Opening[],
): WallGap[] {
const gaps: WallGap[] = [];
for (const d of doors) gaps.push({ from: d.position, to: d.position + d.width });
for (const o of openings) {
const iv = openingInterval(wall, o);
if (iv) gaps.push(iv);
}
return gaps.sort((a, b) => a.from - b.from);
}
function addWallPoche(
out: Primitive[],
project: Project,
wall: Wall,
doors: Project["doors"],
gaps: WallGap[],
cuts: WallCuts,
greyed: boolean,
detail: DetailLevel,
@@ -477,10 +620,8 @@ function addWallPoche(
// Schichtfugen), nicht die Schicht-Füllfarben/Schraffuren.
const stroke = wall.color ?? POCHE_STROKE;
// Öffnungs-Intervalle entlang der Wandachse, sortiert.
const openings = doors
.map((d) => ({ from: d.position, to: d.position + d.width }))
.sort((a, b) => a.from - b.from);
// Öffnungs-Intervalle entlang der Wandachse, sortiert (bereits vereint).
const openings = gaps;
// Wand in Segmente zwischen den Öffnungen zerlegen.
let cursor = 0;
@@ -634,6 +775,105 @@ function addDoorSymbol(
}
}
/**
* Öffnungs-Symbol (Fenster/Tür) je Detailgrad. Anders als das Legacy-`addDoorSymbol`
* arbeitet diese Funktion über die reinen Geometrie-Helfer (opening.ts) und taggt
* ihre Primitive mit `openingId`, sodass die Öffnung im Plan selektierbar ist.
*
* Tür:
* • grob — gerade Türblatt-Linie (die Lücke kommt aus der ausgesparten Poché).
* • mittel — + Schwenkbogen (Öffnungswinkel).
* • fein — + Anschlag-Striche an beiden Pfosten.
* Fenster:
* • grob — eine einzelne Glaslinie über die Lücke.
* • mittel — Rahmen-Rechteck (Umriss) + eine Glaslinie.
* • fein — Rahmen + zwei Glaslinien (Doppelverglasung angedeutet).
*/
function addOpeningSymbol(
out: Primitive[],
project: Project,
wall: Wall,
o: Opening,
greyed: boolean,
detail: DetailLevel,
lwMm: number,
): void {
if (o.kind === "door") {
const sym = doorSymbol(wall, o);
if (!sym) return;
// Türblatt (alle Stufen).
out.push({
kind: "line",
a: sym.hinge,
b: sym.openEnd,
cls: "door-leaf",
weightMm: lwMm,
greyed,
openingId: o.id,
});
// mittel + fein: Schwenkbogen von geschlossen → offen.
if (detail !== "grob") {
out.push({
kind: "arc",
center: sym.hinge,
from: sym.closedEnd,
to: sym.openEnd,
r: sym.radius,
cls: "door-swing",
weightMm: lwMm * 0.6,
dash: [0.06, 0.04],
greyed,
openingId: o.id,
});
}
// fein: Anschlag-Striche an beiden Pfosten (quer zur Wand).
if (detail === "fein") {
const reveal = 0.06;
for (const jamb of [sym.jambStart, sym.jambEnd]) {
out.push({
kind: "line",
a: add(jamb, scale(sym.normal, reveal)),
b: add(jamb, scale(sym.normal, -reveal)),
cls: "door-frame",
weightMm: lwMm,
greyed,
openingId: o.id,
});
}
}
return;
}
// Fenster: Rahmen (mittel/fein) + Glaslinie(n) je Detailgrad.
const glassCount = detail === "fein" ? 2 : 1;
const sym = windowSymbol(project, wall, o, glassCount);
if (!sym) return;
if (detail !== "grob") {
// Rahmen-Rechteck als reine Umrisslinie (Poché-Stroke, keine Füllung).
out.push({
kind: "polygon",
pts: sym.frame,
fill: "none",
stroke: o.color ?? POCHE_STROKE,
strokeWidthMm: lwMm,
hatch: NO_HATCH,
greyed,
openingId: o.id,
});
}
for (const [a, b] of sym.glassLines) {
out.push({
kind: "line",
a,
b,
cls: "window-glass",
weightMm: lwMm * 0.7,
greyed,
openingId: o.id,
});
}
}
/** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */
const NO_HATCH: HatchRender = {
pattern: "none",
@@ -660,9 +900,248 @@ function addReferenceLine(out: Primitive[], wall: Wall, greyed: boolean): void {
});
}
/**
* Decken-Poché im Grundriss: die Decke liegt als Slab ÜBER der Schnittebene und
* wird daher als geschlossener Umriss ihres Grundriss-Polygons gezeichnet — eine
* gefüllte + schraffierte Fläche plus eine kräftige Umrisslinie. Fill/Schraffur
* stammen aus dem BAUTEIL der ersten (obersten) Schicht des Aufbau-Typs (analog
* zur Wand-Schichtauflösung); die Umrissfarbe aus der optionalen Decken-Farbe
* bzw. dem Poché-Strich. Jedes Polygon trägt die `ceilingId` für die Auswahl.
*
* Im Detailgrad „grob" entfällt die Schraffur (nur Fläche + Umriss); „mittel"/
* „fein" zeigen die Component-Schraffur. `lwMm` ist die Kategorie-Strichstärke.
*/
function addCeilingPoche(
out: Primitive[],
project: Project,
ceiling: Ceiling,
greyed: boolean,
detail: DetailLevel,
lwMm: number,
): void {
const pts = ceiling.outline;
if (pts.length < 3) return;
const wt = getCeilingType(project, ceiling);
const comp = wt.layers.length > 0 ? getComponent(project, wt.layers[0].componentId) : null;
const stroke = ceiling.color ?? POCHE_STROKE;
const outlineMm = lwMm * OUTLINE_DETAIL_FACTOR[detail];
const ceilingId = ceiling.id;
// Gefüllte + schraffierte Fläche (Component-Poché). Im Detailgrad „grob" ohne
// Schraffur (reine Füllung), sonst die Bauteil-Schraffur.
out.push({
kind: "polygon",
pts,
fill: comp ? comp.color : "none",
stroke,
strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
hatch: comp && detail !== "grob" ? resolveHatch(project, comp.hatchId) : NO_HATCH,
greyed,
ceilingId,
});
// Kräftige Umrisslinie über die volle Decke (nur Kontur, keine Füllung).
out.push({
kind: "polygon",
pts,
fill: "none",
stroke,
strokeWidthMm: outlineMm,
hatch: NO_HATCH,
greyed,
ceilingId,
});
}
/** Deckkraft (0..255) der transluzenten Raum-Füllung → 2-stelliges Hex-Suffix. */
const ROOM_FILL_ALPHA = "33"; // ~20 % Deckkraft
/** Basis-Schriftgrösse des Raum-Stempels in Punkt (pt). */
const ROOM_STAMP_PT = 10;
/** Farbe mit Alpha-Suffix versehen (nur, wenn `color` ein 6-stelliges Hex ist). */
function withAlpha(color: string, alphaHex: string): string {
return /^#[0-9a-fA-F]{6}$/.test(color) ? `${color}${alphaHex}` : color;
}
/**
* Raum (SIA-416-Fläche) im Grundriss: eine transluzente farbige Füllfläche über
* dem lichten Umriss + eine dünne Umrisslinie (beide `roomId`-getaggt für die
* Auswahl) + ein Raum-Stempel (Text) am Anker (Centroid oder `stampAnchor`) mit
* Name, Fläche „24.30 m²" und SIA-Kürzel. Der Stempel entfällt beim Detailgrad
* „grob"; Fläche/Umfang/Schwerpunkt werden aus `boundary` abgeleitet (nie
* gespeichert). Wird UNTER der Wand-Poché gezeichnet.
*/
function addRoomArea(
out: Primitive[],
room: Room,
greyed: boolean,
detail: DetailLevel,
): void {
const pts = room.boundary;
if (pts.length < 3) return;
const res = evaluateRoom(pts, room.siaCategory);
const stroke = room.color;
// Transluzente Farbfüllung.
out.push({
kind: "polygon",
pts,
fill: withAlpha(room.color, ROOM_FILL_ALPHA),
stroke,
strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
hatch: NO_HATCH,
greyed,
roomId: room.id,
});
// Raum-Stempel: frei editierbarer Rich-Text (Name/Notizen) + LIVE-Flächenzeile
// + SIA-Kürzel. Anker fix (stampAnchor, sonst Centroid). Nur ab Detailgrad
// „mittel". Die Flächenzeile aktualisiert sich unabhängig vom editierten Text.
if (detail !== "grob") {
const at = room.stampAnchor ?? res.centroid;
// Strukturierter Stempel (Feldmodell) hat Vorrang; sonst Alt-Pfad (Rich-Text
// bzw. Raum-Name als einfacher Text).
const doc = room.stamp
? roomStampToDoc(room.stamp)
: room.stampDoc ?? docFromText(room.name);
const extraLines = room.stamp
? roomStampExtraLines(room.stamp, {
netArea: res.netArea,
siaCategory: room.siaCategory,
siaLabel: siaLabel(room.siaCategory),
})
: [
`${res.netArea.toFixed(2)}`,
`${room.siaCategory} · ${siaLabel(room.siaCategory)}`,
];
out.push({
kind: "text",
at,
doc,
extraLines,
basePt: ROOM_STAMP_PT,
color: stroke,
roomId: room.id,
greyed,
});
}
}
/**
* Treppen-Symbol im Grundriss (DOSSIER/SIA):
* • Tritte als Rechtecke: die Tritt-Trennlinien werden gezeichnet — unter der
* Schnittebene DURCHGEZOGEN (sichtbar), darüber GESTRICHELT (verdeckt).
* • Der erste Tritt oberhalb der Schnittebene trägt die diagonale SIA-Bruchlinie
* (Doppelstrich quer über den Lauf).
* • Zwischenpodest (L/Wendel) als Umriss-Polygon.
* • Lauflinie (Lauflinie) mit Auf-/Abpfeil (Auf-/Abpfeil) und Stufenanzahl-Label.
* Alle Primitive tragen `stairId` für die Auswahl.
*/
function addStairSymbol(
out: Primitive[],
project: Project,
stair: Stair,
greyed: boolean,
lwMm: number,
cutHeight: number,
): void {
const { zBottom, zTop } = stairVerticalExtent(project, stair);
const totalRise = zTop - zBottom;
const geo = stairGeometry(stair, totalRise);
const cut = stairCut(geo, cutHeight);
const below = new Set(cut.belowIndices);
const stroke = stair.color ?? POCHE_STROKE;
const stairId = stair.id;
// Umriss + Tritt-Trennlinien je Stufe. Der letzte Punkt jedes Tritt-Rechtecks
// liefert die vordere Kante (Auftritt); wir zeichnen jede Tritt-Kante.
for (const tr of geo.treads) {
const isBelow = below.has(tr.index);
// Tritt-Rechteck als reine Umrisslinie (keine Füllung), durchgezogen wenn
// unter der Schnittebene, sonst gestrichelt (verdeckt).
out.push({
kind: "polygon",
pts: tr.pts,
fill: "none",
stroke,
strokeWidthMm: lwMm * 0.7,
hatch: NO_HATCH,
greyed,
stairId,
});
// Zusätzliche Auftrittskante (vordere Kante der Stufe) etwas kräftiger.
const front: [Vec2, Vec2] = [tr.pts[1], tr.pts[2]];
out.push({
kind: "line",
a: front[0],
b: front[1],
cls: "stair-tread",
weightMm: lwMm * (isBelow ? 1.0 : 0.6),
dash: isBelow ? null : [0.12, 0.08],
color: stroke,
greyed,
stairId,
});
}
// Zwischenpodest (L/Wendel-Auge): Umriss-Polygon.
if (geo.landing && geo.landing.length >= 3) {
out.push({
kind: "polygon",
pts: geo.landing,
fill: "none",
stroke,
strokeWidthMm: lwMm * 0.7,
hatch: NO_HATCH,
greyed,
stairId,
});
}
// SIA-Bruchlinie (Doppelstrich diagonal über den Schnitt-Tritt).
if (cut.breakLine) {
for (const [a, b] of cut.breakLine) {
out.push({
kind: "line",
a,
b,
cls: "stair-break",
weightMm: lwMm,
color: stroke,
greyed,
stairId,
});
}
}
// Lauflinie (Lauflinie) — kräftig, durchgezogen.
for (let i = 0; i < geo.runLine.length - 1; i++) {
out.push({
kind: "line",
a: geo.runLine[i],
b: geo.runLine[i + 1],
cls: "stair-run",
weightMm: lwMm,
color: stroke,
greyed,
stairId,
});
}
// Auf-/Abpfeil an der Spitze der Lauflinie.
const [h1, tip, h2] = geo.arrow.head;
out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
}
const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y);
function computeBounds(project: Project, walls: Wall[], drawings: Drawing2D[] = []) {
function computeBounds(
project: Project,
walls: Wall[],
drawings: Drawing2D[] = [],
ceilings: Ceiling[] = [],
stairs: Stair[] = [],
rooms: Room[] = [],
) {
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);
@@ -672,6 +1151,14 @@ function computeBounds(project: Project, walls: Wall[], drawings: Drawing2D[] =
const t = wallTypeThickness(getWallType(project, w));
for (const c of wallCorners(w.start, w.end, t)) acc(c);
}
for (const c of ceilings) for (const p of c.outline) acc(p);
for (const r of rooms) for (const p of r.boundary) acc(p);
for (const s of stairs) {
const { zBottom, zTop } = stairVerticalExtent(project, s);
const geo = stairGeometry(s, zTop - zBottom);
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);
}
for (const d of drawings) {
const g = d.geom;
if (g.shape === "line") { acc(g.a); acc(g.b); }
+111
View File
@@ -0,0 +1,111 @@
/**
* Unit-Tests für die GPU-Tessellierung: Ear-Clipping (konvex + konkav) und
* Bildschirm-Raum-Abbildung.
*/
import { describe, it, expect } from 'vitest';
import { triangulate } from './glPlanCompile';
import { computeOrthoMatrix } from './glPlanRender';
import type { Vec2 } from '../../model/types';
/** Summierte Dreiecksfläche (Betrag) aus Indizes über pts. */
function triArea(pts: Vec2[], idx: number[]): number {
let area = 0;
for (let i = 0; i < idx.length; i += 3) {
const a = pts[idx[i]];
const b = pts[idx[i + 1]];
const c = pts[idx[i + 2]];
area += Math.abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) / 2;
}
return area;
}
/** Polygon-Fläche (Shoelace, Betrag). */
function polyArea(pts: Vec2[]): number {
let a = 0;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
a += (pts[j].x + pts[i].x) * (pts[j].y - pts[i].y);
}
return Math.abs(a) / 2;
}
describe('triangulate', () => {
it('trianguliert ein Quadrat (2 Dreiecke, volle Fläche)', () => {
const sq: Vec2[] = [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 4, y: 4 },
{ x: 0, y: 4 },
];
const idx = triangulate(sq);
expect(idx.length).toBe(6); // 2 Dreiecke
expect(triArea(sq, idx)).toBeCloseTo(polyArea(sq), 6);
});
it('trianguliert ein KONKAVES L-Polygon flächentreu (Fan wäre falsch)', () => {
// L-Form: konkave Ecke bei (2,2).
const L: Vec2[] = [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 4, y: 2 },
{ x: 2, y: 2 },
{ x: 2, y: 4 },
{ x: 0, y: 4 },
];
const idx = triangulate(L);
// 6 Ecken → 4 Dreiecke.
expect(idx.length).toBe(12);
// Flächentreu: Summe der Dreiecke == Polygonfläche (12). Ein Fan über die
// konkave Ecke würde deutlich mehr Fläche (außerhalb des L) erzeugen.
expect(triArea(L, idx)).toBeCloseTo(polyArea(L), 6);
expect(triArea(L, idx)).toBeCloseTo(12, 6);
});
it('liefert [] bei <3 Ecken', () => {
expect(triangulate([])).toEqual([]);
expect(triangulate([{ x: 0, y: 0 }])).toEqual([]);
expect(triangulate([{ x: 0, y: 0 }, { x: 1, y: 1 }])).toEqual([]);
});
it('behandelt beide Wicklungsrichtungen (CW + CCW) gleich', () => {
const cw: Vec2[] = [
{ x: 0, y: 0 },
{ x: 0, y: 4 },
{ x: 4, y: 4 },
{ x: 4, y: 0 },
];
const idx = triangulate(cw);
expect(idx.length).toBe(6);
expect(triArea(cw, idx)).toBeCloseTo(polyArea(cw), 6);
});
});
describe('computeOrthoMatrix', () => {
it('liefert eine 4×4-Matrix', () => {
const m = computeOrthoMatrix({ x: 0, y: 0, w: 100, h: 100 }, 100, 100);
expect(m).toBeInstanceOf(Float32Array);
expect(m.length).toBe(16);
});
it('bildet die viewBox-Ecken bei passendem Aspekt auf Clip [-1,1] ab', () => {
// Quadratischer viewBox + quadratisches Canvas → keine Aspekt-Dehnung.
const m = computeOrthoMatrix({ x: 0, y: 0, w: 100, h: 100 }, 100, 100);
// Bildschirm-Punkt (0,0) = obere-linke Ecke → Clip (-1, +1).
const clip = (x: number, y: number) => ({
x: m[0] * x + m[4] * y + m[12],
y: m[1] * x + m[5] * y + m[13],
});
const tl = clip(0, 0);
expect(tl.x).toBeCloseTo(-1, 5);
expect(tl.y).toBeCloseTo(1, 5);
const br = clip(100, 100);
expect(br.x).toBeCloseTo(1, 5);
expect(br.y).toBeCloseTo(-1, 5);
});
it('verschiedene viewBoxes → verschiedene Matrizen', () => {
const a = computeOrthoMatrix({ x: 0, y: 0, w: 100, h: 100 }, 100, 100);
const b = computeOrthoMatrix({ x: 50, y: 50, w: 200, h: 200 }, 100, 100);
expect([...a].every((v, i) => v === b[i])).toBe(false);
});
});
+334
View File
@@ -0,0 +1,334 @@
/**
* Tessellierung: wandelt Plan-`Primitive` in GPU-fertige Puffer.
* • Polygone → echtes Ear-Clipping (konkav-fähig), Bildschirm-Raum-Dreiecke
* • Linien → Quad mit Normale + Seiten-Flag (bildschirmkonstante Breite)
*
* Koordinaten: alles wird in BILDSCHIRM-Raum abgelegt (wie `toScreen`):
* sx = mx·PX_PER_M, sy = -my·PX_PER_M.
* Damit ist die Projektion eine reine viewBox-Orthografie (siehe glPlanRender).
*/
import type { Primitive } from '../generatePlan';
import type { Vec2 } from '../../model/types';
import type { GpuGeometry, Rgba } from './glPlanTypes';
const PX_PER_M = 90;
/** Modell-Meter → Bildschirm-Raum (identisch zu PlanView.toScreen). */
function toScreen(p: Vec2): Vec2 {
return { x: p.x * PX_PER_M, y: -p.y * PX_PER_M };
}
/** Signierte Fläche (Shoelace); >0 = CCW (Modell-Y nach oben). */
function signedArea(pts: Vec2[]): number {
let a = 0;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
a += pts[j].x * pts[i].y - pts[i].x * pts[j].y;
}
return a / 2;
}
/** Kreuzprodukt (b-a)×(c-a). */
function cross(a: Vec2, b: Vec2, c: Vec2): number {
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
}
/** Liegt p im (a,b,c)-Dreieck? (CCW-orientiert). */
function pointInTri(a: Vec2, b: Vec2, c: Vec2, p: Vec2): boolean {
const d1 = cross(a, b, p);
const d2 = cross(b, c, p);
const d3 = cross(c, a, p);
const hasNeg = d1 < 0 || d2 < 0 || d3 < 0;
const hasPos = d1 > 0 || d2 > 0 || d3 > 0;
return !(hasNeg && hasPos);
}
/**
* Ear-Clipping-Triangulierung eines einfachen (lochfreien) Polygons. Robust für
* konvexe UND konkave Ringe. O(n²) — für Plan-Polygone (wenige Ecken) völlig
* ausreichend. Gibt Dreiecks-Indizes (0-basiert auf `pts`) zurück; [] bei <3
* Ecken oder Degeneration.
*/
export function triangulate(pts: Vec2[]): number[] {
const n = pts.length;
if (n < 3) return [];
// Ohr-Test unten nutzt cross>0 = konvex, was CCW voraussetzt. Bei CW-Polygonen
// die Index-Reihenfolge umdrehen (Triangulierung ist raum-affin-invariant, die
// Indizes gelten danach auch für die Bildschirm-Raum-Vertices).
const idx: number[] = [];
for (let i = 0; i < n; i++) idx.push(i);
if (signedArea(pts) < 0) idx.reverse(); // <0 = CW → auf CCW drehen
const tris: number[] = [];
let guard = 0;
const maxGuard = n * n + 16;
while (idx.length > 3 && guard++ < maxGuard) {
let clipped = false;
for (let i = 0; i < idx.length; i++) {
const iPrev = idx[(i + idx.length - 1) % idx.length];
const iCur = idx[i];
const iNext = idx[(i + 1) % idx.length];
const a = pts[iPrev];
const b = pts[iCur];
const c = pts[iNext];
// Konvexe Ecke? (bei CCW: cross > 0)
if (cross(a, b, c) <= 0) continue;
// Kein anderer Vertex im Ohr?
let contains = false;
for (let k = 0; k < idx.length; k++) {
const vi = idx[k];
if (vi === iPrev || vi === iCur || vi === iNext) continue;
if (pointInTri(a, b, c, pts[vi])) {
contains = true;
break;
}
}
if (contains) continue;
// Ohr abschneiden.
tris.push(iPrev, iCur, iNext);
idx.splice(i, 1);
clipped = true;
break;
}
if (!clipped) break; // Degeneriert → abbrechen (kein Absturz).
}
if (idx.length === 3) tris.push(idx[0], idx[1], idx[2]);
return tris;
}
/**
* Farbe → RGBA[0..1]; ungültig/"none"/"transparent" → null.
* Unterstützt "#rgb", "#rrggbb", "#rrggbbaa" und "rgb()/rgba()".
*/
function parseColor(hex: string | undefined): Rgba | null {
if (!hex) return null;
const s = hex.trim().toLowerCase();
if (s === 'none' || s === 'transparent') return null;
if (s.startsWith('rgb')) {
const m = s.match(/[\d.]+/g);
if (!m || m.length < 3) return null;
const a = m.length >= 4 ? parseFloat(m[3]) : 1;
return [+m[0] / 255, +m[1] / 255, +m[2] / 255, a > 1 ? a / 255 : a];
}
let h = s[0] === '#' ? s.slice(1) : s;
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
if (h.length !== 6 && h.length !== 8) return null;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
return [r / 255, g / 255, b / 255, a];
}
/**
* Tessellliert Plan-Primitive zu GPU-Geometrie (MVP: gefüllte Polygone +
* bildschirmkonstante Striche). Text/Bögen bleiben im SVG-Overlay.
*/
export function compilePrimitivesToGpu(
gl: WebGL2RenderingContext,
primitives: Primitive[],
): GpuGeometry {
// Füll-Puffer (Bildschirm-Raum Positionen + Indizes).
const fillPos: number[] = [];
const fillIdx: number[] = [];
const fillBatches: GpuGeometry['fill']['batches'] = [];
// Linien-Puffer (interleaved [x,y, nx,ny, side]).
const lineVerts: number[] = [];
const lineIdx: number[] = [];
const lineBatches: GpuGeometry['line']['batches'] = [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const track = (mx: number, my: number) => {
if (mx < minX) minX = mx;
if (my < minY) minY = my;
if (mx > maxX) maxX = mx;
if (my > maxY) maxY = my;
};
const sameRgba = (a: Rgba, b: Rgba): boolean =>
a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];
// Ordnungserhaltendes Batch-Merging: aufeinanderfolgende Indizes gleicher Farbe
// (+ Breite bei Linien) werden zu EINEM Draw-Call zusammengefasst (Reihenfolge
// bleibt exakt → korrekte Z-/Alpha-Überlagerung, nur viel weniger State-Wechsel).
const addFillBatch = (count: number, color: Rgba) => {
const last = fillBatches[fillBatches.length - 1];
if (last && sameRgba(last.color, color)) last.indexCount += count;
else fillBatches.push({ startIndex: fillIdx.length - count, indexCount: count, color });
};
const addLineBatch = (count: number, color: Rgba, strokeMm: number) => {
const last = lineBatches[lineBatches.length - 1];
if (last && last.strokeMm === strokeMm && sameRgba(last.color, color))
last.indexCount += count;
else lineBatches.push({ startIndex: lineIdx.length - count, indexCount: count, color, strokeMm });
};
/** Maximaler Miter-Längenfaktor; darüber wird geklemmt (kein Spike an spitzen Ecken). */
const MITER_LIMIT = 4;
/**
* Zeichnet einen zusammenhängenden Linienzug (Bildschirm-Raum) als EINEN
* gehrten Streifen: an jedem Stützpunkt wird der Versatz entlang des Miter-
* Bisektors verlängert (1/cos(θ/2)), sodass benachbarte Segmente bündig
* verschmelzen → gehrte Ecke statt Butt-Cap-Stufe. `closed` schließt den Ring
* (letzter↔erster Punkt). Vertex-Layout: [x,y, bx,by, side, miter].
*/
const strokePolyline = (ptsM: Vec2[], closed: boolean, color: Rgba, strokeMm: number) => {
// Auf Bildschirm-Raum abbilden + aufeinanderfolgende Duplikate entfernen.
const S: Vec2[] = [];
for (const p of ptsM) {
const s = toScreen(p);
if (S.length && Math.abs(S[S.length - 1].x - s.x) < 1e-6 && Math.abs(S[S.length - 1].y - s.y) < 1e-6)
continue;
S.push(s);
track(p.x, p.y);
}
if (closed && S.length > 1) {
const f = S[0], l = S[S.length - 1];
if (Math.abs(f.x - l.x) < 1e-6 && Math.abs(f.y - l.y) < 1e-6) S.pop();
}
const k = S.length;
if (k < 2) return;
const leftNormal = (from: Vec2, to: Vec2): Vec2 | null => {
const dx = to.x - from.x, dy = to.y - from.y;
const len = Math.hypot(dx, dy);
return len < 1e-6 ? null : { x: -dy / len, y: dx / len };
};
const base = lineVerts.length / 6;
for (let i = 0; i < k; i++) {
const hasIn = closed || i > 0;
const hasOut = closed || i < k - 1;
const nIn = hasIn ? leftNormal(S[(i - 1 + k) % k], S[i]) : null;
const nOut = hasOut ? leftNormal(S[i], S[(i + 1) % k]) : null;
let bx: number, by: number, miter: number;
if (nIn && nOut) {
let sx = nIn.x + nOut.x, sy = nIn.y + nOut.y;
const slen = Math.hypot(sx, sy);
if (slen < 1e-3) {
// ~180°-Umkehr → kein sinnvoller Bisektor, gerade weiterlaufen.
bx = nOut.x; by = nOut.y; miter = 1;
} else {
bx = sx / slen; by = sy / slen;
const denom = bx * nOut.x + by * nOut.y; // cos(θ/2)
miter = denom > 1e-3 ? Math.min(1 / denom, MITER_LIMIT) : 1;
}
} else {
const n = nIn ?? nOut!;
bx = n.x; by = n.y; miter = 1;
}
lineVerts.push(S[i].x, S[i].y, bx, by, +1, miter);
lineVerts.push(S[i].x, S[i].y, bx, by, -1, miter);
}
const segs = closed ? k : k - 1;
for (let i = 0; i < segs; i++) {
const a = base + 2 * i;
const b = base + 2 * ((i + 1) % k);
lineIdx.push(a, a + 1, b, a + 1, b + 1, b);
}
addLineBatch(segs * 6, color, strokeMm);
};
/** Einzelnes Segment als (ungehrter) 2-Punkt-Zug. */
const pushLine = (aM: Vec2, bM: Vec2, color: Rgba, strokeMm: number) =>
strokePolyline([aM, bM], false, color, strokeMm);
for (const prim of primitives) {
if (prim.kind === 'polygon') {
// Füllung (falls vorhanden).
const fill = parseColor(prim.fill);
if (fill) {
const tris = triangulate(prim.pts);
if (tris.length) {
const base = fillPos.length / 2;
for (const pt of prim.pts) {
const s = toScreen(pt);
fillPos.push(s.x, s.y);
track(pt.x, pt.y);
}
for (const t of tris) fillIdx.push(base + t);
addFillBatch(tris.length, fill);
}
}
// Umriss (crispe Kante) — geschlossener Ring, GEHRT (kein Stufen-Cap an
// Ecken). Breite = ECHTE Papier-mm; der Renderer rechnet massstabs-/
// zoomrichtig in px (wie SVG-printStrokeVb → GL == SVG).
const stroke = parseColor(prim.stroke);
if (stroke && prim.strokeWidthMm > 0 && prim.pts.length >= 2) {
strokePolyline(prim.pts, true, stroke, prim.strokeWidthMm);
}
} else if (prim.kind === 'line') {
const color = parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1];
pushLine(prim.a, prim.b, color, prim.weightMm || 0.18);
} else if (prim.kind === 'arc') {
// Bogen → ein gehrter Polylinienzug (glatt, keine Segment-Stufen).
const color = parseColor((prim as { color?: string }).color) ?? [0.1, 0.1, 0.1, 1];
const strokeMm = prim.weightMm || 0.18;
const c = prim.center;
const a0 = Math.atan2(prim.from.y - c.y, prim.from.x - c.x);
const a1 = Math.atan2(prim.to.y - c.y, prim.to.x - c.x);
// Kürzeste Drehrichtung (Delta nach (-π, π]).
let d = a1 - a0;
while (d <= -Math.PI) d += 2 * Math.PI;
while (d > Math.PI) d -= 2 * Math.PI;
const segs = Math.max(4, Math.ceil((Math.abs(d) / (Math.PI / 2)) * 16));
const arcPts: Vec2[] = [prim.from];
for (let i = 1; i <= segs; i++) {
const t = a0 + (d * i) / segs;
arcPts.push({ x: c.x + prim.r * Math.cos(t), y: c.y + prim.r * Math.sin(t) });
}
strokePolyline(arcPts, false, color, strokeMm);
}
// Text: bleibt im SVG-Overlay (scharfe Schrift, DOM-Hit-Test).
}
// Puffer hochladen.
const uploadArray = (data: number[]): WebGLBuffer | null => {
if (!data.length) return null;
const buf = gl.createBuffer();
if (!buf) return null;
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW);
return buf;
};
const uploadIndex = (data: number[]): WebGLBuffer | null => {
if (!data.length) return null;
const buf = gl.createBuffer();
if (!buf) return null;
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buf);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(data), gl.STATIC_DRAW);
return buf;
};
return {
fill: {
positionBuffer: uploadArray(fillPos),
indexBuffer: uploadIndex(fillIdx),
batches: fillBatches,
},
line: {
vertexBuffer: uploadArray(lineVerts),
indexBuffer: uploadIndex(lineIdx),
batches: lineBatches,
},
bounds: {
minX: isFinite(minX) ? minX : 0,
minY: isFinite(minY) ? minY : 0,
maxX: isFinite(maxX) ? maxX : 1,
maxY: isFinite(maxY) ? maxY : 1,
},
};
}
+261
View File
@@ -0,0 +1,261 @@
/**
* WebGL2-Render-Loop des GPU-Grundrisses.
*
* Zeichnet zwischengespeicherte GPU-Geometrie (aus glPlanCompile) und aktualisiert
* beim Pan/Zoom NUR die Projektionsmatrix (kein Re-Tessellieren). Getrieben vom
* Aufrufer (PlanView-Pan/Zoom-Handler), nicht per requestAnimationFrame.
*/
import type { GpuGeometry, ShaderProgram, ViewBox } from './glPlanTypes';
/** viewBox-Einheiten je Meter (identisch zu PlanView/toScreen). */
const PX_PER_M = 90;
/** 4×4-Einheitsmatrix (Spalten-Major, WebGL-Konvention). */
function mat4Identity(): Float32Array {
const m = new Float32Array(16);
m[0] = 1;
m[5] = 1;
m[10] = 1;
m[15] = 1;
return m;
}
/**
* Orthografische Projektion [left,right]×[bottom,top] → Clip [-1,1].
* Spalten-Major.
*/
function mat4Ortho(
left: number,
right: number,
bottom: number,
top: number,
): Float32Array {
const m = mat4Identity();
const w = right - left;
const h = top - bottom;
m[0] = 2 / w;
m[5] = 2 / h;
m[10] = -1;
m[12] = -(right + left) / w;
m[13] = -(top + bottom) / h;
return m;
}
/** viewBox-Gleichheit (Cache-Invalidierung). */
function viewBoxEqual(a: ViewBox, b: ViewBox): boolean {
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
}
/**
* Projektionsmatrix für einen viewBox, aspekt-korrigiert wie SVG
* `preserveAspectRatio="xMidYMid meet"`: der viewBox wird auf das
* Canvas-Seitenverhältnis GEDEHNT (zentriert), damit die Skalierung in X und Y
* gleich ist (keine Verzerrung, Perpendikel bleiben senkrecht).
*
* Eingabe-Positionen sind bereits im BILDSCHIRM-Raum (toScreen), daher keine
* zusätzliche Meter-Skalierung. Bildschirm-Y zeigt nach unten → top=vb.y,
* bottom=vb.y+vb.h (kleineres Y oben → Clip +1).
*/
export function computeOrthoMatrix(
viewBox: ViewBox,
canvasW: number,
canvasH: number,
): Float32Array {
let { x, y, w, h } = viewBox;
if (canvasW > 0 && canvasH > 0 && w > 0 && h > 0) {
const cAspect = canvasW / canvasH;
const vAspect = w / h;
if (cAspect > vAspect) {
// Canvas breiter → viewBox in der Breite dehnen, X zentrieren.
const nw = h * cAspect;
x -= (nw - w) / 2;
w = nw;
} else {
// Canvas höher → viewBox in der Höhe dehnen, Y zentrieren.
const nh = w / cAspect;
y -= (nh - h) / 2;
h = nh;
}
}
// top = y (Bildschirm-Y oben, kleiner), bottom = y + h.
return mat4Ortho(x, x + w, y + h, y);
}
/**
* WebGL2-GPU-Renderer. Verwaltet Shader-Programme + Projektionsmatrix, zeichnet
* Füll- und Linien-Batches effizient über Matrix-Uniforms.
*/
export class PlanGpuRenderer {
private gl: WebGL2RenderingContext;
private fillProgram: ShaderProgram;
private lineProgram: ShaderProgram;
private lastViewBox: ViewBox | null = null;
private lastCanvas: { w: number; h: number } = { w: 0, h: 0 };
private projMatrix: Float32Array;
constructor(
gl: WebGL2RenderingContext,
fillProg: ShaderProgram,
lineProg: ShaderProgram,
) {
this.gl = gl;
this.fillProgram = fillProg;
this.lineProgram = lineProg;
this.projMatrix = mat4Identity();
// Zeichenblatt-Hintergrund (hell, Default #f0f0f0 = --sheet), keine Tiefe;
// Alpha-Blending für weiche Kanten/transluzente Räume.
gl.clearColor(0.941, 0.941, 0.941, 1);
gl.disable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
}
/** Löschfarbe (Zeichenblatt) setzen — theme-sicher aus --sheet. */
setClearColor(r: number, g: number, b: number): void {
this.gl.clearColor(r, g, b, 1);
}
/** Projektionsmatrix nur bei viewBox-/Canvas-Änderung neu berechnen. */
private updateProjection(viewBox: ViewBox, canvasW: number, canvasH: number): void {
if (
!this.lastViewBox ||
!viewBoxEqual(viewBox, this.lastViewBox) ||
this.lastCanvas.w !== canvasW ||
this.lastCanvas.h !== canvasH
) {
this.projMatrix = computeOrthoMatrix(viewBox, canvasW, canvasH);
this.lastViewBox = { ...viewBox };
this.lastCanvas = { w: canvasW, h: canvasH };
}
}
/**
* Zeichnet einen Frame.
* @param geometry GPU-Geometrie (Fill + Line); null → nur löschen.
* @param viewBox Aktueller Ausschnitt (Bildschirm-Einheiten).
* @param viewport Zeichenpuffer-Größe in Geräte-Pixeln.
* @param paperScaleN Papier-Massstab-Nenner (1:N) für echte mm-Strichbreiten.
*/
render(
geometry: GpuGeometry | null,
viewBox: ViewBox,
viewport: { width: number; height: number },
paperScaleN = 100,
): void {
const gl = this.gl;
gl.viewport(0, 0, viewport.width, viewport.height);
gl.clear(gl.COLOR_BUFFER_BIT);
if (!geometry) return;
this.updateProjection(viewBox, viewport.width, viewport.height);
// Umrechnung Papier-mm → Geräte-px, EXAKT wie der SVG-printStrokeVb-Pfad:
// Breite_vb = mm·N/1000·PX_PER_M (viewBox-Einheiten, skaliert mit Zoom)
// Breite_px = Breite_vb · meetSkala (Geräte-px je viewBox-Einheit)
// → mm·(N/1000·PX_PER_M·meet). meet nutzt die Geräte-px-Viewport-Größe, enthält
// damit dpr. So gilt: 0.35 mm bei 1:100 = echte 0.35 mm Papier (== SVG).
const meet = Math.min(viewport.width / viewBox.w, viewport.height / viewBox.h);
const mmToDevicePx = (paperScaleN / 1000) * PX_PER_M * meet;
this.drawFills(geometry);
this.drawLines(geometry, viewport, mmToDevicePx);
}
/** Gefüllte Polygone (Poché). */
private drawFills(geometry: GpuGeometry): void {
const gl = this.gl;
const { positionBuffer, indexBuffer, batches } = geometry.fill;
if (!positionBuffer || !indexBuffer || !batches.length) return;
const prog = this.fillProgram;
if (!prog?.program) return;
gl.useProgram(prog.program);
if (prog.uniforms.viewProj) {
gl.uniformMatrix4fv(prog.uniforms.viewProj, false, this.projMatrix);
}
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const pos = prog.attribs.position;
if (pos !== undefined && pos >= 0) {
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 8, 0);
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
for (const b of batches) {
if (prog.uniforms.color) gl.uniform4fv(prog.uniforms.color, b.color);
gl.drawElements(gl.TRIANGLES, b.indexCount, gl.UNSIGNED_INT, b.startIndex * 4);
}
if (pos !== undefined && pos >= 0) gl.disableVertexAttribArray(pos);
}
/** Striche mit echter Papier-mm-Breite (massstabs- + zoomrichtig). */
private drawLines(
geometry: GpuGeometry,
viewport: { width: number; height: number },
mmToDevicePx: number,
): void {
const gl = this.gl;
const { vertexBuffer, indexBuffer, batches } = geometry.line;
if (!vertexBuffer || !indexBuffer || !batches.length) return;
const prog = this.lineProgram;
if (!prog?.program) return;
gl.useProgram(prog.program);
if (prog.uniforms.viewProj) {
gl.uniformMatrix4fv(prog.uniforms.viewProj, false, this.projMatrix);
}
if (prog.uniforms.viewportPx) {
gl.uniform2f(prog.uniforms.viewportPx, viewport.width, viewport.height);
}
// strokeScale = mm → Geräte-px; die Basis-Breite (strokePx) trägt die mm.
if (prog.uniforms.strokeScale) {
gl.uniform1f(prog.uniforms.strokeScale, mmToDevicePx);
}
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
const stride = 6 * 4; // [x,y, nx,ny, side, miter]
const pos = prog.attribs.position;
const nrm = prog.attribs.normal;
const side = prog.attribs.side;
const miter = prog.attribs.miter;
if (pos !== undefined && pos >= 0) {
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, stride, 0);
}
if (nrm !== undefined && nrm >= 0) {
gl.enableVertexAttribArray(nrm);
gl.vertexAttribPointer(nrm, 2, gl.FLOAT, false, stride, 2 * 4);
}
if (side !== undefined && side >= 0) {
gl.enableVertexAttribArray(side);
gl.vertexAttribPointer(side, 1, gl.FLOAT, false, stride, 4 * 4);
}
if (miter !== undefined && miter >= 0) {
gl.enableVertexAttribArray(miter);
gl.vertexAttribPointer(miter, 1, gl.FLOAT, false, stride, 5 * 4);
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
// strokePx-Uniform trägt die ECHTE Papier-mm-Breite; der Shader multipliziert
// mit strokeScale (= mm→Geräte-px) und klemmt bei ~0.6 px (Haarlinie sichtbar,
// MSAA via antialias:true), damit sehr feine Gewichte beim Rauszoomen nicht
// verschwinden.
for (const b of batches) {
if (prog.uniforms.color) gl.uniform4fv(prog.uniforms.color, b.color);
if (prog.uniforms.strokePx) gl.uniform1f(prog.uniforms.strokePx, b.strokeMm);
gl.drawElements(gl.TRIANGLES, b.indexCount, gl.UNSIGNED_INT, b.startIndex * 4);
}
if (pos !== undefined && pos >= 0) gl.disableVertexAttribArray(pos);
if (nrm !== undefined && nrm >= 0) gl.disableVertexAttribArray(nrm);
if (side !== undefined && side >= 0) gl.disableVertexAttribArray(side);
if (miter !== undefined && miter >= 0) gl.disableVertexAttribArray(miter);
}
/** Cache invalidieren (z. B. nach Größenänderung). */
invalidateViewBox(): void {
this.lastViewBox = null;
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* WebGL2-Shader-Programme für den GPU-Grundriss-Renderer.
* Kompilierung + Fehlerbehandlung (Warnung + null, keine Exceptions).
*/
import type { ShaderProgram } from './glPlanTypes';
/**
* Füll-Vertex-Shader: bildet Bildschirm-Raum-Positionen über die viewProj-Matrix
* in den Clip-Raum ab. Für gefüllte Polygone (Wand-Poché, Räume, Decken).
*/
export const FILL_VERT = `#version 300 es
precision highp float;
uniform mat4 viewProj;
in vec2 position;
void main() {
gl_Position = viewProj * vec4(position, 0.0, 1.0);
}
`;
/** Füll-Fragment-Shader: einheitliche Farbe (MVP, noch keine Schraffur). */
export const FILL_FRAG = `#version 300 es
precision mediump float;
uniform vec4 color;
out vec4 outColor;
void main() {
outColor = color;
}
`;
/**
* Linien-Vertex-Shader mit BILDSCHIRMKONSTANTER Breite.
*
* Idee: sowohl der Endpunkt als auch (Endpunkt + Normale) werden durch viewProj
* transformiert; ihre Differenz im Clip-Raum (in Pixel skaliert) ergibt die
* Perpendikel-Richtung. Diese wird auf `strokePx/2` Pixel normiert und — je nach
* `side` — auf die passende Seite versetzt, dann px→Clip zurückgerechnet. So ist
* die Strichbreite unabhängig vom Zoom (keine Re-Tessellierung nötig).
*/
export const LINE_VERT = `#version 300 es
precision highp float;
uniform mat4 viewProj;
uniform float strokePx; // Basis-Breite in Geräte-px (bei strokeScale=1)
uniform float strokeScale; // Zoom-Skalierung: >1 = reingezoomt → dicker (Papier)
uniform vec2 viewportPx; // Zeichenpuffer-Größe [w,h] in Geräte-px
in vec2 position; // Bildschirm-Raum-Stützpunkt
in vec2 normal; // Bildschirm-Raum-Einheits-Miter-Bisektor (Richtung des Versatzes)
in float side; // +1.0 oder -1.0
in float miter; // Miter-Längenfaktor 1/cos(θ/2) (1.0 = gerade, keine Ecke)
void main() {
vec4 clipP = viewProj * vec4(position, 0.0, 1.0);
vec4 clipN = viewProj * vec4(position + normal, 0.0, 1.0);
// Bisektor-Richtung im Pixel-Raum (Clip-Differenz → px).
vec2 dirPx = (clipN.xy - clipP.xy) * viewportPx * 0.5;
float len = length(dirPx);
vec2 unitPx = len > 1e-6 ? dirPx / len : vec2(0.0);
// Echte Papierbreite in Geräte-px = mm(strokePx) · (mm→px)(strokeScale); mind.
// 0.6 px. Der Miter-Faktor verlängert den Versatz an Ecken, sodass benachbarte
// Segmente bündig verschmelzen (gehrte Ecke statt Butt-Cap-Stufe).
float widthPx = max(0.6, strokePx * strokeScale) * miter;
vec2 offsetPx = unitPx * (widthPx * 0.5) * side;
clipP.xy += offsetPx / (viewportPx * 0.5);
gl_Position = clipP;
}
`;
/** Linien-Fragment-Shader: einheitliche Farbe. */
export const LINE_FRAG = `#version 300 es
precision mediump float;
uniform vec4 color;
out vec4 outColor;
void main() {
outColor = color;
}
`;
/** Kompiliert einen einzelnen Shader; null + Warnung bei Fehler. */
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) {
console.warn('WebGL: Shader-Objekt konnte nicht erstellt werden');
return null;
}
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.warn(`WebGL: Shader-Kompilierung fehlgeschlagen:\n${gl.getShaderInfoLog(shader)}`);
gl.deleteShader(shader);
return null;
}
return shader;
}
/** Verlinkt Vertex + Fragment zu einem Programm; null + Warnung bei Fehler. */
function linkProgram(
gl: WebGL2RenderingContext,
vert: WebGLShader,
frag: WebGLShader,
): WebGLProgram | null {
const program = gl.createProgram();
if (!program) {
console.warn('WebGL: Programm konnte nicht erstellt werden');
return null;
}
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.warn(`WebGL: Programm-Verlinkung fehlgeschlagen:\n${gl.getProgramInfoLog(program)}`);
gl.deleteProgram(program);
return null;
}
return program;
}
/**
* Kompiliert + verlinkt ein vollständiges Shader-Programm und ermittelt alle
* Attribut-/Uniform-Orte. Gibt null zurück (mit Warnung) statt zu werfen.
*/
export function compileShaderProgram(
gl: WebGL2RenderingContext,
vertSrc: string,
fragSrc: string,
): ShaderProgram | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, vertSrc);
if (!vert) return null;
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc);
if (!frag) {
gl.deleteShader(vert);
return null;
}
const program = linkProgram(gl, vert, frag);
gl.deleteShader(vert);
gl.deleteShader(frag);
if (!program) return null;
const loc = (name: string): GLint => gl.getAttribLocation(program, name);
const uni = (name: string) => gl.getUniformLocation(program, name);
return {
program,
attribs: {
position: loc('position'),
normal: loc('normal'),
side: loc('side'),
miter: loc('miter'),
},
uniforms: {
viewProj: uni('viewProj'),
color: uni('color'),
strokePx: uni('strokePx'),
strokeScale: uni('strokeScale'),
viewportPx: uni('viewportPx'),
},
};
}
/** Füll-Programm (gefüllte Polygone). */
export function createFillProgram(gl: WebGL2RenderingContext): ShaderProgram | null {
return compileShaderProgram(gl, FILL_VERT, FILL_FRAG);
}
/** Linien-Programm (bildschirmkonstante Striche). */
export function createLineProgram(gl: WebGL2RenderingContext): ShaderProgram | null {
return compileShaderProgram(gl, LINE_VERT, LINE_FRAG);
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Typen des WebGL2-GPU-Plan-Renderers.
* Gemeinsamer Vertrag zwischen Shader-Kompilierung, Tessellierung und Render-Loop.
*
* Koordinaten-Konvention (WICHTIG, muss überall gleich sein):
* • Die Tessellierung liefert BILDSCHIRM-Raum-Koordinaten exakt wie `toScreen`
* in PlanView: `sx = mx·PX_PER_M`, `sy = -my·PX_PER_M` (Modell-Y zeigt nach
* oben, Bildschirm-Y nach unten). So ist die Projektion eine reine viewBox-
* Orthografie — kein zusätzlicher Skalen-/Spiegel-Schritt, deckungsgleich zum
* SVG-Renderer.
* • Linienbreiten sind BILDSCHIRM-KONSTANT (Papier-px): die Geometrie trägt eine
* Normale + Seiten-Flag, der Vertex-Shader expandiert erst im Clip-Raum um
* `strokePx` — unabhängig vom Zoom, ohne Re-Tessellierung.
*/
/** RGBA-Farbe, Komponenten in [0,1]. */
export type Rgba = [number, number, number, number];
/** Shader-Programm mit kompiliertem Programm + Attribut-/Uniform-Orten. */
export interface ShaderProgram {
program: WebGLProgram;
attribs: {
position?: GLint;
/** Bildschirm-Raum-Miter-Bisektor (Einheits-Versatzrichtung) für Linien. */
normal?: GLint;
/** Seite der Linie: +1 oder -1. */
side?: GLint;
/** Miter-Längenfaktor 1/cos(θ/2) (1 = gerade). */
miter?: GLint;
};
uniforms: {
viewProj?: WebGLUniformLocation | null;
color?: WebGLUniformLocation | null;
/** Basis-Strichbreite in Geräte-Pixeln (bei strokeScale=1). */
strokePx?: WebGLUniformLocation | null;
/** Zoom-Skalierung der Strichbreite (>1 = reingezoomt → dicker, Papier). */
strokeScale?: WebGLUniformLocation | null;
/** Zeichenpuffer-Größe in Geräte-Pixeln [w,h] (px→Clip-Umrechnung). */
viewportPx?: WebGLUniformLocation | null;
};
}
/** Ein Zeichen-Batch: zusammenhängender Index-Bereich mit einer Farbe. */
export interface FillBatch {
startIndex: number;
indexCount: number;
color: Rgba;
}
/** Ein Linien-Batch: Index-Bereich mit Farbe + echter Papier-Strichbreite. */
export interface LineBatch {
startIndex: number;
indexCount: number;
color: Rgba;
/**
* ECHTE Strichbreite in Papier-Millimeter. Der Renderer rechnet sie über den
* Massstab (1:N) + die aktuelle meet-Skala in Pixel — genau wie der SVG-
* `printStrokeVb`-Pfad, sodass GL == SVG (0.35 mm bei 1:100 = 0.35 mm Papier).
*/
strokeMm: number;
}
/**
* GPU-fertige Geometrie: getrennte Puffer für gefüllte Flächen (Poché) und
* Striche (bildschirmkonstante Breite). Einmal je Plan tessellieren, danach
* treibt nur die Projektionsmatrix Pan/Zoom.
*/
export interface GpuGeometry {
/** Gefüllte Polygone (Poché/Räume/Decken) — Bildschirm-Raum. */
fill: {
/** Interleaved [x,y, x,y, …] in Bildschirm-Raum. */
positionBuffer: WebGLBuffer | null;
indexBuffer: WebGLBuffer | null;
batches: FillBatch[];
};
/** Striche — Bildschirm-Raum-Position + Normale + Seiten-Flag. */
line: {
/** Interleaved [x,y, nx,ny, side, …] in Bildschirm-Raum. */
vertexBuffer: WebGLBuffer | null;
indexBuffer: WebGLBuffer | null;
batches: LineBatch[];
};
/** Modell-Bounds (Meter) für Debug/Einpassen; nicht render-kritisch. */
bounds: { minX: number; minY: number; maxX: number; maxY: number };
}
/** Rechteckiger Ausschnitt in Bildschirm-Einheiten (viewBox). */
export interface ViewBox {
x: number;
y: number;
w: number;
h: number;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* WebGL2-GPU-Plan-Renderer — öffentliche API.
*/
export * from './glPlanTypes';
export {
compileShaderProgram,
createFillProgram,
createLineProgram,
} from './glPlanShaders';
export { compilePrimitivesToGpu, triangulate } from './glPlanCompile';
export { PlanGpuRenderer, computeOrthoMatrix } from './glPlanRender';
+122
View File
@@ -0,0 +1,122 @@
/**
* React-Hook für den WebGL2-GPU-Plan-Renderer.
* Verwaltet GL-Kontext, Shader, Geometrie-Cache und Render-Aufrufe.
*
* Fällt bei fehlendem WebGL2/Shader-Fehler still auf null zurück; der Aufrufer
* (PlanView) behält dann den SVG-Pfad (Parität-Fallback).
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Plan } from './generatePlan';
import {
compilePrimitivesToGpu,
createFillProgram,
createLineProgram,
PlanGpuRenderer,
type GpuGeometry,
type ViewBox,
} from './glPlan';
/** Liest eine CSS-Custom-Property (z. B. "--sheet") als RGB[0..1] oder null. */
function readCssColor(varName: string): [number, number, number] | null {
if (typeof window === 'undefined') return null;
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
let h = raw.startsWith('#') ? raw.slice(1) : raw;
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
if (h.length < 6) return null;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
return [r / 255, g / 255, b / 255];
}
export function useGlPlanRenderer(
canvasRef: React.RefObject<HTMLCanvasElement | null>,
enabled: boolean,
) {
const glRef = useRef<WebGL2RenderingContext | null>(null);
const rendererRef = useRef<PlanGpuRenderer | null>(null);
const geomRef = useRef<GpuGeometry | null>(null);
// GL WIRKLICH aktiv (Kontext + Shader ok)? Steuert den SVG-Fallback: bei false
// rendert der Aufrufer den vollen SVG-Pfad, bei true übernimmt die GL-Ebene.
const [ready, setReady] = useState(false);
// GL-Kontext + Shader einmal aufsetzen.
useEffect(() => {
if (!enabled || !canvasRef.current) {
setReady(false);
return;
}
let disposed = false;
try {
const gl = canvasRef.current.getContext('webgl2', {
antialias: true,
alpha: false,
premultipliedAlpha: false,
});
if (!gl) {
console.warn('WebGL2 nicht verfügbar → SVG-Fallback');
return;
}
const fillProg = createFillProgram(gl);
const lineProg = createLineProgram(gl);
if (!fillProg || !lineProg) {
console.warn('Shader-Kompilierung fehlgeschlagen → SVG-Fallback');
return;
}
if (disposed) return;
glRef.current = gl;
const renderer = new PlanGpuRenderer(gl, fillProg, lineProg);
// Löschfarbe theme-sicher aus --sheet (Zeichenblatt ist immer „Papier").
const sheet = readCssColor('--sheet');
if (sheet) renderer.setClearColor(sheet[0], sheet[1], sheet[2]);
rendererRef.current = renderer;
setReady(true);
} catch (e) {
console.warn('WebGL2-Init fehlgeschlagen:', e);
glRef.current = null;
rendererRef.current = null;
setReady(false);
}
return () => {
disposed = true;
setReady(false);
};
}, [enabled, canvasRef]);
/** Geometrie beim Plan-Wechsel neu tessellieren (einmalig, gecacht). */
const updateGeometry = useCallback((plan: Plan) => {
const gl = glRef.current;
if (!gl || !plan) return;
try {
geomRef.current = compilePrimitivesToGpu(gl, plan.primitives);
} catch (e) {
console.warn('Tessellierung fehlgeschlagen:', e);
geomRef.current = null;
}
}, []);
/** Einen Frame zeichnen (Canvas-Größe + dpr hier abgleichen). */
const render = useCallback(
(viewBox: ViewBox, paperScaleN = 100) => {
const gl = glRef.current;
const renderer = rendererRef.current;
const canvas = canvasRef.current;
if (!gl || !renderer || !canvas) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.round(rect.width * dpr));
const h = Math.max(1, Math.round(rect.height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
renderer.render(geomRef.current, viewBox, { width: w, height: h }, paperScaleN);
},
[canvasRef],
);
return { render, updateGeometry, ready };
}