Compare commits

..

2 Commits

4 changed files with 404 additions and 19 deletions
+4 -3
View File
@@ -628,7 +628,7 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
- [x] F5 DXF Parser erweitern: ELLIPSE, SPLINE(grad≤3→Polyline approx), MTEXT, POINT, HATCH(basic solid/lines), ATTDEF/ATTR, INSERT→Blockinstanz; Fehlerreport (Liste skipped entities) *(2026-08-29: alle 6 Typen + MINSERT-Arrays + skippedEntities-Report; HATCH defensiv mapping-fähig da dxf-parser 1.1.x echte HATCH-Entities noch nicht durchreicht; Log F5)* - [x] F5 DXF Parser erweitern: ELLIPSE, SPLINE(grad≤3→Polyline approx), MTEXT, POINT, HATCH(basic solid/lines), ATTDEF/ATTR, INSERT→Blockinstanz; Fehlerreport (Liste skipped entities) *(2026-08-29: alle 6 Typen + MINSERT-Arrays + skippedEntities-Report; HATCH defensiv mapping-fähig da dxf-parser 1.1.x echte HATCH-Entities noch nicht durchreicht; Log F5)*
- [x] F6 DXF Writer erweitern: BLOCKS+INSERTS, ATTR; Version AC1009 (R12) + AC1015 Ziel *(2026-08-29: BLOCKS-Sektion + INSERT Name-Mapping/Sanitizing, ATTDEF in Blockdefinitionen, ATTRIB/SEQEND bei attrValues, AC1009-Default/AC1015-Option, R12-korrektes POLYLINE+VERTEX+SEQEND; Roundtrip-Tests deckten 3 latente Konventions-Bugs auf und fixten sie; Log F6)* - [x] F6 DXF Writer erweitern: BLOCKS+INSERTS, ATTR; Version AC1009 (R12) + AC1015 Ziel *(2026-08-29: BLOCKS-Sektion + INSERT Name-Mapping/Sanitizing, ATTDEF in Blockdefinitionen, ATTRIB/SEQEND bei attrValues, AC1009-Default/AC1015-Option, R12-korrektes POLYLINE+VERTEX+SEQEND; Roundtrip-Tests deckten 3 latente Konventions-Bugs auf und fixten sie; Log F6)*
- [x] F7 .wcadlib Paket (zip: manifest.json, blocks.json, thumbs/) Import+Export (Backend endpoint + UI Dialog) *(2026-08-29: jszip export-zip/import-zip Endpoints + Fastify ContentType-Parser für application/zip, Frontend-ZIP-Buttons; Log F7)* - [x] F7 .wcadlib Paket (zip: manifest.json, blocks.json, thumbs/) Import+Export (Backend endpoint + UI Dialog) *(2026-08-29: jszip export-zip/import-zip Endpoints + Fastify ContentType-Parser für application/zip, Frontend-ZIP-Buttons; Log F7)*
- [ ] F8 SVG-Import als Block (svg→Path→Polyline approximation, simple subset: path/rect/circle/ellipse/line/polygon) - [x] F8 SVG-Import als Block (svg→Path→Polyline approximation, simple subset: path/rect/circle/ellipse/line/polygon) *(2026-08-29: voller Path-Parser M/L/H/V/C/S/Q/T/A/Z mit Bézier-Flattening + Arc-Endpoint→Center-Konvertierung, rect/circle-Konventions-Fixes, ellipse→64-Punkt-Polygon, BlockLibraryTree nutzt echte Geometrie-Payload; Log F8)*
## Phase G — Layouts & Plot ## Phase G — Layouts & Plot
- [ ] G1 Datenmodell layouts (Yjs Map: layoutId→{name, viewport{center,scale}, frameBlockRef}), Backend persistence folgt automatisch via drawing json - [ ] G1 Datenmodell layouts (Yjs Map: layoutId→{name, viewport{center,scale}, frameBlockRef}), Backend persistence folgt automatisch via drawing json
@@ -667,6 +667,7 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen | | Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 2026-08-29 | F8 | SVG-Import als Block: (1) parsePathData NEU — Tokenizer für d-Attribut mit allen Befehlen M/L/H/V/C/S/Q/T/A/Z (absolut+relativ), implizite Befehlswiederholung, M→L-Fortsetzung; kubische/quadratische Bézier geflattet (16/12 Segmente), Arc über Endpoint→Center-Parametrisierung (SVG Spec F.6.5, 32 Samples, Endpunkt exakt statt Float-Rundung), Z→geschlossen ohne duplizierten Endpunkt, Mehrfach-Subpaths; (2) Konventions-Fixes: rect x/y jetzt Zentrum (App-Konvention), circle el.x/el.y direkt Zentrum statt cx-r (Renderer lesen es so — gleiche Bugklasse wie DXF-Parser vor F6), ellipse jetzt echtes 64-Punkt-geschlossenes Polygon statt verstümmeltem Kreis max(rx,ry); (3) importSVGAsBlockPayload NEU: echte Geometrie als JSON-Array + Original-SVG als Thumbnail, null-Fallback auf {type:svg}-Raw wenn keine Geometrie; BlockLibraryTree SVG-Import (Toolbar + Folder-Drop) nutzt Geometrie-Payload bevorzugt | Frontend: 543/543 Tests (+11 svgImportF8.test.ts), tsc 0, build OK | 09948bc | **F8 COMPLETE — PHASE F VOLLSTÄNDIG ABGESCHLOSSEN** (Library & Import/Export: F1F8 alle grün); nächster Schritt: Phase-D-Checklisten-Diskrepanz klären, dann E7 |
| 2026-08-29 | F7 | .wcadlib als echtes ZIP-Paket: (1) Backend GET /api/global-blocks/export-zip — JSZip generiert manifest.json (format wcadlib-zip v1, exported_at, counts), blocks.json (Ordner + Blöcke mit thumbnail-Referenz) und thumbs/<blockId>.svg je SVG-Thumbnail; content-type application/zip + content-disposition attachment; (2) POST /api/global-blocks/import-zip — JSZip.loadAsync, Validierung (kein ZIP → 400, blocks.json fehlt → 400, invalides JSON → 400, manifest.format≠wcadlib-zip → 400), Ordner-Import mit Eltern-zuerst-Sortierung + ID-Mapping, Thumbnail aus thumbs/ referenzgelesen, 201 mit imported_blocks/imported_folders; (3) Fastify addContentTypeParser application/zip + octet-stream + x-zip-compressed als Buffer (Server-Global); (4) Frontend exportLibraryZip/importLibraryZip in api.ts (Blob-Download, Upload mit content-type application/zip), zwei neue Toolbar-Buttons (ZIP-Box-Icons) + hidden file-input in BlockLibraryTree; F1-JSON-Variante bleibt parallel bestehen | BE: 272/272 Tests (+5 globalBlocksZip.test.ts), BE tsc 0; FE: 532/532, FE tsc 0, build OK | d311820 | F7 COMPLETE — ZIP-Roundtrip mit echten write→read-Roundtrip-Tests (Export→Import→DB-Verifikation) | | 2026-08-29 | F7 | .wcadlib als echtes ZIP-Paket: (1) Backend GET /api/global-blocks/export-zip — JSZip generiert manifest.json (format wcadlib-zip v1, exported_at, counts), blocks.json (Ordner + Blöcke mit thumbnail-Referenz) und thumbs/<blockId>.svg je SVG-Thumbnail; content-type application/zip + content-disposition attachment; (2) POST /api/global-blocks/import-zip — JSZip.loadAsync, Validierung (kein ZIP → 400, blocks.json fehlt → 400, invalides JSON → 400, manifest.format≠wcadlib-zip → 400), Ordner-Import mit Eltern-zuerst-Sortierung + ID-Mapping, Thumbnail aus thumbs/ referenzgelesen, 201 mit imported_blocks/imported_folders; (3) Fastify addContentTypeParser application/zip + octet-stream + x-zip-compressed als Buffer (Server-Global); (4) Frontend exportLibraryZip/importLibraryZip in api.ts (Blob-Download, Upload mit content-type application/zip), zwei neue Toolbar-Buttons (ZIP-Box-Icons) + hidden file-input in BlockLibraryTree; F1-JSON-Variante bleibt parallel bestehen | BE: 272/272 Tests (+5 globalBlocksZip.test.ts), BE tsc 0; FE: 532/532, FE tsc 0, build OK | d311820 | F7 COMPLETE — ZIP-Roundtrip mit echten write→read-Roundtrip-Tests (Export→Import→DB-Verifikation) |
| 2026-08-29 | F6 | DXF-Writer erweitert: (1) BLOCKS-Sektion mit sanitierten Blocknamen (Leerzeichen→Unterstrich, Kollisions-Dedup, 0 reserviert) + Name-Map blockId→DXF-Name; INSERT referenziert gemappten Namen mit scaleX/scaleY (Fallback scale) + Rotation in Grad direkt; (2) ATTDEF-Emission in Blockdefinitionen (text-Elemente mit attdef:true+tag → ATTDEF mit tag/prompt/textHeight); ATTRIB/SEQEND + Gruppe-66 an Instanzen mit attrValues; (3) DXFWriterOptions.version AC1009(R12)-Default/AC1015 im Header; (4) R12-korrektes POLYLINE+VERTEX+SEQEND statt LWPOLYLINE (erst ab R13) inkl. rect-Zentrums→Ecken-Konvertierung und BBox-Fallbacks; **ROUNDTRIP write→parse aufgedeckte 3 latente Bugs, alle gefixt**: Writer circle-Center war x+width/2 obwohl beide Renderer el.x/el.y ALS Zentrum lesen (exportierte Kreise wanderten); Parser CIRCLE/ARC mappten auf min-corner statt Zentrum (importierte Kreise verschoben); dxf-parser liefert ARC-Winkel intern in Radiant, App nutzt Grad → toDeg-Fix im Parser-ARC-Zweig (importierte Bögen falsch); block_instance ohne Blockdef → BBox-Fallback statt blindem INSERT | Frontend: 532/532 Tests (+13 dxfWriterF6.test.ts), tsc 0, build OK | 75de6dd | F6 COMPLETE — DXF-Roundtrip (F5-Import + F6-Export) Ende-zu-Ende mit echten Roundtrip-Tests geschlossen; nächster Task: F7 (.wcadlib ZIP-Paket) | | 2026-08-29 | F6 | DXF-Writer erweitert: (1) BLOCKS-Sektion mit sanitierten Blocknamen (Leerzeichen→Unterstrich, Kollisions-Dedup, 0 reserviert) + Name-Map blockId→DXF-Name; INSERT referenziert gemappten Namen mit scaleX/scaleY (Fallback scale) + Rotation in Grad direkt; (2) ATTDEF-Emission in Blockdefinitionen (text-Elemente mit attdef:true+tag → ATTDEF mit tag/prompt/textHeight); ATTRIB/SEQEND + Gruppe-66 an Instanzen mit attrValues; (3) DXFWriterOptions.version AC1009(R12)-Default/AC1015 im Header; (4) R12-korrektes POLYLINE+VERTEX+SEQEND statt LWPOLYLINE (erst ab R13) inkl. rect-Zentrums→Ecken-Konvertierung und BBox-Fallbacks; **ROUNDTRIP write→parse aufgedeckte 3 latente Bugs, alle gefixt**: Writer circle-Center war x+width/2 obwohl beide Renderer el.x/el.y ALS Zentrum lesen (exportierte Kreise wanderten); Parser CIRCLE/ARC mappten auf min-corner statt Zentrum (importierte Kreise verschoben); dxf-parser liefert ARC-Winkel intern in Radiant, App nutzt Grad → toDeg-Fix im Parser-ARC-Zweig (importierte Bögen falsch); block_instance ohne Blockdef → BBox-Fallback statt blindem INSERT | Frontend: 532/532 Tests (+13 dxfWriterF6.test.ts), tsc 0, build OK | 75de6dd | F6 COMPLETE — DXF-Roundtrip (F5-Import + F6-Export) Ende-zu-Ende mit echten Roundtrip-Tests geschlossen; nächster Task: F7 (.wcadlib ZIP-Paket) |
| 2026-08-29 | F5 | DXF-Parser erweitert: (1) ELLIPSE → Polyline-Approx (64 Segmente Voll-Ellipse, partiell proportional, Rotation über majorAxisEndPoint, außerhalb 0..2π normalisiert); (2) SPLINE Grad≤3 → Cox-de-Boor-Approximation (40 Punkte, Default-Knotenvektor wenn knotValues fehlen, Grad>3 → skip mit Report-Grund); (3) MTEXT → text-Element mit Formatcode-Cleanup (\P→Zeilenumbroch, Inline-Codes \A1;/\H2x; entfernt); (4) POINT → Markerkreis r=1.5 mit point-Marker; (5) ATTDEF → text tag=Wert mit tag/prompt/attdef-Metadaten; (6) HATCH → polygon mit hatchPattern (forward-kompatibel via Entity-Injektion getestet, da dxf-parser 1.1.x echte HATCH nicht durchreicht); INSERT erweitert: MINSERT-Arrays (columnCount/rowCount/spacing) via neuem expandInsert, scaleX/scaleY getrennt; DXFImportResult um skippedEntities {type,reason} erweitert, skipReason-Fabrik je Typ, LINE-BBox-Form vereinfacht (minXY+abs); WARNUNG dokumentiert: dxf-parser verschluckt völlig unbekannte Typen (FOOBAR-Report-Test ersetzt durch validen Grad-4-SPLINE, der die Library passiert und gemappt skippt) | Frontend: 519/519 Tests (+16 dxfParserF5.test.ts), tsc 0, build OK | 6857694 | F5 COMPLETE; Zwischenfall: erster Suite-Lauf hing in dxf-parser-Endlosschleife bei malformed LINE-Test-STRING (nicht unserem Code) — Test auf validen SPLINE-Grad-4-Fall umgestellt, Escape-Fehler in Testdatei selbst erkannt und repariert; nächster Task: F6 (DXF Writer erweitern) | | 2026-08-29 | F5 | DXF-Parser erweitert: (1) ELLIPSE → Polyline-Approx (64 Segmente Voll-Ellipse, partiell proportional, Rotation über majorAxisEndPoint, außerhalb 0..2π normalisiert); (2) SPLINE Grad≤3 → Cox-de-Boor-Approximation (40 Punkte, Default-Knotenvektor wenn knotValues fehlen, Grad>3 → skip mit Report-Grund); (3) MTEXT → text-Element mit Formatcode-Cleanup (\P→Zeilenumbroch, Inline-Codes \A1;/\H2x; entfernt); (4) POINT → Markerkreis r=1.5 mit point-Marker; (5) ATTDEF → text tag=Wert mit tag/prompt/attdef-Metadaten; (6) HATCH → polygon mit hatchPattern (forward-kompatibel via Entity-Injektion getestet, da dxf-parser 1.1.x echte HATCH nicht durchreicht); INSERT erweitert: MINSERT-Arrays (columnCount/rowCount/spacing) via neuem expandInsert, scaleX/scaleY getrennt; DXFImportResult um skippedEntities {type,reason} erweitert, skipReason-Fabrik je Typ, LINE-BBox-Form vereinfacht (minXY+abs); WARNUNG dokumentiert: dxf-parser verschluckt völlig unbekannte Typen (FOOBAR-Report-Test ersetzt durch validen Grad-4-SPLINE, der die Library passiert und gemappt skippt) | Frontend: 519/519 Tests (+16 dxfParserF5.test.ts), tsc 0, build OK | 6857694 | F5 COMPLETE; Zwischenfall: erster Suite-Lauf hing in dxf-parser-Endlosschleife bei malformed LINE-Test-STRING (nicht unserem Code) — Test auf validen SPLINE-Grad-4-Fall umgestellt, Escape-Fehler in Testdatei selbst erkannt und repariert; nächster Task: F6 (DXF Writer erweitern) |
@@ -729,5 +730,5 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2) - `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
--- ---
*Nächster offener Schritt: **F8** (SVG-Import als Block: svg→Path→Polyline-Approximation, simples Subset path/rect/circle/ellipse/line/polygon). Danach: Phase-D-Diskrepanz klären, E7, Phase G/H/I.* *Nächster offener Schritt: **Phase-D-Diskrepanz klären** (ungehakte D-Items gegen Code verifizieren: D5/D6/D7/D8/D10 abhaken falls implementiert; D1/D2/D3/D4/D9/D11/D12 ggf. nachziehen), dann **E7** (Event-Elemente-Polish). Danach: Phase G (Layouts/PDF), H (Modi/i18n), I (Release).*
*Status 2026-08-29: Phasen B+A+C+D+E+F1F7 komplett (DXF-Roundtrip + ZIP-Paket geschlossen), BUG-1-Kollisionsklasse geschlossen. FE 532/532 Tests, BE 272/272, tsc/Build grün. Alle Commits auf Forgejo main.* *Status 2026-08-29: Phasen B+A+C+D+E+F komplett (F1F8: Library, DXF-Roundtrip, ZIP-Paket, SVG-Geometrie-Import), BUG-1-Kollisionsklasse geschlossen. FE 543/543 Tests, BE 272/272, tsc/Build grün. Alle Commits auf Forgejo main.*
+5 -2
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import type { GlobalBlockFolder, GlobalBlock } from '../services/api'; import type { GlobalBlockFolder, GlobalBlock } from '../services/api';
import { importSVGAsBlockPayload } from '../services/importService';
import { import {
getGlobalFolders, getGlobalFolders,
createGlobalFolder, createGlobalFolder,
@@ -305,10 +306,11 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
reader.onload = async (ev) => { reader.onload = async (ev) => {
const svgContent = ev.target?.result as string; const svgContent = ev.target?.result as string;
try { try {
const svgPayload = importSVGAsBlockPayload(svgContent);
const created = await createGlobalBlock(token, { const created = await createGlobalBlock(token, {
name: file.name.replace(/\.svg$/i, ''), name: file.name.replace(/\.svg$/i, ''),
folder_id: folderId, folder_id: folderId,
block_data: JSON.stringify({ type: 'svg', svg: svgContent }), block_data: svgPayload.blockData ?? JSON.stringify({ type: 'svg', svg: svgContent }),
svg_data: svgContent, svg_data: svgContent,
}); });
setBlocks(prev => [...prev, created]); setBlocks(prev => [...prev, created]);
@@ -343,9 +345,10 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
reader.onload = async (ev) => { reader.onload = async (ev) => {
const svgContent = ev.target?.result as string; const svgContent = ev.target?.result as string;
try { try {
const svgPayload = importSVGAsBlockPayload(svgContent);
const created = await createGlobalBlock(token, { const created = await createGlobalBlock(token, {
name: file.name.replace(/\.svg$/i, ''), 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, svg_data: svgContent,
}); });
setBlocks(prev => [...prev, created]); 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. * 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 { export function importSVG(svgString: string): ImportResult {
try { try {
@@ -79,6 +289,12 @@ export function importSVG(svgString: string): ImportResult {
vbY = parts[1] || 0; 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 // Process SVG elements
const processElement = (node: Element) => { const processElement = (node: Element) => {
const tag = node.tagName.toLowerCase(); const tag = node.tagName.toLowerCase();
@@ -101,38 +317,50 @@ export function importSVG(svgString: string): ImportResult {
break; break;
} }
case 'rect': { case 'rect': {
// App-Konvention (Task F8): rect x/y = Zentrum der BBox
const x = parseFloat(node.getAttribute('x') || '0') - vbX; const x = parseFloat(node.getAttribute('x') || '0') - vbX;
const y = parseFloat(node.getAttribute('y') || '0') - vbY; const y = parseFloat(node.getAttribute('y') || '0') - vbY;
const w = parseFloat(node.getAttribute('width') || '0'); const w = parseFloat(node.getAttribute('width') || '0');
const h = parseFloat(node.getAttribute('height') || '0'); const h = parseFloat(node.getAttribute('height') || '0');
elements.push({ elements.push({
id: nextId(), type: 'rect', layerId: 'layer-0', 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 }, properties: { stroke, strokeWidth, fill: fill !== 'none' ? fill : undefined },
}); });
break; break;
} }
case 'circle': { 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 cx = parseFloat(node.getAttribute('cx') || '0') - vbX;
const cy = parseFloat(node.getAttribute('cy') || '0') - vbY; const cy = parseFloat(node.getAttribute('cy') || '0') - vbY;
const r = parseFloat(node.getAttribute('r') || '0'); const r = parseFloat(node.getAttribute('r') || '0');
elements.push({ elements.push({
id: nextId(), type: 'circle', layerId: 'layer-0', id: nextId(), type: 'circle', layerId: 'layer-0',
x: cx - r, y: cy - r, width: r * 2, height: r * 2, x: cx, y: cy, width: r * 2, height: r * 2,
properties: { stroke, strokeWidth, radius: r }, properties: { stroke, strokeWidth, radius: r, fill: fill !== 'none' ? fill : undefined },
}); });
break; break;
} }
case 'ellipse': { 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 cx = parseFloat(node.getAttribute('cx') || '0') - vbX;
const cy = parseFloat(node.getAttribute('cy') || '0') - vbY; const cy = parseFloat(node.getAttribute('cy') || '0') - vbY;
const rx = parseFloat(node.getAttribute('rx') || '0'); const rx = parseFloat(node.getAttribute('rx') || '0');
const ry = parseFloat(node.getAttribute('ry') || '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({ elements.push({
id: nextId(), type: 'circle', layerId: 'layer-0', id: nextId(), type: 'polygon', layerId: 'layer-0',
x: cx - rx, y: cy - ry, width: rx * 2, height: ry * 2, ...bboxOf(pts),
properties: { stroke, strokeWidth, radius: Math.max(rx, ry) }, properties: { stroke, strokeWidth, points: pts, fill: fill !== 'none' ? fill : undefined },
}); });
}
break; break;
} }
case 'polyline': case 'polyline':
@@ -144,14 +372,26 @@ export function importSVG(svgString: string): ImportResult {
return acc; return acc;
}, []); }, []);
if (pts.length >= 2) { 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({ elements.push({
id: nextId(), type: tag === 'polygon' ? 'polygon' : 'polyline', layerId: 'layer-0', id: nextId(), type: tag === 'polygon' ? 'polygon' : 'polyline', layerId: 'layer-0',
x: minX, y: minY, width: maxX - minX, height: maxY - minY, ...bboxOf(pts),
properties: { stroke, strokeWidth, points: 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; 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. * 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');
});
});