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
+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;
}
}