task(F3-frontend): save selection as block - svg thumbnail from elementToPrimitives, save button in library tree

This commit is contained in:
Agent Zero
2026-08-28 13:37:35 +02:00
parent 8baba7e124
commit 5d8253a22b
3 changed files with 196 additions and 0 deletions
@@ -13,6 +13,9 @@ import {
exportLibrary,
importLibrary,
} from '../services/api';
import { getActiveDocument } from '../kernel/document/documentService';
import { generateBlockSvg } from '../utils/blockThumbnail';
import type { CADElement } from '../types/cad.types';
interface BlockLibraryTreeProps {
token: string;
@@ -76,6 +79,38 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
loadData();
}, [loadData]);
// ─── Task F3: Auswahl als Block speichern ─────────────────
const handleSaveSelectionAsBlock = useCallback(async () => {
const bridge = (window as unknown as { __v2GetSelection?: () => { ids: string[] } }).__v2GetSelection;
const ids = bridge?.().ids ?? [];
if (ids.length === 0) {
window.alert('Keine Auswahl im Canvas — erst Elemente wählen (V2-Tools).');
return;
}
const doc = getActiveDocument();
const elements: CADElement[] = [];
for (const id of ids) {
const el = doc?.getElement(id);
if (el) elements.push(el);
}
if (elements.length === 0) {
window.alert('Auswahl enthält keine Elemente.');
return;
}
const name = window.prompt('Blockname:', `Block-${new Date().toISOString().slice(0, 10)}`);
if (!name?.trim()) return;
try {
await createGlobalBlock(token, {
name: name.trim(),
block_data: JSON.stringify(elements),
svg_data: generateBlockSvg(elements),
});
await loadData();
} catch (err) {
window.alert(`Speichern fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
}
}, [token, loadData]);
// ─── Task F1: Library-Paket Export/Import ────────────────
const handleLibraryExport = useCallback(async () => {
@@ -459,6 +494,13 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
<div className="global-lib-tree" onContextMenu={(e) => handleContextMenu(e, 'root', null, null)}>
<div className="global-lib-header">
<span className="global-lib-title">🌐 Globale Bibliothek</span>
<button
className="global-lib-add-btn"
title="Canvas-Auswahl als Block speichern (F3)"
onClick={() => void handleSaveSelectionAsBlock()}
>
💾
</button>
<button
className="global-lib-add-btn"
title="Bibliothek als .wcadlib exportieren"
+109
View File
@@ -0,0 +1,109 @@
/**
* blockThumbnail SVG-Thumbnail-Generierung für Block-Bibliothek (Task F3).
*
* Statt canvas.toDataURL werden die Elemente über die bereits vorhandene,
* testbare Shape-Fabrik elementToPrimitives (Task C2) beschrieben und als
* eigenständiges SVG serialisiert. Vorteile:
* - Konsistent zu den svg_data-Thumbnails der globalen Bibliothek (Tree/Grid)
* - Unit-testbar ohne Canvas/DOM
* - Skalierbar & themefarben-identisch zur Zeichenfläche
*
* BBox-Konvention wie elementDrawers: rect x/y = Zentrum der BBox,
* circle/arc radius via center ± r.
*/
import { elementToPrimitives } from '../render/pixi/elementDrawers';
import type { ShapePrimitive } from '../render/pixi/elementDrawers';
import type { CADElement } from '../types/cad.types';
const PAD = 10;
function fmt(n: number): string {
return String(Math.round(n * 100) / 100);
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
interface BBox { minX: number; minY: number; maxX: number; maxY: number }
function primExtent(p: ShapePrimitive, acc: BBox): void {
const add = (x: number, y: number) => {
if (x < acc.minX) acc.minX = x;
if (y < acc.minY) acc.minY = y;
if (x > acc.maxX) acc.maxX = x;
if (y > acc.maxY) acc.maxY = y;
};
switch (p.kind) {
case 'line':
add(p.from.x, p.from.y); add(p.to.x, p.to.y);
break;
case 'rect':
add(p.x - p.w / 2, p.y - p.h / 2); add(p.x + p.w / 2, p.y + p.h / 2);
break;
case 'circle':
add(p.center.x - p.radius, p.center.y - p.radius); add(p.center.x + p.radius, p.center.y + p.radius);
break;
case 'arc':
add(p.center.x - p.radius, p.center.y - p.radius); add(p.center.x + p.radius, p.center.y + p.radius);
break;
case 'polyline':
for (const pt of p.points) add(pt.x, pt.y);
break;
case 'text':
add(p.at.x, p.at.y - p.fontSize);
add(p.at.x + p.fontSize * 0.6 * p.text.length, p.at.y);
break;
}
}
function primToSvgTag(p: ShapePrimitive): string {
switch (p.kind) {
case 'line':
return `<line x1="${fmt(p.from.x)}" y1="${fmt(p.from.y)}" x2="${fmt(p.to.x)}" y2="${fmt(p.to.y)}" stroke="${p.stroke}" stroke-width="${p.strokeWidth}"/>`;
case 'rect':
return `<rect x="${fmt(p.x - p.w / 2)}" y="${fmt(p.y - p.h / 2)}" width="${fmt(p.w)}" height="${fmt(p.h)}" fill="${p.fill ?? 'none'}" stroke="${p.stroke}" stroke-width="${p.strokeWidth}"/>`;
case 'circle':
return `<circle cx="${fmt(p.center.x)}" cy="${fmt(p.center.y)}" r="${fmt(p.radius)}" fill="${p.fill ?? 'none'}" stroke="${p.stroke}" stroke-width="${p.strokeWidth}"/>`;
case 'arc': {
// Konvention: 0° = 3 Uhr, gegen Uhrzeigersinn (Bildschirm-Y zeigt nach unten)
const rad = (deg: number) => (deg * Math.PI) / 180;
const sx = p.center.x + p.radius * Math.cos(rad(p.startDeg));
const sy = p.center.y - p.radius * Math.sin(rad(p.startDeg));
const ex = p.center.x + p.radius * Math.cos(rad(p.endDeg));
const ey = p.center.y - p.radius * Math.sin(rad(p.endDeg));
let delta = (p.endDeg - p.startDeg) % 360;
if (delta < 0) delta += 360;
const largeArc = delta > 180 ? 1 : 0;
// CCW auf dem Schirm = SVG sweep-flag 0
return `<path d="M ${fmt(sx)} ${fmt(sy)} A ${fmt(p.radius)} ${fmt(p.radius)} 0 ${largeArc} 0 ${fmt(ex)} ${fmt(ey)}" fill="none" stroke="${p.stroke}" stroke-width="${p.strokeWidth}"/>`;
}
case 'polyline': {
const pts = p.points.map(pt => `${fmt(pt.x)},${fmt(pt.y)}`).join(' ');
if (p.close) {
return `<polygon points="${pts}" fill="${'none'}" stroke="${p.stroke}" stroke-width="${p.strokeWidth}"/>`;
}
return `<polyline points="${pts}" fill="none" stroke="${p.stroke}" stroke-width="${p.strokeWidth}"/>`;
}
case 'text':
return `<text x="${fmt(p.at.x)}" y="${fmt(p.at.y)}" font-size="${p.fontSize}" fill="${p.color}">${escapeXml(p.text)}</text>`;
}
}
/** Serialisiert Primitive zu einem eigenständigen SVG-String (leer bei []). */
export function primitivesToSvg(prims: ShapePrimitive[]): string {
if (prims.length === 0) return '';
const acc: BBox = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
for (const p of prims) primExtent(p, acc);
const w = acc.maxX - acc.minX;
const h = acc.maxY - acc.minY;
const vb = `${fmt(acc.minX - PAD)} ${fmt(acc.minY - PAD)} ${fmt(w + 2 * PAD)} ${fmt(h + 2 * PAD)}`;
const body = prims.map(primToSvgTag).join('');
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vb}">${body}</svg>`;
}
/** Erzeugt ein SVG-Thumbnail aus einer Element-Auswahl (Task F3). */
export function generateBlockSvg(elements: CADElement[]): string {
const prims = elements.flatMap(el => elementToPrimitives(el));
return primitivesToSvg(prims);
}
+45
View File
@@ -0,0 +1,45 @@
/**
* BlockThumbnail Tests (Task F3) SVG-Thumbnail aus elementToPrimitives
*/
import { describe, it, expect } from 'vitest';
import { generateBlockSvg, primitivesToSvg } from '../src/utils/blockThumbnail';
import { elementToPrimitives } from '../src/render/pixi/elementDrawers';
import type { CADElement } from '../src/types/cad.types';
describe('blockThumbnail (F3)', () => {
const chair: CADElement = {
id: 'c1',
type: 'chair',
layerId: 'l0',
x: 100,
y: 200,
width: 40,
height: 40,
properties: {},
} as CADElement;
it('erzeugt SVG mit viewBox aus Element-BBox', () => {
const svg = generateBlockSvg([chair]);
expect(svg).toContain('<svg');
expect(svg).toContain('viewBox');
});
it('konvertiert rect-Primitive in SVG-rect', () => {
const prims = elementToPrimitives(chair);
expect(prims.length).toBeGreaterThan(0);
const svg = primitivesToSvg(prims);
expect(svg).toContain('<rect');
});
it('liefert leeren String für leere Auswahl', () => {
expect(generateBlockSvg([])).toBe('');
});
it('BBox umfasst mehrere Elemente korrekt', () => {
const a: CADElement = { ...chair, id: 'a', x: 0, y: 0 };
const b: CADElement = { ...chair, id: 'b', x: 500, y: 300 };
const svg = generateBlockSvg([a, b]);
// chair-Drawer erzeugt rect-Zentrum bei (el.x - w/2) → BBox -40..500/-40..300 + PAD 10
expect(svg).toContain('viewBox="-50 -50 560 360"');
});
});