├── .gitignore
├── .vscode
└── settings.json
├── .DS_Store
├── dist
├── nik-logo.png
└── index.html
├── public
└── nik-logo.png
├── package.json
├── test.js
├── index.html
├── readme.md
├── menu.js
├── store.js
├── geometry.js
├── preview.js
├── graph.js
└── index.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | premiere
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.fontSize": 12
3 | }
--------------------------------------------------------------------------------
/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NikLever/ThreeJS-PathEditor/HEAD/.DS_Store
--------------------------------------------------------------------------------
/dist/nik-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NikLever/ThreeJS-PathEditor/HEAD/dist/nik-logo.png
--------------------------------------------------------------------------------
/public/nik-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NikLever/ThreeJS-PathEditor/HEAD/public/nik-logo.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "threejs-patheditor",
3 | "version": "1.0.0",
4 | "description": "A ThreeJS Path Editor",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview"
10 | },
11 | "keywords": [
12 | "threejs"
13 | ],
14 | "author": "Nik Lever",
15 | "license": "ISC",
16 | "dependencies": {
17 | "file-saver": "^2.0.5",
18 | "three": "^0.168.0",
19 | "vite": "^5.4.3"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/test.js:
--------------------------------------------------------------------------------
1 | const shape = new THREE.Shape();
2 | shape.moveTo( 0.000 , -0.500);
3 | shape.lineTo( 0.400, -0.500);
4 | shape.quadraticCurveTo( 0.994, -0.497, 1.000, 0.100;
5 | shape.quadraticCurveTo( 1.004, 0.600, 0.400, 0.600;
6 | shape.lineTo( 0.400, 1.000);
7 | shape.lineTo( -0.400, 1.000);
8 | shape.lineTo( -0.400, 0.600);
9 | shape.quadraticCurveTo( -1.000, 0.599, -1.000, 0.100;
10 | shape.quadraticCurveTo( -1.002, -0.498, -0.400, -0.500;
11 | shape.lineTo( 0.000, -0.500);
12 | const path1 = new THREE.Path()
13 | path1.absarc( -0.002, 0.068, 0.328, 0, -6.283, false );
14 | path1.moveTo( 0.326, 0.068 );
15 | shape.holes.push(path1);
16 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
35 |
36 |
37 |
38 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/dist/index.html:
--------------------------------------------------------------------------------
1 |
2 |
35 |
36 |
37 |
38 |
39 |
40 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # The ThreeJS Path Editor
2 |
3 | For this years [js13kGames](https://js13kgames.com) competition. I submitted a WebXR game. To stay under 13k, you can't use glb files as assets. Instead you build them at runtime. I used ExtrudeGeometry a lot. This takes a ThreeJS Path as a parameter. I used a lot of graph paper designing the paths. For next year I decided to create an editor.
4 |
5 | Click the thumbnail to view a video\
6 | [](https://youtu.be/Zy4Myfd0XAI)
7 |
8 | ## Instructions
9 |
10 | You can use the gui to position the x and y axes. And position the max x value visible.
11 | Your path is saved to localStorage with the name from the gui.
12 |
13 | ### Tutorial
14 |
15 | - Select the moveTo tool.
16 | - Click to place the first point.
17 | - Select the lineTo tool.
18 | - Click to add lines.
19 | - Right click to delete a node
20 | - Right click over a line to insert a node
21 | - Right click to change the tool to the active node.
22 | - The green nodes allow you to edit the control point for a curve.
23 | - For an arc, orange adjusts the radius, green the start angle and red the end angle. The grey triangle when clicked flips the arc direction.
24 |
25 | Click a blue node to select.
26 | The current active line will be displayed in black.
27 |
28 | ### Settings
29 | - **xAxis** the positioning of the xAxis. 0 axis will be at the bottom of the screen, 1 at the top
30 | - **yAxis** the positioning of the yAxis. 0 axis will be at the left of the screen, 1 at the right
31 | - **xMax** the maximum x axis value
32 | - **units** setting cm will scale the export value by 0.01, mm will scale by 0.001
33 | - **extrudeDepth** the depth value used when displaying the 3d view of the extruded path
34 | - **tool** the tool used when creating a new node or changing an existing node
35 | - **snap** If checked then placement will snap to the grid.
36 |
37 | ### Commands
38 | - **new** creates a new empty path.
39 | - **copy** creates a copy of the current path and switches to the copy
40 | - **udo** Udo upto 6 times.
41 | - **delete** deletes the current path
42 | - **show** displays a ThreeJS 3D view of the extruded path
43 | - **export** converts the path to ThreeJS code and copies this to the clipboard for copying into your project
44 | **name** select a different path
45 |
46 | ### Backdrop
47 | The backdrop folder can be used to view an existing path(s) while editing another. Just set the Ghosts > PathX as checked. If 'Use ghosts as holes' is checked then the ghosts will appear as holes in the 3D object when ***show*** is clicked. The code to add holes will also be added when using ***export***
48 |
49 | ### Tools
50 | - **moveTo**
51 | Click the screen to add.
52 | - **lineTo**
53 | Click the screen to add.
54 | - **quadraticCurveTo:**
55 | Click the screen to add. Then move the green control to shape the curve.
56 | - **bezierCurveTo:**
57 | Click the screen to add. Then move the green controls to shape the curve.
58 | - **arc:**
59 | Click the screen to add. Then move the orange control to change the radius, green to change the start angle and red to change the end angle. Click the arrow to change the arc direction.
60 |
61 | ### Demo
62 | View an online demo [here](https://niklever.com/apps/threejs-path-editor/)
63 |
64 |
65 |
--------------------------------------------------------------------------------
/menu.js:
--------------------------------------------------------------------------------
1 | export class Menu{
2 | constructor( app, links ){
3 | this.menu = document.querySelector(".context-menu");
4 | this.menuState = 0;
5 | this.contextMenuActive = "block";
6 | this.app = app;
7 |
8 | this.addLinks( links );
9 |
10 | document.addEventListener("contextmenu", (e) => {
11 | e.preventDefault();
12 | this.show(e);
13 | });
14 |
15 | // Event Listener for Close Context Menu when outside of menu clicked
16 | document.addEventListener("click", (e) => {
17 | this.hide();
18 | });
19 |
20 | // Close Context Menu on Esc key press
21 | window.onkeyup = (e) => {
22 | if (e.keyCode === 27) this.hide();
23 | }
24 | }
25 |
26 | addLinks( links = [] ){
27 | let html = "";
28 |
29 | /*
30 | Delete
31 |
*/
32 |
33 | const prefix = '${link.name}
`;
37 | });
38 |
39 | const elm = document.getElementById("menu-links");
40 | elm.innerHTML = html;
41 | }
42 |
43 | show(e) {
44 | if (this.menuState !== 1) {
45 | this.positionMenu(e);
46 | this.menuState = 1;
47 | this.menu.style.display = "block";
48 | }
49 | }
50 |
51 | hide(){
52 | if (this.menuState !== 0) {
53 | this.menuState = 0;
54 | this.menu.style.display = "none";
55 | }
56 | }
57 |
58 | getPosition(e) {
59 | let posx = 0;
60 | let posy = 0;
61 |
62 | //if (!e) e = window.event;
63 |
64 | if (e.pageX || e.pageY) {
65 | posx = e.pageX;
66 | posy = e.pageY;
67 | } else if (e.clientX || e.clientY) {
68 | posx =
69 | e.clientX +
70 | document.body.scrollLeft +
71 | document.documentElement.scrollLeft;
72 | posy =
73 | e.clientY + document.body.scrollTop + document.documentElement.scrollTop;
74 | }
75 |
76 | this.position = { x: posx, y: posy };
77 |
78 | return this.position;
79 | }
80 |
81 | positionMenu(e) {
82 | let clickCoords = this.getPosition(e);
83 |
84 | let menuWidth = this.menu.offsetWidth + 4;
85 | let menuHeight = this.menu.offsetHeight + 4;
86 |
87 | let windowWidth = window.innerWidth;
88 | let windowHeight = window.innerHeight;
89 |
90 | if (windowWidth - clickCoords.x < menuWidth) {
91 | this.menu.style.left = windowWidth - menuWidth + "px";
92 | } else {
93 | this.menu.style.left = clickCoords.x + "px";
94 | }
95 |
96 | if (windowHeight - clickCoords.y < menuHeight) {
97 | this.menu.style.top = windowHeight - menuHeight + "px";
98 | } else {
99 | this.menu.style.top = clickCoords.y + "px";
100 | }
101 | }
102 | }
--------------------------------------------------------------------------------
/store.js:
--------------------------------------------------------------------------------
1 | export class Store{
2 | static namespace = "THREEJS-PATH-EDITOR";
3 |
4 | constructor(){
5 | this.udoArr = [];
6 | }
7 |
8 | udo(){
9 | const data = this.udoArr.pop();
10 | if (data){
11 | localStorage.setItem(Store.namespace, data );
12 | return true;
13 | }
14 | return false;
15 | }
16 |
17 | write( config, nodes ) {
18 | const serializedData = localStorage.getItem( Store.namespace );
19 | const data = serializedData ? JSON.parse(serializedData) : {};
20 | const config1 = {};
21 | for (const [key, value] of Object.entries(config)) {
22 | if (typeof value != "function" ) config1[key] = value;
23 | }
24 | this.udoArr.push( JSON.stringify(data) );
25 | if (this.udoArr.length>6) this.udoArr.shift();
26 | const path = { config: config1, nodes };
27 | data[config.name] = path;
28 | data['activePath'] = config.name;
29 | localStorage.setItem(Store.namespace, JSON.stringify(data));
30 | }
31 |
32 | read( key ) {
33 | const serializedData = localStorage.getItem( Store.namespace );
34 | const data = JSON.parse(serializedData);
35 | return data ? data[key] : undefined;
36 | }
37 |
38 | delete( key ){
39 | const serializedData = localStorage.getItem( Store.namespace );
40 | const data = JSON.parse(serializedData);
41 | delete data[key];
42 | localStorage.setItem(Store.namespace, JSON.stringify(data));
43 | }
44 |
45 | getPathNames(){
46 | const names = [];
47 | const serializedData = localStorage.getItem( Store.namespace );
48 | if (serializedData==null) return ['Path1'];
49 | const data = JSON.parse(serializedData);
50 | for (const [key, value] of Object.entries(data)) {
51 | if (key != 'activePath' && key != 'tips') names.push( key );
52 | }
53 | return names;
54 | }
55 |
56 | copy(){
57 | let name = this.read( 'activePath' );
58 | if (name){
59 | const path = this.read( name );
60 | if (path){
61 | name = this.nextPathName;
62 | path.config.name = name;
63 | this.write( path.config, path.nodes );
64 | return name;
65 | }
66 | }
67 | }
68 |
69 | get nextPathName(){
70 | const names = this.getPathNames();
71 | if (names.length == 0 ) return "Path1";
72 | const lastName = names.pop();
73 | const lastIndex = Number(lastName.substring(4));
74 | return `Path${lastIndex + 1}`;
75 | }
76 |
77 | get activePath(){
78 | const key = this.read( 'activePath' );
79 | return this.read( key );
80 | }
81 |
82 | set activePath( value ){
83 | const serializedData = localStorage.getItem( Store.namespace );
84 | const data = serializedData ? JSON.parse(serializedData) : {};
85 | data['activePath'] = value;
86 | localStorage.setItem(Store.namespace, JSON.stringify(data));
87 | }
88 |
89 | get tipsShown(){
90 | let tips = this.read( 'tips' );
91 | if (tips == undefined) tips = [ false, false, false, false, false, false ];
92 | return tips;
93 | }
94 |
95 | set tipShown( index ){
96 | const serializedData = localStorage.getItem( Store.namespace );
97 | const data = serializedData ? JSON.parse(serializedData) : {};
98 | if (data['tips']==undefined) data['tips'] = [ false, false, false, false, false, false ];
99 | data['tips'][index] = true;
100 | localStorage.setItem(Store.namespace, JSON.stringify(data));
101 | }
102 | }
--------------------------------------------------------------------------------
/geometry.js:
--------------------------------------------------------------------------------
1 | export class Geometry{
2 | //Returns {.x, .y}, a projected point perpendicular on the (infinite) line.
3 | static calcNearestPointOnLine(line1, line2, pnt) {
4 | const L2 = ( ((line2.x - line1.x) * (line2.x - line1.x)) + ((line2.y - line1.y) * (line2.y - line1.y)) );
5 | if(L2 == 0) return false;
6 | const r = ( ((pnt.x - line1.x) * (line2.x - line1.x)) + ((pnt.y - line1.y) * (line2.y - line1.y)) ) / L2;
7 |
8 | return {
9 | x: line1.x + (r * (line2.x - line1.x)),
10 | y: line1.y + (r * (line2.y - line1.y))
11 | };
12 | }
13 |
14 | //Returns float, the shortest distance to the (infinite) line.
15 | static calcDistancePointToLine(line1, line2, pnt) {
16 | const L2 = ( ((line2.x - line1.x) * (line2.x - line1.x)) + ((line2.y - line1.y) * (line2.y - line1.y)) );
17 | if(L2 == 0) return false;
18 | const s = (((line1.y - pnt.y) * (line2.x - line1.x)) - ((line1.x - pnt.x) * (line2.y - line1.y))) / L2;
19 | return Math.abs(s) * Math.sqrt(L2);
20 | }
21 |
22 | //Returns bool, whether the projected point is actually inside the (finite) line segment.
23 | static calcIsInsideLineSegment(line1, line2, pnt) {
24 | const L2 = ( ((line2.x - line1.x) * (line2.x - line1.x)) + ((line2.y - line1.y) * (line2.y - line1.y)) );
25 | if(L2 == 0) return false;
26 | const r = ( ((pnt.x - line1.x) * (line2.x - line1.x)) + ((pnt.y - line1.y) * (line2.y - line1.y)) ) / L2;
27 |
28 | return (0 <= r) && (r <= 1);
29 | }
30 |
31 | //The most useful function. Returns bool true, if the mouse point is actually inside the (finite) line, given a line thickness from the theoretical line away. It also assumes that the line end points are circular, not square.
32 | static calcIsInsideThickLineSegment(line1, line2, pnt, lineThickness) {
33 | const L2 = ( ((line2.x - line1.x) * (line2.x - line1.x)) + ((line2.y - line1.y) * (line2.y - line1.y)) );
34 | if(L2 == 0) return false;
35 | const r = ( ((pnt.x - line1.x) * (line2.x - line1.x)) + ((pnt.y - line1.y) * (line2.y - line1.y)) ) / L2;
36 |
37 | //Assume line thickness is circular
38 | if(r < 0) {
39 | //Outside line1
40 | return (Math.sqrt(( (line1.x - pnt.x) * (line1.x - pnt.x) ) + ( (line1.y - pnt.y) * (line1.y - pnt.y) )) <= lineThickness);
41 | } else if((0 <= r) && (r <= 1)) {
42 | //On the line segment
43 | const s = (((line1.y - pnt.y) * (line2.x - line1.x)) - ((line1.x - pnt.x) * (line2.y - line1.y))) / L2;
44 | return (Math.abs(s) * Math.sqrt(L2) <= lineThickness);
45 | } else {
46 | //Outside line2
47 | return (Math.sqrt(( (line2.x - pnt.x) * (line2.x - pnt.x) ) + ( (line2.y - pnt.y) * (line2.y - pnt.y) )) <= lineThickness);
48 | }
49 | }
50 |
51 | static calcLineMidPoint( a, b ){
52 | const pt = {};
53 | pt.x = (a.x - b.x)/2 + b.x;
54 | pt.y = (a.y - b.y)/2 + b.y;
55 |
56 | return pt;
57 | }
58 |
59 | static calcPointAlongLine( a, b, delta ){
60 | const pt = {};
61 | pt.x = (a.x - b.x)* delta + b.x;
62 | pt.y = (a.y - b.y) * delta + b.y;
63 |
64 | return pt;
65 | }
66 |
67 | static calcDistanceBetweenTwoPoints( a, b ){
68 | const dx = a.x - b.x;
69 | const dy = a.y - b.y;
70 | return Math.sqrt( dx*dx + dy*dy );
71 | }
72 |
73 | static calcPointOnCircle( cx, cy, radius, theta ){
74 | const x = Math.cos( theta ) * radius + cx;
75 | const y = Math.sin( theta ) * radius + cy;
76 | return { x, y };
77 | }
78 |
79 | static calcAngleFromXAxis( org, pt ){
80 | const x = pt.x - org.x;
81 | const y = pt.y - org.y;
82 | return ( x>0 ) ? Math.atan( y/x ) : Math.atan( y/x ) - Math.PI;
83 | }
84 | }
--------------------------------------------------------------------------------
/preview.js:
--------------------------------------------------------------------------------
1 | import { ExtrudeGeometry,
2 | MeshStandardMaterial,
3 | Mesh,
4 | Shape,
5 | Path,
6 | Scene,
7 | Color,
8 | DirectionalLight,
9 | HemisphereLight,
10 | PerspectiveCamera,
11 | Vector3,
12 | WebGLRenderer } from "three";
13 | import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
14 | import { Geometry } from "./geometry";
15 |
16 | export class Preview{
17 | constructor(){
18 | this.scene = new Scene();
19 | this.scene.background = new Color( 0xAAAAAA );
20 | this.camera = new PerspectiveCamera( 40, 1, 0.1, 20 );
21 | this.camera.position.set( 0, 0, 3 );
22 | this.renderer = new WebGLRenderer( { antialias: true } );
23 | this.renderer.setSize( 512, 512 );
24 | this.light = new DirectionalLight( 0xFFFFFF, 3 );
25 | this.light.position.set( 1, 1, 1 );
26 | this.ambient = new HemisphereLight( 0xAAAAFF, 0x222255, 0.1 );
27 | this.scene.add( this.light );
28 | this.scene.add( this.ambient );
29 | this.container = document.getElementById("preview");
30 | this.container.appendChild( this.renderer.domElement );
31 | this.controls = new OrbitControls( this.camera, this.renderer.domElement );
32 | this.controls.addEventListener( "change", this.render.bind(this) );
33 | this.material = new MeshStandardMaterial( { color: 0x3333BB } );//, wireframe: true } );
34 | this.on = false;
35 | }
36 |
37 | show( nodes, depth, holes ){
38 | const shape = this.nodesToShape( nodes );
39 |
40 | if (holes){
41 | for( const [key, nodes] of Object.entries(holes)){
42 | const path = this.nodesToPath( nodes );
43 | shape.holes.push( path );
44 | }
45 | }
46 |
47 | if ( this.mesh ){
48 | this.mesh.geometry.dispose();
49 | this.scene.remove( this.mesh );
50 | }
51 |
52 | const geometry = new ExtrudeGeometry( shape, {
53 | depth,
54 | bevelEnabled: false
55 | });
56 |
57 | this.mesh = new Mesh( geometry, this.material );
58 | this.scene.add( this.mesh );
59 |
60 | const height = window.innerHeight;
61 | const top = (height - 512) / 2;
62 | this.container.style.top = `${top}px`;
63 |
64 | this.controls.target = this.getCenterPoint( this.mesh );
65 | this.camera.position.set( this.controls.target.x, this.controls.target.y, this.getCameraOffset( this.mesh ) );
66 | this.controls.update();
67 |
68 | this.render();
69 |
70 | this.on = true;
71 | }
72 |
73 | hide(){
74 | this.container.style.top = "-600px";
75 | this.on = false;
76 | }
77 |
78 | getCameraOffset(mesh) {
79 | const geometry = mesh.geometry;
80 |
81 | geometry.computeBoundingBox();
82 |
83 | const theta = 20 * (Math.PI/180.0);
84 | const halfwidth = (geometry.boundingBox.max.x - geometry.boundingBox.min.x)/2;
85 | //y = sin(t) * sc => sc = y/sin(t)
86 | let scale = halfwidth / Math.sin( theta );
87 | //x = cos(t) * sc =>
88 | let posZ = (Math.cos( theta ) * scale) * 1.5;
89 |
90 | const halfheight = (geometry.boundingBox.max.y - geometry.boundingBox.min.y)/2;
91 | //y = sin(t) * sc => sc = y/sin(t)
92 | scale = halfheight / Math.sin( theta );
93 | //x = cos(t) * sc =>
94 | posZ = Math.max( posZ, (Math.cos( theta ) * scale) * 1.5 );
95 |
96 | return posZ;
97 | }
98 |
99 | getCenterPoint(mesh) {
100 | const middle = new Vector3();
101 | const geometry = mesh.geometry;
102 |
103 | geometry.computeBoundingBox();
104 |
105 | middle.x = (geometry.boundingBox.max.x + geometry.boundingBox.min.x) / 2;
106 | middle.y = (geometry.boundingBox.max.y + geometry.boundingBox.min.y) / 2;
107 | middle.z = (geometry.boundingBox.max.z + geometry.boundingBox.min.z) / 2;
108 |
109 | mesh.localToWorld( middle );
110 |
111 | return middle;
112 | }
113 |
114 | nodesToShape( nodes ){
115 | const shape = new Shape();
116 |
117 | nodes.forEach( node => {
118 | switch( node.tool ){
119 | case 'moveTo':
120 | shape.moveTo( node.x, -node.y );
121 | break;
122 | case 'lineTo':
123 | shape.lineTo( node.x, -node.y );
124 | break;
125 | case 'quadraticCurveTo':
126 | shape.quadraticCurveTo( node.options.ctrlA.x, -node.options.ctrlA.y, node.x, -node.y );
127 | break;
128 | case 'bezierCurveTo':
129 | shape.bezierCurveTo( node.options.ctrlA.x, -node.options.ctrlA.y, node.options.ctrlB.x, -node.options.ctrlB.y, node.x, -node.y );
130 | break;
131 | case 'arc':
132 | shape.absarc( node.x, -node.y, node.options.radius, -node.options.start, -node.options.end, !node.options.clockwise );
133 | const pt = Geometry.calcPointOnCircle( node.x, node.y, node.options.radius, node.options.end );
134 | shape.moveTo( pt.x, -pt.y );
135 | break;
136 | }
137 | });
138 |
139 | return shape;
140 | }
141 |
142 | nodesToPath( nodes ){
143 | const path = new Path();
144 |
145 | nodes.forEach( node => {
146 | switch( node.tool ){
147 | case 'moveTo':
148 | path.moveTo( node.x, -node.y );
149 | break;
150 | case 'lineTo':
151 | path.lineTo( node.x, -node.y );
152 | break;
153 | case 'quadraticCurveTo':
154 | path.quadraticCurveTo( node.options.ctrlA.x, -node.options.ctrlA.y, node.x, -node.y );
155 | break;
156 | case 'bezierCurveTo':
157 | path.bezierCurveTo( node.options.ctrlA.x, -node.options.ctrlA.y, node.options.ctrlB.x, -node.options.ctrlB.y, node.x, -node.y );
158 | break;
159 | case 'arc':
160 | path.absarc( node.x, -node.y, node.options.radius, -node.options.start, -node.options.end, !node.options.clockwise );
161 | const pt = Geometry.calcPointOnCircle( node.x, node.y, node.options.radius, node.options.end );
162 | path.moveTo( pt.x, -pt.y );
163 | break;
164 | }
165 | });
166 |
167 | return path;
168 | }
169 |
170 | render(){
171 | this.renderer.render( this.scene, this.camera );
172 | }
173 | }
--------------------------------------------------------------------------------
/graph.js:
--------------------------------------------------------------------------------
1 | import { Geometry } from "./geometry.js";
2 |
3 | export class Graph{
4 | constructor( canvas, config ){
5 | this.canvas = canvas;
6 | this.config = config;
7 | this.context = canvas.getContext( '2d' );
8 | }
9 |
10 | drawBg(){
11 | let x = this.canvas.width * this.config.yAxis;
12 | let y = this.canvas.height - this.canvas.height * this.config.xAxis;
13 |
14 | this.drawGrid( x, y );
15 | this.drawAxes( x, y );
16 | }
17 |
18 | drawAxes( x, y, lineWidth = 2 ){
19 | this.context.strokeStyle = "#88A";
20 | this.context.lineWidth = lineWidth;
21 |
22 | // yAxis
23 | this.context.beginPath();
24 | this.context.moveTo( x, 0 );
25 | this.context.lineTo( x, this.canvas.height );
26 |
27 | // xAxis
28 | this.context.moveTo( 0, y );
29 | this.context.lineTo( this.canvas.width, y );
30 |
31 | // Draw the Path
32 | this.context.stroke();
33 | }
34 |
35 | drawLightGrid( yAxis, pos, extent ){
36 | if ( extent < 5 ) return;
37 |
38 | const inc = ( extent < 30 ) ? extent/2 : extent/10;
39 |
40 | this.context.strokeStyle = "#AAD";
41 | this.context.lineWidth = 0.5;
42 |
43 | this.context.beginPath();
44 |
45 | if (yAxis){
46 | for( let x=pos; x 0; xPos -= inc){
72 | this.drawLightGrid( true, xPos-inc, inc );
73 |
74 | this.context.strokeStyle = "#99C";
75 | this.context.lineWidth = lineWidth;
76 |
77 | // y grid
78 | this.context.beginPath();
79 | this.context.moveTo( xPos, 0 );
80 | this.context.lineTo( xPos, this.canvas.height );
81 |
82 | // Draw the Path
83 | this.context.stroke();
84 |
85 | if (num){
86 | this.context.fillText( num, xPos, y + 10 );
87 | }
88 |
89 | num--;
90 | }
91 |
92 | num = 0;
93 |
94 | for( let xPos = x; xPos < this.canvas.width; xPos += inc){
95 | this.drawLightGrid( true, xPos, inc );
96 |
97 | this.context.strokeStyle = "#555";
98 | this.context.lineWidth = lineWidth;
99 |
100 | // y grid
101 | this.context.beginPath();
102 | this.context.moveTo( xPos, 0 );
103 | this.context.lineTo( xPos, this.canvas.height );
104 |
105 | // Draw the Path
106 | this.context.stroke();
107 |
108 | if (num){
109 | this.context.fillText( num, xPos, y + 10 );
110 | }
111 |
112 | num++;
113 | }
114 |
115 | num = 0;
116 |
117 | for( let yPos = y; yPos > 0; yPos -= inc){
118 | this.drawLightGrid( false, yPos-inc, inc );
119 |
120 | this.context.strokeStyle = "#555";
121 | this.context.lineWidth = lineWidth;
122 |
123 | // x grid
124 | this.context.beginPath();
125 | this.context.moveTo( 0, yPos );
126 | this.context.lineTo( this.canvas.width, yPos );
127 |
128 | // Draw the Path
129 | this.context.stroke();
130 |
131 | if (num){
132 | this.context.fillText( num, x - 8, yPos );
133 | }
134 |
135 | num++;
136 | }
137 |
138 | num = 0;
139 |
140 | for( let yPos = y; yPos < this.canvas.height; yPos += inc){
141 | this.drawLightGrid( false, yPos, inc );
142 |
143 | this.context.strokeStyle = "#558";
144 | this.context.lineWidth = lineWidth;
145 |
146 | // y grid
147 | this.context.beginPath();
148 | this.context.moveTo( 0, yPos );
149 | this.context.lineTo( this.canvas.width, yPos );
150 |
151 | // Draw the Path
152 | this.context.stroke();
153 |
154 | if (num){
155 | this.context.fillText( num, x - 10, yPos );
156 | }
157 |
158 | num--;
159 | }
160 | }
161 |
162 | drawCircle( x, y, radius, fill, stroke = false ){
163 | this.context.fillStyle = fill;
164 | if ( stroke ) this.context.strokeStyle = "#000";
165 | this.context.lineWidth = 1;
166 | this.context.beginPath();
167 | this.context.arc( x, y, radius, 0, Math.PI*2 );
168 | this.context.fill();
169 | if ( stroke ) this.context.stroke();
170 | }
171 |
172 | drawArrow( x, y, radius, fill, left ){
173 | this.context.fillStyle = fill;
174 | let pt;
175 | let theta = (left) ? Math.PI : 0;
176 | const inc = Math.PI / 1.5;
177 | this.context.beginPath();
178 | pt = Geometry.calcPointOnCircle( x, y, radius, theta );
179 | this.context.moveTo( pt.x, pt.y );
180 | for( let i=0; i<3; i++){
181 | theta += inc;
182 | pt = Geometry.calcPointOnCircle( x, y, radius, theta );
183 | this.context.lineTo( pt.x, pt.y );
184 | }
185 | this.context.fill();
186 | }
187 |
188 | drawNode( node, activeNode, activeCtrl ){
189 | const pt = this.convertPathToScreen( node.x, node.y );
190 | let ptA, ptB;
191 | let active = ( activeNode == node );
192 | switch( node.tool ){
193 | case 'moveTo':
194 | this.drawCircle( pt.x, pt.y, 8, "#88f", active );
195 | break;
196 | case 'lineTo':
197 | this.context.strokeStyle = (node == activeNode) ? "#000" : "#777";
198 | this.context.lineWidth = 1;
199 | this.context.beginPath();
200 | this.context.moveTo( this.prevPt.x, this.prevPt.y );
201 | this.context.lineTo( pt.x, pt.y );
202 | this.context.stroke();
203 | this.drawCircle( pt.x, pt.y, 8, "#88f", active );
204 | break;
205 | case 'quadraticCurveTo':
206 | try{
207 | this.context.fillStyle = "#88f";
208 | this.context.strokeStyle = (node == this.activeNode || node.options.ctrlA == this.activeCtrl ) ? "#000" : "#777";
209 | this.context.lineWidth = 1;
210 | ptA = this.convertPathToScreen( node.options.ctrlA.x, node.options.ctrlA.y );
211 | this.context.beginPath();
212 | this.context.moveTo( this.prevPt.x, this.prevPt.y );
213 | this.context.quadraticCurveTo( ptA.x, ptA.y, pt.x, pt.y );
214 | this.context.stroke();
215 | this.context.setLineDash([5, 5]);
216 | this.context.beginPath();
217 | this.context.moveTo( this.prevPt.x, this.prevPt.y );
218 | this.context.lineTo( ptA.x, ptA.y );
219 | this.context.lineTo( pt.x, pt.y );
220 | this.context.stroke();
221 | this.context.setLineDash([]);
222 | this.drawCircle( ptA.x, ptA.y, 8, "#8f8", activeCtrl == node.options.ctrlA );
223 | }catch( e ){
224 |
225 | }
226 | this.drawCircle( pt.x, pt.y, 8, "#88f", active );
227 | break;
228 | case 'bezierCurveTo':
229 | try{
230 | this.context.fillStyle = "#88f";
231 | this.context.strokeStyle = (node == activeNode || node.options.ctrlA == activeCtrl || node.options.ctrlB == activeCtrl ) ? "#000" : "#777";
232 | this.context.lineWidth = 1;
233 | ptA = this.convertPathToScreen( node.options.ctrlA.x, node.options.ctrlA.y );
234 | ptB = this.convertPathToScreen( node.options.ctrlB.x, node.options.ctrlB.y );
235 | this.context.beginPath();
236 | this.context.moveTo( this.prevPt.x, this.prevPt.y );
237 | this.context.bezierCurveTo( ptA.x, ptA.y, ptB.x, ptB.y, pt.x, pt.y );
238 | this.context.stroke();
239 | this.context.setLineDash([5, 5]);
240 | this.context.beginPath();
241 | this.context.moveTo( this.prevPt.x, this.prevPt.y );
242 | this.context.lineTo( ptB.x, ptB.y );
243 | this.context.lineTo( ptA.x, ptA.y );
244 | this.context.lineTo( pt.x, pt.y );
245 | this.context.stroke();
246 | this.context.setLineDash([]);
247 | this.drawCircle( ptA.x, ptA.y, 8, "#8f8", activeCtrl == node.options.ctrlA );
248 | this.drawCircle( ptB.x, ptB.y, 8, "#8f8", activeCtrl == node.options.ctrlB );
249 | }catch( e ){
250 | console.log(`Problem displaying a bezier curve ${e.message}`);
251 | }
252 | this.drawCircle( pt.x, pt.y, 8, "#88f", active );
253 | break;
254 | case 'arc':
255 | this.context.fillStyle = "#88f";
256 | this.context.strokeStyle = (node == this.activeNode || node.options.start == activeCtrl || node.options.end == activeCtrl ) ? "#000" : "#777";
257 | this.context.lineWidth = 1;
258 | const radius = this.scalePathValueToScreen( node.options.radius );
259 | this.context.beginPath();
260 | this.context.arc( pt.x, pt.y, radius, node.options.start, node.options.end, node.options.clockwise );
261 | this.context.stroke();
262 | this.drawCircle( pt.x, pt.y, 8, "#88f", active );
263 | ptA = Geometry.calcPointOnCircle( pt.x, pt.y, radius, Math.PI );
264 | active = this.isValueCtrlActive( 'radius' );
265 | this.drawCircle( ptA.x, ptA.y, 8, "#ea0", active );
266 | ptA = Geometry.calcPointOnCircle( pt.x, pt.y, radius, node.options.start );
267 | active = this.isValueCtrlActive( 'start' );
268 | this.drawCircle( ptA.x, ptA.y, 8, "#8f8", active );
269 | ptA = Geometry.calcPointOnCircle( pt.x, pt.y, radius, node.options.end);
270 | active = this.isValueCtrlActive( 'end' );
271 | this.drawCircle( ptA.x, ptA.y, 8, "#f88", active );
272 | pt.y += 20;
273 | if (node.options.clockwise==null) node.options.clockwise = false;
274 | this.drawArrow( pt.x, pt.y, 8, "#aaa", node.options.clockwise );
275 | pt.x = ptA.x;
276 | pt.y = ptA.y;
277 | break;
278 | }
279 | this.prevPt = pt;
280 | }
281 |
282 | drawGhosts( ghosts ){
283 | this.context.strokeStyle = "#333";
284 | this.context.setLineDash([5, 10]);
285 |
286 | this.context.beginPath();
287 |
288 | let pt, ptA, ptB, radius;
289 |
290 | for( const [key, nodes] of Object.entries( ghosts )){
291 | try{
292 | nodes.forEach( node => {
293 | pt = this.convertPathToScreen( node.x, node.y );
294 | switch( node.tool ){
295 | case 'moveTo':
296 | this.context.moveTo( pt.x, pt.y );
297 | break;
298 | case 'lineTo':
299 | this.context.lineTo( pt.x, pt.y );
300 | break;
301 | case 'quadraticCurveTo':
302 | ptA = this.convertPathToScreen( node.options.ctrlA.x, node.options.ctrlA.y );
303 | this.context.quadraticCurveTo( ptA.x, ptA.y, pt.x, pt.y );
304 | break;
305 | case 'bezierCurveTo':
306 | ptA = this.convertPathToScreen( node.options.ctrlA.x, node.options.ctrlA.y );
307 | ptB = this.convertPathToScreen( node.options.ctrlB.x, node.options.ctrlB.y );
308 | this.context.bezierCurveTo( ptA.x, ptA.y, ptB.x, ptB.y, pt.x, pt.y );
309 | break;
310 | case 'arc':
311 | radius = this.scalePathValueToScreen( node.options.radius );
312 | this.context.arc( pt.x, pt.y, radius, node.options.start, node.options.end, node.options.clockwise );
313 | break;
314 | }
315 | });
316 | }catch(e){
317 | console.warn( e.message );
318 | }
319 | }
320 |
321 | this.context.stroke();
322 |
323 | this.context.setLineDash([]);
324 | }
325 |
326 | isValueCtrlActive( type ){
327 | if (this.activeCtrl == null ) return false;
328 | if (this.activeCtrl.node == null ) return false;
329 | return this.activeCtrl.type == type;
330 | }
331 |
332 | snapToGrid( pt ){
333 | pt.x = Math.round(pt.x * 10) / 10;
334 | pt.y = Math.round(pt.y * 10) / 10;
335 | //return { x, y };
336 | }
337 |
338 | convertScreenToPath( x, y ){
339 | const xOrg = this.canvas.width * this.config.yAxis;
340 | const yOrg = this.canvas.height - this.canvas.height * this.config.xAxis;
341 | const scale = (this.canvas.width * 0.95 - xOrg)/ this.config.xMax;
342 | return { x: ( x - xOrg ) / scale, y: ( y - yOrg ) / scale };
343 | }
344 |
345 | convertPathToScreen( x, y ){
346 | const xOrg = this.canvas.width * this.config.yAxis;
347 | const yOrg = this.canvas.height - this.canvas.height * this.config.xAxis;
348 | const scale = (this.canvas.width * 0.95 - xOrg)/ this.config.xMax;
349 | return { x: x * scale + xOrg, y: y * scale + yOrg };
350 | }
351 |
352 | scalePathValueToScreen( value ){
353 | const xOrg = this.canvas.width * this.config.yAxis;
354 | const scale = (this.canvas.width * 0.95 - xOrg)/ this.config.xMax;
355 | return value * scale;
356 | }
357 |
358 | render( nodes, activeNode, activeCtrl, ghosts ){
359 | this.context.clearRect( 0, 0, this.canvas.width, this.canvas.height );
360 |
361 | this.drawBg();
362 | nodes.forEach( node => this.drawNode( node, activeNode, activeCtrl ) );
363 |
364 | if ( ghosts ) this.drawGhosts( ghosts );
365 | }
366 | }
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import GUI from "three/examples/jsm/libs/lil-gui.module.min.js";
2 | import * as THREE from "three";
3 | import { Store } from "./store.js";
4 | import { Menu } from "./menu.js";
5 | import { Geometry } from "./geometry.js";
6 | import { Preview } from "./preview.js";
7 | import { Graph } from "./graph.js";
8 |
9 | class App{
10 | constructor(){
11 | const canvas = document.createElement('canvas');
12 | document.body.appendChild( canvas );
13 | this.canvas = canvas;
14 |
15 | this.store = new Store();
16 |
17 | let activePath = this.store.read( 'activePath' );
18 | if (activePath==null) activePath = "Path1";
19 |
20 | this.config = { yAxis: 0.5, xAxis: 0.5, xMax: 2, units: 'm', snap: true,
21 | export: () => {
22 | this.export();
23 | },
24 | delete: () => {
25 | this.deletePath();
26 | },
27 | newPath: () => {
28 | this.newPath();
29 | },
30 | copyPath: () => {
31 | this.copyPath();
32 | },
33 | show: () => {
34 | //console.log( 'Show' );
35 | if (this.config.holes){
36 | this.preview.show( this.nodes, this.config.depth, this.ghosts.paths );
37 | }else{
38 | this.preview.show( this.nodes, this.config.depth );
39 | }
40 | this.previewOn = true;
41 | },
42 | udo: () => {
43 | if (this.store.udo()){
44 | this.loadPath( this.activePath );
45 | }
46 | },
47 | readme: () => {
48 | window.location = 'https://github.com/NikLever/ThreeJS-PathEditor';
49 | },
50 | name: activePath,
51 | tool: 'select', depth: 0.2,
52 | snap: false,
53 | holes: false
54 | };
55 |
56 | this.graph = new Graph( canvas, this.config );
57 |
58 | const pathNames = this.store.getPathNames();
59 |
60 | const gui = new GUI();
61 | const config = gui.addFolder( 'Settings' );
62 | config.add( this.config, 'xAxis', 0, 1).onChange( value => this.render() );
63 | config.add( this.config, 'yAxis', 0, 1).onChange( value => this.render() );
64 | config.add( this.config, 'xMax', 0.5, 10, 1).onChange( value => this.render() );
65 | //gui.add( this.config, 'scale', 0, 100).name('scale (%)');
66 | config.add( this.config, 'units', [ 'm', 'cm', 'mm' ] );
67 | config.add( this.config, 'depth', 0.02, 2 ).name('extrude depth');
68 | config.add( this.config, 'snap');
69 | // gui.add( this.config, 'snap' );
70 | gui.add( this.config, 'tool', ['select', 'moveTo', 'lineTo', 'quadraticCurveTo', 'bezierCurveTo', 'arc']);
71 | gui.add( this.config, 'newPath').name('new');
72 | gui.add( this.config, 'copyPath').name('copy');
73 | gui.add( this.config, 'delete');
74 | gui.add( this.config, 'udo');
75 | gui.add( this.config, 'show');
76 | gui.add( this.config, 'export');
77 | gui.add( this.config, 'readme');
78 | this.nameCtrl = gui.add( this.config, 'name' ).options( pathNames ).onChange( ( value ) => {
79 | this.loadPath( value );
80 | });
81 | const backdrop = gui.addFolder( 'Backdrop' );
82 | backdrop.add( this.config, 'holes' ).name('Use ghosts as holes');
83 | backdrop.close();
84 |
85 | const ghosts = backdrop.addFolder( 'Ghosts' );
86 |
87 | this.ghosts = { folder: ghosts, paths: {}, settings: {}, ctrls: [] };
88 |
89 | this.gui = gui;
90 | this.updatePathNamesGUI();
91 |
92 | window.addEventListener( 'resize', this.resize.bind(this) );
93 |
94 | this.nodes = [];
95 | this.activeNode = null;
96 | this.activeCtrl = null;
97 |
98 | if (activePath) this.loadPath( activePath );
99 |
100 | canvas.addEventListener('pointerdown', ( evt ) => {
101 | if ( this.preview.on ){
102 | this.preview.hide();
103 | return;
104 | }
105 | if ( evt.button == 2 ) return;
106 | this.pointerDown = true;
107 | const pt = this.graph.convertScreenToPath( evt.x, evt.y );
108 | this.activeNode = this.selectNode( pt.x, pt.y );
109 | if ( this.activeNode == null){
110 | this.activeCtrl = this.selectCtrl( pt.x, pt.y );
111 | if (this.activeCtrl == null){
112 | pt.x = evt.x;
113 | pt.y = evt.y;
114 | this.addNode( this.config.tool, pt.x, pt.y );
115 | }else if (this.activeCtrl.type == 'clockwise'){
116 | //Just flip the direction
117 | this.activeCtrl.node.options.clockwise = !this.activeCtrl.node.options.clockwise;
118 | this.activeCtrl = null;
119 | }
120 | }
121 | this.render();
122 | if (this.tipActive){
123 | this.tipActive = false;
124 | }else{
125 | this.showTip();
126 | }
127 | }, false);
128 |
129 | canvas.addEventListener( 'pointermove', ( evt ) => {
130 | if (this.pointerDown ){
131 | const pt = this.graph.convertScreenToPath( evt.x, evt.y );
132 | let theta;
133 | if (this.activeCtrl){
134 | if (this.activeCtrl.type){
135 | switch( this.activeCtrl.type ){
136 | case 'radius':
137 | let radius = this.activeCtrl.node.x - pt.x;
138 | if (radius<0) radius = 0;
139 | this.activeCtrl.node.options.radius = radius;
140 | break;
141 | case 'start':
142 | theta = Geometry.calcAngleFromXAxis( this.activeCtrl.node, pt );
143 | this.activeCtrl.node.options.start = theta;
144 | break;
145 | case 'end':
146 | theta = Geometry.calcAngleFromXAxis( this.activeCtrl.node, pt );
147 | this.activeCtrl.node.options.end = theta;
148 | break;
149 | }
150 | }else{
151 | this.activeCtrl.x = pt.x;
152 | this.activeCtrl.y = pt.y;
153 | }
154 | this.render();
155 | }if (this.activeNode){
156 | if (this.config.snap) this.graph.snapToGrid(pt);
157 | this.activeNode.x = pt.x;
158 | this.activeNode.y = pt.y;
159 | this.render();
160 | }
161 | }
162 | });
163 |
164 | canvas.addEventListener( 'pointerup', (evt) => {
165 | this.pointerDown = false;
166 | this.render(true);
167 | });
168 |
169 | this.menu = new Menu( this, [
170 | { name: 'Delete', code: 'deleteNode()'},
171 | { name: 'Insert', code: 'insertNode()'},
172 | { name: 'Change', code: 'changeNode()'}
173 | ]);
174 |
175 | this.preview = new Preview();
176 |
177 | this.resize();
178 | }
179 |
180 | showTip(){
181 | const tipsShown = this.store.tipsShown;
182 | let count = 0;
183 | tipsShown.forEach( tip => {
184 | if ( tip ) count++
185 | } );
186 | if (count == tipsShown.length) return;
187 |
188 | const tips = [
189 | "Select a tool",
190 | "When editing a curve the green dots are the control points",
191 | "When editing an arc, orange is the radius, green the start angle and red the end angle. The grey triangle lets you flip the direction.",
192 | "Use Backdrop Ghosts to display a Path(s) while editing another.",
193 | "Snap makes positioning snap to the grid",
194 | "Backdrop 'Use Ghosts as holes' will convert the dotted line paths into holes when using the 'show' or 'export' buttons."
195 | ]
196 |
197 | let index;
198 |
199 | if ( this.config.tool == 'select' && !tipsShown[0]){
200 | index = 0;
201 | }else if ( this.config.tool.includes( 'CurveTo') && !tipsShown[1]){
202 | index = 1;
203 | }else if (this.config.tool == 'arc' && !tipsShown[2]){
204 | index = 2;
205 | }else{
206 | index = 3;
207 | while(index < tips.length){
208 | if (!tipsShown[index]) break;
209 | index++;
210 | }
211 | if (index >= tips.length) return;
212 | }
213 |
214 | alert( tips[index] );
215 | this.store.tipShown = index;
216 | this.tipActive = true;
217 | }
218 |
219 | updatePathNamesGUI(){
220 | //this.ghosts = { folder: backdrop, settings: {}, ctrls: {} };
221 | const pathNames = this.store.getPathNames();
222 |
223 | //Remove settings and ctrls that are no longer in the pathNames array
224 | for( const [ key, value ] of Object.entries(this.ghosts.settings) ){
225 | const index = pathNames.indexOf( key );
226 | if (index == -1){
227 | delete this.ghosts.settings[ key ];
228 | this.ghosts.ctrls[ key ].destroy();
229 | delete this.ghosts.ctrls[ key ];
230 | }
231 | }
232 |
233 | //Add new settings and ctrls
234 | pathNames.forEach( name => {
235 | if ( this.ghosts.settings[name] == undefined ){
236 | this.ghosts.settings[name] = false;
237 | const ctrl = this.ghosts.folder.add( this.ghosts.settings, name ).onChange( value => {
238 | if ( value ){
239 | try{
240 | const path = this.store.read( name ).nodes;
241 | this.ghosts.paths[name] = path;
242 | }catch(e){
243 | console.warn( e.message );
244 | }
245 | }else{
246 | delete this.ghosts.paths[name];
247 | }
248 |
249 | this.render();
250 |
251 | } );
252 | this.ghosts.ctrls[name] = ctrl;
253 | }
254 | });
255 | }
256 |
257 | loadPath( name ){
258 | const data = this.store.read( name );
259 | if (data){
260 | this.nodes = data.nodes;
261 | for (const [key, value] of Object.entries(data.config)) {
262 | this.config[key] = value;
263 | }
264 | this.activePath = name;
265 | this.store.activePath = name;
266 | }
267 | this.render();
268 | this.gui.controllersRecursive().forEach( controller => controller.updateDisplay() );
269 | }
270 |
271 | deletePath(){
272 | if ( this.activePath == null ) return;
273 |
274 | this.store.delete( this.activePath );
275 | const names = this.store.getPathNames();
276 | this.nameCtrl = this.nameCtrl.options( names );
277 | this.nameCtrl.onChange( ( value ) => {
278 | this.loadPath( value );
279 | });
280 |
281 | if ( names.length > 0) this.loadPath( names[0] );
282 | this.updatePathNamesGUI();
283 | }
284 |
285 | export(){
286 | let unitScalar, precision;
287 | switch ( this.config.units ){
288 | case 'm':
289 | unitScalar = 1;
290 | precision = 3;
291 | break;
292 | case 'cm':
293 | unitScalar = 0.01;
294 | precision = 5;
295 | break;
296 | case 'mm':
297 | unitScalar = 0.001;
298 | precision = 6;
299 | break;
300 | }
301 | let str = "const shape = new THREE.Shape();\n";
302 |
303 | this.nodes.forEach( node => {
304 | switch( node.tool ){
305 | case "moveTo":
306 | str = `${str}shape.moveTo( ${(node.x * unitScalar).toFixed( precision )} , ${(-node.y * unitScalar).toFixed( precision ) });\n`;
307 | break;
308 | case "lineTo":
309 | str = `${str}shape.lineTo( ${(node.x * unitScalar).toFixed( precision )}, ${(-node.y * unitScalar).toFixed( precision ) });\n`;
310 | break;
311 | case "quadraticCurveTo":
312 | str = `${str}shape.quadraticCurveTo( ${(node.options.ctrlA.x * unitScalar).toFixed( precision )}, ${(-node.options.ctrlA.y * unitScalar).toFixed( precision )}, ${(node.x * unitScalar).toFixed( precision )}, ${(-node.y * unitScalar).toFixed( precision ) };\n`;
313 | break;
314 | case "bezierCurveTo":
315 | str = `${str}shape.bezierCurveTo( ${(node.options.ctrlA.x * unitScalar).toFixed( precision )}, ${(-node.options.ctrlA.y * unitScalar).toFixed( precision )}, ${(node.options.ctrlB.x * unitScalar).toFixed( precision )}, ${(-node.options.ctrlB.y * unitScalar).toFixed( precision )}, ${(node.x * unitScalar).toFixed( precision )}, ${(-node.y * unitScalar).toFixed( precision ) });\n`;
316 | break;
317 | case 'arc':
318 | str = `${str}shape.absarc( ${(node.x * unitScalar).toFixed( precision )}, ${(-node.y * unitScalar).toFixed( precision )}, ${(node.options.radius * unitScalar).toFixed( precision )}, ${-node.options.start.toFixed( 3 )}, ${-node.options.end.toFixed( 3 )}, ${(node.options.clockwise) ? 'true' : 'false'} );\n`;
319 | const pt = Geometry.calcPointOnCircle( node.x, node.y, node.options.radius, node.options.end );
320 | str = `${str}shape.moveTo( ${(pt.x* unitScalar).toFixed( precision )}, ${(-pt.y * unitScalar).toFixed( precision )} );\n`;
321 | break;
322 | }
323 | })
324 |
325 | if (this.config.holes){
326 | let index = 1;
327 |
328 | for( const [key, nodes] of Object.entries( this.ghosts.paths)){
329 | const path = `path${index++}`;
330 | str = `${str}const ${path} = new THREE.Path()\n`;
331 |
332 | nodes.forEach( node => {
333 | switch( node.tool ){
334 | case "moveTo":
335 | str = `${str}${path}.moveTo( ${(node.x * unitScalar).toFixed( precision )} , ${(-node.y * unitScalar).toFixed( precision ) });\n`;
336 | break;
337 | case "lineTo":
338 | str = `${str}${path}.lineTo( ${(node.x * unitScalar).toFixed( precision )}, ${(-node.y * unitScalar).toFixed( precision ) });\n`;
339 | break;
340 | case "quadraticCurveTo":
341 | str = `${str}${path}.quadraticCurveTo( ${(node.options.ctrlA.x * unitScalar).toFixed( precision )}, ${(-node.options.ctrlA.y * unitScalar).toFixed( precision )}, ${(node.x * unitScalar).toFixed( precision )}, ${(-node.y * unitScalar).toFixed( precision ) });\n`;
342 | break;
343 | case "bezierCurveTo":
344 | str = `${str}${path}.bezierCurveTo( ${(node.options.ctrlA.x * unitScalar).toFixed( precision )}, ${(-node.options.ctrlA.y * unitScalar).toFixed( precision )}, ${(node.options.ctrlB.x * unitScalar).toFixed( precision )}, ${(-node.options.ctrlB.y * unitScalar).toFixed( precision )}, ${(node.x * unitScalar).toFixed( precision )}, ${(-node.y * unitScalar).toFixed( precision ) });\n`;
345 | break;
346 | case 'arc':
347 | str = `${str}${path}.absarc( ${(node.x * unitScalar).toFixed( precision )}, ${(-node.y * unitScalar).toFixed( precision )}, ${(node.options.radius * unitScalar).toFixed( precision )}, ${-node.options.start.toFixed( 3 )}, ${-node.options.end.toFixed( 3 )}, ${(node.options.clockwise) ? 'true' : 'false'} );\n`;
348 | const pt = Geometry.calcPointOnCircle( node.x, node.y, node.options.radius, node.options.end );
349 | str = `${str}${path}.moveTo( ${(pt.x* unitScalar).toFixed( precision )}, ${(-pt.y * unitScalar).toFixed( precision )} );\n`;
350 | break;
351 | }
352 | });
353 |
354 | str = `${str}shape.holes.push(${path});\n`;
355 | }
356 | }
357 |
358 | navigator.clipboard.writeText(str);
359 |
360 | alert( "Code copied to the clipboard" );
361 | }
362 |
363 | deleteNode(){
364 | //Called by context menu
365 | if (this.activeNode){
366 | const index = this.nodes.indexOf( this.activeNode );
367 | if (index!=-1){
368 | this.nodes.splice( index, 1 );
369 | this.activeNode = null;
370 | }
371 | this.render(true);
372 | }
373 | }
374 |
375 | insertNode( ){
376 | //Called by context menu
377 | const pt = this.menu.position;
378 | const prevPt = {};
379 | let found = false;
380 |
381 | this.nodes.forEach( node => {
382 | if ( !found ){
383 | const pt1 = this.graph.convertPathToScreen( node.x, node.y );
384 |
385 | if ( node.tool == 'lineTo' && prevPt.x != undefined ){
386 | if ( Geometry.calcIsInsideThickLineSegment( prevPt, pt1, pt, 10 ) ){
387 | const index = this.nodes.indexOf( node );
388 | this.addNode( this.config.tool, pt.x, pt.y, index );
389 | found = true;
390 | }
391 | }
392 |
393 | prevPt.x = pt1.x;
394 | prevPt.y = pt1.y;
395 | }
396 | })
397 | }
398 |
399 | changeNode(){
400 | //Called by context menu
401 | if (this.activeNode){
402 | const index = this.nodes.indexOf( this.activeNode );
403 | if (index!=-1){
404 | const node = this.nodes[index];
405 | if (node.tool == this.config.tool) return;
406 | if (index == 0){
407 | //Only moveTo allowed
408 | if (this.config.tool != 'moveTo'){
409 | alert("You can only change the first node to 'moveTo'");
410 | return;
411 | }
412 | node.tool = this.config.tool;
413 | delete node.options;
414 | }else{
415 | const prevNode = this.nodes[index-1];
416 | node.tool = this.config.tool;
417 | delete node.options;
418 | let ctrlA, ctrlB, center;
419 | switch( node.tool ){
420 | case 'quadraticCurveTo':
421 | ctrlA = Geometry.calcLineMidPoint( prevNode, node );
422 | node.options = { ctrlA };
423 | break;
424 | case 'bezierCurveTo':
425 | ctrlA = Geometry.calcPointAlongLine( prevNode, node, 0.33 );
426 | ctrlB = Geometry.calcPointAlongLine( prevNode, node, 0.66 );
427 | node.options = { ctrlA, ctrlB };
428 | break;
429 | case 'arc':
430 | const radius = Geometry.distanceBetweenPoints( prevNode, node );
431 | node.options = { radius, start: 0, end: Math.PI/2, clockwise: false };
432 | break;
433 | }
434 | }
435 | this.render(true);
436 | }
437 | }
438 | }
439 |
440 | newPath(){
441 | const names = this.store.getPathNames();
442 | const name = this.store.nextPathName;
443 | names.push( name );
444 |
445 | this.nameCtrl = this.nameCtrl.options( names );
446 | this.nameCtrl.onChange( ( value ) => {
447 | this.loadPath( value );
448 | });
449 |
450 | this.config.yAxis = 0.5;
451 | this.config.xAxis = 0.5;
452 | this.config.xMax = 2;
453 | this.config.snap = false;
454 | this.config.name = name;
455 | this.config.tool = 'select';
456 | this.config.depth = 0.2;
457 |
458 | this.gui.controllers.forEach( controller => controller.updateDisplay() );
459 | //this.nameCtrl.updateDisplay();
460 |
461 | this.activeNode = null;
462 | this.nodes = [];
463 |
464 | this.activePath = name;
465 |
466 | this.render(true);
467 | this.updatePathNamesGUI();
468 | }
469 |
470 | copyPath(){
471 | const name = this.store.copy();
472 | this.loadPath( name );
473 | const names = this.store.getPathNames();
474 | //names.push( name );
475 | this.nameCtrl = this.nameCtrl.options( names );
476 | this.nameCtrl.onChange( ( value ) => {
477 | this.loadPath( value );
478 | });
479 | this.nameCtrl.updateDisplay();
480 | this.updatePathNamesGUI();
481 | }
482 |
483 | savePath(){
484 | const data = {
485 | config: this.config,
486 | nodes: this.nodes
487 | }
488 |
489 | localStorage.setItem( this.config.name, JSON.stringify( data ));
490 | }
491 |
492 | selectNode( x, y ){
493 | let activeNode = null;
494 | const xOrg = this.canvas.width * this.config.yAxis;
495 | const scale = (this.canvas.width * 0.95 - xOrg)/ this.config.xMax;
496 | const tolerance = (9.0/scale) * (9.0/scale);
497 | this.nodes.forEach( node => {
498 | const x1 = node.x - x;
499 | const y1 = node.y - y;
500 | const sqDist = x1*x1 + y1*y1;
501 | if (sqDist {
514 | if (node.options){
515 | if ( activeCtrl == null){
516 | ctrls.forEach( ctrlName => {
517 | if (activeCtrl == null ){
518 | let ctrl;
519 | switch(ctrlName){
520 | case 'ctrlA':
521 | case 'ctrlB':
522 | ctrl = node.options[ctrlName];
523 | break;
524 | case 'radius':
525 | ctrl = Geometry.calcPointOnCircle( node.x, node.y, node.options.radius, Math.PI );
526 | ctrl.type = 'radius';
527 | ctrl.node = node;
528 | break;
529 | case 'start':
530 | ctrl = Geometry.calcPointOnCircle( node.x, node.y, node.options.radius, node.options.start );
531 | ctrl.type = 'start';
532 | ctrl.node = node;
533 | break;
534 | case 'end':
535 | ctrl = Geometry.calcPointOnCircle( node.x, node.y, node.options.radius, node.options.end );
536 | ctrl.type = 'end';
537 | ctrl.node = node;
538 | break;
539 | case 'clockwise':
540 | ctrl = { x: node.x, y: node.y + 20/scale, node, type: ctrlName };
541 | break;
542 | }
543 | if ( ctrl){
544 | const x1 = ctrl.x - x;
545 | const y1 = ctrl.y - y;
546 | const sqDist = x1*x1 + y1*y1;
547 | if (sqDist