diff --git a/src/model/parametricWalls.ts b/src/model/parametricWalls.ts new file mode 100644 index 0000000..f89e5b4 --- /dev/null +++ b/src/model/parametricWalls.ts @@ -0,0 +1,569 @@ +// 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 }; diff --git a/src/model/types.ts b/src/model/types.ts index 648896a..e64cebe 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -11,6 +11,14 @@ export type Vec2 = { x: number; y: number }; +// SIA-416-Blatt-Kategorie eines Raums. Der reine Rechenkern (geometry/roomArea) +// definiert den Typ; hier nur der Typ-Import (kein Zyklus zur Laufzeit). +import type { SiaCategory } from "../geometry/roomArea"; +export type { SiaCategory } from "../geometry/roomArea"; +// Rich-Text-Dokument für den Raum-Stempel (frei editierbarer Teil). +import type { RichTextDoc } from "../text/richText"; +export type { RichTextDoc } from "../text/richText"; + // ── Ressourcen-Bibliotheken (Vectorworks-/DOSSIER-Stil) ──────────────────── // Verwaltete, per id verwiesene Stil-Ressourcen. Verweis-Kette: // Component → Hatch → LineStyle. @@ -56,6 +64,33 @@ export interface HatchStyle { lineStyleId?: string; } +/** + * PBR-Material-Karten eines Bauteils (3D-Texturierung). Jede URL verweist auf + * ein Bild — entweder ein eingebautes Bibliotheks-Asset unter + * `/assets/materials/...` ODER eine per Nutzer-Upload erzeugte Object-/Data-URL. + * Fehlende Karten werden weggelassen (z. B. nur `color` bei einem Upload). + * `sizeM` ist die physische Kantenlänge EINER Texturkachel in Metern (für die + * korrekte Skalierung der Wiederholung), Default 1.0 m. + */ +export interface ComponentMaterial { + /** Optionaler Verweis auf ein Bibliotheks-Asset (`MaterialAsset.id`). */ + libraryId?: string; + /** Albedo-/Farb-Karte (map). */ + color?: string; + /** Normal-Karte (normalMap) — erzeugt die Oberflächentiefe. */ + normal?: string; + /** Rauheits-Karte (roughnessMap). */ + roughness?: string; + /** Metallizitäts-Karte (metalnessMap). */ + metalness?: string; + /** Höhen-/Displacement-Karte (displacementMap, dezent angewandt). */ + displacement?: string; + /** Ambient-Occlusion-Karte (aoMap). */ + ao?: string; + /** Physische Kachelgröße in Metern (Default 1.0). */ + sizeM?: number; +} + /** * Ein Bauteil-Material (Component Manager) — vereint Plan-Schnittdarstellung * (Schraffur) und 3D-Erscheinung. Ersetzt das frühere `Material`. @@ -69,6 +104,12 @@ export interface Component { hatchId: string; /** Optionale 3D-Textur (vorerst ignoriert). */ texture3d?: string; + /** + * Optionales echtes PBR-Material für die 3D-Texturierung (Bibliothek ODER + * Upload). Fehlt es, bleibt das heutige Verhalten (matte Farbe). Wirkt nur im + * Render-Modus „textured". + */ + material?: ComponentMaterial; /** Verschneidungs-Rang: höher läuft am Stoß durch (Backbone). */ joinPriority: number; } @@ -88,6 +129,182 @@ export interface WallType { layers: Layer[]; } +// ── Parametrische Wände (Parametric Walls) ──────────────────────────────── +// Parametrische Wand-Regeln generieren automatisch Wall[]-Arrays — analog zu +// FreeCAD BIM. Sie leben in Project.resources.parametricWalls[] und werden +// durch `resolveParametricWall()` in `src/model/parametricWalls.ts` aufgelöst. +// Ausgabe: normale Wall[]-Objekte (kein neuer Elementtyp). + +/** + * Eine parametrische Wand-Regel — generiert automatisch Wall[]-Einträge für + * ein gegebenes Geschoss. Lebt in der Ressourcen-Bibliothek des Projekts. + * Wird über `resolveParametricWall()` aufgelöst, nicht zur Laufzeit gespeichert. + */ +export interface ParametricWall { + id: string; + /** Anzeigename, z. B. „Wohnbau-Raster 3m". */ + name: string; + /** Optionale Beschreibung (für den Ressourcen-Manager). */ + description?: string; + /** + * Geordnete Liste der anzuwendenden Regeln. Spätere Regeln können die + * Ausgabe früherer Regeln verfeinern (z. B. Dickenzuweisung nach Raster). + */ + rules: ParametricRule[]; + /** + * Rückfall-Wandtyp, falls eine Regel keinen eigenen `wallTypeId` nennt. + * Muss auf einen gültigen WallType im Projekt verweisen. + */ + defaultWallTypeId: string; +} + +/** + * Eine einzelne parametrische Regel — eine Strategie zur Wandplatzierung oder + * -verfeinerung. Regeln werden als Discriminated Union kodiert; der `type`-Tag + * bestimmt, welche Felder verfügbar sind. + */ +export type ParametricRule = + | GridRule + | ModuleRule + | ConditionalThicknessRule + | ReferenceLineRule + | SequenceRule; + +/** + * Raster-Regel: generiert Wände entlang gleichmäßiger X-/Y-Achsen. + * Typischer Anwendungsfall: Tragwerks-Achsraster (z. B. 3 m Abstand). + * + * Ablauf (Engine): + * 1. Rasterachsen aus `spacing` oder (später) verlinkter Grid-Ressource. + * 2. Je Achse eine Wand von Rand zu Rand (begrenzt durch `boundaryId`). + * 3. Wandtyp, Referenzlinie und Höhe gemäß Regelfelder. + */ +export interface GridRule { + type: "grid"; + /** + * Optionaler Verweis auf eine Grid-Ressource (Phase 3). Für MVP wird + * stattdessen `spacing` genutzt. + */ + gridId?: string; + /** Rasterabstand in Metern (Default: 3.0). Wird genutzt, wenn kein `gridId`. */ + spacing?: number; + /** + * Achsrichtungen: „x" = nur horizontale Wände, „y" = nur vertikale, + * „both" = Vollraster. + */ + directions: "x" | "y" | "both"; + /** + * Optionaler Verweis auf eine Drawing2D-Grenze (als Clipping-Polygon). + * Fehlt er, reicht das Raster über den sichtbaren Bereich des Geschosses. + */ + boundaryId?: string; + /** Optionale Wandtyp-Übersteuerung; sonst `defaultWallTypeId`. */ + wallTypeId?: string; + /** Lage der Wandachse über die Dicke (Vectorworks-Stil). */ + referenceLine?: WallReferenceLine; + /** Optionale Höhenübersteuerung in Metern; sonst Geschosshöhe. */ + height?: number; +} + +/** + * Modul-Regel: teilt eine Referenzspanne in proportionale Felder auf. + * Typischer Anwendungsfall: Tragwerks-Joche (z. B. 6 m-Module in einem Bauteil). + * + * Ablauf (Engine): + * 1. Gesamtspanne aus `referenceWallId` oder Geschossausdehnung ableiten. + * 2. In Module der Größe `moduleSize` unterteilen. + * 3. Querwände an jedem Modulteilungspunkt setzen. + */ +export interface ModuleRule { + type: "module"; + /** Modulmaß in Metern (z. B. 6.0, 3.6). */ + moduleSize: number; + /** Ausrichtung der Trennwände: „x" = quer zur X-Achse, „y" = quer zur Y-Achse. */ + direction: "x" | "y"; + /** + * Optionaler Verweis auf eine Referenzwand, die die Spannweite definiert. + * Fehlt er, wird die Geschoss-Ausdehnung genutzt (Phase 3: Achsen-Referenz). + */ + referenceWallId?: string; + /** Optionale Wandtyp-Übersteuerung; sonst `defaultWallTypeId`. */ + wallTypeId?: string; + /** Lage der Wandachse über die Dicke. */ + referenceLine?: WallReferenceLine; + /** Optionale Höhenübersteuerung in Metern. */ + height?: number; +} + +/** + * Bedingte-Dicken-Regel: weist bereits generierten Wänden einen anderen + * Wandtyp zu, wenn eine Bedingung erfüllt ist. + * Typischer Anwendungsfall: Außenwände erhalten einen anderen Aufbau als Innenwände. + * + * Ablauf (Engine): + * Bestehende Wände aus `existingWalls` filtern → `wallTypeId` ändern. + * Gibt modifizierte Kopien zurück (keine Mutation). + */ +export interface ConditionalThicknessRule { + type: "conditional-thickness"; + /** + * Bedingung für den Treffer: + * • „exterior" — Wand liegt am Außenrand (ermittelt via Grenzpolygon). + * • „interior" — Wand liegt im Inneren. + * • „bearing" — tragende Wand (über Tag oder Wandtyp-Rang). + * • beliebiger String — benutzerdefiniertes Tag (Phase 3: Wall.tags[]). + */ + condition: "exterior" | "interior" | "bearing" | string; + /** Ziel-Wandtyp, der bei Treffer gesetzt wird. */ + wallTypeId: string; + /** + * Verknüpfungslogik für mehrere Bedingungen (Phase 3: mehrere `condition`-Felder). + * Vorerst ungenutzt; Default ist implizites „or". + */ + logic?: "and" | "or"; +} + +/** + * Referenzlinien-Regel: setzt die `referenceLine`-Eigenschaft bei passenden + * Wänden einheitlich (Vectorworks-Stil). + * Typischer Anwendungsfall: Alle Außenwände auf „left" (linke Fläche = Fassade). + * + * Ablauf (Engine): + * Bestehende Wände aus `existingWalls` filtern → `referenceLine` setzen. + * Gibt modifizierte Kopien zurück. + */ +export interface ReferenceLineRule { + type: "reference-line"; + /** Neue Lage der Wandachse, die einheitlich gesetzt wird. */ + referenceLine: WallReferenceLine; + /** + * Filterziel: + * • „all" — alle Wände im aktuellen Satz. + * • „exterior" — nur Außenwände (wie bei ConditionalThicknessRule). + * • beliebiger String — benutzerdefiniertes Tag (Phase 3). + */ + target: "all" | "exterior" | string; +} + +/** + * Sequenz-Regel: fasst mehrere Unterregeln zusammen und wendet sie geordnet an. + * Jede Unterregel kann die Ausgabe der vorherigen verfeinern. + * Typischer Anwendungsfall: Raster → bedingte Dicke → Referenzlinie als atomare Einheit. + * + * Ablauf (Engine): + * Regeln in `rules` werden sequenziell ausgeführt; das Ergebnis jeder Regel wird + * als `existingWalls` der nächsten übergeben. `stopOnMatch` bricht ab, sobald + * eine Unterregel mindestens eine Wand generiert hat. + */ +export interface SequenceRule { + type: "sequence"; + /** Unterregeln, in Ausführungsreihenfolge. */ + rules: ParametricRule[]; + /** + * Wenn `true`: Abbruch nach der ersten Unterregel, die mindestens eine Wand + * generiert/verändert. Ähnlich wie ein Short-Circuit-Fallback. + */ + stopOnMatch?: boolean; +} + /** * Art einer Zeichnungsebene. * • "floor" — Geschoss, trägt Bauteile. @@ -206,9 +423,220 @@ export interface Wall { top?: VerticalAnchor; } +/** + * Eine Decke (Slab) — ein geschossgebundenes, mehrschichtiges Flächenbauteil, + * definiert über einen GESCHLOSSENEN Umriss (Polygon) im Grundriss und eine + * Dicke. Analog zur Wand trägt sie ihren Schichtaufbau über einen `wallTypeId` + * (die Bauteil-/Schraffur-/Materialauflösung ist identisch); eine optionale + * `thickness` übersteuert die Gesamtdicke des Typs (Meter). + * + * Vertikale Lage: die OBERKANTE (OK) der Decke. Fehlt `top`, liegt die OK an der + * Oberkante des Geschosses (baseElevation + floorHeight — also bündig mit dem + * Wandkopf); die Decke wächst um `thickness` nach UNTEN. `top: custom` setzt eine + * absolute Z-Höhe, `top: floor` bindet die OK an ein Geschoss. + */ +export interface Ceiling { + id: string; + type: "ceiling"; + /** Zugehörige Zeichnungsebene (Geschoss). */ + floorId: string; + /** Grafik-Kategorie (Ebene), z. B. "30" für Decken. */ + categoryCode: string; + /** + * Geschlossener Umriss im Grundriss (Meter). Der Schlusspunkt wird NICHT + * dupliziert (die letzte Kante läuft von outline[n-1] zu outline[0]). + */ + outline: Vec2[]; + /** Verweis auf den (mehrschichtigen) Aufbau-Typ (wie WallType). */ + wallTypeId: string; + /** Optionale Übersteuerung der Gesamtdicke in Metern (sonst Typ-Dicke). */ + thickness?: number; + /** + * Optionale Übersteuerung der Strich-/Umrandungsfarbe; sonst gilt die + * Kategorie-Farbe. + */ + color?: string; + /** + * Vertikale Bindung der OBERKANTE (OK). Fehlt sie, sitzt die OK an der + * Oberkante des Geschosses (baseElevation + floorHeight). + */ + top?: VerticalAnchor; +} + /** Schwenkrichtung der Tür relativ zur Wandachse. */ export type SwingSide = "left" | "right"; +/** + * Eine Öffnung (Fenster oder Tür), gehostet in einer Wand. Sie „kennt" ihre Wand + * (`hostWallId`) und liegt über `position` (Abstand vom Wand-Startpunkt entlang + * der Achse) relativ zur Wand — bewegt sich die Wand, folgt die Öffnung, weil die + * Weltkoordinaten beim Rendern IMMER aus der Wandachse abgeleitet werden. + * + * Vertikale Lage (relativ zur Wand-Unterkante, UK): + * • Tür — sitzt am Boden, `sillHeight` = 0, Höhe = lichte Türhöhe. + * • Fenster— Brüstung `sillHeight` > 0, Öffnung reicht von sillHeight bis + * sillHeight + height. + * + * Türspezifisch: `hinge` (Anschlagpfosten) + `swing` (Aufschlagseite) + optional + * `swingAngle` (Öffnungswinkel des Blatts in Grad, Default 90) + `openingDir` + * (Aufschlag nach innen/außen — kippt den Schwenkbogen auf die andere Achsseite). + * Fenster nutzen optional `frameDepth`/`frameThickness` für die 3D-Rahmenstärke. + */ +export interface Opening { + id: string; + type: "opening"; + /** Wirts-Wand; das Geschoss ergibt sich aus der Wand. */ + hostWallId: string; + /** Grafik-Kategorie (Ebene), z. B. "21" für Türen/Fenster. */ + categoryCode: string; + /** Fenster oder Tür. */ + kind: "window" | "door"; + /** Abstand des ersten Pfostens vom Wand-Startpunkt entlang der Achse (Meter). */ + position: number; + /** Lichte Öffnungsbreite in Metern. */ + width: number; + /** Lichte Öffnungshöhe in Metern. */ + height: number; + /** Brüstungshöhe (Unterkante der Öffnung) über der Wand-UK; 0 bei Türen. */ + sillHeight: number; + /** Nur Tür: an welchem Pfosten das Scharnier sitzt. */ + hinge?: "start" | "end"; + /** Nur Tür: auf welche Seite der Wandachse das Blatt aufschlägt. */ + swing?: SwingSide; + /** Nur Tür: Öffnungswinkel des Blatts in Grad (Default 90). */ + swingAngle?: number; + /** Nur Tür: Aufschlagrichtung (nach innen/außen); Default "in". */ + openingDir?: "in" | "out"; + /** Optionale Rahmenstärke (quer zur Wand) in Metern für die 3D-Darstellung. */ + frameThickness?: number; + /** + * Optionale Übersteuerung der Strich-/Symbolfarbe; sonst gilt die + * Kategorie-Farbe. + */ + color?: string; +} + +/** + * Grundform einer Treppe (DOSSIER: gerade / L / Wendel). + * • "straight" — ein gerader Lauf (Lauflinie = Start → Richtung). + * • "L" — zwei rechtwinklige Läufe mit Zwischenpodest an der Ecke. + * • "spiral" — Wendeltreppe um ein Zentrum (keilförmige Tritte). + */ +export type StairShape = "straight" | "L" | "spiral"; + +/** + * Eine Treppe (Treppe/Stair) — ein geschossübergreifendes Bauteil, das über die + * Geschosshöhe (OKFF → OKFF des nächsten Geschosses) steigt. Definiert über eine + * Grundform (gerade / L / Wendel), eine Basis-Geometrie, die Laufbreite und die + * Stufung (Steigungshöhe/Auftrittstiefe, aus der Stufenanzahl abgeleitet). + * + * Basis-Geometrie je Grundform: + * • straight — `start` + `dir` (Einheitsrichtung) + `runLength` (Lauflänge). + * • L — `start` + `dir` (erster Lauf) + `runLength` (erster Lauf) + + * `run2Length` (zweiter Lauf) + `turn` (+1 = links, −1 = rechts abbiegen). + * Das Zwischenpodest sitzt am Ende des ersten Laufs (quadratisch, `width`). + * • spiral — `center` + `radius` (Innenradius zur Lauflinie) + `sweep` + * (Gesamtwinkel in Grad, +/− = Drehrichtung) + `start` (Startpunkt des ersten + * Tritts am äußeren Rand, definiert die Anfangsrichtung). + * + * Vertikale Lage: die UNTERKANTE (`baseZ`, abgeleitet aus dem Geschoss-OKFF) plus + * `totalRise` (Default = Geschosshöhe des zugehörigen Geschosses, sodass die + * Treppe genau ins nächste Geschoss steigt). `stepCount` Tritte/Setzstufen + * verteilen `totalRise` gleichmäßig; die Steigungshöhe = totalRise/stepCount, die + * Auftrittstiefe ergibt sich aus Lauflänge/(stepCount−1). Eine SIA-nahe + * Schrittregel (2·Steigung + Auftritt ≈ 0.63 m) liefert die Default-Stufenzahl. + */ +export interface Stair { + id: string; + type: "stair"; + /** Zugehörige Zeichnungsebene (Geschoss), von dem die Treppe aufsteigt. */ + floorId: string; + /** Grafik-Kategorie (Ebene), z. B. "40" für Treppen. */ + categoryCode: string; + /** Grundform (gerade / L / Wendel). */ + shape: StairShape; + /** Startpunkt der Lauflinie im Grundriss (Meter). */ + start: Vec2; + /** Einheits-Laufrichtung des (ersten) Laufs im Grundriss. */ + dir: Vec2; + /** Lauflänge des (ersten) Laufs in Metern (entlang `dir`). */ + runLength: number; + /** Nur L: Lauflänge des zweiten Laufs in Metern. */ + run2Length?: number; + /** Nur L: Abbiegerichtung des zweiten Laufs (+1 = links, −1 = rechts). */ + turn?: 1 | -1; + /** Nur Wendel: Zentrum der Wendeltreppe (Meter). */ + center?: Vec2; + /** Nur Wendel: Radius (Meter) von der Mitte zur Lauflinie. */ + radius?: number; + /** Nur Wendel: Gesamt-Drehwinkel in Grad (+ = CCW, − = CW). */ + sweep?: number; + /** Laufbreite in Metern (quer zur Laufrichtung). */ + width: number; + /** + * Gesamt-Steighöhe in Metern (OKFF → OKFF nächstes Geschoss). Fehlt sie, gilt + * beim Auflösen die Geschosshöhe des zugehörigen Geschosses. + */ + totalRise?: number; + /** Anzahl der Steigungen (Setzstufen). Die Trittanzahl = stepCount (letzte = + * Austritt aufs obere Geschoss). Steigungshöhe = totalRise / stepCount. */ + stepCount: number; + /** + * Laufrichtung „aufwärts": true = die Lauflinie steigt von `start` in Richtung + * `dir` (Default). false kehrt Auf-/Abpfeil um (Treppe steigt zum Start hin). + */ + up?: boolean; + /** + * Optionale Übersteuerung der Strich-/Umrandungsfarbe; sonst gilt die + * Kategorie-Farbe. + */ + color?: string; +} + +/** + * Ein Raum (Raum/Room) — eine SIA-416-Fläche auf einem Geschoss, definiert über + * einen GESCHLOSSENEN Umriss (Polygon, lichte Innenkontur) im Grundriss. Trägt + * eine SIA-416-Blatt-Kategorie (HNF/NNF/VF/FF/KGF), einen Namen und eine Farbe. + * + * Fläche/Umfang/Schwerpunkt werden NICHT gespeichert, sondern bei jedem Rendern + * aus `boundary` über den reinen Rechenkern (geometry/roomArea) abgeleitet — + * so sind sie nie veraltet. `stampAnchor` (optional) setzt den Ankerpunkt des + * Raum-Stempels; fehlt er, gilt der Flächenschwerpunkt (Centroid). + */ +export interface Room { + id: string; + type: "room"; + /** Zugehörige Zeichnungsebene (Geschoss). */ + floorId: string; + /** Grafik-Kategorie (Ebene), z. B. "45" für Räume. */ + categoryCode: string; + /** SIA-416-Blatt-Kategorie (HNF/NNF/VF/FF/KGF). */ + siaCategory: SiaCategory; + /** Raum-Name, z. B. „Wohnen". */ + name: string; + /** + * Geschlossener Umriss (lichte Innenkontur) im Grundriss (Meter). Der + * Schlusspunkt wird NICHT dupliziert (die letzte Kante läuft von boundary[n-1] + * zu boundary[0]). + */ + boundary: Vec2[]; + /** Strich-/Füllfarbe des Raums (hex). */ + color: string; + /** + * Anker des Raum-Stempels (Meter). Wird bei der Erstellung EINMAL auf den + * Zentroid gesetzt und danach nie automatisch neu berechnet — der Stempel ist + * frei verschiebbar und bleibt beim Ändern der Kontur/Fläche an seiner Stelle. + * Fehlt er (Alt-Daten), gilt beim Rendern der Centroid. + */ + stampAnchor?: Vec2; + /** + * Frei editierbarer Rich-Text des Raum-Stempels (Name + Notizen, mit Fett/ + * Kursiv/Grösse/Farbe/Ausrichtung). Fehlt er, gilt der Raum-Name als einfacher + * Text. Die LIVE-Flächenzeile wird beim Rendern separat darunter gesetzt. + */ + stampDoc?: RichTextDoc; +} + /** Eine Tür, gehostet in einer Wand. Ihr Geschoss ergibt sich aus der Wand. */ export interface Door { id: string; @@ -282,9 +710,17 @@ export interface EdgeGrip { normal: Vec2; aIndex: number; bIndex: number; + /** + * Freie Kanten-Verschiebung: gesetzt bei OFFENER Geometrie (Linie/offene + * Polylinie). Das Segment folgt dem vollen Cursor-Delta (nicht nur der Normale), + * beide Endpunkte wandern mit, die Nachbar-Segmente dehnen sich nach. Bei + * geschlossenen Formen/Wänden fehlt das Flag → senkrechte (parallel-)Verschiebung + * entlang der Außennormale wie bisher. + */ + free?: boolean; } -export type Element = Wall | Door | Drawing2D; +export type Element = Wall | Ceiling | Opening | Door | Stair | Room | Drawing2D; // ── Kontext-Geometrie (importiert / abgeleitet, NICHT semantisch) ─────────── // Importierte Geometrie ist „dumme" KONTEXT-Geometrie (Anzeige + späteres @@ -362,7 +798,29 @@ export interface Project { /** Grafik-Kategorie-Baum (geschossübergreifend). */ layers: LayerCategory[]; walls: Wall[]; + /** + * Decken (Slabs) — geschossgebundene Flächenbauteile mit geschlossenem Umriss. + * Optional, damit bestehende Projekte/Tests ohne `ceilings` gültig bleiben + * (Default: leer behandeln). + */ + ceilings?: Ceiling[]; doors: Door[]; + /** + * Öffnungen (Fenster/Türen), gehostet in Wänden. Optional, damit bestehende + * Projekte/Tests ohne `openings` gültig bleiben (Default: leer behandeln). + */ + openings?: Opening[]; + /** + * Treppen (Treppe) — geschossübergreifende Bauteile (gerade/L/Wendel). Optional, + * damit bestehende Projekte/Tests ohne `stairs` gültig bleiben (Default: leer). + */ + stairs?: Stair[]; + /** + * Räume (SIA-416-Flächen) — geschossgebundene Flächen mit geschlossenem Umriss. + * Optional, damit bestehende Projekte/Tests ohne `rooms` gültig bleiben + * (Default: leer behandeln). + */ + rooms?: Room[]; /** Freie 2D-Zeichengeometrie (Line/Polyline/Rect/Circle/Arc/Text). */ drawings2d: Drawing2D[]; /** @@ -371,6 +829,13 @@ export interface Project { * Projekte/Tests ohne `context` gültig bleiben (Default: leer behandeln). */ context?: ContextObject[]; + /** + * Bibliothek parametrischer Wand-Regelwerke. Optional, damit bestehende + * Projekte ohne `parametricWalls` gültig bleiben (Default: leer behandeln). + * Wird durch `resolveParametricWall()` in `src/model/parametricWalls.ts` + * aufgelöst — generiert Wall[]-Objekte bei Bedarf. + */ + parametricWalls?: ParametricWall[]; } // ── Helfer ─────────────────────────────────────────────────────────────── @@ -381,6 +846,48 @@ export const getWallType = (project: Project, wall: Wall): WallType => { return wt; }; +/** Liefert den Aufbau-Typ (WallType) einer Decke oder wirft. */ +export const getCeilingType = (project: Project, ceiling: Ceiling): WallType => { + const wt = project.wallTypes.find((t) => t.id === ceiling.wallTypeId); + if (!wt) throw new Error(`Unbekannter Deckentyp: ${ceiling.wallTypeId}`); + return wt; +}; + +/** + * Gesamtdicke einer Decke (Meter): eine explizite `thickness`-Übersteuerung hat + * Vorrang, sonst die Summe der Schichtdicken ihres Aufbau-Typs. + */ +export const ceilingThickness = (project: Project, ceiling: Ceiling): number => { + if (ceiling.thickness != null && ceiling.thickness > 0) return ceiling.thickness; + const wt = project.wallTypes.find((t) => t.id === ceiling.wallTypeId); + return wt ? wallTypeThickness(wt) : 0.2; +}; + +/** Alle Öffnungen einer Wand (leere Liste, wenn keine oder `openings` fehlt). */ +export const openingsOfWall = (project: Project, wallId: string): Opening[] => + (project.openings ?? []).filter((o) => o.hostWallId === wallId); + +/** Menschenlesbarer Standardname einer Öffnung (Fenster/Tür + Breite×Höhe). */ +export const openingLabel = (o: Opening): string => + `${o.kind === "door" ? "Tür" : "Fenster"} ${(o.width * 100).toFixed(0)}×${( + o.height * 100 + ).toFixed(0)}`; + +/** Alle Treppen eines Geschosses (leere Liste, wenn keine oder `stairs` fehlt). */ +export const stairsOfFloor = (project: Project, floorId: string): Stair[] => + (project.stairs ?? []).filter((s) => s.floorId === floorId); + +/** Menschenlesbarer Standardname einer Treppe (Grundform + Stufenanzahl). */ +export const stairLabel = (s: Stair): string => { + const shape = + s.shape === "straight" ? "Gerade" : s.shape === "L" ? "L-Treppe" : "Wendel"; + return `${shape} ${s.stepCount} STG`; +}; + +/** Alle Räume eines Geschosses (leere Liste, wenn keine oder `rooms` fehlt). */ +export const roomsOfFloor = (project: Project, floorId: string): Room[] => + (project.rooms ?? []).filter((r) => r.floorId === floorId); + /** Liefert ein Bauteil-Material (Component) per ID oder wirft. */ export const getComponent = (project: Project, id: string): Component => { const c = project.components.find((co) => co.id === id);