Befehl 'Deckenloch': Aussparung als Rechteck in eine Decke zeichnen

Zwei Klicks (Ecke → Gegenecke, Live-Vorschau + Mass-HUD); die Aussparung wird
der Decke des aktiven Geschosses hinzugefügt, die das Rechteck vollständig
enthält (alle 4 Ecken im Umriss), sonst No-op. Aliase 'deckenloch'/
'aussparung', BIM-Ribbon-Eintrag mit eigenem Icon. Komplettiert die Decken-
Aussparungen (cdbef99) um den Zeichenweg. 684/684 grün.
This commit is contained in:
2026-07-10 18:40:30 +02:00
parent cdbef992f3
commit aee884639e
6 changed files with 152 additions and 0 deletions
+133
View File
@@ -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(),
};
+4
View File
@@ -12,6 +12,7 @@ import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening";
import { stairCommand } from "./cmds/stair"; import { stairCommand } from "./cmds/stair";
import { columnCommand } from "./cmds/column"; import { columnCommand } from "./cmds/column";
import { roofCommand } from "./cmds/roof"; import { roofCommand } from "./cmds/roof";
import { ceilingOpeningCommand } from "./cmds/ceilingOpening";
import { roomCommand } from "./cmds/room"; import { roomCommand } from "./cmds/room";
import { rectCommand } from "./cmds/rect"; import { rectCommand } from "./cmds/rect";
import { circleCommand } from "./cmds/circle"; import { circleCommand } from "./cmds/circle";
@@ -39,6 +40,7 @@ export const COMMANDS: Record<string, Command> = {
stair: stairCommand, stair: stairCommand,
column: columnCommand, column: columnCommand,
roof: roofCommand, roof: roofCommand,
ceilingopening: ceilingOpeningCommand,
room: roomCommand, room: roomCommand,
line: lineCommand, line: lineCommand,
polyline: polylineCommand, polyline: polylineCommand,
@@ -78,6 +80,8 @@ export const ALIASES: Record<string, string> = {
stuetze: "column", stuetze: "column",
stütze: "column", stütze: "column",
dach: "roof", dach: "roof",
deckenloch: "ceilingopening",
aussparung: "ceilingopening",
rf: "roof", rf: "roof",
raum: "room", raum: "room",
rm: "room", rm: "room",
+3
View File
@@ -963,6 +963,9 @@ export const de = {
"cmd.roof.label": "Dach", "cmd.roof.label": "Dach",
"cmd.roof.start": "Erste Dachecke:", "cmd.roof.start": "Erste Dachecke:",
"cmd.roof.corner": "Gegenecke:", "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.sattel": "Satteldach",
"cmd.roof.shape.walm": "Walmdach", "cmd.roof.shape.walm": "Walmdach",
"cmd.roof.shape.pult": "Pultdach", "cmd.roof.shape.pult": "Pultdach",
+3
View File
@@ -953,6 +953,9 @@ export const en: Record<TranslationKey, string> = {
"cmd.roof.label": "Roof", "cmd.roof.label": "Roof",
"cmd.roof.start": "First roof corner:", "cmd.roof.start": "First roof corner:",
"cmd.roof.corner": "Opposite 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.sattel": "Gable roof",
"cmd.roof.shape.walm": "Hip roof", "cmd.roof.shape.walm": "Hip roof",
"cmd.roof.shape.pult": "Mono-pitch roof", "cmd.roof.shape.pult": "Mono-pitch roof",
+8
View File
@@ -108,6 +108,14 @@ export function CommandIcon({ name }: { name: string }) {
<line x1="8" y1="3" x2="8" y2="9" strokeDasharray="1.5 1.5" /> <line x1="8" y1="3" x2="8" y2="9" strokeDasharray="1.5 1.5" />
</svg> </svg>
); );
case "ceilingopening":
// Deckenloch: Decken-Rechteck mit ausgespartem Innenrechteck.
return (
<svg {...common}>
<rect x="2.5" y="3.5" width="11" height="9" />
<rect x="6" y="6.5" width="4" height="3" strokeDasharray="1.4 1.2" />
</svg>
);
default: default:
return null; return null;
} }
+1
View File
@@ -105,6 +105,7 @@ export const RIBBON_TABS: RibbonTab[] = [
t("stair"), t("stair"),
c("column"), c("column"),
t("ceiling"), t("ceiling"),
c("ceilingopening"),
c("roof"), c("roof"),
t("room"), t("room"),
], ],