c9baff58b0
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.
184 lines
7.0 KiB
TypeScript
184 lines
7.0 KiB
TypeScript
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 });
|
||
});
|
||
});
|