Files
DOSSIER-STANDALONE/src/model/parametricWalls.ts
T

570 lines
20 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.
// Parametrische Wand-Engine — löst ParametricWall-Regelwerke zu Wall[]-Arrays auf.
//
// Dieses Modul ist ABSICHTLICH frei von React-, Store- und App.tsx-Importen.
// Es ist eine reine Modell-Schicht: Eingabe ist ein ParametricWall-Regelwerk +
// Kontext (Geschoss, Grid-Achsen, bestehende Wände), Ausgabe sind neue Wall[]-
// Objekte, die direkt in project.walls eingefügt werden können.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type {
ConditionalThicknessRule,
DrawingLevel,
GridRule,
ModuleRule,
ParametricRule,
ParametricWall,
ReferenceLineRule,
SequenceRule,
Vec2,
Wall,
WallType,
} from "./types";
// ── Kontext-Typen ─────────────────────────────────────────────────────────
/**
* Kontext, der dem Engine beim Auflösen übergeben wird.
* Enthält alle Informationen, die für die Generierung von Wänden benötigt werden.
*/
export interface ParametricContext {
/** Das Ziel-Geschoss. */
floor: DrawingLevel;
/**
* Optionale Rasterachsen (Phase 3: verlinkter Grid-Ressource). Fehlen sie,
* berechnet die Engine die Achsen aus `GridRule.spacing`.
*/
gridAxes?: { x: number[]; y: number[] };
/**
* Optionales Clipping-Polygon (Meter). Fehlt es, reicht das Raster über
* einen Standardbereich (0…spacing*count).
*/
boundaryGeometry?: { boundary: Vec2[] };
/**
* Bereits im Projekt vorhandene Wände des Geschosses. Werden von
* refinierenden Regeln (ConditionalThicknessRule, ReferenceLineRule) genutzt.
* Optional — fehlt er, wird mit leerem Array gearbeitet.
*/
existingWalls?: Wall[];
}
/** Interner Arbeitskontext, der durch den Rule-Dispatcher weitergereicht wird. */
interface RuleCtx {
floorId: string;
defaultWallType: WallType;
context: ParametricContext;
/** Wände, die bisher durch frühere Regeln erzeugt wurden (veränderbar). */
existingWalls: Wall[];
}
// ── ID-Hilfsfunktion ──────────────────────────────────────────────────────
/** Einfacher Zähler für generierte Wand-IDs (sessionlokal, nicht persistent). */
let _idCounter = 0;
/**
* Erzeugt eine eindeutige ID für eine parametrisch generierte Wand.
* Format: „pw-<parametricWallId>-<counter>".
*/
function makeWallId(prefix: string): string {
_idCounter += 1;
return `pw-${prefix}-${_idCounter}`;
}
// ── Öffentliche API ───────────────────────────────────────────────────────
/**
* Löst ein ParametricWall-Regelwerk zu einem Wall[]-Array für ein gegebenes
* Geschoss auf.
*
* Ablauf:
* 1. Regelwerk sequenziell ausführen; jede Regel erhält die Ausgabe der
* vorherigen als `existingWalls` (ermöglicht Verfeinerung).
* 2. Duplikate (gleicher Start-/Endpunkt innerhalb `tolerance`) entfernen.
* 3. Bereinigte Wall[]-Liste zurückgeben.
*
* Die Ausgabe ist sofort bereit zur Einfügung in `project.walls`. Es werden
* keine Seiteneffekte erzeugt — kein Store, kein Dispatch, kein React.
*
* @param parametricWall - Das Regelwerk (aus Project.parametricWalls[]).
* @param floorId - ID des Ziel-Geschosses.
* @param context - Kontext (Geschoss-Objekt, Grid-Achsen, Grenzen, …).
* @param defaultWallType - Fallback-Wandtyp, wenn eine Regel keinen nennt.
* @param tolerance - Näherungstoleranz für Duplikat-Erkennung (Meter, Default 0.01).
* @returns Wall[]-Array, bereit zur Einfügung.
*/
export function resolveParametricWall(
parametricWall: ParametricWall,
floorId: string,
context: ParametricContext,
defaultWallType: WallType,
tolerance = 0.01,
): Wall[] {
const ctx: RuleCtx = {
floorId,
defaultWallType,
context,
existingWalls: context.existingWalls ?? [],
};
const generated: Wall[] = [];
for (const rule of parametricWall.rules) {
const ruleWalls = applyRule(rule, {
...ctx,
existingWalls: [...ctx.existingWalls, ...generated],
});
generated.push(...ruleWalls);
}
return deduplicateWalls(generated, tolerance);
}
// ── Rule-Dispatcher ───────────────────────────────────────────────────────
/**
* Dispatcher: delegiert eine Regel an die passende Implementierung.
* Alle Branches sind exhaustiv — unbekannte Typen geben leer zurück.
*
* @param rule - Die auszuführende Regel.
* @param ctx - Interner Arbeitskontext.
* @returns Wall[]-Array, das diese Regel erzeugt oder verändert hat.
*/
export function applyRule(rule: ParametricRule, ctx: RuleCtx): Wall[] {
switch (rule.type) {
case "grid":
return applyGridRule(rule, ctx);
case "module":
return applyModuleRule(rule, ctx);
case "conditional-thickness":
return applyConditionalThicknessRule(rule, ctx);
case "reference-line":
return applyReferenceLineRule(rule, ctx);
case "sequence":
return applySequenceRule(rule, ctx);
default: {
// TypeScript exhaustiveness-Guard: niemals erreicht bei vollständiger Union.
const _exhaustive: never = rule;
void _exhaustive;
return [];
}
}
}
// ── Regel-Implementierungen ────────────────────────────────────────────────
/**
* Raster-Regel: generiert Wände entlang gleichmäßiger X-/Y-Achsen.
*
* MVP-Verhalten:
* - Rasterachsen aus `context.gridAxes` ODER gleichmäßigem `spacing`.
* - Standardbereich: 0 … `defaultExtent` (10 Einheiten × Spacing), wenn keine
* Grenzgeometrie vorhanden.
* - Clipping durch `context.boundaryGeometry` (nur einfache Bounding-Box, MVP).
* - Je Achse eine Wand senkrecht zur Richtung.
*
* @param rule - GridRule-Parameter.
* @param ctx - Arbeitskontext (Geschoss, Wandtyp, …).
* @returns Wall[]-Array mit generierten Rasterwänden.
*/
export function applyGridRule(rule: GridRule, ctx: RuleCtx): Wall[] {
const walls: Wall[] = [];
const spacing = rule.spacing ?? 3.0;
const wallTypeId = rule.wallTypeId ?? ctx.defaultWallType.id;
const referenceLine = rule.referenceLine;
const height = rule.height ?? (ctx.context.floor.floorHeight ?? 2.6);
const floorId = ctx.floorId;
const categoryCode = "20";
// Bereich aus Grenzpolygon (Bounding-Box) oder Standardbereich ermitteln.
const { minX, maxX, minY, maxY } = computeBounds(ctx.context, spacing);
// X-Richtung: Wände parallel zur Y-Achse (also senkrecht zur X-Richtung).
if (rule.directions === "x" || rule.directions === "both") {
const axes = rule.gridId != null && ctx.context.gridAxes
? ctx.context.gridAxes.x
: generateAxisPositions(minX, maxX, spacing);
for (const axisX of axes) {
const wall: Wall = {
id: makeWallId(`${floorId}-gx`),
type: "wall",
floorId,
categoryCode,
start: { x: axisX, y: minY },
end: { x: axisX, y: maxY },
wallTypeId,
height,
...(referenceLine != null ? { referenceLine } : {}),
};
walls.push(wall);
}
}
// Y-Richtung: Wände parallel zur X-Achse (also senkrecht zur Y-Richtung).
if (rule.directions === "y" || rule.directions === "both") {
const axes = rule.gridId != null && ctx.context.gridAxes
? ctx.context.gridAxes.y
: generateAxisPositions(minY, maxY, spacing);
for (const axisY of axes) {
const wall: Wall = {
id: makeWallId(`${floorId}-gy`),
type: "wall",
floorId,
categoryCode,
start: { x: minX, y: axisY },
end: { x: maxX, y: axisY },
wallTypeId,
height,
...(referenceLine != null ? { referenceLine } : {}),
};
walls.push(wall);
}
}
return walls;
}
/**
* Modul-Regel: unterteilt eine Referenzspanne proportional in Felder.
*
* MVP-Verhalten:
* - Spanne aus Geschoss-Ausdehnung (Bounding-Box) oder Referenzwand.
* - Teilungspunkte im `moduleSize`-Abstand.
* - Querwände (senkrecht zur `direction`) an jedem Teilungspunkt.
*
* @param rule - ModuleRule-Parameter.
* @param ctx - Arbeitskontext.
* @returns Wall[]-Array mit Querwänden an Modul-Teilungspunkten.
*/
export function applyModuleRule(rule: ModuleRule, ctx: RuleCtx): Wall[] {
const walls: Wall[] = [];
const wallTypeId = rule.wallTypeId ?? ctx.defaultWallType.id;
const referenceLine = rule.referenceLine;
const height = rule.height ?? (ctx.context.floor.floorHeight ?? 2.6);
const floorId = ctx.floorId;
const categoryCode = "20";
const { minX, maxX, minY, maxY } = computeBounds(ctx.context, rule.moduleSize);
// Referenzwand: falls angegeben, Spanne aus dieser Wand ableiten.
let spanStart: number;
let spanEnd: number;
let perpStart: number;
let perpEnd: number;
if (rule.referenceWallId != null) {
const refWall = ctx.existingWalls.find((w) => w.id === rule.referenceWallId);
if (refWall != null) {
// Spanne entlang der Hauptachse der Referenzwand.
if (rule.direction === "x") {
spanStart = Math.min(refWall.start.x, refWall.end.x);
spanEnd = Math.max(refWall.start.x, refWall.end.x);
perpStart = minY;
perpEnd = maxY;
} else {
spanStart = Math.min(refWall.start.y, refWall.end.y);
spanEnd = Math.max(refWall.start.y, refWall.end.y);
perpStart = minX;
perpEnd = maxX;
}
} else {
// Referenzwand nicht gefunden: Fallback auf Bounding-Box.
spanStart = rule.direction === "x" ? minX : minY;
spanEnd = rule.direction === "x" ? maxX : maxY;
perpStart = rule.direction === "x" ? minY : minX;
perpEnd = rule.direction === "x" ? maxY : maxX;
}
} else {
spanStart = rule.direction === "x" ? minX : minY;
spanEnd = rule.direction === "x" ? maxX : maxY;
perpStart = rule.direction === "x" ? minY : minX;
perpEnd = rule.direction === "x" ? maxY : maxX;
}
// Modul-Teilungspunkte (ohne Start und Ende der Spanne selbst).
const positions = generateAxisPositions(spanStart, spanEnd, rule.moduleSize);
// Ersten und letzten Punkt herausfiltern, da das dort bereits Außenwände gibt.
const dividers = positions.filter((p) => p > spanStart + 1e-6 && p < spanEnd - 1e-6);
for (const pos of dividers) {
const wall: Wall = rule.direction === "x"
? {
id: makeWallId(`${floorId}-mx`),
type: "wall",
floorId,
categoryCode,
start: { x: pos, y: perpStart },
end: { x: pos, y: perpEnd },
wallTypeId,
height,
...(referenceLine != null ? { referenceLine } : {}),
}
: {
id: makeWallId(`${floorId}-my`),
type: "wall",
floorId,
categoryCode,
start: { x: perpStart, y: pos },
end: { x: perpEnd, y: pos },
wallTypeId,
height,
...(referenceLine != null ? { referenceLine } : {}),
};
walls.push(wall);
}
return walls;
}
/**
* Bedingte-Dicken-Regel: weist bestehenden Wänden einen neuen Wandtyp zu,
* wenn eine Bedingung erfüllt ist.
*
* Gibt MODIFIZIERTE KOPIEN der passenden Wände zurück (keine Mutation).
* Die Originalwände in `existingWalls` bleiben unverändert.
*
* @param rule - ConditionalThicknessRule-Parameter.
* @param ctx - Arbeitskontext (enthält die zu prüfenden Wände).
* @returns Wall[]-Array mit geändertem `wallTypeId` für passende Wände.
*/
export function applyConditionalThicknessRule(
rule: ConditionalThicknessRule,
ctx: RuleCtx,
): Wall[] {
return ctx.existingWalls
.filter((w) => matchesCondition(w, rule.condition, ctx))
.map((w) => ({ ...w, id: makeWallId(`${ctx.floorId}-ct`), wallTypeId: rule.wallTypeId }));
}
/**
* Referenzlinien-Regel: setzt `referenceLine` bei passenden Wänden einheitlich.
*
* Gibt MODIFIZIERTE KOPIEN der passenden Wände zurück.
*
* @param rule - ReferenceLineRule-Parameter.
* @param ctx - Arbeitskontext.
* @returns Wall[]-Array mit gesetzter `referenceLine`.
*/
export function applyReferenceLineRule(
rule: ReferenceLineRule,
ctx: RuleCtx,
): Wall[] {
return ctx.existingWalls
.filter((w) => matchesTarget(w, rule.target, ctx))
.map((w) => ({
...w,
id: makeWallId(`${ctx.floorId}-rl`),
referenceLine: rule.referenceLine,
}));
}
/**
* Sequenz-Regel: führt Unterregeln in Reihenfolge aus.
*
* Jede Unterregel erhält das bisherige Ergebnis als `existingWalls`, sodass
* spätere Regeln die früheren verfeinern können (z. B. Raster → Dicke → Linie).
* Mit `stopOnMatch=true` bricht die Sequenz nach dem ersten produktiven Schritt ab.
*
* @param rule - SequenceRule-Parameter (enthält `rules[]`).
* @param ctx - Arbeitskontext.
* @returns Wall[]-Array (Summe aller Unterregel-Ausgaben, oder Abbruch bei stopOnMatch).
*/
export function applySequenceRule(rule: SequenceRule, ctx: RuleCtx): Wall[] {
let result: Wall[] = [];
for (const subrule of rule.rules) {
const subruleCtx: RuleCtx = {
...ctx,
existingWalls: [...ctx.existingWalls, ...result],
};
const subruleWalls = applyRule(subrule, subruleCtx);
result = [...result, ...subruleWalls];
if (rule.stopOnMatch === true && subruleWalls.length > 0) {
break;
}
}
return result;
}
// ── Hilfsfunktionen ───────────────────────────────────────────────────────
/**
* Entfernt doppelte Wände aus der generierten Liste.
*
* Zwei Wände gelten als Duplikat, wenn Start- und Endpunkt jeweils innerhalb
* `tolerance` (Meter) übereinstimmen — sowohl in der gleichen als auch in der
* umgekehrten Orientierung (A→B = B→A).
*
* Behält jeweils die ERSTE Instanz; spätere Duplikate werden verworfen.
*
* @param walls - Eingabe-Wall[]-Array (wird nicht mutiert).
* @param tolerance - Abstandsschwelle in Metern (Default 0.01 m = 1 cm).
* @returns Bereinigte Wall[]-Liste ohne Duplikate.
*/
export function deduplicateWalls(walls: Wall[], tolerance = 0.01): Wall[] {
const unique: Wall[] = [];
for (const candidate of walls) {
const isDuplicate = unique.some((existing) => {
const fwd =
dist2(candidate.start, existing.start) <= tolerance &&
dist2(candidate.end, existing.end) <= tolerance;
const rev =
dist2(candidate.start, existing.end) <= tolerance &&
dist2(candidate.end, existing.start) <= tolerance;
return fwd || rev;
});
if (!isDuplicate) {
unique.push(candidate);
}
}
return unique;
}
/**
* Prüft, ob eine Wand die Bedingung einer ConditionalThicknessRule erfüllt.
*
* MVP-Implementierung:
* • „exterior" — Wand liegt am Rand der Bounding-Box des Kontexts
* (innerhalb einer großzügigen Toleranz).
* • „interior" — Gegenteil von „exterior".
* • „bearing" — Wand ist in der Primärrichtung (X oder Y) ausgerichtet
* (einfaches Heuristikum für MVP).
* • Sonstige — immer `false` (Phase 3: Wall.tags[]).
*
* @param wall - Zu prüfende Wand.
* @param condition - Bedingungsstring aus der Regel.
* @param ctx - Arbeitskontext (enthält Grenzgeometrie).
* @returns `true`, wenn die Bedingung zutrifft.
*/
export function matchesCondition(
wall: Wall,
condition: string,
ctx: RuleCtx,
): boolean {
if (condition === "exterior") {
return isExteriorWall(wall, ctx);
}
if (condition === "interior") {
return !isExteriorWall(wall, ctx);
}
if (condition === "bearing") {
// Heuristikum: Wand ist „tragend", wenn sie in X- oder Y-Richtung läuft
// (nahezu horizontal/vertikal). Phase 3: strukturelle Klassifizierung.
const dx = Math.abs(wall.end.x - wall.start.x);
const dy = Math.abs(wall.end.y - wall.start.y);
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 1e-6) return false;
const angleFromX = Math.atan2(dy, dx);
// Gilt als tragend, wenn Winkel innerhalb 10° von X- oder Y-Achse liegt.
const tenDeg = (10 * Math.PI) / 180;
return (
angleFromX <= tenDeg ||
angleFromX >= Math.PI / 2 - tenDeg
);
}
// Unbekannte Bedingung — Phase 3: Tag-Matching.
return false;
}
/**
* Prüft, ob eine Wand dem Ziel einer ReferenceLineRule entspricht.
*
* @param wall - Zu prüfende Wand.
* @param target - Zielstring aus der Regel.
* @param ctx - Arbeitskontext.
* @returns `true`, wenn die Wand dem Ziel entspricht.
*/
export function matchesTarget(
wall: Wall,
target: string,
ctx: RuleCtx,
): boolean {
if (target === "all") return true;
if (target === "exterior") return isExteriorWall(wall, ctx);
// Unbekanntes Ziel — Phase 3: Tag-Matching.
return false;
}
// ── Interne Hilfsfunktionen ───────────────────────────────────────────────
/** Euklidischer Abstand zweier 2D-Punkte. */
function dist2(a: Vec2, b: Vec2): number {
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Berechnet die Bounding-Box des Kontexts.
* Nutzt `boundaryGeometry`, falls vorhanden; sonst einen Standardbereich,
* der auf `spacing` basiert (0 … spacing × 10).
*/
function computeBounds(
context: ParametricContext,
spacing: number,
): { minX: number; maxX: number; minY: number; maxY: number } {
if (context.boundaryGeometry != null && context.boundaryGeometry.boundary.length >= 2) {
const pts = context.boundaryGeometry.boundary;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const p of pts) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
return { minX, maxX, minY, maxY };
}
// Standardbereich: 10 Einheiten × Spacing.
const extent = spacing * 10;
return { minX: 0, maxX: extent, minY: 0, maxY: extent };
}
/**
* Generiert gleichmäßig verteilte Achspositionen im Intervall [min, max].
* Die erste Position liegt bei `min`, jede weitere `spacing` danach.
* Enthält `max`, wenn dieser exakt auf einem Rasterpunkt liegt (±1e-6).
*/
function generateAxisPositions(min: number, max: number, spacing: number): number[] {
if (spacing <= 0 || max <= min) return [];
const positions: number[] = [];
let pos = min;
while (pos <= max + 1e-6) {
positions.push(Math.round(pos * 1e6) / 1e6); // Rundungsfehler vermeiden
pos += spacing;
}
return positions;
}
/**
* Klassifiziert eine Wand als „außen" (Rand der Bounding-Box) oder „innen".
*
* MVP: Eine Wand gilt als außen, wenn BEIDE ihrer Endpunkte auf oder nahe
* am Rand der Bounding-Box des Kontexts liegen (±`edgeTolerance`).
*/
function isExteriorWall(wall: Wall, ctx: RuleCtx): boolean {
const { minX, maxX, minY, maxY } = computeBounds(ctx.context, 1.0);
const edgeTol = 0.1; // 10 cm Toleranz am Rand.
const isOnEdge = (p: Vec2): boolean =>
Math.abs(p.x - minX) <= edgeTol ||
Math.abs(p.x - maxX) <= edgeTol ||
Math.abs(p.y - minY) <= edgeTol ||
Math.abs(p.y - maxY) <= edgeTol;
return isOnEdge(wall.start) && isOnEdge(wall.end);
}
// ── Re-Export der Kontexttypen für Unit-Tests / Integration ──────────────
// (ermöglicht Import ohne Direktverweis auf interne RuleCtx)
export type { ParametricContext as ResolveContext };
// DrawingLevel-Re-Export für Konsumenten, die keinen eigenen types-Import wollen.
export type { DrawingLevel };