Cube_One_Handed / app.js
AK51's picture
Upload 2 files
f324dcf verified
Raw
History Blame Contribute Delete
51.2 kB
// ============================================================
// 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">&#x2630; 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()">&#x25C0; Prev</button>';
}
if (isComplete) {
html += '<button class="step-btn step-btn-done" onclick="cancelStepMode()">&#x2714; Done</button>';
} else {
html += '<button class="step-btn step-btn-next" onclick="executeNextStep()">Next Step &#x25B6;</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 = '&#x25B2; Hide';
btn.title = 'Collapse 3D view for more algorithm space';
onResize();
} else {
cubeSection.classList.remove('fullscreen');
cubeSection.classList.add('collapsed');
btn.innerHTML = '&#x25BC; 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 = '&#x26F6; Full';
fsBtn.title = 'Full screen 3D view';
onResize();
} else {
cubeSection.classList.remove('collapsed');
cubeSection.classList.add('fullscreen');
fsBtn.innerHTML = '&#x2716; 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 = '&#x25B2; Hide';
}
}
// Startup is handled by index.html after JSON files load