/** * Task F5 – DXF-Parser-Erweiterungstests. * * Neue Entity-Abdeckung: ELLIPSE (als Polyline-Approx), SPLINE (grad<=3 → * Polyline via de-Boor), MTEXT (Formatcodes gecleanup), POINT, ATTDEF, * INSERT mit MINSERT-Arrays und HATCH (forward-kompatibel_via Fake-Entity). * Plus strukturierter Fehlerreport (skippedEntities) neben warnings. */ import { describe, it, expect } from 'vitest'; import { parseDXF, entityToCADElement, expandInsert } from '../src/services/dxfParser'; import type { CADLayer } from '../src/types/cad.types'; const layer0: CADLayer = { id: 'dxf-layer-0', name: '0', visible: true, locked: false, color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null, }; const layers = new Map([['0', layer0]]); function dxfOf(entityBody: string): string { return `0\nSECTION\n2\nENTITIES\n${entityBody}0\nENDSEC\n0\nEOF\n`; } function ptsOf(el: { properties: { points?: Array<{ x: number; y: number }> } }): Array<{ x: number; y: number }> { return el.properties.points ?? []; } describe('F5: ELLIPSE', () => { it('volle Ellipse → geschlossene Polyline mit 64 Punkten und korrekter BBox', () => { const el = entityToCADElement({ type: 'ELLIPSE', layer: '0', center: { x: 0, y: 0 }, majorAxisEndPoint: { x: 100, y: 0 }, axisRatio: 0.5, startAngle: 0, endAngle: Math.PI * 2, } as any, layers); expect(el).not.toBeNull(); expect(el!.type).toBe('polyline'); const pts = ptsOf(el!); expect(pts.length).toBe(64); const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y); expect(Math.max(...xs)).toBeCloseTo(100, 0); expect(Math.min(...xs)).toBeCloseTo(-100, 0); expect(Math.max(...ys)).toBeCloseTo(50, 0); expect(Math.min(...ys)).toBeCloseTo(-50, 0); }); it('rotierte Ellipse respektiert majorAxisEndPoint-Winkel', () => { const el = entityToCADElement({ type: 'ELLIPSE', layer: '0', center: { x: 0, y: 0 }, majorAxisEndPoint: { x: 0, y: 100 }, // 90° rotiert axisRatio: 0.5, startAngle: 0, endAngle: Math.PI * 2, } as any, layers); const pts = ptsOf(el!); expect(Math.max(...pts.map((p) => p.y))).toBeCloseTo(100, 0); expect(Math.max(...pts.map((p) => p.x))).toBeCloseTo(50, 0); }); it('partielle Ellipse → offene Polyline', () => { const el = entityToCADElement({ type: 'ELLIPSE', layer: '0', center: { x: 0, y: 0 }, majorAxisEndPoint: { x: 100, y: 0 }, axisRatio: 1, startAngle: 0, endAngle: Math.PI, } as any, layers); const pts = ptsOf(el!); expect(pts.length).toBe(32); }); it('Integration über parseDXF mit echtem ELLIPSE-DXF-String', () => { const res = parseDXF(dxfOf( '0\nELLIPSE\n8\n0\n10\n0\n20\n0\n30\n0\n11\n100\n21\n0\n31\n0\n41\n0.5\n42\n0\n51\n6.283185307179586\n', )); expect(res.skippedEntities).toHaveLength(0); const el = res.elements.find((e) => e.type === 'polyline'); expect(el).toBeDefined(); expect(ptsOf(el as any).length).toBeGreaterThan(32); }); }); describe('F5: SPLINE', () => { const controlPoints = [ { x: 0, y: 0 }, { x: 33, y: 200 }, { x: 66, y: -100 }, { x: 100, y: 0 }, ]; it('Grad 3 → Polyline-Approx mit mehr Punkten als Kontrollpunkte', () => { const el = entityToCADElement({ type: 'SPLINE', layer: '0', degreeOfSplineCurve: 3, controlPoints, fitPoints: [], } as any, layers); expect(el).not.toBeNull(); expect(el!.type).toBe('polyline'); const pts = ptsOf(el!); expect(pts.length).toBeGreaterThan(16); // Approximation startet/endet bei exakten Kontrollpunkten expect(pts[0].x).toBeCloseTo(0, 3); expect(pts[0].y).toBeCloseTo(0, 3); expect(pts[pts.length - 1].x).toBeCloseTo(100, 3); }); it('Grad 4 → skipped mit Grund', () => { const el = entityToCADElement({ type: 'SPLINE', layer: '0', degreeOfSplineCurve: 4, controlPoints, fitPoints: [], } as any, layers); expect(el).toBeNull(); }); it('ohne Kontrollpunkte → skipped', () => { const el = entityToCADElement({ type: 'SPLINE', layer: '0', degreeOfSplineCurve: 3 } as any, layers); expect(el).toBeNull(); }); }); describe('F5: MTEXT', () => { it('mappt auf text-Element, \\P → Zeilenumbruch, Formatcodes entfernt', () => { const el = entityToCADElement({ type: 'MTEXT', layer: '0', position: { x: 10, y: 20 }, height: 15, width: 200, text: 'Zeile1\\PZeile2\\A1;\\H2x;Titel', } as any, layers); expect(el).not.toBeNull(); expect(el!.type).toBe('text'); expect(el!.properties.text).toBe('Zeile1\nZeile2Titel'); expect(el!.properties.fontSize).toBe(15); }); }); describe('F5: POINT', () => { it('mappt auf kleinen gefüllten Kreis mit point-Marker', () => { const el = entityToCADElement({ type: 'POINT', layer: '0', position: { x: 55, y: 66 }, } as any, layers); expect(el).not.toBeNull(); expect(el!.type).toBe('circle'); expect(el!.x).toBeCloseTo(55 - 1.5, 5); expect(el!.properties.radius).toBeCloseTo(1.5, 5); expect(el!.properties.point).toBe(true); }); }); describe('F5: ATTDEF', () => { it('mappt auf text-Element mit tag=Wert und Metadaten', () => { const el = entityToCADElement({ type: 'ATTDEF', layer: '0', tag: 'NAME', text: 'Buehne', prompt: 'Name der Buehne', startPoint: { x: 5, y: 5 }, textHeight: 12, } as any, layers); expect(el).not.toBeNull(); expect(el!.type).toBe('text'); expect(el!.properties.text).toBe('NAME=Buehne'); expect(el!.properties.fontSize).toBe(12); expect(el!.properties.tag).toBe('NAME'); expect(el!.properties.prompt).toBe('Name der Buehne'); }); it('leerer ATTDEF-Text ohne Tag → skipped', () => { const el = entityToCADElement({ type: 'ATTDEF', layer: '0', startPoint: { x: 0, y: 0 } } as any, layers); expect(el).toBeNull(); }); }); describe('F5: INSERT (MINSERT-Arrays)', () => { it('columnCount/rowCount > 1 erzeugt n-fache Instanzen mit Spacing-Offset', () => { const els = [] as any[]; // Der Parser-Einstieg ist parseDXF; INSERT-Expansion passiert dort je Entity — hier via Helper const base = { type: 'INSERT', layer: '0', name: 'stuhl', position: { x: 0, y: 0 }, xScale: 2, yScale: 3, rotation: 0, columnCount: 3, rowCount: 2, columnSpacing: 100, rowSpacing: 50, } as any; const out = expandInsert(base); expect(out).toHaveLength(6); const uniqXs = [...new Set(out.map((e) => e.x))].sort((a, b) => a - b); expect(uniqXs).toEqual([0, 100, 200]); const ys = [...new Set(out.map((e) => e.y))].sort((a, b) => a - b); expect(ys).toEqual([0, 50]); expect(out.every((e) => e.properties.scaleX === 2 && e.properties.scaleY === 3)).toBe(true); }); it('einfacher INSERT bleibt einzelne Instanz mit kombiniertem scale', () => { const out = expandInsert({ type: 'INSERT', layer: '0', name: 'b', position: { x: 5, y: 5 }, xScale: 1, yScale: 1, rotation: 90, } as any); expect(out).toHaveLength(1); expect(out[0].properties.blockId).toBe('b'); expect(out[0].properties.rotation).toBe(90); }); }); describe('F5: HATCH (forward-kompatibel_via Entity-Injektion)', () => { it('HATCH solid → polygon mit hatchPattern', () => { const el = entityToCADElement({ type: 'HATCH', layer: '0', patternName: 'SOLID', isSolid: true, boundaryPoints: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }], } as any, layers); expect(el).not.toBeNull(); expect(el!.type).toBe('polygon'); expect(el!.properties.hatchPattern).toBe('SOLID'); expect(ptsOf(el as any)).toHaveLength(4); }); }); describe('F5: Fehlerreport (skippedEntities)', () => { // Hinweis: dxf-parser 1.1.x verschluckt voellig unbekannte Typen (FOOBAR) schlicht; // valide SPLINE-Entities mit Grad > 3 kommen dagegen durch und werden vom // Mapper mit Grund uebersprungen — genau dafuer ist der Report da. const DEG4_SPLINE = '0\nSPLINE\n8\n0\n71\n4\n11\n0\n21\n0\n11\n10\n21\n0\n11\n20\n21\n0\n11\n30\n21\n0\n'; it('unbrauchbare Entities (SPLINE Grad 4) landen im Report', () => { const res = parseDXF(dxfOf(DEG4_SPLINE)); const types = res.skippedEntities.map((s) => s.type); expect(types).toContain('SPLINE'); expect(res.warnings.length).toBeGreaterThan(0); expect(res.skippedEntities[0].reason).toContain('> 3'); }); it('Result enthält skippedEntities-Feld mit type+reason (Design-Vertrag)', () => { const res = parseDXF(dxfOf(DEG4_SPLINE)); expect(res.skippedEntities).toHaveLength(1); expect(res.skippedEntities[0].type).toBe('SPLINE'); expect(typeof res.skippedEntities[0].reason).toBe('string'); }); });