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