diff --git a/bin/esbuild.config.mjs b/bin/esbuild.config.mjs index 11b24998a..4f1ccff91 100644 --- a/bin/esbuild.config.mjs +++ b/bin/esbuild.config.mjs @@ -77,6 +77,7 @@ const rec = { define: { 'process.env.NODE_ENV': `"${mode}"` }, external: [ 'module', + 'node:module', './constants', './voronoi_structures', './voronoi_ctypes', diff --git a/docs/kiri-moto/integrations.md b/docs/kiri-moto/integrations.md index 64147ec9d..d94236a79 100644 --- a/docs/kiri-moto/integrations.md +++ b/docs/kiri-moto/integrations.md @@ -26,6 +26,27 @@ Kiri:Moto is the slicing engine behind [SimplyPrint's](https://simplyprint.io/) [CAMLab](http://camlab.sienci.com/camlab) is a fork of an earlier version of Kiri:Moto. +## CNCjs + +[CNCjs](https://cncjs.io/) is a web-based controller for GRBL and other CNC firmware. Kiri:Moto's laser mode can send gcode directly to CNCjs using the `tools/cncjs-bridge` shim included in this repository. + +The bridge is a zero-dependency Node.js server that accepts Kiri's OctoPrint-format uploads and forwards them to CNCjs's `/api/gcode` endpoint. + +### Setup + +``` +cd tools/cncjs-bridge +node bridge.js +``` + +The bridge listens on port **5310** by default. In Kiri:Moto's laser export dialog, enable the **exportOcto** controller setting, then set the host to `http://localhost:5310` with no API key. + +In the laser device settings, configure the laser on/off commands for GRBL: +- **Laser On**: `M3 S{power}` +- **Laser Off**: `M5` + +Enable GRBL laser mode on your controller once with `$32=1` via the CNCjs console. + ## Thingiverse Like Onshape, Kiri:Moto is integrated as a native [Thing-app](https://www.thingiverse.com/apps/kirimoto) into Thingiverse. As a user of Thingiverse, you can elect to have Kiri:Moto show up as a "way to open" a Thing file. This provides the convenience of directly accessing, slicing, and printing parts on Thingiverse without first having to download them and then re-import them into a slicer. diff --git a/docs/release.md b/docs/release.md index d31b21396..1b4a2e7d4 100644 --- a/docs/release.md +++ b/docs/release.md @@ -4,6 +4,11 @@ Full docs @ https://docs.grid.space/projects/kiri-moto # Release 4.4.0 +## Laser + +- add OctoPrint/Moonraker remote send from laser export dialog +- add concentric fill for closed shapes: enable **fill** in the output section, set **spacing** in mm to control ring density + ## General - add version numbering utility script diff --git a/src/kiri/app/conf/defaults.js b/src/kiri/app/conf/defaults.js index 1efecc0b1..3d089426c 100644 --- a/src/kiri/app/conf/defaults.js +++ b/src/kiri/app/conf/defaults.js @@ -676,7 +676,9 @@ export const conf = { outputInvertX: false, outputInvertY: false, ctOutInches: false, - ctOutShaper: false + ctOutShaper: false, + ctFillEnable: false, + ctFillSpacing: 1 } }, drag: { diff --git a/src/kiri/app/export.js b/src/kiri/app/export.js index b1e5556fa..7d9a68f13 100644 --- a/src/kiri/app/export.js +++ b/src/kiri/app/export.js @@ -180,6 +180,50 @@ function exportLaserDialog(data, names) { $('print-dxf').onclick = download_dxf; $('print-obj').onclick = download_obj; $('print-lg').onclick = download_gcode; + + const showRemote = settings.controller.exportOcto; + $('laser-remote-head').style.display = showRemote ? '' : 'none'; + $('laser-remote-send').style.display = showRemote ? '' : 'none'; + + if (showRemote) { + const hostEl = $('laser-octo-host'); + const apikEl = $('laser-octo-apik'); + const typeEl = $('laser-octo-type'); + hostEl.value = localGet('octo-host') || ''; + apikEl.value = localGet('octo-apik') || ''; + typeEl.value = localGet('octo-type') || 'octoprint'; + + $('laser-send-remote').onclick = function() { + const host = hostEl.value.trim(); + const apik = apikEl.value.trim(); + const type = typeEl.value; + if (!host) { api.show.alert('host is required'); return; } + localSet('octo-host', host); + localSet('octo-apik', apik); + localSet('octo-type', type); + + const gcode = driver.exportGCode(settings, data); + const fname = $('print-filename-laser').value + '.gcode'; + const form = new FormData(); + form.append('file', new Blob([gcode], { type: 'text/plain' }), fname); + + const endpoint = type === 'moonraker' ? '/server/files/upload' : '/api/files/local'; + const xhr = new XMLHttpRequest(); + xhr.open('POST', host + endpoint); + if (apik) xhr.setRequestHeader('X-Api-Key', apik); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + if (xhr.status >= 200 && xhr.status < 300) { + api.show.alert('sent to ' + host); + api.modal.hide(); + } else { + api.show.alert('send failed: ' + xhr.status + ' ' + xhr.responseText); + } + } + }; + xhr.send(form); + }; + } } /** diff --git a/src/kiri/mode/laser/init-menu.js b/src/kiri/mode/laser/init-menu.js index 5e5bac27c..b23a44854 100644 --- a/src/kiri/mode/laser/init-menu.js +++ b/src/kiri/mode/laser/init-menu.js @@ -72,6 +72,9 @@ export function menu() { ctOutClean: newBoolean('clean', onBooleanClick, { title:'clean', modes:LASER, show:() => ui.ctOutStack.checked }), ctOutFilter: newInput('filter', { title:'filter', modes:LASER, convert:toFloat, show:() => ui.ctOutStack.checked }), ctOutSmooth: newInput('smooth', { title:'smooth', modes:LASER, convert:toFloat, show:() => ui.ctOutStack.checked }), + separator: newBlank({ class:"set-sep", driven, modes:LASER }), + ctFillEnable: newBoolean('fill', onBooleanClick, { title:'fill closed shapes with concentric inset paths', modes:LASER }), + ctFillSpacing: newInput('spacing', { title:'concentric fill spacing in mm', convert:toFloat, modes:LASER, show:() => ui.ctFillEnable.checked }), }; diff --git a/src/kiri/mode/laser/init-work.js b/src/kiri/mode/laser/init-work.js index edcd186ad..dd407e76b 100644 --- a/src/kiri/mode/laser/init-work.js +++ b/src/kiri/mode/laser/init-work.js @@ -85,6 +85,7 @@ function sliceEmitObjects(print, slice, groups, opt = { }) { // cut inside before outside polyOut(inner, group, "in", outer.length > 1); polyOut(outer, group, "out", outer.length > 1); + fillOut(outer, group); groups.push(group); } else { for (let top of slice.offset) { @@ -92,11 +93,52 @@ function sliceEmitObjects(print, slice, groups, opt = { }) { group.thick = slice.thick; polyOut([ top ], group, "in"); polyOut(top.inner || [], group, "out"); + fillOut([ top ], group); groups.push(group); } } return emit; + + function fillOut(outers, group) { + let { ctFillEnable, ctFillSpacing } = process; + if (!ctFillEnable || !(ctFillSpacing > 0)) return; + const z = outers[0]?.getZ() || 0; + const minDim = ctFillSpacing * 0.25; + // for shapes that have inner children (e.g. a circle represented as a thin ring + // from slicing a cylindrical shell), fill from the inner boundary inward so the + // fill covers the enclosed interior rather than the ring wall itself; + // for solid shapes with no inner children, fill from the outer boundary + // process each outer polygon separately so we can use its own inner holes + // as the fill boundary stop condition + for (const outer of outers) { + // sum of all inner hole areas; fill stops before entering any hole + const holeArea = outer.inner ? outer.inner.reduce((s, h) => s + h.area(), 0) : 0; + // strip inner children so Clipper treats the outer as a solid; + // passing inner children causes Clipper to expand the holes on each offset + // iteration, collapsing the ring prematurely + let current = [outer.inner ? newPolygon().addPoints(outer.points).setZ(z) : outer]; + while (current && current.length) { + current = POLY.offset(current, -ctFillSpacing, { z, minArea: 0 }); + if (!current || !current.length) break; + // strip inner children from intermediate results for the same reason + current = current.map(p => p.inner ? newPolygon().addPoints(p.points).setZ(z) : p); + // if the original shape had holes, stop when the fill ring shrinks + // into hole territory (area ≤ combined hole area) + if (holeArea > 0) { + current = current.filter(p => p.area() > holeArea); + } + current = current.filter(p => { + const b = p.bounds; + return (b.maxx - b.minx) > minDim && (b.maxy - b.miny) > minDim; + }); + if (!current.length) break; + for (let poly of current) { + print.PPP(poly, group, { extrude: 1, rate: 1 }); + } + } + } + } }; /** diff --git a/src/main/kiri.js b/src/main/kiri.js index 8691ecda2..f171c4748 100644 --- a/src/main/kiri.js +++ b/src/main/kiri.js @@ -30,7 +30,7 @@ function safeExec(fn, name) { async function checkReady() { if (document.readyState === 'complete') { - let bootctrl = navigator.serviceWorker.controller; + let bootctrl = navigator.serviceWorker?.controller; console.log(`kiri | boot ctrl | ` + (bootctrl ? true : false)); kiri.api = api; self.$ = api.web.$; diff --git a/tools/cncjs-bridge/README.md b/tools/cncjs-bridge/README.md new file mode 100644 index 000000000..6ca6801b7 --- /dev/null +++ b/tools/cncjs-bridge/README.md @@ -0,0 +1,34 @@ +# kiri-cncjs-bridge + +A zero-dependency Node.js shim that lets Kiri:Moto's laser export send gcode directly to [CNCjs](https://cncjs.io/). + +Kiri's "send to host" export speaks OctoPrint's multipart upload format. CNCjs has its own REST API. This bridge accepts the OctoPrint-format POST from Kiri and forwards the gcode to CNCjs's `/api/gcode` endpoint. + +## Requirements + +- Node.js 18+ +- CNCjs running and connected to your machine (default: `http://localhost:8000`) + +## Usage + +``` +node bridge.js +``` + +The bridge listens on port **5310**. Leave it running while you work. + +## Kiri:Moto setup + +1. In Preferences, enable **exportOcto** +2. In the laser export dialog, set: + - **host**: `http://localhost:5310` + - **api key**: _(leave blank)_ + - **type**: `octoprint` + +## Laser device setup (GRBL) + +In your Kiri laser device settings, set: +- **Laser On**: `M3 S{power}` +- **Laser Off**: `M5` + +Send `$32=1` once from the CNCjs console to enable GRBL laser mode. diff --git a/tools/cncjs-bridge/bridge.js b/tools/cncjs-bridge/bridge.js new file mode 100644 index 000000000..e9d9212c6 --- /dev/null +++ b/tools/cncjs-bridge/bridge.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// Bridges Kiri:Moto's OctoPrint-format file send to CNCjs's /api/gcode endpoint. +// No external dependencies — uses only Node.js built-ins. + +const http = require('http'); + +const CNCJS = 'http://127.0.0.1:8000'; +const PORT = 5310; + +let cachedToken = null; + +// Parse multipart/form-data body, return { filename, content } for the first file field +function parseMultipart(body, contentType) { + const match = contentType.match(/boundary=([^\s;]+)/); + if (!match) throw new Error('no boundary in content-type'); + const boundary = '--' + match[1]; + const parts = body.split(boundary).slice(1); // skip preamble + for (const part of parts) { + if (part.startsWith('--')) break; // epilogue + const split = part.indexOf('\r\n\r\n'); + if (split === -1) continue; + const headers = part.slice(0, split); + // strip leading \r\n and trailing \r\n before next boundary + const content = part.slice(split + 4, part.lastIndexOf('\r\n')); + const dispMatch = headers.match(/Content-Disposition:[^\r\n]*name="file"[^\r\n]*/i); + if (!dispMatch) continue; + const fnMatch = headers.match(/filename="([^"]+)"/i); + return { filename: fnMatch ? fnMatch[1] : 'kiri.gcode', content }; + } + throw new Error('no file field in multipart body'); +} + +function post(url, body, headers = {}) { + return new Promise((resolve, reject) => { + const buf = Buffer.isBuffer(body) ? body : Buffer.from(body); + const u = new URL(url); + const req = http.request({ + hostname: u.hostname, port: u.port || 80, path: u.pathname, + method: 'POST', + headers: { 'Content-Length': buf.length, ...headers } + }, res => { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() })); + }); + req.on('error', reject); + req.write(buf); + req.end(); + }); +} + +function get(url, headers = {}) { + return new Promise((resolve, reject) => { + const u = new URL(url); + http.get({ hostname: u.hostname, port: u.port || 80, path: u.pathname, headers }, res => { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() })); + }).on('error', reject); + }); +} + +async function getToken() { + if (cachedToken) return cachedToken; + const res = await post(`${CNCJS}/api/signin`, JSON.stringify({ token: '' }), { + 'Content-Type': 'application/json' + }); + cachedToken = JSON.parse(res.body).token; + return cachedToken; +} + +async function getPort(token) { + const res = await get(`${CNCJS}/api/controllers`, { Authorization: `Bearer ${token}` }); + const data = JSON.parse(res.body); + const ports = Array.isArray(data) ? data.map(c => c.port) : Object.keys(data); + return ports.find(Boolean) || null; +} + +function cors(res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Api-Key, Authorization'); +} + +const server = http.createServer((req, res) => { + cors(res); + if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } + + const isUpload = req.url === '/api/files/local' || req.url === '/server/files/upload'; + if (req.method !== 'POST' || !isUpload) { + res.writeHead(404); res.end('not found'); return; + } + + const chunks = []; + req.on('data', c => chunks.push(c)); + req.on('end', async () => { + try { + const body = Buffer.concat(chunks).toString('binary'); + const { filename, content } = parseMultipart(body, req.headers['content-type'] || ''); + const token = await getToken(); + const port = await getPort(token); + if (!port) { + res.writeHead(503); + res.end(JSON.stringify({ error: 'No connected CNCjs controller — connect to a machine in CNCjs first.' })); + return; + } + const payload = JSON.stringify({ port, name: filename, gcode: content }); + const result = await post(`${CNCJS}/api/gcode`, payload, { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }); + console.log(`[bridge] sent ${filename} to CNCjs port ${port} → ${result.status}`); + res.writeHead(result.status < 300 ? 200 : result.status); + res.end(result.body); + } catch (e) { + console.error('[bridge] error:', e.message); + cachedToken = null; + res.writeHead(500); res.end(JSON.stringify({ error: e.message })); + } + }); +}); + +server.listen(PORT, () => console.log(`[bridge] kiri→cncjs bridge listening on :${PORT}`)); diff --git a/tools/cncjs-bridge/package.json b/tools/cncjs-bridge/package.json new file mode 100644 index 000000000..58f01d542 --- /dev/null +++ b/tools/cncjs-bridge/package.json @@ -0,0 +1,9 @@ +{ + "name": "kiri-cncjs-bridge", + "version": "1.0.0", + "description": "OctoPrint-format shim that forwards Kiri:Moto laser gcode to CNCjs", + "main": "bridge.js", + "scripts": { + "start": "node bridge.js" + } +} diff --git a/web/kiri/index.html b/web/kiri/index.html index ed3866daf..d08546e28 100644 --- a/web/kiri/index.html +++ b/web/kiri/index.html @@ -435,6 +435,25 @@ +
+
+
+ + +
+
+ + +
+
+ + +
+ +