3D: Wand an Deckenunterkante trimmen (Prioritaets-Trim, kein Z-Fighting)

Wo eine Decke (hoehere joinPriority, z.B. Beton 100) buendig auf der
Wandoberkante sitzt, durchdrangen sich Wand- und Deckenvolumen exakt
ueber die Deckendicke -> Z-Fighting im 3D-Viewport. emitWall trimmt jetzt
zTop der Wand auf die Deckenunterkante, wenn eine Decke gleichen
Geschosses mit Footprint-Ueberlapp hoehere Prioritaet hat. Footprint-Test
ist eine Bounding-Box-Naeherung (dokumentiert). Rein TS-seitig vor dem
RWall-Export -> kein WASM-Rebuild. +3 Tests (208 gesamt).
This commit is contained in:
2026-07-04 14:02:22 +02:00
parent 32497bcfab
commit e4b8df6ae8
2 changed files with 198 additions and 2 deletions
+45
View File
@@ -155,6 +155,51 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
}); });
}); });
describe("Prioritäts-Trim Wand/Decke (Z-Fighting-Fix, s. trimWallTopForCeilings)", () => {
// Sample-Projekt: W1-W4 (EG, Wandtyp "aw", dominante Schicht "brick"
// joinPriority 50) tragen exakt Wandhöhe 2.6 m = Geschosshöhe -> zTop 2.6.
// Decke C1 (EG, Deckentyp "dg-massiv", dominante Schicht "concrete"
// joinPriority 100) hat OK bündig mit dem Wandkopf (zTop 2.6) und
// Gesamtdicke 0.3 m (screed .06 + insulation .04 + concrete .2) -> zBottom
// 2.3. Vor dem Trim überlappten sich Wand- und Deckenvolumen exakt über
// [2.3..2.6] im ganzen Wand-Footprint (Z-Fighting). Nach dem Trim endet die
// Wand an der Deckenunterkante, weil die Decke die höhere joinPriority hat.
it("Decke mit höherer joinPriority kappt die Wand an ihrer Unterkante", () => {
const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2");
expect(w2.length).toBeGreaterThan(0);
for (const seg of w2) {
expect(seg.baseElevation + seg.height).toBeCloseTo(2.3, 6);
}
});
it("kein Trim, wenn die Wand-Priorität >= Decken-Priorität ist", () => {
// Deckentyp-Priorität (concrete) auf 40 senken -> unter der Wand-Priorität
// (brick 50) -> die Decke gewinnt nicht mehr, die Wand bleibt unverändert
// auf voller Höhe (2.6 m, wie ohne Trim-Logik).
const p: Project = {
...sampleProject,
components: sampleProject.components.map((c) =>
c.id === "concrete" ? { ...c, joinPriority: 40 } : c,
),
};
const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2");
expect(w2.length).toBeGreaterThan(0);
for (const seg of w2) {
expect(seg.baseElevation + seg.height).toBeCloseTo(2.6, 6);
}
});
it("kein Trim, wenn keine Decke im selben Geschoss überlappt (z.B. OG-Wände ohne Dach-Decke)", () => {
// W6 (OG) hat keine überlappende Decke im Sample-Projekt (C1 ist EG) ->
// unverändertes Verhalten (volle wall.height).
const w6 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W6");
expect(w6.length).toBeGreaterThan(0);
for (const seg of w6) {
expect(seg.height).toBeCloseTo(2.6, 6);
}
});
});
describe("Bauteil-/Materialfarbe Decke", () => { describe("Bauteil-/Materialfarbe Decke", () => {
it("Decke -> Farbe der dicksten Schicht (dg-massiv: concrete 0.2 m, #9aa0a6)", () => { it("Decke -> Farbe der dicksten Schicht (dg-massiv: concrete 0.2 m, #9aa0a6)", () => {
// Deckentyp "dg-massiv": screed 0.06, insulation 0.04, concrete 0.2 -> // Deckentyp "dg-massiv": screed 0.06, insulation 0.04, concrete 0.2 ->
+153 -2
View File
@@ -32,7 +32,7 @@
// Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle // Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle
// Dicke im neutralen Grundton (heutiges Fallback-Verhalten). // Dicke im neutralen Grundton (heutiges Fallback-Verhalten).
import type { Project, Wall, Layer } from "../model/types"; import type { Project, Wall, Ceiling, Layer } from "../model/types";
import { import {
getComponent, getComponent,
getWallType, getWallType,
@@ -257,10 +257,161 @@ function pushSegment(
}); });
} }
/**
* Achsenparalleles Grundriss-Rechteck (min/max je Achse), Meter, Modell-XY.
* Dient hier NUR der groben Footprint-Überlapp-Prüfung Wand↔Decke (s.
* {@link trimWallTopForCeilings}) — kein exakter Polygon-Test, siehe dort.
*/
interface Bounds2 {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
/** Bounding-Box der Wandachse, um `halfThickness` in beide Richtungen geweitet
* (sichere Über-Approximation des tatsächlichen Wandkörpers, unabhängig von
* dessen Orientierung). */
function wallFootprintBounds(wall: Wall, halfThickness: number): Bounds2 {
return {
minX: Math.min(wall.start.x, wall.end.x) - halfThickness,
maxX: Math.max(wall.start.x, wall.end.x) + halfThickness,
minY: Math.min(wall.start.y, wall.end.y) - halfThickness,
maxY: Math.max(wall.start.y, wall.end.y) + halfThickness,
};
}
/** Bounding-Box eines geschlossenen Umrisses (Decke). */
function outlineBounds(outline: Array<{ x: number; y: number }>): Bounds2 {
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const p of outline) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
return { minX, maxX, minY, maxY };
}
/** Überlappen sich zwei Bounding-Boxen (mit EPS-Toleranz an den Rändern)? */
function boundsOverlap(a: Bounds2, b: Bounds2): boolean {
return (
a.minX <= b.maxX + EPS &&
a.maxX >= b.minX - EPS &&
a.minY <= b.maxY + EPS &&
a.maxY >= b.minY - EPS
);
}
/**
* Dicke einer Wand für die Footprint-Näherung (s. {@link wallFootprintBounds}):
* Summe der WallType-Schichtdicken, sonst der Fallback-Grundton-Dicke aus
* {@link resolveWallBands} (0.2 m), falls kein WallType auflösbar ist.
*/
function wallThicknessSafe(project: Project, wall: Wall): number {
try {
return wallTypeThickness(getWallType(project, wall));
} catch {
return 0.2;
}
}
/**
* Repräsentative `joinPriority` einer Wand (höchste ihrer Schichten) — analog
* `wallRepresentativePriority` in `toSection.ts` (2D-Schnitt-Dominanz), hier
* fürs 3D-Trim gegen überlappende Decken (s. {@link trimWallTopForCeilings}).
* `undefined`, wenn kein WallType/keine Schicht auflösbar ist (dann findet nie
* ein Trim statt — konservativ, kein Verhaltensbruch für unbekannte Typen).
*/
function wallDominantPriority(project: Project, wall: Wall): number | undefined {
try {
const wt = getWallType(project, wall);
let best: number | undefined;
for (const layer of wt.layers) {
const p = getComponent(project, layer.componentId).joinPriority;
if (best === undefined || p > best) best = p;
}
return best;
} catch {
return undefined;
}
}
/**
* Repräsentative `joinPriority` einer Decke (höchste ihrer Schichten, analog
* {@link wallDominantPriority}) — bestimmt, ob die Decke am Wand/Decke-Stoss
* "durchläuft" (gewinnt) und die Wand kappt.
*/
function ceilingDominantPriority(project: Project, ceiling: Ceiling): number | undefined {
try {
const ct = getCeilingType(project, ceiling);
let best: number | undefined;
for (const layer of ct.layers) {
const p = getComponent(project, layer.componentId).joinPriority;
if (best === undefined || p > best) best = p;
}
return best;
} catch {
return undefined;
}
}
/**
* Kappt `zTop` einer Wand an der Unterkante jeder überlappenden Decke mit
* HÖHERER `joinPriority` (Prioritäts-Join, Phase 3 — behebt das Wand/Decke-
* Z-Fighting aus `wallVerticalExtent`/`ceilingVerticalExtent`, s. Moduldoc):
* wächst `wall.height` bis zur Deckenoberkante (Standardfall, häufig bündig
* mit dem Geschoss-Wandkopf) und die Decke wächst vom selben Z nach unten, so
* überlappen sich beide Volumen exakt über die Deckendicke im Wand-Footprint.
* Gewinnt die Decke (höhere Priorität), endet die Wand VOR der Deckenunterkante
* statt sie zu durchdringen.
*
* Kriterien je Decke (alle müssen zutreffen, sonst kein Effekt):
* • dasselbe Geschoss (`ceiling.floorId === wall.floorId`),
* • vertikale Überlappung mit der (ungekappten) Wand-Ausdehnung,
* • grober Footprint-Überlapp (Bounding-Box, s. {@link boundsOverlap}) —
* KEIN exakter Polygon-Test; eine Decke, deren Umriss die Wand nur knapp
* berührt/verfehlt, kann so fälschlich als überlappend gelten. Das ist eine
* bekannte Restlücke (siehe Moduldoc-Kommentar in dieser Datei): für den
* Regelfall (Decken-Umriss = Gebäude-/Raumgrundriss inkl. Wand-Footprints)
* ist die Bounding-Box exakt genug, um das Haupt-Z-Fighting zu beheben.
* • Decke hat eine HÖHERE (strikt) repräsentative `joinPriority` als die Wand.
*
* Bei mehreren qualifizierenden Decken gewinnt die NIEDRIGSTE Deckenunterkante
* (kappt am weitesten). Ohne auflösbare Wand-Priorität (unbekannter WallType)
* bleibt `zTop` unverändert (konservativ, wie heute).
*/
function trimWallTopForCeilings(
project: Project,
wall: Wall,
zBottom: number,
zTop: number,
): number {
const wallPriority = wallDominantPriority(project, wall);
if (wallPriority === undefined) return zTop;
const halfThickness = wallThicknessSafe(project, wall) / 2;
const wallBounds = wallFootprintBounds(wall, halfThickness);
let top = zTop;
for (const c of project.ceilings ?? []) {
if (c.floorId !== wall.floorId) continue;
if (!c.outline || c.outline.length < 3) continue;
const cExtent = ceilingVerticalExtent(project, c);
// Keine vertikale Überlappung mit der (ungekappten) Wand-Ausdehnung -> Decke
// liegt komplett ausserhalb des Wandkörpers, betrifft ihn nicht.
if (cExtent.zBottom >= zTop - EPS || cExtent.zTop <= zBottom + EPS) continue;
const ceilingPriority = ceilingDominantPriority(project, c);
if (ceilingPriority === undefined || ceilingPriority <= wallPriority) continue;
if (!boundsOverlap(wallBounds, outlineBounds(c.outline))) continue;
if (cExtent.zBottom < top) top = cExtent.zBottom;
}
return Math.max(zBottom, top);
}
/** Emittiert die Teilquader EINER Wand (mit oder ohne Öffnungen). */ /** Emittiert die Teilquader EINER Wand (mit oder ohne Öffnungen). */
function emitWall(out: RWall[], project: Project, wall: Wall): void { function emitWall(out: RWall[], project: Project, wall: Wall): void {
const bands = resolveWallBands(project, wall); const bands = resolveWallBands(project, wall);
const { zBottom, zTop } = wallVerticalExtent(project, wall); const { zBottom, zTop: rawZTop } = wallVerticalExtent(project, wall);
const zTop = trimWallTopForCeilings(project, wall, zBottom, rawZTop);
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y); const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9 || zTop - zBottom <= EPS) return; if (axisLen < 1e-9 || zTop - zBottom <= EPS) return;