/** Scene-Dressing-Engine (rein visuelle Deko-Ebene, getrennt vom Plan). * * Konzept: FP_Kopf/vault/50_Umsetzung/Scene-Dressing-Konzept.md * * `sceneDressing(room, plan, catalog, styleProfile, seed)` leitet aus dem * bestehenden Plan **Anker** ab (Oberflächen von Möbeln, freie Ecken, Wände) * und platziert kleine Deko-Objekte daran – **deterministisch** (Seed-RNG), * **stilabhängig** (Varianten-/Materialwahl per stilNaehe) und **ohne den Plan * zu verändern**. Der Plan wird ausschliesslich GELESEN. Ergebnis ist eine * eigene Struktur (`DressingPlatzierung[]`), die die 3D-Ebene rendert – sie * fliesst NIE in placements/constraintReport/LV zurück (Solver-Invariante 0 ❌ * bleibt unberührt). */ import { footprint, overlapDepth, pointInPolygon, type Quad, type Vec2 } from "@fp/shared/rules"; import type { DressingItem, KatalogItem, Placement, Plan, Room } from "./api"; import { stilNaehe } from "./stilnaehe"; import { ablageHoehe } from "./moebelProportionen"; import { RAUMHOEHE_FALLBACK } from "./viewer3d-logik"; /** Stil-Variante eines Deko-Objekts (steuert Material/Farbe, nicht die Geometrie). */ export type DressingVariante = "warm" | "kuehl"; /** Ergebnis der Engine: eine visuelle Deko-Platzierung in Weltkoordinaten * (y-up, Meter). `pos` ist die Objekt-**Mitte** (wie bei Moebel3D, Gruppe). */ export interface DressingPlatzierung { /** Deterministische, stabile ID (dressingItemId + Anker + Index). */ id: string; dressingItemId: string; /** Bausatz-Schlüssel für dressing3d (= funktionsTyp des Deko-Objekts). */ funktionsTyp: string; modell3d?: string; /** Welt-Mitte [x, y, z] (y = Höhe der Objektmitte). */ pos: [number, number, number]; yawDeg: number; masse: { w: number; d: number; h: number }; /** Aufgelöste Basisfarbe (stilabhängig). */ farbe: string; variante: DressingVariante; klasse: DressingItem["klasse"]; assetStatus: "placeholder" | "modeled"; gltfRef?: string; } // ── Seed-RNG (mulberry32) – deterministisch, NICHT Math.random ──────────────── /** Kleiner deterministischer 32-bit-RNG. Gleicher Seed ⇒ gleiche Sequenz. */ function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // ── Farb-Paletten (warm/kühl) je Deko-funktionsTyp ──────────────────────────── const DRESSING_FARBE: Record = { seifenspender: { warm: "#D8C3A5", kuehl: "#C9CDD2" }, zahnputzbecher: { warm: "#E3D5C0", kuehl: "#BFD3D9" }, handtuchstapel: { warm: "#E8DCC8", kuehl: "#CBD6DD" }, ablagetray: { warm: "#B9906B", kuehl: "#8C8F94" }, badpflanze: { warm: "#6F8F6A", kuehl: "#7C948C" }, // Wohnen-Deko buecherstapel: { warm: "#9C6B4A", kuehl: "#6E7A82" }, vase: { warm: "#D9C7B0", kuehl: "#C2CBD0" }, kerzenstaender: { warm: "#B8975B", kuehl: "#A9AEB2" }, dekoschale: { warm: "#C2A579", kuehl: "#9BA1A6" }, plaid: { warm: "#D8C4A0", kuehl: "#B7C2C8" }, zimmerpflanze: { warm: "#5F8457", kuehl: "#6E877D" }, // Küchen-Deko schneidebrett: { warm: "#B08A5E", kuehl: "#9A9488" }, kaffeemaschine: { warm: "#8F8478", kuehl: "#7E858B" }, obstschale: { warm: "#D9C4A6", kuehl: "#C2C8CC" }, gewuerzglaeser: { warm: "#CBB79A", kuehl: "#BCC6CC" }, kraeutertopf: { warm: "#5E8A54", kuehl: "#6E877D" }, geschirrtuch: { warm: "#E0D2B8", kuehl: "#C6CFD3" }, // Licht (Schirmfarbe der Fassung; der Diffusor selbst leuchtet fix warmweiss, // siehe DIFFUSOR_FARBE in dressing3d.tsx) pendelleuchte: { warm: "#C9A574", kuehl: "#8C9096" }, tischleuchte: { warm: "#D8C7A8", kuehl: "#B9C0C4" }, }; const DRESSING_FARBE_FALLBACK = { warm: "#C9A38A", kuehl: "#AEB4B8" }; /** Aus Deko-Achsen + Stilprofil eine Variante (warm/kühl) ableiten. Ohne Profil * oder ohne gemeinsame Achse gilt die neutrale Warm-Variante. */ function waehleVariante(item: DressingItem, styleProfile: StilprofilLike | null): DressingVariante { const naehe = styleProfile ? stilNaehe(styleProfile.styleVector, item.achsenTags) : null; return (naehe ?? 0.5) >= 0.5 ? "warm" : "kuehl"; } function farbeFuer(funktionsTyp: string, variante: DressingVariante): string { return (DRESSING_FARBE[funktionsTyp] ?? DRESSING_FARBE_FALLBACK)[variante]; } /** Nur der vom Engine gelesene Teil eines Stilprofils. */ interface StilprofilLike { styleVector: Record; } // ── Geometrie-Helfer ────────────────────────────────────────────────────────── type Masse = { w: number; d: number; h: number }; /** Rotationsrichtungen eines Objekts: Front = lokal +z, rechts = Front um −90°. */ function achsen(yawDeg: number): { front: Vec2; rechts: Vec2 } { const r = (yawDeg * Math.PI) / 180; // frontDir wie im Regel-Interpreter: rotateY([0,1], yaw) = (sin, cos). const front: Vec2 = [Math.sin(r), Math.cos(r)]; const rechts: Vec2 = [front[1], -front[0]]; return { front, rechts }; } /** Einheits-Innennormale einer Wand (zeigt ins Rauminnere). */ function innenNormale(a: Vec2, b: Vec2, floor: Vec2[]): Vec2 { const dx = b[0] - a[0]; const dz = b[1] - a[1]; const len = Math.hypot(dx, dz) || 1; const ux = dx / len; const uz = dz / len; const mid: Vec2 = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; for (const s of [1, -1]) { const n: Vec2 = [-uz * s, ux * s]; if (pointInPolygon([mid[0] + n[0] * 0.1, mid[1] + n[1] * 0.1], floor)) return n; } return [-uz, ux]; } /** Ein aus dem Plan abgeleiteter Anker (Möbel-Oberfläche/Standfläche). */ interface MoebelAnker { placementId: string; funktionsTyp: string; center: Vec2; masse: Masse; yawDeg: number; mountHeight: number; /** Oberkante des Möbels (Weltnhöhe) = mountHeight + Höhe. */ topY: number; fp: Quad; } /** Freie Raumecke als Anker (Boden). */ interface EckAnker { index: number; ecke: Vec2; /** Einheitsrichtung ins Rauminnere (Winkelhalbierende). */ innen: Vec2; } /** Wandanker (für an_wand). */ interface WandAnker { wallId: string; center: Vec2; innen: Vec2; laenge: number; yawDeg: number; } /** Deckenhöhe des Raums (Vertrag: Raummodell-Schema, `shell.walls[].height`). * Gleiche Konvention wie im 3D-Viewer (`Viewer3D.tsx`, `raumHoehe`): erste * Wand ist massgebend (Räume sind hier durchgehend gleich hoch); ohne * verwertbare Wandhöhe gilt der Standard-Fallback (2.4 m). */ function raumhoehe(room: Room): number { return (room.shell.walls[0] as { height?: number } | undefined)?.height ?? RAUMHOEHE_FALLBACK; } function katalogKarte(catalog: KatalogItem[]): Map { return new Map(catalog.map((c) => [c.id, c])); } /** Alle Möbel-Anker aus dem Plan (per Katalog aufgelöst). */ function moebelAnker(placements: Placement[], byId: Map): MoebelAnker[] { const anker: MoebelAnker[] = []; for (const p of placements) { const item = byId.get(p.catalogItemId); if (!item) continue; const center: Vec2 = [p.pose.pos[0], p.pose.pos[1]]; const mountHeight = p.mountHeight ?? 0; anker.push({ placementId: p.id, funktionsTyp: item.funktionsTyp, center, masse: item.masse, yawDeg: p.pose.yawDeg, mountHeight, // Ablagefläche, NICHT bbox-Oberkante: bei einem Sofa ist oben die // Rückenlehne – ein Plaid landete dadurch frei schwebend über der // Sitzfläche. `ablageHoehe` kennt die Sitz-/Liegehöhe je Möbeltyp. topY: mountHeight + ablageHoehe(item.funktionsTyp, item.masse.h), fp: footprint(center, item.masse.w, item.masse.d, p.pose.yawDeg), }); } return anker; } /** Footprints aller Möbel im Plan (Hindernisse für Klasse-B-Kollision). */ function planFootprints(placements: Placement[], byId: Map): Quad[] { const quads: Quad[] = []; for (const p of placements) { const item = byId.get(p.catalogItemId); if (!item) continue; quads.push( footprint([p.pose.pos[0], p.pose.pos[1]], item.masse.w, item.masse.d, p.pose.yawDeg), ); } return quads; } /** Tür-Zugangszonen (Rechtecke vor Türen) als zusätzliche Hindernisse. */ function tuerZonen(room: Room): Quad[] { const zonen: Quad[] = []; const floor = room.shell.floor.polygon as Vec2[]; const wallById = new Map(room.shell.walls.map((w) => [w.id, w])); for (const o of room.openings) { if (o.type !== "door") continue; const wall = wallById.get(o.hostWall); if (!wall) continue; const a = wall.start as Vec2; const b = wall.end as Vec2; const dx = b[0] - a[0]; const dz = b[1] - a[1]; const len = Math.hypot(dx, dz) || 1; const u: Vec2 = [dx / len, dz / len]; const n = innenNormale(a, b, floor); const mitte = o.offset + o.width / 2; const zentrum: Vec2 = [a[0] + u[0] * mitte, a[1] + u[1] * mitte]; const halbBreite = o.width / 2 + 0.2; const tiefe = 0.6; // Rechteck an der Wand, tiefe nach innen (4 Ecken). const c1: Vec2 = [zentrum[0] - u[0] * halbBreite, zentrum[1] - u[1] * halbBreite]; const c2: Vec2 = [zentrum[0] + u[0] * halbBreite, zentrum[1] + u[1] * halbBreite]; const c3: Vec2 = [c2[0] + n[0] * tiefe, c2[1] + n[1] * tiefe]; const c4: Vec2 = [c1[0] + n[0] * tiefe, c1[1] + n[1] * tiefe]; zonen.push([c1, c2, c3, c4]); } return zonen; } /** Freie Ecken des Raumpolygons (Winkelhalbierende zeigt nach innen). */ function eckAnker(room: Room): EckAnker[] { const poly = room.shell.floor.polygon as Vec2[]; const n = poly.length; const anker: EckAnker[] = []; for (let i = 0; i < n; i++) { const curr = poly[i] as Vec2; const prev = poly[(i - 1 + n) % n] as Vec2; const next = poly[(i + 1) % n] as Vec2; const toPrev = norm([prev[0] - curr[0], prev[1] - curr[1]]); const toNext = norm([next[0] - curr[0], next[1] - curr[1]]); let innen = norm([toPrev[0] + toNext[0], toPrev[1] + toNext[1]]); // Degenerierte (kollineare) Ecke: Normale einer Kante nach innen nehmen. if (innen[0] === 0 && innen[1] === 0) innen = innenNormale(prev, curr, poly); anker.push({ index: i, ecke: curr, innen }); } return anker; } /** Wandanker (Wandmitte, Innennormale) für an_wand-Deko. */ function wandAnker(room: Room): WandAnker[] { const floor = room.shell.floor.polygon as Vec2[]; return room.shell.walls.map((w) => { const a = w.start as Vec2; const b = w.end as Vec2; const center: Vec2 = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; const innen = innenNormale(a, b, floor); const laenge = Math.hypot(b[0] - a[0], b[1] - a[1]); // Yaw so, dass Front (+z) = Innennormale: yaw = atan2(front.x, front.z). const yawDeg = (Math.atan2(innen[0], innen[1]) * 180) / Math.PI; return { wallId: w.id, center, innen, laenge, yawDeg }; }); } function norm(v: Vec2): Vec2 { const l = Math.hypot(v[0], v[1]); return l < 1e-9 ? [0, 0] : [v[0] / l, v[1] / l]; } /** Kollidiert das Deko-Footprint mit einem der Hindernisse (Überlappung)? */ function kollidiert(fp: Quad, hindernisse: Quad[]): boolean { return hindernisse.some((h) => overlapDepth(fp, h) !== null); } /** Liegt das ganze Footprint im Raumpolygon? */ function imRaum(fp: Quad, floor: Vec2[]): boolean { return fp.every((ecke) => pointInPolygon(ecke, floor)); } /** * Scene-Dressing für einen Raum. Deterministisch aus (room, plan, catalog, * styleProfile, seed). Verändert den übergebenen Plan NICHT (nur lesend). * * @param seed Wird aus `plan.meta.seed` gespeist (vom Aufrufer), damit das * Dressing an denselben Plan-Seed gekoppelt ist – gleiche Deko je Plan. */ export function sceneDressing( room: Room, plan: Plan, catalog: KatalogItem[], styleProfile: StilprofilLike | null, dressingItems: DressingItem[], seed: number, ): DressingPlatzierung[] { const rng = mulberry32(seed); const byId = katalogKarte(catalog); const anker = moebelAnker(plan.placements, byId); const floor = room.shell.floor.polygon as Vec2[]; const hindernisse = [...planFootprints(plan.placements, byId), ...tuerZonen(room)]; const ecken = eckAnker(room); const waende = wandAnker(room); const hoehe = raumhoehe(room); // Wie viele Deko-Objekte schon auf einem Möbel-Anker sitzen (zum Verteilen). const belegung = new Map(); // Bereits belegte Ecken (jede Ecke max. 1 Bodenobjekt). const eckenBelegt = new Set(); const ergebnis: DressingPlatzierung[] = []; for (const item of dressingItems) { if (!item.roomTypes.includes(room.roomType)) continue; const variante = waehleVariante(item, styleProfile); const farbe = farbeFuer(item.funktionsTyp, variante); const platz = item.platzierung === "freie_ecke" ? platziereEcke(item, ecken, eckenBelegt, hindernisse, floor, rng) : item.platzierung === "an_wand" ? platziereWand(item, waende, rng) : item.platzierung === "an_decke" ? platziereDecke(item, anker, hoehe, rng) : platziereMoebel(item, anker, belegung, floor, rng); if (!platz) continue; // Kein passender Anker → weglassen (nie erzwingen). ergebnis.push({ id: `${item.id}:${platz.ankerRef}`, dressingItemId: item.id, funktionsTyp: item.funktionsTyp, modell3d: item.modell3d, pos: platz.pos, yawDeg: platz.yawDeg, masse: item.masse, farbe, variante, klasse: item.klasse, assetStatus: item.assetStatus, gltfRef: item.gltfRef, }); } return ergebnis; } interface Platzierungsergebnis { pos: [number, number, number]; yawDeg: number; ankerRef: string; } /** Deko auf/an einem Möbel (auf_oberflaeche = auf Oberkante, an_moebel = daneben). */ function platziereMoebel( item: DressingItem, anker: MoebelAnker[], belegung: Map, floor: Vec2[], rng: () => number, ): Platzierungsergebnis | null { const kandidaten = anker.filter((a) => item.anchorTypes.includes(a.funktionsTyp)); if (kandidaten.length === 0) return null; const a = kandidaten[Math.floor(rng() * kandidaten.length)] as MoebelAnker; const { front, rechts } = achsen(a.yawDeg); if (item.platzierung === "an_moebel") { // Bodenobjekt seitlich neben dem Möbel; Seite/kleine Streuung aus RNG. const seite = rng() < 0.5 ? 1 : -1; const gap = 0.04 + rng() * 0.04; const dist = a.masse.w / 2 + item.masse.w / 2 + gap; const pos: Vec2 = [ a.center[0] + rechts[0] * dist * seite, a.center[1] + rechts[1] * dist * seite, ]; // Muss im Raum liegen; sonst andere Seite versuchen. const fp = footprint(pos, item.masse.w, item.masse.d, a.yawDeg); if (!imRaum(fp, floor)) { const pos2: Vec2 = [ a.center[0] - rechts[0] * dist * seite, a.center[1] - rechts[1] * dist * seite, ]; if (!imRaum(footprint(pos2, item.masse.w, item.masse.d, a.yawDeg), floor)) return null; return { pos: [pos2[0], item.masse.h / 2, pos2[1]], yawDeg: a.yawDeg, ankerRef: a.placementId, }; } return { pos: [pos[0], item.masse.h / 2, pos[1]], yawDeg: a.yawDeg, ankerRef: a.placementId }; } // auf_oberflaeche: auf die Oberkante, seitlich verteilt (mehrere Objekte je Möbel). const idx = belegung.get(a.placementId) ?? 0; belegung.set(a.placementId, idx + 1); const spielraumSeit = Math.max(0, a.masse.w / 2 - item.masse.w / 2 - 0.02); const spielraumTief = Math.max(0, a.masse.d / 2 - item.masse.d / 2 - 0.02); // Verteilung: 1. Objekt Mitte, danach abwechselnd nach rechts/links gestaffelt. const stufe = Math.ceil(idx / 2) * 0.12 * (idx % 2 === 0 ? 1 : -1); const seit = Math.max(-spielraumSeit, Math.min(spielraumSeit, stufe + (rng() - 0.5) * 0.03)); const tief = (rng() - 0.5) * 2 * spielraumTief * 0.4; const pos: Vec2 = [ a.center[0] + rechts[0] * seit + front[0] * tief, a.center[1] + rechts[1] * seit + front[1] * tief, ]; const yawDeg = a.yawDeg + (rng() - 0.5) * 20; return { pos: [pos[0], a.topY + item.masse.h / 2, pos[1]], yawDeg, ankerRef: `${a.placementId}#${idx}`, }; } /** Deko in einer freien Raumecke (Boden, Klasse B → Kollisionsprüfung). */ function platziereEcke( item: DressingItem, ecken: EckAnker[], eckenBelegt: Set, hindernisse: Quad[], floor: Vec2[], rng: () => number, ): Platzierungsergebnis | null { if (!item.anchorTypes.includes("ecke")) return null; // Ecken in deterministisch gemischter Reihenfolge durchprobieren. const reihenfolge = mischeIndex(ecken.length, rng); const halbDiag = Math.hypot(item.masse.w, item.masse.d) / 2; for (const i of reihenfolge) { const e = ecken[i] as EckAnker; if (eckenBelegt.has(e.index)) continue; // Yaw: Front zeigt aus der Ecke ins Rauminnere. const yawDeg = (Math.atan2(e.innen[0], e.innen[1]) * 180) / Math.PI; // Entlang der Winkelhalbierenden nach innen schieben, bis der (rotierte) // Footprint ganz im Raum liegt UND frei ist. Der Startabstand deckt einen // rechten Winkel nur knapp; bei spitzen Ecken/grossen Objekten poken die // Ecken sonst durch die Wand – darum inkrementell weiter reinrücken. let platz: { pos: Vec2; fp: Quad } | null = null; for (let dist = halbDiag + 0.06; dist <= halbDiag + 0.5; dist += 0.04) { const pos: Vec2 = [e.ecke[0] + e.innen[0] * dist, e.ecke[1] + e.innen[1] * dist]; const fp = footprint(pos, item.masse.w, item.masse.d, yawDeg); if (!imRaum(fp, floor)) continue; if (kollidiert(fp, hindernisse)) continue; platz = { pos, fp }; break; } if (!platz) continue; eckenBelegt.add(e.index); hindernisse.push(platz.fp); // belegt die Fläche für spätere Bodenobjekte return { pos: [platz.pos[0], item.masse.h / 2, platz.pos[1]], yawDeg, ankerRef: `ecke${e.index}`, }; } return null; } /** Wandmontierte Deko (an_wand); Default-Montagehöhe, mittig an einer Wand. */ function platziereWand( item: DressingItem, waende: WandAnker[], rng: () => number, ): Platzierungsergebnis | null { if (!item.anchorTypes.includes("wand")) return null; const nutzbar = waende.filter((w) => w.laenge >= item.masse.w + 0.1); if (nutzbar.length === 0) return null; const w = nutzbar[Math.floor(rng() * nutzbar.length)] as WandAnker; const mountHeight = 1.1; // typische Accessoire-Höhe (Unterkante-Näherung) // Objekt bündig an die Wand-Innenseite (halbe Tiefe nach innen). const pos: Vec2 = [ w.center[0] + w.innen[0] * (item.masse.d / 2), w.center[1] + w.innen[1] * (item.masse.d / 2), ]; return { pos: [pos[0], mountHeight + item.masse.h / 2, pos[1]], yawDeg: w.yawDeg, ankerRef: `wand-${w.wallId}`, }; } /** Deckenhängende Deko (an_decke): hängt mittig über einem Möbel-Anker * (Zentrum x/z aus dem Plan), Oberkante an der Deckenhöhe – `masse.h` ist * die volle Hängelänge inkl. Schirm. Kein Anker im Raum → nicht platziert * (nie erzwingen, wie bei den anderen Ankerarten). Keine Kollisionsprüfung * nötig (hängt frei über dem Boden-Footprint des Ankers). */ function platziereDecke( item: DressingItem, anker: MoebelAnker[], hoehe: number, rng: () => number, ): Platzierungsergebnis | null { const kandidaten = anker.filter((a) => item.anchorTypes.includes(a.funktionsTyp)); if (kandidaten.length === 0) return null; const a = kandidaten[Math.floor(rng() * kandidaten.length)] as MoebelAnker; return { pos: [a.center[0], hoehe - item.masse.h / 2, a.center[1]], yawDeg: a.yawDeg, ankerRef: `decke-${a.placementId}`, }; } /** Deterministische Permutation [0..n) aus dem RNG (Fisher-Yates). */ function mischeIndex(n: number, rng: () => number): number[] { const arr = Array.from({ length: n }, (_, i) => i); for (let i = n - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [arr[i], arr[j]] = [arr[j] as number, arr[i] as number]; } return arr; }