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:
@@ -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]);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user