231 lines
8.6 KiB
TypeScript
231 lines
8.6 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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');
|
|||
|
|
});
|
|||
|
|
});
|