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
+177
View File
@@ -0,0 +1,177 @@
// Snapping (docs/design/drawing-tools.md §5). Läuft auf jedem Pointer-Move
// BEVOR der Punkt an das Werkzeug geht: sammelt Snap-Kandidaten, wählt den
// besten innerhalb der Bildschirm-Toleranz und liefert Marker-Info.
//
// Phase 2: endpoint · midpoint · intersection · onEdge (Lot) · grid · ortho.
// Prioritäts-Reihenfolge (Vorrang bei nahem Abstand): endpoint > intersection >
// midpoint > onEdge > grid; ortho/Winkelraster ist eine Projektion (überlagert).
import type { Project, Vec2 } from "../model/types";
import { lineIntersect } from "../model/geometry";
import type { SnapKind, SnapResult, SnapSettings } from "./types";
const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y);
const mid = (a: Vec2, b: Vec2): Vec2 => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
/** Bildschirm-px-Bonus je Snap-Art: höher = gewinnt knappe Vergleiche. */
const PRIORITY: Record<SnapKind, number> = {
endpoint: 7,
intersection: 5,
midpoint: 3,
center: 3,
quadrant: 3,
onEdge: 0,
grid: -2,
ortho: 0,
extension: 0,
};
export interface SnapInput {
raw: Vec2;
project: Project;
levelId: string;
visibleCodes: Set<string>;
settings: SnapSettings;
draftPoints: Vec2[];
lastPoint: Vec2 | null;
pxPerMeter: number;
shift: boolean;
ctrl: boolean;
}
/** Eine Strecke als Snap-Quelle (Wandachse oder 2D-Segment). */
type Seg = [Vec2, Vec2];
/** Sammelt alle sichtbaren Strecken des Geschosses (Wandachsen + 2D-Segmente). */
function collectSegments(input: SnapInput): Seg[] {
const segs: Seg[] = [];
for (const w of input.project.walls) {
if (w.floorId !== input.levelId) continue;
if (!input.visibleCodes.has(w.categoryCode)) continue;
segs.push([w.start, w.end]);
}
for (const d of input.project.drawings2d) {
if (d.levelId !== input.levelId) continue;
if (!input.visibleCodes.has(d.categoryCode)) continue;
const g = d.geom;
if (g.shape === "line") segs.push([g.a, g.b]);
else if (g.shape === "polyline") {
for (let i = 0; i < g.pts.length - 1; i++) segs.push([g.pts[i], g.pts[i + 1]]);
if (g.closed && g.pts.length > 2) segs.push([g.pts[g.pts.length - 1], g.pts[0]]);
} else if (g.shape === "rect") {
const c1 = g.min, c3 = g.max;
const c2 = { x: g.max.x, y: g.min.y }, c4 = { x: g.min.x, y: g.max.y };
segs.push([c1, c2], [c2, c3], [c3, c4], [c4, c1]);
}
}
// Bereits gesetzte Draft-Segmente (Polylinie/Wand im Bau).
for (let i = 0; i < input.draftPoints.length - 1; i++) {
segs.push([input.draftPoints[i], input.draftPoints[i + 1]]);
}
return segs;
}
/** Lotfußpunkt von p auf die Strecke a-b, auf das Segment geklemmt. */
function perpFoot(p: Vec2, a: Vec2, b: Vec2): Vec2 {
const dx = b.x - a.x, dy = b.y - a.y;
const len2 = dx * dx + dy * dy;
if (len2 < 1e-12) return a;
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
t = Math.max(0, Math.min(1, t));
return { x: a.x + dx * t, y: a.y + dy * t };
}
/**
* Bestimmt den effektiven Snap für den rohen Cursor-Punkt (oder null).
* Punkt-Snaps haben Vorrang vor Ortho und Raster (Prioritäts-Bonus).
*/
export function computeSnap(input: SnapInput): SnapResult | null {
const { raw, settings, pxPerMeter, shift, ctrl } = input;
if (ctrl || !settings.enabled) return null;
const tolM = settings.tolerancePx / Math.max(pxPerMeter, 1e-6);
// Bester Kandidat nach (distPx Prioritäts-Bonus).
let best: SnapResult | null = null;
let bestScore = Infinity;
const consider = (point: Vec2, kind: SnapKind, refA?: Vec2) => {
const d = dist(point, raw);
if (d > tolM) return;
const distPx = d * pxPerMeter;
const score = distPx - PRIORITY[kind];
if (score < bestScore) {
bestScore = score;
best = { point, kind, distPx, refA };
}
};
const segs =
settings.midpoint || settings.intersection || settings.onEdge || settings.endpoint
? collectSegments(input)
: [];
// endpoint (Strecken-Endpunkte + Draft-Knoten).
if (settings.endpoint) {
for (const [a, b] of segs) {
consider(a, "endpoint");
consider(b, "endpoint");
}
for (const p of input.draftPoints) consider(p, "endpoint");
}
// midpoint.
if (settings.midpoint) {
for (const [a, b] of segs) consider(mid(a, b), "midpoint");
}
// intersection (paarweise Geraden-Schnitt der Strecken, nahe am Cursor).
if (settings.intersection) {
for (let i = 0; i < segs.length; i++) {
for (let j = i + 1; j < segs.length; j++) {
const [a, b] = segs[i];
const [c, d] = segs[j];
const x = lineIntersect(a, { x: b.x - a.x, y: b.y - a.y }, c, {
x: d.x - c.x,
y: d.y - c.y,
});
if (x) consider(x, "intersection");
}
}
}
// onEdge (Lotfußpunkt auf nahe Strecken; niedrige Priorität).
if (settings.onEdge) {
for (const [a, b] of segs) consider(perpFoot(raw, a, b), "onEdge");
}
if (best) return best;
// Ortho / Winkelraster (Projektion relativ zum letzten Punkt).
if ((settings.ortho || shift) && input.lastPoint) {
const point = applyAngleConstraint(input.lastPoint, raw, settings.angleStep);
return { point, kind: "ortho", refA: input.lastPoint, distPx: 0 };
}
// Raster.
if (settings.grid) {
const g = snapToGrid(raw, settings.gridSize);
if (dist(g, raw) <= tolM) {
return { point: g, kind: "grid", distPx: dist(g, raw) * pxPerMeter };
}
}
return null;
}
/** Projiziert `to` auf die nächste erlaubte Richtung vom Punkt `from`. */
export function applyAngleConstraint(from: Vec2, to: Vec2, stepDeg: number): Vec2 {
const dx = to.x - from.x;
const dy = to.y - from.y;
const ang = Math.atan2(dy, dx);
const step = (stepDeg * Math.PI) / 180;
const k = Math.round(ang / step) * step;
const len = Math.hypot(dx, dy);
return { x: from.x + Math.cos(k) * len, y: from.y + Math.sin(k) * len };
}
/** Rundet einen Punkt auf das Raster (Meter). */
function snapToGrid(p: Vec2, size: number): Vec2 {
if (!(size > 0)) return p;
return { x: Math.round(p.x / size) * size, y: Math.round(p.y / size) * size };
}
+389
View File
@@ -0,0 +1,389 @@
// Konkrete Werkzeuge (Phase 1: select / wall / line) + Registry.
// docs/design/drawing-tools.md §3, §4, §10.
import type { Drawing2D, Project, Vec2, Wall } from "../model/types";
import { wallTypeThickness } from "../model/types";
import { wallCorners } from "../model/geometry";
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 }];
},
};
// ── 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 }],
};
// ── Registry ─────────────────────────────────────────────────────────────────
const TOOLS: Record<ToolId, Tool> = {
select: selectTool,
wall: wallTool,
line: lineTool,
polyline: polylineTool,
rect: rectTool,
};
/** 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", "line", "polyline", "rect"];
+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,
};
}
+191
View File
@@ -0,0 +1,191 @@
// Tool-System für das aktive Zeichnen im Grundriss (docs/design/drawing-tools.md).
//
// Werkzeuge sind reine Funktionen über einen internen `ToolState`
// (Discriminated Union je Werkzeug). Sie greifen NUR über `commit(project)`
// immutabel auf das Modell zu und schreiben nie Plan-Primitive — die Ansicht
// bleibt abgeleitet. Bezeichner englisch, UI-Text via t().
import type { DrawingLevel, Project, Vec2 } from "../model/types";
/** Werkzeug-Identität. */
export type ToolId = "select" | "wall" | "line" | "polyline" | "rect";
// ── Snapping ───────────────────────────────────────────────────────────────
export type SnapKind =
| "endpoint"
| "midpoint"
| "intersection"
| "center"
| "quadrant"
| "onEdge"
| "grid"
| "ortho"
| "extension";
export interface SnapResult {
/** Gefangener Punkt (Meter). */
point: Vec2;
kind: SnapKind;
/** Quell-Bezugspunkt (für Hilfslinien bei ortho/extension), optional. */
refA?: Vec2;
/** Bildschirm-Distanz Cursor→Snap (px) — für die Auswahl des Besten. */
distPx: number;
}
export interface SnapSettings {
enabled: boolean;
endpoint: boolean;
midpoint: boolean;
intersection: boolean;
center: boolean;
onEdge: boolean;
grid: boolean;
/** Rasterweite in Metern. */
gridSize: number;
/** Ortho/Winkelraster (Shift erzwingt zusätzlich). */
ortho: boolean;
/** Winkelraster in Grad (z. B. 90 = H/V). */
angleStep: number;
/** Fangradius am Bildschirm in Pixeln. */
tolerancePx: number;
}
/** Default-Snap-Einstellungen (endpoint/midpoint/intersection/onEdge + grid). */
export const DEFAULT_SNAP: SnapSettings = {
enabled: true,
endpoint: true,
midpoint: true,
intersection: true,
center: false,
onEdge: true,
grid: true,
gridSize: 0.1,
ortho: false,
angleStep: 90,
tolerancePx: 12,
};
// ── Werkzeug-Schnittstelle ──────────────────────────────────────────────────
/** Live-Kontext, den ein Werkzeug bei jedem Schritt erhält. */
export interface ToolContext {
project: Project;
/** Aktives Geschoss/die aktive Zeichnungsebene (Ziel der neuen Elemente). */
level: DrawingLevel;
/** Default-Kategorie-Code für neue Elemente. */
defaultCategoryCode: string;
/** Aktiver Wandtyp für das Wand-Werkzeug. */
activeWallTypeId: string;
/** Aktiver Linienstil-Code für 2D-Primitive. */
activeLineStyleId: string;
}
/** Ein an einer Modellposition ausgelöstes Pointer-Ereignis (fertig gesnappt). */
export interface ToolPointer {
/** Roher Modellpunkt (vor Snapping), in Metern. */
raw: Vec2;
/** Gesnappter Punkt + Marker-Info; null = kein Snap. */
snap: SnapResult | null;
/** Effektiver Punkt = snap?.point ?? raw. */
point: Vec2;
shift: boolean;
ctrl: boolean;
alt: boolean;
/** 0 = links, 2 = rechts. */
button: number;
}
/**
* Leichte Vorschau-Form (Rubber-Band). Bewusst NICHT der volle `Primitive`-Typ
* (kein HatchRender nötig); PlanView zeichnet sie mit einer Vorschau-CSS-Klasse
* (gestrichelt, Akzentfarbe). Modell-Meter; PlanView projiziert via toScreen.
*/
export type DraftShape =
| { kind: "line"; a: Vec2; b: Vec2 }
| { kind: "poly"; pts: Vec2[]; closed?: boolean };
/** Darstellbare Vorschau (Rubber-Band). */
export interface ToolDraft {
/** Vorschau-Formen (gestrichelt/halbtransparent gezeichnet). */
preview: DraftShape[];
/** Bereits gesetzte „feste" Stützpunkte (kleine Quadrate). */
vertices: Vec2[];
/** Aktiver Snap (für den Bildschirm-Marker), optional. */
snap?: SnapResult | null;
/** Optionaler Maß-/Winkel-Text am Cursor (z. B. „3.20 m · 90°"). */
hud?: { at: Vec2; text: string };
}
/** Was ein Werkzeug-Schritt nach außen meldet. */
export interface ToolResult {
/** Neuer Vorschau-Zustand; null = nichts zu zeigen. */
draft: ToolDraft | null;
/** Bei Abschluss: Mutation, die App über setProject anwendet. */
commit?: (p: Project) => Project;
/** true → Werkzeug ist fertig und kehrt in seinen Ruhezustand zurück. */
done?: boolean;
}
/**
* Interner Zustand eines Werkzeugs. Basis-Form; jedes Werkzeug verfeinert sie zu
* einer eigenen Discriminated Union (z. B. WallState) und castet beim Eintritt.
*/
export interface ToolState {
phase: string;
}
/** Die Werkzeug-Schnittstelle (reine Funktionen über einen internen State). */
export interface Tool {
id: ToolId;
/** UI-Label-Key (i18n), z. B. „tool.wall". */
labelKey: string;
/** Statuszeilen-Hinweis-Key je Phase. */
hintKey: (state: ToolState) => string;
/** Nur auf Geschossen aktiv? (Wand braucht ein Geschoss.) */
floorOnly?: boolean;
/** Initialer Ruhezustand. */
init(): ToolState;
/** Klick (Punkt setzen). */
onClick(state: ToolState, p: ToolPointer, ctx: ToolContext): [ToolState, ToolResult];
/** Bewegung (Hover/Drag): nur Vorschau, nie Commit. */
onMove(state: ToolState, p: ToolPointer, ctx: ToolContext): [ToolState, ToolResult];
/** Doppelklick/Enter/Rechtsklick: mehrteilige Werkzeuge abschließen. */
onCommitGesture(state: ToolState, ctx: ToolContext): [ToolState, ToolResult];
/** Esc: Entwurf verwerfen, im selben Werkzeug bleiben. */
onCancel(state: ToolState): [ToolState, ToolResult];
/** Backspace: letzten gesetzten Punkt zurücknehmen (optional). */
onUndoPoint?(state: ToolState, ctx: ToolContext): [ToolState, ToolResult];
}
// ── PlanView-Brücke ──────────────────────────────────────────────────────────
// PlanView meldet rohe Modellpunkte + die aktuelle Pixel/Meter-Skala nach oben;
// der ToolController (App) snappt, fährt das Werkzeug und hält den `draft`,
// den PlanView als Overlay zeichnet. So bleibt die Snap-/Modell-Logik außerhalb
// der reinen Darstellungs-/Eingabeschicht.
export interface ToolMods {
shift: boolean;
ctrl: boolean;
alt: boolean;
}
export interface ToolHandlers {
/** Hover/Drag: roher Modellpunkt (Meter) + Pixel/Meter + Modifikatoren. */
onToolMove(raw: Vec2, pxPerMeter: number, mods: ToolMods): void;
/** Klick (Punkt setzen). */
onToolClick(raw: Vec2, pxPerMeter: number, mods: ToolMods): void;
/** Doppelklick / Rechtsklick: mehrteiliges Werkzeug abschließen. */
onToolCommit(): void;
/** Aktuelle Vorschau (vom Controller gehalten). */
draft: ToolDraft | null;
}
// ── ID-Vergabe ───────────────────────────────────────────────────────────────
let idCounter = 0;
/** Neue eindeutige ID, konsistent mit der Praxis in App (`prefix-Date-counter`). */
export function uniqueId(prefix: string): string {
idCounter += 1;
return `${prefix}-${Date.now()}-${idCounter}`;
}