Files
DOSSIER-STANDALONE/src/commands/cmds/opening.ts
T
karim 3d2d4d6321 2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand
Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung
(konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im
Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text-
Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback,
falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform)
fuer fluessige Interaktion ohne React-Re-Render je Frame.

Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM
(Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext-
Import, Tauri-Compute-Boundary-PoC).
2026-07-02 00:12:39 +02:00

369 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, number> => {
const s = state as OpState;
const out: Record<string, number> = {
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"),
};