diff --git a/src/lib/export-dxf.ts b/src/lib/export-dxf.ts new file mode 100644 index 0000000..6efdc9e --- /dev/null +++ b/src/lib/export-dxf.ts @@ -0,0 +1,24 @@ +import { formatMm, shapeSize } from './geometry'; +import type { Shape } from './types'; + +/** Minimal ASCII DXF R12: one closed POLYLINE per ring, units mm, Y axis up. */ +export function toDxf(shapeMm: Shape): string { + const { height } = shapeSize(shapeMm); + const out: (string | number)[] = [ + 0, 'SECTION', 2, 'HEADER', + 9, '$ACADVER', 1, 'AC1009', + 9, '$INSUNITS', 70, 4, + 0, 'ENDSEC', + 0, 'SECTION', 2, 'ENTITIES', + ]; + for (const ring of [shapeMm.outer, ...shapeMm.holes]) { + out.push(0, 'POLYLINE', 8, '0', 66, 1, 70, 1, 10, '0.0', 20, '0.0', 30, '0.0'); + for (const p of ring) { + out.push(0, 'VERTEX', 8, '0', 10, formatMm(p.x), 20, formatMm(height - p.y), 30, '0.0'); + } + out.push(0, 'SEQEND', 8, '0'); + } + out.push(0, 'ENDSEC', 0, 'EOF'); + // RDWorks is a Windows tool; CRLF is the most widely accepted DXF line ending. + return out.join('\r\n') + '\r\n'; +} diff --git a/src/lib/export-svg.ts b/src/lib/export-svg.ts new file mode 100644 index 0000000..036afa3 --- /dev/null +++ b/src/lib/export-svg.ts @@ -0,0 +1,20 @@ +import { formatMm, shapeSize } from './geometry'; +import type { Point, Shape } from './types'; + +export function toSvg(shapeMm: Shape): string { + const { width, height } = shapeSize(shapeMm); + const w = formatMm(width); + const h = formatMm(height); + const d = [shapeMm.outer, ...shapeMm.holes].map(ringPath).join(' '); + return [ + '', + ``, + ` `, + '', + '', + ].join('\n'); +} + +function ringPath(ring: Point[]): string { + return 'M' + ring.map((p) => `${formatMm(p.x)},${formatMm(p.y)}`).join(' L') + ' Z'; +} diff --git a/tests/export-dxf.test.ts b/tests/export-dxf.test.ts new file mode 100644 index 0000000..b661773 --- /dev/null +++ b/tests/export-dxf.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { toDxf } from '../src/lib/export-dxf'; + +const shape = { + outer: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 50 }, { x: 0, y: 50 }], + holes: [[{ x: 10, y: 10 }, { x: 20, y: 10 }, { x: 20, y: 20 }]], +}; + +/** Split DXF text into [groupCode, value] pairs. */ +function pairs(dxf: string): [string, string][] { + const lines = dxf.split('\r\n'); + if (lines[lines.length - 1] === '') lines.pop(); + const out: [string, string][] = []; + for (let i = 0; i < lines.length; i += 2) out.push([lines[i].trim(), lines[i + 1]]); + return out; +} + +describe('toDxf', () => { + it('is a well-formed R12 file in mm with CRLF line endings', () => { + const dxf = toDxf(shape); + expect(dxf).not.toMatch(/[^\r]\n/); + const p = pairs(dxf); + expect(p[0]).toEqual(['0', 'SECTION']); + expect(p).toContainEqual(['1', 'AC1009']); + const unitsIdx = p.findIndex(([c, v]) => c === '9' && v === '$INSUNITS'); + expect(p[unitsIdx + 1]).toEqual(['70', '4']); + expect(p[p.length - 1]).toEqual(['0', 'EOF']); + }); + + it('writes one closed POLYLINE per ring with all vertices', () => { + const p = pairs(toDxf(shape)); + const entities = p.filter(([c]) => c === '0').map(([, v]) => v); + expect(entities.filter((e) => e === 'POLYLINE')).toHaveLength(2); + expect(entities.filter((e) => e === 'VERTEX')).toHaveLength(7); + expect(entities.filter((e) => e === 'SEQEND')).toHaveLength(2); + const polyIdx = p.findIndex(([c, v]) => c === '0' && v === 'POLYLINE'); + const flags = p.slice(polyIdx, polyIdx + 8).find(([c]) => c === '70'); + expect(flags).toEqual(['70', '1']); + }); + + it('flips Y so the piece is not mirrored', () => { + const p = pairs(toDxf(shape)); + const firstVertex = p.findIndex(([c, v]) => c === '0' && v === 'VERTEX'); + const x = p.slice(firstVertex).find(([c]) => c === '10')![1]; + const y = p.slice(firstVertex).find(([c]) => c === '20')![1]; + expect([x, y]).toEqual(['0', '50']); // (0,0) top-left → (0,50) in Y-up space + }); +}); diff --git a/tests/export-svg.test.ts b/tests/export-svg.test.ts new file mode 100644 index 0000000..937d343 --- /dev/null +++ b/tests/export-svg.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { toSvg } from '../src/lib/export-svg'; + +const shape = { + outer: [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 50 }, { x: 0, y: 50 }], + holes: [[{ x: 10, y: 10 }, { x: 20, y: 10 }, { x: 20, y: 20.5 }]], +}; + +describe('toSvg', () => { + it('declares its size in mm with a matching viewBox', () => { + const svg = toSvg(shape); + expect(svg).toContain('width="100mm"'); + expect(svg).toContain('height="50mm"'); + expect(svg).toContain('viewBox="0 0 100 50"'); + }); + + it('writes one closed subpath per ring in a single evenodd path', () => { + const svg = toSvg(shape); + const d = svg.match(/ d="([^"]+)"/)![1]; + expect(d.match(/M/g)).toHaveLength(2); + expect(d.match(/Z/g)).toHaveLength(2); + expect(d).toContain('M0,0 L100,0 L100,50 L0,50 Z'); + expect(d).toContain('20,20.5'); + expect(svg).toContain('fill-rule="evenodd"'); + expect(svg).toContain('fill="none"'); + }); +});