Files
DOSSIER-STANDALONE/src-tauri/render3d/src/lib.rs
T
karim 0e484746de render3d im Browser: WASM/WebGPU-3D-Viewport hinter ?engine=wasm
Feature web (wasm-bindgen) + cdylib analog render2d; WebModelRenderer
mit Canvas-Surface, set_model (walls/slabs wie der native Push) und
set_camera. Projektion liefert bereits [0,1]-Clip-Z, math.rs unveraendert.
wgpu-22-requestDevice-Shim in src/engine/requestDeviceShim.ts geteilt.
Neuer Hook useWasm3dRenderer + Wasm3DViewport (Orbit/Pan/Zoom wie three.js-
Sicht); Viewport3D dispatcht per ?engine=wasm bzw. localStorage, three.js
bleibt Default. Build-Script build:engine3d (wasm-pack, src/engine/pkg3d).
Verifiziert headful per scripts/probe-engine3d.mjs (37 % Geometrie-Pixel);
headless praesentiert Chromium keine WebGPU-Frames (auch bei render2d).
2026-07-03 07:41:37 +02:00

385 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Nativer wgpu-3D-Renderer fuer die CAD-Modellsicht.
//
// Aufbau in Schichten (bewusst getrennt, siehe Feature-Flags in Cargo.toml):
// - `types` : serde-only Eingabe (geflachte Waende, Kamera) + Mesh-Ausgabe.
// - `mesh` : Wand-Extrusion (Band aus Achse+Dicke, hochgezogen auf Hoehe).
// GPU-frei, headless per `cargo test` pruefbar. Kern-Port der
// three.js-`ExtrudeGeometry`-Wanderzeugung.
// - `math` : Mat4 + Kamera (View/Projektion, Perspektive + Orthografie,
// die fuenf Presets) + Orbit-Helfer. Handgerechnet, testbar.
// - `shaders` : WGSL-Quelle (View-Projektion + Directional-Light).
// - `gpu` : wgpu-Pipeline mit Tiefenpuffer + Backface-Culling (Feature "render").
// - `bin/spike3d` : winit-Fenster mit Orbit-Kamera (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 math;
pub mod mesh;
pub mod shaders;
pub mod types;
#[cfg(feature = "render")]
pub mod gpu;
// Browser-Bindings (wasm32 + WebGPU), nur mit Feature "web". Setzt "render" voraus
// (dieselbe GPU-Schicht wie das native Fenster), fuegt aber KEINE winit-Abhaengigkeit
// hinzu — die Canvas dient direkt als wgpu-Surface. Muster: render2d/src/web.rs.
#[cfg(feature = "web")]
pub mod web;
pub use math::{
look_at, orbit_eye, orthographic, perspective, preset_camera, projection_matrix,
view_matrix, view_projection, Mat4,
};
pub use mesh::{build_model_mesh, build_walls_mesh, extrude_slab, extrude_wall, triangulate};
pub use types::{
Camera, CameraPreset, Mesh, Point2, Projection, Rgb, SlabInput, WallInput, FLOATS_PER_VERTEX,
};
// --- Tests: Mesh-Erzeugung (Muster wie render2d/tessellate) -------------------
#[cfg(test)]
mod tests {
use super::mesh::{build_walls_mesh, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX};
use super::types::{Mesh, WallInput, FLOATS_PER_VERTEX};
/// Bequemer Bau einer achsparallelen Wand entlang +X.
fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput {
WallInput {
start: [0.0, 0.0],
end: [len, 0.0],
thickness,
height,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
}
}
/// Liest Position + Normale eines Vertex (index i) aus dem interleaved Puffer.
fn vert(mesh: &Mesh, i: usize) -> ([f32; 3], [f32; 3]) {
let b = i * FLOATS_PER_VERTEX;
(
[mesh.verts[b], mesh.verts[b + 1], mesh.verts[b + 2]],
[mesh.verts[b + 3], mesh.verts[b + 4], mesh.verts[b + 5]],
)
}
#[test]
fn eine_wand_hat_quader_zaehlung() {
// Eine Wand -> ein Quader: 24 Vertices, 36 Indizes (12 Dreiecke).
let mesh = build_walls_mesh(&[wall_x(3.0, 0.2, 2.5)]);
assert_eq!(mesh.vertex_count(), VERTS_PER_BOX, "24 Vertices je Quader");
assert_eq!(mesh.indices.len(), INDICES_PER_BOX, "36 Indizes je Quader");
assert_eq!(mesh.triangle_count(), 12, "12 Dreiecke je Quader");
// Kein Index zeigt ausserhalb des Puffers.
let max_idx = *mesh.indices.iter().max().unwrap();
assert!((max_idx as usize) < mesh.vertex_count());
}
#[test]
fn mehrere_waende_addieren_sich() {
let mesh = build_walls_mesh(&[
wall_x(3.0, 0.2, 2.5),
WallInput {
start: [3.0, 0.0],
end: [3.0, 4.0],
thickness: 0.2,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
},
]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
assert_eq!(mesh.indices.len(), 2 * INDICES_PER_BOX);
}
#[test]
fn bounding_box_deckt_dicke_laenge_hoehe_ab() {
// Wand entlang +X, Laenge 3, Dicke 0.2 -> Band in Z von -0.1..0.1;
// Hoehe 2.5 ab Basis 0. world: (x, y=hoehe, z=grundriss.y).
let mesh = build_walls_mesh(&[wall_x(3.0, 0.2, 2.5)]);
let (min, max) = mesh.bounds();
// X: 0..3 (Achsenlaenge).
assert!((min[0] - 0.0).abs() < 1e-5, "min x == 0");
assert!((max[0] - 3.0).abs() < 1e-5, "max x == 3");
// Y: 0..2.5 (Hoehe).
assert!((min[1] - 0.0).abs() < 1e-5, "min y == 0");
assert!((max[1] - 2.5).abs() < 1e-5, "max y == 2.5");
// Z: -0.1..0.1 (halbe Dicke je Seite).
assert!((min[2] + 0.1).abs() < 1e-5, "min z == -0.1");
assert!((max[2] - 0.1).abs() < 1e-5, "max z == 0.1");
}
#[test]
fn basis_hoehe_verschiebt_in_y() {
let mut w = wall_x(3.0, 0.2, 2.5);
w.base_elevation = 3.0; // Obergeschoss.
let mesh = build_walls_mesh(&[w]);
let (min, max) = mesh.bounds();
assert!((min[1] - 3.0).abs() < 1e-5, "Unterkante auf Basis 3.0");
assert!((max[1] - 5.5).abs() < 1e-5, "Oberkante 3.0 + 2.5");
}
#[test]
fn deckel_normale_zeigt_nach_oben() {
// Der Deckel wird als erstes Quad angehaengt (Vertices 0..3): Normale +Y.
let mesh = build_walls_mesh(&[wall_x(3.0, 0.2, 2.5)]);
let (_, n0) = vert(&mesh, 0);
assert!((n0[0]).abs() < 1e-6);
assert!((n0[1] - 1.0).abs() < 1e-6, "Deckel-Normale +Y");
assert!((n0[2]).abs() < 1e-6);
}
#[test]
fn mantel_normalen_zeigen_nach_aussen() {
// Wand entlang +X: die +n-Seite liegt bei z=+0.1 (n = leftNormal von +X =
// (0,1) im Grundriss -> world +Z), die -n-Seite bei z=-0.1. Fuer JEDE
// Mantelflaechen-Normale muss gelten: sie zeigt vom Wand-Zentrum weg.
let mesh = build_walls_mesh(&[wall_x(3.0, 0.2, 2.5)]);
// Wand-Zentrum in world.
let center = [1.5f32, 1.25, 0.0];
// Alle Vertices durchgehen; fuer jede Flaeche muss die Normale eine
// positive Komponente in Richtung (Vertex - Zentrum) haben (zeigt raus).
let n = mesh.vertex_count();
for i in 0..n {
let (p, nor) = vert(&mesh, i);
let out = [p[0] - center[0], p[1] - center[1], p[2] - center[2]];
let d = out[0] * nor[0] + out[1] * nor[1] + out[2] * nor[2];
// >= 0: die Normale weist nie ins Innere (Backface-Culling korrekt).
assert!(
d >= -1e-5,
"Vertex {i}: Normale zeigt nach innen (dot={d})"
);
}
}
#[test]
fn diagonale_wand_hat_gleiche_zaehlung() {
// Auch nicht-achsparallele Waende extrudieren korrekt (nur andere Ecken).
let w = WallInput {
start: [0.0, 0.0],
end: [2.0, 2.0],
thickness: 0.3,
height: 3.0,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
};
let mut mesh = Mesh::default();
extrude_wall(&mut mesh, &w);
assert_eq!(mesh.vertex_count(), VERTS_PER_BOX);
// Hoehe deckt Y 0..3 ab.
let (min, max) = mesh.bounds();
assert!((min[1]).abs() < 1e-5 && (max[1] - 3.0).abs() < 1e-5);
}
#[test]
fn degenerierte_wand_erzeugt_nichts() {
// Start == Ende -> keine Richtung -> uebersprungen (kein Absturz).
let w = WallInput {
start: [1.0, 1.0],
end: [1.0, 1.0],
thickness: 0.2,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
};
let mesh = build_walls_mesh(&[w]);
assert_eq!(mesh.vertex_count(), 0);
assert!(mesh.indices.is_empty());
}
// --- Deckenplatten (extrudierte Polygone) ---------------------------------
use super::mesh::{build_model_mesh, extrude_slab, triangulate};
use super::types::SlabInput;
/// Quadratischer Decken-Umriss (CCW) mit Kantenlaenge `s`, Ecke im Ursprung.
fn square_slab(s: f32, z_bottom: f32, z_top: f32) -> SlabInput {
SlabInput {
outline: vec![[0.0, 0.0], [s, 0.0], [s, s], [0.0, s]],
z_bottom,
z_top,
color: [0.86, 0.86, 0.88],
}
}
#[test]
fn triangulate_quadrat_gibt_zwei_dreiecke() {
let tris = triangulate(&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]);
assert_eq!(tris.len(), 6, "Quadrat -> 2 Dreiecke = 6 Indizes");
}
#[test]
fn triangulate_cw_umriss_ebenfalls_zwei_dreiecke() {
// CW-Umriss (negative Flaeche) muss genauso trianguliert werden.
let tris = triangulate(&[[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]);
assert_eq!(tris.len(), 6);
}
#[test]
fn slab_deckel_und_boden_und_mantel() {
// Quadratische Platte: Deckel (2 Tri) + Boden (2 Tri) + 4 Mantel-Quads
// (je 2 Tri) = 4 + 8 = 12 Dreiecke = 36 Vertices (je Tri eigene Vertices).
let mut mesh = Mesh::default();
extrude_slab(&mut mesh, &square_slab(4.0, 2.4, 2.65));
assert_eq!(mesh.triangle_count(), 12, "2+2 Kappen + 8 Mantel");
assert_eq!(mesh.vertex_count(), 36);
// Z-Ausdehnung deckt die Deckendicke ab.
let (min, max) = mesh.bounds();
assert!((min[1] - 2.4).abs() < 1e-5, "UK 2.4");
assert!((max[1] - 2.65).abs() < 1e-5, "OK 2.65");
}
#[test]
fn slab_deckel_normale_zeigt_nach_oben_boden_nach_unten() {
let mut mesh = Mesh::default();
extrude_slab(&mut mesh, &square_slab(4.0, 0.0, 0.25));
// Fuer jedes Vertex mit y==0.25 (Deckel) muss die Normale +Y sein, fuer
// y==0.0 (Boden) Y. Mantel-Vertices haben n.y ≈ 0.
let n = mesh.vertex_count();
for i in 0..n {
let b = i * FLOATS_PER_VERTEX;
let py = mesh.verts[b + 1];
let ny = mesh.verts[b + 4];
if (py - 0.25).abs() < 1e-6 && (mesh.verts[b + 3]).abs() < 1e-6 && (mesh.verts[b + 5]).abs() < 1e-6 {
assert!((ny - 1.0).abs() < 1e-5, "Deckel-Normale +Y");
}
}
}
#[test]
fn slab_mantel_normalen_zeigen_nach_aussen() {
// Zentriertes Quadrat um den Ursprung: jede Mantel-Normale muss vom
// Zentrum weg zeigen.
let slab = SlabInput {
outline: vec![[-2.0, -2.0], [2.0, -2.0], [2.0, 2.0], [-2.0, 2.0]],
z_bottom: 0.0,
z_top: 0.3,
color: [0.8, 0.8, 0.8],
};
let mut mesh = Mesh::default();
extrude_slab(&mut mesh, &slab);
let center = [0.0f32, 0.15, 0.0];
let n = mesh.vertex_count();
for i in 0..n {
let b = i * FLOATS_PER_VERTEX;
let p = [mesh.verts[b], mesh.verts[b + 1], mesh.verts[b + 2]];
let nor = [mesh.verts[b + 3], mesh.verts[b + 4], mesh.verts[b + 5]];
let out = [p[0] - center[0], p[1] - center[1], p[2] - center[2]];
let d = out[0] * nor[0] + out[1] * nor[1] + out[2] * nor[2];
assert!(d >= -1e-4, "Vertex {i}: Normale zeigt nach innen (dot={d})");
}
}
#[test]
fn build_model_mesh_haengt_slabs_an_waende() {
let wall = build_walls_mesh(&[wall_x(4.0, 0.2, 2.6)]);
let model = build_model_mesh(&[wall_x(4.0, 0.2, 2.6)], &[square_slab(4.0, 2.6, 2.85)]);
// Modell = Wand-Vertices + Slab-Vertices.
assert_eq!(model.vertex_count(), wall.vertex_count() + 36);
assert!(model.indices.len() > wall.indices.len());
}
#[test]
fn entarteter_slab_erzeugt_nichts() {
let mut mesh = Mesh::default();
// <3 Ecken.
extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8] });
assert_eq!(mesh.vertex_count(), 0);
// Nullhoehe.
extrude_slab(&mut mesh, &square_slab(4.0, 2.6, 2.6));
assert_eq!(mesh.vertex_count(), 0);
}
// --- Kamera / Matrizen ----------------------------------------------------
#[test]
fn look_at_setzt_kamera_ins_zentrum() {
use super::math::{transform_point, view_matrix};
use super::types::Camera;
let cam = Camera {
eye: [0.0, 0.0, 5.0],
target: [0.0, 0.0, 0.0],
up: [0.0, 1.0, 0.0],
..Camera::default()
};
let v = view_matrix(&cam);
// Das Blickziel (Ursprung) liegt im View-Raum vor der Kamera bei z=-5
// (Kamera schaut entlang -Z).
let p = transform_point(&v, [0.0, 0.0, 0.0]);
assert!((p[0]).abs() < 1e-5 && (p[1]).abs() < 1e-5);
assert!((p[2] + 5.0).abs() < 1e-5, "Ziel bei view-z = -5");
}
#[test]
fn perspektive_klemmt_z_in_null_bis_eins() {
use super::math::{perspective, transform_point};
let m = perspective(60.0_f32.to_radians(), 1.0, 1.0, 100.0);
// Punkt auf der Nah-Ebene (view-z = -near) -> clip-z/w == 0.
let near = transform_point(&m, [0.0, 0.0, -1.0]);
assert!((near[2] / near[3]).abs() < 1e-4, "Nah-Ebene -> z=0");
// Punkt auf der Fern-Ebene (view-z = -far) -> clip-z/w == 1.
let far = transform_point(&m, [0.0, 0.0, -100.0]);
assert!(((far[2] / far[3]) - 1.0).abs() < 1e-4, "Fern-Ebene -> z=1");
}
#[test]
fn orbit_eye_haelt_abstand_ein() {
use super::math::orbit_eye;
let target = [1.0, 0.0, 2.0];
let e = orbit_eye(target, 0.7, 0.4, 10.0);
let d = ((e[0] - target[0]).powi(2)
+ (e[1] - target[1]).powi(2)
+ (e[2] - target[2]).powi(2))
.sqrt();
assert!((d - 10.0).abs() < 1e-4, "Abstand == dist");
}
#[test]
fn presets_setzen_projektionsart() {
use super::math::preset_camera;
use super::types::{CameraPreset, Projection};
let t = [0.0, 0.0, 0.0];
assert_eq!(
preset_camera(CameraPreset::Top, t, 10.0).projection,
Projection::Orthographic
);
assert_eq!(
preset_camera(CameraPreset::Front, t, 10.0).projection,
Projection::Orthographic
);
assert_eq!(
preset_camera(CameraPreset::Side, t, 10.0).projection,
Projection::Orthographic
);
assert_eq!(
preset_camera(CameraPreset::Iso, t, 10.0).projection,
Projection::Perspective
);
assert_eq!(
preset_camera(CameraPreset::Persp, t, 10.0).projection,
Projection::Perspective
);
// Top blickt von oben herab: eye.y > target.y.
let top = preset_camera(CameraPreset::Top, t, 10.0);
assert!(top.eye[1] > t[1], "Top-Kamera ueber dem Ziel");
}
/// Validiert die WGSL-Quelle headless ueber naga (Parser + Validator) — faengt
/// Syntax-/Typfehler ohne GPU/Display ab. Nur mit Feature "render", weil naga
/// sonst nicht mitgebaut wird (Muster: render2d).
#[cfg(feature = "render")]
#[test]
fn wgsl_quelle_ist_valide() {
use naga::valid::{Capabilities, ValidationFlags, Validator};
let src = super::shaders::MESH_WGSL;
let module = naga::front::wgsl::parse_str(src)
.unwrap_or_else(|e| panic!("mesh: WGSL-Parse-Fehler: {e:?}"));
let mut validator = Validator::new(ValidationFlags::all(), Capabilities::all());
validator
.validate(&module)
.unwrap_or_else(|e| panic!("mesh: WGSL-Validierung fehlgeschlagen: {e:?}"));
}
}