Bauteile Gruppe A (4-6): Treppe-Referenz, Fenster-Fluegel, Tuer-wandoeffnung

- Treppe referenz links/mitte/rechts (Stair.referenz, Default mitte): Perp-Versatz
  referenzMidShift auf start/center, Lauflinie/Pfeil bleiben visuell zentriert.
- Fenster Fluegel-Mittelpfosten (Opening.wingCount 1-4, Default 1): wingCount-1
  Querlinien im Rahmen (window-mullion), nur mittel/fein.
- Tuer-Typ wandoeffnung (Opening.doorType, Default normal): ueberspringt
  Tuerblatt + Schwenkbogen, Laibung/Sturz bleiben.
- Alle Modellfelder optional/rueckwaertskompatibel. 17 neue Tests.
  vitest 292/292, tsc sauber.
This commit is contained in:
2026-07-05 02:33:48 +02:00
parent 6e5998ce72
commit 26fbd11b73
6 changed files with 361 additions and 31 deletions
+27 -2
View File
@@ -170,12 +170,21 @@ export interface WindowSymbol {
frame: Vec2[];
/** Glaslinien-Segmente quer über die Lücke (12 parallele Linien). */
glassLines: [Vec2, Vec2][];
/**
* Flügel-Mittelpfosten (`wingCount 1` Querlinien), jeweils von einer
* Laibungsseite zur anderen (parallel zu den Laibungslinien, quer zur
* Öffnungsrichtung), gleichmäßig zwischen den beiden Pfosten verteilt. Bei
* `wingCount ≤ 1` leer. Entspricht den Mittelpfosten in `_make_oeffnung_pieces`
* des Rhino-Plugins (`oeff_fluegel`).
*/
mullionLines: [Vec2, Vec2][];
}
/**
* Löst die Fenster-Symbolik auf: ein Rahmen-Rechteck über die Lücke (volle
* Wanddicke) plus 12 Glaslinien (dünne parallele Linien in der Wandmitte,
* `count` steuert die Anzahl je Detailgrad).
* `count` steuert die Anzahl je Detailgrad) plus optionale Flügel-Mittelpfosten
* (`wingCount 1` Querlinien).
*/
export function windowSymbol(
project: Project,
@@ -205,5 +214,21 @@ export function windowSymbol(
const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2);
glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]);
}
return { frame, glassLines };
// Flügel-Mittelpfosten: (wingCount 1) Querlinien gleichmäßig zwischen den
// Pfosten. Jede Linie läuft quer über die Öffnung (n-Richtung), auf ~60 % der
// Mauerdicke symmetrisch um die Wandmitte (Referenz-Offset) — analog Rhino, wo
// die Pfosten x_mid = inner_l + span·(i/fluegel) über die Rahmentiefe sitzen.
const mullionLines: [Vec2, Vec2][] = [];
const wings = Math.max(1, Math.min(4, Math.round(o.wingCount ?? 1)));
if (wings > 1) {
const postDepth = total * 0.6;
const pOuter = refOff + postDepth / 2;
const pInner = refOff - postDepth / 2;
for (let i = 1; i < wings; i++) {
const t = i / wings; // gleichmäßige Teilung entlang der Achse
const onAxis = add(jambStart, scale(sub(jambEnd, jambStart), t));
mullionLines.push([add(onAxis, scale(n, pOuter)), add(onAxis, scale(n, pInner))]);
}
}
return { frame, glassLines, mullionLines };
}
+53 -10
View File
@@ -27,6 +27,32 @@ const norm = (a: Vec2): Vec2 => {
/** Linke Normale (n = (u.y, u.x)) — quer zur Laufrichtung. */
const leftN = (u: Vec2): Vec2 => ({ x: -u.y, y: u.x });
/**
* Perp-Versatz von der Referenzkante zur visuellen Treppen-MITTE, entlang der
* linken Normale `n = leftN(u)`. Entspricht `mid_off` aus `_treppe_2d_side_offsets`
* im Rhino-Plugin (Konvention: `perp` = CCW-Normale = linke Normale):
* • referenz "links" → Achse ist die LINKE Kante; die Mitte liegt eine
* halbe Breite in n-Richtung (rechts) → mid_off = width/2.
* • referenz "rechts" → Achse ist die RECHTE Kante; die Mitte liegt eine
* halbe Breite in +n-Richtung (links) → mid_off = +width/2.
* • referenz "mitte"/undefined → Achse ist bereits die Mitte → 0.
*
* ANNAHME (selbstkritisch): „links" heisst „linke Kante in Laufrichtung", und die
* linke Kante liegt in +n (n = leftN(u), 90° links der Laufrichtung). Von der
* linken Kante zur Mitte geht es also nach n, daher mid_off = width/2. Das
* deckt sich mit `_treppe_2d_side_offsets` (referenz="links" → off_a=0, off_b=b,
* Mitte = b/2·perp). Wird die Richtung im UI als seitenverkehrt empfunden, ist
* hier das Vorzeichen zu drehen — die Geometrie bleibt in sich konsistent.
*/
function referenzMidShift(stair: Stair, u: Vec2): Vec2 {
const ref = stair.referenz ?? "mitte";
if (ref === "mitte") return { x: 0, y: 0 };
const n = leftN(u);
const halfW = Math.max(0.05, stair.width / 2);
const midOff = ref === "links" ? -halfW : halfW;
return scale(n, midOff);
}
/** SIA-nahe Schrittregel: gute Steigungshöhe ≈ 0.17 m, Auftritt ≈ 0.29 m. */
export const IDEAL_RISER = 0.17;
export const IDEAL_TREAD = 0.29;
@@ -105,13 +131,22 @@ export function stairGeometry(stair: Stair, totalRise: number): StairGeometry {
const u = norm(stair.dir);
const n = leftN(u);
if (stair.shape === "spiral") {
return spiralGeometry(stair, totalRise, steps, riserHeight, halfW);
// Referenz (links/mitte/rechts): die gespeicherte Achse wird auf die visuelle
// Treppen-MITTE versetzt, danach greift die bestehende, um die Achse zentrierte
// Tritt-/Lauflinien-Logik unverändert. So bleibt die Lauflinie samt Pfeil stets
// auf der Mitte (nicht auf der Referenzkante). "mitte"/undefined ⇒ Nullversatz.
const c = referenzMidShift(stair, u);
const s = stair.referenz && stair.referenz !== "mitte"
? { ...stair, start: add(stair.start, c), center: stair.center ? add(stair.center, c) : stair.center }
: stair;
if (s.shape === "spiral") {
return spiralGeometry(s, totalRise, steps, riserHeight, halfW);
}
if (stair.shape === "L") {
return lGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
if (s.shape === "L") {
return lGeometry(s, totalRise, steps, riserHeight, halfW, u, n);
}
return straightGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
return straightGeometry(s, totalRise, steps, riserHeight, halfW, u, n);
}
/** Gerade Treppe: gleich breite Tritte über die Lauflänge. */
@@ -530,11 +565,19 @@ export function stairOutline(
): { 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) };
// Referenz (links/mitte/rechts): die gespeicherte Achse zuerst auf die
// visuelle Treppen-Mitte versetzen, danach die um die Mitte zentrierte
// Aussenlinien-Logik unverändert anwenden (identisch zu `stairGeometry`).
const u = norm(stair.dir);
const c = referenzMidShift(stair, u);
const s = stair.referenz && stair.referenz !== "mitte"
? { ...stair, start: add(stair.start, c), center: stair.center ? add(stair.center, c) : stair.center }
: stair;
if (s.shape === "spiral") {
return { polygon: null, spiral: spiralOutline(s, halfW) };
}
if (stair.shape === "L") {
return { polygon: lOutlinePoints(stair, halfW), spiral: null };
if (s.shape === "L") {
return { polygon: lOutlinePoints(s, halfW), spiral: null };
}
return { polygon: straightOutlinePoints(stair, halfW), spiral: null };
return { polygon: straightOutlinePoints(s, halfW), spiral: null };
}
+64 -1
View File
@@ -11,7 +11,7 @@
*/
import { describe, it, expect } from "vitest";
import { stairOutline } from "./stair";
import { stairOutline, stairGeometry } from "./stair";
import type { Stair, Vec2 } from "../model/types";
/** Einfache Distanz-Funktion. */
@@ -146,3 +146,66 @@ describe("stairOutline — Wendeltreppe", () => {
expect(d).toBeCloseTo(arcIn.r, 4);
});
});
describe("stairOutline — Referenz links/mitte/rechts (Item 4)", () => {
const totalRise = 3.0;
// Gerade Treppe entlang +X, Breite 1.2 m. Perp (linke Normale) = (0, +1).
const base: Stair = { ...geradeStair, referenz: "mitte" };
const mitte = stairOutline(base, totalRise).polygon!;
const links = stairOutline({ ...base, referenz: "links" }, totalRise).polygon!;
const rechts = stairOutline({ ...base, referenz: "rechts" }, totalRise).polygon!;
/** Mittlere Y-Höhe eines Rechteck-Polygons. */
const midY = (pts: Vec2[]) => pts.reduce((s, p) => s + p.y, 0) / pts.length;
it("Breite bleibt bei allen Referenzen gleich", () => {
for (const poly of [mitte, links, rechts]) {
expect(dist(poly[0], poly[1])).toBeCloseTo(geradeStair.width, 5);
}
});
it("referenz=links: Treppe liegt RECHTS der Achse (n) → Mitte um width/2 nach Y", () => {
// Achse (start/dir) = linke Kante; die Fläche versetzt sich in n = (0,1).
expect(midY(links) - midY(mitte)).toBeCloseTo(-geradeStair.width / 2, 5);
});
it("referenz=rechts: Treppe liegt LINKS der Achse (+n) → Mitte um width/2 nach +Y", () => {
expect(midY(rechts) - midY(mitte)).toBeCloseTo(+geradeStair.width / 2, 5);
});
it("bei referenz=links liegt die Achse (y=0) auf der linken Kante der Treppe", () => {
// Linke Kante = max-Y-Kante der Fläche (n = +Y). Sie soll bei y=0 liegen.
const maxY = Math.max(...links.map((p) => p.y));
expect(maxY).toBeCloseTo(0, 5);
});
it("mitte entspricht dem Default (kein referenz-Feld gesetzt)", () => {
const noRef = stairOutline({ ...geradeStair }, totalRise).polygon!;
for (let i = 0; i < noRef.length; i++) {
expect(noRef[i].x).toBeCloseTo(mitte[i].x, 6);
expect(noRef[i].y).toBeCloseTo(mitte[i].y, 6);
}
});
});
describe("stairGeometry — Lauflinie bleibt visuell zentriert (Item 4)", () => {
const totalRise = 3.0;
const links = stairGeometry({ ...geradeStair, referenz: "links" }, totalRise);
const mitte = stairGeometry({ ...geradeStair, referenz: "mitte" }, totalRise);
it("Lauflinie liegt auf der Treppen-Mitte, nicht auf der Referenzkante (y=0)", () => {
// Bei referenz=links liegt die Mitte bei y = width/2 = 0.6.
for (const p of links.runLine) {
expect(p.y).toBeCloseTo(-geradeStair.width / 2, 5);
}
// Bei mitte liegt die Lauflinie auf y=0 (Achse = Mitte).
for (const p of mitte.runLine) {
expect(p.y).toBeCloseTo(0, 5);
}
});
it("Tritte sind bei referenz=links um width/2 versetzt", () => {
const dy = links.treads[0].pts[0].y - mitte.treads[0].pts[0].y;
expect(dy).toBeCloseTo(-geradeStair.width / 2, 5);
});
});