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,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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user