301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
|
|
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 } },
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
}
|