Spaces:
Running
Running
Download app.js from User01110/rubik_s-cube-solver: direct link, hf CLI and curl.
- Browser
- Download file 45.5 kB
-
https://huggingface.co/spaces/User01110/rubik_s-cube-solver/resolve/main/app.js
- Command line
-
hf download hf://spaces/User01110/rubik_s-cube-solver/app.js
-
curl -L -o app.js https://huggingface.co/spaces/User01110/rubik_s-cube-solver/resolve/main/app.js
45.5 kB
| ; | |
| 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 = `<span>${FACE_NAMES[face]} center</span>`; | |
| 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 = "<span>↔</span> 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) => `<button class="move-chip" data-step="${index + 1}" title="Play through step ${index + 1}">${move}</button>`).join("") : "<span>No turns needed</span>"; | |
| $("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 = "<span>↔</span> 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(); | |