Web CAD - complete TypeScript source (React 18 frontend, Node.js backend, CRDT collaboration, KI Copilot)
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.git/
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
|
.DS_Store
|
||||||
|
.a0/
|
||||||
+28
@@ -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"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
*.log
|
||||||
@@ -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"]
|
||||||
Generated
+3888
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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() };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+11
@@ -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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dmVyc2lvbjogJzMuOCcKCnNlcnZpY2VzOgogIGJhY2tlbmQ6CiAgICBidWlsZDogLi9iYWNrZW5kCiAgICBwb3J0czoKICAgICAgLSAiNTAwMDo1MDAwIgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIEpXVF9TRUNSRVQ9ZjJiMjI3MWU1OWJmY2MxNWMxZjE3MzI4YjUxMGRiMDg2NjIwNTJjZGRhMDJkYjk4NWM5NDhkOWY1MzJhMjgyMgogICAgdm9sdW1lczoKICAgICAgLSBzcWxpdGVfZGF0YTovYXBwL2RhdGEKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogWyJDTUQtU0hFTEwiLCAid2dldCAtcU8tIGh0dHA6Ly9sb2NhbGhvc3Q6NTAwMC9hcGkvaGVhbHRoIHx8IGV4aXQgMSJdCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQoKICBmcm9udGVuZDoKICAgIGJ1aWxkOiAuL2Zyb250ZW5kCiAgICBleHBvc2U6CiAgICAgIC0gIjgwIgogICAgZGVwZW5kc19vbjoKICAgICAgLSBiYWNrZW5kCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6IFsiQ01ELVNIRUxMIiwgIndnZXQgLXFPLSBodHRwOi8vbG9jYWxob3N0OjgwIHx8IGV4aXQgMSJdCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQoKdm9sdW1lczoKICBzcWxpdGVfZGF0YToK
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3299
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user