/** API-Client zum Engines-Dienst. Immer relative `/api/*`-Pfade: lokal leitet * der Vite-Proxy an FastAPI :8000 weiter, im HF-Space mountet `space.py` die * API unter /api, auf Vercel leitet ein Rewrite an den Space weiter. */ import type { CatalogItemInput, RoomInput } from "@fp/shared/rules"; import { istBackendAntwort, istJsonAntwort, warteAufBackend, type BackendStatus } from "./backend"; import type { FarbSlug } from "./farben"; import type { FlaechenKonzept } from "./oberflaechen"; export interface Placement { id: string; catalogItemId: string; pose: { pos: [number, number]; yawDeg: number }; gewerk: string; locked: boolean; source: string; mountHeight?: number; /** Gewählte Farbvariante (KI oder Nutzer), Welle 3. Fehlt = Default-Optik. */ farbe?: FarbSlug; } export interface Plan { id: string; roomRef: string; version: number; status: string; placements: Placement[]; constraintReport: unknown; meta: { seed: number; normProfile: string; solverVersion: string }; } export interface KatalogItem extends CatalogItemInput { name: string; priorityClass: "P1" | "P2" | "P3"; /** Optionale 3D-Bausatz-Variante für den Viewer (Katalog-Feld modell3d). */ modell3d?: string; /** Wählbare Farbvarianten (Welle 2); erste = Default-Optik. Grundlage für den * UI-Farbpicker und die KI-Farbwahl. */ farbVarianten?: FarbSlug[]; /** Stil-Achsen-Ausprägungen des Items (für Stil-Match/Kurator). */ achsenTags?: Record; /** Preis-Stammdaten (für Kostenanzeige & LV). Provenance liegt im Schema. */ preis?: { value: number; currency: "CHF"; stand?: string; quelle?: string; bandbreitePct?: number; }; } export interface Room extends RoomInput { id: string; name: string; } /** Scene-Dressing-Objekt (rein visuelle Deko-Ebene, getrennt vom Plan). * Vertrag: packages/shared/schemas/dressing-item.schema.json. NICHT solver-/ * normrelevant – erscheint nie in placements/constraintReport/LV. */ export interface DressingItem { id: string; schemaVersion: string; name: string; roomTypes: string[]; /** B leicht kollisionsrelevant · C an Wand/Möbel · D Mikro-Deko auf Oberfläche. */ klasse: "B" | "C" | "D"; funktionsTyp: string; /** funktionsTypen aus dem Plan ODER die Schlüsselworte "wand"/"ecke". */ anchorTypes: string[]; platzierung: "auf_oberflaeche" | "an_wand" | "freie_ecke" | "an_moebel" | "an_decke"; masse: { w: number; d: number; h: number }; achsenTags: Record; attributTags: string[]; assetStatus: "placeholder" | "modeled"; /** Dateiname (ohne Endung) unter public/assets/dressing/.glb. */ gltfRef?: string; modell3d?: string; } export interface KV { raumName: string; mengen: { bodenflaeche_m2: number; wandflaeche_m2: number; objekte: number }; positionen: { bezeichnung: string; gewerk: string; menge: number; einheit: string; einzelpreis_chf: number; total_chf: number; }[]; summe_chf: number; bandbreitePct: number; von_chf: number; bis_chf: number; nextSteps: string[]; hinweis: string; } /** Optionen für /solve – Küche braucht normProfile/form/zoneId, sonst Kurator. */ export interface SolveOpts { kurator?: KuratorAntwort; stilprofilRef?: string; stilprofil?: unknown | null; normProfile?: string; form?: string; zoneId?: string; /** Flächen-Wünsche des Kurators (Call C); der Server reicht sie unverändert * in der Antwort zurück (der Client rendert daraus die Optik). */ flaechen?: FlaechenKonzept | null; /** Farbwahl je Item (Welle 3): itemId→Slug → placement.farbe im Plan. */ farben?: Record | null; /** K-Varianten (Welle 5): true → Server erzeugt K=3 Varianten und liefert die * beste (+ varianteInfo). Nur Nicht-Küche. Default false. */ varianten?: boolean; /** «3 Vorschläge» (Welle C): true → Response zusätzlich `variantenPlaene` * (ALLE K Pläne best-first). Nur zusammen mit `varianten` wirksam. */ variantenDetails?: boolean; } /** Auswahl-Spur der K-Varianten (Welle 5), additiv in der /solve-Response-Hülle. * Rein informativ – der `plan` ist bereits die gewählte beste Variante. */ export interface VarianteInfo { /** Index der gewählten Variante in `varianten`. */ gewaehlt: number; anzahl: number; varianten: { index: number; seed: number; /** Anzahl platzierter Items (Überlebensrate). */ platziert: number; /** Anzahl «knappe» harte Regeln (weniger = mehr Norm-Luft). */ knapp: number; /** Weiche Relations-/Stil-Zufriedenheit (höher = besser). */ relationScore: number; }[]; } /** Ein Vorschlag der «3 Vorschläge»-Ansicht (Welle C): kompletter Plan + die * zugehörige Score-Zeile aus varianteInfo. Liste kommt best-first vom Server. */ export interface VariantenPlan { plan: Plan; info: VarianteInfo["varianten"][number]; } export class ApiFehler extends Error { constructor( public code: string, message: string, ) { super(message); } } /** Fehlercode, wenn das Backend auch nach dem Wecken nicht antwortet. */ export const NICHT_ERREICHBAR = "BACKEND_NICHT_ERREICHBAR"; const TEXT_NICHT_ERREICHBAR = "Der Server ist gerade nicht erreichbar. Bitte in einigen Minuten erneut versuchen."; let statusHoerer: ((s: BackendStatus) => void) | null = null; /** App-weiter Beobachter des Backend-Zustands (für den «wird geweckt»-Hinweis). */ export function beobachteBackend(hoerer: ((s: BackendStatus) => void) | null): void { statusHoerer = hoerer; } async function pingHealth(): Promise { try { const res = await fetch("/api/health", { cache: "no-store", signal: AbortSignal.timeout(20_000), }); if (!res.ok || !istJsonAntwort(res)) return false; return ((await res.json()) as { status?: string }).status === "ok"; } catch { return false; } } let laufendesWecken: Promise | null = null; /** Genau EIN Weckvorgang, auch wenn mehrere Aufrufe gleichzeitig warten * (z.B. die fünf Stammdaten-Abrufe nach der Raumwahl). */ function weckeBackend(): Promise { if (laufendesWecken === null) { statusHoerer?.("wacht"); laufendesWecken = warteAufBackend({ ping: pingHealth, schlafe: (ms) => new Promise((ok) => setTimeout(ok, ms)), jetzt: () => Date.now(), }) .then((bereit) => { statusHoerer?.(bereit ? "bereit" : "nicht-erreichbar"); return bereit; }) .finally(() => { laufendesWecken = null; }); } return laufendesWecken; } async function versuche(pfad: string, init?: RequestInit): Promise { try { return await fetch(pfad, init); } catch { return null; // Netzfehler: wie «Backend nicht bereit» behandeln } } /** fetch, der «Backend schläft/startet» erkennt (HTML-Ladeseite, Proxy- * Fehlerseite, Netzfehler), das Backend weckt und die Anfrage EINMAL * wiederholt. Wiederholen ist unbedenklich: alle Endpunkte sind reine * Berechnungen ohne Server-Zustand. */ async function holeAntwort( pfad: string, init: RequestInit | undefined, binaer: boolean, ): Promise { const erste = await versuche(pfad, init); if (erste !== null && istBackendAntwort(erste, binaer)) return erste; if (!(await weckeBackend())) throw new ApiFehler(NICHT_ERREICHBAR, TEXT_NICHT_ERREICHBAR); const zweite = await versuche(pfad, init); if (zweite === null) throw new ApiFehler(NICHT_ERREICHBAR, TEXT_NICHT_ERREICHBAR); if (istBackendAntwort(zweite, binaer)) return zweite; throw new ApiFehler( "UNERWARTETE_ANTWORT", `Der Server hat unerwartet geantwortet (HTTP ${zweite.status}). Bitte erneut versuchen.`, ); } /** Fehler-Envelope der API ({code, message}) in einen ApiFehler übersetzen. */ async function envelopeFehler(res: Response, codeFallback: string): Promise { const body = istJsonAntwort(res) ? ((await res.json().catch(() => ({}))) as { code?: string; message?: string }) : {}; return new ApiFehler(body.code ?? codeFallback, body.message ?? res.statusText); } async function call(pfad: string, init?: RequestInit): Promise { const res = await holeAntwort( `/api${pfad}`, { headers: { "Content-Type": "application/json" }, ...init }, false, ); if (!res.ok) throw await envelopeFehler(res, "UNBEKANNT"); return (await res.json()) as T; } export interface KuratorAntwort { auswahl: string[]; relationaleAbsichten: { itemId: string; relation: string }[]; /** Raumprägende Haupt-Objekte (Objekt-Ebenen-Modell, ADR-0014), zuerst gewählt. */ hauptObjekte?: string[]; /** Ergänzungen mit Anzahl (Stühle zum Esstisch …). Grundlage für `mengen`. */ ergaenzungen?: { itemId: string; anzahl: number }[]; /** Flächen-Konzept (Boden-/Wand-Material je Wand). Fehlt bei Teil-Fallback. */ flaechen?: FlaechenKonzept | null; /** Design-Leitidee aus Call A (roter Faden), optional. */ konzept?: string | null; /** KI-Farbwahl je Item (itemId→Slug), Welle 3. Fehlt/leer = Default-Optik. */ farben?: Record | null; begruendung?: string; } /** Instanz-Mengen (itemId→anzahl) aus den Kurator-Ergänzungen für /solve. * Nur Einträge mit anzahl > 1 (Default 1 bleibt implizit) – ADR-0014. */ export function mengenAusKurator(kurator?: KuratorAntwort): Record | undefined { const erg = kurator?.ergaenzungen?.filter((e) => e.anzahl > 1) ?? []; return erg.length ? Object.fromEntries(erg.map((e) => [e.itemId, e.anzahl])) : undefined; } /** Gemessenes AMK-Arbeitsdreieck eines Küchenplans (server-berechnet, M6-Politur). */ export interface Arbeitsdreieck { /** Die drei Seitenlängen Spüle–Kochfeld–Kühlschrank (m). */ seiten_m: number[]; /** Summe der drei Seiten (m); AMK-effizient bei ~4–8 m. */ summe_m: number; /** Ergonomie-Score in [0,1] (= constraintReport.softScore.ergonomie). */ score: number; bewertung: "effizient" | "akzeptabel" | "beengt" | "weitläufig"; } /** Eine Küchenform-Empfehlung (Formwahl, M6). */ export interface KuechenForm { form: "i" | "l" | "u" | "galley" | "insel"; score: number; begruendung: string; anchorWallIds: string[]; nutzlaenge_m: number; } export const api = { rooms: () => call("/samples/rooms"), /** Scan-Bundle (vorberechnetes layout.txt, optional .zip mit poses.json) → Raummodell. * FormData statt JSON – daher eigener fetch (nicht der json-`call`-Helfer). */ async scan( bundle: File, roomType: string, name: string, ): Promise<{ room: Room; warnungen: string[] }> { const fd = new FormData(); fd.append("bundle", bundle); fd.append("roomType", roomType); fd.append("name", name); const res = await holeAntwort("/api/scan", { method: "POST", body: fd }, false); if (!res.ok) throw await envelopeFehler(res, "SCAN"); return (await res.json()) as { room: Room; warnungen: string[] }; }, images: (roomType: string) => call(`/images/${roomType}`), taxonomy: () => call<{ achsen: import("./Stil").Achse[] }>("/taxonomy"), styleProfile: (roomType: string, likes: string[], dislikes: string[], presetId: string | null) => call("/style/profile", { method: "POST", body: JSON.stringify({ roomType, likes, dislikes, presetId }), }), curate: (room: Room, stilprofil: unknown, seed: number) => call<{ kurator: KuratorAntwort; port: string }>("/curate", { method: "POST", body: JSON.stringify({ room, stilprofil, seed }), }), catalog: (roomType: string) => call(`/catalog/${roomType}`), /** Scene-Dressing-Stammdaten je Raumtyp (rein visuelle Deko-Ebene, read-only). */ dressing: (roomType: string) => call(`/dressing/${roomType}`), rules: (roomType: string) => call(`/rules/${roomType}`), /** Top-3 Küchenformen (Formwahl) für einen Küchen- oder Grossraum. */ kuecheFormen: (room: Room, stilprofil: unknown | null, normProfile: string, zoneId?: string) => call<{ formen: KuechenForm[] }>("/kueche/formen", { method: "POST", body: JSON.stringify({ room, styleProfile: stilprofil ?? undefined, normProfile, zoneId }), }), solve: (room: Room, seed: number, opts: SolveOpts = {}) => call<{ plan: Plan; room: Room; hinweis?: string; arbeitsdreieck?: Arbeitsdreieck; flaechen?: FlaechenKonzept | null; varianteInfo?: VarianteInfo; variantenPlaene?: VariantenPlan[]; }>("/solve", { method: "POST", body: JSON.stringify({ room, seed, auswahl: opts.kurator?.auswahl, relationaleAbsichten: opts.kurator?.relationaleAbsichten ?? [], // Mehrfach-Instanzen (ADR-0014): itemId→anzahl aus den Kurator-Ergänzungen. mengen: mengenAusKurator(opts.kurator), stilprofilRef: opts.stilprofilRef, stilprofil: opts.stilprofil ?? undefined, normProfile: opts.normProfile, form: opts.form, zoneId: opts.zoneId, flaechen: opts.flaechen ?? undefined, farben: opts.farben ?? undefined, varianten: opts.varianten ?? undefined, variantenDetails: opts.variantenDetails ?? undefined, }), }), /** Manuelle Flächen-Wahl (Welle 3) gegen die Norm prüfen + korrigieren lassen. * Quelle bleibt Python (kein TS-Nachbau der Regeln → kein Drift). */ pruefeFlaechen: (room: Room, flaechen: FlaechenKonzept) => call<{ verstoesse: string[]; korrigiert: FlaechenKonzept }>("/flaechen/pruefen", { method: "POST", body: JSON.stringify({ room, flaechen }), }), evaluate: (room: Room, plan: Plan) => call("/evaluate", { method: "POST", body: JSON.stringify({ room, plan }) }), /** Dokument vom Export-Endpunkt herunterladen (Blob → Browser-Download). */ async dokument(pfad: string, dateiname: string, room: Room, plan: Plan): Promise { const res = await holeAntwort( `/api/export/${pfad}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ room, plan }), }, true, ); if (!res.ok) throw await envelopeFehler(res, "EXPORT"); const url = URL.createObjectURL(await res.blob()); const a = document.createElement("a"); a.href = url; a.download = dateiname; a.click(); URL.revokeObjectURL(url); }, };