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:
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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` },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,439 @@
|
||||
// Command-Runner / State-Machine (docs/design/rhino-command-system.md §3.2, §2.3,
|
||||
// §2.6). Hält den aktiven Befehl + Schritt-Zustand, einen Prompt-Stack-Grundbau
|
||||
// (für künftige transparente Befehle §2.6) und `lastCommand` (Leer-Enter bzw.
|
||||
// Rechtsklick wiederholt den letzten Befehl §2.3).
|
||||
//
|
||||
// Eingaben kommen aus DREI Quellen, die alle in DENSELBEN Schritt fließen:
|
||||
// • getippte Command-Line-Eingabe (submitText)
|
||||
// • Maus-Pick aus PlanView (pick) / Hover (move)
|
||||
// • Klick auf eine Inline-Option (chooseOption)
|
||||
//
|
||||
// Die Engine ist UI-agnostisch: Kontext (Projekt/Level/Kategorie), Snapping und
|
||||
// das Anwenden von draft/commit liefert ein injizierter `EngineHost`. Bei jeder
|
||||
// Zustandsänderung ruft sie `emit()` → React-Komponenten (CommandLine, App)
|
||||
// abonnieren via subscribe() und re-rendern.
|
||||
|
||||
import type { Project, Vec2 } from "../model/types";
|
||||
import type { SnapResult, ToolDraft } from "../tools/types";
|
||||
import { parseInput, resolvePoint } from "./parseInput";
|
||||
import { getCommand, resolveCommand, autocomplete } from "./registry";
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
CmdInput,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Brücke zur App: liefert den Live-Kontext + Snapping und wendet die Ergebnisse
|
||||
* an (Vorschau zeigen, committen). So bleibt die Engine frei von React/Store.
|
||||
*/
|
||||
export interface EngineHost {
|
||||
/** Aktueller Befehls-Kontext (Projekt, aktive Ebene/Kategorie, lastPoint). */
|
||||
context(lastPoint: Vec2 | null): CommandContext;
|
||||
/** Snap für einen rohen Cursor-Punkt (oder null), inkl. der Draft-Punkte. */
|
||||
snap(raw: Vec2, draftPoints: Vec2[]): SnapResult | null;
|
||||
/** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz). */
|
||||
pxPerMeter(): number;
|
||||
/** Vorschau setzen (oder löschen). */
|
||||
setDraft(draft: ToolDraft | null): void;
|
||||
/** Commit immutabel anwenden. */
|
||||
commit(mutate: (p: Project) => Project): void;
|
||||
/** Ist die aktive Ebene ein Geschoss? (für floorOnly-Befehle). */
|
||||
isFloor(): boolean;
|
||||
}
|
||||
|
||||
/** Öffentlicher Schnappschuss für die Command-Line-UI (rein lesend). */
|
||||
export interface EngineView {
|
||||
/** Läuft gerade ein Befehl? */
|
||||
active: boolean;
|
||||
/** Aktueller Prompt-Key (i18n) oder null (Ruhezustand). */
|
||||
promptKey: string | null;
|
||||
/** Inline-Optionen des aktuellen Schritts. */
|
||||
options: { id: string; labelKey: string; value?: string }[];
|
||||
/** Name des aktiven Befehls (für Anzeige), null im Ruhezustand. */
|
||||
commandName: string | null;
|
||||
/**
|
||||
* Geordnete Zahlenfelder des aktuellen Schritts (Tab-Feld-Zyklus §2.7). Leer,
|
||||
* wenn der aktive Befehl/Schritt keine Felder anbietet. `value` ist der
|
||||
* gelockte Wert (oder null = folgt der Maus), `active` markiert das aktuelle
|
||||
* Tab-Ziel.
|
||||
*/
|
||||
fields: { id: string; labelKey: string; value: number | null; active: boolean }[];
|
||||
}
|
||||
|
||||
export class CommandEngine {
|
||||
private host: EngineHost;
|
||||
private command: Command | null = null;
|
||||
private state: CommandState | null = null;
|
||||
private lastCommandName: string | null = null;
|
||||
/** Letzter (gesnappter) Cursor-Punkt für Distanz-/Winkel-Lock-Auflösung. */
|
||||
private lastCursor: Vec2 | null = null;
|
||||
/** Tab-Feld-Zyklus: aktiver Feld-Index des aktuellen Schritts (§2.7). */
|
||||
private activeFieldIndex = 0;
|
||||
/** Tab-Feld-Zyklus: gelockte Feldwerte (id → Zahl); ungelockt folgt der Maus. */
|
||||
private locks: Record<string, number> = {};
|
||||
/** Identität des Schritts, für den der Feld-Zustand gilt (Reset bei Wechsel). */
|
||||
private fieldPhaseKey: string | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(host: EngineHost) {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
// ── Abo / Snapshot ─────────────────────────────────────────────────────────
|
||||
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
private emit(): void {
|
||||
for (const l of this.listeners) l();
|
||||
}
|
||||
|
||||
/** Läuft gerade ein Befehl? */
|
||||
isActive(): boolean {
|
||||
return this.command !== null;
|
||||
}
|
||||
|
||||
/** Lesender Schnappschuss für die Command-Line. */
|
||||
view(): EngineView {
|
||||
if (!this.command || !this.state) {
|
||||
return { active: false, promptKey: null, options: [], commandName: null, fields: [] };
|
||||
}
|
||||
this.syncFields();
|
||||
const defs = this.currentFields();
|
||||
const fields = defs.map((f, i) => ({
|
||||
id: f.id,
|
||||
labelKey: f.labelKey as string,
|
||||
value: f.id in this.locks ? this.locks[f.id] : null,
|
||||
active: i === this.activeFieldIndex,
|
||||
}));
|
||||
return {
|
||||
active: true,
|
||||
promptKey: this.command.prompt(this.state),
|
||||
options: this.command.options(this.state),
|
||||
commandName: this.command.name,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
/** Autocomplete-Kandidaten für die getippte Eingabe (Präfix). */
|
||||
suggest(prefix: string): string[] {
|
||||
return autocomplete(prefix);
|
||||
}
|
||||
|
||||
// ── Tab-Feld-Zyklus (§2.7) ────────────────────────────────────────────────
|
||||
|
||||
/** Feld-Definitionen des aktuellen Schritts (leer, wenn keine angeboten). */
|
||||
private currentFields(): { id: string; labelKey: string }[] {
|
||||
if (!this.command || !this.state || !this.command.fields) return [];
|
||||
return this.command.fields(this.state);
|
||||
}
|
||||
|
||||
/** Hat der aktuelle Schritt einen Tab-Feld-Modus? */
|
||||
hasFields(): boolean {
|
||||
return this.currentFields().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den Feld-Zustand zurück, wenn der Schritt wechselt — Signatur =
|
||||
* phase + Feld-IDs + lastPoint. Der lastPoint-Teil sorgt dafür, dass ein
|
||||
* mehrteiliger Befehl (Polyline) je gesetztem Punkt FRISCHE Felder/Locks
|
||||
* bekommt, obwohl die phase ("next") gleich bleibt.
|
||||
*/
|
||||
private syncFields(): void {
|
||||
const defs = this.currentFields();
|
||||
const lp = this.state?.lastPoint;
|
||||
const lpKey = lp ? `${lp.x.toFixed(6)},${lp.y.toFixed(6)}` : "∅";
|
||||
const key = this.state
|
||||
? `${this.state.phase}:${defs.map((f) => f.id).join(",")}:${lpKey}`
|
||||
: null;
|
||||
if (key !== this.fieldPhaseKey) {
|
||||
this.fieldPhaseKey = key;
|
||||
this.activeFieldIndex = 0;
|
||||
this.locks = {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Tab: nächstes Feld aktiv (zyklisch). No-op ohne Felder. */
|
||||
cycleField(): void {
|
||||
this.syncFields();
|
||||
const n = this.currentFields().length;
|
||||
if (n === 0) return;
|
||||
this.activeFieldIndex = (this.activeFieldIndex + 1) % n;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lockt einen getippten Zahlenwert ins aktive Feld und rückt aufs nächste vor
|
||||
* (§2.7, Task-Schritt 2). Sind danach ALLE Felder gelockt, committet der Schritt
|
||||
* sofort den daraus gebildeten Punkt — so commitet ein Ein-Feld-Befehl (Kreis:
|
||||
* Radius) direkt und ein Zwei-Feld-Befehl (Linie) nach dem zweiten Wert.
|
||||
*/
|
||||
private lockActiveField(value: number): void {
|
||||
this.syncFields();
|
||||
const defs = this.currentFields();
|
||||
if (defs.length === 0) return;
|
||||
const field = defs[this.activeFieldIndex];
|
||||
this.locks = { ...this.locks, [field.id]: value };
|
||||
const allLocked = defs.every((f) => f.id in this.locks);
|
||||
if (allLocked) {
|
||||
this.commitFields();
|
||||
return;
|
||||
}
|
||||
this.activeFieldIndex = (this.activeFieldIndex + 1) % defs.length;
|
||||
this.refreshFieldPreview();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** Committet den aus den Feld-Locks (+ Cursor) gebildeten Punkt als Schritt-Eingabe. */
|
||||
private commitFields(): void {
|
||||
const pt = this.fieldResultPoint(this.lastCursor);
|
||||
if (pt) this.feed({ kind: "point", point: pt });
|
||||
else this.emit();
|
||||
}
|
||||
|
||||
/** Vorschau aus den gelockten Feldern + dem letzten Cursor neu zeichnen. */
|
||||
private refreshFieldPreview(): void {
|
||||
if (!this.command || !this.state || !this.command.pointFromFields) return;
|
||||
const pt = this.command.pointFromFields(this.state, this.locks, this.lastCursor);
|
||||
if (!pt) return;
|
||||
const ctx = this.context();
|
||||
const [st, res] = this.command.onMove(this.state, pt, null, ctx);
|
||||
this.state = st;
|
||||
if (res.draft) this.host.setDraft(res.draft);
|
||||
}
|
||||
|
||||
// ── Befehls-Lebenszyklus ─────────────────────────────────────────────────
|
||||
|
||||
/** Startet einen Befehl per Name/Alias/Präfix. Liefert true bei Erfolg. */
|
||||
start(nameOrPrefix: string): boolean {
|
||||
const cmd = resolveCommand(nameOrPrefix) ?? getCommand(nameOrPrefix);
|
||||
if (!cmd) return false;
|
||||
return this.startCommand(cmd);
|
||||
}
|
||||
|
||||
/** Startet einen konkreten Befehl (z. B. zur Wiederholung). */
|
||||
startCommand(cmd: Command): boolean {
|
||||
if (cmd.floorOnly && !this.host.isFloor()) return false;
|
||||
this.command = cmd;
|
||||
this.state = cmd.init();
|
||||
this.lastCommandName = cmd.name;
|
||||
this.lastCursor = null;
|
||||
this.resetFields();
|
||||
this.host.setDraft(null);
|
||||
this.emit();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Tab-Feld-Zustand vollständig zurücksetzen (Befehlsstart/-ende/Abbruch). */
|
||||
private resetFields(): void {
|
||||
this.activeFieldIndex = 0;
|
||||
this.locks = {};
|
||||
this.fieldPhaseKey = null;
|
||||
}
|
||||
|
||||
/** Bricht den laufenden Befehl ab (Esc). */
|
||||
cancel(): void {
|
||||
if (this.command && this.state) {
|
||||
const [, res] = this.command.onCancel(this.state);
|
||||
this.applyResult(res);
|
||||
}
|
||||
this.command = null;
|
||||
this.state = null;
|
||||
this.resetFields();
|
||||
this.host.setDraft(null);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
// ── Eingabe-Routing ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Getippte Command-Line-Eingabe (Enter). Im Ruhezustand: Befehl starten / bei
|
||||
* leerer Zeile letzten wiederholen (§2.3). In einem Befehl: als Koordinate/
|
||||
* Zahl/Option in den aktuellen Schritt routen.
|
||||
*/
|
||||
submitText(raw: string): void {
|
||||
const text = raw.trim();
|
||||
if (!this.command || !this.state) {
|
||||
// Ruhezustand: leere Zeile = letzten Befehl wiederholen.
|
||||
if (text === "") {
|
||||
this.repeatLast();
|
||||
return;
|
||||
}
|
||||
this.start(text);
|
||||
return;
|
||||
}
|
||||
// Laufender Befehl: leere Zeile = bestätigen/beenden (Enter §2.3).
|
||||
if (text === "") {
|
||||
this.confirm();
|
||||
return;
|
||||
}
|
||||
this.routeTypedInput(text);
|
||||
}
|
||||
|
||||
/** Routet getippten Text in den aktuellen Schritt (Koordinate/Zahl/Option). */
|
||||
private routeTypedInput(text: string): void {
|
||||
if (!this.command || !this.state) return;
|
||||
const accepts = this.command.accepts(this.state);
|
||||
|
||||
// Optionsbuchstabe(n)? (nur wenn der Schritt Optionen annimmt) — Präfix-Match.
|
||||
if (accepts.includes("option")) {
|
||||
const opts = this.command.options(this.state);
|
||||
const q = text.toLowerCase();
|
||||
const hit = opts.find((o) => o.id.toLowerCase().startsWith(q));
|
||||
if (hit) {
|
||||
this.feed({ kind: "option", id: hit.id });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseInput(text);
|
||||
|
||||
// Tab-Feld-Modus (§2.7): eine nackte Zahl LOCKT das aktive Feld (statt als
|
||||
// distance/number durchzureichen) und rückt aufs nächste Feld vor. Absolute/
|
||||
// relative/polare Koordinaten umgehen die Felder (committen wie gewohnt).
|
||||
if (parsed.kind === "distance" && this.hasFields()) {
|
||||
this.lockActiveField(parsed.value);
|
||||
return;
|
||||
}
|
||||
|
||||
const lastPoint = this.state.lastPoint;
|
||||
const cursor = this.lastCursor;
|
||||
|
||||
if (parsed.kind === "text") {
|
||||
// Unparsbar im Schritt: ignorieren (kein gültiger Punkt/Option).
|
||||
return;
|
||||
}
|
||||
// Punktbezogene Eingaben → zu Modellpunkt auflösen, wenn der Schritt Punkte
|
||||
// annimmt; sonst nackte Zahl als number-Input.
|
||||
if (parsed.kind === "distance" && accepts.includes("number") && !accepts.includes("point")) {
|
||||
this.feed({ kind: "number", value: parsed.value });
|
||||
return;
|
||||
}
|
||||
const pt = resolvePoint(parsed, lastPoint, cursor);
|
||||
if (pt && accepts.includes("point")) {
|
||||
this.feed({ kind: "point", point: pt });
|
||||
return;
|
||||
}
|
||||
if (parsed.kind === "distance" && accepts.includes("number")) {
|
||||
this.feed({ kind: "number", value: parsed.value });
|
||||
}
|
||||
}
|
||||
|
||||
/** Maus-Pick aus PlanView (linker Klick) — gesnappter Modellpunkt. */
|
||||
pick(raw: Vec2): void {
|
||||
if (!this.command || !this.state) return;
|
||||
if (!this.command.accepts(this.state).includes("point")) return;
|
||||
const snap = this.host.snap(raw, this.draftPoints());
|
||||
const point = snap?.point ?? raw;
|
||||
this.lastCursor = point;
|
||||
// Tab-Feld-Modus: gelockte Felder überschreiben den Klick (z. B. Länge fix,
|
||||
// Richtung per Maus). Der finale Punkt kommt aus pointFromFields.
|
||||
const fieldPt = this.fieldResultPoint(point);
|
||||
if (fieldPt) {
|
||||
this.feed({ kind: "point", point: fieldPt });
|
||||
return;
|
||||
}
|
||||
this.feed({ kind: "point", point, snap });
|
||||
}
|
||||
|
||||
/** Hover/Drag aus PlanView — nur Vorschau (kein Commit). */
|
||||
move(raw: Vec2): void {
|
||||
if (!this.command || !this.state) return;
|
||||
const snap = this.host.snap(raw, this.draftPoints());
|
||||
const point = snap?.point ?? raw;
|
||||
this.lastCursor = point;
|
||||
const ctx = this.context();
|
||||
// Tab-Feld-Modus: ungelockte Felder folgen der Maus, gelockte überschreiben.
|
||||
const previewPoint = this.fieldResultPoint(point) ?? point;
|
||||
const [st, res] = this.command.onMove(this.state, previewPoint, snap, ctx);
|
||||
this.state = st;
|
||||
// Snap-Marker mit in den Draft hängen.
|
||||
if (res.draft) this.host.setDraft({ ...res.draft, snap });
|
||||
else if (snap) this.host.setDraft({ preview: [], vertices: [], snap });
|
||||
else this.host.setDraft(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Punkt aus dem Tab-Feld-Modus für einen gegebenen Cursor — oder null, wenn der
|
||||
* Schritt keine Felder hat (dann gilt der rohe Cursor/Pick).
|
||||
*/
|
||||
private fieldResultPoint(cursor: Vec2 | null): Vec2 | null {
|
||||
if (!this.command || !this.state || !this.command.pointFromFields) return null;
|
||||
if (!this.hasFields()) return null;
|
||||
return this.command.pointFromFields(this.state, this.locks, cursor);
|
||||
}
|
||||
|
||||
/** Klick auf eine Inline-Option (§2.4). */
|
||||
chooseOption(id: string): void {
|
||||
if (!this.command || !this.state) return;
|
||||
this.feed({ kind: "option", id });
|
||||
}
|
||||
|
||||
/** Enter = Space = Rechtsklick: bestätigen/Default/mehrteilig-beenden (§2.3). */
|
||||
confirm(): void {
|
||||
if (!this.command || !this.state) {
|
||||
this.repeatLast();
|
||||
return;
|
||||
}
|
||||
// Tab-Feld-Modus (§2.7): existieren Felder, committet Enter den Schritt mit
|
||||
// dem aus (Locks + Cursor) gebildeten Punkt — statt den Befehl abzubrechen.
|
||||
const fieldPt = this.fieldResultPoint(this.lastCursor);
|
||||
if (fieldPt) {
|
||||
this.feed({ kind: "point", point: fieldPt });
|
||||
return;
|
||||
}
|
||||
const ctx = this.context();
|
||||
const [st, res] = this.command.onConfirm(this.state, ctx);
|
||||
this.state = st;
|
||||
this.applyResult(res);
|
||||
}
|
||||
|
||||
/** Leer-Enter/Rechtsklick im Ruhezustand: letzten Befehl wiederholen. */
|
||||
repeatLast(): void {
|
||||
if (this.lastCommandName) {
|
||||
const cmd = getCommand(this.lastCommandName);
|
||||
if (cmd) this.startCommand(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
// ── interne Mechanik ───────────────────────────────────────────────────────
|
||||
|
||||
private feed(input: CmdInput): void {
|
||||
if (!this.command || !this.state) return;
|
||||
if (input.kind === "point") this.lastCursor = input.point;
|
||||
const ctx = this.context();
|
||||
const [st, res] = this.command.onInput(this.state, input, ctx);
|
||||
this.state = st;
|
||||
this.applyResult(res);
|
||||
}
|
||||
|
||||
private applyResult(res: CommandResult): void {
|
||||
if (res.commit) this.host.commit(res.commit);
|
||||
if (res.draft) this.host.setDraft(res.draft);
|
||||
else this.host.setDraft(null);
|
||||
if (res.done) {
|
||||
this.command = null;
|
||||
this.state = null;
|
||||
this.resetFields();
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private context(): CommandContext {
|
||||
return this.host.context(this.state?.lastPoint ?? null);
|
||||
}
|
||||
|
||||
/** Bereits gesetzte Punkte des laufenden Befehls (für Snap-Quellen). */
|
||||
private draftPoints(): Vec2[] {
|
||||
const s = this.state as (CommandState & { a?: Vec2; points?: Vec2[] }) | null;
|
||||
if (!s) return [];
|
||||
if (s.points) return s.points;
|
||||
if (s.a) return [s.a];
|
||||
if (s.lastPoint) return [s.lastPoint];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Koordinaten- & Numerik-Parser (docs/design/rhino-command-system.md §2.7).
|
||||
//
|
||||
// Parst rohen Command-Line-Text TOLERANT (Komma/Space/Semikolon als Trenner) zu
|
||||
// einer Discriminated Union; die Engine löst die punktbezogenen Varianten mit
|
||||
// `lastPoint` + Cursor-Richtung zu einem konkreten Modellpunkt auf.
|
||||
//
|
||||
// Unterstützte Formen:
|
||||
// `5,3` → absolut X,Y
|
||||
// `5,3,2` → absolut X,Y (Z ignoriert, 2D)
|
||||
// `r5,3` → relativ zum letzten Punkt
|
||||
// `5<45` → polar: Distanz 5 unter 45° vom letzten Punkt
|
||||
// `<45` → nur Winkel-Constraint; Distanz folgt Maus/Zahl
|
||||
// `5` → nackte Zahl = Distanz-Lock entlang Cursor-/Constraint-Richtung
|
||||
// sonst → Roh-Text (Befehlsname / Optionsbuchstabe) — von der Engine geroutet
|
||||
//
|
||||
// Reine Funktionen, kein Zustand. Winkel in Grad, gegen die +X-Achse, CCW positiv
|
||||
// (passend zur Y-flip-freien Modellkonvention: +Y ist „oben").
|
||||
|
||||
import type { Vec2 } from "../model/types";
|
||||
|
||||
/** Geparste, noch nicht aufgelöste Eingabe. */
|
||||
export type ParsedInput =
|
||||
| { kind: "absolute"; point: Vec2 } // 5,3
|
||||
| { kind: "relative"; delta: Vec2 } // r5,3
|
||||
| { kind: "polar"; dist: number; angleDeg: number } // 5<45
|
||||
| { kind: "angle"; angleDeg: number } // <45
|
||||
| { kind: "distance"; value: number } // 5 (nackte Zahl)
|
||||
| { kind: "text"; text: string }; // Befehlsname / Option / unparsbar
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
/** Tolerant: Zahl aus einem Token (erlaubt führendes +, Dezimalpunkt/-komma). */
|
||||
function parseNumber(raw: string): number | null {
|
||||
const s = raw.trim().replace(",", "."); // Hilfsfall: einzelnes Dezimalkomma
|
||||
if (s === "" || s === "+" || s === "-") return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt eine Koordinatenliste (X,Y[,Z]) tolerant in Zahlen. Trenner: Komma,
|
||||
* Semikolon oder Whitespace. Liefert null, wenn nicht ≥2 valide Zahlen vorliegen.
|
||||
* (Wir nutzen die echten Trenner, daher kollidiert das NICHT mit dem
|
||||
* Dezimalkomma-Hilfsfall der nackten Zahl.)
|
||||
*/
|
||||
function parseCoordList(raw: string): number[] | null {
|
||||
const parts = raw
|
||||
.trim()
|
||||
.split(/[\s,;]+/)
|
||||
.filter((p) => p.length > 0);
|
||||
if (parts.length < 2) return null;
|
||||
const nums: number[] = [];
|
||||
for (const p of parts) {
|
||||
const n = Number(p.trim());
|
||||
if (!Number.isFinite(n)) return null;
|
||||
nums.push(n);
|
||||
}
|
||||
return nums;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst rohen Command-Line-Text zu einer {@link ParsedInput}. Befehlsnamen/
|
||||
* Optionsbuchstaben fallen als `{kind:"text"}` durch (die Engine routet sie).
|
||||
*/
|
||||
export function parseInput(raw: string): ParsedInput {
|
||||
const s = raw.trim();
|
||||
if (s === "") return { kind: "text", text: "" };
|
||||
|
||||
// ── polar / Winkel: enthält '<' ───────────────────────────────────────────
|
||||
if (s.includes("<")) {
|
||||
const i = s.indexOf("<");
|
||||
const left = s.slice(0, i).trim();
|
||||
const right = s.slice(i + 1).trim();
|
||||
const angle = parseNumber(right);
|
||||
if (angle == null) return { kind: "text", text: s };
|
||||
if (left === "") return { kind: "angle", angleDeg: angle };
|
||||
const dist = parseNumber(left);
|
||||
if (dist == null) return { kind: "text", text: s };
|
||||
return { kind: "polar", dist, angleDeg: angle };
|
||||
}
|
||||
|
||||
// ── relativ: führendes r / @ ──────────────────────────────────────────────
|
||||
if (/^[r@]/i.test(s)) {
|
||||
const rest = s.slice(1);
|
||||
const nums = parseCoordList(rest);
|
||||
if (nums && nums.length >= 2) {
|
||||
return { kind: "relative", delta: { x: nums[0], y: nums[1] } };
|
||||
}
|
||||
// `r5` → relative Distanz entlang Cursor-Richtung.
|
||||
const one = parseNumber(rest);
|
||||
if (one != null) return { kind: "distance", value: one };
|
||||
return { kind: "text", text: s };
|
||||
}
|
||||
|
||||
// ── absolute Koordinate: ≥2 zahlen mit echtem Trenner ─────────────────────
|
||||
const coords = parseCoordList(s);
|
||||
if (coords && coords.length >= 2) {
|
||||
return { kind: "absolute", point: { x: coords[0], y: coords[1] } };
|
||||
}
|
||||
|
||||
// ── nackte Zahl = Distanz-Lock ────────────────────────────────────────────
|
||||
const one = parseNumber(s);
|
||||
if (one != null) return { kind: "distance", value: one };
|
||||
|
||||
// ── sonst: Text (Befehl/Option) ───────────────────────────────────────────
|
||||
return { kind: "text", text: s };
|
||||
}
|
||||
|
||||
/** Punkt aus polarer Form (Distanz/Winkel) relativ zu einem Basispunkt. */
|
||||
export function polarPoint(base: Vec2, dist: number, angleDeg: number): Vec2 {
|
||||
const a = angleDeg * DEG;
|
||||
return { x: base.x + Math.cos(a) * dist, y: base.y + Math.sin(a) * dist };
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst eine {@link ParsedInput} zu einem konkreten Modellpunkt auf — falls sie
|
||||
* sich als Punkt auflösen lässt. `lastPoint` ist der letzte gesetzte Punkt im
|
||||
* Befehl (für relativ/polar/Distanz), `cursorDir` die aktuelle (gesnappte)
|
||||
* Cursor-Richtung als Roh-Cursorpunkt (für nackte Zahl / reinen Winkel-Lock).
|
||||
*
|
||||
* Liefert null, wenn die Eingabe keinen Punkt ergibt (z. B. reiner Text oder
|
||||
* Distanz ohne Bezug).
|
||||
*/
|
||||
export function resolvePoint(
|
||||
parsed: ParsedInput,
|
||||
lastPoint: Vec2 | null,
|
||||
cursor: Vec2 | null,
|
||||
): Vec2 | null {
|
||||
switch (parsed.kind) {
|
||||
case "absolute":
|
||||
return parsed.point;
|
||||
case "relative":
|
||||
if (!lastPoint) return parsed.delta; // ohne Bezug = vom Ursprung
|
||||
return { x: lastPoint.x + parsed.delta.x, y: lastPoint.y + parsed.delta.y };
|
||||
case "polar": {
|
||||
const base = lastPoint ?? { x: 0, y: 0 };
|
||||
return polarPoint(base, parsed.dist, parsed.angleDeg);
|
||||
}
|
||||
case "angle": {
|
||||
// Reiner Winkel-Lock: ohne Distanz braucht es den Cursor, um die Länge
|
||||
// entlang der gelockten Richtung zu projizieren.
|
||||
if (!lastPoint || !cursor) return null;
|
||||
const a = parsed.angleDeg * DEG;
|
||||
const dir = { x: Math.cos(a), y: Math.sin(a) };
|
||||
const dx = cursor.x - lastPoint.x;
|
||||
const dy = cursor.y - lastPoint.y;
|
||||
const proj = dx * dir.x + dy * dir.y; // skalare Projektion
|
||||
const len = Math.max(0, proj);
|
||||
return { x: lastPoint.x + dir.x * len, y: lastPoint.y + dir.y * len };
|
||||
}
|
||||
case "distance": {
|
||||
// Distanz-Lock: Richtung per Cursor, Länge per Zahl.
|
||||
if (!lastPoint || !cursor) return null;
|
||||
const dx = cursor.x - lastPoint.x;
|
||||
const dy = cursor.y - lastPoint.y;
|
||||
const l = Math.hypot(dx, dy);
|
||||
if (l < 1e-9) return { x: lastPoint.x + parsed.value, y: lastPoint.y };
|
||||
return {
|
||||
x: lastPoint.x + (dx / l) * parsed.value,
|
||||
y: lastPoint.y + (dy / l) * parsed.value,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Befehls-Registry (docs/design/rhino-command-system.md §3.2) — `COMMANDS` +
|
||||
// Aliase + Präfix-Autocomplete. Einzelne Befehle leben als eigene Module unter
|
||||
// `cmds/`, sodass weitere Befehle ADDITIV (ohne Änderung an Engine/App)
|
||||
// dazukommen: hier importieren + registrieren, fertig.
|
||||
|
||||
import type { Command } from "./types";
|
||||
import { lineCommand } from "./cmds/line";
|
||||
import { polylineCommand } from "./cmds/polyline";
|
||||
import { rectCommand } from "./cmds/rect";
|
||||
import { circleCommand } from "./cmds/circle";
|
||||
import { moveCommand } from "./cmds/move";
|
||||
import { copyCommand } from "./cmds/copy";
|
||||
import { offsetCommand } from "./cmds/offset";
|
||||
import { importCommand } from "./cmds/import";
|
||||
import { terrainCommand } from "./cmds/terrain";
|
||||
|
||||
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
|
||||
export const COMMANDS: Record<string, Command> = {
|
||||
line: lineCommand,
|
||||
polyline: polylineCommand,
|
||||
rect: rectCommand,
|
||||
circle: circleCommand,
|
||||
move: moveCommand,
|
||||
copy: copyCommand,
|
||||
offset: offsetCommand,
|
||||
import: importCommand,
|
||||
terrain: terrainCommand,
|
||||
};
|
||||
|
||||
/**
|
||||
* Aliase / Kürzel → Befehlsname (§2.2). Werden VOR dem Autocomplete gematcht
|
||||
* (case-insensitiv). Minimal: Einzelbuchstaben. NICHT `r` (kollidiert mit dem
|
||||
* relativ-Koordinaten-Präfix `r5,3`).
|
||||
*/
|
||||
export const ALIASES: Record<string, string> = {
|
||||
l: "line",
|
||||
pl: "polyline",
|
||||
rec: "rect",
|
||||
c: "circle",
|
||||
m: "move",
|
||||
cp: "copy",
|
||||
o: "offset",
|
||||
imp: "import",
|
||||
ter: "terrain",
|
||||
};
|
||||
|
||||
/** Liefert den Befehl zu einem exakten Namen (oder undefined). */
|
||||
export function getCommand(name: string): Command | undefined {
|
||||
return COMMANDS[name.toLowerCase()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst eine getippte Eingabe zu einem Befehl auf: erst exakter Name, dann Alias,
|
||||
* dann eindeutiges Präfix (case-insensitiv). Mehrdeutige/leere Präfixe → null.
|
||||
*/
|
||||
export function resolveCommand(raw: string): Command | null {
|
||||
const q = raw.trim().toLowerCase();
|
||||
if (q === "") return null;
|
||||
if (COMMANDS[q]) return COMMANDS[q];
|
||||
if (ALIASES[q]) return COMMANDS[ALIASES[q]] ?? null;
|
||||
const matches = autocomplete(q);
|
||||
if (matches.length === 1) return COMMANDS[matches[0]] ?? null;
|
||||
// Exaktes Präfix, das auch ein Alias ist, hat Vorrang, wenn eindeutig.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Präfix-Autocomplete: alle Befehlsnamen, die mit dem (lowercase) Präfix
|
||||
* beginnen, alphabetisch. Aliase werden NICHT mitgelistet (sie sind Kürzel,
|
||||
* keine Vorschläge), aber ein exakter Alias-Treffer wird vorangestellt.
|
||||
*/
|
||||
export function autocomplete(prefix: string): string[] {
|
||||
const q = prefix.trim().toLowerCase();
|
||||
if (q === "") return Object.keys(COMMANDS).sort();
|
||||
const names = Object.keys(COMMANDS)
|
||||
.filter((n) => n.startsWith(q))
|
||||
.sort();
|
||||
return names;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Command-Engine — Verallgemeinerung des `Tool`-Interfaces (src/tools/types.ts)
|
||||
// zu einem Rhino-artigen Befehlsmodell (docs/design/rhino-command-system.md §3.1).
|
||||
//
|
||||
// Ein `Command` ist eine kleine State-Machine: jeder Schritt zeigt einen Prompt,
|
||||
// nimmt bestimmte Eingabe-Arten an (Punkt | Zahl | Option | Auswahl) und liefert
|
||||
// aus (state, input) den nächsten Zustand + ein `CommandResult` (Vorschau +
|
||||
// optional commit/done). Damit speisen Maus-Picks UND getippte Koordinaten/
|
||||
// Optionen DENSELBEN Schritt — es gibt nur einen Eingabepfad.
|
||||
//
|
||||
// Bezeichner englisch, sichtbarer Text via t() (i18n-Keys, Namespace `cmd.*`).
|
||||
|
||||
import type { Project, Vec2 } from "../model/types";
|
||||
import type { TranslationKey } from "../i18n";
|
||||
import type { DrawingLevel } from "../model/types";
|
||||
import type { SnapResult, ToolDraft } from "../tools/types";
|
||||
|
||||
export type { DraftShape, SnapResult, ToolDraft } from "../tools/types";
|
||||
|
||||
// ── Eingabe-Arten ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Arten von Eingaben, die ein Schritt annehmen kann. */
|
||||
export type AcceptKind = "point" | "number" | "option" | "selection";
|
||||
|
||||
/**
|
||||
* Geparste, noch nicht aufgelöste Roh-Eingabe aus der Command-Line bzw. der
|
||||
* Maus. Discriminated Union (§2.7). Die Engine löst `point`-bezogene Varianten
|
||||
* mit `lastPoint` + Cursor-Richtung zu einem konkreten Modellpunkt auf, bevor
|
||||
* sie den Schritt aufruft.
|
||||
*/
|
||||
export type CmdInput =
|
||||
| { kind: "point"; point: Vec2; snap?: SnapResult | null }
|
||||
| { kind: "number"; value: number }
|
||||
| { kind: "option"; id: string }
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "selection"; wallIds: string[]; drawingIds: string[] };
|
||||
|
||||
// ── Optionen (klickbare Inline-Klammern, §2.4) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Eine Inline-Option eines Schritts. `value` (gesetzt) → wird als „Name=Value"
|
||||
* angezeigt und ist togglebar/abfragbar; ohne `value` ist es eine Action-Option
|
||||
* (verzweigt sofort). `id` ist der englische Schlüssel (auch zum Tippen, Präfix).
|
||||
*/
|
||||
export interface CmdOption {
|
||||
id: string;
|
||||
/** Sichtbares Label (i18n). */
|
||||
labelKey: TranslationKey;
|
||||
/** Aktueller Wert (Toggle/Value-Option); fehlt → Action-Option. */
|
||||
value?: string;
|
||||
}
|
||||
|
||||
// ── Kontext + Ergebnis ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Die aktuelle Auswahl (Rhino: erst selektieren, dann Befehl). Editierbefehle
|
||||
* (Move/Copy/Offset) lesen sie hieraus, statt sie selbst zu picken. `wallIds`
|
||||
* sind die gewählten Wände, `drawingId` ist das gewählte 2D-Element (sich
|
||||
* gegenseitig ausschließend, wie in selectionSlice).
|
||||
*/
|
||||
export interface CommandSelection {
|
||||
wallIds: string[];
|
||||
drawingId: string | null;
|
||||
}
|
||||
|
||||
/** Live-Kontext, den ein Befehl bei jedem Schritt erhält (analog ToolContext). */
|
||||
export interface CommandContext {
|
||||
project: Project;
|
||||
/** Aktive Zeichnungsebene (Ziel neuer Elemente). */
|
||||
level: DrawingLevel;
|
||||
/** Default-Kategorie-Code für neue Elemente. */
|
||||
defaultCategoryCode: string;
|
||||
/** Aktiver Linienstil-Code für 2D-Primitive. */
|
||||
activeLineStyleId: string;
|
||||
/**
|
||||
* Letzter gesetzter Punkt im laufenden Befehl (für `r`/polar-Auflösung); null
|
||||
* am ersten Punkt.
|
||||
*/
|
||||
lastPoint: Vec2 | null;
|
||||
/**
|
||||
* Vorab gesetzte Auswahl (Wände + ein 2D-Element). Editierbefehle operieren
|
||||
* darauf (Rhino: erst selektieren, dann Befehl). Beim Befehlsstart aus dem
|
||||
* Store gefüllt.
|
||||
*/
|
||||
selection: CommandSelection;
|
||||
}
|
||||
|
||||
/** Was ein Befehls-Schritt nach außen meldet (analog ToolResult + commit/done). */
|
||||
export interface CommandResult {
|
||||
/** Neuer Vorschau-Zustand; null = nichts zu zeigen. */
|
||||
draft: ToolDraft | null;
|
||||
/** Bei Abschluss: immutable Mutation, die die Engine über setProject anwendet. */
|
||||
commit?: (p: Project) => Project;
|
||||
/** true → Befehl ist fertig und kehrt in den Ruhezustand zurück. */
|
||||
done?: boolean;
|
||||
}
|
||||
|
||||
// ── Befehls-Zustand ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Interner Zustand eines laufenden Befehls. Basis-Form (`phase` + `lastPoint`);
|
||||
* jeder Befehl verfeinert sie zu seiner eigenen Discriminated Union und castet
|
||||
* beim Eintritt. `lastPoint` lebt im Zustand, damit der Parser `r`/polar relativ
|
||||
* dazu auflösen kann.
|
||||
*/
|
||||
export interface CommandState {
|
||||
phase: string;
|
||||
/** Letzter gesetzter Punkt (für relative/polare Eingabe), null am Anfang. */
|
||||
lastPoint: Vec2 | null;
|
||||
}
|
||||
|
||||
// ── Tab-Feld-Zyklus (Präzisionseingabe §2.7, erweitert) ──────────────────────
|
||||
|
||||
/**
|
||||
* Ein geordnetes Zahlenfeld eines Schritts (z. B. Länge, Winkel, Breite, Höhe,
|
||||
* Radius). Mit Tab zyklt der Nutzer durch die Felder; eine getippte Zahl LOCKT
|
||||
* das aktive Feld, ungelockte Felder folgen weiter der Maus.
|
||||
*/
|
||||
export interface CommandField {
|
||||
/** Englischer Schlüssel (Identität des Feldes, z. B. „length"). */
|
||||
id: string;
|
||||
/** Sichtbares Label (i18n, Namespace `cmd.field.*`). */
|
||||
labelKey: TranslationKey;
|
||||
}
|
||||
|
||||
// ── Befehls-Schnittstelle ────────────────────────────────────────────────────
|
||||
|
||||
/** Die Command-Schnittstelle (reine Funktionen über einen internen State). */
|
||||
export interface Command {
|
||||
/** Befehlsname (englisch, in der Command-Line getippt), z. B. „line". */
|
||||
name: string;
|
||||
/** UI-Label-Key (i18n). */
|
||||
labelKey: TranslationKey;
|
||||
/** Prompt-Text-Key des aktuellen Schritts. */
|
||||
prompt(state: CommandState): TranslationKey;
|
||||
/** Eingabe-Arten, die der aktuelle Schritt annimmt. */
|
||||
accepts(state: CommandState): AcceptKind[];
|
||||
/** Inline-Optionen des aktuellen Schritts (klickbar/tippbar). */
|
||||
options(state: CommandState): CmdOption[];
|
||||
/** Nur auf Geschossen aktiv? (analog floorOnly bei Tools.) */
|
||||
floorOnly?: boolean;
|
||||
/** Initialer Schritt-Zustand. */
|
||||
init(): CommandState;
|
||||
/** Eine Eingabe (Punkt/Zahl/Option/Auswahl) verarbeiten. */
|
||||
onInput(
|
||||
state: CommandState,
|
||||
input: CmdInput,
|
||||
ctx: CommandContext,
|
||||
): [CommandState, CommandResult];
|
||||
/** Hover/Bewegung (roher bzw. gesnappter Cursor): nur Vorschau. */
|
||||
onMove(
|
||||
state: CommandState,
|
||||
point: Vec2,
|
||||
snap: SnapResult | null,
|
||||
ctx: CommandContext,
|
||||
): [CommandState, CommandResult];
|
||||
/** Enter/Space/Rechtsklick: Schritt bestätigen / mehrteilig beenden. */
|
||||
onConfirm(state: CommandState, ctx: CommandContext): [CommandState, CommandResult];
|
||||
/** Esc: Entwurf verwerfen, Befehl beenden. */
|
||||
onCancel(state: CommandState): [CommandState, CommandResult];
|
||||
|
||||
// ── Optionaler Tab-Feld-Zyklus (additiv; Befehle ohne diese Methoden
|
||||
// verhalten sich exakt wie bisher) ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Geordnete Zahlenfelder des AKTUELLEN Schritts (leer/undefined = kein
|
||||
* Feld-Modus). Beispiel Linie-Endschritt: [length, angle].
|
||||
*/
|
||||
fields?(state: CommandState): CommandField[];
|
||||
|
||||
/**
|
||||
* Berechnet den resultierenden Modellpunkt aus den gelockten Feldern + dem
|
||||
* Cursor. Ungelockte Felder folgen dem Cursor (Maus); gelockte Felder
|
||||
* überschreiben. Liefert null, wenn (noch) kein Punkt bestimmbar ist.
|
||||
*/
|
||||
pointFromFields?(
|
||||
state: CommandState,
|
||||
locks: Record<string, number>,
|
||||
cursor: Vec2 | null,
|
||||
): Vec2 | null;
|
||||
}
|
||||
|
||||
/** Convenience-Re-Export für Befehls-Module. */
|
||||
export type { Project, Vec2 } from "../model/types";
|
||||
Reference in New Issue
Block a user