From a5849111e302fa6952966426eca75b3b5eaa764e Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 12 Jul 2026 18:15:22 +0200 Subject: [PATCH] =?UTF-8?q?3D-W=C3=A4nde:=20Top=20folgt=20der=20Dach-Unter?= =?UTF-8?q?kante=20statt=20sie=20zu=20durchdringen=20(Z-Fighting-Fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/geometry/roof.test.ts | 30 +++++++++- src/geometry/roof.ts | 52 +++++++++++++++++ src/plan/toWalls3d.test.ts | 115 +++++++++++++++++++++++++++++++++++-- src/plan/toWalls3d.ts | 85 +++++++++++++++++++++++++-- 4 files changed, 272 insertions(+), 10 deletions(-) diff --git a/src/geometry/roof.test.ts b/src/geometry/roof.test.ts index 3cb2cf2..4492af3 100644 --- a/src/geometry/roof.test.ts +++ b/src/geometry/roof.test.ts @@ -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); + }); +}); diff --git a/src/geometry/roof.ts b/src/geometry/roof.ts index 722ec79..39f4fc7 100644 --- a/src/geometry/roof.ts +++ b/src/geometry/roof.ts @@ -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; +} diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index 122b2d4..314a125 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -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), diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index fdd80e4..df27fdb 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -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); } } }