Schnitt entspiegelt: u-Achse = Betrachter-RECHTS (Rust + TS koordiniert)

Die Schnitt-u-Achse nutzte cross(normal, up) — die look_at-Konvention für
Blick entlang −z. Für einen Schnitt, den man entlang +normal betrachtet,
spiegelte das die Zeichnung (Blick nach Norden zeigte Osten links). Neu:
u = cross(up, normal) — wer nach Norden blickt, hat Osten rechts. Dieselbe
Entspiegelung wie zuvor in der Ansicht (b967146).

Koordiniert geändert: SectionPlane::u_axis (section.rs; section_fill nutzt
dieselbe Methode) + sectionUAxisModel (toSection.ts; Schicht-Orientierung
wallLayersReversedInU und Dach-Schnitt hängen daran und kippen konsistent
mit — die Aussenseite bleibt in der Zeichnung physisch aussen). Rust- und
TS-Tests auf die neue Konvention gespiegelt; Engine neu gebaut.
cargo 76/76, vitest 737/737.
This commit is contained in:
2026-07-11 02:30:35 +02:00
parent 1945fecd11
commit 983d061a0c
4 changed files with 85 additions and 75 deletions
+46 -43
View File
@@ -24,12 +24,9 @@
//! - **Ursprung:** `SectionPlane::point`, projiziert. //! - **Ursprung:** `SectionPlane::point`, projiziert.
//! - **u (horizontal in der Zeichnung):** Strecke entlang der Schnittebene, //! - **u (horizontal in der Zeichnung):** Strecke entlang der Schnittebene,
//! senkrecht zur Blickrichtung, in der Horizontalen. Berechnet als //! senkrecht zur Blickrichtung, in der Horizontalen. Berechnet als
//! `u_axis = normalize(cross(normal, world_up))` — dieselbe rechtshaendige //! `u_axis = normalize(cross(world_up, normal))` — das Blickrichtungs-RECHTS
//! Kamera-Konvention wie `math::look_at` (dort `s = cross(f, up)`), damit die //! des Betrachters (wer nach Norden blickt, hat Osten rechts). Die fruehere
//! Vorzeichen-Konvention modulübergreifend konsistent bleibt. Bei den //! look_at-Konvention `cross(normal, up)` spiegelte die Zeichnung.
//! 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 //! - **v (vertikal in der Zeichnung, "Hoehe"):** `v = world.y`, ABSOLUT (nicht
//! relativ zur Schnittebene). `world.y` ist in dieser Codebasis durchgaengig //! relativ zur Schnittebene). `world.y` ist in dieser Codebasis durchgaengig
//! die Hoehen-Achse (Y-up, siehe `types.rs`/`mesh.rs`/`math.rs`: "world.y = //! die Hoehen-Achse (Y-up, siehe `types.rs`/`mesh.rs`/`math.rs`: "world.y =
@@ -189,8 +186,13 @@ impl SectionPlane {
/// `pub(crate)`, damit `section_fill` die (u,v)->Welt-Ruecktransformation /// `pub(crate)`, damit `section_fill` die (u,v)->Welt-Ruecktransformation
/// mit EXAKT derselben Achsdefinition (inkl. Entartungs-Fallback) rechnet. /// mit EXAKT derselben Achsdefinition (inkl. Entartungs-Fallback) rechnet.
pub(crate) fn u_axis(&self) -> [f32; 3] { pub(crate) fn u_axis(&self) -> [f32; 3] {
// Blickrichtungs-RECHTS des Betrachters: cross(up, normal). Die alte
// Formel cross(normal, up) (die `look_at`-right-Konvention fuer Blick
// entlang -z) SPIEGELTE den Schnitt — wer nach Norden blickt, hat
// Osten rechts, die Ausgabe zeigte ihn links. Gleiche Entspiegelung
// wie `elevationFrame` (toElevation.ts) auf der TS-Seite.
let up = [0.0, 1.0, 0.0]; let up = [0.0, 1.0, 0.0];
let u = math::cross(self.normal, up); let u = math::cross(up, self.normal);
if math::length(u) > 1e-6 { if math::length(u) > 1e-6 {
math::normalize(u) math::normalize(u)
} else { } else {
@@ -1099,8 +1101,9 @@ mod tests {
math::dot(u, plane.normal).abs() < 1e-6, math::dot(u, plane.normal).abs() < 1e-6,
"u-Achse orthogonal zur Blickrichtung" "u-Achse orthogonal zur Blickrichtung"
); );
// Fuer looking_plus_x ist u = Modell-Y (world +Z). // Fuer looking_plus_x (Blick nach Osten) zeigt das Betrachter-RECHTS
assert!((u[2] - 1.0).abs() < 1e-6); // nach Sueden: u = world Z (Entspiegelung, s. u_axis-Doc).
assert!((u[2] + 1.0).abs() < 1e-6);
} }
#[test] #[test]
@@ -1140,8 +1143,8 @@ mod tests {
let vs: Vec<f32> = wall_cut.pts.iter().map(|p| p[1]).collect(); let vs: Vec<f32> = 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 (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)); 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_lo + 3.1).abs() < 1e-4, "u_lo={u_lo}");
assert!((u_hi - 3.1).abs() < 1e-4, "u_hi={u_hi}"); assert!((u_hi + 2.9).abs() < 1e-4, "u_hi={u_hi}");
assert!((v_lo - 0.0).abs() < 1e-4, "v_lo={v_lo}"); assert!((v_lo - 0.0).abs() < 1e-4, "v_lo={v_lo}");
assert!((v_hi - 2.5).abs() < 1e-4, "v_hi={v_hi}"); assert!((v_hi - 2.5).abs() < 1e-4, "v_hi={v_hi}");
@@ -1154,8 +1157,8 @@ mod tests {
let vs: Vec<f32> = slab_cut.pts.iter().map(|p| p[1]).collect(); let vs: Vec<f32> = 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 (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)); 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_lo + 3.5).abs() < 1e-4, "u_lo={u_lo}");
assert!((u_hi - 3.5).abs() < 1e-4, "u_hi={u_hi}"); assert!((u_hi - 0.5).abs() < 1e-4, "u_hi={u_hi}");
assert!((v_lo + 0.2).abs() < 1e-4, "v_lo={v_lo}"); assert!((v_lo + 0.2).abs() < 1e-4, "v_lo={v_lo}");
assert!((v_hi - 0.0).abs() < 1e-4, "v_hi={v_hi}"); assert!((v_hi - 0.0).abs() < 1e-4, "v_hi={v_hi}");
} }
@@ -1211,30 +1214,30 @@ mod tests {
let embedded_corner_hidden = out.hidden_edges.iter().any(|e| { let embedded_corner_hidden = out.hidden_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall e.component.kind == ComponentKind::Wall
&& e.component.index == 1 && e.component.index == 1
&& (e.a[0] - 2.9).abs() < 1e-3 && (e.a[0] + 2.9).abs() < 1e-3
&& (e.b[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].min(e.b[1]) - 0.0).abs() < 1e-3
&& (e.a[1].max(e.b[1]) - 2.5).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"); 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 // Die AEUSSERE Eckkante von Schenkel B (u=3.1, ragt ueber Schenkel A
// hinaus) bleibt dagegen sichtbar. // hinaus) bleibt dagegen sichtbar.
let outer_corner_visible = out.visible_edges.iter().any(|e| { let outer_corner_visible = out.visible_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall e.component.kind == ComponentKind::Wall
&& e.component.index == 1 && e.component.index == 1
&& (e.a[0] - 3.1).abs() < 1e-3 && (e.a[0] + 3.1).abs() < 1e-3
&& (e.b[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"); 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 // Die partiell gesplittete Bodenkante (u: -3.1 -> -2.9 bei v=0,
// in einen sichtbaren Anteil ab u=3.0. // gespiegelte u-Achse) zerfaellt in einen sichtbaren Anteil ab u=-3.0.
let split_visible_present = out.visible_edges.iter().any(|e| { let split_visible_present = out.visible_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall e.component.kind == ComponentKind::Wall
&& e.component.index == 1 && e.component.index == 1
&& (e.a[1].abs() < 1e-3 && e.b[1].abs() < 1e-3) && (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) && ((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"); assert!(split_visible_present, "Sichtbarer Teil der gesplitteten Bodenkante ab u=3.0 fehlt");
} }
@@ -1286,14 +1289,14 @@ mod tests {
}; };
let a = wall_cuts.iter().find(|c| c.component.index == 0).expect("Schenkel A"); let a = wall_cuts.iter().find(|c| c.component.index == 0).expect("Schenkel A");
let b = wall_cuts.iter().find(|c| c.component.index == 1).expect("Schenkel B"); let b = wall_cuts.iter().find(|c| c.component.index == 1).expect("Schenkel B");
let (_a_lo, a_hi) = u_bounds(a); let (a_lo, _a_hi) = u_bounds(a);
let (b_lo, _b_hi) = u_bounds(b); let (_b_lo, b_hi) = u_bounds(b);
// Beide enden/beginnen an der Gehrungslinie (u ~ 2.95) statt sich zu // Beide enden/beginnen an der Gehrungslinie (u ~ -2.95, gespiegelte
// ueberlappen (Schenkel A reichte ungehrt bis u=3.0, Schenkel B ab 2.9). // u-Achse) statt sich zu ueberlappen.
assert!((a_hi - 2.95).abs() < 1e-3, "Schenkel-A-Querschnitt endet an der Gehrung, u_hi={a_hi}"); assert!((a_lo + 2.95).abs() < 1e-3, "Schenkel-A-Querschnitt endet an der Gehrung, u_lo={a_lo}");
assert!((b_lo - 2.95).abs() < 1e-3, "Schenkel-B-Querschnitt beginnt an der Gehrung, u_lo={b_lo}"); assert!((b_hi + 2.95).abs() < 1e-3, "Schenkel-B-Querschnitt beginnt an der Gehrung, u_hi={b_hi}");
assert!(a_hi <= b_lo + 1e-4, "keine u-Ueberlappung der beiden Querschnitte"); assert!(b_hi <= a_lo + 1e-4, "keine u-Ueberlappung der beiden Querschnitte");
} }
// --- Oeffnungen (Tueren/Fenster) ----------------------------------------- // --- Oeffnungen (Tueren/Fenster) -----------------------------------------
@@ -1427,8 +1430,8 @@ mod tests {
&& (e.a[1].max(e.b[1]) - 2.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(1.5), "linke Leibung (u=1.5) muss sichtbar sein");
assert!(jamb_visible(-2.5), "rechte Leibung (u=-2.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). // Bruestungs- und Sturzkante (horizontale Rahmenkanten bei v=1.0/2.0).
let horizontal_edge_visible = |v: f32| { let horizontal_edge_visible = |v: f32| {
out.visible_edges.iter().any(|e| { out.visible_edges.iter().any(|e| {
@@ -1436,8 +1439,8 @@ mod tests {
&& e.component.index == 0 && e.component.index == 0
&& (e.a[1] - v).abs() < 1e-3 && (e.a[1] - v).abs() < 1e-3
&& (e.b[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].min(e.b[0]) - 1.5).abs() < 1e-3
&& (e.a[0].max(e.b[0]) + 1.5).abs() < 1e-3 && (e.a[0].max(e.b[0]) - 2.5).abs() < 1e-3
}) })
}; };
assert!(horizontal_edge_visible(1.0), "Bruestungskante (v=1.0) muss sichtbar sein"); assert!(horizontal_edge_visible(1.0), "Bruestungskante (v=1.0) muss sichtbar sein");
@@ -1447,21 +1450,21 @@ mod tests {
let frame_hidden = out.hidden_edges.iter().any(|e| { let frame_hidden = out.hidden_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall e.component.kind == ComponentKind::Wall
&& e.component.index == 0 && e.component.index == 0
&& ((e.a[0] + 1.5).abs() < 1e-3 || (e.a[0] + 2.5).abs() < 1e-3) && ((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] >= 1.0 - 1e-3
&& e.a[1] <= 2.0 + 1e-3 && e.a[1] <= 2.0 + 1e-3
}); });
assert!(!frame_hidden, "Fensterrahmen darf nicht (teilweise) hidden sein"); assert!(!frame_hidden, "Fensterrahmen darf nicht (teilweise) hidden sein");
// -- Durchblick: die (naeherliegende) Vorderkante von Wand 1 bei // -- Durchblick: die (naeherliegende) Vorderkante von Wand 1 bei
// u=-2.0 (Wandstart x=2, liegt IM Fenster-u-Bereich -2.5..-1.5) muss // u=2.0 (Wandstart x=2, liegt IM Fenster-u-Bereich 1.5..2.5) muss
// GENAU im Fensterband (v: 1.0..2.0) sichtbar sein, darueber/darunter // GENAU im Fensterband (v: 1.0..2.0) sichtbar sein, darueber/darunter
// (verdeckt durch Bruestung/Sturz der Wand 0) hidden. -- // (verdeckt durch Bruestung/Sturz der Wand 0) hidden. --
let w1_visible_window = out.visible_edges.iter().any(|e| { let w1_visible_window = out.visible_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall e.component.kind == ComponentKind::Wall
&& e.component.index == 1 && e.component.index == 1
&& (e.a[0] + 2.0).abs() < 1e-3 && (e.a[0] - 2.0).abs() < 1e-3
&& (e.b[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].min(e.b[1]) - 1.0).abs() < 1e-3
&& (e.a[1].max(e.b[1]) - 2.0).abs() < 1e-3 && (e.a[1].max(e.b[1]) - 2.0).abs() < 1e-3
}); });
@@ -1470,8 +1473,8 @@ mod tests {
let w1_hidden_below = out.hidden_edges.iter().any(|e| { let w1_hidden_below = out.hidden_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall e.component.kind == ComponentKind::Wall
&& e.component.index == 1 && e.component.index == 1
&& (e.a[0] + 2.0).abs() < 1e-3 && (e.a[0] - 2.0).abs() < 1e-3
&& (e.b[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].min(e.b[1]) - 0.0).abs() < 1e-3
&& (e.a[1].max(e.b[1]) - 1.0).abs() < 1e-3 && (e.a[1].max(e.b[1]) - 1.0).abs() < 1e-3
}); });
@@ -1480,20 +1483,20 @@ mod tests {
let w1_hidden_above = out.hidden_edges.iter().any(|e| { let w1_hidden_above = out.hidden_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall e.component.kind == ComponentKind::Wall
&& e.component.index == 1 && e.component.index == 1
&& (e.a[0] + 2.0).abs() < 1e-3 && (e.a[0] - 2.0).abs() < 1e-3
&& (e.b[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].min(e.b[1]) - 2.0).abs() < 1e-3
&& (e.a[1].max(e.b[1]) - 2.5).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"); 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 // Kante hinter der GESCHLOSSENEN Wandflaeche (u=4.0, ausserhalb des
// Fensters, x=4-Ende von Wand 1) bleibt vollstaendig verdeckt. // Fensters, x=4-Ende von Wand 1) bleibt vollstaendig verdeckt.
let w1_closed_wall_hidden = out.hidden_edges.iter().any(|e| { let w1_closed_wall_hidden = out.hidden_edges.iter().any(|e| {
e.component.kind == ComponentKind::Wall e.component.kind == ComponentKind::Wall
&& e.component.index == 1 && e.component.index == 1
&& (e.a[0] + 4.0).abs() < 1e-3 && (e.a[0] - 4.0).abs() < 1e-3
&& (e.b[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].min(e.b[1]) - 0.0).abs() < 1e-3
&& (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3 && (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3
}); });
+20 -16
View File
@@ -66,13 +66,14 @@ const RENDER_EXT_T = 0.02; // Aussenputz — layers[0], die Aussenseite
const RENDER_INT_T = 0.015; // Innenputz — letzte Schicht, die Innenseite const RENDER_INT_T = 0.015; // Innenputz — letzte Schicht, die Innenseite
describe("Schnitt-u-Weltachse", () => { describe("Schnitt-u-Weltachse", () => {
it("entspricht der WASM-Konvention u = cross(normal, up) → Grundriss (Nz, Nx)", () => { it("entspricht der WASM-Konvention u = cross(up, normal) → Grundriss (Nz, Nx)", () => {
// Schnitt A: Ebenennormale world = (0,0,1) → uWorld model = (1, 0). // Schnitt A: Ebenennormale world = (0,0,1) → uWorld model = (1, 0)
// Betrachter-RECHTS (Entspiegelung: Blick nach Norden ⇒ Osten rechts).
expect(planeA.normal[0]).toBeCloseTo(0, 9); expect(planeA.normal[0]).toBeCloseTo(0, 9);
expect(planeA.normal[1]).toBeCloseTo(0, 9); expect(planeA.normal[1]).toBeCloseTo(0, 9);
expect(planeA.normal[2]).toBeCloseTo(1, 9); expect(planeA.normal[2]).toBeCloseTo(1, 9);
const u = sectionUAxisModel(planeA); const u = sectionUAxisModel(planeA);
expect(u.x).toBeCloseTo(-1, 9); expect(u.x).toBeCloseTo(1, 9);
expect(u.y).toBeCloseTo(0, 9); expect(u.y).toBeCloseTo(0, 9);
}); });
}); });
@@ -80,11 +81,14 @@ describe("Schnitt-u-Weltachse", () => {
describe("Wand-Aufbaurichtung vs. Schnitt-u-Achse", () => { describe("Wand-Aufbaurichtung vs. Schnitt-u-Achse", () => {
const uWorld = sectionUAxisModel(planeA); const uWorld = sectionUAxisModel(planeA);
it("W2 (Aussenseite +x) nicht gespiegelt, W4 (Aussenseite x) gespiegelt", () => { it("W2 (Aussenseite +x) gespiegelt, W4 (Aussenseite x) nicht — entspiegelte u-Achse", () => {
// Beide Wände gehören zum CCW-Umriss; die linke Normale zeigt nach innen. // Beide Wände gehören zum CCW-Umriss; die linke Normale zeigt nach innen.
// Gegenüberliegende Wände ⇒ genau eine der beiden wird gespiegelt. // Gegenüberliegende Wände ⇒ genau eine der beiden wird gespiegelt. (Mit
expect(wallLayersReversedInU(wall("W2"), uWorld)).toBe(false); // der Entspiegelung der u-Achse tauschen die Rollen gegenüber früher —
expect(wallLayersReversedInU(wall("W4"), uWorld)).toBe(true); // Geometrie UND Bandreihenfolge kippen zusammen, die Aussenseite bleibt
// in der Zeichnung physisch aussen.)
expect(wallLayersReversedInU(wall("W2"), uWorld)).toBe(true);
expect(wallLayersReversedInU(wall("W4"), uWorld)).toBe(false);
}); });
it("degenerierte Wand (Länge 0) bleibt unverändert (kein Spiegeln)", () => { it("degenerierte Wand (Länge 0) bleibt unverändert (kein Spiegeln)", () => {
@@ -97,21 +101,21 @@ describe("splitWallLayers: Aussenputz auf der Aussenseite, Wände spiegelverkehr
const uWorld = sectionUAxisModel(planeA); const uWorld = sectionUAxisModel(planeA);
const cp = rect(0, 0.345); // volle Wanddicke, Skala 1:1 const cp = rect(0, 0.345); // volle Wanddicke, Skala 1:1
it("W2: Aussenputz (layers[0]) an uMin, Innenputz an uMax", () => { it("W2: gespiegelt — Aussenputz an uMax, Innenputz an uMin", () => {
const bands = bandsByU(splitWallLayers(cp, sampleProject, aw, wall("W2"), uWorld)); const bands = bandsByU(splitWallLayers(cp, sampleProject, aw, wall("W2"), uWorld));
expect(bands).toHaveLength(4); expect(bands).toHaveLength(4);
// uMin-Band = Aussenputz (Breite 0.02), uMax-Band = Innenputz (0.015). // Entspiegelte u-Achse: W2s Aussenseite (+x) liegt jetzt bei uMax.
expect(bands[0].width).toBeCloseTo(RENDER_EXT_T, 6);
expect(bands[3].width).toBeCloseTo(RENDER_INT_T, 6);
});
it("W4: gespiegelt — Aussenputz an uMax, Innenputz an uMin", () => {
const bands = bandsByU(splitWallLayers(cp, sampleProject, aw, wall("W4"), uWorld));
expect(bands).toHaveLength(4);
expect(bands[0].width).toBeCloseTo(RENDER_INT_T, 6); expect(bands[0].width).toBeCloseTo(RENDER_INT_T, 6);
expect(bands[3].width).toBeCloseTo(RENDER_EXT_T, 6); expect(bands[3].width).toBeCloseTo(RENDER_EXT_T, 6);
}); });
it("W4: Aussenputz (layers[0]) an uMin, Innenputz an uMax", () => {
const bands = bandsByU(splitWallLayers(cp, sampleProject, aw, wall("W4"), uWorld));
expect(bands).toHaveLength(4);
expect(bands[0].width).toBeCloseTo(RENDER_EXT_T, 6);
expect(bands[3].width).toBeCloseTo(RENDER_INT_T, 6);
});
it("propagiert sourceId (Quell-Element) auf ALLE Schicht-Bänder", () => { it("propagiert sourceId (Quell-Element) auf ALLE Schicht-Bänder", () => {
const cpWithId: SectionCutPolygon = { ...cp, sourceId: "W2" }; const cpWithId: SectionCutPolygon = { ...cp, sourceId: "W2" };
const bands = splitWallLayers(cpWithId, sampleProject, aw, wall("W2"), uWorld); const bands = splitWallLayers(cpWithId, sampleProject, aw, wall("W2"), uWorld);
+12 -10
View File
@@ -49,7 +49,8 @@ function project(r: Roof, extra: Partial<Project> = {}): Project {
} as unknown as Project; } as unknown as Project;
} }
/** Schnittebene: Blick entlang +x, Spur = Modell-Gerade x=0 (u wächst mit y). */ /** Schnittebene: Blick entlang +x (Osten), Spur x=0 Betrachter-RECHTS =
* Süden, u fällt also mit Modell-y (entspiegelte u-Achse). */
const PLANE: SectionPlaneSpec = { point: [0, 0, 0], normal: [1, 0, 0] }; const PLANE: SectionPlaneSpec = { point: [0, 0, 0], normal: [1, 0, 0] };
const emptyOutput = (): SectionOutput => ({ cutPolygons: [], visibleEdges: [], hiddenEdges: [] }); const emptyOutput = (): SectionOutput => ({ cutPolygons: [], visibleEdges: [], hiddenEdges: [] });
@@ -61,10 +62,11 @@ describe("appendRoofSections", () => {
expect(out.cutPolygons.length).toBe(1); expect(out.cutPolygons.length).toBe(1);
const cp = out.cutPolygons[0]; const cp = out.cutPolygons[0];
expect(cp.component).toEqual({ kind: "roof", index: 0 }); expect(cp.component).toEqual({ kind: "roof", index: 0 });
// u-Intervall = Tiefe 0..4; Oberkante 5, Unterkante 5 0.3 (cosθ = 1). // u-Intervall = Tiefe 0..4 → u 4..0 (u = Modell-y); Oberkante 5,
// Unterkante 5 0.3 (cosθ = 1).
const us = cp.pts.map((p) => p[0]).sort((a, b) => a - b); const us = cp.pts.map((p) => p[0]).sort((a, b) => a - b);
expect(us[0]).toBeCloseTo(0, 6); expect(us[0]).toBeCloseTo(-4, 6);
expect(us[3]).toBeCloseTo(4, 6); expect(us[3]).toBeCloseTo(0, 6);
const vs = cp.pts.map((p) => p[1]).sort((a, b) => a - b); const vs = cp.pts.map((p) => p[1]).sort((a, b) => a - b);
expect(vs[3]).toBeCloseTo(5, 6); expect(vs[3]).toBeCloseTo(5, 6);
expect(vs[0]).toBeCloseTo(5 - 0.3, 6); expect(vs[0]).toBeCloseTo(5 - 0.3, 6);
@@ -76,19 +78,19 @@ describe("appendRoofSections", () => {
expect(out.cutPolygons.length).toBe(2); expect(out.cutPolygons.length).toBe(2);
const tan30 = Math.tan((30 * Math.PI) / 180); const tan30 = Math.tan((30 * Math.PI) / 180);
const cos30 = Math.cos((30 * Math.PI) / 180); const cos30 = Math.cos((30 * Math.PI) / 180);
const ridgeZ = 5 + 2 * tan30; // First bei halber Tiefe (y=2) const ridgeZ = 5 + 2 * tan30; // First bei halber Tiefe (y=2 → u=2)
// Alle Oberkanten-Werte je Band: an u=0/4 Traufe (5), an u=2 First. // Alle Oberkanten-Werte je Band: an u=0/4 Traufe (5), an u=2 First.
const topAt = (cp: (typeof out.cutPolygons)[number], u: number): number => const topAt = (cp: (typeof out.cutPolygons)[number], u: number): number =>
Math.max(...cp.pts.filter((p) => Math.abs(p[0] - u) < 1e-6).map((p) => p[1])); Math.max(...cp.pts.filter((p) => Math.abs(p[0] - u) < 1e-6).map((p) => p[1]));
const bands = out.cutPolygons; const bands = out.cutPolygons;
const front = bands.find((b) => b.pts.some((p) => Math.abs(p[0]) < 1e-6)); const front = bands.find((b) => b.pts.some((p) => Math.abs(p[0]) < 1e-6));
const back = bands.find((b) => b.pts.some((p) => Math.abs(p[0] - 4) < 1e-6)); const back = bands.find((b) => b.pts.some((p) => Math.abs(p[0] + 4) < 1e-6));
expect(front).toBeDefined(); expect(front).toBeDefined();
expect(back).toBeDefined(); expect(back).toBeDefined();
expect(topAt(front!, 0)).toBeCloseTo(5, 5); expect(topAt(front!, 0)).toBeCloseTo(5, 5);
expect(topAt(front!, 2)).toBeCloseTo(ridgeZ, 5); expect(topAt(front!, -2)).toBeCloseTo(ridgeZ, 5);
expect(topAt(back!, 4)).toBeCloseTo(5, 5); expect(topAt(back!, -4)).toBeCloseTo(5, 5);
expect(topAt(back!, 2)).toBeCloseTo(ridgeZ, 5); expect(topAt(back!, -2)).toBeCloseTo(ridgeZ, 5);
// Vertikale Banddicke = lotrechte Dicke / cos(Neigung). // Vertikale Banddicke = lotrechte Dicke / cos(Neigung).
const vAt = (cp: (typeof out.cutPolygons)[number], u: number): number[] => const vAt = (cp: (typeof out.cutPolygons)[number], u: number): number[] =>
cp.pts.filter((p) => Math.abs(p[0] - u) < 1e-6).map((p) => p[1]); cp.pts.filter((p) => Math.abs(p[0] - u) < 1e-6).map((p) => p[1]);
+7 -6
View File
@@ -160,16 +160,17 @@ export function sectionPlaneFromLevel(level: DrawingLevel): SectionPlaneSpec | n
* Ebene (Modell-2D: model.x = world.x, model.y = world.z). * Ebene (Modell-2D: model.x = world.x, model.y = world.z).
* *
* Muss EXAKT der u-Achse des WASM-Extraktors entsprechen (`SectionPlane::u_axis` * Muss EXAKT der u-Achse des WASM-Extraktors entsprechen (`SectionPlane::u_axis`
* in section.rs): dort `u = normalize(cross(normal, up))` mit up = (0,1,0). Für * in section.rs): dort `u = normalize(cross(up, normal))` mit up = (0,1,0)
* eine horizontale Normale `[Nx, Ny, Nz]` ist `cross(normal, (0,1,0)) = * das Blickrichtungs-RECHTS des Betrachters (Entspiegelung; wer nach Norden
* (-Nz, 0, Nx)`; die Grundriss-Komponenten (x, z) sind also `(-Nz, Nx)`. Damit * blickt, hat Osten rechts). Für eine horizontale Normale `[Nx, Ny, Nz]` sind
* wächst die Ausgabe-u-Koordinate genau in dieser Modell-Richtung die Referenz, * die Grundriss-Komponenten (x, z) also `(Nz, -Nx)`. (Nicht normiert; für das
* gegen die die Wand-Aufbaurichtung projiziert wird. (Nicht normiert; für das
* Vorzeichen des Skalarprodukts irrelevant.) * Vorzeichen des Skalarprodukts irrelevant.)
*/ */
export function sectionUAxisModel(plane: SectionPlaneSpec): Vec2 { export function sectionUAxisModel(plane: SectionPlaneSpec): Vec2 {
// Blickrichtungs-RECHTS (cross(up, N)) — muss EXAKT `SectionPlane::u_axis`
// in section.rs spiegeln (dort seit der Entspiegelung dieselbe Formel).
const [nx, , nz] = plane.normal; const [nx, , nz] = plane.normal;
return { x: -nz, y: nx }; return { x: nz, y: -nx };
} }
/** /**