From fccc43d0ebfba4697bc6f4aa2535fed00d375727 Mon Sep 17 00:00:00 2001 From: Grzegorz Kucmierz Date: Tue, 24 Feb 2026 11:37:37 +0000 Subject: [PATCH] fix: resolve middle slice state sync and zero-step drag freeze --- src/components/renderers/SmartCube.vue | 253 +++++++++++++----- src/composables/useCube.js | 13 + src/composables/useSettings.js | 4 +- .../{animationSettings.js => settings.js} | 2 + src/utils/CubeLogicAdapter.js | 9 + src/workers/Cube.worker.js | 17 +- src/workers/Solver.worker.js | 4 +- test_beginner_solver.js | 21 ++ 8 files changed, 258 insertions(+), 65 deletions(-) rename src/config/{animationSettings.js => settings.js} (50%) create mode 100644 test_beginner_solver.js diff --git a/src/components/renderers/SmartCube.vue b/src/components/renderers/SmartCube.vue index 990c87d..c19677f 100644 --- a/src/components/renderers/SmartCube.vue +++ b/src/components/renderers/SmartCube.vue @@ -2,13 +2,13 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue"; import { useCube } from "../../composables/useCube"; import { useSettings } from "../../composables/useSettings"; -import { LAYER_ANIMATION_DURATION } from "../../config/animationSettings"; +import { LAYER_ANIMATION_DURATION, MIDDLE_SLICES_ENABLED } from "../../config/settings"; import CubeMoveControls from "./CubeMoveControls.vue"; import MoveHistoryPanel from "./MoveHistoryPanel.vue"; import { DeepCube } from "../../utils/DeepCube.js"; import { showToast } from "../../utils/toastHelper.js"; -const { cubies, initCube, rotateLayer, turn, FACES, solve, solveResult, solveError, isSolverReady } = useCube(); +const { cubies, deepCubeState, initCube, rotateLayer, rotateSlice, turn, FACES, solve, solveResult, solveError, isSolverReady } = useCube(); const { isCubeTranslucent } = useSettings(); // --- Visual State --- @@ -45,6 +45,18 @@ const rotateYMatrix = (deg) => { ]; }; +const rotateZMatrix = (deg) => { + const rad = (deg * Math.PI) / 180; + const c = Math.cos(rad); + const s = Math.sin(rad); + return [ + c, s, 0, 0, + -s, c, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1 + ]; +}; + const multiplyMatrices = (a, b) => { const result = new Array(16).fill(0); for (let r = 0; r < 4; r++) { @@ -145,11 +157,18 @@ const cross = (a, b) => ({ const project = (v) => { const m = viewMatrix.value; // Apply rotation matrix: v' = M * v - // (Ignoring translation/w for pure rotation projection) - const x = v.x * m[0] + v.y * m[4] + v.z * m[8]; - const y = v.x * m[1] + v.y * m[5] + v.z * m[9]; + // However, `v` is in strictly Right-Handed Math Coordinates (Y is UP). + // `viewMatrix` operates strictly in CSS Coordinates (Y is DOWN). + // We must apply a space transformation T^-1 * M * T to maintain correct projection chirality. + const cssY = -v.y; + + const x = v.x * m[0] + cssY * m[4] + v.z * m[8]; + const projY = v.x * m[1] + cssY * m[5] + v.z * m[9]; + + const mathY = -projY; + // z ignored for 2D projection - return { x, y }; + return { x, y: mathY }; }; // --- Interaction Logic --- @@ -163,6 +182,7 @@ const onMouseDown = (e) => { lastX.value = e.clientX; lastY.value = e.clientY; velocity.value = 0; + currentLayerRotation.value = 0; const target = e.target.closest(".sticker"); if (target) { @@ -206,9 +226,11 @@ const onMouseMove = (e) => { viewMatrix.value = multiplyMatrices(combinedDelta, viewMatrix.value); } else if (dragMode.value === "layer" && selectedCubie.value) { const totalDx = e.clientX - startX.value; - const totalDy = e.clientY - startY.value; + const totalDy = -(e.clientY - startY.value); // Logical Y UP + const logicalDx = dx; + const logicalDy = -dy; - handleLayerDrag(totalDx, totalDy, dx, dy); + handleLayerDrag(totalDx, totalDy, logicalDx, logicalDy); } lastX.value = e.clientX; @@ -228,9 +250,8 @@ const handleLayerDrag = (totalDx, totalDy, dx, dy) => { // Analyze candidates axes.forEach((axis) => { - // Tangent = Normal x Axis - // This is the 3D direction of motion for Positive Rotation around this Axis - const t3D = cross(faceNormal, getAxisVector(axis)); + // Tangent rule for rigid body positive rotation: w x r + const t3D = cross(getAxisVector(axis), faceNormal); const t2D = project(t3D); const len = Math.sqrt(t2D.x ** 2 + t2D.y ** 2); @@ -259,6 +280,12 @@ const handleLayerDrag = (totalDx, totalDy, dx, dy) => { if (best.axis === "y") index = selectedCubie.value.y; if (best.axis === "z") index = selectedCubie.value.z; + // If middle slice (index === 0) and middle slices are disabled, ignore the drag + if (index === 0 && !MIDDLE_SLICES_ENABLED) { + dragMode.value = "view"; + return; + } + activeLayer.value = { axis: best.axis, index, @@ -318,6 +345,58 @@ const snapRotation = () => { requestAnimationFrame(animate); }; +const pendingCameraRotation = ref(null); +const pendingDragMoveLabel = ref(null); +// The UI face labels (shown on buttons) differ from internal logic axis names. +// MOVE_MAP shows: Button "R" → base "F", Button "L" → base "B", etc. +// This means the UI coordinate system is rotated 90° around Y from internal coords. +// Internal → UI translation: +const INTERNAL_TO_UI = { + 'F': 'R', 'B': 'L', 'R': 'B', 'L': 'F', + 'U': 'U', 'D': 'D', + 'M': 'M', 'E': 'E', 'S': 'S', +}; + +// Convert axis/index/direction to a standard Rubik's notation label (UI-facing) +const getDragMoveLabel = (axis, index, direction, count) => { + // Outer layers + const OUTER_MAP = { + 'y_1': { base: 'U', dir: -1 }, + 'y_-1': { base: 'D', dir: 1 }, + 'x_1': { base: 'R', dir: -1 }, + 'x_-1': { base: 'L', dir: 1 }, + 'z_1': { base: 'F', dir: -1 }, + 'z_-1': { base: 'B', dir: 1 }, + }; + // Middle slices + const SLICE_MAP = { + 'x_0': { base: 'M', dir: 1 }, + 'y_0': { base: 'E', dir: 1 }, + 'z_0': { base: 'S', dir: -1 }, + }; + + const key = `${axis}_${index}`; + const mapping = OUTER_MAP[key] || SLICE_MAP[key]; + if (!mapping) return null; + + const effective = direction * mapping.dir; + const stepsMod = ((count % 4) + 4) % 4; + if (stepsMod === 0) return null; + + let modifier = ''; + if (stepsMod === 2) { + modifier = '2'; + } else if ((effective > 0 && stepsMod === 1) || (effective < 0 && stepsMod === 3)) { + modifier = ''; + } else { + modifier = "'"; + } + + // Translate internal face name to UI face name + const uiBase = INTERNAL_TO_UI[mapping.base] || mapping.base; + return uiBase + modifier; +}; + const finishMove = (steps, directionOverride = null) => { if (steps !== 0 && activeLayer.value) { const { axis, index } = activeLayer.value; @@ -325,17 +404,32 @@ const finishMove = (steps, directionOverride = null) => { const direction = directionOverride !== null ? directionOverride : steps > 0 ? 1 : -1; - // LOGICAL SYNC (CRITICAL): - // Our visual rotation signs in getCubieStyle and tangent calc are now aligned. - // However, some axes might still be inverted based on coordinate system (Right-handed vs CSS). - let finalDirection = direction; - - // Y-axis spin in project/matrix logic vs cubic logic often needs swap - if (axis === "y") finalDirection *= -1; - if (axis === "z") finalDirection *= -1; - + // LOGICAL SYNC: + // With pure math mapping, visual positive rotation is directly + // equivalent to logical positive rotation. No more axis-flipping hacks! pendingLogicalUpdate.value = true; - rotateLayer(axis, index, finalDirection, count); + + // Record the drag move in history + const moveLabel = getDragMoveLabel(axis, index, direction, count); + if (moveLabel) { + pendingDragMoveLabel.value = moveLabel; + } + + if (index === 0) { + // Middle slice moved! + pendingCameraRotation.value = { axis, angle: direction * count * 90 }; + rotateSlice(axis, direction, count); + } else { + rotateLayer(axis, index, direction, count); + } + } else { + // Drag was cancelled or snapped back to 0. Release lock. + activeLayer.value = null; + isAnimating.value = false; + currentLayerRotation.value = 0; + selectedCubie.value = null; + selectedFace.value = null; + processNextMove(); } }; @@ -377,11 +471,12 @@ const getAxisIndexForBase = (base) => { return { axis: "y", index: 0 }; }; -const getVisualFactor = (axis, base) => { - let factor = 1; - if (axis === "z") factor *= -1; - if (base === "U" || base === "D") factor *= -1; - return factor; +// Mathematical positive rotation (RHR) corresponds to CCW face rules +// for positive axes, and CW face rules for negative axes. +const getMathDirectionForBase = (base) => { + if (['R', 'U', 'F', 'S'].includes(base)) return -1; + if (['L', 'D', 'B', 'M', 'E'].includes(base)) return 1; + return 1; }; const coerceStepsToSign = (steps, sign) => { @@ -500,8 +595,8 @@ const getCubieStyle = (c) => { // CSS rotateY: + is Right->Back. (Spin Right) // CSS rotateZ: + is Top->Right. (Clockwise) - // We align rot so that +90 degrees visually matches logical direction=1 (CW) - if (axis === "x") transform = `rotateX(${rot}deg) ` + transform; + // CSS rotateY aligns with Math +Y. CSS rotateX and rotateZ are inverted. + if (axis === "x") transform = `rotateX(${-rot}deg) ` + transform; if (axis === "y") transform = `rotateY(${rot}deg) ` + transform; if (axis === "z") transform = `rotateZ(${-rot}deg) ` + transform; } @@ -638,12 +733,17 @@ const animateProgrammaticMove = (base, modifier, displayBase) => { if (isAnimating.value || activeLayer.value) return; const { axis, index } = getAxisIndexForBase(base); + const mathDir = getMathDirectionForBase(base); - const count = modifier === "2" ? 2 : 1; - const direction = modifier === "'" ? 1 : -1; - const logicalSteps = direction * count; - const visualFactor = getVisualFactor(axis, displayBase); - const visualDelta = logicalSteps * visualFactor * 90; + let moveSign = 1; // CW + let count = 1; + if (modifier === "'") { moveSign = -1; count = 1; } + else if (modifier === "2") { moveSign = 1; count = 2; } + + // Mathematical target rotation handles the physical modeling + const targetRotation = mathDir * moveSign * count * 90; + // Logical steps controls the worker logic update direction + const logicalSteps = mathDir * moveSign * count; activeLayer.value = { axis, @@ -654,20 +754,18 @@ const animateProgrammaticMove = (base, modifier, displayBase) => { currentLayerRotation.value = 0; const startRotation = 0; - const targetRotation = visualDelta; programmaticAnimation.value = { axis, index, displayBase, logicalSteps, - visualFactor, targetRotation, startRotation, startTime: performance.now(), duration: LAYER_ANIMATION_DURATION * - Math.max(Math.abs(visualDelta) / 90 || 1, 0.01), + Math.max(Math.abs(targetRotation) / 90, 0.01), }; requestAnimationFrame(stepProgrammaticAnimation); @@ -678,20 +776,20 @@ const MOVE_MAP = { "U-prime": { base: "U", modifier: "'" }, U2: { base: "U", modifier: "2" }, - D: { base: "D", modifier: "'" }, - "D-prime": { base: "D", modifier: "" }, + D: { base: "D", modifier: "" }, + "D-prime": { base: "D", modifier: "'" }, D2: { base: "D", modifier: "2" }, - L: { base: "B", modifier: "'" }, - "L-prime": { base: "B", modifier: "" }, + L: { base: "B", modifier: "" }, + "L-prime": { base: "B", modifier: "'" }, L2: { base: "B", modifier: "2" }, R: { base: "F", modifier: "" }, "R-prime": { base: "F", modifier: "'" }, R2: { base: "F", modifier: "2" }, - F: { base: "L", modifier: "'" }, - "F-prime": { base: "L", modifier: "" }, + F: { base: "L", modifier: "" }, + "F-prime": { base: "L", modifier: "'" }, F2: { base: "L", modifier: "2" }, B: { base: "R", modifier: "" }, @@ -722,16 +820,17 @@ const applyMove = (move) => { const mapping = MOVE_MAP[move]; if (!mapping) return; + // Track queue legacy steps formatting: '' = -1, "'" = 1, '2' = -2 let delta = 0; if (mapping.modifier === "'") - delta = 1; // logical +1 + delta = 1; else if (mapping.modifier === "") - delta = -1; // logical -1 - else if (mapping.modifier === "2") delta = -2; // logical -2 + delta = -1; + else if (mapping.modifier === "2") delta = -2; const displayBase = move[0]; const { axis, index } = getAxisIndexForBase(mapping.base); - const visualFactor = getVisualFactor(axis, displayBase); + const mathDir = getMathDirectionForBase(mapping.base); const currentAnim = programmaticAnimation.value; if ( @@ -747,13 +846,21 @@ const applyMove = (move) => { const currentAngle = sampleProgrammaticAngle(currentAnim, now); const currentVelocity = programmaticVelocity(currentAnim, now); // degrees per ms + let moveSign = 1; // CW + let count = 1; + if (mapping.modifier === "'") { moveSign = -1; count = 1; } + else if (mapping.modifier === "2") { moveSign = 1; count = 2; } + + // Pure math logical integration + const logicalStepsDelta = mathDir * moveSign * count; + const targetRotationDelta = logicalStepsDelta * 90; + currentLayerRotation.value = currentAngle; - currentAnim.logicalSteps += delta; - const additionalVisualDelta = delta * currentAnim.visualFactor * 90; + currentAnim.logicalSteps += logicalStepsDelta; // Setup new target currentAnim.startRotation = currentAngle; - currentAnim.targetRotation += additionalVisualDelta; + currentAnim.targetRotation += targetRotationDelta; currentAnim.startTime = now; const remainingVisualDelta = currentAnim.targetRotation - currentAngle; @@ -771,7 +878,6 @@ const applyMove = (move) => { currentAnim.v0 = Math.max(-3, Math.min(3, v0)); // Format the new label instantly - const label = formatMoveLabel(displayBase, currentAnim.logicalSteps); updateCurrentMoveLabel(displayBase, currentAnim.logicalSteps); return; @@ -809,7 +915,17 @@ const handleSolve = async (solverType) => { return; } - const currentCube = DeepCube.fromCubies(cubies.value); + if (!deepCubeState.value) { + console.error("DeepCube state not available yet"); + return; + } + + const currentCube = new DeepCube( + deepCubeState.value.cp, + deepCubeState.value.co, + deepCubeState.value.ep, + deepCubeState.value.eo, + ); if (!currentCube.isValid()) { console.error("Cube is physically impossible!"); @@ -835,13 +951,7 @@ watch(solveResult, (solution) => { if (solution && solution.length > 0) { const uiMoves = solution.map((m) => { const solverBase = m[0]; - let solverModifier = m.slice(1); - - // Topological neg-axes (D, L, B) require visually inverted dir mapping for CW/CCW - if (["D", "L", "B"].includes(solverBase)) { - if (solverModifier === "") solverModifier = "'"; - else if (solverModifier === "'") solverModifier = ""; - } + const solverModifier = m.slice(1); for (const [uiKey, mapping] of Object.entries(MOVE_MAP)) { if ( @@ -873,6 +983,29 @@ watch(cubies, () => { if (!pendingLogicalUpdate.value) return; pendingLogicalUpdate.value = false; + if (pendingCameraRotation.value) { + const { axis, angle } = pendingCameraRotation.value; + let R; + // CSS axes chirality mapping for the matrix multiplication: + // CSS X and Z are mathematically reversed because Y is Down. + // To match getCubieStyle rotations we must use the exact same signs. + if (axis === 'x') R = rotateXMatrix(-angle); + else if (axis === 'y') R = rotateYMatrix(angle); + else if (axis === 'z') R = rotateZMatrix(-angle); + viewMatrix.value = multiplyMatrices(viewMatrix.value, R); + pendingCameraRotation.value = null; + } + + if (pendingDragMoveLabel.value) { + const id = `drag-${Date.now()}`; + movesHistory.value.push({ + id, + label: pendingDragMoveLabel.value, + status: 'done', + }); + pendingDragMoveLabel.value = null; + } + if (currentMoveId.value !== null) { const idx = movesHistory.value.findIndex( (m) => m.id === currentMoveId.value, @@ -890,6 +1023,7 @@ watch(cubies, () => { isAnimating.value = false; selectedCubie.value = null; selectedFace.value = null; + currentLayerRotation.value = 0; processNextMove(); }); @@ -908,11 +1042,10 @@ onUnmounted(() => {