7dc8f0d5c6
Kontext-Meshes (Dach, Fenster-Glas/-Rahmen, künftig swissBUILDINGS3D-Import) werden von append_context_mesh IMMER doppelseitig aufgebaut (jedes Dreieck + gespiegelte Rückseite mit invertierter Normale), weil die Mesh-Pipeline Backface-Culling aktiv hat. Die Kanten-Erkennung sah dadurch an JEDER Kante ein exakt entgegengesetztes Normalen-Paar (Vorder-/Rückseite desselben Dreiecks) und wertete das fälschlich als Knick — jede Flächen-Innendiagonale eines doppelseitigen Meshes wurde gezeichnet (Nutzer-Report: sichtbare Dreiecks-Diagonalen auf Dach und Fensterglas im "Schattiert mit Kanten"-Modus). Fix: Rückseiten-Duplikate (Normalen-Paar mit dot ≈ -1) werden vor der Rand-/ Knick-Entscheidung zusammengeführt (ein Vertreter je Original-Dreieck) — danach gilt dieselbe Logik wie bei einseitigen Meshes (Wände), unabhängig davon ob doppelseitig gerendert wurde. +5 Tests (doppelseitige Varianten der bestehenden Fälle), 88/88 grün mit --features render.
412 lines
15 KiB
Rust
412 lines
15 KiB
Rust
// 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<f32>`), 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<f32> {
|
|
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<f32> = 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<u32> = 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);
|
|
}
|
|
}
|