diff --git a/src/kiri/run/cli.js b/src/kiri/run/cli.js index 022e6810c..f6e14d037 100644 --- a/src/kiri/run/cli.js +++ b/src/kiri/run/cli.js @@ -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 -- 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", @@ -36,10 +47,9 @@ if (opts.help) { console.log([ "cli ", " --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)", @@ -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}) }) @@ -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); }); }