Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save shricodev/1c7ed9967d03c6f5f9b8fd2ad46bcba1 to your computer and use it in GitHub Desktop.

Select an option

Save shricodev/1c7ed9967d03c6f5f9b8fd2ad46bcba1 to your computer and use it in GitHub Desktop.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3D Rubik's Cube - Three.js</title>
<style>
body {
margin: 0;
overflow: hidden;
background-color: #222;
}
canvas {
display: block;
}
#controls {
position: absolute;
top: 10px;
left: 10px;
z-index: 100;
}
button {
padding: 8px 15px;
margin-right: 5px;
cursor: pointer;
background-color: #4caf50;
color: white;
border: none;
border-radius: 3px;
}
button:disabled {
background-color: #aaa;
cursor: not-allowed;
}
button:hover:not(:disabled) {
background-color: #45a049;
}
</style>
</head>
<body>
<div id="controls">
<button id="scrambleBtn">Scramble</button>
<button id="solveBtn">Solve</button>
</div>
<canvas id="canvas"></canvas>
<!-- Dependencies -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tween.js/18.6.4/tween.umd.js"></script>
<!-- Main Script -->
<script src="script.js"></script>
</body>
</html>
// Import necessary modules if using ES6 modules (as indicated by type="module")
// Note: OrbitControls is added to the THREE namespace globally by its script.
// TWEEN is also available globally.
// --- Constants ---
const CUBE_SIZE = 3;
const CUBIE_SIZE = 1;
// const CUBIE_SPACING = 0.08; // Small gap between cubies - ORIGINAL
const CUBIE_SPACING = 0.12; // Increased gap for better visibility
const TOTAL_CUBIE_SIZE = CUBIE_SIZE + CUBIE_SPACING;
const MOVE_DURATION = 300; // milliseconds for one face turn animation
// Standard Rubik's Colors (Hex)
const COLORS = {
WHITE: 0xffffff,
YELLOW: 0xffff00,
BLUE: 0x0000ff,
GREEN: 0x00ff00,
RED: 0xff0000,
ORANGE: 0xffa500,
BLACK: 0x111111, // Inner color
};
// --- Global Variables ---
let scene, camera, renderer, controls;
let cubies = []; // Array to hold all the individual cubie meshes
let pivot; // Helper object for rotations
let isAnimating = false;
let scrambleSequence = []; // To store the moves used for scrambling
// --- DOM Elements ---
const scrambleBtn = document.getElementById("scrambleBtn");
const solveBtn = document.getElementById("solveBtn");
// --- Initialization ---
function init() {
// Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x222222);
// Camera
camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000,
);
camera.position.set(4, 4, 6); // Adjusted for better initial view
camera.lookAt(0, 0, 0);
// Renderer
renderer = new THREE.WebGLRenderer({
canvas: document.getElementById("canvas"),
antialias: true,
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
// Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.6);
directionalLight.position.set(5, 10, 7.5);
scene.add(directionalLight);
// Controls
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.1;
controls.screenSpacePanning = false; // Keep panning relative to the ground plane
// Pivot for Rotation
pivot = new THREE.Object3D();
scene.add(pivot);
// Create Cube
createRubiksCube();
// Event Listeners
window.addEventListener("resize", onWindowResize);
scrambleBtn.addEventListener("click", scrambleCube);
solveBtn.addEventListener("click", solveCube);
solveBtn.disabled = true; // Can't solve initially
// Start Animation Loop
animate();
}
// --- Create Rubik's Cube ---
function createRubiksCube() {
const materials = {
[COLORS.WHITE]: new THREE.MeshStandardMaterial({
color: COLORS.WHITE,
roughness: 0.3,
metalness: 0.1,
}),
[COLORS.YELLOW]: new THREE.MeshStandardMaterial({
color: COLORS.YELLOW,
roughness: 0.3,
metalness: 0.1,
}),
[COLORS.BLUE]: new THREE.MeshStandardMaterial({
color: COLORS.BLUE,
roughness: 0.3,
metalness: 0.1,
}),
[COLORS.GREEN]: new THREE.MeshStandardMaterial({
color: COLORS.GREEN,
roughness: 0.3,
metalness: 0.1,
}),
[COLORS.RED]: new THREE.MeshStandardMaterial({
color: COLORS.RED,
roughness: 0.3,
metalness: 0.1,
}),
[COLORS.ORANGE]: new THREE.MeshStandardMaterial({
color: COLORS.ORANGE,
roughness: 0.3,
metalness: 0.1,
}),
[COLORS.BLACK]: new THREE.MeshStandardMaterial({
color: COLORS.BLACK,
roughness: 0.5,
metalness: 0.0,
}),
};
const geometry = new THREE.BoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE);
// Offset to center the cube
const offset = ((CUBE_SIZE - 1) / 2) * TOTAL_CUBIE_SIZE;
for (let x = 0; x < CUBE_SIZE; x++) {
for (let y = 0; y < CUBE_SIZE; y++) {
for (let z = 0; z < CUBE_SIZE; z++) {
// Skip the core piece (not visible)
if (
x > 0 &&
x < CUBE_SIZE - 1 &&
y > 0 &&
y < CUBE_SIZE - 1 &&
z > 0 &&
z < CUBE_SIZE - 1
) {
continue;
}
const cubieMaterials = [];
// Order: Right (+X), Left (-X), Top (+Y), Bottom (-Y), Front (+Z), Back (-Z)
cubieMaterials.push(
x === CUBE_SIZE - 1 ? materials[COLORS.RED] : materials[COLORS.BLACK],
); // Right
cubieMaterials.push(
x === 0 ? materials[COLORS.ORANGE] : materials[COLORS.BLACK],
); // Left
cubieMaterials.push(
y === CUBE_SIZE - 1
? materials[COLORS.WHITE]
: materials[COLORS.BLACK],
); // Top
cubieMaterials.push(
y === 0 ? materials[COLORS.YELLOW] : materials[COLORS.BLACK],
); // Bottom
cubieMaterials.push(
z === CUBE_SIZE - 1
? materials[COLORS.BLUE]
: materials[COLORS.BLACK],
); // Front
cubieMaterials.push(
z === 0 ? materials[COLORS.GREEN] : materials[COLORS.BLACK],
); // Back
const cubie = new THREE.Mesh(geometry, cubieMaterials);
cubie.position.set(
x * TOTAL_CUBIE_SIZE - offset,
y * TOTAL_CUBIE_SIZE - offset,
z * TOTAL_CUBIE_SIZE - offset,
);
// Store original logical position for easier face selection
cubie.userData = { initialX: x, initialY: y, initialZ: z };
scene.add(cubie);
cubies.push(cubie);
}
}
}
}
// --- Animation Loop ---
function animate() {
requestAnimationFrame(animate);
TWEEN.update(); // Update animations
controls.update(); // Required if damping is enabled
renderer.render(scene, camera);
}
// --- Window Resize Handler ---
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// --- Cube Rotation Logic ---
function rotateFace(face, direction) {
if (isAnimating) return;
isAnimating = true;
updateButtonStates();
const cubiesToRotate = [];
const axis = new THREE.Vector3();
let layer = 0; // Layer index (0, 1, or 2)
// Determine axis, layer, and select cubies
// Use a slightly larger threshold to reliably catch cubies near the edge
const threshold = TOTAL_CUBIE_SIZE * 0.6; // Adjusted threshold
const centerOffset = ((CUBE_SIZE - 1) / 2) * TOTAL_CUBIE_SIZE;
switch (face) {
case "U": // Up (+Y)
axis.set(0, 1, 0);
layer = CUBE_SIZE - 1;
cubies.forEach((c) => {
if (
Math.abs(c.position.y - (layer * TOTAL_CUBIE_SIZE - centerOffset)) <
threshold
)
cubiesToRotate.push(c);
});
break;
case "D": // Down (-Y)
axis.set(0, 1, 0);
layer = 0;
cubies.forEach((c) => {
if (
Math.abs(c.position.y - (layer * TOTAL_CUBIE_SIZE - centerOffset)) <
threshold
)
cubiesToRotate.push(c);
});
break;
case "R": // Right (+X)
axis.set(1, 0, 0);
layer = CUBE_SIZE - 1;
cubies.forEach((c) => {
if (
Math.abs(c.position.x - (layer * TOTAL_CUBIE_SIZE - centerOffset)) <
threshold
)
cubiesToRotate.push(c);
});
break;
case "L": // Left (-X)
axis.set(1, 0, 0);
layer = 0;
cubies.forEach((c) => {
if (
Math.abs(c.position.x - (layer * TOTAL_CUBIE_SIZE - centerOffset)) <
threshold
)
cubiesToRotate.push(c);
});
break;
case "F": // Front (+Z)
axis.set(0, 0, 1);
layer = CUBE_SIZE - 1;
cubies.forEach((c) => {
if (
Math.abs(c.position.z - (layer * TOTAL_CUBIE_SIZE - centerOffset)) <
threshold
)
cubiesToRotate.push(c);
});
break;
case "B": // Back (-Z)
axis.set(0, 0, 1);
layer = 0;
cubies.forEach((c) => {
if (
Math.abs(c.position.z - (layer * TOTAL_CUBIE_SIZE - centerOffset)) <
threshold
)
cubiesToRotate.push(c);
});
break;
default:
console.error("Invalid face:", face);
isAnimating = false;
updateButtonStates();
return;
}
// Attach selected cubies to the pivot
cubiesToRotate.forEach((cubie) => pivot.attach(cubie));
// Determine rotation angle based on direction and face conventions
// direction = 1 (CW), direction = -1 (CCW)
// Positive rotation is CCW around axis using right-hand rule
// U/R/F CW needs negative rotation. D/L/B CW needs positive rotation.
let angle = (Math.PI / 2) * direction;
if (["U", "R", "F"].includes(face)) {
// Keep angle as is for CCW (dir=-1 -> angle=-PI/2), invert for CW (dir=1 -> angle=-PI/2)
angle *= -1;
} else {
// D, L, B
// Keep angle as is for CW (dir=1 -> angle=+PI/2), invert for CCW (dir=-1 -> angle=+PI/2)
// This logic seems slightly off in the previous comment, let's stick to the code:
// angle = (PI/2) * dir. If URF, angle = -(PI/2)*dir. If DLB, angle = (PI/2)*dir. This seems correct.
// Let's re-test mentally:
// U CW (dir=1): angle = -PI/2 (Correct: Negative rotation around +Y)
// U CCW (dir=-1): angle = +PI/2 (Correct: Positive rotation around +Y)
// D CW (dir=1): angle = +PI/2 (Correct: Positive rotation around +Y)
// D CCW (dir=-1): angle = -PI/2 (Correct: Negative rotation around +Y)
// R CW (dir=1): angle = -PI/2 (Correct: Negative rotation around +X)
// L CW (dir=1): angle = +PI/2 (Correct: Positive rotation around +X)
// F CW (dir=1): angle = -PI/2 (Correct: Negative rotation around +Z)
// B CW (dir=1): angle = +PI/2 (Correct: Positive rotation around +Z)
// OKAY, THE LOGIC BELOW SEEMS CORRECT NOW.
if (!["U", "R", "F"].includes(face)) {
angle *= -1; // This was the missing inversion for DLB compared to URF
}
}
// The final correct logic (after fixing the above inversion):
// direction = 1 (CW), direction = -1 (CCW)
angle = (Math.PI / 2) * direction;
if (!["D", "L", "B"].includes(face)) {
// U, R, F
angle *= -1;
}
// Use TWEEN for animation
return new Promise((resolve) => {
new TWEEN.Tween(pivot.rotation)
.to(
{
x: pivot.rotation.x + axis.x * angle,
y: pivot.rotation.y + axis.y * angle,
z: pivot.rotation.z + axis.z * angle,
},
MOVE_DURATION,
)
.easing(TWEEN.Easing.Quadratic.InOut)
.onComplete(() => {
// Detach cubies from pivot and add back to scene
// This applies the pivot's transformation to the cubies
pivot.updateMatrixWorld(); // Ensure matrix is up-to-date
while (pivot.children.length > 0) {
const cubie = pivot.children[0];
scene.attach(cubie);
}
// Reset pivot rotation
pivot.rotation.set(0, 0, 0);
// Snap rotations to grid (optional but good practice)
// Position snapping removed to preserve gaps
cubiesToRotate.forEach((c) => {
// c.position.round(); // <<<< THIS LINE IS REMOVED/COMMENTED OUT
// Simple rotation snapping - keep this
c.rotation.x =
Math.round(c.rotation.x / (Math.PI / 2)) * (Math.PI / 2);
c.rotation.y =
Math.round(c.rotation.y / (Math.PI / 2)) * (Math.PI / 2);
c.rotation.z =
Math.round(c.rotation.z / (Math.PI / 2)) * (Math.PI / 2);
});
isAnimating = false;
updateButtonStates();
resolve(); // Resolve the promise when animation completes
})
.start();
});
}
// --- Scramble Function ---
function scrambleCube() {
if (isAnimating) return;
const moves = ["U", "D", "L", "R", "F", "B"];
const modifiers = ["", "'", "2"]; // Clockwise, Counter-Clockwise, Double
const numScrambleMoves = 20;
scrambleSequence = []; // Clear previous scramble
let promiseChain = Promise.resolve();
for (let i = 0; i < numScrambleMoves; i++) {
const randomMove = moves[Math.floor(Math.random() * moves.length)];
const randomModifier =
modifiers[Math.floor(Math.random() * modifiers.length)];
const moveString = randomMove + randomModifier;
scrambleSequence.push(moveString); // Store the applied move
// Chain the animations using Promises
promiseChain = promiseChain.then(() => applyMove(moveString));
}
// Enable solve button after scrambling is done
promiseChain.then(() => {
solveBtn.disabled = isAnimating; // Should be false if no longer animating
console.log("Scramble complete. Sequence:", scrambleSequence.join(" "));
});
}
// --- Solve Function ---
function solveCube() {
if (isAnimating || scrambleSequence.length === 0) return;
// Reverse the scramble sequence
const solveSequence = scrambleSequence
.slice()
.reverse()
.map((move) => {
if (move.endsWith("'")) return move.slice(0, 1); // U' -> U
if (move.endsWith("2")) return move; // U2 -> U2
return move + "'"; // U -> U'
});
let promiseChain = Promise.resolve();
solveSequence.forEach((move) => {
promiseChain = promiseChain.then(() => applyMove(move));
});
// Clear scramble sequence and disable solve button after solving
promiseChain.then(() => {
scrambleSequence = []; // Cube is now solved (theoretically)
solveBtn.disabled = true;
console.log("Solve complete.");
});
}
// --- Apply a single move string (e.g., "U'", "F2") ---
async function applyMove(moveString) {
if (isAnimating) return; // Should not happen with promise chaining, but safety first
const face = moveString.charAt(0);
const modifier = moveString.charAt(1) || "";
let direction = 1; // Clockwise
let turns = 1;
if (modifier === "'") {
direction = -1; // Counter-clockwise
} else if (modifier === "2") {
turns = 2;
}
// Perform the rotation(s)
for (let i = 0; i < turns; i++) {
await rotateFace(face, direction); // Wait for animation to finish
}
}
// --- UI Update ---
function updateButtonStates() {
scrambleBtn.disabled = isAnimating;
// Solve button is enabled only if NOT animating AND there is a scramble sequence to reverse
solveBtn.disabled = isAnimating || scrambleSequence.length === 0;
}
// --- Start ---
init();
@shricodev
Copy link
Copy Markdown
Author

@benjeffery
Copy link
Copy Markdown

Fascinating! I'd argue that just reversing the scramble isn't really "solving the cube" though.

@ammarmirza5
Copy link
Copy Markdown

It's Amazing.
I have tested it on https://jsfiddle.net/ and it works perfect as in video.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment