// ============================================================ // 3D Rubik's Cube Visualization & OH Algorithm Player // Built with Three.js // ============================================================ let scene, camera, renderer, controls; let cubeGroup; let cubies = []; let isAnimating = false; let moveQueue = []; // Colors: White, Yellow, Red, Orange, Blue, Green (as CSS strings for canvas) const FACE_COLORS = { U: '#ffdd00', // Yellow - top D: '#ffffff', // White - bottom R: '#ee0000', // Red - right L: '#ff8800', // Orange - left F: '#0051ff', // Blue - front B: '#00aa33', // Green - back X: '#1a1a1a' // Black - internal }; const GAP = 1.02; // Gap between cubies // ============ INITIALIZATION ============ function init() { const canvas = document.getElementById('cube-canvas'); const container = canvas.parentElement; scene = new THREE.Scene(); scene.background = new THREE.Color(0x141425); camera = new THREE.PerspectiveCamera(40, container.clientWidth / 400, 0.1, 100); camera.position.set(5.5, 4.2, 5.5); renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true }); renderer.setSize(container.clientWidth, 400); renderer.setPixelRatio(window.devicePixelRatio); // Lighting - use Phong materials so we just need decent lighting const ambientLight = new THREE.AmbientLight(0xffffff, 0.7); scene.add(ambientLight); const mainLight = new THREE.DirectionalLight(0xffffff, 0.6); mainLight.position.set(5, 10, 7); scene.add(mainLight); const fillLight = new THREE.DirectionalLight(0xffffff, 0.3); fillLight.position.set(-5, 0, -5); scene.add(fillLight); // Orbit controls controls = new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.minDistance = 4; controls.maxDistance = 14; controls.enablePan = false; // Create cube createCube(); // Handle resize window.addEventListener('resize', onResize); // Start render loop animate(); } function onResize() { const container = document.getElementById('cube-canvas').parentElement; camera.aspect = container.clientWidth / 400; camera.updateProjectionMatrix(); renderer.setSize(container.clientWidth, 400); } // ============ CANVAS TEXTURE STICKER ============ // Creates a texture with a rounded-corner sticker on a black background function createStickerTexture(color) { const size = 256; const canvas2d = document.createElement('canvas'); canvas2d.width = size; canvas2d.height = size; const ctx = canvas2d.getContext('2d'); // Black background (the gap / body color) ctx.fillStyle = '#111111'; ctx.fillRect(0, 0, size, size); // Draw rounded rectangle sticker const padding = 20; // border around sticker const radius = 28; // corner radius const x = padding; const y = padding; const w = size - padding * 2; const h = size - padding * 2; ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.lineTo(x + w - radius, y); ctx.quadraticCurveTo(x + w, y, x + w, y + radius); ctx.lineTo(x + w, y + h - radius); ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h); ctx.lineTo(x + radius, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - radius); ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); ctx.fillStyle = color; ctx.fill(); // Subtle highlight gradient on sticker for depth const grad = ctx.createLinearGradient(x, y, x, y + h); grad.addColorStop(0, 'rgba(255,255,255,0.15)'); grad.addColorStop(0.5, 'rgba(255,255,255,0)'); grad.addColorStop(1, 'rgba(0,0,0,0.1)'); ctx.fillStyle = grad; ctx.fill(); const texture = new THREE.CanvasTexture(canvas2d); texture.needsUpdate = true; return texture; } // Black face texture (for internal faces with no sticker) function createBlackTexture() { const size = 64; const canvas2d = document.createElement('canvas'); canvas2d.width = size; canvas2d.height = size; const ctx = canvas2d.getContext('2d'); ctx.fillStyle = '#111111'; ctx.fillRect(0, 0, size, size); const texture = new THREE.CanvasTexture(canvas2d); texture.needsUpdate = true; return texture; } // Cache textures const textureCache = {}; function getStickerTexture(colorKey) { if (!textureCache[colorKey]) { if (colorKey === 'X') { textureCache[colorKey] = createBlackTexture(); } else { textureCache[colorKey] = createStickerTexture(FACE_COLORS[colorKey]); } } return textureCache[colorKey]; } // ============ CUBE CREATION ============ function createCubie(x, y, z) { const size = 0.97; const geometry = new THREE.BoxGeometry(size, size, size); // Determine which color each face gets // Three.js box face order: +X, -X, +Y, -Y, +Z, -Z const faceColors = [ x === 1 ? 'R' : 'X', // +X Right x === -1 ? 'L' : 'X', // -X Left y === 1 ? 'U' : 'X', // +Y Up y === -1 ? 'D' : 'X', // -Y Down z === 1 ? 'F' : 'X', // +Z Front z === -1 ? 'B' : 'X', // -Z Back ]; const materials = faceColors.map(key => { return new THREE.MeshPhongMaterial({ map: getStickerTexture(key), specular: 0x222222, shininess: 30 }); }); const cubie = new THREE.Mesh(geometry, materials); cubie.position.set(x * GAP, y * GAP, z * GAP); cubie.userData = { x, y, z }; return cubie; } function createCube() { cubeGroup = new THREE.Group(); cubies = []; for (let x = -1; x <= 1; x++) { for (let y = -1; y <= 1; y++) { for (let z = -1; z <= 1; z++) { if (x === 0 && y === 0 && z === 0) continue; const cubie = createCubie(x, y, z); cubies.push(cubie); cubeGroup.add(cubie); } } } scene.add(cubeGroup); } // ============ ANIMATION LOOP ============ function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } // ============ MOVE SYSTEM ============ // Wide moves = outer layer + middle layer // Rotations = entire cube // Returns {axis, layers[], angle} for 3D animation function get3DMoveDef(moveStr) { const PI = Math.PI; const H = PI / 2; switch(moveStr) { // Face moves case 'R': return { axis:'x', layers:[1], angle:-H }; case 'Ri': return { axis:'x', layers:[1], angle:H }; case 'R2': return { axis:'x', layers:[1], angle:-PI }; case 'L': return { axis:'x', layers:[-1], angle:H }; case 'Li': return { axis:'x', layers:[-1], angle:-H }; case 'L2': return { axis:'x', layers:[-1], angle:PI }; case 'U': return { axis:'y', layers:[1], angle:-H }; case 'Ui': return { axis:'y', layers:[1], angle:H }; case 'U2': return { axis:'y', layers:[1], angle:-PI }; case 'D': return { axis:'y', layers:[-1], angle:H }; case 'Di': return { axis:'y', layers:[-1], angle:-H }; case 'D2': return { axis:'y', layers:[-1], angle:PI }; case 'F': return { axis:'z', layers:[1], angle:-H }; case 'Fi': return { axis:'z', layers:[1], angle:H }; case 'F2': return { axis:'z', layers:[1], angle:-PI }; case 'B': return { axis:'z', layers:[-1], angle:H }; case 'Bi': return { axis:'z', layers:[-1], angle:-H }; case 'B2': return { axis:'z', layers:[-1], angle:PI }; // Wide moves (2 layers) case 'r': return { axis:'x', layers:[1,0], angle:-H }; case 'ri': return { axis:'x', layers:[1,0], angle:H }; case 'r2': return { axis:'x', layers:[1,0], angle:-PI }; case 'l': return { axis:'x', layers:[-1,0], angle:H }; case 'li': return { axis:'x', layers:[-1,0], angle:-H }; case 'l2': return { axis:'x', layers:[-1,0], angle:PI }; case 'u': return { axis:'y', layers:[1,0], angle:-H }; case 'ui': return { axis:'y', layers:[1,0], angle:H }; case 'u2': return { axis:'y', layers:[1,0], angle:-PI }; case 'd': return { axis:'y', layers:[-1,0], angle:H }; case 'di': return { axis:'y', layers:[-1,0], angle:-H }; case 'd2': return { axis:'y', layers:[-1,0], angle:PI }; case 'f': return { axis:'z', layers:[1,0], angle:-H }; case 'fi': return { axis:'z', layers:[1,0], angle:H }; case 'f2': return { axis:'z', layers:[1,0], angle:-PI }; case 'b': return { axis:'z', layers:[-1,0], angle:H }; case 'bi': return { axis:'z', layers:[-1,0], angle:-H }; case 'b2': return { axis:'z', layers:[-1,0], angle:PI }; // Rotations (all 3 layers) case 'x': return { axis:'x', layers:[1,0,-1], angle:-H }; case 'xi': return { axis:'x', layers:[1,0,-1], angle:H }; case 'x2': return { axis:'x', layers:[1,0,-1], angle:-PI }; case 'y': return { axis:'y', layers:[1,0,-1], angle:-H }; case 'yi': return { axis:'y', layers:[1,0,-1], angle:H }; case 'y2': return { axis:'y', layers:[1,0,-1], angle:-PI }; case 'z': return { axis:'z', layers:[1,0,-1], angle:-H }; case 'zi': return { axis:'z', layers:[1,0,-1], angle:H }; case 'z2': return { axis:'z', layers:[1,0,-1], angle:-PI }; // M slice (follows L direction = +x rotation for middle) case 'M': return { axis:'x', layers:[0], angle:H }; case 'Mi': return { axis:'x', layers:[0], angle:-H }; case 'M2': return { axis:'x', layers:[0], angle:PI }; // S slice (follows F direction) case 'S': return { axis:'z', layers:[0], angle:-H }; case 'Si': return { axis:'z', layers:[0], angle:H }; case 'S2': return { axis:'z', layers:[0], angle:PI }; default: return null; } } function getCubiesOnLayers(axis, layers) { const threshold = 0.5; return cubies.filter(cubie => { const pos = cubie.position; for (const layer of layers) { if (axis === 'x' && Math.abs(pos.x - layer * GAP) < threshold) return true; if (axis === 'y' && Math.abs(pos.y - layer * GAP) < threshold) return true; if (axis === 'z' && Math.abs(pos.z - layer * GAP) < threshold) return true; } return false; }); } function animateMove(axis, layers, angle, duration = 300) { return new Promise(resolve => { const layerCubies = getCubiesOnLayers(axis, layers); if (layerCubies.length === 0) { resolve(); return; } const rotationGroup = new THREE.Group(); scene.add(rotationGroup); // Move cubies to rotation group layerCubies.forEach(cubie => { cubeGroup.remove(cubie); rotationGroup.add(cubie); }); const startTime = Date.now(); const axisVec = new THREE.Vector3( axis === 'x' ? 1 : 0, axis === 'y' ? 1 : 0, axis === 'z' ? 1 : 0 ); function step() { const elapsed = Date.now() - startTime; const t = Math.min(elapsed / duration, 1); // Smooth easing const eased = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; rotationGroup.setRotationFromAxisAngle(axisVec, angle * eased); if (t < 1) { requestAnimationFrame(step); } else { // Finalize - apply rotation to each cubie rotationGroup.setRotationFromAxisAngle(axisVec, angle); rotationGroup.updateMatrixWorld(); layerCubies.forEach(cubie => { cubie.applyMatrix4(rotationGroup.matrixWorld); cubie.position.x = Math.round(cubie.position.x / GAP) * GAP; cubie.position.y = Math.round(cubie.position.y / GAP) * GAP; cubie.position.z = Math.round(cubie.position.z / GAP) * GAP; rotationGroup.remove(cubie); cubeGroup.add(cubie); }); scene.remove(rotationGroup); resolve(); } } step(); }); } async function doMove(moveStr) { if (isAnimating) return; isAnimating = true; const def = get3DMoveDef(moveStr); if (def) { await animateMove(def.axis, def.layers, def.angle, 250); } isAnimating = false; processQueue(); } async function processQueue() { if (moveQueue.length === 0 || isAnimating) return; isAnimating = true; const move = moveQueue.shift(); const def = get3DMoveDef(move); if (def) { await animateMove(def.axis, def.layers, def.angle, 180); } isAnimating = false; if (moveQueue.length > 0) { processQueue(); } } // ============ NOTATION PARSER ============ function parseAlgorithm(notation) { // Convert standard notation to our internal format // Normalize curly quotes to straight apostrophes notation = notation.replace(/\u2019/g, "'"); // Normalize X'2 to X2 (e.g., U'2 = U2, R'2 = R2) notation = notation.replace(/([RUDLFBrudlfb])'2/g, '$12'); // Normalize X2' to X2 (e.g., U2' = U2, R2' = R2) notation = notation.replace(/([RUDLFBrudlfb])2'/g, '$12'); const moves = []; const tokens = notation.replace(/\(/g, '').replace(/\)/g, '').trim().split(/\s+/); for (const token of tokens) { let move = ''; if (token === "R") move = 'R'; else if (token === "R'" || token === "R\u2019") move = 'Ri'; else if (token === "R2") move = 'R2'; else if (token === "L") move = 'L'; else if (token === "L'" || token === "L\u2019") move = 'Li'; else if (token === "L2") move = 'L2'; else if (token === "U") move = 'U'; else if (token === "U'" || token === "U\u2019") move = 'Ui'; else if (token === "U2") move = 'U2'; else if (token === "D") move = 'D'; else if (token === "D'" || token === "D\u2019") move = 'Di'; else if (token === "D2") move = 'D2'; else if (token === "F") move = 'F'; else if (token === "F'" || token === "F\u2019") move = 'Fi'; else if (token === "F2") move = 'F2'; else if (token === "B") move = 'B'; else if (token === "B'" || token === "B\u2019") move = 'Bi'; else if (token === "B2") move = 'B2'; // Wide moves else if (token === "r") move = 'r'; else if (token === "r'" || token === "r\u2019") move = 'ri'; else if (token === "r2") move = 'r2'; else if (token === "l") move = 'l'; else if (token === "l'" || token === "l\u2019") move = 'li'; else if (token === "l2") move = 'l2'; else if (token === "u") move = 'u'; else if (token === "u'" || token === "u\u2019") move = 'ui'; else if (token === "u2") move = 'u2'; else if (token === "d") move = 'd'; else if (token === "d'" || token === "d\u2019") move = 'di'; else if (token === "d2") move = 'd2'; else if (token === "f") move = 'f'; else if (token === "f'" || token === "f\u2019") move = 'fi'; else if (token === "f2") move = 'f2'; else if (token === "b") move = 'b'; else if (token === "b'" || token === "b\u2019") move = 'bi'; else if (token === "b2") move = 'b2'; // Rotations else if (token === "x") move = 'x'; else if (token === "x'" || token === "x\u2019") move = 'xi'; else if (token === "x2") move = 'x2'; else if (token === "y") move = 'y'; else if (token === "y'" || token === "y\u2019") move = 'yi'; else if (token === "y2") move = 'y2'; else if (token === "z") move = 'z'; else if (token === "z'" || token === "z\u2019") move = 'zi'; else if (token === "z2") move = 'z2'; // M, S, E slice moves else if (token === "M") move = 'M'; else if (token === "M'" || token === "M\u2019") move = 'Mi'; else if (token === "M2") move = 'M2'; else if (token === "S") move = 'S'; else if (token === "S'" || token === "S\u2019") move = 'Si'; else if (token === "S2") move = 'S2'; if (move) moves.push(move); } return moves; } function playAlgorithm(notation) { if (isAnimating || moveQueue.length > 0) return; const moves = parseAlgorithm(notation); if (moves.length === 0) return; document.getElementById('scramble-display').textContent = 'Playing: ' + notation; moveQueue.push(...moves); processQueue(); // Reset display after animation completes const totalTime = moves.length * 280; setTimeout(() => { document.getElementById('scramble-display').textContent = 'Drag to rotate \u2022 Scroll to zoom'; }, totalTime); } // Invert a single move token (e.g., 'R' -> 'Ri', 'Ri' -> 'R', 'R2' -> 'R2') function invertMove(move) { if (move.endsWith('2')) return move; // double moves are self-inverse if (move.endsWith('i')) return move.slice(0, -1); // Ri -> R return move + 'i'; // R -> Ri } // Setup the case (apply inverse of algorithm instantly) then play the solve animated async function setupAndSolve(notation) { if (isAnimating || moveQueue.length > 0) return; cancelStepMode(); // Focus the pop-out window if cube is popped out if (cubePopWindow && !cubePopWindow.closed) { cubePopWindow.focus(); } // Reset cube first scene.remove(cubeGroup); createCube(); const moves = parseAlgorithm(notation); if (moves.length === 0) return; // Compute inverse sequence (reversed order, each move inverted) const inverseMoves = moves.slice().reverse().map(invertMove); // Apply the inverse instantly (no animation) to set up the case document.getElementById('scramble-display').textContent = 'Setting up case...'; isAnimating = true; for (const move of inverseMoves) { const def = get3DMoveDef(move); if (def) { await animateMove(def.axis, def.layers, def.angle, 40); } } isAnimating = false; // Brief pause to let user see the scrambled state await new Promise(r => setTimeout(r, 600)); // Now play the solution animated document.getElementById('scramble-display').textContent = 'Solving: ' + notation; moveQueue.push(...moves); processQueue(); const totalTime = moves.length * 280; setTimeout(() => { document.getElementById('scramble-display').textContent = 'Solved! \u2714'; }, totalTime); } // ============ STEP BY STEP MODE ============ let stepMoves = []; let stepIndex = 0; let stepTotal = 0; let stepNotation = ''; let stepActive = false; function cancelStepMode() { stepActive = false; stepMoves = []; stepIndex = 0; hideStepPanel(); } function hideStepPanel() { const panel = document.getElementById('step-panel'); if (panel) panel.style.display = 'none'; // Also hide step controls in pop-out window if (cubePopWindow && !cubePopWindow.closed) { const popStepCtrl = cubePopWindow.document.getElementById('step-controls-pop'); if (popStepCtrl) popStepCtrl.style.display = 'none'; } } function showStepPanel() { let panel = document.getElementById('step-panel'); if (!panel) { panel = document.createElement('div'); panel.id = 'step-panel'; document.querySelector('.cube-section').appendChild(panel); } panel.style.display = 'flex'; // Default position: top-right corner, beside the cube panel.style.left = 'auto'; panel.style.right = '15px'; panel.style.top = '15px'; panel.style.bottom = 'auto'; panel.style.transform = 'none'; updateStepPanel(); // Also show step controls in pop-out window if (cubePopWindow && !cubePopWindow.closed) { const popStepCtrl = cubePopWindow.document.getElementById('step-controls-pop'); if (popStepCtrl) popStepCtrl.style.display = 'flex'; } } // ============ DRAG FUNCTIONALITY ============ let isDragging = false; let dragOffsetX = 0; let dragOffsetY = 0; function initPanelDrag() { const handle = document.getElementById('step-drag-handle'); if (!handle) return; handle.addEventListener('mousedown', startDrag); handle.addEventListener('touchstart', startDragTouch, { passive: false }); } function startDrag(e) { e.preventDefault(); const panel = document.getElementById('step-panel'); if (!panel) return; isDragging = true; // Get current panel position const rect = panel.getBoundingClientRect(); const parentRect = panel.parentElement.getBoundingClientRect(); // Switch from right-based to left-based absolute positioning panel.style.transform = 'none'; panel.style.right = 'auto'; panel.style.left = (rect.left - parentRect.left) + 'px'; panel.style.top = (rect.top - parentRect.top) + 'px'; panel.style.bottom = 'auto'; dragOffsetX = e.clientX - rect.left; dragOffsetY = e.clientY - rect.top; document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag); } function startDragTouch(e) { e.preventDefault(); const touch = e.touches[0]; const panel = document.getElementById('step-panel'); if (!panel) return; isDragging = true; const rect = panel.getBoundingClientRect(); const parentRect = panel.parentElement.getBoundingClientRect(); panel.style.transform = 'none'; panel.style.right = 'auto'; panel.style.left = (rect.left - parentRect.left) + 'px'; panel.style.top = (rect.top - parentRect.top) + 'px'; panel.style.bottom = 'auto'; dragOffsetX = touch.clientX - rect.left; dragOffsetY = touch.clientY - rect.top; document.addEventListener('touchmove', onDragTouch, { passive: false }); document.addEventListener('touchend', stopDrag); } function onDrag(e) { if (!isDragging) return; const panel = document.getElementById('step-panel'); if (!panel) return; const parentRect = panel.parentElement.getBoundingClientRect(); let newX = e.clientX - parentRect.left - dragOffsetX; let newY = e.clientY - parentRect.top - dragOffsetY; // Constrain within parent newX = Math.max(0, Math.min(newX, parentRect.width - panel.offsetWidth)); newY = Math.max(0, Math.min(newY, parentRect.height - panel.offsetHeight)); panel.style.left = newX + 'px'; panel.style.top = newY + 'px'; } function onDragTouch(e) { if (!isDragging) return; e.preventDefault(); const touch = e.touches[0]; const panel = document.getElementById('step-panel'); if (!panel) return; const parentRect = panel.parentElement.getBoundingClientRect(); let newX = touch.clientX - parentRect.left - dragOffsetX; let newY = touch.clientY - parentRect.top - dragOffsetY; newX = Math.max(0, Math.min(newX, parentRect.width - panel.offsetWidth)); newY = Math.max(0, Math.min(newY, parentRect.height - panel.offsetHeight)); panel.style.left = newX + 'px'; panel.style.top = newY + 'px'; } function stopDrag() { isDragging = false; document.removeEventListener('mousemove', onDrag); document.removeEventListener('mouseup', stopDrag); document.removeEventListener('touchmove', onDragTouch); document.removeEventListener('touchend', stopDrag); } function updateStepPanel() { const panel = document.getElementById('step-panel'); if (!panel) return; const tokens = parseAlgorithmTokens(stepNotation); // Build move display with highlighting let movesHtml = ''; for (let i = 0; i < tokens.length; i++) { if (i < stepIndex) { movesHtml += '' + tokens[i] + ' '; } else if (i === stepIndex) { movesHtml += '' + tokens[i] + ' '; } else { movesHtml += '' + tokens[i] + ' '; } } const isComplete = stepIndex >= stepTotal; let html = '
${data.description}
`; // Add group filter tabs for ZBLL, COLL, F2L, and SBLS if (section === 'zbll' || section === 'coll' || section === 'f2l' || section === 'sbls') { const groups = []; const groupSet = new Set(); for (const c of data.cases) { const g = c.case || c.name.split(' ')[0]; if (!groupSet.has(g)) { groupSet.add(g); groups.push(g); } } html += '