diff --git a/src/geometry/stair.ts b/src/geometry/stair.ts index 3d2b4fd..79e6b06 100644 --- a/src/geometry/stair.ts +++ b/src/geometry/stair.ts @@ -409,3 +409,132 @@ export function pointHitsStair(p: Vec2, geo: StairGeometry): boolean { if (geo.landing && pointInPolygon(p, geo.landing)) return true; return false; } + +// ── Treppen-Aussenlinie (Plan-Outline) ─────────────────────────────────────── + +/** + * Schliessendes Umriss-Polygon einer geraden Treppe im Grundriss (4 Ecken CCW). + * Entspricht `_aussen_gerade` im Rhino-Plugin (Rechteck über Laufbreite + Lauflänge). + * Liefert null bei entarteter Geometrie. + */ +function straightOutlinePoints(stair: Stair, halfW: number): Vec2[] | null { + const u = norm({ x: stair.dir.x, y: stair.dir.y }); + const n = leftN(u); + const L = Math.max(1e-4, stair.runLength); + const s = stair.start; + // Vier Ecken: Start-links → Start-rechts → End-rechts → End-links (CCW). + return [ + add(s, scale(n, halfW)), + add(s, scale(n, -halfW)), + add(add(s, scale(u, L)), scale(n, -halfW)), + add(add(s, scale(u, L)), scale(n, halfW)), + ]; +} + +/** + * 2D-Linien-Schnittpunkt. Liefert null bei Parallelität (det < ε). + * Entspricht `_line_intersect_xy` im Rhino-Plugin. + */ +function lineIntersect2d(p1: Vec2, d1: Vec2, p2: Vec2, d2: Vec2): Vec2 | null { + const det = d1.x * (-d2.y) - d1.y * (-d2.x); + if (Math.abs(det) < 1e-9) return null; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const t = (dx * (-d2.y) - dy * (-d2.x)) / det; + return { x: p1.x + d1.x * t, y: p1.y + d1.y * t }; +} + +/** + * Umriss-Polygon einer L-Treppe im Grundriss. Entspricht `_aussen_l_polygon` + * im Rhino-Plugin: Linien-Schnittpunkte liefern die Ecken des L-Polygons. + * Liefert null bei entarteter Geometrie. + */ +function lOutlinePoints(stair: Stair, halfW: number): Vec2[] | null { + const u1 = norm(stair.dir); + const n1 = leftN(u1); + const L1 = Math.max(1e-4, stair.runLength); + const turn = stair.turn ?? 1; + const u2: Vec2 = turn > 0 ? n1 : scale(n1, -1); + const n2 = leftN(u2); + const L2 = Math.max(1e-4, stair.run2Length ?? stair.runLength); + + // Podest-Zentrum am Ende des ersten Laufs (Mitte = halfW hinter dem Lauf-Ende). + const corner = add(stair.start, scale(u1, L1 + halfW)); + const run2Start = add(corner, scale(u2, halfW)); + + // Lauf-1-Anfang je Seite + const p0l = add(stair.start, scale(n1, halfW)); + const p0r = add(stair.start, scale(n1, -halfW)); + // Lauf-2-Ende je Seite + const endRun2 = add(run2Start, scale(u2, L2)); + const p3l = add(endRun2, scale(n2, halfW)); + const p3r = add(endRun2, scale(n2, -halfW)); + // Innere Eck-Punkte: Schnittpunkt der parallelen Seitenlinien je Seite. + const cornerL = lineIntersect2d(p0l, u1, p3l, scale(u2, -1)) ?? add(corner, scale(n1, halfW)); + const cornerR = lineIntersect2d(p0r, u1, p3r, scale(u2, -1)) ?? add(corner, scale(n1, -halfW)); + // CCW-Polygon: p0l → p0r → cornerR → p3r → p3l → cornerL + return [p0l, p0r, cornerR, p3r, p3l, cornerL]; +} + +/** + * Spiral-Outline: Innenbogen + Aussenbogen + zwei radiale Schliesslinien. + * Entspricht `_aussen_wendel` im Rhino-Plugin (immer vollständig, ohne Cut). + * Die Bögen werden als Kreisbogen-Parameter geliefert (cx/cy/r/a0/a1), die + * radialen Schliesslinien als Segment-Paare. + */ +export interface SpiralOutlineSegments { + /** Radiale Abschlusslinien (jeweils Innenpunkt → Aussenpunkt). */ + lines: [Vec2, Vec2][]; + /** Kreisbogen-Parameter: Innen- und Aussenkreis. */ + arcs: { cx: number; cy: number; r: number; a0: number; a1: number }[]; +} + +function spiralOutline(stair: Stair, halfW: number): SpiralOutlineSegments | null { + const center = stair.center ?? stair.start; + const radius = Math.max(halfW + 0.1, stair.radius ?? stair.width); + const sweep = (stair.sweep ?? 270) * (Math.PI / 180); + const startVec = sub(stair.start, center); + const a0 = Math.atan2(startVec.y, startVec.x); + const a1 = a0 + sweep; + const rIn = Math.max(0.02, radius - halfW); + const rOut = radius + halfW; + + const ptAt = (r: number, a: number): Vec2 => ({ + x: center.x + r * Math.cos(a), + y: center.y + r * Math.sin(a), + }); + + return { + lines: [ + [ptAt(rIn, a0), ptAt(rOut, a0)], + [ptAt(rIn, a1), ptAt(rOut, a1)], + ], + arcs: [ + { cx: center.x, cy: center.y, r: rIn, a0, a1 }, + { cx: center.x, cy: center.y, r: rOut, a0, a1 }, + ], + }; +} + +/** + * Aussenlinie einer Treppe im Grundriss je nach Form: + * • gerade → geschlossenes Rechteck als Polygon-Punkte. + * • L → L-Polygon via Linien-Schnittpunkten. + * • Wendel → `SpiralOutlineSegments` (Bögen + radiale Linien). + * + * Genau einer von `polygon` / `spiral` ist nicht null. + */ +export function stairOutline( + stair: Stair, + totalRise: number, +): { polygon: Vec2[] | null; spiral: SpiralOutlineSegments | null } { + void totalRise; // Signatur-Konsistenz mit stairGeometry; derzeit ungenutzt + const halfW = Math.max(0.05, stair.width / 2); + if (stair.shape === "spiral") { + return { polygon: null, spiral: spiralOutline(stair, halfW) }; + } + if (stair.shape === "L") { + return { polygon: lOutlinePoints(stair, halfW), spiral: null }; + } + return { polygon: straightOutlinePoints(stair, halfW), spiral: null }; +} diff --git a/src/geometry/stairOutline.test.ts b/src/geometry/stairOutline.test.ts new file mode 100644 index 0000000..fe61d40 --- /dev/null +++ b/src/geometry/stairOutline.test.ts @@ -0,0 +1,148 @@ +/** + * Unit-Tests für `stairOutline` (Plan-Aussenlinie). + * + * Prüft: + * • Gerade Treppe → 4-Punkte-Rechteck (polygon, keine spiral). + * • L-Treppe → 6-Punkte-L-Polygon via Linien-Schnittpunkten. + * • Wendeltreppe → SpiralOutlineSegments (2 Bögen, 2 radiale Linien, kein polygon). + * + * Die genauen Koordinaten-Werte werden nur grob geprüft (Vorzeichen, Länge, + * Schliessen); die Geometrie-Logik wird via Abstand-Invarianten verifiziert. + */ + +import { describe, it, expect } from "vitest"; +import { stairOutline } from "./stair"; +import type { Stair, Vec2 } from "../model/types"; + +/** Einfache Distanz-Funktion. */ +const dist = (a: Vec2, b: Vec2) => Math.hypot(b.x - a.x, b.y - a.y); + +/** Minimale gerade Treppe nach rechts (+X). */ +const geradeStair: Stair = { + id: "g1", + type: "stair", + floorId: "eg", + categoryCode: "40", + shape: "straight", + start: { x: 0, y: 0 }, + dir: { x: 1, y: 0 }, + runLength: 3.0, + width: 1.2, + stepCount: 16, +}; + +/** L-Treppe: erster Lauf +X, zweiter Lauf +Y (turn = +1). */ +const lStair: Stair = { + id: "l1", + type: "stair", + floorId: "eg", + categoryCode: "40", + shape: "L", + start: { x: 0, y: 0 }, + dir: { x: 1, y: 0 }, + runLength: 2.0, + run2Length: 2.0, + turn: 1, + width: 1.2, + stepCount: 14, +}; + +/** Wendeltreppe: 270° im Gegenuhrzeigersinn, Radius 1.5 m. */ +const wendelStair: Stair = { + id: "w1", + type: "stair", + floorId: "eg", + categoryCode: "40", + shape: "spiral", + start: { x: 1.5, y: 0 }, + dir: { x: 1, y: 0 }, + runLength: 3.0, // bei Wendel: Pflichtfeld, inhaltlich nicht genutzt + center: { x: 0, y: 0 }, + radius: 1.5, + sweep: 270, + width: 1.2, + stepCount: 12, +}; + +describe("stairOutline — gerade Treppe", () => { + const totalRise = 3.0; + const result = stairOutline(geradeStair, totalRise); + + it("liefert polygon, keine spiral", () => { + expect(result.polygon).not.toBeNull(); + expect(result.spiral).toBeNull(); + }); + + it("polygon hat genau 4 Ecken", () => { + expect(result.polygon!.length).toBe(4); + }); + + it("Lauflaenge entspricht runLength", () => { + // Start-Kante (x=0) vs End-Kante (x=3): Abstand der gleichseitigen Kanten = 3.0 m. + const pts = result.polygon!; + // pts[0] und pts[1] liegen bei x≈0, pts[2] und pts[3] bei x≈runLength. + const startX = (pts[0].x + pts[1].x) / 2; + const endX = (pts[2].x + pts[3].x) / 2; + expect(Math.abs(endX - startX)).toBeCloseTo(geradeStair.runLength, 5); + }); + + it("Breite entspricht stair.width", () => { + const pts = result.polygon!; + // Seitenkante 0→3 und 1→2 haben die Breite = stair.width. + const w = dist(pts[0], pts[1]); + expect(w).toBeCloseTo(geradeStair.width, 5); + }); +}); + +describe("stairOutline — L-Treppe", () => { + const totalRise = 3.0; + const result = stairOutline(lStair, totalRise); + + it("liefert polygon, keine spiral", () => { + expect(result.polygon).not.toBeNull(); + expect(result.spiral).toBeNull(); + }); + + it("polygon hat 6 Ecken (L-Form)", () => { + // 4 Aussen-Eck + 2 Innen-Eck via Linien-Schnittpunkten = 6 Punkte. + expect(result.polygon!.length).toBe(6); + }); + + it("keine NaN-Koordinaten", () => { + for (const p of result.polygon!) { + expect(isFinite(p.x)).toBe(true); + expect(isFinite(p.y)).toBe(true); + } + }); +}); + +describe("stairOutline — Wendeltreppe", () => { + const totalRise = 3.0; + const result = stairOutline(wendelStair, totalRise); + + it("liefert spiral, kein polygon", () => { + expect(result.spiral).not.toBeNull(); + expect(result.polygon).toBeNull(); + }); + + it("zwei radiale Schliesslinien (Start + Ende)", () => { + expect(result.spiral!.lines.length).toBe(2); + }); + + it("zwei Kreisboegen (Innen + Aussen)", () => { + expect(result.spiral!.arcs.length).toBe(2); + }); + + it("Innenradius < Aussenradius", () => { + const [arcIn, arcOut] = result.spiral!.arcs; + expect(arcIn.r).toBeLessThan(arcOut.r); + }); + + it("Startlinie Innen-Punkt liegt auf Innenkreis", () => { + const arcIn = result.spiral!.arcs[0]; + const [lineStart] = result.spiral!.lines; + const [innerPt] = lineStart; + const d = dist({ x: arcIn.cx, y: arcIn.cy }, innerPt); + expect(d).toBeCloseTo(arcIn.r, 4); + }); +}); diff --git a/src/model/types.ts b/src/model/types.ts index c6a60c6..2fa296e 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -741,6 +741,15 @@ export interface Opening { openingDir?: "in" | "out"; /** Optionale Rahmenstärke (quer zur Wand) in Metern für die 3D-Darstellung. */ frameThickness?: number; + /** + * Nur Tür: Sturzlinien (SIA, gestrichelt) quer über die Öffnung an der Wand- + * Innen- und/oder Aussenkante. Zeigt die Überkopf-Projektion des Sturzes. + * "keine" → keine Sturzlinien + * "innen" → eine Linie an der Wand-Innenkante + * "aussen"→ eine Linie an der Wand-Aussenkante + * "beide" → beide Linien (Default wenn nicht gesetzt) + */ + lintelLines?: "keine" | "innen" | "aussen" | "beide"; /** * Optionale Übersteuerung der Strich-/Symbolfarbe; sonst gilt die * Kategorie-Farbe. diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 8fd220f..2908307 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -38,8 +38,15 @@ import { docFromText } from "../text/richText"; import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp"; import type { RoomStampLine } from "../model/roomStamp"; import { stairVerticalExtent, wallReferenceOffset } from "../model/wall"; -import { stairGeometry, stairCut } from "../geometry/stair"; -import { doorSymbol, openingInterval, windowSymbol } from "../geometry/opening"; +import { stairGeometry, stairCut, stairOutline } from "../geometry/stair"; +import type { SpiralOutlineSegments } from "../geometry/stair"; +import { + doorSymbol, + openingInterval, + openingJambs, + wallAxisFrame, + windowSymbol, +} from "../geometry/opening"; import { add, along, @@ -261,6 +268,8 @@ export type Primitive = dash?: number[] | null; /** ID der Öffnung (für Links-Klick-Auswahl von Tür-Schwenkbögen). */ openingId?: string; + /** ID der Treppe (für Links-Klick-Auswahl von Wendel-Outline-Bögen). */ + stairId?: string; greyed?: boolean; } | { @@ -1618,6 +1627,57 @@ function addOpeningSymbol( }); } } + // Sturzlinien (SIA, gestrichelt) — Überkopf-Projektion des Sturzes quer über + // die Öffnung an der Wand-Innen-/Aussenkante. Entspricht `_make_tuer_sturz_curves` + // im Rhino-Plugin. Default „beide" wenn nicht explizit gesetzt. + const lintelMode = o.lintelLines ?? "beide"; + if (lintelMode !== "keine") { + const jambs = openingJambs(wall, o); + if (jambs) { + const { n } = wallAxisFrame(wall); + const total = wallTypeThickness(getWallType(project, wall)); + const refOff = wallReferenceOffset(wall, total); + const halfD = total / 2; + const outerOff = refOff + halfD; + const innerOff = refOff - halfD; + // Strichmuster: gestrichelt (Überkopf-Projektion, SIA-Konvention). + const dash: number[] = [0.18, 0.09]; + const strokeColor = o.color ?? POCHE_STROKE; + /** Linie quer über die Öffnung am gegebenen Perp-Offset. */ + const lintelLine = (perpOff: number) => ({ + a: add(jambs.jambStart, scale(n, perpOff)), + b: add(jambs.jambEnd, scale(n, perpOff)), + }); + if (lintelMode === "aussen" || lintelMode === "beide") { + const seg = lintelLine(outerOff); + out.push({ + kind: "line" as const, + a: seg.a, + b: seg.b, + cls: "door-lintel", + weightMm: SYMBOL_HAIRLINE_MM, + dash, + color: strokeColor, + greyed, + openingId: o.id, + }); + } + if (lintelMode === "innen" || lintelMode === "beide") { + const seg = lintelLine(innerOff); + out.push({ + kind: "line" as const, + a: seg.a, + b: seg.b, + cls: "door-lintel", + weightMm: SYMBOL_HAIRLINE_MM, + dash, + color: strokeColor, + greyed, + openingId: o.id, + }); + } + } + } return; } @@ -1649,6 +1709,26 @@ function addOpeningSymbol( openingId: o.id, }); } + // Brüstungslinie (gepunktet) — zeigt die Brüstung im Grundriss auf der Wandachse. + // Nur bei Fenstern mit Brüstungshöhe > 0. Entspricht der Sill-Linie in + // `_make_oeffnung_preview` und dem Brüstungs-Plansymbol im Rhino-Plugin. + if (o.sillHeight > 0) { + const jambs = openingJambs(wall, o); + if (jambs) { + out.push({ + kind: "line", + a: jambs.jambStart, + b: jambs.jambEnd, + cls: "window-sill", + weightMm: SYMBOL_HAIRLINE_MM, + // Gepunktet (dot-dash) wie in _make_oeffnung_preview DrawDottedLine. + dash: [0.04, 0.08], + color: o.color ?? POCHE_STROKE, + greyed, + openingId: o.id, + }); + } + } } /** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */ @@ -1865,6 +1945,40 @@ function addRoomArea( } } +/** + * Zeichnet die Wendel-Aussenlinie: Innen-/Aussenbogen als Arc-Primitive plus zwei + * radiale Schliesslinien. Wird von `addStairSymbol` aufgerufen wenn `shape === "spiral"`. + */ +function _addSpiralOutline( + out: Primitive[], + seg: SpiralOutlineSegments, + stroke: string, + lwMm: number, + greyed: boolean, + stairId: string, +): void { + // Radiale Schliesslinien (Startwinkel + Endwinkel). + for (const [a, b] of seg.lines) { + out.push({ kind: "line", a, b, cls: "stair-outline", weightMm: lwMm, color: stroke, greyed, stairId }); + } + // Innen- und Aussenbogen. + for (const arc of seg.arcs) { + const from: Vec2 = { x: arc.cx + arc.r * Math.cos(arc.a0), y: arc.cy + arc.r * Math.sin(arc.a0) }; + const to: Vec2 = { x: arc.cx + arc.r * Math.cos(arc.a1), y: arc.cy + arc.r * Math.sin(arc.a1) }; + out.push({ + kind: "arc", + center: { x: arc.cx, y: arc.cy }, + from, + to, + r: arc.r, + cls: "stair-outline", + weightMm: lwMm, + greyed, + stairId, + }); + } +} + /** * Treppen-Symbol im Grundriss (DOSSIER/SIA): * • Tritte als Rechtecke: die Tritt-Trennlinien werden gezeichnet — unter der @@ -1969,6 +2083,27 @@ function addStairSymbol( const [h1, tip, h2] = geo.arrow.head; out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId }); out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId }); + + // Aussenlinie (Outline) der Treppe — schliessendes Umriss-Polygon (gerade/L) + // bzw. Innen-/Aussenbogen + radiale Schliesslinien (Wendel). + // Entspricht `_aussen_gerade` / `_aussen_l_polygon` / `_aussen_wendel` im + // Rhino-Plugin. Stets durchgezogen, volle Strichstärke. + const outline = stairOutline(stair, totalRise); + if (outline.polygon && outline.polygon.length >= 3) { + out.push({ + kind: "polygon", + pts: outline.polygon, + fill: "none", + stroke, + strokeWidthMm: lwMm, + hatch: NO_HATCH, + greyed, + stairId, + }); + } + if (outline.spiral) { + _addSpiralOutline(out, outline.spiral, stroke, lwMm, greyed, stairId); + } } const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y);