├── LICENSE ├── README.md ├── ViewCubeControls.js └── assets └── view-cube-in-action.gif /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 isRyven 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ViewCubeControls - camera viewpoint controls (WIP) 2 | 3 | ![View cube in action](./assets/view-cube-in-action.gif) 4 | 5 | This is a simple [ThreeJS](http://threjs.org) module, which provides experience similar to the ViewCube used in Autodesk products. 6 | This allows you easily change the camera view angles by clicking on the faces of the view cube. [Demo](https://codesandbox.io/s/y35w749501). 7 | 8 | ## License 9 | MIT 10 | 11 | -------------------------------------------------------------------------------- /ViewCubeControls.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | const MAINCOLOR = 0xDDDDDD; 4 | const ACCENTCOLOR = 0XF2F5CE; 5 | const OUTLINECOLOR = 0xCCCCCC; 6 | 7 | export default class ViewCubeControls extends THREE.EventDispatcher { 8 | constructor(camera, cubeSize = 30, edgeSize = 5, domElement) { 9 | super(); 10 | this.cubeSize = cubeSize; 11 | this.edgeSize = edgeSize; 12 | this.domElement = domElement; 13 | this._cube = new ViewCube({ 14 | size: this.cubeSize, 15 | edge: this.edgeSize, 16 | outline: true, 17 | bgColor: MAINCOLOR, 18 | hoverColor: ACCENTCOLOR, 19 | outlineColor: OUTLINECOLOR 20 | }); 21 | this._camera = camera; 22 | this._animation = null; 23 | this._handleMouseMove = this._handleMouseMove.bind(this); 24 | this._handleMouseClick = this._handleMouseClick.bind(this); 25 | this._listen(); 26 | } 27 | 28 | _listen() { 29 | this.domElement.addEventListener('mousemove', this._handleMouseMove); 30 | this.domElement.addEventListener('click', this._handleMouseClick); 31 | } 32 | 33 | _handleMouseClick(event) { 34 | const x = (event.offsetX / event.target.clientWidth) * 2 - 1; 35 | const y = -(event.offsetY / event.target.clientHeight) * 2 + 1; 36 | this._checkSideTouch(x, y); 37 | } 38 | 39 | _checkSideTouch(x, y) { 40 | const raycaster = new THREE.Raycaster(); 41 | raycaster.setFromCamera({ x, y }, this._camera); 42 | const intersects = raycaster.intersectObjects(this._cube.children, true); 43 | if (intersects.length) { 44 | for (let { object } of intersects) { 45 | if (object.name) { 46 | this._rotateTheCube(object.name); 47 | break; 48 | } 49 | } 50 | } 51 | } 52 | 53 | _rotateTheCube(side) { 54 | switch (side) { 55 | case FACES.FRONT: 56 | this._setCubeAngles(0, 0, 0); 57 | break; 58 | case FACES.RIGHT: 59 | this._setCubeAngles(0, -90, 0); 60 | break; 61 | case FACES.BACK: 62 | this._setCubeAngles(0, -180, 0); 63 | break; 64 | case FACES.LEFT: 65 | this._setCubeAngles(0, -270, 0); 66 | break; 67 | case FACES.TOP: 68 | this._setCubeAngles(90, 0, 0); 69 | break; 70 | case FACES.BOTTOM: 71 | this._setCubeAngles(-90, 0, 0); 72 | break; 73 | 74 | case FACES.TOP_FRONT_EDGE: 75 | this._setCubeAngles(45, 0, 0); 76 | break; 77 | case FACES.TOP_RIGHT_EDGE: 78 | this._setCubeAngles(45, -90, 0); 79 | break; 80 | case FACES.TOP_BACK_EDGE: 81 | this._setCubeAngles(45, -180, 0); 82 | break; 83 | case FACES.TOP_LEFT_EDGE: 84 | this._setCubeAngles(45, -270, 0); 85 | break; 86 | 87 | case FACES.BOTTOM_FRONT_EDGE: 88 | this._setCubeAngles(-45, 0, 0); 89 | break; 90 | case FACES.BOTTOM_RIGHT_EDGE: 91 | this._setCubeAngles(-45, -90, 0); 92 | break; 93 | case FACES.BOTTOM_BACK_EDGE: 94 | this._setCubeAngles(-45, -180, 0); 95 | break; 96 | case FACES.BOTTOM_LEFT_EDGE: 97 | this._setCubeAngles(-45, -270, 0); 98 | break; 99 | 100 | case FACES.FRONT_RIGHT_EDGE: 101 | this._setCubeAngles(0, -45, 0); 102 | break; 103 | case FACES.BACK_RIGHT_EDGE: 104 | this._setCubeAngles(0, -135, 0); 105 | break; 106 | case FACES.BACK_LEFT_EDGE: 107 | this._setCubeAngles(0, -225, 0); 108 | break; 109 | case FACES.FRONT_LEFT_EDGE: 110 | this._setCubeAngles(0, -315, 0); 111 | break; 112 | 113 | case FACES.TOP_FRONT_RIGHT_CORNER: 114 | this._setCubeAngles(45, -45, 0); 115 | break; 116 | case FACES.TOP_BACK_RIGHT_CORNER: 117 | this._setCubeAngles(45, -135, 0); 118 | break; 119 | case FACES.TOP_BACK_LEFT_CORNER: 120 | this._setCubeAngles(45, -225, 0); 121 | break; 122 | case FACES.TOP_FRONT_LEFT_CORNER: 123 | this._setCubeAngles(45, -315, 0); 124 | break; 125 | 126 | case FACES.BOTTOM_FRONT_RIGHT_CORNER: 127 | this._setCubeAngles(-45, -45, 0); 128 | break; 129 | case FACES.BOTTOM_BACK_RIGHT_CORNER: 130 | this._setCubeAngles(-45, -135, 0); 131 | break; 132 | case FACES.BOTTOM_BACK_LEFT_CORNER: 133 | this._setCubeAngles(-45, -225, 0); 134 | break; 135 | case FACES.BOTTOM_FRONT_LEFT_CORNER: 136 | this._setCubeAngles(-45, -315, 0); 137 | break; 138 | 139 | default: 140 | break; 141 | } 142 | } 143 | 144 | _setCubeAngles(x, y, z) { 145 | const base = this._cube.rotation; 146 | this._animation = { 147 | base: { 148 | x: base.x, 149 | y: base.y, 150 | z: base.z 151 | }, 152 | delta: { 153 | x: calculateAngleDelta(base.x, x * toRad), 154 | y: calculateAngleDelta(base.y, y * toRad), 155 | z: calculateAngleDelta(base.z, z * toRad) 156 | }, 157 | duration: 500, 158 | time: Date.now() 159 | }; 160 | } 161 | 162 | _handleMouseMove(event) { 163 | const x = (event.offsetX / event.target.clientWidth) * 2 - 1; 164 | const y = -(event.offsetY / event.target.clientHeight) * 2 + 1; 165 | this._checkSideOver(x, y); 166 | } 167 | 168 | _checkSideOver(x, y) { 169 | const raycaster = new THREE.Raycaster(); 170 | raycaster.setFromCamera({ x, y }, this._camera); 171 | const intersects = raycaster.intersectObjects(this._cube.children, true); 172 | // unhover 173 | this._cube.traverse(function (obj) { 174 | if (obj.name) { 175 | obj.material.color.setHex(MAINCOLOR); 176 | } 177 | }); 178 | // check hover 179 | if (intersects.length) { 180 | for (let { object } of intersects) { 181 | if (object.name) { 182 | const prop = CUBE_FACES.find(prop => prop.name === object.name); 183 | object.parent.children.forEach(function (child) { 184 | if (child.name === object.name) { 185 | child.material.color.setHex(ACCENTCOLOR); 186 | } 187 | }); 188 | break; 189 | } 190 | } 191 | } 192 | } 193 | 194 | update() { 195 | this._animate(); 196 | } 197 | 198 | _animate() { 199 | if (!this._animation) return; 200 | const now = Date.now(); 201 | const { duration, time } = this._animation; 202 | const alpha = Math.min(((now - time) / duration), 1); 203 | this._animateCubeRotation(this._animation, alpha); 204 | if (alpha == 1) this._animation = null; 205 | this.dispatchEvent({ 206 | type: 'angle-change', 207 | quaternion: this._cube.quaternion.clone() 208 | }); 209 | } 210 | 211 | _animateCubeRotation({ base, delta }, alpha) { 212 | const ease = (Math.sin(((alpha * 2) - 1) * Math.PI * 0.5) + 1) * 0.5; 213 | let angleX = -TWOPI + base.x + delta.x * ease 214 | let angleY = -TWOPI + base.y + delta.y * ease; 215 | let angleZ = -TWOPI + base.z + delta.z * ease; 216 | this._cube.rotation.set(angleX % TWOPI, angleY % TWOPI, angleZ % TWOPI); 217 | } 218 | 219 | setQuaternion(quaternion) { 220 | this._cube.setRotationFromQuaternion(quaternion); 221 | // wip 222 | // const base = { x: this._cube.rotation.x, y: this._cube.rotation.y, z: this._cube.rotation.z }; 223 | // const object = new THREE.Object3D(); 224 | // object.setRotationFromQuaternion(quaternion); 225 | // const delta = { 226 | // x: calculateAngleDelta(base.x, object.rotation.x), 227 | // y: calculateAngleDelta(base.y, object.rotation.y), 228 | // z: calculateAngleDelta(base.z, object.rotation.z) 229 | // }; 230 | // let angleX = -TWOPI + base.x + delta.x; 231 | // let angleY = -TWOPI + base.y + delta.y; 232 | // let angleZ = -TWOPI + base.z + delta.z; 233 | // console.log('camera:', (angleX % TWOPI).toFixed(3), (angleY % TWOPI).toFixed(3), (angleZ % TWOPI).toFixed(3)); 234 | // this._cube.rotation.set(angleX % TWOPI, angleY % TWOPI, angleZ % TWOPI); 235 | } 236 | 237 | getObject() { 238 | return this._cube; 239 | } 240 | } 241 | 242 | class ViewCube extends THREE.Object3D { 243 | constructor({ 244 | size = 60, 245 | edge = 5, 246 | outline = true, 247 | bgColor = 0xCCCCCC, 248 | hoverColor = 0xFFFFFF, 249 | outlineColor = 0x999999 250 | }) { 251 | super(); 252 | this._cubeSize = size; 253 | this._edgeSize = edge; 254 | this._outline = outline; 255 | this._bgColor = bgColor; 256 | this._hoverColor = hoverColor; 257 | this._outlineColor = outlineColor; 258 | this._build(); 259 | } 260 | _build() { 261 | const faceSize = this._cubeSize - this._edgeSize * 2; 262 | const faceOffset = this._cubeSize / 2; 263 | const borderSize = this._edgeSize; 264 | 265 | /* faces: front, right, back, left, top, bottom */ 266 | const cubeFaces = this._createCubeFaces(faceSize, faceOffset); 267 | for (let [i, props] of BOX_FACES.entries()) { 268 | cubeFaces.children[i].name = props.name; 269 | cubeFaces.children[i].material.color.setHex(this._bgColor); 270 | cubeFaces.children[i].material.map = props.map; 271 | } 272 | this.add(cubeFaces); 273 | 274 | /* corners: top, bottom */ 275 | const corners = []; 276 | for (let [i, props] of CORNER_FACES.entries()) { 277 | const corner = this._createCornerFaces(borderSize, faceOffset, props.name, { color: this._bgColor }); 278 | corner.rotateOnAxis(new THREE.Vector3(0, 1, 0), (i % 4) * 90 * toRad); 279 | corners.push(corner); 280 | } 281 | const topCorners = new THREE.Group(); 282 | const bottomCorners = new THREE.Group(); 283 | this.add(topCorners.add(...corners.slice(0, 4))); 284 | this.add(bottomCorners.add(...corners.slice(4)).rotateOnAxis(new THREE.Vector3(1, 0, 0), 180 * toRad)); 285 | 286 | /* edges: top + bottom */ 287 | const edges = []; 288 | for (let [i, props] of EDGE_FACES.entries()) { 289 | const edge = this._createHorzEdgeFaces(faceSize, borderSize, faceOffset, props.name, { color: this._bgColor }); 290 | edge.rotateOnAxis(new THREE.Vector3(0, 1, 0), (i % 4) * 90 * toRad); 291 | edges.push(edge); 292 | } 293 | const topEdges = new THREE.Group(); 294 | const bottomEdges = new THREE.Group(); 295 | this.add(topEdges.add(...edges.slice(0, 4))); 296 | this.add(bottomEdges.add(...edges.slice(4)).rotateOnAxis(new THREE.Vector3(1, 0, 0), 180 * toRad)); 297 | 298 | /* edges on the side */ 299 | const sideEdges = new THREE.Group(); 300 | for (let [i, props] of EDGE_FACES_SIDE.entries()) { 301 | const edge = this._createVertEdgeFaces(borderSize, faceSize, faceOffset, props.name, { color: this._bgColor }); 302 | edge.rotateOnAxis(new THREE.Vector3(0, 1, 0), i * 90 * toRad); 303 | sideEdges.add(edge); 304 | } 305 | this.add(sideEdges); 306 | 307 | if (this._outline) { 308 | this.add(this._createCubeOutline(this._cubeSize)); 309 | } 310 | } 311 | _createFace(size, position, { axis = [0, 1, 0], angle = 0, name = "", matProps = {} } = {}) { 312 | if (!Array.isArray(size)) size = [size, size]; 313 | const material = new THREE.MeshBasicMaterial(matProps); 314 | const geometry = new THREE.PlaneGeometry(size[0], size[1]); 315 | const face = new THREE.Mesh(geometry, material); 316 | face.name = name; 317 | face.rotateOnAxis(new THREE.Vector3(...axis), angle * toRad); 318 | face.position.set(...position); 319 | return face; 320 | } 321 | _createCubeFaces(faceSize, offset) { 322 | const faces = new THREE.Object3D(); 323 | faces.add(this._createFace(faceSize, [0, 0, offset], { axis: [0, 1, 0], angle: 0 })); 324 | faces.add(this._createFace(faceSize, [offset, 0, 0], { axis: [0, 1, 0], angle: 90 })); 325 | faces.add(this._createFace(faceSize, [0, 0, -offset], { axis: [0, 1, 0], angle: 180 })); 326 | faces.add(this._createFace(faceSize, [-offset, 0, 0], { axis: [0, 1, 0], angle: 270 })); 327 | faces.add(this._createFace(faceSize, [0, offset, 0], { axis: [1, 0, 0], angle: -90 })); 328 | faces.add(this._createFace(faceSize, [0, -offset, 0], { axis: [1, 0, 0], angle: 90 })); 329 | return faces; 330 | } 331 | _createCornerFaces(faceSize, offset, name = "", matProps = {}) { 332 | const corner = new THREE.Object3D(); 333 | const borderOffset = offset - faceSize / 2; 334 | corner.add(this._createFace(faceSize, [borderOffset, borderOffset, offset], { axis: [0, 1, 0], angle: 0, matProps, name })); 335 | corner.add(this._createFace(faceSize, [offset, borderOffset, borderOffset], { axis: [0, 1, 0], angle: 90, matProps, name })); 336 | corner.add(this._createFace(faceSize, [borderOffset, offset, borderOffset], { axis: [1, 0, 0], angle: -90, matProps, name })); 337 | return corner; 338 | } 339 | _createHorzEdgeFaces(w, h, offset, name = "", matProps = {}) { 340 | const edge = new THREE.Object3D(); 341 | const borderOffset = offset - h / 2; 342 | edge.add(this._createFace([w, h], [0, borderOffset, offset], { axis: [0, 1, 0], angle: 0, name, matProps })); 343 | edge.add(this._createFace([w, h], [0, offset, borderOffset], { axis: [1, 0, 0], angle: -90, name, matProps })); 344 | return edge; 345 | } 346 | _createVertEdgeFaces(w, h, offset, name = "", matProps = {}) { 347 | const edge = new THREE.Object3D(); 348 | const borderOffset = offset - w / 2; 349 | edge.add(this._createFace([w, h], [borderOffset, 0, offset], { axis: [0, 1, 0], angle: 0, name, matProps })); 350 | edge.add(this._createFace([w, h], [offset, 0, borderOffset], { axis: [0, 1, 0], angle: 90, name, matProps })); 351 | return edge; 352 | } 353 | _createCubeOutline(size) { 354 | const geometry = new THREE.BoxGeometry(size, size, size); 355 | const geo = new THREE.EdgesGeometry(geometry); 356 | const mat = new THREE.LineBasicMaterial({ color: this._outlineColor, linewidth: 1 }); 357 | const wireframe = new THREE.LineSegments(geo, mat); 358 | return wireframe 359 | } 360 | } 361 | 362 | var toRad = Math.PI / 180; 363 | var TWOPI = 2 * Math.PI; 364 | 365 | function calculateAngleDelta(from, to) { 366 | const direct = to - from; 367 | const altA = direct - TWOPI; 368 | const altB = direct + TWOPI; 369 | if (Math.abs(direct) > Math.abs(altA)) { 370 | return altA; 371 | } 372 | else if (Math.abs(direct) > Math.abs(altB)) { 373 | return altB; 374 | } 375 | return direct; 376 | } 377 | 378 | function createTextSprite(text, props) { 379 | const fontface = props.font || 'Helvetica'; 380 | const fontsize = props.fontSize || 30; 381 | const width = props.width || 200; 382 | const height = props.height || 200; 383 | const bgColor = props.color ? props.bgColor.join(', ') : "255, 255, 255, 1.0"; 384 | const fgColor = props.color ? props.color.join(', ') : "0, 0, 0, 1.0"; 385 | const canvas = document.createElement('canvas'); 386 | canvas.width = width; 387 | canvas.height = height; 388 | const context = canvas.getContext('2d'); 389 | context.font = `bold ${fontsize}px ${fontface}`; 390 | context.fillStyle = `rgba(${bgColor})`; 391 | context.fillRect(0, 0, width, height); 392 | // get size data (height depends only on font size) 393 | const metrics = context.measureText(text); 394 | const textWidth = metrics.width; 395 | // text color 396 | context.fillStyle = `rgba(${fgColor})`; 397 | context.fillText(text, width / 2 - textWidth / 2, height / 2 + fontsize / 2 - 2); 398 | // canvas contents will be used for a texture 399 | const texture = new THREE.Texture(canvas) 400 | texture.minFilter = THREE.LinearFilter; 401 | texture.needsUpdate = true; 402 | return texture; 403 | } 404 | 405 | var FACES = { 406 | TOP: 1, 407 | FRONT: 2, 408 | RIGHT: 3, 409 | BACK: 4, 410 | LEFT: 5, 411 | BOTTOM: 6, 412 | 413 | TOP_FRONT_EDGE: 7, 414 | TOP_RIGHT_EDGE: 8, 415 | TOP_BACK_EDGE: 9, 416 | TOP_LEFT_EDGE: 10, 417 | 418 | FRONT_RIGHT_EDGE: 11, 419 | BACK_RIGHT_EDGE: 12, 420 | BACK_LEFT_EDGE: 13, 421 | FRONT_LEFT_EDGE: 14, 422 | 423 | BOTTOM_FRONT_EDGE: 15, 424 | BOTTOM_RIGHT_EDGE: 16, 425 | BOTTOM_BACK_EDGE: 17, 426 | BOTTOM_LEFT_EDGE: 18, 427 | 428 | TOP_FRONT_RIGHT_CORNER: 19, 429 | TOP_BACK_RIGHT_CORNER: 20, 430 | TOP_BACK_LEFT_CORNER: 21, 431 | TOP_FRONT_LEFT_CORNER: 22, 432 | 433 | BOTTOM_FRONT_RIGHT_CORNER: 23, 434 | BOTTOM_BACK_RIGHT_CORNER: 24, 435 | BOTTOM_BACK_LEFT_CORNER: 25, 436 | BOTTOM_FRONT_LEFT_CORNER: 26 437 | }; 438 | 439 | var BOX_FACES = [ 440 | { 441 | name: FACES.FRONT, 442 | map: createTextSprite("FRONT", { fontSize: 60, font: "Arial Narrow, sans-serif" }) 443 | }, 444 | { 445 | name: FACES.RIGHT, 446 | map: createTextSprite("RIGHT", { fontSize: 60, font: "Arial Narrow, sans-serif" }) 447 | }, 448 | { 449 | name: FACES.BACK, 450 | map: createTextSprite("BACK", { fontSize: 60, font: "Arial Narrow, sans-serif" }) 451 | }, 452 | { 453 | name: FACES.LEFT, 454 | map: createTextSprite("LEFT", { fontSize: 60, font: "Arial Narrow, sans-serif" }) 455 | }, 456 | { 457 | name: FACES.TOP, 458 | map: createTextSprite("TOP", { fontSize: 60, font: "Arial Narrow, sans-serif" }) 459 | }, 460 | { 461 | name: FACES.BOTTOM, 462 | map: createTextSprite("BOTTOM", { fontSize: 60, font: "Arial Narrow, sans-serif" }) 463 | } 464 | ]; 465 | var CORNER_FACES = [ 466 | { name: FACES.TOP_FRONT_RIGHT_CORNER }, 467 | { name: FACES.TOP_BACK_RIGHT_CORNER }, 468 | { name: FACES.TOP_BACK_LEFT_CORNER }, 469 | { name: FACES.TOP_FRONT_LEFT_CORNER }, 470 | { name: FACES.BOTTOM_BACK_RIGHT_CORNER }, 471 | { name: FACES.BOTTOM_FRONT_RIGHT_CORNER }, 472 | { name: FACES.BOTTOM_FRONT_LEFT_CORNER }, 473 | { name: FACES.BOTTOM_BACK_LEFT_CORNER } 474 | ]; 475 | var EDGE_FACES = [ 476 | { name: FACES.TOP_FRONT_EDGE }, 477 | { name: FACES.TOP_RIGHT_EDGE }, 478 | { name: FACES.TOP_BACK_EDGE }, 479 | { name: FACES.TOP_LEFT_EDGE }, 480 | // flip back and front bottom edges 481 | { name: FACES.BOTTOM_BACK_EDGE }, 482 | { name: FACES.BOTTOM_RIGHT_EDGE }, 483 | { name: FACES.BOTTOM_FRONT_EDGE }, 484 | { name: FACES.BOTTOM_LEFT_EDGE }, 485 | ]; 486 | var EDGE_FACES_SIDE = [ 487 | { name: FACES.FRONT_RIGHT_EDGE }, 488 | { name: FACES.BACK_RIGHT_EDGE }, 489 | { name: FACES.BACK_LEFT_EDGE }, 490 | { name: FACES.FRONT_LEFT_EDGE } 491 | ]; 492 | // merge them all to ease the traversing 493 | var CUBE_FACES = [...BOX_FACES, ...CORNER_FACES, ...EDGE_FACES, ...EDGE_FACES_SIDE]; -------------------------------------------------------------------------------- /assets/view-cube-in-action.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isRyven/ViewCubeControls/4fe2362c9f6173a761fae6b75262e7af1604001e/assets/view-cube-in-action.gif --------------------------------------------------------------------------------