// Opening — Öffnung (Fenster/Tür) als Engine-Befehl. Sie wird in eine BESTEHENDE // Wand gehostet. Ablauf: // 1) „Wand für die Öffnung wählen:" → Klick auf/nahe eine Wand (Host). // 2) „Position entlang der Wand:" → Klick auf der Wandachse ODER getippter // Abstand vom Wand-Startpunkt (Zahl). Die Position wird auf die Achse // projiziert; der HUD zeigt den Abstand vom Wandanfang. // 3) Commit: hängt eine `Opening` ans Projekt. // // Tab-Felder im Positionsschritt: Breite / Höhe / Brüstung (sillHeight) / // Schwenkwinkel (nur Tür). Optionen: Fenster⇄Tür-Umschalter, Anschlag // (Scharnier start/end), Aufschlagseite (links/rechts), Richtung (innen/außen). // // Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md). import type { Opening } from "../../model/types"; import { uniqueId } from "../../tools/types"; import type { Command, CommandContext, CommandField, CommandResult, CommandState, CmdOption, DraftShape, Project, ToolDraft, Vec2, } from "../types"; /** Default-Ebene (Kategorie) für Öffnungen: „21" Türen/Fenster (Fallback: aktive). */ const OPENING_CATEGORY = "21"; /** Toleranz (Modell-Meter) für das Picken einer Host-Wand per Klick. */ const WALL_PICK_DIST = 0.6; /** Default-Maße (Meter). */ const DEF_WINDOW = { width: 1.2, height: 1.2, sill: 0.9 }; const DEF_DOOR = { width: 0.9, height: 2.1, sill: 0 }; const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y }); const dot = (a: Vec2, b: Vec2): number => a.x * b.x + a.y * b.y; const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y }); const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s }); /** Existiert die Öffnungs-Kategorie („21")? Sonst Fallback auf die aktive. */ function openingCategory(ctx: CommandContext): string { const flat: { code: string }[] = []; const walk = (list: { code: string; children?: unknown[] }[]) => { for (const c of list) { flat.push({ code: c.code }); if (c.children) walk(c.children as { code: string; children?: unknown[] }[]); } }; walk(ctx.project.layers as { code: string; children?: unknown[] }[]); return flat.some((c) => c.code === OPENING_CATEGORY) ? OPENING_CATEGORY : ctx.defaultCategoryCode; } /** Auf die Wandachse projizierter Fußpunkt + Parameter t (0..len) + Distanz. */ function projectOntoWall( wall: { start: Vec2; end: Vec2 }, p: Vec2, ): { t: number; foot: Vec2; dist: number } { const ax = sub(wall.end, wall.start); const len2 = dot(ax, ax) || 1e-9; const raw = dot(sub(p, wall.start), ax) / len2; // 0..1 const t = Math.max(0, Math.min(1, raw)); const foot = add(wall.start, scale(ax, t)); const dist = Math.hypot(p.x - foot.x, p.y - foot.y); return { t: t * Math.sqrt(len2), foot, dist }; } /** Nächste Wand auf dem aktiven Geschoss zum Klickpunkt (innerhalb Toleranz). */ function pickWall( ctx: CommandContext, p: Vec2, ): { id: string; start: Vec2; end: Vec2 } | null { let best: { id: string; start: Vec2; end: Vec2; d: number } | null = null; for (const w of ctx.project.walls) { if (w.floorId !== ctx.level.id) continue; const { dist } = projectOntoWall(w, p); if (dist <= WALL_PICK_DIST && (!best || dist < best.d)) { best = { id: w.id, start: w.start, end: w.end, d: dist }; } } return best ? { id: best.id, start: best.start, end: best.end } : null; } /** Tab-Felder im Positionsschritt: Breite/Höhe/Brüstung (+ Winkel bei Tür). */ const BASE_FIELDS: CommandField[] = [ { id: "width", labelKey: "cmd.field.width" }, { id: "height", labelKey: "cmd.field.height" }, { id: "sill", labelKey: "cmd.opening.sillField" }, ]; const SWING_FIELD: CommandField = { id: "swing", labelKey: "cmd.opening.swingField" }; const KIND: CmdOption = { id: "kind", labelKey: "cmd.opening.kind", value: "window" }; const HINGE: CmdOption = { id: "hinge", labelKey: "cmd.opening.hinge", value: "start" }; const SIDE: CmdOption = { id: "side", labelKey: "cmd.opening.side", value: "left" }; const DIR: CmdOption = { id: "dir", labelKey: "cmd.opening.dir", value: "in" }; interface OpIdle extends CommandState { phase: "wall"; kind: "window" | "door"; hinge: "start" | "end"; side: "left" | "right"; dir: "in" | "out"; width: number; height: number; sill: number; swingAngle: number; } interface OpPos extends CommandState { phase: "pos"; wallId: string; start: Vec2; end: Vec2; cursor: Vec2 | null; posAlong: number; // Abstand vom Wandanfang (Meter) kind: "window" | "door"; hinge: "start" | "end"; side: "left" | "right"; dir: "in" | "out"; width: number; height: number; sill: number; swingAngle: number; } type OpState = OpIdle | OpPos; function initState(kind: "window" | "door" = "window"): OpIdle { const def = kind === "door" ? DEF_DOOR : DEF_WINDOW; return { phase: "wall", lastPoint: null, kind, hinge: "start", side: "left", dir: "in", width: def.width, height: def.height, sill: def.sill, swingAngle: 90, }; } /** Vorschau: Lücken-Balken entlang der Wand + Positionsmarke. */ function posDraft(s: OpPos): ToolDraft { const ax = sub(s.end, s.start); const len = Math.hypot(ax.x, ax.y) || 1e-9; const u = { x: ax.x / len, y: ax.y / len }; const n = { x: -u.y, y: u.x }; const from = Math.max(0, Math.min(s.posAlong, len)); const to = Math.max(from, Math.min(from + s.width, len)); const a = add(s.start, scale(u, from)); const b = add(s.start, scale(u, to)); const half = 0.15; // Vorschau-Balken quer zur Wand const quad: Vec2[] = [ add(a, scale(n, half)), add(b, scale(n, half)), add(b, scale(n, -half)), add(a, scale(n, -half)), ]; const preview: DraftShape[] = [{ kind: "poly", pts: quad, closed: true }]; // Symbol-Andeutung: Fenster = Mittellinie, Tür = Blatt-Linie in Schwenkrichtung. if (s.kind === "window") { preview.push({ kind: "line", a, b }); } else { const swingSign = s.side === "left" ? 1 : -1; const dirSign = s.dir === "in" ? 1 : -1; const hinge = s.hinge === "start" ? a : b; const leafEnd = add(hinge, scale(n, swingSign * dirSign * s.width)); preview.push({ kind: "line", a: hinge, b: leafEnd }); } const draft: ToolDraft = { preview, vertices: [a, b] }; const mid = add(a, scale(u, (to - from) / 2)); draft.hud = { at: s.cursor ?? mid, text: `${from.toFixed(2)} m · ${s.kind === "door" ? "Tür" : "Fenster"} ${( s.width * 100 ).toFixed(0)}×${(s.height * 100).toFixed(0)}`, }; return draft; } /** Hängt eine Öffnung ans Projekt (immutabel). */ function appendOpening(p: Project, s: OpPos, ctx: CommandContext): Project { const len = Math.hypot(s.end.x - s.start.x, s.end.y - s.start.y); const width = Math.max(0.1, Math.min(s.width, len)); const position = Math.max(0, Math.min(s.posAlong, Math.max(0, len - width))); const opening: Opening = { id: uniqueId("O"), type: "opening", hostWallId: s.wallId, categoryCode: openingCategory(ctx), kind: s.kind, position, width, height: s.height, sillHeight: s.kind === "door" ? 0 : s.sill, ...(s.kind === "door" ? { hinge: s.hinge, swing: s.side, openingDir: s.dir, swingAngle: s.swingAngle } : {}), }; const openings = p.openings ? [...p.openings, opening] : [opening]; return { ...p, openings }; } const idle = (from?: OpState): [CommandState, CommandResult] => [ initState(from?.kind ?? "window"), { draft: null, done: true }, ]; export const openingCommand: Command = { name: "opening", labelKey: "cmd.opening.label", floorOnly: true, prompt: (s) => (s as OpState).phase === "pos" ? "cmd.opening.pos" : "cmd.opening.wall", accepts: (s) => (s as OpState).phase === "pos" ? ["point", "number", "option"] : ["point", "option"], options: (s): CmdOption[] => { const st = s as OpState; const kind: CmdOption = { ...KIND, value: st.kind }; if (st.kind === "door") { return [ kind, { ...HINGE, value: st.hinge }, { ...SIDE, value: st.side }, { ...DIR, value: st.dir }, ]; } return [kind]; }, init: (): OpIdle => initState("window"), onInput: (state, input, ctx): [CommandState, CommandResult] => { const s = state as OpState; if (input.kind === "option") { // Umschalter zyklen ihren Wert. Bei kind-Wechsel Default-Maße übernehmen. const next = { ...s } as OpState; if (input.id === "kind") { next.kind = s.kind === "window" ? "door" : "window"; const def = next.kind === "door" ? DEF_DOOR : DEF_WINDOW; next.width = def.width; next.height = def.height; next.sill = def.sill; } else if (input.id === "hinge") { next.hinge = s.hinge === "start" ? "end" : "start"; } else if (input.id === "side") { next.side = s.side === "left" ? "right" : "left"; } else if (input.id === "dir") { next.dir = s.dir === "in" ? "out" : "in"; } return [next, { draft: next.phase === "pos" ? posDraft(next as OpPos) : null }]; } if (input.kind === "number") { // Im Positionsschritt: getippte Zahl = Abstand vom Wandanfang (Meter). if (s.phase === "pos") { const ns: OpPos = { ...s, posAlong: input.value }; return [ns, { draft: posDraft(ns) }]; } return [s, { draft: null }]; } if (input.kind !== "point") { return [s, { draft: s.phase === "pos" ? posDraft(s as OpPos) : null }]; } const pt = input.point; if (s.phase === "wall") { const w = pickWall(ctx, pt); if (!w) return [s, { draft: null }]; // kein Treffer: Schritt wiederholen const proj = projectOntoWall(w, pt); const ns: OpPos = { phase: "pos", lastPoint: pt, wallId: w.id, start: w.start, end: w.end, cursor: pt, posAlong: proj.t, kind: s.kind, hinge: s.hinge, side: s.side, dir: s.dir, width: s.width, height: s.height, sill: s.sill, swingAngle: s.swingAngle, }; return [ns, { draft: posDraft(ns) }]; } // Positionsschritt: Klick projiziert auf die Achse → committen. const proj = projectOntoWall(s, pt); const ns: OpPos = { ...s, posAlong: proj.t, cursor: pt }; return [ idle(s)[0], { draft: null, done: true, commit: (p) => appendOpening(p, ns, ctx) }, ]; }, onMove: (state, point): [CommandState, CommandResult] => { const s = state as OpState; if (s.phase !== "pos") return [s, { draft: null }]; const proj = projectOntoWall(s, point); const ns: OpPos = { ...s, posAlong: proj.t, cursor: point }; return [ns, { draft: posDraft(ns) }]; }, onConfirm: (state, ctx): [CommandState, CommandResult] => { const s = state as OpState; if (s.phase === "pos") { const ns = s; return [ idle(s)[0], { draft: null, done: true, commit: (p) => appendOpening(p, ns, ctx) }, ]; } return idle(s); }, onCancel: (state): [CommandState, CommandResult] => idle(state as OpState), fields: (state) => { const s = state as OpState; if (s.phase !== "pos") return []; return s.kind === "door" ? [...BASE_FIELDS, SWING_FIELD] : BASE_FIELDS; }, // Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt. pointFromFields: (state, locks) => { const s = state as OpState; if ("width" in locks && locks.width > 0) s.width = locks.width; if ("height" in locks && locks.height > 0) s.height = locks.height; if ("sill" in locks && locks.sill >= 0) s.sill = locks.sill; if ("swing" in locks && locks.swing > 0) s.swingAngle = locks.swing; return null; }, fieldValues: (state): Record => { const s = state as OpState; const out: Record = { width: s.width, height: s.height, sill: s.sill, }; if (s.kind === "door") out.swing = s.swingAngle; return out; }, }; /** „Fenster"-Variante: identischer Befehl, startet aber im Fenster-Modus. */ export const fensterCommand: Command = { ...openingCommand, name: "fenster", labelKey: "cmd.opening.windowLabel", init: (): OpIdle => initState("window"), }; /** „Tür"-Variante: identischer Befehl, startet aber im Tür-Modus. */ export const tuerCommand: Command = { ...openingCommand, name: "tuer", labelKey: "cmd.opening.doorLabel", init: (): OpIdle => initState("door"), };