// Modell-Kanten (Feature-Edges) fuer die Draufsicht-Stile "wireframe" und // "hidden" des 3D-Renderers. // // Leitet aus einem fertigen Dreiecks-`Mesh` (types::Mesh, interleaved // [pos, normal, color]) eine schlanke Kantenliste ab und liefert sie im // GLEICHEN Vertex-Format wie das Bodengitter (grid.rs): interleaved // [px,py,pz, r,g,b], je 2 Vertices = 1 Liniensegment (LineList). Dadurch teilt // sich die Kanten-Zeichnung Shader (GRID_WGSL) und Vertex-Layout mit dem Grid; // die GPU-Schicht (gpu.rs) laedt die Vertices einmalig bei `set_model` in einen // Linien-Vertex-Buffer und cached sie (nicht je Frame). // // FEATURE-EDGE-STATT-ALLE-DREIECKSKANTEN: Wuerde man jede Dreieckskante zeichnen, // erschienen auch die Triangulierungs-Diagonalen jeder rechteckigen Flaeche // (jede Wand-/Deckenseite ist aus 2 Dreiecken) — ein unruhiges Gitter. Statt // dessen dedupliziert diese Schicht geteilte Kanten UND unterdrueckt Kanten // zwischen zwei koplanaren Dreiecken (Flaechen-Innendiagonalen): gezeichnet // werden nur Silhouetten-/Randkanten (Kante gehoert nur einem Dreieck) und // Knickkanten ("Crease": die beiden angrenzenden Dreiecke stehen deutlich // zueinander gewinkelt). Ergebnis ist ein sauberer Architektur-Drahtgitter-/ // Hidden-Line-Look statt einer Dreiecks-Suppe. // // GPU-frei (nur `Vec`), damit headless per `cargo test` pruefbar (Muster: // grid.rs / mesh.rs / section.rs). use std::collections::HashMap; use crate::types::{Mesh, FLOATS_PER_VERTEX}; /// f32 je Kanten-Vertex im interleaved Puffer: 3 Position (world) + 3 Farbe. /// Bewusst identisch zu `grid::GRID_FLOATS_PER_VERTEX`, damit dieselbe /// LineList-Pipeline/derselbe Shader (GRID_WGSL) beide zeichnen kann. pub const EDGE_FLOATS_PER_VERTEX: usize = 6; /// Farbe der Modell-Kanten (RGB 0..1). Dezentes, dunkles Anthrazit — deutlich /// genug fuer den Hidden-Line-Look, ohne hart schwarz zu wirken. pub const EDGE_COLOR: [f32; 3] = [0.16, 0.16, 0.18]; /// Quantisierungsfaktor fuer den Kanten-Schluessel: Positionen werden auf 0.1 mm /// (1e4 pro Meter) gerundet, damit geometrisch zusammenfallende Endpunkte aus /// verschiedenen (pro Flaeche duplizierten) Vertices denselben Schluessel teilen. const QUANT: f32 = 1.0e4; /// Kosinus des Crease-Winkels: liegen die Normalen zweier angrenzender Dreiecke /// enger beieinander (dot > COS), gelten sie als koplanar → die geteilte Kante /// wird NICHT gezeichnet (Flaechen-Innendiagonale). Darunter = Knickkante → /// zeichnen. cos(15°) ≈ 0.966: Ebenen-Diagonalen (dot≈1) fallen weg, echte /// Bauteil-Kanten (≥ ~90°) bleiben. const CREASE_COS: f32 = 0.966; /// Ganzzahliger Positions-Schluessel (quantisiert) fuer die Kanten-Deduplizierung. type Key = [i64; 3]; fn key_of(p: [f32; 3]) -> Key { [ (p[0] * QUANT).round() as i64, (p[1] * QUANT).round() as i64, (p[2] * QUANT).round() as i64, ] } /// Sammlung je Kante: die (erste gesehene) Position beider Endpunkte plus die /// Normalen aller angrenzenden Dreiecke — daraus entscheidet sich Rand/Knick. struct EdgeRec { p0: [f32; 3], p1: [f32; 3], normals: Vec<[f32; 3]>, } fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] } 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], ] } fn normalize(v: [f32; 3]) -> [f32; 3] { let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); if l <= 1e-12 { [0.0, 0.0, 0.0] } else { [v[0] / l, v[1] / l, v[2] / l] } } fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } /// Baut die Feature-Edge-LineList aus dem Mesh (siehe Moduldoc). /// /// Ausgabe: interleaved `[px,py,pz, r,g,b, ...]`, je 2 Vertices = 1 Segment. /// Leerer Vektor bei leerem/degeneriertem Mesh. pub fn build_mesh_edges(mesh: &Mesh) -> Vec { if mesh.indices.len() < 3 { return Vec::new(); } let pos = |i: u32| -> [f32; 3] { let b = i as usize * FLOATS_PER_VERTEX; [mesh.verts[b], mesh.verts[b + 1], mesh.verts[b + 2]] }; // Kanten-Schluessel (kanonisch sortiertes Endpunkt-Paar) -> Datensatz. let mut edges: HashMap<(Key, Key), EdgeRec> = HashMap::new(); for tri in mesh.indices.chunks_exact(3) { let (a, b, c) = (pos(tri[0]), pos(tri[1]), pos(tri[2])); // Geometrische Flaechennormale (robuster als die pro-Vertex-Normalen). let n = normalize(cross(sub(b, a), sub(c, a))); if n == [0.0, 0.0, 0.0] { continue; // entartetes Dreieck ueberspringen } for (u, v) in [(a, b), (b, c), (c, a)] { let (ku, kv) = (key_of(u), key_of(v)); if ku == kv { continue; // Null-Laengen-Kante } // Kanonische Reihenfolge, damit u->v und v->u denselben Schluessel haben. let (key, p0, p1) = if ku <= kv { ((ku, kv), u, v) } else { ((kv, ku), v, u) }; edges .entry(key) .or_insert_with(|| EdgeRec { p0, p1, normals: Vec::new(), }) .normals .push(n); } } let mut verts: Vec = Vec::new(); let mut push = |p: [f32; 3]| { verts.push(p[0]); verts.push(p[1]); verts.push(p[2]); verts.push(EDGE_COLOR[0]); verts.push(EDGE_COLOR[1]); verts.push(EDGE_COLOR[2]); }; for rec in edges.values() { if should_draw(&rec.normals) { push(rec.p0); push(rec.p1); } } verts } /// Entscheidet, ob eine Kante gezeichnet wird: Randkante (nur ein Dreieck) oder /// Knickkante (zwei angrenzende Dreiecke stehen ueber dem Crease-Winkel zuein- /// ander). Koplanare geteilte Kanten (Flaechendiagonalen) werden unterdrueckt. /// /// DOPPELSEITIGE MESHES (Kontext/Dach/Fenster-Glas, s. `mesh.rs::push_ctx_tri` — /// jedes Dreieck wird dort MIT gespiegelter Rueckseite [-Normale] dupliziert, /// weil die Mesh-Pipeline Backface-Culling aktiv hat): jedes Original-Dreieck /// liefert dadurch IMMER ein exaktes +n/-n-Paar an jeder seiner Kanten. Ohne /// Beruecksichtigung wuerde das faelschlich als extremer Knick (dot ≈ -1) /// gewertet und JEDE Flaechen-Innendiagonale eines doppelseitigen Meshes /// gezeichnet (Nutzer-Report: Dreiecks-Diagonalen sichtbar auf Dach/Glas). /// Fix: zuerst Rueckseiten-Partner (dot ≈ -1 zueinander) einander zuordnen und /// nur EINEN Vertreter je Original-Dreieck behalten — danach greift dieselbe /// Rand-/Knick-Logik wie bei einseitigen Meshes (Waende), unabhaengig davon, /// ob doppelseitig gerendert wurde oder nicht. fn should_draw(normals: &[[f32; 3]]) -> bool { let mut representatives: Vec<[f32; 3]> = Vec::new(); let mut used = vec![false; normals.len()]; for i in 0..normals.len() { if used[i] { continue; } used[i] = true; representatives.push(normals[i]); for j in (i + 1)..normals.len() { if used[j] { continue; } if dot(normals[i], normals[j]) < -CREASE_COS { used[j] = true; // Rueckseiten-Duplikat desselben Dreiecks break; } } } match representatives.len() { 0 => false, 1 => true, // Silhouette/Rand: gehoert nur einem Original-Dreieck _ => { // Zeichnen, sobald irgendein Normalen-Paar deutlich abknickt. for i in 0..representatives.len() { for j in (i + 1)..representatives.len() { if dot(representatives[i], representatives[j]) < CREASE_COS { return true; } } } false } } } #[cfg(test)] mod tests { use super::*; /// Hilfsbau: Mesh aus (pos, dummy-normal, dummy-color) + Indizes. fn mesh(positions: &[[f32; 3]], indices: &[u32]) -> Mesh { let mut verts = Vec::new(); for p in positions { verts.extend_from_slice(&[p[0], p[1], p[2], 0.0, 1.0, 0.0, 0.8, 0.8, 0.8]); } Mesh { verts, indices: indices.to_vec(), } } fn segment_count(v: &[f32]) -> usize { v.len() / EDGE_FLOATS_PER_VERTEX / 2 } #[test] fn single_triangle_has_three_boundary_edges() { let m = mesh( &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], &[0, 1, 2], ); let e = build_mesh_edges(&m); assert_eq!(segment_count(&e), 3); } #[test] fn coplanar_quad_drops_shared_diagonal() { // Ebenes Quad aus 2 Dreiecken in der XZ-Ebene: geteilte Diagonale ist // koplanar -> weg; es bleiben die 4 Umriss-Kanten. let m = mesh( &[ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 0.0, 1.0], [0.0, 0.0, 1.0], ], &[0, 1, 2, 0, 2, 3], ); let e = build_mesh_edges(&m); assert_eq!(segment_count(&e), 4); } #[test] fn folded_quad_keeps_crease() { // Zwei Dreiecke im 90°-Knick teilen die Kante (0,0,0)-(1,0,0): // Normalen stehen senkrecht -> Knickkante bleibt erhalten. let m = mesh( &[ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 0.0, 1.0], // erstes Dreieck in XZ (Normale +Y) [1.0, 1.0, 0.0], // zweites Dreieck kippt nach oben (Normale +Z) ], &[0, 1, 2, 0, 3, 1], ); let e = build_mesh_edges(&m); // geteilte Knickkante + je 2 Randkanten pro Dreieck = 5 Segmente. assert_eq!(segment_count(&e), 5); } #[test] fn empty_mesh_yields_no_edges() { let m = Mesh::default(); assert!(build_mesh_edges(&m).is_empty()); } } #[cfg(test)] mod double_sided_tests { // Deckt den Nutzer-Report ab (Dreiecks-Diagonalen sichtbar auf Dach/Glas): // Kontext-Meshes werden von `mesh.rs::push_ctx_tri` IMMER doppelseitig // aufgebaut (jedes Dreieck + gespiegelte Rueckseite [-Normale], weil die // Mesh-Pipeline Backface-Culling aktiv hat). Diese Tests bauen genau dieses // Muster nach (nicht die einfachen einseitigen Test-Meshes oben). use super::*; fn mesh_single_sided(positions: &[[f32; 3]], indices: &[u32]) -> Mesh { let mut verts = Vec::new(); for p in positions { verts.extend_from_slice(&[p[0], p[1], p[2], 0.0, 1.0, 0.0, 0.8, 0.8, 0.8]); } Mesh { verts, indices: indices.to_vec() } } /// Baut ein Mesh wie `mesh_single_sided`, aber verdoppelt JEDES Dreieck mit /// umgekehrter Wicklung (a,c,b statt a,b,c) — exakt das Muster von /// `mesh.rs::push_ctx_tri` (Vorder- + Rueckseite fuer doppelseitiges Rendering). fn mesh_double_sided(positions: &[[f32; 3]], indices: &[u32]) -> Mesh { let mut verts = Vec::new(); for p in positions { verts.extend_from_slice(&[p[0], p[1], p[2], 0.0, 1.0, 0.0, 0.8, 0.8, 0.8]); } let mut doubled = Vec::with_capacity(indices.len() * 2); for tri in indices.chunks_exact(3) { doubled.extend_from_slice(&[tri[0], tri[1], tri[2]]); doubled.extend_from_slice(&[tri[0], tri[2], tri[1]]); // gespiegelte Rueckseite } Mesh { verts, indices: doubled } } fn segment_count(v: &[f32]) -> usize { v.len() / EDGE_FLOATS_PER_VERTEX / 2 } #[test] fn doubled_coplanar_quad_still_drops_shared_diagonal() { // Dasselbe ebene Quad wie coplanar_quad_drops_shared_diagonal, aber // doppelseitig aufgebaut — VOR dem Fix haette dies faelschlich 6 statt // 4 Segmente geliefert (Diagonale faelschlich als Knick erkannt). let m = mesh_double_sided( &[ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 0.0, 1.0], [0.0, 0.0, 1.0], ], &[0, 1, 2, 0, 2, 3], ); let e = build_mesh_edges(&m); assert_eq!(segment_count(&e), 4); } #[test] fn doubled_single_triangle_still_has_three_boundary_edges() { // Silhouette-Kanten eines EINZELNEN Dreiecks muessen trotz Verdopplung // (Vorder-/Rueckseite) weiterhin als Rand erkannt und gezeichnet werden. let m = mesh_double_sided( &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], &[0, 1, 2], ); let e = build_mesh_edges(&m); assert_eq!(segment_count(&e), 3); } #[test] fn doubled_folded_quad_still_keeps_crease() { // Echter 90°-Knick bleibt auch doppelseitig als Kante erhalten (keine // Ueberkompensation, die auch echte Knicke unterdruecken wuerde). let m = mesh_double_sided( &[ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 0.0, 1.0], [1.0, 1.0, 0.0], ], &[0, 1, 2, 0, 3, 1], ); let e = build_mesh_edges(&m); assert_eq!(segment_count(&e), 5); } #[test] fn real_opening_box_topology_suppresses_face_diagonals_doubled() { // Exakte Ecken-Reihenfolge/Indizes aus toWalls3d.ts::OPENING_BOX_TRIS, // diesmal ECHT doppelseitig wie im tatsaechlichen Render-Pfad (die // Fenster-Glas-/Rahmen-Boxen laufen ueber append_context_mesh). let from = 0.0f32; let to = 1.2f32; let z_bottom = 0.9f32; let z_top = 2.1f32; let n_min = -0.03f32; let n_max = 0.03f32; let mut positions = Vec::new(); for i in 0..8u32 { let s = if i & 1 != 0 { to } else { from }; let z = if i & 2 != 0 { z_top } else { z_bottom }; let t = if i & 4 != 0 { n_max } else { n_min }; positions.push([s, -t, z]); } let indices: Vec = vec![ 0, 1, 3, 0, 3, 2, 4, 5, 7, 4, 7, 6, 0, 1, 5, 0, 5, 4, 2, 3, 7, 2, 7, 6, 0, 2, 6, 0, 6, 4, 1, 3, 7, 1, 7, 5, ]; let m = mesh_double_sided(&positions, &indices); let e = build_mesh_edges(&m); // Ein Quader hat 12 echte Kanten (Silhouette); bei korrekter Diagonalen- // Unterdrueckung sollten es GENAU 12 sein (nicht 12+6 Diagonalen=18). assert_eq!(segment_count(&e), 12, "erwartet 12 Kanten (Quader-Silhouette), keine Flaechendiagonalen"); } #[test] fn single_sided_helper_matches_module_level_mesh_helper() { // Absicherung: der lokale mesh_single_sided-Helfer verhaelt sich // identisch zum bestehenden `tests::mesh` (keine versehentliche // Abweichung beim Kopieren). let m = mesh_single_sided( &[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], &[0, 1, 2], ); let e = build_mesh_edges(&m); assert_eq!(segment_count(&e), 3); } }