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).
This commit is contained in:
2026-07-02 00:12:39 +02:00
parent cfe5249440
commit 3d2d4d6321
184 changed files with 29421 additions and 669 deletions
+368
View File
@@ -0,0 +1,368 @@
// 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"),
};