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:
@@ -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", () => {
|
||||
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 ->
|
||||
|
||||
+153
-2
@@ -32,7 +32,7 @@
|
||||
// Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle
|
||||
// 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 {
|
||||
getComponent,
|
||||
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). */
|
||||
function emitWall(out: RWall[], project: Project, wall: Wall): void {
|
||||
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);
|
||||
if (axisLen < 1e-9 || zTop - zBottom <= EPS) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user