From 09948bc58775d48a12d5126705fe12eb136a6030 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 29 Aug 2026 02:31:02 +0200 Subject: [PATCH] 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 --- frontend/src/components/BlockLibraryTree.tsx | 7 +- frontend/src/services/importService.ts | 284 ++++++++++++++++++- frontend/tests/svgImportF8.test.ts | 125 ++++++++ 3 files changed, 400 insertions(+), 16 deletions(-) create mode 100644 frontend/tests/svgImportF8.test.ts diff --git a/frontend/src/components/BlockLibraryTree.tsx b/frontend/src/components/BlockLibraryTree.tsx index 72d600d..8baafa7 100644 --- a/frontend/src/components/BlockLibraryTree.tsx +++ b/frontend/src/components/BlockLibraryTree.tsx @@ -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 = ({ 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 = ({ 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]); diff --git a/frontend/src/services/importService.ts b/frontend/src/services/importService.ts index 98ca2ab..ab9f93a 100644 --- a/frontend/src/services/importService.ts +++ b/frontend/src/services/importService.ts @@ -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'); - 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) }, - }); + 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: '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 ) 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. */ diff --git a/frontend/tests/svgImportF8.test.ts b/frontend/tests/svgImportF8.test.ts new file mode 100644 index 0000000..d34ba3e --- /dev/null +++ b/frontend/tests/svgImportF8.test.ts @@ -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(''); + 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(''); + 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(''); + 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(''); + 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(''); + 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(''); + 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(''); + 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(''); + 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(''); + 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(''); + 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(' { + const payload = importSVGAsBlockPayload(''); + expect(payload.blockData).toBeNull(); + expect(payload.svg).toContain('