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
+250
View File
@@ -0,0 +1,250 @@
// Ceiling — Decke als echter Engine-Befehl (analog polyline: ein GESCHLOSSENER
// Umriss), committet aber ein einzelnes `Ceiling`-Bauteil statt einer 2D-Form.
// Schritte:
// 1) „Erster Umrisspunkt der Decke:" → Punkt
// 2) „Nächster Punkt ( Schliessen Zurück ):" → Punkt … Der Umriss schließt per
// Klick auf den Startpunkt, Option „Schliessen" (C) oder Enter (≥3 Punkte).
//
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45) —
// derselbe Eingabepfad wie Polylinie/Wand. Die fertige Decke rendert über die
// bestehende Decken-Darstellung (generatePlan / 3D); der Draft zeigt den Umriss.
//
// Herkunft der Decken-Felder (CommandContext):
// • wallTypeId ← ctx.activeWallTypeId (Fallback: erster Projekt-Wandtyp) — der
// Schichtaufbau (Bauteil/Schraffur/Material) wird identisch zur Wand aufgelöst.
// • floorId ← ctx.level.id (aktives Geschoss)
// • categoryCode← "30" (Ebene „Decken"; Fallback ctx.defaultCategoryCode)
import type { Ceiling } from "../../model/types";
import { normalizeOutline, ceilingArea } from "../../geometry/ceiling";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const DEG = Math.PI / 180;
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Umriss
/** Default-Ebene (Kategorie) für Decken. */
const CEILING_CATEGORY = "30";
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const segAngleDeg = (a: Vec2, b: Vec2): number =>
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
/** Tab-Felder je weiteren Punkt: Länge + Winkel relativ zum letzten Punkt. */
const CEIL_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/** Nächster Punkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `last`. */
function nextFromFields(
last: Vec2,
locks: Record<string, number>,
cursor: Vec2 | null,
): Vec2 {
const ref = cursor ?? last;
const length = "length" in locks ? locks.length : segLen(last, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
const ang = angleDeg * DEG;
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
}
/** Aktiver Aufbau-Typ-ID; Fallback auf den ersten Projekt-Wandtyp. */
function activeTypeId(ctx: CommandContext): string {
const wt =
ctx.project.wallTypes.find((t) => t.id === ctx.activeWallTypeId) ??
ctx.project.wallTypes[0];
return wt ? wt.id : ctx.activeWallTypeId;
}
/** Existiert die Decken-Kategorie („30")? Sonst Fallback auf die aktive Kategorie. */
function ceilingCategory(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 === CEILING_CATEGORY)
? CEILING_CATEGORY
: ctx.defaultCategoryCode;
}
interface CeilIdle extends CommandState {
phase: "start";
}
interface CeilDrawing extends CommandState {
phase: "next";
points: Vec2[];
cursor: Vec2 | null;
}
type CeilState = CeilIdle | CeilDrawing;
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
/** Vorschau: geschlossener Umriss-Ring (ab 3 Punkten) + Gummiband + HUD. */
function ceilingDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const showClosed = pts.length >= 3;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(last, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
return draft;
}
/** Hängt eine Decke ans Projekt (immutabel). Entartete Umrisse werden verworfen. */
function appendCeiling(p: Project, pts: Vec2[], ctx: CommandContext): Project {
const outline = normalizeOutline(pts);
if (!outline || ceilingArea(outline) < 1e-4) return p;
const ceiling: Ceiling = {
id: uniqueId("C"),
type: "ceiling",
floorId: ctx.level.id,
categoryCode: ceilingCategory(ctx),
outline,
wallTypeId: activeTypeId(ctx),
};
const ceilings = p.ceilings ? [...p.ceilings, ceiling] : [ceiling];
return { ...p, ceilings };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true },
];
export const ceilingCommand: Command = {
name: "ceiling",
labelKey: "cmd.ceiling.label",
// Decken leben auf Geschossen — wie die Wand nur dort aktiv.
floorOnly: true,
prompt: (s) =>
(s as CeilState).phase === "next" ? "cmd.ceiling.next" : "cmd.ceiling.start",
accepts: (s) =>
(s as CeilState).phase === "next"
? ["point", "number", "option"]
: ["point", "number"],
options: (s) => {
const ps = s as CeilState;
if (ps.phase !== "next") return [];
return ps.points.length >= 3 ? [CLOSE, UNDO] : [UNDO];
},
init: (): CeilIdle => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as CeilState;
if (input.kind === "option") {
if (s.phase !== "next") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0)
return [{ phase: "start", lastPoint: null } as CeilIdle, { draft: null }];
const ns: CeilDrawing = {
phase: "next",
points: pts,
cursor: s.cursor,
lastPoint: pts[pts.length - 1],
};
return [ns, { draft: ceilingDraft(pts, s.cursor) }];
}
// Sofort schließen: committet die Decke (unabhängig, ≥3 Punkte).
if (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
];
}
return [s, { draft: ceilingDraft(s.points, s.cursor) }];
}
if (input.kind !== "point") {
return [
s,
{ draft: s.phase === "next" ? ceilingDraft(s.points, s.cursor) : null },
];
}
const pt = input.point;
if (s.phase !== "next") {
const ns: CeilDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt };
return [ns, { draft: ceilingDraft([pt], pt) }];
}
// Klick nahe Startpunkt → schließen (committet die Decke).
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
];
}
// Null-Strecke (Klick auf denselben Punkt) ignorieren.
const last = s.points[s.points.length - 1];
if (segLen(last, pt) < EPS) {
return [s, { draft: ceilingDraft(s.points, s.cursor) }];
}
const points = [...s.points, pt];
const ns: CeilDrawing = { phase: "next", points, cursor: pt, lastPoint: pt };
return [ns, { draft: ceilingDraft(points, pt) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as CeilState;
if (s.phase !== "next") return [s, { draft: null }];
const ns: CeilDrawing = { ...s, cursor: point };
return [ns, { draft: ceilingDraft(s.points, point) }];
},
// Enter/Space/Rechtsklick: Umriss schließen + committen (≥3 Punkte).
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as CeilState;
if (s.phase === "next" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
];
}
return idle();
},
onCancel: (): [CommandState, CommandResult] => idle(),
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten Punkt.
fields: (state) => ((state as CeilState).phase === "next" ? CEIL_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as CeilState;
if (s.phase !== "next" || s.points.length === 0) return null;
return nextFromFields(s.points[s.points.length - 1], locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as CeilState;
if (s.phase !== "next" || s.points.length === 0 || !cursor) return {};
const last = s.points[s.points.length - 1];
return {
length: segLen(last, cursor),
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
};
},
};
+5
View File
@@ -133,6 +133,11 @@ export const circleCommand: Command = {
// Tab-Feld-Zyklus nur im „radius"-Schritt: ein Radius-Feld; der gelockte Wert
// committet direkt (Punkt liegt auf dem Kreis, dist(center,point)=radius).
fields: (state) => ((state as CircleState).phase === "radius" ? CIRCLE_FIELDS : []),
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as CircleState;
if (s.phase !== "radius" || !cursor) return {};
return { radius: dist(s.center, cursor) };
},
pointFromFields: (state, locks, cursor) => {
const s = state as CircleState;
if (s.phase !== "radius") return null;
+64
View File
@@ -0,0 +1,64 @@
// Join — verbindet die gewählten 2D-Zeichenelemente an koinzidenten Endpunkten zu
// Polylinien (geschlossen, wenn sich eine Kette schließt). Sofort-Befehl (kein
// Punkt-Picken): läuft direkt auf der bestehenden Mehrfachauswahl. So ist „Ctrl+J"
// und getipptes „join" EIN Pfad (§Task C).
//
// Wände werden nicht verbunden (2D-Editier-Operation); nicht-polyline Formen
// (circle/arc/text) reicht `joinDrawings` unverändert durch.
import { joinDrawings } from "../../editors/splitJoin";
import type { Drawing2D } from "../../model/types";
import type {
Command,
CommandContext,
CommandResult,
CommandSelection,
CommandState,
Project,
} from "../types";
/** Alle gewählten 2D-Element-IDs (Mehrfachauswahl mit Fallback aufs Einzelelement). */
function selectedDrawingIds(sel: CommandSelection): string[] {
if (sel.drawingIds && sel.drawingIds.length) return sel.drawingIds;
return sel.drawingId ? [sel.drawingId] : [];
}
/** Ersetzt die gewählten Quell-Drawings durch das Join-Ergebnis (immutabel). */
function applyJoin(project: Project, ids: string[]): Project {
const idSet = new Set(ids);
const srcs = project.drawings2d.filter((d) => idSet.has(d.id));
if (srcs.length < 2) return project; // <2 → nichts zu verbinden
const joined: Drawing2D[] = joinDrawings(srcs);
const rest = project.drawings2d.filter((d) => !idSet.has(d.id));
return { ...project, drawings2d: [...rest, ...joined] };
}
interface JoinState extends CommandState {
phase: "done";
}
const done = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as JoinState,
{ draft: null, done: true },
];
export const joinCommand: Command = {
name: "join",
labelKey: "edit.join.label",
prompt: () => "cmd.join.prompt",
accepts: () => [],
options: () => [],
init: (): JoinState => ({ phase: "done", lastPoint: null }),
// Sofort auf der Auswahl ausführen (kein Pick nötig).
autoRun: (ctx: CommandContext): CommandResult => {
const ids = selectedDrawingIds(ctx.selection);
if (ids.length < 2) return { draft: null, done: true };
return { draft: null, done: true, commit: (p) => applyJoin(p, ids) };
},
onInput: (): [CommandState, CommandResult] => done(),
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
onConfirm: (): [CommandState, CommandResult] => done(),
onCancel: (): [CommandState, CommandResult] => done(),
};
+8
View File
@@ -137,4 +137,12 @@ export const lineCommand: Command = {
if (s.phase !== "end") return null;
return lineEndFromFields(s.a, locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as LineState;
if (s.phase !== "end" || !cursor) return {};
return {
length: segLen(s.a, cursor),
angle: ((segAngleDeg(s.a, cursor) % 360) + 360) % 360,
};
},
};
+160
View File
@@ -0,0 +1,160 @@
// Mirror — spiegelt die AKTUELLE Auswahl (Wände + ein Drawing2D) an einer frei
// gewählten Achse (Rhino/Vectorworks-Spiegeln). Schritte:
// 1) „Erster Achsenpunkt:" → Punkt
// 2) „Zweiter Achsenpunkt:" → Punkt (Live-Vorschau der Spiegelung) → commit
//
// Die Auswahl kommt vorab über ctx.selection (erst selektieren, dann Befehl,
// wie Move/Copy). Ist sie leer, ist nichts zu spiegeln: kurzer No-op mit Hinweis
// („Nichts gewählt") und Beenden.
//
// Der Commit nutzt den bestehenden, getesteten `commitTransform` (op:"mirror",
// mode:"copy") — er spiegelt Wände UND alle 2D-Formen immutabel an der Achse und
// hängt das Spiegelbild als KOPIE an (das Original bleibt stehen). Das ist das
// klassische Mirror „Kopie spiegeln" (Rhino/Vectorworks-Default).
import { commitTransform } from "../../tools/transform";
import type { TransformSelection } from "../../tools/transform";
import { transformPreview } from "../../tools/transform";
import type {
Command,
CommandResult,
CommandSelection,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../types";
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const segAngleDeg = (a: Vec2, b: Vec2): number =>
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
const EPS = 1e-6;
/** Hat die Auswahl überhaupt etwas Spiegelbares? */
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
}
/** Auswahl → TransformSelection (gleiche Form). */
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
}
// Zustand: erst ersten Achsenpunkt setzen, dann den zweiten (mit Auswahl-Snapshot).
interface MirrorFirst extends CommandState {
phase: "first";
}
interface MirrorSecond extends CommandState {
phase: "second";
sel: CommandSelection;
a: Vec2;
cursor: Vec2 | null;
}
// „done": leere Auswahl → No-op, Befehl beendet.
interface MirrorDone extends CommandState {
phase: "done";
}
type MirrorState = MirrorFirst | MirrorSecond | MirrorDone;
/** Live-Vorschau der an der Achse a→b gespiegelten Auswahl. */
function mirrorDraft(
project: Project,
sel: CommandSelection,
a: Vec2,
b: Vec2,
): ToolDraft {
// Ohne echte Achse (Null-Strecke) nur den ersten Punkt markieren.
if (segLen(a, b) < EPS) {
return { preview: [], vertices: [a] };
}
const preview = transformPreview(
project,
toTransformSel(sel),
"mirror",
[a, b],
"copy", // Spiegelbild als Kopie (Original bleibt stehen)
1,
);
return {
// Spiegelbild-Vorschau + die Achse selbst als Hilfslinie.
preview: [...preview, { kind: "line", a, b }],
// Beide Achsenpunkte als Stützpunkte (Marker).
vertices: [a, b],
hud: {
at: b,
text: `${((segAngleDeg(a, b) + 360) % 360).toFixed(0)}°`,
},
};
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as MirrorDone,
{ draft: null, done: true },
];
export const mirrorCommand: Command = {
name: "mirror",
labelKey: "cmd.mirror.label",
prompt: (s) => {
const ms = s as MirrorState;
if (ms.phase === "second") return "cmd.mirror.second";
if (ms.phase === "done") return "cmd.mirror.empty";
return "cmd.mirror.first";
},
accepts: () => ["point"],
options: () => [],
init: (): MirrorFirst => ({ phase: "first", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as MirrorState;
if (s.phase === "done") return idle();
// Leere Auswahl → nichts zu spiegeln (No-op + Hinweis, dann beenden).
if (!hasSelection(ctx.selection)) {
return [{ phase: "done", lastPoint: null } as MirrorDone, { draft: null, done: true }];
}
if (input.kind !== "point") {
if (s.phase === "second") {
return [s, { draft: s.cursor ? mirrorDraft(ctx.project, s.sel, s.a, s.cursor) : null }];
}
return [s, { draft: null }];
}
const pt = input.point;
if (s.phase !== "second") {
// Erster Achsenpunkt gesetzt → Auswahl-Snapshot mitführen.
const next: MirrorSecond = {
phase: "second",
sel: ctx.selection,
a: pt,
cursor: pt,
lastPoint: pt,
};
return [next, { draft: mirrorDraft(ctx.project, next.sel, pt, pt) }];
}
// Zweiter Achsenpunkt → commit (Auswahl an der Achse spiegeln). Null-Achse
// verwerfen (kein Spiegeln ohne Richtung).
if (segLen(s.a, pt) < EPS) {
return [{ phase: "done", lastPoint: null } as MirrorDone, { draft: null, done: true }];
}
const a = s.a;
const sel = s.sel;
return [
{ phase: "done", lastPoint: null } as MirrorDone,
{
draft: null,
done: true,
commit: (p) => commitTransform(p, toTransformSel(sel), "mirror", [a, pt], "copy", 1),
},
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as MirrorState;
if (s.phase !== "second") return [s, { draft: null }];
const ns: MirrorSecond = { ...s, cursor: point };
return [ns, { draft: mirrorDraft(ctx.project, s.sel, s.a, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+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"),
};
+68 -27
View File
@@ -44,21 +44,34 @@ function polyNextFromFields(last: Vec2, locks: Record<string, number>, cursor: V
interface PolyIdle extends CommandState {
phase: "start";
/** Gewählter Schließ-Modus (bleibt über den Befehl hinweg erhalten). */
closed: boolean;
}
interface PolyDrawing extends CommandState {
phase: "next";
points: Vec2[];
cursor: Vec2 | null;
/** Soll der committete Zug als Ring (geschlossen) gelten? Default: offen. */
closed: boolean;
}
type PolyState = PolyIdle | PolyDrawing;
/** Sofort-schließen-Aktion (nur ≥3 Punkte): committet als Ring und beendet. */
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
/** Toggle-Option: Ergebnis geschlossen (Ring) oder offen — im Feld-Row sichtbar. */
const closedOption = (closed: boolean): CmdOption => ({
id: "mode",
labelKey: "cmd.polyline.closedOpt",
value: closed ? "on" : "off",
});
/** Vorschau (Zug + Gummiband zum Cursor) + HUD (Länge·Winkel des letzten Segments). */
function polyDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
function polyDraft(points: Vec2[], cursor: Vec2 | null, closed = false): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: false }];
// Ring nur ab 3 Punkten zeigen; darunter bleibt die Vorschau offen.
const showClosed = closed && pts.length >= 3;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
@@ -89,8 +102,9 @@ function appendPolyline(
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null },
/** Ruhezustand; behält den zuletzt gewählten Schließ-Modus bei. */
const idle = (closed = false): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null, closed } as PolyIdle,
{ draft: null, done: true },
];
@@ -98,77 +112,95 @@ export const polylineCommand: Command = {
name: "polyline",
labelKey: "cmd.polyline.label",
prompt: (s) => ((s as PolyState).phase === "next" ? "cmd.polyline.next" : "cmd.polyline.start"),
accepts: (s) =>
(s as PolyState).phase === "next" ? ["point", "number", "option"] : ["point", "number"],
// Auch im Startschritt darf der Schließ-Modus gewählt werden.
accepts: () => ["point", "number", "option"],
options: (s) => {
const ps = s as PolyState;
if (ps.phase !== "next") return [];
return ps.points.length >= 2 ? [CLOSE, UNDO] : [UNDO];
const toggle = closedOption(ps.closed);
if (ps.phase !== "next") return [toggle];
return ps.points.length >= 2 ? [CLOSE, toggle, UNDO] : [toggle, UNDO];
},
init: (): PolyIdle => ({ phase: "start", lastPoint: null }),
init: (): PolyIdle => ({ phase: "start", lastPoint: null, closed: false }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as PolyState;
if (input.kind === "option") {
// Schließ-Modus umschalten (Tastenkürzel „C" bzw. Klick im Feld-Row);
// in jedem Schritt verfügbar, ohne den laufenden Zug zu beenden.
if (input.id === "mode") {
const ns = { ...s, closed: !s.closed } as PolyState;
return [ns, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor, ns.closed) : null }];
}
if (s.phase !== "next") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0) return [{ phase: "start", lastPoint: null }, { draft: null }];
const ns: PolyDrawing = { phase: "next", points: pts, cursor: s.cursor, lastPoint: pts[pts.length - 1] };
return [ns, { draft: polyDraft(pts, s.cursor) }];
if (pts.length === 0)
return [{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle, { draft: null }];
const ns: PolyDrawing = {
phase: "next",
points: pts,
cursor: s.cursor,
lastPoint: pts[pts.length - 1],
closed: s.closed,
};
return [ns, { draft: polyDraft(pts, s.cursor, s.closed) }];
}
// Sofort schließen: committet als Ring (unabhängig vom Toggle-Zustand).
if (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null },
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
];
}
return [s, { draft: polyDraft(s.points, s.cursor) }];
return [s, { draft: polyDraft(s.points, s.cursor, s.closed) }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor) : null }];
return [s, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor, s.closed) : null }];
}
const pt = input.point;
if (s.phase !== "next") {
const ns: PolyDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt };
return [ns, { draft: polyDraft([pt], pt) }];
const ns: PolyDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt, closed: s.closed };
return [ns, { draft: polyDraft([pt], pt, s.closed) }];
}
// Klick nahe Startpunkt → schließen.
// Klick nahe Startpunkt → schließen (committet als Ring).
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null },
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
];
}
const points = [...s.points, pt];
const ns: PolyDrawing = { phase: "next", points, cursor: pt, lastPoint: pt };
return [ns, { draft: polyDraft(points, pt) }];
const ns: PolyDrawing = { phase: "next", points, cursor: pt, lastPoint: pt, closed: s.closed };
return [ns, { draft: polyDraft(points, pt, s.closed) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as PolyState;
if (s.phase !== "next") return [s, { draft: null }];
const ns: PolyDrawing = { ...s, cursor: point };
return [ns, { draft: polyDraft(s.points, point) }];
return [ns, { draft: polyDraft(s.points, point, s.closed) }];
},
// Enter/Space/Rechtsklick: offenen Zug beenden (≥2 Punkte) und committen.
// Enter/Space/Rechtsklick: Zug beenden (≥2 Punkte). Der Schließ-Modus des
// Toggles bestimmt, ob als Ring oder offen committet wird. Ein Ring braucht
// ≥3 Punkte, sonst fällt er auf „offen" zurück.
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as PolyState;
if (s.phase === "next" && s.points.length >= 2) {
const pts = s.points;
const close = s.closed && pts.length >= 3;
return [
{ phase: "start", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, false, ctx) },
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, close, ctx) },
];
}
return idle();
return idle(s.closed);
},
onCancel: (): [CommandState, CommandResult] => idle(),
onCancel: (state): [CommandState, CommandResult] => idle((state as PolyState).closed),
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten
// Punkt. (Erster Punkt = freie Koordinate, kein Feld-Modus.)
@@ -178,4 +210,13 @@ export const polylineCommand: Command = {
if (s.phase !== "next" || s.points.length === 0) return null;
return polyNextFromFields(s.points[s.points.length - 1], locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as PolyState;
if (s.phase !== "next" || s.points.length === 0 || !cursor) return {};
const last = s.points[s.points.length - 1];
return {
length: segLen(last, cursor),
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
};
},
};
+332 -51
View File
@@ -1,9 +1,20 @@
// Rectangle — zwei Ecken (portiert rectTool). Schritte:
// 1) „Erste Ecke:" → Punkt
// 2) „Gegenüberliegende Ecke:" → Punkt → commit Drawing2D rect → done
// Rectangle — mehrere Konstruktionsmethoden (portiert + erweitert rectTool).
// Wählbar über die Option „Methode" (klickbar im Feld-Row, tippbar als „m",
// zyklisch): 2-Punkt · 3-Punkt · Zentrum.
//
// Getippte Maße: die zweite Ecke kann als `r<breite>,<höhe>` relativ zur ersten
// eingegeben werden (die Engine löst `r…` relativ zu lastPoint auf).
// • 2-Punkt (Default): zwei gegenüberliegende Ecken, achsparallel.
// 1) „Erste Ecke:" → Punkt
// 2) „Gegenüberliegende Ecke:" → Punkt → commit (shape:"rect")
// • 3-Punkt (gedreht): Basiskante + Höhe → beliebig gedrehtes Rechteck.
// 1) „Erste Ecke:" → Punkt (Basis-Start)
// 2) „Zweite Ecke:" → Punkt (Basis-Ende, definiert Winkel+Breite)
// 3) „Höhe:" → Punkt → commit (geschlossene Polylinie)
// • Zentrum: Mittelpunkt + Ecke, achsparallel.
// 1) „Mittelpunkt:" → Punkt
// 2) „Ecke:" → Punkt → commit (shape:"rect")
//
// Getippte Maße: die zweite/dritte Ecke kann relativ (`r<dx>,<dy>`) eingegeben
// werden (die Engine löst `r…` relativ zu lastPoint auf).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
@@ -13,19 +24,50 @@ import type {
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
import type { TranslationKey } from "../../i18n";
const EPS = 1e-6;
/** Tab-Felder des zweiten-Ecke-Schritts: Breite, Höhe. */
/** Konstruktionsmethode. */
type RectMethod = "corner" | "three" | "center";
const METHOD_ORDER: RectMethod[] = ["corner", "three", "center"];
const nextMethod = (m: RectMethod): RectMethod =>
METHOD_ORDER[(METHOD_ORDER.indexOf(m) + 1) % METHOD_ORDER.length];
const methodValue = (m: RectMethod): string =>
m === "corner" ? "2pt" : m === "three" ? "3pt" : "center";
/** Umschalt-Option für die Methode (im Feld-Row sichtbar, tippbar als „m"). */
const methodOption = (m: RectMethod): CmdOption => ({
id: "method",
labelKey: "cmd.rect.method",
value: methodValue(m),
});
/** Tab-Felder des Breite/Höhe-Schritts (2-Punkt & Zentrum). */
const RECT_FIELDS: CommandField[] = [
{ id: "width", labelKey: "cmd.field.width" },
{ id: "height", labelKey: "cmd.field.height" },
];
/** Tab-Felder des Basis-Schritts (3-Punkt): Länge + Winkel der Basiskante. */
const BASE_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/** Tab-Feld des Höhen-Schritts (3-Punkt): senkrechter Abstand. */
const RISE_FIELDS: CommandField[] = [{ id: "height", labelKey: "cmd.field.height" }];
const DEG = Math.PI / 180;
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const segAngleDeg = (a: Vec2, b: Vec2): number =>
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
// ── 2-Punkt / Zentrum: achsparallele Ableitung ───────────────────────────────
/**
* Gegenüberliegende Ecke aus gelockten Feldern (width/height) + Cursor: eine
@@ -43,15 +85,107 @@ function rectCornerFromFields(a: Vec2, locks: Record<string, number>, cursor: Ve
return { x: a.x + w, y: a.y + h };
}
// ── 3-Punkt: Basiskante + Höhe ───────────────────────────────────────────────
/** Basis-Endpunkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `a`. */
function baseFromFields(a: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? a;
const length = "length" in locks ? locks.length : segLen(a, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(a, ref);
const ang = angleDeg * DEG;
return { x: a.x + Math.cos(ang) * length, y: a.y + Math.sin(ang) * length };
}
/**
* Vier Ecken des gedrehten Rechtecks aus Basiskante (a→b) und einem dritten
* Punkt `p`: die Höhe ist der vorzeichenbehaftete senkrechte Abstand von `p` zur
* Basislinie; das Rechteck wird zu dieser Seite hin aufgespannt.
*/
function rotatedCorners(a: Vec2, b: Vec2, p: Vec2): Vec2[] {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < EPS) return [a, b, b, a];
// Einheits-Normale (links der Basisrichtung).
const nx = -dy / len;
const ny = dx / len;
const h = (p.x - a.x) * nx + (p.y - a.y) * ny; // signierte Höhe
const c3 = { x: b.x + nx * h, y: b.y + ny * h };
const c4 = { x: a.x + nx * h, y: a.y + ny * h };
return [a, b, c3, c4];
}
/** Senkrechter (signierter) Abstand eines Punktes zur Basislinie a→b. */
function riseFrom(a: Vec2, b: Vec2, p: Vec2): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < EPS) return 0;
return (p.x - a.x) * (-dy / len) + (p.y - a.y) * (dx / len);
}
/** Dritten Punkt aus gelockter Höhe rekonstruieren (auf der Cursor-Seite). */
function riseToPoint(a: Vec2, b: Vec2, height: number, cursor: Vec2 | null): Vec2 {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < EPS) return cursor ?? b;
const nx = -dy / len;
const ny = dx / len;
const sign = cursor && riseFrom(a, b, cursor) < 0 ? -1 : 1;
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const h = sign * Math.abs(height);
return { x: mid.x + nx * h, y: mid.y + ny * h };
}
// ── Zentrum: Ecke aus Mittelpunkt ────────────────────────────────────────────
/** Achsparallele Ecken aus Zentrum + einer Ecke. */
function centerGeom(center: Vec2, corner: Vec2): { min: Vec2; max: Vec2 } {
const hx = Math.abs(corner.x - center.x);
const hy = Math.abs(corner.y - center.y);
return {
min: { x: center.x - hx, y: center.y - hy },
max: { x: center.x + hx, y: center.y + hy },
};
}
/** Ecke aus Zentrum + gelockten Halbmaßen (width/height als volle Kantenmaße). */
function centerCornerFromFields(
center: Vec2,
locks: Record<string, number>,
cursor: Vec2 | null,
): Vec2 {
const cur = cursor ?? center;
const dx = cur.x - center.x;
const dy = cur.y - center.y;
const sx = dx < 0 ? -1 : 1;
const sy = dy < 0 ? -1 : 1;
const hx = "width" in locks ? Math.abs(locks.width) / 2 : Math.abs(dx);
const hy = "height" in locks ? Math.abs(locks.height) / 2 : Math.abs(dy);
return { x: center.x + sx * hx, y: center.y + sy * hy };
}
// ── Zustand ──────────────────────────────────────────────────────────────────
interface RectIdle extends CommandState {
phase: "corner1";
method: RectMethod;
}
interface RectDrawing extends CommandState {
interface RectCorner2 extends CommandState {
phase: "corner2";
method: RectMethod;
a: Vec2;
cursor: Vec2 | null;
}
type RectState = RectIdle | RectDrawing;
interface RectRise extends CommandState {
phase: "rise";
method: "three";
a: Vec2;
b: Vec2;
cursor: Vec2 | null;
}
type RectState = RectIdle | RectCorner2 | RectRise;
function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
return {
@@ -60,84 +194,231 @@ function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
};
}
/** Vorschau (geschlossenes Rechteck) + HUD (Breite × Höhe). */
function rectDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [a] };
const g = rectGeom(a, cursor);
const pts: Vec2[] = [
g.min,
{ x: g.max.x, y: g.min.y },
g.max,
{ x: g.min.x, y: g.max.y },
];
// ── Vorschau ─────────────────────────────────────────────────────────────────
function corners4Draft(pts: Vec2[], hudAt: Vec2 | null, hudText: string): ToolDraft {
const preview: DraftShape[] = [{ kind: "poly", pts, closed: true }];
const w = g.max.x - g.min.x;
const h = g.max.y - g.min.y;
return {
preview,
vertices: [a],
hud: { at: cursor, text: `${w.toFixed(2)} × ${h.toFixed(2)} m` },
};
const draft: ToolDraft = { preview, vertices: [pts[0]] };
if (hudAt) draft.hud = { at: hudAt, text: hudText };
return draft;
}
function appendRect(p: Project, a: Vec2, b: Vec2, ctx: CommandContext): Project {
const g = rectGeom(a, b);
if (g.max.x - g.min.x < EPS || g.max.y - g.min.y < EPS) return p; // Null-Rechteck
/** Vorschau des 2-Punkt-Rechtecks (achsparallel). */
function cornerDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [a] };
const g = rectGeom(a, cursor);
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
const w = g.max.x - g.min.x;
const h = g.max.y - g.min.y;
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
}
/** Vorschau des Zentrum-Rechtecks (achsparallel). */
function centerDraft(center: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [center] };
const g = centerGeom(center, cursor);
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
const w = g.max.x - g.min.x;
const h = g.max.y - g.min.y;
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
}
/** Vorschau der Basiskante (3-Punkt, erster Teil). */
function baseDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor || segLen(a, cursor) < EPS) return { preview: [{ kind: "line", a, b: a }], vertices: [a] };
const draft: ToolDraft = { preview: [{ kind: "line", a, b: cursor }], vertices: [a] };
draft.hud = {
at: cursor,
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs((segAngleDeg(a, cursor) + 360) % 360).toFixed(0)}°`,
};
return draft;
}
/** Vorschau des gedrehten Rechtecks (3-Punkt, zweiter Teil). */
function riseDraft(a: Vec2, b: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [{ kind: "line", a, b }], vertices: [a] };
const pts = rotatedCorners(a, b, cursor);
const w = segLen(a, b);
const h = Math.abs(riseFrom(a, b, cursor));
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
}
// ── Commit ───────────────────────────────────────────────────────────────────
/** Achsparalleles Rechteck als `shape:"rect"` (geschlossen behandelt). */
function appendRect(p: Project, min: Vec2, max: Vec2, ctx: CommandContext): Project {
if (max.x - min.x < EPS || max.y - min.y < EPS) return p; // Null-Rechteck
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "rect", min: g.min, max: g.max },
geom: { shape: "rect", min, max },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "corner1", lastPoint: null },
/**
* Gedrehtes Rechteck als geschlossene Polylinie (`shape:"polyline", closed:true`)
* — `shape:"rect"` kann nur achsparallel, deshalb hier ein Ring aus vier Ecken.
* Downstream (Füllung/Offset) behandelt einen geschlossenen Polygonzug als Ring.
*/
function appendRotated(p: Project, pts: Vec2[], ctx: CommandContext): Project {
// Fläche prüfen: entartete Rechtecke verwerfen.
const w = segLen(pts[0], pts[1]);
const h = segLen(pts[1], pts[2]);
if (w < EPS || h < EPS) return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "polyline", pts, closed: true },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (method: RectMethod): [CommandState, CommandResult] => [
{ phase: "corner1", lastPoint: null, method } as RectIdle,
{ draft: null, done: true },
];
// ── Prompt je Schritt/Methode ────────────────────────────────────────────────
function promptKey(s: RectState): TranslationKey {
if (s.phase === "corner1") return s.method === "center" ? "cmd.rect.center" : "cmd.rect.first";
if (s.phase === "rise") return "cmd.rect.rise";
// corner2
if (s.method === "three") return "cmd.rect.baseEnd";
if (s.method === "center") return "cmd.rect.corner";
return "cmd.rect.second";
}
export const rectCommand: Command = {
name: "rect",
labelKey: "cmd.rect.label",
prompt: (s) => ((s as RectState).phase === "corner2" ? "cmd.rect.second" : "cmd.rect.first"),
accepts: () => ["point", "number"],
options: () => [],
init: (): RectIdle => ({ phase: "corner1", lastPoint: null }),
prompt: (s) => promptKey(s as RectState),
accepts: () => ["point", "number", "option"],
options: (s) => [methodOption((s as RectState).method)],
init: (): RectIdle => ({ phase: "corner1", lastPoint: null, method: "corner" }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as RectState;
if (input.kind === "option") {
if (input.id === "method") {
// Methode zyklisch umschalten; laufende Konstruktion verwerfen und den
// Befehl AKTIV lassen (kein done) — nur zum ersten Schritt zurück.
const m = nextMethod(s.method);
const ns: RectIdle = { phase: "corner1", lastPoint: null, method: m };
return [ns, { draft: null }];
}
return [s, { draft: null }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "corner2" ? rectDraft(s.a, s.cursor) : null }];
return [s, { draft: draftFor(s) }];
}
const pt = input.point;
if (s.phase !== "corner2") {
const ns: RectDrawing = { phase: "corner2", a: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: rectDraft(pt, pt) }];
if (s.phase === "corner1") {
if (s.method === "three") {
const ns: RectCorner2 = { phase: "corner2", method: "three", a: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: baseDraft(pt, pt) }];
}
const ns: RectCorner2 = { phase: "corner2", method: s.method, a: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: draftFor(ns) }];
}
const a = s.a;
if (s.phase === "corner2") {
if (s.method === "three") {
// Basiskante gesetzt → dritter Punkt bestimmt Höhe.
if (segLen(s.a, pt) < EPS) return [s, { draft: baseDraft(s.a, s.cursor) }];
const ns: RectRise = { phase: "rise", method: "three", a: s.a, b: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: riseDraft(s.a, pt, pt) }];
}
const a = s.a;
const method = s.method;
return [
{ phase: "corner1", lastPoint: null, method } as RectIdle,
{
draft: null,
done: true,
commit: (p) => {
if (method === "center") {
const g = centerGeom(a, pt);
return appendRect(p, g.min, g.max, ctx);
}
const g = rectGeom(a, pt);
return appendRect(p, g.min, g.max, ctx);
},
},
];
}
// phase "rise" (3-Punkt) → committen als gedrehte, geschlossene Polylinie.
const { a, b } = s;
return [
{ phase: "corner1", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendRect(p, a, pt, ctx) },
{ phase: "corner1", lastPoint: null, method: "three" } as RectIdle,
{ draft: null, done: true, commit: (p) => appendRotated(p, rotatedCorners(a, b, pt), ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as RectState;
if (s.phase !== "corner2") return [s, { draft: null }];
const ns: RectDrawing = { ...s, cursor: point };
return [ns, { draft: rectDraft(s.a, point) }];
if (s.phase === "corner1") return [s, { draft: null }];
const ns = { ...s, cursor: point } as RectState;
return [ns, { draft: draftFor(ns) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
onConfirm: (state): [CommandState, CommandResult] => idle((state as RectState).method),
onCancel: (state): [CommandState, CommandResult] => idle((state as RectState).method),
// Tab-Feld-Zyklus nur im „corner2"-Schritt: Breite + Höhe (signiert nach Cursor).
fields: (state) => ((state as RectState).phase === "corner2" ? RECT_FIELDS : []),
// Tab-Feld-Zyklus je Schritt/Methode.
fields: (state) => {
const s = state as RectState;
if (s.phase === "corner2") return s.method === "three" ? BASE_FIELDS : RECT_FIELDS;
if (s.phase === "rise") return RISE_FIELDS;
return [];
},
pointFromFields: (state, locks, cursor) => {
const s = state as RectState;
if (s.phase !== "corner2") return null;
return rectCornerFromFields(s.a, locks, cursor);
if (s.phase === "corner2") {
if (s.method === "three") return baseFromFields(s.a, locks, cursor);
if (s.method === "center") return centerCornerFromFields(s.a, locks, cursor);
return rectCornerFromFields(s.a, locks, cursor);
}
if (s.phase === "rise") {
if ("height" in locks) return riseToPoint(s.a, s.b, locks.height, cursor);
return cursor;
}
return null;
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as RectState;
if (!cursor) return {};
if (s.phase === "corner2") {
if (s.method === "three") {
return {
length: segLen(s.a, cursor),
angle: ((segAngleDeg(s.a, cursor) % 360) + 360) % 360,
};
}
return { width: Math.abs(cursor.x - s.a.x) * (s.method === "center" ? 2 : 1),
height: Math.abs(cursor.y - s.a.y) * (s.method === "center" ? 2 : 1) };
}
if (s.phase === "rise") return { height: Math.abs(riseFrom(s.a, s.b, cursor)) };
return {};
},
};
/** Vorschau passend zum Zustand. */
function draftFor(s: RectState): ToolDraft {
if (s.phase === "corner2") {
if (s.method === "three") return baseDraft(s.a, s.cursor);
if (s.method === "center") return centerDraft(s.a, s.cursor);
return cornerDraft(s.a, s.cursor);
}
if (s.phase === "rise") return riseDraft(s.a, s.b, s.cursor);
return { preview: [], vertices: [] };
}
+355
View File
@@ -0,0 +1,355 @@
// Raum (Room / SIA-416-Fläche) als Engine-Befehl. Zwei Modi (togglebar über die
// Option „Modus"):
//
// 1) „innen" (Default) — EIN Klick IN einen von Wänden umschlossenen Bereich.
// Der Umriss wird über den reinen Rechenkern (geometry/roomBoundary,
// `roomFromPointInside`) aus den Wänden des aktiven Geschosses automatisch
// als lichte Innenkontur erkannt. Findet der Klick keine geschlossene Zelle,
// passiert nichts (der Nutzer kann in den Manuell-Modus wechseln).
//
// 2) „manuell" — ein GESCHLOSSENER Umriss wird Punkt für Punkt gezeichnet
// (wie eine geschlossene Polylinie); Klick auf den Start / Option „Schliessen"
// / Enter (≥3 Punkte) committet den Raum.
//
// Der committete Raum bekommt SIA-Blatt HNF, einen Default-Namen und die
// Kategorie „45 Räume" (Fallback: aktive Kategorie). Fläche/Umfang/Schwerpunkt
// werden NICHT gespeichert (bei jedem Rendern aus `boundary` abgeleitet).
//
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md). Einheit: METER.
import type { Room } from "../../model/types";
import { wallTypeThickness } from "../../model/types";
import { roomFromPointInside, type WallSegment } from "../../geometry/roomBoundary";
import { polygonArea, centroid } from "../../geometry/roomArea";
import { docFromText } from "../../text/richText";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const DEG = Math.PI / 180;
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Umriss
/** Default-Ebene (Kategorie) für Räume: „60" Räume (Fallback: aktive). */
const ROOM_CATEGORY = "60";
/** Default-Raumfarbe (falls Kategorie nicht auflösbar). */
const ROOM_COLOR = "#5a7a9a";
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const segAngleDeg = (a: Vec2, b: Vec2): number =>
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
/** Existiert die Raum-Kategorie („45")? Sonst Fallback auf die aktive Kategorie. */
function roomCategory(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 === ROOM_CATEGORY)
? ROOM_CATEGORY
: ctx.defaultCategoryCode;
}
/** Farbe der Raum-Kategorie (Ebene) — sonst Default. */
function roomColor(ctx: CommandContext, code: string): string {
const flat: { code: string; color?: string; children?: unknown[] }[] = [];
const walk = (list: { code: string; color?: string; children?: unknown[] }[]) => {
for (const c of list) {
flat.push(c);
if (c.children)
walk(
c.children as {
code: string;
color?: string;
children?: unknown[];
}[],
);
}
};
walk(
ctx.project.layers as {
code: string;
color?: string;
children?: unknown[];
}[],
);
return flat.find((c) => c.code === code)?.color ?? ROOM_COLOR;
}
/**
* Wände des aktiven Geschosses in die vom Rechenkern erwartete Form bringen:
* Mittellinie (start→end) + Gesamtdicke des Wandtyps. Grafik-2D-Geometrie und
* andere Geschosse bleiben außen vor.
*/
function wallsOfFloor(ctx: CommandContext): WallSegment[] {
const { project, level } = ctx;
const out: WallSegment[] = [];
for (const w of project.walls) {
if (w.floorId !== level.id) continue;
const wt = project.wallTypes.find((t) => t.id === w.wallTypeId);
const thickness = wt ? wallTypeThickness(wt) : 0.2;
out.push({ a: w.start, b: w.end, thickness });
}
return out;
}
/** Baut einen Raum aus einem fertigen Umriss (immutabel; keine abgeleiteten Felder). */
function buildRoom(ctx: CommandContext, boundary: Vec2[]): Room {
const code = roomCategory(ctx);
const name = defaultRoomName(ctx);
return {
id: uniqueId("RM"),
type: "room",
floorId: ctx.level.id,
categoryCode: code,
siaCategory: "HNF",
name,
boundary,
color: roomColor(ctx, code),
// Stempel-Anker EINMALIG auf den Zentroid setzen (danach frei verschiebbar,
// bleibt beim Ändern der Kontur an seiner Stelle).
stampAnchor: centroid(boundary),
// Frei editierbarer Stempel-Text: ein zentrierter Absatz mit dem Raumnamen.
stampDoc: { ...docFromText(name), paragraphs: docFromText(name).paragraphs.map((p) => ({ ...p, align: "center" as const })) },
};
}
/** Fortlaufender Default-Name „Raum N" (N = Anzahl vorhandener Räume + 1). */
function defaultRoomName(ctx: CommandContext): string {
const n = (ctx.project.rooms ?? []).length + 1;
return `Raum ${n}`;
}
/** Hängt einen Raum ans Projekt (immutabel); entartete Umrisse werden verworfen. */
function appendRoom(p: Project, boundary: Vec2[], ctx: CommandContext): Project {
if (boundary.length < 3 || polygonArea(boundary) < 1e-4) return p;
const room = buildRoom(ctx, boundary);
const rooms = p.rooms ? [...p.rooms, room] : [room];
return { ...p, rooms };
}
// ── Zustand ──────────────────────────────────────────────────────────────────
type RoomMode = "inside" | "manual";
interface RoomIdle extends CommandState {
phase: "start";
mode: RoomMode;
}
interface RoomDrawing extends CommandState {
phase: "draw";
mode: RoomMode;
points: Vec2[];
cursor: Vec2 | null;
}
type RoomCmdState = RoomIdle | RoomDrawing;
const MODE: CmdOption = { id: "mode", labelKey: "cmd.room.mode", value: "inside" };
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
/** Tab-Felder je weiteren Punkt (Manuell-Modus): Länge + Winkel relativ zum letzten. */
const ROOM_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
function nextFromFields(
last: Vec2,
locks: Record<string, number>,
cursor: Vec2 | null,
): Vec2 {
const ref = cursor ?? last;
const length = "length" in locks ? locks.length : segLen(last, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
const ang = angleDeg * DEG;
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
}
/** Vorschau des manuellen Umrisses (geschlossener Ring ab 3 Punkten) + HUD. */
function manualDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const showClosed = pts.length >= 3;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(last, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
return draft;
}
const idle = (mode: RoomMode): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null, mode } as RoomIdle,
{ draft: null, done: true },
];
export const roomCommand: Command = {
name: "room",
labelKey: "cmd.room.label",
// Räume leben auf Geschossen — wie Wand/Decke nur dort aktiv.
floorOnly: true,
prompt: (s) => {
const st = s as RoomCmdState;
if (st.phase === "draw") return "cmd.room.next";
return st.mode === "inside" ? "cmd.room.inside" : "cmd.room.first";
},
accepts: (s) =>
(s as RoomCmdState).phase === "draw"
? ["point", "number", "option"]
: ["point", "option"],
options: (s): CmdOption[] => {
const st = s as RoomCmdState;
const modeOpt: CmdOption = { ...MODE, value: st.mode };
if (st.phase !== "draw") return [modeOpt];
return st.points.length >= 3 ? [modeOpt, CLOSE, UNDO] : [modeOpt, UNDO];
},
init: (): RoomIdle => ({ phase: "start", lastPoint: null, mode: "inside" }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as RoomCmdState;
if (input.kind === "option") {
if (input.id === "mode") {
// Modus umschalten; ein laufender Manuell-Entwurf wird verworfen.
const mode: RoomMode = s.mode === "inside" ? "manual" : "inside";
return idle(mode);
}
if (s.phase !== "draw") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0)
return [
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
{ draft: null },
];
const ns: RoomDrawing = {
phase: "draw",
mode: s.mode,
points: pts,
cursor: s.cursor,
lastPoint: pts[pts.length - 1],
};
return [ns, { draft: manualDraft(pts, s.cursor) }];
}
if (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
{ draft: null, done: true, commit: (p) => appendRoom(p, pts, ctx) },
];
}
return [s, { draft: s.phase === "draw" ? manualDraft(s.points, s.cursor) : null }];
}
if (input.kind !== "point") {
return [
s,
{ draft: s.phase === "draw" ? manualDraft(s.points, s.cursor) : null },
];
}
const pt = input.point;
// ── Modus „innen": ein Klick → Umriss automatisch erkennen + committen ──
if (s.mode === "inside" && s.phase !== "draw") {
const boundary = roomFromPointInside(pt, wallsOfFloor(ctx));
if (boundary && boundary.length >= 3) {
return [
{ phase: "start", lastPoint: null, mode: "inside" } as RoomIdle,
{ draft: null, done: true, commit: (p) => appendRoom(p, boundary, ctx) },
];
}
// Kein umschlossener Bereich getroffen → im Werkzeug bleiben (kein Commit).
return [s, { draft: null }];
}
// ── Modus „manuell": Umriss Punkt für Punkt ──
if (s.phase !== "draw") {
const ns: RoomDrawing = {
phase: "draw",
mode: "manual",
points: [pt],
cursor: pt,
lastPoint: pt,
};
return [ns, { draft: manualDraft([pt], pt) }];
}
// Klick nahe Startpunkt → schließen + committen.
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
{ draft: null, done: true, commit: (p) => appendRoom(p, pts, ctx) },
];
}
const last = s.points[s.points.length - 1];
if (segLen(last, pt) < EPS) {
return [s, { draft: manualDraft(s.points, s.cursor) }];
}
const points = [...s.points, pt];
const ns: RoomDrawing = {
phase: "draw",
mode: "manual",
points,
cursor: pt,
lastPoint: pt,
};
return [ns, { draft: manualDraft(points, pt) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as RoomCmdState;
if (s.phase !== "draw") return [s, { draft: null }];
const ns: RoomDrawing = { ...s, cursor: point };
return [ns, { draft: manualDraft(s.points, point) }];
},
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as RoomCmdState;
if (s.phase === "draw" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
{ draft: null, done: true, commit: (p) => appendRoom(p, pts, ctx) },
];
}
return idle(s.mode);
},
onCancel: (state): [CommandState, CommandResult] =>
idle((state as RoomCmdState).mode),
fields: (state) => ((state as RoomCmdState).phase === "draw" ? ROOM_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as RoomCmdState;
if (s.phase !== "draw" || s.points.length === 0) return null;
return nextFromFields(s.points[s.points.length - 1], locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as RoomCmdState;
if (s.phase !== "draw" || s.points.length === 0 || !cursor) return {};
const last = s.points[s.points.length - 1];
return {
length: segLen(last, cursor),
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
};
},
};
+268
View File
@@ -0,0 +1,268 @@
// Treppe (Stair) als Engine-Befehl. Ablauf:
// 1) „Startpunkt der Treppe:" → Punkt (Antritt, unterste Stufe).
// 2) „Laufrichtung / Ende:" → Punkt: definiert Richtung + Lauflänge des ersten
// Laufs. Bei L folgt ein zweiter Lauf (Ende des zweiten Laufs); bei Wendel
// ist der zweite Punkt der äußere Rand (Radius). Committet die Treppe.
//
// Optionen (togglebar): Grundform gerade/L/Wendel (Auf-/Ab-Umschalter). Tab-Felder
// im Laufschritt: Breite / Stufenanzahl / Steighöhe (totalRise). Live-Vorschau
// zeigt die Tritt-Linien + die Lauflinie.
//
// Herkunft der Treppen-Felder (CommandContext):
// • floorId ← ctx.level.id (aktives Geschoss)
// • categoryCode← "40 Treppen" (Fallback ctx.defaultCategoryCode)
// • totalRise ← Geschosshöhe (ctx.level.floorHeight), damit die Treppe genau
// ins nächste Geschoss steigt (geschossübergreifend).
//
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
import type { Stair, StairShape, Vec2 as MVec2 } from "../../model/types";
import { stairGeometry, defaultStepCount } from "../../geometry/stair";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
/** Default-Ebene (Kategorie) für Treppen: „40" Treppen (Fallback: aktive). */
const STAIR_CATEGORY = "40";
/** Default-Laufbreite (Meter). */
const DEF_WIDTH = 1.0;
const EPS = 1e-6;
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
const lenOf = (a: Vec2): number => Math.hypot(a.x, a.y);
const normv = (a: Vec2): Vec2 => {
const l = lenOf(a) || 1e-9;
return { x: a.x / l, y: a.y / l };
};
/** Existiert die Treppen-Kategorie („40")? Sonst Fallback auf die aktive. */
function stairCategory(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 === STAIR_CATEGORY)
? STAIR_CATEGORY
: ctx.defaultCategoryCode;
}
/** Geschosshöhe als Default-Steighöhe (Fallback 2.6 m). */
function floorRise(ctx: CommandContext): number {
return ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : 2.6;
}
/** Tab-Felder im Laufschritt: Breite / Stufenanzahl / Steighöhe. */
const RUN_FIELDS: CommandField[] = [
{ id: "width", labelKey: "cmd.field.width" },
{ id: "steps", labelKey: "cmd.stair.stepsField" },
{ id: "rise", labelKey: "cmd.stair.riseField" },
];
const SHAPE: CmdOption = { id: "shape", labelKey: "cmd.stair.shape", value: "straight" };
const UPDOWN: CmdOption = { id: "updown", labelKey: "cmd.stair.updown", value: "up" };
interface StairIdle extends CommandState {
phase: "start";
shape: StairShape;
up: boolean;
width: number;
steps: number | null; // null = automatisch
rise: number | null; // null = Geschosshöhe
}
interface StairRun extends CommandState {
phase: "run";
shape: StairShape;
up: boolean;
width: number;
steps: number | null;
rise: number | null;
start: Vec2;
cursor: Vec2;
}
type StairCmdState = StairIdle | StairRun;
function initState(shape: StairShape = "straight"): StairIdle {
return { phase: "start", lastPoint: null, shape, up: true, width: DEF_WIDTH, steps: null, rise: null };
}
/**
* Baut aus dem Laufzustand ein konkretes `Stair`-Objekt (aufgelöste Felder). Die
* Richtung/Länge folgen aus start→cursor; bei L bricht der zweite Lauf um 90°
* (links) ab, bei Wendel wird der Cursorabstand zum Radius.
*/
function buildStair(s: StairRun, ctx: CommandContext): Stair {
const rise = s.rise ?? floorRise(ctx);
const d = sub(s.cursor, s.start);
const dist = Math.max(0.1, lenOf(d));
const dir = dist > EPS ? normv(d) : ({ x: 1, y: 0 } as MVec2);
const runLength = s.shape === "spiral" ? Math.max(0.5, dist) : dist;
const steps = s.steps ?? defaultStepCount(rise, runLength);
const base: Stair = {
id: uniqueId("ST"),
type: "stair",
floorId: ctx.level.id,
categoryCode: stairCategory(ctx),
shape: s.shape,
start: s.start,
dir,
runLength,
width: s.width,
totalRise: rise,
stepCount: steps,
up: s.up,
};
if (s.shape === "L") {
base.run2Length = runLength; // symmetrischer zweiter Lauf
base.turn = 1;
} else if (s.shape === "spiral") {
base.center = s.start;
base.radius = dist;
base.sweep = 270;
// Startpunkt am äußeren Rand: Lauflinie beginnt hier.
base.start = s.cursor;
base.dir = dir;
}
return base;
}
/** Vorschau: Tritt-Linien + Lauflinie + HUD (Länge · Stufenanzahl). */
function runDraft(s: StairRun, ctx: CommandContext): ToolDraft {
const stair = buildStair(s, ctx);
const rise = stair.totalRise ?? floorRise(ctx);
const geo = stairGeometry(stair, rise);
const preview: DraftShape[] = [];
for (const tr of geo.treads) preview.push({ kind: "poly", pts: tr.pts, closed: true });
if (geo.landing) preview.push({ kind: "poly", pts: geo.landing, closed: true });
if (geo.runLine.length >= 2) preview.push({ kind: "poly", pts: geo.runLine, closed: false });
const draft: ToolDraft = { preview, vertices: [s.start] };
draft.hud = {
at: s.cursor,
text: `${lenOf(sub(s.cursor, s.start)).toFixed(2)} m · ${stair.stepCount} STG`,
};
return draft;
}
/** Hängt eine Treppe ans Projekt (immutabel). */
function appendStair(p: Project, s: StairRun, ctx: CommandContext): Project {
const stair = buildStair(s, ctx);
const stairs = p.stairs ? [...p.stairs, stair] : [stair];
return { ...p, stairs };
}
const idle = (from?: StairCmdState): [CommandState, CommandResult] => [
initState(from?.shape ?? "straight"),
{ draft: null, done: true },
];
export const stairCommand: Command = {
name: "stair",
labelKey: "cmd.stair.label",
floorOnly: true,
prompt: (s) => ((s as StairCmdState).phase === "run" ? "cmd.stair.run" : "cmd.stair.start"),
accepts: (s) =>
(s as StairCmdState).phase === "run"
? ["point", "number", "option"]
: ["point", "option"],
options: (s): CmdOption[] => {
const st = s as StairCmdState;
return [
{ ...SHAPE, value: st.shape },
{ ...UPDOWN, value: st.up ? "up" : "down" },
];
},
init: (): StairIdle => initState("straight"),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as StairCmdState;
if (input.kind === "option") {
const next = { ...s } as StairCmdState;
if (input.id === "shape") {
next.shape = s.shape === "straight" ? "L" : s.shape === "L" ? "spiral" : "straight";
} else if (input.id === "updown") {
next.up = !s.up;
}
return [next, { draft: next.phase === "run" ? runDraft(next as StairRun, ctx) : null }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "run" ? runDraft(s as StairRun, ctx) : null }];
}
const pt = input.point;
if (s.phase !== "run") {
const ns: StairRun = {
phase: "run",
lastPoint: pt,
shape: s.shape,
up: s.up,
width: s.width,
steps: s.steps,
rise: s.rise,
start: pt,
cursor: pt,
};
return [ns, { draft: null }];
}
// Zweiter Punkt: Lauf definieren + committen (Null-Strecke ignorieren).
if (lenOf(sub(pt, s.start)) < 0.05) {
return [{ ...s }, { draft: runDraft(s, ctx) }];
}
const ns: StairRun = { ...s, cursor: pt };
return [
idle(s)[0],
{ draft: null, done: true, commit: (p) => appendStair(p, ns, ctx) },
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as StairCmdState;
if (s.phase !== "run") return [s, { draft: null }];
const ns: StairRun = { ...s, cursor: point };
return [ns, { draft: runDraft(ns, ctx) }];
},
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as StairCmdState;
if (s.phase === "run" && lenOf(sub(s.cursor, s.start)) >= 0.05) {
const ns = s;
return [
idle(s)[0],
{ draft: null, done: true, commit: (p) => appendStair(p, ns, ctx) },
];
}
return idle(s);
},
onCancel: (state): [CommandState, CommandResult] => idle(state as StairCmdState),
fields: (state) => ((state as StairCmdState).phase === "run" ? RUN_FIELDS : []),
// Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt.
pointFromFields: (state, locks) => {
const s = state as StairCmdState;
if ("width" in locks && locks.width > 0) s.width = locks.width;
if ("steps" in locks && locks.steps >= 2) s.steps = Math.round(locks.steps);
if ("rise" in locks && locks.rise > 0) s.rise = locks.rise;
return null;
},
fieldValues: (state, _locks, _cursor): Record<string, number> => {
const s = state as StairCmdState;
const out: Record<string, number> = { width: s.width };
if (s.steps != null) out.steps = s.steps;
if (s.rise != null) out.rise = s.rise;
return out;
},
};
+242
View File
@@ -0,0 +1,242 @@
// Trim — Quick-Trim („stutzen") für 2D-Kurven (line/polyline/rect), Rhino/
// AutoCAD-artig (docs §2.10/§3.4 Tier-1). Modell:
// • Cutters sind IMPLIZIT alle ANDEREN Drawing2D-Elemente der aktiven Ebene
// (Quick-Trim — kein separater Cutter-Auswahlschritt; üblich + einfacher).
// • Ein Schritt: der Nutzer klickt auf den WEGZUSCHNEIDENDEN Teil einer Kurve.
// Wir bestimmen das getroffene Element (Nähe), zerlegen es an seinen
// Schnittpunkten mit allen Cuttern und ENTFERNEN den angeklickten Abschnitt
// (zwischen den beiden nächstgelegenen Schnittpunkten um den Klick — bzw. bis
// zum Element-Ende, wenn nur einseitig ein Schnitt liegt).
// • Wiederholend, bis Esc/Enter.
//
// Linie → wird gekürzt/aufgeteilt; Polylinie → der getroffene Abschnitt fällt
// raus (kann die Polylinie in Stücke teilen). Reine Geometrie liegt im Kernel
// (`trimPolyline`); hier nur Treffer-Bestimmung, Drawing2D ↔ {pts,closed} und der
// Commit. Wände sind ausgenommen (nur Drawing2D).
//
// Bezeichner englisch, Kommentare/UI deutsch (CONVENTIONS.md).
import type { Drawing2D, Drawing2DGeom } from "../../model/types";
import { uniqueId } from "../../tools/types";
import {
pointSegmentDistance,
polylineEdges,
trimPolyline,
vecEqual,
} from "../../geometry/kernel2d";
import type {
Command,
CommandResult,
CommandState,
DraftShape,
Project,
Vec2,
} from "../types";
const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Kurvenwahl
// ── Kurven-Abstraktion: line/polyline/rect → Punktliste + closed ──────────────
interface Curve {
pts: Vec2[];
closed: boolean;
}
/** Wandelt eine trim-fähige 2D-Form in eine Punktliste (+closed) oder null. */
function geomToCurve(g: Drawing2DGeom): Curve | null {
if (g.shape === "line") return { pts: [g.a, g.b], closed: false };
if (g.shape === "polyline") return { pts: g.pts, closed: g.closed };
if (g.shape === "rect") {
return {
pts: [
g.min,
{ x: g.max.x, y: g.min.y },
g.max,
{ x: g.min.x, y: g.max.y },
],
closed: true,
};
}
return null; // circle/arc/text: hier nicht trim-fähig (offener Punkt)
}
/** Ist diese Form trim-fähig (line/polyline/rect)? */
function isTrimmable(d: Drawing2D): boolean {
return (
d.geom.shape === "line" ||
d.geom.shape === "polyline" ||
d.geom.shape === "rect"
);
}
/** Nächstes trim-fähiges Drawing2D der Ebene zum Klickpunkt (innerhalb Toleranz). */
function pickCurveAt(
project: Project,
levelId: string,
pt: Vec2,
): Drawing2D | null {
let best: Drawing2D | null = null;
let bestD = HIT_TOL;
for (const d of project.drawings2d) {
if (d.levelId !== levelId) continue;
if (!isTrimmable(d)) continue;
const curve = geomToCurve(d.geom);
if (!curve) continue;
for (const [a, b] of polylineEdges(curve.pts, curve.closed)) {
const dd = pointSegmentDistance(pt, a, b);
if (dd < bestD) {
bestD = dd;
best = d;
}
}
}
return best;
}
/** Alle anderen trim-fähigen Drawing2D der Ebene als Cutter-Kurven. */
function cuttersFor(
project: Project,
levelId: string,
exceptId: string,
): Curve[] {
const out: Curve[] = [];
for (const d of project.drawings2d) {
if (d.id === exceptId) continue;
if (d.levelId !== levelId) continue;
const curve = geomToCurve(d.geom);
if (curve) out.push(curve);
}
return out;
}
/** Sind zwei Ketten geometrisch identisch (gleiches closed + gleiche Punkte)? */
function sameChain(a: { pts: Vec2[]; closed: boolean }, b: Curve): boolean {
if (a.closed !== b.closed || a.pts.length !== b.pts.length) return false;
for (let i = 0; i < a.pts.length; i++) {
if (!vecEqual(a.pts[i], b.pts[i])) return false;
}
return true;
}
/** Eine getrimmte Rest-Kette als passende Drawing2D-Geometrie. */
function chainToGeom(chain: { pts: Vec2[]; closed: boolean }): Drawing2DGeom {
if (chain.pts.length === 2 && !chain.closed) {
return { shape: "line", a: chain.pts[0], b: chain.pts[1] };
}
return { shape: "polyline", pts: chain.pts, closed: chain.closed };
}
/**
* Wendet einen Trim auf das Projekt an: ersetzt das getroffene Element durch
* seine Rest-Ketten (Attribute geerbt). Bleibt nichts übrig, fällt es weg.
*/
function applyTrim(
p: Project,
target: Drawing2D,
pick: Vec2,
): Project {
const curve = geomToCurve(target.geom);
if (!curve) return p;
const cutters = cuttersFor(p, target.levelId, target.id).map((c) => ({
pts: c.pts,
closed: c.closed,
}));
const rest = trimPolyline(curve.pts, curve.closed, cutters, pick);
// Echtes No-op: genau eine Kette, identisch (gleiche Punkte) zur Quelle →
// nichts wurde weggeschnitten (kein Schnitt mit Cuttern).
if (rest.length === 1 && sameChain(rest[0], curve)) return p;
const replacements: Drawing2D[] = rest
.filter((c) => c.pts.length >= 2)
.map((c) => ({ ...target, id: uniqueId("dr2d"), geom: chainToGeom(c) }));
const others = p.drawings2d.filter((d) => d.id !== target.id);
return { ...p, drawings2d: [...others, ...replacements] };
}
/** Vorschau-Formen der NACH dem Trim verbleibenden Ketten (für den Draft). */
function trimPreview(
project: Project,
target: Drawing2D,
pick: Vec2,
): DraftShape[] {
const curve = geomToCurve(target.geom);
if (!curve) return [];
const cutters = cuttersFor(project, target.levelId, target.id).map((c) => ({
pts: c.pts,
closed: c.closed,
}));
const rest = trimPolyline(curve.pts, curve.closed, cutters, pick);
const out: DraftShape[] = [];
for (const c of rest) {
if (c.pts.length < 2) continue;
if (c.pts.length === 2 && !c.closed) {
out.push({ kind: "line", a: c.pts[0], b: c.pts[1] });
} else {
out.push({ kind: "poly", pts: c.pts, closed: c.closed });
}
}
return out;
}
// ── Zustand ───────────────────────────────────────────────────────────────────
// Einziger Schritt „pick": auf den wegzuschneidenden Abschnitt klicken;
// wiederholend, bis Esc/Enter. „done": beendet.
interface TrimPick extends CommandState {
phase: "pick";
}
interface TrimDone extends CommandState {
phase: "done";
}
type TrimState = TrimPick | TrimDone;
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as TrimDone,
{ draft: null, done: true },
];
export const trimCommand: Command = {
name: "trim",
labelKey: "cmd.trim.label",
prompt: () => "cmd.trim.pick",
accepts: () => ["point"],
options: () => [],
init: (): TrimPick => ({ phase: "pick", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as TrimState;
if (s.phase === "done") return finish();
if (input.kind !== "point") return [s, { draft: null }];
const target = pickCurveAt(ctx.project, ctx.level.id, input.point);
if (!target) {
// Daneben geklickt → Befehl bleibt aktiv (weiter trimmen).
return [s, { draft: null }];
}
const pick = input.point;
// Wiederholend: NICHT done — nach dem Commit bleibt „pick" aktiv.
return [
s,
{ draft: null, commit: (p) => applyTrim(p, target, pick) },
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as TrimState;
if (s.phase !== "pick") return [s, { draft: null }];
const target = pickCurveAt(ctx.project, ctx.level.id, point);
if (!target) return [s, { draft: null }];
const preview = trimPreview(ctx.project, target, point);
return [
s,
{
draft: {
preview,
vertices: [],
hud: { at: point, text: "✂" },
},
},
];
},
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
};
+350
View File
@@ -0,0 +1,350 @@
// Wall — Wand als echter Engine-Befehl (analog polyline, aber je Achssegment
// entsteht ein eigenständiges `Wall`). Schritte:
// 1) „Startpunkt der Wand:" → Punkt
// 2) „Nächster Punkt ( Zurück ):" → Punkt … (chainend: jede weitere Wand
// beginnt am letzten Endpunkt) bis Esc/Enter den Zug beendet.
//
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45) —
// derselbe Eingabepfad wie Linie/Polylinie. Die fertige Wand rendert über die
// bestehende Wand-Darstellung (generatePlan / 3D); der Draft zeigt die Achse +
// eine einfache Band-Vorschau in der Dicke des aktiven Wandtyps.
//
// Herkunft der Wand-Felder (CommandContext):
// • wallTypeId ← ctx.activeWallTypeId (Fallback: project.wallTypes[0].id)
// • height ← aktives Geschoss (ctx.level.floorHeight, sonst 2.6 m)
// • floorId ← ctx.level.id (aktives Geschoss)
// • categoryCode← ctx.defaultCategoryCode (aktive Kategorie, i. d. R. „20")
import type { Wall, WallType } from "../../model/types";
import { wallTypeThickness } from "../../model/types";
import { wallCorners } from "../../model/geometry";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const DEG = Math.PI / 180;
const DEFAULT_HEIGHT = 2.6; // Fallback-Wandhöhe (Meter), wenn das Geschoss keine hat
const DEFAULT_THICKNESS = 0.2; // Fallback-Banddicke für die Vorschau (Meter)
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const segAngleDeg = (a: Vec2, b: Vec2): number =>
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
const MIN_THICKNESS = 0.01; // kleinste sinnvolle Wanddicke (Meter)
/** Tab-Felder je weiteren Punkt: Länge + Winkel relativ zum letzten Punkt. */
const WALL_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/** Zusätzliches Dicke-Feld (nur bei einschichtigen Wandtypen). */
const THICK_FIELD: CommandField = { id: "thick", labelKey: "cmd.field.thickness" };
/** Nächster Punkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `last`. */
function wallNextFromFields(last: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? last;
const length = "length" in locks ? locks.length : segLen(last, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
const ang = angleDeg * DEG;
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
}
/**
* Dicke des aktiven Wandtyps (für die Band-Vorschau). Liest `ctx.activeWallTypeId`;
* fehlt der Typ, Fallback auf den ersten Projekt-Wandtyp bzw. DEFAULT_THICKNESS.
*/
function activeWallThickness(ctx: CommandContext): number {
const wt =
ctx.project.wallTypes.find((t) => t.id === ctx.activeWallTypeId) ??
ctx.project.wallTypes[0];
return wt ? wallTypeThickness(wt) : DEFAULT_THICKNESS;
}
/** Aktives Wandtyp-Objekt (Fallback: erster Projekt-Wandtyp). */
function activeWallType(ctx: CommandContext): WallType | undefined {
return (
ctx.project.wallTypes.find((t) => t.id === ctx.activeWallTypeId) ??
ctx.project.wallTypes[0]
);
}
/** Aktiver Wandtyp-ID; Fallback auf den ersten Projekt-Wandtyp. */
function activeWallTypeId(ctx: CommandContext): string {
const wt = activeWallType(ctx);
return wt ? wt.id : ctx.activeWallTypeId;
}
/** Einschichtig? Nur dann ist das Dicke-Feld sinnvoll (überschreibt die Schicht). */
function isSingleLayer(ctx: CommandContext): boolean {
const wt = activeWallType(ctx);
return !!wt && wt.layers.length === 1;
}
/**
* Liefert die effektive Vorschau-/Commit-Dicke: eine gelockte `thick`-Eingabe
* (nur bei einschichtigen Typen) überschreibt die Wandtyp-Dicke.
*/
function effectiveThickness(ctx: CommandContext, override: number | null): number {
if (override != null && override >= MIN_THICKNESS && isSingleLayer(ctx)) return override;
return activeWallThickness(ctx);
}
/**
* Findet einen einschichtigen Wandtyp mit gegebener Dicke (gleiches Bauteil wie
* `base`) oder erzeugt ihn. Gibt das (evtl. erweiterte) Projekt + die Typ-ID
* zurück — so bleibt die Plan-/3D-Darstellung unverändert (sie liest die Dicke
* aus den Schichten des Wandtyps).
*/
function wallTypeForThickness(
p: Project,
base: WallType,
thickness: number,
): { project: Project; wallTypeId: string } {
const componentId = base.layers[0].componentId;
const near = (a: number, b: number) => Math.abs(a - b) < 1e-4;
const existing = p.wallTypes.find(
(t) =>
t.layers.length === 1 &&
t.layers[0].componentId === componentId &&
near(t.layers[0].thickness, thickness),
);
if (existing) return { project: p, wallTypeId: existing.id };
const nt: WallType = {
id: uniqueId("WT"),
name: `${base.name} ${(thickness * 100).toFixed(0)} cm`,
layers: [{ componentId, thickness }],
};
return { project: { ...p, wallTypes: [...p.wallTypes, nt] }, wallTypeId: nt.id };
}
interface WallIdle extends CommandState {
phase: "start";
/** Sticky Dicke-Übersteuerung (nur einschichtig); null = Wandtyp-Dicke. */
thickOverride: number | null;
/** Ist der aktive Wandtyp einschichtig? (aus ctx in onMove/onInput gesetzt) */
single: boolean;
}
interface WallDrawing extends CommandState {
phase: "next";
points: Vec2[];
cursor: Vec2 | null;
/** Sticky Dicke-Übersteuerung (nur einschichtig); null = Wandtyp-Dicke. */
thickOverride: number | null;
/** Ist der aktive Wandtyp einschichtig? (aus ctx in onMove/onInput gesetzt) */
single: boolean;
}
type WallCmdState = WallIdle | WallDrawing;
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
/**
* Vorschau: Achs-Polylinie + Band-Vorschau je Segment (Dicke = aktiver Wandtyp) +
* HUD (Länge·Winkel des lebenden Segments). Die FERTIGE Wand rendert über die
* bestehende Wand-Darstellung — hier ist nur der Rubber-Band-Entwurf.
*/
function wallDraft(points: Vec2[], cursor: Vec2 | null, thickness: number): ToolDraft {
const all = cursor ? [...points, cursor] : points;
const preview: DraftShape[] = [];
for (let i = 0; i < all.length - 1; i++) {
const a = all[i];
const b = all[i + 1];
if (segLen(a, b) < EPS) continue;
preview.push({ kind: "poly", pts: wallCorners(a, b, thickness), closed: true });
preview.push({ kind: "line", a, b }); // Achse
}
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(last, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
return draft;
}
/**
* Hängt je Achssegment ein `Wall` des aktiven Typs ans Projekt (immutabel). Höhe
* aus dem aktiven Geschoss (floorHeight), Geschoss + Kategorie + Wandtyp aus dem
* Context. Null-Strecken werden übersprungen. Gehrung an Ecken entsteht später
* automatisch aus computeJoins (generatePlan).
*/
function appendWalls(
p: Project,
pts: Vec2[],
ctx: CommandContext,
thickOverride: number | null,
): Project {
const height =
ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : DEFAULT_HEIGHT;
// Dicke-Übersteuerung (nur einschichtig): passenden Wandtyp finden/erzeugen,
// damit Plan/3D — die die Dicke aus dem Wandtyp lesen — korrekt darstellen.
let project = p;
let wallTypeId = activeWallTypeId(ctx);
const base = activeWallType(ctx);
if (
base &&
base.layers.length === 1 &&
thickOverride != null &&
thickOverride >= MIN_THICKNESS &&
Math.abs(thickOverride - wallTypeThickness(base)) > 1e-4
) {
const r = wallTypeForThickness(project, base, thickOverride);
project = r.project;
wallTypeId = r.wallTypeId;
}
const newWalls: Wall[] = [];
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i];
const b = pts[i + 1];
if (segLen(a, b) < EPS) continue;
newWalls.push({
id: uniqueId("W"),
type: "wall",
floorId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
start: a,
end: b,
wallTypeId,
height,
});
}
if (newWalls.length === 0) return p;
return { ...project, walls: [...project.walls, ...newWalls] };
}
const idle = (
thickOverride: number | null = null,
single = false,
): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null, thickOverride, single } as WallIdle,
{ draft: null, done: true },
];
export const wallCommand: Command = {
name: "wall",
labelKey: "cmd.wall.label",
// Wände leben auf Geschossen — wie das Legacy-Werkzeug nur dort aktiv.
floorOnly: true,
prompt: (s) => ((s as WallCmdState).phase === "next" ? "cmd.wall.next" : "cmd.wall.start"),
accepts: (s) =>
(s as WallCmdState).phase === "next" ? ["point", "number", "option"] : ["point", "number"],
options: (s) => {
const ps = s as WallCmdState;
return ps.phase === "next" ? [UNDO] : [];
},
init: (): WallIdle => ({ phase: "start", lastPoint: null, thickOverride: null, single: false }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as WallCmdState;
const thickness = effectiveThickness(ctx, s.thickOverride);
const to = s.thickOverride;
const single = isSingleLayer(ctx);
if (input.kind === "option") {
if (s.phase !== "next") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0)
return [{ phase: "start", lastPoint: null, thickOverride: to, single } as WallIdle, { draft: null }];
const ns: WallDrawing = {
phase: "next",
points: pts,
cursor: s.cursor,
lastPoint: pts[pts.length - 1],
thickOverride: to,
single,
};
return [ns, { draft: wallDraft(pts, s.cursor, thickness) }];
}
return [{ ...s, single } as WallCmdState, { draft: wallDraft(s.points, s.cursor, thickness) }];
}
if (input.kind !== "point") {
return [{ ...s, single } as WallCmdState, { draft: s.phase === "next" ? wallDraft(s.points, s.cursor, thickness) : null }];
}
const pt = input.point;
if (s.phase !== "next") {
const ns: WallDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt, thickOverride: to, single };
return [ns, { draft: wallDraft([pt], pt, thickness) }];
}
// Chaining: jede weitere Wand beginnt am letzten Endpunkt. Null-Strecke
// (Klick auf denselben Punkt) ignorieren — nicht abbrechen.
const last = s.points[s.points.length - 1];
if (segLen(last, pt) < EPS) {
return [{ ...s, single } as WallCmdState, { draft: wallDraft(s.points, s.cursor, thickness) }];
}
const points = [...s.points, pt];
const ns: WallDrawing = { phase: "next", points, cursor: pt, lastPoint: pt, thickOverride: to, single };
return [ns, { draft: wallDraft(points, pt, thickness) }];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as WallCmdState;
if (s.phase !== "next") return [s, { draft: null }];
const ns: WallDrawing = { ...s, cursor: point, single: isSingleLayer(ctx) };
return [ns, { draft: wallDraft(s.points, point, effectiveThickness(ctx, s.thickOverride)) }];
},
// Enter/Space/Rechtsklick: offenen Zug beenden (≥2 Punkte) und committen.
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as WallCmdState;
if (s.phase === "next" && s.points.length >= 2) {
const pts = s.points;
const to = s.thickOverride;
return [
{ phase: "start", lastPoint: null, thickOverride: to, single: s.single } as WallIdle,
{ draft: null, done: true, commit: (p) => appendWalls(p, pts, ctx, to) },
];
}
return idle(s.thickOverride, s.single);
},
onCancel: (state): [CommandState, CommandResult] => {
const s = state as WallCmdState;
return idle(s.thickOverride, s.single);
},
// Tab-Feld-Zyklus im „next"-Schritt: Länge + Winkel relativ zum letzten Punkt.
// Bei EINSCHICHTIGEN Wandtypen zusätzlich ein Dicke-Feld (überschreibt die
// Bandbreite live + auf Commit). Mehrschichtige Typen: kein Dicke-Feld
// (der aktive Wandtyp wird in onMove/onInput aus dem Context ermittelt).
fields: (state) => {
const s = state as WallCmdState;
if (s.phase !== "next") return [];
return s.single ? [...WALL_FIELDS, THICK_FIELD] : WALL_FIELDS;
},
pointFromFields: (state, locks, cursor) => {
const s = state as WallCmdState;
// Gelockte Dicke sticky in den State übernehmen (kein Koordinatenfeld):
// die Engine ruft diese Funktion bei jeder Maus-/Pick-/Enter-Aktion, bevor
// sie den Punkt an onInput reicht — so kennt der Commit die Dicke.
if ("thick" in locks && locks.thick >= MIN_THICKNESS) s.thickOverride = locks.thick;
if (s.phase !== "next" || s.points.length === 0) return null;
return wallNextFromFields(s.points[s.points.length - 1], locks, cursor);
},
fieldValues: (state, locks, cursor): Record<string, number> => {
const s = state as WallCmdState;
if (s.phase !== "next") return {};
const out: Record<string, number> = {};
// Dicke-Feld: gelockt → Lock, sonst die aktuelle (sticky) Übersteuerung.
if ("thick" in locks) out.thick = locks.thick;
else if (s.thickOverride != null) out.thick = s.thickOverride;
if (s.points.length === 0 || !cursor) return out;
const last = s.points[s.points.length - 1];
out.length = segLen(last, cursor);
out.angle = ((segAngleDeg(last, cursor) % 360) + 360) % 360;
return out;
},
};