├── .babelrc ├── .gitignore ├── demo ├── screenshot.png ├── index.js ├── svgToCommands.js └── icon.svg ├── .npmignore ├── LICENSE.md ├── package.json ├── README.md └── PathGeometry.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | presets: [ "es2015" ] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | dist/ 7 | -------------------------------------------------------------------------------- /demo/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/three-path-geometry/HEAD/demo/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 11 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Jam3 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "three-path-geometry", 3 | "version": "1.0.2", 4 | "description": "thick 2D lines for ThreeJS", 5 | "main": "./dist/PathGeometry.js", 6 | "jsnext:main": "./PathGeometry.js", 7 | "license": "MIT", 8 | "semistandard": { 9 | "globals": [ 10 | "THREE" 11 | ] 12 | }, 13 | "author": { 14 | "name": "Matt DesLauriers", 15 | "email": "dave.des@gmail.com", 16 | "url": "https://github.com/mattdesl" 17 | }, 18 | "dependencies": { 19 | "array-equal": "^1.0.0", 20 | "defined": "^1.0.0", 21 | "gl-vec2": "^1.0.0", 22 | "three-buffer-vertex-data": "^1.0.2" 23 | }, 24 | "devDependencies": { 25 | "babel-cli": "^6.14.0", 26 | "babel-preset-es2015": "^6.14.0", 27 | "babelify": "^7.3.0", 28 | "bound-points": "^1.0.0", 29 | "budo": "^9.2.0", 30 | "extract-svg-path": "^2.1.0", 31 | "normalize-path-scale": "^2.0.0", 32 | "parse-svg-path": "^0.1.2", 33 | "simplify-path": "^1.1.0", 34 | "svg-path-contours": "^2.0.0", 35 | "three": "^0.81.2" 36 | }, 37 | "scripts": { 38 | "test": "node test.js", 39 | "compile": "babel -d dist/ PathGeometry.js", 40 | "prepublish": "npm run compile", 41 | "dev": "budo demo/index.js:bundle.js --live -- -t babelify -t extract-svg-path/transform", 42 | "start": "npm run compile && npm run dev" 43 | }, 44 | "keywords": [ 45 | "line", 46 | "2d", 47 | "join", 48 | "path", 49 | "geometry" 50 | ], 51 | "repository": { 52 | "type": "git", 53 | "url": "git://github.com/Jam3/three-path-geometry.git" 54 | }, 55 | "homepage": "https://github.com/Jam3/three-path-geometry", 56 | "bugs": { 57 | "url": "https://github.com/Jam3/three-path-geometry/issues" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | global.THREE = require('three'); 2 | 3 | const PathGeometry = require('../'); 4 | const svgToCommands = require('./svgToCommands'); 5 | const svgPaths = require('extract-svg-path')(__dirname + '/icon.svg'); 6 | 7 | start(); 8 | 9 | function start () { 10 | const simplifies = [ 0, 10, 20, 30, 40, 50 ]; 11 | const count = simplifies.length; 12 | const scale = 150; 13 | const width = scale * count; 14 | const height = scale; 15 | const renderer = new THREE.WebGLRenderer({ 16 | antialias: true 17 | }); 18 | renderer.sortObjects = false; 19 | renderer.setPixelRatio(window.devicePixelRatio); 20 | renderer.setSize(width, height); 21 | document.body.appendChild(renderer.domElement); 22 | document.body.style.overflow = 'hidden'; 23 | document.body.style.margin = '20px'; 24 | 25 | renderer.setClearColor(new THREE.Color('hsl(0, 0%, 90%)'), 1); 26 | 27 | const camera = new THREE.OrthographicCamera(-width / 2, width / 2, -height / 2, height / 2, -100, 100); 28 | const scene = new THREE.Scene(); 29 | 30 | const pathGeometry = new PathGeometry({ 31 | thickness: 2 / scale, 32 | miterLimit: Infinity 33 | }); 34 | const pathMesh = new THREE.Mesh(pathGeometry, new THREE.MeshBasicMaterial({ 35 | color: 'hsl(0, 0%, 15%)', 36 | side: THREE.DoubleSide 37 | })); 38 | 39 | // ensure frustum culling is not enabled on mesh 40 | pathMesh.frustumCulled = false; 41 | pathMesh.scale.multiplyScalar(scale * 0.40); 42 | 43 | // build a list of commands 44 | const allCommands = []; 45 | for (let i = 0; i < count; i++) { 46 | // add two paths 47 | const commands = svgToCommands(svgPaths, { simplify: simplifies[i] }); 48 | const offset = ((i / (count - 1)) * 2 - 1) * (count - 1); 49 | commands.forEach(command => { 50 | command.position[0] += offset; 51 | }); 52 | commands.forEach(cmd => allCommands.push(cmd)); 53 | } 54 | 55 | // upload all commands at once 56 | pathGeometry.update(allCommands); 57 | 58 | scene.add(pathMesh); 59 | render(); 60 | 61 | function render () { 62 | renderer.render(scene, camera); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /demo/svgToCommands.js: -------------------------------------------------------------------------------- 1 | const simplify = require('simplify-path'); 2 | const getContours = require('svg-path-contours'); 3 | const parseSVG = require('parse-svg-path'); 4 | const normalize = require('normalize-path-scale'); 5 | const defined = require('defined'); 6 | 7 | module.exports = function (paths, opt = {}) { 8 | const svg = parseSVG(paths); 9 | const scale = defined(opt.scale, 1); 10 | const simplifyThreshold = defined(opt.simplify, 0.05); 11 | const contours = getContours(svg, scale).map(c => { 12 | return simplify(c, simplifyThreshold); 13 | }); 14 | 15 | let min = [ +Infinity, +Infinity ]; 16 | let max = [ -Infinity, -Infinity ]; 17 | for (let i = 0; i < contours.length; i++) { 18 | for (let p = 0; p < contours[i].length; p++) { 19 | const point = contours[i][p]; 20 | if (point[0] > max[0]) max[0] = point[0]; 21 | if (point[1] > max[1]) max[1] = point[1]; 22 | if (point[0] < min[0]) min[0] = point[0]; 23 | if (point[1] < min[1]) min[1] = point[1]; 24 | } 25 | } 26 | 27 | const bounds = [ min, max ]; 28 | const commands = contours.map(c => { 29 | if (opt.normalize !== false) { 30 | normalize(c, bounds); 31 | } 32 | return c.map((p, i) => { 33 | return { 34 | type: i === 0 ? 'M' : 'L', 35 | position: p 36 | }; 37 | }); 38 | }).reduce((a, b) => a.concat(b), []); 39 | return commands; 40 | }; 41 | 42 | /* 43 | // Some canvas rendering code for testing... 44 | const paths = require('extract-svg-path')(__dirname + '/icon.svg'); 45 | const commands = module.exports(paths); 46 | const canvas = document.createElement('canvas'); 47 | const ctx = canvas.getContext('2d'); 48 | 49 | canvas.width = 512; 50 | canvas.height = 512; 51 | 52 | ctx.translate(256, 256); 53 | ctx.scale(256, 256); 54 | 55 | ctx.beginPath(); 56 | commands.forEach(cmd => { 57 | if (cmd.type === 'M') { 58 | ctx.moveTo(cmd.position[0], cmd.position[1]); 59 | } else { 60 | ctx.lineTo(cmd.position[0], cmd.position[1]); 61 | } 62 | }); 63 | ctx.lineWidth = 1 / 256; 64 | ctx.stroke(); 65 | 66 | commands.forEach(cmd => { 67 | cmd.position[0] /= canvas.width; 68 | cmd.position[1] /= canvas.height; 69 | }); 70 | 71 | window.cmds = JSON.stringify(commands); 72 | // console.log(JSON.stringify(commands)); 73 | 74 | document.body.appendChild(canvas); 75 | */ 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # three-path-geometry 2 | 3 | [![experimental](http://badges.github.io/stability-badges/dist/experimental.svg)](http://github.com/badges/stability-badges) 4 | 5 | 6 | 7 | *Above: a `BufferGeometry` combines several variations of an SVG file.* 8 | 9 | Thick 2D line geometry for ThreeJS, converting a polyline into triangles. This has been optimized and designed for a specific application, so its feature set is limited: 10 | 11 | - Supports "Move To" and "Line To" commands for a polyline 12 | - Designed for fixed line thickness 13 | - Supported joins: miter or bevel (with miter limiting) 14 | - Uses a mix of front and back side indexing 15 | - Can incrementally add new paths to the geometry in an optimized manner 16 | 17 | This is best suited for a drawing app that needs to render *thousands* of commands, i.e. using a static geometry. 18 | 19 | > :bulb: Dynamic growing/shrinking of buffers is only supported in ThreeJS r82 and higher. 20 | 21 | ## Install 22 | 23 | ```sh 24 | npm install three-path-geometry --save 25 | ``` 26 | 27 | ## Example 28 | 29 | See [./demo/index.js](./demo/index.js) for a full demo, which renders the earlier screenshot of the SVG. 30 | 31 | ```js 32 | global.THREE = reqiure('three'); 33 | const PathGeometry = require('three-path-geometry'); 34 | 35 | const geometry = new PathGeometry({ thickness: 2 }); 36 | geometry.update([ 37 | { type: 'M', position: [ 25, 15 ] }, 38 | { type: 'L', position: [ 50, 15 ] }, 39 | { type: 'L', position: [ 50, 25 ] } 40 | ]); 41 | 42 | const material = new THREE.MeshBasicMaterial({ 43 | color: 'black', 44 | side: THREE.DoubleSide // needed for this geometry 45 | }); 46 | 47 | const mesh = new THREE.Mesh(geometry, material); 48 | mesh.frustumCulled = false; // currently needed for 2D geometries 49 | 50 | scene.add(mesh); 51 | ``` 52 | 53 | This module expects `THREE` to exist on global scope. 54 | 55 | ## Usage 56 | 57 | [![NPM](https://nodei.co/npm/three-path-geometry.png)](https://www.npmjs.com/package/three-path-geometry) 58 | 59 | #### `geometry = new PathGeometry([opt])` 60 | 61 | Creates a new PathGeometry with the options: 62 | 63 | - `thickness` — the thickness of the line in world units, default 1 64 | - `miterLimit` — the limit to use when mitering line joins, default 8 (use `Infinity` for pure bevel, 0 for pure miter) 65 | 66 | #### `geometry.clear()` 67 | 68 | Clears the current geometry and its paths. 69 | 70 | #### `geometry.update(path)` 71 | 72 | Clears the geometry and sets it to the new `path`, which is an array of commands like so: 73 | 74 | ```js 75 | [ 76 | { type: 'M', position: [ 25, 15 ] }, 77 | { type: 'L', position: [ 50, 15 ] } 78 | ] 79 | ``` 80 | 81 | Commands can be either type `'M'` (moveTo) or `'L'` (lineTo). The position is a 2D plain array with the `[ x, y ]` value. 82 | 83 | #### `geometry.append(path)` 84 | 85 | Appends a new path to the existing geometry, without clearing anything first. The commands are the same format as in `update`. 86 | 87 | #### `geometry.thickness` 88 | 89 | The current thickness of the geometry. 90 | 91 | #### `geometry.miterLimit` 92 | 93 | The current miter limit of the geometry. 94 | 95 | ## License 96 | 97 | MIT, see [LICENSE.md](http://github.com/Jam3/three-path-geometry/blob/master/LICENSE.md) for details. 98 | -------------------------------------------------------------------------------- /demo/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 10 | 13 | 15 | 17 | 19 | 21 | 23 | 25 | 27 | 29 | 35 | 39 | 41 | 43 | 46 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /PathGeometry.js: -------------------------------------------------------------------------------- 1 | const buffer = require('three-buffer-vertex-data'); 2 | const vec2 = require('gl-vec2'); 3 | const arrayEqual = require('array-equal'); 4 | const defined = require('defined'); 5 | 6 | // Avoid GC by re-using all our arrays 7 | const tmpCurrent = []; 8 | const tmpPrevious = []; 9 | const tmpNext = []; 10 | const tmpDirNext = []; 11 | const tmpDirPrevious = []; 12 | const tmp1 = []; 13 | const tmp2 = []; 14 | const tmp3 = []; 15 | const tmp4 = []; 16 | const tmp5 = []; 17 | const tmp6 = []; 18 | const tmpBevel1 = []; 19 | const tmpBevel2 = []; 20 | const tmpBevel3 = []; 21 | const tmpBevel4 = []; 22 | const tmpVert1 = []; 23 | const tmpVert2 = []; 24 | 25 | class PathGeometry extends THREE.BufferGeometry { 26 | 27 | constructor (opt = {}) { 28 | super(); 29 | this._cellOffset = 0; 30 | this._cells = []; 31 | this._positions = []; 32 | this.thickness = defined(opt.thickness, 1); 33 | this.miterLimit = defined(opt.miterLimit, 8); 34 | } 35 | 36 | _clearArrays () { 37 | this._positions.length = 0; 38 | this._cells.length = 0; 39 | this._cellOffset = 0; 40 | } 41 | 42 | clear () { 43 | this._clearArrays(); 44 | this._updateBuffers(); 45 | } 46 | 47 | update (path) { 48 | this._clearArrays(); 49 | this.append(path); 50 | } 51 | 52 | append (path) { 53 | path = this._cleanPath(path); 54 | if (path.length === 0) return; 55 | 56 | for (let i = 0; i < path.length; i++) { 57 | const current = path[i]; 58 | let next = i === path.length - 1 ? current : path[i + 1]; 59 | let previous = i === 0 ? current : path[i - 1]; 60 | if (current.type === 'M' && current !== next && next.type === 'M') { 61 | // skip consecutive moveTos 62 | continue; 63 | } 64 | 65 | // if next point is a move, end this line here 66 | if (next !== current && next.type === 'M') { 67 | next = current; 68 | } 69 | 70 | // if we need to skip to a new line segment 71 | if (current.type === 'M' && this._positions.length > 0) { 72 | this._newSegment(); 73 | previous = current; 74 | } 75 | this._addSegment(current, previous, next); 76 | } 77 | 78 | // now update the buffers with float/short data 79 | this._updateBuffers(); 80 | } 81 | 82 | _updateBuffers () { 83 | buffer.index(this, this._cells, 1, this._cells.length > 65535 ? 'uint32' : 'uint16'); 84 | buffer.attr(this, 'position', this._positions, 2); 85 | } 86 | 87 | _toModelPosition (out, position) { 88 | out[0] = position[0]; 89 | out[1] = position[1]; 90 | return out; 91 | } 92 | 93 | _cleanPath (path) { 94 | const output = []; 95 | let penStart = null; 96 | for (let i = 0; i < path.length; i++) { 97 | const current = path[i]; 98 | if (i === 0 || current.type === 'M') { 99 | penStart = current; 100 | continue; 101 | } 102 | let next = i === path.length - 1 ? current : path[i + 1]; 103 | // if next lineTo is at the same spot as current lineTo 104 | if (i < path.length - 1 && arrayEqual(current.position, next.position) && current.type === 'L' && next.type === 'L') { 105 | // just skip for next command 106 | continue; 107 | } 108 | if (penStart) { 109 | output.push({ type: 'M', position: penStart.position.slice() }); 110 | penStart = null; 111 | } 112 | output.push(current); 113 | } 114 | return output; 115 | } 116 | 117 | _newSegment () { 118 | if (this._cellOffset > 0) this._cellOffset += 2; 119 | } 120 | 121 | _addSegment (currentCommand, previousCommand, nextCommand) { 122 | const current = this._toModelPosition(tmpCurrent, currentCommand.position); 123 | const previous = this._toModelPosition(tmpPrevious, previousCommand.position); 124 | const next = this._toModelPosition(tmpNext, nextCommand.position); 125 | 126 | const thickness = this.thickness; 127 | const dirPrevious = getDirection(tmpDirPrevious, current, previous); 128 | const dirNext = getDirection(tmpDirNext, next, current); 129 | const isStart = currentCommand === previousCommand; 130 | const isEnd = currentCommand === nextCommand; 131 | 132 | let dir; 133 | if (isStart || isEnd) { 134 | dir = isStart ? dirNext : dirPrevious; 135 | 136 | const len = thickness; 137 | const normal = vec2.set(tmp1, -dir[1], dir[0]); 138 | const vertexA = vec2.scaleAndAdd(tmp2, current, normal, 1 * len / 2); 139 | const vertexB = vec2.scaleAndAdd(tmp3, current, normal, -1 * len / 2); 140 | 141 | this._positions.push(vertexA.slice(), vertexB.slice()); 142 | if (!isEnd) { 143 | // if we still have another edge coming up next 144 | const off = this._cellOffset; 145 | pushTris(this._cells, off, 0, 1, 2, 2, 1, 3); 146 | this._cellOffset += 2; 147 | } 148 | } else { 149 | // We are at a join.. need to add an extra triangle 150 | const tangent = vec2.add(tmp1, dirPrevious, dirNext); 151 | vec2.normalize(tangent, tangent); 152 | 153 | const miter = vec2.set(tmp2, -tangent[1], tangent[0]); 154 | const perpendicular = vec2.set(tmp3, -dirPrevious[1], dirPrevious[0]); 155 | const miterDot = vec2.dot(miter, perpendicular); 156 | const miterLen = miterDot === 0 ? 0 : (thickness / miterDot); 157 | 158 | // bevel line end 159 | const miterNormal = vec2.set(tmp4, -tangent[1], tangent[0]); 160 | const isInside = vec2.dot(miterNormal, dirPrevious) < 0; 161 | 162 | // The miter points 163 | const miterVertexA = vec2.scaleAndAdd(tmpVert1, current, miterNormal, 1 * miterLen / 2); 164 | const miterVertexB = vec2.scaleAndAdd(tmpVert2, current, miterNormal, -1 * miterLen / 2); 165 | 166 | // bevel line next start 167 | const len = thickness; 168 | const normalA = vec2.set(tmp5, -dirPrevious[1], dirPrevious[0]); 169 | const normalB = vec2.set(tmp6, -dirNext[1], dirNext[0]); 170 | const bevelA1 = vec2.scaleAndAdd(tmpBevel1, current, normalA, 1 * len / 2); 171 | const bevelA2 = vec2.scaleAndAdd(tmpBevel2, current, normalA, -1 * len / 2); 172 | const bevelB1 = vec2.scaleAndAdd(tmpBevel3, current, normalB, 1 * len / 2); 173 | const bevelB2 = vec2.scaleAndAdd(tmpBevel4, current, normalB, -1 * len / 2); 174 | 175 | // inside 176 | let off = this._cellOffset; 177 | const miterLimit = this.miterLimit; 178 | const doJoin = miterLen !== 0 && (miterLen / thickness) <= miterLimit; 179 | if (doJoin) { 180 | // We want to join with miter or bevel 181 | if (isInside) { 182 | this._positions.push(miterVertexA.slice(), bevelA2.slice(), bevelB2.slice()); 183 | } else { 184 | this._positions.push(bevelA1.slice(), miterVertexB.slice(), bevelB1.slice()); 185 | } 186 | // bevel triangle 187 | pushTris(this._cells, off, 0, 1, 2); 188 | 189 | if (isInside) { 190 | pushTris(this._cells, off, 0, 2, 3, 3, 2, 4); 191 | } else { 192 | pushTris(this._cells, off, 1, 2, 4, 4, 3, 2); 193 | } 194 | this._cellOffset += 3; 195 | } else { 196 | // We want to join without any miter or bevel, this 197 | // is useful when we have extreme edges or exactly overlapping lines 198 | this._positions.push(bevelA1.slice(), bevelA2.slice()); 199 | this._positions.push(bevelB1.slice(), bevelB2.slice()); 200 | off += 2; 201 | pushTris(this._cells, off, 0, 1, 2, 2, 1, 3); 202 | this._cellOffset += 4; 203 | } 204 | } 205 | } 206 | } 207 | 208 | module.exports = PathGeometry; 209 | 210 | function pushTris (cells, offset) { 211 | const args = Array.prototype.slice.call(arguments, 0); 212 | for (let i = 2; i < args.length; i++) { 213 | cells.push(offset + args[i]); 214 | } 215 | } 216 | 217 | function getDirection (out, a, b) { 218 | vec2.subtract(out, a, b); 219 | return vec2.normalize(out, out); 220 | } 221 | --------------------------------------------------------------------------------