├── 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 |
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 |
--------------------------------------------------------------------------------