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;
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);
});
});