Browser-BIM (cad): semantisches Modell, abgeleitete 2D/3D-Sichten, Zeichenwerkzeuge

Standalone-Browser-Port von DOSSIER. Enthaelt das semantische Modell mit
Plan-/3D-Ableitung, Zeichen- und Editierwerkzeuge, Rhino-artiges Befehlssystem,
dockbares Panel-System, Resource-Manager, DXF/.lin/.pat-Import, i18n (de/en)
sowie Projektdokumentation und Probe-Harness.
This commit is contained in:
2026-06-30 20:52:27 +02:00
commit ca859c4aa4
157 changed files with 37921 additions and 0 deletions
+251
View File
@@ -0,0 +1,251 @@
// 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 {
Drawing2D,
Drawing2DGeom,
Project,
Vec2,
Wall,
} from "../model/types";
import { wallTypeThickness } from "../model/types";
import { wallCorners } from "../model/geometry";
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;
}
/** 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;
}
type AnyItem = SelItem | SelDrawingItem;
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 });
}
return items;
}
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 },
];
}
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;
// 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,
);
}
}
// Kopien anhängen (I/O/P bzw. mirror copy).
const newWalls: Wall[] = [];
const newDrawings: Drawing2D[] = [];
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 {
const d = item.drawing;
newDrawings.push({ ...d, id: uniqueId("dr2d"), geom: mvGeom(d.geom, fn) });
}
}
}
return {
...project,
walls: newWalls.length ? [...walls, ...newWalls] : walls,
drawings2d: newDrawings.length ? [...drawings2d, ...newDrawings] : drawings2d,
};
}