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;
|
||||
}
|
||||
|
||||
+111
-4
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
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 { selectionHighlightLines, projectToWalls3d, projectToModel3d, pickGeometry } from "./toWalls3d";
|
||||
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);
|
||||
});
|
||||
|
||||
it("keine Subtraktion, 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).
|
||||
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) -> keine
|
||||
// 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");
|
||||
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) {
|
||||
if (seg === offHeight[0]) continue;
|
||||
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", () => {
|
||||
it("Decke -> je Schicht ein Slab, oben->unten gestapelt mit Bauteilfarbe/-schraffur", () => {
|
||||
// Deckentyp "dg-massiv": screed 0.06 (#c9c2b3), insulation 0.04 (#ffffff),
|
||||
|
||||
+80
-5
@@ -54,8 +54,8 @@ import { mergeHoles } from "../geometry/polygonHoles";
|
||||
import { openingInterval, openingVerticalExtent, openingJambs } from "../geometry/opening";
|
||||
import type { DetailLevel } from "../ui/TopBar";
|
||||
import { stairGeometry, stairBBox } from "../geometry/stair";
|
||||
import { roofGeometry, roofBaseElevation } from "../geometry/roof";
|
||||
import type { Vec3 as RoofVec3 } from "../geometry/roof";
|
||||
import { roofGeometry, roofBaseElevation, roofUndersideAt } from "../geometry/roof";
|
||||
import type { Vec3 as RoofVec3, RoofGeometry } from "../geometry/roof";
|
||||
import { extrudePolygon, extrudeCircle } from "../engine/truckSolid";
|
||||
import { computeJoins } 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);
|
||||
}
|
||||
|
||||
/** 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).
|
||||
*
|
||||
@@ -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 }))
|
||||
.filter((c) => c.to > c.from + EPS);
|
||||
if (local.length === 0) {
|
||||
// Kein Decken-Überlapp auf diesem Achsenstück ⇒ volle Höhe (Band läuft durch).
|
||||
emitPiece(a, bb, zBottom, zTop);
|
||||
// Kein Decken-Überlapp auf diesem Achsenstück ⇒ volle Höhe (Band läuft
|
||||
// durch) — ausser ein Dach kappt den Top (s. {@link clipPieceToRoofs}).
|
||||
clipPieceToRoofs(project, wall, u, n, b.offset, a, bb, zBottom, zTop, emitPiece);
|
||||
continue;
|
||||
}
|
||||
// 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.
|
||||
// {@link terminateOrSubtractSpans}). "both"/undefined = heutiges Verhalten.
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user