// 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--". */ 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 };