Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bin/esbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const rec = {
define: { 'process.env.NODE_ENV': `"${mode}"` },
external: [
'module',
'node:module',
'./constants',
'./voronoi_structures',
'./voronoi_ctypes',
Expand Down
21 changes: 21 additions & 0 deletions docs/kiri-moto/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/kiri/app/conf/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,9 @@ export const conf = {
outputInvertX: false,
outputInvertY: false,
ctOutInches: false,
ctOutShaper: false
ctOutShaper: false,
ctFillEnable: false,
ctFillSpacing: 1
}
},
drag: {
Expand Down
44 changes: 44 additions & 0 deletions src/kiri/app/export.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
}
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/kiri/mode/laser/init-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),

};

Expand Down
57 changes: 55 additions & 2 deletions src/kiri/mode/laser/init-work.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,60 @@ 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) {
let group = [];
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 });
}
}
}
}
};

/**
Expand Down Expand Up @@ -324,13 +366,24 @@ async function laser_prepare(widgets, settings, update) {
}
let lastEmit;
let slices = ctOutStack ? widget.slices.reverse() : widget.slices;
let wpos = widget.track.pos;
let hasWPos = wpos && (wpos.x || wpos.y);
for (let slice of slices) {
// apply arrange-view translation to slice polys so gcode reflects user positioning
if (hasWPos) {
for (let poly of (slice.offset || [])) {
poly.move({ x: wpos.x, y: wpos.y });
}
}
lastEmit = sliceEmitObjects(print, slice, layers, {simple: isKnife, lastEmit});
update((slices++ / totalSlices) * 0.5, "prepare");
}
}
}

// detect manual positioning: any widget moved from origin skips auto-layout
let hasManualPositions = widgets.some(w => w.track.pos.x || w.track.pos.y);

// for tile layout packing
let dw = device.bedWidth / 2,
dh = device.bedDepth / 2,
Expand All @@ -356,8 +409,8 @@ async function laser_prepare(widgets, settings, update) {
return packer.rescale(1.1, 1.1);
});

// reposition tiles into their packed locations (unless 3d stack)
if (!(ctOutStack || ctSliceSingle))
// reposition tiles into their packed locations (unless 3d stack or manual positions)
if (!(ctOutStack || ctSliceSingle || hasManualPositions))
for (let tile of tiles) {
let { fit, bounds } = tile;
for (let { poly } of tile) {
Expand Down
2 changes: 1 addition & 1 deletion src/main/kiri.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.$;
Expand Down
34 changes: 34 additions & 0 deletions tools/cncjs-bridge/README.md
Original file line number Diff line number Diff line change
@@ -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.
123 changes: 123 additions & 0 deletions tools/cncjs-bridge/bridge.js
Original file line number Diff line number Diff line change
@@ -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}`));
9 changes: 9 additions & 0 deletions tools/cncjs-bridge/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading