From 2a2b3b294ca57ecf79bcc4aaea7fc6e46a52ec63 Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 2 Jul 2026 22:07:18 +0200 Subject: [PATCH] =?UTF-8?q?2D-B=C3=B6gen=20analytisch:=20exakter=20Kreis-S?= =?UTF-8?q?hader=20statt=20Segment-Tessellierung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bögen im nativen 2D-wgpu-Renderer werden nicht mehr zoomabhängig in Segmente zerlegt, sondern per SDF-Fragment-Shader (ARC_WGSL) mathematisch exakt rund gerendert — bei jeder Zoomstufe ein echter Kreis, kein Vieleck, ohne Neu-Tessellierung. - compile_scene sammelt je Bogen EINE analytische Instanz (ArcInstanceData, Bildschirm-Raum-Parameter + Dash in Modell-Metern), zoom-invariant. - Eigene Arc-Pipeline (ein Frame-Uniform, Quad je Instanz aus vertex_index): radiale Kante, Butt-Cap-Winkelclamp (beide Sweep-Vorzeichen) und Dash (Bogenlänge modulo Muster) analytisch antialiased; Strichbreite mit derselben mm->px-Formel wie die Linien. - tessellate_arc + Zoom-Retessellierungs-Cache (last_scene/arc_px_per_m/ maybe_retessellate) entfernt; upload_scene ohne px_per_m. - Tests auf die neue Semantik umgeschrieben (Winkel-Parität, Bounding-Box, Dash-Mapping), ARC_WGSL per naga validiert. --- src-tauri/render2d/src/bin/spike.rs | 6 +- src-tauri/render2d/src/gpu.rs | 238 ++++++++++++++++---- src-tauri/render2d/src/lib.rs | 6 +- src-tauri/render2d/src/shaders.rs | 161 ++++++++++++++ src-tauri/render2d/src/tessellate.rs | 319 +++++++++++++++++---------- src-tauri/render2d/src/types.rs | 8 +- src-tauri/src/native.rs | 9 +- src/plan/toRenderScene.ts | 13 +- 8 files changed, 588 insertions(+), 172 deletions(-) diff --git a/src-tauri/render2d/src/bin/spike.rs b/src-tauri/render2d/src/bin/spike.rs index c3235b8..5103701 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, meet_scale, PX_PER_M}; +use render2d::{demo_scene, initial_view_box}; use winit::application::ApplicationHandler; use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}; @@ -74,9 +74,7 @@ impl GpuState { surface.configure(&device, &config); let mut renderer = Renderer::new(&device, format); - 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); + renderer.upload_scene(&device, &demo_scene()); Self { surface, diff --git a/src-tauri/render2d/src/gpu.rs b/src-tauri/render2d/src/gpu.rs index ced58a0..a067d93 100644 --- a/src-tauri/render2d/src/gpu.rs +++ b/src-tauri/render2d/src/gpu.rs @@ -18,8 +18,8 @@ use bytemuck::{Pod, Zeroable}; 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_scaled, to_screen, GpuGeometry, PX_PER_M}; +use crate::shaders::{ARC_WGSL, FILL_WGSL, LINE_WGSL}; +use crate::tessellate::{compile_scene, to_screen, ArcInstanceData, GpuGeometry, MAX_ARC_DASH}; use crate::types::{Scene, Text, TextAlign, ViewBox}; /// MSAA-Faktor: 4x Multisampling fuer glatte Linien-/Fuellkanten (wie im Browser). @@ -49,6 +49,49 @@ impl Default for Globals { } } +/// Frame-Uniform der analytischen Bogen-Pipeline (1:1 zu `ArcGlobals` in WGSL). +/// EIN Block je Frame (fuer ALLE Boegen gleich) — anders als die per-Batch- +/// `Globals` braucht er keinen dynamischen Offset. std140: mat4 + vec2 + 2 Skalare +/// = 80 Byte (16-Byte-Vielfaches). +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct ArcGlobals { + view_proj: [f32; 16], + viewport_px: [f32; 2], + px_per_screen: f32, + stroke_scale: f32, +} + +/// EINE Bogen-Instanz fuer die GPU (Vertex-Puffer, step_mode Instance), 1:1 zum +/// Instanz-Layout in `ARC_WGSL`. Zoom-invariant: Bildschirm-Raum-Geometrie + +/// Dash in Modell-Metern. 18 f32 = 72 Byte. +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct ArcInstance { + center: [f32; 2], + /// (r_screen, a0, sweep, r_model) + geom: [f32; 4], + color: [f32; 4], + /// (width_mm, dash_total, dash_count, _pad) + wdash: [f32; 4], + /// bis zu MAX_ARC_DASH An/Aus-Laengen (Modell-Meter). + dash: [f32; 4], +} + +impl ArcInstance { + fn from_data(a: &ArcInstanceData) -> Self { + // MAX_ARC_DASH ist 4 (== vec4 im Shader); Compile-Time abgesichert. + const _: () = assert!(MAX_ARC_DASH == 4); + Self { + center: a.center, + geom: [a.r_screen, a.a0, a.sweep, a.r_model], + color: a.color, + wdash: [a.width_mm, a.dash_total, a.dash_count as f32, 0.0], + dash: a.dash, + } + } +} + /// Rundet `size` auf das naechste Vielfache von `align` (>=1) auf. fn align_up(size: u64, align: u64) -> u64 { if align <= 1 { @@ -63,6 +106,10 @@ struct SceneBuffers { fill_ibo: Option, line_vbo: Option, line_ibo: Option, + /// Instanz-Puffer der analytischen Boegen (je Bogen EINE Instanz); None wenn leer. + arc_vbo: Option, + /// Anzahl Bogen-Instanzen (Draw: 6 Vertices je Instanz). + arc_count: u32, geo: GpuGeometry, /// Textzeilen der Szene (Modell-Anker + Papier-mm-Groesse). Werden nicht /// tesselliert, sondern pro Frame ueber den Glyphen-Atlas gesetzt (die @@ -93,16 +140,16 @@ pub struct Renderer { fill_pipeline: wgpu::RenderPipeline, line_pipeline: wgpu::RenderPipeline, bind_group_layout: wgpu::BindGroupLayout, + /// Analytische Bogen-Pipeline (exakter Kreis-Shader, `ARC_WGSL`). + arc_pipeline: wgpu::RenderPipeline, + /// Frame-Uniform der Bogen-Pipeline (ein Block, kein dynamischer Offset). + arc_uniform: wgpu::Buffer, + arc_bind_group: wgpu::BindGroup, /// Ausgerichtete Groesse eines Globals-Blocks im dynamischen Uniform-Puffer. uniform_stride: u64, /// 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. @@ -242,6 +289,91 @@ impl Renderer { cache: None, }); + // ── Analytische Bogen-Pipeline (exakter Kreis-Shader) ───────────────── + let arc_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("arc.wgsl"), + source: wgpu::ShaderSource::Wgsl(ARC_WGSL.into()), + }); + // Eigenes Layout: EIN Uniform-Block je Frame (kein dynamischer Offset). + let arc_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("arc.globals.layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new( + std::mem::size_of::() as u64, + ), + }, + count: None, + }], + }); + let arc_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("arc.pipeline.layout"), + bind_group_layouts: &[&arc_bind_group_layout], + push_constant_ranges: &[], + }); + // Instanz-Layout: [center vec2, geom vec4, color vec4, wdash vec4, dash vec4], + // stride 18*4, step_mode Instance. Das Quad kommt aus @builtin(vertex_index). + let arc_instance_layout = wgpu::VertexBufferLayout { + array_stride: 18 * 4, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &wgpu::vertex_attr_array![ + 0 => Float32x2, 1 => Float32x4, 2 => Float32x4, 3 => Float32x4, 4 => Float32x4 + ], + }; + let arc_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("arc.pipeline"), + layout: Some(&arc_pipeline_layout), + vertex: wgpu::VertexState { + module: &arc_module, + entry_point: "vs_main", + buffers: &[arc_instance_layout], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &arc_module, + entry_point: "fs_main", + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: SAMPLE_COUNT, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview: None, + cache: None, + }); + let arc_uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("arc.globals.buffer"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let arc_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("arc.globals.bind"), + layout: &arc_bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: arc_uniform.as_entire_binding(), + }], + }); + // Block-Stride = Globals auf die Dynamic-Offset-Ausrichtung des Geraets gepolstert. let min_align = device.limits().min_uniform_buffer_offset_alignment as u64; let uniform_stride = align_up(std::mem::size_of::() as u64, min_align.max(1)); @@ -250,11 +382,12 @@ impl Renderer { fill_pipeline, line_pipeline, bind_group_layout, + arc_pipeline, + arc_uniform, + arc_bind_group, 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), @@ -270,12 +403,13 @@ impl Renderer { } } - /// 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); + /// Kompiliert eine Szene und laedt die GPU-Puffer hoch. Alles ist zoom- + /// invariant — Papier-mm-Striche wie auch die analytischen Boegen werden erst + /// im Shader auf Geraete-px abgebildet. Daher KEIN Zoom-Parameter und keine + /// Neu-Tessellierung bei Zoomaenderung mehr (der frueher noetige Bogen-Re-Tess- + /// Trigger entfaellt: der Kreis-Shader rendert bei jeder Skala exakt rund). + pub fn upload_scene(&mut self, device: &wgpu::Device, scene: &Scene) { + let geo = compile_scene(scene); let mk_vbo = |data: &[f32], label: &str| -> Option { if data.is_empty() { @@ -298,37 +432,30 @@ impl Renderer { })) }; + // Bogen-Instanzen (je Bogen EINE) in einen Instanz-Vertexpuffer packen. + let arc_instances: Vec = + geo.arcs.iter().map(ArcInstance::from_data).collect(); + let arc_count = arc_instances.len() as u32; + let arc_vbo = if arc_instances.is_empty() { + None + } else { + Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("arc.instances"), + contents: bytemuck::cast_slice(&arc_instances), + usage: wgpu::BufferUsages::VERTEX, + })) + }; + self.scene = Some(SceneBuffers { fill_vbo: mk_vbo(&geo.fill_pos, "fill.vbo"), fill_ibo: mk_ibo(&geo.fill_idx, "fill.ibo"), line_vbo: mk_vbo(&geo.line_verts, "line.vbo"), line_ibo: mk_ibo(&geo.line_idx, "line.ibo"), + arc_vbo, + arc_count, 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 @@ -431,11 +558,25 @@ 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); + // Geraete-px je Bildschirm-Einheit (== meet-Skala) — der Bogen-Shader braucht + // beides: px_per_screen fuer Radial-/Kappen-/Dash-AA, stroke_scale fuer die + // Papier-mm-Breite (identisch zu den Linien). + let px_per_screen = meet_scale(view_box, vw, vh); + + // Frame-Uniform der Bogen-Pipeline schreiben (ein Block fuer ALLE Boegen). + queue.write_buffer( + &self.arc_uniform, + 0, + bytemuck::bytes_of(&ArcGlobals { + view_proj: proj, + viewport_px: [vw, vh], + px_per_screen, + stroke_scale: mm_px, + }), + ); // Alle Globals-Bloecke der Reihenfolge nach (erst Fuell-, dann Linien-Batches) // sammeln, den Puffer einmal schreiben, danach nur noch dynamisch binden. @@ -650,7 +791,22 @@ impl Renderer { } } - // 3) Text ZUOBERST im selben MSAA-Pass (der TextRenderer wurde mit + // 3) Analytische Boegen: eigene Pipeline, EIN Frame-Uniform, je Bogen + // EINE Instanz (6 Vertices, Quad aus vertex_index). Mathematisch + // exakt rund per SDF — nach den Linien, damit der Schwenkbogen ueber + // dem Tuerblatt liegt. + if let Some(scene) = &self.scene { + if let Some(arc_vbo) = &scene.arc_vbo { + if scene.arc_count > 0 { + pass.set_pipeline(&self.arc_pipeline); + pass.set_bind_group(0, &self.arc_bind_group, &[]); + pass.set_vertex_buffer(0, arc_vbo.slice(..)); + pass.draw(0..6, 0..scene.arc_count); + } + } + } + + // 4) Text ZUOBERST im selben MSAA-Pass (der TextRenderer wurde mit // identischem MultisampleState erstellt, siehe ensure_text). if let Some(ts) = &self.text { if let Err(e) = ts.renderer.render(&ts.atlas, &ts.viewport, &mut pass) { diff --git a/src-tauri/render2d/src/lib.rs b/src-tauri/render2d/src/lib.rs index 9bc7256..1bb5b09 100644 --- a/src-tauri/render2d/src/lib.rs +++ b/src-tauri/render2d/src/lib.rs @@ -23,7 +23,10 @@ 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, compile_scene_scaled, triangulate, GpuGeometry, PX_PER_M}; +pub use tessellate::{ + arc_screen_params, compile_scene, prepare_arc_dash, triangulate, ArcInstanceData, GpuGeometry, + MAX_ARC_DASH, PX_PER_M, +}; pub use types::{Arc, FillPolygon, Line, Outline, Point, Rgba, Scene, Text, TextAlign, ViewBox}; // --- Tests: Tessellierung (Muster wie glPlanCompile.test.ts) ----------------- @@ -163,6 +166,7 @@ mod tests { for (name, src) in [ ("fill", super::shaders::FILL_WGSL), ("line", super::shaders::LINE_WGSL), + ("arc", super::shaders::ARC_WGSL), ] { let module = naga::front::wgsl::parse_str(src) .unwrap_or_else(|e| panic!("{name}: WGSL-Parse-Fehler: {e:?}")); diff --git a/src-tauri/render2d/src/shaders.rs b/src-tauri/render2d/src/shaders.rs index 751b0af..1b8b9c9 100644 --- a/src-tauri/render2d/src/shaders.rs +++ b/src-tauri/render2d/src/shaders.rs @@ -101,3 +101,164 @@ fn fs_main() -> @location(0) vec4 { return globals.color; } "#; + +/// Analytische BOGEN-Pipeline: rendert einen Kreisbogen mathematisch exakt (SDF im +/// Fragment-Shader) statt als Segment-Kette — bei jedem Zoom ein "richtiger" Kreis, +/// nie ein Vieleck. +/// +/// Ein Bogen = EINE Instanz + EIN Quad (6 Vertices, aus `vertex_index` erzeugt). +/// Das Quad ist die Bounding-Box des Bogens im BILDSCHIRM-Raum, im Vertex-Shader +/// aus der aktuellen px-Skala aufgespannt (Radius + halbe Strichbreite + AA-Rand), +/// sodass es bei jedem Zoom passt — OHNE Neu-Tessellierung. +/// +/// Instanz-Layout (alles zoom-invariant, in Bildschirm-Raum bzw. Modell-Metern): +/// @location(0) center : vec2 Mittelpunkt (Bildschirm-Raum) +/// @location(1) geom : vec4 (r_screen, a0, sweep, r_model) +/// @location(2) color : vec4 RGBA +/// @location(3) wdash : vec4 (width_mm, dash_total[m], dash_count, _pad) +/// @location(4) dash : vec4 bis zu MAX_ARC_DASH(=4) An/Aus-Laengen (Modell-m) +/// +/// Frame-Uniform (`ArcGlobals`): view_proj, viewport_px, px_per_screen (== meet- +/// Skala, Geraete-px je Bildschirm-Einheit) und stroke_scale (mm -> Geraete-px, +/// dieselbe Formel wie die Linien, `ortho::mm_to_device_px`). +/// +/// Fragment: `d = abs(length(p-center) - r)` gibt den Ring-Abstand; die Kante wird +/// analytisch per `smoothstep` (~device-px) geglaettet (MSAA glaettet zusaetzlich). +/// Der Winkel wird gegen [0, sweep] geklemmt (beide Sweep-Vorzeichen, sauberer +/// Wrap) mit Butt-Cap an den Enden. Dash: Bogenlaenge s = theta_rel * r_model +/// (Modell-Meter!) modulo Muster, weicher An/Aus-Uebergang. +pub const ARC_WGSL: &str = r#" +struct ArcGlobals { + view_proj : mat4x4, + viewport_px : vec2, + px_per_screen : f32, + stroke_scale : f32, +}; +@group(0) @binding(0) var g : ArcGlobals; + +const PI : f32 = 3.14159265358979; +// Bildschirm-Einheiten je Modell-Meter (== tessellate::PX_PER_M). Fest, weil die +// Instanz-Geometrie bereits in Bildschirm-Raum vorliegt (to_screen skaliert *90). +const PX_PER_M_2D : f32 = 90.0; + +struct VsOut { + @builtin(position) pos : vec4, + @location(0) frag : vec2, + @location(1) @interpolate(flat) center : vec2, + @location(2) @interpolate(flat) geom : vec4, + @location(3) @interpolate(flat) color : vec4, + @location(4) @interpolate(flat) wdash : vec4, + @location(5) @interpolate(flat) dash : vec4, +}; + +@vertex +fn vs_main( + @builtin(vertex_index) vidx : u32, + @location(0) center : vec2, + @location(1) geom : vec4, + @location(2) color : vec4, + @location(3) wdash : vec4, + @location(4) dash : vec4, +) -> VsOut { + // Zwei Dreiecke, Ecken in {-1,+1}^2. + var corners = array, 6>( + vec2(-1.0, -1.0), vec2( 1.0, -1.0), vec2( 1.0, 1.0), + vec2(-1.0, -1.0), vec2( 1.0, 1.0), vec2(-1.0, 1.0), + ); + let corner = corners[vidx]; + + let r_s = geom.x; + // Echte Papierbreite (Geraete-px) -> zurueck in Bildschirm-Einheiten fuer die + // Quad-Groesse; plus AA-Rand (~2 px). px_per_screen gegen 0 sichern. + let pps = max(g.px_per_screen, 1e-6); + let width_px = max(0.6, wdash.x * g.stroke_scale); + let half_w_screen = 0.5 * width_px / pps; + let aa_screen = 2.0 / pps; + let ext = r_s + half_w_screen + aa_screen; + + let p_screen = center + corner * ext; + + var out : VsOut; + out.pos = g.view_proj * vec4(p_screen, 0.0, 1.0); + out.frag = p_screen; + out.center = center; + out.geom = geom; + out.color = color; + out.wdash = wdash; + out.dash = dash; + return out; +} + +@fragment +fn fs_main(in : VsOut) -> @location(0) vec4 { + let center = in.center; + let r_s = in.geom.x; + let a0 = in.geom.y; + let sweep = in.geom.z; + let r_m = in.geom.w; + let width_mm = in.wdash.x; + let dash_total = in.wdash.y; + let dash_count = i32(in.wdash.z + 0.5); + let pps = max(g.px_per_screen, 1e-6); + + let rel = in.frag - center; + let dist = length(rel); + + // 1) Radiale Kante (Strichbreite quer zum Bogen), analytisch antialiased. + let d_ring_px = abs(dist - r_s) * pps; + let half_w_px = 0.5 * max(0.6, width_mm * g.stroke_scale); + let cov_radial = 1.0 - smoothstep(half_w_px - 0.6, half_w_px + 0.6, d_ring_px); + + // 2) Winkel-Clamp auf [min(0,sweep), max(0,sweep)] mit Butt-Cap an den Enden. + let theta = atan2(rel.y, rel.x); + var da = theta - a0; + da = da - 2.0 * PI * round(da / (2.0 * PI)); // Wrap nach (-pi, pi] + let lo = min(0.0, sweep); + let hi = max(0.0, sweep); + var sd : f32; + if (da < lo) { + sd = lo - da; + } else if (da > hi) { + sd = da - hi; + } else { + sd = -min(da - lo, hi - da); + } + let cap_px = sd * r_s * pps; // signierter Abstand zur Kappe (Geraete-px) + let cov_cap = 1.0 - smoothstep(-0.5, 0.5, cap_px); + + // 3) Dash: Bogenlaenge ab a0 (Modell-Meter) modulo Muster, weicher Uebergang. + var cov_dash = 1.0; + if (dash_count > 0 && dash_total > 1e-9) { + var progress = da; + if (sweep < 0.0) { progress = -da; } + progress = max(progress, 0.0); + let s_model = progress * r_m; + let m = s_model - dash_total * floor(s_model / dash_total); + + var cyc = array(in.dash.x, in.dash.y, in.dash.z, in.dash.w); + var acc = 0.0; + var cur_on = true; + var edge = dash_total; + for (var i = 0; i < 4; i = i + 1) { + if (i >= dash_count) { break; } + let seg = cyc[i]; + if (m >= acc && m < acc + seg) { + cur_on = (i % 2) == 0; // gerade Segmente = "an" + edge = min(m - acc, acc + seg - m); // Abstand zur naechsten Grenze + } + acc = acc + seg; + } + let px_per_m = PX_PER_M_2D * pps; + let edge_px = edge * px_per_m; + // Signierter Abstand: innen "an" positiv, innen "aus" negativ. + let sdist = select(-edge_px, edge_px, cur_on); + cov_dash = smoothstep(-0.5, 0.5, sdist); + } + + let a = in.color.a * cov_radial * cov_cap * cov_dash; + if (a <= 0.002) { + discard; + } + return vec4(in.color.rgb, a); +} +"#; diff --git a/src-tauri/render2d/src/tessellate.rs b/src-tauri/render2d/src/tessellate.rs index f0a33ec..3cd2321 100644 --- a/src-tauri/render2d/src/tessellate.rs +++ b/src-tauri/render2d/src/tessellate.rs @@ -27,44 +27,96 @@ 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; +/// Hoechstzahl Dash-Muster-Eintraege je Bogen (An/Aus-Laengen), die der analytische +/// Bogen-Shader modulo rechnet. Deckt die real vorkommenden Muster (max. EIN An/Aus- +/// Paar, z.B. Tuerschwenk [0.06,0.04]) mit Reserve; laengere Muster werden gekappt. +pub const MAX_ARC_DASH: usize = 4; -/// 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; +/// GPU-fertige Beschreibung EINES analytisch (im Fragment-Shader) gezeichneten +/// Bogens — bereits in BILDSCHIRM-Raum-Parametern, damit ihn die GPU pro Frame +/// mathematisch exakt rund rendert (SDF), ohne Segment-Tessellierung. Alle Felder +/// sind zoom-INVARIANT (haengen nicht von der aktuellen Skala ab): `center`/ +/// `r_screen` in Bildschirm-Einheiten, Winkel im Bildschirm-Raum, `dash` in +/// Modell-Metern. Die Strichbreite (`width_mm`) mappt der Shader pro Frame mit +/// derselben Formel wie die Linien (`ortho::mm_to_device_px`) auf Geraete-px. +#[derive(Debug, Clone)] +pub struct ArcInstanceData { + /// Mittelpunkt in Bildschirm-Raum-Einheiten (`to_screen(center)`). + pub center: Point, + /// Radius in Bildschirm-Raum-Einheiten (`r * PX_PER_M`). + pub r_screen: f32, + /// Radius in MODELL-Metern (fuer die Dash-Bogenlaenge). + pub r_model: f32, + /// Start-Winkel im Bildschirm-Raum (atan2 relativ `center`). + pub a0: f32, + /// Vorzeichenbehafteter Sweep (kuerzerer Bogen, |sweep| <= pi). + pub sweep: f32, + /// Strichfarbe (RGBA 0..1). + pub color: Rgba, + /// Strichbreite in echten Papier-Millimetern. + pub width_mm: f32, + /// Dash-Zyklus in MODELL-Metern (An/Aus-Laengen), ggf. verdoppelt bei ungerader + /// Laenge (Standard-Dash-Semantik, wie `split_dash`). Nur `dash_count` gueltig. + pub dash: [f32; MAX_ARC_DASH], + /// Anzahl gueltiger `dash`-Eintraege; 0 = durchgezogen. + pub dash_count: u32, + /// Summe der gueltigen `dash`-Eintraege (Zykluslaenge, Modell-Meter). + pub dash_total: f32, +} + +/// Bildschirm-Raum-Parameter eines Bogens: Mittelpunkt + Radius (Bildschirm- +/// Einheiten) sowie Start- und Sweep-Winkel IM BILDSCHIRM-RAUM. Der Sweep ist der +/// KUERZERE Bogen (|sweep| <= pi) — exakt die Winkel-Normalisierung der frueheren +/// `tessellate_arc` (dort im Modell-Raum). Die Bildschirm-Y-Spiegelung (`to_screen`) +/// negiert lediglich beide Winkel; der BETRAG von `sweep` bleibt identisch, sodass +/// weiterhin derselbe (kuerzere) Bogen entsteht. +pub fn arc_screen_params(center: Point, from: Point, to: Point, r: f32) -> (Point, f32, f32, f32) { + let cs = to_screen(center); + let fs = to_screen(from); + let ts = to_screen(to); + let a0 = (fs[1] - cs[1]).atan2(fs[0] - cs[0]); + let a1 = (ts[1] - cs[1]).atan2(ts[0] - cs[0]); + let mut delta = a1 - 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; } + (cs, r * PX_PER_M, a0, delta) +} - 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]); +/// Wandelt ein Papier-mm-Dash-Muster in den Modell-Meter-Zyklus, den der Bogen- +/// Shader modulo rechnet — IDENTISCHE Semantik zu `split_dash` (`DASH_MM_TO_M`- +/// Referenz, Verdopplung bei ungerader Musterlaenge). Auf `MAX_ARC_DASH` Eintraege +/// begrenzt; laengere Muster (kommen real nicht vor) werden gekappt. Rueckgabe: +/// (Zyklus, Anzahl, Gesamtlaenge); Anzahl 0 = durchgezogen. +pub fn prepare_arc_dash(dash: Option<&[f32]>) -> ([f32; MAX_ARC_DASH], u32, f32) { + let mut out = [0.0f32; MAX_ARC_DASH]; + let Some(d) = dash else { + return (out, 0, 0.0); + }; + let pat: Vec = d + .iter() + .copied() + .map(|v| v.max(0.0) * DASH_MM_TO_M) + .filter(|v| *v > 0.0) + .collect(); + if pat.is_empty() { + return (out, 0, 0.0); } - pts.push(to); - pts + let cycle: Vec = if pat.len() % 2 == 0 { + pat + } else { + [pat.as_slice(), pat.as_slice()].concat() + }; + let n = cycle.len().min(MAX_ARC_DASH); + let mut total = 0.0f32; + for (i, v) in cycle.iter().take(n).enumerate() { + out[i] = *v; + total += *v; + } + (out, n as u32, total) } /// Faktor Papier-mm -> Modell-Meter fuer ALLGEMEINE Linien-/Bogen-Striche, bei @@ -330,6 +382,9 @@ pub struct GpuGeometry { pub line_verts: Vec, pub line_idx: Vec, pub line_batches: Vec, + /// Analytisch gezeichnete Boegen (je EINE Instanz, kein Segment-Mesh mehr): + /// die GPU rendert sie pro Frame exakt rund per SDF-Fragment-Shader. + pub arcs: Vec, /// Modell-Bounds (Meter) fuer Debug/Einpassen; nicht render-kritisch. pub bounds: [f32; 4], // [min_x, min_y, max_x, max_y] } @@ -518,22 +573,13 @@ impl Bounds { } } -/// 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`). +/// Kompiliert eine ganze Szene zu GPU-Geometrie: gefuellte Polygone + +/// Papier-mm-Striche (tesselliert) + analytische Boegen (je EINE Instanz). +/// Reihenfolge: Fuellungen -> Umrisse -> offene Polylinien -> freie Linien; die +/// Boegen sammelt `geo.arcs` (eigene Pipeline, gezeichnet nach den Linien). ALLES +/// ist zoom-INVARIANT: Papier-mm-Striche wie auch die Bogen-Kanten werden erst im +/// Shader auf Geraete-px abgebildet — keine Neu-Tessellierung bei Zoom noetig. 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(); @@ -556,15 +602,16 @@ pub fn compile_scene_scaled(scene: &Scene, px_per_m: f32) -> GpuGeometry { stroke_dashed_or_solid(&mut geo, &pl.pts, false, pl.color, pl.width_mm, pl.dash.as_deref(), &mut bounds); } } - // 4) Kreisboegen: zoomabhaengig in eine Punktfolge zerlegt (`tessellate_arc`), - // dann wie eine offene Polylinie gestrichen (mit oder ohne Dash). - for a in &scene.arcs { - compile_arc(&mut geo, a, px_per_m, &mut bounds); - } - // 5) Freie Einzel-Linien (Tuerblaetter, Referenzlinien). + // 4) Freie Einzel-Linien (Tuerblaetter, Referenzlinien). for l in &scene.lines { compile_line(&mut geo, l, &mut bounds); } + // 5) Kreisboegen: NICHT mehr in Segmente zerlegt — je Bogen eine analytische + // Instanz (`ArcInstanceData`), die die GPU per SDF-Fragment-Shader exakt + // rund rendert (siehe `gpu::Renderer` Arc-Pipeline). + for a in &scene.arcs { + compile_arc(&mut geo, a, &mut bounds); + } geo.bounds = bounds.finish(); geo @@ -589,12 +636,33 @@ 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 { +fn compile_arc(geo: &mut GpuGeometry, a: &Arc, bounds: &mut Bounds) { + if a.width_mm <= 0.0 || a.r <= 0.0 { return; } - let pts = tessellate_arc(a.center, a.from, a.to, a.r, px_per_m); - stroke_dashed_or_solid(geo, &pts, false, a.color, a.width_mm, a.dash.as_deref(), bounds); + let (center, r_screen, a0, sweep) = arc_screen_params(a.center, a.from, a.to, a.r); + // Degenerierter Bogen (from == to -> Sweep ~ 0): nichts zu zeichnen. Echte + // Vollkreise kommen als geschlossenes Polygon (siehe generatePlan `circle`). + if sweep.abs() < 1e-6 { + return; + } + let (dash, dash_count, dash_total) = prepare_arc_dash(a.dash.as_deref()); + geo.arcs.push(ArcInstanceData { + center, + r_screen, + r_model: a.r, + a0, + sweep, + color: a.color, + width_mm: a.width_mm, + dash, + dash_count, + dash_total, + }); + // Modell-Bounds: Bounding-Box des vollen Kreises (grosszuegig, aber sicher + // fuer "Einpassen"; der sichtbare Bogen liegt stets darin). + bounds.track([a.center[0] - a.r, a.center[1] - a.r]); + bounds.track([a.center[0] + a.r, a.center[1] + a.r]); } fn compile_line(geo: &mut GpuGeometry, l: &Line, bounds: &mut Bounds) { @@ -607,44 +675,80 @@ mod tests { use super::*; use crate::types::{Arc, Outline}; - // --- tessellate_arc: adaptive Bogen-Zerlegung ---------------------------- + // --- arc_screen_params: analytische Bogen-Parameter ---------------------- - #[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"); + /// Modell-Raum-Sweep (kuerzerer Bogen) — Referenz fuer die Winkel-Paritaet + /// (frueher die Winkellogik der `tessellate_arc`). Die Bildschirm-Y-Spiegelung + /// aendert nur das Vorzeichen, nicht den Betrag. + fn model_delta(center: Point, from: Point, to: Point) -> f32 { + let a0 = (from[1] - center[1]).atan2(from[0] - center[0]); + let a1 = (to[1] - center[1]).atan2(to[0] - center[0]); + let mut d = a1 - a0; + while d > std::f32::consts::PI { + d -= 2.0 * std::f32::consts::PI; + } + while d < -std::f32::consts::PI { + d += 2.0 * std::f32::consts::PI; + } + d } #[test] - fn arc_segmentzahl_waechst_mit_px_per_m() { + fn arc_screen_params_endpunkte_treffen_exakt() { + let center = [1.0, 1.0]; + let from = [2.0, 1.0]; // r=1, Modell-Winkel 0 + let to = [1.0, 2.0]; // Modell-Winkel 90 Grad + let (cs, r_s, a0, sweep) = arc_screen_params(center, from, to, 1.0); + // Punkt bei a0 == to_screen(from), Punkt bei a0+sweep == to_screen(to). + let p0 = [cs[0] + a0.cos() * r_s, cs[1] + a0.sin() * r_s]; + let p1 = [cs[0] + (a0 + sweep).cos() * r_s, cs[1] + (a0 + sweep).sin() * r_s]; + let fs = to_screen(from); + let ts = to_screen(to); + assert!((p0[0] - fs[0]).abs() < 1e-3 && (p0[1] - fs[1]).abs() < 1e-3, "Start trifft from"); + assert!((p1[0] - ts[0]).abs() < 1e-3 && (p1[1] - ts[1]).abs() < 1e-3, "Ende trifft to"); + assert!((r_s - PX_PER_M).abs() < 1e-3, "r_screen = r * PX_PER_M"); + } + + #[test] + fn arc_screen_params_kuerzerer_bogen_paritaet() { 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; + // Ziel bei Modell-Winkel 200 Grad -> naiv 200 Grad, kuerzer aber -160 Grad. + let ang = 200.0_f32.to_radians(); + let to = [ang.cos(), ang.sin()]; + let (_, _, _, sweep) = arc_screen_params(center, from, to, 1.0); + assert!(sweep.abs() <= std::f32::consts::PI + 1e-4, "kuerzerer Bogen (|sweep|<=pi)"); + // Betrag identisch zur Modell-Raum-Winkellogik (Y-Spiegelung nur Vorzeichen). assert!( - segs_high > segs_low, - "mehr Segmente bei hoeherem Zoom erwartet: {segs_low} vs {segs_high}" + (sweep.abs() - model_delta(center, from, to).abs()).abs() < 1e-4, + "Sweep-Betrag == Modell-Delta-Betrag (Paritaet zur alten tessellate_arc-Logik)" ); } + // --- prepare_arc_dash: Papier-mm -> Modell-Meter-Zyklus ------------------- + #[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"); + fn prepare_arc_dash_tuerschwenk_mapping() { + // Tuerschwenk-Muster [0.06, 0.04] mm -> * DASH_MM_TO_M(0.1) = [0.006, 0.004] m. + let (cyc, n, total) = prepare_arc_dash(Some(&[0.06, 0.04])); + assert_eq!(n, 2, "ein An/Aus-Paar"); + assert!((cyc[0] - 0.006).abs() < 1e-6 && (cyc[1] - 0.004).abs() < 1e-6); + assert!((total - 0.010).abs() < 1e-6, "Zykluslaenge 0.01 m"); + } + + #[test] + fn prepare_arc_dash_ungerade_wird_verdoppelt() { + // Einzelwert -> Standard-Dash-Semantik: verdoppelt auf [x, x]. + let (cyc, n, total) = prepare_arc_dash(Some(&[10.0])); + assert_eq!(n, 2, "ungerade Laenge wird verdoppelt"); + assert!((cyc[0] - 1.0).abs() < 1e-6 && (cyc[1] - 1.0).abs() < 1e-6); + assert!((total - 2.0).abs() < 1e-6); + } + + #[test] + fn prepare_arc_dash_leer_ist_durchgezogen() { + assert_eq!(prepare_arc_dash(None).1, 0); + assert_eq!(prepare_arc_dash(Some(&[])).1, 0); } // --- split_dash: geometrische Strichmuster-Zerlegung --------------------- @@ -675,10 +779,10 @@ mod tests { assert_eq!(pieces[0], pts.to_vec()); } - // --- compile_scene_scaled: End-zu-End-Verdrahtung ------------------------- + // --- compile_scene: End-zu-End-Verdrahtung -------------------------------- #[test] - fn compile_scene_mit_bogen_erzeugt_liniengeometrie() { + fn compile_scene_mit_bogen_erzeugt_arc_instanz() { let scene = Scene { arcs: vec![Arc { center: [0.0, 0.0], @@ -691,9 +795,15 @@ mod tests { }], ..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"); + let geo = compile_scene(&scene); + // Analytisch: EINE Instanz, KEINE tessellierte Liniengeometrie. + assert_eq!(geo.arcs.len(), 1, "genau eine Bogen-Instanz"); + assert!(geo.line_verts.is_empty(), "Bogen erzeugt keine Linien-Vertices mehr"); + let a = &geo.arcs[0]; + assert_eq!(a.dash_count, 0, "durchgezogen"); + assert!((a.r_screen - PX_PER_M).abs() < 1e-3); + // Bounds decken den vollen Kreis (center +- r). + assert!((geo.bounds[0] + 1.0).abs() < 1e-3 && (geo.bounds[2] - 1.0).abs() < 1e-3); } #[test] @@ -717,8 +827,8 @@ mod tests { }], ..Default::default() }; - let geo_solid = compile_scene_scaled(&solid, PX_PER_M); - let geo_dashed = compile_scene_scaled(&dashed, PX_PER_M); + let geo_solid = compile_scene(&solid); + let geo_dashed = compile_scene(&dashed); assert!(!geo_solid.line_idx.is_empty()); assert!(!geo_dashed.line_idx.is_empty()); assert_ne!( @@ -728,15 +838,8 @@ mod tests { ); } - /// Gesamtlaenge eines offenen Punktzugs (Modell-Meter). - fn polyline_len(pts: &[Point]) -> f32 { - pts.windows(2) - .map(|w| ((w[1][0] - w[0][0]).powi(2) + (w[1][1] - w[0][1]).powi(2)).sqrt()) - .sum() - } - #[test] - fn gestrichelter_tuerschwenk_bogen_kein_panic_und_kuerzere_deckung() { + fn gestrichelter_tuerschwenk_bogen_erzeugt_dash_instanz() { // Spiegelt den echten Anwendungsfall (Tuerschwenk-Boegen, dash 0.06/0.04mm). let center = [0.0, 0.0]; let from = [1.0, 0.0]; @@ -754,20 +857,14 @@ mod tests { }], ..Default::default() }; - // Rundlauf durch die volle Pipeline: darf nicht paniken, muss Geometrie liefern. - let geo = compile_scene_scaled(&scene, PX_PER_M); - assert!(!geo.line_idx.is_empty(), "gestrichelter Bogen sollte trotzdem Geometrie erzeugen"); - - // Geometrische Invariante an den Bausteinen selbst: die Summe der "an"- - // Stuecke deckt WENIGER Bogenlaenge ab als der volle (durchgezogene) Bogen. - let arc_pts = tessellate_arc(center, from, to, 1.0, PX_PER_M); - let full_len = polyline_len(&arc_pts); - let pieces = split_dash(&arc_pts, &dash); - assert!(pieces.len() > 1, "sollte in mehrere an-Stuecke gesplittet werden"); - let on_len: f32 = pieces.iter().map(|p| polyline_len(p)).sum(); - assert!( - on_len < full_len, - "gestrichelte Deckung ({on_len}) sollte kuerzer als der volle Bogen ({full_len}) sein" - ); + // Rundlauf durch die volle Pipeline: eine analytische, GESTRICHELTE Instanz. + let geo = compile_scene(&scene); + assert_eq!(geo.arcs.len(), 1, "genau eine Bogen-Instanz"); + let a = &geo.arcs[0]; + assert_eq!(a.dash_count, 2, "ein An/Aus-Paar (dash-Zyklus)"); + assert!((a.dash_total - 0.010).abs() < 1e-6, "Zykluslaenge 0.01 m"); + // Sweep = Viertelkreis (Betrag pi/2), Radius exakt. + assert!((a.sweep.abs() - std::f32::consts::FRAC_PI_2).abs() < 1e-4); + assert!((a.r_screen - PX_PER_M).abs() < 1e-3); } } diff --git a/src-tauri/render2d/src/types.rs b/src-tauri/render2d/src/types.rs index 4287fae..ba8b8be 100644 --- a/src-tauri/render2d/src/types.rs +++ b/src-tauri/render2d/src/types.rs @@ -80,10 +80,10 @@ pub struct Polyline { } /// 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. +/// Metern — MATHEMATISCH EXAKT gerendert: `compile_scene` verdichtet ihn nicht zu +/// Segmenten, sondern zu EINER analytischen Instanz (`tessellate::ArcInstanceData`), +/// die die GPU per SDF-Fragment-Shader (`shaders::ARC_WGSL`) bei JEDER Zoomstufe als +/// echten Kreis zeichnet — kein Vieleck, keine Neu-Tessellierung. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Arc { /// Mittelpunkt in Modell-Metern. diff --git a/src-tauri/src/native.rs b/src-tauri/src/native.rs index b6ba67b..e642cf2 100644 --- a/src-tauri/src/native.rs +++ b/src-tauri/src/native.rs @@ -124,8 +124,8 @@ impl GpuState2d { 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); - 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); + let _ = view_box; // Szene-Upload ist zoom-invariant (Boegen analytisch im Shader). + renderer.upload_scene(&device, scene); Self { surface, device, queue, config, renderer, window } } @@ -696,9 +696,8 @@ impl ApplicationHandler for App { self.pending2d = Some(scene); return; }; - 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); + let _ = self.view_box.get_or_insert_with(initial_view_box); + state.renderer.upload_scene(&state.device, &scene); if !self.nav2d { self.view_box = Some(scene_view_box(&scene)); } diff --git a/src/plan/toRenderScene.ts b/src/plan/toRenderScene.ts index 21cdaa5..5526159 100644 --- a/src/plan/toRenderScene.ts +++ b/src/plan/toRenderScene.ts @@ -10,9 +10,10 @@ // lines:[{a,b,color,widthMm,dash}], 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. Strichmuster +// Bögen werden NICHT hier tessellliert: der Rust-Renderer zeichnet sie +// mathematisch exakt per SDF-Fragment-Shader (`shaders::ARC_WGSL`, eine +// analytische Instanz je Bogen) — bei jeder Vergrößerung ein echter Kreis statt +// eines Vielecks, ohne Neu-Tessellierung. Strichmuster // (`dash`, mm Papier) werden ebenfalls unverändert durchgereicht — das geometrische // Zerschneiden in Teilstücke (`applyDashRuns`-Algorithmus) passiert erst NACH der // Bogen-Tessellierung in Rust (`tessellate::split_dash`), damit die Phase über die @@ -328,9 +329,9 @@ 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 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. + // Bogen unvortessellliert an Rust übergeben (`RArc`) — die exakte runde + // Darstellung (analytischer SDF-Shader, `ARC_WGSL`) und ein evtl. Strich- + // muster übernimmt der native Renderer (`compile_scene`) drüben. arcs.push({ center: [p.center.x, p.center.y], from: [p.from.x, p.from.y],