├── bin ├── test.js ├── babelify.js ├── client-transform.js ├── template.js ├── file-saver.js ├── index.js └── dev.js ├── docs └── screenshots │ ├── paint-1.png │ ├── circles-1.png │ ├── circles-2.png │ └── screenshot.png ├── .npmignore ├── .gitignore ├── lib ├── create-canvas │ ├── browser.js │ └── index.js ├── node-client.js ├── run-entry.js ├── browser-client.js └── penplot.js ├── example ├── commonjs.js ├── swirling-circles.js ├── simple-circles.js └── generative-paint.js ├── LICENSE.md ├── util ├── svg.js ├── random.js └── geom.js ├── package.json └── README.md /bin/test.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/screenshots/paint-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/penplot/HEAD/docs/screenshots/paint-1.png -------------------------------------------------------------------------------- /docs/screenshots/circles-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/penplot/HEAD/docs/screenshots/circles-1.png -------------------------------------------------------------------------------- /docs/screenshots/circles-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/penplot/HEAD/docs/screenshots/circles-2.png -------------------------------------------------------------------------------- /docs/screenshots/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/penplot/HEAD/docs/screenshots/screenshot.png -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | test 7 | test.js 8 | demo/ 9 | .npmignore 10 | LICENSE.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | example/plot/ 7 | !example/plot/basic.js 8 | !example/plot/commonjs.js 9 | tmp -------------------------------------------------------------------------------- /lib/create-canvas/browser.js: -------------------------------------------------------------------------------- 1 | export default function createCanvas (width = 300, height = 150) { 2 | const canvas = document.createElement('canvas'); 3 | canvas.width = width; 4 | canvas.height = height; 5 | return canvas; 6 | }; -------------------------------------------------------------------------------- /lib/node-client.js: -------------------------------------------------------------------------------- 1 | import run from './run-entry'; 2 | 3 | module.exports = function (name, file, opts) { 4 | let entry; 5 | try { 6 | entry = require(file); 7 | } catch (err) { 8 | console.error(err); 9 | console.error('Could not require file:', name); 10 | process.exit(1); 11 | } 12 | run(name, entry, opts); 13 | }; 14 | 15 | 16 | -------------------------------------------------------------------------------- /lib/create-canvas/index.js: -------------------------------------------------------------------------------- 1 | export default function createCanvas (width = 300, height = 150) { 2 | let canvas; 3 | try { 4 | const Canvas = require('canvas'); 5 | return canvas = new Canvas(width, height); 6 | } catch (err) { 7 | console.error(err.message); 8 | console.error('Could not install "canvas" module for use with --node option.'); 9 | process.exit(1); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /example/commonjs.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is an example of how to format your module if 3 | you prefer to use CommonJS. 4 | */ 5 | 6 | const { PaperSize, Orientation } = require('penplot'); 7 | 8 | module.exports = function createPlot (context, dimensions) { 9 | const [ width, height ] = dimensions; 10 | 11 | return { 12 | draw, 13 | animate: false, 14 | clear: true 15 | }; 16 | 17 | function draw (dt = 0) { 18 | context.fillRect(0, 0, width / 2, height / 2); 19 | } 20 | }; 21 | 22 | module.exports.dimensions = PaperSize.LETTER; 23 | module.exports.orientation = Orientation.LANDSCAPE; 24 | -------------------------------------------------------------------------------- /lib/run-entry.js: -------------------------------------------------------------------------------- 1 | import penplot from 'penplot'; 2 | 3 | export default function run (name, result, options) { 4 | options = options || {}; 5 | if (!result) throw new Error(`Module ${name} does not export anything!`); 6 | if (result.__esModule) { 7 | // assume ES2015 8 | if (!result.default) { 9 | throw new Error(`Malformed penplot function in ${name}\nES2015 modules must export a default function.`); 10 | } 11 | const opts = Object.assign({}, result, options); 12 | delete opts.default; 13 | delete opts.__esModule; 14 | penplot(result.default, opts); 15 | } else if (typeof result === 'function') { 16 | // assume CommonJS 17 | penplot(result, Object.assign({}, result, options)); 18 | } else { 19 | throw new Error(`Malformed penplot function in ${name}\nModule must be ` + 20 | 'in ES2015 or CommonJS style and should return a function.'); 21 | } 22 | } -------------------------------------------------------------------------------- /bin/babelify.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const alias = { 5 | penplot: require.resolve('../') 6 | }; 7 | 8 | // Add in all the utils 9 | const utilFolder = path.resolve(__dirname, '../util'); 10 | const files = fs.readdirSync(utilFolder) 11 | .filter(f => /\.js(on)?$/i.test(f)) 12 | .map(file => { 13 | const ext = /\.json$/i.test(file) ? undefined : path.extname(file); 14 | const name = path.basename(file, ext); 15 | const filePath = path.resolve(utilFolder, file); 16 | return { 17 | name: `penplot/util/${name}`, 18 | filePath: filePath 19 | }; 20 | }); 21 | 22 | files.forEach(file => { 23 | alias[file.name] = file.filePath; 24 | }); 25 | 26 | module.exports.getTransform = function () { 27 | return require('babelify').configure(module.exports.getOptions()); 28 | }; 29 | 30 | module.exports.getOptions = function () { 31 | return { 32 | presets: [ require.resolve('babel-preset-es2015') ], 33 | plugins: [ 34 | [ require.resolve('babel-plugin-module-resolver'), { 35 | alias 36 | } ] 37 | ] 38 | }; 39 | }; -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Matt DesLauriers 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 18 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 19 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 20 | OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /bin/client-transform.js: -------------------------------------------------------------------------------- 1 | const through = require('through2'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const maxstache = require('maxstache'); 5 | 6 | module.exports = function clientTransform (clientEntry, entries) { 7 | const cwd = process.cwd(); 8 | 9 | return function (file) { 10 | if (path.resolve(file) === clientEntry) { 11 | return through(undefined, end); 12 | } else { 13 | return through(); 14 | } 15 | } 16 | 17 | function end () { 18 | const src = maxstache(fs.readFileSync(clientEntry, 'utf8'), { 19 | entry: generateEntryCode() 20 | }); 21 | 22 | this.push(src); 23 | this.push(null); 24 | } 25 | 26 | function generateEntryCode () { 27 | const values = entries.map(entry => { 28 | const fileName = path.basename(entry, path.extname(entry)); 29 | const filePath = path.relative(cwd, entry); 30 | const name = path.join(path.dirname(filePath), fileName); 31 | const file = path.resolve(entry); 32 | return ` ${JSON.stringify(name)}: require(${JSON.stringify(file)})`; 33 | }).join(',\n'); 34 | 35 | return `const entries = {\n${values}\n};`; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /util/svg.js: -------------------------------------------------------------------------------- 1 | import defined from 'defined'; 2 | 3 | const TO_PX = 35.43307; 4 | const DEFAULT_SVG_LINE_WIDTH = 0.03; 5 | 6 | export function polylinesToSVG (polylines, opt = {}) { 7 | const dimensions = opt.dimensions; 8 | if (!dimensions) throw new TypeError('must specify dimensions currently'); 9 | const decimalPlaces = 5; 10 | 11 | let commands = []; 12 | polylines.forEach(line => { 13 | line.forEach((point, j) => { 14 | const type = (j === 0) ? 'M' : 'L'; 15 | const x = (TO_PX * point[0]).toFixed(decimalPlaces); 16 | const y = (TO_PX * point[1]).toFixed(decimalPlaces); 17 | commands.push(`${type} ${x} ${y}`); 18 | }); 19 | }); 20 | 21 | const svgPath = commands.join(' '); 22 | const viewWidth = (dimensions[0] * TO_PX).toFixed(decimalPlaces); 23 | const viewHeight = (dimensions[1] * TO_PX).toFixed(decimalPlaces); 24 | const fillStyle = opt.fillStyle || 'none'; 25 | const strokeStyle = opt.strokeStyle || 'black'; 26 | const lineWidth = defined(opt.lineWidth, DEFAULT_SVG_LINE_WIDTH); 27 | 28 | return ` 29 | 31 | 33 | 34 | 35 | 36 | `; 37 | } 38 | -------------------------------------------------------------------------------- /bin/template.js: -------------------------------------------------------------------------------- 1 | import { PaperSize, Orientation } from 'penplot'; 2 | import { polylinesToSVG } from 'penplot/util/svg'; 3 | import { clipPolylinesToBox } from 'penplot/util/geom'; 4 | 5 | export const orientation = Orientation.LANDSCAPE; 6 | export const dimensions = PaperSize.LETTER; 7 | 8 | export default function createPlot (context, dimensions) { 9 | const [ width, height ] = dimensions; 10 | let lines = []; 11 | 12 | // Draw some circles expanding outward 13 | const steps = 5; 14 | const count = 20; 15 | const spacing = 1; 16 | const radius = 2; 17 | for (let j = 0; j < count; j++) { 18 | const r = radius + j * spacing; 19 | const circle = []; 20 | for (let i = 0; i < steps; i++) { 21 | const t = i / Math.max(1, steps - 1); 22 | const angle = Math.PI * 2 * t; 23 | circle.push([ 24 | width / 2 + Math.cos(angle) * r, 25 | height / 2 + Math.sin(angle) * r 26 | ]); 27 | } 28 | lines.push(circle); 29 | } 30 | 31 | // Clip all the lines to a margin 32 | const margin = 1.5; 33 | const box = [ margin, margin, width - margin, height - margin ]; 34 | lines = clipPolylinesToBox(lines, box); 35 | 36 | return { 37 | draw, 38 | print, 39 | background: 'white', 40 | animate: false, 41 | clear: true 42 | }; 43 | 44 | function draw () { 45 | lines.forEach(points => { 46 | context.beginPath(); 47 | points.forEach(p => context.lineTo(p[0], p[1])); 48 | context.stroke(); 49 | }); 50 | } 51 | 52 | function print () { 53 | return polylinesToSVG(lines, { 54 | dimensions 55 | }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /example/swirling-circles.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is an example of swirling circles clipped by a 1.5 cm margin. 3 | */ 4 | 5 | import { PaperSize, Orientation } from 'penplot'; 6 | import { polylinesToSVG } from 'penplot/util/svg'; 7 | import { clipPolylinesToBox } from 'penplot/util/geom'; 8 | 9 | export const orientation = Orientation.PORTRAIT; 10 | export const dimensions = PaperSize.LETTER; 11 | 12 | export default function createPlot (context, dimensions) { 13 | const [ width, height ] = dimensions; 14 | 15 | let lines = []; 16 | 17 | // Fill the array of lines 18 | const steps = 256; 19 | const circle = []; 20 | for (let i = 0; i < steps; i++) { 21 | const t = i / Math.max(1, steps - 1); 22 | const radius = 20 * t; 23 | const swirl = 30; 24 | const angle = Math.PI * 2 * t * swirl; 25 | const x = Math.cos(angle) * radius; 26 | const y = Math.sin(angle) * radius; 27 | const cx = width / 2; 28 | const cy = height / 2; 29 | circle.push([ x + cx, y + cy ]); 30 | } 31 | lines.push(circle); 32 | 33 | // Clip all the lines to a margin 34 | const margin = 1.5; 35 | const box = [ margin, margin, width - margin, height - margin ]; 36 | lines = clipPolylinesToBox(lines, box); 37 | 38 | return { 39 | draw, 40 | print, 41 | background: 'white', 42 | animate: false, 43 | clear: true 44 | }; 45 | 46 | function draw () { 47 | lines.forEach(points => { 48 | context.beginPath(); 49 | points.forEach(p => context.lineTo(p[0], p[1])); 50 | context.stroke(); 51 | }); 52 | } 53 | 54 | function print () { 55 | return polylinesToSVG(lines, { 56 | dimensions 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /example/simple-circles.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is a basic example of a plot that could be sent 3 | to AxiDraw V3 and similar pen plotters. 4 | */ 5 | 6 | import newArray from 'new-array'; 7 | 8 | import { PaperSize, Orientation } from 'penplot'; 9 | import { randomFloat, setSeed } from 'penplot/util/random'; 10 | import { polylinesToSVG } from 'penplot/util/svg'; 11 | 12 | setSeed(2); 13 | 14 | export const orientation = Orientation.PORTRAIT; 15 | export const dimensions = PaperSize.LETTER; 16 | 17 | export default function createPlot (context, dimensions) { 18 | const [ width, height ] = dimensions; 19 | 20 | const lineCount = 20; 21 | const segments = 500; 22 | const radius = 2; 23 | 24 | const lines = newArray(lineCount).map((_, j) => { 25 | const angleOffset = randomFloat(-Math.PI * 2, Math.PI * 2); 26 | const angleScale = randomFloat(0.001, 1); 27 | 28 | return newArray(segments).map((_, i) => { 29 | const t = i / (segments - 1); 30 | const angle = (Math.PI * 2 * t + angleOffset) * angleScale; 31 | const x = Math.cos(angle); 32 | const y = Math.sin(angle); 33 | const offset = j * 0.2; 34 | const r = radius + offset; 35 | return [ x * r + width / 2, y * r + height / 2 ]; 36 | }); 37 | }); 38 | 39 | return { 40 | draw, 41 | print, 42 | clear: true, 43 | background: 'white' 44 | }; 45 | 46 | function draw () { 47 | lines.forEach(points => { 48 | context.beginPath(); 49 | points.forEach(p => context.lineTo(p[0], p[1])); 50 | context.stroke(); 51 | }); 52 | } 53 | 54 | function print () { 55 | return polylinesToSVG(lines, { 56 | dimensions 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/browser-client.js: -------------------------------------------------------------------------------- 1 | /* 2 | Scaffolds a penplot canvas and render loop and handles 3 | the directory listing for multiple entries. 4 | */ 5 | 6 | import 'babel-polyfill'; 7 | import insertCSS from 'insert-css'; 8 | import run from './run-entry'; 9 | 10 | function list (names) { 11 | insertCSS(` 12 | body { 13 | margin: 0; 14 | } 15 | div { 16 | margin: 20px; 17 | columns: 3; 18 | } 19 | a, a:hover, a:visited, a:active { 20 | display: block; 21 | font: 14px 'Helvetica', sans-serif; 22 | text-decoration: none; 23 | color: #89bbed; 24 | padding-bottom: 5px; 25 | } 26 | a:hover { 27 | color: #518cc6; 28 | } 29 | `) 30 | const container = document.createElement('div'); 31 | names.forEach(name => { 32 | const uri = encodeURI(name); 33 | const a = document.createElement('a'); 34 | a.setAttribute('href', uri); 35 | a.textContent = name; 36 | container.appendChild(a); 37 | }); 38 | document.body.appendChild(container); 39 | } 40 | 41 | /* 42 | The following gets generated by our browserify transform 43 | to fill in the desired penplot entries. 44 | */ 45 | {{entry}} 46 | 47 | /* 48 | Runs the current entry or provides a directory listing if necessary. 49 | */ 50 | const names = Object.keys(entries); 51 | if (names.length === 1) { 52 | const name = names[0]; 53 | run(name, entries[name]); 54 | } else { 55 | const path = decodeURI(location.pathname) 56 | .replace(/[\/]+$/, '') 57 | .replace(/^[\/]+/, '') || '/'; 58 | if (path === '/') { 59 | list(names); 60 | } else { 61 | if (path in entries) { 62 | run(path, entries[path]); 63 | } else { 64 | console.error('no file', path) 65 | console.log(entries) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /bin/file-saver.js: -------------------------------------------------------------------------------- 1 | const downloadsFolder = require('downloads-folder'); 2 | const uuid = require('uuid/v1'); 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const moment = require('moment'); 6 | const chalk = require('chalk'); 7 | 8 | module.exports = function createFileSaver (opt) { 9 | const cwd = opt.cwd || process.cwd(); 10 | let printOutputFolder; 11 | let isDownloads = false; 12 | if (opt.output) { 13 | printOutputFolder = opt.output; 14 | } else { 15 | isDownloads = true; 16 | printOutputFolder = downloadsFolder(); 17 | } 18 | 19 | return { 20 | getFile: getFile, 21 | printDisplayPath: printDisplayPath, 22 | getDisplayPath: getDisplayPath 23 | }; 24 | 25 | function printDisplayPath (filePath) { 26 | const ext = path.extname(filePath).replace(/^\./, '').toUpperCase(); 27 | console.log(chalk.cyan(`‣ Saved ${ext} print to:`), chalk.bold(getDisplayPath(filePath))); 28 | } 29 | 30 | function composeFile (name, extension, number) { 31 | return (number === 0 ? name : `${name} (${number})`) + `.${extension}`; 32 | } 33 | 34 | function getFile (extension, cb) { 35 | fs.readdir(printOutputFolder, (err, files) => { 36 | if (err) { 37 | console.error(chalk.yellow(`‣ WARN`), 'Could not read folder:', chalk.bold(printOutputFolder)); 38 | console.error(err); 39 | cb(path.resolve(printOutputFolder, uuid() + `.${extension}`)); 40 | } else { 41 | const type = extension === 'svg' ? 'Plot' : 'Render'; 42 | const date = moment().format('YYYY-MM-DD [at] h.mm.ss A'); 43 | let name = `${type} - ${date}`; 44 | let number = 0; 45 | while (true) { 46 | let test = composeFile(name, extension, number); 47 | if (files.indexOf(test) >= 0) { 48 | // file already exists 49 | number++; 50 | } else { 51 | break; 52 | } 53 | } 54 | const fileName = composeFile(name, extension, number); 55 | cb(path.resolve(printOutputFolder, fileName)); 56 | } 57 | }); 58 | } 59 | 60 | function getDisplayPath (filePath) { 61 | return isDownloads ? filePath : path.relative(cwd, filePath); 62 | } 63 | } -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const args = process.argv.slice(2); 3 | const argv = require('budo/lib/parse-args')(args); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | const chalk = require('chalk'); 7 | const mkdirp = require('mkdirp'); 8 | const glob = require('globby'); 9 | 10 | if (argv._.length === 0) { 11 | console.error(chalk.red('‣ ERROR'), 'You must specify a file path:') 12 | console.error(chalk.dim(' penplot myplot.js')); 13 | process.exit(1); 14 | } 15 | 16 | const promise = argv.write ? Promise.resolve([ argv._[0] ]) : glob(argv._); 17 | promise.then(paths => { 18 | if (paths.length > 1) { 19 | console.log(chalk.cyan(`‣ Bundling ${chalk.bold(paths.length)} entries...`)); 20 | start(paths); 21 | } else { 22 | const entry = paths[0]; 23 | // if the file doesn't exist, stub it out 24 | isFile(entry, exists => { 25 | if (exists) { 26 | if (argv.write) { 27 | console.error(chalk.yellow('‣ WARN'), 'Ignoring --write argument, file exists:', chalk.bold(entry)); 28 | } 29 | start(entry); 30 | } else if (argv.write) { 31 | console.log(chalk.cyan(`‣ Writing plot file to:`), chalk.bold(entry)); 32 | const dir = path.dirname(entry); 33 | mkdirp(dir, err => { 34 | if (err) throw err; 35 | const template = fs.readFileSync(path.resolve(__dirname, 'template.js')); 36 | fs.writeFile(entry, template, function (err) { 37 | if (err) throw err; 38 | start(entry); 39 | }); 40 | }); 41 | } else { 42 | fileError(entry); 43 | } 44 | }); 45 | } 46 | }).catch(() => { 47 | fileError(argv._[0]); 48 | }); 49 | 50 | function fileError (entry) { 51 | console.error(chalk.red('‣ ERROR'), 'File does not exist:', chalk.bold(entry)); 52 | console.error('\nUse --write if you want to stub a new file, for e.g.'); 53 | console.error(' penplot myplot.js --write'); 54 | process.exit(1); 55 | } 56 | 57 | function start (entries) { 58 | entries = Array.isArray(entries) ? entries : [ entries ]; 59 | 60 | if (process.env.NODE_ENV !== 'production') { 61 | require('./dev.js')(args, argv, entries); 62 | } else { 63 | throw new Error('Build output not yet supported.'); 64 | } 65 | } 66 | 67 | function isFile (file, cb) { 68 | fs.stat(file, function (err, stat) { 69 | if (!err) { 70 | if (!stat.isFile()) throw new Error(`${file} is not a file!`); 71 | return cb(true); 72 | } 73 | if (err.code === 'ENOENT') { 74 | cb(false); 75 | } else { 76 | throw err; 77 | } 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /util/random.js: -------------------------------------------------------------------------------- 1 | // utility for random features 2 | 3 | import seedRandom from 'seed-random'; 4 | import SimplexNoise from 'simplex-noise'; 5 | 6 | const defaultRandom = () => Math.random(); 7 | let currentRandom = defaultRandom; 8 | let currentSimplex = new SimplexNoise(currentRandom); 9 | 10 | export const random = () => currentRandom(); 11 | 12 | export const setSeed = (seed, opt) => { 13 | if (typeof seed === 'number' || typeof seed === 'string') { 14 | currentRandom = seedRandom(seed, opt); 15 | } else { 16 | currentRandom = defaultRandom; 17 | } 18 | currentSimplex = new SimplexNoise(currentRandom); 19 | } 20 | 21 | export const noise2D = (x, y) => currentSimplex.noise2D(x, y); 22 | export const noise3D = (x, y, z) => currentSimplex.noise3D(x, y, z); 23 | export const noise4D = (x, y, z, w) => currentSimplex.noise4D(x, y, z, w); 24 | 25 | export const randomSign = () => random() > 0.5 ? 1 : -1; 26 | 27 | export const randomFloat = (min, max) => { 28 | if (max === undefined) { 29 | max = min; 30 | min = 0; 31 | } 32 | 33 | if (typeof min !== 'number' || typeof max !== 'number') { 34 | throw new TypeError('Expected all arguments to be numbers'); 35 | } 36 | 37 | return random() * (max - min) + min; 38 | }; 39 | 40 | export const shuffle = (arr) => { 41 | if (!Array.isArray(arr)) { 42 | throw new TypeError('Expected Array, got ' + typeof arr); 43 | } 44 | 45 | var rand; 46 | var tmp; 47 | var len = arr.length; 48 | var ret = arr.slice(); 49 | while (len) { 50 | rand = Math.floor(random() * len--); 51 | tmp = ret[len]; 52 | ret[len] = ret[rand]; 53 | ret[rand] = tmp; 54 | } 55 | return ret; 56 | }; 57 | 58 | export const randomInt = (min, max) => { 59 | if (max === undefined) { 60 | max = min; 61 | min = 0; 62 | } 63 | 64 | if (typeof min !== 'number' || typeof max !== 'number') { 65 | throw new TypeError('Expected all arguments to be numbers'); 66 | } 67 | 68 | return Math.floor(randomFloat(min, max)); 69 | }; 70 | 71 | // uniform distribution in a 2D circle 72 | export const randomCircle = (out, scale = 1) => { 73 | var r = random() * 2.0 * Math.PI; 74 | out[0] = Math.cos(r) * scale; 75 | out[1] = Math.sin(r) * scale; 76 | return out; 77 | }; 78 | 79 | // uniform distribution in a 3D sphere 80 | export const randomSphere = (out, scale = 1) => { 81 | var r = random() * 2.0 * Math.PI; 82 | var z = (random() * 2.0) - 1.0; 83 | var zScale = Math.sqrt(1.0 - z * z) * scale; 84 | out[0] = Math.cos(r) * zScale; 85 | out[1] = Math.sin(r) * zScale; 86 | out[2] = z * scale; 87 | return out; 88 | }; 89 | 90 | // uniform distribution of quaternion rotations 91 | export const randomQuaternion = (out) => { 92 | const u1 = random(); 93 | const u2 = random(); 94 | const u3 = random(); 95 | 96 | const sq1 = Math.sqrt(1 - u1); 97 | const sq2 = Math.sqrt(u1); 98 | 99 | const theta1 = Math.PI * 2 * u2; 100 | const theta2 = Math.PI * 2 * u3; 101 | 102 | const x = Math.sin(theta1) * sq1; 103 | const y = Math.cos(theta1) * sq1; 104 | const z = Math.sin(theta2) * sq2; 105 | const w = Math.cos(theta2) * sq2; 106 | out[0] = x; 107 | out[1] = y; 108 | out[2] = z; 109 | out[3] = w; 110 | return out; 111 | }; 112 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "penplot", 3 | "version": "3.0.0", 4 | "description": "a lightweight tool for generative 2D line art", 5 | "main": "./lib/penplot.js", 6 | "license": "MIT", 7 | "author": { 8 | "name": "Matt DesLauriers", 9 | "email": "dave.des@gmail.com", 10 | "url": "https://github.com/mattdesl" 11 | }, 12 | "browser": { 13 | "./lib/create-canvas": "./lib/create-canvas/browser.js" 14 | }, 15 | "dependencies": { 16 | "acorn": "^4.0.11", 17 | "alpha-complex": "^1.0.0", 18 | "array-almost-equal": "^1.0.0", 19 | "ast-types": "^0.9.5", 20 | "babel-plugin-module-resolver": "^2.5.0", 21 | "babel-polyfill": "^6.23.0", 22 | "babel-preset-es2015": "^6.22.0", 23 | "babel-register": "^6.24.1", 24 | "babelify": "^7.3.0", 25 | "body": "^5.1.0", 26 | "body-parser": "^1.17.1", 27 | "bound-points": "^1.0.0", 28 | "budo": "^10.0.3", 29 | "bunny": "^1.0.1", 30 | "cdt2d": "^1.0.0", 31 | "chalk": "^1.1.3", 32 | "clamp": "^1.0.1", 33 | "color-style": "^1.0.0", 34 | "defined": "^1.0.0", 35 | "delaunay-triangulate": "^1.1.6", 36 | "dom-css": "^2.1.0", 37 | "downloads-folder": "^1.0.0", 38 | "envify": "^4.1.0", 39 | "escodegen": "^1.8.1", 40 | "gl-vec2": "^1.0.0", 41 | "gl-vec3": "^1.0.3", 42 | "glob": "^7.1.1", 43 | "globby": "^6.1.0", 44 | "insert-css": "^2.0.0", 45 | "installify": "^1.1.0", 46 | "is-buffer": "^1.1.4", 47 | "is-promise": "^2.1.0", 48 | "keycode": "^2.1.8", 49 | "lerp": "^1.0.3", 50 | "lineclip": "^1.1.5", 51 | "loud-rejection": "^1.6.0", 52 | "map-limit": "0.0.1", 53 | "mapify": "^0.1.0", 54 | "maxstache": "^1.0.7", 55 | "merge-vertices": "^1.0.1", 56 | "minimist": "^1.2.0", 57 | "mkdirp": "^0.5.1", 58 | "moment": "^2.17.1", 59 | "new-array": "^1.0.0", 60 | "object-assign": "^4.1.1", 61 | "parse-unit": "^1.0.1", 62 | "point-in-triangle": "^1.0.1", 63 | "primitive-cube": "^2.0.0", 64 | "quantize-vertices": "^1.0.2", 65 | "raf-loop": "^1.1.3", 66 | "recast": "^0.11.22", 67 | "remove-orphan-vertices": "^1.0.0", 68 | "rescale-vertices": "^1.0.0", 69 | "seed-random": "^2.2.0", 70 | "simplex-noise": "^2.3.0", 71 | "smoothstep": "^1.0.1", 72 | "split-polygon": "^1.0.0", 73 | "svg-3d-simplicial-complex": "^0.1.1", 74 | "through2": "^2.0.3", 75 | "triangle-centroid": "^1.0.0", 76 | "triangle-incenter": "^1.0.2", 77 | "unreachable-branch-transform": "^0.5.1", 78 | "uuid": "^3.0.1", 79 | "vectors": "^0.1.0", 80 | "vertices-bounding-box": "^1.0.0", 81 | "xhr": "^2.4.0" 82 | }, 83 | "devDependencies": { 84 | "color-convert": "^1.9.0", 85 | "css-color-converter": "^1.1.0", 86 | "draw-triangles-2d": "^1.0.0", 87 | "icosphere": "^1.0.0", 88 | "nice-color-palettes": "^2.0.0", 89 | "perspective-camera": "^2.0.1" 90 | }, 91 | "scripts": { 92 | "test": "node test.js" 93 | }, 94 | "keywords": [ 95 | "pen", 96 | "plot", 97 | "plotter", 98 | "plotterart", 99 | "ink", 100 | "svg", 101 | "2d", 102 | "makeblock", 103 | "xy", 104 | "axidraw", 105 | "axidrawv3", 106 | "evil", 107 | "mad", 108 | "scientist" 109 | ], 110 | "repository": { 111 | "type": "git", 112 | "url": "git://github.com/mattdesl/penplot.git" 113 | }, 114 | "homepage": "https://github.com/mattdesl/penplot", 115 | "bugs": { 116 | "url": "https://github.com/mattdesl/penplot/issues" 117 | }, 118 | "bin": { 119 | "penplot": "./bin/index.js" 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /bin/dev.js: -------------------------------------------------------------------------------- 1 | require('loud-rejection')(); 2 | 3 | const assign = require('object-assign'); 4 | const budo = require('budo'); 5 | const bodyParser = require('body-parser'); 6 | const path = require('path'); 7 | const fs = require('fs'); 8 | const createClientTransform = require('./client-transform'); 9 | const createEnvify = require('envify/custom'); 10 | 11 | const babelify = require('./babelify'); 12 | const installify = require('installify'); 13 | const unreachableBranch = require('unreachable-branch-transform'); 14 | const createFileSaver = require('./file-saver'); 15 | 16 | module.exports = dev; 17 | function dev (args, argv, entries) { 18 | const cwd = process.cwd(); 19 | const fileSaver = createFileSaver({ 20 | output: argv.output, 21 | cwd: cwd 22 | }); 23 | 24 | const envVars = {}; 25 | const isNode = argv.node; 26 | if (isNode) { 27 | process.env.IS_NODE = envVars.IS_NODE = '1'; 28 | throw new Error('The --node option has been removed in penplot@3.x'); 29 | } 30 | 31 | if (isNode) { 32 | require('babel-register')(babelify.getOptions()); 33 | if (entries.length > 1 || entries.length === 0) { 34 | throw new Error('The --node option only supports a single entry right now.'); 35 | } 36 | 37 | const isStdout = argv.stdout; 38 | const entryName = entries[0]; 39 | const entryFile = path.resolve(cwd, entries[0]); 40 | const opts = { 41 | onComplete: (context) => { 42 | const runSave = stream => { 43 | context.canvas.pngStream().pipe(stream); 44 | }; 45 | 46 | if (isStdout) { 47 | runSave(process.stdout); 48 | } else { 49 | fileSaver.getFile('png', (filePath) => { 50 | const outStream = fs.createWriteStream(filePath); 51 | outStream.on('close', () => { 52 | fileSaver.printDisplayPath(filePath); 53 | }); 54 | runSave(outStream); 55 | }); 56 | } 57 | } 58 | }; 59 | require('../lib/node-client.js')(entryName, entryFile, opts); 60 | } else { 61 | // replace entry with our own client 62 | const clientEntry = path.resolve(__dirname, '../lib/browser-client.js'); 63 | argv._ = [ clientEntry ]; 64 | 65 | const generateClient = createClientTransform(clientEntry, entries); 66 | const transforms = [ 67 | generateClient, 68 | babelify.getTransform(), 69 | createEnvify(envVars), 70 | unreachableBranch 71 | ]; 72 | 73 | if (argv['auto-install']) { 74 | transforms.push([ installify, { save: true } ]); 75 | } 76 | 77 | const opts = assign({}, argv, { 78 | title: 'penplot', 79 | live: true, 80 | pushstate: true, 81 | base: '/', 82 | browserify: { 83 | transform: transforms 84 | }, 85 | middleware: [ 86 | bodyParser.json({ 87 | limit: '1gb' 88 | }), 89 | middleware 90 | ], 91 | serve: 'bundle.js' 92 | }); 93 | 94 | budo.cli(args, opts); 95 | } 96 | 97 | function middleware (req, res, next) { 98 | if (req.url === '/print') { 99 | fileSaver.getFile('svg', file => svg(file, req, res)); 100 | } else if (req.url === '/save') { 101 | fileSaver.getFile('png', file => png(file, req, res)); 102 | } else { 103 | next(); 104 | } 105 | } 106 | 107 | function svg (filePath, req, res) { 108 | if (!req.body || !req.body.svg) { 109 | res.writeHead(400, 'missing SVG in print()'); 110 | res.end(); 111 | } 112 | fs.writeFile(filePath, req.body.svg, function (err) { 113 | if (err) { 114 | console.error(err); 115 | res.writeHead(400, err.message); 116 | return res.end(); 117 | } 118 | fileSaver.printDisplayPath(filePath); 119 | res.writeHead(200, 'ok'); 120 | res.end(); 121 | }); 122 | } 123 | 124 | function png (filePath, req, res) { 125 | if (!req.body || !req.body.data) { 126 | res.writeHead(400, 'missing base64 data for save function'); 127 | res.end(); 128 | } 129 | const data = Buffer.from(req.body.data, 'base64'); 130 | fs.writeFile(filePath, data, function (err) { 131 | if (err) { 132 | console.error(err); 133 | res.writeHead(400, err.message); 134 | return res.end(); 135 | } 136 | fileSaver.printDisplayPath(filePath); 137 | res.writeHead(200, 'ok'); 138 | res.end(); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /example/generative-paint.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is a more advanced example of a generative/algorithmic 3 | print created with `penplot` and Canvas2D. It mostly consists of 4 | concentric circles with different arc lengths and a lot of 5 | random offsets. 6 | */ 7 | 8 | import newArray from 'new-array'; 9 | import clamp from 'clamp'; 10 | import { PaperSize, Orientation } from 'penplot'; 11 | import * as RND from 'penplot/util/random'; 12 | import allPalettes from 'nice-color-palettes/500'; 13 | import colorConvert from 'color-convert'; 14 | import fromCSS from 'css-color-converter'; 15 | 16 | // often it's handy to mark down a nice seed so you can re-print it later 17 | // at a different size 18 | const seed = '42032'; 19 | 20 | // other times you may want to idly browse through random seeds 21 | // const seed = String(Math.floor(Math.random() * 100000)); 22 | 23 | // check the console for the seed number :) 24 | console.log('Seed:', seed); 25 | 26 | RND.setSeed(seed); 27 | 28 | const palettes = [ 29 | [ 'hsl(0, 0%, 85%)', 'hsl(0, 0%, 95%)' ], 30 | RND.shuffle(RND.shuffle(allPalettes)[0]).slice(0, 3) 31 | ]; 32 | 33 | export const orientation = Orientation.PORTRAIT; 34 | export const dimensions = PaperSize.SKETCHBOOK; 35 | 36 | export default function createPlot (context, dimensions) { 37 | const [ width, height ] = dimensions; 38 | 39 | const lineCount = 300; 40 | const segments = 2000; 41 | 42 | const corePalette = palettes[0]; 43 | const altPalette = palettes[1]; 44 | const allPoints = []; 45 | 46 | const lines = newArray(lineCount).map((_, j) => { 47 | const radius = 0; 48 | const angleOffset = RND.randomFloat(-Math.PI * 2, Math.PI * 2); 49 | const angleScale = RND.randomFloat(0.01, 0.01); 50 | const pal = (RND.randomFloat(1) > 0.75) ? altPalette : corePalette; 51 | 52 | const startColor = pal[RND.randomInt(pal.length)]; 53 | const hsl = colorConvert.hex.hsl(fromCSS(startColor).toHexString().replace(/^#/, '')); 54 | const color = startColor; 55 | 56 | // only modify the color palettes 57 | const isHSLMod = pal === altPalette; 58 | 59 | return { 60 | lineWidth: RND.randomFloat(1) > 0.25 ? RND.randomFloat(0.01, 0.05) : RND.randomFloat(0.01, 4), 61 | color, 62 | hsl, 63 | isHSLMod, 64 | alpha: RND.randomFloat(0.15, 0.75), 65 | points: newArray(segments).map((_, i) => { 66 | const t = i / (segments - 1); 67 | const K = j / (lineCount - 1); 68 | const angleOff = RND.noise2D(K * 1, t * 200) * 0.1; 69 | const angle = (Math.PI * 2 * t) * angleScale + angleOff + angleOffset; 70 | const x = Math.cos(angle); 71 | const y = Math.sin(angle); 72 | const offset = j * (0.2 + RND.randomFloat(-1, RND.randomFloat(0, 5)) * 0.1) * 0.5; 73 | const r = radius + offset; 74 | const center = RND.randomCircle([], 0.01) 75 | const point = [ x * r + width / 2 + center[0], y * r + height / 2 + center[1] ]; 76 | const f = 10; 77 | const amp = 0.005; 78 | point[0] += RND.noise2D(f * point[0], f * point[1], f * 1000) * amp; 79 | point[1] += RND.noise2D(f * point[0], f * point[1], f * -1000) * amp; 80 | 81 | const newColor = isHSLMod 82 | ? `#${colorConvert.hsl.hex(offsetLightness(hsl, RND.randomFloat(-1, 1) * 10))}` 83 | : startColor; 84 | allPoints.push(point); 85 | return { 86 | position: point, 87 | color: newColor 88 | }; 89 | }) 90 | }; 91 | }); 92 | 93 | return { 94 | draw, 95 | outputSize: '300 dpi', // render as print resolution instead of web 96 | background: corePalette[0] 97 | }; 98 | 99 | function offsetLightness (hsl, l) { 100 | hsl = hsl.slice(); 101 | hsl[2] += l; 102 | hsl[2] = clamp(hsl[2], 0, 100); 103 | return hsl; 104 | } 105 | 106 | function draw () { 107 | lines.forEach(line => { 108 | // render each line as small segments so we get overlapping 109 | // opacities 110 | for (let i = 0; i < line.points.length / 2; i++) { 111 | context.beginPath(); 112 | context.globalAlpha = line.alpha; 113 | context.lineWidth = line.lineWidth; 114 | context.lineJoin = 'round'; 115 | context.lineCap = 'square'; 116 | context.strokeStyle = line.points[i * 2 + 0].color; 117 | const [ x1, y1 ] = line.points[i * 2 + 0].position; 118 | const [ x2, y2 ] = line.points[i * 2 + 1].position; 119 | context.lineTo(x1, y1); 120 | context.lineTo(x2, y2); 121 | context.stroke(); 122 | } 123 | }); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # penplot 2 | 3 | [![experimental](http://badges.github.io/stability-badges/dist/experimental.svg)](http://github.com/badges/stability-badges) 4 | 5 | An experimental and highly opinionated development environment for generative and pen plotter art. 6 | 7 | Some features: 8 | 9 | - Zero configuration: just run a command and start writing `` renderings 10 | - Fast live-reload on file save 11 | - Hotkey for high-quality PNG output 12 | - Hotkey for SVG rendering 13 | - A builtin library of utilities for random numbers, geometry tools, SVG exporting, and other functions 14 | - Easy integration with Inkscape and AxiDraw v3 15 | 16 | ## Quick Start 17 | 18 | You can install this with npm. 19 | 20 | ```sh 21 | npm install penplot -g 22 | ``` 23 | 24 | Here is a simple command you can use to quick-start a new plot: 25 | 26 | ```sh 27 | penplot src/index.js --write --open 28 | ``` 29 | 30 | This will write a new `src/index.js` file and open `localhost:9966`. Now start editing your `index.js` file to see the LiveReload in action. 31 | 32 | 33 | 34 | While in your browser session, you can hit `Cmd/Ctrl + P` to export the SVG to a file in your Downloads folder, or `Cmd/Ctrl + S` to save a PNG file. 35 | 36 | The SVG should be formatted to fit a Letter size paper with a pen plotter like AxiDraw V3. 37 | 38 | ## Penplot Modules 39 | 40 | The *penplot* tool is both a development environment and kitchen sink of utility functions. It tries to make some aspects easier for you, like sizing and printing to SVG or PNG. 41 | 42 | The `--write` flag generates a simple plot that looks like this: 43 | 44 | ```js 45 | // Some handy functions & constants 46 | import { PaperSize, Orientation } from 'penplot'; 47 | import { polylinesToSVG } from 'penplot/util/svg'; 48 | import { clipPolylinesToBox } from 'penplot/util/geom'; 49 | 50 | // Export the paper layout & dimensions for penplot to set up 51 | export const orientation = Orientation.LANDSCAPE; 52 | export const dimensions = PaperSize.LETTER; 53 | 54 | // The plot functiond defines how the artwork will look 55 | export default function createPlot (context, dimensions) { 56 | const [ width, height ] = dimensions; 57 | let lines = []; 58 | 59 | // Add [ x, y ] points to the array of lines 60 | // e.g. [ [ 5, 2 ], [ 2, 3 ] ] is one line 61 | ... algorithmic code ... 62 | 63 | // Clip all the lines to a 1.5 cm margin for our pen plotter 64 | const margin = 1.5; 65 | const box = [ margin, margin, width - margin, height - margin ]; 66 | lines = clipPolylinesToBox(lines, box); 67 | 68 | return { 69 | draw, 70 | print, 71 | background: 'white' // used when exporting the canvas to PNG 72 | }; 73 | 74 | function draw () { 75 | lines.forEach(points => { 76 | context.beginPath(); 77 | points.forEach(p => context.lineTo(p[0], p[1])); 78 | context.stroke(); 79 | }); 80 | } 81 | 82 | function print () { 83 | return polylinesToSVG(lines, { 84 | dimensions 85 | }); 86 | } 87 | } 88 | ``` 89 | 90 | All units here are in centimeters (including `width` and `height`), which makes it easy to reason about things like line thickness and distances. 91 | 92 | Using an array of line primitives, you can build up complex prints that can be easily exported to SVG or PNG. However, this means everything will be built from line segments; e.g. circles are generated with `cos()` and `sin()`. 93 | 94 | See the [Some Examples](#some-examples) for more inspiration. 95 | 96 | ## More Commands 97 | 98 | Here are some commands you can try. 99 | 100 | ```sh 101 | # stub out a new file called plot.js 102 | penplot plot.js --write 103 | 104 | # run plot.js and open the browser 105 | penplot plot.js --open 106 | 107 | # set the output folder for SVG/PNG files 108 | penplot plot.js --output=tmp 109 | ``` 110 | 111 | ## Print Output 112 | 113 | You can also use this as a tool for developing algorithmic/generative art. For example, you can develop the artwork in a browser for LiveReload and fast iterations, and when you want to print it you can set the dimensions and output size like so: 114 | 115 | ```js 116 | // desired orientation 117 | export const orientation = Orientation.PORTRAIT; 118 | 119 | // desired dimensions in CM (used for aspect ratio) 120 | export const dimensions = PaperSize.LETTER; 121 | 122 | // your artwork 123 | export default function createPlot (context, dimensions) { 124 | // your artwork... 125 | 126 | return { 127 | outputSize: '300 dpi' 128 | } 129 | } 130 | ``` 131 | 132 | The `outputSize` option can be any of the following: 133 | 134 | - a string with DPI resolution like `'300dpi'` or `'72 DPI'` 135 | - a single number to use as the pixel width; in this case the height is computed automatically based on the `dimensions` aspect 136 | - an array of `[ width, height ]`, where either (or both) can be specified as pixel sizes. If you specify `'auto'`, `-1` or `null` as a dimension, it will be computed automatically based on the aspect ratio 137 | 138 | The default output width is 1280 px. 139 | 140 | ## Some Examples 141 | 142 | In the [example](./example) folder you will find some variations of plots. 143 | 144 | ##### [simple-circles.js](./example/simple-circles.js) 145 | 146 | 147 | 148 | This example shows the basics of using *penplot* for hardware like AxiDraw V3. You can run it like so: 149 | 150 | ```sh 151 | penplot example/simple-circles.js --open 152 | ``` 153 | 154 | And hit `Cmd/Ctrl + S` or `Cmd/Ctrl + P` to save a PNG or SVG file, respectively. 155 | 156 | ##### [swirling-circles.js](./example/swirling-circles.js) 157 | 158 | 159 | 160 | This example shows how you can use a built-in function `clipPolylinesToBox` in `penplot/util/geom.js` to clip the lines to a margin. 161 | 162 | ##### [generative-paint.js](./example/generative-paint.js) 163 | 164 | 165 | 166 | This example shows a more complex algorithmic artwork, and how you can use penplot as a development environment for print-size generative art even when you have no plans to print it to a pen plotter. 167 | 168 | The `outputSize` parameter in this demo is set to `'300 dpi'`, which will convert the `dimensions` and `orientation` to a pixel size suitable for print when saving to PNG. 169 | 170 | ## License 171 | 172 | MIT, see [LICENSE.md](http://github.com/mattdesl/penplot/blob/master/LICENSE.md) for details. 173 | -------------------------------------------------------------------------------- /lib/penplot.js: -------------------------------------------------------------------------------- 1 | import createLoop from 'raf-loop'; 2 | import defined from 'defined'; 3 | import keycode from 'keycode'; 4 | import xhr from 'xhr'; 5 | import isBuffer from 'is-buffer'; 6 | import isPromise from 'is-promise'; 7 | import createCanvasImpl from './create-canvas'; 8 | import parseUnit from 'parse-unit'; 9 | import css from 'dom-css'; 10 | 11 | const DEFAULT_OUTPUT_WIDTH = 1280; 12 | const noop = () => {}; 13 | 14 | export const isBrowser = () => process.env.IS_NODE !== '1'; 15 | 16 | // Can't export an imported function in ES6...? 17 | export const createCanvas = (width = 300, height = 150) => createCanvasImpl(width, height); 18 | 19 | export const PaperSize = { 20 | LETTER: [ 21.59, 27.94 ], 21 | PORTRAIT: [ 24, 36 ], 22 | SKETCHBOOK: [ 17.7, 25.4 ], 23 | SQUARE_POSTER: [ 30, 30 ] 24 | }; 25 | 26 | export const Orientation = { 27 | LANDSCAPE: 'landscape', 28 | PORTRAIT: 'portrait' 29 | }; 30 | 31 | export const Margin = { 32 | ONE_INCH: [ 2.54, 2.54 ] // 1 in margins 33 | }; 34 | 35 | export const PenThickness = { 36 | FINE_TIP: 0.03 37 | }; 38 | 39 | export default function penplot (createPlot, opt = {}) { 40 | const deprecations = checkDeprecations(); 41 | 42 | const displayPadding = defined(opt.displayPadding, 40); 43 | 44 | const canvas = createCanvasImpl(); 45 | const context = canvas.getContext('2d'); 46 | 47 | if (isBrowser() && typeof document !== 'undefined') { 48 | document.body.appendChild(canvas); 49 | document.body.style.margin = '0'; 50 | css(canvas, { 51 | display: 'none', 52 | position: 'absolute', 53 | 'box-shadow': '3px 3px 20px 0px rgba(0, 0, 0, 0.15)', 54 | 'box-sizing': 'border-box' 55 | }); 56 | } 57 | 58 | let canvasWidth, canvasHeight; 59 | let pixelRatio = 1; 60 | 61 | if (opt.orientation && typeof opt.orientation !== 'string') { 62 | throw new TypeError('opt.orientaiton must be a string or Orientation constant, "landscape" or "portrait"'); 63 | } 64 | if (opt.dimensions && !Array.isArray(opt.dimensions)) { 65 | throw new TypeError('opt.dimensions must be an array or PaperSize constant, e.g. [ 25, 12 ]'); 66 | } 67 | 68 | const orientation = opt.orientation || Orientation.PORTRAIT; 69 | const dimensions = orient(opt.dimensions || PaperSize.LETTER, orientation); 70 | const aspect = dimensions[0] / dimensions[1]; 71 | 72 | const result = createPlot(context, dimensions); 73 | if (!result) throw new TypeError('penplot function must return a valid object'); 74 | 75 | let plot, lineWidth; 76 | if (isPromise(result)) { 77 | result.then(plotResult => { 78 | plot = plotResult; 79 | setup(); 80 | }); 81 | } else { 82 | plot = result; 83 | setup(); 84 | } 85 | 86 | function setup () { 87 | lineWidth = defined(plot.lineWidth, PenThickness.FINE_TIP); 88 | 89 | if (isBrowser()) { 90 | window.addEventListener('resize', () => { 91 | resize(); 92 | draw(); 93 | }); 94 | window.addEventListener('keydown', ev => { 95 | const key = keycode(ev); 96 | const cmdOrCtrl = ev.ctrlKey || ev.metaKey; 97 | if (cmdOrCtrl && key === 'p') { 98 | ev.preventDefault(); 99 | print(); 100 | } else if (cmdOrCtrl && key === 's') { 101 | ev.preventDefault(); 102 | save(); 103 | } 104 | }); 105 | } 106 | 107 | if (isBrowser()) { 108 | canvas.style.display = 'block'; 109 | } 110 | resize(); 111 | clear(); 112 | 113 | const onComplete = typeof opt.onComplete === 'function' ? opt.onComplete : noop; 114 | if (!isBrowser()) { 115 | setToOutputSize(); 116 | draw(); 117 | onComplete(context); 118 | } else { 119 | draw(); // first render 120 | if (plot.animate) { // keep rendering with rAF 121 | createLoop(draw).start(); 122 | } 123 | onComplete(context); 124 | window.focus(); 125 | } 126 | } 127 | 128 | function clear () { 129 | if (plot.background) { 130 | context.save(); 131 | context.globalAlpha = 1; 132 | context.fillStyle = plot.background; 133 | context.fillRect(0, 0, canvas.width, canvas.height); 134 | context.restore(); 135 | } else { 136 | context.clearRect(0, 0, canvas.width, canvas.height); 137 | } 138 | } 139 | 140 | function orient (dimensions, orientation) { 141 | return orientation === Orientation.LANDSCAPE 142 | ? dimensions.slice().reverse() 143 | : dimensions.slice(); 144 | } 145 | 146 | function draw (dt = 0) { 147 | context.save(); 148 | 149 | if (plot.clear !== false) { 150 | clear(); 151 | } 152 | 153 | let scaleX = pixelRatio * (canvasWidth / dimensions[0]); 154 | let scaleY = pixelRatio * (canvasHeight / dimensions[1]); 155 | context.scale(scaleX, scaleY); 156 | 157 | context.lineJoin = 'round'; 158 | context.lineCap = 'round'; 159 | context.globalAlpha = 1; 160 | context.lineWidth = lineWidth; 161 | context.fillStyle = 'black'; 162 | context.strokeStyle = 'black'; 163 | 164 | plot.draw(dt, { pixelRatio, viewportWidth: canvasWidth, viewportHeight: canvasHeight, scaleX, scaleY }); 165 | context.restore(); 166 | } 167 | 168 | function resize () { 169 | if (!isBrowser()) { 170 | setToOutputSize(); 171 | } else { 172 | // browser size for display 173 | pixelRatio = window.devicePixelRatio; 174 | 175 | const windowWidth = window.innerWidth; 176 | const windowHeight = window.innerHeight; 177 | const windowAspect = windowWidth / windowHeight; 178 | 179 | let width, height; 180 | if (windowAspect > aspect) { 181 | height = windowHeight - displayPadding * 2; 182 | width = height * aspect; 183 | } else { 184 | width = windowWidth - displayPadding * 2; 185 | height = width / aspect; 186 | } 187 | 188 | const left = Math.floor((windowWidth - width) / 2); 189 | const top = Math.floor((windowHeight - height) / 2); 190 | canvas.width = width * pixelRatio; 191 | canvas.height = height * pixelRatio; 192 | canvas.style.width = `${width}px`; 193 | canvas.style.height = `${height}px`; 194 | canvas.style.left = `${left}px`; 195 | canvas.style.top = `${top}px`; 196 | canvasWidth = width; 197 | canvasHeight = height; 198 | } 199 | } 200 | 201 | function setToOutputSize () { 202 | const outputSize = getOutputSize(); 203 | const width = outputSize[0]; 204 | const height = outputSize[1]; 205 | if (canvas.width !== width) canvas.width = width; 206 | if (canvas.height !== height) canvas.height = height; 207 | canvasWidth = width; 208 | canvasHeight = height; 209 | pixelRatio = 1; 210 | } 211 | 212 | function getOutputSize (desiredSize) { 213 | let outputSize = deprecations.outputSize 214 | ? [ opt.outputImageWidth, opt.outputImageHeight ] 215 | : (plot.outputSize || [ DEFAULT_OUTPUT_WIDTH, null ]); 216 | 217 | if (typeof outputSize === 'string') { 218 | const parsed = parseUnit(outputSize); 219 | if (/^dpi$/i.test(parsed[1])) { 220 | outputSize = cmToPixels(dimensions, parsed[0]); 221 | } else { 222 | throw new Error('Invalid value for outputSize - expected number, array or "X dpi" string'); 223 | } 224 | } 225 | 226 | // single value -> [ width, 'auto' ] 227 | if (typeof outputSize === 'number') { 228 | outputSize = [ outputSize, null ]; 229 | } 230 | 231 | const hasWidth = hasDimension(outputSize[0]); 232 | const hasHeight = hasDimension(outputSize[1]); 233 | 234 | let newWidth, newHeight; 235 | 236 | // if width is defined and height is not, compute height automatically 237 | // if height is defined and width is not, compute width automatically 238 | // if neither are defined, use default width & compute height automatically 239 | // if both are defined, use both 240 | if ((hasWidth && !hasHeight) || (!hasWidth && !hasHeight)) { 241 | newWidth = hasWidth ? outputSize[0] : DEFAULT_OUTPUT_WIDTH; 242 | newHeight = newWidth / aspect; 243 | } else if (hasHeight && !hasWidth) { 244 | newHeight = outputSize[1]; 245 | newWidth = newHeight * aspect; 246 | } else if (hasWidth && hasHeight) { 247 | newWidth = outputSize[0]; 248 | newHeight = outputSize[1]; 249 | } else { 250 | throw new Error('wtf'); 251 | } 252 | 253 | // to whole pixels 254 | newWidth = Math.floor(newWidth); 255 | newHeight = Math.floor(newHeight); 256 | return [ newWidth, newHeight ]; 257 | } 258 | 259 | function hasDimension (n) { 260 | return typeof n === 'number' && n >= 0; 261 | } 262 | 263 | function save () { 264 | // capture frame at a larger resolution 265 | setToOutputSize(); 266 | draw(); 267 | 268 | if (isBrowser()) { 269 | const base64 = canvas.toDataURL().slice('data:image/png;base64,'.length); 270 | 271 | // resize back to original resolution 272 | resize(); 273 | draw(); 274 | 275 | if (process.env.NODE_ENV !== 'production') { 276 | xhr.post('/save', { 277 | json: true, 278 | body: { 279 | data: base64 280 | } 281 | }, err => { 282 | if (err) throw err; 283 | }); 284 | } else { 285 | console.warn('Not yet implemented: save canvas to PNG in client-side / production.'); 286 | } 287 | } 288 | } 289 | 290 | function print () { 291 | if (process.env.NODE_ENV === 'production') { 292 | console.log('You need to be in development mode to print a plot!'); 293 | } else if (typeof plot.print !== 'function') { 294 | throw new Error('Plot has no print() function defined!'); 295 | } else { 296 | const svg = plot.print(); 297 | if (!svg || (!isBuffer(svg) && typeof svg !== 'string')) { 298 | throw new Error('print() must return a string or Buffer SVG file!'); 299 | } 300 | xhr.post('/print', { 301 | json: true, 302 | body: { 303 | dimensions, 304 | orientation, 305 | lineWidth, 306 | svg 307 | } 308 | }, err => { 309 | if (err) throw err; 310 | }); 311 | } 312 | } 313 | 314 | function checkDeprecations () { 315 | let result = {}; 316 | if (typeof opt.outputImageWidth === 'number' || typeof opt.outputImageHeight === 'number') { 317 | const example = `Example: 318 | return { 319 | background: 'white', 320 | outputSize: [ 1280, 'auto' ] 321 | } 322 | `; 323 | console.warn('[penplot] Deprecation notice - outputImageWidth/Height has been moved to outputSize in the options returned from your penplot function.\n\n' + example); 324 | result.outputSize = true; 325 | } 326 | return result; 327 | } 328 | } 329 | 330 | export function cmToPixels (dimensions, dpi = 300) { 331 | const CM_IN = 2.54; 332 | if (Array.isArray(dimensions)) { 333 | const inchWidth = dimensions[0] / CM_IN; 334 | const inchHeight = dimensions[1] / CM_IN; 335 | return [ inchWidth * dpi, inchHeight * dpi ]; 336 | } else { 337 | return (dimensions / CM_IN) * dpi; 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /util/geom.js: -------------------------------------------------------------------------------- 1 | import vec3 from 'gl-vec3'; 2 | import vec2 from 'gl-vec2'; 3 | import lineclip from 'lineclip'; 4 | import arrayAlmostEqual from 'array-almost-equal'; 5 | import triangleCentroid from 'triangle-centroid'; 6 | import insideTriangle from 'point-in-triangle'; 7 | import * as RND from './random'; 8 | 9 | const tmp1 = []; 10 | const tmp2 = []; 11 | const tmpTriangle = [ 0, 0, 0 ]; 12 | 13 | // Random point in N-dimensional triangle 14 | export function randomPointInTriangle (out = [], a, b, c, u = RND.random(), v = RND.random()) { 15 | if ((u + v) > 1) { 16 | u = 1 - u; 17 | v = 1 - v; 18 | } 19 | const dim = a.length; 20 | const Q = 1 - u - v; 21 | for (let i = 0; i < dim; i++) { 22 | out[i] = (a[i] * u) + (b[i] * v) + (c[i] * Q); 23 | } 24 | return out; 25 | } 26 | 27 | export const FaceCull = { 28 | BACK: -1, 29 | FRONT: 1, 30 | NONE: 0 31 | }; 32 | 33 | function isTriangleVisible (cell, vertices, rayDir, side = FaceCull.BACK) { 34 | if (side === FaceCull.NONE) return true; 35 | const verts = cell.map(i => vertices[i]); 36 | const v0 = verts[0]; 37 | const v1 = verts[1]; 38 | const v2 = verts[2]; 39 | vec3.subtract(tmp1, v1, v0); 40 | vec3.subtract(tmp2, v2, v0); 41 | vec3.cross(tmp1, tmp1, tmp2); 42 | vec3.normalize(tmp1, tmp1); 43 | const d = vec3.dot(rayDir, tmp1); 44 | return side === FaceCull.BACK ? d > 0 : d <= 0; 45 | } 46 | 47 | // Whether the 3D triangle face is visible to the camera 48 | // i.e. backface / frontface culling 49 | export function isFaceVisible (cell, vertices, rayDir, side = FaceCull.BACK) { 50 | if (side === FaceCull.NONE) return true; 51 | if (cell.length === 3) { 52 | return isTriangleVisible(cell, vertices, rayDir, side); 53 | } 54 | if (cell.length !== 4) throw new Error('isFaceVisible can only handle triangles and quads'); 55 | } 56 | 57 | export function clipPolylinesToBox (polylines, bbox, border = false, closeLines = true) { 58 | if (border) { 59 | return polylines.map(line => { 60 | const result = lineclip.polygon(line, bbox); 61 | if (closeLines && result.length > 2) result.push(result[0]); 62 | return result; 63 | }).filter(lines => lines.length > 0); 64 | } else { 65 | return polylines.map(line => { 66 | return lineclip.polyline(line, bbox); 67 | }).reduce((a, b) => a.concat(b), []); 68 | } 69 | } 70 | 71 | // Normal of a 3D triangle face 72 | export function computeFaceNormal (cell, positions, out = []) { 73 | const a = positions[cell[0]]; 74 | const b = positions[cell[1]]; 75 | const c = positions[cell[2]]; 76 | vec3.subtract(out, c, b); 77 | vec3.subtract(tmp2, a, b); 78 | vec3.cross(out, out, tmp2); 79 | vec3.normalize(out, out); 80 | return out; 81 | } 82 | 83 | // Area of 2D or 3D triangle 84 | export function computeTriangleArea (a, b, c) { 85 | if (a.length >= 3 && b.length >= 3 && c.length >= 3) { 86 | vec3.subtract(tmp1, c, b); 87 | vec3.subtract(tmp2, a, b); 88 | vec3.cross(tmp1, tmp1, tmp2); 89 | return vec3.length(tmp1) * 0.5; 90 | } else { 91 | return Math.abs((a[0] - c[0]) * (b[1] - a[1]) - (a[0] - b[0]) * (c[1] - a[1])) * 0.5; 92 | } 93 | } 94 | 95 | export function createHatchLines (bounds, angle = -Math.PI / 4, spacing = 0.5, out = []) { 96 | // Reference: 97 | // https://github.com/evil-mad/EggBot/blob/master/inkscape_driver/eggbot_hatch.py 98 | spacing = Math.abs(spacing); 99 | if (spacing === 0) throw new Error('cannot use a spacing of zero as it will run an infinite loop!'); 100 | 101 | const xmin = bounds[0][0]; 102 | const ymin = bounds[0][1]; 103 | const xmax = bounds[1][0]; 104 | const ymax = bounds[1][1]; 105 | 106 | const w = xmax - xmin; 107 | const h = ymax - ymin; 108 | if (w === 0 || h === 0) return out; 109 | const r = Math.sqrt(w * w + h * h) / 2; 110 | const rotAngle = Math.PI / 2 - angle; 111 | const ca = Math.cos(rotAngle); 112 | const sa = Math.sin(rotAngle); 113 | const cx = bounds[0][0] + (w / 2); 114 | const cy = bounds[0][1] + (h / 2); 115 | let i = -r; 116 | while (i <= r) { 117 | // Line starts at (i, -r) and goes to (i, +r) 118 | const x1 = cx + (i * ca) + (r * sa); // i * ca - (-r) * sa 119 | const y1 = cy + (i * sa) - (r * ca); // i * sa + (-r) * ca 120 | const x2 = cx + (i * ca) - (r * sa); // i * ca - (+r) * sa 121 | const y2 = cy + (i * sa) + (r * ca); // i * sa + (+r) * ca 122 | i += spacing; 123 | // Remove any potential hatch lines which are entirely 124 | // outside of the bounding box 125 | if ((x1 < xmin && x2 < xmin) || (x1 > xmax && x2 > xmax)) { 126 | continue; 127 | } 128 | if ((y1 < ymin && y2 < ymin) || (y1 > ymax && y2 > ymax)) { 129 | continue; 130 | } 131 | out.push([ [ x1, y1 ], [ x2, y2 ] ]); 132 | } 133 | return out; 134 | } 135 | 136 | export function intersectLineSegmentLineSegment (p1, p2, p3, p4) { 137 | // Reference: 138 | // https://github.com/evil-mad/EggBot/blob/master/inkscape_driver/eggbot_hatch.py 139 | const d21x = p2[0] - p1[0]; 140 | const d21y = p2[1] - p1[1]; 141 | const d43x = p4[0] - p3[0]; 142 | const d43y = p4[1] - p3[1]; 143 | 144 | // denominator 145 | const d = d21x * d43y - d21y * d43x; 146 | if (d === 0) return -1; 147 | 148 | const nb = (p1[1] - p3[1]) * d21x - (p1[0] - p3[0]) * d21y; 149 | const sb = nb / d; 150 | if (sb < 0 || sb > 1) return -1; 151 | 152 | const na = (p1[1] - p3[1]) * d43x - (p1[0] - p3[0]) * d43y; 153 | const sa = na / d; 154 | if (sa < 0 || sa > 1) return -1; 155 | return sa; 156 | } 157 | 158 | export function expandTriangle (triangle, border = 0) { 159 | if (border === 0) return triangle; 160 | let centroid = triangleCentroid(triangle); 161 | triangle[0] = expandVector(triangle[0], centroid, border); 162 | triangle[1] = expandVector(triangle[1], centroid, border); 163 | triangle[2] = expandVector(triangle[2], centroid, border); 164 | return triangle; 165 | } 166 | 167 | export function expandVector (point, centroid, amount = 0) { 168 | point = vec2.copy([], point); 169 | const dir = vec2.subtract([], centroid, point); 170 | const maxLen = vec2.length(dir); 171 | const len = Math.min(maxLen, amount); 172 | if (maxLen !== 0) vec2.scale(dir, dir, 1 / maxLen); // normalize 173 | vec2.scaleAndAdd(point, point, dir, len); 174 | return point; 175 | } 176 | 177 | export function clipLineToTriangle (p1, p2, a, b, c, border = 0, result = []) { 178 | if (border !== 0) { 179 | let centroid = triangleCentroid([ a, b, c ]); 180 | a = expandVector(a, centroid, border); 181 | b = expandVector(b, centroid, border); 182 | c = expandVector(c, centroid, border); 183 | } 184 | 185 | // first check if all points are inside triangle 186 | tmpTriangle[0] = a; 187 | tmpTriangle[1] = b; 188 | tmpTriangle[2] = c; 189 | if (insideTriangle(p1, tmpTriangle) && insideTriangle(p2, tmpTriangle)) { 190 | result[0] = p1.slice(); 191 | result[1] = p2.slice(); 192 | return true; 193 | } 194 | 195 | // triangle segments 196 | const segments = [ 197 | [ a, b ], 198 | [ b, c ], 199 | [ c, a ] 200 | ]; 201 | 202 | for (let i = 0; i < 3; i++) { 203 | // test against each triangle edge 204 | const segment = segments[i]; 205 | let p3 = segment[0]; 206 | let p4 = segment[1]; 207 | 208 | const fract = intersectLineSegmentLineSegment(p1, p2, p3, p4); 209 | if (fract >= 0 && fract <= 1) { 210 | result.push([ 211 | p1[0] + fract * (p2[0] - p1[0]), 212 | p1[1] + fract * (p2[1] - p1[1]) 213 | ]); 214 | // when we have 2 result we can stop checking 215 | if (result.length >= 2) break; 216 | } 217 | } 218 | 219 | if (arrayAlmostEqual(result[0], result[1])) { 220 | // if the two points are close enough they are basically 221 | // touching, or if the border pushed them close together, 222 | // then ignore this altogether 223 | result.length = 0; 224 | } 225 | 226 | return result.length === 2; 227 | } 228 | 229 | // Parses a Three.js JSON to a mesh 230 | export function parseThreeJSONGeometry (mesh) { 231 | const positionAttribute = mesh.data.attributes.position; 232 | const cells = rollArray(mesh.data.index.array, 3); 233 | const positions = rollArray(positionAttribute.array, positionAttribute.itemSize); 234 | return { cells, positions }; 235 | } 236 | 237 | function rollArray (array, count) { 238 | const output = []; 239 | for (let i = 0, j = 0; i < array.length / count; i++) { 240 | const item = []; 241 | for (let c = 0; c < count; c++, j++) { 242 | item[c] = array[j]; 243 | } 244 | output.push(item); 245 | } 246 | return output; 247 | } 248 | 249 | export function resampleLineBySpacing (points, spacing = 1, closed = false) { 250 | if (spacing <= 0) { 251 | throw new Error('Spacing must be positive and larger than 0'); 252 | } 253 | let totalLength = 0; 254 | let curStep = 0; 255 | let lastPosition = points.length - 1; 256 | if (closed) { 257 | lastPosition++; 258 | } 259 | const result = []; 260 | const tmp = [ 0, 0 ]; 261 | for (let i = 0; i < lastPosition; i++) { 262 | const repeatNext = i === points.length - 1; 263 | const cur = points[i]; 264 | const next = repeatNext ? points[0] : points[i + 1]; 265 | const diff = vec2.subtract(tmp, next, cur); 266 | 267 | let curSegmentLength = vec2.length(diff); 268 | totalLength += curSegmentLength; 269 | 270 | while (curStep * spacing <= totalLength) { 271 | let curSample = curStep * spacing; 272 | let curLength = curSample - (totalLength - curSegmentLength); 273 | let relativeSample = curLength / curSegmentLength; 274 | result.push(vec2.lerp([], cur, next, relativeSample)); 275 | curStep++; 276 | } 277 | } 278 | return result; 279 | } 280 | 281 | export function getPolylinePerimeter (points, closed = false) { 282 | let perimeter = 0; 283 | let lastPosition = points.length - 1; 284 | for (let i = 0; i < lastPosition; i++) { 285 | perimeter += vec2.distance(points[i], points[i + 1]); 286 | } 287 | if (closed && points.length > 1) { 288 | perimeter += vec2.distance(points[points.length - 1], points[0]); 289 | } 290 | return perimeter; 291 | } 292 | 293 | export function resampleLineByCount (points, count = 1, closed = false) { 294 | if (count <= 0) return []; 295 | const perimeter = getPolylinePerimeter(points, closed); 296 | return resampleLineBySpacing(points, perimeter / count, closed); 297 | } 298 | 299 | // Returns a list that is a cubic spline of the input points 300 | // This function could probably be optimized for real-time a bit better 301 | export function cubicSpline (points, tension = 0.5, segments = 25, closed = false) { 302 | // unroll pairs into flat array 303 | points = points.reduce((a, b) => a.concat(b), []); 304 | 305 | var pts; // for cloning point array 306 | var i = 1; 307 | var l = points.length; 308 | var rPos = 0; 309 | var rLen = (l - 2) * segments + 2 + (closed ? 2 * segments : 0); 310 | var res = new Float32Array(rLen); 311 | var cache = new Float32Array((segments + 2) * 4); 312 | var cachePtr = 4; 313 | var st, st2, st3, st23, st32, parse; 314 | 315 | pts = points.slice(0); 316 | if (closed) { 317 | pts.unshift(points[l - 1]); // insert end point as first point 318 | pts.unshift(points[l - 2]); 319 | pts.push(points[0], points[1]); // first point as last point 320 | } else { 321 | pts.unshift(points[1]); // copy 1. point and insert at beginning 322 | pts.unshift(points[0]); 323 | pts.push(points[l - 2], points[l - 1]); // duplicate end-points 324 | } 325 | // cache inner-loop calculations as they are based on t alone 326 | cache[0] = 1; // 1,0,0,0 327 | for (; i < segments; i++) { 328 | st = i / segments; 329 | st2 = st * st; 330 | st3 = st2 * st; 331 | st23 = st3 * 2; 332 | st32 = st2 * 3; 333 | cache[cachePtr++] = st23 - st32 + 1; // c1 334 | cache[cachePtr++] = st32 - st23; // c2 335 | cache[cachePtr++] = st3 - 2 * st2 + st; // c3 336 | cache[cachePtr++] = st3 - st2; // c4 337 | } 338 | cache[++cachePtr] = 1; // 0,1,0,0 339 | 340 | parse = function (pts, cache, l) { 341 | var i = 2; 342 | var t, pt1, pt2, pt3, pt4, t1x, t1y, t2x, t2y, c, c1, c2, c3, c4; 343 | 344 | for (i; i < l; i += 2) { 345 | pt1 = pts[i]; 346 | pt2 = pts[i + 1]; 347 | pt3 = pts[i + 2]; 348 | pt4 = pts[i + 3]; 349 | t1x = (pt3 - pts[i - 2]) * tension; 350 | t1y = (pt4 - pts[i - 1]) * tension; 351 | t2x = (pts[i + 4] - pt1) * tension; 352 | t2y = (pts[i + 5] - pt2) * tension; 353 | for (t = 0; t < segments; t++) { 354 | // t * 4 355 | c = t << 2; // jshint ignore: line 356 | c1 = cache[c]; 357 | c2 = cache[c + 1]; 358 | c3 = cache[c + 2]; 359 | c4 = cache[c + 3]; 360 | 361 | res[rPos++] = c1 * pt1 + c2 * pt3 + c3 * t1x + c4 * t2x; 362 | res[rPos++] = c1 * pt2 + c2 * pt4 + c3 * t1y + c4 * t2y; 363 | } 364 | } 365 | }; 366 | 367 | // calc. points 368 | parse(pts, cache, l); 369 | 370 | if (closed) { 371 | // l = points.length 372 | pts = []; 373 | pts.push(points[l - 4], points[l - 3], points[l - 2], points[l - 1]); // second last and last 374 | pts.push(points[0], points[1], points[2], points[3]); // first and second 375 | parse(pts, cache, 4); 376 | } 377 | // add last point 378 | l = closed ? 0 : points.length - 2; 379 | res[rPos++] = points[l]; 380 | res[rPos] = points[l + 1]; 381 | 382 | // roll back up into pairs 383 | const rolled = []; 384 | for (let i = 0; i < res.length / 2; i++) { 385 | rolled.push([ res[i * 2 + 0], res[i * 2 + 1] ]); 386 | } 387 | return rolled; 388 | } 389 | --------------------------------------------------------------------------------