├── README.md ├── .gitignore ├── public └── img │ ├── forkme_right_gray_6d6d6d.png │ └── forkme_right_gray_6d6d6d.webp ├── .babelrc ├── src ├── types │ └── index.ts ├── assets │ ├── index.html │ └── css │ │ └── index.scss ├── geometries │ └── index.ts ├── libs │ ├── progress-bar.ts │ └── router.ts ├── rubik-cube-model.ts ├── layer-model.ts ├── utils │ └── index.ts ├── rubik-cube.ts └── index.ts ├── tsconfig.json ├── .eslintrc.js ├── webpack.dev.js ├── docs └── index.html ├── webpack.common.js ├── LICENSE ├── webpack.build.js └── package.json /README.md: -------------------------------------------------------------------------------- 1 | # rubik-cube 2 | 3 | A Rubik's Cube by Three.js. 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | resource/ 3 | node_modules/ 4 | yarn.lock 5 | yarn-error.log 6 | 7 | src/solve.ts -------------------------------------------------------------------------------- /public/img/forkme_right_gray_6d6d6d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aaron-Bird/rubiks-cube/HEAD/public/img/forkme_right_gray_6d6d6d.png -------------------------------------------------------------------------------- /public/img/forkme_right_gray_6d6d6d.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aaron-Bird/rubiks-cube/HEAD/public/img/forkme_right_gray_6d6d6d.webp -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript" 5 | ], 6 | "plugins": [ 7 | "@babel/proposal-class-properties", 8 | "@babel/proposal-object-rest-spread", 9 | "@babel/plugin-transform-runtime" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type Axis = 'x' | 'y' | 'z'; 2 | export type AxisValue = number; 3 | export type Toward = 1 | -1; 4 | 5 | export type Notation = string; 6 | export type NotationBase = 'L' | 'M' | 'R' | 'D' | 'E' | 'U' | 'B' | 'S' | 'F'; 7 | export type NotationExtra = '' | `'` | '2'; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "outDir": "./dist/", 5 | "target": "ES2020", 6 | "moduleResolution": "node", // threejs need 7 | "module": "CommonJS", 8 | "esModuleInterop": true, 9 | "noImplicitAny": true, 10 | "sourceMap": true, 11 | "isolatedModules": true, 12 | "strictBindCallApply": true, 13 | }, 14 | "exclude": [ 15 | "node_modules" 16 | ], 17 | "include": [ 18 | "src/**/*.ts", 19 | "types/**/*.d.ts" 20 | ] 21 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable linebreak-style */ 2 | module.exports = { 3 | env: { 4 | es6: true, 5 | node: true, 6 | }, 7 | extends: ['google'], 8 | globals: { 9 | Atomics: 'readonly', 10 | SharedArrayBuffer: 'readonly', 11 | }, 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | ecmaVersion: 2018, 15 | sourceType: 'module', 16 | }, 17 | plugins: ['@typescript-eslint'], 18 | rules: { 19 | 'max-len': ['error', {'code': 120}], 20 | 'require-jsdoc': 'off', 21 | 'no-unused-vars': 'off', 22 | '@typescript-eslint/no-unused-vars': 'error', 23 | 'linebreak-style': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = merge(common, { 5 | 'mode': 'development', 6 | 'devtool': 'eval-source-map', 7 | 'devServer': { 8 | port: '8000', 9 | host: '0.0.0.0', 10 | public: 'localhost:8000', 11 | open: false, 12 | quiet: true, 13 | contentBase: './public', 14 | watchContentBase: true, 15 | hot: true, 16 | }, 17 | 'module': { 18 | rules: [ 19 | { 20 | test: /\.s[ac]ss$/i, 21 | use: [ 22 | 'style-loader', 23 | 'css-loader', 24 | 'sass-loader', 25 | ], 26 | }, 27 | ], 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Document 9 | 10 | 11 | 12 | 15 |

16 | The page has been moved.
17 | Please click below if the browser does not redirect.
18 | 19 | https://aaron-bird.github.io/demo/rubiks-cube/index.html 20 | 21 |

22 | 23 | 24 | -------------------------------------------------------------------------------- /src/assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Rubik's Cube 8 | 9 | 10 | 11 |
12 |
Random
13 |
Reset
14 |
15 | 16 | 17 | Fork me on GitHub 19 | 20 | 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const SpeedMeasurePlugin = require('speed-measure-webpack-plugin'); 5 | const smp = new SpeedMeasurePlugin(); 6 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 7 | 8 | module.exports = smp.wrap({ 9 | 'entry': ['./src/index.ts'], 10 | 'output': { 11 | filename: 'main.js', 12 | path: path.resolve(__dirname, 'dist'), 13 | }, 14 | 'plugins': [ 15 | new ForkTsCheckerWebpackPlugin(), 16 | new webpack.ProgressPlugin(), 17 | new HtmlWebpackPlugin({ 18 | template: './src/assets/index.html', 19 | inject: true, 20 | }), 21 | ], 22 | 'module': { 23 | rules: [ 24 | { 25 | test: /\.(js|jsx|tsx|ts)$/, 26 | exclude: /node_modules/, 27 | loader: 'babel-loader', 28 | }, 29 | { 30 | test: /\.(png|svg|jpg|gif|webp)$/, 31 | use: ['file-loader'], 32 | }, 33 | ], 34 | }, 35 | 'resolve': { 36 | extensions: ['.tsx', '.ts', '.js'], 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Aaron Bird 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. 22 | -------------------------------------------------------------------------------- /webpack.build.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | const FileManagerPlugin = require('filemanager-webpack-plugin'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | 6 | module.exports = merge(common, { 7 | 'mode': 'production', 8 | 'output': { 9 | filename: '[name].[chunkhash].js', 10 | }, 11 | 'plugins': [ 12 | new MiniCssExtractPlugin({ 13 | filename: '[name].[chunkhash].css', 14 | chunkFilename: '[id].[chunkhash].css', 15 | }), 16 | new FileManagerPlugin({ 17 | onStart: { 18 | delete: ['./dist/**'], 19 | }, 20 | onEnd: { 21 | copy: [ 22 | { 23 | source: './public', 24 | destination: 'dist', 25 | }, 26 | ], 27 | }, 28 | }), 29 | ], 30 | 'module': { 31 | rules: [ 32 | { 33 | test: /\.(sa|sc|c)ss$/, 34 | use: [ 35 | { 36 | loader: MiniCssExtractPlugin.loader, 37 | options: { 38 | hmr: process.env.NODE_ENV === 'development', 39 | }, 40 | }, 41 | 'css-loader', 42 | 'sass-loader', 43 | ], 44 | }, 45 | ], 46 | }, 47 | }); 48 | -------------------------------------------------------------------------------- /src/assets/css/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | overflow: hidden; 3 | margin: 0; 4 | font-size: 16px; 5 | color: #333; 6 | } 7 | 8 | canvas { 9 | outline: 0; 10 | user-select: none; 11 | } 12 | 13 | .cursor-pointer { 14 | cursor: pointer; 15 | } 16 | 17 | #ribbon { 18 | position: fixed; 19 | right: 35px; 20 | bottom: 30px; 21 | font-family: 'Open Sans', sans-serif; 22 | user-select: none; 23 | &.disable { 24 | color: #aaa; 25 | } 26 | } 27 | 28 | @media screen and (max-width: 768px) { 29 | #ribbon { 30 | left: 50%; 31 | right: initial; 32 | transform: translateX(-50%); 33 | } 34 | } 35 | 36 | .btn { 37 | display: inline-block; 38 | cursor: pointer; 39 | transition: 0.3s; 40 | padding: 15px 15px; 41 | } 42 | 43 | #ribbon.disable .btn:hover{ 44 | text-shadow: none; 45 | } 46 | 47 | .btn:hover { 48 | text-shadow: 0 0 4px rgba(0,0,0,0.3); 49 | } 50 | 51 | .btn:checked { 52 | background: rgba(0,0,0,0.1); 53 | } 54 | 55 | #ribbon.disable .btn:checked{ 56 | text-shadow: none; 57 | } 58 | 59 | #fork-me { 60 | position: fixed; 61 | top: 0; 62 | right: 0; 63 | } 64 | 65 | #progressbar { 66 | position: fixed; 67 | top: 0; 68 | left: 0; 69 | right: 0; 70 | height: 2px; 71 | } 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rubiks-cube", 3 | "version": "1.0.0", 4 | "description": "Rubik's Cube", 5 | "author": "Aaron Bird", 6 | "main": "index.js", 7 | "scripts": { 8 | "dev": "webpack-dev-server --config webpack.dev.js", 9 | "build": "webpack --config webpack.build.js" 10 | }, 11 | "devDependencies": { 12 | "@babel/core": "^7.10.2", 13 | "@babel/plugin-proposal-class-properties": "^7.10.1", 14 | "@babel/plugin-proposal-object-rest-spread": "^7.10.1", 15 | "@babel/plugin-transform-runtime": "^7.10.3", 16 | "@babel/preset-env": "^7.10.2", 17 | "@babel/preset-typescript": "^7.10.1", 18 | "@typescript-eslint/eslint-plugin": "^3.2.0", 19 | "@typescript-eslint/parser": "^3.2.0", 20 | "babel-loader": "^8.1.0", 21 | "css-loader": "^3.6.0", 22 | "eslint": "^7.2.0", 23 | "eslint-config-google": "^0.14.0", 24 | "file-loader": "^6.0.0", 25 | "filemanager-webpack-plugin": "^2.0.5", 26 | "fork-ts-checker-webpack-plugin": "4.1.6", 27 | "html-webpack-plugin": "^4.3.0", 28 | "mini-css-extract-plugin": "^0.9.0", 29 | "sass": "^1.26.9", 30 | "sass-loader": "^8.0.2", 31 | "speed-measure-webpack-plugin": "^1.3.3", 32 | "style-loader": "^1.2.1", 33 | "typescript": "^3.9.5", 34 | "webpack": "^4.43.0", 35 | "webpack-cli": "^3.3.11", 36 | "webpack-dev-server": "^3.11.0", 37 | "webpack-merge": "^4.2.2" 38 | }, 39 | "dependencies": { 40 | "@tweenjs/tween.js": "^18.6.0", 41 | "three": "^0.118.3" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/geometries/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as THREE from 'three'; 3 | 4 | export function roundedEdgeBox(width = 1, height = 1, depth = 1, radius0 = 0.1, smoothness = 4) { 5 | // Reference: https://discourse.threejs.org/t/round-edged-box/1402 6 | const shape = new THREE.Shape(); 7 | const eps = 0.00001; 8 | const radius = radius0 - eps; 9 | shape.absarc(eps, eps, eps, -Math.PI / 2, -Math.PI, true); 10 | shape.absarc(eps, height - radius * 2, eps, Math.PI, Math.PI / 2, true); 11 | shape.absarc(width - radius * 2, height - radius * 2, eps, Math.PI / 2, 0, true); 12 | shape.absarc(width - radius * 2, eps, eps, 0, -Math.PI / 2, true); 13 | const geometry = new THREE.ExtrudeBufferGeometry(shape, { 14 | depth: depth - radius0 * 2, 15 | bevelEnabled: true, 16 | bevelSegments: smoothness * 2, 17 | steps: 1, 18 | bevelSize: radius, 19 | bevelThickness: radius0, 20 | curveSegments: smoothness, 21 | }); 22 | geometry.center(); 23 | return geometry; 24 | } 25 | export function roundedPlane(x = 0, y = 0, width = 0.9, height = 0.9, radius = 0.1) { 26 | // Reference: https://threejs.org/examples/webgl_geometry_shapes.html 27 | const shape = new THREE.Shape(); 28 | const center = new THREE.Vector2(-(x + width / 2), -(y + height / 2)); 29 | shape.moveTo(center.x, center.y + radius); 30 | shape.lineTo(center.x, center.y + height - radius); 31 | shape.quadraticCurveTo(center.x, center.y + height, center.x + radius, center.y + height); 32 | shape.lineTo(center.x + width - radius, center.y + height); 33 | shape.quadraticCurveTo(center.x + width, center.y + height, center.x + width, center.y + height - radius); 34 | shape.lineTo(center.x + width, center.y + radius); 35 | shape.quadraticCurveTo(center.x + width, center.y, center.x + width - radius, center.y); 36 | shape.lineTo(center.x + radius, center.y); 37 | shape.quadraticCurveTo(center.x, center.y, center.x, center.y + radius); 38 | const geometry = new THREE.ShapeBufferGeometry(shape); 39 | return geometry; 40 | } 41 | 42 | -------------------------------------------------------------------------------- /src/libs/progress-bar.ts: -------------------------------------------------------------------------------- 1 | function focusRedraw(el: HTMLElement) { 2 | window.getComputedStyle(el, null).getPropertyValue('display'); 3 | } 4 | 5 | export class ProgressBar { 6 | percentage = 0; 7 | el: HTMLElement; 8 | percentageEl: HTMLElement; 9 | duration = 0.5; 10 | constructor(el: HTMLElement) { 11 | this.el = el; 12 | this.hide(); 13 | 14 | const percentageEl = document.createElement('div'); 15 | percentageEl.classList.add('progressbar-percentage'); 16 | el.appendChild(percentageEl); 17 | this.percentageEl = percentageEl; 18 | 19 | const position = window.getComputedStyle(el, null).getPropertyValue('position'); 20 | if (position === 'static') { 21 | el.style.position = 'relative'; 22 | } 23 | 24 | if (!document.querySelector('.progressbar-style')) { 25 | const styleEl = document.createElement('style'); 26 | styleEl.classList.add('progressbar-style'); 27 | styleEl.innerHTML = ` 28 | .progressbar-percentage { 29 | transition: ${this.duration}s linear; 30 | height: 100%; 31 | background: rgb(20, 150, 200); 32 | } 33 | `; 34 | document.head.appendChild(styleEl); 35 | } 36 | } 37 | 38 | setPercentage(value: number) { 39 | this.percentage = value; 40 | const width = this.el.getBoundingClientRect().width; 41 | 42 | this.percentageEl.style.width = `${width * value}px`; 43 | } 44 | 45 | getPercentage() { 46 | return this.percentage; 47 | } 48 | 49 | start() { 50 | this.percentageEl.style.transitionDuration = `0s`; 51 | this.setPercentage(0); 52 | focusRedraw(this.percentageEl); 53 | this.percentageEl.style.transitionDuration = `${this.duration}s`; 54 | 55 | this.show(); 56 | } 57 | 58 | done() { 59 | this.hide(); 60 | setTimeout(() => { 61 | this.hide(); 62 | }, this.duration * 1000); 63 | } 64 | 65 | show() { 66 | this.el.style.visibility = 'visible'; 67 | } 68 | 69 | hide() { 70 | this.el.style.visibility = 'hidden'; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/rubik-cube-model.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import {RubikCube, Cubelet} from './rubik-cube'; 3 | import {roundedEdgeBox, roundedPlane} from './geometries'; 4 | 5 | const faceInfo: { 6 | [index: string]: { 7 | position: [number, number, number], 8 | rotation: [number, number, number] 9 | } 10 | } = { 11 | U: {position: [0, 0.51, 0], rotation: [-Math.PI /2, 0, 0]}, 12 | D: {position: [0, -0.51, 0], rotation: [Math.PI /2, 0, 0]}, 13 | F: {position: [0, 0, 0.51], rotation: [0, 0, 0]}, 14 | B: {position: [0, 0, -0.51], rotation: [Math.PI, 0, 0]}, 15 | L: {position: [-0.51, 0, 0], rotation: [0, -Math.PI /2, 0]}, 16 | R: {position: [0.51, 0, 0], rotation: [0, Math.PI /2, 0]}, 17 | }; 18 | 19 | export interface CubeletModel extends THREE.Mesh { 20 | cubeType?: string; 21 | num?: number; 22 | initPosition?: THREE.Vector3; 23 | } 24 | 25 | export class RubikCubeModel extends RubikCube { 26 | model = new THREE.Group(); 27 | constructor(fb?: string) { 28 | super(fb); 29 | for (const cubeInfo of this.cubelets) { 30 | const cubeletModel = this.generateCubeletModel(cubeInfo); 31 | cubeletModel.name = 'cubelet'; 32 | cubeletModel.cubeType = cubeInfo.type; 33 | cubeletModel.num = cubeInfo.num; 34 | cubeletModel.position.set(cubeInfo.x, cubeInfo.y, cubeInfo.z); 35 | cubeletModel.initPosition = new THREE.Vector3().set(cubeInfo.x, cubeInfo.y, cubeInfo.z); 36 | this.model.add(cubeletModel); 37 | } 38 | } 39 | 40 | generateCubeletModel(info: Cubelet) { 41 | const geometry = roundedEdgeBox(1, 1, 1, 0.05, 4); 42 | const materials =new THREE.MeshLambertMaterial({emissive: '#333', transparent: true}); 43 | const cubeletModel = new THREE.Mesh(geometry, materials) as CubeletModel; 44 | const color = info.color; 45 | for (const key of Object.keys(color)) { 46 | const planeGeometry = roundedPlane(0, 0, 0.9, 0.9, 0.1); 47 | const planeMaterial = new THREE.MeshLambertMaterial({emissive: color[key], transparent: true}); 48 | const plane = new THREE.Mesh(planeGeometry, planeMaterial); 49 | plane.rotation.fromArray(faceInfo[key].rotation); 50 | plane.position.fromArray(faceInfo[key].position); 51 | plane.name = 'face'; 52 | cubeletModel.attach(plane); 53 | } 54 | return cubeletModel; 55 | } 56 | 57 | dispose() { 58 | for (const cubeletModel of (this.model.children as CubeletModel[])) { 59 | if (cubeletModel.material instanceof THREE.Material) { 60 | cubeletModel.material.dispose(); 61 | } 62 | cubeletModel.geometry.dispose(); 63 | for (const plan of (cubeletModel.children as THREE.Mesh[])) { 64 | if (plan.material instanceof THREE.Material) { 65 | plan.material.dispose(); 66 | } 67 | (plan as THREE.Mesh).geometry.dispose(); 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/libs/router.ts: -------------------------------------------------------------------------------- 1 | function serializeParams(obj: Object) { 2 | let str = ''; 3 | for (const [key, value] of Object.entries(obj)) { 4 | str += '&'; 5 | str += `${window.encodeURIComponent(key)}=${window.encodeURIComponent(value as string)}`; 6 | } 7 | if (str) { 8 | str = str.slice(1); 9 | } 10 | return str; 11 | } 12 | 13 | export class Router { 14 | #path = '/'; 15 | #search: {[index: string]: string} 16 | #hash = ''; 17 | constructor() { 18 | const searchTarget: {[index: string]: string} = {}; 19 | this.#search = new Proxy(searchTarget, { 20 | set: (target, propKey, value) => { 21 | if (typeof value !== 'string') { 22 | throw new Error(`Search param value must be a string`); 23 | } 24 | 25 | target[propKey as string] = value; 26 | this.update(); 27 | return true; 28 | }, 29 | }); 30 | 31 | const rawHash = window.location.hash.slice(1); 32 | if (!rawHash) { 33 | return; 34 | } 35 | 36 | const matchPath = rawHash.match(/[^?#]*/); 37 | const matchSearch = rawHash.match(/(?<=\?)[^#]*/); 38 | const matchHash= rawHash.match(/(?<=#).*/); 39 | 40 | if (matchPath) { 41 | this.#path = window.decodeURIComponent(matchPath[0]); 42 | } 43 | 44 | if (matchSearch) { 45 | const searchParams = matchSearch[0].split('&'); 46 | for (const param of searchParams) { 47 | const [key, value] = param.split('='); 48 | this.#search[window.decodeURIComponent(key)] = window.decodeURIComponent(value); 49 | } 50 | } 51 | 52 | if (matchHash) { 53 | this.#hash = window.decodeURIComponent(matchHash[0]); 54 | } 55 | } 56 | 57 | get path() { 58 | return this.#path; 59 | } 60 | 61 | set path(value) { 62 | this.#path = value; 63 | this.update(); 64 | } 65 | 66 | get search() { 67 | return this.#search; 68 | } 69 | 70 | set search(_) { 71 | throw new Error('Not allowed to set property: search'); 72 | } 73 | 74 | get hash() { 75 | return this.#hash; 76 | } 77 | 78 | set hash(value) { 79 | this.#hash = value; 80 | this.update(); 81 | } 82 | 83 | update() { 84 | const searchStr = serializeParams(this.search); 85 | const hashStr = window.encodeURIComponent(this.#hash); 86 | let url = '#'; 87 | if (this.#path) { 88 | url += this.path; 89 | } 90 | if (searchStr) { 91 | url += '?' + searchStr; 92 | } 93 | if (hashStr) { 94 | url += '#' + hashStr; 95 | } 96 | if (url === '#') { 97 | url = ''; 98 | } 99 | // history.replaceState may add multiple history when the initial url doesn't have '#' . 100 | // window.history.replaceState(null, '', url); 101 | window.location.replace(url); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/layer-model.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import {setOpacity} from './utils'; 3 | import TWEEN from '@tweenjs/tween.js'; 4 | import {Axis, AxisValue} from './types'; 5 | 6 | export class LayerModel extends THREE.Group { 7 | debug: boolean = false; 8 | constructor(debug?: boolean) { 9 | super(); 10 | if (debug) { 11 | this.debug = true; 12 | } 13 | } 14 | group(axis: Axis, value: AxisValue, cubelets: THREE.Object3D[]) { 15 | // Each Object3d can only have one parent. 16 | // Object3d will be removed from cubeletModels when it is added to layerGroup. 17 | // for (let i = 0; i < cubeletModels.length; i++) { 18 | for (let i = cubelets.length - 1; i >= 0; i--) { 19 | if (cubelets[i].position[axis] === value) { 20 | if (this.debug) { 21 | setOpacity(cubelets[i] as THREE.Mesh, 0.5); 22 | } 23 | this.add(cubelets[i]); 24 | } 25 | } 26 | } 27 | 28 | ungroup(target: THREE.Object3D) { 29 | if (!this.children.length) { 30 | return; 31 | } 32 | // Updates the global transform If you need to get rotation immediately when rotation Object3d 33 | this.updateWorldMatrix(false, false); 34 | 35 | for (let i = this.children.length - 1; i >= 0; i--) { 36 | const obj = this.children[i]; 37 | 38 | const position = new THREE.Vector3(); 39 | obj.getWorldPosition(position); 40 | 41 | const quaternion = new THREE.Quaternion(); 42 | obj.getWorldQuaternion(quaternion); 43 | 44 | this.remove(obj); 45 | 46 | position.x = parseFloat((position.x).toFixed(15)); 47 | position.y = parseFloat((position.y).toFixed(15)); 48 | position.z = parseFloat((position.z).toFixed(15)); 49 | 50 | if (this.debug) { 51 | setOpacity(obj as THREE.Mesh, 1); 52 | } 53 | obj.position.copy(position); 54 | obj.quaternion.copy(quaternion); 55 | 56 | target.add(obj); 57 | } 58 | } 59 | 60 | initRotation() { 61 | this.rotation.x = 0; 62 | this.rotation.y = 0; 63 | this.rotation.z = 0; 64 | } 65 | 66 | async rotationAnimation(axis: Axis, endRad: number) { 67 | if (!['x', 'y', 'z'].includes(axis)) { 68 | throw new Error(`Wrong axis: ${axis}`); 69 | } 70 | 71 | // The rotation degree may be greater than 360 72 | // Like: 361 -> 0 73 | const startRad = this.rotation[axis] % (Math.PI * 2); 74 | if (startRad === endRad) { 75 | return; 76 | } 77 | 78 | const current = {rad: startRad}; 79 | const end = {rad: endRad}; 80 | const time = Math.abs(endRad - startRad) * (500 / Math.PI); 81 | 82 | return new Promise((resolve, reject) => { 83 | try { 84 | new TWEEN.Tween(current) 85 | .to(end, time) 86 | .easing(TWEEN.Easing.Quadratic.Out) 87 | .onUpdate(() => { 88 | this.rotation[axis] = current.rad; 89 | // Updates the global transform If you need to get rotation immediately 90 | // this.updateWorldMatrix(false, false); 91 | }) 92 | .onComplete(resolve) 93 | // Parameter 'undefined' is needed in version 18.6.0 94 | // Reference: https://github.com/tweenjs/tween.js/pull/550 95 | .start(undefined); 96 | } catch (err) { 97 | reject(err); 98 | } 99 | }); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import {Axis, AxisValue, Toward, NotationBase, NotationExtra} from '../types'; 3 | 4 | const axisTable: {[key in NotationBase]: [Axis, AxisValue, Toward]} = { 5 | L: ['x', -1, 1], M: ['x', 0, 1], R: ['x', 1, -1], 6 | D: ['y', -1, 1], E: ['y', 0, 1], U: ['y', 1, -1], 7 | B: ['z', -1, 1], S: ['z', 0, -1], F: ['z', 1, -1], 8 | }; 9 | 10 | export function debounce(func: Function, delay = 200) { 11 | let timer: number; 12 | return function(...args: any[]) { 13 | if (timer) { 14 | window.clearTimeout(timer); 15 | } 16 | timer = window.setTimeout(()=> { 17 | func(args); 18 | }, delay); 19 | }; 20 | } 21 | 22 | export function random(start: number, end: number) { 23 | return start + Math.floor(Math.random() * (end - start + 1)); 24 | } 25 | 26 | export function randomChoice(obj: {}): any 27 | export function randomChoice(obj: any[]): any { 28 | if (Array.isArray(obj)) { 29 | const i = Math.floor(Math.random() * obj.length); 30 | return obj[i]; 31 | } else { 32 | const list = Object.keys(obj); 33 | const key = Math.floor(Math.random() * list.length); 34 | return obj[key]; 35 | } 36 | } 37 | 38 | export function sleep(millisecond: number) { 39 | return new Promise((resolve) => { 40 | setTimeout(() => resolve(), millisecond); 41 | }); 42 | } 43 | 44 | export function err(strs: TemplateStringsArray, ...args: any[]) { 45 | let result = ''; 46 | strs.forEach((str, i) => { 47 | let arg = args[i]; 48 | if (typeof arg === 'object') { 49 | arg = JSON.stringify(arg); 50 | } else if (arg === undefined) { 51 | arg = ''; 52 | } 53 | 54 | result += str + arg; 55 | }); 56 | return result; 57 | } 58 | 59 | 60 | // Get rotation angle relative to the origin (ignore the y-axis) 61 | export function horizontalRotationAngle(position: THREE.Vector3) { 62 | const dir = new THREE.Vector3(); 63 | dir.subVectors(position, new THREE.Vector3(0, position.y, 0)).normalize(); 64 | const rad = new THREE.Vector2(dir.z, dir.x).angle(); 65 | return rad; 66 | } 67 | 68 | export function setOpacity(mesh: THREE.Mesh, opacity: number) { 69 | const material = mesh.material; 70 | if (material) { 71 | if (Array.isArray(material)) { 72 | for (const i of material) { 73 | i.opacity = opacity; 74 | } 75 | } else { 76 | material.opacity = opacity; 77 | } 78 | } 79 | 80 | if (mesh.children) { 81 | for (const i of mesh.children) { 82 | setOpacity(i as THREE.Mesh, opacity); 83 | } 84 | } 85 | } 86 | 87 | // Returns the index of the absolute maximum 88 | export function absMaxIndex(arr: number[]) { 89 | const len = arr.length; 90 | if (!len) { 91 | return null; 92 | } 93 | 94 | let maxValue = Math.abs(arr[0]); 95 | let maxIndex = 0; 96 | for (let i = 1; i < len; i++) { 97 | const value = Math.abs(arr[i]); 98 | if (value > maxValue) { 99 | maxValue = value; 100 | maxIndex = i; 101 | } 102 | } 103 | return maxIndex; 104 | } 105 | 106 | // Returns the closest axis 107 | export function getClosestAxis(vec: THREE.Vector3): Axis { 108 | let maxAxis; 109 | let maxValue; 110 | for (const [axis, value] of Object.entries(vec)) { 111 | const absValue = Math.abs(value); 112 | if (!maxValue || absValue > maxValue) { 113 | maxAxis = axis; 114 | maxValue = absValue; 115 | } 116 | } 117 | return maxAxis as Axis; 118 | } 119 | 120 | export function toRotation(notation: string): [Axis, number, number] { 121 | notation = notation.trim(); 122 | 123 | const base = notation[0] as NotationBase; 124 | const extra = notation[1] as NotationExtra; 125 | 126 | if (!axisTable[base]) { 127 | throw new Error(`Wrong notation: ${notation}`); 128 | } 129 | 130 | const [axis, axisValue, toward] = axisTable[base]; 131 | let rad = (Math.PI / 2) * toward; 132 | 133 | if (extra) { 134 | if (extra === `'`) { 135 | rad *= -1; 136 | } else if (extra === '2') { 137 | rad *= 2; 138 | } else { 139 | throw new Error(`Wrong notation: ${notation}`); 140 | } 141 | } 142 | return [axis, axisValue, rad]; 143 | } 144 | 145 | const bases = ['L', 'M', 'R', 'D', 'E', 'U', 'B', 'S', 'F']; 146 | const extras = ['', `'`, '2', '', `'`]; 147 | export function randomNotation() { 148 | const base = randomChoice(bases); 149 | const extra = randomChoice(extras); 150 | return base + extra; 151 | } 152 | 153 | const axes = ['x', 'y', 'z']; 154 | const towards = ['1', '-1']; 155 | const rads = [Math.PI / 2, Math.PI]; 156 | export function randomRotation(): [Axis, number] { 157 | const axis = randomChoice(axes); 158 | const toward = randomChoice(towards); 159 | const rad = randomChoice(rads); 160 | return [axis, rad * toward]; 161 | } 162 | -------------------------------------------------------------------------------- /src/rubik-cube.ts: -------------------------------------------------------------------------------- 1 | const c: {[index: string]: string} = { 2 | 'U': '#FEFEFE', // White 3 | 'R': '#891214', // Red 4 | 'F': '#199B4C', // Green 5 | 'D': '#FED52F', // yellow 6 | 'B': '#0D48AC', // Blue 7 | 'L': '#FF5525', // Orange 8 | }; 9 | 10 | const notationSwapTable: { 11 | [indexOf: string]: [number, number, number, number][]; 12 | } = { 13 | L: [[0, 18, 27, 53], [3, 21, 30, 50], [6, 24, 33, 47], [36, 38, 44, 42], [37, 41, 43, 39]], 14 | M: [[1, 19, 28, 52], [4, 22, 31, 49], [7, 25, 34, 46]], 15 | R: [[20, 2, 51, 29], [23, 5, 48, 32], [26, 8, 45, 35], [9, 11, 17, 15], [10, 14, 16, 12]], 16 | U: [[9, 18, 36, 45], [10, 19, 37, 46], [11, 20, 38, 47], [0, 2, 8, 6], [1, 5, 7, 3]], 17 | E: [[39, 21, 12, 48], [40, 22, 13, 49], [41, 23, 14, 50]], 18 | D: [[15, 51, 42, 24], [16, 52, 43, 25], [17, 53, 44, 26], [27, 29, 35, 33], [28, 32, 34, 30]], 19 | F: [[6, 9, 29, 44], [7, 12, 28, 41], [8, 15, 27, 38], [18, 20, 26, 24], [19, 23, 25, 21]], 20 | S: [[3, 10, 32, 43], [4, 13, 31, 40], [5, 16, 30, 37]], 21 | B: [[2, 36, 33, 17], [1, 39, 34, 14], [0, 42, 35, 11], [45, 47, 53, 51], [46, 50, 52, 48]], 22 | }; 23 | 24 | export interface Cubelet { 25 | x: number, 26 | y: number, 27 | z: number, 28 | num: number, 29 | type: string, 30 | color?: {[index: string]: string}, 31 | } 32 | 33 | export class RubikCube { 34 | cubelets: Cubelet[] = []; 35 | colors: string[]; 36 | constructor(colorStr?: string) { 37 | if (!colorStr) { 38 | colorStr = 'UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB'; 39 | } 40 | this.colors = colorStr.trim().split(''); 41 | 42 | this.generateCoords(); 43 | this.generateColors(); 44 | } 45 | 46 | generateCoords() { 47 | let num = 0; 48 | for (let y = 1; y >= -1; y--) { 49 | for (let z = -1; z <= 1; z++) { 50 | for (let x = -1; x <= 1; x++) { 51 | const n = [x, y, z].filter(Boolean).length; 52 | let type; 53 | if (n === 3) type = 'corner'; // Corner block 54 | if (n === 2) type = 'edge'; // Edge block 55 | if (n === 1) type = 'center'; // Center block 56 | 57 | this.cubelets.push({x, y, z, num, type}); 58 | num++; 59 | } 60 | } 61 | } 62 | } 63 | 64 | generateColors() { 65 | const colorNames = 'URFDLB'.split(''); 66 | interface FaceColor { 67 | [index: string]: string[]; 68 | } 69 | const faceColor: FaceColor = {}; 70 | for (let i = 0; i < colorNames.length; i++) { 71 | const name = colorNames[i]; 72 | const start = i * 9; 73 | const end = start + 9; 74 | faceColor[name] = this.colors.slice(start, end); 75 | } 76 | 77 | for (const cubelet of this.cubelets) { 78 | const cubeColor: {[index: string]: string} = {}; 79 | const {x, y, z, num} = cubelet; 80 | 81 | // Up 82 | if (y === 1) { 83 | const i = num; 84 | cubeColor['U']= c[faceColor['U'][i]]; 85 | } 86 | 87 | // Down 88 | if (y === -1) { 89 | const n = num - 18; 90 | const i = Math.floor((8 - n) / 3) * 3 + (3 - (8 - n) % 3) - 1; 91 | cubeColor['D'] = c[faceColor['D'][i]]; 92 | } 93 | 94 | // Right 95 | if (x === 1) { 96 | const n = (num + 1) / 3 - 1; 97 | const i = Math.floor(n / 3) * 3 + (3 - n % 3) - 1; 98 | cubeColor['R'] = c[faceColor['R'][i]]; 99 | } 100 | 101 | // Left 102 | if (x === -1) { 103 | const i = num / 3; 104 | cubeColor['L'] = c[faceColor['L'][i]]; 105 | } 106 | 107 | // Front 108 | if (z === 1) { 109 | const i = Math.floor((num - 6) / 7) + ((num - 6) % 7); 110 | cubeColor['F'] = c[faceColor['F'][i]]; 111 | } 112 | 113 | // Back 114 | if (z === -1) { 115 | const n = Math.floor(num / 7) + (num % 7); 116 | const i = Math.floor(n / 3) * 3 + (3 - n % 3) - 1; 117 | cubeColor['B'] = c[faceColor['B'][i]]; 118 | } 119 | cubelet.color = cubeColor; 120 | } 121 | } 122 | 123 | asString() { 124 | return this.colors.join(''); 125 | } 126 | 127 | move(notationStr: string) { 128 | const notations = notationStr.trim().split(' '); 129 | for (const i of notations) { 130 | let toward = 1; 131 | let rotationTimes = 1; 132 | const notation = i[0]; 133 | const secondNota = i[1]; 134 | if (secondNota) { 135 | if (secondNota === `'`) { 136 | toward = -1; 137 | } else if (secondNota === `2`) { 138 | rotationTimes = 2; 139 | } else { 140 | throw new Error(`Wrong secondNota: ${secondNota}`); 141 | } 142 | } 143 | 144 | for (let j = 0; j < rotationTimes; j++) { 145 | const actions = notationSwapTable[notation]; 146 | for (const k of actions) { 147 | this.swapFaceColor(k, toward); 148 | } 149 | } 150 | } 151 | } 152 | 153 | swapFaceColor(faceColorNums: number[], toward: number) { 154 | const [a, b, c, d] = faceColorNums; 155 | const colors = this.colors; 156 | const aColor = colors[a]; 157 | if (toward === -1) { 158 | colors[a] = colors[b]; 159 | colors[b] = colors[c]; 160 | colors[c] = colors[d]; 161 | colors[d] = aColor; 162 | } else if (toward === 1) { 163 | colors[a] = colors[d]; 164 | colors[d] = colors[c]; 165 | colors[c] = colors[b]; 166 | colors[b] = aColor; 167 | } else { 168 | throw new Error(`Wrong toward: ${toward}`); 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './assets/css/index.scss'; 2 | 3 | import * as THREE from 'three'; 4 | import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls'; 5 | import TWEEN from '@tweenjs/tween.js'; 6 | 7 | import {debounce, horizontalRotationAngle, setOpacity, getClosestAxis, toRotation, randomNotation} from './utils'; 8 | import {RubikCubeModel} from './rubik-cube-model'; 9 | import {LayerModel} from './layer-model'; 10 | import {Axis, NotationBase, Toward} from './types'; 11 | import {ProgressBar} from './libs/progress-bar'; 12 | import {Router} from './libs/router'; 13 | 14 | const notationTable: {[key in Axis]: [NotationBase, Toward][]} = { 15 | x: [['L', 1], ['M', 1], ['R', -1]], 16 | y: [['D', 1], ['E', 1], ['U', -1]], 17 | z: [['B', 1], ['S', -1], ['F', -1]], 18 | }; 19 | 20 | const minMoveDistance = 10; 21 | const rotationRadPerPx = 0.01; 22 | const debug = false; 23 | 24 | 25 | const router = new Router(); 26 | 27 | const raycaster = new THREE.Raycaster(); 28 | let screenWidth = window.innerWidth; 29 | let screenHeight = window.innerHeight; 30 | const screenCenterCoords = new THREE.Vector2(screenWidth / 2, screenHeight / 2); 31 | 32 | let draggable = true; 33 | let mouseTarget: THREE.Intersection; 34 | let mouseMoveAxis: 'x' | 'y'; 35 | let initMoveToward: number; 36 | const mouseTargetFaceDirection = new THREE.Vector3(); // Vector3 37 | const mouseCoords = new THREE.Vector2(); 38 | const mousedownCoords = new THREE.Vector2(); 39 | 40 | const layerGroup = new LayerModel(debug); 41 | const box = new THREE.BoxHelper( layerGroup, '#fff' ); 42 | box.onBeforeRender = function() { 43 | this.update(); 44 | }; 45 | 46 | let layerRorationAxis: 'x' | 'y' | 'z'; 47 | let layerRotationAxisToward: 1 | -1 = 1; 48 | let lockRotationDirection = false; 49 | 50 | const scene = new THREE.Scene(); 51 | // scene.add(box); 52 | scene.background = new THREE.Color('#F1F3F3'); 53 | // scene.background = new THREE.TextureLoader().load(require('./img/background.jpg').default); 54 | 55 | const directionalLight = new THREE.DirectionalLight('#FFF', 0.05); 56 | directionalLight.position.set(10, 10, 10); 57 | scene.add(directionalLight); 58 | 59 | const directionalLight2 = new THREE.DirectionalLight('#FFF', 0.05); 60 | directionalLight2.position.set(-10, -10, -10); 61 | scene.add(directionalLight2); 62 | 63 | // const ambientLight = new THREE.AmbientLight('#FFF'); 64 | // scene.add(ambientLight); 65 | 66 | const camera = new THREE.PerspectiveCamera(75, screenWidth / screenHeight, 0.1, 30); 67 | if (screenWidth < 576) { 68 | camera.position.set(4, 4, 4); 69 | } else { 70 | camera.position.set(3, 3, 3); 71 | } 72 | 73 | const renderer = new THREE.WebGLRenderer({ 74 | antialias: true, 75 | }); 76 | renderer.setSize(screenWidth, screenHeight); 77 | renderer.setPixelRatio( window.devicePixelRatio ); 78 | document.body.appendChild(renderer.domElement); 79 | 80 | const controls = new OrbitControls(camera, renderer.domElement); 81 | controls.enablePan = false; 82 | controls.enableDamping = true; 83 | controls.rotateSpeed = 1.5; 84 | controls.minDistance = debug ? 1 : 3; 85 | controls.maxDistance = debug ? 20 : 10; 86 | 87 | let rubikCube = new RubikCubeModel(router.search.fd); 88 | let cubeletModels = rubikCube.model.children; 89 | scene.add(rubikCube.model); 90 | scene.add(layerGroup); 91 | 92 | 93 | window.addEventListener('resize', debounce(function() { 94 | screenWidth = window.innerWidth; 95 | screenHeight = window.innerHeight; 96 | screenCenterCoords.set(screenWidth / 2, screenHeight / 2); 97 | 98 | camera.aspect = screenWidth / screenHeight; 99 | camera.updateProjectionMatrix(); 100 | renderer.setSize(screenWidth, screenHeight); 101 | })); 102 | 103 | const progressBarEl = document.querySelector('#progressbar') as HTMLElement; 104 | const progress = new ProgressBar(progressBarEl); 105 | 106 | let disable = false; 107 | const ribbonEl = document.querySelector('#ribbon'); 108 | function lock(func: Function) { 109 | return async function() { 110 | if (disable) { 111 | return; 112 | } 113 | 114 | disable = true; 115 | ribbonEl.classList.add('disable'); 116 | try { 117 | await func(); 118 | } finally { 119 | disable = false; 120 | ribbonEl.classList.remove('disable'); 121 | } 122 | }; 123 | } 124 | 125 | const randomEl = document.querySelector('#random'); 126 | randomEl.addEventListener('click', lock(async () => { 127 | draggable = false; 128 | progress.start(); 129 | 130 | let i = 0; 131 | let lastNotation = ''; 132 | const total = 20; 133 | while (i < total) { 134 | const notation = randomNotation(); 135 | 136 | if (lastNotation && notation[0] === lastNotation[0]) { 137 | continue; 138 | } 139 | lastNotation = notation; 140 | 141 | 142 | const [layerRorationAxis, axisValue, rotationRad] = toRotation(notation); 143 | rubikCube.move(notation); 144 | 145 | router.search.fd = rubikCube.asString(); 146 | 147 | layerGroup.group(layerRorationAxis, axisValue, cubeletModels); 148 | const promise = rotationTransition(layerRorationAxis, rotationRad); 149 | 150 | i++; 151 | progress.setPercentage(i / total); 152 | await promise; 153 | } 154 | 155 | progress.done(); 156 | mouseTarget = null; 157 | layerRorationAxis = null; 158 | mouseMoveAxis = null; 159 | draggable = true; 160 | })); 161 | 162 | const resetEl = document.querySelector('#reset'); 163 | resetEl.addEventListener('click', lock(async function() { 164 | scene.remove(rubikCube.model); 165 | rubikCube.dispose(); 166 | rubikCube = new RubikCubeModel(); 167 | cubeletModels = rubikCube.model.children; 168 | scene.add(rubikCube.model); 169 | 170 | window.history.replaceState('', '', './'); 171 | })); 172 | 173 | 174 | renderer.domElement.addEventListener('mousedown', function() { 175 | handleMouseDown(); 176 | }); 177 | 178 | renderer.domElement.addEventListener('touchstart', function(e) { 179 | const touch = e.changedTouches[0]; 180 | mouseCoords.set(touch.clientX, touch.clientY); 181 | handleMouseDown(); 182 | }); 183 | 184 | renderer.domElement.addEventListener('mouseup', function() { 185 | handleMouseUp(); 186 | }); 187 | 188 | renderer.domElement.addEventListener('touchend', function() { 189 | handleMouseUp(); 190 | }); 191 | 192 | renderer.domElement.addEventListener('mousemove', function(e) { 193 | mouseCoords.set(e.clientX, e.clientY); 194 | handleMouseMove(); 195 | }); 196 | 197 | renderer.domElement.addEventListener('touchmove', function(e) { 198 | const touch = e.changedTouches[0]; 199 | mouseCoords.set(touch.clientX, touch.clientY); 200 | handleMouseMove(); 201 | }); 202 | 203 | function animate(time?: number) { 204 | requestAnimationFrame(animate); 205 | if (controls) { 206 | controls.update(); 207 | } 208 | TWEEN.update(time); 209 | renderer.render(scene, camera); 210 | }; 211 | animate(); 212 | 213 | async function rotationTransition(axis: Axis, endRad: number) { 214 | await layerGroup.rotationAnimation(axis, endRad); 215 | layerGroup.ungroup(rubikCube.model); 216 | layerGroup.initRotation(); 217 | } 218 | 219 | function getNotation(axis: 'x' | 'y' | 'z', value: number, sign: number, endDeg: number) { 220 | if (endDeg < 90) { 221 | throw new Error(`Wrong endDeg: ${endDeg}`); 222 | } 223 | // -1 0 1 -> 0 1 2 224 | const index = value + 1; 225 | const layerRotationNotation = notationTable[axis][index]; 226 | let notation = ''; 227 | // Use url search params to record cube colors 228 | if (endDeg > 0 && layerRotationNotation) { 229 | let toward = layerRotationNotation[1]; 230 | if (sign < 0) { 231 | toward *= -1; 232 | } 233 | let baseStr = layerRotationNotation[0]; 234 | if (toward< 0) { 235 | baseStr += `'`; 236 | } 237 | baseStr += ' '; 238 | for (let i = 0; i < Math.floor(endDeg / 90); i++) { 239 | notation += baseStr; 240 | } 241 | } 242 | return notation; 243 | } 244 | 245 | async function handleMouseUp() { 246 | if (debug && mouseTarget) { 247 | const cubeletModel = mouseTarget.object; 248 | setOpacity(cubeletModel as THREE.Mesh, 1); 249 | } 250 | 251 | controls.enabled = true; 252 | 253 | if (!layerRorationAxis || !draggable) { 254 | return; 255 | } 256 | 257 | // current rotation deg 258 | const deg = Math.abs((THREE as any).Math.radToDeg(layerGroup.rotation[layerRorationAxis])) % 360; 259 | const sign = Math.sign(layerGroup.rotation[layerRorationAxis]); 260 | 261 | let endDeg; 262 | if (0 <= deg && deg <= 40) { 263 | endDeg = 0; 264 | } else if (40 < deg && deg <= 90 + 40) { 265 | endDeg = 90; 266 | } else if (90 + 40 < deg && deg <= 180 + 40) { 267 | endDeg = 180; 268 | } else if (180 + 40 < deg && deg <= 270 + 40) { 269 | endDeg = 270; 270 | } else if (270 + 40 < deg && deg <= 360) { 271 | endDeg = 360; 272 | } 273 | 274 | if (endDeg > 0) { 275 | // Get Singmaster notation according the rotation axis and mouse movement direction 276 | const position = mouseTarget.object.position; 277 | // // -1 0 1 -> 0 1 2 278 | // const index = position[layerRorationAxis] + 1; 279 | const value = position[layerRorationAxis]; 280 | const notation = getNotation(layerRorationAxis, value, sign, endDeg); 281 | rubikCube.move(notation); 282 | 283 | router.search.fd = rubikCube.asString(); 284 | } 285 | 286 | // const startRad =(THREE as any).Math.degToRad(deg * sign); 287 | const endRad = (THREE as any).Math.degToRad(endDeg * sign); 288 | 289 | draggable = false; 290 | // Must use await 291 | // Disable drag cube until the transition is complete 292 | await rotationTransition(layerRorationAxis, endRad); 293 | draggable = true; 294 | 295 | lockRotationDirection = false; 296 | mouseTarget = null; 297 | layerRotationAxisToward = 1; 298 | initMoveToward = null; 299 | 300 | 301 | layerRorationAxis = null; 302 | mouseMoveAxis = null; 303 | } 304 | 305 | function handleMouseDown() { 306 | const x = (mouseCoords.x/ screenWidth) * 2 - 1; 307 | const y = -(mouseCoords.y/ screenHeight) * 2 + 1; 308 | raycaster.setFromCamera({x, y}, camera); 309 | const intersects = raycaster.intersectObjects(rubikCube.model.children); 310 | 311 | // Disable camera control when playing rotation animation 312 | if (intersects.length || raycaster.intersectObjects(layerGroup.children).length) { 313 | controls.enabled = false; 314 | } 315 | // Fix bug: Incorrect fd value (url) when rotating layer after random shuffle 316 | // Don't move the code up. 317 | // Otherwise the above controls.enabled will not be executed 318 | if (!draggable) { 319 | return; 320 | } 321 | 322 | if (intersects.length) { 323 | // Show hand when the mouse is over the cube 324 | document.body.classList.add('cursor-pointer'); 325 | mousedownCoords.copy(mouseCoords); 326 | 327 | mouseTarget = intersects[0]; 328 | if (debug) { 329 | const cubeletModel = mouseTarget.object as THREE.Mesh; 330 | setOpacity(cubeletModel, 0.5); 331 | } 332 | } 333 | } 334 | 335 | function handleMouseMove() { 336 | const x = (mouseCoords.x/ screenWidth) * 2 - 1; 337 | const y = -(mouseCoords.y/ screenHeight) * 2 + 1; 338 | 339 | raycaster.setFromCamera({x, y}, camera); 340 | const intersects = raycaster.intersectObjects(rubikCube.model.children); 341 | if (intersects.length) { 342 | document.body.classList.add('cursor-pointer'); 343 | } else { 344 | document.body.classList.remove('cursor-pointer'); 345 | } 346 | 347 | if (!mouseTarget || !draggable) { 348 | return; 349 | } 350 | 351 | if (!lockRotationDirection) { 352 | const mouseMoveDistance = mousedownCoords.distanceTo(mouseCoords); 353 | if (Math.abs(mouseMoveDistance) < minMoveDistance) { 354 | return; 355 | } 356 | 357 | lockRotationDirection = true; 358 | 359 | const direction = new THREE.Vector2(); 360 | direction.subVectors(mouseCoords, mousedownCoords).normalize(); 361 | mouseMoveAxis = Math.abs(direction.x) > Math.abs(direction.y) ? 'x' : 'y'; 362 | 363 | mouseTargetFaceDirection.copy(mouseTarget.face.normal); 364 | mouseTargetFaceDirection.transformDirection(mouseTarget.object.matrixWorld); 365 | 366 | const point = mouseTarget.point; 367 | const mouseDirection = new THREE.Vector3().subVectors(point, new THREE.Vector3(0, 0, 0)).normalize(); 368 | // Don't use mouseTargetFaceDirection 369 | // The rounded corners of the box may face the other way. 370 | // const closestAxis = getClosestAxis(mouseTargetFaceDirection); 371 | const closestAxis = getClosestAxis(mouseDirection); 372 | const axisValue = mouseDirection[closestAxis]; 373 | mouseTargetFaceDirection.set(0, 0, 0); 374 | mouseTargetFaceDirection[closestAxis] = Math.sign(axisValue); 375 | 376 | // Get the rotation axis according to the direction of mouse movement and target face normal 377 | if (mouseTargetFaceDirection.y > 0.9) { // Top face 378 | const rad = horizontalRotationAngle(camera.position); 379 | direction.rotateAround(new THREE.Vector2(0, 0), rad * -1); 380 | mouseMoveAxis = Math.abs(direction.x) > Math.abs(direction.y) ? 'x' : 'y'; 381 | 382 | if (mouseMoveAxis === 'y') { 383 | layerRorationAxis = 'x'; 384 | } else if (mouseMoveAxis === 'x') { 385 | layerRorationAxis = 'z'; 386 | layerRotationAxisToward = -1; 387 | } 388 | } else if (mouseTargetFaceDirection.y < -0.9) { // Down face 389 | const rad = horizontalRotationAngle(camera.position); 390 | direction.rotateAround(new THREE.Vector2(0, 0), rad * 1); 391 | mouseMoveAxis = Math.abs(direction.x) > Math.abs(direction.y) ? 'x' : 'y'; 392 | 393 | if (mouseMoveAxis === 'y') { 394 | layerRorationAxis = 'x'; 395 | } else if (mouseMoveAxis === 'x') { 396 | layerRorationAxis = 'z'; 397 | } 398 | } else if (mouseTargetFaceDirection.x < -0.9) { // Left face 399 | if (mouseMoveAxis === 'y') { 400 | layerRorationAxis = 'z'; 401 | } else if (mouseMoveAxis === 'x') { 402 | layerRorationAxis = 'y'; 403 | } 404 | } else if (mouseTargetFaceDirection.x > 0.9) { // Right face 405 | if (mouseMoveAxis === 'y') { 406 | layerRorationAxis = 'z'; 407 | layerRotationAxisToward = -1; 408 | } else if (mouseMoveAxis === 'x') { 409 | layerRorationAxis = 'y'; 410 | } 411 | } else if (mouseTargetFaceDirection.z > 0.9) { // Front face 412 | if (mouseMoveAxis === 'y') { // Vertical movement 413 | layerRorationAxis = 'x'; 414 | } else if (mouseMoveAxis === 'x') { // Horizontal movement 415 | layerRorationAxis = 'y'; 416 | } 417 | } else if (mouseTargetFaceDirection.z < -0.9) { // Back face 418 | if (mouseMoveAxis === 'y') { 419 | layerRorationAxis = 'x'; 420 | layerRotationAxisToward = -1; 421 | } else if (mouseMoveAxis === 'x') { 422 | layerRorationAxis = 'y'; 423 | } 424 | } else { 425 | throw new Error(`Wrong mouseTargetFaceDirection: ${mouseTargetFaceDirection}`); 426 | } 427 | 428 | const value = mouseTarget.object.position[layerRorationAxis]; 429 | layerGroup.group(layerRorationAxis, value, cubeletModels); 430 | } else { 431 | let mouseMoveDistance = mouseCoords[mouseMoveAxis] - mousedownCoords[mouseMoveAxis]; 432 | // Get the moving distance by the camera rotation angle relative to origin when clicking on the top face and down face 433 | if (mouseTargetFaceDirection && Math.abs(mouseTargetFaceDirection.y) > 0.9) { 434 | const yAxisDirection = Math.sign(mouseTargetFaceDirection.y) * -1; 435 | const dir = new THREE.Vector3(); 436 | dir.subVectors(camera.position, new THREE.Vector3(0, camera.position.y, 0)).normalize(); 437 | const rad = new THREE.Vector2(dir.z, dir.x).angle(); 438 | const mouseCurrentRotation = new THREE.Vector2().copy(mouseCoords); 439 | mouseCurrentRotation.rotateAround(screenCenterCoords, rad * yAxisDirection); 440 | const mouseDownRotation = new THREE.Vector2().copy(mousedownCoords); 441 | mouseDownRotation.rotateAround(screenCenterCoords, rad * yAxisDirection); 442 | 443 | mouseMoveDistance = mouseCurrentRotation[mouseMoveAxis] - mouseDownRotation[mouseMoveAxis]; 444 | } 445 | 446 | if (!initMoveToward) { 447 | initMoveToward = Math.sign(mouseMoveDistance); 448 | } 449 | if (layerGroup.children.length && layerRorationAxis) { 450 | layerGroup.rotation[layerRorationAxis] = 451 | (mouseMoveDistance - minMoveDistance * initMoveToward) * rotationRadPerPx * layerRotationAxisToward; 452 | } 453 | } 454 | } 455 | 456 | --------------------------------------------------------------------------------