// ============================================================ // 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 = '
☰ Drag to move
'; html += '
'; html += '
'; html += '
Step ' + Math.min(stepIndex + 1, stepTotal) + ' / ' + stepTotal + '
'; html += '
' + movesHtml + '
'; html += '
'; html += '
'; if (stepIndex > 0) { html += ''; } if (isComplete) { html += ''; } else { html += ''; } html += ''; html += '
'; html += '
'; panel.innerHTML = html; // Attach drag listeners after render initPanelDrag(); // Sync step info to pop-out window if (cubePopWindow && !cubePopWindow.closed) { const popInfo = cubePopWindow.document.getElementById('step-info-pop'); if (popInfo) { let popHtml = '
Step ' + Math.min(stepIndex + 1, stepTotal) + ' / ' + stepTotal + '
'; popHtml += '
' + movesHtml + '
'; popInfo.innerHTML = popHtml; } } } // Parse notation keeping original tokens for display function parseAlgorithmTokens(notation) { notation = notation.replace(/\u2019/g, "'"); return notation.replace(/\(/g, '').replace(/\)/g, '').trim().split(/\s+/); } async function startStepByStep(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 to set up the case const inverseMoves = moves.slice().reverse().map(invertMove); 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; // Enter step mode stepMoves = moves; stepIndex = 0; stepTotal = moves.length; stepNotation = notation; stepActive = true; document.getElementById('scramble-display').textContent = 'Step-by-step: Click "Next Step" to advance'; showStepPanel(); // Ensure pop-out step controls are visible (retry after short delay for timing) if (cubePopWindow && !cubePopWindow.closed) { setTimeout(function() { const popStepCtrl = cubePopWindow.document.getElementById('step-controls-pop'); if (popStepCtrl) popStepCtrl.style.display = 'flex'; }, 200); } } async function executeNextStep() { if (!stepActive || isAnimating || stepIndex >= stepTotal) return; const move = stepMoves[stepIndex]; const def = get3DMoveDef(move); if (def) { isAnimating = true; await animateMove(def.axis, def.layers, def.angle, 350); isAnimating = false; } stepIndex++; if (stepIndex >= stepTotal) { document.getElementById('scramble-display').textContent = 'Solved! \u2714'; } else { const tokens = parseAlgorithmTokens(stepNotation); document.getElementById('scramble-display').textContent = 'Step ' + (stepIndex + 1) + '/' + stepTotal + ': next is ' + tokens[stepIndex]; } updateStepPanel(); } async function executePrevStep() { if (!stepActive || isAnimating || stepIndex <= 0) return; // Undo the previous move by applying its inverse stepIndex--; const move = stepMoves[stepIndex]; const inverseMove = invertMove(move); const def = get3DMoveDef(inverseMove); if (def) { isAnimating = true; await animateMove(def.axis, def.layers, def.angle, 350); isAnimating = false; } const tokens = parseAlgorithmTokens(stepNotation); document.getElementById('scramble-display').textContent = 'Step ' + (stepIndex + 1) + '/' + stepTotal + ': next is ' + tokens[stepIndex]; updateStepPanel(); } // ============ SCRAMBLE & RESET ============ function scrambleCube() { if (isAnimating || moveQueue.length > 0) return; const scramblePool = ['R','Ri','R2','L','Li','L2','U','Ui','U2','D','Di','D2','F','Fi','F2','B','Bi','B2']; const scrambleMoves = []; for (let i = 0; i < 20; i++) { const move = scramblePool[Math.floor(Math.random() * scramblePool.length)]; scrambleMoves.push(move); } document.getElementById('scramble-display').textContent = 'Scrambling...'; moveQueue.push(...scrambleMoves); processQueue(); setTimeout(() => { document.getElementById('scramble-display').textContent = 'Scrambled! Try an algorithm.'; }, scrambleMoves.length * 210); } function resetCube() { if (isAnimating) return; moveQueue = []; // Remove existing cube and recreate scene.remove(cubeGroup); createCube(); document.getElementById('scramble-display').textContent = 'Cube reset! Drag to rotate \u2022 Scroll to zoom'; } // ============ UI RENDERING ============ let currentSection = 'f2l'; // ============ TOP VIEW DIAGRAM GENERATOR ============ // Computes the top-view diagram by simulating the algorithm inverse on a solved cube // This guarantees the diagram matches exactly what the 3D cube shows const DIAG_COLORS = { U: '#ffdd00', // Yellow (top) D: '#ffffff', // White (bottom) R: '#ee0000', // Red L: '#ff8800', // Orange F: '#0051ff', // Blue B: '#00aa33', // Green X: '#444444' // Grey (unknown/internal) }; // Mini cube state simulator for computing top-view diagrams // State: 6 faces x 9 stickers = 54 values // Face order: U, D, R, L, F, B // Sticker order per face (looking at face): // 0 1 2 // 3 4 5 // 6 7 8 function createSolvedState() { return { U: ['U','U','U','U','U','U','U','U','U'], D: ['D','D','D','D','D','D','D','D','D'], R: ['R','R','R','R','R','R','R','R','R'], L: ['L','L','L','L','L','L','L','L','L'], F: ['F','F','F','F','F','F','F','F','F'], B: ['B','B','B','B','B','B','B','B','B'] }; } function cloneState(s) { return { U: [...s.U], D: [...s.D], R: [...s.R], L: [...s.L], F: [...s.F], B: [...s.B] }; } // Apply a single move to the state function applyMoveToState(state, move) { let s = cloneState(state); switch(move) { case 'U': return moveU(s); case 'Ui': return moveU(moveU(moveU(s))); case 'U2': return moveU(moveU(s)); case 'D': return moveD(s); case 'Di': return moveD(moveD(moveD(s))); case 'D2': return moveD(moveD(s)); case 'R': return moveR(s); case 'Ri': return moveR(moveR(moveR(s))); case 'R2': return moveR(moveR(s)); case 'L': return moveL(s); case 'Li': return moveL(moveL(moveL(s))); case 'L2': return moveL(moveL(s)); case 'F': return moveF(s); case 'Fi': return moveF(moveF(moveF(s))); case 'F2': return moveF(moveF(s)); case 'B': return moveB(s); case 'Bi': return moveB(moveB(moveB(s))); case 'B2': return moveB(moveB(s)); // Wide moves: r = R + M' (M' same direction as R) case 'r': s = moveR(s); return moveMx(s); case 'ri': s = moveR(moveR(moveR(s))); return moveMx(moveMx(moveMx(s))); case 'r2': s = moveR(moveR(s)); return moveMx(moveMx(s)); case 'l': s = moveL(s); return moveMxi(s); case 'li': s = moveL(moveL(moveL(s))); return moveMxi(moveMxi(moveMxi(s))); case 'l2': s = moveL(moveL(s)); return moveMxi(moveMxi(s)); case 'u': s = moveU(s); return moveMy(s); case 'ui': s = moveU(moveU(moveU(s))); return moveMy(moveMy(moveMy(s))); case 'u2': s = moveU(moveU(s)); return moveMy(moveMy(s)); case 'd': s = moveD(s); return moveMyi(s); case 'di': s = moveD(moveD(moveD(s))); return moveMyi(moveMyi(moveMyi(s))); case 'd2': s = moveD(moveD(s)); return moveMyi(moveMyi(s)); case 'f': s = moveF(s); return moveMz(s); case 'fi': s = moveF(moveF(moveF(s))); return moveMz(moveMz(moveMz(s))); case 'f2': s = moveF(moveF(s)); return moveMz(moveMz(s)); case 'b': s = moveB(s); return moveMzi(s); case 'bi': s = moveB(moveB(moveB(s))); return moveMzi(moveMzi(moveMzi(s))); case 'b2': s = moveB(moveB(s)); return moveMzi(moveMzi(s)); // Rotations: x follows R, y follows U, z follows F // Physical R direction: F→U→B→D (sticker at F moves to U position) // moveR implements this: new U = old F (F→U) ✓ // Physical L direction: U→F→D→B (opposite of R) // So for x (R direction on ALL layers): left layer needs L' = moveL³ case 'x': s = moveR(s); s = moveMx(s); return moveL(moveL(moveL(s))); case 'xi': s = moveR(moveR(moveR(s))); s = moveMx(moveMx(moveMx(s))); return moveL(s); case 'x2': s = moveR(moveR(s)); s = moveMx(moveMx(s)); return moveL(moveL(s)); case 'y': s = moveU(s); s = moveMy(s); return moveD(moveD(moveD(s))); case 'yi': s = moveU(moveU(moveU(s))); s = moveMy(moveMy(moveMy(s))); return moveD(s); case 'y2': s = moveU(moveU(s)); s = moveMy(moveMy(s)); return moveD(moveD(s)); case 'z': s = moveF(s); s = moveMz(s); return moveB(moveB(moveB(s))); case 'zi': s = moveF(moveF(moveF(s))); s = moveMz(moveMz(moveMz(s))); return moveB(s); case 'z2': s = moveF(moveF(s)); s = moveMz(moveMz(s)); return moveB(moveB(s)); // M slice (between R and L, follows L direction = opposite of R) case 'M': return moveMx(moveMx(moveMx(s))); case 'Mi': return moveMx(s); case 'M2': return moveMx(moveMx(s)); // S slice (between F and B, follows F direction) case 'S': return moveMz(s); case 'Si': return moveMz(moveMz(moveMz(s))); case 'S2': return moveMz(moveMz(s)); default: return s; } } // Middle slice moves for state simulation // Mx = M slice following R direction (between R and L, rotates like R) // Mx = Middle slice (same cycle direction as moveR for consistency) function moveMx(s) { const tmp = [s.U[1],s.U[4],s.U[7]]; s.U[1]=s.F[1]; s.U[4]=s.F[4]; s.U[7]=s.F[7]; s.F[1]=s.D[1]; s.F[4]=s.D[4]; s.F[7]=s.D[7]; s.D[1]=s.B[7]; s.D[4]=s.B[4]; s.D[7]=s.B[1]; s.B[7]=tmp[0]; s.B[4]=tmp[1]; s.B[1]=tmp[2]; return s; } function moveMxi(s) { return moveMx(moveMx(moveMx(s))); } // My = E slice following U direction (between U and D, rotates like U) function moveMy(s) { const tmp = [s.F[3],s.F[4],s.F[5]]; s.F[3]=s.R[3]; s.F[4]=s.R[4]; s.F[5]=s.R[5]; s.R[3]=s.B[3]; s.R[4]=s.B[4]; s.R[5]=s.B[5]; s.B[3]=s.L[3]; s.B[4]=s.L[4]; s.B[5]=s.L[5]; s.L[3]=tmp[0]; s.L[4]=tmp[1]; s.L[5]=tmp[2]; return s; } function moveMyi(s) { return moveMy(moveMy(moveMy(s))); } // Mz = S slice following F direction (between F and B, rotates like F) function moveMz(s) { const tmp = [s.U[3],s.U[4],s.U[5]]; s.U[3]=s.L[7]; s.U[4]=s.L[4]; s.U[5]=s.L[1]; s.L[1]=s.D[3]; s.L[4]=s.D[4]; s.L[7]=s.D[5]; s.D[3]=s.R[7]; s.D[4]=s.R[4]; s.D[5]=s.R[1]; s.R[1]=tmp[0]; s.R[4]=tmp[1]; s.R[7]=tmp[2]; return s; } function moveMzi(s) { return moveMz(moveMz(moveMz(s))); } function rotateFaceCW(face) { return [face[6],face[3],face[0],face[7],face[4],face[1],face[8],face[5],face[2]]; } function moveU(s) { s.U = rotateFaceCW(s.U); const tmp = [s.F[0],s.F[1],s.F[2]]; s.F[0]=s.R[0]; s.F[1]=s.R[1]; s.F[2]=s.R[2]; s.R[0]=s.B[0]; s.R[1]=s.B[1]; s.R[2]=s.B[2]; s.B[0]=s.L[0]; s.B[1]=s.L[1]; s.B[2]=s.L[2]; s.L[0]=tmp[0]; s.L[1]=tmp[1]; s.L[2]=tmp[2]; return s; } function moveD(s) { s.D = rotateFaceCW(s.D); const tmp = [s.F[6],s.F[7],s.F[8]]; s.F[6]=s.L[6]; s.F[7]=s.L[7]; s.F[8]=s.L[8]; s.L[6]=s.B[6]; s.L[7]=s.B[7]; s.L[8]=s.B[8]; s.B[6]=s.R[6]; s.B[7]=s.R[7]; s.B[8]=s.R[8]; s.R[6]=tmp[0]; s.R[7]=tmp[1]; s.R[8]=tmp[2]; return s; } function moveR(s) { s.R = rotateFaceCW(s.R); const tmp = [s.U[2],s.U[5],s.U[8]]; s.U[2]=s.F[2]; s.U[5]=s.F[5]; s.U[8]=s.F[8]; s.F[2]=s.D[2]; s.F[5]=s.D[5]; s.F[8]=s.D[8]; s.D[2]=s.B[6]; s.D[5]=s.B[3]; s.D[8]=s.B[0]; s.B[6]=tmp[0]; s.B[3]=tmp[1]; s.B[0]=tmp[2]; return s; } function moveL(s) { s.L = rotateFaceCW(s.L); const tmp = [s.U[0],s.U[3],s.U[6]]; s.U[0]=s.B[8]; s.U[3]=s.B[5]; s.U[6]=s.B[2]; s.B[8]=s.D[0]; s.B[5]=s.D[3]; s.B[2]=s.D[6]; s.D[0]=s.F[0]; s.D[3]=s.F[3]; s.D[6]=s.F[6]; s.F[0]=tmp[0]; s.F[3]=tmp[1]; s.F[6]=tmp[2]; return s; } function moveF(s) { s.F = rotateFaceCW(s.F); const tmp = [s.U[6],s.U[7],s.U[8]]; s.U[6]=s.L[8]; s.U[7]=s.L[5]; s.U[8]=s.L[2]; s.L[2]=s.D[0]; s.L[5]=s.D[1]; s.L[8]=s.D[2]; s.D[0]=s.R[6]; s.D[1]=s.R[3]; s.D[2]=s.R[0]; s.R[0]=tmp[0]; s.R[3]=tmp[1]; s.R[6]=tmp[2]; return s; } function moveB(s) { s.B = rotateFaceCW(s.B); const tmp = [s.U[0],s.U[1],s.U[2]]; s.U[0]=s.R[2]; s.U[1]=s.R[5]; s.U[2]=s.R[8]; s.R[2]=s.D[8]; s.R[5]=s.D[7]; s.R[8]=s.D[6]; s.D[6]=s.L[0]; s.D[7]=s.L[3]; s.D[8]=s.L[6]; s.L[0]=tmp[2]; s.L[3]=tmp[1]; s.L[6]=tmp[0]; return s; } // Compute the cube state after applying the inverse of an algorithm (setup state) function computeSetupState(notation) { const moves = parseAlgorithm(notation); if (moves.length === 0) return createSolvedState(); // Inverse: reverse order, invert each move const inverseMoves = moves.slice().reverse().map(invertMove); let state = createSolvedState(); for (const move of inverseMoves) { state = applyMoveToState(state, move); } return state; } function generateTopViewSVG(section, caseName, caseDesc, firstAlgNotation) { // Only generate for OLL, PLL, COLL, ZBLL if (section === 'f2l') return ''; if (!firstAlgNotation) return ''; const state = computeSetupState(firstAlgNotation); const size = 80; const cell = size / 5; const topStart = cell; const r = 2; let svg = ''; svg += ''; // Draw top face (3x3 grid) - state.U for (let row = 0; row < 3; row++) { for (let col = 0; col < 3; col++) { const color = DIAG_COLORS[state.U[row * 3 + col]] || '#444'; const x = topStart + col * cell; const y = topStart + row * cell; svg += ''; } } // Side stickers visible from top: // Top of diagram = Back face top row (reversed because viewing from opposite side): B[2], B[1], B[0] for (let i = 0; i < 3; i++) { const color = DIAG_COLORS[state.B[2 - i]] || '#444'; const x = topStart + i * cell; const y = 0; svg += ''; } // Right of diagram = Right face top row (back to front): R[2], R[1], R[0] for (let i = 0; i < 3; i++) { const color = DIAG_COLORS[state.R[2 - i]] || '#444'; const x = topStart + 3 * cell; const y = topStart + i * cell; svg += ''; } // Bottom of diagram = Front face top row: F[0], F[1], F[2] for (let i = 0; i < 3; i++) { const color = DIAG_COLORS[state.F[i]] || '#444'; const x = topStart + i * cell; const y = topStart + 3 * cell; svg += ''; } // Left of diagram = Left face top row: L[0], L[1], L[2] (front to back) for (let i = 0; i < 3; i++) { const color = DIAG_COLORS[state.L[i]] || '#444'; const x = 0; const y = topStart + i * cell; svg += ''; } svg += ''; return svg; } function renderAlgorithms(section, filterGroup) { currentSection = section; const data = OH_ALGORITHMS[section]; const container = document.getElementById('algorithm-section'); let html = `

${data.title}

`; 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 += '
'; html += ``; for (const g of groups) { const active = filterGroup === g ? 'active' : ''; html += ``; } html += '
'; } // Filter cases by group if specified let cases = data.cases; if (filterGroup) { cases = data.cases.filter(c => (c.case || c.name.split(' ')[0]) === filterGroup); } html += '
'; for (const caseData of cases) { html += `
`; // Top view diagram + case info in a row const firstAlg = caseData.algs[0] ? caseData.algs[0].notation : ''; // Generate VisualCube 3D image for F2L cases if (section === 'f2l' && firstAlg) { const vcAlg = firstAlg.replace(/\(/g, '').replace(/\)/g, '').replace(/\u2019/g, "'").replace(/\u2032/g, "'"); const vcUrl = `https://visualcube.api.cubing.net/visualcube.php?fmt=svg&size=150&case=${encodeURIComponent(vcAlg)}&bg=t&cc=black`; html += `
`; html += `
${caseData.name}
`; html += `
`; html += `
${caseData.name}
`; html += `

${caseData.case}

`; html += `
`; } else if (section === 'f2l' && !firstAlg) { // Solved case (F2L 37) html += `
`; html += `
${caseData.name}
`; html += `
`; html += `
${caseData.name}
`; html += `

${caseData.case}

`; html += `
`; } else { const diagram = generateTopViewSVG(section, caseData.name, caseData.case, firstAlg); if (diagram) { html += `
`; html += `
${diagram}
`; html += `
`; html += `
${caseData.name}
`; html += `

${caseData.case}

`; html += `
`; } else { html += `
${caseData.name}
`; html += `

${caseData.case}

`; } } for (const alg of caseData.algs) { const escaped = alg.notation.replace(/'/g, "\\'").replace(/\u2019/g, "\\'"); html += `
`; html += `
${alg.notation}
`; html += `
`; html += `${alg.note}`; html += `${alg.moves} moves`; html += ``; html += `
`; html += `
`; } html += `
`; } html += '
'; container.innerHTML = html; } // ============ NAVIGATION ============ function setupNavigation() { const navItems = document.querySelectorAll('.nav-item'); navItems.forEach(item => { item.addEventListener('click', () => { navItems.forEach(n => { n.classList.remove('active'); n.setAttribute('aria-pressed', 'false'); }); item.classList.add('active'); item.setAttribute('aria-pressed', 'true'); const section = item.getAttribute('data-section'); renderAlgorithms(section); }); // Keyboard support item.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); item.click(); } }); }); } // ============ KEYBOARD SHORTCUTS ============ document.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; // Step mode: space or right arrow advances, left arrow goes back if (stepActive) { if (e.key === ' ' || e.key === 'ArrowRight' || e.key === 'Enter') { e.preventDefault(); executeNextStep(); return; } if (e.key === 'ArrowLeft') { e.preventDefault(); executePrevStep(); return; } if (e.key === 'Escape') { cancelStepMode(); return; } } const key = e.key.toLowerCase(); const shift = e.shiftKey; switch (key) { case 'r': doMove(shift ? 'Ri' : 'R'); break; case 'u': doMove(shift ? 'Ui' : 'U'); break; case 'f': doMove(shift ? 'Fi' : 'F'); break; case 'l': doMove(shift ? 'Li' : 'L'); break; case 'd': doMove(shift ? 'Di' : 'D'); break; case 'b': doMove(shift ? 'Bi' : 'B'); break; case 's': scrambleCube(); break; case 'escape': resetCube(); break; } }); // ============ STARTUP ============ function toggleMoveButtons() { const panel = document.getElementById('move-buttons'); const btn = document.getElementById('toggle-moves-btn'); if (panel.style.display === 'none') { panel.style.display = 'flex'; btn.textContent = 'Moves ▲'; } else { panel.style.display = 'none'; btn.textContent = 'Moves ▼'; } } // ============ COLLAPSE / FULLSCREEN 3D VIEW ============ let cubePopWindow = null; // kept for compatibility with showStepPanel/hideStepPanel references function toggleCollapse() { const cubeSection = document.getElementById('cube-section'); const btn = document.getElementById('popout-btn'); if (cubeSection.classList.contains('collapsed')) { cubeSection.classList.remove('collapsed'); btn.innerHTML = '▲ Hide'; btn.title = 'Collapse 3D view for more algorithm space'; onResize(); } else { cubeSection.classList.remove('fullscreen'); cubeSection.classList.add('collapsed'); btn.innerHTML = '▼ Show'; btn.title = 'Show 3D view'; } } function toggleFullscreen() { const cubeSection = document.getElementById('cube-section'); const fsBtn = document.getElementById('fullscreen-btn'); if (cubeSection.classList.contains('fullscreen')) { cubeSection.classList.remove('fullscreen'); fsBtn.innerHTML = '⛶ Full'; fsBtn.title = 'Full screen 3D view'; onResize(); } else { cubeSection.classList.remove('collapsed'); cubeSection.classList.add('fullscreen'); fsBtn.innerHTML = '✖ Exit'; fsBtn.title = 'Exit full screen'; // Update canvas size for fullscreen const container = document.getElementById('cube-canvas').parentElement; camera.aspect = container.clientWidth / (window.innerHeight - 120); camera.updateProjectionMatrix(); renderer.setSize(container.clientWidth, window.innerHeight - 120); // Also show the collapse button properly document.getElementById('popout-btn').innerHTML = '▲ Hide'; } } // Startup is handled by index.html after JSON files load