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
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
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"
}
}
19 changes: 19 additions & 0 deletions web/kiri/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,25 @@
<button id="print-obj" class="grow">obj</button>
<button id="print-lg" class="grow">gcode</button>
</div>
<div id="laser-remote-head" class="header"><label>send to host</label></div>
<div id="laser-remote-send" class="f-col box">
<div>
<label>host</label>
<input id="laser-octo-host" size="20" placeholder="http://host:port" />
</div>
<div>
<label>api key</label>
<input id="laser-octo-apik" size="20" />
</div>
<div>
<label>type</label>
<select id="laser-octo-type">
<option selected>octoprint</option>
<option>moonraker</option>
</select>
</div>
<button id="laser-send-remote" class="grow">send</button>
</div>
</div>
<!-- dynamic dialogs -->
<div id="mod-any" class="mdialog f-col"></div>
Expand Down