From 3aa8b77e77a6d11ef3547f753c0823da28e2183f Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Fri, 20 Mar 2026 12:11:34 -0400 Subject: [PATCH] add dxf import --- src/kiri/app/init/menu.js | 2 +- src/kiri/app/platform.js | 47 ++++- src/load/dxf.js | 359 ++++++++++++++++++++++++++++++++++++++ src/load/file.js | 8 +- src/load/url.js | 7 +- 5 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 src/load/dxf.js diff --git a/src/kiri/app/init/menu.js b/src/kiri/app/init/menu.js index 8c936c6f..bbdcf9d8 100644 --- a/src/kiri/app/init/menu.js +++ b/src/kiri/app/init/menu.js @@ -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' }), diff --git a/src/kiri/app/platform.js b/src/kiri/app/platform.js index 8763bbae..e7875fbb 100644 --- a/src/kiri/app/platform.js +++ b/src/kiri/app/platform.js @@ -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: [ + "
", + "

Import DXF

", + "

", + " Extrude a 3D model from a 2D DXF.", + " Supports POLYLINE, LWPOLYLINE, LINE, CIRCLE, and ARC entities.", + "

", + "
", + " ", + " ", + " ", + " ", + "
z height in mm
arc segment size in mm
minimum arc segments
nest shapes
", + "
" + ]}; + 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. diff --git a/src/load/dxf.js b/src/load/dxf.js new file mode 100644 index 00000000..b7b38aa8 --- /dev/null +++ b/src/load/dxf.js @@ -0,0 +1,359 @@ +/** Copyright Stewart Allen -- 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 }; +} diff --git a/src/load/file.js b/src/load/file.js index 88962c27..82978395 100644 --- a/src/load/file.js +++ b/src/load/file.js @@ -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 }; diff --git a/src/load/url.js b/src/load/url.js index 6b481a1a..d4f5ed82 100644 --- a/src/load/url.js +++ b/src/load/url.js @@ -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;