Spaces:
Running
Running
| // ============================================================ | |
| // 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 += '<span class="step-done">' + tokens[i] + '</span> '; | |
| } else if (i === stepIndex) { | |
| movesHtml += '<span class="step-current">' + tokens[i] + '</span> '; | |
| } else { | |
| movesHtml += '<span class="step-pending">' + tokens[i] + '</span> '; | |
| } | |
| } | |
| const isComplete = stepIndex >= stepTotal; | |
| let html = '<div class="drag-handle" id="step-drag-handle">☰ Drag to move</div>'; | |
| html += '<div class="step-content">'; | |
| html += '<div class="step-info">'; | |
| html += '<div class="step-counter">Step ' + Math.min(stepIndex + 1, stepTotal) + ' / ' + stepTotal + '</div>'; | |
| html += '<div class="step-moves-display">' + movesHtml + '</div>'; | |
| html += '</div>'; | |
| html += '<div class="step-buttons">'; | |
| if (stepIndex > 0) { | |
| html += '<button class="step-btn step-btn-prev" onclick="executePrevStep()">◀ Prev</button>'; | |
| } | |
| if (isComplete) { | |
| html += '<button class="step-btn step-btn-done" onclick="cancelStepMode()">✔ Done</button>'; | |
| } else { | |
| html += '<button class="step-btn step-btn-next" onclick="executeNextStep()">Next Step ▶</button>'; | |
| } | |
| html += '<button class="step-btn step-btn-cancel" onclick="cancelStepMode()">Cancel</button>'; | |
| html += '</div>'; | |
| html += '</div>'; | |
| 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 = '<div class="step-counter">Step ' + Math.min(stepIndex + 1, stepTotal) + ' / ' + stepTotal + '</div>'; | |
| popHtml += '<div class="step-moves">' + movesHtml + '</div>'; | |
| 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 width="' + size + '" height="' + size + '" viewBox="0 0 ' + size + ' ' + size + '" xmlns="http://www.w3.org/2000/svg">'; | |
| svg += '<rect width="' + size + '" height="' + size + '" fill="#1a1a1a" rx="4"/>'; | |
| // 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 += '<rect x="' + (x+1) + '" y="' + (y+1) + '" width="' + (cell-2) + '" height="' + (cell-2) + '" fill="' + color + '" rx="' + r + '"/>'; | |
| } | |
| } | |
| // 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 += '<rect x="' + (x+1) + '" y="' + (y+1) + '" width="' + (cell-2) + '" height="' + (cell-2) + '" fill="' + color + '" rx="' + r + '"/>'; | |
| } | |
| // 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 += '<rect x="' + (x+1) + '" y="' + (y+1) + '" width="' + (cell-2) + '" height="' + (cell-2) + '" fill="' + color + '" rx="' + r + '"/>'; | |
| } | |
| // 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 += '<rect x="' + (x+1) + '" y="' + (y+1) + '" width="' + (cell-2) + '" height="' + (cell-2) + '" fill="' + color + '" rx="' + r + '"/>'; | |
| } | |
| // 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 += '<rect x="' + (x+1) + '" y="' + (y+1) + '" width="' + (cell-2) + '" height="' + (cell-2) + '" fill="' + color + '" rx="' + r + '"/>'; | |
| } | |
| svg += '</svg>'; | |
| return svg; | |
| } | |
| function renderAlgorithms(section, filterGroup) { | |
| currentSection = section; | |
| const data = OH_ALGORITHMS[section]; | |
| const container = document.getElementById('algorithm-section'); | |
| let html = `<h2>${data.title}</h2>`; | |
| html += `<p class="description">${data.description}</p>`; | |
| // 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 += '<div class="group-tabs">'; | |
| html += `<button class="group-tab ${!filterGroup ? 'active' : ''}" onclick="renderAlgorithms('${section}')">All</button>`; | |
| for (const g of groups) { | |
| const active = filterGroup === g ? 'active' : ''; | |
| html += `<button class="group-tab ${active}" onclick="renderAlgorithms('${section}','${g}')">${g}</button>`; | |
| } | |
| html += '</div>'; | |
| } | |
| // 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 += '<div class="alg-grid">'; | |
| for (const caseData of cases) { | |
| html += `<div class="alg-card">`; | |
| // 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 += `<div class="alg-card-header">`; | |
| html += `<div class="alg-diagram"><img src="${vcUrl}" alt="${caseData.name}" class="f2l-case-img" loading="lazy"></div>`; | |
| html += `<div class="alg-card-info">`; | |
| html += `<div class="case-name">${caseData.name}</div>`; | |
| html += `<h4>${caseData.case}</h4>`; | |
| html += `</div></div>`; | |
| } else if (section === 'f2l' && !firstAlg) { | |
| // Solved case (F2L 37) | |
| html += `<div class="alg-card-header">`; | |
| html += `<div class="alg-diagram"><img src="https://visualcube.api.cubing.net/visualcube.php?fmt=svg&size=150&bg=t&cc=black" alt="${caseData.name}" class="f2l-case-img" loading="lazy"></div>`; | |
| html += `<div class="alg-card-info">`; | |
| html += `<div class="case-name">${caseData.name}</div>`; | |
| html += `<h4>${caseData.case}</h4>`; | |
| html += `</div></div>`; | |
| } else { | |
| const diagram = generateTopViewSVG(section, caseData.name, caseData.case, firstAlg); | |
| if (diagram) { | |
| html += `<div class="alg-card-header">`; | |
| html += `<div class="alg-diagram">${diagram}</div>`; | |
| html += `<div class="alg-card-info">`; | |
| html += `<div class="case-name">${caseData.name}</div>`; | |
| html += `<h4>${caseData.case}</h4>`; | |
| html += `</div></div>`; | |
| } else { | |
| html += `<div class="case-name">${caseData.name}</div>`; | |
| html += `<h4>${caseData.case}</h4>`; | |
| } | |
| } | |
| for (const alg of caseData.algs) { | |
| const escaped = alg.notation.replace(/'/g, "\\'").replace(/\u2019/g, "\\'"); | |
| html += `<div class="alg-row">`; | |
| html += `<div class="alg-notation" onclick="setupAndSolve('${escaped}')" title="Click to setup case & watch the solve">${alg.notation}</div>`; | |
| html += `<div class="alg-meta">`; | |
| html += `<span>${alg.note}</span>`; | |
| html += `<span class="moves">${alg.moves} moves</span>`; | |
| html += `<button class="step-by-step-btn" onclick="startStepByStep('${escaped}')" title="Step through one move at a time">Step by Step</button>`; | |
| html += `</div>`; | |
| html += `</div>`; | |
| } | |
| html += `</div>`; | |
| } | |
| html += '</div>'; | |
| 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 | |