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
+2783
View File
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
// Circle — Mittelpunkt + Radius. Schritte:
// 1) „Mittelpunkt:" → Punkt
// 2) „Radius:" → Punkt (Abstand) ODER getippte Zahl (Radius) → commit
//
// Radius akzeptiert sowohl einen Maus-Punkt (Abstand zum Mittelpunkt) als auch
// eine getippte nackte Zahl. Der Kreis wird in generatePlan als geschlossene
// Polygon-Approximation gerendert.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
interface CircleIdle extends CommandState {
phase: "center";
}
interface CircleDrawing extends CommandState {
phase: "radius";
center: Vec2;
cursor: Vec2 | null;
}
type CircleState = CircleIdle | CircleDrawing;
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
/** Tab-Feld des Radius-Schritts: der Radius selbst. */
const CIRCLE_FIELDS: CommandField[] = [{ id: "radius", labelKey: "cmd.field.radius" }];
/**
* Punkt auf dem Kreis aus gelocktem Radius (oder Cursor-Abstand), so dass
* dist(center, point) = Radius. Die Richtung folgt dem Cursor (sonst +X).
*/
function circlePointFromFields(center: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const r = "radius" in locks ? Math.abs(locks.radius) : cursor ? dist(center, cursor) : 0;
if (cursor) {
const d = dist(center, cursor);
if (d > EPS) {
return { x: center.x + ((cursor.x - center.x) / d) * r, y: center.y + ((cursor.y - center.y) / d) * r };
}
}
return { x: center.x + r, y: center.y };
}
/** Punkte einer Kreis-Approximation (für die Vorschau). */
function circlePts(center: Vec2, r: number): Vec2[] {
const N = 48;
const pts: Vec2[] = [];
for (let i = 0; i < N; i++) {
const t = (i / N) * Math.PI * 2;
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
}
return pts;
}
/** Vorschau (Kreis) + HUD (Radius). */
function circleDraft(center: Vec2, r: number, at: Vec2 | null): ToolDraft {
if (r < EPS) return { preview: [], vertices: [center] };
const preview: DraftShape[] = [{ kind: "poly", pts: circlePts(center, r), closed: true }];
const draft: ToolDraft = { preview, vertices: [center] };
if (at) draft.hud = { at, text: `r ${r.toFixed(2)} m` };
return draft;
}
function appendCircle(p: Project, center: Vec2, r: number, ctx: CommandContext): Project {
if (r < EPS) return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "circle", center, r },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "center", lastPoint: null },
{ draft: null, done: true },
];
export const circleCommand: Command = {
name: "circle",
labelKey: "cmd.circle.label",
prompt: (s) => ((s as CircleState).phase === "radius" ? "cmd.circle.radius" : "cmd.circle.center"),
accepts: (s) => ((s as CircleState).phase === "radius" ? ["point", "number"] : ["point"]),
options: () => [],
init: (): CircleIdle => ({ phase: "center", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as CircleState;
if (s.phase !== "radius") {
if (input.kind !== "point") return [s, { draft: null }];
const ns: CircleDrawing = { phase: "radius", center: input.point, cursor: input.point, lastPoint: input.point };
return [ns, { draft: circleDraft(input.point, 0, null) }];
}
// Radius-Schritt: Zahl = Radius, Punkt = Abstand zum Mittelpunkt.
let r: number;
if (input.kind === "number") r = input.value;
else if (input.kind === "point") r = dist(s.center, input.point);
else {
const rr = s.cursor ? dist(s.center, s.cursor) : 0;
return [s, { draft: circleDraft(s.center, rr, s.cursor) }];
}
const center = s.center;
return [
{ phase: "center", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendCircle(p, center, r, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as CircleState;
if (s.phase !== "radius") return [s, { draft: null }];
const r = dist(s.center, point);
const ns: CircleDrawing = { ...s, cursor: point };
return [ns, { draft: circleDraft(s.center, r, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
// Tab-Feld-Zyklus nur im „radius"-Schritt: ein Radius-Feld; der gelockte Wert
// committet direkt (Punkt liegt auf dem Kreis, dist(center,point)=radius).
fields: (state) => ((state as CircleState).phase === "radius" ? CIRCLE_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as CircleState;
if (s.phase !== "radius") return null;
return circlePointFromFields(s.center, locks, cursor);
},
};
+165
View File
@@ -0,0 +1,165 @@
// Copy — wie Move, aber jeder Zielpunkt fügt eine KOPIE der Auswahl (neue IDs)
// an der Ziellage hinzu; das Original bleibt (Rhino-Copy, docs §2.10). Schritte:
// 1) „Basispunkt:" → Punkt
// 2) „Zielpunkt:" → Punkt (Tab length/angle) → Kopie + BLEIBT AKTIV
// → weitere Zielpunkte = weitere Kopien (Basispunkt bleibt), bis Esc/Enter.
//
// Die Auswahl kommt vorab über ctx.selection. Leere Auswahl → No-op + Hinweis.
// Der Commit nutzt `commitTransform` (op:"move", mode:"copy") — eine Kopie der
// Auswahl an die Endlage, Original unverändert; neue IDs via uniqueId (dort).
import { commitTransform } from "../../tools/transform";
import type { TransformSelection } from "../../tools/transform";
import { transformPreview } from "../../tools/transform";
import type {
Command,
CommandField,
CommandResult,
CommandSelection,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../types";
const DEG = Math.PI / 180;
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;
/** Tab-Felder des Zielschritts: Länge + Winkel des Kopier-Versatzes (§2.7). */
const COPY_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? base;
const length = "length" in locks ? locks.length : segLen(base, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(base, ref);
const ang = angleDeg * DEG;
return { x: base.x + Math.cos(ang) * length, y: base.y + Math.sin(ang) * length };
}
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
}
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
}
interface CopyBase extends CommandState {
phase: "base";
}
interface CopyTarget extends CommandState {
phase: "target";
sel: CommandSelection;
base: Vec2;
cursor: Vec2 | null;
}
interface CopyDone extends CommandState {
phase: "done";
}
type CopyState = CopyBase | CopyTarget | CopyDone;
/** Vorschau EINER Kopie der Auswahl an der Cursor-Lage. */
function copyDraft(
project: Project,
sel: CommandSelection,
base: Vec2,
target: Vec2,
): ToolDraft {
const preview = transformPreview(
project,
toTransformSel(sel),
"move",
[base, target],
"copy",
1,
);
return {
preview,
vertices: [base],
hud: {
at: target,
text: `${segLen(base, target).toFixed(2)} m · ${Math.abs(
(segAngleDeg(base, target) + 360) % 360,
).toFixed(0)}°`,
},
};
}
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as CopyDone,
{ draft: null, done: true },
];
export const copyCommand: Command = {
name: "copy",
labelKey: "cmd.copy.label",
prompt: (s) => {
const cs = s as CopyState;
if (cs.phase === "target") return "cmd.copy.target";
if (cs.phase === "done") return "cmd.copy.empty";
return "cmd.copy.base";
},
accepts: () => ["point"],
options: () => [],
init: (): CopyBase => ({ phase: "base", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as CopyState;
if (s.phase === "done") return finish();
if (!hasSelection(ctx.selection)) {
return [{ phase: "done", lastPoint: null } as CopyDone, { draft: null, done: true }];
}
if (input.kind !== "point") {
if (s.phase === "target") {
return [s, { draft: s.cursor ? copyDraft(ctx.project, s.sel, s.base, s.cursor) : null }];
}
return [s, { draft: null }];
}
const pt = input.point;
if (s.phase !== "target") {
const next: CopyTarget = {
phase: "target",
sel: ctx.selection,
base: pt,
cursor: pt,
lastPoint: pt,
};
return [next, { draft: copyDraft(ctx.project, next.sel, pt, pt) }];
}
// Zielpunkt → eine Kopie committen, ABER aktiv bleiben (wiederholend): der
// Befehl kehrt NICHT in den Ruhezustand zurück (kein done), Basispunkt
// bleibt; jeder weitere Zielpunkt = weitere Kopie, bis Esc/Enter.
const base = s.base;
const sel = s.sel;
const next: CopyTarget = { ...s, cursor: pt, lastPoint: pt };
return [
next,
{
draft: copyDraft(ctx.project, sel, base, pt),
commit: (p) => commitTransform(p, toTransformSel(sel), "move", [base, pt], "copy", 1),
},
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as CopyState;
if (s.phase !== "target") return [s, { draft: null }];
const ns: CopyTarget = { ...s, cursor: point };
return [ns, { draft: copyDraft(ctx.project, s.sel, s.base, point) }];
},
// Enter/Space/Rechtsklick: Copy-Serie beenden (kein Commit hier).
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
fields: (state) => ((state as CopyState).phase === "target" ? COPY_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as CopyState;
if (s.phase !== "target") return null;
return targetFromFields(s.base, locks, cursor);
},
};
+37
View File
@@ -0,0 +1,37 @@
// Import — öffnet den DXF-Datei-Dialog (in App montiert) und beendet sich
// sofort. Der eigentliche Import (Datei lesen → parseDxf → addContextObjects)
// läuft asynchron über das App-onChange des versteckten <input type=file>; ein
// Command kann den Datei-Dialog nicht selbst „committen" (er ist DOM-/async-
// seitig). Daher koppelt dieser Befehl nur den Trigger an (über den Modul-Hook
// dxfImportHook) und gibt keine Mutation zurück.
//
// Bezeichner englisch, sichtbarer Text via t() (Namespace `cmd.import.*`).
import { openDxfImport } from "../../io/dxfImportHook";
import type { Command, CommandResult, CommandState } from "../types";
const IDLE: CommandState = { phase: "idle", lastPoint: null };
/** Öffnet den Datei-Dialog und beendet den Befehl sofort (keine Mutation). */
function open(): [CommandState, CommandResult] {
openDxfImport();
return [{ ...IDLE }, { draft: null, done: true }];
}
export const importCommand: Command = {
name: "import",
labelKey: "cmd.import.label",
prompt: () => "cmd.import.prompt",
accepts: () => [],
options: () => [],
init: () => ({ ...IDLE }),
// Der Befehl hat keine Schritte: Start/Enter/Input öffnet den Dialog + endet.
onInput: () => open(),
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
onConfirm: () => open(),
onCancel: (): [CommandState, CommandResult] => [
{ ...IDLE },
{ draft: null, done: true },
],
};
+140
View File
@@ -0,0 +1,140 @@
// Line — erster Befehl der Command-Engine (portiert lineTool aus
// src/tools/tools.ts). Zwei Punkte → Drawing2D{shape:"line"}.
//
// Schritte:
// 1) „Start of line:" → Punkt (Klick ODER getippt: 0,0 / r3,0 / 3<45)
// 2) „End of line:" → Punkt → commit Drawing2D line → done
//
// Akzeptiert in JEDEM Punkt-Schritt sowohl Maus-Picks (gesnappt) als auch
// getippte Koordinaten — das ist die Kern-Vereinheitlichung gegenüber dem alten
// Tool. Live-Vorschau + HUD (Länge/Winkel) wie die bestehenden Tools.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
DraftShape,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const DEG = Math.PI / 180;
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;
/** Tab-Felder des End-Schritts: erst Länge, dann Winkel (§2.7). */
const LINE_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/**
* Endpunkt aus gelockten Feldern (length/angle) + Cursor: ungelockte Werte
* folgen dem Cursor relativ zum Startpunkt `a`.
*/
function lineEndFromFields(a: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? a;
const length = "length" in locks ? locks.length : segLen(a, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(a, ref);
const ang = angleDeg * DEG;
return { x: a.x + Math.cos(ang) * length, y: a.y + Math.sin(ang) * length };
}
interface LineIdle extends CommandState {
phase: "start";
}
interface LineDrawing extends CommandState {
phase: "end";
a: Vec2;
cursor: Vec2 | null;
}
type LineState = LineIdle | LineDrawing;
/** Vorschau (Rubber-Band) + HUD (Länge·Winkel) für das lebende Segment. */
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;
}
/** Hängt eine 2D-Linie ans Projekt (immutabel). Farbe/Strich erbt die Kategorie. */
function appendLine(p: import("../types").Project, a: Vec2, b: Vec2, ctx: CommandContext) {
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "line", a, b },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
export const lineCommand: Command = {
name: "line",
labelKey: "cmd.line.label",
prompt: (s) => ((s as LineState).phase === "end" ? "cmd.line.end" : "cmd.line.start"),
accepts: () => ["point", "number"],
options: () => [],
init: (): LineIdle => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as LineState;
// Beide Punkt-Schritte erwarten einen aufgelösten Punkt; die Engine reicht
// getippte Koordinaten bereits als {kind:"point"} herein.
if (input.kind !== "point") return [s, { draft: s.phase === "end" ? lineDraft(s.a, s.cursor) : null }];
const pt = input.point;
if (s.phase !== "end") {
const next: LineDrawing = { phase: "end", a: pt, cursor: pt, lastPoint: pt };
return [next, { draft: lineDraft(pt, pt) }];
}
// Zweiter Punkt: Null-Strecke verwerfen, sonst committen.
if (segLen(s.a, pt) < EPS) {
return [{ phase: "start", lastPoint: null }, { draft: null, done: true }];
}
const a = s.a;
return [
{ phase: "start", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendLine(p, a, pt, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as LineState;
if (s.phase !== "end") return [s, { draft: null }];
const next: LineDrawing = { ...s, cursor: point };
return [next, { draft: lineDraft(s.a, point) }];
},
// Line braucht genau zwei Punkte; Enter/Rechtsklick im offenen Entwurf bricht ab.
onConfirm: (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null },
{ draft: null, done: true },
],
onCancel: (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null },
{ draft: null, done: true },
],
// Tab-Feld-Zyklus nur im End-Schritt: Länge + Winkel relativ zum Startpunkt.
fields: (state) => ((state as LineState).phase === "end" ? LINE_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as LineState;
if (s.phase !== "end") return null;
return lineEndFromFields(s.a, locks, cursor);
},
};
+181
View File
@@ -0,0 +1,181 @@
// Move — verschiebt die AKTUELLE Auswahl (Wände + ein Drawing2D) um
// delta = Ziel Basis (Rhino-Move, docs §2.10/§3.4 Tier-1). Schritte:
// 1) „Basispunkt:" → Punkt
// 2) „Zielpunkt:" → Punkt (mit Tab-Feldern length/angle wie Line) → commit
//
// Die Auswahl kommt vorab über ctx.selection (erst selektieren, dann Befehl).
// Ist sie leer, ist nichts zu verschieben: kurzer No-op mit Hinweis-Prompt
// („Nichts gewählt") und Beenden — das ist der einfache, robuste Weg (statt
// einen zweiten, divergierenden Pick-Pfad für die Objektwahl aufzumachen; die
// Selektion läuft schon sauber über die Plan-Ansicht/Store).
//
// Der Commit nutzt den bestehenden, getesteten `commitTransform` (op:"move",
// mode:"move") statt eine zweite Verschiebe-Logik zu duplizieren — er bewegt
// Wände UND alle 2D-Formen immutabel an die Endlage.
import { commitTransform } from "../../tools/transform";
import type { TransformSelection } from "../../tools/transform";
import { transformPreview } from "../../tools/transform";
import type {
Command,
CommandField,
CommandResult,
CommandSelection,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../types";
const DEG = Math.PI / 180;
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;
/** Tab-Felder des Zielschritts: Länge + Winkel des Verschiebevektors (§2.7). */
const MOVE_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/** Zielpunkt aus gelockten Feldern (length/angle) + Cursor, relativ zum Basispunkt. */
function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? base;
const length = "length" in locks ? locks.length : segLen(base, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(base, ref);
const ang = angleDeg * DEG;
return { x: base.x + Math.cos(ang) * length, y: base.y + Math.sin(ang) * length };
}
/** Hat die Auswahl überhaupt etwas Verschiebbares? */
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
}
/** Auswahl → TransformSelection (gleiche Form). */
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
}
// Zustand: erst Basispunkt setzen, dann Ziel (mit Auswahl-Snapshot vom Start).
interface MoveBase extends CommandState {
phase: "base";
}
interface MoveTarget extends CommandState {
phase: "target";
sel: CommandSelection;
base: Vec2;
cursor: Vec2 | null;
}
// „done": leere Auswahl → No-op, Befehl beendet.
interface MoveDone extends CommandState {
phase: "done";
}
type MoveState = MoveBase | MoveTarget | MoveDone;
/** Live-Vorschau der Auswahl an der aktuellen Cursor-Lage (Move). */
function moveDraft(
project: Project,
sel: CommandSelection,
base: Vec2,
target: Vec2,
): ToolDraft {
const preview = transformPreview(
project,
toTransformSel(sel),
"move",
[base, target],
"move",
1,
);
return {
preview,
vertices: [base],
hud: {
at: target,
text: `${segLen(base, target).toFixed(2)} m · ${Math.abs(
(segAngleDeg(base, target) + 360) % 360,
).toFixed(0)}°`,
},
};
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as MoveDone,
{ draft: null, done: true },
];
export const moveCommand: Command = {
name: "move",
labelKey: "cmd.move.label",
prompt: (s) => {
const ms = s as MoveState;
if (ms.phase === "target") return "cmd.move.target";
if (ms.phase === "done") return "cmd.move.empty";
return "cmd.move.base";
},
accepts: () => ["point"],
options: () => [],
// Beim Start die Auswahl aus dem Kontext einfrieren — dafür braucht init()
// den Kontext nicht, das macht onInput beim ersten Aufruf; bis dahin „base".
// Damit die leere Auswahl SOFORT (vor dem ersten Klick) auffällt, prüfen wir
// sie zusätzlich im ersten onMove/onInput.
init: (): MoveBase => ({ phase: "base", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as MoveState;
if (s.phase === "done") return idle();
// Leere Auswahl → nichts zu verschieben (No-op + Hinweis, dann beenden).
if (!hasSelection(ctx.selection)) {
return [{ phase: "done", lastPoint: null } as MoveDone, { draft: null, done: true }];
}
if (input.kind !== "point") {
if (s.phase === "target") {
return [s, { draft: s.cursor ? moveDraft(ctx.project, s.sel, s.base, s.cursor) : null }];
}
return [s, { draft: null }];
}
const pt = input.point;
if (s.phase !== "target") {
// Basispunkt gesetzt → Auswahl-Snapshot mitführen.
const next: MoveTarget = {
phase: "target",
sel: ctx.selection,
base: pt,
cursor: pt,
lastPoint: pt,
};
return [next, { draft: moveDraft(ctx.project, next.sel, pt, pt) }];
}
// Zielpunkt → commit (Auswahl an die Endlage verschieben).
const base = s.base;
const sel = s.sel;
return [
{ phase: "done", lastPoint: null } as MoveDone,
{
draft: null,
done: true,
commit: (p) => commitTransform(p, toTransformSel(sel), "move", [base, pt], "move", 1),
},
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as MoveState;
if (s.phase !== "target") return [s, { draft: null }];
const ns: MoveTarget = { ...s, cursor: point };
return [ns, { draft: moveDraft(ctx.project, s.sel, s.base, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
// Tab-Feld-Zyklus nur im Zielschritt: Länge + Winkel relativ zum Basispunkt.
fields: (state) => ((state as MoveState).phase === "target" ? MOVE_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as MoveState;
if (s.phase !== "target") return null;
return targetFromFields(s.base, locks, cursor);
},
};
+339
View File
@@ -0,0 +1,339 @@
// Offset — DAS Architektur-Primitiv (docs §2.10/§3.4 Tier-1, §3.6 persistente
// Distanz). Erzeugt eine parallel versetzte Kopie einer 2D-Kurve (line/polyline/
// rect). Schritte:
// 1) Kurve bestimmen: ist genau EIN passendes Drawing2D (line/polyline/rect)
// selektiert, wird es genommen; sonst „Kurve wählen:" → Klick auf eine Kurve.
// 2) „Seite/Distanz:" → Distanz tippen (PERSISTENT als Default über Aufrufe,
// modulweite Variable) ODER Seite per Klick (Vorzeichen aus der Klickseite
// relativ zur Kurve). → commit neue versetzte Drawing2D-Kurve.
//
// Distanz-Vorzeichen: Konvention `+d = links` (kernel2d/leftNormal). Die
// Klickseite bestimmt links/rechts: liegt der Klick links der Kurve (bzw. der
// nächsten Kante), wird +|d| genommen, sonst |d|. Eine getippte Zahl nutzt das
// Vorzeichen der zuletzt gewählten Seite (Default links/+), bzw. ihr eigenes
// Vorzeichen, wenn negativ getippt.
import type { Drawing2D, Drawing2DGeom } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { leftNormal, normalize, sub } from "../../model/geometry";
import {
offsetPolyline,
offsetSegment,
pointSegmentDistance,
polylineEdges,
} from "../../geometry/kernel2d";
import type {
CmdInput,
Command,
CommandContext,
CommandResult,
CommandSelection,
CommandState,
DraftShape,
Project,
Vec2,
} from "../types";
const EPS = 1e-6;
const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Kurvenwahl
/**
* Persistente Offset-Distanz über Aufrufe hinweg (§2.4/§3.6 — Nutzer erwarten,
* dass die letzte Distanz als Default wiederkommt). Modulweite Variable, |d|;
* das Vorzeichen (Seite) wird je Offset frisch aus Klick/Eingabe bestimmt.
*/
let lastDistance = 0.5;
/** Zuletzt gewählte Seite als Vorzeichen (+1 = links, 1 = rechts). */
let lastSign = 1;
// ── Kurven-Abstraktion: line/polyline/rect → Punktliste + closed ──────────────
interface Curve {
pts: Vec2[];
closed: boolean;
}
/** Wandelt eine offset-fähige 2D-Form in eine Punktliste (+closed) oder null. */
function geomToCurve(g: Drawing2DGeom): Curve | null {
if (g.shape === "line") return { pts: [g.a, g.b], closed: false };
if (g.shape === "polyline") return { pts: g.pts, closed: g.closed };
if (g.shape === "rect") {
// Rechteck als geschlossene 4-Punkt-Polylinie offsetten (Task).
return {
pts: [
g.min,
{ x: g.max.x, y: g.min.y },
g.max,
{ x: g.min.x, y: g.max.y },
],
closed: true,
};
}
return null; // circle/arc/text: hier nicht offset-fähig
}
/** Ist diese Form offset-fähig (line/polyline/rect)? */
function isOffsetable(d: Drawing2D): boolean {
return d.geom.shape === "line" || d.geom.shape === "polyline" || d.geom.shape === "rect";
}
/** Genau ein passendes Drawing2D vorselektiert? Dann liefere es. */
function singleSelectedCurve(project: Project, sel: CommandSelection): Drawing2D | null {
if (!sel.drawingId || sel.wallIds.length > 0) return null;
const d = project.drawings2d.find((x) => x.id === sel.drawingId);
if (d && isOffsetable(d)) return d;
return null;
}
/** Nächstes offset-fähiges Drawing2D zum Klickpunkt (innerhalb Toleranz). */
function pickCurveAt(project: Project, levelId: string, pt: Vec2): Drawing2D | null {
let best: Drawing2D | null = null;
let bestD = HIT_TOL;
for (const d of project.drawings2d) {
if (d.levelId !== levelId) continue;
const curve = geomToCurve(d.geom);
if (!curve) continue;
for (const [a, b] of polylineEdges(curve.pts, curve.closed)) {
const dd = pointSegmentDistance(pt, a, b);
if (dd < bestD) {
bestD = dd;
best = d;
}
}
}
return best;
}
/**
* Vorzeichen (+links / rechts) für einen Klickpunkt relativ zur Kurve: die
* nächste Kante bestimmen, dann das Vorzeichen der Skalarprojektion von
* (klick Kantenmitte) auf die LINKS-Normale der Kante. So zeigt das Resultat
* auf die angeklickte Seite (Konvention `+d = links`).
*/
function sideSign(curve: Curve, pt: Vec2): number {
let bestD = Infinity;
let sign = lastSign;
for (const [a, b] of polylineEdges(curve.pts, curve.closed)) {
const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const dd = pointSegmentDistance(pt, a, b);
if (dd < bestD) {
bestD = dd;
const u = normalize(sub(b, a));
const n = leftNormal(u);
const rel = sub(pt, mid);
const s = rel.x * n.x + rel.y * n.y;
sign = s >= 0 ? 1 : -1;
}
}
return sign;
}
/** Offset einer Kurve um die vorzeichenbehaftete Distanz `d` → neue Punktliste. */
function offsetCurve(curve: Curve, d: number): Vec2[] {
if (!curve.closed && curve.pts.length === 2) {
const [a, b] = offsetSegment(curve.pts[0], curve.pts[1], d);
return [a, b];
}
return offsetPolyline(curve.pts, d, curve.closed);
}
/** Versetzte Form als neues Drawing2D (gleiche Attribute, neue ID/Geometrie). */
function offsetGeom(src: Drawing2D, d: number): Drawing2DGeom | null {
const g = src.geom;
if (g.shape === "line") {
const [a, b] = offsetSegment(g.a, g.b, d);
return { shape: "line", a, b };
}
if (g.shape === "polyline") {
return { shape: "polyline", pts: offsetPolyline(g.pts, d, g.closed), closed: g.closed };
}
if (g.shape === "rect") {
const curve = geomToCurve(g)!;
return { shape: "polyline", pts: offsetPolyline(curve.pts, d, true), closed: true };
}
return null;
}
/** Vorschau-Formen der versetzten Kurve (für den Draft). */
function offsetDraftShapes(curve: Curve, d: number): DraftShape[] {
const pts = offsetCurve(curve, d);
if (pts.length < 2) return [];
if (!curve.closed && pts.length === 2) return [{ kind: "line", a: pts[0], b: pts[1] }];
return [{ kind: "poly", pts, closed: curve.closed }];
}
function appendOffset(p: Project, src: Drawing2D, d: number): Project {
const geom = offsetGeom(src, d);
if (!geom) return p;
const copy: Drawing2D = { ...src, id: uniqueId("dr2d"), geom };
return { ...p, drawings2d: [...p.drawings2d, copy] };
}
// ── Zustand ───────────────────────────────────────────────────────────────────
// „pick": Kurve wählen (keine passende Vorselektion). „side": Kurve steht fest,
// jetzt Seite/Distanz. „done": Befehl beendet.
interface OffPick extends CommandState {
phase: "pick";
}
interface OffSide extends CommandState {
phase: "side";
drawingId: string;
cursor: Vec2 | null;
}
interface OffDone extends CommandState {
phase: "done";
}
type OffState = OffPick | OffSide | OffDone;
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as OffDone,
{ draft: null, done: true },
];
/** Aktuelle Kurve aus dem Side-Zustand (oder null, wenn weg). */
function curveOf(project: Project, drawingId: string): { src: Drawing2D; curve: Curve } | null {
const src = project.drawings2d.find((x) => x.id === drawingId);
if (!src) return null;
const curve = geomToCurve(src.geom);
if (!curve) return null;
return { src, curve };
}
export const offsetCommand: Command = {
name: "offset",
labelKey: "cmd.offset.label",
prompt: (s) => {
const os = s as OffState;
if (os.phase === "side") return "cmd.offset.side";
return "cmd.offset.pick";
},
// Side-Schritt: Punkt (Seite klicken) ODER Zahl (Distanz tippen). Auch der
// „pick"-Schritt nimmt eine Zahl an, damit eine getippte Distanz sofort greift,
// wenn eine Kurve vorselektiert ist (dann wird „pick" live wie „side" behandelt).
accepts: () => ["point", "number"],
options: () => [],
// init() kennt den Kontext nicht; die Vorselektion wird im ersten onMove/
// onInput ausgewertet (geht direkt auf „side", wenn genau eine Kurve gewählt
// ist). Bis dahin „pick".
init: (): OffPick => ({ phase: "pick", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as OffState;
if (s.phase === "done") return finish();
// Vorselektion: gibt es genau eine passende Kurve, direkt in den Side-Schritt
// springen (auch wenn die Engine erst onInput aufruft).
if (s.phase === "pick") {
const pre = singleSelectedCurve(ctx.project, ctx.selection);
if (pre) {
// Eingabe im selben Schritt direkt als Side/Distanz behandeln.
return offsetSideInput(
{ phase: "side", drawingId: pre.id, cursor: null, lastPoint: null },
input,
ctx,
);
}
// Keine Vorselektion → Kurve per Klick wählen.
if (input.kind === "point") {
const hit = pickCurveAt(ctx.project, ctx.level.id, input.point);
if (!hit) return [s, { draft: null }];
const ns: OffSide = { phase: "side", drawingId: hit.id, cursor: null, lastPoint: null };
return [ns, { draft: null }];
}
return [s, { draft: null }];
}
// phase === "side"
return offsetSideInput(s as OffSide, input, ctx);
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as OffState;
// Vorselektion live: in „pick" mit genau einer Kurve → als Side behandeln.
if (s.phase === "pick") {
const pre = singleSelectedCurve(ctx.project, ctx.selection);
if (pre) {
return offsetSideMove({ phase: "side", drawingId: pre.id, cursor: point, lastPoint: null }, point, ctx);
}
return [s, { draft: null }];
}
if (s.phase === "side") return offsetSideMove(s as OffSide, point, ctx);
return [s, { draft: null }];
},
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
// KEIN Tab-Feld-Zyklus: die Offset-Distanz ist ein reiner Skalar, der als
// `number`-Eingabe in den Schritt läuft (nicht als feld-erzeugter Punkt). So
// committet eine getippte Distanz direkt; die Seite kommt per Klick/Vorzeichen.
};
// ── Side-Schritt-Logik (für Vorselektion UND echten pick wiederverwendet) ─────
/** Eingabe im Side-Schritt: Punkt (Seite klicken) ODER Zahl (Distanz tippen). */
function offsetSideInput(
s: OffSide,
input: CmdInput,
ctx: CommandContext,
): [CommandState, CommandResult] {
const cc = curveOf(ctx.project, s.drawingId);
if (!cc) return finish();
const { src, curve } = cc;
if (input.kind === "number") {
// Getippte Distanz: Betrag merken; Vorzeichen aus der zuletzt gewählten
// Seite (bzw. dem getippten Vorzeichen, falls negativ).
const typed = input.value;
const mag = Math.abs(typed);
if (mag < EPS) return [s, { draft: null }];
lastDistance = mag;
const sign = typed < 0 ? -1 : lastSign;
lastSign = sign;
const d = sign * mag;
return [
{ phase: "done", lastPoint: null } as OffDone,
{ draft: null, done: true, commit: (p) => appendOffset(p, src, d) },
];
}
if (input.kind === "point") {
// Seite per Klick: Vorzeichen aus der Klickseite, Betrag = letzte Distanz.
const sign = sideSign(curve, input.point);
lastSign = sign;
const d = sign * lastDistance;
return [
{ phase: "done", lastPoint: null } as OffDone,
{ draft: null, done: true, commit: (p) => appendOffset(p, src, d) },
];
}
return [s, { draft: null }];
}
/** Hover im Side-Schritt: Vorschau der versetzten Kurve an der Cursor-Seite. */
function offsetSideMove(
s: OffSide,
point: Vec2,
ctx: CommandContext,
): [CommandState, CommandResult] {
const cc = curveOf(ctx.project, s.drawingId);
if (!cc) return [s, { draft: null }];
const { curve } = cc;
const sign = sideSign(curve, point);
const d = sign * lastDistance;
const preview = offsetDraftShapes(curve, d);
const ns: OffSide = { ...s, cursor: point };
return [
ns,
{
draft: {
preview,
vertices: [],
hud: { at: point, text: `${lastDistance.toFixed(2)} m` },
},
},
];
}
+181
View File
@@ -0,0 +1,181 @@
// Polyline — mehrteiliger Zug (portiert polylineTool). Schritte:
// 1) „Startpunkt:" → Punkt
// 2) „Nächster Punkt ( Schliessen Zurück ):" → Punkt … (Enter beendet offen,
// „Schliessen" schließt; Klick auf den Startpunkt schließt ebenfalls)
//
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const DEG = Math.PI / 180;
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Zug
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;
/** Tab-Felder je weiteren Punkt: Länge + Winkel relativ zum letzten Punkt. */
const POLY_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/** Nächster Punkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `last`. */
function polyNextFromFields(last: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? last;
const length = "length" in locks ? locks.length : segLen(last, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
const ang = angleDeg * DEG;
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
}
interface PolyIdle extends CommandState {
phase: "start";
}
interface PolyDrawing extends CommandState {
phase: "next";
points: Vec2[];
cursor: Vec2 | null;
}
type PolyState = PolyIdle | PolyDrawing;
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
/** Vorschau (Zug + Gummiband zum Cursor) + HUD (Länge·Winkel des letzten Segments). */
function polyDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: false }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(last, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
return draft;
}
/** Hängt eine 2D-Polylinie ans Projekt (immutabel). Farbe/Strich erbt die Kategorie. */
function appendPolyline(
p: Project,
pts: Vec2[],
closed: boolean,
ctx: CommandContext,
): Project {
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "polyline", pts, closed },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null },
{ draft: null, done: true },
];
export const polylineCommand: Command = {
name: "polyline",
labelKey: "cmd.polyline.label",
prompt: (s) => ((s as PolyState).phase === "next" ? "cmd.polyline.next" : "cmd.polyline.start"),
accepts: (s) =>
(s as PolyState).phase === "next" ? ["point", "number", "option"] : ["point", "number"],
options: (s) => {
const ps = s as PolyState;
if (ps.phase !== "next") return [];
return ps.points.length >= 2 ? [CLOSE, UNDO] : [UNDO];
},
init: (): PolyIdle => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as PolyState;
if (input.kind === "option") {
if (s.phase !== "next") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0) return [{ phase: "start", lastPoint: null }, { draft: null }];
const ns: PolyDrawing = { phase: "next", points: pts, cursor: s.cursor, lastPoint: pts[pts.length - 1] };
return [ns, { draft: polyDraft(pts, s.cursor) }];
}
if (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
];
}
return [s, { draft: polyDraft(s.points, s.cursor) }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor) : null }];
}
const pt = input.point;
if (s.phase !== "next") {
const ns: PolyDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt };
return [ns, { draft: polyDraft([pt], pt) }];
}
// Klick nahe Startpunkt → schließen.
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
];
}
const points = [...s.points, pt];
const ns: PolyDrawing = { phase: "next", points, cursor: pt, lastPoint: pt };
return [ns, { draft: polyDraft(points, pt) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as PolyState;
if (s.phase !== "next") return [s, { draft: null }];
const ns: PolyDrawing = { ...s, cursor: point };
return [ns, { draft: polyDraft(s.points, point) }];
},
// Enter/Space/Rechtsklick: offenen Zug beenden (≥2 Punkte) und committen.
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as PolyState;
if (s.phase === "next" && s.points.length >= 2) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, false, ctx) },
];
}
return idle();
},
onCancel: (): [CommandState, CommandResult] => idle(),
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten
// Punkt. (Erster Punkt = freie Koordinate, kein Feld-Modus.)
fields: (state) => ((state as PolyState).phase === "next" ? POLY_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as PolyState;
if (s.phase !== "next" || s.points.length === 0) return null;
return polyNextFromFields(s.points[s.points.length - 1], locks, cursor);
},
};
+143
View File
@@ -0,0 +1,143 @@
// Rectangle — zwei Ecken (portiert rectTool). Schritte:
// 1) „Erste Ecke:" → Punkt
// 2) „Gegenüberliegende Ecke:" → Punkt → commit Drawing2D rect → done
//
// Getippte Maße: die zweite Ecke kann als `r<breite>,<höhe>` relativ zur ersten
// eingegeben werden (die Engine löst `r…` relativ zu lastPoint auf).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
/** Tab-Felder des zweiten-Ecke-Schritts: Breite, Höhe. */
const RECT_FIELDS: CommandField[] = [
{ id: "width", labelKey: "cmd.field.width" },
{ id: "height", labelKey: "cmd.field.height" },
];
/**
* Gegenüberliegende Ecke aus gelockten Feldern (width/height) + Cursor: eine
* gelockte Kantenlänge nimmt ihren Betrag, das Vorzeichen (Quadrant) folgt dem
* Cursor relativ zur ersten Ecke `a`; ungelockt folgt die Kante direkt dem Cursor.
*/
function rectCornerFromFields(a: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const cur = cursor ?? a;
const dx = cur.x - a.x;
const dy = cur.y - a.y;
const sx = dx < 0 ? -1 : 1;
const sy = dy < 0 ? -1 : 1;
const w = "width" in locks ? sx * Math.abs(locks.width) : dx;
const h = "height" in locks ? sy * Math.abs(locks.height) : dy;
return { x: a.x + w, y: a.y + h };
}
interface RectIdle extends CommandState {
phase: "corner1";
}
interface RectDrawing extends CommandState {
phase: "corner2";
a: Vec2;
cursor: Vec2 | null;
}
type RectState = RectIdle | RectDrawing;
function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
return {
min: { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) },
max: { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) },
};
}
/** Vorschau (geschlossenes Rechteck) + HUD (Breite × Höhe). */
function rectDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [a] };
const g = rectGeom(a, cursor);
const pts: Vec2[] = [
g.min,
{ x: g.max.x, y: g.min.y },
g.max,
{ x: g.min.x, y: g.max.y },
];
const preview: DraftShape[] = [{ kind: "poly", pts, closed: true }];
const w = g.max.x - g.min.x;
const h = g.max.y - g.min.y;
return {
preview,
vertices: [a],
hud: { at: cursor, text: `${w.toFixed(2)} × ${h.toFixed(2)} m` },
};
}
function appendRect(p: Project, a: Vec2, b: Vec2, ctx: CommandContext): Project {
const g = rectGeom(a, b);
if (g.max.x - g.min.x < EPS || g.max.y - g.min.y < EPS) return p; // Null-Rechteck
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "rect", min: g.min, max: g.max },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "corner1", lastPoint: null },
{ draft: null, done: true },
];
export const rectCommand: Command = {
name: "rect",
labelKey: "cmd.rect.label",
prompt: (s) => ((s as RectState).phase === "corner2" ? "cmd.rect.second" : "cmd.rect.first"),
accepts: () => ["point", "number"],
options: () => [],
init: (): RectIdle => ({ phase: "corner1", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as RectState;
if (input.kind !== "point") {
return [s, { draft: s.phase === "corner2" ? rectDraft(s.a, s.cursor) : null }];
}
const pt = input.point;
if (s.phase !== "corner2") {
const ns: RectDrawing = { phase: "corner2", a: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: rectDraft(pt, pt) }];
}
const a = s.a;
return [
{ phase: "corner1", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendRect(p, a, pt, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as RectState;
if (s.phase !== "corner2") return [s, { draft: null }];
const ns: RectDrawing = { ...s, cursor: point };
return [ns, { draft: rectDraft(s.a, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
// Tab-Feld-Zyklus nur im „corner2"-Schritt: Breite + Höhe (signiert nach Cursor).
fields: (state) => ((state as RectState).phase === "corner2" ? RECT_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as RectState;
if (s.phase !== "corner2") return null;
return rectCornerFromFields(s.a, locks, cursor);
},
};
+60
View File
@@ -0,0 +1,60 @@
// Terrain — erzeugt aus dem (ersten) ContourSet der Kontext-Schicht ein
// Gelände-TIN und hängt es an `project.context`. Null-Schritt-Befehl: er
// committet sofort beim Start (über onConfirm, das die Engine direkt aufruft,
// wenn keine Punkte erwartet werden) — wir lösen das robust über onInput/
// onConfirm, die beide dieselbe Mutation liefern.
//
// Die Mutation ist rein (kein Store/React): sie sucht den ersten ContourSet in
// p.context, ruft generateTerrainFromContours und fügt das TIN hinzu. Liefert
// das TIN kein sinnvolles Mesh (zu wenige/kollineare Punkte), bleibt p
// unverändert.
//
// Bezeichner englisch, sichtbarer Text via t() (Namespace `cmd.terrain.*`).
import { generateTerrainFromContours } from "../../model/terrain";
import type { ContextObject } from "../../model/types";
import type {
Command,
CommandResult,
CommandState,
Project,
} from "../types";
/** Hängt ein aus dem ersten ContourSet abgeleitetes TIN an (immutabel). */
function appendTerrain(p: Project): Project {
const context: ContextObject[] = p.context ?? [];
const set = context.find((o) => o.type === "contourSet");
if (!set || set.type !== "contourSet") return p;
const terrain = generateTerrainFromContours(set.contours);
if (terrain.indices.length === 0) return p; // kein sinnvolles TIN
return { ...p, context: [...context, terrain] };
}
/** Ergebnis, das den Befehl sofort beendet und das TIN committet. */
const COMMIT_DONE: CommandResult = {
draft: null,
done: true,
commit: appendTerrain,
};
const IDLE: CommandState = { phase: "idle", lastPoint: null };
export const terrainCommand: Command = {
name: "terrain",
labelKey: "cmd.terrain.label",
prompt: () => "cmd.terrain.prompt",
// Erwartet keine echten Eingaben; Enter/Start committet sofort.
accepts: () => [],
options: () => [],
init: () => ({ ...IDLE }),
// Jeglicher Input beendet den Befehl mit dem Commit (es gibt keine Schritte).
onInput: (): [CommandState, CommandResult] => [{ ...IDLE }, COMMIT_DONE],
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
// Enter/Space/Rechtsklick: Gelände erzeugen + beenden.
onConfirm: (): [CommandState, CommandResult] => [{ ...IDLE }, COMMIT_DONE],
onCancel: (): [CommandState, CommandResult] => [
{ ...IDLE },
{ draft: null, done: true },
],
};
+439
View File
@@ -0,0 +1,439 @@
// Command-Runner / State-Machine (docs/design/rhino-command-system.md §3.2, §2.3,
// §2.6). Hält den aktiven Befehl + Schritt-Zustand, einen Prompt-Stack-Grundbau
// (für künftige transparente Befehle §2.6) und `lastCommand` (Leer-Enter bzw.
// Rechtsklick wiederholt den letzten Befehl §2.3).
//
// Eingaben kommen aus DREI Quellen, die alle in DENSELBEN Schritt fließen:
// • getippte Command-Line-Eingabe (submitText)
// • Maus-Pick aus PlanView (pick) / Hover (move)
// • Klick auf eine Inline-Option (chooseOption)
//
// Die Engine ist UI-agnostisch: Kontext (Projekt/Level/Kategorie), Snapping und
// das Anwenden von draft/commit liefert ein injizierter `EngineHost`. Bei jeder
// Zustandsänderung ruft sie `emit()` → React-Komponenten (CommandLine, App)
// abonnieren via subscribe() und re-rendern.
import type { Project, Vec2 } from "../model/types";
import type { SnapResult, ToolDraft } from "../tools/types";
import { parseInput, resolvePoint } from "./parseInput";
import { getCommand, resolveCommand, autocomplete } from "./registry";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
CmdInput,
} from "./types";
/**
* Brücke zur App: liefert den Live-Kontext + Snapping und wendet die Ergebnisse
* an (Vorschau zeigen, committen). So bleibt die Engine frei von React/Store.
*/
export interface EngineHost {
/** Aktueller Befehls-Kontext (Projekt, aktive Ebene/Kategorie, lastPoint). */
context(lastPoint: Vec2 | null): CommandContext;
/** Snap für einen rohen Cursor-Punkt (oder null), inkl. der Draft-Punkte. */
snap(raw: Vec2, draftPoints: Vec2[]): SnapResult | null;
/** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz). */
pxPerMeter(): number;
/** Vorschau setzen (oder löschen). */
setDraft(draft: ToolDraft | null): void;
/** Commit immutabel anwenden. */
commit(mutate: (p: Project) => Project): void;
/** Ist die aktive Ebene ein Geschoss? (für floorOnly-Befehle). */
isFloor(): boolean;
}
/** Öffentlicher Schnappschuss für die Command-Line-UI (rein lesend). */
export interface EngineView {
/** Läuft gerade ein Befehl? */
active: boolean;
/** Aktueller Prompt-Key (i18n) oder null (Ruhezustand). */
promptKey: string | null;
/** Inline-Optionen des aktuellen Schritts. */
options: { id: string; labelKey: string; value?: string }[];
/** Name des aktiven Befehls (für Anzeige), null im Ruhezustand. */
commandName: string | null;
/**
* Geordnete Zahlenfelder des aktuellen Schritts (Tab-Feld-Zyklus §2.7). Leer,
* wenn der aktive Befehl/Schritt keine Felder anbietet. `value` ist der
* gelockte Wert (oder null = folgt der Maus), `active` markiert das aktuelle
* Tab-Ziel.
*/
fields: { id: string; labelKey: string; value: number | null; active: boolean }[];
}
export class CommandEngine {
private host: EngineHost;
private command: Command | null = null;
private state: CommandState | null = null;
private lastCommandName: string | null = null;
/** Letzter (gesnappter) Cursor-Punkt für Distanz-/Winkel-Lock-Auflösung. */
private lastCursor: Vec2 | null = null;
/** Tab-Feld-Zyklus: aktiver Feld-Index des aktuellen Schritts (§2.7). */
private activeFieldIndex = 0;
/** Tab-Feld-Zyklus: gelockte Feldwerte (id → Zahl); ungelockt folgt der Maus. */
private locks: Record<string, number> = {};
/** Identität des Schritts, für den der Feld-Zustand gilt (Reset bei Wechsel). */
private fieldPhaseKey: string | null = null;
private listeners = new Set<() => void>();
constructor(host: EngineHost) {
this.host = host;
}
// ── Abo / Snapshot ─────────────────────────────────────────────────────────
subscribe(fn: () => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private emit(): void {
for (const l of this.listeners) l();
}
/** Läuft gerade ein Befehl? */
isActive(): boolean {
return this.command !== null;
}
/** Lesender Schnappschuss für die Command-Line. */
view(): EngineView {
if (!this.command || !this.state) {
return { active: false, promptKey: null, options: [], commandName: null, fields: [] };
}
this.syncFields();
const defs = this.currentFields();
const fields = defs.map((f, i) => ({
id: f.id,
labelKey: f.labelKey as string,
value: f.id in this.locks ? this.locks[f.id] : null,
active: i === this.activeFieldIndex,
}));
return {
active: true,
promptKey: this.command.prompt(this.state),
options: this.command.options(this.state),
commandName: this.command.name,
fields,
};
}
/** Autocomplete-Kandidaten für die getippte Eingabe (Präfix). */
suggest(prefix: string): string[] {
return autocomplete(prefix);
}
// ── Tab-Feld-Zyklus (§2.7) ────────────────────────────────────────────────
/** Feld-Definitionen des aktuellen Schritts (leer, wenn keine angeboten). */
private currentFields(): { id: string; labelKey: string }[] {
if (!this.command || !this.state || !this.command.fields) return [];
return this.command.fields(this.state);
}
/** Hat der aktuelle Schritt einen Tab-Feld-Modus? */
hasFields(): boolean {
return this.currentFields().length > 0;
}
/**
* Setzt den Feld-Zustand zurück, wenn der Schritt wechselt — Signatur =
* phase + Feld-IDs + lastPoint. Der lastPoint-Teil sorgt dafür, dass ein
* mehrteiliger Befehl (Polyline) je gesetztem Punkt FRISCHE Felder/Locks
* bekommt, obwohl die phase ("next") gleich bleibt.
*/
private syncFields(): void {
const defs = this.currentFields();
const lp = this.state?.lastPoint;
const lpKey = lp ? `${lp.x.toFixed(6)},${lp.y.toFixed(6)}` : "∅";
const key = this.state
? `${this.state.phase}:${defs.map((f) => f.id).join(",")}:${lpKey}`
: null;
if (key !== this.fieldPhaseKey) {
this.fieldPhaseKey = key;
this.activeFieldIndex = 0;
this.locks = {};
}
}
/** Tab: nächstes Feld aktiv (zyklisch). No-op ohne Felder. */
cycleField(): void {
this.syncFields();
const n = this.currentFields().length;
if (n === 0) return;
this.activeFieldIndex = (this.activeFieldIndex + 1) % n;
this.emit();
}
/**
* Lockt einen getippten Zahlenwert ins aktive Feld und rückt aufs nächste vor
* (§2.7, Task-Schritt 2). Sind danach ALLE Felder gelockt, committet der Schritt
* sofort den daraus gebildeten Punkt — so commitet ein Ein-Feld-Befehl (Kreis:
* Radius) direkt und ein Zwei-Feld-Befehl (Linie) nach dem zweiten Wert.
*/
private lockActiveField(value: number): void {
this.syncFields();
const defs = this.currentFields();
if (defs.length === 0) return;
const field = defs[this.activeFieldIndex];
this.locks = { ...this.locks, [field.id]: value };
const allLocked = defs.every((f) => f.id in this.locks);
if (allLocked) {
this.commitFields();
return;
}
this.activeFieldIndex = (this.activeFieldIndex + 1) % defs.length;
this.refreshFieldPreview();
this.emit();
}
/** Committet den aus den Feld-Locks (+ Cursor) gebildeten Punkt als Schritt-Eingabe. */
private commitFields(): void {
const pt = this.fieldResultPoint(this.lastCursor);
if (pt) this.feed({ kind: "point", point: pt });
else this.emit();
}
/** Vorschau aus den gelockten Feldern + dem letzten Cursor neu zeichnen. */
private refreshFieldPreview(): void {
if (!this.command || !this.state || !this.command.pointFromFields) return;
const pt = this.command.pointFromFields(this.state, this.locks, this.lastCursor);
if (!pt) return;
const ctx = this.context();
const [st, res] = this.command.onMove(this.state, pt, null, ctx);
this.state = st;
if (res.draft) this.host.setDraft(res.draft);
}
// ── Befehls-Lebenszyklus ─────────────────────────────────────────────────
/** Startet einen Befehl per Name/Alias/Präfix. Liefert true bei Erfolg. */
start(nameOrPrefix: string): boolean {
const cmd = resolveCommand(nameOrPrefix) ?? getCommand(nameOrPrefix);
if (!cmd) return false;
return this.startCommand(cmd);
}
/** Startet einen konkreten Befehl (z. B. zur Wiederholung). */
startCommand(cmd: Command): boolean {
if (cmd.floorOnly && !this.host.isFloor()) return false;
this.command = cmd;
this.state = cmd.init();
this.lastCommandName = cmd.name;
this.lastCursor = null;
this.resetFields();
this.host.setDraft(null);
this.emit();
return true;
}
/** Tab-Feld-Zustand vollständig zurücksetzen (Befehlsstart/-ende/Abbruch). */
private resetFields(): void {
this.activeFieldIndex = 0;
this.locks = {};
this.fieldPhaseKey = null;
}
/** Bricht den laufenden Befehl ab (Esc). */
cancel(): void {
if (this.command && this.state) {
const [, res] = this.command.onCancel(this.state);
this.applyResult(res);
}
this.command = null;
this.state = null;
this.resetFields();
this.host.setDraft(null);
this.emit();
}
// ── Eingabe-Routing ────────────────────────────────────────────────────────
/**
* Getippte Command-Line-Eingabe (Enter). Im Ruhezustand: Befehl starten / bei
* leerer Zeile letzten wiederholen (§2.3). In einem Befehl: als Koordinate/
* Zahl/Option in den aktuellen Schritt routen.
*/
submitText(raw: string): void {
const text = raw.trim();
if (!this.command || !this.state) {
// Ruhezustand: leere Zeile = letzten Befehl wiederholen.
if (text === "") {
this.repeatLast();
return;
}
this.start(text);
return;
}
// Laufender Befehl: leere Zeile = bestätigen/beenden (Enter §2.3).
if (text === "") {
this.confirm();
return;
}
this.routeTypedInput(text);
}
/** Routet getippten Text in den aktuellen Schritt (Koordinate/Zahl/Option). */
private routeTypedInput(text: string): void {
if (!this.command || !this.state) return;
const accepts = this.command.accepts(this.state);
// Optionsbuchstabe(n)? (nur wenn der Schritt Optionen annimmt) — Präfix-Match.
if (accepts.includes("option")) {
const opts = this.command.options(this.state);
const q = text.toLowerCase();
const hit = opts.find((o) => o.id.toLowerCase().startsWith(q));
if (hit) {
this.feed({ kind: "option", id: hit.id });
return;
}
}
const parsed = parseInput(text);
// Tab-Feld-Modus (§2.7): eine nackte Zahl LOCKT das aktive Feld (statt als
// distance/number durchzureichen) und rückt aufs nächste Feld vor. Absolute/
// relative/polare Koordinaten umgehen die Felder (committen wie gewohnt).
if (parsed.kind === "distance" && this.hasFields()) {
this.lockActiveField(parsed.value);
return;
}
const lastPoint = this.state.lastPoint;
const cursor = this.lastCursor;
if (parsed.kind === "text") {
// Unparsbar im Schritt: ignorieren (kein gültiger Punkt/Option).
return;
}
// Punktbezogene Eingaben → zu Modellpunkt auflösen, wenn der Schritt Punkte
// annimmt; sonst nackte Zahl als number-Input.
if (parsed.kind === "distance" && accepts.includes("number") && !accepts.includes("point")) {
this.feed({ kind: "number", value: parsed.value });
return;
}
const pt = resolvePoint(parsed, lastPoint, cursor);
if (pt && accepts.includes("point")) {
this.feed({ kind: "point", point: pt });
return;
}
if (parsed.kind === "distance" && accepts.includes("number")) {
this.feed({ kind: "number", value: parsed.value });
}
}
/** Maus-Pick aus PlanView (linker Klick) — gesnappter Modellpunkt. */
pick(raw: Vec2): void {
if (!this.command || !this.state) return;
if (!this.command.accepts(this.state).includes("point")) return;
const snap = this.host.snap(raw, this.draftPoints());
const point = snap?.point ?? raw;
this.lastCursor = point;
// Tab-Feld-Modus: gelockte Felder überschreiben den Klick (z. B. Länge fix,
// Richtung per Maus). Der finale Punkt kommt aus pointFromFields.
const fieldPt = this.fieldResultPoint(point);
if (fieldPt) {
this.feed({ kind: "point", point: fieldPt });
return;
}
this.feed({ kind: "point", point, snap });
}
/** Hover/Drag aus PlanView — nur Vorschau (kein Commit). */
move(raw: Vec2): void {
if (!this.command || !this.state) return;
const snap = this.host.snap(raw, this.draftPoints());
const point = snap?.point ?? raw;
this.lastCursor = point;
const ctx = this.context();
// Tab-Feld-Modus: ungelockte Felder folgen der Maus, gelockte überschreiben.
const previewPoint = this.fieldResultPoint(point) ?? point;
const [st, res] = this.command.onMove(this.state, previewPoint, snap, ctx);
this.state = st;
// Snap-Marker mit in den Draft hängen.
if (res.draft) this.host.setDraft({ ...res.draft, snap });
else if (snap) this.host.setDraft({ preview: [], vertices: [], snap });
else this.host.setDraft(null);
}
/**
* Punkt aus dem Tab-Feld-Modus für einen gegebenen Cursor — oder null, wenn der
* Schritt keine Felder hat (dann gilt der rohe Cursor/Pick).
*/
private fieldResultPoint(cursor: Vec2 | null): Vec2 | null {
if (!this.command || !this.state || !this.command.pointFromFields) return null;
if (!this.hasFields()) return null;
return this.command.pointFromFields(this.state, this.locks, cursor);
}
/** Klick auf eine Inline-Option (§2.4). */
chooseOption(id: string): void {
if (!this.command || !this.state) return;
this.feed({ kind: "option", id });
}
/** Enter = Space = Rechtsklick: bestätigen/Default/mehrteilig-beenden (§2.3). */
confirm(): void {
if (!this.command || !this.state) {
this.repeatLast();
return;
}
// Tab-Feld-Modus (§2.7): existieren Felder, committet Enter den Schritt mit
// dem aus (Locks + Cursor) gebildeten Punkt — statt den Befehl abzubrechen.
const fieldPt = this.fieldResultPoint(this.lastCursor);
if (fieldPt) {
this.feed({ kind: "point", point: fieldPt });
return;
}
const ctx = this.context();
const [st, res] = this.command.onConfirm(this.state, ctx);
this.state = st;
this.applyResult(res);
}
/** Leer-Enter/Rechtsklick im Ruhezustand: letzten Befehl wiederholen. */
repeatLast(): void {
if (this.lastCommandName) {
const cmd = getCommand(this.lastCommandName);
if (cmd) this.startCommand(cmd);
}
}
// ── interne Mechanik ───────────────────────────────────────────────────────
private feed(input: CmdInput): void {
if (!this.command || !this.state) return;
if (input.kind === "point") this.lastCursor = input.point;
const ctx = this.context();
const [st, res] = this.command.onInput(this.state, input, ctx);
this.state = st;
this.applyResult(res);
}
private applyResult(res: CommandResult): void {
if (res.commit) this.host.commit(res.commit);
if (res.draft) this.host.setDraft(res.draft);
else this.host.setDraft(null);
if (res.done) {
this.command = null;
this.state = null;
this.resetFields();
}
this.emit();
}
private context(): CommandContext {
return this.host.context(this.state?.lastPoint ?? null);
}
/** Bereits gesetzte Punkte des laufenden Befehls (für Snap-Quellen). */
private draftPoints(): Vec2[] {
const s = this.state as (CommandState & { a?: Vec2; points?: Vec2[] }) | null;
if (!s) return [];
if (s.points) return s.points;
if (s.a) return [s.a];
if (s.lastPoint) return [s.lastPoint];
return [];
}
}
+166
View File
@@ -0,0 +1,166 @@
// Koordinaten- & Numerik-Parser (docs/design/rhino-command-system.md §2.7).
//
// Parst rohen Command-Line-Text TOLERANT (Komma/Space/Semikolon als Trenner) zu
// einer Discriminated Union; die Engine löst die punktbezogenen Varianten mit
// `lastPoint` + Cursor-Richtung zu einem konkreten Modellpunkt auf.
//
// Unterstützte Formen:
// `5,3` → absolut X,Y
// `5,3,2` → absolut X,Y (Z ignoriert, 2D)
// `r5,3` → relativ zum letzten Punkt
// `5<45` → polar: Distanz 5 unter 45° vom letzten Punkt
// `<45` → nur Winkel-Constraint; Distanz folgt Maus/Zahl
// `5` → nackte Zahl = Distanz-Lock entlang Cursor-/Constraint-Richtung
// sonst → Roh-Text (Befehlsname / Optionsbuchstabe) — von der Engine geroutet
//
// Reine Funktionen, kein Zustand. Winkel in Grad, gegen die +X-Achse, CCW positiv
// (passend zur Y-flip-freien Modellkonvention: +Y ist „oben").
import type { Vec2 } from "../model/types";
/** Geparste, noch nicht aufgelöste Eingabe. */
export type ParsedInput =
| { kind: "absolute"; point: Vec2 } // 5,3
| { kind: "relative"; delta: Vec2 } // r5,3
| { kind: "polar"; dist: number; angleDeg: number } // 5<45
| { kind: "angle"; angleDeg: number } // <45
| { kind: "distance"; value: number } // 5 (nackte Zahl)
| { kind: "text"; text: string }; // Befehlsname / Option / unparsbar
const DEG = Math.PI / 180;
/** Tolerant: Zahl aus einem Token (erlaubt führendes +, Dezimalpunkt/-komma). */
function parseNumber(raw: string): number | null {
const s = raw.trim().replace(",", "."); // Hilfsfall: einzelnes Dezimalkomma
if (s === "" || s === "+" || s === "-") return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
/**
* Zerlegt eine Koordinatenliste (X,Y[,Z]) tolerant in Zahlen. Trenner: Komma,
* Semikolon oder Whitespace. Liefert null, wenn nicht ≥2 valide Zahlen vorliegen.
* (Wir nutzen die echten Trenner, daher kollidiert das NICHT mit dem
* Dezimalkomma-Hilfsfall der nackten Zahl.)
*/
function parseCoordList(raw: string): number[] | null {
const parts = raw
.trim()
.split(/[\s,;]+/)
.filter((p) => p.length > 0);
if (parts.length < 2) return null;
const nums: number[] = [];
for (const p of parts) {
const n = Number(p.trim());
if (!Number.isFinite(n)) return null;
nums.push(n);
}
return nums;
}
/**
* Parst rohen Command-Line-Text zu einer {@link ParsedInput}. Befehlsnamen/
* Optionsbuchstaben fallen als `{kind:"text"}` durch (die Engine routet sie).
*/
export function parseInput(raw: string): ParsedInput {
const s = raw.trim();
if (s === "") return { kind: "text", text: "" };
// ── polar / Winkel: enthält '<' ───────────────────────────────────────────
if (s.includes("<")) {
const i = s.indexOf("<");
const left = s.slice(0, i).trim();
const right = s.slice(i + 1).trim();
const angle = parseNumber(right);
if (angle == null) return { kind: "text", text: s };
if (left === "") return { kind: "angle", angleDeg: angle };
const dist = parseNumber(left);
if (dist == null) return { kind: "text", text: s };
return { kind: "polar", dist, angleDeg: angle };
}
// ── relativ: führendes r / @ ──────────────────────────────────────────────
if (/^[r@]/i.test(s)) {
const rest = s.slice(1);
const nums = parseCoordList(rest);
if (nums && nums.length >= 2) {
return { kind: "relative", delta: { x: nums[0], y: nums[1] } };
}
// `r5` → relative Distanz entlang Cursor-Richtung.
const one = parseNumber(rest);
if (one != null) return { kind: "distance", value: one };
return { kind: "text", text: s };
}
// ── absolute Koordinate: ≥2 zahlen mit echtem Trenner ─────────────────────
const coords = parseCoordList(s);
if (coords && coords.length >= 2) {
return { kind: "absolute", point: { x: coords[0], y: coords[1] } };
}
// ── nackte Zahl = Distanz-Lock ────────────────────────────────────────────
const one = parseNumber(s);
if (one != null) return { kind: "distance", value: one };
// ── sonst: Text (Befehl/Option) ───────────────────────────────────────────
return { kind: "text", text: s };
}
/** Punkt aus polarer Form (Distanz/Winkel) relativ zu einem Basispunkt. */
export function polarPoint(base: Vec2, dist: number, angleDeg: number): Vec2 {
const a = angleDeg * DEG;
return { x: base.x + Math.cos(a) * dist, y: base.y + Math.sin(a) * dist };
}
/**
* Löst eine {@link ParsedInput} zu einem konkreten Modellpunkt auf — falls sie
* sich als Punkt auflösen lässt. `lastPoint` ist der letzte gesetzte Punkt im
* Befehl (für relativ/polar/Distanz), `cursorDir` die aktuelle (gesnappte)
* Cursor-Richtung als Roh-Cursorpunkt (für nackte Zahl / reinen Winkel-Lock).
*
* Liefert null, wenn die Eingabe keinen Punkt ergibt (z. B. reiner Text oder
* Distanz ohne Bezug).
*/
export function resolvePoint(
parsed: ParsedInput,
lastPoint: Vec2 | null,
cursor: Vec2 | null,
): Vec2 | null {
switch (parsed.kind) {
case "absolute":
return parsed.point;
case "relative":
if (!lastPoint) return parsed.delta; // ohne Bezug = vom Ursprung
return { x: lastPoint.x + parsed.delta.x, y: lastPoint.y + parsed.delta.y };
case "polar": {
const base = lastPoint ?? { x: 0, y: 0 };
return polarPoint(base, parsed.dist, parsed.angleDeg);
}
case "angle": {
// Reiner Winkel-Lock: ohne Distanz braucht es den Cursor, um die Länge
// entlang der gelockten Richtung zu projizieren.
if (!lastPoint || !cursor) return null;
const a = parsed.angleDeg * DEG;
const dir = { x: Math.cos(a), y: Math.sin(a) };
const dx = cursor.x - lastPoint.x;
const dy = cursor.y - lastPoint.y;
const proj = dx * dir.x + dy * dir.y; // skalare Projektion
const len = Math.max(0, proj);
return { x: lastPoint.x + dir.x * len, y: lastPoint.y + dir.y * len };
}
case "distance": {
// Distanz-Lock: Richtung per Cursor, Länge per Zahl.
if (!lastPoint || !cursor) return null;
const dx = cursor.x - lastPoint.x;
const dy = cursor.y - lastPoint.y;
const l = Math.hypot(dx, dy);
if (l < 1e-9) return { x: lastPoint.x + parsed.value, y: lastPoint.y };
return {
x: lastPoint.x + (dx / l) * parsed.value,
y: lastPoint.y + (dy / l) * parsed.value,
};
}
default:
return null;
}
}
+79
View File
@@ -0,0 +1,79 @@
// Befehls-Registry (docs/design/rhino-command-system.md §3.2) — `COMMANDS` +
// Aliase + Präfix-Autocomplete. Einzelne Befehle leben als eigene Module unter
// `cmds/`, sodass weitere Befehle ADDITIV (ohne Änderung an Engine/App)
// dazukommen: hier importieren + registrieren, fertig.
import type { Command } from "./types";
import { lineCommand } from "./cmds/line";
import { polylineCommand } from "./cmds/polyline";
import { rectCommand } from "./cmds/rect";
import { circleCommand } from "./cmds/circle";
import { moveCommand } from "./cmds/move";
import { copyCommand } from "./cmds/copy";
import { offsetCommand } from "./cmds/offset";
import { importCommand } from "./cmds/import";
import { terrainCommand } from "./cmds/terrain";
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
export const COMMANDS: Record<string, Command> = {
line: lineCommand,
polyline: polylineCommand,
rect: rectCommand,
circle: circleCommand,
move: moveCommand,
copy: copyCommand,
offset: offsetCommand,
import: importCommand,
terrain: terrainCommand,
};
/**
* Aliase / Kürzel → Befehlsname (§2.2). Werden VOR dem Autocomplete gematcht
* (case-insensitiv). Minimal: Einzelbuchstaben. NICHT `r` (kollidiert mit dem
* relativ-Koordinaten-Präfix `r5,3`).
*/
export const ALIASES: Record<string, string> = {
l: "line",
pl: "polyline",
rec: "rect",
c: "circle",
m: "move",
cp: "copy",
o: "offset",
imp: "import",
ter: "terrain",
};
/** Liefert den Befehl zu einem exakten Namen (oder undefined). */
export function getCommand(name: string): Command | undefined {
return COMMANDS[name.toLowerCase()];
}
/**
* Löst eine getippte Eingabe zu einem Befehl auf: erst exakter Name, dann Alias,
* dann eindeutiges Präfix (case-insensitiv). Mehrdeutige/leere Präfixe → null.
*/
export function resolveCommand(raw: string): Command | null {
const q = raw.trim().toLowerCase();
if (q === "") return null;
if (COMMANDS[q]) return COMMANDS[q];
if (ALIASES[q]) return COMMANDS[ALIASES[q]] ?? null;
const matches = autocomplete(q);
if (matches.length === 1) return COMMANDS[matches[0]] ?? null;
// Exaktes Präfix, das auch ein Alias ist, hat Vorrang, wenn eindeutig.
return null;
}
/**
* Präfix-Autocomplete: alle Befehlsnamen, die mit dem (lowercase) Präfix
* beginnen, alphabetisch. Aliase werden NICHT mitgelistet (sie sind Kürzel,
* keine Vorschläge), aber ein exakter Alias-Treffer wird vorangestellt.
*/
export function autocomplete(prefix: string): string[] {
const q = prefix.trim().toLowerCase();
if (q === "") return Object.keys(COMMANDS).sort();
const names = Object.keys(COMMANDS)
.filter((n) => n.startsWith(q))
.sort();
return names;
}
+183
View File
@@ -0,0 +1,183 @@
// Command-Engine — Verallgemeinerung des `Tool`-Interfaces (src/tools/types.ts)
// zu einem Rhino-artigen Befehlsmodell (docs/design/rhino-command-system.md §3.1).
//
// Ein `Command` ist eine kleine State-Machine: jeder Schritt zeigt einen Prompt,
// nimmt bestimmte Eingabe-Arten an (Punkt | Zahl | Option | Auswahl) und liefert
// aus (state, input) den nächsten Zustand + ein `CommandResult` (Vorschau +
// optional commit/done). Damit speisen Maus-Picks UND getippte Koordinaten/
// Optionen DENSELBEN Schritt — es gibt nur einen Eingabepfad.
//
// Bezeichner englisch, sichtbarer Text via t() (i18n-Keys, Namespace `cmd.*`).
import type { Project, Vec2 } from "../model/types";
import type { TranslationKey } from "../i18n";
import type { DrawingLevel } from "../model/types";
import type { SnapResult, ToolDraft } from "../tools/types";
export type { DraftShape, SnapResult, ToolDraft } from "../tools/types";
// ── Eingabe-Arten ────────────────────────────────────────────────────────────
/** Arten von Eingaben, die ein Schritt annehmen kann. */
export type AcceptKind = "point" | "number" | "option" | "selection";
/**
* Geparste, noch nicht aufgelöste Roh-Eingabe aus der Command-Line bzw. der
* Maus. Discriminated Union (§2.7). Die Engine löst `point`-bezogene Varianten
* mit `lastPoint` + Cursor-Richtung zu einem konkreten Modellpunkt auf, bevor
* sie den Schritt aufruft.
*/
export type CmdInput =
| { kind: "point"; point: Vec2; snap?: SnapResult | null }
| { kind: "number"; value: number }
| { kind: "option"; id: string }
| { kind: "text"; text: string }
| { kind: "selection"; wallIds: string[]; drawingIds: string[] };
// ── Optionen (klickbare Inline-Klammern, §2.4) ───────────────────────────────
/**
* Eine Inline-Option eines Schritts. `value` (gesetzt) → wird als „Name=Value"
* angezeigt und ist togglebar/abfragbar; ohne `value` ist es eine Action-Option
* (verzweigt sofort). `id` ist der englische Schlüssel (auch zum Tippen, Präfix).
*/
export interface CmdOption {
id: string;
/** Sichtbares Label (i18n). */
labelKey: TranslationKey;
/** Aktueller Wert (Toggle/Value-Option); fehlt → Action-Option. */
value?: string;
}
// ── Kontext + Ergebnis ───────────────────────────────────────────────────────
/**
* Die aktuelle Auswahl (Rhino: erst selektieren, dann Befehl). Editierbefehle
* (Move/Copy/Offset) lesen sie hieraus, statt sie selbst zu picken. `wallIds`
* sind die gewählten Wände, `drawingId` ist das gewählte 2D-Element (sich
* gegenseitig ausschließend, wie in selectionSlice).
*/
export interface CommandSelection {
wallIds: string[];
drawingId: string | null;
}
/** Live-Kontext, den ein Befehl bei jedem Schritt erhält (analog ToolContext). */
export interface CommandContext {
project: Project;
/** Aktive Zeichnungsebene (Ziel neuer Elemente). */
level: DrawingLevel;
/** Default-Kategorie-Code für neue Elemente. */
defaultCategoryCode: string;
/** Aktiver Linienstil-Code für 2D-Primitive. */
activeLineStyleId: string;
/**
* Letzter gesetzter Punkt im laufenden Befehl (für `r`/polar-Auflösung); null
* am ersten Punkt.
*/
lastPoint: Vec2 | null;
/**
* Vorab gesetzte Auswahl (Wände + ein 2D-Element). Editierbefehle operieren
* darauf (Rhino: erst selektieren, dann Befehl). Beim Befehlsstart aus dem
* Store gefüllt.
*/
selection: CommandSelection;
}
/** Was ein Befehls-Schritt nach außen meldet (analog ToolResult + commit/done). */
export interface CommandResult {
/** Neuer Vorschau-Zustand; null = nichts zu zeigen. */
draft: ToolDraft | null;
/** Bei Abschluss: immutable Mutation, die die Engine über setProject anwendet. */
commit?: (p: Project) => Project;
/** true → Befehl ist fertig und kehrt in den Ruhezustand zurück. */
done?: boolean;
}
// ── Befehls-Zustand ──────────────────────────────────────────────────────────
/**
* Interner Zustand eines laufenden Befehls. Basis-Form (`phase` + `lastPoint`);
* jeder Befehl verfeinert sie zu seiner eigenen Discriminated Union und castet
* beim Eintritt. `lastPoint` lebt im Zustand, damit der Parser `r`/polar relativ
* dazu auflösen kann.
*/
export interface CommandState {
phase: string;
/** Letzter gesetzter Punkt (für relative/polare Eingabe), null am Anfang. */
lastPoint: Vec2 | null;
}
// ── Tab-Feld-Zyklus (Präzisionseingabe §2.7, erweitert) ──────────────────────
/**
* Ein geordnetes Zahlenfeld eines Schritts (z. B. Länge, Winkel, Breite, Höhe,
* Radius). Mit Tab zyklt der Nutzer durch die Felder; eine getippte Zahl LOCKT
* das aktive Feld, ungelockte Felder folgen weiter der Maus.
*/
export interface CommandField {
/** Englischer Schlüssel (Identität des Feldes, z. B. „length"). */
id: string;
/** Sichtbares Label (i18n, Namespace `cmd.field.*`). */
labelKey: TranslationKey;
}
// ── Befehls-Schnittstelle ────────────────────────────────────────────────────
/** Die Command-Schnittstelle (reine Funktionen über einen internen State). */
export interface Command {
/** Befehlsname (englisch, in der Command-Line getippt), z. B. „line". */
name: string;
/** UI-Label-Key (i18n). */
labelKey: TranslationKey;
/** Prompt-Text-Key des aktuellen Schritts. */
prompt(state: CommandState): TranslationKey;
/** Eingabe-Arten, die der aktuelle Schritt annimmt. */
accepts(state: CommandState): AcceptKind[];
/** Inline-Optionen des aktuellen Schritts (klickbar/tippbar). */
options(state: CommandState): CmdOption[];
/** Nur auf Geschossen aktiv? (analog floorOnly bei Tools.) */
floorOnly?: boolean;
/** Initialer Schritt-Zustand. */
init(): CommandState;
/** Eine Eingabe (Punkt/Zahl/Option/Auswahl) verarbeiten. */
onInput(
state: CommandState,
input: CmdInput,
ctx: CommandContext,
): [CommandState, CommandResult];
/** Hover/Bewegung (roher bzw. gesnappter Cursor): nur Vorschau. */
onMove(
state: CommandState,
point: Vec2,
snap: SnapResult | null,
ctx: CommandContext,
): [CommandState, CommandResult];
/** Enter/Space/Rechtsklick: Schritt bestätigen / mehrteilig beenden. */
onConfirm(state: CommandState, ctx: CommandContext): [CommandState, CommandResult];
/** Esc: Entwurf verwerfen, Befehl beenden. */
onCancel(state: CommandState): [CommandState, CommandResult];
// ── Optionaler Tab-Feld-Zyklus (additiv; Befehle ohne diese Methoden
// verhalten sich exakt wie bisher) ──────────────────────────────────────
/**
* Geordnete Zahlenfelder des AKTUELLEN Schritts (leer/undefined = kein
* Feld-Modus). Beispiel Linie-Endschritt: [length, angle].
*/
fields?(state: CommandState): CommandField[];
/**
* Berechnet den resultierenden Modellpunkt aus den gelockten Feldern + dem
* Cursor. Ungelockte Felder folgen dem Cursor (Maus); gelockte Felder
* überschreiben. Liefert null, wenn (noch) kein Punkt bestimmbar ist.
*/
pointFromFields?(
state: CommandState,
locks: Record<string, number>,
cursor: Vec2 | null,
): Vec2 | null;
}
/** Convenience-Re-Export für Befehls-Module. */
export type { Project, Vec2 } from "../model/types";
+277
View File
@@ -0,0 +1,277 @@
// Editier-Operationen Split / Join / Segment-Löschen für 2D-Zeichenelemente
// (Drawing2D). Wandeln Drawing2D ↔ {pts, closed}, rufen den reinen Geometrie-
// Kernel (`../geometry/kernel2d`) und liefern neue Drawing2D-Listen. Stil-,
// Kategorie- und Level-Attribute der Quelle werden geerbt.
//
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
import type { Drawing2D, Drawing2DGeom, Vec2 } from "../model/types";
import {
joinChains,
removeSegment,
splitAtIntersections,
splitClosedByChord,
splitPolylineAtParam,
vecEqual,
} from "../geometry/kernel2d";
/** Eine Polylinien-Darstellung eines Drawing2D (für die Kernel-Funktionen). */
export interface PolyForm {
pts: Vec2[];
closed: boolean;
}
/**
* Wandelt die Geometrie eines Drawing2D in eine Polylinien-Form `{pts, closed}`.
* Nur line/polyline/rect werden unterstützt (circle/arc/text → `null`).
*/
export function toPolyForm(geom: Drawing2DGeom): PolyForm | null {
switch (geom.shape) {
case "line":
return { pts: [geom.a, geom.b], closed: false };
case "polyline":
return { pts: geom.pts.slice(), closed: geom.closed };
case "rect":
return {
pts: [
geom.min,
{ x: geom.max.x, y: geom.min.y },
geom.max,
{ x: geom.min.x, y: geom.max.y },
],
closed: true,
};
default:
return null;
}
}
/**
* Baut aus einer Polylinien-Form die passende Drawing2D-Geometrie. Zwei Punkte +
* offen → `line`; sonst `polyline` (geschlossen oder offen). Rechtecke werden
* NICHT rekonstruiert (nach dem Schneiden sind die Teile beliebige Polygone).
*/
export function fromPolyForm(form: PolyForm): Drawing2DGeom {
const pts = form.pts;
if (pts.length === 2 && !form.closed) {
return { shape: "line", a: pts[0], b: pts[1] };
}
return { shape: "polyline", pts, closed: form.closed };
}
let counter = 0;
/** Eindeutige Drawing2D-ID (zeitbasiert + Zähler, damit ein Split nicht kollidiert). */
function newId(prefix: string): string {
counter += 1;
return `${prefix}-${Date.now()}-${counter}`;
}
/** Übernimmt Stil-/Kategorie-/Level-Attribute der Quelle auf ein neues Drawing2D. */
function inherit(src: Drawing2D, geom: Drawing2DGeom): Drawing2D {
const out: Drawing2D = {
id: newId("d2d"),
type: "drawing2d",
levelId: src.levelId,
categoryCode: src.categoryCode,
geom,
};
if (src.lineStyleId !== undefined) out.lineStyleId = src.lineStyleId;
if (src.color !== undefined) out.color = src.color;
if (src.weightMm !== undefined) out.weightMm = src.weightMm;
// Füllung/Schraffur nur an geschlossene Ergebnisse weitergeben.
const closed =
geom.shape === "polyline" ? geom.closed : geom.shape === "rect";
if (closed) {
if (src.hatchId !== undefined) out.hatchId = src.hatchId;
if (src.fillColor !== undefined) out.fillColor = src.fillColor;
}
return out;
}
/**
* Split eines Drawing2D gegen alle ANDEREN Elemente (`others`, als PolyForm).
* • Trifft das Ziel andere Elemente, wird an den Schnittpunkten geteilt
* (geschlossen + 2 Schnitte → zwei GESCHLOSSENE Polygone via Sehne).
* • Ohne Schnitt: Fallback — an der Kanten-MITTE der „längsten" Kante teilen
* (offen → zwei Stücke; geschlossen → an einer Kantenmitte + der gegenüber-
* liegenden Kantenmitte zu zwei geschlossenen Hälften).
* Liefert ≥2 neue Drawing2D, oder `[src]` (unverändert), wenn keine Teilung
* möglich war (z. B. nicht-polyline Form). Stil/Kategorie/Level werden geerbt.
*/
export function splitDrawing(
src: Drawing2D,
others: PolyForm[],
): Drawing2D[] {
const form = toPolyForm(src.geom);
if (!form) return [src];
const otherPts = others.map((o) =>
// Geschlossene als Ring (erster==letzter) übergeben, damit der Kernel die
// Schlusskante als Kante erkennt.
o.closed && o.pts.length > 2 ? [...o.pts, o.pts[0]] : o.pts,
);
let pieces = splitAtIntersections(form.pts, form.closed, otherPts);
// Kein Schnitt-Ergebnis (eine Liste, identisch zur Quelle) → Fallback.
const noCut = pieces.length < 2;
if (noCut) {
pieces = fallbackSplit(form);
}
if (pieces.length < 2) return [src];
// Jedes Stück: geschlossen bleibt geschlossen NUR, wenn das Original
// geschlossen war UND der Schnitt zwei geschlossene Hälften erzeugte
// (Sehnen-Split / Fallback-geschlossen). Bei offenem Aufschneiden sind die
// Stücke offen.
const piecesClosed = form.closed && isClosedSplit(form, pieces);
return pieces.map((pts) =>
inherit(src, fromPolyForm({ pts, closed: piecesClosed })),
);
}
/**
* Heuristik: erzeugte der Schnitt zwei GESCHLOSSENE Hälften (Sehnen-Split)?
* Wahr, wenn das Original geschlossen war und genau zwei Stücke entstanden
* (Chord-Split) — diese sollen wieder geschlossen sein. Bei mehr Stücken
* (geschlossen an >2 Schnitten aufgetrennt) bleiben sie offen.
*/
function isClosedSplit(form: PolyForm, pieces: Vec2[][]): boolean {
return form.closed && pieces.length === 2;
}
/**
* Fallback ohne Schnitt mit anderen Elementen: an der Mitte teilen.
* • Offen: längste Kante an t=0.5 in zwei offene Stücke.
* • Geschlossen: an den Mitten zweier „gegenüberliegender" Kanten in zwei
* geschlossene Polygone (Sehne zwischen den beiden Kantenmitten).
*/
function fallbackSplit(form: PolyForm): Vec2[][] {
const n = form.pts.length;
if (n < 2) return [form.pts];
if (!form.closed) {
// Längste Kante suchen.
let best = 0;
let bestLen = -1;
for (let i = 0; i < n - 1; i++) {
const d = dist(form.pts[i], form.pts[i + 1]);
if (d > bestLen) {
bestLen = d;
best = i;
}
}
return splitPolylineAtParam(form.pts, false, best, 0.5);
}
// Geschlossen: Kante 0 und die „gegenüberliegende" Kante.
const i = 0;
const j = Math.max(1, Math.floor(n / 2)) % n;
if (i === j) return [form.pts];
// Direkt den Kernel-Chord-Split nutzen.
const res = splitClosedHalf(form.pts, i, j);
return res ?? [form.pts];
}
/** Geschlossenes Polygon an den Mitten der Kanten i und j halbieren. */
function splitClosedHalf(pts: Vec2[], i: number, j: number): Vec2[][] | null {
// Reuse: splitClosedByChord ist im Kernel; hier über splitAtIntersections-Form
// nachzubauen wäre umständlich — direkt importieren.
const res = chordSplit(pts, i, 0.5, j, 0.5);
return res;
}
function chordSplit(
pts: Vec2[],
i: number,
ti: number,
j: number,
tj: number,
): Vec2[][] | null {
const r = splitClosedByChord(pts, i, ti, j, tj);
return r ? [r[0], r[1]] : null;
}
const dist = (a: Vec2, b: Vec2): number =>
Math.hypot(a.x - b.x, a.y - b.y);
/**
* Join: verbindet mehrere Drawing2D an koinzidenten Endpunkten zu Polylinien
* (geschlossen, wenn sich eine Kette schließt). Nicht-polyline Elemente
* (circle/arc/text) werden unverändert durchgereicht. Liefert die NEUE Menge
* der Drawing2D, die die Eingabe-Elemente ersetzt. Attribute werden vom ersten
* verbundenen Quell-Element geerbt.
*/
export function joinDrawings(srcs: Drawing2D[]): Drawing2D[] {
const joinable: { form: PolyForm; src: Drawing2D }[] = [];
const passthrough: Drawing2D[] = [];
for (const d of srcs) {
const form = toPolyForm(d.geom);
if (form) joinable.push({ form, src: d });
else passthrough.push(d);
}
if (joinable.length === 0) return srcs;
const chains = joinChains(joinable.map((j) => j.form));
const template = joinable[0].src;
const joined = chains.map((c) =>
inherit(template, fromPolyForm({ pts: c.pts, closed: c.closed })),
);
return [...joined, ...passthrough];
}
/**
* Segment-Löschen (Schere): entfernt das Segment `edgeIndex` aus einem
* Drawing2D. Geschlossene Form → offene Polylinie. Liefert das geänderte
* Drawing2D, oder `null`, wenn weniger als 2 Punkte übrig blieben (Element
* komplett löschen) bzw. die Form nicht unterstützt wird.
*/
export function cutSegment(src: Drawing2D, edgeIndex: number): Drawing2D | null {
const form = toPolyForm(src.geom);
if (!form) return src; // nicht unterstützte Form unverändert lassen
const res = removeSegment(form.pts, form.closed, edgeIndex);
if (res.pts.length < 2) return null;
return inherit(src, fromPolyForm(res));
}
/**
* Liefert den Index der Kante eines Drawing2D, die dem Modellpunkt `p` am
* nächsten liegt (für Alt+Klick = Segment-Löschen). `null`, wenn die Form keine
* Kanten hat (circle/arc/text) oder zu wenige Punkte. Reuse: Polyline-Form +
* Punkt-Strecke-Distanz.
*/
export function nearestEdgeIndex(geom: Drawing2DGeom, p: Vec2): number | null {
const form = toPolyForm(geom);
if (!form || form.pts.length < 2) return null;
const edges: [number, Vec2, Vec2][] = [];
for (let i = 0; i < form.pts.length - 1; i++) {
edges.push([i, form.pts[i], form.pts[i + 1]]);
}
if (form.closed && form.pts.length > 2) {
edges.push([form.pts.length - 1, form.pts[form.pts.length - 1], form.pts[0]]);
}
let best: number | null = null;
let bestD = Infinity;
for (const [i, a, b] of edges) {
const d = segDist(p, a, b);
if (d < bestD) {
bestD = d;
best = i;
}
}
return best;
}
/** Abstand Punkt → Strecke (lokal, klein gehalten). */
function segDist(p: Vec2, a: Vec2, b: Vec2): number {
const abx = b.x - a.x;
const aby = b.y - a.y;
const l2 = abx * abx + aby * aby;
let t = l2 > 0 ? ((p.x - a.x) * abx + (p.y - a.y) * aby) / l2 : 0;
t = Math.max(0, Math.min(1, t));
const cx = a.x + abx * t;
const cy = a.y + aby * t;
return Math.hypot(p.x - cx, p.y - cy);
}
// `vecEqual` re-export für etwaige Aufrufer (Auswahl-Vergleich).
export { vecEqual };
+735
View File
@@ -0,0 +1,735 @@
// 2D-Geometrie-Kernel — die „echte Arbeit" für Offset / Trim / Extend / Fillet /
// Split (Rhino-artige Editierbefehle, Tier 1). Reine Funktionen über `Vec2`,
// kein externer Kernel. Baut auf den Helfern in `../model/geometry` auf.
//
// Konvention: Polylinien sind `Vec2[]`; `closed` heißt, die letzte Kante geht
// von pts[n-1] zu pts[0] (der Schlusspunkt wird NICHT dupliziert). Offsets sind
// vorzeichenbehaftet: positiv = nach LINKS bezogen auf die Laufrichtung
// (`leftNormal`-Konvention des Projekts), negativ = nach rechts.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Vec2 } from "../model/types";
import {
add,
cross,
leftNormal,
len,
lineIntersect,
normalize,
scale,
sub,
} from "../model/geometry";
/** Numerische Toleranz (Meter) für Schnitt-/Gleichheitstests. */
export const EPS = 1e-7;
const dot = (a: Vec2, b: Vec2): number => a.x * b.x + a.y * b.y;
const dist = (a: Vec2, b: Vec2): number => len(sub(a, b));
/** Punkt-Gleichheit innerhalb Toleranz. */
export function vecEqual(a: Vec2, b: Vec2, eps = EPS): boolean {
return Math.abs(a.x - b.x) <= eps && Math.abs(a.y - b.y) <= eps;
}
// ── Punkt/Strecke ────────────────────────────────────────────────────────────
/** Projektionsparameter t von p auf die Gerade a→b (nicht geklemmt). */
export function projectParam(p: Vec2, a: Vec2, b: Vec2): number {
const ab = sub(b, a);
const l2 = dot(ab, ab);
if (l2 < EPS) return 0;
return dot(sub(p, a), ab) / l2;
}
/** Nächster Punkt auf der STRECKE a→b zu p (t auf [0,1] geklemmt). */
export function closestPointOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 {
const t = Math.max(0, Math.min(1, projectParam(p, a, b)));
return add(a, scale(sub(b, a), t));
}
/** Abstand von p zur Strecke a→b. */
export function pointSegmentDistance(p: Vec2, a: Vec2, b: Vec2): number {
return dist(p, closestPointOnSegment(p, a, b));
}
// ── Schnitt ──────────────────────────────────────────────────────────────────
/** Ergebnis eines Strecken-/Linienschnitts. */
export interface Hit {
point: Vec2;
/** Parameter auf der ersten Strecke (0 = a1, 1 = a2). */
t: number;
/** Parameter auf der zweiten Strecke (0 = b1, 1 = b2). */
s: number;
}
/**
* Schnitt zweier STRECKEN a1→a2 und b1→b2. Liefert den Schnittpunkt nur, wenn er
* auf BEIDEN Strecken liegt (t,s ∈ [-eps, 1+eps]); sonst null. Parallel → null.
*/
export function segmentIntersect(
a1: Vec2,
a2: Vec2,
b1: Vec2,
b2: Vec2,
eps = EPS,
): Hit | null {
const da = sub(a2, a1);
const db = sub(b2, b1);
const denom = cross(da, db);
if (Math.abs(denom) < EPS) return null; // parallel/kollinear
const t = cross(sub(b1, a1), db) / denom;
const s = cross(sub(b1, a1), da) / denom;
if (t < -eps || t > 1 + eps || s < -eps || s > 1 + eps) return null;
return { point: add(a1, scale(da, t)), t, s };
}
/**
* Schnitt der unendlichen GERADE durch a1→a2 mit der STRECKE b1→b2 (für Extend/
* Trim gegen einen Schneider): Schnittpunkt muss auf der Strecke b liegen
* (s ∈ [0,1]), t (auf a) ist frei. Null bei parallel / kein Treffer auf b.
*/
export function lineSegmentIntersect(
a1: Vec2,
a2: Vec2,
b1: Vec2,
b2: Vec2,
eps = EPS,
): Hit | null {
const da = sub(a2, a1);
const db = sub(b2, b1);
const denom = cross(da, db);
if (Math.abs(denom) < EPS) return null;
const t = cross(sub(b1, a1), db) / denom;
const s = cross(sub(b1, a1), da) / denom;
if (s < -eps || s > 1 + eps) return null;
return { point: add(a1, scale(da, t)), t, s };
}
/** Kanten einer Polylinie als [from,to]-Paare (Schlusskante bei `closed`). */
export function polylineEdges(pts: Vec2[], closed: boolean): [Vec2, Vec2][] {
const out: [Vec2, Vec2][] = [];
for (let i = 0; i < pts.length - 1; i++) out.push([pts[i], pts[i + 1]]);
if (closed && pts.length > 2) out.push([pts[pts.length - 1], pts[0]]);
return out;
}
/**
* Alle Schnittpunkte einer STRECKE a1→a2 mit den Kanten einer Polylinie, nach
* Parameter t auf der Strecke sortiert (für Trim/Split). Endpunkt-Duplikate
* (gleicher Punkt aufeinanderfolgender Kanten) werden zusammengefasst.
*/
export function segmentPolylineHits(
a1: Vec2,
a2: Vec2,
pts: Vec2[],
closed: boolean,
): Hit[] {
const hits: Hit[] = [];
for (const [b1, b2] of polylineEdges(pts, closed)) {
const h = segmentIntersect(a1, a2, b1, b2);
if (h) hits.push(h);
}
hits.sort((p, q) => p.t - q.t);
const dedup: Hit[] = [];
for (const h of hits) {
if (dedup.length === 0 || Math.abs(dedup[dedup.length - 1].t - h.t) > 1e-6) {
dedup.push(h);
}
}
return dedup;
}
// ── Offset (DAS Architektur-Primitiv) ───────────────────────────────────────
/** Offset einer einzelnen Strecke um `d` (links positiv). */
export function offsetSegment(a: Vec2, b: Vec2, d: number): [Vec2, Vec2] {
const n = leftNormal(normalize(sub(b, a)));
const off = scale(n, d);
return [add(a, off), add(b, off)];
}
/**
* Offset einer Polylinie um `d` (links positiv) mit GEHRUNG (miter): jede Kante
* wird parallel verschoben, benachbarte verschobene Kanten werden geschnitten.
* Bei (nahezu) geraden/parallelen Nachbarn wird der verschobene Endpunkt direkt
* genommen. Funktioniert für offene und geschlossene Polylinien; degenerierte
* (zu kurze) Kanten werden übersprungen. Selbstüberschneidungen bei starkem
* Offset werden NICHT bereinigt (genügt für den Architektur-Workflow; ein
* vollständiger Polygon-Offset mit Selbstschnitt-Heilung wäre Tier-2-Arbeit).
*/
export function offsetPolyline(pts: Vec2[], d: number, closed: boolean): Vec2[] {
// Auf signifikante Kanten reduzieren (Duplikate verwerfen).
const clean: Vec2[] = [];
for (const p of pts) {
if (clean.length === 0 || dist(clean[clean.length - 1], p) > EPS) clean.push(p);
}
if (closed && clean.length > 1 && dist(clean[0], clean[clean.length - 1]) <= EPS) {
clean.pop();
}
const n = clean.length;
if (n < 2) return [...pts];
const edges = polylineEdges(clean, closed).map(([a, b]) => offsetSegment(a, b, d));
if (edges.length === 0) return [...pts];
const result: Vec2[] = [];
// Hilfsfunktion: Schnitt zweier (verschobener) Kanten als unendliche Geraden.
const join = (e1: [Vec2, Vec2], e2: [Vec2, Vec2], fallback: Vec2): Vec2 => {
const d1 = sub(e1[1], e1[0]);
const d2 = sub(e2[1], e2[0]);
const p = lineIntersect(e1[0], d1, e2[0], d2);
return p ?? fallback;
};
if (!closed) {
// Offener Zug: erster Punkt = Start der ersten Kante; innere Punkte =
// Schnitt aufeinanderfolgender Kanten; letzter Punkt = Ende der letzten Kante.
result.push(edges[0][0]);
for (let i = 0; i < edges.length - 1; i++) {
result.push(join(edges[i], edges[i + 1], edges[i][1]));
}
result.push(edges[edges.length - 1][1]);
return result;
}
// Geschlossen: jeder Ausgabepunkt = Schnitt von Kante i mit Kante i+1 (zyklisch).
for (let i = 0; i < edges.length; i++) {
const prev = edges[(i - 1 + edges.length) % edges.length];
const curr = edges[i];
result.push(join(prev, curr, curr[0]));
}
return result;
}
// ── Trim / Split ─────────────────────────────────────────────────────────────
/**
* Schneidet eine STRECKE seg=[a1,a2] an allen Schnittpunkten mit den Schneider-
* Kurven (Polylinien) und liefert die resultierenden Teilstrecken. `cutters`
* sind Polylinien (jede mit `closed`-Flag). Reine Geometrie; welche Teile
* behalten/verworfen werden, entscheidet der Aufrufer (z. B. Trim verwirft das
* Stück, das den Pick-Punkt enthält — siehe {@link trimSegment}).
*/
export function splitSegmentByCutters(
a1: Vec2,
a2: Vec2,
cutters: { pts: Vec2[]; closed: boolean }[],
): [Vec2, Vec2][] {
const ts: number[] = [0, 1];
for (const c of cutters) {
for (const h of segmentPolylineHits(a1, a2, c.pts, c.closed)) {
if (h.t > EPS && h.t < 1 - EPS) ts.push(h.t);
}
}
ts.sort((p, q) => p - q);
const da = sub(a2, a1);
const pieces: [Vec2, Vec2][] = [];
for (let i = 0; i < ts.length - 1; i++) {
if (ts[i + 1] - ts[i] < 1e-6) continue;
pieces.push([add(a1, scale(da, ts[i])), add(a1, scale(da, ts[i + 1]))]);
}
return pieces;
}
/**
* Trim: schneidet seg an den Cuttern und VERWIRFT das Teilstück, das `pick`
* am nächsten liegt (das angeklickte „wegzuschneidende" Stück). Liefert die
* verbleibenden Teilstücke (0..n).
*/
export function trimSegment(
a1: Vec2,
a2: Vec2,
cutters: { pts: Vec2[]; closed: boolean }[],
pick: Vec2,
): [Vec2, Vec2][] {
const pieces = splitSegmentByCutters(a1, a2, cutters);
if (pieces.length <= 1) return pieces;
let best = 0;
let bestD = Infinity;
for (let i = 0; i < pieces.length; i++) {
const d = pointSegmentDistance(pick, pieces[i][0], pieces[i][1]);
if (d < bestD) {
bestD = d;
best = i;
}
}
return pieces.filter((_, i) => i !== best);
}
// ── Extend ───────────────────────────────────────────────────────────────────
/**
* Verlängert das gewählte Ende (`end`: "start" → a1, "end" → a2) der Strecke
* a1→a2 bis zur NÄCHSTGELEGENEN Schneider-Kante in Verlängerungsrichtung.
* Liefert die neue Strecke oder null, wenn kein Treffer in der korrekten
* Richtung existiert.
*/
export function extendSegment(
a1: Vec2,
a2: Vec2,
end: "start" | "end",
cutters: { pts: Vec2[]; closed: boolean }[],
): [Vec2, Vec2] | null {
// Richtung der Verlängerung: bei "end" über a2 hinaus (t>1), bei "start"
// über a1 hinaus (t<0). Wir parametrisieren stets entlang a1→a2.
let bestT: number | null = null;
for (const c of cutters) {
for (const [b1, b2] of polylineEdges(c.pts, c.closed)) {
const h = lineSegmentIntersect(a1, a2, b1, b2);
if (!h) continue;
if (end === "end" && h.t > 1 + EPS) {
if (bestT === null || h.t < bestT) bestT = h.t; // nächstgelegen über a2
} else if (end === "start" && h.t < -EPS) {
if (bestT === null || h.t > bestT) bestT = h.t; // nächstgelegen unter a1
}
}
}
if (bestT === null) return null;
const da = sub(a2, a1);
const hitPoint = add(a1, scale(da, bestT));
return end === "end" ? [a1, hitPoint] : [hitPoint, a2];
}
// ── Kreis-Schnitte (für Circle/Arc, 2P/3P/Tangenten) ────────────────────────
/** Schnittpunkte einer unendlichen GERADE a→b mit einem Kreis (0/1/2 Punkte). */
export function lineCircleIntersect(
a: Vec2,
b: Vec2,
center: Vec2,
r: number,
): Vec2[] {
const d = sub(b, a);
const f = sub(a, center);
const A = dot(d, d);
if (A < EPS) return [];
const B = 2 * dot(f, d);
const C = dot(f, f) - r * r;
let disc = B * B - 4 * A * C;
if (disc < -EPS) return [];
if (disc < 0) disc = 0;
const sq = Math.sqrt(disc);
const t1 = (-B - sq) / (2 * A);
const t2 = (-B + sq) / (2 * A);
const out = [add(a, scale(d, t1))];
if (Math.abs(t1 - t2) > EPS) out.push(add(a, scale(d, t2)));
return out;
}
/** Schnittpunkte einer STRECKE a→b mit einem Kreis (nur t ∈ [0,1]). */
export function segmentCircleIntersect(
a: Vec2,
b: Vec2,
center: Vec2,
r: number,
): Vec2[] {
return lineCircleIntersect(a, b, center, r).filter((p) => {
const t = projectParam(p, a, b);
return t >= -EPS && t <= 1 + EPS;
});
}
/** Schnittpunkte zweier Kreise (0/1/2 Punkte). */
export function circleCircleIntersect(
c1: Vec2,
r1: number,
c2: Vec2,
r2: number,
): Vec2[] {
const d = dist(c1, c2);
if (d < EPS) return []; // konzentrisch
if (d > r1 + r2 + EPS || d < Math.abs(r1 - r2) - EPS) return []; // getrennt/innen
const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d);
const h2 = r1 * r1 - a * a;
const h = h2 > 0 ? Math.sqrt(h2) : 0;
const u = normalize(sub(c2, c1));
const mid = add(c1, scale(u, a));
const n = leftNormal(u);
if (h < EPS) return [mid];
return [add(mid, scale(n, h)), add(mid, scale(n, -h))];
}
// ── Polygon-Fläche / Wicklung ───────────────────────────────────────────────
/** Vorzeichenbehaftete Polygonfläche (Shoelace); >0 = CCW, <0 = CW. */
export function signedArea(pts: Vec2[]): number {
let s = 0;
for (let i = 0; i < pts.length; i++) {
const a = pts[i];
const b = pts[(i + 1) % pts.length];
s += a.x * b.y - b.x * a.y;
}
return s / 2;
}
/** Ob ein Polygonzug gegen den Uhrzeigersinn (CCW) gewickelt ist. */
export function isCCW(pts: Vec2[]): boolean {
return signedArea(pts) > 0;
}
// ── Fillet (Eck-Verrundung) ─────────────────────────────────────────────────
/** Ergebnis einer Eck-Verrundung. */
export interface Fillet {
center: Vec2;
radius: number;
/** Tangentenpunkt auf dem ersten Schenkel (corner→p1). */
tangentA: Vec2;
/** Tangentenpunkt auf dem zweiten Schenkel (corner→p2). */
tangentB: Vec2;
/** Bogen-Endwinkel in Radiant (für ein Arc-Primitiv). */
startAngle: number;
endAngle: number;
}
/**
* Verrundet die Ecke bei `corner`, gebildet von den beiden Schenkeln corner→p1
* und corner→p2, mit Radius `r`. Liefert Bogenmittelpunkt + Tangentenpunkte
* (an denen die Schenkel zurückgetrimmt werden) oder null, wenn die Schenkel
* (nahezu) kollinear sind oder zu kurz für den Radius. Standard-Kanten-Fillet
* (Distanz vom Eck = r / tan(θ/2)).
*/
export function filletCorner(
corner: Vec2,
p1: Vec2,
p2: Vec2,
r: number,
): Fillet | null {
const u1 = normalize(sub(p1, corner));
const u2 = normalize(sub(p2, corner));
const cosTheta = Math.max(-1, Math.min(1, dot(u1, u2)));
const theta = Math.acos(cosTheta);
if (theta < 1e-4 || Math.PI - theta < 1e-4) return null; // kollinear
const tanHalf = Math.tan(theta / 2);
if (tanHalf < EPS) return null;
const setback = r / tanHalf; // Abstand Eck → Tangentenpunkt entlang je Schenkel
// Genügend Schenkellänge?
if (setback > len(sub(p1, corner)) + EPS || setback > len(sub(p2, corner)) + EPS) {
return null;
}
const tangentA = add(corner, scale(u1, setback));
const tangentB = add(corner, scale(u2, setback));
// Mittelpunkt liegt auf der Winkelhalbierenden, Abstand r/sin(θ/2) vom Eck.
const bis = normalize(add(u1, u2));
const centerDist = r / Math.sin(theta / 2);
const center = add(corner, scale(bis, centerDist));
const startAngle = Math.atan2(tangentA.y - center.y, tangentA.x - center.x);
const endAngle = Math.atan2(tangentB.y - center.y, tangentB.x - center.x);
return { center, radius: r, tangentA, tangentB, startAngle, endAngle };
}
// ── Split / Join / Segment-Löschen (Drawing2D-Editieren) ─────────────────────
// Reine Geometrie auf Polylinien (`Vec2[]` + `closed`). Die Operations-Schicht
// (`src/editors/splitJoin.ts`) wandelt Drawing2D ↔ {pts, closed} und ruft diese
// Funktionen. Punkt P einer Kante: `lerp(pts[i], pts[i+1], t)`.
/** Lineare Interpolation zweier Punkte. */
const lerp = (a: Vec2, b: Vec2, t: number): Vec2 => add(a, scale(sub(b, a), t));
/**
* Teilt eine Polylinie an EINEM Punkt P = lerp(pts[edgeIndex], pts[edgeIndex+1],
* t) (t ∈ (0,1) entlang der Kante; bei geschlossenen Zügen ist die Schlusskante
* `edgeIndex === pts.length-1`).
* • OFFEN → zwei offene Polylinien [start…P] und [P…ende].
* • GESCHLOSSEN → EINE offene Polylinie, am Punkt P aufgeschnitten (der Zug
* läuft von P einmal um die Schleife zurück nach P).
* Liegt P (nahezu) auf einem vorhandenen Vertex, wird KEIN Duplikat eingefügt.
*/
export function splitPolylineAtParam(
pts: Vec2[],
closed: boolean,
edgeIndex: number,
t: number,
): Vec2[][] {
const n = pts.length;
if (n < 2) return [pts.slice()];
const a = pts[edgeIndex];
const b = pts[(edgeIndex + 1) % n];
const p = lerp(a, b, t);
const atStart = t <= EPS;
const atEnd = t >= 1 - EPS;
if (!closed) {
// Offen: [0..edgeIndex] + P + [edgeIndex+1..n-1].
const left = pts.slice(0, edgeIndex + 1);
if (!atStart && !atEnd) left.push(p);
else if (atEnd) left.push(b); // P == b → b gehört noch links dazu
const right: Vec2[] = [];
if (!atStart && !atEnd) right.push(p);
else if (atStart) right.push(a);
for (let i = edgeIndex + 1; i < n; i++) right.push(pts[i]);
const pieces = [left, right].filter((s) => s.length >= 2);
return pieces.length ? pieces : [pts.slice()];
}
// Geschlossen: an P aufschneiden → eine offene Kette, die einmal herumläuft.
const out: Vec2[] = [];
if (!atStart) out.push(p);
// Ab dem Punkt NACH der Schnittkante einmal komplett um die Schleife.
for (let k = 1; k <= n; k++) {
out.push(pts[(edgeIndex + k) % n]);
}
// out endet bei pts[edgeIndex] (== a); P schließt den Zug am Ende ab.
if (!atStart) out.push(p);
else out.push(a); // bei P==a: a doppelt (Start==Ende) ergäbe eine Schleife;
// dedupe identische aufeinanderfolgende Punkte
return [dedupeConsecutive(out)];
}
/** Entfernt aufeinanderfolgende (nahezu) gleiche Punkte (kein Ringschluss). */
function dedupeConsecutive(pts: Vec2[]): Vec2[] {
const out: Vec2[] = [];
for (const p of pts) {
if (out.length === 0 || !vecEqual(out[out.length - 1], p)) out.push(p);
}
return out;
}
/**
* Teilt ein GESCHLOSSENES Polygon an zwei Randpunkten (auf den Kanten `i` bzw.
* `j`, Parameter `ti`/`tj`) in ZWEI GESCHLOSSENE Polygone: jedes besteht aus dem
* jeweiligen Rand-Bogen plus der Sehne (Verbindung Pi—Pj). Liefert `null`, wenn
* i==j (dieselbe Kante → keine sinnvolle Sehne).
*/
export function splitClosedByChord(
pts: Vec2[],
i: number,
ti: number,
j: number,
tj: number,
): [Vec2[], Vec2[]] | null {
const n = pts.length;
if (n < 3 || i === j) return null;
// Reihenfolge sicherstellen: a vor b entlang des Ringes.
let ia = i, ta = ti, ib = j, tb = tj;
if (i > j) {
ia = j; ta = tj; ib = i; tb = ti;
}
const Pa = lerp(pts[ia], pts[(ia + 1) % n], ta);
const Pb = lerp(pts[ib], pts[(ib + 1) % n], tb);
// Bogen 1: Pa → (Vertices ia+1 … ib) → Pb.
const arc1: Vec2[] = [Pa];
for (let k = ia + 1; k <= ib; k++) arc1.push(pts[k % n]);
arc1.push(Pb);
// Bogen 2: Pb → (Vertices ib+1 … ia, modular) → Pa.
const arc2: Vec2[] = [Pb];
for (let k = ib + 1; k <= ia + n; k++) arc2.push(pts[k % n]);
arc2.push(Pa);
return [dedupeRing(arc1), dedupeRing(arc2)];
}
/**
* Dedupliziert aufeinanderfolgende gleiche Punkte UND einen schließenden
* Duplikat-Endpunkt (Ring: pts[last]==pts[0] → letzten verwerfen).
*/
function dedupeRing(pts: Vec2[]): Vec2[] {
const out = dedupeConsecutive(pts);
if (out.length > 1 && vecEqual(out[0], out[out.length - 1])) out.pop();
return out;
}
/**
* Entfernt das Segment `edgeIndex` aus einer Polylinie. Liefert die neue
* Punktliste plus neues `closed`-Flag:
* • OFFEN: innere Kante → die Polylinie zerfällt in zwei Stücke; hier wird das
* LÄNGERE Stück (mehr Punkte) zurückgegeben (Schere). Erste/letzte Kante →
* einfach den End-Vertex abschneiden.
* • GESCHLOSSEN: die Schleife wird an dieser Kante zur OFFENEN Polylinie
* aufgetrennt (Start = Punkt nach der Kante, einmal herum bis zum Punkt davor).
*/
export function removeSegment(
pts: Vec2[],
closed: boolean,
edgeIndex: number,
): { pts: Vec2[]; closed: boolean } {
const n = pts.length;
if (closed) {
// Auftrennen: Start = (edgeIndex+1) … einmal herum bis edgeIndex.
const out: Vec2[] = [];
for (let k = 1; k <= n; k++) out.push(pts[(edgeIndex + k) % n]);
return { pts: dedupeConsecutive(out), closed: false };
}
// Offen.
if (edgeIndex <= 0) return { pts: pts.slice(1), closed: false };
if (edgeIndex >= n - 1) return { pts: pts.slice(0, n - 1), closed: false };
const left = pts.slice(0, edgeIndex + 1);
const right = pts.slice(edgeIndex + 1);
// Das punktreichere (geometrisch i. d. R. „größere") Teilstück behalten.
return { pts: left.length >= right.length ? left : right, closed: false };
}
/** Ein Schnitt-Treffer auf einer Ziel-Kante: Kantenindex + Parameter + Punkt. */
interface EdgeHit {
edge: number;
t: number;
point: Vec2;
}
/**
* Teilt das Ziel (`targetPts`, `closed`) an allen Schnittpunkten mit den anderen
* Polylinien (`others`). Schnittpunkte entstehen je Kantenpaar via
* {@link segmentIntersect}.
* • GESCHLOSSEN + genau zwei Schnitte → {@link splitClosedByChord} (zwei
* geschlossene Polygone). Bei !=2 Schnitten: unverändert (eine Liste).
* • OFFEN → an den (nach Bogenlänge geordneten) Schnittpunkten in Stücke
* zerschnitten.
* Schnitte an vorhandenen Vertices/Endpunkten werden zusammengefasst.
*/
export function splitAtIntersections(
targetPts: Vec2[],
closed: boolean,
others: Vec2[][],
): Vec2[][] {
const edges = polylineEdges(targetPts, closed);
const hits: EdgeHit[] = [];
for (let ei = 0; ei < edges.length; ei++) {
const [a1, a2] = edges[ei];
for (const o of others) {
// jede Kante jeder anderen Polylinie (offen UND geschlossen behandeln wir
// gleich: die Aufrufer-Schicht liefert `others` bereits als Polylinien-
// Punktlisten; geschlossene Schließkante über vecEqual-Heuristik unnötig,
// da der Aufrufer Endpunkt nicht dupliziert — wir testen offene Kanten).
const oEdges = polylineEdgesAuto(o);
for (const [b1, b2] of oEdges) {
const h = segmentIntersect(a1, a2, b1, b2);
if (!h) continue;
// Nur „echte" innere Schnitte zählen (nicht exakt am Ziel-Vertex 0/1,
// die ohnehin Trennstellen sind).
hits.push({ edge: ei, t: h.t, point: h.point });
}
}
}
// Pro Ziel-Kante nach t sortieren + dedupen.
const dedup: EdgeHit[] = [];
hits.sort((p, q) => (p.edge - q.edge) || (p.t - q.t));
for (const h of hits) {
const prev = dedup[dedup.length - 1];
if (prev && prev.edge === h.edge && Math.abs(prev.t - h.t) < 1e-6) continue;
// Schnitte exakt auf einem Ziel-Vertex (t≈0/1) ignorieren — keine echte Teilung.
if (h.t <= EPS || h.t >= 1 - EPS) continue;
dedup.push(h);
}
if (dedup.length === 0) return [targetPts.slice()];
if (closed) {
if (dedup.length === 2) {
const [h1, h2] = dedup;
const res = splitClosedByChord(targetPts, h1.edge, h1.t, h2.edge, h2.t);
return res ? res : [targetPts.slice()];
}
// Mehr/weniger als 2 Schnitte: geschlossen an allen Schnitten zur offenen
// Polylinie aufgetrennt (am ersten Schnitt aufgeschnitten, dann in Stücke).
// Pragmatisch: am ersten Schnitt aufschneiden, dann wie offen weiter.
const opened = splitPolylineAtParam(targetPts, true, dedup[0].edge, dedup[0].t)[0];
return splitOpenAtHits(opened, dedup.slice(1).map((h) => h.point));
}
// Offen: an den geordneten Schnittpunkten zerschneiden.
return splitOpenByEdgeHits(targetPts, dedup);
}
/** Kanten einer (offenen ODER implizit geschlossenen) Punktliste. */
function polylineEdgesAuto(pts: Vec2[]): [Vec2, Vec2][] {
// Heuristik: ist der erste == letzter Punkt, gilt sie als geschlossen.
const closed = pts.length > 2 && vecEqual(pts[0], pts[pts.length - 1]);
return polylineEdges(closed ? pts.slice(0, -1) : pts, closed);
}
/** Offene Polylinie an gegebenen EdgeHits (auf ihren Kanten) zerschneiden. */
function splitOpenByEdgeHits(pts: Vec2[], hits: EdgeHit[]): Vec2[][] {
const pieces: Vec2[][] = [];
let cur: Vec2[] = [pts[0]];
let hi = 0;
for (let ei = 0; ei < pts.length - 1; ei++) {
while (hi < hits.length && hits[hi].edge === ei) {
const p = hits[hi].point;
cur.push(p);
pieces.push(cur);
cur = [p];
hi++;
}
cur.push(pts[ei + 1]);
}
pieces.push(cur);
return pieces.map(dedupeConsecutive).filter((s) => s.length >= 2);
}
/** Offene Polylinie an einer Liste von Schnitt-PUNKTEN (auf dem Zug) zerschneiden. */
function splitOpenAtHits(pts: Vec2[], cutPoints: Vec2[]): Vec2[][] {
if (cutPoints.length === 0) return [pts.slice()];
const hits: EdgeHit[] = [];
for (let ei = 0; ei < pts.length - 1; ei++) {
const a = pts[ei];
const b = pts[ei + 1];
for (const cp of cutPoints) {
const t = projectParam(cp, a, b);
if (t > EPS && t < 1 - EPS && pointSegmentDistance(cp, a, b) < 1e-6) {
hits.push({ edge: ei, t, point: cp });
}
}
}
hits.sort((p, q) => (p.edge - q.edge) || (p.t - q.t));
return splitOpenByEdgeHits(pts, hits);
}
/**
* Verschmilzt Polylinien an koinzidenten Endpunkten zu längeren Ketten. Jeder
* Eintrag ist `{pts, closed}`. Bereits geschlossene Ketten bleiben unverändert.
* Offene Ketten werden an gemeinsamen Endpunkten (vecEqual) aneinandergehängt;
* schließt sich eine Kette (erster == letzter Punkt), wird sie als `closed`
* markiert (der Schlusspunkt-Duplikat entfernt). Reihenfolge-/Richtungs-stabil
* genug für den Editier-Workflow (kein Verzweigungs-Handling: Knoten mit >2
* Enden werden greedy verbunden).
*/
export function joinChains(
polylines: { pts: Vec2[]; closed: boolean }[],
): { pts: Vec2[]; closed: boolean }[] {
const closedOut: { pts: Vec2[]; closed: boolean }[] = [];
// Offene Ketten als veränderbare Punktlisten sammeln (geschlossene bleiben).
const open: Vec2[][] = [];
for (const pl of polylines) {
if (pl.closed) closedOut.push({ pts: pl.pts.slice(), closed: true });
else if (pl.pts.length >= 2) open.push(pl.pts.slice());
else if (pl.pts.length === 1) open.push(pl.pts.slice());
}
let merged = true;
while (merged) {
merged = false;
outer: for (let i = 0; i < open.length; i++) {
for (let j = i + 1; j < open.length; j++) {
const A = open[i];
const B = open[j];
const aS = A[0], aE = A[A.length - 1];
const bS = B[0], bE = B[B.length - 1];
let combined: Vec2[] | null = null;
if (vecEqual(aE, bS)) combined = [...A, ...B.slice(1)];
else if (vecEqual(aE, bE)) combined = [...A, ...B.slice(0, -1).reverse()];
else if (vecEqual(aS, bE)) combined = [...B, ...A.slice(1)];
else if (vecEqual(aS, bS)) combined = [...B.slice().reverse(), ...A.slice(1)];
if (combined) {
open.splice(j, 1);
open.splice(i, 1, combined);
merged = true;
break outer;
}
}
}
}
const out: { pts: Vec2[]; closed: boolean }[] = [...closedOut];
for (const chain of open) {
if (chain.length > 2 && vecEqual(chain[0], chain[chain.length - 1])) {
out.push({ pts: chain.slice(0, -1), closed: true });
} else {
out.push({ pts: chain, closed: false });
}
}
return out;
}
+500
View File
@@ -0,0 +1,500 @@
// Deutsches Wörterbuch (Default-Sprache). Die Werte hier sind EXAKT die bisher
// sichtbaren UI-Texte — die i18n-Umstellung darf optisch nichts ändern.
//
// Identifier/Keys sind englisch und namespaced wie bei DOSSIER
// (z. B. `topbar.grundriss`); nur die Werte sind übersetzbarer Text.
// Platzhalter in geschweiften Klammern (`{name}`) werden von t(...) ersetzt.
export const de = {
// ── Marke / Topbar ───────────────────────────────────────────────────────
"brand.tag": "Dokumentmodell",
"topbar.viewGroup": "Ansicht",
"topbar.grundriss": "Grundriss",
"topbar.perspektive": "Perspektive",
"view3d.group": "Ansicht 3D",
"view3d.front": "Front",
"view3d.front.hint": "Frontalansicht von vorne",
"view3d.top": "Oben",
"view3d.top.hint": "Draufsicht (senkrecht von oben)",
"view3d.side": "Seite",
"view3d.side.hint": "Seitenansicht (90° zur Front)",
"view3d.perspective": "Perspektive",
"view3d.perspective.hint": "Freie Perspektive (3/4-Blick)",
"view3d.iso": "Isometrie",
"view3d.iso.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)",
"view3d.camera": "Kamera",
"view3d.camera.hint": "Kamera-Einstellungen (Sichtwinkel)",
"view3d.fov": "Sichtwinkel",
"view3d.fov.hint": "Sichtwinkel (FOV) der Perspektivkamera in Grad",
"topbar.detailGroup": "Detailgrad",
"topbar.detail": "Detailgrad",
"topbar.detail.hint": "Detailgrad der Plandarstellung (Symbole, Schraffuren, Linien)",
"topbar.detail.hintDisabled": "Detailgrad wirkt im Grundriss (in der Perspektive noch nicht)",
"topbar.detail.grob": "grob",
"topbar.detail.mittel": "mittel",
"topbar.detail.fein": "fein",
"topbar.scaleGroup": "Massstab und Zoom",
"topbar.scale": "Massstab",
"topbar.scale.apply": "Papier-Massstab 1:N anwenden",
"topbar.scale.custom": "frei …",
"topbar.scale.live": "Aktueller Papier-Massstab (live)",
"topbar.scale.prompt": "Massstab eingeben (z. B. 1:35 oder 35):",
"topbar.zoom100": "100%",
"topbar.zoom100.hint": "Zoom exakt auf den gewählten Massstab 1:{n}",
"topbar.fit": "Einpassen",
"topbar.fit.hint": "Auf Plan einpassen",
"topbar.fitSelection": "Auswahl",
"topbar.fitSelection.hint": "Auf Auswahl einpassen (sonst auf Plan)",
"topbar.zoom.current": "Aktueller Zoom",
"topbar.renderGroup": "Render-Modus",
"topbar.render.shaded": "Schattiert",
"topbar.render.shaded.hint": "Schattiert",
"topbar.render.wireframe": "Draht",
"topbar.render.wireframe.hint": "Drahtgitter",
"topbar.render.hidden": "Kanten",
"topbar.render.hidden.hint": "Verdeckte Kanten (Hidden-Line)",
"topbar.render.hintDisabled": "Render-Modus wirkt in der Perspektive",
// Darstellungsart (kontextabhängiger Dropdown): Grundriss = Farbig/SW,
// Perspektive = Schattiert/Weiss/Draht/Kanten.
"display.style": "Darstellung",
"display.style.group": "Darstellungsart",
"display.style.hint": "Darstellungsart der aktiven Ansicht",
"display.style.color": "Farbig",
"display.style.mono": "Schwarz-Weiss",
"display.style.shaded": "Schattiert",
"display.style.white": "Weiss",
"display.style.wireframe": "Drahtgitter",
"display.style.hidden": "Kanten",
"topbar.referenceLinesGroup": "Referenzlinien",
"topbar.referenceLines": "Referenzlinien",
"topbar.referenceLines.hint": "Referenzlinien (Wandachsen) ein-/ausblenden",
"topbar.referenceLines.hintDisabled": "Referenzlinien wirken im Grundriss",
"topbar.resources": "Ressourcen",
"topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden",
// ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
"layout.title": "Layout",
"layout.placeholder": "Layout …",
"layout.saveCurrent": " Aktuelles speichern …",
"layout.loadGroup": "Laden",
"layout.deleteGroup": "Löschen",
"layout.savePrompt": "Layout speichern als:",
// ── Sichtbarkeits-Kombinationen (Topbar) ─────────────────────────────────
"combo.layers": "Ebenen-Komb.",
"combo.drawings": "Zeichn.-Komb.",
"combo.placeholder": "Kombination …",
"combo.saveCurrent": " Aktuelle speichern …",
"combo.loadGroup": "Laden",
"combo.deleteGroup": "Löschen",
"combo.layers.title": "Ebenenkombinationen",
"combo.drawings.title": "Zeichnungskombinationen",
"combo.layers.savePrompt": "Ebenenkombination speichern als:",
"combo.drawings.savePrompt": "Zeichnungskombination speichern als:",
// ── Zeichenwerkzeuge (Topbar-Werkzeugleiste + Statushinweise) ────────────
"tool.group": "Werkzeuge",
"tool.select": "Auswahl",
"tool.wall": "Wand",
"tool.line": "Linie",
"tool.polyline": "Polylinie",
"tool.rect": "Rechteck",
"tool.select.hint": "Auswahl — Elemente klicken/aufziehen",
"tool.wall.firstPoint": "Wand: ersten Achspunkt setzen",
"tool.wall.nextPoint": "Wand: nächsten Punkt — Doppelklick/Rechtsklick beendet",
"tool.line.firstPoint": "Linie: Startpunkt setzen",
"tool.line.secondPoint": "Linie: Endpunkt setzen",
"tool.polyline.firstPoint": "Polylinie: ersten Punkt setzen",
"tool.polyline.nextPoint": "Polylinie: nächster Punkt — Klick auf Start schließt, Doppelklick beendet",
"tool.rect.firstCorner": "Rechteck: erste Ecke setzen",
"tool.rect.secondCorner": "Rechteck: gegenüberliegende Ecke setzen",
"tool.wallType": "Wandtyp",
"tool.layer": "Ebene",
"tool.layer.hint": "Aktive Ebene — alles Gezeichnete kommt auf diese Kategorie",
"tool.floorOnlyDisabled": "Wand nur auf einem Geschoss zeichenbar",
"tool.snap": "Fang",
"tool.snap.hint": "Fang an Endpunkten/Raster; Shift = Ortho, Ctrl = Fang aus",
"snap.enabled": "Fang aktiv",
"snap.endpoint": "Endpunkt",
"snap.midpoint": "Mittelpunkt",
"snap.intersection": "Schnittpunkt",
"snap.onEdge": "Auf Kante (Lot)",
"snap.grid": "Raster",
"snap.ortho": "Ortho (Shift)",
"snap.gridSize": "Rasterweite (m)",
"snap.angleStep": "Winkelraster",
// ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ───────────────
"transform.bar": "Transformation",
"transform.cancel": "Abbrechen (Esc)",
"transform.countPrompt": "Anzahl Kopien:",
"transform.move": "Bewegen (M)",
"transform.mirror": "Spiegeln (S)",
"transform.rotate": "Drehen (D)",
"transform.mode.move": "Bewegen",
"transform.mode.copy": "Kopie",
"transform.mode.array": "N Kopien",
"transform.mode.distribute": "Verteilen",
"transform.hint.move.0": "Basispunkt klicken",
"transform.hint.move.1": "Zielpunkt klicken",
"transform.hint.rotate.0": "Drehzentrum klicken",
"transform.hint.rotate.1": "Bezugspunkt (Startwinkel) klicken",
"transform.hint.rotate.2": "Zielwinkel klicken",
"transform.hint.mirror.0": "Erster Punkt der Spiegelachse",
"transform.hint.mirror.1": "Zweiter Punkt der Spiegelachse",
// ── Linien-Darstellung (Display/Print) ───────────────────────────────────
"topbar.lineModeGroup": "Linien-Darstellung",
"topbar.lineMode.display": "Display",
"topbar.lineMode.print": "Print",
"topbar.lineMode.display.hint": "Display: alle Linien als konstante Haarlinie (ruhiges Arbeiten)",
"topbar.lineMode.print.hint": "Print: echte mm-Strichstärken (Papier-/Druckbild)",
// ── Statusleiste ─────────────────────────────────────────────────────────
"status.selection": "Auswahl",
"status.selectionN": "Auswahl: {n} {noun}",
"status.wall": "Wand",
"status.walls": "Wände",
"status.xy": "X/Y",
"status.unit": "Einheit",
"status.scale": "Massstab",
"status.zoom": "Zoom",
"status.floor": "Geschoss",
"status.layer": "Ebene",
"status.none": "—",
// ── Navigator-Panels ─────────────────────────────────────────────────────
"nav.drawingLevels": "Zeichnungsebenen",
"nav.layers": "Ebenen",
"nav.tools": "Werkzeuge",
"nav.attributes": "Attribute",
"nav.objectInfo": "Objekt-Info",
// Attribute-Palette (AttributesPanel)
"attr.title": "Attribute",
"attr.empty": "Keine Auswahl",
"attr.stroke": "Stift",
"attr.color": "Farbe",
"attr.weight": "Strichstärke (mm)",
"attr.lineStyle": "Linienstil",
"attr.fill": "Füllung",
"attr.fillColor": "Füllfarbe",
"attr.clearFill": "Füllfarbe entfernen",
"attr.hatch": "Schraffur",
"attr.none": "keine",
"attr.inheritedFromLayer": "Wände erben die Strichstärke aus der Ebene",
"attr.fillNeedsClosed": "Füllung nur für geschlossene Formen",
"attr.size": "Maße",
"attr.width": "Breite",
"attr.height": "Höhe",
// Objekt-Info-Palette (ObjectInfoPanel)
"objinfo.title": "Objekt-Info",
"objinfo.empty": "Kein Objekt gewählt",
"objinfo.refpoint": "Bezugspunkt",
"objinfo.setRefpoint": "Bezugspunkt wählen",
"objinfo.x": "X",
"objinfo.y": "Y",
"objinfo.z": "Z",
"objinfo.zHint": "2D-Ebene keine Höhe am Element",
"objinfo.dimensions": "Maße",
"objinfo.width": "Breite",
"objinfo.height": "Höhe",
"objinfo.type": "Typ",
"objinfo.kind.wall": "Wand",
"objinfo.kind.drawing2d": "2D-Objekt",
"objinfo.kind.door": "Tür",
// ── Wand-Attribute (Object-Info-Panel) ──────────────────────────────────
"objinfo.wall.section": "Wand",
"objinfo.wall.refLine": "Referenzlinie",
"objinfo.wall.refLine.left": "Außen (links)",
"objinfo.wall.refLine.center": "Mitte",
"objinfo.wall.refLine.right": "Innen (rechts)",
"objinfo.wall.buildup": "Aufbau",
"objinfo.wall.single": "Einschichtig",
"objinfo.wall.multi": "Mehrschichtig",
"objinfo.wall.thickness": "Dicke",
"objinfo.wall.preset": "Wandaufbau",
"objinfo.wall.presetLabel": "{name} · {thickness} m",
"objinfo.wall.refFloor": "Referenzgeschoss",
"objinfo.wall.linkAbove": "An oberes Geschoss binden",
"objinfo.wall.bottom": "Unterkante (UK)",
"objinfo.wall.top": "Oberkante (OK)",
"objinfo.wall.anchorFloor": "An Geschoss gebunden",
"objinfo.wall.anchorCustom": "Eigene Höhe",
"objinfo.wall.height": "Höhe",
"objinfo.wall.floorAboveNone": "Kein oberes Geschoss",
"levels.addFloor": "+ Geschoss",
"levels.addDrawing": "+ Zeichnung",
"layers.addCategory": "+ Ebene",
"nav.visibility": "Sichtbarkeit",
"nav.hide": "Ausblenden",
"nav.show": "Einblenden",
"nav.categoryVisible": "{name} sichtbar",
"nav.categorySelect": "{name} als aktive Ebene wählen (Zeichen-Ziel)",
// ── Badges (Art einer Zeichnungsebene) ───────────────────────────────────
"badge.floor": "Geschoss",
"badge.section": "Schnitt",
"badge.elevation": "Ansicht",
"badge.drawing": "Zeichnung",
// ── Darstellungsmodus-Umschalter ─────────────────────────────────────────
"display.title": "Darstellungsmodus",
"display.all_force": "Alle anzeigen",
"display.all": "Ausgewählte",
"display.active": "Nur aktive",
"display.grey": "Andere grau",
"display.grey_locked": "Andere grau & gesperrt",
// ── Dock ─────────────────────────────────────────────────────────────────
"dock.resize": "Breite anpassen",
"dock.dock": "Andocken",
"dock.close": "Schließen",
"dock.float": "Lösen",
"dock.redock": "Andocken",
"dock.resizePanel": "Größe ändern",
"dock.dropLeft": "Links andocken",
"dock.dropRight": "Rechts andocken",
// ── Kontextmenü (Einträge) ───────────────────────────────────────────────
"ctx.layerSettings": "Ebeneneinstellungen…",
"ctx.addSubLayer": "Sub-Ebene hinzufügen…",
"ctx.assignSelection": "Selektion hierher übertragen",
"ctx.assignSelection.disabled": "Keine Wand ausgewählt — zuerst im Grundriss/3D eine Wand anklicken.",
"ctx.duplicate": "Duplizieren",
"ctx.copyProps": "Eigenschaften kopieren",
"ctx.pasteProps": "Eigenschaften einfügen",
"ctx.pasteProps.disabled": "Zwischenablage leer — zuerst „Eigenschaften kopieren“.",
"ctx.delete": "Löschen",
"ctx.delete.lastLayer": "Die letzte Ebene kann nicht gelöscht werden.",
"ctx.delete.lastLevel": "Die letzte Zeichnungsebene kann nicht gelöscht werden.",
"ctx.levelSettings": "Einstellungen…",
"ctx.fitSelection": "Auf Auswahl einpassen",
"ctx.clearSelection": "Auswahl aufheben",
"ctx.fit": "Ansicht einpassen",
"ctx.noWallHit": "Keine Wand getroffen",
"ctx.noWallHit.hint": "Rechtsklick auf eine Wand wählt sie und zeigt Wand-Aktionen.",
// Kontextmenü-Titel je Quelle.
"ctx.title.level": "Zeichnungsebene",
"ctx.title.wall": "Wand",
"ctx.title.view": "Ansicht",
// ── Inline-Editor (Ebene / Geschoss) ─────────────────────────────────────
"editor.close": "Schließen",
"editor.layerTitle": "Ebene {code}",
"editor.name": "Name",
"editor.color": "Farbe",
"editor.lineWeight": "Linienstärke",
"editor.floorHeight": "Geschosshöhe",
"editor.cutHeight": "Schnitthöhe",
"editor.unit.mm": "mm",
"editor.unit.m": "m",
"editor.noHeightDrawing": "Für Zeichnungen gibt es keine Geschoss-/Schnitthöhe.",
"editor.noHeightSection": "Für Schnitte/Ansichten gibt es keine Geschoss-/Schnitthöhe.",
// ── Hauptinhalt (Panes / Eigenschaften) ──────────────────────────────────
"content.perspektiveTitle": "Perspektive · {name}",
"content.grundrissTitle": "Grundriss · {name}",
"content.grundrissLegend": "Grundriss · {name} · Schnitthöhe {cutHeight}",
"content.drawingTitle": "Zeichnung · {name}",
"content.drawingCanvas": "Zeichenfläche",
"content.drawingLegend": "Freie 2D-Zeichnung",
"props.floorHeight": "Geschosshöhe",
"props.cutHeight": "Schnitthöhe",
"props.okff": "OKFF",
"props.okffValue": "+{value} m",
// ── Stub (Schnitt / Ansicht) ─────────────────────────────────────────────
"stub.section": "Schnitt",
"stub.elevation": "Ansicht",
"stub.note": "{kind} — abgeleitete Projektion folgt in einer späteren Phase",
// ── Default-Namen neuer Elemente ─────────────────────────────────────────
"default.floorName": "Geschoss {n}",
"default.drawingName": "Zeichnung {n}",
"default.categoryName": "Neue Ebene",
"default.subCategoryName": "Neue Sub-Ebene",
"default.componentName": "Neues Bauteil",
"default.hatchName": "Neue Schraffur",
"default.lineStyleName": "Neuer Linienstil",
"default.hatchNone": "Ohne",
"default.copySuffixLevel": "{name} Kopie",
"default.copySuffixLayer": "{name} KOPIE",
"default.wallTypeSingle": "Wand {thickness} m",
// ── Warnungen (Lösch-Schutz) ─────────────────────────────────────────────
"alert.layerInUse": "Ebene „{code} {name}\" wird von Bauteilen verwendet und kann nicht gelöscht werden.",
"alert.componentInUse": "Bauteil „{name}\" wird von einem Wandtyp verwendet und kann nicht gelöscht werden.",
"alert.hatchInUse": "Schraffur „{name}\" wird von einem Bauteil verwendet und kann nicht gelöscht werden.",
"alert.lineStyleInUse": "Linienstil „{name}\" wird von einer Schraffur verwendet und kann nicht gelöscht werden.",
// ── Ressourcen-Manager ───────────────────────────────────────────────────
"resources.title": "Ressourcen",
"resources.label": "Ressourcen-Manager",
"resources.close": "Schließen",
"resources.tab.components": "Bauteile",
"resources.tab.hatches": "Schraffuren",
"resources.tab.lines": "Linien",
"resources.add": "Neu",
"resources.import.lin": "⤓ .lin importieren",
"resources.import.pat": "⤓ .pat importieren",
// Bauteile.
"resources.components.hint": "Höhere Priorität läuft am Stoß durch (verschneidet zuerst); niedrigere stößt seitlich an.",
"resources.components.empty": "Noch keine Bauteile.",
"resources.components.placeholder": "Bauteil",
"resources.col.name": "Name",
"resources.col.color": "Farbe",
"resources.col.hatch": "Schraffur",
"resources.col.prio": "Prio",
"resources.col.prio.hint": "Höhere Priorität läuft am Stoß durch",
"resources.col.texture": "Textur",
"resources.delete.component": "Bauteil „{name}\" löschen",
// Schraffuren.
"resources.hatches.hint": "Muster, Maßstab und Winkel steuern die Schnitt-Schraffur; der Linienstil bestimmt die Stärke der Musterlinien.",
"resources.hatches.empty": "Noch keine Schraffuren.",
"resources.hatches.placeholder": "Schraffur",
"resources.col.pattern": "Muster",
"resources.col.scale": "Maßstab",
"resources.col.angle": "Winkel °",
"resources.col.lineStyle": "Linienstil",
"resources.lineStyle.none": "— ohne —",
"resources.delete.hatch": "Schraffur „{name}\" löschen",
// Schraffur-Muster.
"pattern.none": "Ohne",
"pattern.solid": "Vollfläche",
"pattern.insulation": "Wärmedämmung",
"pattern.diagonal": "Diagonal",
"pattern.crosshatch": "Kreuzschraffur",
// Linien.
"resources.lines.hint": "Strichstärke in Millimetern (Paper-Space). Strichmuster: durchgezogen, gestrichelt oder gepunktet.",
"resources.lines.empty": "Noch keine Linienstile.",
"resources.lines.placeholder": "Linienstil",
"resources.col.weight": "Stärke (mm)",
"resources.col.stroke": "Strich",
"resources.col.preview": "Vorschau",
"resources.delete.lineStyle": "Linienstil „{name}\" löschen",
"resources.texture.empty": "—",
// Strichmuster.
"dash.solid": "Durchgezogen",
"dash.dashed": "Gestrichelt",
"dash.dotted": "Gepunktet",
// ── Command-Line / Befehle (Rhino-artiges Befehlssystem) ──────────────────
"cmd.aria": "Befehlszeile",
"cmd.idle.prompt": "Befehl:",
"cmd.idle.placeholder": "Befehl eingeben … (Tab)",
"cmd.line.label": "Linie",
"cmd.line.start": "Startpunkt der Linie:",
"cmd.line.end": "Endpunkt der Linie:",
"cmd.polyline.label": "Polylinie",
"cmd.polyline.start": "Startpunkt:",
"cmd.polyline.next": "Nächster Punkt:",
"cmd.polyline.close": "Schliessen",
"cmd.polyline.undo": "Zurück",
"cmd.rect.label": "Rechteck",
"cmd.rect.first": "Erste Ecke:",
"cmd.rect.second": "Gegenüberliegende Ecke:",
"cmd.circle.label": "Kreis",
"cmd.circle.center": "Mittelpunkt:",
"cmd.circle.radius": "Radius:",
// Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl.
"cmd.move.label": "Verschieben",
"cmd.move.base": "Basispunkt:",
"cmd.move.target": "Zielpunkt:",
"cmd.move.empty": "Nichts gewählt (erst Objekte wählen).",
"cmd.copy.label": "Kopieren",
"cmd.copy.base": "Basispunkt:",
"cmd.copy.target": "Zielpunkt (Enter beendet):",
"cmd.copy.empty": "Nichts gewählt (erst Objekte wählen).",
"cmd.offset.label": "Versatz",
"cmd.offset.pick": "Kurve wählen:",
"cmd.offset.side": "Seite oder Distanz:",
// Editier-Werkzeuge Split / Join / Segment-Löschen (Tastatur: Ctrl+S / Ctrl+J /
// Alt+Klick). Labels für Befehlsleiste/Menüs.
"edit.split.label": "Teilen",
"edit.join.label": "Verbinden",
"edit.cutSegment.label": "Segment löschen",
// Tab-Feld-Zyklus (Präzisionseingabe): geordnete Zahlenfelder je Schritt.
"cmd.field.length": "Länge",
"cmd.field.angle": "Winkel",
"cmd.field.width": "Breite",
"cmd.field.height": "Höhe",
"cmd.field.radius": "Radius",
"cmd.field.distance": "Distanz",
// ── Befehle: Import / Gelände ────────────────────────────────────────────
"cmd.import.label": "Importieren",
"cmd.import.prompt": "DXF-Datei wählen …",
"cmd.terrain.label": "Gelände",
"cmd.terrain.prompt": "Gelände aus Konturen erzeugen …",
// ── Site-/Kontext-Panel (SitePanel) ──────────────────────────────────────
"nav.site": "Gelände",
"site.title": "Gelände & Kontext",
"site.import": "⤓ DXF importieren",
"site.generateTerrain": "Gelände erzeugen",
"site.contourSet": "Kontur-Satz",
"site.empty": "Kein Kontext geladen. Importiere eine DXF-Datei (Höhenlinien oder Mesh).",
"site.delete": "Entfernen",
"site.type.mesh": "Mesh",
"site.type.contours": "Konturen",
"site.type.terrain": "Gelände",
"site.dwgHint": "DWG? Zuerst mit dem gratis ODA File Converter in DXF umwandeln.",
"site.terrainFailed": "Kein Gelände erzeugbar (zu wenige/kollineare Konturpunkte).",
// ── Import-Dialog (ImportDialog) ─────────────────────────────────────────
"import.title": "DXF importieren",
"import.close": "Schließen",
"import.file": "Datei",
"import.summary.heading": "Inhalt",
"import.summary.contours": "{n} Konturen",
"import.summary.meshes": "{n} Meshes",
"import.summary.layers": "Layer: {layers}",
"import.summary.noLayers": "keine Layer-Namen",
"import.summary.empty": "Keine importierbare 2D-Geometrie gefunden.",
"import.target.heading": "Ziel-Zeichnungsebene",
"import.target.existing": "In bestehende Zeichnungsebene importieren",
"import.target.new": "Neue Zeichnung anlegen",
"import.target.newNamePlaceholder": "Name der neuen Zeichnung",
"import.layers.heading": "Ebenen-Handling (DXF-Layer)",
"import.layers.asCategories": "DXF-Layer als Kategorien übernehmen",
"import.layers.toActive": "Alles auf die aktive Kategorie ({code})",
"import.mesh.heading": "Meshes",
"import.mesh.asContext": "Meshes als Kontext-Geometrie (3D) importieren",
"import.mesh.note": "Meshes sind keine 2D-Zeichnung — sie landen in der Kontext-Schicht.",
"import.dwg.heading": "DWG-Datei",
"import.dwg.loading": "DWG wird gelesen …",
"import.dwg.hint":
"DWG konnte nicht gelesen werden. Wandle die Datei alternativ mit dem gratis ODA File Converter in DXF um und importiere die DXF-Datei.",
"import.confirm": "Importieren",
"import.cancel": "Abbrechen",
"import.badge.floor": "Geschoss",
"import.badge.section": "Schnitt",
"import.badge.elevation": "Ansicht",
"import.badge.drawing": "Zeichnung",
// ── Drag & Drop (App-weiter Datei-Drop) ──────────────────────────────────
"drop.hint": "DXF/DWG-Datei hier ablegen",
// ── Fehler-Boundary ──────────────────────────────────────────────────────
"error.console": "Unerwarteter Fehler:",
"error.title": "Etwas ist schiefgelaufen",
"error.body": "Ein unerwarteter Fehler ist aufgetreten. Details in der Konsole.",
"error.reload": "Neu laden",
} as const;
export type TranslationKey = keyof typeof de;
+490
View File
@@ -0,0 +1,490 @@
// English dictionary — proves translatability. Same keys as de.ts; only the
// values differ. Identifiers/keys stay English (DOSSIER namespacing); values are
// the translated, user-visible text.
import type { TranslationKey } from "./de";
export const en: Record<TranslationKey, string> = {
// Brand / Topbar.
"brand.tag": "Document model",
"topbar.viewGroup": "View",
"topbar.grundriss": "Plan",
"topbar.perspektive": "Perspective",
"view3d.group": "3D view",
"view3d.front": "Front",
"view3d.front.hint": "Front view (frontal)",
"view3d.top": "Top",
"view3d.top.hint": "Top view (straight down)",
"view3d.side": "Side",
"view3d.side.hint": "Side view (90° to front)",
"view3d.perspective": "Perspective",
"view3d.perspective.hint": "Free perspective (3/4 view)",
"view3d.iso": "Isometric",
"view3d.iso.hint": "Isometric view (equiangular from front-top-right)",
"view3d.camera": "Camera",
"view3d.camera.hint": "Camera settings (field of view)",
"view3d.fov": "Field of view",
"view3d.fov.hint": "Field of view (FOV) of the perspective camera in degrees",
"topbar.detailGroup": "Detail level",
"topbar.detail": "Detail level",
"topbar.detail.hint": "Detail level of the plan (symbols, hatches, lines)",
"topbar.detail.hintDisabled": "Detail level applies in the plan (not yet in perspective)",
"topbar.detail.grob": "coarse",
"topbar.detail.mittel": "medium",
"topbar.detail.fein": "fine",
"topbar.scaleGroup": "Scale and zoom",
"topbar.scale": "Scale",
"topbar.scale.apply": "Apply paper scale 1:N",
"topbar.scale.custom": "custom …",
"topbar.scale.live": "Current paper scale (live)",
"topbar.scale.prompt": "Enter scale (e.g. 1:35 or 35):",
"topbar.zoom100": "100%",
"topbar.zoom100.hint": "Zoom exactly to the chosen scale 1:{n}",
"topbar.fit": "Fit",
"topbar.fit.hint": "Fit to plan",
"topbar.fitSelection": "Selection",
"topbar.fitSelection.hint": "Fit to selection (otherwise to plan)",
"topbar.zoom.current": "Current zoom",
"topbar.renderGroup": "Render mode",
"topbar.render.shaded": "Shaded",
"topbar.render.shaded.hint": "Shaded",
"topbar.render.wireframe": "Wire",
"topbar.render.wireframe.hint": "Wireframe",
"topbar.render.hidden": "Edges",
"topbar.render.hidden.hint": "Hidden line",
"topbar.render.hintDisabled": "Render mode applies in perspective",
// Display style (context-dependent dropdown): plan = Color/Mono,
// perspective = Shaded/White/Wireframe/Hidden.
"display.style": "Display",
"display.style.group": "Display style",
"display.style.hint": "Display style of the active view",
"display.style.color": "Color",
"display.style.mono": "Black & white",
"display.style.shaded": "Shaded",
"display.style.white": "White",
"display.style.wireframe": "Wireframe",
"display.style.hidden": "Hidden line",
"topbar.referenceLinesGroup": "Reference lines",
"topbar.referenceLines": "Reference lines",
"topbar.referenceLines.hint": "Show/hide reference lines (wall axes)",
"topbar.referenceLines.hintDisabled": "Reference lines apply in the plan",
"topbar.resources": "Resources",
"topbar.resources.hint": "Show/hide the resources window",
// Layout menu.
"layout.title": "Layout",
"layout.placeholder": "Layout …",
"layout.saveCurrent": " Save current …",
"layout.loadGroup": "Load",
"layout.deleteGroup": "Delete",
"layout.savePrompt": "Save layout as:",
// Visibility combinations (topbar).
"combo.layers": "Layer comb.",
"combo.drawings": "Drawing comb.",
"combo.placeholder": "Combination …",
"combo.saveCurrent": " Save current …",
"combo.loadGroup": "Load",
"combo.deleteGroup": "Delete",
"combo.layers.title": "Layer combinations",
"combo.drawings.title": "Drawing combinations",
"combo.layers.savePrompt": "Save layer combination as:",
"combo.drawings.savePrompt": "Save drawing combination as:",
// Drawing tools (topbar toolbar + status hints).
"tool.group": "Tools",
"tool.select": "Select",
"tool.wall": "Wall",
"tool.line": "Line",
"tool.polyline": "Polyline",
"tool.rect": "Rectangle",
"tool.select.hint": "Select — click/drag elements",
"tool.wall.firstPoint": "Wall: set first axis point",
"tool.wall.nextPoint": "Wall: next point — double/right-click to finish",
"tool.line.firstPoint": "Line: set start point",
"tool.line.secondPoint": "Line: set end point",
"tool.polyline.firstPoint": "Polyline: set first point",
"tool.polyline.nextPoint": "Polyline: next point — click start to close, double-click to finish",
"tool.rect.firstCorner": "Rectangle: set first corner",
"tool.rect.secondCorner": "Rectangle: set opposite corner",
"tool.wallType": "Wall type",
"tool.layer": "Layer",
"tool.layer.hint": "Active layer — everything drawn goes onto this category",
"tool.floorOnlyDisabled": "Walls can only be drawn on a floor",
"tool.snap": "Snap",
"tool.snap.hint": "Snap to endpoints/grid; Shift = ortho, Ctrl = snap off",
"snap.enabled": "Snap on",
"snap.endpoint": "Endpoint",
"snap.midpoint": "Midpoint",
"snap.intersection": "Intersection",
"snap.onEdge": "On edge (perp.)",
"snap.grid": "Grid",
"snap.ortho": "Ortho (Shift)",
"snap.gridSize": "Grid size (m)",
"snap.angleStep": "Angle step",
// Transform (move/mirror/rotate) + mode bar.
"transform.bar": "Transform",
"transform.cancel": "Cancel (Esc)",
"transform.countPrompt": "Number of copies:",
"transform.move": "Move (M)",
"transform.mirror": "Mirror (S)",
"transform.rotate": "Rotate (D)",
"transform.mode.move": "Move",
"transform.mode.copy": "Copy",
"transform.mode.array": "N copies",
"transform.mode.distribute": "Distribute",
"transform.hint.move.0": "Click base point",
"transform.hint.move.1": "Click destination point",
"transform.hint.rotate.0": "Click rotation centre",
"transform.hint.rotate.1": "Click reference point (start angle)",
"transform.hint.rotate.2": "Click target angle",
"transform.hint.mirror.0": "First point of mirror axis",
"transform.hint.mirror.1": "Second point of mirror axis",
// Line display (Display/Print).
"topbar.lineModeGroup": "Line display",
"topbar.lineMode.display": "Display",
"topbar.lineMode.print": "Print",
"topbar.lineMode.display.hint": "Display: all lines as constant hairlines (calm editing)",
"topbar.lineMode.print.hint": "Print: real mm line weights (paper/print look)",
// Status bar.
"status.selection": "Selection",
"status.selectionN": "Selection: {n} {noun}",
"status.wall": "wall",
"status.walls": "walls",
"status.xy": "X/Y",
"status.unit": "Unit",
"status.scale": "Scale",
"status.zoom": "Zoom",
"status.floor": "Floor",
"status.layer": "Layer",
"status.none": "—",
// Navigator panels.
"nav.drawingLevels": "Drawing levels",
"nav.layers": "Layers",
"nav.tools": "Tools",
"nav.attributes": "Attributes",
"nav.objectInfo": "Object Info",
// Attribute-Palette (AttributesPanel)
"attr.title": "Attributes",
"attr.empty": "No selection",
"attr.stroke": "Pen",
"attr.color": "Color",
"attr.weight": "Line weight (mm)",
"attr.lineStyle": "Line style",
"attr.fill": "Fill",
"attr.fillColor": "Fill color",
"attr.clearFill": "Remove fill color",
"attr.hatch": "Hatch",
"attr.none": "none",
"attr.inheritedFromLayer": "Walls inherit line weight from the layer",
"attr.fillNeedsClosed": "Fill only for closed shapes",
"attr.size": "Size",
"attr.width": "Width",
"attr.height": "Height",
// Objekt-Info-Palette (ObjectInfoPanel)
"objinfo.title": "Object Info",
"objinfo.empty": "No object selected",
"objinfo.refpoint": "Reference point",
"objinfo.setRefpoint": "Set reference point",
"objinfo.x": "X",
"objinfo.y": "Y",
"objinfo.z": "Z",
"objinfo.zHint": "2D layer elements have no height",
"objinfo.dimensions": "Dimensions",
"objinfo.width": "Width",
"objinfo.height": "Height",
"objinfo.type": "Type",
"objinfo.kind.wall": "Wall",
"objinfo.kind.drawing2d": "2D object",
"objinfo.kind.door": "Door",
// ── Wall attributes (object info panel) ──────────────────────────────────
"objinfo.wall.section": "Wall",
"objinfo.wall.refLine": "Reference line",
"objinfo.wall.refLine.left": "Exterior (left)",
"objinfo.wall.refLine.center": "Center",
"objinfo.wall.refLine.right": "Interior (right)",
"objinfo.wall.buildup": "Build-up",
"objinfo.wall.single": "Single layer",
"objinfo.wall.multi": "Multi layer",
"objinfo.wall.thickness": "Thickness",
"objinfo.wall.preset": "Wall style",
"objinfo.wall.presetLabel": "{name} · {thickness} m",
"objinfo.wall.refFloor": "Reference floor",
"objinfo.wall.linkAbove": "Bind to floor above",
"objinfo.wall.bottom": "Bottom (UK)",
"objinfo.wall.top": "Top (OK)",
"objinfo.wall.anchorFloor": "Bound to floor",
"objinfo.wall.anchorCustom": "Custom height",
"objinfo.wall.height": "Height",
"objinfo.wall.floorAboveNone": "No floor above",
"levels.addFloor": "+ Floor",
"levels.addDrawing": "+ Drawing",
"layers.addCategory": "+ Layer",
"nav.visibility": "Visibility",
"nav.hide": "Hide",
"nav.show": "Show",
"nav.categoryVisible": "{name} visible",
"nav.categorySelect": "Make {name} the active layer (drawing target)",
// Badges.
"badge.floor": "Floor",
"badge.section": "Section",
"badge.elevation": "Elevation",
"badge.drawing": "Drawing",
// Display mode select.
"display.title": "Display mode",
"display.all_force": "Show all",
"display.all": "Selected",
"display.active": "Active only",
"display.grey": "Others grey",
"display.grey_locked": "Others grey & locked",
// Dock.
"dock.resize": "Adjust width",
"dock.dock": "Dock",
"dock.close": "Close",
"dock.float": "Float",
"dock.redock": "Dock",
"dock.resizePanel": "Resize",
"dock.dropLeft": "Dock left",
"dock.dropRight": "Dock right",
// Context menu.
"ctx.layerSettings": "Layer settings…",
"ctx.addSubLayer": "Add sub-layer…",
"ctx.assignSelection": "Assign selection here",
"ctx.assignSelection.disabled": "No wall selected — first click a wall in the plan/3D.",
"ctx.duplicate": "Duplicate",
"ctx.copyProps": "Copy properties",
"ctx.pasteProps": "Paste properties",
"ctx.pasteProps.disabled": "Clipboard empty — first use “Copy properties”.",
"ctx.delete": "Delete",
"ctx.delete.lastLayer": "The last layer cannot be deleted.",
"ctx.delete.lastLevel": "The last drawing level cannot be deleted.",
"ctx.levelSettings": "Settings…",
"ctx.fitSelection": "Fit to selection",
"ctx.clearSelection": "Clear selection",
"ctx.fit": "Fit view",
"ctx.noWallHit": "No wall hit",
"ctx.noWallHit.hint": "Right-clicking a wall selects it and shows wall actions.",
"ctx.title.level": "Drawing level",
"ctx.title.wall": "Wall",
"ctx.title.view": "View",
// Inline editor.
"editor.close": "Close",
"editor.layerTitle": "Layer {code}",
"editor.name": "Name",
"editor.color": "Color",
"editor.lineWeight": "Line weight",
"editor.floorHeight": "Floor height",
"editor.cutHeight": "Cut height",
"editor.unit.mm": "mm",
"editor.unit.m": "m",
"editor.noHeightDrawing": "Drawings have no floor/cut height.",
"editor.noHeightSection": "Sections/elevations have no floor/cut height.",
// Main content.
"content.perspektiveTitle": "Perspective · {name}",
"content.grundrissTitle": "Plan · {name}",
"content.grundrissLegend": "Plan · {name} · Cut height {cutHeight}",
"content.drawingTitle": "Drawing · {name}",
"content.drawingCanvas": "Drawing canvas",
"content.drawingLegend": "Free 2D drawing",
"props.floorHeight": "Floor height",
"props.cutHeight": "Cut height",
"props.okff": "FFL",
"props.okffValue": "+{value} m",
// Stub.
"stub.section": "Section",
"stub.elevation": "Elevation",
"stub.note": "{kind} — derived projection follows in a later phase",
// Default names.
"default.floorName": "Floor {n}",
"default.drawingName": "Drawing {n}",
"default.categoryName": "New layer",
"default.subCategoryName": "New sub-layer",
"default.componentName": "New component",
"default.hatchName": "New hatch",
"default.lineStyleName": "New line style",
"default.hatchNone": "None",
"default.copySuffixLevel": "{name} copy",
"default.copySuffixLayer": "{name} COPY",
"default.wallTypeSingle": "Wall {thickness} m",
// Alerts.
"alert.layerInUse": "Layer “{code} {name}” is used by components and cannot be deleted.",
"alert.componentInUse": "Component “{name}” is used by a wall type and cannot be deleted.",
"alert.hatchInUse": "Hatch “{name}” is used by a component and cannot be deleted.",
"alert.lineStyleInUse": "Line style “{name}” is used by a hatch and cannot be deleted.",
// Resource manager.
"resources.title": "Resources",
"resources.label": "Resource manager",
"resources.close": "Close",
"resources.tab.components": "Components",
"resources.tab.hatches": "Hatches",
"resources.tab.lines": "Lines",
"resources.add": "New",
"resources.import.lin": "⤓ Import .lin",
"resources.import.pat": "⤓ Import .pat",
"resources.components.hint": "Higher priority runs through at the join (intersects first); lower butts against the side.",
"resources.components.empty": "No components yet.",
"resources.components.placeholder": "Component",
"resources.col.name": "Name",
"resources.col.color": "Color",
"resources.col.hatch": "Hatch",
"resources.col.prio": "Prio",
"resources.col.prio.hint": "Higher priority runs through at the join",
"resources.col.texture": "Texture",
"resources.delete.component": "Delete component “{name}”",
"resources.hatches.hint": "Pattern, scale and angle drive the section hatch; the line style sets the weight of the pattern lines.",
"resources.hatches.empty": "No hatches yet.",
"resources.hatches.placeholder": "Hatch",
"resources.col.pattern": "Pattern",
"resources.col.scale": "Scale",
"resources.col.angle": "Angle °",
"resources.col.lineStyle": "Line style",
"resources.lineStyle.none": "— none —",
"resources.delete.hatch": "Delete hatch “{name}”",
"pattern.none": "None",
"pattern.solid": "Solid",
"pattern.insulation": "Insulation",
"pattern.diagonal": "Diagonal",
"pattern.crosshatch": "Crosshatch",
"resources.lines.hint": "Line weight in millimetres (paper space). Stroke: solid, dashed or dotted.",
"resources.lines.empty": "No line styles yet.",
"resources.lines.placeholder": "Line style",
"resources.col.weight": "Weight (mm)",
"resources.col.stroke": "Stroke",
"resources.col.preview": "Preview",
"resources.delete.lineStyle": "Delete line style “{name}”",
"resources.texture.empty": "—",
"dash.solid": "Solid",
"dash.dashed": "Dashed",
"dash.dotted": "Dotted",
// Command line / commands (Rhino-style command system).
"cmd.aria": "Command line",
"cmd.idle.prompt": "Command:",
"cmd.idle.placeholder": "Type a command … (Tab)",
"cmd.line.label": "Line",
"cmd.line.start": "Start of line:",
"cmd.line.end": "End of line:",
"cmd.polyline.label": "Polyline",
"cmd.polyline.start": "Start point:",
"cmd.polyline.next": "Next point:",
"cmd.polyline.close": "Close",
"cmd.polyline.undo": "Undo",
"cmd.rect.label": "Rectangle",
"cmd.rect.first": "First corner:",
"cmd.rect.second": "Opposite corner:",
"cmd.circle.label": "Circle",
"cmd.circle.center": "Center:",
"cmd.circle.radius": "Radius:",
"cmd.move.label": "Move",
"cmd.move.base": "Base point:",
"cmd.move.target": "Target point:",
"cmd.move.empty": "Nothing selected (select objects first).",
"cmd.copy.label": "Copy",
"cmd.copy.base": "Base point:",
"cmd.copy.target": "Target point (Enter to finish):",
"cmd.copy.empty": "Nothing selected (select objects first).",
"cmd.offset.label": "Offset",
"cmd.offset.pick": "Select curve:",
"cmd.offset.side": "Side or distance:",
// Edit tools Split / Join / Delete segment (keyboard: Ctrl+S / Ctrl+J /
// Alt+Click). Labels for the command line / menus.
"edit.split.label": "Split",
"edit.join.label": "Join",
"edit.cutSegment.label": "Delete segment",
// Tab field cycle (precision input): ordered numeric fields per step.
"cmd.field.length": "Length",
"cmd.field.angle": "Angle",
"cmd.field.width": "Width",
"cmd.field.height": "Height",
"cmd.field.radius": "Radius",
"cmd.field.distance": "Distance",
// Commands: import / terrain.
"cmd.import.label": "Import",
"cmd.import.prompt": "Choose DXF file …",
"cmd.terrain.label": "Terrain",
"cmd.terrain.prompt": "Generate terrain from contours …",
// Site / context panel (SitePanel).
"nav.site": "Terrain",
"site.title": "Terrain & Context",
"site.import": "⤓ Import DXF",
"site.generateTerrain": "Generate terrain",
"site.contourSet": "Contour set",
"site.empty": "No context loaded. Import a DXF file (contour lines or mesh).",
"site.delete": "Remove",
"site.type.mesh": "Mesh",
"site.type.contours": "Contours",
"site.type.terrain": "Terrain",
"site.dwgHint": "DWG? Convert it to DXF first with the free ODA File Converter.",
"site.terrainFailed": "No terrain could be generated (too few/collinear contour points).",
// Import dialog.
"import.title": "Import DXF",
"import.close": "Close",
"import.file": "File",
"import.summary.heading": "Contents",
"import.summary.contours": "{n} contours",
"import.summary.meshes": "{n} meshes",
"import.summary.layers": "Layers: {layers}",
"import.summary.noLayers": "no layer names",
"import.summary.empty": "No importable 2D geometry found.",
"import.target.heading": "Target drawing level",
"import.target.existing": "Import into existing drawing level",
"import.target.new": "Create new drawing",
"import.target.newNamePlaceholder": "New drawing name",
"import.layers.heading": "Layer handling (DXF layers)",
"import.layers.asCategories": "Keep DXF layers as categories",
"import.layers.toActive": "Everything onto the active category ({code})",
"import.mesh.heading": "Meshes",
"import.mesh.asContext": "Import meshes as context geometry (3D)",
"import.mesh.note": "Meshes are not 2D drawings — they go into the context layer.",
"import.dwg.heading": "DWG file",
"import.dwg.loading": "Reading DWG …",
"import.dwg.hint":
"DWG could not be read. Alternatively, convert the file to DXF with the free ODA File Converter, then import the DXF.",
"import.confirm": "Import",
"import.cancel": "Cancel",
"import.badge.floor": "Floor",
"import.badge.section": "Section",
"import.badge.elevation": "Elevation",
"import.badge.drawing": "Drawing",
// Drag & drop (app-wide file drop).
"drop.hint": "Drop a DXF/DWG file here",
// Error boundary.
"error.console": "Unexpected error:",
"error.title": "Something went wrong",
"error.body": "An unexpected error occurred. Details in the console.",
"error.reload": "Reload",
};
+94
View File
@@ -0,0 +1,94 @@
// i18n-Schicht — eine einzige Übersetzungsfunktion `t(key, params?)` plus ein
// optionaler React-Hook `useT`. Default-Sprache ist Deutsch; das deutsche
// Wörterbuch (de.ts) hält die EXAKTEN bisherigen UI-Texte, sodass die Umstellung
// optisch nichts ändert. en.ts beweist die Übersetzbarkeit.
//
// Bewusst minimal gehalten: ein modulweiter „current language" + Wörterbuch-
// Lookup mit {param}-Interpolation. Kein Provider-Zwang — t(...) ist überall
// direkt aufrufbar; useT() liefert dieselbe Funktion und re-rendert Komponenten
// beim Sprachwechsel (subscribe), falls je ein Umschalter gebaut wird.
//
// Identifier/Keys bleiben englisch (DOSSIER-Namespacing); nur die Wörterbuch-
// Werte sind übersetzbarer Text.
import { useSyncExternalStore } from "react";
import { de } from "./de";
import { en } from "./en";
import type { TranslationKey } from "./de";
export type { TranslationKey } from "./de";
/** Unterstützte Sprachen. Default ist „de". */
export type Language = "de" | "en";
/** Wörterbücher je Sprache. de ist die Quelle der Keys (Default-Sprache). */
const DICTS: Record<Language, Record<TranslationKey, string>> = { de, en };
/** Werte für die Platzhalter-Interpolation (`{name}` → params.name). */
export type TParams = Record<string, string | number>;
// ── Modulweiter Sprachzustand + Abonnenten (für useT) ──────────────────────
let currentLanguage: Language = "de";
const listeners = new Set<() => void>();
/** Aktuell gewählte Sprache. */
export function getLanguage(): Language {
return currentLanguage;
}
/**
* Sprache umschalten. Benachrichtigt abonnierte Komponenten (useT), damit sie
* mit den neuen Texten neu rendern. (Noch kein UI-Umschalter — die Naht ist
* aber vorhanden und verdrahtet.)
*/
export function setLanguage(lang: Language): void {
if (lang === currentLanguage) return;
currentLanguage = lang;
for (const l of listeners) l();
}
// ── Kern: Lookup + Interpolation ───────────────────────────────────────────
/** Ersetzt `{name}`-Platzhalter im Wert durch die übergebenen Parameter. */
function interpolate(value: string, params?: TParams): string {
if (!params) return value;
return value.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match,
);
}
/**
* Übersetzt einen Key in der aktuellen Sprache mit optionaler {param}-
* Interpolation. Fehlt der Key in der gewählten Sprache, greift Deutsch als
* Fallback; fehlt er auch dort, wird der Key selbst zurückgegeben (sichtbar,
* damit fehlende Texte auffallen statt leer zu bleiben).
*/
export function t(key: TranslationKey | string, params?: TParams): string {
const dict = DICTS[currentLanguage];
const value =
(dict as Record<string, string>)[key] ??
(de as Record<string, string>)[key] ??
key;
return interpolate(value, params);
}
// ── React-Hook ─────────────────────────────────────────────────────────────
/**
* Hook-Variante: liefert dieselbe `t`-Funktion, abonniert aber den
* Sprachzustand, sodass die Komponente beim Sprachwechsel neu rendert. In den
* meisten Komponenten reicht der direkte `t`-Import; useT() ist für den Fall
* gedacht, dass ein Live-Sprachumschalter existiert.
*/
export function useT(): typeof t {
useSyncExternalStore(
(cb) => {
listeners.add(cb);
return () => listeners.delete(cb);
},
getLanguage,
getLanguage,
);
return t;
}
+353
View File
@@ -0,0 +1,353 @@
// DWG-Import → Kontext-Geometrie (three-frei), echtes In-Browser-Parsing.
//
// DWG ist ein geschlossenes Binärformat; wir lesen es hier über LibreDWG-WASM
// (`@mlightcad/libredwg-web`) DIREKT im Browser — KEIN ODA-Zwischenschritt mehr.
// Ergebnis ist dasselbe `{meshes, contours}`-Format wie der DXF-Parser, damit der
// gesamte nachgelagerte Import-Fluss (Dialog, Ziel-Ebene, Layer-Handling,
// Mesh→Kontext) unverändert wiederverwendet wird.
//
// API (aus den .d.ts der Lib abgelesen, NICHT geraten):
// LibreDwg.create() → Promise<LibreDwgEx> (lädt WASM)
// dwg.dwg_read_data(buffer, Dwg_File_Type.DWG) → Dwg_Data-Pointer
// dwg.convert(ptr) → DwgDatabase (stark typisiert)
// dwg.dwg_free(ptr) → Speicher freigeben
// Der WASM-Build dieser Lib hat DXF-Schreiben/-Lesen DEAKTIVIERT (disable-dxf),
// daher gehen wir bewusst NICHT über DWG→DXF-Text, sondern mappen die geparste
// DwgDatabase direkt (ein Mapping, gespiegelt aus dxfParser).
//
// Unterstützte Entities (Modellraum):
// • 3DFACE → Dreiecks-Mesh (3 bzw. 4 Ecken → 12 Tri).
// • POLYLINE2D (Polyface, Flag 64) → Dreiecks-Mesh (Polyface-Faces).
// • LWPOLYLINE / POLYLINE2D / 3D → Kontur (z aus elevation/Vertex-Z).
// • LINE → Kontur (zwei-Punkt-Linienzug).
//
// Lazy: Das Paket wird per dynamischem import() geladen (kein Bloat im Initial-
// Bundle); WASM startet erst beim ersten DWG-Import.
//
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
import type { DxfImportResult } from "./dxfParser";
// Lose getippte Sicht auf die libredwg-web-Entities (wir greifen tolerant auf die
// Felder zu, die wir brauchen; alle Punkte sind {x,y,z}).
interface DwgPoint {
x: number;
y: number;
z?: number;
}
// Polyface-/Polyline-Vertex: Punktkoordinaten + optionale Polyface-Indizes.
interface DwgVertexLike extends DwgPoint {
polyfaceIndex0?: number;
polyfaceIndex1?: number;
polyfaceIndex2?: number;
polyfaceIndex3?: number;
flag?: number;
}
interface DwgEntityLike {
type?: string;
layer?: string;
flag?: number;
elevation?: number;
startPoint?: DwgPoint;
endPoint?: DwgPoint;
corner1?: DwgPoint;
corner2?: DwgPoint;
corner3?: DwgPoint;
corner4?: DwgPoint;
vertices?: DwgVertexLike[];
[k: string]: unknown;
}
// Schmale Sicht auf die LibreDWG-Instanz (nur die Methoden, die wir nutzen).
interface LibreDwgApi {
dwg_read_data(data: ArrayBuffer, fileType: number): number | undefined;
convert(ptr: number): { entities?: unknown[] };
dwg_free(ptr: number): void;
}
// Singleton der WASM-Instanz (einmal laden, dann wiederverwenden).
let libredwgPromise: Promise<LibreDwgApi> | null = null;
/**
* Lädt + initialisiert LibreDWG-WASM (lazy, einmalig). Wir laden die Emscripten-
* Glue + die `.wasm`-URL explizit über Vite (`?url`), damit die WASM-Datei in
* Dev UND Build mit korrektem MIME-Type aufgelöst wird (sonst liefert der Dev-
* Server `index.html` statt der WASM → „Incorrect response MIME type").
*/
function createLibreDwg(): Promise<LibreDwgApi> {
if (!libredwgPromise) {
libredwgPromise = (async () => {
const [{ LibreDwg }, glue, wasmUrlMod] = await Promise.all([
import("@mlightcad/libredwg-web"),
// Glue + WASM über ein lokales virtuelles Modul (Alias in vite.config),
// da das Paket seine `wasm/`-Dateien nicht über `exports` freigibt.
import("virtual:libredwg-glue"),
// Vite gibt die WASM als gehashtes Asset aus und liefert ihre URL.
import("virtual:libredwg-wasm-url"),
]);
const createModule = glue.default;
const wasmUrl = wasmUrlMod.default;
const wasmInstance = await createModule({
// locateFile bekommt den Original-Dateinamen; wir liefern die von Vite
// aufgelöste (gehashte) URL zurück → korrektes Laden in Dev + Build.
locateFile: () => wasmUrl,
});
// createByWasmInstance erwartet das (intern getippte) MainModule; die
// Glue liefert es als unknown → bewusster Cast, Rückgabe schmal getippt.
return LibreDwg.createByWasmInstance(
wasmInstance as Parameters<typeof LibreDwg.createByWasmInstance>[0],
) as unknown as LibreDwgApi;
})();
}
return libredwgPromise;
}
let importSeq = 0;
const nextId = (prefix: string): string => `${prefix}-${Date.now()}-${importSeq++}`;
// POLYLINE-Flag 64 = Polyface-Mesh (siehe DwgPolylineFlag in der Lib).
const POLYFACE_FLAG = 64;
// POLYLINE-Flag 1 = geschlossen.
const CLOSED_FLAG = 1;
/**
* Parst eine DWG-Datei (als ArrayBuffer) in dasselbe Kontext-Geometrie-Format
* wie der DXF-Parser. Lädt LibreDWG-WASM lazy. Wirft bei fatalen Fehlern (der
* Aufrufer fängt das ab und zeigt den ODA-Fallback) — pro-Entity-Fehler nicht.
*/
export async function parseDwg(data: ArrayBuffer): Promise<DxfImportResult> {
const libredwg = await createLibreDwg();
let dwgPtr: number | undefined;
try {
// Dwg_File_Type.DWG === 0 (siehe enums.d.ts der Lib). Literal, damit wir den
// Enum-Wert nicht zusätzlich in den lazy Chunk ziehen.
const FILE_TYPE_DWG = 0;
dwgPtr = libredwg.dwg_read_data(data, FILE_TYPE_DWG);
if (dwgPtr == null) {
throw new Error("LibreDWG: dwg_read_data lieferte keinen Pointer.");
}
const db = libredwg.convert(dwgPtr);
const entities = (db?.entities ?? []) as unknown as DwgEntityLike[];
const meshTriangles: number[] = [];
const meshIndices: number[] = [];
const contours: Contour[] = [];
for (const e of entities) {
const type = (e.type ?? "").toUpperCase();
switch (type) {
case "3DFACE":
addFace(meshTriangles, meshIndices, e);
break;
case "POLYLINE2D":
case "POLYLINE3D":
if (isPolyfaceMesh(e)) {
addPolyfaceMesh(meshTriangles, meshIndices, e);
} else {
const ct = polylineContour(e);
if (ct) contours.push(ct);
}
break;
case "LWPOLYLINE": {
const ct = polylineContour(e);
if (ct) contours.push(ct);
break;
}
case "LINE": {
const ct = lineContour(e);
if (ct) contours.push(ct);
break;
}
default:
// Unbekannte/irrelevante Entity → ignorieren (tolerant).
break;
}
}
const meshes: ImportedMesh[] = [];
if (meshIndices.length > 0) {
meshes.push({
id: nextId("imported"),
type: "importedMesh",
name: "DWG-Mesh",
positions: meshTriangles,
indices: meshIndices,
});
}
const contourSets: ContourSet[] = [];
if (contours.length > 0) {
contourSets.push({
id: nextId("contours"),
type: "contourSet",
name: "DWG-Konturen",
contours,
});
}
return { meshes, contours: contourSets };
} finally {
// Speicher der WASM-Instanz freigeben (auch im Fehlerfall).
if (dwgPtr != null) {
try {
libredwg.dwg_free(dwgPtr);
} catch {
// Freigabe-Fehler ignorieren — nicht fatal.
}
}
}
}
// ── Mesh-Entities ────────────────────────────────────────────────────────────
/** Fügt einen Vertex zu positions hinzu und liefert seinen Index. */
function pushVertex(
positions: number[],
x: number,
y: number,
z: number,
): number {
const idx = positions.length / 3;
positions.push(x, y, z);
return idx;
}
/**
* 3DFACE: 3 oder 4 Eckpunkte (corner1..4). Bei 4 ≠ 3 Punkten zwei Dreiecke (Fan).
*/
function addFace(
positions: number[],
indices: number[],
e: DwgEntityLike,
): void {
const corners = [e.corner1, e.corner2, e.corner3, e.corner4].filter(
(c): c is DwgPoint => !!c && Number.isFinite(c.x) && Number.isFinite(c.y),
);
if (corners.length < 3) return;
const idx = corners.map((c) =>
pushVertex(positions, c.x, c.y, c.z ?? 0),
);
pushTri(indices, positions, idx[0], idx[1], idx[2]);
if (idx.length >= 4) pushTri(indices, positions, idx[0], idx[2], idx[3]);
}
/** Ob eine POLYLINE eine Polyface-Mesh-Variante ist (Flag 64 oder Polyface-Indizes). */
function isPolyfaceMesh(e: DwgEntityLike): boolean {
if (((e.flag ?? 0) & POLYFACE_FLAG) !== 0) return true;
return (e.vertices ?? []).some(
(v) =>
Number.isFinite(v.polyfaceIndex0) ||
Number.isFinite(v.polyfaceIndex1) ||
Number.isFinite(v.polyfaceIndex2) ||
Number.isFinite(v.polyfaceIndex3),
);
}
/**
* Polyface-Mesh (POLYLINE, Flag 64): Geometrie-Vertices (mit Koordinaten, keine
* Polyface-Indizes) und Face-Records (mit 1-basierten, ggf. negativen = unsicht-
* bare Kante Polyface-Indizes). Wir trennen beide und fan-triangulieren jede Face.
*/
function addPolyfaceMesh(
positions: number[],
indices: number[],
e: DwgEntityLike,
): void {
const all = e.vertices ?? [];
const hasFaceIdx = (v: DwgVertexLike): boolean =>
!!(
v.polyfaceIndex0 ||
v.polyfaceIndex1 ||
v.polyfaceIndex2 ||
v.polyfaceIndex3
);
const geom = all.filter((v) => !hasFaceIdx(v));
const faceRecs = all.filter((v) => hasFaceIdx(v));
if (geom.length < 3) return;
const base = positions.length / 3;
for (const v of geom) positions.push(v.x, v.y, v.z ?? 0);
for (const fr of faceRecs) {
// 1-basierte Indizes; Vorzeichen markiert (un)sichtbare Kanten → abs().
const ring = [
fr.polyfaceIndex0,
fr.polyfaceIndex1,
fr.polyfaceIndex2,
fr.polyfaceIndex3,
]
.map((i) => Math.abs(i ?? 0))
.filter((i) => i >= 1)
.map((i) => base + (i - 1));
if (ring.length < 3) continue;
for (let i = 1; i + 1 < ring.length; i++) {
pushTri(indices, positions, ring[0], ring[i], ring[i + 1]);
}
}
}
/**
* Fügt ein Dreieck hinzu, sofern es nicht entartet ist (gleiche/ungültige
* Indizes). Hält das importierte Mesh sauber.
*/
function pushTri(
indices: number[],
positions: number[],
a: number,
b: number,
c: number,
): void {
if (a === b || b === c || a === c) return;
const n = positions.length / 3;
if (a < 0 || b < 0 || c < 0 || a >= n || b >= n || c >= n) return;
indices.push(a, b, c);
}
// ── Kontur-Entities ──────────────────────────────────────────────────────────
/**
* LWPOLYLINE / POLYLINE2D / POLYLINE3D → Kontur. Z-Höhe: erster gültiger
* Vertex-Z, sonst `elevation`, sonst 0. `closed` aus Flag 1. < 2 Punkte → null.
*/
function polylineContour(e: DwgEntityLike): Contour | null {
const vs = e.vertices ?? [];
const pts: Vec2[] = [];
let zFromVertex: number | undefined;
for (const v of vs) {
if (!Number.isFinite(v.x) || !Number.isFinite(v.y)) continue;
pts.push({ x: v.x, y: v.y });
if (zFromVertex === undefined && Number.isFinite(v.z)) zFromVertex = v.z;
}
if (pts.length < 2) return null;
const z =
zFromVertex ??
(Number.isFinite(e.elevation) ? (e.elevation as number) : 0);
const closed = ((e.flag ?? 0) & CLOSED_FLAG) !== 0;
return { z, pts, closed, layer: e.layer };
}
/** LINE → zweipunktige (offene) Kontur. Z aus dem Startpunkt (sonst 0). */
function lineContour(e: DwgEntityLike): Contour | null {
const a = e.startPoint;
const b = e.endPoint;
if (
!a ||
!b ||
!Number.isFinite(a.x) ||
!Number.isFinite(a.y) ||
!Number.isFinite(b.x) ||
!Number.isFinite(b.y)
) {
return null;
}
const z = Number.isFinite(a.z) ? (a.z as number) : 0;
return {
z,
pts: [
{ x: a.x, y: a.y },
{ x: b.x, y: b.y },
],
closed: false,
layer: e.layer,
};
}
+22
View File
@@ -0,0 +1,22 @@
// Kleiner Modul-Hook, der das (in App montierte) versteckte DXF-Datei-Input
// mit dem `import`-Befehl entkoppelt verbindet. App registriert beim Mount eine
// Trigger-Funktion (öffnet den Datei-Dialog); der Befehl ruft sie auf, ohne die
// Command-Engine an React/DOM zu koppeln. So bleibt der Import-Befehl additiv.
//
// Bewusst KEIN window-Global: ein Modul-Singleton genügt (eine App-Instanz).
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
type DxfImportTrigger = () => void;
let trigger: DxfImportTrigger | null = null;
/** App registriert hier den Datei-Dialog-Öffner (oder hebt ihn beim Unmount auf). */
export function setDxfImportTrigger(fn: DxfImportTrigger | null): void {
trigger = fn;
}
/** Öffnet den DXF-Datei-Dialog, falls App ihn registriert hat. */
export function openDxfImport(): void {
trigger?.();
}
+295
View File
@@ -0,0 +1,295 @@
// DXF-Import → Kontext-Geometrie (three-frei).
//
// Importierte Geometrie ist „dumme" KONTEXT-Geometrie (Anzeige + späteres
// Snap-Ziel), NICHT semantisch. Dieser Parser liefert nur rohe Buffer-Daten
// (positions/indices) bzw. Konturen (Vec2 + Z), die in `Project.context` landen.
//
// ── DWG-Hinweis (bewusst KEIN DWG-Parser) ──────────────────────────────────
// DWG ist ein geschlossenes Binärformat ohne robusten Pure-JS-Parser; wir bauen
// hier KEINEN DWG-Parser. Der Import-Weg führt über DXF:
// DWG → DXF konvertieren (gratis „ODA File Converter" von der Open Design
// Alliance), dann die DXF-Datei hier importieren.
// LibreDWG-WASM ist nicht zuverlässig genug und wird bewusst nicht verfolgt.
//
// Unterstützte DXF-Entities:
// • 3DFACE → Dreiecks-Mesh (3 bzw. 4 Ecken → 12 Tri).
// • POLYLINE (Polyface/PolygonMesh)→ Dreiecks-Mesh (Faces bzw. Gitter).
// • MESH → Dreiecks-Mesh (Vertices + Face-Liste).
// • LWPOLYLINE / POLYLINE (2D/3D) → Kontur (z aus elevation/Vertex-Z).
// • LINE → Kontur (zwei-Punkt-Linienzug).
import DxfParser from "dxf-parser";
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze. */
export interface DxfImportResult {
meshes: ImportedMesh[];
contours: ContourSet[];
}
// Lose getippte Sicht auf die dxf-parser-Ausgabe (das Paket liefert keine engen
// Typen); wir greifen tolerant auf die Felder zu, die wir brauchen.
interface DxfVertex {
x?: number;
y?: number;
z?: number;
// Polyface-Mesh: 1-basierte, ggf. negative Vertex-Indizes (negativ = unsichtbare Kante).
faces?: number[];
}
interface DxfEntity {
type?: string;
layer?: string;
elevation?: number;
vertices?: DxfVertex[];
// MESH-Entity-Felder (variieren je nach Parser-Version):
position?: DxfVertex[];
faces?: number[][];
isPolyfaceMesh?: boolean;
is3dPolygonMesh?: boolean;
[k: string]: unknown;
}
interface DxfDocument {
entities?: DxfEntity[];
}
let importSeq = 0;
const nextId = (prefix: string) =>
`${prefix}-${Date.now()}-${importSeq++}`;
/**
* Parst DXF-Text in Kontext-Geometrie. Tolerant gegen fehlende Felder; nicht
* unterstützte/leere Entities werden übersprungen. Ein fataler Parse-Fehler wird
* geworfen (der Aufrufer kann ihn anzeigen) — pro-Entity-Fehler nicht.
*/
export function parseDxf(text: string): DxfImportResult {
const parser = new DxfParser();
// parseSync wirft bei strukturell kaputtem DXF; das soll nach oben.
const doc = parser.parseSync(text) as unknown as DxfDocument;
const entities = doc?.entities ?? [];
const meshTriangles: number[] = []; // gesammelte Mesh-Positions (x,y,z…)
const meshIndices: number[] = [];
const contours: Contour[] = [];
for (const e of entities) {
const type = (e.type ?? "").toUpperCase();
switch (type) {
case "3DFACE":
addFace(meshTriangles, meshIndices, e);
break;
case "MESH":
addMesh(meshTriangles, meshIndices, e);
break;
case "POLYLINE":
// POLYLINE ist mehrdeutig: Polyface/PolygonMesh → Mesh; sonst → Kontur.
if (isMeshPolyline(e)) {
addPolyfaceMesh(meshTriangles, meshIndices, e);
} else {
const ct = polylineContour(e);
if (ct) contours.push(ct);
}
break;
case "LWPOLYLINE": {
const ct = polylineContour(e);
if (ct) contours.push(ct);
break;
}
case "LINE": {
const ct = lineContour(e);
if (ct) contours.push(ct);
break;
}
default:
// Unbekannte/irrelevante Entity → ignorieren (tolerant).
break;
}
}
const meshes: ImportedMesh[] = [];
if (meshIndices.length > 0) {
meshes.push({
id: nextId("imported"),
type: "importedMesh",
name: "DXF-Mesh",
positions: meshTriangles,
indices: meshIndices,
});
}
const contourSets: ContourSet[] = [];
if (contours.length > 0) {
contourSets.push({
id: nextId("contours"),
type: "contourSet",
name: "DXF-Konturen",
contours,
});
}
return { meshes, contours: contourSets };
}
// ── Mesh-Entities ────────────────────────────────────────────────────────────
/** Fügt einen Vertex zu positions hinzu und liefert seinen Index. */
function pushVertex(
positions: number[],
x: number,
y: number,
z: number,
): number {
const idx = positions.length / 3;
positions.push(x, y, z);
return idx;
}
/**
* 3DFACE: 3 oder 4 Eckpunkte. Bei 4 Punkten zwei Dreiecke (Fan). Entartete
* (degenerierte) Punkte (4. == 3.) ergeben nur ein Dreieck.
*/
function addFace(positions: number[], indices: number[], e: DxfEntity): void {
const vs = (e.vertices ?? []).filter(
(v) => Number.isFinite(v.x) && Number.isFinite(v.y),
);
if (vs.length < 3) return;
const idx = vs.map((v) =>
pushVertex(positions, v.x ?? 0, v.y ?? 0, v.z ?? 0),
);
// Dreieck (0,1,2).
pushTri(indices, positions, idx[0], idx[1], idx[2]);
// Viereck → zweites Dreieck (0,2,3), falls 4. Punkt vorhanden und ≠ 3.
if (idx.length >= 4) pushTri(indices, positions, idx[0], idx[2], idx[3]);
}
/**
* MESH-Entity: explizite Vertex-Liste + Face-Liste. Die Face-Liste kann Drei-
* oder Vierecke enthalten (jeweils 0-basierte Vertex-Indizes); Vierecke werden
* fan-trianguliert.
*/
function addMesh(positions: number[], indices: number[], e: DxfEntity): void {
const verts = e.position ?? e.vertices ?? [];
if (verts.length === 0) return;
const base = positions.length / 3;
for (const v of verts) {
positions.push(v.x ?? 0, v.y ?? 0, v.z ?? 0);
}
const faces = Array.isArray(e.faces) ? e.faces : [];
for (const face of faces) {
if (!Array.isArray(face) || face.length < 3) continue;
for (let i = 1; i + 1 < face.length; i++) {
pushTri(
indices,
positions,
base + face[0],
base + face[i],
base + face[i + 1],
);
}
}
}
/** Ob eine POLYLINE eine Mesh-Variante ist (Polyface- oder Polygon-Gitter). */
function isMeshPolyline(e: DxfEntity): boolean {
if (e.isPolyfaceMesh === true || e.is3dPolygonMesh === true) return true;
// Heuristik: enthält irgendein Vertex eine `faces`-Liste → Polyface-Mesh.
return (e.vertices ?? []).some(
(v) => Array.isArray(v.faces) && v.faces.length > 0,
);
}
/**
* Polyface-Mesh (POLYLINE, Flag 64): erst Geometrie-Vertices, dann Face-Records
* mit 1-basierten (ggf. negativen = unsichtbare Kante) Vertex-Indizes. Wir
* trennen Geometrie- von Face-Vertices und fan-triangulieren jede Face.
*/
function addPolyfaceMesh(
positions: number[],
indices: number[],
e: DxfEntity,
): void {
const all = e.vertices ?? [];
const geom = all.filter((v) => !(Array.isArray(v.faces) && v.faces.length));
const faceRecs = all.filter((v) => Array.isArray(v.faces) && v.faces.length);
if (geom.length < 3) return;
const base = positions.length / 3;
for (const v of geom) {
positions.push(v.x ?? 0, v.y ?? 0, v.z ?? 0);
}
for (const fr of faceRecs) {
// 1-basierte Indizes; Vorzeichen markiert (un)sichtbare Kanten → abs().
const ring = (fr.faces ?? [])
.map((i) => Math.abs(i))
.filter((i) => i >= 1)
.map((i) => base + (i - 1));
if (ring.length < 3) continue;
for (let i = 1; i + 1 < ring.length; i++) {
pushTri(indices, positions, ring[0], ring[i], ring[i + 1]);
}
}
}
/**
* Fügt ein Dreieck hinzu, sofern es nicht entartet ist (zwei gleiche Indizes
* oder kollineare/Null-Flächen-Ecken). Hält das importierte Mesh sauber.
*/
function pushTri(
indices: number[],
positions: number[],
a: number,
b: number,
c: number,
): void {
if (a === b || b === c || a === c) return;
const n = positions.length / 3;
if (a < 0 || b < 0 || c < 0 || a >= n || b >= n || c >= n) return;
indices.push(a, b, c);
}
// ── Kontur-Entities ──────────────────────────────────────────────────────────
/**
* LWPOLYLINE / 2D-/3D-POLYLINE → Kontur. Z-Höhe: bei 3D-Polylinie aus den
* Vertex-Z (erster gültiger), sonst aus `elevation`, sonst 0. Die 2D-Punkte sind
* (x,y) jedes Vertex. Bei < 2 Punkten wird nichts erzeugt.
*/
function polylineContour(e: DxfEntity): Contour | null {
const vs = e.vertices ?? [];
const pts: Vec2[] = [];
let zFromVertex: number | undefined;
for (const v of vs) {
if (!Number.isFinite(v.x) || !Number.isFinite(v.y)) continue;
pts.push({ x: v.x ?? 0, y: v.y ?? 0 });
if (zFromVertex === undefined && Number.isFinite(v.z)) zFromVertex = v.z;
}
if (pts.length < 2) return null;
const z = zFromVertex ?? (Number.isFinite(e.elevation) ? (e.elevation as number) : 0);
// closed-Flag aus dem Parser ableiten (LWPOLYLINE: `shape`, POLYLINE: `shape`).
const closed = e.shape === true || e.closed === true;
return { z, pts, closed, layer: e.layer };
}
/** LINE → zweipunktige (offene) Kontur. Z aus dem Start-Vertex (sonst 0). */
function lineContour(e: DxfEntity): Contour | null {
const vs = e.vertices ?? [];
if (vs.length < 2) return null;
const a = vs[0];
const b = vs[1];
if (
!Number.isFinite(a.x) ||
!Number.isFinite(a.y) ||
!Number.isFinite(b.x) ||
!Number.isFinite(b.y)
) {
return null;
}
const z = Number.isFinite(a.z) ? (a.z as number) : 0;
return {
z,
pts: [
{ x: a.x ?? 0, y: a.y ?? 0 },
{ x: b.x ?? 0, y: b.y ?? 0 },
],
closed: false,
layer: e.layer,
};
}
+89
View File
@@ -0,0 +1,89 @@
// DXF-Konturen → semantische 2D-Zeichengeometrie (Drawing2D).
//
// Der DXF-Parser liefert „dumme" Konturen (Vec2 + Z + optionaler Layer-Name).
// Beim Import in eine ZEICHNUNGSEBENE werden daraus echte Drawing2D-Elemente
// (line/polyline). Die Z-Höhe der Kontur wird in 2D bewusst IGNORIERT (eine
// Zeichnungsebene ist flach). Das `closed`-Flag steuert line vs. polyline:
// • genau zwei Punkte, nicht geschlossen → `line`.
// • sonst → `polyline` (mit `closed`-Flag).
//
// Kategorie-Zuordnung (categoryMode):
// • "active" — alles bekommt die aktive Kategorie (activeCategoryCode).
// • "byLayer" — der DXF-Layer-Name wird auf einen Kategorie-Code gemappt.
// `layerToCode` liefert (deterministisch) einen Code je
// Layer-Name; unbekannte/leere Layer fallen auf `fallbackCode`.
//
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
import type { Contour, ContourSet, Drawing2D, Drawing2DGeom } from "../model/types";
/** Wie DXF-Layer auf Kategorien abgebildet werden. */
export type CategoryMode = "active" | "byLayer";
let drawingSeq = 0;
const nextId = (): string => `dxf2d-${Date.now()}-${drawingSeq++}`;
/**
* Wandelt einen Satz Konturen-Sätze in Drawing2D-Elemente für die Ziel-Ebene
* `levelId` um. Bei `categoryMode === "byLayer"` liefert `layerToCode` den
* Kategorie-Code je DXF-Layer-Name; sonst bekommt alles `fallbackCode`.
*/
export function contoursToDrawings(
sets: ContourSet[],
levelId: string,
categoryMode: CategoryMode,
fallbackCode: string,
layerToCode: (layerName: string) => string,
): Drawing2D[] {
const out: Drawing2D[] = [];
for (const set of sets) {
for (const contour of set.contours) {
const geom = contourGeom(contour);
if (!geom) continue;
const layerName = contour.layer ?? set.layer ?? "";
const categoryCode =
categoryMode === "byLayer" && layerName
? layerToCode(layerName)
: fallbackCode;
out.push({
id: nextId(),
type: "drawing2d",
levelId,
categoryCode,
geom,
});
}
}
return out;
}
/** Eine Kontur → 2D-Geometrie (line bei 2 offenen Punkten, sonst polyline). */
function contourGeom(contour: Contour): Drawing2DGeom | null {
const pts = contour.pts;
if (pts.length < 2) return null;
if (pts.length === 2 && !contour.closed) {
return { shape: "line", a: { ...pts[0] }, b: { ...pts[1] } };
}
return {
shape: "polyline",
pts: pts.map((p) => ({ ...p })),
closed: contour.closed,
};
}
/** Liste der unterschiedlichen DXF-Layer-Namen über alle Konturen-Sätze. */
export function distinctLayers(sets: ContourSet[]): string[] {
const seen = new Set<string>();
for (const set of sets) {
for (const contour of set.contours) {
const name = contour.layer ?? set.layer ?? "";
if (name) seen.add(name);
}
}
return [...seen];
}
/** Gesamtzahl der Konturen über alle Sätze (für die Datei-Zusammenfassung). */
export function countContours(sets: ContourSet[]): number {
return sets.reduce((sum, s) => sum + s.contours.length, 0);
}
+19
View File
@@ -0,0 +1,19 @@
// Ambient-Deklarationen für die WASM-Glue-/Asset-Importe von
// @mlightcad/libredwg-web. Das Paket gibt seine `wasm/`-Dateien NICHT über
// `exports` frei, daher laden wir sie über zwei virtuelle Module (Aliase in
// vite.config.ts → echte Dateien im Paket). Hier nur schmale Modul-Stubs.
// (Identifier englisch.)
declare module "virtual:libredwg-glue" {
/** Emscripten-Glue: erzeugt die WASM-Instanz; `locateFile` löst die .wasm-URL. */
const createModule: (opts?: {
locateFile?: (filename: string, scriptDir: string) => string;
}) => Promise<unknown>;
export default createModule;
}
declare module "virtual:libredwg-wasm-url" {
/** Von Vite aufgelöste URL der WASM-Datei (gehashtes Asset im Build). */
const url: string;
export default url;
}
+88
View File
@@ -0,0 +1,88 @@
// Parser für AutoCAD .lin Linientyp-Definitionsdateien.
// Liefert benannte Strichmuster (dash patterns) zur späteren Übernahme in die LineStyle-Bibliothek.
export interface LinPattern {
name: string;
description: string;
dash: number[] | null;
}
// Punkt (0) wird als sehr kurzer Strich dargestellt, da SVG dasharray keine echten Punkte kennt.
const DOT_LENGTH = 0.01;
// Zerlegt eine Musterzeile in Tokens und entfernt eingeklammerte Shape-/Text-Segmente.
function extractNumericSegments(patternLine: string): number[] {
// Klammer-Segmente (z. B. ["TEXT",STYLE,...]) entfernen.
const withoutBrackets = patternLine.replace(/\[[^\]]*\]/g, '');
const tokens = withoutBrackets.split(',');
const numbers: number[] = [];
for (const raw of tokens) {
const token = raw.trim();
if (token === '') continue;
// Alignment-Token überspringen (üblicherweise 'A').
if (/^[A-Za-z]/.test(token)) continue;
const value = Number(token);
if (Number.isFinite(value)) {
numbers.push(value);
}
// Nicht-numerische Tokens werden ignoriert.
}
return numbers;
}
// Wandelt die Roh-Segmente in ein positives alternierendes dash/gap-Array um.
function toDashArray(segments: number[]): number[] | null {
if (segments.length === 0) return null;
const dash: number[] = [];
for (const seg of segments) {
if (seg === 0) {
dash.push(DOT_LENGTH);
} else {
dash.push(Math.abs(seg));
}
}
return dash.length > 0 ? dash : null;
}
export function parseLin(text: string): LinPattern[] {
const result: LinPattern[] = [];
// CRLF/CR vereinheitlichen.
const lines = text.replace(/\r\n?/g, '\n').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line === '') continue;
if (line.startsWith(';')) continue;
if (!line.startsWith('*')) continue;
// Definitionszeile: *NAME,Description
const body = line.slice(1);
const commaIndex = body.indexOf(',');
const name = (commaIndex >= 0 ? body.slice(0, commaIndex) : body).trim();
const description = commaIndex >= 0 ? body.slice(commaIndex + 1).trim() : '';
if (name === '') continue;
// Folgende Musterzeile suchen (Kommentare/Leerzeilen überspringen).
let j = i + 1;
while (j < lines.length) {
const candidate = lines[j].trim();
if (candidate === '' || candidate.startsWith(';')) {
j++;
continue;
}
break;
}
// Keine folgende Zeile oder direkt nächste Definition -> Eintrag überspringen.
if (j >= lines.length || lines[j].trim().startsWith('*')) {
continue;
}
const patternLine = lines[j].trim();
const segments = extractNumericSegments(patternLine);
const dash = toDashArray(segments);
result.push({ name, description, dash });
i = j;
}
return result;
}
+75
View File
@@ -0,0 +1,75 @@
// Parser für AutoCAD .pat Schraffur-Musterdateien.
// Liefert strukturierte Linienfamilien zur späteren Übernahme in die Hatch-Bibliothek.
export interface PatLineFamily {
angle: number; // Grad
x: number;
y: number; // Ursprung
dx: number;
dy: number; // Verschiebung entlang / senkrecht
dashes: number[]; // ggf. leer (durchgezogene Familie)
}
export interface PatPattern {
name: string;
description: string;
families: PatLineFamily[];
}
// Zerlegt eine Familienzeile in numerische Tokens.
function parseNumbers(line: string): number[] {
const tokens = line.split(',');
const numbers: number[] = [];
for (const raw of tokens) {
const token = raw.trim();
if (token === '') continue;
const value = Number(token);
numbers.push(value);
}
return numbers;
}
export function parsePat(text: string): PatPattern[] {
const result: PatPattern[] = [];
// CRLF/CR vereinheitlichen.
const lines = text.replace(/\r\n?/g, '\n').split('\n');
let current: PatPattern | null = null;
for (const rawLine of lines) {
const line = rawLine.trim();
if (line === '') continue;
if (line.startsWith(';')) continue;
if (line.startsWith('*')) {
// Header: *PATTERNNAME, optional description
const body = line.slice(1);
const commaIndex = body.indexOf(',');
const name = (commaIndex >= 0 ? body.slice(0, commaIndex) : body).trim();
const description = commaIndex >= 0 ? body.slice(commaIndex + 1).trim() : '';
current = { name, description, families: [] };
result.push(current);
continue;
}
// Familienzeile nur gültig innerhalb eines Pattern-Blocks.
if (!current) continue;
const numbers = parseNumbers(line);
// Mindestens angle, x, y, dx, dy erforderlich.
if (numbers.length < 5) continue;
if (!numbers.slice(0, 5).every((n) => Number.isFinite(n))) continue;
const [angle, x, y, dx, dy] = numbers;
const dashes: number[] = [];
for (let k = 5; k < numbers.length; k++) {
if (Number.isFinite(numbers[k])) {
dashes.push(numbers[k]);
}
}
current.families.push({ angle, x, y, dx, dy, dashes });
}
return result;
}
+60
View File
@@ -0,0 +1,60 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { ErrorBoundary } from "./ui/ErrorBoundary";
import { getLanguage, setLanguage } from "./i18n";
import type { Language } from "./i18n";
import "./styles.css";
// Sprach-Naht für Werkzeuge/Tests (und einen späteren Umschalter): die i18n-
// Steuerung am window verfügbar machen. Identifier englisch; Werte übersetzbar.
declare global {
interface Window {
__i18n: { getLanguage: () => Language; setLanguage: (lang: Language) => void };
}
}
window.__i18n = { getLanguage, setLanguage };
// ── Native-App-Verhalten (kein Browser-Standard) ─────────────────────────────
// Die App soll sich wie ein natives Programm anfühlen. Diese globalen Hooks
// laufen einmal beim Start (außerhalb von React, am document) — sie greifen
// app-weit und stören die eigenen React-Handler (z. B. unser ContextMenu) nicht.
/** Prüft, ob ein Element eine echte Texteingabe ist (Input/Textarea/CE). */
const isTextEntry = (el: EventTarget | null): boolean => {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
return (
tag === "INPUT" ||
tag === "TEXTAREA" ||
el.isContentEditable
);
};
// 1) Browser-Kontextmenü global unterdrücken. Unser eigenes ContextMenu wird
// über React-onContextMenu-Handler geöffnet (die preventDefault separat
// aufrufen); auf Flächen ohne eigenes Menü passiert hier nur das Verhindern
// des Browser-Menüs — sonst nichts.
document.addEventListener("contextmenu", (e) => e.preventDefault());
// 2) Ctrl/Cmd+A („Alles markieren") außerhalb echter Eingaben unterbinden.
// In Eingaben bleibt es das normale Markieren des Feldinhalts.
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === "a" || e.key === "A")) {
if (!isTextEntry(document.activeElement)) e.preventDefault();
}
});
// 3) Browser-Drag-Gesten unterbinden (Bild-/Element-Drag, Text-Drag). Echte
// Eingaben bleiben unberührt, damit Auswahl-Drag im Feld weiter funktioniert.
document.addEventListener("dragstart", (e) => {
if (!isTextEntry(e.target)) e.preventDefault();
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
);
+110
View File
@@ -0,0 +1,110 @@
// Geometrie-Helfer für das Gebäudemodell. Reine 2D/3D-Mathematik,
// kein externer Kernel nötig (Phase 0).
import type { Vec2 } from "./types";
export const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
export const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y });
export const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s });
export const len = (a: Vec2): number => Math.hypot(a.x, a.y);
export const normalize = (a: Vec2): Vec2 => {
const l = len(a) || 1;
return { x: a.x / l, y: a.y / l };
};
/** Linke Normale (90° gegen den Uhrzeigersinn gedreht). */
export const leftNormal = (a: Vec2): Vec2 => ({ x: -a.y, y: a.x });
/** Kreuzprodukt (Z-Komponente) zweier 2D-Vektoren. */
export const cross = (p: Vec2, q: Vec2): number => p.x * q.y - p.y * q.x;
/** Eine unendliche Gerade als Stützpunkt + Richtung. */
export interface Line {
point: Vec2;
dir: Vec2;
}
/**
* Schnittpunkt der Geraden (a + t·da) mit (b + s·db).
* Liefert null bei (nahezu) parallelen Richtungen.
*/
export function lineIntersect(
a: Vec2,
da: Vec2,
b: Vec2,
db: Vec2,
): Vec2 | null {
const denom = cross(da, db);
if (Math.abs(denom) < 1e-9) return null; // parallel → kein Schnitt
const t = cross(sub(b, a), db) / denom;
return add(a, scale(da, t));
}
/** Punkt entlang einer Strecke p1→p2 im Abstand d vom Start. */
export const along = (p1: Vec2, p2: Vec2, d: number): Vec2 =>
add(p1, scale(normalize(sub(p2, p1)), d));
/**
* Die vier Eckpunkte eines Bandes, das quer zur Normalen zwischen den
* Offsets offA und offB liegt (gemessen entlang der linken Normalen n).
* Reihenfolge: start+n*offA, end+n*offA, end+n*offB, start+n*offB.
*/
export function wallBand(
start: Vec2,
end: Vec2,
offA: number,
offB: number,
): [Vec2, Vec2, Vec2, Vec2] {
const u = normalize(sub(end, start));
const n = leftNormal(u);
return [
add(start, scale(n, offA)),
add(end, scale(n, offA)),
add(end, scale(n, offB)),
add(start, scale(n, offB)),
];
}
/** Die vier Eckpunkte einer Wand als Polygon (gegen den Uhrzeigersinn). */
export function wallCorners(
start: Vec2,
end: Vec2,
thickness: number,
): [Vec2, Vec2, Vec2, Vec2] {
const h = thickness / 2;
return wallBand(start, end, h, -h);
}
/**
* Wie {@link wallBand}, aber die Bandenden werden bei Bedarf an Geh­rungs­linien
* (startCut / endCut) abgeschnitten statt rechtwinklig gekappt. Jede der beiden
* Bandkanten (bei Offset offA bzw. offB, Richtung u) wird mit der jeweiligen
* Schnittlinie verschnitten; fehlt eine Schnittlinie, bleibt das Ende
* rechtwinklig (Punkt auf der Achssenkrechten).
* Reihenfolge bleibt konsistent zu wallBand: [A.start, A.end, B.end, B.start].
*/
export function clippedBand(
start: Vec2,
end: Vec2,
offA: number,
offB: number,
startCut: Line | null,
endCut: Line | null,
): [Vec2, Vec2, Vec2, Vec2] {
const u = normalize(sub(end, start));
const n = leftNormal(u);
const edgeStart = (off: number): Vec2 => {
const base = add(start, scale(n, off));
if (!startCut) return base;
return lineIntersect(base, u, startCut.point, startCut.dir) ?? base;
};
const edgeEnd = (off: number): Vec2 => {
const base = add(end, scale(n, off));
if (!endCut) return base;
return lineIntersect(base, u, endCut.point, endCut.dir) ?? base;
};
return [edgeStart(offA), edgeEnd(offA), edgeEnd(offB), edgeStart(offB)];
}
+141
View File
@@ -0,0 +1,141 @@
// Wand-Verschneidung (Gehrung): an einer Ecke, wo zwei Wände aufeinander­
// treffen, sollen sich die Schicht-Bänder nicht überlappen, sondern an einer
// gemeinsamen Gehrungslinie sauber stoßen. Dieses Modul berechnet pro Wand
// die optionalen Schnittlinien an Start- und Endpunkt.
import type { Project, Vec2, Wall } from "./types";
import { getWallType, wallTypeThickness } from "./types";
import { wallReferenceOffset } from "./wall";
import {
add,
leftNormal,
len,
lineIntersect,
normalize,
scale,
sub,
} from "./geometry";
import type { Line } from "./geometry";
/** Schnittlinien einer Wand an ihren beiden Achsenden. */
export interface WallCuts {
startCut: Line | null;
endCut: Line | null;
}
/** Rundet eine Koordinate auf ein Gitter, um Endpunkte robust zu gruppieren. */
const roundKey = (p: Vec2): string => {
const r = (v: number) => Math.round(v * 1e4) / 1e4;
return `${r(p.x)},${r(p.y)}`;
};
interface WallEnd {
wallId: string;
end: "start" | "end";
}
/**
* Berechnet für jede Wand die Gehrungs-Schnittlinien.
* Nur L-Ecken (genau zwei Wandenden treffen sich) werden behandelt; freie
* Enden und T-/X-Stöße bleiben rechtwinklig (siehe Kommentare unten).
*
* `walls` ist die bereits gefilterte Wandmenge (z. B. ein Geschoss); die
* Gehrung wird nur innerhalb dieser Menge gebildet.
*/
export function computeJoins(
project: Project,
walls: Wall[],
): Map<string, WallCuts> {
const byId = new Map(walls.map((w) => [w.id, w]));
const result = new Map<string, WallCuts>();
for (const w of walls) result.set(w.id, { startCut: null, endCut: null });
// Knotenkarte: gerundeter Endpunkt → Liste der dort endenden Wandenden.
const junctions = new Map<string, WallEnd[]>();
const push = (p: Vec2, we: WallEnd) => {
const key = roundKey(p);
const list = junctions.get(key);
if (list) list.push(we);
else junctions.set(key, [we]);
};
for (const w of walls) {
push(w.start, { wallId: w.id, end: "start" });
push(w.end, { wallId: w.id, end: "end" });
}
for (const [, ends] of junctions) {
// Freies Ende → kein Schnitt.
if (ends.length === 1) continue;
// T-/X-Stöße (>2 Enden): vorerst rechtwinklig lassen (Folge-Arbeit).
if (ends.length !== 2) continue;
const a = byId.get(ends[0].wallId)!;
const b = byId.get(ends[1].wallId)!;
const cut = miterLine(project, a, ends[0].end, b);
if (!cut) continue; // kollinear → kein Schnitt
setCut(result, ends[0], cut);
setCut(result, ends[1], cut);
}
return result;
}
/** Trägt eine Schnittlinie am passenden Ende einer Wand ein. */
function setCut(result: Map<string, WallCuts>, we: WallEnd, cut: Line): void {
const cuts = result.get(we.wallId)!;
if (we.end === "start") cuts.startCut = cut;
else cuts.endCut = cut;
}
/** Achsrichtung start→end, normalisiert. */
const dirOf = (w: Wall): Vec2 => normalize(sub(w.end, w.start));
/**
* Gemeinsame Gehrungslinie zweier Wände A, B, die sich im Knoten J treffen.
* Robust gegen beliebige Wicklung und ungleiche Dicken: A's Außenfläche wird
* mit der NÄCHSTGELEGENEN Fläche von B verschnitten, A's Innenfläche mit der
* jeweils anderen. Die Gerade durch beide Eckpunkte ist die Gehrung.
*/
function miterLine(
project: Project,
a: Wall,
aEnd: "start" | "end",
b: Wall,
): Line | null {
const j = aEnd === "start" ? a.start : a.end;
const tA = wallTypeThickness(getWallType(project, a));
const tB = wallTypeThickness(getWallType(project, b));
const uA = dirOf(a);
const uB = dirOf(b);
const nA = leftNormal(uA);
const nB = leftNormal(uB);
// Referenzlinien-Versatz: liegt die Achse nicht mittig, sind die beiden
// Wandflächen um diesen Betrag entlang +n verschoben (außen T/2+off, innen
// +T/2+off). Für „center" (Default) ist off=0 → unverändert.
const offA = wallReferenceOffset(a, tA);
const offB = wallReferenceOffset(b, tB);
// Flächen-Stützpunkte am Knoten (linke/rechte Wandseite), inkl. Referenzversatz.
const pLA = add(j, scale(nA, offA + tA / 2));
const pRA = add(j, scale(nA, offA - tA / 2));
const pLB = add(j, scale(nB, offB + tB / 2));
const pRB = add(j, scale(nB, offB - tB / 2));
// Für A's linke Fläche die nähere B-Fläche wählen; A's rechte bekommt die andere.
const lbCloser =
len(sub(pLA, pLB)) <= len(sub(pLA, pRB));
const bForLeft = lbCloser ? pLB : pRB;
const bForRight = lbCloser ? pRB : pLB;
const c1 = lineIntersect(pLA, uA, bForLeft, uB);
const c2 = lineIntersect(pRA, uA, bForRight, uB);
if (!c1 || !c2) return null;
const dir = sub(c2, c1);
if (len(dir) < 1e-9) return null;
return { point: c1, dir };
}
+182
View File
@@ -0,0 +1,182 @@
import type { Project } from "./types";
import { recomputeFloorElevations } from "./types";
// Demo-Haus mit zwei Geschossen:
// EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand.
// OG — rechteckiger Raum (5 × 3 m) ohne Tür.
// Maße in Metern. Die Außenwand ist ein realistischer SIA-naher
// Schichtaufbau (außen → innen).
//
// Zwei UNABHÄNGIGE Achsen (Dokumentmodell nach DOSSIER):
// • drawingLevels — Geschosse (EG, OG) + Platzhalter Schnitt/Ansicht.
// • layers — Grafik-Kategorie-Baum (Codes/Namen/Farben/lw 1:1 aus DOSSIER,
// DEFAULT_LAYER_SCHEMA in launcher/src/App.jsx). 21 Türen/Fenster ist hier
// als Kind von 20 Wände gehängt.
export const sampleProject: Project = {
id: "demo",
name: "Demo-Haus",
// ── Ressourcen-Bibliotheken (zentral verwiesen per id) ──────────────────
// Linienstile: Strichstärken aus DEFAULT_LAYER_SCHEMA (lw in mm). "hatch-line"
// ist der dünne Strich für Schraffur-Muster.
lineStyles: [
{ id: "thin", name: "Dünn 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
{ id: "medium", name: "Mittel 0.25", weight: 0.25, color: "#1a1a1a", dash: null },
{ id: "thick", name: "Stark 0.5", weight: 0.5, color: "#0a0a0a", dash: null },
// Schraffurlinie = schwarze Haarlinie (Grundeinstellung für alle Schraffuren).
{ id: "hatch-line", name: "Schraffurlinie", weight: 0.13, color: "#1a1a1a", dash: null },
],
// Schraffuren: color = Linien-/Vollfüllfarbe des Musters; angle in Grad.
// "none" = keine Schraffur (nur Component-Füllung).
// Grundeinstellung aller Linien-Schraffuren: schwarze Haarlinie (color) auf
// weißem Grund (die zugehörigen Bauteile tragen weiße Füllfarbe).
hatches: [
{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#1a1a1a" },
{
id: "insulation",
name: "Wärmedämmung",
pattern: "insulation",
scale: 1,
angle: 0,
color: "#1a1a1a",
lineStyleId: "hatch-line",
},
{
id: "diagonal",
name: "Diagonal",
pattern: "diagonal",
scale: 1,
angle: 45,
color: "#1a1a1a",
lineStyleId: "hatch-line",
},
{
id: "crosshatch",
name: "Kreuzschraffur",
pattern: "crosshatch",
scale: 1,
angle: 0,
color: "#1a1a1a",
lineStyleId: "hatch-line",
},
{
id: "solid-concrete",
name: "Beton (voll)",
pattern: "solid",
scale: 1,
angle: 0,
color: "#9aa0a6",
lineStyleId: "thin",
},
],
// Bauteil-Materialien: color = Poché-Füllung im Grundriss UND 3D-Diffusfarbe;
// joinPriority höher = läuft am Stoß durch (für spätere T-Stöße).
components: [
{ id: "render-ext", name: "Aussenputz", color: "#d8d2c7", hatchId: "none", joinPriority: 10 },
// Weißer Grund unter der schwarzen Dämmungs-Haarlinie (Schraffur-Grundeinstellung).
{ id: "insulation", name: "Wärmedämmung", color: "#ffffff", hatchId: "insulation", joinPriority: 20 },
{ id: "brick", name: "Backstein", color: "#8c5544", hatchId: "none", joinPriority: 50 },
{ id: "render-int", name: "Innenputz", color: "#efece6", hatchId: "none", joinPriority: 10 },
// Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität).
{ id: "concrete", name: "Beton", color: "#9aa0a6", hatchId: "solid-concrete", joinPriority: 100 },
],
wallTypes: [
{
id: "aw",
name: "Aussenwand 34.5 cm",
// Schichten außen → innen; Priorität lebt jetzt am Component.
layers: [
{ componentId: "render-ext", thickness: 0.02 },
{ componentId: "insulation", thickness: 0.16 },
{ componentId: "brick", thickness: 0.15 },
{ componentId: "render-int", thickness: 0.015 },
],
},
],
// Oberste Schnitte: Geschosse + Schnitt/Ansicht + freie Zeichnung.
// baseElevation wird über recomputeFloorElevations gestapelt: EG=0, OG=2.6.
drawingLevels: recomputeFloorElevations([
{ id: "eg", name: "Erdgeschoss", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0 },
{ id: "og", name: "Obergeschoss", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0 },
{
id: "schnitt-a",
name: "Schnitt A",
kind: "section",
visible: true,
locked: false,
linePoints: [{ x: -1, y: 2 }, { x: 6, y: 2 }],
directionSign: 1,
},
{ id: "ansicht-sued", name: "Ansicht Süd", kind: "elevation", visible: true, locked: false },
{ id: "detail-1", name: "Detail 1", kind: "drawing", visible: true, locked: false },
]),
// Grafik-Kategorie-Baum (DOSSIER-Codes 1:1).
layers: [
{ code: "00", name: "Raster", color: "#484850", lw: 0.13, visible: true, locked: false },
{ code: "01", name: "Vermessung", color: "#707078", lw: 0.18, visible: true, locked: false },
{ code: "10", name: "Situation", color: "#909090", lw: 0.18, visible: true, locked: false },
{ code: "11", name: "Strasse", color: "#a89070", lw: 0.18, visible: true, locked: false },
{ code: "12", name: "Gebäude", color: "#888888", lw: 0.25, visible: true, locked: false },
{ code: "13", name: "Bäume", color: "#50a050", lw: 0.13, visible: true, locked: false },
{ code: "14", name: "Höhenlinien", color: "#909050", lw: 0.18, visible: true, locked: false },
{
code: "20",
name: "Wände",
color: "#0a0a0a",
lw: 0.5,
visible: true,
locked: false,
children: [
{ code: "21", name: "Türen/Fenster", color: "#5080c8", lw: 0.25, visible: true, locked: false },
{ code: "22", name: "Möbel", color: "#909090", lw: 0.13, visible: true, locked: false },
{ code: "25", name: "Stützen", color: "#c87050", lw: 0.5, visible: true, locked: false },
],
},
{ code: "30", name: "Decken", color: "#605850", lw: 0.35, visible: true, locked: false },
{ code: "31", name: "Dächer", color: "#7a4a3a", lw: 0.35, visible: true, locked: false },
{ code: "35", name: "Träger", color: "#a87858", lw: 0.5, visible: true, locked: false },
{ code: "50", name: "Text", color: "#d0d0d0", lw: 0.13, visible: true, locked: false },
{ code: "60", name: "Plangrafik", color: "#c0a040", lw: 0.13, visible: true, locked: false },
{ code: "90", name: "Referenzen", color: "#585860", lw: 0.13, visible: true, locked: false },
{ code: "99", name: "Konstruktion", color: "#404048", lw: 0.13, visible: true, locked: false },
],
walls: [
// ── EG: 5 × 4 m Raum (CCW) ──
// Süd (unten)
{ id: "W1", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 0, y: 0 }, end: { x: 5, y: 0 }, wallTypeId: "aw", height: 2.6 },
// Ost (rechts)
{ id: "W2", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 5, y: 0 }, end: { x: 5, y: 4 }, wallTypeId: "aw", height: 2.6 },
// Nord (oben)
{ id: "W3", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 5, y: 4 }, end: { x: 0, y: 4 }, wallTypeId: "aw", height: 2.6 },
// West (links)
{ id: "W4", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 0, y: 4 }, end: { x: 0, y: 0 }, wallTypeId: "aw", height: 2.6 },
// ── OG: 5 × 3 m Raum (CCW), keine Tür ──
// Süd (unten)
{ id: "W5", type: "wall", floorId: "og", categoryCode: "20", start: { x: 0, y: 0 }, end: { x: 5, y: 0 }, wallTypeId: "aw", height: 2.6 },
// Ost (rechts)
{ id: "W6", type: "wall", floorId: "og", categoryCode: "20", start: { x: 5, y: 0 }, end: { x: 5, y: 3 }, wallTypeId: "aw", height: 2.6 },
// Nord (oben)
{ id: "W7", type: "wall", floorId: "og", categoryCode: "20", start: { x: 5, y: 3 }, end: { x: 0, y: 3 }, wallTypeId: "aw", height: 2.6 },
// West (links)
{ id: "W8", type: "wall", floorId: "og", categoryCode: "20", start: { x: 0, y: 3 }, end: { x: 0, y: 0 }, wallTypeId: "aw", height: 2.6 },
],
doors: [
{
id: "D1",
type: "door",
hostWallId: "W1",
categoryCode: "21",
position: 2.0, // 2 m vom Wand-Start (Ecke unten links)
width: 0.9,
height: 2.1,
swing: "left", // schlägt nach innen (in den Raum) auf
hinge: "start",
},
],
// Freie 2D-Zeichengeometrie — anfangs leer; wird mit den Zeichenwerkzeugen
// gefüllt (docs/design/drawing-tools.md).
drawings2d: [],
// Kontext-Schicht (importierte/abgeleitete Geometrie) — anfangs leer; wird
// über den DXF-Import bzw. die Gelände-Generierung gefüllt.
context: [],
};
+100
View File
@@ -0,0 +1,100 @@
// Gelände-Generierung (TIN) aus Konturen (Höhenlinien).
//
// Prinzip: Die Stütz-Vertices der Konturen werden als Punktmenge genommen
// (sie liegen direkt auf den Höhenlinien = bevorzugte „Breaklines"). Über deren
// (x,y)-Projektion wird eine 2D-Delaunay-Triangulation gelegt; die Z-Höhe je
// Vertex stammt aus der jeweiligen Kontur. Daraus entsteht ein TIN (Triangulated
// Irregular Network) als rohes positions/indices-Mesh (three-frei).
//
// Gelände ist NICHT semantisch — es ist Kontext-Geometrie (Project.context).
import Delaunator from "delaunator";
import type { Contour, TerrainMesh } from "./types";
let terrainSeq = 0;
/**
* Erzeugt ein TIN aus Konturen.
*
* Algorithmus:
* 1. Sampling: jeder Stützpunkt jeder Kontur wird ein 3D-Vertex (x, y, z) mit
* z = Kontur-Höhe. Es wird KEIN zusätzliches Resampling betrieben — die
* Kontur-Vertices selbst sind die Breaklines.
* 2. Delaunay: 2D-Triangulation über die (x,y)-Projektion aller Vertices
* (Delaunator). Die Z-Höhe je Vertex bleibt erhalten → echtes Gelände-TIN.
* 3. Degenerate-Filter: Dreiecke mit (nahezu) null Fläche in der (x,y)-Ebene
* bzw. mit nicht-endlichen Koordinaten werden verworfen.
*
* Robustheit:
* • offene UND geschlossene Konturen werden gleich behandelt (nur die Punkte
* zählen; closed/offen beeinflusst die TIN-Fläche nicht).
* • leere/zu kleine Eingabe (< 3 Punkte) → leeres Mesh (kein Crash).
* • NaN/Infinity-Punkte werden vor der Triangulation aussortiert.
*/
export function generateTerrainFromContours(contours: Contour[]): TerrainMesh {
const id = `terrain-${Date.now()}-${terrainSeq++}`;
const name = "Gelände";
// 1. Sampling: alle Kontur-Stützpunkte als (x,y,z)-Vertices sammeln, ungültige
// (nicht-endliche) Punkte überspringen.
const xs: number[] = [];
const ys: number[] = [];
const zs: number[] = [];
for (const c of contours) {
const z = Number.isFinite(c.z) ? c.z : 0;
for (const p of c.pts) {
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue;
xs.push(p.x);
ys.push(p.y);
zs.push(z);
}
}
// Zu wenige Punkte für ein Dreieck → triviales leeres Mesh.
if (xs.length < 3) {
return { id, type: "terrainMesh", name, positions: [], indices: [] };
}
// 2. Delaunay über die (x,y)-Projektion. Delaunator erwartet ein flaches
// [x0,y0, x1,y1, …]-Koordinatenarray.
const coords2d = new Float64Array(xs.length * 2);
for (let i = 0; i < xs.length; i++) {
coords2d[i * 2] = xs[i];
coords2d[i * 2 + 1] = ys[i];
}
// Bei kollinearen Punkten liefert Delaunator eine leere Triangulation — das ist
// korrekt (kein Crash). Trotzdem schützen wir gegen einen möglichen Wurf.
let triangles: Uint32Array;
try {
triangles = new Delaunator(coords2d).triangles;
} catch {
return { id, type: "terrainMesh", name, positions: [], indices: [] };
}
// 3. positions (x,y,z) aufbauen; Z aus zs je Vertex.
const positions: number[] = new Array(xs.length * 3);
for (let i = 0; i < xs.length; i++) {
positions[i * 3] = xs[i];
positions[i * 3 + 1] = ys[i];
positions[i * 3 + 2] = zs[i];
}
// Degenerierte Dreiecke (Null-Fläche in (x,y)) herausfiltern, damit das TIN
// keine entarteten Faces enthält.
const indices: number[] = [];
const EPS_AREA = 1e-9;
for (let t = 0; t < triangles.length; t += 3) {
const a = triangles[t];
const b = triangles[t + 1];
const c = triangles[t + 2];
const ax = xs[a], ay = ys[a];
const bx = xs[b], by = ys[b];
const cx = xs[c], cy = ys[c];
// doppelte Dreiecksfläche (Kreuzprodukt) in der (x,y)-Ebene.
const area2 = Math.abs((bx - ax) * (cy - ay) - (cx - ax) * (by - ay));
if (area2 <= EPS_AREA) continue;
indices.push(a, b, c);
}
return { id, type: "terrainMesh", name, positions, indices };
}
+469
View File
@@ -0,0 +1,469 @@
// Das semantische Gebäudemodell — der Kern von BIM.
// Bauteile haben Bedeutung, nicht nur Form. Eine Tür "kennt" ihre Wand,
// eine Wand "kennt" ihren mehrschichtigen Aufbau (WallType).
//
// Dokumentmodell (nach DOSSIER): zwei UNABHÄNGIGE Achsen.
// 1. Zeichnungsebenen (DrawingLevel) = oberste Schnitte: Geschosse +
// Schnitte/Ansichten.
// 2. Ebenen (LayerCategory) = Grafik-Kategorie-Schema, das für jedes
// Geschoss gilt. Ein Element lebt auf einer Kategorie (code) UND einem
// Geschoss.
export type Vec2 = { x: number; y: number };
// ── Ressourcen-Bibliotheken (Vectorworks-/DOSSIER-Stil) ────────────────────
// Verwaltete, per id verwiesene Stil-Ressourcen. Verweis-Kette:
// Component → Hatch → LineStyle.
// Darstellung wird beim Rendern aus diesen Ressourcen aufgelöst, nie in die
// Geometrie eingebacken (siehe docs/design/resources-graphics.md).
/** Ein wiederverwendbarer Linienstil (Line Manager). */
export interface LineStyle {
id: string;
name: string;
/** Strichstärke in Millimetern (≙ Rhino PlotWeight). */
weight: number;
/** Farbe (hex). */
color: string;
/** Strichmuster in Millimetern; `null` = durchgezogen. */
dash: number[] | null;
}
/** Mögliche Schraffur-Muster im Plan/Schnitt. */
export type HatchPattern =
| "none"
| "solid"
| "insulation"
| "diagonal"
| "crosshatch";
/** Eine wiederverwendbare Schraffur (Hatch Manager). */
export interface HatchStyle {
id: string;
name: string;
/** Muster-Typ. */
pattern: HatchPattern;
/** Grundmaßstab des Musters (1 = Standardteilung). */
scale: number;
/** Drehung des Musters in Grad. */
angle: number;
/**
* Farbe der Musterlinien bzw. der Vollfüllung (`pattern==="solid"`). Bei
* `pattern==="none"` ungenutzt.
*/
color: string;
/** Optionaler Linienstil für die Musterlinien (Line Manager). */
lineStyleId?: string;
}
/**
* Ein Bauteil-Material (Component Manager) — vereint Plan-Schnittdarstellung
* (Schraffur) und 3D-Erscheinung. Ersetzt das frühere `Material`.
*/
export interface Component {
id: string;
name: string;
/** Füllfarbe im Grundriss (Poché) und 3D-Diffusfarbe. */
color: string;
/** Schnitt-Schraffur → Hatch Manager. */
hatchId: string;
/** Optionale 3D-Textur (vorerst ignoriert). */
texture3d?: string;
/** Verschneidungs-Rang: höher läuft am Stoß durch (Backbone). */
joinPriority: number;
}
/** Eine Schicht eines mehrschichtigen Bauteils. */
export interface Layer {
/** Verweis auf das Bauteil-Material (Component Manager). */
componentId: string;
/** Schichtdicke in Metern. */
thickness: number;
}
/** Ein Wandtyp = geordneter Schichtaufbau (außen → innen). */
export interface WallType {
id: string;
name: string;
layers: Layer[];
}
/**
* Art einer Zeichnungsebene.
* • "floor" — Geschoss, trägt Bauteile.
* • "section" — Schnitt (abgeleitete Projektion entlang einer Linie).
* • "elevation" — Ansicht (abgeleitete Projektion entlang einer Linie).
* • "drawing" — freie 2D-Zeichnung, nicht an ein Geschoss gebunden.
*/
export type DrawingLevelKind = "floor" | "section" | "elevation" | "drawing";
/**
* Eine Zeichnungsebene (DrawingLevel) — eine oberste Schnittebene des
* Dokuments. Ein Geschoss trägt die Bauteile (über `floorId`); ein
* Schnitt/Ansicht ist eine abgeleitete Projektion (vorerst Platzhalter); eine
* reine Zeichnung ist eine freie 2D-Ebene ohne Geschossbezug.
*
* Geschoss nutzt floorHeight/cutHeight/baseElevation; Schnitt/Ansicht nutzen
* linePoints/directionSign; "drawing" nutzt keines dieser Felder.
*/
export interface DrawingLevel {
id: string;
name: string;
kind: DrawingLevelKind;
/** Sichtbarkeit der Zeichnungsebene im Navigator. */
visible: boolean;
/** Gesperrt (keine Bearbeitung). */
locked: boolean;
/** Lichte Geschosshöhe in Metern (nur Geschoss). */
floorHeight?: number;
/** Schnitthöhe über OKFF in Metern (nur Geschoss, für den Grundriss). */
cutHeight?: number;
/** Oberkante Fertigfußboden in Metern (nur Geschoss). */
baseElevation?: number;
/** Schnitt-/Ansichtslinie im Grundriss (nur Schnitt/Ansicht). */
linePoints?: [Vec2, Vec2];
/** Blickrichtung relativ zur Schnittlinie (nur Schnitt/Ansicht). */
directionSign?: 1 | -1;
}
/**
* Eine Ebene (LayerCategory) — ein Knoten im Grafik-Kategorie-Baum. Das
* Schema gilt geschossübergreifend; Elemente verweisen über `code` darauf.
*/
export interface LayerCategory {
/** Eindeutiger Kategorie-Code, z. B. "20" für Wände. */
code: string;
name: string;
/** Darstellungsfarbe (hex). */
color: string;
/** Linienstärke in Millimetern. */
lw: number;
visible: boolean;
locked: boolean;
/** Optionale Standard-Schraffur der Kategorie. */
hatch?: string;
/** Unterkategorien (Baum). */
children?: LayerCategory[];
}
/**
* Lage der Wandachse (Referenzlinie) über die Dicke der Wand — analog
* Vectorworks. Gemessen relativ zur Laufrichtung (start→end) mit der
* leftNormal-Konvention `n = (-u.y, u.x)`:
* • "center" — Achse mittig (Default = heutiges Verhalten).
* • "left" — Achse liegt auf der linken Wandfläche (+n-Seite, „außen").
* • "right" — Achse liegt auf der rechten Wandfläche (n-Seite, „innen").
*/
export type WallReferenceLine = "left" | "center" | "right";
/**
* Vertikale Bindung einer Wandkante (UK/OK).
* • "floor" — an ein Geschoss gebunden: Z = baseElevation des Geschosses
* (+ optionalem `offset`). Stapelt automatisch mit dem Geschoss.
* • "custom" — fester absoluter Z-Wert (Meter).
*/
export type VerticalAnchor =
| { mode: "floor"; floorId: string; offset?: number }
| { mode: "custom"; z: number };
/** Eine Wand, definiert über ihre Achse (Centerline) und ihren Typ. */
export interface Wall {
id: string;
type: "wall";
/** Zugehörige Zeichnungsebene (Geschoss). */
floorId: string;
/** Grafik-Kategorie (Ebene), z. B. "20" für Wände. */
categoryCode: string;
/** Achs-Startpunkt im Grundriss (Meter). */
start: Vec2;
/** Achs-Endpunkt im Grundriss (Meter). */
end: Vec2;
/** Verweis auf den (mehrschichtigen) Wandtyp. */
wallTypeId: string;
/** Wandhöhe in Metern. */
height: number;
/**
* Optionale Übersteuerung der Strich-/Umrandungsfarbe der Wand; sonst gilt
* die Kategorie-Farbe. Übersteuert NUR die Linienfarbe (Umriss/Schichtfugen),
* nicht die Schicht-Füllfarben/Schraffuren.
*/
color?: string;
/**
* Lage der Wandachse über die Dicke. Fehlt sie, gilt "center" (= heutiges
* Verhalten: Schichten symmetrisch T/2 … +T/2 um die Achse).
*/
referenceLine?: WallReferenceLine;
/**
* Vertikale Bindung der Unterkante (UK). Fehlt sie, sitzt die UK auf der
* baseElevation des zugehörigen Geschosses (= heutiges Verhalten).
*/
bottom?: VerticalAnchor;
/**
* Vertikale Bindung der Oberkante (OK). Fehlt sie, ergibt sich die OK aus
* UK + `height` (= heutiges Verhalten). „custom" setzt einen absoluten
* Z-Wert; „floor" bindet die OK an ein (z. B. nächsthöheres) Geschoss.
*/
top?: VerticalAnchor;
}
/** Schwenkrichtung der Tür relativ zur Wandachse. */
export type SwingSide = "left" | "right";
/** Eine Tür, gehostet in einer Wand. Ihr Geschoss ergibt sich aus der Wand. */
export interface Door {
id: string;
type: "door";
hostWallId: string;
/** Grafik-Kategorie (Ebene), z. B. "21" für Türen/Fenster. */
categoryCode: string;
/** Abstand des Türanschlags (erster Pfosten) vom Wand-Startpunkt, in Metern. */
position: number;
/** Türbreite (lichte Öffnung) in Metern. */
width: number;
/** Türhöhe in Metern. */
height: number;
/** Auf welche Seite der Wandachse die Tür aufschlägt. */
swing: SwingSide;
/** An welchem Pfosten das Scharnier sitzt. */
hinge: "start" | "end";
}
// ── Freie 2D-Zeichengeometrie (Drawing2D) ──────────────────────────────────
// Ein semantisches 2D-Element (wie Wall/Door), das beim Rendern abgeleitet wird
// (keine vorab erzeugten Plan-Primitive). Siehe docs/design/drawing-tools.md §7.
/** Geometrie-Form eines 2D-Zeichenelements. */
export type Drawing2DGeom =
| { shape: "line"; a: Vec2; b: Vec2 }
| { shape: "polyline"; pts: Vec2[]; closed: boolean }
| { shape: "rect"; min: Vec2; max: Vec2 }
| { shape: "circle"; center: Vec2; r: number }
| { shape: "arc"; center: Vec2; r: number; a0: number; a1: number }
| { shape: "text"; at: Vec2; text: string; height: number; angle: number };
/** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */
export interface Drawing2D {
id: string;
type: "drawing2d";
/** Zeichnungsebene (Geschoss ODER freie 2D-Ebene). */
levelId: string;
/** Grafik-Kategorie (Ebene) — liefert Farbe/Strichstärke als Default. */
categoryCode: string;
geom: Drawing2DGeom;
/** Optionaler Linienstil (Line Manager); sonst Kategorie-Default. */
lineStyleId?: string;
/** Optionale Schraffur für geschlossene Formen (Hatch Manager). */
hatchId?: string;
/** Optionale explizite Strichfarbe; sonst Kategorie-Farbe. */
color?: string;
/**
* Optionale Vollton-Füllfarbe für geschlossene Formen (getrennt von der
* Strichfarbe `color`). Fehlt sie, ist die Fläche transparent (nur Schraffur
* bzw. ungefüllt).
*/
fillColor?: string;
/**
* Optionale direkte Strichstärke-Übersteuerung in Millimetern; hat Vorrang
* vor dem LineStyle-Gewicht und der Kategorie-Strichstärke.
*/
weightMm?: number;
}
/**
* Ein Kanten-/Seiten-Griff eines selektierten Elements (zusätzlich zu den
* Eckpunkt-Griffen). Liegt am Mittelpunkt einer Seite (Modell-Meter) und zeigt
* mit `normal` als Einheitsvektor nach AUSSEN (vom Element weg). `aIndex`/
* `bIndex` sind die beiden Vertex-Indizes der Kante — passend zur Indizierung
* von `drawingVertices`/`moveGripOf`. Ziehen verschiebt BEIDE Vertices senkrecht
* zur Kante (Form wächst/schrumpft an dieser Seite).
*/
export interface EdgeGrip {
mid: Vec2;
normal: Vec2;
aIndex: number;
bIndex: number;
}
export type Element = Wall | Door | Drawing2D;
// ── Kontext-Geometrie (importiert / abgeleitet, NICHT semantisch) ───────────
// Importierte Geometrie ist „dumme" KONTEXT-Geometrie (Anzeige + späteres
// Snap-Ziel), KEIN Teil des semantischen BIM-Modells. Sie lebt in einer eigenen
// Schicht `Project.context` und wird beim Rendern wie eine Referenz behandelt.
// Bewusst three-frei: nur rohe Buffer-Daten (positions/indices), three-Objekte
// entstehen erst im Viewport.
/**
* Ein importiertes Dreiecks-Mesh (z. B. aus DXF 3DFACE/POLYFACE/MESH). Rohe
* BufferGeometry-Daten: `positions` = flaches Array (x,y,z, x,y,z, …) in Metern,
* `indices` = Dreiecks-Indizes (je 3 ein Dreieck). Keine three-Objekte.
*/
export interface ImportedMesh {
id: string;
type: "importedMesh";
name: string;
/** Ursprünglicher DXF-Layer-Name (für spätere Kategorisierung). */
layer?: string;
positions: number[];
indices: number[];
}
/**
* Eine einzelne Kontur (Höhenlinie / Polylinie) auf konstanter Höhe `z`. `pts`
* sind 2D-Stützpunkte (x,y) in Metern; `closed` schließt den Linienzug.
*/
export interface Contour {
z: number;
pts: Vec2[];
closed: boolean;
/** Ursprünglicher DXF-Layer-Name (für die Kategorisierung beim 2D-Import). */
layer?: string;
}
/** Ein Satz Konturen (z. B. alle Höhenlinien eines DXF-Imports). */
export interface ContourSet {
id: string;
type: "contourSet";
name: string;
/** Ursprünglicher DXF-Layer-Name (für spätere Kategorisierung). */
layer?: string;
contours: Contour[];
}
/**
* Ein TIN-Geländemodell, abgeleitet aus Konturen (Delaunay über (x,y), Z aus der
* jeweiligen Kontur-Höhe). `positions` = flaches (x,y,z…)-Array in Metern,
* `indices` = Dreiecks-Indizes. Gelände ist NICHT semantisch (Kontext-Schicht).
*/
export interface TerrainMesh {
id: string;
type: "terrainMesh";
name: string;
positions: number[];
indices: number[];
}
/** Ein Kontext-Objekt: importiertes Mesh, Konturen-Satz oder abgeleitetes TIN. */
export type ContextObject = ImportedMesh | ContourSet | TerrainMesh;
/** Das gesamte Projekt. */
export interface Project {
id: string;
name: string;
/** Linienstil-Bibliothek (Line Manager). */
lineStyles: LineStyle[];
/** Schraffur-Bibliothek (Hatch Manager). */
hatches: HatchStyle[];
/** Bauteil-Material-Bibliothek (Component Manager). */
components: Component[];
wallTypes: WallType[];
/** Oberste Schnitte: Geschosse + Schnitte/Ansichten. */
drawingLevels: DrawingLevel[];
/** Grafik-Kategorie-Baum (geschossübergreifend). */
layers: LayerCategory[];
walls: Wall[];
doors: Door[];
/** Freie 2D-Zeichengeometrie (Line/Polyline/Rect/Circle/Arc/Text). */
drawings2d: Drawing2D[];
/**
* Kontext-Schicht: importierte/abgeleitete „dumme" Geometrie (Meshes,
* Konturen, Gelände-TIN) — NICHT semantisch. Optional, damit bestehende
* Projekte/Tests ohne `context` gültig bleiben (Default: leer behandeln).
*/
context?: ContextObject[];
}
// ── Helfer ───────────────────────────────────────────────────────────────
export const getWallType = (project: Project, wall: Wall): WallType => {
const wt = project.wallTypes.find((t) => t.id === wall.wallTypeId);
if (!wt) throw new Error(`Unbekannter Wandtyp: ${wall.wallTypeId}`);
return wt;
};
/** Liefert ein Bauteil-Material (Component) per ID oder wirft. */
export const getComponent = (project: Project, id: string): Component => {
const c = project.components.find((co) => co.id === id);
if (!c) throw new Error(`Unbekanntes Bauteil-Material: ${id}`);
return c;
};
/** Liefert eine Schraffur (HatchStyle) per ID oder wirft. */
export const getHatch = (project: Project, id: string): HatchStyle => {
const h = project.hatches.find((ht) => ht.id === id);
if (!h) throw new Error(`Unbekannte Schraffur: ${id}`);
return h;
};
/** Liefert einen Linienstil (LineStyle) per ID oder wirft. */
export const getLineStyle = (project: Project, id: string): LineStyle => {
const l = project.lineStyles.find((ls) => ls.id === id);
if (!l) throw new Error(`Unbekannter Linienstil: ${id}`);
return l;
};
/** Liefert eine Zeichnungsebene (Geschoss) per ID oder wirft. */
export const getFloor = (project: Project, id: string): DrawingLevel => {
const g = project.drawingLevels.find((z) => z.id === id);
if (!g) throw new Error(`Unbekanntes Geschoss: ${id}`);
return g;
};
/**
* Stapelt baseElevation der Geschosse in Dokumentreihenfolge: Das erste
* Geschoss beginnt bei 0, jedes weitere bei baseElevation + floorHeight des
* vorigen Geschosses. Nicht-Geschoss-Ebenen behalten baseElevation undefined.
* Liefert eine neue Liste (mit neuen Geschoss-Objekten); die Eingabe bleibt
* unverändert.
*/
export const recomputeFloorElevations = (
levels: DrawingLevel[],
): DrawingLevel[] => {
let nextBase = 0;
return levels.map((level) => {
if (level.kind !== "floor") {
return { ...level, baseElevation: undefined };
}
const baseElevation = nextBase;
nextBase = baseElevation + (level.floorHeight ?? 0);
return { ...level, baseElevation };
});
};
/** Flacht den Kategorie-Baum (Tiefensuche) in eine Liste ab. */
export const flattenCategories = (cats: LayerCategory[]): LayerCategory[] => {
const out: LayerCategory[] = [];
const walk = (list: LayerCategory[]) => {
for (const c of list) {
out.push(c);
if (c.children) walk(c.children);
}
};
walk(cats);
return out;
};
/** Menge aller Codes sichtbarer Kategorien (Baum berücksichtigt). */
export const collectVisibleCodes = (layers: LayerCategory[]): Set<string> => {
const codes = new Set<string>();
for (const c of flattenCategories(layers)) {
if (c.visible) codes.add(c.code);
}
return codes;
};
/** Gesamtdicke eines Wandtyps = Summe der Schichtdicken. */
export const wallTypeThickness = (wt: WallType): number =>
wt.layers.reduce((sum, l) => sum + l.thickness, 0);
/** Formatiert Meter mit zwei Nachkommastellen, z. B. "0.35 m". */
export const formatM = (meters: number): string => meters.toFixed(2) + " m";
/**
* Standard-Stiftstärken (mm Papier bei 100 %), Vorgabeliste für Linienstil-/
* Strichstärke-Eingaben. Bedeutung: Breite auf dem Papier — die Linien skalieren
* mit dem Massstab (non-scaling-stroke). Werte sind Vorschläge; man darf abweichen.
*/
export const PEN_WEIGHTS: number[] = [
0.02, 0.1, 0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0, 1.4, 2.0,
];
+87
View File
@@ -0,0 +1,87 @@
// Wand-Resolver: leitet aus den (optionalen) Wand-Attributen die konkreten
// geometrischen Größen ab — Versatz der Achse über die Dicke (Referenzlinie)
// und die vertikale Ausdehnung (UK..OK). Alle Defaults entsprechen exakt dem
// bisherigen Verhalten, sodass bestehende Wände unverändert rendern.
//
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
import type { Project, VerticalAnchor, Wall } from "./types";
import { getFloor } from "./types";
/**
* Versatz der Schichtstapelung entlang der linken Normalen `+n` (leftNormal),
* sodass die Achse auf der gewünschten Wandfläche liegt. Die Schichten werden
* normalerweise symmetrisch von `T/2` (außen, +n-Seite) bis `+T/2` (innen)
* gestapelt; dieser Versatz wird ZUSÄTZLICH auf jeden Schicht-Offset addiert.
*
* • "center" → 0 (Schichten symmetrisch um die Achse — Default).
* • "left" → +T/2 (Achse auf der +n-/„außen"-Fläche; Wand liegt auf n).
* • "right" → T/2 (Achse auf der n-/„innen"-Fläche; Wand liegt auf +n).
*
* Die Schicht-Offsets laufen von T/2 (= +n außen) nach +T/2 (= n innen).
* Für "left" soll die T/2-Kante (außen) auf der Achse liegen → +T/2 addieren,
* sodass die Schichten von 0…T (alle auf der n-Seite) liegen.
*/
export function wallReferenceOffset(
wall: Wall,
totalThickness: number,
): number {
const ref = wall.referenceLine ?? "center";
const half = totalThickness / 2;
if (ref === "left") return +half;
if (ref === "right") return -half;
return 0;
}
/** Löst einen VerticalAnchor zu einem absoluten Z (Meter) auf oder `null`. */
function resolveAnchor(
project: Project,
anchor: VerticalAnchor | undefined,
): number | null {
if (!anchor) return null;
if (anchor.mode === "custom") return anchor.z;
// "floor": baseElevation des gebundenen Geschosses (+ optionalem Offset).
const floor = project.drawingLevels.find((z) => z.id === anchor.floorId);
if (!floor) return null;
return (floor.baseElevation ?? 0) + (anchor.offset ?? 0);
}
/**
* Vertikale Ausdehnung der Wand (absolute Z-Werte in Metern).
* • zBottom: aus `wall.bottom`, sonst baseElevation des Wand-Geschosses
* (= heutiges Verhalten).
* • zTop: aus `wall.top`, sonst `zBottom + wall.height` (= heute).
* Sind beide UK/OK ungesetzt, liefert dies exakt `[baseElevation, base+height]`.
*/
export function wallVerticalExtent(
project: Project,
wall: Wall,
): { zBottom: number; zTop: number } {
// Geschoss-baseElevation als Default-UK (heutiges Verhalten). Fehlt das
// Geschoss, 0 als sicherer Rückfall.
let floorBase = 0;
try {
floorBase = getFloor(project, wall.floorId).baseElevation ?? 0;
} catch {
floorBase = 0;
}
const zBottom = resolveAnchor(project, wall.bottom) ?? floorBase;
const zTop = resolveAnchor(project, wall.top) ?? zBottom + wall.height;
return { zBottom, zTop };
}
/**
* Das Geschoss DIREKT ÜBER dem Wand-Geschoss (in Dokumentreihenfolge der
* "floor"-Ebenen) oder `undefined`, wenn die Wand im obersten Geschoss liegt.
* Dient der „Verknüpfung zum oberen Geschoss" (OK an nächstes Geschoss binden).
*/
export function nextFloorAbove(
project: Project,
wall: Wall,
): { id: string; name: string } | undefined {
const floors = project.drawingLevels.filter((z) => z.kind === "floor");
const idx = floors.findIndex((z) => z.id === wall.floorId);
if (idx < 0) return undefined;
const above = floors[idx + 1];
return above ? { id: above.id, name: above.name } : undefined;
}
+177
View File
@@ -0,0 +1,177 @@
// Inhalts-Panel „Attribute" — die Attribut-Palette (Vectorworks-Stil) für die
// aktuelle Auswahl. Zeigt die EFFEKTIV aufgelösten Stift-/Füll-Eigenschaften des
// ersten selektierten Elements und erlaubt das direkte Bearbeiten (Farbe,
// Strichstärke, Füllschraffur).
//
// Daten/Handler kommen ausschließlich über usePanelHost (kein Prop-Drilling).
// Es werden NUR Eigenschaften angeboten, die der Host-Kontrakt tatsächlich
// setzen kann — keine Stubs/Fake-Setter (Konvention wire-dont-stub). Felder ohne
// echten Setter (Linienstil) werden read-only/informativ gezeigt; Felder, die
// das Modell gar nicht trägt (Deckkraft, Caps, Schlagschatten), werden bewusst
// weggelassen, um die Palette ehrlich und schlank zu halten.
//
// DOSSIER-Tabellen-Look: KEINE wiederholten Feld-Labels pro Zeile — ein kompaktes
// Label→Wert-Grid. Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
// Alle sichtbaren Texte über t(...).
import { t } from "../i18n";
import { formatM } from "../model/types";
import { PEN_WEIGHTS } from "../model/types";
import { usePanelHost } from "./host";
export function AttributesPanel() {
const host = usePanelHost();
const sel = host.selection;
const { project } = host;
// Leerzustand: kompakter Hinweis, wenn nichts (Sinnvolles) selektiert ist.
if (sel === null) {
return (
<div className="attr-panel">
<div className="attr-empty">{t("attr.empty")}</div>
</div>
);
}
const isDrawing = sel.kind === "drawing2d";
// Strichstärke wirkt laut Kontrakt nur auf Drawing2D; Wände erben sie aus der
// Ebene und sind daher hier nicht editierbar.
const weightEditable = isDrawing;
// Füllung nur bei geschlossener 2D-Form sinnvoll.
const fillEditable = isDrawing && sel.closed === true;
// Effektiver Linienstil rein informativ (kein Setter im Kontrakt → kein Fake).
// Wir leiten ihn aus dem Modell-Element ab, wenn explizit gesetzt; sonst „—".
const drawing = isDrawing
? project.drawings2d.find((d) => d.id === sel.id)
: undefined;
const lineStyle = drawing?.lineStyleId
? project.lineStyles.find((l) => l.id === drawing.lineStyleId)
: undefined;
const bboxW = sel.bbox.maxX - sel.bbox.minX;
const bboxH = sel.bbox.maxY - sel.bbox.minY;
return (
<div className="attr-panel">
<div className="attr-title">{t("attr.title")}</div>
{/* ── Stift/Strich ──────────────────────────────────────────────── */}
<div className="attr-section-label">{t("attr.stroke")}</div>
<div className="attr-grid">
{/* Farbe — gilt für Wand UND Drawing2D (Kontrakt setzt beide). */}
<span className="attr-key">{t("attr.color")}</span>
<span className="attr-val">
<label className="attr-color">
<span
className="attr-color-swatch"
style={{ background: sel.color }}
/>
<input
type="color"
value={sel.color}
onChange={(e) => host.onSetSelectionColor(e.target.value)}
/>
<span className="attr-color-hex">{sel.color}</span>
</label>
</span>
{/* Strichstärke (mm) — nur Drawing2D editierbar; Wände erben aus Ebene. */}
<span className="attr-key">{t("attr.weight")}</span>
<span className="attr-val">
<input
className="attr-num"
type="number"
step={0.01}
min={0}
list="attr-pen-weights"
value={sel.weightMm}
disabled={!weightEditable}
title={weightEditable ? undefined : t("attr.inheritedFromLayer")}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) host.onSetSelectionWeight(v);
}}
/>
<datalist id="attr-pen-weights">
{PEN_WEIGHTS.map((w) => (
<option key={w} value={w} />
))}
</datalist>
</span>
{/* Linienstil — read-only/informativ (kein Setter im Kontrakt). */}
<span className="attr-key">{t("attr.lineStyle")}</span>
<span className="attr-val attr-readonly">
{lineStyle ? lineStyle.name : "—"}
</span>
</div>
{/* ── Füllung (nur geschlossene 2D-Form) ────────────────────────── */}
<div className="attr-section-label">{t("attr.fill")}</div>
<div className="attr-grid">
{/* Vollton-Füllfarbe (getrennt von der Strichfarbe). „keine" = transparent. */}
<span className="attr-key">{t("attr.fillColor")}</span>
<span className="attr-val">
<label className="attr-color">
<span
className="attr-color-swatch"
style={{
background: fillEditable && sel.fillColor ? sel.fillColor : "transparent",
}}
/>
<input
type="color"
value={sel.fillColor ?? "#808080"}
disabled={!fillEditable}
title={fillEditable ? undefined : t("attr.fillNeedsClosed")}
onChange={(e) => host.onSetSelectionFillColor(e.target.value)}
/>
<span className="attr-color-hex">
{fillEditable && sel.fillColor ? sel.fillColor : t("attr.none")}
</span>
{fillEditable && sel.fillColor && (
<button
type="button"
className="attr-color-clear"
title={t("attr.clearFill")}
onClick={() => host.onSetSelectionFillColor(null)}
>
×
</button>
)}
</label>
</span>
<span className="attr-key">{t("attr.hatch")}</span>
<span className="attr-val">
<select
className="attr-select"
value={fillEditable ? sel.fillHatchId ?? "" : ""}
disabled={!fillEditable}
title={fillEditable ? undefined : t("attr.fillNeedsClosed")}
onChange={(e) =>
host.onSetSelectionFill(e.target.value || null)
}
>
<option value="">{t("attr.none")}</option>
{project.hatches.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</span>
</div>
{/* ── Maße (informativ, aus der bbox) ───────────────────────────── */}
<div className="attr-section-label">{t("attr.size")}</div>
<div className="attr-grid">
<span className="attr-key">{t("attr.width")}</span>
<span className="attr-val attr-readonly">{formatM(bboxW)}</span>
<span className="attr-key">{t("attr.height")}</span>
<span className="attr-val attr-readonly">{formatM(bboxH)}</span>
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
// Darstellungsmodus-Umschalter — kleines dunkles Dropdown für die Panel-Kopfzeile.
//
// Reines, kontrolliertes Steuerelement: zeigt die fünf DOSSIER-DisplayMode-
// Optionen (Alle anzeigen / Ausgewählte / Nur aktive / Andere grau / Andere
// grau & gesperrt) und meldet die Wahl per Callback. Es kennt weder Host noch
// Registry — der Rahmen (PanelFrame) reicht `value` und `onChange` durch, die
// App verdrahtet sie mit `host.displayMode`.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import type { DisplayMode } from "./types";
import { t } from "../i18n";
/**
* Beschriftungs-Keys je Modus (Werte englisch, vgl. types.ts) — exakt die
* fünf DOSSIER-Modi aus docs/design/context-menu.md.
*/
const MODE_OPTIONS: { value: DisplayMode; labelKey: string }[] = [
{ value: "all_force", labelKey: "display.all_force" },
{ value: "all", labelKey: "display.all" },
{ value: "active", labelKey: "display.active" },
{ value: "grey", labelKey: "display.grey" },
{ value: "grey_locked", labelKey: "display.grey_locked" },
];
/**
* Kleines Pill-Dropdown für den Darstellungsmodus. Vollständig kontrolliert:
* `value` ist der aktuelle Modus, `onChange` liefert den neuen.
*/
export function DisplayModeSelect({
value,
onChange,
title,
}: {
/** Aktuell gewählter Darstellungsmodus. */
value: DisplayMode;
/** Wird mit dem neuen Modus aufgerufen, wenn der Nutzer wählt. */
onChange: (mode: DisplayMode) => void;
/** Optionaler Tooltip auf dem Dropdown (Default: i18n „Darstellungsmodus"). */
title?: string;
}) {
const label = title ?? t("display.title");
return (
<select
className="display-mode"
value={value}
title={label}
aria-label={label}
onChange={(e) => onChange(e.target.value as DisplayMode)}
>
{MODE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{t(o.labelKey)}
</option>
))}
</select>
);
}
+265
View File
@@ -0,0 +1,265 @@
// Dock — eine angedockte Spalte (links oder rechts) mit GESTAPELTEN Gruppen.
//
// Ein Dock hält eine Liste vertikal gestapelter Gruppen (DockState.groups). Jede
// Gruppe ist ein eigener Tab-Stapel (TabStrip + aktives Panel im PanelFrame) und
// trägt ein Höhen-Gewicht; zwischen den Gruppen sitzt ein Splitter, der die
// Gewichte der Nachbarn verschiebt. So entsteht das Vectorworks-Bild (z. B.
// links Werkzeuge OBEN, Attribute UNTEN gleichzeitig).
//
// Jede Gruppe rendert ihren PanelFrame in einem EIGENEN Host-Provider, damit der
// Darstellungsmodus-Umschalter dem aktiven Tab DIESER Gruppe folgt. Den Provider
// baut der Host (App) und reicht ihn als `buildHost(activeTab)` herein — das Dock
// bleibt so vom konkreten Host-Modell entkoppelt.
//
// Breite ist per Ziehgriff an der INNEREN Kante veränderbar (rechte Kante beim
// linken Dock, linke Kante beim rechten Dock); `dock.size` ist die Quelle der
// Wahrheit, `onResize(size)` meldet jede Änderung zurück. Die Gruppen-Höhen
// meldet `onResizeGroups(weights)`.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useCallback, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { PanelFrame } from "./PanelFrame";
import { TabStrip } from "./TabStrip";
import { getPanel } from "./registry";
import { PanelHostContext } from "./types";
import type {
DisplayMode,
DockId,
DockState,
PanelContext,
PanelHost,
} from "./types";
import { t } from "../i18n";
/** Untere/obere Grenze der Dock-Breite in CSS-Pixeln (Voreinstellung). */
const DEFAULT_MIN_WIDTH = 200;
const DEFAULT_MAX_WIDTH = 480;
/** Mindest-Pixelhöhe, die eine Gruppe beim Splitter-Ziehen behält. */
const MIN_GROUP_PX = 70;
export interface DockProps {
/** Welches Dock: bestimmt Rand + Seite des Ziehgriffs. */
side: DockId;
/** Zustand dieses Docks (Gruppen + Breite). */
dock: DockState;
/**
* Beginn einer Tab-Geste (pointerdown auf einem Tab) — an die TabStrip
* weitergereicht. Der Drag-Controller klärt Klick (aktivieren) vs. Ziehen
* (Reorder/Andocken/Lösen/Stapeln).
*/
onStartTabDrag: (panelId: string, e: React.PointerEvent) => void;
/** Breite geändert — liefert die neue (bereits geklemmte) Breite in px. */
onResize: (size: number) => void;
/** Gruppen-Höhen geändert — liefert die neue Gewichts-Liste (so lang wie groups). */
onResizeGroups: (weights: number[]) => void;
/**
* Baut den Host-Context-Wert für eine Gruppe (passend zu ihrem aktiven Tab).
* App reicht hier `hostWithMode(...)` herein; das Dock wickelt jede Gruppe in
* einen eigenen <PanelHostContext.Provider>.
*/
buildHost: (activeTab: string | null) => PanelHost;
/** Generischer Kontext, den der PanelFrame an `def.render` weiterreicht. */
ctx?: PanelContext;
/** Aktueller Darstellungsmodus zur aktiven Tab-ID einer Gruppe. */
displayModeFor: (activeTab: string | null) => DisplayMode;
/** Setter des Darstellungsmodus zur aktiven Tab-ID einer Gruppe. */
onDisplayModeChange: (activeTab: string | null, mode: DisplayMode) => void;
/** Minimale Breite in px (Voreinstellung 200). */
minWidth?: number;
/** Maximale Breite in px (Voreinstellung 480). */
maxWidth?: number;
}
/** Begrenzt eine Breite auf [min, max]. */
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* Eine angedockte Spalte mit gestapelten Gruppen. Rendert je Gruppe TabStrip +
* aktives Panel (im eigenen Host-Provider) und erlaubt das Verändern von Breite
* (innere Kante) und Gruppen-Höhen (Splitter zwischen Gruppen).
*/
export function Dock({
side,
dock,
onStartTabDrag,
onResize,
onResizeGroups,
buildHost,
ctx,
displayModeFor,
onDisplayModeChange,
minWidth = DEFAULT_MIN_WIDTH,
maxWidth = DEFAULT_MAX_WIDTH,
}: DockProps) {
// Wurzelelement, um die Breite während des Ziehens aus clientX zu berechnen.
const rootRef = useRef<HTMLDivElement | null>(null);
// Container der Gruppen (für die Gesamthöhe beim Gruppen-Splitter).
const stackRef = useRef<HTMLDivElement | null>(null);
// Breite während des aktiven Ziehens; null = nicht im Ziehen (dock.size gilt).
const [dragWidth, setDragWidth] = useState<number | null>(null);
const onResizeStart = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
// Nur primäre Taste / Berührung; Griff fängt den Zeiger ein, damit das
// Ziehen auch außerhalb des Elements weiterläuft.
e.preventDefault();
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
const move = (ev: PointerEvent) => {
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
// Linkes Dock wächst nach rechts (Zeiger linke Kante);
// rechtes Dock wächst nach links (rechte Kante Zeiger).
const raw =
side === "left" ? ev.clientX - rect.left : rect.right - ev.clientX;
const next = clamp(Math.round(raw), minWidth, maxWidth);
setDragWidth(next);
onResize(next);
};
const up = (ev: PointerEvent) => {
setDragWidth(null);
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
},
[side, minWidth, maxWidth, onResize],
);
// Splitter zwischen Gruppe `index` und `index+1`: verschiebt Gewicht zwischen
// den beiden Nachbarn. Umgerechnet wird der Pixel-Versatz über die Gesamthöhe
// des Stapels in einen Gewichts-Anteil (Summe der Gewichte bleibt erhalten).
const onGroupResizeStart = useCallback(
(index: number, e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
const stack = stackRef.current;
const totalPx = stack ? stack.getBoundingClientRect().height : 0;
const weights = dock.groups.map((g) => g.weight);
const sumW = weights.reduce((s, w) => s + w, 0) || 1;
const startY = e.clientY;
const wA = weights[index];
const wB = weights[index + 1];
// Pixel ↔ Gewicht: ein Pixel entspricht sumW/totalPx an Gewicht.
const perPx = totalPx > 0 ? sumW / totalPx : 0;
const minW = MIN_GROUP_PX * perPx;
const move = (ev: PointerEvent) => {
if (perPx === 0) return;
const dW = (ev.clientY - startY) * perPx;
// A wächst nach unten, B schrumpft; beide bleiben über dem Minimum.
let nextA = wA + dW;
let nextB = wB - dW;
const pair = wA + wB;
nextA = clamp(nextA, minW, pair - minW);
nextB = pair - nextA;
const out = [...weights];
out[index] = nextA;
out[index + 1] = nextB;
onResizeGroups(out);
};
const up = (ev: PointerEvent) => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
},
[dock.groups, onResizeGroups],
);
const width = dragWidth ?? dock.size;
// Breite als CSS-Variable; styles.css liest --dock-w für flex-basis/width.
const style = { "--dock-w": `${width}px` } as CSSProperties;
return (
<aside
ref={rootRef}
className={`dock dock-${side}${dragWidth !== null ? " resizing" : ""}`}
style={style}
>
<div className="dock-stack" ref={stackRef}>
{dock.groups.map((group, gi) => {
const def = group.activeTab ? getPanel(group.activeTab) : undefined;
const groupStyle = { flexGrow: group.weight } as CSSProperties;
return [
<section
key={`g${gi}`}
className="dock-group"
style={groupStyle}
// Datenattribute für die Drag-Auflösung: über dem Körper einer
// Gruppe entsteht beim Ablegen eine NEUE Gruppe darüber/darunter.
data-group-body=""
data-dock={side}
data-group-index={gi}
>
<TabStrip
side={side}
groupIndex={gi}
group={group}
onStartDrag={onStartTabDrag}
/>
{def ? (
<PanelHostContext.Provider value={buildHost(group.activeTab)}>
<PanelFrame
def={def}
ctx={ctx}
displayMode={displayModeFor(group.activeTab)}
onDisplayModeChange={(m) =>
onDisplayModeChange(group.activeTab, m)
}
/>
</PanelHostContext.Provider>
) : (
<div className="panel-frame panel-frame-empty" />
)}
</section>,
// Splitter zwischen dieser und der nächsten Gruppe.
gi < dock.groups.length - 1 ? (
<div
key={`s${gi}`}
className="dock-group-resize"
role="separator"
aria-orientation="horizontal"
aria-label={t("dock.resize")}
onPointerDown={(e) => onGroupResizeStart(gi, e)}
/>
) : null,
];
})}
</div>
{/* Ziehgriff an der inneren Kante (rechts bei links, links bei rechts). */}
<div
className="dock-resize"
role="separator"
aria-orientation="vertical"
aria-label={t("dock.resize")}
onPointerDown={onResizeStart}
/>
</aside>
);
}
+157
View File
@@ -0,0 +1,157 @@
// Inhalts-Panel „Zeichnungsebenen" — die obere Navigator-Liste als Panel.
//
// Zeigt die Zeichnungsebenen (Geschosse + Schnitte/Ansichten/Zeichnungen)
// exakt wie der bisherige Navigator: EyeIcon schaltet die Sichtbarkeit, ein
// Kind-Badge zeigt die Art, die aktive Ebene ist hervorgehoben, Höhen-Readout
// (+OKFF) bei Geschossen. Kopfzeile mit + Geschoss / + Zeichnung.
//
// Neu gegenüber dem Inline-Navigator: das Panel deklariert hasDisplayMode und
// liest den gewählten DisplayMode aus dem Host-Context. `itemDisplay`
// entscheidet je Zeile, ob sie gezeichnet und ob sie ausgegraut wird (aktives
// Element = Arbeitsfokus, immer sichtbar). Daten/Handler kommen über
// usePanelHost — kein Prop-Drilling.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { EyeIcon } from "../ui/EyeIcon";
import { itemDisplay } from "./displayMode";
import { usePanelHost } from "./host";
import type { DrawingLevel } from "./host";
import type { DisplayMode } from "./types";
import type { DrawingLevelKind } from "../model/types";
import { t } from "../i18n";
/** i18n-Key des Kurzbadges für die Art einer Zeichnungsebene. */
const KIND_BADGE_KEY: Record<DrawingLevelKind, string> = {
floor: "badge.floor",
section: "badge.section",
elevation: "badge.elevation",
drawing: "badge.drawing",
};
/** Inhalt des Panels „Zeichnungsebenen". */
export function DrawingLevelsPanel() {
const host = usePanelHost();
const { project, activeLevelId, displayMode } = host;
// Oberstes Geschoss zuoberst (wie DOSSIER): Anzeige umkehren.
const levelsTopDown = [...project.drawingLevels].reverse();
// Klick aufs Auge in „Alle anzeigen"/„Nur aktive" schaltet automatisch auf
// „Ausgewählte" (DOSSIER-Regel), damit das Sichtbarkeits-Flag sofort greift.
const toggle = (id: string) => {
const m = displayMode.mode;
if (m === "all_force" || m === "active") displayMode.setMode("all");
host.onToggleLevel(id);
};
// Rechtsklick auf eine Zeile → Kontextmenü in App öffnen (Browser-Menü aus).
const onRowContext = (id: string, e: React.MouseEvent) => {
e.preventDefault();
host.onLevelContextMenu(id, e.clientX, e.clientY);
};
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("nav.drawingLevels")}</span>
<span className="nav-group-actions">
<button className="nav-add" onClick={host.onAddFloor}>
{t("levels.addFloor")}
</button>
<button className="nav-add" onClick={host.onAddDrawing}>
{t("levels.addDrawing")}
</button>
</span>
</header>
<div className="nav-list">
{levelsTopDown.map((level) => (
<LevelRow
key={level.id}
level={level}
active={level.id === activeLevelId}
mode={displayMode.mode}
onSelect={() => host.onSelectLevel(level.id)}
onToggle={() => toggle(level.id)}
onContext={(e) => onRowContext(level.id, e)}
/>
))}
</div>
</section>
);
}
/** Eine Zeile im Zeichnungsebenen-Bereich. */
function LevelRow({
level,
active,
mode,
onSelect,
onToggle,
onContext,
}: {
level: DrawingLevel;
active: boolean;
mode: DisplayMode;
onSelect: () => void;
onToggle: () => void;
onContext: (e: React.MouseEvent) => void;
}) {
// Darstellungsmodus auf diese Zeile anwenden: greyed steuert das Dimmen,
// render würde im Modus "active" Nicht-Aktive ausblenden. Im Navigator bleibt
// jede Zeile bedienbar (Liste = Steuerung), daher blenden wir nicht ganz aus,
// sondern dimmen nicht gezeichnete Zeilen wie ausgegraute.
const display = itemDisplay(mode, active, level.visible);
const dimmed = display.greyed || !display.render;
// In „Alle anzeigen" sind die Augen gedimmt (Sichtbarkeit erzwungen, Flag
// ohne Wirkung). „grey_locked" sperrt nicht-aktive Zeilen.
const eyeDimmed = mode === "all_force";
const cls = [
"nav-row",
active ? "active" : "",
display.locked ? "locked" : "",
// Bisheriges Verhalten: nicht-aktive, unsichtbare Zeilen gedimmt.
!active && !level.visible ? "hidden" : "",
]
.filter(Boolean)
.join(" ");
// Zusätzliches Dimmen aus dem DisplayMode (nicht in styles.css → inline).
// Aktiv bleibt immer voll sichtbar.
const style = !active && dimmed ? { opacity: 0.45 } : undefined;
return (
<div
className={cls}
style={style}
onClick={onSelect}
onContextMenu={onContext}
>
<button
className={`eye${level.visible ? " is-on" : ""}`}
aria-label={t("nav.visibility")}
title={level.visible ? t("nav.hide") : t("nav.show")}
style={eyeDimmed ? { opacity: 0.5 } : undefined}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
<EyeIcon open={level.visible} />
</button>
<span className="nav-label">{level.name}</span>
{level.kind === "floor" && level.baseElevation !== undefined && (
<span className="nav-elev">+{level.baseElevation.toFixed(2)}</span>
)}
<span className={`chip badge-${level.kind}`}>
{t(KIND_BADGE_KEY[level.kind])}
</span>
</div>
);
}
+214
View File
@@ -0,0 +1,214 @@
// Schwebendes Panel — ein frei positioniertes, dunkles Fenster über der Mitte.
//
// Rendert denselben Panel-Inhalt wie ein Dock (über Registry + PanelFrame, inkl.
// Darstellungsmodus-Dropdown für Ebenen-Panels), aber in einem eigenen Fenster
// mit Titelleiste (zum Verschieben), Andock-/Schließen-Knopf und einem
// Greifer unten rechts zum Ändern der Größe. Ein Klick irgendwo im Fenster holt
// es nach vorne (z-Index).
//
// Verschieben/Größe ändern laufen über POINTER-Events mit Pointer-Capture, damit
// die Geste auch außerhalb des Fensters weiterläuft. Während des Ziehens der
// Titelleiste meldet `onTitleDrag` jede Cursorposition an den Host (App), der
// daraus eine mögliche Andock-Geste an einer Rand-Zone ableitet; beim Loslassen
// entscheidet `onTitleDrop`, ob das Fenster schwebend bleibt (verschoben) oder
// in ein Dock wandert.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { PanelFrame } from "./PanelFrame";
import { getPanel } from "./registry";
import type { DisplayMode, FloatingPanel as FloatingPanelState, PanelContext } from "./types";
import { t } from "../i18n";
/** Mindestgröße eines schwebenden Fensters in CSS-Pixeln. */
const MIN_W = 220;
const MIN_H = 160;
export interface FloatingPanelProps {
/** Zustand (Position/Größe/z) dieses schwebenden Panels. */
panel: FloatingPanelState;
/** Generischer Kontext für den Panel-Inhalt (an def.render). */
ctx?: PanelContext;
/** Aktueller Darstellungsmodus (für Panels mit hasDisplayMode). */
displayMode?: DisplayMode;
/** Setter des Darstellungsmodus (zeigt den Umschalter in der Kopfzeile). */
onDisplayModeChange?: (mode: DisplayMode) => void;
/** Holt das Fenster nach vorne (z-Index) — bei Klick/Geste-Beginn. */
onFocus: (panelId: string) => void;
/** Verschiebt das Fenster (Position-Patch). */
onMove: (panelId: string, x: number, y: number) => void;
/** Ändert die Größe (w/h-Patch). */
onResize: (panelId: string, w: number, h: number) => void;
/** Dockt das Fenster wieder an (Standard-Andockziel des Redock-Knopfs). */
onRedock: (panelId: string) => void;
/** Schließt das Fenster (entfernt es ganz). */
onClose: (panelId: string) => void;
/**
* Während des Titel-Ziehens: meldet die aktuelle Cursorposition (clientX/Y) an
* den Host. Der Host hebt mögliche Rand-Andock-Zonen hervor.
*/
onTitleDrag?: (panelId: string, clientX: number, clientY: number) => void;
/**
* Beim Loslassen der Titelleiste über einer Andock-Zone: meldet die finale
* Cursorposition. Liefert `true`, wenn der Host das Fenster angedockt hat (dann
* unterbleibt das reine Verschieben). Sonst bleibt das Fenster schwebend.
*/
onTitleDrop?: (panelId: string, clientX: number, clientY: number) => boolean;
}
/**
* Ein einzelnes schwebendes Panel-Fenster. Titelleiste = verschieben (+ Andocken
* über Rand-Zonen), Greifer unten rechts = Größe ändern, Andock-/Schließen-Knopf
* in der Titelleiste.
*/
export function FloatingPanel({
panel,
ctx,
displayMode,
onDisplayModeChange,
onFocus,
onMove,
onResize,
onRedock,
onClose,
onTitleDrag,
onTitleDrop,
}: FloatingPanelProps) {
const def = getPanel(panel.panelId);
// Verschiebe-Offset (Cursor linke/obere Kante) für ein ruckelfreies Greifen.
const dragOffset = useRef<{ dx: number; dy: number }>({ dx: 0, dy: 0 });
const onTitlePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
// Knöpfe in der Titelleiste nicht als Verschiebe-Start werten.
if ((e.target as HTMLElement).closest("button")) return;
e.preventDefault();
onFocus(panel.panelId);
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
dragOffset.current = {
dx: e.clientX - panel.x,
dy: e.clientY - panel.y,
};
const move = (ev: PointerEvent) => {
const x = ev.clientX - dragOffset.current.dx;
const y = ev.clientY - dragOffset.current.dy;
onMove(panel.panelId, x, y);
onTitleDrag?.(panel.panelId, ev.clientX, ev.clientY);
};
const up = (ev: PointerEvent) => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
// Über einer Rand-Zone? Dann dockt der Host an; sonst bleibt es schweben.
onTitleDrop?.(panel.panelId, ev.clientX, ev.clientY);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
},
[panel.panelId, panel.x, panel.y, onFocus, onMove, onTitleDrag, onTitleDrop],
);
const onResizePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
onFocus(panel.panelId);
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
const startX = e.clientX;
const startY = e.clientY;
const startW = panel.w;
const startH = panel.h;
const move = (ev: PointerEvent) => {
const w = Math.max(MIN_W, Math.round(startW + (ev.clientX - startX)));
const h = Math.max(MIN_H, Math.round(startH + (ev.clientY - startY)));
onResize(panel.panelId, w, h);
};
const up = (ev: PointerEvent) => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
},
[panel.panelId, panel.w, panel.h, onFocus, onResize],
);
const style: CSSProperties = {
left: `${panel.x}px`,
top: `${panel.y}px`,
width: `${panel.w}px`,
height: `${panel.h}px`,
zIndex: 500 + panel.z,
};
const title = def ? t(def.title) : panel.panelId;
return (
<section
className="floating-panel"
style={style}
onPointerDownCapture={() => onFocus(panel.panelId)}
role="dialog"
aria-label={title}
>
<div className="floating-titlebar" onPointerDown={onTitlePointerDown}>
<span className="floating-title">{title}</span>
<div className="floating-actions">
<button
type="button"
className="floating-btn"
title={t("dock.redock")}
aria-label={t("dock.redock")}
onClick={() => onRedock(panel.panelId)}
>
</button>
<button
type="button"
className="floating-btn"
title={t("dock.close")}
aria-label={t("dock.close")}
onClick={() => onClose(panel.panelId)}
>
×
</button>
</div>
</div>
{def ? (
<PanelFrame
def={def}
ctx={ctx}
displayMode={displayMode}
onDisplayModeChange={onDisplayModeChange}
/>
) : (
<div className="panel-frame panel-frame-empty" />
)}
{/* Greifer unten rechts (Größe ändern). */}
<div
className="floating-resize"
role="separator"
aria-label={t("dock.resizePanel")}
title={t("dock.resizePanel")}
onPointerDown={onResizePointerDown}
/>
</section>
);
}
+155
View File
@@ -0,0 +1,155 @@
// Inhalts-Panel „Ebenen" — der Kategorie-Baum als Panel.
//
// Zeigt den Grafik-Kategorie-Baum (LayerCategory) exakt wie der bisherige
// Navigator: Farb-Swatch, Code, Name, EyeIcon zum Schalten, eingerückte
// Kinder, Kopfzeile mit + Ebene. Daten/Handler über usePanelHost.
//
// Das Panel deklariert hasDisplayMode. Kategorien kennen keine „aktive"
// Auswahl (anders als Zeichnungsebenen), daher ist isActive je Zeile false;
// `itemDisplay` ergibt damit (fünf DOSSIER-Modi):
// • "all_force" — alle erzwungen sichtbar; Augen gedimmt.
// • "all" — alle sichtbaren normal (Standard, wie bisher).
// • "active" — keine Kategorie aktiv → alle gedimmt.
// • "grey" — sichtbare Kategorien ausgegraut.
// • "grey_locked" — wie „grey", zusätzlich gesperrt.
// Wie im Zeichnungsebenen-Panel bleibt jede Zeile bedienbar (die Liste ist die
// Steuerung); statt auszublenden dimmen wir gedimmte Zeilen per Inline-Opacity
// (styles.css bleibt unangetastet).
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { EyeIcon } from "../ui/EyeIcon";
import { itemDisplay } from "./displayMode";
import { usePanelHost } from "./host";
import type { LayerCategory } from "./host";
import type { DisplayMode } from "./types";
import { t } from "../i18n";
/** Inhalt des Panels „Ebenen". */
export function LayersPanel() {
const host = usePanelHost();
const { project, displayMode } = host;
// Klick aufs Auge in „Alle anzeigen"/„Nur aktive" schaltet automatisch auf
// „Ausgewählte" (DOSSIER-Regel), damit das Sichtbarkeits-Flag sofort greift.
const toggle = (code: string) => {
const m = displayMode.mode;
if (m === "all_force" || m === "active") displayMode.setMode("all");
host.onToggleCategory(code);
};
// Rechtsklick auf eine Zeile → Kontextmenü in App öffnen (Browser-Menü aus).
const onRowContext = (code: string, e: React.MouseEvent) => {
e.preventDefault();
host.onLayerContextMenu(code, e.clientX, e.clientY);
};
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("nav.layers")}</span>
<span className="nav-group-actions">
<button className="nav-add" onClick={host.onAddCategory}>
{t("layers.addCategory")}
</button>
</span>
</header>
<div className="nav-list">
{project.layers.map((cat) => (
<CategoryRow
key={cat.code}
category={cat}
depth={0}
mode={displayMode.mode}
activeCode={host.activeCategoryCode}
onSelect={host.onSelectCategory}
onToggle={toggle}
onContext={onRowContext}
/>
))}
</div>
</section>
);
}
/** Eine Kategorie-Zeile im Ebenen-Baum (rekursiv für Kinder). */
function CategoryRow({
category,
depth,
mode,
activeCode,
onSelect,
onToggle,
onContext,
}: {
category: LayerCategory;
depth: number;
mode: DisplayMode;
activeCode: string;
onSelect: (code: string) => void;
onToggle: (code: string) => void;
onContext: (code: string, e: React.MouseEvent) => void;
}) {
// „aktiv" = diese Kategorie ist das Zeichen-Ziel (per Klick gesetzt).
const isActive = category.code === activeCode;
const display = itemDisplay(mode, isActive, category.visible);
const dimmed = display.greyed || !display.render;
// In „Alle anzeigen" sind die Augen gedimmt (Sichtbarkeit erzwungen).
const eyeDimmed = mode === "all_force";
const cls = [
"cat-row",
isActive ? "active" : "",
display.locked ? "locked" : "",
!category.visible ? "hidden" : "",
]
.filter(Boolean)
.join(" ");
// Einrückung wie bisher; zusätzliches Dimmen aus dem DisplayMode inline.
// In „Alle anzeigen" erzwungen sichtbar → auch unsichtbare Zeilen normal.
const style: React.CSSProperties = { paddingLeft: 6 + depth * 14 };
if (dimmed) style.opacity = 0.45;
return (
<>
<div
className={cls}
style={style}
onClick={() => onSelect(category.code)}
onContextMenu={(e) => onContext(category.code, e)}
title={t("nav.categorySelect", { name: category.name })}
>
<button
className={`eye${category.visible ? " is-on" : ""}`}
aria-label={t("nav.categoryVisible", { name: category.name })}
title={category.visible ? t("nav.hide") : t("nav.show")}
style={eyeDimmed ? { opacity: 0.5 } : undefined}
onClick={(e) => {
e.stopPropagation();
onToggle(category.code);
}}
>
<EyeIcon open={category.visible} />
</button>
<span className="cat-code">{category.code}</span>
<span className="cat-swatch" style={{ background: category.color }} />
<span className="cat-name">{category.name}</span>
</div>
{category.children?.map((child) => (
<CategoryRow
key={child.code}
category={child}
depth={depth + 1}
mode={mode}
activeCode={activeCode}
onSelect={onSelect}
onToggle={onToggle}
onContext={onContext}
/>
))}
</>
);
}
+440
View File
@@ -0,0 +1,440 @@
// Inhalts-Panel „Objekt-Info" (Vectorworks-Stil) — kompakte Sicht auf die
// aktuelle Auswahl: ein 3×3-Bezugspunkt-„Würfel", die X/Y-Koordinaten des
// gewählten Bezugspunkts (read-only) sowie editierbare Maße Breite×Höhe.
//
// Bei Auswahl GENAU EINER Wand zeigt das Panel zusätzlich einen Wand-Abschnitt
// (Referenzlinie, Aufbau-Typ/Dicke/Preset, Referenzgeschoss + UK/OK) — analog
// Vectorworks. Die Referenzlinie sitzt rechts oben im Kopf.
//
// Alle Daten/Handler kommen über usePanelHost (kein Prop-Drilling, kein
// Modellzugriff). Der gewählte Bezugspunkt (fx,fy ∈ {0,0.5,1}) ist lokaler
// State und wird als Anker an onResizeSelection durchgereicht: Beim Ändern
// der Maße bleibt genau dieser Punkt fix.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
// Texte über t(...).
import { useState } from "react";
import { t } from "../i18n";
import { formatM } from "../model/types";
import type { VerticalAnchor, WallReferenceLine } from "../model/types";
import { Dropdown } from "../ui/Dropdown";
import { usePanelHost } from "./host";
import type { WallInfo } from "../state/selectionInfo";
// ── Bezugspunkt-Raster ───────────────────────────────────────────────────────
// Neun Anker als 3×3-Raster. Zeile 0 = oben (fy=0), Spalte 0 = links (fx=0).
// Reihenfolge des Arrays = Lesereihenfolge des Grids (links→rechts, oben→unten).
const FRACTIONS = [0, 0.5, 1] as const;
/** Stabiler Schlüssel je Anker (z. B. "0-0.5"), unabhängig von Float-Anzeige. */
function anchorKey(fx: number, fy: number): string {
return `${fx}-${fy}`;
}
export function ObjectInfoPanel() {
const host = usePanelHost();
const sel = host.selection;
// Gewählter Bezugspunkt — Default Mitte (Vectorworks-Standardanker beim
// Skalieren). Bleibt erhalten, solange das Panel montiert ist.
const [anchor, setAnchor] = useState<{ fx: number; fy: number }>({
fx: 0.5,
fy: 0.5,
});
// ── Leerzustand ───────────────────────────────────────────────────────────
if (sel === null) {
return (
<div className="objinfo-panel">
<div className="objinfo-empty">{t("objinfo.empty")}</div>
</div>
);
}
const { bbox } = sel;
const width = bbox.maxX - bbox.minX;
const height = bbox.maxY - bbox.minY;
// X/Y des gewählten Bezugspunkts (Modell-Meter). Read-only: Der Kontrakt
// bietet keinen Positions-Setter, daher wird hier kein Verschieben gefaket.
const px = bbox.minX + anchor.fx * width;
const py = bbox.minY + anchor.fy * height;
// ── Maß-Commit ──────────────────────────────────────────────────────────────
// Liest beide aktuellen Maße aus den Feldern und skaliert um den aktiven
// Anker. Negative/ungültige Werte werden ignoriert (kein Resize).
function commitSize(nextWidth: number, nextHeight: number) {
if (!isFinite(nextWidth) || !isFinite(nextHeight)) return;
if (nextWidth < 0 || nextHeight < 0) return;
host.onResizeSelection(nextWidth, nextHeight, anchor);
}
const wall = sel.wall;
return (
<div className="objinfo-panel">
{/* Kopfzeile: Typ + Kategorie. Bei einer Wand rechts oben das
Referenzlinie-Dropdown (wo die Achse über die Dicke liegt). */}
<div className="objinfo-head">
<span className="objinfo-kind">{t(`objinfo.kind.${sel.kind}`)}</span>
{wall ? (
<div className="objinfo-head-ref" title={t("objinfo.wall.refLine")}>
<span className="objinfo-head-ref-label">
{t("objinfo.wall.refLine")}
</span>
<Dropdown
value={wall.referenceLine}
onChange={(v) =>
host.onSetWallReferenceLine(v as WallReferenceLine)
}
options={[
{ value: "left", label: t("objinfo.wall.refLine.left") },
{ value: "center", label: t("objinfo.wall.refLine.center") },
{ value: "right", label: t("objinfo.wall.refLine.right") },
]}
title={t("objinfo.wall.refLine")}
width={120}
/>
</div>
) : (
<span className="objinfo-cat">{sel.categoryCode}</span>
)}
</div>
<div className="objinfo-sep" />
{/* Bezugspunkt-Würfel (3×3). */}
<div className="objinfo-section-label">{t("objinfo.refpoint")}</div>
<div className="objinfo-cube" role="group" aria-label={t("objinfo.refpoint")}>
{FRACTIONS.map((fy) =>
FRACTIONS.map((fx) => {
const active = anchor.fx === fx && anchor.fy === fy;
return (
<button
key={anchorKey(fx, fy)}
type="button"
className={"objinfo-anchor" + (active ? " active" : "")}
title={t("objinfo.setRefpoint")}
aria-pressed={active}
onClick={() => setAnchor({ fx, fy })}
>
<span className="objinfo-dot" />
</button>
);
}),
)}
</div>
{/* X / Y des gewählten Bezugspunkts (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.x")}</span>
<span className="objinfo-fval">{formatM(px)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.y")}</span>
<span className="objinfo-fval">{formatM(py)}</span>
</div>
{/* Z ehrlich: Das Modell ist 2D pro Ebene; es gibt keine Z am Element.
Daher „—" statt einer gefakten 0, mit kurzem Hinweis im Titel. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.z")}</span>
<span className="objinfo-fval objinfo-muted" title={t("objinfo.zHint")}>
</span>
</div>
<div className="objinfo-sep" />
{/* Maße (editierbar) — Commit auf blur/Enter skaliert um den Anker. */}
<div className="objinfo-section-label">{t("objinfo.dimensions")}</div>
<DimensionField
label={t("objinfo.width")}
value={width}
onCommit={(w) => commitSize(w, height)}
/>
<DimensionField
label={t("objinfo.height")}
value={height}
onCommit={(h) => commitSize(width, h)}
/>
{/* ── Wand-Abschnitt (nur bei genau einer Wand) ────────────────────── */}
{wall && <WallSection wall={wall} host={host} />}
</div>
);
}
// ── Wand-Attribut-Abschnitt ──────────────────────────────────────────────────
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
// Oberverknüpfung, UK/OK je Modus-Umschalter. Alle Werte/Setter über den Host.
function WallSection({
wall,
host,
}: {
wall: WallInfo;
host: ReturnType<typeof usePanelHost>;
}) {
// Aufbau-Typ-Segment: einschichtig vs. mehrschichtig. Aus dem Modell
// abgeleitet (singleLayer); der Umschalter auf „mehrschichtig" wählt einen
// mehrschichtigen Wandtyp-Preset (erster mit >1 Schicht), auf „einschichtig"
// setzt er die Dicke des aktuellen Aufbaus als einschichtige Wand.
const isSingle = wall.singleLayer;
const multiPresets = wall.wallTypes.filter((wt) => wt.layerCount > 1);
function chooseSingle() {
if (isSingle) return;
// Auf einschichtig wechseln: aktuelle Gesamtdicke als Einzelschicht setzen.
host.onSetWallThickness(wall.thickness);
}
function chooseMulti() {
if (!isSingle) return;
const first = multiPresets[0];
if (first) host.onSetWallType(first.id);
}
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.wall.section")}</div>
{/* Aufbau-Typ-Segment. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span>
<div className="objinfo-segment">
<button
type="button"
className={"objinfo-seg-btn" + (isSingle ? " active" : "")}
onClick={chooseSingle}
>
{t("objinfo.wall.single")}
</button>
<button
type="button"
className={"objinfo-seg-btn" + (!isSingle ? " active" : "")}
onClick={chooseMulti}
disabled={isSingle && multiPresets.length === 0}
>
{t("objinfo.wall.multi")}
</button>
</div>
</div>
{/* Einschichtig → Dicke-Zahlfeld. Mehrschichtig → Preset-Dropdown. */}
{isSingle ? (
<DimensionField
label={t("objinfo.wall.thickness")}
value={wall.thickness}
onCommit={(v) => host.onSetWallThickness(v)}
/>
) : (
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.preset")}</span>
<Dropdown
value={wall.wallTypeId}
onChange={(id) => host.onSetWallType(id)}
options={wall.wallTypes.map((wt) => ({
value: wt.id,
label: t("objinfo.wall.presetLabel", {
name: wt.name,
thickness: wt.thickness.toFixed(2),
}),
}))}
title={t("objinfo.wall.preset")}
width={150}
/>
</div>
)}
{/* Referenzgeschoss (read-only Anzeige). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.refFloor")}</span>
<span className="objinfo-fval">{wall.floorName}</span>
</div>
{/* Verknüpfung zum oberen Geschoss: OK an nächstes Geschoss binden. */}
<label className="objinfo-field objinfo-check-field">
<span className="objinfo-flabel">{t("objinfo.wall.linkAbove")}</span>
<input
type="checkbox"
className="objinfo-check"
checked={wall.top?.mode === "floor"}
disabled={!wall.floorAbove}
title={
wall.floorAbove
? wall.floorAbove.name
: t("objinfo.wall.floorAboveNone")
}
onChange={(e) => {
if (e.target.checked && wall.floorAbove) {
host.onSetWallTop({ mode: "floor", floorId: wall.floorAbove.id });
} else {
host.onSetWallTop(null);
}
}}
/>
</label>
<div className="objinfo-sep" />
{/* UK & OK getrennt: je „an Geschoss gebunden" ODER „eigene Höhe". */}
<VerticalAnchorRow
label={t("objinfo.wall.bottom")}
anchor={wall.bottom}
defaultZ={wall.zBottom}
floors={wall.floors}
defaultFloorId={wall.floorId}
onChange={(a) => host.onSetWallBottom(a)}
/>
<VerticalAnchorRow
label={t("objinfo.wall.top")}
anchor={wall.top}
defaultZ={wall.zTop}
floors={wall.floors}
defaultFloorId={wall.floorAbove?.id ?? wall.floorId}
onChange={(a) => host.onSetWallTop(a)}
/>
{/* Höhe = OK UK (abgeleitet, read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.height")}</span>
<span className="objinfo-fval">{formatM(wall.zTop - wall.zBottom)}</span>
</div>
</>
);
}
// ── UK/OK-Zeile: Modus-Umschalter (Geschoss/eigene Höhe) + Feld ──────────────
// „Geschoss gebunden" → Geschoss-Dropdown; „eigene Höhe" → absolutes Z-Feld.
// `null`-Anchor (undefined im Modell) = Geschoss-Default; zeigt „eigene Höhe"
// mit dem aufgelösten Default-Z als Ausgangswert NICHT — Default ist
// „an Geschoss gebunden" (Geschoss-Default-Verhalten).
function VerticalAnchorRow({
label,
anchor,
defaultZ,
floors,
defaultFloorId,
onChange,
}: {
label: string;
anchor?: VerticalAnchor;
defaultZ: number;
floors: { id: string; name: string }[];
defaultFloorId: string;
onChange: (a: VerticalAnchor | null) => void;
}) {
const mode: "floor" | "custom" = anchor?.mode === "custom" ? "custom" : "floor";
function setMode(next: "floor" | "custom") {
if (next === mode) return;
if (next === "custom") {
onChange({ mode: "custom", z: defaultZ });
} else {
// Zurück auf Geschoss-Bindung: bei vorhandenem expliziten Geschoss dieses,
// sonst Default-Geschoss → entspricht dem Geschoss-Default-Verhalten.
onChange({ mode: "floor", floorId: defaultFloorId });
}
}
return (
<div className="objinfo-wall-anchor">
<div className="objinfo-field">
<span className="objinfo-flabel">{label}</span>
<div className="objinfo-segment">
<button
type="button"
className={"objinfo-seg-btn" + (mode === "floor" ? " active" : "")}
onClick={() => setMode("floor")}
>
{t("objinfo.wall.anchorFloor")}
</button>
<button
type="button"
className={"objinfo-seg-btn" + (mode === "custom" ? " active" : "")}
onClick={() => setMode("custom")}
>
{t("objinfo.wall.anchorCustom")}
</button>
</div>
</div>
{mode === "floor" ? (
<div className="objinfo-field objinfo-subfield">
<Dropdown
value={anchor?.mode === "floor" ? anchor.floorId : defaultFloorId}
onChange={(id) => onChange({ mode: "floor", floorId: id })}
options={floors.map((f) => ({ value: f.id, label: f.name }))}
title={label}
width={150}
/>
</div>
) : (
<div className="objinfo-subfield">
<DimensionField
label={t("objinfo.z")}
value={anchor?.mode === "custom" ? anchor.z : defaultZ}
min={-Infinity}
onCommit={(z) => onChange({ mode: "custom", z })}
/>
</div>
)}
</div>
);
}
// ── Editierbares Maßfeld ─────────────────────────────────────────────────────
// Kontrolliert über den vom Host gelieferten Wert, hält aber während des
// Tippens einen lokalen Entwurf. Commit auf blur/Enter; Escape verwirft.
// Nach erfolgreichem Resize liefert der Host eine neue bbox → neuer `value` →
// das Feld zeigt automatisch die aktualisierte Größe. `min` erlaubt negative
// Werte (z. B. absolute Z-Höhen unter 0).
function DimensionField({
label,
value,
onCommit,
min = 0,
}: {
label: string;
value: number;
onCommit: (v: number) => void;
min?: number;
}) {
// Entwurf als String, damit Zwischenzustände (leer, „1.") tippbar sind.
// `null` = kein aktiver Entwurf → zeige den Host-Wert.
const [draft, setDraft] = useState<string | null>(null);
const shown = draft ?? value.toFixed(3);
function commit() {
if (draft === null) return;
const v = Number(draft);
setDraft(null);
if (isFinite(v) && v >= min) onCommit(v);
}
return (
<label className="objinfo-field">
<span className="objinfo-flabel">{label}</span>
<input
className="objinfo-input"
type="number"
step={0.01}
min={isFinite(min) ? min : undefined}
value={shown}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
commit();
(e.target as HTMLInputElement).blur();
} else if (e.key === "Escape") {
setDraft(null);
(e.target as HTMLInputElement).blur();
}
}}
/>
<span className="objinfo-unit">m</span>
</label>
);
}
+67
View File
@@ -0,0 +1,67 @@
// Panel-Rahmen — Kopfzeile + scrollbarer Körper um einen Panel-Inhalt.
//
// Der Rahmen ist inhaltsneutral: er bekommt eine PanelDef (für Titel und das
// Flag hasDisplayMode) und den generischen PanelContext, mit dem er
// `def.render(ctx)` aufruft. Den eigentlichen Inhalt liefert also das Panel
// selbst; der Rahmen kümmert sich nur um Titelzeile, optionalen
// Darstellungsmodus-Umschalter und den überlaufenden, scrollbaren Körper.
//
// Der Darstellungsmodus wird von außen durchgereicht (value + onChange), damit
// der Rahmen nichts vom Host-Context wissen muss. Der Umschalter erscheint nur,
// wenn das Panel ihn deklariert (def.hasDisplayMode) UND ein onChange anliegt.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { DisplayModeSelect } from "./DisplayModeSelect";
import type { DisplayMode, PanelContext, PanelDef } from "./types";
import { t } from "../i18n";
export interface PanelFrameProps {
/** Definition des darzustellenden Panels (liefert Titel + hasDisplayMode). */
def: PanelDef;
/** Generischer Kontext, der an `def.render` weitergereicht wird. */
ctx?: PanelContext;
/**
* Aktueller Darstellungsmodus. Nur relevant, wenn das Panel
* `hasDisplayMode` deklariert; der Umschalter ist sonst ausgeblendet.
*/
displayMode?: DisplayMode;
/**
* Setter für den Darstellungsmodus. Liegt er an und deklariert das Panel
* `hasDisplayMode`, zeigt die Kopfzeile das Dropdown.
*/
onDisplayModeChange?: (mode: DisplayMode) => void;
}
/**
* Container eines einzelnen Panels: Kopfzeile (Titel + optionaler
* Darstellungsmodus-Umschalter) über einem scrollbaren Körper, der den
* gerenderten Panel-Inhalt aufnimmt.
*/
export function PanelFrame({
def,
ctx,
displayMode,
onDisplayModeChange,
}: PanelFrameProps) {
// Umschalter nur, wenn das Panel ihn will UND er verdrahtet ist.
const showMode =
Boolean(def.hasDisplayMode) &&
onDisplayModeChange !== undefined &&
displayMode !== undefined;
return (
<section className="panel-frame">
<header className="panel-head">
<span className="panel-title">{t(def.title)}</span>
{showMode && (
<DisplayModeSelect
value={displayMode as DisplayMode}
onChange={onDisplayModeChange as (m: DisplayMode) => void}
/>
)}
</header>
<div className="panel-body">{def.render(ctx ?? {})}</div>
</section>
);
}
+91
View File
@@ -0,0 +1,91 @@
// Inhalts-Panel „Ressourcen" — dünner Adapter um den ResourceManager.
//
// Wiederverwendet die bestehende <ResourceManager>-Komponente (Bauteile /
// Schraffuren / Linien) UNVERÄNDERT, rendert sie aber INLINE als Panel-Körper
// statt als rechte Schublade. Projekt + Handler kommen aus dem Host-Context.
//
// ResourceManager ist als Overlay-Schublade gebaut (.res-overlay fixed/inset:0
// → .res-drawer mit fester Breite, Rand und Schatten) und erwartet onClose.
// Da styles.css und ResourceManager selbst nicht angefasst werden dürfen,
// zähmt dieser Adapter die Schubladen-Chrome lokal: ein eingebetteter,
// gescopter <style>-Block (kein Eingriff in styles.css) löst das Overlay aus
// der Fixpositionierung, lässt die „Schublade" die Panel-Fläche füllen und
// blendet den Schließen-Knopf aus (ein Panel schließt man über den Rahmen,
// nicht über die Schublade). onClose ist daher ein No-Op.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { ResourceManager } from "../ui/ResourceManager";
import type { ResourceManagerHandlers } from "../ui/ResourceManager";
import { usePanelHost } from "./host";
/** Inhalt des Panels „Ressourcen". */
export function ResourcesPanel() {
const host = usePanelHost();
// Handler aus dem Host auf die Signatur des ResourceManager bündeln.
const handlers: ResourceManagerHandlers = {
onPatchComponent: host.onPatchComponent,
onAddComponent: host.onAddComponent,
onDeleteComponent: host.onDeleteComponent,
onPatchHatch: host.onPatchHatch,
onAddHatch: host.onAddHatch,
onDeleteHatch: host.onDeleteHatch,
onPatchLineStyle: host.onPatchLineStyle,
onAddLineStyle: host.onAddLineStyle,
onDeleteLineStyle: host.onDeleteLineStyle,
onImportLineStyles: host.onImportLineStyles,
onImportHatches: host.onImportHatches,
};
return (
<div className="panel-resources-host">
{/* Gescopte Override-Regeln: machen aus der Overlay-Schublade einen
flächenfüllenden Inline-Körper. Betrifft nur Nachfahren dieses
Wrappers — styles.css bleibt unberührt. */}
<style>{PANEL_RESOURCES_CSS}</style>
<ResourceManager
project={host.project}
handlers={handlers}
onClose={NO_OP}
/>
</div>
);
}
/** onClose-Platzhalter: als Panel gibt es keinen Schubladen-Schließer. */
const NO_OP = () => {};
/**
* Lokale Override-Regeln, die die fixe Overlay-Schublade in einen normalen,
* im Panel sitzenden Block verwandeln. Klassen-Selektoren sind unter
* `.panel-resources-host` verschachtelt, damit nichts außerhalb des Panels
* betroffen ist.
*/
const PANEL_RESOURCES_CSS = `
.panel-resources-host {
height: 100%;
min-height: 0;
display: flex;
}
.panel-resources-host .res-overlay {
position: static;
inset: auto;
z-index: auto;
background: transparent;
display: flex;
flex: 1;
min-height: 0;
justify-content: stretch;
}
.panel-resources-host .res-drawer {
width: 100%;
height: 100%;
border-left: none;
box-shadow: none;
}
/* Im Panel ohne Schubladen-Schließer. */
.panel-resources-host .res-close {
display: none;
}
`;
+125
View File
@@ -0,0 +1,125 @@
// Inhalts-Panel „Gelände & Kontext" — verwaltet die Kontext-Schicht
// (importierte Meshes, Höhenlinien-Sätze, abgeleitetes Gelände-TIN). Daten/
// Handler kommen über usePanelHost (kein Prop-Drilling).
//
// Aufbau:
// • Aktions-Zeile: „DXF importieren" (öffnet den in App montierten Datei-
// Dialog) + „Gelände erzeugen" (aus dem gewählten Kontur-Satz).
// • Liste der Kontext-Objekte: Name + Typ-Badge + „Entfernen".
// • DWG-Hinweis am Fuß.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
// Texte über t(...).
import { useState } from "react";
import { t } from "../i18n";
import type { TranslationKey } from "../i18n";
import { usePanelHost } from "./host";
import type { ContextObject } from "../model/types";
/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */
function typeBadgeKey(obj: ContextObject): TranslationKey {
switch (obj.type) {
case "importedMesh":
return "site.type.mesh";
case "contourSet":
return "site.type.contours";
case "terrainMesh":
return "site.type.terrain";
}
}
export function SitePanel() {
const host = usePanelHost();
const objects = host.contextObjects;
const contourSets = objects.filter((o) => o.type === "contourSet");
// Gewählter Kontur-Satz für „Gelände erzeugen" (default: erster vorhandener).
const [selectedSet, setSelectedSet] = useState<string>("");
const effectiveSet =
selectedSet && contourSets.some((c) => c.id === selectedSet)
? selectedSet
: (contourSets[0]?.id ?? "");
const onGenerate = () => {
if (!effectiveSet) return;
const id = host.onGenerateTerrain(effectiveSet);
if (id === null) {
// Kein sinnvolles TIN — ruhiger Hinweis (keine harte Fehlerbox).
// eslint-disable-next-line no-alert
alert(t("site.terrainFailed"));
}
};
return (
<div className="site-panel">
{/* Aktions-Zeile: Import + Gelände-Erzeugung. */}
<div className="site-actions">
<button
type="button"
className="site-btn"
onClick={() => host.onImportDxf()}
>
{t("site.import")}
</button>
</div>
{contourSets.length > 0 && (
<div className="site-terrain-row">
{contourSets.length > 1 ? (
<select
className="site-select"
value={effectiveSet}
onChange={(e) => setSelectedSet(e.target.value)}
aria-label={t("site.contourSet")}
>
{contourSets.map((cs) => (
<option key={cs.id} value={cs.id}>
{cs.name}
</option>
))}
</select>
) : (
<span className="site-terrain-src" title={t("site.contourSet")}>
{contourSets[0].name}
</span>
)}
<button type="button" className="site-btn" onClick={onGenerate}>
{t("site.generateTerrain")}
</button>
</div>
)}
<div className="site-sep" />
{/* Liste der Kontext-Objekte (Name + Typ-Badge + Entfernen). */}
{objects.length === 0 ? (
<div className="site-empty">{t("site.empty")}</div>
) : (
<ul className="site-list">
{objects.map((obj) => (
<li key={obj.id} className="site-row">
<span className="site-name" title={obj.name}>
{obj.name}
</span>
<span className={"site-badge site-badge-" + obj.type}>
{t(typeBadgeKey(obj))}
</span>
<button
type="button"
className="site-remove"
title={t("site.delete")}
aria-label={t("site.delete")}
onClick={() => host.onRemoveContextObject(obj.id)}
>
×
</button>
</li>
))}
</ul>
)}
<div className="site-hint">{t("site.dwgHint")}</div>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
// Tab-Leiste eines Docks — ein Tab je Panel-ID, Titel aus der Registry.
//
// Rein darstellend: bekommt einen DockState (Reihenfolge der Panel-IDs +
// aktiver Tab) und meldet das Greifen eines Tabs über `onStartDrag`. Der
// Drag-Controller (panelDrag) entscheidet anhand der Bewegung, ob daraus ein
// Klick (Tab aktivieren) oder ein Ziehen (Reorder/Andocken/Lösen) wird — die
// Leiste selbst kennt diese Logik nicht.
//
// Titel werden über die Registry (getPanel) aufgelöst, sodass die Leiste nichts
// über konkrete Panels weiß. IDs ohne registriertes Panel (z. B. ein
// abgeschaltetes Plugin) werden übersprungen — vgl. Hinweis in layout.ts.
//
// Datenattribute für die Drag-Trefferprüfung (panelDrag.resolveHover):
// data-tabstrip="<dock>" an der Leiste,
// data-group-index="<g>" an der Leiste (welche Gruppe im Dock),
// data-tab-index="<i>" an jedem Tab.
//
// Viele Tabs laufen nicht um, sondern werden horizontal scrollbar (die Leiste
// trägt .tabstrip mit overflow-x in styles.css). So bleibt jeder Tab erreichbar,
// ohne ein zusätzliches Überlaufmenü zu brauchen.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { getPanel } from "./registry";
import type { DockGroup, DockId } from "./types";
import { t } from "../i18n";
export interface TabStripProps {
/** Welches Dock diese Leiste darstellt (für die Drag-Auflösung). */
side: DockId;
/** Index der Gruppe im Dock (für die Drag-Auflösung). */
groupIndex: number;
/** Zustand der Gruppe (liefert tabs-Reihenfolge + activeTab). */
group: DockGroup;
/**
* Beginn einer Tab-Geste (pointerdown auf einem Tab). Der Drag-Controller
* entscheidet über die Bewegung, ob Klick oder Ziehen.
*/
onStartDrag: (panelId: string, e: React.PointerEvent) => void;
}
/**
* Reihe greifbarer Tabs für eine Dock-Gruppe. Der aktive Tab ist hervorgehoben;
* das Greifen eines Tabs meldet `onStartDrag` (Klick vs. Ziehen klärt der
* Controller).
*/
export function TabStrip({ side, groupIndex, group, onStartDrag }: TabStripProps) {
return (
<div
className="tabstrip"
role="tablist"
data-tabstrip={side}
data-group-index={groupIndex}
>
{group.tabs.map((id, index) => {
const def = getPanel(id);
// Unbekannte ID (kein registriertes Panel) überspringen.
if (!def) return null;
const active = id === group.activeTab;
const title = t(def.title);
return (
<button
key={id}
type="button"
role="tab"
aria-selected={active}
title={title}
data-tab-index={index}
className={`tab${active ? " active" : ""}`}
onPointerDown={(e) => onStartDrag(id, e)}
>
{title}
</button>
);
})}
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
// Inhalts-Panel „Werkzeuge" — die vertikale Werkzeug-Palette (ArchiCAD-/
// Vectorworks-Stil): eine Zeile je Zeichenwerkzeug, jeweils Icon + Name, das
// aktive Werkzeug hervorgehoben. Darunter — wenn das Wand-Werkzeug aktiv ist —
// ein Wandtyp-Auswahlfeld, und ein kompakter Fang-Abschnitt mit Checkboxen.
//
// Daten/Handler kommen über usePanelHost (kein Prop-Drilling). Die Werkzeug-
// Liste und ihre Metadaten stammen aus der Tools-Registry (TOOL_ORDER/getTool).
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
// Texte über t(...).
import { t } from "../i18n";
import { usePanelHost } from "./host";
import type { ToolId } from "./host";
import { TOOL_ORDER, getTool } from "../tools/tools";
// ── Icons ──────────────────────────────────────────────────────────────────
// Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug
// (~16×16, stroke=currentColor, fill=none), damit das Icon die Textfarbe der
// Zeile übernimmt.
function ToolIcon({ id }: { id: ToolId }) {
const common = {
width: 16,
height: 16,
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.4,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
switch (id) {
case "select":
// Pfeil-Cursor.
return (
<svg {...common} fill="currentColor" stroke="none">
<path d="M3 2l9 5-3.6 1.1L10.4 13 8.5 13.7 6.6 9.4 3 11z" />
</svg>
);
case "wall":
// Zwei parallele Linien (Wandband).
return (
<svg {...common}>
<line x1="2" y1="5.5" x2="14" y2="5.5" />
<line x1="2" y1="10.5" x2="14" y2="10.5" />
</svg>
);
case "line":
// Einzelne Diagonale.
return (
<svg {...common}>
<line x1="3" y1="13" x2="13" y2="3" />
</svg>
);
case "polyline":
// Dreisegmentiger Zickzack.
return (
<svg {...common}>
<polyline points="2,12 6,5 10,11 14,4" />
</svg>
);
case "rect":
// Rechteck-Umriss.
return (
<svg {...common}>
<rect x="2.5" y="3.5" width="11" height="9" />
</svg>
);
default:
return null;
}
}
// ── Panel ────────────────────────────────────────────────────────────────────
export function ToolsPanel() {
const host = usePanelHost();
const snap = host.snap;
return (
<div className="tools-panel">
{/* Werkzeug-Zeilen: Icon + Name, aktives hervorgehoben. */}
{TOOL_ORDER.map((id) => {
const tool = getTool(id);
const active = host.activeTool === id;
const disabled = id !== "select" && !host.toolsEnabled;
const title = disabled
? t("tool.floorOnlyDisabled")
: t(tool.hintKey(tool.init()));
const cls =
"tool-row" + (active ? " active" : "") + (disabled ? " disabled" : "");
return (
<button
key={id}
type="button"
className={cls}
title={title}
disabled={disabled}
onClick={() => host.onSelectTool(id)}
>
<ToolIcon id={id} />
<span>{t(tool.labelKey)}</span>
</button>
);
})}
<div className="tools-sep" />
{/* Wandtyp-Auswahl — nur sichtbar, wenn das Wand-Werkzeug aktiv ist. */}
{host.activeTool === "wall" && (
<label className="tools-field">
<span>{t("tool.wallType")}</span>
<select
value={host.activeWallTypeId}
disabled={!host.toolsEnabled}
onChange={(e) => host.onActiveWallTypeId(e.target.value)}
>
{host.project.wallTypes.map((wt) => (
<option key={wt.id} value={wt.id}>
{wt.name}
</option>
))}
</select>
</label>
)}
{host.activeTool === "wall" && <div className="tools-sep" />}
{/* Fang-Abschnitt. Master-Checkbox steuert die übrigen Fang-Optionen. */}
<label className="tools-snap-row">
<input
type="checkbox"
checked={snap.enabled}
onChange={(e) => host.onSnapChange({ ...snap, enabled: e.target.checked })}
/>
<span>{t("snap.enabled")}</span>
</label>
<label className="tools-snap-row">
<input
type="checkbox"
checked={snap.endpoint}
disabled={!snap.enabled}
onChange={(e) => host.onSnapChange({ ...snap, endpoint: e.target.checked })}
/>
<span>{t("snap.endpoint")}</span>
</label>
<label className="tools-snap-row">
<input
type="checkbox"
checked={snap.midpoint}
disabled={!snap.enabled}
onChange={(e) => host.onSnapChange({ ...snap, midpoint: e.target.checked })}
/>
<span>{t("snap.midpoint")}</span>
</label>
<label className="tools-snap-row">
<input
type="checkbox"
checked={snap.intersection}
disabled={!snap.enabled}
onChange={(e) =>
host.onSnapChange({ ...snap, intersection: e.target.checked })
}
/>
<span>{t("snap.intersection")}</span>
</label>
<label className="tools-snap-row">
<input
type="checkbox"
checked={snap.onEdge}
disabled={!snap.enabled}
onChange={(e) => host.onSnapChange({ ...snap, onEdge: e.target.checked })}
/>
<span>{t("snap.onEdge")}</span>
</label>
<label className="tools-snap-row">
<input
type="checkbox"
checked={snap.grid}
disabled={!snap.enabled}
onChange={(e) => host.onSnapChange({ ...snap, grid: e.target.checked })}
/>
<span>{t("snap.grid")}</span>
</label>
<label className="tools-snap-row">
<input
type="checkbox"
checked={snap.ortho}
disabled={!snap.enabled}
onChange={(e) => host.onSnapChange({ ...snap, ortho: e.target.checked })}
/>
<span>{t("snap.ortho")}</span>
</label>
<label className="tools-field">
<span>{t("snap.gridSize")}</span>
<input
type="number"
step={0.05}
min={0.01}
value={snap.gridSize}
onChange={(e) =>
host.onSnapChange({ ...snap, gridSize: Number(e.target.value) })
}
/>
</label>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
// Registrierung der eingebauten Inhalts-Panels.
//
// Importiert man dieses Modul (Seiteneffekt), tragen sich die beiden
// dockbaren Kern-Panels in die Registry ein und tauchen damit in den Docks auf:
// • "drawing-levels" — Zeichnungsebenen (mit Darstellungsmodus).
// • "layers" — Ebenen (Kategorie-Baum, mit Darstellungsmodus).
//
// „Ressourcen" ist KEIN Dock-Panel mehr: der ResourceManager ist ein
// schwebendes Fenster (Overlay über allem), das die Oberleiste öffnet — siehe
// App.tsx. Es wird daher hier nicht (mehr) registriert.
//
// Reine Verdrahtung: jedes Panel liest seinen Zustand selbst über den
// Host-Context (usePanelHost), daher reicht render() ohne ctx-Auswertung.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { registerPanel } from "./registry";
import { DrawingLevelsPanel } from "./DrawingLevelsPanel";
import { LayersPanel } from "./LayersPanel";
import { ToolsPanel } from "./ToolsPanel";
import { AttributesPanel } from "./AttributesPanel";
import { ObjectInfoPanel } from "./ObjectInfoPanel";
import { SitePanel } from "./SitePanel";
/** IDs der eingebauten Dock-Panels (auch für defaultLayout/Filter nutzbar). */
export const BUILTIN_PANEL_IDS = {
tools: "tools",
drawingLevels: "drawing-levels",
layers: "layers",
attributes: "attributes",
objectInfo: "object-info",
site: "site",
} as const;
// Werkzeug-Palette (Symbol + Name je Werkzeug, ArchiCAD/Vectorworks-Stil).
registerPanel({
id: BUILTIN_PANEL_IDS.tools,
title: "nav.tools",
hasDisplayMode: false,
render: () => <ToolsPanel />,
});
// Attribute-Palette (Stift/Füllung/Strichstärke der Auswahl, Vectorworks-Stil).
registerPanel({
id: BUILTIN_PANEL_IDS.attributes,
title: "nav.attributes",
hasDisplayMode: false,
render: () => <AttributesPanel />,
});
// Objekt-Info-Palette (Bezugspunkt-Würfel + X/Y, Maße B×H der Auswahl).
registerPanel({
id: BUILTIN_PANEL_IDS.objectInfo,
title: "nav.objectInfo",
hasDisplayMode: false,
render: () => <ObjectInfoPanel />,
});
registerPanel({
id: BUILTIN_PANEL_IDS.drawingLevels,
// i18n-Key statt fertigem Text: TabStrip/PanelFrame lösen ihn über t() auf,
// damit der Titel der gewählten Sprache folgt.
title: "nav.drawingLevels",
hasDisplayMode: true,
render: () => <DrawingLevelsPanel />,
});
registerPanel({
id: BUILTIN_PANEL_IDS.layers,
title: "nav.layers",
hasDisplayMode: true,
render: () => <LayersPanel />,
});
// Gelände-/Kontext-Palette (DXF-Import, Konturen-Liste, Gelände-Erzeugung).
registerPanel({
id: BUILTIN_PANEL_IDS.site,
title: "nav.site",
hasDisplayMode: false,
render: () => <SitePanel />,
});
+69
View File
@@ -0,0 +1,69 @@
// Darstellungsmodus — reiner Helfer.
//
// Übersetzt einen DisplayMode plus den Status eines Elements (aktiv? sichtbar?)
// in eine Render-Entscheidung: ob das Element gezeichnet wird, ob es ausgegraut
// erscheint und ob es gesperrt ist. Dieselbe Logik nutzen Navigator-Listen und
// die abgeleiteten Ansichten (3D, Grundriss), damit die Auswahl überall
// identisch wirkt. Die fünf Modi entsprechen DOSSIER (docs/design/context-menu.md).
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Bewusst seiteneffektfrei.
import type { DisplayMode } from "./types";
/** Ergebnis von `itemDisplay`: zeichnen? ausgegraut? gesperrt? */
export interface ItemDisplay {
/** Element zeichnen. */
render: boolean;
/** Element ausgegraut (gedimmt) darstellen. */
greyed: boolean;
/** Element gesperrt (nicht bearbeitbar) — nur im Modus „grey_locked". */
locked: boolean;
}
/**
* Entscheidet anhand des Modus und des Element-Status, wie ein Element
* dargestellt wird — die fünf DOSSIER-Modi:
*
* • "all_force" — alle erzwungen zeichnen (Sichtbarkeits-Flag ignoriert),
* nie ausgegraut.
* • "all" — nur sichtbare zeichnen, nie ausgegraut.
* • "active" — nur das aktive Element zeichnen, nie ausgegraut.
* • "grey" — aktives normal; übrige nur, wenn sichtbar — dann
* ausgegraut.
* • "grey_locked" — wie „grey", übrige (sichtbare, nicht aktive) zusätzlich
* gesperrt.
*
* Das aktive Element wird in "active"/"grey"/"grey_locked" stets gezeichnet,
* auch wenn seine Sichtbarkeit abgeschaltet ist — es ist der Arbeitsfokus. Ein
* nicht aktives, unsichtbares Element wird (außer in "all_force") nie
* gezeichnet.
*/
export function itemDisplay(
mode: DisplayMode,
isActive: boolean,
isVisible: boolean,
): ItemDisplay {
switch (mode) {
case "all_force":
// Alles erzwungen sichtbar (Flags ignoriert), normal.
return { render: true, greyed: false, locked: false };
case "all":
return { render: isVisible, greyed: false, locked: false };
case "active":
// Nur das aktive Element; andere werden nicht gezeichnet.
return { render: isActive, greyed: false, locked: false };
case "grey": {
const other = isVisible && !isActive;
return { render: isActive || isVisible, greyed: other, locked: false };
}
case "grey_locked": {
// Wie „grey", übrige sichtbare zusätzlich gesperrt.
const other = isVisible && !isActive;
return { render: isActive || isVisible, greyed: other, locked: other };
}
}
}
+197
View File
@@ -0,0 +1,197 @@
// Host-Vertrag für Inhalts-Panels — die konkrete Form des PanelHostContext.
//
// Die Foundation (types.ts) hält den Context bewusst lose (Record<string,
// unknown>), damit der Kern nicht vom Projekt-/Handler-Modell abhängt. Die
// eingebauten Panels brauchen jedoch eine klare, getippte Sicht auf den Host.
// Dieses Modul definiert daher EINEN Vertrag (`PanelHostValue`) und einen Hook
// (`usePanelHost`), der den Context liest, gegen das Fehlen eines Providers
// absichert und das Ergebnis auf diesen Vertrag verengt.
//
// App stellt einen Wert dieser Form über <PanelHostContext.Provider> bereit
// (siehe Report am Ende der Aufgabe). Bezeichner englisch, Kommentare deutsch
// (CONVENTIONS.md).
import { useContext } from "react";
import { PanelHostContext } from "./types";
import type { DisplayMode } from "./types";
import type {
Component,
ContextObject,
DrawingLevel,
HatchStyle,
LayerCategory,
LineStyle,
Project,
VerticalAnchor,
WallReferenceLine,
} from "../model/types";
import type { SnapSettings, ToolId } from "../tools/types";
import type { Selection } from "../state/selectionInfo";
// ── Darstellungsmodus-Steuerung je Dock-Inhalt ────────────────────────────
/**
* Aktueller Darstellungsmodus plus Setter. Panels, die `hasDisplayMode: true`
* deklarieren, lesen `mode` und gruppieren ihre Zeilen danach (über
* `itemDisplay` aus displayMode.ts). Der Umschalter selbst sitzt in der
* Panel-Kopfzeile des Rahmens und ruft `setMode`.
*/
export interface DisplayModeControl {
mode: DisplayMode;
setMode: (mode: DisplayMode) => void;
}
// ── Vollständiger Host-Vertrag ─────────────────────────────────────────────
/**
* Was die eingebauten Panels vom Host erwarten. App reicht genau dieses Objekt
* über <PanelHostContext.Provider value={…}> hinein; jedes Feld entspricht
* einem Stück Zustand bzw. einem immutablen Handler, der heute in App.tsx als
* Closure über setProject existiert.
*/
export interface PanelHostValue {
// Projektzustand (Single Source of Truth aus App).
project: Project;
// Aktive Auswahl (Zeichnungsebene) — Arbeitsfokus für „active"/„grey".
activeLevelId: string;
// ── Werkzeug-Palette (Tools-Panel) ──────────────────────────────────────
/** Aktives Zeichenwerkzeug. */
activeTool: ToolId;
/** Werkzeug wählen. */
onSelectTool: (id: ToolId) => void;
/** Werkzeuge nur im Grundriss eines Geschosses sinnvoll. */
toolsEnabled: boolean;
/** Aktiver Wandtyp (für das Wand-Werkzeug). */
activeWallTypeId: string;
onActiveWallTypeId: (id: string) => void;
/** Snap-Einstellungen + Setter (Fang-Optionen in der Werkzeug-Palette). */
snap: SnapSettings;
onSnapChange: (s: SnapSettings) => void;
// Darstellungsmodus für ortsabhängige Panels (DrawingLevels, Layers).
displayMode: DisplayModeControl;
// Zeichnungsebenen-Handler.
onSelectLevel: (id: string) => void;
onToggleLevel: (id: string) => void;
onAddFloor: () => void;
onAddDrawing: () => void;
/**
* Rechtsklick auf eine Zeichnungsebenen-Zeile: öffnet das Kontextmenü an der
* Cursorposition. App hält das offene Menü und baut die Einträge
* (Einstellungen / Duplizieren / Löschen).
*/
onLevelContextMenu: (id: string, clientX: number, clientY: number) => void;
// Ebenen-(Kategorie-)Handler.
/** Aktive Ebene (Kategorie-Code) — Ziel neuer Zeichnungen; per Klick wählbar. */
activeCategoryCode: string;
/** Eine Ebene zur aktiven machen (Klick auf die Zeile im Ebenen-Panel). */
onSelectCategory: (code: string) => void;
onToggleCategory: (code: string) => void;
onAddCategory: () => void;
/**
* Rechtsklick auf eine Ebenen-(Kategorie-)Zeile: öffnet das Kontextmenü an der
* Cursorposition. App baut die Einträge (Einstellungen / Sub-Ebene /
* Selektion übertragen / Duplizieren / Eigenschaften kopieren+einfügen /
* Löschen) und hält das offene Menü.
*/
onLayerContextMenu: (code: string, clientX: number, clientY: number) => void;
// Ressourcen-Handler (Bauteile / Schraffuren / Linienstile). Spiegelt
// ResourceManagerHandlers, hier flach getippt, damit dieses Modul nicht von
// der UI-Komponente abhängt.
onPatchComponent: (id: string, patch: Partial<Component>) => void;
onAddComponent: () => void;
onDeleteComponent: (id: string) => void;
onPatchHatch: (id: string, patch: Partial<HatchStyle>) => void;
onAddHatch: () => void;
onDeleteHatch: (id: string) => void;
onPatchLineStyle: (id: string, patch: Partial<LineStyle>) => void;
onAddLineStyle: () => void;
onDeleteLineStyle: (id: string) => void;
/** Import fertiger (id-loser) Linienstile/Schraffuren (.lin/.pat). */
onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
// ── Selektions-Attribute (Attributes-/Object-Info-Paletten) ─────────────
/**
* Normalisierte, effektiv aufgelöste Sicht auf das ERSTE selektierte Element
* (oder `null`). Die Paletten lesen daraus Farbe/Strichstärke/Füllung/bbox
* und grauen nicht-anwendbare Felder per `selection.kind` aus.
*/
selection: Selection | null;
/** Setzt die (Strich-)Farbe der aktuellen Selektion (Wand oder Drawing2D). */
onSetSelectionColor: (color: string) => void;
/** Setzt die Strichstärke (mm) — wirkt NUR auf Drawing2D (sonst No-op). */
onSetSelectionWeight: (weightMm: number) => void;
/** Setzt/entfernt die Füllschraffur — wirkt NUR auf Drawing2D (sonst No-op). */
onSetSelectionFill: (hatchId: string | null) => void;
/**
* Setzt/entfernt die Vollton-Füllfarbe — wirkt NUR auf Drawing2D (sonst No-op).
* `null` entfernt die Füllfarbe (Fläche wieder transparent).
*/
onSetSelectionFillColor: (color: string | null) => void;
/**
* Skaliert die Selektion auf Zielbreite×-höhe (Meter) um einen Anker
* (fx/fy ∈ [0,1] relativ zur bbox; 0,0 = oben-links … 1,1 = unten-rechts).
*/
onResizeSelection: (
w: number,
h: number,
anchor: { fx: number; fy: number },
) => void;
// ── Wand-Attribute (Object-Info-Panel; wirken NUR auf die selektierte Wand) ─
/** Setzt die Lage der Wandachse über die Dicke (außen/mitte/innen). */
onSetWallReferenceLine: (ref: WallReferenceLine) => void;
/** Weist der Wand einen Wandtyp-Preset (mehrschichtiger Aufbau) zu. */
onSetWallType: (wallTypeId: string) => void;
/** Setzt die Gesamtdicke einer einschichtigen Wand (Meter). */
onSetWallThickness: (thickness: number) => void;
/** Setzt/entfernt die UK-Bindung (`null` = Geschoss-Default). */
onSetWallBottom: (anchor: VerticalAnchor | null) => void;
/** Setzt/entfernt die OK-Bindung (`null` = UK + height). */
onSetWallTop: (anchor: VerticalAnchor | null) => void;
// ── Site-/Kontext-Schicht (SitePanel) ───────────────────────────────────
/** Aktuelle Kontext-Objekte (Meshes/Konturen/Gelände) aus `project.context`. */
contextObjects: ContextObject[];
/** Fügt mehrere Kontext-Objekte hinzu (z. B. aus einem DXF-Import). */
onAddContextObjects: (objs: ContextObject[]) => void;
/** Entfernt ein Kontext-Objekt per ID. */
onRemoveContextObject: (id: string) => void;
/**
* Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN. Liefert die neue
* TerrainMesh-ID oder null (unbekannte ID / kein sinnvolles TIN).
*/
onGenerateTerrain: (contourSetId: string) => string | null;
/** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */
onImportDxf: () => void;
}
// Re-Export der Modelltypen, die die Panels im selben Atemzug brauchen — so
// importieren Panels nur aus „./host" und bleiben auf einen Pfad fokussiert.
export type { DrawingLevel, LayerCategory };
export type { SnapSettings, ToolId };
export type { Selection } from "../state/selectionInfo";
// ── Hook ───────────────────────────────────────────────────────────────────
/**
* Liest den Host-Context und verengt ihn auf `PanelHostValue`. Wirft, wenn das
* Panel außerhalb eines Providers gerendert wird — das ist immer ein
* Programmierfehler im Rahmen und soll laut scheitern statt still „leer"
* anzuzeigen.
*/
export function usePanelHost(): PanelHostValue {
const host = useContext(PanelHostContext);
if (host === null) {
throw new Error(
"usePanelHost: außerhalb von <PanelHostContext.Provider> verwendet.",
);
}
return host as unknown as PanelHostValue;
}
+551
View File
@@ -0,0 +1,551 @@
// Panel-Layout — Standard + Persistenz (localStorage).
//
// Verwaltet den LayoutState (welche Panels in welchem Dock, aktiver Tab,
// Größen). Drei Ebenen:
// 1. defaultLayout() — der eingebaute Startzustand.
// 2. loadLayout()/saveLayout() — das zuletzt benutzte Layout (1 Slot).
// 3. saveNamedLayout()/… und Co. — benannte Layouts (Arbeitsbereiche).
//
// Sämtlicher localStorage-Zugriff ist in try/catch gekapselt und prüft, ob
// `localStorage` überhaupt existiert (SSR/Tests/Privatmodus) — Lesefehler
// liefern den Standard, Schreibfehler werden still geschluckt.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type {
DockGroup,
DockId,
DockState,
FloatingPanel,
LayoutState,
} from "./types";
/**
* Aktuelle Layout-Schema-Version (bei Strukturänderungen erhöhen). v2: rechtes
* Dock standardmäßig leer, „Ressourcen" nun schwebendes Fenster statt Dock-
* Panel. v3: schwebende Panels (`floating`). v4: Werkzeug-Palette als Kern-
* Panel. v5: GESTAPELTE GRUPPEN — ein Dock hält eine Liste vertikaler Gruppen
* (je eigener Tab-Stapel + aktiver Tab + Höhen-Gewicht) statt eines einzigen
* Tab-Stapels. v6: Attribute- + Objekt-Info-Palette im Default-Layout
* (Vectorworks-Bild). v7: Gelände-/Kontext-Palette („site") als weiterer Tab in
* der rechten unteren Gruppe. Das Hochzählen verwirft alt gespeicherte Layouts
* sauber auf den neuen Standard (kein Migrationspfad über die Strukturgrenze).
*/
export const LAYOUT_VERSION = 7;
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */
const CURRENT_KEY = "cad.layout";
/** Präfix der localStorage-Schlüssel benannter Layouts: `cad.layouts.<name>`. */
const NAMED_PREFIX = "cad.layouts.";
/**
* Konventionelle IDs der Kern-Panels für den Standard (entsprechen den IDs in
* builtinPanels: "drawing-levels", "layers"). Sind diese Panels nicht
* registriert (Plugin aus, Test), sollte der Host die IDs gegen die Registry
* filtern (siehe `hasPanel`); das Layout selbst bleibt registry-unabhängig.
*
* Das rechte Dock ist standardmäßig LEER: „Ressourcen" ist kein Dock-Panel
* mehr, sondern ein schwebendes Fenster (ResourceManager), das über die
* Oberleiste geöffnet wird. Ein leeres Dock wird nicht gerendert, sodass die
* Mitte (Grundriss/Perspektive) die Breite voll nutzt.
*/
/**
* Standard-Gruppen je Dock-Seite (oben → unten) — das Vectorworks-Bild.
* Links: Werkzeug-Palette OBEN, Attribute-Palette UNTEN. Rechts: Objekt-Info
* OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein
* schwebendes Fenster.) Jede Gruppe trägt ein Höhen-Gewicht.
*/
const DEFAULT_LEFT_GROUPS: DockGroup[] = [
{ tabs: ["tools"], activeTab: "tools", weight: 1 },
{ tabs: ["attributes"], activeTab: "attributes", weight: 1.1 },
];
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
{ tabs: ["object-info"], activeTab: "object-info", weight: 1 },
{
tabs: ["drawing-levels", "layers", "site"],
activeTab: "drawing-levels",
weight: 1.5,
},
];
/** Standardbreiten der Docks in CSS-Pixeln. */
const DEFAULT_LEFT_SIZE = 280;
const DEFAULT_RIGHT_SIZE = 340;
/** Höhen-Gewicht einer neu erzeugten Gruppe (Mittel der vorhandenen, sonst 1). */
function newGroupWeight(groups: DockGroup[]): number {
if (groups.length === 0) return 1;
return groups.reduce((s, g) => s + g.weight, 0) / groups.length;
}
/** Tiefe (frische Objekte) Kopie einer Gruppenliste. */
function cloneGroups(groups: DockGroup[]): DockGroup[] {
return groups.map((g) => ({ tabs: [...g.tabs], activeTab: g.activeTab, weight: g.weight }));
}
// ── Standard ────────────────────────────────────────────────────────────────
/**
* Liefert den eingebauten Startzustand: links zwei Gruppen (Werkzeuge oben,
* Zeichnungsebenen/Ebenen unten), rechts LEER. Jeder Aufruf erzeugt frische
* Objekte/Arrays, damit Aufrufer das Ergebnis gefahrlos mutieren können.
*/
export function defaultLayout(): LayoutState {
return {
left: { groups: cloneGroups(DEFAULT_LEFT_GROUPS), size: DEFAULT_LEFT_SIZE },
right: { groups: cloneGroups(DEFAULT_RIGHT_GROUPS), size: DEFAULT_RIGHT_SIZE },
floating: [],
version: LAYOUT_VERSION,
};
}
// ── Mutations-Helfer (rein, immutabel) ──────────────────────────────────────
//
// Alle Helfer liefern ein NEUES LayoutState und mutieren die Eingabe nicht.
// Sie wahren die Invariante „jedes Panel an genau einer Stelle": ein Panel wird
// zuerst überall entfernt (beide Docks + floating), dann am Ziel eingefügt. Sie
// kennen die Registry NICHT — Aufrufer reichen gültige Panel-IDs herein.
/** Der gegenüberliegende Dock (für Aufräum-Logik). */
function otherDock(dock: DockId): DockId {
return dock === "left" ? "right" : "left";
}
/**
* Entfernt eine Panel-ID aus einer Gruppe (immutabel) oder liefert die Gruppe
* unverändert zurück, wenn sie das Panel nicht enthält. Wird das Panel der
* aktive Tab, rückt `activeTab` auf den nächstgelegenen Nachbarn (rechts, sonst
* links); ist die Gruppe danach leer, wird `activeTab` `null`.
*/
function removeFromGroup(group: DockGroup, panelId: string): DockGroup {
const i = group.tabs.indexOf(panelId);
if (i < 0) return group;
const tabs = group.tabs.filter((id) => id !== panelId);
let activeTab = group.activeTab;
if (activeTab === panelId) activeTab = tabs[i] ?? tabs[i - 1] ?? null;
return { ...group, tabs, activeTab };
}
/**
* Entfernt eine Panel-ID aus allen Gruppen eines Docks und wirft die dadurch
* leer gewordenen Gruppen weg (immutabel). Dock-Breite bleibt erhalten.
*/
function removeFromDock(dock: DockState, panelId: string): DockState {
const groups = dock.groups
.map((g) => removeFromGroup(g, panelId))
.filter((g) => g.tabs.length > 0);
if (groups.length === dock.groups.length && groups.every((g, i) => g === dock.groups[i])) {
return dock; // nichts geändert
}
return { ...dock, groups };
}
/**
* Entfernt eine Panel-ID überall (beide Docks + floating). Basis für alle
* „verschiebe nach …"-Helfer, damit die Eindeutigkeits-Invariante hält.
*/
function detach(layout: LayoutState, panelId: string): LayoutState {
return {
...layout,
left: removeFromDock(layout.left, panelId),
right: removeFromDock(layout.right, panelId),
floating: layout.floating.filter((f) => f.panelId !== panelId),
};
}
/** Höchster vergebener z-Index der schwebenden Panels (0, wenn keine). */
function maxZ(floating: FloatingPanel[]): number {
return floating.reduce((m, f) => Math.max(m, f.z), 0);
}
/**
* Verschiebt ein Panel in eine BESTEHENDE Gruppe eines Docks und macht es dort
* aktiv (Tab-Stapel zusammenlegen). Es wird zuvor überall entfernt. `tabIndex`
* bestimmt die Einfügeposition in der Gruppe; fehlt er, wird angehängt. Liegt
* `groupIndex` außerhalb (z. B. weil das Detachen eine Gruppe entfernt hat),
* wird auf die letzte Gruppe geklemmt; gibt es danach keine Gruppe, wird eine
* neue angelegt.
*/
export function moveToGroup(
layout: LayoutState,
panelId: string,
dock: DockId,
groupIndex: number,
tabIndex?: number,
): LayoutState {
const detached = detach(layout, panelId);
const target = detached[dock];
if (target.groups.length === 0) {
return moveToNewGroup(detached, panelId, dock, 0);
}
const gi = Math.max(0, Math.min(groupIndex, target.groups.length - 1));
const groups = target.groups.map((g, i) => {
if (i !== gi) return g;
const at =
tabIndex === undefined
? g.tabs.length
: Math.max(0, Math.min(tabIndex, g.tabs.length));
const tabs = [...g.tabs.slice(0, at), panelId, ...g.tabs.slice(at)];
return { ...g, tabs, activeTab: panelId };
});
return { ...detached, [dock]: { ...target, groups } };
}
/**
* Legt ein Panel als NEUE Gruppe (eigener Stapel) an Position `atIndex` im Dock
* an (für gestapelte Paletten). Es wird zuvor überall entfernt. `atIndex` wird
* auf [0, groups.length] geklemmt; das neue Gewicht ist das Mittel der
* vorhandenen Gruppen, damit der Stapel ausgewogen startet.
*/
export function moveToNewGroup(
layout: LayoutState,
panelId: string,
dock: DockId,
atIndex: number,
): LayoutState {
const detached = detach(layout, panelId);
const target = detached[dock];
const at = Math.max(0, Math.min(atIndex, target.groups.length));
const group: DockGroup = {
tabs: [panelId],
activeTab: panelId,
weight: newGroupWeight(target.groups),
};
const groups = [...target.groups.slice(0, at), group, ...target.groups.slice(at)];
return { ...detached, [dock]: { ...target, groups } };
}
/**
* Rückwärtskompatible Hülle: verschiebt ein Panel in ein Dock, indem es als
* neue Gruppe ans Ende gehängt wird (das natürliche Ziel beim Andocken an die
* Rand-Zone oder beim Redock eines schwebenden Fensters).
*/
export function moveToDock(
layout: LayoutState,
panelId: string,
dock: DockId,
): LayoutState {
return moveToNewGroup(layout, panelId, dock, layout[dock].groups.length);
}
/**
* Macht ein Panel schwebend (frei positioniert). Es wird zuvor aus jedem Dock
* entfernt. `rect` setzt Position/Größe; das Panel kommt nach vorne (z über
* allen anderen). War es bereits schwebend, wird sein Eintrag durch den neuen
* (mit `rect` und Front-z) ersetzt.
*/
export function moveToFloat(
layout: LayoutState,
panelId: string,
rect: { x: number; y: number; w: number; h: number },
): LayoutState {
const detached = detach(layout, panelId);
const z = maxZ(detached.floating) + 1;
const panel: FloatingPanel = { panelId, ...rect, z };
return { ...detached, floating: [...detached.floating, panel] };
}
/**
* Ordnet einen Tab innerhalb EINER Gruppe um (immutabel). Bewegt den Tab an
* `fromIndex` vor den Tab, der nach dem Entfernen an `toIndex` steht. Indizes
* außerhalb des Bereichs werden geklemmt; ist nichts zu tun, kommt das Layout
* unverändert zurück. Der aktive Tab bleibt erhalten (folgt seiner ID).
*/
export function reorderInGroup(
layout: LayoutState,
dock: DockId,
groupIndex: number,
fromIndex: number,
toIndex: number,
): LayoutState {
const d = layout[dock];
const g = d.groups[groupIndex];
if (!g) return layout;
const n = g.tabs.length;
if (n === 0) return layout;
const from = Math.max(0, Math.min(fromIndex, n - 1));
const to = Math.max(0, Math.min(toIndex, n - 1));
if (from === to) return layout;
const tabs = [...g.tabs];
const [moved] = tabs.splice(from, 1);
tabs.splice(to, 0, moved);
const groups = d.groups.map((gr, i) => (i === groupIndex ? { ...gr, tabs } : gr));
return { ...layout, [dock]: { ...d, groups } };
}
/**
* Verteilt die Höhen-Gewichte der Gruppen eines Docks neu (immutabel) — vom
* Gruppen-Splitter aufgerufen. `weights` muss so lang sein wie `groups`; zu
* kleine/0-Werte werden auf ein Minimum angehoben, damit keine Gruppe
* verschwindet.
*/
export function setGroupWeights(
layout: LayoutState,
dock: DockId,
weights: number[],
): LayoutState {
const d = layout[dock];
if (weights.length !== d.groups.length) return layout;
const groups = d.groups.map((g, i) => ({ ...g, weight: Math.max(0.05, weights[i]) }));
return { ...layout, [dock]: { ...d, groups } };
}
/**
* Aktualisiert ein schwebendes Panel (Position/Größe/z) per Teil-Patch. Ein
* `z`-Wert im Patch wird übernommen wie gegeben (für gezieltes Setzen); zum
* Nach-vorne-Holen siehe `bringToFront`. Unbekannte ID → Layout unverändert.
*/
export function updateFloat(
layout: LayoutState,
panelId: string,
partial: Partial<Omit<FloatingPanel, "panelId">>,
): LayoutState {
let changed = false;
const floating = layout.floating.map((f) => {
if (f.panelId !== panelId) return f;
changed = true;
return { ...f, ...partial };
});
return changed ? { ...layout, floating } : layout;
}
/**
* Holt ein schwebendes Panel nach vorne (höchster z-Index). Ist es bereits
* vorne (oder unbekannt), kommt das Layout unverändert zurück.
*/
export function bringToFront(layout: LayoutState, panelId: string): LayoutState {
const top = maxZ(layout.floating);
const current = layout.floating.find((f) => f.panelId === panelId);
if (!current || current.z === top) return layout;
return updateFloat(layout, panelId, { z: top + 1 });
}
/**
* Entfernt ein schwebendes Panel aus `floating` (immutabel). Damit ist das
* Panel an KEINER Stelle mehr — Aufrufer, die es nicht schließen, sondern
* andocken wollen, nutzen `moveToDock`.
*/
export function removeFloat(layout: LayoutState, panelId: string): LayoutState {
const floating = layout.floating.filter((f) => f.panelId !== panelId);
if (floating.length === layout.floating.length) return layout;
return { ...layout, floating };
}
/**
* Entfernt ein Panel aus einem Dock (immutabel). Dünne, benannte Hülle um
* `removeFromDock` auf Layout-Ebene; nützlich, um ein Panel ganz zu schließen.
*/
export function removeFromDockLayout(
layout: LayoutState,
dock: DockId,
panelId: string,
): LayoutState {
const next = removeFromDock(layout[dock], panelId);
if (next === layout[dock]) return layout;
return { ...layout, [dock]: next };
}
/**
* Setzt das Panel als aktiven Tab innerhalb seiner Gruppe (immutabel). Sucht
* die Gruppe im Dock, die das Panel enthält, und macht es dort aktiv. Liegt die
* ID in keiner Gruppe des Docks (oder ist sie schon aktiv), kommt das Layout
* unverändert zurück.
*/
export function setActiveTab(
layout: LayoutState,
dock: DockId,
panelId: string,
): LayoutState {
const d = layout[dock];
const gi = d.groups.findIndex((g) => g.tabs.includes(panelId));
if (gi < 0 || d.groups[gi].activeTab === panelId) return layout;
const groups = d.groups.map((g, i) => (i === gi ? { ...g, activeTab: panelId } : g));
return { ...layout, [dock]: { ...d, groups } };
}
// `otherDock` wird (noch) nicht extern gebraucht, ist aber Teil der Aufräum-
// Semantik; als Helfer exportiert, damit Wiring-Code symmetrisch arbeiten kann.
export { otherDock };
// ── localStorage-Sicherung ──────────────────────────────────────────────────
/** Liefert das localStorage-Objekt oder `null`, wenn nicht verfügbar. */
function storage(): Storage | null {
try {
if (typeof localStorage === "undefined") return null;
return localStorage;
} catch {
// Zugriff kann werfen (z. B. blockierte Cookies/Privatmodus).
return null;
}
}
/** Liest und parst einen JSON-Wert; `null` bei Fehlen/ungültig. */
function readJson<T>(key: string): T | null {
const ls = storage();
if (!ls) return null;
try {
const raw = ls.getItem(key);
if (raw === null) return null;
return JSON.parse(raw) as T;
} catch {
return null;
}
}
/** Schreibt einen JSON-Wert; liefert `false`, wenn das Schreiben scheitert. */
function writeJson(key: string, value: unknown): boolean {
const ls = storage();
if (!ls) return false;
try {
ls.setItem(key, JSON.stringify(value));
return true;
} catch {
// Kontingent überschritten oder Zugriff verweigert — still ignorieren.
return false;
}
}
// ── Validierung / Migration ─────────────────────────────────────────────────
/** Typwächter für eine DockGroup (toleriert Fremddaten aus localStorage). */
function isDockGroup(v: unknown): v is DockGroup {
if (typeof v !== "object" || v === null) return false;
const g = v as Record<string, unknown>;
return (
Array.isArray(g.tabs) &&
g.tabs.every((t) => typeof t === "string") &&
(g.activeTab === null || typeof g.activeTab === "string") &&
typeof g.weight === "number"
);
}
/** Typwächter für einen DockState (toleriert Fremddaten aus localStorage). */
function isDockState(v: unknown): v is DockState {
if (typeof v !== "object" || v === null) return false;
const d = v as Record<string, unknown>;
return (
Array.isArray(d.groups) &&
d.groups.every(isDockGroup) &&
typeof d.size === "number"
);
}
/** Typwächter für einen FloatingPanel (toleriert Fremddaten aus localStorage). */
function isFloatingPanel(v: unknown): v is FloatingPanel {
if (typeof v !== "object" || v === null) return false;
const f = v as Record<string, unknown>;
return (
typeof f.panelId === "string" &&
typeof f.x === "number" &&
typeof f.y === "number" &&
typeof f.w === "number" &&
typeof f.h === "number" &&
typeof f.z === "number"
);
}
/**
* Prüft eine geladene Struktur und führt sie bei Bedarf auf ein gültiges
* Layout zurück. Beschädigte Daten ergeben den Standard (konservativ statt
* Absturz).
*
* MIGRATION: Ältere Layouts (Version < aktuell) ohne `floating` werden
* angehoben, statt verworfen — die Docks bleiben erhalten, `floating` wird zu
* `[]`. So überlebt das zuletzt benutzte Layout den Umbau auf schwebende
* Panels. Unbekannte/zukünftige (zu hohe) Versionen ergeben den Standard.
*/
function normalizeLayout(v: unknown): LayoutState {
if (typeof v !== "object" || v === null) return defaultLayout();
const o = v as Record<string, unknown>;
// Andere Version (älter ODER neuer) → auf den aktuellen Standard zurückfallen.
// So erscheinen bei Schema-/Default-Änderungen (z. B. neues Kern-Panel
// „tools") die neuen Standard-Tabs, statt dass ein altes Layout sie verdeckt.
if (typeof o.version !== "number" || o.version !== LAYOUT_VERSION) {
return defaultLayout();
}
if (!isDockState(o.left) || !isDockState(o.right)) return defaultLayout();
// floating fehlt in v2 → leeres Array; sonst nur gültige Einträge übernehmen.
const floating = Array.isArray(o.floating)
? o.floating.filter(isFloatingPanel)
: [];
return {
left: o.left,
right: o.right,
floating,
version: LAYOUT_VERSION,
};
}
// ── Aktuelles Layout (1 Slot) ───────────────────────────────────────────────
/**
* Lädt das zuletzt benutzte Layout. Fehlt es, ist es beschädigt oder ist
* localStorage nicht verfügbar, kommt `defaultLayout()` zurück.
*/
export function loadLayout(): LayoutState {
const raw = readJson<unknown>(CURRENT_KEY);
if (raw === null) return defaultLayout();
return normalizeLayout(raw);
}
/** Speichert das aktuelle Layout. Liefert `true` bei Erfolg. */
export function saveLayout(layout: LayoutState): boolean {
return writeJson(CURRENT_KEY, layout);
}
// ── Benannte Layouts (Arbeitsbereiche) ──────────────────────────────────────
/** Baut den localStorage-Schlüssel für ein benanntes Layout. */
function namedKey(name: string): string {
return NAMED_PREFIX + name;
}
/** Speichert ein Layout unter einem Namen. Liefert `true` bei Erfolg. */
export function saveNamedLayout(name: string, layout: LayoutState): boolean {
if (!name) return false;
return writeJson(namedKey(name), layout);
}
/**
* Lädt ein benanntes Layout. Fehlt es oder ist es beschädigt, kommt `null`
* zurück (anders als `loadLayout`, damit Aufrufer „nicht gefunden" erkennen).
*/
export function loadNamedLayout(name: string): LayoutState | null {
const raw = readJson<unknown>(namedKey(name));
if (raw === null) return null;
return normalizeLayout(raw);
}
/**
* Listet die Namen aller gespeicherten benannten Layouts (alphabetisch
* sortiert). Leer, wenn localStorage nicht verfügbar ist.
*/
export function listNamedLayouts(): string[] {
const ls = storage();
if (!ls) return [];
const names: string[] = [];
try {
for (let i = 0; i < ls.length; i++) {
const key = ls.key(i);
if (key && key.startsWith(NAMED_PREFIX)) {
names.push(key.slice(NAMED_PREFIX.length));
}
}
} catch {
return [];
}
return names.sort();
}
/** Löscht ein benanntes Layout. Liefert `true`, wenn der Zugriff gelang. */
export function deleteNamedLayout(name: string): boolean {
const ls = storage();
if (!ls) return false;
try {
ls.removeItem(namedKey(name));
return true;
} catch {
return false;
}
}
+304
View File
@@ -0,0 +1,304 @@
// Panel-Tab-Drag — die Steuerung für „Tab greifen → Reorder / Andocken / Lösen".
//
// Eine Geste beginnt am TAB selbst (pointerdown). Unter dem Schwellwert (~5 px)
// gilt sie als Klick (Tab aktivieren); darüber wird sie zum Ziehen. Während des
// Ziehens folgt ein kleines Geist-Etikett dem Cursor, und gültige Ablageziele
// werden hervorgehoben:
// • über einer Tab-Leiste (links/rechts) → REORDER bzw. Verschieben an den
// ermittelten Einfügeindex (Caret zwischen den Tabs);
// • über einer Rand-Andock-Zone (links/rechts) → in dieses Dock andocken;
// • sonst über der Arbeitsfläche → an der Cursorposition LÖSEN (schweben).
//
// Die Auflösung läuft rein über DOM-Datenattribute, die TabStrip/Dock setzen:
// data-tabstrip="<dock>" + data-group-index an der Tab-Leiste einer Gruppe,
// data-tab-index="<i>" an jedem Tab-Knopf,
// data-group-body + data-dock + data-group-index am Körper einer Gruppe,
// data-drop-zone="<dock>" an den Rand-Andock-Zonen.
// So braucht der Drag-Controller keine Refs der einzelnen Tabs und bleibt vom
// konkreten Layout entkoppelt.
//
// Ablage-Semantik:
// • über einer Tab-Leiste → in DIESE Gruppe einreihen (Stapel zusammenlegen),
// • über einem Gruppen-Körper (obere/untere Hälfte) → NEUE Gruppe darüber/-unter,
// • über einer Rand-Zone → neue Gruppe ans Ende des Docks,
// • sonst → lösen (schweben).
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useCallback, useRef, useState } from "react";
import type { DockId } from "./types";
import { getPanel } from "./registry";
import { t } from "../i18n";
/** Pixel-Schwelle, ab der ein pointerdown auf einem Tab zum Ziehen wird. */
const DRAG_THRESHOLD = 5;
/** Standardgröße eines neu gelösten (schwebenden) Panels in CSS-Pixeln. */
export const DEFAULT_FLOAT_W = 300;
export const DEFAULT_FLOAT_H = 360;
/** Ein aufgelöstes Ablageziel beim Loslassen. */
export type DropTarget =
| { kind: "join"; dock: DockId; group: number; index?: number }
| { kind: "newgroup"; dock: DockId; at: number }
| { kind: "float"; x: number; y: number };
/** Laufender Drag-Zustand (für Geist + Hervorhebung). */
interface DragState {
panelId: string;
/** Aktuelle Cursorposition (Geist folgt). */
x: number;
y: number;
/** Aktuell überfahrenes Ziel (für Hervorhebung). */
hover: HoverTarget;
}
/** Was gerade überfahren wird (für Caret/Zonen-Hervorhebung). */
type HoverTarget =
| {
kind: "tabstrip";
dock: DockId;
group: number;
index: number;
caretX: number;
stripTop: number;
stripHeight: number;
}
| {
kind: "split";
dock: DockId;
/** Einfügeposition der neuen Gruppe (0..groups.length). */
at: number;
/** Y-Position der horizontalen Caret-Linie + horizontale Ausdehnung. */
lineY: number;
lineLeft: number;
lineWidth: number;
}
| { kind: "zone"; dock: DockId }
| { kind: "none" };
/** Was App zum Abschluss einer Drag-Geste umsetzt. */
export interface PanelDragCallbacks {
/** Tab unter Schwelle losgelassen → als Klick: Tab aktivieren. */
onActivate: (panelId: string) => void;
/** Über einer Tab-Leiste losgelassen → in diese Gruppe einreihen (am Index). */
onJoinGroup: (panelId: string, dock: DockId, group: number, index?: number) => void;
/** Über einem Gruppen-Körper / einer Rand-Zone → neue Gruppe an `at`. */
onNewGroup: (panelId: string, dock: DockId, at: number) => void;
/** Über der Arbeitsfläche losgelassen → an Position lösen (schweben). */
onDropFloat: (panelId: string, x: number, y: number) => void;
}
/**
* Ermittelt das überfahrene Ziel an (clientX, clientY): zuerst eine Tab-Leiste
* (einreihen), dann ein Gruppen-Körper (neue Gruppe ober-/unterhalb), dann eine
* Rand-Andock-Zone, sonst nichts (= später lösen). Liest die Datenattribute der
* unter dem Cursor liegenden Elemente.
*/
function resolveHover(clientX: number, clientY: number): HoverTarget {
const stack = document.elementsFromPoint(clientX, clientY);
// 1) Tab-Leiste? Einfügeindex aus den Tab-Mittelpunkten bestimmen.
const strip = stack.find((el) =>
(el as HTMLElement).dataset?.tabstrip !== undefined,
) as HTMLElement | undefined;
if (strip) {
const dock = strip.dataset.tabstrip as DockId;
const group = Number(strip.dataset.groupIndex ?? 0);
const tabs = Array.from(
strip.querySelectorAll<HTMLElement>("[data-tab-index]"),
);
const stripRect = strip.getBoundingClientRect();
let index = tabs.length;
let caretX = stripRect.left + 4;
for (let i = 0; i < tabs.length; i++) {
const r = tabs[i].getBoundingClientRect();
const mid = r.left + r.width / 2;
if (clientX < mid) {
index = i;
caretX = r.left - 2;
break;
}
caretX = r.right + 2;
}
return {
kind: "tabstrip",
dock,
group,
index,
caretX,
stripTop: stripRect.top,
stripHeight: stripRect.height,
};
}
// 2) Gruppen-Körper? Obere Hälfte → neue Gruppe DARÜBER (at = gi), untere
// Hälfte → DARUNTER (at = gi + 1). Caret-Linie an der jeweiligen Kante.
const body = stack.find((el) =>
(el as HTMLElement).dataset?.groupBody !== undefined,
) as HTMLElement | undefined;
if (body) {
const dock = body.dataset.dock as DockId;
const gi = Number(body.dataset.groupIndex ?? 0);
const r = body.getBoundingClientRect();
const below = clientY > r.top + r.height / 2;
return {
kind: "split",
dock,
at: below ? gi + 1 : gi,
lineY: below ? r.bottom : r.top,
lineLeft: r.left,
lineWidth: r.width,
};
}
// 3) Rand-Andock-Zone?
const zone = stack.find((el) =>
(el as HTMLElement).dataset?.dropZone !== undefined,
) as HTMLElement | undefined;
if (zone) {
return { kind: "zone", dock: zone.dataset.dropZone as DockId };
}
// 4) Sonst: lösen (schweben).
return { kind: "none" };
}
/**
* Drag-Controller. Liefert:
* • startTabDrag — pointerdown-Handler, den TabStrip an jeden Tab hängt;
* • drag — der laufende Zustand (null = nichts) für das Overlay;
* • dropDockHint — der aktuell hervorzuhebende Dock (für Zonen/Strip), oder null.
*/
export function usePanelDrag(cb: PanelDragCallbacks) {
const [drag, setDrag] = useState<DragState | null>(null);
// Mutable Mitschrift der Geste (Listener lesen daraus, ohne Re-Renders).
const ref = useRef<{
panelId: string;
startX: number;
startY: number;
active: boolean;
hover: HoverTarget;
} | null>(null);
const startTabDrag = useCallback(
(panelId: string, e: React.PointerEvent) => {
// Nur primäre Taste / Touch.
if (e.button !== 0 && e.pointerType === "mouse") return;
const startX = e.clientX;
const startY = e.clientY;
ref.current = { panelId, startX, startY, active: false, hover: { kind: "none" } };
const move = (ev: PointerEvent) => {
const s = ref.current;
if (!s) return;
if (!s.active) {
const moved =
Math.abs(ev.clientX - s.startX) + Math.abs(ev.clientY - s.startY);
if (moved < DRAG_THRESHOLD) return;
s.active = true; // Schwelle überschritten → echtes Ziehen.
}
const hover = resolveHover(ev.clientX, ev.clientY);
s.hover = hover;
setDrag({ panelId: s.panelId, x: ev.clientX, y: ev.clientY, hover });
};
const up = (ev: PointerEvent) => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", cancel);
const s = ref.current;
ref.current = null;
setDrag(null);
if (!s) return;
if (!s.active) {
// Unter der Schwelle: als Klick werten (Tab aktivieren).
cb.onActivate(s.panelId);
return;
}
const hover = resolveHover(ev.clientX, ev.clientY);
if (hover.kind === "tabstrip") {
cb.onJoinGroup(s.panelId, hover.dock, hover.group, hover.index);
} else if (hover.kind === "split") {
cb.onNewGroup(s.panelId, hover.dock, hover.at);
} else if (hover.kind === "zone") {
// Rand-Zone → neue Gruppe ans Ende (at sehr groß, Helfer klemmt).
cb.onNewGroup(s.panelId, hover.dock, Number.MAX_SAFE_INTEGER);
} else {
// Lösen: Geist mittig über dem Cursor platzieren.
cb.onDropFloat(
s.panelId,
Math.round(ev.clientX - DEFAULT_FLOAT_W / 2),
Math.round(ev.clientY - 12),
);
}
};
const cancel = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", cancel);
ref.current = null;
setDrag(null);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", cancel);
},
[cb],
);
return { drag, startTabDrag };
}
/**
* Drag-Overlay: rendert während eines aktiven Tab-Ziehens den Geist am Cursor
* und das Einfüge-Caret in der überfahrenen Tab-Leiste. Die Rand-Andock-Zonen
* selbst rendert der Host (App), damit sie auch beim Verschieben eines
* schwebenden Fensters dieselben Trefferflächen bilden. Liegt über allem;
* reagiert nicht auf Zeiger (pointer-events: none), damit die Trefferprüfung
* weiter die darunterliegenden Tab-Leisten/Zonen erreicht.
*/
export function DragOverlay({ drag }: { drag: ReturnType<typeof usePanelDrag>["drag"] }) {
if (!drag) return null;
const def = getPanel(drag.panelId);
const label = def ? t(def.title) : drag.panelId;
const hover = drag.hover;
return (
<div className="drag-overlay" aria-hidden>
{/* Einfüge-Caret in der überfahrenen Tab-Leiste (vertikal). */}
{hover.kind === "tabstrip" && (
<div
className="tab-caret"
style={{
left: `${hover.caretX}px`,
top: `${hover.stripTop}px`,
height: `${hover.stripHeight}px`,
}}
/>
)}
{/* Neue-Gruppe-Caret am oberen/unteren Rand eines Gruppen-Körpers
(horizontal). */}
{hover.kind === "split" && (
<div
className="group-caret"
style={{
left: `${hover.lineLeft}px`,
top: `${hover.lineY}px`,
width: `${hover.lineWidth}px`,
}}
/>
)}
{/* Geist-Etikett am Cursor. */}
<div
className="drag-ghost"
style={{ left: `${drag.x + 12}px`, top: `${drag.y + 8}px` }}
>
{label}
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
// Panel-Registry — die Plugin-Naht.
//
// Modulweites Verzeichnis aller bekannten Panels. Kern-Panels und Plugins/
// eigene Panels rufen `registerPanel` (typisch beim Import ihres Moduls) auf;
// die Docks fragen anschließend über `getPanel`/`listPanels` ab. So bleibt der
// Rahmen offen für Erweiterungen, ohne ihn zu ändern.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { PanelDef } from "./types";
/** Modulweites Verzeichnis: Panel-ID → Definition (Einfügereihenfolge bewahrt). */
const registry = new Map<string, PanelDef>();
/**
* Registriert ein Panel. Idempotent pro ID: ein erneuter Aufruf mit derselben
* ID ersetzt die vorhandene Definition (praktisch für Hot-Reload und für
* Plugins, die Kern-Panels überschreiben). Liefert die übergebene Definition
* zurück, damit Aufrufe verkettet werden können.
*/
export function registerPanel(def: PanelDef): PanelDef {
registry.set(def.id, def);
return def;
}
/** Liefert die registrierte Panel-Definition zur ID oder `undefined`. */
export function getPanel(id: string): PanelDef | undefined {
return registry.get(id);
}
/** Liefert alle registrierten Panels in Registrierungsreihenfolge. */
export function listPanels(): PanelDef[] {
return [...registry.values()];
}
/**
* Ob zu einer ID ein Panel registriert ist. Hilfreich, um aus einem
* persistierten Layout verschwundene (z. B. von einem deaktivierten Plugin
* stammende) Panel-IDs herauszufiltern.
*/
export function hasPanel(id: string): boolean {
return registry.has(id);
}
/**
* Entfernt ein Panel aus der Registry. Liefert `true`, wenn etwas entfernt
* wurde. Vor allem für Tests und das Entladen von Plugins gedacht.
*/
export function unregisterPanel(id: string): boolean {
return registry.delete(id);
}
+164
View File
@@ -0,0 +1,164 @@
// Panel-System — Kerntypen.
//
// Das Panel-System ist der erweiterbare Andockrahmen der App: links und rechts
// je ein Dock mit Tabs, jeder Tab ist ein registriertes Panel. Plugins/eigene
// Panels registrieren sich über die Registry (registry.ts) und tauchen dann in
// den Docks auf — der Rahmen kennt keine konkreten Panels.
//
// Bezeichner englisch, Kommentare/UI-Text deutsch (CONVENTIONS.md). Dieses Modul ist
// rein typdeklarativ plus der React-Context-Träger; es hat keine Laufzeitlogik
// außer createContext und ist daher SSR-/test-sicher.
import { createContext } from "react";
import type { ReactNode } from "react";
// ── Dock-Identität & Layout ────────────────────────────────────────────────
/** Welches der beiden Docks (links/rechts) gemeint ist. */
export type DockId = "left" | "right";
/**
* Eine vertikal gestapelte Gruppe innerhalb eines Docks: ein eigener
* Tab-Stapel mit eigenem aktivem Tab. Mehrere Gruppen übereinander ergeben das
* Vectorworks-Bild (z. B. links Werkzeuge OBEN, Attribute UNTEN). Jede Gruppe
* trägt ein Höhen-Gewicht (relativer flex-Anteil im Dock); die Splitter
* zwischen den Gruppen verschieben diese Gewichte.
*/
export interface DockGroup {
/** Geordnete Liste der Panel-IDs in dieser Gruppe (Tab-Reihenfolge). */
tabs: string[];
/** Aktiver Tab (Panel-ID) oder `null`, wenn die Gruppe leer ist. */
activeTab: string | null;
/**
* Relatives Höhen-Gewicht der Gruppe im Dock (flex-grow-Anteil). Nur die
* Verhältnisse zählen; ein Splitter-Zug verteilt Gewicht zwischen Nachbarn.
*/
weight: number;
}
/**
* Zustand eines einzelnen Docks: eine geordnete Liste vertikal gestapelter
* Gruppen (oben → unten) plus die Dock-Breite. Ein leeres Dock hat `groups: []`
* und wird nicht gerendert (die Mitte nutzt den Platz).
*/
export interface DockState {
/** Vertikal gestapelte Gruppen (oben → unten). */
groups: DockGroup[];
/**
* Breite des Docks in CSS-Pixeln. Persistiert, damit die Aufteilung über
* Sitzungen erhalten bleibt.
*/
size: number;
}
/**
* Ein schwebendes (frei positioniertes) Panel: weder im linken noch im rechten
* Dock, sondern als eigenes Fenster über der Mitte. Position/Größe sind
* CSS-Pixel relativ zum Anwendungsbereich; `z` ist die Stapelreihenfolge
* (höher = weiter vorne), damit zuletzt fokussierte Fenster oben liegen.
*/
export interface FloatingPanel {
/** Panel-ID (Registry-Schlüssel) — wie in DockState.tabs eindeutig. */
panelId: string;
/** Linke Kante in CSS-Pixeln (relativ zum Anwendungsbereich). */
x: number;
/** Obere Kante in CSS-Pixeln (relativ zum Anwendungsbereich). */
y: number;
/** Breite in CSS-Pixeln. */
w: number;
/** Höhe in CSS-Pixeln. */
h: number;
/** Stapelindex (höher = weiter vorne). */
z: number;
}
/**
* Gesamter Layout-Zustand des Panel-Rahmens: beide Docks, die Liste der
* schwebenden Panels plus eine Schema-Version (für Migrationen beim Laden aus
* localStorage).
*
* INVARIANTE: Jede registrierte Panel-ID erscheint an GENAU EINER Stelle —
* in einer Gruppe des linken Docks, in einer Gruppe des rechten Docks oder in
* `floating` (als `panelId`). Die Helfer in layout.ts wahren diese Invariante
* (sie entfernen ein Panel überall, bevor sie es am Ziel einfügen, und werfen
* leer gewordene Gruppen weg).
*/
export interface LayoutState {
left: DockState;
right: DockState;
/** Frei positionierte Panels (nicht in einem Dock). */
floating: FloatingPanel[];
/** Schema-Version des Layouts (für künftige Migrationen). */
version: number;
}
// ── Darstellungsmodus ──────────────────────────────────────────────────────
/**
* Darstellungsmodus für ortsabhängige Inhalte (Navigator-Listen, 3D, Grundriss).
* Steuert, welche Elemente bezogen auf die aktive Auswahl gezeigt werden — die
* fünf DOSSIER-Modi (siehe docs/design/context-menu.md), für Ebenen UND
* Zeichnungsebenen identisch:
* • "all_force" — alle erzwungen sichtbar; Augen gedimmt; Klick aufs Auge
* wechselt zu „Ausgewählte".
* • "all" — sichtbar nach per-Zeile-Flag (Standard).
* • "active" — nur das aktive Element; andere stark gedimmt.
* • "grey" — aktives normal, andere 45 % (Sichtbarkeits-Flags gelten).
* • "grey_locked" — wie „grey", andere zusätzlich gesperrt.
*/
export type DisplayMode = "all_force" | "all" | "active" | "grey" | "grey_locked";
// ── Panel-Definition & Kontext ─────────────────────────────────────────────
/**
* Was ein Panel beim Rendern erhält. Bewusst generisch gehalten: konkrete
* App-Daten (Projekt, Handler, aktive Auswahl) reicht der Host stattdessen über
* den React-Context `PanelHostContext` durch — so bleiben Panels von der
* Props-Signatur des Hosts entkoppelt und Plugins müssen diesen Typ nicht
* kennen. Die Index-Signatur erlaubt es, bei Bedarf trotzdem ad-hoc-Werte
* mitzugeben, ohne den Typ zu brechen.
*/
export interface PanelContext {
[key: string]: unknown;
}
/**
* Definition eines Panels — die Einheit, die in der Registry registriert und in
* einem Dock als Tab dargestellt wird.
*/
export interface PanelDef {
/** Stabile, eindeutige ID (Registry-Schlüssel, in Layouts persistiert). */
id: string;
/**
* Im Tab angezeigter Titel — als i18n-Key (z. B. „nav.layers"). Der Rahmen
* (TabStrip/PanelFrame) löst ihn über `t()` auf, sodass der Titel der
* gewählten Sprache folgt. Ist der String kein bekannter Key, gibt `t()` ihn
* unverändert zurück (Plugins können also auch einen fertigen Text setzen).
*/
title: string;
/** Rendert den Panel-Inhalt. Erhält den (generischen) PanelContext. */
render: (ctx: PanelContext) => ReactNode;
/**
* Ob das Panel den Darstellungsmodus-Umschalter (DisplayMode) in seiner
* Kopfzeile anbieten soll. Default: kein Umschalter.
*/
hasDisplayMode?: boolean;
}
// ── Host-Context (App-Zustand + Handler für Panels) ────────────────────────
/**
* Träger des App-Zustands und der Mutations-Handler, den die App über einen
* Provider bereitstellt und den Panels per `useContext(PanelHostContext)`
* abgreifen. Bewusst lose typisiert (Record), damit dieses Kernmodul nicht vom
* konkreten Projekt-/Handler-Modell abhängt und Plugins frei darauf zugreifen
* können. Die App liefert hier u. a. `project`, die aktive Auswahl, die
* Ressourcen-Handler und die Darstellungsmodi hinein.
*
* `null` bedeutet „außerhalb eines Providers gerendert" — Consumer sollten das
* abfangen (siehe usePanelHost in einem späteren Schritt).
*/
export type PanelHost = Record<string, unknown>;
/** React-Context, über den der Host seinen Zustand an Panels durchreicht. */
export const PanelHostContext = createContext<PanelHost | null>(null);
File diff suppressed because it is too large Load Diff
+692
View File
@@ -0,0 +1,692 @@
// Grundriss-Generator: erzeugt aus dem semantischen Modell den
// 2D-Grundriss als abstrakte Vektor-Primitive (später → SVG).
//
// Kern-Erkenntnis: Der Grundriss entsteht NICHT durch Zerschneiden eines
// 3D-Meshes, sondern direkt aus den Parametern (Wandachsen, Schichtaufbau,
// Öffnungen) — exakt, schnell, sauber.
import type {
Drawing2D,
HatchPattern,
LayerCategory,
Project,
Vec2,
Wall,
} from "../model/types";
import {
flattenCategories,
getComponent,
getHatch,
getLineStyle,
getWallType,
wallTypeThickness,
} from "../model/types";
import { wallReferenceOffset } from "../model/wall";
import {
add,
along,
clippedBand,
leftNormal,
normalize,
scale,
sub,
wallCorners,
} from "../model/geometry";
/**
* Detailgrad der Plan-Darstellung (Oberleiste „grob/mittel/fein"). Steuert hier
* konkret zwei Dinge im Grundriss:
* • Tür-/Fenstersymbole: grob = nur Öffnungs-Lücke + gerade Türblatt-Linie;
* mittel = + Schwenkbogen; fein = + Rahmen/Anschlag-Striche.
* • Schraffuren + Linienfeinheit: grob = vereinfachte Poché (eine Sammelfläche
* ohne Schraffur, dickere Umrisslinie); mittel = aktueller mehrschichtiger
* Aufbau mit Schraffuren; fein = volle Schraffuren + dünnere Linien.
*
* NOCH OFFEN (folgt, sobald die Features existieren — hier bewusst KEIN
* Schein-Effekt): Bemaßung/Beschriftung werden mit dem Detailgrad auf-/abblenden
* (Maßketten, Raumstempel), und die 3D-Geometrie-Auflösung (z. B. Profil-
* Rundungen, Tritt-/Setzstufen) wird ebenfalls dem Detailgrad folgen, sobald
* Bemaßung bzw. eine feinere 3D-Tessellierung implementiert sind.
*/
export type DetailLevel = "grob" | "mittel" | "fein";
import { computeJoins } from "../model/joins";
import type { WallCuts } from "../model/joins";
/** Stroke-Farbe der Schicht-Polygone (dunkles Grau). */
const POCHE_STROKE = "#2b3039";
/** Tinte für den Schwarz-Weiss-Modus: einheitliches Dunkel für alle Striche. */
const MONO_INK = "#111111";
// ── Linienstärken in PAPIER-Millimeter (vgl. docs/design/plans-output.md §3.2) ──
// Alle Stricharten sind in mm Papier definiert; die PlanView rechnet sie über
// dpi = 96·devicePixelRatio in konstante Bildschirm-Pixel um (non-scaling-stroke),
// damit Linien beim Zoomen ihre Papiergröße behalten und der Massstab stimmt.
/** Dünne Trennlinie zwischen den Schichten (Schichtfugen) in mm. */
const LAYER_LINE_MM = 0.13;
/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */
const OUTLINE_DETAIL_FACTOR: Record<DetailLevel, number> = {
grob: 1.6, // dickere Sammellinie
mittel: 1.0, // wie die Kategorie-Strichstärke
fein: 0.6, // feiner
};
/** Stärke der Schichtfugen je Detailgrad (mittel/fein; grob hat keine Fugen). */
const LAYER_DETAIL_FACTOR: Record<DetailLevel, number> = {
grob: 1.0,
mittel: 1.0,
fein: 0.7,
};
/** Default-Umrandungs-Strichstärke (mm), falls die Kategorie keine lw trägt
(Grundeinstellung für „normale Elemente"). */
const WALL_FALLBACK_MM = 0.18;
/**
* Aufgelöste Schraffur eines Polygons — alle Werte stammen aus einer
* HatchStyle-Ressource (Muster/Maßstab/Winkel/Farbe) plus dem optional
* verknüpften LineStyle (Linienstärke/Strichmuster der Musterlinien). Die
* PlanView ist damit rein darstellend und kennt das Projekt nicht.
*/
export interface HatchRender {
pattern: HatchPattern;
scale: number;
angle: number;
/** Farbe der Musterlinien bzw. der Vollfüllung (pattern==="solid"). */
color: string;
/** Linienstärke der Musterlinien in mm. */
lineWeight: number;
/** Strichmuster der Musterlinien in mm; null = durchgezogen. */
dash: number[] | null;
}
/**
* Render-Entscheidung je Kategorie-Code für den Grundriss (vgl. ItemDisplay aus
* dem Panel-Darstellungsmodus). `render`=false blendet die Kategorie ganz aus,
* `greyed`=true zeichnet sie gedimmt. Wird kein Resolver übergeben, gilt für
* jede sichtbare Kategorie {render:true, greyed:false} (bisheriges Verhalten).
*/
export interface CategoryDisplay {
render: boolean;
greyed: boolean;
}
/** Funktion, die je Kategorie-Code die Render-Entscheidung liefert. */
export type CategoryDisplayResolver = (code: string) => CategoryDisplay;
/** Standard-Resolver: jede (bereits gefilterte) Kategorie normal zeichnen. */
const SHOW_ALL: CategoryDisplayResolver = () => ({ render: true, greyed: false });
export type Primitive =
| {
kind: "polygon";
pts: Vec2[];
/** Füllfarbe (Component-Poché) bzw. "none" für reine Umrisslinien. */
fill: string;
stroke: string;
/** Umriss-Strichstärke in PAPIER-Millimeter (siehe oben). */
strokeWidthMm: number;
hatch: HatchRender;
/** Gedimmt zeichnen (Darstellungsmodus „andere grau"). */
greyed?: boolean;
/**
* ID der Wand, zu der dieses Schicht-/Umriss-Polygon gehört (für die
* Links-Klick-Auswahl: die PlanView markiert ALLE Polygone derselben
* Wand und meldet die wallId nach oben). Reine Umriss-/Symbol-Primitive
* ohne Wandbezug lassen das Feld weg.
*/
wallId?: string;
/**
* ID des 2D-Zeichenelements (Drawing2D), falls dieses gefüllte Polygon eine
* geschlossene 2D-Form ist (für die Links-Klick-Auswahl der Fläche).
*/
drawingId?: string;
}
| {
kind: "line";
a: Vec2;
b: Vec2;
cls: string;
/** Strichstärke in mm Papier (non-scaling). */
weightMm: number;
/** Strichmuster in mm Papier; null/undefined = durchgezogen. */
dash?: number[] | null;
/** Optionale explizite Strichfarbe (überschreibt die CSS-Klasse). */
color?: string;
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl von Drawing2D). */
drawingId?: string;
greyed?: boolean;
}
| {
kind: "arc";
center: Vec2;
from: Vec2;
to: Vec2;
r: number;
cls: string;
/** Strichstärke in mm Papier (non-scaling). */
weightMm: number;
/** Strichmuster in mm Papier; null/undefined = durchgezogen. */
dash?: number[] | null;
greyed?: boolean;
};
/**
* Löst eine HatchStyle-Ressource (per id) zu einer darstellungsfertigen
* {@link HatchRender} auf. Der Linienstil der Musterlinien wird, falls gesetzt,
* aus dem Line Manager nachgeschlagen; sonst gilt ein dünner Default.
*/
function resolveHatch(project: Project, hatchId: string): HatchRender {
const h = getHatch(project, hatchId);
const ls = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null;
return {
pattern: h.pattern,
scale: h.scale,
angle: h.angle,
color: h.color,
lineWeight: ls?.weight ?? 0.13,
dash: ls?.dash ?? null,
};
}
export interface Plan {
primitives: Primitive[];
bounds: { minX: number; minY: number; maxX: number; maxY: number };
}
/**
* Erzeugt den Grundriss eines Geschosses, gefiltert nach sichtbaren Ebenen
* (Kategorie-Codes).
*
* Enthält Wände dieses Geschosses, deren `categoryCode` ∈ `visibleCodes`. Eine
* Türöffnung gehört zur Wandgeometrie und wird stets ausgespart; das
* Tür-SYMBOL (Blatt + Bogen) erscheint nur, wenn der Tür-Code sichtbar ist.
*/
export function generatePlan(
project: Project,
floorId: string,
visibleCodes: Set<string>,
categoryDisplay: CategoryDisplayResolver = SHOW_ALL,
detail: DetailLevel = "mittel",
showReferenceLines = false,
mono = false,
): Plan {
const primitives: Primitive[] = [];
// Sichtbarkeit wie bisher; der Darstellungsmodus verfeinert sie zusätzlich:
// render=false blendet die Wand aus, greyed=true dimmt sie.
const walls = project.walls.filter(
(w) =>
w.floorId === floorId &&
visibleCodes.has(w.categoryCode) &&
categoryDisplay(w.categoryCode).render,
);
// Code → Kategorie-Strichstärke (mm), damit die Wand-Umrisslinie aus dem
// Modell (Ebene) stammt statt aus einer Magie-Konstante.
const lwByCode = categoryLwMap(project.layers);
const colorByCode = categoryColorMap(project.layers);
// Freie 2D-Zeichengeometrie dieses Geschosses (wie Wände nach sichtbaren
// Kategorien + Darstellungsmodus gefiltert). Wird über der Wand-Poché
// gezeichnet (am Ende angehängt).
const drawings = project.drawings2d.filter(
(d) =>
d.levelId === floorId &&
visibleCodes.has(d.categoryCode) &&
categoryDisplay(d.categoryCode).render,
);
// Gehrungs-Schnittlinien einmal pro Plan berechnen (auf der gefilterten Menge).
const joins = computeJoins(project, walls);
for (const wall of walls) {
const wallGreyed = categoryDisplay(wall.categoryCode).greyed;
const wallLwMm = lwByCode.get(wall.categoryCode) ?? WALL_FALLBACK_MM;
// Türen am Wandhost: die Öffnung bestimmt immer die Wandgeometrie.
const doors = project.doors.filter((d) => d.hostWallId === wall.id);
const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null };
addWallPoche(primitives, project, wall, doors, cuts, wallGreyed, detail, wallLwMm);
// Referenzlinie: dünne gestrichelte Wand-Achslinie (von der Oberleiste
// umschaltbar). Wird über der Poché gezeichnet, damit sie sichtbar bleibt.
if (showReferenceLines) addReferenceLine(primitives, wall, wallGreyed);
// Tür-Symbol nur, wenn die Tür-Kategorie sichtbar ist und der Modus sie zeigt.
for (const door of doors) {
const d = categoryDisplay(door.categoryCode);
if (visibleCodes.has(door.categoryCode) && d.render) {
const doorLwMm = lwByCode.get(door.categoryCode) ?? LAYER_LINE_MM;
addDoorSymbol(primitives, wall, door, d.greyed, detail, doorLwMm);
}
}
}
// 2D-Zeichengeometrie zuletzt (über der Poché).
for (const d of drawings) {
const greyed = categoryDisplay(d.categoryCode).greyed;
addDrawing2D(primitives, project, d, greyed, lwByCode, colorByCode);
}
// Kontext-Konturen (Höhenlinien) als dezente Linien zeichnen — UNTER der
// Wand-Poché wäre ideal, aber da sie eine eigene gedämpfte Farbe tragen, ist
// die Reihenfolge unkritisch; wir hängen sie als Referenz an.
addContextContours(primitives, project);
// Schwarz-Weiss (mono): nachträglich alle Farben auf reine Tinte zwingen.
// Element-/Kategorie-/Hatch-Farben werden ignoriert, Füllungen werden weiss,
// Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan.
if (mono) toMono(primitives);
return { primitives, bounds: computeBounds(project, walls, drawings) };
}
/** Dezente Plan-Farbe der Kontext-Konturen (gedämpftes Grau). */
const CONTEXT_LINE_COLOR = "#9aa3ad";
/** Strichstärke der Kontext-Konturen (mm Papier, dünn). */
const CONTEXT_LINE_MM = 0.1;
/**
* Zeichnet die Konturen (Höhenlinien) der Kontext-Schicht `project.context` als
* dünne, gedämpfte Referenz-Linien in den Grundriss. Die Z-Höhe wird ignoriert
* (reine 2D-Projektion). TerrainMeshes/ImportedMeshes erscheinen im Plan NICHT
* (3D genügt). Rein additiv — Wand-/Drawing-Darstellung bleibt unberührt.
*/
function addContextContours(out: Primitive[], project: Project): void {
const context = project.context ?? [];
for (const obj of context) {
if (obj.type !== "contourSet") continue;
for (const c of obj.contours) {
const pts = c.pts;
if (pts.length < 2) continue;
const segEnd = c.closed ? pts.length : pts.length - 1;
for (let i = 0; i < segEnd; i++) {
out.push({
kind: "line",
a: pts[i],
b: pts[(i + 1) % pts.length],
cls: "context-line",
weightMm: CONTEXT_LINE_MM,
color: CONTEXT_LINE_COLOR,
});
}
}
}
}
/**
* Post-Pass für den Schwarz-Weiss-Modus: ersetzt alle Farb-Informationen der
* Plan-Primitive durch einheitliche Tinte/Weiss. Wirkt in-place.
* - Polygone: Füllung → weiss, Umriss → Tinte, Schraffur-Musterlinien → Tinte
* (Vollfüllung „solid" wird zu Weiss neutralisiert, damit keine Poché-Farbe).
* - Linien: explizite Strichfarbe → Tinte (CSS-Klassen sind bereits neutral).
* - Bögen tragen keine Farbe (rein CSS, neutral) → unverändert.
*/
function toMono(primitives: Primitive[]): void {
for (const p of primitives) {
if (p.kind === "polygon") {
// Vollfüllung „solid" deckte die Poché farbig — in mono auf Weiss setzen,
// damit nur der Umriss trägt. Linien-/Kreuzschraffur bleibt, aber in Tinte.
if (p.hatch.pattern === "solid") {
p.hatch = { ...p.hatch, color: "#ffffff" };
} else if (p.hatch.pattern !== "none") {
p.hatch = { ...p.hatch, color: MONO_INK };
}
p.fill = p.fill === "none" ? "none" : "#ffffff";
p.stroke = MONO_INK;
} else if (p.kind === "line") {
p.color = MONO_INK;
}
}
}
/** Baut eine Map Kategorie-Code → Strichstärke (mm) über den ganzen Baum. */
function categoryLwMap(layers: LayerCategory[]): Map<string, number> {
const map = new Map<string, number>();
for (const c of flattenCategories(layers)) map.set(c.code, c.lw);
return map;
}
/** Baut eine Map Kategorie-Code → Farbe über den ganzen Baum. */
function categoryColorMap(layers: LayerCategory[]): Map<string, string> {
const map = new Map<string, string>();
for (const c of flattenCategories(layers)) map.set(c.code, c.color);
return map;
}
/**
* Leitet ein 2D-Zeichenelement in Plan-Primitive ab (docs/design/drawing-tools.md
* §7.2). Farbe/Strichstärke kommen aus dem optionalen LineStyle, sonst aus der
* Kategorie. Phase 1 deckt line/polyline/rect ab (alles → line-Primitive);
* circle/arc/text folgen mit den Primitive-Erweiterungen (Phase 3).
*/
function addDrawing2D(
out: Primitive[],
project: Project,
d: Drawing2D,
greyed: boolean,
lwByCode: Map<string, number>,
colorByCode: Map<string, string>,
): void {
const ls = d.lineStyleId
? project.lineStyles.find((l) => l.id === d.lineStyleId)
: undefined;
// Plan-Tinte fix dunkel: Fallback auf MONO_INK (statt hell „#d0d0d0"), damit
// ein kategorieloses 2D-Element auf dem hellen Papier sichtbar bleibt.
const color = d.color ?? ls?.color ?? colorByCode.get(d.categoryCode) ?? MONO_INK;
const weightMm = d.weightMm ?? ls?.weight ?? lwByCode.get(d.categoryCode) ?? WALL_FALLBACK_MM;
const dash = ls?.dash ?? null;
const seg = (a: Vec2, b: Vec2) =>
out.push({ kind: "line", a, b, cls: "draw2d", weightMm, dash, color, greyed, drawingId: d.id });
/**
* Gefüllte Fläche einer GESCHLOSSENEN Form: trägt die Vollton-Füllfarbe
* (`d.fillColor`, sonst transparent) UND die optionale Füllschraffur
* (`d.hatchId`, sonst NO_HATCH). Wird VOR den Strichen gepusht, damit die
* Umrisslinien oben liegen. Trägt `drawingId`, sodass ein Klick auf die Fläche
* das Element selektiert (analog zu den Linien).
*/
const fillPoly = (pts: Vec2[]) =>
out.push({
kind: "polygon",
pts,
fill: d.fillColor ?? "none",
// Umriss zeichnen die separaten Linien-Primitive (mit Strichmuster); die
// Füllfläche selbst bleibt randlos, um doppelte Striche zu vermeiden.
stroke: "none",
strokeWidthMm: 0,
hatch: d.hatchId ? resolveHatch(project, d.hatchId) : NO_HATCH,
greyed,
drawingId: d.id,
});
const g = d.geom;
switch (g.shape) {
case "line":
seg(g.a, g.b);
break;
case "polyline": {
// Geschlossene Polylinie: zuerst die gefüllte Fläche (Vollton/Schraffur).
if (g.closed && g.pts.length > 2) fillPoly(g.pts);
for (let i = 0; i < g.pts.length - 1; i++) seg(g.pts[i], g.pts[i + 1]);
if (g.closed && g.pts.length > 2) seg(g.pts[g.pts.length - 1], g.pts[0]);
break;
}
case "rect": {
const c1 = g.min;
const c2 = { x: g.max.x, y: g.min.y };
const c3 = g.max;
const c4 = { x: g.min.x, y: g.max.y };
// Rechteck ist immer geschlossen → zuerst die gefüllte Fläche.
fillPoly([c1, c2, c3, c4]);
seg(c1, c2);
seg(c2, c3);
seg(c3, c4);
seg(c4, c1);
break;
}
case "circle": {
// Kreis als geschlossene Polygon-Approximation (N Segmente) — nutzt die
// vorhandene Füll-/Strich-Pipeline, ohne ein eigenes Kreis-Primitiv.
const N = 64;
const pts: Vec2[] = [];
for (let i = 0; i < N; i++) {
const t = (i / N) * Math.PI * 2;
pts.push({ x: g.center.x + g.r * Math.cos(t), y: g.center.y + g.r * Math.sin(t) });
}
fillPoly(pts);
for (let i = 0; i < N; i++) seg(pts[i], pts[(i + 1) % N]);
break;
}
// arc/text: Primitive-Erweiterungen folgen später.
default:
break;
}
}
/**
* Wand als gefüllte Schnittfläche (Poché), um Öffnungen ausgespart.
*
* Detailgrad:
* • mittel/fein — jede Schicht des Wandtyps als eigenes Band (Füllfarbe +
* Schraffur des Bauteils), dazu eine kräftige Wand-Umrisslinie. fein zeichnet
* Schichtfugen und Umriss dünner als mittel.
* • grob — KEINE Schichten, KEINE Schraffur: eine einzige Sammelfläche über die
* volle Wanddicke (Füllung = Farbe des „Backbone"-Bauteils mit höchster
* joinPriority) mit dicker Umrisslinie. Bewusst vereinfacht (≙ DOSSIER
* „Einfach").
*/
function addWallPoche(
out: Primitive[],
project: Project,
wall: Wall,
doors: Project["doors"],
cuts: WallCuts,
greyed: boolean,
detail: DetailLevel,
wallLwMm: number,
): void {
const wt = getWallType(project, wall);
const total = wallTypeThickness(wt);
// Versatz der Schichtstapelung über die Dicke gemäß Referenzlinie (center=0,
// left=+T/2, right=T/2). Alle Band-Offsets unten werden hierum verschoben,
// sodass die Achse bei „außen"/„innen" auf der jeweiligen Wandfläche liegt.
const refOff = wallReferenceOffset(wall, total);
// Jede Schicht-/Umrissfläche trägt die Wand-ID, damit ein Links-Klick die
// GANZE Wand (alle Bänder) markiert und die PlanView die wallId nach oben
// melden kann.
const wallId = wall.id;
// Strich-/Umrandungsfarbe der Wand: optionale Übersteuerung (wall.color),
// sonst der Default-Poché-Strich. Betrifft NUR die Linienfarbe (Umriss +
// Schichtfugen), nicht die Schicht-Füllfarben/Schraffuren.
const stroke = wall.color ?? POCHE_STROKE;
// Öffnungs-Intervalle entlang der Wandachse, sortiert.
const openings = doors
.map((d) => ({ from: d.position, to: d.position + d.width }))
.sort((a, b) => a.from - b.from);
// Wand in Segmente zwischen den Öffnungen zerlegen.
let cursor = 0;
const segments: Array<[number, number]> = [];
for (const op of openings) {
if (op.from > cursor) segments.push([cursor, op.from]);
cursor = Math.max(cursor, op.to);
}
const axisLen = length(wall);
if (cursor < axisLen) segments.push([cursor, axisLen]);
const outlineMm = wallLwMm * OUTLINE_DETAIL_FACTOR[detail];
const layerLineMm = LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail];
// Vereinfachte Füllung (grob): Farbe des tragenden Bauteils (höchste Priorität).
const simpleFill = backboneColor(project, wt);
for (const [s, e] of segments) {
const p1 = along(wall.start, wall.end, s);
const p2 = along(wall.start, wall.end, e);
// Nur die echten Wandenden bekommen die Gehrung. Türnahe Segmentenden
// bleiben rechtwinklig (kein Schnitt).
const startCut = s <= 1e-6 ? cuts.startCut : null;
const endCut = e >= axisLen - 1e-6 ? cuts.endCut : null;
if (detail === "grob") {
// Eine Sammelfläche über die volle Dicke, ohne Schraffur, dicker Umriss.
out.push({
kind: "polygon",
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
fill: simpleFill,
stroke,
strokeWidthMm: outlineMm,
hatch: NO_HATCH,
greyed,
wallId,
});
continue;
}
// mittel/fein: Schichten von außen (-T/2) nach innen (+T/2) stapeln. Jede
// Schicht löst Bauteil (Füllfarbe) + Schraffur (Muster/Maßstab/Winkel/Farbe)
// auf; die Schichtfugen werden dünn gestrichen.
let off = refOff - total / 2;
for (const layer of wt.layers) {
const comp = getComponent(project, layer.componentId);
out.push({
kind: "polygon",
pts: clippedBand(p1, p2, off, off + layer.thickness, startCut, endCut),
fill: comp.color,
stroke,
strokeWidthMm: layerLineMm,
hatch: resolveHatch(project, comp.hatchId),
greyed,
wallId,
});
off += layer.thickness;
}
// Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung),
// damit die Wand sich klar von den dünnen Schichtfugen abhebt.
out.push({
kind: "polygon",
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
fill: "none",
stroke,
strokeWidthMm: outlineMm,
hatch: NO_HATCH,
greyed,
wallId,
});
}
}
/** Farbe des tragenden Bauteils (höchste joinPriority) für die grobe Poché. */
function backboneColor(project: Project, wt: Project["wallTypes"][number]): string {
let best: { color: string; prio: number } | null = null;
for (const layer of wt.layers) {
const comp = getComponent(project, layer.componentId);
if (!best || comp.joinPriority > best.prio)
best = { color: comp.color, prio: comp.joinPriority };
}
return best?.color ?? "#9aa0a6";
}
/**
* Tür-Symbol je Detailgrad:
* • grob — nur die gerade Türblatt-Linie (die Öffnungs-Lücke kommt aus der
* ausgesparten Poché).
* • mittel — + Schwenkbogen (geschlossen → offen).
* • fein — + Rahmen/Anschlag-Striche an beiden Pfosten (kurze Striche quer
* zur Wand, die den Türrahmen/-anschlag andeuten).
*/
function addDoorSymbol(
out: Primitive[],
wall: Wall,
door: Project["doors"][number],
greyed: boolean,
detail: DetailLevel,
doorLwMm: number,
): void {
const u = normalize(sub(wall.end, wall.start));
const n = leftNormal(u); // linke Seite der Wandachse
const swingDir = door.swing === "left" ? n : scale(n, -1);
const jambStart = along(wall.start, wall.end, door.position);
const jambEnd = along(wall.start, wall.end, door.position + door.width);
const hinge = door.hinge === "start" ? jambStart : jambEnd;
const closedDir = door.hinge === "start" ? u : scale(u, -1);
const openEnd = add(hinge, scale(swingDir, door.width));
const closedEnd = add(hinge, scale(closedDir, door.width));
// Türblatt in geöffneter Stellung (alle Stufen).
out.push({
kind: "line",
a: hinge,
b: openEnd,
cls: "door-leaf",
weightMm: doorLwMm,
greyed,
});
// mittel + fein: Schwenkbogen von geschlossen → offen.
if (detail !== "grob") {
out.push({
kind: "arc",
center: hinge,
from: closedEnd,
to: openEnd,
r: door.width,
cls: "door-swing",
weightMm: doorLwMm * 0.6,
dash: [0.06, 0.04], // gestrichelt in mm Papier
greyed,
});
}
// fein: Rahmen/Anschlag-Striche an beiden Pfosten (kurz quer zur Wand,
// symmetrisch um die Achse über eine halbe Wanddicke-Andeutung).
if (detail === "fein") {
const reveal = 0.06; // 6 cm Anschlag-Andeutung je Seite
for (const jamb of [jambStart, jambEnd]) {
out.push({
kind: "line",
a: add(jamb, scale(n, reveal)),
b: add(jamb, scale(n, -reveal)),
cls: "door-frame",
weightMm: doorLwMm,
greyed,
});
}
}
}
/** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */
const NO_HATCH: HatchRender = {
pattern: "none",
scale: 1,
angle: 0,
color: POCHE_STROKE,
lineWeight: LAYER_LINE_MM,
dash: null,
};
/**
* Referenzlinie einer Wand: dünne, gestrichelte Achslinie entlang der Wandachse
* (start → end). Von der Oberleiste umschaltbar; rein darstellend.
*/
function addReferenceLine(out: Primitive[], wall: Wall, greyed: boolean): void {
out.push({
kind: "line",
a: wall.start,
b: wall.end,
cls: "wall-axis",
weightMm: 0.13,
dash: [0.18, 0.06, 0.04, 0.06], // Strich-Punkt-Achse in mm Papier
greyed,
});
}
const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y);
function computeBounds(project: Project, walls: Wall[], drawings: Drawing2D[] = []) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const acc = (p: Vec2) => {
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
};
for (const w of walls) {
const t = wallTypeThickness(getWallType(project, w));
for (const c of wallCorners(w.start, w.end, t)) acc(c);
}
for (const d of drawings) {
const g = d.geom;
if (g.shape === "line") { acc(g.a); acc(g.b); }
else if (g.shape === "polyline") g.pts.forEach(acc);
else if (g.shape === "rect") { acc(g.min); acc(g.max); }
else if (g.shape === "circle" || g.shape === "arc") {
acc({ x: g.center.x - g.r, y: g.center.y - g.r });
acc({ x: g.center.x + g.r, y: g.center.y + g.r });
} else if (g.shape === "text") acc(g.at);
}
// Kontext-Konturen mit einrahmen, damit „Einpassen" sie ebenfalls zeigt.
for (const obj of project.context ?? []) {
if (obj.type !== "contourSet") continue;
for (const c of obj.contours) for (const p of c.pts) acc(p);
}
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 1, maxY: 1 };
return { minX, minY, maxX, maxY };
}
+51
View File
@@ -0,0 +1,51 @@
// App-Store: komponiert die vier Domänen-Slices (project/selection/view/layout)
// über `createStore` zu EINEM RootState und exportiert die Store-Schnittstelle
// (useStore/getState/setState). Jede Slice ist eine Factory `(api) => slice`;
// alle bekommen dieselbe `set/get`-API über den Gesamt-RootState, sodass
// Cross-Slice-Aktionen (z. B. deleteLevel ↔ activeLevelId) typsicher arbeiten.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { createStore } from "./store";
import type { StoreApi } from "./store";
import { createProjectSlice } from "./projectSlice";
import type { ProjectSlice } from "./projectSlice";
import { createSelectionSlice } from "./selectionSlice";
import type { SelectionSlice } from "./selectionSlice";
import { createViewSlice } from "./viewSlice";
import type { ViewSlice } from "./viewSlice";
import { createLayoutSlice } from "./layoutSlice";
import type { LayoutSlice } from "./layoutSlice";
import { createSiteSlice } from "./siteSlice";
import type { SiteSlice } from "./siteSlice";
/** Gesamtzustand: Vereinigung aller Slice-Felder + Actions. */
export type RootState = ProjectSlice &
SelectionSlice &
ViewSlice &
LayoutSlice &
SiteSlice;
const store = createStore<RootState>((api) => ({
// Jede Slice-Factory bekommt die volle RootState-API. Die Slices tippen nur
// ihren eigenen Ausschnitt; die Cast auf die enge Slice-API ist sicher, weil
// `set/get` immer über den Gesamt-RootState laufen.
...createProjectSlice(api as unknown as StoreApi<ProjectSlice & { activeLevelId: string }>),
...createSelectionSlice(api as unknown as StoreApi<SelectionSlice>),
...createViewSlice(api as unknown as StoreApi<ViewSlice>),
...createLayoutSlice(api as unknown as StoreApi<LayoutSlice>),
// Site-/Kontext-Slice liest `project` und schreibt über `setProject`
// (beide aus der Projekt-Slice) — daher die kombinierte Sicht im Cast.
...createSiteSlice(
api as unknown as StoreApi<
SiteSlice & {
project: ProjectSlice["project"];
setProject: ProjectSlice["setProject"];
}
>,
),
}));
export const useStore = store.useStore;
export const getState = store.getState;
export const setState = store.setState;
+202
View File
@@ -0,0 +1,202 @@
// Layout-Slice: das Panel-Layout (Docks/Tabs/Größen + schwebende Fenster) und
// alle Mutationen darauf. Nutzt die reinen Helfer aus src/panels/layout.ts
// (nicht dupliziert). Reiner Umzug aus App.tsx. Bezeichner englisch, Kommentare
// deutsch (CONVENTIONS.md).
//
// Persistenz (saveLayout) und das Init-Reconcile bleiben in App (useState-Init +
// useEffect); diese Slice liefert nur die Mutationen + reconcileLayout-Helfer.
import {
bringToFront,
defaultLayout,
loadLayout,
moveToDock,
moveToFloat,
moveToGroup,
moveToNewGroup,
removeFloat,
reorderInGroup,
setActiveTab,
setGroupWeights,
updateFloat,
} from "../panels/layout";
import { DEFAULT_FLOAT_W, DEFAULT_FLOAT_H } from "../panels/panelDrag";
import { hasPanel } from "../panels/registry";
// Seiteneffekt: registriert die dockbaren Kern-Panels in der Registry. MUSS vor
// `reconcileLayout(loadLayout())` (Slice-Init) laufen, sonst filtert hasPanel
// alle Tabs weg und die Docks blieben leer (Import-Reihenfolge!).
import "../panels/builtinPanels";
import type { DockId, DockState, LayoutState } from "../panels/types";
import type { StoreApi } from "./store";
/** Felder, die diese Slice in den RootState beisteuert. */
export interface LayoutSlice {
layout: LayoutState;
/** Layout direkt ersetzen (z. B. nach „Layout anwenden" aus dem Menü). */
setLayout: (next: LayoutState | ((l: LayoutState) => LayoutState)) => void;
// ── Dock-Größen / Gruppen ────────────────────────────────────────────────
resizeDock: (side: "left" | "right", size: number) => void;
resizeGroups: (side: "left" | "right", weights: number[]) => void;
// ── Tab-Drag-Auflösung (Reorder / Einreihen / Stapeln / Lösen) ────────────
activateTab: (panelId: string) => void;
joinGroup: (
panelId: string,
dock: DockId,
group: number,
index?: number,
) => void;
newGroup: (panelId: string, dock: DockId, at: number) => void;
dropFloat: (panelId: string, x: number, y: number) => void;
// ── Schwebende Fenster ────────────────────────────────────────────────────
focusFloat: (panelId: string) => void;
moveFloat: (panelId: string, x: number, y: number) => void;
resizeFloat: (panelId: string, w: number, h: number) => void;
closeFloat: (panelId: string) => void;
redockFloat: (panelId: string) => void;
/** Titel-Drop eines schwebenden Fensters auf eine Rand-Zone (dock). */
dockFloatTo: (panelId: string, dock: DockId) => void;
}
export function createLayoutSlice(api: StoreApi<LayoutSlice>): LayoutSlice {
const { set, get } = api;
const setLayout: LayoutSlice["setLayout"] = (next) =>
set((s) => ({
layout: typeof next === "function" ? (next as (l: LayoutState) => LayoutState)(s.layout) : next,
}));
// Welcher Dock enthält ein Panel gerade?
const dockOf = (panelId: string): DockId | null => {
const layout = get().layout;
if (layout.left.groups.some((g) => g.tabs.includes(panelId))) return "left";
if (layout.right.groups.some((g) => g.tabs.includes(panelId))) return "right";
return null;
};
// Genaue Lage eines Panels im Layout: Dock + Gruppen-Index + Tab-Index.
const locate = (
panelId: string,
): { dock: DockId; group: number; tab: number } | null => {
const layout = get().layout;
for (const dock of ["left", "right"] as const) {
const groups = layout[dock].groups;
for (let g = 0; g < groups.length; g++) {
const tab = groups[g].tabs.indexOf(panelId);
if (tab >= 0) return { dock, group: g, tab };
}
}
return null;
};
return {
layout: reconcileLayout(loadLayout()),
setLayout,
resizeDock: (side, size) =>
setLayout((l) => ({ ...l, [side]: { ...l[side], size } })),
resizeGroups: (side, weights) =>
setLayout((l) => setGroupWeights(l, side, weights)),
// Unter der Schwelle losgelassen → Klick: Tab in seiner Gruppe aktivieren.
activateTab: (panelId) => {
const side = dockOf(panelId);
if (side) setLayout((l) => setActiveTab(l, side, panelId));
},
// Über einer Tab-Leiste → in DIESE Gruppe einreihen (Stapel zusammenlegen).
joinGroup: (panelId, dock, group, index) => {
const from = locate(panelId);
const layout = get().layout;
if (from && from.dock === dock && from.group === group && index !== undefined) {
setLayout((l) =>
reorderInGroup(l, dock, group, from.tab, clampReorderIndex(from.tab, index)),
);
return;
}
const shift =
from &&
from.dock === dock &&
from.group < group &&
layout[dock].groups[from.group].tabs.length === 1
? 1
: 0;
setLayout((l) => moveToGroup(l, panelId, dock, group - shift, index));
},
// Über einem Gruppen-Körper / einer Rand-Zone → NEUE gestapelte Gruppe.
newGroup: (panelId, dock, at) => {
const from = locate(panelId);
const layout = get().layout;
const shift =
from &&
from.dock === dock &&
from.group < at &&
layout[dock].groups[from.group].tabs.length === 1
? 1
: 0;
setLayout((l) => moveToNewGroup(l, panelId, dock, at - shift));
},
// Über der Arbeitsfläche → lösen (schweben) an der Cursorposition.
dropFloat: (panelId, x, y) =>
setLayout((l) =>
moveToFloat(l, panelId, {
x: Math.max(0, x),
y: Math.max(0, y),
w: DEFAULT_FLOAT_W,
h: DEFAULT_FLOAT_H,
}),
),
focusFloat: (panelId) => setLayout((l) => bringToFront(l, panelId)),
moveFloat: (panelId, x, y) =>
setLayout((l) => updateFloat(l, panelId, { x: Math.max(0, x), y: Math.max(0, y) })),
resizeFloat: (panelId, w, h) =>
setLayout((l) => updateFloat(l, panelId, { w, h })),
closeFloat: (panelId) => setLayout((l) => removeFloat(l, panelId)),
redockFloat: (panelId) => setLayout((l) => moveToDock(l, panelId, "left")),
dockFloatTo: (panelId, dock) => setLayout((l) => moveToDock(l, panelId, dock)),
};
}
// ── Layout-Helfer (verschoben aus App.tsx, 1:1) ──────────────────────────────
/**
* Übersetzt einen Einfüge-Index (0..n) in einen Reorder-Zielindex (0..n-1).
*/
export function clampReorderIndex(fromIndex: number, insertIndex: number): number {
const to = insertIndex > fromIndex ? insertIndex - 1 : insertIndex;
return Math.max(0, to);
}
/**
* Gleicht ein (geladenes) Layout gegen die Registry ab: Tabs ohne registriertes
* Panel werden entfernt, ein verwaister activeTab korrigiert. Wird ein Dock
* dadurch leer, bleibt es leer (Größe erhalten).
*/
export function reconcileLayout(layout: LayoutState): LayoutState {
const def = defaultLayout();
const fix = (dock: DockState): DockState => {
const groups = dock.groups
.map((g) => {
const tabs = g.tabs.filter((id) => hasPanel(id));
const activeTab =
g.activeTab && tabs.includes(g.activeTab) ? g.activeTab : (tabs[0] ?? null);
return { tabs, activeTab, weight: g.weight };
})
.filter((g) => g.tabs.length > 0);
return { groups, size: dock.size };
};
const floating = layout.floating.filter((f) => hasPanel(f.panelId));
return {
left: fix(layout.left),
right: fix(layout.right),
floating,
version: def.version,
};
}
File diff suppressed because it is too large Load Diff
+268
View File
@@ -0,0 +1,268 @@
// Selektions-Attribut-Kontrakt: leitet aus der aktuellen Auswahl
// (selectedWallIds/selectedDrawingId) + dem Projekt ein normalisiertes,
// darstellungsfertiges Attribut-Objekt für das ERSTE selektierte Element ab.
// Die kommenden Paletten (Attributes, Object-Info) lesen ausschließlich diesen
// Kontrakt — sie kennen weder das Modell noch die Auflösungs-Logik.
//
// „Effektiv aufgelöst" = dieselbe Reihenfolge wie generatePlan: explizite
// Übersteuerung am Element → LineStyle → Kategorie-Default. So zeigt die Palette
// den TATSÄCHLICH gezeichneten Wert, nicht nur das (oft leere) Override-Feld.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { flattenCategories, getWallType, wallTypeThickness } from "../model/types";
import type {
Drawing2D,
Drawing2DGeom,
LayerCategory,
Project,
VerticalAnchor,
Wall,
WallReferenceLine,
} from "../model/types";
import { nextFloorAbove, wallVerticalExtent } from "../model/wall";
/** Eintrag für die WallType-Auswahl (Preset-Dropdown) im Object-Info-Panel. */
export interface WallTypeChoice {
id: string;
name: string;
/** Gesamtdicke (Meter) — informativ im Dropdown-Label. */
thickness: number;
/** Anzahl Schichten (1 = einschichtig). */
layerCount: number;
}
/** Eintrag für die Geschoss-Auswahl (Referenzgeschoss / OK-Bindung). */
export interface FloorChoice {
id: string;
name: string;
}
/**
* Wand-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl einer
* einzelnen Wand gesetzt). Effektiv aufgelöste, anzeigefertige Werte plus die
* Listen, die die Dropdowns brauchen.
*/
export interface WallInfo {
referenceLine: WallReferenceLine;
wallTypeId: string;
/** Aktuelle Gesamtdicke (Meter). */
thickness: number;
/** Ob der aktuelle Wandtyp einschichtig ist (1 Schicht). */
singleLayer: boolean;
/** Verfügbare Wandtyp-Presets (für den Mehrschicht-Aufbau). */
wallTypes: WallTypeChoice[];
/** Geschoss, dem die Wand zugeordnet ist (Referenzgeschoss). */
floorId: string;
floorName: string;
/** Alle Geschosse (für UK/OK-„an Geschoss gebunden"-Auswahl). */
floors: FloorChoice[];
/** Geschoss direkt darüber (für „Verknüpfung zum oberen Geschoss"). */
floorAbove?: FloorChoice;
/** UK-Bindung (undefined = Geschoss-Default). */
bottom?: VerticalAnchor;
/** OK-Bindung (undefined = UK + height). */
top?: VerticalAnchor;
/** Aufgelöste absolute UK/OK (Meter) + abgeleitete Höhe. */
zBottom: number;
zTop: number;
}
/** Achsparallele Bounding-Box eines Elements in Modell-Metern. */
export interface SelectionBBox {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
/**
* Normalisierte Sicht auf das erste selektierte Element. Farbe/Strichstärke sind
* EFFEKTIV aufgelöst (Override sonst Default), damit die Palette den real
* gezeichneten Wert zeigt. `fillHatchId`/`closed` sind nur für Drawing2D sinnvoll.
*/
export interface Selection {
kind: "wall" | "drawing2d" | "door";
id: string;
/** Grafik-Kategorie (Ebene) des Elements. */
categoryCode: string;
/** Effektiv aufgelöste Strich-/Umrandungsfarbe (hex). */
color: string;
/** Effektiv aufgelöste Strichstärke in Millimetern. */
weightMm: number;
/**
* Schraffur-ID der Füllung (nur Drawing2D, geschlossene Form); `null` = keine.
* Bei Wänden/Türen `undefined` (Konzept nicht anwendbar).
*/
fillHatchId?: string | null;
/**
* Vollton-Füllfarbe der Füllung (nur Drawing2D, geschlossene Form); `null` =
* keine (transparent). Bei Wänden/Türen `undefined` (nicht anwendbar).
*/
fillColor?: string | null;
/** Ob die Form geschlossen ist (rect/circle/closed polyline) — nur Drawing2D. */
closed?: boolean;
/** Achsparallele Bounding-Box in Modell-Metern. */
bbox: SelectionBBox;
/**
* Wand-Attribute (nur bei `kind === "wall"`). Trägt Referenzlinie, Aufbau-Typ
* (Wandtyp/Dicke), Referenzgeschoss + UK/OK für den Wand-Abschnitt im Panel.
*/
wall?: WallInfo;
}
const WALL_FALLBACK_MM = 0.18;
const DRAW_FALLBACK_COLOR = "#d0d0d0";
const WALL_FALLBACK_COLOR = "#2b3039";
/** Kategorie (per Code) im Baum suchen — flach, da Codes baumweit eindeutig sind. */
function findCategory(
layers: LayerCategory[],
code: string,
): LayerCategory | undefined {
return flattenCategories(layers).find((c) => c.code === code);
}
/** Ob eine 2D-Form geschlossen ist (Füllung/Schraffur sinnvoll). */
function isClosedGeom(g: Drawing2DGeom): boolean {
return (
g.shape === "rect" ||
g.shape === "circle" ||
(g.shape === "polyline" && g.closed)
);
}
/** Bounding-Box einer 2D-Form. */
function geomBBox(g: Drawing2DGeom): SelectionBBox {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
const acc = (x: number, y: number) => {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
};
switch (g.shape) {
case "line":
acc(g.a.x, g.a.y);
acc(g.b.x, g.b.y);
break;
case "polyline":
for (const p of g.pts) acc(p.x, p.y);
break;
case "rect":
acc(g.min.x, g.min.y);
acc(g.max.x, g.max.y);
break;
case "circle":
case "arc":
acc(g.center.x - g.r, g.center.y - g.r);
acc(g.center.x + g.r, g.center.y + g.r);
break;
case "text":
acc(g.at.x, g.at.y);
break;
}
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
return { minX, minY, maxX, maxY };
}
/** Selektion für eine Wand ableiten (Farbe übersteuerbar, Strichstärke = Kategorie). */
function wallSelection(project: Project, wall: Wall): Selection {
const cat = findCategory(project.layers, wall.categoryCode);
const color = wall.color ?? cat?.color ?? WALL_FALLBACK_COLOR;
const weightMm = cat?.lw ?? WALL_FALLBACK_MM;
const minX = Math.min(wall.start.x, wall.end.x);
const minY = Math.min(wall.start.y, wall.end.y);
const maxX = Math.max(wall.start.x, wall.end.x);
const maxY = Math.max(wall.start.y, wall.end.y);
// Wand-Attribute auflösen (Referenzlinie, Aufbau, Referenzgeschoss, UK/OK).
const wt = getWallType(project, wall);
const thickness = wallTypeThickness(wt);
const floor = project.drawingLevels.find((z) => z.id === wall.floorId);
const { zBottom, zTop } = wallVerticalExtent(project, wall);
const above = nextFloorAbove(project, wall);
const wallInfo: WallInfo = {
referenceLine: wall.referenceLine ?? "center",
wallTypeId: wall.wallTypeId,
thickness,
singleLayer: wt.layers.length <= 1,
wallTypes: project.wallTypes.map((t) => ({
id: t.id,
name: t.name,
thickness: wallTypeThickness(t),
layerCount: t.layers.length,
})),
floorId: wall.floorId,
floorName: floor?.name ?? wall.floorId,
floors: project.drawingLevels
.filter((z) => z.kind === "floor")
.map((z) => ({ id: z.id, name: z.name })),
floorAbove: above,
bottom: wall.bottom,
top: wall.top,
zBottom,
zTop,
};
// bbox aus der Wandachse (start/end); die Schichtdicke bleibt unberücksichtigt,
// damit die Box der Achs-Geometrie folgt (genügt für die Paletten).
return {
kind: "wall",
id: wall.id,
categoryCode: wall.categoryCode,
color,
weightMm,
fillHatchId: undefined,
fillColor: undefined,
closed: undefined,
bbox: { minX, minY, maxX, maxY },
wall: wallInfo,
};
}
/** Selektion für ein 2D-Zeichenelement ableiten (gleiche Auflösung wie generatePlan). */
function drawingSelection(project: Project, d: Drawing2D): Selection {
const ls = d.lineStyleId
? project.lineStyles.find((l) => l.id === d.lineStyleId)
: undefined;
const cat = findCategory(project.layers, d.categoryCode);
const color = d.color ?? ls?.color ?? cat?.color ?? DRAW_FALLBACK_COLOR;
const weightMm = d.weightMm ?? ls?.weight ?? cat?.lw ?? WALL_FALLBACK_MM;
const closed = isClosedGeom(d.geom);
return {
kind: "drawing2d",
id: d.id,
categoryCode: d.categoryCode,
color,
weightMm,
fillHatchId: closed ? d.hatchId ?? null : null,
fillColor: closed ? d.fillColor ?? null : null,
closed,
bbox: geomBBox(d.geom),
};
}
/**
* Leitet den Selektions-Kontrakt für das ERSTE selektierte Element ab.
* Drawing-Auswahl und Wand-Auswahl schließen sich aus; bei Drawing-Auswahl
* hat diese Vorrang. Liefert `null`, wenn nichts (oder kein auffindbares
* Element) selektiert ist.
*/
export function deriveSelection(
project: Project,
selectedWallIds: string[],
selectedDrawingId: string | null,
): Selection | null {
if (selectedDrawingId) {
const d = project.drawings2d.find((x) => x.id === selectedDrawingId);
return d ? drawingSelection(project, d) : null;
}
const wallId = selectedWallIds[0];
if (wallId) {
const w = project.walls.find((x) => x.id === wallId);
return w ? wallSelection(project, w) : null;
}
return null;
}
+33
View File
@@ -0,0 +1,33 @@
// Auswahl-Slice: gewählte Wände (Menge) und das gewählte 2D-Zeichenelement.
// Wand- und 2D-Auswahl schließen sich gegenseitig aus. Reiner Umzug aus
// App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { StoreApi } from "./store";
/** Felder, die diese Slice in den RootState beisteuert. */
export interface SelectionSlice {
// Gewählte Wände als MENGE von IDs (Marquee/Einzelauswahl).
selectedWallIds: string[];
// Gewählte 2D-Zeichenelemente als MENGE (Marquee/Mehrfach/Einzel) — getrennt
// von der Wand-Auswahl. Einzel-Auswahl = Array der Länge 1.
selectedDrawingIds: string[];
setSelectedWallIds: (ids: string[]) => void;
setSelectedDrawingIds: (ids: string[]) => void;
/** Auswahl (Wände + 2D-Elemente) vollständig leeren. */
clearSelection: () => void;
}
export function createSelectionSlice(
api: StoreApi<SelectionSlice>,
): SelectionSlice {
const { set } = api;
return {
selectedWallIds: [],
selectedDrawingIds: [],
setSelectedWallIds: (ids) => set({ selectedWallIds: ids }),
setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }),
clearSelection: () =>
set({ selectedWallIds: [], selectedDrawingIds: [] }),
};
}
+81
View File
@@ -0,0 +1,81 @@
// Site-/Kontext-Slice: verwaltet die Kontext-Schicht `project.context`
// (importierte Meshes, Konturen-Sätze, abgeleitetes Gelände-TIN). Diese
// Geometrie ist „dumm"/nicht-semantisch — sie liegt bewusst NEBEN dem BIM-Modell.
//
// Entwurfsentscheidung: `context` ist Teil von `project` (ein einziger
// Wahrheits-Baum, konsistent mit projectSlice). Diese Slice MUTIERT daher
// `project.context` IMMUTABEL über die vorhandene `setProject`-Aktion
// (Cross-Slice), genau wie projectSlice ein neues Project-Objekt liefert. So
// bleiben Persistenz/Undo später einheitlich am Project hängen.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { generateTerrainFromContours } from "../model/terrain";
import type { ContextObject, Project } from "../model/types";
import type { StoreApi } from "./store";
/** Felder/Actions, die diese Slice in den RootState beisteuert. */
export interface SiteSlice {
/** Fügt ein Kontext-Objekt (Mesh/Konturen/Gelände) hinzu (immutabel). */
addContextObject: (obj: ContextObject) => void;
/** Fügt mehrere Kontext-Objekte in einem Schritt hinzu (z. B. DXF-Import). */
addContextObjects: (objs: ContextObject[]) => void;
/** Entfernt ein Kontext-Objekt per ID (No-op bei unbekannter ID). */
removeContextObject: (id: string) => void;
/**
* Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN und fügt es als
* weiteres Kontext-Objekt hinzu. Liefert die ID des neuen TerrainMesh, oder
* null, wenn der ContourSet nicht existiert / kein Mesh entsteht.
*/
generateTerrain: (contourSetId: string) => string | null;
/** Leert die gesamte Kontext-Schicht. */
clearContext: () => void;
}
/**
* Cross-Slice-Abhängigkeit: diese Slice liest `project` und schreibt es über
* `setProject` (beide aus projectSlice). Der Store komponiert SiteSlice mit
* dieser Sicht, sodass `get()`/Actions typsicher auf das Projekt zugreifen.
*/
type SiteSliceDeps = {
project: Project;
setProject: (next: Project | ((p: Project) => Project)) => void;
};
export function createSiteSlice(
api: StoreApi<SiteSlice & SiteSliceDeps>,
): SiteSlice {
const { get } = api;
// Immutabler Kontext-Updater: ersetzt `project.context` über die vorhandene
// setProject-Aktion (konsistent mit projectSlice-Stil).
const setContext = (
update: (ctx: ContextObject[]) => ContextObject[],
): void => {
get().setProject((p) => ({ ...p, context: update(p.context ?? []) }));
};
return {
addContextObject: (obj) => setContext((ctx) => [...ctx, obj]),
addContextObjects: (objs) =>
setContext((ctx) => (objs.length ? [...ctx, ...objs] : ctx)),
removeContextObject: (id) =>
setContext((ctx) => ctx.filter((o) => o.id !== id)),
generateTerrain: (contourSetId) => {
const ctx = get().project.context ?? [];
const set = ctx.find(
(o) => o.id === contourSetId && o.type === "contourSet",
);
if (!set || set.type !== "contourSet") return null;
const terrain = generateTerrainFromContours(set.contours);
if (terrain.indices.length === 0) return null; // kein sinnvolles TIN
setContext((c) => [...c, terrain]);
return terrain.id;
},
clearContext: () => setContext(() => []),
};
}
+87
View File
@@ -0,0 +1,87 @@
// Globaler Store — dependency-freier Kern (useSyncExternalStore).
//
// Bewusst leichtgewichtig: kein Zustand-/Redux-Paket, sondern ein winziger
// Store nach demselben Muster wie src/i18n (externe Quelle + React-Hook). Er
// kombiniert die Slices (project/selection/view/layout) zu EINEM Zustand und
// bietet selektives Lesen über `useStore(selector)`, sodass Komponenten nur bei
// Änderung ihres Ausschnitts neu rendern (kein Re-Render-Sturm, kein
// Prop-Drilling).
//
// Eine Slice ist eine Factory `(set, get) => ({ ...state, ...actions })`. `set`
// nimmt ein Teil-Patch ODER eine Updater-Funktion (wie React-setState); `get`
// liest den aktuellen Gesamtzustand. Actions leben damit IM Zustand (z. B.
// `useStore(s => s.addFloor)`), genau wie in der Spec (state-architecture.md).
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useSyncExternalStore } from "react";
/** Patch-Form für `set`: Teilobjekt oder Updater über den Gesamtzustand. */
export type SetStatePatch<T> = Partial<T> | ((state: T) => Partial<T>);
/** Was eine Slice-Factory bekommt, um Zustand zu lesen/schreiben. */
export type StoreApi<T> = {
/** Schreibt ein Teil-Patch (oder Updater) und benachrichtigt Abonnenten. */
set: (patch: SetStatePatch<T>) => void;
/** Liefert den aktuellen Gesamtzustand (für Actions, die andere Felder lesen). */
get: () => T;
};
/** Eine Slice: Factory, die ihren Zustands-/Action-Ausschnitt liefert. */
export type SliceCreator<T, S> = (api: StoreApi<T>) => S;
/** Öffentliche Store-Schnittstelle (Hook + imperative Helfer). */
export interface Store<T> {
/** React-Hook: liest einen Ausschnitt; rendert nur bei dessen Änderung neu. */
useStore: <U>(selector: (state: T) => U) => U;
/** Imperatives Lesen (außerhalb von React, z. B. in Event-Handlern). */
getState: () => T;
/** Imperatives Schreiben (Teil-Patch oder Updater). */
setState: (patch: SetStatePatch<T>) => void;
/** Abonnement (selten direkt nötig; useStore nutzt es intern). */
subscribe: (listener: () => void) => () => void;
}
/**
* Baut den Store aus einer Initializer-Funktion, die mit `(set, get)` den
* kompletten Zustand (Felder + Actions) erzeugt. Der Zustand wird flach mit dem
* Patch zusammengeführt (`{ ...state, ...patch }`); für tiefe/immutable Updates
* sind die Actions selbst verantwortlich (sie liefern fertige neue Werte).
*/
export function createStore<T>(init: SliceCreator<T, T>): Store<T> {
let state: T;
const listeners = new Set<() => void>();
const set: StoreApi<T>["set"] = (patch) => {
const partial = typeof patch === "function" ? (patch as (s: T) => Partial<T>)(state) : patch;
// Referenz-Gleichheit als No-op-Schutz: identisches Objekt → kein Notify.
let changed = false;
for (const k in partial) {
if ((partial as Record<string, unknown>)[k] !== (state as Record<string, unknown>)[k]) {
changed = true;
break;
}
}
if (!changed) return;
state = { ...state, ...partial };
for (const l of listeners) l();
};
const get: StoreApi<T>["get"] = () => state;
state = init({ set, get });
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const useStore = <U,>(selector: (s: T) => U): U =>
useSyncExternalStore(
subscribe,
() => selector(state),
() => selector(state),
);
return { useStore, getState: get, setState: set, subscribe };
}
+97
View File
@@ -0,0 +1,97 @@
// View-Slice: alles, was die Darstellung steuert, ohne Modell zu sein —
// Ansichtstyp, Detailgrad, Render-Modus, Referenzlinien, aktive Zeichnungsebene,
// Massstab, Linien-Modus sowie der Darstellungsmodus je Panel. Reiner Umzug aus
// App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
//
// Entscheidung `activeCategoryCode`: liegt hier (View), NICHT in selectionSlice.
// Es ist keine Element-Selektion, sondern die „aktive Ebene" (Arbeitsfokus für
// neue Zeichnungen / Status-Leiste) — view-seitiger Zustand wie activeLevelId.
import { sampleProject } from "../model/sampleProject";
import type { DetailLevel, RenderMode, View3d, ViewType } from "../ui/TopBar";
import type { DisplayMode } from "../panels/types";
import type { StoreApi } from "./store";
/** Standard-Darstellungsmodus je Panel (wie bisher: alles sichtbar normal). */
const DEFAULT_DISPLAY_MODE: DisplayMode = "all";
/** Felder, die diese Slice in den RootState beisteuert. */
export interface ViewSlice {
// Aktive Zeichnungsebene (Geschoss/Schnitt/Zeichnung).
activeLevelId: string;
// Ansichtstyp (Grundriss / Perspektive).
viewType: ViewType;
// Detailgrad im Grundriss.
detail: DetailLevel;
// Render-Modus der 3D-Ansicht.
renderMode: RenderMode;
// Kanonischer Blickwinkel der 3D-Ansicht (Front/Top/Seite/Iso/freie Persp.).
view3d: View3d;
// Sichtwinkel (FOV) der 3D-Perspektivkamera in Grad.
fov: number;
// Farb-Modus des Grundrisses: "color" = Kategorie-/Element-Farben (heute),
// "mono" = reiner Schwarz-Weiss-Plan (Tinte, keine Farb-/Schraffurfarben).
planColorMode: "color" | "mono";
// Wand-Achslinien im Grundriss.
referenceLines: boolean;
// Linien-Darstellung: "display" = Haarlinie, "print" = echte mm-Stärken.
lineMode: "display" | "print";
// Gewählter (angewandter) Papier-Massstab „1:N".
scaleDenominator: number;
// Aktive Ebene (Kategorie-Code) — Ziel neuer Zeichnungen (Arbeitsfokus).
activeCategoryCode: string;
// Darstellungsmodus je Panel-ID (z. B. "drawing-levels", "layers").
displayModes: Record<string, DisplayMode>;
setActiveLevelId: (id: string) => void;
setViewType: (v: ViewType) => void;
setDetail: (d: DetailLevel) => void;
setRenderMode: (m: RenderMode) => void;
setView3d: (v: View3d) => void;
setFov: (n: number) => void;
setPlanColorMode: (m: "color" | "mono") => void;
setReferenceLines: (b: boolean) => void;
setLineMode: (m: "display" | "print") => void;
setScaleDenominator: (n: number) => void;
setActiveCategoryCode: (code: string) => void;
/** Darstellungsmodus eines Panels lesen (Default „all"). */
modeFor: (id: string | null) => DisplayMode;
/** Darstellungsmodus eines Panels setzen (No-op bei null id). */
setModeFor: (id: string | null, mode: DisplayMode) => void;
}
export function createViewSlice(api: StoreApi<ViewSlice>): ViewSlice {
const { set, get } = api;
return {
activeLevelId: sampleProject.drawingLevels[0].id,
viewType: "grundriss",
detail: "mittel",
renderMode: "shaded",
view3d: "perspective",
fov: 50,
planColorMode: "color",
referenceLines: false,
lineMode: "display",
scaleDenominator: 100,
activeCategoryCode:
sampleProject.walls[0]?.categoryCode ?? sampleProject.layers[0].code,
displayModes: {},
setActiveLevelId: (id) => set({ activeLevelId: id }),
setViewType: (v) => set({ viewType: v }),
setDetail: (d) => set({ detail: d }),
setRenderMode: (m) => set({ renderMode: m }),
setView3d: (v) => set({ view3d: v }),
setFov: (n) => set({ fov: n }),
setPlanColorMode: (m) => set({ planColorMode: m }),
setReferenceLines: (b) => set({ referenceLines: b }),
setLineMode: (m) => set({ lineMode: m }),
setScaleDenominator: (n) => set({ scaleDenominator: n }),
setActiveCategoryCode: (code) => set({ activeCategoryCode: code }),
modeFor: (id) => (id && get().displayModes[id]) || DEFAULT_DISPLAY_MODE,
setModeFor: (id, mode) => {
if (!id) return;
set((s) => ({ displayModes: { ...s.displayModes, [id]: mode } }));
},
};
}
+176
View File
@@ -0,0 +1,176 @@
// Sichtbarkeits-Kombinationen — Persistenz (localStorage).
//
// Speichert benannte Sichtbarkeits-Sätze (Vectorworks: „Ebenenkombinationen"
// / „Zeichnungskombinationen") für zwei unabhängige Achsen:
// • Ebenen-Kombination (LayerCombo) — pro Kategorie-`code` → sichtbar?
// • Zeichnungs-Komb. (DrawingCombo) — pro DrawingLevel-`id` → sichtbar?
//
// Analog zu panels/layout.ts: reine Speicher-Helfer OHNE React-Abhängigkeit,
// sämtlicher localStorage-Zugriff in try/catch + storage()-Guard (SSR/Tests/
// Privatmodus). Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
//
// Hinweis: Diese Sätze liegen (wie die benannten Layouts) im localStorage und
// sind damit GERÄTE-/Browser-lokal, nicht im Projekt-Modell. Sollen sie mit dem
// Projekt mitwandern (Export/Teilen), gehören sie später ins `Project` (siehe
// Report).
/** Eine gespeicherte Ebenen-Kombination: Kategorie-Code → sichtbar?. */
export interface LayerCombo {
codes: Record<string, boolean>;
}
/** Eine gespeicherte Zeichnungs-Kombination: DrawingLevel-Id → sichtbar?. */
export interface DrawingCombo {
ids: Record<string, boolean>;
}
/** Präfix der localStorage-Schlüssel für Ebenen-Kombinationen. */
const LAYER_PREFIX = "cad.layercombos.";
/** Präfix der localStorage-Schlüssel für Zeichnungs-Kombinationen. */
const DRAWING_PREFIX = "cad.drawingcombos.";
// ── localStorage-Sicherung (wie panels/layout.ts) ───────────────────────────
/** Liefert das localStorage-Objekt oder `null`, wenn nicht verfügbar. */
function storage(): Storage | null {
try {
if (typeof localStorage === "undefined") return null;
return localStorage;
} catch {
// Zugriff kann werfen (z. B. blockierte Cookies/Privatmodus).
return null;
}
}
/** Liest und parst einen JSON-Wert; `null` bei Fehlen/ungültig. */
function readJson<T>(key: string): T | null {
const ls = storage();
if (!ls) return null;
try {
const raw = ls.getItem(key);
if (raw === null) return null;
return JSON.parse(raw) as T;
} catch {
return null;
}
}
/** Schreibt einen JSON-Wert; liefert `false`, wenn das Schreiben scheitert. */
function writeJson(key: string, value: unknown): boolean {
const ls = storage();
if (!ls) return false;
try {
ls.setItem(key, JSON.stringify(value));
return true;
} catch {
// Kontingent überschritten oder Zugriff verweigert — still ignorieren.
return false;
}
}
/** Löscht einen Schlüssel; liefert `true`, wenn der Zugriff gelang. */
function removeKey(key: string): boolean {
const ls = storage();
if (!ls) return false;
try {
ls.removeItem(key);
return true;
} catch {
return false;
}
}
/** Listet alle Namen unter einem Präfix (alphabetisch sortiert). */
function listNames(prefix: string): string[] {
const ls = storage();
if (!ls) return [];
const names: string[] = [];
try {
for (let i = 0; i < ls.length; i++) {
const key = ls.key(i);
if (key && key.startsWith(prefix)) {
names.push(key.slice(prefix.length));
}
}
} catch {
return [];
}
return names.sort();
}
/**
* Typwächter für eine `Record<string, boolean>`-Map (toleriert Fremddaten aus
* localStorage). Liefert eine bereinigte Map (nur boolesche Werte), sonst `null`.
*/
function asBoolMap(v: unknown): Record<string, boolean> | null {
if (typeof v !== "object" || v === null) return null;
const out: Record<string, boolean> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (typeof val === "boolean") out[k] = val;
}
return out;
}
// ── Ebenen-Kombinationen ─────────────────────────────────────────────────────
/** Speichert eine Ebenen-Kombination unter einem Namen. `true` bei Erfolg. */
export function saveLayerCombo(
name: string,
codes: Record<string, boolean>,
): boolean {
if (!name) return false;
const value: LayerCombo = { codes };
return writeJson(LAYER_PREFIX + name, value);
}
/**
* Lädt eine Ebenen-Kombination (nur die `codes`-Map). Fehlt sie oder ist sie
* beschädigt, kommt `null` zurück (so erkennt der Aufrufer „nicht gefunden").
*/
export function loadLayerCombo(name: string): Record<string, boolean> | null {
const raw = readJson<unknown>(LAYER_PREFIX + name);
if (raw === null || typeof raw !== "object") return null;
return asBoolMap((raw as { codes?: unknown }).codes);
}
/** Listet die Namen aller gespeicherten Ebenen-Kombinationen (alphabetisch). */
export function listLayerCombos(): string[] {
return listNames(LAYER_PREFIX);
}
/** Löscht eine Ebenen-Kombination. `true`, wenn der Zugriff gelang. */
export function deleteLayerCombo(name: string): boolean {
return removeKey(LAYER_PREFIX + name);
}
// ── Zeichnungs-Kombinationen ─────────────────────────────────────────────────
/** Speichert eine Zeichnungs-Kombination unter einem Namen. `true` bei Erfolg. */
export function saveDrawingCombo(
name: string,
ids: Record<string, boolean>,
): boolean {
if (!name) return false;
const value: DrawingCombo = { ids };
return writeJson(DRAWING_PREFIX + name, value);
}
/**
* Lädt eine Zeichnungs-Kombination (nur die `ids`-Map). Fehlt sie oder ist sie
* beschädigt, kommt `null` zurück.
*/
export function loadDrawingCombo(name: string): Record<string, boolean> | null {
const raw = readJson<unknown>(DRAWING_PREFIX + name);
if (raw === null || typeof raw !== "object") return null;
return asBoolMap((raw as { ids?: unknown }).ids);
}
/** Listet die Namen aller gespeicherten Zeichnungs-Kombinationen (alphabetisch). */
export function listDrawingCombos(): string[] {
return listNames(DRAWING_PREFIX);
}
/** Löscht eine Zeichnungs-Kombination. `true`, wenn der Zugriff gelang. */
export function deleteDrawingCombo(name: string): boolean {
return removeKey(DRAWING_PREFIX + name);
}
+3339
View File
File diff suppressed because it is too large Load Diff
+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}`;
}
+175
View File
@@ -0,0 +1,175 @@
// Command-Line-UI (docs/design/rhino-command-system.md §3.2) — sitzt ÜBER der
// Statusleiste. Drei Rollen gleichzeitig (§2.1): Prompt-Text (links), klickbare
// Inline-Optionen, Texteingabe; darüber ein Autocomplete-Dropdown.
//
// Rein gesteuert: alle Aktionen laufen über Callbacks an die Engine. Tab (global
// in App) fokussiert das Eingabefeld. Bezeichner englisch, sichtbarer Text via t().
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { t } from "../i18n";
import type { TranslationKey } from "../i18n";
export interface CommandLineOption {
id: string;
labelKey: string;
value?: string;
}
/** Ein Zahlenfeld des Tab-Feld-Zyklus (§2.7). */
export interface CommandLineField {
id: string;
labelKey: string;
/** Gelockter Wert (oder null = folgt der Maus). */
value: number | null;
/** Aktuelles Tab-Ziel? */
active: boolean;
}
export interface CommandLineProps {
/** Läuft gerade ein Befehl? (steuert den Ruhetext.) */
active: boolean;
/** Aktueller Prompt-Key (i18n) oder null im Ruhezustand. */
promptKey: string | null;
/** Inline-Optionen des aktuellen Schritts. */
options: CommandLineOption[];
/** Geordnete Zahlenfelder des aktuellen Schritts (Tab-Feld-Zyklus). */
fields: CommandLineField[];
/** Enter mit dem aktuellen Eingabetext (Befehl/Koordinate/Zahl). */
onSubmit: (text: string) => void;
/** Klick auf eine Inline-Option. */
onOption: (id: string) => void;
/** Tab im Feld-Modus: nächstes Feld aktivieren. */
onCycleField: () => void;
/** Esc: laufenden Befehl abbrechen. */
onCancel: () => void;
/** Autocomplete-Kandidaten für ein Präfix (nur im Ruhezustand sinnvoll). */
suggest: (prefix: string) => string[];
}
export interface CommandLineHandle {
/** Fokussiert (und öffnet) das Eingabefeld — vom globalen Tab-Handler genutzt. */
focus: () => void;
}
export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
function CommandLine(
{ active, promptKey, options, fields, onSubmit, onOption, onCycleField, onCancel, suggest },
ref,
) {
const [text, setText] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
}));
// Autocomplete nur im Ruhezustand (Befehlsname tippen), nicht in einem Schritt.
const suggestions = !active && text.trim() !== "" ? suggest(text).slice(0, 6) : [];
const hasFields = fields.length > 0;
const submit = (value: string) => {
onSubmit(value);
setText("");
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Tasten NICHT zum globalen App-Handler durchreichen (eigener Eingabefokus).
if (e.key === "Enter") {
e.preventDefault();
submit(text);
} else if (e.key === "Escape") {
e.preventDefault();
if (active) onCancel();
setText("");
inputRef.current?.blur();
} else if (e.key === "Tab") {
if (hasFields) {
// Feld-Modus (§2.7): Tab zyklt die Felder. Steht eine getippte Zahl im
// Feld, wird sie zuerst ins aktive Feld gelockt (das rückt selbst vor),
// sonst nur zum nächsten Feld weiterschalten.
e.preventDefault();
if (text.trim() !== "") submit(text);
else onCycleField();
} else if (suggestions.length > 0) {
// Ruhezustand: Tab akzeptiert den ersten Autocomplete-Vorschlag.
e.preventDefault();
setText(suggestions[0]);
}
}
};
const promptText = promptKey
? t(promptKey as TranslationKey)
: t("cmd.idle.prompt");
return (
<div className="cmdline" role="group" aria-label={t("cmd.aria")}>
{suggestions.length > 0 && (
<ul className="cmdline-suggest" role="listbox">
{suggestions.map((s) => (
<li
key={s}
role="option"
aria-selected="false"
className="cmdline-suggest-item"
onMouseDown={(e) => {
// mousedown (vor blur), damit der Klick nicht den Fokus verliert.
e.preventDefault();
submit(s);
}}
>
{s}
</li>
))}
</ul>
)}
<span className="cmdline-prompt">{promptText}</span>
{options.length > 0 && (
<span className="cmdline-options">
{options.map((o) => (
<button
key={o.id}
type="button"
className="cmdline-option"
onMouseDown={(e) => {
e.preventDefault();
onOption(o.id);
}}
>
{t(o.labelKey as TranslationKey)}
{o.value != null && <span className="cmdline-option-val">={o.value}</span>}
</button>
))}
</span>
)}
{hasFields && (
<span className="cmdline-fields">
{fields.map((f) => (
<span
key={f.id}
className={`cmdline-field${f.active ? " active" : ""}`}
title={t(f.labelKey as TranslationKey)}
>
<span className="cmdline-field-label">{t(f.labelKey as TranslationKey)}</span>
<span className="cmdline-field-val">
{f.value == null ? "—" : Number(f.value.toFixed(3)).toString()}
</span>
</span>
))}
</span>
)}
<input
ref={inputRef}
className="cmdline-input"
type="text"
spellCheck={false}
autoComplete="off"
value={text}
placeholder={active ? "" : t("cmd.idle.placeholder")}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
/>
</div>
);
},
);
+164
View File
@@ -0,0 +1,164 @@
// Generisches, wiederverwendbares Kontextmenü — 1:1 nach DOSSIER
// (docs/design/context-menu.md, Quelle src/components/ContextMenu.jsx).
//
// Reine Darstellung + Callbacks: der Aufrufer hält die offene Position (x/y in
// Viewport-Pixeln), die Item-Liste und reagiert über `onClose`. Das Menü selbst
// kümmert sich nur um Positionierung (Fixed + Rand-Clamp), das Schließen
// (Außenklick · Escape · erneuter Rechtsklick · Scroll · nach Item-Klick) und
// die Darstellung der Items (Icon, Shortcut, danger, divider, disabled).
//
// Gerendert über ein Portal in <body>, damit es über allem liegt und nicht vom
// overflow/transform eines Panels abgeschnitten wird (z-index ~300, unter
// Modals). Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
/**
* Ein Eintrag im Kontextmenü. Ein reiner Trenner setzt nur `divider: true`
* (alle anderen Felder werden dann ignoriert). Sonst ist `label` Pflicht und
* `onClick` die ausgelöste Aktion.
*/
export interface ContextMenuItem {
/** Sichtbarer Text (deutsch). Bei `divider` ungenutzt. */
label?: string;
/** Optionales Icon (z. B. Material-Symbol-Name oder eigener Knoten). */
icon?: ReactNode;
/** Aktion beim Klick. Wird nur ausgeführt, wenn nicht `disabled`. */
onClick?: () => void;
/** Ausgegraut, nicht klickbar. */
disabled?: boolean;
/** Rote Schrift (destruktive Aktion, z. B. Löschen). */
danger?: boolean;
/** Optionales Tastenkürzel rechtsbündig (nur Anzeige). */
shortcut?: string;
/**
* Optionaler Tooltip (title) vor allem, um bei `disabled` den Grund zu
* nennen (z. B. keine Auswahl"). Reine Anzeige.
*/
title?: string;
/** Reiner Trenner (1px Linie); ignoriert alle anderen Felder. */
divider?: boolean;
}
export interface ContextMenuProps {
/** Ankerposition in Viewport-Pixeln (clientX). */
x: number;
/** Ankerposition in Viewport-Pixeln (clientY). */
y: number;
/** Die Einträge in Anzeigereihenfolge. */
items: ContextMenuItem[];
/** Schließen-Callback (Außenklick · Esc · Scroll · nach Item-Klick). */
onClose: () => void;
/** Optionaler Titel oben (caps, gedämpft) — z. B. Ebenenname/-code. */
title?: string;
}
/** Rand-Abstand zum Viewport beim Clampen (px). */
const EDGE_PAD = 4;
/**
* Material-Symbols-Icon (DOSSIER nutzt die Icon-Font). Fällt auf reinen Text
* zurück; ist die Font nicht geladen, bleibt der Slot via min-width leer
* ausgerichtet, sodass die Labels trotzdem fluchten.
*/
export function MenuIcon({ name }: { name: string }) {
return (
<span className="material-symbols-outlined ctx-icon" aria-hidden="true">
{name}
</span>
);
}
export function ContextMenu({ x, y, items, onClose, title }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
// Tatsächliche Position nach dem Clampen an die Viewport-Ränder. Bis das Menü
// gemessen ist, am Anker zeigen (kein Sprung — useLayoutEffect korrigiert vor
// dem Paint).
const [pos, setPos] = useState({ x, y });
// Nach dem Mount messen und so verschieben, dass das Menü vollständig im
// Viewport bleibt (rechter/unterer Rand → nach links/oben klappen).
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let nx = x;
let ny = y;
if (nx + rect.width > vw - EDGE_PAD) nx = vw - rect.width - EDGE_PAD;
if (ny + rect.height > vh - EDGE_PAD) ny = vh - rect.height - EDGE_PAD;
if (nx < EDGE_PAD) nx = EDGE_PAD;
if (ny < EDGE_PAD) ny = EDGE_PAD;
setPos({ x: nx, y: ny });
}, [x, y, items]);
// Schließen bei: Klick/Rechtsklick außerhalb · Escape · Scroll · Resize.
// pointerdown (capture) fängt auch den Beginn eines erneuten Rechtsklicks.
useEffect(() => {
const onPointerDown = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const onScrollOrResize = () => onClose();
// capture: vor anderen Handlern; passive scroll reicht (wir reagieren nur).
window.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("keydown", onKey);
window.addEventListener("scroll", onScrollOrResize, true);
window.addEventListener("resize", onScrollOrResize);
return () => {
window.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScrollOrResize, true);
window.removeEventListener("resize", onScrollOrResize);
};
}, [onClose]);
const onItemClick = (item: ContextMenuItem) => {
if (item.disabled || item.divider) return;
item.onClick?.();
onClose(); // Auto-Close nach Item-Klick.
};
const menu = (
<div
ref={ref}
className="ctx-menu"
role="menu"
style={{ left: pos.x, top: pos.y }}
// Eigenen Rechtsklick abfangen, damit kein Browser-Menü erscheint.
onContextMenu={(e) => e.preventDefault()}
>
{title && <div className="ctx-title">{title}</div>}
{items.map((item, i) =>
item.divider ? (
<div key={`div-${i}`} className="ctx-divider" role="separator" />
) : (
<button
key={`item-${i}`}
type="button"
role="menuitem"
className={`ctx-item${item.danger ? " danger" : ""}`}
disabled={item.disabled}
title={item.title}
onClick={() => onItemClick(item)}
>
{item.icon !== undefined && (
<span className="ctx-item-icon">{item.icon}</span>
)}
<span className="ctx-item-label">{item.label}</span>
{item.shortcut && (
<span className="ctx-item-shortcut">{item.shortcut}</span>
)}
</button>
),
)}
</div>
);
return createPortal(menu, document.body);
}
+330
View File
@@ -0,0 +1,330 @@
// Eigene, themebare Dropdown-Komponente im Stil des Kontextmenüs (ContextMenu).
//
// Ersetzt die nativen <select>-Dropdowns der Oberleiste, deren geöffnete
// Optionsliste vom Betriebssystem gerendert (blau, eckig, nicht themebar) wird.
// Das Popover hier ist dasselbe dunkle, abgerundete Popover wie das
// Kontextmenü: gleiche Tokens (Schatten, Radius, Akzent-Hover), Tastatur
// (↑/↓/Enter/Esc), Klick-außerhalb schließt, aktive Option markiert.
//
// Zwei Verwendungsarten:
// • Wert-Auswahl wie <select>: `options` + `value` + `onChange` (Häkchen an
// der aktiven Option).
// • Aktions-Menü wie ein Trigger-Dropdown: `items` (mit Trennern/danger/
// disabled, analog ContextMenu-Items). Der Trigger zeigt dann `value`-Label
// bzw. den festen `placeholder`.
//
// Reine Darstellung + Callbacks; voll gesteuert. Identifier englisch, UI-Text
// kommt als Props (i18n) herein (CLAUDE.md).
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
/** Eine wählbare Option (Wert-Auswahl-Modus, analog <option>). */
export interface DropdownOption {
value: string;
label: string;
/** Ausgegraut, nicht wählbar. */
disabled?: boolean;
}
/**
* Ein Eintrag im Aktions-Modus (analog ContextMenuItem). Ein reiner Trenner
* setzt nur `divider: true`. Sonst ist `label` Pflicht und `onSelect` die
* Aktion.
*/
export interface DropdownItem {
/** Sichtbarer Text. Bei `divider` ungenutzt. */
label?: string;
/** Aktion bei Auswahl. Nur ausgeführt, wenn nicht `disabled`. */
onSelect?: () => void;
/** Ausgegraut, nicht klickbar. */
disabled?: boolean;
/** Rote Schrift (destruktive Aktion). */
danger?: boolean;
/** Als aktiv markiert (Häkchen). */
active?: boolean;
/** Reiner Trenner (1px Linie); ignoriert alle anderen Felder. */
divider?: boolean;
}
export interface DropdownProps {
/**
* Wert-Auswahl-Modus: aktueller Wert + Optionsliste. Genau einer von
* `options`/`items` wird angegeben.
*/
options?: DropdownOption[];
/** Aktueller Wert (Wert-Auswahl-Modus) — bestimmt Trigger-Label + Häkchen. */
value?: string;
/** Wertwechsel-Callback (Wert-Auswahl-Modus). */
onChange?: (value: string) => void;
/** Aktions-Modus: Liste aus Einträgen/Trennern (statt `options`). */
items?: DropdownItem[];
/**
* Festes Trigger-Label (Aktions-Modus, da es keinen Wert" gibt). Im
* Wert-Auswahl-Modus ungenutzt dort zeigt der Trigger das Options-Label.
*/
placeholder?: string;
/** Tooltip/aria-label des Triggers. */
title?: string;
/** Trigger ausgegraut, nicht klickbar. */
disabled?: boolean;
/** Mindestbreite des Triggers (px) — analog <select>-Breiten. */
width?: number;
/** Optionale Zusatzklasse am Trigger (z. B. monospace beim Massstab). */
triggerClassName?: string;
/** Optionaler Inhalt rechts im Trigger statt des Chevrons (selten). */
triggerSuffix?: ReactNode;
}
/** Rand-Abstand zum Viewport beim Clampen (px). */
const EDGE_PAD = 4;
/** Kleines Chevron (Aufklapp-Pfeil) im Trigger. */
function Chevron() {
return (
<svg
className="tb-dd-chevron"
width="9"
height="6"
viewBox="0 0 10 6"
aria-hidden="true"
>
<path d="M0 0l5 6 5-6z" fill="currentColor" />
</svg>
);
}
/** Häkchen vor der aktiven Option. */
function Check() {
return (
<svg
className="tb-dd-check"
width="12"
height="12"
viewBox="0 0 12 12"
aria-hidden="true"
>
<path
d="M2.5 6.2 L4.7 8.6 L9.5 3"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function Dropdown({
options,
value,
onChange,
items,
placeholder,
title,
disabled = false,
width,
triggerClassName,
triggerSuffix,
}: DropdownProps) {
const [open, setOpen] = useState(false);
// Popover-Position (fixed), aus dem Trigger-Rect berechnet — die Oberleiste
// hat overflow:hidden und würde ein absolut positioniertes Popover
// abschneiden (gleiche Technik wie CameraMenu/ContextMenu).
const [pos, setPos] = useState({ left: 0, top: 0, minWidth: 0 });
// Tastatur-Fokus (Index in der gerenderten Zeilenliste).
const [activeIdx, setActiveIdx] = useState(-1);
const btnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
// Einheitliche Zeilenliste: im Wert-Modus aus `options`, im Aktions-Modus aus
// `items`. So teilen beide Modi dieselbe Render-/Tastatur-Logik.
const rows: DropdownItem[] = items
? items
: (options ?? []).map((o) => ({
label: o.label,
disabled: o.disabled,
active: o.value === value,
onSelect: () => onChange?.(o.value),
}));
// Trigger-Label: Wert-Modus → Label der aktiven Option; sonst `placeholder`.
const current = options?.find((o) => o.value === value);
const triggerLabel = current?.label ?? placeholder ?? "";
// Index der ersten auswählbaren Zeile (für Tastatur-Start).
const firstSelectable = rows.findIndex((r) => !r.divider && !r.disabled);
const place = () => {
const r = btnRef.current?.getBoundingClientRect();
if (!r) return;
setPos({ left: r.left, top: r.bottom + 4, minWidth: r.width });
};
const openMenu = () => {
if (disabled) return;
place();
// Tastatur-Fokus auf die aktive bzw. erste auswählbare Zeile setzen.
const activeRow = rows.findIndex((r) => r.active && !r.disabled);
setActiveIdx(activeRow >= 0 ? activeRow : firstSelectable);
setOpen(true);
};
const close = () => {
setOpen(false);
setActiveIdx(-1);
};
const selectRow = (row: DropdownItem) => {
if (row.disabled || row.divider) return;
row.onSelect?.();
close();
};
// Nach dem Öffnen: Position vor dem Paint an die Viewport-Ränder clampen.
useLayoutEffect(() => {
if (!open) return;
const el = menuRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
setPos((p) => {
let left = p.left;
let top = p.top;
if (left + rect.width > vw - EDGE_PAD) left = vw - rect.width - EDGE_PAD;
if (top + rect.height > vh - EDGE_PAD) {
// Nach oben klappen, wenn unten kein Platz ist.
const r = btnRef.current?.getBoundingClientRect();
top = r ? r.top - rect.height - 4 : vh - rect.height - EDGE_PAD;
}
if (left < EDGE_PAD) left = EDGE_PAD;
if (top < EDGE_PAD) top = EDGE_PAD;
return { ...p, left, top };
});
}, [open]);
// Schließen bei: Außenklick · Scroll · Resize. (Esc + Pfeile laufen über den
// Keydown-Handler unten, der nur bei offenem Menü aktiv ist.)
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
const tgt = e.target as Node;
if (
menuRef.current?.contains(tgt) ||
btnRef.current?.contains(tgt)
) {
return;
}
close();
};
const onScrollOrResize = () => close();
window.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("scroll", onScrollOrResize, true);
window.addEventListener("resize", onScrollOrResize);
return () => {
window.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("scroll", onScrollOrResize, true);
window.removeEventListener("resize", onScrollOrResize);
};
}, [open]);
// Tastatur: ↑/↓ bewegt den Fokus über auswählbare Zeilen (Trenner/disabled
// überspringen), Enter wählt, Esc schließt.
const step = (dir: 1 | -1) => {
if (rows.length === 0) return;
let i = activeIdx;
for (let n = 0; n < rows.length; n++) {
i = (i + dir + rows.length) % rows.length;
const row = rows[i];
if (row && !row.divider && !row.disabled) {
setActiveIdx(i);
return;
}
}
};
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
if (!open) {
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
openMenu();
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
close();
} else if (e.key === "ArrowDown") {
e.preventDefault();
step(1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
step(-1);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
const row = rows[activeIdx];
if (row) selectRow(row);
}
};
const menu = open ? (
<div
ref={menuRef}
className="ctx-menu tb-dd-menu"
role="listbox"
style={{ left: pos.left, top: pos.top, minWidth: pos.minWidth }}
onContextMenu={(e) => e.preventDefault()}
>
{rows.map((row, i) =>
row.divider ? (
<div key={`div-${i}`} className="ctx-divider" role="separator" />
) : (
<button
key={`row-${i}`}
type="button"
role="option"
aria-selected={!!row.active}
className={`ctx-item tb-dd-item${row.danger ? " danger" : ""}${
i === activeIdx ? " is-active" : ""
}`}
disabled={row.disabled}
onMouseEnter={() => !row.disabled && setActiveIdx(i)}
onClick={() => selectRow(row)}
>
<span className="tb-dd-check-slot">{row.active && <Check />}</span>
<span className="ctx-item-label">{row.label}</span>
</button>
),
)}
</div>
) : null;
return (
<>
<button
ref={btnRef}
type="button"
className={`tb-dd-trigger${open ? " active" : ""}${
triggerClassName ? ` ${triggerClassName}` : ""
}`}
style={width ? { minWidth: width } : undefined}
disabled={disabled}
title={title}
aria-label={title}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => (open ? close() : openMenu())}
onKeyDown={onTriggerKeyDown}
>
<span className="tb-dd-trigger-label">{triggerLabel}</span>
{triggerSuffix ?? <Chevron />}
</button>
{menu && createPortal(menu, document.body)}
</>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { t } from "../i18n";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
/** Sicherheitsnetz: fängt Render-Fehler ab, statt die Seite weiß zu lassen. */
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error(t("error.console"), error, info);
}
render(): ReactNode {
if (this.state.error) {
return (
<div className="error-screen">
<h1>{t("error.title")}</h1>
<p>{t("error.body")}</p>
<pre>{this.state.error.message}</pre>
<button onClick={() => location.reload()}>{t("error.reload")}</button>
</div>
);
}
return this.props.children;
}
}
+39
View File
@@ -0,0 +1,39 @@
// Inline-SVG-Augensymbol für Sichtbarkeits-Schalter im Navigator.
// Nutzt currentColor → erbt die Textfarbe der Zeile (aktiv/gedimmt/grau).
// Offen = sichtbar, geschlossen/durchgestrichen = ausgeblendet.
/** Augensymbol; `open` steuert offen (sichtbar) vs. durchgestrichen. */
export function EyeIcon({ open }: { open: boolean }) {
return (
<svg
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{open ? (
<>
{/* Augenkontur + Pupille. */}
<path d="M1.5 12S5 5 12 5s10.5 7 10.5 7-3.5 7-10.5 7S1.5 12 1.5 12Z" />
<circle cx={12} cy={12} r={3} />
</>
) : (
<>
{/* Augenkontur, gedimmt, plus diagonaler Strich. */}
<path
d="M1.5 12S5 5 12 5s10.5 7 10.5 7-3.5 7-10.5 7S1.5 12 1.5 12Z"
opacity={0.45}
/>
<circle cx={12} cy={12} r={3} opacity={0.45} />
<line x1={3} y1={3} x2={21} y2={21} />
</>
)}
</svg>
);
}
+333
View File
@@ -0,0 +1,333 @@
// Import-Dialog (modal) für DXF/DWG. Öffnet sich beim Import (Button ODER
// Datei-Drop) und fragt, WIE mit der Datei umzugehen ist:
// • Ziel-Zeichnungsebene: bestehende wählen ODER neue „Zeichnung" anlegen.
// • Ebenen-Handling: DXF-Layer als Kategorien (categoryCode) übernehmen vs.
// alles auf die aktive Kategorie.
// • Mesh-Behandlung: vorhandene Meshes als Kontext-Geometrie (3D) importieren.
//
// Muster wie ResourceManager: abgedunkeltes Overlay, Klick auf den Hintergrund
// und Esc schließen. Diese Komponente HÄLT nur Formular-Zustand und liefert beim
// Bestätigen eine strukturierte Entscheidung (`ImportDecision`) nach oben; die
// eigentlichen Store-Mutationen (Ebene anlegen, Drawings anhängen, Kontext)
// führt App in der richtigen Reihenfolge aus.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CLAUDE.md). Alle sichtbaren
// Texte über t(...).
import { useEffect, useState } from "react";
import { t } from "../i18n";
import type { TranslationKey } from "../i18n";
import type { DrawingLevelKind, ImportedMesh, Project } from "../model/types";
import type { DxfImportResult } from "../io/dxfParser";
import {
contoursToDrawings,
countContours,
distinctLayers,
} from "../io/dxfToDrawings";
import type { CategoryMode } from "../io/dxfToDrawings";
/** Strukturierte Import-Entscheidung, die der Dialog beim Bestätigen liefert. */
export interface ImportDecision {
/** Ziel: bestehende Ebene (id) ODER neue Zeichnung (name). */
target: { kind: "existing"; levelId: string } | { kind: "new"; name: string };
/** Wie DXF-Layer auf Kategorien abgebildet werden. */
categoryMode: CategoryMode;
/** Ob die Meshes als Kontext-Geometrie übernommen werden. */
importMeshes: boolean;
/** Die geparsten Konturen-Sätze (für die spätere 2D-Konvertierung). */
parsed: DxfImportResult;
}
/** i18n-Badge-Key je Zeichnungsebenen-Art. */
function kindBadgeKey(kind: DrawingLevelKind): TranslationKey {
switch (kind) {
case "floor":
return "import.badge.floor";
case "section":
return "import.badge.section";
case "elevation":
return "import.badge.elevation";
case "drawing":
return "import.badge.drawing";
}
}
export interface ImportDialogProps {
fileName: string;
/**
* true, wenn eine DWG-Datei NICHT gelesen werden konnte (LibreDWG-WASM schlug
* fehl) ODA-Hinweis als Fallback. Bei erfolgreichem DWG-Parsing ist das false
* und `parsed` enthält das Ergebnis (gleicher Fluss wie DXF).
*/
isDwg: boolean;
/** true, solange eine DWG-Datei noch geparst wird (WASM lädt/läuft). */
loading?: boolean;
/** Geparstes Ergebnis (null während Laden / bei DWG-Parse-Fehler). */
parsed: DxfImportResult | null;
project: Project;
activeCategoryCode: string;
onImport: (decision: ImportDecision) => void;
onClose: () => void;
}
export function ImportDialog({
fileName,
isDwg,
loading = false,
parsed,
project,
activeCategoryCode,
onImport,
onClose,
}: ImportDialogProps) {
// Esc schließt den Dialog (wie ResourceManager).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const contourSets = parsed?.contours ?? [];
const meshes: ImportedMesh[] = parsed?.meshes ?? [];
const contourCount = countContours(contourSets);
const layers = distinctLayers(contourSets);
const hasDrawings = contourCount > 0;
const hasMeshes = meshes.length > 0;
// ── Formular-Zustand ──────────────────────────────────────────────────────
const firstLevelId = project.drawingLevels[0]?.id ?? "";
const [targetKind, setTargetKind] = useState<"existing" | "new">("new");
const [existingLevelId, setExistingLevelId] = useState<string>(firstLevelId);
// Default-Name der neuen Zeichnung aus dem Dateinamen (ohne Endung).
const [newName, setNewName] = useState<string>(() =>
fileName.replace(/\.[^.]+$/, "") || "",
);
const [categoryMode, setCategoryMode] = useState<CategoryMode>(
layers.length > 0 ? "byLayer" : "active",
);
const [importMeshes, setImportMeshes] = useState<boolean>(true);
const canImport =
!isDwg && !loading && (hasDrawings || (hasMeshes && importMeshes));
const onConfirm = () => {
if (!parsed || !canImport) return;
const target =
targetKind === "existing"
? ({ kind: "existing", levelId: existingLevelId } as const)
: ({ kind: "new", name: newName } as const);
onImport({ target, categoryMode, importMeshes, parsed });
};
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={t("import.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{t("import.title")}</span>
<button
className="imp-close"
onClick={onClose}
aria-label={t("import.close")}
>
×
</button>
</header>
<div className="imp-body">
{/* Datei-Zusammenfassung. */}
<div className="imp-file">
<span className="imp-file-label">{t("import.file")}</span>
<span className="imp-file-name" title={fileName}>
{fileName}
</span>
</div>
{loading ? (
// DWG wird gelesen (WASM lädt/parst) → Ladehinweis, nur Abbrechen.
<section className="imp-section">
<div className="imp-section-title">{t("import.dwg.heading")}</div>
<div className="imp-dwg-hint">{t("import.dwg.loading")}</div>
</section>
) : isDwg ? (
// DWG konnte nicht gelesen werden → ODA-Fallback-Hinweis.
<section className="imp-section">
<div className="imp-section-title">{t("import.dwg.heading")}</div>
<div className="imp-dwg-hint">{t("import.dwg.hint")}</div>
</section>
) : (
<>
{/* Inhalt der Datei. */}
<section className="imp-section">
<div className="imp-section-title">
{t("import.summary.heading")}
</div>
<ul className="imp-summary">
<li>{t("import.summary.contours", { n: contourCount })}</li>
<li>{t("import.summary.meshes", { n: meshes.length })}</li>
<li>
{layers.length > 0
? t("import.summary.layers", { layers: layers.join(", ") })
: t("import.summary.noLayers")}
</li>
</ul>
{!hasDrawings && !hasMeshes && (
<div className="imp-warn">{t("import.summary.empty")}</div>
)}
</section>
{/* Ziel-Zeichnungsebene. */}
{hasDrawings && (
<section className="imp-section">
<div className="imp-section-title">
{t("import.target.heading")}
</div>
<label className="imp-radio">
<input
type="radio"
name="imp-target"
checked={targetKind === "existing"}
onChange={() => setTargetKind("existing")}
/>
<span>{t("import.target.existing")}</span>
</label>
{targetKind === "existing" && (
<select
className="imp-select"
value={existingLevelId}
onChange={(e) => setExistingLevelId(e.target.value)}
aria-label={t("import.target.existing")}
>
{project.drawingLevels.map((z) => (
<option key={z.id} value={z.id}>
{z.name} · {t(kindBadgeKey(z.kind))}
</option>
))}
</select>
)}
<label className="imp-radio">
<input
type="radio"
name="imp-target"
checked={targetKind === "new"}
onChange={() => setTargetKind("new")}
/>
<span>{t("import.target.new")}</span>
</label>
{targetKind === "new" && (
<input
type="text"
className="imp-input"
value={newName}
placeholder={t("import.target.newNamePlaceholder")}
onChange={(e) => setNewName(e.target.value)}
aria-label={t("import.target.newNamePlaceholder")}
/>
)}
</section>
)}
{/* Ebenen-Handling (DXF-Layer → Kategorien). */}
{hasDrawings && (
<section className="imp-section">
<div className="imp-section-title">
{t("import.layers.heading")}
</div>
<label className="imp-radio">
<input
type="radio"
name="imp-cat"
checked={categoryMode === "byLayer"}
disabled={layers.length === 0}
onChange={() => setCategoryMode("byLayer")}
/>
<span>{t("import.layers.asCategories")}</span>
</label>
<label className="imp-radio">
<input
type="radio"
name="imp-cat"
checked={categoryMode === "active"}
onChange={() => setCategoryMode("active")}
/>
<span>
{t("import.layers.toActive", { code: activeCategoryCode })}
</span>
</label>
</section>
)}
{/* Mesh-Behandlung. */}
{hasMeshes && (
<section className="imp-section">
<div className="imp-section-title">
{t("import.mesh.heading")}
</div>
<label className="imp-check">
<input
type="checkbox"
checked={importMeshes}
onChange={(e) => setImportMeshes(e.target.checked)}
/>
<span>{t("import.mesh.asContext")}</span>
</label>
<div className="imp-note">{t("import.mesh.note")}</div>
</section>
)}
</>
)}
</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onClose}>
{t("import.cancel")}
</button>
<button
className="imp-btn primary"
onClick={onConfirm}
disabled={!canImport}
>
{t("import.confirm")}
</button>
</footer>
</div>
</div>
);
}
/**
* Baut aus einer Import-Entscheidung die fertigen Drawing2D-Elemente für die
* Ziel-Ebene `levelId`. `byLayer` mappt jeden DXF-Layer-Namen deterministisch
* auf einen Kategorie-Code: existiert eine Kategorie mit passendem Namen, wird
* deren Code genutzt; sonst fällt der Layer auf den aktiven Code zurück.
*
* (Die App ruft diese Funktion, NACHDEM sie ggf. die neue Ebene angelegt hat
* sie braucht die echte Ziel-`levelId`.)
*/
export function buildDrawings(
decision: ImportDecision,
levelId: string,
project: Project,
activeCategoryCode: string,
) {
// Layer-Name → Kategorie-Code: bevorzugt eine Kategorie mit demselben Namen
// (case-insensitiv), sonst der aktive Code. Identifier bleiben so stabil.
const byName = new Map<string, string>();
for (const c of project.layers) byName.set(c.name.toLowerCase(), c.code);
const layerToCode = (layerName: string): string =>
byName.get(layerName.toLowerCase()) ?? activeCategoryCode;
return contoursToDrawings(
decision.parsed.contours,
levelId,
decision.categoryMode,
activeCategoryCode,
layerToCode,
);
}
+840
View File
@@ -0,0 +1,840 @@
// Ressourcen-Manager — verwaltete Stil-Bibliotheken nach DOSSIER-Vorbild
// (Vectorworks-Stil). Drei Sektionen in einer rechten Schublade (Drawer):
// • Bauteile (Components) — Poché-/3D-Farbe, Verschneidungs-Rang, Schraffur.
// • Schraffuren (Hatches) — Muster/Maßstab/Winkel/Farbe + Linienstil.
// • Linien (Line Styles) — Strichstärke/Farbe/Strichmuster.
//
// Darstellung als saubere Tabelle (CONVENTIONS.md): pro Tab EINE sticky Kopfzeile
// mit Spaltentiteln, darunter kompakte Datenzeilen mit Inline-Edit pro Zelle —
// keine wiederholten Feld-Beschriftungen je Zeile. Umgesetzt als CSS-Grid, das
// Kopf- und Datenzeilen dieselben Spalten teilen (`grid-template-columns` je
// Tab), damit die Spalten exakt fluchten.
//
// Reine Darstellung + Callbacks: alle Mutationen laufen immutabel über die
// vom App gereichten Handler (setProject). Plan & 3D leiten aus demselben
// Projekt ab und aktualisieren dadurch live — diese Komponente kennt weder
// Rendering noch Persistenz. Bezeichner englisch, UI-Text deutsch
// (CONVENTIONS.md).
import { useRef, useState } from "react";
import type {
Component,
HatchPattern,
HatchStyle,
LineStyle,
Project,
} from "../model/types";
import { PEN_WEIGHTS } from "../model/types";
import { parseLin } from "../io/linParser";
import { parsePat } from "../io/patParser";
import { EyeIcon } from "./EyeIcon";
import { t } from "../i18n";
// ── Auswahl-Optionen (UI-Beschriftung übersetzt, Werte englisch) ───────────
/** Schraffur-Muster mit i18n-Key für die Beschriftung. */
const PATTERN_OPTIONS: { value: HatchPattern; labelKey: string }[] = [
{ value: "none", labelKey: "pattern.none" },
{ value: "solid", labelKey: "pattern.solid" },
{ value: "insulation", labelKey: "pattern.insulation" },
{ value: "diagonal", labelKey: "pattern.diagonal" },
{ value: "crosshatch", labelKey: "pattern.crosshatch" },
];
/**
* Benannte Strichmuster (mm). `null` = durchgezogen. Werte als Schlüssel
* serialisiert, damit das Dropdown den aktuellen Stil wiedererkennt.
*/
const DASH_OPTIONS: { key: string; labelKey: string; dash: number[] | null }[] = [
{ key: "solid", labelKey: "dash.solid", dash: null },
{ key: "dashed", labelKey: "dash.dashed", dash: [6, 4] },
{ key: "dotted", labelKey: "dash.dotted", dash: [1, 3] },
];
/** Findet den Dash-Schlüssel, der zu einem Strichmuster passt (sonst custom). */
function dashKey(dash: number[] | null): string {
const hit = DASH_OPTIONS.find(
(o) =>
(o.dash === null && dash === null) ||
(o.dash !== null &&
dash !== null &&
o.dash.length === dash.length &&
o.dash.every((v, i) => v === dash[i])),
);
return hit ? hit.key : "custom";
}
// ── Wiederverwendbare Feld-Bausteine (DOSSIER-Look) ────────────────────────
// Alle Steuerelemente füllen ihre Tabellenzelle (width:100%) und werden über
// die Spaltenbreite des Grids dimensioniert; Zahlenfelder sind rechtsbündig.
/** Kompaktes Textfeld (Pill) für Namen/Platzhalter. */
function TextField({
value,
onChange,
placeholder,
mono = false,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
mono?: boolean;
}) {
return (
<input
type="text"
className={`res-input${mono ? " mono" : ""}`}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
);
}
/** Zahlenfeld (Pill, rechtsbündig, monospace) mit Live-Commit. */
function NumberField({
value,
onChange,
step = 1,
min,
max,
list,
}: {
value: number;
onChange: (v: number) => void;
step?: number;
min?: number;
max?: number;
/** Optionale <datalist>-ID mit Vorschlagswerten (z. B. Stiftstärken). */
list?: string;
}) {
return (
<input
type="number"
className="res-input mono num"
value={Number.isFinite(value) ? value : 0}
step={step}
min={min}
max={max}
list={list}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isNaN(v)) onChange(v);
}}
/>
);
}
/**
* Farb-Swatch mit nativem Color-Picker. Das <label> umschließt einen
* sichtbaren Swatch und den unsichtbaren color-input Klick öffnet den
* Picker, der Swatch zeigt die Farbe live (nach DOSSIER ColorBar).
*/
function ColorField({
color,
onChange,
}: {
color: string;
onChange: (v: string) => void;
}) {
return (
<label className="res-color" title={color}>
<span className="res-color-swatch" style={{ background: color }} />
<input
type="color"
value={color}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
/** Pill-Dropdown (erbt das globale select-Styling). */
function SelectField<T extends string>({
value,
onChange,
options,
}: {
value: T;
onChange: (v: T) => void;
options: { value: T; label: string }[];
}) {
return (
<select
className="res-select"
value={value}
onChange={(e) => onChange(e.target.value as T)}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
/** Runder Löschen-Knopf (Mülleimer-Icon) in der letzten Spalte. */
function DeleteButton({ onClick, title }: { onClick: () => void; title: string }) {
return (
<button className="res-delete" title={title} onClick={onClick}>
<svg
width={15}
height={15}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14" />
<path d="M10 11v6M14 11v6" />
</svg>
</button>
);
}
// ── Tabellen-Gerüst ────────────────────────────────────────────────────────
/** Eine Spaltendefinition: Titel-Key + optionale rechtsbündige Ausrichtung. */
interface ResColumn {
/** i18n-Key des Spaltentitels (leerer String = titellose Spalte). */
titleKey: string;
/** Zahlen-Spalten rechtsbündig ausrichten (Titel + Zellen). */
align?: "right";
/** Optionaler i18n-Key für den Tooltip auf dem Spaltentitel. */
hintKey?: string;
}
/**
* Tabelle als CSS-Grid: eine sticky Kopfzeile mit Spaltentiteln, darunter eine
* Datenzeile je Item. Kopf und Zeilen erben `gridTemplateColumns` vom äußeren
* Grid, damit die Spalten exakt fluchten. Datenzeilen werden als Kinder
* übergeben (jede Zeile = `ResRow`).
*/
function ResTable({
columns,
template,
children,
}: {
columns: ResColumn[];
template: string;
children: React.ReactNode;
}) {
return (
<div className="res-table" style={{ gridTemplateColumns: template }}>
<div className="res-thead">
{columns.map((c, i) => (
<div
key={i}
className={`res-th${c.align === "right" ? " num" : ""}`}
title={c.hintKey ? t(c.hintKey) : undefined}
>
{c.titleKey ? t(c.titleKey) : ""}
</div>
))}
</div>
{children}
</div>
);
}
/** Eine Datenzeile der Tabelle (Zellen als Kinder, eine je Spalte). */
function ResRow({ children }: { children: React.ReactNode }) {
return <div className="res-tr">{children}</div>;
}
/** Eine Tabellenzelle; optional rechtsbündig (für Zahlen). */
function ResCell({
children,
align,
emphasis = false,
}: {
children: React.ReactNode;
align?: "right";
emphasis?: boolean;
}) {
return (
<div
className={`res-td${align === "right" ? " num" : ""}${
emphasis ? " emphasis" : ""
}`}
>
{children}
</div>
);
}
// ── Bauteile (Components) ──────────────────────────────────────────────────
// Spalten: Name | Farbe | Schraffur | Prio | Textur | (löschen).
const COMPONENT_COLUMNS: ResColumn[] = [
{ titleKey: "resources.col.name" },
{ titleKey: "resources.col.color" },
{ titleKey: "resources.col.hatch" },
{
titleKey: "resources.col.prio",
align: "right",
hintKey: "resources.col.prio.hint",
},
{ titleKey: "resources.col.texture" },
{ titleKey: "" },
];
const COMPONENT_TEMPLATE =
"minmax(120px, 1fr) auto minmax(120px, 0.8fr) 64px minmax(100px, 0.8fr) 36px";
function ComponentRow({
component,
hatches,
onPatch,
onDelete,
}: {
component: Component;
hatches: HatchStyle[];
onPatch: (patch: Partial<Component>) => void;
onDelete: () => void;
}) {
const hatchOptions = hatches.map((h) => ({ value: h.id, label: h.name }));
return (
<ResRow>
<ResCell>
<TextField
value={component.name}
onChange={(name) => onPatch({ name })}
placeholder={t("resources.components.placeholder")}
/>
</ResCell>
<ResCell>
<ColorField
color={component.color}
onChange={(color) => onPatch({ color })}
/>
</ResCell>
<ResCell>
<SelectField
value={component.hatchId}
onChange={(hatchId) => onPatch({ hatchId })}
options={hatchOptions}
/>
</ResCell>
<ResCell align="right" emphasis>
<NumberField
value={component.joinPriority}
onChange={(joinPriority) => onPatch({ joinPriority })}
step={10}
min={0}
/>
</ResCell>
<ResCell>
<TextField
value={component.texture3d ?? ""}
onChange={(v) => onPatch({ texture3d: v || undefined })}
placeholder={t("resources.texture.empty")}
mono
/>
</ResCell>
<ResCell>
<DeleteButton
onClick={onDelete}
title={t("resources.delete.component", { name: component.name })}
/>
</ResCell>
</ResRow>
);
}
function ComponentsTab({
project,
onPatchComponent,
onAddComponent,
onDeleteComponent,
}: {
project: Project;
onPatchComponent: (id: string, patch: Partial<Component>) => void;
onAddComponent: () => void;
onDeleteComponent: (id: string) => void;
}) {
return (
<div className="res-tab">
<div className="res-hint">{t("resources.components.hint")}</div>
<ResTable columns={COMPONENT_COLUMNS} template={COMPONENT_TEMPLATE}>
{project.components.map((c) => (
<ComponentRow
key={c.id}
component={c}
hatches={project.hatches}
onPatch={(patch) => onPatchComponent(c.id, patch)}
onDelete={() => onDeleteComponent(c.id)}
/>
))}
{project.components.length === 0 && (
<EmptyState text={t("resources.components.empty")} />
)}
</ResTable>
<AddButton label={t("resources.add")} onClick={onAddComponent} />
</div>
);
}
// ── Schraffuren (Hatches) ──────────────────────────────────────────────────
// Spalten: Name | Muster | Maßstab | Winkel | Farbe | Linienstil | (löschen).
const HATCH_COLUMNS: ResColumn[] = [
{ titleKey: "resources.col.name" },
{ titleKey: "resources.col.pattern" },
{ titleKey: "resources.col.scale", align: "right" },
{ titleKey: "resources.col.angle", align: "right" },
{ titleKey: "resources.col.color" },
{ titleKey: "resources.col.lineStyle" },
{ titleKey: "" },
];
const HATCH_TEMPLATE =
"minmax(110px, 1fr) minmax(120px, 0.9fr) 64px 60px auto minmax(120px, 0.9fr) 36px";
function HatchRow({
hatch,
lineStyles,
onPatch,
onDelete,
}: {
hatch: HatchStyle;
lineStyles: LineStyle[];
onPatch: (patch: Partial<HatchStyle>) => void;
onDelete: () => void;
}) {
// Sentinel-Eintrag „— ohne —", da lineStyleId optional ist.
const NONE = "__none__";
const lineOptions = [
{ value: NONE, label: t("resources.lineStyle.none") },
...lineStyles.map((l) => ({ value: l.id, label: l.name })),
];
return (
<ResRow>
<ResCell>
<TextField
value={hatch.name}
onChange={(name) => onPatch({ name })}
placeholder={t("resources.hatches.placeholder")}
/>
</ResCell>
<ResCell>
<SelectField
value={hatch.pattern}
onChange={(pattern) => onPatch({ pattern })}
options={PATTERN_OPTIONS.map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
/>
</ResCell>
<ResCell align="right">
<NumberField
value={hatch.scale}
onChange={(scale) => onPatch({ scale })}
step={0.1}
min={0.01}
/>
</ResCell>
<ResCell align="right">
<NumberField
value={hatch.angle}
onChange={(angle) => onPatch({ angle })}
step={5}
/>
</ResCell>
<ResCell>
<ColorField color={hatch.color} onChange={(color) => onPatch({ color })} />
</ResCell>
<ResCell>
<SelectField
value={hatch.lineStyleId ?? NONE}
onChange={(v) => onPatch({ lineStyleId: v === NONE ? undefined : v })}
options={lineOptions}
/>
</ResCell>
<ResCell>
<DeleteButton
onClick={onDelete}
title={t("resources.delete.hatch", { name: hatch.name })}
/>
</ResCell>
</ResRow>
);
}
function HatchesTab({
project,
onPatchHatch,
onAddHatch,
onDeleteHatch,
onImportHatches,
}: {
project: Project;
onPatchHatch: (id: string, patch: Partial<HatchStyle>) => void;
onAddHatch: () => void;
onDeleteHatch: (id: string) => void;
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
}) {
return (
<div className="res-tab">
<div className="res-hint">{t("resources.hatches.hint")}</div>
<ResTable columns={HATCH_COLUMNS} template={HATCH_TEMPLATE}>
{project.hatches.map((h) => (
<HatchRow
key={h.id}
hatch={h}
lineStyles={project.lineStyles}
onPatch={(patch) => onPatchHatch(h.id, patch)}
onDelete={() => onDeleteHatch(h.id)}
/>
))}
{project.hatches.length === 0 && (
<EmptyState text={t("resources.hatches.empty")} />
)}
</ResTable>
<div className="res-add-row">
<button className="res-add" onClick={onAddHatch}>
<span className="res-add-plus">+</span> {t("resources.add")}
</button>
<FileImportButton
accept=".pat,text/plain"
label={t("resources.import.pat")}
onText={(text) => onImportHatches(patToHatches(text))}
/>
</div>
</div>
);
}
// ── Linien (Line Styles) ───────────────────────────────────────────────────
/** Kleine Vorschau eines Linienstils (Stärke + Strichmuster) als SVG. */
function LinePreview({ style }: { style: LineStyle }) {
// Stärke (mm) optisch skaliert auf 16 px für die Vorschau.
const px = Math.max(1, Math.min(6, style.weight * 6));
const dasharray = style.dash ? style.dash.map((d) => d * 4).join(" ") : undefined;
return (
<svg className="res-line-preview" viewBox="0 0 120 12" preserveAspectRatio="none">
<line
x1={4}
y1={6}
x2={116}
y2={6}
stroke={style.color}
strokeWidth={px}
strokeDasharray={dasharray}
strokeLinecap="butt"
/>
</svg>
);
}
// Spalten: Name | Stärke (mm) | Farbe | Strich | Vorschau | (löschen).
const LINE_COLUMNS: ResColumn[] = [
{ titleKey: "resources.col.name" },
{ titleKey: "resources.col.weight", align: "right" },
{ titleKey: "resources.col.color" },
{ titleKey: "resources.col.stroke" },
{ titleKey: "resources.col.preview" },
{ titleKey: "" },
];
const LINE_TEMPLATE =
"minmax(110px, 1fr) 80px auto minmax(120px, 0.9fr) minmax(90px, 1fr) 36px";
function LineStyleRow({
style,
onPatch,
onDelete,
}: {
style: LineStyle;
onPatch: (patch: Partial<LineStyle>) => void;
onDelete: () => void;
}) {
return (
<ResRow>
<ResCell>
<TextField
value={style.name}
onChange={(name) => onPatch({ name })}
placeholder={t("resources.lines.placeholder")}
/>
</ResCell>
<ResCell align="right">
<NumberField
value={style.weight}
onChange={(weight) => onPatch({ weight })}
step={0.05}
min={0.01}
max={2}
list="pen-weights"
/>
</ResCell>
<ResCell>
<ColorField color={style.color} onChange={(color) => onPatch({ color })} />
</ResCell>
<ResCell>
<SelectField
value={dashKey(style.dash)}
onChange={(key) => {
const opt = DASH_OPTIONS.find((o) => o.key === key);
if (opt) onPatch({ dash: opt.dash });
}}
options={DASH_OPTIONS.map((o) => ({ value: o.key, label: t(o.labelKey) }))}
/>
</ResCell>
<ResCell>
<LinePreview style={style} />
</ResCell>
<ResCell>
<DeleteButton
onClick={onDelete}
title={t("resources.delete.lineStyle", { name: style.name })}
/>
</ResCell>
</ResRow>
);
}
function LinesTab({
project,
onPatchLineStyle,
onAddLineStyle,
onDeleteLineStyle,
onImportLineStyles,
}: {
project: Project;
onPatchLineStyle: (id: string, patch: Partial<LineStyle>) => void;
onAddLineStyle: () => void;
onDeleteLineStyle: (id: string) => void;
onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
}) {
return (
<div className="res-tab">
{/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */}
<datalist id="pen-weights">
{PEN_WEIGHTS.map((w) => (
<option key={w} value={w} />
))}
</datalist>
<div className="res-hint">{t("resources.lines.hint")}</div>
<ResTable columns={LINE_COLUMNS} template={LINE_TEMPLATE}>
{project.lineStyles.map((l) => (
<LineStyleRow
key={l.id}
style={l}
onPatch={(patch) => onPatchLineStyle(l.id, patch)}
onDelete={() => onDeleteLineStyle(l.id)}
/>
))}
{project.lineStyles.length === 0 && (
<EmptyState text={t("resources.lines.empty")} />
)}
</ResTable>
<div className="res-add-row">
<button className="res-add" onClick={onAddLineStyle}>
<span className="res-add-plus">+</span> {t("resources.add")}
</button>
<FileImportButton
accept=".lin,text/plain"
label={t("resources.import.lin")}
onText={(text) => onImportLineStyles(linToStyles(text))}
/>
</div>
</div>
);
}
/**
* Datei-Import-Schaltfläche (verstecktes <input type=file>). Liest die gewählte
* Datei als Text und reicht ihn nach oben. Für .lin/.pat-Import.
*/
function FileImportButton({
accept,
label,
onText,
}: {
accept: string;
label: string;
onText: (text: string) => void;
}) {
const ref = useRef<HTMLInputElement | null>(null);
return (
<>
<button className="res-add" onClick={() => ref.current?.click()}>
{label}
</button>
<input
ref={ref}
type="file"
accept={accept}
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) {
const r = new FileReader();
r.onload = () => onText(String(r.result ?? ""));
r.readAsText(f);
}
e.target.value = "";
}}
/>
</>
);
}
/** .lin-Text → id-lose Linienstile (schwarze Haarlinie 0.18 als Default). */
function linToStyles(text: string): Omit<LineStyle, "id">[] {
return parseLin(text).map((p) => ({
name: p.name,
weight: 0.18,
color: "#1a1a1a",
dash: p.dash,
}));
}
/**
* .pat-Text id-lose Schraffuren. Unser Modell kennt nur feste Muster; daher
* approximieren wir: 2 Linienfamilien Kreuzschraffur, sonst Diagonal, mit dem
* Winkel der ersten Familie. (Vollwertiges custom-Pattern später.)
*/
function patToHatches(text: string): Omit<HatchStyle, "id">[] {
return parsePat(text).map((p) => ({
name: p.name,
pattern: (p.families.length >= 2 ? "crosshatch" : "diagonal") as HatchPattern,
scale: 1,
angle: p.families[0]?.angle ?? 45,
color: "#1a1a1a",
lineStyleId: "hatch-line",
}));
}
// ── Gemeinsame kleine Bausteine ────────────────────────────────────────────
function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<div className="res-add-row">
<button className="res-add" onClick={onClick}>
<span className="res-add-plus">+</span> {label}
</button>
</div>
);
}
function EmptyState({ text }: { text: string }) {
return <div className="res-empty">{text}</div>;
}
// ── Manager-Schublade ──────────────────────────────────────────────────────
type TabId = "components" | "hatches" | "lines";
const TABS: { id: TabId; labelKey: string }[] = [
{ id: "components", labelKey: "resources.tab.components" },
{ id: "hatches", labelKey: "resources.tab.hatches" },
{ id: "lines", labelKey: "resources.tab.lines" },
];
/** Bündel der immutablen Mutationen (von App über setProject bereitgestellt). */
export interface ResourceManagerHandlers {
onPatchComponent: (id: string, patch: Partial<Component>) => void;
onAddComponent: () => void;
onDeleteComponent: (id: string) => void;
onPatchHatch: (id: string, patch: Partial<HatchStyle>) => void;
onAddHatch: () => void;
onDeleteHatch: (id: string) => void;
onPatchLineStyle: (id: string, patch: Partial<LineStyle>) => void;
onAddLineStyle: () => void;
onDeleteLineStyle: (id: string) => void;
/** Import: fügt fertige (id-lose) Linienstile/Schraffuren hinzu (.lin/.pat). */
onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
}
/**
* Rechte Schublade mit drei Tabs (Bauteile / Schraffuren / Linien). Wird über
* den Ressourcen"-Eintrag der Oberleiste geöffnet und mutiert das Projekt
* live über die gereichten Handler.
*/
export function ResourceManager({
project,
onClose,
handlers,
}: {
project: Project;
onClose: () => void;
handlers: ResourceManagerHandlers;
}) {
const [tab, setTab] = useState<TabId>("components");
return (
<div className="res-overlay" onClick={onClose}>
<aside
className="res-drawer"
role="dialog"
aria-label={t("resources.label")}
onClick={(e) => e.stopPropagation()}
>
<header className="res-head">
<div className="res-head-title">
<span className="res-head-icon">
<EyeIcon open />
</span>
{t("resources.title")}
</div>
<button
className="res-close"
onClick={onClose}
aria-label={t("resources.close")}
>
×
</button>
</header>
<nav className="res-tabs" role="tablist">
{TABS.map((tabDef) => (
<button
key={tabDef.id}
role="tab"
aria-selected={tab === tabDef.id}
className={`res-tab-btn${tab === tabDef.id ? " active" : ""}`}
onClick={() => setTab(tabDef.id)}
>
{t(tabDef.labelKey)}
</button>
))}
</nav>
<div className="res-body">
{tab === "components" && (
<ComponentsTab
project={project}
onPatchComponent={handlers.onPatchComponent}
onAddComponent={handlers.onAddComponent}
onDeleteComponent={handlers.onDeleteComponent}
/>
)}
{tab === "hatches" && (
<HatchesTab
project={project}
onPatchHatch={handlers.onPatchHatch}
onAddHatch={handlers.onAddHatch}
onDeleteHatch={handlers.onDeleteHatch}
onImportHatches={handlers.onImportHatches}
/>
)}
{tab === "lines" && (
<LinesTab
project={project}
onPatchLineStyle={handlers.onPatchLineStyle}
onAddLineStyle={handlers.onAddLineStyle}
onDeleteLineStyle={handlers.onDeleteLineStyle}
onImportLineStyles={handlers.onImportLineStyles}
/>
)}
</div>
</aside>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
// Footer / Status-Leiste (unten, ~22 px) — MVP nach docs/design/topbar-footer.md.
//
// Layout: links der Werkzeug-Hinweis (kontextueller Text des aktiven
// Werkzeugs), rechts ein Cluster aus abgeleiteten View-/Modell-Werten:
// Cursor X/Y (Meter, „—" wenn nicht über dem Plan) · Einheit „m" ·
// Massstab 1:N · Zoom % · aktives Geschoss · aktive Ebene.
//
// Rein gesteuert (controlled): alle Werte kommen aus App (Modell + View-State),
// keine eigene Logik. Bezeichner englisch, UI-Text/Kommentare deutsch.
import type { Vec2 } from "../model/types";
import { t } from "../i18n";
export interface StatusBarProps {
/** Kontextueller Werkzeug-Hinweis links (z. B. „Auswahl"). */
hint: string;
/** Cursor in Modell-Metern oder `null` (nicht über dem Plan). */
cursor: Vec2 | null;
/** Einheit aus dem Projekt (vorerst fix „m"). */
unit: string;
/**
* LIVE Papier-Massstab-Nenner (1:N) aus der View-Transform oder `null`
* (keine Plan-Ansicht aktiv ").
*/
scaleDenominator: number | null;
/** Live-Zoom in Prozent oder `null` (keine zoombare Ansicht aktiv). */
zoomPercent: number | null;
/** Name des aktiven Geschosses / der aktiven Zeichnungsebene. */
activeFloor: string;
/** Name der aktiven Ebene (Kategorie) oder „—". */
activeLayer: string;
}
/** Ein Wert-Feld im rechten Cluster: kleine Beschriftung + Wert. */
function Field({ label, value }: { label: string; value: string }) {
return (
<span className="sb-field">
<span className="sb-key">{label}</span>
<span className="sb-val">{value}</span>
</span>
);
}
/** Vertikaler Trenner im Cluster. */
function Sep() {
return <span className="sb-sep" aria-hidden="true" />;
}
/** Eine Meterzahl für die Statusleiste (3 Nachkommastellen, ohne Einheit). */
const fmt = (n: number): string => n.toFixed(3);
export function StatusBar({
hint,
cursor,
unit,
scaleDenominator,
zoomPercent,
activeFloor,
activeLayer,
}: StatusBarProps) {
const coords = cursor
? `${fmt(cursor.x)}, ${fmt(cursor.y)}`
: "—";
return (
<footer className="statusbar">
{/* Links: Werkzeug-Hinweis. */}
<span className="sb-hint">{hint}</span>
{/* Rechts: abgeleiteter Wert-Cluster. */}
<div className="sb-cluster">
<Field label={t("status.xy")} value={coords} />
<Sep />
<Field label={t("status.unit")} value={unit} />
<Sep />
<Field
label={t("status.scale")}
value={scaleDenominator == null ? "—" : `1:${Math.round(scaleDenominator)}`}
/>
<Sep />
<Field
label={t("status.zoom")}
value={zoomPercent == null ? "—" : `${Math.round(zoomPercent)}%`}
/>
<Sep />
<Field label={t("status.floor")} value={activeFloor} />
<Sep />
<Field label={t("status.layer")} value={activeLayer} />
</div>
</footer>
);
}
+753
View File
@@ -0,0 +1,753 @@
// Oberleiste (Top-Bar) — MVP nach docs/design/topbar-footer.md.
//
// Gruppen (links → rechts): Marke · Ansichts-Umschalter (Grundriss/Perspektive)
// · Detailgrad (grob/mittel/fein) · Massstab „1:N" + Zoom (Einpassen/Auswahl/
// 100 %) · Render-Modus · Referenzlinien-Toggle · Layout-Menü · Ressourcen.
//
// Die Leiste ist rein gesteuert (controlled): alle Zustände kommen als Props
// aus App, Aktionen melden sich über Callbacks zurück. Bezeichner englisch,
// UI-Text/Kommentare deutsch (CONVENTIONS.md).
import type { ReactNode } from "react";
import type { Project } from "../model/types";
import { t } from "../i18n";
import { useEffect, useRef, useState } from "react";
import { Dropdown } from "./Dropdown";
import type { DropdownItem, DropdownOption } from "./Dropdown";
/** Ansichtstyp eines Geschosses (wie in App). */
export type ViewType = "grundriss" | "perspektive";
/**
* Detailgrad der Darstellung ( DOSSIER Einfach/Standard/Detail). Wirkt im
* Grundriss auf Tür-/Fenstersymbole + Schraffuren/Linienfeinheit (siehe
* generatePlan.DetailLevel); in der Perspektive noch ohne Wirkung (deferred).
*/
export type DetailLevel = "grob" | "mittel" | "fein";
/**
* Render-/Anzeigemodus der 3D-Ansicht: schattiert, einheitlich weiss (Clay-Look),
* Drahtgitter, Verdeckte Kanten (Hidden-Line). Wirkt nur in der Perspektive.
*/
export type RenderMode = "shaded" | "white" | "wireframe" | "hidden";
/**
* Kanonischer Blickwinkel der 3D-Ansicht (Vectorworks/DOSSIER-Stil). front",
* top", „side", iso" springen auf die jeweilige Standardansicht; „perspective"
* ist der freie 3/4-Blick. Wirkt nur in der Perspektive.
*/
export type View3d = "front" | "top" | "side" | "iso" | "perspective";
/**
* Farb-Modus des Grundrisses: color" zeigt Kategorie-/Element-Farben (heutiges
* Verhalten), mono" erzeugt einen reinen Schwarz-Weiss-Plan. Wirkt nur im
* Grundriss (orthogonal zum Linien-Modus Display/Print).
*/
export type PlanColorMode = "color" | "mono";
/**
* Massstab-Presets 1:N" (Nenner). `null` steht für „frei" (benutzerdefiniert,
* per Eingabe). Quelle für die Anzeige in Top-Bar und Status-Leiste.
*/
export const SCALE_PRESETS: number[] = [1, 5, 10, 20, 50, 100, 200, 500, 1000];
export interface TopBarProps {
project: Project;
// Ansicht.
/** Ob der Ansichts-Umschalter sinnvoll ist (nur für Geschosse). */
viewToggleEnabled: boolean;
viewType: ViewType;
onViewType: (v: ViewType) => void;
// 3D-Ansicht: kanonischer Blickwinkel + Kamera-Sichtwinkel (FOV). Wirkt nur in
// der Perspektive; ein Preset-Klick wechselt nötigenfalls auch dorthin.
view3d: View3d;
onView3d: (v: View3d) => void;
/** Sichtwinkel der Perspektivkamera in Grad (Kamera-Einstellungen). */
fov: number;
onFov: (n: number) => void;
// Zeichenwerkzeuge (Auswahl/Wand/Linie …), Wandtyp-Wahl und Fang-Menü leben
// jetzt in der Werkzeug-Palette (ToolsPanel), nicht mehr in der Oberleiste.
// Detailgrad.
detail: DetailLevel;
onDetail: (d: DetailLevel) => void;
/** Detailgrad wirkt nur, wenn ein Plan gezeichnet wird (sonst deaktiviert). */
detailEnabled: boolean;
// Massstab + Zoom.
/**
* Gewählter (angewandter) Papier-Massstab-Nenner (1:N) steuert das Dropdown
* und 100 %". Setzt beim Wählen den Plan-Zoom auf exakt 1:N.
*/
scaleDenominator: number;
/** Einen Papier-Massstab anwenden (Preset/frei): setzt N + Plan-Zoom. */
onApplyScale: (n: number) => void;
/**
* LIVE Papier-Massstab-Nenner aus der aktuellen View-Transform (ändert sich
* beim Wheel-Zoom) oder `null`, wenn keine Plan-Ansicht aktiv ist.
*/
liveScale: number | null;
/** Live-Zoom in Prozent (aus der Plan-View-Transform). */
zoomPercent: number;
/** Zoom-Aktionen — nur wirksam, wenn eine Plan-Ansicht aktiv ist. */
zoomEnabled: boolean;
onFit: () => void;
onFitSelection: () => void;
onZoom100: () => void;
// Darstellungsart — kontextabhängig: Perspektive nutzt `renderMode`,
// Grundriss nutzt `planColorMode`. Genau einer der beiden ist je View aktiv.
renderMode: RenderMode;
onRenderMode: (m: RenderMode) => void;
/** Render-Modus nur in der Perspektive sinnvoll (sonst deaktiviert). */
renderModeEnabled: boolean;
// Farb-Modus des Grundrisses (Farbig ↔ Schwarz-Weiss). Nur im Grundriss.
planColorMode: PlanColorMode;
onPlanColorMode: (m: PlanColorMode) => void;
// Referenzlinien (Wandachsen).
referenceLines: boolean;
onReferenceLines: (on: boolean) => void;
/** Referenzlinien nur im Grundriss sinnvoll (sonst deaktiviert). */
referenceLinesEnabled: boolean;
// Linien-Darstellung: Display (Haarlinien) ↔ Print (echte mm-Strichstärken).
lineMode: "display" | "print";
onLineMode: (m: "display" | "print") => void;
/** Nur im Grundriss sinnvoll. */
lineModeEnabled: boolean;
// Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen).
/** Handler-Satz für die beiden Kombinations-Dropdowns. */
combos: ComboMenuHandlers;
// Ressourcen-Fenster.
resourcesOpen: boolean;
onToggleResources: () => void;
/** Layout-Menü (von App geliefert, damit dessen Logik dort bleibt). */
layoutMenu: ReactNode;
}
/**
* Verdrahtung der beiden Kombinations-Dropdowns (Vectorworks-Stil). App liefert
* die Speicher-/Lade-/Lösch-Logik; die Komponente bleibt rein gesteuert.
*/
export interface ComboMenuHandlers {
// Ebenen-Kombinationen.
listLayerCombos: () => string[];
saveLayerCombo: (name: string) => void;
loadLayerCombo: (name: string) => void;
deleteLayerCombo: (name: string) => void;
// Zeichnungs-Kombinationen.
listDrawingCombos: () => string[];
saveDrawingCombo: (name: string) => void;
loadDrawingCombo: (name: string) => void;
deleteDrawingCombo: (name: string) => void;
}
/** Kleiner vertikaler Trenner zwischen Gruppen. */
function Sep() {
return <span className="tb-sep" aria-hidden="true" />;
}
/**
* Kleines Kombinations-Menü (Vectorworks-Stil) gespiegelt von `LayoutMenu`:
* aktuelle Sichtbarkeit unter einem Namen sichern, eine gespeicherte Kombination
* wählen (laden) oder löschen. Die Namen werden frisch gelesen, sobald das Menü
* geöffnet wird (onFocus/onMouseDown). Rein gesteuert: alle Aktionen kommen über
* die Callbacks zurück. Wiederverwendbar für Ebenen- UND Zeichnungskombinationen.
*/
function ComboMenu({
title,
savePrompt,
list,
onSave,
onLoad,
onDelete,
}: {
/** Tooltip/aria-label des Dropdowns. */
title: string;
/** Prompt-Text beim Speichern. */
savePrompt: string;
list: () => string[];
onSave: (name: string) => void;
onLoad: (name: string) => void;
onDelete: (name: string) => void;
}) {
// Namen werden frisch gelesen, sobald das Menü gebaut wird (beim Render des
// Triggers reicht nicht — wir lesen bei jedem Öffnen über `refresh`). Da das
// Dropdown gesteuert ist, halten wir die aktuelle Liste im State und
// aktualisieren sie beim Trigger-Klick (siehe `items`-Aufbau unten).
const [names, setNames] = useState<string[]>([]);
const refresh = () => setNames(list());
const save = () => {
const name = window.prompt(savePrompt);
if (!name) return;
onSave(name.trim());
refresh();
};
// Aktions-Menü (analog ContextMenu-Items): „Aktuelle speichern" oben, dann je
// eine Gruppe „Laden" und „Löschen" (danach Trenner). Das Häkchen entfällt
// hier (kein „Wert"); Löschen ist als danger-Item markiert.
const items: DropdownItem[] = [];
items.push({ label: t("combo.saveCurrent"), onSelect: save });
if (names.length > 0) {
items.push({ divider: true });
for (const n of names) {
items.push({ label: n, onSelect: () => onLoad(n) });
}
items.push({ divider: true });
for (const n of names) {
items.push({
label: `${n}`,
danger: true,
onSelect: () => {
onDelete(n);
refresh();
},
});
}
}
return (
<span onPointerDownCapture={refresh}>
<Dropdown
items={items}
placeholder={t("combo.placeholder")}
title={title}
/>
</span>
);
}
/**
* Kamera-Einstellungen als kleines Popover (Vectorworks-Stil): ein FOV-Regler
* für den Sichtwinkel der Perspektivkamera. Öffnet/schliesst per Button; ein
* Klick ausserhalb schliesst es. Rein gesteuert über `fov`/`onFov`.
*/
function CameraMenu({
fov,
onFov,
disabled,
}: {
fov: number;
onFov: (n: number) => void;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
// Popover-Position (fixed), aus dem Button-Rect berechnet — die Oberleiste
// hat overflow:hidden und würde ein absolut positioniertes Popover abschneiden.
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
const wrapRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const toggle = () => {
const next = !open;
if (next && btnRef.current) {
const r = btnRef.current.getBoundingClientRect();
setPos({ left: r.left, top: r.bottom + 6 });
}
setOpen(next);
};
return (
<div className="tb-popwrap" ref={wrapRef}>
<button
ref={btnRef}
className={`view-icon${open ? " active" : ""}`}
disabled={disabled}
onClick={toggle}
title={`${t("view3d.camera")}${t("view3d.camera.hint")}`}
aria-label={t("view3d.camera")}
aria-haspopup="true"
aria-expanded={open}
>
<ViewIcon name="camera" />
</button>
{open && !disabled && (
<div
className="tb-popover"
role="dialog"
aria-label={t("view3d.camera")}
style={{ left: pos.left, top: pos.top }}
>
<label className="tb-poprow">
<span className="tb-label">
{t("view3d.fov")}: {Math.round(fov)}°
</span>
<input
type="range"
min={20}
max={90}
step={1}
value={fov}
onChange={(e) => onFov(Number(e.target.value))}
title={t("view3d.fov.hint")}
/>
</label>
</div>
)}
</div>
);
}
/**
* Parst eine Massstab-Eingabe 1:35" ODER „35" zum Nenner N (gerundet, > 0).
* Liefert null bei ungültiger/leerer Eingabe.
*/
function parseScaleInput(input: string | null): number | null {
if (!input) return null;
// Teil nach einem ':' nehmen, falls vorhanden („1:35" → „35").
const raw = input.includes(":") ? input.split(":").pop() ?? "" : input;
const n = Math.round(Number(raw.trim()));
return Number.isFinite(n) && n > 0 ? n : null;
}
/**
* Kleines Vektor-Icon je 3D-Ansicht (16×16, currentColor). Front/Oben/Seite als
* Quadrat mit Mittellinie (keine/horizontal/vertikal), Perspektive/Isometrie als
* Würfel, Kamera als Kamera-Glyph. Tooltip nennt die Bedeutung im Klartext.
*/
function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) {
const s = {
width: 16,
height: 16,
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinejoin: "round" as const,
strokeLinecap: "round" as const,
"aria-hidden": true,
};
switch (name) {
case "grundriss":
// Grundriss (2D-Plan): Rechteck mit zwei inneren Wandstummeln + Türlücke.
return (
<svg {...s}>
<rect x="2.5" y="2.5" width="11" height="11" />
<path d="M2.5 9.5 L6 9.5 M9 9.5 L13.5 9.5 M9 9.5 L9 13.5" />
</svg>
);
case "front":
return (
<svg {...s}>
<rect x="3.5" y="3.5" width="9" height="9" />
</svg>
);
case "top":
return (
<svg {...s}>
<rect x="3.5" y="3.5" width="9" height="9" />
<line x1="3.5" y1="8" x2="12.5" y2="8" />
</svg>
);
case "side":
return (
<svg {...s}>
<rect x="3.5" y="3.5" width="9" height="9" />
<line x1="8" y1="3.5" x2="8" y2="12.5" />
</svg>
);
case "perspective":
return (
<svg {...s}>
<rect x="2.5" y="6" width="7" height="7" />
<path d="M9.5 6 L13.5 3 M9.5 13 L13.5 10 M2.5 6 L6.5 3 L13.5 3 L13.5 10" />
</svg>
);
case "iso":
return (
<svg {...s}>
<path d="M8 2 L14 5.5 L14 10.5 L8 14 L2 10.5 L2 5.5 Z M8 8 L8 14 M8 8 L14 5.5 M8 8 L2 5.5" />
</svg>
);
case "camera":
return (
<svg {...s}>
<rect x="2.5" y="5.5" width="11" height="7.5" rx="1" />
<path d="M6 5.5 L7 3.5 L9 3.5 L10 5.5" />
<circle cx="8" cy="9.2" r="2.1" />
</svg>
);
}
}
export function TopBar({
project,
viewToggleEnabled,
viewType,
onViewType,
view3d,
onView3d,
fov,
onFov,
detail,
onDetail,
detailEnabled,
scaleDenominator,
onApplyScale,
liveScale,
zoomPercent,
zoomEnabled,
onFit,
onFitSelection,
onZoom100,
renderMode,
onRenderMode,
renderModeEnabled,
planColorMode,
onPlanColorMode,
referenceLines,
onReferenceLines,
referenceLinesEnabled,
lineMode,
onLineMode,
lineModeEnabled,
combos,
resourcesOpen,
onToggleResources,
layoutMenu,
}: TopBarProps) {
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
// NUR während des Scrollens ein (Klasse `is-scrolling`, ~900 ms nachlaufend).
const headerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const el = headerRef.current;
if (!el) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const onScroll = () => {
el.classList.add("is-scrolling");
if (timer) clearTimeout(timer);
timer = setTimeout(() => el.classList.remove("is-scrolling"), 900);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
el.removeEventListener("scroll", onScroll);
if (timer) clearTimeout(timer);
};
}, []);
// „frei" wird über einen Prompt erfasst; akzeptiert „1:35" ODER „35".
const onScaleChange = (value: string) => {
if (value === "__custom__") {
const input = window.prompt(
t("topbar.scale.prompt"),
`1:${scaleDenominator}`,
);
const n = parseScaleInput(input);
if (n != null) onApplyScale(n);
return;
}
const n = Number(value);
if (Number.isFinite(n) && n > 0) onApplyScale(n);
};
// Aktueller Nenner steht evtl. nicht in den Presets (frei gesetzt) → dann
// einen Eintrag „1:N" voranstellen, damit das Select den Wert anzeigt.
const inPresets = SCALE_PRESETS.includes(scaleDenominator);
// Optionsliste des Massstab-Dropdowns: ggf. der frei gesetzte aktuelle Wert
// (damit er sichtbar bleibt), dann die Presets, zuletzt „frei …" als
// Sonder-Wert, der über onScaleChange den Prompt öffnet (Funktion unverändert).
const scaleOptions: DropdownOption[] = [
...(inPresets
? []
: [{ value: "__current__", label: `1:${scaleDenominator}` }]),
...SCALE_PRESETS.map((n) => ({ value: String(n), label: `1:${n}` })),
{ value: "__custom__", label: t("topbar.scale.custom") },
];
return (
<header className="topbar" ref={headerRef}>
{/* Marke/Logo. */}
<div className="brand">
<span className="logo"></span> cad{" "}
<span className="tag">{t("brand.tag")}</span>
</div>
<Sep />
{/* Ansichten als EIN kompaktes Icon-Grid (Vectorworks/DOSSIER-Stil): der
2D-Grundriss UND die 3D-Blicke sind eine durchgängige Icon-Serie
Grundriss" schaltet auf den 2D-Plan, die übrigen auf die jeweilige
3D-Ansicht (Perspektive wird dabei automatisch aktiviert). 4 oben /
3 unten. Tooltip nennt jede Ansicht im Klartext. */}
{viewToggleEnabled && (
<>
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
<div className="view-grid view-grid-4">
<button
className={`view-icon${viewType === "grundriss" ? " active" : ""}`}
onClick={() => onViewType("grundriss")}
title={t("topbar.grundriss")}
aria-label={t("topbar.grundriss")}
>
<ViewIcon name="grundriss" />
</button>
{(
[
["top", t("view3d.top")],
["iso", t("view3d.iso")],
["perspective", t("view3d.perspective")],
["front", t("view3d.front")],
["side", t("view3d.side")],
] as [View3d, string][]
).map(([key, label]) => (
<button
key={key}
className={`view-icon${
viewType === "perspektive" && view3d === key ? " active" : ""
}`}
onClick={() => {
onView3d(key);
if (viewType !== "perspektive") onViewType("perspektive");
}}
title={`${label}${t(`view3d.${key}.hint`)}`}
aria-label={label}
>
<ViewIcon name={key} />
</button>
))}
<CameraMenu
fov={fov}
onFov={onFov}
disabled={viewType !== "perspektive"}
/>
</div>
</div>
<Sep />
</>
)}
{/* Sichtbarkeits-Kombinationen (Vectorworks: Ebenen-/Zeichnungs-
kombinationen). Sitzt direkt nach dem Ansichts-Umschalter, weil beides
Navigations-/Sicht"-Steuerung ist ruhige Stelle vor dem Detailgrad.
Nur die Dropdowns; eine grafische Überschreibung kommt später. */}
<div className="tb-group tb-stack" role="group" aria-label={t("combo.layers.title")}>
<label className="tb-field tb-field-row">
<span className="tb-label">{t("combo.layers")}</span>
<ComboMenu
title={t("combo.layers.title")}
savePrompt={t("combo.layers.savePrompt")}
list={combos.listLayerCombos}
onSave={combos.saveLayerCombo}
onLoad={combos.loadLayerCombo}
onDelete={combos.deleteLayerCombo}
/>
</label>
<label className="tb-field tb-field-row">
<span className="tb-label">{t("combo.drawings")}</span>
<ComboMenu
title={t("combo.drawings.title")}
savePrompt={t("combo.drawings.savePrompt")}
list={combos.listDrawingCombos}
onSave={combos.saveDrawingCombo}
onLoad={combos.loadDrawingCombo}
onDelete={combos.deleteDrawingCombo}
/>
</label>
</div>
<Sep />
{/* Zeichenwerkzeuge liegen jetzt in der Werkzeug-Palette (ToolsPanel,
dockbar) nicht mehr in der Oberleiste (Vectorworks-Bild: Werkzeuge
in der Palette). Auch Wandtyp-Wahl + Fang-Menü sind dort. */}
{/* Detailgrad (grob/mittel/fein) wirkt im Grundriss auf Symbole +
Schraffuren/Linien. In der Perspektive (noch) ohne Wirkung dort
deaktiviert mit Tooltip statt stiller No-Op. */}
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
<label className="tb-field">
<span className="tb-label">{t("topbar.detail")}</span>
<Dropdown
value={detail}
disabled={!detailEnabled}
onChange={(v) => onDetail(v as DetailLevel)}
title={
detailEnabled
? t("topbar.detail.hint")
: t("topbar.detail.hintDisabled")
}
options={[
{ value: "grob", label: t("topbar.detail.grob") },
{ value: "mittel", label: t("topbar.detail.mittel") },
{ value: "fein", label: t("topbar.detail.fein") },
]}
/>
</label>
</div>
<Sep />
{/* Massstab 1:N" (echter Papier-Massstab) + Zoom-Buttons. Das Dropdown
zeigt den gewählten/angewandten Massstab; rechts steht der LIVE 1:N
aus der View-Transform (ändert sich beim Zoomen). Nur aktiv, wenn ein
Plan gezeichnet wird (Perspektive zeigt "). */}
<div className="tb-group" role="group" aria-label={t("topbar.scaleGroup")}>
<label className="tb-field">
<span className="tb-label">{t("topbar.scale")}</span>
<Dropdown
value={inPresets ? String(scaleDenominator) : "__current__"}
disabled={!zoomEnabled}
onChange={onScaleChange}
title={t("topbar.scale.apply")}
triggerClassName="tb-dd-mono"
options={scaleOptions}
/>
</label>
<span className="tb-live-scale" title={t("topbar.scale.live")}>
{zoomEnabled && liveScale != null ? `1:${Math.round(liveScale)}` : "—"}
</span>
<div className="tb-btns">
<button
className="tb-btn"
onClick={onZoom100}
disabled={!zoomEnabled}
title={t("topbar.zoom100.hint", { n: scaleDenominator })}
>
{t("topbar.zoom100")}
</button>
<button
className="tb-btn"
onClick={onFit}
disabled={!zoomEnabled}
title={t("topbar.fit.hint")}
>
{t("topbar.fit")}
</button>
<button
className="tb-btn"
onClick={onFitSelection}
disabled={!zoomEnabled}
title={t("topbar.fitSelection.hint")}
>
{t("topbar.fitSelection")}
</button>
</div>
<span className="tb-zoom" title={t("topbar.zoom.current")}>
{zoomEnabled ? `${Math.round(zoomPercent)}%` : "—"}
</span>
</div>
<Sep />
{/* Darstellungsart (Vectorworks-Stil) EIN Dropdown, dessen Optionen vom
Ansichtstyp abhängen: Grundriss Farbig/Schwarz-Weiss (planColorMode);
Perspektive Schattiert/Weiss/Drahtgitter/Kanten (renderMode). Der
Linien-Modus (Display/Print) bleibt separat. */}
<div className="tb-group" role="group" aria-label={t("display.style.group")}>
<label className="tb-field">
<span className="tb-label">{t("display.style")}</span>
{viewType === "perspektive" ? (
<Dropdown
value={renderMode}
disabled={!renderModeEnabled}
onChange={(v) => onRenderMode(v as RenderMode)}
title={t("display.style.hint")}
options={[
{ value: "shaded", label: t("display.style.shaded") },
{ value: "white", label: t("display.style.white") },
{ value: "wireframe", label: t("display.style.wireframe") },
{ value: "hidden", label: t("display.style.hidden") },
]}
/>
) : (
<Dropdown
value={planColorMode}
onChange={(v) => onPlanColorMode(v as PlanColorMode)}
title={t("display.style.hint")}
options={[
{ value: "color", label: t("display.style.color") },
{ value: "mono", label: t("display.style.mono") },
]}
/>
)}
</label>
</div>
<Sep />
{/* Referenzlinien (Wandachsen) zeigt/versteckt die gestrichelten
Achslinien im Grundriss. Nur im Grundriss sinnvoll sonst deaktiviert. */}
<div className="tb-group" role="group" aria-label={t("topbar.referenceLinesGroup")}>
<button
className={`tb-toggle${referenceLines ? " active" : ""}`}
onClick={() => onReferenceLines(!referenceLines)}
disabled={!referenceLinesEnabled}
aria-pressed={referenceLines}
title={
referenceLinesEnabled
? t("topbar.referenceLines.hint")
: t("topbar.referenceLines.hintDisabled")
}
>
{t("topbar.referenceLines")}
</button>
</div>
<Sep />
{/* Linien-Darstellung: Display (alle Haarlinien) Print (echte mm-
Strichstärken). Nur im Grundriss sinnvoll. */}
<div className="tb-group" role="group" aria-label={t("topbar.lineModeGroup")}>
<div className="view-toggle">
<button
className={`view-btn${lineMode === "display" ? " active" : ""}`}
onClick={() => onLineMode("display")}
disabled={!lineModeEnabled}
title={t("topbar.lineMode.display.hint")}
>
{t("topbar.lineMode.display")}
</button>
<button
className={`view-btn${lineMode === "print" ? " active" : ""}`}
onClick={() => onLineMode("print")}
disabled={!lineModeEnabled}
title={t("topbar.lineMode.print.hint")}
>
{t("topbar.lineMode.print")}
</button>
</div>
</div>
{/* Rechte Gruppe: Layout-Menü · Ressourcen · Projektname. */}
<div className="topbar-right">
{layoutMenu}
<button
className={`res-trigger${resourcesOpen ? " active" : ""}`}
onClick={onToggleResources}
title={t("topbar.resources.hint")}
>
{t("topbar.resources")}
</button>
<span className="project-name">{project.name}</span>
</div>
</header>
);
}
// Hinweis: Die früheren Helfer SNAP_TOGGLES + SnapMenu (Fang-Optionen) und die
// Zeichenwerkzeug-Buttons wurden aus der Oberleiste entfernt; sie leben jetzt
// in der Werkzeug-Palette (src/panels/ToolsPanel.tsx).
+65
View File
@@ -0,0 +1,65 @@
// Modusleiste der aktiven Transformation (Bewegen/Spiegeln/Drehen), im Stil von
// Vectorworks. Zeigt die Operation, die Vervielfältigungs-Modi U/I/O/P und einen
// kontextuellen Klick-Hinweis. Tasten U/I/O/P schalten die Modi (in App); ein
// Klick auf die Schaltflächen tut dasselbe. Rein gesteuert (controlled).
import { t } from "../i18n";
import type { CopyMode, TransformOp } from "../tools/transform";
const MODES: { key: string; mode: CopyMode; labelKey: string }[] = [
{ key: "U", mode: "move", labelKey: "transform.mode.move" },
{ key: "I", mode: "copy", labelKey: "transform.mode.copy" },
{ key: "O", mode: "array", labelKey: "transform.mode.array" },
{ key: "P", mode: "distribute", labelKey: "transform.mode.distribute" },
];
/** Kontextueller Hinweis je Operation + Anzahl bereits gesetzter Punkte. */
function hintKey(op: TransformOp, pointsLen: number): string {
return `transform.hint.${op}.${Math.min(pointsLen, op === "rotate" ? 2 : 1)}`;
}
export function TransformBar({
op,
copyMode,
count,
pointsLen,
onMode,
onCancel,
}: {
op: TransformOp;
copyMode: CopyMode;
count: number;
pointsLen: number;
onMode: (mode: CopyMode) => void;
onCancel: () => void;
}) {
return (
<div className="transform-bar" role="group" aria-label={t("transform.bar")}>
<span className="tf-op">{t(`transform.${op}`)}</span>
<span className="tf-sep" />
<div className="tf-modes">
{MODES.map((m) => (
<button
key={m.mode}
className={`tf-mode${copyMode === m.mode ? " active" : ""}`}
onClick={() => onMode(m.mode)}
title={t(m.labelKey)}
>
<span className="tf-key">{m.key}</span>
<span className="tf-mode-label">
{t(m.labelKey)}
{(m.mode === "array" || m.mode === "distribute") && copyMode === m.mode
? ` ×${count}`
: ""}
</span>
</button>
))}
</div>
<span className="tf-sep" />
<span className="tf-hint">{t(hintKey(op, pointsLen))}</span>
<button className="tf-cancel" onClick={onCancel} title={t("transform.cancel")}>
</button>
</div>
);
}
File diff suppressed because it is too large Load Diff