feat: initial commit web-cad-neu with docker-compose, frontend and backend
This commit is contained in:
@@ -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 || 'http://localhost:3001';
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Export Service – handles DXF, SVG, PDF, PNG, JSON exports
|
||||
*/
|
||||
import { writeDXF } from './dxfWriter';
|
||||
import { exportPDF } from './pdfExport';
|
||||
import type { CADElement, CADLayer, ProjectData } from '../types/cad.types';
|
||||
|
||||
export type ExportFormat = 'dxf' | 'svg' | 'pdf' | 'png' | 'json';
|
||||
|
||||
export interface ExportOptions {
|
||||
format: ExportFormat;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface ExportFileResult {
|
||||
success: boolean;
|
||||
blob: Blob | null;
|
||||
filename: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export project data to the specified format and trigger download.
|
||||
*/
|
||||
export async function exportProject(
|
||||
data: ProjectData,
|
||||
options: ExportOptions,
|
||||
): Promise<ExportFileResult> {
|
||||
const filename = options.filename || `${data.name || 'cad-export'}.${options.format}`;
|
||||
|
||||
try {
|
||||
switch (options.format) {
|
||||
case 'json':
|
||||
return exportJSON(data, filename);
|
||||
case 'dxf':
|
||||
return exportDXF(data, filename);
|
||||
case 'svg':
|
||||
return exportSVG(data, filename);
|
||||
case 'pdf':
|
||||
return await exportPDFFile(data, filename);
|
||||
case 'png':
|
||||
return await exportPNG(data, filename);
|
||||
default:
|
||||
return { success: false, blob: null, filename, error: `Unsupported format: ${options.format}` };
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false, blob: null, filename,
|
||||
error: `Export error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as JSON (full project data).
|
||||
*/
|
||||
function exportJSON(data: ProjectData, filename: string): ExportFileResult {
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
return { success: true, blob, filename };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as DXF.
|
||||
*/
|
||||
function exportDXF(data: ProjectData, filename: string): ExportFileResult {
|
||||
const dxf = writeDXF(data);
|
||||
const blob = new Blob([dxf], { type: 'application/dxf' });
|
||||
return { success: true, blob, filename };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as SVG.
|
||||
*/
|
||||
function exportSVG(data: ProjectData, filename: string): ExportFileResult {
|
||||
const svg = generateSVG(data);
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||
return { success: true, blob, filename };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as PDF.
|
||||
*/
|
||||
async function exportPDFFile(data: ProjectData, filename: string): Promise<ExportFileResult> {
|
||||
const bytes = await exportPDF(data);
|
||||
const blob = new Blob([bytes as BlobPart], { type: 'application/pdf' });
|
||||
return { success: true, blob, filename };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export as PNG — renders canvas to image.
|
||||
* Requires a canvas element reference.
|
||||
*/
|
||||
async function exportPNG(data: ProjectData, filename: string): Promise<ExportFileResult> {
|
||||
// Create an offscreen canvas and render elements
|
||||
const canvas = document.createElement('canvas');
|
||||
const bbox = calculateBoundingBox(data.elements);
|
||||
const padding = 20;
|
||||
const w = (bbox.maxX - bbox.minX) + padding * 2;
|
||||
const h = (bbox.maxY - bbox.minY) + padding * 2;
|
||||
canvas.width = Math.max(w, 800);
|
||||
canvas.height = Math.max(h, 600);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return { success: false, blob: null, filename, error: 'Canvas context unavailable' };
|
||||
|
||||
// White background
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Translate to content origin
|
||||
ctx.save();
|
||||
ctx.translate(-bbox.minX + padding, -bbox.minY + padding);
|
||||
|
||||
// Draw elements
|
||||
for (const el of data.elements) {
|
||||
const layer = data.layers.find(l => l.id === el.layerId);
|
||||
if (layer && !layer.visible) continue;
|
||||
drawElementToCanvas(ctx, el, layer?.color);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
const blob = await new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob((b) => resolve(b!), 'image/png');
|
||||
});
|
||||
return { success: true, blob, filename };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SVG string from project data.
|
||||
*/
|
||||
function generateSVG(data: ProjectData): string {
|
||||
const bbox = calculateBoundingBox(data.elements);
|
||||
const padding = 20;
|
||||
const minX = bbox.minX - padding;
|
||||
const minY = bbox.minY - padding;
|
||||
const w = (bbox.maxX - bbox.minX) + padding * 2;
|
||||
const h = (bbox.maxY - bbox.minY) + padding * 2;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
||||
lines.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${w} ${h}" width="${w}" height="${h}">`);
|
||||
|
||||
for (const el of data.elements) {
|
||||
const layer = data.layers.find(l => l.id === el.layerId);
|
||||
if (layer && !layer.visible) continue;
|
||||
const svgEl = elementToSVG(el, layer?.color);
|
||||
if (svgEl) lines.push(svgEl);
|
||||
}
|
||||
|
||||
lines.push(`</svg>`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function elementToSVG(el: CADElement, layerColor?: string): string | null {
|
||||
const p = el.properties;
|
||||
const stroke = (p.stroke as string) || layerColor || '#000000';
|
||||
const sw = p.strokeWidth ?? 1;
|
||||
const fill = p.fill as string || 'none';
|
||||
const style = `stroke="${stroke}" stroke-width="${sw}" fill="${fill}"`;
|
||||
|
||||
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 `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" ${style}/>`;
|
||||
}
|
||||
case 'circle': {
|
||||
const cx = el.x + el.width / 2;
|
||||
const cy = el.y + el.height / 2;
|
||||
const r = p.radius ?? el.width / 2;
|
||||
return `<circle cx="${cx}" cy="${cy}" r="${r}" ${style}/>`;
|
||||
}
|
||||
case 'arc': {
|
||||
const cx = el.x + el.width / 2;
|
||||
const cy = el.y + el.height / 2;
|
||||
const r = p.radius ?? el.width / 2;
|
||||
const start = p.startAngle ?? 0;
|
||||
const end = p.endAngle ?? Math.PI * 2;
|
||||
const x1 = cx + Math.cos(start) * r;
|
||||
const y1 = cy + Math.sin(start) * r;
|
||||
const x2 = cx + Math.cos(end) * r;
|
||||
const y2 = cy + Math.sin(end) * r;
|
||||
const largeArc = (end - start) > Math.PI ? 1 : 0;
|
||||
return `<path d="M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}" ${style}/>`;
|
||||
}
|
||||
case 'rect': {
|
||||
return `<rect x="${el.x}" y="${el.y}" width="${el.width}" height="${el.height}" ${style}/>`;
|
||||
}
|
||||
case 'polygon':
|
||||
case 'polyline': {
|
||||
const pts = (p.points ?? []).map(pt => `${pt.x},${pt.y}`).join(' ');
|
||||
const tag = el.type === 'polygon' ? 'polygon' : 'polyline';
|
||||
return `<${tag} points="${pts}" ${style}/>`;
|
||||
}
|
||||
case 'text': {
|
||||
const size = p.fontSize ?? 12;
|
||||
return `<text x="${el.x}" y="${el.y}" font-size="${size}" fill="${stroke}" font-family="sans-serif">${escapeXml(p.text ?? '')}</text>`;
|
||||
}
|
||||
case 'dimension': {
|
||||
const pts = p.points ?? [];
|
||||
if (pts.length < 2) return null;
|
||||
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
|
||||
const midX = (pts[0].x + pts[1].x) / 2;
|
||||
const midY = (pts[0].y + pts[1].y) / 2;
|
||||
return `<line x1="${pts[0].x}" y1="${pts[0].y}" x2="${pts[1].x}" y2="${pts[1].y}" ${style}/>\n<text x="${midX}" y="${midY}" font-size="10" fill="${stroke}">${dist.toFixed(2)}</text>`;
|
||||
}
|
||||
default: {
|
||||
return `<rect x="${el.x}" y="${el.y}" width="${el.width}" height="${el.height}" ${style}/>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawElementToCanvas(ctx: CanvasRenderingContext2D, el: CADElement, layerColor?: string): void {
|
||||
const p = el.properties;
|
||||
const stroke = (p.stroke as string) || layerColor || '#000000';
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.lineWidth = p.strokeWidth ?? 1;
|
||||
ctx.fillStyle = (p.fill as string) || 'transparent';
|
||||
|
||||
switch (el.type) {
|
||||
case 'line':
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x1 ?? el.x, p.y1 ?? el.y);
|
||||
ctx.lineTo(p.x2 ?? el.x + el.width, p.y2 ?? el.y + el.height);
|
||||
ctx.stroke();
|
||||
break;
|
||||
case 'circle':
|
||||
ctx.beginPath();
|
||||
ctx.arc(el.x + el.width / 2, el.y + el.height / 2, p.radius ?? el.width / 2, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
case 'rect':
|
||||
ctx.strokeRect(el.x, el.y, el.width, el.height);
|
||||
break;
|
||||
case 'polygon':
|
||||
case 'polyline': {
|
||||
const pts = p.points ?? [];
|
||||
if (pts.length < 2) break;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0].x, pts[0].y);
|
||||
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
||||
if (el.type === 'polygon') ctx.closePath();
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'text':
|
||||
ctx.font = `${p.fontSize ?? 12}px sans-serif`;
|
||||
ctx.fillStyle = stroke;
|
||||
ctx.fillText(p.text ?? '', el.x, el.y);
|
||||
break;
|
||||
default:
|
||||
ctx.strokeRect(el.x, el.y, el.width, el.height);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function calculateBoundingBox(elements: CADElement[]) {
|
||||
if (elements.length === 0) return { minX: 0, minY: 0, maxX: 1000, maxY: 1000 };
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of elements) {
|
||||
minX = Math.min(minX, el.x);
|
||||
minY = Math.min(minY, el.y);
|
||||
maxX = Math.max(maxX, el.x + el.width);
|
||||
maxY = Math.max(maxY, el.y + el.height);
|
||||
}
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download from a blob.
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Import Service – handles DXF, SVG, PDF, JSON imports
|
||||
*/
|
||||
import { parseDXF, type DXFImportResult } from './dxfParser';
|
||||
import type { CADElement, CADLayer, BlockDefinition, ProjectData } from '../types/cad.types';
|
||||
|
||||
export interface ImportResult {
|
||||
success: boolean;
|
||||
elements: CADElement[];
|
||||
layers?: CADLayer[];
|
||||
blocks?: BlockDefinition[];
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let idCounter = 0;
|
||||
const nextId = () => `imp-${Date.now()}-${idCounter++}`;
|
||||
|
||||
/**
|
||||
* Import a file based on its extension.
|
||||
*/
|
||||
export async function importFile(file: File): Promise<ImportResult> {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
const text = await file.text();
|
||||
|
||||
switch (ext) {
|
||||
case 'dxf':
|
||||
return importDXF(text);
|
||||
case 'svg':
|
||||
return importSVG(text);
|
||||
case 'json':
|
||||
return importJSON(text);
|
||||
case 'pdf':
|
||||
return { success: false, elements: [], warnings: ['PDF import not yet supported'] };
|
||||
default:
|
||||
return { success: false, elements: [], warnings: [`Unsupported format: ${ext}`] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import DXF string.
|
||||
*/
|
||||
export function importDXF(dxfString: string): ImportResult {
|
||||
try {
|
||||
const result: DXFImportResult = parseDXF(dxfString);
|
||||
return {
|
||||
success: true,
|
||||
elements: result.elements,
|
||||
layers: result.layers,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
elements: [],
|
||||
warnings: [],
|
||||
error: `DXF parse error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import SVG string — converts SVG elements to CAD elements.
|
||||
*/
|
||||
export function importSVG(svgString: string): ImportResult {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(svgString, 'image/svg+xml');
|
||||
const svg = doc.documentElement;
|
||||
const elements: CADElement[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Parse SVG viewBox for coordinate mapping
|
||||
const viewBox = svg.getAttribute('viewBox');
|
||||
let vbX = 0, vbY = 0;
|
||||
if (viewBox) {
|
||||
const parts = viewBox.split(/[\s,]+/).map(Number);
|
||||
vbX = parts[0] || 0;
|
||||
vbY = parts[1] || 0;
|
||||
}
|
||||
|
||||
// Process SVG elements
|
||||
const processElement = (node: Element) => {
|
||||
const tag = node.tagName.toLowerCase();
|
||||
const stroke = node.getAttribute('stroke') || '#ffffff';
|
||||
const strokeWidth = parseFloat(node.getAttribute('stroke-width') || '1');
|
||||
const fill = node.getAttribute('fill') || 'none';
|
||||
|
||||
switch (tag) {
|
||||
case 'line': {
|
||||
const x1 = parseFloat(node.getAttribute('x1') || '0') - vbX;
|
||||
const y1 = parseFloat(node.getAttribute('y1') || '0') - vbY;
|
||||
const x2 = parseFloat(node.getAttribute('x2') || '0') - vbX;
|
||||
const y2 = parseFloat(node.getAttribute('y2') || '0') - vbY;
|
||||
elements.push({
|
||||
id: nextId(), type: 'line', layerId: 'layer-0',
|
||||
x: Math.min(x1, x2), y: Math.min(y1, y2),
|
||||
width: Math.abs(x2 - x1), height: Math.abs(y2 - y1),
|
||||
properties: { stroke, strokeWidth, x1, y1, x2, y2 },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
const x = parseFloat(node.getAttribute('x') || '0') - vbX;
|
||||
const y = parseFloat(node.getAttribute('y') || '0') - vbY;
|
||||
const w = parseFloat(node.getAttribute('width') || '0');
|
||||
const h = parseFloat(node.getAttribute('height') || '0');
|
||||
elements.push({
|
||||
id: nextId(), type: 'rect', layerId: 'layer-0',
|
||||
x, y, width: w, height: h,
|
||||
properties: { stroke, strokeWidth, fill: fill !== 'none' ? fill : undefined },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const cx = parseFloat(node.getAttribute('cx') || '0') - vbX;
|
||||
const cy = parseFloat(node.getAttribute('cy') || '0') - vbY;
|
||||
const r = parseFloat(node.getAttribute('r') || '0');
|
||||
elements.push({
|
||||
id: nextId(), type: 'circle', layerId: 'layer-0',
|
||||
x: cx - r, y: cy - r, width: r * 2, height: r * 2,
|
||||
properties: { stroke, strokeWidth, radius: r },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'ellipse': {
|
||||
const cx = parseFloat(node.getAttribute('cx') || '0') - vbX;
|
||||
const cy = parseFloat(node.getAttribute('cy') || '0') - vbY;
|
||||
const rx = parseFloat(node.getAttribute('rx') || '0');
|
||||
const ry = parseFloat(node.getAttribute('ry') || '0');
|
||||
elements.push({
|
||||
id: nextId(), type: 'circle', layerId: 'layer-0',
|
||||
x: cx - rx, y: cy - ry, width: rx * 2, height: ry * 2,
|
||||
properties: { stroke, strokeWidth, radius: Math.max(rx, ry) },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'polyline':
|
||||
case 'polygon': {
|
||||
const ptsStr = node.getAttribute('points') || '';
|
||||
const pts = ptsStr.trim().split(/[\s,]+/).reduce((acc: Array<{x:number;y:number}>, val: string, idx: number) => {
|
||||
if (idx % 2 === 0) acc.push({ x: parseFloat(val) - vbX, y: 0 });
|
||||
else acc[acc.length - 1].y = parseFloat(val) - vbY;
|
||||
return acc;
|
||||
}, []);
|
||||
if (pts.length >= 2) {
|
||||
const minX = Math.min(...pts.map(p => p.x));
|
||||
const minY = Math.min(...pts.map(p => p.y));
|
||||
const maxX = Math.max(...pts.map(p => p.x));
|
||||
const maxY = Math.max(...pts.map(p => p.y));
|
||||
elements.push({
|
||||
id: nextId(), type: tag === 'polygon' ? 'polygon' : 'polyline', layerId: 'layer-0',
|
||||
x: minX, y: minY, width: maxX - minX, height: maxY - minY,
|
||||
properties: { stroke, strokeWidth, points: pts },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const x = parseFloat(node.getAttribute('x') || '0') - vbX;
|
||||
const y = parseFloat(node.getAttribute('y') || '0') - vbY;
|
||||
const fontSize = parseFloat(node.getAttribute('font-size') || '12');
|
||||
const text = node.textContent || '';
|
||||
elements.push({
|
||||
id: nextId(), type: 'text', layerId: 'layer-0',
|
||||
x, y, width: 0, height: 0,
|
||||
properties: { stroke, strokeWidth, text, fontSize },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'g': {
|
||||
// Process group children
|
||||
Array.from(node.children).forEach(processElement);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
warnings.push(`Unsupported SVG element: ${tag}`);
|
||||
}
|
||||
};
|
||||
|
||||
Array.from(svg.children).forEach(processElement);
|
||||
|
||||
return { success: true, elements, warnings };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false, elements: [], warnings: [],
|
||||
error: `SVG parse error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import JSON project file.
|
||||
*/
|
||||
export function importJSON(jsonString: string): ImportResult {
|
||||
try {
|
||||
const data: ProjectData = JSON.parse(jsonString);
|
||||
return {
|
||||
success: true,
|
||||
elements: data.elements || [],
|
||||
layers: data.layers || [],
|
||||
blocks: data.blocks || [],
|
||||
warnings: [],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false, elements: [], warnings: [],
|
||||
error: `JSON parse error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* PDF Export – renders CAD elements to PDF using pdf-lib
|
||||
*/
|
||||
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
|
||||
import type { CADElement, CADLayer, ProjectData } from '../types/cad.types';
|
||||
|
||||
/**
|
||||
* Export project data to a PDF byte array.
|
||||
*/
|
||||
export async function exportPDF(data: ProjectData): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
|
||||
// Calculate bounding box of all elements
|
||||
const bbox = calculateBoundingBox(data.elements);
|
||||
const padding = 20;
|
||||
const contentW = bbox.maxX - bbox.minX + padding * 2;
|
||||
const contentH = bbox.maxY - bbox.minY + padding * 2;
|
||||
|
||||
// Use A4 landscape or fit to content (whichever is larger)
|
||||
const a4W = 842; // A4 landscape in points
|
||||
const a4H = 595;
|
||||
const pageW = Math.max(a4W, contentW);
|
||||
const pageH = Math.max(a4H, contentH);
|
||||
|
||||
const page = pdfDoc.addPage([pageW, pageH]);
|
||||
const { width: pw, height: ph } = page.getSize();
|
||||
|
||||
// Flip Y axis: PDF origin is bottom-left, CAD origin is top-left
|
||||
const flipY = (y: number) => ph - y + bbox.minY - padding;
|
||||
const offsetX = -bbox.minX + padding;
|
||||
const offsetY = bbox.minY - padding;
|
||||
|
||||
// Draw elements
|
||||
for (const el of data.elements) {
|
||||
const layer = data.layers.find(l => l.id === el.layerId);
|
||||
if (layer && !layer.visible) continue;
|
||||
drawElement(page, el, font, offsetX, offsetY, flipY, layer?.color);
|
||||
}
|
||||
|
||||
return pdfDoc.save();
|
||||
}
|
||||
|
||||
function drawElement(
|
||||
page: any,
|
||||
el: CADElement,
|
||||
font: any,
|
||||
offX: number,
|
||||
offY: number,
|
||||
flipY: (y: number) => number,
|
||||
layerColor?: string,
|
||||
): void {
|
||||
const p = el.properties;
|
||||
const color = hexToRgb(p.stroke as string) ?? hexToRgb(layerColor ?? '#000000') ?? rgb(0, 0, 0);
|
||||
const lineWidth = p.strokeWidth ?? 1;
|
||||
|
||||
switch (el.type) {
|
||||
case 'line': {
|
||||
const x1 = (p.x1 ?? el.x) + offX;
|
||||
const y1 = flipY((p.y1 ?? el.y) - offY);
|
||||
const x2 = (p.x2 ?? el.x + el.width) + offX;
|
||||
const y2 = flipY((p.y2 ?? el.y + el.height) - offY);
|
||||
page.drawLine({ start: { x: x1, y: y1 }, end: { x: x2, y: y2 }, thickness: lineWidth, color });
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const cx = el.x + el.width / 2 + offX;
|
||||
const cy = flipY(el.y + el.height / 2 - offY);
|
||||
const r = p.radius ?? el.width / 2;
|
||||
page.drawCircle({ x: cx, y: cy, radius: r, borderColor: color, borderWidth: lineWidth });
|
||||
break;
|
||||
}
|
||||
case 'arc': {
|
||||
// Approximate arc with line segments
|
||||
const cx = el.x + el.width / 2 + offX;
|
||||
const cy = flipY(el.y + el.height / 2 - offY);
|
||||
const r = p.radius ?? el.width / 2;
|
||||
const start = p.startAngle ?? 0;
|
||||
const end = p.endAngle ?? Math.PI * 2;
|
||||
const segments = 32;
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const a1 = start + (end - start) * (i / segments);
|
||||
const a2 = start + (end - start) * ((i + 1) / segments);
|
||||
page.drawLine({
|
||||
start: { x: cx + Math.cos(a1) * r, y: cy + Math.sin(a1) * r },
|
||||
end: { x: cx + Math.cos(a2) * r, y: cy + Math.sin(a2) * r },
|
||||
thickness: lineWidth, color,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
page.drawRectangle({
|
||||
x: el.x + offX, y: flipY(el.y + el.height - offY),
|
||||
width: el.width, height: el.height,
|
||||
borderColor: color, borderWidth: lineWidth,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'polygon':
|
||||
case 'polyline': {
|
||||
const pts = p.points ?? [];
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
page.drawLine({
|
||||
start: { x: pts[i].x + offX, y: flipY(pts[i].y - offY) },
|
||||
end: { x: pts[i + 1].x + offX, y: flipY(pts[i + 1].y - offY) },
|
||||
thickness: lineWidth, color,
|
||||
});
|
||||
}
|
||||
if (el.type === 'polygon' && pts.length > 2) {
|
||||
page.drawLine({
|
||||
start: { x: pts[pts.length - 1].x + offX, y: flipY(pts[pts.length - 1].y - offY) },
|
||||
end: { x: pts[0].x + offX, y: flipY(pts[0].y - offY) },
|
||||
thickness: lineWidth, color,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const size = p.fontSize ?? 12;
|
||||
page.drawText(p.text ?? '', {
|
||||
x: el.x + offX, y: flipY(el.y - offY) - size,
|
||||
size, font, color,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'dimension': {
|
||||
const pts = p.points ?? [];
|
||||
if (pts.length >= 2) {
|
||||
page.drawLine({
|
||||
start: { x: pts[0].x + offX, y: flipY(pts[0].y - offY) },
|
||||
end: { x: pts[1].x + offX, y: flipY(pts[1].y - offY) },
|
||||
thickness: lineWidth, color,
|
||||
});
|
||||
const midX = (pts[0].x + pts[1].x) / 2 + offX;
|
||||
const midY = flipY((pts[0].y + pts[1].y) / 2 - offY);
|
||||
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
|
||||
page.drawText(dist.toFixed(2), { x: midX, y: midY, size: 10, font, color });
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Bounding box for other element types
|
||||
page.drawRectangle({
|
||||
x: el.x + offX, y: flipY(el.y + el.height - offY),
|
||||
width: el.width, height: el.height,
|
||||
borderColor: color, borderWidth: lineWidth,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function calculateBoundingBox(elements: CADElement[]) {
|
||||
if (elements.length === 0) return { minX: 0, minY: 0, maxX: 1000, maxY: 1000 };
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of elements) {
|
||||
minX = Math.min(minX, el.x);
|
||||
minY = Math.min(minY, el.y);
|
||||
maxX = Math.max(maxX, el.x + el.width);
|
||||
maxY = Math.max(maxY, el.y + el.height);
|
||||
}
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string | undefined): ReturnType<typeof rgb> | null {
|
||||
if (!hex) return null;
|
||||
const h = hex.replace('#', '');
|
||||
if (h.length < 6) return null;
|
||||
const r = parseInt(h.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(h.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(h.substring(4, 6), 16) / 255;
|
||||
return rgb(r, g, b);
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import type { CADElement, CADProperties } from '../types/cad.types';
|
||||
|
||||
/**
|
||||
* Seating Service — creates chairs, rows, blocks, tables, stages with configurable parameters.
|
||||
* Also provides seat counting and template presets.
|
||||
*/
|
||||
|
||||
export interface ChairConfig {
|
||||
width: number;
|
||||
height: number;
|
||||
fill: string;
|
||||
backrestColor: string;
|
||||
outlineColor: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_CHAIR: ChairConfig = {
|
||||
width: 40,
|
||||
height: 40,
|
||||
fill: '#4a90d9',
|
||||
backrestColor: '#3a7ac9',
|
||||
outlineColor: '#2a5a99',
|
||||
};
|
||||
|
||||
export interface SeatingRowConfig {
|
||||
count: number;
|
||||
spacing: number;
|
||||
rotation: number;
|
||||
chairWidth: number;
|
||||
chairHeight: number;
|
||||
fill: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROW: SeatingRowConfig = {
|
||||
count: 10,
|
||||
spacing: 50,
|
||||
rotation: 0,
|
||||
chairWidth: 40,
|
||||
chairHeight: 40,
|
||||
fill: '#4a90d9',
|
||||
};
|
||||
|
||||
export interface SeatingBlockConfig {
|
||||
rows: number;
|
||||
cols: number;
|
||||
rowSpacing: number;
|
||||
colSpacing: number;
|
||||
rowOffset: number;
|
||||
rotation: number;
|
||||
chairWidth: number;
|
||||
chairHeight: number;
|
||||
fill: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_BLOCK: SeatingBlockConfig = {
|
||||
rows: 5,
|
||||
cols: 10,
|
||||
rowSpacing: 50,
|
||||
colSpacing: 50,
|
||||
rowOffset: 0,
|
||||
rotation: 0,
|
||||
chairWidth: 40,
|
||||
chairHeight: 40,
|
||||
fill: '#4a90d9',
|
||||
};
|
||||
|
||||
export interface TableConfig {
|
||||
width: number;
|
||||
height: number;
|
||||
shape: 'rect' | 'round';
|
||||
fill: string;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_TABLE: TableConfig = {
|
||||
width: 80,
|
||||
height: 40,
|
||||
shape: 'rect',
|
||||
fill: '#8b6f47',
|
||||
rotation: 0,
|
||||
};
|
||||
|
||||
export interface StageConfig {
|
||||
width: number;
|
||||
height: number;
|
||||
fill: string;
|
||||
rotation: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_STAGE: StageConfig = {
|
||||
width: 200,
|
||||
height: 60,
|
||||
fill: '#2c3e50',
|
||||
rotation: 0,
|
||||
label: 'Bühne',
|
||||
};
|
||||
|
||||
export interface SeatingTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'row' | 'block' | 'mixed';
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const SEATING_TEMPLATES: SeatingTemplate[] = [
|
||||
{
|
||||
name: 'Kleine Reihe',
|
||||
description: '5 Stühle in einer Reihe',
|
||||
type: 'row',
|
||||
config: { count: 5, spacing: 50, rotation: 0 },
|
||||
},
|
||||
{
|
||||
name: 'Mittlere Reihe',
|
||||
description: '10 Stühle in einer Reihe',
|
||||
type: 'row',
|
||||
config: { count: 10, spacing: 50, rotation: 0 },
|
||||
},
|
||||
{
|
||||
name: 'Große Reihe',
|
||||
description: '20 Stühle in einer Reihe',
|
||||
type: 'row',
|
||||
config: { count: 20, spacing: 50, rotation: 0 },
|
||||
},
|
||||
{
|
||||
name: 'Kleiner Block',
|
||||
description: '3×5 Stühle',
|
||||
type: 'block',
|
||||
config: { rows: 3, cols: 5, rowSpacing: 50, colSpacing: 50, rowOffset: 0 },
|
||||
},
|
||||
{
|
||||
name: 'Mittlerer Block',
|
||||
description: '5×10 Stühle',
|
||||
type: 'block',
|
||||
config: { rows: 5, cols: 10, rowSpacing: 50, colSpacing: 50, rowOffset: 0 },
|
||||
},
|
||||
{
|
||||
name: 'Großer Block',
|
||||
description: '8×15 Stühle',
|
||||
type: 'block',
|
||||
config: { rows: 8, cols: 15, rowSpacing: 50, colSpacing: 50, rowOffset: 0 },
|
||||
},
|
||||
{
|
||||
name: 'Konzertsaal',
|
||||
description: '3 Blöcke mit Gängen: 5×8, 5×12, 5×8',
|
||||
type: 'mixed',
|
||||
config: {
|
||||
blocks: [
|
||||
{ rows: 5, cols: 8, offsetX: 0, rowSpacing: 50, colSpacing: 50 },
|
||||
{ rows: 5, cols: 12, offsetX: 500, rowSpacing: 50, colSpacing: 50 },
|
||||
{ rows: 5, cols: 8, offsetX: 1200, rowSpacing: 50, colSpacing: 50 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Theater',
|
||||
description: '10 Reihen mit 15 Stühlen, versetzt',
|
||||
type: 'block',
|
||||
config: { rows: 10, cols: 15, rowSpacing: 55, colSpacing: 50, rowOffset: 25 },
|
||||
},
|
||||
];
|
||||
|
||||
export class SeatingService {
|
||||
private generateId(): string {
|
||||
return `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
/** Create a single chair element */
|
||||
createChair(x: number, y: number, layerId: string, config: Partial<ChairConfig> = {}): CADElement {
|
||||
const cfg = { ...DEFAULT_CHAIR, ...config };
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'chair',
|
||||
layerId,
|
||||
x,
|
||||
y,
|
||||
width: cfg.width,
|
||||
height: cfg.height,
|
||||
properties: {
|
||||
rotation: 0,
|
||||
fill: cfg.fill,
|
||||
backrestColor: cfg.backrestColor,
|
||||
outlineColor: cfg.outlineColor,
|
||||
seatType: 'standard',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a seating row (multiple chairs in a line) */
|
||||
createSeatingRow(x: number, y: number, layerId: string, config: Partial<SeatingRowConfig> = {}): CADElement[] {
|
||||
const cfg = { ...DEFAULT_ROW, ...config };
|
||||
const elements: CADElement[] = [];
|
||||
const totalWidth = (cfg.count - 1) * cfg.spacing + cfg.chairWidth;
|
||||
const startX = x - totalWidth / 2 + cfg.chairWidth / 2;
|
||||
|
||||
for (let i = 0; i < cfg.count; i++) {
|
||||
const cx = startX + i * cfg.spacing;
|
||||
const cy = y;
|
||||
// Apply rotation around center
|
||||
const rot = cfg.rotation * Math.PI / 180;
|
||||
const dx = cx - x;
|
||||
const dy = cy - y;
|
||||
const rx = dx * Math.cos(rot) - dy * Math.sin(rot) + x;
|
||||
const ry = dx * Math.sin(rot) + dy * Math.cos(rot) + y;
|
||||
|
||||
elements.push({
|
||||
id: this.generateId(),
|
||||
type: 'chair',
|
||||
layerId,
|
||||
x: rx,
|
||||
y: ry,
|
||||
width: cfg.chairWidth,
|
||||
height: cfg.chairHeight,
|
||||
properties: {
|
||||
rotation: cfg.rotation,
|
||||
fill: cfg.fill,
|
||||
backrestColor: '#3a7ac9',
|
||||
outlineColor: '#2a5a99',
|
||||
seatType: 'standard',
|
||||
rowIndex: i,
|
||||
rowId: `row_${Date.now()}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
/** Create a seating block (rows × cols of chairs) */
|
||||
createSeatingBlock(x: number, y: number, layerId: string, config: Partial<SeatingBlockConfig> = {}): CADElement[] {
|
||||
const cfg = { ...DEFAULT_BLOCK, ...config };
|
||||
const elements: CADElement[] = [];
|
||||
const totalW = (cfg.cols - 1) * cfg.colSpacing + cfg.chairWidth;
|
||||
const totalH = (cfg.rows - 1) * cfg.rowSpacing + cfg.chairHeight;
|
||||
const startX = x - totalW / 2 + cfg.chairWidth / 2;
|
||||
const startY = y - totalH / 2 + cfg.chairHeight / 2;
|
||||
const rot = cfg.rotation * Math.PI / 180;
|
||||
const blockId = `block_${Date.now()}`;
|
||||
|
||||
for (let row = 0; row < cfg.rows; row++) {
|
||||
const offset = cfg.rowOffset * (row % 2);
|
||||
for (let col = 0; col < cfg.cols; col++) {
|
||||
const lx = startX + col * cfg.colSpacing + offset;
|
||||
const ly = startY + row * cfg.rowSpacing;
|
||||
// Rotate around center
|
||||
const dx = lx - x;
|
||||
const dy = ly - y;
|
||||
const rx = dx * Math.cos(rot) - dy * Math.sin(rot) + x;
|
||||
const ry = dx * Math.sin(rot) + dy * Math.cos(rot) + y;
|
||||
|
||||
elements.push({
|
||||
id: this.generateId(),
|
||||
type: 'chair',
|
||||
layerId,
|
||||
x: rx,
|
||||
y: ry,
|
||||
width: cfg.chairWidth,
|
||||
height: cfg.chairHeight,
|
||||
properties: {
|
||||
rotation: cfg.rotation,
|
||||
fill: cfg.fill,
|
||||
backrestColor: '#3a7ac9',
|
||||
outlineColor: '#2a5a99',
|
||||
seatType: 'standard',
|
||||
rowIndex: row,
|
||||
colIndex: col,
|
||||
blockId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
/** Create a table element */
|
||||
createTable(x: number, y: number, layerId: string, config: Partial<TableConfig> = {}): CADElement {
|
||||
const cfg = { ...DEFAULT_TABLE, ...config };
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'table',
|
||||
layerId,
|
||||
x,
|
||||
y,
|
||||
width: cfg.width,
|
||||
height: cfg.height,
|
||||
properties: {
|
||||
rotation: cfg.rotation,
|
||||
fill: cfg.fill,
|
||||
shape: cfg.shape,
|
||||
stroke: '#5a4a37',
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a stage element */
|
||||
createStage(x: number, y: number, layerId: string, config: Partial<StageConfig> = {}): CADElement {
|
||||
const cfg = { ...DEFAULT_STAGE, ...config };
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'stage',
|
||||
layerId,
|
||||
x,
|
||||
y,
|
||||
width: cfg.width,
|
||||
height: cfg.height,
|
||||
properties: {
|
||||
rotation: cfg.rotation,
|
||||
fill: cfg.fill,
|
||||
stroke: '#1a2e3f',
|
||||
strokeWidth: 2,
|
||||
label: cfg.label,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create elements from a template */
|
||||
createFromTemplate(templateName: string, x: number, y: number, layerId: string): CADElement[] {
|
||||
const template = SEATING_TEMPLATES.find(t => t.name === templateName);
|
||||
if (!template) return [];
|
||||
|
||||
if (template.type === 'row') {
|
||||
return this.createSeatingRow(x, y, layerId, template.config as Partial<SeatingRowConfig>);
|
||||
}
|
||||
if (template.type === 'block') {
|
||||
return this.createSeatingBlock(x, y, layerId, template.config as Partial<SeatingBlockConfig>);
|
||||
}
|
||||
if (template.type === 'mixed') {
|
||||
const blocks = (template.config as { blocks: Array<Record<string, number>> }).blocks;
|
||||
const elements: CADElement[] = [];
|
||||
for (const blk of blocks) {
|
||||
const bx = x + (blk.offsetX || 0);
|
||||
const cfg: Partial<SeatingBlockConfig> = {
|
||||
rows: blk.rows,
|
||||
cols: blk.cols,
|
||||
rowSpacing: blk.rowSpacing || 50,
|
||||
colSpacing: blk.colSpacing || 50,
|
||||
rowOffset: 0,
|
||||
};
|
||||
elements.push(...this.createSeatingBlock(bx, y, layerId, cfg));
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Count seats in a list of elements */
|
||||
countSeats(elements: CADElement[]): { total: number; byRow: Record<string, number>; byBlock: Record<string, number> } {
|
||||
let total = 0;
|
||||
const byRow: Record<string, number> = {};
|
||||
const byBlock: Record<string, number> = {};
|
||||
|
||||
for (const el of elements) {
|
||||
if (el.type === 'chair') {
|
||||
total++;
|
||||
const rowId = el.properties.rowId as string | undefined;
|
||||
const blockId = el.properties.blockId as string | undefined;
|
||||
if (rowId) {
|
||||
byRow[rowId] = (byRow[rowId] || 0) + 1;
|
||||
}
|
||||
if (blockId) {
|
||||
byBlock[blockId] = (byBlock[blockId] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { total, byRow, byBlock };
|
||||
}
|
||||
|
||||
/** Get all chairs belonging to a specific row */
|
||||
getRowElements(elements: CADElement[], rowId: string): CADElement[] {
|
||||
return elements.filter(el => el.type === 'chair' && el.properties.rowId === rowId);
|
||||
}
|
||||
|
||||
/** Get all chairs belonging to a specific block */
|
||||
getBlockElements(elements: CADElement[], blockId: string): CADElement[] {
|
||||
return elements.filter(el => el.type === 'chair' && el.properties.blockId === blockId);
|
||||
}
|
||||
|
||||
/** Add a chair to an existing row at the end */
|
||||
addChairToRow(elements: CADElement[], rowId: string, layerId: string): CADElement | null {
|
||||
const rowChairs = this.getRowElements(elements, rowId);
|
||||
if (rowChairs.length === 0) return null;
|
||||
const last = rowChairs[rowChairs.length - 1];
|
||||
const spacing = 50;
|
||||
const rot = (last.properties.rotation || 0) * Math.PI / 180;
|
||||
const dx = spacing;
|
||||
const dy = 0;
|
||||
const rx = dx * Math.cos(rot) - dy * Math.sin(rot) + last.x;
|
||||
const ry = dx * Math.sin(rot) + dy * Math.cos(rot) + last.y;
|
||||
return this.createChair(rx, ry, layerId, {
|
||||
width: last.width,
|
||||
height: last.height,
|
||||
fill: last.properties.fill as string,
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a chair from a row by index */
|
||||
removeChairFromRow(elements: CADElement[], rowId: string, index: number): string[] {
|
||||
const rowChairs = this.getRowElements(elements, rowId);
|
||||
if (index < 0 || index >= rowChairs.length) return [];
|
||||
const sorted = rowChairs.sort((a, b) => (a.properties.rowIndex as number) - (b.properties.rowIndex as number));
|
||||
return [sorted[index].id];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user