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:
2026-07-02 22:07:18 +02:00
parent 926dedca40
commit 2a2b3b294c
8 changed files with 588 additions and 172 deletions
+2 -4
View File
@@ -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
View File
@@ -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) {
+5 -1
View File
@@ -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:?}"));
+161
View File
@@ -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);
}
"#;
+208 -111
View File
@@ -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;
}
(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<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);
}
pts.push(to);
pts
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);
}
}
+4 -4
View File
@@ -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.
+4 -5
View File
@@ -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));
}
+7 -6
View File
@@ -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],