Files
DOSSIER-STANDALONE/src/geometry/opening.ts
T
karim 4319e12e35 Tür: zweiflügelige Türen wirklich rendern (leafCount 2D+3D)
DoorType.leafCount wurde bisher von keinem Renderer gelesen — eine zweiflügelige
Tür sah aus wie eine einflügelige (Designdoc-Prio #3).

- opening.ts: additive doorLeaves(wall, o, leafCount) — einflüglig ein Blatt
  voller Breite, zweiflüglig zwei halbbreite Blätter an gegenüberliegenden
  Pfosten, beide zur selben Seite (symmetrische Doppeltür). doorSymbol bleibt
  unverändert (Parität).
- generatePlan: Tür-Zweig zeichnet je Blatt Linie + Schwenkbogen (leafCount aus
  DoorType).
- toWalls3d: Tür-wingCount = leafCount → mittiger Anschlagpfosten (Stulp) im 3D.
- +1 Test (2 Blätter/Bögen, halber Radius). 669/669 grün.
2026-07-10 02:06:37 +02:00

459 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, SashDef, 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,
};
}
/** Ein einzelnes Türblatt (Grundriss): Scharnier + geschlossenes/offenes Ende + Radius. */
export interface DoorLeaf {
hinge: Vec2;
openEnd: Vec2;
closedEnd: Vec2;
radius: number;
}
/** Blatt-Geometrie aus Scharnier, geschlossener Richtung, Schwenkrichtung, Winkel, Breite. */
function computeLeaf(
hinge: Vec2,
closedDir: Vec2,
swingDir: Vec2,
angle: number,
width: number,
): DoorLeaf {
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,
};
}
/**
* Türblätter im Grundriss (1 oder 2 Flügel). Einflüglig: ein Blatt über die
* volle Breite (Scharnier je `o.hinge`). Zweiflüglig (`leafCount === 2`): zwei
* halbbreite Blätter, an den GEGENüberliegenden Pfosten angeschlagen, beide zur
* selben Seite schwenkend (symmetrische Doppeltür / Stulptür). `doorSymbol`
* bleibt unverändert (einflügelig) für Bestandspfade/Parität; dies ist additiv.
*/
export function doorLeaves(wall: Wall, o: Opening, leafCount: number): DoorLeaf[] {
const jambs = openingJambs(wall, o);
if (!jambs) return [];
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 angle = (o.swingAngle ?? 90) * DEG;
if (leafCount >= 2) {
const hw = width / 2;
return [
computeLeaf(jambStart, u, swingDir, angle, hw),
computeLeaf(jambEnd, scale(u, -1), swingDir, angle, hw),
];
}
const hinge = (o.hinge ?? "start") === "start" ? jambStart : jambEnd;
const closedDir = (o.hinge ?? "start") === "start" ? u : scale(u, -1);
return [computeLeaf(hinge, closedDir, swingDir, angle, width)];
}
// ── 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 (13 parallele Linien). */
glassLines: [Vec2, Vec2][];
/**
* Flügel-/Pfosten-Trennlinien, jeweils von einer Laibungsseite zur anderen
* (parallel zu den Laibungslinien, quer zur Öffnungsrichtung). Ist `sashes`
* (dritter Parameter von {@link windowSymbol}) gesetzt, ergeben sich die
* Positionen aus den Flügel-/Pfostenbreiten (siehe `sashSegments`); sonst
* (Alt-Fall) `wingCount 1` gleichmäßig verteilte Querlinien. Entspricht den
* Mittelpfosten in `_make_oeffnung_pieces` des Rhino-Plugins (`oeff_fluegel`).
*/
mullionLines: [Vec2, Vec2][];
/**
* Flügelrahmen (Flügelkontur) je ÖFFENBAREM Flügel (Dreh/Kipp/Drehkipp/
* Schiebe): ein Rechteck INNERHALB des Blendrahmens (Schachtelung
* Blendrahmen → Flügelrahmen → Glas), das den öffenbaren Flügel vom festen
* Feld unterscheidet. Feste Flügel (`opening:"fest"`) bekommen KEINEN
* Flügelrahmen — dort sitzt das Glas direkt im Blendrahmen. Nur befüllt im
* neuen (spec-)Pfad; leer im Alt-Fall (kein `spec`).
*/
sashFrames: Vec2[][];
/**
* (Alt-Feld, für Rückwärtskompatibilität der Schnittstelle beibehalten) — im
* neuen Pfad leer; die Öffenbarkeit wird jetzt über {@link sashFrames}
* ausgedrückt statt über eine Diagonale quer über die Box.
*/
sashOpeningLines: [Vec2, Vec2][];
}
/**
* Render-Parameter des Fensters für die reiche 2D-Symbolik (aus dem
* referenzierten {@link WindowType}): Flügeltabelle, Profilbreite, Rahmentiefe
* und Schichteinzug. Fehlt der Spec (kein Typ auflösbar), zeichnet
* {@link windowSymbol} das schlichte Alt-Symbol (`o.wingCount`, volle Wanddicke).
*/
export interface WindowRenderSpec {
sashes: SashDef[];
/** Sichtbare Profilbreite (Meter) von Blend-/Flügelrahmen in der Öffnungsebene. */
frameWidth: number;
/** Rahmentiefe quer zur Wand (Meter); fehlt ⇒ volle Wanddicke. */
frameDepth?: number;
/** Schichteinzug: Abstand der Rahmen-Vorderkante von {@link insetFace} (Meter). */
insetFromFace?: number;
/** Bezugsfläche des Schichteinzugs; Default "aussen". */
insetFace?: "aussen" | "innen";
}
/** Ein Segment (Flügel/Pfosten) der Flügeleinteilung, mit aufgelöster Breite. */
interface SashSegment {
sash: SashDef;
/** Abstand vom Laibungsstart in Metern (0 = jambStart). */
from: number;
/** Abstand vom Laibungsstart in Metern. */
to: number;
}
/** Default-Pfostenbreite (Meter), wenn ein `kind:"pfosten"`-Eintrag keine `postWidth` angibt. */
const DEFAULT_POST_WIDTH = 0.06;
/**
* Löst eine Flügeleinteilung ({@link SashDef}-Liste) zu konkreten Segment-
* Intervallen (Meter, gemessen ab Laibungsstart) über die gegebene
* Gesamtbreite auf: `kind:"pfosten"` und Flügel mit `autoWidth === false`
* erhalten ihre feste Breite (`postWidth` bzw. `width`), die restlichen
* (`autoWidth`, Default) teilen sich die verbleibende Breite gleichmäßig.
* Degeneriert bei GLEICHMÄSSIGEN Flügeln (alle `autoWidth`, keine Pfosten) zur
* bisherigen `wingCount`-Gleichverteilung (Regressionssicherheit).
*/
function sashSegments(sashes: SashDef[], totalWidth: number): SashSegment[] {
if (sashes.length === 0) return [];
let fixedSum = 0;
let autoCount = 0;
for (const s of sashes) {
if (s.kind === "pfosten") fixedSum += Math.max(0, s.postWidth ?? DEFAULT_POST_WIDTH);
else if (s.autoWidth === false) fixedSum += Math.max(0, s.width ?? 0);
else autoCount++;
}
const remaining = Math.max(0, totalWidth - fixedSum);
const autoWidth = autoCount > 0 ? remaining / autoCount : 0;
const segs: SashSegment[] = [];
let cursor = 0;
for (const s of sashes) {
const w =
s.kind === "pfosten"
? Math.max(0, s.postWidth ?? DEFAULT_POST_WIDTH)
: s.autoWidth === false
? Math.max(0, s.width ?? 0)
: autoWidth;
segs.push({ sash: s, from: cursor, to: cursor + w });
cursor += w;
}
return segs;
}
/**
* Löst die Fenster-Symbolik auf: ein Rahmen-Rechteck über die Lücke (volle
* Wanddicke) plus 13 Glaslinien (dünne parallele Linien in der Wandmitte,
* `glassCount` steuert die Anzahl) plus optionale Flügel-/Pfosten-Trennlinien
* und Öffnungsandeutungen je Flügel.
*
* `sashes` (optional) ist die aufgelöste Flügeltabelle (siehe
* `sashesOfWindowType`) EINES referenzierten Fenstertyps. Fehlt sie (kein Typ
* referenziert/auflösbar), bleibt die Mittelpfosten-Berechnung beim
* BISHERIGEN Verhalten — `o.wingCount` gleichmäßig verteilt, KEINE
* Öffnungsandeutungen — exakt wie vor dieser Erweiterung (Rückwärtskompatibilität).
*/
export function windowSymbol(
project: Project,
wall: Wall,
o: Opening,
glassCount: number,
spec?: WindowRenderSpec,
): 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;
// ── Reicher Pfad (typisiertes Fenster mit Flügeltabelle) ─────────────────
// Blendrahmen an der realen Einbaulage (Schichteinzug), je Flügel
// Blendrahmen → Flügelrahmen (öffenbar) → Glas. Feste Flügel: Glas direkt im
// Blendrahmen (kein Flügelrahmen) — so unterscheidet sich fest von öffenbar.
if (spec && spec.sashes.length > 0) {
const fw = Math.max(0.01, spec.frameWidth);
const depth = spec.frameDepth && spec.frameDepth > 1e-9 ? spec.frameDepth : total;
const inset = Math.max(0, spec.insetFromFace ?? 0);
const halfT = total / 2;
const sign = (spec.insetFace ?? "aussen") === "innen" ? -1 : 1;
// Rahmen-Vorderkante bündig mit der Bezugsfläche, um `inset` zurück; der
// Körper reicht `depth` zur Wandmitte. Auf die Wanddicke geklemmt (mirror
// von resolveFrameNormalRange im 3D). Alles relativ zur Referenzlinie.
let frontRel = sign * (halfT - inset);
let backRel = frontRel - sign * depth;
frontRel = Math.max(-halfT, Math.min(halfT, frontRel));
backRel = Math.max(-halfT, Math.min(halfT, backRel));
const frameOuterN = refOff + Math.max(frontRel, backRel);
const frameInnerN = refOff + Math.min(frontRel, backRel);
const bandDepth = frameOuterN - frameInnerN;
const frame = [
add(jambStart, scale(n, frameOuterN)),
add(jambEnd, scale(n, frameOuterN)),
add(jambEnd, scale(n, frameInnerN)),
add(jambStart, scale(n, frameInnerN)),
];
const axisWidth = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y);
const segs = sashSegments(spec.sashes, axisWidth);
// Trennlinien (Pfosten/Mittelpfosten) an den inneren Segmentgrenzen, über
// die volle Rahmentiefe.
const mullionLines: [Vec2, Vec2][] = [];
for (let i = 0; i < segs.length - 1; i++) {
const onAxis = along(jambStart, jambEnd, segs[i].to);
mullionLines.push([add(onAxis, scale(n, frameOuterN)), add(onAxis, scale(n, frameInnerN))]);
}
const sashFrames: Vec2[][] = [];
const glassLines: [Vec2, Vec2][] = [];
const count = Math.max(1, glassCount);
// Öffenbarer Flügel sitzt `reveal` tiefer im Blendrahmen (n- und u-Richtung).
const reveal = Math.min(bandDepth * 0.3, fw * 0.7);
for (const seg of segs) {
if (seg.sash.kind === "pfosten") continue;
const clearFrom = seg.from + fw;
const clearTo = seg.to - fw;
if (clearTo - clearFrom <= 1e-6) continue;
const operable = !!seg.sash.opening && seg.sash.opening !== "fest";
const gOuterN = frameOuterN - (operable ? reveal : 0);
const gInnerN = frameInnerN + (operable ? reveal : 0);
if (operable) {
const sFrom = along(jambStart, jambEnd, clearFrom);
const sTo = along(jambStart, jambEnd, clearTo);
sashFrames.push([
add(sFrom, scale(n, gOuterN)),
add(sTo, scale(n, gOuterN)),
add(sTo, scale(n, gInnerN)),
add(sFrom, scale(n, gInnerN)),
]);
}
// Glas innerhalb des Flügels (bei öffenbar zusätzlich in u eingerückt).
const gInsetU = operable ? fw * 0.6 : 0;
const gFrom = clearFrom + gInsetU;
const gTo = clearTo - gInsetU;
if (gTo - gFrom <= 1e-6) continue;
const gMid = (gOuterN + gInnerN) / 2;
const gBand = gOuterN - gInnerN;
const step = count > 1 ? Math.min(total / 10, gBand / (count + 1)) : 0;
const a = along(jambStart, jambEnd, gFrom);
const b = along(jambStart, jambEnd, gTo);
for (let i = 0; i < count; i++) {
const off = gMid + (count === 1 ? 0 : (i - (count - 1) / 2) * step);
glassLines.push([add(a, scale(n, off)), add(b, scale(n, off))]);
}
}
return { frame, glassLines, mullionLines, sashFrames, sashOpeningLines: [] };
}
// ── Alt-Pfad (kein Typ auflösbar) ────────────────────────────────────────
// UNVERÄNDERT (Rust-Parität kernel2d): Rahmen an voller Wanddicke, Glaslinien
// mittig, (wingCount1) gleichmäßige Mittelpfosten. Keine Flügelrahmen.
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++) {
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))]);
}
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;
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, sashFrames: [], sashOpeningLines: [] };
}