"use strict"; const FACES = ["U", "R", "F", "D", "L", "B"]; const FACE_NAMES = {U: "Top", R: "Right", F: "Front", D: "Bottom", L: "Left", B: "Back"}; const FACE_AXES = {U: "Y", D: "Y", R: "X", L: "X", F: "Z", B: "Z"}; const SCRAMBLE_SUFFIXES = ["", "'", "2"]; const DEMO_SCRAMBLE_LENGTH = 20; const COLOR_PRESETS = [ {name: "white", label: "White", hex: "#f4f4f5"}, {name: "yellow", label: "Yellow", hex: "#facc15"}, {name: "red", label: "Red", hex: "#ef4444"}, {name: "orange", label: "Orange", hex: "#f97316"}, {name: "blue", label: "Blue", hex: "#3b82f6"}, {name: "green", label: "Green", hex: "#22c55e"}, {name: "pink", label: "Pink", hex: "#ec4899"}, {name: "purple", label: "Purple", hex: "#a855f7"}, {name: "cyan", label: "Cyan", hex: "#06b6d4"}, {name: "teal", label: "Teal", hex: "#14b8a6"}, {name: "lime", label: "Lime", hex: "#84cc16"}, {name: "brown", label: "Brown", hex: "#92400e"}, {name: "black", label: "Black", hex: "#111827"}, {name: "gray", label: "Gray", hex: "#6b7280"}, {name: "magenta", label: "Magenta", hex: "#d946ef"}, {name: "maroon", label: "Maroon", hex: "#881337"}, {name: "navy", label: "Navy", hex: "#1e3a8a"}, {name: "gold", label: "Gold", hex: "#eab308"}, {name: "coral", label: "Coral", hex: "#fb7185"}, {name: "turquoise", label: "Turquoise", hex: "#2dd4bf"}, ]; const PRESET_MAP = Object.fromEntries(COLOR_PRESETS.map(color => [color.name, color])); let COLORS = [ {name: "blue", label: "Blue", hex: "#3b82f6"}, {name: "green", label: "Green", hex: "#22c55e"}, {name: "white", label: "White", hex: "#f4f4f5"}, {name: "pink", label: "Pink", hex: "#ec4899"}, {name: "yellow", label: "Yellow", hex: "#facc15"}, {name: "orange", label: "Orange", hex: "#f97316"}, ]; let COLOR_MAP = Object.fromEntries(COLORS.map(color => [color.name, color])); let SOLVED_COLORS = {U: "yellow", R: "orange", F: "white", D: "green", L: "blue", B: "pink"}; const TOPOLOGY_FACES = ["U", "R", "D", "L", "B"]; const CAMERA_PRESETS = { HOME: {x: -24, y: -36, label: "Free view"}, F: {x: -14, y: -8, label: "Front face"}, R: {x: -14, y: -82, label: "Right face"}, B: {x: -14, y: 172, label: "Back face"}, L: {x: -14, y: 82, label: "Left face"}, U: {x: -68, y: -10, label: "Top face"}, D: {x: 68, y: 10, label: "Bottom face"}, }; const CAMERA_UP_FACE = {F: "U", R: "U", B: "U", L: "U", U: "B", D: "F"}; const TURN_SPECS = { F: {axis: "Z", sign: 1, select: cubie => cubie.dataset.z === "1"}, B: {axis: "Z", sign: -1, select: cubie => cubie.dataset.z === "-1"}, R: {axis: "X", sign: 1, select: cubie => cubie.dataset.x === "1"}, L: {axis: "X", sign: -1, select: cubie => cubie.dataset.x === "-1"}, U: {axis: "Y", sign: -1, select: cubie => cubie.dataset.y === "1"}, D: {axis: "Y", sign: 1, select: cubie => cubie.dataset.y === "-1"}, }; const MOVE_TIMING = { announce: 650, cameraMin: 520, cameraMax: 1100, cameraPerDegree: 10, sameFaceCamera: 420, faceHold: 600, quarterTurn: 1350, halfTurn: 1800, settle: 420, betweenMoves: 520, }; const cube = Object.fromEntries(FACES.map(face => [face, Array(9).fill(null)])); function resetCubeToCenters() { FACES.forEach(face => { cube[face].fill(null); cube[face][4] = SOLVED_COLORS[face]; }); } resetCubeToCenters(); let inputMode = true; let solution = null; let frameIndex = 0; let selectedSticker = null; let camera = {...CAMERA_PRESETS.HOME}; let pointerStart = null; let dragging = false; let suppressStickerClick = false; let animating = false; let playing = false; let jumpVersion = 0; let menuGlobalListenersBound = false; let solverReady = false; let solverFailure = null; let solverWorker = null; let solverRequestId = 0; const solverPending = new Map(); let validationReturnFocus = null; const CUBE_INPUT_ERROR_CODES = new Set([ "input_type", "face_shape", "empty_sticker", "duplicate_centers", "color_multiplicity", "unknown_color", "piece_set", "corner_orientation", "edge_orientation", "corner_twist", "edge_flip", "parity", ]); const $ = id => document.getElementById(id); const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); const nextPaint = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; function randomInteger(maximum) { const values = new Uint32Array(1); const limit = 0x100000000 - (0x100000000 % maximum); do window.crypto.getRandomValues(values); while (values[0] >= limit); return values[0] % maximum; } function randomScramble(length = DEMO_SCRAMBLE_LENGTH) { const scramble = []; let previousAxis = null; for (let index = 0; index < length; index++) { const availableFaces = FACES.filter(face => FACE_AXES[face] !== previousAxis); const face = availableFaces[randomInteger(availableFaces.length)]; const suffix = SCRAMBLE_SUFFIXES[randomInteger(SCRAMBLE_SUFFIXES.length)]; scramble.push(`${face}${suffix}`); previousAxis = FACE_AXES[face]; } return scramble; } async function transitionTransform(element, transform, duration, easing = "cubic-bezier(.4,.01,.18,1)") { element.style.transition = `transform ${Math.round(duration)}ms ${easing}`; await nextPaint(); element.style.transform = transform; await sleep(Math.round(duration) + 24); } function colorInfo(value) { return COLOR_MAP[String(value || "").toLowerCase()] || {name: value, label: String(value), hex: "#94a3b8"}; } function safeColorHex(value) { return /^#[0-9a-f]{6}$/i.test(String(value)) ? String(value) : "#94a3b8"; } function axisOffset(value) { if (value === 0) return "0px"; return value > 0 ? "var(--step)" : "calc(var(--step) * -1)"; } function faceletIndex(face, x, y, z) { if (face === "U") return (z + 1) * 3 + (x + 1); if (face === "D") return (1 - z) * 3 + (x + 1); if (face === "F") return (1 - y) * 3 + (x + 1); if (face === "B") return (1 - y) * 3 + (1 - x); if (face === "R") return (1 - y) * 3 + (1 - z); return (1 - y) * 3 + (z + 1); } const FACE_OFFSETS = Object.fromEntries(FACES.map((face, index) => [face, index * 9])); const FACE_NORMALS = { U: [0, 1, 0], R: [1, 0, 0], F: [0, 0, 1], D: [0, -1, 0], L: [-1, 0, 0], B: [0, 0, -1], }; function stickerGeometry(face, index) { const row = Math.floor(index / 3); const column = index % 3; if (face === "U") return {position: [column - 1, 1, row - 1], normal: FACE_NORMALS.U}; if (face === "D") return {position: [column - 1, -1, 1 - row], normal: FACE_NORMALS.D}; if (face === "F") return {position: [column - 1, 1 - row, 1], normal: FACE_NORMALS.F}; if (face === "B") return {position: [1 - column, 1 - row, -1], normal: FACE_NORMALS.B}; if (face === "R") return {position: [1, 1 - row, 1 - column], normal: FACE_NORMALS.R}; return {position: [-1, 1 - row, column - 1], normal: FACE_NORMALS.L}; } function geometryKey(position, normal) { return `${position.join(",")}|${normal.join(",")}`; } const FACELET_LOOKUP = new Map(); FACES.forEach(face => { for (let index = 0; index < 9; index++) { const geometry = stickerGeometry(face, index); FACELET_LOOKUP.set(geometryKey(geometry.position, geometry.normal), FACE_OFFSETS[face] + index); } }); function rotateClockwise(vector, face) { const [x, y, z] = vector; if (face === "F") return [y, -x, z]; if (face === "B") return [-y, x, z]; if (face === "R") return [x, z, -y]; if (face === "L") return [x, -z, y]; if (face === "U") return [-z, y, x]; return [z, y, -x]; } const QUARTER_TURN_PERMUTATIONS = Object.fromEntries(FACES.map(face => { const normal = FACE_NORMALS[face]; const permutation = Array.from({length: 54}, (_, index) => index); FACES.forEach(sourceFace => { for (let sourceIndex = 0; sourceIndex < 9; sourceIndex++) { const source = FACE_OFFSETS[sourceFace] + sourceIndex; const geometry = stickerGeometry(sourceFace, sourceIndex); const onLayer = geometry.position[0] * normal[0] + geometry.position[1] * normal[1] + geometry.position[2] * normal[2] === 1; if (!onLayer) continue; const targetPosition = rotateClockwise(geometry.position, face); const targetNormal = rotateClockwise(geometry.normal, face); const target = FACELET_LOOKUP.get(geometryKey(targetPosition, targetNormal)); if (target === undefined) throw new Error(`Internal facelet mapping failed for ${face}.`); permutation[source] = target; } }); return [face, permutation]; })); function applyCanonicalMove(canonical, move) { if (typeof canonical !== "string" || canonical.length !== 54 || !/^[URFDLB](?:2|')?$/.test(move)) { throw new Error("The solution contained invalid cube data."); } const face = move[0]; const turns = move.endsWith("2") ? 2 : move.endsWith("'") ? 3 : 1; let state = canonical.split(""); const permutation = QUARTER_TURN_PERMUTATIONS[face]; for (let turn = 0; turn < turns; turn++) { const next = Array(54); for (let source = 0; source < 54; source++) next[permutation[source]] = state[source]; state = next; } return state.join(""); } function coloredFacesFromCanonical(canonical, faceToColor) { return Object.fromEntries(FACES.map((face, faceIndex) => [ face, [...canonical.slice(faceIndex * 9, faceIndex * 9 + 9)].map(value => faceToColor[value]), ])); } function moveInstruction(index, move) { const face = move[0]; const turns = move.endsWith("2") ? 2 : move.endsWith("'") ? -1 : 1; const text = Math.abs(turns) === 2 ? `Turn the ${FACE_NAMES[face]} face 180 degrees.` : `Turn the ${FACE_NAMES[face]} face ${turns > 0 ? "clockwise" : "counterclockwise"} 90 degrees.`; return { index, move, text, camera: {transition: "rotate_cube_to_face", front_face: face, up_face: CAMERA_UP_FACE[face], center_identity: face}, turn: {layer: face, reference: "clockwise_when_viewed_directly_at_the_face", clockwise_quarter_turns: turns, absolute_degrees: Math.abs(turns) === 2 ? 180 : 90}, before_frame: index - 1, after_frame: index, }; } function buildVerifiedSolution(workerResult) { const solved = FACES.map(face => face.repeat(9)).join(""); let canonical = workerResult.canonical; const frames = [{index: 0, last_move: null, canonical_facelets: canonical, faces: coloredFacesFromCanonical(canonical, workerResult.faceToColor), solved: canonical === solved}]; const instructions = []; workerResult.moves.forEach((move, moveIndex) => { canonical = applyCanonicalMove(canonical, move); frames.push({index: moveIndex + 1, last_move: move, canonical_facelets: canonical, faces: coloredFacesFromCanonical(canonical, workerResult.faceToColor), solved: canonical === solved}); instructions.push(moveInstruction(moveIndex + 1, move)); }); if (canonical !== solved) { throw new Error("Safety check failed: the proposed moves did not solve the entered cube. No steps were shown."); } return { ok: true, valid: true, solver: {algorithm: "deterministic_two_phase_worker", trained: false, deterministic_search: true, optimality_guaranteed: false, search_seconds: workerResult.searchSeconds}, moves: workerResult.moves, move_count: workerResult.moves.length, instructions, frames, final_canonical_facelets: canonical, solved: true, }; } function setRuntimeStatus(status, text) { const pill = $("runtimePill"); pill.classList.remove("ready", "error"); if (status === "ready") pill.classList.add("ready"); if (status === "error") pill.classList.add("error"); $("runtimeText").textContent = text; pill.title = status === "error" && solverFailure ? solverFailure : text; } function rejectPendingSolverRequests(message) { solverPending.forEach(({reject, timeout}) => { clearTimeout(timeout); reject(new Error(message)); }); solverPending.clear(); } function initializeSolver() { if (!("Worker" in window)) { solverFailure = "This browser does not support Web Workers. Please use a current Chrome, Edge, Firefox, or Safari."; setRuntimeStatus("error", "Browser unsupported"); updateEntryStatus(); return; } setRuntimeStatus("preparing", "Preparing browser solver…"); const workerUrl = new URL("./solver-worker.js", window.location.href); workerUrl.searchParams.set("v", "20260810-cube-v2"); const privateSignature = new URLSearchParams(window.location.search).get("__sign"); if (privateSignature) workerUrl.searchParams.set("__sign", privateSignature); solverWorker = new Worker(workerUrl); solverWorker.addEventListener("message", event => { const message = event.data || {}; if (message.type === "status") { if (message.status === "ready") { solverReady = true; solverFailure = null; setRuntimeStatus("ready", "Solver ready · on-device"); } else if (message.status === "error") { solverReady = false; solverFailure = message.message || "The browser solver could not initialize."; console.error(solverFailure); setRuntimeStatus("error", "Solver unavailable"); rejectPendingSolverRequests(solverFailure); } else { setRuntimeStatus("preparing", message.message || "Building solve tables…"); } updateEntryStatus(); return; } if (message.type !== "result") return; const pending = solverPending.get(message.id); if (!pending) return; clearTimeout(pending.timeout); solverPending.delete(message.id); if (message.ok) pending.resolve(message); else pending.reject(Object.assign(new Error(message.error?.message || "The cube could not be solved."), {code: message.error?.code})); }); solverWorker.addEventListener("error", event => { solverReady = false; const detail = event && event.message ? ` (${event.message})` : ""; solverFailure = `The browser solver stopped unexpectedly${detail}. Reload the page to try again.`; console.error(solverFailure); setRuntimeStatus("error", "Solver unavailable"); rejectPendingSolverRequests(solverFailure); updateEntryStatus(); }); solverWorker.postMessage({type: "init"}); } function requestWorkerSolve(cubeInput) { return new Promise((resolve, reject) => { if (!solverReady || !solverWorker) { reject(new Error(solverFailure || "The browser solver is still preparing. Please wait a moment.")); return; } const id = ++solverRequestId; const timeout = setTimeout(() => { solverPending.delete(id); reject(new Error("This solve took too long. Please reload and recheck the entered cube.")); }, 60000); solverPending.set(id, {resolve, reject, timeout}); solverWorker.postMessage({type: "solve", id, cube: cubeInput}); }); } function stickerPositionName(index) { if (index === 4) return "center"; const rows = ["top", "middle", "bottom"]; const columns = ["left", "middle", "right"]; return `${rows[Math.floor(index / 3)]} ${columns[index % 3]}`; } function addPlasticFaces(cubie) { FACES.forEach(face => { const side = document.createElement("div"); side.className = `plastic-face surface-${face}`; cubie.append(side); }); } function setStickerAppearance(sticker, value) { if (!value) { sticker.style.background = "linear-gradient(145deg, #343947, #242833)"; sticker.dataset.color = ""; return; } const color = colorInfo(value); sticker.style.background = `linear-gradient(145deg, color-mix(in srgb, ${color.hex}, white 14%), ${color.hex} 58%, color-mix(in srgb, ${color.hex}, black 12%))`; sticker.dataset.color = color.name; } function addSticker(cubie, face, index, state, interactive) { const sticker = document.createElement("button"); const value = state[face][index]; const editable = interactive && index !== 4; sticker.className = `cube-sticker surface-${face}${editable ? "" : " locked"}${interactive && index === 4 ? " center-fixed" : ""}`; sticker.type = "button"; sticker.dataset.face = face; sticker.dataset.index = String(index); sticker.setAttribute("aria-label", `${FACE_NAMES[face]} face, ${stickerPositionName(index)} sticker${value ? `, ${colorInfo(value).label}` : ", uncolored"}${interactive && index === 4 ? ", fixed by cube setup" : ""}`); setStickerAppearance(sticker, value); if (editable) { sticker.addEventListener("click", event => { if (suppressStickerClick || dragging) return; openColorMenu(face, index, event); }); } cubie.append(sticker); } function renderCube(state = cube, interactive = inputMode) { const root = $("cube3d"); root.replaceChildren(); for (let x = -1; x <= 1; x++) { for (let y = -1; y <= 1; y++) { for (let z = -1; z <= 1; z++) { const cubie = document.createElement("div"); cubie.className = "cubie"; cubie.dataset.x = String(x); cubie.dataset.y = String(y); cubie.dataset.z = String(z); cubie.style.transform = `translate3d(${axisOffset(x)}, ${axisOffset(-y)}, ${axisOffset(z)})`; addPlasticFaces(cubie); if (y === 1) addSticker(cubie, "U", faceletIndex("U", x, y, z), state, interactive); if (y === -1) addSticker(cubie, "D", faceletIndex("D", x, y, z), state, interactive); if (z === 1) addSticker(cubie, "F", faceletIndex("F", x, y, z), state, interactive); if (z === -1) addSticker(cubie, "B", faceletIndex("B", x, y, z), state, interactive); if (x === 1) addSticker(cubie, "R", faceletIndex("R", x, y, z), state, interactive); if (x === -1) addSticker(cubie, "L", faceletIndex("L", x, y, z), state, interactive); root.append(cubie); } } } } function applyCamera(animated = true) { const rig = $("cubeRig"); rig.classList.toggle("no-transition", !animated); rig.style.transform = `rotateX(${camera.x}deg) rotateY(${camera.y}deg)`; } function chooseView(face, animated = true) { const preset = CAMERA_PRESETS[face] || CAMERA_PRESETS.HOME; camera = {...preset}; applyCamera(animated); $("viewLabel").textContent = preset.label; document.querySelectorAll("[data-view]").forEach(button => button.classList.toggle("active", button.dataset.view === face)); } function nearestAngle(current, target) { let candidate = target; while (candidate - current > 180) candidate -= 360; while (candidate - current < -180) candidate += 360; return candidate; } function cameraTransform(value) { return `rotateX(${value.x}deg) rotateY(${value.y}deg)`; } function cameraSegmentDuration(start, target) { const distance = Math.hypot(target.x - start.x, target.y - start.y); return Math.round(Math.max(MOVE_TIMING.cameraMin, Math.min(MOVE_TIMING.cameraMax, distance * MOVE_TIMING.cameraPerDegree))); } function sameCameraPosition(left, right) { return Math.hypot(left.x - right.x, left.y - right.y) < .6; } function cameraTravelStages(start, target) { const stages = []; let current = {...start}; const sidePitch = -14; const startIsVertical = Math.abs(current.x) > 48; const targetIsVertical = Math.abs(target.x) > 48; // Human hands reorient a cube around one principal axis at a time. First // return a top/bottom view to a side view, then yaw to the requested face, // and only then pitch into a new top/bottom view. if (startIsVertical) { current = {x: sidePitch, y: current.y}; stages.push(current); } if (Math.abs(target.y - current.y) >= .6) { current = {x: targetIsVertical ? sidePitch : target.x, y: target.y}; stages.push(current); } if (Math.abs(target.x - current.x) >= .6) stages.push({...target}); if (!stages.length && !sameCameraPosition(start, target)) stages.push({...target}); return stages; } async function animateCameraToFace(face) { const preset = CAMERA_PRESETS[face] || CAMERA_PRESETS.HOME; const start = {...camera}; const target = { ...preset, x: nearestAngle(start.x, preset.x), y: nearestAngle(start.y, preset.y), }; camera = target; $("viewLabel").textContent = preset.label; document.querySelectorAll("[data-view]").forEach(button => button.classList.toggle("active", button.dataset.view === face)); const rig = $("cubeRig"); rig.classList.add("no-transition"); const vertical = face === "U" || face === "D"; $("cubeViewport").dataset.travelAxis = vertical ? "vertical" : "horizontal"; rig.style.transform = cameraTransform(start); await nextPaint(); if (sameCameraPosition(start, target)) { await sleep(MOVE_TIMING.sameFaceCamera); } else { let segmentStart = start; for (const stage of cameraTravelStages(start, target)) { await transitionTransform(rig, cameraTransform(stage), cameraSegmentDuration(segmentStart, stage), "cubic-bezier(.25,.72,.28,1)"); segmentStart = stage; } } rig.style.transform = cameraTransform(target); rig.style.transition = ""; } function initializeCameraControls() { const viewport = $("cubeViewport"); viewport.addEventListener("pointerdown", event => { if (animating || event.button !== 0) return; pointerStart = {x: event.clientX, y: event.clientY, cameraX: camera.x, cameraY: camera.y}; dragging = false; }); viewport.addEventListener("pointermove", event => { if (!pointerStart || animating) return; const dx = event.clientX - pointerStart.x; const dy = event.clientY - pointerStart.y; if (Math.hypot(dx, dy) < 5 && !dragging) return; dragging = true; if (!viewport.hasPointerCapture(event.pointerId)) viewport.setPointerCapture(event.pointerId); suppressStickerClick = true; viewport.classList.add("dragging"); camera.x = Math.max(-88, Math.min(88, pointerStart.cameraX - dy * .42)); camera.y = pointerStart.cameraY + dx * .46; camera.label = "Free view"; $("viewLabel").textContent = "Free view"; document.querySelectorAll("[data-view]").forEach(button => button.classList.remove("active")); applyCamera(false); }); const finishDrag = event => { if (!pointerStart) return; if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId); pointerStart = null; viewport.classList.remove("dragging"); if (dragging) setTimeout(() => suppressStickerClick = false, 80); dragging = false; }; viewport.addEventListener("pointerup", finishDrag); viewport.addEventListener("pointercancel", finishDrag); document.querySelectorAll("[data-view]").forEach(button => button.addEventListener("click", () => { if (!animating) chooseView(button.dataset.view); })); } function buildColorMenu() { const options = $("colorOptions"); options.replaceChildren(); COLORS.forEach(color => { const button = document.createElement("button"); button.className = "color-option"; button.dataset.color = color.name; const swatch = document.createElement("i"); swatch.style.background = safeColorHex(color.hex); const label = document.createElement("span"); label.textContent = color.label; const count = document.createElement("b"); count.className = "color-option-count"; button.append(swatch, label, count); options.append(button); }); $("colorOptions").querySelectorAll(".color-option").forEach(button => button.addEventListener("click", () => { if (!selectedSticker || button.disabled) return; cube[selectedSticker.face][selectedSticker.index] = button.dataset.color; closeColorMenu(); renderCube(cube, true); updateEntryStatus(); })); if (!menuGlobalListenersBound) { menuGlobalListenersBound = true; $("removeColor").addEventListener("click", () => { if (!selectedSticker) return; cube[selectedSticker.face][selectedSticker.index] = null; closeColorMenu(); renderCube(cube, true); updateEntryStatus(); }); $("closeColorMenu").addEventListener("click", closeColorMenu); document.addEventListener("pointerdown", event => { if (!$("colorMenu").hidden && !$("colorMenu").contains(event.target) && !event.target.closest(".cube-sticker")) closeColorMenu(); }); document.addEventListener("keydown", event => { if (event.key === "Escape" && !$("validationModal").hidden) { closeValidationModal(); return; } if (event.key === "Escape") closeColorMenu(); if (!solution) return; if (event.key === "ArrowRight") { event.preventDefault(); stepSolution(1); } if (event.key === "ArrowLeft") { event.preventDefault(); stepSolution(-1); } }); } } function buildSchemeEditor() { const palette = $("paletteEditor"); palette.replaceChildren(); COLORS.forEach(color => { const row = document.createElement("label"); row.className = "palette-slot"; row.dataset.color = color.name; const swatch = document.createElement("i"); const select = document.createElement("select"); select.dataset.paletteChoice = color.name; select.setAttribute("aria-label", `${color.label} palette color`); const currentPreset = COLOR_PRESETS.find(preset => preset.label.toLowerCase() === color.label.toLowerCase()) || PRESET_MAP[color.name] || COLOR_PRESETS[0]; const choices = color.name === "white" ? [PRESET_MAP.white] : COLOR_PRESETS.filter(preset => preset.name !== "white"); choices.forEach(preset => { const option = document.createElement("option"); option.value = preset.name; option.textContent = preset.label; option.selected = preset.name === currentPreset.name; select.append(option); }); if (color.name === "white") { select.disabled = true; select.title = "White is the fixed front reference."; } row.append(swatch, select); palette.append(row); }); const topology = $("topologyGrid"); topology.replaceChildren(); TOPOLOGY_FACES.forEach(face => { const field = document.createElement("label"); field.innerHTML = `${FACE_NAMES[face]} center`; const select = document.createElement("select"); select.dataset.topologyFace = face; select.setAttribute("aria-label", `${FACE_NAMES[face]} center color`); COLORS.filter(color => color.name !== "white").forEach(color => { const option = document.createElement("option"); option.value = color.name; option.textContent = color.label; option.selected = SOLVED_COLORS[face] === color.name; select.append(option); }); field.append(select); topology.append(field); }); palette.querySelectorAll("select").forEach(select => select.addEventListener("change", previewSchemeEditor)); previewSchemeEditor(); } function previewSchemeEditor() { const draftColors = Object.fromEntries(COLORS.map(color => { const select = document.querySelector(`[data-palette-choice="${color.name}"]`); const preset = PRESET_MAP[select?.value] || color; const swatch = document.querySelector(`.palette-slot[data-color="${color.name}"] > i`); if (swatch) swatch.style.background = safeColorHex(preset.hex); return [color.name, preset]; })); document.querySelectorAll("[data-topology-face]").forEach(select => { [...select.options].forEach(option => option.textContent = draftColors[option.value]?.label || option.value); }); $("referenceSwatch").style.background = safeColorHex(draftColors.white?.hex || COLOR_MAP.white.hex); } function schemeDescription() { return `${colorInfo(SOLVED_COLORS.F).label} front · ${colorInfo(SOLVED_COLORS.U).label} top · ${colorInfo(SOLVED_COLORS.R).label} right`; } function applyScheme() { const selectedPresets = {}; COLORS.forEach(color => { const select = document.querySelector(`[data-palette-choice="${color.name}"]`); selectedPresets[color.name] = PRESET_MAP[select.value]; }); if (new Set(Object.values(selectedPresets).map(preset => preset.name)).size !== 6) { $("schemeError").textContent = "Choose six different colors—each color can be used only once."; return; } const nextTopology = {F: "white"}; document.querySelectorAll("[data-topology-face]").forEach(select => nextTopology[select.dataset.topologyFace] = select.value); if (new Set(Object.values(nextTopology)).size !== 6) { $("schemeError").textContent = "Each center color must be used exactly once in the topology."; return; } COLORS = COLORS.map(color => ({...color, label: selectedPresets[color.name].label, hex: selectedPresets[color.name].hex})); COLOR_MAP = Object.fromEntries(COLORS.map(color => [color.name, color])); SOLVED_COLORS = nextTopology; $("schemeError").textContent = ""; $("schemeSummary").textContent = schemeDescription(); buildColorMenu(); resetCubeToCenters(); closeColorMenu(); renderCube(cube, true); updateEntryStatus(); hideEntryMessage(); chooseView("HOME"); $("schemeCard").open = false; showEntryMessage("Cube setup applied. Keep White facing you while copying stickers.", "success"); } function openColorMenu(face, index, event) { if (!inputMode) return; selectedSticker = {face, index}; updateColorOptionAvailability(); document.querySelectorAll(".cube-sticker").forEach(sticker => sticker.classList.toggle("selected", sticker.dataset.face === face && Number(sticker.dataset.index) === index)); $("colorMenuTitle").textContent = `${FACE_NAMES[face]} · ${stickerPositionName(index)}`; const menu = $("colorMenu"); menu.hidden = false; const width = 285; const approximateHeight = 220; menu.style.left = `${Math.max(12, Math.min(window.innerWidth - width - 12, event.clientX + 14))}px`; menu.style.top = `${Math.max(12, Math.min(window.innerHeight - approximateHeight - 12, event.clientY - 40))}px`; const firstOption = menu.querySelector(".color-option"); if (firstOption) firstOption.focus({preventScroll: true}); } function closeColorMenu() { $("colorMenu").hidden = true; selectedSticker = null; document.querySelectorAll(".cube-sticker.selected").forEach(sticker => sticker.classList.remove("selected")); } function colorCounts() { const counts = Object.fromEntries(COLORS.map(color => [color.name, 0])); let filled = 0; FACES.forEach(face => cube[face].forEach(value => { if (!value) return; filled++; counts[value] = (counts[value] || 0) + 1; })); return {counts, filled}; } function updateColorOptionAvailability() { const {counts} = colorCounts(); const currentColor = selectedSticker ? cube[selectedSticker.face][selectedSticker.index] : null; $("colorOptions").querySelectorAll(".color-option").forEach(button => { const color = button.dataset.color; const isCurrent = color === currentColor; const isFull = counts[color] >= 9; const unavailable = isFull && !isCurrent; button.disabled = unavailable; button.classList.toggle("current", isCurrent); button.classList.toggle("exhausted", unavailable); button.setAttribute("aria-disabled", String(unavailable)); button.querySelector(".color-option-count").textContent = `${counts[color]}/9`; button.title = unavailable ? `${colorInfo(color).label} is already used on all nine stickers.` : isCurrent ? `${colorInfo(color).label} is the current sticker color.` : ""; }); } function updateEntryStatus() { const {counts, filled} = colorCounts(); const percent = Math.round(filled / 54 * 100); $("progressLabel").textContent = `${filled} of 54 stickers`; $("progressPercent").textContent = `${percent}%`; $("entryProgress").style.width = `${percent}%`; const centers = FACES.map(face => cube[face][4]).filter(Boolean); const distinctCenters = centers.length === 6 && new Set(centers).size === 6; const balanced = COLORS.every(color => counts[color.name] === 9); const ready = filled === 54 && balanced && distinctCenters; $("solveBtn").disabled = !ready || !solverReady; const message = $("guideMessage").querySelector("p"); if (centers.length < 6) message.textContent = `Start with the six center stickers. ${6 - centers.length} center${6 - centers.length === 1 ? "" : "s"} still need a color.`; else if (!distinctCenters) message.textContent = "Each center needs a different color. Click a repeated center to change it."; else if (filled < 54) message.textContent = `Centers are done—great. Keep clicking stickers; ${54 - filled} remain.`; else if (!balanced) message.textContent = "Almost there. Open a sticker and check the color counts inside the picker."; else if (!solverReady) message.textContent = "Everything is filled correctly. The on-device solver is still preparing its lookup tables."; else message.textContent = "Everything is filled correctly. Click Solve my cube for the physical check and solution."; } function showEntryMessage(text, kind = "error") { const element = $("entryMessage"); element.textContent = text; element.className = `status-message show ${kind}`; } function hideEntryMessage() { $("entryMessage").className = "status-message"; } function showValidationModal(message) { closeColorMenu(); validationReturnFocus = document.activeElement; $("validationMessage").textContent = message; $("validationModal").hidden = false; document.body.classList.add("modal-open"); requestAnimationFrame(() => $("validationReviewBtn").focus({preventScroll: true})); } function closeValidationModal() { if ($("validationModal").hidden) return; $("validationModal").hidden = true; document.body.classList.remove("modal-open"); if (validationReturnFocus && typeof validationReturnFocus.focus === "function") { validationReturnFocus.focus({preventScroll: true}); } validationReturnFocus = null; } function replaceCubeState(nextState) { FACES.forEach(face => cube[face] = [...nextState[face]].flat(Infinity)); renderCube(cube, true); updateEntryStatus(); } function loadSolvedCube() { const state = Object.fromEntries(FACES.map(face => [face, Array(9).fill(SOLVED_COLORS[face])])); replaceCubeState(state); hideEntryMessage(); chooseView("HOME"); } function loadRandomDemoCube() { hideEntryMessage(); try { const scramble = randomScramble(); let canonical = FACES.map(face => face.repeat(9)).join(""); scramble.forEach(move => canonical = applyCanonicalMove(canonical, move)); replaceCubeState(coloredFacesFromCanonical(canonical, SOLVED_COLORS)); showEntryMessage(`Random demo loaded: ${scramble.join(" ")}`, "success"); chooseView("HOME"); } catch (error) { showEntryMessage(error.message || "A random demo cube could not be loaded."); } } function clearCube() { resetCubeToCenters(); closeColorMenu(); renderCube(cube, true); updateEntryStatus(); hideEntryMessage(); chooseView("HOME"); } async function solveCube() { hideEntryMessage(); const button = $("solveBtn"); if (button.disabled) return; button.disabled = true; button.classList.add("busy"); try { const workerResult = await requestWorkerSolve(Object.fromEntries(FACES.map(face => [face, [...cube[face]]]))); const result = buildVerifiedSolution(workerResult); enterSolutionMode(result); } catch (error) { const message = error.message || "The solver request failed. Please try again."; if (CUBE_INPUT_ERROR_CODES.has(error.code)) showValidationModal(message); else showEntryMessage(message); } finally { button.classList.remove("busy"); updateEntryStatus(); } } function frameState(frame) { return Object.fromEntries(FACES.map(face => [face, [...frame.faces[face]].flat(Infinity)])); } function enterSolutionMode(result) { solution = result; frameIndex = 0; inputMode = false; playing = false; closeColorMenu(); $("entryPanel").hidden = true; $("solutionPanel").hidden = false; $("timelineCard").hidden = false; $("stageTitle").textContent = "Watch the cube solve"; $("dragHint").innerHTML = "↔ Drag to inspect between turns"; $("solutionSummary").textContent = result.move_count ? `${result.move_count}-move solution ready` : "Already solved"; $("moveCount").textContent = String(result.move_count); $("searchTime").textContent = `${Number(result.solver.search_seconds).toFixed(3)}s`; $("timeline").innerHTML = result.moves.length ? result.moves.map((move, index) => ``).join("") : "No turns needed"; $("timeline").querySelectorAll(".move-chip").forEach(chip => chip.addEventListener("click", () => playToStep(Number(chip.dataset.step)))); renderCube(frameState(result.frames[0]), false); clearMoveCoach(); chooseView("HOME"); updateSolutionUI(); $("solutionPanel").scrollIntoView({behavior: "smooth", block: "nearest"}); } function exitSolutionMode() { stopPlayback(); solution = null; frameIndex = 0; inputMode = true; resetCubeToCenters(); $("entryPanel").hidden = false; $("solutionPanel").hidden = true; $("timelineCard").hidden = true; $("stageTitle").textContent = "Enter your cube"; clearMoveCoach(); $("dragHint").innerHTML = "↔ Drag anywhere to rotate"; renderCube(cube, true); chooseView("HOME"); updateEntryStatus(); } function updateSolutionUI() { if (!solution) return; const total = solution.move_count; $("stepCount").textContent = `${frameIndex} / ${total}`; $("playerProgress").style.width = `${total ? frameIndex / total * 100 : 100}%`; $("prevBtn").disabled = animating || frameIndex === 0; $("nextBtn").disabled = animating || frameIndex === total; $("playBtn").disabled = animating || total === 0; $("playBtn").textContent = playing ? "Ⅱ" : "▶"; $("playBtn").setAttribute("aria-label", playing ? "Pause solution" : "Play solution"); if (frameIndex === 0) { $("stepNumber").textContent = total ? "Starting position" : "Solved cube"; $("moveSymbol").textContent = total ? "Ready" : "Solved"; $("moveDirection").textContent = total ? "Your cube before the first move." : "No turns are needed."; $("humanInstruction").textContent = total ? "Press Next or Play. The cube will face the correct side, then turn that layer." : "This cube is already solved."; } else { const instruction = solution.instructions[frameIndex - 1]; const turns = instruction.turn.clockwise_quarter_turns; $("stepNumber").textContent = `Step ${frameIndex} of ${total}`; $("moveSymbol").textContent = instruction.move; $("moveDirection").textContent = `${FACE_NAMES[instruction.camera.front_face]} face · ${Math.abs(turns) === 2 ? "half turn" : turns > 0 ? "clockwise" : "counterclockwise"}`; $("humanInstruction").textContent = instruction.text; } $("timeline").querySelectorAll(".move-chip").forEach((chip, index) => { chip.classList.toggle("active", index + 1 === frameIndex); chip.classList.toggle("done", index + 1 < frameIndex); if (index + 1 === frameIndex) chip.scrollIntoView({behavior: "smooth", block: "nearest", inline: "center"}); }); } function setMotionPhase(phase, label) { $("cubeViewport").dataset.motionPhase = phase; $("coachPhase").textContent = label; } function clearMoveCoach() { $("cubeViewport").dataset.motionPhase = "idle"; delete $("cubeViewport").dataset.travelAxis; $("moveCoach").hidden = true; $("turnGuide").hidden = true; document.querySelectorAll(".cube-sticker.active-turn-face").forEach(sticker => sticker.classList.remove("active-turn-face")); } function effectiveTurns(instruction, direction) { return instruction.turn.clockwise_quarter_turns * direction; } function turnDescription(turns) { if (Math.abs(turns) === 2) return {short: "180° HALF TURN", direction: "a half turn (180°)", arrow: "180°"}; if (turns > 0) return {short: "CLOCKWISE", direction: "clockwise by 90°", arrow: "↻"}; return {short: "COUNTERCLOCKWISE", direction: "counterclockwise by 90°", arrow: "↺"}; } function announceMove(instruction, direction, target) { const face = instruction.turn.layer; const detail = turnDescription(effectiveTurns(instruction, direction)); const action = direction > 0 ? "Next move" : "Undo move"; const text = `${action}: turn the ${FACE_NAMES[face]} face ${detail.direction}.`; $("moveCoach").hidden = false; $("coachFace").textContent = `${FACE_NAMES[face]} FACE`; $("coachInstruction").textContent = text; $("turnArrow").textContent = detail.arrow; $("turnGuideLabel").textContent = detail.short; $("stepNumber").textContent = `${action} · ${Math.max(frameIndex, target)} of ${solution.move_count}`; $("moveSymbol").textContent = instruction.move; $("moveDirection").textContent = `${FACE_NAMES[face]} face · ${detail.direction}`; $("humanInstruction").textContent = text; setMotionPhase("announce", `${instruction.move} · watch first`); } function highlightTurnFace(face, enabled = true) { document.querySelectorAll(`.cube-sticker[data-face="${face}"]`).forEach(sticker => sticker.classList.toggle("active-turn-face", enabled)); } async function animateLayer(instruction, direction, duration) { const face = instruction.turn.layer; const spec = TURN_SPECS[face]; const layer = document.createElement("div"); layer.className = "turn-layer"; layer.dataset.face = face; layer.dataset.axis = spec.axis; const cubies = [...$("cube3d").querySelectorAll(":scope > .cubie")].filter(spec.select); cubies.forEach(cubie => layer.append(cubie)); $("cube3d").append(layer); const quarters = instruction.turn.clockwise_quarter_turns * direction; const degrees = spec.sign * quarters * 90; const targetTransform = `rotate${spec.axis}(${degrees}deg)`; layer.style.transform = `rotate${spec.axis}(0deg)`; await transitionTransform(layer, targetTransform, duration, "cubic-bezier(.42,0,.18,1)"); layer.style.transform = targetTransform; } async function stepSolution(direction) { if (!solution || animating) return false; const target = frameIndex + direction; if (target < 0 || target > solution.move_count) return false; animating = true; updateSolutionUI(); const instructionIndex = direction > 0 ? frameIndex : target; const instruction = solution.instructions[instructionIndex]; const turns = effectiveTurns(instruction, direction); const motionScale = reduceMotion ? .82 : 1; const turnDuration = Math.round((Math.abs(turns) === 2 ? MOVE_TIMING.halfTurn : MOVE_TIMING.quarterTurn) * motionScale); try { announceMove(instruction, direction, target); await sleep(Math.round(MOVE_TIMING.announce * motionScale)); setMotionPhase("camera", `Slowly moving to the ${FACE_NAMES[instruction.camera.front_face]} face`); await animateCameraToFace(instruction.camera.front_face); highlightTurnFace(instruction.turn.layer, true); $("turnGuide").hidden = false; setMotionPhase("ready", "This highlighted layer will turn"); await sleep(Math.round(MOVE_TIMING.faceHold * motionScale)); setMotionPhase("turning", `Now turn ${turnDescription(turns).direction}`); await animateLayer(instruction, direction, turnDuration); setMotionPhase("settling", "Turn complete · hold this position"); await sleep(Math.round(MOVE_TIMING.settle * motionScale)); frameIndex = target; renderCube(frameState(solution.frames[frameIndex]), false); highlightTurnFace(instruction.turn.layer, true); await sleep(180); } finally { animating = false; clearMoveCoach(); updateSolutionUI(); } return true; } function stopPlayback() { playing = false; jumpVersion++; updateSolutionUI(); } async function togglePlayback() { if (!solution || animating) return; if (playing) { stopPlayback(); return; } if (frameIndex >= solution.move_count) { frameIndex = 0; renderCube(frameState(solution.frames[0]), false); } playing = true; const version = ++jumpVersion; updateSolutionUI(); while (playing && version === jumpVersion && frameIndex < solution.move_count) { await stepSolution(1); if (playing) await sleep(Math.round(MOVE_TIMING.betweenMoves * (reduceMotion ? .82 : 1))); } if (version === jumpVersion) { playing = false; updateSolutionUI(); } } async function playToStep(target) { if (!solution || animating || target === frameIndex) return; playing = false; const version = ++jumpVersion; const direction = target > frameIndex ? 1 : -1; while (version === jumpVersion && frameIndex !== target) { await stepSolution(direction); if (frameIndex !== target) await sleep(Math.round(MOVE_TIMING.betweenMoves * .7)); } } function initialize() { resetCubeToCenters(); document.body.dataset.appVersion = "cube-solver-v2"; buildSchemeEditor(); buildColorMenu(); initializeCameraControls(); $("clearBtn").addEventListener("click", clearCube); $("solvedBtn").addEventListener("click", loadSolvedCube); $("demoBtn").addEventListener("click", loadRandomDemoCube); $("solveBtn").addEventListener("click", solveCube); $("prevBtn").addEventListener("click", () => { stopPlayback(); stepSolution(-1); }); $("nextBtn").addEventListener("click", () => { stopPlayback(); stepSolution(1); }); $("playBtn").addEventListener("click", togglePlayback); $("startOverBtn").addEventListener("click", exitSolutionMode); $("applySchemeBtn").addEventListener("click", applyScheme); $("validationCloseBtn").addEventListener("click", closeValidationModal); $("validationReviewBtn").addEventListener("click", closeValidationModal); $("validationBackdrop").addEventListener("click", closeValidationModal); renderCube(cube, true); chooseView("HOME", false); updateEntryStatus(); initializeSolver(); } initialize();