Files
DOSSIER-STANDALONE/src/geometry/roof.ts
T
karim c9baff58b0 Dach: getrennter Überstand Traufe/Ortgang (statt ringsum)
Bisher ein einziger overhang ringsum (Designdoc-Prio #2). Neu Roof.overhangGable
für den Ortgang (Giebelseite, entlang First); overhang gilt für die Traufe
(senkrecht zum First). Fehlt overhangGable, gilt ringsum overhang (rückwärts-
kompatibel). Geometrie mappt die Überstände je nach ridgeAxis auf die Outline-
Achsen. Panel: zweites Feld 'Überstand Ortgang' (ausser flach/zelt). +3 Tests.
672/672 grün.
2026-07-10 02:10:03 +02:00

365 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Dach-Geometrie: leitet aus einem (rechteckigen) Grundriss-Umriss + Dachform +
// Neigung die Grundriss-Linien (Traufe/First/Grat/Knick) UND die 3D-Dachflächen
// ab. Bewusst auf der BOUNDING-BOX des Umrisses gerechnet (First entlang einer
// Hauptachse) — die gängige, intuitive Vereinfachung, die alle Standardformen
// (Flach/Pult/Sattel/Walm/Mansarde/Zelt) exakt und ohne Straight-Skeleton abdeckt.
//
// Koordinaten: Grundriss (x, y) in Metern; 3D-Punkte als [x, y, z] mit z = Höhe.
// Reine Datenschicht (kein React/Store), voll unit-testbar.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Project, Roof, Vec2 } from "../model/types";
import { getFloor } from "../model/types";
/** 3D-Punkt [x, y, z=Höhe] in Modell-Metern. */
export type Vec3 = [number, number, number];
/** Eine ebene Dachfläche als Polygon (3D-Eckpunkte in Umlaufreihenfolge). */
export interface RoofPlane {
pts: Vec3[];
}
/** Vollständige Dach-Geometrie (Grundriss-Linien + 3D-Flächen). */
export interface RoofGeometry {
/** Traufe-Umriss (Grundriss, inkl. Überstand) als geschlossener Ring. */
eaves: Vec2[];
/** Firstlinie(n) im Grundriss (leer bei Flach/Zelt). */
ridges: [Vec2, Vec2][];
/** Gratlinien (Walm/Zelt: Ecke → First/Spitze). */
hips: [Vec2, Vec2][];
/** Knicklinien (Mansarde). */
breaks: [Vec2, Vec2][];
/** 3D-Dachflächen. */
planes: RoofPlane[];
/** Giebelflächen (vertikale Polygone an Sattel-/Mansarde-Enden). */
gables: Vec3[][];
/** Firsthöhe über der Traufe (Meter). */
ridgeHeight: number;
}
const DEG = Math.PI / 180;
/** Bounding-Box eines Umrisses. */
export function roofBBox(outline: Vec2[]): { x0: number; y0: number; x1: number; y1: number } {
let x0 = Infinity;
let y0 = Infinity;
let x1 = -Infinity;
let y1 = -Infinity;
for (const p of outline) {
if (p.x < x0) x0 = p.x;
if (p.y < y0) y0 = p.y;
if (p.x > x1) x1 = p.x;
if (p.y > y1) y1 = p.y;
}
return { x0, y0, x1, y1 };
}
/**
* Traufhöhe (absolutes Z) eines Dachs: `baseElevation`, sonst die OBERKANTE des
* zugehörigen Geschosses (baseElevation + floorHeight) — das Dach sitzt also
* standardmäßig auf der Geschossdecke auf.
*/
export function roofBaseElevation(project: Project, roof: Roof): number {
if (roof.baseElevation !== undefined) return roof.baseElevation;
try {
const floor = getFloor(project, roof.floorId);
return (floor.baseElevation ?? 0) + (floor.floorHeight ?? 0);
} catch {
return 0;
}
}
const P = (x: number, y: number): Vec2 => ({ x, y });
/**
* Kanonische Berechnung mit First entlang der X-Achse auf dem Rechteck
* [x0,x1]×[y0,y1] (Traufe, inkl. Überstand) auf Traufhöhe `e`. Die „y"-First-
* Richtung wird im Wrapper durch Transponieren (x↔y) erreicht.
*/
function computeCanonical(
x0: number,
y0: number,
x1: number,
y1: number,
e: number,
roof: Roof,
): RoofGeometry {
const xc = (x0 + x1) / 2;
const yc = (y0 + y1) / 2;
const halfW = (x1 - x0) / 2;
const halfD = (y1 - y0) / 2;
const a = Math.max(0, roof.pitchDeg) * DEG;
const tan = Math.tan(a);
const h = halfD * tan; // Firsthöhe (Sattel/Walm/Mansarde)
const eaves: Vec2[] = [P(x0, y0), P(x1, y0), P(x1, y1), P(x0, y1)];
const flat = (): RoofGeometry => ({
eaves,
ridges: [],
hips: [],
breaks: [],
planes: [{ pts: [[x0, y0, e], [x1, y0, e], [x1, y1, e], [x0, y1, e]] }],
gables: [],
ridgeHeight: 0,
});
switch (roof.shape) {
case "flach":
return flat();
case "pult": {
// Eine Fläche: tiefe Seite y0 auf Traufe, hohe Seite y1 auf e+H.
const H = (y1 - y0) * tan;
return {
eaves,
ridges: [[P(x0, y1), P(x1, y1)]], // hohe Kante (First-Ersatz)
hips: [],
breaks: [],
planes: [{ pts: [[x0, y0, e], [x1, y0, e], [x1, y1, e + H], [x0, y1, e + H]] }],
gables: [
[[x0, y0, e], [x0, y1, e + H], [x0, y1, e]],
[[x1, y0, e], [x1, y1, e + H], [x1, y1, e]],
],
ridgeHeight: H,
};
}
case "sattel": {
const rz = e + h;
return {
eaves,
ridges: [[P(x0, yc), P(x1, yc)]],
hips: [],
breaks: [],
planes: [
{ pts: [[x0, y0, e], [x1, y0, e], [x1, yc, rz], [x0, yc, rz]] },
{ pts: [[x0, y1, e], [x1, y1, e], [x1, yc, rz], [x0, yc, rz]] },
],
gables: [
[[x0, y0, e], [x0, y1, e], [x0, yc, rz]],
[[x1, y0, e], [x1, y1, e], [x1, yc, rz]],
],
ridgeHeight: h,
};
}
case "walm": {
const rz = e + h;
// First um die halbe Tiefe an beiden Enden verkürzt (45°-Grate bei
// gleicher Neigung). Bei schmalem Baukörper (halfW<halfD) degeneriert er
// zum Punkt (xc) → Walm wird zeltartig.
let ra = x0 + halfD;
let rb = x1 - halfD;
if (ra > rb) ra = rb = xc;
return {
eaves,
ridges: [[P(ra, yc), P(rb, yc)]],
hips: [
[P(x0, y0), P(ra, yc)],
[P(x0, y1), P(ra, yc)],
[P(x1, y0), P(rb, yc)],
[P(x1, y1), P(rb, yc)],
],
breaks: [],
planes: [
{ pts: [[x0, y0, e], [x1, y0, e], [rb, yc, rz], [ra, yc, rz]] },
{ pts: [[x0, y1, e], [x1, y1, e], [rb, yc, rz], [ra, yc, rz]] },
{ pts: [[x0, y0, e], [x0, y1, e], [ra, yc, rz]] },
{ pts: [[x1, y0, e], [x1, y1, e], [rb, yc, rz]] },
],
gables: [],
ridgeHeight: h,
};
}
case "mansarde": {
// Mansarde: steile untere Neigung (pitchDeg) bis zur Knicklinie, dann
// flache obere (pitchUpperDeg ?? halbe Hauptneigung). Untertyp steuert, ob
// die Knickform an ZWEI Seiten sitzt (Giebel) oder RINGSUM (Walm/Zelt).
const aLow = a;
const aUp = Math.max(0, roof.pitchUpperDeg ?? roof.pitchDeg / 2) * DEG;
const ratio = Math.min(0.49, Math.max(0.05, roof.mansardKneeRatio ?? 0.4));
const mType = roof.mansardType ?? "giebel";
if (mType === "giebel") {
// Mansard-Satteldach: Knick nur an den beiden Traufseiten, Giebel an den
// Enden (bisheriges Verhalten, jetzt mit parametrierbarem Knick).
const d1 = halfD * ratio;
const z1 = e + d1 * Math.tan(aLow);
const z2 = z1 + (halfD - d1) * Math.tan(aUp);
const yf = y0 + d1;
const yb = y1 - d1;
return {
eaves,
ridges: [[P(x0, yc), P(x1, yc)]],
hips: [],
breaks: [
[P(x0, yf), P(x1, yf)],
[P(x0, yb), P(x1, yb)],
],
planes: [
{ pts: [[x0, y0, e], [x1, y0, e], [x1, yf, z1], [x0, yf, z1]] },
{ pts: [[x0, yf, z1], [x1, yf, z1], [x1, yc, z2], [x0, yc, z2]] },
{ pts: [[x0, y1, e], [x1, y1, e], [x1, yb, z1], [x0, yb, z1]] },
{ pts: [[x0, yb, z1], [x1, yb, z1], [x1, yc, z2], [x0, yc, z2]] },
],
gables: [
[[x0, y0, e], [x0, yf, z1], [x0, yc, z2], [x0, yb, z1], [x0, y1, e]],
[[x1, y0, e], [x1, yf, z1], [x1, yc, z2], [x1, yb, z1], [x1, y1, e]],
],
ridgeHeight: z2 - e,
};
}
// Allseitige Mansarde (Walm/Zelt): steiler Sockel ringsum bis zur
// Knicklinie (inneres Rechteck), darüber flacher Walm bzw. flache Spitze.
const dLow = Math.min(halfW, halfD) * ratio;
const z1 = e + dLow * Math.tan(aLow);
const ix0 = x0 + dLow;
const iy0 = y0 + dLow;
const ix1 = x1 - dLow;
const iy1 = y1 - dLow;
const ihalfD = (iy1 - iy0) / 2;
const ihalfW = (ix1 - ix0) / 2;
// Sockel-Trapeze (4 Seiten, steil) + Sockel-Grate (Ecken aussen→innen) +
// Knicklinie (inneres Rechteck) sind beiden allseitigen Formen gemein.
const socketPlanes = [
{ pts: [[x0, y0, e], [x1, y0, e], [ix1, iy0, z1], [ix0, iy0, z1]] as Vec3[] },
{ pts: [[x1, y1, e], [x0, y1, e], [ix0, iy1, z1], [ix1, iy1, z1]] as Vec3[] },
{ pts: [[x0, y1, e], [x0, y0, e], [ix0, iy0, z1], [ix0, iy1, z1]] as Vec3[] },
{ pts: [[x1, y0, e], [x1, y1, e], [ix1, iy1, z1], [ix1, iy0, z1]] as Vec3[] },
];
const socketHips: [Vec2, Vec2][] = [
[P(x0, y0), P(ix0, iy0)],
[P(x1, y0), P(ix1, iy0)],
[P(x1, y1), P(ix1, iy1)],
[P(x0, y1), P(ix0, iy1)],
];
const kneeBreaks: [Vec2, Vec2][] = [
[P(ix0, iy0), P(ix1, iy0)],
[P(ix1, iy0), P(ix1, iy1)],
[P(ix1, iy1), P(ix0, iy1)],
[P(ix0, iy1), P(ix0, iy0)],
];
if (mType === "walm") {
// Oberer Walm auf dem inneren Rechteck (First entlang X).
const z2 = z1 + ihalfD * Math.tan(aUp);
let ra = ix0 + ihalfD;
let rb = ix1 - ihalfD;
if (ra > rb) ra = rb = xc;
return {
eaves,
ridges: [[P(ra, yc), P(rb, yc)]],
hips: [
...socketHips,
[P(ix0, iy0), P(ra, yc)],
[P(ix1, iy0), P(rb, yc)],
[P(ix1, iy1), P(rb, yc)],
[P(ix0, iy1), P(ra, yc)],
],
breaks: kneeBreaks,
planes: [
...socketPlanes,
{ pts: [[ix0, iy0, z1], [ix1, iy0, z1], [rb, yc, z2], [ra, yc, z2]] },
{ pts: [[ix1, iy1, z1], [ix0, iy1, z1], [ra, yc, z2], [rb, yc, z2]] },
{ pts: [[ix0, iy1, z1], [ix0, iy0, z1], [ra, yc, z2]] },
{ pts: [[ix1, iy0, z1], [ix1, iy1, z1], [rb, yc, z2]] },
],
gables: [],
ridgeHeight: z2 - e,
};
}
// "zelt": flache Spitze über der Mitte des inneren Rechtecks.
const z2 = z1 + Math.min(ihalfW, ihalfD) * Math.tan(aUp);
return {
eaves,
ridges: [],
hips: [
...socketHips,
[P(ix0, iy0), P(xc, yc)],
[P(ix1, iy0), P(xc, yc)],
[P(ix1, iy1), P(xc, yc)],
[P(ix0, iy1), P(xc, yc)],
],
breaks: kneeBreaks,
planes: [
...socketPlanes,
{ pts: [[ix0, iy0, z1], [ix1, iy0, z1], [xc, yc, z2]] },
{ pts: [[ix1, iy0, z1], [ix1, iy1, z1], [xc, yc, z2]] },
{ pts: [[ix1, iy1, z1], [ix0, iy1, z1], [xc, yc, z2]] },
{ pts: [[ix0, iy1, z1], [ix0, iy0, z1], [xc, yc, z2]] },
],
gables: [],
ridgeHeight: z2 - e,
};
}
case "zelt": {
// Allseitig zur Spitze über der Mitte.
const hz = Math.min(halfW, halfD) * tan;
const az = e + hz;
return {
eaves,
ridges: [],
hips: [
[P(x0, y0), P(xc, yc)],
[P(x1, y0), P(xc, yc)],
[P(x1, y1), P(xc, yc)],
[P(x0, y1), P(xc, yc)],
],
breaks: [],
planes: [
{ pts: [[x0, y0, e], [x1, y0, e], [xc, yc, az]] },
{ pts: [[x1, y0, e], [x1, y1, e], [xc, yc, az]] },
{ pts: [[x1, y1, e], [x0, y1, e], [xc, yc, az]] },
{ pts: [[x0, y1, e], [x0, y0, e], [xc, yc, az]] },
],
gables: [],
ridgeHeight: hz,
};
}
}
}
const swap2 = (p: Vec2): Vec2 => ({ x: p.y, y: p.x });
const swap3 = (p: Vec3): Vec3 => [p[1], p[0], p[2]];
const swapLine = (l: [Vec2, Vec2]): [Vec2, Vec2] => [swap2(l[0]), swap2(l[1])];
/**
* Dach-Geometrie aus Form/Neigung/Überstand + Traufhöhe `eavesZ`. Rechnet auf
* der Bounding-Box des Umrisses, um `overhang` nach aussen geweitet. Für
* `ridgeAxis:"y"` wird kanonisch (First entlang X) gerechnet und danach x↔y
* transponiert.
*/
export function roofGeometry(roof: Roof, eavesZ: number): RoofGeometry {
const bb = roofBBox(roof.outline);
const oe = Math.max(0, roof.overhang); // Traufüberstand (senkrecht zum First)
const og = Math.max(0, roof.overhangGable ?? roof.overhang); // Ortgang (entlang First)
// Der First liegt entlang der `ridgeAxis`-Achse: dort wirkt der Ortgang-, quer
// dazu der Traufüberstand. In OUTLINE-Koordinaten je nach Firstrichtung mappen.
const ox = roof.ridgeAxis === "x" ? og : oe;
const oy = roof.ridgeAxis === "x" ? oe : og;
let x0 = bb.x0 - ox;
let y0 = bb.y0 - oy;
let x1 = bb.x1 + ox;
let y1 = bb.y1 + oy;
const flip = roof.ridgeAxis === "y";
if (flip) {
[x0, y0, x1, y1] = [y0, x0, y1, x1];
}
const g = computeCanonical(x0, y0, x1, y1, eavesZ, roof);
if (!flip) return g;
return {
eaves: g.eaves.map(swap2),
ridges: g.ridges.map(swapLine),
hips: g.hips.map(swapLine),
breaks: g.breaks.map(swapLine),
planes: g.planes.map((pl) => ({ pts: pl.pts.map(swap3) })),
gables: g.gables.map((poly) => poly.map(swap3)),
ridgeHeight: g.ridgeHeight,
};
}