feat: reposition solver controls to a dropdown

Moved the Kociemba/Beginner solve options into a sleek dropdown menu positioned above the Scramble button on the left side of the screen. This ensures the solver controls no longer obstruct the programmatic move queue at the bottom.
This commit is contained in:
2026-02-23 21:46:15 +00:00
parent f6b34449df
commit 929761ac9e
31 changed files with 6843 additions and 987 deletions

View File

@@ -1,7 +1,7 @@
<script setup>
import SmartCube from './components/renderers/SmartCube.vue'
import NavBar from './components/NavBar.vue'
import Footer from './components/Footer.vue'
import SmartCube from "./components/renderers/SmartCube.vue";
import NavBar from "./components/NavBar.vue";
import Footer from "./components/Footer.vue";
</script>
<template>

View File

@@ -1,16 +1,16 @@
<script setup>
import { Sun, Moon, Grid2x2 } from 'lucide-vue-next';
import { ref, onMounted } from 'vue';
import { useSettings } from '../composables/useSettings';
import { Sun, Moon, Grid2x2 } from "lucide-vue-next";
import { ref, onMounted } from "vue";
import { useSettings } from "../composables/useSettings";
const { isCubeTranslucent, toggleCubeTranslucent } = useSettings();
const isDark = ref(true);
const setTheme = (dark) => {
isDark.value = dark;
const theme = dark ? 'dark' : 'light';
const theme = dark ? "dark" : "light";
document.documentElement.dataset.theme = theme;
localStorage.setItem('theme', theme);
localStorage.setItem("theme", theme);
};
const toggleTheme = () => {
@@ -18,9 +18,9 @@ const toggleTheme = () => {
};
onMounted(() => {
const savedTheme = localStorage.getItem('theme');
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
setTheme(savedTheme === 'dark');
setTheme(savedTheme === "dark");
} else {
setTheme(true);
}
@@ -38,14 +38,22 @@ onMounted(() => {
<button
class="btn-neon nav-btn icon-only"
@click="toggleCubeTranslucent"
:title="isCubeTranslucent ? 'Wyłącz przezroczystość kostki' : 'Włącz przezroczystość kostki'"
:title="
isCubeTranslucent
? 'Wyłącz przezroczystość kostki'
: 'Włącz przezroczystość kostki'
"
:class="{ active: isCubeTranslucent }"
>
<Grid2x2 :size="20" />
</button>
<!-- Theme Toggle -->
<button class="btn-neon nav-btn icon-only" @click="toggleTheme" :title="isDark ? 'Przełącz na jasny' : 'Przełącz na ciemny'">
<button
class="btn-neon nav-btn icon-only"
@click="toggleTheme"
:title="isDark ? 'Przełącz na jasny' : 'Przełącz na ciemny'"
>
<Sun v-if="isDark" :size="20" />
<Moon v-else :size="20" />
</button>

View File

@@ -1,55 +1,55 @@
<script setup>
import { computed } from 'vue';
import { computed } from "vue";
const props = defineProps({
start: {
type: Object,
required: true // {x, y, z}
required: true, // {x, y, z}
},
end: {
type: Object,
required: true // {x, y, z}
required: true, // {x, y, z}
},
color: {
type: String,
default: 'var(--text-color, #fff)'
default: "var(--text-color, #fff)",
},
thickness: {
type: Number,
default: 1
}
default: 1,
},
});
const style = computed(() => {
const dx = props.end.x - props.start.x;
const dy = props.end.y - props.start.y;
const dz = props.end.z - props.start.z;
const length = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (length === 0) return {};
const midX = (props.start.x + props.end.x) / 2;
const midY = (props.start.y + props.end.y) / 2;
const midZ = (props.start.z + props.end.z) / 2;
// Rotation
// Yaw (around Y axis)
const yaw = Math.atan2(dz, dx);
// Pitch (around Z axis)
const pitch = Math.atan2(dy, Math.sqrt(dx * dx + dz * dz));
return {
width: `${length}px`,
height: `${props.thickness}px`,
backgroundColor: props.color,
position: 'absolute',
top: '0',
left: '0',
transformOrigin: 'center center',
position: "absolute",
top: "0",
left: "0",
transformOrigin: "center center",
transform: `translate3d(${midX}px, ${midY}px, ${midZ}px) rotateY(${-yaw}rad) rotateZ(${pitch}rad) translate(-50%, -50%)`,
opacity: 0.3, // Delicate
pointerEvents: 'none'
pointerEvents: "none",
};
});
</script>

View File

@@ -1,5 +1,31 @@
<script setup>
const emit = defineEmits(['move', 'scramble'])
import { ref, onMounted, onUnmounted } from "vue";
const emit = defineEmits(["move", "scramble", "solve"]);
const showSolveDropdown = ref(false);
const toggleDropdown = () => {
showSolveDropdown.value = !showSolveDropdown.value;
};
const triggerSolve = (method) => {
showSolveDropdown.value = false;
emit("solve", method);
};
// Close dropdown when clicking outside
const closeDropdown = (e) => {
if (!e.target.closest(".solve-dropdown-wrapper")) {
showSolveDropdown.value = false;
}
};
onMounted(() => {
document.addEventListener("click", closeDropdown);
});
onUnmounted(() => {
document.removeEventListener("click", closeDropdown);
});
</script>
<template>
@@ -11,14 +37,26 @@ const emit = defineEmits(['move', 'scramble'])
<button class="btn-neon move-btn" @click="emit('move', 'L')">L</button>
</div>
<div class="controls-row">
<button class="btn-neon move-btn" @click="emit('move', 'U-prime')">U'</button>
<button class="btn-neon move-btn" @click="emit('move', 'D-prime')">D'</button>
<button class="btn-neon move-btn" @click="emit('move', 'L-prime')">L'</button>
<button class="btn-neon move-btn" @click="emit('move', 'U-prime')">
U'
</button>
<button class="btn-neon move-btn" @click="emit('move', 'D-prime')">
D'
</button>
<button class="btn-neon move-btn" @click="emit('move', 'L-prime')">
L'
</button>
</div>
<div class="controls-row">
<button class="btn-neon move-btn" @click="emit('move', 'U2')">U2</button>
<button class="btn-neon move-btn" @click="emit('move', 'D2')">D2</button>
<button class="btn-neon move-btn" @click="emit('move', 'L2')">L2</button>
<button class="btn-neon move-btn" @click="emit('move', 'U2')">
U2
</button>
<button class="btn-neon move-btn" @click="emit('move', 'D2')">
D2
</button>
<button class="btn-neon move-btn" @click="emit('move', 'L2')">
L2
</button>
</div>
</div>
@@ -29,20 +67,48 @@ const emit = defineEmits(['move', 'scramble'])
<button class="btn-neon move-btn" @click="emit('move', 'B')">B</button>
</div>
<div class="controls-row">
<button class="btn-neon move-btn" @click="emit('move', 'R-prime')">R'</button>
<button class="btn-neon move-btn" @click="emit('move', 'F-prime')">F'</button>
<button class="btn-neon move-btn" @click="emit('move', 'B-prime')">B'</button>
<button class="btn-neon move-btn" @click="emit('move', 'R-prime')">
R'
</button>
<button class="btn-neon move-btn" @click="emit('move', 'F-prime')">
F'
</button>
<button class="btn-neon move-btn" @click="emit('move', 'B-prime')">
B'
</button>
</div>
<div class="controls-row">
<button class="btn-neon move-btn" @click="emit('move', 'R2')">R2</button>
<button class="btn-neon move-btn" @click="emit('move', 'F2')">F2</button>
<button class="btn-neon move-btn" @click="emit('move', 'B2')">B2</button>
<button class="btn-neon move-btn" @click="emit('move', 'R2')">
R2
</button>
<button class="btn-neon move-btn" @click="emit('move', 'F2')">
F2
</button>
<button class="btn-neon move-btn" @click="emit('move', 'B2')">
B2
</button>
</div>
</div>
<button class="btn-neon move-btn scramble-btn" @click="emit('scramble')">
Scramble
</button>
<div class="bottom-left-controls">
<div class="solve-dropdown-wrapper">
<button class="btn-neon move-btn solve-btn" @click="toggleDropdown">
Solve
</button>
<div v-if="showSolveDropdown" class="solve-dropdown-menu">
<button class="dropdown-item" @click="triggerSolve('kociemba')">
Kociemba (Optimal)
</button>
<button class="dropdown-item" @click="triggerSolve('beginner')">
Beginner (Human)
</button>
</div>
</div>
<button class="btn-neon move-btn scramble-btn" @click="emit('scramble')">
Scramble
</button>
</div>
</div>
</template>
@@ -77,11 +143,58 @@ const emit = defineEmits(['move', 'scramble'])
padding: 0 10px;
}
.scramble-btn {
.bottom-left-controls {
position: absolute;
bottom: 72px;
left: 24px;
z-index: 50;
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.solve-dropdown-wrapper {
position: relative;
}
.solve-dropdown-menu {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 8px;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 180px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.dropdown-item {
background: transparent;
color: #fff;
border: none;
padding: 8px 12px;
text-align: left;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: 0.9rem;
transition: background 0.2s;
}
.dropdown-item:hover {
background: rgba(255, 255, 255, 0.1);
}
</style>

View File

@@ -1,96 +1,99 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
const props = defineProps({
moves: {
type: Array,
required: true
}
})
required: true,
},
});
const emit = defineEmits(['reset', 'copy', 'add-moves', 'open-add-modal'])
const emit = defineEmits(["reset", "copy", "add-moves", "open-add-modal"]);
const MIN_MOVES_COLUMN_GAP = 6
const MIN_MOVES_COLUMN_GAP = 6;
const movesHistoryEl = ref(null)
const samplePillEl = ref(null)
const movesPerRow = ref(0)
const movesColumnGap = ref(MIN_MOVES_COLUMN_GAP)
const movesHistoryEl = ref(null);
const samplePillEl = ref(null);
const movesPerRow = ref(0);
const movesColumnGap = ref(MIN_MOVES_COLUMN_GAP);
const displayMoves = computed(() => props.moves || [])
const displayMoves = computed(() => props.moves || []);
const moveRows = computed(() => {
const perRow = movesPerRow.value || displayMoves.value.length || 1
const rows = []
const all = displayMoves.value
const perRow = movesPerRow.value || displayMoves.value.length || 1;
const rows = [];
const all = displayMoves.value;
for (let i = 0; i < all.length; i += perRow) {
rows.push(all.slice(i, i + perRow))
rows.push(all.slice(i, i + perRow));
}
return rows
})
return rows;
});
const hasMoves = computed(() => displayMoves.value.length > 0)
const hasMoves = computed(() => displayMoves.value.length > 0);
const copyQueueToClipboard = () => {
emit('copy')
}
emit("copy");
};
const resetQueue = () => {
emit('reset')
}
emit("reset");
};
const setSamplePill = (el) => {
if (el && !samplePillEl.value) {
samplePillEl.value = el
samplePillEl.value = el;
}
}
};
const recalcMovesLayout = () => {
const container = movesHistoryEl.value
const pill = samplePillEl.value
if (!container || !pill) return
const container = movesHistoryEl.value;
const pill = samplePillEl.value;
if (!container || !pill) return;
const containerWidth = container.clientWidth
const pillWidth = pill.offsetWidth
if (pillWidth <= 0) return
const containerWidth = container.clientWidth;
const pillWidth = pill.offsetWidth;
if (pillWidth <= 0) return;
const totalWidth = (cols) => {
if (cols <= 0) return 0
if (cols === 1) return pillWidth
return cols * pillWidth + (cols - 1) * MIN_MOVES_COLUMN_GAP
}
if (cols <= 0) return 0;
if (cols === 1) return pillWidth;
return cols * pillWidth + (cols - 1) * MIN_MOVES_COLUMN_GAP;
};
let cols = Math.floor((containerWidth + MIN_MOVES_COLUMN_GAP) / (pillWidth + MIN_MOVES_COLUMN_GAP))
if (cols < 1) cols = 1
let cols = Math.floor(
(containerWidth + MIN_MOVES_COLUMN_GAP) /
(pillWidth + MIN_MOVES_COLUMN_GAP),
);
if (cols < 1) cols = 1;
while (cols > 1 && totalWidth(cols) > containerWidth) {
cols -= 1
cols -= 1;
}
let gap = 0
let gap = 0;
if (cols > 1) {
gap = (containerWidth - cols * pillWidth) / (cols - 1)
gap = (containerWidth - cols * pillWidth) / (cols - 1);
}
movesPerRow.value = cols
movesColumnGap.value = gap
}
movesPerRow.value = cols;
movesColumnGap.value = gap;
};
const openAddModal = () => {
emit('open-add-modal')
}
emit("open-add-modal");
};
watch(displayMoves, () => {
nextTick(recalcMovesLayout)
})
nextTick(recalcMovesLayout);
});
onMounted(() => {
window.addEventListener('resize', recalcMovesLayout)
nextTick(recalcMovesLayout)
})
window.addEventListener("resize", recalcMovesLayout);
nextTick(recalcMovesLayout);
});
onUnmounted(() => {
window.removeEventListener('resize', recalcMovesLayout)
})
window.removeEventListener("resize", recalcMovesLayout);
});
</script>
<template>
@@ -108,7 +111,7 @@ onUnmounted(() => {
class="move-pill"
:class="{
'move-pill-active': m.status === 'in_progress',
'move-pill-pending': m.status === 'pending'
'move-pill-pending': m.status === 'pending',
}"
:ref="rowIndex === 0 && idx === 0 ? setSamplePill : null"
>
@@ -135,7 +138,6 @@ onUnmounted(() => {
reset
</button>
</div>
</div>
</template>
@@ -218,5 +220,4 @@ onUnmounted(() => {
outline: none;
box-shadow: none;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,11 @@
import { ref, computed } from 'vue';
import { COLORS, FACES } from '../utils/CubeModel';
import { ref, computed } from "vue";
import { COLORS, FACES } from "../utils/CubeModel";
// Singleton worker
const worker = new Worker(new URL('../workers/Cube.worker.js', import.meta.url), { type: 'module' });
const worker = new Worker(
new URL("../workers/Cube.worker.js", import.meta.url),
{ type: "module" },
);
// Reactive state
const cubies = ref([]);
@@ -12,35 +14,37 @@ const validationResult = ref(null);
worker.onmessage = (e) => {
const { type, payload } = e.data;
if (type === 'STATE_UPDATE') {
if (type === "STATE_UPDATE") {
cubies.value = payload.cubies;
isReady.value = true;
} else if (type === 'VALIDATION_RESULT') {
} else if (type === "VALIDATION_RESULT") {
validationResult.value = payload;
} else if (type === 'ERROR') {
console.error('Worker Error:', payload);
} else if (type === "ERROR") {
console.error("Worker Error:", payload);
}
};
// Init worker
worker.postMessage({ type: 'INIT' });
worker.postMessage({ type: "INIT" });
export function useCube() {
const initCube = () => {
worker.postMessage({ type: 'RESET' });
worker.postMessage({ type: "RESET" });
};
const rotateLayer = (axis, index, direction) => {
worker.postMessage({ type: 'ROTATE_LAYER', payload: { axis, index, direction } });
const rotateLayer = (axis, index, direction, steps = 1) => {
worker.postMessage({
type: "ROTATE_LAYER",
payload: { axis, index, direction, steps },
});
};
const turn = (move) => {
worker.postMessage({ type: 'TURN', payload: { move } });
worker.postMessage({ type: "TURN", payload: { move } });
};
const validate = () => {
worker.postMessage({ type: 'VALIDATE' });
worker.postMessage({ type: "VALIDATE" });
};
return {
@@ -52,6 +56,6 @@ export function useCube() {
turn,
validate,
COLORS,
FACES
FACES,
};
}

View File

@@ -1,10 +1,10 @@
import { ref } from 'vue';
import { ref } from "vue";
let initialCubeTranslucent = false;
try {
const stored = localStorage.getItem('cubeTranslucent');
const stored = localStorage.getItem("cubeTranslucent");
if (stored !== null) {
initialCubeTranslucent = stored === 'true';
initialCubeTranslucent = stored === "true";
}
} catch (e) {}
@@ -14,12 +14,12 @@ export function useSettings() {
const toggleCubeTranslucent = () => {
isCubeTranslucent.value = !isCubeTranslucent.value;
try {
localStorage.setItem('cubeTranslucent', String(isCubeTranslucent.value));
localStorage.setItem("cubeTranslucent", String(isCubeTranslucent.value));
} catch (e) {}
};
return {
isCubeTranslucent,
toggleCubeTranslucent
toggleCubeTranslucent,
};
}

View File

@@ -1 +1 @@
export const LAYER_ANIMATION_DURATION = 200
export const LAYER_ANIMATION_DURATION = 200;

View File

@@ -1,5 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";
createApp(App).mount('#app')
createApp(App).mount("#app");

View File

@@ -0,0 +1,59 @@
import { DeepCube, MOVES } from './DeepCube.js';
import { CubeModel } from './CubeModel.js';
export class RubiksJSModel {
constructor() {
this.state = new DeepCube();
this.visual = new CubeModel();
}
reset() {
this.state = new DeepCube();
this.visual = new CubeModel();
}
rotateLayer(axis, index, dir, steps = 1) {
let move = '';
if (axis === 'y') {
if (index === 1) move = dir === 1 ? "U'" : "U";
else if (index === -1) move = dir === -1 ? "D'" : "D";
} else if (axis === 'x') {
if (index === 1) move = dir === 1 ? "R'" : "R";
else if (index === -1) move = dir === -1 ? "L'" : "L";
} else if (axis === 'z') {
if (index === 1) move = dir === 1 ? "F'" : "F";
else if (index === -1) move = dir === -1 ? "B'" : "B";
}
if (move) {
for (let i = 0; i < steps; i++) {
try {
this.state = this.state.multiply(MOVES[move]);
} catch (e) {
console.error('[RubiksJSModel] Failed to apply move:', move, e);
}
this.visual.rotateLayer(axis, index, dir);
}
}
}
applyTurn(move) {
if (!move) return;
try {
this.state = this.state.multiply(MOVES[move]);
} catch (e) {
console.error('[RubiksJSModel] Failed to apply direct move:', move, e);
}
this.visual.applyMove(move);
}
toCubies() {
return this.visual.toCubies();
}
validate() {
const valid = this.state.isValid();
return { valid, errors: valid ? [] : ['Invalid cube configuration (Parity or Orientation rules violated)'] };
}
}

View File

@@ -12,22 +12,22 @@
*/
export const COLORS = {
WHITE: 'white',
YELLOW: 'yellow',
ORANGE: 'orange',
RED: 'red',
GREEN: 'green',
BLUE: 'blue',
BLACK: 'black'
WHITE: "white",
YELLOW: "yellow",
ORANGE: "orange",
RED: "red",
GREEN: "green",
BLUE: "blue",
BLACK: "black",
};
export const FACES = {
UP: 'up',
DOWN: 'down',
LEFT: 'left',
RIGHT: 'right',
FRONT: 'front',
BACK: 'back',
UP: "up",
DOWN: "down",
LEFT: "left",
RIGHT: "right",
FRONT: "front",
BACK: "back",
};
// Standard Face Colors (Solved State)
@@ -99,7 +99,7 @@ export class CubeModel {
*/
rotateLayer(axis, index, direction) {
// Determine the relevant cubies in the slice
const slice = this.cubies.filter(c => c[axis] === index);
const slice = this.cubies.filter((c) => c[axis] === index);
// Coordinate rotation (Matrix Logic)
// 90 deg CW rotation formulas:
@@ -120,7 +120,7 @@ export class CubeModel {
// If direction is -1: Inverse.
slice.forEach(cubie => {
slice.forEach((cubie) => {
this._rotateCubieCoordinates(cubie, axis, direction);
this._rotateCubieFaces(cubie, axis, direction);
});
@@ -129,7 +129,7 @@ export class CubeModel {
_rotateCubieCoordinates(cubie, axis, direction) {
const { x, y, z } = cubie;
if (axis === 'x') {
if (axis === "x") {
if (direction === 1) {
cubie.y = -z;
cubie.z = y;
@@ -137,7 +137,7 @@ export class CubeModel {
cubie.y = z;
cubie.z = -y;
}
} else if (axis === 'y') {
} else if (axis === "y") {
if (direction === 1) {
cubie.z = -x;
cubie.x = z;
@@ -145,11 +145,13 @@ export class CubeModel {
cubie.z = x;
cubie.x = -z;
}
} else if (axis === 'z') {
if (direction === 1) { // CW
} else if (axis === "z") {
if (direction === 1) {
// CW
cubie.x = -y;
cubie.y = x;
} else { // CCW
} else {
// CCW
cubie.x = y;
cubie.y = -x;
}
@@ -165,38 +167,44 @@ export class CubeModel {
const f = { ...cubie.faces };
if (axis === 'x') {
if (direction === 1) { // Up -> Front -> Down -> Back -> Up
if (axis === "x") {
if (direction === 1) {
// Up -> Front -> Down -> Back -> Up
cubie.faces[FACES.FRONT] = f[FACES.UP];
cubie.faces[FACES.DOWN] = f[FACES.FRONT];
cubie.faces[FACES.BACK] = f[FACES.DOWN];
cubie.faces[FACES.UP] = f[FACES.BACK];
// Left/Right unchanged in position, but might rotate? No, faces are solid colors.
} else { // Up -> Back -> Down -> Front -> Up
} else {
// Up -> Back -> Down -> Front -> Up
cubie.faces[FACES.BACK] = f[FACES.UP];
cubie.faces[FACES.DOWN] = f[FACES.BACK];
cubie.faces[FACES.FRONT] = f[FACES.DOWN];
cubie.faces[FACES.UP] = f[FACES.FRONT];
}
} else if (axis === 'y') {
if (direction === 1) { // Front -> Right -> Back -> Left -> Front
} else if (axis === "y") {
if (direction === 1) {
// Front -> Right -> Back -> Left -> Front
cubie.faces[FACES.RIGHT] = f[FACES.FRONT];
cubie.faces[FACES.BACK] = f[FACES.RIGHT];
cubie.faces[FACES.LEFT] = f[FACES.BACK];
cubie.faces[FACES.FRONT] = f[FACES.LEFT];
} else { // Front -> Left -> Back -> Right -> Front
} else {
// Front -> Left -> Back -> Right -> Front
cubie.faces[FACES.LEFT] = f[FACES.FRONT];
cubie.faces[FACES.BACK] = f[FACES.LEFT];
cubie.faces[FACES.RIGHT] = f[FACES.BACK];
cubie.faces[FACES.FRONT] = f[FACES.RIGHT];
}
} else if (axis === 'z') {
if (direction === 1) { // CCW: Up -> Left -> Down -> Right -> Up
} else if (axis === "z") {
if (direction === 1) {
// CCW: Up -> Left -> Down -> Right -> Up
cubie.faces[FACES.LEFT] = f[FACES.UP];
cubie.faces[FACES.DOWN] = f[FACES.LEFT];
cubie.faces[FACES.RIGHT] = f[FACES.DOWN];
cubie.faces[FACES.UP] = f[FACES.RIGHT];
} else { // CW: Up -> Right -> Down -> Left -> Up
} else {
// CW: Up -> Right -> Down -> Left -> Up
cubie.faces[FACES.RIGHT] = f[FACES.UP];
cubie.faces[FACES.DOWN] = f[FACES.RIGHT];
cubie.faces[FACES.LEFT] = f[FACES.DOWN];
@@ -208,12 +216,12 @@ export class CubeModel {
toCubies() {
// Return copy of state for rendering
// CubeCSS expects array of objects with x, y, z, faces
return this.cubies.map(c => ({
return this.cubies.map((c) => ({
id: c.id,
x: c.x,
y: c.y,
z: c.z,
faces: { ...c.faces }
faces: { ...c.faces },
}));
}
@@ -238,30 +246,54 @@ export class CubeModel {
// B (CW around -Z): 1 (since Z(1) is CW around -Z)
switch (base) {
case 'U': direction = 1; break;
case 'D': direction = -1; break;
case 'L': direction = -1; break;
case 'R': direction = 1; break;
case 'F': direction = -1; break;
case 'B': direction = 1; break;
case "U":
direction = 1;
break;
case "D":
direction = -1;
break;
case "L":
direction = -1;
break;
case "R":
direction = 1;
break;
case "F":
direction = -1;
break;
case "B":
direction = 1;
break;
}
if (modifier === "'") direction *= -1;
if (modifier === '2') {
if (modifier === "2") {
// 2 moves. Direction doesn't matter for 180, but let's keep it.
// We will call rotateLayer twice.
}
const count = modifier === '2' ? 2 : 1;
const count = modifier === "2" ? 2 : 1;
for (let i = 0; i < count; i++) {
switch (base) {
case 'U': this.rotateLayer('y', 1, direction); break;
case 'D': this.rotateLayer('y', -1, direction); break;
case 'L': this.rotateLayer('x', -1, direction); break;
case 'R': this.rotateLayer('x', 1, direction); break;
case 'F': this.rotateLayer('z', 1, direction); break;
case 'B': this.rotateLayer('z', -1, direction); break;
case "U":
this.rotateLayer("y", 1, direction);
break;
case "D":
this.rotateLayer("y", -1, direction);
break;
case "L":
this.rotateLayer("x", -1, direction);
break;
case "R":
this.rotateLayer("x", 1, direction);
break;
case "F":
this.rotateLayer("z", 1, direction);
break;
case "B":
this.rotateLayer("z", -1, direction);
break;
}
}
}
@@ -282,25 +314,46 @@ export class CubeModel {
for (let c = 0; c < 3; c++) {
let cubie;
// Map r,c to x,y,z based on face
if (face === FACES.UP) { // y=1. r=0->z=-1 (Back), r=2->z=1 (Front). c=0->x=-1 (Left).
if (face === FACES.UP) {
// y=1. r=0->z=-1 (Back), r=2->z=1 (Front). c=0->x=-1 (Left).
// Standard U face view: Top Left is Back Left (-1, 1, -1).
// Row 0 (Top of U face) is Back.
// Row 2 (Bottom of U face) is Front.
cubie = this.cubies.find(cu => cu.y === 1 && cu.x === (c - 1) && cu.z === (r - 1)); // Wait.
cubie = this.cubies.find(
(cu) => cu.y === 1 && cu.x === c - 1 && cu.z === r - 1,
); // Wait.
// Back is z=-1. Front is z=1.
// Visual Top of U face is Back (z=-1).
// Visual Bottom of U face is Front (z=1).
cubie = this.cubies.find(cu => cu.y === 1 && cu.x === (c - 1) && cu.z === (r - 1 - 2 * r)); // Complicated.
cubie = this.cubies.find(
(cu) => cu.y === 1 && cu.x === c - 1 && cu.z === r - 1 - 2 * r,
); // Complicated.
// Let's just find by strict coordinates
// r=0 -> z=-1. r=1 -> z=0. r=2 -> z=1.
// c=0 -> x=-1. c=1 -> x=0. c=2 -> x=1.
cubie = this.cubies.find(cu => cu.y === 1 && cu.x === (c - 1) && cu.z === (r - 1));
}
else if (face === FACES.DOWN) cubie = this.cubies.find(cu => cu.y === -1 && cu.x === (c - 1) && cu.z === (1 - r)); // Down View?
else if (face === FACES.FRONT) cubie = this.cubies.find(cu => cu.z === 1 && cu.x === (c - 1) && cu.y === (1 - r));
else if (face === FACES.BACK) cubie = this.cubies.find(cu => cu.z === -1 && cu.x === (1 - c) && cu.y === (1 - r));
else if (face === FACES.LEFT) cubie = this.cubies.find(cu => cu.x === -1 && cu.z === (1 - c) && cu.y === (1 - r)); // Left view z order?
else if (face === FACES.RIGHT) cubie = this.cubies.find(cu => cu.x === 1 && cu.z === (c - 1) && cu.y === (1 - r));
cubie = this.cubies.find(
(cu) => cu.y === 1 && cu.x === c - 1 && cu.z === r - 1,
);
} else if (face === FACES.DOWN)
cubie = this.cubies.find(
(cu) => cu.y === -1 && cu.x === c - 1 && cu.z === 1 - r,
); // Down View?
else if (face === FACES.FRONT)
cubie = this.cubies.find(
(cu) => cu.z === 1 && cu.x === c - 1 && cu.y === 1 - r,
);
else if (face === FACES.BACK)
cubie = this.cubies.find(
(cu) => cu.z === -1 && cu.x === 1 - c && cu.y === 1 - r,
);
else if (face === FACES.LEFT)
cubie = this.cubies.find(
(cu) => cu.x === -1 && cu.z === 1 - c && cu.y === 1 - r,
); // Left view z order?
else if (face === FACES.RIGHT)
cubie = this.cubies.find(
(cu) => cu.x === 1 && cu.z === c - 1 && cu.y === 1 - r,
);
if (cubie) {
rowStr += cubie.faces[face][0].toUpperCase() + " ";
@@ -313,16 +366,16 @@ export class CubeModel {
out += "\n";
};
printFace(FACES.UP, 'U');
printFace(FACES.DOWN, 'D');
printFace(FACES.FRONT, 'F');
printFace(FACES.BACK, 'B');
printFace(FACES.LEFT, 'L');
printFace(FACES.RIGHT, 'R');
printFace(FACES.UP, "U");
printFace(FACES.DOWN, "D");
printFace(FACES.FRONT, "F");
printFace(FACES.BACK, "B");
printFace(FACES.LEFT, "L");
printFace(FACES.RIGHT, "R");
return out;
}
scramble(n = 20) {
const axes = ['x', 'y', 'z'];
const axes = ["x", "y", "z"];
const indices = [-1, 1]; // Usually rotate outer layers for scramble
// Actually, scrambling usually involves random face moves (U, D, L, R, F, B)
// U: y=1, dir -1 (Standard CW)

398
src/utils/DeepCube.js Normal file
View File

@@ -0,0 +1,398 @@
// Corner indices
export const CORNERS = {
URF: 0,
UFL: 1,
ULB: 2,
UBR: 3,
DFR: 4,
DLF: 5,
DBL: 6,
DRB: 7,
};
// Edge indices
export const EDGES = {
UR: 0,
UF: 1,
UL: 2,
UB: 3,
DR: 4,
DF: 5,
DL: 6,
DB: 7,
FR: 8,
FL: 9,
BL: 10,
BR: 11,
};
export class DeepCube {
constructor(cp, co, ep, eo) {
if (cp && co && ep && eo) {
this.cp = [...cp];
this.co = [...co];
this.ep = [...ep];
this.eo = [...eo];
} else {
// Solved identity state
this.cp = [0, 1, 2, 3, 4, 5, 6, 7];
this.co = [0, 0, 0, 0, 0, 0, 0, 0];
this.ep = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
this.eo = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
}
}
// Multiply (apply) another cube state to this one.
multiply(b) {
const cp = new Array(8);
const co = new Array(8);
const ep = new Array(12);
const eo = new Array(12);
// Corners
for (let i = 0; i < 8; i++) {
cp[i] = this.cp[b.cp[i]];
co[i] = (this.co[b.cp[i]] + b.co[i]) % 3;
}
// Edges
for (let i = 0; i < 12; i++) {
ep[i] = this.ep[b.ep[i]];
eo[i] = (this.eo[b.ep[i]] + b.eo[i]) % 2;
}
return new DeepCube(cp, co, ep, eo);
}
clone() {
return new DeepCube(this.cp, this.co, this.ep, this.eo);
}
// Checks if the mathematical state is solvable/possible
isValid() {
// 1. Edge parity must equal corner parity
let edgeParity = 0;
for (let i = 11; i >= 0; i--) {
for (let j = i - 1; j >= 0; j--) {
if (this.ep[j] > this.ep[i]) edgeParity++;
}
}
let cornerParity = 0;
for (let i = 7; i >= 0; i--) {
for (let j = i - 1; j >= 0; j--) {
if (this.cp[j] > this.cp[i]) cornerParity++;
}
}
if (edgeParity % 2 !== cornerParity % 2) return false;
// 2. Edge orientations must sum to even
let eoSum = this.eo.reduce((a, b) => a + b, 0);
if (eoSum % 2 !== 0) return false;
// 3. Corner orientations must be divisible by 3
let coSum = this.co.reduce((a, b) => a + b, 0);
if (coSum % 3 !== 0) return false;
return true;
}
static fromCubies(cubies) {
const c2f = {
white: "U",
yellow: "D",
orange: "L",
red: "R",
green: "F",
blue: "B",
};
const getCubie = (x, y, z) =>
cubies.find((c) => c.x === x && c.y === y && c.z === z);
const baseC = [
["U", "R", "F"],
["U", "F", "L"],
["U", "L", "B"],
["U", "B", "R"],
["D", "F", "R"],
["D", "L", "F"],
["D", "B", "L"],
["D", "R", "B"],
];
const slotC = [
{ x: 1, y: 1, z: 1, faces: ["up", "right", "front"] }, // 0: URF
{ x: -1, y: 1, z: 1, faces: ["up", "front", "left"] }, // 1: UFL
{ x: -1, y: 1, z: -1, faces: ["up", "left", "back"] }, // 2: ULB
{ x: 1, y: 1, z: -1, faces: ["up", "back", "right"] }, // 3: UBR
{ x: 1, y: -1, z: 1, faces: ["down", "front", "right"] }, // 4: DFR
{ x: -1, y: -1, z: 1, faces: ["down", "left", "front"] }, // 5: DLF
{ x: -1, y: -1, z: -1, faces: ["down", "back", "left"] }, // 6: DBL
{ x: 1, y: -1, z: -1, faces: ["down", "right", "back"] }, // 7: DRB
];
let cp = [],
co = [];
for (let i = 0; i < 8; i++) {
let slot = slotC[i];
let c = getCubie(slot.x, slot.y, slot.z);
let colors = [
c2f[c.faces[slot.faces[0]]],
c2f[c.faces[slot.faces[1]]],
c2f[c.faces[slot.faces[2]]],
];
let perm = baseC.findIndex(
(bc) =>
colors.includes(bc[0]) &&
colors.includes(bc[1]) &&
colors.includes(bc[2]),
);
cp[i] = perm;
co[i] = colors.indexOf(baseC[perm][0]);
}
const baseE = [
["U", "R"],
["U", "F"],
["U", "L"],
["U", "B"],
["D", "R"],
["D", "F"],
["D", "L"],
["D", "B"],
["F", "R"],
["F", "L"],
["B", "L"],
["B", "R"],
];
const slotE = [
{ x: 1, y: 1, z: 0, faces: ["up", "right"] },
{ x: 0, y: 1, z: 1, faces: ["up", "front"] },
{ x: -1, y: 1, z: 0, faces: ["up", "left"] },
{ x: 0, y: 1, z: -1, faces: ["up", "back"] },
{ x: 1, y: -1, z: 0, faces: ["down", "right"] },
{ x: 0, y: -1, z: 1, faces: ["down", "front"] },
{ x: -1, y: -1, z: 0, faces: ["down", "left"] },
{ x: 0, y: -1, z: -1, faces: ["down", "back"] },
{ x: 1, y: 0, z: 1, faces: ["front", "right"] },
{ x: -1, y: 0, z: 1, faces: ["front", "left"] },
{ x: -1, y: 0, z: -1, faces: ["back", "left"] },
{ x: 1, y: 0, z: -1, faces: ["back", "right"] },
];
let ep = [],
eo = [];
for (let i = 0; i < 12; i++) {
let slot = slotE[i];
let c = getCubie(slot.x, slot.y, slot.z);
let colors = [c2f[c.faces[slot.faces[0]]], c2f[c.faces[slot.faces[1]]]];
let perm = baseE.findIndex(
(be) => colors.includes(be[0]) && colors.includes(be[1]),
);
ep[i] = perm;
eo[i] = colors.indexOf(baseE[perm][0]);
}
return new DeepCube(cp, co, ep, eo);
}
}
// ----------------------------------------------------------------------------
// BASE MOVES DEFINITIONS
// Represents the effect of 90-degree clockwise faces on the solved state.
// ----------------------------------------------------------------------------
export const MOVES = {};
// U (Up Face Clockwise)
MOVES["U"] = new DeepCube(
[
CORNERS.UBR,
CORNERS.URF,
CORNERS.UFL,
CORNERS.ULB,
CORNERS.DFR,
CORNERS.DLF,
CORNERS.DBL,
CORNERS.DRB,
],
[0, 0, 0, 0, 0, 0, 0, 0],
[
EDGES.UB,
EDGES.UR,
EDGES.UF,
EDGES.UL,
EDGES.DR,
EDGES.DF,
EDGES.DL,
EDGES.DB,
EDGES.FR,
EDGES.FL,
EDGES.BL,
EDGES.BR,
],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
);
// R (Right Face Clockwise)
MOVES["R"] = new DeepCube(
[
CORNERS.DFR,
CORNERS.UFL,
CORNERS.ULB,
CORNERS.URF,
CORNERS.DRB,
CORNERS.DLF,
CORNERS.DBL,
CORNERS.UBR,
],
[2, 0, 0, 1, 1, 0, 0, 2],
[
EDGES.FR,
EDGES.UF,
EDGES.UL,
EDGES.UB,
EDGES.BR,
EDGES.DF,
EDGES.DL,
EDGES.DB,
EDGES.DR,
EDGES.FL,
EDGES.BL,
EDGES.UR,
],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
);
// F (Front Face Clockwise)
MOVES["F"] = new DeepCube(
[
CORNERS.UFL,
CORNERS.DLF,
CORNERS.ULB,
CORNERS.UBR,
CORNERS.URF,
CORNERS.DFR,
CORNERS.DBL,
CORNERS.DRB,
],
[1, 2, 0, 0, 2, 1, 0, 0],
[
EDGES.UR,
EDGES.FL,
EDGES.UL,
EDGES.UB,
EDGES.DR,
EDGES.FR,
EDGES.DL,
EDGES.DB,
EDGES.UF,
EDGES.DF,
EDGES.BL,
EDGES.BR,
],
[0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0],
);
// D (Down Face Clockwise)
MOVES["D"] = new DeepCube(
[
CORNERS.URF,
CORNERS.UFL,
CORNERS.ULB,
CORNERS.UBR,
CORNERS.DLF,
CORNERS.DBL,
CORNERS.DRB,
CORNERS.DFR,
],
[0, 0, 0, 0, 0, 0, 0, 0],
[
EDGES.UR,
EDGES.UF,
EDGES.UL,
EDGES.UB,
EDGES.DF,
EDGES.DL,
EDGES.DB,
EDGES.DR,
EDGES.FR,
EDGES.FL,
EDGES.BL,
EDGES.BR,
],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
);
// L (Left Face Clockwise)
MOVES["L"] = new DeepCube(
[
CORNERS.URF,
CORNERS.ULB,
CORNERS.DBL,
CORNERS.UBR,
CORNERS.DFR,
CORNERS.UFL,
CORNERS.DLF,
CORNERS.DRB,
],
[0, 1, 2, 0, 0, 2, 1, 0],
[
EDGES.UR,
EDGES.UF,
EDGES.BL,
EDGES.UB,
EDGES.DR,
EDGES.DF,
EDGES.FL,
EDGES.DB,
EDGES.FR,
EDGES.UL,
EDGES.DL,
EDGES.BR,
],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
);
// B (Back Face Clockwise)
MOVES["B"] = new DeepCube(
[
CORNERS.URF,
CORNERS.UFL,
CORNERS.UBR,
CORNERS.DRB,
CORNERS.DFR,
CORNERS.DLF,
CORNERS.ULB,
CORNERS.DBL,
],
[0, 0, 1, 2, 0, 0, 2, 1],
[
EDGES.UR,
EDGES.UF,
EDGES.UL,
EDGES.BR,
EDGES.DR,
EDGES.DF,
EDGES.DL,
EDGES.BL,
EDGES.FR,
EDGES.FL,
EDGES.UB,
EDGES.DB,
],
[0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1],
);
// Generate inverses and 180s
const faces = ["U", "R", "F", "D", "L", "B"];
faces.forEach((f) => {
const m1 = MOVES[f];
const m2 = m1.multiply(m1);
const m3 = m2.multiply(m1);
MOVES[f + "2"] = m2;
MOVES[f + "'"] = m3;
});

View File

@@ -1,172 +0,0 @@
import { State } from 'rubiks-js/src/state/index.js';
import { CubeModel } from './CubeModel.js';
// Static order definitions from rubiks-js source
const CORNER_ORDER = ['URF', 'ULF', 'ULB', 'URB', 'DRF', 'DLF', 'DLB', 'DRB'];
const EDGE_ORDER = ['UF', 'UL', 'UB', 'UR', 'FR', 'FL', 'BL', 'BR', 'DF', 'DL', 'DB', 'DR'];
// Coordinate mapping for visualization
// Coordinates match the visual grid positions
const CORNER_SLOTS = [
{ id: 'URF', x: 1, y: 1, z: 1 },
{ id: 'ULF', x: -1, y: 1, z: 1 },
{ id: 'ULB', x: -1, y: 1, z: -1 },
{ id: 'URB', x: 1, y: 1, z: -1 },
{ id: 'DRF', x: 1, y: -1, z: 1 },
{ id: 'DLF', x: -1, y: -1, z: 1 },
{ id: 'DLB', x: -1, y: -1, z: -1 },
{ id: 'DRB', x: 1, y: -1, z: -1 }
];
const EDGE_SLOTS = [
{ id: 'UF', x: 0, y: 1, z: 1 },
{ id: 'UL', x: -1, y: 1, z: 0 },
{ id: 'UB', x: 0, y: 1, z: -1 },
{ id: 'UR', x: 1, y: 1, z: 0 },
{ id: 'FR', x: 1, y: 0, z: 1 },
{ id: 'FL', x: -1, y: 0, z: 1 },
{ id: 'BL', x: -1, y: 0, z: -1 },
{ id: 'BR', x: 1, y: 0, z: -1 },
{ id: 'DF', x: 0, y: -1, z: 1 },
{ id: 'DL', x: -1, y: -1, z: 0 },
{ id: 'DB', x: 0, y: -1, z: -1 },
{ id: 'DR', x: 1, y: -1, z: 0 }
];
const CENTERS = [
{ id: 'c0', x: 0, y: 1, z: 0, faces: { up: 'white' } },
{ id: 'c1', x: 0, y: -1, z: 0, faces: { down: 'yellow' } },
{ id: 'c2', x: 0, y: 0, z: 1, faces: { front: 'green' } },
{ id: 'c3', x: 0, y: 0, z: -1, faces: { back: 'blue' } },
{ id: 'c4', x: -1, y: 0, z: 0, faces: { left: 'orange' } },
{ id: 'c5', x: 1, y: 0, z: 0, faces: { right: 'red' } },
{ id: 'core', x: 0, y: 0, z: 0, faces: {} }
];
// Face mapping for pieces
// Each piece (e.g. URF) has 3 faces. We need to map them to colors based on orientation.
// Standard color scheme: U=white, D=yellow, F=green, B=blue, L=orange, R=red
const FACE_COLORS = {
U: 'white', D: 'yellow', F: 'green', B: 'blue', L: 'orange', R: 'red'
};
// Map piece name (e.g. 'URF') to its primary face keys
const CORNER_FACES = {
'URF': ['up', 'right', 'front'],
'ULF': ['up', 'front', 'left'],
'ULB': ['up', 'left', 'back'],
'URB': ['up', 'back', 'right'],
'DRF': ['down', 'right', 'front'],
'DLF': ['down', 'left', 'front'],
'DLB': ['down', 'back', 'left'],
'DRB': ['down', 'right', 'back']
};
const EDGE_FACES = {
'UF': ['up', 'front'],
'UL': ['up', 'left'],
'UB': ['up', 'back'],
'UR': ['up', 'right'],
'FR': ['front', 'right'],
'FL': ['front', 'left'],
'BL': ['back', 'left'],
'BR': ['back', 'right'],
'DF': ['down', 'front'],
'DL': ['down', 'left'],
'DB': ['down', 'back'],
'DR': ['down', 'right']
};
// Map piece name to its solved colors
const getCornerColors = (name) => {
// URF -> white, red, green
const map = {
'URF': ['white', 'red', 'green'],
'ULF': ['white', 'green', 'orange'],
'ULB': ['white', 'orange', 'blue'],
'URB': ['white', 'blue', 'red'],
'DRF': ['yellow', 'red', 'green'],
'DLF': ['yellow', 'orange', 'green'], // Adjusted to match DLF face order (D, L, F)
'DLB': ['yellow', 'blue', 'orange'], // Adjusted to match DLB face order (D, B, L)
'DRB': ['yellow', 'red', 'blue'] // Adjusted to match DRB face order (D, R, B)
};
return map[name];
};
const getEdgeColors = (name) => {
const map = {
'UF': ['white', 'green'],
'UL': ['white', 'orange'],
'UB': ['white', 'blue'],
'UR': ['white', 'red'],
'FR': ['green', 'red'],
'FL': ['green', 'orange'],
'BL': ['blue', 'orange'],
'BR': ['blue', 'red'],
'DF': ['yellow', 'green'],
'DL': ['yellow', 'orange'],
'DB': ['yellow', 'blue'],
'DR': ['yellow', 'red']
};
return map[name];
};
export class RubiksJSModel {
constructor() {
this.state = new State(false); // trackCenters=false
this.visual = new CubeModel();
}
reset() {
this.state = new State(false);
this.visual = new CubeModel();
}
rotateLayer(axis, index, dir) {
let move = '';
if (axis === 'y') {
if (index === 1) move = dir === 1 ? "U'" : "U";
else if (index === -1) move = dir === 1 ? "D'" : "D";
}
else if (axis === 'x') {
if (index === 1) move = dir === 1 ? "R'" : "R";
else if (index === -1) move = dir === 1 ? "L'" : "L";
}
else if (axis === 'z') {
if (index === 1) move = dir === 1 ? "F'" : "F";
else if (index === -1) move = dir === 1 ? "B'" : "B";
}
if (move) {
console.log('[RubiksJSModel] Applying move:', move);
try {
this.state.applyTurn(move);
console.log('[RubiksJSModel] Move applied successfully');
} catch (e) {
console.error('[RubiksJSModel] Failed to apply move:', move, e);
}
this.visual.rotateLayer(axis, index, dir);
}
}
applyTurn(move) {
if (!move) return;
try {
this.state.applyTurn(move);
} catch (e) {
console.error('[RubiksJSModel] Failed to apply direct move:', move, e);
}
this.visual.applyMove(move);
}
toCubies() {
return this.visual.toCubies();
}
validate() {
// State doesn't expose validate, but we can assume it's valid if using the library
return { valid: true, errors: [] };
}
}

View File

@@ -0,0 +1,376 @@
import { DeepCube, MOVES } from "../DeepCube.js";
export class BeginnerSolver {
constructor(cube) {
this.cube = cube.clone();
this.solution = [];
}
apply(moveStr) {
const moveArr = moveStr.split(" ").filter((m) => m);
for (const m of moveArr) {
if (!MOVES[m]) throw new Error(`Invalid move: ${m}`);
this.solution.push(m);
this.cube = this.cube.multiply(MOVES[m]);
}
}
solve() {
this.solution = [];
// Safety check - is it already solved?
if (this.isSolvedState(this.cube)) return [];
console.log("Starting Cross");
this.solveCross();
console.log("Starting F2L Corners");
this.solveF2LCorners();
console.log("Starting F2L Edges");
this.solveF2LEdges();
console.log("Starting Yellow Cross");
this.solveYellowCross();
console.log("Starting Yellow OLL");
this.orientYellowCorners();
console.log("Starting Yellow PLL");
this.permuteYellowCorners();
this.permuteYellowEdges();
// Optional: align U face
this.alignUFace();
// Collapse redundant moves like U U -> U2, R R' -> nothing
this.optimizeSolution();
return this.solution;
}
isSolvedState(state) {
for (let i = 0; i < 8; i++)
if (state.cp[i] !== i || state.co[i] !== 0) return false;
for (let i = 0; i < 12; i++)
if (state.ep[i] !== i || state.eo[i] !== 0) return false;
return true;
}
// Generalized Depth-Limited Search for localized piece-by-piece goals
solveGoal(condition, maxDepth, allowedMoves = Object.keys(MOVES)) {
if (condition(this.cube)) return true;
const opposing = { U: "D", D: "U", L: "R", R: "L", F: "B", B: "F" };
const dfs = (node, depth, lastMove) => {
if (depth === 0) return condition(node) ? [] : null;
const lastFace = lastMove ? lastMove[0] : null;
for (let m of allowedMoves) {
const face = m[0];
if (face === lastFace) continue;
if (opposing[face] === lastFace && face > lastFace) continue;
const nextNode = node.multiply(MOVES[m]);
if (depth === 1) {
if (condition(nextNode)) return [m];
} else {
const path = dfs(nextNode, depth - 1, m);
if (path) return [m, ...path];
}
}
return null;
};
for (let d = 1; d <= maxDepth; d++) {
const res = dfs(this.cube, d, "");
if (res) {
res.forEach((m) => this.apply(m));
return true;
}
}
return false;
}
solveCross() {
// White Cross on D face
// Edge Pieces: DF(5), DR(4), DB(7), DL(6)
const targets = [5, 4, 7, 6];
for (let i = 0; i < targets.length; i++) {
const goal = (state) => {
for (let j = 0; j <= i; j++) {
const t = targets[j];
if (state.ep[t] !== t || state.eo[t] !== 0) return false;
}
return true;
};
if (!this.solveGoal(goal, 7)) throw new Error("Failed Cross depth 7");
}
}
solveF2LCorners() {
// DFR(4), DRB(7), DBL(6), DLF(5)
const corners = [4, 7, 6, 5];
for (let i = 0; i < corners.length; i++) {
const goal = (state) => {
// Must preserve cross
if (state.ep[5] !== 5 || state.eo[5] !== 0) return false;
if (state.ep[4] !== 4 || state.eo[4] !== 0) return false;
if (state.ep[7] !== 7 || state.eo[7] !== 0) return false;
if (state.ep[6] !== 6 || state.eo[6] !== 0) return false;
// Must preserve prior corners
for (let j = 0; j <= i; j++) {
const c = corners[j];
if (state.cp[c] !== c || state.co[c] !== 0) return false;
}
return true;
};
if (!this.solveGoal(goal, 8))
throw new Error("Failed F2L Corners depth 8");
}
}
solveF2LEdges() {
// FR(8), BR(11), BL(10), FL(9)
const edges = [8, 11, 10, 9];
const allowed = [
"U",
"U'",
"U2",
"R",
"R'",
"R2",
"F",
"F'",
"F2",
"L",
"L'",
"L2",
"B",
"B'",
"B2",
]; // Avoid D moves
for (let i = 0; i < edges.length; i++) {
const goal = (state) => {
// Preserve cross
if (state.ep[5] !== 5 || state.eo[5] !== 0) return false;
if (state.ep[4] !== 4 || state.eo[4] !== 0) return false;
if (state.ep[7] !== 7 || state.eo[7] !== 0) return false;
if (state.ep[6] !== 6 || state.eo[6] !== 0) return false;
// Preserve all D corners
if (state.cp[4] !== 4 || state.co[4] !== 0) return false;
if (state.cp[7] !== 7 || state.co[7] !== 0) return false;
if (state.cp[6] !== 6 || state.co[6] !== 0) return false;
if (state.cp[5] !== 5 || state.co[5] !== 0) return false;
// Preserve prior edges
for (let j = 0; j <= i; j++) {
const e = edges[j];
if (state.ep[e] !== e || state.eo[e] !== 0) return false;
}
return true;
};
// Depth 10 is needed for inserting edge from bad positions
if (!this.solveGoal(goal, 10, allowed))
throw new Error("Failed F2L Edges depth 10");
}
}
// --- BEGIN CFOP LAST LAYER MACROS ---
solveYellowCross() {
// Find yellow cross edges: UR(0), UF(1), UL(2), UB(3)
// They just need orientation eo=0.
const getOrientedCount = () =>
[0, 1, 2, 3].filter((i) => this.cube.eo[i] === 0).length;
let safetyCount = 0;
while (getOrientedCount() < 4 && safetyCount++ < 10) {
const oriented = [0, 1, 2, 3].filter((i) => this.cube.eo[i] === 0);
if (oriented.length === 0) {
this.apply("F R U R' U' F'");
} else if (oriented.length === 2) {
// Line (opposite) or L-shape (adjacent)
const [a, b] = oriented;
if (Math.abs(a - b) === 2) {
// Line geometry (UR and UL, or UF and UB)
// To apply F R U R' U' F', the line must be horizontal.
// If line is UR(0) and UL(2), it's horizontal from F perspective.
if (a === 0 && b === 2) {
this.apply("F R U R' U' F'");
} else {
this.apply("U"); // turn line so it is UR/UL
this.apply("F R U R' U' F'");
}
} else {
// L-shape geometry
// Macro: F U R U' R' F' requires L to be at Back and Left (UB=3, UL=2)
if (oriented.includes(3) && oriented.includes(2)) {
this.apply("F U R U' R' F'");
} else {
this.apply("U");
}
}
}
}
}
orientYellowCorners() {
// Sune / Antisune to orient yellow corners (co=0)
// OLL for corners alone using the repetitive beginner algorithm (R' D' R D)
// Position the unsolved corner at URF (0) and repeat R' D' R D until co[0] === 0
let safetyCount = 0;
while (safetyCount++ < 20) {
let solvedCount = [0, 1, 2, 3].filter(
(i) => this.cube.co[i] === 0,
).length;
if (solvedCount === 4) break;
if (this.cube.co[0] === 0) {
this.apply("U"); // Next
} else {
// Apply R' D' R D twice (1 cycle)
this.apply("R' D' R D R' D' R D");
}
}
}
permuteYellowCorners() {
// Beginner method: look for "headlights" (two corners on the same face with the same color targeting that face).
// Mathematically: two adjacent U corners whose permutation matches their distance.
// E.g., URF(0) and UBR(3). If their permuted values are also adjacent, they are headlights.
const hasHeadlightsOnBack = () => {
// Back corners are ULB(2) and UBR(3)
// To be headlights on the back, they must belong to the same face in their solved state.
// Wait, comparing corner colors mathematically:
// In a solved cube, the U face has 4 corners: 0, 1, 2, 3.
// The distance between cp[2] and cp[3] modulo 4 should be exactly 1 or 3 (adjacent).
// Actually, if they are correctly relative to EACH OTHER:
return (
(this.cube.cp[2] - this.cube.cp[3] + 4) % 4 === 3 ||
(this.cube.cp[2] - this.cube.cp[3] + 4) % 4 === 1
);
};
// Simplified: Just keep applying the A-perm (headlight creator) until all 4 corners are relatively solved.
let safetyCount = 0;
while (safetyCount++ < 10) {
// Check if corners are relatively solved (i.e. 0->1->2->3 in order)
let c0 = this.cube.cp[0],
c1 = this.cube.cp[1],
c2 = this.cube.cp[2],
c3 = this.cube.cp[3];
if (
(c1 - c0 + 4) % 4 === 1 &&
(c2 - c1 + 4) % 4 === 1 &&
(c3 - c2 + 4) % 4 === 1
) {
break; // All corners are cyclically ordered
}
// If Back corners are cyclically ordered (Headlights on Back)
if ((c2 - c3 + 4) % 4 === 1 || (c3 - c2 + 4) % 4 === 3) {
// Wait, order must be 3->0->1->2. So ULB(2) should be next after UBR(3).
// If cp[2] comes immediately after cp[3] cyclically:
if ((this.cube.cp[2] - this.cube.cp[3] + 4) % 4 === 1) {
this.apply("R' F R' B2 R F' R' B2 R2");
} else {
this.apply("U");
}
} else {
// Find ANY headlights and put them on the back
if ((c1 - c2 + 4) % 4 === 1) {
this.apply("U");
continue;
}
if ((c0 - c1 + 4) % 4 === 1) {
this.apply("U2");
continue;
}
if ((c3 - c0 + 4) % 4 === 1) {
this.apply("U'");
continue;
}
// No headlights at all (diagonal swap), just apply the alg anywhere
this.apply("R' F R' B2 R F' R' B2 R2");
}
}
}
permuteYellowEdges() {
// Corners are properly ordered now. We just need to align them to the centers first.
while (this.cube.cp[0] !== 0) {
this.apply("U");
}
// Now corners are 100% solved. Check edges.
let safetyCount = 0;
while (safetyCount++ < 10) {
if (
this.cube.ep[0] === 0 &&
this.cube.ep[1] === 1 &&
this.cube.ep[2] === 2 &&
this.cube.ep[3] === 3
) {
break; // fully solved!
}
// Check if one edge is solved. If so, put it at the BACK (UB=3)
if (this.cube.ep[3] === 3) {
// Apply U-perm
this.apply("R U' R U R U R U' R' U' R2");
} else if (this.cube.ep[0] === 0) {
this.apply("U"); // turn whole cube or just U... wait, standard alg rotates U.
// To keep corners solved but move edges, we'd have to do y rotations.
// Let's just use the U-perm and see if it solves.
// If UR(0) is solved, we want it at UB(3). So we do a U move, apply alg, do U'.
// Wait, applying U moves the corners! We can't do that.
// Standard solving often uses `y` cube rotations. Since our model only supports face moves,
// we can use the transposed algorithms.
// Alg for solved Right(0): F U' F U F U F U' F' U' F2
this.apply("F U' F U F U F U' F' U' F2");
} else if (this.cube.ep[1] === 1) {
this.apply("L U' L U L U L U' L' U' L2");
} else if (this.cube.ep[2] === 2) {
this.apply("B U' B U B U B U' B' U' B2");
} else {
// No edges solved. Apply U-perm from anywhere.
this.apply("R U' R U R U R U' R' U' R2");
}
}
}
alignUFace() {
while (this.cube.cp[0] !== 0) {
this.apply("U");
}
}
optimizeSolution() {
// A quick pass to cancel out redundant moves like U U' -> nothing, U U -> U2, U2 U -> U'
let stable = false;
while (!stable) {
stable = true;
for (let i = 0; i < this.solution.length - 1; i++) {
const a = this.solution[i];
const b = this.solution[i + 1];
if (a[0] === b[0]) {
// Same face! Let's sum the rotation.
const val = (m) => (m.includes("'") ? -1 : m.includes("2") ? 2 : 1);
let sum = (val(a) + val(b)) % 4;
if (sum < 0) sum += 4; // normalize to positive
this.solution.splice(i, 2); // remove both
if (sum === 1) this.solution.splice(i, 0, a[0]);
else if (sum === 2) this.solution.splice(i, 0, a[0] + "2");
else if (sum === 3) this.solution.splice(i, 0, a[0] + "'");
stable = false;
break;
}
}
}
}
}

View File

@@ -0,0 +1,139 @@
import Cube from "cubejs";
// Initialize the core pruning tables on module load
Cube.initSolver();
import { DeepCube, CORNERS, EDGES } from "../DeepCube.js";
export class KociembaSolver {
constructor(cube) {
this.cube = cube.clone();
}
// Convert DeepCube permutation/orientation to Kociemba facelet string
// Kociemba format: U1..U9 R1..R9 F1..F9 D1..D9 L1..L9 B1..B9
toFaceletString() {
// Array of 54 characters representing the 6 faces.
// 0..8 = U
// 9..17 = R
// 18..26 = F
// 27..35 = D
// 36..44 = L
// 45..53 = B
const f = new Array(54).fill(" ");
// Centers
f[4] = "U";
f[13] = "R";
f[22] = "F";
f[31] = "D";
f[40] = "L";
f[49] = "B";
// DeepCube to Kociemba mapping:
// Corners:
// 0: URF, 1: UFL, 2: ULB, 3: UBR, 4: DFR, 5: DLF, 6: DBL, 7: DRB
// Edges:
// 0: UR, 1: UF, 2: UL, 3: UB, 4: DR, 5: DF, 6: DL, 7: DB, 8: FR, 9: FL, 10: BL, 11: BR
const cornerColors = [
["U", "R", "F"], // 0: URF
["U", "F", "L"], // 1: UFL
["U", "L", "B"], // 2: ULB
["U", "B", "R"], // 3: UBR
["D", "F", "R"], // 4: DFR
["D", "L", "F"], // 5: DLF
["D", "B", "L"], // 6: DBL
["D", "R", "B"], // 7: DRB
];
const cornerFacelets = [
[8, 9, 20], // URF (U9, R1, F3)
[6, 18, 38], // UFL (U7, F1, L3)
[0, 36, 47], // ULB (U1, L1, B3)
[2, 45, 11], // UBR (U3, B1, R3)
[29, 26, 15], // DFR (D3, F9, R7)
[27, 44, 24], // DLF (D1, L9, F7)
[33, 53, 42], // DBL (D7, B9, L7)
[35, 17, 51], // DRB (D9, R9, B7)
];
for (let i = 0; i < 8; i++) {
const perm = this.cube.cp[i];
const ori = this.cube.co[i];
// The physical piece at position `i` is `perm`.
// Its colors are cornerColors[perm].
// Because of orientation, the colors are shifted.
// If ori=0, U/D color is on U/D face.
// If ori=1, U/D color is twisted clockwise.
// If ori=2, U/D color is twisted counter-clockwise.
const c0 = cornerColors[perm][(0 - ori + 3) % 3];
const c1 = cornerColors[perm][(1 - ori + 3) % 3];
const c2 = cornerColors[perm][(2 - ori + 3) % 3];
f[cornerFacelets[i][0]] = c0;
f[cornerFacelets[i][1]] = c1;
f[cornerFacelets[i][2]] = c2;
}
const edgeColors = [
["U", "R"], // 0: UR
["U", "F"], // 1: UF
["U", "L"], // 2: UL
["U", "B"], // 3: UB
["D", "R"], // 4: DR
["D", "F"], // 5: DF
["D", "L"], // 6: DL
["D", "B"], // 7: DB
["F", "R"], // 8: FR
["F", "L"], // 9: FL
["B", "L"], // 10: BL
["B", "R"], // 11: BR
];
const edgeFacelets = [
[5, 10], // UR (U6, R2)
[7, 19], // UF (U8, F2)
[3, 37], // UL (U4, L2)
[1, 46], // UB (U2, B2)
[32, 16], // DR (D6, R8)
[28, 25], // DF (D2, F8)
[30, 43], // DL (D4, L8)
[34, 52], // DB (D8, B8)
[23, 12], // FR (F6, R4)
[21, 41], // FL (F4, L6)
[50, 39], // BL (B6, L4)
[48, 14], // BR (B4, R6)
];
for (let i = 0; i < 12; i++) {
const perm = this.cube.ep[i];
const ori = this.cube.eo[i];
const e0 = edgeColors[perm][(0 + ori) % 2];
const e1 = edgeColors[perm][(1 + ori) % 2];
f[edgeFacelets[i][0]] = e0;
f[edgeFacelets[i][1]] = e1;
}
return f.join("");
}
solve() {
const faceletStr = this.toFaceletString();
try {
const cube = Cube.fromString(faceletStr);
if (cube.isSolved()) return [];
const solution = cube.solve();
if (!solution) return [];
return solution.split(" ").filter((m) => m);
} catch (e) {
throw new Error(
`Kociemba Solve Failed: ${e.message} \nFacelet: ${faceletStr}`,
);
}
}
}

View File

@@ -1,21 +1,22 @@
import { RubiksJSModel } from '../utils/RubiksJSModel.js';
import { RubiksJSModel } from "../utils/CubeLogicAdapter.js";
const cube = new RubiksJSModel();
// Helper to send state update
const sendUpdate = () => {
try {
const cubies = cube.toCubies();
// console.log('[Worker] Sending update with cubies:', cubies.length);
postMessage({
type: 'STATE_UPDATE',
type: "STATE_UPDATE",
payload: {
cubies
}
cubies,
},
});
} catch (e) {
console.error('[Worker] Error generating cubies:', e);
postMessage({ type: 'ERROR', payload: e.message });
console.error("[Worker] Error generating cubies:", e);
postMessage({ type: "ERROR", payload: e.message });
}
};
@@ -23,31 +24,31 @@ self.onmessage = (e) => {
const { type, payload } = e.data;
switch (type) {
case 'INIT':
case 'RESET':
case "INIT":
case "RESET":
cube.reset();
sendUpdate();
break;
case 'ROTATE_LAYER': {
const { axis, index, direction } = payload;
cube.rotateLayer(axis, index, direction);
case "ROTATE_LAYER": {
const { axis, index, direction, steps = 1 } = payload;
cube.rotateLayer(axis, index, direction, steps);
sendUpdate();
break;
}
case 'TURN': {
case "TURN": {
const { move } = payload;
cube.applyTurn(move);
sendUpdate();
break;
}
case 'VALIDATE':
case "VALIDATE":
const validation = cube.validate();
postMessage({
type: 'VALIDATION_RESULT',
payload: { valid: validation.valid, errors: validation.errors }
type: "VALIDATION_RESULT",
payload: { valid: validation.valid, errors: validation.errors },
});
break;
}