diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 054ec9e8..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - "extends": "airbnb-base", - "rules": { - "no-restricted-syntax": "off", - "no-underscore-dangle": "off", - "no-plusplus": "off", - "max-len": ["error", { "code": 90 }], - "comma-dangle": ["error", { - "exports": "never", - "functions": "never", - "arrays": "always-multiline", - "objects": "always-multiline" - }], - "class-methods-use-this": "off", - "no-console": "off", // TODO: Move to some other logging method. - "no-multi-str": "off", - } -}; diff --git a/.gitignore b/.gitignore index 62840d6a..4074d5e9 100755 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ npm-debug.log config.ini +test_* + .DS_Store /nbproject/* diff --git a/.nvmrc b/.nvmrc index 48082f72..b6a7d89c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -12 +16 diff --git a/machine_types/watercolorbot.ini b/machine_types/watercolorbot.ini index 7b1b4703..7ea12944 100755 --- a/machine_types/watercolorbot.ini +++ b/machine_types/watercolorbot.ini @@ -53,6 +53,7 @@ height = 206.25 [park] x = 0 y = 0 +z = 0 [workArea] ; Also measured in steps @@ -93,182 +94,43 @@ max = 1023 min = 0 [tools] +[tools.pan] +x = 22 +y = -2.5 +width = 38.4 +height = 214 +position = topleft +radius = 4 +type = swappable +group = Placeholder + [tools.water0] -x = 0 -y = 0 -wiggleAxis = y -wiggleTravel = 300 -wiggleIterations = 4 +x = -12.2 +y = 14.8 +width = 57.85 +height = 57.85 +position = center +radius = 35 group = Water -[tools.water0dip] -x = 0 -y = 0 -wiggleAxis = y -wiggleTravel = 5 -wiggleIterations = 2 -group = Water Dip - [tools.water1] -x = 0 -y = 1650 -wiggleAxis = y -wiggleTravel = 300 -wiggleIterations = 4 +x = -12.2 +y = 90 +width = 57.85 +height = 57.85 +position = center +radius = 35 group = Water -[tools.water1dip] -x = 0 -y = 1650 -wiggleAxis = y -wiggleTravel = 5 -wiggleIterations = 2 -group = Water Dip - [tools.water2] -x = 0 -y = 3000 -wiggleAxis = y -wiggleTravel = 300 -wiggleIterations = 4 +x = -12.2 +y = 168 +width = 57.85 +height = 57.85 +position = center +radius = 35 group = Water -[tools.water2dip] -x = 0 -y = 3000 -wiggleAxis = y -wiggleTravel = 5 -wiggleIterations = 2 -group = Water Dip - -[tools.color0] -x = 775 -y = 250 -wiggleAxis = xy -wiggleTravel = 300 -wiggleIterations = 8 -group = Colors - -[tools.color1] -x = 775 -y = 715 -wiggleAxis = xy -wiggleTravel = 300 -wiggleIterations = 8 -group = Colors - -[tools.color2] -x = 775 -y = 1135 -wiggleAxis = xy -wiggleTravel = 300 -wiggleIterations = 8 -group = Colors - -[tools.color3] -x = 775 -y = 1625 -wiggleAxis = xy -wiggleTravel = 300 -wiggleIterations = 8 -group = Colors - -[tools.color4] -x = 775 -y = 2035 -wiggleAxis = xy -wiggleTravel = 300 -wiggleIterations = 8 -group = Colors - -[tools.color5] -x = 775 -y = 2502 -wiggleAxis = xy -wiggleTravel = 300 -wiggleIterations = 8 -group = Colors - -[tools.color6] -x = 775 -y = 2955 -wiggleAxis = xy -wiggleTravel = 300 -wiggleIterations = 8 -group = Colors - -[tools.color7] -x = 775 -y = 3405 -wiggleAxis = xy -wiggleTravel = 300 -wiggleIterations = 8 -group = Colors - -[tools.color0dip] -x = 775 -y = 250 -wiggleAxis = xy -wiggleTravel = 5 -wiggleIterations = 2 -group = Colors Dip - -[tools.color1dip] -x = 775 -y = 715 -wiggleAxis = xy -wiggleTravel = 5 -wiggleIterations = 2 -group = Colors Dip - -[tools.color2dip] -x = 775 -y = 1135 -wiggleAxis = xy -wiggleTravel = 5 -wiggleIterations = 2 -group = Colors Dip - -[tools.color3dip] -x = 775 -y = 1625 -wiggleAxis = xy -wiggleTravel = 5 -wiggleIterations = 2 -group = Colors Dip - -[tools.color4dip] -x = 775 -y = 2035 -wiggleAxis = xy -wiggleTravel = 5 -wiggleIterations = 2 -group = Colors Dip - -[tools.color5dip] -x = 775 -y = 2502 -wiggleAxis = xy -wiggleTravel = 5 -wiggleIterations = 2 -group = Colors Dip - -[tools.color6dip] -x = 775 -y = 2955 -wiggleAxis = xy -wiggleTravel = 5 -wiggleIterations = 2 -group = Colors Dip - -[tools.color7dip] -x = 775 -y = 3405 -wiggleAxis = xy -wiggleTravel = 5 -wiggleIterations = 2 -group = Colors Dip - [tools.manualswap] x = 0 y = 0 diff --git a/node.importmap b/node.importmap new file mode 100644 index 00000000..b524579f --- /dev/null +++ b/node.importmap @@ -0,0 +1,73 @@ +{ + "imports": { + "cs/unify": "./src/components/core/utils/cncserver.unify.js", + "cs/utils": "./src/components/core/utils/cncserver.utils.js", + "cs/binder": "./src/components/core/utils/cncserver.binder.js", + "cs/settings": "./src/components/core/utils/cncserver.settings.js", + "cs/i18n": "./src/components/core/utils/cncserver.i18n.js", + "cs/server": "./src/components/core/comms/cncserver.server.js", + "cs/rest": "./src/components/core/comms/cncserver.rest.js", + "cs/ipc": "./src/components/core/comms/cncserver.ipc.js", + "cs/serial": "./src/components/core/comms/cncserver.serial.js", + "cs/sockets": "./src/components/core/comms/cncserver.sockets.js", + "cs/pen": "./src/components/core/control/cncserver.pen.js", + "cs/actualPen": "./src/components/core/control/cncserver.actualpen.js", + "cs/control": "./src/components/core/control/cncserver.control.js", + "cs/tools": "./src/components/core/control/cncserver.tools.js", + "cs/buffer": "./src/components/core/utils/cncserver.buffer.js", + "cs/run": "./src/components/core/utils/cncserver.run.js", + "cs/scratch": "./src/components/third_party/scratch/cncserver.scratch.js", + "cs/drawing": "./src/components/core/drawing.js", + "cs/projects": "./src/components/core/control/cncserver.projects.js", + "cs/print": "./src/components/core/control/cncserver.print.js", + "cs/content": "./src/components/core/control/cncserver.content.js", + "cs/schemas": "./src/components/core/schemas/cncserver.schemas.js", + "cs/api": "./src/components/core/comms/cncserver.api.js", + "cs/api/handlers": "./src/components/core/comms/api/index.js", + "cs/api/handlers/settings": "./src/components/core/comms/api/cncserver.api.settings.js", + "cs/api/handlers/pen": "./src/components/core/comms/api/cncserver.api.pen.js", + "cs/api/handlers/motors": "./src/components/core/comms/api/cncserver.api.motors.js", + "cs/api/handlers/buffer": "./src/components/core/comms/api/cncserver.api.buffer.js", + "cs/api/handlers/tools": "./src/components/core/comms/api/cncserver.api.tools.js", + "cs/api/handlers/colors": "./src/components/core/comms/api/cncserver.api.colors.js", + "cs/api/handlers/implements": "./src/components/core/comms/api/cncserver.api.implements.js", + "cs/api/handlers/projects": "./src/components/core/comms/api/cncserver.api.projects.js", + "cs/api/handlers/content": "./src/components/core/comms/api/cncserver.api.content.js", + "cs/api/handlers/print": "./src/components/core/comms/api/cncserver.api.print.js", + "cs/api/handlers/serial": "./src/components/core/comms/api/cncserver.api.serial.js", + "cs/bots": "./src/components/machine_support/index.js", + "cs/bots/base": "./src/components/machine_support/cncserver.bots.base.js", + "cs/bots/ebb": "./src/components/machine_support/cncserver.bots.ebb.js", + "cs/bots/watercolorbot": "./src/components/machine_support/cncserver.bots.watercolorbot.js", + "cs/drawing": "./src/components/core/drawing/index.js", + "cs/drawing/base": "./src/components/core/drawing/cncserver.drawing.base.js", + "cs/drawing/occlusion": "./src/components/core/drawing/cncserver.drawing.occlusion.js", + "cs/drawing/trace": "./src/components/core/drawing/cncserver.drawing.trace.js", + "cs/drawing/spawner": "./src/components/core/drawing/cncserver.drawing.spawner.js", + "cs/drawing/fill": "./src/components/core/drawing/cncserver.drawing.fill.js", + "cs/drawing/vectorize": "./src/components/core/drawing/cncserver.drawing.vectorize.js", + "cs/drawing/text": "./src/components/core/drawing/cncserver.drawing.text.js", + "cs/drawing/accell": "./src/components/core/drawing/cncserver.drawing.accell.js", + "cs/drawing/colors": "./src/components/core/drawing/cncserver.drawing.colors.js", + "cs/drawing/colors/matcher": "./src/interface/modules/utils/colorset-matcher.mjs", + "cs/drawing/implements": "./src/components/core/drawing/cncserver.drawing.implements.js", + "cs/drawing/stage": "./src/components/core/drawing/cncserver.drawing.stage.js", + "cs/drawing/preview": "./src/components/core/drawing/cncserver.drawing.preview.js", + "cs/drawing/temp": "./src/components/core/drawing/cncserver.drawing.temp.js", + "cs/schemas": "./src/components/core/schemas/cncserver.schemas.js", + "cs/schemas/index": "./src/components/core/schemas/index.js", + "cs/schemas/projects": "./src/components/core/schemas/cncserver.schemas.projects.js", + "cs/schemas/content": "./src/components/core/schemas/cncserver.schemas.content.js", + "cs/schemas/content/settings": "./src/components/core/schemas/cncserver.schemas.content.settings.js", + "cs/schemas/fill": "./src/components/core/schemas/cncserver.schemas.fill.js", + "cs/schemas/stroke": "./src/components/core/schemas/cncserver.schemas.stroke.js", + "cs/schemas/text": "./src/components/core/schemas/cncserver.schemas.text.js", + "cs/schemas/vectorize": "./src/components/core/schemas/cncserver.schemas.vectorize.js", + "cs/schemas/path": "./src/components/core/schemas/cncserver.schemas.path.js", + "cs/schemas/color": "./src/components/core/schemas/cncserver.schemas.color.js", + "cs/schemas/colors": "./src/components/core/schemas/cncserver.schemas.colors.js", + "cs/schemas/tools": "./src/components/core/schemas/cncserver.schemas.tools.js", + "cs/schemas/implements": "./src/components/core/schemas/cncserver.schemas.implements.js", + "cs/schemas/toolsets": "./src/components/core/schemas/cncserver.schemas.toolsets.js" + } +} diff --git a/package.json b/package.json index f8968e4b..497e2e5e 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,30 @@ { "name": "cncserver", - "version": "3.0.0-beta1", + "version": "3.0.0-beta.1", "description": "A web based a web based CNC Server/Controller to drive @makersylvia's WaterColorBot and beyond", "main": "cncserver.js", "author": "techninja", + "type": "module", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^5.9.0", - "@json-editor/json-editor": "^2.2.0", + "@json-editor/json-editor": "https://github.com/techninja/json-editor.git#no-undefined-data", + "@node-loader/import-maps": "^1.0.3", "ajv": "^6.12.0", "axios": "^0.19.0", "body-parser": "^1.17.1", "bootstrap": "^4.4.1", "bulma": "^0.7.5", "canvas": "^2.6.0", + "chroma-js": "^2.1.0", "connect-slashes": "techninja/connect-slashes#ignore-files", "data-uri-to-buffer": "^3.0.0", "datauri": "^2.0.0", "express": "~3.0.0", "font-list": "^1.2.9", "glob": "^7.1.6", - "hersheytext": "1", - "hybrids": "^4.0.3", + "hersheytext": "2", + "hybrids": "^4.2.1", "i18next": "^19.0.1", "i18next-express-middleware": "^1.8.2", "i18next-node-fs-backend": "^2.1.3", @@ -30,30 +33,34 @@ "jquery": "^3.4.1", "js-angusj-clipper": "^1.0.3", "jsonform": "^2.1.6", + "jsonpath": "^1.0.2", "merge-deep": "^3.0.2", "nconf": "0.8.4", - "nearest-color": "^0.4.4", "node-ipc": "8.9.2", "paper": "^0.12.3", "paper-jsdom": "^0.12.3", "path-to-regexp": "^1.7.0", + "png-metadata": "^1.0.2", "request": "*", "select2": "^4.0.12", "semver": "^6.3.0", - "serialport": "7.1.5", + "serialport": "^9", "snowpack": "^1.4.0", "socket.io": "^2.3.0", "underscore": "^1.9.2", "vectorize-text": "^3.2.1", - "weightless": "^0.0.37", "zodiac-ts": "^1.0.3" }, "devDependencies": { + "@jsenv/importmap-eslint-resolver": "^2.4.0", + "babel-eslint": "^10.1.0", "chai": "*", - "eslint": "^6.0.1", - "eslint-config-airbnb-base": "^13.2.0", - "eslint-plugin-import": "^2.18.0", - "mocha": "*" + "eslint": "^7.2.0", + "eslint-config-airbnb-base": "^14.2.0", + "eslint-plugin-import": "^2.22.0", + "mocha": "*", + "prettier": "^2.2.1", + "prettier-eslint": "^12.0.0" }, "engines": { "node": ">= 10.0.0" @@ -64,7 +71,68 @@ }, "scripts": { "lint": "eslint \"src/**/*.js\"", - "start": "node ./src/cncserver", + "start": "node --experimental-loader @node-loader/import-maps ./src/cncserver.js", + "test": "node --experimental-loader @node-loader/import-maps ./src/test.js", "prepare": "snowpack" + }, + "eslintConfig": { + "parser": "babel-eslint", + "parserOptions": { + "ecmaVersion": 2017 + }, + "extends": "airbnb-base", + "plugins": [ + "import" + ], + "rules": { + "no-restricted-syntax": "off", + "no-underscore-dangle": "off", + "no-plusplus": "off", + "import/extensions": "off", + "import/prefer-default-export": [ + 0 + ], + "import/no-unresolved": [ + 2, + { + "ignore": [ + "cs/*" + ] + } + ], + "max-len": [ + "error", + { + "code": 90 + } + ], + "comma-dangle": [ + "error", + { + "exports": "never", + "functions": "never", + "arrays": "always-multiline", + "objects": "always-multiline" + } + ], + "object-property-newline": [ + 2, + { + "allowAllPropertiesOnSameLine": true + } + ], + "arrow-parens": [ + 2, + "as-needed" + ], + "class-methods-use-this": "off", + "no-console": "off", + "no-multi-str": "off" + } + }, + "prettier": { + "singleQuote": true, + "trailingComma": "es5", + "arrowParens": "avoid" } } diff --git a/prettier.config.js b/prettier.config.js deleted file mode 100644 index a425d3f7..00000000 --- a/prettier.config.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - singleQuote: true, - trailingComma: 'es5', -}; diff --git a/src/cncserver.js b/src/cncserver.js index 6ea5a322..245448d8 100644 --- a/src/cncserver.js +++ b/src/cncserver.js @@ -18,57 +18,18 @@ * cncserver.start({ * error: function callback(err){ // ERROR! }, * success: function callback(){ // SUCCESS! }, - * disconnect: function callback(){ //BOT DISCONNECTED! } + * disconnect: function callback(){ // BOT DISCONNECTED! } * }; * */ -// CONFIGURATION =============================================================== -const cncserver = { // Master object for holding/passing stuff! - bot: {}, // Holds clean rendered settings set after botConfig is loaded - exports: {}, // This entire object will be added to the module.exports, -}; - -global.__basedir = __dirname; - -// Global Defaults (also used to write the initial config.ini) -cncserver.globalConfigDefaults = { - httpPort: 4242, - httpLocalOnly: true, - swapMotors: false, // Global setting for bots that don't have it configured - invertAxis: { - x: false, - y: false, - }, - maximumBlockingCallStack: 100, // Limit for the # blocking sequential calls - showSerial: false, // Specific debug to show serial data. - serialPath: '{auto}', // Empty for auto-config - bufferLatencyOffset: 30, // Number of ms to move each command closer together - corsDomain: '*', // Start as open to CORs enabled browser clients - debug: false, - botType: 'watercolorbot', - scratchSupport: true, - flipZToggleBit: false, - botOverride: { - info: 'Override bot settings E.G. > [botOverride.eggbot] servo:max = 1234', - }, -}; - -// COMPONENT REQUIRES ========================================================== +import 'cs/i18n'; +import { loadGlobalConfig, loadBotConfig } from 'cs/settings'; +import { initServer } from 'cs/ipc'; +import { connect, localTrigger } from 'cs/serial'; +import 'cs/bots'; -// Load all components. -const components = require('./components'); - -// TODO: Find a better way to do this. -for (const [key, path] of Object.entries(components)) { - // eslint-disable-next-line global-require, import/no-dynamic-require - const component = require(path)(cncserver); - cncserver[key] = component; - if (component && component.exports) { - cncserver.exports = { ...cncserver.exports, ...component.exports }; - delete component.exports; - } -} +// CONFIGURATION =============================================================== // TODO: Convert to promises instaed of endless callbacks and error management. @@ -94,29 +55,28 @@ for (const [key, path] of Object.entries(components)) { // INTIAL SETUP ================================================================ // Load the Global Configuration (from config, defaults & CL vars) -cncserver.settings.loadGlobalConfig(() => { - // Only if we're running standalone... try to start the server immediately! - if (!module.parent) { - // Load the bot specific configuration, defaulting to gConf bot type - cncserver.settings.loadBotConfig(() => { - cncserver.ipc.initServer({ localRunner: true }, () => { - // Runner is ready! Attempt Initial Serial Connection. - cncserver.serial.connect({ - error: () => { - console.error('CONNECTSERIAL ERROR!'); - cncserver.serial.localTrigger('simulationStart'); - cncserver.serial.localTrigger('serialReady'); - }, - connect: () => { - cncserver.serial.localTrigger('serialReady'); - }, - disconnect: () => { - cncserver.serial.localTrigger('serialClose'); - }, - }); +loadGlobalConfig(() => { + // Try to start the server immediately! + // Load the bot specific configuration, defaulting to gConf bot type + loadBotConfig(() => { + initServer({ localRunner: true }, () => { + // Runner is ready! Attempt Initial Serial Connection. + connect({ + error: () => { + console.error('CONNECTSERIAL ERROR!'); + localTrigger('simulationStart'); + localTrigger('serialReady'); + }, + connect: () => { + localTrigger('serialReady'); + }, + disconnect: () => { + localTrigger('serialClose'); + }, }); }); - } else { // Export the module's useful API functions! ================= + }); + /* } else { // Export the module's useful API functions! ================= // Enherit all module added exports. module.exports = cncserver.exports; @@ -131,7 +91,7 @@ cncserver.settings.loadGlobalConfig(() => { module.exports.penUpdateTrigger = options.penUpdate; } - cncserver.settings.loadBotConfig(() => { + loadBotConfig(() => { // Before we can attempt to connect to the serialport, we must ensure // The IPC runner is connected... @@ -175,5 +135,5 @@ cncserver.settings.loadGlobalConfig(() => { module.exports.serialReadyInit = () => { cncserver.serial.localTrigger('serialReady'); }; - } + } */ }); diff --git a/src/components/core/comms/api/cncserver.api.buffer.js b/src/components/core/comms/api/cncserver.api.buffer.js index 3b9330cc..13af4ab1 100644 --- a/src/components/core/comms/api/cncserver.api.buffer.js +++ b/src/components/core/comms/api/cncserver.api.buffer.js @@ -1,147 +1,156 @@ /** * @file CNCServer ReSTful API endpoint module for pen state management. */ -const handlers = {}; - -module.exports = (cncserver) => { - handlers['/v2/buffer'] = function bufferMain(req, res) { - const { buffer } = cncserver; - if (req.route.method === 'get' || req.route.method === 'put') { - // Pause/resume (normalize input) - if (typeof req.body.paused === 'string') { - req.body.paused = req.body.paused === 'true'; - } +import { + state as bufferState, + pause, + resume, + clear +} from 'cs/buffer'; +import { setHeight } from 'cs/pen'; +import { state as actualPenState } from 'cs/actualPen'; +import { actuallyMove, actuallyMoveHeight } from 'cs/control'; +import { gConf } from 'cs/settings'; +import { sendBufferVars } from 'cs/sockets'; +import run from 'cs/run'; +import { trigger } from 'cs/binder'; + +export const handlers = {}; + +handlers['/v2/buffer'] = function bufferMain(req, res) { + if (req.route.method === 'get' || req.route.method === 'put') { + // Pause/resume (normalize input) + if (typeof req.body.paused === 'string') { + req.body.paused = req.body.paused === 'true'; + } - if (typeof req.body.paused === 'boolean') { - if (req.body.paused !== buffer.paused) { - // If pausing, trigger immediately. - // Resuming can't do this if returning from another position. - if (req.body.paused) { - buffer.pause(); - cncserver.pen.setHeight('up', null, true); // Pen up for safety! - console.log('Run buffer paused!'); - } else { - console.log('Resume to begin shortly...'); - } - - // Changed to paused! - buffer.newlyPaused = req.body.paused; + if (typeof req.body.paused === 'boolean') { + if (req.body.paused !== bufferState.paused) { + // If pausing, trigger immediately. + // Resuming can't do this if returning from another position. + if (req.body.paused) { + pause(); + setHeight('up', null, true); // Pen up for safety! + console.log('Run buffer paused!'); + } else { + console.log('Resume to begin shortly...'); } + + // Changed to paused! + bufferState.newlyPaused = req.body.paused; } + } - // Did we actually change position since pausing? - let changedSincePause = false; - if (buffer.pausePen) { - if (buffer.pausePen.x !== cncserver.actualPen.state.x - || buffer.pausePen.y !== cncserver.actualPen.state.y - || buffer.pausePen.height !== cncserver.actualPen.state.height) { - changedSincePause = true; - console.log('CHANGED SINCE PAUSE'); - } else if (!req.body.paused) { - // If we're resuming, and there's no change... clear the pause pen - console.log('RESUMING NO CHANGE!'); - } + // Did we actually change position since pausing? + let changedSincePause = false; + if (bufferState.pausePen && req.route.method === 'put') { + if (bufferState.pausePen.x !== actualPenState.x + || bufferState.pausePen.y !== actualPenState.y + || bufferState.pausePen.height !== actualPenState.height) { + changedSincePause = true; + console.log('CHANGED SINCE PAUSE'); + } else if (!req.body.paused) { + // If we're resuming, and there's no change... clear the pause pen + console.log('RESUMING NO CHANGE!'); } + } - // Resuming? - if (!req.body.paused) { - // Move back to position we paused at (if changed). - if (changedSincePause) { - // Remain paused until we've finished... - console.log('Moving back to pre-pause position...'); - - // Set the pen up before moving to resume position - cncserver.pen.setHeight('up', () => { - cncserver.control.actuallyMove(buffer.pausePen, () => { - // Set the height back to what it was AFTER moving - cncserver.control.actuallyMoveHeight( - buffer.pausePen.height, - buffer.pausePen.state, - () => { - console.log('Resuming buffer!'); - buffer.resume(); - - res.status(200).send(JSON.stringify({ - running: buffer.running, - paused: buffer.paused, - count: buffer.data.length, - buffer: "This isn't a great idea...", // TODO: FIX << - })); - - if (cncserver.settings.gConf.get('debug')) { - console.log('>RESP', req.route.path, '200'); - } + // Resuming? + if (!req.body.paused) { + // Move back to position we paused at (if changed). + if (changedSincePause) { + // Remain paused until we've finished... + console.log('Moving back to pre-pause position...'); + + // Set the pen up before moving to resume position + setHeight('up', () => { + actuallyMove(bufferState.pausePen, () => { + // Set the height back to what it was AFTER moving + actuallyMoveHeight( + bufferState.pausePen.height, + bufferState.pausePen.state, + () => { + console.log('Resuming buffer!'); + resume(); + + res.status(200).send(JSON.stringify({ + running: bufferState.running, + paused: bufferState.paused, + count: bufferState.data.length, + buffer: "This isn't a great idea...", // TODO: FIX << + })); + + if (gConf.get('debug')) { + console.log('>RESP', req.route.path, '200'); } - ); - }); - }, true); // Skipbuffer on setheight! - - return true; // Don't finish the response till after move back ^^^ - } + } + ); + }); + }, true); // Skipbuffer on setheight! - // Plain resume. - buffer.resume(); + return true; // Don't finish the response till after move back ^^^ } - // In case paused with 0 items in buffer... - if (!buffer.newlyPaused || buffer.data.length === 0) { - buffer.newlyPaused = false; - cncserver.sockets.sendBufferVars(); - return { - code: 200, - body: { - running: buffer.running, - paused: buffer.paused, - count: buffer.data.length, - }, - }; - } + // Plain resume. + resume(); + } - // Buffer isn't empty and we're newly paused - // Wait until last item has finished before returning - console.log('Waiting for last item to finish...'); - - buffer.pauseCallback = () => { - res.status(200).send(JSON.stringify({ - running: buffer.running, - paused: buffer.paused, - count: buffer.length, - })); - cncserver.sockets.sendBufferVars(); - buffer.newlyPaused = false; - - if (cncserver.settings.gConf.get('debug')) { - console.log('>RESP', req.route.path, 200); - } + // In case paused with 0 items in buffer... + if (!bufferState.newlyPaused || bufferState.data.length === 0) { + bufferState.newlyPaused = false; + sendBufferVars(); + return { + code: 200, + body: { + running: bufferState.running, + paused: bufferState.paused, + count: bufferState.data.length, + }, }; - - return true; // Don't finish the response till later } - if (req.route.method === 'post') { - // Create a status message/callback and shuck it into the buffer - if (typeof req.body.message === 'string') { - cncserver.run('message', req.body.message); - return [200, 'Message added to buffer']; + // Buffer isn't empty and we're newly paused + // Wait until last item has finished before returning + console.log('Waiting for last item to finish...'); + + bufferState.pauseCallback = () => { + res.status(200).send(JSON.stringify({ + running: bufferState.running, + paused: bufferState.paused, + count: bufferState.length, + })); + sendBufferVars(); + bufferState.newlyPaused = false; + + if (gConf.get('debug')) { + console.log('>RESP', req.route.path, 200); } + }; - if (typeof req.body.callback === 'string') { - cncserver.run('callbackname', req.body.callback); - return [200, 'Callback name added to buffer']; - } + return true; // Don't finish the response till later + } - return [400, '/v2/buffer POST only accepts "message" or "callback"']; + if (req.route.method === 'post') { + // Create a status message/callback and shuck it into the buffer + if (typeof req.body.message === 'string') { + run('message', req.body.message); + return [200, 'Message added to buffer']; } - if (req.route.method === 'delete') { - cncserver.binder.trigger('buffer.clear'); - buffer.clear(); - return [200, 'Buffer Cleared']; + if (typeof req.body.callback === 'string') { + run('callbackname', req.body.callback); + return [200, 'Callback name added to buffer']; } - // Error to client for unsupported request types. - return false; - }; + return [400, '/v2/buffer POST only accepts "message" or "callback"']; + } + + if (req.route.method === 'delete') { + trigger('buffer.clear'); + clear(); + return [200, 'Buffer Cleared']; + } - return handlers; + // Error to client for unsupported request types. + return false; }; diff --git a/src/components/core/comms/api/cncserver.api.colors.js b/src/components/core/comms/api/cncserver.api.colors.js index cdce1fca..bc391d7a 100644 --- a/src/components/core/comms/api/cncserver.api.colors.js +++ b/src/components/core/comms/api/cncserver.api.colors.js @@ -1,92 +1,159 @@ /** * @file CNCServer ReSTful API endpoint module for colorset management. */ -const handlers = {}; +import * as colors from 'cs/drawing/colors'; +import { validateData } from 'cs/schemas'; +import { err } from 'cs/rest'; +import { + deletePreset, + isValidPreset, + merge, + singleLineString +} from 'cs/utils'; -module.exports = (cncserver) => { - handlers['/v2/colors'] = (req) => { - const { drawing: { colors } } = cncserver; +export const handlers = {}; - if (req.route.method === 'get') { // Get current colorset and - return { - code: 200, - body: { - set: colors.set, - presets: colors.listPresets(req.t), - }, - }; +// Primary Colorset group handler. +handlers['/v2/colors'] = (req, res) => { + // Standard post resolve for colors requests. + function postResolve() { + res.status(200).send({ + set: colors.getCurrentSet(req.t), // The current colorset. + presets: colors.listPresets(req.t), // List all presets. + customs: colors.customKeys(), // List of custom preset machine names. + internals: colors.internalKeys(), // List of internal preset machine names. + invalidSets: colors.invalidPresets(), // Keys of invalid colorsets. + }); + } + + // Get current colorset and presets. + if (req.route.method === 'get') { + postResolve(); + return true; // Tell endpoint wrapper we'll handle the GET response. + } + + // Add color, or replace set from preset. + if (req.route.method === 'post') { + // Set via preset, only err here is 404 preset not found. + if (req.body.preset) { + colors.applyPreset(req.body.preset, req.t) + .then(postResolve) + .catch(err(res, 404)); + } else { + // Validate data and add color item, or error out. + validateData('color', req.body, true) + .then(isValidPreset('implement', true)) + .then(colors.add) + .then(postResolve) + .catch(err(res)); } - if (req.route.method === 'post') { // Set color/preset - if (req.body.preset) { - if (!colors.applyPreset(req.body.preset)) { - return { - code: 404, - body: { - status: `Preset with id of '${req.body.preset}' not found in preset list.`, - validOptions: Object.keys(colors.presets), - }, - }; - } - } else if (!req.body.id || !req.body.name || !req.body.color) { - return [ - 406, - 'Must include valid id, name and color, see color documentation API docs', - ]; - } else if (!colors.add(req.body)) { - return [ - 406, - `Color with id ${req.body.id} already exists, update it directly or change id`, - ]; - } + return true; // Tell endpoint wrapper we'll handle the POST response. + } + // Allow deleting of custom presets. + if (req.route.method === 'delete' && req.body.preset) { + const customKeys = colors.customKeys(); + if (!customKeys.includes(req.body.preset)) { return { - code: 200, + code: 406, body: { - set: colors.set, - presets: colors.presets, + status: 'Only custom or overridden presets can be deleted.', + allowedValues: customKeys, }, }; } - return false; - }; + // Preset found, delete it! + deletePreset('colorsets', req.body.preset); + postResolve(); + return true; // Tell endpoint wrapper we'll handle the GET response. + } + + // Change set options directly. + if (req.route.method === 'patch') { + // If item data is attempted to be changed here, give a specific message for it. + if (req.body.items) { + err(res, 406)( + new Error(singleLineString`Patching the colors endpoint can only edit the + current set details, not individual colorset items. + Patch to /v2/colors/[ID] to edit a colorset item.`) + ); + } + + // Merge with current set, validate data, then edit. + const set = colors.getCurrentSet(); + delete set.items; + const mergedItem = merge(set, req.body); + validateData('colors', mergedItem, true) + .then(isValidPreset('implement')) + .then(isValidPreset('toolset')) + .then(colors.editSet) + .then(postResolve) + .catch(err(res)); - handlers['/v2/colors/:colorID'] = (req) => { - // Sanity check color ID - const { colorID } = req.params; - const { drawing: { colors } } = cncserver; - const color = colors.getColor(colorID); + return true; // Tell endpoint wrapper we'll handle the PATCH response. + } - if (!color) { + return false; +}; + +// Colorset item handler. +handlers['/v2/colors/:colorID'] = (req, res) => { + // Sanity check color ID + const { colorID } = req.params; + + // TODO: Apply translation here?... + const color = colors.getColor(colorID); + + if (!color) { + return { + code: 404, + body: { + status: `Color with id of '${colorID}' not found in color set.`, + allowedValues: colors.getIDs(), + }, + }; + } + + // Display the color info. + if (req.route.method === 'get') { + return { code: 200, body: color }; + } + + // Patch item. + if (req.route.method === 'patch') { + // Error out if client is trying to change the ID in the request. + if (req.body.id && req.body.id !== colorID) { return { - code: 404, + code: 406, body: { - status: `Color with id of '${colorID}' not found in color set.`, - validOptions: colors.getIDs(), + status: 'error', + message: singleLineString`You cannot rewrite a colorset ID in a patch. + Delete item and recreate.`, }, }; } - // Display the color info. - if (req.route.method === 'get') { - return { code: 200, body: color }; - } + // Merge the incoming data with the existing object as we don't need delta. + const mergedItem = merge(color, req.body); - // Update color info - if (req.route.method === 'put') { - return { code: 200, body: colors.update(colorID, req.body) }; - } + // Validate the request data against the schema before continuing. + validateData('color', mergedItem, true) + .then(isValidPreset('implement', true)) + .then(colors.edit) + .then(finalItem => { res.status(200).send(finalItem); }) + .catch(err(res)); - // Delete color - if (req.route.method === 'delete') { - colors.delete(color); - return { code: 200, body: { set: colors.set, presets: colors.presets } }; - } + return true; // Tell endpoint wrapper we'll handle the PATCH response. + } - // Error to client for unsupported request types. - return false; - }; + // Delete color + if (req.route.method === 'delete') { + colors.deleteColor(colorID); + return { code: 200, body: { status: 'success' } }; + } - return handlers; + // Error to client for unsupported request types. + return false; }; diff --git a/src/components/core/comms/api/cncserver.api.content.js b/src/components/core/comms/api/cncserver.api.content.js index 052024e7..2e529a62 100644 --- a/src/components/core/comms/api/cncserver.api.content.js +++ b/src/components/core/comms/api/cncserver.api.content.js @@ -1,101 +1,82 @@ /** * @file CNCServer ReSTful API endpoint for high level project content. */ -const handlers = {}; - -module.exports = (cncserver) => { - handlers['/v2/content'] = (req, res) => { - const { content } = cncserver; - - // Enumerate content. - if (req.route.method === 'get') { - return { - code: 200, - body: { - items: content.getItems(), - }, - }; - } - - // Create a piece of content. - if (req.route.method === 'post') { - // Validate the request data against the schema before continuing. - cncserver.schemas.validateData('content', req.body) - .then(body => content.normalizeInput(body)) - .then(content.addItem) - .then((item) => { res.status(200).send(item); }) - .catch((err) => { - const errBody = { - status: 'error', - message: err.message || err, - }; - - if (err.stack) errBody.stack = err.stack.split('\n'); - res.status(406).send(errBody); - }); - - return true; // Tell endpoint wrapper we'll handle the response - } - - // Error to client for unsupported request types. - return false; - }; - - - // Individual content management. - handlers['/v2/content/:hash'] = (req, res) => { - const { hash } = req.params; - const { content, utils } = cncserver; - const item = content.getResponseItem(hash); - - // Sanity check hash lookup. - if (!item) { - return [404, `Content with hash ID "${hash}" not found`]; - } +import * as content from 'cs/content'; +import { merge } from 'cs/utils'; +import { validateData } from 'cs/schemas'; +import { err } from 'cs/rest'; + +export const handlers = {}; + +handlers['/v2/content'] = (req, res) => { + // Enumerate content. + if (req.route.method === 'get') { + return { + code: 200, + body: { + items: content.getItems(), + }, + }; + } + + // Create a piece of content. + if (req.route.method === 'post') { + // Validate the request data against the schema before continuing. + validateData('content', req.body) + .then(body => content.normalizeInput(body)) + .then(content.addItem) + .then(item => { res.status(200).send(item); }) + .catch(err(res)); + + return true; // Tell endpoint wrapper we'll handle the response + } + + // Error to client for unsupported request types. + return false; +}; - // Display item. - if (req.route.method === 'get') { - return { - code: 200, - body: item, - }; +// Individual content management. +handlers['/v2/content/:hash'] = (req, res) => { + const { hash } = req.params; + const item = content.getResponseItem(hash); + + // Sanity check hash lookup. + if (!item) { + return [404, `Content with hash ID "${hash}" not found`]; + } + + // Display item. + if (req.route.method === 'get') { + return { + code: 200, + body: item, + }; + } + + // Patch item. + if (req.route.method === 'patch') { + // Validate the request data against the schema before continuing. + const mergedItem = merge(item, req.body); + + // If a new source is given, replace the entire item in merged. + if (req.body.source) { + mergedItem.source = req.body.source; } - // Patch item. - if (req.route.method === 'patch') { - // Validate the request data against the schema before continuing. - const mergedItem = utils.merge(item, req.body); - - // If a new source is given, replace the entire item in merged. - if (req.body.source) { - mergedItem.source = req.body.source; - } - - cncserver.schemas.validateData('content', mergedItem) - .then(() => content.editItem(item, req.body, mergedItem)) - .then((finalItem) => { res.status(200).send(finalItem); }) - .catch((err) => { - const errBody = { - status: 'error', - message: err.message || err, - }; + validateData('content', mergedItem) + .then(() => content.editItem(item, req.body, mergedItem)) + .then(finalItem => { res.status(200).send(finalItem); }) + .catch(err(res)); - if (err.stack) errBody.stack = err.stack.split('\n'); - res.status(406).send(errBody); - }); - - return true; // Tell endpoint wrapper we'll handle the response - } - - // Remove item. - if (req.route.method === 'delete') { - content.removeItem(hash); - return [200, `Content identified by hash "${hash}" removed`]; - } + return true; // Tell endpoint wrapper we'll handle the response + } - // Error to client for unsupported request types. - return false; - }; + // Remove item. + if (req.route.method === 'delete') { + content.removeItem(hash); + return [200, `Content identified by hash "${hash}" removed`]; + } - return handlers; + // Error to client for unsupported request types. + return false; }; diff --git a/src/components/core/comms/api/cncserver.api.implements.js b/src/components/core/comms/api/cncserver.api.implements.js new file mode 100644 index 00000000..5ac2812a --- /dev/null +++ b/src/components/core/comms/api/cncserver.api.implements.js @@ -0,0 +1,125 @@ +/** + * @file CNCServer ReSTful API endpoint module for implement management. + */ +import { + listPresets, + customKeys, + internalKeys, + edit, + get, + add, + deleteImplement, + IMPLEMENT_PARENT +} from 'cs/drawing/implements'; +import { colors } from 'cs/drawing'; +import { validateData } from 'cs/schemas'; +import { singleLineString, merge } from 'cs/utils'; +import { err } from 'cs/rest'; + +export const handlers = {}; + +// Primary group handler. +handlers['/v2/implements'] = (req, res) => { + // Standard post resolve for colors requests. + function postResolve() { + res.status(200).send({ + presets: listPresets(req.t), + customs: customKeys(), // List of custom preset machine names. + internals: internalKeys(), // List of internal preset machine names. + }); + } + + // Get presets. + if (req.route.method === 'get') { + postResolve(); + return true; // Tell endpoint wrapper we'll handle the GET response. + } + + // Add implement. + if (req.route.method === 'post') { + // Validate data and add implement, or error out. + validateData('drawing.implements', req.body, true) + .then(add) + .then(postResolve) + .catch(err(res)); + + return true; // Tell endpoint wrapper we'll handle the POST response. + } + + // Error to client for unsupported request types. + return false; +}; + +// Item handler. +handlers['/v2/implements/:implementName'] = (req, res) => { + // Sanity check color ID + let { implementName } = req.params; + + // Allow inherit to redirect to colorset parent default. + if (implementName === IMPLEMENT_PARENT) { + implementName = colors.set.implement; + } + + const implement = get(implementName); + + if (!implement) { + return { + code: 404, + body: { + status: `Implement with name of '${implementName}' not found.`, + validOptions: Object.keys(listPresets(req.t)), + }, + }; + } + + // Display the item. + if (req.route.method === 'get') { + return { code: 200, body: implement }; + } + + // Patch item. Editing internal presets saves a new custom with override data. + if (req.route.method === 'patch') { + if (req.body.name) { + return { + code: 406, + body: { + status: 'error', + message: singleLineString`You cannot rewrite an implement name in a patch. + Delete item and recreate.`, + }, + }; + } + + // Merge the incoming data with the existing object as we don't need delta. + const mergedItem = merge(implement, req.body); + + // Validate the request data against the schema before continuing. + validateData('implements', mergedItem, true) + .then(edit) + .then(finalItem => { res.status(200).send(finalItem); }) + .catch(err(res)); + + return true; // Tell endpoint wrapper we'll handle the PATCH response. + } + + // Delete item. + if (req.route.method === 'delete') { + // Notify client that they cannot delete internal presets. + if (!get(implementName, true)) { + return { + code: 406, + body: { + status: 'You cannot delete internal presets.', + validOptions: Object.keys(listPresets(req.t, true)), + }, + }; + } + + // Actually delete. + deleteImplement(implementName); + return { code: 200, body: { status: 'success' } }; + } + + // Error to client for unsupported request types. + return false; +}; diff --git a/src/components/core/comms/api/cncserver.api.motors.js b/src/components/core/comms/api/cncserver.api.motors.js index f8ac395f..64ab98c5 100644 --- a/src/components/core/comms/api/cncserver.api.motors.js +++ b/src/components/core/comms/api/cncserver.api.motors.js @@ -1,62 +1,63 @@ /** * @file CNCServer ReSTful API endpoint module for pen state management. */ -const handlers = {}; - -module.exports = (cncserver) => { - handlers['/v2/motors'] = function motorsMain(req) { - // Disable/unlock motors - if (req.route.method === 'delete') { - cncserver.run('custom', cncserver.buffer.cmdstr('disablemotors')); - return [201, 'Disable Queued']; - } - - if (req.route.method === 'put') { - if (parseInt(req.body.reset, 10) === 1) { - // ZERO motor position to park position - const park = cncserver.utils.centToSteps(cncserver.settings.bot.park, true); - // It is at this point assumed that one would *never* want to do this as - // a buffered operation as it implies *manually* moving the bot to the - // parking location, so we're going to man-handle the variables a bit. - // completely not repecting the buffer (as really, it should be empty) - - // EDIT: There are plenty of queued operations that don't involve moving - // the pen that make sense to have in the buffer after a zero operation, - // not to mention if there are items in the queue during a pause, we - // should still want the ability to do this. - - // Set tip of buffer to current - cncserver.pen.forceState({ +import { forceState } from 'cs/pen'; +import * as actualPen from 'cs/actualPen'; +import { bot, gConf } from 'cs/settings'; +import { cmdstr } from 'cs/buffer'; +import { centToSteps } from 'cs/utils'; +import run from 'cs/run'; + +export const handlers = {}; + +handlers['/v2/motors'] = function motorsMain(req) { + // Disable/unlock motors + if (req.route.method === 'delete') { + run('custom', cmdstr('disablemotors')); + return [201, 'Disable Queued']; + } + + if (req.route.method === 'put') { + if (parseInt(req.body.reset, 10) === 1) { + // ZERO motor position to park position + const park = centToSteps(bot.park, true); + // It is at this point assumed that one would *never* want to do this as + // a buffered operation as it implies *manually* moving the bot to the + // parking location, so we're going to man-handle the variables a bit. + // completely not repecting the buffer (as really, it should be empty) + + // EDIT: There are plenty of queued operations that don't involve moving + // the pen that make sense to have in the buffer after a zero operation, + // not to mention if there are items in the queue during a pause, we + // should still want the ability to do this. + + // Set tip of buffer to current + forceState({ + x: park.x, + y: park.y, + }); + + run('callback', () => { + // Set actualPen position. This is the ONLY place we set this value + // without a movement, because it's assumed to have been moved there + // physically by a user. Also we're assuming they did it instantly! + actualPen.forceState({ x: park.x, y: park.y, + lastDuration: 0, }); - cncserver.run('callback', () => { - // Set actualPen position. This is the ONLY place we set this value - // without a movement, because it's assumed to have been moved there - // physically by a user. Also we're assuming they did it instantly! - cncserver.actualPen.forceState({ - x: park.x, - y: park.y, - lastDuration: 0, - }); - - cncserver.sockets.sendPenUpdate(); - - if (cncserver.settings.gConf.get('debug')) { - console.log('Motor offset reset to park position'); - } - }); - - return [201, 'Motor offset reset to park position queued']; - } + if (gConf.get('debug')) { + console.log('Motor offset reset to park position'); + } + }); - return [406, 'Input not acceptable, see API spec for details.']; + return [201, 'Motor offset reset to park position queued']; } - // Error to client for unsupported request types. - return false; - }; + return [406, 'Input not acceptable, see API spec for details.']; + } - return handlers; + // Error to client for unsupported request types. + return false; }; diff --git a/src/components/core/comms/api/cncserver.api.pen.js b/src/components/core/comms/api/cncserver.api.pen.js index d1e8a972..0de33b4a 100644 --- a/src/components/core/comms/api/cncserver.api.pen.js +++ b/src/components/core/comms/api/cncserver.api.pen.js @@ -1,90 +1,90 @@ /** * @file CNCServer ReSTful API endpoint module for pen state management. */ -const handlers = {}; - -module.exports = (cncserver) => { - handlers['/v2/pen'] = function penMain(req, res) { - if (req.route.method === 'put') { - // Verify absolute measurement input. - if (req.body.abs) { - if (!['in', 'mm'].includes(req.body.abs)) { - return [ - 406, - 'Input not acceptable, absolute measurement must be: in, mm', - ]; - } - - if (!cncserver.settings.bot.maxAreaMM) { - return [ - 406, - 'Input not acceptable, bot does not support absolute position.', - ]; - } +import { gConf, bot } from 'cs/settings'; +import { setPen, state, park } from 'cs/pen'; +import * as actualPen from 'cs/actualPen'; + +export const handlers = {}; + +handlers['/v2/pen'] = function penMain(req, res) { + if (req.route.method === 'put') { + // Verify absolute measurement input. + if (req.body.abs) { + if (!['in', 'mm'].includes(req.body.abs)) { + return [ + 406, + 'Input not acceptable, absolute measurement must be: in, mm', + ]; } - // SET/UPDATE pen status - cncserver.pen.setPen(req.body, (stat) => { - let code = 202; - let body = {}; - - if (!stat) { - code = 500; - body.status = 'Error setting pen!'; - } else { - // Wait return. - if (req.body.waitForCompletion) { - code = 200; - } - body = cncserver.pen.state; - } - - body = JSON.stringify(body); - res.status(code).send(body); - - if (cncserver.settings.gConf.get('debug')) { - console.log('>RESP', req.route.path, code, body); - } - }); - - return true; // Tell endpoint wrapper we'll handle the response + if (!bot.maxAreaMM) { + return [ + 406, + 'Input not acceptable, bot does not support absolute position.', + ]; + } } - if (req.route.method === 'delete') { - // Reset pen to defaults (park) - cncserver.pen.park(req.body.skipBuffer, (stat) => { - let code = 200; - let body = {}; - - if (!stat) { - code = 500; - body.status = 'Error parking pen!'; - } else { - body = cncserver.pen.state; + // SET/UPDATE pen status + setPen(req.body, stat => { + let code = 202; + let body = {}; + + if (!stat) { + code = 500; + body.status = 'Error setting pen!'; + } else { + // Wait return. + if (req.body.waitForCompletion) { + code = 200; } + body = state; + } - body = JSON.stringify(body); - res.status(code).send(body); + body = JSON.stringify(body); + res.status(code).send(body); - if (cncserver.settings.gConf.get('debug')) { - console.log('>RESP', req.route.path, code, body); - } - }); + if (gConf.get('debug')) { + console.log('>RESP', req.route.path, code, body); + } + }); + + return true; // Tell endpoint wrapper we'll handle the response + } + + if (req.route.method === 'delete') { + // Reset pen to defaults (park) + park(req.body.skipBuffer, stat => { + let code = 200; + let body = {}; + + if (!stat) { + code = 500; + body.status = 'Error parking pen!'; + } else { + body = state; + } - return true; // Tell endpoint wrapper we'll handle the response - } + body = JSON.stringify(body); + res.status(code).send(body); - if (req.route.method === 'get') { - if (req.query.actual) { - return { code: 200, body: cncserver.actualPen.state }; + if (gConf.get('debug')) { + console.log('>RESP', req.route.path, code, body); } + }); + + return true; // Tell endpoint wrapper we'll handle the response + } - return { code: 200, body: cncserver.pen.state }; + if (req.route.method === 'get') { + if (req.query.actual) { + return { code: 200, body: actualPen.state }; } - // Error to client for unsupported request types. - return false; - }; + return { code: 200, body: state }; + } - return handlers; + // Error to client for unsupported request types. + return false; }; diff --git a/src/components/core/comms/api/cncserver.api.print.js b/src/components/core/comms/api/cncserver.api.print.js new file mode 100644 index 00000000..f90cfc1b --- /dev/null +++ b/src/components/core/comms/api/cncserver.api.print.js @@ -0,0 +1,23 @@ +/** + * @file CNCServer ReSTful API endpoint module for pen state management. + */ +import * as print from 'cs/print'; + +export const handlers = {}; + +// TODO: +// - GET /: List all prints for all projects +// - GET /[PROJECT HASH]: All prints for project +// - GET /[PROJECT HASH]/[PRINT_HASH]: All data for specific print +// - PUT /[PROJECT HASH]/[PRINT_HASH]: Edit only title, desc for a print. +// - POST /[PROJECT-HASH] or /: Create print render with given settings. +// - DELETE /[PROJECT HASH]/[PRINT_HASH]: Delete a given print +handlers['/v2/print'] = function printMain(req) { + if (req.route.method === 'get') { + print.getPrintData(); + return [200, 'Check the file']; + } + + // Error to client for unsupported request types. + return false; +}; diff --git a/src/components/core/comms/api/cncserver.api.projects.js b/src/components/core/comms/api/cncserver.api.projects.js index 7bd0f9ca..23d4625a 100644 --- a/src/components/core/comms/api/cncserver.api.projects.js +++ b/src/components/core/comms/api/cncserver.api.projects.js @@ -1,143 +1,120 @@ /** * @file CNCServer ReSTful API endpoint for high level project management. */ -const handlers = {}; - -module.exports = (cncserver) => { - handlers['/v2/projects'] = (req, res) => { - const { projects } = cncserver; - - // Standard projects api return. - const projectsBody = () => ({ - current: projects.getCurrentHash(), - rendering: projects.getRenderingState(), - printing: projects.getPrintingState(), - items: projects.getItems(), - }); - - // Enumerate projects. - if (req.route.method === 'get') { - return { code: 200, body: projectsBody() }; - } - - // Create a project. - if (req.route.method === 'post') { - // Validate the request data against the schema before continuing. - cncserver.schemas.validateData('projects', req.body, true) - .then(body => projects.addItem(body)) - .then((item) => { res.status(200).send(item); }) - .catch((err) => { - console.error('Error on Projects request:', err); - const errBody = { - status: 'error', - message: err, - }; - - if (err.stack) errBody.stack = err.stack; - res.status(406).send(errBody); - }); - - return true; // Tell endpoint wrapper we'll handle the response - } +import * as projects from 'cs/projects'; +import { validateData } from 'cs/schemas'; +import { err } from 'cs/rest'; + +export const handlers = {}; + +handlers['/v2/projects'] = (req, res) => { + // Standard projects api return. + const projectsBody = () => ({ + current: projects.getCurrentHash(), + rendering: projects.getRenderingState(), + printing: projects.getPrintingState(), + items: projects.getItems(), + }); + + // Enumerate projects. + if (req.route.method === 'get') { + return { code: 200, body: projectsBody() }; + } + + // Create a project. + if (req.route.method === 'post') { + // Validate the request data against the schema before continuing. + validateData('projects', req.body, true) + .then(projects.addItem) + .then(item => { res.status(200).send(item); }) + .catch(err(res)); + + return true; // Tell endpoint wrapper we'll handle the response + } + + // Allow modification of general project management state: + // "current": Open a project by patching "current" key with project hash. + // "rendering": Start rending by setting to true. + // "printing": Start printing render by setting to true. + if (req.route.method === 'patch') { + const { current, rendering, printing } = req.body || {}; - // Allow modification of general project management state: - // "current": Open a project by patching "current" key with project hash. - // "rendering": Start rending by setting to true. - // "printing": Start printing render by setting to true. - if (req.route.method === 'patch') { - const { current, rendering, printing } = req.body || {}; - - // Sanity check hash lookup. - if (current) { - if (!projects.items.has(current)) { - return [404, `Project with hash ID "${current}" not found`]; - } - if (projects.getCurrentHash() === current) { - return [302, 'Project already loaded.']; - } - - // Actually open the project. - projects.open(current); - } else if (rendering !== undefined) { - projects.setRenderingState(rendering); - } else if (printing !== undefined) { - projects.setPrintingState(printing); - } else { - // Nothing sent right, let em know. - return [406, 'Patch either "current" hash to open, or new "rendering" or "printing" state.']; + // Sanity check hash lookup. + if (current) { + if (!projects.items.has(current)) { + return [404, `Project with hash ID "${current}" not found`]; + } + if (projects.getCurrentHash() === current) { + return [302, 'Project already loaded.']; } - // Return the full new projects return body. - return { code: 200, body: projectsBody() }; - } - - // Error to client for unsupported request types. - return false; - }; - - - // Individual project management. - handlers['/v2/projects/:hash'] = (req, res) => { - const { projects } = cncserver; - - // Shortcut "current" hash lookup. - const hash = req.params.hash === 'current' - ? projects.getCurrentHash() - : req.params.hash; - - // Sanity check hash lookup. - if (!projects.items.has(hash)) { - return [404, `Project with hash ID "${hash}" not found`]; + // Actually open the project. + projects.openProject(current); + } else if (rendering !== undefined) { + projects.setRenderingState(rendering); + } else if (printing !== undefined) { + projects.setPrintingState(printing); + } else { + // Nothing sent right, let em know. + return [ + 406, + 'Patch either "current" hash to open, or new "rendering" or "printing" state.', + ]; } - // Get the project via validated hash. - const project = projects.getResponseItem(hash); + // Return the full new projects return body. + return { code: 200, body: projectsBody() }; + } - // Display item. - if (req.route.method === 'get') { - return { code: 200, body: project }; - } + // Error to client for unsupported request types. + return false; +}; - // Patch item. - if (req.route.method === 'patch') { - // Validate the request data against the schema before continuing. - const mergedProject = { ...project, ...req.body }; - cncserver.schemas.validateData('projects', mergedProject) - .then(() => projects.editItem(project, req.body)) - .then((item) => { res.status(200).send(item); }) - .catch((err) => { - const errBody = { - status: 'error', - message: err, - }; - - if (err.stack) errBody.stack = err.stack; - res.status(406).send(errBody); +// Individual project management. +handlers['/v2/projects/:hash'] = (req, res) => { + // Shortcut "current" hash lookup. + const hash = req.params.hash === 'current' + ? projects.getCurrentHash() + : req.params.hash; + + // Sanity check hash lookup. + if (!projects.items.has(hash)) { + return [404, `Project with hash ID "${hash}" not found`]; + } + + // Get the project via validated hash. + const project = projects.getResponseItem(hash); + + // Display item. + if (req.route.method === 'get') { + return { code: 200, body: project }; + } + + // Patch item. + if (req.route.method === 'patch') { + // Validate the request data against the schema before continuing. + const mergedProject = { ...project, ...req.body }; + validateData('projects', mergedProject) + .then(() => projects.editItem(project, req.body)) + .then(item => { res.status(200).send(item); }) + .catch(err(res)); + + return true; // Tell endpoint wrapper we'll handle the response + } + + // Remove item. + if (req.route.method === 'delete') { + projects.removeItem(hash).then(() => { + res + .status(200) + .send({ + status: `Project identified by "${hash}" moved to trash directory`, }); + }).catch(err(res, 500)); - return true; // Tell endpoint wrapper we'll handle the response - } - - // Remove item. - if (req.route.method === 'delete') { - projects.removeItem(hash).then(() => { - res.status(200).send({ status: `Project identified by "${hash}" moved to trash directory` }); - }).catch((err) => { - const errBody = { - status: 'error', - message: err, - }; - - if (err.stack) errBody.stack = err.stack; - res.status(500).send(errBody); - }); - - return true; // Tell endpoint wrapper we'll handle the response - } - - // Error to client for unsupported request types. - return false; - }; + return true; // Tell endpoint wrapper we'll handle the response + } - return handlers; + // Error to client for unsupported request types. + return false; }; diff --git a/src/components/core/comms/api/cncserver.api.serial.js b/src/components/core/comms/api/cncserver.api.serial.js new file mode 100644 index 00000000..0c77c25e --- /dev/null +++ b/src/components/core/comms/api/cncserver.api.serial.js @@ -0,0 +1,19 @@ +/** + * @file CNCServer ReSTful API endpoint module for direct serial execution. + */ +import { getSerialValueRaw } from 'cs/ipc'; + +export const handlers = {}; + +handlers['/v2/serial'] = function serialMain(req, res) { + if (req.route.method === 'post') { + console.log('GOT SERIAL', req.body); + getSerialValueRaw(req.body.command).then(message => { + res.status(200).send(message); + }); + return true; // Tell endpoint wrapper we'll handle the response + } + + // Error to client for unsupported request types. + return false; +}; diff --git a/src/components/core/comms/api/cncserver.api.settings.js b/src/components/core/comms/api/cncserver.api.settings.js index dbea2dcb..eedeb320 100644 --- a/src/components/core/comms/api/cncserver.api.settings.js +++ b/src/components/core/comms/api/cncserver.api.settings.js @@ -1,69 +1,67 @@ /** * @file CNCServer ReSTful API endpoint module for settings management. */ -const handlers = {}; +import { botConf, gConf } from 'cs/settings'; -module.exports = (cncserver) => { - handlers['/v2/settings'] = function settingsGet(req) { - if (req.route.method === 'get') { // Get list of tools - return { - code: 200, - body: { - global: '/v2/settings/global', - bot: '/v2/settings/bot', - }, - }; - } +export const handlers = {}; - return false; - }; +handlers['/v2/settings'] = function settingsGet(req) { + if (req.route.method === 'get') { // Get list of tools + return { + code: 200, + body: { + global: '/v2/settings/global', + bot: '/v2/settings/bot', + }, + }; + } - handlers['/v2/settings/:type'] = function settingsMain(req) { - // TODO: Refactor most/all of this to be more consistent, and pull useful - // cleaned up config from cnccserver.settings.[x] + return false; +}; - // Sanity check type - const setType = req.params.type; - if (!['global', 'bot'].includes(setType)) { - return [404, 'Settings group not found']; - } +handlers['/v2/settings/:type'] = function settingsMain(req) { + // TODO: Refactor most/all of this to be more consistent, and pull useful + // cleaned up config from cnccserver.settings.[x] - let conf = cncserver.settings.botConf; - if (setType === 'global') { - conf = cncserver.settings.gConf; - } + // Sanity check type + const setType = req.params.type; + if (!['global', 'bot'].includes(setType)) { + return [404, 'Settings group not found']; + } - function getSettings() { - let out = {}; - // Clean the output for global as it contains all commandline env vars! - if (setType === 'global') { - const g = conf.get(); - for (const [key, value] of Object.entries(g)) { - if (key === 'botOverride') { - break; - } - out[key] = value; + let conf = botConf; + if (setType === 'global') { + conf = gConf; + } + + function getSettings() { + let out = {}; + // Clean the output for global as it contains all commandline env vars! + if (setType === 'global') { + const g = conf.get(); + for (const [key, value] of Object.entries(g)) { + if (key === 'botOverride') { + break; } - } else { - out = conf.get(); + out[key] = value; } - return out; + } else { + out = conf.get(); } + return out; + } - // Get the full list for the type - if (req.route.method === 'get') { - return { code: 200, body: getSettings() }; + // Get the full list for the type + if (req.route.method === 'get') { + return { code: 200, body: getSettings() }; + } + if (req.route.method === 'put') { + for (const [key, value] of req.body) { + conf.set(key, value); } - if (req.route.method === 'put') { - for (const [key, value] of req.body) { - conf.set(key, value); - } - return { code: 200, body: getSettings() }; - } - - // Error to client for unsupported request types. - return false; - }; + return { code: 200, body: getSettings() }; + } - return handlers; + // Error to client for unsupported request types. + return false; }; diff --git a/src/components/core/comms/api/cncserver.api.tools.js b/src/components/core/comms/api/cncserver.api.tools.js index 70491e19..61769ba6 100644 --- a/src/components/core/comms/api/cncserver.api.tools.js +++ b/src/components/core/comms/api/cncserver.api.tools.js @@ -1,76 +1,188 @@ /** * @file CNCServer ReSTful API endpoint module for pen state management. */ -const handlers = {}; +import * as tools from 'cs/tools'; +import { validateData } from 'cs/schemas'; +import { err } from 'cs/rest'; +import { singleLineString, merge } from 'cs/utils'; +import { forceState } from 'cs/pen'; +import { gConf } from 'cs/settings'; -module.exports = (cncserver) => { - handlers['/v2/tools'] = function toolsGet(req) { - if (req.route.method === 'get') { // Get list of tools +export const handlers = {}; + +// Unified item not found. +function notFound(name) { + return { + code: 404, + body: { + status: 'error', + message: `Tool: '${name}' not found.`, + validOptions: tools.getNames(), + }, + }; +} + +// Primary tools endpoint handler. List, create. +handlers['/v2/tools'] = function toolsMain(req, res) { + // Get list of tools + if (req.route.method === 'get') { + return { + code: 200, + body: { + set: tools.getResponseSet(res.t), + tools: tools.items(), + presets: tools.listPresets(), + customs: tools.customKeys(), // List of custom preset machine names. + internals: tools.internalKeys(), // List of internal preset machine names. + invalidSets: tools.invalidPresets(), + }, + }; + } + + // Add new custom tool. + if (req.route.method === 'post') { + // Validate data and add tool item, or error out. + validateData('tools', req.body, true) + .then(tools.add) + .then(tool => { res.status(200).send(tool); }) + .catch(err(res)); + + return true; // Tell endpoint wrapper we'll handle the POST response. + } + + // Change toolset options directly. + if (req.route.method === 'patch') { + // If item data is attempted to be changed here, give a specific message for it. + if (req.body.items) { + err(res, 406)( + new Error(singleLineString`Patching the tools endpoint can only edit the + current toolset details, not individual tool items. + Patch to /v2/tools/[ID] to edit a custom toolset item.`) + ); + } + + // Merge with current set, validate data, then edit. + const set = { ...tools.set }; + delete set.items; + const mergedItem = merge(set, req.body); + validateData('toolsets', mergedItem, true) + .then(tools.editSet) + .then(editSet => { res.status(200).send(editSet); }) + .catch(err(res)); + + return true; // Tell endpoint wrapper we'll handle the PATCH response. + } + + // Error to client for unsupported request types. + return false; +}; + +// Tool specific enpoint. +handlers['/v2/tools/:toolID'] = function toolsItemMain(req, res) { + const { toolID } = req.params; + const tool = tools.getItem(toolID); + + // Sanity check tool. + if (!tool) return notFound(toolID); + + // Set current end of buffer tool to ID. + if (req.route.method === 'put') { + tools.changeTo(tool.id, null, () => { + res.status(200).send(JSON.stringify({ + status: `Tool changed to ${tool.id}`, + })); + + if (gConf.get('debug')) { + console.log('>RESP', req.route.path, 200, `Tool:${tool.id}`); + } + }, req.body.waitForCompletion); + return true; // Tell endpoint wrapper we'll handle the response + } + + // Edit tool by ID (Only allow editing of colorset tools). + if (req.route.method === 'patch') { + // No rewriting ID via patch. + if (req.body.id) { return { - code: 200, + code: 406, body: { - tools: Object.keys(cncserver.settings.botConf.get('tools')), - toolData: cncserver.settings.botConf.get('tools'), + status: 'error', + message: 'You cannot rewrite a tool ID in a patch. Delete item and recreate.', }, }; } - // Error to client for unsupported request types. - return false; - }; + // Only edit colorset tools. + if (!tools.canEdit(toolID)) { + return { + code: 406, + body: { + status: 'error', + message: singleLineString`This is a bot level tool, you can only edit colorset + level tools via the API.`, + allowedValues: tools.canEdit(), + }, + }; + } - // Standard toolchanges. - // TODO: Prevent manual swap "wait" toolchanges on this endpoint? - handlers['/v2/tools/:tool'] = function toolsMain(req, res) { - const toolName = req.params.tool; - // TODO: Support other tool methods... (needs API design!) - if (req.route.method === 'put') { // Set Tool - if (cncserver.settings.botConf.get(`tools:${toolName}`)) { - cncserver.control.setTool(toolName, null, () => { - res.status(200).send(JSON.stringify({ - status: `Tool changed to ${toolName}`, - })); - - if (cncserver.settings.gConf.get('debug')) { - console.log('>RESP', req.route.path, 200, `Tool:${toolName}`); - } - }, req.body.waitForCompletion); - return true; // Tell endpoint wrapper we'll handle the response - } + // Merge the incoming data with the existing object as we don't need delta. + const mergedItem = merge(tool, req.body); - return [404, `Tool: "${toolName}" not found`]; + // Validate the request data against the schema before continuing. + validateData('tools', mergedItem, true) + .then(tools.edit) + .then(finalItem => { res.status(200).send(finalItem); }) + .catch(err(res)); + + return true; // Tell endpoint wrapper we'll handle the PATCH response. + } + + // Delete color + if (req.route.method === 'delete') { + // Only allow deleting colorset tools. + if (!tools.canEdit(toolID)) { + return { + code: 406, + body: { + status: 'error', + message: singleLineString`This is a bot level tool, you can only delete + colorset level tools via the API.`, + validOptions: tools.canEdit(), + }, + }; } - // Error to client for unsupported request types. - return false; - }; + tools.delete(toolID); + return { code: 200, body: { tools: tools.items() } }; + } - // "wait" manual swap toolchanges with index - handlers['/v2/tools/:tool/:index'] = function toolsMain(req, res) { - const toolName = req.params.tool; - const toolIndex = req.params.index; - - if (req.route.method === 'put') { // Set Tool - if (cncserver.settings.botConf.get(`tools:${toolName}`)) { - cncserver.control.setTool(toolName, toolIndex, () => { - cncserver.pen.forceState({ tool: toolName }); - res.status(200).send(JSON.stringify({ - status: `Tool changed to ${toolName}, for index ${toolIndex}`, - })); - - if (cncserver.settings.gConf.get('debug')) { - console.log('>RESP', req.route.path, 200, `Tool:${toolName}, Index:${toolIndex}`); - } - }, req.body.waitForCompletion); - return true; // Tell endpoint wrapper we'll handle the response - } + // Error to client for unsupported request types. + return false; +}; - return [404, `Tool: "${toolName}" not found`]; - } +// "wait" manual swap toolchanges with index +handlers['/v2/tools/:tool/:index'] = function toolsIndex(req, res) { + const toolIndex = req.params.index; + const tool = tools.getItem(req.params.tool); - // Error to client for unsupported request types. - return false; - }; + // Sanity check tool. + if (!tool) return notFound(req.params.tool); + + if (req.route.method === 'put') { // Set Tool + tools.changeTo(tool.id, toolIndex, () => { + // TODO: Is this force state needed? + forceState({ tool: tool.id }); + res.status(200).send(JSON.stringify({ + status: `Tool changed to ${tool.id}, for index ${toolIndex}`, + })); + + if (gConf.get('debug')) { + console.log('>RESP', req.route.path, 200, `Tool:${tool.id}, Index:${toolIndex}`); + } + }, req.body.waitForCompletion); + return true; // Tell endpoint wrapper we'll handle the response + } - return handlers; + // Error to client for unsupported request types. + return false; }; diff --git a/src/components/core/comms/api/index.js b/src/components/core/comms/api/index.js index 58bf39b3..4992b50c 100644 --- a/src/components/core/comms/api/index.js +++ b/src/components/core/comms/api/index.js @@ -1,14 +1,28 @@ /** * @file Index for all ReSTful API endpoint handlers. */ -/* eslint-disable global-require, import/no-dynamic-require */ -module.exports = (cncserver) => { - const modules = [ - {}, 'settings', 'pen', 'motors', 'buffer', 'tools', 'colors', 'projects', 'content', - ]; +import { handlers as settings } from 'cs/api/handlers/settings'; +import { handlers as pen } from 'cs/api/handlers/pen'; +import { handlers as motors } from 'cs/api/handlers/motors'; +import { handlers as buffer } from 'cs/api/handlers/buffer'; +import { handlers as tools } from 'cs/api/handlers/tools'; +import { handlers as colors } from 'cs/api/handlers/colors'; +import { handlers as imps } from 'cs/api/handlers/implements'; +import { handlers as projects } from 'cs/api/handlers/projects'; +import { handlers as content } from 'cs/api/handlers/content'; +import { handlers as print } from 'cs/api/handlers/print'; +import { handlers as serial } from 'cs/api/handlers/serial'; - return modules.reduce((acc, name) => ({ - ...acc, - ...require(`./cncserver.api.${name}.js`)(cncserver), - })); +export const handlers = { + ...settings, + ...pen, + ...motors, + ...buffer, + ...tools, + ...colors, + ...imps, + ...projects, + ...content, + ...print, + ...serial, }; diff --git a/src/components/core/comms/cncserver.api.js b/src/components/core/comms/cncserver.api.js index 5eb6aebc..a1b76cac 100644 --- a/src/components/core/comms/cncserver.api.js +++ b/src/components/core/comms/cncserver.api.js @@ -1,261 +1,255 @@ /** * @file Abstraction module for all Restful API related code for CNC Server! */ +import fs from 'fs'; +import request from 'request'; +import querystring from 'querystring'; +import pathToRegexp from 'path-to-regexp'; +import { createServerEndpoint } from 'cs/rest'; +import { gConf } from 'cs/settings'; +import { handlers } from 'cs/api/handlers'; + +export const batchState = { batchRunning: false }; + +// CNC Server API ============================================================ +// Enpoints are created and assigned via a server path to respond to, and +// and callback function that manages handles the request and response. +// We hold all of these in handlers to be able to call them +// directly from the batch API endpoint. These are actually only turned into +// endpoints at the end via createServerEndpoint(). +Object.entries(handlers).forEach(([path, callback]) => { + createServerEndpoint(path, callback); +}); -const fs = require('fs'); -const request = require('request'); -const _ = require('underscore'); -const querystring = require('querystring'); -const pathToRegexp = require('path-to-regexp'); -const handlerResolve = require('./api'); - -const api = {}; - -module.exports = (cncserver) => { - // CNC Server API ============================================================ - // Enpoints are created and assigned via a server path to respond to, and - // and callback function that manages handles the request and response. - // We hold all of these in api.handlers to be able to call them - // directly from the batch API endpoint. These are actually only turned into - // endpoints at the end via createServerEndpoint(). - api.handlers = handlerResolve(cncserver); - _.each(api.handlers, (callback, path) => { - cncserver.rest.createServerEndpoint(path, callback); - }); - - /** - * Return a dummy 'request' or 'response' object for faking express requests. - * - * @param {string} type - * Either 'request' or 'response'. - * - * @return {object} - * The dummy object with minimum required parts for the handlers to use the - * same code as the express handler arguments. - */ - function getDummyObject(type) { - let out = {}; - - switch (type) { - case 'request': - out = { - route: { - method: '', - path: '', - }, - query: {}, - params: {}, - body: {}, - }; - break; - - case 'response': - out = { status: () => ({ send: () => { } }) }; - break; - default: - } - - return out; +/** + * Return a dummy 'request' or 'response' object for faking express requests. + * + * @param {string} type + * Either 'request' or 'response'. + * + * @return {object} + * The dummy object with minimum required parts for the handlers to use the + * same code as the express handler arguments. + */ +function getDummyObject(type) { + let out = {}; + + switch (type) { + case 'request': + out = { + route: { + method: '', + path: '', + }, + query: {}, + params: {}, + body: {}, + }; + break; + + case 'response': + out = { status: () => ({ send: () => { } }) }; + break; + default: } - /** - * Process a flat array of semi-abstracted commands into the queue. - * - * @param {array} commands - * Flat array of command objects in the following format: - * {"[POST|PUT|DELETE] /v1/[ENDPOINT]": {data: 'for the endpoint'}} - * @param {function} callback - * Callback function when command processing is complete. - * @param {number} index - * Array index of commands to process. Ignore/Pass as undefined to init. - * Function calls itself via callbacks to ensure delayed api handlers remain - * queued in order while async. - * @param {number} goodCount - * Running tally of successful commands, to be returned to callback once - * complete. - */ - function processBatchData(commands, callback, index, goodCount) { - // Initiate for the first loop run. - if (typeof index === 'undefined') { - // eslint-disable-next-line no-param-reassign - index = 0; - // eslint-disable-next-line no-param-reassign - goodCount = 0; - - api.batchRunning = true; - } - - const command = commands[index]; - if (typeof command !== 'undefined' && api.batchRunning) { - const key = Object.keys(command)[0]; - const data = command[key]; - const method = key.split(' ')[0]; - let path = key.split(' ')[1].split('?')[0]; - if (path[0] !== '/') path = `/${path}`; - - const query = path.split('?')[1]; // Query params. - const params = {}; // URL Params. - - // Batch runs are send and forget, force waitForCompletion false. - data.waitForCompletion = false; - - const req = getDummyObject('request'); - const res = getDummyObject('response'); - let handlerKey = ''; + return out; +} - // Attempt to match the path to a requstHandler by express path match. - _.each(Object.keys(api.handlers), (pattern) => { - const keys = []; - const match = pathToRegexp(pattern, keys).exec(path); - if (match) { - handlerKey = pattern; +/** + * Process a flat array of semi-abstracted commands into the queue. + * + * @param {array} commands + * Flat array of command objects in the following format: + * {"[POST|PUT|DELETE] /v1/[ENDPOINT]": {data: 'for the endpoint'}} + * @param {function} callback + * Callback function when command processing is complete. + * @param {number} index + * Array index of commands to process. Ignore/Pass as undefined to init. + * Function calls itself via callbacks to ensure delayed api handlers remain + * queued in order while async. + * @param {number} goodCount + * Running tally of successful commands, to be returned to callback once + * complete. + */ +function processBatchData(commands, callback, index, goodCount) { + // Initiate for the first loop run. + if (typeof index === 'undefined') { + // eslint-disable-next-line no-param-reassign + index = 0; + // eslint-disable-next-line no-param-reassign + goodCount = 0; + + batchState.batchRunning = true; + } - // If there's keyed url params, inject them. - if (keys.length) { - _.each(keys, (p, id) => { - params[p.name] = match[id + 1]; - }); - } + const command = commands[index]; + if (typeof command !== 'undefined' && batchState.batchRunning) { + const key = Object.keys(command)[0]; + const data = command[key]; + const method = key.split(' ')[0]; + let path = key.split(' ')[1].split('?')[0]; + if (path[0] !== '/') path = `/${path}`; + + const query = path.split('?')[1]; // Query params. + const params = {}; // URL Params. + + // Batch runs are send and forget, force waitForCompletion false. + data.waitForCompletion = false; + + const req = getDummyObject('request'); + const res = getDummyObject('response'); + let handlerKey = ''; + + // Attempt to match the path to a requstHandler by express path match. + Object.keys(handlers).forEach(pattern => { + const keys = []; + const match = pathToRegexp(pattern, keys).exec(path); + if (match) { + handlerKey = pattern; + + // If there's keyed url params, inject them. + if (keys.length) { + keys.forEach((p, id) => { + params[p.name] = match[id + 1]; + }); } - }); - - // Fill out request details: - req.route.method = method.toLowerCase(); - req.route.path = path; - req.query = query ? querystring.parse(query) : {}; - req.params = params; - req.body = data; - - // Call the api handler (send and forget via batch!) - if (api.handlers[handlerKey]) { - res.status = code => ({ - send: (sendData) => { - if (cncserver.settings.gConf.get('debug')) { - console.log(`#${index}, Batch Delay:`, handlerKey, code, sendData); - } - - if (code.toString()[0] === '2') goodCount++; - process.nextTick(() => { - processBatchData(commands, callback, index + 1, goodCount); - }); - }, - }); - - // Naively check to see if the request was successful. - // Technically if there's a wait for return (=== true), we could only - // see it in the .status() return callback. - const response = api.handlers[handlerKey](req, res); - if (response !== true) { - if (cncserver.settings.gConf.get('debug')) { - console.log(`#${index}, Batch Immediate:`, handlerKey, response); + } + }); + + // Fill out request details: + req.route.method = method.toLowerCase(); + req.route.path = path; + req.query = query ? querystring.parse(query) : {}; + req.params = params; + req.body = data; + + // Call the api handler (send and forget via batch!) + if (handlers[handlerKey]) { + res.status = code => ({ + send: sendData => { + if (gConf.get('debug')) { + console.log(`#${index}, Batch Delay:`, handlerKey, code, sendData); } - if (response !== false) goodCount++; + if (code.toString()[0] === '2') goodCount++; process.nextTick(() => { processBatchData(commands, callback, index + 1, goodCount); }); + }, + }); + + // Naively check to see if the request was successful. + // Technically if there's a wait for return (=== true), we could only + // see it in the .status() return callback. + const response = handlers[handlerKey](req, res); + if (response !== true) { + if (gConf.get('debug')) { + console.log(`#${index}, Batch Immediate:`, handlerKey, response); } - } else { - // Unhandled path, not a valid API handler available. Move on. - processBatchData(commands, callback, index + 1, goodCount); + + if (response !== false) goodCount++; + process.nextTick(() => { + processBatchData(commands, callback, index + 1, goodCount); + }); } } else { - // We're out of commands, or batch was cancelled. - api.batchRunning = false; - if (callback) callback(goodCount); + // Unhandled path, not a valid API handler available. Move on. + processBatchData(commands, callback, index + 1, goodCount); } + } else { + // We're out of commands, or batch was cancelled. + batchState.batchRunning = false; + if (callback) callback(goodCount); } +} + +// Batch Command API ========================================================= +export function setBatchRunningState(runningState = false) { + batchState.batchRunning = !!runningState; +} + +createServerEndpoint('/v1/batch', (req, res) => { + // Create a new batch set. + if (req.route.method === 'post') { + // For exceedingly large batches over 50k commands, batching in takes + // longer than the socket will stay open, so we simply respond with a "201 + // queued" immediately after counting. + if (req.body.file) { + const { file } = req.body; + + if (file.substr(0, 4) === 'http') { + // Internet file. + request.get(file, (error, response, body) => { + // Attempt to parse/process data. + if (body) { + try { + const commands = JSON.parse(body); + res.status(201).send(JSON.stringify({ + status: `Parsed ${commands.length} commands, queuing`, + count: commands.length, + })); - // Batch Command API ========================================================= - api.batchRunning = false; - api.setBatchRunningState = (runningState = false) => { - api.batchRunning = !!runningState; - }; - - cncserver.rest.createServerEndpoint('/v1/batch', (req, res) => { - // Create a new batch set. - if (req.route.method === 'post') { - // For exceedingly large batches over 50k commands, batching in takes - // longer than the socket will stay open, so we simply respond with a "201 - // queued" immediately after counting. - if (req.body.file) { - const { file } = req.body; - - if (file.substr(0, 4) === 'http') { - // Internet file. - request.get(file, (error, response, body) => { - // Attempt to parse/process data. - if (body) { - try { - const commands = JSON.parse(body); - res.status(201).send(JSON.stringify({ - status: `Parsed ${commands.length} commands, queuing`, - count: commands.length, - })); - - processBatchData(commands); - } catch (err) { - error = err; - } + processBatchData(commands); + } catch (err) { + error = err; } + } - // Catch response for errors (on parsing or reading). - if (error) { - res.status(400).send(JSON.stringify({ - status: `Error reading file "${file}"`, - remoteHTTPCode: response.statusCode, - data: error, + // Catch response for errors (on parsing or reading). + if (error) { + res.status(400).send(JSON.stringify({ + status: `Error reading file "${file}"`, + remoteHTTPCode: response.statusCode, + data: error, + })); + } + }); + } else { + // Local file. + fs.readFile(file, (error, data) => { + // Attempt to read the data. + if (data) { + try { + const commands = JSON.parse(data.toString()); + res.status(201).send(JSON.stringify({ + status: `Parsed ${commands.length} commands, queuing`, + count: commands.length, })); + processBatchData(commands); + } catch (err) { + error = err; } - }); - } else { - // Local file. - fs.readFile(file, (error, data) => { - // Attempt to read the data. - if (data) { - try { - const commands = JSON.parse(data.toString()); - res.status(201).send(JSON.stringify({ - status: `Parsed ${commands.length} commands, queuing`, - count: commands.length, - })); - processBatchData(commands); - } catch (err) { - error = err; - } - } + } - // Catch response for errors (on parsing or reading). - if (error) { - res.status(400).send(JSON.stringify({ - status: `Error reading file "${file}"`, - data: error, - })); - } - }); - } - } else { - // Raw command data (not from a file); - try { - res.status(201).send(JSON.stringify({ - status: `Parsed ${req.body.length} commands, queuing`, - count: req.body.length, - })); - processBatchData(req.body); - } catch (err) { - res.status(400).send(JSON.stringify({ - status: 'Error reading/processing batch data', - data: err, - })); - } + // Catch response for errors (on parsing or reading). + if (error) { + res.status(400).send(JSON.stringify({ + status: `Error reading file "${file}"`, + data: error, + })); + } + }); + } + } else { + // Raw command data (not from a file); + try { + res.status(201).send(JSON.stringify({ + status: `Parsed ${req.body.length} commands, queuing`, + count: req.body.length, + })); + processBatchData(req.body); + } catch (err) { + res.status(400).send(JSON.stringify({ + status: 'Error reading/processing batch data', + data: err, + })); } - - return true; // Tell endpoint wrapper we'll handle the response } - // Error to client for unsupported request types. - return false; - }); + return true; // Tell endpoint wrapper we'll handle the response + } - return api; -}; + // Error to client for unsupported request types. + return false; +}); diff --git a/src/components/core/comms/cncserver.ipc.js b/src/components/core/comms/cncserver.ipc.js index 90b85eff..9d94cae5 100644 --- a/src/components/core/comms/cncserver.ipc.js +++ b/src/components/core/comms/cncserver.ipc.js @@ -3,231 +3,256 @@ * for talking to the "runner" process, for CNC Server! * */ -const { spawn } = require('child_process'); // Process spawner. -const nodeIPC = require('node-ipc'); // Inter Process Comms for runner. +import { spawn } from 'child_process'; // Process spawner. +import nodeIPC from 'node-ipc'; // Inter Process Comms for runner. +import path from 'path'; +import { trigger } from 'cs/binder'; +import { + cmdstr, startItem, removeItem, setRunning +} from 'cs/buffer'; +import { gConf, botConf } from 'cs/settings'; +import { callbacks as serialCallbacks } from 'cs/serial'; +import { forceState } from 'cs/pen'; +import { __basedir } from 'cs/utils'; + +// IPC server config. +nodeIPC.config.silent = true; +nodeIPC.config.id = 'cncserver'; +nodeIPC.config.retry = 1500; + +// TODO: Evaluate usage of state to ensure only what we need exported is exported. +export const state = { + runnerSocket: {}, // The IPC socket for communicating to the runner + runnerInitCallback: null, // Placeholder for init set callback. + getSerialValueCallback: null, +}; -// Export object. -const ipc = {}; +/** + * Send a message to the runner. + * + * @param {string} command + * The string identifier for the command in dot notation. + * @param {object/string} data + * Data to be sent message receiver on client. + * @param {object} socket + * The IPC socket to send to, defaults to initial connect socket. + * + * @return {null} + */ +export function sendMessage(command, data, socket = state.runnerSocket) { + const packet = { + command, + data, + }; -module.exports = (cncserver) => { - let runnerInitCallback = null; // Placeholder for init set callback. - ipc.runnerSocket = {}; // The IPC socket for communicating to the runner + nodeIPC.server.emit(socket, 'app.message', packet); +} - // IPC server config. - nodeIPC.config.silent = true; - nodeIPC.config.id = 'cncserver'; - nodeIPC.config.retry = 1500; +// Define the runner tracker object. +export const runner = { + process: {}, /** - * Send a message to the runner. - * - * @param {string} command - * The string identifier for the command in dot notation. - * @param {object/string} data - * Data to be sent message receiver on client. - * @param {object} socket - * The IPC socket to send to, defaults to initial connect socket. - * - * @return {null} - */ - ipc.sendMessage = (command, data, socket = cncserver.ipc.runnerSocket) => { - const packet = { - command, - data, - }; - - nodeIPC.server.emit(socket, 'app.message', packet); - }; + * Start up & init the Runner process via node + */ + init: () => { + runner.process = spawn( + 'node', + [path.join(__basedir, 'components', 'core', 'runner', 'cncserver.runner.js')] + ); + + runner.process.stdout.on('data', rawData => { + const data = rawData.toString().split('\n'); + for (const i in data) { + if (data[i].length) console.log(`RUNNER:${data[i]}`); + } + }); - /** - * Initialize and start the IPC server - * - * @param {object} options - * localRunner {boolean}: true if we should try to init the runner locally, - * false to defer starting the runner to the parent application. - * @param {Function} callback - * Function called when the runner is connected and ready. - * - * @return {null} - */ - ipc.initServer = (options, callback) => { - runnerInitCallback = callback; - - // Initialize and start the IPC Server... - nodeIPC.serve(() => { - cncserver.binder.trigger('ipc.serve'); - nodeIPC.server.on('app.message', ipc.ipcGotMessage); + runner.process.stderr.on('data', data => { + console.log(`RUNNER ERROR: ${data}`); }); - nodeIPC.server.start(); - console.log('Starting IPC server, waiting for runner client to start...'); + runner.process.on('exit', exitCode => { + // TODO: Restart it? Who knows. + console.log(`RUNNER EXITED: ${exitCode}`); + }); + }, - if (options.localRunner) { - // Register an event callback to shutdown the runner if we're exiting. - process.on('SIGTERM', ipc.runner.shutdown); - process.on('SIGINT', ipc.runner.shutdown); - ipc.runner.init(); - } - }; + shutdown: () => { + console.log('Killing runner process before exiting...'); + runner.process.kill(); + process.exit(); + }, +}; +/** + * Helper for getting an async value from the serial port, always direct. + * + * @param {string} command + * A named machine configuration command. + * @param {object} options + * Keyed value replacement options for the command. + * + * @return {Promise} + * Promise that will always succeed with next message from serial. + */ +export const getSerialValue = (command, options = {}) => new Promise(resolve => { + process.nextTick(() => { + sendMessage('serial.direct.command', { + commands: [cmdstr(command, options)], + duration: 0, + }); - /** - * Helper for getting an async value from the serial port, always direct. - * - * @param {string} command - * A named machine configuration command. - * @param {object} options - * Keyed value replacement options for the command. - * - * @return {Promise} - * Promise that will always succeed with next message from serial. - */ - ipc.getSerialValueCallback = null; - ipc.getSerialValue = (command, options = {}) => new Promise((resolver) => { - process.nextTick(() => { - ipc.sendMessage('serial.direct.command', { - commands: [cncserver.buffer.cmdstr(command, options)], - duration: 0, - }); + state.getSerialValueCallback = resolve; + }); +}); - ipc.getSerialValueCallback = resolver; +/** + * Raw serial helper for getting an async value from the serial port, always direct. + * + * @param {string} command + * Raw serial string to send. + * + * @return {Promise} + * Promise that will always succeed with next message from serial. + */ +export const getSerialValueRaw = command => new Promise(resolve => { + process.nextTick(() => { + console.log('Sending raw', command); + sendMessage('serial.direct.command', { + commands: [command], + duration: 0, }); + + state.getSerialValueCallback = resolve; }); +}); - /** - * IPC Message callback event parser/handler. - * - * @param {object} packet - * The entire message object directly from the event. - * @param {object} socket - * The originating IPC client socket object. - * - * @return {null} - */ - ipc.ipcGotMessage = (packet, socket) => { - const { callbacks: serialCallbacks } = cncserver.serial; - const { data } = packet; - const messages = typeof data === 'string' ? data.trim().split('\n') : []; - const { baudRate } = cncserver.settings.botConf.get('controller'); - - switch (packet.command) { - case 'runner.ready': - ipc.runnerSocket = socket; - ipc.sendMessage('runner.config', { - debug: cncserver.settings.gConf.get('debug'), - ack: cncserver.settings.botConf.get('controller').ack, - showSerial: cncserver.settings.gConf.get('showSerial'), - }); - - if (runnerInitCallback) runnerInitCallback(); - break; - - // Sync simulation state from runner. - case 'serial.simulation': - cncserver.pen.forceState({ simulation: packet ? 1 : 0 }); - break; - - case 'serial.connected': - console.log( - `Serial connection open at ${baudRate}bps` - ); +/** + * IPC Message callback event parser/handler. + * + * @param {object} packet + * The entire message object directly from the event. + * @param {object} socket + * The originating IPC client socket object. + * + * @return {null} + */ +export function ipcGotMessage(packet, socket) { + const { data } = packet; + const messages = typeof data === 'string' ? data.trim().split('\n') : []; + const { baudRate } = botConf.get('controller'); + + switch (packet.command) { + case 'runner.ready': + state.runnerSocket = socket; + sendMessage('runner.config', { + debug: gConf.get('debug'), + ack: botConf.get('controller').ack, + showSerial: gConf.get('showSerial'), + }); - cncserver.binder.trigger('serial.connected'); + if (state.runnerInitCallback) state.runnerInitCallback(); + break; - if (serialCallbacks.connect) serialCallbacks.connect(data); - if (serialCallbacks.success) serialCallbacks.success(data); - break; + // Sync simulation state from runner. + case 'serial.simulation': + forceState({ simulation: packet ? 1 : 0 }); + break; - case 'serial.disconnected': - if (serialCallbacks.disconnect) serialCallbacks.disconnect(data); - break; + case 'serial.connected': + console.log( + `Serial connection open at ${baudRate}bps` + ); - case 'serial.error': - if (packet.type === 'connect') { - console.log( - 'Serial port failed to connect. Is it busy or in use? Error #10' - ); - console.log('SerialPort says:', packet.message); - if (serialCallbacks.complete) serialCallbacks.complete(data); - } else { - // TODO: Add better error message here, or figure out when this - // happens. - console.log('Serial failed to send data. Error #44'); - } + trigger('serial.connected'); - if (serialCallbacks.error) serialCallbacks.error(data); - break; - - case 'serial.data': - // Either get the value for a caller, or trigger generic bind. - messages.forEach((message) => { - if (ipc.getSerialValueCallback && message !== cncserver.settings.botConf.get('controller').ack) { - ipc.getSerialValueCallback(message); - ipc.getSerialValueCallback = null; - } else { - cncserver.binder.trigger('serial.message', message); - } - }); - break; - - case 'buffer.item.start': - // Buffer action item begun to run. - cncserver.buffer.startItem(data); - break; - - case 'buffer.item.done': - // Buffer item has completed. - cncserver.buffer.removeItem(data); - break; - - case 'buffer.empty': - // TODO: Is this needed? - break; - - case 'buffer.running': - cncserver.buffer.setRunning(data); - break; - default: - } - }; + if (serialCallbacks.connect) serialCallbacks.connect(data); + if (serialCallbacks.success) serialCallbacks.success(data); + break; - // Define the runner tracker object. - ipc.runner = { - process: {}, - - /** - * Start up & init the Runner process via node - */ - init: () => { - // TODO: Use FS path to join instead of fixed slashes. - ipc.runner.process = spawn( - 'node', - [`${__dirname}/../runner/cncserver.runner`] - ); + case 'serial.disconnected': + if (serialCallbacks.disconnect) serialCallbacks.disconnect(data); + break; - ipc.runner.process.stdout.on('data', (rawData) => { - const data = rawData.toString().split('\n'); - for (const i in data) { - if (data[i].length) console.log(`RUNNER:${data[i]}`); + case 'serial.error': + if (packet.type === 'connect') { + console.log( + 'Serial port failed to connect. Is it busy or in use? Error #10' + ); + console.log('SerialPort says:', packet.message); + if (serialCallbacks.complete) serialCallbacks.complete(data); + } else { + // TODO: Add better error message here, or figure out when this + // happens. + console.log('Serial failed to send data. Error #44'); + } + + if (serialCallbacks.error) serialCallbacks.error(data); + break; + + case 'serial.data': + // Either get the value for a caller, or trigger generic bind. + messages.forEach(message => { + // TODO: Does this work every time? + // && message !== botConf.get('controller').ack) { + if (state.getSerialValueCallback) { + state.getSerialValueCallback(message); + state.getSerialValueCallback = null; + } else { + trigger('serial.message', message); } }); + break; - ipc.runner.process.stderr.on('data', (data) => { - console.log(`RUNNER ERROR: ${data}`); - }); + case 'buffer.item.start': + // Buffer action item begun to run. + startItem(data); + break; - ipc.runner.process.on('exit', (exitCode) => { - // TODO: Restart it? Who knows. - console.log(`RUNNER EXITED: ${exitCode}`); - }); - }, + case 'buffer.item.done': + // Buffer item has completed. + removeItem(data); + break; - shutdown: () => { - console.log('Killing runner process before exiting...'); - ipc.runner.process.kill(); - process.exit(); - }, - }; + case 'buffer.empty': + // TODO: Is this needed? + break; - return ipc; -}; + case 'buffer.running': + setRunning(data); + break; + default: + } +} + +/** + * Initialize and start the IPC server + * + * @param {object} options + * localRunner {boolean}: true if we should try to init the runner locally, + * false to defer starting the runner to the parent application. + * @param {Function} callback + * Function called when the runner is connected and ready. + * + * @return {null} + */ +export function initServer(options, callback) { + state.runnerInitCallback = callback; + + // Initialize and start the IPC Server... + nodeIPC.serve(() => { + trigger('ipc.serve'); + nodeIPC.server.on('app.message', ipcGotMessage); + }); + + nodeIPC.server.start(); + console.log('Starting IPC server, waiting for runner client to start...'); + + if (options.localRunner) { + // Register an event callback to shutdown the runner if we're exiting. + process.on('SIGTERM', runner.shutdown); + process.on('SIGINT', runner.shutdown); + runner.init(); + } +} diff --git a/src/components/core/comms/cncserver.rest.js b/src/components/core/comms/cncserver.rest.js index 54db8597..995dabda 100644 --- a/src/components/core/comms/cncserver.rest.js +++ b/src/components/core/comms/cncserver.rest.js @@ -1,111 +1,128 @@ /** * @file Abstraction module for restful helper utilities createServerEndpoint. */ -const express = require('express'); // Express object (for static). +import express from 'express'; // Express object (for static). +import { app } from 'cs/server'; +import { gConf } from 'cs/settings'; +import { getFromRequest } from 'cs/schemas'; -const rest = {}; +/** + * Wrapper for creating a static (directory reading HTML) endpoint. + * + * @param {string} userPath [description] + * Path a user would enter to get to the content. + * @param {string} sourcePath + * Path for source files to be served from. + * @param {object} options + * options object for static serving of files. + */ +export function createStaticEndpoint(userPath, sourcePath, options) { + app.use(userPath, express.static(sourcePath, options)); +} -module.exports = (cncserver) => { - /** - * Wrapper for creating a static (directory reading HTML) endpoint. - * - * @param {string} userPath [description] - * Path a user would enter to get to the content. - * @param {string} sourcePath - * Path for source files to be served from. - * @param {object} options - * options object for static serving of files. - */ - rest.createStaticEndpoint = (userPath, sourcePath, options) => { - cncserver.server.app.use(userPath, express.static(sourcePath, options)); - }; +/** + * Wrapper for unifying the creation and logic of standard endpoints, their + * headers and their responses and formats. + * + * @param {string} path + * Full path of HTTP callback in express path format (can include wildcards) + * @param {function} callback + * Callback triggered on HTTP request + */ +export function createServerEndpoint(path, callback) { + const what = Object.prototype.toString; + app.all(path, (req, res) => { + res.set('Content-Type', 'application/json; charset=UTF-8'); + res.set('Access-Control-Allow-Origin', gConf.get('corsDomain')); - /** - * Wrapper for unifying the creation and logic of standard endpoints, their - * headers and their responses and formats. - * - * @param {string} path - * Full path of HTTP callback in express path format (can include wildcards) - * @param {function} callback - * Callback triggered on HTTP request - */ - rest.createServerEndpoint = (path, callback) => { - const what = Object.prototype.toString; - cncserver.server.app.all(path, (req, res) => { - res.set('Content-Type', 'application/json; charset=UTF-8'); - res.set('Access-Control-Allow-Origin', cncserver.settings.gConf.get('corsDomain')); + if (gConf.get('debug') && path !== '/poll') { + console.log( + req.route.method.toUpperCase(), + req.route.path, + JSON.stringify(req.body) + ); + } - if (cncserver.settings.gConf.get('debug') && path !== '/poll') { - console.log( - req.route.method.toUpperCase(), - req.route.path, - JSON.stringify(req.body) - ); - } + // Handle CORS Pre-flight OPTIONS request ourselves + // TODO: Allow implementers to define options and allowed methods. + if (req.route.method === 'options') { + res.set( + 'Access-Control-Allow-Methods', + 'PUT, PATCH, POST, GET, DELETE' + ); + res.set( + 'Access-Control-Allow-Headers', + 'Origin, X-Requested-Width, Content-Type, Accept' + ); - // Handle CORS Pre-flight OPTIONS request ourselves - // TODO: Allow implementers to define options and allowed methods. - if (req.route.method === 'options') { - res.set( - 'Access-Control-Allow-Methods', - 'PUT, PATCH, POST, GET, DELETE' - ); - res.set( - 'Access-Control-Allow-Headers', - 'Origin, X-Requested-Width, Content-Type, Accept' - ); + // Attach the schema and send, if any. + res.status(200).send(getFromRequest(path)); + return; + } - // Attach the schema and send, if any. - res.status(200).send(cncserver.schemas.getFromRequest(path)); - return; - } + const cbStat = callback(req, res); - const cbStat = callback(req, res); - - if (cbStat === false) { // Super simple "not supported" - // Debug Response - if (cncserver.settings.gConf.get('debug') && path !== '/poll') { - console.log('>RESP', req.route.path, 405, 'Not Supported'); - } + if (cbStat === false) { // Super simple "not supported" + // Debug Response + if (gConf.get('debug') && path !== '/poll') { + console.log('>RESP', req.route.path, 405, 'Not Supported'); + } - res.status(405).send(JSON.stringify({ - status: 'Not supported', - })); - } else if (what.call(cbStat) === '[object Array]') { // Just return message - // Debug Response - if (cncserver.settings.gConf.get('debug') && path !== '/poll') { - console.log('>RESP', req.route.path, cbStat[0], cbStat[1]); - } + res.status(405).send(JSON.stringify({ + status: 'Not supported', + })); + } else if (what.call(cbStat) === '[object Array]') { // Just return message + // Debug Response + if (gConf.get('debug') && path !== '/poll') { + console.log('>RESP', req.route.path, cbStat[0], cbStat[1]); + } - // Array format: [/http code/, /status message/] - res.status(cbStat[0]).send(JSON.stringify({ status: cbStat[1] })); - } else if (what.call(cbStat) === '[object Object]') { // Full message - // Debug Response - if (cncserver.settings.gConf.get('debug') && path !== '/poll') { - console.log( - '>RESP', - req.route.path, - cbStat.code, - JSON.stringify(req.body) - ); - } + // Array format: [/http code/, /status message/] + res.status(cbStat[0]).send(JSON.stringify({ status: cbStat[1] })); + } else if (what.call(cbStat) === '[object Object]') { // Full message + // Debug Response + if (gConf.get('debug') && path !== '/poll') { + console.log( + '>RESP', + req.route.path, + cbStat.code, + JSON.stringify(req.body) + ); + } - // Send plaintext if body is string, otherwise convert to JSON. - if (typeof cbStat.body === 'string') { - res.set('Content-Type', 'text/plain; charset=UTF-8'); - res.status(cbStat.code).send(cbStat.body); - } else { - res.status(cbStat.code).send(JSON.stringify(cbStat.body)); - } + // Send plaintext if body is string, otherwise convert to JSON. + if (typeof cbStat.body === 'string') { + res.set('Content-Type', 'text/plain; charset=UTF-8'); + res.status(cbStat.code).send(cbStat.body); + } else { + res.status(cbStat.code).send(JSON.stringify(cbStat.body)); } - }); - }; + } + }); +} - // Exports. - rest.exports = { - createStaticEndpoint: rest.createStaticEndpoint, - createServerEndpoint: rest.createServerEndpoint, +/** + * Standardized response error handler (to be passed to promise catches). + * + * The intent is to handle all error objects and give something useful to the + * client in the message and associated objects. This is curried to hand the catch a + * new function once we get the local response object. + * + * @param {HTTP Response} res + * Specific request response object. + * @param {number} code + * Response code to use. + * + * @returns {function} + * Catch callback that takes a single error object as arg. + */ +export const err = (res, code = 406) => error => { + const errBody = { + status: 'error', + message: error.message || error, }; - return rest; + if (error.allowedValues) errBody.allowedValues = error.allowedValues; + if (!error.allowedValues && error.stack) errBody.stack = error.stack.split('\n'); + res.status(code).send(errBody); }; diff --git a/src/components/core/comms/cncserver.serial.js b/src/components/core/comms/cncserver.serial.js index 3b9049fa..bd33c5c6 100644 --- a/src/components/core/comms/cncserver.serial.js +++ b/src/components/core/comms/cncserver.serial.js @@ -4,228 +4,224 @@ * Taking in only the global CNCServer object, add's the "serial" object. * */ -const SerialPort = require('serialport'); - -const serial = {}; // Export interface object; - -module.exports = (cncserver) => { - serial.callbacks = {}; // Hold global serial connection/error callbacks. - serial.connectPath = '{auto}'; - - // Board support provided setup commands to be run on serial connect. - serial.setupCommands = []; - serial.setSetupCommands = (commands) => { - serial.setupCommands = commands; - }; - - /** - * Helper function to manage initial serial connection and reconnection. - * - * @param {object} options - * Holds all possible callbacks for the serial connection: - * connect: Callback for successful serial connect event - * success: Callback for general success - * error: Callback for init/connect error, arg of error string/object - * disconnect: Callback for close/unexpected disconnect - * complete: Callback for general completion - */ - serial.connect = (options) => { - // Apply any passed callbacks to a new serial callbacks object. - serial.callbacks = { - connect: options.connect, - disconnect: options.disconnect, - error: options.error, - success: options.success, - }; - - // Run everything through the callback as port list is async. - console.log('Finding available serial ports...'); - const botController = cncserver.settings.botConf.get('controller'); - cncserver.serial.autoDetectPort(botController, (ports) => { - // Give some console feedback on ports. - if (cncserver.settings.gConf.get('debug')) { - console.log('Full Available Port Data:', ports.full); - } else { - const names = ports.names.length ? ports.names.join(', ') : '[NONE]'; - console.log(`Available Serial ports: ${names}`); - } +import SerialPort from 'serialport'; +import * as ipc from 'cs/ipc'; +import * as server from 'cs/server'; +import { botConf, gConf } from 'cs/settings'; +import { state as penState, forceState } from 'cs/pen'; +import { initAPI as initScratch } from 'cs/scratch'; + +export const callbacks = {}; // Hold global serial connection/error callbacks. + +// Board support provided setup commands to be run on serial connect. +export const state = { + setupCommands: [], + connectPath: '{auto}', +}; - const passedPort = cncserver.settings.gConf.get('serialPath'); - if (passedPort === '' || passedPort === '{auto}') { - if (ports.auto.length) { - cncserver.settings.gConf.set('serialPath', ports.auto[0]); - console.log(`Using first detected port: "${ports.auto[0]}"...`); - } else { - console.error('No matching serial ports detected.'); - } - } else { - console.log(`Using passed serial port "${passedPort}"...`); - } +// Setup command setter. +export function setSetupCommands(commands) { + state.setupCommands = commands; +} - // Send connect to runner... - const connectPath = cncserver.settings.gConf.get('serialPath'); - - // Try to connect to serial, or exit with error code. - if (connectPath === '' || connectPath === '{auto}') { - console.error( - `Error #22: ${botController.name} not found. \ - Are you sure it's connected?` - ); - - if (options.error) { - options.error({ - message: `${botController.name} not found.`, - type: 'notfound', - }); - } - } else { - console.log(`Attempting to open serial port: "${connectPath}"...`); - - const connectData = { - port: connectPath, - baudRate: Number(botController.baudRate), - autoReconnect: true, - autoReconnectTries: 20, - autoReconnectRate: 5000, - setupCommands: serial.setupCommands, - }; - - cncserver.ipc.sendMessage('serial.connect', connectData); +/** + * Helper function to implement matching port information to configured bot + * parameters. + * + * Attempts to match SerialPort.list output to parameters and set global + * 'serialPath' to matching port. + * + * @param {object} botControllerConf + * The configured bot controller to try to match + * @param {function} callback + * The callback function when async getports completes. Returns single + * object argument containing three keys: + * auto {array}: Array of auto detected port names based on bot conf. + * Empty array if none found. + * names {array}: Clean flat array of all available comm paths/port names. + * full {array}: Array of all valid serial port objects for debugging. + */ +export function autoDetectPort(botControllerConf, callback) { + const botMaker = botControllerConf.manufacturer.toLowerCase(); + const botProductId = parseInt(botControllerConf.productId.toLowerCase(), 10); + const botName = botControllerConf.name.toLowerCase(); + + // Output data arrays. + const detectList = []; + const portNames = []; + const cleanList = []; + + SerialPort.list().then(ports => { + ports.forEach(port => { + const portMaker = (port.manufacturer || '').toLowerCase(); + // Convert reported product ID from hex string to decimal. + const portProductId = parseInt( + `0x${(port.productId || '').toLowerCase()}`, + 16 // TODO: Is this right? + ); + const portPnpId = (port.pnpId || '').toLowerCase(); + + // Add this port to the clean list if its vendor ID isn't undefined. + if (typeof port.vendorId !== 'undefined') { + cleanList.push(port); + portNames.push(port.comName); } - }); - }; - - /** - * Helper function to implement matching port information to configured bot - * parameters. - * - * Attempts to match SerialPort.list output to parameters and set global - * 'serialPath' to matching port. - * - * @param {object} botControllerConf - * The configured bot controller to try to match - * @param {function} callback - * The callback function when async getports completes. Returns single - * object argument containing three keys: - * auto {array}: Array of auto detected port names based on bot conf. - * Empty array if none found. - * names {array}: Clean flat array of all available comm paths/port names. - * full {array}: Array of all valid serial port objects for debugging. - */ - serial.autoDetectPort = (botControllerConf, callback) => { - const botMaker = botControllerConf.manufacturer.toLowerCase(); - const botProductId = parseInt(botControllerConf.productId.toLowerCase(), 10); - const botName = botControllerConf.name.toLowerCase(); - - // Output data arrays. - const detectList = []; - const portNames = []; - const cleanList = []; - - SerialPort.list((err, ports) => { - // TODO: Catch errors thrown here. - err = err; - - ports.forEach((port) => { - const portMaker = (port.manufacturer || '').toLowerCase(); - // Convert reported product ID from hex string to decimal. - const portProductId = parseInt( - `0x${(port.productId || '').toLowerCase()}`, - 16 // TODO: Is this right? - ); - const portPnpId = (port.pnpId || '').toLowerCase(); - - // Add this port to the clean list if its vendor ID isn't undefined. - if (typeof port.vendorId !== 'undefined') { - cleanList.push(port); - portNames.push(port.comName); - } - - // OS specific board detection based on serialport 2.0.5 - switch (process.platform) { - case 'win32': - // Match by manufacturer partial only. - if (portMaker.indexOf(botMaker) !== -1) { - detectList.push(port.comName); - } - break; - default: // includes 'darwin', 'linux' - // Match by Exact Manufacturer... - if (portMaker === botMaker) { - // Match by exact product ID (hex to dec), or PNP ID partial - if (portProductId === botProductId - || portPnpId.indexOf(botName) !== -1) { - detectList.push(port.comName); - } + // OS specific board detection based on serialport 2.0.5 + switch (process.platform) { + case 'win32': + // Match by manufacturer partial only. + if (portMaker.indexOf(botMaker) !== -1) { + detectList.push(port.comName); + } + + break; + default: // includes 'darwin', 'linux' + // Match by Exact Manufacturer... + if (portMaker === botMaker) { + // Match by exact product ID (hex to dec), or PNP ID partial + if (portProductId === botProductId + || portPnpId.indexOf(botName) !== -1) { + detectList.push(port.comName); } - } - }); - - callback({ auto: detectList, names: portNames, full: cleanList }); + } + } }); - }; - // Util function to just get the full port output from exports. - serial.getPorts = (cb) => { - SerialPort.list((err, ports) => { - cb(ports, err); - }); - }; - - // Local triggers. - serial.localTrigger = (event) => { - const restriction = cncserver.settings.gConf.get('httpLocalOnly') ? 'localhost' : '*'; - const port = cncserver.settings.gConf.get('httpPort'); - const isVirtual = cncserver.pen.state.simulation ? ' (simulated)' : ''; - - switch (event) { - case 'simulationStart': - console.log('=======Continuing in SIMULATION MODE!!!============'); - cncserver.pen.forceState({ simulation: 1 }); - break; - - case 'serialReady': - console.log(`CNC server API listening on ${restriction}:${port}`); - - cncserver.pen.forceState({ simulation: 0 }); - cncserver.serial.localTrigger('botInit'); - cncserver.server.start(); - - // Initialize scratch v2 endpoint & API. - // TODO: This needs to be moved out into something more self contained. - if (cncserver.settings.gConf.get('scratchSupport')) { - cncserver.scratch.initAPI(); - } - break; - - case 'serialClose': - console.log( - `Serialport connection to "${cncserver.settings.gConf.get( - 'serialPath' - )}" lost!! Did it get unplugged?` - ); - - // Assume the serialport isn't coming back... It's on a long vacation! - cncserver.settings.gConf.set('serialPath', ''); - cncserver.serial.localTrigger('simulationStart'); - break; - - case 'botInit': - console.info( - `---=== ${cncserver.settings.botConf.get( - 'name' - )}${isVirtual} is ready to receive commands ===---` - ); - break; - default: + callback({ auto: detectList, names: portNames, full: cleanList }); + }).catch(err => { + // TODO: Catch errors thrown here. + // err = err; + }); +} + +/** + * Helper function to manage initial serial connection and reconnection. + * + * @param {object} options + * Holds all possible callbacks for the serial connection: + * connect: Callback for successful serial connect event + * success: Callback for general success + * error: Callback for init/connect error, arg of error string/object + * disconnect: Callback for close/unexpected disconnect + * complete: Callback for general completion + */ +export function connect(options) { + // Apply any passed callbacks to a new serial callbacks object. + callbacks.connect = options.connect; + callbacks.disconnect = options.disconnect; + callbacks.error = options.error; + callbacks.success = options.success; + + // Run everything through the callback as port list is async. + console.log('Finding available serial ports...'); + const botController = botConf.get('controller'); + autoDetectPort(botController, ports => { + // Give some console feedback on ports. + if (gConf.get('debug')) { + console.log('Full Available Port Data:', ports.full); + } else { + const names = ports.names.length ? ports.names.join(', ') : '[NONE]'; + console.log(`Available Serial ports: ${names}`); } - }; - // Exports... - serial.exports = { - getPorts: serial.getPorts, - }; + const passedPort = gConf.get('serialPath'); + if (passedPort === '' || passedPort === '{auto}') { + if (ports.auto.length) { + gConf.set('serialPath', ports.auto[0]); + console.log(`Using first detected port: "${ports.auto[0]}"...`); + } else { + console.error('No matching serial ports detected.'); + } + } else { + console.log(`Using passed serial port "${passedPort}"...`); + } - return serial; -}; + // Send connect to runner... + const connectPath = gConf.get('serialPath'); + + // Try to connect to serial, or exit with error code. + if (connectPath === '' || connectPath === '{auto}') { + console.error( + `Error #22: ${botController.name} not found. \ + Are you sure it's connected?` + ); + + if (options.error) { + options.error({ + message: `${botController.name} not found.`, + type: 'notfound', + }); + } + } else { + console.log(`Attempting to open serial port: "${connectPath}"...`); + + const connectData = { + port: connectPath, + baudRate: Number(botController.baudRate), + autoReconnect: true, + autoReconnectTries: 20, + autoReconnectRate: 5000, + setupCommands: state.setupCommands, + }; + + ipc.sendMessage('serial.connect', connectData); + } + }); +} + +// Util function to just get the full port output from exports. +export function getPorts(cb) { + SerialPort.list((err, ports) => { + cb(ports, err); + }); +} + +// Local triggers. +export function localTrigger(event) { + const restriction = gConf.get('httpLocalOnly') ? 'localhost' : '*'; + const port = gConf.get('httpPort'); + const isVirtual = penState.simulation ? ' (simulated)' : ''; + + switch (event) { + case 'simulationStart': + console.log('=======Continuing in SIMULATION MODE!!!============'); + forceState({ simulation: 1 }); + break; + + case 'serialReady': + console.log(`CNC server API listening on ${restriction}:${port}`); + + forceState({ simulation: 0 }); + localTrigger('botInit'); + server.start(); + + // Initialize scratch v2 endpoint & API. + // TODO: This needs to be moved out into something more self contained. + if (gConf.get('scratchSupport')) { + initScratch(); + } + break; + + case 'serialClose': + console.log( + `Serialport connection to "${gConf.get( + 'serialPath' + )}" lost!! Did it get unplugged?` + ); + + // Assume the serialport isn't coming back... It's on a long vacation! + gConf.set('serialPath', ''); + localTrigger('simulationStart'); + break; + + case 'botInit': + console.info( + `---=== ${botConf.get( + 'name' + )}${isVirtual} is ready to receive commands ===---` + ); + break; + default: + } +} diff --git a/src/components/core/comms/cncserver.server.js b/src/components/core/comms/cncserver.server.js index 3935d38f..6fa9cace 100644 --- a/src/components/core/comms/cncserver.server.js +++ b/src/components/core/comms/cncserver.server.js @@ -2,108 +2,106 @@ * @file Abstraction module for all express/server related code for CNC Server! * */ -const express = require('express'); // Express Webserver Requires -const slashes = require('connect-slashes'); // Middleware to manage URI slashes -const http = require('http'); -const path = require('path'); -const { homedir } = require('os'); - -const server = {}; // Global component export. - -module.exports = (cncserver) => { - server.app = express(); // Create router (app). - - // Setup the cental server object. - server.httpServer = http.createServer(server.app); - - // Global express initialization (must run before any endpoint creation) - server.app.configure(() => { - // Base static path for remote interface. - server.app.use('/', express.static(path.join(global.__basedir, 'interface'))); - - // Configure module JS file mime type. - express.static.mime.define({ 'text/javascript': ['mjs'] }); - - // Add static libraries from node_modules. - const nm = path.resolve(global.__basedir, '..', 'node_modules'); - - // Custom static dirs. - const statics = { - paper: path.join(nm, 'paper', 'dist'), - axios: path.join(nm, 'axios', 'dist'), - jquery: path.join(nm, 'jquery', 'dist'), - jsonform: path.join(nm, 'jsonform', 'lib'), - underscore: path.join(nm, 'underscore'), - bulma: path.join(nm, 'bulma', 'css'), - select2: path.join(nm, 'select2', 'dist'), - jsoneditor: path.join(nm, '@json-editor', 'json-editor', 'dist'), - bootstrap: path.join(nm, 'bootstrap', 'dist'), - 'font-awesome': path.join(nm, '@fortawesome', 'fontawesome-free', 'css'), - webfonts: path.join(nm, '@fortawesome', 'fontawesome-free', 'webfonts'), - modules: path.resolve(global.__basedir, '..', 'web_modules'), - home: path.join(path.resolve(homedir(), 'cncserver')), - }; - - // Add routing for all static dirs. - Object.entries(statics).forEach(([staticPath, dirSource]) => { - server.app.use(`/${staticPath}`, express.static(dirSource)); - }); - - // Setup remaining middleware. - server.app.use(express.bodyParser()); - server.app.use(slashes()); +import express from 'express'; // Express Webserver Requires +import slashes from 'connect-slashes'; // Middleware to manage URI slashes +import http from 'http'; +import path from 'path'; +import { homedir } from 'os'; +import { trigger } from 'cs/binder'; +import { gConf } from 'cs/settings'; +import { __basedir } from 'cs/utils'; + +export const app = express(); // Create router (app). + +// Setup the cental server object. +export const httpServer = http.createServer(app); + +// Global express initialization (must run before any endpoint creation) +app.configure(() => { + console.log('APP CONFIG ======================================= '); + // Base static path for remote interface. + app.use('/', express.static(path.join(__basedir, 'interface'))); + + // Configure module JS file mime type. + express.static.mime.define({ 'text/javascript': ['mjs'] }); + + // Add static libraries from node_modules. + const nm = path.resolve(__basedir, '..', 'node_modules'); + + // Custom static dirs. + const statics = { + paper: path.join(nm, 'paper', 'dist'), + axios: path.join(nm, 'axios', 'dist'), + jquery: path.join(nm, 'jquery', 'dist'), + jsonform: path.join(nm, 'jsonform', 'lib'), + underscore: path.join(nm, 'underscore'), + bulma: path.join(nm, 'bulma', 'css'), + chroma: path.join(nm, 'chroma-js'), + select2: path.join(nm, 'select2', 'dist'), + jsoneditor: path.join(nm, '@json-editor', 'json-editor', 'dist'), + bootstrap: path.join(nm, 'bootstrap', 'dist'), + 'font-awesome': path.join(nm, '@fortawesome', 'fontawesome-free', 'css'), + webfonts: path.join(nm, '@fortawesome', 'fontawesome-free', 'webfonts'), + modules: path.resolve(__basedir, '..', 'web_modules'), + home: path.join(path.resolve(homedir(), 'cncserver')), + }; - // Allow any implementing binder support for middleware or static routes. - cncserver.binder.trigger('server.configure', server.app); + // Add routing for all static dirs. + Object.entries(statics).forEach(([staticPath, dirSource]) => { + app.use(`/${staticPath}`, express.static(dirSource)); }); - // Start express HTTP server for API on the given port - let serverStarted = false; - - /** - * Attempt to start the server. - */ - server.start = () => { - // Only run start server once... - if (serverStarted) return; - serverStarted = true; + // Setup remaining middleware. + app.use(express.bodyParser()); + app.use(slashes()); - const hostname = cncserver.settings.gConf.get('httpLocalOnly') ? 'localhost' : null; + // Allow any implementing binder support for middleware or static routes. + trigger('server.configure', app, true); +}); - // Catch Addr in Use Error - server.httpServer.on('error', (e) => { - if (e.code === 'EADDRINUSE') { - console.log('Address in use, retrying...'); - setTimeout(() => { - server.close(); - server.httpServer.listen(cncserver.settings.gConf.get('httpPort'), hostname); - }, 1000); - } - }); +// Start express HTTP server for API on the given port +let serverStarted = false; - server.httpServer.listen( - cncserver.settings.gConf.get('httpPort'), - hostname, - () => { - // Properly close down server on fail/close - process.on('SIGTERM', (err) => { - console.log(err); - server.close(); - }); - } - ); - }; - - /** - * Attempt to close down the server. - */ - server.close = () => { - try { - server.httpServer.close(); - } catch (e) { - console.log("Whoops, server wasn't running.. Oh well."); +/** + * Attempt to close down the server. + */ +export function close() { + try { + httpServer.close(); + } catch (e) { + console.log("Whoops, server wasn't running.. Oh well."); + } +} +/** + * Attempt to start the server. + */ +export function start() { + // Only run start server once... + if (serverStarted) return; + serverStarted = true; + + const hostname = gConf.get('httpLocalOnly') ? 'localhost' : null; + + // Catch Addr in Use Error + httpServer.on('error', err => { + if (err.code === 'EADDRINUSE') { + console.log('Address in use, retrying...'); + setTimeout(() => { + close(); + httpServer.listen(gConf.get('httpPort'), hostname); + }, 1000); } - }; + }); - return server; -}; + httpServer.listen( + gConf.get('httpPort'), + hostname, + () => { + // Properly close down server on fail/close + process.on('SIGTERM', err => { + console.log(err); + close(); + }); + } + ); +} diff --git a/src/components/core/comms/cncserver.sockets.js b/src/components/core/comms/cncserver.sockets.js index cc65c375..1a4829a5 100644 --- a/src/components/core/comms/cncserver.sockets.js +++ b/src/components/core/comms/cncserver.sockets.js @@ -2,187 +2,214 @@ * @file Abstraction module for all Socket I/O related code for CNC Server! * */ -const socketio = require('socket.io'); +import socketio from 'socket.io'; +import { bindTo, trigger } from 'cs/binder'; +import { httpServer } from 'cs/server'; +import { setPen, state as penState } from 'cs/pen'; +import { state as actualPenState } from 'cs/actualPen'; +import { state as bufferState } from 'cs/buffer'; +import { layers } from 'cs/drawing/base'; +import { snapPathsToColorset } from 'cs/drawing/colors'; + +const io = socketio(httpServer); + +// Shortcut functions for move/height streaming. +export const shortcut = { + move: data => { + setPen(data, () => { + if (data.returnData) io.emit('move', penState); + }); + }, -const sockets = {}; // Export interface. + height: data => { + setPen({ state: data.state }, () => { + if (data.returnData) io.emit('height', penState); + }); + }, +}; -module.exports = (cncserver) => { - const io = socketio(cncserver.server.httpServer); +/** + * Send an update to all stream clients when something is added to the buffer. + * Includes only the item added to the buffer, expects the client to handle. + */ +export function sendBufferAdd(item, hash) { + const data = { + type: 'add', + item, + hash, + }; - // SOCKET DATA STREAM ======================================================== - io.on('connection', (socket) => { - // Send buffer and pen updates on user connect - cncserver.sockets.sendPenUpdate(); + /* TODO: This is not the right way to do this. + if (cncserver.exports.bufferUpdateTrigger) { + cncserver.exports.bufferUpdateTrigger(data); + } + */ - // TODO: this likely needs to be sent ONLY to new connections - cncserver.sockets.sendBufferComplete(); + io.emit('buffer update', data); +} - socket.on('disconnect', () => { - // console.log('user disconnected'); - }); +/** + * Send an update to all stream clients when something is removed from the + * buffer. Assumes the client knows where to remove from. + */ +export function sendBufferRemove() { + const data = { + type: 'remove', + }; - // Shortcuts for moving and height for streaming lots of commands. - socket.on('move', cncserver.sockets.shortcut.move); - socket.on('height', cncserver.sockets.shortcut.height); - }); + /* TODO: This is not the right way to do this. + if (cncserver.exports.bufferUpdateTrigger) { + cncserver.exports.bufferUpdateTrigger(data); + } + */ + io.emit('buffer update', data); +} - /** - * Send an update to all Stream clients about the actualPen object. - * Called whenever actualPen object has been changed, E.G.: right before - * a serial command is run, or internal state changes. - */ - sockets.sendPenUpdate = () => { - if (cncserver.exports.penUpdateTrigger) { - cncserver.exports.penUpdateTrigger(cncserver.actualPen.state); - } - io.emit('pen update', cncserver.actualPen.state); +/** + * Send an update to all stream clients when something is added to the buffer. + * Includes only the item added to the buffer, expects the client to handle. + */ +export function sendBufferVars() { + const data = { + type: 'vars', + bufferRunning: bufferState.running, + bufferPaused: bufferState.paused, + bufferPausePen: bufferState.pausePen, }; - /** - * Send an update to all stream clients when something is added to the buffer. - * Includes only the item added to the buffer, expects the client to handle. - */ - sockets.sendBufferAdd = (item, hash) => { - const data = { - type: 'add', - item, - hash, - }; - - if (cncserver.exports.bufferUpdateTrigger) { - cncserver.exports.bufferUpdateTrigger(data); - } - io.emit('buffer update', data); - }; + /* TODO: This is not the right way to do this. + if (cncserver.exports.bufferUpdateTrigger) { + cncserver.exports.bufferUpdateTrigger(data); + } + */ + io.emit('buffer update', data); +} - /** - * Send an update to all stream clients when something is removed from the - * buffer. Assumes the client knows where to remove from. - */ - sockets.sendBufferRemove = () => { - const data = { - type: 'remove', - }; +/** + * Send an update to all stream clients of the given custom text string. + * + * @param {string} message + * Message to send out to all clients. + */ +export function sendMessageUpdate(message) { + io.emit('message update', { + message, + timestamp: new Date().toString(), + }); +} - if (cncserver.exports.bufferUpdateTrigger) { - cncserver.exports.bufferUpdateTrigger(data); - } - io.emit('buffer update', data); - }; +/** + * Send an update to all stream clients of a machine name callback event. + * + * @param {string} name + * Machine name of callback to send to clients + */ +export function sendCallbackUpdate(name) { + io.emit('callback update', { + name, + timestamp: new Date().toString(), + }); +} - /** - * Send an update to all stream clients when something is added to the buffer. - * Includes only the item added to the buffer, expects the client to handle. - */ - sockets.sendBufferVars = () => { - const data = { - type: 'vars', - bufferRunning: cncserver.buffer.running, - bufferPaused: cncserver.buffer.paused, - bufferPausePen: cncserver.buffer.pausePen, - }; - - if (cncserver.exports.bufferUpdateTrigger) { - cncserver.exports.bufferUpdateTrigger(data); - } - io.emit('buffer update', data); - }; +/** + * Trigger manual swap complete to all stream clients. Buffer will be paused. + * + * @param {int} vIndex + * Virtual index of manual swap + */ +export function manualSwapTrigger(vIndex) { + io.emit('manualswap trigger', { index: vIndex }); +} - /** - * Send an update to all stream clients about everything buffer related. - * Called only during connection inits. - */ - sockets.sendBufferComplete = () => { - const data = { - type: 'complete', - bufferList: cncserver.buffer.data, - bufferData: cncserver.buffer.dataSet, - bufferRunning: cncserver.buffer.running, - bufferPaused: cncserver.buffer.paused, - bufferPausePen: cncserver.buffer.pausePen, - }; - - // Low-level event callback trigger to avoid Socket.io overhead - if (cncserver.exports.bufferUpdateTrigger) { - cncserver.exports.bufferUpdateTrigger(data); - } - io.emit('buffer update', data); - - // Send this second. - sockets.sendPaperUpdate('preview'); - sockets.sendPaperUpdate('stage'); - }; +/** + * Clientside helper for keeping object variable states in sync. + * + * @export + * @param {string} key + * Global recognized key for variable (colorset, pen, etc). + * @param {*} value + * New complete value for item. + */ +export function liveStateUpdate(key, value) { + io.emit(`livestate ${key}`, value); +} - /** - * Send an update to all stream clients of the given custom text string. - * - * @param {string} message - * Message to send out to all clients. - */ - sockets.sendMessageUpdate = (message) => { - io.emit('message update', { - message, - timestamp: new Date().toString(), - }); - }; +// Manage state change via binders. +bindTo('colors.update', 'sockets.liveupdate', colorset => { + liveStateUpdate('colorset', colorset); +}); +bindTo('project.update', 'sockets.liveupdate', project => { + liveStateUpdate('project', project); +}); - /** - * Send an update to all stream clients of a machine name callback event. - * - * @param {string} name - * Machine name of callback to send to clients - */ - sockets.sendCallbackUpdate = (name) => { - io.emit('callback update', { - name, - timestamp: new Date().toString(), - }); - }; +bindTo('pen.update', 'sockets.liveupdate', pen => { + liveStateUpdate('pen', pen); +}); - /** - * Trigger manual swap complete to all stream clients. Buffer will be paused. - * - * @param {int} vIndex - * Virtual index of manual swap - */ - sockets.manualSwapTrigger = (vIndex) => { - io.emit('manualswap trigger', { index: vIndex }); - }; +bindTo('actualpen.update', 'sockets.liveupdate', actualPen => { + liveStateUpdate('actualPen', actualPen); +}); - /** - * Send an update to all stream clients for a Paper layer update. - */ - sockets.sendPaperUpdate = (layer = 'preview') => { - if (layer === 'preview') { - cncserver.drawing.colors.snapPathColors( - cncserver.drawing.base.layers.preview - ); - } - - io.emit('paper layer', { - layer, - paperJSON: cncserver.drawing.base.layers[layer].exportJSON(), - timestamp: new Date().toString(), - }); - }; +/** + * Send an update to all stream clients for a Paper layer update. + */ +export function sendPaperUpdate(layer = 'preview') { + if (layer === 'preview') { + snapPathsToColorset(layers.preview); + sendPaperUpdate('print'); + } + + io.emit('paper layer', { + layer, + paperJSON: layers[layer].exportJSON(), + timestamp: new Date().toString(), + }); +} - // Shortcut functions for move/height streaming. - sockets.shortcut = { - move: (data) => { - cncserver.pen.setPen(data, () => { - if (data.returnData) io.emit('move', cncserver.pen.state); - }); - }, - - height: (data) => { - cncserver.pen.setPen({ state: data.state }, () => { - if (data.returnData) io.emit('height', cncserver.pen.state); - }); - }, +/** + * Send an update to all stream clients about everything buffer related. + * Called only during connection inits. + */ +export function sendBufferComplete() { + const data = { + type: 'complete', + bufferList: bufferState.data, + bufferData: bufferState.dataSet, + bufferRunning: bufferState.running, + bufferPaused: bufferState.paused, + bufferPausePen: bufferState.pausePen, }; - return sockets; -}; + // Low-level event callback trigger to avoid Socket.io overhead + /* TODO: This is not the right way to do this. + if (cncserver.exports.bufferUpdateTrigger) { + cncserver.exports.bufferUpdateTrigger(data); + } + */ + io.emit('buffer update', data); + + // Send this second. + sendPaperUpdate('preview'); + sendPaperUpdate('stage'); + sendPaperUpdate('tools'); +} + +// SOCKET DATA STREAM. +io.on('connection', socket => { + // Send buffer and pen updates on user connect + trigger('pen.update', penState); + + // TODO: this likely needs to be sent ONLY to new connections + sendBufferComplete(); + + socket.on('disconnect', () => { + // console.log('user disconnected'); + }); + + // Shortcuts for moving and height for streaming lots of commands. + socket.on('move', shortcut.move); + socket.on('height', shortcut.height); +}); diff --git a/src/components/core/control/cncserver.actualpen.js b/src/components/core/control/cncserver.actualpen.js index c5910b77..3983ab8e 100644 --- a/src/components/core/control/cncserver.actualpen.js +++ b/src/components/core/control/cncserver.actualpen.js @@ -2,46 +2,31 @@ * @file Abstraction module for functions that generate movement or help * functions for calculating movement command generation for CNC Server! */ -const extend = require('util')._extend; // Util for cloning objects +import { bindTo, trigger } from 'cs/binder'; +import { applyObjectTo } from 'cs/utils'; -const actualPen = {}; // Exposed export. +// actualPen: This is set to the state of the pen variable as it passes through +// the buffer queue and into the robot, meant to reflect the actual position and +// state of the robot, and will be where the pen object is reset to when the +// buffer is cleared and the future state is lost. +export const state = {}; -module.exports = (cncserver) => { - // actualPen: This is set to the state of the pen variable as it passes through - // the buffer queue and into the robot, meant to reflect the actual position and - // state of the robot, and will be where the pen object is reset to when the - // buffer is cleared and the future state is lost. - actualPen.state = extend({}, cncserver.pen.state); - - /** - * Set internal state object as an extended copy of the passed object. - * - * @param {object} state - */ - actualPen.updateState = (state) => { - actualPen.state = extend({}, state); - - // Trigger an update for actualPen change. - cncserver.sockets.sendPenUpdate(); - }; - - /** - * Force the values of a given set of keys within the actualPen state. - * - * @param {object} inState - * Flat object of key/value pairs to FORCE into the actualPen state. Only - * used to fix state when it needs correcting from inherited buffer. - * EG: After a cancel/estop. - */ - actualPen.forceState = (inState) => { - for (const [key, value] of Object.entries(inState)) { - // Only set a value if the key exists in the state already. - if (key in actualPen.state) { - actualPen.state[key] = value; - } - } - cncserver.sockets.sendPenUpdate(); - }; +/** + * Force the values of a given set of keys within the actualPen state. + * + * @param {object} inState + * Flat object of key/value pairs to FORCE into the actualPen state. Only + * used to fix state when it needs correcting from inherited buffer. + * EG: After a cancel/estop. + */ +export function forceState(inState) { + // Only trigger update if state changed. + if (applyObjectTo(inState, state)) { + trigger('actualpen.update', state); + } +} - return actualPen; -}; +// On pen setup, force state to match. +bindTo('pen.setup', 'actualPen', newState => { + forceState(newState); +}); diff --git a/src/components/core/control/cncserver.content.js b/src/components/core/control/cncserver.content.js index f7a83849..9819ec4e 100644 --- a/src/components/core/control/cncserver.content.js +++ b/src/components/core/control/cncserver.content.js @@ -1,495 +1,488 @@ /** * @file Abstraction for high level project content management. */ -const { Raster, PointText } = require('paper'); -const request = require('request'); -const DataURI = require('datauri'); - -// Exposed export to be attached as cncserver.content -const content = { - id: 'content', - items: new Map(), - limits: { - fileSize: 9 * 1024 * 1024, // TODO: Get this into settings somewhere. - mimetypes: { - svg: ['image/svg+xml'], - path: ['text/plain'], - paper: ['application/json'], - raster: ['image/bmp', 'image/gif', 'image/png', 'image/jpeg'], - text: ['text/plain'], - }, - // Extensions from mimetype. - extensions: { - 'image/svg+xml': 'svg', - 'application/json': 'json', - 'text/plain': 'txt', - 'image/bmp': 'bmp', - 'image/gif': 'gif', - 'image/png': 'png', - 'image/jpeg': 'jpg', - }, +import Paper from 'paper'; +import request from 'request'; +import DataURI from 'datauri'; +import { singleLineString, getHash, merge } from 'cs/utils'; +import { sendPaperUpdate } from 'cs/sockets'; +import { getDataDefault } from 'cs/schemas'; +import * as drawing from 'cs/drawing'; +import * as projects from 'cs/projects'; + +const { Raster, PointText } = Paper; + +export const items = new Map(); +export const limits = { // TODO: Get this into settings somewhere. + fileSize: 9 * 1024 * 1024, + mimetypes: { + svg: ['image/svg+xml'], + path: ['text/plain'], + paper: ['application/json'], + raster: ['image/bmp', 'image/gif', 'image/png', 'image/jpeg'], + text: ['text/plain'], + }, + // Extensions from mimetype. + extensions: { + 'image/svg+xml': 'svg', + 'application/json': 'json', + 'text/plain': 'txt', + 'image/bmp': 'bmp', + 'image/gif': 'gif', + 'image/png': 'png', + 'image/jpeg': 'jpg', }, }; -module.exports = (cncserver) => { - const { utils, projects } = cncserver; - const { limits } = content; - - /** - * Validates a source object to contain the correctly formatted string. - * Converts raster binary URL input to data URI as needed. - * - * @param {object} source - * "Content" schema validated source object from request. - * - * @returns {Promise} - * Resolves with source object with content key populated with string, - * rejects error object detailing problem. - */ - function normalizeContentSource(source) { - return new Promise((resolve, reject) => { - // First, sanity check that we have anything to work with. - if (!source.url && !source.content) { - reject(new Error('Request failed: Must include either source content or url')); - } else if (source.url && !source.content) { - // Validate URL via HEADer request first, don't download if we don't have to. - request({ url: source.url, method: 'HEAD' }, (error, res) => { - const mimetype = res.headers['content-type']; - - // How'd the request go? - if (error) { - // Escape on error. - reject(error); - } else if (res.statusCode < 200 || res.statusCode > 202) { - // Escape if the server says something bad happened. - reject( - new Error(`Request failed: Server returned status code ${res.statusCode}`) - ); - } else if (res.headers['content-length'] > limits.fileSize) { - // Escape if the file is too big. - reject( - new Error( - utils.singleLineString`Request failed: Requested file size - (${res.headers['content-length']} bytes) is greater than maximum - allowed (${limits.fileSize} bytes)` - ) - ); - } else if (!limits.mimetypes[source.type].includes(mimetype)) { - reject( - new Error( - utils.singleLineString`Content type for URL is - '${mimetype}', must be one of - [${limits.mimetypes[source.type].join(', ')}] - for type: ${source.type}` - ) - ); - - // We should be good! Actually go get the file. - } else if (source.type === 'raster') { - // Binary source content needs to be converted to data URI. - // TODO: Handling this like this means a LOT of memory usage for larger files, - // should move to buffer/stream based handling. - request.get({ url: source.url, encoding: null }, (getErr, getRes, body) => { - if (getErr) { - reject(getErr); - } else { - const datauri = new DataURI(); - datauri.format(limits.extensions[mimetype], body); - resolve({ - ...source, - mimetype, - content: datauri.content, - }); - } - }); - } else { - // UTF-8/ASCII text content can just be grabbed directly. - request.get({ url: source.url }, (getErr, getRes, body) => { - if (getErr) { - reject(getErr); - } else { - resolve({ - ...source, - content: body, - mimetype: limits.mimetypes[source.type][0], - }); - } - }); - } - }); - } else { - // We already have the content, resolve the full source object. - // TODO: Validate that raster input handed here is actually a data URI - // as the error for adding bad data here isn't helpful. - resolve({ ...source, mimetype: limits.mimetypes[source.type][0] }); - } - }); - } - - /** - * Normalize incoming content between allowed types given intent. - * Imports content to "import" layer in prep for adding to project. - * - * @param {object} payload - * The content schema validated object from request to be imported. - * - * @return {Promise} - * Promise that returns on success the imported & normalized input, or error - * on failure. - */ - content.normalizeInput = payload => new Promise((success, err) => { - const { drawing: { base: { layers } } } = cncserver; - const settings = projects.getFullSettings(payload.settings); - - // Validate the incoming request for correct project destination. - if (!payload.project) { - // Default to current. - payload.project = projects.getCurrentHash(); - } else if (!projects.items.get(payload.project)) { - err(new Error( - utils.singleLineString`Project identified by '${payload.project}' does - not exist. Verify you have the correct project, or create a new one - before adding content.` - )); - return; - } - - // Get the data from the URL or the content. - normalizeContentSource(payload.source).then((source) => { - // Draw to empty import layer. - layers.import.activate(); - layers.import.removeChildren(); - - let item = null; - - // What kind of input content is this? - // Options: svg, path, paper, raster, text - switch (source.type) { - // Full SVG XML content - case 'svg': - try { - item = layers.import.importSVG(source.content.trim(), { - expandShapes: true, - applyMatrix: true, - }); - - if (!item || !item.children) { - err(new Error( - utils.singleLineString`Failed to import SVG, please validate - source content and try again.` - )); +/** + * Validates a source object to contain the correctly formatted string. + * Converts raster binary URL input to data URI as needed. + * + * @param {object} source + * "Content" schema validated source object from request. + * + * @returns {Promise} + * Resolves with source object with content key populated with string, + * rejects error object detailing problem. + */ +function normalizeContentSource(source) { + return new Promise((resolve, reject) => { + // First, sanity check that we have anything to work with. + if (!source.url && !source.content) { + reject(new Error('Request failed: Must include either source content or url')); + } else if (source.url && !source.content) { + // Validate URL via HEADer request first, don't download if we don't have to. + request({ url: source.url, method: 'HEAD' }, (error, res) => { + const mimetype = res.headers['content-type']; + + // How'd the request go? + if (error) { + // Escape on error. + reject(error); + } else if (res.statusCode < 200 || res.statusCode > 202) { + // Escape if the server says something bad happened. + reject( + new Error(`Request failed: Server returned status code ${res.statusCode}`) + ); + } else if (res.headers['content-length'] > limits.fileSize) { + // Escape if the file is too big. + reject( + new Error( + singleLineString`Request failed: Requested file size + (${res.headers['content-length']} bytes) is greater than maximum + allowed (${limits.fileSize} bytes)` + ) + ); + } else if (!limits.mimetypes[source.type].includes(mimetype)) { + reject( + new Error( + singleLineString`Content type for URL is + '${mimetype}', must be one of + [${limits.mimetypes[source.type].join(', ')}] + for type: ${source.type}` + ) + ); + + // We should be good! Actually go get the file. + } else if (source.type === 'raster') { + // Binary source content needs to be converted to data URI. + // TODO: Handling this like this means a LOT of memory usage for larger files, + // should move to buffer/stream based handling. + request.get({ url: source.url, encoding: null }, (getErr, getRes, body) => { + if (getErr) { + reject(getErr); } else { - success({ ...payload, source, item }); + const datauri = new DataURI(); + datauri.format(limits.extensions[mimetype], body); + resolve({ + ...source, + mimetype, + content: datauri.content, + }); } - } catch (error) { - // Likely couldn't parse SVG. - err(error); - } - break; - - case 'path': - try { - item = cncserver.drawing.base.normalizeCompoundPath(source.content); - - // Apply all path item defaults directly. - // TODO: streamline this a bit more? - if (settings.fill.render) item.fillColor = settings.path.fillColor; - if (settings.stroke.render) item.strokeColor = settings.path.strokeColor; - - // Does this come from somewhere else?? - // item.closed = settings.path.closed; - - // If we don't have a path at this point we failed to import anything. - if (!item || !item.length) { - throw new Error('Invalid path source, verify input content and try again.'); + }); + } else { + // UTF-8/ASCII text content can just be grabbed directly. + request.get({ url: source.url }, (getErr, getRes, body) => { + if (getErr) { + reject(getErr); + } else { + resolve({ + ...source, + content: body, + mimetype: limits.mimetypes[source.type][0], + }); } - - layers.import.addChild(item); - success({ ...payload, source, item }); - } catch (error) { - // Likely not a valid path string. - err(error); - } - break; - - case 'paper': - try { - item = layers.import.importJSON(source.content); - success({ ...payload, source, item }); - } catch (error) { - // Likely couldn't parse JSON. - err(error); - } - break; - - case 'raster': - // Content should be a Data URI at this point. - try { - item = new Raster(source.content); - item.onError = (error) => { - err(new Error(`Problem loading raster: ${error}`)); - }; - item.onLoad = () => { - success({ ...payload, source, item }); - }; - } catch (imageErr) { - // Couldn't load image. - err(imageErr); - } - break; - - case 'text': - item = new PointText({ - point: cncserver.drawing.base.validateBounds(payload.bounds), - content: source.content, }); - success({ ...payload, source, item }); - break; - - default: - break; - } - // ^^^ All promise resolves exist in the type specific logic above ^^^ - }).catch(err); - }); - - // TODO: Get all items in current project. - content.getItems = () => { - const items = []; - content.items.forEach(({ - hash, title, bounds, - }) => { - items.push({ - hash, title, bounds, + } }); - }); - return items; - }; - - // Assume schema has been checked by the time we get here. - content.addItem = payload => new Promise((resolve, reject) => { - // TODO: We have content! We need to add it to the requested project. - // - Generate a non-incremental hash for the content string - // - Write a file with the contents to the project folder: [hash].[extension] - // - Add functionality for project to retrieve content data to write to its - // store and JSON. - // - Build the content object and return. - - projects.saveContentFile(payload.source, payload.project).then((filePath) => { - // Build the final content item. - const item = { ...payload }; - item.source.content = filePath; - const hash = utils.getHash(item); - item.hash = hash; - - item.item = cncserver.drawing.stage.import(payload.item, hash, item.bounds); - content.items.set(hash, item); - - const responseItem = content.getResponseItem(hash); - projects.saveContentData(responseItem, payload.project); - resolve(responseItem); - }).catch(reject); - }); - - // Format a content item from an internal item into a response item. - content.getResponseItem = (hash) => { - const item = content.items.get(hash); - const fullItem = cncserver.schemas.getDataDefault('content', item); - return { - project: item.project, - hash: item.hash, - autoRender: fullItem.autoRender, - title: fullItem.title, - bounds: fullItem.bounds, // TODO: Format as object - source: { - type: item.source.type, - originalUrl: item.source.url || '', - content: item.source.content, - }, - settings: item.settings || {}, - }; - }; - - // Take in a validated merged item object and compare against the change item - // to see how to make edits. - content.editItem = ({ hash }, deltaItem, mergedItem) => new Promise((resolve, reject) => { - // Changing content? Run through normalizer. - if (deltaItem.source) { - content.normalizeInput(mergedItem).then((finalItem) => { - projects.saveContentFile(finalItem.source, finalItem.project).then((filePath) => { - // Build the final content item. - const item = { ...finalItem }; - item.source.content = filePath; - item.hash = hash; - - item.item = cncserver.drawing.stage.import(finalItem.item, hash, item.bounds); - content.items.set(hash, item); - - const responseItem = content.getResponseItem(hash); - projects.saveContentData(responseItem, mergedItem.project); - resolve(responseItem); - - // Trigger autorender after resolve. - if (responseItem.autoRender) { - projects.setRenderingState(true, hash); - } - }).catch(reject); - }).catch(reject); } else { - // Otherwise, just assume the deep merged object is good and update stage. - const item = content.items.get(hash); - const fullChangedItem = utils.merge(item, deltaItem); - content.items.set(hash, fullChangedItem); - cncserver.drawing.stage.updateItem(fullChangedItem); - - // TODO: Apply path specific defaults that change stage item (color, fill render, etc); - const responseItem = content.getResponseItem(hash); - projects.saveContentData(responseItem, mergedItem.project); - resolve(responseItem); - - // Trigger autorender after resolve. - if (responseItem.autoRender) { - projects.setRenderingState(true, hash); - } + // We already have the content, resolve the full source object. + // TODO: Validate that raster input handed here is actually a data URI + // as the error for adding bad data here isn't helpful. + resolve({ ...source, mimetype: limits.mimetypes[source.type][0] }); } }); +} +/** + * Normalize incoming content between allowed types given intent. + * Imports content to "import" layer in prep for adding to project. + * + * @param {object} payload + * The content schema validated object from request to be imported. + * + * @return {Promise} + * Promise that returns on success the imported & normalized input, or error + * on failure. + */ +export const normalizeInput = payload => new Promise((success, err) => { + const settings = projects.getFullSettings(payload.settings); + + // Validate the incoming request for correct project destination. + if (!payload.project) { + // Default to current. + payload.project = projects.getCurrentHash(); + } else if (!projects.items.get(payload.project)) { + err(new Error( + singleLineString`Project identified by '${payload.project}' does + not exist. Verify you have the correct project, or create a new one + before adding content.` + )); + return; + } - // Pull a piece of content in fully from a file and config. - content.loadFromFile = (fileItem, data) => { - const fileName = fileItem.source.content; - const filePath = projects.getContentFilePath(fileName, fileItem.project); + // Get the data from the URL or the content. + normalizeContentSource(payload.source).then(source => { + const { layers } = drawing.base; + // Draw to empty import layer. + layers.import.activate(); + layers.import.removeChildren(); - // Prepare a copy of the item to be validated. - const item = { ...fileItem }; - item.source.content = data; + let item = null; - content.normalizeInput(item).then((finalItem) => { - // Build the final content item. - item.source.content = fileName; + // What kind of input content is this? + // Options: svg, path, paper, raster, text + switch (source.type) { + // Full SVG XML content + case 'svg': + try { + item = layers.import.importSVG(source.content.trim(), { + expandShapes: true, + applyMatrix: true, + }); - item.item = cncserver.drawing.stage.import(finalItem.item, item.hash, item.bounds); - content.items.set(item.hash, item); - }).catch((err) => { - console.error(err); - throw new Error(`Error loading content for file ${filePath}`); - }); - }; + if (!item || !item.children) { + err(new Error( + singleLineString`Failed to import SVG, please validate + source content and try again.` + )); + } else { + drawing.base.validateFills(item); + success({ ...payload, source, item }); + } + } catch (error) { + // Likely couldn't parse SVG. + err(error); + } + break; - // Fully remove a piece of content from a project. - content.removeItem = (hash) => { - const { project } = content.items.get(hash); - cncserver.drawing.stage.remove(hash); - content.items.delete(hash); - projects.removeContentData(hash, project); - cncserver.sockets.sendPaperUpdate('stage'); - // TODO: Delete file, update project. - }; + case 'path': + try { + item = drawing.base.normalizeCompoundPath(source.content); - // Render a single content item (that may or may not contain other paths). - // Assume a fully validated content item with delta settings. - content.renderContentItem = ({ - hash, title, item, source, bounds, settings: rawSettings, project, - }) => new Promise((resolve, reject) => { - const { - drawing: { - fill, trace, vectorize, text, preview, - }, - } = cncserver; - const settings = projects.getFullSettings(rawSettings); - const promiseQueue = []; + // Apply all path item defaults directly. + // TODO: streamline this a bit more? + if (settings.fill.render) item.fillColor = settings.path.fillColor; + if (settings.stroke.render) item.strokeColor = settings.path.strokeColor; - console.log(`Rendering ${title} - ${hash}...`); - preview.remove(hash, true); + // Does this come from somewhere else?? + // item.closed = settings.path.closed; + // If we don't have a path at this point we failed to import anything. + if (!item || !item.length) { + throw new Error('Invalid path source, verify input content and try again.'); + } - switch (source.type) { - // Full Item Group (svg or paper) - case 'paper': - case 'svg': - // Break it down into parts and render those single items. - promiseQueue.push(content.renderGroup(item, hash, settings)); + layers.import.addChild(item); + success({ ...payload, source, item }); + } catch (error) { + // Likely not a valid path string. + err(error); + } break; - case 'path': - // Single item render. - // console.log(settings); - if (settings.fill.render) { - promiseQueue.push(fill(item, hash, bounds, settings.fill)); - } - if (settings.stroke.render) { - promiseQueue.push(trace(item, hash, bounds, settings.stroke)); + case 'paper': + try { + item = layers.import.importJSON(source.content); + success({ ...payload, source, item }); + } catch (error) { + // Likely couldn't parse JSON. + err(error); } break; case 'raster': - // Rasterization render (single item only) - // Full settings are passed here as fills and stroke can be generated. - if (settings.vectorize.render) { - const imagePath = projects.getContentFilePath(source.content, project); - promiseQueue.push(vectorize(imagePath, hash, bounds, settings)); + // Content should be a Data URI at this point. + try { + item = new Raster(source.content); + item.onError = error => { + err(new Error(`Problem loading raster: ${error}`)); + }; + item.onLoad = () => { + success({ ...payload, source, item }); + }; + } catch (imageErr) { + // Couldn't load image. + err(imageErr); } break; case 'text': - // Text render to bounds, etc. - // Full settings are passed here as fills can be sub-rendered on system fonts. - if (settings.text.render) { - promiseQueue.push(text.draw(item.content, hash, bounds, settings)); - } + item = new PointText({ + point: drawing.base.validateBounds(payload.bounds), + content: drawing.text.format(source.content), + data: { originalText: source.content }, + }); + success({ ...payload, source, item }); break; default: break; } - - Promise.all(promiseQueue).then(resolve).catch(reject); + // ^^^ All promise resolves exist in the type specific logic above ^^^ + }).catch(err); +}); + +// TODO: Get all items in current project. +export function getItems() { + const returnItems = []; + items.forEach(({ + hash, title, bounds, + }) => { + returnItems.push({ + hash, title, bounds, + }); }); - - // Do everything needed to render a group of path items (no rasters or text). - content.renderGroup = (group, hash, settings, skipOcclusion = false) => new Promise((resolve, reject) => { - const { - fill, trace, occlusion, temp, - } = cncserver.drawing; - const { ungroupAllGroups, cleanupInput } = cncserver.drawing.base; - const promiseQueue = []; - - // Render Stroke for all subitems. - if (settings.stroke.render) { - const tempGroup = temp.addItem(group, `${hash}-stroke`); - ungroupAllGroups(tempGroup); - cleanupInput(tempGroup, settings); - - if (settings.stroke.cutoutOcclusion && !skipOcclusion) { - occlusion('stroke', tempGroup); + return returnItems; +} + +// Assume schema has been checked by the time we get here. +export const addItem = payload => new Promise((resolve, reject) => { + // TODO: We have content! We need to add it to the requested project. + // - Generate a non-incremental hash for the content string + // - Write a file with the contents to the project folder: [hash].[extension] + // - Add functionality for project to retrieve content data to write to its + // store and JSON. + // - Build the content object and return. + + projects.saveContentFile(payload.source, payload.project).then(filePath => { + // Build the final content item. + const item = { ...payload }; + item.source.content = filePath; + const hash = getHash(item); + item.hash = hash; + + item.item = drawing.stage.import(payload.item, hash, item.bounds); + items.set(hash, item); + + const responseItem = getResponseItem(hash); + projects.saveContentData(responseItem, payload.project); + resolve(responseItem); + }).catch(reject); +}); + +// Format a content item from an internal item into a response item. +export function getResponseItem(hash) { + const item = items.get(hash); + const fullItem = getDataDefault('content', item); + return { + project: item.project, + hash: item.hash, + autoRender: fullItem.autoRender, + title: fullItem.title, + bounds: fullItem.bounds, // TODO: Format as object + source: { + type: item.source.type, + originalUrl: item.source.url || '', + content: item.source.content, + }, + settings: item.settings || {}, + }; +} + +// Take in a validated merged item object and compare against the change item +// to see how to make edits. +export const editItem = ( + { hash }, deltaItem, mergedItem +) => new Promise((resolve, reject) => { + // Changing content? Run through normalizer. + if (deltaItem.source) { + normalizeInput(mergedItem).then(finalItem => { + projects.saveContentFile(finalItem.source, finalItem.project).then(filePath => { + // Build the final content item. + const item = { ...finalItem }; + item.source.content = filePath; + item.hash = hash; + + item.item = drawing.stage.import(finalItem.item, hash, item.bounds); + items.set(hash, item); + + const responseItem = getResponseItem(hash); + projects.saveContentData(responseItem, mergedItem.project); + resolve(responseItem); + + // Trigger autorender after resolve. + if (responseItem.autoRender) { + projects.setRenderingState(true, hash); + } + }).catch(reject); + }).catch(reject); + } else { + // Otherwise, just assume the deep merged object is good and update stage. + const item = items.get(hash); + const fullChangedItem = merge(item, deltaItem); + items.set(hash, fullChangedItem); + drawing.stage.updateItem(fullChangedItem); + + // TODO: Apply path specific defaults to change stage item (color, fill render, etc); + const responseItem = getResponseItem(hash); + projects.saveContentData(responseItem, mergedItem.project); + resolve(responseItem); + + // Trigger autorender after resolve. + if (responseItem.autoRender) { + projects.setRenderingState(true, hash); + } + } +}); + +// Pull a piece of content in fully from a file and config. +export function loadFromFile(fileItem, data) { + const fileName = fileItem.source.content; + const filePath = projects.getContentFilePath(fileName, fileItem.project); + + // Prepare a copy of the item to be validated. + const item = { ...fileItem }; + item.source.content = data; + + normalizeInput(item).then(finalItem => { + // Build the final content item. + item.source.content = fileName; + + item.item = drawing.stage.importGroup(finalItem.item, item.hash, item.bounds); + items.set(item.hash, item); + }).catch(err => { + console.error(err); + throw new Error(`Error loading content for file ${filePath}`); + }); +} + +// Fully remove a piece of content from a project. +export function removeItem(hash) { + const { project } = items.get(hash); + drawing.stage.remove(hash); + items.delete(hash); + projects.removeContentData(hash, project); + drawing.preview.remove(hash, true); + sendPaperUpdate('stage'); + // TODO: Delete file, update project. +} + +// Render a single content item (that may or may not contain other paths). +// Assume a fully validated content item with delta settings. +export const renderContentItem = ({ + hash, title, item, source, bounds, settings: rawSettings, project, +}) => new Promise((resolve, reject) => { + const settings = projects.getFullSettings(rawSettings); + const promiseQueue = []; + + console.log(`Rendering ${title} - ${hash}...`); + drawing.preview.remove(hash, true); + + switch (source.type) { + // Full Item Group (svg or paper) + case 'paper': + case 'svg': + // Break it down into parts and render those single items. + promiseQueue.push(renderGroup(item, hash, settings)); + break; + + case 'path': + // Single item render. + if (settings.fill.render) { + promiseQueue.push(drawing.fill(item, hash, bounds, settings.fill)); + } + if (settings.stroke.render) { + promiseQueue.push(drawing.trace(item, hash, bounds, settings.stroke)); } + break; + + case 'raster': + // Rasterization render (single item only) + // Full settings are passed here as fills and stroke can be generated. + if (settings.vectorize.render) { + const imagePath = projects.getContentFilePath(source.content, project); + promiseQueue.push(drawing.vectorize(imagePath, hash, bounds, settings)); + } + break; + + case 'text': + // Text render to bounds, etc. + // Full settings are passed here as fills can be sub-rendered on system fonts. + if (settings.text.render) { + promiseQueue.push( + drawing.text.draw(item.data.originalText, hash, bounds, settings) + ); + } + break; - // TODO: Only run stroke here if one is needed. - tempGroup.children.forEach((path) => { - promiseQueue.push(trace(path, hash, null, settings.stroke)); - }); - } + default: + break; + } + Promise.all(promiseQueue).then(resolve).catch(reject); +}); - // Render Fill for all subitems. - if (settings.fill.render) { - const tempGroupFill = temp.addItem(group, `${hash}-fill`); - ungroupAllGroups(tempGroupFill); - cleanupInput(tempGroupFill, settings); +// Do everything needed to render a group of path items (no rasters or text). +export const renderGroup = ( + group, hash, settings, skipOcclusion = false +) => new Promise((resolve, reject) => { + const { ungroupAllGroups, cleanupInput } = drawing.base; + const promiseQueue = []; - if (settings.fill.cutoutOcclusion && !skipOcclusion) { - occlusion('fill', tempGroupFill); - } + // Render Stroke for all subitems. + if (settings.stroke.render) { + const tempGroup = drawing.temp.addItem(group, `${hash}-stroke`); + ungroupAllGroups(tempGroup); + cleanupInput(tempGroup, settings); - tempGroupFill.children.forEach((path, index) => { - if (path.hasFill()) { - promiseQueue.push(fill(path, hash, null, settings.fill, index)); - } - }); + if (settings.stroke.cutoutOcclusion && !skipOcclusion) { + drawing.occlusion('stroke', tempGroup); } - Promise.all(promiseQueue).then(resolve).catch(reject); - }); + // TODO: Only run stroke here if one is needed. + tempGroup.children.forEach(path => { + promiseQueue.push(drawing.trace(path, hash, null, settings.stroke)); + }); + } - return content; -}; + // Render Fill for all subitems. + if (settings.fill.render) { + const tempGroupFill = drawing.temp.addItem(group, `${hash}-fill`); + ungroupAllGroups(tempGroupFill); + cleanupInput(tempGroupFill, settings); + + if (settings.fill.cutoutOcclusion && !skipOcclusion) { + drawing.occlusion('fill', tempGroupFill); + } + + tempGroupFill.children.forEach((path, index) => { + if (path.hasFill()) { + promiseQueue.push(drawing.fill(path, hash, null, settings.fill, index)); + } + }); + } + + Promise.all(promiseQueue).then(resolve).catch(reject); +}); diff --git a/src/components/core/control/cncserver.control.js b/src/components/core/control/cncserver.control.js index da66350f..655096b8 100644 --- a/src/components/core/control/cncserver.control.js +++ b/src/components/core/control/cncserver.control.js @@ -2,542 +2,324 @@ * @file Abstraction module for functions that generate movement or help * functions for calculating movement command generation for CNC Server! */ -const control = {}; // Exposed export. - -module.exports = (cncserver) => { - /** - * Run the operation to set the current tool (and any aggregate operations - * required) into the buffer - * - * @param name - * The machine name of the tool (as defined in the bot config file). - * @param index - * Index for notifying user of what the manual tool change is for. - * @param callback - * Triggered when the full tool change is to have been completed, or on - * failure. - * @param waitForCompletion - * Pass false to call callback immediately after calculation, true to - * callback only after physical movement is complete. - * - * @returns {boolean} - * True if success, false on failure. - */ - control.setTool = (name, index = null, callback = () => {}, waitForCompletion = false) => { - // Get the matching tool object from the bot configuration. - const tool = cncserver.settings.botConf.get(`tools:${name}`); - - // No tool found with that name? Augh! Run AWAY! - if (!tool) { - cncserver.run('callback', callback); - return false; - } +import * as utils from 'cs/utils'; +import { bot, gConf } from 'cs/settings'; +import { state as bufferState, render } from 'cs/buffer'; +import * as pen from 'cs/pen'; +import * as actualPen from 'cs/actualPen'; +import run from 'cs/run'; +import { sendMessage } from 'cs/ipc'; + +// Command state variables. +export const state = { + commandDuration: 0, +}; - // For wait=false/"resume" tools, we really just resume the buffer. - // It should be noted, this is obviously NOT a queable toolchange. - // This should ONLY be called to restart the queue after a swap. - if (tool.wait !== undefined && tool.wait === false) { - cncserver.buffer.resume(); - callback(1); - return true; +/** + * Triggered when the pen is requested to move across the bounds of the draw + * area (either in or out). + * + * @param {boolean} newValue + * Pass true when moving "off screen", false when moving back into bounds + */ +export function offCanvasChange(newValue) { + // Only do anything if the value is different + if (pen.state.offCanvas !== newValue) { + pen.forceState({ offCanvas: newValue }); + if (pen.state.offCanvas) { // Pen is now off screen/out of bounds + if (pen.isDown()) { + // Don't draw stuff while out of bounds (also, don't change the + // current known state so we can come back to it when we return to + // bounds),but DO change the buffer tip height so that is reflected on + // actualPen if it's every copied over on buffer execution. + run('callback', () => { + pen.setHeight('up', false, true); + const { height } = utils.stateToHeight('up'); + pen.forceState({ z: height, height }); + }); + } + } else { // Pen is now back in bounds + // Set the state regardless of actual change + console.log('Go back to:', pen.state.back); + + // Assume starting from up state & height (ensures correct timing) + const newHeight = utils.stateToHeight('up').height; + pen.forceState({ + state: 'up', + height: newHeight, + z: newHeight, + }); + pen.setHeight(pen.state.back); } + } +} - // Pen Up - cncserver.pen.setHeight('up'); - - // Move to the tool - cncserver.control.movePenAbs(tool); - - // Set the tip of state pen to the tool now that the change is done. - cncserver.pen.forceState({ tool: name }); +/** + * Actually move the position of the pen, called inside and outside buffer + * runs, figures out timing/offset based on actualPen position. + * + * @param {{x: number, y: number}} destination + * Absolute destination coordinate position (in steps). + * @param {function} callback + * Optional, callback for when operation should have completed. + * @param {number} speedOverride + * Percent of speed to set for this movement only. + */ +export function actuallyMove(destination, callback, speedOverride = null) { + // Get the amount of change/duration from difference between actualPen and + // absolute position in given destination + const change = pen.getPosChangeData( + actualPen.state, + destination, + speedOverride + ); + + state.commandDuration = Math.max(change.d, 0); + + // Execute the command immediately via serial.direct.command. + sendMessage('serial.direct.command', + render({ + command: { + type: 'absmove', + x: destination.x, + y: destination.y, + source: actualPen.state, + }, + })); + + // Set the correct duration and new position through to actualPen + actualPen.forceState({ + lastDuration: change.d, + x: destination.x, + y: destination.y, + }); - // Trigger the binder event. - cncserver.binder.trigger('tool.change', { - ...tool, - index, - name, - }); + // If there's nothing in the buffer, reset pen to actualPen + if (bufferState.data.length === 0) { + pen.resetState(); + } - // Finish up. - if (waitForCompletion) { // Run inside the buffer - cncserver.run('callback', callback); - } else { // Run as soon as items have been buffered + // Delayed callback (if used) + if (callback) { + setTimeout(() => { callback(1); - } - - return true; - }; - - /** - * "Move" the pen (tip of the buffer) to an absolute point inside the maximum - * available bot area. Includes cutoffs and sanity checks. - * - * @param {{x: number, y: number, [limit: string]}} inPoint - * Absolute coordinate measured in steps to move to. src is assumed to be - * "pen" tip of buffer. Also can contain optional "limit" key to set where - * movement should be limited to. Defaults to none, accepts "workArea". - * @param {function} callback - * Callback triggered when operation should be complete. - * @param {boolean} immediate - * Set to true to trigger the callback immediately. - * @param {boolean} skip - * Set to true to skip adding to the buffer, simplifying this function - * down to just a sanity checker. - * @param {number} speedOverride - * Percent of speed to set for this movement only. - * - * @returns {number} - * Distance moved from previous position, in steps. - */ - control.movePenAbs = (inPoint, callback, immediate = true, skip, speedOverride = null) => { - // Something really bad happened here... - if (Number.isNaN(inPoint.x) || Number.isNaN(inPoint.y)) { - console.error('INVALID Move pen input, given:', inPoint); - if (callback) callback(false); - return 0; - } - - // Make a local copy of point as we don't want to mess with its values ByRef - const point = cncserver.utils.extend({}, inPoint); - - // Sanity check absolute position input point and round everything (as we - // only move in whole number steps) - point.x = Math.round(Number(point.x)); - point.y = Math.round(Number(point.y)); - - // If moving in the workArea only, limit to allowed workArea, and trigger - // on/off screen events when we go offscreen, retaining suggested position. - let startOffCanvasChange = false; - if (point.limit === 'workArea') { - // Off the Right - if (point.x > cncserver.settings.bot.workArea.right) { - point.x = cncserver.settings.bot.workArea.right; - startOffCanvasChange = true; - } - - // Off the Left - if (point.x < cncserver.settings.bot.workArea.left) { - point.x = cncserver.settings.bot.workArea.left; - startOffCanvasChange = true; - } + }, Math.max(state.commandDuration, 0)); + } +} - // Off the Top - if (point.y < cncserver.settings.bot.workArea.top) { - point.y = cncserver.settings.bot.workArea.top; - startOffCanvasChange = true; - } +/** + * Actually change the height of the pen, called inside and outside buffer + * runs, figures out timing offset based on actualPen position. + * + * @param {integer} height + * Write-ready servo "height" value calculated from "state" + * @param {string} stateValue + * Optional, pass what the name of the state should be saved as in the + * actualPen object when complete. + * @param {function} cb + * Optional, callback for when operation should have completed. + */ +export function actuallyMoveHeight(height, stateValue, cb) { + const change = utils.getHeightChangeData( + actualPen.state.height, + height + ); + + state.commandDuration = Math.max(change.d, 0); + + // Pass along the correct height position through to actualPen. + if (typeof stateValue !== 'undefined') { + actualPen.forceState({ state: stateValue }); + } + + // Execute the command immediately via serial.direct.command. + sendMessage('serial.direct.command', + render({ + command: { + type: 'absheight', + z: height, + source: actualPen.state.height, + }, + })); + + actualPen.forceState({ + z: height, + height, + lastDuration: change.d, + }); - // Off the Bottom - if (point.y > cncserver.settings.bot.workArea.bottom) { - point.y = cncserver.settings.bot.workArea.bottom; - startOffCanvasChange = true; - } + // Delayed callback (if used) + if (cb) { + setTimeout(() => { + cb(1); + }, Math.max(state.commandDuration, 0)); + } +} - // Are we beyond our workarea limits? - if (startOffCanvasChange) { // Yep. - // We MUST trigger the start offscreen change AFTER the movement to draw - // up to that point (which happens later). - startOffCanvasChange = true; - } else { // Nope! - // The off canvas STOP trigger must happen BEFORE the move happens - // (which is fine right here) - cncserver.control.offCanvasChange(false); - } +/** + * "Move" the pen (tip of the buffer) to an absolute point inside the maximum + * available bot area. Includes cutoffs and sanity checks. + * + * @param {{x: number, y: number, [limit: string]}} inPoint + * Absolute coordinate measured in steps to move to. src is assumed to be + * "pen" tip of buffer. Also can contain optional "limit" key to set where + * movement should be limited to. Defaults to none, accepts "workArea". + * @param {function} callback + * Callback triggered when operation should be complete. + * @param {boolean} immediate + * Set to true to trigger the callback immediately. + * @param {boolean} skip + * Set to true to skip adding to the buffer, simplifying this function + * down to just a sanity checker. + * @param {number} speedOverride + * Percent of speed to set for this movement only. + * + * @returns {number} + * Distance moved from previous position, in steps. + */ +export function movePenAbs( + inPoint, callback, immediate = true, skip, speedOverride = null +) { + // Something really bad happened here... + if (Number.isNaN(inPoint.x) || Number.isNaN(inPoint.y)) { + console.error('INVALID Move pen input, given:', inPoint); + if (callback) callback(false); + return 0; + } + + // Make a local copy of point as we don't want to mess with its values ByRef + const point = utils.extend({}, inPoint); + + // Sanity check absolute position input point and round everything (as we + // only move in whole number steps) + point.x = Math.round(Number(point.x)); + point.y = Math.round(Number(point.y)); + + // If moving in the workArea only, limit to allowed workArea, and trigger + // on/off screen events when we go offscreen, retaining suggested position. + let startOffCanvasChange = false; + if (point.limit === 'workArea') { + // Off the Right + if (point.x > bot.workArea.right) { + point.x = bot.workArea.right; + startOffCanvasChange = true; } - // Ensure values don't go off the rails - cncserver.utils.sanityCheckAbsoluteCoord(point); - - // If we're skipping the buffer, just move to the point - // Pen stays put as last point set in buffer - if (skip) { - console.log('Skipping buffer for:', point); - cncserver.control.actuallyMove(point, callback, speedOverride); - return 0; // Don't return any distance for buffer skipped movements + // Off the Left + if (point.x < bot.workArea.left) { + point.x = bot.workArea.left; + startOffCanvasChange = true; } - // Calculate change from end of buffer pen position - const source = { x: cncserver.pen.state.x, y: cncserver.pen.state.y }; - const change = { - x: Math.round(point.x - cncserver.pen.state.x), - y: Math.round(point.y - cncserver.pen.state.y), - }; - - // Don't do anything if there's no change - if (change.x === 0 && change.y === 0) { - if (callback) callback(cncserver.pen.state); - return 0; + // Off the Top + if (point.y < bot.workArea.top) { + point.y = bot.workArea.top; + startOffCanvasChange = true; } - /* - Duration/distance is only calculated as relative from last assumed point, - which may not actually ever happen, though it is likely to happen. - Buffered items may not be pushed out of order, but previous location may - have changed as user might pause the buffer, and move the actualPen - position. - @see executeNext - for more details on how this is handled. - */ - const distance = cncserver.utils.getVectorLength(change); - const duration = cncserver.utils.getDurationFromDistance(distance, 1, null, speedOverride); - - // Only if we actually moved anywhere should we queue a movement - if (distance !== 0) { - // Set the tip of buffer pen at new position - cncserver.pen.forceState({ - x: point.x, - y: point.y, - }); - - // Adjust the distance counter based on movement amount, not if we're off - // the canvas though. - if (cncserver.utils.penDown() - && !cncserver.pen.state.offCanvas - && cncserver.settings.bot.inWorkArea(point)) { - cncserver.pen.forceState({ - distanceCounter: parseFloat( - Number(distance) + Number(cncserver.pen.state.distanceCounter) - ), - }); - } - - // Queue the final absolute move (serial command generated later) - cncserver.run( - 'move', - { - x: cncserver.pen.state.x, - y: cncserver.pen.state.y, - source, - }, - duration - ); + // Off the Bottom + if (point.y > bot.workArea.bottom) { + point.y = bot.workArea.bottom; + startOffCanvasChange = true; } - // Required start offCanvas change -after- movement has been queued - if (startOffCanvasChange) { - cncserver.control.offCanvasChange(true); + // Are we beyond our workarea limits? + if (startOffCanvasChange) { // Yep. + // We MUST trigger the start offscreen change AFTER the movement to draw + // up to that point (which happens later). + startOffCanvasChange = true; + } else { // Nope! + // The off canvas STOP trigger must happen BEFORE the move happens + // (which is fine right here) + offCanvasChange(false); } - - if (callback) { - if (immediate === true) { - callback(cncserver.pen.state); - } else { - // Set the timeout to occur sooner so the next command will execute - // before the other is actually complete. This will push into the buffer - // and allow for far smoother move runs. - - const latency = cncserver.settings.gConf.get('bufferLatencyOffset'); - const cmdDuration = Math.max(duration - latency, 0); - - if (cmdDuration < 2) { - callback(cncserver.pen.state); - } else { - setTimeout(() => { - callback(cncserver.pen.state); - }, cmdDuration); - } - } - } - - return distance; + } + + // Ensure values don't go off the rails + utils.sanityCheckAbsoluteCoord(point); + + // If we're skipping the buffer, just move to the point + // Pen stays put as last point set in buffer + if (skip) { + console.log('Skipping buffer for:', point); + actuallyMove(point, callback, speedOverride); + return 0; // Don't return any distance for buffer skipped movements + } + + // Calculate change from end of buffer pen position + const source = { x: pen.state.x, y: pen.state.y }; + const change = { + x: Math.round(point.x - pen.state.x), + y: Math.round(point.y - pen.state.y), }; - /** - * Triggered when the pen is requested to move across the bounds of the draw - * area (either in or out). - * - * @param {boolean} newValue - * Pass true when moving "off screen", false when moving back into bounds - */ - control.offCanvasChange = (newValue) => { - // Only do anything if the value is different - if (cncserver.pen.state.offCanvas !== newValue) { - cncserver.pen.forceState({ offCanvas: newValue }); - if (cncserver.pen.state.offCanvas) { // Pen is now off screen/out of bounds - if (cncserver.utils.penDown()) { - // Don't draw stuff while out of bounds (also, don't change the - // current known state so we can come back to it when we return to - // bounds),but DO change the buffer tip height so that is reflected on - // actualPen if it's every copied over on buffer execution. - cncserver.run('callback', () => { - cncserver.pen.setHeight('up', false, true); - const { height } = cncserver.utils.stateToHeight('up'); - cncserver.pen.forceState({ height }); - }); - } - } else { // Pen is now back in bounds - // Set the state regardless of actual change - const { state: back } = cncserver.pen.state; - console.log('Go back to:', back); - - // Assume starting from up state & height (ensures correct timing) - cncserver.pen.forceState({ - state: 'up', - height: cncserver.utils.stateToHeight('up').height, - }); - cncserver.pen.setHeight(back); - } + // Don't do anything if there's no change + if (change.x === 0 && change.y === 0) { + if (callback) callback(pen.state); + return 0; + } + + /* + Duration/distance is only calculated as relative from last assumed point, + which may not actually ever happen, though it is likely to happen. + Buffered items may not be pushed out of order, but previous location may + have changed as user might pause the buffer, and move the actualPen + position. + @see executeNext - for more details on how this is handled. + */ + const distance = utils.getVectorLength(change); + const duration = pen.getDurationFromDistance(distance, 1, null, speedOverride); + + // Only if we actually moved anywhere should we queue a movement + if (distance !== 0) { + // Set the tip of buffer pen at new position + // TODO: Figure out a better way to do all of this. + pen.forceState({ + x: point.x, + y: point.y, + }, true); + + // Adjust the distance counter based on movement amount, not if we're off + // the canvas though. + if (pen.isDown() + && !pen.state.offCanvas + && bot.inWorkArea(point)) { + pen.forceState({ + distanceCounter: parseFloat( + Number(distance) + Number(pen.state.distanceCounter) + ), + }); } - }; - /** - * Actually move the position of the pen, called inside and outside buffer - * runs, figures out timing/offset based on actualPen position. - * - * @param {{x: number, y: number}} destination - * Absolute destination coordinate position (in steps). - * @param {function} callback - * Optional, callback for when operation should have completed. - * @param {number} speedOverride - * Percent of speed to set for this movement only. - */ - control.actuallyMove = (destination, callback, speedOverride = null) => { - // Get the amount of change/duration from difference between actualPen and - // absolute position in given destination - const change = cncserver.utils.getPosChangeData( - cncserver.actualPen.state, - destination, - speedOverride - ); - - control.commandDuration = Math.max(change.d, 0); - - // Execute the command immediately via serial.direct.command. - cncserver.ipc.sendMessage('serial.direct.command', - cncserver.buffer.render({ - command: { - type: 'absmove', - x: destination.x, - y: destination.y, - source: cncserver.actualPen.state, - }, - })); - - // Set the correct duration and new position through to actualPen - cncserver.actualPen.forceState({ - lastDuration: change.d, - x: destination.x, - y: destination.y, - }); - - // If there's nothing in the buffer, reset pen to actualPen - if (cncserver.buffer.data.length === 0) { - cncserver.pen.resetState(); - } + // Queue the final absolute move (serial command generated later) + run('move', { x: pen.state.x, y: pen.state.y, source }, duration); + } - // Trigger an update for pen position - cncserver.sockets.sendPenUpdate(); + // Required start offCanvas change -after- movement has been queued + if (startOffCanvasChange) { + offCanvasChange(true); + } - // Delayed callback (if used) - if (callback) { - setTimeout(() => { - callback(1); - }, Math.max(cncserver.control.commandDuration, 0)); - } - }; - - /** - * Actually change the height of the pen, called inside and outside buffer - * runs, figures out timing offset based on actualPen position. - * - * @param {integer} height - * Write-ready servo "height" value calculated from "state" - * @param {string} stateValue - * Optional, pass what the name of the state should be saved as in the - * actualPen object when complete. - * @param {function} cb - * Optional, callback for when operation should have completed. - */ - control.actuallyMoveHeight = (height, stateValue, cb) => { - const change = cncserver.utils.getHeightChangeData( - cncserver.actualPen.state.height, - height - ); - - control.commandDuration = Math.max(change.d, 0); - - // Pass along the correct height position through to actualPen. - if (typeof stateValue !== 'undefined') { - cncserver.actualPen.forceState({ state: stateValue }); - } - - // Execute the command immediately via serial.direct.command. - cncserver.ipc.sendMessage('serial.direct.command', - cncserver.buffer.render({ - command: { - type: 'absheight', - z: height, - source: cncserver.actualPen.state.height, - }, - })); - - cncserver.actualPen.forceState({ - height, - lastDuration: change.d, - }); - - // Trigger an update for pen position. - cncserver.sockets.sendPenUpdate(); - - // Delayed callback (if used) - if (cb) { - setTimeout(() => { - cb(1); - }, Math.max(cncserver.control.commandDuration, 0)); - } - }; + if (callback) { + if (immediate === true) { + callback(pen.state); + } else { + // Set the timeout to occur sooner so the next command will execute + // before the other is actually complete. This will push into the buffer + // and allow for far smoother move runs. + const latency = gConf.get('bufferLatencyOffset'); + const cmdDuration = Math.max(duration - latency, 0); - /** - * Actually render Paper paths into movements - * - * @param {object} source - * Source paper object containing the children, defaults to preview layer. - */ - control.renderPathsToMoves = (source = cncserver.drawing.base.layers.preview, reqSettings = {}) => { - const { settings: { botConf }, drawing: { colors } } = cncserver; - const settings = { - parkAfter: true, - ...reqSettings, - }; - // TODO: - // * Join extant non-closed paths with endpoint distances < 0.5mm - // * Split work by colors - // * Allow WCB bot support to inject tool changes for refill support - // * Order paths by pickup/dropoff distance - - // Store work for all paths grouped by color - const workGroups = colors.getWorkGroups(); - const validColors = Object.keys(workGroups); - const allPaths = cncserver.drawing.base.getPaths(source); - allPaths.forEach((path) => { - const colorID = cncserver.drawing.base.getColorID(path); - - if (path.length && colorID && validColors.includes(colorID)) { - workGroups[colorID].push(path); - // Allow implementing triggers to modify current list of paths. - workGroups[colorID] = cncserver.binder.trigger('control.render.path.select', workGroups[colorID]); - } else if (colorID !== 'ignore') { - console.log(`DEBUG: Invalid Draw path ${colorID} ${path.name} ==================`); - } - }); - - let workGroupIndex = 0; - function nextWorkGroup() { - const colorID = validColors[workGroupIndex]; - if (colorID) { - const paths = workGroups[colorID]; - - if (paths.length) { - // Bind trigger for implementors on work group begin. - cncserver.binder.trigger('control.render.group.begin', colorID); - - // Do we have a tool for this colorID? If not, use manualswap. - if (colors.doColorParsing()) { - const changeTool = botConf.get(`tools:${colorID}`) ? colorID : 'manualswap'; - control.setTool(changeTool, colorID); - } - - let pathIndex = 0; - const nextPath = () => { - if (paths[pathIndex]) { - control.accelMoveOnPath(paths[pathIndex]).then(() => { - // Trigger implementors for plath render complete. - cncserver.binder.trigger('control.render.path.finish', paths[pathIndex]); - - // Path in this group done, move to the next. - pathIndex++; - nextPath(); - }).catch((error) => { - // If we have an error object, it's an actual error! - if (error) { - console.error(error); - } - // Otherwise, path generation has been entirely cancelled. - workGroupIndex = validColors.length; - }); - } else { - // No more paths in this group, move to the next. - workGroupIndex++; - nextWorkGroup(); - } - }; - - // Start processing paths in the initial workgroup. - nextPath(); - } else { - // There is no work for this group, move to the next one. - workGroupIndex++; - nextWorkGroup(); - } + if (cmdDuration < 2) { + callback(pen.state); } else { - // Actually complete with all paths in all work groups! - // TODO: Fullfull a promise for the function? - - // Trigger binding for implementors on sucessfull rendering completion. - cncserver.binder.trigger('control.render.finish'); - - // If settings say to park. - if (settings.parkAfter) cncserver.pen.park(); + setTimeout(() => { + callback(pen.state); + }, cmdDuration); } } - // Intitialize working on the first group on the next process tick. - process.nextTick(nextWorkGroup); - }; - - control.accelMoveOnPath = path => new Promise((success, error) => { - const move = (point, speed = null) => { - const stepPoint = cncserver.utils.absToSteps(point, 'mm', true); - control.movePenAbs(stepPoint, null, true, null, speed); - }; - - // Pen up - cncserver.pen.setPen({ state: 'up' }); - - // Move to start of path, then pen down. - move(path.getPointAt(0)); - cncserver.pen.setPen({ state: 'draw' }); - - // Calculate groups of accell points and run them into moves. - cncserver.drawing.accell.getPoints(path, (accellPoints) => { - // If we have data, move to those points. - if (accellPoints && accellPoints.length) { - // Move through all accell points from start to nearest end point - accellPoints.forEach((pos) => { - move(pos.point, pos.speed); - }); - } else { - // Null means generation of accell points was cancelled. - if (accellPoints !== null) { - // No points? We're done. Wrap up the line. - - // Move to end of path... - move(path.getPointAt(path.length)); - - // If it's a closed path, overshoot back home. - if (path.closed) { - move(path.getPointAt(0)); - } - - // End with pen up. - cncserver.pen.setPen({ state: 'up' }); + } - // Fulfull the promise for this subpath. - success(); - return; - } - - // If we're here, path generation was cancelled, bubble it. - error(); - } - }); - }); - - - // Exports... - control.exports = { - setTool: control.setTool, - movePenAbs: control.movePenAbs, - }; - - return control; -}; + return distance; +} diff --git a/src/components/core/control/cncserver.pen.base.js b/src/components/core/control/cncserver.pen.base.js new file mode 100644 index 00000000..742b1487 --- /dev/null +++ b/src/components/core/control/cncserver.pen.base.js @@ -0,0 +1,435 @@ +/** + * @file Abstraction module for pen state and setter/helper methods. + */ +import { gConf, bot, botConf } from 'cs/settings'; +import * as utils from 'cs/utils'; +import { connect, localTrigger } from 'cs/serial'; +import { bindTo, trigger } from 'cs/binder'; +import { movePenAbs, actuallyMoveHeight } from 'cs/control'; +import { cmdstr } from 'cs/buffer'; +import run from 'cs/run'; +import * as actualPen from 'cs/actualPen'; + +// The pen state: this holds the state of the pen at the "latest tip" of the buffer, +// meaning that as soon as an instruction intended to be run in the buffer is +// received, this is updated to reflect the intention of the buffered item. +export const state = { + x: null, // XY set by bot defined park position (assumed initial location) + y: null, + z: null, + state: 0, // Pen state is from 0 (up/off) to 1 (down/on) + height: 0, // Last set pen height in output servo value + power: 0, // Pen power (only used in special circumstances) + tool: null, // Current tool ID string. + colorsetItem: null, // Current colorset item ID. + implement: null, // Inherit current colorset implement by default. + bufferHash: null, // Holds the last pen buffer hash. + offCanvas: false, // Whether the current position is beyond the edges. + lastDuration: 0, // Holds the last movement timing in milliseconds + distanceCounter: 0, // Holds a running tally of distance travelled + simulation: 0, // Fake everything and act like it's working, no serial +}; + +/** + * General logic sorting function for most "pen" requests. + * + * @param {object} inPenState + * Raw object containing data from /v1/pen PUT requests. See API spec for + * pen to get an idea of what can live in this object. + * @param {function} callback + * Callback triggered when intended action should be complete. + * @param {number} speedOverride + * Percent of speed to set for this movement only. + */ +export function setPen(inPenState, callback = () => { }, speedOverride = null) { + const debug = gConf.get('debug'); + + // What can happen here? + // We're changing state, and what we need is the ability to find all the + // passed, changed state from either the actual pen state, OR the tip of + // our last "buffered" movement. + // + // We can set: + // - Power + // - X/Y (with speed) + // - Height/pen "state"/Z + // - X/Y/Z (must complete together) + // - Simulation on/off + + // Force the distanceCounter to be a number (was coming up as null) + state.distanceCounter = parseFloat(state.distanceCounter); + + // Counter Reset + if (inPenState.resetCounter) { + state.distanceCounter = Number(0); + callback(true); + return; + } + + // Setting the value of the power to the pen + if (typeof inPenState.power !== 'undefined') { + setPower(inPenState.power, callback); + return; + } + + // Setting the value of simulation + if (typeof inPenState.simulation !== 'undefined') { + // No change + if (inPenState.simulation === state.simulation) { + callback(true); + return; + } + + if (inPenState.simulation === '0') { // Attempt to connect to serial + connect({ complete: callback }); + } else { // Turn off serial! + // TODO: Actually nullify connection.. no use case worth it yet + localTrigger('simulationStart'); + } + + return; + } + + // State/z position has been passed + if (typeof inPenState.state !== 'undefined') { + // Disallow actual pen setting when off canvas (unless skipping buffer) + if (!state.offCanvas || inPenState.skipBuffer) { + setHeight(inPenState.state, callback, inPenState.skipBuffer); + } else { + // Save the state anyways so we can come back to it + state.state = inPenState.state; + if (callback) callback(1); + } + return; + } + + // Absolute positions are set + if (inPenState.x !== undefined) { + // Input values are given as percentages of working area (not max area) + const point = { + abs: inPenState.abs, + x: Number(inPenState.x), + y: Number(inPenState.y), + }; + + // Don't accept bad input + const penNaN = Number.isNaN(point.x) || Number.isNaN(point.y); + const penFinite = Number.isFinite(point.x) && Number.isFinite(point.y); + if (penNaN || !penFinite) { + if (debug) { + console.log('setPen: Either X/Y not valid numbers.'); + } + callback(false); + return; + } + + // Override this to move with accelleration if override isn't set. + // TODO: Allow switching between accell and flat speed movements. + /* if (speedOverride === null) { + // Convert the percentage or absolute in/mm XY values into absolute steps. + const startPoint = cncserver.utils.stepsToAbs(pen.state, 'mm'); + const endPoint = utils.stepsToAbs(cncserver.utils.inPenToSteps(point), 'mm'); + + const movePath = new Path([ + startPoint, + endPoint, + ]); + // cncserver.sockets.sendPaperUpdate(); + + const accellPoints = cncserver.drawing.accell.getPointsSync(movePath); + accellPoints.forEach((pos) => { + pen.setPen({ ...pos.point, abs: 'mm' }, null, pos.speed); + }); + + pen.setPen({ ...endPoint, abs: 'mm' }, null, 0); + callback(true); + + // movePath.remove(); // TODO: Move this to when it's done? + return; + } */ + + // Convert the percentage or absolute in/mm XY values into absolute steps. + const absInput = utils.inPenToSteps(point); + absInput.limit = 'workArea'; + + // Are we parking? + if (inPenState.park) { + // Don't repark if already parked (but not if we're skipping the buffer) + const parkPos = utils.centToSteps(bot.park, true); + if ( + state.x === parkPos.x + && state.y === parkPos.y + && !inPenState.skipBuffer + ) { + if (debug) { + console.log('setPen: Can\'t park when already parked.'); + } + if (callback) callback(false); + return; + } + + // Set Absolute input value to park position in steps + absInput.x = parkPos.x; + absInput.y = parkPos.y; + absInput.limit = 'maxArea'; + } + + movePenAbs( + absInput, + callback, + inPenState.waitForCompletion, + inPenState.skipBuffer, + speedOverride + ); + + return; + } + + if (callback) callback(state); +} + +/** + * Set the "power" option + * + * @param {number} power + * Value from 0 to 100 to send to the bot. + * @param callback + * Callback triggered when operation should be complete. + * @param skipBuffer + * Set to true to skip adding the command to the buffer and run it + * immediately. + */ +export function setPower(power, callback, skipBuffer) { + const powers = botConf.get('penpower') || { min: 0, max: 0 }; + + run( + 'custom', + cmdstr( + 'penpower', + { p: Math.round(power * powers.max) + Number(powers.min) } + ) + ); + + state.power = power; + if (callback) callback(true); +} + +/** + * Run a servo position from a given percentage or named height value into + * the buffer, or directly via skipBuffer. + * + * @param {number|string} inState + * Named height preset machine name, or float between 0 & 1. + * @param callback + * Callback triggered when operation should be complete. + * @param skipBuffer + * Set to true to skip adding the command to the buffer and run it + * immediately. + */ +export function setHeight(inState, callback, skipBuffer) { + let servoDuration = botConf.get('servo:minduration'); + + // Convert the incoming state + const conv = utils.stateToHeight(inState); + const { height = 0, state: stateValue = null } = conv; + + // If we're skipping the buffer, just set the height directly + if (skipBuffer) { + console.log('Skipping buffer to set height:', height); + actuallyMoveHeight(height, stateValue, callback); + return; + } + + const sourceHeight = state.height; + + // Pro-rate the duration depending on amount of change to tip of buffer. + // TODO: Replace with cncserver.utils.getHeightChangeData() + if (state.height) { + const servo = botConf.get('servo'); + const range = parseInt(servo.max, 10) - parseInt(servo.min, 10); + servoDuration = Math.round( + (Math.abs(height - state.height) / range) * servoDuration + ) + 1; + } + + // Actually set tip of buffer to given sanitized state & servo height. + state.height = height; + state.z = height; + state.state = stateValue; + + // Run the height into the command buffer + run('height', { z: height, source: sourceHeight }, servoDuration); + + // Height movement callback servo movement duration offset + const delay = servoDuration - gConf.get('bufferLatencyOffset'); + if (callback) { + setTimeout(() => { + callback(1); + }, Math.max(delay, 0)); + } +} + +/** + * Reset state of pen to current head (actualPen). + */ +export function resetState() { + utils.applyObjectTo(actualPen.state, state); + trigger('pen.update', state); +} + +/** + * Park the pen. + * + * @param {boolean} direct + * True to send direct and skip the buffer. + * @param {function} callback + * Ya know. + */ +export function park(direct = false, callback = () => { }) { + setHeight('up', () => { + setPen({ + x: bot.park.x, + y: bot.park.y, + park: true, + skipBuffer: direct, + }, callback); + }, direct); +} + +/** + * Force the values of a given set of keys within the pen state. + * + * @param {object} inState + * Flat object of key/value pairs to FORCE into the pen state. Only used to + * correct head state or to update position along the buffer. + * @param {bool} skipUpdate + * If true, no update will be sent. + */ +export function forceState(inState, skipUpdate = false) { + // Only trigger update if not skipping. + utils.applyObjectTo(inState, state, true); + if (!skipUpdate) { + trigger('pen.update', state); + } +} + +/** + * Set the internal state hash value. + * + * @param {string} hash + * Buffer hash to set. + */ +export function setHash(hash) { + state.bufferHash = hash; +} + +// Setup initial park position and set default. +bindTo('controller.setup', 'pen', () => { + // Set initial pen position at park position + forceState(utils.centToSteps(bot.park, true)); + + // Trigger pen.setup with full state. + trigger('pen.setup', state, true); +}); + +/** + * Helper abstraction for checking if the tip of buffer pen is "down" or not. + * + * @param {object} inPen + * The pen state object to check for down status, defaults to buffer tip. + * @returns {Boolean} + * False if pen is considered up, true if pen is considered down. + */ +export function isDown(inPen) { + const checkPen = inPen?.state || state; + if (checkPen.state === 'up' || checkPen.state < 0.5) { + return false; + } + + return true; +} + +/** + * Calculate the duration for a pen movement from the distance. + * Takes into account whether pen is up or down + * + * @param {float} distance + * Distance in steps that we'll be moving + * @param {int} min + * Optional minimum value for output duration, defaults to 1. + * @param {object} inPen + * Incoming pen object to check (buffer tip or bot current). + * @param {number} speedOverride + * Optional speed override, overrides calculated speed percent. + * + * @returns {number} + * Millisecond duration of how long the move should take + */ +export function getDurationFromDistance(distance, min = 1, inPen, speedOverride = null) { + const minSpeed = parseFloat(botConf.get('speed:min')); + const maxSpeed = parseFloat(botConf.get('speed:max')); + const drawingSpeed = botConf.get('speed:drawing'); + const movingSpeed = botConf.get('speed:moving'); + + // Use given speed over distance to calculate duration + let speed = (isDown(inPen)) ? drawingSpeed : movingSpeed; + if (speedOverride != null) { + speed = speedOverride; + } + + speed = parseFloat(speed) / 100; + + // Convert to steps from percentage + speed = (speed * (maxSpeed - minSpeed)) + minSpeed; + + // Sanity check speed value + speed = speed > maxSpeed ? maxSpeed : speed; + speed = speed < minSpeed ? minSpeed : speed; + + // How many steps a second? + return Math.max(Math.abs(Math.round(distance / speed * 1000)), min); +} + +/** + * Given two points, find the difference and duration at current speed + * + * @param {{x: number, y: number}} src + * Source position coordinate (in steps). + * @param {{x: number, y: number}} dest + * Destination position coordinate (in steps). + * @param {number} speed + * Speed override for this movement in percent. + * + * @returns {{d: number, x: number, y: number}} + * Object containing the change amount in steps for x & y, along with the + * duration in milliseconds. + */ +export function getPosChangeData(src, dest, speed = null) { + let change = { + x: Math.round(dest.x - src.x), + y: Math.round(dest.y - src.y), + }; + + // Calculate distance + const duration = getDurationFromDistance(utils.getVectorLength(change), 1, src, speed); + + // Adjust change direction/inversion + if (botConf.get('controller').position === 'relative') { + // Invert X or Y to match stepper direction + change.x = gConf.get('invertAxis:x') ? change.x * -1 : change.x; + change.y = gConf.get('invertAxis:y') ? change.y * -1 : change.y; + } else { // Absolute! Just use the "new" absolute X & Y locations + change.x = state.x; + change.y = state.y; + } + + // Swap motor positions + if (gConf.get('swapMotors')) { + change = { + x: change.y, + y: change.x, + }; + } + + return { d: duration, x: change.x, y: change.y }; +} diff --git a/src/components/core/control/cncserver.pen.js b/src/components/core/control/cncserver.pen.js index d65a1773..742b1487 100644 --- a/src/components/core/control/cncserver.pen.js +++ b/src/components/core/control/cncserver.pen.js @@ -1,328 +1,435 @@ /** * @file Abstraction module for pen state and setter/helper methods. */ -const { Path } = require('paper'); - -const pen = {}; // Exposed export. - -module.exports = (cncserver) => { - // The pen state: this holds the state of the pen at the "latest tip" of the buffer, - // meaning that as soon as an instruction intended to be run in the buffer is - // received, this is updated to reflect the intention of the buffered item. - pen.state = { - x: null, // XY set by bot defined park position (assumed initial location) - y: null, - z: null, - state: 0, // Pen state is from 0 (up/off) to 1 (down/on) - height: 0, // Last set pen height in output servo value - power: 0, - busy: false, - tool: 'color0', // TODO: This seems wrong and assuming. - offCanvas: false, - bufferHash: '', // Holds the last pen buffer hash. - lastDuration: 0, // Holds the last movement timing in milliseconds - distanceCounter: 0, // Holds a running tally of distance travelled - simulation: 0, // Fake everything and act like it's working, no serial - }; +import { gConf, bot, botConf } from 'cs/settings'; +import * as utils from 'cs/utils'; +import { connect, localTrigger } from 'cs/serial'; +import { bindTo, trigger } from 'cs/binder'; +import { movePenAbs, actuallyMoveHeight } from 'cs/control'; +import { cmdstr } from 'cs/buffer'; +import run from 'cs/run'; +import * as actualPen from 'cs/actualPen'; + +// The pen state: this holds the state of the pen at the "latest tip" of the buffer, +// meaning that as soon as an instruction intended to be run in the buffer is +// received, this is updated to reflect the intention of the buffered item. +export const state = { + x: null, // XY set by bot defined park position (assumed initial location) + y: null, + z: null, + state: 0, // Pen state is from 0 (up/off) to 1 (down/on) + height: 0, // Last set pen height in output servo value + power: 0, // Pen power (only used in special circumstances) + tool: null, // Current tool ID string. + colorsetItem: null, // Current colorset item ID. + implement: null, // Inherit current colorset implement by default. + bufferHash: null, // Holds the last pen buffer hash. + offCanvas: false, // Whether the current position is beyond the edges. + lastDuration: 0, // Holds the last movement timing in milliseconds + distanceCounter: 0, // Holds a running tally of distance travelled + simulation: 0, // Fake everything and act like it's working, no serial +}; - const debug = cncserver.settings.gConf.get('debug'); - - /** - * General logic sorting function for most "pen" requests. - * - * @param {object} inPenState - * Raw object containing data from /v1/pen PUT requests. See API spec for - * pen to get an idea of what can live in this object. - * @param {function} callback - * Callback triggered when intended action should be complete. - * @param {number} speedOverride - * Percent of speed to set for this movement only. - */ - pen.setPen = (inPenState, callback = () => {}, speedOverride = null) => { - // What can happen here? - // We're changing state, and what we need is the ability to find all the - // passed, changed state from either the actual pen state, OR the tip of - // our last "buffered" movement. - // - // We can set: - // - Power - // - X/Y (with speed) - // - Height/pen "state"/Z - // - X/Y/Z (must complete together) - // - Simulation on/off - - // Force the distanceCounter to be a number (was coming up as null) - pen.state.distanceCounter = parseFloat(pen.state.distanceCounter); - - // Counter Reset - if (inPenState.resetCounter) { - pen.state.distanceCounter = Number(0); +/** + * General logic sorting function for most "pen" requests. + * + * @param {object} inPenState + * Raw object containing data from /v1/pen PUT requests. See API spec for + * pen to get an idea of what can live in this object. + * @param {function} callback + * Callback triggered when intended action should be complete. + * @param {number} speedOverride + * Percent of speed to set for this movement only. + */ +export function setPen(inPenState, callback = () => { }, speedOverride = null) { + const debug = gConf.get('debug'); + + // What can happen here? + // We're changing state, and what we need is the ability to find all the + // passed, changed state from either the actual pen state, OR the tip of + // our last "buffered" movement. + // + // We can set: + // - Power + // - X/Y (with speed) + // - Height/pen "state"/Z + // - X/Y/Z (must complete together) + // - Simulation on/off + + // Force the distanceCounter to be a number (was coming up as null) + state.distanceCounter = parseFloat(state.distanceCounter); + + // Counter Reset + if (inPenState.resetCounter) { + state.distanceCounter = Number(0); + callback(true); + return; + } + + // Setting the value of the power to the pen + if (typeof inPenState.power !== 'undefined') { + setPower(inPenState.power, callback); + return; + } + + // Setting the value of simulation + if (typeof inPenState.simulation !== 'undefined') { + // No change + if (inPenState.simulation === state.simulation) { callback(true); return; } - // Setting the value of the power to the pen - if (typeof inPenState.power !== 'undefined') { - pen.setPower(inPenState.power, callback); - return; + if (inPenState.simulation === '0') { // Attempt to connect to serial + connect({ complete: callback }); + } else { // Turn off serial! + // TODO: Actually nullify connection.. no use case worth it yet + localTrigger('simulationStart'); } - // Setting the value of simulation - if (typeof inPenState.simulation !== 'undefined') { - // No change - if (inPenState.simulation === pen.state.simulation) { - callback(true); - return; - } - - if (inPenState.simulation === '0') { // Attempt to connect to serial - cncserver.serial.connect({ complete: callback }); - } else { // Turn off serial! - // TODO: Actually nullify connection.. no use case worth it yet - cncserver.serial.localTrigger('simulationStart'); + return; + } + + // State/z position has been passed + if (typeof inPenState.state !== 'undefined') { + // Disallow actual pen setting when off canvas (unless skipping buffer) + if (!state.offCanvas || inPenState.skipBuffer) { + setHeight(inPenState.state, callback, inPenState.skipBuffer); + } else { + // Save the state anyways so we can come back to it + state.state = inPenState.state; + if (callback) callback(1); + } + return; + } + + // Absolute positions are set + if (inPenState.x !== undefined) { + // Input values are given as percentages of working area (not max area) + const point = { + abs: inPenState.abs, + x: Number(inPenState.x), + y: Number(inPenState.y), + }; + + // Don't accept bad input + const penNaN = Number.isNaN(point.x) || Number.isNaN(point.y); + const penFinite = Number.isFinite(point.x) && Number.isFinite(point.y); + if (penNaN || !penFinite) { + if (debug) { + console.log('setPen: Either X/Y not valid numbers.'); } - + callback(false); return; } + // Override this to move with accelleration if override isn't set. + // TODO: Allow switching between accell and flat speed movements. + /* if (speedOverride === null) { + // Convert the percentage or absolute in/mm XY values into absolute steps. + const startPoint = cncserver.utils.stepsToAbs(pen.state, 'mm'); + const endPoint = utils.stepsToAbs(cncserver.utils.inPenToSteps(point), 'mm'); - // State/z position has been passed - if (typeof inPenState.state !== 'undefined') { - // Disallow actual pen setting when off canvas (unless skipping buffer) - if (!pen.state.offCanvas || inPenState.skipBuffer) { - pen.setHeight(inPenState.state, callback, inPenState.skipBuffer); - } else { - // Save the state anyways so we can come back to it - pen.state.state = inPenState.state; - if (callback) callback(1); - } - return; - } + const movePath = new Path([ + startPoint, + endPoint, + ]); + // cncserver.sockets.sendPaperUpdate(); + + const accellPoints = cncserver.drawing.accell.getPointsSync(movePath); + accellPoints.forEach((pos) => { + pen.setPen({ ...pos.point, abs: 'mm' }, null, pos.speed); + }); - // Absolute positions are set - if (inPenState.x !== undefined) { - // Input values are given as percentages of working area (not max area) - const point = { - abs: inPenState.abs, - x: Number(inPenState.x), - y: Number(inPenState.y), - }; - - // Don't accept bad input - const penNaN = Number.isNaN(point.x) || Number.isNaN(point.y); - const penFinite = Number.isFinite(point.x) && Number.isFinite(point.y); - if (penNaN || !penFinite) { + pen.setPen({ ...endPoint, abs: 'mm' }, null, 0); + callback(true); + + // movePath.remove(); // TODO: Move this to when it's done? + return; + } */ + + // Convert the percentage or absolute in/mm XY values into absolute steps. + const absInput = utils.inPenToSteps(point); + absInput.limit = 'workArea'; + + // Are we parking? + if (inPenState.park) { + // Don't repark if already parked (but not if we're skipping the buffer) + const parkPos = utils.centToSteps(bot.park, true); + if ( + state.x === parkPos.x + && state.y === parkPos.y + && !inPenState.skipBuffer + ) { if (debug) { - console.log('setPen: Either X/Y not valid numbers.'); + console.log('setPen: Can\'t park when already parked.'); } - callback(false); + if (callback) callback(false); return; } + // Set Absolute input value to park position in steps + absInput.x = parkPos.x; + absInput.y = parkPos.y; + absInput.limit = 'maxArea'; + } - // Override this to move with accelleration if override isn't set. - // TODO: Allow switching between accell and flat speed movements. - /* if (speedOverride === null) { - // Convert the percentage or absolute in/mm XY values into absolute steps. - const startPoint = cncserver.utils.stepsToAbs(pen.state, 'mm'); - const endPoint = cncserver.utils.stepsToAbs(cncserver.utils.inPenToSteps(point), 'mm'); - - const movePath = new Path([ - startPoint, - endPoint, - ]); - // cncserver.sockets.sendPaperUpdate(); - - const accellPoints = cncserver.drawing.accell.getPointsSync(movePath); - accellPoints.forEach((pos) => { - pen.setPen({ ...pos.point, abs: 'mm' }, null, pos.speed); - }); + movePenAbs( + absInput, + callback, + inPenState.waitForCompletion, + inPenState.skipBuffer, + speedOverride + ); - pen.setPen({ ...endPoint, abs: 'mm' }, null, 0); - callback(true); + return; + } - // movePath.remove(); // TODO: Move this to when it's done? - return; - } */ + if (callback) callback(state); +} - // Convert the percentage or absolute in/mm XY values into absolute steps. - const absInput = cncserver.utils.inPenToSteps(point); - absInput.limit = 'workArea'; - - // Are we parking? - if (inPenState.park) { - // Don't repark if already parked (but not if we're skipping the buffer) - const park = cncserver.utils.centToSteps(cncserver.settings.bot.park, true); - if ( - pen.state.x === park.x - && pen.state.y === park.y - && !inPenState.skipBuffer - ) { - if (debug) { - console.log('setPen: Can\'t park when already parked.'); - } - if (callback) callback(false); - return; - } +/** + * Set the "power" option + * + * @param {number} power + * Value from 0 to 100 to send to the bot. + * @param callback + * Callback triggered when operation should be complete. + * @param skipBuffer + * Set to true to skip adding the command to the buffer and run it + * immediately. + */ +export function setPower(power, callback, skipBuffer) { + const powers = botConf.get('penpower') || { min: 0, max: 0 }; - // Set Absolute input value to park position in steps - absInput.x = park.x; - absInput.y = park.y; - absInput.limit = 'maxArea'; - } + run( + 'custom', + cmdstr( + 'penpower', + { p: Math.round(power * powers.max) + Number(powers.min) } + ) + ); - cncserver.control.movePenAbs( - absInput, - callback, - inPenState.waitForCompletion, - inPenState.skipBuffer, - speedOverride - ); + state.power = power; + if (callback) callback(true); +} - return; - } +/** + * Run a servo position from a given percentage or named height value into + * the buffer, or directly via skipBuffer. + * + * @param {number|string} inState + * Named height preset machine name, or float between 0 & 1. + * @param callback + * Callback triggered when operation should be complete. + * @param skipBuffer + * Set to true to skip adding the command to the buffer and run it + * immediately. + */ +export function setHeight(inState, callback, skipBuffer) { + let servoDuration = botConf.get('servo:minduration'); + + // Convert the incoming state + const conv = utils.stateToHeight(inState); + const { height = 0, state: stateValue = null } = conv; + + // If we're skipping the buffer, just set the height directly + if (skipBuffer) { + console.log('Skipping buffer to set height:', height); + actuallyMoveHeight(height, stateValue, callback); + return; + } + + const sourceHeight = state.height; + + // Pro-rate the duration depending on amount of change to tip of buffer. + // TODO: Replace with cncserver.utils.getHeightChangeData() + if (state.height) { + const servo = botConf.get('servo'); + const range = parseInt(servo.max, 10) - parseInt(servo.min, 10); + servoDuration = Math.round( + (Math.abs(height - state.height) / range) * servoDuration + ) + 1; + } + + // Actually set tip of buffer to given sanitized state & servo height. + state.height = height; + state.z = height; + state.state = stateValue; + + // Run the height into the command buffer + run('height', { z: height, source: sourceHeight }, servoDuration); + + // Height movement callback servo movement duration offset + const delay = servoDuration - gConf.get('bufferLatencyOffset'); + if (callback) { + setTimeout(() => { + callback(1); + }, Math.max(delay, 0)); + } +} - if (callback) callback(pen); - }; +/** + * Reset state of pen to current head (actualPen). + */ +export function resetState() { + utils.applyObjectTo(actualPen.state, state); + trigger('pen.update', state); +} - /** - * Set the "power" option - * - * @param {number} power - * Value from 0 to 100 to send to the bot. - * @param callback - * Callback triggered when operation should be complete. - * @param skipBuffer - * Set to true to skip adding the command to the buffer and run it - * immediately. - */ - pen.setPower = (power, callback, skipBuffer) => { - const powers = cncserver.settings.botConf.get('penpower') || { min: 0, max: 0 }; - - cncserver.run( - 'custom', - cncserver.control.cmdstr( - 'penpower', - { p: Math.round(power * powers.max) + Number(powers.min) } - ) - ); +/** + * Park the pen. + * + * @param {boolean} direct + * True to send direct and skip the buffer. + * @param {function} callback + * Ya know. + */ +export function park(direct = false, callback = () => { }) { + setHeight('up', () => { + setPen({ + x: bot.park.x, + y: bot.park.y, + park: true, + skipBuffer: direct, + }, callback); + }, direct); +} - pen.state.power = power; - if (callback) callback(true); - }; +/** + * Force the values of a given set of keys within the pen state. + * + * @param {object} inState + * Flat object of key/value pairs to FORCE into the pen state. Only used to + * correct head state or to update position along the buffer. + * @param {bool} skipUpdate + * If true, no update will be sent. + */ +export function forceState(inState, skipUpdate = false) { + // Only trigger update if not skipping. + utils.applyObjectTo(inState, state, true); + if (!skipUpdate) { + trigger('pen.update', state); + } +} - /** - * Run a servo position from a given percentage or named height value into - * the buffer, or directly via skipBuffer. - * - * @param {number|string} state - * Named height preset machine name, or float between 0 & 1. - * @param callback - * Callback triggered when operation should be complete. - * @param skipBuffer - * Set to true to skip adding the command to the buffer and run it - * immediately. - */ - pen.setHeight = (state, callback, skipBuffer) => { - let servoDuration = cncserver.settings.botConf.get('servo:minduration'); - - // Convert the incoming state - const conv = cncserver.utils.stateToHeight(state); - const { height = 0, state: stateValue = null } = conv; - - // If we're skipping the buffer, just set the height directly - if (skipBuffer) { - console.log('Skipping buffer to set height:', height); - cncserver.control.actuallyMoveHeight(height, stateValue, callback); - return; - } +/** + * Set the internal state hash value. + * + * @param {string} hash + * Buffer hash to set. + */ +export function setHash(hash) { + state.bufferHash = hash; +} - const sourceHeight = pen.state.height; +// Setup initial park position and set default. +bindTo('controller.setup', 'pen', () => { + // Set initial pen position at park position + forceState(utils.centToSteps(bot.park, true)); - // Pro-rate the duration depending on amount of change to tip of buffer. - // TODO: Replace with cncserver.utils.getHeightChangeData() - if (pen.state.height) { - const servo = cncserver.settings.botConf.get('servo'); - const range = parseInt(servo.max, 10) - parseInt(servo.min, 10); - servoDuration = Math.round( - (Math.abs(height - pen.state.height) / range) * servoDuration - ) + 1; - } + // Trigger pen.setup with full state. + trigger('pen.setup', state, true); +}); - // Actually set tip of buffer to given sanitized state & servo height. - pen.state.height = height; - pen.state.state = stateValue; +/** + * Helper abstraction for checking if the tip of buffer pen is "down" or not. + * + * @param {object} inPen + * The pen state object to check for down status, defaults to buffer tip. + * @returns {Boolean} + * False if pen is considered up, true if pen is considered down. + */ +export function isDown(inPen) { + const checkPen = inPen?.state || state; + if (checkPen.state === 'up' || checkPen.state < 0.5) { + return false; + } - // Run the height into the command buffer - cncserver.run('height', { z: height, source: sourceHeight }, servoDuration); + return true; +} - // Height movement callback servo movement duration offset - const delay = servoDuration - cncserver.settings.gConf.get('bufferLatencyOffset'); - if (callback) { - setTimeout(() => { - callback(1); - }, Math.max(delay, 0)); - } - }; +/** + * Calculate the duration for a pen movement from the distance. + * Takes into account whether pen is up or down + * + * @param {float} distance + * Distance in steps that we'll be moving + * @param {int} min + * Optional minimum value for output duration, defaults to 1. + * @param {object} inPen + * Incoming pen object to check (buffer tip or bot current). + * @param {number} speedOverride + * Optional speed override, overrides calculated speed percent. + * + * @returns {number} + * Millisecond duration of how long the move should take + */ +export function getDurationFromDistance(distance, min = 1, inPen, speedOverride = null) { + const minSpeed = parseFloat(botConf.get('speed:min')); + const maxSpeed = parseFloat(botConf.get('speed:max')); + const drawingSpeed = botConf.get('speed:drawing'); + const movingSpeed = botConf.get('speed:moving'); - /** - * Reset state of pen to current head (actualPen). - */ - pen.resetState = () => { - pen.state = cncserver.utils.extend({}, cncserver.actualPen.state); - }; + // Use given speed over distance to calculate duration + let speed = (isDown(inPen)) ? drawingSpeed : movingSpeed; + if (speedOverride != null) { + speed = speedOverride; + } - /** - * Park the pen. - * - * @param {boolean} direct - * True to send direct and skip the buffer. - * @param {function} callback - * Ya know. - */ - pen.park = (direct = false, callback = () => {}) => { - pen.setHeight('up', () => { - pen.setPen({ - x: cncserver.settings.bot.park.x, - y: cncserver.settings.bot.park.y, - park: true, - skipBuffer: direct, - }, callback); - }, direct); - }; + speed = parseFloat(speed) / 100; + // Convert to steps from percentage + speed = (speed * (maxSpeed - minSpeed)) + minSpeed; - /** - * Force the values of a given set of keys within the pen state. - * - * @param {object} inState - * Flat object of key/value pairs to FORCE into the pen state. Only used to - * correct head state or to update position along the buffer. - */ - pen.forceState = (inState) => { - for (const [key, value] of Object.entries(inState)) { - // Only set a value if the key exists in the state already. - if (key in pen.state) { - pen.state[key] = value; - } - } - }; + // Sanity check speed value + speed = speed > maxSpeed ? maxSpeed : speed; + speed = speed < minSpeed ? minSpeed : speed; - /** - * Set the internal state hash value. - * - * @param {string} hash - * Buffer hash to set. - */ - pen.setHash = (hash) => { - pen.state.bufferHash = hash; - }; + // How many steps a second? + return Math.max(Math.abs(Math.round(distance / speed * 1000)), min); +} - // Exports... - pen.exports = { - setPen: pen.setPen, - setHeight: pen.setHeight, +/** + * Given two points, find the difference and duration at current speed + * + * @param {{x: number, y: number}} src + * Source position coordinate (in steps). + * @param {{x: number, y: number}} dest + * Destination position coordinate (in steps). + * @param {number} speed + * Speed override for this movement in percent. + * + * @returns {{d: number, x: number, y: number}} + * Object containing the change amount in steps for x & y, along with the + * duration in milliseconds. + */ +export function getPosChangeData(src, dest, speed = null) { + let change = { + x: Math.round(dest.x - src.x), + y: Math.round(dest.y - src.y), }; - return pen; -}; + // Calculate distance + const duration = getDurationFromDistance(utils.getVectorLength(change), 1, src, speed); + + // Adjust change direction/inversion + if (botConf.get('controller').position === 'relative') { + // Invert X or Y to match stepper direction + change.x = gConf.get('invertAxis:x') ? change.x * -1 : change.x; + change.y = gConf.get('invertAxis:y') ? change.y * -1 : change.y; + } else { // Absolute! Just use the "new" absolute X & Y locations + change.x = state.x; + change.y = state.y; + } + + // Swap motor positions + if (gConf.get('swapMotors')) { + change = { + x: change.y, + y: change.x, + }; + } + + return { d: duration, x: change.x, y: change.y }; +} diff --git a/src/components/core/control/cncserver.print.js b/src/components/core/control/cncserver.print.js new file mode 100644 index 00000000..70a6c87d --- /dev/null +++ b/src/components/core/control/cncserver.print.js @@ -0,0 +1,269 @@ +/** + * @file Abstraction module for print API and print rendering. + */ + +import * as projects from 'cs/projects'; +import png from 'png-metadata'; +import path from 'path'; +import * as utils from 'cs/utils'; +import * as tools from 'cs/tools'; +import { movePenAbs } from 'cs/control'; +import { colors, base, accell } from 'cs/drawing'; +import { trigger } from 'cs/binder'; + +// TODO: +// - Convert buffer render over to string render +// - Resettable render pen state (Don't do it like this below) +// - Render sections to work groups, output as ordered colorset item keyed "gcode" arrays +// - Ensure this render is still bufferable and supports all features (Inside, outside, tool positions) +// - Render progress updates. +// - Build PNG with info window in bottom left. +// - Write render data to PNG +// - Finish WCB options. + +// Set of PNG chunk headers for C.NC S.erver P.rint PNGs. +export const pngPayloadChunks = { + PRINT_CHUNK_TITLE: 'CSPt', // Plain: Title of the print. + PRINT_CHUNK_PROJECT: 'CSPp', // JSON: Project specific dat (paper color, etc). + PRINT_CHUNK_COLORSET: 'CSPc', // JSON: Colorset used to generate data. + PRINT_CHUNK_SETTINGS: 'CSPs', // JSON: Print settings used to generate data. + PRINT_CHUNK_DATA: 'CSPd', // JSON: Array of rendered work groupings. +}; + +/** + * Using a loaded PNG file buffer, set a specified chunk by name. + * + * @param {Buffer} data + * PNG data buffer from readFileSync. + * @param {string} name + * 4 character chunk identifier. + * + * @returns {Buffer} + * Joined binary buffer containing new data. + */ +function addChunk(data, name, value, isJSON = true) { + const chunks = png.splitChunk(data); + const writeData = isJSON ? JSON.stringify(value) : value; + chunks.splice(-1, 0, png.createChunk(name, writeData)); + return png.joinChunk(chunks); +} + +/** + * Using a loaded PNG file buffer, get the specified chunk by name. + * + * @param {Buffer} data + * PNG data buffer from readFileSync. + * @param {string} name + * 4 character chunk identifier. + * + * @returns {string|Object} + * Data pulled from PNG file data. + */ +function getChunk(data, name, isJSON = true) { + const chunks = png.splitChunk(data); + let outData = chunks.find(chunk => chunk.type === name); + outData = outData?.data ?? null; + if (isJSON) { + try { + outData = JSON.parse(outData); + } catch (error) { + // Oh well. Return the untouched data. + } + } + return outData; +} + +/** + * Save the current project and rendered print content as a PNG file. + * + * @export + */ +export function saveFile() { + const filePath = path.join(utils.__basedir, 'interface', 'test_zener.png'); + const data = png.readFileSync(filePath); + + /* + data = addChunk(data, PRINT_CHUNK_COLORSET, 'colorset goes here'); + data = addChunk(data, PRINT_CHUNK_SETTINGS, 'settings goes here'); + data = addChunk(data, PRINT_CHUNK_DATA, 'data goes here'); + */ + + const outFilePath = path.join(utils.__basedir, 'interface', 'test_png_write.png'); + png.writeFileSync(outFilePath, data, 'binary'); +} + +/** + * Parse the data chunks from a given PNG file. + * + * @export + * @param {string} filePath + * Path to file to be read. + * + * @returns {Object|null} + * Object of all print data from the PNG, null if invalid. + */ +export function getPrintData(filePath) { + const data = png.readFileSync(filePath); + + const commands = getChunk(data, pngPayloadChunks.PRINT_CHUNK_DATA); + + const out = {}; + if (commands) { + out.title = getChunk(data, pngPayloadChunks.PRINT_CHUNK_TITLE); + out.colorset = getChunk(data, pngPayloadChunks.PRINT_CHUNK_COLORSET); + out.settings = getChunk(data, pngPayloadChunks.PRINT_CHUNK_SETTINGS); + out.data = commands; + } else { + return null; + } + + return out; +} + +/** + * Recursively calculate acceleration along a given path. + * + * @export + * @param {paper.Path} pathItem + * Path to file to be read. + * + * @returns {Object|null} + * Object of all print data from the PNG, null if invalid. + */ +export const accelMoveOnPath = pathItem => new Promise((success, error) => { + const move = (point, speed = null) => { + const stepPoint = utils.absToSteps(point, 'mm', true); + movePenAbs(stepPoint, null, true, null, speed); + }; + + // Pen up + pen.setPen({ state: 'up' }); + + // Move to start of path, then pen down. + move(pathItem.getPointAt(0)); + pen.setPen({ state: 'draw' }); + + // Calculate groups of accell points and run them into moves. + accell.getPoints(pathItem, accellPoints => { + // If we have data, move to those points. + if (accellPoints && accellPoints.length) { + // Move through all accell points from start to nearest end point + accellPoints.forEach(pos => { + move(pos.point, pos.speed); + }); + } else { + // Null means generation of accell points was cancelled. + if (accellPoints !== null) { + // No points? We're done. Wrap up the line. + + // Move to end of path... + move(pathItem.getPointAt(pathItem.length)); + + // If it's a closed path, overshoot back home. + if (pathItem.closed) { + move(pathItem.getPointAt(0)); + } + + // End with pen up. + pen.setPen({ state: 'up' }); + + // Fulfull the promise for this subpath. + success(); + return; + } + + // If we're here, path generation was cancelled, bubble it. + error(); + } + }); +}); + +/** + * Actually render Paper paths into movements + * + * @param {object} source + * Source paper object containing the children, defaults to preview layer. + */ +export function renderPathsToMoves(reqSettings = {}) { + const source = base.layers.print; + const settings = { + parkAfter: true, + ...reqSettings, + }; + // TODO: + // * Join extant non-closed paths with endpoint distances < 0.5mm + // * Split work by colors + // * Allow WCB bot support to inject tool changes for refill support + // * Order paths by pickup/dropoff distance + + // Store work for all paths grouped by color + const workGroups = colors.getWorkGroups(); + const validColors = Object.keys(workGroups); + source.children.forEach(colorGroup => { + if (workGroups[colorGroup.name]) { + workGroups[colorGroup.name] = base.getPaths(colorGroup); + } + }); + + let workGroupIndex = 0; + function nextWorkGroup() { + const colorID = validColors[workGroupIndex]; + if (colorID) { + const paths = workGroups[colorID]; + + if (paths.length) { + // Bind trigger for implementors on work group begin. + trigger('print.render.group.begin', colorID); + + // Do we have a tool for this colorID? If not, use manualswap. + if (colors.doColorParsing()) { + const changeTool = tools.has(colorID) ? colorID : 'manualswap'; + tools.changeTo(changeTool, colorID); + } + + let pathIndex = 0; + const nextPath = () => { + if (paths[pathIndex]) { + accelMoveOnPath(paths[pathIndex]).then(() => { + // Trigger implementors for path render complete. + trigger('print.render.path.finish', paths[pathIndex]); + + // Path in this group done, move to the next. + pathIndex++; + nextPath(); + }).catch(error => { + // If we have an error object, it's an actual error! + if (error) { + console.error(error); + } + // Otherwise, path generation has been entirely cancelled. + workGroupIndex = validColors.length; + }); + } else { + // No more paths in this group, move to the next. + workGroupIndex++; + nextWorkGroup(); + } + }; + + // Start processing paths in the initial workgroup. + nextPath(); + } else { + // There is no work for this group, move to the next one. + workGroupIndex++; + nextWorkGroup(); + } + } else { + // Actually complete with all paths in all work groups! + // TODO: Fullfull a promise for the function? + + // Trigger binding for implementors on sucessfull rendering completion. + trigger('print.render.finish'); + + // If settings say to park. + if (settings.parkAfter) pen.park(); + } + } + // Intitialize working on the first group on the next process tick. + process.nextTick(nextWorkGroup); +} diff --git a/src/components/core/control/cncserver.projects.js b/src/components/core/control/cncserver.projects.js index 8ce5ed42..f850ba82 100644 --- a/src/components/core/control/cncserver.projects.js +++ b/src/components/core/control/cncserver.projects.js @@ -1,211 +1,249 @@ /** * @file Abstraction for high level project management, execution. */ -const fs = require('fs'); -const path = require('path'); -const DataURI = require('datauri'); -const dataUriToBuffer = require('data-uri-to-buffer'); -const { homedir } = require('os'); +import fs from 'fs'; +import path from 'path'; +import DataURI from 'datauri'; +import dataUriToBuffer from 'data-uri-to-buffer'; +import { homedir } from 'os'; +import * as utils from 'cs/utils'; +import { getDataDefault } from 'cs/schemas'; +import { + colors, stage, preview, temp +} from 'cs/drawing'; +import { bindTo, trigger } from 'cs/binder'; +// import { renderPathsToMoves } from 'cs/control'; +import * as content from 'cs/content'; const PROJECT_JSON = 'cncserver.project.json'; const PREVIEW_SVG = 'cncserver.project.preview.svg'; // Exposed export to be attached as cncserver.projects -const projects = { - items: new Map(), - id: 'projects', +export const state = { homeDir: '', current: '', rendering: false, printing: false, }; +export const items = new Map(); + +// Fit a "simple" or complete object into the full schema object shape. +export const fitShape = data => getDataDefault('projects', data); + const getDirectories = source => fs.readdirSync(source, { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); -function getProjectDirName(item) { - return path.resolve(projects.homeDir, `${item.name}-${item.hash}`); +function getProjectDirName({ name, hash }) { + return path.resolve(state.homeDir, `${name}-${hash}`); } // Load all projects by file from given paths. function loadProjects() { - projects.items.clear(); + items.clear(); - const dirs = getDirectories(projects.homeDir); - dirs.forEach((dir) => { - const jsonPath = path.resolve(projects.homeDir, dir, PROJECT_JSON); + const dirs = getDirectories(state.homeDir); + dirs.forEach(dir => { + const jsonPath = path.resolve(state.homeDir, dir, PROJECT_JSON); if (fs.existsSync(jsonPath)) { - // eslint-disable-next-line import/no-dynamic-require, global-require - const item = require(jsonPath); - projects.items.set(item.hash, { + const item = fitShape(utils.getJSONFile(jsonPath)); + items.set(item.hash, { ...item, - dir: path.resolve(projects.homeDir, dir), + dir: path.resolve(state.homeDir, dir), }); } }); } +// Load a project from a file. Assume hash is validated. +export function openProject(hash) { + const project = items.get(hash); + state.current = hash; + + content.items.clear(); + stage.clearAll(); + preview.clearAll(); + + // Apply the colorset preset in the project, if we can. + if (project.colorset) { + colors.applyPreset(project.colorset).catch(e => { + console.error(e); + }); + } + + // Get all the info loaded into the content items, and get the file data. + Object.entries(project.content).forEach(([, item]) => { + const filePath = getContentFilePath(item.source.content, hash); + let data = ''; + + // TODO: What if a file isn't there? + if (item.source.type === 'raster') { + const datauri = new DataURI(filePath); + data = datauri.content; + } else { + data = fs.readFileSync(filePath).toString(); + } + + content.loadFromFile(item, data); + }); + + trigger('projects.update', project); + + return getResponseItem(hash); +} + // Initialize with a "current" project. function initProject() { const now = new Date(); // Create a temp project or load last. - projects.addItem({ + // TODO: When deleting an open project, default to this. + addItem({ title: 'New Project', description: `Automatic project created ${now.toLocaleDateString()}`, open: true, + colorset: 'default', }); } -module.exports = (cncserver) => { - const { utils } = cncserver; - - // TODO: Implement project paging. - projects.getItems = () => { - const items = []; - projects.items.forEach(({ - modified, hash, title, description, name, ...project - }) => { - items.push({ - hash, - title, - description, - modified, - name, - preview: projects.getRelativePreview(project), - }); +// Get the relative preview SVG path from a project object. +export function getRelativePreview(project) { + const absPath = path.resolve(project.dir, PREVIEW_SVG); + const cncserverHome = path.resolve(homedir(), 'cncserver'); + return fs.existsSync(absPath) ? absPath.replace(cncserverHome, '/home') : null; +} + +// TODO: Implement project paging. +export function getItems() { + const newItems = []; + items.forEach(({ + modified, hash, title, description, name, ...project + }) => { + newItems.push({ + hash, + title, + description, + modified, + name, + preview: getRelativePreview(project), }); - return items; - }; + }); + return newItems; +} - // Get the relative preview SVG path from a project object. - projects.getRelativePreview = (project) => { - const absPath = path.resolve(project.dir, PREVIEW_SVG); - const cncserverHome = path.resolve(homedir(), 'cncserver'); - return fs.existsSync(absPath) ? absPath.replace(cncserverHome, '/home') : null; - }; +export const getCurrentHash = () => state.current; - projects.getCurrentHash = () => projects.current; +// Customize the stored object to be more appropriate for responses. +export function getResponseItem(hash = state.current) { + const project = { ...items.get(hash) }; + delete project.dir; + return project; +} - // Customize the stored object to be more appropriate for responses. - projects.getResponseItem = (hash) => { - const project = { ...projects.items.get(hash) }; - delete project.dir; - return project; - }; +// Assume schema has been checked by the time we get here. +// TODO: add support for adding content on creation. +// TODO: When does creating a project not set it as current? +export function addItem({ + title, description = '', name, open, options = {}, +}) { + const cDate = new Date(); + + const optionsWithDefaults = fitShape({ + title, + description, + name: utils.getMachineName(name || title, 15), + open, + colorset: colors.set.name, + options, + }); - // Assume schema has been checked by the time we get here. - // TODO: add support for adding content on creation. - // TODO: When does creating a project not set it as current? - projects.addItem = ({ - title, description = '', name, open, - }) => { - const cDate = new Date(); + // Compute the entire new object. + const item = { + cncserverProject: 'v3', + hash: utils.getHash({ title, description, name }, 'date'), + created: cDate.toISOString(), + modified: cDate.toISOString(), + ...optionsWithDefaults, + content: {}, + }; + item.dir = getProjectDirName(item); - // Compute the entire new object. - const item = { - cncserverProject: 'v3', - hash: utils.getHash({ title, description, name }, 'date'), - title, - description, - name: utils.getMachineName(name || title, 15), - created: cDate.toISOString(), - modified: cDate.toISOString(), - content: {}, - }; - item.dir = getProjectDirName(item); - - projects.items.set(item.hash, item); - - // If we're opening by default. - if (open) { - projects.open(item.hash); - } + items.set(item.hash, item); - return projects.getResponseItem(item.hash); - }; + // If we're opening by default. + if (open) { + openProject(item.hash); + } - // Convert a relative file path and project hash into a full file path. - projects.getContentFilePath = (name, projectHash) => { - const project = projects.items.get(projectHash); - return path.resolve(project.dir, name); - }; + return getResponseItem(item.hash); +} - // Actually save the files out for a project. - projects.saveProjectFiles = (hash = projects.current) => { - const item = projects.items.get(hash); - item.modified = new Date().toISOString(); - projects.items.set(hash, item); +// Convert a relative file path and project hash into a full file path. +export function getContentFilePath(name, projectHash) { + const project = items.get(projectHash); + return path.resolve(project.dir, name); +} - const dir = utils.getDir(getProjectDirName(item)); +// Set the colorset for a project. +// Currently only happens when a colorset preset is loaded. +export function setColorset(colorset, hash = state.current) { + const item = items.get(hash); + if (item.colorset !== colorset) { + item.colorset = colorset; + saveProjectFiles(hash); + } +} - // Make an ammended version of item for saving, don't store some keys. - const saveItem = { ...item }; - delete saveItem.dir; +// Actually save the files out for a project. +export function saveProjectFiles(hash = state.current) { + const item = items.get(hash); + item.modified = new Date().toISOString(); + items.set(hash, item); - // Save the preview. - fs.writeFileSync(path.resolve(dir, PREVIEW_SVG), cncserver.drawing.stage.getPreviewSVG()); + const dir = utils.getDir(getProjectDirName(item)); - // Write the final settings file. - fs.writeFileSync(path.resolve(dir, PROJECT_JSON), JSON.stringify(saveItem, null, 2)); - }; + // Make an ammended version of item for saving, don't store some keys. + const saveItem = { ...item }; + delete saveItem.dir; - // Load a project from a file. Assume hash is validated. - projects.open = (hash) => { - const { content } = cncserver; - const project = projects.items.get(hash); - projects.current = hash; - - content.items.clear(); - cncserver.drawing.stage.clearAll(); - - // Get all the info loaded into the content items, and get the file data. - Object.entries(project.content).forEach(([, item]) => { - const filePath = projects.getContentFilePath(item.source.content, hash); - let data = ''; - - // TODO: What if a file isn't there? - if (item.source.type === 'raster') { - const datauri = new DataURI(filePath); - data = datauri.content; - } else { - data = fs.readFileSync(filePath).toString(); - } + // Save the preview. + fs.writeFileSync(path.resolve(dir, PREVIEW_SVG), stage.getPreviewSVG()); - content.loadFromFile(item, data); - }); + // Write the final settings file. + fs.writeFileSync(path.resolve(dir, PROJECT_JSON), JSON.stringify(saveItem, null, 2)); - return projects.getResponseItem(hash); - }; + trigger('projects.update', getResponseItem()); +} - // Add (or update) a content instance entry for a project. - // Assume project hash validation. - projects.saveContentData = (item, projectHash) => { - const project = projects.items.get(projectHash); - if (!project.content) project.content = {}; - project.content[item.hash] = item; - projects.items.set(projectHash, project); - projects.saveProjectFiles(projectHash); - }; +// Add (or update) a content instance entry for a project. +// Assume project hash validation. +export function saveContentData(item, projectHash) { + const project = items.get(projectHash); + if (!project.content) project.content = {}; + project.content[item.hash] = item; + items.set(projectHash, project); + saveProjectFiles(projectHash); +} - // Remove the data for a piece of content. - projects.removeContentData = (contentHash, projectHash) => { - const project = projects.items.get(projectHash); - delete project.content[contentHash]; - projects.items.set(projectHash, project); - projects.saveProjectFiles(projectHash); - }; +// Remove the data for a piece of content. +export function removeContentData(contentHash, projectHash) { + const project = items.get(projectHash); + delete project.content[contentHash]; + items.set(projectHash, project); + saveProjectFiles(projectHash); +} - // Save content to a file/project. - projects.saveContentFile = (source, projectHash) => new Promise((resolve, reject) => { - const ext = cncserver.content.limits.extensions[source.mimetype]; +// Save content to a file/project. +export function saveContentFile(source, projectHash) { + return new Promise((resolve, reject) => { + const ext = content.limits.extensions[source.mimetype]; const fileName = `${utils.getHash(source.content, null)}.${ext}`; // If this is the first time content is being added to a project, we need to // create the destination dir first and save all the parts. - const project = projects.items.get(projectHash); + const project = items.get(projectHash); if (!fs.existsSync(project.dir)) { - projects.saveProjectFiles(projectHash); + saveProjectFiles(projectHash); } // Use a buffer for Data URI raster, or a string for the rest. @@ -215,33 +253,55 @@ module.exports = (cncserver) => { // TODO: Don't bother writing the file if it's the same. // EDGECASE: file bytes don't match and it needs a rewrite. - fs.writeFile(projects.getContentFilePath(fileName, projectHash), data, (err) => { + fs.writeFile(getContentFilePath(fileName, projectHash), data, err => { if (err) { reject(err); } else { resolve(fileName); } }); }); +} - // Get a fully filled out merged settings object including project overrides. - projects.getFullSettings = (settings, projectHash = projects.current) => { - if (projectHash) { - const project = projects.items.get(projectHash); - return cncserver.schemas.getDataDefault( - 'settings', - utils.merge(project.settings || {}, settings) - ); - } +// Get a copy of the raw internal item for the current project. +export const getCurrent = () => ({ ...items.get(state.current) }); - return cncserver.schemas.getDataDefault('settings', utils.merge(settings)); - }; +// Get a fully filled out merged settings object including project overrides. +export function getFullSettings(settings, projectHash = state.current) { + if (projectHash) { + const project = items.get(projectHash); + return getDataDefault( + 'settings', + utils.merge(project.settings || {}, settings) + ); + } - // The only thing we actually allow editing of here is the Title, name and desc. - projects.editItem = ({ hash }, { name, title, description, settings }) => new Promise((resolve, reject) => { + return getDataDefault('settings', utils.merge(settings)); +} + +// The only thing we actually allow editing of here is the Title, name and desc. +export function editItem({ hash }, { + name, title, description, settings, +}) { + return new Promise((resolve, reject) => { let changes = false; - const project = projects.items.get(hash); + const project = items.get(hash); - // Change name. + // Change name (must rename folder). if (name) { changes = true; - project.name = utils.getMachineName(name || title, 15); + const newName = utils.getMachineName(name || title, 15); + + // Name change? Rename the dest folder. + if (newName !== project.name) { + const oldPath = getProjectDirName({ hash: project.hash, name: project.name }); + const newPath = getProjectDirName({ hash: project.hash, name: newName }); + + // If the old dir exists, rename it. + if (fs.existsSync(oldPath)) { + fs.renameSync(oldPath, newPath); + } + + project.dir = newPath; + } + + project.name = newName; } // Change title @@ -263,91 +323,101 @@ module.exports = (cncserver) => { } if (changes) { - projects.items.set(hash, project); - projects.saveProjectFiles(hash); - resolve(projects.getResponseItem(hash)); + items.set(hash, project); + trigger('projects.update', project); + saveProjectFiles(hash); + resolve(getResponseItem(hash)); } else { - reject(new Error('Edits to existing projects can only change title, name, description, or settings.')); + reject(new Error(utils.singleLineString`Edits to existing projects can only + change title, name, description, or settings.`)); } }); +} - // Remove a project. - projects.removeItem = hash => new Promise((resolve, reject) => { - const project = projects.items.get(hash); - const trashDir = path.resolve(utils.getUserDir('trash'), `project-${project.name}-${hash}`); - fs.rename(project.dir, trashDir, (err) => { - if (err) { - reject(err); - } else { - projects.items.delete(hash); - resolve(); - } - }); - }); - - // Wait till after Paper.js and schemas are loaded and get home folder, load projects. - cncserver.binder.bindTo('schemas.loaded', projects.id, () => { - projects.homeDir = cncserver.utils.getUserDir('projects'); - loadProjects(); - initProject(); - }); - - // Rendering and print state management. - projects.getPrintingState = () => projects.printing; - projects.getRenderingState = () => projects.rendering; - - projects.setRenderingState = (newState, specificHash = null) => { - if (projects.rendering === newState) return; - - if (newState) { - projects.renderCurrentContent(specificHash).then(() => { - // TODO: Send async stream update for render completion. - // ...and render start? - projects.rendering = false; - - // Clear out the temp layer to free memory. - // TODO: Move this to a binder event? - cncserver.drawing.temp.clearAll(); - }); - } else { - // TODO: Stop the render...somehow? - } - projects.rendering = newState; - }; - - projects.setPrintingState = (newState) => { - if (projects.printing === newState) return; - - if (newState) { - // TODO: - } else { - // TODO: - } - projects.printing = newState; - }; - - // Render all loaded items to preview, or just one. - projects.renderCurrentContent = (specificHash) => { - // Clear out the preview. - if (specificHash) { - cncserver.drawing.preview.remove(specificHash, true); - } else { - cncserver.drawing.preview.clearAll(specificHash); - } - - const renderPromises = []; - if (specificHash) { - const item = cncserver.content.items.get(specificHash); - renderPromises.push(cncserver.content.renderContentItem(item)); +// Remove a project. +export const removeItem = hash => new Promise((resolve, reject) => { + // TODO: When deleting an open project, default to this. + const project = items.get(hash); + const trashDir = path.resolve( + utils.getUserDir('trash'), `project-${project.name}-${hash}` + ); + fs.rename(project.dir, trashDir, err => { + if (err) { + reject(err); } else { - cncserver.content.items.forEach((item) => { - renderPromises.push(cncserver.content.renderContentItem(item)); - }); + items.delete(hash); + resolve(); } + }); +}); + +// Wait till after Paper.js and schemas are loaded and get home folder, load projects. +bindTo('paper.ready', 'projects', () => { + state.homeDir = utils.getUserDir('projects'); + loadProjects(); + initProject(); + + // Load last. DEBUG + setTimeout(() => { + openProject('6a1253d8aaefb233'); + }, 1); +}); + +// Rendering and print state management. +export const getPrintingState = () => state.printing; +export const getRenderingState = () => state.rendering; + +export function setRenderingState(newState, specificHash = null) { + if (state.rendering === newState) return; + + if (newState) { + renderCurrentContent(specificHash).then(() => { + // TODO: Send async stream update for render completion. + // ...and render start? + state.rendering = false; + + // Clear out the temp layer to free memory. + // TODO: Move this to a binder event? + temp.clearAll(); + }); + } else { + // TODO: Stop the render...somehow? + } + state.rendering = newState; +} - return Promise.all(renderPromises); - }; +export function setPrintingState(newState) { + if (state.printing === newState) return; + + if (newState) { + // TODO: + console.log('Start printing!'); + // renderPathsToMoves(); + } else { + // TODO: + console.log('Stop printing!'); + } + state.printing = newState; +} +// Render all loaded items to preview, or just one. +export function renderCurrentContent(specificHash) { + // Clear out the preview. + if (specificHash) { + preview.remove(specificHash, true); + } else { + preview.clearAll(specificHash); + } + + const renderPromises = []; + if (specificHash) { + const item = content.items.get(specificHash); + renderPromises.push(content.renderContentItem(item)); + } else { + content.items.forEach(item => { + renderPromises.push(content.renderContentItem(item)); + }); + } - return projects; -}; + return Promise.all(renderPromises); +} diff --git a/src/components/core/control/cncserver.tools.js b/src/components/core/control/cncserver.tools.js new file mode 100644 index 00000000..78720f55 --- /dev/null +++ b/src/components/core/control/cncserver.tools.js @@ -0,0 +1,328 @@ +/** + * @file Abstraction module for tool state and helper methods. + */ +import Paper from 'paper'; +import * as utils from 'cs/utils'; +import { botConf } from 'cs/settings'; +import { movePenAbs } from 'cs/control'; +import { setHeight, forceState } from 'cs/pen'; +import { trigger, bindTo } from 'cs/binder'; +import { base, colors } from 'cs/drawing'; +import run from 'cs/run'; +import { sendPaperUpdate } from 'cs/sockets'; +import { resume } from 'cs/buffer'; + +const { Path, Group, PointText } = Paper; + +// Set as part of colorset tool update. +export const set = { + name: '', + items: new Map(), +}; + +// List all tool presets and their data. +export function listPresets(t, customOnly) { + // TODO: Translate title/description. + return customOnly + ? utils.getCustomPresets('toolsets') + : utils.getPresets('toolsets'); +} + +// List custom/overridden machine names. +export function customKeys() { + return Object.keys(utils.getCustomPresets('toolsets')); +} + +// List internal machine names. +export function internalKeys() { + return Object.keys(utils.getInternalPresets('toolsets')); +} + +// Object of preset keys with set tools parents unavailable to this bot. +export function invalidPresets() { + const out = {}; + const sets = listPresets(); + const botTools = botConf.get('tools'); + const botName = botConf.get('name'); + + // Move through all sets, check the toolset for missing parents + Object.entries(sets).forEach(([name, { items: setItems }]) => { + setItems.forEach(item => { + if (item.parent && !(item.parent in botTools)) { + if (!(name in out)) out[name] = {}; + out[name][item.parent] = utils.singleLineString`'${botName}' does not supply + required parent tool '${item.parent}'`; + } + }); + }); + + return out; +} + +// Get the current toolset as a translated, array based object. +export function getResponseSet(t) { + // TODO: Translate title/description. + return { + ...set, + items: utils.mapToArray(set.items), + }; +} + +// Convert a colorset toolset to a flat array +export function colorsetTools(asMap = false) { + return asMap ? set.items : utils.mapToArray(set.items); +} + +// Can we edit the given tool id? +export function canEdit(id) { + const editableList = Array.from(set.items.keys()); + return id ? editableList.includes(id) : editableList; +} + +// Function to run after the tools have been changed (add, delete, edit). +export function sendUpdate() { + saveCustom(); + sendPaperUpdate(); + trigger('tools.update'); +} + +// Function for editing tools (from the toolset only). +export const edit = tool => new Promise(resolve => { + set.items.set(tool.id, tool); + sendUpdate(); + resolve(set.items.get(tool.id)); +}); + +// Function for editing toolset base properties (name, manufacturer, title, desc). +export const editSet = toolset => new Promise(resolve => { + toolset.name = utils.getMachineName(toolset.name, 64); + // Name change: Update in colorset. + if (set.name !== toolset.name) { + colors.set.toolset = toolset.name; + colors.saveCustom(); + } + utils.applyObjectTo({ ...toolset, items: set.items }, set); + sendUpdate(); + resolve(getResponseSet()); +}); + +// Delete the a verified ID. +export function deletePreset(id) { + set.items.delete(id); + sendUpdate(); +} + +// Add a validated tool. +export const add = tool => new Promise((resolve, reject) => { + if (!set.items.get(tool.id)) { + set.items.set(tool.id, tool); + sendUpdate(); + resolve(set.items.get(tool)); + } else { + reject( + new Error(utils.singleLineString`Custom colorset tool with id + "${tool.id}" already exists, update it directly or choose a different id.`) + ); + } +}); + +// Flatten bot tools to array. +export function getBotTools() { + const botTools = botConf.get('tools'); + const out = []; + + Object.entries(botTools).forEach(([id, tool]) => { + out.push({ + id, + parent: '', + ...tool, + x: parseFloat(tool.x), + y: parseFloat(tool.y), + width: tool.width ? parseFloat(tool.width) : 0, + height: tool.height ? parseFloat(tool.height) : 0, + }); + }); + return out; +} + +// Get a flat array of tools. +export function items() { + return [ + ...getBotTools(), + ...colorsetTools(), + ]; +} + +export const getNames = () => items().map(({ id }) => id); + +// Get a single item, undefined if invalid. +export const getItem = name => items().find(({ id }) => id === name); + +// Automatically set the internal "tools.set" from "colors.set.toolset". +export function setFromColors() { + const preset = utils.getPreset('toolsets', colors.set.toolset); + + utils.applyObjectTo({ ...preset, items: utils.arrayToIDMap(preset.items) }, set); +} + +// Save changes to the current toolset +export function saveCustom() { + // Special failover to prevent squashing empty "default". + if (set.name === 'default') { + set.name = 'default-custom'; + colors.set.toolset = set.name; + colors.saveCustom(); + } + + utils.savePreset('toolsets', { + ...set, + items: utils.mapToArray(set.items), + }); +} + +/** + * Run the operation to change the current tool (and any aggregate operations + * required) into the buffer + * + * @param name + * The machine name of the tool (as defined in the bot config file). + * @param index + * Index for notifying user of what the manual tool change is for. + * @param callback + * Triggered when the full tool change is to have been completed, or on + * failure. + * @param waitForCompletion + * Pass false to call callback immediately after calculation, true to + * callback only after physical movement is complete. + * + * @returns {boolean} + * True if success, false on failure. + */ +export const changeTo = ( + name, index = null, callback = () => { }, waitForCompletion = false +) => { + // Get the matching tool object from the bot configuration. + const tool = getItem(name); + + // No tool found with that name? Augh! Run AWAY! + if (!tool) { + run('callback', callback); + return false; + } + + // For wait=false/"resume" tools, we really just resume the buffer. + // It should be noted, this is obviously NOT a queable toolchange. + // This should ONLY be called to restart the queue after a swap. + if (tool.wait !== undefined && tool.wait === false) { + resume(); + callback(1); + return true; + } + + // Pen Up + setHeight('up'); + + // Figure out the final position: + let toolPos = { x: tool.x, y: tool.y }; + + // Is there a parent? Offset for that. + const parent = getItem(tool.parent); + if (parent) { + toolPos.x += parseFloat(parent.x); + toolPos.y += parseFloat(parent.y); + } + + // Convert MM to Abs steps. + toolPos = utils.absToSteps(toolPos, 'mm', true); + + // Prevent out of bounds moves. + toolPos = utils.sanityCheckAbsoluteCoord(toolPos); + + // Move to the tool + movePenAbs(toolPos); + + // Set the tip of state pen to the tool now that the change is done. + forceState({ tool: name }); + + if (index && tool.wait) { + const { implement } = colors.getColor(index); + forceState({ implement, colorsetItem: index }); + } + + // Run force state in as the next step to be resumed. + /* if (index && tool.wait) { + console.log('BUFFERING implement forcestate ==============='); + run('callback', () => { + + console.log('RUNNING implement forcestate ===============', implement, index); + }); + } */ + + // Trigger the binder event. + trigger('tool.change', { ...tool, index, name }); + + // Finish up. + if (waitForCompletion) { // Run inside the buffer + run('callback', callback); + } else { // Run as soon as items have been buffered + callback(1); + } + + return true; +}; + +// Check for an ID. +export function has(checkID) { + return !!items().filter(({ id }) => id === checkID).length; +} + +// Bind to tools.update to redraw the tools layer. +bindTo('tools.update', 'tools', () => { + const { layers } = base; + const toolGroup = new Group(); + + setFromColors(); + + const toolItems = items(); + + layers.tools.removeChildren(); + + // Create a representation path for each tool. + toolItems.forEach(tool => { + const toolPos = { x: tool.x, y: tool.y }; + + // Offset for center positions. + if (tool.position === 'center') { + toolPos.x -= tool.width / 2; + toolPos.y -= tool.height / 2; + } + + // Apply parent offset. + const parent = getItem(tool.parent); + if (parent) { + toolPos.x += parent.x; + toolPos.y += parent.y; + } + + // Don't try to display tools without size. + if (tool.width && tool.height) { + const path = new Path.Rectangle({ + ...toolPos, + width: tool.width, + height: tool.height, + radius: tool.radius, + name: tool.id, + strokeWidth: 1, + strokeColor: 'black', + fillColor: colors.getToolColor(tool.id), + }); + + const label = new PointText({ fontSize: 8, content: tool.id, opacity: 0.5 }); + label.fitBounds(path.bounds); + toolGroup.addChild(new Group([path, label])); + } + }); + + layers.tools.addChild(toolGroup); + sendPaperUpdate('tools'); +}); diff --git a/src/components/core/drawing/cncserver.drawing.accell.js b/src/components/core/drawing/cncserver.drawing.accell.js index 4109e7db..38e03a83 100644 --- a/src/components/core/drawing/cncserver.drawing.accell.js +++ b/src/components/core/drawing/cncserver.drawing.accell.js @@ -1,20 +1,23 @@ /** * @file Code for determining path drawing accelleration planning. */ -const zodiac = require('zodiac-ts'); +import zodiac from 'zodiac-ts'; +import { bindTo } from 'cs/binder'; -// Conglomerated feature export. -const accell = { id: 'drawing.accell', state: 'idle' }; +export const state = { + renderStatus: 'idle', +}; + +// Time before work is sent to the callback for long operations. +const splitTimeout = 2500; // Path planning Settings const s = { - accelRate: 10, // Percentage increase over distance. - speedMultiplyer: 0.55, // Conversion of moment length to velocity. - minSpeed: 5, + accelRate: 25, // 10, // Percentage increase over distance. + speedMultiplyer: 0.75, // 0.55 // Conversion of moment length to velocity. + minSpeed: 15, // 5, resolution: 0.5, // Steps to check along path by - maxDeflection: 5, - // Time before work is sent to the callback for long operations. - splitTimeout: 2500, + maxDeflection: 10, // 5, }; // Path planning: @@ -40,7 +43,6 @@ function getCurvatureBetween(path, from, to, curvatureThreshold) { // CLone the working path into memory. let p = path.clone({ insert: false }); - // Split the path at the from and to locations. p = p.splitAt(from); // Returns the part after from. @@ -90,13 +92,11 @@ function getCurvatureBetween(path, from, to, curvatureThreshold) { return { curvature: startVector.getDirectedAngle(endVector), maxPointDistance }; } - function stepCalc(path, inputOffset) { if (inputOffset > path.length) { return null; } - let offset = inputOffset; if (inputOffset < 0) { offset = 0; @@ -133,125 +133,121 @@ function stepCalc(path, inputOffset) { return { point, tangent, speed }; } -module.exports = (cncserver, drawing) => { - function getSmoothed(vals, rawResults) { - const results = rawResults; - try { - const alpha = 0.3; - const ses = new zodiac.SimpleExponentialSmoothing(vals, alpha); - const forecast = ses.predict(0); - - // Repair forecast ends, always start/end on 0. - forecast.pop(); - /* - forecast[0] = 0; - forecast[forecast.length - 1] = 0; - */ - - // Reinsert smoothed values back to results. - forecast.forEach((smoothedSpeed, index) => { - results[index].speed = Math.round(smoothedSpeed * 10) / 10; - }); - } catch (error) { - // Oh well. - } - - return results; +function getSmoothed(vals, rawResults) { + const results = rawResults; + try { + const alpha = 0.3; + const ses = new zodiac.SimpleExponentialSmoothing(vals, alpha); + const forecast = ses.predict(0); + + // Repair forecast ends, always start/end on 0. + forecast.pop(); + /* + forecast[0] = 0; + forecast[forecast.length - 1] = 0; + */ + + // Reinsert smoothed values back to results. + forecast.forEach((smoothedSpeed, index) => { + results[index].speed = Math.round(smoothedSpeed * 10) / 10; + }); + } catch (error) { + // Oh well. } - // Allow external cancelling of accell process. - accell.cancel = () => { - accell.state = 'idle'; - }; + return results; +} - // Bind to Cancel. - cncserver.binder.bindTo('buffer.clear', accell.id, accell.cancel); +// Allow external cancelling of accell process. +export function cancel() { + state.renderStatus = 'idle'; +} - // SYNC - Get and return a list of accell points directly. - // WARNING: Will entirely block event loop on long paths. - accell.getPointsSync = (path) => { - // Accell should only be doing work on one path at a time. - if (accell.state !== 'idle') { - throw new Error('Can only accell one path at a time.'); - } +// Bind to Cancel. +bindTo('buffer.clear', 'drawing.accell', cancel); - accell.state = 'processing'; - const results = []; - const vals = []; - const traverseLength = path.length + s.resolution; +// SYNC - Get and return a list of accell points directly. +// WARNING: Will entirely block event loop on long paths. +export function getPointsSync(path) { + // Accell should only be doing work on one path at a time. + if (state.renderStatus !== 'idle') { + throw new Error('Can only accell one path at a time.'); + } - // Reset speed follow soft global. - speed = 0; - for (let offset = 0; offset <= traverseLength; offset += s.resolution) { - const v = stepCalc(path, offset); - if (v) { - vals.push(v.speed); - results.push(v); - } + state.renderStatus = 'processing'; + const results = []; + const vals = []; + const traverseLength = path.length + s.resolution; + + // Reset speed follow soft global. + speed = 0; + for (let offset = 0; offset <= traverseLength; offset += s.resolution) { + const v = stepCalc(path, offset); + if (v) { + vals.push(v.speed); + results.push(v); } + } - accell.state = 'idle'; - return getSmoothed(vals, results); - }; - - // ASYNC - Get a list of accelleration points, splitting the work up into batches. - accell.getPoints = (path, resultCallback) => { - // Accell should only be doing work on one path at a time. - if (accell.state !== 'idle') { - throw new Error('Can only accell one path at a time.'); - } + state.renderStatus = 'idle'; + return getSmoothed(vals, results); +} - speed = 0; // Reset speed follow soft global. +// ASYNC - Get a list of accelleration points, splitting the work up into batches. +export function getPoints(path, resultCallback) { + // Accell should only be doing work on one path at a time. + if (state.renderStatus !== 'idle') { + throw new Error('Can only accell one path at a time.'); + } - let splitTimer = new Date(); - const results = []; - const vals = []; - const traverseLength = path.length + s.resolution; - accell.state = 'processing'; + speed = 0; // Reset speed follow soft global. - // Start range of where to return results. - let returnResultIndex = 0; + let splitTimer = new Date(); + const results = []; + const vals = []; + const traverseLength = path.length + s.resolution; + state.renderStatus = 'processing'; - let offset = 0; - const nextOffset = () => { - // If the state changes here then processing has been canceled. - if (accell.state === 'idle') { - resultCallback(null); - return; - } + // Start range of where to return results. + let returnResultIndex = 0; - // Process along the offset in the path. - if (offset <= traverseLength) { - const v = stepCalc(path, offset); - if (v) { - vals.push(v.speed); - results.push(v); - } + let offset = 0; + const nextOffset = () => { + // If the state changes here then processing has been canceled. + if (state.renderStatus === 'idle') { + resultCallback(null); + return; + } - // Is this taking too long? Split the work up. - if (new Date() - splitTimer > s.splitTimeout) { - resultCallback( - getSmoothed(vals, results).slice(returnResultIndex) - ); - returnResultIndex = vals.length; - splitTimer = new Date(); - } - offset += s.resolution; - setTimeout(nextOffset, 0); - } else { - accell.state = 'idle'; + // Process along the offset in the path. + if (offset <= traverseLength) { + const v = stepCalc(path, offset); + if (v) { + vals.push(v.speed); + results.push(v); + } - // We're completely done with the path. + // Is this taking too long? Split the work up. + if (new Date() - splitTimer > splitTimeout) { resultCallback( getSmoothed(vals, results).slice(returnResultIndex) ); - resultCallback([]); + returnResultIndex = vals.length; + splitTimer = new Date(); } - }; - - // Initialize getting the first offset. - setTimeout(nextOffset, 0); + offset += s.resolution; + setTimeout(nextOffset, 0); + } else { + state.renderStatus = 'idle'; + + // We're completely done with the path. + resultCallback( + getSmoothed(vals, results).slice(returnResultIndex) + ); + resultCallback([]); + } }; - return accell; -}; + // Initialize getting the first offset. + setTimeout(nextOffset, 0); +} diff --git a/src/components/core/drawing/cncserver.drawing.base.js b/src/components/core/drawing/cncserver.drawing.base.js index 0110c621..f3f08b6c 100644 --- a/src/components/core/drawing/cncserver.drawing.base.js +++ b/src/components/core/drawing/cncserver.drawing.base.js @@ -1,310 +1,341 @@ /** * @file Drawing base code used by other drawing utils. */ -const { - Point, Size, Project, Rectangle, Group, Path, CompoundPath, Layer, -} = require('paper'); - -// Central drawing base export -const base = { id: 'drawing.base', layers: {} }; -module.exports = (cncserver) => { - base.project = {}; - - cncserver.binder.bindTo('controller.setup', base.id, () => { - const { settings: { bot } } = cncserver; - // Setup the project with the max cavas size in mm. - base.size = new Size(bot.maxAreaMM.width, bot.maxAreaMM.height); - - // Setup the actual printable work space as a rectangle. - base.workspace = new Rectangle({ - from: [bot.workAreaMM.left, bot.workAreaMM.top], - to: [bot.workAreaMM.right, bot.workAreaMM.bottom], - }); +// Paper does everything with getters and settings attached to object params. +/* eslint-disable no-param-reassign */ - base.project = new Project(base.size); +import Paper from 'paper'; +import { trigger, bindTo } from 'cs/binder'; +import { bot } from 'cs/settings'; +import { sendPaperUpdate } from 'cs/sockets'; - // Setup layers: temp, working - // Whatever the last layer added was, will be default. - const layers = ['import', 'temp', 'stage', 'preview']; - layers.forEach((name) => { - base.layers[name] = new Layer({ name }); - }); +const { + Point, Size, Project, Rectangle, Group, Path, CompoundPath, Layer, +} = Paper; - // Trigger paper ready. - cncserver.binder.trigger('paper.ready'); - }); +// Central drawing base export +const bindID = 'drawing.base'; - // Clear preview canvas on cancel/clear. - cncserver.binder.bindTo('buffer.clear', base.id, () => { - base.layers.preview.removeChildren(); - cncserver.sockets.sendPaperUpdate('preview'); +export const layers = {}; +export const workspace = {}; +export const state = { + size: {}, + project: {}, +}; - // TODO: We likely don't want to do this here, but it helps for now. - base.layers.stage.removeChildren(); - cncserver.sockets.sendPaperUpdate('stage'); +bindTo('schemas.loaded', bindID, () => { + // Setup the project with the max cavas size in mm. + state.size = new Size(bot.maxAreaMM.width, bot.maxAreaMM.height); + + // Setup the actual printable work space as a rectangle. + workspace.left = bot.workAreaMM.left; + workspace.top = bot.workAreaMM.top; + workspace.bottom = bot.workAreaMM.bottom; + workspace.right = bot.workAreaMM.right; + + state.project = new Project(state.size); + + // Setup layers: + // Whatever the last layer added was, will be default. + const createLayers = [ + 'import', // Raw content, cleared on each import. + 'temp', // Temporary working space, cleared before each operation. + 'stage', // Project imported groups of items. + 'tools', // Helper visualization of tool positions. + 'preview', // Render destination, item groups of lines w/color data only (no fills). + 'print', // Final print source, grouped by colorset work groupings. + ]; + createLayers.forEach(name => { + layers[name] = new Layer({ name }); }); - // Get a list of all simple paths from all children as an array. - base.getPaths = (parent = base.layers.preview, items = []) => { - if (parent.children && parent.children.length && !(parent instanceof CompoundPath)) { - let moreItems = []; - parent.children.forEach((child) => { - moreItems = base.getPaths(child, moreItems); - }); - return [...items, ...moreItems]; - } - return [...items, parent]; - }; - - // Just the object for the rectangle (for JSON). - base.defaultBoundsRaw = (margin = 10) => ({ + // Trigger paper ready. + trigger('paper.ready', null, true); +}); + +// Clear preview canvas on cancel/clear. +bindTo('buffer.clear', bindID, () => { + layers.preview.removeChildren(); + sendPaperUpdate('preview'); + + // TODO: We likely don't want to do this here, but it helps for now. + layers.stage.removeChildren(); + sendPaperUpdate('stage'); +}); + +// Get a list of all simple paths from all children as an array. +export function getPaths(parent = layers.preview, items = []) { + if (parent.children) { + let moreItems = []; + parent.children.forEach(child => { + moreItems = getPaths(child, moreItems); + }); + return [...items, ...moreItems]; + } + return [...items, parent]; +} + +// Just the object for the rectangle (for JSON). +export function defaultBoundsRaw(margin = 10) { + return { x: margin, y: margin, - width: base.workspace.width - margin * 2, - height: base.workspace.height - margin * 2, - }); - - // Get a default bound for high level drawings. - base.defaultBounds = margin => new Rectangle(base.defaultBoundsRaw(margin)); - - // Get the snapped stroke color ID of an item through its parentage. - base.getColorID = (item) => { - if (item.data.colorID) return item.data.colorID; - if (item.parent) return base.getColorID(item.parent); - return null; - }; - - // Offset any passed bounds to fit within the workspace. - base.fitToWorkspace = (bounds) => { - const adjBounds = bounds.clone(); - - // No negative bounds positions. - if (adjBounds.point.x < 0) adjBounds.point.x = 0; - if (adjBounds.point.y < 0) adjBounds.point.y = 0; - - // Offset for top/left workspaces. - adjBounds.point = adjBounds.point.add(base.workspace); - - // Keep width/height from overflowing. - if (adjBounds.right > base.workspace.right) { - // console.log('Too far right!', adjBounds, adjBounds.right - base.workspace.right); - adjBounds.width -= adjBounds.right - base.workspace.right; - } - - if (adjBounds.bottom > base.workspace.bottom) { - adjBounds.height -= adjBounds.bottom - base.workspace.bottom; - // console.log('Too far down!', adjBounds); - } - - return adjBounds; + width: workspace.width - margin * 2, + height: workspace.height - margin * 2, }; - - // Verify a set of given bounds. - base.validateBounds = (rawBounds) => { - let bounds = rawBounds; - - if (!bounds) { - bounds = base.defaultBounds(); - } else if (!(bounds instanceof Rectangle)) { - bounds = new Rectangle(bounds); - } - - return base.fitToWorkspace(bounds); - }; - - // Fit the given item within either the drawing bounds, or custom one. - base.fitBounds = (item, rawBounds) => { - const bounds = base.validateBounds(rawBounds); - item.fitBounds(bounds); - return bounds; - }; - - // Get the position of item via anchor from relative offset EG {x:0, y:-1} - base.getAnchorPos = (item, anchor, relative = true) => { - const { bounds } = item; - const halfW = bounds.width / 2; - const halfH = bounds.height / 2; - let center = { x: 0, y: 0 }; - - if (!relative) { - center = bounds.center; - } - - return new Point({ - x: center.x + (halfW * anchor.x), - y: center.y + (halfH * anchor.y), +} + +// Get a default bound for high level drawings. +export const defaultBounds = margin => new Rectangle(defaultBoundsRaw(margin)); + +// Get the snapped stroke color ID of an item through its parentage. +export function getColorID(item) { + if (item.data.colorID) return item.data.colorID; + if (item.parent) return getColorID(item.parent); + return null; +} + +// Offset any passed bounds to fit within the workspace. +export function fitToWorkspace(bounds) { + const adjBounds = bounds.clone(); + + // No negative bounds positions. + if (adjBounds.point.x < 0) adjBounds.point.x = 0; + if (adjBounds.point.y < 0) adjBounds.point.y = 0; + + // Offset for top/left workspaces. + adjBounds.point = adjBounds.point.add(workspace); + + // Keep width/height from overflowing. + if (adjBounds.right > workspace.right) { + // console.log('Too far right!', adjBounds, adjBounds.right - workspace.right); + adjBounds.width -= adjBounds.right - workspace.right; + } + + if (adjBounds.bottom > workspace.bottom) { + adjBounds.height -= adjBounds.bottom - workspace.bottom; + // console.log('Too far down!', adjBounds); + } + + return adjBounds; +} + +// Verify a set of given bounds. +export function validateBounds(rawBounds) { + let bounds = rawBounds; + + if (!bounds) { + bounds = defaultBounds(); + } else if (!(bounds instanceof Rectangle)) { + bounds = new Rectangle(bounds); + } + + return fitToWorkspace(bounds); +} + +// Fit the given item within either the drawing bounds, or custom one. +export function fitBounds(item, rawBounds) { + const bounds = validateBounds(rawBounds); + item.fitBounds(bounds); + return bounds; +} + +// Get the position of item via anchor from relative offset EG {x:0, y:-1} +export function getAnchorPos(item, anchor, relative = true) { + const { bounds } = item; + const halfW = bounds.width / 2; + const halfH = bounds.height / 2; + let center = { x: 0, y: 0 }; + + if (!relative) { + center = bounds.center; + } + + return new Point({ + x: center.x + (halfW * anchor.x), + y: center.y + (halfH * anchor.y), + }); +} + +// Set the position of item via anchor from relative offset EG {x:0, y:-1} +export function setPosFromAnchor(item, position, anchor) { + const offset = getAnchorPos(item, anchor); + + // eslint-disable-next-line no-param-reassign + item.position = position.subtract(offset); +} + +// Return true if the layer contains any groups at the top level +export function layerContainsGroups(layer = state.project.activeLayer) { + for (const i in layer.children) { + if (layer.children[i] instanceof Group) return true; + } + return false; +} + +// Ungroup any groups recursively +export function ungroupAllGroups(layer = state.project.activeLayer) { + // Remove all groups + while (layerContainsGroups(layer)) { + layer.children.forEach(path => { + if (path instanceof Group) { + path.parent.insertChildren(0, path.removeChildren()); + path.remove(); + } }); - }; - - // Set the position of item via anchor from relative offset EG {x:0, y:-1} - base.setPosFromAnchor = (item, position, anchor) => { - const offset = base.getAnchorPos(item, anchor); - - item.position = position.subtract(offset); - }; - - // Return true if the layer contains any groups at the top level - base.layerContainsGroups = (layer = base.project.activeLayer) => { - for (const i in layer.children) { - if (layer.children[i] instanceof Group) return true; - } - return false; - }; - - // Ungroup any groups recursively - base.ungroupAllGroups = (layer = base.project.activeLayer) => { - // Remove all groups - while (base.layerContainsGroups(layer)) { - layer.children.forEach((path) => { - if (path instanceof Group) { - path.parent.insertChildren(0, path.removeChildren()); - path.remove(); - } - }); - } - }; - - // Standardize path names to ensure everything has one. - base.setName = (item) => { - // eslint-disable-next-line no-param-reassign - item.name = item.name || `draw_path_${item.id}`; - return item; - }; - - // Simple helper to check if the path is one of the parsable types. - base.isDrawable = item => item instanceof Path || item instanceof CompoundPath; - - // Normalize a 'd' string, JSON or path input into a compound path. - base.normalizeCompoundPath = (importPath) => { - let path = importPath; - - // Attempt to detect 'd' string or JSON import. - if (typeof path === 'string') { - if (path.includes('{')) { - // If this fails the paper error will bubble up to the implementor. - path = cncserver.drawing.base.layers.temp.importJSON(path); + } +} + +// SVG content can have paths with NO fill or strokes, they're assumed to be black fill. +export function validateFills(item) { + item.children.forEach(child => { + if (!child.fillColor && !child.strokeColor) { + if (child.children && child.children.length) { + validateFills(child); } else { - // D string, create the compound path directly - return base.setName(new CompoundPath(path)); + // TODO: This likely needs more rules for cleanup. + child.fillColor = 'black'; } } - - // If we don't have a path at this point we failed to import the JSON. - if (!path || !path.length) { - throw new Error('Invalid path source, verify input content and try again.'); - } - - // If the passed object already is compounnd, return it directly. - if (path instanceof CompoundPath) { - return base.setName(path); + }); +} + +// Standardize path names to ensure everything has one. +export function setName(item) { + // eslint-disable-next-line no-param-reassign + item.name = item.name || `draw_path_${item.id}`; + return item; +} + +// Simple helper to check if the path is one of the parsable types. +export const isDrawable = item => item instanceof Path || item instanceof CompoundPath; + +// Normalize a 'd' string, JSON or path input into a compound path. +export function normalizeCompoundPath(importPath) { + let path = importPath; + + // Attempt to detect 'd' string or JSON import. + if (typeof path === 'string') { + if (path.includes('{')) { + // If this fails the paper error will bubble up to the implementor. + path = layers.temp.importJSON(path); + } else { + // D string, create the compound path directly + return setName(new CompoundPath(path)); } - - // Standard path, create a compound path from it. - return base.setName(new CompoundPath({ - children: [path], - fillColor: path.fillColor, - strokeColor: path.strokeColor, - })); - }; - - // Check if a fill/stroke color is "real". - // Returns True if not null or fully transparent. - base.hasColor = (color) => { - if (!color) { - return false; + } + + // If we don't have a path at this point we failed to import the JSON. + if (!path || !path.length) { + throw new Error('Invalid path source, verify input content and try again.'); + } + + // If the passed object already is compounnd, return it directly. + if (path instanceof CompoundPath) { + return setName(path); + } + + // Standard path, create a compound path from it. + return setName(new CompoundPath({ + children: [path], + fillColor: path.fillColor, + strokeColor: path.strokeColor, + })); +} + +// Check if a fill/stroke color is "real". +// Returns True if not null or fully transparent. +export function hasColor(color) { + if (!color) { + return false; + } + return color.alpha !== 0; +} + +// At this point, simply removing anything that isn't a path. +export function removeNonPaths(layer = state.project.activeLayer) { + // If you modify the child list, you MUST operate on a COPY + const kids = [...layer.children]; + kids.forEach(path => { + if (!isDrawable(path)) { + path.remove(); + } else if (!path.length) { + path.remove(); + } else { + setName(path); } - return color.alpha !== 0; - }; - - // Takes an item with children, and cleans up all the first level children to - // verify they have either: Stroke with no fill, stroke with fill (closed), - // fill only. Removes or fixes all paths that don't fit here. - base.cleanupInput = (layer, settings) => { - const { trace: fillStroke } = settings.fill; - const { hasColor } = base; - base.removeNonPaths(layer); - - const kids = [...layer.children]; - kids.forEach((item) => { - const { name } = item; - - // Maybe a fill? - if (hasColor(item.fillColor)) { - // console.log('Fill', item.name, item.fillColor.toCSS()); - item.closed = true; - item.fillColor.alpha = 1; - - // Has a stroke? - if (hasColor(item.strokeColor) || item.strokeWidth) { - if (!hasColor(item.strokeColor)) { - item.strokeColor = item.fillColor || settings.path.fillColor; - item.strokeColor.alpha = 1; - } - if (!item.strokeWidth) { - item.strokeWidth = 1; - } - } else if (fillStroke) { - // Add a stroke to fills if requested. - // console.log('Adding stroke to fill', item.name, item.fillColor.toCSS()); - item.strokeColor = item.fillColor; - item.strokeWidth = 1; - } - } else if (hasColor(item.strokeColor) || item.strokeWidth) { + }); +} + +// Takes an item with children, and cleans up all the first level children to +// verify they have either: Stroke with no fill, stroke with fill (closed), +// fill only. Removes or fixes all paths that don't fit here. +export function cleanupInput(layer, settings) { + const { trace: fillStroke } = settings.fill; + removeNonPaths(layer); + + const kids = [...layer.children]; + kids.forEach(item => { + // const { name } = item; + + // Maybe a fill? + if (hasColor(item.fillColor)) { + // console.log('Fill', item.name, item.fillColor.toCSS()); + item.closed = true; + item.fillColor.alpha = 1; + + // Has a stroke? + if (hasColor(item.strokeColor) || item.strokeWidth) { if (!hasColor(item.strokeColor)) { item.strokeColor = item.fillColor || settings.path.fillColor; - } else { item.strokeColor.alpha = 1; } - if (!item.strokeWidth) { item.strokeWidth = 1; } - } else { - console.log('Removing No fill/stroke', item.name); - item.remove(); + } else if (fillStroke) { + // Add a stroke to fills if requested. + // console.log('Adding stroke to fill', item.name, item.fillColor.toCSS()); + item.strokeColor = item.fillColor; + item.strokeWidth = 1; } - }); - }; - - base.setPathOption = (path, options) => { - Object.entries(options).forEach(([key, value]) => { - path[key] = value; - }); - }; - - // Prepare a layer to remove anything that isn't a fill. - base.cleanupFills = (tmp) => { - const kids = [...tmp.children]; - kids.forEach((path) => { - if (!base.hasColor(path.fillColor)) { - path.remove(); + } else if (hasColor(item.strokeColor) || item.strokeWidth) { + if (!hasColor(item.strokeColor)) { + item.strokeColor = item.fillColor || settings.path.fillColor; } else { - // Bulk set path options. - base.setPathOption(path, { - closed: true, - strokeWidth: 0, - strokeColor: null, - }); + item.strokeColor.alpha = 1; } - }); - }; - // At this point, simply removing anything that isn't a path. - base.removeNonPaths = (layer = base.project.activeLayer) => { - // If you modify the child list, you MUST operate on a COPY - const kids = [...layer.children]; - kids.forEach((path) => { - if (!base.isDrawable(path)) { - path.remove(); - } else if (!path.length) { - path.remove(); - } else { - base.setName(path); + if (!item.strokeWidth) { + item.strokeWidth = 1; } - }); - }; - + } else { + console.log('Removing No fill/stroke', item.name); + item.remove(); + } + }); +} - return base; -}; +export function setPathOption(path, options) { + Object.entries(options).forEach(([key, value]) => { + path[key] = value; + }); +} + +// Prepare a layer to remove anything that isn't a fill. +export function cleanupFills(tmp) { + const kids = [...tmp.children]; + kids.forEach(path => { + if (!hasColor(path.fillColor)) { + path.remove(); + } else { + // Bulk set path options. + setPathOption(path, { + closed: true, + strokeWidth: 0, + strokeColor: null, + }); + } + }); +} diff --git a/src/components/core/drawing/cncserver.drawing.colors.js b/src/components/core/drawing/cncserver.drawing.colors.js index 20251e14..383540d6 100644 --- a/src/components/core/drawing/cncserver.drawing.colors.js +++ b/src/components/core/drawing/cncserver.drawing.colors.js @@ -1,247 +1,391 @@ /** - * @file Code for drawing color and "tool" change management. - */ -const glob = require('glob'); -const path = require('path'); -const nc = require('nearest-color'); -const { Color } = require('paper'); - -const defaultColor = { id: 'color0', name: 'Black', color: '#000000' }; -const ignoreWhite = { id: 'ignore', name: 'White', color: '#FFFFFF' }; -const defaultPreset = { - manufacturer: 'default', - media: 'pen', - machineName: 'default', - weight: -10, - colors: { black: '#000000' }, +* @file Code for drawing color and "tool" change management. +*/ + +// Paper.js is all about that parameter reassignment life. +/* eslint-disable no-param-reassign */ + +import Paper from 'paper'; +import chroma from 'chroma-js'; +import { get as getImplement, IMPLEMENT_PARENT } from 'cs/drawing/implements'; +import * as utils from 'cs/utils'; +import * as tools from 'cs/tools'; +import * as projects from 'cs/projects'; +import * as matcher from 'cs/drawing/colors/matcher'; +import { layers } from 'cs/drawing/base'; +import { bindTo, trigger } from 'cs/binder'; +import { getDataDefault } from 'cs/schemas'; +import { sendPaperUpdate } from 'cs/sockets'; + +const { Color, Group } = Paper; + +const DEFAULT_PRESET = 'default-single-pen'; +const IGNORE_ITEM = '[IGNORE]'; +const bindID = 'drawing.colors'; + +export const set = { // Set via initial preset or colorset loader. + name: '', // Machine name for loading/folder storage + title: '', // Clean name. + description: '', // Description of what it is beyond title. + implement: '', // Implement preset string. + items: new Map(), // Items mapped by id. + toolset: '', // Extra Toolset by machine name. }; -const colors = { id: 'drawing.colors', presets: { default: defaultPreset }, set: [] }; -const presetDir = path.resolve( - global.__basedir, 'components', 'core', 'drawing', 'colorsets' -); - -module.exports = (cncserver, drawing) => { - // What is this and why? - // - // When we draw, we assume a color: color0. It's assumed this is "black", but - // if there's only one color in use, no color switching will occur so this - // definition is moot unless we have more than one color in our set. - // - // A "colorset" is a set of colors that can be applied to the available colors - // in `cncserver.drawing.colors.set` - - // Load all color presets into the presets key. - const files = glob.sync(path.join(presetDir, '*.json')); - files.forEach((setPath) => { - try { - // eslint-disable-next-line global-require, import/no-dynamic-require - const sets = require(setPath); - sets.forEach((set) => { - const key = `${set.manufacturer}-${set.media}-${set.machineName}`; - colors.presets[key] = set; - }); - } catch (error) { - console.error(`Problem loading color preset: '${setPath}'`); - } - }); - // Function to render presets for the API, translating human readable strings. - colors.listPresets = (t) => { - const out = {}; - Object.entries(colors.presets).forEach(([key, p]) => { - const basekey = `colorsets:${p.manufacturer}.${p.machineName}`; - out[key] = { - manufacturer: p.manufacturer, - media: p.media, - machineName: p.machineName, - manufacturerName: t(`${basekey}.manufacturer`), - name: t(`${basekey}.name`), - description: t(`${basekey}.description`), - mediaName: t(`colorsets:media.${p.media}`), - colors: {}, - }; - - Object.entries(p.colors).forEach(([id, color]) => { - out[key].colors[id] = { - color, - name: t(`colorsets:colors.${id}`), - }; - }); - }); +// Setup matcher Chroma library, colorset and project settings. +matcher.setup({ chroma }); - return out; - }; +bindTo('colors.update', bindID, colorset => { + matcher.setup({ colorset }); +}); - colors.getIDs = () => { - const items = []; - colors.set.forEach(({ id }) => { - items.push(id); - }); - return items; - }; +bindTo('projects.update', bindID, ({ options }) => { + matcher.setup({ options }); +}); - colors.getIndex = (findID) => { - let findIndex = null; - colors.set.forEach(({ id }, index) => { - if (id === findID) { - findIndex = index; - } +// Fully translate a given non-map based colorset. +export function translateSet(inputSet, t = tx => tx) { + let transSet = null; + if (inputSet) { + transSet = { ...inputSet }; + transSet.title = t(transSet.title); + transSet.description = t(transSet.description); + transSet.manufacturer = t(transSet.manufacturer); + + transSet.items.forEach(item => { + item.name = t(item.name); }); - return findIndex; - }; + } - colors.delete = ({ id }) => { - const index = colors.getIndex(id); - colors.set.splice(index, 1); + return transSet; +} - if (colors.set.length === 0) { - colors.set.push(defaultColor); - } - cncserver.sockets.sendPaperUpdate(); - }; +// Render presets for the API, translating human readable strings. +export function listPresets(t) { + const sets = Object.values(utils.getPresets('colorsets')); + const sorted = sets.sort((a, b) => (a.sortWeight > b.sortWeight ? 1 : -1)); + const out = {}; - colors.add = ({ id, name, color }) => { - if (colors.getIndex(id) === null) { - colors.set.push({ id, name, color }); - cncserver.sockets.sendPaperUpdate(); - return true; - } - return null; - }; + // Translate strings. + sorted.forEach(sortSet => { + out[sortSet.name] = translateSet(sortSet, t); + }); - colors.update = (id, { name, color }) => { - const index = colors.getIndex(id); - colors.set[index] = { id, name, color }; - cncserver.sockets.sendPaperUpdate(); - return colors.set[index]; - }; + return out; +} - colors.getColor = (findID) => { - let color = colors.getIndex(findID); - if (color !== null) color = colors.set[color]; - return color; - }; +// List custom/overridden machine names. +export function customKeys() { + return Object.keys(utils.getCustomPresets('colorsets')); +} - /** - * Mutate the set array to match a preset by machine name. - * - * @param {string} presetName - * - * @returns {boolean} - * Null for failure, true if success. - */ - colors.applyPreset = (presetName) => { - const preset = colors.setFromPreset(presetName); - if (preset) { - colors.set = preset; - cncserver.sockets.sendPaperUpdate(); - return true; - } - return null; - }; +// List internal machine names. +export function internalKeys() { + return Object.keys(utils.getInternalPresets('colorsets')); +} - /** - * Get colorset array from a preset name. - * - * @param {string} presetName - * - * @returns {array} - * Colorset style array with default toolnames - */ - colors.setFromPreset = (presetName) => { - if (colors.presets[presetName]) { - const set = []; - Object.entries(colors.presets[presetName].colors).forEach(([name, color]) => { - set.push({ - id: `color${set.length}`, - name, // cncserver.i18n.t(`colorsets:colors.${name}`), - color, - }); - }); - - // TODO: Allow this to be set somewhere? - set.push({ - ...ignoreWhite, - // name: cncserver.i18n.t('colorsets:colors.white'), - }); - return set; +// Object of preset keys with colorset toolset tool parents unavailable to this bot. +export function invalidPresets() { + const out = {}; + const sets = listPresets(); + const invalidToolsets = tools.invalidPresets(); + + // Move through all sets, report invalid toolsets + Object.entries(sets).forEach(([name, { toolset }]) => { + if (toolset in invalidToolsets) { + out[name] = invalidToolsets[toolset]; } + }); - return null; - }; + return out; +} - /** - * Run at setup, allows machine specific colorset defaults. - */ - colors.setDefault = () => { - const defaultSet = cncserver.binder.trigger('colors.setDefault', [defaultColor, ignoreWhite]); - colors.set = defaultSet; +// Take in a colorset and convert the items/tools to a map +export function setAsMap(mapSet = {}) { + return { + ...mapSet, + items: utils.arrayToIDMap(mapSet.items), }; +} - // Bind to when bot/controller is configured and setup, set default. - cncserver.binder.bindTo('controller.setup', colors.id, () => { - colors.setDefault(); +// Return a flat array version of the set. +export function setAsArray(arrSet = set) { + return getDataDefault('colors', { + ...arrSet, + items: Array.from(arrSet.items.values()), }); +} + +// Save custom from set. +export function saveCustom() { + const arrColorSet = setAsArray(); + trigger('colors.update', arrColorSet); + utils.savePreset('colorsets', arrColorSet); +} + +/** + * Get a flat list of valid colorset key ids. + * + * @returns {array} + * Array of colorset item keys, empty array if none. + */ +export const getIDs = () => Array.from(set.items.keys()); + +// Make changes to the colorset object. +export const editSet = changedSet => new Promise(resolve => { + delete changedSet.items; + + // Enforce clean machine name. + changedSet.name = utils.getMachineName(changedSet.name, 64); + + utils.applyObjectTo(changedSet, set); + saveCustom(); + resolve(); +}); + +// Load and return a correctly mapped colorset from a preset name. +// Pass translate function to rewrite translatable fields. +export function getPreset(presetName, t) { + const getSet = translateSet(utils.getPreset('colorsets', presetName), t); + return getSet ? setAsMap(getSet) : getSet; +} + +// Edit an item. +export const edit = item => new Promise(resolve => { + const { id } = item; + set.items.set(id, item); + sendPaperUpdate(); + saveCustom(); + resolve(set.items.get(id)); +}); + +// Delete a color by id. +export function deleteColor(id) { + set.items.delete(id); + + if (set.items.size === 0) { + // Load in the default 1 color preset. + const color = getPreset(DEFAULT_PRESET).items.get('color0'); + set.items.set(color.id, color); + } + sendPaperUpdate(); + saveCustom(); +} + +// Add a color by all of its info, appended to the end. +export const add = ({ id, ...item }) => new Promise((resolve, reject) => { + id = utils.getMachineName(id, 64); + if (!getColor(id)) { + set.items.set(id, { id, ...item }); + sendPaperUpdate(); + saveCustom(); + resolve(); + } else { + reject( + new Error(`Color with id "${id}" already exists, update it directly or change id`) + ); + } +}); + +// Get a renderable color from a tool name. 'transparent' if no match. +export function getToolColor(name) { + const item = set.items.get(name); + return item ? item.color : 'transparent'; +} + +// Get the full implement object for a given colorset item id. +export function getItemImplement(id) { + const item = set.items.get(id); + const preset = item.implement === IMPLEMENT_PARENT + ? set.implement : item.implement; + return getImplement(preset); +} + +// Get non-reference copy of colorset item by id. +export function getColor(id, applyInheritance = false, withImplement = false) { + const rawItem = set.items.get(id); + let item = null; - // Figure out if we should even parse color work - colors.doColorParsing = () => { - const items = {}; - colors.set.forEach((item) => { - if (item.id !== 'ignore') { - items[item.id] = true; + if (rawItem) { + item = { ...rawItem }; + + // If the implementor wants it, and the item wants inheritance... + if (item.implement === IMPLEMENT_PARENT) { + if (applyInheritance) { + item.implement = withImplement ? getImplement(set.implement) : set.implement; } - }); - return Object.keys(items).length > 1; - }; + } else if (withImplement) { + item.implement = getImplement(item.implement); + } + } + + return item; +} + +/** + * Mutate the set object to match a preset by machine name. + * + * @param {string} presetName + * + * @returns {Promise} + * Rejects on failure, resolves on success (no return value). + */ +export const applyPreset = (presetName, t) => new Promise((resolve, reject) => { + const newSet = getPreset(presetName); + if (newSet?.items) { + utils.applyObjectTo(newSet, set); + tools.sendUpdate(); + trigger('colors.update', setAsArray()); + projects.setColorset(presetName); + resolve(); + } else { + const err = new Error( + utils.singleLineString`Colorset preset with machine name ID '${presetName}' not + found or failed to load.` + ); + err.allowedValues = Object.keys(utils.getPresets('colorsets')); + reject(err); + } +}); + +// Get the current colorset as a JSON ready object. +export const getCurrentSet = t => translateSet(setAsArray(), t); + +/** + * Run at setup, allows machine specific colorset defaults. + */ +export function setDefault() { + // Trigger on schema loaded for schema & validation defaults. + bindTo('schemas.loaded', bindID, () => { + let defaultSet = utils.getPreset('colorsets', 'default-single-pen'); + defaultSet = trigger('colors.setDefault', defaultSet); + utils.applyObjectTo(setAsMap(defaultSet), set); + trigger('colors.update', defaultSet); + trigger('tools.update'); + }); +} + +// Bind to when bot/controller is configured and setup, set default. +bindTo('controller.setup', bindID, () => { + setDefault(); +}); + +// Figure out if we should even parse color work +// TODO: this kinda sucks. +export function doColorParsing() { + const items = {}; + set.items.forEach(item => { + if (item.id !== 'ignore') { + items[item.id] = true; + } + }); + return Object.keys(items).length > 1; +} - // Get a luminosity sorted list of colors. - colors.getSortedSet = () => colors.set.sort( - (a, b) => new Color(b.color).gray - new Color(a.color).gray +// Apply luminosity sorting (for presets without weighting info). +export function applyDefaultPrintSorting(fromSet) { + const luminositySorted = Array + .from(fromSet.items.values()) + .sort((a, b) => new Color(b.color).gray - new Color(a.color).gray); + + let weight = -15; + luminositySorted.forEach(({ id }) => { + weight += 3; + fromSet.items.get(id).printWeight = weight; + }); +} + +// Get the print weight sorted list of colors. +export function getSortedSet(baseSet = set) { + return Array.from(baseSet.items.values()).sort( + (a, b) => b.printWeight - a.printWeight ); +} - // Get an object keyed by color work ID, ordered by luminosity light->dark. - colors.getWorkGroups = () => { - const groups = {}; - const sorted = colors.getSortedSet(); +// Get an object keyed by color work ID, ordered by print weight of empty arrays. +export function getWorkGroups() { + const groups = {}; + const sorted = getSortedSet().reverse(); - sorted.forEach((color) => { - if (color.id !== 'ignore') { - groups[color.id] = []; - } - }); - return groups; - }; + sorted.forEach(({ id }) => { + if (id !== IGNORE_ITEM) { + groups[id] = []; + } + }); + return groups; +} - /** - * Snap all the paths in the given layer to a particular color. - * - * @param {*} layer - */ - colors.snapPathColors = (layer) => { - // Build Nearest Color matcher - const c = {}; - colors.set.forEach(({ id, color }) => { - c[id] = color; - }); +// Apply preview styles to an item. +export function applyPreview(item, color) { + // If item matched to "ignore", hide it. + if (color.id === IGNORE_ITEM) { + item.strokeWidth = 0; + } else { + // Match colorset item effective implement size. + item.strokeWidth = color.implement.width; + + // Save/set new color and matched ID. + // IMPORTANT: This is how tool set swaps are rendered. + // TODO: Set swaps from print groupings. + // item.data.colorID = colorID; + item.strokeColor = color.color; - const nearestColor = nc.from(c); - layer.children.forEach((group) => { - group.children.forEach((path) => { - if (path.strokeColor) { - // If we've never touched this path before, save the original color. - if (!path.data.originalColor) { - path.data.originalColor = path.strokeColor; - } + // Assume less than full opacity with brush/watercolor paintings. + item.opacity = color.implement.type === 'brush' ? 0.8 : 1; - // Find nearest color. - const nearest = nearestColor(path.data.originalColor.toCSS(true)); + // Prevent sharp corners messing up render. + item.strokeCap = 'round'; + item.strokeJoin = 'round'; + } +} - path.data.colorID = nearest.name; - path.strokeColor = nearest.value; +/** + * Snap all the paths in the given layer to a particular colorset item. + * + * @param {paper.Layer} layer + */ +export function snapPathsToColorset(layer) { + // This gets called every time there's an update to the render "preview" layer. + // - Layer children is a list of content Group() items by hash + // - Each group contains all the paths + + // To snap all the paths to a color, we should move through each path + // Priorty? + // - Line color + // - Line thickness (not transferred yet, need to add) + // - Line Transparency (not transferred, need to add) + + // Remove everything on print, rebuild it from here. + layers.print.removeChildren(); + + // Setup all the destination groups within layers. + const sorted = getSortedSet().reverse(); + const printGroups = {}; // ID keyed set of Paper Groups. + const colorsetItems = {}; // Static cache of items in groups. + sorted.forEach(({ id }) => { + printGroups[id] = new Group({ name: id }); + layers.print.addChild(printGroups[id]); + colorsetItems[id] = getColor(id, true, true); + }); + + console.log('MATCHING ITEMS ====================================================='); + + // Move through all preview groups, then all items within them. + layer.children.forEach(group => { + group.children.forEach(item => { + if (item.strokeColor) { + // If we've never touched this path before, save the original color. + if (!item.data.originalColor) { + item.data.originalColor = item.strokeColor; } - }); - }); - }; - return colors; -}; + // Find nearest color. + const nearestID = matcher.matchItemToColor(item); + if (nearestID !== IGNORE_ITEM) { + applyPreview(item, colorsetItems[nearestID]); + printGroups[nearestID].addChild(item.clone()); + } + } + }); + }); +} diff --git a/src/components/core/drawing/cncserver.drawing.fill.js b/src/components/core/drawing/cncserver.drawing.fill.js index 1df52247..722c658a 100644 --- a/src/components/core/drawing/cncserver.drawing.fill.js +++ b/src/components/core/drawing/cncserver.drawing.fill.js @@ -1,8 +1,27 @@ /** * @file Code for drawing fill management. */ -module.exports = (cncserver, drawing) => { - const fill = (path, hash, bounds = null, settings, subIndex) => new Promise((success, error) => { +import path from 'path'; +import { fitBounds } from 'cs/drawing/base'; +import spawner from 'cs/drawing/spawner'; +import { addRenderJSON } from 'cs/drawing/preview'; +import { __basedir } from 'cs/utils'; + +export default function fill( + fillPath, hash, bounds = null, settings, subIndex +) { + return new Promise((success, error) => { + const { method } = settings; + const script = path.resolve( + __basedir, + 'components', + 'core', + 'drawing', + 'fillers', + method, + `cncserver.drawing.fillers.${method}.js` + ); + // Add in computed settings values here. if (settings.randomizeRotation) { settings.rotation = Math.round(Math.random() * 360); @@ -10,32 +29,24 @@ module.exports = (cncserver, drawing) => { // TODO: Should we fitbounds here? Or earlier? if (bounds) { - drawing.base.fitBounds(path, bounds); + fitBounds(fillPath, bounds); } - const { method } = settings; - const script = `${__dirname}/fillers/${method}/cncserver.drawing.fillers.${method}.js`; - // Use spawner to run fill process. - drawing - .spawner({ - type: 'filler', - hash, - script, - settings, - object: path.exportJSON(), - subIndex, - }) - .then((result) => { - drawing.preview.addRenderJSON(result, hash, { - fillColor: null, - strokeWidth: 1, - strokeColor: path.fillColor, - }); - success(); - }) - .catch(error); + spawner({ + type: 'filler', + hash, + script, + settings, + object: fillPath.exportJSON(), + subIndex, + }).then(result => { + addRenderJSON(result, hash, { + fillColor: null, + strokeWidth: 1, + strokeColor: fillPath.fillColor, + }); + success(); + }).catch(error); }); - - return fill; -}; +} diff --git a/src/components/core/drawing/cncserver.drawing.implements.js b/src/components/core/drawing/cncserver.drawing.implements.js new file mode 100644 index 00000000..aade2e4e --- /dev/null +++ b/src/components/core/drawing/cncserver.drawing.implements.js @@ -0,0 +1,63 @@ +/** +* @file Code for drawing implement presets and management. +*/ +import * as utils from 'cs/utils'; + +// Inherit tag. +export const IMPLEMENT_PARENT = '[inherit]'; + +// List Existing Presets. +export function listPresets(t, customOnly) { + const sets = customOnly + ? utils.getCustomPresets('implements') + : utils.getPresets('implements'); + const items = Object.values(sets); + const sorted = items.sort((a, b) => (a.sortWeight > b.sortWeight ? 1 : -1)); + const out = {}; + + // TODO: Translate implement titles. + sorted.forEach(item => { + out[item.name] = item; + }); + return out; +} + +// List custom/overridden machine names. +export function customKeys() { + return Object.keys(utils.getCustomPresets('implements')); +} + +// List internal machine names. +export function internalKeys() { + return Object.keys(utils.getInternalPresets('implements')); +} + +// Get a single preset directly from the files. +export function get(name, customOnly) { + return utils.getPreset('implements', name, customOnly); +} + +// Edit an implement. +export const edit = item => new Promise(resolve => { + utils.savePreset('implements', item); + resolve(item); +}); + +// Add a new custom override implement. +export const add = item => new Promise((resolve, reject) => { + item.name = utils.getMachineName(item.name, 64); + if (!get(item.name, true)) { + utils.savePreset('implements', item); + resolve(); + } else { + reject( + new Error(utils.singleLineString`Custom implement with name "${item.name}" + already exists, update it directly or change new item name.`) + ); + } +}); + +// Remove a custom preset. +export function deleteImplement(name) { + utils.deletePreset('implements', name); +} diff --git a/src/components/core/drawing/cncserver.drawing.occlusion.js b/src/components/core/drawing/cncserver.drawing.occlusion.js index c1d10e52..5259f117 100644 --- a/src/components/core/drawing/cncserver.drawing.occlusion.js +++ b/src/components/core/drawing/cncserver.drawing.occlusion.js @@ -1,44 +1,45 @@ /** * @file Code for drawing occlusion path segmentation management. */ -// const { Path, Rectangle } = require('paper'); -module.exports = (cncserver, drawing) => { - const occlusion = (type, layer = drawing.base.project.activeLayer) => { - // console.log('Layer children:', layer.children); - for (let srcIndex = 0; srcIndex < layer.children.length; srcIndex++) { - let srcPath = layer.children[srcIndex]; - if (type === 'fill' && !srcPath.closed) { +import { state } from 'cs/drawing/base'; + +export default function occlusion(type, layer = state.project.activeLayer) { + // console.log('Layer children:', layer.children); + for (let srcIndex = 0; srcIndex < layer.children.length; srcIndex++) { + let srcPath = layer.children[srcIndex]; + if (type === 'fill' && !srcPath.closed) { + // eslint-disable-next-line no-continue + continue; + } + + srcPath.data.processed = true; + + // Replace this path with a subtract for every intersecting path, + // starting at the current index (lower paths don't subtract from + // higher ones) + const tmpLen = layer.children.length; + for (let destIndex = srcIndex; destIndex < tmpLen; destIndex++) { + const destPath = layer.children[destIndex]; + if (!destPath.closed) { // eslint-disable-next-line no-continue continue; } + if (destIndex !== srcIndex) { + const tmpPath = srcPath; // Hold onto the original path - srcPath.data.processed = true; - - // Replace this path with a subtract for every intersecting path, - // starting at the current index (lower paths don't subtract from - // higher ones) - const tmpLen = layer.children.length; - for (let destIndex = srcIndex; destIndex < tmpLen; destIndex++) { - const destPath = layer.children[destIndex]; - if (!destPath.closed) { - // eslint-disable-next-line no-continue - continue; - } - if (destIndex !== srcIndex) { - const tmpPath = srcPath; // Hold onto the original path + // Set the new srcPath to the subtracted one inserted at the + // same index. + // console.log('Source subtract from:', srcPath.name, destPath.name); + srcPath = layer.insertChild(srcIndex, srcPath.subtract(destPath)); - // Set the new srcPath to the subtracted one inserted at the - // same index. - // console.log('Source subtract from:', srcPath.name, destPath.name); - srcPath = layer.insertChild(srcIndex, srcPath.subtract(destPath)); - srcPath.name = tmpPath.name ? tmpPath.name : `auto_path_${srcPath.id}`; - srcPath.data = { ...tmpPath.data }; - tmpPath.remove(); // Remove the old srcPath - } + // TODO: This might not be correct. But we need something liek it or all + // combined open paths end up closed. + srcPath.closed = tmpPath.closed; + srcPath.name = tmpPath.name ? tmpPath.name : `auto_path_${srcPath.id}`; + srcPath.data = { ...tmpPath.data }; + tmpPath.remove(); // Remove the old srcPath } } - }; - - return occlusion; -}; + } +} diff --git a/src/components/core/drawing/cncserver.drawing.preview.js b/src/components/core/drawing/cncserver.drawing.preview.js index 4b1c7f90..ca01c1e9 100644 --- a/src/components/core/drawing/cncserver.drawing.preview.js +++ b/src/components/core/drawing/cncserver.drawing.preview.js @@ -1,56 +1,54 @@ /** * @file Code for drawing preview/render layer management. */ -/* eslint-disable implicit-arrow-linebreak */ -const { Group } = require('paper'); - -const preview = { id: 'drawing.preview' }; - -module.exports = (cncserver, drawing) => { - const { layers } = drawing.base; - - // Clear all items off the preview and update. - preview.clearAll = () => { - layers.preview.removeChildren(); - cncserver.sockets.sendPaperUpdate('preview'); - }; - - // Remove an item from the stage, returns true if it worked. - preview.remove = (hash, sendUpdate = false) => { - if (layers.preview.children[hash]) { - layers.preview.children[hash].remove(); - if (sendUpdate) cncserver.sockets.sendPaperUpdate('preview'); - return true; - } - return false; - }; - - // Get a full preview SVG of the stage layer content. - preview.getPreviewSVG = () => { - const svgContent = layers.stage.exportSVG({ asString: true }); - return cncserver.utils.wrapSVG(svgContent); - }; - - // Assumes clean internally rendered JSON. - // Add the JSON and return the item within the hash group. - preview.addRenderJSON = (json, hash, adjustments = {}) => - preview.addRender(layers.preview.importJSON(json), hash, adjustments); - - // Import rendered into the hash group. - preview.addRender = (importItem, hash, adjustments = {}) => { - layers.preview.activate(); - - const renderGroup = layers.preview.children[hash] || new Group({ name: hash }); - renderGroup.addChild(importItem); - - // Apply adjustments to the item before sending final update. - Object.entries(adjustments).forEach(([key, value]) => { importItem[key] = value; }); - - // Send final update for the addition of this item. - cncserver.sockets.sendPaperUpdate('preview'); - - return importItem; - }; - - return preview; -}; +import Paper from 'paper'; +import { layers } from 'cs/drawing/base'; +import { sendPaperUpdate } from 'cs/sockets'; +import { wrapSVG } from 'cs/utils'; + +const { Group } = Paper; + +// Clear all items off the preview and update. +export function clearAll() { + layers.preview.removeChildren(); + sendPaperUpdate('preview'); +} + +// Remove an item from the preview layer, returns true if it worked. +export function remove(hash, sendUpdate = false) { + if (layers.preview.children[hash]) { + layers.preview.children[hash].remove(); + if (sendUpdate) sendPaperUpdate('preview'); + return true; + } + return false; +} + +// Get a full preview SVG of the preview layer content. +export function getPreviewSVG() { + const svgContent = layers.stage.exportSVG({ asString: true }); + return wrapSVG(svgContent); +} + +// Import rendered content into the hash group. +export function addRender(importItem, hash, adjustments = {}) { + layers.preview.activate(); + + const renderGroup = layers.preview.children[hash] || new Group({ name: hash }); + renderGroup.addChild(importItem); + + // Apply adjustments to the item before sending final update. + // eslint-disable-next-line no-param-reassign + Object.entries(adjustments).forEach(([key, value]) => { importItem[key] = value; }); + + // Send final update for the addition of this item. + sendPaperUpdate('preview'); + + return importItem; +} + +// Assumes clean internally rendered JSON. +// Add the JSON and return the item within the hash group. +export function addRenderJSON(json, hash, adjustments = {}) { + return addRender(layers.preview.importJSON(json), hash, adjustments); +} diff --git a/src/components/core/drawing/cncserver.drawing.spawner.js b/src/components/core/drawing/cncserver.drawing.spawner.js index 1cd1ee59..162b9cc3 100644 --- a/src/components/core/drawing/cncserver.drawing.spawner.js +++ b/src/components/core/drawing/cncserver.drawing.spawner.js @@ -3,138 +3,139 @@ * * Manages secondary processes for work hash based functions. */ -const { spawn } = require('child_process'); // Process spawner. -const ipc = require('node-ipc'); // Inter Process Comms (shared with ). -const path = require('path'); // File System path management. -const fs = require('fs'); +import { spawn } from 'child_process'; // Process spawner. +import ipc from 'node-ipc'; // Inter Process Comms (shared with ). +import path from 'path'; // File System path management. +import fs from 'fs'; +import { state as drawingBase } from 'cs/drawing/base'; +import { bindTo } from 'cs/binder'; // Hold onto paths and settings to be injected, keyed on job hashes & work type. const workingQueue = {}; -module.exports = (cncserver, drawing) => { - // Generate a spawn key using a data object. - function getSpawnKey({ hash, type, subIndex }) { - const spawnKeys = [type, hash]; - if (subIndex || subIndex === 0) spawnKeys.push(subIndex); - - return spawnKeys.join('-'); +// Generate a spawn key using a data object. +function getSpawnKey({ hash, type, subIndex }) { + const spawnKeys = [type, hash]; + if (subIndex || subIndex === 0) spawnKeys.push(subIndex); + + return spawnKeys.join('-'); +} + +// IPC recieve message handler. +const gotMessage = (packet, socket) => { + const { command, data } = packet; + const spawnKey = getSpawnKey(data); + const workingQueueItem = workingQueue[spawnKey]; + + // Catch any non-matching spawn data. This shouldn't happen, but could. + if (!workingQueueItem) { + // TODO: If this happens, need to clear out process source SOMEHOW. + // throw new Error(`Spawn data item mismatch: ${spawnKey}`); + console.error(`Spawn data item mismatch: ${spawnKey}, killing process`); + ipc.server.emit(socket, 'cancel'); + return; } - // IPC recieve message handler. - const gotMessage = (packet, socket) => { - const { command, data } = packet; - const spawnKey = getSpawnKey(data); - const workingQueueItem = workingQueue[spawnKey]; - - // Catch any non-matching spawn data. This shouldn't happen, but could. - if (!workingQueueItem) { - // TODO: If this happens, need to clear out process source SOMEHOW. - // throw new Error(`Spawn data item mismatch: ${spawnKey}`); - console.error(`Spawn data item mismatch: ${spawnKey}, killing process`); + const timeTaken = Math.round((new Date() - workingQueueItem.start) / 100) / 10; + switch (command) { + case 'ready': + // Our spawned worker module is ready! Send it the initial data. + ipc.server.emit(socket, 'spawner.init', { + size: { + width: drawingBase.size.width, + height: drawingBase.size.height, + }, + object: workingQueueItem.object, + settings: workingQueueItem.settings, + }); + + console.log(`SPAWN ${spawnKey}: Spawned in ${timeTaken} secs`); + break; + + case 'progress': + // TODO: This: + break; + case 'complete': + // Fulfull the promise with the worker returned result. + workingQueueItem.success(data.result); + + // End the process, we're done with it now. ipc.server.emit(socket, 'cancel'); - } - - const timeTaken = Math.round((new Date() - workingQueueItem.start) / 100) / 10; - switch (command) { - case 'ready': - // Our spawned worker module is ready! Send it the initial data. - ipc.server.emit(socket, 'spawner.init', { - size: { - width: drawing.base.size.width, - height: drawing.base.size.height, - }, - object: workingQueueItem.object, - settings: workingQueueItem.settings, - }); - - console.log(`SPAWN ${spawnKey}: Spawned in ${timeTaken} secs`); - break; - - case 'progress': - // TODO: This: - break; - case 'complete': - // Fulfull the promise with the worker returned result. - workingQueueItem.success(data.result); - - // End the process, we're done with it now. - workingQueueItem.process.kill('SIGHUP'); - console.log(`SPAWN ${spawnKey}: Completed in ${timeTaken} secs`); - - // Free up the working queue memory. - delete workingQueue[`${data.type}-${data.hash}`]; - break; - - default: - break; - } - }; + workingQueueItem.process.kill('SIGHUP'); + console.log(`SPAWN ${spawnKey}: Completed in ${timeTaken} secs`); - // Bind to IPC serve init. - cncserver.binder.bindTo('ipc.serve', 'spawner', () => { - // IPC server that manages the runner should be going, just bind it. - ipc.server.on('spawner.message', gotMessage); - }); + // Free up the working queue memory. + delete workingQueue[`${data.type}-${data.hash}`]; + break; + default: + break; + } +}; - /** - * Process spawn wrapper for work management! - * - * @param {string} arg.hash - * The hash that identifies the work. - * @param {string} arg.type - * The identifier for what kind of process this is, EG 'filler'. - * @param {string} arg.subIndex - * An extra identifier for delineating a sub fill within a base hash. - * @param {string} arg.script - * The full path of the script to pass to the node binary. - * @param {object} arg.object - * The object to be worked on, likely a Paper path. - * @param {object} arg.settings - * The settings to be passed along to the spawned worker. - * - * @return {Promise} - * Promise that on success, returns processed object & original passed data, - * otherwise results in error if spawn process dies with non-zero exit code. - */ - const spawner = ({ - hash, type, script, object, settings, subIndex = '', - }) => new Promise((success, error) => { - const spawnKey = getSpawnKey({ hash, type, subIndex }); - const spawnData = { - start: new Date(), - settings, - object, - success, - error, - subIndex, - }; - - const resolvedScript = path.resolve(script); - if (!fs.existsSync(resolvedScript)) { - error(new Error(`"${type}" spawn entry doesn't exist: ${resolvedScript}`)); - } - - // Spawn process and bind basic i/o. - spawnData.process = spawn('node', [resolvedScript, hash, subIndex]); - spawnData.process.stdout.on('data', (rawData) => { - rawData.toString().split('\n').forEach((line) => { - if (line.length) console.log(`SPAWN ${spawnKey}: ${line}`); - }); - }); +// Bind to IPC serve init. +bindTo('ipc.serve', 'spawner', () => { + // IPC server that manages the runner should be going, just bind it. + ipc.server.on('spawner.message', gotMessage); +}); - spawnData.process.stderr.on('data', (err) => { - console.error(`SPAWN ERROR ${spawnKey}: ${err}`); - spawnData.error(err); - spawnData.process.kill('SIGHUP'); - }); +/** + * Process spawn wrapper for work management! + * + * @param {string} arg.hash + * The hash that identifies the work. + * @param {string} arg.type + * The identifier for what kind of process this is, EG 'filler'. + * @param {string} arg.subIndex + * An extra identifier for delineating a sub fill within a base hash. + * @param {string} arg.script + * The full path of the script to pass to the node binary. + * @param {object} arg.object + * The object to be worked on, likely a Paper path. + * @param {object} arg.settings + * The settings to be passed along to the spawned worker. + * + * @return {Promise} + * Promise that on success, returns processed object & original passed data, + * otherwise results in error if spawn process dies with non-zero exit code. + */ +const spawner = ({ + hash, type, script, object, settings, subIndex = '', +}) => new Promise((success, error) => { + const spawnKey = getSpawnKey({ hash, type, subIndex }); + const spawnData = { + start: new Date(), + settings, + object, + success, + error, + subIndex, + }; + + const resolvedScript = path.resolve(script); + if (!fs.existsSync(resolvedScript)) { + error(new Error(`"${type}" spawn entry doesn't exist: ${resolvedScript}`)); + } - spawnData.process.on('exit', (exitCode) => { - if (exitCode) console.log(`SPAWN EXIT ${spawnKey}: ${exitCode}`); + // Spawn process and bind basic i/o. + spawnData.process = spawn('node', [resolvedScript, hash, subIndex]); + spawnData.process.stdout.on('data', rawData => { + rawData.toString().split('\n').forEach(line => { + if (line.length) console.log(`SPAWN ${spawnKey}: ${line}`); }); + }); - workingQueue[spawnKey] = spawnData; + spawnData.process.stderr.on('data', err => { + console.error(`SPAWN ERROR ${spawnKey}: ${err}`); + spawnData.error(err); + spawnData.process.kill('SIGHUP'); }); - return spawner; -}; + spawnData.process.on('exit', exitCode => { + if (exitCode) console.log(`SPAWN EXIT ${spawnKey}: ${exitCode}`); + }); + + workingQueue[spawnKey] = spawnData; +}); + +export default spawner; diff --git a/src/components/core/drawing/cncserver.drawing.stage.js b/src/components/core/drawing/cncserver.drawing.stage.js index 758d18d5..a361a2d8 100644 --- a/src/components/core/drawing/cncserver.drawing.stage.js +++ b/src/components/core/drawing/cncserver.drawing.stage.js @@ -1,106 +1,105 @@ +/* eslint-disable no-param-reassign */ /** * @file Code for drawing stage layer management. */ -const { Group, Path } = require('paper'); - -const stage = { id: 'drawing.stage' }; - -module.exports = (cncserver, drawing) => { - const { layers } = drawing.base; - - // Default projects settings. - stage.defaultSettings = () => ({ - // TODO +import Paper from 'paper'; +import { layers, fitBounds, workspace } from 'cs/drawing/base'; +import { sendPaperUpdate } from 'cs/sockets'; +import { wrapSVG } from 'cs/utils'; + +const { Group, Path } = Paper; + +// Default projects settings. +export function defaultSettings() { + // TODO +} + +// Clear all items off the stage and update. +export function clearAll() { + layers.stage.removeChildren(); + sendPaperUpdate('stage'); +} + +// Remove an item from the stage, returns true if it worked. +export function remove(hash) { + if (layers.stage.children[hash]) { + layers.stage.children[hash].remove(); + return true; + } + return false; +} + +// Toggle the visibility of the bound rects. +export function toggleRects(state) { + layers.stage.children.forEach(group => { + group.children.bounds.opacity = state ? 1 : 0; + group.children['content-bounds'].opacity = state ? 1 : 0; }); - - // Clear all items off the stage and update. - stage.clearAll = () => { - layers.stage.removeChildren(); - cncserver.sockets.sendPaperUpdate('stage'); - }; - - // Remove an item from the stage, returns true if it worked. - stage.remove = (hash) => { - if (layers.stage.children[hash]) { - layers.stage.children[hash].remove(); - return true; - } - return false; - }; - - // Get a full preview SVG of the stage layer content. - stage.getPreviewSVG = () => { - // Hide bounds rects. - stage.toggleRects(false); - - const svgContent = layers.stage.exportSVG({ asString: true }); - - // Show bounds rects. - stage.toggleRects(true); - - return cncserver.utils.wrapSVG(svgContent, drawing.base.workspace); - }; - - // Toggle the visibility of the bound rects. - stage.toggleRects = (state) => { - layers.stage.children.forEach((group) => { - group.children.bounds.opacity = state ? 1 : 0; - group.children['content-bounds'].opacity = state ? 1 : 0; - }); - }; - - // Import an imported paper item into the stage layer. - stage.import = (importItem, hash, bounds) => { - const finalBounds = drawing.base.fitBounds(importItem, bounds); - - // console.log(importItem); - // console.log(importItem.bounds); - - // If an existing item with a matching hash exists, remove it first. - stage.remove(hash); - - // Build a group with the name/id of the hash, containing the content and a - // path rectangle matching the bounds. - - // eslint-disable-next-line no-param-reassign - importItem.name = 'content'; - layers.stage.addChild( - new Group({ - name: hash, - children: [ - new Path.Rectangle({ - name: 'bounds', - point: finalBounds.point, - size: finalBounds.size, - strokeWidth: 1, - strokeColor: 'green', - dashArray: [2, 2], - }), - new Path.Rectangle({ - name: 'content-bounds', - point: importItem.bounds.point, - size: importItem.bounds.size, - strokeWidth: 1, - strokeColor: 'red', - opacity: 0.5, - dashArray: [2, 2], - }), - importItem, - ], - }) - ); - cncserver.sockets.sendPaperUpdate('stage'); - return importItem; - }; - - // Update an item on the stage with its action item. - stage.updateItem = (item) => { - // Update bounds. - const stageGroup = layers.stage.children[item.hash]; - if (stageGroup) { - return stage.import(stageGroup.children.content, item.hash, item.bounds); - } - }; - - return stage; -}; +} + +// Import an imported paper item into the stage layer. +export function importGroup(importItem, hash, bounds) { + const finalBounds = fitBounds(importItem, bounds); + + // console.log(importItem); + // console.log(importItem.bounds); + + // If an existing item with a matching hash exists, remove it first. + remove(hash); + + // Build a group with the name/id of the hash, containing the content and a + // path rectangle matching the bounds. + + // eslint-disable-next-line no-param-reassign + importItem.name = 'content'; + layers.stage.addChild( + new Group({ + name: hash, + children: [ + new Path.Rectangle({ + name: 'bounds', + point: finalBounds.point, + size: finalBounds.size, + strokeWidth: 1, + strokeColor: 'green', + dashArray: [2, 2], + }), + new Path.Rectangle({ + name: 'content-bounds', + point: importItem.bounds.point, + size: importItem.bounds.size, + strokeWidth: 1, + strokeColor: 'red', + opacity: 0.5, + dashArray: [2, 2], + }), + importItem, + ], + }) + ); + sendPaperUpdate('stage'); + return importItem; +} + +// Get a full preview SVG of the stage layer content. +export function getPreviewSVG() { + // Hide bounds rects. + toggleRects(false); + + const svgContent = layers.stage.exportSVG({ asString: true }); + + // Show bounds rects. + toggleRects(true); + + return wrapSVG(svgContent, workspace); +} + +// Update an item on the stage with its action item. +export function updateItem(item) { + // Update bounds. + const stageGroup = layers.stage.children[item.hash]; + if (stageGroup) { + return importGroup(stageGroup.children.content, item.hash, item.bounds); + } + return null; +} diff --git a/src/components/core/drawing/cncserver.drawing.temp.js b/src/components/core/drawing/cncserver.drawing.temp.js index d51ebfdc..7a6c1bb7 100644 --- a/src/components/core/drawing/cncserver.drawing.temp.js +++ b/src/components/core/drawing/cncserver.drawing.temp.js @@ -1,39 +1,35 @@ /** * @file Code for drawing temp layer management. */ -/* eslint-disable implicit-arrow-linebreak */ -const { Group } = require('paper'); +import Paper from 'paper'; +import { layers } from 'cs/drawing/base'; +import { sendPaperUpdate } from 'cs/sockets'; -const temp = { id: 'drawing.temp' }; +const { Group } = Paper; -module.exports = (cncserver, drawing) => { - const { layers } = drawing.base; +// Clear all items off the temp layer. +export function clearAll() { + layers.temp.removeChildren(); +} - // Clear all items off the temp layer. - temp.clearAll = () => { - layers.temp.removeChildren(); - }; +// Import temp item into a hash group, return group with item(s). +export function addItem(importItem, hash, adjustments = {}) { + layers.temp.activate(); + const item = importItem.copyTo(layers.temp); - // Add the JSON and return the item within the hash group. - temp.addJSON = (json, hash, adjustments = {}) => - temp.addItem(layers.temp.importJSON(json), hash, adjustments); + const tempGroup = layers.temp.children[hash] || new Group({ name: hash }); + tempGroup.addChild(item); - // Import temp item into a hash group, return group with item(s). - temp.addItem = (importItem, hash, adjustments = {}) => { - layers.temp.activate(); - const item = importItem.copyTo(layers.temp); + // Apply adjustments to the item before sending final update. + Object.entries(adjustments).forEach(([key, value]) => { item[key] = value; }); - const tempGroup = layers.temp.children[hash] || new Group({ name: hash }); - tempGroup.addChild(item); + // Send final update for the addition of this item. + sendPaperUpdate('preview'); - // Apply adjustments to the item before sending final update. - Object.entries(adjustments).forEach(([key, value]) => { item[key] = value; }); + return tempGroup; +} - // Send final update for the addition of this item. - cncserver.sockets.sendPaperUpdate('preview'); - - return tempGroup; - }; - - return temp; -}; +// Add the JSON and return the item within the hash group. +export function addJSON(json, hash, adjustments = {}) { + return addItem(layers.temp.importJSON(json), hash, adjustments); +} diff --git a/src/components/core/drawing/cncserver.drawing.text.js b/src/components/core/drawing/cncserver.drawing.text.js index d6174897..51bcbe38 100644 --- a/src/components/core/drawing/cncserver.drawing.text.js +++ b/src/components/core/drawing/cncserver.drawing.text.js @@ -1,172 +1,206 @@ +/* eslint-disable no-param-reassign */ /** * @file Code for text rendering. */ -const hershey = require('hersheytext'); -const vectorizeText = require('vectorize-text'); -const { createCanvas } = require('canvas'); - -const { - Path, CompoundPath, Point, Group, -} = require('paper'); - -const text = {}; - -module.exports = (cncserver, drawing) => { - text.fonts = hershey.svgFonts; - - /** - * Returns a group of lines and characters rendered in single line hersheyfont - */ - function renderHersheyPaths(textContent, bounds, settings) { - // Render the text array. - const textArray = hershey.renderTextArray(textContent, { - // TODO: Find out differences in what this takes vs our settings object - // and make it EXPLICIT. - ...settings, - pos: { x: 0, y: 0 }, - }); - - const { view } = drawing.base.project; +import hershey from 'hersheytext'; +import Paper from 'paper'; +import vectorizeText from 'vectorize-text'; +import Canvas from 'canvas'; +import { getUserDirFiles } from 'cs/utils'; +import { layers, fitBounds } from 'cs/drawing/base'; +import { trace, fill } from 'cs/drawing'; +import { trigger } from 'cs/binder'; + +const { CompoundPath, Point, Group } = Paper; +const { createCanvas } = Canvas; + +export const fonts = hershey.svgFonts; + +// Add any custom fonts. +getUserDirFiles('fonts', '*.svg', hershey.addSVGFont); + +// Trigger font load complete. +// TODO: This might need to move. +trigger('drawing.text.setup', fonts, true); + +// Problem: With really long strings, they never break on their own, so if by +// default we wrap strings, lets format the text that goes in the stage +// preview to have manual breaks in it. +export function format(text) { + const words = text.split(' '); + const out = []; + let lineLength = 0; + words.forEach(word => { + lineLength += word.length; + if (lineLength > 60) { + lineLength = 0; + out.push(`${word}\n`); + } else { + out.push(word); + } + }); + return out.join(' '); +} - // Move through each char and build out chars and lines. - const caretPos = new Point(0, 50); - const chars = new Group(); // Hold output lines groups - const lines = [new Group({ name: 'line-0' })]; // Hold chars in lines - - let cLine = 0; - textArray.forEach((char, index) => { - if (char.type === 'space' || char.type === 'newline') { - caretPos.x += settings.spaceWidth; - - // Allow line wrap on space - if (caretPos.x > settings.wrapWidth || char.type === 'newline') { - // Before wrapping, reverse the order of the chars. - lines[cLine].reverseChildren(); - - caretPos.x = 0; - caretPos.y += settings.lineHeight; - - cLine++; - lines.push(new Group({ name: `line-${cLine}` })); - } - } else { - // Create the compound path of the character. - const c = new CompoundPath({ - data: { char: char.type }, - pathData: char.d, - name: `letter-${char.type}-${index}-${cLine}`, - }); - - lines[cLine].insertChild(0, c); - - // TODO: Add this to settings with some kind of validation schema. - if (settings.smooth) c.smooth(settings.smooth); - - // Rotate chararcters. - if (settings.character.rotation) c.rotate(settings.character.rotation); - - // Align to the top left as expected by the font system - const b = c.bounds; - c.pivot = new Point(0, 0); - c.position = caretPos; - - // Move the caret to the next position based on width and char spacing - caretPos.x += b.width + settings.character.spacing; +/** + * Returns a group of lines and characters rendered in single line hershey/SVG font. + * + * All raw values are in the font's number system before import scaling. + */ +function renderHersheyPaths(textContent, bounds, settings) { + // Render the text array. + const textArray = hershey.renderTextArray(textContent, settings); + + // const { view } = project; + + // Move through each char and build out chars and lines. + const caretPos = new Point(0, 50); + const chars = new Group(); // Hold output lines groups + const lines = [new Group({ name: 'line-0' })]; // Hold chars in lines + + let cLine = 0; + textArray.forEach((char, index) => { + if (char.name === 'space' || char.name === 'newline') { + caretPos.x += settings.spaceWidth + char.width; + + // Allow for wrapping based on wrapChars setting. + let passedWrapWidth = false; + if (char.name === 'space' && settings.wrapLines) { + passedWrapWidth = caretPos.x > settings.wrapChars * char.width; } - }); - // Reverse the chars in the line if only one line. - if (cLine === 0) { - lines[0].reverseChildren(); - } + // Allow line wrap on space, or forced via newline. + if (passedWrapWidth || char.name === 'newline') { + // Before wrapping, reverse the order of the chars. + lines[cLine].reverseChildren(); - // Add all lines of text to the final group. - chars.addChildren(lines); + caretPos.x = 0; + // TODO: Get the actual base line height from the font. + caretPos.y += 1000 + settings.lineHeight; - // Align the lines - if (settings.align.paragraph === 'center') { - lines.forEach((line) => { - line.position.x = chars.position.x; - }); - } else if (settings.align.paragraph === 'right') { - lines.forEach((line) => { - line.pivot = new Point(line.bounds.width, line.bounds.height / 2); - line.position.x = chars.bounds.width; + cLine++; + lines.push(new Group({ name: `line-${cLine}` })); + } + } else { + // Create the compound path of the character. + const c = new CompoundPath({ + data: { char: char.type }, + pathData: char.d, + name: `letter-${char.type}-${index}-${cLine}`, }); + + lines[cLine].insertChild(0, c); + + // TODO: Add this to settings with some kind of validation schema. + if (settings.smooth) c.smooth(settings.smooth); + + // Rotate chararcters. + if (settings.character.rotation) c.rotate(settings.character.rotation); + + // Align to the top left as expected by the font system. + c.pivot = new Point(0, 0); + c.position = [caretPos.x, caretPos.y]; + + // Move the caret to the next position based on width and char spacing. + caretPos.x += char.width + settings.character.spacing; + + // Flip the glyph over vertically. + c.scale([1, -1]); } + }); - return chars; + // Reverse the chars in the line if only one line. + if (cLine === 0) { + lines[0].reverseChildren(); } - /** - * Return a group characters rendered in filled compound path system font. - */ - function renderFilledText(textContent, hash, bounds, settings) { - const canvas = createCanvas(8192, 1024); - const { - fontStyle, fontVariant, fontWeight, textBaseline = 'hanging', textAlign, systemFont, - } = settings.text; - const polygons = vectorizeText(textContent, { - polygons: true, - width: 200, - font: systemFont, - context: canvas.getContext('2d'), - fontStyle, - fontVariant, - fontWeight, - textBaseline, - textAlign, - canvas, + // Add all lines of text to the final group. + chars.addChildren(lines); + + // Align the lines + if (settings.align.paragraph === 'center') { + lines.forEach(line => { + line.position.x = chars.position.x; + }); + } else if (settings.align.paragraph === 'right') { + lines.forEach(line => { + line.pivot = new Point(line.bounds.width, line.bounds.height / 2); + line.position.x = chars.bounds.width; }); + } - const chars = new Group(); - polygons.forEach((loops) => { - let d = ''; - loops.forEach((loop) => { - loop.forEach(([x, y], index) => { - const op = index === 0 ? 'M' : 'L'; - d = `${d} ${op} ${x} ${y}`; - }); - - // Add the first point back as end point. - d = `${d} L ${loop[0][0]} ${loop[0][1]}`; + return chars; +} + +/** + * Return a group characters rendered in filled compound path system font. + */ +function renderFilledText(textContent, hash, bounds, settings) { + const canvas = createCanvas(8192, 1024); + const { + fontStyle, fontVariant, fontWeight, textBaseline = 'hanging', textAlign, systemFont, + } = settings.text; + const polygons = vectorizeText(textContent, { + polygons: true, + width: 200, + font: systemFont, + context: canvas.getContext('2d'), + fontStyle, + fontVariant, + fontWeight, + textBaseline, + textAlign, + canvas, + }); + + const chars = new Group(); + polygons.forEach(loops => { + let d = ''; + loops.forEach(loop => { + loop.forEach(([x, y], index) => { + const op = index === 0 ? 'M' : 'L'; + d = `${d} ${op} ${x} ${y}`; }); - chars.addChild(new CompoundPath(d)); + + // Add the first point back as end point. + d = `${d} L ${loop[0][0]} ${loop[0][1]}`; }); + chars.addChild(new CompoundPath(d)); + }); + // Text sizing and position! + if (bounds) { + // Scale and fit within the given bounds rectangle. + fitBounds(chars, bounds); + } else { + // Position off from center, or at exact position. + /* const anchorPos = drawing.base.getAnchorPos(chars, settings.anchor); + const { view } = drawing.base.project; - // Text sizing and position! - if (bounds) { - // Scale and fit within the given bounds rectangle. - drawing.base.fitBounds(chars, bounds); + if (settings.position) { + chars.position = new Point(settings.position).subtract(anchorPos); } else { - // Position off from center, or at exact position. - /* const anchorPos = drawing.base.getAnchorPos(chars, settings.anchor); - const { view } = drawing.base.project; - - if (settings.position) { - chars.position = new Point(settings.position).subtract(anchorPos); - } else { - chars.position = view.center.add(new Point(settings.hCenter, settings.vCenter)); - } - chars.scale(settings.scale, anchorPos); */ - } - - // Fill each character if settings are given. - if (settings.fill.render) { - chars.children.forEach((char, subIndex) => { - char.fillColor = settings.path.fillColor; - cncserver.drawing.fill(char, hash, null, settings.fill, subIndex); - }); + chars.position = view.center.add(new Point(settings.hCenter, settings.vCenter)); } + chars.scale(settings.scale, anchorPos); */ + } - return chars; + // Fill each character if settings are given. + if (settings.fill.render) { + chars.children.forEach((char, subIndex) => { + char.fillColor = settings.path.fillColor; + fill(char, hash, null, settings.fill, subIndex); + }); } - // Actually build the paths for drawing. - text.draw = (textContent, hash, bounds, settings) => new Promise((resolve, reject) => { + return chars; +} + +// Actually build the paths for drawing. +export function draw(textContent, hash, bounds, settings) { + return new Promise((resolve, reject) => { // Render content to the temp layer. - drawing.base.layers.temp.activate(); + layers.temp.activate(); let chars; try { @@ -184,14 +218,12 @@ module.exports = (cncserver, drawing) => { if (settings.stroke.render) { chars.strokeColor = settings.path.strokeColor; - drawing.trace(chars, hash, bounds, settings.stroke); + trace(chars, hash, bounds, settings.stroke); } else if (settings.fill.trace) { chars.strokeColor = settings.path.fillColor; - drawing.trace(chars, hash, bounds, settings.stroke); + trace(chars, hash, bounds, settings.stroke); } } resolve(); }); - - return text; -}; +} diff --git a/src/components/core/drawing/cncserver.drawing.trace.js b/src/components/core/drawing/cncserver.drawing.trace.js index f984083c..a7c545b3 100644 --- a/src/components/core/drawing/cncserver.drawing.trace.js +++ b/src/components/core/drawing/cncserver.drawing.trace.js @@ -1,11 +1,14 @@ /** * @file Trace code for drawing base, pretty much just imports it into Paper! */ -module.exports = (cncserver, drawing) => { - const trace = (inputPath, hash, bounds = null, settings = {}) => new Promise((resolve, reject) => { +import { fitBounds } from 'cs/drawing/base'; +import { addRender } from 'cs/drawing/preview'; + +export default function trace(inputPath, hash, bounds = null, settings = {}) { + return new Promise(resolve => { // If bounds set, resize the path. if (bounds) { - drawing.base.fitBounds(inputPath, bounds); + fitBounds(inputPath, bounds); } // TODO: Actually render if we have dash or other options. @@ -13,16 +16,13 @@ module.exports = (cncserver, drawing) => { // TODO: This. } else { // Take normalized path and add it to the preview layer. - drawing.preview.addRender(inputPath.clone(), hash, { - strokeWidth: 1, + addRender(inputPath.clone(), hash, { + strokeWidth: inputPath.strokeWidth || 1, strokeColor: inputPath.strokeColor, fillColor: null, }); } - resolve(); }); - - return trace; -}; +} diff --git a/src/components/core/drawing/cncserver.drawing.vectorize.js b/src/components/core/drawing/cncserver.drawing.vectorize.js index 01d47838..18923ed0 100644 --- a/src/components/core/drawing/cncserver.drawing.vectorize.js +++ b/src/components/core/drawing/cncserver.drawing.vectorize.js @@ -1,40 +1,42 @@ /** * @file Code for image vectorizor management. */ -const path = require('path'); +import path from 'path'; +import spawner from 'cs/drawing/spawner'; +import { renderGroup } from 'cs/content'; +import { addJSON } from 'cs/drawing/temp'; +import { __basedir } from 'cs/utils'; -module.exports = (cncserver, drawing) => { - const vectorize = (imagePath, hash, bounds, settings) => new Promise((success, error) => { +export default function vectorize(imagePath, hash, bounds, settings) { + return new Promise((success, error) => { const { method } = settings.vectorize; const script = path.resolve( - __dirname, + __basedir, + 'components', + 'core', + 'drawing', 'vectorizers', method, `cncserver.drawing.vectorizers.${method}.js` ); // Use spawner to run vectorizer process. - drawing - .spawner({ - type: 'vectorizer', - hash, - script, - settings: { ...settings.vectorize, bounds }, - object: imagePath, - }) - .then((result) => { - // Because some vectorizers produce filled shapes, we need to process - // these in the temp layer before moving them to render preview. - const group = drawing.temp.addJSON(result, hash); + spawner({ + type: 'vectorizer', + hash, + script, + settings: { ...settings.vectorize, bounds }, + object: imagePath, + }).then(result => { + // Because some vectorizers produce filled shapes, we need to process + // these in the temp layer before moving them to render preview. + const group = addJSON(result, hash); - console.log('Returned items', group.children[0].children.length); + console.log('Returned items', group.children[0].children.length); - // We'll NEVER have occlusion if converting a raster to a vector. - cncserver.content.renderGroup(group, hash, settings, true); - success(); - }) - .catch(error); + // We'll NEVER have occlusion if converting a raster to a vector. + renderGroup(group, hash, settings, true); + success(); + }).catch(error); }); - - return vectorize; -}; +} diff --git a/src/components/core/drawing/colorsets/crayola.json b/src/components/core/drawing/colorsets/crayola.json deleted file mode 100644 index 7b4b5894..00000000 --- a/src/components/core/drawing/colorsets/crayola.json +++ /dev/null @@ -1,52 +0,0 @@ -[{ - "manufacturer": "crayola", - "media": "watercolor", - "machineName": "secondary", - "weight": 1, - "colors": { - "maroon": "#bb39b9", - "orangered": "#e14a35", - "tangerine": "#f3e730", - "yellowgreen": "#b5d02e", - "teal": "#2ed07e", - "skyblue": "#53bee7", - "indigo": "#535ee7", - "white": "#EEE" - } -}, - -{ - "manufacturer": "crayola", - "media":"watercolor", - "machineName": "tertiary", - "weight": 5, - "colors": { - "grey": "#716d78", - "pink": "#ff61bc", - "salmon": "#ffb260", - "lemon": "#ffff30", - "lime": "#b2f743", - "turquoise": "#00c0a3", - "fuchsia": "#d55ce9", - "beige": "#de896d" - } -}, - -{ - "manufacturer": "crayola", - "media":"pen", - "machineName": "classic", - "weight": 5, - "colors": { - "brown":"#8B4513", - "purple":"#800080", - "red":"#FF0000", - "orange":"#FFA500", - "yellow":"#FFFF00", - "green":"#008000", - "blue":"#0000FF", - "black":"#000000", - "pink": "#ff61bc", - "grey": "#716d78" - } -}] diff --git a/src/components/core/drawing/colorsets/generic.json b/src/components/core/drawing/colorsets/generic.json deleted file mode 100644 index aac34b8c..00000000 --- a/src/components/core/drawing/colorsets/generic.json +++ /dev/null @@ -1,16 +0,0 @@ -[{ - "manufacturer": "generic", - "media": "watercolor", - "machineName": "generic", - "weight": -10, - "colors": { - "black":"#000000", - "red":"#FF0000", - "orange":"#FFA500", - "yellow":"#FFFF00", - "green":"#008000", - "blue":"#0000FF", - "purple":"#800080", - "brown":"#8B4513" - } -}] diff --git a/src/components/core/drawing/colorsets/prang.json b/src/components/core/drawing/colorsets/prang.json deleted file mode 100644 index e6bddd13..00000000 --- a/src/components/core/drawing/colorsets/prang.json +++ /dev/null @@ -1,48 +0,0 @@ -[{ - "manufacturer": "prang", - "media": "watercolor", - "machineName": "glitter", - "weight": 11, - "colors": { - "gold":"#aba66d", - "silver":"#b1bcac", - "lilac":"#cdb2d0", - "sapphire":"#32c0c4", - "emerald":"#2ca440", - "canary":"#dede5d", - "tangerine":"#efa038", - "ruby":"#f0846c" - } -}, -{ - "manufacturer": "prang", - "media": "watercolor", - "machineName": "metallic", - "weight": 12, - "colors": { - "gold":"#d9bf81", - "silver":"#569aaa", - "copper":"#a74837", - "blue":"#0082ad", - "green":"#00ae4d", - "yellow":"#ffe30c", - "orange":"#f35400", - "red":"#c0080b" - } -}, -{ - "manufacturer": "prang", - "media": "watercolor", - "machineName": "secondary", - "weight": 10, - "colors": { - "vermilion":"#ff5a59", - "yelloworange":"#ffa300", - "yellowgreen":"#9aea24", - "bluegreen":"#009aa6", - "turquoiseblue":"#00aadf", - "blueviolet":"#00aadf", - "redviolet":"#d842ce", - "white":"#eee" - } -}] diff --git a/src/components/core/drawing/colorsets/sargent.json b/src/components/core/drawing/colorsets/sargent.json deleted file mode 100644 index 64936e9f..00000000 --- a/src/components/core/drawing/colorsets/sargent.json +++ /dev/null @@ -1,16 +0,0 @@ -[{ - "manufacturer": "sargent", - "media": "watercolor", - "machineName": "secondary", - "weight": 20, - "colors": { - "yelloworange":"#ffbe2c", - "vermilion":"#ff451f", - "redviolet":"#c02987", - "blueviolet":"#212aa9", - "green":"#a3d130", - "teal":"#00a6b8", - "teal2":"#00a6b8", - "white":"#eee" - } -}] diff --git a/src/components/core/drawing/fillers/cncserver.drawing.fillers.util.js b/src/components/core/drawing/fillers/cncserver.drawing.fillers.util.js index dbee5197..71b72bca 100644 --- a/src/components/core/drawing/fillers/cncserver.drawing.fillers.util.js +++ b/src/components/core/drawing/fillers/cncserver.drawing.fillers.util.js @@ -4,12 +4,17 @@ * Holds all standardized processes for managing IPC, paper setup, and export * so the fill algorithm can do whatever it needs. */ +import Paper from 'paper'; +import clipperLib from 'js-angusj-clipper'; +import ipc from 'node-ipc'; + const { Project, Size, Path, CompoundPath, -} = require('paper'); -const clipperLib = require('js-angusj-clipper'); -const ipc = require('node-ipc'); +} = Paper; +export const state = { + project: {}, // Placeholder for paper project to work off of. +}; const hostname = 'cncserver'; const ipcBase = { hash: process.argv[2], @@ -17,6 +22,12 @@ const ipcBase = { type: 'filler', }; +// Catch uncaught exceptions to clean things up. +process.addListener('uncaughtException', err => { + console.error(err); + process.exit(1); +}); + // Config IPC. ipc.config.silent = true; @@ -27,7 +38,7 @@ const send = (command, data = {}) => { }; // Clipper helper utilities. -const clipper = { +export const clipper = { scalePrecision: 10000, getInstance: async () => clipperLib.loadNativeClipperLibInstanceAsync( clipperLib.NativeClipperLibRequestedFormat.WasmWithAsmJsFallback @@ -51,7 +62,7 @@ const clipper = { c.flatten(resolution); geometries[pathIndex] = []; - c.segments.forEach((s) => { + c.segments.forEach(s => { geometries[pathIndex].push(clipper.translatePoint(s.point)); }); @@ -71,7 +82,7 @@ const clipper = { geometries[0] = []; p.flatten(resolution); - p.segments.forEach((s) => { + p.segments.forEach(s => { geometries[0].push(clipper.translatePoint(s.point)); }); } @@ -98,9 +109,9 @@ const clipper = { const out = []; if (result && result.length) { - result.forEach((subPathPoints) => { + result.forEach(subPathPoints => { const subPath = new Path(); - subPathPoints.forEach((point) => { + subPathPoints.forEach(point => { subPath.add({ x: point.x / clipper.scalePrecision, y: point.y / clipper.scalePrecision, @@ -117,99 +128,87 @@ const clipper = { }, }; -const exp = { - connect: (initCallback) => { - ipc.connectTo(hostname, () => { - // Setup bindings now that the socket is ready. - ipc.of[hostname].on('connect', () => { - // Connected! Tell the server we're ready for data. - send('ready', ipcBase); - }); - - // Bind central init, this gives us everything we need to do the work! - ipc.of[hostname].on('spawner.init', ({ size, object, settings }) => { - exp.project = new Project(new Size(size)); - const item = exp.project.activeLayer.importJSON(object); - // console.log('Path:', item.name, `${Math.round(item.length * 10) / 10}mm long`); - - // For any non-zero value, we want to inset the object before it gets in. - if (settings.inset) { - clipper.getInstance().then((clipperInstance) => { - const geo = clipper.getPathGeo(item, settings.flattenResolution); - const paths = clipper.getOffsetPaths(geo, settings.inset, clipperInstance); - - initCallback(new CompoundPath(paths), settings); - }); - } else { - initCallback(item, settings); - } - }); - - // Cancel/quit the process. - ipc.of[hostname].on('cancel', () => { process.exit(0); }); +export function connect(initCallback) { + ipc.connectTo(hostname, () => { + // Setup bindings now that the socket is ready. + ipc.of[hostname].on('connect', () => { + // Connected! Tell the server we're ready for data. + send('ready', ipcBase); }); - }, - - // Get information about this spawn process. - info: { ...ipcBase }, - // Report progress on processing. - progress: (status, value) => { - send('progress', { ...ipcBase, status, value }); - }, - - // Final fill paths! Send and host will shutdown when done. - finish: (paths = {}) => { - send('complete', { - ...ipcBase, - result: paths.exportJSON(), // exp.project.activeLayer.exportJSON() - }); - }, + // Bind central init, this gives us everything we need to do the work! + ipc.of[hostname].on('spawner.init', ({ size, object, settings }) => { + state.project = new Project(new Size(size)); + const item = state.project.activeLayer.importJSON(object); + // console.log('Path:', item.name, `${Math.round(item.length * 10) / 10}mm long`); - // Get only the ID of closest point in an intersection array. - getClosestIntersectionID: (srcPoint, points) => { - let closestID = 0; - let closest = srcPoint.getDistance(points[0].point); + // For any non-zero value, we want to inset the object before it gets in. + if (settings.inset) { + clipper.getInstance().then(clipperInstance => { + const geo = clipper.getPathGeo(item, settings.flattenResolution); + const paths = clipper.getOffsetPaths(geo, settings.inset, clipperInstance); - points.forEach((destPoint, index) => { - const dist = srcPoint.getDistance(destPoint.point); - if (dist < closest) { - closest = dist; - closestID = index; + initCallback(new CompoundPath(paths), settings); + }); + } else { + initCallback(item, settings); } }); - return closestID; - }, - - // Will return true if the given point is in either the top left or bottom - // right otuside the realm of the bound rect: - // | - // (true)| (false) - // ----------------+ - // | Bounds| - // (false) |(false)| (false) - // +---------------- - // (false) | (true) - // | - pointBeyond: (point, bounds) => { - // Outside top left - if (point.x < bounds.left && point.y < bounds.top) return true; - - // Outside bottom right - if (point.x > bounds.right && point.y > bounds.bottom) return true; - - // Otherwise, not. - return false; - }, - - // Add in the Clipper utilities. - clipper, -}; - -process.addListener('uncaughtException', (err) => { - console.error(err); - process.exit(1); -}); - -module.exports = exp; + // Cancel/quit the process. + ipc.of[hostname].on('cancel', () => { process.exit(0); }); + }); +} + +// Get information about this spawn process. +export const info = { ...ipcBase }; + +// Report progress on processing. +export function progress(status, value) { + send('progress', { ...ipcBase, status, value }); +} + +// Final fill paths! Send and host will shutdown when done. +export function finish(paths = {}) { + send('complete', { + ...ipcBase, + result: paths.exportJSON(), // exp.project.activeLayer.exportJSON() + }); +} + +// Get only the ID of closest point in an intersection array. +export function getClosestIntersectionID(srcPoint, points) { + let closestID = 0; + let closest = srcPoint.getDistance(points[0].point); + + points.forEach((destPoint, index) => { + const dist = srcPoint.getDistance(destPoint.point); + if (dist < closest) { + closest = dist; + closestID = index; + } + }); + + return closestID; +} + +// Will return true if the given point is in either the top left or bottom +// right otuside the realm of the bound rect: +// | +// (true)| (false) +// ----------------+ +// | Bounds| +// (false) |(false)| (false) +// +---------------- +// (false) | (true) +// | +export function pointBeyond(point, bounds) { + // Outside top left + if (point.x < bounds.left && point.y < bounds.top) return true; + + // Outside bottom right + if (point.x > bounds.right && point.y > bounds.bottom) return true; + + // Otherwise, not. + return false; +} diff --git a/src/components/core/drawing/fillers/offset/cncserver.drawing.filler.schema.js b/src/components/core/drawing/fillers/offset/cncserver.drawing.filler.schema.js index 8136af29..80fbf4e6 100644 --- a/src/components/core/drawing/fillers/offset/cncserver.drawing.filler.schema.js +++ b/src/components/core/drawing/fillers/offset/cncserver.drawing.filler.schema.js @@ -4,10 +4,28 @@ * This schema defines the fill method specific settings schema for the * application to import and use for settings validation and documentation. */ - -module.exports = { +/* eslint-disable max-len */ +const schema = { + // === Pattern fill method keys, found @ settings.fill.offset ============== type: 'object', - title: 'Offset method options', + title: 'Offset Shape Fill', options: { collapsed: true }, - properties: {}, + properties: { + connectShells: { + type: 'boolean', + title: 'Connect shells', + description: 'If checked, each offset shell will be connected to the previous one, making a spiral. Does not connect multi-shells.', + default: false, + format: 'checkbox', + }, + fillGaps: { + type: 'boolean', + title: 'Fill Gaps', + description: 'If checked, the fill method will check for single width gaps and fill them.', + default: false, + format: 'checkbox', + }, + }, }; + +export default schema; diff --git a/src/components/core/drawing/fillers/offset/cncserver.drawing.fillers.offset.js b/src/components/core/drawing/fillers/offset/cncserver.drawing.fillers.offset.js index 54df0903..77624634 100644 --- a/src/components/core/drawing/fillers/offset/cncserver.drawing.fillers.offset.js +++ b/src/components/core/drawing/fillers/offset/cncserver.drawing.fillers.offset.js @@ -3,29 +3,44 @@ * "cam" style offset fill utilizing the "clipper" and cam.js libraries. */ -const { Group } = require('paper'); -const fillUtil = require('../cncserver.drawing.fillers.util'); +import Paper from 'paper'; +import { connect, clipper, finish } from '../cncserver.drawing.fillers.util.js'; -let settings = {}; -let exportGroup = {}; +const { Group } = Paper; // Connect to the main process, start the fill operation. -fillUtil.connect((path, settingsOverride) => { - fillUtil.clipper.getInstance().then((clipper) => { - settings = { ...settings, ...settingsOverride }; - exportGroup = new Group(); +connect((path, { flattenResolution, spacing, offset }) => { + clipper.getInstance().then(clipperInstance => { + const exportGroup = new Group(); let items = []; let increment = 0; - const geo = fillUtil.clipper.getPathGeo(path, settings.flattenResolution); + const geo = clipper.getPathGeo(path, flattenResolution); + let angle = 0; while (items) { - items = fillUtil.clipper.getOffsetPaths(geo, settings.spacing + increment, clipper); + items = clipper.getOffsetPaths(geo, spacing + increment, clipperInstance); if (items) { - exportGroup.addChildren(items); - increment += settings.spacing; + // Try to connect the shells. + if (offset.connectShells && exportGroup.lastChild && items.length === 1) { + exportGroup.lastChild.closed = false; + angle += 4.2; + items[0].rotate(angle); + items[0].segments.forEach(({ point: { x, y } }) => { + exportGroup.lastChild.add([x, y]); + }); + } else { + angle += 4.2; + items[0].rotate(angle); + items[0].smooth(); + exportGroup.addChildren(items); + } + increment += spacing; } } - fillUtil.finish(exportGroup); + // TODO: Add support for settings.offset.fillGaps + // - Add the centerline of the last fill shape(s). + + finish(exportGroup); }); }); diff --git a/src/components/core/drawing/fillers/pattern/cncserver.drawing.filler.schema.js b/src/components/core/drawing/fillers/pattern/cncserver.drawing.filler.schema.js index 03f9737c..61b3f036 100644 --- a/src/components/core/drawing/fillers/pattern/cncserver.drawing.filler.schema.js +++ b/src/components/core/drawing/fillers/pattern/cncserver.drawing.filler.schema.js @@ -5,11 +5,14 @@ * application to import and use for settings validation and documentation. */ /* eslint-disable max-len */ -const fs = require('fs'); -const path = require('path'); +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const svgPatterns = []; -fs.readdirSync(path.resolve(__dirname, 'patterns')).map((file) => { +fs.readdirSync(path.resolve(__dirname, 'patterns')).map(file => { svgPatterns.push(file.split('.')[0]); }); @@ -90,9 +93,11 @@ const properties = { }, }; -module.exports = { +const schema = { type: 'object', - title: 'Pattern method options', + title: 'Pattern Fill', options: { collapsed: true }, properties, }; + +export default schema; diff --git a/src/components/core/drawing/fillers/pattern/cncserver.drawing.fillers.pattern.js b/src/components/core/drawing/fillers/pattern/cncserver.drawing.fillers.pattern.js index 1234c40d..79e17640 100644 --- a/src/components/core/drawing/fillers/pattern/cncserver.drawing.fillers.pattern.js +++ b/src/components/core/drawing/fillers/pattern/cncserver.drawing.fillers.pattern.js @@ -2,10 +2,12 @@ * @file Path fill algortihm module: Node filler app for running the pattern * path fill (overlaying a given large space filling path over the fill object). */ -const { Path, Group } = require('paper'); -const fs = require('fs'); -const path = require('path'); -const fillUtil = require('../cncserver.drawing.fillers.util'); +import Paper from 'paper'; +import fs from 'fs'; +import path from 'path'; +import * as fillUtil from '../cncserver.drawing.fillers.util.js'; + +const { Path, Group } = Paper; let viewBounds = {}; let settings = {}; // Globalize settings from settings.fill > @@ -82,7 +84,7 @@ function generateFromLib(name, fillPath) { let pattern = root.clone(); - // With our pattern, we have to tile it along X and y until it fits the bounds of our path. + // With our pattern, we tile it along X and y until it fits the bounds of our path. const { bounds } = fillPath; // Tile till we reach the width. @@ -221,7 +223,7 @@ function applyDensity(density, singlePath) { break; } - densityArray.forEach((angle) => { + densityArray.forEach(angle => { patternGroup.addChild(singlePath.clone().rotate(angle)); }); @@ -273,9 +275,9 @@ function generatePattern(name, fillPath) { // Actually connect to the main process, start the fill operation. fillUtil.connect((fillPath, rawSettings) => { - fillUtil.clipper.getInstance().then((clipper) => { + fillUtil.clipper.getInstance().then(clipper => { settings = { ...rawSettings }; - viewBounds = fillUtil.project.view.bounds; + viewBounds = fillUtil.state.project.view.bounds; const pattern = generatePattern(settings.pattern.pattern, fillPath); // Convert the paths to clipper geometry. diff --git a/src/components/core/drawing/index.js b/src/components/core/drawing/index.js index 6b82cbaf..afbb64b2 100644 --- a/src/components/core/drawing/index.js +++ b/src/components/core/drawing/index.js @@ -1,22 +1,17 @@ /** * @file Index for all high level drawing components. */ -/* eslint-disable global-require */ -const drawing = {}; // Conglomerated drawing export. -module.exports = (cncserver) => { - // TODO: build with an array loop - drawing.base = require('./cncserver.drawing.base.js')(cncserver); - drawing.occlusion = require('./cncserver.drawing.occlusion.js')(cncserver, drawing); - drawing.trace = require('./cncserver.drawing.trace.js')(cncserver, drawing); - drawing.spawner = require('./cncserver.drawing.spawner.js')(cncserver, drawing); - drawing.fill = require('./cncserver.drawing.fill.js')(cncserver, drawing); - drawing.vectorize = require('./cncserver.drawing.vectorize.js')(cncserver, drawing); - drawing.text = require('./cncserver.drawing.text.js')(cncserver, drawing); - drawing.accell = require('./cncserver.drawing.accell.js')(cncserver, drawing); - drawing.colors = require('./cncserver.drawing.colors.js')(cncserver, drawing); - drawing.stage = require('./cncserver.drawing.stage.js')(cncserver, drawing); - drawing.preview = require('./cncserver.drawing.preview.js')(cncserver, drawing); - drawing.temp = require('./cncserver.drawing.temp.js')(cncserver, drawing); - return drawing; -}; +export * as base from 'cs/drawing/base'; +export { default as fill } from 'cs/drawing/fill'; +export { default as vectorize } from 'cs/drawing/vectorize'; +export { default as occlusion } from 'cs/drawing/occlusion'; +export { default as trace } from 'cs/drawing/trace'; +export { default as spawner } from 'cs/drawing/spawner'; +export * as text from 'cs/drawing/text'; +export * as accell from 'cs/drawing/accell'; +export * as colors from 'cs/drawing/colors'; +export * as implements from 'cs/drawing/implements'; +export * as stage from 'cs/drawing/stage'; +export * as preview from 'cs/drawing/preview'; +export * as temp from 'cs/drawing/temp'; diff --git a/src/components/core/drawing/vectorizers/basic/cncserver.drawing.vectorizer.schema.js b/src/components/core/drawing/vectorizers/basic/cncserver.drawing.vectorizer.schema.js index fc29fa24..09643b45 100644 --- a/src/components/core/drawing/vectorizers/basic/cncserver.drawing.vectorizer.schema.js +++ b/src/components/core/drawing/vectorizers/basic/cncserver.drawing.vectorizer.schema.js @@ -48,9 +48,11 @@ const properties = { }, }; -module.exports = { +const schema = { type: 'object', - title: 'Basic method options', + title: 'Basic Autotrace Vectorization', options: { collapsed: true }, properties, }; + +export default schema; diff --git a/src/components/core/drawing/vectorizers/basic/cncserver.drawing.vectorizers.basic.js b/src/components/core/drawing/vectorizers/basic/cncserver.drawing.vectorizers.basic.js index 690a1d46..4753374f 100644 --- a/src/components/core/drawing/vectorizers/basic/cncserver.drawing.vectorizers.basic.js +++ b/src/components/core/drawing/vectorizers/basic/cncserver.drawing.vectorizers.basic.js @@ -1,19 +1,21 @@ /** * @file Basic vectorizer using autotrace. */ - -const { Group, CompoundPath, Color } = require('paper'); -const { exec } = require('child_process'); -const { tmpdir } = require('os'); -const fs = require('fs'); -const path = require('path'); -const jsdom = require('jsdom'); -const util = require('../cncserver.drawing.vectorizers.util'); - +import Paper from 'paper'; +import { exec } from 'child_process'; +import { tmpdir } from 'os'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import jsdom from 'jsdom'; +import { connect, finish, info } from '../cncserver.drawing.vectorizers.util.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const { JSDOM } = jsdom; +const { Group, CompoundPath, Color } = Paper; const bin = path.resolve(__dirname, 'bin', process.platform, 'autotrace'); const tmp = path.resolve(tmpdir()); -const outputFile = path.resolve(tmp, `autotrace_output_${util.info.hash}.svg`); +const outputFile = path.resolve(tmp, `autotrace_output_${info.hash}.svg`); let settings = { }; // Globalize contents of filler.vectorize > @@ -59,7 +61,7 @@ const buildOptions = () => { }; // Connect to the main process, start the vectorization operation. -util.connect('bmp', (inputImagePath, rawSettings) => { +connect('bmp', (inputImagePath, rawSettings) => { settings = { ...rawSettings }; // Remove any previous run output file. @@ -71,11 +73,11 @@ util.connect('bmp', (inputImagePath, rawSettings) => { const child = exec(fullExec); - child.stderr.on('data', (data) => { + child.stderr.on('data', data => { console.log('ERROR:', data); }); - child.stdout.on('data', (data) => { + child.stdout.on('data', data => { console.log('Output:', data); }); @@ -88,12 +90,12 @@ util.connect('bmp', (inputImagePath, rawSettings) => { const svg = fs.readFileSync(outputFile, 'utf8'); const dom = new JSDOM(svg, { contentType: 'text/xml' }); const paths = dom.window.document.querySelectorAll('path'); - paths.forEach((pathItem) => { + paths.forEach(pathItem => { const styleParts = pathItem.getAttribute('style').split(';'); const styles = { fill: null, stroke: null }; - styleParts.forEach((item) => { + styleParts.forEach(item => { const part = item.split(':'); - styles[part[0]] = part[1]; + [, styles[part[0]]] = part; }); exportGroup.addChild( @@ -107,10 +109,10 @@ util.connect('bmp', (inputImagePath, rawSettings) => { }); exportGroup.fitBounds(settings.bounds); - util.finish(exportGroup); + finish(exportGroup); } else { console.log('No output file 😢', outputFile); - util.finish(new Group()); + finish(new Group()); } }); }); diff --git a/src/components/core/drawing/vectorizers/cncserver.drawing.vectorizers.util.js b/src/components/core/drawing/vectorizers/cncserver.drawing.vectorizers.util.js index 6640b432..bb97f71c 100644 --- a/src/components/core/drawing/vectorizers/cncserver.drawing.vectorizers.util.js +++ b/src/components/core/drawing/vectorizers/cncserver.drawing.vectorizers.util.js @@ -4,14 +4,15 @@ * Holds all standardized processes for managing IPC, paper setup, and export * so the vectorization algorithm can do whatever it needs. */ -const { - Project, Size, Path, Rectangle, Raster, Color, -} = require('paper'); -const { tmpdir } = require('os'); -const ipc = require('node-ipc'); -const Jimp = require('jimp'); -const path = require('path'); +import Paper from 'paper'; +import { tmpdir } from 'os'; +import ipc from 'node-ipc'; +import Jimp from 'jimp'; +import path from 'path'; +const { + Project, Size, Path, Rectangle, Raster, Color +} = Paper; const hostname = 'cncserver'; const ipcBase = { hash: process.argv[2], @@ -19,157 +20,161 @@ const ipcBase = { type: 'vectorizer', }; +// Vectorizer state. +export const state = { + project: {}, // Paper project placeholder. +}; + // Config IPC. ipc.config.silent = true; +// Catch errors and quit cleanly. +process.addListener('uncaughtException', err => { + console.error(err); + process.exit(1); +}); + // Generic message sender. const send = (command, data = {}) => { const packet = { command, data }; ipc.of[hostname].emit('spawner.message', packet); }; -const exp = { - connect: (outputFormat, initCallback) => { - ipc.connectTo(hostname, () => { - // Setup bindings now that the socket is ready. - ipc.of[hostname].on('connect', () => { - // Connected! Tell the server we're ready for data. - send('ready', ipcBase); - }); +export function connect(outputFormat, initCallback) { + ipc.connectTo(hostname, () => { + // Setup bindings now that the socket is ready. + ipc.of[hostname].on('connect', () => { + // Connected! Tell the server we're ready for data. + send('ready', ipcBase); + }); - // Bind central init, this gives us everything we need to do the work! - ipc.of[hostname].on('spawner.init', ({ size, object: imagePath, settings }) => { - exp.project = new Project(new Size(size)); - - // Use JIMP to apply raster/pixel based modifications & work from that. - const ext = outputFormat === 'paper' ? 'png' : outputFormat; - const outputImagePath = path.resolve( - tmpdir(), - `cncserver_vectorizer_image_${ipcBase.hash}.${ext}` - ); - const { raster } = settings; - - // eslint-disable-next-line no-eval - const flattenColor = eval( - `${new Color(raster.flattenColor).toCSS(true).replace('#', '0x')}ff` - ); - Jimp.read(imagePath).then(img => new Promise((resolve) => { - img.brightness(raster.brightness); - img.contrast(raster.contrast); - - if (raster.grayscale) img.greyscale(); // Spelling much? 🤣 - if (raster.invert) img.invert(); - if (raster.normalize) img.normalize(); - if (raster.blur) img.blur(raster.blur); - if (raster.flatten || ext !== 'png') img.background(flattenColor); - // TODO: apply image resolution adjustment/resize. - - return img.write(outputImagePath, resolve); - })).then(() => { - // If the vectorizer requests paper, load that in from the JIMP output. - if (outputFormat === 'paper') { - const item = new Raster(outputImagePath); - item.onLoad = () => { - item.fitBounds(settings.bounds); - initCallback(item, settings); - }; - } else { - // Otherwise, just hand it the output image path. - initCallback(outputImagePath, settings); - } - }); + // Bind central init, this gives us everything we need to do the work! + ipc.of[hostname].on('spawner.init', ({ size, object: imagePath, settings }) => { + state.project = new Project(new Size(size)); + + // Use JIMP to apply raster/pixel based modifications & work from that. + const ext = outputFormat === 'paper' ? 'png' : outputFormat; + const outputImagePath = path.resolve( + tmpdir(), + `cncserver_vectorizer_image_${ipcBase.hash}.${ext}` + ); + const { raster } = settings; + + // eslint-disable-next-line no-eval + const flattenColor = eval( + `${new Color(raster.flattenColor).toCSS(true).replace('#', '0x')}ff` + ); + Jimp.read(imagePath).then(img => new Promise(resolve => { + img.brightness(raster.brightness); + img.contrast(raster.contrast); + + if (raster.grayscale) img.greyscale(); // Spelling much? 🤣 + if (raster.invert) img.invert(); + if (raster.normalize) img.normalize(); + if (raster.blur) img.blur(raster.blur); + if (raster.flatten || ext !== 'png') img.background(flattenColor); + // TODO: apply image resolution adjustment/resize. + + return img.write(outputImagePath, resolve); + })).then(() => { + // If the vectorizer requests paper, load that in from the JIMP output. + if (outputFormat === 'paper') { + const item = new Raster(outputImagePath); + item.onLoad = () => { + item.fitBounds(settings.bounds); + initCallback(item, settings); + }; + } else { + // Otherwise, just hand it the output image path. + initCallback(outputImagePath, settings); + } }); - - // Cancel/quit the process. - ipc.of[hostname].on('cancel', () => { process.exit(0); }); }); - }, - // Get information about this spawn process. - info: { ...ipcBase }, + // Cancel/quit the process. + ipc.of[hostname].on('cancel', () => { process.exit(0); }); + }); +} - // Report progress on processing. - progress: (status, value) => { - send('progress', { ...ipcBase, status, value }); - }, +// Get information about this spawn process. +export const info = { ...ipcBase }; - // Final vectorized paths! Send and shutdown when done. - finish: (paths = {}) => { - send('complete', { - ...ipcBase, - result: paths.exportJSON(), // exp.project.activeLayer.exportJSON() - }); - }, - - /** - * Map a value in a given range to a new range. - * - * @param {number} x - * The input number to be mapped. - * @param {number} inMin - * Expected minimum of the input number. - * @param {number} inMax - * Expected maximum of the input number. - * @param {number} outMin - * Expected minimum of the output map. - * @param {number} outMax - * Expected maximum of the output map. - * - * @return {number} - * The output number after mapping. - */ - map: (x, inMin, inMax, outMin, outMax) => (x - inMin) * (outMax - outMin) / (inMax - inMin) + outMin, - - generateSpiralPath: (inputSpacing, spiralBounds, offset = { x: 0, y: 0 }) => { - // Calculate a single spiral coordinate given a distance and spacing. - function calculateSpiral(distance, spacing = 1, center) { - return { - x: (spacing * distance * Math.cos(distance)) + center.x, - y: (spacing * distance * Math.sin(distance)) + center.y, - }; - } +// Report progress on processing. +export function progress(status, value) { + send('progress', { ...ipcBase, status, value }); +} - // Make a list of points along a spiral. - function makeSpiral(turns, spacing, startOn, center) { - const start = startOn ? startOn * Math.PI * 2 : 0; - const points = []; - const stepAngle = Math.PI / 4; // We want at least 8 points per turn +// Final vectorized paths! Send and shutdown when done. +export function finish(paths = {}) { + send('complete', { + ...ipcBase, + result: paths.exportJSON(), // state.project.activeLayer.exportJSON() + }); +} - for (let i = start; i < turns * Math.PI * 2; i += stepAngle) { - points.push(calculateSpiral(i, spacing, center)); - } - - return points; +/** + * Map a value in a given range to a new range. + * + * @param {number} x + * The input number to be mapped. + * @param {number} inMin + * Expected minimum of the input number. + * @param {number} inMax + * Expected maximum of the input number. + * @param {number} outMin + * Expected minimum of the output map. + * @param {number} outMax + * Expected maximum of the output map. + * + * @return {number} + * The output number after mapping. + */ +export function map(x, inMin, inMax, outMin, outMax) { + return ((x - inMin) * (outMax - outMin)) / ((inMax - inMin) + outMin) +} + +export function generateSpiralPath(inputSpacing, spiralBounds, offset = { x: 0, y: 0 }) { + // Calculate a single spiral coordinate given a distance and spacing. + function calculateSpiral(distance, spacing = 1, center) { + return { + x: (spacing * distance * Math.cos(distance)) + center.x, + y: (spacing * distance * Math.sin(distance)) + center.y, + }; + } + + // Make a list of points along a spiral. + function makeSpiral(turns, spacing, startOn, center) { + const start = startOn ? startOn * Math.PI * 2 : 0; + const points = []; + const stepAngle = Math.PI / 4; // We want at least 8 points per turn + + for (let i = start; i < turns * Math.PI * 2; i += stepAngle) { + points.push(calculateSpiral(i, spacing, center)); } - // Setup the spiral: - const viewBounds = new Rectangle(spiralBounds) || exp.project.view.bounds; - const spiral = new Path(); - const spacing = inputSpacing / (Math.PI * 2); + return points; + } - // This is the diagonal distance of the area in which we will be filling. - let boundsSize = viewBounds.topLeft.getDistance(viewBounds.bottomRight) / 2; + // Setup the spiral: + const viewBounds = new Rectangle(spiralBounds) || state.project.view.bounds; + const spiral = new Path(); + const spacing = inputSpacing / (Math.PI * 2); - // Add extra for offset. - boundsSize += viewBounds.center.getDistance(viewBounds.center.add(offset)) / 2; + // This is the diagonal distance of the area in which we will be filling. + let boundsSize = viewBounds.topLeft.getDistance(viewBounds.bottomRight) / 2; - // Estimate the number of turns based on the spacing and boundsSize - const turns = Math.ceil(boundsSize / (spacing * 2 * Math.PI)); + // Add extra for offset. + boundsSize += viewBounds.center.getDistance(viewBounds.center.add(offset)) / 2; - spiral.position = viewBounds.center.add(offset); - spiral.addSegments(makeSpiral(turns, spacing, null, spiral.position)); + // Estimate the number of turns based on the spacing and boundsSize + const turns = Math.ceil(boundsSize / (spacing * 2 * Math.PI)); - spiral.smooth(); + spiral.position = viewBounds.center.add(offset); + spiral.addSegments(makeSpiral(turns, spacing, null, spiral.position)); - // The last few segments are not spiralular so remove them - spiral.removeSegments(spiral.segments.length - 4); - return spiral; - }, -}; - -process.addListener('uncaughtException', (err) => { - console.error(err); - process.exit(1); -}); + spiral.smooth(); -module.exports = exp; + // The last few segments are not spiralular so remove them + spiral.removeSegments(spiral.segments.length - 4); + return spiral; +} diff --git a/src/components/core/drawing/vectorizers/squiggle/cncserver.drawing.vectorizer.schema.js b/src/components/core/drawing/vectorizers/squiggle/cncserver.drawing.vectorizer.schema.js index 7988b25b..a206baaa 100644 --- a/src/components/core/drawing/vectorizers/squiggle/cncserver.drawing.vectorizer.schema.js +++ b/src/components/core/drawing/vectorizers/squiggle/cncserver.drawing.vectorizer.schema.js @@ -132,9 +132,11 @@ const properties = { }, }; -module.exports = { +const schema = { type: 'object', - title: 'Squiggle method options', + title: 'Squiggle Density Vectorization', options: { collapsed: true }, properties, }; + +export default schema; diff --git a/src/components/core/drawing/vectorizers/squiggle/cncserver.drawing.vectorizers.squiggle.js b/src/components/core/drawing/vectorizers/squiggle/cncserver.drawing.vectorizers.squiggle.js index c108f2f4..9464cdd5 100644 --- a/src/components/core/drawing/vectorizers/squiggle/cncserver.drawing.vectorizers.squiggle.js +++ b/src/components/core/drawing/vectorizers/squiggle/cncserver.drawing.vectorizers.squiggle.js @@ -1,10 +1,15 @@ /** * @file Squiggle vectorizer! */ - -const { Group, Path } = require('paper'); -const util = require('../cncserver.drawing.vectorizers.util'); - +import Paper from 'paper'; +import { + connect, + finish, + generateSpiralPath, + map +} from '../cncserver.drawing.vectorizers.util.js'; + +const { Group, Path } = Paper; const components = { cyan: 'red', magenta: 'green', @@ -54,7 +59,7 @@ function renderSquiggleAlongPath(image, path, colorComponent) { const color = image.getAverageColor(area); if (color) { const lum = 1 - bright(color[components[colorComponent]], 0.1); - const amp = util.map(lum, 0, 1, 0, (spacing + overlap) / 2); + const amp = map(lum, 0, 1, 0, (spacing + overlap) / 2); const density = Math.ceil(maxDensity * lum); const pointSpacing = sampleWidth / density; @@ -101,7 +106,7 @@ function renderSquiggleAlongPath(image, path, colorComponent) { } // Connect to the main process, start the fill operation. -util.connect('paper', (image, rawSettings) => { +connect('paper', (image, rawSettings) => { settings = { ...rawSettings }; // Convert color components from the schema verifiable object into the array @@ -122,7 +127,9 @@ util.connect('paper', (image, rawSettings) => { if (style === 'lines') { // Build group with double length, double height lines to overlay const guideLines = new Group(); - for (let y = image.bounds.top + spacing / 2; y < image.bounds.bottom + image.bounds.height; y = y + spacing - overlap) { + const yTop = image.bounds.top + spacing / 2; + const yBottom = image.bounds.bottom + image.bounds.height; + for (let y = yTop; y < yBottom; y = y + spacing - overlap) { guideLines.addChild(new Path([ [image.bounds.left - image.bounds.width, y], [image.bounds.right, y], @@ -137,7 +144,7 @@ util.connect('paper', (image, rawSettings) => { guideLines.rotation = angle + ((360 / colorComponents.length) * index); // Render squiggles for each guide line. - guideLines.children.forEach((guideLine) => { + guideLines.children.forEach(guideLine => { // Flip every other line around for easy reconnection if (evenOdd === null) { evenOdd = true; @@ -161,12 +168,12 @@ util.connect('paper', (image, rawSettings) => { }); }); } else if (style === 'spiral') { - const spiral = util.generateSpiralPath(spacing, image.bounds, settings.squiggle.offset); + const spiral = generateSpiralPath(spacing, image.bounds, settings.squiggle.offset); colorComponents.forEach((colorComponent, index) => { spiral.rotation = angle + ((360 / colorComponents.length) * index); renderSquiggleAlongPath(image, spiral, colorComponent); }); } - util.finish(exportGroup); + finish(exportGroup); }); diff --git a/src/components/core/drawing/vectorizers/stipple/cncserver.drawing.vectorizer.schema.js b/src/components/core/drawing/vectorizers/stipple/cncserver.drawing.vectorizer.schema.js index 8791082f..fed47395 100644 --- a/src/components/core/drawing/vectorizers/stipple/cncserver.drawing.vectorizer.schema.js +++ b/src/components/core/drawing/vectorizers/stipple/cncserver.drawing.vectorizer.schema.js @@ -56,9 +56,11 @@ const properties = { }, }; -module.exports = { +const schema = { type: 'object', - title: 'Stipple method options', + title: 'Stipple Dot Vectorization', options: { collapsed: true }, properties, }; + +export default schema; diff --git a/src/components/core/drawing/vectorizers/stipple/cncserver.drawing.vectorizers.stipple.js b/src/components/core/drawing/vectorizers/stipple/cncserver.drawing.vectorizers.stipple.js index 495ed758..5a54f5e4 100644 --- a/src/components/core/drawing/vectorizers/stipple/cncserver.drawing.vectorizers.stipple.js +++ b/src/components/core/drawing/vectorizers/stipple/cncserver.drawing.vectorizers.stipple.js @@ -1,15 +1,16 @@ +/* eslint-disable no-param-reassign */ /** * @file Stipple vectorizer */ +import Paper from 'paper'; +import { exec } from 'child_process'; +import { tmpdir } from 'os'; +import fs from 'fs'; +import path from 'path'; +import * as utils from '../cncserver.drawing.vectorizers.util.js'; -const { Group } = require('paper'); -const { exec } = require('child_process'); -const { tmpdir } = require('os'); -const fs = require('fs'); -const path = require('path'); -const util = require('../cncserver.drawing.vectorizers.util'); - -const bin = path.resolve(__dirname, 'bin', process.platform, 'voronoi_stippler'); +const { Group } = Paper; +const bin = path.resolve('.', 'bin', process.platform, 'voronoi_stippler'); const tmp = tmpdir(); let settings = { }; // Globalize settings.vectorize > @@ -41,8 +42,8 @@ const buildOptions = () => { }; // Connect to the main process, start the vectorization operation. -util.connect('png', (input, rawSettings) => { - const output = path.resolve(tmp, `stipple_output_${util.info.hash}.svg`); +utils.connect('png', (input, rawSettings) => { + const output = path.resolve(tmp, `stipple_output_${utils.info.hash}.svg`); settings = { ...rawSettings.stipple, input, @@ -58,11 +59,11 @@ util.connect('png', (input, rawSettings) => { console.log('Executing:', fullExec); const child = exec(fullExec); - child.stderr.on('data', (data) => { + child.stderr.on('data', data => { console.error('ERROR:', data); }); - child.stdout.on('data', (data) => { + child.stdout.on('data', data => { if (data.includes('% Complete')) { const progress = Math.min(100, parseInt(data.split('%')[0], 10)); console.log('Progress:', progress); @@ -73,23 +74,23 @@ util.connect('png', (input, rawSettings) => { if (fs.existsSync(output)) { console.log('Importing file', output); - util.project.importSVG(output, { + utils.state.project.importSVG(output, { expandShapes: true, - onLoad: (group) => { + onLoad: group => { group.fitBounds(rawSettings.bounds); // Should be a flat list of circles converted to 4 segment paths. - group.children.forEach((item) => { + group.children.forEach(item => { item.strokeColor = item.fillColor; item.strokeWidth = 0.5; item.fillColor = null; }); - util.finish(group); + utils.finish(group); }, }); } else { console.log('No output file 😢', output); - util.finish(new Group()); + utils.finish(new Group()); } }); }); diff --git a/src/components/core/runner/cncserver.runner.ipc.js b/src/components/core/runner/cncserver.runner.ipc.js index 68ff2779..e0552657 100644 --- a/src/components/core/runner/cncserver.runner.ipc.js +++ b/src/components/core/runner/cncserver.runner.ipc.js @@ -2,9 +2,9 @@ * @file CNC Server runner IPC wrapper code, used to manage communication and * events between processes. */ -const ipc = require('node-ipc'); +import ipc from 'node-ipc'; -module.exports = (options) => { +export default function initIPC(options) { // Setup passed config. for (const [key, val] of Object.entries(options.config)) { ipc.config[key] = val; @@ -57,4 +57,4 @@ module.exports = (options) => { }; return exp; -}; +} diff --git a/src/components/core/runner/cncserver.runner.js b/src/components/core/runner/cncserver.runner.js index ff83fc66..40cac0be 100644 --- a/src/components/core/runner/cncserver.runner.js +++ b/src/components/core/runner/cncserver.runner.js @@ -7,10 +7,13 @@ * socket messages, always use the API to communicate via serial, not this. */ -// REQUIRES ==================================================================== -const { Readable, Writable } = require('stream'); -const serial = require('./cncserver.runner.serial'); -const ipc = require('./cncserver.runner.ipc')({ +// Imports ==================================================================== +import { Readable, Writable } from 'stream'; + +import * as serial from './cncserver.runner.serial.js'; +import initIPC from './cncserver.runner.ipc.js'; + +const ipc = initIPC({ ipcHost: 'cncserver', config: { id: 'cncrunner', @@ -56,7 +59,7 @@ function instructionStreamWrite(item, _, callback) { // Some items don't have any rendered commands, only run those that do! if (item.commands.length) { durationTimer = new Date(); - serial.writeMultiple(item.commands, (err) => { + serial.writeMultiple(item.commands, err => { setTimeout(() => { ipc.sendMessage('buffer.item.done', item.hash); state.instructionIsExecuting = false; @@ -135,7 +138,7 @@ const directRenderer = new Writable({ highWaterMark: 1, write: (item, _, callback) => { if (global.config.showSerial) console.log('DIRECT Stream writing!', item.commands); - serial.writeMultiple(item.commands, (err) => { + serial.writeMultiple(item.commands, err => { setTimeout(() => { callback(err); }, item.duration); @@ -167,7 +170,7 @@ state.setPaused = (paused, init = false) => { /** * Simulation state setter */ -state.setSimulation = (isSimulating) => { +state.setSimulation = isSimulating => { state.simulation = isSimulating; ipc.sendMessage('serial.simulation', isSimulating); }; @@ -202,7 +205,7 @@ serial.bindAll({ }, // Called on serial close if reconnect fails. - close: (err) => { + close: err => { console.log(err); console.log(`Serial Disconnected: ${err.toString()}`); ipc.sendMessage('serial.disconnected', { @@ -274,7 +277,7 @@ function gotMessage(packet) { } // Catch any uncaught error. -process.on('uncaughtException', (err) => { +process.on('uncaughtException', err => { // Assume Disconnection and kill the process. serial.triggerBind('disconnect', err); console.error('Uncaught error, disconnected from server, shutting down'); diff --git a/src/components/core/runner/cncserver.runner.serial.js b/src/components/core/runner/cncserver.runner.serial.js index 83b472c0..84ef6a3b 100644 --- a/src/components/core/runner/cncserver.runner.serial.js +++ b/src/components/core/runner/cncserver.runner.serial.js @@ -3,21 +3,26 @@ * serial writes and batch processes. */ // Base serialport module globals. -const SerialPort = require('serialport'); - +import SerialPort from 'serialport'; // State vars. let port; let simulation = true; +let bindings = {}; + // Event bindings object and helpers. -let bindings = { - triggerBind: (name, ...args) => { - if (bindings[name] && typeof bindings[name] === 'function') { - bindings[name](...args); - } - }, -}; +export function triggerBind(name, ...args) { + if (bindings[name] && typeof bindings[name] === 'function') { + bindings[name](...args); + } +} + +bindings = { triggerBind }; + +export function bindAll(newBindings = {}) { + bindings = { ...bindings, ...newBindings }; +} /** * Setter for simulation state change. @@ -27,7 +32,7 @@ let bindings = { * @param {function} callback * Callback when it should be sent/drained. */ -const setSimulation = (status) => { +const setSimulation = status => { simulation = !!status; bindings.triggerBind('simulation', simulation); }; @@ -40,7 +45,7 @@ const setSimulation = (status) => { * @param {function} callback * Callback when it should be sent/drained. */ -const write = (command, callback) => { +export function write(command, callback) { if (simulation) { if (global.config.showSerial) console.info(`Simulating serial write: ${command}`); setTimeout(() => { @@ -71,7 +76,7 @@ const write = (command, callback) => { if (callback) callback(err); } } -}; +} /** * Execute a set of commands representing a single buffer action item to write, @@ -83,7 +88,7 @@ const write = (command, callback) => { * @returns {boolean} * True if success, false if failure */ -const writeMultiple = (commands, callback, index = 0) => { +export function writeMultiple(commands, callback, index = 0) { // Ensure commands is an array if only one sent. if (typeof commands === 'string') { // eslint-disable-next-line no-param-reassign @@ -91,7 +96,7 @@ const writeMultiple = (commands, callback, index = 0) => { } // Run the command at the index. - write(commands[index], (err) => { + write(commands[index], err => { // eslint-disable-next-line no-param-reassign index++; // Increment the index. @@ -113,7 +118,7 @@ const writeMultiple = (commands, callback, index = 0) => { }); return true; -}; +} let retries = 0; const handleConnectionError = (err, options) => { @@ -124,7 +129,7 @@ const handleConnectionError = (err, options) => { retries++; console.log(`Serial connection to "${options.port}" failed, retrying ${retries}/${options.autoReconnectTries}`); setTimeout(() => { - module.exports.connect(options); + connect(options); }, options.autoReconnectRate); } else { setSimulation(true); @@ -134,13 +139,13 @@ const handleConnectionError = (err, options) => { }; // Exported connection function. -const connect = (options) => { +export function connect(options) { if (global.debug) console.log(`Connect to: ${JSON.stringify(options)}`); global.connectOptions = options; // Note: runner doesn't do autodetection. try { - port = new SerialPort(options.port, options, (err) => { + port = new SerialPort(options.port, options, err => { if (!err) { retries = 0; setSimulation(false); @@ -150,7 +155,7 @@ const connect = (options) => { // Send setup commands if (options.setupCommands.length) { console.log('Sending bot specific board setup...'); - writeMultiple(options.setupCommands, (error) => { + writeMultiple(options.setupCommands, error => { if (global.debug && error) { console.log(`SerialPort says: ${error.toString()}`); } @@ -162,7 +167,7 @@ const connect = (options) => { // Bind read, reconnect logic and close/disconnect. parser.on('data', bindings.read); - port.on('close', (error) => { + port.on('close', error => { if (error.disconnect) bindings.triggerBind('disconnect'); // If we got disconnected, throw to the try/catch for reconnect. handleConnectionError(error, options); @@ -174,15 +179,4 @@ const connect = (options) => { } catch (err) { handleConnectionError(err, options); } -}; - -// Build direct export object. -module.exports = { - connect, - write, - writeMultiple, - triggerBind: bindings.triggerBind, - bindAll: (newBindings = {}) => { - bindings = { ...bindings, ...newBindings }; - }, -}; +} diff --git a/src/components/core/runner/runner.html b/src/components/core/runner/runner.html deleted file mode 100644 index 108e9553..00000000 --- a/src/components/core/runner/runner.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - This file is meant to be the src of a webview tag, run in an Electron Shared node context. It won't do much outside of that :)
- See the implementation within RoboPaint for an idea of how this is meant to work. - - diff --git a/src/components/core/schemas/cncserver.schemas.color.js b/src/components/core/schemas/cncserver.schemas.color.js new file mode 100644 index 00000000..4a46f7e0 --- /dev/null +++ b/src/components/core/schemas/cncserver.schemas.color.js @@ -0,0 +1,121 @@ +/** + * @file Colorset item settings schema. + * + */ +/* eslint-disable max-len */ +const properties = { + id: { + type: 'string', + title: 'ID', + description: 'Tool or machine name for the color.', + }, + name: { + type: 'string', + title: 'Name of colorset item', + description: 'Description of this colorset item', + }, + color: { + type: 'string', + format: 'color', + title: 'Color', + description: 'Color that this item represents. Will be used to select areas to be printed with it.', + }, + printWeight: { + type: 'integer', + title: 'Print Weighting', + description: 'Sets printing order for each colorset item work grouping. Lower goes first, higher goes last.', + format: 'range', + minimum: -30, + maximum: 30, + default: 0, + }, + selectionMethod: { + type: 'string', + title: 'Selection Method', + description: 'When parsing the drawing, how should this be selected?', + enum: ['color', 'opacity', 'strokeWidth'], + options: { enum_titles: ['Color', 'Opacity', 'Stroke Width'] }, + default: 'color', + }, + selectionOverrides: { + type: 'array', + title: 'Selection Overrides', + description: 'Set of colors that will override all other selection methods.', + items: { + type: 'string', + format: 'color', + title: 'Path Color', + }, + default: [], + }, + colorWeight: { + type: 'number', + title: 'Color Weighting', + description: 'Amount to adjust for color selection preference. Smaller than 0 selects less often, larger than 0 selects more often.', + format: 'range', + minimum: -1, + maximum: 1, + step: 0.01, + default: 0, + options: { dependencies: { selectionMethod: 'color' } }, + }, + opacityMin: { + type: 'number', + title: 'Minimum Opacity', + description: 'Opacity must be ABOVE this for opacity selection method.', + format: 'range', + minimum: 0, + maximum: 0.99, + step: 0.01, + default: 0, + options: { dependencies: { selectionMethod: 'opacity' } }, + }, + opacityMax: { + type: 'number', + title: 'Maximum Opacity', + description: 'Opacity must be BELOW this for opacity selection method.', + format: 'range', + minimum: 0.01, + maximum: 1, + default: 0.75, + step: 0.01, + options: { dependencies: { selectionMethod: 'opacity' } }, + }, + strokeWidthMin: { + type: 'number', + title: 'Minimum Stroke Width', + description: 'Stroke width must be ABOVE this for stroke width selection method.', + format: 'range', + minimum: 0, + maximum: 39.99, + step: 0.01, + default: 0, + options: { dependencies: { selectionMethod: 'strokeWidth' } }, + }, + strokeWidthMax: { + type: 'number', + title: 'Maximum Stroke Width', + description: 'Stroke width must be BELOW this for stroke width selection method.', + format: 'range', + minimum: 0.01, + maximum: 40, + step: 0.01, + default: 2, + options: { dependencies: { selectionMethod: 'strokeWidth' } }, + }, + implement: { + type: 'string', + title: 'Implement', + description: 'Machine name of valid implement to draw with, or "[inherit]" to use colorset parent implement.', + default: '[inherit]', + }, +}; + +const schema = { + type: 'object', + title: 'Colorset Item', + required: ['color', 'name', 'id', 'selectionMethod'], + properties, +}; + +export default schema; diff --git a/src/components/core/schemas/cncserver.schemas.colors.js b/src/components/core/schemas/cncserver.schemas.colors.js new file mode 100644 index 00000000..02152138 --- /dev/null +++ b/src/components/core/schemas/cncserver.schemas.colors.js @@ -0,0 +1,66 @@ +/** + * @file Colorset settings schema. + * + */ + +const properties = { + name: { + type: 'string', + title: 'Machine Name', + description: 'Machine name for the colorset, set from title.', + }, + title: { + type: 'string', + title: 'Title', + description: 'Human readable name for the colorset.', + }, + manufacturer: { + type: 'string', + title: 'Manufacturer', + description: 'Creator of this set of colors.', + default: '', + }, + description: { + type: 'string', + title: 'Description', + description: 'Extra information about this colorset', + format: 'textarea', + default: '', + }, + sortWeight: { + type: 'integer', + title: 'Sort Weighting', + description: 'Sets display order for each colorset in the UI. Lower values rise, higher values sink.', + format: 'range', + minimum: -100, + maximum: 100, + default: 0, + }, + implement: { + type: 'string', + title: 'Implement', + description: 'Machine name of valid implement to draw with.', + default: 'sharpie-ultra-fine-marker', + }, + items: { + type: 'array', + title: 'Items', + description: 'Colorset items representing all the colors/implements used.', + items: {}, // Set in indexer as color schema. + }, + toolset: { + type: 'string', + title: 'Toolset', + description: 'Set of additional tools this colorset uses.', + default: 'default', + }, +}; + +const schema = { + type: 'object', + required: ['title'], + title: 'Colors', + properties, +}; + +export default schema; diff --git a/src/components/core/schemas/cncserver.schemas.content.js b/src/components/core/schemas/cncserver.schemas.content.js index 70604d2a..a73bc944 100644 --- a/src/components/core/schemas/cncserver.schemas.content.js +++ b/src/components/core/schemas/cncserver.schemas.content.js @@ -5,117 +5,145 @@ * interface restrictions and expectations for all data IO. */ /* eslint-disable max-len */ -module.exports = (cncserver) => { - const { base } = cncserver.drawing; - const bounds = base.defaultBounds(); - const properties = { - project: { - type: 'string', - title: 'Project', - description: 'Parent project for the content to live in, defaults to currently open project.', - }, - title: { - type: 'string', - title: 'Content Title', - description: 'Human readable name for the content.', - default: '', - }, - autoRender: { - type: 'boolean', - format: 'checkbox', - title: 'Auto Render', - description: 'Automatically re-render this content when updated.', - default: true, - }, - // settings: @see cncserver.schemas.content.settings.js - source: { - type: 'object', - title: 'Content Source', - properties: { - type: { - type: 'string', - title: 'Content type', - description: 'The type of content', - enum: ['svg', 'path', 'paper', 'raster', 'text'], - }, - url: { - type: 'string', - format: 'uri', - title: 'URL', - description: 'Location on the internet where the content can be found.', - }, - content: { - type: 'string', - title: 'Content Body', - description: 'String content of the specified type, or binary data URI for rasters', +import { defaultBounds, state as drawingBase } from 'cs/drawing/base'; +import { bindTo } from 'cs/binder'; + +const properties = { + project: { + type: 'string', + title: 'Project', + description: 'Parent project for the content to live in, defaults to currently open project.', + }, + title: { + type: 'string', + title: 'Content Title', + description: 'Human readable name for the content.', + default: '', + }, + autoRender: { + type: 'boolean', + format: 'checkbox', + title: 'Auto Render', + description: 'Automatically re-render this content when updated.', + default: true, + }, + // settings: @see cncserver.schemas.content.settings.js + source: { + type: 'object', + title: 'Content Source', + properties: { + type: { + type: 'string', + title: 'Content type', + description: 'The type of content', + enum: ['svg', 'path', 'paper', 'raster', 'text'], + options: { + enum_titles: [ + 'SVG XML', + 'SVG Path Data', + 'Paper.js JSON', + 'Raster Image', + 'Plain Text', + ], }, }, - /* if: { // TODO: This doesn't work, but it really should. - properties: { - type: { const: 'raster' }, - }, + url: { + type: 'string', + format: 'uri', + title: 'URL', + description: 'Location on the internet where the content can be found.', + }, + content: { + type: 'string', + title: 'Content Body', + description: 'String content of the specified type, or binary data URI for rasters', }, - then: { - properties: { - content: { pattern: '^(data:)([\w\/\+]+);(charset=[\w-]+|base64).*,(.*)' }, - }, - }, */ - oneOf: [ - { required: ['type', 'url'] }, - { required: ['type', 'content'] }, - ], }, - bounds: { - type: 'object', - title: 'Bounds rectangle', - description: 'Definition of a rectangle in mm to size the content to within the canvas.', + /* if: { // TODO: This doesn't work, but it really should. properties: { - x: { - type: 'number', - format: 'number', - title: 'X Point', - description: 'X coordinate of top left position of the rectangle.', - default: bounds.point.x, - minimum: 0, - maximum: base.size.width - 1, - }, - y: { - type: 'number', - format: 'number', - title: 'Y Point', - description: 'Y coordinate of top left position of the rectangle.', - default: bounds.point.y, - minimum: 0, - maximum: base.size.height - 1, - }, - width: { - type: 'number', - format: 'number', - title: 'Width', - description: 'Width of the rectangle.', - default: bounds.width, - minimum: 1, - maximum: base.size.width, - }, - height: { - type: 'number', - format: 'number', - title: 'Height', - description: 'Height of the rectangle.', - default: bounds.height, - minimum: 1, - maximum: base.size.height, - }, + type: { const: 'raster' }, }, }, - }; - - return { + then: { + properties: { + content: { pattern: '^(data:)([\w\/\+]+);(charset=[\w-]+|base64).*,(.*)' }, + }, + }, */ + oneOf: [ + { required: ['type', 'url'] }, + { required: ['type', 'content'] }, + ], + }, + bounds: { type: 'object', - title: 'Content', - description: 'Full definition of a single unit of content for a project.', - properties, - required: ['source'], - }; + title: 'Bounds rectangle', + description: 'Definition of a rectangle in mm to size the content to within the canvas.', + properties: { + x: { + type: 'number', + format: 'number', + title: 'X Point', + description: 'X coordinate of top left position of the rectangle.', + default: 0, // Set in bindTo below. + minimum: 0, + maximum: 1, // Set in bindTo below. + step: 0.1, + }, + y: { + type: 'number', + format: 'number', + title: 'Y Point', + description: 'Y coordinate of top left position of the rectangle.', + default: 0, // Set in bindTo below. + minimum: 0, + maximum: 1, // Set in bindTo below. + step: 0.1, + }, + width: { + type: 'number', + format: 'number', + title: 'Width', + description: 'Width of the rectangle.', + default: 1, // Set in bindTo below. + minimum: 1, + maximum: 2, // Set in bindTo below. + step: 0.1, + }, + height: { + type: 'number', + format: 'number', + title: 'Height', + description: 'Height of the rectangle.', + default: 1, // Set in bindTo below. + minimum: 1, + maximum: 2, // Set in bindTo below. + step: 0.1, + }, + }, + }, }; + +// Set the schema level content boundaries after they've been defined. +bindTo('paper.ready', 'schemas.content', () => { + const bounds = defaultBounds(); + properties.bounds.properties.x.default = bounds.point.x; + properties.bounds.properties.y.default = bounds.point.y; + properties.bounds.properties.width.default = bounds.width; + properties.bounds.properties.height.default = bounds.height; + + properties.bounds.properties.x.maximum = drawingBase.size.width - 1; + properties.bounds.properties.y.maximum = drawingBase.size.height - 1; + properties.bounds.properties.width.maximum = drawingBase.size.width; + properties.bounds.properties.height.maximum = drawingBase.size.height; +}); + +const schema = { + type: 'object', + title: 'Content', + description: 'Full definition of a single unit of content for a project.', + properties, + required: ['source'], +}; + +export default schema; diff --git a/src/components/core/schemas/cncserver.schemas.content.settings.js b/src/components/core/schemas/cncserver.schemas.content.settings.js index 0aa20309..26854107 100644 --- a/src/components/core/schemas/cncserver.schemas.content.settings.js +++ b/src/components/core/schemas/cncserver.schemas.content.settings.js @@ -3,10 +3,12 @@ * * This builds a conglomerate render settings schema based on keys passed in. */ -module.exports = properties => ({ - type: 'object', - title: 'Settings', - format: 'categories', - description: 'All render specific settings overrides', - properties, -}); +export default function settingsSchema(properties) { + return { + type: 'object', + title: 'Settings', + format: 'categories', + description: 'All render specific settings overrides', + properties, + }; +} diff --git a/src/components/core/schemas/cncserver.schemas.fill.js b/src/components/core/schemas/cncserver.schemas.fill.js index 0d6f0942..f42cd493 100644 --- a/src/components/core/schemas/cncserver.schemas.fill.js +++ b/src/components/core/schemas/cncserver.schemas.fill.js @@ -7,104 +7,116 @@ * All supplied fill schemas are merged into this one. */ /* eslint-disable max-len */ -const fs = require('fs'); -const path = require('path'); +import fs from 'fs'; +import path from 'path'; +import { __basedir } from 'cs/utils'; const fillerSchema = 'cncserver.drawing.filler.schema.js'; -module.exports = () => { - const globalSchema = { - render: { - type: 'boolean', - format: 'checkbox', - title: 'Render', - description: 'Whether fill should be rendered, set false to skip.', - default: true, - }, - cutoutOcclusion: { - type: 'boolean', - format: 'checkbox', - title: 'Cutout Occlusion', - description: 'Whether fill spaces will be cut out depending on overlapping fills', - default: true, - }, - trace: { - type: 'boolean', - format: 'checkbox', - title: 'Trace Fill', - description: 'Whether fill object will get a stroke of the fill color', - default: false, - }, - method: { - type: 'string', - title: 'Fill Method', - description: 'The method used to turn a fill into paths.', - default: 'offset', - enum: [], // Filled below from available filler schemas. - }, - flattenResolution: { - type: 'number', - title: 'Flatten Resolution', - description: 'How much detail is preserved when converting curves. Smaller is higher resolution but less performant.', - default: 0.25, - minimum: 0.01, - maximum: 5, - }, - rotation: { - type: 'number', - format: 'range', - title: 'Rotation', - description: 'If applicable, the rotation for a fill method.', - default: 28, - minimum: -360, - maximum: 360, - }, - randomizeRotation: { - type: 'boolean', - format: 'checkbox', - title: 'Randomize Rotation', - description: 'If set, the rotation setting will be ignored and a single random angle will be selected for each fill.', - default: false, - }, - inset: { - type: 'number', - format: 'range', - title: 'Inset', - description: 'The number of mm to negatively offset a fill path, allowing for space between outside stroke and internal size.', - default: 0, - minimum: -50, - maximum: 50, - }, - spacing: { - type: 'number', - format: 'range', - title: 'Spacing', - description: 'If applicable, the amount of space between items in MM, lower number is higher density.', - default: 3, - minimum: 0.1, - maximum: 100, - }, - }; +const globalSchema = { + render: { + type: 'boolean', + format: 'checkbox', + title: 'Render', + description: 'Whether fill should be rendered, set false to skip.', + default: true, + }, + cutoutOcclusion: { + type: 'boolean', + format: 'checkbox', + title: 'Cutout Occlusion', + description: 'Whether fill spaces will be cut out depending on overlapping fills', + default: true, + }, + trace: { + type: 'boolean', + format: 'checkbox', + title: 'Trace Fill', + description: 'Whether fill object will get a stroke of the fill color', + default: false, + }, + method: { + type: 'string', + title: 'Fill Method', + description: 'The method used to turn a fill into paths.', + default: 'offset', + enum: [], // Filled below from available filler schemas. + options: { enum_titles: [] }, + }, + flattenResolution: { + type: 'number', + title: 'Flatten Resolution', + description: 'How much detail is preserved when converting curves. Smaller is higher resolution but less performant.', + default: 0.25, + minimum: 0.01, + step: 0.01, + maximum: 5, + }, + rotation: { + type: 'number', + format: 'range', + title: 'Rotation', + description: 'If applicable, the rotation for a fill method.', + default: 28, + minimum: -360, + maximum: 360, + step: 0.1, + }, + randomizeRotation: { + type: 'boolean', + format: 'checkbox', + title: 'Randomize Rotation', + description: 'If set, the rotation setting will be ignored and a single random angle will be selected for each fill.', + default: false, + }, + inset: { + type: 'number', + format: 'range', + title: 'Inset', + description: 'The number of mm to negatively offset a fill path, allowing for space between outside stroke and internal size.', + default: 0, + minimum: -50, + maximum: 50, + step: 0.1, + }, + spacing: { + type: 'number', + format: 'range', + title: 'Spacing', + description: 'If applicable, the amount of space between items in MM, lower number is higher density.', + default: 3, + minimum: 0.1, + maximum: 100, + step: 0.1, + }, +}; + +// Compile list of all other filler modules and their schemas and merge them. +const fillerPath = path.resolve(__basedir, 'components', 'core', 'drawing', 'fillers'); +fs.readdirSync(fillerPath).map(dir => { + const fullPath = path.resolve(fillerPath, dir); + if (fs.lstatSync(fullPath).isDirectory()) { + const schemaPath = path.resolve(fullPath, fillerSchema); + if (fs.existsSync(schemaPath)) { + globalSchema.method.enum.push(dir); - // Compile list of all other filler modules and their schemas and merge them. - const fillerPath = path.resolve(__dirname, '..', 'drawing', 'fillers'); - fs.readdirSync(fillerPath).map((dir) => { - const fullPath = path.resolve(fillerPath, dir); - if (fs.lstatSync(fullPath).isDirectory()) { - const schemaPath = path.resolve(fullPath, fillerSchema); - if (fs.existsSync(schemaPath)) { - globalSchema.method.enum.push(dir); - // eslint-disable-next-line - const fillerSchemaObject = require(schemaPath); + import(schemaPath).then(({ default: fillerSchemaObject }) => { + globalSchema.method.options.enum_titles.push(fillerSchemaObject.title); // Only add if filler defines custom props. if (Object.entries(fillerSchemaObject.properties).length) { globalSchema[dir] = fillerSchemaObject; globalSchema[dir].options = { dependencies: { method: dir } }; } - } + }); } - }); + } +}); - return { type: 'object', title: 'Fill', properties: globalSchema }; +const schema = { + type: 'object', + title: 'Fill', + properties: globalSchema, }; + +export default schema; diff --git a/src/components/core/schemas/cncserver.schemas.implements.js b/src/components/core/schemas/cncserver.schemas.implements.js new file mode 100644 index 00000000..543f4898 --- /dev/null +++ b/src/components/core/schemas/cncserver.schemas.implements.js @@ -0,0 +1,109 @@ +/** + * @file Colorset item implement schema. + * + */ +/* eslint-disable max-len */ +const schema = { + type: 'object', + title: 'Implement Details', + required: ['type', 'name'], + properties: { + type: { + type: 'string', + title: 'Type', + description: 'Type of implement', + enum: ['brush', 'pen', 'other'], + default: 'pen', + }, + manufacturer: { + type: 'string', + title: 'Manufacturer', + description: 'Creator of this implement.', + default: 'generic', + }, + name: { + type: 'string', + title: 'Name', + description: 'Machine readable name of the implement.', + }, + title: { + type: 'string', + title: 'Title', + description: 'Human readable name of the implement.', + }, + sortWeight: { + type: 'integer', + title: 'Sort Weighting', + description: 'Sets display order for each implement in the UI. Higher values sink, lower values rise.', + format: 'range', + minimum: -100, + maximum: 100, + default: 0, + }, + handleWidth: { + type: 'number', + title: 'Handle Width', + description: 'Measured width of the handle, to account for center draw position offset.', + format: 'range', + minimum: 2, + maximum: 20, + default: 10, + step: 0.01, + }, + handleColors: { + type: 'array', + title: 'Handle Color(s)', + description: 'Color of the handle for the implement, helps with physical user selection.', + items: { + type: 'string', + format: 'color', + title: 'Color', + }, + default: ['#000000'], + }, + width: { + type: 'number', + title: 'Width', + description: 'Calculated maximum effective width in mm of the implement being used.', + format: 'range', + minimum: 0.01, + maximum: 35, + default: 1, + step: 0.01, + }, + length: { + type: 'number', + title: 'Length', + description: 'Dynamic fulcrum length for the implement. Set to bristle length in mm for brushes.', + format: 'range', + minimum: 0, + maximum: 35, + default: 0, + step: 0.01, + }, + stiffness: { + type: 'number', + title: 'Stiffness', + description: 'Stiffness of dynamic fulcrum length. Set to 1 for fully stiff (same as length 0), 1 for fully loose (soft bristles).', + format: 'range', + minimum: 0, + maximum: 1, + default: 1, + options: { dependencies: { type: ['brush', 'other'] } }, + step: 0.01, + }, + drawLength: { + type: 'number', + title: 'Draw Length', + description: 'Distance in mm the implement can draw before it needs to be refreshed.', + format: 'range', + minimum: 0, + maximum: 3000, + default: 0, + options: { dependencies: { type: 'brush' } }, + step: 0.1, + }, + }, +}; + +export default schema; diff --git a/src/components/core/schemas/cncserver.schemas.js b/src/components/core/schemas/cncserver.schemas.js index bd4d2748..9ebb3627 100644 --- a/src/components/core/schemas/cncserver.schemas.js +++ b/src/components/core/schemas/cncserver.schemas.js @@ -1,138 +1,134 @@ /** * @file Wrapper module for verifying data to schemas, and providing errors. */ -const Ajv = require('ajv'); - -// Global core component export object to be attached. -const schemas = { id: 'schemas', all: {} }; - -module.exports = (cncserver) => { - const ajv = Ajv({ - allErrors: true, - removeAdditional: true, - unknownFormats: ['checkbox', 'color', 'range', 'tabs', 'categories', 'number'], - }); - - // Format the AJV field error messages. - function formatMessages(errors) { - const fields = errors.reduce((acc, e) => { - if (e.dataPath.length && e.dataPath[0] === '.') { - const av = e.params.allowedValues ? `: ${e.params.allowedValues.join(', ')}` : ''; - acc[e.dataPath.slice(1)] = [`${e.message}${av}`]; - } else { - acc[e.dataPath] = [e.message]; - } - return acc; - }, - {}); +import Ajv from 'ajv'; +import { trigger, bindTo } from 'cs/binder'; +import schemas from 'cs/schemas/index'; +import { applyObjectTo } from 'cs/utils'; + +const ajv = Ajv({ + allErrors: true, + removeAdditional: true, + unknownFormats: [ + 'checkbox', 'color', 'range', 'tabs', 'categories', 'number', 'textarea' + ], +}); + +// Format the AJV field error messages. +function formatMessages(errors) { + const fields = errors.reduce((acc, e) => { + if (e.dataPath.length && e.dataPath[0] === '.') { + const av = e.params.allowedValues ? `: ${e.params.allowedValues.join(', ')}` : ''; + acc[e.dataPath.slice(1)] = [`${e.message}${av}`]; + } else { + acc[e.dataPath] = [e.message]; + } + return acc; + }, {}); + + return { fields }; +} + +// Fill in defaults of the entire schema, forked from: +// https://github.com/harmvandendorpel/json-schema-fill-defaults +function autoDefaults(data, schema) { + function processNode(schemaNode, dataNode) { + switch (schemaNode.type) { + case 'object': + // eslint-disable-next-line no-use-before-define + return processObject(schemaNode, dataNode); + + case 'array': + // eslint-disable-next-line no-use-before-define + return processArray(schemaNode, dataNode); + + default: + if (dataNode !== undefined) return dataNode; + if (schemaNode.default !== undefined) return schemaNode.default; + return undefined; + } + } - return { fields }; + function forOwn(object, callback) { + Object.keys(object).map(key => callback(object[key], key, object)); } - // Fill in defaults of the entire schema, forked from: - // https://github.com/harmvandendorpel/json-schema-fill-defaults - function autoDefaults(data, schema) { - function processNode(schemaNode, dataNode) { - switch (schemaNode.type) { - case 'object': - return processObject(schemaNode, dataNode); // eslint-disable-line no-use-before-define - - case 'array': - return processArray(schemaNode, dataNode); // eslint-disable-line no-use-before-define - - default: - if (dataNode !== undefined) return dataNode; - if (schemaNode.default !== undefined) return schemaNode.default; - return undefined; - } + function processObject(schemaNode, dataNode) { + const result = {}; + // If no properties, pick the first oneOf. + if (!schemaNode.properties) { + // eslint-disable-next-line no-param-reassign + schemaNode.properties = schemaNode.oneOf[0].properties; } + forOwn(schemaNode.properties, (propertySchema, propertyName) => { + const nodeValue = dataNode !== undefined ? dataNode[propertyName] : undefined; + result[propertyName] = processNode(propertySchema, nodeValue); + }); - function forOwn(object, callback) { - Object.keys(object).map(key => callback(object[key], key, object)); + if (dataNode) { + forOwn(dataNode, (propertyValue, propertyName) => { + if (result[propertyName] === undefined && propertyValue !== undefined) { + result[propertyName] = propertyValue; + } + }); } + return result; + } - function processObject(schemaNode, dataNode) { - const result = {}; - // If no properties, pick the first oneOf. - if (!schemaNode.properties) { - schemaNode.properties = schemaNode.oneOf[0].properties; + function processArray(schemaNode, dataNode) { + if (dataNode === undefined) { + if (schemaNode.default) { + return schemaNode.default; } - forOwn(schemaNode.properties, (propertySchema, propertyName) => { - const nodeValue = dataNode !== undefined ? dataNode[propertyName] : undefined; - result[propertyName] = processNode(propertySchema, nodeValue); - }); - if (dataNode) { - forOwn(dataNode, (propertyValue, propertyName) => { - if (result[propertyName] === undefined && propertyValue !== undefined) { - result[propertyName] = propertyValue; - } - }); - } - return result; + return undefined; } - function processArray(schemaNode, dataNode) { - if (dataNode === undefined) { - if (schemaNode.default) { - return schemaNode.default; - } - - return undefined; - } + const result = []; - const result = []; - - for (let i = 0; i < dataNode.length; i++) { - result.push(processNode(schemaNode.items, dataNode[i])); - } - return result; + for (let i = 0; i < dataNode.length; i++) { + result.push(processNode(schemaNode.items, dataNode[i])); } - - return processNode(schema, data); + return result; } - // Add all schemas captured via binder. - schemas.all = cncserver.binder.trigger('schemas.compile', schemas.all); - - // After controller load, load all file schemas from index. - cncserver.binder.bindTo('controller.setup', schemas.id, () => { - // eslint-disable-next-line global-require - schemas.all = { ...schemas.all, ...require('./index')(cncserver) }; + return processNode(schema, data); +} - // Compile final list of schemas, registered by their key. - Object.entries(schemas.all).forEach(([key, schema]) => { - ajv.addSchema(schema, key); - }); +// Add all schemas captured via binder. +// applyObjectTo(trigger('schemas.compile', schemas), schemas); - cncserver.binder.trigger('schemas.loaded'); +// After controller load, load all file schemas from index. +bindTo('controller.setup', 'schemas', () => { + // Compile final list of schemas, registered by their key. + Object.entries(schemas).forEach(([key, schema]) => { + ajv.addSchema(schema, key); }); - // Get a completely filled out default data object, with passed modifier object. - schemas.getDataDefault = (type, data) => autoDefaults(data, schemas.all[type]); - - // Get a full schema by name. - schemas.getFromRequest = (path) => { - const type = path.split('/')[2]; - return ajv.getSchema(type) ? ajv.getSchema(type).schema : null; - }; - - // Validate data given a specific schema by name from above. - schemas.validateData = (type, data, provideDefaults = false) => new Promise( - (resolve, reject) => { - const isValid = ajv.validate(type, data); - if (isValid) { - if (provideDefaults) { - resolve(schemas.getDataDefault(type, data)); - } else { - resolve(data); - } + trigger('schemas.loaded', null, true); +}); + +// Get a completely filled out default data object, with passed modifier object. +export const getDataDefault = (type, data) => autoDefaults(data, schemas[type]); + +// Get a full schema by name. +export function getFromRequest(path) { + const type = path.split('/')[2]; + return ajv.getSchema(type) ? ajv.getSchema(type).schema : null; +} + +// Validate data given a specific schema by name from above. +export const validateData = (type, data, provideDefaults = false) => new Promise( + (resolve, reject) => { + const isValid = ajv.validate(type, data); + if (isValid) { + if (provideDefaults) { + resolve(getDataDefault(type, data)); } else { - reject(formatMessages(ajv.errors)); + resolve(data); } + } else { + reject(formatMessages(ajv.errors)); } - ); - - - return schemas; -}; + } +); diff --git a/src/components/core/schemas/cncserver.schemas.path.js b/src/components/core/schemas/cncserver.schemas.path.js index fa2b5f21..b93ead94 100644 --- a/src/components/core/schemas/cncserver.schemas.path.js +++ b/src/components/core/schemas/cncserver.schemas.path.js @@ -6,7 +6,7 @@ * */ /* eslint-disable max-len */ -module.exports = () => ({ +const schema = { type: 'object', title: 'Path', properties: { @@ -25,4 +25,6 @@ module.exports = () => ({ default: 'black', }, }, -}); +}; + +export default schema; diff --git a/src/components/core/schemas/cncserver.schemas.print.js b/src/components/core/schemas/cncserver.schemas.print.js new file mode 100644 index 00000000..4c2057ef --- /dev/null +++ b/src/components/core/schemas/cncserver.schemas.print.js @@ -0,0 +1,166 @@ +/** +* @file Print settings schema. +*/ +/* eslint-disable max-len */ + +const properties = { + hash: { + type: 'string', + title: 'Hash of settings to validate differences', + description: 'Machine name for the colorset, set from title.', + }, + title: { + type: 'string', + title: 'Title', + description: 'Human readable name for the print and its settings.', + maxLength: 200, + }, + description: { + type: 'string', + title: 'Description', + description: 'Description of this print.', + }, + botType: { + type: 'string', + title: 'Bot Type', + description: 'Type of bot expected', + }, + settings: { + type: 'object', + title: 'Print Settings', + properties: { + parkAfter: { + type: 'boolean', + title: 'Park After Print?', + description: 'When true, after print completes the bot will park.', + format: 'checkbox', + default: true, + }, + planning: { + type: 'object', + title: 'Path Planning', + description: 'Methods of path planning.', + properties: { + method: { + type: 'string', + title: 'Method', + enum: ['linear', 'dvl'], + options: { + enum_titles: [ + 'Linear (single speed)', + 'Dynamic Vector Lookahead (DVL)', + ], + }, + default: 'dvl', + }, + linear: { + type: 'object', + title: 'Linear movement options', + properties: { + moveSpeed: { + type: 'number', + title: 'Moving Speed', + description: 'Percentage of maximum speed of the carriage while moving above the work (not drawing).', + format: 'range', + step: 0.1, + minimum: 1, + maximum: 100, + default: 40, + }, + drawSpeed: { + type: 'number', + title: 'Draw Speed', + description: 'Percentage of maximum speed of the carriage while drawing the work.', + format: 'range', + step: 0.1, + minimum: 1, + maximum: 100, + default: 25, + }, + }, + }, + dvl: { + type: 'object', + title: 'DVL movement options', + properties: { + accelRate: { + type: 'number', + title: 'Accelleration Rate', + description: 'Percentage of maximum speed to increase over given time/distance.', + format: 'range', + step: 0.1, + minimum: 1, + maximum: 80, + default: 25, + }, + speedMultiplyer: { + type: 'number', + title: 'Moment conversion factor', + description: 'Factor for converting moment length to velocity', + format: 'range', + step: 0.01, + minimum: 0.1, + maximum: 1, + default: 0.75, + }, + minSpeed: { + type: 'number', + title: 'Minimum Speed', + description: 'Lowest speed to move at for detailed work.', + format: 'range', + step: 0.01, + minimum: 1, + maximum: 50, + default: 15, + }, + resolution: { + type: 'number', + title: 'Step Resolution', + description: 'Resolution of step breakdown, in MM. Smaller numbers give more resolution.', + format: 'range', + step: 0.01, + minimum: 0.01, + maximum: 2, + default: 0.5, + }, + maxDeflection: { + type: 'number', + title: 'Maximum Deflection', + description: 'Maximum angle of deflection to allow before slowing down around tight corners.', + format: 'range', + step: 0.1, + minimum: 0.5, + maximum: 20, + default: 10, + }, + }, + }, + }, + }, + wcb: { + type: 'object', + title: 'Watercolor Settings', + properties: { + wash: { + type: 'string', + title: 'Wash', + enum: ['full', 'light'], + }, + // TODO: + // Wash settings + // Reink Settings + /// etc? + }, + }, + }, + }, +}; + +const schema = { + type: 'object', + required: [''], + title: 'Print', + properties, +}; + +export default schema; diff --git a/src/components/core/schemas/cncserver.schemas.projects.js b/src/components/core/schemas/cncserver.schemas.projects.js index 9628e839..fadd1453 100644 --- a/src/components/core/schemas/cncserver.schemas.projects.js +++ b/src/components/core/schemas/cncserver.schemas.projects.js @@ -5,55 +5,101 @@ * interface restrictions and expectations for all data IO. */ /* eslint-disable max-len */ -module.exports = () => { - const properties = { - /* hash: { - type: 'string', - title: 'Hash ID', - description: 'Initial hash for the project, used as a UUID', - }, - created: { - type: 'string', - format: 'date', - title: 'Created date', - description: 'When the project was last edited.', - }, - updated: { - type: 'string', - format: 'date', - title: 'Updated date', - description: 'When the project was last edited.', - }, */ // These are computed 🤔 - name: { - type: 'string', - title: 'Machine name', - description: 'Machine name of the project, if not given, comes from title.', - }, - title: { - type: 'string', - title: 'Project Title', - description: 'Full title of the project.', - default: 'New Project', - }, - description: { - type: 'string', - title: 'Description', - description: 'Full text describing the project.', - }, - open: { - type: 'boolean', - format: 'checkbox', - title: 'Open After Creation', - description: 'Will open new project by default. Set to false to create in the background.', - default: true, +const properties = { + /* hash: { + type: 'string', + title: 'Hash ID', + description: 'Initial hash for the project, used as a UUID', + }, + created: { + type: 'string', + format: 'date', + title: 'Created date', + description: 'When the project was last edited.', + }, + updated: { + type: 'string', + format: 'date', + title: 'Updated date', + description: 'When the project was last edited.', + }, */ // These are computed 🤔 + name: { + type: 'string', + title: 'Machine name', + description: 'Machine name of the project, if not given, comes from title.', + }, + title: { + type: 'string', + title: 'Project Title', + description: 'Full title of the project.', + default: 'New Project', + }, + description: { + type: 'string', + title: 'Description', + description: 'Full text describing the project.', + format: 'textarea', + }, + open: { + type: 'boolean', + format: 'checkbox', + title: 'Open After Creation', + description: 'Will open new project by default. Set to false to create in the background.', + default: true, + }, + colorset: { + type: 'string', + title: 'Suggested Colorset', + description: 'Colorset preset attached to the project', + default: 'default-single-pen', + }, + options: { + type: 'object', + title: 'Project Options', + description: 'Customizable settings specific to this project', + properties: { + paper: { + type: 'object', + title: 'Paper Options', + description: 'Customizable settings specific to this project', + properties: { + color: { + type: 'string', + format: 'color', + title: 'Paper Color', + description: 'The assumed color of the paper, for preview and ignoring.', + default: '#FFFFFF', + }, + ignore: { + type: 'boolean', + format: 'checkbox', + title: 'Ignore Paper Color', + description: 'Attempt to match items to the paper color to prevent rendering them.', + default: true, + }, + ignoreColorWeight: { + type: 'number', + title: 'Ignore Color Weighting', + description: 'Amount to adjust for color selection preference. Smaller than 0 selects less often, larger than 0 selects more often.', + format: 'range', + minimum: -1, + maximum: 1, + default: -0.5, + step: 0.01, + options: { dependencies: { ignore: true } }, + }, + }, + }, }, - // settings: @see cncserver.schemas.content.settings.js - }; + }, + // settings: @see cncserver.schemas.content.settings.js +}; - return { - type: 'object', - properties, - title: 'Project', - description: 'Full definition of a project that holds content', - }; +const schema = { + type: 'object', + properties, + title: 'Project', + description: 'Full definition of a project that holds content', }; + +export default schema; diff --git a/src/components/core/schemas/cncserver.schemas.stroke.js b/src/components/core/schemas/cncserver.schemas.stroke.js index 37c8fbb1..802406e4 100644 --- a/src/components/core/schemas/cncserver.schemas.stroke.js +++ b/src/components/core/schemas/cncserver.schemas.stroke.js @@ -6,7 +6,7 @@ * */ /* eslint-disable max-len */ -const strokeSchema = { +const properties = { render: { type: 'boolean', format: 'checkbox', @@ -23,4 +23,10 @@ const strokeSchema = { }, }; -module.exports = () => ({ type: 'object', title: 'Stroke', properties: strokeSchema }); +const schema = { + type: 'object', + title: 'Stroke', + properties, +}; + +export default schema; diff --git a/src/components/core/schemas/cncserver.schemas.text.js b/src/components/core/schemas/cncserver.schemas.text.js index 2643deeb..2b8005d5 100644 --- a/src/components/core/schemas/cncserver.schemas.text.js +++ b/src/components/core/schemas/cncserver.schemas.text.js @@ -5,99 +5,137 @@ * system fonts. */ /* eslint-disable max-len */ -const fontList = require('font-list'); +import fontList from 'font-list'; +import { bindTo } from 'cs/binder'; -module.exports = (cncserver) => { - const properties = { - render: { - type: 'boolean', - format: 'checkbox', - title: 'Render', - description: 'Render text', - default: true, +const properties = { + render: { + type: 'boolean', + format: 'checkbox', + title: 'Render', + description: 'Render text', + default: true, + }, + spaceWidth: { + type: 'number', + format: 'range', + default: 0, + minimum: -1000, + maximum: 1000, + title: 'Space width adjustment', + description: 'Units to add/remove from the internal font width.', + }, + wrapLines: { + type: 'boolean', + format: 'checkbox', + default: true, + title: 'Automatically wrap long lines', + description: 'If enabled, lines beyond textwrap width will automatically move to the next line.', + }, + wrapChars: { + type: 'integer', + format: 'range', + default: 60, + minimum: 1, + maximum: 500, + title: 'Textwrap width (in spaces)', + description: 'Number of space character widths to wait before text length triggers a newline.', + }, + font: { + type: 'string', + default: 'hershey_sans_1', + enum: ['temp'], // Set below after fonts have loaded. + title: 'Stroke Font', + description: 'Which EMS/SVG stroke font to use to render the text.', + options: { + enum_titles: ['temp'], }, - spaceWidth: { - type: 'number', - default: 20, - title: 'Space width', - description: 'Width of the space character.', - }, - font: { - type: 'string', - default: 'hershey_sans_1', - enum: Object.keys(cncserver.drawing.text.fonts), - title: 'Stroke Font', - description: 'Which EMS/SVG stroke font to use to render the text.', - }, - systemFont: { - type: 'string', - default: '', - title: 'System Font', - description: 'Which system font to render the text with. Overrides stroke font settings, creates filled text.', - }, - lineHeight: { - type: 'number', - default: 90, - title: 'Line height', - description: 'Height of each line when more than one line of text is given.', - }, - character: { - type: 'object', - title: 'Character settings', - description: 'Options that effect individual characters', - options: { collapsed: true }, - properties: { - rotation: { - type: 'number', - format: 'range', - default: 0, - minimum: -360, - maximum: 360, - title: 'Rotation', - description: 'Amount to rotate each character.', - }, - spacing: { - type: 'number', - format: 'range', - minimum: -100, - maximum: 100, - default: 18, - title: 'Spacing', - description: 'Amount of space given to each character after the width of the previous.', - }, + }, + systemFont: { + type: 'string', + default: '', + title: 'System Font', + description: 'Which system font to render the text with. Overrides stroke font settings, creates filled text.', + }, + lineHeight: { + type: 'number', + default: 0, + format: 'range', + minimum: -1000, + maximum: 1000, + title: 'Line height adjustment', + description: 'Adjustment to the height of each line when more than one line of text is given or computed.', + }, + character: { + type: 'object', + title: 'Character settings', + description: 'Options that effect individual characters', + options: { collapsed: true }, + properties: { + rotation: { + type: 'number', + format: 'range', + default: 0, + minimum: -360, + maximum: 360, + title: 'Rotation', + description: 'Amount to rotate each character.', }, - }, - align: { - type: 'object', - title: 'Text alignment', - description: 'Options for aligning the text.', - options: { collapsed: true }, - properties: { - paragraph: { - type: 'string', - enum: ['left', 'right', 'center'], - default: 'left', - title: 'Paragraph', - description: 'When more than one line of text exists, how to align the text within the bounds.', - }, - // TODO: What other alignment options do we need? + spacing: { + type: 'number', + format: 'range', + default: 0, + minimum: -1000, + maximum: 1000, + title: 'Spacing', + description: 'Amount of space given to each character after the width of the previous.', }, }, - rotation: { - type: 'number', - format: 'range', - default: 0, - minimum: -360, - maximum: 360, - title: 'Text rotation', - description: 'Angle to rotate the entire text object within the bounds.', + }, + align: { + type: 'object', + title: 'Text alignment', + description: 'Options for aligning the text.', + options: { collapsed: true }, + properties: { + paragraph: { + type: 'string', + enum: ['left', 'right', 'center'], + options: { enum_titles: ['Left', 'Center', 'Right'] }, + default: 'left', + title: 'Paragraph', + description: 'When more than one line of text exists, how to align the text within the bounds.', + }, + // TODO: What other alignment options do we need? }, - }; + }, + rotation: { + type: 'number', + format: 'range', + default: 0, + minimum: -360, + maximum: 360, + step: 0.1, + title: 'Text rotation', + description: 'Angle to rotate the entire text object within the bounds.', + }, +}; - // Add system font enum. - fontList.getFonts().then((fonts) => { - properties.systemFont.enum = ['', ...fonts.map(s => s.replace(/"/g, ''))]; - }); +// Add system font enum. +fontList.getFonts().then(fonts => { + properties.systemFont.enum = ['', ...fonts.map(s => s.replace(/"/g, ''))]; +}); - return { type: 'object', title: 'Text', properties }; +// Add SVG font enum. +bindTo('drawing.text.setup', 'schemas.text', fonts => { + properties.font.enum = Object.keys(fonts); + properties.font.options.enum_titles = Object.values(fonts).map(font => font.name); +}); + +const schema = { + type: 'object', + title: 'Text', + properties, }; + +export default schema; diff --git a/src/components/core/schemas/cncserver.schemas.tools.js b/src/components/core/schemas/cncserver.schemas.tools.js new file mode 100644 index 00000000..e8ded62a --- /dev/null +++ b/src/components/core/schemas/cncserver.schemas.tools.js @@ -0,0 +1,83 @@ +/** + * @file Individual Tool schema. + * + */ +/* eslint-disable max-len */ +const schema = { + type: 'object', + title: 'Tool', + description: 'A tool defines a specific location (and optionally area) outside of the workspace to change a property of the current drawing implement.', + required: ['id', 'x', 'y'], + properties: { + id: { + type: 'string', + title: 'ID', + description: 'Machine name for colorset tool.', + }, + title: { + type: 'string', + title: 'Title', + description: 'Human readable name for the tool.', + }, + x: { + type: 'number', + format: 'number', + title: 'X Point', + step: 0.1, + description: 'X coordinate of the position of the tool in mm.', + }, + y: { + type: 'number', + format: 'number', + title: 'Y Point', + step: 0.1, + description: 'Y coordinate of top left position of the tool in mm away from parent top left.', + }, + position: { + type: 'string', + title: 'Position Type', + description: 'Height of the usable tool area in mm.', + enum: ['center', 'topleft'], + options: { enum_titles: ['Center (default)', 'Top Left'] }, + default: 'center', + }, + width: { + type: 'number', + format: 'number', + title: 'Width', + description: 'Width of the usable tool area in mm.', + minimum: 1, + step: 0.1, + }, + height: { + type: 'number', + format: 'number', + title: 'Height', + description: 'Height of the usable tool area in mm.', + minimum: 1, + step: 0.1, + }, + radius: { + type: 'number', + format: 'number', + title: 'Radius', + description: 'Amount in mm to curve corners of tool area. Set to 0 for square corners.', + minimum: 0, + step: 0.1, + }, + parent: { + type: 'string', + title: 'Parent tool', + description: 'Parent tool ID to base X and Y off of.', + default: '', + }, + group: { + type: 'string', + title: 'Tool Group', + description: 'Arbitrary grouping tag for tools', + default: '', + }, + }, +}; + +export default schema; diff --git a/src/components/core/schemas/cncserver.schemas.toolsets.js b/src/components/core/schemas/cncserver.schemas.toolsets.js new file mode 100644 index 00000000..9f478fc5 --- /dev/null +++ b/src/components/core/schemas/cncserver.schemas.toolsets.js @@ -0,0 +1,41 @@ +/** + * @file Toolset tool holder. + * + */ +const schema = { + type: 'object', + title: 'Toolset', + description: 'A set of tools saved as a preset.', + required: ['name', 'title'], + properties: { + name: { + type: 'string', + title: 'Machine Name', + description: 'Machine name identifier for this toolset.', + }, + manufacturer: { + type: 'string', + title: 'Manufacturer', + description: 'Original creator of the toolset.', + }, + title: { + type: 'string', + title: 'Title', + description: 'Human readable name for the toolset.', + }, + description: { + type: 'string', + title: 'Description', + description: 'Human readable description of the toolset.', + format: 'textarea', + }, + items: { + type: 'array', + title: 'Tool Items', + description: 'Custom additional tools in the set.', + items: {}, // Set in indexer as tools schema. + }, + }, +}; + +export default schema; diff --git a/src/components/core/schemas/cncserver.schemas.vectorize.js b/src/components/core/schemas/cncserver.schemas.vectorize.js index a3180399..039fc850 100644 --- a/src/components/core/schemas/cncserver.schemas.vectorize.js +++ b/src/components/core/schemas/cncserver.schemas.vectorize.js @@ -6,124 +6,134 @@ * */ /* eslint-disable max-len */ -const fs = require('fs'); -const path = require('path'); +import fs from 'fs'; +import path from 'path'; +import { __basedir } from 'cs/utils'; const vectorizerSchema = 'cncserver.drawing.vectorizer.schema.js'; -module.exports = () => { - const globalSchema = { - render: { - type: 'boolean', - format: 'checkbox', - title: 'Render', - description: 'Render vectorization', - default: true, - }, - method: { - type: 'string', - title: 'Vectorize Method', - description: 'The method used to turn a raster into paths.', - default: 'basic', - enum: [], // Filled below from available vectorizer method schemas. - }, - raster: { - type: 'object', - title: 'Image Processing', - description: 'All settings relating to raster image pre-processing.', - options: { collapsed: true }, - properties: { - brightness: { - type: 'number', - title: 'Brightness', - description: 'How much to brighten or darken the image.', - default: 0.05, - minimum: -1, - maximum: 1, - }, - contrast: { - type: 'number', - title: 'Contrast', - description: 'How much to adjust overall pixel contrast.', - default: 0, - minimum: -1, - maximum: 1, - }, - invert: { - type: 'boolean', - format: 'checkbox', - title: 'Invert', - description: 'Invert the pixel color before processing.', - default: false, - }, - grayscale: { - type: 'boolean', - title: 'Grayscale', - format: 'checkbox', - description: 'Convert the image to black and white grayscale.', - default: false, - }, - normalize: { - type: 'boolean', - title: 'Normalize', - format: 'checkbox', - description: 'Normalize color channels to correct for exposure/color temperature.', - default: false, - }, - flatten: { - type: 'boolean', - format: 'checkbox', - title: 'Flatten transparency', - description: 'Flatten alpha transparency in the image will to the flatten color.', - default: false, - }, - flattenColor: { - type: 'string', - format: 'color', - title: 'Flatten Color', - description: 'The color to treat as transparent, or to replace transparency.', - default: '#FFFFFF', - }, - resolution: { - type: 'integer', - title: 'Resolution', - description: 'Raster re-render resolution in DPI, higher gives more detail.', - default: 150, - minimum: 25, - maximum: 600, - }, - blur: { - type: 'integer', - title: 'Blur', - description: 'Blurs the image by the number of pixels specified', - default: 0, - minimum: 0, - maximum: 50, - }, +const globalSchema = { + render: { + type: 'boolean', + format: 'checkbox', + title: 'Render', + description: 'Render vectorization', + default: true, + }, + method: { + type: 'string', + title: 'Vectorize Method', + description: 'The method used to turn a raster into paths.', + default: 'basic', + enum: [], // Filled below from available vectorizer method schemas. + options: { enum_titles: [] }, + }, + raster: { + type: 'object', + title: 'Image Processing', + description: 'All settings relating to raster image pre-processing.', + options: { collapsed: true }, + properties: { + brightness: { + type: 'number', + title: 'Brightness', + description: 'How much to brighten or darken the image.', + default: 0.05, + minimum: -1, + maximum: 1, + step: 0.01, + }, + contrast: { + type: 'number', + title: 'Contrast', + description: 'How much to adjust overall pixel contrast.', + default: 0, + minimum: -1, + maximum: 1, + step: 0.01, + }, + invert: { + type: 'boolean', + format: 'checkbox', + title: 'Invert', + description: 'Invert the pixel color before processing.', + default: false, + }, + grayscale: { + type: 'boolean', + title: 'Grayscale', + format: 'checkbox', + description: 'Convert the image to black and white grayscale.', + default: false, + }, + normalize: { + type: 'boolean', + title: 'Normalize', + format: 'checkbox', + description: 'Normalize color channels to correct for exposure/color temperature.', + default: false, + }, + flatten: { + type: 'boolean', + format: 'checkbox', + title: 'Flatten transparency', + description: 'Flatten alpha transparency in the image will to the flatten color.', + default: false, + }, + flattenColor: { + type: 'string', + format: 'color', + title: 'Flatten Color', + description: 'The color to treat as transparent, or to replace transparency.', + default: '#FFFFFF', + }, + resolution: { + type: 'integer', + title: 'Resolution', + description: 'Raster re-render resolution in DPI, higher gives more detail.', + default: 150, + minimum: 25, + maximum: 600, + }, + blur: { + type: 'integer', + title: 'Blur', + description: 'Blurs the image by the number of pixels specified', + default: 0, + minimum: 0, + maximum: 50, }, }, - }; + }, +}; - // Compile list of all other filler modules and their schemas and merge them. - const vectorizerPath = path.resolve(__dirname, '..', 'drawing', 'vectorizers'); - fs.readdirSync(vectorizerPath).map((dir) => { - const fullPath = path.resolve(vectorizerPath, dir); - if (fs.lstatSync(fullPath).isDirectory()) { - const schemaPath = path.resolve(fullPath, vectorizerSchema); - if (fs.existsSync(schemaPath)) { - globalSchema.method.enum.push(dir); +// Compile list of all other filler modules and their schemas and merge them. +const vectorizerPath = path.resolve(__basedir, 'components', 'core', 'drawing', 'vectorizers'); +fs.readdirSync(vectorizerPath).map(dir => { + const fullPath = path.resolve(vectorizerPath, dir); + if (fs.lstatSync(fullPath).isDirectory()) { + const schemaPath = path.resolve(fullPath, vectorizerSchema); + if (fs.existsSync(schemaPath)) { + globalSchema.method.enum.push(dir); - // eslint-disable-next-line - const vectorizerSchemaObject = require(schemaPath); + // eslint-disable-next-line + import(schemaPath).then(({ default: vectorizerSchemaObject }) => { + globalSchema.method.options.enum_titles.push(vectorizerSchemaObject.title); // Only add if vectorizer defines custom props. if (Object.entries(vectorizerSchemaObject.properties).length) { globalSchema[dir] = vectorizerSchemaObject; globalSchema[dir].options = { dependencies: { method: dir } }; } - } + }); } - }); + } +}); - return { type: 'object', title: 'Vectorization', properties: globalSchema }; +const schema = { + type: 'object', + title: 'Vectorization', + properties: globalSchema, }; + +export default schema; diff --git a/src/components/core/schemas/index.js b/src/components/core/schemas/index.js index bfb872ab..f691740b 100644 --- a/src/components/core/schemas/index.js +++ b/src/components/core/schemas/index.js @@ -1,29 +1,55 @@ /** * @file Settings schema indexer. - * */ -/* eslint-disable global-require, import/no-dynamic-require */ -module.exports = (cncserver) => { - // TODO: Schemas to finalize: stroke, vectorize, text - const items = ['projects', 'content', 'fill', 'stroke', 'text', 'vectorize', 'path']; - const settingsKeys = ['fill', 'stroke', 'text', 'vectorize', 'path']; +import projects from 'cs/schemas/projects'; +import content from 'cs/schemas/content'; +import contentSettings from 'cs/schemas/content/settings'; +import fill from 'cs/schemas/fill'; +import stroke from 'cs/schemas/stroke'; +import text from 'cs/schemas/text'; +import vectorize from 'cs/schemas/vectorize'; +import path from 'cs/schemas/path'; +import color from 'cs/schemas/color'; +import colors from 'cs/schemas/colors'; +import tools from 'cs/schemas/tools'; +import implementSchema from 'cs/schemas/implements'; +import toolsets from 'cs/schemas/toolsets'; - const schemas = {}; - const settingsKeySchemas = {}; +// TODO: Schemas to finalize: stroke, vectorize, text - items.forEach((name) => { - schemas[name] = require(`./cncserver.schemas.${name}.js`)(cncserver); - if (settingsKeys.includes(name)) settingsKeySchemas[name] = schemas[name]; - }); +// All schemas that define content specific settings. +const settingsSchemas = { + fill, + stroke, + text, + vectorize, + path, +}; - // Build the content "settings" schema. - schemas.settings = require( - './cncserver.schemas.content.settings.js' - )(settingsKeySchemas); +const schemas = { + projects, + content, + settings: contentSettings(settingsSchemas), + fill, + stroke, + text, + vectorize, + path, + color, + colors, + tools, + implements: implementSchema, + toolsets, +}; - // Attach to content and projects schemas. - schemas.content.properties.settings = schemas.settings; - schemas.projects.properties.settings = schemas.settings; +// Add the color schema to the colors.items schema. +schemas.colors.properties.items.items = schemas.color; - return schemas; -}; +// Add the tool schema to the toolset.items schema. +schemas.toolsets.properties.items.items = schemas.tools; + +// Attach to content and projects schemas. +schemas.content.properties.settings = schemas.settings; +schemas.projects.properties.settings = schemas.settings; + +export default schemas; diff --git a/src/components/core/utils/cncserver.binder.js b/src/components/core/utils/cncserver.binder.js index 8fe06bf0..e5485775 100644 --- a/src/components/core/utils/cncserver.binder.js +++ b/src/components/core/utils/cncserver.binder.js @@ -1,63 +1,74 @@ /** * @file Util helper for binding to arbitrary events. */ -const binder = {}; // Exposed export. - - -module.exports = (cncserver) => { - const hooks = {}; - - /** - * Binder binder! Registers a callback for an arbitrary event. - * - * @param {string} event - * Named event to trigger on. - * @param {string} caller - * Who's binding this? ensures there's only one binding for each caller. - * @param {function} callback - Function called when the event is triggered. - */ - binder.bindTo = (event, caller, callback) => { - if (typeof hooks[event] !== 'object') { - hooks[event] = {}; +const hooks = { }; +const lateTrigger = {}; +const debug = false; + +/** + * Binder binder! Registers a callback for an arbitrary event. + * + * @param {string} event + * Named event to trigger on. + * @param {string} caller + * Who's binding this? ensures there's only one binding for each caller. + * @param {function} callback + Function called when the event is triggered. + */ +export function bindTo(event, caller, callback) { + if (debug) console.log('BINDER: BindTo', event, 'from', caller); + if (typeof hooks[event] !== 'object') { + hooks[event] = {}; + } + + hooks[event][caller] = callback; + + // Immediately trigger binding if there's a late binding trigger. + if (lateTrigger[event]) { + if (debug) console.log('BINDER: LATE TRIGGER', event, caller); + callback(lateTrigger[event]); + } +} + +/** + * Trigger an event callback for any bound event callers. + * + * @param {string} event + * Event name being triggered. + * @param {object} payload + * Optional data payload to hand to bound callbacks. + * @param {bool} allowLateTrigger + * If true, late bindings will trigger with last payload. + */ +export function trigger(event, payload = {}, allowLateTrigger = false) { + let runningPayload = payload; + + // Allow late triggering for lazy binders? + if (allowLateTrigger) { + lateTrigger[event] = payload; + } + + if (debug) console.log('BINDER: TRIGGER', event); + + if (typeof hooks[event] === 'object') { + // Debug for unbound triggers. + if (debug && !Object.keys(hooks[event]).length) { + console.log(`BINDER: Event "${event}" triggered with NO BOUND IMPLEMENTORS`); } + for (const [caller, callback] of Object.entries(hooks[event])) { + if (typeof callback === 'function') { + if (debug) { + console.log(`BINDER: Event "${event}" triggered for "${caller}" with`, payload); + } + runningPayload = callback(runningPayload); - hooks[event][caller] = callback; - }; - - /** - * Trigger an event callback for any bound event callers. - * - * @param {string} event - * Event name being triggered. - * @param {object} payload - * Optional data payload to hand to bound callbacks. - */ - binder.trigger = (event, payload = {}) => { - let runningPayload = payload; - - if (typeof hooks[event] === 'object') { - // Debug for unbound triggers. - if (cncserver.settings.gConf.get('debug') && !Object.keys(hooks[event]).length) { - console.log(`Event "${event}" triggered with NO BOUND IMPLEMENTORS`); - } - for (const [caller, callback] of Object.entries(hooks[event])) { - if (typeof callback === 'function') { - if (cncserver.settings.gConf.get('debug')) { - console.log(`Event "${event}" triggered for "${caller}" with`, payload); - } - runningPayload = callback(runningPayload); - - // If a binder implementation doesn't return, reset the payload. - if (runningPayload === undefined) { - runningPayload = payload; - } + // If a binder implementation doesn't return, reset the payload. + if (runningPayload === undefined) { + runningPayload = payload; } } } + } - return runningPayload; - }; - - return binder; -}; + return runningPayload; +} diff --git a/src/components/core/utils/cncserver.buffer.js b/src/components/core/utils/cncserver.buffer.js index d45167e2..9ef96568 100644 --- a/src/components/core/utils/cncserver.buffer.js +++ b/src/components/core/utils/cncserver.buffer.js @@ -1,313 +1,308 @@ /** * @file Abstraction module for the run/queue utilities for CNC Server! */ -let buffer = {}; - -module.exports = (cncserver) => { - // Buffer State variables - buffer = { - dataSet: {}, // Holds the actual buffer data keyed by hash. - data: [], // Holds the order of the in a flat array of hashes. - running: false, // Are we running? True if items in buffer/not paused - paused: false, // Are we paused? - newlyPaused: false, // Trigger for pause callback on executeNext() - pauseCallback: null, // Temporary callback storage when pause is complete. - pausePen: null, // Hold the state when paused initiated for resuming - }; - /** - * Helper function for clearing the buffer. Used mainly by plugins. - */ - buffer.clear = () => { - buffer.data = []; - - // Reset the state of the buffer tip pen to the state of the actual robot. - // If this isn't done, it will be assumed to be a state that was deleted - // and never sent out. - cncserver.pen.resetState(); - cncserver.sockets.sendBufferVars(); - }; +import { getHash, extend, getHeightChangeData } from 'cs/utils'; +import { trigger as binderTrigger } from 'cs/binder'; +import { sendMessage } from 'cs/ipc'; +import { bot, gConf } from 'cs/settings'; +import { resetState, getPosChangeData } from 'cs/pen'; +import { state as actualPenState, forceState as forceActualPen } from 'cs/actualPen'; +import { setBatchRunningState } from 'cs/api'; +import { + sendBufferVars, + sendBufferAdd, + sendBufferRemove, + sendBufferComplete, + sendMessageUpdate, + sendCallbackUpdate +} from 'cs/sockets'; + +// Buffer State variables +export const state = { + dataSet: {}, // Holds the actual buffer data keyed by hash. + data: [], // Holds the order of the in a flat array of hashes. + running: false, // Are we running? True if items in buffer/not paused + paused: false, // Are we paused? + newlyPaused: false, // Trigger for pause callback on executeNext() + pauseCallback: null, // Temporary callback storage when pause is complete. + pausePen: null, // Hold the state when paused initiated for resuming +}; - /** - * Setter for the internal buffer running flag. - * - * @param {boolean} runState - * True to keep buffer running, false to stop buffer on next loop. - */ - buffer.setRunning = (runState) => { - buffer.running = !!runState; - }; +/** + * Setter for the internal buffer running flag. + * + * @param {boolean} runState + * True to keep buffer running, false to stop buffer on next loop. + */ +export function setRunning(runState) { + state.running = !!runState; +} - /** - * Setter for the internal buffer newly paused trigger flag. - * - * @param {boolean} pauseState - * True to allow triggering of pauseCallback, false to end trigger state. - */ - buffer.setNewlyPaused = (pauseState) => { - buffer.newlyPaused = !!pauseState; - }; +/** + * Setter for the internal buffer newly paused trigger flag. + * + * @param {boolean} pauseState + * True to allow triggering of pauseCallback, false to end trigger state. + */ +export function setNewlyPaused(pauseState) { + state.newlyPaused = !!pauseState; +} - /** - * Setter for the internal buffer paused trigger callback. - * - * @param {function} pauseCB - */ - buffer.setPauseCallback = (pauseCB) => { - buffer.pauseCallback = () => { - buffer.newlyPaused = false; - buffer.pauseCallback = null; - pauseCB(); - }; +/** + * Setter for the internal buffer paused trigger callback. + * + * @param {function} pauseCB + */ +export function setPauseCallback(pauseCB) { + state.pauseCallback = () => { + state.newlyPaused = false; + state.pauseCallback = null; + pauseCB(); }; +} - // Pause the buffer running. - buffer.pause = () => { - buffer.paused = true; - - // Hold on to the current actualPen to return to before resuming. - buffer.pausePen = cncserver.utils.extend( - {}, cncserver.actualPen.state +/** + * Create a bot specific serial command string from a key:value object + * + * @param {string} name + * Key in bot.commands object to find the command string + * @param {object} values + * Object containing the keys of placeholders to find in command string, + * with value to replace placeholder. + * + * @returns {string} + * Serial command string intended to be outputted directly, empty string + * if error. + */ +export function cmdstr(name, values = {}) { + if (!name || !bot.commands[name]) return ''; // Sanity check + let out = bot.commands[name]; + + for (const [key, value] of Object.entries(values)) { + out = out.replace(`%${key}`, value); + } + + return out; +} + +// Pause the buffer running. +export function pause() { + state.paused = true; + + // Hold on to the current actualPen to return to before resuming. + state.pausePen = { ...actualPenState }; + sendMessage('buffer.pause'); + sendBufferVars(); +} + +// Resume the buffer running. +export function resume() { + state.paused = false; + state.pausePen = null; + sendMessage('buffer.resume'); + sendBufferVars(); +} + +// Toggle the state +export function toggle(setPause) { + if (setPause && !state.paused) { + pause(); + } else if (!setPause && state.paused) { + resume(); + } +} + +// Event for when a buffer has been started. +export function startItem(hash) { + if (gConf.get('debug')) { + console.log(`Buffer RUN [${hash}]`); + } + const index = state.data.indexOf(hash); + if (state.dataSet[hash] && index > -1) { + const item = state.dataSet[hash]; + + // Update the state of the actualPen to match the one in the buffer. + item.pen.bufferHash = hash; + forceActualPen(item.pen); + } else { + // TODO: when this happens, account for why or PREVENT IT. + console.error( + 'IPC/Buffer Item or Hash Mismatch. This should never happen!', + hash, + `Index: ${index}` ); - cncserver.ipc.sendMessage('buffer.pause'); - cncserver.sockets.sendBufferVars(); - }; + } +} - // Resume the buffer running. - buffer.resume = () => { - buffer.paused = false; - buffer.pausePen = null; - cncserver.ipc.sendMessage('buffer.resume'); - cncserver.sockets.sendBufferVars(); - }; - - // Toggle the state - buffer.toggle = (setPause) => { - if (setPause && !buffer.paused) { - buffer.pause(); - } else if (!setPause && buffer.paused) { - buffer.resume(); - } - }; - - // Add an object to the buffer. - buffer.addItem = (item) => { - const hash = cncserver.utils.getHash(item); - if (cncserver.settings.gConf.get('debug')) { - console.log(`Buffer ADD [${hash}]:`, item); +/** + * Trigger non-serial commands in local buffer items on execution by the + * runner. The runner can't do anything with these except say that their + * place in line has come. + * + * @param {object} item + * Buffer item to check/trigger. + * + * @return {boolean} + * True if triggered, false if not applicable. + */ +export function trigger(item) { + if (typeof item.command === 'function') { // Custom Callback buffer item + // Just call the callback function. + item.command(1); + return true; + } + + if (typeof item.command === 'object') { // Detailed buffer object + switch (item.command.type) { + case 'message': + sendMessageUpdate(item.command.message); + return true; + case 'callbackname': + sendCallbackUpdate(item.command.name); + return true; + default: } - - buffer.data.unshift(hash); - buffer.dataSet[hash] = item; - - // Add the item to the runner's buffer. - cncserver.ipc.sendMessage('buffer.add', { + } + + return false; +} + +// Remove an object with the specific hash from the buffer. +// +// This should only be called by the process running the buffer, and denotes +// when an item is run into the machine. +export function removeItem(hash) { + const index = state.data.indexOf(hash); + if (state.dataSet[hash] && index > -1) { + state.data.splice(index, 1); + const item = state.dataSet[hash]; + + // For buffer items with non-serial commands, it's time to do something! + trigger(item); + + delete state.dataSet[hash]; + sendBufferRemove(); + } else if (state.data.length) { + // This is really only an issue if we didn't just clear the buffer. + console.error( + 'End IPC/Buffer Item & Hash Mismatch. This should never happen!', hash, - ...buffer.render(item), - }); - - cncserver.sockets.sendBufferAdd(item, hash); // Alert clients. - return hash; - }; - - // Event for when a buffer has been started. - buffer.startItem = (hash) => { - if (cncserver.settings.gConf.get('debug')) { - console.log(`Buffer RUN [${hash}]`); - } - const index = buffer.data.indexOf(hash); - if (buffer.dataSet[hash] && index > -1) { - const item = buffer.dataSet[hash]; - - // Update the state of the actualPen to match the one in the buffer. - item.pen.bufferHash = hash; - cncserver.actualPen.updateState(item.pen); - } else { - // TODO: when this happens, account for why or PREVENT IT. - console.error( - 'IPC/Buffer Item or Hash Mismatch. This should never happen!', - hash, - `Index: ${index}` - ); - } - }; - - // Remove an object with the specific hash from the buffer. - // - // This should only be called by the process running the buffer, and denotes - // when an item is run into the machine. - buffer.removeItem = (hash) => { - const index = buffer.data.indexOf(hash); - if (buffer.dataSet[hash] && index > -1) { - buffer.data.splice(index, 1); - const item = buffer.dataSet[hash]; - - // For buffer items with non-serial commands, it's time to do something! - buffer.trigger(item); - - delete buffer.dataSet[hash]; - cncserver.sockets.sendBufferRemove(); - } else if (buffer.data.length) { - // This is really only an issue if we didn't just clear the buffer. - console.error( - 'End IPC/Buffer Item & Hash Mismatch. This should never happen!', - hash, - `Index: ${index}` - ); - } + `Index: ${index}` + ); + } - // Trigger the pause callback if it exists when this item is done. - if (typeof buffer.pauseCallback === 'function') { - buffer.pauseCallback(); - } - }; + // Trigger the pause callback if it exists when this item is done. + if (typeof state.pauseCallback === 'function') { + state.pauseCallback(); + } +} - /** - * Helper function for clearing the buffer. - */ - buffer.clear = (isEmpty) => { - buffer.data = []; - buffer.dataSet = {}; +/** + * Helper function for clearing the buffer. + */ +export function clear(isEmpty) { + state.data.length = 0; + state.dataSet = {}; - buffer.pausePen = null; // Resuming with an empty buffer is silly - buffer.paused = false; + state.pausePen = null; // Resuming with an empty buffer is silly + state.paused = false; - // If we're clearing, we need to kill any batch processes running. - cncserver.api.setBatchRunningState(false); + // If we're clearing, we need to kill any batch processes running. + setBatchRunningState(false); - // Reset the state of the buffer tip pen to the state of the actual robot. - // If this isn't done, it will be assumed to be a state that was deleted - // and never sent out. - cncserver.pen.resetState(); + // Reset the state of the buffer tip pen to the state of the actual robot. + // If this isn't done, it will be assumed to be a state that was deleted + // and never sent out. + resetState(); - // Detect if this came from IPC runner being empty or not. - if (!isEmpty) { - cncserver.ipc.sendMessage('buffer.clear'); - console.log('Run buffer cleared!'); - } + // Detect if this came from IPC runner being empty or not. + if (!isEmpty) { + sendMessage('buffer.clear'); + console.log('Run buffer cleared!'); + } - // Send full update as it's been cleared. - cncserver.sockets.sendBufferComplete(); + // Send full update as it's been cleared. + sendBufferComplete(); - // Trigger the event. - cncserver.binder.trigger('buffer.clear'); - }; + // Trigger the event. + binderTrigger('buffer.clear'); +} - /** - * Render an action item into an array of serial command strings. - * - * @param {object} item - * The raw buffer "action" item. - * - * @return {object} - * Object containing keys: - * commands: array of all serial command strings rendered from item. - * duration: numeric duration (in milliseconds) that item should take. - */ - buffer.render = (item) => { - let commands = []; - let duration = 0; - - if (typeof item.command === 'object') { // Detailed buffer object - switch (item.command.type) { - case 'absmove': - // eslint-disable-next-line - const posChangeData = cncserver.utils.getPosChangeData( +/** + * Render an action item into an array of serial command strings. + * + * @param {object} item + * The raw buffer "action" item. + * + * @return {object} + * Object containing keys: + * commands: array of all serial command strings rendered from item. + * duration: numeric duration (in milliseconds) that item should take. + */ +export function render(item) { + let commands = []; + let duration = 0; + + if (typeof item.command === 'object') { // Detailed buffer object + switch (item.command.type) { + case 'absmove': + // eslint-disable-next-line + const posChangeData = getPosChangeData( + item.command.source, + item.command + ); + + posChangeData.d = item.duration; + duration = posChangeData.d; + commands = [cmdstr('movexy', posChangeData)]; + break; + + case 'absheight': + // To set Height, we can set a rate for how slow it moves, + // - servo.minduration is minimum time. + // - Don't set duration if moving at same time + // + commands = [cmdstr('movez', { + r: 2200, + z: item.command.z, + d: getHeightChangeData( item.command.source, - item.command - ); - - posChangeData.d = item.duration; - duration = posChangeData.d; - commands = [buffer.cmdstr('movexy', posChangeData)]; - break; - - case 'absheight': - // To set Height, we can set a rate for how slow it moves, - // - servo.minduration is minimum time. - // - Don't set duration if moving at same time - // - commands = [buffer.cmdstr('movez', { - r: 2200, - z: item.command.z, - d: cncserver.utils.getHeightChangeData( - item.command.source, - item.command.z - ).d, - })]; - - break; - - case 'special': - return { commands, duration, special: item.command.data }; - - default: - } - } else if (typeof item.command === 'string') { - // Serial command is direct string in item.command, no render needed. - commands = [item.command]; - } + item.command.z + ).d, + })]; - return { commands, duration }; - }; + break; - /** - * Trigger non-serial commands in local buffer items on execution by the - * runner. The runner can't do anything with these except say that their - * place in line has come. - * - * @param {object} item - * Buffer item to check/trigger. - * - * @return {boolean} - * True if triggered, false if not applicable. - */ - buffer.trigger = (item) => { - if (typeof item.command === 'function') { // Custom Callback buffer item - // Just call the callback function. - item.command(1); - return true; - } + case 'special': + return { commands, duration, special: item.command.data }; - if (typeof item.command === 'object') { // Detailed buffer object - switch (item.command.type) { - case 'message': - cncserver.sockets.sendMessageUpdate(item.command.message); - return true; - case 'callbackname': - cncserver.sockets.sendCallbackUpdate(item.command.name); - return true; - default: - } + default: } - - return false; - }; - - /** - * Create a bot specific serial command string from a key:value object - * - * @param {string} name - * Key in cncserver.settings.bot.commands object to find the command string - * @param {object} values - * Object containing the keys of placeholders to find in command string, - * with value to replace placeholder. - * - * @returns {string} - * Serial command string intended to be outputted directly, empty string - * if error. - */ - buffer.cmdstr = (name, values = {}) => { - if (!name || !cncserver.settings.bot.commands[name]) return ''; // Sanity check - - let out = cncserver.settings.bot.commands[name]; - - for (const [key, value] of Object.entries(values)) { - out = out.replace(`%${key}`, value); - } - - return out; - }; - - return buffer; -}; + } else if (typeof item.command === 'string') { + // Serial command is direct string in item.command, no render needed. + commands = [item.command]; + } + + return { commands, duration }; +} + +// Add an object to the buffer. +export function addItem(item) { + const hash = getHash(item); + if (gConf.get('debug')) { + console.log(`Buffer ADD [${hash}]:`, item); + } + + state.data.unshift(hash); + state.dataSet[hash] = item; + + // Add the item to the runner's buffer. + sendMessage('buffer.add', { + hash, + ...render(item), + }); + + sendBufferAdd(item, hash); // Alert clients. + return hash; +} diff --git a/src/components/core/utils/cncserver.i18n.js b/src/components/core/utils/cncserver.i18n.js index b8065c89..dc58cc29 100644 --- a/src/components/core/utils/cncserver.i18n.js +++ b/src/components/core/utils/cncserver.i18n.js @@ -2,45 +2,46 @@ * @file Glue module to manage i18n localization initialization, etc. * @see https://www.i18next.com/translation-function/essentials */ -const i18next = require('i18next'); -const i18nextFSBackend = require('i18next-node-fs-backend'); -const i18nextMiddleware = require('i18next-express-middleware'); -const path = require('path'); +import i18next from 'i18next'; +import i18nextFSBackend from 'i18next-node-fs-backend'; +import i18nextMiddleware from 'i18next-express-middleware'; +import path from 'path'; +import { bindTo } from 'cs/binder'; +import { __basedir } from 'cs/utils'; -const i18n = { id: 'i18n', t: {} }; // Final Object to be exported. - -module.exports = (cncserver) => { - // Initialize full i18next stack with middleware. - i18next - .use(i18nextMiddleware.LanguageDetector) - .use(i18nextFSBackend) - .init({ - preload: ['ar', 'bn', 'de', 'en-US', 'es', 'fr', 'hi', 'pt', 'ru', 'ur', 'zh-CN'], - fallbackLng: ['en-US'], - lng: 'en', - // debug: true, - ns: ['common', 'colorsets'], - defaultNS: 'common', - detection: { - lookupHeader: 'accept-language', - }, - backend: { - loadPath: path.join(global.__basedir, 'locales', '{{ns}}', '{{lng}}.json'), - addPath: path.join(global.__basedir, 'locales', '{{ns}}', '{{lng}}.missing.json'), - }, - }) - .then((t) => { - i18n.n = i18next; - i18n.t = t; - }) - .catch((err) => { - console.log(err); - }); +export const i18n = { + t: s => s, + n: {}, +}; - // Bind to server config to initialize locale detection. - cncserver.binder.bindTo('server.configure', i18n.id, (app) => { - app.use(i18nextMiddleware.handle(i18next)); +// Initialize full i18next stack with middleware. +i18next + .use(i18nextMiddleware.LanguageDetector) + .use(i18nextFSBackend) + .init({ + preload: ['ar', 'bn', 'de', 'en-US', 'es', 'fr', 'hi', 'pt', 'ru', 'ur', 'zh-CN'], + fallbackLng: ['en-US'], + lng: 'en', + // debug: true, + ns: ['common', 'colorsets'], + defaultNS: 'common', + detection: { + lookupHeader: 'accept-language', + }, + backend: { + loadPath: path.join(__basedir, 'locales', '{{ns}}', '{{lng}}.json'), + addPath: path.join(__basedir, 'locales', '{{ns}}', '{{lng}}.missing.json'), + }, + }) + .then(t => { + i18n.n = i18next; + i18n.t = t; + }) + .catch(err => { + console.log(err); }); - return i18n; -}; +// Bind to server config to initialize locale detection. +bindTo('server.configure', 'i18n', app => { + app.use(i18nextMiddleware.handle(i18next)); +}); diff --git a/src/components/core/utils/cncserver.run.js b/src/components/core/utils/cncserver.run.js index 08a46368..80ec2107 100644 --- a/src/components/core/utils/cncserver.run.js +++ b/src/components/core/utils/cncserver.run.js @@ -2,100 +2,98 @@ * @file Abstraction module for the cncserver run utility */ -let run; +import * as pen from 'cs/pen'; +import { cmdstr, addItem } from 'cs/buffer'; +import { bot } from 'cs/settings'; -module.exports = (cncserver) => { - /** - * Add a command to the command runner buffer. - * - * @param {string} command - * The command type to be run, must be one of the supported: - * - move - * - height - * - message - * - callbackname - * - wait - * - custom - * - callback - * @param {object} data - * The data to be applied in the command. - * @param {int} rawDuration - * The time in milliseconds this command should take to run. - * - * @returns {boolean} - * Return false if failure, true if success - */ - run = (command, data, rawDuration) => { - let c = ''; - - // Sanity check duration to minimum of 1, int only - let duration = !rawDuration ? 1 : Math.abs(parseInt(rawDuration, 10)); - duration = duration <= 0 ? 1 : duration; +/** + * Add a command to the command runner buffer. + * + * @param {string} command + * The command type to be run, must be one of the supported: + * - move + * - height + * - message + * - callbackname + * - wait + * - custom + * - callback + * @param {object} data + * The data to be applied in the command. + * @param {int} rawDuration + * The time in milliseconds this command should take to run. + * + * @returns {boolean} + * Return false if failure, true if success + */ +export default function run(command, data, rawDuration) { + let c = ''; - switch (command) { - case 'move': - // Detailed buffer object X and Y. - c = { - type: 'absmove', - x: data.x, - y: data.y, - source: data.source, - }; - break; + // Sanity check duration to minimum of 1, int only + let duration = !rawDuration ? 1 : Math.abs(parseInt(rawDuration, 10)); + duration = duration <= 0 ? 1 : duration; - case 'height': - // Detailed buffer object with z height and state string - c = { - type: 'absheight', - z: data.z, - source: data.source, - state: cncserver.pen.state.state, - }; - break; + switch (command) { + case 'move': + // Detailed buffer object X and Y. + c = { + type: 'absmove', + x: data.x, + y: data.y, + source: data.source, + }; + break; - case 'message': - // Detailed buffer object with a string message - c = { type: 'message', message: data }; - break; + case 'height': + // Detailed buffer object with z height and state string + c = { + type: 'absheight', + z: data.z, + source: data.source, + state: pen.state.state, + }; + break; - case 'callbackname': - // Detailed buffer object with a callback machine name - c = { type: 'callbackname', name: data }; - break; + case 'message': + // Detailed buffer object with a string message + c = { type: 'message', message: data }; + break; - case 'wait': - // Send wait, blocking buffer - if (!cncserver.settings.bot.commands.wait) return false; - c = cncserver.buffer.cmdstr('wait', { d: duration }); - break; + case 'callbackname': + // Detailed buffer object with a callback machine name + c = { type: 'callbackname', name: data }; + break; - case 'custom': - c = data; - break; + case 'wait': + // Send wait, blocking buffer + if (!bot.commands.wait) return false; + c = cmdstr('wait', { d: duration }); + break; - case 'callback': // Custom callback runner for API return triggering - c = data; - break; + case 'custom': + c = data; + break; - case 'special': // Low level special command, executed only in the runner. - c = { type: 'special', data }; - break; + case 'callback': // Custom callback runner for API return triggering + c = data; + break; - default: - return false; - } + case 'special': // Low level special command, executed only in the runner. + c = { type: 'special', data }; + break; - // Add final command and duration to end of queue, along with a copy of the - // pen state at this point in time to be copied to actualPen after execution - cncserver.pen.forceState({ lastDuration: duration }); - cncserver.buffer.addItem({ - command: c, - duration, - pen: cncserver.utils.extend({}, cncserver.pen.state), - }); + default: + return false; + } - return true; - }; + // Add final command and duration to end of queue, along with a copy of the + // pen state at this point in time to be copied to actualPen after execution + pen.forceState({ lastDuration: duration }); + addItem({ + command: c, + duration, + pen: { ...pen.state }, + }); - return run; -}; + return true; +} diff --git a/src/components/core/utils/cncserver.settings.js b/src/components/core/utils/cncserver.settings.js index fc99cc46..8249c71d 100644 --- a/src/components/core/utils/cncserver.settings.js +++ b/src/components/core/utils/cncserver.settings.js @@ -2,226 +2,224 @@ * @file Abstraction module for all settings related code for CNC Server! * */ -const nconf = require('nconf'); // Configuration and INI file. -const fs = require('fs'); // File System management. -const path = require('path'); // Path management and normalization. -const ini = require('ini'); // Reading INI files. - -const settings = {}; // Global core component export object to be attached. - -module.exports = (cncserver) => { - settings.gConf = new nconf.Provider(); - settings.botConf = new nconf.Provider(); - settings.bot = {}; - - // Pull conf from env, or arguments - settings.gConf.env().argv(); - - /** - * Initialize/load the global cncserver configuration file & options. - * - * @param {function} cb - * Optional callback triggered when complete. - */ - settings.loadGlobalConfig = (cb) => { - // Pull conf from file - const configPath = path.resolve(global.__basedir, '..', 'config.ini'); - settings.gConf.reset(); - settings.gConf.use('file', { - file: configPath, - format: nconf.formats.ini, - }).load(() => { - // Set Global Config Defaults - settings.gConf.defaults(cncserver.globalConfigDefaults); - - // Save Global Conf file defaults if not saved - if (!fs.existsSync(configPath)) { - const def = settings.gConf.stores.defaults.store; - for (const [key, value] in Object.entries(def)) { - if (key !== 'type') { - settings.gConf.set(key, value); - } - } +import nconf from 'nconf'; // Configuration and INI file. +import fs from 'fs'; // File System management. +import path from 'path'; // Path management and normalization. +import ini from 'ini'; // Reading INI files. + +import { trigger } from 'cs/binder'; +import { __basedir } from 'cs/utils'; + +// Export base constants. +export const gConf = new nconf.Provider(); +export const botConf = new nconf.Provider(); +export const bot = {}; + +// Global Defaults (also used to write the initial config.ini) +export const globalConfigDefaults = { + httpPort: 4242, + httpLocalOnly: true, + swapMotors: false, // Global setting for bots that don't have it configured + invertAxis: { + x: false, + y: false, + }, + maximumBlockingCallStack: 100, // Limit for the # blocking sequential calls + showSerial: false, // Specific debug to show serial data. + serialPath: '{auto}', // Empty for auto-config + bufferLatencyOffset: 30, // Number of ms to move each command closer together + corsDomain: '*', // Start as open to CORs enabled browser clients + debug: false, + botType: 'watercolorbot', + scratchSupport: true, + flipZToggleBit: false, + botOverride: { + info: 'Override bot settings E.G. > [botOverride.eggbot] servo:max = 1234', + }, +}; + +// Pull conf from env, or arguments +gConf.env().argv(); - // Should be sync/blocking save with no callback - settings.gConf.save(); +/** + * Initialize/load the global cncserver configuration file & options. + * + * @param {function} cb + * Optional callback triggered when complete. + */ +export function loadGlobalConfig(cb) { + // Pull conf from file + const configPath = path.resolve(__basedir, '..', 'config.ini'); + gConf.reset(); + gConf.use('file', { + file: configPath, + format: nconf.formats.ini, + }).load(() => { + // Set Global Config Defaults + gConf.defaults(globalConfigDefaults); + + // Save Global Conf file defaults if not saved + if (!fs.existsSync(configPath)) { + const def = gConf.stores.defaults.store; + for (const [key, value] in Object.entries(def)) { + if (key !== 'type') { + gConf.set(key, value); + } } - if (cb) cb(); // Trigger the callback + // Should be sync/blocking save with no callback + gConf.save(); + } - // Output if debug mode is on - if (settings.gConf.get('debug')) { - console.info('== CNCServer Debug mode is ON =='); - } - }); - }; - - /** - * Load bot specific config file - * - * @param {function} cb - * Callback triggered when loading is complete - * @param {string} botType - * Optional, the machine name for the bot type to load. Defaults to the - * globally configured bot type. - */ - settings.loadBotConfig = (cb, botType = settings.gConf.get('botType')) => { - const botFile = path.resolve( - global.__basedir, - '..', - 'machine_types', - `${botType}.ini` - ); + if (cb) cb(); // Trigger the callback - if (!fs.existsSync(botFile)) { - console.error( - `Bot configuration file "${botFile}" doesn't exist. Error #16` - ); - - process.exit(16); - } else { - settings.botConf.reset(); - settings.botConf - .use('file', { - file: botFile, - format: nconf.formats.ini, - }) - .load(() => { - // Mesh in bot overrides from main config - const overrides = settings.gConf.get('botOverride'); - if (overrides) { - if (overrides[botType]) { - for (const [key, value] of Object.entries(overrides[botType])) { - settings.botConf.set(key, value); - } - } - } + // Output if debug mode is on + if (gConf.get('debug')) { + console.info('== CNCServer Debug mode is ON =='); + } + }); +} - // Handy bot constant for easy number from string conversion - settings.bot = { - workArea: { - left: Number(settings.botConf.get('workArea:left')), - top: Number(settings.botConf.get('workArea:top')), - right: Number(settings.botConf.get('maxArea:width')), - bottom: Number(settings.botConf.get('maxArea:height')), - }, - maxArea: { - width: Number(settings.botConf.get('maxArea:width')), - height: Number(settings.botConf.get('maxArea:height')), - }, - maxAreaMM: { - width: Number(settings.botConf.get('maxAreaMM:width')), - height: Number(settings.botConf.get('maxAreaMM:height')), - }, - park: { - x: Number(settings.botConf.get('park:x')), - y: Number(settings.botConf.get('park:y')), - }, - controller: settings.botConf.get('controller'), - commands: settings.botConf.get('controller').commands, - }; +/** + * Load bot specific config file + * + * @param {function} cb + * Callback triggered when loading is complete + * @param {string} botType + * Optional, the machine name for the bot type to load. Defaults to the + * globally configured bot type. + */ +export function loadBotConfig(cb, botType = gConf.get('botType')) { + const botFile = path.resolve(__basedir, '..', 'machine_types', `${botType}.ini`); + + if (!fs.existsSync(botFile)) { + console.error( + `Bot configuration file "${botFile}" doesn't exist. Error #16` + ); - // Check if a point is within the work area. - settings.bot.inWorkArea = ({ x, y }) => { - const area = settings.bot.workArea; - if (x > area.right || x < area.left) { - return false; - } - if (y > area.bottom || y < area.top) { - return false; + process.exit(16); + } else { + botConf.reset(); + botConf + .use('file', { + file: botFile, + format: nconf.formats.ini, + }) + .load(() => { + // Mesh in bot overrides from main config + const overrides = gConf.get('botOverride'); + if (overrides) { + if (overrides[botType]) { + for (const [key, value] of Object.entries(overrides[botType])) { + botConf.set(key, value); } - return true; - }; - - // Store assumed constants. - const { bot } = settings; - bot.workArea.width = bot.maxArea.width - bot.workArea.left; - bot.workArea.height = bot.maxArea.height - bot.workArea.top; + } + } - bot.workArea.relCenter = { - x: bot.workArea.width / 2, - y: bot.workArea.height / 2, + // Handy bot constant for easy number from string conversion + bot.workArea = { + left: Number(botConf.get('workArea:left')), + top: Number(botConf.get('workArea:top')), + right: Number(botConf.get('maxArea:width')), + bottom: Number(botConf.get('maxArea:height')), + }; + bot.maxArea = { + width: Number(botConf.get('maxArea:width')), + height: Number(botConf.get('maxArea:height')), + }; + bot.maxAreaMM = { + width: Number(botConf.get('maxAreaMM:width')), + height: Number(botConf.get('maxAreaMM:height')), + }; + bot.park = { + x: Number(botConf.get('park:x')), + y: Number(botConf.get('park:y')), + }; + bot.controller = botConf.get('controller'); + bot.commands = botConf.get('controller').commands; + + // Check if a point is within the work area. + bot.inWorkArea = ({ x, y }) => { + const area = bot.workArea; + if (x > area.right || x < area.left) { + return false; + } + if (y > area.bottom || y < area.top) { + return false; + } + return true; + }; + + // Store assumed constants. + bot.workArea.width = bot.maxArea.width - bot.workArea.left; + bot.workArea.height = bot.maxArea.height - bot.workArea.top; + + bot.workArea.relCenter = { + x: bot.workArea.width / 2, + y: bot.workArea.height / 2, + }; + + bot.workArea.absCenter = { + x: bot.workArea.relCenter.x + bot.workArea.left, + y: bot.workArea.relCenter.y + bot.workArea.top, + }; + + // If supplied, add conversions for abs distance. + if (bot.maxAreaMM.width) { + bot.stepsPerMM = { + x: bot.maxArea.width / bot.maxAreaMM.width, + y: bot.maxArea.height / bot.maxAreaMM.height, }; - bot.workArea.absCenter = { - x: bot.workArea.relCenter.x + bot.workArea.left, - y: bot.workArea.relCenter.y + bot.workArea.top, + bot.workAreaMM = { + left: bot.workArea.left / bot.stepsPerMM.x, + top: bot.workArea.top / bot.stepsPerMM.y, + right: bot.workArea.right / bot.stepsPerMM.x, + bottom: bot.workArea.bottom / bot.stepsPerMM.y, }; + // bot.stepsPerMM + } else { + bot.maxAreaMM = false; + } - // If supplied, add conversions for abs distance. - if (bot.maxAreaMM.width) { - bot.stepsPerMM = { - x: bot.maxArea.width / bot.maxAreaMM.width, - y: bot.maxArea.height / bot.maxAreaMM.height, - }; - - bot.workAreaMM = { - left: bot.workArea.left / bot.stepsPerMM.x, - top: bot.workArea.top / bot.stepsPerMM.y, - right: bot.workArea.right / bot.stepsPerMM.x, - bottom: bot.workArea.bottom / bot.stepsPerMM.y, - }; - // bot.stepsPerMM - } else { - settings.bot.maxAreaMM = false; - } - - // Set initial pen position at park position - // TODO: Add set park position helper in control. - const park = cncserver.utils.centToSteps(bot.park, true); - cncserver.pen.forceState({ - x: park.x, - y: park.y, - }); - - // Set global override for swapMotors if set by bot config - const swapMotors = settings.botConf.get('controller:swapMotors'); - if (typeof swapMotors !== 'undefined') { - settings.gConf.set('swapMotors', swapMotors); - } - - // Trigger any bot/board specific setup. - cncserver.binder.trigger('controller.setup', settings.botConf.get('controller')); + // Set global override for swapMotors if set by bot config + const swapMotors = botConf.get('controller:swapMotors'); + if (typeof swapMotors !== 'undefined') { + gConf.set('swapMotors', swapMotors); + } - console.log( - `Successfully loaded config for ${settings.botConf.get( - 'name' - )}! Initializing...` - ); + // Trigger any bot/board specific setup. + trigger('controller.setup', botConf.get('controller'), true); - // Trigger the callback once we're done - if (cb) cb(); - }); - } - }; - - /** - * Get the list of supported bots and their full ini config arrays. - * - * @return {object} - * A keyed array/object of all supported bot configurations and data. - */ - settings.getSupportedBots = () => { - const list = fs.readdirSync(path.resolve(__dirname, '..', 'machine_types')); - const out = {}; - for (const i of list) { - const file = path.resolve(__dirname, '..', 'machine_types', list[i]); - const data = ini.parse(fs.readFileSync(file, 'utf-8'), 'utf-8'); - const type = list[i].split('.')[0]; - out[type] = { - name: data.name, - data, - }; - } - return out; - }; + console.log( + `Successfully loaded config for ${botConf.get( + 'name' + )}! Initializing...` + ); - // Exports. - settings.exports = { - getSupportedBots: settings.getSupportedBots, - loadGlobalConfig: settings.loadGlobalConfig, - loadBotConfig: settings.loadBotConfig, - }; + // Trigger the callback once we're done + if (cb) cb(); + }); + } +} - return settings; -}; +/** + * Get the list of supported bots and their full ini config arrays. + * + * @return {object} + * A keyed array/object of all supported bot configurations and data. + */ +export function getSupportedBots() { + const list = fs.readdirSync(path.resolve(__basedir, '..', 'machine_types')); + const out = {}; + for (const i of list) { + const file = path.resolve(__basedir, '..', 'machine_types', list[i]); + const data = ini.parse(fs.readFileSync(file, 'utf-8'), 'utf-8'); + const type = list[i].split('.')[0]; + out[type] = { + name: data.name, + data, + }; + } + return out; +} diff --git a/src/components/core/utils/cncserver.utils.js b/src/components/core/utils/cncserver.utils.js index d7007edf..e54c33b7 100644 --- a/src/components/core/utils/cncserver.utils.js +++ b/src/components/core/utils/cncserver.utils.js @@ -1,490 +1,681 @@ /** * @file Abstraction module for generic util helper functions for CNC Server! */ -const crypto = require('crypto'); // Crypto library for hashing. -const extend = require('util')._extend; // Util for cloning objects -const merge = require('merge-deep'); +import crypto from 'crypto'; // Crypto library for hashing. +import glob from 'glob'; // File Utils. -const { homedir } = require('os'); -const fs = require('fs'); -const path = require('path'); - -const utils = { extend }; // Final Object to be exported. - -module.exports = (cncserver) => { - /** - * Sanity check a given coordinate within the absolute area. - * @param {object} point - * The point to be checked and operated on by reference. - * // TODO: It's an antipattern to operate by ref, refactor. - * @return {null} - */ - utils.sanityCheckAbsoluteCoord = ({ x, y }) => { - const { maxArea } = cncserver.settings.bot; - return { - x: Math.max(0, x > maxArea.width ? maxArea.width : x), - y: Math.max(0, y > maxArea.height ? maxArea.height : y), - }; - }; +import { homedir } from 'os'; +import fs from 'fs'; +import path from 'path'; - /** - * Create a 16char hash using passed data (and a salt). - * - * @param {mixed} data - * Data to be hashed, either an object or array. - * @param {string} salt - * Type of salt to adjust the hash with. Pass null to disable, otherwise: - * "increment" (default): will increment each has salt by 1, providing - * consecutive data similarty hash safety. - * "date": Inserts the ISO date as a salt. - * - * @return {string} - * 16 char hash of data and current time in ms. - */ - let hashSequence = 0; - utils.getHash = (data, saltMethod = 'increment') => { - const md5sum = crypto.createHash('md5'); - let salt = ''; - - switch (saltMethod) { - case 'increment': - salt = hashSequence++; - break; - - case 'date': - salt = new Date().toISOString(); - break; - - default: - break; - } +// Settings. +import { gConf, bot, botConf } from 'cs/settings'; - md5sum.update(`${JSON.stringify(data)}${salt}`); - return md5sum.digest('hex').substr(0, 16); - }; +// Directly exported imports. +export { _extend as extend } from 'util'; // Util for cloning objects +export { default as merge } from 'merge-deep'; - /** - * Get a named user content directory. - * - * @param {string} name - * The arbitrary name of the dir. - * - * @return {string} - * The full path of the writable path. - */ - utils.getUserDir = (name) => { - // Ensure we have the base user folder. - const home = utils.getDir(path.resolve(homedir(), 'cncserver')); - const botHome = utils.getDir(path.resolve(home, cncserver.settings.gConf.get('botType'))); +// MM to Inches const. +export const MM_TO_INCHES = 25.4; - // Home base dir? or bot specific? - if (['projects'].includes(name)) { - return utils.getDir(path.resolve(botHome, name)); +/** + * Apply byref all keys and values from left to right. + * + * @export + * @param {object} source + * Source single level object with key/values to set from. + * @param {object} dest + * Destination single level object to be modified. + * @param {bool} strict + * If true, only keys existing on the destination will be set. + * + * @returns {boolean} + * True if there was a difference, false if no change. + */ +export function applyObjectTo(source, dest, strict = false) { + let hasChanges = false; + Object.entries(source).forEach(([key, value]) => { + // eslint-disable-next-line no-param-reassign + if (strict) { + if (key in dest) { + if (dest[key] !== value) { + hasChanges = true; + // eslint-disable-next-line no-param-reassign + dest[key] = value; + } + } + } else if (dest[key] !== value) { + hasChanges = true; + // eslint-disable-next-line no-param-reassign + dest[key] = value; } - return utils.getDir(path.resolve(home, name)); - }; + }); - utils.singleLineString = (strings, ...values) => { - // Interweave the strings with the - // substitution vars first. - let output = ''; - for (let i = 0; i < values.length; i++) { - output += strings[i] + values[i]; - } - output += strings[values.length]; + return hasChanges; +} - // Split on newlines. - const lines = output.split(/(?:\r\n|\n|\r)/); +// ES Module basedir replacement. +export const __basedir = path.resolve('./src/'); - // Rip out the leading whitespace. - return lines.map(line => line.replace(/^\s+/gm, '')).join(' ').trim(); - }; +/** + * Sanity check a given coordinate within the absolute area. + * @param {object} point + * The point in absolute steps to be checked and operated on by reference. + * + * @return {object} + * The final sanitized coordinate. + */ +export function sanityCheckAbsoluteCoord({ x, y }) { + const { maxArea } = bot; - /** - * Validate that a directory at the given path exists. - * - * @param {string} dir - * The full path of the dir. - * - * @return {string} - * The full path dir, or an error is thrown if there's permission issues. - */ - utils.getDir = (dir) => { - if (!fs.existsSync(dir)) fs.mkdirSync(dir); - return dir; + return { + x: Math.round(Math.max(0, x > maxArea.width ? maxArea.width : x)), + y: Math.round(Math.max(0, y > maxArea.height ? maxArea.height : y)), }; +} - // Externalize Merge Deep: - // @see https://github.com/jonschlinkert/merge-deep - utils.merge = merge; - - /** - * Calculate the duration for a pen movement from the distance. - * Takes into account whether pen is up or down - * - * @param {float} distance - * Distance in steps that we'll be moving - * @param {int} min - * Optional minimum value for output duration, defaults to 1. - * @param {object} inPen - * Incoming pen object to check (buffer tip or bot current). - * @param {number} speedOverride - * Optional speed override, overrides calculated speed percent. - * - * @returns {number} - * Millisecond duration of how long the move should take - */ - utils.getDurationFromDistance = (distance, min = 1, inPen, speedOverride = null) => { - const minSpeed = parseFloat(cncserver.settings.botConf.get('speed:min')); - const maxSpeed = parseFloat(cncserver.settings.botConf.get('speed:max')); - const drawingSpeed = cncserver.settings.botConf.get('speed:drawing'); - const movingSpeed = cncserver.settings.botConf.get('speed:moving'); - - // Use given speed over distance to calculate duration - let speed = (utils.penDown(inPen)) ? drawingSpeed : movingSpeed; - if (speedOverride != null) { - speed = speedOverride; - } +/** + * Create a 16char hash using passed data (and a salt). + * + * @param {mixed} data + * Data to be hashed, either an object or array. + * @param {string} salt + * Type of salt to adjust the hash with. Pass null to disable, otherwise: + * "increment" (default): will increment each has salt by 1, providing + * consecutive data similarty hash safety. + * "date": Inserts the ISO date as a salt. + * + * @return {string} + * 16 char hash of data and current time in ms. + */ +let hashSequence = 0; +export function getHash(data, saltMethod = 'increment') { + const md5sum = crypto.createHash('md5'); + let salt = ''; - speed = parseFloat(speed) / 100; + switch (saltMethod) { + case 'increment': + salt = hashSequence++; + break; - // Convert to steps from percentage - speed = (speed * (maxSpeed - minSpeed)) + minSpeed; + case 'date': + salt = new Date().toISOString(); + break; - // Sanity check speed value - speed = speed > maxSpeed ? maxSpeed : speed; - speed = speed < minSpeed ? minSpeed : speed; + default: + break; + } - // How many steps a second? - return Math.max(Math.abs(Math.round(distance / speed * 1000)), min); - }; + md5sum.update(`${JSON.stringify(data)}${salt}`); + return md5sum.digest('hex').substr(0, 16); +} - /** - * Given two points, find the difference and duration at current speed - * - * @param {{x: number, y: number}} src - * Source position coordinate (in steps). - * @param {{x: number, y: number}} dest - * Destination position coordinate (in steps). - * @param {number} speed - * Speed override for this movement in percent. - * - * @returns {{d: number, x: number, y: number}} - * Object containing the change amount in steps for x & y, along with the - * duration in milliseconds. - */ - utils.getPosChangeData = (src, dest, speed = null) => { - let change = { - x: Math.round(dest.x - src.x), - y: Math.round(dest.y - src.y), - }; +/** + * Validate that a directory at the given path exists. + * + * @param {string} dir + * The full path of the dir. + * + * @return {string} + * The full path dir, or an error is thrown if there's permission issues. + */ +export function getDir(dir) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir); + return dir; +} - // Calculate distance - const duration = utils.getDurationFromDistance( - utils.getVectorLength(change), - 1, - src, - speed - ); - - // Adjust change direction/inversion - if (cncserver.settings.botConf.get('controller').position === 'relative') { - // Invert X or Y to match stepper direction - change.x = cncserver.settings.gConf.get('invertAxis:x') ? change.x * -1 : change.x; - change.y = cncserver.settings.gConf.get('invertAxis:y') ? change.y * -1 : change.y; - } else { // Absolute! Just use the "new" absolute X & Y locations - change.x = cncserver.pen.state.x; - change.y = cncserver.pen.state.y; +/** + * Get a named user content directory. + * + * @param {string} name + * The arbitrary name of the dir. + * + * @return {string} + * The full path of the writable path. + */ +export function getUserDir(name) { + // Ensure we have the base user folder. + const home = getDir(path.resolve(homedir(), 'cncserver')); + const botType = gConf.get('botType'); + + // Only if we have a bot type, use its specific home dir. + // This happens when this function is used before settings are available. + if (botType) { + const botHome = getDir(path.resolve(home, botType)); + + // Home base dir? or bot specific? + if (['projects', 'colorsets', 'implements', 'toolsets'].includes(name)) { + return getDir(path.resolve(botHome, name)); } + } + return getDir(path.resolve(home, name)); +} + +/** + * Get list of files from a named user content directory. + * + * @param {string} name + * The arbitrary name of the dir. + * @param {string} pattern + * The file pattern to filter to, defaults to all files. + * @param {function} mapFunc + * The function to map over the array of files. + * + * @return {array} + * List of file paths matching the pattern, after running through the map. + */ +export function getUserDirFiles(name, pattern = '*.*', mapFunc = x => x) { + let files = []; + try { + const dir = getUserDir(name); + files = glob.sync(path.join(dir, pattern)); + } catch (error) { + console.error(error); + } + + return files.map(mapFunc); +} - // Swap motor positions - if (cncserver.settings.gConf.get('swapMotors')) { - change = { - x: change.y, - y: change.x, - }; +/** + * Get JSON data from a file in a given folder. Null if problem. + * + * @param {string} file + * The full file path of the JSON file. + * + * @return {object} + * Data in file, null if not found or bad JSON. + */ +export function getJSONFile(file) { + let data = null; + + if (fs.existsSync(file)) { + try { + data = JSON.parse(fs.readFileSync(file)); + + // Clean out metadata header here. + // TODO: Validate header somehow? + delete data._meta; + } catch (error) { + console.error(`Problem loading JSON file: '${file}'`, error); } + } - return { d: duration, x: change.x, y: change.y }; - }; + return data; +} - /** - * Given two height positions, find the difference and pro-rate duration. - * - * @param {integer} src - * Source position. - * @param {integer} dest - * Destination position. - * - * @returns {{d: number, a: number}} - * Object containing the change amount in steps for x & y, along with the - * duration in milliseconds. - */ - utils.getHeightChangeData = (src, dest) => { - const sd = cncserver.settings.botConf.get('servo:minduration'); - - // Get the amount of change from difference between actualPen and absolute - // height position, pro-rating the duration depending on amount of change - const range = parseInt(cncserver.settings.botConf.get('servo:max'), 10) - - parseInt(cncserver.settings.botConf.get('servo:min'), 10); - - const duration = Math.max(1, Math.round((Math.abs(dest - src) / range) * sd)); - - return { d: duration, a: dest - src }; - }; +/** + * Get an object of all JSON data in a given folder. + * + * @param {string} dir + * The full path of the directory to search in. + * + * @return {object} + * Object keyed on "name" of all items in dir. Empty object if invalid or no files. + */ +export function getJSONList(dir) { + const out = {}; + + if (fs.existsSync(dir)) { + const files = glob.sync(path.join(dir, '*.json')); + files.forEach(jsonPath => { + const data = getJSONFile(jsonPath); + if (data && data.name) { + out[data.name] = data; + } + }); + } - /** - * Helper abstraction for checking if the tip of buffer pen is "down" or not. - * - * @param {object} inPen - * The pen object to check for down status, defaults to buffer tip. - * @returns {Boolean} - * False if pen is considered up, true if pen is considered down. - */ - utils.penDown = (inPen) => { - // TODO: Refactor to ensure no modification by reference/intent. - if (!inPen || !inPen.state) inPen = cncserver.pen.state; - - if (inPen.state === 'up' || inPen.state < 0.5) { - return false; - } + return out; +} - return true; - }; +/** + * Get an object of all JSON data in a given custom folder. + * + * @param {string} name + * The name of the preset type. + * + * @return {object} + * Object keyed on "name" of all items. + */ +export function getCustomPresets(name) { + return getJSONList(getUserDir(name)); +} - /** - * Convert an incoming pen object absolute step coordinate values. - * - * @param {{x: number, y: number, abs: [in|mm]}} pen - * Pen/Coordinate measured in percentage of total draw area, or absolute - * distance to be converted to steps. - * - * @returns {{x: number, y: number}} - * Converted coordinate in absolute steps. - */ - utils.inPenToSteps = (inPen) => { - if (inPen.abs === 'in' || inPen.abs === 'mm') { - return utils.absToSteps({ x: inPen.x, y: inPen.y }, inPen.abs); - } +/** + * Get an object of all JSON data in a given internal preset folder. + * + * @param {string} name + * The name of the preset type. + * + * @return {object} + * Object keyed on "name" of all items. + */ +export function getInternalPresets(name) { + return getJSONList(path.join(__basedir, 'presets', name)); +} - return utils.centToSteps({ x: inPen.x, y: inPen.y }); +/** + * Get an object of all JSON data in a given preset/custom folders. + * + * @param {string} name + * The name of the preset type. + * + * @return {object} + * Object keyed on "name" of all items. + */ +export function getPresets(name) { + return { + ...getInternalPresets(name), + ...getCustomPresets(name), }; +} - /** - * Convert an absolute point object to absolute step coordinate values. - * - * @param {{x: number, y: number, abs: [in|mm]}} point - * Coordinate measured in percentage of total draw area, or absolute - * distance to be converted to steps. - * @param {string} scale - * Either 'in' for inches, or 'mm' for millimeters. - * @param {boolean} inMaxArea - * Pass "true" if percent vals should be considered within the maximum area - * otherwise steps will be calculated as part of the global work area. - * - * @returns {{x: number, y: number}} - * Converted coordinate in absolute steps. - */ - utils.absToSteps = (point, scale, inMaxArea) => { - const { settings: { bot } } = cncserver; - - // TODO: Don't operate by reference (intionally or otherwise), refactor. - // ALSO move '25.4' value to config. - // Convert Inches to MM. - if (scale === 'in') { - point = { x: point.x * 25.4, y: point.y * 25.4 }; +/** + * Curried promise to validate an existing key in set of type presets. + * + * @param {string} key + * Key off of source object to check for valid machine name. + * @param {boolean} allowInherit + * When true, allows '[inherit]' machine name. + * @param {string} type + * The type of preset, defaults to pluralized keyname "[key]s". + * + * @return {Function > Promise} + * Curried function, to promise that resolves if the value is valid, rejects + * with error and allowed value list if invalid. + */ +export function isValidPreset(key, allowInherit = false, type = `${key}s`) { + return source => new Promise((resolve, reject) => { + const keys = Object.keys(getPresets(type)); + if (allowInherit) keys.unshift('[inherit]'); + if (!keys.includes(source[key])) { + const err = new Error(`Invalid ${key}, must be one of allowed values`); + err.allowedValues = keys; + reject(err); } + resolve(source); + }); +} - // Return absolute calculation. - return { - x: (!inMaxArea ? bot.workArea.left : 0) + (point.x * bot.stepsPerMM.x), - y: (!inMaxArea ? bot.workArea.top : 0) + (point.y * bot.stepsPerMM.y), - }; - }; +/** + * Get the direct data of a preset/custom by type and machine name. + * + * @param {string} type + * The type of preset (folder). + * @param {string} name + * The machine name of the preset (filename). + * @param {boolean} customOnly + * If true, will not failover to read-only presets. + * + * @return {object} + * Raw data from the JSON file, null if there's a problem. + */ +export function getPreset(type, name, customOnly) { + const presetPath = path.join(__basedir, 'presets', type, `${name}.json`); + const customPath = path.join(getUserDir(type), `${name}.json`); + + if (customOnly) { + return getJSONFile(customPath); + } + + if (fs.existsSync(customPath)) { + return getJSONFile(customPath); + } + return getJSONFile(presetPath); +} - /** - * Convert an absolute step coordinate to absolute point object. - * - * @param {{x: number, y: number}} point - * Coordinate measured in steps to be converted to absolute in scale. - * @param {string} scale - * Either 'in' for inches, or 'mm' for millimeters. - * - * @returns {{x: number, y: number}} - * Converted coordinate in absolute steps. - */ - utils.stepsToAbs = (point, scale) => { - const { settings: { bot } } = cncserver; - - // Setup output, less workarea boundaries, divided by mm per step. - let out = { - x: (point.x - bot.workArea.left) / bot.stepsPerMM.x, - y: (point.y - bot.workArea.top) / bot.stepsPerMM.y, - }; +/** + * Save the data of a preset/custom by type and data. + * + * @param {string} type + * The type of preset (folder). + * @param {string} data + * The data to save, with a required key "name" used as machine name. + * + * @return {object} + * Raw data from the JSON file, null if there's a problem. + */ +export function savePreset(type, data) { + const customPath = path.join(getUserDir(type), `${data.name}.json`); + // TODO: Pull version dynamically. + const _meta = { cncserverVersion: '3.0.0-beta1', fileType: type }; + fs.writeFileSync(customPath, JSON.stringify({ _meta, ...data }, null, 2)); +} - if (scale === 'in') { - out = { x: point.x / 25.4, y: point.y / 25.4 }; - } +/** + * Delete custom by type and name. + * + * @param {string} type + * The type of preset (folder). + * @param {string} name + * The machine name of the preset. + */ +export function deletePreset(type, name) { + const customPath = path.join(getUserDir(type), `${name}.json`); + const trashPath = path.resolve(getUserDir('trash'), `${type}-${name}.json`); + // Try to rename to trash. If there's one existing, delete. + try { + if (fs.existsSync(trashPath)) fs.unlinkSync(trashPath); + fs.renameSync(customPath, trashPath); + } catch (error) { + // Basically ignore errors for now. + console.error(error); + } +} + +// String literal translation function to keep code line lengths down. +export function singleLineString(strings, ...values) { + // Interweave the strings with the + // substitution vars first. + let output = ''; + for (let i = 0; i < values.length; i++) { + output += strings[i] + values[i]; + } + output += strings[values.length]; + + // Split on newlines. + const lines = output.split(/(?:\r\n|\n|\r)/); + + // Rip out the leading whitespace. + return lines.map(line => line.replace(/^\s+/gm, '')).join(' ').trim(); +} - // Return absolute calculation. - return out; +/** + * Given two height positions, find the difference and pro-rate duration. + * + * @param {integer} src + * Source position. + * @param {integer} dest + * Destination position. + * + * @returns {{d: number, a: number}} + * Object containing the change amount in steps for x & y, along with the + * duration in milliseconds. + */ +export function getHeightChangeData(src, dest) { + const sd = botConf.get('servo:minduration'); + + // Get the amount of change from difference between actualPen and absolute + // height position, pro-rating the duration depending on amount of change + const range = parseInt(botConf.get('servo:max'), 10) + - parseInt(botConf.get('servo:min'), 10); + + const duration = Math.max(1, Math.round((Math.abs(dest - src) / range) * sd)); + + return { d: duration, a: dest - src }; +} + +/** + * Convert an absolute point object to absolute step coordinate values. + * + * @param {{x: number, y: number}} point + * Coordinate measured in percentage of total draw area, or absolute + * distance to be converted to steps. + * @param {string} scale + * Either 'in' for inches, or 'mm' for millimeters. + * @param {boolean} inMaxArea + * Pass "true" if percent vals should be considered within the maximum area + * otherwise steps will be calculated as part of the global work area. + * + * @returns {{x: number, y: number}} + * Converted coordinate in absolute steps. + */ +export function absToSteps({ x, y }, scale, inMaxArea) { + // Convert Inches to MM. + let point = { x, y }; + if (scale === 'in') { + point = { x: x * MM_TO_INCHES, y: y * MM_TO_INCHES }; + } + + // Return absolute calculation. + // TODO: validate this works for inches. + return { + x: (!inMaxArea ? bot.workArea.left : 0) + (point.x * bot.stepsPerMM.x), + y: (!inMaxArea ? bot.workArea.top : 0) + (point.y * bot.stepsPerMM.y), }; +} - /** - * Convert percent of total area coordinates into absolute step coordinates. - * - * @param {{x: number, y: number}} point - * Coordinate (measured in steps) to be converted. - * @param {boolean} inMaxArea - * Pass "true" if percent vals should be considered within the maximum area - * otherwise steps will be calculated as part of the global work area. - * - * @returns {{x: number, y: number}} - * Converted coordinate in steps. - */ - utils.centToSteps = (point, inMaxArea) => { - const { bot } = cncserver.settings; - if (!inMaxArea) { // Calculate based on workArea - return { - x: bot.workArea.left + ((point.x / 100) * bot.workArea.width), - y: bot.workArea.top + ((point.y / 100) * bot.workArea.height), - }; - } - // Calculate based on ALL area +/** + * Convert an absolute step coordinate to absolute point object. + * + * @param {{x: number, y: number}} point + * Coordinate measured in steps to be converted to absolute in scale. + * @param {string} scale + * Either 'in' for inches, or 'mm' for millimeters. + * + * @returns {{x: number, y: number}} + * Converted coordinate in absolute steps. + */ +export function stepsToAbs(point, scale) { + // Setup output, less workarea boundaries, divided by mm per step. + let out = { + x: (point.x - bot.workArea.left) / bot.stepsPerMM.x, + y: (point.y - bot.workArea.top) / bot.stepsPerMM.y, + }; + + if (scale === 'in') { + out = { x: point.x / 25.4, y: point.y / 25.4 }; + } + + // Return absolute calculation. + return out; +} + +/** + * Convert percent of total area coordinates into absolute step coordinates. + * + * @param {{x: number, y: number}} point + * Coordinate (measured in steps) to be converted. + * @param {boolean} inMaxArea + * Pass "true" if percent vals should be considered within the maximum area + * otherwise steps will be calculated as part of the global work area. + * + * @returns {{x: number, y: number}} + * Converted coordinate in steps. + */ +export function centToSteps(point, inMaxArea) { + if (!inMaxArea) { // Calculate based on workArea return { - x: (point.x / 100) * bot.maxArea.width, - y: (point.y / 100) * bot.maxArea.height, + x: bot.workArea.left + ((point.x / 100) * bot.workArea.width), + y: bot.workArea.top + ((point.y / 100) * bot.workArea.height), }; + } + // Calculate based on ALL area + return { + x: (point.x / 100) * bot.maxArea.width, + y: (point.y / 100) * bot.maxArea.height, }; +} - /** - * Convert any string into a machine name. - * - * @param {string} string - * Object representing coordinate away from (0,0) - * @returns {number} - * Length (in steps) of the given vector point - */ - utils.getMachineName = (string = '', limit = 32) => string - .replace(/[^A-Za-z0-9 ]/g, '') // Remove unwanted characters. +/** + * Convert an incoming pen object absolute step coordinate values. + * + * @param {{x: number, y: number, abs: [in|mm]}} pen + * Pen/Coordinate measured in percentage of total draw area, or absolute + * distance to be converted to steps. + * + * @returns {{x: number, y: number}} + * Converted coordinate in absolute steps. + */ +export function inPenToSteps(inPen) { + if (inPen.abs === 'in' || inPen.abs === 'mm') { + return absToSteps({ x: inPen.x, y: inPen.y }, inPen.abs); + } + + return centToSteps({ x: inPen.x, y: inPen.y }); +} + +/** + * Convert any string into a machine name. + * + * @param {string} string + * Object representing coordinate away from (0,0) + * @returns {number} + * Length (in steps) of the given vector point + */ +export function getMachineName(string = '', limit = 32) { + return string + .replace(/[^A-Za-z0-9- ]/g, '') // Remove unwanted characters. .replace(/\s{2,}/g, ' ') // Replace multi spaces with a single space .replace(/\s/g, '-') // Replace space with a '-' symbol .toLowerCase() // Lowercase only. .substr(0, limit); // Limit +} - /** - * Get the distance/length of the given vector coordinate - * - * @param {{x: number, y: number}} vector - * Object representing coordinate away from (0,0) - * @returns {number} - * Length (in steps) of the given vector point - */ - utils.getVectorLength = vector => Math.sqrt((vector.x ** 2) + (vector.y ** 2)); - - /** - * Perform conversion from named/0-1 number state value to given pen height - * suitable for outputting to a Z axis control statement. - * - * @param {string/integer} state - * - * @returns {object} - * Object containing normalized state, and numeric height value. As: - * {state: [integer|string], height: [float]} - */ - utils.stateToHeight = (state) => { - // Whether to use the full min/max range (used for named presets only) - let fullRange = false; - let min = parseInt(cncserver.settings.botConf.get('servo:min'), 10); - let max = parseInt(cncserver.settings.botConf.get('servo:max'), 10); - let range = max - min; - let normalizedState = state; // Normalize/sanitize the incoming state - - const presets = cncserver.settings.botConf.get('servo:presets'); - let height = 0; // Placeholder for height output - - // Validate Height, and conform to a bottom to top based percentage 0 to 100 - if (Number.isNaN(parseInt(state, 10))) { // Textual position! - if (typeof presets[state] !== 'undefined') { - height = parseFloat(presets[state]); - } else { // Textual expression not found, default to UP - height = presets.up; - normalizedState = 'up'; - } - - fullRange = true; - } else { // Numerical position (0 to 1), moves between up (0) and draw (1) - height = Math.abs(parseFloat(state)); - height = height > 1 ? 1 : height; // Limit to 1 - normalizedState = height; - - // Reverse value and lock to 0 to 100 percentage with 1 decimal place - height = parseInt((1 - height) * 1000, 10) / 10; - } - - // Lower the range when using 0 to 1 values to between up and draw - if (!fullRange) { - min = ((presets.draw / 100) * range) + min; - max = ((presets.up / 100) * range); - max += parseInt(cncserver.settings.botConf.get('servo:min'), 10); +/** + * Get the distance/length of the given vector coordinate + * + * @param {{x: number, y: number}} vector + * Object representing coordinate away from (0,0) + * @returns {number} + * Length (in steps) of the given vector point + */ +export function getVectorLength(vector) { + return Math.sqrt((vector.x ** 2) + (vector.y ** 2)); +} - range = max - min; +/** + * Perform conversion from named/0-1 number state value to given pen height + * suitable for outputting to a Z axis control statement. + * + * @param {string|integer} state + * + * @returns {object} + * Object containing normalized state, and numeric height value. As: + * {state: [integer|string], height: [float]} + */ +export function stateToHeight(state) { + // Whether to use the full min/max range (used for named presets only) + let fullRange = false; + let min = parseInt(botConf.get('servo:min'), 10); + let max = parseInt(botConf.get('servo:max'), 10); + let range = max - min; + let normalizedState = state; // Normalize/sanitize the incoming state + + const presets = botConf.get('servo:presets'); + let height = 0; // Placeholder for height output + + // Validate Height, and conform to a bottom to top based percentage 0 to 100 + if (Number.isNaN(parseInt(state, 10))) { // Textual position! + if (typeof presets[state] !== 'undefined') { + height = parseFloat(presets[state]); + } else { // Textual expression not found, default to UP + height = presets.up; + normalizedState = 'up'; } - // Sanity check incoming height value to 0 to 100 - height = height > 100 ? 100 : height; - height = height < 0 ? 0 : height; - - // Calculate the final servo value from percentage - height = Math.round(((height / 100) * range) + min); - return { height, state: normalizedState }; + fullRange = true; + } else { // Numerical position (0 to 1), moves between up (0) and draw (1) + height = Math.abs(parseFloat(state)); + height = height > 1 ? 1 : height; // Limit to 1 + normalizedState = height; + + // Reverse value and lock to 0 to 100 percentage with 1 decimal place + height = parseInt((1 - height) * 1000, 10) / 10; + } + + // Lower the range when using 0 to 1 values to between up and draw + if (!fullRange) { + min = ((presets.draw / 100) * range) + min; + max = ((presets.up / 100) * range); + max += parseInt(botConf.get('servo:min'), 10); + + range = max - min; + } + + // Sanity check incoming height value to 0 to 100 + height = height > 100 ? 100 : height; + height = height < 0 ? 0 : height; + + // Calculate the final servo value from percentage + height = Math.round(((height / 100) * range) + min); + return { height, state: normalizedState }; +} + +export function round(number, precision) { + const p = 10 ** precision; + return Math.round(number * p) / p; +} + +export function roundPoint(point, precision = 2) { + if (Array.isArray(point)) { + return [ + round(point[0], precision), + round(point[1], precision), + ]; + } + + return { + x: round(point.x, precision), + y: round(point.y, precision), }; +} - utils.round = (number, precision) => { - const p = 10 ** precision; - return Math.round(number * p) / p; - }; - - utils.roundPoint = (point, precision = 2) => { - if (Array.isArray(point)) { - return [ - utils.round(point[0], precision), - utils.round(point[1], precision), - ]; - } +/** + * Wrap SVG string content with a header and footer. + * + * @export + * @param {string} content + * SVG inner content to be wrapped. + * @param {{width: number, height: number}} size + * Object containing width and height in SVG size. + * + * @returns {string} + * Final complete SVG text content. + */ +export function wrapSVG(content, size) { + return singleLineString`\n + ${content}`; +} - return { - x: utils.round(point.x, precision), - y: utils.round(point.y, precision), - }; - }; +/** + * Map a value in a given range to a new range. + * + * @param {number} x + * The input number to be mapped. + * @param {number} inMin + * Expected minimum of the input number. + * @param {number} inMax + * Expected maximum of the input number. + * @param {number} outMin + * Expected minimum of the output map. + * @param {number} outMax + * Expected maximum of the output map. + * + * @return {number} + * The output number after mapping. + */ +export function map(x, inMin, inMax, outMin, outMax) { + return ((x - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin; +} - // Wrap SVG string content with a header and footer. - utils.wrapSVG = (content, size) => { - const header = ` - `; - return `${header}${content}`; - }; +/** + * Perform conversion from array structure to a map based on ID. + * + * @param {array} data + * + * @returns {Map} + * Map of array data keyed by "id" in array data. + */ +export function arrayToIDMap(data) { + const m = new Map(); + data.forEach(d => { + m.set(d.id, d); + }); + + return m; +} - /** - * Map a value in a given range to a new range. - * - * @param {number} x - * The input number to be mapped. - * @param {number} inMin - * Expected minimum of the input number. - * @param {number} inMax - * Expected maximum of the input number. - * @param {number} outMin - * Expected minimum of the output map. - * @param {number} outMax - * Expected maximum of the output map. - * - * @return {number} - * The output number after mapping. - */ - utils.map = (x, inMin, inMax, outMin, outMax) => (x - inMin) * (outMax - outMin) / (inMax - inMin) + outMin; - - return utils; -}; +/** + * Reverse conversion from map flat array. + * + * @param {Map} data + * + * @returns {array} + * Flat array of data. + */ +export function mapToArray(data) { + return data ? Array.from(data.values()) : []; +} diff --git a/src/components/index.js b/src/components/index.js deleted file mode 100644 index 639024d5..00000000 --- a/src/components/index.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @file Index of all source components. - */ - -module.exports = { - utils: './components/core/utils/cncserver.utils', - binder: './components/core/utils/cncserver.binder', - settings: './components/core/utils/cncserver.settings', - i18n: './components/core/utils/cncserver.i18n', - server: './components/core/comms/cncserver.server', - rest: './components/core/comms/cncserver.rest', - api: './components/core/comms/cncserver.api', - ipc: './components/core/comms/cncserver.ipc', - serial: './components/core/comms/cncserver.serial', - sockets: './components/core/comms/cncserver.sockets', - pen: './components/core/control/cncserver.pen', - actualPen: './components/core/control/cncserver.actualpen', - control: './components/core/control/cncserver.control', - buffer: './components/core/utils/cncserver.buffer', - run: './components/core/utils/cncserver.run', - scratch: './components/third_party/scratch/cncserver.scratch', - bots: './components/machine_support/', - drawing: './components/core/drawing', - projects: './components/core/control/cncserver.projects', - content: './components/core/control/cncserver.content', - schemas: './components/core/schemas/cncserver.schemas', -}; diff --git a/src/components/machine_support/cncserver.bots.base.js b/src/components/machine_support/cncserver.bots.base.js index 70b7b830..f4d1ee45 100644 --- a/src/components/machine_support/cncserver.bots.base.js +++ b/src/components/machine_support/cncserver.bots.base.js @@ -1,24 +1,31 @@ /** * @file Abstraction module for base bot specific stuff. */ -const base = { id: 'base' }; // Exposed export. +import { bindTo } from 'cs/binder'; +import run from 'cs/run'; +import { manualSwapTrigger } from 'cs/sockets'; -module.exports = (cncserver) => { +/** + * Initialize bot specific code. + * + * @export + */ +export default function initBot() { // Bind base wait toolchange support to the toolchange event. - cncserver.binder.bindTo('tool.change', base.id, (tool) => { + bindTo('tool.change', 'base', tool => { // A "wait" tool requires user feedback before it can continue. if (typeof tool.wait !== 'undefined') { // Queue a callback to pause continued execution on tool.wait value if (tool.wait) { - cncserver.run('callback', () => { - cncserver.sockets.manualSwapTrigger(tool.index); + run('callback', () => { + manualSwapTrigger(tool.index); }); // Queue a special low-level pause in the runner. - cncserver.run('special', 'pause'); + run('special', 'pause'); } } }); - return base; -}; + return { id: 'bots.base' }; +} diff --git a/src/components/machine_support/cncserver.bots.ebb.js b/src/components/machine_support/cncserver.bots.ebb.js index d5fae298..a0584458 100644 --- a/src/components/machine_support/cncserver.bots.ebb.js +++ b/src/components/machine_support/cncserver.bots.ebb.js @@ -2,42 +2,51 @@ * @file Abstraction module for EiBotBoard specific support. * @see http://evil-mad.github.io/EggBot/ebb.html */ -const semver = require('semver'); - -const ebb = { id: 'ebb', version: {} }; // Exposed export. +import semver from 'semver'; +import { bindTo } from 'cs/binder'; +import run from 'cs/run'; +import { setSetupCommands } from 'cs/serial'; +import { cmdstr } from 'cs/buffer'; +import { botConf } from 'cs/settings'; +import { getSerialValue } from 'cs/ipc'; +import { singleLineString as SLS } from 'cs/utils'; + +const ebb = { id: 'bots.ebb', version: {} }; // Exposed export. const minVersion = '>=2.2.7'; let controller = {}; // Placeholder for machine config export. -module.exports = (cncserver) => { +/** + * Initialize bot specific code. + * + * @export + */ +export default function initBot() { // Bot support in use parent callback. - ebb.checkInUse = (botConf) => { - ebb.inUse = botConf.controller.name === 'EiBotBoard'; + ebb.checkInUse = botConfig => { + ebb.inUse = botConfig.controller.name === 'EiBotBoard'; }; // Bind EBB support on controller setup -before- serial connection. - cncserver.binder.bindTo('controller.setup', ebb.id, (controller) => { - if (controller.name === 'EiBotBoard') { - cncserver.serial.setSetupCommands([ + bindTo('controller.setup', ebb.id, controllerConfig => { + if (controllerConfig.name === 'EiBotBoard') { + setSetupCommands([ // Set motor precision. - cncserver.buffer.cmdstr( - 'enablemotors', - { p: cncserver.settings.botConf.get('speed:precision') } - ), + cmdstr('enablemotors', { p: botConf.get('speed:precision') }), ]); } }); // Bind to serial connection. - cncserver.binder.bindTo('serial.connected', ebb.id, () => { + bindTo('serial.connected', ebb.id, () => { // Exit early if we've already done this (happens on reconnects). if (ebb.version.value) { return; } - controller = cncserver.settings.botConf.get('controller'); + controller = botConf.get('controller'); // Get EBB version. - cncserver.ipc.getSerialValue('version').then((message) => { + getSerialValue('version').then(message => { if (message.includes(controller.error)) { console.error('='.repeat(76)); console.error('Failed to load version information! Message given:'); @@ -52,19 +61,23 @@ module.exports = (cncserver) => { value: version, string: message, }; - console.log(`Connected to ${controller.manufacturer} ${controller.name}, firmware v${version}`); + console.log( + SLS`Connected to ${controller.manufacturer} ${controller.name}, + firmware v${version}` + ); if (!semver.satisfies(version, minVersion)) { console.error('='.repeat(76)); - console.error(`ERROR: Firmware version does not meet minimum requirements (${minVersion})`); - console.error('To upgrade, see: https://wiki.evilmadscientist.com/Updating_EBB_firmware'); + console.error(SLS`ERROR: Firmware version does not meet minimum + requirements (${minVersion})`); + console.error(SLS`To upgrade, see: + https://wiki.evilmadscientist.com/Updating_EBB_firmware`); console.error('='.repeat(76)); } }); }); - // Bind to general controller message returns. - cncserver.binder.bindTo('serial.message', ebb.id, (message) => { + bindTo('serial.message', ebb.id, message => { // If this isn't an acknowledgement of a working command... if (message !== controller.ack) { if (message.includes(controller.error)) { @@ -118,8 +131,8 @@ module.exports = (cncserver) => { */ // TODO: Find/replace all instances of "serial.sendEBBSetup" ebb.sendSetup = (id, value) => { - cncserver.run('custom', `SC,${id},${value}`); + run('custom', `SC,${id},${value}`); }; return ebb; -}; +} diff --git a/src/components/machine_support/cncserver.bots.watercolorbot.js b/src/components/machine_support/cncserver.bots.watercolorbot.js index f0ab0480..cdf838d2 100644 --- a/src/components/machine_support/cncserver.bots.watercolorbot.js +++ b/src/components/machine_support/cncserver.bots.watercolorbot.js @@ -1,17 +1,28 @@ /** * @file Abstraction module for watercolorbot specific stuff. */ +import { bindTo } from 'cs/binder'; +import * as pen from 'cs/pen'; +import * as control from 'cs/control'; +import * as tools from 'cs/tools'; +import { getColorID } from 'cs/drawing/base'; +import { getPreset } from 'cs/utils'; // Exposed export. const watercolorbot = { - id: 'watercolorbot', + id: 'bots.watercolorbot', paintDistance: 0, maxPaintDistance: 150, // 48.2 * 10, }; -module.exports = (cncserver) => { +/** + * Initialize bot specific code. + * + * @export + */ +export default function initBot() { // Bot support in use parent callback. - watercolorbot.checkInUse = (botConf) => { + watercolorbot.checkInUse = botConf => { watercolorbot.inUse = botConf.name === 'WaterColorBot'; }; @@ -27,7 +38,7 @@ module.exports = (cncserver) => { * How many times to move. */ watercolorbot.wigglePen = (axis, rawTravel, iterations) => { - const start = { x: Number(cncserver.pen.state.x), y: Number(cncserver.pen.state.y) }; + const start = { x: Number(pen.state.x), y: Number(pen.state.y) }; let i = 0; const travel = Number(rawTravel); // Make sure it's not a string @@ -55,7 +66,7 @@ module.exports = (cncserver) => { point[axis] += (toggle ? travel : travel * -1); } - cncserver.control.movePenAbs(point); + control.movePenAbs(point); i++; @@ -63,7 +74,7 @@ module.exports = (cncserver) => { if (i <= iterations) { _wiggleSlave(!toggle); } else { // Done wiggling, go back to start - cncserver.control.movePenAbs(start); + control.movePenAbs(start); } } @@ -74,26 +85,26 @@ module.exports = (cncserver) => { // Run a full wash in all the waters. watercolorbot.fullWash = () => { // TODO: Fix water0 overreach - cncserver.control.setTool('water0dip'); - cncserver.control.setTool('water1'); - cncserver.control.setTool('water2'); + tools.changeTo('water0dip'); + tools.changeTo('water1'); + tools.changeTo('water2'); }; // Reink with a water dip. - watercolorbot.reink = (tool = cncserver.pen.state.tool) => { - cncserver.control.setTool('water0dip'); - cncserver.control.setTool(tool); + watercolorbot.reink = (tool = pen.state.tool) => { + tools.changeTo('water0dip'); + tools.changeTo(tool); }; // Bind the wiggle to the toolchange event. - cncserver.binder.bindTo('tool.change', watercolorbot.id, (tool) => { + bindTo('tool.change', 'watercolorbot', tool => { // Only trigger this when the current tool isn't a wait. if (typeof tool.wait === 'undefined') { // Set the height based on what kind of tool it is. const downHeight = tool.name.indexOf('water') !== -1 ? 'wash' : 'draw'; // Brush down - cncserver.pen.setHeight(downHeight); + pen.setHeight(downHeight); // Wiggle the brush a bit watercolorbot.wigglePen( @@ -103,7 +114,7 @@ module.exports = (cncserver) => { ); // Put the pen back up when done! - cncserver.pen.setHeight('up'); + pen.setHeight('up'); // Reset paintDistance watercolorbot.paintDistance = 0; @@ -111,17 +122,17 @@ module.exports = (cncserver) => { }); // Bind to begin of rendering path color group. - cncserver.binder.bindTo('control.render.group.begin', watercolorbot.id, (colorID) => { + bindTo('print.render.group.begin', watercolorbot.id, colorID => { watercolorbot.fullWash(); }); // Bind to end of rendering everything, wash that brush. - cncserver.binder.bindTo('control.render.finish', watercolorbot.id, () => { + bindTo('print.render.finish', watercolorbot.id, () => { watercolorbot.fullWash(); }); // Bind to path parsing for printing, allows for splitting paths to reink. - cncserver.binder.bindTo('control.render.path.select', watercolorbot.id, (paths) => { + bindTo('print.render.path.select', watercolorbot.id, paths => { // Only trigger this when WCB is in use. // TODO: don't trigger this for non reinking implements 😬 if (watercolorbot.inUse) { @@ -158,16 +169,19 @@ module.exports = (cncserver) => { }); // Bind to render path complete, to trigger reinking as needed - cncserver.binder.bindTo('control.render.path.finish', watercolorbot.id, (path) => { + bindTo('print.render.path.finish', watercolorbot.id, path => { watercolorbot.paintDistance += path.length; if (watercolorbot.paintDistance >= watercolorbot.maxPaintDistance - 1) { - watercolorbot.reink(cncserver.drawing.base.getColorID(path)); + watercolorbot.reink(getColorID(path)); } }); // Bind to color setdefault to set watercolors - cncserver.binder.bindTo('colors.setDefault', watercolorbot.id, passthroughSet => (watercolorbot.inUse ? cncserver.drawing.colors.setFromPreset('generic-watercolor-generic') : passthroughSet)); - + bindTo('colors.setDefault', watercolorbot.id, passthroughSet => ( + watercolorbot.inUse + ? getPreset('colorsets', 'generic-watercolor-generic') + : passthroughSet + )); return watercolorbot; -}; +} diff --git a/src/components/machine_support/index.js b/src/components/machine_support/index.js index b72f8681..c23685a1 100644 --- a/src/components/machine_support/index.js +++ b/src/components/machine_support/index.js @@ -1,24 +1,25 @@ /** * @file Index for all machine support components. */ -/* eslint-disable global-require */ -const bots = {}; // Conglomerated bot support export. - -module.exports = (cncserver) => { - bots.base = require('./cncserver.bots.base.js')(cncserver); - bots.ebb = require('./cncserver.bots.ebb.js')(cncserver); - bots.watercolorbot = require('./cncserver.bots.watercolorbot.js')(cncserver); +import { bindTo } from 'cs/binder'; +import { botConf } from 'cs/settings'; +import base from 'cs/bots/base'; +import ebb from 'cs/bots/ebb'; +import watercolorbot from 'cs/bots/watercolorbot'; +// Conglomerated bot support export. +export const bots = { + base: base(), + ebb: ebb(), + watercolorbot: watercolorbot(), +}; - // Allow each bot support module to enable/disable itself based on setup info. - cncserver.binder.bindTo('controller.setup', 'bots', () => { - const conf = cncserver.settings.botConf.get(); - Object.values(bots).forEach((botSupport) => { - if (typeof botSupport.checkInUse === 'function') { - botSupport.checkInUse(conf); - } - }); +// Allow each bot support module to enable/disable itself based on setup info. +bindTo('controller.setup', 'bots', () => { + const conf = botConf.get(); + Object.values(bots).forEach(botSupport => { + if (typeof botSupport.checkInUse === 'function') { + botSupport.checkInUse(conf); + } }); - - return bots; -}; +}); diff --git a/src/components/third_party/scratch/SCRATCH.API.md b/src/components/third_party/scratch/SCRATCH.API.md index b4971905..bb88eb04 100644 --- a/src/components/third_party/scratch/SCRATCH.API.md +++ b/src/components/third_party/scratch/SCRATCH.API.md @@ -7,25 +7,26 @@ these are as experimental as Scratch considers them, and the output format may change without warning. ### Unlike the ReSTful API: - * `GET` is the only method used for the requests. This means data is only -passed in the URL path structure, or in get variables. - * No data is intended to be read from HTTP responses with the exception of the -`/poll` endpoint, therefore other request in the API return no data. - * Scratch currently doesn't support any kind of namespacing for URIs, so these -endpoints live outside the `/v1` ReSTful namsespaced API, directly on the -root. - * Scratch _**also**_ doesn't seem to support arbitrary slashes in URI structure -so I've replaced them with periods. This unfortunately does not apply to -variables, and must be also added to the end before any variables (if any). -:imp: + +- `GET` is the only method used for the requests. This means data is only + passed in the URL path structure, or in get variables. +- No data is intended to be read from HTTP responses with the exception of the + `/poll` endpoint, therefore other request in the API return no data. +- Scratch currently doesn't support any kind of namespacing for URIs, so these + endpoints live outside the `/v1` ReSTful namsespaced API, directly on the + root. +- Scratch _**also**_ doesn't seem to support arbitrary slashes in URI structure + so I've replaced them with periods. This unfortunately does not apply to + variables, and must be also added to the end before any variables (if any). + :imp: In each request example below, the server is assumed to be added to the beginning of each resource, E.G.: `GET http://localhost:4242/poll` will `GET` the poll content output page from a server plugged into the local computer, at the default port of `4242`. - ### How do I use this? + Though there's not a 1:1 relationship between them, these API endpoints are intended to be used in Scratch via the custom block definitions found in [watercolorbot_scratch.s2e](watercolorbot_scratch.s2e). Though this is @@ -42,8 +43,8 @@ the `.s2e` file above from the root of CNC Server. Once imported, the blocks depicted above should all be available in the "More Blocks" section on the UI. - ### An Important Note on Timing... :clock1: :clock1030: :clock10: + At the beginning of [creating Scratch support](https://github.com/techninja/cncserver/issues/50), efforts focused on ensuring that Scratch would wait for every single process to @@ -57,17 +58,20 @@ the buffer. One remaining downside is that X/Y/Z positional values (and others) read from the bot are realtime and do not take into account past or possible future values. -* * * +--- ## 1. Output -These are Scratch 2 required, *output only* resources required for general use. + +These are Scratch 2 required, _output only_ resources required for general use. ### GET /poll + This page is polled during every screen draw (about 30 times a second), and -all input *into* Scratch is handled here. Any value block are populated directly +all input _into_ Scratch is handled here. Any value block are populated directly from this page, along with any waiting processes (not yet fully implemented). #### Response + ```javascript HTTP/1.1 200 OK Content-Type: text/plain; charset=UTF-8 @@ -79,7 +83,6 @@ angle 90 sleeping 0 state 0 height 0 -busy false tool color0 lastDuration 0 distanceCounter 0 @@ -87,65 +90,77 @@ simulation 1 ``` ##### Usage Notes - * Values are set directly from internal storage variables and reflect what the -bot is currently doing, not what was sent into the buffer and still must be -done. - * `x` & `y` are in an arbitrary absolute scale based on the relative offset -between Scratch pixel values and the number of steps on the WaterColorBot stage -at default resolution, with 0,0 right in the center to mimic Scratch standards. - * `z` is either 0 or 1 depending on if the brush is up (0/Not painting), or down -(1/Totally painting) - * `angle` the current angle of the internal "turtle", used only for this API to -allow for linear forward and backward relative movement. - * `sleeping`, either 0 or 1, states whether the API is actually listening to -commands. - * Other fields are pulled directly from the -[pen object of the ReSTful API](https://github.com/techninja/cncserver/blob/master/API.md#response). - -* * * + +- Values are set directly from internal storage variables and reflect what the + bot is currently doing, not what was sent into the buffer and still must be + done. +- `x` & `y` are in an arbitrary absolute scale based on the relative offset + between Scratch pixel values and the number of steps on the WaterColorBot stage + at default resolution, with 0,0 right in the center to mimic Scratch standards. +- `z` is either 0 or 1 depending on if the brush is up (0/Not painting), or down + (1/Totally painting) +- `angle` the current angle of the internal "turtle", used only for this API to + allow for linear forward and backward relative movement. +- `sleeping`, either 0 or 1, states whether the API is actually listening to + commands. +- Other fields are pulled directly from the + [pen object of the ReSTful API](https://github.com/techninja/cncserver/blob/master/API.md#response). + +--- ### GET /crossdomain.xml + Created before the CORs standard, this required "file" for flash applications allows the resources to be accessed outside of the security realm of the flash application. #### Response + ```javascript GET /crossdomain.xml Content-Type: text/plain; charset=UTF-8 ``` -* * * + +--- ## Triggers/Input + **Note:** The following examples do not include requests or responses as these all use the `GET` HTTP method with URL parameters and return no text in the body as described above. ## 3. Absolute movement/settings + If you need to change something to an exact know value, these are what you need. ### GET /pen.up & /pen.down + Does just what it says on the tin. ### GET /coord/:x/:y + Set the absolute X/Y position, given in the same pixel approximate scale in the return coordinates. ### GET /coord/:named-x/:named-y + Set the absolute X/Y position based on the given word for the position, E.G.: `/coord/right/bottom`, `/coord/center/top`. Accepts standard English `top`, `left`, `right`, `bottom` & `center`. ### GET /move.absturn./:angle + Set the exact angle of the turtle's facing direction or relative movement to the given angle. ### GET /move.speed./:value + Sets the move speed to the given value, accepts 0-10. ### GET /penreink/:distance + Sets the number of centimeters the bot will draw (movement over the canvas while the brush is down) before "re-inking" the brush with the last used media (water/paint). 48 is the default value used by RoboPaint and is recommended. @@ -153,74 +168,90 @@ This value is reset to 0 (do nothing) whenever the Stop button/`/reset_all` is triggered. ### GET /penstopreink + A shortcut to set the re-ink distance to 0, or OFF. -* * * +--- ## 4. Relative movement/settings + Relative movement is made possible via the "turtle" pointer interface and is based on angle, X/Y or other known variables. All relative values are sanity checked to avoid crashes. ### GET /move.forward./:amount + Steps the turtle forward (based on angle) from current position. ### GET /move.nudge.x./:amount & /move.nudge.y./:amount + Nudges the turtle X or Y value by the given amount. Allows for positive or negative values, does not change turtle angle. ### GET /move.right./:amount & /move.left./:amount + Rotate direction that the turtle is facing either right or left by the amount of degrees given. ### GET /move.toward./:x/:y + Absolutely sets the turtle facing direction angle towards an absolute X/Y -coordinate *relative* to the current turtle position. Useful for "follow the +coordinate _relative_ to the current turtle position. Useful for "follow the mouse" applications. -* * * +--- ## 5. Grouped functions + These do a series of actions to help make things easier ### GET /tool.color./:index & /tool.water./:index + Helpers for getting water or paint on the brush based off the integer index number given. 0-2 for `water`, 0-7 for `color`. ### GET /pen.wash + Washes the brush in all three water dishes from the top to the bottom. Does not park, but does life the brush when complete. ### GET /park -Park the bot in the top left parking location. Note: this is *not* the default + +Park the bot in the top left parking location. Note: this is _not_ the default absolute center position the turtle pointer begins at, and this will not effect the turtle position. ## 6. Misc ### GET /reset_all + Triggered by Scratch when the red "stop sign" is clicked, or when another action is expected to reset the program. ##### Usage Notes - * Completely resets internal "turtle" to 0 position - * Clears entire run buffer, so this should stop any currently running action -or group action (like getting paint). - * _*Does not*_ park or lift the brush. This may be up for debate. -* * * +- Completely resets internal "turtle" to 0 position +- Clears entire run buffer, so this should stop any currently running action + or group action (like getting paint). +- _*Does not*_ park or lift the brush. This may be up for debate. + +--- ### GET /pen.off + Turn off motors and reset pointer to top left park position. ### GET /pen.resetDistance + Reset the `distanceCounter` variable back to 0. ### GET /pen.sleep.0 & /pen.sleep.1 + Turn on or off sleep mode based on the given value, either 1 for on, 0 for off. Sleep mode ON will prevent the API from doing anything until sleep mode is turned OFF. ### GET /move.wait./:seconds + Sets a "wait" command in the buffer for the specified number of seconds, decimal values like 0.5 are OK. diff --git a/src/components/third_party/scratch/cncserver.scratch.js b/src/components/third_party/scratch/cncserver.scratch.js index ac50b165..90313e56 100644 --- a/src/components/third_party/scratch/cncserver.scratch.js +++ b/src/components/third_party/scratch/cncserver.scratch.js @@ -1,405 +1,408 @@ /** * @file CNC Server scratch support module. */ +import * as pen from 'cs/pen'; +import * as actualPen from 'cs/actualPen'; +import * as utils from 'cs/utils'; +import * as control from 'cs/control'; +import * as tools from 'cs/tools'; +import { bot, botConf, gConf } from 'cs/settings'; +import run from 'cs/run'; +import { createServerEndpoint } from 'cs/rest'; +import * as buffer from 'cs/buffer'; + const sizeMultiplier = 10; // Amount to increase size of steps let turtle = {}; // Global turtle state object. -const scratch = {}; - -module.exports = (cncserver) => { - // Move request endpoint handler function - function moveRequest(req) { - const url = req.originalUrl.split('.'); - - const op = url[1]; - const { arg2 } = req.params; - let { arg } = req.params; - if (arg2 && !arg) { - [,, arg] = url; - } - // Do nothing if sleeping - if (turtle.sleeping) { - // TODO: Do we care about running the math? - return { code: 200, body: '' }; - } +// Move request endpoint handler function +function moveRequest(req) { + const url = req.originalUrl.split('.'); - // Park - if (req.url === '/park') { - cncserver.pen.setHeight('up'); - cncserver.pen.setPen({ - x: cncserver.settings.bot.park.x, - y: cncserver.settings.bot.park.y, - park: true, - }); - return { code: 200, body: '' }; - } + const op = url[1]; + const { arg2 } = req.params; + let { arg } = req.params; + if (arg2 && !arg) { + [, , arg] = url; + } - // Arbitrary Wait - if (op === 'wait') { - arg = parseFloat(arg) * 1000; - cncserver.run('wait', false, arg); - return { code: 200, body: '' }; - } + // Do nothing if sleeping + if (turtle.sleeping) { + // TODO: Do we care about running the math? + return { code: 200, body: '' }; + } - // Speed setting - if (op === 'speed') { - arg = parseFloat(arg) * 10; - cncserver.settings.botConf.set('speed:drawing', arg); - cncserver.settings.botConf.set('speed:moving', arg); - } + // Park + if (req.url === '/park') { + pen.setHeight('up'); + pen.setPen({ + x: bot.park.x, + y: bot.park.y, + park: true, + }); + return { code: 200, body: '' }; + } - // Rotating Pointer? (just rotate) - if (op === 'left' || op === 'right') { - arg = parseInt(arg, 10); - turtle.degrees = op === 'right' - ? turtle.degrees + arg - : turtle.degrees - arg; - - if (turtle.degrees > 360) turtle.degrees -= 360; - if (turtle.degrees < 0) turtle.degrees += 360; - console.log(`Rotate ${op} ${arg} deg. to ${turtle.degrees} deg.`); - return { code: 200, body: '' }; - } + // Arbitrary Wait + if (op === 'wait') { + arg = parseFloat(arg) * 1000; + run('wait', false, arg); + return { code: 200, body: '' }; + } - // Rotate pointer towards turtle relative X/Y - if (op === 'toward') { - const { workArea } = cncserver.settings.bot; + // Speed setting + if (op === 'speed') { + arg = parseFloat(arg) * 10; + botConf.set('speed:drawing', arg); + botConf.set('speed:moving', arg); + } - // Convert input X/Y from scratch coordinates - const point = { - x: (parseInt(arg, 10) * sizeMultiplier) + workArea.absCenter.x, - y: (-parseInt(arg2, 10) * sizeMultiplier) + workArea.absCenter.y, - }; + // Rotating Pointer? (just rotate) + if (op === 'left' || op === 'right') { + arg = parseInt(arg, 10); + turtle.degrees = op === 'right' + ? turtle.degrees + arg + : turtle.degrees - arg; - const theta = Math.atan2(point.y - turtle.y, point.x - turtle.x); - turtle.degrees = Math.round(theta * 180 / Math.PI); - if (turtle.degrees > 360) turtle.degrees -= 360; - if (turtle.degrees < 0) turtle.degrees += 360; + if (turtle.degrees > 360) turtle.degrees -= 360; + if (turtle.degrees < 0) turtle.degrees += 360; + console.log(`Rotate ${op} ${arg} deg. to ${turtle.degrees} deg.`); + return { code: 200, body: '' }; + } - console.log(`Rotate relative towards ${point.x}, ${point.y} - from ${turtle.x}, ${turtle.y} to ${turtle.degrees} deg`); - return { code: 200, body: '' }; - } + // Rotate pointer towards turtle relative X/Y + if (op === 'toward') { + const { workArea } = bot; - // Rotate pointer directly - if (op === 'absturn') { - // Correct for "standard" Turtle orientation in Scratch. - turtle.degrees = parseInt(arg, 10) - 90; - console.log( - `Rotate to ${arg} scratch degrees (actual angle ${turtle.degrees} deg)` - ); - return { code: 200, body: '' }; - } + // Convert input X/Y from scratch coordinates + const point = { + x: (parseInt(arg, 10) * sizeMultiplier) + workArea.absCenter.x, + y: (-parseInt(arg2, 10) * sizeMultiplier) + workArea.absCenter.y, + }; - // Simple Nudge X/Y - if (op === 'nudge') { - if (arg === 'y') { - turtle[arg] += -1 * parseInt(arg2, 10) * sizeMultiplier; - } else { - turtle[arg] += parseInt(arg2, 10) * sizeMultiplier; - } - } + const theta = Math.atan2(point.y - turtle.y, point.x - turtle.x); + turtle.degrees = Math.round(theta * 180 / Math.PI); + if (turtle.degrees > 360) turtle.degrees -= 360; + if (turtle.degrees < 0) turtle.degrees += 360; + + console.log(`Rotate relative towards ${point.x}, ${point.y} + from ${turtle.x}, ${turtle.y} to ${turtle.degrees} deg`); + return { code: 200, body: '' }; + } - // Move Pointer? Actually move! - if (op === 'forward') { - arg = parseInt(arg, 10); + // Rotate pointer directly + if (op === 'absturn') { + // Correct for "standard" Turtle orientation in Scratch. + turtle.degrees = parseInt(arg, 10) - 90; + console.log( + `Rotate to ${arg} scratch degrees (actual angle ${turtle.degrees} deg)` + ); + return { code: 200, body: '' }; + } - console.log(`Move pen by ${arg} steps`); - const radians = turtle.degrees * (Math.PI / 180); - turtle.x = Math.round(turtle.x + Math.cos(radians) * arg * sizeMultiplier); - turtle.y = Math.round(turtle.y + Math.sin(radians) * arg * sizeMultiplier); + // Simple Nudge X/Y + if (op === 'nudge') { + if (arg === 'y') { + turtle[arg] += -1 * parseInt(arg2, 10) * sizeMultiplier; + } else { + turtle[arg] += parseInt(arg2, 10) * sizeMultiplier; } + } + + // Move Pointer? Actually move! + if (op === 'forward') { + arg = parseInt(arg, 10); - // Move x, y or both - if (op === 'x' || op === 'y' || typeof req.params.x !== 'undefined') { - arg = parseInt(arg, 10); + console.log(`Move pen by ${arg} steps`); + const radians = turtle.degrees * (Math.PI / 180); + turtle.x = Math.round(turtle.x + Math.cos(radians) * arg * sizeMultiplier); + turtle.y = Math.round(turtle.y + Math.sin(radians) * arg * sizeMultiplier); + } - if (op === 'x' || op === 'y') { - turtle[op] = arg * sizeMultiplier; + // Move x, y or both + if (op === 'x' || op === 'y' || typeof req.params.x !== 'undefined') { + arg = parseInt(arg, 10); + + if (op === 'x' || op === 'y') { + turtle[op] = arg * sizeMultiplier; + } else { + // Word positions? convert to actual coordinates + // X/Y swapped for "top left" arg positions. + const wordX = ['left', 'center', 'right'].indexOf(req.params.y); + const wordY = ['top', 'center', 'bottom'].indexOf(req.params.x); + if (wordX > -1) { + const steps = utils.centToSteps({ + x: (wordX / 2) * 100, + y: (wordY / 2) * 100, + }); + + turtle.x = steps.x; + turtle.y = steps.y; } else { - // Word positions? convert to actual coordinates - // X/Y swapped for "top left" arg positions. - const wordX = ['left', 'center', 'right'].indexOf(req.params.y); - const wordY = ['top', 'center', 'bottom'].indexOf(req.params.x); - if (wordX > -1) { - const steps = cncserver.utils.centToSteps({ - x: (wordX / 2) * 100, - y: (wordY / 2) * 100, - }); - - turtle.x = steps.x; - turtle.y = steps.y; - } else { - // Convert input X/Y to steps via multiplier - turtle.x = parseInt(req.params.x, 10) * sizeMultiplier; - - // In Scratch, positive Y is up on the page. :( - turtle.y = -1 * parseInt(req.params.y, 10) * sizeMultiplier; - - // When directly setting XY position, offset by half for center 0,0 - turtle.x += cncserver.settings.bot.workArea.absCenter.x; - turtle.y += cncserver.settings.bot.workArea.absCenter.y; - } - } + // Convert input X/Y to steps via multiplier + turtle.x = parseInt(req.params.x, 10) * sizeMultiplier; + + // In Scratch, positive Y is up on the page. :( + turtle.y = -1 * parseInt(req.params.y, 10) * sizeMultiplier; - console.log(`Move pen to coord ${turtle.x}, ${turtle.y}`); + // When directly setting XY position, offset by half for center 0,0 + turtle.x += bot.workArea.absCenter.x; + turtle.y += bot.workArea.absCenter.y; + } } - // Attempt to move pen to desired point (may be off screen) - const distance = cncserver.control.movePenAbs(turtle); - if (distance === 0) console.log('Not moved any distance'); + console.log(`Move pen to coord ${turtle.x}, ${turtle.y}`); + } - // Add up distance counter - if ((cncserver.utils.penDown()) && !cncserver.pen.state.offCanvas) { - turtle.distanceCounter = parseInt( - Number(distance) + Number(turtle.distanceCounter), - 10 - ); - } + // Attempt to move pen to desired point (may be off screen) + const distance = control.movePenAbs(turtle); + if (distance === 0) console.log('Not moved any distance'); - // If reink initialized, check distance and initiate reink! - if (turtle.reinkDistance > 0 - && turtle.distanceCounter > turtle.reinkDistance) { - turtle.distanceCounter = 0; + // Add up distance counter + if ((pen.isDown()) && !pen.state.offCanvas) { + turtle.distanceCounter = parseInt( + Number(distance) + Number(turtle.distanceCounter), + 10 + ); + } - // Reink procedure! - cncserver.control.setTool('water0dip'); // Dip in the water - cncserver.control.setTool(turtle.media); // Apply the last saved media - cncserver.control.movePenAbs(turtle); // Move back to "current" position - cncserver.pen.setHeight('draw'); // Set the position back to draw - } + // If reink initialized, check distance and initiate reink! + if (turtle.reinkDistance > 0 + && turtle.distanceCounter > turtle.reinkDistance) { + turtle.distanceCounter = 0; - return { code: 200, body: '' }; + // Reink procedure! + tools.changeTo('water0dip'); // Dip in the water + tools.changeTo(turtle.media); // Apply the last saved media + control.movePenAbs(turtle); // Move back to "current" position + pen.setHeight('draw'); // Set the position back to draw } - // General pen style request handler - function penRequest(req) { - // Parse out the arguments as we can't use slashes in the URI(!?!) - const url = req.originalUrl.split('.'); - let [, op, arg] = url; + return { code: 200, body: '' }; +} - // Reset internal counter - if (op === 'resetDistance') { - turtle.distanceCounter = 0; - return { code: 200, body: '' }; - } +// General pen style request handler +function penRequest(req) { + // Parse out the arguments as we can't use slashes in the URI(!?!) + const url = req.originalUrl.split('.'); + let [, op, arg] = url; - // Toggle sleep/simulation mode - if (op === 'sleep') { - arg = parseInt(arg, 10); - turtle.sleeping = !!arg; // Convert integer to true boolean - return { code: 200, body: '' }; - } + // Reset internal counter + if (op === 'resetDistance') { + turtle.distanceCounter = 0; + return { code: 200, body: '' }; + } - // Do nothing if sleeping - if (turtle.sleeping) { - // TODO: Do we care about running the math? - return { code: 200, body: '' }; - } + // Toggle sleep/simulation mode + if (op === 'sleep') { + arg = parseInt(arg, 10); + turtle.sleeping = !!arg; // Convert integer to true boolean + return { code: 200, body: '' }; + } - // Set Pen up/down - if (op === 'up' || op === 'down') { - if (op === 'down') { - op = 'draw'; - } + // Do nothing if sleeping + if (turtle.sleeping) { + // TODO: Do we care about running the math? + return { code: 200, body: '' }; + } - // Don't set the height explicitly when off the canvas - if (!cncserver.pen.state.offCanvas) { - cncserver.pen.setHeight(op); - } else { - // Save the state for when we come back - cncserver.pen.forceState({ state: op }); - } + // Set Pen up/down + if (op === 'up' || op === 'down') { + if (op === 'down') { + op = 'draw'; } - // Run simple wash - if (op === 'wash') { - cncserver.control.setTool('water0'); - cncserver.control.setTool('water1'); - cncserver.control.setTool('water2'); + // Don't set the height explicitly when off the canvas + if (!pen.state.offCanvas) { + pen.setHeight(op); + } else { + // Save the state for when we come back + pen.forceState({ state: op }); } + } - // Turn off motors and zero to park pos - if (op === 'off') { - // Zero the assumed position - const park = cncserver.utils.centToSteps(cncserver.settings.bot.park, true); - cncserver.pen.forceState({ x: park.x, y: park.y }); - cncserver.actualPen.forceState({ x: park.x, y: park.y }); - - // You must zero FIRST then disable, otherwise actualPen is overwritten - cncserver.run('custom', 'EM,0,0'); - cncserver.sockets.sendPenUpdate(); - } - return { code: 200, body: '' }; + // Run simple wash + if (op === 'wash') { + tools.changeTo('water0'); + tools.changeTo('water1'); + tools.changeTo('water2'); } - // Tool Request Handler - function toolRequest(req) { - const type = req.originalUrl.split('.')[1]; + // Turn off motors and zero to park pos + if (op === 'off') { + // Zero the assumed position + const park = utils.centToSteps(bot.park, true); + pen.forceState({ x: park.x, y: park.y }); + actualPen.forceState({ x: park.x, y: park.y }); - // Do nothing if sleeping - if (turtle.sleeping) { - // TODO: Do we care about running the math? - return { code: 200, body: '' }; - } + // You must zero FIRST then disable, otherwise actualPen is overwritten + run('custom', 'EM,0,0'); + } + return { code: 200, body: '' }; +} - // Set by ID (water/color) - if (type) { - const tool = type + parseInt(req.params.id, 10); - cncserver.control.setTool(tool); - turtle.media = tool; - } +// Tool Request Handler +function toolRequest(req) { + const type = req.originalUrl.split('.')[1]; + // Do nothing if sleeping + if (turtle.sleeping) { + // TODO: Do we care about running the math? return { code: 200, body: '' }; } - scratch.initAPI = () => { - const pollData = {}; // "Array" of "sensor" data to be spat out to poll page - turtle = { // Helper turtle for relative movement - x: cncserver.settings.bot.workArea.absCenter.x, - y: cncserver.settings.bot.workArea.absCenter.y, - limit: 'workArea', - sleeping: false, - reinkDistance: 0, - media: 'water0', - degrees: 0, - distanceCounter: 0, - }; + // Set by ID (water/color) + if (type) { + const tool = type + parseInt(req.params.id, 10); + tools.changeTo(tool); + turtle.media = tool; + } - pollData.render = function renderData() { - let out = ''; + return { code: 200, body: '' }; +} + +export function initAPI() { + const pollData = {}; // "Array" of "sensor" data to be spat out to poll page + turtle = { // Helper turtle for relative movement + x: bot.workArea.absCenter.x, + y: bot.workArea.absCenter.y, + limit: 'workArea', + sleeping: false, + reinkDistance: 0, + media: 'water0', + degrees: 0, + distanceCounter: 0, + }; - const { settings: { bot: { workArea } } } = cncserver; + pollData.render = function renderData() { + let out = ''; - out += `x ${(turtle.x - workArea.absCenter.x) / sizeMultiplier}\n`; - out += `y ${(turtle.y - workArea.absCenter.y) / sizeMultiplier}\n`; - out += `z ${cncserver.utils.penDown() ? '1' : '0'}\n`; + const { workArea } = bot; - // Correct for "standard" Turtle orientation in Scratch - let angleTemp = turtle.degrees + 90; - if (angleTemp > 360) { - angleTemp -= 360; - } + out += `x ${(turtle.x - workArea.absCenter.x) / sizeMultiplier}\n`; + out += `y ${(turtle.y - workArea.absCenter.y) / sizeMultiplier}\n`; + out += `z ${pen.isDown() ? '1' : '0'}\n`; - out += `angle ${angleTemp}\n`; - out += `distanceCounter ${turtle.distanceCounter / sizeMultiplier}\n`; - out += `sleeping ${turtle.sleeping ? '1' : '0'}\n`; - - // Loop through all existing/static pollData - // TODO: Fix this from original source, this is just wrong :/ - /* out += Object.keys(pollData).reduce( - (line, key) => `${line}${key} ${pollData[key].join(' ')}\n` - ); */ - - // Throw in full pen data as well - for (const [key, value] of Object.entries(cncserver.pen.state)) { - if (key !== 'x' && key !== 'y' && key !== 'distanceCounter') { - out += `${key} ${value}\n`; - } - } - return out; - }; + // Correct for "standard" Turtle orientation in Scratch + let angleTemp = turtle.degrees + 90; + if (angleTemp > 360) { + angleTemp -= 360; + } - // Helper function to add/remove busy watchers - // TODO: Not fully implemented as performance is better without waiting. - pollData.busy = (id, destroy) => { - if (!pollData._busy) { - pollData._busy = []; // Add busy placeholder - } + out += `angle ${angleTemp}\n`; + out += `distanceCounter ${turtle.distanceCounter / sizeMultiplier}\n`; + out += `sleeping ${turtle.sleeping ? '1' : '0'}\n`; - const index = pollData._busy.indexOf(id); + // Loop through all existing/static pollData + // TODO: Fix this from original source, this is just wrong :/ + /* out += Object.keys(pollData).reduce( + (line, key) => `${line}${key} ${pollData[key].join(' ')}\n` + ); */ - if (destroy && index > -1) { // Remove - pollData._busy.splice(index, 1); - } else if (!destroy && index === -1) { // Add! - pollData._busy.push(id); + // Throw in full pen data as well + for (const [key, value] of Object.entries(pen.state)) { + if (key !== 'x' && key !== 'y' && key !== 'distanceCounter') { + out += `${key} ${value}\n`; } - }; + } + return out; + }; + // Helper function to add/remove busy watchers + // TODO: Not fully implemented as performance is better without waiting. + pollData.busy = (id, destroy) => { + if (!pollData._busy) { + pollData._busy = []; // Add busy placeholder + } - // SCRATCH v2 Specific endpoints =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= - // Central poll returner (Queried ~30hz) - cncserver.rest.createServerEndpoint('/poll', () => ( - { code: 200, body: pollData.render() } - )); - - // Flash crossdomain helper - cncserver.rest.createServerEndpoint('/crossdomain.xml', () => ({ - code: 200, - body: - `\ - `, - })); - - // Initialize/reset status - cncserver.rest.createServerEndpoint('/reset_all', () => { - turtle = { // Reset to default - x: cncserver.settings.bot.workArea.absCenter.x, - y: cncserver.settings.bot.workArea.absCenter.y, - limit: 'workArea', // Limits movements to bot work area - sleeping: false, - media: 'water0', - reinkDistance: 0, - degrees: 0, - distanceCounter: 0, - }; - - // Clear Run Buffer - // @see /v1/buffer/ DELETE - cncserver.buffer.clear(); - - pollData._busy = []; // Clear busy indicators - return { code: 200, body: '' }; - }); + const index = pollData._busy.indexOf(id); - // SCRATCH v2 Specific endpoints =^=-=^=-=^=-=^=-=^=-=^=-=^=-=^=-=^=-=^=-=^= - - // Move Endpoint(s) - cncserver.rest.createServerEndpoint('/park', moveRequest); - cncserver.rest.createServerEndpoint('/coord/:x/:y', moveRequest); - cncserver.rest.createServerEndpoint('/move.forward./:arg', moveRequest); - cncserver.rest.createServerEndpoint('/move.wait./:arg', moveRequest); - cncserver.rest.createServerEndpoint('/move.right./:arg', moveRequest); - cncserver.rest.createServerEndpoint('/move.left./:arg', moveRequest); - cncserver.rest.createServerEndpoint('/move.absturn./:arg', moveRequest); - cncserver.rest.createServerEndpoint('/move.toward./:arg/:arg2', moveRequest); - cncserver.rest.createServerEndpoint('/move.speed./:arg', moveRequest); - - cncserver.rest.createServerEndpoint('/move.nudge.x./:arg2', moveRequest); - cncserver.rest.createServerEndpoint('/move.nudge.y./:arg2', moveRequest); - - // Reink initialization endpoint - cncserver.rest.createServerEndpoint('/penreink/:distance', (req) => { - // 167.7 = 1.6mm per step * 100 mm per cm (as input) - const cm = parseFloat(req.params.distance); - turtle.reinkDistance = Math.round(cm * 167.7); - console.log('Reink distance: ', turtle.reinkDistance); - return { code: 200, body: '' }; - }); + if (destroy && index > -1) { // Remove + pollData._busy.splice(index, 1); + } else if (!destroy && index === -1) { // Add! + pollData._busy.push(id); + } + }; - // Stop Reinking endpoint - cncserver.rest.createServerEndpoint('/penstopreink', () => { - turtle.reinkDistance = 0; - console.log('Reink distance: ', turtle.reinkDistance); - return { code: 200, body: '' }; - }); + // SCRATCH v2 Specific endpoints =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= + // Central poll returner (Queried ~30hz) + createServerEndpoint('/poll', () => ( + { code: 200, body: pollData.render() } + )); + + // Flash crossdomain helper + createServerEndpoint('/crossdomain.xml', () => ({ + code: 200, + body: + `\ + `, + })); + + // Initialize/reset status + createServerEndpoint('/reset_all', () => { + turtle = { // Reset to default + x: bot.workArea.absCenter.x, + y: bot.workArea.absCenter.y, + limit: 'workArea', // Limits movements to bot work area + sleeping: false, + media: 'water0', + reinkDistance: 0, + degrees: 0, + distanceCounter: 0, + }; - // Pen endpoints - cncserver.rest.createServerEndpoint('/pen', penRequest); - cncserver.rest.createServerEndpoint('/pen.wash', penRequest); - cncserver.rest.createServerEndpoint('/pen.up', penRequest); - cncserver.rest.createServerEndpoint('/pen.down', penRequest); - cncserver.rest.createServerEndpoint('/pen.off', penRequest); - cncserver.rest.createServerEndpoint('/pen.resetDistance', penRequest); - cncserver.rest.createServerEndpoint('/pen.sleep.1', penRequest); - cncserver.rest.createServerEndpoint('/pen.sleep.0', penRequest); - - // Tool set endpoints - cncserver.rest.createServerEndpoint('/tool.color./:id', toolRequest); - cncserver.rest.createServerEndpoint('/tool.water./:id', toolRequest); - }; + // Clear Run Buffer + // @see /v1/buffer/ DELETE + buffer.clear(); - return scratch; -}; + pollData._busy = []; // Clear busy indicators + return { code: 200, body: '' }; + }); + + // SCRATCH v2 Specific endpoints =^=-=^=-=^=-=^=-=^=-=^=-=^=-=^=-=^=-=^=-=^= + + // Move Endpoint(s) + createServerEndpoint('/park', moveRequest); + createServerEndpoint('/coord/:x/:y', moveRequest); + createServerEndpoint('/move.forward./:arg', moveRequest); + createServerEndpoint('/move.wait./:arg', moveRequest); + createServerEndpoint('/move.right./:arg', moveRequest); + createServerEndpoint('/move.left./:arg', moveRequest); + createServerEndpoint('/move.absturn./:arg', moveRequest); + createServerEndpoint('/move.toward./:arg/:arg2', moveRequest); + createServerEndpoint('/move.speed./:arg', moveRequest); + + createServerEndpoint('/move.nudge.x./:arg2', moveRequest); + createServerEndpoint('/move.nudge.y./:arg2', moveRequest); + + // Reink initialization endpoint + createServerEndpoint('/penreink/:distance', req => { + // 167.7 = 1.6mm per step * 100 mm per cm (as input) + const cm = parseFloat(req.params.distance); + turtle.reinkDistance = Math.round(cm * 167.7); + console.log('Reink distance: ', turtle.reinkDistance); + return { code: 200, body: '' }; + }); + + // Stop Reinking endpoint + createServerEndpoint('/penstopreink', () => { + turtle.reinkDistance = 0; + console.log('Reink distance: ', turtle.reinkDistance); + return { code: 200, body: '' }; + }); + + // Pen endpoints + createServerEndpoint('/pen', penRequest); + createServerEndpoint('/pen.wash', penRequest); + createServerEndpoint('/pen.up', penRequest); + createServerEndpoint('/pen.down', penRequest); + createServerEndpoint('/pen.off', penRequest); + createServerEndpoint('/pen.resetDistance', penRequest); + createServerEndpoint('/pen.sleep.1', penRequest); + createServerEndpoint('/pen.sleep.0', penRequest); + + // Tool set endpoints + createServerEndpoint('/tool.color./:id', toolRequest); + createServerEndpoint('/tool.water./:id', toolRequest); +} diff --git a/src/interface/images/loading.svg b/src/interface/images/loading.svg new file mode 100644 index 00000000..997f091c --- /dev/null +++ b/src/interface/images/loading.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/interface/index.html b/src/interface/index.html index d2a43db5..2738b6c6 100644 --- a/src/interface/index.html +++ b/src/interface/index.html @@ -1,109 +1,113 @@ + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + CNC Server client - - - - - - - - - -CNC Server client + + - - - - -
-
- - - - - - - - - - - -
+ + +
+
+ + + + + + + + + + + + + + +
-
- -
-
-
+
+ +
+
+
- -
-
- - - - - - - + +
+
+ + + + + + + +

TODO

+
+ + + + +
+
- - - - - +
+
+ +
- - -
+ -
-
- -
+
+ +
+
+
+
+
- + - + // Globalize various imports. + ['cncserver', 'preview', 'shapes'].forEach(name => { + window[name] = eval(name); + }); + // Initialize CNC Server connection details + cncserver.init({ + domain: document.domain, + port: location.port ? location.port : 80, + protocol: 'http', + version: '2', + ax: axios, + socketio: io, + }); + + diff --git a/src/interface/lib/cncserver.client.api.js b/src/interface/lib/cncserver.client.api.js index 9c7f9a02..414a1079 100644 --- a/src/interface/lib/cncserver.client.api.js +++ b/src/interface/lib/cncserver.client.api.js @@ -18,6 +18,7 @@ let axios = {}; // Placeholder. // Initialize wrapper object is this library is being used elsewhere const cncserver = { + contentTimeout: 20000, init: ({ domain = 'localhost', port = 4242, @@ -25,7 +26,7 @@ const cncserver = { version = 2, ax, socketio, - }) => new Promise((resolve) => { + }) => new Promise(resolve => { axios = ax; if (socketio) { cncserver.socket = socketio(`${protocol}://${domain}:${port}`); @@ -90,6 +91,7 @@ function _request(method, path, options = {}) { return axios.request({ url, method, + params: options.params, data: options.data, timeout: options.timeout || 5000, }); @@ -140,18 +142,20 @@ cncserver.api = { }, renderStage: () => _patch('projects', { data: { rendering: true } }), - drawPreview: () => _patch('projects', { data: { drawing: true } }), + startPrinting: () => _patch('projects', { data: { printing: true } }), schema: () => _options('projects'), }, content: { stat: () => _get('content'), add: { - direct: data => _post('content', { data }), + direct: data => _post('content', { data, timeout: cncserver.contentTimeout }), local: (type, content, options = {}) => _post('content', { data: { ...options, source: { type, content } }, + timeout: cncserver.contentTimeout, }), remote: (type, url, options = {}) => _post('content', { data: { ...options, source: { type, url } }, + timeout: cncserver.contentTimeout, }), }, @@ -166,15 +170,33 @@ cncserver.api = { colors: { stat: () => _get('colors'), preset: preset => _post('colors', { data: { preset } }), + deletePreset: preset => _delete('colors', { data: { preset } }), + get: id => _get(`colors/${id}`), add: data => _post('colors', { data }), - save: data => _put(`colors/${data.id}`, { data }), - delete: id => _delete(`colors/${id}`), + editSet: data => _patch('colors', { data }), + save: data => _patch(`colors/${data.id}`, { data }), + delete: id => _delete(`colors/${id}/`), + schema: () => _options('colors'), + }, + implements: { + stat: () => _get('implements'), + get: preset => _get(`implements/${preset}`), + add: data => _post('implements', { data }), + edit: data => _patch('implements', { data }), + save: data => _put(`implements/${data.name}/`, { data }), + delete: name => _delete(`implements/${name}/`), + schema: () => _options('implements'), }, pen: { /** * Get pen stat without doing anything else. Directly sets state. */ - stat: () => _get('pen'), + stat: (actual = false) => { + if (actual) { + return _get('pen', { params: { actual: 1 } }); + } + return _get('pen'); + }, /** * Set pen position height @@ -219,7 +241,7 @@ cncserver.api = { * {x, y} point object of coordinate within 0-100% of canvas to move to, * or with `abs` key set to 'mm' or 'in' for absolute position. */ - move: (point) => { + move: point => { if (typeof point === 'undefined') { return Promise.reject(new Error('Invalid coordinates for move')); } @@ -309,15 +331,15 @@ cncserver.api = { // Scratch turtle/abstracted API, non-ReSTful. scratch: { - move: (direction, amount) => new Promise((success) => { + move: (direction, amount) => new Promise(success => { _get(`/move.${direction}./${amount}`).then(() => { cncserver.api.scratch.stat().then(success); }); }), - stat: () => new Promise((success) => { + stat: () => new Promise(success => { _get('/poll').then(({ data }) => { const out = {}; - data.split('\n').forEach((item) => { + data.split('\n').forEach(item => { const [key, val] = item.split(' '); out[key] = val; }); @@ -450,7 +472,7 @@ cncserver.api = { _post('batch', { data: dump, timeout: 1000 * 60 * 10, // Timeout of 10 mins! - success: (d) => { + success: d => { console.timeEnd('process-batch'); console.info(d); callback(); diff --git a/src/interface/modules/components/elements/button-single.mjs b/src/interface/modules/components/elements/button-single.mjs index 3d0cc3c4..3c126ef1 100644 --- a/src/interface/modules/components/elements/button-single.mjs +++ b/src/interface/modules/components/elements/button-single.mjs @@ -4,9 +4,9 @@ import { html } from '/modules/hybrids.js'; export default styles => ({ - title: '', + text: '', icon: '', - style: 'plain', + type: 'plain', loading: false, desc: '', fullwidth: false, @@ -14,16 +14,27 @@ export default styles => ({ disabled: false, render: ({ - style, icon, title, desc, fullwidth, active, disabled, loading + icon, text, desc, fullwidth, active, disabled, loading, type, }) => { const linkClasses = { button: true, 'is-active': active, 'is-loading': loading }; - if (style) linkClasses[`is-${style}`] = true; + if (type) linkClasses[`is-${type}`] = true; + + const buttonStyle = { display: fullwidth ? 'flex' : 'inline-block' }; return html` ${styles} - - ${icon && html``} - ${title && html`${title}`} + + + ${icon && html` + + + + `} + ${text && html`${text}`} `; }, diff --git a/src/interface/modules/components/elements/button-toggle.mjs b/src/interface/modules/components/elements/button-toggle.mjs index af5a1a6b..6647f282 100644 --- a/src/interface/modules/components/elements/button-toggle.mjs +++ b/src/interface/modules/components/elements/button-toggle.mjs @@ -17,19 +17,19 @@ export default styles => ({ fullwidth: false, onTitle: 'On', onIcon: 'smile', - onStyle: 'success', + onType: 'success', offTitle: 'Off', offIcon: 'frown', - offStyle: '', + offType: '', render: (props) => { const word = props.state ? 'on' : 'off'; - const style = props[`${word}Style`]; + const type = props[`${word}Type`]; const title = props[`${word}Title`]; const icon = props[`${word}Icon`]; const aClasses = { button: true, 'is-fullwidth': props.fullwidth }; - if (style) aClasses[`is-${style}`] = true; + if (type) aClasses[`is-${type}`] = true; const iconClasses = { fas: true }; if (icon) iconClasses[`fa-${icon}`] = true; diff --git a/src/interface/modules/components/elements/color-set.mjs b/src/interface/modules/components/elements/color-set.mjs new file mode 100644 index 00000000..8a3672dc --- /dev/null +++ b/src/interface/modules/components/elements/color-set.mjs @@ -0,0 +1,104 @@ +/** + * @file Color set/preset display element definition. + */ +import { html, svg } from '/modules/hybrids.js'; + +export default () => ({ + colors: 'black', + labels: 'Black', + display: 'round', // 'round', or 'line'. + width: 55, + + render: ({ colors, labels, width, display }) => { + colors = colors.split(','); + labels = labels.split(','); + + const spots = []; + let spotWidth = 0; + if (display === 'round') { + // Build out positions on a circle. + const radius = width / 2; + spotWidth = colors.length === 1 ? radius : radius * ((Math.PI * 2) / colors.length); + colors.forEach((color, index) => { + const angle = (Math.PI * 2) * (index / colors.length) - Math.PI; + + const x = Math.round(Math.cos(angle) * radius); + const y = Math.round(Math.sin(angle) * radius); + const style = { + backgroundColor: color, + left: `${x + radius - spotWidth / 2}px`, + top: `${y + radius - spotWidth / 2}px`, + }; + + spots.push(html` +
+ `); + }); + } else if (display === 'line') { + spotWidth = width / colors.length; + colors.forEach((color, index) => { + const style = { backgroundColor: color }; + spots.push(html` +
+ `); + }); + } + + return html` + +
+ +
+ ${spots.map(spot => spot)} +
+
+ `; + }, +}); diff --git a/src/interface/modules/components/elements/index.mjs b/src/interface/modules/components/elements/index.mjs index 200791c7..5a8e3640 100644 --- a/src/interface/modules/components/elements/index.mjs +++ b/src/interface/modules/components/elements/index.mjs @@ -3,18 +3,32 @@ */ import TabGroup from './tab-group.mjs'; import TabItem from './tab-item.mjs'; +import SlideGroup from './slide-group.mjs'; +import SlideItem from './slide-item.mjs'; import ButtonToggle from './button-toggle.mjs'; import ButtonSingle from './button-single.mjs'; import LabelTitle from './label-title.mjs'; import MainTitle from './main-title.mjs'; import PaperCanvas from './paper-canvas.mjs'; +import SchemaForm from './schema-form.mjs'; +import ToolImplement from './tool-implement.mjs'; +import ColorSet from './color-set.mjs'; +import NotifyLoading from './notify-loading.mjs'; +import NotifyModal from './notify-modal.mjs'; export default styles => ({ 'tab-item': TabItem(styles), 'tab-group': TabGroup(styles), + 'slide-item': SlideItem(styles), + 'slide-group': SlideGroup(styles), 'button-toggle': ButtonToggle(styles), 'button-single': ButtonSingle(styles), 'label-title': LabelTitle(styles), 'main-title': MainTitle(styles), 'paper-canvas': PaperCanvas(), + 'schema-form': SchemaForm(styles), + 'tool-implement': ToolImplement(styles), + 'color-set': ColorSet(styles), + 'notify-loading': NotifyLoading(styles), + 'notify-modal': NotifyModal(styles), }); diff --git a/src/interface/modules/components/elements/label-title.mjs b/src/interface/modules/components/elements/label-title.mjs index 2e2d206c..ea311d0d 100644 --- a/src/interface/modules/components/elements/label-title.mjs +++ b/src/interface/modules/components/elements/label-title.mjs @@ -17,6 +17,15 @@ export default styles => ({ return html` ${styles} +