2D-Bögen analytisch: exakter Kreis-Shader statt Segment-Tessellierung
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
+197
-41
@@ -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<wgpu::Buffer>,
|
||||
line_vbo: Option<wgpu::Buffer>,
|
||||
line_ibo: Option<wgpu::Buffer>,
|
||||
/// Instanz-Puffer der analytischen Boegen (je Bogen EINE Instanz); None wenn leer.
|
||||
arc_vbo: Option<wgpu::Buffer>,
|
||||
/// 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<UniformArena>,
|
||||
scene: Option<SceneBuffers>,
|
||||
/// Die zuletzt hochgeladene Szene (fuer Re-Tessellierung bei Zoomaenderung,
|
||||
/// siehe `maybe_retessellate` — nur relevant, wenn die Szene Boegen enthaelt).
|
||||
last_scene: Option<Scene>,
|
||||
/// 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::<ArcGlobals>() 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::<ArcGlobals>() 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::<Globals>() 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<wgpu::Buffer> {
|
||||
if data.is_empty() {
|
||||
@@ -298,37 +432,30 @@ impl Renderer {
|
||||
}))
|
||||
};
|
||||
|
||||
// Bogen-Instanzen (je Bogen EINE) in einen Instanz-Vertexpuffer packen.
|
||||
let arc_instances: Vec<ArcInstance> =
|
||||
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) {
|
||||
|
||||
@@ -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:?}"));
|
||||
|
||||
@@ -101,3 +101,164 @@ fn fs_main() -> @location(0) vec4<f32> {
|
||||
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<f32>,
|
||||
viewport_px : vec2<f32>,
|
||||
px_per_screen : f32,
|
||||
stroke_scale : f32,
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> 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<f32>,
|
||||
@location(0) frag : vec2<f32>,
|
||||
@location(1) @interpolate(flat) center : vec2<f32>,
|
||||
@location(2) @interpolate(flat) geom : vec4<f32>,
|
||||
@location(3) @interpolate(flat) color : vec4<f32>,
|
||||
@location(4) @interpolate(flat) wdash : vec4<f32>,
|
||||
@location(5) @interpolate(flat) dash : vec4<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(
|
||||
@builtin(vertex_index) vidx : u32,
|
||||
@location(0) center : vec2<f32>,
|
||||
@location(1) geom : vec4<f32>,
|
||||
@location(2) color : vec4<f32>,
|
||||
@location(3) wdash : vec4<f32>,
|
||||
@location(4) dash : vec4<f32>,
|
||||
) -> VsOut {
|
||||
// Zwei Dreiecke, Ecken in {-1,+1}^2.
|
||||
var corners = array<vec2<f32>, 6>(
|
||||
vec2<f32>(-1.0, -1.0), vec2<f32>( 1.0, -1.0), vec2<f32>( 1.0, 1.0),
|
||||
vec2<f32>(-1.0, -1.0), vec2<f32>( 1.0, 1.0), vec2<f32>(-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<f32>(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<f32> {
|
||||
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<f32, 4>(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<f32>(in.color.rgb, a);
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -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<Point> {
|
||||
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;
|
||||
}
|
||||
|
||||
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]);
|
||||
(cs, r * PX_PER_M, a0, delta)
|
||||
}
|
||||
pts.push(to);
|
||||
pts
|
||||
|
||||
/// 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<f32> = 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);
|
||||
}
|
||||
let cycle: Vec<f32> = 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<f32>,
|
||||
pub line_idx: Vec<u32>,
|
||||
pub line_batches: Vec<LineBatch>,
|
||||
/// 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<ArcInstanceData>,
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -124,8 +124,8 @@ impl GpuState2d {
|
||||
fn new(window: Arc<Window>, 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<UserEvent> 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));
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user