/** Manueller Raum-Editor (dritte Erstellungsvariante, Brain: Raum-Editor-Manuell). * * Formulargetriebene Eingabe von Form, Massen, Öffnungen und Anschlüssen mit * SVG-Live-Vorschau (Draufsicht x/z). Ergebnis = schema-valides Raummodell, * das per `onFertig` in den bestehenden Klickpfad (wie Scan/Sample) geht. * * Reine Bau-Logik liegt in raumbau.ts (getestet); diese Datei ist nur UI. * Die toScreen-Mathematik wird aus plan2d wiederverwendet (Solver-Konvention). */ import { useMemo, useRef, useState } from "react"; import type { Vec2 } from "@fp/shared/rules"; import { computeTransform, toScreen, toWorld } from "./plan2d.ts"; import { karte as ciKarte, pill, THEME, titel } from "./theme"; import { anschlussKuerzel, baueRaummodell, fehlendeAnschluesse, grundrissPunkte, OEFFNUNG_DEFAULT, pruefeOeffnung, RAUMHOEHE_DEFAULT, wandLaenge, type Anschlusstyp, type FixpunktSpec, type Grundform, type LFormParams, type OeffnungsSpec, type Raummodell, type RaumTyp, } from "./raumbau"; const SIZE = 460; const PAD = 56; const FARBE_TUER = THEME.orange; const FARBE_FENSTER = THEME.blau; const FARBE_WAND = THEME.gruen; const FARBE_FIX = "#8a2ec9"; const ANSCHLUSS_TYPEN: Anschlusstyp[] = ["wasser", "abwasser", "elektro", "starkstrom", "lueftung"]; const stil = { overlay: { position: "fixed" as const, inset: 0, background: "#000000aa", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 10, }, // CI-Kartenstil (innerer Schein + harter Schlagschatten) mit Editor-Layout. karte: { ...ciKarte, padding: 20, width: 980, maxWidth: "94vw", maxHeight: "92vh", overflowY: "auto" as const, display: "grid", gridTemplateColumns: `${SIZE}px minmax(0, 1fr)`, gap: 20, fontFamily: "system-ui, sans-serif", }, knopf: pill, feld: { width: 70 }, zeile: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" as const, marginBottom: 6, }, } as const; /** Aktives Werkzeug: bestimmt, was ein Klick auf eine Wand erzeugt. */ type Werkzeug = | { art: "keins" } | { art: "tuer" } | { art: "fenster" } | { art: "fix"; typ: Anschlusstyp }; interface Wand { start: Vec2; end: Vec2; laenge: number; } /** Projektion eines Welt-Punkts auf das Wand-Segment → {offset ab Start, Abstand}. */ function projiziere(punkt: Vec2, wand: Wand): { offset: number; dist: number } { const dx = wand.end[0] - wand.start[0]; const dz = wand.end[1] - wand.start[1]; const len2 = dx * dx + dz * dz || 1; let t = ((punkt[0] - wand.start[0]) * dx + (punkt[1] - wand.start[1]) * dz) / len2; t = Math.max(0, Math.min(1, t)); const px = wand.start[0] + dx * t; const pz = wand.start[1] + dz * t; return { offset: t * wand.laenge, dist: Math.hypot(punkt[0] - px, punkt[1] - pz) }; } /** Nächste Wand zu einem Welt-Klick (oder null, wenn zu weit weg). */ function naechsteWand(punkt: Vec2, waende: Wand[]): { index: number; offset: number } | null { // for-Schleife statt forEach: TS kann Closure-Zuweisungen nicht narrowen. let best: { index: number; offset: number; dist: number } | null = null; for (let index = 0; index < waende.length; index++) { const w = waende[index]; if (!w) continue; const { offset, dist } = projiziere(punkt, w); if (best === null || dist < best.dist) best = { index, offset, dist }; } return best !== null && best.dist < 0.5 ? { index: best.index, offset: best.offset } : null; } export function RaumEditor({ onFertig, onAbbruch, }: { onFertig: (room: Raummodell) => void; onAbbruch: () => void; }) { const [name, setName] = useState("Mein Raum"); const [roomType, setRoomType] = useState("bad"); const [form, setForm] = useState("rechteck"); const [breite, setBreite] = useState(3); const [tiefe, setTiefe] = useState(2.5); const [ausschnittBreite, setAusschnittBreite] = useState(1); const [ausschnittTiefe, setAusschnittTiefe] = useState(1); const [raumhoehe, setRaumhoehe] = useState(RAUMHOEHE_DEFAULT); const [oeffnungen, setOeffnungen] = useState([]); const [fixpunkte, setFixpunkte] = useState([]); const [werkzeug, setWerkzeug] = useState({ art: "keins" }); const svgRef = useRef(null); const params: LFormParams = { breite, tiefe, ausschnittBreite, ausschnittTiefe }; // Formfehler blocken «Fertig» und die Vorschau-Interaktion. const formFehler = useMemo(() => { if (breite <= 0 || tiefe <= 0) return "Breite und Tiefe müssen > 0 sein."; if (form === "l-form") { if (ausschnittBreite <= 0 || ausschnittTiefe <= 0) return "Ausschnitt-Masse müssen > 0 sein."; if (ausschnittBreite >= breite || ausschnittTiefe >= tiefe) return "Ausschnitt muss kleiner als der Raum sein."; } return null; }, [breite, tiefe, form, ausschnittBreite, ausschnittTiefe]); const punkte = useMemo( () => (formFehler ? [] : grundrissPunkte(form, params)), [form, formFehler, breite, tiefe, ausschnittBreite, ausschnittTiefe], ); const waende = useMemo( () => punkte.map((start, i) => { // Modulo schliesst den Umlauf; ?? start nur für den strikten Indexzugriff. const end = punkte[(i + 1) % punkte.length] ?? start; return { start, end, laenge: wandLaenge(start, end) }; }), [punkte], ); const transform = useMemo( () => (punkte.length >= 3 ? computeTransform(punkte, SIZE, PAD) : null), [punkte], ); // Fehlende Pflicht-Anschlüsse (Solver-Hinweis für Bad/Küche). const fehlt = fehlendeAnschluesse( roomType, fixpunkte.map((f) => f.type), ); // Ungültige Öffnungen (ragen über die Wand hinaus) → blocken «Fertig». const oeffnungsFehler = oeffnungen.some((o) => { const w = waende[o.wandIndex]; return w ? pruefeOeffnung(o, w.laenge) !== null : true; }); const svgKlick = (e: React.MouseEvent) => { if (werkzeug.art === "keins" || !transform || !svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); const sx = ((e.clientX - rect.left) * SIZE) / rect.width; const sy = ((e.clientY - rect.top) * SIZE) / rect.height; const welt = toWorld([sx, sy], transform); const treffer = naechsteWand(welt, waende); if (!treffer) return; const wand = waende[treffer.index]; if (!wand) return; if (werkzeug.art === "tuer" || werkzeug.art === "fenster") { const def = werkzeug.art === "tuer" ? OEFFNUNG_DEFAULT.door : OEFFNUNG_DEFAULT.window; // Öffnung um den Klick zentrieren und in die Wand einpassen. const offset = Math.max(0, Math.min(wand.laenge - def.width, treffer.offset - def.width / 2)); setOeffnungen((prev) => [ ...prev, { wandIndex: treffer.index, type: werkzeug.art === "tuer" ? "door" : "window", offset: Math.round(offset * 100) / 100, width: def.width, height: def.height, sill: def.sill, }, ]); } else { setFixpunkte((prev) => [ ...prev, { wandIndex: treffer.index, type: werkzeug.typ, offset: Math.round(treffer.offset * 100) / 100, heightFromFloor: 0.3, }, ]); } }; const fertig = () => { if (formFehler || oeffnungsFehler) return; onFertig(baueRaummodell({ name, roomType, form, params, raumhoehe, oeffnungen, fixpunkte })); }; // --- Vorschau-Elemente --------------------------------------------------- const punktAufWandScreen = (wand: Wand, offset: number): Vec2 => { const t = wand.laenge ? offset / wand.laenge : 0; return [ wand.start[0] + (wand.end[0] - wand.start[0]) * t, wand.start[1] + (wand.end[1] - wand.start[1]) * t, ]; }; return (
e.stopPropagation()}> {/* Linke Spalte: SVG-Vorschau */}

Vorschau (Draufsicht)

{transform && punkte.length >= 3 && ( <> {/* Bodenfläche */} toScreen(p, transform).join(",")).join(" ")} fill="#ffffff" stroke="none" /> {/* Wände + Längen-Beschriftung + Wand-Index */} {waende.map((w, i) => { const [ax, ay] = toScreen(w.start, transform); const [bx, by] = toScreen(w.end, transform); const mx = (ax + bx) / 2; const my = (ay + by) / 2; return ( W{i} · {w.laenge.toFixed(2)} m ); })} {/* Öffnungen als farbige Segmente auf der Wand */} {oeffnungen.map((o, i) => { const w = waende[o.wandIndex]; if (!w) return null; const ungueltig = pruefeOeffnung(o, w.laenge) !== null; const [ax, ay] = toScreen(punktAufWandScreen(w, o.offset), transform); const [bx, by] = toScreen( punktAufWandScreen(w, Math.min(o.offset + o.width, w.laenge)), transform, ); return ( ); })} {/* Fixpunkte als Punkte mit Kürzel */} {fixpunkte.map((f, i) => { const w = waende[f.wandIndex]; if (!w) return null; const [px, py] = toScreen(punktAufWandScreen(w, f.offset), transform); return ( {anschlussKuerzel(f.type)} ); })} )} {formFehler && ( {formFehler} )}

Werkzeug wählen, dann auf eine Wand klicken. Tür = orange, Fenster = blau, Anschluss = violetter Punkt (W/A/E/S/L).

{/* Rechte Spalte: Eingaben */}

✏️ Raum selbst erstellen

{form === "l-form" && (
Ausschnitt (Ecke max x / max z):
)} {/* Werkzeugleiste */}
Einzeichnen: {( [ { art: "tuer", label: "🚪 Tür", farbe: FARBE_TUER }, { art: "fenster", label: "🪟 Fenster", farbe: FARBE_FENSTER }, ] as const ).map((wz) => ( ))}
Anschluss: {ANSCHLUSS_TYPEN.map((typ) => ( ))}
{fehlt.length > 0 && (

⚠ Solver braucht {fehlt.join(" + ")} – bitte als Anschluss setzen.

)} {/* Öffnungs-Liste */} {oeffnungen.length > 0 && (

Öffnungen

{oeffnungen.map((o, i) => { const w = waende[o.wandIndex]; const fehler = w ? pruefeOeffnung(o, w.laenge) : "Wand fehlt"; const setze = (patch: Partial) => setOeffnungen((prev) => prev.map((x, j) => (j === i ? { ...x, ...patch } : x))); return (
{o.type === "door" ? "🚪 Tür" : "🪟 Fenster"} W{o.wandIndex} {fehler && {fehler}}
); })}
)} {/* Fixpunkt-Liste */} {fixpunkte.length > 0 && (

Anschlüsse

{fixpunkte.map((f, i) => { const setze = (patch: Partial) => setFixpunkte((prev) => prev.map((x, j) => (j === i ? { ...x, ...patch } : x))); return (
{f.type} W{f.wandIndex}
); })}
)}
); }