diff --git a/src/commands/cmds/ceilingOpening.ts b/src/commands/cmds/ceilingOpening.ts new file mode 100644 index 0000000..baf520d --- /dev/null +++ b/src/commands/cmds/ceilingOpening.ts @@ -0,0 +1,133 @@ +// Deckenloch — Aussparung (Treppenauge/Schacht) als Engine-Befehl: ein RECHTECK +// wird aufgezogen (zwei Klicks: erste Ecke → Gegenecke) und der Decke des +// aktiven Geschosses hinzugefügt, die das Rechteck ENTHÄLT (alle vier Ecken +// innerhalb des Decken-Umrisses). Gerendert wird das Loch in 2D (Poché-Loch + +// Randlinien) und 3D (Loch im Slab), s. Ceiling.openings/mergeHoles. +// +// Schritte: +// 1) „Erste Ecke der Aussparung:" → Punkt +// 2) „Gegenecke:" → Live-Rechteck-Vorschau; Klick committet. + +import { pointInOutline } from "../../geometry/ceiling"; +import type { + Command, + CommandContext, + CommandResult, + CommandState, + DraftShape, + Project, + ToolDraft, + Vec2, +} from "../types"; + +/** Vier Eckpunkte eines Rechtecks aus zwei Gegenecken (CCW). */ +function rectCorners(a: Vec2, b: Vec2): Vec2[] { + const x0 = Math.min(a.x, b.x); + const x1 = Math.max(a.x, b.x); + const y0 = Math.min(a.y, b.y); + const y1 = Math.max(a.y, b.y); + return [ + { x: x0, y: y0 }, + { x: x1, y: y0 }, + { x: x1, y: y1 }, + { x: x0, y: y1 }, + ]; +} + +interface HoleIdle extends CommandState { + phase: "start"; +} +interface HoleRect extends CommandState { + phase: "corner"; + a: Vec2; + cursor: Vec2 | null; +} +type HoleState = HoleIdle | HoleRect; + +/** Vorschau: aufgezogenes Rechteck + Masse im HUD. */ +function holeDraft(a: Vec2, cursor: Vec2 | null): ToolDraft { + const pts = cursor ? rectCorners(a, cursor) : [a]; + const preview: DraftShape[] = [{ kind: "poly", pts, closed: !!cursor }]; + const draft: ToolDraft = { preview, vertices: [a] }; + if (cursor) { + draft.hud = { + at: cursor, + text: `${Math.abs(cursor.x - a.x).toFixed(2)} × ${Math.abs(cursor.y - a.y).toFixed(2)} m`, + }; + } + return draft; +} + +/** + * Fügt die Aussparung der ERSTEN Decke des aktiven Geschosses hinzu, die das + * Rechteck vollständig enthält (alle 4 Ecken im Umriss). Entartete Rechtecke + * und Rechtecke ausserhalb jeder Decke werden verworfen (Projekt unverändert). + */ +function appendCeilingOpening(p: Project, a: Vec2, b: Vec2, ctx: CommandContext): Project { + const w = Math.abs(b.x - a.x); + const d = Math.abs(b.y - a.y); + if (w < 0.05 || d < 0.05) return p; + const corners = rectCorners(a, b); + const host = (p.ceilings ?? []).find( + (c) => + c.floorId === ctx.level.id && + c.outline.length >= 3 && + corners.every((pt) => pointInOutline(pt, c.outline)), + ); + if (!host) return p; + return { + ...p, + ceilings: (p.ceilings ?? []).map((c) => + c.id === host.id ? { ...c, openings: [...(c.openings ?? []), corners] } : c, + ), + }; +} + +const idle = (): [CommandState, CommandResult] => [ + { phase: "start", lastPoint: null } as HoleIdle, + { draft: null, done: true }, +]; + +export const ceilingOpeningCommand: Command = { + name: "ceilingopening", + labelKey: "cmd.ceilingOpening.label", + floorOnly: true, + prompt: (s) => + (s as HoleState).phase === "corner" ? "cmd.ceilingOpening.corner" : "cmd.ceilingOpening.start", + accepts: () => ["point"], + options: () => [], + init: (): HoleIdle => ({ phase: "start", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as HoleState; + if (input.kind !== "point") { + return [s, { draft: s.phase === "corner" ? holeDraft(s.a, s.cursor) : null }]; + } + if (s.phase !== "corner") { + const ns: HoleRect = { + phase: "corner", + a: input.point, + cursor: input.point, + lastPoint: input.point, + }; + return [ns, { draft: holeDraft(input.point, input.point) }]; + } + // Zweite Ecke → committen. + const a = s.a; + const b = input.point; + return [ + { phase: "start", lastPoint: null } as HoleIdle, + { draft: null, done: true, commit: (p) => appendCeilingOpening(p, a, b, ctx) }, + ]; + }, + + onMove: (state, point): [CommandState, CommandResult] => { + const s = state as HoleState; + if (s.phase !== "corner") return [s, { draft: null }]; + const ns: HoleRect = { ...s, cursor: point }; + return [ns, { draft: holeDraft(s.a, point) }]; + }, + + onConfirm: (): [CommandState, CommandResult] => idle(), + onCancel: (): [CommandState, CommandResult] => idle(), +}; diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 9b4c366..581dd03 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -12,6 +12,7 @@ import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening"; import { stairCommand } from "./cmds/stair"; import { columnCommand } from "./cmds/column"; import { roofCommand } from "./cmds/roof"; +import { ceilingOpeningCommand } from "./cmds/ceilingOpening"; import { roomCommand } from "./cmds/room"; import { rectCommand } from "./cmds/rect"; import { circleCommand } from "./cmds/circle"; @@ -39,6 +40,7 @@ export const COMMANDS: Record = { stair: stairCommand, column: columnCommand, roof: roofCommand, + ceilingopening: ceilingOpeningCommand, room: roomCommand, line: lineCommand, polyline: polylineCommand, @@ -78,6 +80,8 @@ export const ALIASES: Record = { stuetze: "column", stütze: "column", dach: "roof", + deckenloch: "ceilingopening", + aussparung: "ceilingopening", rf: "roof", raum: "room", rm: "room", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 8e1c3cf..18394c6 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -963,6 +963,9 @@ export const de = { "cmd.roof.label": "Dach", "cmd.roof.start": "Erste Dachecke:", "cmd.roof.corner": "Gegenecke:", + "cmd.ceilingOpening.label": "Deckenloch", + "cmd.ceilingOpening.start": "Erste Ecke der Aussparung (in einer Decke):", + "cmd.ceilingOpening.corner": "Gegenecke:", "cmd.roof.shape.sattel": "Satteldach", "cmd.roof.shape.walm": "Walmdach", "cmd.roof.shape.pult": "Pultdach", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index fd5eef2..002de2a 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -953,6 +953,9 @@ export const en: Record = { "cmd.roof.label": "Roof", "cmd.roof.start": "First roof corner:", "cmd.roof.corner": "Opposite corner:", + "cmd.ceilingOpening.label": "Slab opening", + "cmd.ceilingOpening.start": "First corner of the opening (inside a slab):", + "cmd.ceilingOpening.corner": "Opposite corner:", "cmd.roof.shape.sattel": "Gable roof", "cmd.roof.shape.walm": "Hip roof", "cmd.roof.shape.pult": "Mono-pitch roof", diff --git a/src/ui/ribbon/CommandIcon.tsx b/src/ui/ribbon/CommandIcon.tsx index 7e253e5..a81d652 100644 --- a/src/ui/ribbon/CommandIcon.tsx +++ b/src/ui/ribbon/CommandIcon.tsx @@ -108,6 +108,14 @@ export function CommandIcon({ name }: { name: string }) { ); + case "ceilingopening": + // Deckenloch: Decken-Rechteck mit ausgespartem Innenrechteck. + return ( + + + + + ); default: return null; } diff --git a/src/ui/ribbon/ribbonItems.ts b/src/ui/ribbon/ribbonItems.ts index b05191b..5fefe0a 100644 --- a/src/ui/ribbon/ribbonItems.ts +++ b/src/ui/ribbon/ribbonItems.ts @@ -105,6 +105,7 @@ export const RIBBON_TABS: RibbonTab[] = [ t("stair"), c("column"), t("ceiling"), + c("ceilingopening"), c("roof"), t("room"), ],