File size: 14,516 Bytes
9de795a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
/** 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<string, number>;
  /** 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<string, number>;
  attributTags: string[];
  assetStatus: "placeholder" | "modeled";
  /** Dateiname (ohne Endung) unter public/assets/dressing/<gltfRef>.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<string, FarbSlug> | 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<boolean> {
  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<boolean> | null = null;

/** Genau EIN Weckvorgang, auch wenn mehrere Aufrufe gleichzeitig warten
 *  (z.B. die fünf Stammdaten-Abrufe nach der Raumwahl). */
function weckeBackend(): Promise<boolean> {
  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<Response | null> {
  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<Response> {
  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<ApiFehler> {
  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<T>(pfad: string, init?: RequestInit): Promise<T> {
  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<string, FarbSlug> | 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<string, number> | 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<Room[]>("/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<import("./Stil").BildItem[]>(`/images/${roomType}`),
  taxonomy: () => call<{ achsen: import("./Stil").Achse[] }>("/taxonomy"),
  styleProfile: (roomType: string, likes: string[], dislikes: string[], presetId: string | null) =>
    call<import("./Stil").Stilprofil>("/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<KatalogItem[]>(`/catalog/${roomType}`),
  /** Scene-Dressing-Stammdaten je Raumtyp (rein visuelle Deko-Ebene, read-only). */
  dressing: (roomType: string) => call<DressingItem[]>(`/dressing/${roomType}`),
  rules: (roomType: string) => call<unknown[]>(`/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<KV>("/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<void> {
    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);
  },
};