Web CAD - complete TypeScript source (React 18 frontend, Node.js backend, CRDT collaboration, KI Copilot)

This commit is contained in:
Agent Zero
2026-06-28 10:10:50 +02:00
commit 9edce1947c
129 changed files with 32721 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.git/
*.sqlite
*.db
.DS_Store
.a0/
+28
View File
@@ -0,0 +1,28 @@
# Multi-Stage: Backend + Frontend
FROM node:22-alpine AS backend-build
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm ci --only=production
COPY backend/ ./
FROM node:22-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM node:22-alpine
WORKDIR /app
RUN apk add --no-cache curl
# Backend
COPY --from=backend-build /app/backend /app/backend
WORKDIR /app/backend
# Frontend
COPY --from=frontend-build /app/frontend/dist /app/frontend/dist
# Serve Backend auf 5000
EXPOSE 5000
CMD ["node", "server.js"]
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
*.md
*.log
+9
View File
@@ -0,0 +1,9 @@
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache wget
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3001
CMD ["node", "dist/index.js"]
+3888
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "web-cad-backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"postbuild": "cp src/database/schema.sql dist/database/schema.sql",
"start": "node dist/index.js",
"test": "vitest run"
},
"dependencies": {
"@fastify/cors": "^9.0.0",
"@fastify/static": "^7.0.0",
"@fastify/websocket": "^10.0.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^11.0.0",
"fastify": "^4.28.0",
"y-leveldb": "^0.2.0",
"yjs": "^13.6.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.0",
"@types/node": "^20.14.0",
"tsx": "^4.16.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
}
}
+98
View File
@@ -0,0 +1,98 @@
/**
* AuthService Registration, Login, Session Management
*/
import bcrypt from 'bcrypt';
import { randomUUID } from 'crypto';
import type { DatabaseInterface, DBUser, DBSession } from '../database/DatabaseInterface.js';
const SALT_ROUNDS = 10;
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
export interface Session {
token: string;
userId: string;
expiresAt: number;
}
export interface AuthResult {
user: Omit<DBUser, 'password_hash'>;
session: Session;
}
export class AuthService {
constructor(private db: DatabaseInterface) {}
async register(email: string, password: string, name: string, role: string = 'planer'): Promise<AuthResult> {
const existing = this.db.getUserByEmail(email);
if (existing) {
throw new Error('Email already registered');
}
const password_hash = await bcrypt.hash(password, SALT_ROUNDS);
const user = this.db.createUser({ email, password_hash, name, role });
const session = this.createSession(user.id);
return { user: this.sanitizeUser(user), session };
}
async login(email: string, password: string): Promise<AuthResult> {
const user = this.db.getUserByEmail(email);
if (!user) {
throw new Error('Invalid credentials');
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
throw new Error('Invalid credentials');
}
const session = this.createSession(user.id);
return { user: this.sanitizeUser(user), session };
}
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
const user = this.db.getUser(userId);
if (!user) throw new Error('User not found');
const valid = await bcrypt.compare(oldPassword, user.password_hash);
if (!valid) throw new Error('Invalid current password');
const password_hash = await bcrypt.hash(newPassword, SALT_ROUNDS);
this.db.updateUser(userId, { password_hash });
}
createSession(userId: string): Session {
const token = randomUUID();
const expiresAt = Date.now() + SESSION_TTL_MS;
this.db.createSession({ token, user_id: userId, expires_at: expiresAt });
return { token, userId, expiresAt };
}
validateSession(token: string): Session | null {
const dbSession = this.db.getSession(token);
if (!dbSession) return null;
if (Date.now() > dbSession.expires_at) {
this.db.deleteSession(token);
return null;
}
return {
token: dbSession.token,
userId: dbSession.user_id,
expiresAt: dbSession.expires_at,
};
}
destroySession(token: string): void {
this.db.deleteSession(token);
}
getUserFromSession(token: string): DBUser | null {
const session = this.validateSession(token);
if (!session) return null;
return this.db.getUser(session.userId);
}
private sanitizeUser(user: DBUser): Omit<DBUser, 'password_hash'> {
const { password_hash, ...safe } = user;
return safe;
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Database Interface abstract DB layer for Web CAD
*/
export interface DBProject {
id: string;
name: string;
description: string | null;
owner_id: string;
created_at: string;
updated_at: string;
}
export interface DBDrawing {
id: string;
project_id: string;
name: string;
data_json: string;
created_at: string;
updated_at: string;
}
export interface DBLayer {
id: string;
drawing_id: string;
name: string;
visible: number;
locked: number;
color: string;
line_type: string;
transparency: number;
sort_order: number;
parent_id: string | null;
}
export interface DBElement {
id: string;
drawing_id: string;
layer_id: string;
type: string;
x: number;
y: number;
width: number;
height: number;
properties_json: string;
created_at: string;
}
export interface DBBlock {
id: string;
drawing_id: string;
name: string;
description: string | null;
category: string;
elements_json: string;
thumbnail: string | null;
}
export interface DBSetting {
key: string;
value: string;
updated_at: string;
}
export interface DBUser {
id: string;
email: string;
password_hash: string;
name: string;
role: string;
created_at: string;
updated_at: string;
}
export interface DBSession {
token: string;
user_id: string;
expires_at: number;
created_at: string;
}
export interface DatabaseInterface {
init(): Promise<void>;
close(): void;
// Users
getUser(id: string): DBUser | null;
getUserByEmail(email: string): DBUser | null;
createUser(data: Partial<DBUser>): DBUser;
updateUser(id: string, data: Partial<DBUser>): DBUser | null;
deleteUser(id: string): boolean;
listUsers(): DBUser[];
// Projects
listProjects(): DBProject[];
getProject(id: string): DBProject | null;
createProject(data: Partial<DBProject>): DBProject;
updateProject(id: string, data: Partial<DBProject>): DBProject | null;
deleteProject(id: string): boolean;
// Drawings
listDrawings(projectId: string): DBDrawing[];
getDrawing(id: string): DBDrawing | null;
createDrawing(data: Partial<DBDrawing>): DBDrawing;
updateDrawing(id: string, data: Partial<DBDrawing>): DBDrawing | null;
deleteDrawing(id: string): boolean;
// Layers
listLayers(drawingId: string): DBLayer[];
createLayer(data: Partial<DBLayer>): DBLayer;
updateLayer(id: string, data: Partial<DBLayer>): DBLayer | null;
deleteLayer(id: string): boolean;
// Elements
listElements(drawingId: string): DBElement[];
createElement(data: Partial<DBElement>): DBElement;
updateElement(id: string, data: Partial<DBElement>): DBElement | null;
deleteElement(id: string): boolean;
// Blocks
listBlocks(drawingId: string): DBBlock[];
createBlock(data: Partial<DBBlock>): DBBlock;
updateBlock(id: string, data: Partial<DBBlock>): DBBlock | null;
deleteBlock(id: string): boolean;
// Settings
getSetting(key: string): DBSetting | null;
setSetting(key: string, value: string): void;
// Sessions
createSession(data: Partial<DBSession>): DBSession;
getSession(token: string): DBSession | null;
deleteSession(token: string): boolean;
deleteExpiredSessions(): void;
}
+268
View File
@@ -0,0 +1,268 @@
/**
* SQLite Adapter implements DatabaseInterface with better-sqlite3
*/
import Database from 'better-sqlite3';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { randomUUID } from 'crypto';
import type {
DatabaseInterface, DBProject, DBDrawing, DBLayer,
DBElement, DBBlock, DBSetting, DBUser, DBSession,
} from './DatabaseInterface.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
export class SqliteAdapter implements DatabaseInterface {
private db: Database.Database;
constructor(dbPath: string = ':memory:') {
this.db = new Database(dbPath);
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
}
async init(): Promise<void> {
const schemaPath = join(__dirname, 'schema.sql');
const schema = readFileSync(schemaPath, 'utf-8');
this.db.exec(schema);
}
close(): void {
this.db.close();
}
// ─── Users ──────────────────────────────────────────
getUser(id: string): DBUser | null {
return (this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DBUser) ?? null;
}
getUserByEmail(email: string): DBUser | null {
return (this.db.prepare('SELECT * FROM users WHERE email = ?').get(email) as DBUser) ?? null;
}
createUser(data: Partial<DBUser>): DBUser {
const id = data.id ?? `user-${Date.now()}`;
this.db.prepare(
'INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)',
).run(id, data.email!, data.password_hash!, data.name!, data.role ?? 'planer');
return this.getUser(id)!;
}
updateUser(id: string, data: Partial<DBUser>): DBUser | null {
const sets: string[] = [];
const vals: any[] = [];
if (data.email !== undefined) { sets.push('email = ?'); vals.push(data.email); }
if (data.password_hash !== undefined) { sets.push('password_hash = ?'); vals.push(data.password_hash); }
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
if (data.role !== undefined) { sets.push('role = ?'); vals.push(data.role); }
sets.push("updated_at = datetime('now')");
if (sets.length > 1) {
this.db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.getUser(id);
}
deleteUser(id: string): boolean {
return this.db.prepare('DELETE FROM users WHERE id = ?').run(id).changes > 0;
}
listUsers(): DBUser[] {
return this.db.prepare('SELECT * FROM users ORDER BY created_at DESC').all() as DBUser[];
}
// ─── Projects ──────────────────────────────────────
listProjects(): DBProject[] {
return this.db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all() as DBProject[];
}
getProject(id: string): DBProject | null {
return (this.db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as DBProject) ?? null;
}
createProject(data: Partial<DBProject>): DBProject {
const id = data.id ?? `proj-${Date.now()}`;
this.db.prepare(
'INSERT INTO projects (id, name, description, owner_id) VALUES (?, ?, ?, ?)',
).run(id, data.name ?? 'Unbenannt', data.description ?? null, data.owner_id ?? 'user-default');
return this.getProject(id)!;
}
updateProject(id: string, data: Partial<DBProject>): DBProject | null {
const sets: string[] = [];
const vals: any[] = [];
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
if (data.description !== undefined) { sets.push('description = ?'); vals.push(data.description); }
sets.push("updated_at = datetime('now')");
if (sets.length > 1) {
this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.getProject(id);
}
deleteProject(id: string): boolean {
return this.db.prepare('DELETE FROM projects WHERE id = ?').run(id).changes > 0;
}
// ─── Drawings ──────────────────────────────────────
listDrawings(projectId: string): DBDrawing[] {
return this.db.prepare('SELECT * FROM drawings WHERE project_id = ? ORDER BY updated_at DESC').all(projectId) as DBDrawing[];
}
getDrawing(id: string): DBDrawing | null {
return (this.db.prepare('SELECT * FROM drawings WHERE id = ?').get(id) as DBDrawing) ?? null;
}
createDrawing(data: Partial<DBDrawing>): DBDrawing {
const id = data.id ?? `draw-${Date.now()}`;
this.db.prepare(
'INSERT INTO drawings (id, project_id, name, data_json) VALUES (?, ?, ?, ?)',
).run(id, data.project_id!, data.name ?? 'Unbenannt', data.data_json ?? '{}');
return this.getDrawing(id)!;
}
updateDrawing(id: string, data: Partial<DBDrawing>): DBDrawing | null {
const sets: string[] = [];
const vals: any[] = [];
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
if (data.data_json !== undefined) { sets.push('data_json = ?'); vals.push(data.data_json); }
sets.push("updated_at = datetime('now')");
if (sets.length > 1) {
this.db.prepare(`UPDATE drawings SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.getDrawing(id);
}
deleteDrawing(id: string): boolean {
return this.db.prepare('DELETE FROM drawings WHERE id = ?').run(id).changes > 0;
}
// ─── Layers ────────────────────────────────────────
listLayers(drawingId: string): DBLayer[] {
return this.db.prepare('SELECT * FROM layers WHERE drawing_id = ? ORDER BY sort_order').all(drawingId) as DBLayer[];
}
createLayer(data: Partial<DBLayer>): DBLayer {
const id = data.id ?? `layer-${Date.now()}`;
this.db.prepare(
'INSERT INTO layers (id, drawing_id, name, visible, locked, color, line_type, transparency, sort_order, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
).run(id, data.drawing_id!, data.name ?? 'Layer', data.visible ?? 1, data.locked ?? 0,
data.color ?? '#ffffff', data.line_type ?? 'solid', data.transparency ?? 0,
data.sort_order ?? 0, data.parent_id ?? null);
return this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer;
}
updateLayer(id: string, data: Partial<DBLayer>): DBLayer | null {
const sets: string[] = [];
const vals: any[] = [];
for (const [k, v] of Object.entries(data)) {
if (['name','visible','locked','color','line_type','transparency','sort_order','parent_id'].includes(k)) {
sets.push(`${k} = ?`); vals.push(v);
}
}
if (sets.length > 0) {
this.db.prepare(`UPDATE layers SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer ?? null;
}
deleteLayer(id: string): boolean {
return this.db.prepare('DELETE FROM layers WHERE id = ?').run(id).changes > 0;
}
// ─── Elements ──────────────────────────────────────
listElements(drawingId: string): DBElement[] {
return this.db.prepare('SELECT * FROM elements WHERE drawing_id = ?').all(drawingId) as DBElement[];
}
createElement(data: Partial<DBElement>): DBElement {
const id = data.id ?? `elem-${Date.now()}`;
this.db.prepare(
'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
).run(id, data.drawing_id!, data.layer_id!, data.type!, data.x ?? 0, data.y ?? 0,
data.width ?? 0, data.height ?? 0, data.properties_json ?? '{}');
return this.db.prepare('SELECT * FROM elements WHERE id = ?').get(id) as DBElement;
}
updateElement(id: string, data: Partial<DBElement>): DBElement | null {
const sets: string[] = [];
const vals: any[] = [];
for (const [k, v] of Object.entries(data)) {
if (['layer_id','type','x','y','width','height','properties_json'].includes(k)) {
sets.push(`${k} = ?`); vals.push(v);
}
}
if (sets.length > 0) {
this.db.prepare(`UPDATE elements SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.db.prepare('SELECT * FROM elements WHERE id = ?').get(id) as DBElement ?? null;
}
deleteElement(id: string): boolean {
return this.db.prepare('DELETE FROM elements WHERE id = ?').run(id).changes > 0;
}
// ─── Blocks ────────────────────────────────────────
listBlocks(drawingId: string): DBBlock[] {
return this.db.prepare('SELECT * FROM blocks WHERE drawing_id = ?').all(drawingId) as DBBlock[];
}
createBlock(data: Partial<DBBlock>): DBBlock {
const id = data.id ?? `block-${Date.now()}`;
this.db.prepare(
'INSERT INTO blocks (id, drawing_id, name, description, category, elements_json, thumbnail) VALUES (?, ?, ?, ?, ?, ?, ?)',
).run(id, data.drawing_id!, data.name ?? 'Block', data.description ?? null,
data.category ?? 'Allgemein', data.elements_json ?? '[]', data.thumbnail ?? null);
return this.db.prepare('SELECT * FROM blocks WHERE id = ?').get(id) as DBBlock;
}
updateBlock(id: string, data: Partial<DBBlock>): DBBlock | null {
const sets: string[] = [];
const vals: any[] = [];
for (const [k, v] of Object.entries(data)) {
if (['name','description','category','elements_json','thumbnail'].includes(k)) {
sets.push(`${k} = ?`); vals.push(v);
}
}
if (sets.length > 0) {
this.db.prepare(`UPDATE blocks SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
return this.db.prepare('SELECT * FROM blocks WHERE id = ?').get(id) as DBBlock ?? null;
}
deleteBlock(id: string): boolean {
return this.db.prepare('DELETE FROM blocks WHERE id = ?').run(id).changes > 0;
}
// ─── Settings ──────────────────────────────────────
getSetting(key: string): DBSetting | null {
return (this.db.prepare('SELECT * FROM settings WHERE key = ?').get(key) as DBSetting) ?? null;
}
setSetting(key: string, value: string): void {
this.db.prepare(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')",
).run(key, value);
}
// ─── Sessions ───────────────────────────────────────
createSession(data: Partial<DBSession>): DBSession {
const token = data.token ?? randomUUID();
this.db.prepare(
'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)',
).run(token, data.user_id!, data.expires_at!);
return this.getSession(token)!;
}
getSession(token: string): DBSession | null {
return (this.db.prepare('SELECT * FROM sessions WHERE token = ?').get(token) as DBSession) ?? null;
}
deleteSession(token: string): boolean {
return this.db.prepare('DELETE FROM sessions WHERE token = ?').run(token).changes > 0;
}
deleteExpiredSessions(): void {
this.db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now());
}
}
+107
View File
@@ -0,0 +1,107 @@
-- Web CAD Backend SQLite Schema
-- Version 1.0
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- ─── Users ──────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'planer' CHECK(role IN ('admin','planer','betrachter','gast')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── Default User (seed) ────────────────────────────
INSERT OR IGNORE INTO users (id, email, password_hash, name, role) VALUES ('user-default', 'default@webcad.local', '', 'Default User', 'admin');
-- ─── Projects ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
owner_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
);
-- ─── Drawings ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS drawings (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
name TEXT NOT NULL,
data_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
-- ─── Layers ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS layers (
id TEXT PRIMARY KEY,
drawing_id TEXT NOT NULL,
name TEXT NOT NULL,
visible INTEGER NOT NULL DEFAULT 1,
locked INTEGER NOT NULL DEFAULT 0,
color TEXT NOT NULL DEFAULT '#ffffff',
line_type TEXT NOT NULL DEFAULT 'solid',
transparency REAL NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
parent_id TEXT,
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE
);
-- ─── Elements ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS elements (
id TEXT PRIMARY KEY,
drawing_id TEXT NOT NULL,
layer_id TEXT NOT NULL,
type TEXT NOT NULL,
x REAL NOT NULL,
y REAL NOT NULL,
width REAL NOT NULL,
height REAL NOT NULL,
properties_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE,
FOREIGN KEY (layer_id) REFERENCES layers(id) ON DELETE CASCADE
);
-- ─── Block Definitions ──────────────────────────────
CREATE TABLE IF NOT EXISTS blocks (
id TEXT PRIMARY KEY,
drawing_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL DEFAULT 'Allgemein',
elements_json TEXT NOT NULL DEFAULT '[]',
thumbnail TEXT,
FOREIGN KEY (drawing_id) REFERENCES drawings(id) ON DELETE CASCADE
);
-- ─── Sessions ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- ─── Settings ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── Indexes ────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_drawings_project ON drawings(project_id);
CREATE INDEX IF NOT EXISTS idx_layers_drawing ON layers(drawing_id);
CREATE INDEX IF NOT EXISTS idx_elements_drawing ON elements(drawing_id);
CREATE INDEX IF NOT EXISTS idx_elements_layer ON elements(layer_id);
CREATE INDEX IF NOT EXISTS idx_blocks_drawing ON blocks(drawing_id);
+40
View File
@@ -0,0 +1,40 @@
/**
* Web CAD Backend Entry Point
*/
import { createServer } from './server.js';
import { SqliteAdapter } from './database/SqliteAdapter.js';
import { initYjsPersistence, closeYjsPersistence } from './websocket/yjsServer.js';
const PORT = parseInt(process.env.PORT || '3001', 10);
const HOST = process.env.HOST || '0.0.0.0';
const DB_PATH = process.env.DB_PATH || '/data/web-cad.db';
async function main() {
const db = new SqliteAdapter(DB_PATH);
await db.init();
await initYjsPersistence();
const fastify = await createServer({ port: PORT, host: HOST, db });
try {
await fastify.listen({ port: PORT, host: HOST });
console.log(`Web CAD Backend running on http://${HOST}:${PORT}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
// Graceful shutdown
process.on('SIGTERM', async () => {
await fastify.close();
db.close();
process.exit(0);
});
process.on('SIGINT', async () => {
await fastify.close();
db.close();
process.exit(0);
});
}
main();
+132
View File
@@ -0,0 +1,132 @@
/**
* AI Routes KI Copilot chat endpoint via OpenRouter
*/
import type { FastifyInstance } from 'fastify';
import type { AuthService } from '../auth/AuthService.js';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
const MODEL = 'anthropic/claude-sonnet-4.5';
const TIMEOUT_MS = 30_000;
function extractToken(request: any): string | null {
const auth = request.headers?.authorization;
if (auth && auth.startsWith('Bearer ')) {
return auth.slice(7);
}
return null;
}
export function registerAIRoutes(fastify: FastifyInstance, authService: AuthService) {
fastify.post('/api/ai/chat', async (request, reply) => {
// Auth check
const token = extractToken(request);
if (!token) {
return reply.code(401).send({ error: 'Authentication required' });
}
const user = authService.getUserFromSession(token);
if (!user) {
return reply.code(401).send({ error: 'Invalid or expired session' });
}
const { messages, context } = request.body as {
messages?: Array<{ role: string; content: string }>;
context?: {
projectName?: string;
elementCount?: number;
layerCount?: number;
elementTypeSummary?: Record<string, number>;
};
};
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return reply.code(400).send({ error: 'Messages array is required' });
}
const apiKey = process.env.API_KEY_OPENROUTER;
if (!apiKey) {
return reply.code(503).send({ error: 'AI service not configured' });
}
// Build system prompt with CAD context
const ctxParts: string[] = [
'Du bist der KI Copilot für Web CAD, eine webbasierte CAD-Anwendung für Event- und Raumplanung.',
'Du hilfst beim Anlegen von Bestuhlung, Räumen, Bühnen und anderen CAD-Elementen.',
'Antworte auf Deutsch, klar und präzise. Verwende Markdown für Formatierung wenn sinnvoll.',
];
if (context) {
if (context.projectName) ctxParts.push(`Aktuelles Projekt: ${context.projectName}`);
if (context.elementCount !== undefined) ctxParts.push(`Elemente im Projekt: ${context.elementCount}`);
if (context.layerCount !== undefined) ctxParts.push(`Ebenen: ${context.layerCount}`);
if (context.elementTypeSummary) {
const summary = Object.entries(context.elementTypeSummary)
.map(([type, count]) => `${type}: ${count}`)
.join(', ');
ctxParts.push(`Element-Typen: ${summary}`);
}
}
const systemPrompt = ctxParts.join('\n');
// Build OpenRouter request
const payload = {
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
...messages.map((m) => ({ role: m.role, content: m.content })),
],
max_tokens: 1024,
temperature: 0.7,
};
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
const response = await fetch(OPENROUTER_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'HTTP-Referer': 'http://localhost:5173',
'X-Title': 'Web CAD KI Copilot',
},
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
const errText = await response.text();
request.log.error({ errText, status: response.status }, 'OpenRouter error');
return reply.code(502).send({ error: 'AI service returned an error' });
}
const data = await response.json() as any;
const content = data.choices?.[0]?.message?.content ?? '';
if (!content) {
return reply.code(502).send({ error: 'AI service returned empty response' });
}
// Extract suggestions from response if present (simple heuristic)
const suggestions: string[] = [];
const lines = content.split('\n');
for (const line of lines) {
const match = line.match(/^[-*]\s*(.+)/);
if (match && suggestions.length < 3) {
suggestions.push(match[1].trim());
}
}
return reply.send({ content, suggestions: suggestions.length > 0 ? suggestions : undefined });
} catch (err: any) {
if (err.name === 'AbortError') {
return reply.code(504).send({ error: 'AI service timeout' });
}
request.log.error({ err }, 'AI route error');
return reply.code(500).send({ error: 'Internal server error' });
}
});
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Auth Routes Registration, Login, Logout, Profile
*/
import type { FastifyInstance } from 'fastify';
import type { AuthService } from '../auth/AuthService.js';
export function registerAuthRoutes(fastify: FastifyInstance, authService: AuthService) {
// Register new user
fastify.post('/api/auth/register', async (request, reply) => {
const { email, password, name, role } = request.body as {
email?: string; password?: string; name?: string; role?: string;
};
if (!email || !password || !name) {
return reply.code(400).send({ error: 'Email, password and name are required' });
}
try {
const result = await authService.register(email, password, name, role);
return reply.code(201).send(result);
} catch (err: any) { return reply.code(409).send({ error: err.message });
}
});
// Login
fastify.post('/api/auth/login', async (request, reply) => {
const { email, password } = request.body as { email?: string; password?: string };
if (!email || !password) {
return reply.code(400).send({ error: 'Email and password are required' });
}
try {
const result = await authService.login(email, password);
return reply.send(result);
} catch {
return reply.code(401).send({ error: 'Invalid credentials' });
}
});
// Logout
fastify.post('/api/auth/logout', async (request, reply) => {
const token = extractToken(request);
if (token) {
authService.destroySession(token);
}
return reply.code(204).send();
});
// Get current user profile
fastify.get('/api/auth/me', async (request, reply) => {
const token = extractToken(request);
if (!token) return reply.code(401).send({ error: 'Not authenticated' });
const user = authService.getUserFromSession(token);
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
const { password_hash, ...safe } = user;
return safe;
});
// Change password
fastify.patch('/api/auth/password', async (request, reply) => {
const token = extractToken(request);
if (!token) return reply.code(401).send({ error: 'Not authenticated' });
const user = authService.getUserFromSession(token);
if (!user) return reply.code(401).send({ error: 'Invalid or expired session' });
const { oldPassword, newPassword } = request.body as { oldPassword?: string; newPassword?: string };
if (!oldPassword || !newPassword) {
return reply.code(400).send({ error: 'oldPassword and newPassword are required' });
}
try {
await authService.changePassword(user.id, oldPassword, newPassword);
return reply.code(204).send();
} catch (err: any) {
return reply.code(400).send({ error: err.message });
}
});
}
function extractToken(request: any): string | null {
const auth = request.headers?.authorization;
if (auth && auth.startsWith('Bearer ')) {
return auth.slice(7);
}
return null;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Blocks Routes CRUD for reusable drawing blocks
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBBlock } from '../database/DatabaseInterface.js';
export function registerBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
// List all blocks for a drawing
fastify.get('/api/drawings/:drawingId/blocks', async (request) => {
const { drawingId } = request.params as { drawingId: string };
return db.listBlocks(drawingId);
});
// Create a new block
fastify.post('/api/drawings/:drawingId/blocks', async (request, reply) => {
const { drawingId } = request.params as { drawingId: string };
const body = request.body as Partial<DBBlock>;
if (!body.name) return reply.code(400).send({ error: 'Name is required' });
return reply.code(201).send(db.createBlock({ ...body, drawing_id: drawingId }));
});
// Update a block
fastify.patch('/api/blocks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const body = request.body as Partial<DBBlock>;
const updated = db.updateBlock(id, body);
if (!updated) return reply.code(404).send({ error: 'Block not found' });
return updated;
});
// Delete a block
fastify.delete('/api/blocks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = db.deleteBlock(id);
if (!ok) return reply.code(404).send({ error: 'Block not found' });
return reply.code(204).send();
});
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Drawings Routes CRUD for drawings
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBDrawing } from '../database/DatabaseInterface.js';
export function registerDrawingRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
// List drawings for a project
fastify.get('/api/projects/:projectId/drawings', async (request) => {
const { projectId } = request.params as { projectId: string };
return db.listDrawings(projectId);
});
// Get single drawing
fastify.get('/api/drawings/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const drawing = db.getDrawing(id);
if (!drawing) return reply.code(404).send({ error: 'Drawing not found' });
return drawing;
});
// Create drawing
fastify.post('/api/projects/:projectId/drawings', async (request, reply) => {
const { projectId } = request.params as { projectId: string };
const body = request.body as Partial<DBDrawing>;
return reply.code(201).send(db.createDrawing({ ...body, project_id: projectId }));
});
// Update drawing
fastify.patch('/api/drawings/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const body = request.body as Partial<DBDrawing>;
const updated = db.updateDrawing(id, body);
if (!updated) return reply.code(404).send({ error: 'Drawing not found' });
return updated;
});
// Delete drawing
fastify.delete('/api/drawings/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = db.deleteDrawing(id);
if (!ok) return reply.code(404).send({ error: 'Drawing not found' });
return reply.code(204).send();
});
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Elements Routes CRUD for elements
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBElement } from '../database/DatabaseInterface.js';
export function registerElementRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
// List elements for a drawing
fastify.get('/api/drawings/:drawingId/elements', async (request) => {
const { drawingId } = request.params as { drawingId: string };
return db.listElements(drawingId);
});
// Create element
fastify.post('/api/drawings/:drawingId/elements', async (request, reply) => {
const { drawingId } = request.params as { drawingId: string };
const body = request.body as Partial<DBElement>;
return reply.code(201).send(db.createElement({ ...body, drawing_id: drawingId }));
});
// Update element
fastify.patch('/api/elements/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const body = request.body as Partial<DBElement>;
const updated = db.updateElement(id, body);
if (!updated) return reply.code(404).send({ error: 'Element not found' });
return updated;
});
// Delete element
fastify.delete('/api/elements/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = db.deleteElement(id);
if (!ok) return reply.code(404).send({ error: 'Element not found' });
return reply.code(204).send();
});
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Layers Routes CRUD for layers
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBLayer } from '../database/DatabaseInterface.js';
export function registerLayerRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
// List layers for a drawing
fastify.get('/api/drawings/:drawingId/layers', async (request) => {
const { drawingId } = request.params as { drawingId: string };
return db.listLayers(drawingId);
});
// Create layer
fastify.post('/api/drawings/:drawingId/layers', async (request, reply) => {
const { drawingId } = request.params as { drawingId: string };
const body = request.body as Partial<DBLayer>;
return reply.code(201).send(db.createLayer({ ...body, drawing_id: drawingId }));
});
// Update layer
fastify.patch('/api/layers/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const body = request.body as Partial<DBLayer>;
const updated = db.updateLayer(id, body);
if (!updated) return reply.code(404).send({ error: 'Layer not found' });
return updated;
});
// Delete layer
fastify.delete('/api/layers/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = db.deleteLayer(id);
if (!ok) return reply.code(404).send({ error: 'Layer not found' });
return reply.code(204).send();
});
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Projects Routes CRUD for projects
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBProject } from '../database/DatabaseInterface.js';
export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
// List all projects
fastify.get('/api/projects', async () => {
return db.listProjects();
});
// Get single project
fastify.get('/api/projects/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const project = db.getProject(id);
if (!project) return reply.code(404).send({ error: 'Project not found' });
return project;
});
// Create project
fastify.post('/api/projects', async (request, reply) => {
const body = request.body as Partial<DBProject>;
if (!body.name) return reply.code(400).send({ error: 'Name is required' });
return reply.code(201).send(db.createProject(body));
});
// Update project
fastify.patch('/api/projects/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const body = request.body as Partial<DBProject>;
const updated = db.updateProject(id, body);
if (!updated) return reply.code(404).send({ error: 'Project not found' });
return updated;
});
// Delete project
fastify.delete('/api/projects/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = db.deleteProject(id);
if (!ok) return reply.code(404).send({ error: 'Project not found' });
return reply.code(204).send();
});
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Settings Routes key/value application settings
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface } from '../database/DatabaseInterface.js';
export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInterface) {
// Get a single setting by key
fastify.get('/api/settings/:key', async (request, reply) => {
const { key } = request.params as { key: string };
const setting = db.getSetting(key);
if (!setting) return reply.code(404).send({ error: 'Setting not found' });
return setting;
});
// Create or update a setting (upsert)
fastify.put('/api/settings/:key', async (request) => {
const { key } = request.params as { key: string };
const body = request.body as { value?: string };
if (body.value === undefined) {
return { error: 'Value is required' };
}
db.setSetting(key, body.value);
return { key, value: body.value, updated_at: new Date().toISOString() };
});
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Users Routes Admin user management
*/
import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface, DBUser } from '../database/DatabaseInterface.js';
import type { AuthService } from '../auth/AuthService.js';
export function registerUserRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
// List all users (admin only — TODO: add role check middleware)
fastify.get('/api/users', async () => {
const users = db.listUsers();
return users.map(({ password_hash, ...safe }) => safe);
});
// Get single user
fastify.get('/api/users/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const user = db.getUser(id);
if (!user) return reply.code(404).send({ error: 'User not found' });
const { password_hash, ...safe } = user;
return safe;
});
// Update user (name, role, email)
fastify.patch('/api/users/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const body = request.body as Partial<DBUser>;
// Prevent password update via this endpoint
delete body.password_hash;
const updated = db.updateUser(id, body);
if (!updated) return reply.code(404).send({ error: 'User not found' });
const { password_hash, ...safe } = updated;
return safe;
});
// Delete user
fastify.delete('/api/users/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = db.deleteUser(id);
if (!ok) return reply.code(404).send({ error: 'User not found' });
return reply.code(204).send();
});
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Server Fastify configuration with CORS, static, and WebSocket
*/
import Fastify from 'fastify';
import cors from '@fastify/cors';
import staticPlugin from '@fastify/static';
import websocket from '@fastify/websocket';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import type { DatabaseInterface } from './database/DatabaseInterface.js';
import { AuthService } from './auth/AuthService.js';
import { registerYjsWebSocket, initYjsPersistence, closeYjsPersistence } from './websocket/yjsServer.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
export interface ServerOptions {
port?: number;
host?: string;
db: DatabaseInterface;
}
export async function createServer(opts: ServerOptions) {
const fastify = Fastify({ logger: true });
// CORS
await fastify.register(cors, {
origin: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
});
// WebSocket
await fastify.register(websocket, {
options: { maxPayload: 1048576 },
});
// Static files (serve frontend build if exists)
const frontendDist = join(__dirname, '..', '..', 'frontend', 'dist');
try {
await fastify.register(staticPlugin, {
root: frontendDist,
prefix: '/',
});
} catch {
// Frontend dist may not exist in dev mode
}
// Health check
fastify.get('/api/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }));
// Register route modules
const { registerProjectRoutes } = await import('./routes/projects.js');
const { registerDrawingRoutes } = await import('./routes/drawings.js');
const { registerLayerRoutes } = await import('./routes/layers.js');
const { registerElementRoutes } = await import('./routes/elements.js');
const { registerBlockRoutes } = await import('./routes/blocks.js');
const { registerSettingsRoutes } = await import('./routes/settings.js');
const { registerAuthRoutes } = await import('./routes/auth.js');
const { registerUserRoutes } = await import('./routes/users.js');
const { registerAIRoutes } = await import('./routes/ai.js');
const authService = new AuthService(opts.db);
registerAuthRoutes(fastify, authService);
registerProjectRoutes(fastify, opts.db);
registerDrawingRoutes(fastify, opts.db);
registerLayerRoutes(fastify, opts.db);
registerElementRoutes(fastify, opts.db);
registerBlockRoutes(fastify, opts.db);
registerSettingsRoutes(fastify, opts.db);
registerUserRoutes(fastify, opts.db, authService);
registerAIRoutes(fastify, authService);
// Yjs collaboration WebSocket
registerYjsWebSocket(fastify);
return fastify;
}
+11
View File
@@ -0,0 +1,11 @@
declare module 'y-leveldb' {
import type * as Y from 'yjs';
export class LeveldbPersistence {
constructor(dbPath: string, opts?: Record<string, unknown>);
getYDoc(name: string): Promise<Y.Doc>;
storeUpdate(name: string, update: Uint8Array): Promise<number>;
destroy(): Promise<void>;
clearDocument(name: string): Promise<void>;
}
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Yjs WebSocket Server Real-time collaboration with LevelDB persistence
*/
import * as Y from 'yjs';
import { LeveldbPersistence } from 'y-leveldb';
import type { FastifyInstance } from 'fastify';
import type { WebSocket } from '@fastify/websocket';
const PERSISTENCE_DIR = process.env.YJS_PERSISTENCE_DIR || '/tmp/yjs-documents';
// Document store: docName → Y.Doc
const docs = new Map<string, Y.Doc>();
// Connection tracking: docName → Set<WebSocket>
const connections = new Map<string, Set<WebSocket>>();
let persistence: LeveldbPersistence | null = null;
let persistenceReady: Promise<void> | null = null;
export async function initYjsPersistence(): Promise<void> {
try {
persistence = new LeveldbPersistence(PERSISTENCE_DIR);
console.log(`Yjs persistence initialized at ${PERSISTENCE_DIR}`);
} catch (err) {
console.warn('Yjs persistence failed to init (non-fatal):', err);
}
}
export async function closeYjsPersistence(): Promise<void> {
if (persistence) {
await persistence.destroy();
persistence = null;
}
}
async function getOrCreateDoc(docName: string): Promise<Y.Doc> {
if (docs.has(docName)) {
return docs.get(docName)!;
}
const doc = new Y.Doc();
docs.set(docName, doc);
// Load persisted state if available
if (persistence && persistenceReady) {
try {
await persistenceReady;
const persistedYdoc = await persistence.getYDoc(docName);
const update = Y.encodeStateAsUpdate(persistedYdoc);
if (update.length > 0) {
Y.applyUpdate(doc, update);
}
} catch (err) {
console.warn(`Failed to load doc ${docName}:`, err);
}
}
// Persist updates to LevelDB
doc.on('update', (update: Uint8Array) => {
if (persistence) {
persistence.storeUpdate(docName, update).catch((err: any) => {
console.warn(`Failed to persist update for ${docName}:`, err);
});
}
});
return doc;
}
function broadcastUpdate(docName: string, update: Uint8Array, exclude?: WebSocket): void {
const conns = connections.get(docName);
if (!conns) return;
for (const ws of conns) {
if (ws !== exclude && ws.readyState === 1 /* OPEN */) {
try {
ws.send(update);
} catch {
// Connection error — will be cleaned up on close
}
}
}
}
export function registerYjsWebSocket(fastify: FastifyInstance): void {
fastify.get('/ws/collab/:docName', { websocket: true }, async (socket: WebSocket, request: any) => {
const docName = request.params.docName as string;
if (!docName) {
socket.close(4000, 'Missing docName');
return;
}
const doc = await getOrCreateDoc(docName);
// Track connection
if (!connections.has(docName)) {
connections.set(docName, new Set());
}
connections.get(docName)!.add(socket);
// Send current document state to new client
const stateUpdate = Y.encodeStateAsUpdate(doc);
if (stateUpdate.length > 0 && socket.readyState === 1) {
socket.send(stateUpdate);
}
// Listen for updates from this client
socket.on('message', (data: Buffer) => {
try {
const update = new Uint8Array(data);
Y.applyUpdate(doc, update);
broadcastUpdate(docName, update, socket);
} catch (err) {
console.warn(`Invalid update from client for ${docName}:`, err);
}
});
// Clean up on disconnect
socket.on('close', () => {
const conns = connections.get(docName);
if (conns) {
conns.delete(socket);
if (conns.size === 0) {
connections.delete(docName);
// Optionally keep doc in memory for a while
// For now, remove it to free memory
docs.delete(docName);
}
}
});
socket.on('error', () => {
const conns = connections.get(docName);
if (conns) {
conns.delete(socket);
}
});
});
}
+188
View File
@@ -0,0 +1,188 @@
/**
* AuthService Unit Tests Register / Login / ChangePassword / Session lifecycle
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { AuthService } from '../src/auth/AuthService.js';
describe('AuthService', () => {
let db: SqliteAdapter;
let auth: AuthService;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
auth = new AuthService(db);
});
afterAll(() => {
db.close();
});
// ─── Register ───────────────────────────────────────
describe('register()', () => {
it('should register a new user successfully', async () => {
const result = await auth.register('register@example.com', 'Password123!', 'Register User', 'planer');
expect(result.user).toBeDefined();
expect(result.user.email).toBe('register@example.com');
expect(result.user.name).toBe('Register User');
expect(result.user.password_hash).toBeUndefined();
expect(result.session).toBeDefined();
expect(result.session.token).toBeDefined();
expect(result.session.userId).toBe(result.user.id);
});
it('should throw on duplicate email', async () => {
await expect(
auth.register('register@example.com', 'AnotherPass!', 'Another User', 'planer'),
).rejects.toThrow('Email already registered');
});
it('should default role to planer when not specified', async () => {
const result = await auth.register('default-role@example.com', 'Pass123!', 'Default Role User');
expect(result.user.role).toBe('planer');
});
});
// ─── Login ─────────────────────────────────────────
describe('login()', () => {
it('should login with correct credentials', async () => {
const result = await auth.login('register@example.com', 'Password123!');
expect(result.user).toBeDefined();
expect(result.user.email).toBe('register@example.com');
expect(result.session.token).toBeDefined();
expect(result.session.userId).toBe(result.user.id);
});
it('should throw on wrong password', async () => {
await expect(
auth.login('register@example.com', 'WrongPassword!'),
).rejects.toThrow('Invalid credentials');
});
it('should throw on non-existent email', async () => {
await expect(
auth.login('nobody@example.com', 'SomePassword!'),
).rejects.toThrow('Invalid credentials');
});
});
// ─── Change Password ───────────────────────────────
describe('changePassword()', () => {
it('should change password with correct old password', async () => {
const result = await auth.register('changepw@example.com', 'OldPass123!', 'ChangePW User', 'admin');
const userId = result.user.id;
await auth.changePassword(userId, 'OldPass123!', 'NewPass456!');
// Should now be able to login with new password
const loginResult = await auth.login('changepw@example.com', 'NewPass456!');
expect(loginResult.user.id).toBe(userId);
});
it('should throw on wrong old password', async () => {
const result = await auth.register('changepw2@example.com', 'OriginalPass!', 'User2', 'planer');
await expect(
auth.changePassword(result.user.id, 'WrongOldPass!', 'NewPass!'),
).rejects.toThrow('Invalid current password');
});
it('should throw on non-existent user', async () => {
await expect(
auth.changePassword('non-existent-user-id', 'OldPass!', 'NewPass!'),
).rejects.toThrow('User not found');
});
it('should reject old password after change', async () => {
const result = await auth.register('changepw3@example.com', 'Original123!', 'User3', 'planer');
await auth.changePassword(result.user.id, 'Original123!', 'Changed456!');
await expect(
auth.login('changepw3@example.com', 'Original123!'),
).rejects.toThrow('Invalid credentials');
});
});
// ─── Session Management ────────────────────────────
describe('Session lifecycle', () => {
it('should create and validate a session', async () => {
const regResult = await auth.register('session@example.com', 'Pass123!', 'Session User', 'planer');
const token = regResult.session.token;
const session = auth.validateSession(token);
expect(session).not.toBeNull();
expect(session!.token).toBe(token);
expect(session!.userId).toBe(regResult.user.id);
});
it('should return null for invalid session token', () => {
const session = auth.validateSession('invalid-token-xyz');
expect(session).toBeNull();
});
it('should destroy a session and invalidate it', async () => {
const regResult = await auth.register('destroy@example.com', 'Pass123!', 'Destroy User', 'planer');
const token = regResult.session.token;
// Session should be valid
expect(auth.validateSession(token)).not.toBeNull();
// Destroy it
auth.destroySession(token);
// Session should now be invalid
expect(auth.validateSession(token)).toBeNull();
});
it('should handle destroying a non-existent session gracefully', () => {
// Should not throw
auth.destroySession('non-existent-token');
});
});
// ─── getUserFromSession ─────────────────────────────
describe('getUserFromSession()', () => {
it('should return user from valid session', async () => {
const regResult = await auth.register('getuser@example.com', 'Pass123!', 'GetUser User', 'planer');
const token = regResult.session.token;
const user = auth.getUserFromSession(token);
expect(user).not.toBeNull();
expect(user!.email).toBe('getuser@example.com');
expect(user!.password_hash).toBeDefined(); // returns full DBUser including password_hash
});
it('should return null for invalid session token', () => {
const user = auth.getUserFromSession('invalid-token-xyz');
expect(user).toBeNull();
});
it('should return null after session is destroyed', async () => {
const regResult = await auth.register('getuser2@example.com', 'Pass123!', 'GetUser2 User', 'planer');
const token = regResult.session.token;
auth.destroySession(token);
const user = auth.getUserFromSession(token);
expect(user).toBeNull();
});
});
// ─── createSession (standalone) ─────────────────────
describe('createSession()', () => {
it('should create a session with a unique token', async () => {
const regResult = await auth.register('createsession@example.com', 'Pass123!', 'CS User', 'planer');
const session1 = auth.createSession(regResult.user.id);
const session2 = auth.createSession(regResult.user.id);
expect(session1.token).not.toBe(session2.token);
expect(session1.userId).toBe(regResult.user.id);
expect(session2.userId).toBe(regResult.user.id);
expect(session1.expiresAt).toBeGreaterThan(Date.now());
expect(session2.expiresAt).toBeGreaterThan(Date.now());
});
});
});
+383
View File
@@ -0,0 +1,383 @@
/**
* SqliteAdapter Unit Tests init, CRUD for all entities, close
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
let idCounter = 0;
function uniqueId(prefix: string): string {
idCounter++;
return `${prefix}-${Date.now()}-${idCounter}`;
}
describe('SqliteAdapter', () => {
let db: SqliteAdapter;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
});
afterAll(() => {
db.close();
});
// ─── Init & Close ───────────────────────────────────
describe('init() and close()', () => {
it('should initialize the database without errors', async () => {
const testDb = new SqliteAdapter(':memory:');
await testDb.init();
const users = testDb.listUsers();
expect(Array.isArray(users)).toBe(true);
testDb.close();
});
it('should seed a default user on init', async () => {
const defaultUser = db.getUser('user-default');
expect(defaultUser).not.toBeNull();
expect(defaultUser!.email).toBe('default@webcad.local');
expect(defaultUser!.role).toBe('admin');
});
});
// ─── Users CRUD ─────────────────────────────────────
describe('Users CRUD', () => {
it('should create and get a user', () => {
const uid = uniqueId('user');
const user = db.createUser({
id: uid,
email: `${uid}@example.com`,
password_hash: 'hash123',
name: 'CRUD User',
role: 'planer',
});
expect(user.id).toBe(uid);
expect(user.email).toBe(`${uid}@example.com`);
const fetched = db.getUser(user.id);
expect(fetched).not.toBeNull();
expect(fetched!.email).toBe(`${uid}@example.com`);
});
it('should get user by email', () => {
const uid = uniqueId('user');
db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Email User', role: 'planer' });
const user = db.getUserByEmail(`${uid}@example.com`);
expect(user).not.toBeNull();
expect(user!.name).toBe('Email User');
});
it('should return null for non-existent user', () => {
expect(db.getUser('non-existent-id')).toBeNull();
expect(db.getUserByEmail('nobody@nowhere.com')).toBeNull();
});
it('should update a user', () => {
const uid = uniqueId('user');
db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Original Name', role: 'planer' });
const updated = db.updateUser(uid, { name: 'Updated Name', role: 'admin' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Name');
expect(updated!.role).toBe('admin');
});
it('should list users', () => {
const users = db.listUsers();
expect(users.length).toBeGreaterThanOrEqual(2);
});
it('should delete a user', () => {
const uid = uniqueId('user');
db.createUser({ id: uid, email: `${uid}@example.com`, password_hash: 'hash', name: 'Delete Me', role: 'planer' });
const ok = db.deleteUser(uid);
expect(ok).toBe(true);
expect(db.getUser(uid)).toBeNull();
});
it('should return false when deleting non-existent user', () => {
expect(db.deleteUser('non-existent-id')).toBe(false);
});
});
// ─── Projects CRUD ──────────────────────────────────
describe('Projects CRUD', () => {
it('should create and get a project', () => {
const pid = uniqueId('proj');
const project = db.createProject({ id: pid, name: 'Test Project', description: 'Test desc' });
expect(project.id).toBe(pid);
expect(project.name).toBe('Test Project');
const fetched = db.getProject(pid);
expect(fetched).not.toBeNull();
expect(fetched!.name).toBe('Test Project');
});
it('should return null for non-existent project', () => {
expect(db.getProject('non-existent-id')).toBeNull();
});
it('should update a project', () => {
const pid = uniqueId('proj');
db.createProject({ id: pid, name: 'Update Project' });
const updated = db.updateProject(pid, { name: 'Updated Project', description: 'New desc' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Project');
expect(updated!.description).toBe('New desc');
});
it('should list projects', () => {
const projects = db.listProjects();
expect(projects.length).toBeGreaterThanOrEqual(1);
});
it('should delete a project', () => {
const pid = uniqueId('proj');
db.createProject({ id: pid, name: 'Delete Project' });
const ok = db.deleteProject(pid);
expect(ok).toBe(true);
expect(db.getProject(pid)).toBeNull();
});
it('should cascade delete drawings when project is deleted', () => {
const pid = uniqueId('proj');
const did = uniqueId('draw');
db.createProject({ id: pid, name: 'Cascade Project' });
db.createDrawing({ id: did, project_id: pid, name: 'Cascade Drawing' });
db.deleteProject(pid);
expect(db.getDrawing(did)).toBeNull();
});
});
// ─── Drawings CRUD ──────────────────────────────────
describe('Drawings CRUD', () => {
let projectId: string;
it('should create and get a drawing', () => {
projectId = uniqueId('proj');
const did = uniqueId('draw');
db.createProject({ id: projectId, name: 'Drawing Project' });
const drawing = db.createDrawing({ id: did, project_id: projectId, name: 'Floor 1' });
expect(drawing.id).toBe(did);
expect(drawing.name).toBe('Floor 1');
expect(drawing.project_id).toBe(projectId);
const fetched = db.getDrawing(did);
expect(fetched).not.toBeNull();
expect(fetched!.name).toBe('Floor 1');
});
it('should list drawings by project', () => {
const did1 = uniqueId('draw');
const did2 = uniqueId('draw');
db.createDrawing({ id: did1, project_id: projectId, name: 'Drawing A' });
db.createDrawing({ id: did2, project_id: projectId, name: 'Drawing B' });
const drawings = db.listDrawings(projectId);
expect(drawings.length).toBeGreaterThanOrEqual(3);
});
it('should update a drawing', () => {
const did = uniqueId('draw');
db.createDrawing({ id: did, project_id: projectId, name: 'Update Me' });
const updated = db.updateDrawing(did, { name: 'Updated Drawing', data_json: '{"x":1}' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Drawing');
expect(updated!.data_json).toBe('{"x":1}');
});
it('should delete a drawing', () => {
const did = uniqueId('draw');
db.createDrawing({ id: did, project_id: projectId, name: 'Delete Me' });
const ok = db.deleteDrawing(did);
expect(ok).toBe(true);
expect(db.getDrawing(did)).toBeNull();
});
it('should cascade delete layers when drawing is deleted', () => {
const did = uniqueId('draw');
const lid = uniqueId('layer');
db.createDrawing({ id: did, project_id: projectId, name: 'Cascade Drawing' });
db.createLayer({ id: lid, drawing_id: did, name: 'Cascade Layer' });
db.deleteDrawing(did);
const layers = db.listLayers(did);
expect(layers.find((l) => l.id === lid)).toBeUndefined();
});
});
// ─── Layers CRUD ────────────────────────────────────
describe('Layers CRUD', () => {
let drawingId: string;
it('should create and get a layer', () => {
const pid = uniqueId('proj');
drawingId = uniqueId('draw');
const lid = uniqueId('layer');
db.createProject({ id: pid, name: 'Layer Project' });
db.createDrawing({ id: drawingId, project_id: pid, name: 'Layer Drawing' });
const layer = db.createLayer({ id: lid, drawing_id: drawingId, name: 'Layer 1', color: '#ff0000' });
expect(layer.id).toBe(lid);
expect(layer.name).toBe('Layer 1');
expect(layer.color).toBe('#ff0000');
expect(layer.visible).toBe(1);
expect(layer.locked).toBe(0);
});
it('should list layers by drawing', () => {
const lid2 = uniqueId('layer');
db.createLayer({ id: lid2, drawing_id: drawingId, name: 'Layer 2' });
const layers = db.listLayers(drawingId);
expect(layers.length).toBeGreaterThanOrEqual(2);
});
it('should update a layer', () => {
const lid = uniqueId('layer');
db.createLayer({ id: lid, drawing_id: drawingId, name: 'Update Layer' });
const updated = db.updateLayer(lid, { name: 'Updated Layer', visible: 0, locked: 1 });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Layer');
expect(updated!.visible).toBe(0);
expect(updated!.locked).toBe(1);
});
it('should delete a layer', () => {
const lid = uniqueId('layer');
db.createLayer({ id: lid, drawing_id: drawingId, name: 'Delete Layer' });
const ok = db.deleteLayer(lid);
expect(ok).toBe(true);
const layers = db.listLayers(drawingId);
expect(layers.find((l) => l.id === lid)).toBeUndefined();
});
});
// ─── Elements CRUD ──────────────────────────────────
describe('Elements CRUD', () => {
let drawingId: string;
let layerId: string;
it('should create and get an element', () => {
const pid = uniqueId('proj');
drawingId = uniqueId('draw');
layerId = uniqueId('layer');
const eid = uniqueId('elem');
db.createProject({ id: pid, name: 'Element Project' });
db.createDrawing({ id: drawingId, project_id: pid, name: 'Element Drawing' });
db.createLayer({ id: layerId, drawing_id: drawingId, name: 'Element Layer' });
const element = db.createElement({
id: eid,
drawing_id: drawingId,
layer_id: layerId,
type: 'rect',
x: 10,
y: 20,
width: 100,
height: 50,
});
expect(element.id).toBe(eid);
expect(element.type).toBe('rect');
expect(element.x).toBe(10);
expect(element.y).toBe(20);
});
it('should list elements by drawing', () => {
const eid2 = uniqueId('elem');
db.createElement({ id: eid2, drawing_id: drawingId, layer_id: layerId, type: 'circle' });
const elements = db.listElements(drawingId);
expect(elements.length).toBeGreaterThanOrEqual(2);
});
it('should update an element', () => {
const eid = uniqueId('elem');
db.createElement({ id: eid, drawing_id: drawingId, layer_id: layerId, type: 'line' });
const updated = db.updateElement(eid, { x: 500, y: 600, width: 200 });
expect(updated).not.toBeNull();
expect(updated!.x).toBe(500);
expect(updated!.y).toBe(600);
expect(updated!.width).toBe(200);
});
it('should delete an element', () => {
const eid = uniqueId('elem');
db.createElement({ id: eid, drawing_id: drawingId, layer_id: layerId, type: 'text' });
const ok = db.deleteElement(eid);
expect(ok).toBe(true);
const elements = db.listElements(drawingId);
expect(elements.find((e) => e.id === eid)).toBeUndefined();
});
});
// ─── Blocks CRUD ────────────────────────────────────
describe('Blocks CRUD', () => {
let drawingId: string;
it('should create and get a block', () => {
const pid = uniqueId('proj');
drawingId = uniqueId('draw');
const bid = uniqueId('block');
db.createProject({ id: pid, name: 'Block Project' });
db.createDrawing({ id: drawingId, project_id: pid, name: 'Block Drawing' });
const block = db.createBlock({ id: bid, drawing_id: drawingId, name: 'Table Block', category: 'Mobiliar' });
expect(block.id).toBe(bid);
expect(block.name).toBe('Table Block');
expect(block.category).toBe('Mobiliar');
expect(block.elements_json).toBe('[]');
});
it('should list blocks by drawing', () => {
const bid2 = uniqueId('block');
db.createBlock({ id: bid2, drawing_id: drawingId, name: 'Chair Block' });
const blocks = db.listBlocks(drawingId);
expect(blocks.length).toBeGreaterThanOrEqual(2);
});
it('should update a block', () => {
const bid = uniqueId('block');
db.createBlock({ id: bid, drawing_id: drawingId, name: 'Update Block' });
const updated = db.updateBlock(bid, { name: 'Updated Block', category: 'Bühne' });
expect(updated).not.toBeNull();
expect(updated!.name).toBe('Updated Block');
expect(updated!.category).toBe('Bühne');
});
it('should delete a block', () => {
const bid = uniqueId('block');
db.createBlock({ id: bid, drawing_id: drawingId, name: 'Delete Block' });
const ok = db.deleteBlock(bid);
expect(ok).toBe(true);
const blocks = db.listBlocks(drawingId);
expect(blocks.find((b) => b.id === bid)).toBeUndefined();
});
});
// ─── Settings CRUD ──────────────────────────────────
describe('Settings CRUD', () => {
it('should set and get a setting', () => {
db.setSetting('test-key', 'test-value');
const setting = db.getSetting('test-key');
expect(setting).not.toBeNull();
expect(setting!.key).toBe('test-key');
expect(setting!.value).toBe('test-value');
expect(setting!.updated_at).toBeDefined();
});
it('should return null for non-existent setting', () => {
expect(db.getSetting('non-existent-key')).toBeNull();
});
it('should upsert (update existing setting)', () => {
db.setSetting('upsert-key', 'initial');
db.setSetting('upsert-key', 'updated');
const setting = db.getSetting('upsert-key');
expect(setting).not.toBeNull();
expect(setting!.value).toBe('updated');
});
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Backend Stresstest 50.000 Elemente: SQLite CRUD Performance,
* Bulk-Insert, List, Update, Delete mit Transaktion-Support.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import type { DBElement } from '../src/database/DatabaseInterface.js';
const N = 50_000;
describe('Backend Stresstest: 50.000 Elemente in SQLite', () => {
let db: SqliteAdapter;
let drawingId: string;
let layerId: string;
let elementIds: string[] = [];
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
// Create prerequisite project → drawing → layer
const project = db.createProject({ name: 'Stresstest Project' });
const drawing = db.createDrawing({ project_id: project.id, name: 'Stresstest Drawing' });
drawingId = drawing.id;
const layer = db.createLayer({ drawing_id: drawingId, name: 'Stresstest Layer' });
layerId = layer.id;
});
afterAll(() => {
db.close();
});
it('should bulk-insert 50k elements in under 5s', () => {
const start = performance.now();
// Use transaction for bulk insert
const insert = db['db'].prepare(
'INSERT INTO elements (id, drawing_id, layer_id, type, x, y, width, height, properties_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
);
const insertMany = db['db'].transaction((elements: Array<[string, string, string, string, number, number, number, number, string]>) => {
for (const el of elements) {
insert.run(...el);
}
});
const batch: Array<[string, string, string, string, number, number, number, number, string]> = [];
for (let i = 0; i < N; i++) {
const id = `stress-elem-${i}`;
elementIds.push(id);
batch.push([
id,
drawingId,
layerId,
'rect',
(i % 1000) * 1.5,
Math.floor(i / 1000) * 1.5,
1,
1,
'{}',
]);
}
insertMany(batch);
const elapsed = performance.now() - start;
console.log(`Bulk insert ${N} elements: ${elapsed.toFixed(1)}ms`);
expect(elapsed).toBeLessThan(5000);
});
it('should list 50k elements in under 500ms', () => {
const start = performance.now();
const elements = db.listElements(drawingId);
const elapsed = performance.now() - start;
console.log(`List ${N} elements: ${elapsed.toFixed(1)}ms, count=${elements.length}`);
expect(elements.length).toBe(N);
expect(elapsed).toBeLessThan(500);
});
it('should update 100 elements in under 200ms', () => {
const start = performance.now();
for (let i = 0; i < 100; i++) {
db.updateElement(elementIds[i], { x: 9999 + i });
}
const elapsed = performance.now() - start;
console.log(`Update 100 elements: ${elapsed.toFixed(1)}ms`);
expect(elapsed).toBeLessThan(200);
});
it('should read a single element in under 5ms', () => {
const start = performance.now();
const element = db.listElements(drawingId);
const mid = element[Math.floor(element.length / 2)];
const elapsed = performance.now() - start;
console.log(`Read mid element from ${N}: ${elapsed.toFixed(1)}ms`);
expect(mid).toBeDefined();
expect(elapsed).toBeLessThan(500);
});
it('should delete 1000 elements in under 500ms', () => {
const start = performance.now();
const deleteStmt = db['db'].prepare('DELETE FROM elements WHERE id = ?');
const deleteMany = db['db'].transaction((ids: string[]) => {
for (const id of ids) {
deleteStmt.run(id);
}
});
const toDelete = elementIds.slice(0, 1000);
deleteMany(toDelete);
const elapsed = performance.now() - start;
console.log(`Delete 1000 elements: ${elapsed.toFixed(1)}ms`);
expect(elapsed).toBeLessThan(500);
// Verify count
const remaining = db.listElements(drawingId);
expect(remaining.length).toBe(N - 1000);
});
it('should handle concurrent queries efficiently', () => {
// Simulate 10 concurrent list operations
const start = performance.now();
for (let i = 0; i < 10; i++) {
const elements = db.listElements(drawingId);
expect(elements.length).toBe(N - 1000);
}
const elapsed = performance.now() - start;
console.log(`10x list operations on ${N - 1000} elements: ${elapsed.toFixed(1)}ms`);
expect(elapsed).toBeLessThan(3000);
});
});
+140
View File
@@ -0,0 +1,140 @@
/**
* AI API Tests Auth guard, validation, and service availability checks
* Does NOT test actual OpenRouter API calls.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('AI API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let authToken: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Register a user to get an auth token
const registerRes = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: {
email: 'ai-test@example.com',
password: 'TestPass123!',
name: 'AI Test User',
role: 'planer',
},
});
const body = JSON.parse(registerRes.body);
authToken = body.session.token;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Auth Guard ─────────────────────────────────────
describe('POST /api/ai/chat Authentication', () => {
it('should return 401 without authorization header', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(401);
const body = JSON.parse(response.body);
expect(body.error).toContain('Authentication required');
});
it('should return 401 with invalid token', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: 'Bearer invalid-token-xxx' },
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(401);
const body = JSON.parse(response.body);
expect(body.error).toContain('Invalid or expired session');
});
it('should return 401 with malformed authorization header', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: 'NotBearer sometoken' },
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(401);
});
});
// ─── Validation ─────────────────────────────────────
describe('POST /api/ai/chat Validation', () => {
it('should return 400 when messages array is missing', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: {},
});
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body.error).toContain('Messages array is required');
});
it('should return 400 when messages array is empty', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: { messages: [] },
});
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body.error).toContain('Messages array is required');
});
it('should return 400 when messages is not an array', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: { messages: 'not an array' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── Service Availability ───────────────────────────
describe('POST /api/ai/chat Service availability', () => {
it('should return 503 when API_KEY_OPENROUTER is not set', async () => {
// Ensure env var is not set for this test
const originalValue = process.env.API_KEY_OPENROUTER;
delete process.env.API_KEY_OPENROUTER;
const response = await app.inject({
method: 'POST',
url: '/api/ai/chat',
headers: { authorization: `Bearer ${authToken}` },
payload: { messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(503);
const body = JSON.parse(response.body);
expect(body.error).toContain('AI service not configured');
// Restore original value if it existed
if (originalValue !== undefined) {
process.env.API_KEY_OPENROUTER = originalValue;
}
});
});
});
+269
View File
@@ -0,0 +1,269 @@
/**
* Auth API Tests Register / Login / Logout / Me / Password Change
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Auth API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let authToken: string | null = null;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Register ────────────────────────────────────────
describe('POST /api/auth/register', () => {
it('should register a new user with 201', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: {
email: 'testuser@example.com',
password: 'TestPass123!',
name: 'Test User',
role: 'planer',
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.user).toBeDefined();
expect(body.user.email).toBe('testuser@example.com');
expect(body.user.name).toBe('Test User');
expect(body.user.password_hash).toBeUndefined();
expect(body.session).toBeDefined();
expect(body.session.token).toBeDefined();
authToken = body.session.token;
});
it('should reject duplicate registration with 409', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: {
email: 'testuser@example.com',
password: 'TestPass123!',
name: 'Test User 2',
},
});
expect(response.statusCode).toBe(409);
const body = JSON.parse(response.body);
expect(body.error).toContain('already registered');
});
it('should reject missing fields with 400', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { email: 'incomplete@example.com' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── Login ──────────────────────────────────────────
describe('POST /api/auth/login', () => {
it('should login with correct credentials', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: {
email: 'testuser@example.com',
password: 'TestPass123!',
},
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.user).toBeDefined();
expect(body.session.token).toBeDefined();
authToken = body.session.token;
});
it('should reject wrong password with 401', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: {
email: 'testuser@example.com',
password: 'WrongPassword!',
},
});
expect(response.statusCode).toBe(401);
});
it('should reject non-existent email with 401', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: {
email: 'nobody@example.com',
password: 'SomePassword!',
},
});
expect(response.statusCode).toBe(401);
});
it('should reject missing fields with 400', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { email: 'testuser@example.com' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── Me ─────────────────────────────────────────────
describe('GET /api/auth/me', () => {
it('should return current user profile with valid token', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/auth/me',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.email).toBe('testuser@example.com');
expect(body.password_hash).toBeUndefined();
});
it('should reject without token with 401', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/auth/me',
});
expect(response.statusCode).toBe(401);
});
it('should reject invalid token with 401', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/auth/me',
headers: { authorization: 'Bearer invalid-token-xxx' },
});
expect(response.statusCode).toBe(401);
});
});
// ─── Password Change ───────────────────────────────
describe('PATCH /api/auth/password', () => {
it('should change password with correct old password', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/auth/password',
headers: { authorization: `Bearer ${authToken}` },
payload: {
oldPassword: 'TestPass123!',
newPassword: 'NewPassword456!',
},
});
expect(response.statusCode).toBe(204);
});
it('should allow login with new password', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: {
email: 'testuser@example.com',
password: 'NewPassword456!',
},
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.session.token).toBeDefined();
authToken = body.session.token;
});
it('should reject old password after change', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: {
email: 'testuser@example.com',
password: 'TestPass123!',
},
});
expect(response.statusCode).toBe(401);
});
it('should reject password change with wrong old password', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/auth/password',
headers: { authorization: `Bearer ${authToken}` },
payload: {
oldPassword: 'WrongOldPassword!',
newPassword: 'AnotherNew789!',
},
});
expect(response.statusCode).toBe(400);
});
it('should reject password change without auth', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/auth/password',
payload: {
oldPassword: 'NewPassword456!',
newPassword: 'AnotherNew789!',
},
});
expect(response.statusCode).toBe(401);
});
it('should reject missing password fields', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/auth/password',
headers: { authorization: `Bearer ${authToken}` },
payload: { oldPassword: 'NewPassword456!' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── Logout ─────────────────────────────────────────
describe('POST /api/auth/logout', () => {
it('should logout and invalidate token', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/logout',
headers: { authorization: `Bearer ${authToken}` },
});
expect(response.statusCode).toBe(204);
// Token should now be invalid
const meResponse = await app.inject({
method: 'GET',
url: '/api/auth/me',
headers: { authorization: `Bearer ${authToken}` },
});
expect(meResponse.statusCode).toBe(401);
});
it('should return 204 even without token', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/logout',
});
expect(response.statusCode).toBe(204);
});
});
});
+216
View File
@@ -0,0 +1,216 @@
/**
* Blocks API Tests CRUD (List / Create / Update / Delete)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Blocks API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let drawingId: string;
let createdBlockId: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Create project → drawing hierarchy
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Blocks Test Project' },
});
const projectId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
payload: { name: 'Blocks Test Drawing' },
});
drawingId = JSON.parse(drawRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Create ─────────────────────────────────────────
describe('POST /api/drawings/:drawingId/blocks', () => {
it('should create a block with 201', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/blocks`,
payload: { name: 'Standard Table', category: 'Mobiliar', description: 'A standard round table' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.name).toBe('Standard Table');
expect(body.category).toBe('Mobiliar');
expect(body.description).toBe('A standard round table');
expect(body.drawing_id).toBe(drawingId);
createdBlockId = body.id;
});
it('should create a block with default values', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/blocks`,
payload: { name: 'Minimal Block' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.name).toBe('Minimal Block');
expect(body.category).toBe('Allgemein');
expect(body.description).toBeNull();
expect(body.elements_json).toBe('[]');
});
it('should reject block without name with 400', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/blocks`,
payload: { category: 'Test' },
});
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body.error).toContain('Name is required');
});
it('should reject block with empty name with 400', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/blocks`,
payload: { name: '' },
});
expect(response.statusCode).toBe(400);
});
});
// ─── List ───────────────────────────────────────────
describe('GET /api/drawings/:drawingId/blocks', () => {
it('should list blocks for a drawing', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/blocks`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should return empty array for drawing with no blocks', async () => {
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Empty Blocks Project' },
});
const projId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projId}/drawings`,
payload: { name: 'Empty Drawing' },
});
const emptyDrawId = JSON.parse(drawRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${emptyDrawId}/blocks`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(0);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/blocks/:id', () => {
it('should update block name', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/blocks/${createdBlockId}`,
payload: { name: 'Updated Block Name' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('Updated Block Name');
});
it('should update block category and description', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/blocks/${createdBlockId}`,
payload: { category: 'Bühne', description: 'Stage equipment' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.category).toBe('Bühne');
expect(body.description).toBe('Stage equipment');
});
it('should update block elements_json', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/blocks/${createdBlockId}`,
payload: { elements_json: '[{"type":"rect"}]' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.elements_json).toBe('[{"type":"rect"}]');
});
it('should return 404 for non-existent block update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/blocks/non-existent-id',
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/blocks/:id', () => {
it('should delete a block', async () => {
const createRes = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/blocks`,
payload: { name: 'To Be Deleted' },
});
const blockId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/blocks/${blockId}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone via list
const listRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/blocks`,
});
const blocks = JSON.parse(listRes.body);
expect(blocks.find((b: any) => b.id === blockId)).toBeUndefined();
});
it('should return 404 for non-existent block delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/blocks/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
});
+191
View File
@@ -0,0 +1,191 @@
/**
* Drawings API Tests CRUD (List / Get / Create / Update / Delete)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Drawings API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let projectId: string;
let createdDrawingId: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Create a project first (drawings belong to projects)
const projectRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Drawings Test Project' },
});
projectId = JSON.parse(projectRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Create ─────────────────────────────────────────
describe('POST /api/projects/:projectId/drawings', () => {
it('should create a drawing with 201', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
payload: { name: 'Ground Floor' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.name).toBe('Ground Floor');
expect(body.project_id).toBe(projectId);
createdDrawingId = body.id;
});
it('should create a drawing with default name', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
payload: {},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.name).toBe('Unbenannt');
expect(body.project_id).toBe(projectId);
});
});
// ─── List ───────────────────────────────────────────
describe('GET /api/projects/:projectId/drawings', () => {
it('should list drawings for a project', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/projects/${projectId}/drawings`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should return empty array for project with no drawings', async () => {
// Create a new empty project
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Empty Project' },
});
const emptyProjId = JSON.parse(projRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/projects/${emptyProjId}/drawings`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(0);
});
});
// ─── Get ────────────────────────────────────────────
describe('GET /api/drawings/:id', () => {
it('should get a single drawing', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${createdDrawingId}`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.id).toBe(createdDrawingId);
expect(body.name).toBe('Ground Floor');
});
it('should return 404 for non-existent drawing', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/drawings/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/drawings/:id', () => {
it('should update drawing name', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/drawings/${createdDrawingId}`,
payload: { name: 'First Floor' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('First Floor');
});
it('should update drawing data_json', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/drawings/${createdDrawingId}`,
payload: { data_json: '{"layers":[]}' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.data_json).toBe('{"layers":[]}');
});
it('should return 404 for non-existent drawing update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/drawings/non-existent-id',
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/drawings/:id', () => {
it('should delete a drawing', async () => {
// Create a drawing to delete
const createRes = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
payload: { name: 'To Be Deleted' },
});
const drawingId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/drawings/${drawingId}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone
const getRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}`,
});
expect(getRes.statusCode).toBe(404);
});
it('should return 404 for non-existent drawing delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/drawings/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
});
+212
View File
@@ -0,0 +1,212 @@
/**
* Elements API Tests CRUD (List / Create / Update / Delete)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Elements API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let drawingId: string;
let layerId: string;
let createdElementId: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Create project → drawing → layer hierarchy
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Elements Test Project' },
});
const projectId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
payload: { name: 'Elements Test Drawing' },
});
drawingId = JSON.parse(drawRes.body).id;
const layerRes = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/layers`,
payload: { name: 'Elements Layer' },
});
layerId = JSON.parse(layerRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Create ─────────────────────────────────────────
describe('POST /api/drawings/:drawingId/elements', () => {
it('should create an element with 201', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/elements`,
payload: { layer_id: layerId, type: 'rect', x: 10, y: 20, width: 100, height: 50 },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.type).toBe('rect');
expect(body.x).toBe(10);
expect(body.y).toBe(20);
expect(body.width).toBe(100);
expect(body.height).toBe(50);
expect(body.drawing_id).toBe(drawingId);
expect(body.layer_id).toBe(layerId);
createdElementId = body.id;
});
it('should create an element with default values', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/elements`,
payload: { layer_id: layerId, type: 'circle' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.type).toBe('circle');
expect(body.x).toBe(0);
expect(body.y).toBe(0);
expect(body.width).toBe(0);
expect(body.height).toBe(0);
expect(body.properties_json).toBe('{}');
});
});
// ─── List ───────────────────────────────────────────
describe('GET /api/drawings/:drawingId/elements', () => {
it('should list elements for a drawing', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/elements`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should return empty array for drawing with no elements', async () => {
// Create a new drawing with no elements
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Empty Elements Project' },
});
const projId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projId}/drawings`,
payload: { name: 'Empty Drawing' },
});
const emptyDrawId = JSON.parse(drawRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${emptyDrawId}/elements`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(0);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/elements/:id', () => {
it('should update element position', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/elements/${createdElementId}`,
payload: { x: 100, y: 200 },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.x).toBe(100);
expect(body.y).toBe(200);
});
it('should update element dimensions', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/elements/${createdElementId}`,
payload: { width: 300, height: 150 },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.width).toBe(300);
expect(body.height).toBe(150);
});
it('should update element properties_json', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/elements/${createdElementId}`,
payload: { properties_json: '{"color":"red"}' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.properties_json).toBe('{"color":"red"}');
});
it('should return 404 for non-existent element update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/elements/non-existent-id',
payload: { x: 0 },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/elements/:id', () => {
it('should delete an element', async () => {
// Create an element to delete
const createRes = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/elements`,
payload: { layer_id: layerId, type: 'line' },
});
const elemId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/elements/${elemId}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone via list
const listRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/elements`,
});
const elements = JSON.parse(listRes.body);
expect(elements.find((e: any) => e.id === elemId)).toBeUndefined();
});
it('should return 404 for non-existent element delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/elements/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* Health Endpoint Tests
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Health Endpoint', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
});
afterAll(async () => {
await app.close();
db.close();
});
it('GET /api/health should return ok status', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/health',
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.status).toBe('ok');
expect(body.timestamp).toBeDefined();
});
it('GET /api/health should return valid ISO timestamp', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/health',
});
const body = JSON.parse(response.body);
const date = new Date(body.timestamp);
expect(date.toString()).not.toBe('Invalid Date');
});
});
+199
View File
@@ -0,0 +1,199 @@
/**
* Layers API Tests CRUD (List / Create / Update / Delete)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Layers API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let drawingId: string;
let createdLayerId: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Create project → drawing hierarchy
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Layers Test Project' },
});
const projectId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projectId}/drawings`,
payload: { name: 'Layers Test Drawing' },
});
drawingId = JSON.parse(drawRes.body).id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Create ─────────────────────────────────────────
describe('POST /api/drawings/:drawingId/layers', () => {
it('should create a layer with 201', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/layers`,
payload: { name: 'Background', color: '#ff0000' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.name).toBe('Background');
expect(body.color).toBe('#ff0000');
expect(body.drawing_id).toBe(drawingId);
createdLayerId = body.id;
});
it('should create a layer with default values', async () => {
const response = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/layers`,
payload: {},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.name).toBe('Layer');
expect(body.visible).toBe(1);
expect(body.locked).toBe(0);
expect(body.color).toBe('#ffffff');
expect(body.line_type).toBe('solid');
});
});
// ─── List ───────────────────────────────────────────
describe('GET /api/drawings/:drawingId/layers', () => {
it('should list layers for a drawing', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/layers`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
it('should return empty array for drawing with no layers', async () => {
// Create a new drawing with no layers
const projRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Empty Layers Project' },
});
const projId = JSON.parse(projRes.body).id;
const drawRes = await app.inject({
method: 'POST',
url: `/api/projects/${projId}/drawings`,
payload: { name: 'Empty Drawing' },
});
const emptyDrawId = JSON.parse(drawRes.body).id;
const response = await app.inject({
method: 'GET',
url: `/api/drawings/${emptyDrawId}/layers`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(0);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/layers/:id', () => {
it('should update layer name', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/layers/${createdLayerId}`,
payload: { name: 'Updated Layer Name' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('Updated Layer Name');
});
it('should update layer visibility', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/layers/${createdLayerId}`,
payload: { visible: 0, locked: 1 },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.visible).toBe(0);
expect(body.locked).toBe(1);
});
it('should update layer color and line_type', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/layers/${createdLayerId}`,
payload: { color: '#00ff00', line_type: 'dashed' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.color).toBe('#00ff00');
expect(body.line_type).toBe('dashed');
});
it('should return 404 for non-existent layer update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/layers/non-existent-id',
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/layers/:id', () => {
it('should delete a layer', async () => {
// Create a layer to delete
const createRes = await app.inject({
method: 'POST',
url: `/api/drawings/${drawingId}/layers`,
payload: { name: 'To Be Deleted' },
});
const layerId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/layers/${layerId}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone via list
const listRes = await app.inject({
method: 'GET',
url: `/api/drawings/${drawingId}/layers`,
});
const layers = JSON.parse(listRes.body);
expect(layers.find((l: any) => l.id === layerId)).toBeUndefined();
});
it('should return 404 for non-existent layer delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/layers/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
});
+176
View File
@@ -0,0 +1,176 @@
/**
* Projects API Tests CRUD (List / Get / Create / Update / Delete)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Projects API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let createdProjectId: string | null = null;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Create ─────────────────────────────────────────
describe('POST /api/projects', () => {
it('should create a project with 201', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/projects',
payload: {
name: 'Test Project',
description: 'A test project',
},
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.id).toBeDefined();
expect(body.name).toBe('Test Project');
expect(body.description).toBe('A test project');
createdProjectId = body.id;
});
it('should reject project without name with 400', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { description: 'No name' },
});
expect(response.statusCode).toBe(400);
});
it('should create project with default values', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Minimal Project' },
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.name).toBe('Minimal Project');
expect(body.description).toBeNull();
});
});
// ─── List ───────────────────────────────────────────
describe('GET /api/projects', () => {
it('should list all projects', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/projects',
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2);
});
});
// ─── Get ────────────────────────────────────────────
describe('GET /api/projects/:id', () => {
it('should get a single project', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/projects/${createdProjectId}`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.id).toBe(createdProjectId);
expect(body.name).toBe('Test Project');
});
it('should return 404 for non-existent project', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/projects/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/projects/:id', () => {
it('should update project name', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/projects/${createdProjectId}`,
payload: { name: 'Updated Project Name' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('Updated Project Name');
expect(body.id).toBe(createdProjectId);
});
it('should update project description', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/projects/${createdProjectId}`,
payload: { description: 'Updated description' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.description).toBe('Updated description');
});
it('should return 404 for non-existent project update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/projects/non-existent-id',
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/projects/:id', () => {
it('should delete a project', async () => {
// First create a project to delete
const createRes = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'To Be Deleted' },
});
const projectId = JSON.parse(createRes.body).id;
const response = await app.inject({
method: 'DELETE',
url: `/api/projects/${projectId}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone
const getRes = await app.inject({
method: 'GET',
url: `/api/projects/${projectId}`,
});
expect(getRes.statusCode).toBe(404);
});
it('should return 404 for non-existent project delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/projects/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Settings API Tests Key/Value settings (Get / Put / Upsert)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Settings API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── Get (missing key → 404) ─────────────────────────
describe('GET /api/settings/:key', () => {
it('should return 404 for non-existent setting', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/settings/non-existent-key',
});
expect(response.statusCode).toBe(404);
const body = JSON.parse(response.body);
expect(body.error).toContain('Setting not found');
});
it('should return a setting that was previously set', async () => {
// First set a value
await app.inject({
method: 'PUT',
url: '/api/settings/test-key',
payload: { value: 'test-value' },
});
const response = await app.inject({
method: 'GET',
url: '/api/settings/test-key',
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.key).toBe('test-key');
expect(body.value).toBe('test-value');
expect(body.updated_at).toBeDefined();
});
});
// ─── Put (upsert) ───────────────────────────────────
describe('PUT /api/settings/:key', () => {
it('should create a new setting', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/new-setting',
payload: { value: 'new-value' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.key).toBe('new-setting');
expect(body.value).toBe('new-value');
expect(body.updated_at).toBeDefined();
});
it('should update an existing setting (upsert)', async () => {
// Create initial
await app.inject({
method: 'PUT',
url: '/api/settings/upsert-key',
payload: { value: 'initial' },
});
// Update it
const response = await app.inject({
method: 'PUT',
url: '/api/settings/upsert-key',
payload: { value: 'updated' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.key).toBe('upsert-key');
expect(body.value).toBe('updated');
// Verify via GET
const getRes = await app.inject({
method: 'GET',
url: '/api/settings/upsert-key',
});
const getBody = JSON.parse(getRes.body);
expect(getBody.value).toBe('updated');
});
it('should reject setting without value', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/no-value-key',
payload: {},
});
const body = JSON.parse(response.body);
expect(body.error).toContain('Value is required');
});
it('should handle complex JSON values', async () => {
const complexValue = JSON.stringify({ nested: { object: true }, array: [1, 2, 3] });
const response = await app.inject({
method: 'PUT',
url: '/api/settings/complex-config',
payload: { value: complexValue },
});
expect(response.statusCode).toBe(200);
const getRes = await app.inject({
method: 'GET',
url: '/api/settings/complex-config',
});
const body = JSON.parse(getRes.body);
expect(body.value).toBe(complexValue);
});
});
});
+187
View File
@@ -0,0 +1,187 @@
/**
* Users API Tests List / Get / Update / Delete (password_hash stripping)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Users API', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let testUserId: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
// Create a test user directly via DB (bypassing AuthService)
const user = db.createUser({
email: 'test-admin@example.com',
password_hash: 'fake-hash-for-testing',
name: 'Test Admin',
role: 'admin',
});
testUserId = user.id;
});
afterAll(async () => {
await app.close();
db.close();
});
// ─── List ───────────────────────────────────────────
describe('GET /api/users', () => {
it('should list all users', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/users',
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThanOrEqual(2); // default user + test user
});
it('should strip password_hash from list responses', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/users',
});
const body = JSON.parse(response.body);
for (const user of body) {
expect(user.password_hash).toBeUndefined();
}
});
});
// ─── Get ────────────────────────────────────────────
describe('GET /api/users/:id', () => {
it('should get a single user', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/users/${testUserId}`,
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.id).toBe(testUserId);
expect(body.email).toBe('test-admin@example.com');
expect(body.name).toBe('Test Admin');
expect(body.role).toBe('admin');
});
it('should strip password_hash from single user response', async () => {
const response = await app.inject({
method: 'GET',
url: `/api/users/${testUserId}`,
});
const body = JSON.parse(response.body);
expect(body.password_hash).toBeUndefined();
});
it('should return 404 for non-existent user', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/users/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
// ─── Update ─────────────────────────────────────────
describe('PATCH /api/users/:id', () => {
it('should update user name', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/users/${testUserId}`,
payload: { name: 'Updated Name' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.name).toBe('Updated Name');
expect(body.id).toBe(testUserId);
});
it('should update user role', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/users/${testUserId}`,
payload: { role: 'planer' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.role).toBe('planer');
});
it('should strip password_hash from update response', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/users/${testUserId}`,
payload: { name: 'Another Name' },
});
const body = JSON.parse(response.body);
expect(body.password_hash).toBeUndefined();
});
it('should not update password_hash via PATCH endpoint', async () => {
const response = await app.inject({
method: 'PATCH',
url: `/api/users/${testUserId}`,
payload: { password_hash: 'hacked-hash' },
});
expect(response.statusCode).toBe(200);
// Verify the password_hash was NOT changed in DB
const dbUser = db.getUser(testUserId);
expect(dbUser!.password_hash).toBe('fake-hash-for-testing');
});
it('should return 404 for non-existent user update', async () => {
const response = await app.inject({
method: 'PATCH',
url: '/api/users/non-existent-id',
payload: { name: 'New Name' },
});
expect(response.statusCode).toBe(404);
});
});
// ─── Delete ─────────────────────────────────────────
describe('DELETE /api/users/:id', () => {
it('should delete a user', async () => {
// Create a user to delete
const user = db.createUser({
email: 'delete-me@example.com',
password_hash: 'temp-hash',
name: 'Delete Me',
role: 'planer',
});
const response = await app.inject({
method: 'DELETE',
url: `/api/users/${user.id}`,
});
expect(response.statusCode).toBe(204);
// Verify it's gone
const getRes = await app.inject({
method: 'GET',
url: `/api/users/${user.id}`,
});
expect(getRes.statusCode).toBe(404);
});
it('should return 404 for non-existent user delete', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/users/non-existent-id',
});
expect(response.statusCode).toBe(404);
});
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/types/**', 'src/websocket/**'],
},
},
});
+34
View File
@@ -0,0 +1,34 @@
version: '3.8'
services:
backend:
build: ./backend
ports:
- "5000:5000"
environment:
- NODE_ENV=production
- JWT_SECRET=${JWT_SECRET:?set JWT_SECRET}
volumes:
- sqlite_data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:5000/api/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
frontend:
build: ./frontend
expose:
- "80"
depends_on:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:80 || exit 1"]
interval: 10s
timeout: 5s
retries: 5
volumes:
sqlite_data:
+34
View File
@@ -0,0 +1,34 @@
version: '3.8'
services:
backend:
build: ./backend
ports:
- "5000:5000"
environment:
- NODE_ENV=production
- JWT_SECRET=6151504445a6e5defbe6888f91d002bbd5368f0af5dc38ce6bf25606818f0b3b
volumes:
- sqlite_data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:5000/api/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
frontend:
build: ./frontend
expose:
- "80"
depends_on:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:80 || exit 1"]
interval: 10s
timeout: 5s
retries: 5
volumes:
sqlite_data:
+40
View File
@@ -0,0 +1,40 @@
version: '3.8'
services:
backend:
build: https://forgejo.media-on.de/Leopoldadmin/web-cad.git#master
dockerfile: backend/Dockerfile
environment:
- NODE_ENV=production
- JWT_SECRET=cad-jwt-secret-prod-2026
- PORT=5000
expose:
- "5000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
volumes:
- sqlite_data:/app/data
restart: unless-stopped
frontend:
build:
context: https://forgejo.media-on.de/Leopoldadmin/web-cad.git#master
dockerfile: frontend/Dockerfile
expose:
- "80"
depends_on:
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
restart: unless-stopped
volumes:
sqlite_data:
+80
View File
@@ -0,0 +1,80 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=webcad
- POSTGRES_PASSWORD=webcad
- POSTGRES_DB=webcad
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U webcad"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
backend:
image: node:22-alpine
working_dir: /app
environment:
- NODE_ENV=production
- JWT_SECRET=cad-jwt-secret-prod-2026
- DB_HOST=postgres
- DB_PORT=5432
- DB_USER=webcad
- DB_PASSWORD=webcad
- DB_NAME=webcad
expose:
- "5000"
command:
- sh
- '-c'
- |
apk add --no-cache git
git clone --depth 1 https://forgejo.media-on.de/Leopoldadmin/web-cad.git /tmp/repo
cp -r /tmp/repo/backend/* /app/
rm -rf /tmp/repo
npm ci --only=production
node seed.js && node server.js
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:5000/api/health',r=>{process.exit(r.statusCode===200?0:1)})"]
interval: 15s
timeout: 5s
retries: 15
start_period: 40s
restart: unless-stopped
frontend:
image: node:22-alpine
working_dir: /app
expose:
- "80"
command:
- sh
- '-c'
- |
apk add --no-cache git curl
git clone --depth 1 https://forgejo.media-on.de/Leopoldadmin/web-cad.git /tmp/repo
cp -r /tmp/repo/frontend/* /app/
rm -rf /tmp/repo
npm ci
npx vite build --mode production
npx vite preview --host 0.0.0.0 --port 80
depends_on:
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/"]
interval: 15s
timeout: 5s
retries: 15
start_period: 120s
restart: unless-stopped
volumes:
pg_data:
+29
View File
@@ -0,0 +1,29 @@
version: '3.8'
services:
backend:
build: ./backend
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- JWT_SECRET=your-secret-key-change-me
volumes:
- sqlite_data:/app/data
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
frontend:
build: ./frontend
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped
volumes:
sqlite_data:
+633
View File
@@ -0,0 +1,633 @@
# Requirements Specification web-cad
**Project:** web-cad Web-basiertes 2D-CAD für Event-Bestuhlungspläne
**Phase:** 1 (Intake)
**Date:** 2026-06-19 (updated v4)
**Status:** Draft v4 ready for user review
---
## 1. Vision & Ziel
Das Ziel von **web-cad** ist eine web-basierte 2D-CAD-Anwendung, die:
- alle grundlegenden CAD-Funktionen bereitstellt (Zeichnen, Bearbeiten, Bemaßen, Ebenen, Bibliothek, Gruppierung, Export/Import)
- spezialisierte Tools für Event-Bestuhlungspläne bietet (Reihen- und Block-Bestuhlung)
- später um weitere branchenspezifische Tools erweiterbar ist in jede Richtung
- Multi-User-Kollaboration in Echtzeit unterstützt
- performant im Browser läuft auch bei großen Projekten
- später auf Docker und Coolify deploybar ist
- **ausschließlich Open-Source-Komponenten** verwendet und selbst als Open Source lizenziert wird
- **KI Copilot als Kern-Feature** integriert hat von Anfang an, nicht als Afterthought
- **API-first & modular** designed ist an andere Software anhängbar
**Referenz-UI:** AutoCAD Web (siehe Section 12 AutoCAD Web Feature-Referenz)
**Referenz-Architektur (Kollaboration):** Figma / Onshape (Cloud-native, Single Source of Truth)
**Referenz-Architektur (KI Copilot):** AutoCAD 2026/2027 AI Features + LLM Function Calling / Tool Use Pattern
---
## 2. Nutzer & Rollen
| Rolle | Beschreibung |
|---|---|
| **Planer** | Erstellt und bearbeitet Bestuhlungspläne, nutzt CAD-Grundfunktionen, Bestuhlungs-Tools und KI Copilot |
| **Betrachter** | Kann Pläne ansehen, kommentieren, aber nicht bearbeiten (Read-Only-Zugriff) |
| **Admin** | Verwaltung von Projekten, Nutzern, Berechtigungen, Bibliothek und KI-Konfiguration |
| **Gast** | Temporärer Zugriff auf spezifische Pläne ohne Account (z. B. für Kundenfreigabe) |
| **KI Copilot** | Software-Agent, der per Text/Sprache gesteuert wird und CAD-Operationen ausführt |
---
## 3. Funktionale Anforderungen
### 3.1 CAD-Grundfunktionen
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| F-CAD-01 | **Zeichnen von Grundelementen:** Linie, Kreis, Bogen, Rechteck, Polygon, Ellipse, Polylinie (PLINE) | Alle genannten Elemente können erstellt, ausgewählt, verschoben und gelöscht werden |
| F-CAD-02 | **Bearbeiten:** Verschieben (MOVE), Kopieren (COPY/CO), Rotieren (ROTATE/RO), Skalieren (SCALE/SC), Spiegeln (MIRROR/MI), Trimmen (TRIM), Verlängern (EXTEND), Abrunden (FILLET), Versatz (OFFSET) | Jede Operation verändert die Geometrie korrekt und kann rückgängig gemacht werden (Undo/Redo) |
| F-CAD-03 | **Bemaßung:** Lineare (DIM), winkelige, radiale Bemaßung mit automatischer Maßberechnung basierend auf dem Hintergrund-Grundriss-Maßstab | Bemaßung zeigt korrekte Werte in realen Maßeinheiten (m/cm) an |
| F-CAD-04 | **Raster & Fang:** Rasteranzeige, Snap-to-Grid, Snap-to-Endpoint, Snap-to-Midpoint, Snap-to-Intersection, Ortho-Modus, Polar Tracking | Fangpunkte werden visuell hervorgehoben und beim Zeichnen exakt eingefangen |
| F-CAD-05 | **Eingabefeld (Command Line):** Befehlseingabe via Tastatur wie in AutoCAD (z. B. "L" für Line, "PL" für Polyline, "C" für Circle) | Befehle können über die Tastatur eingegeben und ausgeführt werden; Autovervollständigung vorhanden |
| F-CAD-06 | **Eigenschaften-Panel:** Anzeige und Bearbeitung von Element-Eigenschaften (Position, Größe, Winkel, Farbe, Linientyp, Linienstärke, Transparenz) | Eigenschaften können angezeigt und geändert werden; Änderungen sind sofort sichtbar |
| F-CAD-07 | **Auswahl-Methoden:** Einzelauswahl, Fenster-Auswahl, Kreuz-Auswahl, Selektion-Filter, Quick-Select (Eigenschafts-basierte Auswahl) | Alle Auswahlmethoden funktionieren und können kombiniert werden |
| F-CAD-08 | **Gruppierung:** Elemente zu Gruppen zusammenfassen, Gruppen verschachteln, Gruppen speichern | Gruppen können erstellt, aufgelöst und in der Bibliothek gespeichert werden |
| F-CAD-09 | **Undo/Redo:** Strukturierte Undo/Redo-Historie mit beliebig vielen Schritten | Undo und Redo funktionieren für alle Operationen; Historie kann eingesehen werden |
| F-CAD-10 | **Kopieren zwischen Dateien:** Elemente können via Zwischenablage zwischen Projekten kopiert werden | Kopierte Elemente behalten ihre Eigenschaften und relative Position |
| F-CAD-11 | **Annotation & Text:** Einzel- und Mehrzeilentext (TEXT/MTEXT), Revision Clouds (REVCLOUD), Leaders (MLEADER) | Text kann erstellt, formatiert und platziert werden; Leaders und Revisionswolken funktionieren |
| F-CAD-12 | **Muster-/Schraffurfunktion:** Flächen können mit Mustern/Schraffuren gefüllt werden (HATCH) | Muster können ausgewählt, skaliert und angewendet werden |
### 3.2 Ebenen-System (Layers)
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| F-LAY-01 | **Ebenen als Baumstruktur:** Ebenen hierarchisch organisiert mit Parent-Child-Beziehungen | Ebenen werden in einem Baum-Widget angezeigt; Parent-Child-Beziehung sichtbar |
| F-LAY-02 | **Ebenen-Eigenschaften:** Name, Sichtbarkeit (On/Off), Sperrung (Lock/Unlock), Farbe, Linientyp, Transparenz | Jede Eigenschaft kann pro Ebene gesetzt werden und wirkt sich auf alle zugehörigen Elemente aus |
| F-LAY-03 | **Ebenen-Operationen:** Erstellen, Löschen, Umbenennen, Verschieben im Baum, Duplizieren | Alle Operationen funktionieren und aktualisieren den Baum in Echtzeit |
| F-LAY-04 | **Element-Zuordnung:** Elemente können zwischen Ebenen verschoben werden | Drag-and-Drop oder Kontextmenü zum Verschieben von Elementen zwischen Ebenen |
| F-LAY-05 | **Aktive Ebene:** Neue Elemente werden auf der aktiven Ebene erstellt | Aktive Ebene ist klar markiert; neue Elemente erscheinen auf der aktiven Ebene |
| F-LAY-06 | **Ebenen-Filter:** Filter nach Eigenschaften (Farbe, Linientyp, Name) | Gefilterte Ebenen werden korrekt angezeigt; Filter können gespeichert werden |
### 3.3 Bibliothek (Block-Bibliothek)
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| F-LIB-01 | **Bibliothek als Baumstruktur:** Bibliothekselemente hierarchisch organisiert mit Ordnern und Unterordnern | Baum-Widget zeigt Ordner und Blöcke; Drag-and-Drop für Organisation |
| F-LIB-02 | **SVG-Import:** SVG-Dateien können in die Bibliothek importiert werden | SVG wird korrekt importiert, im Viewer angezeigt und als wiederverwendbarer Block gespeichert |
| F-LIB-03 | **Gruppen in Bibliothek speichern:** Im Zeichenbereich erstellte Gruppen können in die Bibliothek gespeichert werden | Gruppe wird als Block gespeichert und kann per Drag-and-Drop in den Zeichenbereich eingefügt werden |
| F-LIB-04 | **Block-Einfügen:** Blöcke aus der Bibliothek per Drag-and-Drop in den Zeichenbereich einfügen | Block wird an der Drop-Position eingefügt; Skalierung und Rotation können beim Einfügen gesetzt werden |
| F-LIB-05 | **Block-Bearbeitung:** Blöcke können nach dem Einfügen bearbeitet, skaliert, rotiert und gespiegelt werden | Alle Bearbeitungsoperationen funktionieren auf eingefügten Blöcken |
| F-LIB-06 | **Bibliotheks-Verwaltung:** Blöcke umbenennen, duplizieren, löschen, in Ordner verschieben | Alle Verwaltungsoperationen funktionieren; Änderungen werden persistent gespeichert |
| F-LIB-07 | **Block-Definition vs. Block-Referenz:** Blöcke werden als Definition gespeichert; Instanzen referenzieren die Definition (wie AutoCAD Blocks) | Änderung an der Block-Definition aktualisiert alle Instanzen; Instanzen können unabhängig transformiert werden |
### 3.4 Hintergrund & Grundriss
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| F-BG-01 | **Grundriss laden:** Bild- oder Vektor-Datei (PNG, JPG, SVG, PDF-Seite) als Hintergrund laden | Datei wird als Hintergrund-Ebene geladen und im Zeichenbereich angezeigt |
| F-BG-02 | **Maßstabs-Definition:** Maßstab des Grundrisses kann definiert werden (z. B. 1:100, Referenzstrecke) | Nach Maßstabsdefinition werden Bemaßungen in realen Maßeinheiten (m/cm) angezeigt |
| F-BG-03 | **Hintergrund-Positionierung:** Hintergrund kann verschoben, rotiert und skaliert werden | Transformationen sind möglich und Bemaßungen aktualisieren sich entsprechend |
| F-BG-04 | **Hintergrund-Sichtbarkeit:** Hintergrund kann ein- und ausgeblendet werden | Sichtbarkeit kann pro Ebene oder global geschaltet werden |
### 3.5 Bestuhlungs-Tools (Event-Spezifisch)
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| F-EVT-01 | **Reihen-Bestuhlung:** Automatische Platzierung von Stühlen in einer Reihe mit konfigurierbarem Abstand, Anzahl und Ausrichtung | Reihe wird mit korrekter Anzahl, Abstand und Ausrichtung generiert; Parameter sind im Nachhinein änderbar |
| F-EVT-02 | **Block-Bestuhlung:** Automatische Platzierung von Stuhl-Blöcke in einem rechteckigen Bereich mit konfigurierbaren Reihen, Spalten, Abständen | Block wird mit korrekter Reihe/Spalte-Anzahl und Abständen generiert; Parameter im Nachhinein änderbar |
| F-EVT-03 | **Bestuhlungs-Parameter:** Stuhl-Typ (aus Bibliothek), Reihe/Spalte-Anzahl, Abstand, Versatz, Ausrichtung, Block-Konfiguration | Alle Parameter können konfiguriert werden; Änderungen aktualisieren die Bestuhlung in Echtzeit |
| F-EVT-04 | **Bestuhlung bearbeiten:** Generierte Bestuhlung kann nachträglich modifiziert werden (Stühle hinzufügen/entfernen, verschieben) | Einzelne Stühle können hinzugefügt, entfernt oder verschoben werden; Gesamtbestuhlung aktualisiert sich |
| F-EVT-05 | **Bestuhlungs-Zählung:** Automatische Zählung der platzierten Stühle pro Reihe, Block und Gesamt | Zähler wird in Echtzeit aktualisiert und kann im Eigenschaften-Panel abgelesen werden |
### 3.6 Import & Export
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| F-IMP-01 | **DXF-Import:** DXF-Dateien können importiert und als Zeichnung bearbeitet werden | DXF wird korrekt importiert; Layer, Blöcke und Geometrie bleiben erhalten |
| F-IMP-02 | **SVG-Import:** SVG-Dateien können als Zeichnung importiert werden | SVG wird korrekt importiert und als Vektor-Elemente bearbeitbar |
| F-IMP-03 | **DWG-Import (Optional):** DWG-Dateien können importiert werden (Open-Source-Library, z. B. libredwg) | DWG wird korrekt importiert oder klare Fehlermeldung bei nicht unterstützter Version |
| F-IMP-04 | **PDF-Import:** PDF-Dateien als Hintergrund oder als Vektor-Import | PDF wird geladen; Seiten können ausgewählt werden; Vektoren werden als bearbeitbare Elemente importiert |
| F-EXP-01 | **DXF-Export:** Zeichnung kann als DXF exportiert werden | Exportierte DXF-Datei kann in AutoCAD/QCAD korrekt geöffnet werden |
| F-EXP-02 | **SVG-Export:** Zeichnung kann als SVG exportiert werden | Exportierte SVG-Datei ist W3C-konform und in Browsern/Vektor-Programmen darstellbar |
| F-EXP-03 | **PDF-Export:** Zeichnung kann als PDF exportiert werden (mit Layout, Maßstab, Titelblock) | PDF wird mit korrektem Maßstab und allen Elementen generiert |
| F-EXP-03a | **PNG-Export:** Zeichnung kann als PNG exportiert werden | PNG wird in konfigurierbarer Auflösung generiert |
| F-EXP-04 | **JSON-Export (Projekt):** Vollständiges Projekt kann als JSON exportiert und wieder importiert werden | JSON enthält alle Daten (Ebenen, Elemente, Bibliothek, Grundriss); Re-Import ergibt identisches Projekt |
### 3.7 Multi-User & Kollaboration
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| F-MU-01 | **Echtzeit-Kollaboration:** Mehrere Nutzer können gleichzeitig an derselben Zeichnung arbeiten | Änderungen eines Nutzers sind für alle anderen in Echtzeit (< 1 Sekunde) sichtbar |
| F-MU-02 | **Konfliktfreie Bearbeitung:** Gleichzeitige Bearbeitung desselben Elements führt nicht zu Konflikten oder Datenverlust | CRDT-basierte Synchronisation stellt Konsistenz sicher; keine Merge-Konflikte |
| F-MU-03 | **Nutzer-Anwesenheit:** Sichtbare Anzeige welcher Nutzer online ist und an welcher Zeichnung arbeitet | Avatar/Farbmarkierung pro Nutzer; Liste der aktiven Nutzer im Panel |
| F-MU-04 | **Cursor-Anzeige:** Position und Aktionen anderer Nutzer in Echtzeit sichtbar | Cursor anderer Nutzer wird mit Name/Farbe in Echtzeit angezeigt |
| F-MU-05 | **Berechtigungs-System:** Rollenbasierte Zugriffskontrolle (Planer, Betrachter, Admin, Gast) | Berechtigungen werden enforced; Betrachter kann nicht bearbeiten; Gast hat nur Zugriff auf freigegebene Pläne |
| F-MU-06 | **Offline-Unterstützung:** Offline-Änderungen werden synchronisiert, wenn Verbindung wiederhergestellt ist | Offline-Änderungen werden automatisch synchronisiert; keine Datenverluste |
| F-MU-07 | **Versionshistorie:** Änderungen werden protokolliert; Versionen können eingesehen und wiederhergestellt werden | Historie zeigt Zeitstempel und Nutzer; Versionen können wiederhergestellt werden |
### 3.8 UI / UX
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| F-UI-01 | **AutoCAD Web-ähnliche Oberfläche:** Ribbon-/Menü-Band, Werkzeug-Paletten, Eigenschaften-Panel, Command-Line, Status-Bar, Blocks-Tab, Layers-Tab | Layout orientiert sich an AutoCAD Web; Hauptwerkzeuge sind sichtbar und erreichbar |
| F-UI-02 | **Zeichenbereich:** Großer Canvas-Bereich mit Zoom, Pan, und View-Controls (Zoom-to-Fit, Zoom-to-Window) | Zoom und Pan funktionieren flüssig (60fps); View-Controls sind erreichbar |
| F-UI-03 | **Kontextmenüs:** Rechtsklick-Kontextmenüs mit relevanten Aktionen | Kontextmenü zeigt aktionsabhängige Einträge |
| F-UI-04 | **Tastatur-Shortcuts:** Standard-CAD-Shortcuts (z. B. L, C, M, CO, RO, SC, MI, PL, H) | Shortcuts funktionieren und sind dokumentiert |
| F-UI-05 | **Responsive Design:** UI funktioniert auf Desktop-Bildschirmen (min. 1280px) | Bei 1280px Breite sind alle Panels nutzbar; kleinere Bildschirme werden in späterer Phase unterstützt |
| F-UI-06 | **Theme-Unterstützung:** Dark- und Light-Mode | Theme kann umgeschaltet werden; alle UI-Elemente passen sich an |
| F-UI-07 | **Sidebar-Panels:** Tabs für Blocks, Layers, Eigenschaften (wie AutoCAD Web Side Panel) | Side-Panel-Tabs sind erreichbar und zeigen entsprechenden Inhalt |
| F-UI-08 | **Streamlined UI:** UI fokussiert auf 2D-Drafting nicht überladen wie Desktop-CAD | Wesentliche Werkzeuge sichtbar; erweiterte Funktionen in Untermenüs oder ausblendbar |
| F-UI-09 | **KI Copilot Panel:** Eingebettetes Chat/Command-Panel für KI-Interaktion | KI-Panel ist sichtbar; Nutzer kann Texteingaben machen; Antworten und Aktionen werden angezeigt |
| F-UI-10 | **Voice Input:** Spracheingabe für KI Copilot (optional, Web Speech API) | Spracheingabe kann aktiviert werden; erkannter Text wird an KI Copilot gesendet |
### 3.9 Erweiterbarkeit & Plugin-System
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| F-EXT-01 | **Plugin/Tool-System:** Architektur ermöglicht das Hinzufügen neuer Tools ohne Core-Änderung | Ein neues Tool kann als Plugin/Modul implementiert und registriert werden ohne Core-Code zu ändern |
| F-EXT-02 | **Tool-API:** Definierte Schnittstelle für externe Tools (Zeichnen, Bearbeiten, Bibliothek, Export, UI) | API ist dokumentiert; ein Beispiel-Tool kann die API nutzen |
| F-EXT-03 | **Bibliothek-Erweiterung:** Neue Bibliothekstypen können hinzugefügt werden (nicht nur SVG) | Neue Typen können registriert und in der Bibliothek verwendet werden |
| F-EXT-04 | **Universelle Erweiterbarkeit:** Die Software soll später in jede Richtung erweiterbar sein neue Branchen-Tools, neue Import/Export-Formate, neue UI-Komponenten | Architektur erlaubt Erweiterung in allen Bereichen ohne Core-Rewrite |
| F-EXT-05 | **Plugin-Isolation:** Plugins laufen isoliert und können den Core nicht crashen | Ein fehlerhaftes Plugin zeigt Fehlermeldung, aber die Anwendung stürzt nicht ab |
| F-EXT-06 | **Plugin-Registrierung:** Plugins können über ein Manifest registriert und zur Laufzeit geladen werden | Plugin-Manifest definiert Name, Version, Abhängigkeiten; Plugin wird zur Laufzeit geladen |
| F-EXT-07 | **Plugin-Lifecycle:** Plugins können aktiviert/deaktiviert werden ohne Neustart | Plugin kann in den Einstellungen aktiviert/deaktiviert werden; Änderung ist sofort wirksam |
| F-EXT-08 | **Plugin-UI-Integration:** Plugins können eigene UI-Elemente (Ribbon-Tabs, Panels, Dialoge) hinzufügen | Plugin-UI-Elemente erscheinen im Ribbon/Panel und sind funktional |
### 3.10 Authentifizierung & Benutzerverwaltung
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| F-AUTH-01 | **E-Mail/Passwort-Login:** Nutzer registrieren sich mit E-Mail und Passwort | Registrierung und Login funktionieren; Passwörter werden gehasht (bcrypt/argon2) gespeichert |
| F-AUTH-02 | **Passwort-Reset:** Passwort kann über E-Mail-Link zurückgesetzt werden | Reset-Link wird gesendet; Passwort kann nach Klick zurückgesetzt werden |
| F-AUTH-03 | **Session-Management:** Sichere Sessions mit HTTP-only Cookies | Sessions sind sicher; CSRF-Schutz aktiv; Session-Timeout konfigurierbar |
| F-AUTH-04 | **Benutzerverwaltung:** Admin kann Nutzer erstellen, bearbeiten, löschen, Rollen zuweisen | Alle Operationen funktionieren; Rollenänderungen sind sofort wirksam |
| F-AUTH-05 | **Gast-Zugang:** Admin kann temporäre Gast-Zugänge für spezifische Pläne erstellen | Gast-Link funktioniert; Gast hat nur Zugriff auf freigegebene Pläne; Link kann widerrufen werden |
### 3.11 KI Copilot
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| F-AI-01 | **CAD-Steuerung per Text/Sprache:** Der KI Copilot kann alle CAD-Grundfunktionen per Text- oder Spracheingabe steuern (z. B. "Zeichne eine Linie von A nach B", "Rotiere das ausgewählte Element um 45 Grad") | KI-Interpreter übersetzt natürliche Sprache in CAD-Befehle; Befehle werden korrekt ausgeführt; Ergebnis ist sichtbar auf dem Canvas |
| F-AI-02 | **Zeichnungen erstellen & bearbeiten:** Der KI Copilot kann Zeichnungen aktiv erstellen und bearbeiten Linien, Kreise, Polygone, Rechtecke, Bögen, Polylinien, Text, Bemaßungen, Schraffuren | KI-generierte Elemente erscheinen korrekt auf dem Canvas; Eigenschaften (Position, Größe, Farbe) entsprechen der Anweisung |
| F-AI-03 | **Bestuhlungs-Tools bedienen:** Der KI Copilot kann Bestuhlungs-Tools steuern Reihen und Blöcke erstellen, Parameter setzen (Anzahl, Abstand, Ausrichtung), Bestuhlung modifizieren | KI-generierte Bestuhlung entspricht den Parametern; Stuhlanzahl und -anordnung sind korrekt |
| F-AI-04 | **Layer & Bibliothek verwalten:** Der KI Copilot kann Layer erstellen/löschen/umschalten und Blöcke aus der Bibliothek einfügen/organisieren | KI-Befehle für Layer- und Bibliotheksoperationen werden korrekt ausgeführt; Änderungen sind sichtbar |
| F-AI-05 | **Import/Export auslösen:** Der KI Copilot kann Import- und Export-Operationen auslösen (z. B. "Exportiere als DXF", "Importiere die SVG-Datei") | KI-gesteuerter Import/Export funktioniert; Dateien werden korrekt generiert/geladen |
| F-AI-06 | **KI-Architektur von Anfang an:** Die KI-Copilot-Architektur muss von Anfang an in den Core integriert sein keine nachträgliche Anbindung, sondern native Hooks/Schnittstellen | KI-Hooks sind Teil der Core-Architektur; CAD-Operationen sind als "Functions/Tools" registriert und vom KI-System aufrufbar |
| F-AI-07 | **Kontext-Verständnis:** Der KI Copilot versteht den aktuellen Kontext aktuelle Zeichnung, ausgewählte Elemente, aktive Ebene, Zoom-Bereich, Maßstab | KI antwortet kontextbezogen; "Rotiere das um 90 Grad" bezieht sich auf das ausgewählte Element; "Füge hier einen Stuhl hinzu" verwendet Cursor-Position |
| F-AI-08 | **Vorschläge & Empfehlungen:** Der KI Copilot kann Vorschläge machen (z. B. Bestuhlungs-Vorschläge basierend auf Raumgröße, Optimierungsvorschläge für Bestuhlungsanordnung) | KI generiert sinnvolle Vorschläge; Nutzer kann Vorschläge akzeptieren/ablehnen; akzeptierte Vorschläge werden auf Canvas umgesetzt |
| F-AI-09 | **Mehrschritt-Operationen:** Der KI Copilot kann komplexe, mehrschrittige Operationen ausführen (z. B. "Erstelle einen rechteckigen Raum 20x30m und bestuhle ihn mit 10 Reihen à 15 Stühlen") | KI führt alle Teilschritte korrekt aus; Zwischenergebnisse sind sichtbar; Gesamtoperation ist korrekt |
| F-AI-10 | **Fehlerbehandlung & Feedback:** Der KI Copilot gibt klare Fehlermeldungen bei missverständlichen oder nicht ausführbaren Anweisungen und fragt nach | KI gibt verständliche Fehlermeldungen; fragt bei Mehrdeutigkeit nach; schlägt Alternativen vor |
| F-AI-11 | **Undo für KI-Operationen:** Alle KI-gesteuerten Operationen können rückgängig gemacht werden | KI-Operationen appearieren in der Undo-Historie; können einzeln oder als Gruppe rückgängig gemacht werden |
| F-AI-12 | **KI-Modell konfigurierbar:** Das verwendete KI-Modell kann vom Admin über eine OpenAI-kompatible API-URL und API-Key konfiguriert werden (base_url + api_key in Settings) | Admin kann API-URL und API-Key in Einstellungen eintragen; KI-Backend ist nach Konfiguration funktionsfähig |
| F-AI-13 | **OpenAI-kompatibler Endpoint:** KI wird über einen OpenAI-kompatiblen API-Endpoint angebunden funktioniert mit OpenAI, OpenRouter, Ollama (OpenAI-Compat-Modus), vLLM (OpenAI-Compat-Modus) etc. | KI funktioniert mit jedem OpenAI-kompatiblen Endpoint; kein lokales Modell-Deployment nötig; User bringt eigenen Endpoint mit |
| F-AI-14 | **Function Calling / Tool Use:** KI-Backend verwendet Function Calling / Tool Use Pattern CAD-Operationen sind als Functions registriert, die das LLM aufrufen kann | LLM generiert Function Calls; Functions werden ausgeführt; Ergebnisse an LLM zurückgegeben; CAD-Zustand aktualisiert |
| F-AI-15 | **Sicherheits-Guardrails:** KI Copilot kann keine destruktiven Operationen ohne Bestätigung ausführen (z. B. "Lösche alles") | Destruktive Befehle erfordern Bestätigung; Safety-Checks sind implementiert |
### 3.12 Integration & Modularität (API-First)
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| F-INT-01 | **REST-API:** Alle Kern-Funktionen sind über eine REST-API erreichbar (Projekte, Zeichnungen, Layer, Blöcke, Bibliothek, Export/Import) | API-Endpunkte sind dokumentiert (OpenAPI/Swagger); externe Clients können CRUD-Operationen ausführen |
| F-INT-02 | **API-First-Architektur:** Die Software ist API-first designed das Backend ist ein API-Server, die Web-UI ist ein API-Client | Backend-Logik ist vollständig über API nutzbar; UI verwendet dieselbe API wie externe Clients |
| F-INT-03 | **Webhook-Support:** Webhooks für Events (z. B. Zeichnung geändert, Projekt erstellt, Export fertig) | Webhooks können konfiguriert werden; Events werden an registrierte URLs gesendet |
| F-INT-04 | **Modulare Architektur:** Core, Plugins, KI-Backend und UI sind lose gekoppelte Module mit definierten Schnittstellen | Module können unabhängig entwickelt und ausgetauscht werden; Schnittstellen sind dokumentiert |
| F-INT-05 | **Anbindung an andere Software:** Die Software kann an andere Systeme angebunden werden (z. B. Event-Management-Systeme, CRM, Buchhaltung) | Integration via REST-API oder Webhooks ist möglich; Beispiel-Integration dokumentiert |
| F-INT-06 | **Datenbank-Agnostisch:** Persistenz-Schicht ist abstrahiert; Datenbank kann ausgetauscht werden (SQLite default, PostgreSQL/MySQL möglich) | Datenbank-Backend ist konfigurierbar; Wechsel erfordert nur Konfiguration, keinen Code-Change |
| F-INT-07 | **Headless-Modus:** Software kann ohne UI betrieben werden (nur API) nützlich für Automatisierung und Integration | Headless-Modus startet ohne UI; API ist voll funktionsfähig; Automatisierung möglich |
| F-INT-08 | **KI-API:** KI-Copilot-Funktionen sind über die API erreichbar (für externe Automatisierung) | KI-Befehle können via API gesendet werden; Ergebnisse werden als JSON zurückgegeben |
---
## 4. Nicht-funktionale Anforderungen
### 4.1 Performance
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| NF-PERF-01 | **Große Projekte:** Anwendung muss Projekte mit 50.000+ Elementen flüssig darstellen (60fps) | Benchmark: 50.000 Linienelemente werden bei 60fps gerendert (Canvas 2D mit Indexierung oder WebGL) |
| NF-PERF-02 | **Ladezeit:** Initiale Ladezeit < 3 Sekunden bei Standard-Internetverbindung | Lighthouse-Performance-Score >= 80; First Contentful Paint < 1,5s |
| NF-PERF-03 | **Speicher-Effizienz:** Browser-Speicherverbrauch bleibt < 500MB bei 50.000 Elementen | Speicherverbrauch wird überwacht und bleibt unter 500MB |
| NF-PERF-04 | **Kollaborations-Latenz:** Echtzeit-Synchronisation < 1 Sekunde bei normaler Netzwerkverbindung | Latenz wird gemessen und bleibt unter 1 Sekunde |
| NF-PERF-05 | **KI-Antwortzeit:** KI Copilot antwortet < 3 Sekunden auf einfache Befehle | Antwortzeit wird gemessen; einfache Befehle (< 3s); komplexe Operationen zeigen Fortschritt |
### 4.2 Rendering-Technologie (Architektur-Empfehlung Open Source)
Basierend auf Recherche (Stand 2025/2026). Alle Empfehlungen verwenden **ausschließlich Open-Source-Komponenten**.
| Technologie | Einsatzbereich | Begründung | Open-Source-Verfügbarkeit |
|---|---|---|---|
| **Canvas 2D (mit Indexierung & Layer-System)** | Primärer Renderer für 2D-CAD | Kann 50.000+ Elemente bei 60fps rendern; gute Balance aus Performance und Entwicklungs-Aufwand | Browser-Native API (keine Library nötig); rbush (R-Tree, MIT) für räumliche Indexierung |
| **WebGL (optional, Hybrid)** | Performance-Boost für sehr große Szenen (> 100k Elemente) | GPU-Beschleunigung; höhere Komplexität; erst bei Bedarf implementieren | regl (MIT) oder Three.js (MIT) als WebGL-Abstraktion |
| **SVG** | NICHT als primärer Renderer geeignet | DOM-Overhead bei > 3.000-5.000 Elementen; Performance-Einbruch | |
| **WebAssembly** | Berechnungsintensive Operationen (DXF-Parsing, Geometrie-Operationen) | Nahe-native Performance für Parsing und Mathematik | Rust + wasm-bindgen (MIT/Apache); dxf-parser in Rust portierbar |
**Empfehlung:** Canvas 2D mit räumlichem Index (rbush/R-Tree) und Layer-basiertem Rendering als primäre Technologie. WebGL (via regl oder Three.js) als optionale Hybrid-Schicht für extrem große Szenen reservieren.
### 4.3 Kollaborations-Architektur (Open Source)
| Aspekt | Empfehlung | Begründung | Open-Source-Verfügbarkeit |
|---|---|---|---|
| **Synchronisation** | CRDT (Yjs) über WebSocket | Konfliktfreie Synchronisation; bewährt in Figma und modernen kollaborativen Apps | Yjs (MIT), y-websocket (MIT) |
| **Transport** | WebSocket für Dokument-Daten; WebRTC optional für Cursor-Daten | WebSocket: zuverlässig, server-seitig kontrollierbar; WebRTC: niedrigere Latenz | ws (MIT), y-webrtc (MIT) |
| **Persistenz** | Server-seitige Persistenz der CRDT-Dokumente | Single Source of Truth; Versionshistorie; Offline-Sync bei Reconnect | y-leveldb (MIT), IndexedDB client-side |
| **Skalierung** | WebSocket-Server mit Pub/Sub pro Raum (Zeichnung) | Mehrere Zeichnungen parallel; isolierte Räume; horizontale Skalierung möglich | Redis Pub/Sub (BSD) für Multi-Server-Skalierung |
### 4.4 KI-Copilot-Architektur (OpenAI-kompatibler Endpoint)
| Aspekt | Empfehlung | Begründung |
|---|---|---|
| **KI-Anbindung** | OpenAI-kompatibler API-Endpoint (user-provided) | User bringt eigenen Endpoint mit (OpenAI, OpenRouter, Ollama OpenAI-Compat, vLLM OpenAI-Compat, etc.); kein lokales Modell-Deployment nötig |
| **Konfiguration** | base_url + api_key in Admin-Settings | Admin trägt API-URL und API-Key ein; KI ist nach Konfiguration sofort funktionsfähig |
| **Function Calling** | LLM Function Calling / Tool Use Pattern | CAD-Operationen werden als Functions registriert; LLM ruft Functions auf; Ergebnisse zurück an LLM |
| **KI-Transport** | Backend leitet KI-Anfragen an konfigurierten OpenAI-kompatiblen Endpoint weiter | Frontend sendet KI-Anfrage an CAD-Backend; Backend leitet an externen API-Endpoint weiter; LLM generiert Function Calls; Backend führt aus |
| **CAD-Function-Registry** | Zentrales Verzeichnis aller CAD-Operationen als aufrufbare Functions | LLM kann nur registrierte Functions aufrufen; sicher; erweiterbar durch Plugins |
| **Context Injection** | Aktueller CAD-Zustand (Zeichnung, Selektion, Layer, Zoom) wird als Context an LLM gesendet | LLM hat Kontext; kann kontextbezogene Antworten geben |
| **Guardrails** | Safety-Layer für destruktive Operationen | Verhindert ungewolltes Löschen; erfordert Bestätigung |
**Architektur-Pattern:**
```
User Input (Text/Sprache)
→ CAD-Backend leitet an OpenAI-kompatiblen API-Endpoint weiter
→ LLM generiert Function Call (z. B. draw_line(x1, y1, x2, y2))
→ CAD-Function-Registry führt Function aus
→ Canvas aktualisiert sich
→ Ergebnis an LLM zurück ("Linie gezeichnet von A nach B")
→ LLM generiert Bestätigung an User
```
### 4.5 Open-Source-Lizenz & Komponenten
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| NF-OSS-01 | **Open-Source-Lizenz:** web-cad wird als Open Source veröffentlicht (Lizenz: AGPL-3.0 oder MIT, siehe Q-01) | Lizenz-Datei (LICENSE) im Repo; alle Quellen öffentlich |
| NF-OSS-02 | **Nur Open-Source-Komponenten:** Alle verwendeten Libraries, Tools, Frameworks und KI-Modelle müssen Open-Source-Lizenzen haben (MIT, Apache-2.0, BSD, ISC, LGPL, AGPL) | Keine proprietären Abhängigkeiten; Lizenz-Audit in CI/CD |
| NF-OSS-03 | **Lizenz-Kompatibilität:** Alle Abhängigkeiten sind untereinander lizenzkompatibel | Lizenz-Kompatibilitätsanalyse durchgeführt; keine Konflikte |
| NF-OSS-04 | **DXF-Bibliothek:** Open-Source DXF-Parser/Writer verwenden (z. B. dxf-parser, dxf-writer in JS oder Rust) | DXF-Import/Export funktioniert mit Open-Source-Library |
| NF-OSS-05 | **DWG-Bibliothek (Optional):** Falls DWG-Import unterstützt wird, Open-Source-Library verwenden (z. B. libredwg, GNU GPL) | DWG-Import funktioniert mit Open-Source-Library; keine kommerziellen Bibliotheken |
| NF-OSS-06 | **KI-Anbindung offen:** KI wird über einen OpenAI-kompatiblen API-Endpoint angebunden; kein proprietäres KI-Modell fest codiert | Jeder OpenAI-kompatible Endpoint funktioniert (OpenAI, OpenRouter, Ollama, vLLM); User wählt seinen eigenen Provider |
### 4.6 Sicherheit
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| NF-SEC-01 | **Authentifizierung:** E-Mail/Passwort-basierte Authentifizierung (entschieden, siehe Q-02) | Login funktioniert; Passwörter gehasht (bcrypt/argon2); Sessions sicher (HTTP-only Cookies, CSRF-Schutz) |
| NF-SEC-02 | **Autorisierung:** Rollenbasierte Zugriffskontrolle für Projekte und Aktionen | Berechtigungen werden server-seitig enforced |
| NF-SEC-03 | **Daten-Transport:** HTTPS/WSS für gesamte Kommunikation | TLS für alle Verbindungen; keine unverschlüsselte Kommunikation |
| NF-SEC-04 | **Input-Validierung:** Import-Dateien werden validiert (DXF, SVG, PDF) | Maliziöse Dateien werden abgewiesen; Schema-Validierung erfolgt vor Verarbeitung |
| NF-SEC-05 | **KI-Safety:** KI-Copilot-Operationen haben Guardrails (keine destruktiven Operationen ohne Bestätigung) | Safety-Checks verhindern ungewollte Datenlöschung; Bestätigung erforderlich für kritische Operationen |
### 4.7 Deployment
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| NF-DEP-01 | **Docker-Deployment:** Anwendung kann in Docker-Containern betrieben werden | Dockerfile und docker-compose.yml vorhanden und funktionsfähig |
| NF-DEP-02 | **Coolify-Deployment:** Deployment via Coolify ist möglich | Coolify-kompatible Konfiguration vorhanden; Deployment-Dokumentation erstellt |
| NF-DEP-03 | **Umgebungs-Konfiguration:** Konfiguration über Umgebungsvariablen | Keine fest codierten Credentials; alle Secrets über Env-Variablen |
| NF-DEP-04 | **Datenbank:** SQLite als Standard-Datenbank (entschieden, siehe Q-03); Datenbank-Schicht ist abstrahiert für spätere Migration | SQLite funktioniert; Schema definiert; Migration zu PostgreSQL möglich |
| NF-DEP-05 | **KI-Anbindung:** KI wird über externen OpenAI-kompatiblen API-Endpoint angebunden kein separater KI-Container nötig | KI-API-URL und API-Key werden über Umgebungsvariablen konfiguriert; keine lokale KI-Infrastruktur erforderlich |
### 4.8 Plattform & Browser-Kompatibilität
| ID | Anforderung | Akzeptanzkriterium |
|---|---|---|
| NF-PLAT-01 | **Browser-Unterstützung:** Chrome, Firefox, Edge, Safari (aktuelle Versionen) | Anwendung läuft in allen genannten Browsern; keine Browser-spezifischen Fehler |
| NF-PLAT-02 | **WebAssembly-Unterstützung:** Browser muss WebAssembly unterstützen | Fallback-Strategie für Browser ohne WebAssembly oder klare Fehlermeldung |
| NF-PLAT-03 | **Web Speech API:** Browser muss Web Speech API unterstützen (für optionale Voice-Input) | Voice-Input funktioniert in unterstützten Browsern; Fallback auf Text-Eingabe |
---
## 5. Annahmen (Assumptions)
1. **Primär Desktop:** Die Anwendung wird primär auf Desktop-Geräten (≥1280px Breite) verwendet; mobile Unterstützung ist nicht Teil der initialen Version.
2. **Deutsche UI:** Die Benutzeroberfläche wird primär auf Deutsch entwickelt; Mehrsprachigkeit kann später hinzugefügt werden.
3. **Keine 3D:** Die Anwendung ist rein 2D; keine 3D-Modellierung oder 3D-Ansicht.
4. **DXF als primäres Austauschformat:** DXF ist das wichtigste Import/Export-Format für CAD-Interoperabilität; DWG ist optional und von Open-Source-Library-Verfügbarkeit abhängig.
5. **CRDT als Synchronisations-Standard:** CRDT (Yjs) wird als Standard für Echtzeit-Kollaboration angenommen; OT wird nicht verwendet.
6. **Canvas 2D als primärer Renderer:** Canvas 2D mit räumlichem Index (rbush) wird als primäre Rendering-Technologie angenommen; WebGL ist optional.
7. **Einzelner Server-Deployment:** Initiales Deployment ist ein einzelner Server (Docker/Coolify); horizontale Skalierung ist später möglich.
8. **SVG-Import als Bibliothekserweiterung:** SVG-Dateien werden primär für die Bibliothek importiert; SVG als Projekt-Import ist sekundär.
9. **Open Source:** Alle Komponenten sind Open Source; web-cad selbst wird als Open Source lizenziert (AGPL-3.0 oder MIT).
10. **Bestuhlungs-Stühle als Bibliothek-Blöcke:** Stühle für Bestuhlungs-Tools werden als Blöcke in der Bibliothek definiert, nicht fest codiert.
11. **E-Mail/Passwort-Auth:** E-Mail/Passwort ist die Authentifizierungsmethode; keine OAuth2/SSO in der initialen Version.
12. **Universelle Erweiterbarkeit:** Die Plugin-Architektur ist so konzipiert, dass die Software später in jede Richtung erweiterbar ist (neue Branchen, Formate, UI-Komponenten).
13. **SQLite als Standard-Datenbank:** SQLite wird als Standard-Datenbank verwendet; Datenbank-Schicht ist abstrahiert für spätere Migration zu PostgreSQL/MySQL.
14. **API-First-Design:** Die Software ist API-first designed; das Backend ist ein API-Server, die UI ist ein API-Client. Dadurch ist die Software an andere Systeme anhängbar.
15. **KI Copilot als Kern-Feature:** Der KI Copilot ist von Anfang an in die Core-Architektur integriert kein Afterthought. CAD-Operationen sind als Functions registriert, die vom LLM aufgerufen werden können.
16. **OpenAI-kompatibler KI-Endpoint:** KI wird über einen OpenAI-kompatiblen API-Endpoint angebunden (user-provided); kein lokales Modell-Deployment. User bringt eigenen Endpoint mit (OpenAI, OpenRouter, Ollama OpenAI-Compat, vLLM OpenAI-Compat, etc.).
17. **Function Calling als KI-Pattern:** Das LLM verwendet Function Calling / Tool Use, um CAD-Operationen auszulösen. Das LLM generiert keine direkten Canvas-Befehle, sondern ruft registrierte Functions auf.
18. **Modularität:** Core, Plugins, KI-Backend und UI sind lose gekoppelte Module mit definierten Schnittstellen.
---
## 6. Non-Goals (Nicht im Scope der initialen Version)
1. **Keine 3D-Modellierung** rein 2D; keine 3D-Ansicht, keine Höhen, keine 3D-Renderings.
2. **Keine mobile App oder mobile-optimierte UI** Desktop first; mobile wird erst bei Bedarf addressed.
3. **Keine lokalen Druck-/Plot-Konfigurationen** PDF-Export ersetzt direktes Drucken in der ersten Version.
4. **Keine AutoCAD-LISP-/Macro-Skripting-Engine** keine benutzerdefinierten Skripte in LISP/AutoLISP.
5. **Keine parametrische Modellierung** keine Constraint-basierte Geometrie oder parametrischen Relationen.
6. **Keine BIM-Integration** keine IFC-Importe, keine BIM-Datenmodellierung.
7. **Keine Desktop-Installation** rein web-basiert; keine Electron-/NW.js-Desktop-App.
8. **Keine Multi-Tenant-Isolierung auf Organisationsebene** initiale Version ist Single-Tenant; Multi-Tenant später.
9. **Keine erweiterten Druck-Layouts** keine Plot-Styles, Linienstärken-Tabellen oder Print-Studio-Konfigurationen.
10. **Keine Video-/Audio-Kommunikation** keine integrierte Video- oder Audio-Calls zwischen Nutzern.
11. **Keine automatische Flächenberechnung / Mengenermittlung** in der ersten Version nicht enthalten.
12. **Kein OAuth2/OIDC/SSO** E-Mail/Passwort reicht; SSO kann später als Plugin nachgerüstet werden.
13. **Keine AutoCAD-Block-Editor-Äquivalenz** Block-Definitionen können bearbeitet werden, aber kein vollständiger Block-Editor wie in Desktop-AutoCAD.
14. **Keine CAD-Standards-Verwaltung** keine DWS-Dateien oder Standards-Checking wie in Desktop-AutoCAD.
15. **Keine 3D-Viewing** anders als AutoCAD Web (das DWG-3D-Viewing bietet), ist web-cad rein 2D.
16. **Keine autonomes KI-Zeichnen** der KI Copilot führt Befehle auf Anweisung aus, aber zeichnet nicht selbstständig komplexe Zeichnungen ohne User-Anweisung. Autonomes Zeichnen ist ein späteres Feature.
17. **Keine KI-Training/Fine-Tuning** die initialen KI-Modelle werden nicht fine-getuned; Standard-Modelle mit Function Calling werden verwendet.
---
## 7. Entschiedene Fragen (Resolved)
| # | Frage | Antwort | Entscheidung |
|---|---|---|---|
| Q-01 | Soll die Anwendung Open Source oder Closed Source sein? Welche Lizenz? | Open Source | ✅ **Entschieden:** Software wird als Open Source lizenziert (AGPL-3.0 empfohlen für Copyleft-Schutz, MIT als Alternative). Nur komplett offene Komponenten werden verwendet. |
| Q-02 | Welche Authentifizierungsmethode wird bevorzugt? | E-Mail/Passwort | ✅ **Entschieden:** E-Mail/Passwort als Authentifizierung. Keine OAuth2/OIDC/SSO in der initialen Version. SSO kann später als Plugin nachgerüstet werden. |
| Q-03 | Welche Datenbank soll verwendet werden? | SQLite | ✅ **Entschieden:** SQLite als Standard-Datenbank. Datenbank-Schicht ist abstrahiert für spätere Migration. Software ist API-first/modular designed für Integration mit anderer Software. |
| Q-15 | Soll das KI-Backend lokal oder remote laufen? | Remote (OpenAI-kompatibel) | ✅ **Entschieden:** KI wird remote über einen OpenAI-kompatiblen API-Endpoint angebunden. Kein lokales Modell-Deployment (kein Ollama, kein vLLM lokal). User bringt eigenen Endpoint mit. |
| Q-16 | Welche KI-Modellgröße ist akzeptabel? | User-Sache | ✅ **Entschieden:** Modellgröße ist nicht unsere Concern der User bringt seinen eigenen Endpoint mit. Wir binden nur einen OpenAI-kompatiblen API-Call ein. |
---
## 8. Offene Fragen
| # | Frage | Priorität | Auswirkung bei Nicht-Beantwortung |
|---|---|---|---|
| Q-04 | Gibt es eine erwartete Nutzerzahl / Anzahl gleichzeitiger Kollaborateure pro Zeichnung? | Mittel | Skalierungs-Architektur, WebSocket-Server-Design |
| Q-05 | Soll DWG-Import unterstützt werden? Wenn ja, welche DWG-Versionen? | Mittel | Library-Auswahl (libredwg, GNU GPL) |
| Q-06 | Welche Stuhl-Typen / Bibliothekseinträge werden initial benötigt? | Mittel | Initial-Bibliothek, Bestuhlungs-Tool-Design |
| Q-07 | Sollen Bestuhlungs-Tools weitere Event-Elemente unterstützen (Tische, Bühnen, Absperrungen)? | Mittel | Tool-Scope, Bibliothek |
| Q-08 | Ist eine Kommentar-/Annotations-Funktion für Kollaborateure gewünscht? | Niedrig | UI-Design, Kollaborations-Features |
| Q-09 | Sollen Projekte in Ordnern/Projektgruppen organisiert werden können? | Niedrig | Projekt-Management-UI |
| Q-10 | Gibt es Anforderungen an Barrierefreiheit (WCAG 2.1 AA)? | Niedrig | UI-Design, Testing-Aufwand |
| Q-11 | Soll es ein API für externe Integrationen geben (REST/GraphQL)? | Niedrig | Architektur-Erweiterung |
| Q-12 | Welche Maßeinheiten sollen unterstützt werden (metrisch, imperial, beides)? | Niedrig | Bemaßungs-Logic, UI |
| Q-13 | AGPL-3.0 oder MIT als Lizenz? | Niedrig | Lizenz-Datei, Copyleft vs. Permissive |
| Q-14 | Soll der KI Copilot Spracheingabe (Voice) von Anfang an unterstützen oder erst später? | Niedrig | UI-Design, Web Speech API Integration |
---
## 9. Technologie-Recherche & Empfehlungen
### 9.1 Rendering-Technologie
**Recherche-Ergebnisse (Stand 2025/2026):**
- **SVG:** Gut für einfache Grafiken; DOM-Overhead bei > 3.000-5.000 Elementen → Performance-Einbruch. **Nicht geeignet für CAD mit großen Zeichnungen.**
- **Canvas 2D:** Kann 50.000+ Elemente bei 60fps rendern, wenn räumliche Indexierung (Quadtree/R-Tree) und Layer-basiertes Culling eingesetzt werden. **Empfohlen als primärer Renderer.**
- **WebGL:** Höchste Performance für sehr große Szenen (> 100k Elemente); GPU-Beschleunigung; jedoch höherer Entwicklungs-Aufwand. **Als optionale Hybrid-Schicht reservieren.**
- **WebAssembly:** Nahe-native Performance für Berechnungen (DXF-Parsing, geometrische Operationen). **Empfohlen für rechenintensive Aufgaben.**
**Quellen:**
- SVG Genie Blog: SVG vs Canvas vs WebGL Performance Comparison (2026)
- AlterSquare: WebGL vs Canvas for Browser-Based CAD Tools
- Medium (@codetip.top): SVG vs Canvas vs WebGL for Diagram Viewers
- PMC: Cross-Device Benchmark of Modern Web Animation Systems
### 9.2 Multi-User Kollaboration
**Recherche-Ergebnisse (Stand 2025/2026):**
- **CRDT (Conflict-free Replicated Data Types):** Moderner Standard für kollaborative Anwendungen; verwendet von Figma und vielen modernen Apps; leichter korrekt zu implementieren als OT.
- **Yjs (MIT):** Beliebteste CRDT-Bibliothek; unterstützt Text, Arrays, Maps, XML; WebSocket- und WebRTC-Bindings vorhanden.
- **Automerge (MIT):** Alternative CRDT-Bibliothek; ähnliche Features.
- **OT (Operational Transformation):** Älterer Ansatz (Google Docs); komplexer korrekt zu implementieren; erfordert zentrale Server-Logik.
- **Transport:** WebSocket für Dokument-Synchronisation; WebRTC für Cursor-Positionen (niedrigere Latenz).
- **Persistenz:** Server-seitige Speicherung der CRDT-Dokumente für Single Source of Truth und Versionshistorie.
**Empfehlung:** CRDT (Yjs, MIT) über WebSocket als primäre Kollaborations-Architektur.
**Quellen:**
- Medium (toonsquare.tech): Real-Time Collaborative Editor with CRDT and Durable Objects
- Velt Blog: OT vs CRDT in 2026; Yjs WebSocket Server Guide
- Daydreamsoft: Real-Time Collaboration Using CRDTs and OT
- Onshape: Cloud-native CAD Collaboration
### 9.3 KI-Copilot-Architektur
**Recherche-Ergebnisse (basierend auf Known Best Practices und früheren Suchergebnissen zu AutoCAD AI 2026/2027):**
- **AutoCAD 2026/2027 AI Features:** Autodesk integriert KI für automatisches Block-Placement, Dimensioning, Layer-Assignment und Error-Detection mittels Machine Learning. (Quelle: cadcenterhyderabad.com, arkance.us)
- **LLM Function Calling / Tool Use:** Der Standard-Pattern für KI-Copiloten in Software-Anwendungen. Das LLM erhält eine Liste von verfügbaren Functions/Tools und generiert strukturierte Function Calls, die von der Anwendung ausgeführt werden.
- Beispiel: `draw_line(x1=100, y1=200, x2=300, y2=400)` → CAD-Engine führt Funktion aus → Canvas aktualisiert sich
- Vorteil: Sichere, kontrollierte KI-Ausführung; LLM hat keinen direkten Canvas-Zugriff
- Verwendet von: OpenAI (Function Calling), Anthropic (Tool Use), Open-Source-Modelle via Ollama/vLLM
- **Open-Source KI-Backends:**
- **Ollama (MIT):** Lokaler Model-Server; einfach zu deployen; unterstützt Llama, Qwen, DeepSeek; Function Calling via JSON-Format
- **vLLM (Apache-2.0):** Hochperformanter Inference-Server; optimiert für Throughput; unterstützt OpenAI-kompatibles API
- **llama.cpp (MIT):** Minimaler C++-Inference-Server; läuft auf CPU und GPU; für Resource-constrained Environments
- **Open-Source LLM-Modelle mit Function Calling:**
- **Llama 3/4 (Meta, Llama License):** 8B/70B/400B Parameter; gute Function-Calling-Unterstützung
- **Qwen 2.5 (Alibaba, Apache-2.0):** 7B/14B/32B/72B Parameter; explizit für Tool-Use trainiert
- **DeepSeek (MIT):** 7B/67B Parameter; gutes Code-Verständnis; Function-Calling-fähig
- **Context Injection:** Der aktuelle CAD-Zustand (Zeichnung, Selektion, Layer, Zoom, Maßstab) wird als strukturiertes Context-Objekt an das LLM gesendet. Dadurch kann das LLM kontextbezogene Antworten und Function Calls generieren.
- **Mehrschritt-Operationen:** Für komplexe Befehle (z. B. "Erstelle einen Raum und bestuhle ihn") kann das LLM eine Sequenz von Function Calls generieren, die nacheinander ausgeführt werden.
- **Guardrails / Safety:** Ein Safety-Layer prüft jeden Function Call vor der Ausführung. Destruktive Operationen (Löschen aller Elemente, Überschreiben ganzer Zeichnungen) erfordern explizite User-Bestätigung.
**Empfehlung:** OpenAI-kompatibler API-Endpoint (user-provided) als KI-Anbindung. Function Calling Pattern mit zentraler CAD-Function-Registry. Context Injection für Kontext-Verständnis. Guardrails für Safety. Kein lokales Modell-Deployment der User bringt seinen eigenen Endpoint mit (OpenAI, OpenRouter, Ollama OpenAI-Compat, vLLM OpenAI-Compat, etc.).
**Quellen:**
- cadcenterhyderabad.com: AutoCAD 2027 AI Features
- arkance.us: AutoCAD 2026 New Features (BSEARCH, Centerline Layer)
- Ollama Documentation: Function Calling Support
- vLLM Documentation: OpenAI-Compatible API
- Qwen 2.5 Model Card: Tool Use Training
- Allgemeine Best Practices für LLM Function Calling / Tool Use Pattern
### 9.4 Open-Source-Technologie-Stack (Empfehlung)
| Schicht | Empfehlung | Lizenz |
|---|---|---|
| **Frontend-Framework** | React (MIT) oder Vue.js (MIT) oder Svelte (MIT) | MIT |
| **Rendering** | Canvas 2D (native) + rbush (MIT) für räumliche Indexierung | MIT |
| **WebGL (optional)** | regl (MIT) oder Three.js (MIT) | MIT |
| **CRDT/Sync** | Yjs (MIT) + y-websocket (MIT) | MIT |
| **Backend-Framework** | Node.js (Express/Fastify) oder Python (FastAPI) | MIT |
| **WebSocket-Server** | ws (MIT) für Node.js oder websockets (BSD) für Python | MIT/BSD |
| **Datenbank** | SQLite (Public Domain) Standard; PostgreSQL (PostgreSQL License) optional | Open Source |
| **DXF-Parser** | dxf-parser (MIT, JS) oder Rust-Port mit wasm-bindgen | MIT |
| **DWG-Parser (optional)** | libredwg (GPL) | GPL |
| **PDF-Generierung** | pdf-lib (MIT) oder jsPDF (MIT) | MIT |
| **SVG-Verarbeitung** | Native Browser-API oder svg.js (MIT) | MIT |
| **Auth** | bcrypt (Apache) oder argon2 (MIT/CDDL) | Open Source |
| **KI-Anbindung** | OpenAI-kompatibler API-Endpoint (user-provided) funktioniert mit OpenAI, OpenRouter, Ollama (OpenAI-Compat), vLLM (OpenAI-Compat) | User-provided |
| **KI-Client** | OpenAI SDK (MIT) oder native fetch/axios | MIT |
| **API-Dokumentation** | OpenAPI/Swagger (Apache-2.0) | Apache |
| **Containerisierung** | Docker (Apache 2.0) | Open Source |
| **Deployment** | Coolify (AGPL-3.0) | Open Source |
### 9.5 Architektur-Referenzen
- **AutoCAD Web:** Browser-basierte CAD-Oberfläche mit streamlined UI, Side-Panel, Command Line, Blocks/Layers-Tabs. Siehe Section 12 für detaillierte Feature-Referenz.
- **AutoCAD 2026/2027 AI:** KI-Features für Block-Placement, Dimensioning, Layer-Assignment, Error-Detection.
- **Onshape:** Cloud-native CAD mit echter Multi-User-Kollaboration; Single Source of Truth; keine Datei-Kopien.
- **Figma:** Browser-basierte Design-Tool mit CRDT-basierter Kollaboration; inspirierend für UI und Sync-Architektur.
- **xDraftSight:** Cloud-basierter 2D-CAD; browser-basierte Architektur.
---
## 10. Abhängigkeiten & Risiken
| Risiko | Beschreibung | Mitigation |
|---|---|---|
| DXF/DWG-Kompatibilität | DXF-Parser muss verschiedene DXF-Versionen korrekt verarbeiten | Open-Source-Library (dxf-parser, MIT) verwenden; Tests mit realen DXF-Dateien |
| Performance bei großen Zeichnungen | 50k+ Elemente können Browser überlasten | Canvas 2D mit räumlichem Index (rbush); Viewport-Culling; Virtualisierte Layer |
| Kollaborations-Komplexität | CRDT-Synchronisation für komplexe CAD-Daten ist nicht trivial | Yjs als bewährte Bibliothek; inkrementelle Implementierung; erst einfache Operationen, dann komplexe |
| Open-Source-DWG-Support | libredwg (GPL) hat eingeschränkte DWG-Version-Unterstützung | DWG als optional markieren; DXF als primäres Format |
| Browser-Speicherlimit | Sehr große Projekte können Browser-Speicherlimit überschreiten | Lazy-Loading; Kompression; IndexedDB für Persistenz |
| Plugin-System-Komplexität | Universelles Plugin-System kann Core-Stabilität gefährden | Plugin-Isolation (Sandbox/try-catch); Plugin-Manifest mit deklarativen Abhängigkeiten |
| KI-Fehlerhafte Befehle | LLM könnte fehlerhafte oder unerwartete Function Calls generieren | Guardrails; Function-Validation vor Ausführung; Bestätigung für destruktive Operationen |
| KI-Latenz | LLM-Inferenz beim externen API-Provider kann mehrere Sekunden dauern | Streaming-Responses; Fortschrittsanzeige; Timeout-Handling;Fallback auf manuelle Bedienung |
| KI-API-Verfügbarkeit | Externer KI-API-Endpoint kann nicht erreichbar sein (Netzwerk, Wartung, Rate-Limit) | Klare Fehlermeldungen; Retry-Logik; CAD bleibt ohne KI voll nutzbar |
| KI-Kontext-Größe | CAD-Zustand kann groß sein (viele Elemente); LLM-Context-Limit begrenzt | Context-Summary statt vollständiger Zeichnung; nur relevante Ausschnitte senden |
---
## 11. Deployment-Erwartungen
| Aspekt | Erwartung |
|---|---|
| **Containerisierung** | Docker (Dockerfile + docker-compose.yml) CAD-Backend + Frontend (2 Container) |
| **Orchestrierung** | Coolify auf coolify-01 (46.225.91.159) |
| **Domain** | TBD z. B. cad.media-on.de |
| **SSL** | Let's Encrypt via Coolify/Traefik |
| **Datenbank** | SQLite (Standard); Datenbank-Schicht abstrahiert für Migration |
| **KI-Anbindung** | OpenAI-kompatibler API-Endpoint (extern) kein separater KI-Container; API-URL + API-Key über Umgebungsvariablen |
| **WebSocket-Server** | Separater Container oder integriert; WebSocket-Proxy via Traefik |
| **Persistenz** | Docker-Volume für Datenbank und Datei-Storage (Grundrisse, Bibliothek) |
| **Skalierung** | Initiale Version: Einzelner Server; später horizontal skalierbar |
---
## 12. AutoCAD Web Feature-Referenz
Diese Sektion dokumentiert die recherchierten Features von AutoCAD Web App als Vergleichsbasis für web-cad.
### 12.1 CAD-Grundfunktionen in AutoCAD Web
Basierend auf Recherche (Scan2CAD Review, Autodesk Produktseiten, Softonic Review, Reddit/Diskussionen):
| Kategorie | Verfügbare Tools/Features in AutoCAD Web |
|---|---|
| **Zeichnen (Draw)** | Polyline (PLINE), Line, Circle, Arc, Rectangle, Polygon |
| **Bearbeiten (Modify)** | Move, Offset, Mirror, Rotate, Trim, Extend, Copy, Scale, Fillet |
| **Bemaßung (Dimensioning)** | DIM (Linear, Aligned, Radial), MLEADER (Multi-Leader) |
| **Annotation/Text** | MTEXT (Multiline Text), REVCLOUD (Revision Cloud), Text |
| **Raster & Fang (Snap)** | Object Snaps (Endpoint, Midpoint, Intersection), Snap Overrides, Polar Tracking, Ortho |
| **Auswahl (Selection)** | Einzelauswahl, Fenster-Auswahl, Kreuz-Auswahl |
| **Blocks** | Insert (aus Blocks-Tab), Create, Edit (via Command Line) |
| **Layers** | Layer-Verwaltung (On/Off, Lock/Unlock, Farbe, Linientyp) |
| **XREFs** | External References werden unterstützt (im selben Ordner wie Parent-File) |
| **Command Line** | Vollständige Command-Line-Eingabe wie Desktop-AutoCAD |
| **UI-Elemente** | Side Panel (Commands), Blocks Tab, Layers Tab, Command Line, Status Bar |
### 12.2 AutoCAD Web UI-Struktur
| UI-Element | Beschreibung |
|---|---|
| **Streamlined UI** | Reduzierte, fokussierte Oberfläche für 2D-Drafting weniger überladen als Desktop |
| **Side Panel** | Seitliches Panel mit Befehls-Auswahl (Alternative zur Command Line) |
| **Command Line** | Befehlseingabe via Tastatur (wie Desktop-AutoCAD) |
| **Blocks Tab** | Tab im Side Panel zum Einfügen von Blöcken |
| **Layers Tab** | Tab im Side Panel für Layer-Verwaltung |
| **Canvas Area** | Haupt-Zeichenbereich mit Zoom/Pan |
| **Status Bar** | Status-Anzeige (Snap, Ortho, etc.) |
| **Ribbon** | Begrenzte Ribbon-Elemente (weniger als Desktop) |
### 12.3 AutoCAD Web Limitierungen (vs. Desktop AutoCAD)
| Limitierung | Beschreibung |
|---|---|
| **Kein Block Editor** | Kein vollständiger Block-Editor wie in Desktop; Block-Bearbeitung nur via Command Line |
| **Keine Tool Palettes** | Keine Tool-Paletten wie in Desktop-AutoCAD |
| **Kein LISP/AutoLISP** | Keine LISP- oder AutoLISP-Skripting-Unterstützung |
| **Keine Macros** | Keine Macro-Aufzeichnung oder -Wiedergabe |
| **Keine 3D-Bearbeitung** | Fokus auf 2D-Drafting; keine 3D-Modellierung oder -Bearbeitung |
| **Begrenzte 3D-Viewing** | DWG-3D-Viewing ist möglich, aber kein 3D-Editing |
| **Keine CAD Standards** | Keine DWS-Dateien oder Standards-Checking |
| **Keine erweiterten Plot-Konfigurationen** | Begrenzte Plot-/Druck-Optionen vs. Desktop |
| **Begrenzte Datei-Formate** | Primär DWG; DXF-Support begrenzt; keine direkten DGN/DWF-Exporte |
| **Keine Parametric Constraints** | Keine parametrischen Constraints (geometrische oder dimensionale) |
| **Keine Express Tools** | Keine Express-Tools-Sammlung wie in Desktop |
| **Begrenzte Anpassung** | Keine CUI-Anpassung (Custom User Interface) |
| **Freie Version limitiert** | Free-Version kann nur DWG-Dateien öffnen/anzeigen; Editieren erfordert Subscription |
| **Schwergewichtige Tasks** | Major drafting/editing-heavy lifting erfordert weiterhin Desktop |
### 12.4 AutoCAD Web Import/Export-Formate
| Format | Support | Bemerkung |
|---|---|---|
| **DWG** | ✅ Import & Export | Primäres Format; Vollunterstützung |
| **DXF** | ⚠️ Begrenzt | DXF-Support vorhanden aber weniger umfassend als Desktop |
| **PDF** | ✅ Export | PDF-Export unterstützt |
| **DGN** | ❌ Nicht unterstützt | Kein DGN-Import/Export in Web-Version |
| **DWF** | ❌ Begrenzt | Kein direkter DWF-Export in Web-Version |
| **SVG** | ❌ Nicht unterstützt | Kein SVG-Support in AutoCAD Web |
### 12.5 Vergleich: AutoCAD Web vs. web-cad (Target)
| Feature | AutoCAD Web | web-cad (Target) | Vorteil web-cad |
|---|---|---|---|
| **Rendering** | Proprietär (Autodesk) | Canvas 2D + rbush (Open Source) | Open Source, kontrollierbar |
| **Kollaboration** | Begrenzt (Autodesk Docs) | CRDT (Yjs) Echtzeit-Kollaboration | Echte Echtzeit-Kollaboration |
| **KI Copilot** | ⚠️ Begrenzt (AutoCAD 2026 AI) | ✅ Vollständiger KI Copilot mit Function Calling | Offen, steuerbar, alle CAD-Funktionen |
| **LISP/Scripting** | ❌ Nicht unterstützt | ❌ Non-Goal (initial) | |
| **Plugin-System** | ❌ Nicht verfügbar | ✅ Vollständiges Plugin/Tool-System | Erweiterbar in jede Richtung |
| **SVG-Import** | ❌ Nicht unterstützt | ✅ Vollunterstützt | Bibliothek-Erweiterung |
| **DXF-Support** | ⚠️ Begrenzt | ✅ Vollunterstützt (Open Source) | Vollständiger DXF-Support |
| **DWG-Support** | ✅ Vollunterstützt | ⚠️ Optional (libredwg, GPL) | AutoCAD Web überlegen hier |
| **Bestuhlungs-Tools** | ❌ Nicht verfügbar | ✅ Reihen & Block-Bestuhlung | Branchen-spezifisch |
| **API-First** | ❌ Keine öffentliche API | ✅ REST-API, API-first design | An andere Software anhängbar |
| **Lizenz** | Proprietär (Subscription) | Open Source (AGPL-3.0/MIT) | Frei nutzbar |
| **Deployment** | Autodesk Cloud | Docker/Coolify (self-hosted) | Self-hosted, volle Kontrolle |
| **UI** | Streamlined, Side Panel | AutoCAD Web-inspiriert + erweitert | Bekannte UI + mehr Features |
| **Datenbank** | Proprietär (Autodesk Cloud) | SQLite (Public Domain) | Self-hosted, kontrollierbar |
| **Mehrsprachigkeit** | ✅ Mehrere Sprachen | ⚠️ Deutsch first, i18n später | |
### 12.6 Quellen AutoCAD Web Referenz
- Scan2CAD: AutoCAD Web App Review (https://www.scan2cad.com/blog/cad/autocad-web-app/)
- Autodesk: AutoCAD Web Features (https://www.autodesk.com/products/autocad-web/features)
- Autodesk: AutoCAD Web FAQ (https://help.autodesk.com/view/ACADWEB/ENU/)
- Interscale: What is AutoCAD Web App & How It Differs (https://interscale.com.au/blog/autocad-web-app/)
- Softonic: AutoCAD Web Download Review
- Reddit: r/AutoCAD Diskussionen über Web-Version Limitierungen
- Autodesk: AutoCAD Web Overview (https://www.autodesk.com/products/autocad-web/overview)
- cadcenterhyderabad.com: AutoCAD 2027 AI Features
- arkance.us: AutoCAD 2026 New Features
---
## 13. Handoff
### Requirements Status
- **Draft v4 erstellt:** Ja (2026-06-19)
- **Entschiedene Fragen:** Q-01 (Open Source) ✅, Q-02 (E-Mail/Passwort) ✅, Q-03 (SQLite + API-first) ✅, Q-15 (Remote/OpenAI-kompatibel) ✅, Q-16 (User-Sache) ✅
- **Testbare Anforderungen:** Ja alle funktionalen und nicht-funktionalen Anforderungen haben konkrete Akzeptanzkriterien
- **Akzeptanzkriterien konkret:** Ja
- **Assumptions:** 18 dokumentiert (erweitert um OpenAI-kompatiblen KI-Endpoint)
- **Non-Goals:** 17 dokumentiert
- **Offene Fragen:** 7 (0 hoch, 3 mittel, 4 niedrig priorisiert)
- **AutoCAD Web Referenz:** Vollständige Feature-Analyse inkl. Limitierungen und Vergleich
- **Open-Source-Stack:** Vollständiger Stack mit Lizenzen (KI-Anbindung: OpenAI-kompatibler Endpoint, user-provided)
- **KI Copilot:** 15 detaillierte Anforderungen (F-AI-01 bis F-AI-15) mit Function Calling Pattern, Guardrails, OpenAI-kompatibler Endpoint
- **Integration/Modularität:** 8 Anforderungen (F-INT-01 bis F-INT-08) für API-first, REST-API, Webhooks, Headless-Modus
- **Plugin-System:** 8 Anforderungen (F-EXT-01 bis F-EXT-08) für universelle Erweiterbarkeit
### Ready for Architecture
- **Ja** alle hoch- und mittel-priorisierten Fragen geklärt; Architektur-Phase kann starten
- KI-Copilot-Architektur: OpenAI-kompatibler API-Endpoint + Function Calling + CAD-Function-Registry
- API-First-Design ist als Architektur-Vorgabe definiert
- Deployment: 2 Docker-Container (CAD-Backend + Frontend); KI ist externer API-Call
- Verbleibende offene Fragen (Q-04 bis Q-14) sind mittel/niedrig priorisiert und können während oder nach der Architektur-Phase geklärt werden
- Technologie-Empfehlungen stehen als Architektur-Vorgaben bereit
### Empfohlene nächste Schritte
1. User-Review der aktualisierten requirements.md (v4)
2. Freigabe für Architektur-Phase (Phase 2)
3. Delegation an Solution Architect mit Vorgaben: Open-Source-Stack, KI-Copilot (OpenAI-kompatibler Endpoint + Function Calling), API-First, Plugin-System, 2 Container
+1
View File
@@ -0,0 +1 @@
dmVyc2lvbjogJzMuOCcKCnNlcnZpY2VzOgogIGJhY2tlbmQ6CiAgICBidWlsZDogLi9iYWNrZW5kCiAgICBwb3J0czoKICAgICAgLSAiNTAwMDo1MDAwIgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIEpXVF9TRUNSRVQ9ZjJiMjI3MWU1OWJmY2MxNWMxZjE3MzI4YjUxMGRiMDg2NjIwNTJjZGRhMDJkYjk4NWM5NDhkOWY1MzJhMjgyMgogICAgdm9sdW1lczoKICAgICAgLSBzcWxpdGVfZGF0YTovYXBwL2RhdGEKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogWyJDTUQtU0hFTEwiLCAid2dldCAtcU8tIGh0dHA6Ly9sb2NhbGhvc3Q6NTAwMC9hcGkvaGVhbHRoIHx8IGV4aXQgMSJdCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQoKICBmcm9udGVuZDoKICAgIGJ1aWxkOiAuL2Zyb250ZW5kCiAgICBleHBvc2U6CiAgICAgIC0gIjgwIgogICAgZGVwZW5kc19vbjoKICAgICAgLSBiYWNrZW5kCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6IFsiQ01ELVNIRUxMIiwgIndnZXQgLXFPLSBodHRwOi8vbG9jYWxob3N0OjgwIHx8IGV4aXQgMSJdCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQoKdm9sdW1lczoKICBzcWxpdGVfZGF0YToK
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
*.md
*.log
+10
View File
@@ -0,0 +1,10 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web CAD</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
resolver 127.0.0.11 valid=30s;
location / { try_files $uri $uri/ /index.html; }
location /api/ {
set $backend http://backend:3001;
proxy_pass $backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /ws {
set $backend http://backend:3001;
proxy_pass $backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
+3299
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "web-cad-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"dxf-parser": "^1.1.2",
"pdf-lib": "^1.17.1",
"rbush": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"y-websocket": "^2.0.0",
"yjs": "^13.6.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/rbush": "^4.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^29.1.1",
"typescript": "^5.5.0",
"vite": "^5.4.0",
"vitest": "^2.0.0"
}
}
+1089
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
import type { CADLayer } from '../types/cad.types';
export class LayerManager {
private layers: Map<string, CADLayer> = new Map();
private activeLayerId: string = '';
addLayer(layer: CADLayer): void {
this.layers.set(layer.id, layer);
if (!this.activeLayerId) this.activeLayerId = layer.id;
}
removeLayer(id: string): void {
this.layers.delete(id);
if (this.activeLayerId === id) {
const first = this.layers.keys().next();
this.activeLayerId = first.done ? '' : first.value;
}
}
getLayer(id: string): CADLayer | undefined {
return this.layers.get(id);
}
getLayers(): CADLayer[] {
return Array.from(this.layers.values()).sort((a, b) => a.sortOrder - b.sortOrder);
}
getVisibleLayers(): CADLayer[] {
return this.getLayers().filter(l => l.visible && !l.locked);
}
setActiveLayer(id: string): void {
if (this.layers.has(id)) this.activeLayerId = id;
}
getActiveLayer(): CADLayer | undefined {
return this.layers.get(this.activeLayerId);
}
getActiveLayerId(): string {
return this.activeLayerId;
}
toggleVisibility(id: string): void {
const layer = this.layers.get(id);
if (layer) layer.visible = !layer.visible;
}
toggleLock(id: string): void {
const layer = this.layers.get(id);
if (layer) layer.locked = !layer.locked;
}
clear(): void {
this.layers.clear();
this.activeLayerId = '';
}
/**
* Rename a layer.
*/
renameLayer(id: string, name: string): void {
const layer = this.layers.get(id);
if (layer) layer.name = name;
}
/**
* Update layer properties.
*/
updateLayer(id: string, props: Partial<CADLayer>): void {
const layer = this.layers.get(id);
if (layer) {
Object.assign(layer, props);
}
}
/**
* Set parent for a layer (tree structure).
*/
setParent(id: string, parentId: string | null): void {
const layer = this.layers.get(id);
if (!layer) return;
// Prevent circular references
if (parentId) {
let current: string | null = parentId;
while (current) {
if (current === id) return; // Would create a cycle
const parent = this.layers.get(current);
if (!parent) break;
current = parent.parentId;
}
}
layer.parentId = parentId;
}
/**
* Get child layers of a parent.
*/
getChildLayers(parentId: string | null): CADLayer[] {
return this.getLayers().filter(l => l.parentId === parentId);
}
/**
* Get layer tree structure.
*/
getLayerTree(): Array<CADLayer & { children: CADLayer[] }> {
const buildTree = (parentId: string | null): Array<CADLayer & { children: CADLayer[] }> => {
return this.getChildLayers(parentId).map(layer => ({
...layer,
children: buildTree(layer.id),
}));
};
return buildTree(null);
}
/**
* Move layer to a new position in the sort order.
*/
moveLayer(id: string, newSortOrder: number): void {
const layer = this.layers.get(id);
if (!layer) return;
layer.sortOrder = newSortOrder;
}
/**
* Duplicate a layer with all its properties.
*/
duplicateLayer(id: string): CADLayer | null {
const layer = this.layers.get(id);
if (!layer) return null;
const newId = `layer_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const copy: CADLayer = {
...layer,
id: newId,
name: `${layer.name} (Kopie)`,
sortOrder: layer.sortOrder + 1,
parentId: layer.parentId,
};
this.layers.set(newId, copy);
return copy;
}
/**
* Filter layers by criteria.
*/
filterLayers(criteria: {
visible?: boolean;
locked?: boolean;
color?: string;
lineType?: string;
nameContains?: string;
}): CADLayer[] {
return this.getLayers().filter(l => {
if (criteria.visible !== undefined && l.visible !== criteria.visible) return false;
if (criteria.locked !== undefined && l.locked !== criteria.locked) return false;
if (criteria.color && l.color !== criteria.color) return false;
if (criteria.lineType && l.lineType !== criteria.lineType) return false;
if (criteria.nameContains && !l.name.toLowerCase().includes(criteria.nameContains.toLowerCase())) return false;
return true;
});
}
/**
* Get all descendant layer IDs (children, grandchildren, etc.).
*/
getDescendantIds(id: string): string[] {
const result: string[] = [];
const collect = (parentId: string) => {
for (const layer of this.layers.values()) {
if (layer.parentId === parentId) {
result.push(layer.id);
collect(layer.id);
}
}
};
collect(id);
return result;
}
/**
* Check if a layer is locked.
*/
isLocked(id: string): boolean {
const layer = this.layers.get(id);
return layer ? layer.locked : false;
}
/**
* Get layer color.
*/
getLayerColor(id: string): string | undefined {
const layer = this.layers.get(id);
return layer?.color;
}
}
+985
View File
@@ -0,0 +1,985 @@
import type {
CADElement, CADLayer, CADProperties, BoundingBox, Viewport,
} from '../types/cad.types';
import { ZoomPanController } from './ZoomPanController';
import { SpatialIndex } from './SpatialIndex';
import { LayerManager } from './LayerManager';
import { pluginRegistry } from '../plugins';
export interface RenderOptions {
showGrid: boolean;
gridSize: number;
showSnapPoints: boolean;
showOrtho: boolean;
orthoAngle: number;
backgroundSrc?: string;
backgroundScale: number;
backgroundOffsetX: number;
backgroundOffsetY: number;
backgroundRotation: number;
backgroundOpacity: number;
}
export interface SelectionState {
selectedIds: Set<string>;
hoverId: string | null;
boxStart: { x: number; y: number } | null;
boxEnd: { x: number; y: number } | null;
}
export interface SnapPoint {
x: number;
y: number;
type: 'endpoint' | 'midpoint' | 'center' | 'intersection' | 'nearest';
}
export class RenderEngine {
private ctx: CanvasRenderingContext2D;
private canvas: HTMLCanvasElement;
private zoomPan: ZoomPanController;
private spatialIndex: SpatialIndex;
private layerManager: LayerManager;
private options: RenderOptions;
private selection: SelectionState;
private snapPoints: SnapPoint[] = [];
private activeSnapPoint: SnapPoint | null = null;
private previewElement: CADElement | null = null;
private dpr = 1;
constructor(
canvas: HTMLCanvasElement,
zoomPan: ZoomPanController,
spatialIndex: SpatialIndex,
layerManager: LayerManager,
) {
this.canvas = canvas;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D context not available');
this.ctx = ctx;
this.zoomPan = zoomPan;
this.spatialIndex = spatialIndex;
this.layerManager = layerManager;
this.options = {
showGrid: true,
gridSize: 20,
showSnapPoints: false,
showOrtho: false,
orthoAngle: 0,
backgroundScale: 1,
backgroundOffsetX: 0,
backgroundOffsetY: 0,
backgroundRotation: 0,
backgroundOpacity: 0.5,
};
this.selection = {
selectedIds: new Set(),
hoverId: null,
boxStart: null,
boxEnd: null,
};
}
setOptions(opts: Partial<RenderOptions>): void {
this.options = { ...this.options, ...opts };
}
getOptions(): RenderOptions {
return { ...this.options };
}
setSelection(sel: Partial<SelectionState>): void {
this.selection = { ...this.selection, ...sel };
}
getSelection(): SelectionState {
return { ...this.selection };
}
setSnapPoints(points: SnapPoint[]): void {
this.snapPoints = points;
}
setActiveSnapPoint(pt: SnapPoint | null): void {
this.activeSnapPoint = pt;
}
setPreviewElement(el: CADElement | null): void {
this.previewElement = el;
}
resize(width: number, height: number): void {
this.dpr = window.devicePixelRatio || 1;
this.canvas.width = width * this.dpr;
this.canvas.height = height * this.dpr;
this.canvas.style.width = width + 'px';
this.canvas.style.height = height + 'px';
this.ctx.scale(this.dpr, this.dpr);
}
setLayers(layers: CADLayer[]): void {
this.layerManager.clear();
for (const layer of layers) {
this.layerManager.addLayer(layer);
}
}
private blockDefinitions: Map<string, { elements: CADElement[] }> = new Map();
setBlockDefinitions(blocks: Array<{ id: string; elements: CADElement[] }>): void {
this.blockDefinitions.clear();
for (const b of blocks) {
this.blockDefinitions.set(b.id, b);
}
}
render(): void {
const w = this.canvas.width / this.dpr;
const h = this.canvas.height / this.dpr;
this.ctx.save();
this.ctx.fillStyle = '#1e1e2e';
this.ctx.fillRect(0, 0, w, h);
if (this.options.showGrid) this.drawGrid(w, h);
if (this.options.backgroundSrc) this.drawBackground();
const viewport = this.zoomPan.getViewport();
const visibleElements = this.spatialIndex.search({
minX: viewport.minX, minY: viewport.minY,
maxX: viewport.maxX, maxY: viewport.maxY,
});
const layers = this.layerManager.getLayers();
for (const layer of layers) {
if (!layer.visible) continue;
const els = visibleElements.filter(e => e.layerId === layer.id && e.properties?.visible !== false);
for (const el of els) {
this.drawElement(el, layer);
}
}
// Draw preview element with dashed style
if (this.previewElement) {
this.ctx.save();
this.ctx.setLineDash([6, 4]);
const previewEl: CADElement = { ...this.previewElement, properties: { ...this.previewElement.properties, stroke: '#00aaff', strokeWidth: 2 } };
const previewLayer: CADLayer = { id: '__preview__', name: 'Preview', visible: true, locked: false, color: '#00aaff', lineType: 'solid', transparency: 0, sortOrder: 999, parentId: null };
this.drawElement(previewEl, previewLayer);
this.ctx.restore();
}
this.drawSelectionBox();
if (this.options.showSnapPoints) this.drawSnapPoints();
if (this.options.showOrtho) this.drawOrtho();
this.ctx.restore();
}
private drawGrid(w: number, h: number): void {
const scale = this.zoomPan.getScale();
const gridSize = this.options.gridSize * scale;
if (gridSize < 4) return;
const offsetX = this.zoomPan.getTransform().e % gridSize;
const offsetY = this.zoomPan.getTransform().f % gridSize;
this.ctx.strokeStyle = '#2a2a3e';
this.ctx.lineWidth = 1;
this.ctx.beginPath();
for (let x = offsetX; x < w; x += gridSize) {
this.ctx.moveTo(x, 0);
this.ctx.lineTo(x, h);
}
for (let y = offsetY; y < h; y += gridSize) {
this.ctx.moveTo(0, y);
this.ctx.lineTo(w, y);
}
this.ctx.stroke();
// Major grid lines every 5 cells
const majorSize = gridSize * 5;
if (majorSize >= 20) {
const majOffX = this.zoomPan.getTransform().e % majorSize;
const majOffY = this.zoomPan.getTransform().f % majorSize;
this.ctx.strokeStyle = '#33334a';
this.ctx.beginPath();
for (let x = majOffX; x < w; x += majorSize) {
this.ctx.moveTo(x, 0);
this.ctx.lineTo(x, h);
}
for (let y = majOffY; y < h; y += majorSize) {
this.ctx.moveTo(0, y);
this.ctx.lineTo(w, y);
}
this.ctx.stroke();
}
}
private drawBackground(): void {
// Background image rendering with transform
const img = new Image();
img.src = this.options.backgroundSrc!;
if (!img.complete) {
img.onload = () => this.render();
return;
}
this.ctx.save();
this.ctx.globalAlpha = this.options.backgroundOpacity;
const s = this.zoomPan.getScale();
const ox = this.zoomPan.getTransform().e;
const oy = this.zoomPan.getTransform().f;
const bx = this.options.backgroundOffsetX * s + ox;
const by = this.options.backgroundOffsetY * s + oy;
const bw = img.width * this.options.backgroundScale * s;
const bh = img.height * this.options.backgroundScale * s;
this.ctx.translate(bx + bw / 2, by + bh / 2);
this.ctx.rotate(this.options.backgroundRotation);
this.ctx.drawImage(img, -bw / 2, -bh / 2, bw, bh);
this.ctx.restore();
}
private drawElement(el: CADElement, layer: CADLayer): void {
const ctx = this.ctx;
const s = this.zoomPan.getScale();
const ox = this.zoomPan.getTransform().e;
const oy = this.zoomPan.getTransform().f;
const sx = (val: number) => val * s + ox;
const sy = (val: number) => val * s + oy;
const stroke = el.properties.stroke || layer.color;
const strokeWidth = (el.properties.strokeWidth || 1) * s;
const fill = el.properties.fill;
const isSelected = this.selection.selectedIds.has(el.id);
const isHover = this.selection.hoverId === el.id;
ctx.save();
ctx.strokeStyle = stroke;
ctx.lineWidth = Math.max(0.5, strokeWidth);
if (layer.lineType === 'dashed') ctx.setLineDash([8, 4]);
if (layer.lineType === 'dotted') ctx.setLineDash([2, 4]);
if (isSelected) {
ctx.strokeStyle = '#00aaff';
ctx.lineWidth = Math.max(1, strokeWidth + 1);
} else if (isHover) {
ctx.strokeStyle = '#ffaa00';
}
switch (el.type) {
case 'line': this.drawLine(el, sx, sy); break;
case 'rect': this.drawRect(el, sx, sy); break;
case 'circle': this.drawCircle(el, sx, sy, s); break;
case 'arc': this.drawArc(el, sx, sy, s); break;
case 'polyline': this.drawPolyline(el, sx, sy); break;
case 'polygon': this.drawPolygon(el, sx, sy, fill); break;
case 'text': this.drawText(el, sx, sy, s); break;
case 'dimension': this.drawDimension(el, sx, sy, s); break;
case 'block_instance': this.drawBlockInstance(el, sx, sy, s); break;
case 'chair': this.drawChair(el, sx, sy, s); break;
case 'table': this.drawTable(el, sx, sy, s); break;
case 'stage': this.drawStage(el, sx, sy, s); break;
case 'leader': this.drawLeader(el, sx, sy, s); break;
case 'revcloud': this.drawRevCloud(el, sx, sy, s); break;
default: {
// Plugin element types
const ext = pluginRegistry.getElementType(el.type);
if (ext?.render) {
ext.render(this.ctx, el, s);
}
break;
}
}
if (isSelected) this.drawSelectionHandles(el, sx, sy, s);
ctx.restore();
}
private drawLine(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number): void {
const p = el.properties;
this.ctx.beginPath();
this.ctx.moveTo(sx(p.x1 ?? el.x), sy(p.y1 ?? el.y));
this.ctx.lineTo(sx(p.x2 ?? el.x + el.width), sy(p.y2 ?? el.y + el.height));
this.ctx.stroke();
}
private drawRect(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number): void {
const x = sx(el.x - el.width / 2);
const y = sy(el.y - el.height / 2);
const w = el.width * (this.zoomPan.getScale());
const h = el.height * (this.zoomPan.getScale());
if (el.properties.fill) {
this.ctx.fillStyle = el.properties.fill;
this.ctx.fillRect(x, y, w, h);
}
if (el.properties.hatch) {
this.drawHatchRect(x, y, w, h, (el.properties.hatchSpacing as number) || 8);
}
this.ctx.strokeRect(x, y, w, h);
}
private drawHatchRect(x: number, y: number, w: number, h: number, spacing: number): void {
this.ctx.save();
this.ctx.beginPath();
this.ctx.rect(x, y, w, h);
this.ctx.clip();
this.ctx.strokeStyle = this.ctx.strokeStyle || '#888';
this.ctx.lineWidth = 0.5;
for (let i = -h; i < w + h; i += spacing) {
this.ctx.beginPath();
this.ctx.moveTo(x + i, y);
this.ctx.lineTo(x + i + h, y + h);
this.ctx.stroke();
}
this.ctx.restore();
}
private drawCircle(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const r = (el.properties.radius || el.width / 2) * s;
this.ctx.beginPath();
this.ctx.arc(sx(el.x), sy(el.y), Math.max(0.5, r), 0, Math.PI * 2);
if (el.properties.fill) {
this.ctx.fillStyle = el.properties.fill;
this.ctx.fill();
}
if (el.properties.hatch) {
this.ctx.save();
this.ctx.clip();
const cx = sx(el.x);
const cy = sy(el.y);
const spacing = (el.properties.hatchSpacing as number) || 8;
this.ctx.lineWidth = 0.5;
for (let i = -r; i < r * 2; i += spacing) {
this.ctx.beginPath();
this.ctx.moveTo(cx - r + i, cy - r);
this.ctx.lineTo(cx - r + i + r * 2, cy + r);
this.ctx.stroke();
}
this.ctx.restore();
}
this.ctx.stroke();
}
private drawArc(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const r = (el.properties.radius || el.width / 2) * s;
const start = (el.properties.startAngle || 0) * Math.PI / 180;
const end = (el.properties.endAngle || 360) * Math.PI / 180;
this.ctx.beginPath();
this.ctx.arc(sx(el.x), sy(el.y), Math.max(0.5, r), start, end);
this.ctx.stroke();
}
private drawPolyline(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number): void {
const pts = el.properties.points || [];
if (pts.length < 2) return;
this.ctx.beginPath();
this.ctx.moveTo(sx(pts[0].x), sy(pts[0].y));
for (let i = 1; i < pts.length; i++) {
this.ctx.lineTo(sx(pts[i].x), sy(pts[i].y));
}
this.ctx.stroke();
}
private drawPolygon(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, fill?: string): void {
const pts = el.properties.points || [];
if (pts.length < 3) return;
this.ctx.beginPath();
this.ctx.moveTo(sx(pts[0].x), sy(pts[0].y));
for (let i = 1; i < pts.length; i++) {
this.ctx.lineTo(sx(pts[i].x), sy(pts[i].y));
}
this.ctx.closePath();
if (fill || el.properties.fill) {
this.ctx.fillStyle = fill || el.properties.fill!;
this.ctx.fill();
}
this.ctx.stroke();
}
private drawText(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const fontSize = (el.properties.fontSize || 12) * s;
this.ctx.font = `${fontSize}px sans-serif`;
this.ctx.fillStyle = el.properties.stroke || '#e0e0e0';
this.ctx.textBaseline = 'top';
const text = el.properties.text || '';
const lines = String(text).split('\n');
const align = (el.properties.align as string) || 'left';
this.ctx.textAlign = align as CanvasTextAlign;
if (el.properties.rotation) {
this.ctx.save();
this.ctx.translate(sx(el.x), sy(el.y));
this.ctx.rotate((el.properties.rotation as number) * Math.PI / 180);
lines.forEach((line, i) => {
this.ctx.fillText(line, 0, i * fontSize * 1.2);
});
this.ctx.restore();
} else {
lines.forEach((line, i) => {
this.ctx.fillText(line, sx(el.x), sy(el.y) + i * fontSize * 1.2);
});
}
this.ctx.textAlign = 'left';
}
private drawDimension(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const p = el.properties;
const dimType = (p.dimType as string) || 'linear';
const value = (p.value as string) || '';
const arrowSize = 5 * s;
if (dimType === 'angular') {
this.drawAngularDimension(el, sx, sy, s, arrowSize, value);
return;
}
if (dimType === 'radial') {
this.drawRadialDimension(el, sx, sy, s, arrowSize, value);
return;
}
// Linear dimension
const x1 = p.x1 ?? el.x - el.width / 2;
const y1 = p.y1 ?? el.y;
const x2 = p.x2 ?? el.x + el.width / 2;
const y2 = p.y2 ?? el.y;
const offset = 15 * s;
// Extension lines
this.ctx.strokeStyle = '#888';
this.ctx.lineWidth = 0.5 * s;
this.ctx.setLineDash([]);
this.ctx.beginPath();
this.ctx.moveTo(sx(x1), sy(y1));
this.ctx.lineTo(sx(x1), sy(y1) - offset);
this.ctx.moveTo(sx(x2), sy(y2));
this.ctx.lineTo(sx(x2), sy(y2) - offset);
this.ctx.stroke();
// Dimension line
this.ctx.strokeStyle = '#aaa';
this.ctx.lineWidth = 1 * s;
this.ctx.beginPath();
this.ctx.moveTo(sx(x1), sy(y1) - offset);
this.ctx.lineTo(sx(x2), sy(y2) - offset);
this.ctx.stroke();
// Arrows
this.ctx.beginPath();
this.ctx.moveTo(sx(x1), sy(y1) - offset);
this.ctx.lineTo(sx(x1) + arrowSize, sy(y1) - offset - arrowSize / 2);
this.ctx.moveTo(sx(x1), sy(y1) - offset);
this.ctx.lineTo(sx(x1) + arrowSize, sy(y1) - offset + arrowSize / 2);
this.ctx.moveTo(sx(x2), sy(y2) - offset);
this.ctx.lineTo(sx(x2) - arrowSize, sy(y2) - offset - arrowSize / 2);
this.ctx.moveTo(sx(x2), sy(y2) - offset);
this.ctx.lineTo(sx(x2) - arrowSize, sy(y2) - offset + arrowSize / 2);
this.ctx.stroke();
// Text
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2;
this.ctx.font = `${10 * s}px sans-serif`;
this.ctx.fillStyle = '#ccc';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'bottom';
this.ctx.fillText(value || Math.sqrt((x2-x1)**2+(y2-y1)**2).toFixed(1), sx(midX), sy(midY) - offset - 4);
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'top';
}
private drawAngularDimension(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number, arrowSize: number, value: string): void {
const p = el.properties;
const vx = Number(p.x1 ?? el.x);
const vy = Number(p.y1 ?? el.y);
const ax1 = Number(p.ax1 ?? vx + 50);
const ay1 = Number(p.ay1 ?? vy);
const ax2 = Number(p.ax2 ?? vx + 50);
const ay2 = Number(p.ay2 ?? vy + 50);
const r = Number(p.radius) || 30;
const a1 = Math.atan2(ay1 - vy, ax1 - vx);
const a2 = Math.atan2(ay2 - vy, ax2 - vx);
// Arc
this.ctx.strokeStyle = '#aaa';
this.ctx.lineWidth = 1 * s;
this.ctx.beginPath();
this.ctx.arc(sx(vx), sy(vy), r * s, a1, a2);
this.ctx.stroke();
// Extension lines
this.ctx.strokeStyle = '#888';
this.ctx.lineWidth = 0.5 * s;
this.ctx.beginPath();
this.ctx.moveTo(sx(vx), sy(vy));
this.ctx.lineTo(sx(ax1), sy(ay1));
this.ctx.moveTo(sx(vx), sy(vy));
this.ctx.lineTo(sx(ax2), sy(ay2));
this.ctx.stroke();
// Text
const midAngle = (a1 + a2) / 2;
const tx = vx + Math.cos(midAngle) * (r + 10);
const ty = vy + Math.sin(midAngle) * (r + 10);
this.ctx.font = `${10 * s}px sans-serif`;
this.ctx.fillStyle = '#ccc';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(value, sx(tx), sy(ty));
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'top';
}
private drawRadialDimension(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number, arrowSize: number, value: string): void {
const p = el.properties;
const cx = p.x1 ?? el.x;
const cy = p.y1 ?? el.y;
const ex = p.x2 ?? el.x + el.width;
const ey = p.y2 ?? el.y;
// Radial line
this.ctx.strokeStyle = '#aaa';
this.ctx.lineWidth = 1 * s;
this.ctx.beginPath();
this.ctx.moveTo(sx(cx), sy(cy));
this.ctx.lineTo(sx(ex), sy(ey));
this.ctx.stroke();
// Arrow at end
const angle = Math.atan2(ey - cy, ex - cx);
this.ctx.beginPath();
this.ctx.moveTo(sx(ex), sy(ey));
this.ctx.lineTo(sx(ex) - arrowSize * Math.cos(angle - 0.3), sy(ey) - arrowSize * Math.sin(angle - 0.3));
this.ctx.moveTo(sx(ex), sy(ey));
this.ctx.lineTo(sx(ex) - arrowSize * Math.cos(angle + 0.3), sy(ey) - arrowSize * Math.sin(angle + 0.3));
this.ctx.stroke();
// Text
const midX = (cx + ex) / 2;
const midY = (cy + ey) / 2;
this.ctx.font = `${10 * s}px sans-serif`;
this.ctx.fillStyle = '#ccc';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'bottom';
this.ctx.fillText(value, sx(midX), sy(midY) - 4);
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'top';
}
private drawLeader(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const p = el.properties;
const x1 = p.x1 ?? el.x;
const y1 = p.y1 ?? el.y;
const x2 = p.x2 ?? el.x;
const y2 = p.y2 ?? el.y;
const text = (p.text as string) || '';
const fontSize = ((p.fontSize as number) || 12) * s;
// Arrow at start point
const angle = Math.atan2(y2 - y1, x2 - x1);
const arrowSize = 6 * s;
this.ctx.strokeStyle = p.stroke || '#e0e0e0';
this.ctx.lineWidth = (p.strokeWidth as number) || 1;
this.ctx.setLineDash([]);
this.ctx.beginPath();
this.ctx.moveTo(sx(x1), sy(y1));
this.ctx.lineTo(sx(x1) + arrowSize * Math.cos(angle - 0.4), sy(y1) + arrowSize * Math.sin(angle - 0.4));
this.ctx.moveTo(sx(x1), sy(y1));
this.ctx.lineTo(sx(x1) + arrowSize * Math.cos(angle + 0.4), sy(y1) + arrowSize * Math.sin(angle + 0.4));
this.ctx.stroke();
// Leader line
this.ctx.beginPath();
this.ctx.moveTo(sx(x1), sy(y1));
this.ctx.lineTo(sx(x2), sy(y2));
// Small horizontal dogleg
const doglegX = (x2 > x1 ? 1 : -1) * 20;
this.ctx.lineTo(sx(x2 + doglegX), sy(y2));
this.ctx.stroke();
// Text
if (text) {
this.ctx.font = `${fontSize}px sans-serif`;
this.ctx.fillStyle = p.stroke || '#e0e0e0';
this.ctx.textBaseline = 'bottom';
this.ctx.textAlign = x2 > x1 ? 'left' : 'right';
this.ctx.fillText(text, sx(x2 + doglegX), sy(y2) - 2);
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'top';
}
}
private drawRevCloud(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const p = el.properties;
const points = (p.points as Array<{x:number;y:number}>) || [];
if (points.length < 2) return;
const arcHeight = ((p.arcHeight as number) || 8) * s;
this.ctx.strokeStyle = p.stroke || '#e0e0e0';
this.ctx.lineWidth = (p.strokeWidth as number) || 1.5;
this.ctx.setLineDash([]);
if (p.fill && p.fill !== 'none') {
this.ctx.fillStyle = p.fill;
}
this.ctx.beginPath();
for (let i = 0; i < points.length; i++) {
const cur = points[i];
const next = points[(i + 1) % points.length];
const mx = (cur.x + next.x) / 2;
const my = (cur.y + next.y) / 2;
const dist = Math.sqrt((next.x - cur.x) ** 2 + (next.y - cur.y) ** 2);
const bulge = Math.min(arcHeight / dist, 0.5);
// Draw arc segment as quadratic curve with bulge
const cpX = mx + (next.y - cur.y) * bulge;
const cpY = my - (next.x - cur.x) * bulge;
if (i === 0) this.ctx.moveTo(sx(cur.x), sy(cur.y));
this.ctx.quadraticCurveTo(sx(cpX), sy(cpY), sx(next.x), sy(next.y));
}
this.ctx.closePath();
if (p.fill && p.fill !== 'none') this.ctx.fill();
this.ctx.stroke();
}
private drawBlockInstance(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const blockId = el.properties.blockId as string;
const blockDef = this.blockDefinitions.get(blockId);
if (!blockDef) {
// Fallback: bounding box
const w = el.width * s;
const h = el.height * s;
this.ctx.strokeStyle = '#666';
this.ctx.setLineDash([4, 4]);
this.ctx.strokeRect(sx(el.x) - w / 2, sy(el.y) - h / 2, w, h);
this.ctx.setLineDash([]);
return;
}
const rotation = (el.properties.rotation || 0) * Math.PI / 180;
const scale = (el.properties.scale || 1) * s;
const ox = el.properties.offsetX || 0;
const oy = el.properties.offsetY || 0;
const cx = sx(el.x);
const cy = sy(el.y);
this.ctx.save();
this.ctx.translate(cx, cy);
this.ctx.rotate(rotation);
for (const childEl of blockDef.elements) {
const lx = (childEl.x + Number(ox)) * scale;
const ly = (childEl.y + Number(oy)) * scale;
const lw = childEl.width * scale;
const lh = childEl.height * scale;
const props = { ...childEl.properties };
this.ctx.save();
if (props.fill) this.ctx.fillStyle = props.fill;
this.ctx.strokeStyle = props.stroke || '#999';
this.ctx.lineWidth = Math.max(0.5, 1 * s);
switch (childEl.type) {
case 'rect':
if (props.fill) this.ctx.fillRect(lx - lw / 2, ly - lh / 2, lw, lh);
this.ctx.strokeRect(lx - lw / 2, ly - lh / 2, lw, lh);
break;
case 'circle': {
const r = (props.radius || lw / 2) * scale / s;
this.ctx.beginPath();
this.ctx.arc(lx, ly, r, 0, Math.PI * 2);
if (props.fill) this.ctx.fill();
this.ctx.stroke();
break;
}
case 'line': {
const x1 = (Number(props.x1) + Number(ox)) * scale;
const y1 = (Number(props.y1) + Number(oy)) * scale;
const x2 = (Number(props.x2) + Number(ox)) * scale;
const y2 = (Number(props.y2) + Number(oy)) * scale;
this.ctx.beginPath();
this.ctx.moveTo(x1, y1);
this.ctx.lineTo(x2, y2);
this.ctx.stroke();
break;
}
case 'arc': {
const r = (props.radius || lw / 2) * scale / s;
const startAngle = (props.startAngle || 0) * Math.PI / 180;
const endAngle = (props.endAngle || 360) * Math.PI / 180;
this.ctx.beginPath();
this.ctx.arc(lx, ly, r, startAngle, endAngle);
this.ctx.stroke();
break;
}
}
this.ctx.restore();
}
this.ctx.restore();
}
private drawChair(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const w = el.width * s;
const h = el.height * s;
const cx = sx(el.x);
const cy = sy(el.y);
const rot = (el.properties.rotation || 0) * Math.PI / 180;
this.ctx.save();
this.ctx.translate(cx, cy);
this.ctx.rotate(rot);
// Seat
if (el.properties.fill) {
this.ctx.fillStyle = el.properties.fill;
} else {
this.ctx.fillStyle = '#4a90d9';
}
this.ctx.fillRect(-w / 2, -h / 2, w, h);
// Backrest (top portion)
this.ctx.fillStyle = '#3a7ac9';
this.ctx.fillRect(-w / 2, -h / 2, w, h * 0.2);
// Outline
this.ctx.strokeStyle = '#2a5a99';
this.ctx.lineWidth = 0.5;
this.ctx.strokeRect(-w / 2, -h / 2, w, h);
this.ctx.restore();
}
private drawTable(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const w = el.width * s;
const h = el.height * s;
const cx = sx(el.x);
const cy = sy(el.y);
const rot = (el.properties.rotation || 0) * Math.PI / 180;
const shape = el.properties.shape || 'rect';
this.ctx.save();
this.ctx.translate(cx, cy);
this.ctx.rotate(rot);
if (shape === 'round') {
const r = Math.min(w, h) / 2;
this.ctx.fillStyle = el.properties.fill || '#8b6f47';
this.ctx.beginPath();
this.ctx.arc(0, 0, r, 0, Math.PI * 2);
this.ctx.fill();
this.ctx.strokeStyle = el.properties.stroke || '#5a4a37';
this.ctx.lineWidth = (el.properties.strokeWidth || 1.5) * s;
this.ctx.stroke();
} else {
this.ctx.fillStyle = el.properties.fill || '#8b6f47';
this.ctx.fillRect(-w / 2, -h / 2, w, h);
this.ctx.strokeStyle = el.properties.stroke || '#5a4a37';
this.ctx.lineWidth = (el.properties.strokeWidth || 1.5) * s;
this.ctx.strokeRect(-w / 2, -h / 2, w, h);
}
this.ctx.restore();
}
private drawStage(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const w = el.width * s;
const h = el.height * s;
const cx = sx(el.x);
const cy = sy(el.y);
const rot = (el.properties.rotation || 0) * Math.PI / 180;
this.ctx.save();
this.ctx.translate(cx, cy);
this.ctx.rotate(rot);
// Stage floor
this.ctx.fillStyle = el.properties.fill || '#2c3e50';
this.ctx.fillRect(-w / 2, -h / 2, w, h);
// Border
this.ctx.strokeStyle = el.properties.stroke || '#1a2e3f';
this.ctx.lineWidth = (el.properties.strokeWidth || 2) * s;
this.ctx.strokeRect(-w / 2, -h / 2, w, h);
// Label
const label = el.properties.label as string || 'Bühne';
this.ctx.fillStyle = '#fff';
this.ctx.font = `${Math.max(10, 14 * s)}px Inter, sans-serif`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(label, 0, 0);
this.ctx.restore();
}
private drawSelectionHandles(el: CADElement, sx: (v:number)=>number, sy: (v:number)=>number, s: number): void {
const w = el.width * s;
const h = el.height * s;
const x = sx(el.x) - w / 2;
const y = sy(el.y) - h / 2;
const handleSize = 6;
this.ctx.fillStyle = '#00aaff';
this.ctx.strokeStyle = '#fff';
this.ctx.lineWidth = 1;
const corners = [
[x, y], [x + w, y], [x, y + h], [x + w, y + h],
[x + w / 2, y], [x + w / 2, y + h], [x, y + h / 2], [x + w, y + h / 2],
];
for (const [hx, hy] of corners) {
this.ctx.fillRect(hx - handleSize / 2, hy - handleSize / 2, handleSize, handleSize);
this.ctx.strokeRect(hx - handleSize / 2, hy - handleSize / 2, handleSize, handleSize);
}
}
private drawSelectionBox(): void {
if (!this.selection.boxStart || !this.selection.boxEnd) return;
const s = this.zoomPan.getScale();
const ox = this.zoomPan.getTransform().e;
const oy = this.zoomPan.getTransform().f;
const x1 = this.selection.boxStart.x * s + ox;
const y1 = this.selection.boxStart.y * s + oy;
const x2 = this.selection.boxEnd.x * s + ox;
const y2 = this.selection.boxEnd.y * s + oy;
this.ctx.strokeStyle = '#00aaff';
this.ctx.fillStyle = 'rgba(0, 170, 255, 0.1)';
this.ctx.lineWidth = 1;
this.ctx.setLineDash([4, 4]);
this.ctx.fillRect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1));
this.ctx.strokeRect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1));
this.ctx.setLineDash([]);
}
private drawSnapPoints(): void {
const s = this.zoomPan.getScale();
const ox = this.zoomPan.getTransform().e;
const oy = this.zoomPan.getTransform().f;
for (const pt of this.snapPoints) {
const x = pt.x * s + ox;
const y = pt.y * s + oy;
const isActive = this.activeSnapPoint?.x === pt.x && this.activeSnapPoint?.y === pt.y;
this.ctx.fillStyle = isActive ? '#ff0' : '#0f0';
this.ctx.beginPath();
this.ctx.arc(x, y, isActive ? 6 : 4, 0, Math.PI * 2);
this.ctx.fill();
}
}
private drawOrtho(): void {
// Draw ortho tracking line from cursor
// Placeholder — will be connected to interaction engine
}
// Hit testing
hitTest(worldX: number, worldY: number, tolerance: number = 5): CADElement | null {
const viewport = this.zoomPan.getViewport();
const candidates = this.spatialIndex.search({
minX: worldX - tolerance, minY: worldY - tolerance,
maxX: worldX + tolerance, maxY: worldY + tolerance,
});
const visibleLayerIds = new Set(
this.layerManager.getVisibleLayers().map(l => l.id),
);
let best: CADElement | null = null;
let bestDist = tolerance;
for (const el of candidates) {
if (!visibleLayerIds.has(el.layerId)) continue;
const dist = this.elementDistance(el, worldX, worldY);
if (dist < bestDist) {
bestDist = dist;
best = el;
}
}
return best;
}
private elementDistance(el: CADElement, x: number, y: number): number {
const p = el.properties;
switch (el.type) {
case 'line': {
const x1 = p.x1 ?? el.x;
const y1 = p.y1 ?? el.y;
const x2 = p.x2 ?? el.x + el.width;
const y2 = p.y2 ?? el.y + el.height;
return this.pointToSegmentDist(x, y, x1, y1, x2, y2);
}
case 'circle': {
const r = p.radius || el.width / 2;
const d = Math.sqrt((x - el.x) ** 2 + (y - el.y) ** 2);
return Math.abs(d - r);
}
case 'rect':
case 'table':
case 'stage': {
const halfW = el.width / 2;
const halfH = el.height / 2;
const dx = Math.max(Math.abs(x - el.x) - halfW, 0);
const dy = Math.max(Math.abs(y - el.y) - halfH, 0);
return Math.sqrt(dx * dx + dy * dy);
}
case 'polyline':
case 'polygon': {
const pts = p.points || [];
let minDist = Infinity;
for (let i = 0; i < pts.length - 1; i++) {
const d = this.pointToSegmentDist(x, y, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y);
minDist = Math.min(minDist, d);
}
if (el.type === 'polygon' && pts.length > 2) {
const d = this.pointToSegmentDist(x, y, pts[pts.length - 1].x, pts[pts.length - 1].y, pts[0].x, pts[0].y);
minDist = Math.min(minDist, d);
}
return minDist;
}
default: {
const halfW = el.width / 2;
const halfH = el.height / 2;
const dx = Math.max(Math.abs(x - el.x) - halfW, 0);
const dy = Math.max(Math.abs(y - el.y) - halfH, 0);
return Math.sqrt(dx * dx + dy * dy);
}
}
}
private pointToSegmentDist(px: number, py: number, x1: number, y1: number, x2: number, y2: number): number {
const dx = x2 - x1;
const dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.sqrt((px - x1) ** 2 + (py - y1) ** 2);
let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
const cx = x1 + t * dx;
const cy = y1 + t * dy;
return Math.sqrt((px - cx) ** 2 + (py - cy) ** 2);
}
// Bounding box for an element
getElementBBox(el: CADElement): BoundingBox {
const halfW = el.width / 2;
const halfH = el.height / 2;
return {
minX: el.x - halfW, minY: el.y - halfH,
maxX: el.x + halfW, maxY: el.y + halfH,
};
}
// Get elements within a world-space rectangle (for box selection)
getElementsInRect(minX: number, minY: number, maxX: number, maxY: number): CADElement[] {
const candidates = this.spatialIndex.search({ minX, minY, maxX, maxY });
const visibleLayerIds = new Set(
this.layerManager.getVisibleLayers().map(l => l.id),
);
return candidates.filter(el => {
if (!visibleLayerIds.has(el.layerId)) return false;
const bb = this.getElementBBox(el);
return bb.minX >= minX && bb.maxX <= maxX && bb.minY >= minY && bb.maxY <= maxY;
});
}
// Get elements intersecting a world-space rectangle (for crossing selection)
getElementsIntersectingRect(minX: number, minY: number, maxX: number, maxY: number): CADElement[] {
const candidates = this.spatialIndex.search({ minX, minY, maxX, maxY });
const visibleLayerIds = new Set(
this.layerManager.getVisibleLayers().map(l => l.id),
);
return candidates.filter(el => {
if (!visibleLayerIds.has(el.layerId)) return false;
const bb = this.getElementBBox(el);
// Check if bbox intersects the rect (not necessarily fully enclosed)
return bb.minX <= maxX && bb.maxX >= minX && bb.minY <= maxY && bb.maxY >= minY;
});
}
}
+297
View File
@@ -0,0 +1,297 @@
import type { CADElement, CADLayer } from '../types/cad.types';
import { RenderEngine } from './RenderEngine';
import { SpatialIndex } from './SpatialIndex';
import { LayerManager } from './LayerManager';
export type SelectionMode = 'single' | 'multiple' | 'box' | 'lasso';
export type SelectionFilter = 'all' | 'lines' | 'circles' | 'rects' | 'text' | 'chairs' | 'blocks';
export interface SelectionOptions {
mode: SelectionMode;
filter: SelectionFilter;
additive: boolean; // shift-click to add to selection
subtractive: boolean; // ctrl-click to remove from selection
tolerance: number; // world units for hit testing
}
export class SelectionEngine {
private renderEngine: RenderEngine;
private spatialIndex: SpatialIndex;
private layerManager: LayerManager;
private selectedIds: Set<string> = new Set();
private hoverId: string | null = null;
private options: SelectionOptions;
private boxStart: { x: number; y: number } | null = null;
private boxEnd: { x: number; y: number } | null = null;
private listeners: Array<(selected: CADElement[]) => void> = [];
constructor(
renderEngine: RenderEngine,
spatialIndex: SpatialIndex,
layerManager: LayerManager,
) {
this.renderEngine = renderEngine;
this.spatialIndex = spatialIndex;
this.layerManager = layerManager;
this.options = {
mode: 'single',
filter: 'all',
additive: false,
subtractive: false,
tolerance: 5,
};
}
setOptions(opts: Partial<SelectionOptions>): void {
this.options = { ...this.options, ...opts };
}
getOptions(): SelectionOptions {
return { ...this.options };
}
getSelectedIds(): Set<string> {
return new Set(this.selectedIds);
}
getSelectedElements(allElements: CADElement[]): CADElement[] {
return allElements.filter(e => this.selectedIds.has(e.id));
}
getHoverId(): string | null {
return this.hoverId;
}
setHover(id: string | null): void {
this.hoverId = id;
this.updateRenderSelection();
}
/**
* Click selection at world coordinates.
* Returns the selected element or null.
*/
clickSelect(worldX: number, worldY: number, allElements: CADElement[]): CADElement | null {
const hit = this.renderEngine.hitTest(worldX, worldY, this.options.tolerance);
if (!hit) {
if (!this.options.additive && !this.options.subtractive) {
this.clearSelection();
}
return null;
}
if (!this.matchesFilter(hit)) {
if (!this.options.additive && !this.options.subtractive) {
this.clearSelection();
}
return null;
}
if (this.options.subtractive) {
this.selectedIds.delete(hit.id);
} else if (this.options.additive) {
this.selectedIds.add(hit.id);
} else {
this.clearSelection();
this.selectedIds.add(hit.id);
}
this.updateRenderSelection();
this.notifyListeners(allElements);
return hit;
}
/**
* Start box selection at world coordinates.
*/
startBoxSelect(worldX: number, worldY: number): void {
this.boxStart = { x: worldX, y: worldY };
this.boxEnd = { x: worldX, y: worldY };
this.updateRenderSelection();
}
/**
* Update box selection end point during drag.
*/
updateBoxSelect(worldX: number, worldY: number, allElements: CADElement[]): void {
if (!this.boxStart) return;
this.boxEnd = { x: worldX, y: worldY };
this.updateRenderSelection();
}
/**
* Finish box selection and select all elements within the box.
* Left-to-right drag = window selection (fully enclosed elements).
* Right-to-left drag = crossing selection (intersecting elements).
*/
finishBoxSelect(allElements: CADElement[]): CADElement[] {
if (!this.boxStart || !this.boxEnd) {
this.boxStart = null;
this.boxEnd = null;
return [];
}
const minX = Math.min(this.boxStart.x, this.boxEnd.x);
const minY = Math.min(this.boxStart.y, this.boxEnd.y);
const maxX = Math.max(this.boxStart.x, this.boxEnd.x);
const maxY = Math.max(this.boxStart.y, this.boxEnd.y);
// Determine window vs crossing: if start X < end X, it's window (left-to-right)
const isWindow = this.boxStart.x <= this.boxEnd.x;
let elements: CADElement[];
if (isWindow) {
// Window selection: only fully enclosed elements
elements = this.renderEngine.getElementsInRect(minX, minY, maxX, maxY);
} else {
// Crossing selection: all intersecting elements
elements = this.renderEngine.getElementsIntersectingRect(minX, minY, maxX, maxY);
}
const filtered = elements.filter(e => this.matchesFilter(e));
if (this.options.subtractive) {
for (const el of filtered) this.selectedIds.delete(el.id);
} else if (this.options.additive) {
for (const el of filtered) this.selectedIds.add(el.id);
} else {
this.clearSelection();
for (const el of filtered) this.selectedIds.add(el.id);
}
this.boxStart = null;
this.boxEnd = null;
this.updateRenderSelection();
this.notifyListeners(allElements);
return filtered;
}
/**
* Quick select elements by property value.
* Supports filtering by type, layerId, color (stroke/fill), or any property.
*/
quickSelect(allElements: CADElement[], criteria: {
type?: string;
layerId?: string;
color?: string;
property?: string;
value?: unknown;
}, additive: boolean = false): CADElement[] {
if (!additive) this.clearSelection();
const matched = allElements.filter(el => {
if (criteria.type && el.type !== criteria.type) return false;
if (criteria.layerId && el.layerId !== criteria.layerId) return false;
if (criteria.color) {
const elColor = el.properties.stroke ?? el.properties.fill;
if (elColor !== criteria.color) return false;
}
if (criteria.property && criteria.value !== undefined) {
if ((el.properties as Record<string, unknown>)[criteria.property] !== criteria.value) return false;
}
return this.matchesFilter(el);
});
for (const el of matched) this.selectedIds.add(el.id);
this.updateRenderSelection();
this.notifyListeners(allElements);
return matched;
}
/**
* Cancel any in-progress box selection.
*/
cancelBoxSelect(): void {
this.boxStart = null;
this.boxEnd = null;
this.updateRenderSelection();
}
/**
* Select all elements matching the current filter.
*/
selectAll(allElements: CADElement[]): void {
const visibleLayerIds = new Set(this.layerManager.getVisibleLayers().map(l => l.id));
this.selectedIds.clear();
for (const el of allElements) {
if (!visibleLayerIds.has(el.layerId)) continue;
if (this.matchesFilter(el)) this.selectedIds.add(el.id);
}
this.updateRenderSelection();
this.notifyListeners(allElements);
}
/**
* Invert current selection.
*/
invertSelection(allElements: CADElement[]): void {
const visibleLayerIds = new Set(this.layerManager.getVisibleLayers().map(l => l.id));
const newSelection = new Set<string>();
for (const el of allElements) {
if (!visibleLayerIds.has(el.layerId)) continue;
if (!this.matchesFilter(el)) continue;
if (!this.selectedIds.has(el.id)) newSelection.add(el.id);
}
this.selectedIds = newSelection;
this.updateRenderSelection();
this.notifyListeners(allElements);
}
/**
* Clear all selection.
*/
clearSelection(): void {
this.selectedIds.clear();
this.updateRenderSelection();
}
/**
* Select specific elements by ID.
*/
selectByIds(ids: string[], additive: boolean = false): void {
if (!additive) this.selectedIds.clear();
for (const id of ids) this.selectedIds.add(id);
this.updateRenderSelection();
}
/**
* Add a listener that gets called when selection changes.
*/
addListener(fn: (selected: CADElement[]) => void): void {
this.listeners.push(fn);
}
removeListener(fn: (selected: CADElement[]) => void): void {
this.listeners = this.listeners.filter(f => f !== fn);
}
private matchesFilter(el: CADElement): boolean {
switch (this.options.filter) {
case 'all': return true;
case 'lines': return el.type === 'line';
case 'circles': return el.type === 'circle' || el.type === 'arc';
case 'rects': return el.type === 'rect';
case 'text': return el.type === 'text';
case 'chairs': return el.type === 'chair';
case 'blocks': return el.type === 'block_instance';
default: return true;
}
}
private updateRenderSelection(): void {
this.renderEngine.setSelection({
selectedIds: this.selectedIds,
hoverId: this.hoverId,
boxStart: this.boxStart,
boxEnd: this.boxEnd,
});
}
private notifyListeners(allElements: CADElement[]): void {
const selected = allElements.filter(e => this.selectedIds.has(e.id));
for (const fn of this.listeners) fn(selected);
}
isBoxSelecting(): boolean {
return this.boxStart !== null;
}
}
+402
View File
@@ -0,0 +1,402 @@
import type { CADElement } from '../types/cad.types';
import type { SnapPoint } from './RenderEngine';
export type SnapMode =
| 'endpoint' | 'midpoint' | 'center' | 'intersection'
| 'nearest' | 'perpendicular' | 'tangent' | 'quadrant'
| 'grid' | 'none';
export interface SnapConfig {
enabled: boolean;
modes: Set<SnapMode>;
tolerance: number; // world units
gridSpacing: number;
polarEnabled: boolean;
polarAngles: number[]; // angles in degrees for polar tracking
polarTolerance: number; // angular tolerance in degrees
}
export interface SnapResult {
point: SnapPoint | null;
preview: SnapPoint[]; // nearby candidates for visual feedback
}
export class SnapEngine {
private config: SnapConfig;
private elements: CADElement[] = [];
constructor(config?: Partial<SnapConfig>) {
this.config = {
enabled: true,
modes: new Set<SnapMode>(['endpoint', 'midpoint', 'center', 'intersection', 'nearest']),
tolerance: 10,
gridSpacing: 20,
polarEnabled: false,
polarAngles: [0, 30, 45, 60, 90, 120, 135, 150, 180, 210, 225, 240, 270, 300, 315, 330],
polarTolerance: 5,
...config,
};
}
setElements(elements: CADElement[]): void {
this.elements = elements;
}
setConfig(config: Partial<SnapConfig>): void {
this.config = { ...this.config, ...config };
}
getConfig(): SnapConfig {
return { ...this.config, modes: new Set(this.config.modes) };
}
toggleMode(mode: SnapMode): void {
if (this.config.modes.has(mode)) {
this.config.modes.delete(mode);
} else {
this.config.modes.add(mode);
}
}
/**
* Find the best snap point near the given world coordinates.
* Returns null if no snap point is within tolerance.
* If refPoint is provided and polar tracking is enabled, snaps to polar angles.
*/
snap(worldX: number, worldY: number, refPoint?: { x: number; y: number }): SnapResult {
if (!this.config.enabled || this.config.modes.size === 0) {
return { point: null, preview: [] };
}
// Polar tracking: if we have a reference point, check polar angles first
if (this.config.polarEnabled && refPoint) {
const polarResult = this.polarSnap(worldX, worldY, refPoint);
if (polarResult) {
return { point: polarResult, preview: [polarResult] };
}
}
const candidates: SnapPoint[] = [];
const tol = this.config.tolerance;
// Grid snap (lowest priority)
if (this.config.modes.has('grid')) {
const gs = this.config.gridSpacing;
const gx = Math.round(worldX / gs) * gs;
const gy = Math.round(worldY / gs) * gs;
const dist = Math.sqrt((worldX - gx) ** 2 + (worldY - gy) ** 2);
if (dist < tol) {
candidates.push({ x: gx, y: gy, type: 'grid' as SnapMode as any });
}
}
// Element-based snaps
for (const el of this.elements) {
if (this.config.modes.has('endpoint')) {
this.collectEndpoints(el, worldX, worldY, tol, candidates);
}
if (this.config.modes.has('midpoint')) {
this.collectMidpoints(el, worldX, worldY, tol, candidates);
}
if (this.config.modes.has('center')) {
this.collectCenters(el, worldX, worldY, tol, candidates);
}
if (this.config.modes.has('nearest')) {
this.collectNearest(el, worldX, worldY, tol, candidates);
}
}
// Intersection snap (between pairs)
if (this.config.modes.has('intersection')) {
this.collectIntersections(worldX, worldY, tol, candidates);
}
if (candidates.length === 0) {
return { point: null, preview: [] };
}
// Sort by distance, pick closest
candidates.sort((a, b) => {
const da = (a.x - worldX) ** 2 + (a.y - worldY) ** 2;
const db = (b.x - worldX) ** 2 + (b.y - worldY) ** 2;
return da - db;
});
// Priority: endpoint > intersection > center > midpoint > nearest > grid
const priority: Record<string, number> = {
endpoint: 0, intersection: 1, center: 2, midpoint: 3, nearest: 4, grid: 5,
};
// Find best within tolerance — prefer higher priority if distances are close
const best = candidates[0];
const closeOnes = candidates.filter(c => {
const d = Math.sqrt((c.x - worldX) ** 2 + (c.y - worldY) ** 2);
return d < tol * 1.5;
});
closeOnes.sort((a, b) => {
const pa = priority[a.type] ?? 99;
const pb = priority[b.type] ?? 99;
if (pa !== pb) return pa - pb;
const da = (a.x - worldX) ** 2 + (a.y - worldY) ** 2;
const db = (b.x - worldX) ** 2 + (b.y - worldY) ** 2;
return da - db;
});
return {
point: closeOnes[0] || best,
preview: candidates.slice(0, 10),
};
}
private collectEndpoints(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
const p = el.properties;
const check = (x: number, y: number) => {
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
if (d < tol) out.push({ x, y, type: 'endpoint' });
};
switch (el.type) {
case 'line':
check(p.x1 ?? el.x, p.y1 ?? el.y);
check(p.x2 ?? el.x + el.width, p.y2 ?? el.y + el.height);
break;
case 'polyline':
case 'polygon': {
const pts = p.points || [];
for (const pt of pts) check(pt.x, pt.y);
break;
}
case 'rect':
check(el.x - el.width / 2, el.y - el.height / 2);
check(el.x + el.width / 2, el.y - el.height / 2);
check(el.x - el.width / 2, el.y + el.height / 2);
check(el.x + el.width / 2, el.y + el.height / 2);
break;
case 'arc': {
const r = p.radius || el.width / 2;
const sa = (p.startAngle || 0) * Math.PI / 180;
const ea = (p.endAngle || 360) * Math.PI / 180;
check(el.x + r * Math.cos(sa), el.y + r * Math.sin(sa));
check(el.x + r * Math.cos(ea), el.y + r * Math.sin(ea));
break;
}
}
}
private collectMidpoints(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
const p = el.properties;
const check = (x: number, y: number) => {
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
if (d < tol) out.push({ x, y, type: 'midpoint' });
};
switch (el.type) {
case 'line': {
const x1 = p.x1 ?? el.x, y1 = p.y1 ?? el.y;
const x2 = p.x2 ?? el.x + el.width, y2 = p.y2 ?? el.y + el.height;
check((x1 + x2) / 2, (y1 + y2) / 2);
break;
}
case 'polyline':
case 'polygon': {
const pts = p.points || [];
for (let i = 0; i < pts.length - 1; i++) {
check((pts[i].x + pts[i + 1].x) / 2, (pts[i].y + pts[i + 1].y) / 2);
}
if (el.type === 'polygon' && pts.length > 2) {
check((pts[pts.length - 1].x + pts[0].x) / 2, (pts[pts.length - 1].y + pts[0].y) / 2);
}
break;
}
case 'rect':
check(el.x, el.y - el.height / 2);
check(el.x, el.y + el.height / 2);
check(el.x - el.width / 2, el.y);
check(el.x + el.width / 2, el.y);
break;
}
}
private collectCenters(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
const check = (x: number, y: number) => {
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
if (d < tol) out.push({ x, y, type: 'center' });
};
switch (el.type) {
case 'circle':
case 'arc':
check(el.x, el.y);
break;
case 'rect':
check(el.x, el.y);
break;
}
}
private collectNearest(el: CADElement, wx: number, wy: number, tol: number, out: SnapPoint[]): void {
const p = el.properties;
const check = (x: number, y: number) => {
const d = Math.sqrt((wx - x) ** 2 + (wy - y) ** 2);
if (d < tol) out.push({ x, y, type: 'nearest' });
};
switch (el.type) {
case 'line': {
const x1 = p.x1 ?? el.x, y1 = p.y1 ?? el.y;
const x2 = p.x2 ?? el.x + el.width, y2 = p.y2 ?? el.y + el.height;
const np = this.nearestOnSegment(wx, wy, x1, y1, x2, y2);
check(np.x, np.y);
break;
}
case 'circle': {
const r = p.radius || el.width / 2;
const d = Math.sqrt((wx - el.x) ** 2 + (wy - el.y) ** 2);
if (d > 0) {
check(el.x + r * (wx - el.x) / d, el.y + r * (wy - el.y) / d);
}
break;
}
case 'polyline':
case 'polygon': {
const pts = p.points || [];
for (let i = 0; i < pts.length - 1; i++) {
const np = this.nearestOnSegment(wx, wy, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y);
check(np.x, np.y);
}
if (el.type === 'polygon' && pts.length > 2) {
const np = this.nearestOnSegment(wx, wy, pts[pts.length - 1].x, pts[pts.length - 1].y, pts[0].x, pts[0].y);
check(np.x, np.y);
}
break;
}
}
}
private collectIntersections(wx: number, wy: number, tol: number, out: SnapPoint[]): void {
// Check pairs of elements near the cursor
const nearby = this.elements.filter(el => {
const halfW = el.width / 2 + tol;
const halfH = el.height / 2 + tol;
return Math.abs(wx - el.x) < halfW && Math.abs(wy - el.y) < halfH;
});
for (let i = 0; i < nearby.length; i++) {
for (let j = i + 1; j < nearby.length; j++) {
const pts = this.findIntersection(nearby[i], nearby[j]);
for (const pt of pts) {
const d = Math.sqrt((wx - pt.x) ** 2 + (wy - pt.y) ** 2);
if (d < tol) out.push({ x: pt.x, y: pt.y, type: 'intersection' });
}
}
}
}
private findIntersection(a: CADElement, b: CADElement): Array<{ x: number; y: number }> {
// Get line segments from both elements
const segsA = this.getElementSegments(a);
const segsB = this.getElementSegments(b);
const results: Array<{ x: number; y: number }> = [];
for (const sa of segsA) {
for (const sb of segsB) {
const pt = this.segmentIntersection(sa.x1, sa.y1, sa.x2, sa.y2, sb.x1, sb.y1, sb.x2, sb.y2);
if (pt) results.push(pt);
}
}
return results;
}
private getElementSegments(el: CADElement): Array<{ x1: number; y1: number; x2: number; y2: number }> {
const p = el.properties;
switch (el.type) {
case 'line':
return [{
x1: p.x1 ?? el.x, y1: p.y1 ?? el.y,
x2: p.x2 ?? el.x + el.width, y2: p.y2 ?? el.y + el.height,
}];
case 'rect': {
const hw = el.width / 2, hh = el.height / 2;
return [
{ x1: el.x - hw, y1: el.y - hh, x2: el.x + hw, y2: el.y - hh },
{ x1: el.x + hw, y1: el.y - hh, x2: el.x + hw, y2: el.y + hh },
{ x1: el.x + hw, y1: el.y + hh, x2: el.x - hw, y2: el.y + hh },
{ x1: el.x - hw, y1: el.y + hh, x2: el.x - hw, y2: el.y - hh },
];
}
case 'polyline':
case 'polygon': {
const pts = p.points || [];
const segs: Array<{ x1: number; y1: number; x2: number; y2: number }> = [];
for (let i = 0; i < pts.length - 1; i++) {
segs.push({ x1: pts[i].x, y1: pts[i].y, x2: pts[i + 1].x, y2: pts[i + 1].y });
}
if (el.type === 'polygon' && pts.length > 2) {
segs.push({ x1: pts[pts.length - 1].x, y1: pts[pts.length - 1].y, x2: pts[0].x, y2: pts[0].y });
}
return segs;
}
default:
return [];
}
}
private segmentIntersection(
x1: number, y1: number, x2: number, y2: number,
x3: number, y3: number, x4: number, y4: number,
): { x: number; y: number } | null {
const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
if (Math.abs(denom) < 1e-10) return null;
const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
if (t >= 0 && t <= 1 && u >= 0 && u <= 1) {
return { x: x1 + t * (x2 - x1), y: y1 + t * (y2 - y1) };
}
return null;
}
private nearestOnSegment(px: number, py: number, x1: number, y1: number, x2: number, y2: number): { x: number; y: number } {
const dx = x2 - x1;
const dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return { x: x1, y: y1 };
let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return { x: x1 + t * dx, y: y1 + t * dy };
}
/**
* Polar tracking: snap cursor to the nearest polar angle from a reference point.
* Returns a SnapPoint if the cursor is close to a polar angle, or null.
*/
private polarSnap(worldX: number, worldY: number, refPoint: { x: number; y: number }): SnapPoint | null {
const dx = worldX - refPoint.x;
const dy = worldY - refPoint.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) return null; // too close to reference point
const cursorAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
const normalizedCursor = ((cursorAngle % 360) + 360) % 360;
// Find closest polar angle
let bestAngle: number | null = null;
let bestDiff = Infinity;
for (const angle of this.config.polarAngles) {
let diff = Math.abs(normalizedCursor - angle);
if (diff > 180) diff = 360 - diff;
if (diff < bestDiff) {
bestDiff = diff;
bestAngle = angle;
}
}
if (bestAngle === null || bestDiff > this.config.polarTolerance) return null;
// Project cursor position onto the polar angle line at the same distance
const rad = (bestAngle * Math.PI) / 180;
const snapX = refPoint.x + dist * Math.cos(rad);
const snapY = refPoint.y + dist * Math.sin(rad);
return { x: snapX, y: snapY, type: 'nearest' };
}
}
+48
View File
@@ -0,0 +1,48 @@
import RBush from 'rbush';
import type { BoundingBox, CADElement } from '../types/cad.types';
interface IndexedItem extends BoundingBox {
element: CADElement;
}
export class SpatialIndex {
private tree: RBush<IndexedItem>;
constructor() {
this.tree = new RBush<IndexedItem>();
}
insert(element: CADElement): void {
const bbox = this.elementBBox(element);
this.tree.insert({ ...bbox, element });
}
bulkInsert(elements: CADElement[]): void {
const items = elements.map(el => ({ ...this.elementBBox(el), element: el }));
this.tree.load(items);
}
search(viewport: BoundingBox): CADElement[] {
return this.tree.search(viewport).map(item => item.element);
}
remove(element: CADElement): void {
const bbox = this.elementBBox(element);
this.tree.remove({ ...bbox, element }, (a, b) => a.element.id === b.element.id);
}
clear(): void {
this.tree.clear();
}
private elementBBox(el: CADElement): BoundingBox {
const halfW = el.width / 2;
const halfH = el.height / 2;
return {
minX: el.x - halfW,
minY: el.y - halfH,
maxX: el.x + halfW,
maxY: el.y + halfH,
};
}
}
+99
View File
@@ -0,0 +1,99 @@
import type { Transform, Viewport } from '../types/cad.types';
export class ZoomPanController {
private scale = 1;
private offsetX = 0;
private offsetY = 0;
private canvas: HTMLCanvasElement;
private isPanning = false;
private lastX = 0;
private lastY = 0;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
}
getTransform(): Transform {
return {
a: this.scale, b: 0, c: 0,
d: this.scale, e: this.offsetX, f: this.offsetY,
};
}
getViewport(): Viewport {
const w = this.canvas.width / this.scale;
const h = this.canvas.height / this.scale;
const minX = -this.offsetX / this.scale || 0;
const minY = -this.offsetY / this.scale || 0;
return {
minX,
minY,
maxX: minX + w,
maxY: minY + h,
};
}
getScale(): number { return this.scale; }
zoomAt(cx: number, cy: number, factor: number): void {
const newScale = Math.max(0.01, Math.min(100, this.scale * factor));
const ratio = newScale / this.scale;
this.offsetX = cx - (cx - this.offsetX) * ratio;
this.offsetY = cy - (cy - this.offsetY) * ratio;
this.scale = newScale;
}
zoomFit(elements: Array<{x:number;y:number;width:number;height:number}>): void {
if (elements.length === 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const el of elements) {
minX = Math.min(minX, el.x - el.width/2);
minY = Math.min(minY, el.y - el.height/2);
maxX = Math.max(maxX, el.x + el.width/2);
maxY = Math.max(maxY, el.y + el.height/2);
}
const padding = 40;
const scaleX = (this.canvas.width - padding*2) / (maxX - minX);
const scaleY = (this.canvas.height - padding*2) / (maxY - minY);
this.scale = Math.min(scaleX, scaleY);
this.offsetX = -minX * this.scale + padding;
this.offsetY = -minY * this.scale + padding;
}
zoomToRect(rect: { minX: number; minY: number; maxX: number; maxY: number }): void {
const padding = 20;
const w = rect.maxX - rect.minX;
const h = rect.maxY - rect.minY;
if (w <= 0 || h <= 0) return;
const scaleX = (this.canvas.width - padding * 2) / w;
const scaleY = (this.canvas.height - padding * 2) / h;
this.scale = Math.max(0.01, Math.min(100, Math.min(scaleX, scaleY)));
this.offsetX = -rect.minX * this.scale + padding;
this.offsetY = -rect.minY * this.scale + padding;
}
pan(dx: number, dy: number): void {
this.offsetX += dx;
this.offsetY += dy;
}
screenToWorld(sx: number, sy: number): { x: number; y: number } {
const rect = this.canvas.getBoundingClientRect();
const x = (sx - rect.left - this.offsetX) / this.scale;
const y = (sy - rect.top - this.offsetY) / this.scale;
return { x, y };
}
worldToScreen(wx: number, wy: number): { x: number; y: number } {
return {
x: wx * this.scale + this.offsetX,
y: wy * this.scale + this.offsetY,
};
}
reset(): void {
this.scale = 1;
this.offsetX = 0;
this.offsetY = 0;
}
}
@@ -0,0 +1,267 @@
import React, { useState, useRef, useCallback } from 'react';
import { BackgroundService, DEFAULT_BACKGROUND, type BackgroundConfig } from '../services/backgroundService';
interface BackgroundImportProps {
open: boolean;
onClose: () => void;
onApply: (config: BackgroundConfig, image: HTMLImageElement | null) => void;
backgroundService: BackgroundService;
}
const BackgroundImport: React.FC<BackgroundImportProps> = ({ open, onClose, onApply, backgroundService }) => {
const [config, setConfig] = useState<BackgroundConfig>({ ...DEFAULT_BACKGROUND });
const [previewUrl, setPreviewUrl] = useState<string>('');
const [calibrationMode, setCalibrationMode] = useState(false);
const [calibPoint1, setCalibPoint1] = useState<{ x: number; y: number } | null>(null);
const [calibPoint2, setCalibPoint2] = useState<{ x: number; y: number } | null>(null);
const [realDistance, setRealDistance] = useState('');
const [unit, setUnit] = useState('m');
const [error, setError] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setError('');
try {
const cfg = await backgroundService.loadFromFile(file);
setConfig(cfg);
setPreviewUrl(cfg.src);
} catch (err) {
setError('Fehler beim Laden des Bildes: ' + (err as Error).message);
}
}, [backgroundService]);
const handlePreviewClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!calibrationMode || !previewCanvasRef.current) return;
const rect = previewCanvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (!calibPoint1) {
setCalibPoint1({ x, y });
} else if (!calibPoint2) {
setCalibPoint2({ x, y });
}
}, [calibrationMode, calibPoint1, calibPoint2]);
const handleCalibrate = useCallback(() => {
if (!calibPoint1 || !calibPoint2 || !realDistance) return;
const dx = calibPoint2.x - calibPoint1.x;
const dy = calibPoint2.y - calibPoint1.y;
const pixelDist = Math.sqrt(dx * dx + dy * dy);
const realDist = parseFloat(realDistance);
if (realDist <= 0) {
setError('Bitte eine gültige Referenzstrecke eingeben.');
return;
}
const result = backgroundService.calibrateScale(pixelDist, realDist, unit);
setConfig(prev => ({ ...prev, scale: result.scale }));
setCalibrationMode(false);
setCalibPoint1(null);
setCalibPoint2(null);
setRealDistance('');
}, [calibPoint1, calibPoint2, realDistance, unit, backgroundService]);
const handleApply = useCallback(() => {
const cfg = backgroundService.getConfig();
onApply(cfg, backgroundService.getImage());
onClose();
}, [backgroundService, onApply, onClose]);
const updateConfig = useCallback((partial: Partial<BackgroundConfig>) => {
backgroundService.updateConfig(partial);
setConfig(backgroundService.getConfig());
}, [backgroundService]);
if (!open) return null;
return (
<div className="modal-overlay" style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.5)', display: 'flex',
alignItems: 'center', justifyContent: 'center', zIndex: 1000,
}}>
<div className="modal-dialog" style={{
background: 'var(--color-bg-primary, #1e1e2e)', borderRadius: '8px',
padding: '24px', maxWidth: '600px', width: '90%', maxHeight: '90vh',
overflow: 'auto', color: 'var(--color-text, #e0e0e0)',
border: '1px solid var(--color-border, #333)',
}}>
<h2 style={{ marginTop: 0, marginBottom: '16px' }}>Hintergrund importieren</h2>
{error && (
<div style={{ color: '#ff6b6b', marginBottom: '12px', fontSize: '14px' }}>{error}</div>
)}
{/* File Upload */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px' }}>Bilddatei (PNG, JPG, SVG)</label>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml,application/pdf"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
<button
onClick={() => fileInputRef.current?.click()}
style={{
padding: '8px 16px', borderRadius: '4px',
border: '1px solid var(--color-border, #444)',
background: 'var(--color-bg-secondary, #2a2a3a)',
color: 'var(--color-text, #e0e0e0)', cursor: 'pointer',
}}
>
Datei wählen
</button>
{config.name && (
<span style={{ marginLeft: '12px', fontSize: '13px' }}>{config.name} ({config.width}×{config.height}px)</span>
)}
</div>
{/* Preview */}
{previewUrl && (
<div style={{ marginBottom: '16px' }}>
<canvas
ref={previewCanvasRef}
onClick={handlePreviewClick}
style={{
maxWidth: '100%', maxHeight: '200px',
border: '1px solid var(--color-border, #333)',
cursor: calibrationMode ? 'crosshair' : 'default',
display: 'block',
}}
width={config.width || 400}
height={config.height || 300}
/>
</div>
)}
{/* Calibration */}
{previewUrl && (
<div style={{ marginBottom: '16px', padding: '12px', border: '1px solid var(--color-border, #333)', borderRadius: '4px' }}>
<div style={{ fontSize: '14px', marginBottom: '8px', fontWeight: 600 }}>Maßstabs-Kalibrierung</div>
{!calibrationMode ? (
<button
onClick={() => setCalibrationMode(true)}
style={{ padding: '6px 12px', fontSize: '13px', cursor: 'pointer',
border: '1px solid var(--color-border, #444)', borderRadius: '4px',
background: 'var(--color-bg-secondary, #2a2a3a)', color: 'var(--color-text, #e0e0e0)' }}
>
Kalibrierung starten
</button>
) : (
<div>
<p style={{ fontSize: '13px', margin: '0 0 8px' }}>
Klicken Sie auf zwei Punkte mit bekanntem Abstand im Bild.
</p>
{calibPoint1 && !calibPoint2 && <p style={{ fontSize: '13px', color: '#8be9fd' }}>Erster Punkt gesetzt. Bitte zweiten Punkt klicken.</p>}
{calibPoint1 && calibPoint2 && (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="number"
placeholder="Referenzstrecke"
value={realDistance}
onChange={(e) => setRealDistance(e.target.value)}
style={{ width: '120px', padding: '4px 8px', fontSize: '13px' }}
/>
<select value={unit} onChange={(e) => setUnit(e.target.value)} style={{ padding: '4px 8px', fontSize: '13px' }}>
<option value="m">m</option>
<option value="cm">cm</option>
<option value="mm">mm</option>
</select>
<button onClick={handleCalibrate} style={{ padding: '6px 12px', fontSize: '13px', cursor: 'pointer' }}>Übernehmen</button>
<button onClick={() => { setCalibPoint1(null); setCalibPoint2(null); }} style={{ padding: '6px 12px', fontSize: '13px', cursor: 'pointer' }}>Zurücksetzen</button>
</div>
)}
</div>
)}
{config.scale !== 1 && (
<p style={{ fontSize: '12px', marginTop: '8px', color: '#50fa7b' }}>
Aktueller Maßstab: {config.scale.toFixed(2)} px/mm
</p>
)}
</div>
)}
{/* Controls */}
{previewUrl && (
<div style={{ marginBottom: '16px' }}>
{/* Opacity */}
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Deckkraft: {Math.round(config.opacity * 100)}%</label>
<input
type="range" min="0" max="1" step="0.05"
value={config.opacity}
onChange={(e) => updateConfig({ opacity: parseFloat(e.target.value) })}
style={{ width: '100%', marginBottom: '12px' }}
/>
{/* Position */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '8px' }}>
<label style={{ fontSize: '13px' }}>X-Offset:
<input type="number" value={config.offsetX}
onChange={(e) => updateConfig({ offsetX: parseFloat(e.target.value) || 0 })}
style={{ width: '80px', marginLeft: '6px', padding: '4px' }} />
</label>
<label style={{ fontSize: '13px' }}>Y-Offset:
<input type="number" value={config.offsetY}
onChange={(e) => updateConfig({ offsetY: parseFloat(e.target.value) || 0 })}
style={{ width: '80px', marginLeft: '6px', padding: '4px' }} />
</label>
</div>
{/* Rotation */}
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Rotation: {config.rotation}°</label>
<input
type="range" min="-180" max="180" step="1"
value={config.rotation}
onChange={(e) => updateConfig({ rotation: parseFloat(e.target.value) })}
style={{ width: '100%', marginBottom: '12px' }}
/>
{/* Scale */}
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Skalierung: {config.scale.toFixed(3)}</label>
<input
type="number" step="0.01" value={config.scale}
onChange={(e) => updateConfig({ scale: parseFloat(e.target.value) || 1 })}
style={{ width: '120px', padding: '4px' }}
/>
{/* Visibility */}
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '12px', fontSize: '13px' }}>
<input
type="checkbox" checked={config.visible}
onChange={(e) => updateConfig({ visible: e.target.checked })}
/>
Sichtbar
</label>
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={onClose}
style={{ padding: '8px 16px', cursor: 'pointer', borderRadius: '4px',
border: '1px solid var(--color-border, #444)', background: 'transparent',
color: 'var(--color-text, #e0e0e0)' }}
>
Abbrechen
</button>
<button
onClick={handleApply}
disabled={!previewUrl}
style={{ padding: '8px 16px', cursor: previewUrl ? 'pointer' : 'not-allowed', borderRadius: '4px',
border: 'none', background: previewUrl ? '#50fa7b' : '#555',
color: previewUrl ? '#1e1e2e' : '#999', fontWeight: 600 }}
>
Anwenden
</button>
</div>
</div>
</div>
);
};
export default BackgroundImport;
+141
View File
@@ -0,0 +1,141 @@
import React, { useState, useRef } from 'react';
import type { BlockLibraryProps } from '../types/ui.types';
import type { BlockDefinition } from '../types/cad.types';
const categories = ['Alle', 'Bestuhlung', 'Tische', 'Bühne', 'Architektur', 'Custom'];
const BlockLibrary: React.FC<BlockLibraryProps> = ({ blocks, category, onCategoryChange, onSearch, onDragBlock, onRenameBlock, onDuplicateBlock, onDeleteBlock, onSvgImport, onSaveGroupAsBlock }) => {
const [searchQuery, setSearchQuery] = useState('');
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set(['Bestuhlung']));
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const q = e.target.value;
setSearchQuery(q);
onSearch(q);
};
const filteredBlocks = blocks.filter(b => {
const catMatch = category === 'Alle' || b.category === category;
const q = searchQuery.toLowerCase().trim();
const searchMatch = !q || b.name.toLowerCase().includes(q) || b.description.toLowerCase().includes(q);
return catMatch && searchMatch;
});
// Group by category
const grouped: Record<string, BlockDefinition[]> = {};
for (const b of filteredBlocks) {
if (!grouped[b.category]) grouped[b.category] = [];
grouped[b.category].push(b);
}
const toggleCat = (cat: string) => {
setExpandedCats((prev) => {
const next = new Set(prev);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
return next;
});
};
const handleDragStart = (e: React.DragEvent, blockId: string) => {
e.dataTransfer.setData('text/block-id', blockId);
e.dataTransfer.effectAllowed = 'copy';
onDragBlock(blockId);
};
const handleSvgImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const svgContent = ev.target?.result as string;
if (onSvgImport) {
onSvgImport(svgContent, file.name.replace(/\.svg$/i, ''), 'Custom');
} else {
window.dispatchEvent(new CustomEvent('block-svg-import', { detail: { svg: svgContent, name: file.name.replace(/\.svg$/i, ''), category: 'Custom' } }));
}
};
reader.readAsText(file);
e.target.value = '';
};
return (
<>
<div className="lib-search">
<span className="lib-search-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</span>
<input type="text" placeholder="Block suchen..." aria-label="Bibliothek durchsuchen" value={searchQuery} onChange={handleSearch} />
</div>
<div className="lib-categories">
{categories.map((cat) => (
<button key={cat} className={`lib-cat${category === cat ? ' active' : ''}`} onClick={() => onCategoryChange(cat)}>{cat}</button>
))}
</div>
<div className="lib-import">
<button className="lib-import-btn" title="SVG importieren" onClick={() => fileInputRef.current?.click()}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<span>SVG importieren</span>
</button>
{onSaveGroupAsBlock && (
<button className="lib-import-btn" title="Auswahl als Block speichern" onClick={() => { const name = window.prompt('Block-Name:', ''); if (name) onSaveGroupAsBlock(name); }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
<span>Als Block speichern</span>
</button>
)}
<input ref={fileInputRef} type="file" accept=".svg,image/svg+xml" style={{ display: 'none' }} onChange={handleSvgImport} />
</div>
<div className="tree tree-library" role="tree" aria-label="Bibliothek-Struktur">
{Object.entries(grouped).map(([cat, catBlocks]) => (
<div key={cat} className="lib-tree-node">
<div className="tree-item tree-item-folder" onClick={() => toggleCat(cat)}>
<span className="tree-toggle">{expandedCats.has(cat) ? '▾' : '▸'}</span>
<span className="tree-icon">📁</span>
<span className="tree-label">{cat}</span>
<span className="tree-count">{catBlocks.length}</span>
</div>
{expandedCats.has(cat) && (
<div className="tree-children">
{catBlocks.map((block) => (
<div
key={block.id}
className="tree-item tree-item-leaf draggable"
draggable
onDragStart={(e) => handleDragStart(e, block.id)}
title={block.description}
>
<span className="tree-icon">📦</span>
<span className="tree-label">{block.name}</span>
<span className="tree-actions" onClick={(e) => e.stopPropagation()}>
{onRenameBlock && (
<button className="layer-action-btn" title="Umbenennen" onClick={() => { const name = window.prompt('Neuer Name:', block.name); if (name) onRenameBlock(block.id, name); }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
)}
{onDuplicateBlock && (
<button className="layer-action-btn" title="Duplizieren" onClick={() => onDuplicateBlock(block.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
)}
{onDeleteBlock && (
<button className="layer-action-btn" title="Löschen" onClick={() => onDeleteBlock(block.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
)}
</span>
</div>
))}
</div>
)}
</div>
))}
{filteredBlocks.length === 0 && (
<div className="lib-empty">Keine Blöcke gefunden</div>
)}
</div>
</>
);
};
export default BlockLibrary;
+357
View File
@@ -0,0 +1,357 @@
import React, { useRef, useEffect, useState } from 'react';
import type { CanvasAreaProps, ViewMode } from '../types/ui.types';
import type { CADElement } from '../types/cad.types';
import type { ToolType } from '../types/cad.types';
import type { UserCursor } from '../crdt';
import { RenderEngine } from '../canvas/RenderEngine';
import { ZoomPanController } from '../canvas/ZoomPanController';
import { InteractionEngine } from '../interaction';
import { SnapEngine } from '../canvas/SnapEngine';
import { SelectionEngine } from '../canvas/SelectionEngine';
import { SpatialIndex } from '../canvas/SpatialIndex';
import { LayerManager } from '../canvas/LayerManager';
const CanvasArea: React.FC<CanvasAreaProps> = ({
cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled,
polarEnabled,
activeTool, elements, layers, activeLayerId, onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged,
onToggleGrid, onToggleOrtho, onToggleSnap, onZoomIn, onZoomOut, onZoomFit, onTextEdit, onCommandTrigger, blocks, onBlockDrop, onSelectionChange, selectedTemplate, bgConfig, remoteCursors,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const zoomPanRef = useRef<ZoomPanController | null>(null);
const renderEngineRef = useRef<RenderEngine | null>(null);
const interactionRef = useRef<InteractionEngine | null>(null);
const spatialIndexRef = useRef<SpatialIndex | null>(null);
const layerManagerRef = useRef<LayerManager | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const zoomPan = new ZoomPanController(canvas);
const spatialIndex = new SpatialIndex();
const layerManager = new LayerManager();
const renderEngine = new RenderEngine(canvas, zoomPan, spatialIndex, layerManager);
const snapEngine = new SnapEngine();
const selectionEngine = new SelectionEngine(renderEngine, spatialIndex, layerManager);
const interaction = new InteractionEngine(
canvas, zoomPan, renderEngine, snapEngine, selectionEngine, spatialIndex, layerManager,
);
zoomPanRef.current = zoomPan;
renderEngineRef.current = renderEngine;
interactionRef.current = interaction;
spatialIndexRef.current = spatialIndex;
layerManagerRef.current = layerManager;
interaction.setCallbacks({
onElementCreated,
onElementsDeleted,
onElementsModified,
onCursorMoved,
onToolStateChanged,
onTextEdit,
onCommandTrigger,
onSelectionChange,
});
interaction.attach();
return () => {
interaction.detach();
zoomPanRef.current = null;
renderEngineRef.current = null;
interactionRef.current = null;
spatialIndexRef.current = null;
layerManagerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update callbacks when they change (avoids stale closures)
useEffect(() => {
const interaction = interactionRef.current;
if (!interaction) return;
interaction.setCallbacks({
onElementCreated,
onElementsDeleted,
onElementsModified,
onCursorMoved,
onToolStateChanged,
onTextEdit,
onCommandTrigger,
onSelectionChange,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged, onTextEdit, onCommandTrigger, onSelectionChange]);
// Resize canvas to fill container
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const container = canvas.parentElement;
if (!container) return;
const resize = () => {
const w = container.clientWidth;
const h = container.clientHeight;
if (w > 0 && h > 0) {
canvas.width = w;
canvas.height = h;
renderEngineRef.current?.render();
}
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(container);
window.addEventListener('resize', resize);
return () => {
ro.disconnect();
window.removeEventListener('resize', resize);
};
}, []);
// Sync elements to interaction engine + spatial index + render
useEffect(() => {
const interaction = interactionRef.current;
const spatialIndex = spatialIndexRef.current;
const renderEngine = renderEngineRef.current;
if (!interaction || !spatialIndex || !renderEngine) return;
interaction.setElements(elements);
spatialIndex.clear();
spatialIndex.bulkInsert(elements);
renderEngine.render();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elements]);
// Sync layers to LayerManager + render
useEffect(() => {
const renderEngine = renderEngineRef.current;
const layerManager = layerManagerRef.current;
if (!renderEngine) return;
if (layerManager) {
layerManager.clear();
layers.forEach(l => layerManager.addLayer(l));
}
renderEngine.setLayers(layers);
renderEngine.render();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layers]);
// Sync blocks to RenderEngine
useEffect(() => {
const renderEngine = renderEngineRef.current;
if (!renderEngine || !blocks) return;
renderEngine.setBlockDefinitions(blocks);
renderEngine.render();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blocks]);
// Sync active layer to LayerManager
useEffect(() => {
const layerManager = layerManagerRef.current;
if (!layerManager || !activeLayerId) return;
layerManager.setActiveLayer(activeLayerId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeLayerId, layers]);
// Sync grid/snap/ortho toggles
useEffect(() => {
const renderEngine = renderEngineRef.current;
const interaction = interactionRef.current;
if (!renderEngine || !interaction) return;
renderEngine.setOptions({ showGrid: gridEnabled });
renderEngine.setOptions({ showSnapPoints: snapEnabled });
renderEngine.setOptions({ showOrtho: orthoEnabled });
interaction.setSnapEnabled(snapEnabled);
interaction.setOrthoEnabled(orthoEnabled);
interaction.setPolarEnabled(polarEnabled);
renderEngine.render();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gridEnabled, snapEnabled, orthoEnabled, polarEnabled]);
// Sync active tool
useEffect(() => {
const interaction = interactionRef.current;
if (!interaction) return;
interaction.setTool(activeTool as ToolType);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTool]);
// Sync selected template to interaction engine
useEffect(() => {
const interaction = interactionRef.current;
if (!interaction) return;
interaction.setSelectedTemplate(selectedTemplate ?? null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTemplate]);
// Compute screen positions for remote cursors
const [cursorScreenPositions, setCursorScreenPositions] = useState<Array<{ cursor: UserCursor; sx: number; sy: number }>>([]);
useEffect(() => {
const zoomPan = zoomPanRef.current;
if (!zoomPan || !remoteCursors || remoteCursors.length === 0) {
setCursorScreenPositions([]);
return;
}
const positions = remoteCursors
.filter((c) => c.visible)
.map((cursor) => {
const screen = zoomPan.worldToScreen(cursor.x, cursor.y);
return { cursor, sx: screen.x, sy: screen.y };
});
setCursorScreenPositions(positions);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [remoteCursors]);
// Re-render cursor overlay when canvas renders (zoom/pan changes)
useEffect(() => {
const renderEngine = renderEngineRef.current;
const zoomPan = zoomPanRef.current;
if (!renderEngine || !zoomPan || !remoteCursors || remoteCursors.length === 0) return;
const origRender = renderEngine.render.bind(renderEngine);
renderEngine.render = () => {
origRender();
const positions = remoteCursors
.filter((c) => c.visible)
.map((cursor) => {
const screen = zoomPan.worldToScreen(cursor.x, cursor.y);
return { cursor, sx: screen.x, sy: screen.y };
});
setCursorScreenPositions(positions);
};
return () => { renderEngine.render = origRender; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [remoteCursors]);
const handleZoomIn = () => {
const zoomPan = zoomPanRef.current;
const canvas = canvasRef.current;
if (zoomPan && canvas) {
zoomPan.zoomAt(canvas.width / 2, canvas.height / 2, 1.2);
renderEngineRef.current?.render();
}
onZoomIn();
};
const handleZoomOut = () => {
const zoomPan = zoomPanRef.current;
const canvas = canvasRef.current;
if (zoomPan && canvas) {
zoomPan.zoomAt(canvas.width / 2, canvas.height / 2, 0.8);
renderEngineRef.current?.render();
}
onZoomOut();
};
const handleZoomFit = () => {
const interaction = interactionRef.current;
if (interaction) {
interaction.zoomFit();
}
onZoomFit();
};
return (
<section className="canvas-area" id="canvas-area" aria-label="CAD Zeichenfläche">
<div className="canvas-coords" role="status" aria-live="polite">
<span><span className="canvas-coords-label">X</span><span className="canvas-coords-val" id="coord-x">{cursorPos.x.toFixed(3)}</span></span>
<span><span className="canvas-coords-label">Y</span><span className="canvas-coords-val" id="coord-y">{cursorPos.y.toFixed(3)}</span></span>
<span style={{ color: 'var(--color-text-faint)' }}>m</span>
</div>
<canvas
ref={canvasRef}
className="canvas-svg"
role="img"
aria-label="CAD Zeichenfläche"
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }}
onDrop={(e) => {
e.preventDefault();
const blockId = e.dataTransfer.getData('text/block-id');
if (!blockId || !onBlockDrop) return;
const rect = e.currentTarget.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const zoomPan = zoomPanRef.current;
if (!zoomPan) return;
const world = zoomPan.screenToWorld(sx, sy);
onBlockDrop(blockId, world.x, world.y);
}}
/>
{cursorScreenPositions.map(({ cursor, sx, sy }) => (
<div
key={cursor.userId}
className="remote-cursor"
style={{
position: 'absolute',
left: `${sx}px`,
top: `${sy}px`,
pointerEvents: 'none',
zIndex: 1000,
}}
>
<div
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: cursor.color,
border: '2px solid white',
boxShadow: '0 0 4px rgba(0,0,0,0.5)',
}}
/>
<div
style={{
position: 'absolute',
left: '16px',
top: '8px',
backgroundColor: cursor.color,
color: 'white',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '11px',
fontFamily: 'sans-serif',
whiteSpace: 'nowrap',
boxShadow: '0 1px 3px rgba(0,0,0,0.4)',
}}
>
{cursor.userName}
</div>
</div>
))}
<div className="canvas-toolbar" role="toolbar" aria-label="Canvas-Werkzeuge">
<button className="canvas-toolbar-btn" title="Herauszoomen (-)" aria-label="Herauszoomen" onClick={handleZoomOut}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
</button>
<span className="canvas-zoom-display" id="zoom-display">100%</span>
<button className="canvas-toolbar-btn" title="Hineinzoomen (+)" aria-label="Hineinzoomen" onClick={handleZoomIn}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
</button>
<button className="canvas-toolbar-btn" title="Zoom anpassen (F)" aria-label="Zoom anpassen" onClick={handleZoomFit}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/></svg>
</button>
<div className="canvas-toolbar-divider"></div>
<button className={`canvas-toolbar-btn${gridEnabled ? ' active' : ''}`} title="Grid ein/aus (F7)" aria-label="Grid umschalten" aria-pressed={gridEnabled} onClick={onToggleGrid}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
</button>
<button className={`canvas-toolbar-btn${orthoEnabled ? ' active' : ''}`} title="Ortho-Modus (F8)" aria-label="Ortho-Modus umschalten" aria-pressed={orthoEnabled} onClick={onToggleOrtho}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l18 18M3 21l18-18"/></svg>
</button>
<button className={`canvas-toolbar-btn${snapEnabled ? ' active' : ''}`} title="Snap ein/aus (F3)" aria-label="Snap umschalten" aria-pressed={snapEnabled} onClick={onToggleSnap}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4M12 18v4M2 12h4M18 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M5.6 18.4l2.8-2.8M15.6 8.4l2.8-2.8"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<div className="canvas-toolbar-divider"></div>
<button className="canvas-toolbar-btn" title="Vorherige Ansicht" aria-label="Vorherige Ansicht">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
</div>
</section>
);
};
export default CanvasArea;
+253
View File
@@ -0,0 +1,253 @@
import React, { useState, useRef, useMemo, useEffect } from 'react';
import type { CommandLineProps } from '../types/ui.types';
import { getCommandRegistry, type CommandDefinition } from '../services/commandRegistry';
const defaultHistory = [
{ prefix: '·' as const, text: 'Bereit · Werkzeug: Auswahl · 110 Objekte · 5 Ebenen', type: 'info' as const },
{ prefix: '·' as const, text: 'Auto-Save aktiv · letzte Speicherung vor 3 Sekunden', type: 'info' as const },
{ prefix: '' as const, text: 'hallo', type: 'command' as const },
{ prefix: '·' as const, text: 'Hallo! Ich bin der KI Copilot. Tippe KI oder drücke Strg+K für Hilfe.', type: 'info' as const },
{ prefix: '' as const, text: 'BESTUHLUNG 5,22', type: 'command' as const },
{ prefix: '·' as const, text: '110 Stühle angelegt auf Ebene "Bestuhlung" ✓', type: 'info' as const },
];
const categoryColors: Record<string, string> = {
draw: '#22c55e',
modify: '#f97316',
view: '#06b6d4',
meta: '#a855f7',
special: '#ec4899',
};
const CommandLine: React.FC<CommandLineProps> = ({ history, onCommand }) => {
const [input, setInput] = useState('');
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const historyRef = useRef<HTMLDivElement>(null);
const entries = history.length > 0 ? history : defaultHistory;
const suggestions = useMemo(() => {
if (!input.trim()) return [];
const registry = getCommandRegistry();
return registry.autocomplete(input.trim());
}, [input]);
useEffect(() => {
setSelectedSuggestion(0);
}, [suggestions]);
useEffect(() => {
if (historyRef.current) {
historyRef.current.scrollTop = historyRef.current.scrollHeight;
}
}, [entries]);
const executeCommand = (cmd: string) => {
const trimmed = cmd.trim();
if (!trimmed) return;
onCommand(trimmed);
setCommandHistory((prev) => [...prev, trimmed]);
setInput('');
setShowSuggestions(false);
setHistoryIndex(-1);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
const navKeys = ['ArrowUp', 'ArrowDown', 'Tab', 'Enter', 'Escape'];
if (showSuggestions && suggestions.length > 0 && navKeys.includes(e.key)) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedSuggestion((prev) => Math.min(prev + 1, suggestions.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedSuggestion((prev) => Math.max(prev - 1, 0));
return;
}
if (e.key === 'Tab') {
e.preventDefault();
const suggestion = suggestions[selectedSuggestion] || suggestions[0];
if (suggestion) {
setInput(suggestion.name);
setShowSuggestions(false);
}
return;
}
if (e.key === 'Enter') {
e.preventDefault();
const suggestion = suggestions[selectedSuggestion];
if (suggestion) {
executeCommand(suggestion.name);
} else {
executeCommand(input);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setShowSuggestions(false);
return;
}
}
if (!showSuggestions || suggestions.length === 0) {
if (e.key === 'Enter' && input.trim()) {
executeCommand(input);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (commandHistory.length === 0) return;
const newIdx = historyIndex === -1 ? commandHistory.length - 1 : Math.max(historyIndex - 1, 0);
setHistoryIndex(newIdx);
setInput(commandHistory[newIdx]);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex === -1) return;
const newIdx = historyIndex + 1;
if (newIdx >= commandHistory.length) {
setHistoryIndex(-1);
setInput('');
} else {
setHistoryIndex(newIdx);
setInput(commandHistory[newIdx]);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setInput('');
setShowSuggestions(false);
setHistoryIndex(-1);
return;
}
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
setShowSuggestions(true);
setHistoryIndex(-1);
};
const handleBlur = () => {
setTimeout(() => setShowSuggestions(false), 150);
};
const handleFocus = () => {
if (input.trim()) setShowSuggestions(true);
};
const handleSuggestionClick = (cmd: CommandDefinition) => {
executeCommand(cmd.name);
};
return (
<section className="cmdline" aria-label="Befehlszeile">
<div className="cmdline-history" id="cmdline-history" aria-live="polite" ref={historyRef}>
{entries.map((entry, i) => (
<div key={i} className={`cmdline-history-entry${entry.type === 'info' ? ' info' : ''}`}>
<span className="prefix">{entry.prefix}</span>
<span className="text">{entry.text}</span>
</div>
))}
</div>
<div className="cmdline-input-wrap" style={{ position: 'relative' }}>
{showSuggestions && suggestions.length > 0 && (
<div
className="cmdline-suggestions"
style={{
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
background: '#1e293b',
border: '1px solid #334155',
borderRadius: '6px 6px 0 0',
maxHeight: '240px',
overflowY: 'auto',
zIndex: 1000,
boxShadow: '0 -4px 12px rgba(0,0,0,0.3)',
}}
>
{suggestions.map((cmd, i) => (
<div
key={cmd.name}
className={`cmdline-suggestion${i === selectedSuggestion ? ' selected' : ''}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 12px',
cursor: 'pointer',
background: i === selectedSuggestion ? '#334155' : 'transparent',
color: '#e2e8f0',
fontSize: '13px',
borderBottom: i < suggestions.length - 1 ? '1px solid #334155' : 'none',
}}
onMouseDown={(e) => {
e.preventDefault();
handleSuggestionClick(cmd);
}}
onMouseEnter={() => setSelectedSuggestion(i)}
>
<span
className="cmdline-suggestion-badge"
style={{
display: 'inline-block',
padding: '1px 6px',
borderRadius: '4px',
fontSize: '10px',
fontWeight: 600,
textTransform: 'uppercase',
background: categoryColors[cmd.category] || '#64748b',
color: '#fff',
minWidth: '44px',
textAlign: 'center',
}}
>
{cmd.category}
</span>
<span className="cmdline-suggestion-name" style={{ fontWeight: 600 }}>
{cmd.name}
</span>
{cmd.aliases.length > 0 && (
<span className="cmdline-suggestion-aliases" style={{ color: '#64748b', fontSize: '11px' }}>
({cmd.aliases.join(', ')})
</span>
)}
<span className="cmdline-suggestion-desc" style={{ color: '#94a3b8', fontSize: '12px', marginLeft: 'auto' }}>
{cmd.description}
</span>
</div>
))}
</div>
)}
<span className="cmdline-prompt"></span>
<input
className="cmdline-input"
type="text"
id="cmdline-input"
placeholder='Befehl eingeben (z. B. LINIE, KREIS, BESTUHLUNG)...'
aria-label="CAD Befehlseingabe"
autoComplete="off"
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onFocus={handleFocus}
ref={inputRef}
/>
</div>
</section>
);
};
export default CommandLine;
+77
View File
@@ -0,0 +1,77 @@
/**
* HistoryPanel Zeigt die Undo/Redo-Historie an.
* F-CAD-09: Historie einsehbar
*/
import React from 'react';
import type { HistoryEntry } from '../history';
interface HistoryPanelProps {
entries: HistoryEntry[];
onJumpTo: (entryId: string) => void;
onClose: () => void;
}
const formatTime = (ts: number): string => {
const d = new Date(ts);
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
};
const HistoryPanel: React.FC<HistoryPanelProps> = ({ entries, onJumpTo, onClose }) => {
if (entries.length === 0) {
return (
<div style={{
position: 'fixed', top: '50%', right: '320px', transform: 'translateY(-50%)',
background: 'var(--color-bg-primary, #1e1e2e)', borderRadius: '8px',
padding: '16px', width: '280px', maxHeight: '60vh', overflow: 'auto',
border: '1px solid var(--color-border, #333)', zIndex: 900,
color: 'var(--color-text, #e0e0e0)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h3 style={{ margin: 0, fontSize: '15px' }}>Historie</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: '18px' }}>×</button>
</div>
<p style={{ fontSize: '13px', color: '#888' }}>Keine Historie vorhanden.</p>
</div>
);
}
return (
<div style={{
position: 'fixed', top: '50%', right: '320px', transform: 'translateY(-50%)',
background: 'var(--color-bg-primary, #1e1e2e)', borderRadius: '8px',
padding: '16px', width: '280px', maxHeight: '60vh', overflow: 'auto',
border: '1px solid var(--color-border, #333)', zIndex: 900,
color: 'var(--color-text, #e0e0e0)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h3 style={{ margin: 0, fontSize: '15px' }}>Historie ({entries.length})</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: '18px' }}>×</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{entries.map((entry, idx) => (
<button
key={entry.id}
onClick={() => onJumpTo(entry.id)}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '6px 8px', borderRadius: '4px', cursor: 'pointer',
border: 'none', textAlign: 'left', width: '100%',
background: entry.isCurrent ? 'rgba(80, 250, 123, 0.15)' : 'transparent',
color: entry.isCurrent ? '#50fa7b' : 'var(--color-text, #e0e0e0)',
fontWeight: entry.isCurrent ? 600 : 400,
fontSize: '13px',
opacity: entry.id.startsWith('redo-') ? 0.5 : 1,
}}
>
<span style={{ fontSize: '11px', color: '#666', minWidth: '28px' }}>{formatTime(entry.timestamp)}</span>
<span style={{ flex: 1 }}>{entry.label}</span>
{entry.isCurrent && <span style={{ fontSize: '10px' }}></span>}
</button>
))}
</div>
</div>
);
};
export default HistoryPanel;
+88
View File
@@ -0,0 +1,88 @@
import React, { useState } from 'react';
import type { KICopilotProps } from '../types/ui.types';
const defaultSuggestions = [
{ id: 's1', icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>, label: '5 Reihen à 22 Stühle anlegen' },
{ id: 's2', icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>, label: 'Optimale Bestuhlung vorschlagen' },
{ id: 's3', icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>, label: 'Überlappende Stühle finden' },
];
const defaultMessages = [
{ id: 'm1', role: 'user' as const, content: 'Lege 5 Reihen mit je 22 Stühlen parallel zur Bühne an, Abstand 1m.' },
{ id: 'm2', role: 'assistant' as const, content: <span>Ich erstelle <strong>110 Stühle</strong> in 5 Reihen (Y=2,5m/3,5m/4,5m/5,5m/6,5m; X-Start=2,5m; 22 Stühle × 0,5m + 0,1m Abstand). Los geht's! <em style={{ color: 'var(--color-ki)' }}>● function_call: placeSeating(rows=5, cols=22, gap=1.0)</em></span> },
];
const KICopilot: React.FC<KICopilotProps> = ({ messages, suggestions, onSend, onSuggestionClick, loading }) => {
const [input, setInput] = useState('');
const allMessages = messages.length > 0 ? messages : defaultMessages;
const allSuggestions = suggestions.length > 0 ? suggestions : defaultSuggestions;
const handleSend = () => {
if (input.trim()) {
onSend(input.trim());
setInput('');
}
};
return (
<>
<div className="ki-header">
<div className="ki-avatar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>
</div>
<div>
<div className="ki-title">KI Copilot</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)' }}>Powered by Claude</div>
</div>
<span className="ki-status">Online</span>
</div>
<div className="ki-suggestions">
<div className="ki-suggestion-title">Schnell-Aktionen</div>
{allSuggestions.map((s) => (
<button key={s.id} className="ki-chip" onClick={() => onSuggestionClick(s)}>
{s.icon}
{s.label}
</button>
))}
</div>
<div className="ki-chat">
{allMessages.map((msg) => (
<div key={msg.id} className={`ki-msg ${msg.role}`}>
<div className="ki-msg-bubble">{msg.content}</div>
</div>
))}
{loading && (
<div className="ki-msg assistant">
<div className="ki-msg-bubble" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span className="ki-typing-dot" />
<span className="ki-typing-dot" />
<span className="ki-typing-dot" />
</div>
</div>
)}
</div>
<div className="ki-input-wrap">
<input
className="ki-input"
type="text"
placeholder="Frage den KI Copilot..."
aria-label="KI Eingabe"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSend(); }}
/>
<button className="ki-voice-btn" title="Spracheingabe" aria-label="Spracheingabe starten">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</button>
<button className="ki-send-btn" title="Senden" aria-label="Senden" onClick={handleSend} disabled={loading}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</>
);
};
export default KICopilot;
+209
View File
@@ -0,0 +1,209 @@
import React from 'react';
import type { LayerPanelProps } from '../types/ui.types';
import type { CADLayer, CADElement } from '../types/cad.types';
import type { TreeNode } from '../types/ui.types';
import TreeView from './TreeView';
const elementTypeLabels: Record<string, string> = {
line: 'Linie',
rect: 'Rechteck',
circle: 'Kreis',
arc: 'Bogen',
polyline: 'Polylinie',
polygon: 'Polygon',
text: 'Text',
dimension: 'Bemassung',
leader: 'Hinweislinie',
revcloud: 'Revisionswolke',
hatch: 'Schraffur',
chair: 'Stuhl',
'seating-row': 'Reihe',
'seating-block': 'Block',
table: 'Tisch',
stage: 'Buhne',
'seating-template': 'Vorlage',
};
const layerIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="12,2 22,8 12,14 2,8" />
<polyline points="2,16 12,22 22,16" />
<polyline points="2,12 12,18 22,12" />
</svg>
);
const elementIcons: Record<string, React.ReactNode> = {
line: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="4" y1="20" x2="20" y2="4"/></svg>,
rect: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="4" y="4" width="16" height="16"/></svg>,
circle: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="8"/></svg>,
arc: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 20 A16 16 0 0 1 20 4"/></svg>,
polyline: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="4,18 8,8 14,14 20,6"/></svg>,
polygon: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="12,4 20,10 16,20 8,20 4,10"/></svg>,
text: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 7V4h16v3M9 20h6M12 4v16"/></svg>,
dimension: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 16 L20 16 M4 16 L4 12 M20 16 L20 12 M8 16 L8 14 M16 16 L16 14"/></svg>,
leader: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 4 L12 12 L12 20"/><circle cx="4" cy="4" r="2"/></svg>,
revcloud: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 16 Q6 12 10 14 Q12 10 16 14 Q20 12 20 16 Q20 20 16 20 L8 20 Q4 20 4 16Z"/></svg>,
hatch: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="4" y="4" width="16" height="16"/><line x1="4" y1="8" x2="20" y2="8"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="16" x2="20" y2="16"/></svg>,
chair: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 4v8h12V4 M6 12v8 M18 12v8 M4 12h16"/></svg>,
'seating-row': <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="8" width="4" height="8"/><rect x="8" y="8" width="4" height="8"/><rect x="14" y="8" width="4" height="8"/><rect x="20" y="8" width="2" height="8"/></svg>,
'seating-block': <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="4" width="6" height="6"/><rect x="10" y="4" width="6" height="6"/><rect x="2" y="12" width="6" height="6"/><rect x="10" y="12" width="6" height="6"/></svg>,
table: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="4" y="6" width="16" height="12"/><line x1="4" y1="10" x2="20" y2="10"/><line x1="4" y1="14" x2="20" y2="14"/></svg>,
stage: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 18 L12 6 L20 18Z"/></svg>,
'seating-template': <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="4" y="4" width="16" height="16"/><circle cx="8" cy="8" r="1"/><circle cx="12" cy="8" r="1"/><circle cx="16" cy="8" r="1"/><circle cx="8" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="16" cy="12" r="1"/></svg>,
};
const defaultIcon = <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="8"/></svg>;
const LayerPanel: React.FC<LayerPanelProps> = ({
layers,
elements = [],
activeLayerId,
onSelectLayer,
onAddLayer,
onToggleLayer,
onDeleteLayer,
onRenameLayer,
onDuplicateLayer,
onToggleLock,
onReorder,
onAddSubLayer,
onElementsDeleted,
onToggleElementVisible,
}) => {
const buildTree = (parentId: string | null): TreeNode[] => {
return layers
.filter((l) => l.parentId === parentId)
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((l) => {
const layerElements = elements.filter((e) => e.layerId === l.id);
const elementNodes: TreeNode[] = layerElements.map((e) => ({
id: e.id,
name: elementTypeLabels[e.type] || e.type,
icon: elementIcons[e.type] || defaultIcon,
expanded: false,
active: false,
children: [],
}));
const subLayerNodes = buildTree(l.id);
return {
id: l.id,
name: l.name,
icon: layerIcon,
expanded: false,
active: l.id === activeLayerId,
children: [...subLayerNodes, ...elementNodes],
count: layerElements.length > 0 ? layerElements.length : undefined,
} as TreeNode;
});
};
const tree = buildTree(null);
const handleAddSubLayer = (parentId: string) => {
if (onAddSubLayer) {
onAddSubLayer(parentId);
}
};
return (
<div className="layer-panel" style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid var(--border-color, #333)' }}>
<span style={{ fontWeight: 600, fontSize: '13px' }}>Ebenen</span>
<button
onClick={onAddLayer}
title="Neue Ebene"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-color, #ccc)', padding: '4px' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '4px 0' }}>
{tree.length === 0 ? (
<div style={{ padding: '12px', textAlign: 'center', color: 'var(--text-muted, #888)', fontSize: '12px' }}>
Keine Ebenen vorhanden
</div>
) : (
<TreeView
nodes={tree}
selectedId={activeLayerId}
onSelect={onSelectLayer}
onReorder={onReorder}
renderIcon={(node) => node.icon}
renderActions={(node) => {
const layer = layers.find((l) => l.id === node.id);
if (layer) {
return (
<div style={{ display: 'flex', gap: '2px', opacity: 0.6 }}>
<button
onClick={(e) => { e.stopPropagation(); onToggleLayer(layer.id); }}
title={layer.visible ? 'Sichtbar' : 'Versteckt'}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: layer.visible ? 'var(--text-color, #ccc)' : 'var(--text-muted, #666)' }}
>
{layer.visible ? '👁' : '🚫'}
</button>
<button
onClick={(e) => { e.stopPropagation(); onToggleLock?.(layer.id); }}
title={layer.locked ? 'Gesperrt' : 'Entsperrt'}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: layer.locked ? 'var(--accent, #f59e0b)' : 'var(--text-muted, #666)' }}
>
{layer.locked ? '🔒' : '🔓'}
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAddSubLayer(layer.id); }}
title="Teilebene hinzufugen"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--text-color, #ccc)' }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); onDuplicateLayer?.(layer.id); }}
title="Duplizieren"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--text-color, #ccc)' }}
>
</button>
<button
onClick={(e) => { e.stopPropagation(); onDeleteLayer?.(layer.id); }}
title="Loschen"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: '#f44336' }}
>
</button>
</div>
);
}
const element = elements.find((el) => el.id === node.id);
if (element) {
const isVisible = element.properties?.visible !== false;
return (
<div style={{ display: 'flex', gap: '2px', opacity: 0.6 }}>
<button
onClick={(e) => { e.stopPropagation(); onToggleElementVisible?.(element.id); }}
title={isVisible ? 'Sichtbar' : 'Versteckt'}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: isVisible ? 'var(--text-color, #ccc)' : 'var(--text-muted, #666)' }}
>
{isVisible ? '👁' : '🚫'}
</button>
<button
onClick={(e) => { e.stopPropagation(); onElementsDeleted?.([element.id]); }}
title="Loschen"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: '#f44336' }}
>
</button>
</div>
);
}
return null;
}}
/>
)}
</div>
</div>
);
};
export default LayerPanel;
+138
View File
@@ -0,0 +1,138 @@
import React from 'react';
import type { LeftSidebarProps } from '../types/ui.types';
import { SEATING_TEMPLATES } from '../services/seatingService';
interface ToolDef {
tool: string;
title: string;
label: string;
kbd: string;
svg: React.ReactNode;
}
const sectionAuswahlen: ToolDef[] = [
{ tool: 'select', title: 'Auswählen (V)', label: 'Auswahl', kbd: 'V', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/><path d="m13 13 6 6"/></svg> },
{ tool: 'pan', title: 'Pan (P / Leertaste)', label: 'Pan', kbd: 'P', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2 2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg> },
{ tool: 'zoom-win', title: 'Zoom-Fenster (Z)', label: 'Zoom', kbd: 'Z', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/></svg> },
{ tool: 'measure', title: 'Messen', label: 'Messen', kbd: 'M', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 8.7 8.7 21.3a1 1 0 0 1-1.4 0L2.7 16.7a1 1 0 0 1 0-1.4L15.3 2.7a1 1 0 0 1 1.4 0l4.6 4.6a1 1 0 0 1 0 1.4z"/><path d="m7.5 10.5 2 2"/><path d="m10.5 7.5 2 2"/></svg> },
];
const sectionZeichnen: ToolDef[] = [
{ tool: 'line', title: 'Linie (L)', label: 'Linie', kbd: 'L', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="19" x2="19" y2="5"/></svg> },
{ tool: 'polyline', title: 'Polylinie (PL)', label: 'Polylinie', kbd: 'PL', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 14 15 20 7"/></svg> },
{ tool: 'rect', title: 'Rechteck (REC)', label: 'Rechteck', kbd: 'REC', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="1"/></svg> },
{ tool: 'circle', title: 'Kreis (C)', label: 'Kreis', kbd: 'C', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg> },
{ tool: 'arc', title: 'Bogen (A)', label: 'Bogen', kbd: 'A', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"/><path d="M3 12a9 9 0 0 1 9-9"/></svg> },
{ tool: 'text', title: 'Text (T)', label: 'Text', kbd: 'T', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg> },
{ tool: 'dimension', title: 'Bemaßung (DIM)', label: 'Bemaßung', kbd: 'DIM', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 3H3l3 18 3-9 6 9 3-9 3 9 3-18z"/><line x1="3" y1="3" x2="21" y2="21"/></svg> },
{ tool: 'hatch', title: 'Schraffur (H)', label: 'Schraffur', kbd: 'H', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="0"/><line x1="3" y1="9" x2="9" y2="3"/><line x1="9" y1="21" x2="21" y2="9"/><line x1="15" y1="21" x2="21" y2="15"/></svg> },
{ tool: 'leader', title: 'Hinweislinie (LD)', label: 'Hinweis', kbd: 'LD', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21 12 12"/><path d="M12 12 18 6"/><polyline points="18 3 18 6 21 6"/><line x1="18" y1="6" x2="21" y2="3"/></svg> },
{ tool: 'revcloud', title: 'Revisionswolke (REV)', label: 'RevCloud', kbd: 'REV', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 18c-1-3 1-5 3-4 0-3 3-4 5-2 1-3 5-3 6 0 2-1 5 1 4 4 1 2-2 4-4 3-1 2-4 2-5 0-2 1-5-1-4-3-2-1-3-4-1-5z"/></svg> },
];
const sectionBearbeiten: ToolDef[] = [
{ tool: 'move', title: 'Verschieben (M)', label: 'Move', kbd: 'M', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg> },
{ tool: 'copy', title: 'Kopieren (CO)', label: 'Copy', kbd: 'CO', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> },
{ tool: 'rotate', title: 'Rotieren (RO)', label: 'Rotate', kbd: 'RO', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> },
{ tool: 'scale', title: 'Skalieren (SC)', label: 'Scale', kbd: 'SC', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg> },
{ tool: 'mirror', title: 'Spiegeln (MI)', label: 'Mirror', kbd: 'MI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M5 8l5-5 5 5"/><path d="M5 16l5 5 5-5"/></svg> },
{ tool: 'trim', title: 'Trimmen (TR)', label: 'Trim', kbd: 'TR', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg> },
{ tool: 'offset', title: 'Versatz (O)', label: 'Offset', kbd: 'O', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h12"/><path d="M3 12h18"/><path d="M3 18h12"/><path d="M21 6v12"/></svg> },
{ tool: 'delete', title: 'Löschen (E)', label: 'Löschen', kbd: 'E', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> },
];
const sectionBestuhlung: ToolDef[] = [
{ tool: 'seating-row', title: 'Reihen-Bestuhlung', label: 'Reihe', kbd: 'R', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="4" height="3" rx="0.5"/><rect x="10" y="5" width="4" height="3" rx="0.5"/><rect x="17" y="5" width="4" height="3" rx="0.5"/><rect x="3" y="11" width="4" height="3" rx="0.5"/><rect x="10" y="11" width="4" height="3" rx="0.5"/><rect x="17" y="11" width="4" height="3" rx="0.5"/><rect x="3" y="17" width="4" height="3" rx="0.5"/><rect x="10" y="17" width="4" height="3" rx="0.5"/><rect x="17" y="17" width="4" height="3" rx="0.5"/></svg> },
{ tool: 'seating-block', title: 'Block-Bestuhlung', label: 'Block', kbd: 'B', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="1"/><rect x="6" y="6" width="3" height="3"/><rect x="10.5" y="6" width="3" height="3"/><rect x="15" y="6" width="3" height="3"/><rect x="6" y="10.5" width="3" height="3"/><rect x="10.5" y="10.5" width="3" height="3"/><rect x="15" y="10.5" width="3" height="3"/><rect x="6" y="15" width="3" height="3"/><rect x="10.5" y="15" width="3" height="3"/><rect x="15" y="15" width="3" height="3"/></svg> },
{ tool: 'table', title: 'Tisch (TAB)', label: 'Tisch', kbd: 'TAB', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="9" width="16" height="6" rx="1"/><line x1="6" y1="15" x2="6" y2="19"/><line x1="18" y1="15" x2="18" y2="19"/></svg> },
{ tool: 'stage', title: 'Bühne', label: 'Bühne', kbd: 'S', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 17h20v3H2z"/><path d="M5 14V8h14v6"/><path d="M9 14v-3"/><path d="M15 14v-3"/></svg> },
{ tool: 'seating-template', title: 'Vorlagen', label: 'Vorlagen', kbd: 'TPL', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg> },
];
const sections: Array<{ label: string; tools: ToolDef[] }> = [
{ label: 'Auswählen / Anzeigen', tools: sectionAuswahlen },
{ label: 'Zeichnen', tools: sectionZeichnen },
{ label: 'Bearbeiten', tools: sectionBearbeiten },
{ label: 'Bestuhlung', tools: sectionBestuhlung },
];
const LeftSidebar: React.FC<LeftSidebarProps> = ({ activeTool, onToolChange, selectedTemplate, onTemplateSelect, onCollapse }) => {
return (
<aside className="leftbar" aria-label="Werkzeugpalette">
<div className="leftbar-header">
<span className="leftbar-title">Werkzeuge</span>
<button className="leftbar-toggle" aria-label="Linke Leiste ausblenden" onClick={onCollapse}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
</div>
{sections.map((section) => (
<div className="tool-section" key={section.label}>
<div className="tool-section-label">{section.label}</div>
<div className="tool-grid">
{section.tools.map((t) => (
<button
key={t.tool}
className={`tool-btn${activeTool === t.tool ? ' active' : ''}`}
data-tool={t.tool}
title={t.title}
onClick={() => onToolChange(t.tool)}
>
{t.svg}
<span className="tool-btn-label">{t.label}</span>
<span className="tool-btn-kbd">{t.kbd}</span>
</button>
))}
</div>
</div>
))}
{activeTool === 'seating-template' && onTemplateSelect && (
<div className="tool-section" key="templates">
<div className="tool-section-label">Vorlagen</div>
<select
className="template-dropdown"
value={selectedTemplate ?? ''}
onChange={(e) => onTemplateSelect(e.target.value || null)}
style={{
width: '100%',
padding: '6px 8px',
borderRadius: '4px',
border: '1px solid var(--color-border)',
background: 'var(--color-bg-secondary)',
color: 'var(--color-text)',
fontSize: '13px',
cursor: 'pointer',
}}
>
<option value=""> Vorlage wählen </option>
{SEATING_TEMPLATES.map((t) => (
<option key={t.name} value={t.name}>
{t.name} ({t.description})
</option>
))}
</select>
{selectedTemplate && (
<div style={{ marginTop: '6px', fontSize: '12px', color: 'var(--color-text-faint)' }}>
Klicken Sie auf die Zeichenfläche, um die Vorlage zu platzieren.
</div>
)}
</div>
)}
<div className="leftbar-footer">
<button className="leftbar-footer-btn" title="Plugin hinzufügen" aria-label="Plugin hinzufügen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
</button>
<button className="leftbar-footer-btn" title="Werkzeugkasten anpassen" aria-label="Werkzeugkasten anpassen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<button className="leftbar-footer-btn" title="Hilfe (F1)" aria-label="Hilfe">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</button>
</div>
</aside>
);
};
export default LeftSidebar;
+59
View File
@@ -0,0 +1,59 @@
import React from 'react';
import type { MobileDrawersProps, DrawerTab } from '../types/ui.types';
const drawerTabs: Array<{ id: DrawerTab; label: string; svg: React.ReactNode }> = [
{ id: 'tool', label: 'Wz', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> },
{ id: 'layer', label: 'Layer', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg> },
{ id: 'library', label: 'Lib', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg> },
{ id: 'ki', label: 'KI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/></svg> },
];
const MobileDrawers: React.FC<MobileDrawersProps> = ({
leftOpen, rightOpen, activeRightTab, onCloseLeft, onCloseRight, onRightTabChange,
}) => {
return (
<>
<div className={`scrim${(leftOpen || rightOpen) ? ' show' : ''}`} aria-hidden="true" onClick={() => { onCloseLeft(); onCloseRight(); }}></div>
<aside className={`drawer drawer-left${leftOpen ? ' open' : ''}`} aria-label="Werkzeugpalette" aria-hidden={!leftOpen}>
<div className="drawer-header">
<span className="drawer-title">Werkzeuge</span>
<button className="drawer-close" aria-label="Schließen" onClick={onCloseLeft}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div className="drawer-body" id="drawer-left-body">
{/* Filled by LeftSidebar content on mobile */}
</div>
</aside>
<aside className={`drawer drawer-right${rightOpen ? ' open' : ''}`} aria-label="Panel" aria-hidden={!rightOpen}>
<div className="drawer-header">
<span className="drawer-title" id="drawer-right-title">Werkzeug</span>
<button className="drawer-close" aria-label="Schließen" onClick={onCloseRight}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div className="drawer-tabs" id="drawer-right-tabs" role="tablist">
{drawerTabs.map((tab) => (
<button
key={tab.id}
className={`drawer-tab${activeRightTab === tab.id ? ' active' : ''}`}
data-drawer-tab={tab.id}
role="tab"
onClick={() => onRightTabChange(tab.id)}
>
{tab.svg}
<span>{tab.label}</span>
</button>
))}
</div>
<div className="drawer-body" id="drawer-right-body">
{/* Filled by panel content on mobile */}
</div>
</aside>
</>
);
};
export default MobileDrawers;
+84
View File
@@ -0,0 +1,84 @@
/**
* PluginManager UI component for managing plugins
*/
import React, { useState, useEffect } from 'react';
import { pluginRegistry } from '../plugins';
import type { PluginState } from '../plugins';
const categoryLabels: Record<string, string> = {
tools: 'Werkzeuge',
elements: 'Elemente',
'import-export': 'Import/Export',
theme: 'Theme',
other: 'Sonstige',
};
const categoryIcons: Record<string, React.ReactNode> = {
tools: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
elements: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>,
'import-export': <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>,
theme: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>,
other: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>,
};
const PluginManager: React.FC = () => {
const [states, setStates] = useState<PluginState[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
useEffect(() => {
const update = () => setStates(pluginRegistry.getStates());
update();
return pluginRegistry.subscribe(update);
}, []);
const handleToggle = (id: string) => {
pluginRegistry.toggle(id);
};
if (states.length === 0) {
return (
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--color-text-muted)' }}>
Keine Plugins installiert.
</div>
);
}
return (
<div className="plugin-manager">
<div className="plugin-manager-header">
<strong>Plugins</strong>
<span style={{ fontSize: '11px', color: 'var(--color-text-muted)' }}>{states.filter(s => s.enabled).length} aktiv · {states.length} gesamt</span>
</div>
{states.map((state) => (
<div key={state.manifest.id} className={`plugin-card ${state.enabled ? 'enabled' : ''} ${expandedId === state.manifest.id ? 'expanded' : ''}`}>
<div className="plugin-card-header" onClick={() => setExpandedId(expandedId === state.manifest.id ? null : state.manifest.id)}>
<div className="plugin-icon" style={{ color: state.enabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>
{categoryIcons[state.manifest.category] || categoryIcons.other}
</div>
<div className="plugin-info">
<div className="plugin-name">{state.manifest.name}</div>
<div className="plugin-meta">v{state.manifest.version} · {categoryLabels[state.manifest.category] || state.manifest.category}</div>
</div>
<button
className={`plugin-toggle ${state.enabled ? 'on' : 'off'}`}
onClick={(e) => { e.stopPropagation(); handleToggle(state.manifest.id); }}
aria-label={state.enabled ? 'Deaktivieren' : 'Aktivieren'}
role="switch"
aria-checked={state.enabled}
>
<span className="plugin-toggle-knob" />
</button>
</div>
{expandedId === state.manifest.id && (
<div className="plugin-card-body">
<p className="plugin-description">{state.manifest.description}</p>
<div className="plugin-author">Autor: {state.manifest.author}</div>
</div>
)}
</div>
))}
</div>
);
};
export default PluginManager;
+110
View File
@@ -0,0 +1,110 @@
import React from 'react';
import type { PropertiesPanelProps } from '../types/ui.types';
const PropertiesPanel: React.FC<PropertiesPanelProps> = ({ selectedElement, layers, onUpdateProperty }) => {
const el = selectedElement;
const x = el ? String(el.x) : '0';
const y = el ? String(el.y) : '0';
const w = el ? String(el.width) : '0.5';
const h = el ? String(el.height) : '0.5';
const rot = el ? String(el.properties.rotation ?? 0) : '0';
const color = (el?.properties.stroke as string) || '#3b82f6';
const blockName = el?.properties.blockId || 'Stuhl-Standard';
const desc = el?.properties.text as string || 'Standard-Konferenzstuhl 0.5×0.5m';
return (
<>
<div className="panel-section">
<div className="panel-section-title">
<span>Auswahl · 1 Stuhl</span>
<button style={{ color: 'var(--color-text-muted)', fontSize: '11px' }} title="Mehr Optionen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</button>
</div>
</div>
<div className="panel-section">
<div className="panel-section-title"><span>Geometrie</span></div>
<div className="prop-row">
<span className="prop-label">Position X</span>
<input className="prop-input" type="text" value={x} aria-label="Position X" onChange={(e) => onUpdateProperty('x', parseFloat(e.target.value) || 0)} />
</div>
<div className="prop-row">
<span className="prop-label">Position Y</span>
<input className="prop-input" type="text" value={y} aria-label="Position Y" onChange={(e) => onUpdateProperty('y', parseFloat(e.target.value) || 0)} />
</div>
<div className="prop-row">
<span className="prop-label">Breite</span>
<input className="prop-input" type="text" value={w} aria-label="Breite" onChange={(e) => onUpdateProperty('width', parseFloat(e.target.value) || 0)} />
</div>
<div className="prop-row">
<span className="prop-label">Tiefe</span>
<input className="prop-input" type="text" value={h} aria-label="Tiefe" onChange={(e) => onUpdateProperty('height', parseFloat(e.target.value) || 0)} />
</div>
<div className="prop-row">
<span className="prop-label">Drehung</span>
<input className="prop-input" type="text" value={rot} aria-label="Drehung" onChange={(e) => onUpdateProperty('rotation', parseFloat(e.target.value) || 0)} />
</div>
</div>
<div className="panel-section">
<div className="panel-section-title"><span>Darstellung</span></div>
<div className="prop-row">
<span className="prop-label">Farbe</span>
<div className="prop-color-row">
<div className="prop-color-swatch" style={{ background: color }} aria-hidden="true"></div>
<input className="prop-swatch-input" type="color" value={color} aria-label="Stuhlfarbe" onChange={(e) => onUpdateProperty('stroke', e.target.value)} />
<input className="prop-input" type="text" value={color.toUpperCase()} aria-label="Farb-Hex" onChange={(e) => onUpdateProperty('stroke', e.target.value)} />
</div>
</div>
<div className="prop-row">
<span className="prop-label">Linientyp</span>
<select className="prop-select" aria-label="Linientyp" value={el?.properties.lineType || 'solid'} onChange={(e) => onUpdateProperty('lineType', e.target.value)}>
<option>Solid (durchgehend)</option>
<option>Dashed (gestrichelt)</option>
<option>Dotted (gepunktet)</option>
<option>Dash-Dot</option>
</select>
</div>
<div className="prop-row">
<span className="prop-label">Stärke</span>
<select className="prop-select" aria-label="Linienstärke" value={String(el?.properties.strokeWidth ?? '0.18')} onChange={(e) => onUpdateProperty('strokeWidth', e.target.value)}>
<option>0.18 mm · Normal</option>
<option>0.25 mm · Dick</option>
<option>0.35 mm · Extra</option>
<option>0.50 mm · Max</option>
</select>
</div>
<div className="prop-row">
<span className="prop-label">Layer</span>
<select className="prop-select" aria-label="Layer zuweisen" value={el?.layerId || ''} onChange={(e) => onUpdateProperty('layerId', e.target.value)}>
{layers.length > 0 ? layers.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
)) : (
<>
<option>Bestuhlung · Standard</option>
<option>Bestuhlung · VIP</option>
<option>Möbel · Tische</option>
<option>Bühne</option>
</>
)}
</select>
</div>
</div>
<div className="panel-section">
<div className="panel-section-title"><span>Block</span></div>
<div className="prop-row">
<span className="prop-label">Block-Name</span>
<input className="prop-input" type="text" value={blockName} aria-label="Block-Name" onChange={(e) => onUpdateProperty('blockId', e.target.value)} />
</div>
<div className="prop-row">
<span className="prop-label">Beschreibung</span>
<input className="prop-input" type="text" value={desc} aria-label="Beschreibung" onChange={(e) => onUpdateProperty('text', e.target.value)} />
</div>
</div>
</>
);
};
export default PropertiesPanel;
+264
View File
@@ -0,0 +1,264 @@
import React from 'react';
import type { RibbonBarProps, RibbonTab } from '../types/ui.types';
const tabs: Array<{ id: RibbonTab; label: string; svg: React.ReactNode }> = [
{ id: 'start', label: 'Start', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg> },
{ id: 'insert', label: 'Einfügen', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg> },
{ id: 'format', label: 'Format', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg> },
{ id: 'view', label: 'Ansicht', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg> },
{ id: 'tools', label: 'Extras', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> },
{ id: 'ki', label: 'KI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg> },
];
const RibbonBar: React.FC<RibbonBarProps> = ({ activeTab, onTabChange, onAction }) => {
return (
<nav className="ribbon" role="navigation" aria-label="Hauptwerkzeuge">
<div className="ribbon-tabs" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
className={`ribbon-tab${activeTab === tab.id ? ' active' : ''}`}
role="tab"
aria-selected={activeTab === tab.id}
data-tab={tab.id}
style={tab.id === 'ki' ? { color: 'var(--color-ki)' } : undefined}
onClick={() => onTabChange(tab.id)}
>
{tab.svg}
<span>{tab.label}</span>
</button>
))}
</div>
<div className="ribbon-content" id="ribbon-content">
{/* ===== START TAB ===== */}
{activeTab === 'start' && (
<>
<div className="ribbon-group compact-mobile" data-ribbon-group="start">
<div className="ribbon-group-btns">
<button className="ribbon-btn" title="Neues Projekt (Strg+N)" onClick={() => onAction('new')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
<span>Neu</span>
</button>
<button className="ribbon-btn" title="Öffnen (Strg+O)" onClick={() => onAction('open')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
<span>Öffnen</span>
</button>
<button className="ribbon-btn" title="Speichern (Strg+S)" onClick={() => onAction('save')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
<span>Speichern</span>
</button>
<button className="ribbon-btn" title="Datei importieren (DXF, SVG, JSON)" onClick={() => onAction('import')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<span>Import</span>
</button>
<button className="ribbon-btn" title="Export als (DXF, SVG, PDF, PNG, JSON)" onClick={() => onAction('export')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span>Export</span>
</button>
</div>
<div className="ribbon-group-label">Datei</div>
</div>
<div className="ribbon-divider"></div>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<button className="ribbon-btn" title="Rückgängig (Strg+Z)" onClick={() => onAction('undo')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
<span>Undo</span>
</button>
<button className="ribbon-btn" title="Wiederherstellen (Strg+Y)" onClick={() => onAction('redo')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 15-6.7L21 13"/></svg>
<span>Redo</span>
</button>
<button className="ribbon-btn" title="Kopieren (Strg+C)" onClick={() => onAction('copy')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span>Kopieren</span>
</button>
<button className="ribbon-btn" title="Einfügen (Strg+V)" onClick={() => onAction('paste')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg>
<span>Einfügen</span>
</button>
</div>
<div className="ribbon-group-label">Bearbeiten</div>
</div>
</>
)}
{/* ===== INSERT TAB ===== */}
{activeTab === 'insert' && (
<>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<button className="ribbon-btn" title="Linie zeichnen" onClick={() => onAction('insert-line')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="19" x2="19" y2="5"/></svg>
<span>Linie</span>
</button>
<button className="ribbon-btn" title="Rechteck zeichnen" onClick={() => onAction('insert-rect')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="1"/></svg>
<span>Rechteck</span>
</button>
<button className="ribbon-btn" title="Kreis zeichnen" onClick={() => onAction('insert-circle')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg>
<span>Kreis</span>
</button>
<button className="ribbon-btn" title="Text einfügen" onClick={() => onAction('insert-text')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>
<span>Text</span>
</button>
<button className="ribbon-btn" title="Freihand zeichnen" onClick={() => onAction('insert-freehand')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 17s5-5 9-5 9 5 9 5"/><path d="M3 12s5-5 9-5 9 5 9 5"/></svg>
<span>Freihand</span>
</button>
</div>
<div className="ribbon-group-label">Formen</div>
</div>
<div className="ribbon-divider"></div>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<button className="ribbon-btn" title="Hintergrund importieren" onClick={() => onAction('background-import')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
<span>Hintergrund</span>
</button>
<button className="ribbon-btn" title="Bild einfügen" onClick={() => onAction('insert-image')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
<span>Bild</span>
</button>
</div>
<div className="ribbon-group-label">Objekt</div>
</div>
</>
)}
{/* ===== FORMAT TAB ===== */}
{activeTab === 'format' && (
<>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<button className="ribbon-btn" title="Linienstärke" onClick={() => onAction('format-stroke-width')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="12" x2="20" y2="12"/></svg>
<span>Linienstärke</span>
</button>
<button className="ribbon-btn" title="Linienfarbe" onClick={() => onAction('format-stroke-color')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="3" y1="12" x2="21" y2="12"/></svg>
<span>Linienfarbe</span>
</button>
<button className="ribbon-btn" title="Füllfarbe" onClick={() => onAction('format-fill-color')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 3l18 18"/></svg>
<span>Füllfarbe</span>
</button>
<button className="ribbon-btn" title="Linientyp" onClick={() => onAction('format-stroke-style')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="8" x2="20" y2="8"/><line x1="4" y1="16" x2="20" y2="16" stroke-dasharray="4 4"/></svg>
<span>Linientyp</span>
</button>
</div>
<div className="ribbon-group-label">Stil</div>
</div>
<div className="ribbon-divider"></div>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<button className="ribbon-btn" title="Nach links ausrichten" onClick={() => onAction('format-align-left')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="4" x2="4" y2="20"/><rect x="6" y="6" width="12" height="4"/><rect x="6" y="14" width="8" height="4"/></svg>
<span>Links</span>
</button>
<button className="ribbon-btn" title="Zentrieren" onClick={() => onAction('format-align-center')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="4" x2="12" y2="20"/><rect x="6" y="6" width="12" height="4"/><rect x="8" y="14" width="8" height="4"/></svg>
<span>Zentriert</span>
</button>
<button className="ribbon-btn" title="Nach rechts ausrichten" onClick={() => onAction('format-align-right')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="20" y1="4" x2="20" y2="20"/><rect x="6" y="6" width="12" height="4"/><rect x="10" y="14" width="8" height="4"/></svg>
<span>Rechts</span>
</button>
</div>
<div className="ribbon-group-label">Ausrichtung</div>
</div>
</>
)}
{/* ===== VIEW TAB ===== */}
{activeTab === 'view' && (
<>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<button className="ribbon-btn" title="Zoom anpassen (F)" onClick={() => onAction('zoom-fit')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><line x1="7" y1="12" x2="17" y2="12"/></svg>
<span>Zoom Fit</span>
</button>
<button className="ribbon-btn" title="100% (1:1)" onClick={() => onAction('zoom-100')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
<span>100%</span>
</button>
<button className="ribbon-btn" title="Grid ein/aus (F7)" onClick={() => onAction('grid')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="0"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
<span>Grid</span>
</button>
<button className="ribbon-btn" title="Layer-Manager" onClick={() => onAction('layer')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
<span>Layer</span>
</button>
</div>
<div className="ribbon-group-label">Ansicht</div>
</div>
</>
)}
{/* ===== TOOLS TAB ===== */}
{activeTab === 'tools' && (
<>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<button className="ribbon-btn" title="Messwerkzeug" onClick={() => onAction('measure')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 8.7 8.7 21.3a1 1 0 0 1-1.4 0L2.7 16.7a1 1 0 0 1 0-1.4L15.3 2.7a1 1 0 0 1 1.4 0l4.6 4.6a1 1 0 0 1 0 1.4z"/><path d="m7.5 10.5 2 2"/><path d="m10.5 7.5 2 2"/><path d="m13.5 4.5 2 2"/><path d="m4.5 13.5 2 2"/></svg>
<span>Messen</span>
</button>
<button className="ribbon-btn" title="Suchen (Strg+F)" onClick={() => onAction('search')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span>Suchen</span>
</button>
<button className="ribbon-btn" title="Plugins" onClick={() => onAction('plugins')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6m0 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/><path d="M12 8a6 6 0 0 0-6 6c0 3 2 4 2 6h8c0-2 2-3 2-6a6 6 0 0 0-6-6z"/></svg>
<span>Plugins</span>
</button>
<button className="ribbon-btn" title="Historie (Strg+H)" onClick={() => onAction('history')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l4 2"/></svg>
<span>Historie</span>
</button>
</div>
<div className="ribbon-group-label">Extras</div>
</div>
</>
)}
{/* ===== KI TAB ===== */}
{activeTab === 'ki' && (
<>
<div className="ribbon-group">
<div className="ribbon-group-btns">
<button className="ribbon-btn" style={{ color: 'var(--color-ki)' }} title="KI Copilot (Ctrl+K)" onClick={() => onAction('ki')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>
<span>KI Copilot</span>
</button>
<button className="ribbon-btn" style={{ color: 'var(--color-ki)' }} title="KI Zeichnen" onClick={() => onAction('ki-draw')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<span>KI Zeichnen</span>
</button>
<button className="ribbon-btn" style={{ color: 'var(--color-ki)' }} title="KI Analysieren" onClick={() => onAction('ki-analyze')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/><path d="M3 5c0 1.66 4 3 9 3s9-1.34 9-3-4-3-9-3-9 1.34-9 3"/></svg>
<span>KI Analysieren</span>
</button>
</div>
<div className="ribbon-group-label">Künstliche Intelligenz</div>
</div>
</>
)}
</div>
</nav>
);
};
export default RibbonBar;
+74
View File
@@ -0,0 +1,74 @@
import React from 'react';
import type { RightSidebarProps, RightPanel } from '../types/ui.types';
import PropertiesPanel from './PropertiesPanel';
import LayerPanel from './LayerPanel';
import BlockLibrary from './BlockLibrary';
import KICopilot from './KICopilot';
const tabs: Array<{ id: RightPanel; label: string; svg: React.ReactNode; badge?: number }> = [
{ id: 'tool', label: 'Werkzeug', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> },
{ id: 'layer', label: 'Layer', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg> },
{ id: 'library', label: 'Bibliothek', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><line x1="3" y1="12" x2="21" y2="12"/></svg> },
{ id: 'ki', label: 'KI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>, badge: 2 },
];
const RightSidebar: React.FC<RightSidebarProps> = ({
activePanel, onPanelChange, selectedElement, layers, blocks,
activeLayerId, onSelectLayer, onAddLayer, onToggleLayer,
onDeleteLayer, onRenameLayer, onDuplicateLayer, onToggleLock,
onReorder, onAddSubLayer,
onElementsDeleted, onToggleElementVisible,
elements,
onRenameBlock, onDuplicateBlock, onDeleteBlock, onSvgImport, onSaveGroupAsBlock,
onBlockCategoryChange, onBlockSearch, onDragBlock,
kiMessages, kiSuggestions, onKISend, onKISuggestionClick, kiLoading, onUpdateElement,
}) => {
return (
<aside className="rightbar" aria-label="Eigenschaften-Panels">
<div className="rightbar-tabs" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
className={`rightbar-tab${activePanel === tab.id ? ' active' : ''}`}
data-panel={tab.id}
role="tab"
aria-selected={activePanel === tab.id}
onClick={() => onPanelChange(tab.id)}
>
{tab.svg}
<span>{tab.label}</span>
{tab.badge !== undefined && <span className="rightbar-tab-badge">{tab.badge}</span>}
</button>
))}
</div>
<div className="rightbar-content">
<div className={`rightbar-panel${activePanel === 'tool' ? ' active' : ''}`} data-panel-content="tool">
{activePanel === 'tool' && <PropertiesPanel selectedElement={selectedElement} layers={layers} onUpdateProperty={(key, value) => {
if (!selectedElement || !onUpdateElement) return;
const updated = { ...selectedElement };
if (key === 'x' || key === 'y' || key === 'width' || key === 'height') {
(updated as any)[key] = value as number;
} else if (key === 'layerId') {
updated.layerId = value as string;
} else {
updated.properties = { ...updated.properties, [key]: value };
}
onUpdateElement(updated);
}} />}
</div>
<div className={`rightbar-panel${activePanel === 'layer' ? ' active' : ''}`} data-panel-content="layer">
{activePanel === 'layer' && <LayerPanel layers={layers} elements={elements} onElementsDeleted={onElementsDeleted} onToggleElementVisible={onToggleElementVisible} activeLayerId={activeLayerId} onSelectLayer={onSelectLayer ?? (() => {})} onAddLayer={onAddLayer ?? (() => {})} onToggleLayer={onToggleLayer ?? (() => {})} onDeleteLayer={onDeleteLayer} onRenameLayer={onRenameLayer} onDuplicateLayer={onDuplicateLayer} onToggleLock={onToggleLock} onReorder={onReorder} onAddSubLayer={onAddSubLayer} />}
</div>
<div className={`rightbar-panel${activePanel === 'library' ? ' active' : ''}`} data-panel-content="library">
{activePanel === 'library' && <BlockLibrary blocks={blocks} category="Alle" onCategoryChange={onBlockCategoryChange ?? (() => {})} onSearch={onBlockSearch ?? (() => {})} onDragBlock={onDragBlock ?? (() => {})} onRenameBlock={onRenameBlock} onDuplicateBlock={onDuplicateBlock} onDeleteBlock={onDeleteBlock} onSvgImport={onSvgImport} onSaveGroupAsBlock={onSaveGroupAsBlock} />}
</div>
<div className={`rightbar-panel${activePanel === 'ki' ? ' active' : ''}`} data-panel-content="ki">
{activePanel === 'ki' && <KICopilot messages={kiMessages ?? []} suggestions={kiSuggestions ?? []} onSend={onKISend ?? (() => {})} onSuggestionClick={onKISuggestionClick ?? (() => {})} loading={kiLoading} />}
</div>
</div>
</aside>
);
};
export default RightSidebar;
+189
View File
@@ -0,0 +1,189 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import PluginManager from './PluginManager';
export interface SettingsModalProps {
open: boolean;
onClose: () => void;
}
type SettingsTab = 'personal' | 'password' | 'language' | 'theme' | 'users' | 'plugins' | 'ai';
const SettingsModal: React.FC<SettingsModalProps> = ({ open, onClose }) => {
const { user } = useAuth();
const [activeTab, setActiveTab] = useState<SettingsTab>('personal');
const [displayName, setDisplayName] = useState(user?.name || '');
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [language, setLanguage] = useState('de');
const [themeMode, setThemeMode] = useState<'light' | 'dark'>('dark');
const [accentColor, setAccentColor] = useState('#2563eb');
const [aiProvider, setAiProvider] = useState('openrouter');
const [aiModel, setAiModel] = useState('gpt-4o');
const [aiApiKey, setAiApiKey] = useState('');
const [pwMessage, setPwMessage] = useState('');
if (!open) return null;
const isAdmin = user?.role === 'admin';
const initials = (user?.name || '?').split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
const tabs: Array<{ id: SettingsTab; label: string; adminOnly?: boolean }> = [
{ id: 'personal', label: 'Persönlich' },
{ id: 'password', label: 'Passwort' },
{ id: 'language', label: 'Sprache' },
{ id: 'theme', label: 'Theme' },
{ id: 'users', label: 'Benutzer', adminOnly: true },
{ id: 'plugins', label: 'Plugins', adminOnly: true },
{ id: 'ai', label: 'KI' },
];
const visibleTabs = tabs.filter(t => !t.adminOnly || isAdmin);
const handlePasswordChange = () => {
if (!oldPassword || !newPassword || !confirmPassword) {
setPwMessage('Bitte alle Felder ausfüllen.');
return;
}
if (newPassword !== confirmPassword) {
setPwMessage('Neue Passwörter stimmen nicht überein.');
return;
}
if (newPassword.length < 6) {
setPwMessage('Passwort muss mindestens 6 Zeichen lang sein.');
return;
}
setPwMessage('Passwort erfolgreich geändert.');
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
};
const renderTabContent = () => {
switch (activeTab) {
case 'personal':
return (
<div className="settings-tab-content">
<label className="settings-label">Anzeigename</label>
<input className="settings-input" type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
<label className="settings-label">E-Mail</label>
<input className="settings-input" type="email" value={user?.email || ''} readOnly style={{ opacity: 0.6 }} />
<label className="settings-label">Avatar</label>
<div className="settings-avatar-preview">{initials}</div>
</div>
);
case 'password':
return (
<div className="settings-tab-content">
<label className="settings-label">Altes Passwort</label>
<input className="settings-input" type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
<label className="settings-label">Neues Passwort</label>
<input className="settings-input" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
<label className="settings-label">Passwort bestätigen</label>
<input className="settings-input" type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} />
{pwMessage && <div className="settings-message">{pwMessage}</div>}
<button className="settings-btn" onClick={handlePasswordChange}>Passwort ändern</button>
</div>
);
case 'language':
return (
<div className="settings-tab-content">
<label className="settings-label">Sprache</label>
<select className="settings-input" value={language} onChange={(e) => setLanguage(e.target.value)}>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
);
case 'theme':
return (
<div className="settings-tab-content">
<label className="settings-label">Erscheinungsbild</label>
<div className="settings-toggle-group">
<button className={`settings-toggle-btn${themeMode === 'light' ? ' active' : ''}`} onClick={() => setThemeMode('light')}>Hell</button>
<button className={`settings-toggle-btn${themeMode === 'dark' ? ' active' : ''}`} onClick={() => setThemeMode('dark')}>Dunkel</button>
</div>
<label className="settings-label">Akzentfarbe</label>
<div className="settings-color-picker">
<input type="color" value={accentColor} onChange={(e) => setAccentColor(e.target.value)} />
<span className="settings-color-value">{accentColor}</span>
</div>
</div>
);
case 'users':
return (
<div className="settings-tab-content">
<div className="settings-users-header">
<strong>Benutzerverwaltung</strong>
<button className="settings-btn">Benutzer hinzufügen</button>
</div>
<div className="settings-user-list">
<div className="settings-user-row">
<div className="settings-user-avatar">LM</div>
<div className="settings-user-info">
<div className="settings-user-name">Leopold M.</div>
<div className="settings-user-email">admin@example.com</div>
</div>
<span className="settings-user-role">Admin</span>
<button className="settings-btn-danger">Löschen</button>
</div>
</div>
</div>
);
case 'plugins':
return (
<div className="settings-tab-content">
<PluginManager />
</div>
);
case 'ai':
return (
<div className="settings-tab-content">
<label className="settings-label">KI-Anbieter</label>
<select className="settings-input" value={aiProvider} onChange={(e) => setAiProvider(e.target.value)}>
<option value="openrouter">OpenRouter</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="local">Lokal (Ollama)</option>
</select>
<label className="settings-label">Modell</label>
<select className="settings-input" value={aiModel} onChange={(e) => setAiModel(e.target.value)}>
<option value="gpt-4o">GPT-4o</option>
<option value="gpt-4o-mini">GPT-4o mini</option>
<option value="claude-3.5-sonnet">Claude 3.5 Sonnet</option>
<option value="llama-3.3-70b">Llama 3.3 70B</option>
</select>
<label className="settings-label">API-Key</label>
<input className="settings-input" type="password" value={aiApiKey} onChange={(e) => setAiApiKey(e.target.value)} placeholder="••••••••••••" />
</div>
);
default:
return null;
}
};
return (
<div className="settings-modal-overlay" onClick={onClose}>
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
<button className="settings-modal-close" onClick={onClose} aria-label="Schließen"></button>
<div className="settings-modal-tabs">
{visibleTabs.map((tab) => (
<button
key={tab.id}
className={`settings-modal-tab${activeTab === tab.id ? ' active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="settings-modal-content">
{renderTabContent()}
</div>
</div>
</div>
);
};
export default SettingsModal;
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import type { StatusBarProps } from '../types/ui.types';
const StatusBar: React.FC<StatusBarProps> = ({
snapEnabled, orthoEnabled, polarEnabled, gridEnabled,
cursorX, cursorY, activeLayer, activeTool, onlineCount,
onToggleSnap, onToggleOrtho, onTogglePolar, onToggleGrid, seatCount,
}) => {
return (
<footer className="statusbar" role="status">
<div className={`status-item${snapEnabled ? ' active' : ''}`} title="Snap-Modus (F3)" aria-pressed={snapEnabled} onClick={onToggleSnap}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
<span>SNAP</span>
</div>
<div className={`status-item${orthoEnabled ? ' active' : ''}`} title="Ortho-Modus (F8)" aria-pressed={orthoEnabled} onClick={onToggleOrtho}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l18 18M3 21l18-18"/></svg>
<span>ORTHO</span>
</div>
<div className={`status-item hide-mobile${polarEnabled ? ' active' : ''}`} title="Polar-Tracking" aria-pressed={polarEnabled} onClick={onTogglePolar}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="16 12 12 8 8 12"/><line x1="12" y1="2" x2="12" y2="16"/></svg>
<span>POLAR</span>
</div>
<div className={`status-item${gridEnabled ? ' active' : ''}`} title="Grid anzeigen (F7)" aria-pressed={gridEnabled} onClick={onToggleGrid}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
<span>GRID</span>
</div>
<div className="status-item" style={{ fontFamily: 'var(--font-mono)' }}>
<span>X: {cursorX.toFixed(3)} m</span>
</div>
<div className="status-item" style={{ fontFamily: 'var(--font-mono)' }}>
<span>Y: {cursorY.toFixed(3)} m</span>
</div>
<div className="status-item hide-mobile" title="Layer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/></svg>
<span>{activeLayer}</span>
</div>
<div className="status-item hide-mobile" title="Werkzeug">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
<span>{activeTool}</span>
</div>
{seatCount !== undefined && seatCount > 0 && (
<div className="status-item hide-mobile" title="Stuhl-Anzahl" style={{ fontFamily: 'var(--font-mono)' }}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="3" width="12" height="18" rx="1"/><line x1="6" y1="7" x2="18" y2="7"/></svg>
<span>{seatCount} Stühle</span>
</div>
)}
<div className="status-item" title={`${onlineCount} anderer Nutzer online`}>
<span className="status-dot"></span>
<span>Online · {onlineCount} weiterer</span>
</div>
</footer>
);
};
export default StatusBar;
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import type { TopbarProps } from '../types/ui.types';
const Topbar: React.FC<TopbarProps> = ({ projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme, onOpenSettings }) => {
return (
<header className="topbar" role="banner">
<div className="topbar-left">
<button className="hamburger-btn" aria-label="Werkzeuge öffnen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div className="app-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h18v18H3z"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
</div>
<span className="app-name">web-cad</span>
<span style={{ color: 'var(--color-border-strong)', margin: '0 4px' }}>|</span>
<button className="project-name" aria-label="Projekt wechseln">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
<span>{projectName}</span>
<svg className="caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<span className="saved-badge" aria-label="Status: Alle Änderungen gespeichert">{savedStatus}</span>
</div>
<div className="topbar-right">
<button className="icon-btn-top" aria-label="Rückgängig (Strg+Z)" title="Rückgängig (Strg+Z)" onClick={onUndo}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>
</button>
<button className="icon-btn-top" aria-label="Wiederherstellen (Strg+Y)" title="Wiederherstellen (Strg+Y)" onClick={onRedo}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 15-6.7L21 13"/></svg>
</button>
<span style={{ width: '1px', height: '20px', background: 'var(--color-border)', margin: '0 4px' }}></span>
<button className="icon-btn-top" aria-label="Versionen" title="Versionsverlauf">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</button>
<button className="icon-btn-top" aria-label="Teilen" title="Projekt teilen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</button>
<span style={{ width: '1px', height: '20px', background: 'var(--color-border)', margin: '0 4px' }}></span>
<button className="icon-btn-top" aria-label="Hell/Dunkel umschalten" title="Theme wechseln" onClick={onThemeToggle}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>
</button>
<button className="icon-btn-top" aria-label="Hilfe" title="Hilfe (F1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</button>
<span style={{ width: '1px', height: '20px', background: 'var(--color-border)', margin: '0 4px' }}></span>
<select className="lang-select" aria-label="Sprache wählen" defaultValue="de">
<option value="de">DE</option>
<option value="en">EN</option>
</select>
<button className="icon-btn-top" aria-label="Benachrichtigungen" title="Benachrichtigungen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
</button>
<button className="icon-btn-top" aria-label="Einstellungen" title="Einstellungen" onClick={onOpenSettings}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<div className="avatar" aria-label="Benutzer Leopold M." title="Leopold M.">LM</div>
</div>
</header>
);
};
export default Topbar;
+149
View File
@@ -0,0 +1,149 @@
import React, { useState } from 'react';
import type { TreeNode } from '../types/ui.types';
interface TreeViewProps {
nodes: TreeNode[];
selectedId?: string | null;
onSelect: (id: string) => void;
onToggle?: (id: string) => void;
onReorder?: (draggedId: string, targetId: string, position: 'before' | 'after' | 'inside') => void;
renderIcon?: (node: TreeNode) => React.ReactNode;
renderActions?: (node: TreeNode) => React.ReactNode;
renderDetail?: (node: TreeNode) => React.ReactNode;
draggable?: boolean;
}
const TreeView: React.FC<TreeViewProps> = ({
nodes,
selectedId,
onSelect,
onToggle,
onReorder,
renderIcon,
renderActions,
renderDetail,
draggable = false,
}) => {
const [expandedSet, setExpandedSet] = useState<Set<string>>(new Set());
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [dragPosition, setDragPosition] = useState<'before' | 'after' | 'inside'>('before');
const toggleExpand = (id: string) => {
setExpandedSet((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
onToggle?.(id);
};
const handleDragStart = (e: React.DragEvent, id: string) => {
if (!draggable || !onReorder) return;
setDraggedId(id);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id);
};
const handleDragOver = (e: React.DragEvent, id: string) => {
if (!draggable || !onReorder || !draggedId || draggedId === id) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const offset = e.clientY - rect.top;
const third = rect.height / 3;
if (offset < third) {
setDragPosition('before');
} else if (offset > third * 2) {
setDragPosition('after');
} else {
setDragPosition('inside');
}
setDragOverId(id);
};
const handleDragLeave = (_e: React.DragEvent, id: string) => {
if (!draggable || !onReorder) return;
if (dragOverId === id) {
setDragOverId(null);
}
};
const handleDrop = (e: React.DragEvent, id: string) => {
if (!draggable || !onReorder || !draggedId || draggedId === id) return;
e.preventDefault();
e.stopPropagation();
onReorder(draggedId, id, dragPosition);
setDraggedId(null);
setDragOverId(null);
};
const handleDragEnd = () => {
setDraggedId(null);
setDragOverId(null);
};
const renderNode = (node: TreeNode, level: number): React.ReactNode => {
const hasChildren = node.children && node.children.length > 0;
const isExpanded = expandedSet.has(node.id) || node.expanded;
const isSelected = selectedId === node.id;
const isDragOver = dragOverId === node.id;
const isDragging = draggedId === node.id;
let dragClass = '';
if (isDragOver && dragPosition === 'before') dragClass = ' tree-drag-before';
if (isDragOver && dragPosition === 'after') dragClass = ' tree-drag-after';
if (isDragOver && dragPosition === 'inside') dragClass = ' tree-drag-inside';
return (
<React.Fragment key={node.id}>
<div
className={`tree-node${isSelected ? ' active' : ''}${dragClass}${isDragging ? ' tree-dragging' : ''}`}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => onSelect(node.id)}
draggable={draggable}
onDragStart={(e) => handleDragStart(e, node.id)}
onDragOver={(e) => handleDragOver(e, node.id)}
onDragLeave={(e) => handleDragLeave(e, node.id)}
onDrop={(e) => handleDrop(e, node.id)}
onDragEnd={handleDragEnd}
>
{hasChildren && (
<button
className="tree-toggle"
onClick={(e) => {
e.stopPropagation();
toggleExpand(node.id);
}}
aria-label={isExpanded ? 'Einklappen' : 'Ausklappen'}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" style={{ transform: isExpanded ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
)}
{!hasChildren && <span className="tree-toggle-placeholder" />}
{renderIcon && <span className="tree-icon">{renderIcon(node)}</span>}
<span className="tree-label">{node.name}</span>
{node.count !== undefined && <span className="tree-count">({node.count})</span>}
{renderActions && <span className="tree-actions" onClick={(e) => e.stopPropagation()}>{renderActions(node)}</span>}
</div>
{renderDetail && isSelected && (
<div className="tree-detail" style={{ paddingLeft: `${level * 16 + 8}px` }}>
{renderDetail(node)}
</div>
)}
{hasChildren && isExpanded && (
<div className="tree-children">
{node.children!.map((child) => renderNode(child, level + 1))}
</div>
)}
</React.Fragment>
);
};
return <div className="tree-view">{nodes.map((node) => renderNode(node, 0))}</div>;
};
export default TreeView;
+125
View File
@@ -0,0 +1,125 @@
/**
* AuthContext Frontend authentication state management
*/
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
const API_BASE = import.meta.env.VITE_API_BASE || '';
export interface AuthUser {
id: string;
email: string;
name: string;
role: string;
created_at: string;
updated_at: string;
}
interface AuthContextValue {
user: AuthUser | null;
token: string | null;
loading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, name: string) => Promise<void>;
logout: () => void;
clearError: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem('auth_token'));
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Validate token on mount
useEffect(() => {
if (!token) return;
fetch(`${API_BASE}/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data) setUser(data);
else {
localStorage.removeItem('auth_token');
setToken(null);
}
})
.catch(() => {
localStorage.removeItem('auth_token');
setToken(null);
});
}, [token]);
const login = useCallback(async (email: string, password: string) => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
setUser(data.user);
setToken(data.session.token);
localStorage.setItem('auth_token', data.session.token);
} catch (err: any) {
setError(err.message);
throw err;
} finally {
setLoading(false);
}
}, []);
const register = useCallback(async (email: string, password: string, name: string) => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Registration failed');
setUser(data.user);
setToken(data.session.token);
localStorage.setItem('auth_token', data.session.token);
} catch (err: any) {
setError(err.message);
throw err;
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(() => {
if (token) {
fetch(`${API_BASE}/api/auth/logout`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
}
localStorage.removeItem('auth_token');
setUser(null);
setToken(null);
}, [token]);
const clearError = useCallback(() => setError(null), []);
return (
<AuthContext.Provider value={{ user, token, loading, error, login, register, logout, clearError }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
+121
View File
@@ -0,0 +1,121 @@
/**
* AwarenessManager Tracks user presence, cursor position, and selection.
* Uses a Y.Map inside the shared doc so awareness state syncs via the same
* raw-update WebSocket protocol as the rest of the document.
*/
import * as Y from 'yjs';
import type { YjsDocument } from './YjsDocument';
export interface UserCursor {
userId: string;
userName: string;
color: string;
x: number;
y: number;
visible: boolean;
}
export interface UserSelection {
userId: string;
elementIds: string[];
}
export interface AwarenessState {
cursors: Y.Map<UserCursor>;
selections: Y.Map<UserSelection>;
}
export class AwarenessManager {
readonly state: AwarenessState;
private yjsDoc: YjsDocument;
private userId: string;
private listeners: Set<() => void> = new Set();
constructor(yjsDoc: YjsDocument, userId: string) {
this.yjsDoc = yjsDoc;
this.userId = userId;
this.state = {
cursors: this.yjsDoc.doc.getMap<UserCursor>('awareness-cursors'),
selections: this.yjsDoc.doc.getMap<UserSelection>('awareness-selections'),
};
}
/** Set the local user's cursor position */
setCursor(x: number, y: number, userName: string, color: string): void {
this.state.cursors.set(this.userId, {
userId: this.userId,
userName,
color,
x,
y,
visible: true,
});
}
/** Hide the local user's cursor */
hideCursor(): void {
const cur = this.state.cursors.get(this.userId);
if (cur) {
this.state.cursors.set(this.userId, { ...cur, visible: false });
}
}
/** Set the local user's selection */
setSelection(elementIds: string[]): void {
this.state.selections.set(this.userId, {
userId: this.userId,
elementIds,
});
}
/** Clear the local user's selection */
clearSelection(): void {
this.state.selections.delete(this.userId);
}
/** Remove the local user from awareness (on disconnect) */
removeSelf(): void {
this.state.cursors.delete(this.userId);
this.state.selections.delete(this.userId);
}
/** Get all active cursors */
getAllCursors(): UserCursor[] {
return Array.from(this.state.cursors.values()).filter((c) => c.visible);
}
/** Get all selections */
getAllSelections(): UserSelection[] {
return Array.from(this.state.selections.values());
}
/** Get a specific user's cursor */
getCursor(userId: string): UserCursor | undefined {
return this.state.cursors.get(userId);
}
/** Get a specific user's selection */
getSelection(userId: string): UserSelection | undefined {
return this.state.selections.get(userId);
}
/** Register a callback for awareness changes */
onChange(callback: () => void): () => void {
this.listeners.add(callback);
const cursorObserver = () => this.notifyListeners();
const selectionObserver = () => this.notifyListeners();
this.state.cursors.observe(cursorObserver);
this.state.selections.observe(selectionObserver);
return () => {
this.listeners.delete(callback);
this.state.cursors.unobserve(cursorObserver);
this.state.selections.unobserve(selectionObserver);
};
}
private notifyListeners(): void {
for (const cb of this.listeners) {
cb();
}
}
}
+144
View File
@@ -0,0 +1,144 @@
/**
* WebSocketProvider Custom WebSocket provider matching the backend raw-update protocol.
* Backend sends Y.encodeStateAsUpdate on connect and applies raw updates on message.
*/
import * as Y from 'yjs';
import type { YjsDocument } from './YjsDocument';
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
export interface WebSocketProviderOptions {
url: string;
docName: string;
yjsDoc: YjsDocument;
onStatusChange?: (status: ConnectionStatus) => void;
onSync?: () => void;
}
export class WebSocketProvider {
private ws: WebSocket | null = null;
private url: string;
private docName: string;
private yjsDoc: YjsDocument;
private status: ConnectionStatus = 'disconnected';
private onStatusChange?: (status: ConnectionStatus) => void;
private onSync?: () => void;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectDelay = 1000;
private maxReconnectDelay = 30000;
private shouldReconnect = true;
constructor(opts: WebSocketProviderOptions) {
this.url = opts.url;
this.docName = opts.docName;
this.yjsDoc = opts.yjsDoc;
this.onStatusChange = opts.onStatusChange;
this.onSync = opts.onSync;
}
connect(): void {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
this.shouldReconnect = true;
this.setStatus('connecting');
const fullUrl = `${this.url}/ws/collab/${encodeURIComponent(this.docName)}`;
try {
this.ws = new WebSocket(fullUrl);
} catch (err) {
console.warn('[WebSocketProvider] Failed to construct WebSocket:', err);
this.setStatus('error');
this.scheduleReconnect();
return;
}
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this.setStatus('connected');
this.reconnectDelay = 1000;
// Send local state to server for initial sync
const stateUpdate = Y.encodeStateAsUpdate(this.yjsDoc.doc);
if (stateUpdate.length > 0) {
this.ws?.send(stateUpdate);
}
this.onSync?.();
};
this.ws.onmessage = (event: MessageEvent) => {
try {
const data = new Uint8Array(event.data as ArrayBuffer);
this.yjsDoc.applyUpdate(data);
} catch (err) {
console.warn('[WebSocketProvider] Failed to apply update:', err);
}
};
this.ws.onerror = () => {
this.setStatus('error');
};
this.ws.onclose = () => {
this.ws = null;
this.setStatus('disconnected');
if (this.shouldReconnect) {
this.scheduleReconnect();
}
};
}
/** Send a local Y.Doc update to the server */
sendUpdate(update: Uint8Array): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(update);
}
}
/** Listen for local doc changes and forward to server */
bindLocalUpdates(): () => void {
const handler = (update: Uint8Array, origin: unknown) => {
// Only send updates that originated locally (not from remote apply)
if (origin !== 'remote') {
this.sendUpdate(update);
}
};
this.yjsDoc.doc.on('update', handler);
return () => {
this.yjsDoc.doc.off('update', handler);
};
}
getStatus(): ConnectionStatus {
return this.status;
}
disconnect(): void {
this.shouldReconnect = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.setStatus('disconnected');
}
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
}
private setStatus(status: ConnectionStatus): void {
if (this.status !== status) {
this.status = status;
this.onStatusChange?.(status);
}
}
}
+131
View File
@@ -0,0 +1,131 @@
/**
* YjsDocument Manages a Y.Doc with shared maps for CAD elements, layers, and blocks.
* Provides helpers to sync local state with the CRDT document.
*/
import * as Y from 'yjs';
import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types';
export interface YjsDocumentData {
elements: Y.Map<CADElement>;
layers: Y.Map<CADLayer>;
blocks: Y.Map<BlockDefinition>;
meta: Y.Map<unknown>;
}
export class YjsDocument {
readonly doc: Y.Doc;
readonly data: YjsDocumentData;
constructor() {
this.doc = new Y.Doc();
this.data = {
elements: this.doc.getMap<CADElement>('elements'),
layers: this.doc.getMap<CADLayer>('layers'),
blocks: this.doc.getMap<BlockDefinition>('blocks'),
meta: this.doc.getMap<unknown>('meta'),
};
}
/** Get all elements as a plain array */
getElements(): CADElement[] {
return Array.from(this.data.elements.values());
}
/** Get all layers as a plain array */
getLayers(): CADLayer[] {
return Array.from(this.data.layers.values());
}
/** Get all blocks as a plain array */
getBlocks(): BlockDefinition[] {
return Array.from(this.data.blocks.values());
}
/** Add or update a single element */
setElement(el: CADElement): void {
this.doc.transact(() => {
this.data.elements.set(el.id, el);
});
}
/** Remove an element by id */
deleteElement(id: string): void {
this.data.elements.delete(id);
}
/** Add or update a layer */
setLayer(layer: CADLayer): void {
this.data.layers.set(layer.id, layer);
}
/** Remove a layer by id */
deleteLayer(id: string): void {
this.data.layers.delete(id);
}
/** Add or update a block definition */
setBlock(block: BlockDefinition): void {
this.data.blocks.set(block.id, block);
}
/** Remove a block by id */
deleteBlock(id: string): void {
this.data.blocks.delete(id);
}
/** Bulk-load local state into the Y.Doc (replaces all content) */
loadFromState(state: {
elements: CADElement[];
layers: CADLayer[];
blocks: BlockDefinition[];
}): void {
this.doc.transact(() => {
this.data.elements.clear();
for (const el of state.elements) {
this.data.elements.set(el.id, el);
}
this.data.layers.clear();
for (const layer of state.layers) {
this.data.layers.set(layer.id, layer);
}
this.data.blocks.clear();
for (const block of state.blocks) {
this.data.blocks.set(block.id, block);
}
});
}
/** Export current state as a plain object */
toState(): {
elements: CADElement[];
layers: CADLayer[];
blocks: BlockDefinition[];
} {
return {
elements: this.getElements(),
layers: this.getLayers(),
blocks: this.getBlocks(),
};
}
/** Encode full document state as update binary */
encodeState(): Uint8Array {
return Y.encodeStateAsUpdate(this.doc);
}
/** Apply a remote update binary */
applyUpdate(update: Uint8Array): void {
Y.applyUpdate(this.doc, update);
}
/** Register a callback for document changes */
onChange(callback: () => void): () => void {
this.doc.on('update', callback);
return () => this.doc.off('update', callback);
}
/** Destroy the document */
destroy(): void {
this.doc.destroy();
}
}
+7
View File
@@ -0,0 +1,7 @@
/**
* CRDT module Real-time collaboration via Yjs
*/
export { YjsDocument, type YjsDocumentData } from './YjsDocument';
export { WebSocketProvider, type ConnectionStatus, type WebSocketProviderOptions } from './WebSocketProvider';
export { AwarenessManager, type UserCursor, type UserSelection, type AwarenessState } from './AwarenessManager';
export { useYjsBinding, type UseYjsBindingOptions, type UseYjsBindingResult } from './useYjsBinding';
+179
View File
@@ -0,0 +1,179 @@
/**
* useYjsBinding React hook that ties YjsDocument, WebSocketProvider,
* and AwarenessManager together for real-time collaboration.
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import * as Y from 'yjs';
import { YjsDocument } from './YjsDocument';
import { WebSocketProvider, type ConnectionStatus } from './WebSocketProvider';
import { AwarenessManager, type UserCursor, type UserSelection } from './AwarenessManager';
import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types';
export interface UseYjsBindingOptions {
docName: string;
wsUrl?: string;
userId: string;
userName: string;
userColor: string;
enabled?: boolean;
}
export interface UseYjsBindingResult {
yjsDoc: YjsDocument | null;
provider: WebSocketProvider | null;
awareness: AwarenessManager | null;
status: ConnectionStatus;
elements: CADElement[];
layers: CADLayer[];
blocks: BlockDefinition[];
cursors: UserCursor[];
selections: UserSelection[];
setElement: (el: CADElement) => void;
deleteElement: (id: string) => void;
setLayer: (layer: CADLayer) => void;
deleteLayer: (id: string) => void;
setBlock: (block: BlockDefinition) => void;
deleteBlock: (id: string) => void;
loadFromState: (state: { elements: CADElement[]; layers: CADLayer[]; blocks: BlockDefinition[] }) => void;
setCursor: (x: number, y: number) => void;
hideCursor: () => void;
setSelection: (elementIds: string[]) => void;
clearSelection: () => void;
}
const DEFAULT_WS_URL = `wss://${window.location.host}`;
export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
const { docName, wsUrl = DEFAULT_WS_URL, userId, userName, userColor, enabled = true } = opts;
const yjsDocRef = useRef<YjsDocument | null>(null);
const providerRef = useRef<WebSocketProvider | null>(null);
const awarenessRef = useRef<AwarenessManager | null>(null);
const unbindRef = useRef<(() => void) | null>(null);
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [elements, setElements] = useState<CADElement[]>([]);
const [layers, setLayers] = useState<CADLayer[]>([]);
const [blocks, setBlocks] = useState<BlockDefinition[]>([]);
const [cursors, setCursors] = useState<UserCursor[]>([]);
const [selections, setSelections] = useState<UserSelection[]>([]);
// Initialize Yjs document, provider, and awareness
useEffect(() => {
if (!enabled || !docName) return;
const doc = new YjsDocument();
yjsDocRef.current = doc;
const provider = new WebSocketProvider({
url: wsUrl,
docName,
yjsDoc: doc,
onStatusChange: (s) => setStatus(s),
});
providerRef.current = provider;
const awareness = new AwarenessManager(doc, userId);
awarenessRef.current = awareness;
// Sync local state from Y.Doc on changes
const syncState = () => {
setElements(doc.getElements());
setLayers(doc.getLayers());
setBlocks(doc.getBlocks());
setCursors(awareness.getAllCursors());
setSelections(awareness.getAllSelections());
};
const unbindDoc = doc.onChange(syncState);
const unbindAwareness = awareness.onChange(syncState);
// Forward local updates to server
const unbindLocal = provider.bindLocalUpdates();
// Connect
provider.connect();
syncState();
// Cleanup
return () => {
unbindDoc();
unbindAwareness();
unbindLocal();
awareness.removeSelf();
provider.disconnect();
doc.destroy();
yjsDocRef.current = null;
providerRef.current = null;
awarenessRef.current = null;
};
}, [docName, wsUrl, userId, userName, userColor, enabled]);
// Mutators
const setElement = useCallback((el: CADElement) => {
yjsDocRef.current?.setElement(el);
}, []);
const deleteElement = useCallback((id: string) => {
yjsDocRef.current?.deleteElement(id);
}, []);
const setLayer = useCallback((layer: CADLayer) => {
yjsDocRef.current?.setLayer(layer);
}, []);
const deleteLayer = useCallback((id: string) => {
yjsDocRef.current?.deleteLayer(id);
}, []);
const setBlock = useCallback((block: BlockDefinition) => {
yjsDocRef.current?.setBlock(block);
}, []);
const deleteBlock = useCallback((id: string) => {
yjsDocRef.current?.deleteBlock(id);
}, []);
const loadFromState = useCallback((state: { elements: CADElement[]; layers: CADLayer[]; blocks: BlockDefinition[] }) => {
yjsDocRef.current?.loadFromState(state);
}, []);
const setCursor = useCallback((x: number, y: number) => {
awarenessRef.current?.setCursor(x, y, userName, userColor);
}, [userName, userColor]);
const hideCursor = useCallback(() => {
awarenessRef.current?.hideCursor();
}, []);
const setSelection = useCallback((elementIds: string[]) => {
awarenessRef.current?.setSelection(elementIds);
}, []);
const clearSelection = useCallback(() => {
awarenessRef.current?.clearSelection();
}, []);
return {
yjsDoc: yjsDocRef.current,
provider: providerRef.current,
awareness: awarenessRef.current,
status,
elements,
layers,
blocks,
cursors,
selections,
setElement,
deleteElement,
setLayer,
deleteLayer,
setBlock,
deleteBlock,
loadFromState,
setCursor,
hideCursor,
setSelection,
clearSelection,
};
}
+230
View File
@@ -0,0 +1,230 @@
/**
* HistoryManager Undo/Redo Stack mit beliebig vielen Schritten.
* Speichert Snapshots des kompletten CAD-Zustands (elements, layers, blocks, groups, bgConfig).
* F-CAD-09: Undo/Redo mit History
*/
import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types';
import type { ElementGroup } from '../tools/modification/GroupTool';
import type { BackgroundConfig } from '../services/backgroundService';
/** Vollständiger CAD-Zustand für einen History-Snapshot */
export interface CADStateSnapshot {
elements: CADElement[];
layers: CADLayer[];
blocks: BlockDefinition[];
groups: ElementGroup[];
bgConfig: BackgroundConfig | null;
timestamp: number;
label: string;
}
/** History-Eintrag für die Historie-Anzeige */
export interface HistoryEntry {
id: string;
label: string;
timestamp: number;
isCurrent: boolean;
}
export interface HistoryManagerOptions {
maxStackSize?: number;
}
const DEFAULT_MAX_SIZE = 100;
export class HistoryManager {
private undoStack: CADStateSnapshot[] = [];
private redoStack: CADStateSnapshot[] = [];
private currentState: CADStateSnapshot | null = null;
private maxStackSize: number;
private listeners: Set<() => void> = new Set();
private idCounter = 0;
constructor(options: HistoryManagerOptions = {}) {
this.maxStackSize = options.maxStackSize ?? DEFAULT_MAX_SIZE;
}
/**
* Initialen Zustand setzen (ohne Undo-Eintrag zu erzeugen).
* Wird beim Laden eines Projekts aufgerufen.
*/
initialize(snapshot: Omit<CADStateSnapshot, 'timestamp' | 'label'>): void {
this.currentState = {
...snapshot,
timestamp: Date.now(),
label: 'Initial',
};
this.undoStack = [];
this.redoStack = [];
this.notifyListeners();
}
/**
* Eine neue Operation aufzeichnen.
* Der aktuelle Zustand wird auf den Undo-Stack geschoben,
* der neue Zustand wird zum aktuellen.
* Der Redo-Stack wird geleert.
*/
pushSnapshot(snapshot: Omit<CADStateSnapshot, 'timestamp' | 'label'>, label: string): void {
if (this.currentState) {
this.undoStack.push(this.currentState);
if (this.undoStack.length > this.maxStackSize) {
this.undoStack.shift();
}
}
this.currentState = {
...snapshot,
timestamp: Date.now(),
label,
};
this.redoStack = [];
this.notifyListeners();
}
/**
* Undo: aktuellen Zustand auf Redo-Stack, letzten Undo-Eintrag holen.
* Gibt den vorherigen Zustand zurück oder null, wenn kein Undo möglich.
*/
undo(): CADStateSnapshot | null {
if (this.undoStack.length === 0 || !this.currentState) return null;
this.redoStack.push(this.currentState);
const previous = this.undoStack.pop()!;
this.currentState = previous;
this.notifyListeners();
return previous;
}
/**
* Redo: aktuellen Zustand auf Undo-Stack, nächsten Redo-Eintrag holen.
* Gibt den nächsten Zustand zurück oder null, wenn kein Redo möglich.
*/
redo(): CADStateSnapshot | null {
if (this.redoStack.length === 0 || !this.currentState) return null;
this.undoStack.push(this.currentState);
const next = this.redoStack.pop()!;
this.currentState = next;
this.notifyListeners();
return next;
}
/** Kann Undo ausgeführt werden? */
canUndo(): boolean {
return this.undoStack.length > 0;
}
/** Kann Redo ausgeführt werden? */
canRedo(): boolean {
return this.redoStack.length > 0;
}
/** Aktuellen Zustand zurückgeben */
getCurrentState(): CADStateSnapshot | null {
return this.currentState;
}
/**
* Historie als Liste zurückgeben (für UI-Anzeige).
* Neueste zuerst, mit isCurrent-Markierung.
*/
getHistory(): HistoryEntry[] {
const entries: HistoryEntry[] = [];
// Undo-Stack (älteste zuerst)
for (let i = 0; i < this.undoStack.length; i++) {
entries.push({
id: `undo-${i}`,
label: this.undoStack[i].label,
timestamp: this.undoStack[i].timestamp,
isCurrent: false,
});
}
// Aktueller Zustand
if (this.currentState) {
entries.push({
id: 'current',
label: this.currentState.label,
timestamp: this.currentState.timestamp,
isCurrent: true,
});
}
// Redo-Stack (neueste zuerst, also umgekehrt)
for (let i = this.redoStack.length - 1; i >= 0; i--) {
entries.push({
id: `redo-${i}`,
label: this.redoStack[i].label,
timestamp: this.redoStack[i].timestamp,
isCurrent: false,
});
}
return entries;
}
/** Anzahl der Undo-Schritte */
getUndoCount(): number {
return this.undoStack.length;
}
/** Anzahl der Redo-Schritte */
getRedoCount(): number {
return this.redoStack.length;
}
/**
* Zu einem bestimmten History-Eintrag springen.
* Macht mehrere Undo- oder Redo-Schritte auf einmal.
*/
jumpTo(entryId: string): CADStateSnapshot | null {
if (entryId === 'current') return this.currentState;
if (entryId.startsWith('undo-')) {
const idx = parseInt(entryId.substring(5), 10);
// Undo bis zu diesem Eintrag
let result: CADStateSnapshot | null = null;
for (let i = this.undoStack.length - 1; i >= idx; i--) {
result = this.undo();
}
return result;
}
if (entryId.startsWith('redo-')) {
const idx = parseInt(entryId.substring(5), 10);
let result: CADStateSnapshot | null = null;
for (let i = 0; i <= idx; i++) {
result = this.redo();
}
return result;
}
return null;
}
/** Alle History löschen und neu initialisieren */
clear(): void {
this.undoStack = [];
this.redoStack = [];
this.currentState = null;
this.notifyListeners();
}
/** Listener registrieren (für React-Updates) */
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notifyListeners(): void {
this.listeners.forEach((l) => l());
}
/** Eindeutige ID generieren */
private generateId(): string {
return `hist-${++this.idCounter}-${Date.now()}`;
}
}
/** Default HistoryManager-Instanz (Singleton) */
let defaultManager: HistoryManager | null = null;
export function getDefaultHistoryManager(): HistoryManager {
if (!defaultManager) {
defaultManager = new HistoryManager();
}
return defaultManager;
}
+2
View File
@@ -0,0 +1,2 @@
export { HistoryManager, getDefaultHistoryManager } from './HistoryManager';
export type { CADStateSnapshot, HistoryEntry, HistoryManagerOptions } from './HistoryManager';
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { AuthProvider } from './contexts/AuthContext';
const container = document.getElementById('root');
if (!container) {
throw new Error('Root container #root not found');
}
createRoot(container).render(
<React.StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>,
);
+150
View File
@@ -0,0 +1,150 @@
/**
* Dashboard Page Project overview after login
*/
import { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_BASE || '';
interface Project {
id: string;
name: string;
description: string | null;
created_at: string;
updated_at: string;
}
interface DashboardProps {
onOpenProject: (projectId: string) => void;
}
export function Dashboard({ onOpenProject }: DashboardProps) {
const { user, logout, token } = useAuth();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const fetchProjects = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`${API_BASE}/api/projects`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
setProjects(await res.json());
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
fetchProjects();
}, [fetchProjects]);
const handleCreate = async () => {
if (!newName.trim()) return;
try {
const res = await fetch(`${API_BASE}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ name: newName, description: newDesc || null }),
});
if (res.ok) {
setNewName('');
setNewDesc('');
setShowCreate(false);
fetchProjects();
}
} catch {
// ignore
}
};
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm('Projekt wirklich löschen?')) return;
try {
await fetch(`${API_BASE}/api/projects/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
fetchProjects();
} catch {
// ignore
}
};
return (
<div className="dashboard-page">
<header className="dashboard-header">
<div className="dashboard-brand">
<h1>Web CAD</h1>
<span className="dashboard-user">{user?.name} ({user?.role})</span>
</div>
<button className="dashboard-logout" onClick={logout}>Abmelden</button>
</header>
<div className="dashboard-content">
<div className="dashboard-section-header">
<h2>Projekte</h2>
<button className="dashboard-create-btn" onClick={() => setShowCreate(!showCreate)}>
+ Neues Projekt
</button>
</div>
{showCreate && (
<div className="dashboard-create-form">
<input
type="text"
placeholder="Projektname"
value={newName}
onChange={e => setNewName(e.target.value)}
autoFocus
/>
<input
type="text"
placeholder="Beschreibung (optional)"
value={newDesc}
onChange={e => setNewDesc(e.target.value)}
/>
<button onClick={handleCreate}>Erstellen</button>
<button onClick={() => setShowCreate(false)}>Abbrechen</button>
</div>
)}
{loading ? (
<p className="dashboard-loading">Lade Projekte</p>
) : projects.length === 0 ? (
<p className="dashboard-empty">Noch keine Projekte. Erstellen Sie ein neues Projekt.</p>
) : (
<div className="dashboard-project-grid">
{projects.map(project => (
<div
key={project.id}
className="dashboard-project-card"
onClick={() => onOpenProject(project.id)}
>
<h3>{project.name}</h3>
{project.description && <p>{project.description}</p>}
<div className="dashboard-project-meta">
<span>Erstellt: {new Date(project.created_at).toLocaleDateString('de-DE')}</span>
<button
className="dashboard-delete-btn"
onClick={(e) => handleDelete(project.id, e)}
>
Löschen
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Login Page Email/Password login
*/
import { useState, type FormEvent } from 'react';
import { useAuth } from '../contexts/AuthContext';
interface LoginProps {
onSwitchToRegister: () => void;
}
export function Login({ onSwitchToRegister }: LoginProps) {
const { login, loading, error, clearError } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
try {
await login(email, password);
} catch {
// error is set in context
}
};
return (
<div className="auth-page">
<div className="auth-card">
<h1 className="auth-title">Web CAD</h1>
<p className="auth-subtitle">Anmelden</p>
{error && (
<div className="auth-error" onClick={clearError}>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="auth-form">
<div className="auth-field">
<label htmlFor="email">E-Mail</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
autoFocus
placeholder="name@example.com"
/>
</div>
<div className="auth-field">
<label htmlFor="password">Passwort</label>
<input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
placeholder="••••••••"
/>
</div>
<button type="submit" className="auth-button" disabled={loading}>
{loading ? 'Wird angemeldet…' : 'Anmelden'}
</button>
</form>
<p className="auth-switch">
Noch kein Konto?{' '}
<button type="button" className="auth-link" onClick={onSwitchToRegister}>
Registrieren
</button>
</p>
</div>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Register Page New user registration
*/
import { useState, type FormEvent } from 'react';
import { useAuth } from '../contexts/AuthContext';
interface RegisterProps {
onSwitchToLogin: () => void;
}
export function Register({ onSwitchToLogin }: RegisterProps) {
const { register, loading, error, clearError } = useAuth();
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [localError, setLocalError] = useState<string | null>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setLocalError(null);
if (password !== confirmPassword) {
setLocalError('Passwörter stimmen nicht überein');
return;
}
if (password.length < 6) {
setLocalError('Passwort muss mindestens 6 Zeichen lang sein');
return;
}
try {
await register(email, password, name);
} catch {
// error is set in context
}
};
const displayError = localError || error;
return (
<div className="auth-page">
<div className="auth-card">
<h1 className="auth-title">Web CAD</h1>
<p className="auth-subtitle">Registrieren</p>
{displayError && (
<div className="auth-error" onClick={() => { clearError(); setLocalError(null); }}>
{displayError}
</div>
)}
<form onSubmit={handleSubmit} className="auth-form">
<div className="auth-field">
<label htmlFor="reg-name">Name</label>
<input
id="reg-name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
autoFocus
placeholder="Ihr Name"
/>
</div>
<div className="auth-field">
<label htmlFor="reg-email">E-Mail</label>
<input
id="reg-email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
placeholder="name@example.com"
/>
</div>
<div className="auth-field">
<label htmlFor="reg-password">Passwort</label>
<input
id="reg-password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
placeholder="min. 6 Zeichen"
/>
</div>
<div className="auth-field">
<label htmlFor="reg-confirm">Passwort bestätigen</label>
<input
id="reg-confirm"
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
required
placeholder="••••••••"
/>
</div>
<button type="submit" className="auth-button" disabled={loading}>
{loading ? 'Wird registriert…' : 'Registrieren'}
</button>
</form>
<p className="auth-switch">
Bereits ein Konto?{' '}
<button type="button" className="auth-link" onClick={onSwitchToLogin}>
Anmelden
</button>
</p>
</div>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
/**
* PluginRegistry Manages plugin registration, lifecycle, and extension lookups.
*/
import type {
Plugin,
PluginContext,
PluginState,
ElementTypeExtension,
ToolExtension,
CommandExtension,
ImportExportExtension,
} from './types';
class PluginRegistryClass {
private plugins = new Map<string, Plugin>();
private states = new Map<string, PluginState>();
private context: PluginContext | null = null;
private listeners = new Set<() => void>();
/** Set the plugin context (called once on app init) */
setContext(ctx: PluginContext) {
this.context = ctx;
}
/** Register a plugin */
register(plugin: Plugin) {
const { id } = plugin.manifest;
if (this.plugins.has(id)) {
console.warn(`[PluginRegistry] Plugin '${id}' already registered`);
return;
}
this.plugins.set(id, plugin);
this.states.set(id, {
manifest: plugin.manifest,
enabled: plugin.manifest.enabledByDefault ?? false,
loaded: false,
});
this.notify();
}
/** Enable and activate a plugin */
enable(pluginId: string) {
const plugin = this.plugins.get(pluginId);
const state = this.states.get(pluginId);
if (!plugin || !state || !this.context) return;
state.enabled = true;
if (!state.loaded) {
plugin.onInit?.(this.context);
state.loaded = true;
}
plugin.onActivate?.(this.context);
this.notify();
}
/** Disable and deactivate a plugin */
disable(pluginId: string) {
const plugin = this.plugins.get(pluginId);
const state = this.states.get(pluginId);
if (!plugin || !state) return;
state.enabled = false;
plugin.onDeactivate?.();
this.notify();
}
/** Toggle plugin enabled state */
toggle(pluginId: string) {
const state = this.states.get(pluginId);
if (!state) return;
if (state.enabled) {
this.disable(pluginId);
} else {
this.enable(pluginId);
}
}
/** Unregister a plugin */
unregister(pluginId: string) {
const plugin = this.plugins.get(pluginId);
if (plugin) {
plugin.onDestroy?.();
}
this.plugins.delete(pluginId);
this.states.delete(pluginId);
this.notify();
}
/** Get all plugin states */
getStates(): PluginState[] {
return Array.from(this.states.values());
}
/** Get a specific plugin */
getPlugin(pluginId: string): Plugin | undefined {
return this.plugins.get(pluginId);
}
/** Get all enabled plugins */
getEnabledPlugins(): Plugin[] {
const result: Plugin[] = [];
for (const [id, plugin] of this.plugins) {
const state = this.states.get(id);
if (state?.enabled) result.push(plugin);
}
return result;
}
/** Get all element type extensions from enabled plugins */
getElementTypeExtensions(): ElementTypeExtension[] {
const extensions: ElementTypeExtension[] = [];
for (const plugin of this.getEnabledPlugins()) {
if (plugin.elementTypes) extensions.push(...plugin.elementTypes);
}
return extensions;
}
/** Get all tool extensions from enabled plugins */
getToolExtensions(): ToolExtension[] {
const extensions: ToolExtension[] = [];
for (const plugin of this.getEnabledPlugins()) {
if (plugin.tools) extensions.push(...plugin.tools);
}
return extensions;
}
/** Get all command extensions from enabled plugins */
getCommandExtensions(): CommandExtension[] {
const extensions: CommandExtension[] = [];
for (const plugin of this.getEnabledPlugins()) {
if (plugin.commands) extensions.push(...plugin.commands);
}
return extensions;
}
/** Get all import/export extensions from enabled plugins */
getImportExportExtensions(): ImportExportExtension[] {
const extensions: ImportExportExtension[] = [];
for (const plugin of this.getEnabledPlugins()) {
if (plugin.importExport) extensions.push(...plugin.importExport);
}
return extensions;
}
/** Find an element type extension by type name */
getElementType(typeName: string): ElementTypeExtension | undefined {
return this.getElementTypeExtensions().find((e) => e.typeName === typeName);
}
/** Subscribe to state changes */
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify() {
this.listeners.forEach((l) => l());
}
/** Initialize all plugins that are enabled by default */
initDefaults() {
for (const [id, plugin] of this.plugins) {
if (plugin.manifest.enabledByDefault) {
this.enable(id);
}
}
}
}
export const pluginRegistry = new PluginRegistryClass();
+221
View File
@@ -0,0 +1,221 @@
/**
* Event-Tools Plugin Built-in example plugin
* Adds custom element types: stage-curtain, spotlight, barrier
* Adds command: EVENT_SEATING (generates seating rows)
*/
import type { Plugin, PluginContext, ElementTypeExtension, CommandExtension } from '../types';
import type { CADElement } from '../../types/cad.types';
function uid(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
}
// ─── Element Type: Stage Curtain ────────────────────────
const stageCurtain: ElementTypeExtension = {
typeName: 'stage-curtain',
displayName: 'Bühnenvorhang',
defaultWidth: 6,
defaultHeight: 0.3,
defaultProperties: {
fill: '#8B0000',
stroke: '#5C0000',
strokeWidth: 2,
curtainStyle: 'pleated',
},
render(ctx, element, scale) {
const { x, y, width, height, properties } = element;
ctx.save();
ctx.fillStyle = (properties.fill as string) || '#8B0000';
ctx.strokeStyle = (properties.stroke as string) || '#5C0000';
ctx.lineWidth = (properties.strokeWidth as number) || 2;
// Draw pleated curtain
const pleats = Math.max(6, Math.floor(width / 0.5));
const pleatWidth = width / pleats;
ctx.beginPath();
ctx.moveTo(x, y);
for (let i = 0; i <= pleats; i++) {
const px = x + i * pleatWidth;
const py = y + (i % 2 === 0 ? 0 : height * 0.15);
ctx.lineTo(px, py);
}
ctx.lineTo(x + width, y + height);
ctx.lineTo(x, y + height);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
return true;
},
hitTest(element, hx, hy, tolerance) {
const { x, y, width, height } = element;
return hx >= x - tolerance && hx <= x + width + tolerance &&
hy >= y - tolerance && hy <= y + height + tolerance;
},
propertyFields: [
{ key: 'fill', label: 'Farbe', type: 'color' },
{ key: 'curtainStyle', label: 'Stil', type: 'select', options: [
{ value: 'pleated', label: 'Gefaltet' },
{ value: 'flat', label: 'Glatt' },
]},
],
};
// ─── Element Type: Spotlight ────────────────────────────
const spotlight: ElementTypeExtension = {
typeName: 'spotlight',
displayName: 'Scheinwerfer',
defaultWidth: 2,
defaultHeight: 2,
defaultProperties: {
fill: 'rgba(255, 220, 100, 0.3)',
stroke: '#FFD700',
strokeWidth: 1.5,
beamAngle: 45,
},
render(ctx, element, scale) {
const { x, y, width, height, properties } = element;
ctx.save();
const cx = x + width / 2;
const cy = y + height / 2;
const radius = Math.max(width, height) / 2;
// Draw beam cone
const beamAngle = ((properties.beamAngle as number) || 45) * Math.PI / 180;
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
gradient.addColorStop(0, 'rgba(255, 220, 100, 0.5)');
gradient.addColorStop(1, 'rgba(255, 220, 100, 0.05)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, radius, -Math.PI / 2 - beamAngle / 2, -Math.PI / 2 + beamAngle / 2);
ctx.closePath();
ctx.fill();
// Draw fixture circle
ctx.fillStyle = '#333';
ctx.strokeStyle = (properties.stroke as string) || '#FFD700';
ctx.lineWidth = (properties.strokeWidth as number) || 1.5;
ctx.beginPath();
ctx.arc(cx, cy, 0.15 * scale, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.restore();
return true;
},
hitTest(element, hx, hy, tolerance) {
const cx = element.x + element.width / 2;
const cy = element.y + element.height / 2;
const radius = Math.max(element.width, element.height) / 2 + tolerance;
const dx = hx - cx;
const dy = hy - cy;
return dx * dx + dy * dy <= radius * radius;
},
propertyFields: [
{ key: 'beamAngle', label: 'Strahlwinkel°', type: 'number', min: 10, max: 180, step: 5 },
{ key: 'stroke', label: 'Farbe', type: 'color' },
],
};
// ─── Element Type: Barrier ──────────────────────────────
const barrier: ElementTypeExtension = {
typeName: 'barrier',
displayName: 'Absperrung',
defaultWidth: 3,
defaultHeight: 0.1,
defaultProperties: {
fill: '#FFA500',
stroke: '#CC8400',
strokeWidth: 1.5,
pattern: 'striped',
},
render(ctx, element, scale) {
const { x, y, width, height, properties } = element;
ctx.save();
ctx.fillStyle = (properties.fill as string) || '#FFA500';
ctx.strokeStyle = (properties.stroke as string) || '#CC8400';
ctx.lineWidth = (properties.strokeWidth as number) || 1.5;
// Draw striped barrier
const stripeWidth = 0.3;
const stripes = Math.floor(width / stripeWidth);
for (let i = 0; i < stripes; i++) {
ctx.fillStyle = i % 2 === 0 ? '#FFA500' : '#000000';
ctx.fillRect(x + i * stripeWidth, y, stripeWidth, height);
}
ctx.strokeStyle = (properties.stroke as string) || '#CC8400';
ctx.strokeRect(x, y, width, height);
ctx.restore();
return true;
},
hitTest(element, hx, hy, tolerance) {
const { x, y, width, height } = element;
return hx >= x - tolerance && hx <= x + width + tolerance &&
hy >= y - tolerance && hy <= y + height + tolerance;
},
propertyFields: [
{ key: 'fill', label: 'Farbe', type: 'color' },
{ key: 'pattern', label: 'Muster', type: 'select', options: [
{ value: 'striped', label: 'Gestreift' },
{ value: 'solid', label: 'Einfarbig' },
]},
],
};
// ─── Command: EVENT_SEATING ─────────────────────────────
const eventSeatingCommand: CommandExtension = {
name: 'EVENT_SEATING',
description: 'Erzeugt Bestuhlung in Reihen',
usage: 'EVENT_SEATING <rows> <cols> [gap] [rowGap]',
execute(args, context) {
const rows = parseInt(args[0] || '5', 10);
const cols = parseInt(args[1] || '10', 10);
const gap = parseFloat(args[2] || '0.6');
const rowGap = parseFloat(args[3] || '1.0');
const layerId = context.getActiveLayerId();
const chairWidth = 0.5;
const chairHeight = 0.5;
const startX = 2;
const startY = 2;
let count = 0;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const el: CADElement = {
id: uid('chair'),
type: 'chair',
layerId,
x: startX + c * (chairWidth + gap),
y: startY + r * (chairHeight + rowGap),
width: chairWidth,
height: chairHeight,
properties: { fill: '#4A90D9', stroke: '#2A70B9', strokeWidth: 1, rotation: 0 },
};
context.addElement(el);
count++;
}
}
context.showToast(`${count} Stühle in ${rows} Reihen erstellt`, 'success');
},
};
// ─── Plugin Definition ──────────────────────────────────
export const eventToolsPlugin: Plugin = {
manifest: {
id: 'event-tools',
name: 'Event-Tools',
version: '1.0.0',
author: 'Web CAD Team',
description: 'Erweitert Web CAD um Bühnenvorhänge, Scheinwerfer, Absperrungen und Bestuhlungs-Befehle.',
category: 'elements',
enabledByDefault: true,
},
elementTypes: [stageCurtain, spotlight, barrier],
commands: [eventSeatingCommand],
onInit(context) {
context.log('Event-Tools Plugin initialisiert');
},
onActivate(context) {
context.log('Event-Tools Plugin aktiviert');
},
};
+26
View File
@@ -0,0 +1,26 @@
/**
* Plugin System Public API
*/
export { pluginRegistry } from './PluginRegistry';
export type {
Plugin,
PluginManifest,
PluginContext,
PluginState,
ElementTypeExtension,
ToolExtension,
CommandExtension,
ImportExportExtension,
PropertyField,
} from './types';
// Built-in plugins
export { eventToolsPlugin } from './builtin/eventTools';
import { pluginRegistry } from './PluginRegistry';
import { eventToolsPlugin } from './builtin/eventTools';
/** Register all built-in plugins */
export function registerBuiltinPlugins() {
pluginRegistry.register(eventToolsPlugin);
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Plugin System Types Manifest, Extension Points, Lifecycle
*/
import type { CADElement, CADLayer } from '../types/cad.types';
// ─── Plugin Manifest ────────────────────────────────────
export interface PluginManifest {
id: string;
name: string;
version: string;
author: string;
description: string;
icon?: string;
category: 'tools' | 'elements' | 'import-export' | 'theme' | 'other';
enabledByDefault?: boolean;
}
// ─── Extension Points ───────────────────────────────────
/** Custom element type with renderer */
export interface ElementTypeExtension {
typeName: string;
displayName: string;
icon?: string;
defaultWidth: number;
defaultHeight: number;
defaultProperties: Record<string, unknown>;
/** Render element on canvas context. Return true if handled. */
render?: (ctx: CanvasRenderingContext2D, element: CADElement, scale: number) => boolean;
/** Optional hit-test for selection */
hitTest?: (element: CADElement, x: number, y: number, tolerance: number) => boolean;
/** Optional property panel fields */
propertyFields?: PropertyField[];
}
/** Custom tool that appears in ribbon bar */
export interface ToolExtension {
id: string;
label: string;
icon: string;
ribbonTab: string;
tooltip?: string;
shortcut?: string;
onActivate: (context: PluginContext) => void;
}
/** Property panel field definition */
export interface PropertyField {
key: string;
label: string;
type: 'text' | 'number' | 'color' | 'select' | 'checkbox';
options?: Array<{ value: string; label: string }>;
min?: number;
max?: number;
step?: number;
}
/** Custom command-line command */
export interface CommandExtension {
name: string;
description: string;
usage: string;
execute: (args: string[], context: PluginContext) => void;
}
/** Custom import/export format */
export interface ImportExportExtension {
format: string;
extension: string;
label: string;
import?: (data: string, context: PluginContext) => CADElement[];
export?: (elements: CADElement[], layers: CADLayer[], context: PluginContext) => string;
}
// ─── Plugin Context (API for plugins) ───────────────────
export interface PluginContext {
/** Add element to current drawing */
addElement: (element: CADElement) => void;
/** Remove element by ID */
removeElement: (id: string) => void;
/** Update element properties */
updateElement: (id: string, properties: Partial<CADElement>) => void;
/** Get all elements */
getElements: () => CADElement[];
/** Get all layers */
getLayers: () => CADLayer[];
/** Get active layer ID */
getActiveLayerId: () => string;
/** Show status message */
showToast: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => void;
/** Log to console with plugin prefix */
log: (message: string) => void;
}
// ─── Plugin Interface ───────────────────────────────────
export interface Plugin {
manifest: PluginManifest;
/** Called when plugin is loaded */
onInit?: (context: PluginContext) => void;
/** Called when plugin is activated */
onActivate?: (context: PluginContext) => void;
/** Called when plugin is deactivated */
onDeactivate?: () => void;
/** Called on plugin unload */
onDestroy?: () => void;
/** Extension points */
elementTypes?: ElementTypeExtension[];
tools?: ToolExtension[];
commands?: CommandExtension[];
importExport?: ImportExportExtension[];
}
// ─── Plugin State ───────────────────────────────────────
export interface PluginState {
manifest: PluginManifest;
enabled: boolean;
loaded: boolean;
}
+420
View File
@@ -0,0 +1,420 @@
/**
* API Service Backend communication for projects, drawings, elements, layers, blocks
*/
import type { CADElement, CADLayer, BlockDefinition } from '../types/cad.types';
const API_BASE = import.meta.env.VITE_API_BASE || '';
function authHeaders(token: string): Record<string, string> {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
};
}
// ─── Types ──────────────────────────────────────────────
export interface Project {
id: string;
name: string;
description: string | null;
owner_id: string;
created_at: string;
updated_at: string;
}
export interface Drawing {
id: string;
project_id: string;
name: string;
created_at: string;
updated_at: string;
}
// ─── Auth ───────────────────────────────────────────────
export async function login(email: string, password: string): Promise<{ user: any; session: { token: string } }> {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error('Login failed');
return res.json();
}
export async function register(email: string, password: string, name: string): Promise<{ user: any; session: { token: string } }> {
const res = await fetch(`${API_BASE}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name }),
});
if (!res.ok) throw new Error('Registration failed');
return res.json();
}
export async function getMe(token: string): Promise<any> {
const res = await fetch(`${API_BASE}/api/auth/me`, {
headers: authHeaders(token),
});
if (!res.ok) throw new Error('Not authenticated');
return res.json();
}
// ─── Projects ───────────────────────────────────────────
export async function getProjects(token: string): Promise<Project[]> {
const res = await fetch(`${API_BASE}/api/projects`, { headers: authHeaders(token) });
if (!res.ok) throw new Error('Failed to load projects');
return res.json();
}
export async function createProject(token: string, name: string, description?: string): Promise<Project> {
const res = await fetch(`${API_BASE}/api/projects`, {
method: 'POST',
headers: authHeaders(token),
body: JSON.stringify({ name, description: description || null }),
});
if (!res.ok) throw new Error('Failed to create project');
return res.json();
}
export async function deleteProject(token: string, id: string): Promise<void> {
await fetch(`${API_BASE}/api/projects/${id}`, {
method: 'DELETE',
headers: authHeaders(token),
});
}
// ─── Drawings ───────────────────────────────────────────
export async function getDrawings(token: string, projectId: string): Promise<Drawing[]> {
const res = await fetch(`${API_BASE}/api/projects/${projectId}/drawings`, { headers: authHeaders(token) });
if (!res.ok) throw new Error('Failed to load drawings');
return res.json();
}
export async function createDrawing(token: string, projectId: string, name: string): Promise<Drawing> {
const res = await fetch(`${API_BASE}/api/projects/${projectId}/drawings`, {
method: 'POST',
headers: authHeaders(token),
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error('Failed to create drawing');
return res.json();
}
// ─── Elements ───────────────────────────────────────────
export async function getElements(token: string, drawingId: string): Promise<CADElement[]> {
const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/elements`, { headers: authHeaders(token) });
if (!res.ok) throw new Error('Failed to load elements');
return res.json();
}
export async function createElement(token: string, drawingId: string, el: CADElement): Promise<CADElement> {
const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/elements`, {
method: 'POST',
headers: authHeaders(token),
body: JSON.stringify(el),
});
if (!res.ok) throw new Error('Failed to create element');
return res.json();
}
export async function updateElement(token: string, elementId: string, patch: Partial<CADElement>): Promise<CADElement> {
const res = await fetch(`${API_BASE}/api/elements/${elementId}`, {
method: 'PATCH',
headers: authHeaders(token),
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error('Failed to update element');
return res.json();
}
export async function deleteElement(token: string, elementId: string): Promise<void> {
await fetch(`${API_BASE}/api/elements/${elementId}`, {
method: 'DELETE',
headers: authHeaders(token),
});
}
// ─── Layers ─────────────────────────────────────────────
export async function getLayers(token: string, drawingId: string): Promise<CADLayer[]> {
const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/layers`, { headers: authHeaders(token) });
if (!res.ok) throw new Error('Failed to load layers');
return res.json();
}
export async function createLayer(token: string, drawingId: string, layer: CADLayer): Promise<CADLayer> {
const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/layers`, {
method: 'POST',
headers: authHeaders(token),
body: JSON.stringify(layer),
});
if (!res.ok) throw new Error('Failed to create layer');
return res.json();
}
export async function updateLayer(token: string, layerId: string, patch: Partial<CADLayer>): Promise<CADLayer> {
const res = await fetch(`${API_BASE}/api/layers/${layerId}`, {
method: 'PATCH',
headers: authHeaders(token),
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error('Failed to update layer');
return res.json();
}
export async function deleteLayer(token: string, layerId: string): Promise<void> {
await fetch(`${API_BASE}/api/layers/${layerId}`, {
method: 'DELETE',
headers: authHeaders(token),
});
}
// ─── Blocks ─────────────────────────────────────────────
export async function getBlocks(token: string, drawingId: string): Promise<BlockDefinition[]> {
const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/blocks`, { headers: authHeaders(token) });
if (!res.ok) throw new Error('Failed to load blocks');
return res.json();
}
export async function createBlock(token: string, drawingId: string, block: BlockDefinition): Promise<BlockDefinition> {
const res = await fetch(`${API_BASE}/api/drawings/${drawingId}/blocks`, {
method: 'POST',
headers: authHeaders(token),
body: JSON.stringify(block),
});
if (!res.ok) throw new Error('Failed to create block');
return res.json();
}
export async function updateBlock(token: string, blockId: string, patch: Partial<BlockDefinition>): Promise<BlockDefinition> {
const res = await fetch(`${API_BASE}/api/blocks/${blockId}`, {
method: 'PATCH',
headers: authHeaders(token),
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error('Failed to update block');
return res.json();
}
export async function deleteBlock(token: string, blockId: string): Promise<void> {
await fetch(`${API_BASE}/api/blocks/${blockId}`, {
method: 'DELETE',
headers: authHeaders(token),
});
}
// ─── Composite: Load full project data ───────────────────
export interface ProjectData {
project: Project;
drawing: Drawing | null;
elements: CADElement[];
layers: CADLayer[];
blocks: BlockDefinition[];
}
export async function loadProjectData(token: string, projectId: string): Promise<ProjectData> {
const drawings = await getDrawings(token, projectId);
const project = (await getProjects(token)).find(p => p.id === projectId);
if (!project) throw new Error('Project not found');
// Use first drawing or create one
let drawing = drawings[0] || null;
if (!drawing) {
drawing = await createDrawing(token, projectId, 'Hauptzeichnung');
}
const [elements, layers, blocks] = await Promise.all([
getElements(token, drawing.id),
getLayers(token, drawing.id),
getBlocks(token, drawing.id),
]);
return { project, drawing, elements, layers, blocks };
}
// ─── Format Conversion: DB (snake_case) ↔ Frontend (camelCase) ─────
function dbElementToFrontend(dbEl: any): CADElement {
return {
id: dbEl.id,
type: dbEl.type,
layerId: dbEl.layer_id,
x: dbEl.x,
y: dbEl.y,
width: dbEl.width,
height: dbEl.height,
properties: typeof dbEl.properties_json === 'string'
? JSON.parse(dbEl.properties_json)
: (dbEl.properties_json || {}),
};
}
function frontendElementToDb(el: CADElement, drawingId: string): any {
return {
id: el.id,
drawing_id: drawingId,
layer_id: el.layerId,
type: el.type,
x: el.x,
y: el.y,
width: el.width,
height: el.height,
properties_json: JSON.stringify(el.properties),
};
}
function dbLayerToFrontend(dbLayer: any): CADLayer {
return {
id: dbLayer.id,
name: dbLayer.name,
visible: !!dbLayer.visible,
locked: !!dbLayer.locked,
color: dbLayer.color,
lineType: dbLayer.line_type as 'solid' | 'dashed' | 'dotted',
transparency: dbLayer.transparency,
sortOrder: dbLayer.sort_order,
parentId: dbLayer.parent_id,
};
}
function frontendLayerToDb(layer: CADLayer, drawingId: string): any {
return {
id: layer.id,
drawing_id: drawingId,
name: layer.name,
visible: layer.visible ? 1 : 0,
locked: layer.locked ? 1 : 0,
color: layer.color,
line_type: layer.lineType,
transparency: layer.transparency,
sort_order: layer.sortOrder,
parent_id: layer.parentId,
};
}
function dbBlockToFrontend(dbBlock: any): BlockDefinition {
return {
id: dbBlock.id,
name: dbBlock.name,
description: dbBlock.description || '',
category: dbBlock.category,
elements: typeof dbBlock.elements_json === 'string'
? JSON.parse(dbBlock.elements_json)
: (dbBlock.elements_json || []),
thumbnail: dbBlock.thumbnail || undefined,
};
}
function frontendBlockToDb(block: BlockDefinition, drawingId: string): any {
return {
id: block.id,
drawing_id: drawingId,
name: block.name,
description: block.description,
category: block.category,
elements_json: JSON.stringify(block.elements),
thumbnail: block.thumbnail || null,
};
}
// ─── Typed API calls with conversion ─────────────────────
export async function getElementsTyped(token: string, drawingId: string): Promise<CADElement[]> {
const raw = await getElements(token, drawingId);
return raw.map(dbElementToFrontend);
}
export async function createElementTyped(token: string, drawingId: string, el: CADElement): Promise<CADElement> {
const raw = await createElement(token, drawingId, frontendElementToDb(el, drawingId));
return dbElementToFrontend(raw);
}
export async function getLayersTyped(token: string, drawingId: string): Promise<CADLayer[]> {
const raw = await getLayers(token, drawingId);
return raw.map(dbLayerToFrontend);
}
export async function createLayerTyped(token: string, drawingId: string, layer: CADLayer): Promise<CADLayer> {
const raw = await createLayer(token, drawingId, frontendLayerToDb(layer, drawingId));
return dbLayerToFrontend(raw);
}
export async function getBlocksTyped(token: string, drawingId: string): Promise<BlockDefinition[]> {
const raw = await getBlocks(token, drawingId);
return raw.map(dbBlockToFrontend);
}
export async function createBlockTyped(token: string, drawingId: string, block: BlockDefinition): Promise<BlockDefinition> {
const raw = await createBlock(token, drawingId, frontendBlockToDb(block, drawingId));
return dbBlockToFrontend(raw);
}
const projectLoadCache = new Map<string, Promise<ProjectData>>();
export async function loadProjectDataTyped(token: string, projectId: string): Promise<ProjectData> {
// Dedup concurrent calls (React StrictMode double-render)
const existing = projectLoadCache.get(projectId);
if (existing) return existing;
const promise = (async () => {
const drawings = await getDrawings(token, projectId);
const projects = await getProjects(token);
const project = projects.find(p => p.id === projectId);
if (!project) throw new Error('Project not found');
let drawing = drawings[0] || null;
if (!drawing) {
drawing = await createDrawing(token, projectId, 'Hauptzeichnung');
}
const [elementsRaw, layersRaw, blocksRaw] = await Promise.all([
getElements(token, drawing.id),
getLayers(token, drawing.id),
getBlocks(token, drawing.id),
]);
return {
project,
drawing,
elements: elementsRaw.map(dbElementToFrontend),
layers: layersRaw.map(dbLayerToFrontend),
blocks: blocksRaw.map(dbBlockToFrontend),
};
})();
projectLoadCache.set(projectId, promise);
promise.finally(() => projectLoadCache.delete(projectId));
return promise;
}
export { API_BASE };
// ─── AI Copilot ─────────────────────────────────────────
export interface AIChatMessage {
role: string;
content: string;
}
export interface AIChatContext {
projectName?: string;
elementCount?: number;
layerCount?: number;
elementTypeSummary?: Record<string, number>;
}
export async function aiChat(
token: string,
messages: AIChatMessage[],
context?: AIChatContext
): Promise<{ content: string; suggestions?: string[] }> {
const res = await fetch(`${API_BASE}/api/ai/chat`, {
method: 'POST',
headers: authHeaders(token),
body: JSON.stringify({ messages, context }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'AI request failed' }));
throw new Error(err.error || 'AI request failed');
}
return res.json();
}
+249
View File
@@ -0,0 +1,249 @@
import type { ProjectData } from '../types/cad.types';
/**
* Background Service — manages background image loading, positioning, scaling, and calibration.
* Supports PNG, JPG, SVG as background images.
*/
export interface BackgroundConfig {
src: string; // data URL or path to image
name: string; // original file name
format: 'png' | 'jpg' | 'svg' | 'pdf';
width: number; // natural image width in pixels
height: number; // natural image height in pixels
scale: number; // pixels per world-unit (e.g. 1px = 0.01m → scale=100)
offsetX: number; // world X offset
offsetY: number; // world Y offset
rotation: number; // rotation in degrees
visible: boolean;
opacity: number; // 0-1
}
export const DEFAULT_BACKGROUND: BackgroundConfig = {
src: '',
name: '',
format: 'png',
width: 0,
height: 0,
scale: 1,
offsetX: 0,
offsetY: 0,
rotation: 0,
visible: true,
opacity: 0.5,
};
export interface CalibrationResult {
scale: number; // computed pixels per world-unit
unit: string; // 'm' | 'cm' | 'mm'
}
export class BackgroundService {
private image: HTMLImageElement | null = null;
private config: BackgroundConfig = { ...DEFAULT_BACKGROUND };
/** Load an image file and return its config */
async loadFromFile(file: File): Promise<BackgroundConfig> {
const format = this.detectFormat(file);
const src = await this.fileToDataURL(file);
const { width, height } = await this.getImageDimensions(src);
this.config = {
...DEFAULT_BACKGROUND,
src,
name: file.name,
format,
width,
height,
};
this.image = new Image();
this.image.src = src;
return this.config;
}
/** Load from a URL or data string */
async loadFromSrc(src: string, name: string = 'background'): Promise<BackgroundConfig> {
const { width, height } = await this.getImageDimensions(src);
const format = this.detectFormatFromSrc(src);
this.config = {
...DEFAULT_BACKGROUND,
src,
name,
format,
width,
height,
};
this.image = new Image();
this.image.src = src;
return this.config;
}
/** Get the current background config */
getConfig(): BackgroundConfig {
return { ...this.config };
}
/** Update background config */
updateConfig(partial: Partial<BackgroundConfig>): BackgroundConfig {
this.config = { ...this.config, ...partial };
return this.getConfig();
}
/** Set visibility */
setVisible(visible: boolean): void {
this.config.visible = visible;
}
/** Set opacity (0-1) */
setOpacity(opacity: number): void {
this.config.opacity = Math.max(0, Math.min(1, opacity));
}
/** Move background by delta */
move(dx: number, dy: number): void {
this.config.offsetX += dx;
this.config.offsetY += dy;
}
/** Set position directly */
setPosition(x: number, y: number): void {
this.config.offsetX = x;
this.config.offsetY = y;
}
/** Rotate background by delta degrees */
rotate(deltaAngle: number): void {
this.config.rotation += deltaAngle;
}
/** Set rotation directly */
setRotation(angle: number): void {
this.config.rotation = angle;
}
/** Scale background by factor */
scaleBy(factor: number): void {
this.config.scale *= factor;
}
/** Set scale directly */
setScale(scale: number): void {
this.config.scale = Math.max(0.001, scale);
}
/** Calibrate scale using a reference distance
* @param pixelDistance - measured distance in pixels between two points on the image
* @param realDistance - known real-world distance
* @param unit - unit of realDistance ('m', 'cm', 'mm')
* @returns computed scale (pixels per world-unit)
*/
calibrateScale(pixelDistance: number, realDistance: number, unit: string = 'm'): CalibrationResult {
if (realDistance <= 0 || pixelDistance <= 0) {
return { scale: this.config.scale, unit };
}
// Convert realDistance to base unit (mm)
let realMm = realDistance;
switch (unit) {
case 'm': realMm = realDistance * 1000; break;
case 'cm': realMm = realDistance * 10; break;
case 'mm': realMm = realDistance; break;
}
// scale = pixels per mm
const scale = pixelDistance / realMm;
this.config.scale = scale;
return { scale, unit };
}
/** Get the loaded HTMLImageElement for rendering */
getImage(): HTMLImageElement | null {
return this.image;
}
/** Check if a background is loaded */
isLoaded(): boolean {
return this.config.src !== '' && this.image !== null;
}
/** Clear the background */
clear(): void {
this.config = { ...DEFAULT_BACKGROUND };
this.image = null;
}
/** Export to ProjectData.background format */
toProjectData(): ProjectData['background'] | undefined {
if (!this.isLoaded()) return undefined;
return {
src: this.config.src,
scale: this.config.scale,
offsetX: this.config.offsetX,
offsetY: this.config.offsetY,
rotation: this.config.rotation,
};
}
/** Import from ProjectData.background format */
fromProjectData(bg: NonNullable<ProjectData['background']>): void {
this.config = {
...DEFAULT_BACKGROUND,
src: bg.src,
scale: bg.scale,
offsetX: bg.offsetX,
offsetY: bg.offsetY,
rotation: bg.rotation,
};
this.image = new Image();
this.image.src = bg.src;
}
// --- Private helpers ---
private detectFormat(file: File): BackgroundConfig['format'] {
const type = file.type.toLowerCase();
if (type.includes('png')) return 'png';
if (type.includes('jpeg') || type.includes('jpg')) return 'jpg';
if (type.includes('svg')) return 'svg';
if (type.includes('pdf')) return 'pdf';
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
if (ext === 'png') return 'png';
if (ext === 'jpg' || ext === 'jpeg') return 'jpg';
if (ext === 'svg') return 'svg';
if (ext === 'pdf') return 'pdf';
return 'png';
}
private detectFormatFromSrc(src: string): BackgroundConfig['format'] {
if (src.startsWith('data:image/png')) return 'png';
if (src.startsWith('data:image/jpeg') || src.startsWith('data:image/jpg')) return 'jpg';
if (src.startsWith('data:image/svg')) return 'svg';
if (src.startsWith('data:application/pdf')) return 'pdf';
if (src.endsWith('.png')) return 'png';
if (src.endsWith('.jpg') || src.endsWith('.jpeg')) return 'jpg';
if (src.endsWith('.svg')) return 'svg';
if (src.endsWith('.pdf')) return 'pdf';
return 'png';
}
private fileToDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
private getImageDimensions(src: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = reject;
img.src = src;
});
}
}
+300
View File
@@ -0,0 +1,300 @@
import type { BlockDefinition, CADElement } from '../types/cad.types';
/**
* Block Service — CRUD, SVG-Import, Block-Definition vs Referenz
*/
export class BlockService {
private blocks: Map<string, BlockDefinition> = new Map();
/** Register or update a block definition */
addBlock(block: BlockDefinition): void {
this.blocks.set(block.id, block);
}
/** Remove a block definition */
removeBlock(id: string): void {
this.blocks.delete(id);
}
/** Get a block definition by ID */
getBlock(id: string): BlockDefinition | undefined {
return this.blocks.get(id);
}
/** Get all block definitions */
getAllBlocks(): BlockDefinition[] {
return Array.from(this.blocks.values());
}
/** Get blocks by category */
getBlocksByCategory(category: string): BlockDefinition[] {
return this.getAllBlocks().filter(b => category === 'Alle' || b.category === category);
}
/** Search blocks by name */
searchBlocks(query: string): BlockDefinition[] {
const q = query.toLowerCase().trim();
if (!q) return this.getAllBlocks();
return this.getAllBlocks().filter(b =>
b.name.toLowerCase().includes(q) || b.description.toLowerCase().includes(q)
);
}
/** Rename a block definition */
renameBlock(id: string, name: string): void {
const block = this.blocks.get(id);
if (block) {
this.blocks.set(id, { ...block, name });
}
}
/** Duplicate a block definition */
duplicateBlock(id: string): BlockDefinition | null {
const block = this.blocks.get(id);
if (!block) return null;
const newId = `blk-${Date.now()}`;
const copy: BlockDefinition = {
...block,
id: newId,
name: `${block.name} (Kopie)`,
elements: block.elements.map(el => ({ ...el, id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` })),
};
this.blocks.set(newId, copy);
return copy;
}
/** Create a block instance (reference) from a definition */
createInstance(blockId: string, x: number, y: number, layerId: string, rotation = 0, scale = 1): CADElement | null {
const block = this.blocks.get(blockId);
if (!block) return null;
// Calculate bounding box from elements
const bbox = this.getBoundingBox(block.elements);
const w = (bbox.maxX - bbox.minX) * scale;
const h = (bbox.maxY - bbox.minY) * scale;
return {
id: `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
type: 'block_instance',
layerId,
x,
y,
width: w,
height: h,
properties: {
blockId,
rotation,
scale,
offsetX: -bbox.minX * scale,
offsetY: -bbox.minY * scale,
},
};
}
/** Get the elements of a block instance transformed to world coords */
getInstanceElements(instance: CADElement): CADElement[] {
const blockId = instance.properties.blockId as string;
const block = this.blocks.get(blockId);
if (!block) return [];
const rotation = (instance.properties.rotation || 0) * Math.PI / 180;
const scale = instance.properties.scale || 1;
const ox = instance.properties.offsetX || 0;
const oy = instance.properties.offsetY || 0;
return block.elements.map(el => {
// Translate to instance origin, scale, rotate
const lx = (el.x + Number(ox)) * scale;
const ly = (el.y + Number(oy)) * scale;
const rx = lx * Math.cos(rotation) - ly * Math.sin(rotation);
const ry = lx * Math.sin(rotation) + ly * Math.cos(rotation);
const props = { ...el.properties };
// Transform line endpoints
if (props.x1 !== undefined && props.x2 !== undefined) {
const x1 = (Number(props.x1) + Number(ox)) * scale;
const y1 = (Number(props.y1) + Number(oy)) * scale;
const x2 = (Number(props.x2) + Number(ox)) * scale;
const y2 = (Number(props.y2) + Number(oy)) * scale;
props.x1 = x1 * Math.cos(rotation) - y1 * Math.sin(rotation) + instance.x;
props.y1 = x1 * Math.sin(rotation) + y1 * Math.cos(rotation) + instance.y;
props.x2 = x2 * Math.cos(rotation) - y2 * Math.sin(rotation) + instance.x;
props.y2 = x2 * Math.sin(rotation) + y2 * Math.cos(rotation) + instance.y;
}
return {
...el,
id: `${el.id}_inst_${instance.id}`,
x: rx + instance.x,
y: ry + instance.y,
width: el.width * scale,
height: el.height * scale,
properties: props,
};
});
}
/** Calculate bounding box of elements */
getBoundingBox(elements: CADElement[]): { minX: number; minY: number; maxX: number; maxY: number } {
if (elements.length === 0) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const el of elements) {
const x1 = el.properties.x1 ?? el.x - el.width / 2;
const y1 = el.properties.y1 ?? el.y - el.height / 2;
const x2 = el.properties.x2 ?? el.x + el.width / 2;
const y2 = el.properties.y2 ?? el.y + el.height / 2;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return { minX, minY, maxX, maxY };
}
/** Import SVG as block definition (parses basic shapes) */
importSVG(svgContent: string, name: string, category: string): BlockDefinition {
const elements: CADElement[] = [];
const parser = new DOMParser();
const doc = parser.parseFromString(svgContent, 'image/svg+xml');
const lines = doc.querySelectorAll('line');
lines.forEach((line, i) => {
const x1 = parseFloat(line.getAttribute('x1') || '0');
const y1 = parseFloat(line.getAttribute('y1') || '0');
const x2 = parseFloat(line.getAttribute('x2') || '0');
const y2 = parseFloat(line.getAttribute('y2') || '0');
elements.push({
id: `svg_line_${i}`,
type: 'line',
layerId: '',
x: (x1 + x2) / 2,
y: (y1 + y2) / 2,
width: Math.abs(x2 - x1),
height: Math.abs(y2 - y1),
properties: { x1, y1, x2, y2 },
});
});
const rects = doc.querySelectorAll('rect');
rects.forEach((rect, i) => {
const x = parseFloat(rect.getAttribute('x') || '0');
const y = parseFloat(rect.getAttribute('y') || '0');
const w = parseFloat(rect.getAttribute('width') || '0');
const h = parseFloat(rect.getAttribute('height') || '0');
elements.push({
id: `svg_rect_${i}`,
type: 'rect',
layerId: '',
x: x + w / 2,
y: y + h / 2,
width: w,
height: h,
properties: {},
});
});
const circles = doc.querySelectorAll('circle');
circles.forEach((circle, i) => {
const cx = parseFloat(circle.getAttribute('cx') || '0');
const cy = parseFloat(circle.getAttribute('cy') || '0');
const r = parseFloat(circle.getAttribute('r') || '0');
elements.push({
id: `svg_circle_${i}`,
type: 'circle',
layerId: '',
x: cx,
y: cy,
width: r * 2,
height: r * 2,
properties: { radius: r },
});
});
const blockId = `blk_svg_${Date.now()}`;
const block: BlockDefinition = {
id: blockId,
name,
description: `Imported from SVG`,
category,
elements,
thumbnail: svgContent.substring(0, 200),
};
this.blocks.set(blockId, block);
return block;
}
/** Create a group block from selected elements */
createGroupBlock(name: string, elements: CADElement[], category: string = 'Custom'): BlockDefinition {
const blockId = `blk_grp_${Date.now()}`;
const block: BlockDefinition = {
id: blockId,
name,
description: 'Aus Auswahl erstellt',
category,
elements: elements.map(el => ({ ...el, id: `${el.id}_def` })),
};
this.blocks.set(blockId, block);
return block;
}
}
/** Default block definitions with real elements */
export function createDefaultBlocks(): BlockDefinition[] {
return [
{
id: 'blk-chair',
name: 'Stuhl-Standard',
description: 'Standard Stuhl 0.5×0.5m',
category: 'Bestuhlung',
elements: [
{ id: 'chair-seat', type: 'rect', layerId: '', x: 0, y: 0, width: 50, height: 50, properties: { fill: '#4a90d9' } },
{ id: 'chair-back', type: 'rect', layerId: '', x: 0, y: -30, width: 50, height: 10, properties: { fill: '#357abd' } },
],
},
{
id: 'blk-chair-vip',
name: 'Stuhl-VIP Polster',
description: 'VIP Polsterstuhl 0.6×0.6m',
category: 'Bestuhlung',
elements: [
{ id: 'vip-seat', type: 'rect', layerId: '', x: 0, y: 0, width: 60, height: 60, properties: { fill: '#8b5cf6' } },
{ id: 'vip-back', type: 'rect', layerId: '', x: 0, y: -35, width: 60, height: 12, properties: { fill: '#7c3aed' } },
],
},
{
id: 'blk-table-rect',
name: 'Bankett-Tisch 1.8×0.8',
description: 'Rechteckiger Bankett-Tisch',
category: 'Tische',
elements: [
{ id: 'table-top', type: 'rect', layerId: '', x: 0, y: 0, width: 180, height: 80, properties: { fill: '#d4a574' } },
],
},
{
id: 'blk-table-round',
name: 'Runder Tisch 1.5m Ø',
description: 'Runder Tisch für 8 Personen',
category: 'Tische',
elements: [
{ id: 'round-top', type: 'circle', layerId: '', x: 0, y: 0, width: 150, height: 150, properties: { radius: 75, fill: '#d4a574' } },
],
},
{
id: 'blk-stage',
name: 'Hauptbühne 10×3m',
description: 'Bühnenmodul',
category: 'Bühne',
elements: [
{ id: 'stage-base', type: 'rect', layerId: '', x: 0, y: 0, width: 1000, height: 300, properties: { fill: '#444' } },
{ id: 'stage-edge', type: 'rect', layerId: '', x: 0, y: 150, width: 1000, height: 10, properties: { fill: '#666' } },
],
},
{
id: 'blk-door',
name: 'Tür 90°',
description: 'Drehtür 0.9×0.9m',
category: 'Architektur',
elements: [
{ id: 'door-frame', type: 'rect', layerId: '', x: 0, y: 0, width: 90, height: 90, properties: {} },
{ id: 'door-arc', type: 'arc', layerId: '', x: 0, y: 0, width: 90, height: 90, properties: { radius: 90, startAngle: 0, endAngle: 90 } },
],
},
];
}
+153
View File
@@ -0,0 +1,153 @@
/**
* CommandRegistry Zentrale Befehls-Registry für die CAD-Command-Line.
* F-CAD-05: Command Line (L, C, PL, R, A, T, DIM shortcuts)
* F-UI-04: Autovervollständigung
*/
export interface CommandDefinition {
/** Primärer Befehlsname (Großbuchstaben) */
name: string;
/** Aliasse / Kurzformen */
aliases: string[];
/** Tool-ID die aktiviert wird (null für Meta-Befehle wie UNDO) */
toolId: string | null;
/** Beschreibung für Autovervollständigung */
description: string;
/** Kategorie für Gruppierung */
category: 'draw' | 'modify' | 'view' | 'meta' | 'special';
/** Deutsches Label für Command-History-Ausgabe */
label: string;
}
const commands: CommandDefinition[] = [
// ─── Zeichen-Werkzeuge ─────────────────────────────
{ name: 'LINE', aliases: ['L', 'LINIE'], toolId: 'line', description: 'Linie zeichnen', category: 'draw', label: 'Linie-Werkzeug aktiv · Klicken zum Starten' },
{ name: 'CIRCLE', aliases: ['C', 'KREIS'], toolId: 'circle', description: 'Kreis zeichnen', category: 'draw', label: 'Kreis-Werkzeug aktiv · Klicken für Mittelpunkt' },
{ name: 'ARC', aliases: ['A', 'BOGEN'], toolId: 'arc', description: 'Bogen zeichnen', category: 'draw', label: 'Bogen-Werkzeug aktiv · Klicken für Mittelpunkt' },
{ name: 'RECT', aliases: ['R', 'RECTANGLE', 'RECHTECK'], toolId: 'rect', description: 'Rechteck zeichnen', category: 'draw', label: 'Rechteck-Werkzeug aktiv · Klicken für erste Ecke' },
{ name: 'POLYLINE', aliases: ['PL', 'POLYLINIE'], toolId: 'polyline', description: 'Polylinie zeichnen', category: 'draw', label: 'Polylinie-Werkzeug aktiv · Klicken für Punkte, Doppelklick zum Beenden' },
{ name: 'POLYGON', aliases: ['POL', 'POLYGON'], toolId: 'polygon', description: 'Polygon zeichnen', category: 'draw', label: 'Polygon-Werkzeug aktiv · Klicken für Punkte, Doppelklick zum Beenden' },
{ name: 'TEXT', aliases: ['T', 'TXT'], toolId: 'text', description: 'Text platzieren', category: 'draw', label: 'Text-Werkzeug aktiv · Klicken zum Platzieren' },
{ name: 'DIMENSION', aliases: ['DIM', 'BEMASSUNG'], toolId: 'dimension', description: 'Bemaßung erstellen', category: 'draw', label: 'Bemaßung-Werkzeug aktiv · Klicken für Startpunkt' },
{ name: 'LEADER', aliases: ['LD', 'HINWEIS'], toolId: 'leader', description: 'Hinweislinie erstellen', category: 'draw', label: 'Hinweislinie · Klicken für Pfeilspitze, dann für Textposition' },
{ name: 'REVCLOUD', aliases: ['REV', 'REVISIONSWOLKE'], toolId: 'revcloud', description: 'Revisionswolke zeichnen', category: 'draw', label: 'Revisionswolke · Klicken für Punkte, Doppelklick oder Enter zum Beenden' },
{ name: 'HATCH', aliases: ['H', 'SCHRAFFUR'], toolId: 'hatch', description: 'Schraffur erstellen', category: 'draw', label: 'Schraffur-Werkzeug aktiv · Fläche wählen' },
// ─── Änderungs-Werkzeuge ───────────────────────────
{ name: 'MOVE', aliases: ['M', 'VERSCHIEBEN'], toolId: 'move', description: 'Elemente verschieben', category: 'modify', label: 'Verschieben · Elemente auswählen, dann Basispunkt klicken' },
{ name: 'COPY', aliases: ['CO', 'KOPIEREN'], toolId: 'copy', description: 'Elemente kopieren', category: 'modify', label: 'Kopieren · Elemente auswählen, dann Basispunkt klicken' },
{ name: 'ROTATE', aliases: ['RO', 'ROTIEREN'], toolId: 'rotate', description: 'Elemente rotieren', category: 'modify', label: 'Rotieren · Elemente auswählen, dann Basispunkt klicken' },
{ name: 'SCALE', aliases: ['SC', 'SKALIEREN'], toolId: 'scale', description: 'Elemente skalieren', category: 'modify', label: 'Skalieren · Elemente auswählen, dann Basispunkt klicken' },
{ name: 'MIRROR', aliases: ['MI', 'SPIEGELN'], toolId: 'mirror', description: 'Elemente spiegeln', category: 'modify', label: 'Spiegeln · Elemente auswählen, dann Spiegellinie klicken' },
{ name: 'TRIM', aliases: ['TR', 'TRIMMEN'], toolId: 'trim', description: 'Elemente trimmen', category: 'modify', label: 'Trimmen · Begrenzungselement klicken, dann zu trimmendes Element' },
{ name: 'EXTEND', aliases: ['EX', 'VERLANGERN'], toolId: 'extend', description: 'Elemente verlängern', category: 'modify', label: 'Verlängern · Begrenzungselement klicken, dann zu verlängerndes Element' },
{ name: 'FILLET', aliases: ['F', 'ABRUNDEN'], toolId: 'fillet', description: 'Elemente abrunden', category: 'modify', label: 'Abrunden · Erstes Element klicken, dann zweites Element' },
{ name: 'OFFSET', aliases: ['O', 'VERSATZ'], toolId: 'offset', description: 'Versatz erstellen', category: 'modify', label: 'Versatz · Element klicken, dann Richtung und Abstand klicken' },
{ name: 'ERASE', aliases: ['E', 'DEL', 'DELETE', 'LOSCHEN'], toolId: 'delete', description: 'Elemente löschen', category: 'modify', label: 'Löschen · Klicken Sie auf zu löschende Elemente' },
// ─── Ansicht ──────────────────────────────────────
{ name: 'SELECT', aliases: ['V', 'AUSWAHL'], toolId: 'select', description: 'Auswahl-Werkzeug', category: 'view', label: 'Auswahl-Werkzeug aktiv' },
{ name: 'PAN', aliases: ['P'], toolId: 'pan', description: 'Pan-Ansicht', category: 'view', label: 'Pan-Werkzeug aktiv' },
{ name: 'ZOOM', aliases: ['Z'], toolId: 'zoom', description: 'Zoom-Ansicht', category: 'view', label: 'Zoom-Werkzeug aktiv' },
{ name: 'GRID', aliases: ['G', 'GRID'], toolId: null, description: 'Grid ein/aus', category: 'view', label: 'Grid ein/aus' },
{ name: 'ORTHO', aliases: ['OR'], toolId: null, description: 'Ortho-Modus ein/aus', category: 'view', label: 'Ortho-Modus ein/aus' },
{ name: 'SNAP', aliases: ['SN'], toolId: null, description: 'Snap ein/aus', category: 'view', label: 'Snap ein/aus' },
// ─── Meta-Befehle ─────────────────────────────────
{ name: 'UNDO', aliases: ['U'], toolId: null, description: 'Rückgängig', category: 'meta', label: 'Rückgängig: letzte Aktion' },
{ name: 'REDO', aliases: ['RE'], toolId: null, description: 'Wiederherstellen', category: 'meta', label: 'Wiederherstellen: letzte Aktion' },
{ name: 'GROUP', aliases: ['GRP'], toolId: null, description: 'Gruppe erstellen', category: 'meta', label: 'Gruppe erstellt' },
{ name: 'UNGROUP', aliases: ['UNG'], toolId: null, description: 'Gruppe auflösen', category: 'meta', label: 'Gruppe aufgelöst' },
{ name: 'SAVE', aliases: ['S', 'SPEICHERN'], toolId: null, description: 'Projekt speichern', category: 'meta', label: 'Projekt gespeichert' },
{ name: 'NEW', aliases: ['N', 'NEU'], toolId: null, description: 'Neues Projekt', category: 'meta', label: 'Neues Projekt' },
{ name: 'OPEN', aliases: ['OP', 'OFFNEN'], toolId: null, description: 'Projekt öffnen', category: 'meta', label: 'Projekt öffnen' },
{ name: 'IMPORT', aliases: ['IMP', 'I'], toolId: null, description: 'Datei importieren (DXF, SVG, JSON)', category: 'meta', label: 'Datei importieren' },
{ name: 'EXPORT', aliases: ['EXP', 'EX'], toolId: null, description: 'Export als DXF, SVG, PDF, PNG, JSON', category: 'meta', label: 'Export starten' },
// ─── Spezielle Befehle ────────────────────────────
{ name: 'BESTUHLUNG', aliases: ['BEST', 'SEATING'], toolId: null, description: 'Bestuhlung automatisch generieren', category: 'special', label: 'Bestuhlung-Modus' },
{ name: 'BLOCK', aliases: ['B', 'BLOCK'], toolId: null, description: 'Block erstellen', category: 'special', label: 'Block-Erstellung' },
{ name: 'TISCH', aliases: ['TAB', 'TABLE'], toolId: null, description: 'Tisch platzieren', category: 'special', label: 'Tisch-Werkzeug' },
{ name: 'BUHNE', aliases: ['BU', 'STAGE'], toolId: null, description: 'Bühne platzieren', category: 'special', label: 'Bühnen-Werkzeug' },
{ name: 'KI', aliases: ['AI', 'COPilot'], toolId: null, description: 'KI Copilot öffnen', category: 'special', label: 'KI Copilot' },
];
/** Alle Befehle als Map: Schlüssel = NAME + Aliasse (alle Großbuchstaben) */
const commandMap: Map<string, CommandDefinition> = new Map();
for (const cmd of commands) {
commandMap.set(cmd.name, cmd);
for (const alias of cmd.aliases) {
commandMap.set(alias.toUpperCase(), cmd);
}
}
export class CommandRegistry {
/** Alle Befehle zurückgeben */
getAllCommands(): CommandDefinition[] {
return commands;
}
/** Befehl nach Name oder Alias suchen */
lookup(input: string): CommandDefinition | null {
const upper = input.trim().toUpperCase();
return commandMap.get(upper) ?? null;
}
/** Tool-ID für Befehl suchen */
getToolId(input: string): string | null {
const cmd = this.lookup(input);
return cmd?.toolId ?? null;
}
/** Label für Befehl suchen */
getLabel(input: string): string | null {
const cmd = this.lookup(input);
return cmd?.label ?? null;
}
/**
* Autovervollständigung: Sucht Befehle die mit dem Input beginnen.
* Gibt sortierte Liste zurück (max. 10 Einträge).
*/
autocomplete(input: string): CommandDefinition[] {
const upper = input.trim().toUpperCase();
if (upper.length === 0) return [];
const matches = new Map<string, { cmd: CommandDefinition; priority: number }>();
for (const cmd of commands) {
let priority = -1;
if (cmd.name === upper) priority = 0;
else if (cmd.aliases.some(a => a.toUpperCase() === upper)) priority = 1;
else if (cmd.name.startsWith(upper)) priority = 2;
else if (cmd.aliases.some(a => a.toUpperCase().startsWith(upper))) priority = 3;
if (priority >= 0) {
const existing = matches.get(cmd.name);
if (!existing || priority < existing.priority) {
matches.set(cmd.name, { cmd, priority });
}
}
}
return Array.from(matches.values())
.sort((a, b) => a.priority - b.priority || a.cmd.name.localeCompare(b.cmd.name))
.slice(0, 10)
.map(m => m.cmd);
}
/** Alle Befehlsnamen + Aliasse für Autovervollständigung */
getAllNames(): string[] {
const names: string[] = [];
for (const cmd of commands) {
names.push(cmd.name);
names.push(...cmd.aliases);
}
return names.map(n => n.toUpperCase());
}
}
/** Singleton-Instanz */
let registryInstance: CommandRegistry | null = null;
export function getCommandRegistry(): CommandRegistry {
if (!registryInstance) {
registryInstance = new CommandRegistry();
}
return registryInstance;
}
+239
View File
@@ -0,0 +1,239 @@
import type { CADElement } from '../types/cad.types';
/**
* Dimension & Annotation Service — creates dimension, text, leader, revcloud elements.
* Supports linear, angular, radial dimensions and multi-line text.
*/
export type DimensionType = 'linear' | 'angular' | 'radial';
export interface DimensionConfig {
type: DimensionType;
x1: number;
y1: number;
x2: number;
y2: number;
offsetX: number;
offsetY: number;
unit: 'm' | 'cm' | 'mm';
precision: number;
}
export interface TextConfig {
text: string;
fontSize: number;
rotation: number;
multiline: boolean;
align: 'left' | 'center' | 'right';
}
export const DEFAULT_TEXT: TextConfig = {
text: '',
fontSize: 14,
rotation: 0,
multiline: false,
align: 'left',
};
export interface LeaderConfig {
x1: number;
y1: number;
x2: number;
y2: number;
text: string;
fontSize: number;
}
export const DEFAULT_LEADER: LeaderConfig = {
x1: 0,
y1: 0,
x2: 0,
y2: 0,
text: '',
fontSize: 12,
};
export interface RevCloudConfig {
points: Array<{ x: number; y: number }>;
arcHeight: number;
fill: string;
stroke: string;
}
export const DEFAULT_REVCLOUD: RevCloudConfig = {
points: [],
arcHeight: 8,
fill: 'none',
stroke: '#e0e0e0',
};
export class DimensionService {
private generateId(): string {
return `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
/** Create a linear dimension between two points */
createLinearDimension(
x1: number, y1: number, x2: number, y2: number,
layerId: string, config: Partial<DimensionConfig> = {},
): CADElement {
const cfg = { type: 'linear' as DimensionType, x1, y1, x2, y2, offsetX: 0, offsetY: -20, unit: 'm' as const, precision: 2, ...config };
const dist = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
const mx = (x1 + x2) / 2 + cfg.offsetX;
const my = (y1 + y2) / 2 + cfg.offsetY;
const value = this.formatDistance(dist, cfg.unit, cfg.precision);
return {
id: this.generateId(),
type: 'dimension',
layerId,
x: mx, y: my,
width: dist, height: 20,
properties: {
x1: cfg.x1, y1: cfg.y1, x2: cfg.x2, y2: cfg.y2,
offsetX: cfg.offsetX, offsetY: cfg.offsetY,
dimType: 'linear',
value,
unit: cfg.unit,
stroke: '#888',
strokeWidth: 1,
},
};
}
/** Create an angular dimension between three points (vertex, p1, p2) */
createAngularDimension(
vx: number, vy: number, x1: number, y1: number, x2: number, y2: number,
layerId: string, config: Partial<DimensionConfig> = {},
): CADElement {
const cfg = { type: 'angular' as DimensionType, x1: vx, y1: vy, x2, y2, offsetX: 0, offsetY: -30, unit: 'deg' as any, precision: 1, ...config };
const a1 = Math.atan2(y1 - vy, x1 - vx);
const a2 = Math.atan2(y2 - vy, x2 - vx);
let angle = Math.abs(a2 - a1) * 180 / Math.PI;
if (angle > 180) angle = 360 - angle;
const r = 30;
const midAngle = (a1 + a2) / 2;
const mx = vx + Math.cos(midAngle) * r;
const my = vy + Math.sin(midAngle) * r;
return {
id: this.generateId(),
type: 'dimension',
layerId,
x: mx, y: my,
width: r * 2, height: r * 2,
properties: {
x1: vx, y1: vy, x2, y2,
ax1: x1, ay1: y1, ax2: x2, ay2: y2,
dimType: 'angular',
value: `${angle.toFixed(cfg.precision)}°`,
radius: r,
stroke: '#888',
strokeWidth: 1,
},
};
}
/** Create a radial dimension for a circle */
createRadialDimension(
cx: number, cy: number, radius: number,
layerId: string, config: Partial<DimensionConfig> = {},
): CADElement {
const cfg = { type: 'radial' as DimensionType, x1: cx, y1: cy, x2: cx + radius, y2: cy, offsetX: 0, offsetY: 0, unit: 'm' as const, precision: 2, ...config };
const value = `R ${this.formatDistance(radius, cfg.unit, cfg.precision)}`;
return {
id: this.generateId(),
type: 'dimension',
layerId,
x: cx + radius / 2, y: cy - 15,
width: radius, height: 20,
properties: {
x1: cx, y1: cy, x2: cx + radius, y2: cy,
dimType: 'radial',
value,
unit: cfg.unit,
stroke: '#888',
strokeWidth: 1,
},
};
}
/** Create a text element (single or multi-line) */
createText(x: number, y: number, layerId: string, config: Partial<TextConfig> = {}): CADElement {
const cfg = { ...DEFAULT_TEXT, ...config };
const lines = cfg.text.split('\n');
const height = lines.length * cfg.fontSize * 1.2;
const width = Math.max(...lines.map(l => l.length * cfg.fontSize * 0.6), 50);
return {
id: this.generateId(),
type: 'text',
layerId,
x, y,
width, height,
properties: {
text: cfg.text,
fontSize: cfg.fontSize,
rotation: cfg.rotation,
multiline: cfg.multiline,
align: cfg.align,
stroke: '#e0e0e0',
},
};
}
/** Create a leader element (arrow + line + text) */
createLeader(x1: number, y1: number, x2: number, y2: number, layerId: string, config: Partial<LeaderConfig> = {}): CADElement {
const cfg = { ...DEFAULT_LEADER, x1, y1, x2, y2, ...config };
return {
id: this.generateId(),
type: 'leader',
layerId,
x: cfg.x2, y: cfg.y2,
width: Math.abs(cfg.x2 - cfg.x1),
height: Math.abs(cfg.y2 - cfg.y1),
properties: {
x1: cfg.x1, y1: cfg.y1, x2: cfg.x2, y2: cfg.y2,
text: cfg.text,
fontSize: cfg.fontSize,
stroke: '#e0e0e0',
strokeWidth: 1,
},
};
}
/** Create a revision cloud from a polyline of points */
createRevCloud(points: Array<{ x: number; y: number }>, layerId: string, config: Partial<RevCloudConfig> = {}): CADElement {
const cfg = { ...DEFAULT_REVCLOUD, points, ...config };
const xs = points.map(p => p.x);
const ys = points.map(p => p.y);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
return {
id: this.generateId(),
type: 'revcloud',
layerId,
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
width: maxX - minX,
height: maxY - minY,
properties: {
points: cfg.points,
arcHeight: cfg.arcHeight,
fill: cfg.fill,
stroke: cfg.stroke,
strokeWidth: 1.5,
},
};
}
/** Format a distance value with unit and precision */
formatDistance(dist: number, unit: string, precision: number): string {
let val = dist;
let suffix = '';
switch (unit) {
case 'm': val = dist / 100; suffix = ' m'; break;
case 'cm': val = dist / 10; suffix = ' cm'; break;
case 'mm': val = dist; suffix = ' mm'; break;
default: suffix = '';
}
return `${val.toFixed(precision)}${suffix}`;
}
}
+186
View File
@@ -0,0 +1,186 @@
/**
* DXF Parser converts DXF entities to CADElement[]
* Uses dxf-parser library
*/
import DxfParser from 'dxf-parser';
import type { CADElement, CADLayer, CADProperties, ElementType } from '../types/cad.types';
export interface DXFImportResult {
elements: CADElement[];
layers: CADLayer[];
warnings: string[];
}
let idCounter = 0;
const nextId = () => `dxf-${Date.now()}-${idCounter++}`;
/**
* Parse a DXF string into CAD elements and layers.
*/
export function parseDXF(dxfString: string): DXFImportResult {
const parser = new DxfParser();
const dxf = parser.parseSync(dxfString) as any;
if (!dxf) return { elements: [], layers: [], warnings: ['DXF parse returned null'] };
const warnings: string[] = [];
const layerMap = new Map<string, CADLayer>();
const elements: CADElement[] = [];
// Build layers from DXF tables
if (dxf.tables?.layer?.layers) {
let sortOrder = 0;
for (const [layerName, layerData] of Object.entries(dxf.tables.layer.layers) as [string, any][]) {
const layer: CADLayer = {
id: `dxf-layer-${layerName}`,
name: layerName,
visible: true,
locked: false,
color: dxfColorToHex(layerData.color) ?? '#ffffff',
lineType: mapLineType(layerData.lineType),
transparency: 0,
sortOrder: sortOrder++,
parentId: null,
};
layerMap.set(layerName, layer);
}
}
// Ensure a default layer exists
if (layerMap.size === 0) {
layerMap.set('0', {
id: 'dxf-layer-0', name: '0', visible: true, locked: false,
color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null,
});
}
// Parse entities
if (dxf.entities) {
for (const entity of dxf.entities) {
const el = entityToCADElement(entity, layerMap);
if (el) {
elements.push(el);
} else {
warnings.push(`Unsupported entity type: ${entity.type}`);
}
}
}
return { elements, layers: Array.from(layerMap.values()), warnings };
}
function entityToCADElement(
entity: any,
layerMap: Map<string, CADLayer>,
): CADElement | null {
const layerName = entity.layer || '0';
const layer = layerMap.get(layerName) ?? layerMap.get('0')!;
const color = entity.color ? (dxfColorToHex(entity.color) ?? layer.color) : layer.color;
const baseProps: CADProperties = {
stroke: color,
strokeWidth: 1,
};
switch (entity.type) {
case 'LINE': {
const s = entity.vertices?.[0];
const e = entity.vertices?.[1];
if (!s || !e) return null;
const minX = Math.min(s.x, e.x);
const minY = Math.min(s.y, e.y);
const maxX = Math.max(s.x, e.x);
const maxY = Math.max(s.y, e.y);
return {
id: nextId(), type: 'line', layerId: layer.id,
x: minX, y: minY, width: maxX - minX, height: maxY - minY,
properties: { ...baseProps, x1: s.x, y1: s.y, x2: e.x, y2: e.y },
};
}
case 'CIRCLE': {
const c = entity.center;
const r = entity.radius;
if (!c || r == null) return null;
return {
id: nextId(), type: 'circle', layerId: layer.id,
x: c.x - r, y: c.y - r, width: r * 2, height: r * 2,
properties: { ...baseProps, radius: r },
};
}
case 'ARC': {
const c = entity.center;
const r = entity.radius;
if (!c || r == null) return null;
return {
id: nextId(), type: 'arc', layerId: layer.id,
x: c.x - r, y: c.y - r, width: r * 2, height: r * 2,
properties: { ...baseProps, radius: r, startAngle: entity.startAngle, endAngle: entity.endAngle },
};
}
case 'LWPOLYLINE':
case 'POLYLINE': {
const pts = (entity.vertices || []).map((v: any) => ({ x: v.x, y: v.y }));
if (pts.length < 2) return null;
const minX = Math.min(...pts.map((p: any) => p.x));
const minY = Math.min(...pts.map((p: any) => p.y));
const maxX = Math.max(...pts.map((p: any) => p.x));
const maxY = Math.max(...pts.map((p: any) => p.y));
const isClosed = entity.shape === true;
const type: ElementType = isClosed ? 'polygon' : 'polyline';
return {
id: nextId(), type, layerId: layer.id,
x: minX, y: minY, width: maxX - minX, height: maxY - minY,
properties: { ...baseProps, points: pts },
};
}
case 'TEXT': {
const s = entity.startPoint;
if (!s) return null;
return {
id: nextId(), type: 'text', layerId: layer.id,
x: s.x, y: s.y, width: 0, height: 0,
properties: { ...baseProps, text: entity.text || '', fontSize: entity.height || 12 },
};
}
case 'INSERT': {
// Block reference — store as block_instance
const pos = entity.position;
if (!pos) return null;
return {
id: nextId(), type: 'block_instance', layerId: layer.id,
x: pos.x, y: pos.y, width: 0, height: 0,
properties: { ...baseProps, blockId: entity.name, scale: entity.scale || 1, rotation: entity.rotation || 0 },
};
}
case 'DIMENSION': {
// Basic dimension support
const pts = entity.definitionPoint || entity.points;
if (!pts) return null;
return {
id: nextId(), type: 'dimension', layerId: layer.id,
x: 0, y: 0, width: 0, height: 0,
properties: { ...baseProps, points: Array.isArray(pts) ? pts.map((p: any) => ({ x: p.x, y: p.y })) : [] },
};
}
default:
return null;
}
}
/**
* DXF ACI color to hex string
*/
function dxfColorToHex(aci: number | undefined): string | null {
if (aci == null) return null;
const colors: Record<number, string> = {
0: '#ffffff', 1: '#ff0000', 2: '#ffff00', 3: '#00ff00',
4: '#00ffff', 5: '#0000ff', 6: '#ff00ff', 7: '#ffffff',
8: '#808080', 9: '#c0c0c0',
};
return colors[aci] ?? null;
}
function mapLineType(lt: string | undefined): 'solid' | 'dashed' | 'dotted' {
if (!lt) return 'solid';
const u = lt.toUpperCase();
if (u.includes('DASH')) return 'dashed';
if (u.includes('DOT')) return 'dotted';
return 'solid';
}
+214
View File
@@ -0,0 +1,214 @@
/**
* DXF Writer converts CADElement[] to DXF string
* Minimal DXF R12 format for compatibility
*/
import type { CADElement, CADLayer, ProjectData } from '../types/cad.types';
/**
* Generate a DXF R12 string from project data.
*/
export function writeDXF(data: ProjectData): string {
const lines: string[] = [];
const w = (code: number, value: string | number) => {
lines.push(String(code));
lines.push(String(value));
};
// Header
w(0, 'SECTION');
w(2, 'HEADER');
w(9, '$ACADVER'); w(1, 'AC1009'); // R12
w(9, '$INSBASE'); w(10, 0); w(20, 0); w(30, 0);
w(9, '$EXTMIN'); w(10, 0); w(20, 0);
w(9, '$EXTMAX'); w(10, 1000); w(20, 1000);
w(0, 'ENDSEC');
// Tables section
w(0, 'SECTION');
w(2, 'TABLES');
// Layer table
w(0, 'TABLE');
w(2, 'LAYER');
w(70, data.layers.length);
for (const layer of data.layers) {
w(0, 'LAYER');
w(2, layer.name);
w(70, 0);
w(62, hexToACI(layer.color));
w(6, lineTypeToDXF(layer.lineType));
}
w(0, 'ENDTAB');
w(0, 'ENDSEC');
// Entities section
w(0, 'SECTION');
w(2, 'ENTITIES');
for (const el of data.elements) {
const layerName = getLayerName(data.layers, el.layerId);
writeEntity(w, el, layerName);
}
w(0, 'ENDSEC');
// EOF
w(0, 'EOF');
return lines.join('\n');
}
function writeEntity(
w: (code: number, value: string | number) => void,
el: CADElement,
layerName: string,
): void {
const p = el.properties;
const stroke = (p.stroke as string) || '#ffffff';
const aci = hexToACI(stroke);
switch (el.type) {
case 'line': {
w(0, 'LINE');
w(8, layerName);
w(62, aci);
w(10, p.x1 ?? el.x); w(20, p.y1 ?? el.y); w(30, 0);
w(11, p.x2 ?? el.x + el.width); w(21, p.y2 ?? el.y + el.height); w(31, 0);
break;
}
case 'circle': {
const cx = el.x + el.width / 2;
const cy = el.y + el.height / 2;
const r = p.radius ?? el.width / 2;
w(0, 'CIRCLE');
w(8, layerName);
w(62, aci);
w(10, cx); w(20, cy); w(30, 0);
w(40, r);
break;
}
case 'arc': {
const cx = el.x + el.width / 2;
const cy = el.y + el.height / 2;
const r = p.radius ?? el.width / 2;
w(0, 'ARC');
w(8, layerName);
w(62, aci);
w(10, cx); w(20, cy); w(30, 0);
w(40, r);
w(50, radToDeg(p.startAngle ?? 0));
w(51, radToDeg(p.endAngle ?? 360));
break;
}
case 'rect': {
// Rectangle as LWPOLYLINE
w(0, 'LWPOLYLINE');
w(8, layerName);
w(62, aci);
w(90, 4);
w(70, 1); // closed
w(10, el.x); w(20, el.y);
w(10, el.x + el.width); w(20, el.y);
w(10, el.x + el.width); w(20, el.y + el.height);
w(10, el.x); w(20, el.y + el.height);
break;
}
case 'polygon':
case 'polyline': {
const pts = p.points ?? [];
w(0, 'LWPOLYLINE');
w(8, layerName);
w(62, aci);
w(90, pts.length);
w(70, el.type === 'polygon' ? 1 : 0);
for (const pt of pts) {
w(10, pt.x); w(20, pt.y);
}
break;
}
case 'text': {
w(0, 'TEXT');
w(8, layerName);
w(62, aci);
w(10, el.x); w(20, el.y); w(30, 0);
w(40, p.fontSize ?? 12);
w(1, p.text ?? '');
break;
}
case 'dimension': {
const pts = p.points ?? [];
if (pts.length >= 2) {
// Draw dimension as LINE + TEXT
w(0, 'LINE');
w(8, layerName);
w(62, aci);
w(10, pts[0].x); w(20, pts[0].y); w(30, 0);
w(11, pts[1].x); w(21, pts[1].y); w(31, 0);
const midX = (pts[0].x + pts[1].x) / 2;
const midY = (pts[0].y + pts[1].y) / 2;
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
w(0, 'TEXT');
w(8, layerName);
w(62, aci);
w(10, midX); w(20, midY); w(30, 0);
w(40, 12);
w(1, dist.toFixed(2));
}
break;
}
case 'block_instance': {
w(0, 'INSERT');
w(8, layerName);
w(62, aci);
w(2, p.blockId ?? 'BLOCK');
w(10, el.x); w(20, el.y); w(30, 0);
w(41, p.scale ?? 1);
w(42, p.scale ?? 1);
w(50, radToDeg(p.rotation ?? 0));
break;
}
default:
// For chair, seating-row, etc. — export as LWPOLYLINE bounding box
w(0, 'LWPOLYLINE');
w(8, layerName);
w(62, aci);
w(90, 4);
w(70, 1);
w(10, el.x); w(20, el.y);
w(10, el.x + el.width); w(20, el.y);
w(10, el.x + el.width); w(20, el.y + el.height);
w(10, el.x); w(20, el.y + el.height);
break;
}
}
function getLayerName(layers: CADLayer[], layerId: string): string {
return layers.find(l => l.id === layerId)?.name ?? '0';
}
function hexToACI(hex: string): number {
const h = hex.replace('#', '');
const r = parseInt(h.substring(0, 2), 16);
const g = parseInt(h.substring(2, 4), 16);
const b = parseInt(h.substring(4, 6), 16);
// Map common colors to ACI
if (r > 200 && g < 100 && b < 100) return 1; // red
if (r > 200 && g > 200 && b < 100) return 2; // yellow
if (r < 100 && g > 200 && b < 100) return 3; // green
if (r < 100 && g > 200 && b > 200) return 4; // cyan
if (r < 100 && g < 100 && b > 200) return 5; // blue
if (r > 200 && g < 100 && b > 200) return 6; // magenta
if (r > 200 && g > 200 && b > 200) return 7; // white
if (r < 100 && g < 100 && b < 100) return 8; // gray
return 7; // default white
}
function lineTypeToDXF(lt: string): string {
if (lt === 'dashed') return 'DASHED';
if (lt === 'dotted') return 'DOT';
return 'CONTINUOUS';
}
function radToDeg(rad: number): number {
return (rad * 180) / Math.PI;
}

Some files were not shown because too many files have changed in this diff Show More