Nativer 3D-wgpu-Renderer (render3d, M0+M1): Wand-Extrusion, Kamera, Licht

Eigenstaendige Crate wie render2d (render/window-Stufung, serde-only Mesh-
schicht headless testbar). Wand-Extrusion (Band via Links-Normale, Quader mit
nach aussen zeigenden Normalen), handgerechnete Mat4 (perspektiv+ortho, wgpu-
Clip-Z [0,1], 5 Kamera-Presets), Directional-Light + Tiefenpuffer + Backface-
Culling. Orbit-Spike (cargo run --features window --bin spike3d). Plus Port-
Briefing mit M2..M9-Milestones (three.js-Viewport-Bestandsaufnahme).
This commit is contained in:
2026-07-02 00:29:02 +02:00
parent 6d4b4e66c1
commit 48bbcaed1e
11 changed files with 4379 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
+2605
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
[package]
name = "render3d"
version = "0.1.0"
edition = "2021"
description = "Nativer wgpu-3D-Renderer fuer die CAD-Modellsicht (Wand-Extrusion + Kamera + Pipeline)"
# Bewusst NICHT Teil des src-tauri-Workspaces: die Mesh-Bibliothek soll unabhaengig
# von Tauri baubar/testbar bleiben (headless, ohne Webview-Toolchain). Der leere
# [workspace]-Block schliesst die Crate aus einem uebergeordneten Workspace aus,
# sodass `cargo test`/`cargo build` hier eigenstaendig laufen (Muster: render2d).
[workspace]
[features]
# Standard: nur Mesh-Erzeugung (Wand-Extrusion) + Kamera/Matrizen + WGSL-Quellen.
# Diese Schicht ist serde-only, hat keine GPU-Abhaengigkeit und ist headless
# vollstaendig per `cargo test` pruefbar.
default = []
# GPU-Pipeline (wgpu). Baut auch headless (wgpu laedt Vulkan/GL erst zur Laufzeit
# dynamisch), rendert aber nur mit einer aktiven Grafik-Session. Enthaelt Tiefen-
# puffer, View-Projektions-Uniform und ein Directional-Light im Fragment-Shader.
render = ["dep:wgpu", "dep:bytemuck", "dep:pollster"]
# Fenster-Spike (winit) inkl. lauffaehigem Binary mit Orbit-Kamera. Braucht
# Wayland/X11 + eine Display-Session zur visuellen Verifikation.
window = ["render", "dep:winit", "dep:env_logger"]
[dependencies]
serde = { version = "1", features = ["derive"] }
# --- GPU (nur mit Feature "render") ------------------------------------------
# Standard-Features von wgpu (wgsl, webgpu, dx12, metal). Die Linux-Backends
# Vulkan/GLES aktiviert wgpu selbst ueber seine target-spezifischen Defaults —
# es gibt in wgpu 22 keine expliziten `vulkan`/`gles`-Feature-Namen.
wgpu = { version = "22", optional = true }
bytemuck = { version = "1", optional = true, features = ["derive"] }
pollster = { version = "0.3", optional = true }
# --- Fenster (nur mit Feature "window") --------------------------------------
winit = { version = "0.30", optional = true }
env_logger = { version = "0.11", optional = true }
[dev-dependencies]
serde_json = "1"
# naga validiert die WGSL-Quellen headless (Parser + Validator) im Test, ohne
# GPU/Display. Version an wgpu 22 gekoppelt (transitiv ohnehin vorhanden).
naga = { version = "22", features = ["wgsl-in"] }
# Der Fenster-Spike ist nur mit Feature "window" verfuegbar; ohne das Feature
# wird das Binary aus dem Build ausgeschlossen (required-features).
[[bin]]
name = "spike3d"
path = "src/bin/spike3d.rs"
required-features = ["window"]
+264
View File
@@ -0,0 +1,264 @@
// Standalone-Fenster-Spike (Feature "window"): oeffnet ein winit-Fenster mit
// eigener wgpu-Surface und zeichnet eine Demo-Szene aus extrudierten Waenden (ein
// geschlossener Raum). Eine ORBIT-KAMERA laesst sich mit der Maus drehen (linke
// Taste ziehen = Yaw/Pitch) und mit dem Rad zoomen (Abstand). Nur die
// View-Projektions-Matrix aendert sich — kein Re-Meshing.
//
// Das ist bewusst der entkoppelte Rendering-Spike (M1): Rendering getrennt von der
// Tauri/Webview-Integration verifizieren. Die Anbindung unter die Webview
// (raw-window-handle) folgt in einem spaeteren Milestone.
//
// Start: cargo run --features window --bin spike3d
// (braucht eine aktive Wayland-/X11-Session; headless nicht sichtbar verifizierbar).
use std::sync::Arc;
use render3d::gpu::Renderer;
use render3d::math::orbit_eye;
use render3d::types::{Camera, Projection, WallInput};
use winit::application::ApplicationHandler;
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::window::{Window, WindowId};
/// Demo-Szene: ein rechteckiger Raum (4 Aussenwaende) plus eine Innenwand. Achsen
/// in Meter; Dicke 0.2 m, Hoehe 2.6 m. Genug, um Extrusion, Tiefenpuffer und
/// Beleuchtung im Orbit zu beurteilen.
fn demo_walls() -> Vec<WallInput> {
let t = 0.2;
let ht = 2.6;
let grey = [0.82, 0.80, 0.76];
let mk = |a: [f32; 2], b: [f32; 2]| WallInput {
start: a,
end: b,
thickness: t,
height: ht,
base_elevation: 0.0,
color: grey,
};
// Raum 6 x 4 m.
vec![
mk([0.0, 0.0], [6.0, 0.0]), // Sued
mk([6.0, 0.0], [6.0, 4.0]), // Ost
mk([6.0, 4.0], [0.0, 4.0]), // Nord
mk([0.0, 4.0], [0.0, 0.0]), // West
mk([3.0, 0.0], [3.0, 2.5]), // Innenwand (Teilung)
]
}
/// Zielpunkt (Raum-Mitte in world) und Start-Abstand fuer die Orbit-Kamera.
fn scene_target() -> [f32; 3] {
// Raum 6x4 in der XZ-Ebene, Wandmitte-Hoehe ~1.3.
[3.0, 1.3, 2.0]
}
struct GpuState {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
renderer: Renderer,
window: Arc<Window>,
}
impl GpuState {
fn new(window: Arc<Window>) -> Self {
let size = window.inner_size();
let instance = wgpu::Instance::default();
let surface = instance
.create_surface(window.clone())
.expect("Surface erstellen");
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&surface),
}))
.expect("kein passender GPU-Adapter");
let (device, queue) = pollster::block_on(adapter.request_device(
&wgpu::DeviceDescriptor {
label: Some("3d.device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
memory_hints: wgpu::MemoryHints::Performance,
},
None,
))
.expect("Device anfordern");
let caps = surface.get_capabilities(&adapter);
let format = caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.unwrap_or(caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: size.width.max(1),
height: size.height.max(1),
present_mode: caps.present_modes[0],
alpha_mode: caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &config);
let mut renderer = Renderer::new(&device, format);
renderer.upload_walls(&device, &demo_walls());
Self {
surface,
device,
queue,
config,
renderer,
window,
}
}
fn resize(&mut self, w: u32, h: u32) {
if w == 0 || h == 0 {
return;
}
self.config.width = w;
self.config.height = h;
self.surface.configure(&self.device, &self.config);
}
fn render(&mut self, camera: &Camera) {
let frame = match self.surface.get_current_texture() {
Ok(f) => f,
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
self.surface.configure(&self.device, &self.config);
return;
}
Err(e) => {
eprintln!("Surface-Fehler: {e:?}");
return;
}
};
let view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
self.renderer.render(
&self.device,
&self.queue,
&view,
camera,
(self.config.width, self.config.height),
);
frame.present();
}
}
/// Orbit-Zustand: Yaw/Pitch (Radiant) + Abstand. Steuert die Kamera-Position um
/// das feste Ziel (Raum-Mitte).
struct Orbit {
yaw: f32,
pitch: f32,
dist: f32,
}
impl Default for Orbit {
fn default() -> Self {
// Schraeg von vorn-oben-rechts, ~10 m Abstand.
Self {
yaw: std::f32::consts::FRAC_PI_4,
pitch: 0.5,
dist: 11.0,
}
}
}
impl Orbit {
fn camera(&self) -> Camera {
let target = scene_target();
Camera {
eye: orbit_eye(target, self.yaw, self.pitch, self.dist),
target,
up: [0.0, 1.0, 0.0],
projection: Projection::Perspective,
..Camera::default()
}
}
}
#[derive(Default)]
struct App {
state: Option<GpuState>,
orbit: Orbit,
dragging: bool,
last_cursor: (f64, f64),
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() {
return;
}
let attrs = Window::default_attributes().with_title("render3d — Spike (Orbit)");
let window = Arc::new(event_loop.create_window(attrs).expect("Fenster erstellen"));
self.state = Some(GpuState::new(window));
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else {
return;
};
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
state.resize(size.width, size.height);
state.window.request_redraw();
}
WindowEvent::MouseInput { state: s, button, .. } => {
if button == MouseButton::Left {
self.dragging = s == ElementState::Pressed;
}
}
WindowEvent::CursorMoved { position, .. } => {
if self.dragging {
// Maus-Delta -> Yaw/Pitch (Radiant je px). Pitch klemmt orbit_eye.
let dx = (position.x - self.last_cursor.0) as f32;
let dy = (position.y - self.last_cursor.1) as f32;
self.orbit.yaw -= dx * 0.01;
self.orbit.pitch += dy * 0.01;
let limit = std::f32::consts::FRAC_PI_2 - 0.01;
self.orbit.pitch = self.orbit.pitch.clamp(-limit, limit);
state.window.request_redraw();
}
self.last_cursor = (position.x, position.y);
}
WindowEvent::MouseWheel { delta, .. } => {
let step = match delta {
MouseScrollDelta::LineDelta(_, y) => y,
MouseScrollDelta::PixelDelta(p) => (p.y as f32) / 40.0,
};
// Rad -> Abstand (multiplikativ), geklemmt auf sinnvollen Bereich.
let factor = if step > 0.0 { 0.9 } else { 1.0 / 0.9 };
self.orbit.dist = (self.orbit.dist * factor).clamp(1.5, 200.0);
state.window.request_redraw();
}
WindowEvent::RedrawRequested => {
let cam = self.orbit.camera();
state.render(&cam);
}
_ => {}
}
}
}
fn main() {
env_logger::init();
let event_loop = EventLoop::new().expect("Event-Loop erstellen");
event_loop.set_control_flow(winit::event_loop::ControlFlow::Wait);
let mut app = App::default();
event_loop.run_app(&mut app).expect("App laufen lassen");
}
+301
View File
@@ -0,0 +1,301 @@
// wgpu-Pipeline des nativen 3D-Renderers (nur mit Feature "render").
//
// Eine Pipeline: extrudierte Wand-Meshes mit Tiefenpuffer, View-Projektions-
// Matrix-Uniform und einem Directional-Light im Fragment-Shader. Backface-Culling
// ist aktiv (die Extrusion liefert konsistent nach aussen zeigende Flaechen, siehe
// mesh.rs) — das haelt das Innere der Waende korrekt verdeckt.
//
// Der Renderer ist fenster-/surface-agnostisch: er bekommt `Device`, `Queue` und
// ein `TextureView` (Surface- oder Offscreen-Textur) plus die aktuelle Kamera und
// zeichnet dort hinein. Die Fenster-Anbindung (winit) liegt separat im Spike-Bin.
use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt;
use crate::math::{view_projection, Mat4};
use crate::mesh::build_walls_mesh;
use crate::shaders::MESH_WGSL;
use crate::types::{Camera, WallInput, FLOATS_PER_VERTEX};
/// Tiefenformat des Z-Puffers (32 Bit Float, ueberall verfuegbar).
pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
/// Uniform-Block, 1:1 zum WGSL-`Globals`-Struct. std140-kompatibel (mat4/vec4 auf
/// 16 Byte ausgerichtet).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct Globals {
view_proj: [f32; 16],
light_dir: [f32; 4],
ambient: [f32; 4],
}
impl Default for Globals {
fn default() -> Self {
Self {
view_proj: crate::math::identity(),
// Richtung ZUM Licht (world), normiert. Entspricht der three.js-Sonne
// bei (6,12,4): das Licht kommt aus dieser Richtung.
light_dir: normalize4([6.0, 12.0, 4.0]),
// Ambienter Sockel 0.6 (wie three.js-AmbientLight(0.6)).
ambient: [0.6, 0.6, 0.6, 1.0],
}
}
}
fn normalize4(v: [f32; 3]) -> [f32; 4] {
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-9);
[v[0] / l, v[1] / l, v[2] / l, 0.0]
}
/// GPU-seitige Puffer eines hochgeladenen Meshes.
struct MeshBuffers {
vbo: wgpu::Buffer,
ibo: wgpu::Buffer,
index_count: u32,
}
/// Der native 3D-Renderer: haelt die Pipeline, das Uniform (View-Projektion +
/// Licht) und das aktuell hochgeladene Mesh. Ein Tiefenpuffer wird passend zur
/// Ziel-Groesse (neu) angelegt.
pub struct Renderer {
pipeline: wgpu::RenderPipeline,
bind_group: wgpu::BindGroup,
uniform: wgpu::Buffer,
mesh: Option<MeshBuffers>,
depth: Option<DepthTarget>,
globals: Globals,
/// Loeschfarbe (Hintergrund). Default heller Grauton wie die three.js-Sicht.
pub clear_color: wgpu::Color,
}
/// Tiefen-Textur samt View, an eine bestimmte Groesse gebunden.
struct DepthTarget {
view: wgpu::TextureView,
width: u32,
height: u32,
}
impl Renderer {
/// Erzeugt Pipeline + Layout fuer ein gegebenes Farbformat (Surface).
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("mesh.wgsl"),
source: wgpu::ShaderSource::Wgsl(MESH_WGSL.into()),
});
let bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("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::<Globals>() as u64
),
},
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("3d.layout"),
bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[],
});
// Vertex-Layout: [pos vec3, normal vec3, color vec3], stride 9*4.
let vertex_layout = wgpu::VertexBufferLayout {
array_stride: (FLOATS_PER_VERTEX * 4) as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x3],
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("mesh.pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: "vs_main",
buffers: &[vertex_layout],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
// Aussen zeigende Flaechen sind CCW (mesh.rs) -> Rueckseiten cullen.
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("globals.buffer"),
size: std::mem::size_of::<Globals>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("globals.bind"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}],
});
Self {
pipeline,
bind_group,
uniform,
mesh: None,
depth: None,
globals: Globals::default(),
// #e9e9e9 heller Hintergrund (wie die three.js-Modellsicht).
clear_color: wgpu::Color {
r: 0.914,
g: 0.914,
b: 0.914,
a: 1.0,
},
}
}
/// Erzeugt das Mesh aus geflachten Waenden und laedt die Puffer hoch.
pub fn upload_walls(&mut self, device: &wgpu::Device, walls: &[WallInput]) {
let mesh = build_walls_mesh(walls);
if mesh.indices.is_empty() {
self.mesh = None;
return;
}
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh.vbo"),
contents: bytemuck::cast_slice(&mesh.verts),
usage: wgpu::BufferUsages::VERTEX,
});
let ibo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh.ibo"),
contents: bytemuck::cast_slice(&mesh.indices),
usage: wgpu::BufferUsages::INDEX,
});
self.mesh = Some(MeshBuffers {
vbo,
ibo,
index_count: mesh.indices.len() as u32,
});
}
/// Setzt die Lichtrichtung (Richtung ZUM Licht, world) und den ambienten Sockel.
pub fn set_light(&mut self, dir_to_light: [f32; 3], ambient: f32) {
self.globals.light_dir = normalize4(dir_to_light);
self.globals.ambient = [ambient, ambient, ambient, 1.0];
}
/// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert.
fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) {
let ok = matches!(&self.depth, Some(d) if d.width == w && d.height == h);
if ok {
return;
}
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("depth"),
size: wgpu::Extent3d {
width: w.max(1),
height: h.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
self.depth = Some(DepthTarget {
view,
width: w.max(1),
height: h.max(1),
});
}
/// Zeichnet einen Frame in `view` (Surface- oder Offscreen-Textur). Die Kamera
/// liefert die View-Projektions-Matrix; `viewport` bestimmt das Seitenverhaeltnis
/// und die Tiefenpuffer-Groesse.
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
view: &wgpu::TextureView,
camera: &Camera,
viewport: (u32, u32),
) {
let (w, h) = viewport;
let aspect = if h > 0 { w as f32 / h as f32 } else { 1.0 };
let vp: Mat4 = view_projection(camera, aspect);
self.globals.view_proj = vp;
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals));
self.ensure_depth(device, w, h);
let depth_view = &self.depth.as_ref().unwrap().view;
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("3d.encoder"),
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("3d.pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(self.clear_color),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
if let Some(m) = &self.mesh {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_vertex_buffer(0, m.vbo.slice(..));
pass.set_index_buffer(m.ibo.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..m.index_count, 0, 0..1);
}
}
queue.submit(std::iter::once(encoder.finish()));
}
}
+275
View File
@@ -0,0 +1,275 @@
// 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;
pub use math::{
look_at, orbit_eye, orthographic, perspective, preset_camera, projection_matrix,
view_matrix, view_projection, Mat4,
};
pub use mesh::{build_walls_mesh, extrude_wall};
pub use types::{
Camera, CameraPreset, Mesh, Point2, Projection, Rgb, 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());
}
// --- 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:?}"));
}
}
+239
View File
@@ -0,0 +1,239 @@
// 4x4-Matrix-Mathematik + Kamera (View/Projektion + Orbit). Handgerechnet,
// spalten-major (WebGL/wgpu-Konvention: `m[col*4 + row]`), damit die Matrizen
// ohne Umsortieren direkt als Uniform hochladbar sind.
//
// WARUM handgerechnet statt `glam`:
// - Die serde-only Standard-Schicht (`default`-Feature) soll — wie in render2d —
// ohne zusaetzliche Crates headless test- und baubar bleiben. `glam` waere eine
// weitere Abhaengigkeit in genau der Schicht, die schlank bleiben muss.
// - Der benoetigte Umfang ist klein (perspective/ortho/look_at + Orbit) und exakt
// testbar. Ein spaeterer Wechsel zu `glam` (nur in der GPU-Schicht) bleibt
// moeglich, ohne die Tests der Mesh-/Kamera-Logik anzufassen.
//
// WICHTIG (Clip-Z-Konvention): wgpu/WebGPU erwartet Tiefe in [0, 1] (NICHT [-1, 1]
// wie OpenGL). Die Projektionen hier bilden daher auf den [0, 1]-Bereich ab.
use crate::types::{Camera, CameraPreset, Projection};
/// 4x4-Matrix, spalten-major (`m[col*4 + row]`).
pub type Mat4 = [f32; 16];
/// Einheitsmatrix.
pub fn identity() -> Mat4 {
let mut m = [0.0f32; 16];
m[0] = 1.0;
m[5] = 1.0;
m[10] = 1.0;
m[15] = 1.0;
m
}
/// Matrix-Produkt `a * b` (spalten-major). Ergebnis wirkt als `(a*b) * v`.
pub fn mul(a: &Mat4, b: &Mat4) -> Mat4 {
let mut m = [0.0f32; 16];
for col in 0..4 {
for row in 0..4 {
let mut sum = 0.0f32;
for k in 0..4 {
// a[k-te Spalte][row] * b[col-te Spalte][k]
sum += a[k * 4 + row] * b[col * 4 + k];
}
m[col * 4 + row] = sum;
}
}
m
}
/// Wendet die Matrix auf einen Punkt (w=1) an und liefert die kartesische xyz
/// (nach Division durch w). Fuer Tests / Punkt-Projektion.
pub fn transform_point(m: &Mat4, p: [f32; 3]) -> [f32; 4] {
[
m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15],
]
}
// --- Vektor-Helfer (3D) ------------------------------------------------------
#[inline]
pub fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
#[inline]
pub fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[inline]
pub fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
#[inline]
pub fn length(a: [f32; 3]) -> f32 {
dot(a, a).sqrt()
}
#[inline]
pub fn normalize(a: [f32; 3]) -> [f32; 3] {
let l = length(a);
if l > 1e-9 {
[a[0] / l, a[1] / l, a[2] / l]
} else {
[0.0, 0.0, 0.0]
}
}
/// Right-handed `look_at`-View-Matrix (Kamera blickt von `eye` auf `center`,
/// mit `up`). Spalten-major, Konvention wie three.js/gluLookAt: die Kamera
/// schaut entlang -Z im View-Raum.
pub fn look_at(eye: [f32; 3], center: [f32; 3], up: [f32; 3]) -> Mat4 {
// f = Blickrichtung (vorwaerts), s = rechts, u = echtes Up.
let f = normalize(sub(center, eye));
let s = normalize(cross(f, up));
let u = cross(s, f);
let mut m = identity();
// Rotationsanteil (Zeilen = s, u, -f).
m[0] = s[0];
m[4] = s[1];
m[8] = s[2];
m[1] = u[0];
m[5] = u[1];
m[9] = u[2];
m[2] = -f[0];
m[6] = -f[1];
m[10] = -f[2];
// Translationsanteil.
m[12] = -dot(s, eye);
m[13] = -dot(u, eye);
m[14] = dot(f, eye);
m
}
/// Perspektivische Projektion (right-handed), Clip-Z in [0, 1] (wgpu-Konvention).
/// `fov_y` in Radiant, `aspect` = Breite/Hoehe.
pub fn perspective(fov_y: f32, aspect: f32, near: f32, far: f32) -> Mat4 {
let f = 1.0 / (fov_y * 0.5).tan();
let mut m = [0.0f32; 16];
m[0] = f / aspect;
m[5] = f;
// Tiefen-Abbildung auf [0,1] (zNear -> 0, zFar -> 1).
m[10] = far / (near - far);
m[11] = -1.0;
m[14] = (near * far) / (near - far);
m
}
/// Orthografische Projektion (right-handed), Clip-Z in [0, 1] (wgpu-Konvention).
/// Symmetrisches Volumen [-h*aspect, h*aspect] x [-h, h], h = `half_height`.
pub fn orthographic(half_height: f32, aspect: f32, near: f32, far: f32) -> Mat4 {
let right = half_height * aspect;
let top = half_height;
let mut m = identity();
m[0] = 1.0 / right;
m[5] = 1.0 / top;
// z: [near, far] -> [0, 1] (near -> 0).
m[10] = 1.0 / (near - far);
m[14] = near / (near - far);
m
}
/// Projektionsmatrix aus einer `Camera` + Seitenverhaeltnis (Breite/Hoehe).
pub fn projection_matrix(cam: &Camera, aspect: f32) -> Mat4 {
match cam.projection {
Projection::Perspective => perspective(cam.fov_y, aspect, cam.near, cam.far),
Projection::Orthographic => {
orthographic(cam.ortho_half_height, aspect, cam.near, cam.far)
}
}
}
/// View-Matrix aus einer `Camera`.
pub fn view_matrix(cam: &Camera) -> Mat4 {
look_at(cam.eye, cam.target, cam.up)
}
/// Kombinierte View-Projektions-Matrix (`proj * view`), fertig fuer das Uniform.
pub fn view_projection(cam: &Camera, aspect: f32) -> Mat4 {
let v = view_matrix(cam);
let p = projection_matrix(cam, aspect);
mul(&p, &v)
}
/// Orbit-Kamera: berechnet die Augen-Position aus Ziel + Kugel-Koordinaten.
/// - `yaw` : Drehung um die Y-Achse (Azimut), Radiant.
/// - `pitch` : Erhebung ueber die XZ-Ebene, Radiant (geklemmt, siehe unten).
/// - `dist` : Abstand vom Ziel.
/// Y-up: Hoehe = dist*sin(pitch); horizontaler Radius = dist*cos(pitch).
pub fn orbit_eye(target: [f32; 3], yaw: f32, pitch: f32, dist: f32) -> [f32; 3] {
// Pitch klemmen, damit die Kamera nicht ueber den Pol kippt (Gimbal-Flip).
let limit = std::f32::consts::FRAC_PI_2 - 0.01;
let p = pitch.clamp(-limit, limit);
let r = dist * p.cos();
[
target[0] + r * yaw.cos(),
target[1] + dist * p.sin(),
target[2] + r * yaw.sin(),
]
}
/// Baut eine `Camera` fuer eines der fuenf Presets der three.js-Sicht. `target`
/// ist das Blickziel (Modell-Mitte), `dist` der Abstand. Front/Top/Side sind
/// orthografisch achsparallel; Iso/Persp sind perspektivisch.
pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camera {
let mut cam = Camera {
target,
..Camera::default()
};
match preset {
// Blick entlang -Z (von vorne), orthografisch.
CameraPreset::Front => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0], target[1], target[2] + dist];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Blick von oben entlang -Y (Grundriss), orthografisch. Up = -Z, damit
// Modell-Y (world +Z) im Bild nach unten zeigt (wie die 2D-Plan-Sicht).
CameraPreset::Top => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0], target[1] + dist, target[2]];
cam.up = [0.0, 0.0, -1.0];
cam.ortho_half_height = dist * 0.5;
}
// Blick entlang -X (von der Seite), orthografisch.
CameraPreset::Side => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] + dist, target[1], target[2]];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick (perspektivisch, Blick von schraeg oben).
CameraPreset::Iso => {
cam.projection = Projection::Perspective;
let d = dist / 3.0_f32.sqrt();
cam.eye = [target[0] + d, target[1] + d, target[2] + d];
cam.up = [0.0, 1.0, 0.0];
}
// Freie perspektivische Standard-Ansicht (leicht von vorn-oben-rechts).
CameraPreset::Persp => {
cam.projection = Projection::Perspective;
cam.eye = orbit_eye(
target,
std::f32::consts::FRAC_PI_4,
0.5,
dist,
);
cam.up = [0.0, 1.0, 0.0];
}
}
cam
}
+136
View File
@@ -0,0 +1,136 @@
// Mesh-Erzeugung: extrudiert geflachte Waende zu Quader-Meshes. GPU-frei, headless
// per `cargo test` pruefbar (Muster: render2d/tessellate).
//
// Grundlage ist der Wandband-Ansatz aus src/model/geometry.ts (`wallCorners`):
// u = normalize(end - start) Achsenrichtung im Grundriss
// n = leftNormal(u) = (-u.y, u.x) Links-Normale (90 Grad CCW)
// Band-Ecken = start/end +/- n * thickness/2 (CCW-Umlauf)
// Diese 2D-Band-Ecken werden nach world abgebildet (XZ-Ebene) und entlang +Y von
// `base_elevation` bis `base_elevation + height` extrudiert — analog zur
// three.js-`ExtrudeGeometry`, die den 2D-`clippedBand` hochzieht (Viewport3D.tsx
// `addLayerPrism`). M1 nimmt die stumpfe (rechtwinklige) Band-Form; Gehrungen
// (`computeJoins`/`clippedBand`) und Oeffnungen kommen in spaeteren Milestones.
//
// KOORDINATEN: model [x, y] -> world (x, elevation, y). Also Grundriss in der
// XZ-Ebene, Extrusion entlang +Y (Y-up), exakt wie Viewport3D.tsx:
// "Modell (x,y,z) -> Three (x, z, y) (Z = Hoehe nach oben)".
use crate::types::{Mesh, Point2, Rgb, WallInput, FLOATS_PER_VERTEX};
/// Ein Quader-Mesh besteht aus 6 Seiten (Boden, Deckel, 4 Waende) zu je 2
/// Dreiecken = 12 Dreiecke, mit flachen Normalen also 24 Vertices (je Seite 4,
/// keine geteilten Normalen). Konstanten dokumentieren die Test-Erwartung.
pub const VERTS_PER_BOX: usize = 24;
pub const INDICES_PER_BOX: usize = 36;
/// 2D-Einheits-Links-Normale zur Richtung (end - start), wie `leftNormal` im Web:
/// n = (-u.y, u.x). Liefert (0,0) bei Nulllaenge.
fn left_normal(start: Point2, end: Point2) -> Point2 {
let dx = end[0] - start[0];
let dy = end[1] - start[1];
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-9 {
return [0.0, 0.0];
}
[-dy / len, dx / len]
}
/// Extrudiert EINE Wand zu einem Quader-Mesh und haengt sie an `mesh` an.
///
/// Aufbau: die vier Grundriss-Ecken des Bandes (CCW) bilden das Deckel-Rechteck.
/// Boden = gleiche XZ auf `y0`, Deckel = auf `y1`. Jede der sechs Seiten bekommt
/// eigene Vertices mit ihrer (nach aussen zeigenden) Flaechen-Normale — daher
/// flaches Shading und korrektes Backface-Culling.
pub fn extrude_wall(mesh: &mut Mesh, wall: &WallInput) {
let n = left_normal(wall.start, wall.end);
// Degenerierte Wand (Start == Ende) ueberspringen.
if n == [0.0, 0.0] {
return;
}
let h = wall.thickness * 0.5;
// Vier Band-Ecken im Grundriss (CCW, wie `wallCorners(start,end,thickness)`):
// p0 = start + n*h, p1 = end + n*h, p2 = end - n*h, p3 = start - n*h.
let off = |p: Point2, s: f32| -> Point2 { [p[0] + n[0] * s * h, p[1] + n[1] * s * h] };
let g0 = off(wall.start, 1.0);
let g1 = off(wall.end, 1.0);
let g2 = off(wall.end, -1.0);
let g3 = off(wall.start, -1.0);
let y0 = wall.base_elevation;
let y1 = wall.base_elevation + wall.height;
let color = wall.color;
// world-Position aus Grundriss-XZ + Hoehe: (x, y, z=grundriss.y).
let w = |g: Point2, y: f32| -> [f32; 3] { [g[0], y, g[1]] };
// Acht Quader-Ecken (b* = Boden auf y0, t* = Deckel auf y1).
let b0 = w(g0, y0);
let b1 = w(g1, y0);
let b2 = w(g2, y0);
let b3 = w(g3, y0);
let t0 = w(g0, y1);
let t1 = w(g1, y1);
let t2 = w(g2, y1);
let t3 = w(g3, y1);
// Sechs Seiten als Quads (Ecken CCW von aussen gesehen -> Normale zeigt raus).
// Deckel oben (+Y): t0,t1,t2,t3 von oben CCW.
push_quad(mesh, t0, t1, t2, t3, [0.0, 1.0, 0.0], color);
// Boden unten (-Y): b0,b3,b2,b1 (von unten gesehen CCW).
push_quad(mesh, b0, b3, b2, b1, [0.0, -1.0, 0.0], color);
// Vier Mantelflaechen. Aussen-Normalen aus dem Grundriss:
// Kante g0->g1 liegt auf der +n-Seite -> Normale +n.
// Kante g2->g3 liegt auf der -n-Seite -> Normale -n.
// Kappen an Start (g3->g0) und Ende (g1->g2) liegen an den Achsen-Enden.
let np = [n[0], 0.0, n[1]]; // +n-Seite (world)
let nn = [-n[0], 0.0, -n[1]]; // -n-Seite
// Achsenrichtung (fuer die End-Kappen-Normalen).
let ux = wall.end[0] - wall.start[0];
let uz = wall.end[1] - wall.start[1];
let ul = (ux * ux + uz * uz).sqrt().max(1e-9);
let u = [ux / ul, 0.0, uz / ul];
let neg_u = [-u[0], 0.0, -u[2]];
// +n-Wand: aussen betrachtet CCW = b0, b1, t1, t0.
push_quad(mesh, b0, b1, t1, t0, np, color);
// -n-Wand: aussen betrachtet CCW = b2, b3, t3, t2.
push_quad(mesh, b2, b3, t3, t2, nn, color);
// End-Kappe (Achsen-Ende, Normale +u): b1, b2, t2, t1.
push_quad(mesh, b1, b2, t2, t1, u, color);
// Start-Kappe (Achsen-Anfang, Normale -u): b3, b0, t0, t3.
push_quad(mesh, b3, b0, t0, t3, neg_u, color);
}
/// Haengt ein Quad (vier Ecken in CCW von aussen) als zwei Dreiecke an, mit fester
/// Flaechen-Normale und Farbe je Vertex. Reihenfolge (0,1,2) + (0,2,3).
fn push_quad(
mesh: &mut Mesh,
a: [f32; 3],
b: [f32; 3],
c: [f32; 3],
d: [f32; 3],
normal: [f32; 3],
color: Rgb,
) {
let base = (mesh.verts.len() / FLOATS_PER_VERTEX) as u32;
for p in [a, b, c, d] {
mesh.verts.extend_from_slice(&[
p[0], p[1], p[2], normal[0], normal[1], normal[2], color[0], color[1], color[2],
]);
}
mesh.indices
.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
}
/// Baut ein einzelnes Mesh aus mehreren Waenden (alle in EINEN Puffer). Die
/// Reihenfolge der Vertices ist deterministisch (je Wand ein Quader), damit die
/// Tests stabile Zaehlungen erwarten koennen.
pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh {
let mut mesh = Mesh::default();
for w in walls {
extrude_wall(&mut mesh, w);
}
mesh
}
+57
View File
@@ -0,0 +1,57 @@
// WGSL-Shader des nativen 3D-Renderers. Als Konstante hier, damit die Quelle auch
// ohne aktives GPU-Feature (headless) im Repo pruefbar bleibt (naga-Test, Muster:
// render2d/shaders).
//
// Beleuchtung: EIN Directional-Light (Sonne) + ambienter Grundterm — das Pendant
// zur three.js-Sicht (AmbientLight 0.6 + DirectionalLight 1.1 bei (6,12,4), siehe
// Viewport3D.tsx). PBR (Rauheit/Metallik/Texturen) folgt in spaeteren Milestones.
//
// Uniform-Layout (group(0) binding(0)):
// view_proj : mat4x4<f32> world -> Clip (proj * view)
// light_dir : vec4<f32> Richtung ZUM Licht (world, xyz; w ungenutzt)
// ambient : vec4<f32> ambienter Grundfaktor (rgb; a ungenutzt)
//
// Vertex-Attribute: position (world), normal (world), color (Albedo).
/// Der einzige Shader (Vertex + Fragment). Diffuse Lambert-Beleuchtung mit einem
/// Directional-Light plus ambientem Sockel — GPU-Aequivalent des three.js-Setups.
pub const MESH_WGSL: &str = r#"
struct Globals {
view_proj : mat4x4<f32>,
light_dir : vec4<f32>,
ambient : vec4<f32>,
};
@group(0) @binding(0) var<uniform> globals : Globals;
struct VsIn {
@location(0) position : vec3<f32>,
@location(1) normal : vec3<f32>,
@location(2) color : vec3<f32>,
};
struct VsOut {
@builtin(position) clip_pos : vec4<f32>,
@location(0) world_normal : vec3<f32>,
@location(1) color : vec3<f32>,
};
@vertex
fn vs_main(in : VsIn) -> VsOut {
var out : VsOut;
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
out.world_normal = in.normal;
out.color = in.color;
return out;
}
@fragment
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
let n = normalize(in.world_normal);
let l = normalize(globals.light_dir.xyz);
// Lambert-Diffusanteil; Rueckseiten (n.l < 0) tragen nichts bei.
let diffuse = max(dot(n, l), 0.0);
// Ambienter Sockel (Albedo * ambient) + gerichteter Anteil (Albedo * diffuse).
let shaded = in.color * globals.ambient.rgb + in.color * diffuse;
return vec4<f32>(shaded, 1.0);
}
"#;
+182
View File
@@ -0,0 +1,182 @@
// Eingabe- und Ausgabe-Typen des nativen 3D-Renderers. Die Eingabe ist eine
// geflachte Beschreibung der Modellsicht (Waende), wie sie aus dem Web-Modell
// (`src/model/wall.ts`) heruebergereicht wird — reduziert auf das, was die
// Extrusion braucht: Achse (Start/Ende), Dicke, Hoehe, Basis-Hoehe.
//
// Die Typen sind serde-only und bewusst frei von GPU-Abhaengigkeiten, damit die
// Mesh-Erzeugung eigenstaendig (headless) getestet werden kann (Muster: render2d).
//
// KOORDINATEN-KONVENTION (identisch zur three.js-Sicht in src/viewport/Viewport3D.tsx):
// Modell ist 2D in Metern (x nach rechts, y nach "oben" im Grundriss).
// In der 3D-Welt gilt Y-up: world.x = model.x, world.z = model.y, world.y = Hoehe.
// D.h. der Grundriss liegt in der XZ-Ebene, die Extrusion laeuft entlang +Y.
use serde::{Deserialize, Serialize};
/// Ein 2D-Punkt in MODELL-Metern (Grundriss). Wird bei der Extrusion nach
/// world (XZ-Ebene) abgebildet: `[x, y] -> world (x, elevation, y)`.
pub type Point2 = [f32; 2];
/// RGB-Farbe/Albedo, Komponenten in [0,1]. Alpha wird (noch) nicht gebraucht.
pub type Rgb = [f32; 3];
/// Eine geflachte Wand: Achse (Mittellinie) Start->Ende im Grundriss, plus Dicke,
/// Hoehe und Basis-Hoehe. Aus diesen Feldern wird ein extrudiertes Quader-Mesh
/// erzeugt (Band aus Achse+Dicke, hochgezogen auf `height` ab `base_elevation`).
///
/// Vereinfachung des Web-`Wall` (siehe src/model/wall.ts): Ecken-Verschneidung
/// (`computeJoins`/`wallCorners`) und Oeffnungen kommen in spaeteren Milestones;
/// M1 extrudiert jede Wand als eigenstaendigen, an den Enden stumpf abgeschlossenen
/// Quader.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WallInput {
/// Achsen-Startpunkt im Grundriss (Modell-Meter).
pub start: Point2,
/// Achsen-Endpunkt im Grundriss (Modell-Meter).
pub end: Point2,
/// Wanddicke in Metern (das Band wird um `thickness/2` je Seite versetzt).
pub thickness: f32,
/// Wandhoehe in Metern (Extrusion entlang +Y).
pub height: f32,
/// Basis-Hoehe (Unterkante) in Metern. Default 0 (Geschossboden).
#[serde(default, rename = "baseElevation")]
pub base_elevation: f32,
/// Albedo-Farbe der Wand (RGB 0..1). Default helles Grau wie die three.js-Sicht.
#[serde(default = "default_wall_color")]
pub color: Rgb,
}
fn default_wall_color() -> Rgb {
[0.82, 0.80, 0.76]
}
/// Projektionsart der Kamera. Die three.js-Sicht schaltet zwischen perspektivisch
/// (freies Orbit) und orthografisch (die achsparallelen Presets front/top/side um).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Projection {
Perspective,
Orthographic,
}
impl Default for Projection {
fn default() -> Self {
Projection::Perspective
}
}
/// Kamera-Parameter (Blickrichtung + Projektion). `eye`/`target`/`up` in world.
/// Fuer die Orbit-Kamera werden `eye`/`target` aus Orbit-Winkeln+Distanz berechnet
/// (siehe `math::orbit_eye`), koennen hier aber auch direkt gesetzt werden.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Camera {
/// Kamera-Position (world).
pub eye: [f32; 3],
/// Blickziel (world).
pub target: [f32; 3],
/// Up-Vektor (world). Fuer Y-up: [0,1,0].
pub up: [f32; 3],
/// Projektionsart.
#[serde(default)]
pub projection: Projection,
/// Vertikaler Oeffnungswinkel in Radiant (nur perspektivisch).
#[serde(default = "default_fov")]
pub fov_y: f32,
/// Halbe Hoehe des Sichtvolumens in world-Metern (nur orthografisch).
#[serde(default = "default_ortho_half_height")]
pub ortho_half_height: f32,
/// Nah-/Fern-Ebene.
#[serde(default = "default_near")]
pub near: f32,
#[serde(default = "default_far")]
pub far: f32,
}
fn default_fov() -> f32 {
// 50 Grad, wie die three.js-PerspectiveCamera-Voreinstellung.
50.0_f32.to_radians()
}
fn default_ortho_half_height() -> f32 {
6.0
}
fn default_near() -> f32 {
0.05
}
fn default_far() -> f32 {
1000.0
}
impl Default for Camera {
fn default() -> Self {
Self {
eye: [8.0, 8.0, 8.0],
target: [0.0, 0.0, 0.0],
up: [0.0, 1.0, 0.0],
projection: Projection::Perspective,
fov_y: default_fov(),
ortho_half_height: default_ortho_half_height(),
near: default_near(),
far: default_far(),
}
}
}
/// Die fuenf Kamera-Presets der three.js-Sicht. `Iso` und `Persp` sind
/// perspektivisch/frei; `Front`/`Top`/`Side` sind achsparallel (orthografisch).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CameraPreset {
Front,
Top,
Side,
Iso,
Persp,
}
/// Ausgabe der Mesh-Erzeugung: interleaved [pos.xyz, normal.xyz, color.rgb] je
/// Vertex plus Indexpuffer. Bewusst GPU-frei (nur Vecs); die GPU-Schicht laedt
/// diese direkt in Vertex-/Index-Buffer hoch.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Mesh {
/// Interleaved Vertices: je 9 f32 = [px,py,pz, nx,ny,nz, r,g,b].
pub verts: Vec<f32>,
/// Dreiecks-Indizes (0-basiert auf die Vertices).
pub indices: Vec<u32>,
}
/// Anzahl f32 je Vertex im interleaved Puffer: 3 Position + 3 Normale + 3 Farbe.
pub const FLOATS_PER_VERTEX: usize = 9;
impl Mesh {
/// Anzahl Vertices im Puffer.
pub fn vertex_count(&self) -> usize {
self.verts.len() / FLOATS_PER_VERTEX
}
/// Anzahl Dreiecke.
pub fn triangle_count(&self) -> usize {
self.indices.len() / 3
}
/// Achsparallele Bounding-Box ueber alle Positionen: (min.xyz, max.xyz).
/// Fuer leere Meshes: beide Ecken im Ursprung.
pub fn bounds(&self) -> ([f32; 3], [f32; 3]) {
if self.verts.is_empty() {
return ([0.0; 3], [0.0; 3]);
}
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
let n = self.vertex_count();
for i in 0..n {
let base = i * FLOATS_PER_VERTEX;
for k in 0..3 {
let v = self.verts[base + k];
if v < min[k] {
min[k] = v;
}
if v > max[k] {
max[k] = v;
}
}
}
(min, max)
}
}