add dxf import

This commit is contained in:
Stewart Allen 2026-03-20 12:11:34 -04:00
commit 3aa8b77e77
5 changed files with 418 additions and 5 deletions

View file

@ -126,7 +126,7 @@ function content(actions) {
menuItem(actions, { id: 'file-recent', lk: 'recent', text: 'recent', iconClass: 'fas fa-list' }),
menuItem(actions, {
id: 'file-import', lk: 'import', text: 'import', iconClass: 'fas fa-file-upload', children:
input({ id: 'load-file', type: 'file', name: 'loadme', style: 'display:none', accept: '.km,.kmz,.stl,.obj,.svg,.png,.jpg,.jpeg,.gcode,.nc' })
input({ id: 'load-file', type: 'file', name: 'loadme', style: 'display:none', accept: '.km,.kmz,.stl,.obj,.svg,.dxf,.png,.jpg,.jpeg,.gcode,.nc' })
}),
hr(),
menuItem(actions, { id: 'mesh-export-obj', lk: 'export-obj', text: 'save as OBJ', iconClass: 'fas fa-dice-d20' }),

View file

@ -979,6 +979,7 @@ function load_files(files, group) {
isobj = lower.endsWith(".obj"),
is3mf = lower.endsWith(".3mf"),
issvg = lower.endsWith(".svg"),
isdxf = lower.endsWith(".dxf"),
ispng = lower.endsWith(".png"),
isjpg = lower.endsWith(".jpg"),
iskmz = lower.endsWith(".kmz"),
@ -1067,7 +1068,7 @@ function load_files(files, group) {
api.function.parse(data.textDecode('utf-8'), 'gcode');
load_dec();
} else if (issvg) {
loadSVGDialog(opt => {
loadSVGDialog(opt => {
group = group || [];
let svg = file_load.SVG.parse(data.textDecode('utf-8'), opt);
let ind = 0;
@ -1080,6 +1081,19 @@ function load_files(files, group) {
}
load_dec();
});
} else if (isdxf) {
loadDXFDialog(opt => {
group = group || [];
let dxf = file_load.DXF.parse(data.textDecode('utf-8'), opt);
let ind = 0;
if (dxf.length === 0) {
api.show.alert(`DXF contains no supported entities`, 10);
}
for (let v of dxf) {
load_verts(group, dxf[ind++], ind ? `${name}-${ind}` : name);
}
load_dec();
});
}
else if (iskmz) api.settings.import_zip(data, true);
else if (isset) api.settings.import(data.textDecode('utf-8'), true);
@ -1125,6 +1139,37 @@ function loadSVGDialog(doit) {
});
}
/**
* Show dialog to configure DXF import settings.
* Prompts for extrusion depth, arc segment size, and nesting.
* @param {Function} doit - Callback with options: {soup, depth, segmentSize, minSegments}
* @private
*/
function loadDXFDialog(doit) {
const opt = {pre: [
"<div class='f-col a-center'>",
" <h3>Import DXF</h3>",
" <p class='t-just' style='width:300px;line-height:1.5em'>",
" Extrude a 3D model from a 2D DXF.",
" Supports POLYLINE, LWPOLYLINE, LINE, CIRCLE, and ARC entities.",
" </p>",
" <div class='f-row t-right'><table>",
" <tr><th>z height in mm</th><td><input id='dxf-depth' value='5' size='3'></td></tr>",
" <tr><th title='target length of each line segment when converting arcs and circles'>arc segment size in mm</th><td><input id='dxf-seg' value='1' size='3'></td></tr>",
" <tr><th title='minimum number of segments for very small arcs to avoid degenerate geometry'>minimum arc segments</th><td><input id='dxf-min' value='4' size='3'></td></tr>",
" <tr><th>nest shapes</th><td><input id='dxf-nest' value='1' type='checkbox' checked></td></tr>",
" </table></div>",
"</div>"
]};
api.uc.confirm(undefined, {convert:true, cancel:false}, undefined, opt).then((ok) => {
let depth = Math.max(0.1, parseFloat($('dxf-depth').value));
let segmentSize = Math.max(0.01, parseFloat($('dxf-seg').value));
let minSegments = Math.max(3, parseInt($('dxf-min').value));
let soup = $('dxf-nest').checked;
ok && doit({ soup, depth, segmentSize, minSegments });
});
}
/**
* Expand platform bed depth to fit widgets (belt mode only).
* Finds maximum Y dimension of all widgets and expands bed if needed.

359
src/load/dxf.js Normal file
View file

@ -0,0 +1,359 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { newPolygon } from '../geo/polygon.js';
import { newPoint } from '../geo/point.js';
import { polygons } from '../geo/polygons.js';
export function parseAsync(text, opt) {
return new Promise((resolve, reject) => {
try {
resolve(parse(text, opt));
} catch (e) {
reject(e);
}
});
}
export function parse(text, opt = { }) {
const justPoly = opt.flat || false;
const fromSoup = opt.soup !== false || justPoly;
const depth = parseFloat(opt.depth || 5);
const segmentSize = parseFloat(opt.segmentSize || 1); // default 1mm segments
const minSegments = parseInt(opt.minSegments || 4); // minimum segments for very small arcs
const objs = [];
const polys = [];
// Parse DXF file - normalize line endings and split
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').map(l => l.trim());
const entities = extractEntities(lines);
// Convert entities to polygons
for (let entity of entities) {
if (entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') {
if (entity.points.length < 2) {
continue;
}
let poly = newPolygon().addPoints(
entity.points.map(p => newPoint(p.x, p.y, p.z || 0))
).clean();
// Check if closed
if (entity.closed && poly.appearsClosed()) {
poly.points.pop();
} else if (!entity.closed) {
poly.setOpen(true);
}
polys.push(poly);
} else if (entity.type === 'LINE') {
// Convert line to polyline
let poly = newPolygon().addPoints([
newPoint(entity.start.x, entity.start.y, entity.start.z || 0),
newPoint(entity.end.x, entity.end.y, entity.end.z || 0)
]);
poly.setOpen(true);
polys.push(poly);
} else if (entity.type === 'CIRCLE') {
// Convert circle to polygon with points
// Calculate segments based on circumference and desired segment size
const circumference = 2 * Math.PI * entity.radius;
const segments = Math.max(minSegments, Math.ceil(circumference / segmentSize));
let points = [];
for (let i = 0; i < segments; i++) {
const angle = (i / segments) * Math.PI * 2;
points.push(newPoint(
entity.center.x + Math.cos(angle) * entity.radius,
entity.center.y + Math.sin(angle) * entity.radius,
entity.center.z || 0
));
}
let poly = newPolygon().addPoints(points).clean();
polys.push(poly);
} else if (entity.type === 'ARC') {
// Convert arc to polyline
// Calculate segments based on arc length and desired segment size
const arcLength = Math.abs(entity.endAngle - entity.startAngle) * entity.radius;
const segments = Math.max(minSegments, Math.ceil(arcLength / segmentSize));
let points = [];
for (let i = 0; i <= segments; i++) {
const angle = entity.startAngle + (i / segments) * (entity.endAngle - entity.startAngle);
points.push(newPoint(
entity.center.x + Math.cos(angle) * entity.radius,
entity.center.y + Math.sin(angle) * entity.radius,
entity.center.z || 0
));
}
let poly = newPolygon().addPoints(points);
poly.setOpen(true);
polys.push(poly);
}
}
// Nest polygons to identify holes vs outlines
const sub = fromSoup ? polygons.nest(polys) : polys;
const nest = sub.filter(p => {
for (let pc of polys) {
if (pc === p) {
return true;
} else {
return !pc.isEquivalent(p);
}
}
});
if (justPoly) {
return nest;
}
// Extrude polygons to 3D
for (let poly of nest) {
let obj = poly.extrude(depth);
objs.push(obj);
}
return objs;
}
function extractEntities(lines) {
const entities = [];
let inEntities = false;
let i = 0;
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
// Check if we're in the ENTITIES section
if (code === '0' && value === 'SECTION') {
if (i + 3 < lines.length && lines[i + 2] === '2' && lines[i + 3] === 'ENTITIES') {
inEntities = true;
i += 4;
continue;
}
}
if (code === '0' && value === 'ENDSEC' && inEntities) {
break;
}
if (inEntities && code === '0') {
if (value === 'POLYLINE') {
const entity = parsePolyline(lines, i);
if (entity) {
entities.push(entity);
i = entity.endIndex;
continue;
}
} else if (value === 'LWPOLYLINE') {
const entity = parseLWPolyline(lines, i);
if (entity) {
entities.push(entity);
i = entity.endIndex;
continue;
}
} else if (value === 'LINE') {
const entity = parseLine(lines, i);
if (entity) {
entities.push(entity);
i = entity.endIndex;
continue;
}
} else if (value === 'CIRCLE') {
const entity = parseCircle(lines, i);
if (entity) {
entities.push(entity);
i = entity.endIndex;
continue;
}
} else if (value === 'ARC') {
const entity = parseArc(lines, i);
if (entity) {
entities.push(entity);
i = entity.endIndex;
continue;
}
}
}
i += 2;
}
return entities;
}
function parsePolyline(lines, start) {
let i = start + 2;
let closed = false;
const points = [];
// Read polyline flags
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
if (code === '70') {
// Polyline flag: 1 = closed
closed = (parseInt(value) & 1) === 1;
}
if (code === '0' && value === 'VERTEX') {
const vertex = parseVertex(lines, i);
if (vertex) {
points.push(vertex.point);
i = vertex.endIndex;
continue;
}
}
if (code === '0' && value === 'SEQEND') {
return { type: 'POLYLINE', points, closed, endIndex: i + 2 };
}
i += 2;
}
return null;
}
function parseVertex(lines, start) {
let i = start + 2;
const point = { x: 0, y: 0, z: 0 };
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
if (code === '10') point.x = parseFloat(value);
if (code === '20') point.y = parseFloat(value);
if (code === '30') point.z = parseFloat(value);
if (code === '0') {
return { point, endIndex: i };
}
i += 2;
}
return { point, endIndex: i };
}
function parseLWPolyline(lines, start) {
let i = start + 2;
let closed = false;
const points = [];
let currentPoint = null;
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
if (code === '70') {
closed = (parseInt(value) & 1) === 1;
}
if (code === '10') {
if (currentPoint) {
points.push(currentPoint);
}
currentPoint = { x: parseFloat(value), y: 0, z: 0 };
}
if (code === '20' && currentPoint) {
currentPoint.y = parseFloat(value);
}
if (code === '0') {
if (currentPoint) {
points.push(currentPoint);
}
return { type: 'LWPOLYLINE', points, closed, endIndex: i };
}
i += 2;
}
if (currentPoint) {
points.push(currentPoint);
}
return { type: 'LWPOLYLINE', points, closed, endIndex: i };
}
function parseLine(lines, start) {
let i = start + 2;
const start_point = { x: 0, y: 0, z: 0 };
const end_point = { x: 0, y: 0, z: 0 };
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
if (code === '10') start_point.x = parseFloat(value);
if (code === '20') start_point.y = parseFloat(value);
if (code === '30') start_point.z = parseFloat(value);
if (code === '11') end_point.x = parseFloat(value);
if (code === '21') end_point.y = parseFloat(value);
if (code === '31') end_point.z = parseFloat(value);
if (code === '0') {
return { type: 'LINE', start: start_point, end: end_point, endIndex: i };
}
i += 2;
}
return { type: 'LINE', start: start_point, end: end_point, endIndex: i };
}
function parseCircle(lines, start) {
let i = start + 2;
const center = { x: 0, y: 0, z: 0 };
let radius = 0;
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
if (code === '10') center.x = parseFloat(value);
if (code === '20') center.y = parseFloat(value);
if (code === '30') center.z = parseFloat(value);
if (code === '40') radius = parseFloat(value);
if (code === '0') {
return { type: 'CIRCLE', center, radius, endIndex: i };
}
i += 2;
}
return { type: 'CIRCLE', center, radius, endIndex: i };
}
function parseArc(lines, start) {
let i = start + 2;
const center = { x: 0, y: 0, z: 0 };
let radius = 0;
let startAngle = 0;
let endAngle = 0;
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
if (code === '10') center.x = parseFloat(value);
if (code === '20') center.y = parseFloat(value);
if (code === '30') center.z = parseFloat(value);
if (code === '40') radius = parseFloat(value);
if (code === '50') startAngle = parseFloat(value) * Math.PI / 180; // Convert to radians
if (code === '51') endAngle = parseFloat(value) * Math.PI / 180; // Convert to radians
if (code === '0') {
return { type: 'ARC', center, radius, startAngle, endAngle, endIndex: i };
}
i += 2;
}
return { type: 'ARC', center, radius, startAngle, endAngle, endIndex: i };
}

View file

@ -6,6 +6,7 @@ import { STL } from './stl.js';
import * as OBJ from './obj.js';
import * as TMF from './3mf.js';
import * as SVG from './svg.js';
import * as DXF from './dxf.js';
import * as GBR from './gbr.js';
import { load as pngLoad } from './png.js';
@ -36,6 +37,11 @@ const types = {
resolve(opt.flat ? out : out.map(m => { return { mesh: m.toFloat32(), file } }));
},
dxf(data, file, resolve, reject, opt = {}) {
let out = DXF.parse(data, opt);
resolve(opt.flat ? out : out.map(m => { return { mesh: m.toFloat32(), file } }));
},
png(data, file, resolve, reject, opt = {}) {
pngLoad.PNG.parse(data, {
...opt,
@ -100,6 +106,6 @@ function load_file(file, opt) {
});
}
Object.assign(load_file, { SVG, OBJ, STL, TMF, GBR, PNG: pngLoad.PNG });
Object.assign(load_file, { SVG, DXF, OBJ, STL, TMF, GBR, PNG: pngLoad.PNG });
export { types, as_buffer, load_data, load_file, load_file as load };

View file

@ -4,7 +4,7 @@
import { load_file } from './file.js';
const { STL, OBJ, TMF, SVG } = load_file;
const { STL, OBJ, TMF, SVG, DXF } = load_file;
const CDH = 'Content-Disposition';
export function load_url(url, options = {}) {
@ -12,7 +12,7 @@ export function load_url(url, options = {}) {
let xhr = new XMLHttpRequest();
let file = options.file || options.filename || (((url.split('?')[0]).split('#')[0]).split('/')).pop();
let ext = file.split('.').pop().toLowerCase();
let deftype = ext === "obj" || ext === 'svg' ? "text" : "arraybuffer";
let deftype = ext === "obj" || ext === 'svg' || ext === 'dxf' ? "text" : "arraybuffer";
let datatype = options.datatype || deftype;
let formdata = options.formdata;
@ -55,6 +55,9 @@ export function load_url(url, options = {}) {
case "svg":
resolve(SVG.parse(data).map(m => { return {mesh: m.toFloat32(), file} }));
break;
case "dxf":
resolve(DXF.parse(data).map(m => { return {mesh: m.toFloat32(), file} }));
break;
default:
reject(`unknown file type: "${ext}" from ${url}`);
break;