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:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { roofGeometry, roofBBox } from "./roof";
|
||||
import { roofGeometry, roofBBox, roofUndersideAt } from "./roof";
|
||||
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.
|
||||
@@ -181,3 +181,31 @@ describe("roofGeometry — Überstand", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -362,3 +362,55 @@ export function roofGeometry(roof: Roof, eavesZ: number): RoofGeometry {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user