From b55ab9eb8f6116c09f59961a36668426dad6e8a5 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 4 Jul 2026 01:39:26 +0200 Subject: [PATCH] Schnitt: Wandschichten nach echter Aussen/Innen-Richtung orientieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit splitWallLayers legte die Schichtbaender bisher fix layers[0]->uMin, ohne die tatsaechliche Wandorientierung — gegenueberliegende Waende sahen identisch aus statt gespiegelt. Jetzt wird die Aufbaurichtung aus der Grundriss-Konvention gespiegelt (Schichten stapeln entlang leftNormal der Wandachse), gegen die Schnitt-u-Weltachse (aus der SectionPlane) projiziert; ist das Skalarprodukt negativ, wird die Bandreihenfolge umgekehrt (layers[0]->uMax). Damit liegt die Daemmung aussen, der Backstein innen, und gegenueberliegende Waende sind spiegelbildlich. Neue Helfer sectionUAxisModel/wallLayersReversedInU, additiv durch attachCutStyles durchgereicht. 6 neue Tests, 141 gruen. --- src/plan/toSection.orientation.test.ts | 124 +++++++++++++++++++++++++ src/plan/toSection.ts | 80 +++++++++++++--- 2 files changed, 191 insertions(+), 13 deletions(-) create mode 100644 src/plan/toSection.orientation.test.ts diff --git a/src/plan/toSection.orientation.test.ts b/src/plan/toSection.orientation.test.ts new file mode 100644 index 0000000..9b74ed0 --- /dev/null +++ b/src/plan/toSection.orientation.test.ts @@ -0,0 +1,124 @@ +/** + * Unit-Tests für die Aussen/Innen-Orientierung der mehrschichtigen Wand-Bänder + * im SCHNITT (toSection): die Schicht-Bänder müssen der GRUNDRISS-Konvention + * folgen (layers[0] = Aussenseite auf der `−leftNormal`-Weltseite der Achse), so + * dass die Dämmung aussen und der Backstein innen liegt und zwei gegenüber- + * liegende Wände im Schnitt SPIEGELVERKEHRT erscheinen (nicht identisch). + * + * Getestet ohne WASM: die Orientierungs-Hilfsfunktionen (`sectionUAxisModel`, + * `wallLayersReversedInU`) und `splitWallLayers` mit einem synthetischen + * Schnittrechteck gegen den echten „aw"-Wandtyp des Sample-Projekts. + */ + +import { describe, it, expect } from "vitest"; +import { + sectionPlaneFromLevel, + sectionUAxisModel, + wallLayersReversedInU, + splitWallLayers, + type SectionCutPolygon, +} from "./toSection"; +import { sampleProject } from "../model/sampleProject"; +import { getWallType } from "../model/types"; +import type { Wall } from "../model/types"; + +/** Wand aus dem Sample-Projekt per ID. */ +function wall(id: string): Wall { + const w = sampleProject.walls.find((x) => x.id === id); + if (!w) throw new Error(`Wand ${id} fehlt im Sample-Projekt`); + return w; +} + +/** Achsparalleles Schnittrechteck (u, v): uMin..uMax × 0..2.6 m. */ +function rect(uMin: number, uMax: number): SectionCutPolygon { + return { + component: { kind: "wall", index: 0 }, + color: [0.5, 0.5, 0.5], + pts: [ + [uMin, 2.6], + [uMax, 2.6], + [uMax, 0], + [uMin, 0], + ], + }; +} + +/** Bänder nach uLeft (linke Kante) aufsteigend, mit ihrer u-Breite. */ +function bandsByU(polys: SectionCutPolygon[]): Array<{ uLeft: number; width: number }> { + return polys + .map((p) => { + const us = p.pts.map((pt) => pt[0]); + // Auf mm runden — glättet die Float-Akkumulation für stabile Vergleiche. + const width = Math.round((Math.max(...us) - Math.min(...us)) * 1e6) / 1e6; + return { uLeft: Math.min(...us), width }; + }) + .sort((a, b) => a.uLeft - b.uLeft); +} + +// Schnitt A: horizontale Linie y=2 (x −1…6), quer durch W2 (x=5) und W4 (x=0). +const planeA = sectionPlaneFromLevel( + sampleProject.drawingLevels.find((l) => l.id === "schnitt-a")!, +)!; + +// „aw" = [render-ext(0.02) · insulation(0.16) · brick(0.15) · render-int(0.015)]. +const aw = getWallType(sampleProject, wall("W2")); +const RENDER_EXT_T = 0.02; // Aussenputz — layers[0], die Aussenseite +const RENDER_INT_T = 0.015; // Innenputz — letzte Schicht, die Innenseite + +describe("Schnitt-u-Weltachse", () => { + it("entspricht der WASM-Konvention u = cross(normal, up) → Grundriss (−Nz, Nx)", () => { + // Schnitt A: Ebenennormale world = (0,0,1) → uWorld model = (−1, 0). + expect(planeA.normal[0]).toBeCloseTo(0, 9); + expect(planeA.normal[1]).toBeCloseTo(0, 9); + expect(planeA.normal[2]).toBeCloseTo(1, 9); + const u = sectionUAxisModel(planeA); + expect(u.x).toBeCloseTo(-1, 9); + expect(u.y).toBeCloseTo(0, 9); + }); +}); + +describe("Wand-Aufbaurichtung vs. Schnitt-u-Achse", () => { + const uWorld = sectionUAxisModel(planeA); + + it("W2 (Aussenseite +x) nicht gespiegelt, W4 (Aussenseite −x) gespiegelt", () => { + // Beide Wände gehören zum CCW-Umriss; die linke Normale zeigt nach innen. + // Gegenüberliegende Wände ⇒ genau eine der beiden wird gespiegelt. + expect(wallLayersReversedInU(wall("W2"), uWorld)).toBe(false); + expect(wallLayersReversedInU(wall("W4"), uWorld)).toBe(true); + }); + + it("degenerierte Wand (Länge 0) bleibt unverändert (kein Spiegeln)", () => { + const dot: Wall = { ...wall("W2"), end: { ...wall("W2").start } }; + expect(wallLayersReversedInU(dot, uWorld)).toBe(false); + }); +}); + +describe("splitWallLayers: Aussenputz auf der Aussenseite, Wände spiegelverkehrt", () => { + const uWorld = sectionUAxisModel(planeA); + const cp = rect(0, 0.345); // volle Wanddicke, Skala 1:1 + + it("W2: Aussenputz (layers[0]) an uMin, Innenputz an uMax", () => { + const bands = bandsByU(splitWallLayers(cp, sampleProject, aw, wall("W2"), uWorld)); + expect(bands).toHaveLength(4); + // uMin-Band = Aussenputz (Breite 0.02), uMax-Band = Innenputz (0.015). + 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[3].width).toBeCloseTo(RENDER_EXT_T, 6); + }); + + it("W2 und W4 liegen spiegelverkehrt (nicht identisch)", () => { + const b2 = bandsByU(splitWallLayers(cp, sampleProject, aw, wall("W2"), uWorld)); + const b4 = bandsByU(splitWallLayers(cp, sampleProject, aw, wall("W4"), uWorld)); + // Reihenfolge der Bandbreiten von uMin→uMax ist exakt umgekehrt. + const w2 = b2.map((b) => b.width); + const w4 = b4.map((b) => b.width); + expect(w2).not.toEqual(w4); + expect(w2).toEqual([...w4].reverse()); + }); +}); diff --git a/src/plan/toSection.ts b/src/plan/toSection.ts index 62a05da..3bd9653 100644 --- a/src/plan/toSection.ts +++ b/src/plan/toSection.ts @@ -15,9 +15,10 @@ // Einheiten: METER. Koordinaten der Ausgabe: u = horizontal entlang der Ebene, // v = absolute Höhe (world.y). Siehe src-tauri/render3d/src/section.rs. -import type { Ceiling, DrawingLevel, Project, Wall } from "../model/types"; +import type { Ceiling, DrawingLevel, Project, Vec2, Wall } from "../model/types"; import { getCeilingType, getComponent, getWallType, openingsOfWall } from "../model/types"; import { ceilingVerticalExtent, wallVerticalExtent } from "../model/wall"; +import { dot, leftNormal, normalize, sub } from "../model/geometry"; import { openingInterval, openingVerticalExtent } from "../geometry/opening"; import { projectToModel3d } from "./toWalls3d"; import { loadEngine3d } from "../engine/engine3d"; @@ -116,6 +117,48 @@ export function sectionPlaneFromLevel(level: DrawingLevel): SectionPlaneSpec | n }; } +/** + * Die in-plane-u-Weltrichtung der Schnittebene, projiziert in die Grundriss- + * Ebene (Modell-2D: model.x = world.x, model.y = world.z). + * + * 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 + * eine horizontale Normale `[Nx, Ny, Nz]` ist `cross(normal, (0,1,0)) = + * (-Nz, 0, Nx)`; die Grundriss-Komponenten (x, z) sind also `(-Nz, Nx)`. Damit + * wächst die Ausgabe-u-Koordinate genau in dieser Modell-Richtung — die Referenz, + * gegen die die Wand-Aufbaurichtung projiziert wird. (Nicht normiert; für das + * Vorzeichen des Skalarprodukts irrelevant.) + */ +export function sectionUAxisModel(plane: SectionPlaneSpec): Vec2 { + const [nx, , nz] = plane.normal; + return { x: -nz, y: nx }; +} + +/** + * Ob die Schicht-Bänder einer Wand im Schnitt gegenüber der Default-Zuordnung + * (layers[0] → uMin) umzukehren sind, damit sie mit der GRUNDRISS-Poché + * übereinstimmen (Quelle der Wahrheit: `addWallPoche`/generatePlan). + * + * Konvention aus dem Grundriss: die Schichten stapeln entlang der linken Normalen + * `n = leftNormal(u)` der Wandachse `u = normalize(end − start)`, von layers[0] + * am kleinsten Offset (−T/2, `−n`-Seite) zu layers[n] am größten Offset (`+n`- + * Seite). Die Aufbaurichtung layers[0] → layers[n] zeigt also nach `+n`. Der + * Referenzlinien-Versatz (`wallReferenceOffset`) verschiebt nur die absolute + * Lage, nicht diese Reihenfolge — daher hier ohne Belang. + * + * `splitWallLayers` legt layers[0] per Default an uMin (u wächst entlang der + * Schnitt-u-Achse `uWorld`). Zeigt die Aufbaurichtung `+n` GEGEN `uWorld` + * (Skalarprodukt < 0), muss die Band-Reihenfolge gespiegelt werden, damit + * layers[0] auf der korrekten Weltseite (z. B. Aussenputz aussen) liegt und + * gegenüberliegende Wände spiegelverkehrt erscheinen. + */ +export function wallLayersReversedInU(owner: Wall, uWorld: Vec2): boolean { + const axis = sub(owner.end, owner.start); + if (Math.hypot(axis.x, axis.y) < 1e-9) return false; + const buildDir = leftNormal(normalize(axis)); // +n: layers[0] → layers[n] + return dot(buildDir, uWorld) < 0; +} + // ── Cut-Polygon → Quell-Bauteil (für die echte Hatch-Auflösung) ──────────── // `cut_section_json` (section.rs) nummeriert jedes Cut-Polygon per // `ComponentRef { kind, index }`, wobei `index` exakt die Position im @@ -204,8 +247,11 @@ export function slabSegmentOwners(project: Project): Ceiling[] { * `fill`/`hatch` — `generateSectionPlan` fällt dafür auf die generische * Schnitt-Schraffur zurück. */ -function attachCutStyles(output: SectionOutput, project: Project): void { +function attachCutStyles(output: SectionOutput, project: Project, plane: SectionPlaneSpec): void { if (output.cutPolygons.length === 0) return; + // Schnitt-u-Weltachse (Modell-2D) — Referenz für die Aussen/Innen-Orientierung + // der mehrschichtigen Wand-Bänder (siehe `wallLayersReversedInU`). + const uWorld = sectionUAxisModel(plane); const walls = wallSegmentOwners(project); const slabs = slabSegmentOwners(project); const next: SectionCutPolygon[] = []; @@ -221,7 +267,7 @@ function attachCutStyles(output: SectionOutput, project: Project): void { if (owner) { const wt = getWallType(project, owner); if (wt.layers.length > 1) { - next.push(...splitWallLayers(cp, project, wt)); + next.push(...splitWallLayers(cp, project, wt, owner, uWorld)); continue; } } @@ -321,20 +367,24 @@ function splitSlabLayers( * Fugen ergeben sich implizit aus den aneinanderstoßenden Rechtecken. * * Reihenfolge/Orientierung: `WallType.layers` ist von einer Wandseite zur anderen - * geordnet. Aus dem hier vorliegenden (u, v)-Rechteck allein lässt sich nicht - * eindeutig ableiten, welche u-Seite der „Aussenseite" (layers[0]) entspricht — - * das erforderte die Schnittebene (u→world) plus Wand-Normale. Daher die - * deterministische Default-Zuordnung layers[0] → uMin, layers[n] → uMax. Keine - * Zufalls-/Reihenfolge-Abhängigkeit. Eine Folge-Iteration kann die Seiten aus - * Wandgeometrie + Schnittebene korrekt orientieren. + * geordnet (layers[0] = Aussenseite). Welche u-Seite des Schnittrechtecks der + * Aussenseite entspricht, folgt NICHT aus dem (u, v)-Rechteck allein, sondern aus + * der Wandgeometrie + der Schnitt-u-Weltachse `uWorld`: `wallLayersReversedInU` + * projiziert die Grundriss-Aufbaurichtung (`+leftNormal` der Achse, dieselbe + * Konvention wie `addWallPoche`/generatePlan) auf `uWorld`. Ist das Vorzeichen + * negativ, wird die Band-Reihenfolge gespiegelt (layers[0] → uMax statt uMin), so + * dass Schnitt und Grundriss übereinstimmen und gegenüberliegende Wände spiegel- + * verkehrt erscheinen (Dämmung aussen, Backstein innen). * * Annahme: rechteckiges Schnittpolygon (min/max-Bounding-Box auf `cp.pts`, * identisch zu `splitSlabLayers`). */ -function splitWallLayers( +export function splitWallLayers( cp: SectionCutPolygon, project: Project, wt: ReturnType, + owner: Wall, + uWorld: Vec2, ): SectionCutPolygon[] { const totalT = wt.layers.reduce((s, l) => s + l.thickness, 0); const us = cp.pts.map((p) => p[0]); @@ -347,9 +397,13 @@ function splitWallLayers( if (totalT <= 1e-9 || uSpan <= 1e-9) return [cp]; const scale = uSpan / totalT; + // Aussen/Innen-Orientierung: per Default liegt layers[0] an uMin; zeigt die + // Wand-Aufbaurichtung gegen die Schnitt-u-Achse, werden die Bänder gespiegelt. + const layers = wallLayersReversedInU(owner, uWorld) ? [...wt.layers].reverse() : wt.layers; + const out: SectionCutPolygon[] = []; - let acc = 0; // kumulierte Dicke von uMin (layers[0]) nach uMax - for (const layer of wt.layers) { + let acc = 0; // kumulierte Dicke von uMin (erste einsortierte Schicht) nach uMax + for (const layer of layers) { const comp = getComponent(project, layer.componentId); const hatch = resolveHatch(project, comp.hatchId, undefined, resolveForeground(comp)); const uLeft = uMin + acc * scale; @@ -403,6 +457,6 @@ export async function computeSection( plane.normal[2], ); const output = JSON.parse(json) as SectionOutput; - attachCutStyles(output, project); + attachCutStyles(output, project, plane); return output; }