task(F3-frontend): save selection as block - svg thumbnail from elementToPrimitives, save button in library tree
This commit is contained in:
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user