diff --git a/src-tauri/render2d/src/bin/spike.rs b/src-tauri/render2d/src/bin/spike.rs index 5103701..c3235b8 100644 --- a/src-tauri/render2d/src/bin/spike.rs +++ b/src-tauri/render2d/src/bin/spike.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use render2d::gpu::Renderer; use render2d::types::ViewBox; -use render2d::{demo_scene, initial_view_box}; +use render2d::{demo_scene, initial_view_box, meet_scale, PX_PER_M}; use winit::application::ApplicationHandler; use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}; @@ -74,7 +74,9 @@ impl GpuState { surface.configure(&device, &config); let mut renderer = Renderer::new(&device, format); - renderer.upload_scene(&device, &demo_scene()); + let view_box = initial_view_box(); + let px_per_m = PX_PER_M * meet_scale(view_box, config.width as f32, config.height as f32); + renderer.upload_scene(&device, &demo_scene(), px_per_m); Self { surface, diff --git a/src-tauri/render2d/src/demo.rs b/src-tauri/render2d/src/demo.rs index 532551d..02ff947 100644 --- a/src-tauri/render2d/src/demo.rs +++ b/src-tauri/render2d/src/demo.rs @@ -43,19 +43,23 @@ pub fn demo_scene() -> Scene { pts: l, color: ink, width_mm: 0.35, + dash: None, }, Outline { pts: room, color: ink, width_mm: 0.18, + dash: None, }, ], polylines: vec![], + arcs: vec![], lines: vec![Line { a: [0.0, -1.0], b: [9.0, -1.0], color: ink, width_mm: 0.25, + dash: None, }], // Ein Raumstempel-artiger Text (echte Glyphen via Atlas), mittig im Raum. texts: vec![Text { diff --git a/src-tauri/render2d/src/gpu.rs b/src-tauri/render2d/src/gpu.rs index ddca31e..ced58a0 100644 --- a/src-tauri/render2d/src/gpu.rs +++ b/src-tauri/render2d/src/gpu.rs @@ -19,7 +19,7 @@ use wgpu::util::DeviceExt; use crate::ortho::{compute_ortho_matrix, corrected_view_box, meet_scale, mm_to_device_px, Mat4}; use crate::shaders::{FILL_WGSL, LINE_WGSL}; -use crate::tessellate::{compile_scene, to_screen, GpuGeometry}; +use crate::tessellate::{compile_scene_scaled, to_screen, GpuGeometry, PX_PER_M}; use crate::types::{Scene, Text, TextAlign, ViewBox}; /// MSAA-Faktor: 4x Multisampling fuer glatte Linien-/Fuellkanten (wie im Browser). @@ -98,6 +98,11 @@ pub struct Renderer { /// Aktuell allozierter Uniform-Puffer + zugehoerige Bind-Group (wachsen bei Bedarf). uniform: Option, scene: Option, + /// Die zuletzt hochgeladene Szene (fuer Re-Tessellierung bei Zoomaenderung, + /// siehe `maybe_retessellate` — nur relevant, wenn die Szene Boegen enthaelt). + last_scene: Option, + /// Geraete-px je Modell-Meter, mit dem `last_scene` zuletzt tessellliert wurde. + arc_px_per_m: f32, /// Farbformat der Ziel-Surface (auch Format der MSAA-Textur). format: wgpu::TextureFormat, /// Multisample-Farbtextur (4x), lazily an die Ziel-Groesse gebunden. @@ -248,6 +253,8 @@ impl Renderer { uniform_stride, uniform: None, scene: None, + last_scene: None, + arc_px_per_m: PX_PER_M, format: color_format, msaa_view: None, msaa_size: (0, 0), @@ -263,9 +270,12 @@ impl Renderer { } } - /// Tessellliert eine Szene und laedt die Puffer hoch (einmal je Szene). - pub fn upload_scene(&mut self, device: &wgpu::Device, scene: &Scene) { - let geo = compile_scene(scene); + /// Tessellliert eine Szene und laedt die Puffer hoch. `px_per_m` sind die + /// aktuellen Geraete-px je Modell-Meter (treibt nur die Bogen-Adaptivitaet, + /// siehe `tessellate::tessellate_arc`); wird gemerkt, damit `render` bei + /// Zoomaenderung automatisch neu tessellliert (`maybe_retessellate`). + pub fn upload_scene(&mut self, device: &wgpu::Device, scene: &Scene, px_per_m: f32) { + let geo = compile_scene_scaled(scene, px_per_m); let mk_vbo = |data: &[f32], label: &str| -> Option { if data.is_empty() { @@ -296,6 +306,29 @@ impl Renderer { geo, texts: scene.texts.clone(), }); + self.last_scene = Some(scene.clone()); + self.arc_px_per_m = px_per_m; + } + + /// Tessellliert die zuletzt hochgeladene Szene neu, wenn sich der Zoom seit + /// dem letzten Upload um mehr als den Faktor 1.3 veraendert hat UND die + /// Szene ueberhaupt Boegen enthaelt (bei anderen Primitiven ist Zoom- + /// invariant, kein Re-Tessellieren noetig). Haelt Boegen bei jeder + /// Zoomstufe glatt, ohne jeden Frame neu zu tessellieren (Szenen sind + /// klein, das Re-Tessellieren selbst ist billig — nur nicht JEDEN Frame). + fn maybe_retessellate(&mut self, device: &wgpu::Device, view_box: ViewBox, viewport: (u32, u32)) { + let Some(scene) = &self.last_scene else { + return; + }; + if scene.arcs.is_empty() { + return; + } + let px_per_m = PX_PER_M * meet_scale(view_box, viewport.0 as f32, viewport.1 as f32); + let ratio = px_per_m / self.arc_px_per_m.max(1e-6); + if ratio > 1.3 || ratio < 1.0 / 1.3 { + let scene = scene.clone(); + self.upload_scene(device, &scene, px_per_m); + } } /// Erstellt den Glyphen-Textpass beim ersten Bedarf (FontSystem laedt die @@ -398,6 +431,8 @@ impl Renderer { view_box: ViewBox, viewport: (u32, u32), ) { + self.maybe_retessellate(device, view_box, viewport); + let (vw, vh) = (viewport.0 as f32, viewport.1 as f32); let proj: Mat4 = compute_ortho_matrix(view_box, vw, vh); let mm_px = mm_to_device_px(view_box, vw, vh, self.paper_scale_n); diff --git a/src-tauri/render2d/src/lib.rs b/src-tauri/render2d/src/lib.rs index b3ff512..9bc7256 100644 --- a/src-tauri/render2d/src/lib.rs +++ b/src-tauri/render2d/src/lib.rs @@ -23,8 +23,8 @@ pub mod gpu; pub use demo::{demo_scene, initial_view_box}; pub use ortho::{compute_ortho_matrix, meet_scale, mm_to_device_px, Mat4}; -pub use tessellate::{compile_scene, triangulate, GpuGeometry, PX_PER_M}; -pub use types::{FillPolygon, Line, Outline, Point, Rgba, Scene, Text, TextAlign, ViewBox}; +pub use tessellate::{compile_scene, compile_scene_scaled, triangulate, GpuGeometry, PX_PER_M}; +pub use types::{Arc, FillPolygon, Line, Outline, Point, Rgba, Scene, Text, TextAlign, ViewBox}; // --- Tests: Tessellierung (Muster wie glPlanCompile.test.ts) ----------------- diff --git a/src-tauri/render2d/src/tessellate.rs b/src-tauri/render2d/src/tessellate.rs index 9e3a5d1..0aaac53 100644 --- a/src-tauri/render2d/src/tessellate.rs +++ b/src-tauri/render2d/src/tessellate.rs @@ -16,7 +16,7 @@ // Schicht, die ohne Display gruen bleiben muss. // - Ergebnis ist byte-fuer-byte-vergleichbar mit dem WebGL-Referenzpfad. -use crate::types::{FillPolygon, Line, Point, Polyline, Rgba, Scene}; +use crate::types::{Arc, FillPolygon, Line, Point, Rgba, Scene}; /// viewBox-Einheiten je Meter (identisch zu PlanView/toScreen im Web-Pfad). pub const PX_PER_M: f32 = 90.0; @@ -27,6 +27,46 @@ pub fn to_screen(p: Point) -> Point { [p[0] * PX_PER_M, -p[1] * PX_PER_M] } +/// Mindest-Sehnenabweichung (Sagitta) in Geraete-px, unter der ein Kreisbogen als +/// glatt gilt. Kleiner -> mehr Segmente bei gleichem Radius/Zoom. +const ARC_SAGITTA_TOL_PX: f32 = 0.3; + +/// Kreisbogen (kuerzerer Sweep von `from` nach `to` um `center`, wie die alte +/// JS-`tessellateArc`) zoomabhaengig in eine offene Punktfolge (Modell-Meter) +/// zerlegen. Winkelschritt so klein, dass die Sehnen-Abweichung (Sagitta) bei der +/// aktuellen Bildschirm-Skala `px_per_m` (Geraete-px je Modell-Meter) unter +/// `ARC_SAGITTA_TOL_PX` bleibt: sagitta = r*(1-cos(dtheta/2)) <= tol +/// => dtheta <= 2*acos(1 - tol/r_px), r_px = r*px_per_m. Segmentzahl auf 8..512 +/// geklemmt (nie zu grob, nie unnoetig fein). Erster/letzter Punkt werden EXAKT +/// auf `from`/`to` gesetzt (kein Trig-Rundungsfehler an den Enden). +pub(crate) fn tessellate_arc(center: Point, from: Point, to: Point, r: f32, px_per_m: f32) -> Vec { + let a0 = (from[1] - center[1]).atan2(from[0] - center[0]); + let a1_raw = (to[1] - center[1]).atan2(to[0] - center[0]); + // Kuerzeren Bogen waehlen (die Szene liefert keine largeArc-Info mit) — 1:1 + // die Winkel-Normalisierung der alten JS-`tessellateArc`. + let mut delta = a1_raw - a0; + 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; + } + + let r_px = (r * px_per_m).max(1e-6); + let ratio = (1.0 - (ARC_SAGITTA_TOL_PX / r_px)).clamp(-1.0, 1.0); + let max_dtheta = (2.0 * ratio.acos()).max(1e-4); + let segs = ((delta.abs() / max_dtheta).ceil() as u32).clamp(8, 512); + + let mut pts = Vec::with_capacity(segs as usize + 1); + pts.push(from); + for i in 1..segs { + let t = a0 + delta * (i as f32) / (segs as f32); + pts.push([center[0] + t.cos() * r, center[1] + t.sin() * r]); + } + pts.push(to); + pts +} + /// Signierte Flaeche (Shoelace); >0 = CCW (Modell-Y nach oben). fn signed_area(pts: &[Point]) -> f32 { let mut a = 0.0f32; @@ -364,9 +404,22 @@ impl Bounds { } } -/// Tessellliert eine ganze Szene zu GPU-Geometrie (gefuellte Polygone + -/// Papier-mm-Striche). Reihenfolge: Fuellungen -> Umrisse -> freie Linien. +/// Tessellliert eine ganze Szene zu GPU-Geometrie beim Standard-Massstab +/// (`PX_PER_M`, kein Zoom beruecksichtigt) — Kompatibilitaets-Wrapper fuer +/// bestehende Aufrufer (Tests, Demo, Fenster-Spike), die keinen aktuellen +/// Geraete-px-je-Meter-Wert kennen. Der eigentliche (zoomabhaengige) Renderpfad +/// nutzt `compile_scene_scaled` direkt (siehe `gpu::Renderer::upload_scene`). pub fn compile_scene(scene: &Scene) -> GpuGeometry { + compile_scene_scaled(scene, PX_PER_M) +} + +/// Tessellliert eine ganze Szene zu GPU-Geometrie (gefuellte Polygone + +/// Papier-mm-Striche). Reihenfolge: Fuellungen -> Umrisse -> offene Polylinien +/// -> Boegen -> freie Linien (siehe `Scene`-Doc-Kommentar). `px_per_m` sind die +/// aktuellen Geraete-px je Modell-Meter (aus der laufenden Zoom-Stufe) — treibt +/// NUR die Bogen-Adaptivitaet (`tessellate_arc`); alles andere ist Zoom-invariant +/// (Papier-mm-Striche werden erst im Shader auf Geraete-px abgebildet). +pub fn compile_scene_scaled(scene: &Scene, px_per_m: f32) -> GpuGeometry { let mut geo = GpuGeometry::default(); let mut bounds = Bounds::new(); @@ -375,6 +428,8 @@ pub fn compile_scene(scene: &Scene) -> GpuGeometry { compile_fill(&mut geo, poly, &mut bounds); } // 2) Umrisse (geschlossene Ringe als EIN gehrter Streifen, inkl. Schluss-Kante). + // `dash` existiert bereits im Typ (siehe `types::Outline`), wird aber erst + // mit der Dash-Zerlegung (Welle 2) beruecksichtigt. for o in &scene.outlines { if o.width_mm > 0.0 && o.pts.len() >= 2 { geo.stroke_polyline(&o.pts, true, o.color, o.width_mm, &mut bounds); @@ -384,9 +439,17 @@ pub fn compile_scene(scene: &Scene) -> GpuGeometry { // gehrter Streifen — die inneren Ecken bekommen so eine Gehrung statt // Stumpfkappen (closed=false). for pl in &scene.polylines { - compile_polyline(&mut geo, pl, &mut bounds); + if pl.width_mm > 0.0 && pl.pts.len() >= 2 { + geo.stroke_polyline(&pl.pts, false, pl.color, pl.width_mm, &mut bounds); + } } - // 4) Freie Einzel-Linien (Tuerblaetter, Bogen-Sehnen, Referenzlinien). + // 4) Kreisboegen: zoomabhaengig in eine Punktfolge zerlegt (`tessellate_arc`), + // dann wie eine offene Polylinie gestrichen. `dash` wird (noch) ignoriert + // (durchgezogen gezeichnet) — die Zerlegung in Teilstriche folgt in Welle 2. + for a in &scene.arcs { + compile_arc(&mut geo, a, px_per_m, &mut bounds); + } + // 5) Freie Einzel-Linien (Tuerblaetter, Referenzlinien). for l in &scene.lines { compile_line(&mut geo, l, &mut bounds); } @@ -395,12 +458,6 @@ pub fn compile_scene(scene: &Scene) -> GpuGeometry { geo } -fn compile_polyline(geo: &mut GpuGeometry, pl: &Polyline, bounds: &mut Bounds) { - if pl.width_mm > 0.0 && pl.pts.len() >= 2 { - geo.stroke_polyline(&pl.pts, false, pl.color, pl.width_mm, bounds); - } -} - fn compile_fill(geo: &mut GpuGeometry, poly: &FillPolygon, bounds: &mut Bounds) { let tris = triangulate(&poly.pts); if tris.is_empty() { @@ -420,7 +477,82 @@ fn compile_fill(geo: &mut GpuGeometry, poly: &FillPolygon, bounds: &mut Bounds) geo.add_fill_batch(count, poly.color); } +fn compile_arc(geo: &mut GpuGeometry, a: &Arc, px_per_m: f32, bounds: &mut Bounds) { + if a.width_mm <= 0.0 { + return; + } + let pts = tessellate_arc(a.center, a.from, a.to, a.r, px_per_m); + geo.stroke_polyline(&pts, false, a.color, a.width_mm, bounds); +} + fn compile_line(geo: &mut GpuGeometry, l: &Line, bounds: &mut Bounds) { let w = if l.width_mm > 0.0 { l.width_mm } else { 0.18 }; geo.stroke_polyline(&[l.a, l.b], false, l.color, w, bounds); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Arc; + + // --- tessellate_arc: adaptive Bogen-Zerlegung ---------------------------- + + #[test] + fn arc_endpunkte_exakt() { + let center = [1.0, 1.0]; + let from = [2.0, 1.0]; // r=1, Winkel 0 + let to = [1.0, 2.0]; // Winkel 90 Grad + let pts = tessellate_arc(center, from, to, 1.0, 100.0); + assert_eq!(*pts.first().unwrap(), from, "erster Punkt exakt = from"); + assert_eq!(*pts.last().unwrap(), to, "letzter Punkt exakt = to"); + } + + #[test] + fn arc_segmentzahl_waechst_mit_px_per_m() { + let center = [0.0, 0.0]; + let from = [1.0, 0.0]; + let to = [0.0, 1.0]; // Viertelkreis, r=1 + let low = tessellate_arc(center, from, to, 1.0, 10.0); + let high = tessellate_arc(center, from, to, 1.0, 1000.0); + let segs_low = low.len() - 1; + let segs_high = high.len() - 1; + assert!( + segs_high > segs_low, + "mehr Segmente bei hoeherem Zoom erwartet: {segs_low} vs {segs_high}" + ); + } + + #[test] + fn arc_segmentzahl_geklemmt_min_max() { + let center = [0.0, 0.0]; + let from = [1.0, 0.0]; + let to = [0.0, 1.0]; + // Sehr grober Fall (winziger Radius/Zoom) -> trotzdem mindestens 8 Segmente. + let coarse = tessellate_arc(center, from, to, 0.001, 1.0); + assert!(coarse.len() - 1 >= 8, "min. 8 Segmente erwartet"); + // Extremer Zoom -> nie mehr als 512 Segmente. + let fine = tessellate_arc(center, from, to, 1.0, 1.0e9); + assert!(fine.len() - 1 <= 512, "max. 512 Segmente erwartet"); + } + + // --- compile_scene_scaled: End-zu-End-Verdrahtung ------------------------- + + #[test] + fn compile_scene_mit_bogen_erzeugt_liniengeometrie() { + let scene = Scene { + arcs: vec![Arc { + center: [0.0, 0.0], + from: [1.0, 0.0], + to: [0.0, 1.0], + r: 1.0, + color: [0.1, 0.1, 0.1, 1.0], + width_mm: 0.25, + dash: None, + }], + ..Default::default() + }; + let geo = compile_scene_scaled(&scene, PX_PER_M); + assert!(!geo.line_verts.is_empty(), "Bogen sollte Linien-Vertices erzeugen"); + assert!(!geo.line_idx.is_empty(), "Bogen sollte Linien-Indizes erzeugen"); + } +} diff --git a/src-tauri/render2d/src/types.rs b/src-tauri/render2d/src/types.rs index d38f95d..4287fae 100644 --- a/src-tauri/render2d/src/types.rs +++ b/src-tauri/render2d/src/types.rs @@ -36,6 +36,9 @@ pub struct Line { /// Strichbreite in ECHTEN Papier-Millimetern (siehe `stroke::mm_to_device_px`). #[serde(rename = "widthMm")] pub width_mm: f32, + /// Strichmuster in Papier-mm; None/leer = durchgezogen (siehe `tessellate::split_dash`). + #[serde(default)] + pub dash: Option>, } /// Ein geschlossenes Polygon als Umriss (nur Kanten, keine Fuellung) — bequemer @@ -50,6 +53,11 @@ pub struct Outline { /// Strichbreite in echten Papier-Millimetern. #[serde(rename = "widthMm")] pub width_mm: f32, + /// Strichmuster in Papier-mm; None/leer = durchgezogen. Ein gestrichelter + /// Umriss wird als offene Teilstuecke gezeichnet (kein gehrter Ring-Schluss + /// ueber die Dash-Luecke hinweg, siehe `tessellate::split_dash`). + #[serde(default)] + pub dash: Option>, } /// Ein OFFENER Linienzug mit echter Papier-mm-Breite. Anders als `Outline` @@ -66,6 +74,34 @@ pub struct Polyline { /// Strichbreite in echten Papier-Millimetern. #[serde(rename = "widthMm")] pub width_mm: f32, + /// Strichmuster in Papier-mm; None/leer = durchgezogen. + #[serde(default)] + pub dash: Option>, +} + +/// Ein Kreisbogen (kuerzerer Sweep von `from` nach `to` um `center`) in Modell- +/// Metern — NICHT vortessellliert (anders als frueher, wo der Web-Renderer den +/// Bogen einmalig in eine feste Facettenzahl zerlegte). Die Zerlegung passiert +/// erst in `tessellate::tessellate_arc`, zoomabhaengig, damit der Bogen bei +/// jeder Vergroesserung glatt bleibt statt sichtbare Facetten zu zeigen. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Arc { + /// Mittelpunkt in Modell-Metern. + pub center: Point, + /// Anfangspunkt (auf dem Kreis) in Modell-Metern. + pub from: Point, + /// Endpunkt (auf dem Kreis) in Modell-Metern. + pub to: Point, + /// Radius in Modell-Metern. + pub r: f32, + /// Strichfarbe (RGBA 0..1). + pub color: Rgba, + /// Strichbreite in echten Papier-Millimetern. + #[serde(rename = "widthMm")] + pub width_mm: f32, + /// Strichmuster in Papier-mm; None = durchgezogen (siehe `tessellate::split_dash`). + #[serde(default)] + pub dash: Option>, } /// Horizontale Ausrichtung eines Textes relativ zu seinem Anker (deckungsgleich @@ -102,8 +138,8 @@ pub struct Text { /// Die vollstaendige Szene: alles, was ein Frame zeichnet. Reihenfolge ist /// signifikant (Z-/Alpha-Ueberlagerung): erst Fuellungen, dann Umrisse, dann -/// offene Polylinien, dann freie Linien — analog zur Draw-Reihenfolge im WebGL-Pfad. -/// Texte liegen zuoberst (eigener Glyphen-Pass nach der Geometrie). +/// offene Polylinien, dann Boegen, dann freie Linien — analog zur Draw-Reihenfolge +/// im WebGL-Pfad. Texte liegen zuoberst (eigener Glyphen-Pass nach der Geometrie). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Scene { #[serde(default)] @@ -112,6 +148,10 @@ pub struct Scene { pub outlines: Vec, #[serde(default)] pub polylines: Vec, + /// Kreisboegen (unvortessellliert, siehe `Arc`); leer in aelteren Szenen + /// ohne Bogen-Unterstuetzung (`#[serde(default)]`). + #[serde(default)] + pub arcs: Vec, #[serde(default)] pub lines: Vec, #[serde(default)] diff --git a/src-tauri/src/native.rs b/src-tauri/src/native.rs index d25173d..b6ba67b 100644 --- a/src-tauri/src/native.rs +++ b/src-tauri/src/native.rs @@ -121,10 +121,11 @@ struct GpuState2d { #[cfg(feature = "native2d")] impl GpuState2d { - fn new(window: Arc, scene: &Scene) -> Self { + fn new(window: Arc, scene: &Scene, view_box: ViewBox) -> Self { let (surface, device, queue, config) = configure_surface(&window, "2d.device"); let mut renderer = Renderer2d::new(&device, config.format); - renderer.upload_scene(&device, scene); + let px_per_m = PX_PER_M * meet_scale(view_box, config.width as f32, config.height as f32); + renderer.upload_scene(&device, scene, px_per_m); Self { surface, device, queue, config, renderer, window } } @@ -661,8 +662,9 @@ impl ApplicationHandler for App { self.id2d = Some(window.id()); // Kam schon ein Live-Push an, gewinnt der; sonst der JSON-Snapshot. let scene = self.pending2d.take().unwrap_or_else(load_scene); - self.view_box = Some(scene_view_box(&scene)); - let state = GpuState2d::new(window, &scene); + let view_box = scene_view_box(&scene); + self.view_box = Some(view_box); + let state = GpuState2d::new(window, &scene, view_box); state.window.request_redraw(); self.s2d = Some(state); } @@ -694,7 +696,9 @@ impl ApplicationHandler for App { self.pending2d = Some(scene); return; }; - state.renderer.upload_scene(&state.device, &scene); + let vb = *self.view_box.get_or_insert_with(initial_view_box); + let px_per_m = PX_PER_M * meet_scale(vb, state.config.width as f32, state.config.height as f32); + state.renderer.upload_scene(&state.device, &scene, px_per_m); if !self.nav2d { self.view_box = Some(scene_view_box(&scene)); } diff --git a/src/plan/toRenderScene.ts b/src/plan/toRenderScene.ts index 87c423e..372ea71 100644 --- a/src/plan/toRenderScene.ts +++ b/src/plan/toRenderScene.ts @@ -5,9 +5,14 @@ // einen GPU-Glyphen-Atlas (glyphon/cosmic-text), keine Vektor-Ersatzschrift. // // Das erzeugte Objekt matcht 1:1 die serde-Structs render2d::types::Scene -// { fills:[{pts,color}], outlines:[{pts,color,widthMm}], lines:[{a,b,color,widthMm}], +// { fills:[{pts,color}], outlines:[{pts,color,widthMm}], polylines:[{pts,color,widthMm}], +// arcs:[{center,from,to,r,color,widthMm,dash}], lines:[{a,b,color,widthMm}], // texts:[{pos,content,sizeMm,color,align}] } // mit Point = [x,y] (Meter) und Rgba = [r,g,b,a] (0..1). +// +// Bögen werden NICHT hier tessellliert (anders als früher): der Rust-Renderer +// zerlegt sie zoomabhängig (`tessellate::tessellate_arc`), damit sie bei jeder +// Vergrößerung glatt bleiben statt sichtbare Facetten zu zeigen. import type { Plan, Primitive } from "./generatePlan"; import { applyDashRuns, buildHatchRuns } from "./glPlan/glPlanHatch"; @@ -26,17 +31,29 @@ export interface ROutline { pts: RPoint[]; color: RRgba; widthMm: number; + dash?: number[] | null; } export interface RPolyline { pts: RPoint[]; color: RRgba; widthMm: number; + dash?: number[] | null; +} +export interface RArc { + center: RPoint; + from: RPoint; + to: RPoint; + r: number; + color: RRgba; + widthMm: number; + dash?: number[] | null; } export interface RLine { a: RPoint; b: RPoint; color: RRgba; widthMm: number; + dash?: number[] | null; } export type RTextAlign = "left" | "center" | "right"; /** EINE Textzeile; serde-kompatibel zu render2d::types::Text. */ @@ -53,6 +70,7 @@ export interface RScene { fills: RFill[]; outlines: ROutline[]; polylines: RPolyline[]; + arcs: RArc[]; lines: RLine[]; texts: RText[]; } @@ -104,29 +122,6 @@ function toRgba(input: string | undefined, alpha = 1): RRgba | null { return [r, g, b, a]; } -/** Kreisbogen in Liniensegmente zerlegen (kürzerer Sweep, ~24 Segmente). */ -function tessellateArc( - center: { x: number; y: number }, - from: { x: number; y: number }, - to: { x: number; y: number }, - r: number, -): RPoint[] { - const a0 = Math.atan2(from.y - center.y, from.x - center.x); - let a1 = Math.atan2(to.y - center.y, to.x - center.x); - // Kürzeren Bogen wählen (generatePlan liefert keine largeArc-Info mit). - let delta = a1 - a0; - while (delta > Math.PI) delta -= 2 * Math.PI; - while (delta < -Math.PI) delta += 2 * Math.PI; - a1 = a0 + delta; - const segs = Math.max(2, Math.ceil((Math.abs(delta) / (Math.PI * 2)) * 48)); - const pts: RPoint[] = []; - for (let i = 0; i <= segs; i++) { - const t = a0 + (delta * i) / segs; - pts.push([center.x + Math.cos(t) * r, center.y + Math.sin(t) * r]); - } - return pts; -} - /** Zwei Modell-Punkte (Meter) als gleich behandeln (Verkettungs-Toleranz). */ function samePt(a: RPoint, b: RPoint): boolean { return Math.abs(a[0] - b[0]) < 1e-6 && Math.abs(a[1] - b[1]) < 1e-6; @@ -234,6 +229,7 @@ export function planToRenderScene(plan: Plan): RScene { const fills: RFill[] = []; const outlines: ROutline[] = []; const polylines: RPolyline[] = []; + const arcs: RArc[] = []; const lines: RLine[] = []; const texts: RText[] = []; @@ -327,9 +323,18 @@ export function planToRenderScene(plan: Plan): RScene { } else if (p.kind === "arc") { flushRun(); const col = toRgba(DEFAULT_LINE, 1) ?? [0.1, 0.1, 0.1, 1]; - // Bogen als EINE zusammenhängende Polylinie (gehrte Sehnen-Ecken). - const poly = tessellateArc(p.center, p.from, p.to, p.r); - if (poly.length >= 2) polylines.push({ pts: poly, color: col, widthMm: p.weightMm }); + // Bogen unvortessellliert an Rust übergeben (`RArc`) — die zoomabhängige + // Zerlegung (glatte Rundung bei jeder Vergrößerung) und ein evtl. Strich- + // muster übernimmt `tessellate::tessellate_arc`/`split_dash` drüben. + arcs.push({ + center: [p.center.x, p.center.y], + from: [p.from.x, p.from.y], + to: [p.to.x, p.to.y], + r: p.r, + color: col, + widthMm: p.weightMm, + dash: p.dash ?? null, + }); } else { // "text": zeilenweise flachen — die ECHTEN Glyphen rastert der Rust- // Renderer über einen GPU-Glyphen-Atlas (glyphon, dieselbe Font-Familie @@ -398,5 +403,5 @@ export function planToRenderScene(plan: Plan): RScene { } } - return { fills, outlines, polylines, lines, texts }; + return { fills, outlines, polylines, arcs, lines, texts }; }