From f95df75ea17982416226c6b5ff3bfdb09beddac0 Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 3 Jul 2026 08:36:35 +0200 Subject: [PATCH] =?UTF-8?q?render3d-Schnitt:=20=C3=96ffnungen=20=E2=80=94?= =?UTF-8?q?=20Br=C3=BCstung/Sturz-Teilpolygone=20und=20Durchblick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WallInput bekommt openings (from/to/sill/height, serde-default). Der Cut durch eine Öffnung liefert mehrere einfache Rechtecke (Brüstung, Sturz, Leibungen) statt Lochpolygon — kompatibel zu render2d::FillPolygon. Der Verdeckungstest behandelt Öffnungen als Durchblick (u-Zuordnung exakt im Elevationsfall, konservativ undurchsichtig bei kantennaher Ausrichtung); Fensterrahmen-Kanten werden als projizierte Kanten ausgegeben. Tests 26 auf 29, Beweis-SVG mit Fenster + dahinterliegender Wand neu generiert (sichtbares Band exakt im Fensterausschnitt). mesh.rs (3D-Vollkoerper) beruecksichtigt Öffnungen weiterhin nicht — dokumentierte Luecke. --- docs/design/engine-section-pipeline.md | 77 ++- .../section-engine-proof.svg | 16 + src-tauri/render3d/examples/section_svg.rs | 47 +- src-tauri/render3d/src/bin/spike3d.rs | 1 + src-tauri/render3d/src/lib.rs | 4 + src-tauri/render3d/src/section.rs | 542 +++++++++++++++++- src-tauri/render3d/src/types.rs | 34 +- 7 files changed, 687 insertions(+), 34 deletions(-) diff --git a/docs/design/engine-section-pipeline.md b/docs/design/engine-section-pipeline.md index 1bf707d..3801d4a 100644 --- a/docs/design/engine-section-pipeline.md +++ b/docs/design/engine-section-pipeline.md @@ -67,6 +67,65 @@ generischer Punktering abgelegt — kompatibel zu `render2d::types::FillPolygon` (`pts: Vec`), dem vorgesehenen Zielformat fuer die spaetere 1:1- Uebersetzung in die Plan-/Schnitt-Ansicht. +## Oeffnungen (Tueren/Fenster) + +`WallInput` hat seit diesem Nachtrag ein Feld `openings: Vec` +(`types.rs`), unabhaengig von der reinen Achse/Dicke/Hoehe: je Oeffnung ein +Intervall entlang der Wandachse (`from`/`to`, Meter ab `start`) plus Bruestungs- +und Kopfhoehe (`sill`/`height`, relativ zu `base_elevation`). Eine Tuer ist der +Sonderfall `sill == 0.0`. + +**Cut-Polygone.** Trifft die Schnittlinie eine Wand exakt im Bereich einer +Oeffnung, splittet `wall_cut_rectangles` das sonst einzelne Vollrechteck +`[z0,z1]` in bis zu ZWEI Teil-Rechtecke: Bruestung `[z0, z0+sill]` und Sturz +`[z0+sill+height, z1]`. Gewaehlt wurde diese Mehrfach-Rechteck-Darstellung +BEWUSST gegenueber einem Loch-Polygon (Ring mit Aussparung): mehrere einfache, +konvexe Ringe sind direkt kompatibel zum Zielformat `render2d::types:: +FillPolygon` (nur einfache Ringe, kein Loch-Format), waehrend ein +Loch-Polygon eine zusaetzliche Datenstruktur (Ring-mit-Loch) noetig gemacht +haette, die die Zielstruktur (noch) nicht kennt. Ausserhalb einer Oeffnung +bleibt es beim einzelnen Vollrechteck (Regressionsfall). + +Die Zuordnung "welche Wandachsen-Position entspricht dieser Schnittposition" +ist fuer den STANDARDFALL (Schnittebene senkrecht zur Wandachse — der uebliche +architektonische Wandquerschnitt) EXAKT konstant ueber das Cut-Intervall. Fuer +schraege Wand/Ebene-Kombinationen wird sie als AFFINE Funktion (`axis_map`) +exakt mitgefuehrt (keine Naeherung noetig, da die Abbildung linear ist). + +**Projektion/Ansicht.** Eine Wand mit Oeffnungen bekommt zusaetzlich zur +Boxen-Drahtsilhouette die vier Rahmenkanten jeder Oeffnung (zwei Leibungen, +Sturz, Bruestung), berechnet auf der Wandachsen-Mittellinie (dieselbe +Vereinfachung wie die Bounding-Box-Verdeckung generell — keine eigene +Dicken-Aufloesung der Leibungsflaeche). Fuer die Verdeckung wird jede +Wand-Bounding-Box um ihre Oeffnungen als "Loch" reduziert +(`PrismBounds::opening_voids`): ein anderes (oder dasselbe) Bauteil hinter der +Wand wird in genau dem (u, Hoehe)-Rechteck der Oeffnung NICHT von dieser Wand +verdeckt — "Durchblick". Die eigenen Rahmenkanten einer Oeffnung werden dabei +NICHT gegen das eigene Bauteil auf Verdeckung geprueft (ein Loch kann sich +nicht selbst verdecken); gegen alle anderen Bauteile gilt die normale +Verdeckungslogik unveraendert (inkl. der bereits dokumentierten +Selbstverdeckung der Rueckseite eines ANDEREN Bauteils durch dessen eigene +Vorderseite). + +GENAUIGKEIT (bewusste Vereinfachung): die u-Zuordnung fuer den Durchblick ist +nur DANN aussagekraeftig, wenn die Wandachse hinreichend parallel zur u-Achse +der Schnittebene steht — das ist GENAU der Fall, in dem man die Wand als +Elevation/Ansicht von vorne sieht (und ein Fenster ueberhaupt als Durchblick +sichtbar waere). Steht die Wand naeher an "senkrecht zur u-Achse" (Wand auf +Kante gesehen bzw. der reine Cut-Fall), wird die Durchblick-Berechnung +uebersprungen und die Wand bleibt fuer die Verdeckung VOLL UNDURCHSICHTIG +(konservativ hidden) — Schwelle `AXIS_ALIGN_EPS = 1e-3` in `section.rs`. Ein +Kante-auf-Kante gesehenes Fenster liefert ohnehin keine sinnvolle +Durchblick-Flaeche in der Projektion. + +Getestet in `section::tests` (u. a. `schnitt_durch_fenster_liefert_bruestung_ +und_sturz`, `schnitt_neben_dem_fenster_liefert_volles_rechteck`, +`ansicht_zeigt_fensterrahmen_sichtbar_und_durchblick_bei_dahinterliegender_ +kante`) und im Beweis-SVG (`examples/section_svg.rs`): Schenkel A der L-Wand +hat dort ein Fenster, eine kurze zusaetzliche Wand steht dahinter genau im +Fensterband und erscheint im SVG als durchgezogene (sichtbare) Linie zwischen +Bruestungs- und Sturzhoehe, gestrichelt (verdeckt) darueber/darunter. + ## Bekannte Luecken - **Keine Wandknoten-Verschneidung (T-/X-Stoesse).** Wie in `mesh.rs` @@ -77,11 +136,19 @@ Uebersetzung in die Plan-/Schnitt-Ansicht. `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. +- **Oeffnungen sind NICHT in der Vollkoerper-Extrusion (`mesh.rs`) + nachgezogen.** `wall_prism`/`wall_cut_rectangles`/die Verdeckung in + `section.rs` werten `WallInput::openings` vollstaendig aus (siehe Abschnitt + "Oeffnungen" oben), aber `mesh::extrude_wall` extrudiert weiterhin die volle + Wandflaeche ohne Aussparung (`mesh.rs`-Moduldoc: „Oeffnungen kommen in + spaeteren Milestones"). Cut-/Ansichts-Pipeline und 3D-Solid-Mesh sind bis zum + Nachziehen von `mesh.rs` also bewusst inkonsistent — eine bekannte, separate + Luecke (nicht Gegenstand dieses Nachtrags). +- **Oeffnungs-Durchblick nutzt dieselbe achsparallele Bounding-Box-Naeherung** + wie die allgemeine Verdeckung (kein exaktes Polygon-Clipping der Lochflaeche + gegen dahinterliegende Kanten) und wird bei Wand-Orientierungen nahe + "senkrecht zur u-Achse" konservativ auf "kein Durchblick" zurueckgestuft + (siehe Abschnitt "Oeffnungen" oben, `AXIS_ALIGN_EPS`). - **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 diff --git a/docs/welle-c-hlr-spike/section-engine-proof.svg b/docs/welle-c-hlr-spike/section-engine-proof.svg index 76ff637..f51f113 100644 --- a/docs/welle-c-hlr-spike/section-engine-proof.svg +++ b/docs/welle-c-hlr-spike/section-engine-proof.svg @@ -5,9 +5,15 @@ + + + + + + @@ -19,6 +25,16 @@ + + + + + + + + + + diff --git a/src-tauri/render3d/examples/section_svg.rs b/src-tauri/render3d/examples/section_svg.rs index c104dd0..de621d8 100644 --- a/src-tauri/render3d/examples/section_svg.rs +++ b/src-tauri/render3d/examples/section_svg.rs @@ -1,16 +1,23 @@ //! 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`. +//! baut die L-Wand + Bodenplatte-Testszene (Schenkel A bekommt ein FENSTER) plus +//! eine kurze "Spaeh"-Wand hinter dem Fenster, schneidet/betrachtet 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::types::Opening; use render3d::{SlabInput, WallInput}; fn l_wall_scene() -> (Vec, Vec) { let color = [0.8, 0.8, 0.8]; let walls = vec![ + // Schenkel A: Achse (0,0)->(0,3) -- bekommt ein Fenster (u = Modell-Y + // liegt fuer diese Blickrichtung parallel zur Achse -> Elevations-Fall, + // siehe section.rs-Moduldoc "Oeffnungen"). Fenster von Y=1.0 bis 2.0, + // Bruestung 0.8 m, Fensterhoehe 1.0 m (Sturz ab Y-Hoehe 1.8). WallInput { start: [0.0, 0.0], end: [0.0, 3.0], @@ -18,6 +25,12 @@ fn l_wall_scene() -> (Vec, Vec) { height: 2.5, base_elevation: 0.0, color, + openings: vec![Opening { + from: 1.0, + to: 2.0, + sill: 0.8, + height: 1.0, + }], }, WallInput { start: [0.0, 3.0], @@ -26,6 +39,21 @@ fn l_wall_scene() -> (Vec, Vec) { height: 2.5, base_elevation: 0.0, color, + openings: vec![], + }, + // "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X, + // also groessere Tiefe von der Betrachter-Ebene aus), genau im + // Grundriss-Y-Bereich des Fensters (1.2..1.8, sicher innerhalb 1.0..2.0) + // -- demonstriert den Durchblick: sichtbar im Fensterband (Hoehe + // 0.8..1.8), verdeckt darueber/darunter. + WallInput { + start: [1.5, 1.2], + end: [1.5, 1.8], + thickness: 0.2, + height: 2.5, + base_elevation: 0.0, + color, + openings: vec![], }, ]; let slabs = vec![SlabInput { @@ -123,12 +151,13 @@ fn render_svg(out: &SectionOutput) -> String { 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. + // [-0.5,3.5]) -- liefert deren Poche als Cut-Polygon -- waehrend ALLE DREI + // Waende (Schenkel A samt Fenster, Schenkel B, die Spaeh-Wand) vollstaendig + // HINTER der Ebene liegen und daher als Draht-Silhouette projiziert werden + // (inkl. der in den Tests verifizierten Eck-Ueberlappungs-Verdeckung UND + // des Fensterrahmens + Durchblicks auf die Spaeh-Wand, siehe + // section.rs-Tests). Zeigt also geschnittenes Material UND eine gemischte + // sichtbar/verdeckt-Ansicht MIT Oeffnung in einem Bild. let plane = SectionPlane::looking_plus_x(-0.3); let out = cut_section(&plane, &walls, &slabs); diff --git a/src-tauri/render3d/src/bin/spike3d.rs b/src-tauri/render3d/src/bin/spike3d.rs index 14cd3c0..d40b2b6 100644 --- a/src-tauri/render3d/src/bin/spike3d.rs +++ b/src-tauri/render3d/src/bin/spike3d.rs @@ -36,6 +36,7 @@ fn demo_walls() -> Vec { height: ht, base_elevation: 0.0, color: grey, + openings: vec![], }; // Raum 6 x 4 m. vec![ diff --git a/src-tauri/render3d/src/lib.rs b/src-tauri/render3d/src/lib.rs index 9e5fadf..2868f45 100644 --- a/src-tauri/render3d/src/lib.rs +++ b/src-tauri/render3d/src/lib.rs @@ -57,6 +57,7 @@ mod tests { height, base_elevation: 0.0, color: [0.8, 0.8, 0.8], + openings: vec![], } } @@ -92,6 +93,7 @@ mod tests { height: 2.5, base_elevation: 0.0, color: [0.8, 0.8, 0.8], + openings: vec![], }, ]); assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX); @@ -168,6 +170,7 @@ mod tests { height: 3.0, base_elevation: 0.0, color: [0.8, 0.8, 0.8], + openings: vec![], }; let mut mesh = Mesh::default(); extrude_wall(&mut mesh, &w); @@ -187,6 +190,7 @@ mod tests { height: 2.5, base_elevation: 0.0, color: [0.8, 0.8, 0.8], + openings: vec![], }; let mesh = build_walls_mesh(&[w]); assert_eq!(mesh.vertex_count(), 0); diff --git a/src-tauri/render3d/src/section.rs b/src-tauri/render3d/src/section.rs index eba2cc8..dd08899 100644 --- a/src-tauri/render3d/src/section.rs +++ b/src-tauri/render3d/src/section.rs @@ -53,6 +53,47 @@ //! Punkt. Kanten werden dafuer in Sichtbarkeits-Teilstuecke zerlegt (echtes //! 1D-Intervall-Clipping in ihrem Parameterbereich, kein Nur-Mittelpunkt-Test). //! +//! ## Oeffnungen (Tueren/Fenster) +//! +//! `WallInput::openings` (siehe `types.rs`) gibt je Wand ein Intervall entlang +//! der Wandachse (`from`/`to`, Meter ab `start`) plus eine vertikale Bruestungs-/ +//! Kopfhoehe (`sill`/`height`, relativ zu `base_elevation`) an. Dieses Modul +//! nutzt das fuer zwei getrennte Faelle: +//! +//! - **Cut-Polygone:** trifft die Schnittlinie eine Wand exakt im Bereich einer +//! Oeffnung, wird die volle Rechteck-Hoehe `[z0,z1]` in bis zu ZWEI Rechtecke +//! gesplittet (Bruestung `[z0, z0+sill]`, Sturz `[z0+sill+height, z1]`) statt +//! eines einzelnen Vollrechtecks — bevorzugt gegenueber einem Loch-Polygon +//! (Ring mit Aussparung): mehrere einfache, konvexe Rechtecke sind fuer die +//! spaetere Schraffur/Fuellung (Ziel: `render2d::types::FillPolygon`, das +//! NUR einfache Ringe kennt) direkt verwendbar, waehrend ein Loch-Polygon +//! ein Ring-mit-Loch-Format braeuchte, das die Zielstruktur (noch) nicht hat. +//! Liegt die Oeffnung ausserhalb der aktuellen Schnittposition, entsteht wie +//! zuvor ein einzelnes Vollrechteck (Regressionsfall, siehe Tests). +//! - **Projektion/Ansicht:** eine Wand mit Oeffnungen bekommt zusaetzlich zur +//! Boxen-Drahtsilhouette die vier Rahmenkanten jeder Oeffnung (zwei Leibungen, +//! Sturz, Bruestung) als eigene projizierte Kanten. Fuer die Verdeckung wird +//! jede Wand-Bounding-Box um ihre Oeffnungen als "Loch" reduziert (siehe +//! `PrismBounds::opening_voids`): ein anderes (oder das gleiche) Bauteil +//! hinter der Wand wird in exakt dem (u, Hoehe)-Rechteck der Oeffnung NICHT +//! von dieser Wand verdeckt — "Durchblick". +//! +//! GENAUIGKEIT (bewusste Vereinfachung, siehe auch unten "Bekannte +//! Vereinfachungen"): die Zuordnung "welche Wandachsen-Position entspricht +//! dieser u-Projektionsposition" ist nur DANN exakt/aussagekraeftig, wenn die +//! Wandachse hinreichend parallel zur u-Achse der Schnittebene steht — das ist +//! GENAU der Fall, in dem man die Wand als Ansicht/Elevation von vorne sieht +//! (und ein Fenster darin ueberhaupt als Durchblick sichtbar waere). Steht die +//! Wand naeher an "senkrecht zur u-Achse" (Wand auf Kante gesehen bzw. der +//! Cut-Fall), wird die Durchblick-Berechnung uebersprungen und die Wand bleibt +//! fuer die Verdeckung VOLL UNDURCHSICHTIG (konservativ hidden) — ein +//! Kante-auf-Kante gesehenes Fenster liefert ohnehin keine sinnvolle +//! Durchblick-Flaeche in der Projektion. Schwelle: siehe `AXIS_ALIGN_EPS`. +//! +//! Die eigenen Rahmenkanten einer Oeffnung werden NICHT gegen das eigene +//! Bauteil auf Verdeckung geprueft (ein Loch kann sich nicht selbst verdecken); +//! gegen ALLE ANDEREN Bauteile gilt die normale Verdeckungslogik. +//! //! ## Bekannte Vereinfachungen (siehe auch docs/design/engine-section-pipeline.md) //! //! - Verdeckung nutzt die ACHSPARALLELE (u, Tiefe)-Bounding-Box jedes Prismas, @@ -66,15 +107,23 @@ //! 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. +//! - Oeffnungen werden hier (Cut + Projektion) ausgewertet, aber NICHT von der +//! Vollkoerper-Extrusion in `mesh.rs` — die 3D-Solid-Mesh-Erzeugung extrudiert +//! weiterhin die volle Wandflaeche (siehe `mesh.rs`-Moduldoc). Cut-Ansicht und +//! 3D-Solid-Ansicht sind bis zu diesem Nachtrag also bewusst inkonsistent; +//! das Nachziehen von `mesh.rs` ist eine bekannte, separate Luecke. +//! - Oeffnungs-Durchblick nutzt dieselbe achsparallele Bounding-Box-Naeherung +//! wie die allgemeine Verdeckung (kein exaktes Polygon-Clipping der Lochflaeche +//! gegen dahinterliegende Kanten) und wird bei Wand-Orientierungen nahe +//! "senkrecht zur u-Achse" konservativ auf "kein Durchblick" zurueckgestuft +//! (siehe Abschnitt "Oeffnungen" oben). //! - 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}; +use crate::types::{Opening, Point2, Rgb, SlabInput, WallInput}; /// Toleranz fuer "naeher an der Ebene" / Grenzwert-Vergleiche (Meter, Tiefe/Hoehe). const EPS: f32 = 1e-4; @@ -244,6 +293,45 @@ struct Prism { z0: f32, z1: f32, color: Rgb, + /// Nur fuer Waende: Achse + Oeffnungen (siehe `WallAxis`). `None` fuer + /// Decken (Slabs kennen keine Oeffnungen). + wall_axis: Option, +} + +/// Wandachse + Oeffnungen, wie sie fuer die Oeffnungs-Zuordnung bei Cut- +/// Polygonen/Projektion gebraucht werden (siehe Moduldoc "Oeffnungen"). +struct WallAxis { + /// Achsen-Startpunkt (== `WallInput::start`). + start: Point2, + /// Normalisierte Achsenrichtung (Start -> Ende). + dir: Point2, + /// Achsenlaenge (Meter). + length: f32, + /// Oeffnungen entlang der Achse (unveraendert aus `WallInput::openings`). + openings: Vec, +} + +/// Schwelle fuer `|dot(u_axis, wall_dir)|`, ab der eine Wandachse als +/// "hinreichend parallel zur u-Achse" gilt (siehe Moduldoc "Oeffnungen" -> +/// Genauigkeit). Darunter wird die Oeffnungs-Durchblick-Berechnung fuer diese +/// Wand konservativ uebersprungen (Wand bleibt fuer die Verdeckung blickdicht). +const AXIS_ALIGN_EPS: f32 = 1e-3; + +/// Affine Abbildung `u -> Wandachsen-Position` fuer eine Wand in einer +/// gegebenen Schnittebene: `along_wall(u) = c + d*u`. Herleitung: ein Punkt bei +/// Projektions-Parameter `u` auf der Schnittlinie liegt bei +/// `plane.plan_point() + u * plane.plan_u_axis()` (siehe `project`/`u_of` in +/// `footprint_u_intervals`); seine Position entlang der Wandachse ist das +/// Skalarprodukt aus (Punkt - Wandstart) und der (normalisierten) Wandrichtung. +/// Ausmultipliziert ergibt das die affine Form unten. `d == 0` heisst: die +/// Wandachse steht senkrecht auf der u-Achse (Wand auf Kante gesehen / reiner +/// Cut-Fall) — dort ist `along_wall` konstant ueber das ganze u-Intervall. +fn axis_map(plane: &SectionPlane, axis: &WallAxis) -> (f32, f32) { + let p0 = plane.plan_point(); + let ua = plane.plan_u_axis(); + let c = (p0[0] - axis.start[0]) * axis.dir[0] + (p0[1] - axis.start[1]) * axis.dir[1]; + let d = ua[0] * axis.dir[0] + ua[1] * axis.dir[1]; + (c, d) } /// 2D-Links-Normale (90 Grad CCW) — 1:1 gespiegelt aus `mesh::left_normal` @@ -274,6 +362,12 @@ fn wall_prism(index: usize, wall: &WallInput) -> Option { off(wall.end, -1.0), off(wall.start, -1.0), ]; + // Achsenrichtung aus der bereits berechneten Links-Normale ableiten (90 Grad + // zurueckgedreht): n = (-dir.y, dir.x) => dir = (n.y, -n.x). + let dir = [n[1], -n[0]]; + let dx = wall.end[0] - wall.start[0]; + let dy = wall.end[1] - wall.start[1]; + let length = (dx * dx + dy * dy).sqrt(); Some(Prism { kind: ComponentKind::Wall, index, @@ -281,6 +375,12 @@ fn wall_prism(index: usize, wall: &WallInput) -> Option { z0: wall.base_elevation, z1: wall.base_elevation + wall.height, color: wall.color, + wall_axis: Some(WallAxis { + start: wall.start, + dir, + length, + openings: wall.openings.clone(), + }), }) } @@ -300,6 +400,7 @@ fn slab_prism(index: usize, slab: &SlabInput) -> Option { z0, z1, color: slab.color, + wall_axis: None, }) } @@ -356,18 +457,100 @@ fn footprint_u_intervals(plane: &SectionPlane, footprint: &[Point2]) -> Vec<(f32 out } +/// Zerlegt EIN Cut-u-Intervall `[u_lo, u_hi]` (volle Rechteck-Hoehe `[prism.z0, +/// prism.z1]`) an den Oeffnungen der Wand in mehrere Rechtecke `(u0, u1, v0, +/// v1)`. Ohne Oeffnungen (oder fuer Decken, `wall_axis == None`) liefert das +/// exakt das eine Vollrechteck von vorher (Regressionsfall). Siehe Moduldoc +/// "Oeffnungen" fuer die Herleitung von `axis_map`. +fn wall_cut_rectangles( + plane: &SectionPlane, + prism: &Prism, + u_lo: f32, + u_hi: f32, +) -> Vec<(f32, f32, f32, f32)> { + let Some(axis) = &prism.wall_axis else { + return vec![(u_lo, u_hi, prism.z0, prism.z1)]; + }; + if axis.openings.is_empty() { + return vec![(u_lo, u_hi, prism.z0, prism.z1)]; + } + let (c, d) = axis_map(plane, axis); + + // Oeffnungs-u-Teilintervalle sammeln (auf [u_lo,u_hi] geclippt), sortiert. + let mut voids: Vec<(f32, f32, &Opening)> = Vec::new(); + for op in &axis.openings { + let from = op.from.max(0.0); + let to = op.to.min(axis.length); + if to <= from { + continue; + } + let (open_lo, open_hi) = if d.abs() < 1e-6 { + // Wandachse (nahezu) senkrecht zur u-Achse: die Wandachsen-Position + // ist ueber das gesamte Cut-Intervall praktisch konstant (reiner + // Quer-Schnitt, wie in der Moduldoc beschrieben) -> ganz drin oder + // ganz draussen. + if c >= from - EPS && c <= to + EPS { + (u_lo, u_hi) + } else { + continue; + } + } else { + let t0 = (from - c) / d; + let t1 = (to - c) / d; + (t0.min(t1), t0.max(t1)) + }; + let lo = open_lo.max(u_lo); + let hi = open_hi.min(u_hi); + if hi - lo > MIN_SPAN { + voids.push((lo, hi, op)); + } + } + if voids.is_empty() { + return vec![(u_lo, u_hi, prism.z0, prism.z1)]; + } + voids.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + + let mut rects = Vec::new(); + let mut cursor = u_lo; + for (v_lo, v_hi, op) in &voids { + if *v_lo > cursor + MIN_SPAN { + rects.push((cursor, *v_lo, prism.z0, prism.z1)); + } + let sill_abs = prism.z0 + op.sill.max(0.0); + let head_abs = (sill_abs + op.height).min(prism.z1); + if sill_abs - prism.z0 > MIN_SPAN { + rects.push((*v_lo, *v_hi, prism.z0, sill_abs)); // Bruestung + } + if prism.z1 - head_abs > MIN_SPAN { + rects.push((*v_lo, *v_hi, head_abs, prism.z1)); // Sturz + } + cursor = cursor.max(*v_hi); + } + if u_hi > cursor + MIN_SPAN { + rects.push((cursor, u_hi, prism.z0, prism.z1)); + } + rects +} + // ───────────────────────────────────────────────────────────────────────────── // Projektions-Kanten + Verdeckung // ───────────────────────────────────────────────────────────────────────────── /// Achsparallele Bounding-Box eines Prismas in (u, Tiefe, Hoehe) — genutzt als -/// Verdeckungs-Naeherung (siehe Moduldoc "Bekannte Vereinfachungen"). +/// Verdeckungs-Naeherung (siehe Moduldoc "Bekannte Vereinfachungen"). `kind`/ +/// `index` erlauben es, das Bauteil bei der Verdeckungspruefung gezielt +/// auszuschliessen (fuer die eigenen Oeffnungs-Rahmenkanten, siehe Moduldoc +/// "Oeffnungen"). `opening_voids` sind (u0,u1,v0,v1)-Rechtecke, in denen diese +/// Bounding-Box NICHT verdeckt (Oeffnungs-"Loch", siehe `axis_map`/Moduldoc). struct PrismBounds { + kind: ComponentKind, + index: usize, u_min: f32, u_max: f32, depth_min: f32, z0: f32, z1: f32, + opening_voids: Vec<(f32, f32, f32, f32)>, } fn prism_bounds(plane: &SectionPlane, prism: &Prism) -> PrismBounds { @@ -382,12 +565,42 @@ fn prism_bounds(plane: &SectionPlane, prism: &Prism) -> PrismBounds { u_max = u_max.max(u); depth_min = depth_min.min(d); } + + let mut opening_voids = Vec::new(); + if let Some(axis) = &prism.wall_axis { + if !axis.openings.is_empty() { + let (c, d) = axis_map(plane, axis); + // Nur bei hinreichend u-paralleler Wandachse: siehe Moduldoc + // "Oeffnungen" -> Genauigkeit. Sonst bleibt die Wand blickdicht. + if d.abs() >= AXIS_ALIGN_EPS { + for op in &axis.openings { + let from = op.from.max(0.0); + let to = op.to.min(axis.length); + if to <= from { + continue; + } + let ua = (from - c) / d; + let ub = (to - c) / d; + let (open_u0, open_u1) = (ua.min(ub), ua.max(ub)); + let sill_abs = prism.z0 + op.sill.max(0.0); + let head_abs = (sill_abs + op.height).min(prism.z1); + if head_abs > sill_abs + MIN_SPAN { + opening_voids.push((open_u0, open_u1, sill_abs, head_abs)); + } + } + } + } + } + PrismBounds { + kind: prism.kind, + index: prism.index, u_min, u_max, depth_min, z0: prism.z0, z1: prism.z1, + opening_voids, } } @@ -493,6 +706,28 @@ fn merge_intervals(mut ivs: Vec<(f32, f32)>) -> Vec<(f32, f32)> { out } +/// Entfernt `cut` aus jedem Intervall in `pieces` (kann ein Intervall in ZWEI +/// Stuecke spalten, wenn `cut` es mittig trifft). Genutzt, um ein Oeffnungs- +/// "Loch" (`PrismBounds::opening_voids`) aus einem sonst blockierten +/// t-Teilstueck herauszuschneiden (siehe Moduldoc "Oeffnungen"). +fn subtract_range(pieces: Vec<(f32, f32)>, cut: (f32, f32)) -> Vec<(f32, f32)> { + let (cs, ce) = cut; + let mut out = Vec::with_capacity(pieces.len()); + for (s, e) in pieces { + if ce <= s + MIN_SPAN || cs >= e - MIN_SPAN { + out.push((s, e)); // keine nennenswerte Ueberlappung + continue; + } + if cs > s + MIN_SPAN { + out.push((s, cs)); + } + if ce < e - MIN_SPAN { + out.push((ce, 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(); @@ -511,7 +746,11 @@ fn subtract_from_unit(blocked: &[(f32, f32)]) -> Vec<(f32, f32)> { /// 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). +/// ueber alle Blocker). `exclude` (falls gesetzt) nimmt EIN Bauteil von der +/// Verdeckungspruefung aus — fuer die eigenen Oeffnungs-Rahmenkanten eines +/// Bauteils, die sich nicht selbst verdecken koennen (siehe Moduldoc +/// "Oeffnungen"). Jeder Blocker mit `opening_voids` (Fenster/Tueren) gibt an +/// diesen Stellen NICHTS zur Verdeckung bei ("Durchblick"). #[allow(clippy::too_many_arguments)] fn blocked_intervals( u0: f32, @@ -522,15 +761,30 @@ fn blocked_intervals( d1: f32, bias: HeightBias, bounds: &[PrismBounds], + exclude: Option, ) -> Vec<(f32, f32)> { let mut ivs = Vec::new(); for b in bounds { + if let Some(ex) = exclude { + if ex.kind == b.kind && ex.index == b.index { + continue; + } + } 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); + let Some(solid) = intersect_ranges(intersect_ranges(ru, rv), rd) else { + continue; + }; + let mut pieces = vec![solid]; + for &(vu0, vu1, vz0, vz1) in &b.opening_voids { + let vru = height_in_range(u0, u1, vu0, vu1, HeightBias::Vertical); + let vrv = height_in_range(v0, v1, vz0, vz1, HeightBias::Vertical); + if let Some(void_range) = intersect_ranges(vru, vrv) { + pieces = subtract_range(pieces, void_range); + } } + ivs.extend(pieces); } merge_intervals(ivs) } @@ -561,6 +815,46 @@ fn prism_wireframe_edges(prism: &Prism) -> Vec<([f32; 3], [f32; 3], HeightBias)> edges } +/// Baut die vier Rahmenkanten JEDER Oeffnung einer Wand (zwei Leibungen, Sturz, +/// Bruestung) als Weltpunkt-Paare — analog zu `prism_wireframe_edges`, aber +/// fuer die Loch-Geometrie statt die Aussenhuelle. Die horizontale Position +/// nutzt die Wandachsen-Mittellinie (siehe Moduldoc "Oeffnungen": dieselbe +/// Vereinfachung wie die Bounding-Box-Verdeckung — keine eigene Dicken- +/// Aufloesung der Leibungsflaeche). `HeightBias::Vertical` fuer alle vier +/// Kanten: weder Leibungen (echte Hoehenvariation) noch Sturz/Bruestung +/// (konstante Hoehe, aber keine Prisma-Deckel-/Bodenkante) brauchen die +/// asymmetrische Cap-Randbehandlung (siehe `HeightBias`-Doku). +fn opening_wire_edges(prism: &Prism) -> Vec<([f32; 3], [f32; 3], HeightBias)> { + let Some(axis) = &prism.wall_axis else { + return Vec::new(); + }; + let mut edges = Vec::new(); + for op in &axis.openings { + let from = op.from.max(0.0); + let to = op.to.min(axis.length); + if to <= from { + continue; + } + let sill_abs = prism.z0 + op.sill.max(0.0); + let head_abs = (sill_abs + op.height).min(prism.z1); + if head_abs <= sill_abs + MIN_SPAN { + continue; + } + let at = |t: f32| -> Point2 { [axis.start[0] + t * axis.dir[0], axis.start[1] + t * axis.dir[1]] }; + let p_from = at(from); + let p_to = at(to); + let jamb_from = (to_world(p_from, sill_abs), to_world(p_from, head_abs)); + let jamb_to = (to_world(p_to, sill_abs), to_world(p_to, head_abs)); + let sill_edge = (to_world(p_from, sill_abs), to_world(p_to, sill_abs)); + let head_edge = (to_world(p_from, head_abs), to_world(p_to, head_abs)); + edges.push((jamb_from.0, jamb_from.1, HeightBias::Vertical)); + edges.push((jamb_to.0, jamb_to.1, HeightBias::Vertical)); + edges.push((sill_edge.0, sill_edge.1, HeightBias::Vertical)); + edges.push((head_edge.0, head_edge.1, HeightBias::Vertical)); + } + edges +} + fn compute_view_edges( plane: &SectionPlane, prisms: &[Prism], @@ -574,7 +868,15 @@ fn compute_view_edges( kind: prism.kind, index: prism.index, }; - for (pa, pb, bias) in prism_wireframe_edges(prism) { + // Box-Silhouette (Selbstverdeckung gewollt, siehe Moduldoc) + eigene + // Oeffnungs-Rahmenkanten (NICHT gegen das eigene Bauteil verdeckt). + let mut all_edges: Vec<(([f32; 3], [f32; 3], HeightBias), bool)> = prism_wireframe_edges(prism) + .into_iter() + .map(|e| (e, false)) + .collect(); + all_edges.extend(opening_wire_edges(prism).into_iter().map(|e| (e, true))); + + for ((pa, pb, bias), self_exclude) in all_edges { let da = plane.depth(pa); let db = plane.depth(pb); if da <= EPS && db <= EPS { @@ -607,7 +909,8 @@ fn compute_view_edges( let d0 = plane.depth(ca); let d1 = plane.depth(cb); - let blocked = blocked_intervals(u0, u1, v0, v1, d0, d1, bias, &bounds); + let exclude = if self_exclude { Some(cref) } else { None }; + let blocked = blocked_intervals(u0, u1, v0, v1, d0, d1, bias, &bounds, exclude); for &(s, e) in &blocked { hidden.push(SectionEdge { component: cref, @@ -650,14 +953,16 @@ pub fn cut_section(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput 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]], - }); + for (rlo, rhi, rz0, rz1) in wall_cut_rectangles(plane, prism, lo, hi) { + cut_polygons.push(CutPolygon { + component: ComponentRef { + kind: prism.kind, + index: prism.index, + }, + color: prism.color, + pts: vec![[rlo, rz0], [rhi, rz0], [rhi, rz1], [rlo, rz1]], + }); + } } } @@ -695,6 +1000,7 @@ mod tests { height: 2.5, base_elevation: 0.0, color, + openings: vec![], }, WallInput { start: [0.0, 3.0], @@ -703,6 +1009,7 @@ mod tests { height: 2.5, base_elevation: 0.0, color, + openings: vec![], }, ]; let slabs = vec![SlabInput { @@ -898,4 +1205,205 @@ mod tests { assert!(e.a[1] >= -1e-4 && e.a[1] <= 2.5 + 1e-4); } } + + // --- Oeffnungen (Tueren/Fenster) ----------------------------------------- + + /// Eine Wand entlang +X (Achse (0,0)->(4,0)) mit einem Fenster: Intervall + /// entlang der Achse [1.5, 2.5] (1 m breit), Bruestung 1.0 m, Fensterhoehe + /// 1.0 m (Sturz beginnt also bei 2.0, Wand-Oberkante bei 2.5 -> 0.5 m Sturz). + fn wall_with_window() -> WallInput { + WallInput { + start: [0.0, 0.0], + end: [4.0, 0.0], + thickness: 0.2, + height: 2.5, + base_elevation: 0.0, + color: [0.8, 0.8, 0.8], + openings: vec![Opening { + from: 1.5, + to: 2.5, + sill: 1.0, + height: 1.0, + }], + } + } + + #[test] + fn schnitt_durch_fenster_liefert_bruestung_und_sturz() { + // Schnittebene bei Grundriss-X = 2.0: liegt MITTEN im Fenster (1.5..2.5). + // Statt eines Vollrechtecks (0..2.5) muessen zwei Teil-Rechtecke + // entstehen: Bruestung (0..1.0) und Sturz (2.0..2.5). + let wall = wall_with_window(); + let plane = SectionPlane::looking_plus_x(2.0); + let out = cut_section(&plane, &[wall], &[]); + + assert_eq!(out.cut_polygons.len(), 2, "Bruestung + Sturz statt Vollrechteck"); + + let bounds_of = |c: &CutPolygon| -> (f32, f32, f32, f32) { + let us: Vec = c.pts.iter().map(|p| p[0]).collect(); + let vs: Vec = c.pts.iter().map(|p| p[1]).collect(); + ( + us.iter().cloned().fold(f32::INFINITY, f32::min), + us.iter().cloned().fold(f32::NEG_INFINITY, f32::max), + vs.iter().cloned().fold(f32::INFINITY, f32::min), + vs.iter().cloned().fold(f32::NEG_INFINITY, f32::max), + ) + }; + + let bruestung = out + .cut_polygons + .iter() + .find(|c| bounds_of(c).2.abs() < 1e-3) + .expect("Bruestungs-Rechteck (v ab 0)"); + let (u_lo, u_hi, v_lo, v_hi) = bounds_of(bruestung); + assert!((u_lo + 0.1).abs() < 1e-4, "u_lo={u_lo}"); + assert!((u_hi - 0.1).abs() < 1e-4, "u_hi={u_hi}"); + assert!((v_lo - 0.0).abs() < 1e-4, "v_lo={v_lo}"); + assert!((v_hi - 1.0).abs() < 1e-4, "v_hi={v_hi} (Bruestungshoehe)"); + + let sturz = out + .cut_polygons + .iter() + .find(|c| (bounds_of(c).3 - 2.5).abs() < 1e-3) + .expect("Sturz-Rechteck (v bis 2.5, Wand-Oberkante)"); + let (u_lo, u_hi, v_lo, v_hi) = bounds_of(sturz); + assert!((u_lo + 0.1).abs() < 1e-4, "u_lo={u_lo}"); + assert!((u_hi - 0.1).abs() < 1e-4, "u_hi={u_hi}"); + assert!((v_lo - 2.0).abs() < 1e-4, "v_lo={v_lo} (Sturz beginnt bei sill+height=2.0)"); + assert!((v_hi - 2.5).abs() < 1e-4, "v_hi={v_hi}"); + } + + #[test] + fn schnitt_neben_dem_fenster_liefert_volles_rechteck() { + // Schnittebene bei Grundriss-X = 0.5: liegt ausserhalb des Fensters + // (1.5..2.5) -> Regressionsfall, unveraendert ein Vollrechteck. + let wall = wall_with_window(); + let plane = SectionPlane::looking_plus_x(0.5); + let out = cut_section(&plane, &[wall], &[]); + + assert_eq!(out.cut_polygons.len(), 1, "kein Fenster an dieser Stelle -> ein Vollrechteck"); + let c = &out.cut_polygons[0]; + let us: Vec = c.pts.iter().map(|p| p[0]).collect(); + let vs: Vec = c.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.1).abs() < 1e-4, "u_lo={u_lo}"); + assert!((u_hi - 0.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} (volle Wandhoehe)"); + } + + #[test] + fn ansicht_zeigt_fensterrahmen_sichtbar_und_durchblick_bei_dahinterliegender_kante() { + // Wand mit Fenster (Index 0) + eine zweite Wand DAHINTER (Index 1, + // ohne Oeffnung), die teils hinter dem Fenster, teils hinter der + // geschlossenen Wandflaeche steht. Ansicht von vorne (Blick +Y, Wand- + // achse (0,0)->(4,0) steht dabei u-parallel -> Elevations-Fall, siehe + // Moduldoc "Oeffnungen"). + let w0 = wall_with_window(); + let w1 = WallInput { + start: [2.0, 1.0], + end: [4.0, 1.0], + thickness: 0.2, + height: 2.5, + base_elevation: 0.0, + color: [0.8, 0.8, 0.8], + openings: vec![], + }; + // Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter. + let plane = SectionPlane::looking_plus_y(-2.0); + let out = cut_section(&plane, &[w0, w1], &[]); + + // -- Fensterrahmen (Wand 0) ist vollstaendig sichtbar (nichts davor). -- + // Leibungen bei u=-1.5 und u=-2.5 (Fenster-Enden), v von 1.0 (Bruestung) + // bis 2.0 (Sturzunterkante). + let jamb_visible = |u: f32| { + out.visible_edges.iter().any(|e| { + e.component.kind == ComponentKind::Wall + && e.component.index == 0 + && (e.a[0] - u).abs() < 1e-3 + && (e.b[0] - u).abs() < 1e-3 + && (e.a[1].min(e.b[1]) - 1.0).abs() < 1e-3 + && (e.a[1].max(e.b[1]) - 2.0).abs() < 1e-3 + }) + }; + assert!(jamb_visible(-1.5), "linke Leibung (u=-1.5) muss sichtbar sein"); + assert!(jamb_visible(-2.5), "rechte Leibung (u=-2.5) muss sichtbar sein"); + // Bruestungs- und Sturzkante (horizontale Rahmenkanten bei v=1.0/2.0). + let horizontal_edge_visible = |v: f32| { + out.visible_edges.iter().any(|e| { + e.component.kind == ComponentKind::Wall + && e.component.index == 0 + && (e.a[1] - v).abs() < 1e-3 + && (e.b[1] - v).abs() < 1e-3 + && (e.a[0].min(e.b[0]) + 2.5).abs() < 1e-3 + && (e.a[0].max(e.b[0]) + 1.5).abs() < 1e-3 + }) + }; + assert!(horizontal_edge_visible(1.0), "Bruestungskante (v=1.0) muss sichtbar sein"); + assert!(horizontal_edge_visible(2.0), "Sturzkante (v=2.0) muss sichtbar sein"); + // Keine dieser vier Rahmenkanten darf (auch nur teilweise) hidden sein + // (Wand 0 steht als naechstes Bauteil vorne, nichts verdeckt sie). + let frame_hidden = out.hidden_edges.iter().any(|e| { + e.component.kind == ComponentKind::Wall + && e.component.index == 0 + && ((e.a[0] + 1.5).abs() < 1e-3 || (e.a[0] + 2.5).abs() < 1e-3) + && e.a[1] >= 1.0 - 1e-3 + && e.a[1] <= 2.0 + 1e-3 + }); + assert!(!frame_hidden, "Fensterrahmen darf nicht (teilweise) hidden sein"); + + // -- Durchblick: die (naeherliegende) Vorderkante von Wand 1 bei + // u=-2.0 (Wandstart x=2, liegt IM Fenster-u-Bereich -2.5..-1.5) muss + // GENAU im Fensterband (v: 1.0..2.0) sichtbar sein, darueber/darunter + // (verdeckt durch Bruestung/Sturz der Wand 0) hidden. -- + let w1_visible_window = out.visible_edges.iter().any(|e| { + e.component.kind == ComponentKind::Wall + && e.component.index == 1 + && (e.a[0] + 2.0).abs() < 1e-3 + && (e.b[0] + 2.0).abs() < 1e-3 + && (e.a[1].min(e.b[1]) - 1.0).abs() < 1e-3 + && (e.a[1].max(e.b[1]) - 2.0).abs() < 1e-3 + }); + assert!(w1_visible_window, "Durchblick auf Wand 1 durchs Fenster (v 1.0..2.0) fehlt"); + + let w1_hidden_below = out.hidden_edges.iter().any(|e| { + e.component.kind == ComponentKind::Wall + && e.component.index == 1 + && (e.a[0] + 2.0).abs() < 1e-3 + && (e.b[0] + 2.0).abs() < 1e-3 + && (e.a[1].min(e.b[1]) - 0.0).abs() < 1e-3 + && (e.a[1].max(e.b[1]) - 1.0).abs() < 1e-3 + }); + assert!(w1_hidden_below, "Wand 1 unterhalb der Bruestung (v 0..1.0) muss hidden sein"); + + let w1_hidden_above = out.hidden_edges.iter().any(|e| { + e.component.kind == ComponentKind::Wall + && e.component.index == 1 + && (e.a[0] + 2.0).abs() < 1e-3 + && (e.b[0] + 2.0).abs() < 1e-3 + && (e.a[1].min(e.b[1]) - 2.0).abs() < 1e-3 + && (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3 + }); + assert!(w1_hidden_above, "Wand 1 oberhalb des Sturzes (v 2.0..2.5) muss hidden sein"); + + // Kante hinter der GESCHLOSSENEN Wandflaeche (u=-4.0, ausserhalb des + // Fensters, x=4-Ende von Wand 1) bleibt vollstaendig verdeckt. + let w1_closed_wall_hidden = out.hidden_edges.iter().any(|e| { + e.component.kind == ComponentKind::Wall + && e.component.index == 1 + && (e.a[0] + 4.0).abs() < 1e-3 + && (e.b[0] + 4.0).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!( + w1_closed_wall_hidden, + "Kante hinter geschlossener Wandflaeche (u=-4.0) muss vollstaendig hidden sein" + ); + let w1_closed_wall_visible = out.visible_edges.iter().any(|e| { + e.component.kind == ComponentKind::Wall && e.component.index == 1 && (e.a[0] + 4.0).abs() < 1e-3 + }); + assert!(!w1_closed_wall_visible, "u=-4.0 (hinter geschlossener Wand) darf nicht sichtbar sein"); + } } diff --git a/src-tauri/render3d/src/types.rs b/src-tauri/render3d/src/types.rs index 7a0f170..c7a9f09 100644 --- a/src-tauri/render3d/src/types.rs +++ b/src-tauri/render3d/src/types.rs @@ -25,9 +25,13 @@ pub type Rgb = [f32; 3]; /// erzeugt (Band aus Achse+Dicke, hochgezogen auf `height` ab `base_elevation`). /// /// Vereinfachung des Web-`Wall` (siehe src/model/wall.ts): Ecken-Verschneidung -/// (`computeJoins`/`wallCorners`) und Oeffnungen kommen in spaeteren Milestones; -/// M1 extrudiert jede Wand als eigenstaendigen, an den Enden stumpf abgeschlossenen -/// Quader. +/// (`computeJoins`/`wallCorners`) kommt in einem spaeteren Milestone; M1 extrudiert +/// jede Wand als eigenstaendigen, an den Enden stumpf abgeschlossenen Quader. +/// `openings` (Tueren/Fenster, siehe `Opening`) werden vom Schnitt-Modul +/// (`section.rs`) ausgewertet; die Vollkoerper-Extrusion (`mesh.rs`) kennt sie +/// (noch) NICHT und extrudiert weiterhin die volle Wandflaeche — siehe +/// `mesh.rs`-Moduldoc und `docs/design/engine-section-pipeline.md` fuer diese +/// bekannte Diskrepanz. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WallInput { /// Achsen-Startpunkt im Grundriss (Modell-Meter). @@ -44,6 +48,30 @@ pub struct WallInput { /// Albedo-Farbe der Wand (RGB 0..1). Default helles Grau wie die three.js-Sicht. #[serde(default = "default_wall_color")] pub color: Rgb, + /// Oeffnungen (Tueren/Fenster) entlang der Wandachse. Default leer (volle Wand). + #[serde(default)] + pub openings: Vec, +} + +/// Eine Oeffnung (Tuer/Fenster) in einer Wand: Intervall entlang der Wandachse +/// (Meter ab `WallInput::start`, 0..Wandlaenge) plus vertikale Ausdehnung relativ +/// zur Wand-Unterkante (`base_elevation`). +/// +/// Vereinfachung des Web-`Opening` (siehe src/model/types.ts): keine eigene Id, +/// kein Oeffnungstyp (Tuer/Fenster) — nur die reine Geometrie, die Schnitt/Ansicht +/// brauchen. Eine Tuer ist der Sonderfall `sill == 0.0` (keine Bruestungsmauer). +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct Opening { + /// Start des Oeffnungs-Intervalls entlang der Wandachse (Meter ab `start`). + pub from: f32, + /// Ende des Oeffnungs-Intervalls entlang der Wandachse (Meter ab `start`). + pub to: f32, + /// Bruestungshoehe: Unterkante der Oeffnung relativ zu `base_elevation` + /// (Meter). 0 = Tuer (keine Bruestungsmauer darunter). + #[serde(default)] + pub sill: f32, + /// Hoehe der Oeffnung (vertikale Ausdehnung ab `sill`), Meter. + pub height: f32, } fn default_wall_color() -> Rgb {