Spaces:
Sleeping
Sleeping
Download apps/web/src/App.tsx from Bryan-HSLU/FP_POC: direct link, hf CLI and curl.
- Browser
- Download file 37.8 kB
-
https://huggingface.co/spaces/Bryan-HSLU/FP_POC/resolve/main/apps/web/src/App.tsx
- Command line
-
hf download hf://spaces/Bryan-HSLU/FP_POC/apps/web/src/App.tsx
-
curl -L -o App.tsx https://huggingface.co/spaces/Bryan-HSLU/FP_POC/resolve/main/apps/web/src/App.tsx
37.8 kB
| /** M3-Klickpfad, jetzt als Schritt-für-Schritt-Wizard (CI-UX-Kreislauf): | |
| * 1 Projekt → 2 Stil → 3 Vorschlag → 4 Anpassen → 5 Auswertung & Export. | |
| * Nach jeder Auswahl einen Schritt weiter; die bisherige UI-Logik/Callbacks | |
| * bleiben unverändert – nur die Anzeige ist pro Schritt gruppiert. | |
| * | |
| * Live-Regel-Feedback läuft CLIENTSEITIG über den TS-Interpreter aus | |
| * @fp/shared – dieselben Regel-JSONs wie der Server (Regel-Parität). | |
| */ | |
| import { buildScene, evaluateRules, type ConstraintReport, type Rule } from "@fp/shared/rules"; | |
| import { useCallback, useEffect, useMemo, useState } from "react"; | |
| import { | |
| api, | |
| ApiFehler, | |
| beobachteBackend, | |
| type Arbeitsdreieck, | |
| type DressingItem, | |
| type KatalogItem, | |
| type KuechenForm, | |
| type KV, | |
| type Plan, | |
| type Room, | |
| type VariantenPlan, | |
| } from "./api"; | |
| import { AppRahmen } from "./AppRahmen"; | |
| import { type BackendStatus } from "./backend"; | |
| import { Ladezustand } from "./Ladezustand"; | |
| import { Splash, StartMenue } from "./StartMenue"; | |
| import { RaumEditor } from "./RaumEditor"; | |
| import { ScanKorrektur } from "./ScanKorrektur"; | |
| import { SchrittAnpassen } from "./SchrittAnpassen"; | |
| import { SchrittAuswertung } from "./SchrittAuswertung"; | |
| import { SchrittProjekt, type RaumtypKey } from "./SchrittProjekt"; | |
| import { SchrittStil } from "./SchrittStil"; | |
| import { SchrittVorschlag } from "./SchrittVorschlag"; | |
| import { type Achse, type BildItem, type Stilprofil } from "./Stil"; | |
| import { CSS, THEME } from "./theme"; | |
| import { Viewer2D } from "./Viewer2D"; | |
| import { Viewer3D, type FlaechenWahl } from "./Viewer3D"; | |
| import { | |
| leiteOberflaechen, | |
| variantenFuer, | |
| wendeVariantenAn, | |
| type FlaechenKonzept, | |
| type MaterialSlug, | |
| type OberflaechenWahl, | |
| } from "./oberflaechen"; | |
| import { loeseFarbSlug } from "./farben"; | |
| import { loeseFlaechen2D } from "./flaechen2d"; | |
| import { | |
| beschreibeWaende, | |
| setzeAlleWaende, | |
| setzeWandMaterial, | |
| type WandBereich, | |
| } from "./wandauswahl"; | |
| import type { Begehungsmodus } from "./viewer3d-logik"; | |
| // UI-Redesign Etappe B: Die fünf Schritt-Inhalte leben jetzt in eigenen | |
| // Schritt*-Komponenten (SchrittProjekt/Stil/Vorschlag/Anpassen/Auswertung). | |
| // App.tsx hält weiterhin State, Callbacks, Live-Ampel und Navigation – die | |
| // bestehende Logik ist unverändert, nur die Präsentation ist ausgelagert. | |
| export function App() { | |
| const [rooms, setRooms] = useState<Room[]>([]); | |
| const [room, setRoom] = useState<Room | null>(null); | |
| const [catalog, setCatalog] = useState<KatalogItem[]>([]); | |
| const [dressingItems, setDressingItems] = useState<DressingItem[]>([]); | |
| const [rules, setRules] = useState<Rule[]>([]); | |
| const [plan, setPlan] = useState<Plan | null>(null); | |
| const [seed, setSeed] = useState(1); | |
| const [gewaehltId, setGewaehltId] = useState<string | null>(null); | |
| const [kv, setKv] = useState<KV | null>(null); | |
| const [meldung, setMeldung] = useState<string>(""); | |
| const [bilder, setBilder] = useState<BildItem[]>([]); | |
| const [achsen, setAchsen] = useState<Achse[]>([]); | |
| const [stilprofil, setStilprofil] = useState<Stilprofil | null>(null); | |
| // Manueller Raum-Editor (dritte Erstellungsvariante neben Sample/Scan). | |
| const [editorOffen, setEditorOffen] = useState(false); | |
| const [begruendung, setBegruendung] = useState<string>(""); | |
| // Design-Leitidee des Kurators (Call A, ADR-0014) - in der Vorschlags-Ansicht sichtbar. | |
| const [konzept, setKonzept] = useState<string>(""); | |
| // Küche (M6): Normprofil-Toggle, Formwahl-Karten, gewählte Form. | |
| const [normProfile, setNormProfile] = useState<"ch" | "eu">("ch"); | |
| const [formen, setFormen] = useState<KuechenForm[] | null>(null); | |
| const [form, setForm] = useState<string | null>(null); | |
| // Gemessenes Arbeitsdreieck des aktuellen Küchenplans (server-berechnet). | |
| const [dreieck, setDreieck] = useState<Arbeitsdreieck | null>(null); | |
| // Effektiv geplanter Raum (Viewer/Ampel): bei Grossraum die Küchen-Zone. | |
| const [planRoom, setPlanRoom] = useState<Room | null>(null); | |
| // Ansicht: 2D-Grundriss (normgerecht beurteilbar) oder 3D-Box-Platzhalter. | |
| const [ansicht, setAnsicht] = useState<"2d" | "3d">("2d"); | |
| // 3D-Viewer: Begehungsmodus (in App gehalten, damit die Möbel-Pfeiltasten im | |
| // Begehungsmodus ruhen und nicht mit der WASD-Kamera kollidieren). | |
| const [viewer3dModus, setViewer3dModus] = useState<Begehungsmodus>("orbit"); | |
| // 3D-Viewer: gewählte Oberfläche (Boden/Wand) + rein visuelle Variantenwahl. | |
| // `oberflaechenWahl` bleibt bewusst über «Variante würfeln» hinweg erhalten | |
| // (loesen fasst sie nicht an) – nur ein Raumwechsel setzt sie zurück. | |
| const [flaeche, setFlaeche] = useState<FlaechenWahl | null>(null); | |
| const [oberflaechenWahl, setOberflaechenWahl] = useState<OberflaechenWahl>({ | |
| boden: null, | |
| wand: null, | |
| }); | |
| // Flächen-Konzept des Kurators (Boden-/Wand-Material je Wand), aus /solve. | |
| // Fehlt es (Küche/Baseline/alte Pläne) → null → stilabgeleiteter Fallback im 3D. | |
| const [flaechen, setFlaechen] = useState<FlaechenKonzept | null>(null); | |
| // Welle 3: manuelle Farb-Overrides je placementId (MANUELL > KI > Default) und | |
| // manuell überschriebene, normgeprüfte Flächen. Beide leben nur im Frontend. | |
| const [farbWahl, setFarbWahl] = useState<Record<string, string>>({}); | |
| const [manuelleFlaechen, setManuelleFlaechen] = useState<FlaechenKonzept | null>(null); | |
| // Welle C: «3 Vorschläge» – alle K Pläne best-first + Index der aktiven Karte. | |
| const [variantenPlaene, setVariantenPlaene] = useState<VariantenPlan[] | null>(null); | |
| const [aktiveVariante, setAktiveVariante] = useState(0); | |
| // Welle C: Einzelwand-Auswahl im Flächen-Panel (null = «Alle Wände»). | |
| const [gewaehlteWand, setGewaehlteWand] = useState<number | null>(null); | |
| // Scan-Upload (M7 Schritt 4): Raumtyp des hochgeladenen Scan-Bundles. | |
| const [scanRoomType, setScanRoomType] = useState("bad"); | |
| // Scan-Korrektur-Modus (M7 Schritt 6): geladener Scan wartet auf Korrektur. | |
| const [korrektur, setKorrektur] = useState<{ room: Room; warnungen: string[] } | null>(null); | |
| // Einstieg vor dem Wizard: Splash (Logo) → Startmenü → App (Bryan-Wunsch). | |
| const [phase, setPhase] = useState<"splash" | "menue" | "app">("splash"); | |
| // Wizard: aktueller Schritt (1..5). Schritt-für-Schritt statt alles auf einmal. | |
| const [schritt, setSchritt] = useState(1); | |
| // Raumtyp-Vorwahl (Schritt 1): steuert Scan-Raumtyp + filtert Beispielräume. | |
| const [raumtypVorwahl, setRaumtypVorwahl] = useState<RaumtypKey | null>(null); | |
| // Ladezustände (Etappe B): hängen die <Ladezustand>-Anzeigen an die await-Stellen. | |
| const [ladenStil, setLadenStil] = useState(false); | |
| const [ladenVorschlag, setLadenVorschlag] = useState(false); | |
| const [ladenDokumente, setLadenDokumente] = useState(false); | |
| const [ladenScan, setLadenScan] = useState(false); | |
| // Backend-Zustand für den «Server wird geweckt»-Hinweis (s. backend.ts): das | |
| // Wecken selbst passiert transparent in api.ts, hier wird es nur angezeigt. | |
| const [backend, setBackend] = useState<BackendStatus>("unbekannt"); | |
| useEffect(() => { | |
| beobachteBackend(setBackend); | |
| return () => beobachteBackend(null); | |
| }, []); | |
| // Läuft schon beim Mount (während Splash/Startmenü) – ein schlafender Server | |
| // wacht so im Hintergrund auf, während der Nutzer noch das Menü liest. | |
| const ladeBeispielraeume = useCallback(() => { | |
| api | |
| .rooms() | |
| .then((r) => { | |
| setRooms(r); | |
| setMeldung(""); | |
| }) | |
| .catch((e: unknown) => { | |
| const text = | |
| e instanceof ApiFehler ? e.message : "Beispielräume konnten nicht geladen werden."; | |
| // Entwickler-Hinweis nur lokal – echte Nutzer können damit nichts anfangen. | |
| setMeldung(import.meta.env.DEV ? `${text} (Lokal: «pnpm api» starten.)` : text); | |
| }); | |
| }, []); | |
| useEffect(() => ladeBeispielraeume(), [ladeBeispielraeume]); | |
| // Küchen-Zone eines (Gross-)Raums: roomType kueche ODER eine Zone roomType | |
| // kueche. Liefert {istKueche, zoneId, effektiverRoomType}. | |
| const kuecheInfo = useMemo(() => { | |
| if (!room) return { istKueche: false, zoneId: undefined as string | undefined }; | |
| if (room.roomType === "kueche") return { istKueche: true, zoneId: undefined }; | |
| const zonen = (room as Room & { zones?: { id: string; roomType: string }[] }).zones ?? []; | |
| const z = zonen.find((zone) => zone.roomType === "kueche"); | |
| return { istKueche: !!z, zoneId: z?.id }; | |
| }, [room]); | |
| const raumWaehlen = useCallback( | |
| async (r: Room) => { | |
| setRoom(r); | |
| setPlan(null); | |
| setKv(null); | |
| setGewaehltId(null); | |
| setStilprofil(null); | |
| setBegruendung(""); | |
| setKonzept(""); | |
| setFormen(null); | |
| setForm(null); | |
| setPlanRoom(null); | |
| setDreieck(null); | |
| setFlaeche(null); | |
| setFlaechen(null); | |
| setFarbWahl({}); | |
| setManuelleFlaechen(null); | |
| setVariantenPlaene(null); | |
| setAktiveVariante(0); | |
| setGewaehlteWand(null); | |
| setOberflaechenWahl({ boden: null, wand: null }); | |
| // Für Küchen den Katalog/Regeln des effektiven Raumtyps laden. | |
| const istKueche = | |
| r.roomType === "kueche" || | |
| ((r as Room & { zones?: { roomType: string }[] }).zones ?? []).some( | |
| (z) => z.roomType === "kueche", | |
| ); | |
| const effTyp = istKueche ? "kueche" : r.roomType; | |
| setCatalog(await api.catalog(effTyp)); | |
| // Scene-Dressing-Stammdaten (rein visuelle Deko) je Raumtyp; fehlt der | |
| // Datensatz (nur «bad» im Durchstich), bleibt die Deko-Ebene einfach leer. | |
| setDressingItems(await api.dressing(r.roomType).catch(() => [])); | |
| setRules((await api.rules(effTyp)) as Rule[]); | |
| setBilder(await api.images(r.roomType).catch(() => [])); | |
| if (achsen.length === 0) setAchsen((await api.taxonomy()).achsen); | |
| // Nach erfolgreicher Raumwahl automatisch weiter zu Schritt 2 «Stil». | |
| setSchritt(2); | |
| }, | |
| [achsen.length], | |
| ); | |
| // Scan-Bundle hochladen → Raummodell → Korrektur-Modus (M7 Schritt 6). | |
| // Gescannte Geometrie ist ~8.5 cm unsicher (kein LiDAR) und trägt weder | |
| // Anschlüsse noch bestätigte Objekte – erst nach der Nutzer-Korrektur geht der | |
| // Raum (geometryConfirmed) in den bestehenden Klickpfad. | |
| const scanLaden = useCallback( | |
| async (datei: File) => { | |
| setMeldung("Scan wird verarbeitet…"); | |
| setLadenScan(true); | |
| try { | |
| const { room: neu, warnungen } = await api.scan(datei, scanRoomType, datei.name); | |
| setKorrektur({ room: neu, warnungen }); | |
| setMeldung("Scan geladen – bitte korrigieren."); | |
| } catch (e) { | |
| setMeldung( | |
| e instanceof ApiFehler ? `Scan fehlgeschlagen: ${e.message}` : "Scan fehlgeschlagen.", | |
| ); | |
| } finally { | |
| setLadenScan(false); | |
| } | |
| }, | |
| [scanRoomType], | |
| ); | |
| // Küche: vor dem ersten Solve die Top-3 Formen holen. | |
| const formenLaden = useCallback(async () => { | |
| if (!room || !kuecheInfo.istKueche) return; | |
| const res = await api.kuecheFormen(room, stilprofil, normProfile, kuecheInfo.zoneId); | |
| setFormen(res.formen); | |
| setForm(res.formen[0]?.form ?? null); | |
| }, [room, kuecheInfo, stilprofil, normProfile]); | |
| const loesen = useCallback( | |
| async (s: number) => { | |
| if (!room) return; | |
| setMeldung(""); | |
| setKv(null); | |
| setLadenVorschlag(true); | |
| try { | |
| let res: { | |
| plan: Plan; | |
| room: Room; | |
| hinweis?: string; | |
| arbeitsdreieck?: Arbeitsdreieck; | |
| flaechen?: FlaechenKonzept | null; | |
| variantenPlaene?: VariantenPlan[]; | |
| }; | |
| if (kuecheInfo.istKueche) { | |
| // Küche: lineare Baugruppe – Form + Normprofil (+ Zone) an den Solver. | |
| let f = form; | |
| if (f === null) { | |
| const fr = await api.kuecheFormen(room, stilprofil, normProfile, kuecheInfo.zoneId); | |
| setFormen(fr.formen); | |
| f = fr.formen[0]?.form ?? "i"; | |
| setForm(f); | |
| } | |
| res = await api.solve(room, s, { | |
| normProfile, | |
| form: f, | |
| zoneId: kuecheInfo.zoneId, | |
| stilprofil, | |
| stilprofilRef: stilprofil?.id, | |
| }); | |
| } else { | |
| // Bad/Wohnen: mit Stilprofil erst Kurator («KI wählt»), dann Solver. | |
| let kurator; | |
| if (stilprofil) { | |
| const k = await api.curate(room, stilprofil, s); | |
| kurator = k.kurator; | |
| setBegruendung(`${k.port}: ${k.kurator.begruendung ?? ""}`); | |
| setKonzept(k.kurator.konzept ?? ""); | |
| } | |
| // Flächen-Wünsche + Farbwahl des Kurators mit an /solve geben; die | |
| // Flächen kommen unverändert zurück (res.flaechen), die Farben landen | |
| // als placement.farbe im Plan → Optik im 3D-Viewer. | |
| res = await api.solve(room, s, { | |
| kurator, | |
| stilprofilRef: stilprofil?.id, | |
| flaechen: kurator?.flaechen, | |
| farben: kurator?.farben, | |
| // Welle 5: K-Varianten – die App bekommt die beste von 3 Varianten. | |
| varianten: true, | |
| // Welle C: alle 3 Pläne für die «3 Vorschläge»-Karten mitliefern. | |
| variantenDetails: true, | |
| }); | |
| } | |
| setPlan(res.plan); | |
| setFlaechen(res.flaechen ?? null); | |
| // «3 Vorschläge» (Welle C): best-first – der gelieferte Plan ist Karte 0. | |
| setVariantenPlaene(res.variantenPlaene ?? null); | |
| setAktiveVariante(0); | |
| // Neuer Plan → neue placementIds: manuelle Overrides zurücksetzen. | |
| setFarbWahl({}); | |
| setManuelleFlaechen(null); | |
| setPlanRoom(res.room); | |
| setDreieck(res.arbeitsdreieck ?? null); | |
| setSeed(s); | |
| // Kein Auto-Sprung mehr: Schritt 3 «Vorschlag» zeigt das Ergebnis selbst | |
| // (der Nutzer geht per «Ansehen & anpassen» weiter zu Schritt 4). So kann | |
| // «Neue Variante» aus Schritt 4 dort bleiben, ohne zurückzuspringen. | |
| if (res.hinweis) | |
| setMeldung("Hinweis: Geometrie unbestätigt – Ampel rechnet mit Messunsicherheit."); | |
| } catch (e) { | |
| if (e instanceof ApiFehler && e.code === "NO_FEASIBLE_PLACEMENT") { | |
| setMeldung(`Solver ehrlich: ${e.message} – Raum zu klein / Anschlüsse fehlen.`); | |
| setPlan(null); | |
| } else throw e; | |
| } finally { | |
| setLadenVorschlag(false); | |
| } | |
| }, | |
| [room, stilprofil, kuecheInfo, form, normProfile], | |
| ); | |
| // Effektiver Raum für Viewer + Ampel: bei Küche/Grossraum die geplante Zone. | |
| const aktuellerRaum = planRoom ?? room; | |
| // Live-Ampel: TS-Interpreter über die aktuelle (ggf. editierte) Szene. | |
| const report: ConstraintReport | null = useMemo(() => { | |
| if (!aktuellerRaum || !plan || rules.length === 0) return null; | |
| return evaluateRules( | |
| buildScene( | |
| aktuellerRaum, | |
| { placements: plan.placements, meta: { normProfile: plan.meta.normProfile } }, | |
| catalog, | |
| ), | |
| rules, | |
| ); | |
| }, [aktuellerRaum, plan, rules, catalog]); | |
| // Pro-Placement-Status aus dem Report (verletzt schlägt knapp) – färbt die | |
| // Footprints im 2D-Grundriss und die Möbel im 3D-Viewer nach der Norm-Ampel. | |
| const statusById = useMemo(() => { | |
| const m = new Map<string, "verletzt" | "knapp">(); | |
| if (!report) return m; | |
| for (const r of report.results) { | |
| if (r.status !== "verletzt" && r.status !== "knapp") continue; | |
| for (const pid of r.placements) { | |
| if (r.status === "verletzt" || m.get(pid) !== "verletzt") m.set(pid, r.status); | |
| } | |
| } | |
| return m; | |
| }, [report]); | |
| const bewege = useCallback( | |
| (dx: number, dz: number, drehung = 0) => { | |
| if (!plan || !gewaehltId) return; | |
| setKv(null); | |
| setPlan({ | |
| ...plan, | |
| status: "bearbeitet", | |
| placements: plan.placements.map((p) => | |
| p.id !== gewaehltId || p.locked | |
| ? p | |
| : { | |
| ...p, | |
| source: "user", | |
| pose: { | |
| pos: [p.pose.pos[0] + dx, p.pose.pos[1] + dz], | |
| yawDeg: (p.pose.yawDeg + drehung + 360) % 360, | |
| }, | |
| }, | |
| ), | |
| }); | |
| }, | |
| [plan, gewaehltId], | |
| ); | |
| const sperren = useCallback(() => { | |
| if (!plan || !gewaehltId) return; | |
| setPlan({ | |
| ...plan, | |
| placements: plan.placements.map((p) => | |
| p.id === gewaehltId ? { ...p, locked: !p.locked } : p, | |
| ), | |
| }); | |
| }, [plan, gewaehltId]); | |
| // «austauschen»: das gewählte Item gegen eine Katalog-Alternative tauschen | |
| // (Pose bleibt; Live-Ampel rechnet die neuen Masse sofort durch). | |
| const tauscheItem = useCallback( | |
| (neueId: string) => { | |
| if (!gewaehltId) return; | |
| setKv(null); | |
| setPlan((prev) => | |
| prev | |
| ? { | |
| ...prev, | |
| status: "bearbeitet", | |
| placements: prev.placements.map((p) => | |
| p.id === gewaehltId ? { ...p, source: "user", catalogItemId: neueId } : p, | |
| ), | |
| } | |
| : prev, | |
| ); | |
| }, | |
| [gewaehltId], | |
| ); | |
| // Absolutes Verschieben per Drag&Drop (Viewer2D). Funktionales setPlan, weil | |
| // beim Ziehen sehr schnell hintereinander aufgerufen wird; gesperrte bleiben. | |
| const verschiebeNach = useCallback((id: string, welt: [number, number]) => { | |
| setKv(null); | |
| setPlan((prev) => | |
| prev | |
| ? { | |
| ...prev, | |
| status: "bearbeitet", | |
| placements: prev.placements.map((p) => | |
| p.id !== id || p.locked | |
| ? p | |
| : { ...p, source: "user", pose: { ...p.pose, pos: welt } }, | |
| ), | |
| } | |
| : prev, | |
| ); | |
| }, []); | |
| // Absolutes Drehen per Rotations-Griff (Viewer2D). Setzt den Yaw direkt (der | |
| // Griff rastet bereits auf 15°) – dieselbe Live-Ampel wie beim Ziehen/«r». | |
| const rotiereNach = useCallback((id: string, yawDeg: number) => { | |
| setKv(null); | |
| setPlan((prev) => | |
| prev | |
| ? { | |
| ...prev, | |
| status: "bearbeitet", | |
| placements: prev.placements.map((p) => | |
| p.id !== id || p.locked | |
| ? p | |
| : { | |
| ...p, | |
| source: "user", | |
| pose: { ...p.pose, yawDeg: ((yawDeg % 360) + 360) % 360 }, | |
| }, | |
| ), | |
| } | |
| : prev, | |
| ); | |
| }, []); | |
| // Auswahl Möbel ↔ Oberfläche schliessen sich gegenseitig aus (eine Karte). | |
| // Möbelwahl UND Abwählen (id=null) beenden immer die Oberflächen-Auswahl. | |
| const waehleItem = useCallback((id: string | null) => { | |
| setGewaehltId(id); | |
| setFlaeche(null); | |
| }, []); | |
| const waehleFlaeche = useCallback((f: FlaechenWahl | null) => { | |
| setFlaeche(f); | |
| if (f !== null) setGewaehltId(null); | |
| if (f !== "wand") setGewaehlteWand(null); | |
| }, []); | |
| // Oberflächen-Variante wählen (rein visuell, kein Schema-/API-Eingriff). | |
| const flaecheVariante = useCallback((art: FlaechenWahl, id: string) => { | |
| setOberflaechenWahl((prev) => ({ ...prev, [art]: id })); | |
| }, []); | |
| // «3 Vorschläge» (Welle C): Karte k übernimmt diesen Plan in Viewer/Auswertung. | |
| // Farb-Overrides hängen an placementIds (je Variante neu) → zurücksetzen; | |
| // Flächen-Overrides sind wandIndex-basiert und bleiben gültig. | |
| const uebernehmeVariante = useCallback( | |
| (idx: number) => { | |
| const vp = variantenPlaene?.[idx]; | |
| if (!vp) return; | |
| setAktiveVariante(idx); | |
| setPlan(vp.plan); | |
| setKv(null); | |
| setGewaehltId(null); | |
| setFarbWahl({}); | |
| }, | |
| [variantenPlaene], | |
| ); | |
| // Welle 3 – Farb-Override je gewähltem Objekt (MANUELL gewinnt). Rein | |
| // client-seitig; kein Re-Solve (Farben sind keine Norm). | |
| const setzeFarbe = useCallback((placementId: string, slug: string) => { | |
| setFarbWahl((prev) => ({ ...prev, [placementId]: slug })); | |
| }, []); | |
| // Manuelle Flächen wechseln KI/Stil-Optik → NORMKONTROLLE über Python | |
| // (/flaechen/pruefen): kein TS-Nachbau der Regeln (Drift). Übernimmt die | |
| // korrigierte Fassung und weist einen Norm-Eingriff deutsch aus. | |
| // Einzelwand (Welle C): `wandIndex` gesetzt → nur diese Wand wird überschrieben | |
| // (Kurator-Einträge anderer Wände bleiben erhalten); null = «Alle Wände». | |
| const waehleFlaechenMaterial = useCallback( | |
| async ( | |
| art: FlaechenWahl, | |
| slug: MaterialSlug, | |
| wandIndex?: number | null, | |
| bereich: WandBereich = "voll", | |
| ) => { | |
| const raum = planRoom ?? room; | |
| if (!raum) return; | |
| const basis: FlaechenKonzept = manuelleFlaechen ?? flaechen ?? {}; | |
| const neu: FlaechenKonzept = | |
| art === "boden" | |
| ? { ...basis, boden: { material: slug } } | |
| : wandIndex != null | |
| ? setzeWandMaterial(basis, wandIndex, slug, bereich) | |
| : setzeAlleWaende(basis, raum.shell.walls.length, slug, bereich); | |
| try { | |
| const { verstoesse, korrigiert } = await api.pruefeFlaechen(raum, neu); | |
| setManuelleFlaechen(korrigiert); | |
| setMeldung( | |
| verstoesse.length | |
| ? `Flächen normkonform angepasst (${verstoesse.length} Norm-Eingriff${ | |
| verstoesse.length > 1 ? "e" : "" | |
| }).` | |
| : "", | |
| ); | |
| } catch { | |
| // Norm-Endpoint nicht erreichbar → Wahl trotzdem lokal anwenden (der | |
| // 3D-Viewer bleibt bedienbar; die harte Prüfung fehlt dann nur visuell). | |
| setManuelleFlaechen(neu); | |
| } | |
| }, | |
| [planRoom, room, manuelleFlaechen, flaechen], | |
| ); | |
| useEffect(() => { | |
| const handler = (e: KeyboardEvent) => { | |
| // Im 3D-Begehungsmodus steuert WASD/Pfeile die Kamera – dann keine | |
| // Möbel-Pfeiltasten (Konflikt vermeiden). | |
| if (ansicht === "3d" && viewer3dModus === "begehung") return; | |
| const schrittW = 0.05; | |
| if (e.key === "ArrowLeft") bewege(-schrittW, 0); | |
| if (e.key === "ArrowRight") bewege(schrittW, 0); | |
| if (e.key === "ArrowUp") bewege(0, -schrittW); | |
| if (e.key === "ArrowDown") bewege(0, schrittW); | |
| if (e.key === "r") bewege(0, 0, 90); | |
| }; | |
| window.addEventListener("keydown", handler); | |
| return () => window.removeEventListener("keydown", handler); | |
| }, [bewege, ansicht, viewer3dModus]); | |
| const auswerten = useCallback(async () => { | |
| if (!room || !plan) return; | |
| setLadenDokumente(true); | |
| try { | |
| setKv(await api.evaluate(room, plan)); | |
| } finally { | |
| setLadenDokumente(false); | |
| } | |
| }, [room, plan]); | |
| // Schritt 5 «Auswertung»: die Kostenschätzung beim Betreten automatisch laden, | |
| // damit die Zusammenfassungs-Karte direkt Zahlen zeigt (nutzt `auswerten`). | |
| useEffect(() => { | |
| if (schritt === 5 && plan && !kv && !ladenDokumente) void auswerten(); | |
| }, [schritt, plan, kv, ladenDokumente, auswerten]); | |
| // Stilprofil FINAL berechnen (Schritt 2: «Weiter mit diesem Profil», letztes | |
| // Bild oder Preset). Danach automatisch weiter zu Schritt 3 «Vorschlag» – | |
| // das Profil steht dann fest, der Nutzer hat es bestätigt. | |
| const stilBerechnen = useCallback( | |
| (likes: string[], dislikes: string[], presetId: string | null) => { | |
| if (!room) return; | |
| setLadenStil(true); | |
| api | |
| .styleProfile(room.roomType, likes, dislikes, presetId) | |
| .then((p) => { | |
| setStilprofil(p); | |
| setSchritt(3); | |
| }) | |
| .catch(() => setMeldung("Stilprofil konnte nicht berechnet werden.")) | |
| .finally(() => setLadenStil(false)); | |
| }, | |
| [room], | |
| ); | |
| // Live-Zwischenprofil (Schritt 2, nach JEDER Bewertung): nur das Profil neu | |
| // rechnen und anzeigen – KEIN Ladezustand (der würde die Swipe-Karte | |
| // verdecken) und KEIN Schrittwechsel. api.styleProfile ist deterministisch | |
| // und billig; Fehler bleiben still (Live-Feedback, kein Blocker). | |
| const stilZwischenprofil = useCallback( | |
| (likes: string[], dislikes: string[]) => { | |
| if (!room) return; | |
| api | |
| .styleProfile(room.roomType, likes, dislikes, null) | |
| .then(setStilprofil) | |
| .catch(() => {}); | |
| }, | |
| [room], | |
| ); | |
| // Dokument-Download (Schritt 5) – Blob über den bestehenden Export-Endpunkt. | |
| const dokumentLaden = useCallback( | |
| (pfad: string, datei: string) => { | |
| if (!room || !plan) return; | |
| void api | |
| .dokument(pfad, datei, room, plan) | |
| .catch(() => setMeldung("Download fehlgeschlagen.")); | |
| }, | |
| [room, plan], | |
| ); | |
| // Raumtyp-Vorwahl (Schritt 1): steuert zugleich den Scan-Raumtyp. | |
| const vorwahlSetzen = useCallback((typ: RaumtypKey) => { | |
| setRaumtypVorwahl(typ); | |
| setScanRoomType(typ); | |
| }, []); | |
| const gewaehltesItem = | |
| plan && gewaehltId | |
| ? catalog.find( | |
| (c) => c.id === plan.placements.find((p) => p.id === gewaehltId)?.catalogItemId, | |
| ) | |
| : null; | |
| // Manuelle Flächen schlagen die Kurator-/Stil-Optik (Welle 3, «Manuell gewinnt»). | |
| const effektiveFlaechen = manuelleFlaechen ?? flaechen; | |
| // Wand-Zeilen fürs Flächen-Panel (Welle C) + aufgelöste 2D-Flächen-Darstellung – | |
| // dieselbe Quelle wie 3D (Stil → Varianten → Kurator/manuelle Overrides). | |
| const wandInfos = useMemo( | |
| () => (aktuellerRaum ? beschreibeWaende(aktuellerRaum) : []), | |
| [aktuellerRaum], | |
| ); | |
| const flaechenAnzeige = useMemo( | |
| () => | |
| aktuellerRaum | |
| ? loeseFlaechen2D( | |
| aktuellerRaum.roomType, | |
| stilprofil ?? null, | |
| oberflaechenWahl, | |
| effektiveFlaechen, | |
| aktuellerRaum.shell.walls.length, | |
| ) | |
| : null, | |
| [aktuellerRaum, stilprofil, oberflaechenWahl, effektiveFlaechen], | |
| ); | |
| // Aktive Farbvariante des gewählten Objekts (MANUELL > KI > Default) – für die | |
| // Markierung im Farb-Picker. | |
| const gewaehltesPlacement = | |
| plan && gewaehltId ? plan.placements.find((p) => p.id === gewaehltId) : null; | |
| const farbAktiv = gewaehltesItem | |
| ? loeseFarbSlug( | |
| gewaehltesItem.farbVarianten, | |
| gewaehltesPlacement?.farbe, | |
| gewaehltId ? farbWahl[gewaehltId] : undefined, | |
| ) | |
| : undefined; | |
| // Tausch-Alternativen: gleicher Funktionstyp, gleiche Normprofil-Variante | |
| // (Küchenzeile bleibt konsistent), ohne das aktuelle Item selbst. | |
| const alternativen = gewaehltesItem | |
| ? catalog.filter( | |
| (c) => | |
| c.funktionsTyp === gewaehltesItem.funktionsTyp && | |
| c.id !== gewaehltesItem.id && | |
| (c as { normProfileVariante?: string }).normProfileVariante === | |
| (gewaehltesItem as { normProfileVariante?: string }).normProfileVariante, | |
| ) | |
| : []; | |
| // Oberflächen-Karte (Schritt 4): angebotene Varianten + effektive Spez des | |
| // aktuellen Raums (Stil-Ableitung, lokal von der Nutzerwahl überschrieben). | |
| const oberflaechenVarianten = aktuellerRaum ? variantenFuer(aktuellerRaum.roomType) : null; | |
| const aktuelleSpez = aktuellerRaum | |
| ? wendeVariantenAn( | |
| leiteOberflaechen(stilprofil ?? null, aktuellerRaum.roomType), | |
| aktuellerRaum.roomType, | |
| oberflaechenWahl, | |
| ) | |
| : null; | |
| // Gesperrt-/Normstatus des aktuell gewählten Placements (Schritt 4). | |
| const gesperrt = !!(plan && gewaehltId | |
| ? plan.placements.find((p) => p.id === gewaehltId)?.locked | |
| : false); | |
| const elementStatus = gewaehltId ? statusById.get(gewaehltId) : undefined; | |
| // Korrektur-Modus erneut öffnen – nur für gescannte Räume (captureMethod "ar"). | |
| const korrigierbar = | |
| !!room && (room.meta as { captureMethod?: string } | undefined)?.captureMethod === "ar"; | |
| // Erreichbarkeit der Schritte: 1/2 immer, 3 nur mit Raum, 4/5 nur mit Plan. | |
| const erreichbar = (nr: number) => (nr <= 2 ? true : nr === 3 ? !!room : !!plan); | |
| // Am weitesten freigeschalteter Schritt (für den Fortschrittsweg): mit Plan | |
| // sind alle 5 offen, mit Raum bis 3, sonst bis 2. | |
| const maxErreicht = plan ? 5 : room ? 3 : 2; | |
| // Viewer-Bereich (Schritt 4): 2D/3D-Umschalter + Viewer in fester Höhe, damit | |
| // der Canvas verlässlich füllt (kein Flex/Grid-Höhenproblem im fp-two-column). | |
| const viewerBereich = ( | |
| <div style={{ display: "flex", flexDirection: "column", height: "min(72vh, 640px)" }}> | |
| <div | |
| style={{ | |
| display: "flex", | |
| gap: 8, | |
| alignItems: "center", | |
| padding: "0 0 8px", | |
| flexWrap: "wrap", | |
| }} | |
| > | |
| <button | |
| type="button" | |
| className={CSS.button} | |
| onClick={() => { | |
| setAnsicht((a) => (a === "2d" ? "3d" : "2d")); | |
| setFlaeche(null); // Oberflächen-Karte gehört zum 3D-Kontext. | |
| }} | |
| style={{ | |
| borderRadius: 999, | |
| padding: "7px 14px", | |
| cursor: "pointer", | |
| border: `1px solid ${THEME.gruen}`, | |
| background: "#fff", | |
| color: THEME.gruen, | |
| }} | |
| > | |
| {ansicht === "2d" ? "🧊 3D-Ansicht" : "🗺️ 2D-Grundriss"} | |
| </button> | |
| {plan && ( | |
| <span style={{ fontSize: 12, color: THEME.salbei }}> | |
| Seed {plan.meta.seed} · Solver {plan.meta.solverVersion} | |
| </span> | |
| )} | |
| </div> | |
| <div className={CSS.card} style={{ flex: 1, minHeight: 0, overflow: "hidden", padding: 6 }}> | |
| {aktuellerRaum && | |
| (ansicht === "2d" ? ( | |
| <Viewer2D | |
| room={aktuellerRaum} | |
| placements={plan?.placements ?? []} | |
| catalog={catalog} | |
| rules={rules} | |
| gewaehltId={gewaehltId} | |
| statusById={statusById} | |
| onSelect={waehleItem} | |
| onMove={verschiebeNach} | |
| onRotate={rotiereNach} | |
| interaktiv | |
| flaechen2d={flaechenAnzeige} | |
| hervorgehobeneWand={flaeche === "wand" ? gewaehlteWand : null} | |
| /> | |
| ) : ( | |
| <Viewer3D | |
| room={aktuellerRaum} | |
| placements={plan?.placements ?? []} | |
| catalog={catalog} | |
| gewaehltId={gewaehltId} | |
| statusById={statusById} | |
| onSelect={waehleItem} | |
| stilprofil={stilprofil} | |
| plan={plan} | |
| dressingItems={dressingItems} | |
| interaktiv | |
| gewaehlteFlaeche={flaeche} | |
| onSelectFlaeche={waehleFlaeche} | |
| oberflaechenWahl={oberflaechenWahl} | |
| flaechen={effektiveFlaechen} | |
| farbWahl={farbWahl} | |
| modus={viewer3dModus} | |
| onModus={setViewer3dModus} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| // Einstieg: Splash → Startmenü, erst danach der Wizard. Hooks oben bleiben | |
| // unverändert aktiv (Daten laden schon im Hintergrund). | |
| if (phase === "splash") return <Splash onFertig={() => setPhase("menue")} />; | |
| if (phase === "menue") return <StartMenue onStart={() => setPhase("app")} />; | |
| return ( | |
| <> | |
| <AppRahmen | |
| projektName={room?.name} | |
| aktuellerSchritt={schritt} | |
| maxErreichterSchritt={maxErreicht} | |
| onSchrittWechsel={setSchritt} | |
| hinweis={meldung || undefined} | |
| onZurueck={() => | |
| // Auf Schritt 1 führt «Zurück» zurück zur Titelseite (Startmenü), | |
| // sonst einen Wizard-Schritt zurück (Bryan 2026-07-07). | |
| schritt === 1 ? setPhase("menue") : setSchritt((s) => Math.max(1, s - 1)) | |
| } | |
| onWeiter={() => setSchritt((s) => Math.min(5, s + 1))} | |
| zurueckLabel={schritt === 1 ? "← Zur Startseite" : "← Zurück"} | |
| weiterDeaktiviert={schritt === 5 || !erreichbar(schritt + 1)} | |
| weiterLabel={schritt === 4 ? "Zur Auswertung" : "Weiter"} | |
| > | |
| {backend === "wacht" && <Ladezustand variante="wecken" />} | |
| {backend === "nicht-erreichbar" && ( | |
| <div className={CSS.card} role="alert" style={{ padding: 16, marginBottom: 12 }}> | |
| <p style={{ margin: "0 0 12px" }}> | |
| Der Server antwortet gerade nicht. Das passiert selten nach längerer Pause oder | |
| während eines Updates. | |
| </p> | |
| <button | |
| type="button" | |
| className={CSS.button} | |
| onClick={() => { | |
| setBackend("unbekannt"); | |
| ladeBeispielraeume(); | |
| }} | |
| > | |
| Erneut versuchen | |
| </button> | |
| </div> | |
| )} | |
| {schritt === 1 && ( | |
| <div style={{ flex: 1, minHeight: 0, overflow: "auto" }}> | |
| <SchrittProjekt | |
| rooms={rooms} | |
| room={room} | |
| vorwahl={raumtypVorwahl} | |
| onVorwahl={vorwahlSetzen} | |
| onRaumWaehlen={(r) => void raumWaehlen(r)} | |
| onScanDatei={(f) => void scanLaden(f)} | |
| onEditorOeffnen={() => setEditorOffen(true)} | |
| onKorrigieren={() => room && setKorrektur({ room, warnungen: [] })} | |
| korrigierbar={korrigierbar} | |
| ladenScan={ladenScan} | |
| /> | |
| </div> | |
| )} | |
| {schritt === 2 && ( | |
| <div style={{ flex: 1, minHeight: 0, overflow: "auto" }}> | |
| <SchrittStil | |
| room={room} | |
| bilder={bilder} | |
| achsen={achsen} | |
| stilprofil={stilprofil} | |
| onProfil={stilBerechnen} | |
| onZwischenProfil={stilZwischenprofil} | |
| onUeberspringen={() => setSchritt(3)} | |
| ladenStil={ladenStil} | |
| /> | |
| </div> | |
| )} | |
| {schritt === 3 && ( | |
| <div style={{ flex: 1, minHeight: 0, overflow: "auto" }}> | |
| <SchrittVorschlag | |
| room={room} | |
| aktuellerRaum={aktuellerRaum} | |
| catalog={catalog} | |
| plan={plan} | |
| report={report} | |
| statusById={statusById} | |
| stilprofil={stilprofil} | |
| begruendung={begruendung} | |
| konzept={konzept} | |
| seed={seed} | |
| ladenVorschlag={ladenVorschlag} | |
| istKueche={kuecheInfo.istKueche} | |
| grossraum={!!kuecheInfo.zoneId} | |
| normProfile={normProfile} | |
| onNormProfile={(np) => { | |
| setNormProfile(np); | |
| setFormen(null); | |
| setForm(null); | |
| }} | |
| formen={formen} | |
| form={form} | |
| onFormWaehlen={setForm} | |
| onFormenLaden={() => void formenLaden()} | |
| onVorschlagen={() => void loesen(seed)} | |
| onNeueVariante={() => void loesen(seed + 1)} | |
| onAnpassen={() => setSchritt(4)} | |
| variantenPlaene={variantenPlaene} | |
| aktiveVariante={aktiveVariante} | |
| onVariante={uebernehmeVariante} | |
| /> | |
| </div> | |
| )} | |
| {schritt === 4 && ( | |
| <div style={{ flex: 1, minHeight: 0, overflow: "auto" }}> | |
| <SchrittAnpassen | |
| viewer={viewerBereich} | |
| gewaehltesItem={gewaehltesItem ?? null} | |
| elementStatus={elementStatus} | |
| gesperrt={gesperrt} | |
| alternativen={alternativen} | |
| stilprofil={stilprofil} | |
| onTausch={tauscheItem} | |
| onSperren={sperren} | |
| onWuerfeln={() => void loesen(seed + 1)} | |
| report={report} | |
| begruendung={begruendung} | |
| flaeche={flaeche} | |
| oberflaechenVarianten={oberflaechenVarianten} | |
| oberflaechenWahl={oberflaechenWahl} | |
| aktuelleSpez={aktuelleSpez} | |
| onFlaecheVariante={flaecheVariante} | |
| farbVarianten={gewaehltesItem?.farbVarianten} | |
| farbAktiv={farbAktiv} | |
| onFarbe={(slug) => gewaehltId && setzeFarbe(gewaehltId, slug)} | |
| flaechenKonzept={effektiveFlaechen} | |
| onFlaechenMaterial={(art, slug, wandIndex, bereich) => | |
| void waehleFlaechenMaterial(art, slug, wandIndex, bereich) | |
| } | |
| wandInfos={wandInfos} | |
| gewaehlteWand={gewaehlteWand} | |
| onWandWahl={setGewaehlteWand} | |
| onFlaecheWaehlen={waehleFlaeche} | |
| /> | |
| </div> | |
| )} | |
| {schritt === 5 && ( | |
| <div style={{ flex: 1, minHeight: 0, overflow: "auto" }}> | |
| <SchrittAuswertung | |
| room={room} | |
| plan={plan} | |
| kv={kv} | |
| dreieck={dreieck} | |
| normOk={!!report?.hard.ok} | |
| ladenDokumente={ladenDokumente} | |
| onDokument={dokumentLaden} | |
| /> | |
| </div> | |
| )} | |
| </AppRahmen> | |
| {editorOffen && ( | |
| <RaumEditor | |
| onAbbruch={() => setEditorOffen(false)} | |
| onFertig={(neu) => { | |
| // Gleicher Klickpfad wie scanLaden: Raum in die Liste, dann wählen. | |
| setRooms((prev) => [neu, ...prev.filter((r) => r.id !== neu.id)]); | |
| void raumWaehlen(neu); | |
| setEditorOffen(false); | |
| setMeldung("Raum erstellt."); | |
| }} | |
| /> | |
| )} | |
| {korrektur && ( | |
| <ScanKorrektur | |
| room={korrektur.room} | |
| warnungen={korrektur.warnungen} | |
| onAbbruch={() => { | |
| // Abbrechen: Scan trotzdem UNKORRIGIERT laden (geometryConfirmed bleibt | |
| // false → Konfidenz-Ampel rechnet weiter mit Messunsicherheit). | |
| const neu = korrektur.room; | |
| setRooms((prev) => [neu, ...prev.filter((r) => r.id !== neu.id)]); | |
| void raumWaehlen(neu); | |
| setKorrektur(null); | |
| setMeldung("Scan unkorrigiert übernommen."); | |
| }} | |
| onFertig={(neu) => { | |
| // Korrigierten Raum in die Liste (gleiche id ersetzt) und wählen. | |
| setRooms((prev) => [neu, ...prev.filter((r) => r.id !== neu.id)]); | |
| void raumWaehlen(neu); | |
| setKorrektur(null); | |
| setMeldung("Scan korrigiert übernommen."); | |
| }} | |
| /> | |
| )} | |
| </> | |
| ); | |
| } | |