2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand
Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung (konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text- Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback, falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform) fuer fluessige Interaktion ohne React-Re-Render je Frame. Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM (Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext- Import, Tauri-Compute-Boundary-PoC).
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
// Geometrie-Helfer für Decken (Slabs) — reine Funktionen über `Vec2`, kein
|
||||
// externer Kernel. Eine Decke ist ein GESCHLOSSENER Umriss (Polygon) plus eine
|
||||
// Dicke; diese Helfer normalisieren den Umriss, berechnen Fläche/Umriss-Prüfung
|
||||
// und liefern eine extrude-fertige Form. Baut auf kernel2d/model/geometry auf.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Vec2 } from "../model/types";
|
||||
import { signedArea, isCCW, vecEqual } from "./kernel2d";
|
||||
|
||||
/** Kleinste sinnvolle Deckenfläche (m²) — darunter gilt der Umriss als entartet. */
|
||||
export const MIN_CEILING_AREA = 1e-4;
|
||||
|
||||
/**
|
||||
* Normalisiert einen Deckenumriss:
|
||||
* • entfernt aufeinanderfolgende (nahezu) gleiche Punkte,
|
||||
* • entfernt einen schließenden Duplikat-Endpunkt (pts[last] == pts[0]),
|
||||
* • erzwingt CCW-Wicklung (positive Fläche) — konsistent für Plan-Füllung und
|
||||
* 3D-Extrusion.
|
||||
* Liefert `null`, wenn nach der Bereinigung < 3 Punkte übrig bleiben (kein
|
||||
* gültiges Polygon).
|
||||
*/
|
||||
export function normalizeOutline(pts: Vec2[]): Vec2[] | null {
|
||||
const clean: Vec2[] = [];
|
||||
for (const p of pts) {
|
||||
if (clean.length === 0 || !vecEqual(clean[clean.length - 1], p)) clean.push(p);
|
||||
}
|
||||
// Schließenden Duplikat-Endpunkt verwerfen.
|
||||
if (clean.length > 1 && vecEqual(clean[0], clean[clean.length - 1])) clean.pop();
|
||||
if (clean.length < 3) return null;
|
||||
// CCW erzwingen.
|
||||
return isCCW(clean) ? clean : clean.slice().reverse();
|
||||
}
|
||||
|
||||
/** Ob ein Umriss ein gültiges (nicht entartetes) Deckenpolygon bildet. */
|
||||
export function isValidOutline(pts: Vec2[]): boolean {
|
||||
const norm = normalizeOutline(pts);
|
||||
return norm != null && ceilingArea(norm) >= MIN_CEILING_AREA;
|
||||
}
|
||||
|
||||
/** Fläche eines Deckenumrisses in m² (immer positiv). */
|
||||
export function ceilingArea(pts: Vec2[]): number {
|
||||
return Math.abs(signedArea(pts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Achsparallele Bounding-Box eines Umrisses (Modell-Meter). Leerer/entarteter
|
||||
* Umriss → Null-Box.
|
||||
*/
|
||||
export function outlineBBox(pts: Vec2[]): {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
} {
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
for (const p of pts) {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
}
|
||||
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
/** Schwerpunkt (Zentroid) des Umriss-Polygons (für HUD/Beschriftung). */
|
||||
export function outlineCentroid(pts: Vec2[]): Vec2 {
|
||||
const a = signedArea(pts);
|
||||
if (Math.abs(a) < 1e-12) {
|
||||
// Entartet: einfacher Mittelwert der Punkte.
|
||||
let sx = 0,
|
||||
sy = 0;
|
||||
for (const p of pts) {
|
||||
sx += p.x;
|
||||
sy += p.y;
|
||||
}
|
||||
const n = pts.length || 1;
|
||||
return { x: sx / n, y: sy / n };
|
||||
}
|
||||
let cx = 0,
|
||||
cy = 0;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const p = pts[i];
|
||||
const q = pts[(i + 1) % pts.length];
|
||||
const cr = p.x * q.y - q.x * p.y;
|
||||
cx += (p.x + q.x) * cr;
|
||||
cy += (p.y + q.y) * cr;
|
||||
}
|
||||
const f = 1 / (6 * a);
|
||||
return { x: cx * f, y: cy * f };
|
||||
}
|
||||
|
||||
/**
|
||||
* Punkt-in-Polygon-Test (Ray-Casting) für einen geschlossenen Umriss — dient dem
|
||||
* Body-Select einer Decke im Grundriss/3D. Randpunkte gelten als innen genug.
|
||||
*/
|
||||
export function pointInOutline(p: Vec2, outline: Vec2[]): boolean {
|
||||
let inside = false;
|
||||
const n = outline.length;
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const a = outline[i];
|
||||
const b = outline[j];
|
||||
const intersect =
|
||||
a.y > p.y !== b.y > p.y &&
|
||||
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-12) + a.x;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
@@ -259,6 +259,157 @@ export function trimSegment(
|
||||
return pieces.filter((_, i) => i !== best);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick-Trim einer ganzen Kurve (offen ODER geschlossen) an einem Klickpunkt.
|
||||
* Die Kurve `pts`/`closed` wird an ALLEN Schnittpunkten mit den Cuttern in
|
||||
* Bögen zerlegt; der Bogen, der `pick` am nächsten liegt (das angeklickte „weg-
|
||||
* zuschneidende" Stück), wird ENTFERNT. Liefert die verbleibenden Ketten als
|
||||
* `{pts, closed}`-Liste (0..n Stücke):
|
||||
* • OFFEN: der getroffene Bogen fällt weg → 0, 1 oder 2 offene Reststücke.
|
||||
* • GESCHLOSSEN: der Ring wird am Schnitt aufgetrennt; das angeklickte Stück
|
||||
* entfällt → genau ein offenes Reststück (oder leer, wenn alles entfällt).
|
||||
* Ohne Schnitt (oder nur randständige) bleibt die Kurve unverändert (eine Kette).
|
||||
*/
|
||||
export function trimPolyline(
|
||||
pts: Vec2[],
|
||||
closed: boolean,
|
||||
cutters: { pts: Vec2[]; closed: boolean }[],
|
||||
pick: Vec2,
|
||||
): { pts: Vec2[]; closed: boolean }[] {
|
||||
const edges = polylineEdges(pts, closed);
|
||||
if (edges.length === 0) return [{ pts: pts.slice(), closed }];
|
||||
|
||||
// Schnittpunkte je Kante (innen, t ∈ (0,1)) als globale „Knoten" sammeln —
|
||||
// beschrieben durch (edgeIndex, t) und in Kettenreihenfolge sortiert.
|
||||
interface Cut {
|
||||
edge: number;
|
||||
t: number;
|
||||
point: Vec2;
|
||||
}
|
||||
const cuts: Cut[] = [];
|
||||
for (let ei = 0; ei < edges.length; ei++) {
|
||||
const [a1, a2] = edges[ei];
|
||||
for (const c of cutters) {
|
||||
for (const h of segmentPolylineHits(a1, a2, c.pts, c.closed)) {
|
||||
if (h.t > EPS && h.t < 1 - EPS) {
|
||||
cuts.push({ edge: ei, t: h.t, point: h.point });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cuts.sort((p, q) => (p.edge - q.edge) || (p.t - q.t));
|
||||
// Dedup koinzidenter Schnitte (gleiche Kante+t).
|
||||
const cut: Cut[] = [];
|
||||
for (const h of cuts) {
|
||||
const prev = cut[cut.length - 1];
|
||||
if (prev && prev.edge === h.edge && Math.abs(prev.t - h.t) < 1e-6) continue;
|
||||
cut.push(h);
|
||||
}
|
||||
if (cut.length === 0) return [{ pts: pts.slice(), closed }];
|
||||
|
||||
// Hilfsfunktion: Kette von Knoten X (auf Kante eX, tX) zu Knoten Y entlang der
|
||||
// Kurve (vorwärts in Kantenrichtung) als Punktliste materialisieren.
|
||||
const span = (eX: number, pX: Vec2, eY: number, pY: Vec2): Vec2[] => {
|
||||
const out: Vec2[] = [pX];
|
||||
let e = eX;
|
||||
// Über die Vertices NACH dem Startknoten bis zum Endknoten laufen.
|
||||
// Anzahl Kanten (modular über die Kettenlänge), damit auch der Wrap bei
|
||||
// geschlossenen Kurven funktioniert.
|
||||
const total = edges.length;
|
||||
let steps = 0;
|
||||
while (steps <= total) {
|
||||
if (e === eY) break;
|
||||
// Endpunkt der aktuellen Kante hinzufügen, dann zur nächsten Kante.
|
||||
out.push(edges[e][1]);
|
||||
e = (e + 1) % total;
|
||||
steps++;
|
||||
}
|
||||
out.push(pY);
|
||||
return dedupeConsecutive(out);
|
||||
};
|
||||
|
||||
if (!closed) {
|
||||
// Offen: der Pick liegt zwischen zwei Knoten ODER zwischen einem Knoten und
|
||||
// einem Element-Ende. Wir finden das Intervall [lo, hi] der Knoten, die den
|
||||
// Pick einklammern (lo = letzter Knoten vor dem Pick, hi = erster danach),
|
||||
// bzw. das Element-Ende, wenn einseitig kein Schnitt liegt.
|
||||
const pickPos = nearestParamOnChain(edges, pick); // globaler Lauf-Param
|
||||
const cutPos = cut.map((c) => c.edge + c.t);
|
||||
let loIdx = -1;
|
||||
let hiIdx = cut.length;
|
||||
for (let i = 0; i < cut.length; i++) {
|
||||
if (cutPos[i] <= pickPos) loIdx = i;
|
||||
else { hiIdx = i; break; }
|
||||
}
|
||||
// Reststück A: Anfang … loIdx-Knoten. Reststück B: hiIdx-Knoten … Ende.
|
||||
const result: { pts: Vec2[]; closed: boolean }[] = [];
|
||||
if (loIdx >= 0) {
|
||||
const c = cut[loIdx];
|
||||
const head = pts.slice(0, c.edge + 1);
|
||||
head.push(c.point);
|
||||
const d = dedupeConsecutive(head);
|
||||
if (d.length >= 2) result.push({ pts: d, closed: false });
|
||||
}
|
||||
if (hiIdx < cut.length) {
|
||||
const c = cut[hiIdx];
|
||||
const tail: Vec2[] = [c.point];
|
||||
for (let k = c.edge + 1; k < pts.length; k++) tail.push(pts[k]);
|
||||
const d = dedupeConsecutive(tail);
|
||||
if (d.length >= 2) result.push({ pts: d, closed: false });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Geschlossen: der Pick liegt in einem Bogen zwischen zwei aufeinanderfolgenden
|
||||
// Knoten (zyklisch). Diesen Bogen entfernen → der Rest ist EINE offene Kette,
|
||||
// die vom Knoten NACH dem Pick einmal herum bis zum Knoten VOR dem Pick läuft.
|
||||
if (cut.length === 1) {
|
||||
// Nur ein Schnitt am Ring: am Schnitt auftrennen, Pick liegt auf der ganzen
|
||||
// (nun offenen) Kette → nichts Sinnvolles zu entfernen; Ring bleibt.
|
||||
return [{ pts: pts.slice(), closed: true }];
|
||||
}
|
||||
const pickPos = nearestParamOnChain(edges, pick);
|
||||
const cutPos = cut.map((c) => c.edge + c.t);
|
||||
// Bogenindex i: zwischen cut[i] und cut[i+1] (mod), der den Pick enthält.
|
||||
let seg = -1;
|
||||
for (let i = 0; i < cut.length; i++) {
|
||||
const a = cutPos[i];
|
||||
const b = cutPos[(i + 1) % cut.length];
|
||||
const inside =
|
||||
i === cut.length - 1
|
||||
? pickPos >= a || pickPos <= b // Wrap-Bogen über das Ketten-Ende
|
||||
: pickPos >= a && pickPos <= b;
|
||||
if (inside) { seg = i; break; }
|
||||
}
|
||||
if (seg < 0) seg = 0;
|
||||
const from = cut[(seg + 1) % cut.length]; // Knoten NACH dem entfernten Bogen
|
||||
const to = cut[seg]; // Knoten VOR dem entfernten Bogen
|
||||
const chain = span(from.edge, from.point, to.edge, to.point);
|
||||
if (chain.length < 2) return [];
|
||||
return [{ pts: chain, closed: false }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Globaler Lauf-Parameter (edgeIndex + t) des dem Punkt `p` nächstgelegenen
|
||||
* Punktes auf der Kantenfolge — für Quick-Trim, um die Klick-Position entlang der
|
||||
* Kurve zu lokalisieren.
|
||||
*/
|
||||
function nearestParamOnChain(edges: [Vec2, Vec2][], p: Vec2): number {
|
||||
let best = 0;
|
||||
let bestD = Infinity;
|
||||
for (let ei = 0; ei < edges.length; ei++) {
|
||||
const [a, b] = edges[ei];
|
||||
const t = Math.max(0, Math.min(1, projectParam(p, a, b)));
|
||||
const q = add(a, scale(sub(b, a), t));
|
||||
const d = dist(p, q);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = ei + t;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ── Extend ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// 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][];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
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))]);
|
||||
}
|
||||
return { frame, glassLines };
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// Räume (Flächen) — Flächenberechnung und SIA-416-Klassifikation.
|
||||
//
|
||||
// Reiner Rechenkern für die Raum-Flächenbuchhaltung nach SIA 416 (Schweizer
|
||||
// Norm „Flächen und Volumen von Gebäuden"). Keine UI, kein Datei-IO — nur
|
||||
// Polygon-Geometrie, Kategorie-Hierarchie und Aggregation für die Bilanz.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare/Labels deutsch (CONVENTIONS.md). Einheit: METER.
|
||||
|
||||
import type { Vec2 } from "../model/types";
|
||||
|
||||
// ── Polygon-Kennzahlen ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Vorzeichenbehaftete Polygonfläche (Gauss'sche Trapezformel / Shoelace).
|
||||
* >0 = gegen den Uhrzeigersinn (CCW), <0 = im Uhrzeigersinn (CW). Der letzte
|
||||
* Punkt darf NICHT dupliziert sein (die Schlusskante n-1 → 0 wird ergänzt).
|
||||
*/
|
||||
export function signedArea(pts: Vec2[]): number {
|
||||
const n = pts.length;
|
||||
if (n < 3) return 0;
|
||||
let s = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % n];
|
||||
s += a.x * b.y - b.x * a.y;
|
||||
}
|
||||
return s / 2;
|
||||
}
|
||||
|
||||
/** Absolute (immer positive) Polygonfläche in m². */
|
||||
export function polygonArea(pts: Vec2[]): number {
|
||||
return Math.abs(signedArea(pts));
|
||||
}
|
||||
|
||||
/** Umfang eines geschlossenen Polygons (Summe aller Kantenlängen). */
|
||||
export function perimeter(pts: Vec2[]): number {
|
||||
const n = pts.length;
|
||||
if (n < 2) return 0;
|
||||
let p = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % n];
|
||||
p += Math.hypot(b.x - a.x, b.y - a.y);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flächenschwerpunkt (Centroid) eines geschlossenen Polygons — Ankerpunkt für
|
||||
* das Raum-Label. Nutzt die momenten-gewichtete Formel; degeneriert (Fläche ≈ 0)
|
||||
* auf den Durchschnitt der Vertices.
|
||||
*/
|
||||
export function centroid(pts: Vec2[]): Vec2 {
|
||||
const n = pts.length;
|
||||
if (n === 0) return { x: 0, y: 0 };
|
||||
if (n < 3) {
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
for (const p of pts) {
|
||||
sx += p.x;
|
||||
sy += p.y;
|
||||
}
|
||||
return { x: sx / n, y: sy / n };
|
||||
}
|
||||
let a = 0;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = pts[i];
|
||||
const q = pts[(i + 1) % n];
|
||||
const cr = p.x * q.y - q.x * p.y;
|
||||
a += cr;
|
||||
cx += (p.x + q.x) * cr;
|
||||
cy += (p.y + q.y) * cr;
|
||||
}
|
||||
a /= 2;
|
||||
if (Math.abs(a) < 1e-12) {
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
for (const p of pts) {
|
||||
sx += p.x;
|
||||
sy += p.y;
|
||||
}
|
||||
return { x: sx / n, y: sy / n };
|
||||
}
|
||||
return { x: cx / (6 * a), y: cy / (6 * a) };
|
||||
}
|
||||
|
||||
// ── SIA-416-Kategorie-Hierarchie ─────────────────────────────────────────────
|
||||
//
|
||||
// SIA 416 gliedert die Geschossfläche (GF) eines Gebäudes hierarchisch:
|
||||
//
|
||||
// GF Geschossfläche
|
||||
// ├── KGF Konstruktionsfläche (Wände, Stützen, Schächte-Wandungen …)
|
||||
// └── NGF Nettogeschossfläche
|
||||
// ├── NF Nutzfläche
|
||||
// │ ├── HNF Hauptnutzfläche (der eigentliche Nutzungszweck)
|
||||
// │ └── NNF Nebennutzfläche (unterstützende Nutzung, Lager, WC …)
|
||||
// ├── VF Verkehrsfläche (Flure, Treppen, Aufzüge, Rampen)
|
||||
// └── FF Funktionsfläche (technische Anlagen, Haustechnik)
|
||||
//
|
||||
// Die „Blatt"-Kategorien, die einem konkreten Raum zugewiesen werden, sind
|
||||
// HNF, NNF, VF, FF und KGF. HNF+NNF = NF; NF+VF+FF = NGF; NGF+KGF = GF.
|
||||
|
||||
/** Blatt-Kategorien nach SIA 416, die einem Raum direkt zugewiesen werden. */
|
||||
export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF";
|
||||
|
||||
/** Aggregat-(Zwischensummen-)Ebenen der SIA-416-Hierarchie. */
|
||||
export type SiaAggregate = "NF" | "NGF" | "GF";
|
||||
|
||||
/** Alle in der Bilanz vorkommenden Schlüssel (Blatt + Aggregat). */
|
||||
export type SiaKey = SiaCategory | SiaAggregate;
|
||||
|
||||
/** Reihenfolge der Blatt-Kategorien für stabile Tabellen/CSV-Ausgabe. */
|
||||
export const SIA_CATEGORIES: readonly SiaCategory[] = [
|
||||
"HNF",
|
||||
"NNF",
|
||||
"VF",
|
||||
"FF",
|
||||
"KGF",
|
||||
] as const;
|
||||
|
||||
/** Deutsche Bezeichnungen (Kurz + Lang) je SIA-416-Schlüssel. */
|
||||
export interface SiaLabel {
|
||||
/** Kürzel, z. B. „HNF". */
|
||||
code: SiaKey;
|
||||
/** Ausgeschriebene deutsche Bezeichnung. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
const SIA_LABELS: Record<SiaKey, string> = {
|
||||
HNF: "Hauptnutzfläche",
|
||||
NNF: "Nebennutzfläche",
|
||||
VF: "Verkehrsfläche",
|
||||
FF: "Funktionsfläche",
|
||||
KGF: "Konstruktionsfläche",
|
||||
NF: "Nutzfläche",
|
||||
NGF: "Nettogeschossfläche",
|
||||
GF: "Geschossfläche",
|
||||
};
|
||||
|
||||
/** Deutsche Bezeichnung zu einem SIA-Schlüssel. */
|
||||
export function siaLabel(key: SiaKey): string {
|
||||
return SIA_LABELS[key];
|
||||
}
|
||||
|
||||
/** „HNF — Hauptnutzfläche" o. ä. für Legenden/Dropdowns. */
|
||||
export function siaLabelFull(key: SiaKey): string {
|
||||
return `${key} — ${SIA_LABELS[key]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zuordnung Blatt-Kategorie → übergeordnete Aggregate. Eine HNF trägt zu
|
||||
* NF, NGF und GF bei; eine KGF nur zu GF; VF/FF zu NGF und GF; NNF wie HNF.
|
||||
* Wird für den Bilanz-Roll-up genutzt.
|
||||
*/
|
||||
const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = {
|
||||
HNF: ["NF", "NGF", "GF"],
|
||||
NNF: ["NF", "NGF", "GF"],
|
||||
VF: ["NGF", "GF"],
|
||||
FF: ["NGF", "GF"],
|
||||
KGF: ["GF"],
|
||||
};
|
||||
|
||||
// ── Raum-Flächenergebnis ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ergebnis der Flächenbewertung eines einzelnen Raums.
|
||||
*
|
||||
* `grossArea` ist die geometrische Polygonfläche (Bruttofläche der Raum-Kontur).
|
||||
* `netArea` ist die anrechenbare Netto-Nutzfläche: standardmässig gleich der
|
||||
* Bruttofläche (die Raum-Kontur ist bereits die lichte Innenfläche zwischen den
|
||||
* Wänden). Über einen optionalen Abzug (`deduction`, z. B. für nicht anrechen-
|
||||
* bare Bereiche mit lichter Höhe < 1.0 m nach SIA 416) kann `netArea` reduziert
|
||||
* werden. `category`/`label` tragen die SIA-Klassifikation.
|
||||
*/
|
||||
export interface RoomAreaResult {
|
||||
grossArea: number;
|
||||
netArea: number;
|
||||
perimeter: number;
|
||||
centroid: Vec2;
|
||||
category: SiaCategory;
|
||||
/** Deutsche Bezeichnung der Kategorie (z. B. „Hauptnutzfläche"). */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Optionen für die Flächenbewertung eines Raums. */
|
||||
export interface RoomAreaOptions {
|
||||
/**
|
||||
* Abzug von der Bruttofläche (m²), z. B. für Bereiche mit lichter Höhe unter
|
||||
* dem SIA-416-Grenzwert, die nicht zur anrechenbaren Fläche zählen. Default 0.
|
||||
*/
|
||||
deduction?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bewertet ein Raum-Polygon: berechnet Brutto-/Nettofläche, Umfang, Schwerpunkt
|
||||
* und hängt die SIA-416-Kategorie samt deutschem Label an.
|
||||
*/
|
||||
export function evaluateRoom(
|
||||
polygon: Vec2[],
|
||||
category: SiaCategory,
|
||||
options: RoomAreaOptions = {},
|
||||
): RoomAreaResult {
|
||||
const gross = polygonArea(polygon);
|
||||
const deduction = Math.max(0, options.deduction ?? 0);
|
||||
const net = Math.max(0, gross - deduction);
|
||||
return {
|
||||
grossArea: gross,
|
||||
netArea: net,
|
||||
perimeter: perimeter(polygon),
|
||||
centroid: centroid(polygon),
|
||||
category,
|
||||
label: SIA_LABELS[category],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Bilanz (Flächenaufstellung) ──────────────────────────────────────────────
|
||||
|
||||
/** Ein Zeileneintrag der Bilanz: Schlüssel, Label, Fläche, Raumanzahl. */
|
||||
export interface BalanceRow {
|
||||
key: SiaKey;
|
||||
label: string;
|
||||
area: number;
|
||||
/** Anzahl Räume, die zu dieser (Blatt-)Kategorie zählen. Für Aggregate: Summe. */
|
||||
count: number;
|
||||
/** true, wenn es sich um eine Zwischensumme (NF/NGF/GF) handelt. */
|
||||
aggregate: boolean;
|
||||
}
|
||||
|
||||
/** Vollständige SIA-416-Bilanz: geordnete Zeilen + Gesamtsumme (GF). */
|
||||
export interface Balance {
|
||||
rows: BalanceRow[];
|
||||
/** Schneller Zugriff auf die Fläche je Schlüssel. */
|
||||
byKey: Record<SiaKey, number>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregiert Räume je SIA-416-Kategorie und rollt die Zwischensummen
|
||||
* (NF, NGF, GF) korrekt auf. Erwartet je Raum die Blatt-Kategorie und die
|
||||
* anzurechnende Fläche (i. d. R. `netArea` aus {@link evaluateRoom}).
|
||||
*
|
||||
* Die zurückgegebenen `rows` sind in kanonischer Reihenfolge:
|
||||
* HNF, NNF, NF(=Σ), VF, FF, NGF(=Σ), KGF, GF(=Σ)
|
||||
* — passend für die Bilanz-Tabelle und den CSV-Export.
|
||||
*/
|
||||
export function balance(
|
||||
rooms: { category: SiaCategory; area: number }[],
|
||||
): Balance {
|
||||
const leaf: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 };
|
||||
const leafCount: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 };
|
||||
const agg: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
|
||||
const aggCount: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
|
||||
|
||||
for (const r of rooms) {
|
||||
const area = Number.isFinite(r.area) ? r.area : 0;
|
||||
leaf[r.category] += area;
|
||||
leafCount[r.category] += 1;
|
||||
for (const a of CATEGORY_ROLLUP[r.category]) {
|
||||
agg[a] += area;
|
||||
aggCount[a] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const byKey: Record<SiaKey, number> = {
|
||||
HNF: leaf.HNF,
|
||||
NNF: leaf.NNF,
|
||||
VF: leaf.VF,
|
||||
FF: leaf.FF,
|
||||
KGF: leaf.KGF,
|
||||
NF: agg.NF,
|
||||
NGF: agg.NGF,
|
||||
GF: agg.GF,
|
||||
};
|
||||
|
||||
const mkLeaf = (k: SiaCategory): BalanceRow => ({
|
||||
key: k,
|
||||
label: SIA_LABELS[k],
|
||||
area: leaf[k],
|
||||
count: leafCount[k],
|
||||
aggregate: false,
|
||||
});
|
||||
const mkAgg = (k: SiaAggregate): BalanceRow => ({
|
||||
key: k,
|
||||
label: SIA_LABELS[k],
|
||||
area: agg[k],
|
||||
count: aggCount[k],
|
||||
aggregate: true,
|
||||
});
|
||||
|
||||
const rows: BalanceRow[] = [
|
||||
mkLeaf("HNF"),
|
||||
mkLeaf("NNF"),
|
||||
mkAgg("NF"),
|
||||
mkLeaf("VF"),
|
||||
mkLeaf("FF"),
|
||||
mkAgg("NGF"),
|
||||
mkLeaf("KGF"),
|
||||
mkAgg("GF"),
|
||||
];
|
||||
|
||||
return { rows, byKey, total: agg.GF };
|
||||
}
|
||||
|
||||
// ── CSV-Serialisierung ───────────────────────────────────────────────────────
|
||||
|
||||
/** Ein Raum, wie er in den CSV-Export einfliesst. */
|
||||
export interface RoomCsvRecord {
|
||||
/** Raum-Nummer/-Kennung (frei), z. B. „R.01". */
|
||||
number?: string;
|
||||
/** Raum-Name, z. B. „Wohnen". */
|
||||
name?: string;
|
||||
/** Geschoss/Ebene, optional. */
|
||||
level?: string;
|
||||
category: SiaCategory;
|
||||
/** Anzurechnende Fläche (m²) — i. d. R. netArea. */
|
||||
area: number;
|
||||
}
|
||||
|
||||
/** Ein Feld CSV-sicher quoten (RFC 4180: „..." bei , " \n; " → ""). */
|
||||
function csvField(value: string): string {
|
||||
if (/[",\n\r;]/.test(value)) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Fläche auf 2 Nachkommastellen, Punkt als Dezimaltrenner (m²). */
|
||||
function fmtArea(area: number): string {
|
||||
return (Math.round(area * 100) / 100).toFixed(2);
|
||||
}
|
||||
|
||||
export interface RoomsToCsvOptions {
|
||||
/** Feldtrenner. Default „;" (Excel-DE-freundlich). */
|
||||
delimiter?: string;
|
||||
/** true (Default): Bilanz-Zwischensummen unter die Raumliste anhängen. */
|
||||
includeBalance?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialisiert eine Raumliste als CSV (reiner String, kein Datei-IO) für den
|
||||
* späteren Export. Kopfzeile deutsch; optional folgt die SIA-416-Bilanz als
|
||||
* Summenblock. Nutzt `;` als Trenner (Standard im DE-Excel-Umfeld).
|
||||
*/
|
||||
export function roomsToCsv(
|
||||
rooms: RoomCsvRecord[],
|
||||
options: RoomsToCsvOptions = {},
|
||||
): string {
|
||||
const d = options.delimiter ?? ";";
|
||||
const includeBalance = options.includeBalance ?? true;
|
||||
const lines: string[] = [];
|
||||
|
||||
const header = ["Nummer", "Name", "Geschoss", "Kategorie", "Bezeichnung", "Fläche [m²]"];
|
||||
lines.push(header.map(csvField).join(d));
|
||||
|
||||
for (const r of rooms) {
|
||||
lines.push(
|
||||
[
|
||||
csvField(r.number ?? ""),
|
||||
csvField(r.name ?? ""),
|
||||
csvField(r.level ?? ""),
|
||||
csvField(r.category),
|
||||
csvField(SIA_LABELS[r.category]),
|
||||
fmtArea(r.area),
|
||||
].join(d),
|
||||
);
|
||||
}
|
||||
|
||||
if (includeBalance) {
|
||||
const bal = balance(rooms.map((r) => ({ category: r.category, area: r.area })));
|
||||
lines.push(""); // Leerzeile trennt Raumliste von Bilanz
|
||||
lines.push([csvField("Bilanz (SIA 416)"), "", "", "", "", ""].join(d));
|
||||
for (const row of bal.rows) {
|
||||
lines.push(
|
||||
[
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
csvField(row.key),
|
||||
csvField(row.label),
|
||||
fmtArea(row.area),
|
||||
].join(d),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
// Raum-Erkennung — geschlossene Raum-Umrisse aus Wandsegmenten ableiten.
|
||||
//
|
||||
// Zwei komplementäre Ansätze:
|
||||
//
|
||||
// 1. detectRooms(walls) — baut die planare Anordnung (Arrangement) der Wand-
|
||||
// MITTELLINIEN, schneidet alle Kanten an ihren Kreuzungen, snappt kleine
|
||||
// Lücken zu und extrahiert die minimalen geschlossenen Maschen (Faces) des
|
||||
// Graphen über das „next edge counter-clockwise"-Verfahren (half-edge face
|
||||
// tracing). Die unbeschränkte Aussenmasche wird verworfen. Für jede innere
|
||||
// Masche wird die lichte Innenkontur berechnet, indem die Masche um die
|
||||
// halbe Wanddicke nach innen versetzt wird (Miter-Offset).
|
||||
//
|
||||
// 2. roomFromPointInside(point, wallFaces) — klick-in-Raum-Fallback: umläuft
|
||||
// ausgehend von einem Saatpunkt die den Punkt einschliessende Masche direkt
|
||||
// (gap-tolerant, wie die meisten CAD-Werkzeuge für „Raum durch Klick"). Er
|
||||
// liefert die lichte Innenkontur der angeklickten Zelle.
|
||||
//
|
||||
// Grenzen (ehrlich): Der Arrangement-Ansatz behandelt Wände als 1D-Mittellinien;
|
||||
// überlappende/kollineare Doppelwände oder gekrümmte Wände werden nicht speziell
|
||||
// aufgelöst. Lücken unterhalb `gapTol` werden per Endpunkt-Snapping geschlossen;
|
||||
// grössere Öffnungen (Türen) müssen als geschlossen modelliert oder vom Aufrufer
|
||||
// überbrückt werden. Der Offset nach innen nutzt Miter-Joins ohne Selbstschnitt-
|
||||
// Heilung — für orthogonale/konvexe Grundrisse (der Normalfall) exakt, bei sehr
|
||||
// spitzen einspringenden Ecken approximativ.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheit: METER.
|
||||
|
||||
import type { Vec2 } from "../model/types";
|
||||
import { signedArea, polygonArea } from "./roomArea";
|
||||
|
||||
/** Ein Wandsegment: Mittellinie a→b mit Dicke (beidseitig je thickness/2). */
|
||||
export interface WallSegment {
|
||||
a: Vec2;
|
||||
b: Vec2;
|
||||
thickness: number;
|
||||
}
|
||||
|
||||
/** Parameter der Raum-Erkennung. */
|
||||
export interface DetectRoomsOptions {
|
||||
/** Lücken/Endpunkt-Abstände ≤ gapTol (m) werden zusammengeschnappt. Default 0.05. */
|
||||
gapTol?: number;
|
||||
/** Kleinste als Raum akzeptierte Fläche (m²). Default 0.05. */
|
||||
minArea?: number;
|
||||
/**
|
||||
* true (Default): Maschen um halbe Wanddicke nach innen versetzen, sodass die
|
||||
* zurückgegebenen Loops die LICHTE Innenkontur sind. false: Wand-Mittellinien-
|
||||
* Maschen (roh) zurückgeben.
|
||||
*/
|
||||
offsetToInner?: boolean;
|
||||
}
|
||||
|
||||
// ── Vektorhelfer (lokal, ohne Fremdabhängigkeit) ─────────────────────────────
|
||||
|
||||
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
|
||||
const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y });
|
||||
const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s });
|
||||
const cross = (a: Vec2, b: Vec2): number => a.x * b.y - a.y * b.x;
|
||||
const len = (a: Vec2): number => Math.hypot(a.x, a.y);
|
||||
const normalize = (a: Vec2): Vec2 => {
|
||||
const l = len(a);
|
||||
return l < 1e-12 ? { x: 0, y: 0 } : { x: a.x / l, y: a.y / l };
|
||||
};
|
||||
/** Linke Normale (90° gegen den Uhrzeigersinn). */
|
||||
const leftNormal = (a: Vec2): Vec2 => ({ x: -a.y, y: a.x });
|
||||
const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
|
||||
const EPS = 1e-9;
|
||||
|
||||
// ── Planarer Graph (Knoten + ungerichtete Kanten) ────────────────────────────
|
||||
|
||||
interface Node {
|
||||
p: Vec2;
|
||||
}
|
||||
|
||||
interface UEdge {
|
||||
u: number; // Knotenindex
|
||||
v: number; // Knotenindex
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut aus den Wand-Mittellinien einen planaren Graphen: Endpunkte werden mit
|
||||
* Toleranz `snap` verschmolzen, jedes Segment an allen echten Kreuzungen mit
|
||||
* anderen Segmenten unterteilt, kollineare Duplikate entfernt.
|
||||
*/
|
||||
function buildPlanarGraph(
|
||||
segments: { a: Vec2; b: Vec2 }[],
|
||||
snap: number,
|
||||
): { nodes: Node[]; edges: UEdge[] } {
|
||||
const nodes: Node[] = [];
|
||||
|
||||
// Knoten mit Snapping-Toleranz einfügen (linearer Scan; für Grundriss-Grössen
|
||||
// ausreichend).
|
||||
const addNode = (p: Vec2): number => {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (dist(nodes[i].p, p) <= snap) return i;
|
||||
}
|
||||
nodes.push({ p: { x: p.x, y: p.y } });
|
||||
return nodes.length - 1;
|
||||
};
|
||||
|
||||
// Für jedes Segment: alle Split-Parameter t sammeln (Endpunkte + Kreuzungen).
|
||||
const raw = segments
|
||||
.map((s) => ({ a: s.a, b: s.b }))
|
||||
.filter((s) => dist(s.a, s.b) > snap);
|
||||
|
||||
interface Split {
|
||||
t: number;
|
||||
p: Vec2;
|
||||
}
|
||||
|
||||
const perSegment: Split[][] = raw.map((s) => [
|
||||
{ t: 0, p: s.a },
|
||||
{ t: 1, p: s.b },
|
||||
]);
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
for (let j = i + 1; j < raw.length; j++) {
|
||||
const A = raw[i];
|
||||
const B = raw[j];
|
||||
const da = sub(A.b, A.a);
|
||||
const db = sub(B.b, B.a);
|
||||
const denom = cross(da, db);
|
||||
if (Math.abs(denom) < EPS) continue; // parallel/kollinear
|
||||
const t = cross(sub(B.a, A.a), db) / denom;
|
||||
const s = cross(sub(B.a, A.a), da) / denom;
|
||||
// Echter innerer Schnitt (Endpunkt-Berührungen sind bereits Knoten).
|
||||
const tolT = snap / Math.max(len(da), 1e-9);
|
||||
const tolS = snap / Math.max(len(db), 1e-9);
|
||||
if (t < -tolT || t > 1 + tolT || s < -tolS || s > 1 + tolS) continue;
|
||||
const p = add(A.a, scale(da, t));
|
||||
perSegment[i].push({ t, p });
|
||||
perSegment[j].push({ t: s, p });
|
||||
}
|
||||
}
|
||||
|
||||
const edgeSet = new Set<string>();
|
||||
const edges: UEdge[] = [];
|
||||
const pushEdge = (u: number, v: number) => {
|
||||
if (u === v) return;
|
||||
const key = u < v ? `${u}_${v}` : `${v}_${u}`;
|
||||
if (edgeSet.has(key)) return;
|
||||
edgeSet.add(key);
|
||||
edges.push({ u, v });
|
||||
};
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const splits = perSegment[i].sort((p, q) => p.t - q.t);
|
||||
let prev = -1;
|
||||
let prevT = -Infinity;
|
||||
for (const sp of splits) {
|
||||
if (sp.t - prevT < 1e-9 && prev >= 0) continue;
|
||||
const idx = addNode(sp.p);
|
||||
if (prev >= 0 && idx !== prev) pushEdge(prev, idx);
|
||||
prev = idx;
|
||||
prevT = sp.t;
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// ── Face-Extraktion über gerichtete Halbkanten ───────────────────────────────
|
||||
|
||||
interface HalfEdge {
|
||||
from: number;
|
||||
to: number;
|
||||
/** Ausgangswinkel from→to (für die CCW-Sortierung an jedem Knoten). */
|
||||
angle: number;
|
||||
used: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert die minimalen geschlossenen Maschen (Faces) des planaren Graphen.
|
||||
* Verfahren: jede ungerichtete Kante wird zu zwei Halbkanten. An jedem Knoten
|
||||
* werden die abgehenden Halbkanten nach Winkel sortiert. Beim Umlaufen einer
|
||||
* Masche wählt man an jedem Zielknoten stets die Halbkante, die im Uhrzeigersinn
|
||||
* unmittelbar VOR der eingehenden (umgekehrten) liegt — das „next edge"-Kriterium
|
||||
* für konsistente Face-Umläufe. Jede Halbkante gehört zu genau einer Masche.
|
||||
* Die (einzige) Masche mit positiver Fläche und maximaler |Fläche|, deren Umlauf
|
||||
* die anderen umschliesst, ist die Aussenmasche und wird verworfen.
|
||||
*/
|
||||
function extractFaces(nodes: Node[], edges: UEdge[]): number[][] {
|
||||
// Adjazenz: je Knoten die Indizes seiner abgehenden Halbkanten.
|
||||
const halfEdges: HalfEdge[] = [];
|
||||
const outgoing: number[][] = nodes.map(() => []);
|
||||
|
||||
const angleOf = (from: number, to: number): number => {
|
||||
const d = sub(nodes[to].p, nodes[from].p);
|
||||
return Math.atan2(d.y, d.x);
|
||||
};
|
||||
|
||||
for (const e of edges) {
|
||||
const h1 = halfEdges.length;
|
||||
halfEdges.push({ from: e.u, to: e.v, angle: angleOf(e.u, e.v), used: false });
|
||||
outgoing[e.u].push(h1);
|
||||
const h2 = halfEdges.length;
|
||||
halfEdges.push({ from: e.v, to: e.u, angle: angleOf(e.v, e.u), used: false });
|
||||
outgoing[e.v].push(h2);
|
||||
}
|
||||
|
||||
// Abgehende Halbkanten je Knoten nach Winkel sortieren.
|
||||
for (const list of outgoing) {
|
||||
list.sort((h1, h2) => halfEdges[h1].angle - halfEdges[h2].angle);
|
||||
}
|
||||
// Positionsindex je Halbkante in der sortierten Liste seines from-Knotens.
|
||||
const posInList = new Map<number, number>();
|
||||
for (const list of outgoing) {
|
||||
for (let k = 0; k < list.length; k++) posInList.set(list[k], k);
|
||||
}
|
||||
|
||||
// Zwilling (umgekehrte Halbkante): Paare liegen benachbart (2i, 2i+1).
|
||||
const twin = (h: number): number => (h % 2 === 0 ? h + 1 : h - 1);
|
||||
|
||||
const faces: number[][] = [];
|
||||
|
||||
for (let start = 0; start < halfEdges.length; start++) {
|
||||
if (halfEdges[start].used) continue;
|
||||
const faceNodes: number[] = [];
|
||||
let h = start;
|
||||
let guard = 0;
|
||||
const maxSteps = halfEdges.length + 2;
|
||||
do {
|
||||
halfEdges[h].used = true;
|
||||
faceNodes.push(halfEdges[h].from);
|
||||
// An to = halfEdges[h].to die nächste Halbkante wählen: rund um den Knoten
|
||||
// die im Uhrzeigersinn nächste NACH dem Zwilling der eingehenden Kante.
|
||||
const tw = twin(h);
|
||||
const list = outgoing[halfEdges[h].to];
|
||||
const pos = posInList.get(tw)!;
|
||||
const nextPos = (pos - 1 + list.length) % list.length; // im Uhrzeigersinn davor
|
||||
h = list[nextPos];
|
||||
guard++;
|
||||
} while (h !== start && guard < maxSteps);
|
||||
if (faceNodes.length >= 3) faces.push(faceNodes);
|
||||
}
|
||||
|
||||
// Aussenmasche(n) verwerfen: eine Masche ist „innen", wenn sie CCW orientiert
|
||||
// ist (positive Shoelace-Fläche). Die Aussenmasche läuft CW (negative Fläche).
|
||||
const inner: number[][] = [];
|
||||
for (const f of faces) {
|
||||
const poly = f.map((n) => nodes[n].p);
|
||||
if (signedArea(poly) > EPS) inner.push(f);
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
|
||||
// ── Innen-Offset einer Masche (halbe Wanddicke) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Versetzt ein CCW-Polygon um `d` nach innen (jede Kante um d nach rechts, da
|
||||
* CCW → Innenseite rechts der Laufrichtung? Nein: bei CCW liegt das Innere LINKS.
|
||||
* Wir verschieben daher jede Kante um d in Richtung Inneres = linke Normale) via
|
||||
* Miter-Join. Bei ungültigem Schnitt (fast parallel) wird der verschobene Eck-
|
||||
* punkt direkt genommen.
|
||||
*/
|
||||
function offsetInward(poly: Vec2[], d: number): Vec2[] {
|
||||
const n = poly.length;
|
||||
if (n < 3 || d <= 0) return poly.slice();
|
||||
// Sicherstellen: CCW (Inneres links). Falls CW, umdrehen.
|
||||
const pts = signedArea(poly) > 0 ? poly : poly.slice().reverse();
|
||||
// Verschobene Kanten (Inneres liegt links → linke Normale zeigt nach innen).
|
||||
const shifted: { p: Vec2; dir: Vec2 }[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % n];
|
||||
const dir = normalize(sub(b, a));
|
||||
const nrm = leftNormal(dir); // zeigt ins Innere bei CCW
|
||||
shifted.push({ p: add(a, scale(nrm, d)), dir });
|
||||
}
|
||||
const out: Vec2[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const prev = shifted[(i - 1 + n) % n];
|
||||
const curr = shifted[i];
|
||||
// Schnitt der beiden verschobenen (unendlichen) Geraden.
|
||||
const denom = cross(prev.dir, curr.dir);
|
||||
if (Math.abs(denom) < 1e-9) {
|
||||
out.push(curr.p); // (nahezu) kollinear → Startpunkt der aktuellen Kante
|
||||
continue;
|
||||
}
|
||||
const t = cross(sub(curr.p, prev.p), curr.dir) / denom;
|
||||
out.push(add(prev.p, scale(prev.dir, t)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Dedupliziert aufeinanderfolgende (nahezu) gleiche Punkte im Ring. */
|
||||
function dedupeRing(pts: Vec2[], tol = 1e-7): Vec2[] {
|
||||
const out: Vec2[] = [];
|
||||
for (const p of pts) {
|
||||
const last = out[out.length - 1];
|
||||
if (!last || dist(last, p) > tol) out.push(p);
|
||||
}
|
||||
if (out.length > 1 && dist(out[0], out[out.length - 1]) <= tol) out.pop();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Durchschnittliche Wanddicke der Segmente, die eine Kante bilden (Fallback 0). */
|
||||
function averageThickness(walls: WallSegment[]): number {
|
||||
if (walls.length === 0) return 0;
|
||||
let s = 0;
|
||||
for (const w of walls) s += w.thickness;
|
||||
return s / walls.length;
|
||||
}
|
||||
|
||||
// ── Öffentliche API ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Erkennt geschlossene Räume aus einer Menge von Wandsegmenten (Mittellinien
|
||||
* mit Dicke). Liefert die lichten Innen-Umrisse (Vec2[] je Raum), CCW orientiert.
|
||||
*
|
||||
* Ablauf: planarer Graph aus Mittellinien → Face-Extraktion → Aussenmasche
|
||||
* verwerfen → jede innere Masche um halbe (mittlere) Wanddicke nach innen
|
||||
* versetzen. Kleine Lücken werden über `gapTol` zusammengeschnappt.
|
||||
*/
|
||||
export function detectRooms(
|
||||
walls: WallSegment[],
|
||||
options: DetectRoomsOptions = {},
|
||||
): Vec2[][] {
|
||||
const gapTol = options.gapTol ?? 0.05;
|
||||
const minArea = options.minArea ?? 0.05;
|
||||
const offsetToInner = options.offsetToInner ?? true;
|
||||
|
||||
if (walls.length < 3) return [];
|
||||
|
||||
const { nodes, edges } = buildPlanarGraph(
|
||||
walls.map((w) => ({ a: w.a, b: w.b })),
|
||||
gapTol,
|
||||
);
|
||||
if (edges.length < 3) return [];
|
||||
|
||||
const faces = extractFaces(nodes, edges);
|
||||
const half = averageThickness(walls) / 2;
|
||||
|
||||
const rooms: Vec2[][] = [];
|
||||
for (const f of faces) {
|
||||
let poly = f.map((n) => nodes[n].p);
|
||||
poly = dedupeRing(poly);
|
||||
if (poly.length < 3) continue;
|
||||
if (offsetToInner && half > 0) {
|
||||
poly = dedupeRing(offsetInward(poly, half));
|
||||
if (poly.length < 3) continue;
|
||||
}
|
||||
if (polygonArea(poly) < minArea) continue;
|
||||
// Kanonisch CCW zurückgeben.
|
||||
if (signedArea(poly) < 0) poly = poly.reverse();
|
||||
rooms.push(poly);
|
||||
}
|
||||
return rooms;
|
||||
}
|
||||
|
||||
// ── Klick-in-Raum: Umlauf-Tracer um einen Saatpunkt ──────────────────────────
|
||||
|
||||
/** Eine Wand-Innenfläche (Face) als gerichtete/ungerichtete Strecke. */
|
||||
export interface WallFace {
|
||||
a: Vec2;
|
||||
b: Vec2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Punkt in einem Polygon liegt (Ray-Casting, ungerade Kreuzungen).
|
||||
*/
|
||||
export function pointInPolygon(p: Vec2, poly: Vec2[]): boolean {
|
||||
let inside = false;
|
||||
const n = poly.length;
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[j];
|
||||
const intersects =
|
||||
a.y > p.y !== b.y > p.y &&
|
||||
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y + 0) + a.x;
|
||||
if (intersects) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Klick-in-Raum-Fallback: findet die Masche, die den Saatpunkt einschliesst, und
|
||||
* gibt ihren lichten Innen-Umriss zurück (oder null, wenn der Punkt in keiner
|
||||
* geschlossenen Zelle liegt). Robust gegen T-Stösse; kleine Lücken werden über
|
||||
* `gapTol` geschlossen. Nutzt intern dieselbe Arrangement-/Face-Extraktion und
|
||||
* wählt die Masche mit kleinster Fläche, die den Punkt enthält (die unmittelbar
|
||||
* umgebende Zelle statt einer grösseren umschliessenden).
|
||||
*/
|
||||
export function roomFromPointInside(
|
||||
point: Vec2,
|
||||
walls: WallSegment[],
|
||||
options: DetectRoomsOptions = {},
|
||||
): Vec2[] | null {
|
||||
const gapTol = options.gapTol ?? 0.05;
|
||||
const offsetToInner = options.offsetToInner ?? true;
|
||||
|
||||
if (walls.length < 3) return null;
|
||||
|
||||
const { nodes, edges } = buildPlanarGraph(
|
||||
walls.map((w) => ({ a: w.a, b: w.b })),
|
||||
gapTol,
|
||||
);
|
||||
if (edges.length < 3) return null;
|
||||
|
||||
const faces = extractFaces(nodes, edges);
|
||||
const half = averageThickness(walls) / 2;
|
||||
|
||||
let best: Vec2[] | null = null;
|
||||
let bestArea = Infinity;
|
||||
|
||||
for (const f of faces) {
|
||||
let raw = dedupeRing(f.map((n) => nodes[n].p));
|
||||
if (raw.length < 3) continue;
|
||||
// Test gegen die ROHE Mittellinien-Masche (schliesst den Klickpunkt weiter ein).
|
||||
if (!pointInPolygon(point, raw)) continue;
|
||||
const area = polygonArea(raw);
|
||||
if (area >= bestArea) continue;
|
||||
let inner = raw;
|
||||
if (offsetToInner && half > 0) {
|
||||
inner = dedupeRing(offsetInward(raw, half));
|
||||
if (inner.length < 3) inner = raw;
|
||||
}
|
||||
if (signedArea(inner) < 0) inner = inner.reverse();
|
||||
best = inner;
|
||||
bestArea = area;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante des Klick-Tracers, die direkt auf bereits berechneten Wand-INNEN-
|
||||
* FLÄCHEN (Faces) arbeitet: die Strecken bilden bereits die lichten Wandseiten.
|
||||
* Kein Innen-Offset (die Faces sind schon die Innenkontur). Für Aufrufer, die
|
||||
* die Wandflächen ohnehin vorhalten (z. B. aus dem Wand-Rendering).
|
||||
*/
|
||||
export function roomFromPointInsideFaces(
|
||||
point: Vec2,
|
||||
wallFaces: WallFace[],
|
||||
gapTol = 0.05,
|
||||
): Vec2[] | null {
|
||||
if (wallFaces.length < 3) return null;
|
||||
const { nodes, edges } = buildPlanarGraph(
|
||||
wallFaces.map((f) => ({ a: f.a, b: f.b })),
|
||||
gapTol,
|
||||
);
|
||||
if (edges.length < 3) return null;
|
||||
const faces = extractFaces(nodes, edges);
|
||||
let best: Vec2[] | null = null;
|
||||
let bestArea = Infinity;
|
||||
for (const f of faces) {
|
||||
const poly = dedupeRing(f.map((n) => nodes[n].p));
|
||||
if (poly.length < 3) continue;
|
||||
if (!pointInPolygon(point, poly)) continue;
|
||||
const area = polygonArea(poly);
|
||||
if (area < bestArea) {
|
||||
best = signedArea(poly) < 0 ? poly.reverse() : poly;
|
||||
bestArea = area;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// Geometrie-Helfer für Treppen (Stairs) — reine Funktionen über `Vec2`, kein
|
||||
// externer Kernel. Aus einer `Stair` (gerade/L/Wendel) werden abgeleitet:
|
||||
// • die Tritt-Rechtecke im Grundriss (jeweils mit ihrer Z-Höhe der Setzstufe),
|
||||
// • das Zwischenpodest-Polygon (L / Wendel-Auge),
|
||||
// • die Lauflinie (Polyline) + der Auf-/Abpfeil,
|
||||
// • die Schnittposition (welche Tritte unter/über der Grundriss-Schnitthöhe
|
||||
// liegen → im Plan durchgezogen vs. gestrichelt) samt SIA-Bruchlinie.
|
||||
//
|
||||
// Die 3D-Darstellung baut aus denselben Tritt-Rechtecken + Setzstufen-Höhen die
|
||||
// Stufenboxen (siehe Viewport3D). So bleiben Plan und 3D konsistent aus EINER
|
||||
// Quelle abgeleitet.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Stair, Vec2 } from "../model/types";
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
|
||||
const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y });
|
||||
const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s });
|
||||
const lenOf = (a: Vec2): number => Math.hypot(a.x, a.y);
|
||||
const norm = (a: Vec2): Vec2 => {
|
||||
const l = lenOf(a) || 1e-9;
|
||||
return { x: a.x / l, y: a.y / l };
|
||||
};
|
||||
/** Linke Normale (n = (−u.y, u.x)) — quer zur Laufrichtung. */
|
||||
const leftN = (u: Vec2): Vec2 => ({ x: -u.y, y: u.x });
|
||||
|
||||
/** 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;
|
||||
export const MIN_STEPS = 2;
|
||||
|
||||
/**
|
||||
* Sinnvolle Default-Stufenzahl aus Steighöhe + verfügbarer Lauflänge: möglichst
|
||||
* nahe an der idealen Steigungshöhe, gedeckelt durch die Lauflänge (min.
|
||||
* Auftritt ~0.24 m). Immer ≥ MIN_STEPS.
|
||||
*/
|
||||
export function defaultStepCount(totalRise: number, runLength: number): number {
|
||||
const byRise = Math.max(MIN_STEPS, Math.round(totalRise / IDEAL_RISER));
|
||||
// Auftrittstiefe = runLength / (steps − 1); mind. 0.24 m halten.
|
||||
const maxByRun = runLength > 0 ? Math.max(MIN_STEPS, Math.floor(runLength / 0.24) + 1) : byRise;
|
||||
return Math.max(MIN_STEPS, Math.min(byRise, maxByRun));
|
||||
}
|
||||
|
||||
/** Ein Tritt im Grundriss (Rechteck-Polygon) samt Setzstufen-Z (Oberkante). */
|
||||
export interface TreadRect {
|
||||
/** Vier Eckpunkte (CCW), Grundriss-Meter. */
|
||||
pts: Vec2[];
|
||||
/** Index der Stufe (0 = unterste). */
|
||||
index: number;
|
||||
/** Oberkante dieses Tritts relativ zur Treppen-UK (Meter). */
|
||||
topRise: number;
|
||||
/** Unterkante-Höhe (Setzstufe darunter) relativ zur UK (Meter). */
|
||||
baseRise: number;
|
||||
}
|
||||
|
||||
/** Vollständige, abgeleitete Treppengeometrie (Plan + 3D + Schnitt). */
|
||||
export interface StairGeometry {
|
||||
/** Alle Tritt-Rechtecke (untere → obere Stufe). */
|
||||
treads: TreadRect[];
|
||||
/** Zwischenpodest-Polygon (L/Wendel) oder null (gerade). */
|
||||
landing: Vec2[] | null;
|
||||
/** Setzstufen-Höhe (Meter). */
|
||||
riserHeight: number;
|
||||
/** Auftrittstiefe (Meter) des geraden Laufs. */
|
||||
treadDepth: number;
|
||||
/** Lauflinie als Polyline (Meter) — Mitte des Laufs, Start → Austritt. */
|
||||
runLine: Vec2[];
|
||||
/** Auf-/Abpfeil-Geometrie (Schaft + zwei Spitzenflügel). */
|
||||
arrow: { shaft: [Vec2, Vec2]; head: [Vec2, Vec2, Vec2] };
|
||||
/** Gesamt-Steighöhe (Meter). */
|
||||
totalRise: number;
|
||||
}
|
||||
|
||||
/** Rechteck-Tritt zwischen Achsparametern a0..a1 entlang `dir`, Breite `width`. */
|
||||
function treadRect(
|
||||
origin: Vec2,
|
||||
dir: Vec2,
|
||||
n: Vec2,
|
||||
a0: number,
|
||||
a1: number,
|
||||
halfW: number,
|
||||
): Vec2[] {
|
||||
const p0 = add(origin, scale(dir, a0));
|
||||
const p1 = add(origin, scale(dir, a1));
|
||||
return [
|
||||
add(p0, scale(n, halfW)),
|
||||
add(p1, scale(n, halfW)),
|
||||
add(p1, scale(n, -halfW)),
|
||||
add(p0, scale(n, -halfW)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitet die vollständige Geometrie einer Treppe ab. `totalRise` MUSS vom
|
||||
* Aufrufer aufgelöst übergeben werden (Geschosshöhe-Default liegt in
|
||||
* `stairVerticalExtent`), damit dieses Modul three-/projektfrei bleibt.
|
||||
*/
|
||||
export function stairGeometry(stair: Stair, totalRise: number): StairGeometry {
|
||||
const steps = Math.max(MIN_STEPS, Math.round(stair.stepCount));
|
||||
const riserHeight = totalRise / steps;
|
||||
const halfW = Math.max(0.05, stair.width / 2);
|
||||
const u = norm(stair.dir);
|
||||
const n = leftN(u);
|
||||
|
||||
if (stair.shape === "spiral") {
|
||||
return spiralGeometry(stair, totalRise, steps, riserHeight, halfW);
|
||||
}
|
||||
if (stair.shape === "L") {
|
||||
return lGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
|
||||
}
|
||||
return straightGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
|
||||
}
|
||||
|
||||
/** Gerade Treppe: gleich breite Tritte über die Lauflänge. */
|
||||
function straightGeometry(
|
||||
stair: Stair,
|
||||
totalRise: number,
|
||||
steps: number,
|
||||
riserHeight: number,
|
||||
halfW: number,
|
||||
u: Vec2,
|
||||
n: Vec2,
|
||||
): StairGeometry {
|
||||
const runLength = Math.max(0.1, stair.runLength);
|
||||
const treadDepth = runLength / steps;
|
||||
const treads: TreadRect[] = [];
|
||||
for (let i = 0; i < steps; i++) {
|
||||
treads.push({
|
||||
pts: treadRect(stair.start, u, n, i * treadDepth, (i + 1) * treadDepth, halfW),
|
||||
index: i,
|
||||
baseRise: i * riserHeight,
|
||||
topRise: (i + 1) * riserHeight,
|
||||
});
|
||||
}
|
||||
const runLine: Vec2[] = [
|
||||
add(stair.start, scale(u, treadDepth * 0.5)),
|
||||
add(stair.start, scale(u, runLength - treadDepth * 0.15)),
|
||||
];
|
||||
return {
|
||||
treads,
|
||||
landing: null,
|
||||
riserHeight,
|
||||
treadDepth,
|
||||
runLine,
|
||||
arrow: arrowFor(runLine, stair.up !== false),
|
||||
totalRise,
|
||||
};
|
||||
}
|
||||
|
||||
/** L-Treppe: zwei Läufe + quadratisches Zwischenpodest an der Ecke. */
|
||||
function lGeometry(
|
||||
stair: Stair,
|
||||
totalRise: number,
|
||||
steps: number,
|
||||
riserHeight: number,
|
||||
halfW: number,
|
||||
u: Vec2,
|
||||
n: Vec2,
|
||||
): StairGeometry {
|
||||
const run1 = Math.max(0.1, stair.runLength);
|
||||
const run2 = Math.max(0.1, stair.run2Length ?? stair.runLength);
|
||||
const turn = stair.turn ?? 1;
|
||||
// Podest quadratisch (Kantenlänge = Laufbreite) am Ende des ersten Laufs.
|
||||
const podest = 2 * halfW;
|
||||
// Stufen auf beide Läufe verteilen (grob proportional zur Lauflänge), ein Tritt
|
||||
// fällt aufs Podest.
|
||||
const bodySteps = steps - 1; // ein Tritt = Podest
|
||||
const s1 = Math.max(1, Math.round((bodySteps * run1) / (run1 + run2)));
|
||||
const s2 = Math.max(1, bodySteps - s1);
|
||||
const td1 = run1 / s1;
|
||||
const td2 = run2 / s2;
|
||||
|
||||
const treads: TreadRect[] = [];
|
||||
let rise = 0;
|
||||
for (let i = 0; i < s1; i++) {
|
||||
treads.push({
|
||||
pts: treadRect(stair.start, u, n, i * td1, (i + 1) * td1, halfW),
|
||||
index: i,
|
||||
baseRise: rise,
|
||||
topRise: rise + riserHeight,
|
||||
});
|
||||
rise += riserHeight;
|
||||
}
|
||||
// Zweiter Lauf beginnt am Ende des Podests. Richtung um 90° gedreht (turn).
|
||||
const cornerCenter = add(stair.start, scale(u, run1 + halfW));
|
||||
const u2: Vec2 = turn > 0 ? n : scale(n, -1); // links/rechts abbiegen
|
||||
const n2 = leftN(u2);
|
||||
// Podest-Tritt (quadratisch um cornerCenter, an u/u2 ausgerichtet).
|
||||
const landing: Vec2[] = [
|
||||
add(add(cornerCenter, scale(u, -halfW)), scale(n, halfW)),
|
||||
add(add(cornerCenter, scale(u, halfW)), scale(n, halfW)),
|
||||
add(add(cornerCenter, scale(u, halfW)), scale(n, -halfW)),
|
||||
add(add(cornerCenter, scale(u, -halfW)), scale(n, -halfW)),
|
||||
];
|
||||
const podestTopRise = rise + riserHeight;
|
||||
rise += riserHeight;
|
||||
// Startpunkt des zweiten Laufs: Podest-Kante in Richtung u2.
|
||||
const run2Start = add(cornerCenter, scale(u2, halfW));
|
||||
for (let i = 0; i < s2; i++) {
|
||||
treads.push({
|
||||
pts: treadRect(run2Start, u2, n2, i * td2, (i + 1) * td2, halfW),
|
||||
index: s1 + 1 + i,
|
||||
baseRise: rise,
|
||||
topRise: rise + riserHeight,
|
||||
});
|
||||
rise += riserHeight;
|
||||
}
|
||||
void podest;
|
||||
void podestTopRise;
|
||||
|
||||
const runLine: Vec2[] = [
|
||||
add(stair.start, scale(u, td1 * 0.5)),
|
||||
cornerCenter,
|
||||
add(run2Start, scale(u2, run2 - td2 * 0.15)),
|
||||
];
|
||||
return {
|
||||
treads,
|
||||
landing,
|
||||
riserHeight,
|
||||
treadDepth: td1,
|
||||
runLine,
|
||||
arrow: arrowFor(runLine, stair.up !== false),
|
||||
totalRise,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wendeltreppe: keilförmige Tritte um ein Zentrum (vereinfachtes Modell). */
|
||||
function spiralGeometry(
|
||||
stair: Stair,
|
||||
totalRise: number,
|
||||
steps: number,
|
||||
riserHeight: number,
|
||||
halfW: number,
|
||||
): StairGeometry {
|
||||
const center = stair.center ?? stair.start;
|
||||
const radius = Math.max(halfW + 0.1, stair.radius ?? stair.width);
|
||||
const sweep = (stair.sweep ?? 270) * DEG;
|
||||
// Startwinkel aus start→center (Lauflinie beginnt am Startpunkt).
|
||||
const startVec = sub(stair.start, center);
|
||||
const a0 = Math.atan2(startVec.y, startVec.x);
|
||||
const dA = sweep / steps;
|
||||
const rIn = Math.max(0.02, radius - halfW);
|
||||
const rOut = radius + halfW;
|
||||
|
||||
const treads: TreadRect[] = [];
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const t0 = a0 + i * dA;
|
||||
const t1 = a0 + (i + 1) * dA;
|
||||
// Keil-Viereck: innen(t0) → außen(t0) → außen(t1) → innen(t1).
|
||||
const pts: Vec2[] = [
|
||||
{ x: center.x + rIn * Math.cos(t0), y: center.y + rIn * Math.sin(t0) },
|
||||
{ x: center.x + rOut * Math.cos(t0), y: center.y + rOut * Math.sin(t0) },
|
||||
{ x: center.x + rOut * Math.cos(t1), y: center.y + rOut * Math.sin(t1) },
|
||||
{ x: center.x + rIn * Math.cos(t1), y: center.y + rIn * Math.sin(t1) },
|
||||
];
|
||||
treads.push({
|
||||
pts,
|
||||
index: i,
|
||||
baseRise: i * riserHeight,
|
||||
topRise: (i + 1) * riserHeight,
|
||||
});
|
||||
}
|
||||
// Lauflinie = Bogen auf Radius `radius` (mittlere Lauflinie).
|
||||
const runLine: Vec2[] = [];
|
||||
const N = Math.max(2, steps);
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const tt = a0 + (sweep * i) / N;
|
||||
runLine.push({ x: center.x + radius * Math.cos(tt), y: center.y + radius * Math.sin(tt) });
|
||||
}
|
||||
return {
|
||||
treads,
|
||||
// Wendel-Auge (Innenkreis) als „Podest"-Polygon.
|
||||
landing: eyePolygon(center, rIn),
|
||||
riserHeight,
|
||||
treadDepth: (2 * Math.PI * radius * (sweep / (2 * Math.PI))) / steps,
|
||||
runLine,
|
||||
arrow: arrowFor(runLine, stair.up !== false),
|
||||
totalRise,
|
||||
};
|
||||
}
|
||||
|
||||
/** Achteck-Approximation des Wendel-Auges (Innenkreis). */
|
||||
function eyePolygon(center: Vec2, r: number): Vec2[] {
|
||||
const pts: Vec2[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const t = (i / 12) * Math.PI * 2;
|
||||
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auf-/Abpfeil aus der Lauflinie: Schaft = erstes→letztes Segment, Spitze am
|
||||
* oberen (bzw. bei `up=false` am unteren) Ende. `up` kehrt die Richtung um.
|
||||
*/
|
||||
function arrowFor(
|
||||
runLine: Vec2[],
|
||||
up: boolean,
|
||||
): { shaft: [Vec2, Vec2]; head: [Vec2, Vec2, Vec2] } {
|
||||
const a = runLine[0];
|
||||
const b = runLine[runLine.length - 1];
|
||||
const tip = up ? b : a;
|
||||
const from = up ? runLine[runLine.length - 2] : runLine[1];
|
||||
const dir = norm(sub(tip, from));
|
||||
const n = leftN(dir);
|
||||
const s = 0.22; // Flügellänge (Meter)
|
||||
const back = add(tip, scale(dir, -s));
|
||||
const w1 = add(back, scale(n, s * 0.55));
|
||||
const w2 = add(back, scale(n, -s * 0.55));
|
||||
return { shaft: [a, b], head: [w1, tip, w2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schnitt-Aufteilung im Grundriss: DOSSIER schneidet die Treppe auf `cutRise`
|
||||
* (Schnitthöhe über OKFF). Tritte mit Oberkante ≤ cutRise werden durchgezogen
|
||||
* gezeichnet (unterhalb der Schnittebene), darüberliegende gestrichelt (oberhalb,
|
||||
* verdeckt). Der ERSTE Tritt oberhalb markiert die SIA-Bruchlinie (diagonaler
|
||||
* Doppelstrich über die Laufbreite).
|
||||
*/
|
||||
export interface StairCut {
|
||||
/** Indizes der Tritte UNTER der Schnittebene (durchgezogen). */
|
||||
belowIndices: number[];
|
||||
/** Indizes der Tritte ÜBER der Schnittebene (gestrichelt/verdeckt). */
|
||||
aboveIndices: number[];
|
||||
/** Bruchlinie (SIA): zwei parallele Diagonalstriche über die Laufbreite. */
|
||||
breakLine: [Vec2, Vec2][] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt die Schnittaufteilung. `cutRise` = Schnitthöhe über der Treppen-UK.
|
||||
* Findet den ersten Tritt, dessen Oberkante die Schnittebene überschreitet, und
|
||||
* legt dort die Bruchlinie quer über den Lauf.
|
||||
*/
|
||||
export function stairCut(geo: StairGeometry, cutRise: number): StairCut {
|
||||
const below: number[] = [];
|
||||
const above: number[] = [];
|
||||
let breakIdx = -1;
|
||||
for (const tr of geo.treads) {
|
||||
if (tr.topRise <= cutRise + 1e-6) {
|
||||
below.push(tr.index);
|
||||
} else {
|
||||
if (breakIdx < 0) breakIdx = tr.index;
|
||||
above.push(tr.index);
|
||||
}
|
||||
}
|
||||
let breakLine: [Vec2, Vec2][] | null = null;
|
||||
if (breakIdx >= 0) {
|
||||
const tr = geo.treads.find((t) => t.index === breakIdx);
|
||||
if (tr) {
|
||||
// Diagonale Bruchlinie über den Tritt: von einer Ecke zur gegenüberliegenden,
|
||||
// als Doppelstrich (leicht versetzt) — SIA-Treppen-Schnittsymbol.
|
||||
const [c0, c1, c2, c3] = tr.pts;
|
||||
const mid01 = scale(add(c0, c1), 0.5);
|
||||
const mid23 = scale(add(c2, c3), 0.5);
|
||||
const off = scale(norm(sub(c1, c0)), 0.06);
|
||||
breakLine = [
|
||||
[add(mid01, off), add(mid23, off)],
|
||||
[sub(mid01, off), sub(mid23, off)],
|
||||
];
|
||||
}
|
||||
}
|
||||
return { belowIndices: below, aboveIndices: above, breakLine };
|
||||
}
|
||||
|
||||
/** Achsparallele Bounding-Box aller Tritte (+ Podest) einer Treppengeometrie. */
|
||||
export function stairBBox(geo: StairGeometry): {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
} {
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
const acc = (p: Vec2) => {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
};
|
||||
for (const tr of geo.treads) for (const p of tr.pts) acc(p);
|
||||
if (geo.landing) for (const p of geo.landing) acc(p);
|
||||
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
/** Punkt-in-Polygon (Ray-Casting) — für die Body-Auswahl im Grundriss/3D. */
|
||||
export function pointInPolygon(p: Vec2, poly: Vec2[]): boolean {
|
||||
let inside = false;
|
||||
const nn = poly.length;
|
||||
for (let i = 0, j = nn - 1; i < nn; j = i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[j];
|
||||
const intersect =
|
||||
a.y > p.y !== b.y > p.y &&
|
||||
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-12) + a.x;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** Ob ein Modellpunkt IRGENDEINEN Tritt (oder das Podest) einer Treppe trifft. */
|
||||
export function pointHitsStair(p: Vec2, geo: StairGeometry): boolean {
|
||||
for (const tr of geo.treads) if (pointInPolygon(p, tr.pts)) return true;
|
||||
if (geo.landing && pointInPolygon(p, geo.landing)) return true;
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user