Files
DOSSIER-STANDALONE/src/tools/transform.ts
T
karim 35299307d6 Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)
Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
2026-07-09 00:57:29 +02:00

327 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
};
}