diff --git a/src-tauri/render3d/src/gpu.rs b/src-tauri/render3d/src/gpu.rs index 5df675f..9d682d1 100644 --- a/src-tauri/render3d/src/gpu.rs +++ b/src-tauri/render3d/src/gpu.rs @@ -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). diff --git a/src-tauri/render3d/src/lib.rs b/src-tauri/render3d/src/lib.rs index 9339588..a5039d9 100644 --- a/src-tauri/render3d/src/lib.rs +++ b/src-tauri/render3d/src/lib.rs @@ -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] diff --git a/src-tauri/render3d/src/mesh.rs b/src-tauri/render3d/src/mesh.rs index 29aca5c..e81cc7d 100644 --- a/src-tauri/render3d/src/mesh.rs +++ b/src-tauri/render3d/src/mesh.rs @@ -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. diff --git a/src-tauri/render3d/src/types.rs b/src-tauri/render3d/src/types.rs index ed98d15..22f6dc8 100644 --- a/src-tauri/render3d/src/types.rs +++ b/src-tauri/render3d/src/types.rs @@ -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, + /// Dreiecks-Indizes (0-basiert auf die Positions-Vertices, je 3 ein Dreieck). + pub indices: Vec, + /// 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, +} + +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)] diff --git a/src-tauri/render3d/src/web.rs b/src-tauri/render3d/src/web.rs index 5649ff4..d72262c 100644 --- a/src-tauri/render3d/src/web.rs +++ b/src-tauri/render3d/src/web.rs @@ -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, #[serde(default)] slabs: Vec, + /// Rohe Kontext-Meshes (Terrain/importierte Volumen). `#[serde(default)]` + /// haelt aeltere Payloads ohne `meshes` gueltig. + #[serde(default)] + meshes: Vec, } /// 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(()) } diff --git a/src-tauri/src/native.rs b/src-tauri/src/native.rs index 9a318f8..bdbc908 100644 --- a/src-tauri/src/native.rs +++ b/src-tauri/src/native.rs @@ -92,7 +92,7 @@ pub fn push_walls(value: serde_json::Value) { fn parse_model3d(value: serde_json::Value) -> Result { if value.is_array() { let walls = serde_json::from_value::>(value)?; - Ok(Model3d { walls, slabs: Vec::new() }) + Ok(Model3d { walls, slabs: Vec::new(), meshes: Vec::new() }) } else { serde_json::from_value::(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, #[serde(default)] slabs: Vec, + /// Rohe Kontext-Meshes (Terrain/importierte Volumen). `#[serde(default)]` + /// haelt aeltere Payloads ohne `meshes` gueltig. + #[serde(default)] + meshes: Vec, } #[cfg(feature = "native3d")] @@ -262,7 +266,7 @@ impl GpuState3d { fn new(window: Arc, 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 { 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 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)); } diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index b10fa20..4ebcac9 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -59,16 +59,38 @@ export interface RSlab { color: RRgb; } -/** Modell-Bündel für den nativen 3D-Renderer (Wände + Decken). */ +/** Art eines rohen Kontext-Meshes (bestimmt in render3d die Default-Einfärbung). */ +export type RMeshKind = "terrain" | "imported"; + +/** + * Ein rohes Dreiecks-Mesh aus dem Geo-Kontext (Terrain-TIN oder importiertes + * Gebäude-/DXF-Volumen). `positions` ist das flache (x,y,z=Höhe…)-Array in + * MODELL-Metern — genau wie `ImportedMesh`/`TerrainMesh` es hält; render3d bildet + * es selbst nach world (x, z, y) ab (analog Wände/Decken). Matcht + * render3d::types::MeshInput. + */ +export interface RMesh { + positions: number[]; + indices: number[]; + kind: RMeshKind; + color: RRgb; +} + +/** Modell-Bündel für den nativen 3D-Renderer (Wände + Decken + Kontext-Meshes). */ export interface RModel3d { walls: RWall[]; slabs: RSlab[]; + meshes: RMesh[]; } /** Warmer, neutraler Wand-Grundton (wie render3d default). */ const WALL_RGB: RRgb = [0.82, 0.8, 0.76]; /** Etwas hellerer, kühlerer Ton für Deckenplatten (heben sich von Wänden ab). */ const SLAB_RGB: RRgb = [0.86, 0.86, 0.88]; +/** Gedämpftes Lehm-/Geländegrün-grau (three.js mats.terrain = 0xa9b0a2). */ +const TERRAIN_RGB: RRgb = [169 / 255, 176 / 255, 162 / 255]; +/** Neutrales Hellgrau für importierte Meshes (three.js mats.mesh = 0x9fa6ae). */ +const IMPORTED_RGB: RRgb = [159 / 255, 166 / 255, 174 / 255]; /** Ignoriere entartete vertikale Ausschnitte (Rundungsrauschen). */ const EPS = 1e-4; @@ -219,6 +241,30 @@ function emitSlabs(project: Project): RSlab[] { return out; } +/** + * Emittiert die rohen Kontext-Meshes eines Projekts: Gelände-TINs (`terrainMesh`) + * und importierte Volumen (`importedMesh`) aus `project.context`. `contourSet` + * bleibt außen vor — das sind nur 2D-Linien (Höhenlinien/Footprints), keine + * Dreiecksmeshes. Die `positions`/`indices` werden 1:1 durchgereicht (Modell- + * Koordinaten, x,y,z=Höhe); render3d bildet sie selbst nach world ab. + */ +function emitMeshes(project: Project): RMesh[] { + const out: RMesh[] = []; + for (const obj of project.context ?? []) { + if (obj.type === "terrainMesh" || obj.type === "importedMesh") { + // Leere/entartete Meshes überspringen (render3d würde sie ohnehin verwerfen). + if (obj.positions.length < 9 || obj.indices.length < 3) continue; + out.push({ + positions: obj.positions, + indices: obj.indices, + kind: obj.type === "terrainMesh" ? "terrain" : "imported", + color: obj.type === "terrainMesh" ? TERRAIN_RGB : IMPORTED_RGB, + }); + } + } + return out; +} + /** * Nur die Wände (Rückwärts-Kompatibilität / bestehende Aufrufer). Zerlegt Wände * mit Öffnungen in Teilquader. @@ -229,7 +275,11 @@ export function projectToWalls3d(project: Project): RWall[] { return out; } -/** Das volle 3D-Modell: Wände (mit Öffnungen) + Deckenplatten. */ +/** Das volle 3D-Modell: Wände (mit Öffnungen) + Deckenplatten + Kontext-Meshes. */ export function projectToModel3d(project: Project): RModel3d { - return { walls: projectToWalls3d(project), slabs: emitSlabs(project) }; + return { + walls: projectToWalls3d(project), + slabs: emitSlabs(project), + meshes: emitMeshes(project), + }; }