Files
DOSSIER-STANDALONE/src/plan/toSection.roof.test.ts
T
karim 88771ea844 Dach im Vertikalschnitt: analytischer TS-Schnitt inkl. Schicht-Bändern
toSection behandelte Dächer bislang gar nicht (der Rust-Extraktor kennt nur
Wände + Decken). appendRoofSections schneidet die Schnittebene jetzt TS-seitig
mit den Dachflächen: Grundriss-Spur der Ebene per Cyrus–Beck gegen jedes
konvexe Aufsichts-Polygon der Dachflächen geclippt → u-Intervall; Oberkante
v(u) linear aus der Ebenengleichung (Newell), Unterkante um lotrechte Dicke /
cos(Neigung) tiefer. Mehrschichtige Dächer (roofLayers) werden wie beim
Decken-Schnitt in Bänder aussen→innen zerlegt, jedes mit Bauteil-Schraffur
(Papier-Neutralisierung wie resolveCeilingSectionStyle).

SectionComponentRef um 'roof' erweitert; attachCutStyles reicht Dach-Bänder
unverändert durch (Stile vorab aufgelöst, keine Boolean-Dominanz). Bewusst
nur SCHNITT-Polygone — Ansichts-/Silhouettenkanten des Dachs sind eine eigene
Phase. +4 analytische Tests. 689/689 grün.
2026-07-10 18:51:58 +02:00

145 lines
5.3 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 im Vertikalschnitt: appendRoofSections schneidet die Schnittebene
// analytisch mit den Dachflächen (der Rust-Extraktor kennt nur Wände/Decken).
// Geprüft werden u-Intervalle, Oberkanten-Verlauf (linear je Fläche), die
// vertikale Dicke (lotrechte Dicke / cos(Neigung)) und die Schicht-Bänder.
import { describe, it, expect } from "vitest";
import { appendRoofSections } from "./toSection";
import type { SectionOutput, SectionPlaneSpec } from "./toSection";
import type { Project, Roof } from "../model/types";
const RECT: Roof["outline"] = [
{ x: -3, y: 0 },
{ x: 3, y: 0 },
{ x: 3, y: 4 },
{ x: -3, y: 4 },
];
function roof(over: Partial<Roof> = {}): Roof {
return {
id: "R1",
type: "roof",
floorId: "eg",
categoryCode: "35",
outline: RECT,
shape: "sattel",
pitchDeg: 30,
overhang: 0,
ridgeAxis: "x",
baseElevation: 5, // explizit — kein Geschoss-Lookup nötig
thickness: 0.3,
...over,
};
}
function project(r: Roof, extra: Partial<Project> = {}): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [],
layers: [],
walls: [],
doors: [],
roofs: [r],
...extra,
} as unknown as Project;
}
/** Schnittebene: Blick entlang +x, Spur = Modell-Gerade x=0 (u wächst mit y). */
const PLANE: SectionPlaneSpec = { point: [0, 0, 0], normal: [1, 0, 0] };
const emptyOutput = (): SectionOutput => ({ cutPolygons: [], visibleEdges: [], hiddenEdges: [] });
describe("appendRoofSections", () => {
it("Flachdach: EIN Rechteck-Band, Oberkante = Traufhöhe, Höhe = Dicke", () => {
const out = emptyOutput();
appendRoofSections(out, project(roof({ shape: "flach", pitchDeg: 0 })), PLANE);
expect(out.cutPolygons.length).toBe(1);
const cp = out.cutPolygons[0];
expect(cp.component).toEqual({ kind: "roof", index: 0 });
// u-Intervall = Tiefe 0..4; Oberkante 5, Unterkante 5 − 0.3 (cosθ = 1).
const us = cp.pts.map((p) => p[0]).sort((a, b) => a - b);
expect(us[0]).toBeCloseTo(0, 6);
expect(us[3]).toBeCloseTo(4, 6);
const vs = cp.pts.map((p) => p[1]).sort((a, b) => a - b);
expect(vs[3]).toBeCloseTo(5, 6);
expect(vs[0]).toBeCloseTo(5 - 0.3, 6);
});
it("Satteldach quer zum First: zwei Bänder (steigend/fallend) mit Neigungs-Dicke", () => {
const out = emptyOutput();
appendRoofSections(out, project(roof()), PLANE);
expect(out.cutPolygons.length).toBe(2);
const tan30 = Math.tan((30 * Math.PI) / 180);
const cos30 = Math.cos((30 * Math.PI) / 180);
const ridgeZ = 5 + 2 * tan30; // First bei halber Tiefe (y=2)
// Alle Oberkanten-Werte je Band: an u=0/4 Traufe (5), an u=2 First.
const topAt = (cp: (typeof out.cutPolygons)[number], u: number): number =>
Math.max(...cp.pts.filter((p) => Math.abs(p[0] - u) < 1e-6).map((p) => p[1]));
const bands = out.cutPolygons;
const front = bands.find((b) => b.pts.some((p) => Math.abs(p[0]) < 1e-6));
const back = bands.find((b) => b.pts.some((p) => Math.abs(p[0] - 4) < 1e-6));
expect(front).toBeDefined();
expect(back).toBeDefined();
expect(topAt(front!, 0)).toBeCloseTo(5, 5);
expect(topAt(front!, 2)).toBeCloseTo(ridgeZ, 5);
expect(topAt(back!, 4)).toBeCloseTo(5, 5);
expect(topAt(back!, 2)).toBeCloseTo(ridgeZ, 5);
// Vertikale Banddicke = lotrechte Dicke / cos(Neigung).
const vAt = (cp: (typeof out.cutPolygons)[number], u: number): number[] =>
cp.pts.filter((p) => Math.abs(p[0] - u) < 1e-6).map((p) => p[1]);
const [hi, lo] = vAt(front!, 0).sort((a, b) => b - a);
expect(hi - lo).toBeCloseTo(0.3 / cos30, 5);
});
it("Schnittspur ausserhalb des Dachs: keine Bänder", () => {
const out = emptyOutput();
// Spur x=0 — Dach komplett rechts davon (x 10..16).
const far = roof({
outline: [
{ x: 10, y: 0 },
{ x: 16, y: 0 },
{ x: 16, y: 4 },
{ x: 10, y: 4 },
],
});
appendRoofSections(out, project(far), PLANE);
expect(out.cutPolygons.length).toBe(0);
});
it("mehrschichtiges Dach (roofTypeId): je Schicht ein Band pro Fläche, aussen oben", () => {
const p = project(roof({ shape: "flach", pitchDeg: 0, roofTypeId: "rt1" }), {
hatches: [
{ id: "h1", name: "H", pattern: "diagonal", scale: 1, angle: 45, color: "#111111" },
],
components: [
{ id: "tile", name: "Ziegel", color: "#a03a2a", hatchId: "h1", joinPriority: 1 },
{ id: "ins", name: "Dämmung", color: "#ffffff", hatchId: "h1", joinPriority: 2 },
],
roofTypes: [
{
id: "rt1",
name: "Aufbau",
layers: [
{ componentId: "tile", thickness: 0.05 },
{ componentId: "ins", thickness: 0.25 },
],
},
],
});
const out = emptyOutput();
appendRoofSections(out, p, PLANE);
expect(out.cutPolygons.length).toBe(2); // 1 Fläche × 2 Schichten
const tops = out.cutPolygons.map((cp) => Math.max(...cp.pts.map((q) => q[1])));
// Eindeckung oben (OK 5), Dämmung darunter (OK 5 − 0.05).
expect(Math.max(...tops)).toBeCloseTo(5, 6);
expect(Math.min(...tops)).toBeCloseTo(4.95, 6);
// Schicht-Stile sind aufgelöst (Schraffur des Bauteils).
expect(out.cutPolygons.every((cp) => cp.hatch?.pattern === "diagonal")).toBe(true);
});
});