├── .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 |
3 | 33 | 34 |
35 | 36 | 37 | 38 |
39 | 45 |
46 | 47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 33 | 34 |
35 | 36 | 37 | 38 | 39 | 40 |
41 | 47 |
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 | [![Watch the video](https://img.youtube.com/vi/Zy4Myfd0XAI/default.jpg)](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 | 31 |
*/ 32 | 33 | const prefix = '
`; 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