From 54211b144385deeb73189790c5715df5b81fc11b Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 3 Jul 2026 08:13:57 +0200 Subject: [PATCH] =?UTF-8?q?render3d:=20Schnitt-Modul=20=E2=80=94=20Cut-Pol?= =?UTF-8?q?ygone=20+=20sichtbare/verdeckte=20Kanten=20aus=20Prismen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analytische Eigen-Engine-Alternative zum OCCT-HLR-Spike: Schnittebene (Punkt+Normale) gegen extrudierte Fussabdruck-Prismen; Cut-Polygone in (u,v)-Schnittkoordinaten mit Komponenten-Referenz (spaetere Schraffur), Projektion der dahinterliegenden Kanten mit Verdeckungstest, getrennt visible/hidden. SectionOutput serde-serialisierbar (Meter). 26 Tests, Example section_svg schreibt docs/welle-c-hlr-spike/section-engine-proof.svg (L-Wand+Bodenplatte: Poché, durchgezogene sichtbare, gestrichelte verdeckte Kanten). Offene Punkte in docs/design/engine-section-pipeline.md. --- docs/design/engine-section-pipeline.md | 119 +++ .../section-engine-proof.svg | 26 + src-tauri/render3d/examples/section_svg.rs | 156 +++ src-tauri/render3d/src/lib.rs | 4 + src-tauri/render3d/src/section.rs | 901 ++++++++++++++++++ 5 files changed, 1206 insertions(+) create mode 100644 docs/design/engine-section-pipeline.md create mode 100644 docs/welle-c-hlr-spike/section-engine-proof.svg create mode 100644 src-tauri/render3d/examples/section_svg.rs create mode 100644 src-tauri/render3d/src/section.rs diff --git a/docs/design/engine-section-pipeline.md b/docs/design/engine-section-pipeline.md new file mode 100644 index 0000000..1bf707d --- /dev/null +++ b/docs/design/engine-section-pipeline.md @@ -0,0 +1,119 @@ +# Schnitt/Ansicht-Pipeline: analytische Prismen-Projektion (`render3d::section`) + +> Gegenstueck zum OCCT-WASM-HLR-Spike (`docs/welle-c-hlr-spike/FEASIBILITY.md`): +> statt eines generischen CAD-Kernels nutzt dieser Ansatz eine Invariante des +> Modells, um Schnitt/Ansicht rein analytisch (kein Hidden-Line-Removal-Solver) +> zu berechnen. Implementiert in `src-tauri/render3d/src/section.rs`. + +## Ansatz + +Jedes Bauteil in diesem Modell ist ein **Prisma**: ein 2D-Grundriss-Fussabdruck- +Polygon (Wand-Band bzw. Decken-Umriss, siehe `mesh.rs`), konstant extrudiert +ueber ein Hoehenintervall `[z0, z1]`. Diese Einschraenkung — konstanter +Querschnitt ueber die gesamte Hoehe — macht die Schnittgeometrie trivial im +Vergleich zu generischem HLR: + +- **Schnitt einer vertikalen Ebene mit einem Prisma** = 2D-Geraden/Polygon- + Clipping IM GRUNDRISS (die Schnittebene projiziert im Grundriss auf eine + Linie) → ein oder mehrere u-Intervalle, in denen die Linie das Fussabdruck- + Polygon durchquert. Jedes Intervall × `[z0, z1]` ist das Cut-Rechteck. Da die + Hoehe unabhaengig von der Grundriss-Position ist, ist das Ergebnis **immer + ein Rechteck**, nie ein Trapez — auch bei diagonalen Wandachsen. +- **Projektion/Ansicht** (Kanten hinter der Ebene, in Blickrichtung) reduziert + sich auf ein **2D-Sichtbarkeitsproblem im Grundriss** kombiniert mit einem + Hoehen-Ueberlappungstest: da alle Seitenflaechen der Prismen vertikal sind, + genuegt ein Sichtstrahl-Test im Grundriss (verdeckt ein naeheres Prisma- + Fussabdruckpolygon die Sichtlinie?) plus Ueberlappung der Hoehenintervalle. + +Das ersetzt einen 66-MB-WASM-CAD-Kernel durch closed-form Arithmetik in reinem +Rust, ohne zusaetzliches Laufzeitgewicht ueber `render3d` hinaus. + +## `SectionOutput`-Format + +Modul: `render3d::section`. Alle Groessen in **Metern**. + +```rust +pub struct SectionPlane { pub point: [f32; 3], pub normal: [f32; 3] } + +pub struct SectionOutput { + pub cut_polygons: Vec, // JSON: "cutPolygons" + pub visible_edges: Vec, // JSON: "visibleEdges" + pub hidden_edges: Vec, // JSON: "hiddenEdges" +} + +pub struct CutPolygon { pub component: ComponentRef, pub color: Rgb, pub pts: Vec<[f32; 2]> } +pub struct SectionEdge { pub component: ComponentRef, pub a: [f32; 2], pub b: [f32; 2] } +pub struct ComponentRef { pub kind: ComponentKind /* Wall | Slab */, pub index: usize } +``` + +**Koordinatensystem der Ausgabe (u, v):** + +- Ursprung: `SectionPlane::point`, projiziert. +- `u` (horizontal): Strecke entlang der Schnittebene, senkrecht zur + Blickrichtung, berechnet als `normalize(cross(normal, world_up))` — dieselbe + rechtshaendige Konvention wie `math::look_at`s `right`-Vektor. Bei den vier + Standard-Konstruktoren (`looking_plus_x/minus_x/plus_y/minus_y`) ist `u` + direkt die jeweils andere Grundriss-Achse. +- `v` (vertikal, "Hoehe"): `v = world.y`, ABSOLUT. Diese Codebasis ist + durchgaengig Y-up (`types.rs`/`mesh.rs`/`math.rs`: „world.y = Hoehe"); `v` + folgt bewusst dieser etablierten Konvention statt einer wortwoertlichen + „world.z"-Lesart, um modulübergreifend konsistent zu bleiben. +- Nur **vertikale** Schnittebenen (Normale ohne Hoehen-Komponente) sind + unterstuetzt — Grundriss-/Horizontalschnitte bleiben Sache der bestehenden + 2D-Plan-Pipeline. + +`cut_polygons` sind bei diesem Modell immer Rechtecke (siehe oben), aber als +generischer Punktering abgelegt — kompatibel zu `render2d::types::FillPolygon` +(`pts: Vec`), dem vorgesehenen Zielformat fuer die spaetere 1:1- +Uebersetzung in die Plan-/Schnitt-Ansicht. + +## Bekannte Luecken + +- **Keine Wandknoten-Verschneidung (T-/X-Stoesse).** Wie in `mesh.rs` + (M1-Stand) werden Waende als eigenstaendige, stumpf abgeschlossene Quader + behandelt, die sich an Ecken UEBERLAPPEN statt sich zu vereinen (kein + Miter-Join). Der Schnitt-Extraktor erbt das: an einem L-/T-Knoten kann eine + Wand als „in die andere eingebettet" verdeckt erscheinen (im Testmodul + `section::tests` bewusst als reales, erwartetes Verhalten dokumentiert und + geprueft — kein Bug dieses Moduls, sondern ein Artefakt der fehlenden + Verschneidungslogik weiter oben in der Pipeline). +- **Keine Oeffnungs-Aussparungen.** `WallInput`/`SlabInput` kennen (noch) keine + Tueren/Fenster; der Schnitt schneidet daher immer die volle Wandflaeche. + Sobald Oeffnungen im Modell ankommen, muss `wall_prism`/die Cut-Polygon- + Bildung sie als Aussparungen (Lochpolygone bzw. mehrere Teil-Rechtecke pro + Hoehenband) beruecksichtigen. +- **Keine echte Component-/Material-Id.** `WallInput`/`SlabInput` haben aktuell + keine eigene Id; `ComponentRef` referenziert daher nur `(Art, Index im + Eingabe-Array)` + die rohe Albedo-Farbe als Material-Platzhalter. Sobald ein + echtes Ressourcen-/Material-System existiert, sollte `ComponentRef` auf eine + stabile Id umgestellt werden (Indizes sind nicht stabil ueber Modell-Edits). +- **Verdeckungstest nutzt Bounding-Boxen, nicht die exakte Fussabdruckform.** + Fuer Wand-Baender und (i. d. R. konvexe) Deckenumrisse ist das exakt; bei + stark konkaven oder diagonalen Grundrissen kann es zu Ueberverdeckung + fuehren (ein Prisma blockiert dann auch Bereiche seiner eigenen „Nischen"). +- **Kein generisches HLR.** Der Ansatz funktioniert NUR, weil alle Bauteile + Prismen mit konstantem Querschnitt sind. Fuer echte gekrümmte oder nicht- + prismatische Geometrie (Bogenwaende, Freiformdaecher, …) waere er nicht + anwendbar — dafuer bliebe der OCCT-Weg (oder eine eigene, generischere + HLR-Implementierung) die Referenz. +- **Keine robuste Sonderfallbehandlung fuer Vertices exakt auf der + Schnittlinie** (Toleranz-basiert, kein Tie-Breaking/Pertubation) — die + Testszenarien vermeiden diesen Fall bewusst. + +## Vergleich zum OCCT-WASM-Spike + +| | OCCT-WASM (`docs/welle-c-hlr-spike`) | Dieser Ansatz (`render3d::section`) | +|---|---|---| +| Verfahren | Generisches HLR (`HLRAppli_ReflectLines`) | Analytische Prismen-Projektion | +| Anwendbarkeit | Beliebige BREP-Geometrie | Nur Prismen (konstanter Querschnitt über Höhe) | +| Zusaetzliches Gewicht | ~62,8 MB WASM (~19,6 MB gzip), separater Lazy-Chunk | Keins — reines Rust in `render3d`, kein weiteres WASM-Asset | +| Modul-Init | ~450 ms (Browser) / ~680 ms (Node) | Kein Initialisierungsschritt (kein Fremd-Modul zu laden) | +| Rechenzeit (L-Wand + Platte) | ~21 ms (reiner HLR-Lauf, gemessen im Browser) | Nicht separat gemessen (kein Millisekunden-Timer im Test); die Operationen sind reine Vektor-/Intervall-Arithmetik über wenige Kanten (O(Anzahl-Prismen × Kanten-pro-Prisma) mit kleinen Konstanten) und liegen der Groessenordnung nach klar unter 1 ms fuer Szenen dieser Groesse — eine belastbare Messung steht noch aus | +| Sichtbarkeit (verdeckte Kanten) | Exakt (echter HLR-Solver) | Naeherung über Bounding-Boxen im Grundriss + Hoehenintervall-Ueberlappung; exakt fuer achsparallele/konvexe Fussabdruecke | +| Reifegrad | Isolierter Spike, nicht verdrahtet | Isolierter Spike (dieses Modul), nicht in `render2d`/die Plan-Ansicht verdrahtet | + +**Fazit:** Fuer den ueberwiegenden Regelfall dieses Projekts (Waende, Decken — +alles Prismen) ist die analytische Loesung der pragmatischere Weg: kein +zusaetzliches WASM-Gewicht, keine Fremd-Bibliothek, headless testbar wie der +Rest von `render3d`. Der OCCT-Weg bleibt die Referenz, falls/sobald echte +generische Volumenkoerper (Booleans, gekrümmte Flaechen) ins Modell kommen. diff --git a/docs/welle-c-hlr-spike/section-engine-proof.svg b/docs/welle-c-hlr-spike/section-engine-proof.svg new file mode 100644 index 0000000..76ff637 --- /dev/null +++ b/docs/welle-c-hlr-spike/section-engine-proof.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/render3d/examples/section_svg.rs b/src-tauri/render3d/examples/section_svg.rs new file mode 100644 index 0000000..c104dd0 --- /dev/null +++ b/src-tauri/render3d/examples/section_svg.rs @@ -0,0 +1,156 @@ +//! Beweis-Artefakt fuer den analytischen Schnitt/Ansicht-Extraktor (`section.rs`): +//! baut die L-Wand + Bodenplatte-Testszene, schneidet sie mit einer Ebene, die +//! sowohl Material schneidet ALS AUCH auf dahinterliegende Kanten blickt, und +//! schreibt das Ergebnis als SVG nach `docs/welle-c-hlr-spike/section-engine-proof.svg`. +//! +//! Aufruf: `cargo run --example section_svg` (im Verzeichnis `src-tauri/render3d`). + +use render3d::section::{cut_section, ComponentKind, SectionOutput, SectionPlane}; +use render3d::{SlabInput, WallInput}; + +fn l_wall_scene() -> (Vec, Vec) { + let color = [0.8, 0.8, 0.8]; + let walls = vec![ + WallInput { + start: [0.0, 0.0], + end: [0.0, 3.0], + thickness: 0.2, + height: 2.5, + base_elevation: 0.0, + color, + }, + WallInput { + start: [0.0, 3.0], + end: [3.0, 3.0], + thickness: 0.2, + height: 2.5, + base_elevation: 0.0, + color, + }, + ]; + let slabs = vec![SlabInput { + outline: vec![[-0.5, -0.5], [3.5, -0.5], [3.5, 3.5], [-0.5, 3.5]], + z_bottom: -0.2, + z_top: 0.0, + color: [0.86, 0.86, 0.88], + }]; + (walls, slabs) +} + +/// Rendert `out` als SVG: Cut-Polygone gefuellt + Kontur, sichtbare Kanten +/// durchgezogen, verdeckte Kanten gestrichelt. Achse v wird fuer den Bildraum +/// gespiegelt (SVG: y waechst nach unten; Schnittkoordinaten: v waechst nach oben). +fn render_svg(out: &SectionOutput) -> String { + const MARGIN: f32 = 0.4; + const SCALE: f32 = 120.0; // Pixel je Meter + + let mut min_u = f32::INFINITY; + let mut max_u = f32::NEG_INFINITY; + let mut min_v = f32::INFINITY; + let mut max_v = f32::NEG_INFINITY; + let mut visit = |u: f32, v: f32| { + min_u = min_u.min(u); + max_u = max_u.max(u); + min_v = min_v.min(v); + max_v = max_v.max(v); + }; + for c in &out.cut_polygons { + for p in &c.pts { + visit(p[0], p[1]); + } + } + for e in out.visible_edges.iter().chain(out.hidden_edges.iter()) { + visit(e.a[0], e.a[1]); + visit(e.b[0], e.b[1]); + } + assert!(min_u.is_finite() && max_u.is_finite(), "leere Szene?"); + + min_u -= MARGIN; + max_u += MARGIN; + min_v -= MARGIN; + max_v += MARGIN; + let width = (max_u - min_u) * SCALE; + let height = (max_v - min_v) * SCALE; + + let sx = |u: f32| (u - min_u) * SCALE; + let sy = |v: f32| height - (v - min_v) * SCALE; // v-Achse spiegeln + + let mut svg = String::new(); + svg.push_str(&format!( + "\n" + )); + svg.push_str(&format!( + "\n" + )); + + // Cut-Polygone: gefuellt (grau) + Kontur. + for c in &out.cut_polygons { + let pts = c + .pts + .iter() + .map(|p| format!("{:.2},{:.2}", sx(p[0]), sy(p[1]))) + .collect::>() + .join(" "); + let fill = if c.component.kind == ComponentKind::Wall { + "#b9b3ab" + } else { + "#c9d2d6" + }; + svg.push_str(&format!( + "\n" + )); + } + + // Sichtbare Kanten: durchgezogen. + for e in &out.visible_edges { + svg.push_str(&format!( + "\n", + sx(e.a[0]), sy(e.a[1]), sx(e.b[0]), sy(e.b[1]) + )); + } + // Verdeckte Kanten: gestrichelt. + for e in &out.hidden_edges { + svg.push_str(&format!( + "\n", + sx(e.a[0]), sy(e.a[1]), sx(e.b[0]), sy(e.b[1]) + )); + } + + svg.push_str("\n"); + svg +} + +fn main() { + let (walls, slabs) = l_wall_scene(); + // Ebene bei Grundriss-X = -0.3: schneidet NUR die Bodenplatte (Umriss x in + // [-0.5,3.5]) -- liefert deren Poche als Cut-Polygon -- waehrend BEIDE + // Wandschenkel (x in [-0.1,0.1] bzw. [0,3]) vollstaendig HINTER der Ebene + // liegen und daher als Draht-Silhouette projiziert werden (inkl. der in + // den Tests verifizierten Eck-Ueberlappungs-Verdeckung). Zeigt also + // geschnittenes Material UND eine gemischte sichtbar/verdeckt-Ansicht in + // einem Bild. + let plane = SectionPlane::looking_plus_x(-0.3); + let out = cut_section(&plane, &walls, &slabs); + + println!( + "cut_polygons={}, visible_edges={}, hidden_edges={}", + out.cut_polygons.len(), + out.visible_edges.len(), + out.hidden_edges.len() + ); + for c in &out.cut_polygons { + println!( + " cut {:?}#{}: pts={:?}", + c.component.kind, c.component.index, c.pts + ); + } + + let svg = render_svg(&out); + + let out_path = format!( + "{}/../../docs/welle-c-hlr-spike/section-engine-proof.svg", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::write(&out_path, svg).expect("SVG schreiben fehlgeschlagen"); + println!("geschrieben: {out_path}"); +} diff --git a/src-tauri/render3d/src/lib.rs b/src-tauri/render3d/src/lib.rs index dc9e10e..9e5fadf 100644 --- a/src-tauri/render3d/src/lib.rs +++ b/src-tauri/render3d/src/lib.rs @@ -16,6 +16,7 @@ pub mod math; pub mod mesh; +pub mod section; pub mod shaders; pub mod types; @@ -33,6 +34,9 @@ pub use math::{ view_matrix, view_projection, Mat4, }; pub use mesh::{build_model_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, }; diff --git a/src-tauri/render3d/src/section.rs b/src-tauri/render3d/src/section.rs new file mode 100644 index 0000000..eba2cc8 --- /dev/null +++ b/src-tauri/render3d/src/section.rs @@ -0,0 +1,901 @@ +//! Analytischer Schnitt/Ansicht-Extraktor fuer Prismen-Modelle (Waende, Decken). +//! +//! ANSATZ: kein generisches Hidden-Line-Removal (HLR) ueber ein CAD-Kernel (OCCT), +//! sondern die Ausnutzung einer Modell-Invariante: JEDES Bauteil ist ein Prisma — +//! ein 2D-Grundriss-Polygon (`footprint`, Modell-Meter), konstant extrudiert ueber +//! ein Hoehenintervall `[z0, z1]` (siehe `WallInput`/`SlabInput`/`mesh.rs`). Bei +//! konstantem Querschnitt ueber die Hoehe ist der Schnitt einer VERTIKALEN Ebene +//! mit einem Prisma immer ein Rechteck (u-Intervall x Hoehenintervall) — nie ein +//! Trapez —, weil die Hoehe unabhaengig von der Grundriss-Position ist. Das macht +//! die Schnittgeometrie analytisch trivial im Vergleich zu generischem HLR. +//! +//! ## Schnittebene (`SectionPlane`) +//! +//! Repraesentiert generisch als Punkt + Normale (Normale == Blickrichtung, zeigt +//! vom Betrachter ins Modell). Komfort-Konstruktoren decken die vier +//! Architektur-Standardfaelle ab: vertikale Ebene senkrecht zur Grundriss-X- bzw. +//! -Y-Achse, Blick nach `+X`/`-X`/`+Y`/`-Y` (wie Schnitt A-A/B-B mit Pfeilen im +//! Grundriss). Nur VERTIKALE Ebenen (Normale ohne Hoehen-Komponente) werden +//! unterstuetzt — Horizontalschnitte (Grundriss-Ebenen) sind Sache der bestehenden +//! 2D-Plan-Pipeline, nicht dieses Moduls. +//! +//! ## Koordinatensystem der Ausgabe (u, v) +//! +//! - **Ursprung:** `SectionPlane::point`, projiziert. +//! - **u (horizontal in der Zeichnung):** Strecke entlang der Schnittebene, +//! senkrecht zur Blickrichtung, in der Horizontalen. Berechnet als +//! `u_axis = normalize(cross(normal, world_up))` — dieselbe rechtshaendige +//! Kamera-Konvention wie `math::look_at` (dort `s = cross(f, up)`), damit die +//! Vorzeichen-Konvention modulübergreifend konsistent bleibt. Bei den +//! Standard-Konstruktoren entspricht `u` direkt der jeweils ANDEREN +//! Grundriss-Achse (Schnitt entlang X -> u = Modell-Y, Schnitt entlang Y -> +//! u = -Modell-X). +//! - **v (vertikal in der Zeichnung, "Hoehe"):** `v = world.y`, ABSOLUT (nicht +//! relativ zur Schnittebene). `world.y` ist in dieser Codebasis durchgaengig +//! die Hoehen-Achse (Y-up, siehe `types.rs`/`mesh.rs`/`math.rs`: "world.y = +//! Hoehe"). Diese Doku benutzt bewusst `v = world.y` statt `world.z`, um mit +//! der etablierten Y-up-Konvention der gesamten Crate konsistent zu bleiben. +//! - **Einheiten:** Meter, wie ueberall in `render3d`. +//! +//! ## Cut-Polygone vs. Projektions-Kanten +//! +//! - `cut_polygons`: Prismen, deren Grundriss von der Schnittlinie (der +//! Grundriss-Projektion der Ebene) durchquert wird — das GESCHNITTENE +//! Material (spaeter Schnitt-Schraffur). Immer Rechtecke (siehe oben). +//! - `visible_edges`/`hidden_edges`: die Draufsicht-Silhouette (Kanten der +//! Boxen: 4 vertikale Eckkanten + Boden-/Deckel-Umrisskanten je Prisma) ALLER +//! Prismen, die (zumindest teilweise) HINTER der Ebene liegen (Blickrichtung), +//! orthogonal auf die Ebene projiziert. Sichtbarkeit wird gegen alle anderen +//! Prismen (inkl. des eigenen — Selbstverdeckung der Rueckseite durch die +//! Vorderseite EINES Bauteils ist real und gewollt) getestet: ein Prisma +//! verdeckt einen projizierten Punkt, wenn es (a) an dieser u-Position liegt, +//! (b) bei dieser Hoehe existiert UND (c) naeher an der Ebene liegt als der +//! Punkt. Kanten werden dafuer in Sichtbarkeits-Teilstuecke zerlegt (echtes +//! 1D-Intervall-Clipping in ihrem Parameterbereich, kein Nur-Mittelpunkt-Test). +//! +//! ## Bekannte Vereinfachungen (siehe auch docs/design/engine-section-pipeline.md) +//! +//! - Verdeckung nutzt die ACHSPARALLELE (u, Tiefe)-Bounding-Box jedes Prismas, +//! nicht dessen exakte (ggf. konkave/diagonale) Grundrissform. Fuer Waende +//! (Rechteck-Baender) und Decken (i.d.R. konvexe Rechtecke) ist das exakt; +//! fuer stark diagonale oder konkave Grundrisse kann es zu Ueberverdeckung +//! fuehren. +//! - Keine Wandknoten-Verschneidung (T-/X-Stoesse): Waende werden wie in +//! `mesh.rs` als eigenstaendige, stumpf abgeschlossene Quader behandelt, die +//! sich an Ecken UEBERLAPPEN (kein Miter-Join). Das kann in Ecknaehe dazu +//! fuehren, dass eine Wand als "in eine andere eingebettet" verdeckt erscheint +//! (siehe Test `eck_ueberlappung_verdeckt_kante_am_wandknoten` unten) — real, +//! aber ein Artefakt der fehlenden Verschneidungslogik, kein Bug dieses Moduls. +//! - Oeffnungen (Tueren/Fenster) sind in `WallInput` noch nicht modelliert und +//! werden daher hier auch nicht ausgespart. +//! - Vertices exakt AUF der Schnittlinie werden nicht robust (kein Tie-Breaking/ +//! Pertubation); die Testszenarien vermeiden diesen Sonderfall bewusst. + +use serde::{Deserialize, Serialize}; + +use crate::math; +use crate::types::{Point2, Rgb, SlabInput, WallInput}; + +/// Toleranz fuer "naeher an der Ebene" / Grenzwert-Vergleiche (Meter, Tiefe/Hoehe). +const EPS: f32 = 1e-4; +/// Mindestlaenge eines Ueberdeckungs-Teilstuecks, damit es als echte Verdeckung +/// zaehlt (verhindert hauchduenne Treffer durch reine Kanten-/Eckenberuehrung). +const MIN_SPAN: f32 = 1e-4; + +// ───────────────────────────────────────────────────────────────────────────── +// Schnittebene +// ───────────────────────────────────────────────────────────────────────────── + +/// Eine Schnitt-/Ansichtsebene: Punkt auf der Ebene + Normale. Die Normale ist +/// GLEICHZEITIG die Blickrichtung (zeigt vom Betrachter weg, ins Modell hinein). +/// Nur vertikale Ebenen (Normale ohne Y-Komponente) sind fuer dieses Modul +/// sinnvoll — siehe Moduldoc. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct SectionPlane { + pub point: [f32; 3], + pub normal: [f32; 3], +} + +impl SectionPlane { + /// Generischer Konstruktor; die Normale wird normalisiert. + pub fn new(point: [f32; 3], normal: [f32; 3]) -> Self { + Self { + point, + normal: math::normalize(normal), + } + } + + /// Vertikaler Schnitt/Ansicht bei Grundriss-X = `x`, Blick nach +X. + pub fn looking_plus_x(x: f32) -> Self { + Self::new([x, 0.0, 0.0], [1.0, 0.0, 0.0]) + } + + /// Vertikaler Schnitt/Ansicht bei Grundriss-X = `x`, Blick nach -X. + pub fn looking_minus_x(x: f32) -> Self { + Self::new([x, 0.0, 0.0], [-1.0, 0.0, 0.0]) + } + + /// Vertikaler Schnitt/Ansicht bei Grundriss-Y = `y`, Blick nach +Y. + pub fn looking_plus_y(y: f32) -> Self { + Self::new([0.0, 0.0, y], [0.0, 0.0, 1.0]) + } + + /// Vertikaler Schnitt/Ansicht bei Grundriss-Y = `y`, Blick nach -Y. + pub fn looking_minus_y(y: f32) -> Self { + Self::new([0.0, 0.0, y], [0.0, 0.0, -1.0]) + } + + /// u-Achse der Projektion: horizontal, senkrecht zur Blickrichtung (siehe + /// Moduldoc — dieselbe Konvention wie `math::look_at`s `right`-Vektor). + fn u_axis(&self) -> [f32; 3] { + let up = [0.0, 1.0, 0.0]; + let u = math::cross(self.normal, up); + if math::length(u) > 1e-6 { + math::normalize(u) + } else { + // Entartet nur, wenn die Normale (fast) vertikal ist — fuer dieses + // Modul kein sinnvoller Eingabefall (siehe Moduldoc), Fallback + // verhindert NaN. + [1.0, 0.0, 0.0] + } + } + + /// Projiziert einen Weltpunkt nach (u, v). `v = world.y` (absolute Hoehe). + pub fn project(&self, p: [f32; 3]) -> [f32; 2] { + let rel = math::sub(p, self.point); + [math::dot(rel, self.u_axis()), p[1]] + } + + /// Vorzeichenbehaftete Tiefe entlang der Blickrichtung (`normal`). `> 0` + /// heisst "hinter der Ebene" (im Blickfeld, wie es die Moduldoc nutzt). + pub fn depth(&self, p: [f32; 3]) -> f32 { + math::dot(math::sub(p, self.point), self.normal) + } + + /// Grundriss-Anteil der Normale (X,Z-Komponenten der Weltnormale). + fn plan_normal(&self) -> [f32; 2] { + [self.normal[0], self.normal[2]] + } + + /// Grundriss-Anteil des Ebenenpunkts. + fn plan_point(&self) -> Point2 { + [self.point[0], self.point[2]] + } + + /// Grundriss-Anteil der u-Achse. + fn plan_u_axis(&self) -> Point2 { + let u = self.u_axis(); + [u[0], u[2]] + } +} + +/// Modell-Punkt `[x, y]` (Grundriss) + Hoehe -> Weltpunkt (siehe `types.rs`: +/// `world.x = model.x, world.z = model.y, world.y = Hoehe`). +fn to_world(p: Point2, height: f32) -> [f32; 3] { + [p[0], height, p[1]] +} + +// ───────────────────────────────────────────────────────────────────────────── +// Bauteil-Referenz + Ausgabetypen +// ───────────────────────────────────────────────────────────────────────────── + +/// Herkunfts-Art eines Prismas (fuer `ComponentRef`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ComponentKind { + Wall, + Slab, +} + +/// Referenz auf das Ursprungs-Bauteil eines Cut-Polygons/einer Kante: Art + +/// Index in das jeweilige Eingabe-Array (`walls`/`slabs`, wie hereingereicht). +/// `WallInput`/`SlabInput` haben (noch) keine eigene Id — der Index ist die +/// einzige stabile Referenz, die die aktuellen Typen hergeben; eine echte +/// Component-Id/Material-Referenz ist eine bekannte Luecke (siehe Moduldoc / +/// `docs/design/engine-section-pipeline.md`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ComponentRef { + pub kind: ComponentKind, + pub index: usize, +} + +/// Ein geschnittenes Bauteil-Rechteck in Schnittkoordinaten (u, v), Meter. +/// Bei diesem Modell (konstanter Querschnitt ueber die Hoehe) immer ein +/// Rechteck, aber generisch als Ring abgelegt (Kompatibilitaet zu +/// `render2d::types::FillPolygon`, das spaetere Ziel-Format). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CutPolygon { + pub component: ComponentRef, + pub color: Rgb, + /// Ring-Ecken (u, v); erste != letzte (Ring implizit geschlossen). + pub pts: Vec<[f32; 2]>, +} + +/// Ein Liniensegment in Schnittkoordinaten (u, v), Meter. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct SectionEdge { + pub component: ComponentRef, + pub a: [f32; 2], + pub b: [f32; 2], +} + +/// Vollstaendige Ausgabe eines Schnitt-/Ansichts-Laufs. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SectionOutput { + #[serde(rename = "cutPolygons")] + pub cut_polygons: Vec, + #[serde(rename = "visibleEdges")] + pub visible_edges: Vec, + #[serde(rename = "hiddenEdges")] + pub hidden_edges: Vec, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Interne Prisma-Repraesentation (Wand ODER Decke, vereinheitlicht) +// ───────────────────────────────────────────────────────────────────────────── + +/// Ein Prisma: Grundriss-Polygon (Modell-Meter, `[x, y]`) extrudiert ueber +/// `[z0, z1]` (absolute Hoehe, Meter). Vereinheitlicht Wand-Baender und +/// Decken-Umrisse fuer die Schnittlogik. +struct Prism { + kind: ComponentKind, + index: usize, + footprint: Vec, + z0: f32, + z1: f32, + color: Rgb, +} + +/// 2D-Links-Normale (90 Grad CCW) — 1:1 gespiegelt aus `mesh::left_normal` +/// (dort privat), weil hier nur die Grundriss-Bandecken gebraucht werden, nicht +/// die volle 3D-Extrusion. +fn left_normal2(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] +} + +/// Baut das Band-Grundriss-Polygon einer Wand (identische Ecken-Konstruktion +/// wie `mesh::extrude_wall`: g0..g3 CCW um die Achse). +fn wall_prism(index: usize, wall: &WallInput) -> Option { + let n = left_normal2(wall.start, wall.end); + if n == [0.0, 0.0] { + return None; // entartete Wand (Start == Ende), wie mesh::extrude_wall + } + let h = wall.thickness * 0.5; + let off = |p: Point2, s: f32| -> Point2 { [p[0] + n[0] * s * h, p[1] + n[1] * s * h] }; + let footprint = vec![ + off(wall.start, 1.0), + off(wall.end, 1.0), + off(wall.end, -1.0), + off(wall.start, -1.0), + ]; + Some(Prism { + kind: ComponentKind::Wall, + index, + footprint, + z0: wall.base_elevation, + z1: wall.base_elevation + wall.height, + color: wall.color, + }) +} + +fn slab_prism(index: usize, slab: &SlabInput) -> Option { + if slab.outline.len() < 3 { + return None; + } + let z0 = slab.z_bottom.min(slab.z_top); + let z1 = slab.z_bottom.max(slab.z_top); + if (z1 - z0) < 1e-6 { + return None; + } + Some(Prism { + kind: ComponentKind::Slab, + index, + footprint: slab.outline.clone(), + z0, + z1, + color: slab.color, + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Cut-Polygone: Grundriss-Linie x Fussabdruck-Polygon -> u-Intervalle +// ───────────────────────────────────────────────────────────────────────────── + +/// Findet die u-Intervalle, in denen die Schnittlinie (Grundriss-Projektion der +/// Ebene) INNERHALB von `footprint` verlaeuft. Allgemeines Polygon-Linie- +/// Clipping: Vorzeichen-Abstand jeder Kante zur Linie, Kreuzungspunkte +/// sammeln, sortieren, paarweise als Innen-Intervalle interpretieren (gerade +/// Anzahl Kreuzungen fuer ein einfaches Polygon). +fn footprint_u_intervals(plane: &SectionPlane, footprint: &[Point2]) -> Vec<(f32, f32)> { + let n = footprint.len(); + if n < 3 { + return Vec::new(); + } + let pn = plane.plan_normal(); + let p0 = plane.plan_point(); + let ua = plane.plan_u_axis(); + let dist = |p: Point2| (p[0] - p0[0]) * pn[0] + (p[1] - p0[1]) * pn[1]; + let u_of = |p: Point2| (p[0] - p0[0]) * ua[0] + (p[1] - p0[1]) * ua[1]; + + let eps = 1e-6; + let mut crossings: Vec = Vec::new(); + for i in 0..n { + let a = footprint[i]; + let b = footprint[(i + 1) % n]; + let da = dist(a); + let db = dist(b); + if da.abs() < eps && db.abs() < eps { + continue; // Kante liegt auf der Linie -> bekannter Sonderfall, ignoriert + } + if da.abs() < eps { + crossings.push(u_of(a)); + continue; + } + if (da < 0.0) != (db < 0.0) { + let t = da / (da - db); + let px = a[0] + t * (b[0] - a[0]); + let py = a[1] + t * (b[1] - a[1]); + crossings.push(u_of([px, py])); + } + } + crossings.sort_by(|x, y| x.partial_cmp(y).unwrap()); + crossings.dedup_by(|x, y| (*x - *y).abs() < eps); + + let mut out = Vec::new(); + let mut i = 0; + while i + 1 < crossings.len() { + out.push((crossings[i], crossings[i + 1])); + i += 2; + } + out +} + +// ───────────────────────────────────────────────────────────────────────────── +// Projektions-Kanten + Verdeckung +// ───────────────────────────────────────────────────────────────────────────── + +/// Achsparallele Bounding-Box eines Prismas in (u, Tiefe, Hoehe) — genutzt als +/// Verdeckungs-Naeherung (siehe Moduldoc "Bekannte Vereinfachungen"). +struct PrismBounds { + u_min: f32, + u_max: f32, + depth_min: f32, + z0: f32, + z1: f32, +} + +fn prism_bounds(plane: &SectionPlane, prism: &Prism) -> PrismBounds { + let mut u_min = f32::INFINITY; + let mut u_max = f32::NEG_INFINITY; + let mut depth_min = f32::INFINITY; + for &p in &prism.footprint { + let w = to_world(p, 0.0); // u/Tiefe sind hoehenunabhaengig (vertikale Ebene) + let u = plane.project(w)[0]; + let d = plane.depth(w); + u_min = u_min.min(u); + u_max = u_max.max(u); + depth_min = depth_min.min(d); + } + PrismBounds { + u_min, + u_max, + depth_min, + z0: prism.z0, + z1: prism.z1, + } +} + +/// Rolle einer Kante bzgl. der Hoehen-Randbehandlung beim Verdeckungstest. +/// Boden-/Deckelkanten liegen KONSTANT auf der eigenen Unter-/Oberkante ihres +/// Prismas (`z0`/`z1`) — bei einer rein geschlossenen (beidseitig inklusiven) +/// Bereichspruefung wuerde ein bloss VERTIKAL angrenzendes (aber nicht wirklich +/// ueberlappendes) Fremd-Prisma faelschlich als Verdecker zaehlen, sobald sich +/// zwei Bauteile exakt an einer Flaeche beruehren (z. B. eine Wand, die exakt +/// auf einer Bodenplatte steht: Wand-`z0` == Platte-`z1`). Boden-/Deckelkanten +/// bekommen daher eine ASYMMETRISCHE (halboffene) Grenzbehandlung, passend zur +/// Richtung, in die das EIGENE Prisma von dieser Kante aus "waechst": eine +/// Bodenkante (Prisma waechst nach oben) zaehlt ein Fremd-Prisma nur, wenn +/// dessen Material an/oberhalb seiner eigenen Unterkante beginnt UND diese +/// Hoehe noch UNTERHALB seiner Oberkante liegt (`[lo, hi)`); eine Deckelkante +/// (Prisma waechst nach unten) symmetrisch mit `(lo, hi]`. Vertikale Eckkanten +/// (Hoehe variiert ueber die volle Prisma-Hoehe) brauchen das nicht: dort +/// ergibt eine reine Beruehrung ohnehin ein Intervall der Laenge 0, das ueber +/// `MIN_SPAN` herausfaellt (siehe `merge_intervals`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HeightBias { + /// Vertikale Eckkante: Hoehe variiert, geschlossenes Clipping. + Vertical, + /// Bodenkante (konstant auf `z0` des eigenen Prismas). + BottomCap, + /// Deckelkante (konstant auf `z1` des eigenen Prismas). + TopCap, +} + +/// t-Bereich (Teilmenge von [0,1]), in dem `v0 + t*(v1-v0)` in `[lo, hi]` +/// liegt. `bias` steuert die Randbehandlung fuer den KONSTANTEN Fall (siehe +/// `HeightBias`); der variierende Fall (vertikale Kante) ist davon unberuehrt. +fn height_in_range(v0: f32, v1: f32, lo: f32, hi: f32, bias: HeightBias) -> Option<(f32, f32)> { + const TOUCH: f32 = 1e-5; + if (v1 - v0).abs() < 1e-9 { + let inside = match bias { + HeightBias::Vertical => v0 >= lo - EPS && v0 <= hi + EPS, + HeightBias::BottomCap => v0 >= lo - TOUCH && v0 <= hi - TOUCH, + HeightBias::TopCap => v0 >= lo + TOUCH && v0 <= hi + TOUCH, + }; + return if inside { Some((0.0, 1.0)) } else { None }; + } + let ta = (lo - v0) / (v1 - v0); + let tb = (hi - v0) / (v1 - v0); + let t0 = ta.min(tb).max(0.0); + let t1 = ta.max(tb).min(1.0); + if t0 <= t1 + EPS { + Some((t0.max(0.0), t1.min(1.0))) + } else { + None + } +} + +/// t-Bereich, in dem `v0 + t*(v1-v0)` STRENG groesser als `min_val` ist (fuer +/// den Tiefenvergleich "naeher als der Blocker"). +fn linear_greater_than(v0: f32, v1: f32, min_val: f32) -> Option<(f32, f32)> { + if (v1 - v0).abs() < 1e-9 { + return if v0 > min_val { Some((0.0, 1.0)) } else { None }; + } + let slope = v1 - v0; + let t_cross = (min_val - v0) / slope; + let (t0, t1) = if slope > 0.0 { + (t_cross, f32::INFINITY) + } else { + (f32::NEG_INFINITY, t_cross) + }; + let t0c = t0.max(0.0); + let t1c = t1.min(1.0); + if t0c <= t1c + EPS { + Some((t0c.max(0.0), t1c.min(1.0))) + } else { + None + } +} + +fn intersect_ranges(a: Option<(f32, f32)>, b: Option<(f32, f32)>) -> Option<(f32, f32)> { + let (a0, a1) = a?; + let (b0, b1) = b?; + let lo = a0.max(b0); + let hi = a1.min(b1); + if lo <= hi + EPS { + Some((lo.max(0.0), hi.min(1.0))) + } else { + None + } +} + +/// Vereinigt (moeglicherweise ueberlappende) t-Intervalle zu einer sortierten, +/// disjunkten Liste. +fn merge_intervals(mut ivs: Vec<(f32, f32)>) -> Vec<(f32, f32)> { + ivs.retain(|(s, e)| *e - *s > MIN_SPAN); + ivs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + let mut out: Vec<(f32, f32)> = Vec::new(); + for (s, e) in ivs { + if let Some(last) = out.last_mut() { + if s <= last.1 + MIN_SPAN { + last.1 = last.1.max(e); + continue; + } + } + out.push((s, e)); + } + out +} + +/// Komplement der (sortierten, disjunkten) Intervalle `blocked` in [0, 1]. +fn subtract_from_unit(blocked: &[(f32, f32)]) -> Vec<(f32, f32)> { + let mut visible = Vec::new(); + let mut cursor = 0.0f32; + for &(s, e) in blocked { + if s > cursor + MIN_SPAN { + visible.push((cursor, s)); + } + cursor = cursor.max(e); + } + if cursor < 1.0 - MIN_SPAN { + visible.push((cursor, 1.0)); + } + visible +} + +/// Fuer eine projizierte Kante (u0,v0)->(u1,v1) bei Tiefen (d0,d1): die +/// Teilbereiche (t in [0,1]), die durch `bounds` verdeckt werden (Vereinigung +/// ueber alle Blocker). +#[allow(clippy::too_many_arguments)] +fn blocked_intervals( + u0: f32, + u1: f32, + v0: f32, + v1: f32, + d0: f32, + d1: f32, + bias: HeightBias, + bounds: &[PrismBounds], +) -> Vec<(f32, f32)> { + let mut ivs = Vec::new(); + for b in bounds { + let ru = height_in_range(u0, u1, b.u_min, b.u_max, HeightBias::Vertical); + let rv = height_in_range(v0, v1, b.z0, b.z1, bias); + let rd = linear_greater_than(d0, d1, b.depth_min + EPS); + if let Some(r) = intersect_ranges(intersect_ranges(ru, rv), rd) { + ivs.push(r); + } + } + merge_intervals(ivs) +} + +fn lerp_uv(u0: f32, v0: f32, u1: f32, v1: f32, t: f32) -> [f32; 2] { + [u0 + t * (u1 - u0), v0 + t * (v1 - v0)] +} + +/// Baut die Draht-Kanten EINES Prismas: n vertikale Eckkanten + n Boden- + n +/// Deckelkanten (n = Anzahl Fussabdruck-Ecken), je als Weltpunkt-Paar + ihre +/// `HeightBias` (siehe dort) fuer den Verdeckungstest. +fn prism_wireframe_edges(prism: &Prism) -> Vec<([f32; 3], [f32; 3], HeightBias)> { + let n = prism.footprint.len(); + let mut edges = Vec::with_capacity(3 * n); + for i in 0..n { + edges.push(( + to_world(prism.footprint[i], prism.z0), + to_world(prism.footprint[i], prism.z1), + HeightBias::Vertical, + )); + } + for i in 0..n { + let a = prism.footprint[i]; + let b = prism.footprint[(i + 1) % n]; + edges.push((to_world(a, prism.z0), to_world(b, prism.z0), HeightBias::BottomCap)); + edges.push((to_world(a, prism.z1), to_world(b, prism.z1), HeightBias::TopCap)); + } + edges +} + +fn compute_view_edges( + plane: &SectionPlane, + prisms: &[Prism], +) -> (Vec, Vec) { + let bounds: Vec = prisms.iter().map(|p| prism_bounds(plane, p)).collect(); + let mut visible = Vec::new(); + let mut hidden = Vec::new(); + + for prism in prisms { + let cref = ComponentRef { + kind: prism.kind, + index: prism.index, + }; + for (pa, pb, bias) in prism_wireframe_edges(prism) { + let da = plane.depth(pa); + let db = plane.depth(pb); + if da <= EPS && db <= EPS { + continue; // komplett vor/auf der Ebene -> nicht im Blickfeld dahinter + } + // Auf den Anteil HINTER der Ebene clippen, falls eine Seite davor liegt. + let (ca, cb) = if da > EPS && db > EPS { + (pa, pb) + } else { + let t = (EPS - da) / (db - da); + let clip = |p0: [f32; 3], p1: [f32; 3]| -> [f32; 3] { + [ + p0[0] + t * (p1[0] - p0[0]), + p0[1] + t * (p1[1] - p0[1]), + p0[2] + t * (p1[2] - p0[2]), + ] + }; + if da <= EPS { + (clip(pa, pb), pb) + } else { + (pa, clip(pa, pb)) + } + }; + + let [u0, v0] = plane.project(ca); + let [u1, v1] = plane.project(cb); + if (u0 - u1).abs() < 1e-5 && (v0 - v1).abs() < 1e-5 { + continue; // kantenparallel zur Blickrichtung -> entartet (Punkt) + } + let d0 = plane.depth(ca); + let d1 = plane.depth(cb); + + let blocked = blocked_intervals(u0, u1, v0, v1, d0, d1, bias, &bounds); + for &(s, e) in &blocked { + hidden.push(SectionEdge { + component: cref, + a: lerp_uv(u0, v0, u1, v1, s), + b: lerp_uv(u0, v0, u1, v1, e), + }); + } + for (s, e) in subtract_from_unit(&blocked) { + visible.push(SectionEdge { + component: cref, + a: lerp_uv(u0, v0, u1, v1, s), + b: lerp_uv(u0, v0, u1, v1, e), + }); + } + } + } + (visible, hidden) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Oeffentliche Einstiegsfunktion +// ───────────────────────────────────────────────────────────────────────────── + +/// Fuehrt einen Schnitt/eine Ansicht entlang `plane` gegen das Modell (Waende + +/// Decken) aus. Siehe Moduldoc fuer Format/Konventionen. +pub fn cut_section(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput]) -> SectionOutput { + let mut prisms: Vec = Vec::new(); + for (i, w) in walls.iter().enumerate() { + if let Some(p) = wall_prism(i, w) { + prisms.push(p); + } + } + for (i, s) in slabs.iter().enumerate() { + if let Some(p) = slab_prism(i, s) { + prisms.push(p); + } + } + + let mut cut_polygons = Vec::new(); + for prism in &prisms { + for (u0, u1) in footprint_u_intervals(plane, &prism.footprint) { + let (lo, hi) = (u0.min(u1), u0.max(u1)); + cut_polygons.push(CutPolygon { + component: ComponentRef { + kind: prism.kind, + index: prism.index, + }, + color: prism.color, + pts: vec![[lo, prism.z0], [hi, prism.z0], [hi, prism.z1], [lo, prism.z1]], + }); + } + } + + let (visible_edges, hidden_edges) = compute_view_edges(plane, &prisms); + + SectionOutput { + cut_polygons, + visible_edges, + hidden_edges, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// L-foermige Wand (zwei Schenkel im rechten Winkel) + Bodenplatte, wie im + /// OCCT-HLR-Spike (`docs/welle-c-hlr-spike/FEASIBILITY.md`) beschrieben. + /// + /// Schenkel A: Achse (0,0)->(0,3) (Grundriss-Y-Richtung), Dicke 0.2, Hoehe + /// 2.5. Schenkel B: Achse (0,3)->(3,3) (Grundriss-X-Richtung), rechter + /// Winkel bei (0,3), gleiche Dicke/Hoehe. Bodenplatte: Rechteck, die den + /// gesamten L-Grundriss ueberdeckt, z in [-0.2, 0] (unterhalb der Waende). + fn l_wall_scene() -> (Vec, Vec) { + let color = [0.8, 0.8, 0.8]; + let walls = vec![ + WallInput { + start: [0.0, 0.0], + end: [0.0, 3.0], + thickness: 0.2, + height: 2.5, + base_elevation: 0.0, + color, + }, + WallInput { + start: [0.0, 3.0], + end: [3.0, 3.0], + thickness: 0.2, + height: 2.5, + base_elevation: 0.0, + color, + }, + ]; + let slabs = vec![SlabInput { + outline: vec![[-0.5, -0.5], [3.5, -0.5], [3.5, 3.5], [-0.5, 3.5]], + z_bottom: -0.2, + z_top: 0.0, + color: [0.86, 0.86, 0.88], + }]; + (walls, slabs) + } + + // --- Konstruktoren ------------------------------------------------------- + + #[test] + fn standardkonstruktoren_setzen_blickrichtung_korrekt() { + let px = SectionPlane::looking_plus_x(2.0); + assert_eq!(px.point, [2.0, 0.0, 0.0]); + assert_eq!(px.normal, [1.0, 0.0, 0.0]); + + let mx = SectionPlane::looking_minus_x(2.0); + assert_eq!(mx.normal, [-1.0, 0.0, 0.0]); + + let py = SectionPlane::looking_plus_y(4.0); + assert_eq!(py.point, [0.0, 0.0, 4.0]); + assert_eq!(py.normal, [0.0, 0.0, 1.0]); + + let my = SectionPlane::looking_minus_y(4.0); + assert_eq!(my.normal, [0.0, 0.0, -1.0]); + } + + #[test] + fn u_achse_steht_senkrecht_auf_blickrichtung_und_ist_horizontal() { + let plane = SectionPlane::looking_plus_x(0.0); + let u = plane.u_axis(); + assert!((u[1]).abs() < 1e-6, "u-Achse ist horizontal (keine Y-Komponente)"); + assert!( + math::dot(u, plane.normal).abs() < 1e-6, + "u-Achse orthogonal zur Blickrichtung" + ); + // Fuer looking_plus_x ist u = Modell-Y (world +Z). + assert!((u[2] - 1.0).abs() < 1e-6); + } + + #[test] + fn project_und_depth_generischer_konstruktor() { + let plane = SectionPlane::new([1.0, 0.0, 0.0], [2.0, 0.0, 0.0]); // unnormalisiert + assert!((math::length(plane.normal) - 1.0).abs() < 1e-6, "Normale wird normalisiert"); + let d = plane.depth([3.0, 1.0, 5.0]); + assert!((d - 2.0).abs() < 1e-5, "Tiefe entlang normalisierter Normale"); + let uv = plane.project([1.0, 1.5, 7.0]); + assert!((uv[1] - 1.5).abs() < 1e-6, "v = world.y"); + } + + // --- Fall (a): Schnitt mittig durch einen Wandschenkel -------------------- + + #[test] + fn schnitt_durch_wandschenkel_b_liefert_zwei_rechtecke() { + let (walls, slabs) = l_wall_scene(); + // Schnittebene bei Grundriss-X = 1.5: liegt in der Mitte von Schenkel B + // (Achse x in [0,3]), aber ausserhalb von Schenkel A (Band x in + // [-0.1,0.1]) und schneidet die volle Bodenplatte (x in [-0.5,3.5]). + let plane = SectionPlane::looking_plus_x(1.5); + let out = cut_section(&plane, &walls, &slabs); + + assert_eq!( + out.cut_polygons.len(), + 2, + "Schenkel A wird NICHT getroffen, nur Schenkel B + Bodenplatte" + ); + + let wall_cut = out + .cut_polygons + .iter() + .find(|c| c.component.kind == ComponentKind::Wall) + .expect("ein Wand-Cut-Polygon"); + assert_eq!(wall_cut.component.index, 1, "Schenkel B ist Index 1"); + let us: Vec = wall_cut.pts.iter().map(|p| p[0]).collect(); + let vs: Vec = wall_cut.pts.iter().map(|p| p[1]).collect(); + let (u_lo, u_hi) = (us.iter().cloned().fold(f32::INFINITY, f32::min), us.iter().cloned().fold(f32::NEG_INFINITY, f32::max)); + let (v_lo, v_hi) = (vs.iter().cloned().fold(f32::INFINITY, f32::min), vs.iter().cloned().fold(f32::NEG_INFINITY, f32::max)); + assert!((u_lo - 2.9).abs() < 1e-4, "u_lo={u_lo}"); + assert!((u_hi - 3.1).abs() < 1e-4, "u_hi={u_hi}"); + assert!((v_lo - 0.0).abs() < 1e-4, "v_lo={v_lo}"); + assert!((v_hi - 2.5).abs() < 1e-4, "v_hi={v_hi}"); + + let slab_cut = out + .cut_polygons + .iter() + .find(|c| c.component.kind == ComponentKind::Slab) + .expect("ein Slab-Cut-Polygon"); + let us: Vec = slab_cut.pts.iter().map(|p| p[0]).collect(); + let vs: Vec = slab_cut.pts.iter().map(|p| p[1]).collect(); + let (u_lo, u_hi) = (us.iter().cloned().fold(f32::INFINITY, f32::min), us.iter().cloned().fold(f32::NEG_INFINITY, f32::max)); + let (v_lo, v_hi) = (vs.iter().cloned().fold(f32::INFINITY, f32::min), vs.iter().cloned().fold(f32::NEG_INFINITY, f32::max)); + assert!((u_lo + 0.5).abs() < 1e-4, "u_lo={u_lo}"); + assert!((u_hi - 3.5).abs() < 1e-4, "u_hi={u_hi}"); + assert!((v_lo + 0.2).abs() < 1e-4, "v_lo={v_lo}"); + assert!((v_hi - 0.0).abs() < 1e-4, "v_hi={v_hi}"); + } + + #[test] + fn schnitt_ausserhalb_des_modells_liefert_keine_cut_polygone() { + let (walls, slabs) = l_wall_scene(); + // x = 10 liegt weit ausserhalb aller Fussabdruecke. + let plane = SectionPlane::looking_plus_x(10.0); + let out = cut_section(&plane, &walls, &slabs); + assert!(out.cut_polygons.is_empty()); + } + + // --- Fall (b): Ansicht/Projektion, Ebene VOR dem Modell ------------------- + + #[test] + fn ansicht_vor_dem_modell_hat_keine_cut_polygone_aber_verdeckung() { + let (walls, slabs) = l_wall_scene(); + // Ebene bei x = -2, Blick nach +X: das gesamte Modell liegt dahinter + // (kleinste Fussabdruck-X ist -0.5 bei der Bodenplatte). + let plane = SectionPlane::looking_plus_x(-2.0); + let out = cut_section(&plane, &walls, &slabs); + + assert!( + out.cut_polygons.is_empty(), + "Ebene liegt ausserhalb aller Fussabdruecke -> keine Cut-Polygone" + ); + + // Erwartete Gesamtzahlen (siehe Kommentar/Herleitung im Modul-Review): + // Schenkel A (senkrecht zur Blickrichtung, wie eine Wand-Elevation): + // 4 sichtbare (Vorderseite) + 4 verdeckte (Rueckseite, Selbstverdeckung). + // Schenkel B (parallel zur Blickrichtung, inkl. Eck-Ueberlappung mit A): + // 3 sichtbare + 7 verdeckte (12 Kanten - 4 entartete Laengskanten = 8 + // Basiskanten, davon 2 an der Eckueberlappung in je 2 Teilstuecke + // gesplittet -> 10 Segmente gesamt). + // Bodenplatte (keine Hoehen-Ueberlappung mit den Waenden -> nur + // Selbstverdeckung wie Schenkel A): 4 sichtbar + 4 verdeckt. + assert_eq!( + out.visible_edges.len(), + 11, + "4 (Schenkel A) + 3 (Schenkel B) + 4 (Bodenplatte)" + ); + assert_eq!( + out.hidden_edges.len(), + 15, + "4 (Schenkel A) + 7 (Schenkel B) + 4 (Bodenplatte)" + ); + + // Konkrete Kante: die "eingebettete" Eckkante von Schenkel B (Index 1) + // bei u=2.9 (naeher an Schenkel A, siehe Moduldoc "Bekannte + // Vereinfachungen" -> Eckueberlappung ohne Wandknoten-Verschneidung) + // muss VOLLSTAENDIG verdeckt sein. + let embedded_corner_hidden = out.hidden_edges.iter().any(|e| { + e.component.kind == ComponentKind::Wall + && e.component.index == 1 + && (e.a[0] - 2.9).abs() < 1e-3 + && (e.b[0] - 2.9).abs() < 1e-3 + && (e.a[1].min(e.b[1]) - 0.0).abs() < 1e-3 + && (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3 + }); + assert!(embedded_corner_hidden, "Eckkante von Schenkel B bei u=2.9 muss hidden sein"); + + // Die AEUSSERE Eckkante von Schenkel B (u=3.1, ragt ueber Schenkel A + // hinaus) bleibt dagegen sichtbar. + let outer_corner_visible = out.visible_edges.iter().any(|e| { + e.component.kind == ComponentKind::Wall + && e.component.index == 1 + && (e.a[0] - 3.1).abs() < 1e-3 + && (e.b[0] - 3.1).abs() < 1e-3 + }); + assert!(outer_corner_visible, "Aeussere Eckkante von Schenkel B (u=3.1) bleibt sichtbar"); + + // Die partiell gesplittete Bodenkante (u: 2.9 -> 3.1 bei v=0) zerfaellt + // in einen sichtbaren Anteil ab u=3.0. + let split_visible_present = out.visible_edges.iter().any(|e| { + e.component.kind == ComponentKind::Wall + && e.component.index == 1 + && (e.a[1].abs() < 1e-3 && e.b[1].abs() < 1e-3) + && ((e.a[0] - 3.0).abs() < 1e-3 || (e.b[0] - 3.0).abs() < 1e-3) + }); + assert!(split_visible_present, "Sichtbarer Teil der gesplitteten Bodenkante ab u=3.0 fehlt"); + } + + #[test] + fn schenkel_a_hat_vier_sichtbare_und_vier_verdeckte_kanten_isoliert() { + // Nur Schenkel A + Bodenplatte (ohne Schenkel B), um die reine + // Box-Selbstverdeckung ohne Eck-Ueberlappung zu pruefen. + let (walls, _) = l_wall_scene(); + let wall_a = vec![walls[0].clone()]; + let plane = SectionPlane::looking_plus_x(-2.0); + let out = cut_section(&plane, &wall_a, &[]); + assert_eq!(out.visible_edges.len(), 4, "Vorderseite: 2 vertikale + Boden + Deckel"); + assert_eq!(out.hidden_edges.len(), 4, "Rueckseite: 2 vertikale + Boden + Deckel"); + // Alle sichtbaren Kanten liegen bei der naeheren Tiefe (kleinerer + // world-x als die verdeckten). + for e in &out.visible_edges { + assert!(e.a[1] >= -1e-4 && e.a[1] <= 2.5 + 1e-4); + } + } +}