Browser-BIM (cad): semantisches Modell, abgeleitete 2D/3D-Sichten, Zeichenwerkzeuge

Standalone-Browser-Port von DOSSIER. Enthaelt das semantische Modell mit
Plan-/3D-Ableitung, Zeichen- und Editierwerkzeuge, Rhino-artiges Befehlssystem,
dockbares Panel-System, Resource-Manager, DXF/.lin/.pat-Import, i18n (de/en)
sowie Projektdokumentation und Probe-Harness.
This commit is contained in:
2026-06-30 20:52:27 +02:00
commit ca859c4aa4
157 changed files with 37921 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
// Circle — Mittelpunkt + Radius. Schritte:
// 1) „Mittelpunkt:" → Punkt
// 2) „Radius:" → Punkt (Abstand) ODER getippte Zahl (Radius) → commit
//
// Radius akzeptiert sowohl einen Maus-Punkt (Abstand zum Mittelpunkt) als auch
// eine getippte nackte Zahl. Der Kreis wird in generatePlan als geschlossene
// Polygon-Approximation gerendert.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
interface CircleIdle extends CommandState {
phase: "center";
}
interface CircleDrawing extends CommandState {
phase: "radius";
center: Vec2;
cursor: Vec2 | null;
}
type CircleState = CircleIdle | CircleDrawing;
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
/** Tab-Feld des Radius-Schritts: der Radius selbst. */
const CIRCLE_FIELDS: CommandField[] = [{ id: "radius", labelKey: "cmd.field.radius" }];
/**
* Punkt auf dem Kreis aus gelocktem Radius (oder Cursor-Abstand), so dass
* dist(center, point) = Radius. Die Richtung folgt dem Cursor (sonst +X).
*/
function circlePointFromFields(center: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const r = "radius" in locks ? Math.abs(locks.radius) : cursor ? dist(center, cursor) : 0;
if (cursor) {
const d = dist(center, cursor);
if (d > EPS) {
return { x: center.x + ((cursor.x - center.x) / d) * r, y: center.y + ((cursor.y - center.y) / d) * r };
}
}
return { x: center.x + r, y: center.y };
}
/** Punkte einer Kreis-Approximation (für die Vorschau). */
function circlePts(center: Vec2, r: number): Vec2[] {
const N = 48;
const pts: Vec2[] = [];
for (let i = 0; i < N; i++) {
const t = (i / N) * Math.PI * 2;
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
}
return pts;
}
/** Vorschau (Kreis) + HUD (Radius). */
function circleDraft(center: Vec2, r: number, at: Vec2 | null): ToolDraft {
if (r < EPS) return { preview: [], vertices: [center] };
const preview: DraftShape[] = [{ kind: "poly", pts: circlePts(center, r), closed: true }];
const draft: ToolDraft = { preview, vertices: [center] };
if (at) draft.hud = { at, text: `r ${r.toFixed(2)} m` };
return draft;
}
function appendCircle(p: Project, center: Vec2, r: number, ctx: CommandContext): Project {
if (r < EPS) return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "circle", center, r },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "center", lastPoint: null },
{ draft: null, done: true },
];
export const circleCommand: Command = {
name: "circle",
labelKey: "cmd.circle.label",
prompt: (s) => ((s as CircleState).phase === "radius" ? "cmd.circle.radius" : "cmd.circle.center"),
accepts: (s) => ((s as CircleState).phase === "radius" ? ["point", "number"] : ["point"]),
options: () => [],
init: (): CircleIdle => ({ phase: "center", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as CircleState;
if (s.phase !== "radius") {
if (input.kind !== "point") return [s, { draft: null }];
const ns: CircleDrawing = { phase: "radius", center: input.point, cursor: input.point, lastPoint: input.point };
return [ns, { draft: circleDraft(input.point, 0, null) }];
}
// Radius-Schritt: Zahl = Radius, Punkt = Abstand zum Mittelpunkt.
let r: number;
if (input.kind === "number") r = input.value;
else if (input.kind === "point") r = dist(s.center, input.point);
else {
const rr = s.cursor ? dist(s.center, s.cursor) : 0;
return [s, { draft: circleDraft(s.center, rr, s.cursor) }];
}
const center = s.center;
return [
{ phase: "center", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendCircle(p, center, r, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as CircleState;
if (s.phase !== "radius") return [s, { draft: null }];
const r = dist(s.center, point);
const ns: CircleDrawing = { ...s, cursor: point };
return [ns, { draft: circleDraft(s.center, r, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
// 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 : []),
pointFromFields: (state, locks, cursor) => {
const s = state as CircleState;
if (s.phase !== "radius") return null;
return circlePointFromFields(s.center, locks, cursor);
},
};
+165
View File
@@ -0,0 +1,165 @@
// Copy — wie Move, aber jeder Zielpunkt fügt eine KOPIE der Auswahl (neue IDs)
// an der Ziellage hinzu; das Original bleibt (Rhino-Copy, docs §2.10). Schritte:
// 1) „Basispunkt:" → Punkt
// 2) „Zielpunkt:" → Punkt (Tab length/angle) → Kopie + BLEIBT AKTIV
// → weitere Zielpunkte = weitere Kopien (Basispunkt bleibt), bis Esc/Enter.
//
// Die Auswahl kommt vorab über ctx.selection. Leere Auswahl → No-op + Hinweis.
// Der Commit nutzt `commitTransform` (op:"move", mode:"copy") — eine Kopie der
// Auswahl an die Endlage, Original unverändert; neue IDs via uniqueId (dort).
import { commitTransform } from "../../tools/transform";
import type { TransformSelection } from "../../tools/transform";
import { transformPreview } from "../../tools/transform";
import type {
Command,
CommandField,
CommandResult,
CommandSelection,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../types";
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;
/** Tab-Felder des Zielschritts: Länge + Winkel des Kopier-Versatzes (§2.7). */
const COPY_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? base;
const length = "length" in locks ? locks.length : segLen(base, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(base, ref);
const ang = angleDeg * DEG;
return { x: base.x + Math.cos(ang) * length, y: base.y + Math.sin(ang) * length };
}
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
}
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
}
interface CopyBase extends CommandState {
phase: "base";
}
interface CopyTarget extends CommandState {
phase: "target";
sel: CommandSelection;
base: Vec2;
cursor: Vec2 | null;
}
interface CopyDone extends CommandState {
phase: "done";
}
type CopyState = CopyBase | CopyTarget | CopyDone;
/** Vorschau EINER Kopie der Auswahl an der Cursor-Lage. */
function copyDraft(
project: Project,
sel: CommandSelection,
base: Vec2,
target: Vec2,
): ToolDraft {
const preview = transformPreview(
project,
toTransformSel(sel),
"move",
[base, target],
"copy",
1,
);
return {
preview,
vertices: [base],
hud: {
at: target,
text: `${segLen(base, target).toFixed(2)} m · ${Math.abs(
(segAngleDeg(base, target) + 360) % 360,
).toFixed(0)}°`,
},
};
}
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as CopyDone,
{ draft: null, done: true },
];
export const copyCommand: Command = {
name: "copy",
labelKey: "cmd.copy.label",
prompt: (s) => {
const cs = s as CopyState;
if (cs.phase === "target") return "cmd.copy.target";
if (cs.phase === "done") return "cmd.copy.empty";
return "cmd.copy.base";
},
accepts: () => ["point"],
options: () => [],
init: (): CopyBase => ({ phase: "base", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as CopyState;
if (s.phase === "done") return finish();
if (!hasSelection(ctx.selection)) {
return [{ phase: "done", lastPoint: null } as CopyDone, { draft: null, done: true }];
}
if (input.kind !== "point") {
if (s.phase === "target") {
return [s, { draft: s.cursor ? copyDraft(ctx.project, s.sel, s.base, s.cursor) : null }];
}
return [s, { draft: null }];
}
const pt = input.point;
if (s.phase !== "target") {
const next: CopyTarget = {
phase: "target",
sel: ctx.selection,
base: pt,
cursor: pt,
lastPoint: pt,
};
return [next, { draft: copyDraft(ctx.project, next.sel, pt, pt) }];
}
// Zielpunkt → eine Kopie committen, ABER aktiv bleiben (wiederholend): der
// Befehl kehrt NICHT in den Ruhezustand zurück (kein done), Basispunkt
// bleibt; jeder weitere Zielpunkt = weitere Kopie, bis Esc/Enter.
const base = s.base;
const sel = s.sel;
const next: CopyTarget = { ...s, cursor: pt, lastPoint: pt };
return [
next,
{
draft: copyDraft(ctx.project, sel, base, pt),
commit: (p) => commitTransform(p, toTransformSel(sel), "move", [base, pt], "copy", 1),
},
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as CopyState;
if (s.phase !== "target") return [s, { draft: null }];
const ns: CopyTarget = { ...s, cursor: point };
return [ns, { draft: copyDraft(ctx.project, s.sel, s.base, point) }];
},
// Enter/Space/Rechtsklick: Copy-Serie beenden (kein Commit hier).
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
fields: (state) => ((state as CopyState).phase === "target" ? COPY_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as CopyState;
if (s.phase !== "target") return null;
return targetFromFields(s.base, locks, cursor);
},
};
+37
View File
@@ -0,0 +1,37 @@
// Import — öffnet den DXF-Datei-Dialog (in App montiert) und beendet sich
// sofort. Der eigentliche Import (Datei lesen → parseDxf → addContextObjects)
// läuft asynchron über das App-onChange des versteckten <input type=file>; ein
// Command kann den Datei-Dialog nicht selbst „committen" (er ist DOM-/async-
// seitig). Daher koppelt dieser Befehl nur den Trigger an (über den Modul-Hook
// dxfImportHook) und gibt keine Mutation zurück.
//
// Bezeichner englisch, sichtbarer Text via t() (Namespace `cmd.import.*`).
import { openDxfImport } from "../../io/dxfImportHook";
import type { Command, CommandResult, CommandState } from "../types";
const IDLE: CommandState = { phase: "idle", lastPoint: null };
/** Öffnet den Datei-Dialog und beendet den Befehl sofort (keine Mutation). */
function open(): [CommandState, CommandResult] {
openDxfImport();
return [{ ...IDLE }, { draft: null, done: true }];
}
export const importCommand: Command = {
name: "import",
labelKey: "cmd.import.label",
prompt: () => "cmd.import.prompt",
accepts: () => [],
options: () => [],
init: () => ({ ...IDLE }),
// Der Befehl hat keine Schritte: Start/Enter/Input öffnet den Dialog + endet.
onInput: () => open(),
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
onConfirm: () => open(),
onCancel: (): [CommandState, CommandResult] => [
{ ...IDLE },
{ draft: null, done: true },
],
};
+140
View File
@@ -0,0 +1,140 @@
// Line — erster Befehl der Command-Engine (portiert lineTool aus
// src/tools/tools.ts). Zwei Punkte → Drawing2D{shape:"line"}.
//
// Schritte:
// 1) „Start of line:" → Punkt (Klick ODER getippt: 0,0 / r3,0 / 3<45)
// 2) „End of line:" → Punkt → commit Drawing2D line → done
//
// Akzeptiert in JEDEM Punkt-Schritt sowohl Maus-Picks (gesnappt) als auch
// getippte Koordinaten — das ist die Kern-Vereinheitlichung gegenüber dem alten
// Tool. Live-Vorschau + HUD (Länge/Winkel) wie die bestehenden Tools.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
DraftShape,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
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;
/** Tab-Felder des End-Schritts: erst Länge, dann Winkel (§2.7). */
const LINE_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/**
* Endpunkt aus gelockten Feldern (length/angle) + Cursor: ungelockte Werte
* folgen dem Cursor relativ zum Startpunkt `a`.
*/
function lineEndFromFields(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 };
}
interface LineIdle extends CommandState {
phase: "start";
}
interface LineDrawing extends CommandState {
phase: "end";
a: Vec2;
cursor: Vec2 | null;
}
type LineState = LineIdle | LineDrawing;
/** Vorschau (Rubber-Band) + HUD (Länge·Winkel) für das lebende Segment. */
function lineDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
const preview: DraftShape[] = [];
if (cursor && segLen(a, cursor) >= EPS) preview.push({ kind: "line", a, b: cursor });
const draft: ToolDraft = { preview, vertices: [a] };
if (cursor && segLen(a, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(a, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
return draft;
}
/** Hängt eine 2D-Linie ans Projekt (immutabel). Farbe/Strich erbt die Kategorie. */
function appendLine(p: import("../types").Project, a: Vec2, b: Vec2, ctx: CommandContext) {
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "line", a, b },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
export const lineCommand: Command = {
name: "line",
labelKey: "cmd.line.label",
prompt: (s) => ((s as LineState).phase === "end" ? "cmd.line.end" : "cmd.line.start"),
accepts: () => ["point", "number"],
options: () => [],
init: (): LineIdle => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as LineState;
// Beide Punkt-Schritte erwarten einen aufgelösten Punkt; die Engine reicht
// getippte Koordinaten bereits als {kind:"point"} herein.
if (input.kind !== "point") return [s, { draft: s.phase === "end" ? lineDraft(s.a, s.cursor) : null }];
const pt = input.point;
if (s.phase !== "end") {
const next: LineDrawing = { phase: "end", a: pt, cursor: pt, lastPoint: pt };
return [next, { draft: lineDraft(pt, pt) }];
}
// Zweiter Punkt: Null-Strecke verwerfen, sonst committen.
if (segLen(s.a, pt) < EPS) {
return [{ phase: "start", lastPoint: null }, { draft: null, done: true }];
}
const a = s.a;
return [
{ phase: "start", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendLine(p, a, pt, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as LineState;
if (s.phase !== "end") return [s, { draft: null }];
const next: LineDrawing = { ...s, cursor: point };
return [next, { draft: lineDraft(s.a, point) }];
},
// Line braucht genau zwei Punkte; Enter/Rechtsklick im offenen Entwurf bricht ab.
onConfirm: (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null },
{ draft: null, done: true },
],
onCancel: (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null },
{ draft: null, done: true },
],
// Tab-Feld-Zyklus nur im End-Schritt: Länge + Winkel relativ zum Startpunkt.
fields: (state) => ((state as LineState).phase === "end" ? LINE_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as LineState;
if (s.phase !== "end") return null;
return lineEndFromFields(s.a, locks, cursor);
},
};
+181
View File
@@ -0,0 +1,181 @@
// Move — verschiebt die AKTUELLE Auswahl (Wände + ein Drawing2D) um
// delta = Ziel Basis (Rhino-Move, docs §2.10/§3.4 Tier-1). Schritte:
// 1) „Basispunkt:" → Punkt
// 2) „Zielpunkt:" → Punkt (mit Tab-Feldern length/angle wie Line) → commit
//
// Die Auswahl kommt vorab über ctx.selection (erst selektieren, dann Befehl).
// Ist sie leer, ist nichts zu verschieben: kurzer No-op mit Hinweis-Prompt
// („Nichts gewählt") und Beenden — das ist der einfache, robuste Weg (statt
// einen zweiten, divergierenden Pick-Pfad für die Objektwahl aufzumachen; die
// Selektion läuft schon sauber über die Plan-Ansicht/Store).
//
// Der Commit nutzt den bestehenden, getesteten `commitTransform` (op:"move",
// mode:"move") statt eine zweite Verschiebe-Logik zu duplizieren — er bewegt
// Wände UND alle 2D-Formen immutabel an die Endlage.
import { commitTransform } from "../../tools/transform";
import type { TransformSelection } from "../../tools/transform";
import { transformPreview } from "../../tools/transform";
import type {
Command,
CommandField,
CommandResult,
CommandSelection,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../types";
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;
/** Tab-Felder des Zielschritts: Länge + Winkel des Verschiebevektors (§2.7). */
const MOVE_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/** Zielpunkt aus gelockten Feldern (length/angle) + Cursor, relativ zum Basispunkt. */
function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? base;
const length = "length" in locks ? locks.length : segLen(base, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(base, ref);
const ang = angleDeg * DEG;
return { x: base.x + Math.cos(ang) * length, y: base.y + Math.sin(ang) * length };
}
/** Hat die Auswahl überhaupt etwas Verschiebbares? */
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 Basispunkt setzen, dann Ziel (mit Auswahl-Snapshot vom Start).
interface MoveBase extends CommandState {
phase: "base";
}
interface MoveTarget extends CommandState {
phase: "target";
sel: CommandSelection;
base: Vec2;
cursor: Vec2 | null;
}
// „done": leere Auswahl → No-op, Befehl beendet.
interface MoveDone extends CommandState {
phase: "done";
}
type MoveState = MoveBase | MoveTarget | MoveDone;
/** Live-Vorschau der Auswahl an der aktuellen Cursor-Lage (Move). */
function moveDraft(
project: Project,
sel: CommandSelection,
base: Vec2,
target: Vec2,
): ToolDraft {
const preview = transformPreview(
project,
toTransformSel(sel),
"move",
[base, target],
"move",
1,
);
return {
preview,
vertices: [base],
hud: {
at: target,
text: `${segLen(base, target).toFixed(2)} m · ${Math.abs(
(segAngleDeg(base, target) + 360) % 360,
).toFixed(0)}°`,
},
};
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as MoveDone,
{ draft: null, done: true },
];
export const moveCommand: Command = {
name: "move",
labelKey: "cmd.move.label",
prompt: (s) => {
const ms = s as MoveState;
if (ms.phase === "target") return "cmd.move.target";
if (ms.phase === "done") return "cmd.move.empty";
return "cmd.move.base";
},
accepts: () => ["point"],
options: () => [],
// Beim Start die Auswahl aus dem Kontext einfrieren — dafür braucht init()
// den Kontext nicht, das macht onInput beim ersten Aufruf; bis dahin „base".
// Damit die leere Auswahl SOFORT (vor dem ersten Klick) auffällt, prüfen wir
// sie zusätzlich im ersten onMove/onInput.
init: (): MoveBase => ({ phase: "base", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as MoveState;
if (s.phase === "done") return idle();
// Leere Auswahl → nichts zu verschieben (No-op + Hinweis, dann beenden).
if (!hasSelection(ctx.selection)) {
return [{ phase: "done", lastPoint: null } as MoveDone, { draft: null, done: true }];
}
if (input.kind !== "point") {
if (s.phase === "target") {
return [s, { draft: s.cursor ? moveDraft(ctx.project, s.sel, s.base, s.cursor) : null }];
}
return [s, { draft: null }];
}
const pt = input.point;
if (s.phase !== "target") {
// Basispunkt gesetzt → Auswahl-Snapshot mitführen.
const next: MoveTarget = {
phase: "target",
sel: ctx.selection,
base: pt,
cursor: pt,
lastPoint: pt,
};
return [next, { draft: moveDraft(ctx.project, next.sel, pt, pt) }];
}
// Zielpunkt → commit (Auswahl an die Endlage verschieben).
const base = s.base;
const sel = s.sel;
return [
{ phase: "done", lastPoint: null } as MoveDone,
{
draft: null,
done: true,
commit: (p) => commitTransform(p, toTransformSel(sel), "move", [base, pt], "move", 1),
},
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as MoveState;
if (s.phase !== "target") return [s, { draft: null }];
const ns: MoveTarget = { ...s, cursor: point };
return [ns, { draft: moveDraft(ctx.project, s.sel, s.base, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
// Tab-Feld-Zyklus nur im Zielschritt: Länge + Winkel relativ zum Basispunkt.
fields: (state) => ((state as MoveState).phase === "target" ? MOVE_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as MoveState;
if (s.phase !== "target") return null;
return targetFromFields(s.base, locks, cursor);
},
};
+339
View File
@@ -0,0 +1,339 @@
// Offset — DAS Architektur-Primitiv (docs §2.10/§3.4 Tier-1, §3.6 persistente
// Distanz). Erzeugt eine parallel versetzte Kopie einer 2D-Kurve (line/polyline/
// rect). Schritte:
// 1) Kurve bestimmen: ist genau EIN passendes Drawing2D (line/polyline/rect)
// selektiert, wird es genommen; sonst „Kurve wählen:" → Klick auf eine Kurve.
// 2) „Seite/Distanz:" → Distanz tippen (PERSISTENT als Default über Aufrufe,
// modulweite Variable) ODER Seite per Klick (Vorzeichen aus der Klickseite
// relativ zur Kurve). → commit neue versetzte Drawing2D-Kurve.
//
// Distanz-Vorzeichen: Konvention `+d = links` (kernel2d/leftNormal). Die
// Klickseite bestimmt links/rechts: liegt der Klick links der Kurve (bzw. der
// nächsten Kante), wird +|d| genommen, sonst |d|. Eine getippte Zahl nutzt das
// Vorzeichen der zuletzt gewählten Seite (Default links/+), bzw. ihr eigenes
// Vorzeichen, wenn negativ getippt.
import type { Drawing2D, Drawing2DGeom } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { leftNormal, normalize, sub } from "../../model/geometry";
import {
offsetPolyline,
offsetSegment,
pointSegmentDistance,
polylineEdges,
} from "../../geometry/kernel2d";
import type {
CmdInput,
Command,
CommandContext,
CommandResult,
CommandSelection,
CommandState,
DraftShape,
Project,
Vec2,
} from "../types";
const EPS = 1e-6;
const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Kurvenwahl
/**
* Persistente Offset-Distanz über Aufrufe hinweg (§2.4/§3.6 — Nutzer erwarten,
* dass die letzte Distanz als Default wiederkommt). Modulweite Variable, |d|;
* das Vorzeichen (Seite) wird je Offset frisch aus Klick/Eingabe bestimmt.
*/
let lastDistance = 0.5;
/** Zuletzt gewählte Seite als Vorzeichen (+1 = links, 1 = rechts). */
let lastSign = 1;
// ── Kurven-Abstraktion: line/polyline/rect → Punktliste + closed ──────────────
interface Curve {
pts: Vec2[];
closed: boolean;
}
/** Wandelt eine offset-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") {
// Rechteck als geschlossene 4-Punkt-Polylinie offsetten (Task).
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 offset-fähig
}
/** Ist diese Form offset-fähig (line/polyline/rect)? */
function isOffsetable(d: Drawing2D): boolean {
return d.geom.shape === "line" || d.geom.shape === "polyline" || d.geom.shape === "rect";
}
/** Genau ein passendes Drawing2D vorselektiert? Dann liefere es. */
function singleSelectedCurve(project: Project, sel: CommandSelection): Drawing2D | null {
if (!sel.drawingId || sel.wallIds.length > 0) return null;
const d = project.drawings2d.find((x) => x.id === sel.drawingId);
if (d && isOffsetable(d)) return d;
return null;
}
/** Nächstes offset-fähiges Drawing2D 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;
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;
}
/**
* Vorzeichen (+links / rechts) für einen Klickpunkt relativ zur Kurve: die
* nächste Kante bestimmen, dann das Vorzeichen der Skalarprojektion von
* (klick Kantenmitte) auf die LINKS-Normale der Kante. So zeigt das Resultat
* auf die angeklickte Seite (Konvention `+d = links`).
*/
function sideSign(curve: Curve, pt: Vec2): number {
let bestD = Infinity;
let sign = lastSign;
for (const [a, b] of polylineEdges(curve.pts, curve.closed)) {
const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const dd = pointSegmentDistance(pt, a, b);
if (dd < bestD) {
bestD = dd;
const u = normalize(sub(b, a));
const n = leftNormal(u);
const rel = sub(pt, mid);
const s = rel.x * n.x + rel.y * n.y;
sign = s >= 0 ? 1 : -1;
}
}
return sign;
}
/** Offset einer Kurve um die vorzeichenbehaftete Distanz `d` → neue Punktliste. */
function offsetCurve(curve: Curve, d: number): Vec2[] {
if (!curve.closed && curve.pts.length === 2) {
const [a, b] = offsetSegment(curve.pts[0], curve.pts[1], d);
return [a, b];
}
return offsetPolyline(curve.pts, d, curve.closed);
}
/** Versetzte Form als neues Drawing2D (gleiche Attribute, neue ID/Geometrie). */
function offsetGeom(src: Drawing2D, d: number): Drawing2DGeom | null {
const g = src.geom;
if (g.shape === "line") {
const [a, b] = offsetSegment(g.a, g.b, d);
return { shape: "line", a, b };
}
if (g.shape === "polyline") {
return { shape: "polyline", pts: offsetPolyline(g.pts, d, g.closed), closed: g.closed };
}
if (g.shape === "rect") {
const curve = geomToCurve(g)!;
return { shape: "polyline", pts: offsetPolyline(curve.pts, d, true), closed: true };
}
return null;
}
/** Vorschau-Formen der versetzten Kurve (für den Draft). */
function offsetDraftShapes(curve: Curve, d: number): DraftShape[] {
const pts = offsetCurve(curve, d);
if (pts.length < 2) return [];
if (!curve.closed && pts.length === 2) return [{ kind: "line", a: pts[0], b: pts[1] }];
return [{ kind: "poly", pts, closed: curve.closed }];
}
function appendOffset(p: Project, src: Drawing2D, d: number): Project {
const geom = offsetGeom(src, d);
if (!geom) return p;
const copy: Drawing2D = { ...src, id: uniqueId("dr2d"), geom };
return { ...p, drawings2d: [...p.drawings2d, copy] };
}
// ── Zustand ───────────────────────────────────────────────────────────────────
// „pick": Kurve wählen (keine passende Vorselektion). „side": Kurve steht fest,
// jetzt Seite/Distanz. „done": Befehl beendet.
interface OffPick extends CommandState {
phase: "pick";
}
interface OffSide extends CommandState {
phase: "side";
drawingId: string;
cursor: Vec2 | null;
}
interface OffDone extends CommandState {
phase: "done";
}
type OffState = OffPick | OffSide | OffDone;
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as OffDone,
{ draft: null, done: true },
];
/** Aktuelle Kurve aus dem Side-Zustand (oder null, wenn weg). */
function curveOf(project: Project, drawingId: string): { src: Drawing2D; curve: Curve } | null {
const src = project.drawings2d.find((x) => x.id === drawingId);
if (!src) return null;
const curve = geomToCurve(src.geom);
if (!curve) return null;
return { src, curve };
}
export const offsetCommand: Command = {
name: "offset",
labelKey: "cmd.offset.label",
prompt: (s) => {
const os = s as OffState;
if (os.phase === "side") return "cmd.offset.side";
return "cmd.offset.pick";
},
// Side-Schritt: Punkt (Seite klicken) ODER Zahl (Distanz tippen). Auch der
// „pick"-Schritt nimmt eine Zahl an, damit eine getippte Distanz sofort greift,
// wenn eine Kurve vorselektiert ist (dann wird „pick" live wie „side" behandelt).
accepts: () => ["point", "number"],
options: () => [],
// init() kennt den Kontext nicht; die Vorselektion wird im ersten onMove/
// onInput ausgewertet (geht direkt auf „side", wenn genau eine Kurve gewählt
// ist). Bis dahin „pick".
init: (): OffPick => ({ phase: "pick", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as OffState;
if (s.phase === "done") return finish();
// Vorselektion: gibt es genau eine passende Kurve, direkt in den Side-Schritt
// springen (auch wenn die Engine erst onInput aufruft).
if (s.phase === "pick") {
const pre = singleSelectedCurve(ctx.project, ctx.selection);
if (pre) {
// Eingabe im selben Schritt direkt als Side/Distanz behandeln.
return offsetSideInput(
{ phase: "side", drawingId: pre.id, cursor: null, lastPoint: null },
input,
ctx,
);
}
// Keine Vorselektion → Kurve per Klick wählen.
if (input.kind === "point") {
const hit = pickCurveAt(ctx.project, ctx.level.id, input.point);
if (!hit) return [s, { draft: null }];
const ns: OffSide = { phase: "side", drawingId: hit.id, cursor: null, lastPoint: null };
return [ns, { draft: null }];
}
return [s, { draft: null }];
}
// phase === "side"
return offsetSideInput(s as OffSide, input, ctx);
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as OffState;
// Vorselektion live: in „pick" mit genau einer Kurve → als Side behandeln.
if (s.phase === "pick") {
const pre = singleSelectedCurve(ctx.project, ctx.selection);
if (pre) {
return offsetSideMove({ phase: "side", drawingId: pre.id, cursor: point, lastPoint: null }, point, ctx);
}
return [s, { draft: null }];
}
if (s.phase === "side") return offsetSideMove(s as OffSide, point, ctx);
return [s, { draft: null }];
},
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
// KEIN Tab-Feld-Zyklus: die Offset-Distanz ist ein reiner Skalar, der als
// `number`-Eingabe in den Schritt läuft (nicht als feld-erzeugter Punkt). So
// committet eine getippte Distanz direkt; die Seite kommt per Klick/Vorzeichen.
};
// ── Side-Schritt-Logik (für Vorselektion UND echten pick wiederverwendet) ─────
/** Eingabe im Side-Schritt: Punkt (Seite klicken) ODER Zahl (Distanz tippen). */
function offsetSideInput(
s: OffSide,
input: CmdInput,
ctx: CommandContext,
): [CommandState, CommandResult] {
const cc = curveOf(ctx.project, s.drawingId);
if (!cc) return finish();
const { src, curve } = cc;
if (input.kind === "number") {
// Getippte Distanz: Betrag merken; Vorzeichen aus der zuletzt gewählten
// Seite (bzw. dem getippten Vorzeichen, falls negativ).
const typed = input.value;
const mag = Math.abs(typed);
if (mag < EPS) return [s, { draft: null }];
lastDistance = mag;
const sign = typed < 0 ? -1 : lastSign;
lastSign = sign;
const d = sign * mag;
return [
{ phase: "done", lastPoint: null } as OffDone,
{ draft: null, done: true, commit: (p) => appendOffset(p, src, d) },
];
}
if (input.kind === "point") {
// Seite per Klick: Vorzeichen aus der Klickseite, Betrag = letzte Distanz.
const sign = sideSign(curve, input.point);
lastSign = sign;
const d = sign * lastDistance;
return [
{ phase: "done", lastPoint: null } as OffDone,
{ draft: null, done: true, commit: (p) => appendOffset(p, src, d) },
];
}
return [s, { draft: null }];
}
/** Hover im Side-Schritt: Vorschau der versetzten Kurve an der Cursor-Seite. */
function offsetSideMove(
s: OffSide,
point: Vec2,
ctx: CommandContext,
): [CommandState, CommandResult] {
const cc = curveOf(ctx.project, s.drawingId);
if (!cc) return [s, { draft: null }];
const { curve } = cc;
const sign = sideSign(curve, point);
const d = sign * lastDistance;
const preview = offsetDraftShapes(curve, d);
const ns: OffSide = { ...s, cursor: point };
return [
ns,
{
draft: {
preview,
vertices: [],
hud: { at: point, text: `${lastDistance.toFixed(2)} m` },
},
},
];
}
+181
View File
@@ -0,0 +1,181 @@
// Polyline — mehrteiliger Zug (portiert polylineTool). Schritte:
// 1) „Startpunkt:" → Punkt
// 2) „Nächster Punkt ( Schliessen Zurück ):" → Punkt … (Enter beendet offen,
// „Schliessen" schließt; Klick auf den Startpunkt schließt ebenfalls)
//
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45).
import type { Drawing2D } from "../../model/types";
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 Zug
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 POLY_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 polyNextFromFields(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 };
}
interface PolyIdle extends CommandState {
phase: "start";
}
interface PolyDrawing extends CommandState {
phase: "next";
points: Vec2[];
cursor: Vec2 | null;
}
type PolyState = PolyIdle | PolyDrawing;
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
/** Vorschau (Zug + Gummiband zum Cursor) + HUD (Länge·Winkel des letzten Segments). */
function polyDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: false }];
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 2D-Polylinie ans Projekt (immutabel). Farbe/Strich erbt die Kategorie. */
function appendPolyline(
p: Project,
pts: Vec2[],
closed: boolean,
ctx: CommandContext,
): Project {
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "polyline", pts, closed },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null },
{ draft: null, done: true },
];
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"],
options: (s) => {
const ps = s as PolyState;
if (ps.phase !== "next") return [];
return ps.points.length >= 2 ? [CLOSE, UNDO] : [UNDO];
},
init: (): PolyIdle => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as PolyState;
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 }, { 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 (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
];
}
return [s, { draft: polyDraft(s.points, s.cursor) }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor) : 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) }];
}
// Klick nahe Startpunkt → schließen.
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null },
{ 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) }];
},
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) }];
},
// Enter/Space/Rechtsklick: offenen Zug beenden (≥2 Punkte) und committen.
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as PolyState;
if (s.phase === "next" && s.points.length >= 2) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, false, ctx) },
];
}
return idle();
},
onCancel: (): [CommandState, CommandResult] => idle(),
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten
// Punkt. (Erster Punkt = freie Koordinate, kein Feld-Modus.)
fields: (state) => ((state as PolyState).phase === "next" ? POLY_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as PolyState;
if (s.phase !== "next" || s.points.length === 0) return null;
return polyNextFromFields(s.points[s.points.length - 1], locks, cursor);
},
};
+143
View File
@@ -0,0 +1,143 @@
// Rectangle — zwei Ecken (portiert rectTool). Schritte:
// 1) „Erste Ecke:" → Punkt
// 2) „Gegenüberliegende Ecke:" → Punkt → commit Drawing2D rect → done
//
// 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).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
/** Tab-Felder des zweiten-Ecke-Schritts: Breite, Höhe. */
const RECT_FIELDS: CommandField[] = [
{ id: "width", labelKey: "cmd.field.width" },
{ id: "height", labelKey: "cmd.field.height" },
];
/**
* Gegenüberliegende Ecke aus gelockten Feldern (width/height) + Cursor: eine
* gelockte Kantenlänge nimmt ihren Betrag, das Vorzeichen (Quadrant) folgt dem
* Cursor relativ zur ersten Ecke `a`; ungelockt folgt die Kante direkt dem Cursor.
*/
function rectCornerFromFields(a: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const cur = cursor ?? a;
const dx = cur.x - a.x;
const dy = cur.y - a.y;
const sx = dx < 0 ? -1 : 1;
const sy = dy < 0 ? -1 : 1;
const w = "width" in locks ? sx * Math.abs(locks.width) : dx;
const h = "height" in locks ? sy * Math.abs(locks.height) : dy;
return { x: a.x + w, y: a.y + h };
}
interface RectIdle extends CommandState {
phase: "corner1";
}
interface RectDrawing extends CommandState {
phase: "corner2";
a: Vec2;
cursor: Vec2 | null;
}
type RectState = RectIdle | RectDrawing;
function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
return {
min: { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) },
max: { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) },
};
}
/** 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 },
];
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` },
};
}
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
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "rect", min: g.min, max: g.max },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "corner1", lastPoint: null },
{ draft: null, done: true },
];
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 }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as RectState;
if (input.kind !== "point") {
return [s, { draft: s.phase === "corner2" ? rectDraft(s.a, s.cursor) : null }];
}
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) }];
}
const a = s.a;
return [
{ phase: "corner1", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendRect(p, a, 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) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
// Tab-Feld-Zyklus nur im „corner2"-Schritt: Breite + Höhe (signiert nach Cursor).
fields: (state) => ((state as RectState).phase === "corner2" ? RECT_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as RectState;
if (s.phase !== "corner2") return null;
return rectCornerFromFields(s.a, locks, cursor);
},
};
+60
View File
@@ -0,0 +1,60 @@
// Terrain — erzeugt aus dem (ersten) ContourSet der Kontext-Schicht ein
// Gelände-TIN und hängt es an `project.context`. Null-Schritt-Befehl: er
// committet sofort beim Start (über onConfirm, das die Engine direkt aufruft,
// wenn keine Punkte erwartet werden) — wir lösen das robust über onInput/
// onConfirm, die beide dieselbe Mutation liefern.
//
// Die Mutation ist rein (kein Store/React): sie sucht den ersten ContourSet in
// p.context, ruft generateTerrainFromContours und fügt das TIN hinzu. Liefert
// das TIN kein sinnvolles Mesh (zu wenige/kollineare Punkte), bleibt p
// unverändert.
//
// Bezeichner englisch, sichtbarer Text via t() (Namespace `cmd.terrain.*`).
import { generateTerrainFromContours } from "../../model/terrain";
import type { ContextObject } from "../../model/types";
import type {
Command,
CommandResult,
CommandState,
Project,
} from "../types";
/** Hängt ein aus dem ersten ContourSet abgeleitetes TIN an (immutabel). */
function appendTerrain(p: Project): Project {
const context: ContextObject[] = p.context ?? [];
const set = context.find((o) => o.type === "contourSet");
if (!set || set.type !== "contourSet") return p;
const terrain = generateTerrainFromContours(set.contours);
if (terrain.indices.length === 0) return p; // kein sinnvolles TIN
return { ...p, context: [...context, terrain] };
}
/** Ergebnis, das den Befehl sofort beendet und das TIN committet. */
const COMMIT_DONE: CommandResult = {
draft: null,
done: true,
commit: appendTerrain,
};
const IDLE: CommandState = { phase: "idle", lastPoint: null };
export const terrainCommand: Command = {
name: "terrain",
labelKey: "cmd.terrain.label",
prompt: () => "cmd.terrain.prompt",
// Erwartet keine echten Eingaben; Enter/Start committet sofort.
accepts: () => [],
options: () => [],
init: () => ({ ...IDLE }),
// Jeglicher Input beendet den Befehl mit dem Commit (es gibt keine Schritte).
onInput: (): [CommandState, CommandResult] => [{ ...IDLE }, COMMIT_DONE],
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
// Enter/Space/Rechtsklick: Gelände erzeugen + beenden.
onConfirm: (): [CommandState, CommandResult] => [{ ...IDLE }, COMMIT_DONE],
onCancel: (): [CommandState, CommandResult] => [
{ ...IDLE },
{ draft: null, done: true },
],
};