feat: add DXF (R12) and SVG exporters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b142c4f766
commit
8d597e856e
4 files changed
+119
No files matched your search
@@ -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';
|
||||
}
|
||||
@@ -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 [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}mm" height="${h}mm" viewBox="0 0 ${w} ${h}">`,
|
||||
` <path d="${d}" fill="none" fill-rule="evenodd" stroke="#ff0000" stroke-width="0.1"/>`,
|
||||
'</svg>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function ringPath(ring: Point[]): string {
|
||||
return 'M' + ring.map((p) => `${formatMm(p.x)},${formatMm(p.y)}`).join(' L') + ' Z';
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user