// Konkrete Werkzeuge (Phase 1: select / wall / line) + Registry. // docs/design/drawing-tools.md §3, §4, §10. import type { Ceiling, Drawing2D, Project, Vec2, Wall } from "../model/types"; import { wallTypeThickness } from "../model/types"; import { wallCorners } from "../model/geometry"; import { normalizeOutline, ceilingArea } from "../geometry/ceiling"; import type { DraftShape, Tool, ToolContext, ToolDraft, ToolId } from "./types"; import { uniqueId } from "./types"; const EPS = 1e-6; 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; /** Dicke des aktiven Wandtyps (Meter); Fallback, falls unbekannt. */ function activeWallThickness(ctx: ToolContext): number { const wt = ctx.project.wallTypes.find((t) => t.id === ctx.activeWallTypeId); return wt ? wallTypeThickness(wt) : 0.2; } // ── Select ────────────────────────────────────────────────────────────────── // Reines Platzhalter-Werkzeug: die Auswahl-/Marquee-Logik bleibt in PlanView; // der Controller ruft dieses Werkzeug für "select" gar nicht erst auf. const selectTool: Tool = { id: "select", labelKey: "tool.select", hintKey: () => "tool.select.hint", init: () => ({ phase: "idle" }), onClick: (s) => [s, { draft: null }], onMove: (s) => [s, { draft: null }], onCommitGesture: (s) => [s, { draft: null, done: true }], onCancel: (s) => [s, { draft: null, done: true }], }; // ── Wall ───────────────────────────────────────────────────────────────────── // Zeichnet eine Achs-Polylinie; jedes Segment wird ein eigenständiges Wall. // Die Gehrung an Ecken entsteht automatisch aus computeJoins (generatePlan). interface WallDrawing { phase: "drawing"; points: Vec2[]; cursor: Vec2 | null; } type WallState = { phase: "idle" } | WallDrawing; /** Baut die Band-Vorschau für eine Achs-Polylinie + lebendes Segment. */ function wallDraft(points: Vec2[], cursor: Vec2 | null, ctx: ToolContext): ToolDraft { const thickness = activeWallThickness(ctx); const preview: DraftShape[] = []; const all = cursor ? [...points, cursor] : points; 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 }; // HUD am lebenden Segment: Länge + Winkel. if (cursor && points.length > 0) { const a = points[points.length - 1]; if (segLen(a, cursor) >= EPS) { draft.hud = { at: cursor, text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs( ((segAngleDeg(a, cursor) + 360) % 360), ).toFixed(0)}°`, }; } } return draft; } /** Hängt je Achssegment ein Wall des aktiven Typs ans Projekt (immutabel). */ function appendWalls(project: Project, pts: Vec2[], ctx: ToolContext): Project { const height = ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : 2.6; 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: ctx.activeWallTypeId, height, }); } if (newWalls.length === 0) return project; return { ...project, walls: [...project.walls, ...newWalls] }; } const wallTool: Tool = { id: "wall", labelKey: "tool.wall", floorOnly: true, hintKey: (s) => (s as WallState).phase === "drawing" ? "tool.wall.nextPoint" : "tool.wall.firstPoint", init: () => ({ phase: "idle" }), onClick: (state, p, ctx) => { const s = state as WallState; const points = s.phase === "drawing" ? [...s.points, p.point] : [p.point]; const next: WallState = { phase: "drawing", points, cursor: p.point }; return [next, { draft: wallDraft(points, p.point, ctx) }]; }, onMove: (state, p, ctx) => { const s = state as WallState; if (s.phase !== "drawing") return [s, { draft: { preview: [], vertices: [] } }]; const next: WallState = { ...s, cursor: p.point }; return [next, { draft: wallDraft(s.points, p.point, ctx) }]; }, onCommitGesture: (state, ctx) => { const s = state as WallState; if (s.phase !== "drawing" || s.points.length < 2) { return [{ phase: "idle" }, { draft: null, done: true }]; } const pts = s.points; return [ { phase: "idle" }, { draft: null, done: true, commit: (proj) => appendWalls(proj, pts, ctx) }, ]; }, onCancel: () => [{ phase: "idle" }, { draft: null, done: true }], onUndoPoint: (state, ctx) => { const s = state as WallState; if (s.phase === "drawing" && s.points.length > 1) { const points = s.points.slice(0, -1); const next: WallState = { phase: "drawing", points, cursor: s.cursor }; return [next, { draft: wallDraft(points, s.cursor, ctx) }]; } return [{ phase: "idle" }, { draft: null, done: true }]; }, }; // ── Ceiling ──────────────────────────────────────────────────────────────── // Zeichnet einen GESCHLOSSENEN Umriss (wie eine geschlossene Polylinie) und // committet daraus eine `Ceiling`. Der reguläre Zeichenpfad läuft über den // gekoppelten `ceiling`-Befehl (TOOL_COMMAND); dieses Legacy-Tool ist eine // vollständige, funktionsfähige Alternative (kein Stub). interface CeilDrawing { phase: "drawing"; points: Vec2[]; cursor: Vec2 | null; } type CeilState = { phase: "idle" } | CeilDrawing; function ceilDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft { const all = cursor ? [...points, cursor] : points; const preview: DraftShape[] = [{ kind: "poly", pts: all, closed: all.length >= 3 }]; const draft: ToolDraft = { preview, vertices: points }; if (cursor && points.length > 0) { const a = points[points.length - 1]; if (segLen(a, cursor) >= EPS) { draft.hud = { at: cursor, text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs( (segAngleDeg(a, cursor) + 360) % 360, ).toFixed(0)}°`, }; } } return draft; } function appendCeiling(project: Project, pts: Vec2[], ctx: ToolContext): Project { const outline = normalizeOutline(pts); if (!outline || ceilingArea(outline) < 1e-4) return project; const hasCat = project.layers.some((c) => c.code === "30"); const c: Ceiling = { id: uniqueId("C"), type: "ceiling", floorId: ctx.level.id, categoryCode: hasCat ? "30" : ctx.defaultCategoryCode, outline, wallTypeId: ctx.activeWallTypeId, }; return { ...project, ceilings: project.ceilings ? [...project.ceilings, c] : [c] }; } const ceilingTool: Tool = { id: "ceiling", labelKey: "tool.ceiling", floorOnly: true, hintKey: (s) => (s as CeilState).phase === "drawing" ? "tool.ceiling.nextPoint" : "tool.ceiling.firstPoint", init: () => ({ phase: "idle" }), onClick: (state, p, ctx) => { const s = state as CeilState; if (s.phase !== "drawing") { return [{ phase: "drawing", points: [p.point], cursor: p.point }, { draft: ceilDraft([p.point], p.point) }]; } // Klick auf den Startpunkt schließt den Umriss (ab 3 Punkten) und committet. if (s.points.length >= 3 && segLen(p.point, s.points[0]) < EPS) { const pts = s.points; return [{ phase: "idle" }, { draft: null, done: true, commit: (proj) => appendCeiling(proj, pts, ctx) }]; } const points = [...s.points, p.point]; return [{ phase: "drawing", points, cursor: p.point }, { draft: ceilDraft(points, p.point) }]; }, onMove: (state, p) => { const s = state as CeilState; if (s.phase !== "drawing") return [s, { draft: { preview: [], vertices: [] } }]; return [{ ...s, cursor: p.point }, { draft: ceilDraft(s.points, p.point) }]; }, onCommitGesture: (state, ctx) => { const s = state as CeilState; if (s.phase !== "drawing" || s.points.length < 3) { return [{ phase: "idle" }, { draft: null, done: true }]; } const pts = s.points; return [{ phase: "idle" }, { draft: null, done: true, commit: (proj) => appendCeiling(proj, pts, ctx) }]; }, onCancel: () => [{ phase: "idle" }, { draft: null, done: true }], onUndoPoint: (state) => { const s = state as CeilState; if (s.phase === "drawing" && s.points.length > 1) { const points = s.points.slice(0, -1); return [{ phase: "drawing", points, cursor: s.cursor }, { draft: ceilDraft(points, s.cursor) }]; } return [{ phase: "idle" }, { draft: null, done: true }]; }, }; // ── Window / Door (Öffnungen) ───────────────────────────────────────────────── // Beide sind an den gekoppelten Befehl `fenster`/`tuer` (TOOL_COMMAND) gebunden: // der interaktive Ablauf (Host-Wand picken → Position → committen) läuft komplett // in der CommandEngine. Diese Tool-Objekte sind reine Platzhalter (wie `select`); // der Controller ruft sie nicht auf, sobald der gekoppelte Befehl läuft. const windowTool: Tool = { id: "window", labelKey: "tool.window", floorOnly: true, hintKey: () => "tool.window.hint", init: () => ({ phase: "idle" }), onClick: (s) => [s, { draft: null }], onMove: (s) => [s, { draft: null }], onCommitGesture: (s) => [s, { draft: null, done: true }], onCancel: (s) => [s, { draft: null, done: true }], }; const doorTool: Tool = { id: "door", labelKey: "tool.door", floorOnly: true, hintKey: () => "tool.door.hint", init: () => ({ phase: "idle" }), onClick: (s) => [s, { draft: null }], onMove: (s) => [s, { draft: null }], onCommitGesture: (s) => [s, { draft: null, done: true }], onCancel: (s) => [s, { draft: null, done: true }], }; // ── Stair (Treppe) ─────────────────────────────────────────────────────────── // An den gekoppelten Befehl `stair` (TOOL_COMMAND) gebunden: der interaktive // Ablauf (Startpunkt → Laufrichtung/Ende → committen) läuft komplett in der // CommandEngine. Dieses Tool-Objekt ist ein reiner Platzhalter (wie window/door); // der Controller ruft es nicht auf, sobald der gekoppelte Befehl läuft. const stairTool: Tool = { id: "stair", labelKey: "tool.stair", floorOnly: true, hintKey: () => "tool.stair.hint", init: () => ({ phase: "idle" }), onClick: (s) => [s, { draft: null }], onMove: (s) => [s, { draft: null }], onCommitGesture: (s) => [s, { draft: null, done: true }], onCancel: (s) => [s, { draft: null, done: true }], }; // ── Room (Raum) ────────────────────────────────────────────────────────────── // An den gekoppelten Befehl `room` (TOOL_COMMAND) gebunden: der interaktive // Ablauf (Klick-innen zur Erkennung ODER manueller Umriss) läuft komplett in der // CommandEngine. Dieses Tool-Objekt ist ein reiner Platzhalter (wie stair/window); // der Controller ruft es nicht auf, sobald der gekoppelte Befehl läuft. const roomTool: Tool = { id: "room", labelKey: "tool.room", floorOnly: true, hintKey: () => "tool.room.hint", init: () => ({ phase: "idle" }), onClick: (s) => [s, { draft: null }], onMove: (s) => [s, { draft: null }], onCommitGesture: (s) => [s, { draft: null, done: true }], onCancel: (s) => [s, { draft: null, done: true }], }; // ── Line ───────────────────────────────────────────────────────────────────── // Zwei-Klick-Strecke → Drawing2D{shape:"line"}. interface LineDrawing { phase: "drawing"; a: Vec2; cursor: Vec2 | null; } type LineState = { phase: "idle" } | LineDrawing; function lineDraft(a: Vec2, cursor: Vec2 | null): ToolDraft { const preview: DraftShape[] = []; if (cursor && segLen(a, cursor) >= EPS) preview.push({ kind: "line", a, b: cursor }); const draft: ToolDraft = { preview, vertices: [a] }; if (cursor && segLen(a, cursor) >= EPS) { draft.hud = { at: cursor, text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs( (segAngleDeg(a, cursor) + 360) % 360, ).toFixed(0)}°`, }; } return draft; } function appendLine(project: Project, a: Vec2, b: Vec2, ctx: ToolContext): Project { const d: Drawing2D = { id: uniqueId("dr2d"), type: "drawing2d", levelId: ctx.level.id, categoryCode: ctx.defaultCategoryCode, geom: { shape: "line", a, b }, // Kein expliziter Linienstil → erbt Farbe/Strichstärke aus der aktiven Ebene // (Kategorie). Ein Linienstil-Picker kann das später überschreiben. }; return { ...project, drawings2d: [...project.drawings2d, d] }; } const lineTool: Tool = { id: "line", labelKey: "tool.line", hintKey: (s) => (s as LineState).phase === "drawing" ? "tool.line.secondPoint" : "tool.line.firstPoint", init: () => ({ phase: "idle" }), onClick: (state, p, ctx) => { const s = state as LineState; if (s.phase !== "drawing") { return [{ phase: "drawing", a: p.point, cursor: p.point }, { draft: lineDraft(p.point, p.point) }]; } // Zweiter Klick: Strecke abschließen (Null-Strecke verwerfen). if (segLen(s.a, p.point) < EPS) { return [{ phase: "idle" }, { draft: null, done: true }]; } const a = s.a; const b = p.point; return [ { phase: "idle" }, { draft: null, done: true, commit: (proj) => appendLine(proj, a, b, ctx) }, ]; }, onMove: (state, p) => { const s = state as LineState; if (s.phase !== "drawing") return [s, { draft: { preview: [], vertices: [] } }]; return [{ ...s, cursor: p.point }, { draft: lineDraft(s.a, p.point) }]; }, // Linie braucht genau zwei Punkte; Doppelklick/Enter bricht den offenen // Entwurf ab (kein gültiges zweites Ende gesetzt). onCommitGesture: () => [{ phase: "idle" }, { draft: null, done: true }], onCancel: () => [{ phase: "idle" }, { draft: null, done: true }], }; // ── Polyline ───────────────────────────────────────────────────────────────── // Mehrteilige 2D-Polylinie → Drawing2D{shape:"polyline"}. Abschluss per Doppel-/ // Rechtsklick/Enter (offen) ODER Klick auf den Startpunkt (geschlossen). interface PolyDrawing { phase: "drawing"; points: Vec2[]; cursor: Vec2 | null; } type PolyState = { phase: "idle" } | PolyDrawing; function polyDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft { const preview: DraftShape[] = []; const all = cursor ? [...points, cursor] : points; for (let i = 0; i < all.length - 1; i++) { if (segLen(all[i], all[i + 1]) >= EPS) preview.push({ kind: "line", a: all[i], b: all[i + 1] }); } const draft: ToolDraft = { preview, vertices: points }; if (cursor && points.length > 0) { const a = points[points.length - 1]; if (segLen(a, cursor) >= EPS) { draft.hud = { at: cursor, text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs( (segAngleDeg(a, cursor) + 360) % 360, ).toFixed(0)}°`, }; } } return draft; } function appendPolyline(project: Project, pts: Vec2[], closed: boolean, ctx: ToolContext): Project { if (pts.length < 2) return project; const d: Drawing2D = { id: uniqueId("dr2d"), type: "drawing2d", levelId: ctx.level.id, categoryCode: ctx.defaultCategoryCode, geom: { shape: "polyline", pts, closed }, // Kein expliziter Linienstil → erbt Farbe/Strichstärke aus der aktiven Ebene // (Kategorie). Ein Linienstil-Picker kann das später überschreiben. }; return { ...project, drawings2d: [...project.drawings2d, d] }; } const polylineTool: Tool = { id: "polyline", labelKey: "tool.polyline", hintKey: (s) => (s as PolyState).phase === "drawing" ? "tool.polyline.nextPoint" : "tool.polyline.firstPoint", init: () => ({ phase: "idle" }), onClick: (state, p, ctx) => { const s = state as PolyState; if (s.phase !== "drawing") { return [{ phase: "drawing", points: [p.point], cursor: p.point }, { draft: polyDraft([p.point], p.point) }]; } // Klick auf den Startpunkt schließt die Polylinie (ab 3 Punkten). if (s.points.length >= 3 && segLen(p.point, s.points[0]) < EPS) { const pts = s.points; return [{ phase: "idle" }, { draft: null, done: true, commit: (proj) => appendPolyline(proj, pts, true, ctx) }]; } const points = [...s.points, p.point]; return [{ phase: "drawing", points, cursor: p.point }, { draft: polyDraft(points, p.point) }]; }, onMove: (state, p) => { const s = state as PolyState; if (s.phase !== "drawing") return [s, { draft: { preview: [], vertices: [] } }]; return [{ ...s, cursor: p.point }, { draft: polyDraft(s.points, p.point) }]; }, onCommitGesture: (state, ctx) => { const s = state as PolyState; if (s.phase !== "drawing" || s.points.length < 2) { return [{ phase: "idle" }, { draft: null, done: true }]; } const pts = s.points; return [{ phase: "idle" }, { draft: null, done: true, commit: (proj) => appendPolyline(proj, pts, false, ctx) }]; }, onCancel: () => [{ phase: "idle" }, { draft: null, done: true }], onUndoPoint: (state) => { const s = state as PolyState; if (s.phase === "drawing" && s.points.length > 1) { const points = s.points.slice(0, -1); return [{ phase: "drawing", points, cursor: s.cursor }, { draft: polyDraft(points, s.cursor) }]; } return [{ phase: "idle" }, { draft: null, done: true }]; }, }; // ── Rectangle ──────────────────────────────────────────────────────────────── // Zwei Ecken (Klick-Klick) → Drawing2D{shape:"rect"} (achsparallel, min/max sortiert). interface RectDrawing { phase: "drawing"; p0: Vec2; cursor: Vec2 | null; } type RectState = { phase: "idle" } | RectDrawing; function rectCorners(a: Vec2, b: Vec2): Vec2[] { const minX = Math.min(a.x, b.x), maxX = Math.max(a.x, b.x); const minY = Math.min(a.y, b.y), maxY = Math.max(a.y, b.y); return [ { x: minX, y: minY }, { x: maxX, y: minY }, { x: maxX, y: maxY }, { x: minX, y: maxY }, ]; } function rectDraft(p0: Vec2, cursor: Vec2 | null): ToolDraft { const preview: DraftShape[] = []; if (cursor) preview.push({ kind: "poly", pts: rectCorners(p0, cursor), closed: true }); const draft: ToolDraft = { preview, vertices: [p0] }; if (cursor) { const w = Math.abs(cursor.x - p0.x); const h = Math.abs(cursor.y - p0.y); draft.hud = { at: cursor, text: `${w.toFixed(2)} × ${h.toFixed(2)} m` }; } return draft; } function appendRect(project: Project, a: Vec2, b: Vec2, ctx: ToolContext): Project { const min = { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) }; const max = { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) }; if (Math.abs(max.x - min.x) < EPS || Math.abs(max.y - min.y) < EPS) return project; const d: Drawing2D = { id: uniqueId("dr2d"), type: "drawing2d", levelId: ctx.level.id, categoryCode: ctx.defaultCategoryCode, geom: { shape: "rect", min, max }, // Kein expliziter Linienstil → erbt Farbe/Strichstärke aus der aktiven Ebene // (Kategorie). Ein Linienstil-Picker kann das später überschreiben. }; return { ...project, drawings2d: [...project.drawings2d, d] }; } const rectTool: Tool = { id: "rect", labelKey: "tool.rect", hintKey: (s) => (s as RectState).phase === "drawing" ? "tool.rect.secondCorner" : "tool.rect.firstCorner", init: () => ({ phase: "idle" }), onClick: (state, p, ctx) => { const s = state as RectState; if (s.phase !== "drawing") { return [{ phase: "drawing", p0: p.point, cursor: p.point }, { draft: rectDraft(p.point, p.point) }]; } const a = s.p0; const b = p.point; return [{ phase: "idle" }, { draft: null, done: true, commit: (proj) => appendRect(proj, a, b, ctx) }]; }, onMove: (state, p) => { const s = state as RectState; if (s.phase !== "drawing") return [s, { draft: { preview: [], vertices: [] } }]; return [{ ...s, cursor: p.point }, { draft: rectDraft(s.p0, p.point) }]; }, onCommitGesture: () => [{ phase: "idle" }, { draft: null, done: true }], onCancel: () => [{ phase: "idle" }, { draft: null, done: true }], }; // Kreis: Platzhalter-Werkzeug (analog window/door), gekoppelt an den // `circle`-Befehl (TOOL_COMMAND in App.tsx). Der eigentliche Ablauf // (Mittelpunkt → Radius) läuft über die Command-Engine — EIN Pfad für // Toolbar-Klick und getipptes „circle". Nicht floorOnly (freie 2D-Zeichnung). const circleTool: Tool = { id: "circle", labelKey: "tool.circle", hintKey: () => "tool.circle.hint", init: () => ({ phase: "idle" }), onClick: (s) => [s, { draft: null }], onMove: (s) => [s, { draft: null }], onCommitGesture: (s) => [s, { draft: null, done: true }], onCancel: (s) => [s, { draft: null, done: true }], }; // Bogen: Platzhalter-Werkzeug, gekoppelt an den `arc`-Befehl (3 Klicks: // Mitte → Start/Radius → Ende). Wie Kreis nicht floorOnly. const arcTool: Tool = { id: "arc", labelKey: "tool.arc", hintKey: () => "tool.arc.hint", init: () => ({ phase: "idle" }), onClick: (s) => [s, { draft: null }], onMove: (s) => [s, { draft: null }], onCommitGesture: (s) => [s, { draft: null, done: true }], onCancel: (s) => [s, { draft: null, done: true }], }; // ── Registry ───────────────────────────────────────────────────────────────── const TOOLS: Record = { select: selectTool, wall: wallTool, ceiling: ceilingTool, window: windowTool, door: doorTool, stair: stairTool, room: roomTool, line: lineTool, polyline: polylineTool, rect: rectTool, circle: circleTool, arc: arcTool, }; /** Liefert das Werkzeug zur ID. */ export function getTool(id: ToolId): Tool { return TOOLS[id]; } /** Reihenfolge der Werkzeuge in der Werkzeugleiste. */ export const TOOL_ORDER: ToolId[] = [ "select", "wall", "ceiling", "window", "door", "stair", "room", "line", "polyline", "rect", "circle", "arc", ];