From af0b044fcac68b703119052c6f343638ebe97218 Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 3 Jul 2026 21:24:28 +0200 Subject: [PATCH] =?UTF-8?q?render2d:=20Rundungen=20an=20Strichen=20?= =?UTF-8?q?=E2=80=94=20Round-Caps=20und=20Round-Joins=20wie=20glPlan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stroke_polyline tesselliert Segmente jetzt einzeln (Butt-Enden) und setzt an Innenknoten Fächer-Bögen auf der Aussenseite sowie an offenen Enden Halbkreis-Kappen. Fächerdichte 18°/Schritt, identisch zur WebGL2-Darstellung. Geschlossene Ringe: Bögen an allen Knoten, keine Kappen; offene Polylinien inkl. Strich-Segmente: Kappen an den Enden. --- src-tauri/render2d/src/shaders.rs | 2 +- src-tauri/render2d/src/tessellate.rs | 224 ++++++++++++++++++--------- 2 files changed, 155 insertions(+), 71 deletions(-) diff --git a/src-tauri/render2d/src/shaders.rs b/src-tauri/render2d/src/shaders.rs index 1b8b9c9..8f2c745 100644 --- a/src-tauri/render2d/src/shaders.rs +++ b/src-tauri/render2d/src/shaders.rs @@ -68,7 +68,7 @@ struct Globals { struct VsIn { @location(0) position : vec2, // Bildschirm-Raum-Endpunkt - @location(1) bisector : vec2, // Bildschirm-Raum-Miter-Bisektor (Einheit) + @location(1) bisector : vec2, // Bildschirm-Raum-Versatzrichtung (Einheit; Normale bei Quads, Radialrichtung bei Rundkappen/-ecken) @location(2) side : f32, // +1.0 oder -1.0 @location(3) miter : f32, // 1/cos(theta/2)-Laengenfaktor (Gehrung) }; diff --git a/src-tauri/render2d/src/tessellate.rs b/src-tauri/render2d/src/tessellate.rs index b0467ff..5947180 100644 --- a/src-tauri/render2d/src/tessellate.rs +++ b/src-tauri/render2d/src/tessellate.rs @@ -252,22 +252,25 @@ fn cross(a: Point, b: Point, c: Point) -> f32 { (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) } -/// Maximaler Miter-Laengenfaktor; darueber wird geklemmt (kein Spike an spitzen Ecken). -const MITER_LIMIT: f32 = 8.0; - -/// Einheits-Links-Normale von `from` nach `to` (Bildschirm-Raum); None bei Nulllaenge. +/// Einheits-Richtung von `from` nach `to` (Bildschirm-Raum); None bei Nulllaenge. #[inline] -fn left_normal(from: Point, to: Point) -> Option { +fn unit_dir(from: Point, to: Point) -> Option { let dx = to[0] - from[0]; let dy = to[1] - from[1]; let len = (dx * dx + dy * dy).sqrt(); - if len < 1e-6 { + if len < 1e-9 { None } else { - Some([-dy / len, dx / len]) + Some([dx / len, dy / len]) } } +/// 90-Grad-Linksdrehung einer Einheits-Richtung -> Einheits-Links-Normale. +#[inline] +fn left_normal_of(d: Point) -> Point { + [-d[1], d[0]] +} + /// Liegt p im (a,b,c)-Dreieck? (CCW-orientiert). fn point_in_tri(a: Point, b: Point, c: Point, p: Point) -> bool { let d1 = cross(a, b, p); @@ -390,7 +393,10 @@ pub struct GpuGeometry { pub fill_idx: Vec, pub fill_batches: Vec, /// Linien-Vertices, interleaved [x,y, bx,by, side, miter] in Bildschirm-Raum - /// (bx,by = Miter-Bisektor, miter = 1/cos(theta/2)-Laengenfaktor). + /// (bx,by = Einheits-Versatzrichtung — Segment-Normale bei Quad-Raendern, + /// Radialrichtung bei Rundkappen-/Rundecken-Faechern; side = 0 im Faecher- + /// Zentrum, sonst +-1; miter ist stets 1 — die Strichbreite wird vollstaendig + /// im Vertex-Shader angewandt, s. `shaders.rs`). pub line_verts: Vec, pub line_idx: Vec, pub line_batches: Vec, @@ -461,11 +467,23 @@ impl GpuGeometry { .push((BatchKind::Line, (self.line_batches.len() - 1) as u32)); } - /// Zeichnet einen zusammenhaengenden Linienzug (Modell-Meter) als EINEN - /// gehrten Streifen: an jedem Stuetzpunkt wird der Versatz entlang des Miter- - /// Bisektors verlaengert (1/cos(theta/2)), sodass benachbarte Segmente buendig - /// verschmelzen -> gehrte Ecke statt Butt-Cap-Stufe. `closed` schliesst den Ring - /// (letzter<->erster Punkt). Vertex-Layout: [x,y, bx,by, side, miter] (6 floats). + /// Zeichnet einen zusammenhaengenden Linienzug (Modell-Meter) 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 eigenstaendiges Quad mit BUTT-Enden (eigene + /// Segment-Normale, keine Miter-Verlaengerung); + /// - an jedem inneren Stuetzpunkt fuellt ein Dreiecksfaecher (Radius = halbe + /// Strichbreite) die Aussenseite der Ecke rund auf (Innenseite ueberlappt + /// unsichtbar, wie bei jedem Disjoint-Segment-Linienbreiten-Ansatz); + /// - an offenen Enden (nicht `closed`) sitzt ein Halbkreis-Faecher als runde + /// Kappe. + /// Die tatsaechliche Pixel-Breite bleibt bildschirmkonstant: alle Faecher-/ + /// Quad-Vertices tragen nur eine EINHEITS-Richtung (`bx,by`) + `side`-Skalar; + /// der Vertex-Shader multipliziert im Clip-Raum mit der aktuellen + /// Strichbreite (`stroke_px*stroke_scale`) — die Tessellierung selbst kennt + /// keine Pixelmasse. `closed` schliesst den Ring (letzter<->erster Punkt). + /// Vertex-Layout unveraendert: [x,y, bx,by, side, miter] (miter bleibt hier + /// immer 1, s. Shader-Vertrag in `shaders.rs`). /// /// 1:1-Port von `strokePolyline` in `src/plan/glPlan/glPlanCompile.ts`. fn stroke_polyline( @@ -501,69 +519,135 @@ impl GpuGeometry { return; } - let base = (self.line_verts.len() / 6) as u32; - for i in 0..k { - let has_in = closed || i > 0; - let has_out = closed || i < k - 1; - let n_in = if has_in { - left_normal(s[(i + k - 1) % k], s[i]) - } else { - None - }; - let n_out = if has_out { - left_normal(s[i], s[(i + 1) % k]) - } else { - None - }; + let start_idx_len = self.line_idx.len(); - let (bx, by, miter): (f32, f32, f32); - match (n_in, n_out) { - (Some(a), Some(b)) => { - let sx = a[0] + b[0]; - let sy = a[1] + b[1]; - let slen = (sx * sx + sy * sy).sqrt(); - if slen < 1e-3 { - // ~180-Grad-Umkehr -> kein sinnvoller Bisektor, gerade weiter. - bx = b[0]; - by = b[1]; - miter = 1.0; - } else { - bx = sx / slen; - by = sy / slen; - let denom = bx * b[0] + by * b[1]; // cos(theta/2) - miter = if denom > 1e-3 { - (1.0 / denom).min(MITER_LIMIT) - } else { - 1.0 - }; - } - } - (Some(n), None) | (None, Some(n)) => { - bx = n[0]; - by = n[1]; - miter = 1.0; - } - (None, None) => { - // Bei k>=2 unerreichbar; sicherheitshalber gerade lassen. - bx = 0.0; - by = 0.0; - miter = 1.0; - } - } - self.line_verts - .extend_from_slice(&[s[i][0], s[i][1], bx, by, 1.0, miter]); - self.line_verts - .extend_from_slice(&[s[i][0], s[i][1], bx, by, -1.0, miter]); + // Vertex fuer Quad-Rand ODER Faecher (Zentrum bei side=0, Rand bei side=1) + // anhaengen; gibt den neuen Index zurueck. + #[inline] + fn push_vert(verts: &mut Vec, p: Point, n: Point, side: f32) -> u32 { + verts.extend_from_slice(&[p[0], p[1], n[0], n[1], side, 1.0]); + (verts.len() / 6 - 1) as u32 } let segs = if closed { k } else { k - 1 }; + let mut seg_dir: Vec> = Vec::with_capacity(segs); for i in 0..segs { - let a = base + 2 * i as u32; - let b = base + 2 * (((i + 1) % k) as u32); - self.line_idx - .extend_from_slice(&[a, a + 1, b, a + 1, b + 1, b]); + seg_dir.push(unit_dir(s[i], s[(i + 1) % k])); } - self.add_line_batch((segs * 6) as u32, color, stroke_mm, width_screen); + + // Segment-Quads: buttendig, jedes mit seiner EIGENEN Normale (kein + // gemeinsamer Bisektor mehr — Ecken werden separat durch Faecher geschlossen). + for i in 0..segs { + let Some(d) = seg_dir[i] else { + continue; // entartetes (Laenge-0) Segment + }; + let n = left_normal_of(d); + let a = s[i]; + let b = s[(i + 1) % k]; + let i0 = push_vert(&mut self.line_verts, a, n, 1.0); + let i1 = push_vert(&mut self.line_verts, a, n, -1.0); + let i2 = push_vert(&mut self.line_verts, b, n, 1.0); + let i3 = push_vert(&mut self.line_verts, b, n, -1.0); + self.line_idx + .extend_from_slice(&[i0, i1, i2, i1, i3, i2]); + } + + // Kreisbogen-Faecher (Zentrum = Vertex, Radius = halbe Strichbreite, per + // Shader skaliert): `side=0` am Zentrum (kein Versatz), `side=1` an den + // Randpunkten (voller Versatz in Richtung (cos(t),sin(t))). Segmentzahl + // skaliert mit dem ueberstrichenen Winkel (18 Grad je Schritt). + #[inline] + fn add_fan_arc(verts: &mut Vec, idx: &mut Vec, p: Point, a_from: f32, a_to: f32) { + let delta = a_to - a_from; + let steps = ((delta.abs() / (std::f32::consts::PI / 10.0)).ceil() as u32).max(1); + let i_center = push_vert(verts, p, [1.0, 0.0], 0.0); + let mut prev = push_vert(verts, p, [a_from.cos(), a_from.sin()], 1.0); + for step in 1..=steps { + let t = a_from + delta * (step as f32) / (steps as f32); + let cur = push_vert(verts, p, [t.cos(), t.sin()], 1.0); + idx.extend_from_slice(&[i_center, prev, cur]); + prev = cur; + } + } + + // Runder Join an einem inneren Stuetzpunkt: Faecher NUR auf der konvexen + // (aeusseren) Seite der Ecke — die konkave Seite ueberlappt bereits durch + // die beiden Segment-Quads (kein Loch, keine zusaetzliche Geometrie noetig). + #[inline] + fn add_round_join(verts: &mut Vec, idx: &mut Vec, p: Point, d1: Point, d2: Point) { + let turn = d1[0] * d2[1] - d1[1] * d2[0]; + let dot = d1[0] * d2[0] + d1[1] * d2[1]; + if turn.abs() < 1e-6 && dot > 0.0 { + return; // praktisch gerade + } + let n1 = left_normal_of(d1); + let n2 = left_normal_of(d2); + if dot < -0.9999 { + // ~180-Grad-Umkehr: Aussenseite mehrdeutig -> voller Kreis (robust, + // entspricht zwei gestapelten Rund-Kappen an derselben Stelle). + let a0 = n1[1].atan2(n1[0]); + add_fan_arc(verts, idx, p, a0, a0 + 2.0 * std::f32::consts::PI); + return; + } + let outer = if turn > 0.0 { -1.0 } else { 1.0 }; + let u1 = [outer * n1[0], outer * n1[1]]; + let u2 = [outer * n2[0], outer * n2[1]]; + let a1 = u1[1].atan2(u1[0]); + let a2 = u2[1].atan2(u2[0]); + let mut delta = a2 - a1; + while delta <= -std::f32::consts::PI { + delta += 2.0 * std::f32::consts::PI; + } + while delta > std::f32::consts::PI { + delta -= 2.0 * std::f32::consts::PI; + } + if delta.abs() < 1e-4 { + return; + } + add_fan_arc(verts, idx, p, a1, a1 + delta); + } + + // Runde Kappe an einem offenen Ende: Halbkreis, der auf der Aussenseite + // (weg von der Linie) ausbaucht. + #[inline] + fn add_round_cap(verts: &mut Vec, idx: &mut Vec, p: Point, n: Point, sweep_sign: f32) { + let a0 = n[1].atan2(n[0]); + add_fan_arc(verts, idx, p, a0, a0 + sweep_sign * std::f32::consts::PI); + } + + for v in 0..k { + let has_in = closed || v > 0; + let has_out = closed || v < k - 1; + let d_in = if has_in { + seg_dir[(v + segs - 1) % segs] + } else { + None + }; + let d_out = if has_out { seg_dir[v % segs] } else { None }; + match (d_in, d_out) { + (Some(di), Some(do_)) => { + add_round_join(&mut self.line_verts, &mut self.line_idx, s[v], di, do_) + } + (None, Some(do_)) => { + // Start-Kappe: baucht rueckwaerts (weg vom ersten Segment) aus. + let n = left_normal_of(do_); + add_round_cap(&mut self.line_verts, &mut self.line_idx, s[v], n, 1.0) + } + (Some(di), None) => { + // End-Kappe: baucht vorwaerts (weg vom letzten Segment) aus. + let n = left_normal_of(di); + add_round_cap(&mut self.line_verts, &mut self.line_idx, s[v], n, -1.0) + } + (None, None) => {} + } + } + + self.add_line_batch( + (self.line_idx.len() - start_idx_len) as u32, + color, + stroke_mm, + width_screen, + ); } }