Files
DOSSIER-STANDALONE/src/plan/glPlan/glPlanRender.ts
T
karim 6aaef8f46a glPlan: runde Linien-Caps/Joins + Schraffurbreite bildschirm-konstant
- Runde Enden und Ecken statt Butt-Caps/Miter: jedes Segment als eigenes
  Quad, an Innenecken ein Dreiecks-Fächer über den Aussenwinkel (addRoundJoin),
  an offenen Enden ein Halbkreis-Fächer (addRoundCap). Gilt für Wandumrisse,
  Linien, Bögen und Schraffurläufe — Look wie das Vektor-PDF. Keine Shader-
  Änderung nötig (Radius weiterhin zur Render-Zeit aus strokePx*strokeScale).
- Schraffur-Dicke-Bug behoben: glPlan schickte die Schraffur-mm durch die
  Papier-mm-Pipeline (strokeMm*mmToDevicePx); im Display-Modus ist der darin
  steckende paperScaleN ein zoom-abgeleiteter, nicht auf 0.13/mm kalibrierter
  Wert → Breite explodierte. Jetzt hatchStrokeVb = max(0.6, hatchMm/0.13) als
  screenPx-Batch (skaliert nur mit meet/Zoom) — byte-genau wie SVG-<pattern>
  und der PDF-Export (widthScreen). Wandumrisse unverändert (echte mm).
2026-07-03 19:47:03 +02:00

272 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, meet);
}
/** 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. Zwei Skalierungs-Regime pro Batch (siehe `LineBatch.screenPx`):
* • Papier-mm (Standard) — Wand-/Objekt-Umrisse: massstabs- + zoomrichtig
* über `mmToDevicePx`.
* • Bildschirm-px (`screenPx`) — Schraffur-Musterlinien: NUR mit `meet`
* (Zoom) skaliert, unabhängig vom Papier-Massstab N — exakt wie der SVG-
* `hatchStrokePx`-Pfad (`patternUnits="userSpaceOnUse"`, keine
* `printStrokeVb`-Umrechnung) und der PDF-Export (`toRenderScene.ts`,
* `widthScreen: true`).
*/
private drawLines(
geometry: GpuGeometry,
viewport: { width: number; height: number },
mmToDevicePx: number,
meet: 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);
}
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 Breite (Papier-mm ODER Bildschirm-px, je nach
// `screenPx`); strokeScale wählt PRO BATCH den passenden Massstabsfaktor.
// Der Shader multipliziert beides 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);
if (prog.uniforms.strokeScale) {
gl.uniform1f(prog.uniforms.strokeScale, b.screenPx ? meet : mmToDevicePx);
}
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;
}
}