diff --git a/src/geometry/roof.test.ts b/src/geometry/roof.test.ts index 533f799..ebbcaae 100644 --- a/src/geometry/roof.test.ts +++ b/src/geometry/roof.test.ts @@ -102,6 +102,39 @@ describe("roofGeometry — Formen (6×4, 45°, First entlang X)", () => { 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)", () => { diff --git a/src/geometry/roof.ts b/src/geometry/roof.ts index ca0097e..5a49624 100644 --- a/src/geometry/roof.ts +++ b/src/geometry/roof.ts @@ -174,33 +174,125 @@ function computeCanonical( } case "mansarde": { - // Sattel mit Knick: steile untere Neigung (pitchDeg) über den äusseren - // Tiefenanteil, dann flache obere (pitchUpperDeg ?? halbe Hauptneigung). + // 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 d1 = halfD * 0.4; // Knick-Einzug von der Traufe (äussere 40% steil) - const z1 = e + d1 * Math.tan(aLow); - const z2 = z1 + (halfD - d1) * Math.tan(aUp); - const yf = y0 + d1; // vordere Knicklinie - const yb = y1 - d1; // hintere Knicklinie + 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: [[P(x0, yc), P(x1, yc)]], - hips: [], - breaks: [ - [P(x0, yf), P(x1, yf)], - [P(x0, yb), P(x1, yb)], + 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: [ - { pts: [[x0, y0, e], [x1, y0, e], [x1, yf, z1], [x0, yf, z1]] }, // vorn unten - { pts: [[x0, yf, z1], [x1, yf, z1], [x1, yc, z2], [x0, yc, z2]] }, // vorn oben - { pts: [[x0, y1, e], [x1, y1, e], [x1, yb, z1], [x0, yb, z1]] }, // hinten unten - { pts: [[x0, yb, z1], [x1, yb, z1], [x1, yc, z2], [x0, yc, z2]] }, // hinten oben - ], - 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]], + ...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, }; } diff --git a/src/i18n/de.ts b/src/i18n/de.ts index e07c4e0..63736af 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -300,6 +300,11 @@ export const de = { "objinfo.roof.ridgeAxis.y": "entlang Y", "objinfo.roof.pitch": "Neigung (°)", "objinfo.roof.pitchUpper": "Obere Neigung (°)", + "objinfo.roof.mansardType": "Mansard-Art", + "objinfo.roof.mansard.giebel": "Giebel-Mansarde", + "objinfo.roof.mansard.walm": "Walm-Mansarde", + "objinfo.roof.mansard.zelt": "Zelt-Mansarde", + "objinfo.roof.mansardKnee": "Knicklage (% Spannweite)", "objinfo.roof.width": "Breite (m)", "objinfo.roof.depth": "Tiefe (m)", "objinfo.roof.overhang": "Überstand (m)", @@ -382,8 +387,8 @@ export const de = { "oed.cat.tuerblatt": "Türblatt", "oed.f.kind": "Öffnungsart", "oed.f.glazing": "Verglasung", - "oed.f.insetFace": "Schichteinzug ab", - "oed.f.insetFromFace": "Schichteinzug (m)", + "oed.f.insetFace": "Einbaulage ab", + "oed.f.insetFromFace": "Abstand von Kante (m)", "oed.f.width": "Breite (m)", "oed.f.height": "Höhe (m)", "oed.f.sillHeight": "Brüstungshöhe (m)", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c45d0b4..2af4358 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -299,6 +299,11 @@ export const en: Record = { "objinfo.roof.ridgeAxis.y": "along Y", "objinfo.roof.pitch": "Pitch (°)", "objinfo.roof.pitchUpper": "Upper pitch (°)", + "objinfo.roof.mansardType": "Mansard type", + "objinfo.roof.mansard.giebel": "Gable mansard", + "objinfo.roof.mansard.walm": "Hip mansard", + "objinfo.roof.mansard.zelt": "Pavilion mansard", + "objinfo.roof.mansardKnee": "Knee position (% span)", "objinfo.roof.width": "Width (m)", "objinfo.roof.depth": "Depth (m)", "objinfo.roof.overhang": "Overhang (m)", @@ -382,8 +387,8 @@ export const en: Record = { "oed.cat.tuerblatt": "Leaf", "oed.f.kind": "Opening type", "oed.f.glazing": "Glazing", - "oed.f.insetFace": "Inset from", - "oed.f.insetFromFace": "Layer inset (m)", + "oed.f.insetFace": "Position from", + "oed.f.insetFromFace": "Distance from edge (m)", "oed.f.width": "Width (m)", "oed.f.height": "Height (m)", "oed.f.sillHeight": "Sill height (m)", diff --git a/src/model/types.ts b/src/model/types.ts index 8929ea2..fb3d060 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -1034,6 +1034,19 @@ export interface Roof { pitchDeg: number; /** Nur Mansarde: obere (flachere) Neigung in Grad; fehlt ⇒ halbe Hauptneigung. */ pitchUpperDeg?: number; + /** + * Nur Mansarde: Untertyp der Mansardform. "giebel" = Mansard-Satteldach + * (steile untere + flache obere Neigung nur an zwei Seiten, Giebel an den + * Enden — Default), "walm" = Mansard-Walmdach (allseitige Mansarde, auch die + * Stirnseiten geknickt, kein Giebel), "zelt" = Mansard-Zeltdach (allseitig + * zur Spitze, geknickt). Fehlt ⇒ "giebel". + */ + mansardType?: "giebel" | "walm" | "zelt"; + /** + * Nur Mansarde: Lage der Knicklinie als Anteil der halben Spannweite (0..0.5), + * gemessen von der Traufe. Kleiner ⇒ steiler Sockel kürzer; fehlt ⇒ 0.4. + */ + mansardKneeRatio?: number; /** Dachüberstand an der Traufe (Meter, ringsum); 0 = bündig mit dem Umriss. */ overhang: number; /** Firstrichtung (Sattel/Walm/Mansarde/Pult): entlang der X- oder Y-Achse. */ diff --git a/src/panels/ObjectInfoPanel.tsx b/src/panels/ObjectInfoPanel.tsx index dcd5b38..f1148c2 100644 --- a/src/panels/ObjectInfoPanel.tsx +++ b/src/panels/ObjectInfoPanel.tsx @@ -523,6 +523,29 @@ export function RoofSection({ numField("objinfo.roof.pitchUpper", roof.pitchUpperDeg ?? roof.pitchDeg / 2, 1, (v) => host.onSetRoofPatch({ pitchUpperDeg: Math.max(0, Math.min(85, v)) }), )} + {/* Mansarde-Untertyp (Giebel/Walm/Zelt) + Knicklage. */} + {roof.shape === "mansarde" && ( +
+ {t("objinfo.roof.mansardType")} + + host.onSetRoofPatch({ mansardType: v as "giebel" | "walm" | "zelt" }) + } + options={[ + { value: "giebel", label: t("objinfo.roof.mansard.giebel") }, + { value: "walm", label: t("objinfo.roof.mansard.walm") }, + { value: "zelt", label: t("objinfo.roof.mansard.zelt") }, + ]} + title={t("objinfo.roof.mansardType")} + width={130} + /> +
+ )} + {roof.shape === "mansarde" && + numField("objinfo.roof.mansardKnee", (roof.mansardKneeRatio ?? 0.4) * 100, 1, (v) => + host.onSetRoofPatch({ mansardKneeRatio: Math.max(5, Math.min(49, v)) / 100 }), + )} {/* Grundriss-Masse (Breite/Tiefe): das Umriss-Rechteck neu bilden, die untere/linke Ecke (minX/minY) bleibt fix. */} diff --git a/src/state/selectionInfo.ts b/src/state/selectionInfo.ts index 01582bd..56e65ba 100644 --- a/src/state/selectionInfo.ts +++ b/src/state/selectionInfo.ts @@ -80,6 +80,10 @@ export interface RoofInfo { pitchDeg: number; /** Obere Neigung (Grad) — nur Mansarde relevant. */ pitchUpperDeg?: number; + /** Mansarde-Untertyp (Giebel/Walm/Zelt) — nur Mansarde relevant. */ + mansardType?: "giebel" | "walm" | "zelt"; + /** Mansarde-Knicklage (0..0.5 der halben Spannweite) — nur Mansarde relevant. */ + mansardKneeRatio?: number; /** Dachüberstand an der Traufe (Meter). */ overhang: number; /** Firstrichtung entlang X oder Y. */ @@ -626,6 +630,8 @@ function roofSelection(project: Project, roof: Roof): Selection { shape: roof.shape, pitchDeg: roof.pitchDeg, pitchUpperDeg: roof.pitchUpperDeg, + mansardType: roof.mansardType, + mansardKneeRatio: roof.mansardKneeRatio, overhang: roof.overhang, ridgeAxis: roof.ridgeAxis, thickness: roof.thickness,