26fbd11b73
- 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.
235 lines
9.2 KiB
TypeScript
235 lines
9.2 KiB
TypeScript
// Geometrie-Helfer für Öffnungen (Fenster/Türen) — reine Funktionen, die eine
|
||
// `Opening` relativ zu ihrer Wirts-Wand in konkrete Welt-Geometrie auflösen:
|
||
// • die Öffnungs-Lücke entlang der Wandachse (from/to in Metern),
|
||
// • das Türblatt (Scharnier → offenes/geschlossenes Ende) + Schwenkbogen,
|
||
// • die Fenster-Rahmen-/Glaslinien quer über die Lücke,
|
||
// • die vertikale Lage (UK/OK der Öffnung, absolut in Metern).
|
||
//
|
||
// Da JEDE Größe aus der aktuellen Wandachse (`wall.start`/`wall.end`) abgeleitet
|
||
// wird, folgt eine Öffnung ihrer Wand automatisch, sobald die Wand bewegt wird —
|
||
// es gibt keine eingebackenen Weltkoordinaten.
|
||
//
|
||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||
|
||
import type { Opening, Project, Vec2, Wall } from "../model/types";
|
||
import { getWallType, wallTypeThickness } from "../model/types";
|
||
import { add, along, leftNormal, normalize, scale, sub } from "../model/geometry";
|
||
import { wallReferenceOffset, wallVerticalExtent } from "../model/wall";
|
||
|
||
const DEG = Math.PI / 180;
|
||
|
||
/** Achslänge einer Wand (Meter). */
|
||
export function wallAxisLength(wall: Wall): number {
|
||
return Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
|
||
}
|
||
|
||
/**
|
||
* Öffnungs-Intervall entlang der Wandachse (from..to in Metern), auf die
|
||
* Achslänge geklemmt. Liefert null, wenn das Intervall entartet (Breite ≤ 0 oder
|
||
* vollständig außerhalb der Achse).
|
||
*/
|
||
export function openingInterval(
|
||
wall: Wall,
|
||
o: Opening,
|
||
): { from: number; to: number } | null {
|
||
const axis = wallAxisLength(wall);
|
||
const from = Math.max(0, Math.min(o.position, axis));
|
||
const to = Math.max(from, Math.min(o.position + o.width, axis));
|
||
if (to - from < 1e-4) return null;
|
||
return { from, to };
|
||
}
|
||
|
||
/**
|
||
* Vertikale Lage einer Öffnung (absolute Z-Werte in Metern) relativ zur Wand-UK.
|
||
* Türen sitzen am Boden (sillHeight 0); Fenster ab Brüstung. Die OK wird auf die
|
||
* Wand-OK geklemmt, damit die Öffnung nie über den Wandkopf hinausragt.
|
||
*/
|
||
export function openingVerticalExtent(
|
||
project: Project,
|
||
wall: Wall,
|
||
o: Opening,
|
||
): { zBottom: number; zTop: number } {
|
||
const { zBottom, zTop } = wallVerticalExtent(project, wall);
|
||
const sill = Math.max(0, o.sillHeight);
|
||
const oBottom = zBottom + sill;
|
||
const oTop = Math.min(zTop, oBottom + o.height);
|
||
return { zBottom: oBottom, zTop: oTop };
|
||
}
|
||
|
||
/** Einheits-Laufrichtung (u) und linke Normale (n) der Wandachse. */
|
||
export function wallAxisFrame(wall: Wall): { u: Vec2; n: Vec2 } {
|
||
const u = normalize(sub(wall.end, wall.start));
|
||
return { u, n: leftNormal(u) };
|
||
}
|
||
|
||
/** Die beiden Pfosten-Punkte (auf der Achse) einer Öffnung im Grundriss. */
|
||
export function openingJambs(
|
||
wall: Wall,
|
||
o: Opening,
|
||
): { jambStart: Vec2; jambEnd: Vec2 } | null {
|
||
const iv = openingInterval(wall, o);
|
||
if (!iv) return null;
|
||
return {
|
||
jambStart: along(wall.start, wall.end, iv.from),
|
||
jambEnd: along(wall.start, wall.end, iv.to),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Die Lücken-Rechteck-Ecken (über die volle Wanddicke), z. B. um im Plan die
|
||
* Öffnung als Aussparung zu markieren (Auswahl-Highlight). Reihenfolge:
|
||
* jambStart(+n/2) → jambEnd(+n/2) → jambEnd(−n/2) → jambStart(−n/2).
|
||
*/
|
||
export function openingGapQuad(project: Project, wall: Wall, o: Opening): Vec2[] | null {
|
||
const jambs = openingJambs(wall, o);
|
||
if (!jambs) return null;
|
||
const { n } = wallAxisFrame(wall);
|
||
const total = wallTypeThickness(getWallType(project, wall));
|
||
const refOff = wallReferenceOffset(wall, total);
|
||
const outer = refOff + total / 2;
|
||
const inner = refOff - total / 2;
|
||
const { jambStart, jambEnd } = jambs;
|
||
return [
|
||
add(jambStart, scale(n, outer)),
|
||
add(jambEnd, scale(n, outer)),
|
||
add(jambEnd, scale(n, inner)),
|
||
add(jambStart, scale(n, inner)),
|
||
];
|
||
}
|
||
|
||
/** Mittelpunkt einer Öffnung im Grundriss (Achse) — für HUD/Auswahl-Anker. */
|
||
export function openingCenter(wall: Wall, o: Opening): Vec2 | null {
|
||
const iv = openingInterval(wall, o);
|
||
if (!iv) return null;
|
||
return along(wall.start, wall.end, (iv.from + iv.to) / 2);
|
||
}
|
||
|
||
// ── Tür-Symbol (Plan) ────────────────────────────────────────────────────────
|
||
|
||
/** Aufgelöste Tür-Symbol-Geometrie im Grundriss (Weltkoordinaten). */
|
||
export interface DoorSymbol {
|
||
/** Scharnierpunkt (auf der Achse). */
|
||
hinge: Vec2;
|
||
/** Ende des Blatts in GEÖFFNETER Stellung. */
|
||
openEnd: Vec2;
|
||
/** Ende des Blatts in GESCHLOSSENER Stellung (Bogen-Startpunkt). */
|
||
closedEnd: Vec2;
|
||
/** Radius (= Türbreite) des Schwenkbogens. */
|
||
radius: number;
|
||
/** Beide Pfosten-Punkte (für Rahmen-/Anschlagstriche). */
|
||
jambStart: Vec2;
|
||
jambEnd: Vec2;
|
||
/** Achs-Normale (für kurze Anschlagstriche quer zur Wand). */
|
||
normal: Vec2;
|
||
}
|
||
|
||
/**
|
||
* Löst die Tür-Symbolik auf. `swing` wählt die Aufschlagseite (linke/rechte
|
||
* Achs-Normale), `openingDir` ("out") spiegelt sie auf die andere Seite,
|
||
* `hinge` wählt den Scharnier-Pfosten, `swingAngle` (Grad, Default 90) bestimmt,
|
||
* wie weit das Blatt in der offenen Stellung aufsteht.
|
||
*/
|
||
export function doorSymbol(wall: Wall, o: Opening): DoorSymbol | null {
|
||
const jambs = openingJambs(wall, o);
|
||
if (!jambs) return null;
|
||
const { u, n } = wallAxisFrame(wall);
|
||
const { jambStart, jambEnd } = jambs;
|
||
const width = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y);
|
||
|
||
const swingSign = (o.swing ?? "left") === "left" ? 1 : -1;
|
||
const dirSign = (o.openingDir ?? "in") === "in" ? 1 : -1;
|
||
const swingDir = scale(n, swingSign * dirSign);
|
||
|
||
const hinge = (o.hinge ?? "start") === "start" ? jambStart : jambEnd;
|
||
// Geschlossene Richtung zeigt vom Scharnier zum GEGENüberliegenden Pfosten.
|
||
const closedDir = (o.hinge ?? "start") === "start" ? u : scale(u, -1);
|
||
|
||
const angle = (o.swingAngle ?? 90) * DEG;
|
||
// Offenes Blatt-Ende: um `angle` von der geschlossenen Richtung zur
|
||
// Schwenkrichtung gedreht (Interpolation über die Basis closedDir/swingDir).
|
||
const openDir = {
|
||
x: closedDir.x * Math.cos(angle) + swingDir.x * Math.sin(angle),
|
||
y: closedDir.y * Math.cos(angle) + swingDir.y * Math.sin(angle),
|
||
};
|
||
return {
|
||
hinge,
|
||
openEnd: add(hinge, scale(openDir, width)),
|
||
closedEnd: add(hinge, scale(closedDir, width)),
|
||
radius: width,
|
||
jambStart,
|
||
jambEnd,
|
||
normal: n,
|
||
};
|
||
}
|
||
|
||
// ── Fenster-Symbol (Plan) ────────────────────────────────────────────────────
|
||
|
||
/** Aufgelöste Fenster-Linien im Grundriss (Weltkoordinaten). */
|
||
export interface WindowSymbol {
|
||
/** Rahmen-Rechteck (volle Dicke) als Ecken. */
|
||
frame: Vec2[];
|
||
/** Glaslinien-Segmente quer über die Lücke (1–2 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 1–2 Glaslinien (dünne parallele Linien in der Wandmitte,
|
||
* `count` steuert die Anzahl je Detailgrad) plus optionale Flügel-Mittelpfosten
|
||
* (`wingCount − 1` Querlinien).
|
||
*/
|
||
export function windowSymbol(
|
||
project: Project,
|
||
wall: Wall,
|
||
o: Opening,
|
||
glassCount: number,
|
||
): WindowSymbol | null {
|
||
const jambs = openingJambs(wall, o);
|
||
if (!jambs) return null;
|
||
const { n } = wallAxisFrame(wall);
|
||
const total = wallTypeThickness(getWallType(project, wall));
|
||
const refOff = wallReferenceOffset(wall, total);
|
||
const outer = refOff + total / 2;
|
||
const inner = refOff - total / 2;
|
||
const { jambStart, jambEnd } = jambs;
|
||
const frame = [
|
||
add(jambStart, scale(n, outer)),
|
||
add(jambEnd, scale(n, outer)),
|
||
add(jambEnd, scale(n, inner)),
|
||
add(jambStart, scale(n, inner)),
|
||
];
|
||
const glassLines: [Vec2, Vec2][] = [];
|
||
const count = Math.max(1, glassCount);
|
||
for (let i = 0; i < count; i++) {
|
||
// Glaslinien symmetrisch um die Wandmitte verteilt (Abstand = Dicke/8).
|
||
const spread = total / 8;
|
||
const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2);
|
||
glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]);
|
||
}
|
||
// 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 };
|
||
}
|