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 7b9f72e79f
commit 6eead6d493
8 changed files with 588 additions and 172 deletions
+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);
}
}