├── .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 | [](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 | [](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 |
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 |
--------------------------------------------------------------------------------