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