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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user