Nativer 2D-wgpu-Renderer (render2d): Ear-Clipping-Fuellungen, gehrte Papier-mm-Linien, GPU-Pan/Zoom
Eigenstaendige Crate (render/window-Feature-Stufung), serde-only Tessellier- schicht headless testbar. Linien als EIN gehrter Streifen (Miter-Bisektor + 1/cos-Laengenfaktor) statt Butt-Cap-Quads pro Segment -> saubere Ecken. Standalone-Spike-Fenster via winit (cargo run --features window --bin spike).
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
// Nativer wgpu-2D-Renderer fuer den CAD-Grundriss.
|
||||
//
|
||||
// Aufbau in Schichten (bewusst getrennt, siehe Feature-Flags in Cargo.toml):
|
||||
// - `types` : serde-only Eingabe (geflachte Primitive, Szene, ViewBox).
|
||||
// - `tessellate` : Ear-Clipping + Linien->Quads, Bildschirm-Raum. GPU-frei,
|
||||
// headless per `cargo test` pruefbar. Kern-Port des WebGL-Pfads.
|
||||
// - `ortho` : aspekt-korrekte Ortho-Matrix + Papier-mm-Strichbreiten-Formel.
|
||||
// - `shaders` : WGSL-Quellen (aus den GLSL-Vorlagen uebersetzt).
|
||||
// - `gpu` : wgpu-Pipelines (Feature "render").
|
||||
// - `bin/spike` : winit-Fenster fuer die visuelle Verifikation (Feature "window").
|
||||
//
|
||||
// Standard-Build (`cargo test`/`cargo build` ohne Features) enthaelt nur die
|
||||
// GPU-freien Schichten und ist damit unabhaengig von einer Display-Session.
|
||||
|
||||
pub mod ortho;
|
||||
pub mod shaders;
|
||||
pub mod tessellate;
|
||||
pub mod types;
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub mod gpu;
|
||||
|
||||
pub use ortho::{compute_ortho_matrix, meet_scale, mm_to_device_px, Mat4};
|
||||
pub use tessellate::{compile_scene, triangulate, GpuGeometry, PX_PER_M};
|
||||
pub use types::{FillPolygon, Line, Outline, Point, Rgba, Scene, ViewBox};
|
||||
|
||||
// --- Tests: Tessellierung (Muster wie glPlanCompile.test.ts) -----------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tessellate::{triangulate, PX_PER_M};
|
||||
use super::types::Point;
|
||||
|
||||
/// Summierte Dreiecksflaeche (Betrag) aus Indizes ueber pts.
|
||||
fn tri_area(pts: &[Point], idx: &[u32]) -> f32 {
|
||||
let mut area = 0.0f32;
|
||||
let mut i = 0;
|
||||
while i < idx.len() {
|
||||
let a = pts[idx[i] as usize];
|
||||
let b = pts[idx[i + 1] as usize];
|
||||
let c = pts[idx[i + 2] as usize];
|
||||
area += ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])).abs() / 2.0;
|
||||
i += 3;
|
||||
}
|
||||
area
|
||||
}
|
||||
|
||||
/// Polygon-Flaeche (Shoelace, Betrag).
|
||||
fn poly_area(pts: &[Point]) -> f32 {
|
||||
let mut a = 0.0f32;
|
||||
let n = pts.len();
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
a += (pts[j][0] + pts[i][0]) * (pts[j][1] - pts[i][1]);
|
||||
j = i;
|
||||
}
|
||||
a.abs() / 2.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quadrat_zwei_dreiecke_volle_flaeche() {
|
||||
let sq: Vec<Point> = vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]];
|
||||
let idx = triangulate(&sq);
|
||||
assert_eq!(idx.len(), 6, "2 Dreiecke erwartet");
|
||||
assert!((tri_area(&sq, &idx) - poly_area(&sq)).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn konkaves_l_flaechentreu_kein_fan() {
|
||||
// L-Form: konkave Ecke bei (2,2). Ein Fan wuerde Flaeche ausserhalb des L
|
||||
// erzeugen; Ear-Clipping muss flaechentreu bleiben.
|
||||
let l: Vec<Point> = vec![
|
||||
[0.0, 0.0],
|
||||
[4.0, 0.0],
|
||||
[4.0, 2.0],
|
||||
[2.0, 2.0],
|
||||
[2.0, 4.0],
|
||||
[0.0, 4.0],
|
||||
];
|
||||
let idx = triangulate(&l);
|
||||
assert_eq!(idx.len(), 12, "6 Ecken -> 4 Dreiecke");
|
||||
assert!((tri_area(&l, &idx) - poly_area(&l)).abs() < 1e-4);
|
||||
assert!((tri_area(&l, &idx) - 12.0).abs() < 1e-4, "L-Flaeche == 12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weniger_als_drei_ecken_leer() {
|
||||
assert!(triangulate(&[]).is_empty());
|
||||
assert!(triangulate(&[[0.0, 0.0]]).is_empty());
|
||||
assert!(triangulate(&[[0.0, 0.0], [1.0, 1.0]]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beide_wicklungsrichtungen_gleich() {
|
||||
// CW-Quadrat.
|
||||
let cw: Vec<Point> = vec![[0.0, 0.0], [0.0, 4.0], [4.0, 4.0], [4.0, 0.0]];
|
||||
let idx = triangulate(&cw);
|
||||
assert_eq!(idx.len(), 6);
|
||||
assert!((tri_area(&cw, &idx) - poly_area(&cw)).abs() < 1e-4);
|
||||
|
||||
// CCW-Quadrat gleicher Groesse -> gleiche Gesamtflaeche.
|
||||
let ccw: Vec<Point> = vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]];
|
||||
let idx2 = triangulate(&ccw);
|
||||
assert_eq!(idx2.len(), 6);
|
||||
assert!((tri_area(&ccw, &idx2) - tri_area(&cw, &idx)).abs() < 1e-4);
|
||||
}
|
||||
|
||||
// --- Zusatz: Bildschirm-Raum-Abbildung + Ortho-Matrix --------------------
|
||||
|
||||
#[test]
|
||||
fn to_screen_konvention() {
|
||||
// sx = mx*90, sy = -my*90 (Modell-Y hoch -> Bildschirm-Y runter).
|
||||
let s = super::tessellate::to_screen([2.0, 3.0]);
|
||||
assert!((s[0] - 2.0 * PX_PER_M).abs() < 1e-4);
|
||||
assert!((s[1] + 3.0 * PX_PER_M).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ortho_bildet_viewbox_ecken_auf_clip_ab() {
|
||||
use super::ortho::compute_ortho_matrix;
|
||||
use super::types::ViewBox;
|
||||
// Quadratischer viewBox + quadratisches Canvas -> keine Aspekt-Dehnung.
|
||||
let m = compute_ortho_matrix(ViewBox::new(0.0, 0.0, 100.0, 100.0), 100.0, 100.0);
|
||||
// Spalten-Major: clip = M * (x,y,0,1).
|
||||
let clip = |x: f32, y: f32| -> (f32, f32) {
|
||||
(
|
||||
m[0] * x + m[4] * y + m[12],
|
||||
m[1] * x + m[5] * y + m[13],
|
||||
)
|
||||
};
|
||||
// Bildschirm-Punkt (0,0) = obere-linke Ecke -> Clip (-1, +1).
|
||||
let tl = clip(0.0, 0.0);
|
||||
assert!((tl.0 + 1.0).abs() < 1e-4);
|
||||
assert!((tl.1 - 1.0).abs() < 1e-4);
|
||||
// (100,100) = untere-rechte Ecke -> Clip (+1, -1).
|
||||
let br = clip(100.0, 100.0);
|
||||
assert!((br.0 - 1.0).abs() < 1e-4);
|
||||
assert!((br.1 + 1.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mm_zu_device_px_35_bei_1zu100() {
|
||||
use super::ortho::mm_to_device_px;
|
||||
use super::types::ViewBox;
|
||||
// meet=1 (viewport == viewBox), N=100: mmToPx = N/1000 * 90 * meet = 9 px/mm.
|
||||
// 0.35 mm -> 3.15 px.
|
||||
let vb = ViewBox::new(0.0, 0.0, 100.0, 100.0);
|
||||
let mm_px = mm_to_device_px(vb, 100.0, 100.0, 100.0);
|
||||
assert!((mm_px - 9.0).abs() < 1e-4, "9 px/mm bei 1:100, meet=1");
|
||||
assert!((0.35 * mm_px - 3.15).abs() < 1e-4);
|
||||
}
|
||||
|
||||
/// Validiert die WGSL-Quellen headless ueber naga (Parser + Validator) —
|
||||
/// faengt Syntax-/Typfehler ohne GPU/Display ab. Nur mit Feature "render",
|
||||
/// weil naga sonst nicht mitgebaut wird.
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn wgsl_quellen_sind_valide() {
|
||||
use naga::valid::{Capabilities, ValidationFlags, Validator};
|
||||
|
||||
for (name, src) in [
|
||||
("fill", super::shaders::FILL_WGSL),
|
||||
("line", super::shaders::LINE_WGSL),
|
||||
] {
|
||||
let module = naga::front::wgsl::parse_str(src)
|
||||
.unwrap_or_else(|e| panic!("{name}: WGSL-Parse-Fehler: {e:?}"));
|
||||
let mut validator =
|
||||
Validator::new(ValidationFlags::all(), Capabilities::all());
|
||||
validator
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{name}: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user