568 lines
23 KiB
TypeScript
568 lines
23 KiB
TypeScript
/**
|
||
* 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';
|
||
import { applyDashRuns, buildHatchRuns, zigzagPoints, motifPoints, DASH_MM_TO_M } from './glPlanHatch';
|
||
|
||
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[],
|
||
// Bildschirm-Massstab (Geräte-px je Modell-Meter) beim aktuellen Zoom. Steuert
|
||
// NUR die adaptive Bogen-Tessellierung (siehe 'arc'-Zweig): je stärker
|
||
// hineingezoomt, desto mehr Segmente, damit der Bogen rund statt facettiert
|
||
// erscheint. 0 → zoom-unabhängiger Rückfall (fixe 16 Segmente je 90°), z. B.
|
||
// wenn der Aufrufer den Massstab (noch) nicht kennt.
|
||
pxPerMeter = 0,
|
||
): 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, screenPx: boolean) => {
|
||
const last = lineBatches[lineBatches.length - 1];
|
||
if (last && last.strokeMm === strokeMm && !!last.screenPx === screenPx && sameRgba(last.color, color))
|
||
last.indexCount += count;
|
||
else
|
||
lineBatches.push({
|
||
startIndex: lineIdx.length - count,
|
||
indexCount: count,
|
||
color,
|
||
strokeMm,
|
||
screenPx,
|
||
});
|
||
};
|
||
|
||
/**
|
||
* Zeichnet einen zusammenhängenden Linienzug (Bildschirm-Raum) mit ECHTEN
|
||
* RUNDEN Kappen/Ecken (wie SVG `stroke-linecap/linejoin: round` und der
|
||
* Vektor-PDF-Pfad) statt eckigem Butt-Cap/Gehrung:
|
||
* • jedes Segment ist ein eigenständiges Quad mit BUTT-Enden (eigene
|
||
* Segment-Normale, keine Miter-Verlängerung);
|
||
* • an jedem inneren Stützpunkt füllt ein Dreiecksfächer (Radius = halbe
|
||
* Strichbreite) die Außenseite der Ecke rund auf (Innenseite überlappt
|
||
* unsichtbar, wie bei jedem Disjoint-Segment-Liniendicken-Ansatz);
|
||
* • an offenen Enden (nicht `closed`) sitzt ein Halbkreis-Fächer als runde
|
||
* Kappe.
|
||
* Die tatsächliche Pixel-Breite bleibt bildschirmkonstant: alle Fächer-/Quad-
|
||
* Vertices tragen nur eine EINHEITS-Richtung (`normal`) + `side`-Skalar; der
|
||
* Vertex-Shader multipliziert im Clip-Raum mit der aktuellen Strichbreite
|
||
* (`strokePx*strokeScale`) — die Tessellierung selbst kennt keine Pixelmasse.
|
||
* `closed` schließt den Ring (letzter↔erster Punkt). Vertex-Layout unverändert:
|
||
* [x,y, bx,by, side, miter] (miter bleibt hier immer 1, s. Shader-Vertrag).
|
||
*/
|
||
const strokePolyline = (
|
||
ptsM: Vec2[],
|
||
closed: boolean,
|
||
color: Rgba,
|
||
strokeMm: number,
|
||
screenPx = false,
|
||
) => {
|
||
// 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 dir = (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-9 ? null : { x: dx / len, y: dy / len };
|
||
};
|
||
const leftNormal = (d: Vec2): Vec2 => ({ x: -d.y, y: d.x });
|
||
|
||
const startIdxLen = lineIdx.length;
|
||
const vBase = lineVerts.length / 6;
|
||
let vcount = 0;
|
||
const pushVert = (p: Vec2, nx: number, ny: number, side: number): number => {
|
||
lineVerts.push(p.x, p.y, nx, ny, side, 1);
|
||
vcount++;
|
||
return vBase + vcount - 1;
|
||
};
|
||
|
||
const segs = closed ? k : k - 1;
|
||
const segDir: (Vec2 | null)[] = [];
|
||
for (let i = 0; i < segs; i++) segDir.push(dir(S[i], S[(i + 1) % k]));
|
||
|
||
// Segment-Quads: butt-endig, jedes mit seiner EIGENEN Normale (kein
|
||
// gemeinsamer Bisektor mehr — Ecken werden separat durch Fächer geschlossen).
|
||
for (let i = 0; i < segs; i++) {
|
||
const d = segDir[i];
|
||
if (!d) continue; // entartetes (Länge-0) Segment
|
||
const n = leftNormal(d);
|
||
const a = S[i], b = S[(i + 1) % k];
|
||
const i0 = pushVert(a, n.x, n.y, +1);
|
||
const i1 = pushVert(a, n.x, n.y, -1);
|
||
const i2 = pushVert(b, n.x, n.y, +1);
|
||
const i3 = pushVert(b, n.x, n.y, -1);
|
||
lineIdx.push(i0, i1, i2, i1, i3, i2);
|
||
}
|
||
|
||
// Kreisbogen-Fächer (Zentrum = Vertex, Radius = halbe Strichbreite, per
|
||
// Shader skaliert): `side=0` am Zentrum (kein Versatz), `side=1` an den
|
||
// Randpunkten (voller Versatz in Richtung (cosθ,sinθ)).
|
||
const addFanArc = (p: Vec2, aFrom: number, aTo: number) => {
|
||
const delta = aTo - aFrom;
|
||
const steps = Math.max(1, Math.ceil(Math.abs(delta) / (Math.PI / 10)));
|
||
const iCenter = pushVert(p, 1, 0, 0);
|
||
let prev = pushVert(p, Math.cos(aFrom), Math.sin(aFrom), 1);
|
||
for (let s = 1; s <= steps; s++) {
|
||
const t = aFrom + (delta * s) / steps;
|
||
const cur = pushVert(p, Math.cos(t), Math.sin(t), 1);
|
||
lineIdx.push(iCenter, prev, cur);
|
||
prev = cur;
|
||
}
|
||
};
|
||
|
||
// Runder Join an einem inneren Stützpunkt: Fächer NUR auf der konvexen
|
||
// (äußeren) Seite der Ecke — die konkave Seite überlappt bereits durch die
|
||
// beiden Segment-Quads (kein Loch, keine zusätzliche Geometrie nötig).
|
||
const addRoundJoin = (p: Vec2, d1: Vec2, d2: Vec2) => {
|
||
const turn = d1.x * d2.y - d1.y * d2.x;
|
||
const dot = d1.x * d2.x + d1.y * d2.y;
|
||
if (Math.abs(turn) < 1e-6 && dot > 0) return; // praktisch gerade
|
||
const n1 = leftNormal(d1), n2 = leftNormal(d2);
|
||
if (dot < -0.9999) {
|
||
// ~180°-Umkehr: Außenseite mehrdeutig → voller Kreis (robust, entspricht
|
||
// zwei gestapelten Rund-Kappen an derselben Stelle).
|
||
const a0 = Math.atan2(n1.y, n1.x);
|
||
addFanArc(p, a0, a0 + 2 * Math.PI);
|
||
return;
|
||
}
|
||
const outer = turn > 0 ? -1 : 1;
|
||
const u1 = { x: outer * n1.x, y: outer * n1.y };
|
||
const u2 = { x: outer * n2.x, y: outer * n2.y };
|
||
const a1 = Math.atan2(u1.y, u1.x);
|
||
let a2 = Math.atan2(u2.y, u2.x);
|
||
let delta = a2 - a1;
|
||
while (delta <= -Math.PI) delta += 2 * Math.PI;
|
||
while (delta > Math.PI) delta -= 2 * Math.PI;
|
||
if (Math.abs(delta) < 1e-4) return;
|
||
addFanArc(p, a1, a1 + delta);
|
||
};
|
||
|
||
// Runde Kappe an einem offenen Ende: Halbkreis, der auf der Außenseite
|
||
// (weg von der Linie) bulgt.
|
||
const addRoundCap = (p: Vec2, n: Vec2, sweepSign: 1 | -1) => {
|
||
const a0 = Math.atan2(n.y, n.x);
|
||
addFanArc(p, a0, a0 + sweepSign * Math.PI);
|
||
};
|
||
|
||
for (let v = 0; v < k; v++) {
|
||
const hasIn = closed || v > 0;
|
||
const hasOut = closed || v < k - 1;
|
||
const dIn = hasIn ? segDir[(v - 1 + segs) % segs] : null;
|
||
const dOut = hasOut ? segDir[v % segs] : null;
|
||
if (dIn && dOut) {
|
||
addRoundJoin(S[v], dIn, dOut);
|
||
} else if (dOut && !dIn) {
|
||
// Start-Kappe: bulgt rückwärts (weg vom ersten Segment).
|
||
addRoundCap(S[v], leftNormal(dOut), 1);
|
||
} else if (dIn && !dOut) {
|
||
// End-Kappe: bulgt vorwärts (weg vom letzten Segment).
|
||
addRoundCap(S[v], leftNormal(dIn), -1);
|
||
}
|
||
}
|
||
|
||
addLineBatch(lineIdx.length - startIdxLen, color, strokeMm, screenPx);
|
||
};
|
||
|
||
/** Einzelnes Segment als (ungehrter, rund gekapptes) 2-Punkt-Zug. */
|
||
const pushLine = (aM: Vec2, bM: Vec2, color: Rgba, strokeMm: number) =>
|
||
strokePolyline([aM, bM], false, color, strokeMm);
|
||
|
||
/**
|
||
* Strichelt den Umriss eines Polygons, wobei die Kanten in `noStroke`
|
||
* ÜBERSPRUNGEN werden (Kante `i` = pts[i]→pts[i+1]). Die Füllung bleibt das
|
||
* volle Polygon; nur innere Gehrungs-Stirnkanten am Wandknoten entfallen. Die
|
||
* sichtbaren Kanten werden als zusammenhängende OFFENE Züge gestrichelt → an
|
||
* den weggelassenen Kanten laufen die Enden gerade aus (keine Gehrung über die
|
||
* Naht = keine überschießende Barbe). Leeres `noStroke` ⇒ geschlossener,
|
||
* gehrter Umriss wie bisher.
|
||
*/
|
||
const strokePolygonOutline = (
|
||
ptsM: Vec2[],
|
||
color: Rgba,
|
||
strokeMm: number,
|
||
noStroke: number[] | undefined,
|
||
) => {
|
||
const n = ptsM.length;
|
||
if (!noStroke || noStroke.length === 0 || n < 2) {
|
||
strokePolyline(ptsM, true, color, strokeMm);
|
||
return;
|
||
}
|
||
const skip = new Set(noStroke.map((i) => ((i % n) + n) % n));
|
||
if (skip.size >= n) return; // alle Kanten unterdrückt → nichts zeichnen
|
||
// Sichtbare Kanten zu zusammenhängenden Läufen bündeln; ein Lauf beginnt an
|
||
// einer sichtbaren Kante, deren Vorgänger übersprungen ist.
|
||
const visible = (i: number) => !skip.has(((i % n) + n) % n);
|
||
for (let s = 0; s < n; s++) {
|
||
if (!visible(s) || visible(s - 1 + n)) continue; // kein Lauf-Anfang
|
||
const run: Vec2[] = [ptsM[s]];
|
||
let j = s;
|
||
while (visible(j)) {
|
||
run.push(ptsM[(j + 1) % n]);
|
||
j++;
|
||
if (j - s >= n) break; // voller Umlauf (kann bei skip.size≥1 nicht sein)
|
||
}
|
||
strokePolyline(run, 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);
|
||
}
|
||
}
|
||
// Schraffur (nach der Füllung, vor/passend zum Umriss — wie der SVG stapelt):
|
||
// das Muster wird in echte Linien-Geometrie (Modell-Meter) tesselliert und
|
||
// aufs Polygon geclippt (konkav-fähig, even-odd). Musterlinien fließen als
|
||
// zusammenhängende Läufe in `strokePolyline` → glatte, RUND gekappte/
|
||
// -verbundene Übergänge (insb. die Dämmungswelle bleibt spitzenfrei).
|
||
// BREITE — die Schraffur ist jetzt ein GEWÖHNLICHER Papier-mm-Stift, mit
|
||
// derselben LineStyle-Stärke (0.02 mm) wie die Wand-Schichtfugen, und
|
||
// läuft durch dieselbe STANDARD-Pipeline wie jede andere Linie
|
||
// (screenPx=false). `paperScaleForGl` liefert im Bildschirm-/Haarlinien-
|
||
// Modus den reziproken `meet`-Massstab, wodurch `mmToDevicePx` dort
|
||
// zoomunabhängig konstant bleibt — die Schraffur erscheint als konstante
|
||
// Haarlinie wie jede Wandlinie, und im Druckmodus als echte 0.02 mm,
|
||
// deckungsgleich mit den Schichtfugen in beiden Modi.
|
||
// `hatch.dash` wird geometrisch (Strich/Lücke) aufgelöst, da die GL-
|
||
// Linien-Pipeline kein Dash kennt.
|
||
// Bild-Schraffur (kind==="image"): WebGL2-Ebene kennt keine Muster-Textur →
|
||
// FALLBACK auf die neutrale Poché-Füllung (oben), keine Musterlinien. Der
|
||
// Random-Untermodus läuft transparent über `buildHatchRuns` (Streu-Striche).
|
||
if (
|
||
prim.hatch &&
|
||
prim.hatch.kind !== 'image' &&
|
||
prim.hatch.pattern !== 'none' &&
|
||
prim.hatch.pattern !== 'solid'
|
||
) {
|
||
const hatchColor = parseColor(prim.hatch.color) ?? [0.1, 0.1, 0.1, 1];
|
||
const hatchMm = prim.hatch.lineWeight > 0 ? prim.hatch.lineWeight : 0.13;
|
||
const runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash);
|
||
for (const run of runs) strokePolyline(run, false, hatchColor, hatchMm, false);
|
||
}
|
||
// 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) {
|
||
strokePolygonOutline(prim.pts, stroke, prim.strokeWidthMm, prim.noStrokeEdges);
|
||
}
|
||
} else if (prim.kind === 'line') {
|
||
const color = parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1];
|
||
if (prim.zigzag) {
|
||
// Zickzack-Linie → gehrter Polylinienzug (amplitude/wavelength Papier-mm
|
||
// → Modell-Meter über dieselbe Kopplung wie Strichmuster).
|
||
const amp = prim.zigzag.amplitude * DASH_MM_TO_M;
|
||
const wav = prim.zigzag.wavelength * DASH_MM_TO_M;
|
||
const zpts = zigzagPoints(prim.a, prim.b, amp, wav);
|
||
strokePolyline(zpts, false, color, prim.weightMm || 0.18);
|
||
} else if (prim.motif) {
|
||
// Custom-Motiv → gehrter Polylinienzug (Papier-mm → Modell-Meter, wie Zickzack).
|
||
const mpts = motifPoints(prim.a, prim.b, prim.motif, DASH_MM_TO_M);
|
||
strokePolyline(mpts, false, color, prim.weightMm || 0.18);
|
||
} else {
|
||
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;
|
||
// Bildschirm-adaptive Tessellierung (LOD): der Bogen-Radius in Geräte-px
|
||
// ist rPx = r · pxPerMeter. Der Segment-Winkel θ wird so gewählt, dass die
|
||
// Sagitta (Sehnen-Ausbauchung) rPx·(1−cos(θ/2)) unter ~0.5 px bleibt →
|
||
// θ = 2·acos(1 − 0.5/rPx). Weit weg (kleiner rPx) = wenige Segmente (billig,
|
||
// Facetten unsichtbar); hineingezoomt = viele Segmente = runder Bogen — wie
|
||
// im CAD. Geklammert auf [8, 2048]. pxPerMeter=0 → fixer Rückfall (16/90°).
|
||
// Der Aufrufer (PlanView) re-tesselliert nur bei LOD-Bucket-Wechseln, nicht
|
||
// je Frame; Pan/Zoom bleibt reines Matrix-Update.
|
||
let segs: number;
|
||
if (pxPerMeter > 0) {
|
||
const rPx = prim.r * pxPerMeter;
|
||
// Für sehr kleine rPx ist 0.5/rPx ≥ 1 (kein realer acos) → Minimum greift.
|
||
const theta = rPx > 0.5 ? 2 * Math.acos(1 - 0.5 / rPx) : Math.PI;
|
||
segs = Math.min(2048, Math.max(8, Math.ceil(Math.abs(d) / theta)));
|
||
} else {
|
||
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);
|
||
} else if (prim.kind === 'drawingCircle' || prim.kind === 'drawingArc') {
|
||
// 2D-Zeichnungs-Kreis/-Bogen (Kreis-/Bogen-Werkzeug ODER DXF-Import). Der
|
||
// SVG-Pfad zeichnet sie als glattes <circle>/<path>; die WebGL-Ebene kennt
|
||
// kein Kreis-Primitiv → hier bildschirm-adaptiv tessellieren (wie 'arc').
|
||
// Kreis = voller Umlauf (geschlossen, optional gefüllt), Bogen = a0..a1 (CCW,
|
||
// offen). OHNE diesen Zweig blieben gezeichnete Kreise/Bögen im GL unsichtbar.
|
||
const stroke = parseColor(prim.stroke) ?? [0.1, 0.1, 0.1, 1];
|
||
const strokeMm = prim.weightMm || 0.18;
|
||
const c = prim.center;
|
||
const TAU = Math.PI * 2;
|
||
const a0 = prim.kind === 'drawingArc' ? prim.a0 : 0;
|
||
const sweep =
|
||
prim.kind === 'drawingArc'
|
||
? ((prim.a1 - prim.a0) % TAU + TAU) % TAU || TAU
|
||
: TAU;
|
||
// Segmentzahl wie im 'arc'-Zweig: Sagitta < ~0.5 px (LOD über pxPerMeter).
|
||
let segs: number;
|
||
if (pxPerMeter > 0) {
|
||
const rPx = prim.r * pxPerMeter;
|
||
const theta = rPx > 0.5 ? 2 * Math.acos(1 - 0.5 / rPx) : Math.PI;
|
||
segs = Math.min(2048, Math.max(8, Math.ceil(sweep / theta)));
|
||
} else {
|
||
segs = Math.max(8, Math.ceil((sweep / (Math.PI / 2)) * 16));
|
||
}
|
||
const pts: Vec2[] = [];
|
||
for (let i = 0; i <= segs; i++) {
|
||
const t = a0 + (sweep * i) / segs;
|
||
pts.push({ x: c.x + prim.r * Math.cos(t), y: c.y + prim.r * Math.sin(t) });
|
||
}
|
||
// Vollton-Füllung nur beim geschlossenen Kreis (Bogen ist offen).
|
||
if (prim.kind === 'drawingCircle') {
|
||
const fill = parseColor(prim.fill);
|
||
if (fill) {
|
||
const ring = pts.slice(0, segs); // ohne den doppelten Schlusspunkt
|
||
const tris = triangulate(ring);
|
||
if (tris.length) {
|
||
const base = fillPos.length / 2;
|
||
for (const pt of ring) {
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
strokePolyline(pts, prim.kind === 'drawingCircle', stroke, 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,
|
||
},
|
||
};
|
||
}
|