// Transformations-System (Bewegen/Spiegeln/Drehen) auf der Auswahl, im Stil von // Vectorworks: Geste „Basispunkt → Ziel", Live-Vorschau, und eine Modusleiste // U/I/O/P, die die Vervielfältigung steuert: // U move — nur das Original transformieren. // I copy — Original bleibt + eine transformierte Kopie. // O array — Original bleibt + N Kopien, je um den vollen Schritt versetzt. // P distribute — Original bleibt + N Kopien gleichmäßig zwischen Original und Ziel. // // Bezeichner englisch, UI-Text via t() (CONVENTIONS.md). Reine Funktionen + Geometrie; // die App hält den State und wendet `commitTransform` immutabel an. import type { Column, Drawing2D, Drawing2DGeom, ExtrudedSolid, Project, Vec2, Wall, } from "../model/types"; import { wallTypeThickness } from "../model/types"; import { wallCorners } from "../model/geometry"; import { columnFootprint } from "../geometry/column"; import type { DraftShape } from "./types"; import { uniqueId } from "./types"; export type TransformOp = "move" | "rotate" | "mirror"; export type CopyMode = "move" | "copy" | "array" | "distribute"; // U / I / O / P /** Welche Auswahl transformiert wird. */ export interface TransformSelection { wallIds: string[]; drawingId: string | null; /** Gewählter extrudierter Körper (truck-Integration); nur move.ts setzt ihn. */ extrudedSolidId?: string | null; /** Gewählte Stütze (Tragwerk); von move/copy/mirror gesetzt. */ columnId?: string | null; } /** Wie viele Klickpunkte die Operation braucht (Drehen = 3, sonst 2). */ export const requiredPoints = (op: TransformOp): number => (op === "rotate" ? 3 : 2); /** * Punkt-Transformation einer Operation bei „Anteil" t (0..1 oder Vielfache): * • move — Verschiebung um t·Vektor (pts[0]→pts[1]). * • rotate — Drehung um pts[0] um t·(Winkel(pts[2]) − Winkel(pts[1])). * • mirror — Spiegelung an der Achse pts[0]→pts[1] (t wird ignoriert). */ export function transformFn(op: TransformOp, pts: Vec2[], t: number): (p: Vec2) => Vec2 { if (op === "move") { const vx = (pts[1].x - pts[0].x) * t; const vy = (pts[1].y - pts[0].y) * t; return (p) => ({ x: p.x + vx, y: p.y + vy }); } if (op === "rotate") { const c = pts[0]; const a0 = Math.atan2(pts[1].y - c.y, pts[1].x - c.x); const a1 = Math.atan2(pts[2].y - c.y, pts[2].x - c.x); const ang = (a1 - a0) * t; const cos = Math.cos(ang), sin = Math.sin(ang); return (p) => { const dx = p.x - c.x, dy = p.y - c.y; return { x: c.x + dx * cos - dy * sin, y: c.y + dx * sin + dy * cos }; }; } // mirror: Spiegelung an der Geraden pts[0]→pts[1]. const a = pts[0], b = pts[1]; const dx = b.x - a.x, dy = b.y - a.y; const len2 = dx * dx + dy * dy || 1; return (p) => { // Projektion von p auf die Achse, dann gespiegelt. const tt = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2; const fx = a.x + dx * tt, fy = a.y + dy * tt; return { x: 2 * fx - p.x, y: 2 * fy - p.y }; }; } /** * Die „Anteile" t der KOPIEN (ohne Original) je Modus. Beim Spiegeln ergeben * Array/Verteilen keine Reihe (Spiegelung ist selbstinvers) → genau eine Kopie. */ export function copyTs(op: TransformOp, mode: CopyMode, count: number): number[] { if (mode === "move") return []; if (op === "mirror") return [1]; // copy/array/distribute → eine gespiegelte Kopie if (mode === "copy") return [1]; const n = Math.max(1, Math.floor(count)); if (mode === "array") return Array.from({ length: n }, (_, k) => k + 1); // distribute: n Kopien gleichmäßig zwischen Original (0) und Ziel (1). return Array.from({ length: n }, (_, k) => (k + 1) / n); } /** Wird beim Modus „move" (U) das Original selbst transformiert? */ export const movesOriginal = (mode: CopyMode): boolean => mode === "move"; // ── Geometrie einer Auswahl ───────────────────────────────────────────────── interface SelItem { kind: "wall"; wall: Wall; } interface SelDrawingItem { kind: "drawing"; drawing: Drawing2D; } interface SelExtrudedItem { kind: "extrudedSolid"; solid: ExtrudedSolid; } interface SelColumnItem { kind: "column"; column: Column; } type AnyItem = SelItem | SelDrawingItem | SelExtrudedItem | SelColumnItem; function gatherItems(project: Project, sel: TransformSelection): AnyItem[] { const items: AnyItem[] = []; const ids = new Set(sel.wallIds); for (const w of project.walls) if (ids.has(w.id)) items.push({ kind: "wall", wall: w }); if (sel.drawingId) { const d = project.drawings2d.find((x) => x.id === sel.drawingId); if (d) items.push({ kind: "drawing", drawing: d }); } if (sel.extrudedSolidId) { const s = (project.extrudedSolids ?? []).find((x) => x.id === sel.extrudedSolidId); if (s) items.push({ kind: "extrudedSolid", solid: s }); } if (sel.columnId) { const c = (project.columns ?? []).find((x) => x.id === sel.columnId); if (c) items.push({ kind: "column", column: c }); } return items; } /** * Transformiert eine Stütze unter einer affinen Punkt-Abbildung `fn` * (Verschiebung/Drehung/Spiegelung): der Einfügepunkt wandert mit; die Profil- * Drehung folgt der Abbildung, indem ein Richtungsvektor (Länge 1 unter dem * aktuellen Winkel) mit-transformiert und der neue Winkel daraus gemessen wird * — das ergibt für alle drei Operationen die korrekte neue Ausrichtung. */ function mvColumn(col: Column, fn: (p: Vec2) => Vec2): Column { const base = col.position; const rot = col.rotation ?? 0; const tip = { x: base.x + Math.cos(rot), y: base.y + Math.sin(rot) }; const nb = fn(base); const nt = fn(tip); return { ...col, position: nb, rotation: Math.atan2(nt.y - nb.y, nt.x - nb.x) }; } const mvGeom = (geom: Drawing2DGeom, fn: (p: Vec2) => Vec2): Drawing2DGeom => { switch (geom.shape) { case "line": return { ...geom, a: fn(geom.a), b: fn(geom.b) }; case "polyline": return { ...geom, pts: geom.pts.map(fn) }; case "rect": { // Achsparallel nur bei reiner Verschiebung erhaltbar; sonst → Polylinie. const c = [ geom.min, { x: geom.max.x, y: geom.min.y }, geom.max, { x: geom.min.x, y: geom.max.y }, ].map(fn); const axisAligned = Math.abs(c[0].y - c[1].y) < 1e-6 && Math.abs(c[1].x - c[2].x) < 1e-6 && Math.abs(c[2].y - c[3].y) < 1e-6; if (axisAligned) { const xs = c.map((p) => p.x), ys = c.map((p) => p.y); return { shape: "rect", min: { x: Math.min(...xs), y: Math.min(...ys) }, max: { x: Math.max(...xs), y: Math.max(...ys) }, }; } return { shape: "polyline", pts: c, closed: true }; } case "circle": return { ...geom, center: fn(geom.center) }; case "arc": return { ...geom, center: fn(geom.center) }; case "text": return { ...geom, at: fn(geom.at) }; } }; /** Vorschau-Formen für ein Element unter der Punkt-Transformation `fn`. */ function itemPreview(project: Project, item: AnyItem, fn: (p: Vec2) => Vec2): DraftShape[] { if (item.kind === "wall") { const w = item.wall; const wt = project.wallTypes.find((t) => t.id === w.wallTypeId); const th = wt ? wallTypeThickness(wt) : 0.2; const a = fn(w.start), b = fn(w.end); return [ { kind: "poly", pts: wallCorners(a, b, th), closed: true }, { kind: "line", a, b }, ]; } if (item.kind === "extrudedSolid") { return [{ kind: "poly", pts: item.solid.points.map(fn), closed: true }]; } if (item.kind === "column") { return [{ kind: "poly", pts: columnFootprint(item.column).map(fn), closed: true }]; } const g = mvGeom(item.drawing.geom, fn); switch (g.shape) { case "line": return [{ kind: "line", a: g.a, b: g.b }]; case "polyline": { const out: DraftShape[] = []; for (let i = 0; i < g.pts.length - 1; i++) out.push({ kind: "line", a: g.pts[i], b: g.pts[i + 1] }); if (g.closed && g.pts.length > 2) out.push({ kind: "line", a: g.pts[g.pts.length - 1], b: g.pts[0] }); return out; } case "rect": return [{ kind: "poly", pts: [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }], closed: true }]; default: return []; } } /** * Vorschau aller transformierten Instanzen (bewegtes Original bei U + alle * Kopien). `pts` sind die bisher gesetzten Punkte inkl. Cursor (Länge = * requiredPoints). Liefert die Stütz-/Basis-Punkte separat für Marker. */ export function transformPreview( project: Project, sel: TransformSelection, op: TransformOp, pts: Vec2[], mode: CopyMode, count: number, ): DraftShape[] { const items = gatherItems(project, sel); const shapes: DraftShape[] = []; const instances: number[] = [...copyTs(op, mode, count)]; if (movesOriginal(mode)) instances.push(1); // U: Original an Endlage zeigen for (const t of instances) { const fn = transformFn(op, pts, t); for (const item of items) shapes.push(...itemPreview(project, item, fn)); } return shapes; } // ── Commit ─────────────────────────────────────────────────────────────────── /** Wendet die Transformation immutabel auf das Projekt an (Original + Kopien). */ export function commitTransform( project: Project, sel: TransformSelection, op: TransformOp, pts: Vec2[], mode: CopyMode, count: number, ): Project { const items = gatherItems(project, sel); if (items.length === 0) return project; const wallIds = new Set(sel.wallIds); let walls = project.walls; let drawings2d = project.drawings2d; let extrudedSolids = project.extrudedSolids ?? []; let columns = project.columns ?? []; // U (move): Original an die Endlage (t=1) transformieren. if (movesOriginal(mode)) { const fn = transformFn(op, pts, 1); walls = walls.map((w) => (wallIds.has(w.id) ? { ...w, start: fn(w.start), end: fn(w.end) } : w)); if (sel.drawingId) { drawings2d = drawings2d.map((d) => d.id === sel.drawingId ? { ...d, geom: mvGeom(d.geom, fn) } : d, ); } if (sel.extrudedSolidId) { extrudedSolids = extrudedSolids.map((s) => s.id === sel.extrudedSolidId ? { ...s, points: s.points.map(fn), circle: s.circle ? { ...s.circle, center: fn(s.circle.center) } : undefined, } : s, ); } if (sel.columnId) { columns = columns.map((c) => (c.id === sel.columnId ? mvColumn(c, fn) : c)); } } // Kopien anhängen (I/O/P bzw. mirror copy). const newWalls: Wall[] = []; const newDrawings: Drawing2D[] = []; const newSolids: ExtrudedSolid[] = []; const newColumns: Column[] = []; for (const t of copyTs(op, mode, count)) { const fn = transformFn(op, pts, t); for (const item of items) { if (item.kind === "wall") { const w = item.wall; newWalls.push({ ...w, id: uniqueId("W"), start: fn(w.start), end: fn(w.end) }); } else if (item.kind === "drawing") { const d = item.drawing; newDrawings.push({ ...d, id: uniqueId("dr2d"), geom: mvGeom(d.geom, fn) }); } else if (item.kind === "column") { newColumns.push({ ...mvColumn(item.column, fn), id: uniqueId("column") }); } else { const s = item.solid; newSolids.push({ ...s, id: uniqueId("extrude"), points: s.points.map(fn), circle: s.circle ? { ...s.circle, center: fn(s.circle.center) } : undefined, }); } } } return { ...project, walls: newWalls.length ? [...walls, ...newWalls] : walls, drawings2d: newDrawings.length ? [...drawings2d, ...newDrawings] : drawings2d, extrudedSolids: newSolids.length ? [...extrudedSolids, ...newSolids] : extrudedSolids, columns: newColumns.length ? [...columns, ...newColumns] : columns, }; }