From 1fd688896cdc7790935de005ea8e41f0bcf71596 Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 3 Jul 2026 20:29:12 +0200 Subject: [PATCH] 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. --- src/plan/PlanView.tsx | 46 +++++++++++++++++++++++++++----- src/plan/glPlan/glPlanCompile.ts | 24 ++++++++++++++++- src/plan/useGlPlanRenderer.ts | 12 ++++++--- 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 120e4ac..57139b4 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -469,6 +469,10 @@ export const PlanView = forwardRef( 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(-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( 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]); diff --git a/src/plan/glPlan/glPlanCompile.ts b/src/plan/glPlan/glPlanCompile.ts index 78fb534..134e364 100644 --- a/src/plan/glPlan/glPlanCompile.ts +++ b/src/plan/glPlan/glPlanCompile.ts @@ -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·(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; diff --git a/src/plan/useGlPlanRenderer.ts b/src/plan/useGlPlanRenderer.ts index 0956ba3..56a3320 100644 --- a/src/plan/useGlPlanRenderer.ts +++ b/src/plan/useGlPlanRenderer.ts @@ -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;