Schnitt: Wandschichten nach echter Aussen/Innen-Richtung orientieren
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.
This commit is contained in:
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
+67
-13
@@ -15,9 +15,10 @@
|
|||||||
// Einheiten: METER. Koordinaten der Ausgabe: u = horizontal entlang der Ebene,
|
// Einheiten: METER. Koordinaten der Ausgabe: u = horizontal entlang der Ebene,
|
||||||
// v = absolute Höhe (world.y). Siehe src-tauri/render3d/src/section.rs.
|
// 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 { getCeilingType, getComponent, getWallType, openingsOfWall } from "../model/types";
|
||||||
import { ceilingVerticalExtent, wallVerticalExtent } from "../model/wall";
|
import { ceilingVerticalExtent, wallVerticalExtent } from "../model/wall";
|
||||||
|
import { dot, leftNormal, normalize, sub } from "../model/geometry";
|
||||||
import { openingInterval, openingVerticalExtent } from "../geometry/opening";
|
import { openingInterval, openingVerticalExtent } from "../geometry/opening";
|
||||||
import { projectToModel3d } from "./toWalls3d";
|
import { projectToModel3d } from "./toWalls3d";
|
||||||
import { loadEngine3d } from "../engine/engine3d";
|
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-Polygon → Quell-Bauteil (für die echte Hatch-Auflösung) ────────────
|
||||||
// `cut_section_json` (section.rs) nummeriert jedes Cut-Polygon per
|
// `cut_section_json` (section.rs) nummeriert jedes Cut-Polygon per
|
||||||
// `ComponentRef { kind, index }`, wobei `index` exakt die Position im
|
// `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
|
* `fill`/`hatch` — `generateSectionPlan` fällt dafür auf die generische
|
||||||
* Schnitt-Schraffur zurück.
|
* 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;
|
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 walls = wallSegmentOwners(project);
|
||||||
const slabs = slabSegmentOwners(project);
|
const slabs = slabSegmentOwners(project);
|
||||||
const next: SectionCutPolygon[] = [];
|
const next: SectionCutPolygon[] = [];
|
||||||
@@ -221,7 +267,7 @@ function attachCutStyles(output: SectionOutput, project: Project): void {
|
|||||||
if (owner) {
|
if (owner) {
|
||||||
const wt = getWallType(project, owner);
|
const wt = getWallType(project, owner);
|
||||||
if (wt.layers.length > 1) {
|
if (wt.layers.length > 1) {
|
||||||
next.push(...splitWallLayers(cp, project, wt));
|
next.push(...splitWallLayers(cp, project, wt, owner, uWorld));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,20 +367,24 @@ function splitSlabLayers(
|
|||||||
* Fugen ergeben sich implizit aus den aneinanderstoßenden Rechtecken.
|
* Fugen ergeben sich implizit aus den aneinanderstoßenden Rechtecken.
|
||||||
*
|
*
|
||||||
* Reihenfolge/Orientierung: `WallType.layers` ist von einer Wandseite zur anderen
|
* Reihenfolge/Orientierung: `WallType.layers` ist von einer Wandseite zur anderen
|
||||||
* geordnet. Aus dem hier vorliegenden (u, v)-Rechteck allein lässt sich nicht
|
* geordnet (layers[0] = Aussenseite). Welche u-Seite des Schnittrechtecks der
|
||||||
* eindeutig ableiten, welche u-Seite der „Aussenseite" (layers[0]) entspricht —
|
* Aussenseite entspricht, folgt NICHT aus dem (u, v)-Rechteck allein, sondern aus
|
||||||
* das erforderte die Schnittebene (u→world) plus Wand-Normale. Daher die
|
* der Wandgeometrie + der Schnitt-u-Weltachse `uWorld`: `wallLayersReversedInU`
|
||||||
* deterministische Default-Zuordnung layers[0] → uMin, layers[n] → uMax. Keine
|
* projiziert die Grundriss-Aufbaurichtung (`+leftNormal` der Achse, dieselbe
|
||||||
* Zufalls-/Reihenfolge-Abhängigkeit. Eine Folge-Iteration kann die Seiten aus
|
* Konvention wie `addWallPoche`/generatePlan) auf `uWorld`. Ist das Vorzeichen
|
||||||
* Wandgeometrie + Schnittebene korrekt orientieren.
|
* 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`,
|
* Annahme: rechteckiges Schnittpolygon (min/max-Bounding-Box auf `cp.pts`,
|
||||||
* identisch zu `splitSlabLayers`).
|
* identisch zu `splitSlabLayers`).
|
||||||
*/
|
*/
|
||||||
function splitWallLayers(
|
export function splitWallLayers(
|
||||||
cp: SectionCutPolygon,
|
cp: SectionCutPolygon,
|
||||||
project: Project,
|
project: Project,
|
||||||
wt: ReturnType<typeof getWallType>,
|
wt: ReturnType<typeof getWallType>,
|
||||||
|
owner: Wall,
|
||||||
|
uWorld: Vec2,
|
||||||
): SectionCutPolygon[] {
|
): SectionCutPolygon[] {
|
||||||
const totalT = wt.layers.reduce((s, l) => s + l.thickness, 0);
|
const totalT = wt.layers.reduce((s, l) => s + l.thickness, 0);
|
||||||
const us = cp.pts.map((p) => p[0]);
|
const us = cp.pts.map((p) => p[0]);
|
||||||
@@ -347,9 +397,13 @@ function splitWallLayers(
|
|||||||
if (totalT <= 1e-9 || uSpan <= 1e-9) return [cp];
|
if (totalT <= 1e-9 || uSpan <= 1e-9) return [cp];
|
||||||
const scale = uSpan / totalT;
|
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[] = [];
|
const out: SectionCutPolygon[] = [];
|
||||||
let acc = 0; // kumulierte Dicke von uMin (layers[0]) nach uMax
|
let acc = 0; // kumulierte Dicke von uMin (erste einsortierte Schicht) nach uMax
|
||||||
for (const layer of wt.layers) {
|
for (const layer of layers) {
|
||||||
const comp = getComponent(project, layer.componentId);
|
const comp = getComponent(project, layer.componentId);
|
||||||
const hatch = resolveHatch(project, comp.hatchId, undefined, resolveForeground(comp));
|
const hatch = resolveHatch(project, comp.hatchId, undefined, resolveForeground(comp));
|
||||||
const uLeft = uMin + acc * scale;
|
const uLeft = uMin + acc * scale;
|
||||||
@@ -403,6 +457,6 @@ export async function computeSection(
|
|||||||
plane.normal[2],
|
plane.normal[2],
|
||||||
);
|
);
|
||||||
const output = JSON.parse(json) as SectionOutput;
|
const output = JSON.parse(json) as SectionOutput;
|
||||||
attachCutStyles(output, project);
|
attachCutStyles(output, project, plane);
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user