35299307d6
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.
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
// 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,
|
||
};
|
||
}
|