glPlan: bildschirm-adaptive Bogen-Tessellierung (LOD)

Boegen (Tuer-Schwenkbogen) wurden mit fester Segmentzahl (16/90 Grad) beim
Kompilieren tesselliert und beim Zoom nur per Matrix neu gezeichnet - beim
Reinzoomen daher sichtbare Facetten. Jetzt skaliert die Segmentzahl mit der
Bildschirmgroesse des Bogens: Sagitta < 0.5 px (segs = clamp(ceil(|dPhi|/theta),
8, 2048)), theta = 2*acos(1 - 0.5/rPx). Weit weg wenige Segmente (Perf),
nah rund.

Anti-Thrash per LOD-Bucket round(log2(pxPerMeter)): Neu-Tessellierung nur
bei Bucket-Wechsel, sonst bleibt Zoom/Pan reiner Matrix-Update. Bogen laeuft
weiter durch strokePolyline - runde Caps/Joins bleiben erhalten.
This commit is contained in:
2026-07-03 20:29:12 +02:00
parent 4211633e3e
commit 3a0652a337
3 changed files with 71 additions and 11 deletions
+39 -7
View File
@@ -469,6 +469,10 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
toScreenRef.current = toScreen;
const planRef = useRef(plan);
planRef.current = plan;
// Zuletzt tessellierter LOD-Bucket (quantisierter Zoom). Nur bei Bucket-Wechsel
// wird die Bogen-Geometrie neu aufgebaut — sonst bliebe Pan/Zoom reines
// Matrix-Update. Infinity = „noch nie tesselliert".
const lodBucketRef = useRef<number>(-Infinity);
// Papier-Massstab-Nenner (1:N) für die GL-Strichbreiten — ECHTE mm im Massstab
// (0.35 mm bei 1:100). Bevorzugt der stabile `paperScale` (wie beim SVG-Print);
@@ -498,20 +502,48 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return print ? paperScale! : 100;
};
// GL-Geometrie beim Plan-Wechsel neu tessellieren + sofort neu zeichnen.
// Bildschirm-Massstab (Geräte-px je Modell-Meter) beim aktuellen Ausschnitt:
// meet-Skala (CSS-px je viewBox-Einheit) · PX_PER_M (viewBox-Einheiten je Meter)
// · dpr (Geräte-px je CSS-px). Steuert die adaptive Bogen-Tessellierung.
const pxPerMeterForView = (v: ViewBox): number => {
const meet = meetScale(v, svgRef.current);
if (!meet || meet <= 0) return 0;
return meet * PX_PER_M * (window.devicePixelRatio || 1);
};
// LOD-Bucket = quantisierter Zoom (log2 des Bildschirm-Massstabs, auf Ganzzahl
// gerundet → Schritte von ×2). Nur ein Bucket-Wechsel löst eine Neu-Tessellierung
// aus; innerhalb eines Buckets bleibt Pan/Zoom reines Matrix-Uniform-Update.
const lodBucketForView = (v: ViewBox): number => {
const s = pxPerMeterForView(v);
return s > 0 ? Math.round(Math.log2(s)) : -Infinity;
};
// GL-Geometrie beim Plan-Wechsel neu tessellieren + sofort neu zeichnen. Der
// aktuelle LOD-Bucket wird mitgeschrieben, damit der Pan/Zoom-Effekt weiss, ab
// wann sich der Zoom weit genug bewegt hat, um Bögen neu aufzulösen.
useEffect(() => {
if (!useGpuRenderer) return;
updateGeometry(plan);
renderGl(viewRef.current, paperScaleForGl(viewRef.current), textScaleForGl());
const v = viewRef.current;
updateGeometry(plan, pxPerMeterForView(v));
lodBucketRef.current = lodBucketForView(v);
renderGl(v, paperScaleForGl(v), textScaleForGl());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [useGpuRenderer, plan, updateGeometry, renderGl]);
// GL bei jeder Ausschnittsänderung (Pan/Zoom) neu zeichnen — nur Matrix-Uniform,
// keine Re-Tessellierung. Auch bei paperScale-Wechsel neu (Strichbreiten).
// GL bei jeder Ausschnittsänderung (Pan/Zoom) neu zeichnen. Normalfall: NUR
// Matrix-Uniform (keine Re-Tessellierung → flüssig). Ausnahme: hat der Zoom
// einen neuen LOD-Bucket erreicht, werden die Bögen einmalig mit dem neuen
// Bildschirm-Massstab neu tesselliert (rund beim Hineinzoomen, billig weit weg).
// Kostennotiz: `updateGeometry` baut die GESAMTE Linien-/Füll-Geometrie neu auf,
// nicht nur die Bögen — Bucket-Wechsel sind aber selten (Zoom-Faktor ×2).
useEffect(() => {
if (useGpuRenderer) {
renderGl(view, paperScaleForGl(view), textScaleForGl());
if (!useGpuRenderer) return;
const bucket = lodBucketForView(view);
if (bucket !== lodBucketRef.current) {
lodBucketRef.current = bucket;
updateGeometry(plan, pxPerMeterForView(view));
}
renderGl(view, paperScaleForGl(view), textScaleForGl());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [useGpuRenderer, renderGl, view, paperScale]);
+23 -1
View File
@@ -137,6 +137,12 @@ function parseColor(hex: string | undefined): Rgba | null {
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[] = [];
@@ -426,7 +432,23 @@ export function compilePrimitivesToGpu(
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));
// 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·(1cos(θ/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;
+9 -3
View File
@@ -85,12 +85,18 @@ export function useGlPlanRenderer(
};
}, [enabled, canvasRef]);
/** Geometrie beim Plan-Wechsel neu tessellieren (einmalig, gecacht). */
const updateGeometry = useCallback((plan: Plan) => {
/**
* Geometrie (neu) tessellieren und cachen. Läuft beim Plan-Wechsel UND bei
* jedem LOD-Bucket-Wechsel des Zooms (PlanView triggert beides). `pxPerMeter`
* = aktueller Bildschirm-Massstab (Geräte-px je Modell-Meter); steuert die
* adaptive Bogen-Tessellierung (mehr Segmente beim Hineinzoomen). 0 → fixer
* zoom-unabhängiger Rückfall.
*/
const updateGeometry = useCallback((plan: Plan, pxPerMeter = 0) => {
const gl = glRef.current;
if (!gl || !plan) return;
try {
geomRef.current = compilePrimitivesToGpu(gl, plan.primitives);
geomRef.current = compilePrimitivesToGpu(gl, plan.primitives, pxPerMeter);
} catch (e) {
console.warn('Tessellierung fehlgeschlagen:', e);
geomRef.current = null;