Skip to content
Draft
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
302 changes: 178 additions & 124 deletions src/kiri/run/cli.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
/** example of how to use Kiri:Moto's slicer engine from the command-line */
let fs = require('fs');
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */

/**
* updated to use esbuild bundle output instead of eval-based gapp loader.
* runs single-threaded with an in-process Worker message bridge.
*
* supported: FDM, LASER, SLA modes (single-threaded slicing)
* limited: CAM mode (requires worker pool for parallel ops - not yet wired)
*/

import fs from 'fs';
import path from 'path';

let args = process.argv.slice(2);
let root = path.resolve(import.meta.dirname, '../../..');
let opts = {
dir: process.cwd(),
dir: root,
output: "-",
model: "web/obj/cube.stl",
source: "src/cli/kiri-source.json",
controller: "src/cli/kiri-controller.json",
process: "src/cli/kiri-fdm-process.json",
device: "src/cli/kiri-fdm-device.json",
Expand Down Expand Up @@ -36,10 +47,9 @@ if (opts.help) {
console.log([
"cli <options> <file>",
" --verbose | enable verbose logging",
" --dir=[dir] | root directory for file paths (default: '.')",
" --dir=[dir] | root directory for file paths (default: repo root)",
" --model=[file] | model file to load (or last parameter)",
" --tools=[file] | tools array for CAM mode",
" --source=[file] | source file list (defaults to kiri engine)",
" --device=[file] | device definition file (json)",
" --process=[file] | process definition file (json)",
" --controller=[file] | controller definition file (json)",
Expand All @@ -49,137 +59,176 @@ if (opts.help) {
" --scale=x,y,z | scale loaded model in x,y,z",
" --move=x,y,z | move loaded model x,y,z millimeters"
].join("\r\n"));
return;
process.exit(0);
}

let { dir, verbose, model, output, position, move, scale, rotate } = opts;
let exports_save = exports,
navigator = { userAgent: "" },
module_save = module,
THREE = {},
gapp = {},
geo = {},
noop = () => { },
self = this.self = {
gapp,
THREE,
location: { hostname: 'local', port: 0, protocol: 'fake' },
postMessage: (msg) => {
self.kiri.client.onmessage({data:msg});
}
};

// fake fetch for worker to get wasm, if needed
let fetch = function(url, opts = {}) {
// resolve paths relative to dir
function resolve(file) {
if (path.isAbsolute(file)) return file;
return path.join(dir, file);
}

// read json config file (supports trailing commas like the cli configs)
function readJSON(file) {
let raw = fs.readFileSync(resolve(file)).toString();
// strip trailing commas before } or ] (relaxed json)
raw = raw.replace(/,\s*([\]}])/g, '$1');
return JSON.parse(raw);
}

// node is missing browser globals used during bundle import
let noop = () => {};
let mockCtx = new Proxy({}, { get: (t, p) => typeof p === 'symbol' ? undefined : () => mockCtx });
let mockEl = () => ({
getContext: () => mockCtx, style: {}, appendChild: noop,
addEventListener: noop, setAttribute: noop, removeEventListener: noop,
classList: { add: noop, remove: noop, contains: () => false },
width: 256, height: 256, getBoundingClientRect: () => ({}),
querySelector: () => null, querySelectorAll: () => [],
innerHTML: '', insertBefore: noop, removeChild: noop, contains: () => false
});

globalThis.self = globalThis;
globalThis.window = globalThis;
globalThis.navigator = { userAgent: 'node', hardwareConcurrency: 0, language: 'en-US' };
globalThis.location = { hostname: 'localhost', port: 0, protocol: 'file:', search: '', hash: '', host: 'localhost', href: 'http://localhost/' };
globalThis.document = {
createElement: mockEl, createElementNS: () => mockEl(),
addEventListener: noop, body: { appendChild: noop, style: {}, contains: () => false },
head: { appendChild: noop }, querySelectorAll: () => [],
getElementById: () => null, documentElement: { style: {} }
};
globalThis.HTMLCanvasElement = class {};
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 16);
globalThis.cancelAnimationFrame = noop;
globalThis.WebSocket = class { addEventListener() {} close() {} send() {} };
globalThis.XMLHttpRequest = class { open() {} send() {} setRequestHeader() {} };
globalThis.Blob = class { constructor() {} };
globalThis.URL.createObjectURL = () => 'blob:fake';
globalThis.URL.revokeObjectURL = noop;
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
globalThis.MutationObserver = class { observe() {} disconnect() {} };
globalThis.getComputedStyle = () => new Proxy({}, { get: () => '' });
globalThis.matchMedia = () => ({ matches: false, addEventListener: noop });
globalThis.localStorage = { getItem: () => null, setItem: noop, removeItem: noop };
globalThis.sessionStorage = { getItem: () => null, setItem: noop };
globalThis.DOMParser = class { parseFromString() { return { querySelector: () => null, querySelectorAll: () => [] } } };
globalThis.Image = class { set src(v) {} addEventListener() {} };
globalThis.indexedDB = null;
globalThis.atob = (a) => Buffer.from(a, 'base64').toString('binary');
globalThis.btoa = (b) => Buffer.from(b, 'binary').toString('base64');

// fake fetch reads files from disk, resolving paths relative to repo root
globalThis.fetch = function(url) {
if (typeof url !== 'string') return Promise.resolve({ ok: false });
// handle relative paths from bundle location (e.g. '../wasm/manifold.wasm')
if (url.startsWith('../') || url.startsWith('./')) {
url = path.resolve(root, 'src/pack', url);
} else if (!path.isAbsolute(url) && !url.startsWith('http')) {
url = resolve(url);
}
if (verbose) console.log({fetch: url});
if (!url.startsWith('/')) {
url = `${dir}/${url}`;
try {
let buf = fs.readFileSync(url);
return Promise.resolve({
ok: true,
arrayBuffer: () => Promise.resolve(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)),
text: () => Promise.resolve(buf.toString()),
json: () => Promise.resolve(JSON.parse(buf.toString()))
});
} catch (e) {
return Promise.resolve({ ok: false });
}
let buf = fs.readFileSync(url);
return new Promise((resolve, reject) => {
resolve(new Promise((resolve, reject) => {
if (opts.format === 'string') {
return resolve(buf.toString());
}
if (opts.format === 'buffer') {
return resolve(buf);
}
if (opts.format === 'eval') {
return resolve(eval('(' + buf + ')'));
}
resolve({
arrayBuffer: function() {
return buf;
}
});
}));
});
};

// imitate worker process
class Worker {
// intercept fs.readFileSync to redirect wasm lookups to src/wasm/
let _readFileSync = fs.readFileSync;
fs.readFileSync = function(filepath, ...args) {
if (typeof filepath === 'string' && filepath.endsWith('.wasm') && !fs.existsSync(filepath)) {
let wasmName = path.basename(filepath);
let wasmPath = path.join(root, 'src/wasm', wasmName);
if (_readFileSync.call(fs, wasmPath, ...args)) {
filepath = wasmPath;
}
}
return _readFileSync.call(fs, filepath, ...args);
};

// suppress async errors from optional wasm modules
process.on('unhandledRejection', () => {});
process.on('uncaughtException', (err) => {
if (err && err.message && err.message.includes('Aborted')) return;
process.stderr.write((err.stack || err.message || String(err)) + '\n');
process.exit(1);
});
process.abort = noop;

// imitate worker process using in-process message bridge
// tracks all workers so pool minions don't steal the main channel
let workerOnMessage = null;
let workers = [];

globalThis.Worker = class {
constructor(url) {
if (verbose) console.log({worker: url});
this.onmessage = null;
this._id = workers.length;
workers.push(this);
}

postMessage(msg) {
setImmediate(() => {
self.kiri.worker.onmessage({data:msg});
});
postMessage(msg, xfer) {
// pool management messages (cmd) are not handled by the worker
if (msg && msg.cmd) return;
setImmediate(() => { if (workerOnMessage) workerOnMessage({ data: msg }) });
}

onmessage(msg) {
// if we end up here, something went wrong
console.trace('worker-recv', msg);
terminate() {}
addEventListener(type, fn) {
if (type === 'message') {
this.onmessage = (e) => fn(e);
}
}
};

terminate() {
// if we end up here, something went wrong
console.trace('worker terminate');
}
}
// worker calls postMessage to send results back to the main worker (first created)
globalThis.postMessage = function(msg, xfer) {
setImmediate(() => {
let w = workers[0];
if (w && w.onmessage) w.onmessage({ data: msg });
});
};

// node is missing these functions so put them in scope during eval
function atob(a) {
return Buffer.from(a).toString('base64');
}
globalThis.createWorker = () => new Worker();

function btoa(b) {
return Buffer.from(b, 'base64').toString();
}
// redirect console.log to stderr so stdout stays clean for gcode piping
let _log = console.log;
console.log = (...args) => {
if (verbose) process.stderr.write(args.map(a => typeof a === 'object' ? JSON.stringify(a) : a).join(' ') + '\n');
};

async function run() {
let files = await fetch(opts.source, { format: "eval" } );
let tools = await fetch(opts.tools, { format: "eval" } );
let device = await fetch(opts.device, { format: "eval" } );
let process = await fetch(opts.process, { format: "eval" } );

for (let file of files.map(p => `${dir}/src/${p}.js`)) {
let isPNG = file.indexOf("/pngjs") > 0;
let isClip = file.indexOf("/clip") > 0;
let isEarcut = file.indexOf("/earcut") > 0;
let isTHREE = file.indexOf("/three") > 0;
if (isTHREE) {
// THREE.js kung-fu fake-out
exports = {};
}
let swapMod = isEarcut;
if (swapMod) {
module = { exports: {} };
}
let clearMod = isPNG || isClip;
if (clearMod) {
module = undefined;
}
try {
if (verbose) console.log(`loading ... ${file}`);
eval(fs.readFileSync(`${file}`).toString());
} catch (e) {
throw e;
}
if (isClip) {
ClipperLib = self.ClipperLib;
}
if (isTHREE) {
Object.assign(THREE, exports);
// restore exports after faking out THREE.js
exports = exports_save;
}
if (isEarcut) {
self.earcut = module.exports;
}
if (clearMod || swapMod) {
module = module_save;
}
}
// load worker bundle first to register message handler
await import(root + '/src/pack/kiri-work.js').catch(() => {});
workerOnMessage = self.onmessage;

let { kiri, moto, load } = self;
// load engine bundle
let { newEngine } = await import(root + '/src/pack/kiri-eng.js');

console.log({version: kiri.version});
async function run() {
let tools = readJSON(opts.tools);
let device = readJSON(opts.device);
let procset = readJSON(opts.process);
let controller = readJSON(opts.controller);

let engine = newEngine();
engine.setController({ threaded: false });

let engine = kiri.newEngine();
let data = await fetch(model)
let buf = new Uint8Array(data.arrayBuffer()).buffer;
let modelPath = resolve(model);
if (!fs.existsSync(modelPath)) {
process.stderr.write(`model not found: ${modelPath}\n`);
process.exit(1);
}
let data = fs.readFileSync(modelPath);
let buf = new Uint8Array(data).buffer;

return engine.parse(buf)
.then(data => { if (verbose) console.log({loaded: data}) })
Expand All @@ -205,22 +254,27 @@ async function run() {
engine.rotate(x,y,z);
}
})
.then(() => { if (device.mode) engine.setMode(device.mode) })
.then(() => engine.setController({ threaded: false }))
.then(() => engine.setDevice(device))
.then(() => engine.setProcess(process))
.then(() => engine.setProcess(procset))
.then(() => { if (device.mode === 'CAM') engine.setTools(tools) })
.then(() => engine.setMode(device.mode))
.then(eng => eng.slice())
.then(eng => eng.prepare())
.then(eng => engine.slice())
.then(eng => engine.prepare())
.then(eng => engine.export())
.then(gcode => {
if (output === '-') {
console.log({gcode});
process.stdout.write(gcode);
} else {
fs.writeFileSync(output, gcode);
let outpath = resolve(output);
fs.writeFileSync(outpath, gcode);
process.stderr.write(`wrote ${gcode.length} bytes to ${outpath}\n`);
}
process.exit(0);
})
.catch(error => {
console.log({error});
process.stderr.write(JSON.stringify({error}) + '\n');
process.exit(1);
});
}

Expand Down