task(F8): SVG import as block - path parser (M/L/H/V/C/S/Q/T/A/Z, bezier flattening, arc endpoint-center), rect/circle convention fixes, ellipse polygon, geometry payload for library

This commit is contained in:
Agent Zero
2026-08-29 02:31:02 +02:00
parent 9013d72c03
commit 09948bc587
3 changed files with 400 additions and 16 deletions
+5 -2
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import type { GlobalBlockFolder, GlobalBlock } from '../services/api';
import { importSVGAsBlockPayload } from '../services/importService';
import {
getGlobalFolders,
createGlobalFolder,
@@ -305,10 +306,11 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
reader.onload = async (ev) => {
const svgContent = ev.target?.result as string;
try {
const svgPayload = importSVGAsBlockPayload(svgContent);
const created = await createGlobalBlock(token, {
name: file.name.replace(/\.svg$/i, ''),
folder_id: folderId,
block_data: JSON.stringify({ type: 'svg', svg: svgContent }),
block_data: svgPayload.blockData ?? JSON.stringify({ type: 'svg', svg: svgContent }),
svg_data: svgContent,
});
setBlocks(prev => [...prev, created]);
@@ -343,9 +345,10 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
reader.onload = async (ev) => {
const svgContent = ev.target?.result as string;
try {
const svgPayload = importSVGAsBlockPayload(svgContent);
const created = await createGlobalBlock(token, {
name: file.name.replace(/\.svg$/i, ''),
block_data: JSON.stringify({ type: 'svg', svg: svgContent }),
block_data: svgPayload.blockData ?? JSON.stringify({ type: 'svg', svg: svgContent }),
svg_data: svgContent,
});
setBlocks(prev => [...prev, created]);
+268 -12
View File
@@ -59,8 +59,218 @@ export function importDXF(dxfString: string): ImportResult {
}
}
/**
* Task F8 SVG-Path-Parser: d-Attribut → Subpaths (Punkte + closed).
*
* Unterstützte Befehle: M/L/H/V/C/S/Q/T/A/Z — absolut und relativ (lowercase).
* Kubische/quadratische Bézier werden mit je 16/12 Segmenten geflattet,
* Arc (A) über Endpoint→Center-Parametrisierung (SVG Spec F.6.5) gesampelt.
* Implizite Befehlswiederholung („L 10 0 20 0") und M→L-Fortsetzung
* sind abgedeckt. viewBox-Offset (vbX/vbY) wird von allen Punkten abgezogen.
*/
interface PathPoint { x: number; y: number }
interface PathSubpath { points: PathPoint[]; closed: boolean }
function parsePathData(d: string, vbX: number, vbY: number): PathSubpath[] {
const tokens = d.match(/[MmLlHhVvCcSsQqTtAaZz]|-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g) ?? [];
const subpaths: PathSubpath[] = [];
let i = 0;
let cx = 0, cy = 0; // aktueller Punkt
let sx = 0, sy = 0; // Subpath-Start (für Z)
let pcx = 0, pcy = 0; // letzter kubischer Kontrollpunkt (für S)
let pqx = 0, pqy = 0; // letzter quadratischer Kontrollpunkt (für T)
let prevCubic = false, prevQuad = false;
let current: PathPoint[] | null = null;
let lastCmd = '';
const flush = (closed: boolean) => {
if (current && current.length >= 2) subpaths.push({ points: current, closed });
current = null;
};
const moveTo = (x: number, y: number) => {
flush(false);
current = [{ x, y }];
cx = x; cy = y; sx = x; sy = y;
};
const lineTo = (x: number, y: number) => {
if (!current) current = [{ x: cx, y: cy }];
current.push({ x, y });
cx = x; cy = y;
};
const cubicTo = (x1: number, y1: number, x2: number, y2: number, ex: number, ey: number) => {
const SEG = 16;
for (let s = 1; s <= SEG; s++) {
const t = s / SEG;
const mt = 1 - t;
const px = mt * mt * mt * cx + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t * ex;
const py = mt * mt * mt * cy + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t * ey;
if (!current) current = [{ x: cx, y: cy }];
current.push({ x: px, y: py });
}
pcx = x2; pcy = y2;
prevCubic = true; prevQuad = false;
cx = ex; cy = ey;
};
const quadTo = (x1: number, y1: number, ex: number, ey: number) => {
const SEG = 12;
for (let s = 1; s <= SEG; s++) {
const t = s / SEG;
const mt = 1 - t;
const px = mt * mt * cx + 2 * mt * t * x1 + t * t * ex;
const py = mt * mt * cy + 2 * mt * t * y1 + t * t * ey;
if (!current) current = [{ x: cx, y: cy }];
current.push({ x: px, y: py });
}
pqx = x1; pqy = y1;
prevQuad = true; prevCubic = false;
cx = ex; cy = ey;
};
const arcTo = (rx: number, ry: number, rotDeg: number, laf: number, sf: number, ex: number, ey: number) => {
if (rx === 0 || ry === 0 || (ex === cx && ey === cy)) { lineTo(ex, ey); return; }
// Endpoint → Center-Parametrisierung (SVG Spec F.6.5)
const rot = (rotDeg * Math.PI) / 180;
const cosP = Math.cos(rot), sinP = Math.sin(rot);
const dx = (cx - ex) / 2, dy = (cy - ey) / 2;
const x1p = cosP * dx + sinP * dy;
const y1p = -sinP * dx + cosP * dy;
let RX = Math.abs(rx), RY = Math.abs(ry);
const lambda = (x1p * x1p) / (RX * RX) + (y1p * y1p) / (RY * RY);
if (lambda > 1) {
const scale = Math.sqrt(lambda);
RX *= scale; RY *= scale;
}
const num = RX * RX * RY * RY - RX * RX * y1p * y1p - RY * RY * x1p * x1p;
const den = RX * RX * y1p * y1p + RY * RY * x1p * x1p;
let co = Math.sqrt(Math.max(0, den === 0 ? 0 : num / den));
if (laf === sf) co = -co;
const cxp = (co * RX * y1p) / RY;
const cyp = (-co * RY * x1p) / RX;
const ccx = cosP * cxp - sinP * cyp + (cx + ex) / 2;
const ccy = sinP * cxp + cosP * cyp + (cy + ey) / 2;
const ang = (ux: number, uy: number, vx: number, vy: number): number => {
const dot = ux * vx + uy * vy;
const len = Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy) || 1;
let a = Math.acos(Math.max(-1, Math.min(1, dot / len)));
if (ux * vy - uy * vx < 0) a = -a;
return a;
};
const theta1 = ang(1, 0, (x1p - cxp) / RX, (y1p - cyp) / RY);
let dTheta = ang((x1p - cxp) / RX, (y1p - cyp) / RY, (-x1p - cxp) / RX, (-y1p - cyp) / RY) % (2 * Math.PI);
if (!sf && dTheta > 0) dTheta -= 2 * Math.PI;
if (sf && dTheta < 0) dTheta += 2 * Math.PI;
const N = 32;
for (let s = 1; s <= N; s++) {
if (!current) current = [{ x: cx, y: cy }];
if (s === N) {
// Endpunkt exakt (Float-Rundung der Trigonometrie vermeiden)
current.push({ x: ex, y: ey });
break;
}
const th = theta1 + dTheta * (s / N);
const px = ccx + cosP * RX * Math.cos(th) - sinP * RY * Math.sin(th);
const py = ccy + sinP * RX * Math.cos(th) + cosP * RY * Math.sin(th);
current.push({ x: px, y: py });
}
cx = ex; cy = ey;
prevCubic = prevQuad = false;
};
const readNums = (n: number): number[] => Array.from({ length: n }, () => parseFloat(tokens[i++] ?? '0'));
const isCmd = (t: string) => /^[MmLlHhVvCcSsQqTtAaZz]$/.test(t);
while (i < tokens.length) {
let cmd = tokens[i++];
if (!isCmd(cmd)) {
// implizite Wiederholung des letzten Befehls; nach M gilt L
i--;
if (!lastCmd) break;
cmd = lastCmd === 'M' ? 'L' : lastCmd === 'm' ? 'l' : lastCmd;
} else {
lastCmd = cmd;
}
const up = cmd.toUpperCase();
const abs = cmd === up;
switch (up) {
case 'M': {
const [x, y] = readNums(2);
moveTo(abs ? x - vbX : cx + x, abs ? y - vbY : cy + y);
break;
}
case 'L': {
const [x, y] = readNums(2);
lineTo(abs ? x - vbX : cx + x, abs ? y - vbY : cy + y);
break;
}
case 'H': {
const [x] = readNums(1);
lineTo(abs ? x - vbX : cx + x, cy);
break;
}
case 'V': {
const [y] = readNums(1);
lineTo(cx, abs ? y - vbY : cy + y);
break;
}
case 'C': {
const n = readNums(6);
cubicTo(
abs ? n[0] - vbX : cx + n[0], abs ? n[1] - vbY : cy + n[1],
abs ? n[2] - vbX : cx + n[2], abs ? n[3] - vbY : cy + n[3],
abs ? n[4] - vbX : cx + n[4], abs ? n[5] - vbY : cy + n[5],
);
break;
}
case 'S': {
const n = readNums(4);
const x1 = prevCubic ? 2 * cx - pcx : cx;
const y1 = prevCubic ? 2 * cy - pcy : cy;
cubicTo(
x1, y1,
abs ? n[0] - vbX : cx + n[0], abs ? n[1] - vbY : cy + n[1],
abs ? n[2] - vbX : cx + n[2], abs ? n[3] - vbY : cy + n[3],
);
break;
}
case 'Q': {
const n = readNums(4);
quadTo(
abs ? n[0] - vbX : cx + n[0], abs ? n[1] - vbY : cy + n[1],
abs ? n[2] - vbX : cx + n[2], abs ? n[3] - vbY : cy + n[3],
);
break;
}
case 'T': {
const n = readNums(2);
const x1 = prevQuad ? 2 * cx - pqx : cx;
const y1 = prevQuad ? 2 * cy - pqy : cy;
quadTo(x1, y1, abs ? n[0] - vbX : cx + n[0], abs ? n[1] - vbY : cy + n[1]);
break;
}
case 'A': {
const n = readNums(7);
arcTo(
n[0], n[1], n[2], n[3], n[4],
abs ? n[5] - vbX : cx + n[5], abs ? n[6] - vbY : cy + n[6],
);
break;
}
case 'Z': {
// Polygon schließt automatisch — keinen duplizierten Endpunkt anhängen
flush(true);
cx = sx; cy = sy;
prevCubic = prevQuad = false;
break;
}
}
}
flush(false);
return subpaths;
}
/**
* Import SVG string — converts SVG elements to CAD elements.
* Task F8: path-Support (M/L/H/V/C/S/Q/T/A/Z), rect/circle in App-Konvention
* (x/y = Zentrum), ellipse als geschlossenes Polygon.
*/
export function importSVG(svgString: string): ImportResult {
try {
@@ -79,6 +289,12 @@ export function importSVG(svgString: string): ImportResult {
vbY = parts[1] || 0;
}
const bboxOf = (pts: PathPoint[]) => {
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
const minX = Math.min(...xs), minY = Math.min(...ys);
return { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
};
// Process SVG elements
const processElement = (node: Element) => {
const tag = node.tagName.toLowerCase();
@@ -101,38 +317,50 @@ export function importSVG(svgString: string): ImportResult {
break;
}
case 'rect': {
// App-Konvention (Task F8): rect x/y = Zentrum der BBox
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,
x: x + w / 2, y: y + h / 2, width: w, height: h,
properties: { stroke, strokeWidth, fill: fill !== 'none' ? fill : undefined },
});
break;
}
case 'circle': {
// App-Konvention (Task F8): el.x/el.y = Zentrum direkt (Renderer liest es so)
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 },
x: cx, y: cy, width: r * 2, height: r * 2,
properties: { stroke, strokeWidth, radius: r, fill: fill !== 'none' ? fill : undefined },
});
break;
}
case 'ellipse': {
// Task F8: echte Ellipse als geschlossenes Polygon (64 Punkte),
// statt Kreis mit max(rx, ry)
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');
const pts: PathPoint[] = [];
const N = 64;
for (let s = 0; s < N; s++) {
const t = (s / N) * Math.PI * 2;
pts.push({ x: cx + rx * Math.cos(t), y: cy + ry * Math.sin(t) });
}
if (pts.length >= 3) {
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) },
id: nextId(), type: 'polygon', layerId: 'layer-0',
...bboxOf(pts),
properties: { stroke, strokeWidth, points: pts, fill: fill !== 'none' ? fill : undefined },
});
}
break;
}
case 'polyline':
@@ -144,14 +372,26 @@ export function importSVG(svgString: string): ImportResult {
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 },
...bboxOf(pts),
properties: { stroke, strokeWidth, points: pts, fill: fill !== 'none' ? fill : undefined },
});
}
break;
}
case 'path': {
// Task F8: d-Attribut → Subpaths (polyline/polygon je Z-Flag)
const d = node.getAttribute('d') || '';
for (const sub of parsePathData(d, vbX, vbY)) {
elements.push({
id: nextId(), type: sub.closed ? 'polygon' : 'polyline', layerId: 'layer-0',
...bboxOf(sub.points),
properties: {
stroke, strokeWidth,
points: sub.points,
fill: sub.closed && fill !== 'none' ? fill : undefined,
},
});
}
break;
@@ -189,6 +429,22 @@ export function importSVG(svgString: string): ImportResult {
}
}
/**
* Task F8 SVG als Block-Payload für die globale Bibliothek.
*
* Liefert die echte Geometrie (CADElement-Array als JSON-String) plus das
* Original-SVG als Thumbnail. Ohne Geometrie (z.B. nur <defs>) ist
* blockData null — der Caller fällt dann auf den {type:'svg'}-Raw-Fallback
* zurück.
*/
export function importSVGAsBlockPayload(svgString: string): { blockData: string | null; svg: string } {
const res = importSVG(svgString);
return {
blockData: res.success && res.elements.length > 0 ? JSON.stringify(res.elements) : null,
svg: svgString,
};
}
/**
* Import JSON project file.
*/
+125
View File
@@ -0,0 +1,125 @@
/**
* Task F8 SVG-Import als Block: Geometrie-Konvertierung.
*
* Path-Parser (M/L/H/V/C/S/Q/T/A/Z, relative + absolute), Bézier-Flattening,
* Arc→Polyline, Konventions-Fixes (rect=Zentrum, circle=cx/cy direkt),
* ellipse→Polyline statt verstümmeltem Kreis, sowie Block-Payload-Erzeugung
* (SVG → Element-Array + Thumbnail) für die globale Bibliothek.
*/
import { describe, it, expect } from 'vitest';
import { importSVG, importSVGAsBlockPayload } from '../src/services/importService';
describe('F8: SVG path → Polyline-Approximation', () => {
it('M + L + Z erzeugt geschlossene Polyline mit exakten Punkten', () => {
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="M 0 0 L 100 0 L 100 100 Z" stroke="#fff" fill="none"/></svg>');
expect(res.success).toBe(true);
const poly = res.elements.find((e) => e.type === 'polygon');
expect(poly).toBeDefined();
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
expect(pts.length).toBeGreaterThanOrEqual(3);
expect(pts[0]).toEqual({ x: 0, y: 0 });
expect(pts[1]).toEqual({ x: 100, y: 0 });
expect(pts[2]).toEqual({ x: 100, y: 100 });
});
it('kleine Befehle sind relativ (m/l): Startpunkt wirkt', () => {
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="m 10 10 l 20 0 l 0 20 z"/></svg>');
const poly = res.elements.find((e) => e.type === 'polygon');
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
expect(pts[0]).toEqual({ x: 10, y: 10 });
expect(pts[1]).toEqual({ x: 30, y: 10 });
expect(pts[2]).toEqual({ x: 30, y: 30 });
});
it('kubischer Bézier (C) wird geflattet: Kurvenpunkte + Endpunkt exakt', () => {
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="M 0 0 C 50 100 100 0 100 100"/></svg>');
const poly = res.elements.find((e) => e.type === 'polyline');
expect(poly).toBeDefined();
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
expect(pts.length).toBeGreaterThan(10); // geflattet, nicht nur Endpunkte
expect(pts[0]).toEqual({ x: 0, y: 0 });
expect(pts[pts.length - 1]).toEqual({ x: 100, y: 100 });
// Mittelpunkt der Kubik-Bézier bei t=0.5: exakt berechenbar
expect(pts[Math.floor(pts.length / 2)].y).toBeGreaterThan(20);
});
it('H/V-Befehle (horizontal/vertical) funktionieren', () => {
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="M 0 0 H 50 V 30 L 10 30 Z"/></svg>');
const poly = res.elements.find((e) => e.type === 'polygon');
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
expect(pts[0]).toEqual({ x: 0, y: 0 });
expect(pts[1]).toEqual({ x: 50, y: 0 });
expect(pts[2]).toEqual({ x: 50, y: 30 });
});
it('Arc (A) wird approximiert: Halbkreis über (100,0) mit korrektem Bogenverlauf', () => {
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="M 0 0 A 50 50 0 0 1 100 0"/></svg>');
const poly = res.elements.find((e) => e.type === 'polyline');
expect(poly).toBeDefined();
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
expect(pts.length).toBeGreaterThan(8);
expect(pts[0]).toEqual({ x: 0, y: 0 });
expect(pts[pts.length - 1]).toEqual({ x: 100, y: 0 });
// Scheitel des Halbkreises: (50, -50) bei sweep=1 (y-up im Bogen oben)
const maxY = Math.max(...pts.map((p) => p.y));
const minY = Math.min(...pts.map((p) => p.y));
expect(minY).toBeLessThan(-40); // Bogen wölbt sich nach oben (negativ)
expect(maxY).toBeCloseTo(0, 5);
});
it('Mehrfach-Subpaths ergeben mehrere Elemente', () => {
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><path d="M 0 0 L 10 0 M 100 100 L 110 100"/></svg>');
const polys = res.elements.filter((e) => e.type === 'polyline');
expect(polys.length).toBe(2);
});
});
describe('F8: Konventions-Fixes', () => {
it('rect: x/y = Zentrum (App-Konvention, nicht Ecke)', () => {
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><rect x="0" y="0" width="100" height="50"/></svg>');
const rect = res.elements.find((e) => e.type === 'rect');
expect(rect).toBeDefined();
expect(rect!.x).toBe(50);
expect(rect!.y).toBe(25);
expect(rect!.width).toBe(100);
});
it('circle: el.x/el.y = Zentrum direkt (Renderer-Konvention)', () => {
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><circle cx="30" cy="40" r="10"/></svg>');
const circ = res.elements.find((e) => e.type === 'circle');
expect(circ).toBeDefined();
expect(circ!.x).toBe(30);
expect(circ!.y).toBe(40);
expect(circ!.properties.radius).toBe(10);
});
it('ellipse → geschlossenes Polygon mit rx/ry korrekt (nicht verstümmelter Kreis)', () => {
const res = importSVG('<svg xmlns="http://www.w3.org/2000/svg"><ellipse cx="50" cy="50" rx="100" ry="40"/></svg>');
const poly = res.elements.find((e) => e.type === 'polygon');
expect(poly).toBeDefined();
const pts = poly!.properties.points as Array<{ x: number; y: number }>;
expect(pts.length).toBeGreaterThanOrEqual(32);
expect(Math.max(...pts.map((p) => p.x))).toBeCloseTo(150, 5);
expect(Math.min(...pts.map((p) => p.x))).toBeCloseTo(-50, 5);
expect(Math.max(...pts.map((p) => p.y))).toBeCloseTo(90, 5);
expect(Math.min(...pts.map((p) => p.y))).toBeCloseTo(10, 5);
});
});
describe('F8: SVG als Block-Payload', () => {
it('importSVGAsBlockPayload liefert Element-Array als JSON-String + svg als Thumbnail', () => {
const payload = importSVGAsBlockPayload('<svg xmlns="http://www.w3.org/2000/svg"><rect x="0" y="0" width="80" height="40"/></svg>');
expect(payload.blockData).toBeTruthy();
const els = JSON.parse(payload.blockData);
expect(Array.isArray(els)).toBe(true);
expect(els.length).toBe(1);
expect(els[0].type).toBe('rect');
expect(payload.svg).toContain('<svg');
});
it('leeres SVG ohne Geometrie liefert null blockData (Caller nutzt {type:svg}-Fallback)', () => {
const payload = importSVGAsBlockPayload('<svg xmlns="http://www.w3.org/2000/svg"><defs/></svg>');
expect(payload.blockData).toBeNull();
expect(payload.svg).toContain('<svg');
});
});