Nordstern: Geo-Kontext-Meshes (Terrain/Import) rendern

Bisher zeigte nur three.js importierte Gebaeude/DXF-Meshes und das
Terrain-TIN (Project.context). Neuer MeshInput-Typ + Kontext-Mesh-Pfad
in render3d (Flat-Normalen, doppelseitig gegen unbekanntes Winding),
projectToModel3d speist project.context jetzt in beide Renderer
(nativ + WASM) ein. Nebenbei: fehlendes layers-Feld in demo_walls()
behoben, das den native3d-Feature-Build zuvor schon brach.
This commit is contained in:
2026-07-04 04:38:00 +02:00
parent 368a2ffcdb
commit fd890a0d74
7 changed files with 339 additions and 19 deletions
+12 -5
View File
@@ -13,9 +13,9 @@ use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt;
use crate::math::{view_projection, Mat4};
use crate::mesh::{build_model_mesh, build_walls_mesh};
use crate::mesh::{build_scene_mesh, build_walls_mesh};
use crate::shaders::MESH_WGSL;
use crate::types::{Camera, Mesh, SlabInput, WallInput, FLOATS_PER_VERTEX};
use crate::types::{Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX};
/// Tiefenformat des Z-Puffers (32 Bit Float, ueberall verfuegbar).
pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
@@ -219,9 +219,16 @@ impl Renderer {
self.upload_mesh(device, build_walls_mesh(walls));
}
/// Erzeugt das Mesh aus Waenden UND Deckenplatten und laedt die Puffer hoch.
pub fn upload_model(&mut self, device: &wgpu::Device, walls: &[WallInput], slabs: &[SlabInput]) {
self.upload_mesh(device, build_model_mesh(walls, slabs));
/// Erzeugt das Mesh aus Waenden, Deckenplatten UND rohen Kontext-Meshes
/// (Terrain/importierte Volumen, siehe `MeshInput`) und laedt die Puffer hoch.
pub fn upload_model(
&mut self,
device: &wgpu::Device,
walls: &[WallInput],
slabs: &[SlabInput],
meshes: &[MeshInput],
) {
self.upload_mesh(device, build_scene_mesh(walls, slabs, meshes));
}
/// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer).
+98 -2
View File
@@ -34,12 +34,16 @@ 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 mesh::{
append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh, extrude_slab,
extrude_wall, triangulate,
};
pub use section::{
cut_section, ComponentKind, ComponentRef, CutPolygon, SectionEdge, SectionOutput, SectionPlane,
};
pub use types::{
Camera, CameraPreset, Mesh, Point2, Projection, Rgb, SlabInput, WallInput, FLOATS_PER_VERTEX,
Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput, WallInput,
FLOATS_PER_VERTEX,
};
// --- Tests: Mesh-Erzeugung (Muster wie render2d/tessellate) -------------------
@@ -624,6 +628,98 @@ mod tests {
assert_eq!(mesh.vertex_count(), 0);
}
// --- Rohe Kontext-Meshes (Terrain / importierte Volumen) ------------------
use super::mesh::{append_context_mesh, build_scene_mesh};
use super::types::{MeshInput, MeshKind};
/// Ein flaches Dreieck auf Modell-Hoehe `z` (Modell x,y,z=Hoehe).
fn flat_context_tri(z: f32, kind: MeshKind, color: Option<[f32; 3]>) -> MeshInput {
MeshInput {
positions: vec![0.0, 0.0, z, 1.0, 0.0, z, 0.0, 1.0, z],
indices: vec![0, 1, 2],
kind,
color,
}
}
#[test]
fn context_mesh_ist_doppelseitig_und_mappt_hoehe_nach_y() {
let mut mesh = Mesh::default();
append_context_mesh(&mut mesh, &flat_context_tri(5.0, MeshKind::Terrain, None));
// Ein Quell-Dreieck -> zwei (Vorder-/Rueckseite) = 6 Vertices.
assert_eq!(mesh.triangle_count(), 2);
assert_eq!(mesh.vertex_count(), 6);
// model (x,y,z) -> world (x, z, y): die Hoehe z=5 liegt auf world.y=5.
let (min, max) = mesh.bounds();
assert!((min[1] - 5.0).abs() < 1e-5 && (max[1] - 5.0).abs() < 1e-5, "flach auf y=5");
// Doppelseitig: eine Normale +Y, eine Y.
let mut saw_up = false;
let mut saw_down = false;
for i in 0..mesh.vertex_count() {
let ny = mesh.verts[i * FLOATS_PER_VERTEX + 4];
if ny > 0.5 {
saw_up = true;
}
if ny < -0.5 {
saw_down = true;
}
}
assert!(saw_up && saw_down, "Vorder- UND Rueckseite (±Y)");
}
#[test]
fn context_mesh_default_farbe_je_kind() {
// Ohne explizite Farbe greift die kind-Default-Farbe (three.js mats.*).
let mut mesh = Mesh::default();
append_context_mesh(&mut mesh, &flat_context_tri(0.0, MeshKind::Terrain, None));
let c = [mesh.verts[6], mesh.verts[7], mesh.verts[8]];
assert_eq!(c, MeshKind::Terrain.default_color());
// Explizite Farbe schlaegt den Default.
let mut mesh2 = Mesh::default();
append_context_mesh(&mut mesh2, &flat_context_tri(0.0, MeshKind::Imported, Some([0.1, 0.2, 0.3])));
assert_eq!([mesh2.verts[6], mesh2.verts[7], mesh2.verts[8]], [0.1, 0.2, 0.3]);
}
#[test]
fn context_mesh_ueberspringt_kaputte_indizes_und_entartung() {
let mut mesh = Mesh::default();
// Index 9 out-of-range (nur 3 Vertices) -> Dreieck verworfen, kein Panic.
append_context_mesh(
&mut mesh,
&MeshInput {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
indices: vec![0, 1, 9],
kind: MeshKind::Imported,
color: None,
},
);
assert_eq!(mesh.vertex_count(), 0);
// Kollineares (entartetes) Dreieck -> keine Normale, verworfen.
append_context_mesh(
&mut mesh,
&MeshInput {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, 0.0, 0.0],
indices: vec![0, 1, 2],
kind: MeshKind::Imported,
color: None,
},
);
assert_eq!(mesh.vertex_count(), 0);
}
#[test]
fn build_scene_mesh_haengt_kontext_meshes_hinten_an() {
let model = build_model_mesh(&[wall_x(4.0, 0.2, 2.6)], &[square_slab(4.0, 2.6, 2.85)]);
let scene = build_scene_mesh(
&[wall_x(4.0, 0.2, 2.6)],
&[square_slab(4.0, 2.6, 2.85)],
&[flat_context_tri(0.0, MeshKind::Terrain, None)],
);
// Szene = Modell (Wand+Slab) + 6 Kontext-Vertices (doppelseitiges Dreieck).
assert_eq!(scene.vertex_count(), model.vertex_count() + 6);
}
// --- Kamera / Matrizen ----------------------------------------------------
#[test]
+91 -1
View File
@@ -58,7 +58,9 @@
use std::collections::HashMap;
use crate::openings::{split_range_by_voids, MIN_SPAN};
use crate::types::{Mesh, Opening, Point2, Rgb, SlabInput, WallInput, WallLayer, FLOATS_PER_VERTEX};
use crate::types::{
Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, WallInput, WallLayer, 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,
@@ -584,6 +586,94 @@ pub fn build_model_mesh(walls: &[WallInput], slabs: &[SlabInput]) -> Mesh {
mesh
}
/// Baut die volle Szene: Waende + Deckenplatten (`build_model_mesh`) und haengt
/// danach die rohen Kontext-Meshes (Terrain/importierte Volumen, siehe
/// `MeshInput`) an — alles in EINEN Puffer. Die Wand-/Slab-Reihenfolge bleibt
/// vorne (deterministische Zaehlung fuer die bestehenden Tests); die Kontext-
/// Meshes kommen hinten dran.
pub fn build_scene_mesh(walls: &[WallInput], slabs: &[SlabInput], meshes: &[MeshInput]) -> Mesh {
let mut mesh = build_model_mesh(walls, slabs);
for m in meshes {
append_context_mesh(&mut mesh, m);
}
mesh
}
// ── Rohe Kontext-Meshes (Terrain / importierte Volumen) ──────────────────────
/// Haengt EIN rohes Dreiecks-Mesh (Terrain-TIN oder importiertes Volumen, siehe
/// `MeshInput`) an `mesh` an. `positions` ist ein flaches (x,y,z)-Array in
/// MODELL-Metern (z = Hoehe), `indices` sind Dreiecks-Indizes. Jede Dreiecks-
/// flaeche bekommt eigene Vertices mit ihrer Flaechen-Normale (Flat Shading, wie
/// Wand/Slab) — so ist das Ergebnis unabhaengig von geteilten Vertices im
/// Quell-Mesh.
///
/// Koordinaten: model `(x, y, z)` -> world `(x, z, y)` (Hoehe entlang +Y), exakt
/// wie `extrude_layer_segment`/`extrude_slab` und `Viewport3D.tsx`.
///
/// DOPPELSEITIG: jedes Dreieck wird ZWEIMAL emittiert (Vorderseite `a,b,c` mit
/// Normale `+n`, Rueckseite `a,c,b` mit `-n`), weil das Quell-Winding von
/// Terrain/Import nicht garantiert ist (three.js rendert sie mit `DoubleSide`).
/// Da der Pfad Rueckseiten cullt (`gpu.rs`), traegt stets das zum Betrachter
/// zeigende Dreieck die korrekte Normale, das andere wird verworfen — keine
/// Z-Fighting (beide liegen deckungsgleich, nur eines ist front-facing).
pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) {
let pos = &input.positions;
let vcount = pos.len() / 3;
let color = input.effective_color();
// world-Position aus Modell (x, y, z=Hoehe): (x, hoehe, y).
let vworld = |i: usize| -> [f32; 3] {
let b = i * 3;
[pos[b], pos[b + 2], pos[b + 1]]
};
for t in input.indices.chunks_exact(3) {
let (ia, ib, ic) = (t[0] as usize, t[1] as usize, t[2] as usize);
// Fehlerhafte Indizes robust ueberspringen (kein Panic bei kaputter Eingabe).
if ia >= vcount || ib >= vcount || ic >= vcount {
continue;
}
let a = vworld(ia);
let b = vworld(ib);
let c = vworld(ic);
// Flaechen-Normale (b-a) x (c-a); entartete Dreiecke ueberspringen.
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
let gn = [
ab[1] * ac[2] - ab[2] * ac[1],
ab[2] * ac[0] - ab[0] * ac[2],
ab[0] * ac[1] - ab[1] * ac[0],
];
let len = (gn[0] * gn[0] + gn[1] * gn[1] + gn[2] * gn[2]).sqrt();
if len < 1e-12 {
continue;
}
let n = [gn[0] / len, gn[1] / len, gn[2] / len];
// Vorderseite + Rueckseite (siehe Moduldoc: doppelseitig).
push_ctx_tri(mesh, a, b, c, n, color);
push_ctx_tri(mesh, a, c, b, [-n[0], -n[1], -n[2]], color);
}
}
/// Haengt EIN Dreieck (drei world-Ecken) mit gegebener Normale + Farbe an —
/// Winding wie uebergeben (im Gegensatz zu `push_tri_oriented`, das umsortiert).
/// Wird von `append_context_mesh` fuer beide Seiten genutzt.
fn push_ctx_tri(
mesh: &mut Mesh,
a: [f32; 3],
b: [f32; 3],
c: [f32; 3],
normal: [f32; 3],
color: Rgb,
) {
let base = (mesh.verts.len() / FLOATS_PER_VERTEX) as u32;
for p in [a, b, c] {
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]);
}
// ── Deckenplatten (extrudierte Polygone) ─────────────────────────────────────
/// Signierte Flaeche eines Grundriss-Polygons (Shoelace) in Modell-Koordinaten.
+68
View File
@@ -128,6 +128,74 @@ fn default_slab_color() -> Rgb {
[0.86, 0.86, 0.88]
}
/// Art eines rohen Kontext-Meshes (aus `Project.context`) — bestimmt die
/// Default-Einfaerbung, wenn kein explizites `color` mitkommt. Werte sind
/// serde-kompatibel mit der TS-Seite (lowercase).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum MeshKind {
/// Gelaende-TIN (offene Flaeche) — gedecktes Lehm-/Gelaendegruen-grau.
Terrain,
/// Importiertes Volumen (Gebaeude/DXF) — neutrales Hellgrau.
#[default]
Imported,
}
impl MeshKind {
/// Default-Albedo (RGB 0..1), wenn `MeshInput::color` fehlt. Werte 1:1 aus
/// der three.js-Sicht (`src/viewport/Viewport3D.tsx`, `mats.terrain`/
/// `mats.mesh`), damit beide Renderer denselben Kontext-Farbton zeigen.
pub fn default_color(self) -> Rgb {
match self {
// 0xa9b0a2 — gedaempftes Lehm-/Gelaendegruen-grau (Clay-Look).
MeshKind::Terrain => [0.663, 0.690, 0.635],
// 0x9fa6ae — neutrales Hellgrau fuer importierte Meshes.
MeshKind::Imported => [0.624, 0.651, 0.682],
}
}
}
/// Ein rohes Dreiecks-Mesh aus dem Geo-Kontext (`Project.context`): entweder ein
/// Gelaende-TIN (`TerrainMesh`) oder ein importiertes Volumen (`ImportedMesh`,
/// z. B. extrudierte OSM-Gebaeude/DXF). Anders als `WallInput`/`SlabInput` bringt
/// es die Geometrie bereits FERTIG TRIANGULIERT mit — `mesh.rs` berechnet daraus
/// nur noch die Flaechen-Normalen (Flat Shading, wie Wand/Slab) und speist es in
/// denselben GPU-Pfad ein.
///
/// KOORDINATEN wie WallInput/SlabInput: die TS-Seite (`projectToModel3d`) liefert
/// `positions` im MODELL-System als flaches `[x, y, z, ...]`-Array (z = Hoehe, in
/// Metern); `mesh.rs` bildet jeden Vertex nach world ab: model `(x, y, z)` ->
/// world `(x, z, y)`, d. h. Grundriss in der XZ-Ebene, Hoehe entlang +Y (exakt wie
/// `Viewport3D.tsx` die rohen positions konsumiert).
///
/// DOPPELSEITIG: Terrain-/Import-Meshes tragen KEIN garantiertes Winding (die
/// three.js-Sicht rendert sie mit `DoubleSide`, siehe `io/geoContext.ts`). Da der
/// Nordstern-Pfad Rueckseiten cullt, emittiert `mesh.rs` jedes Dreieck ZWEIMAL
/// (beide Windings, entgegengesetzte Normale) — so bleibt die Flaeche aus jeder
/// Blickrichtung sichtbar und korrekt beleuchtet.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshInput {
/// Flaches Positions-Array in MODELL-Metern: `[x0,y0,z0, x1,y1,z1, ...]`
/// (z = Hoehe). Wird von `mesh.rs` nach world (x, z, y) abgebildet.
pub positions: Vec<f32>,
/// Dreiecks-Indizes (0-basiert auf die Positions-Vertices, je 3 ein Dreieck).
pub indices: Vec<u32>,
/// Art des Meshes (Default-Einfaerbung, siehe `MeshKind`). Default `Imported`.
#[serde(default)]
pub kind: MeshKind,
/// Optionale explizite Albedo-Farbe (RGB 0..1). Fehlt sie, greift die
/// Default-Farbe je `kind` (siehe `effective_color`).
#[serde(default)]
pub color: Option<Rgb>,
}
impl MeshInput {
/// Effektive Albedo-Farbe: explizit gesetzt (`color`) oder Default je `kind`.
pub fn effective_color(&self) -> Rgb {
self.color.unwrap_or_else(|| self.kind.default_color())
}
}
/// 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)]
+6 -2
View File
@@ -32,7 +32,7 @@ use web_sys::HtmlCanvasElement;
use crate::gpu::Renderer;
use crate::math::preset_camera;
use crate::section::{cut_section, SectionPlane};
use crate::types::{Camera, CameraPreset, Projection, SlabInput, WallInput};
use crate::types::{Camera, CameraPreset, MeshInput, Projection, SlabInput, WallInput};
/// JSON-Eingabe des Modells: Waende (bereits in Teilquader zerlegt, siehe
/// `projectToWalls3d`) + Deckenplatten. `slabs` optional (leeres Modell ohne
@@ -42,6 +42,10 @@ struct ModelInput {
walls: Vec<WallInput>,
#[serde(default)]
slabs: Vec<SlabInput>,
/// Rohe Kontext-Meshes (Terrain/importierte Volumen). `#[serde(default)]`
/// haelt aeltere Payloads ohne `meshes` gueltig.
#[serde(default)]
meshes: Vec<MeshInput>,
}
/// Berechnet einen Schnitt/eine Ansicht (`section::cut_section`) gegen das Modell
@@ -232,7 +236,7 @@ impl WebModelRenderer {
.map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?;
self.bounds = model_bounds(&model.walls, &model.slabs);
self.renderer
.upload_model(&self.device, &model.walls, &model.slabs);
.upload_model(&self.device, &model.walls, &model.slabs, &model.meshes);
Ok(())
}
+11 -6
View File
@@ -92,7 +92,7 @@ pub fn push_walls(value: serde_json::Value) {
fn parse_model3d(value: serde_json::Value) -> Result<Model3d, serde_json::Error> {
if value.is_array() {
let walls = serde_json::from_value::<Vec<WallInput>>(value)?;
Ok(Model3d { walls, slabs: Vec::new() })
Ok(Model3d { walls, slabs: Vec::new(), meshes: Vec::new() })
} else {
serde_json::from_value::<Model3d>(value)
}
@@ -232,7 +232,7 @@ use render3d::gpu::Renderer as Renderer3d;
#[cfg(feature = "native3d")]
use render3d::math::orbit_eye;
#[cfg(feature = "native3d")]
use render3d::types::{Camera, Projection, SlabInput, WallInput};
use render3d::types::{Camera, MeshInput, Projection, SlabInput, WallInput};
#[cfg(feature = "native3d")]
use serde::Deserialize;
@@ -245,6 +245,10 @@ struct Model3d {
walls: Vec<WallInput>,
#[serde(default)]
slabs: Vec<SlabInput>,
/// Rohe Kontext-Meshes (Terrain/importierte Volumen). `#[serde(default)]`
/// haelt aeltere Payloads ohne `meshes` gueltig.
#[serde(default)]
meshes: Vec<MeshInput>,
}
#[cfg(feature = "native3d")]
@@ -262,7 +266,7 @@ impl GpuState3d {
fn new(window: Arc<Window>, model: &Model3d) -> Self {
let (surface, device, queue, config) = configure_surface(&window, "3d.device");
let mut renderer = Renderer3d::new(&device, config.format);
renderer.upload_model(&device, &model.walls, &model.slabs);
renderer.upload_model(&device, &model.walls, &model.slabs, &model.meshes);
renderer.set_light([6.0, 12.0, 4.0]);
Self { surface, device, queue, config, renderer, window }
}
@@ -315,12 +319,12 @@ fn load_model() -> Model3d {
Ok(model) => model,
Err(e) => {
eprintln!("native3d: Modell-Parse-Fehler ({WALLS_PATH}): {e} — nutze Demo-Waende");
Model3d { walls: demo_walls(), slabs: Vec::new() }
Model3d { walls: demo_walls(), slabs: Vec::new(), meshes: Vec::new() }
}
},
Err(e) => {
eprintln!("native3d: Modell nicht ladbar ({WALLS_PATH}): {e} — nutze Demo-Waende");
Model3d { walls: demo_walls(), slabs: Vec::new() }
Model3d { walls: demo_walls(), slabs: Vec::new(), meshes: Vec::new() }
}
}
}
@@ -339,6 +343,7 @@ fn demo_walls() -> Vec<WallInput> {
base_elevation: 0.0,
color: grey,
openings: vec![],
layers: None,
};
vec![
mk([0.0, 0.0], [6.0, 0.0]),
@@ -715,7 +720,7 @@ impl ApplicationHandler<UserEvent> for App {
self.pending3d = Some(model);
return;
};
state.renderer.upload_model(&state.device, &model.walls, &model.slabs);
state.renderer.upload_model(&state.device, &model.walls, &model.slabs, &model.meshes);
if !self.nav3d {
self.orbit = Some(Orbit::framed(&model));
}