3D-Wände: Top folgt der Dach-Unterkante statt sie zu durchdringen (Z-Fighting-Fix)

Wände wurden bisher unabhängig von Dächern mit fixer Höhe emittiert, während
Dächer separat gerendert wurden — wo eine geneigte Dachfläche den flachen
Wand-Top kreuzte, überlappten sich beide Volumen (Z-Fighting im 3D-Viewer,
Nutzer-Report mit Screenshot).

roofUndersideAt (geometry/roof.ts) liefert die Dach-Unterkante an einem
Grundriss-Punkt (Ebenengleichung je Dachfläche, dieselbe Herleitung wie im
Vertikalschnitt). clipPieceToRoofs (toWalls3d.ts) zerlegt betroffene Wand-
Achsenstücke in feine Schritte (~15 cm) und klemmt jeden auf die dort lokal
gemessene Dach-Unterkante — eine Treppenstufen-Annäherung an eine echte
geneigte Giebelwand-Stirnfläche (render3d-Wandkörper haben nur einen flachen
Top; eine echte Schrägfläche bräuchte einen neuen Mesh-Pfad). Ohne Dach im
selben Geschoss bleibt das Verhalten unverändert (kein Overhead).
This commit is contained in:
2026-07-12 18:15:22 +02:00
parent 3d66dc8967
commit a5849111e3
4 changed files with 272 additions and 10 deletions
+29 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { roofGeometry, roofBBox } from "./roof"; import { roofGeometry, roofBBox, roofUndersideAt } from "./roof";
import type { Roof, Vec2 } from "../model/types"; import type { Roof, Vec2 } from "../model/types";
// Rechteck 6×4 (x:0..6, y:0..4), Neigung 45° (tan=1), Traufhöhe 10, kein Überstand. // Rechteck 6×4 (x:0..6, y:0..4), Neigung 45° (tan=1), Traufhöhe 10, kein Überstand.
@@ -181,3 +181,31 @@ describe("roofGeometry — Überstand", () => {
expect(bb).toEqual({ x0: -0.4, y0: -0.4, x1: 6.4, y1: 4.4 }); expect(bb).toEqual({ x0: -0.4, y0: -0.4, x1: 6.4, y1: 4.4 });
}); });
}); });
describe("roofUndersideAt — Dach-Unterkante an einem Grundriss-Punkt", () => {
// Sattel 6×4, First entlang X bei yc=2, Neigung 45° (tan=1) -> Firsthöhe 2.
// Fläche 1 (y0=0..yc=2): z(x,y) = 10 + y (linear, unabhängig von x).
const g = roofGeometry(roof({ shape: "sattel" }), E);
it("an der Traufe (y=0): Oberkante 10, Unterkante minus Schichtdicke", () => {
expect(roofUndersideAt(g, 0, 3, 0)).toBeCloseTo(10, 6);
expect(roofUndersideAt(g, 0.2, 3, 0)).toBeCloseTo(9.8, 6);
});
it("am First (y=2): Oberkante 12 (Firsthöhe 10+2)", () => {
expect(roofUndersideAt(g, 0, 3, 2)).toBeCloseTo(12, 6);
});
it("interpoliert linear zwischen Traufe und First (y=1 -> 11)", () => {
expect(roofUndersideAt(g, 0, 3, 1)).toBeCloseTo(11, 6);
});
it("ausserhalb des Dach-Umrisses -> null", () => {
expect(roofUndersideAt(g, 0, 3, -1)).toBeNull();
expect(roofUndersideAt(g, 0, 8, 1)).toBeNull();
});
it("negative/fehlende Schichtdicke wird auf 0 geklemmt (keine Anhebung)", () => {
expect(roofUndersideAt(g, -0.5, 3, 0)).toBeCloseTo(10, 6);
});
});
+52
View File
@@ -362,3 +362,55 @@ export function roofGeometry(roof: Roof, eavesZ: number): RoofGeometry {
ridgeHeight: g.ridgeHeight, ridgeHeight: g.ridgeHeight,
}; };
} }
/** Punkt-in-Polygon (Ray-Casting), 2D-Aufsicht. */
function pointInPolygon2D(p: Vec2, poly: Vec2[]): boolean {
let inside = false;
const n = poly.length;
for (let i = 0, j = n - 1; i < n; j = i++) {
const a = poly[i];
const b = poly[j];
const intersect =
a.y > p.y !== b.y > p.y &&
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-12) + a.x;
if (intersect) inside = !inside;
}
return inside;
}
/**
* Dach-UNTERKANTE (Innenseite der untersten Schicht) an einem Grundriss-Punkt
* (x,y) — `null`, wenn der Punkt ausserhalb aller Dachflächen liegt (Aufsicht).
* Ebenengleichung je Fläche über die Newell-Normale (identische Herleitung wie
* `toSection.ts::appendRoofSections`, dort inline für den Vertikalschnitt) minus
* `totalLayerThickness` (Summe aller Dachschicht-Dicken, s. `roofLayers`).
* Dient dem Wand-Zuschnitt unter geneigten Dächern ({@link trimWallTopForRoofs}
* in `toWalls3d.ts`) — vermeidet, dass ein flacher Wand-Top die Dachfläche
* durchdringt (Z-Fighting).
*/
export function roofUndersideAt(
geo: RoofGeometry,
totalLayerThickness: number,
x: number,
y: number,
): number | null {
for (const pl of geo.planes) {
const pts = pl.pts;
if (pts.length < 3) continue;
if (!pointInPolygon2D({ x, y }, pts.map((p) => ({ x: p[0], y: p[1] })))) continue;
let A = 0;
let B = 0;
let C = 0;
for (let i = 0; i < pts.length; i++) {
const c = pts[i];
const d = pts[(i + 1) % pts.length];
A += (c[1] - d[1]) * (c[2] + d[2]);
B += (c[2] - d[2]) * (c[0] + d[0]);
C += (c[0] - d[0]) * (c[1] + d[1]);
}
if (Math.abs(C) < 1e-9) continue; // (nahezu) vertikale Fläche
const topZ = pts[0][2] - (A * (x - pts[0][0]) + B * (y - pts[0][1])) / C;
return topZ - Math.max(0, totalLayerThickness);
}
return null;
}
+111 -4
View File
@@ -6,7 +6,7 @@
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import type { Project, Roof } from "../model/types"; import type { Project, Roof, Wall } from "../model/types";
import { sampleProject } from "../model/sampleProject"; import { sampleProject } from "../model/sampleProject";
import { selectionHighlightLines, projectToWalls3d, projectToModel3d, pickGeometry } from "./toWalls3d"; import { selectionHighlightLines, projectToWalls3d, projectToModel3d, pickGeometry } from "./toWalls3d";
import { resolveHatch } from "./generatePlan"; import { resolveHatch } from "./generatePlan";
@@ -1282,17 +1282,124 @@ describe("Per-Schicht-Decken-Dominanz Wand/Decke (3D-Entsprechung der Schnitt-Bo
expect(insul[0].baseElevation + insul[0].height).toBeCloseTo(2.6, 6); expect(insul[0].baseElevation + insul[0].height).toBeCloseTo(2.6, 6);
}); });
it("keine Subtraktion, wenn keine Decke im selben Geschoss überlappt (z.B. OG-Wände ohne Dach-Decke)", () => { it("keine Deckensubtraktion, 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) -> // W6 (OG) hat keine überlappende Decke im Sample-Projekt (C1 ist EG) -> keine
// unverändertes Verhalten (volle wall.height). // Deckenbedingte z-Aufteilung. Volle wall.height, AUSSER das Sample-Projekt
// hat auch ein Dach (RF1) über W6 (s. "Dach-Wand-Verschneidung" unten) — eine
// an der Gehrungsecke leicht über die Achse hinaus verlängerte, hauchdünne
// Putzschicht (0.02 m) reicht dort minimal unter den Dachüberstand und wird
// korrekt (nicht durch die Decken-, sondern die Dach-Logik) leicht gekappt.
const w6 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W6"); const w6 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W6");
expect(w6.length).toBeGreaterThan(0); expect(w6.length).toBeGreaterThan(0);
const offHeight = w6.filter((seg) => Math.abs(seg.height - 2.6) > 1e-6);
expect(offHeight.length).toBe(1);
expect(offHeight[0].thickness).toBeCloseTo(0.02, 6); // die dünne Putz-Gehrungsspitze
expect(offHeight[0].height).toBeLessThan(2.6);
expect(offHeight[0].height).toBeGreaterThan(2.5); // nur ein paar cm gekappt
for (const seg of w6) { for (const seg of w6) {
if (seg === offHeight[0]) continue;
expect(seg.height).toBeCloseTo(2.6, 6); expect(seg.height).toBeCloseTo(2.6, 6);
} }
}); });
}); });
describe("Dach-Wand-Verschneidung (clipPieceToRoofs) — Wand-Top folgt der Dach-Unterkante", () => {
// Giebelwand entlang Y (0..4), unter einem Satteldach (First entlang X bei
// y=2, Traufhöhe 3, Neigung 30°, Dicke 0.1). Wandhöhe 3.0 ⇒ an der Traufe
// (y=0/4) reicht die Wand exakt bis zur Dachfläche (baseElevation=3), muss
// dort also um die Dachdicke (0.1) gekappt werden; am First (y=2) liegt die
// Dachunterkante weit darüber ⇒ unverändert volle Höhe.
const gableRoof: Roof = {
id: "RG",
type: "roof",
floorId: "eg",
categoryCode: "35",
outline: [
{ x: 0, y: 0 },
{ x: 6, y: 0 },
{ x: 6, y: 4 },
{ x: 0, y: 4 },
],
shape: "sattel",
pitchDeg: 30,
overhang: 0,
ridgeAxis: "x",
baseElevation: 3,
thickness: 0.1,
};
const gableWall: Wall = {
id: "WG",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 3, y: 0 },
end: { x: 3, y: 4 },
wallTypeId: sampleProject.walls[0].wallTypeId,
height: 3.0,
};
it("kappt die Wand nahe der Traufe, lässt sie am First unverändert", () => {
const project: Project = {
...sampleProject,
walls: [gableWall],
roofs: [gableRoof],
doors: [],
openings: [],
ceilings: [],
};
const walls = projectToWalls3d(project).filter((w) => w.wallId === "WG");
expect(walls.length).toBeGreaterThan(0);
// EXAKT an der Traufe (start.y = 0 bzw. end nahe 4): das erste ~0.15-m-
// Achsenstück ist geklemmt (Dachunterkante dort knapp unter 3.0, s. o.); die
// Neigung steigt schnell genug, dass bereits das ZWEITE Achsenstück wieder
// unbeeinflusst ist — bewusst NUR das direkt an y=0 anliegende Stück prüfen.
const atEave = walls.filter((s) => s.start[1] < 1e-6 || s.end[1] > 4 - 1e-6);
expect(atEave.length).toBeGreaterThan(0);
for (const seg of atEave) {
expect(seg.height).toBeLessThan(3.0 - 0.05);
expect(seg.height).toBeGreaterThan(2.8); // nicht mehr als die Dachdicke gekappt
}
// Am First (y nahe 2): unverändert volle Höhe (Dachunterkante liegt weit darüber).
const nearRidge = walls.filter((s) => s.start[1] > 1.8 && s.start[1] < 2.2);
expect(nearRidge.length).toBeGreaterThan(0);
for (const seg of nearRidge) {
expect(seg.height).toBeCloseTo(3.0, 6);
}
});
it("ohne Dach im selben Geschoss: unverändertes Verhalten (volle Höhe, EIN Stück)", () => {
const project: Project = {
...sampleProject,
walls: [gableWall],
roofs: [],
doors: [],
openings: [],
ceilings: [],
};
const walls = projectToWalls3d(project).filter((w) => w.wallId === "WG");
expect(walls.length).toBeGreaterThan(0);
for (const seg of walls) {
expect(seg.height).toBeCloseTo(3.0, 6);
}
});
it("Dach auf einem ANDEREN Geschoss wirkt nicht (floorId-Filter)", () => {
const project: Project = {
...sampleProject,
walls: [gableWall],
roofs: [{ ...gableRoof, floorId: "og" }],
doors: [],
openings: [],
ceilings: [],
};
const walls = projectToWalls3d(project).filter((w) => w.wallId === "WG");
expect(walls.length).toBeGreaterThan(0);
for (const seg of walls) {
expect(seg.height).toBeCloseTo(3.0, 6);
}
});
});
describe("Geschichteter Bodenaufbau Decke", () => { describe("Geschichteter Bodenaufbau Decke", () => {
it("Decke -> je Schicht ein Slab, oben->unten gestapelt mit Bauteilfarbe/-schraffur", () => { it("Decke -> je Schicht ein Slab, oben->unten gestapelt mit Bauteilfarbe/-schraffur", () => {
// Deckentyp "dg-massiv": screed 0.06 (#c9c2b3), insulation 0.04 (#ffffff), // Deckentyp "dg-massiv": screed 0.06 (#c9c2b3), insulation 0.04 (#ffffff),
+80 -5
View File
@@ -54,8 +54,8 @@ import { mergeHoles } from "../geometry/polygonHoles";
import { openingInterval, openingVerticalExtent, openingJambs } from "../geometry/opening"; import { openingInterval, openingVerticalExtent, openingJambs } from "../geometry/opening";
import type { DetailLevel } from "../ui/TopBar"; import type { DetailLevel } from "../ui/TopBar";
import { stairGeometry, stairBBox } from "../geometry/stair"; import { stairGeometry, stairBBox } from "../geometry/stair";
import { roofGeometry, roofBaseElevation } from "../geometry/roof"; import { roofGeometry, roofBaseElevation, roofUndersideAt } from "../geometry/roof";
import type { Vec3 as RoofVec3 } from "../geometry/roof"; import type { Vec3 as RoofVec3, RoofGeometry } from "../geometry/roof";
import { extrudePolygon, extrudeCircle } from "../engine/truckSolid"; import { extrudePolygon, extrudeCircle } from "../engine/truckSolid";
import { computeJoins } from "../model/joins"; import { computeJoins } from "../model/joins";
import type { WallCuts } from "../model/joins"; import type { WallCuts } from "../model/joins";
@@ -1025,6 +1025,80 @@ function bandCutDistance(wall: Wall, u: Vec2, n: Vec2, bandOffset: number, cut:
return dot(sub(hit, wall.start), u); return dot(sub(hit, wall.start), u);
} }
/** Schrittweite (Meter) der Dach-Unterkanten-Annäherung, s. {@link clipPieceToRoofs}. */
const ROOF_CLIP_STEP = 0.15;
/**
* Kappt ein Wand-Achsenstück [a,bb] (z-Bereich [zb,zt]) an der Dach-UNTERKANTE
* (falls ein Dach darüber liegt) und emittiert es via `emit` in FEINE Achsen-
* Schritte (~{@link ROOF_CLIP_STEP}) zerlegt, je Schritt auf die dort gemessene
* Dach-Unterkante geklemmt. Das ist eine TREPPENSTUFEN-Annäherung an eine echte
* geneigte Giebelwand-Stirnfläche (render3d-Wandkörper (`RWall`) haben nur einen
* FLACHEN Top echte Schrägflächen bräuchten einen neuen Mesh-Pfad); bei der
* gewählten Schrittweite optisch glatt genug UND behebt das eigentliche Problem
* (Wand durchdringt die Dachfläche Z-Fighting, s. PENDENZEN.md Dach-Wand-
* Verschneidung"), ohne render3d/Rust anzufassen.
*
* Ohne Dächer (oder keinem, das dieses Wandstück überdeckt) läuft das Stück
* UNVERÄNDERT als EIN Body durch (kein Overhead im Normalfall).
*/
function clipPieceToRoofs(
project: Project,
wall: Wall,
u: Vec2,
n: Vec2,
bandOffset: number,
a: number,
bb: number,
zb: number,
zt: number,
emit: (a: number, bb: number, zb: number, zt: number) => void,
): void {
if (zt - zb <= EPS || bb - a <= EPS) return;
const wallBounds = wallFootprintBounds(wall, Math.abs(bandOffset) + 0.01);
const roofs = (project.roofs ?? []).filter((r) => {
if (r.floorId !== wall.floorId) return false;
const eavesZ = roofBaseElevation(project, r);
// Grober Höhen-Ausschluss: Dach-Traufe weit unter dem Wandstück ⇒ kann nicht
// schneiden (übliche Dächer haben keine nach unten ausufernden Übermasse).
return eavesZ + 50 >= zb; // grosszügige Schranke statt exakter First-Höhe
});
if (roofs.length === 0) {
emit(a, bb, zb, zt);
return;
}
const roofData = roofs
.map((r) => {
const eavesZ = roofBaseElevation(project, r);
const geo = roofGeometry(r, eavesZ);
if (!boundsOverlap(wallBounds, outlineBounds(geo.eaves))) return null;
const total = roofLayers(project, r).reduce((s, l) => s + Math.max(0, l.thickness), 0);
return { geo, total };
})
.filter((d): d is { geo: RoofGeometry; total: number } => d !== null);
if (roofData.length === 0) {
emit(a, bb, zb, zt);
return;
}
const span = bb - a;
const steps = Math.max(1, Math.ceil(span / ROOF_CLIP_STEP));
const stepLen = span / steps;
for (let i = 0; i < steps; i++) {
const sa = a + i * stepLen;
const sb = a + (i + 1) * stepLen;
const mid = (sa + sb) / 2;
const p = add(add(wall.start, scale(u, mid)), scale(n, bandOffset));
let clip = zt;
for (const { geo, total } of roofData) {
const under = roofUndersideAt(geo, total, p.x, p.y);
if (under !== null && under < clip) clip = under;
}
const zte = Math.max(zb, clip);
if (zte - zb > EPS) emit(sa, sb, zb, zte);
}
}
/** /**
* Emittiert die Wandkörper EINER Wand (mit oder ohne Öffnungen). * Emittiert die Wandkörper EINER Wand (mit oder ohne Öffnungen).
* *
@@ -1180,8 +1254,9 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu
.map((c) => ({ from: Math.max(a, c.from), to: Math.min(bb, c.to), zBottom: c.zBottom, zTop: c.zTop })) .map((c) => ({ from: Math.max(a, c.from), to: Math.min(bb, c.to), zBottom: c.zBottom, zTop: c.zTop }))
.filter((c) => c.to > c.from + EPS); .filter((c) => c.to > c.from + EPS);
if (local.length === 0) { if (local.length === 0) {
// Kein Decken-Überlapp auf diesem Achsenstück ⇒ volle Höhe (Band läuft durch). // Kein Decken-Überlapp auf diesem Achsenstück ⇒ volle Höhe (Band läuft
emitPiece(a, bb, zBottom, zTop); // durch) — ausser ein Dach kappt den Top (s. {@link clipPieceToRoofs}).
clipPieceToRoofs(project, wall, u, n, b.offset, a, bb, zBottom, zTop, emitPiece);
continue; continue;
} }
// Achse [a,bb] an allen Überdeckungs-Grenzen in elementare Teilstücke // Achse [a,bb] an allen Überdeckungs-Grenzen in elementare Teilstücke
@@ -1205,7 +1280,7 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu
// endet die Wand an der Decke, statt oben wieder aufzutauchen (s. // endet die Wand an der Decke, statt oben wieder aufzutauchen (s.
// {@link terminateOrSubtractSpans}). "both"/undefined = heutiges Verhalten. // {@link terminateOrSubtractSpans}). "both"/undefined = heutiges Verhalten.
for (const [zb, zt] of terminateOrSubtractSpans(zBottom, zTop, zCutters, wall.sliceTermination)) { for (const [zb, zt] of terminateOrSubtractSpans(zBottom, zTop, zCutters, wall.sliceTermination)) {
emitPiece(xa, xb, zb, zt); clipPieceToRoofs(project, wall, u, n, b.offset, xa, xb, zb, zt, emitPiece);
} }
} }
} }