Files
DOSSIER-STANDALONE/src/geometry/roof.test.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

184 lines
7.0 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.
import { describe, it, expect } from "vitest";
import { roofGeometry, roofBBox } 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.
const RECT: Vec2[] = [
{ x: 0, y: 0 },
{ x: 6, y: 0 },
{ x: 6, y: 4 },
{ x: 0, y: 4 },
];
const E = 10;
function roof(over: Partial<Roof>): Roof {
return {
id: "R1",
type: "roof",
floorId: "eg",
categoryCode: "35",
outline: RECT,
shape: "sattel",
pitchDeg: 45,
overhang: 0,
ridgeAxis: "x",
thickness: 0.2,
...over,
};
}
/** Alle 3D-Punkte einer Geometrie (Flächen + Giebel). */
function allZ(g: ReturnType<typeof roofGeometry>): number[] {
const zs: number[] = [];
for (const pl of g.planes) for (const p of pl.pts) zs.push(p[2]);
for (const gp of g.gables) for (const p of gp) zs.push(p[2]);
return zs;
}
describe("roofBBox", () => {
it("liefert die Bounding-Box", () => {
expect(roofBBox(RECT)).toEqual({ x0: 0, y0: 0, x1: 6, y1: 4 });
});
});
describe("roofGeometry — Formen (6×4, 45°, First entlang X)", () => {
it("flach: eine waagrechte Fläche auf Traufhöhe, kein First", () => {
const g = roofGeometry(roof({ shape: "flach" }), E);
expect(g.planes).toHaveLength(1);
expect(g.ridges).toHaveLength(0);
expect(g.ridgeHeight).toBe(0);
expect(allZ(g).every((z) => z === E)).toBe(true);
});
it("sattel: Firsthöhe = halbe Tiefe · tan, First mittig, 2 Flächen + 2 Giebel", () => {
const g = roofGeometry(roof({ shape: "sattel" }), E);
expect(g.ridgeHeight).toBeCloseTo(2, 6); // halfD 2 · tan45 1
expect(g.planes).toHaveLength(2);
expect(g.gables).toHaveLength(2);
expect(g.ridges).toHaveLength(1);
// First auf yc=2, z=E+2.
expect(g.ridges[0][0]).toEqual({ x: 0, y: 2 });
expect(g.ridges[0][1]).toEqual({ x: 6, y: 2 });
expect(Math.max(...allZ(g))).toBeCloseTo(E + 2, 6);
expect(Math.min(...allZ(g))).toBeCloseTo(E, 6);
});
it("walm: First um halbe Tiefe verkürzt, 4 Flächen, 4 Grate, keine Giebel", () => {
const g = roofGeometry(roof({ shape: "walm" }), E);
expect(g.planes).toHaveLength(4);
expect(g.hips).toHaveLength(4);
expect(g.gables).toHaveLength(0);
// ra=2, rb=4.
expect(g.ridges[0][0]).toEqual({ x: 2, y: 2 });
expect(g.ridges[0][1]).toEqual({ x: 4, y: 2 });
expect(g.ridgeHeight).toBeCloseTo(2, 6);
});
it("zelt: Spitze über der Mitte, 4 Flächen, 4 Grate zur Mitte", () => {
const g = roofGeometry(roof({ shape: "zelt" }), E);
expect(g.planes).toHaveLength(4);
expect(g.hips).toHaveLength(4);
expect(g.ridges).toHaveLength(0);
// hz = min(halfW 3, halfD 2)=2.
expect(g.ridgeHeight).toBeCloseTo(2, 6);
// Alle Grate laufen zum Mittelpunkt (3,2).
for (const hip of g.hips) expect(hip[1]).toEqual({ x: 3, y: 2 });
});
it("pult: eine Fläche, hohe Kante = Breite·tan über der Traufe", () => {
const g = roofGeometry(roof({ shape: "pult" }), E);
expect(g.planes).toHaveLength(1);
expect(g.ridgeHeight).toBeCloseTo(4, 6); // Tiefe 4 · tan45
expect(Math.max(...allZ(g))).toBeCloseTo(E + 4, 6);
});
it("mansarde: 4 Flächen, 2 Knicklinien, First mittig, Knick tiefer als First", () => {
const g = roofGeometry(roof({ shape: "mansarde", pitchDeg: 70, pitchUpperDeg: 30 }), E);
expect(g.planes).toHaveLength(4);
expect(g.breaks).toHaveLength(2);
expect(g.ridges).toHaveLength(1);
expect(g.gables).toHaveLength(2); // Pentagon-Giebel
// First höher als 0, Knicklinien innerhalb der Tiefe.
expect(g.ridgeHeight).toBeGreaterThan(0);
expect(g.breaks[0][0].y).toBeCloseTo(0 + 2 * 0.4, 6); // yf = y0 + halfD·0.4
});
it("mansarde giebel: Knick-Ratio parametriert die Knicklinien-Lage", () => {
const g = roofGeometry(
roof({ shape: "mansarde", mansardType: "giebel", mansardKneeRatio: 0.25 }),
E,
);
// yf = y0 + halfD·ratio = 0 + 2·0.25 = 0.5.
expect(g.breaks[0][0].y).toBeCloseTo(0.5, 6);
});
it("mansarde walm: allseitig geknickt -> 8 Flächen, 4 Knicklinien (Rechteck), 8 Grate, keine Giebel", () => {
const g = roofGeometry(roof({ shape: "mansarde", mansardType: "walm" }), E);
expect(g.planes).toHaveLength(8); // 4 Sockel + 2 Walm-Trapeze + 2 Walm-Dreiecke
expect(g.breaks).toHaveLength(4); // inneres Knick-Rechteck
expect(g.hips).toHaveLength(8); // 4 Sockel-Grate + 4 obere Walm-Grate
expect(g.ridges).toHaveLength(1);
expect(g.gables).toHaveLength(0); // Walm hat keine Giebel
expect(g.ridgeHeight).toBeGreaterThan(0);
// Knick-Höhe < First-Höhe (Sockel endet unter dem First).
const kneeZ = g.planes[0].pts[2][2] - E; // z1 - e am inneren Punkt
expect(kneeZ).toBeGreaterThan(0);
expect(kneeZ).toBeLessThan(g.ridgeHeight);
});
it("mansarde zelt: allseitig geknickt zur Spitze -> 8 Flächen, kein First, 8 Grate", () => {
const g = roofGeometry(roof({ shape: "mansarde", mansardType: "zelt" }), E);
expect(g.planes).toHaveLength(8); // 4 Sockel + 4 Spitzen-Dreiecke
expect(g.breaks).toHaveLength(4);
expect(g.hips).toHaveLength(8);
expect(g.ridges).toHaveLength(0); // Zelt hat keinen First
expect(g.gables).toHaveLength(0);
expect(g.ridgeHeight).toBeGreaterThan(0);
});
});
describe("roofGeometry — First entlang Y (Transponierung)", () => {
it("dreht die Sattel-First-Linie auf die X-Mitte", () => {
const g = roofGeometry(roof({ shape: "sattel", ridgeAxis: "y" }), E);
// Firstrichtung Y → First liegt bei x = xc = 3, läuft in Y.
expect(g.ridges[0][0]).toEqual({ x: 3, y: 0 });
expect(g.ridges[0][1]).toEqual({ x: 3, y: 4 });
// Firsthöhe nun aus der halben BREITE (3) · tan = 3.
expect(g.ridgeHeight).toBeCloseTo(3, 6);
});
});
describe("roofGeometry — Überstand", () => {
it("weitet die Traufe um overhang nach aussen", () => {
const g = roofGeometry(roof({ shape: "flach", overhang: 0.5 }), E);
const bb = roofBBox(g.eaves);
expect(bb).toEqual({ x0: -0.5, y0: -0.5, x1: 6.5, y1: 4.5 });
});
it("getrennter Traufe/Ortgang-Überstand: First entlang X -> Ortgang weitet X, Traufe Y", () => {
// ridgeAxis "x": First entlang X -> Ortgang (Giebel) an den X-Enden,
// Traufe an den Y-Seiten. overhang(Traufe)=0.3, overhangGable(Ortgang)=0.8.
const g = roofGeometry(
roof({ shape: "sattel", ridgeAxis: "x", overhang: 0.3, overhangGable: 0.8 }),
E,
);
const bb = roofBBox(g.eaves);
expect(bb).toEqual({ x0: -0.8, y0: -0.3, x1: 6.8, y1: 4.3 });
});
it("getrennter Überstand: First entlang Y -> Ortgang weitet Y, Traufe X", () => {
const g = roofGeometry(
roof({ shape: "sattel", ridgeAxis: "y", overhang: 0.3, overhangGable: 0.8 }),
E,
);
const bb = roofBBox(g.eaves);
expect(bb).toEqual({ x0: -0.3, y0: -0.8, x1: 6.3, y1: 4.8 });
});
it("overhangGable fehlt -> ringsum gleich overhang (rückwärtskompatibel)", () => {
const g = roofGeometry(roof({ shape: "sattel", overhang: 0.4 }), E);
const bb = roofBBox(g.eaves);
expect(bb).toEqual({ x0: -0.4, y0: -0.4, x1: 6.4, y1: 4.4 });
});
});