Bauteile Gruppe A: Treppe-Aussenlinie, Fenster-Bruestung, Tuer-Sturz (Rhino-Vorbild)

- Treppe Aussenlinie/Outline (stair.ts stairOutline): gerade=4-Punkt-Rechteck,
  L=6-Punkt-Polygon via lineIntersect2d, Wendel=2 Boegen+2 Radiallinien; in
  addStairSymbol als durchgezogene Umrisslinie. (Rhino _aussen_gerade/_l/_wendel)
- Fenster Bruestungslinie: bei sillHeight>0 gepunktete Linie auf der Wandachse
  zwischen den Pfosten (Klasse window-sill).
- Tuer Sturzlinien (SIA): gestrichelte Linien an Wand-Innen/-Aussenkante, neues
  optionales Opening-Feld lintelLines (keine/innen/aussen/beide, Default beide),
  rueckwaertskompatibel.
- 12 neue Tests (stairOutline.test.ts). vitest 275/275, tsc sauber.
This commit is contained in:
2026-07-05 01:56:07 +02:00
parent b65c676643
commit 6e5998ce72
4 changed files with 423 additions and 2 deletions
+129
View File
@@ -409,3 +409,132 @@ export function pointHitsStair(p: Vec2, geo: StairGeometry): boolean {
if (geo.landing && pointInPolygon(p, geo.landing)) return true; if (geo.landing && pointInPolygon(p, geo.landing)) return true;
return false; 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 };
}
+148
View File
@@ -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);
});
});
+9
View File
@@ -741,6 +741,15 @@ export interface Opening {
openingDir?: "in" | "out"; openingDir?: "in" | "out";
/** Optionale Rahmenstärke (quer zur Wand) in Metern für die 3D-Darstellung. */ /** Optionale Rahmenstärke (quer zur Wand) in Metern für die 3D-Darstellung. */
frameThickness?: number; 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 * Optionale Übersteuerung der Strich-/Symbolfarbe; sonst gilt die
* Kategorie-Farbe. * Kategorie-Farbe.
+137 -2
View File
@@ -38,8 +38,15 @@ import { docFromText } from "../text/richText";
import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp"; import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp";
import type { RoomStampLine } from "../model/roomStamp"; import type { RoomStampLine } from "../model/roomStamp";
import { stairVerticalExtent, wallReferenceOffset } from "../model/wall"; import { stairVerticalExtent, wallReferenceOffset } from "../model/wall";
import { stairGeometry, stairCut } from "../geometry/stair"; import { stairGeometry, stairCut, stairOutline } from "../geometry/stair";
import { doorSymbol, openingInterval, windowSymbol } from "../geometry/opening"; import type { SpiralOutlineSegments } from "../geometry/stair";
import {
doorSymbol,
openingInterval,
openingJambs,
wallAxisFrame,
windowSymbol,
} from "../geometry/opening";
import { import {
add, add,
along, along,
@@ -261,6 +268,8 @@ export type Primitive =
dash?: number[] | null; dash?: number[] | null;
/** ID der Öffnung (für Links-Klick-Auswahl von Tür-Schwenkbögen). */ /** ID der Öffnung (für Links-Klick-Auswahl von Tür-Schwenkbögen). */
openingId?: string; openingId?: string;
/** ID der Treppe (für Links-Klick-Auswahl von Wendel-Outline-Bögen). */
stairId?: string;
greyed?: boolean; 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; return;
} }
@@ -1649,6 +1709,26 @@ function addOpeningSymbol(
openingId: o.id, 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). */ /** 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): * Treppen-Symbol im Grundriss (DOSSIER/SIA):
* • Tritte als Rechtecke: die Tritt-Trennlinien werden gezeichnet — unter der * • Tritte als Rechtecke: die Tritt-Trennlinien werden gezeichnet — unter der
@@ -1969,6 +2083,27 @@ function addStairSymbol(
const [h1, tip, h2] = geo.arrow.head; 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: 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 }); 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); const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y);