58 Commits

Author SHA1 Message Date
a39ebd606f Update src/components/Footer.vue
All checks were successful
Deploy to Production / deploy (push) Successful in 6s
2026-02-26 22:28:18 +00:00
53fb65091c Update src/components/Footer.vue
All checks were successful
Deploy to Production / deploy (push) Successful in 15s
2026-02-26 22:27:18 +00:00
04c6934ad8 0.6.1
All checks were successful
Deploy to Production / deploy (push) Successful in 15s
2026-02-24 18:05:35 +00:00
97157ddb7a feat: 3-state projection toggle, English UI titles, fix simple mode 2026-02-24 18:05:32 +00:00
4be710a69f 0.6.0
All checks were successful
Deploy to Production / deploy (push) Successful in 16s
2026-02-24 17:20:20 +00:00
3bd919a1cf feat: refactor utils, add tokenReducer, rear face projections with toggle 2026-02-24 17:20:17 +00:00
d82eef86f9 0.5.5
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-24 16:42:22 +00:00
54abcf3414 feat: add tokenReducer, vitest tests, fix merge label convention 2026-02-24 16:42:19 +00:00
68e163270e 0.5.4
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-24 13:40:23 +00:00
9b02b1d9d6 refactor: extract matrix, moveMapping, easing, cubeProjection utils from SmartCube 2026-02-24 13:40:20 +00:00
b71594d0ab 0.5.3
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-24 13:13:31 +00:00
94e1cb7ed3 feat: camera reset button with SLERP, fix drag labels and solver mapping 2026-02-24 13:13:28 +00:00
fd090c6960 0.5.2 2026-02-24 12:46:24 +00:00
fccc43d0eb fix: resolve middle slice state sync and zero-step drag freeze
All checks were successful
Deploy to Production / deploy (push) Successful in 6s
2026-02-24 12:46:08 +00:00
8a20531fa0 feat: separate solver logic into dedicated web worker, improve toast notifications
All checks were successful
Deploy to Production / deploy (push) Successful in 21s
2026-02-24 09:56:16 +00:00
dc95b07188 fix: remove native devDependencies causing production build failures
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-24 00:10:00 +00:00
281614502e style: use default toastify css look for solver notifications
Some checks failed
Deploy to Production / deploy (push) Failing after 9s
2026-02-24 00:03:24 +00:00
4ab408b329 style: remove focus outline from solver dropdown buttons 2026-02-23 23:55:36 +00:00
c7d369c46a feat: integrate toastify-js and add solve guard with solved check
Some checks failed
Deploy to Production / deploy (push) Failing after 10s
2026-02-23 23:53:47 +00:00
349e74d7ef docs: overwrite README with project description and centered preview image
All checks were successful
Deploy to Production / deploy (push) Successful in 5s
2026-02-23 23:26:56 +00:00
6089e6f961 0.5.0
All checks were successful
Deploy to Production / deploy (push) Successful in 15s
2026-02-23 22:04:49 +00:00
e5befab473 fix(solver): replace exponential IDDFS recursion with instantaneous heuristic simulation macros 2026-02-23 22:04:41 +00:00
929761ac9e 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.
2026-02-23 21:49:21 +00:00
f6b34449df 0.4.2
All checks were successful
Deploy to Production / deploy (push) Successful in 10s
2026-02-23 19:42:40 +00:00
21e3465be9 fix(ui): make programmatic moveQueue reactive to immediately reflect intercepted changes like FFF towards F' 2026-02-23 19:42:19 +00:00
ce4a183090 Disable copy/reset actions when move queue is empty 2026-02-23 17:28:33 +00:00
bc7ae67412 Refactor SmartCube controls and move history into separate components 2026-02-23 17:25:59 +00:00
a49ca8f98e 0.4.1
All checks were successful
Deploy to Production / deploy (push) Successful in 8s
2026-02-23 01:14:19 +00:00
afac47c634 chore: adjust panel background for modal 2026-02-23 01:14:10 +00:00
31015366be 0.4.0
All checks were successful
Deploy to Production / deploy (push) Successful in 9s
2026-02-23 01:09:36 +00:00
880d46be1c chore: tweak add-moves modal layout 2026-02-23 01:09:10 +00:00
8d5521e326 0.3.1
All checks were successful
Deploy to Production / deploy (push) Successful in 9s
2026-02-23 00:51:21 +00:00
b5e407f738 chore: refine moves queue layout gap 2026-02-23 00:51:06 +00:00
de736e1daf 0.3.0
All checks were successful
Deploy to Production / deploy (push) Successful in 9s
2026-02-23 00:24:09 +00:00
482da718f3 chore: cube queue UI and copy/reset actions 2026-02-23 00:23:54 +00:00
9cd3de40e8 chore: tweak light theme gradient 2026-02-22 22:10:25 +00:00
ef6a7fed4f chore: bump version to 0.2.0
All checks were successful
Deploy to Production / deploy (push) Successful in 8s
2026-02-22 21:58:02 +00:00
c60a37d0cc Adjust cube control button mappings 2026-02-22 21:52:18 +00:00
3261aea81d chore: remove projections toggle and bump to 0.1.0
All checks were successful
Deploy to Production / deploy (push) Successful in 10s
2026-02-22 21:11:42 +00:00
86c4a18851 fix: sync layer animation with worker updates 2026-02-22 21:07:08 +00:00
eb9e2f993d chore: adjust cube opacity
All checks were successful
Deploy to Production / deploy (push) Successful in 5s
2026-02-22 20:51:14 +00:00
4aea776207 feat: cube transparency toggle
All checks were successful
Deploy to Production / deploy (push) Successful in 6s
2026-02-22 20:48:29 +00:00
a75c148a5b chore: bump version to 0.0.29
All checks were successful
Deploy to Production / deploy (push) Successful in 9s
2026-02-22 20:31:54 +00:00
a9881bb5fa Bump version to 0.0.28
All checks were successful
Deploy to Production / deploy (push) Successful in 8s
2026-02-22 19:53:15 +00:00
0141cd404b Update config
Some checks failed
Deploy to Production / deploy (push) Failing after 4s
2026-02-22 19:49:32 +00:00
5c4eeeb3a9 Align docker compose with piggy-bank
All checks were successful
Deploy to Production / deploy (push) Successful in 5s
2026-02-22 19:40:06 +00:00
a7e97b0d9f Adjust docker compose networking
All checks were successful
Deploy to Production / deploy (push) Successful in 5s
2026-02-22 19:31:16 +00:00
c4b78ad7b7 Update CI and cube projections
Some checks failed
Deploy to Production / deploy (push) Failing after 5s
2026-02-22 19:23:25 +00:00
d536290e5d Adjust projections and CI config
Some checks failed
Deploy to Production / deploy (push) Failing after 4s
2026-02-22 19:19:03 +00:00
6eb60e1cb3 Align rubic-cube docker setup with nonograms
All checks were successful
Deploy to Production / deploy (push) Successful in 8s
2026-02-22 15:57:59 +00:00
a1574a149e Refactor: Remove unused components (Main, CubeCSS, etc.), utils (DeepCube, Matrix4), dependencies (matrix-js, utils), and untrack dist folder
All checks were successful
Deploy to Production / deploy (push) Successful in 9s
2026-02-22 04:46:32 +00:00
3857b926ff Fix: Remove node_modules from git tracking and update .gitignore
All checks were successful
Deploy to Production / deploy (push) Successful in 5s
2026-02-22 04:39:07 +00:00
15447c106a 0.0.27
All checks were successful
Deploy to Production / deploy (push) Successful in 11s
2026-02-22 04:36:04 +00:00
b5ddc21662 Refactor: Implement SmartCube renderer, improve UI styling, and fix gaps 2026-02-22 04:35:59 +00:00
57abfd6b80 0.0.6
All checks were successful
Deploy to Production / deploy (push) Successful in 9s
2026-02-16 03:24:59 +01:00
ae3d650011 fix: restore layout and add dynamic version to footer 2026-02-16 03:24:54 +01:00
14ba647f42 0.0.5
All checks were successful
Deploy to Production / deploy (push) Successful in 9s
2026-02-16 03:19:40 +01:00
c773ff8876 fix: invert rotation logic and add debug tools 2026-02-16 03:19:02 +01:00
65 changed files with 9941 additions and 1413 deletions

View File

@@ -1,5 +1,4 @@
name: Deploy to Production name: Deploy to Production
run-name: Deploy to Production by @${{ github.actor }}
on: on:
push: push:
@@ -15,9 +14,6 @@ jobs:
- name: Build and deploy with Docker Compose - name: Build and deploy with Docker Compose
run: | run: |
# Próba zatrzymania i usunięcia starego kontenera (ignoruje błąd jeśli nie istnieje) # Sprzątaj TYLKO swój projekt
docker compose down --remove-orphans || true docker compose down --remove-orphans || true
docker rm -f rubic-cube || true
# Start nowej wersji
docker compose up -d --build docker compose up -d --build

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.agent/
cache/

View File

@@ -1,5 +1,5 @@
# Stage 1: Build the application # Stage 1: Build the application
FROM node:18-alpine as build-stage FROM node:lts-alpine as build-stage
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app

View File

@@ -1,31 +1,33 @@
# Vue 3 + Vite # Rubik's Cube Logic Engine & Simulator
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. <div align="center">
<img src="./public/preview.png" alt="Cube Preview" width="600" />
</div>
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support). ## Overview
## Uruchamianie (Automatyczne - CI/CD) This application is a 3D animated, Interactive Rubik's Cube simulator and mathematical solver built from scratch using Vue.js. It aims to provide seamless mechanical interactions and mathematically perfectly rigorous tracking of a classic 3x3 Rubik's Cube state.
Projekt wykorzystuje **Gitea Actions** z runnerem **self-hosted** na serwerze produkcyjnym. The software operates entirely in the browser using a custom Group Theory mathematical engine (`DeepCube.js`), which separates the heavy analytical permutation tracking from the 3D CSS visual layer using Web Workers.
Każdy push do gałęzi `main` automatycznie:
1. Pobiera kod na serwerze.
2. Zatrzymuje i usuwa stare kontenery.
3. Buduje i uruchamia nową wersję aplikacji przy użyciu `docker compose up -d --build`.
### Konfiguracja Sieci i Bezpieczeństwa (Izolacja) ## Features
Aplikacja wykorzystuje dwie sieci dockerowe dla zapewnienia izolacji: - **Mechanical Realism:** 3D CSS rendering precisely models physical cube mechanics. Dragging edge and corner pieces rotates the specific mechanical layer dynamically, while dragging the center elements pivots the entire camera view.
1. `npm_public` (zewnętrzna): Sieć, w której znajduje się Nginx Proxy Manager. Tylko kontener `rubic-cube` jest do niej podłączony, aby NPM mógł przekierować ruch. - **Reactive Algorithm Queue:** Execute complex algorithms fluidly. The dynamic queue evaluates incoming inputs and instantly intercepts redundances (e.g. evaluating `U U` into a single `U2` animation, or cancelling out `F` into `F'` on the fly).
2. `rubic-net` (wewnętrzna): Prywatna sieć aplikacji. Wszelkie inne serwisy (np. baza danych, redis - jeśli dodasz w przyszłości) powinny być tylko w tej sieci, niewidoczne dla NPM ani innych aplikacji. - **Deep Mathematical Engine:** Based entirely on Group Theory. It stores corner and edge permutation arrays combined with spatial orientation parities to guarantee that only physically legal mechanical states exist or can be scrambled.
- **Intelligent Solvers:**
- **Beginner Method (Human):** Constructs the solution layer-by-layer simulating human heuristics natively with instantaneous $O(1)$ algorithmic macros.
- **Kociemba's Algorithm (Optimal):** Offloads pruning tables and recursive heuristic searches to Web Workers to instantly calculate and stream back the objectively shortest path solution (typically <20 moves).
- **High Performance:** Decoupling the single-threaded UI rendering stack from mathematical validations ensures 60 FPS 3D animations, even while executing computationally expensive analytical algorithms in the background.
## Development & Asset Generation
To keep the production build lightweight and avoid native dependency issues on servers (e.g. Docker), heavy packages like `canvas`, `puppeteer`, and `imagetracerjs` have been removed from the default `devDependencies`.
If you need to run the auxiliary scripts in `scripts/` (for screenshotting or regenerating `cube.svg`), you must install them manually:
**Wymagania:**
Przed uruchomieniem upewnij się, że na serwerze istnieje sieć publiczna dla proxy:
```bash ```bash
docker network create npm_public npm install -D canvas puppeteer imagetracerjs
``` ```
(Jeśli Twój Nginx Proxy Manager używa innej sieci, zaktualizuj nazwę w `docker-compose.yml`).
W panelu Nginx Proxy Manager skonfiguruj: These are only required for offline asset optimization and are not needed to build or run the main application.
* **Network:** `npm_public` (lub odpowiednia sieć proxy).
* **Forward Hostname:** `rubic-cube`
* **Forward Port:** `80`

View File

@@ -1,4 +1,4 @@
version: '3.8' name: rubic-cube
services: services:
rubic-cube: rubic-cube:
@@ -6,17 +6,16 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
# ports: ports:
# - "8083:80" - "8083:80"
expose: expose:
- "80" - "80"
restart: always restart: unless-stopped
# volumes:
# - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
networks: networks:
- npm_public - npm_public
- rubic-net
networks: networks:
npm_public: npm_public:
external: true external: true
rubic-net:
driver: bridge

View File

@@ -1,13 +1,31 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/assets/rubic-cube.svg" /> <link rel="icon" type="image/png" href="/favicon.png" sizes="32x32" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rubic Cube</title> <title>Rubic Cube Logic Engine</title>
</head>
<body> <meta name="description"
content="A 3D interactive Rubik's Cube simulator and solver powered by a custom mathematical engine." />
<!-- Open Graph (for social media sharing like Facebook/Discord) -->
<meta property="og:title" content="Rubic Cube Logic Engine" />
<meta property="og:description"
content="A 3D interactive Rubik's Cube simulator and solver powered by a custom mathematical engine." />
<meta property="og:image" content="/preview.png" />
<meta property="og:type" content="website" />
<!-- Twitter Cards -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Rubic Cube Logic Engine" />
<meta name="twitter:description"
content="A 3D interactive Rubik's Cube simulator and solver powered by a custom mathematical engine." />
<meta name="twitter:image" content="/preview.png" />
</head>
<body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,6 +1,7 @@
server { server {
listen 80; listen 80;
server_name localhost; listen [::]:80;
server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;

4794
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,23 @@
{ {
"name": "rubic-cube", "name": "rubic-cube",
"private": true, "private": true,
"version": "0.0.4", "version": "0.6.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@gkucmierz/utils": "^1.28.3", "cubejs": "^1.3.2",
"lucide-vue-next": "^0.564.0", "lucide-vue-next": "^0.564.0",
"matrix-js": "^1.8.0", "toastify-js": "^1.12.0",
"vue": "^3.5.13" "vue": "^3.5.13"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.2.0" "vite": "^6.2.0",
"vitest": "^4.0.18"
} }
} }

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 915 B

BIN
public/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

View File

@@ -1,16 +1,14 @@
<script setup> <script setup>
import Main from './components/Main.vue' import SmartCube from "./components/renderers/SmartCube.vue";
import NavBar from './components/NavBar.vue' import NavBar from "./components/NavBar.vue";
import Footer from './components/Footer.vue' import Footer from "./components/Footer.vue";
</script> </script>
<template> <template>
<NavBar /> <NavBar />
<div class="app-content">
<main class="app-content"> <SmartCube />
<Main /> </div>
</main>
<Footer /> <Footer />
</template> </template>
@@ -21,8 +19,10 @@ import Footer from './components/Footer.vue'
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 100%; width: 100%;
padding: 2rem 0; padding: 0;
position: relative; position: relative;
z-index: 1; z-index: 1;
user-select: none;
-webkit-user-select: none;
} }
</style> </style>

View File

@@ -1,55 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<style>
.face { stroke: #000; stroke-width: 6; stroke-linejoin: round; }
.top { fill: #ffffff; }
.left { fill: #009e60; }
.right { fill: #c41e3a; }
</style>
</defs>
<!-- Top Face -->
<g class="face top">
<path d="M256 80 L309.33 106.67 L256 133.33 L202.67 106.67 Z" />
<path d="M309.33 106.67 L362.67 133.33 L309.33 160 L256 133.33 Z" />
<path d="M362.67 133.33 L416 160 L362.67 186.67 L309.33 160 Z" />
<path d="M202.67 106.67 L256 133.33 L202.67 160 L149.33 133.33 Z" />
<path d="M256 133.33 L309.33 160 L256 186.67 L202.67 160 Z" />
<path d="M309.33 160 L362.67 186.67 L309.33 213.33 L256 186.67 Z" />
<path d="M149.33 133.33 L202.67 160 L149.33 186.67 L96 160 Z" />
<path d="M202.67 160 L256 186.67 L202.67 213.33 L149.33 186.67 Z" />
<path d="M256 186.67 L309.33 213.33 L256 240 L202.67 213.33 Z" />
</g>
<!-- Left Face -->
<g class="face left">
<path d="M96 160 L149.33 186.67 L149.33 248 L96 221.33 Z" />
<path d="M149.33 186.67 L202.67 213.33 L202.67 274.67 L149.33 248 Z" />
<path d="M202.67 213.33 L256 240 L256 301.33 L202.67 274.67 Z" />
<path d="M96 221.33 L149.33 248 L149.33 309.33 L96 282.67 Z" />
<path d="M149.33 248 L202.67 274.67 L202.67 336 L149.33 309.33 Z" />
<path d="M202.67 274.67 L256 301.33 L256 362.67 L202.67 336 Z" />
<path d="M96 282.67 L149.33 309.33 L149.33 370.67 L96 344 Z" />
<path d="M149.33 309.33 L202.67 336 L202.67 397.33 L149.33 370.67 Z" />
<path d="M202.67 336 L256 362.67 L256 424 L202.67 397.33 Z" />
</g>
<!-- Right Face -->
<g class="face right">
<path d="M256 240 L309.33 213.33 L309.33 274.67 L256 301.33 Z" />
<path d="M309.33 213.33 L362.67 186.67 L362.67 248 L309.33 274.67 Z" />
<path d="M362.67 186.67 L416 160 L416 221.33 L362.67 248 Z" />
<path d="M256 301.33 L309.33 274.67 L309.33 336 L256 362.67 Z" />
<path d="M309.33 274.67 L362.67 248 L362.67 309.33 L309.33 336 Z" />
<path d="M362.67 248 L416 221.33 L416 282.67 L362.67 309.33 Z" />
<path d="M256 362.67 L309.33 336 L309.33 397.33 L256 424 Z" />
<path d="M309.33 336 L362.67 309.33 L362.67 370.67 L309.33 397.33 Z" />
<path d="M362.67 309.33 L416 282.67 L416 344 L362.67 370.67 Z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,14 +1,12 @@
<script setup> <script setup>
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const version = __APP_VERSION__;
</script> </script>
<template> <template>
<footer class="app-footer glass-panel"> <footer class="app-footer glass-panel">
<div class="footer-content"> <div class="footer-content">
<p>&copy; {{ currentYear }} Rubic Cube. Wersja 0.0.1</p> <p>&copy; {{ currentYear }} Rubic Cube. v{{ version }}</p>
<div class="social-links">
<!-- Placeholder for social links if needed -->
</div>
</div> </div>
</footer> </footer>
</template> </template>
@@ -21,11 +19,15 @@ const currentYear = new Date().getFullYear();
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: auto; position: absolute;
background: var(--panel-bg); bottom: 0;
backdrop-filter: blur(10px); left: 0;
border-top: 1px solid var(--panel-border); z-index: 10;
box-shadow: 0 -4px 15px rgba(0, 0, 0, 0.1); /* Glass panel styles handle background/border/shadow */
border-radius: 0; /* Full width bar usually square corners or specific radius */
border-left: none;
border-right: none;
border-bottom: none;
color: var(--text-muted); color: var(--text-muted);
box-sizing: border-box; box-sizing: border-box;
} }

View File

@@ -1,40 +0,0 @@
<script setup>
import { computed } from 'vue'
import { useRenderer } from '../composables/useRenderer'
import CubeCSS from './renderers/CubeCSS.vue'
import CubeSVG from './renderers/CubeSVG.vue'
import CubeCanvas from './renderers/CubeCanvas.vue'
const { activeRenderer, RENDERERS } = useRenderer()
const currentRendererComponent = computed(() => {
switch (activeRenderer.value) {
case RENDERERS.CSS:
return CubeCSS
case RENDERERS.SVG:
return CubeSVG
case RENDERERS.CANVAS:
return CubeCanvas
default:
return CubeCSS
}
})
</script>
<template>
<div class="wrapper">
<component :is="currentRendererComponent" />
</div>
</template>
<style scoped>
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
width: 100%;
height: 100%;
justify-content: center;
}
</style>

View File

@@ -1,48 +1,35 @@
<script setup> <script setup>
import { Sun, Moon } from 'lucide-vue-next'; import { Sun, Moon, Grid2x2, Layers, Layers2, LayersPlus } from "lucide-vue-next";
import { ref, onMounted } from 'vue'; import { ref, onMounted, computed } from "vue";
import { useRenderer } from '../composables/useRenderer'; import { useSettings } from "../composables/useSettings";
const { activeRenderer, setRenderer, RENDERERS } = useRenderer();
const { isCubeTranslucent, toggleCubeTranslucent, projectionMode, cycleProjectionMode } = useSettings();
const isDark = ref(true); const isDark = ref(true);
const setTheme = (dark) => { const setTheme = (dark) => {
isDark.value = dark; isDark.value = dark;
const theme = dark ? 'dark' : 'light'; const theme = dark ? "dark" : "light";
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
localStorage.setItem("theme", theme);
if (dark) {
document.documentElement.style.setProperty('--bg-gradient', 'linear-gradient(135deg, #2c3e50 0%, #000000 100%)');
document.documentElement.style.setProperty('--text-color', '#ffffff');
document.documentElement.style.setProperty('--text-strong', '#ffffff');
document.documentElement.style.setProperty('--text-muted', 'rgba(255, 255, 255, 0.7)');
document.documentElement.style.setProperty('--glass-bg', 'rgba(255, 255, 255, 0.05)');
document.documentElement.style.setProperty('--glass-border', 'rgba(255, 255, 255, 0.1)');
document.documentElement.style.setProperty('--panel-bg', 'rgba(255, 255, 255, 0.05)');
document.documentElement.style.setProperty('--panel-border', 'rgba(255, 255, 255, 0.1)');
document.documentElement.style.setProperty('--cube-edge-color', '#333333');
document.documentElement.style.setProperty('--title-gradient', 'linear-gradient(45deg, #00f2fe, #4facfe)');
} else {
document.documentElement.style.setProperty('--bg-gradient', 'linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%)');
document.documentElement.style.setProperty('--text-color', '#0f172a');
document.documentElement.style.setProperty('--text-strong', '#0f172a');
document.documentElement.style.setProperty('--text-muted', 'rgba(15, 23, 42, 0.6)');
document.documentElement.style.setProperty('--glass-bg', 'rgba(255, 255, 255, 0.75)');
document.documentElement.style.setProperty('--glass-border', 'rgba(15, 23, 42, 0.12)');
document.documentElement.style.setProperty('--panel-bg', 'rgba(255, 255, 255, 0.7)');
document.documentElement.style.setProperty('--panel-border', 'rgba(15, 23, 42, 0.12)');
document.documentElement.style.setProperty('--cube-edge-color', '#000000');
document.documentElement.style.setProperty('--title-gradient', 'linear-gradient(45deg, #0ea5e9, #6366f1)');
}
}; };
const toggleTheme = () => { const toggleTheme = () => {
setTheme(!isDark.value); setTheme(!isDark.value);
}; };
const projectionTitle = computed(() => {
if (projectionMode.value === 0) return 'Show rear face projections';
if (projectionMode.value === 1) return 'Enable animated projections';
return 'Disable projections';
});
onMounted(() => { onMounted(() => {
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
setTheme(savedTheme === "dark");
} else {
setTheme(true); setTheme(true);
}
}); });
</script> </script>
@@ -53,20 +40,38 @@ onMounted(() => {
</div> </div>
<div class="nav-container"> <div class="nav-container">
<div class="renderer-selector"> <!-- Cube Opacity Toggle -->
<button <button
v-for="renderer in RENDERERS" class="btn-neon nav-btn icon-only"
:key="renderer" @click="toggleCubeTranslucent"
@click="setRenderer(renderer)" :title="
class="renderer-btn" isCubeTranslucent
:class="{ active: activeRenderer === renderer }" ? 'Disable cube transparency'
: 'Enable cube transparency'
"
:class="{ active: isCubeTranslucent }"
> >
{{ renderer }} <Grid2x2 :size="20" />
</button>
<!-- Face Projections Toggle (3-state) -->
<button
class="btn-neon nav-btn icon-only"
@click="cycleProjectionMode"
:title="projectionTitle"
:class="{ active: projectionMode > 0 }"
>
<Layers2 v-if="projectionMode === 0" :size="20" />
<Layers v-else-if="projectionMode === 1" :size="20" />
<LayersPlus v-else :size="20" />
</button> </button>
</div>
<!-- Theme Toggle --> <!-- 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 ? 'Switch to light mode' : 'Switch to dark mode'"
>
<Sun v-if="isDark" :size="20" /> <Sun v-if="isDark" :size="20" />
<Moon v-else :size="20" /> <Moon v-else :size="20" />
</button> </button>
@@ -80,17 +85,18 @@ onMounted(() => {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0 20px; padding: 0 20px;
height: 50px; height: 70px;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
position: sticky; position: absolute;
top: 0; top: 0;
left: 0;
z-index: 100; z-index: 100;
margin-bottom: 0; /* Glass panel styles handle background/border/shadow */
background: var(--glass-bg); border-radius: 0;
backdrop-filter: blur(10px); border-top: none;
border-bottom: 1px solid var(--glass-border); border-left: none;
box-shadow: var(--glass-shadow); border-right: none;
} }
.logo-container { .logo-container {
@@ -100,10 +106,10 @@ onMounted(() => {
} }
.logo-text { .logo-text {
font-size: 1.2rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
color: var(--text-color); color: var(--text-strong);
text-shadow: 0 0 20px var(--title-glow); letter-spacing: 1px;
} }
.nav-container { .nav-container {
@@ -112,67 +118,25 @@ onMounted(() => {
align-items: center; align-items: center;
} }
.renderer-selector {
display: flex;
gap: 5px;
background: rgba(0, 0, 0, 0.2);
padding: 3px;
border-radius: 6px;
}
.renderer-btn {
background: transparent;
border: none;
color: var(--text-muted);
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
transition: all 0.2s;
}
.renderer-btn:hover {
color: var(--text-color);
}
.renderer-btn.active {
background: var(--glass-border);
color: var(--text-color);
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.nav-btn { .nav-btn {
background: transparent; background: transparent;
border: none; border: none;
color: var(--text-color); color: var(--text-strong);
font-size: 1rem;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; padding: 12px;
padding: 8px 12px; border-radius: 50%;
border-radius: 8px;
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.nav-btn:hover { .nav-btn:hover,
background: rgba(255, 255, 255, 0.1); .nav-btn.active {
transform: translateY(-2px); background: rgba(255, 255, 255, 0.2);
} }
.btn-neon { .nav-btn.active {
border: 1px solid var(--toggle-btn-border); color: var(--color-primary);
box-shadow: 0 0 5px rgba(0,0,0,0.1); box-shadow: 0 0 10px rgba(255, 255, 255, 0.1);
}
.desktop-only {
display: flex;
}
@media (max-width: 768px) {
.desktop-only {
display: none;
}
} }
</style> </style>

View File

@@ -0,0 +1,59 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
start: {
type: Object,
required: true, // {x, y, z}
},
end: {
type: Object,
required: true, // {x, y, z}
},
color: {
type: String,
default: "var(--text-color, #fff)",
},
thickness: {
type: Number,
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",
transform: `translate3d(${midX}px, ${midY}px, ${midZ}px) rotateY(${-yaw}rad) rotateZ(${pitch}rad) translate(-50%, -50%)`,
opacity: 0.3, // Delicate
pointerEvents: "none",
};
});
</script>
<template>
<div class="line-3d" :style="style"></div>
</template>

View File

@@ -1,603 +0,0 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useCube } from '../../composables/useCube'
const { cubeState, initCube, rotateLayer, COLOR_MAP, FACES } = useCube()
// --- State ---
const rx = ref(25)
const ry = ref(25)
const rz = ref(0)
const isDragging = ref(false)
const dragMode = ref('view') // 'view' or 'layer'
const startMouseX = ref(0)
const startMouseY = ref(0)
const lastMouseX = ref(0)
const lastMouseY = ref(0)
const selectedCubieId = ref(null) // ID of the cubie where drag started
const selectedFaceNormal = ref(null) // Normal vector of the face clicked
// Animation state
const activeLayer = ref(null) // { axis: 'x'|'y'|'z', index: -1|0|1 }
const layerRotation = ref(0)
const isSnapping = ref(false)
const velocity = ref(0)
const lastTime = ref(0)
const rafId = ref(null)
// Cubies Model
// We represent the cube as 27 independent cubies.
// Each cubie has a current position (x, y, z) in grid coordinates [-1, 0, 1].
// And a rotation matrix (or simplified orientation).
// Actually, for CSS rendering, we can just keep track of their current (x,y,z) and applying transforms.
// But to match `useCube` state (which is color based), we need to map colors to cubies.
//
// Alternative:
// `useCube` maintains the logical state of colors on faces.
// To render 27 cubies that MOVE, we need to know which color belongs to which face of which cubie.
//
// Mapping:
// 27 Cubies. ID: 0..26.
// Position: x,y,z in {-1, 0, 1}.
//
// Colors:
// A cubie at (x,y,z) exposes faces if x/y/z is +/- 1.
// e.g. (1, 1, 1) is Right-Top-Front corner.
// It has 3 colored faces: Right, Top, Front.
// We need to fetch the color from `cubeState` at the correct indices.
//
// `cubeState` is organized by Faces.
// Front Face is a 3x3 matrix.
// (0,0) is Top-Left of Front Face.
// Front Face covers z=1 plane.
// x goes -1 (Left) to 1 (Right).
// y goes 1 (Top) to -1 (Bottom).
//
// Let's define the 27 cubies.
const cubies = ref([])
const initCubies = () => {
const newCubies = []
let id = 0
for (let x = -1; x <= 1; x++) {
for (let y = -1; y <= 1; y++) {
for (let z = -1; z <= 1; z++) {
newCubies.push({
id: id++,
x, y, z, // Current grid position
// Store initial rotation or accumulate transform?
// Simplest is to accumulate rotation transforms for the cubie div.
// But for logic, we update x,y,z after snap.
transform: ''
})
}
}
}
cubies.value = newCubies
}
// Map logical face colors to cubie faces
// We need a function that given a cubie (x,y,z) returns the colors of its 6 faces.
// If a face is internal, color is black (or null).
const getCubieFaces = (cubie) => {
const { x, y, z } = cubie
const faces = {}
// Helper to map grid (x,y) to Matrix indices (row, col)
// Grid: x (-1..1), y (-1..1).
// Matrix: row (0..2), col (0..2).
//
// Face UP (y=1). z: Back(-1)..Front(1). x: Left(-1)..Right(1).
// Up Matrix: row 0 is Back, row 2 is Front. col 0 is Left, col 2 is Right.
// So: row = z + 1? No.
// z=-1 -> row 0. z=0 -> row 1. z=1 -> row 2. Yes.
// x=-1 -> col 0. x=0 -> col 1. x=1 -> col 2. Yes.
if (y === 1) {
const row = z + 1
const col = x + 1
faces.up = getColor(FACES.UP, row, col)
}
// Face DOWN (y=-1). z: Back(-1)..Front(1). x: Left(-1)..Right(1).
// Down Matrix: row 0 is Front, row 2 is Back. col 0 is Left, col 2 is Right.
// Wait, check standard mapping in `Cube.js` or standard rubik.
// Usually unfolding:
// Up: Back row is top.
// Down: Front row is top?
// Let's assume standard intuitive mapping:
// Down Face viewed from bottom.
// Row 0 is Front (top of view).
// z=1 -> row 0. z=-1 -> row 2.
// So row = 1 - z.
// x=-1 -> col 0. x=1 -> col 2.
if (y === -1) {
const row = 1 - z
const col = x + 1
faces.down = getColor(FACES.DOWN, row, col)
}
// Face FRONT (z=1). y: Top(1)..Bottom(-1). x: Left(-1)..Right(1).
// Matrix: row 0 is Top.
// y=1 -> row 0. y=-1 -> row 2.
// row = 1 - y.
// x=-1 -> col 0.
if (z === 1) {
const row = 1 - y
const col = x + 1
faces.front = getColor(FACES.FRONT, row, col)
}
// Face BACK (z=-1). y: Top(1)..Bottom(-1). x: Right(1)..Left(-1)?
// Back Face viewed from Back.
// Left side of view is Cube Right (x=1).
// Right side of view is Cube Left (x=-1).
// Matrix: row 0 is Top.
// y=1 -> row 0.
// col 0 (Left of view) -> x=1.
// col 2 (Right of view) -> x=-1.
// col = 1 - x.
if (z === -1) {
const row = 1 - y
const col = 1 - x
faces.back = getColor(FACES.BACK, row, col)
}
// Face RIGHT (x=1). y: Top(1)..Bottom(-1). z: Front(1)..Back(-1).
// Right Face viewed from Right.
// Left side of view is Front (z=1).
// Right side of view is Back (z=-1).
// Matrix: row 0 is Top.
// y=1 -> row 0.
// col 0 -> z=1.
// col 2 -> z=-1.
// col = 1 - z.
if (x === 1) {
const row = 1 - y
const col = 1 - z
faces.right = getColor(FACES.RIGHT, row, col)
}
// Face LEFT (x=-1). y: Top(1)..Bottom(-1). z: Back(-1)..Front(1).
// Left Face viewed from Left.
// Left side of view is Back (z=-1).
// Right side of view is Front (z=1).
// Matrix: row 0 is Top.
// y=1 -> row 0.
// col 0 -> z=-1.
// col 2 -> z=1.
// col = z + 1.
if (x === -1) {
const row = 1 - y
const col = z + 1
faces.left = getColor(FACES.LEFT, row, col)
}
return faces
}
const getColor = (face, row, col) => {
if (!cubeState.value || !cubeState.value[face]) return 'black'
const colorIndex = cubeState.value[face][row][col]
return COLOR_MAP[colorIndex] || 'black'
}
// Mouse Interaction
const onMouseDown = (event) => {
if (isSnapping.value) return
isDragging.value = true
startMouseX.value = event.clientX
startMouseY.value = event.clientY
lastMouseX.value = event.clientX
lastMouseY.value = event.clientY
lastTime.value = performance.now()
velocity.value = 0 // Reset velocity
const target = event.target
const stickerEl = target.closest('.sticker-face')
if (stickerEl) {
// Clicked on a cubie face
const cubieId = parseInt(stickerEl.dataset.cubieId)
const faceName = stickerEl.dataset.face
selectedCubieId.value = cubieId
selectedFaceNormal.value = faceName // 'up', 'down', etc.
// Check if it's a center face (implies View Drag)?
// User wants drag to rotate layers if grabbing edge/corner.
// Center face of the whole cube? No, center face of a side.
// If I grab the center sticker of Front Face, I might want to rotate View OR Front Face?
// User said: "jedynie centralny element kostki dragowany, bedzie ja po prostu obracal"
// So Center Sticker -> View Drag.
// Center Sticker is when x,y,z has two zeros? No.
// Center of Front Face: (0,0,1).
// Edge: (1,0,1). Corner: (1,1,1).
const cubie = cubies.value.find(c => c.id === cubieId)
const isCenter = (Math.abs(cubie.x) + Math.abs(cubie.y) + Math.abs(cubie.z)) === 1
if (isCenter) {
dragMode.value = 'view'
document.body.style.cursor = 'move'
} else {
dragMode.value = 'layer'
document.body.style.cursor = 'grab'
}
} else {
dragMode.value = 'view'
selectedCubieId.value = null
document.body.style.cursor = 'move'
}
}
const onMouseMove = (event) => {
if (!isDragging.value) return
if (dragMode.value === 'layer') {
document.body.style.cursor = 'grabbing'
}
const deltaX = event.clientX - lastMouseX.value
const deltaY = event.clientY - lastMouseY.value
if (dragMode.value === 'view') {
ry.value += deltaX * 0.5
rx.value -= deltaY * 0.5
velocity.value = 0 // Reset velocity for view drag (or track it separately if needed)
} else if (dragMode.value === 'layer' && selectedCubieId.value !== null) {
const totalDeltaX = event.clientX - startMouseX.value
const totalDeltaY = event.clientY - startMouseY.value
// Calculate velocity
const now = performance.now()
const dt = now - lastTime.value
lastTime.value = now
// We only care about velocity of rotation, so we calculate it inside updateLayerDrag?
// Or we track mouse velocity here.
// Let's track rotation velocity in updateLayerDrag to be accurate with axis mapping.
updateLayerDrag(totalDeltaX, totalDeltaY, dt)
}
lastMouseX.value = event.clientX
lastMouseY.value = event.clientY
}
const updateLayerDrag = (dx, dy, dt) => {
// Determine rotation axis and direction based on drag vector and clicked face
const cubie = cubies.value.find(c => c.id === selectedCubieId.value)
if (!cubie) return
// Need to map 2D drag to 3D axis.
// Face Normals:
// Front: Z. Right: X. Up: Y.
const face = selectedFaceNormal.value
let axis = null
let sign = 1
const absDx = Math.abs(dx)
const absDy = Math.abs(dy)
const isHorizontal = absDx > absDy
// Logic:
if (face === 'front' || face === 'back') {
if (isHorizontal) axis = 'y'; else axis = 'x';
} else if (face === 'right' || face === 'left') {
if (isHorizontal) axis = 'y'; else axis = 'z';
} else if (face === 'up' || face === 'down') {
if (isHorizontal) axis = 'y';
else axis = 'x';
}
if (!axis) return
// Determine layer index
let index = 0
if (axis === 'x') index = cubie.x
if (axis === 'y') index = cubie.y
if (axis === 'z') index = cubie.z
activeLayer.value = { axis, index }
// Determine Sign (Visual mapping)
const delta = isHorizontal ? dx : dy
const baseSign = isHorizontal ? 1 : -1
const newRotation = delta * baseSign * 0.5
// Calculate velocity (deg/ms)
if (dt > 0) {
const dRot = newRotation - layerRotation.value
// Simple low-pass filter for smoothing
velocity.value = 0.6 * velocity.value + 0.4 * (dRot / dt)
}
layerRotation.value = newRotation
}
const onMouseUp = async () => {
if (!isDragging.value) return
isDragging.value = false
document.body.style.cursor = ''
if (dragMode.value === 'layer' && activeLayer.value) {
isSnapping.value = true
// Inertia calculation
// Project final position based on velocity
// 200ms projection is reasonable for "throw" feel
const projection = velocity.value * 200
const projectedRot = layerRotation.value + projection
// Snap to nearest 90 degrees
const steps = Math.round(projectedRot / 90)
const targetRot = steps * 90
// Animation Loop
const startRot = layerRotation.value
const startTime = performance.now()
const duration = 300 // ms
// Ease out cubic function
const easeOut = (t) => 1 - Math.pow(1 - t, 3)
return new Promise(resolve => {
const animate = (time) => {
const elapsed = time - startTime
const progress = Math.min(elapsed / duration, 1)
const ease = easeOut(progress)
layerRotation.value = startRot + (targetRot - startRot) * ease
if (progress < 1) {
rafId.value = requestAnimationFrame(animate)
} else {
finishRotation(steps)
resolve()
}
}
rafId.value = requestAnimationFrame(animate)
})
} else {
selectedCubieId.value = null
}
}
const finishRotation = (steps) => {
if (steps !== 0) {
// Update logical state
const { axis, index } = activeLayer.value
let layerName = null
if (axis === 'x') {
if (index === -1) layerName = 'left'
if (index === 1) layerName = 'right'
} else if (axis === 'y') {
if (index === 1) layerName = 'top'
if (index === -1) layerName = 'bottom'
} else if (axis === 'z') {
if (index === 1) layerName = 'front'
if (index === -1) layerName = 'back'
}
if (layerName) {
// Apply rotation to logical cube
let direction = steps > 0 ? 1 : -1
// Invert direction for specific layers where Visual and Logical rotations are opposite
if (layerName === 'top' || layerName === 'back' || layerName === 'right') {
direction = -direction
}
const count = Math.abs(steps)
// 1. Update Logical State (Colors)
for (let i = 0; i < count; i++) {
rotateLayer(layerName, direction)
}
// 2. Update Visual State (Cubies Position)
// We must rotate the (x,y,z) coordinates of the cubies that were in the active layer.
const visualSteps = steps // + means +90deg along axis
// Apply N times
const rotations = Math.abs(visualSteps)
const sign = Math.sign(visualSteps)
for (let r = 0; r < rotations; r++) {
cubies.value.forEach(cubie => {
// Check if cubie is in the rotating layer
let inLayer = false
if (axis === 'x' && cubie.x === index) inLayer = true
if (axis === 'y' && cubie.y === index) inLayer = true
if (axis === 'z' && cubie.z === index) inLayer = true
if (inLayer) {
const { x, y, z } = cubie
let nx = x, ny = y, nz = z
if (axis === 'x') {
if (sign > 0) { ny = -z; nz = y; } // (x, -z, y)
else { ny = z; nz = -y; } // (x, z, -y)
} else if (axis === 'y') {
if (sign > 0) { nx = z; nz = -x; } // (z, y, -x)
else { nx = -z; nz = x; } // (-z, y, x)
} else if (axis === 'z') {
if (sign > 0) { nx = -y; ny = x; } // (-y, x, z)
else { nx = y; ny = -x; } // (y, -x, z)
}
cubie.x = nx
cubie.y = ny
cubie.z = nz
}
})
}
}
}
activeLayer.value = null
layerRotation.value = 0
isSnapping.value = false
velocity.value = 0
}
// Lifecycle
onMounted(() => {
initCubies()
initCube()
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
})
onUnmounted(() => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
if (rafId.value) cancelAnimationFrame(rafId.value)
})
// Styles
const cubeStyle = computed(() => ({
transform: `rotateX(${rx.value}deg) rotateY(${ry.value}deg) rotateZ(${rz.value}deg)`
}))
const getCubieStyle = (cubie) => {
// Base position
// scale 300px total. 100px per cubie.
// x,y,z in -1..1.
// translate(x*100, -y*100, z*100).
// Y is inverted in CSS (down is positive)?
// Usually in 3D CSS:
// X right, Y down, Z towards viewer.
// My Grid: Y=1 is Top.
// So Y=1 -> translateY(-100px).
const tx = cubie.x * 100
const ty = cubie.y * -100
const tz = cubie.z * 100
let transform = `translate3d(${tx}px, ${ty}px, ${tz}px)`
// Apply rotation if active layer
if (activeLayer.value) {
const { axis, index } = activeLayer.value
let match = false
if (axis === 'x' && cubie.x === index) match = true
if (axis === 'y' && cubie.y === index) match = true
if (axis === 'z' && cubie.z === index) match = true
if (match) {
// Rotation origin is center of cube (0,0,0).
// But we are translating the cubie.
// To rotate around global axis, we should rotate THEN translate?
// No, the Group rotates.
// But here we rotate individual cubies.
// A cubie at (100,0,0) rotating around Y axis:
// Needs to move in arc.
// `rotateY(angle) translate(...)` -> Rotates axis then moves.
// Yes. `rotateY` first puts it on the rotated axis.
// So `rotate` then `translate`.
transform = `rotate${axis.toUpperCase()}(${layerRotation.value}deg) ${transform}`
}
}
return { transform }
}
</script>
<template>
<div class="scene" @mousedown="onMouseDown">
<div class="container">
<div class="cube-group" :style="cubeStyle">
<div v-for="cubie in cubies" :key="cubie.id" class="cubie" :style="getCubieStyle(cubie)">
<!-- Render 6 faces for each cubie -->
<!-- Only render if color is not black? Optimization. -->
<div v-for="(color, face) in getCubieFaces(cubie)" :key="face"
class="sticker-face"
:class="face"
:data-cubie-id="cubie.id"
:data-face="face"
:style="{ backgroundColor: color }">
<div class="sticker-border"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.scene {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.container {
width: 300px;
height: 300px;
perspective: 900px;
pointer-events: auto;
}
.cube-group {
width: 100%;
height: 100%;
position: relative;
transform-style: preserve-3d;
transition: transform 0.1s;
}
.cubie {
position: absolute;
width: 100px;
height: 100px;
top: 100px; /* Center it: 300/2 - 100/2 = 100 */
left: 100px;
transform-style: preserve-3d;
}
.sticker-face {
position: absolute;
width: 100px;
height: 100px;
border: 1px solid rgba(0,0,0,0.8); /* Plastic edge */
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
backface-visibility: hidden; /* Optimization? Or we want to see inside? */
/* We want solid cubies. So we need backfaces or 6 faces. */
/* We are rendering 6 faces. */
}
.sticker-border {
width: 92%;
height: 92%;
border: 2px solid rgba(0,0,0,0.5);
border-radius: 8px; /* Rounded sticker */
background: inherit; /* Sticker color */
/* The face bg is the plastic color (black usually). */
/* Here we set face bg to color directly. */
/* Let's adjust: face bg = black. sticker-border bg = color. */
}
/* Face transforms relative to Cubie Center */
.sticker-face.front { transform: rotateY(0deg) translateZ(50px); }
.sticker-face.back { transform: rotateY(180deg) translateZ(50px); }
.sticker-face.right { transform: rotateY(90deg) translateZ(50px); }
.sticker-face.left { transform: rotateY(-90deg) translateZ(50px); }
.sticker-face.up { transform: rotateX(90deg) translateZ(50px); }
.sticker-face.down { transform: rotateX(-90deg) translateZ(50px); }
</style>

View File

@@ -1,34 +0,0 @@
<script setup>
import { onMounted } from 'vue'
onMounted(() => {
console.log('Canvas Renderer mounted (placeholder)')
})
</script>
<template>
<div class="canvas-container">
<canvas width="300" height="300"></canvas>
<div class="overlay">
Canvas Renderer (Coming Soon)
</div>
</div>
</template>
<style scoped>
.canvas-container {
width: 300px;
height: 300px;
position: relative;
display: flex;
justify-content: center;
align-items: center;
border: 1px dashed rgba(255, 255, 255, 0.3);
}
.overlay {
position: absolute;
color: white;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,253 @@
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { Locate, LocateFixed } from "lucide-vue-next";
const props = defineProps({
isViewDefault: { type: Boolean, default: true },
});
const emit = defineEmits(["move", "scramble", "solve", "reset-camera"]);
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>
<div>
<div class="controls controls-left">
<div class="controls-row">
<button class="btn-neon move-btn" @click="emit('move', 'U')">U</button>
<button class="btn-neon move-btn" @click="emit('move', 'D')">D</button>
<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>
</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>
</div>
</div>
<div class="controls controls-right">
<div class="controls-row">
<button class="btn-neon move-btn" @click="emit('move', 'R')">R</button>
<button class="btn-neon move-btn" @click="emit('move', 'F')">F</button>
<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>
</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>
</div>
</div>
<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 class="bottom-right-controls">
<button
class="btn-neon move-btn camera-reset-btn"
:class="{ 'is-default': props.isViewDefault }"
:disabled="props.isViewDefault"
@click="emit('reset-camera')"
>
<LocateFixed v-if="props.isViewDefault" :size="18" />
<Locate v-else :size="18" />
</button>
</div>
</div>
</template>
<style scoped>
.controls {
position: absolute;
top: 96px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 50;
}
.controls-left {
left: 24px;
}
.controls-right {
right: 24px;
}
.controls-row {
display: flex;
gap: 8px;
justify-content: center;
}
.move-btn {
min-width: 44px;
height: 36px;
font-size: 0.9rem;
padding: 0 10px;
}
.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);
}
.dropdown-item:focus {
outline: none;
}
.bottom-right-controls {
position: absolute;
bottom: 72px;
right: 24px;
z-index: 50;
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-end;
}
.camera-reset-btn {
min-width: 40px;
height: 40px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.3s, transform 0.2s;
}
.camera-reset-btn.is-default {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
.camera-reset-btn:not(.is-default):hover {
transform: scale(1.1);
}
</style>

View File

@@ -1,28 +0,0 @@
<script setup>
import { onMounted } from 'vue'
onMounted(() => {
console.log('SVG Renderer mounted (placeholder)')
})
</script>
<template>
<div class="svg-container">
<svg width="300" height="300" viewBox="0 0 300 300">
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white">
SVG Renderer (Coming Soon)
</text>
</svg>
</div>
</template>
<style scoped>
.svg-container {
width: 300px;
height: 300px;
display: flex;
justify-content: center;
align-items: center;
border: 1px dashed rgba(255, 255, 255, 0.3);
}
</style>

View File

@@ -0,0 +1,192 @@
<script setup>
import { computed } from "vue";
import { REAR_FACE_DISTANCE } from "../../config/settings.js";
const props = defineProps({
cubies: { type: Array, required: true },
viewMatrix: { type: Array, required: true },
FACES: { type: Object, required: true },
SCALE: { type: Number, default: 100 },
activeLayer: { type: Object, default: null },
currentLayerRotation: { type: Number, default: 0 },
animateLayers: { type: Boolean, default: false },
});
// Face definitions with logical normals and grid axes
const FACE_DEFS = computed(() => {
const F = props.FACES;
return [
{ face: F.FRONT, normal: [0, 0, 1], gridU: [1, 0, 0], gridV: [0, 1, 0], faceKey: 'front' },
{ face: F.BACK, normal: [0, 0, -1], gridU: [-1, 0, 0], gridV: [0, 1, 0], faceKey: 'back' },
{ face: F.RIGHT, normal: [1, 0, 0], gridU: [0, 0, -1], gridV: [0, 1, 0], faceKey: 'right' },
{ face: F.LEFT, normal: [-1, 0, 0], gridU: [0, 0, 1], gridV: [0, 1, 0], faceKey: 'left' },
{ face: F.UP, normal: [0, 1, 0], gridU: [1, 0, 0], gridV: [0, 0, -1], faceKey: 'up' },
{ face: F.DOWN, normal: [0, -1, 0], gridU: [1, 0, 0], gridV: [0, 0, 1], faceKey: 'down' },
];
});
// Which faces are hidden (for static cells)
const hiddenFaceKeys = computed(() => {
const m = props.viewMatrix;
const keys = new Set();
for (const fd of FACE_DEFS.value) {
const [nx, ny, nz] = fd.normal;
const tz = nx * m[2] + (-ny) * m[6] + nz * m[10];
if (tz < 0) keys.add(fd.faceKey);
}
return keys;
});
// Orientation: cells face INWARD (toward cube center)
// Combined with backface-visibility: hidden, this means:
// - hidden face cells: front faces camera → visible
// - visible face cells: front faces away → invisible
// - rotating cells crossing the plane: naturally swap visibility
const inwardRotation = (nx, ny, nz) => {
if (nx === 1) return 'rotateY(-90deg)';
if (nx === -1) return 'rotateY(90deg)';
if (ny === 1) return 'rotateX(-90deg)';
if (ny === -1) return 'rotateX(90deg)';
if (nz === -1) return ''; // front faces +Z (toward camera)
return 'rotateY(180deg)'; // nz === 1: flip to face -Z (toward center)
};
// Build cells for one face
const buildFaceCells = (fd, S, dist, al, rot, isRotatingOnly) => {
const [nx, ny, nz] = fd.normal;
const [gu, gv] = [fd.gridU, fd.gridV];
const orient = inwardRotation(nx, ny, nz);
const d = S * 1.5 + dist;
const cells = [];
const faceCubies = props.cubies.filter((c) => {
if (nx !== 0) return c.x === nx;
if (ny !== 0) return c.y === ny;
if (nz !== 0) return c.z === nz;
return false;
});
for (let v = 1; v >= -1; v--) {
for (let u = -1; u <= 1; u++) {
const cx = nx * Math.max(Math.abs(nx), 0) || u * gu[0] + v * gv[0];
const cy = ny * Math.max(Math.abs(ny), 0) || u * gu[1] + v * gv[1];
const cz = nz * Math.max(Math.abs(nz), 0) || u * gu[2] + v * gv[2];
const inLayer = al && (
(al.axis === 'x' && cx === al.index) ||
(al.axis === 'y' && cy === al.index) ||
(al.axis === 'z' && cz === al.index)
);
// Skip: if isRotatingOnly, only include rotating cells
// If not isRotatingOnly (hidden face), include non-rotating cells
if (isRotatingOnly && !inLayer) continue;
if (!isRotatingOnly && inLayer && props.animateLayers) continue;
const cubie = faceCubies.find(
(c) => c.x === cx && c.y === cy && c.z === cz
);
const color = cubie ? cubie.faces[fd.faceKey] || 'black' : 'black';
// 3D position
const posX = nx * d + u * gu[0] * S + v * gv[0] * S;
const posY = ny * d + u * gu[1] * S + v * gv[1] * S;
const posZ = nz * d + u * gu[2] * S + v * gv[2] * S;
let transform = `translate3d(${posX}px, ${-posY}px, ${posZ}px) ${orient}`;
// Rotating cells: prepend rotation around scene center (only in advanced mode)
if (props.animateLayers && inLayer && rot !== 0) {
let rotPre = '';
if (al.axis === 'x') rotPre = `rotateX(${-rot}deg)`;
else if (al.axis === 'y') rotPre = `rotateY(${rot}deg)`;
else if (al.axis === 'z') rotPre = `rotateZ(${-rot}deg)`;
transform = `${rotPre} ${transform}`;
}
cells.push({
key: `${fd.faceKey}-${u}-${v}`,
color,
transform,
});
}
}
return cells;
};
// All cells to render
const allCells = computed(() => {
const S = props.SCALE;
const dist = REAR_FACE_DISTANCE * S * 3;
const al = props.activeLayer;
const rot = props.currentLayerRotation;
const cells = [];
for (const fd of FACE_DEFS.value) {
const isHidden = hiddenFaceKeys.value.has(fd.faceKey);
if (isHidden) {
// Hidden face: render non-rotating cells (static projections)
cells.push(...buildFaceCells(fd, S, dist, al, rot, false));
}
// ALL faces: render rotating-layer cells (they swap via backface-visibility)
if (props.animateLayers && al && rot !== 0) {
cells.push(...buildFaceCells(fd, S, dist, al, rot, true));
}
}
return cells;
});
</script>
<template>
<div class="face-projections-root">
<div
v-for="cell in allCells"
:key="cell.key"
class="proj-cell"
:class="cell.color"
:style="{ transform: cell.transform }"
></div>
</div>
</template>
<style scoped>
.face-projections-root {
position: absolute;
transform-style: preserve-3d;
}
.proj-cell {
position: absolute;
width: 100px;
height: 100px;
margin-left: -50px;
margin-top: -50px;
box-sizing: border-box;
background: #000;
border: 1px solid #000;
backface-visibility: hidden;
}
.proj-cell::after {
content: "";
position: absolute;
top: 4px;
left: 4px;
right: 4px;
bottom: 4px;
border-radius: 8px;
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.3);
}
/* Colors - use global design system variables */
.proj-cell.white::after { background: var(--sticker-white); }
.proj-cell.yellow::after { background: var(--sticker-yellow); }
.proj-cell.green::after { background: var(--sticker-green); }
.proj-cell.blue::after { background: var(--sticker-blue); }
.proj-cell.orange::after { background: var(--sticker-orange); }
.proj-cell.red::after { background: var(--sticker-red); }
.proj-cell.black::after { display: none; }
</style>

View File

@@ -0,0 +1,225 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
const props = defineProps({
moves: {
type: Array,
required: true,
},
});
const emit = defineEmits(["reset", "copy", "add-moves", "open-add-modal"]);
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 displayMoves = computed(() => props.moves || []);
const moveRows = computed(() => {
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));
}
return rows;
});
const hasMoves = computed(() => displayMoves.value.length > 0);
const copyQueueToClipboard = () => {
emit("copy");
};
const resetQueue = () => {
emit("reset");
};
const setSamplePill = (el) => {
if (el && !samplePillEl.value) {
samplePillEl.value = el;
}
};
const recalcMovesLayout = () => {
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 totalWidth = (cols) => {
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;
while (cols > 1 && totalWidth(cols) > containerWidth) {
cols -= 1;
}
let gap = 0;
if (cols > 1) {
gap = (containerWidth - cols * pillWidth) / (cols - 1);
}
movesPerRow.value = cols;
movesColumnGap.value = gap;
};
const openAddModal = () => {
emit("open-add-modal");
};
watch(displayMoves, () => {
nextTick(recalcMovesLayout);
});
onMounted(() => {
window.addEventListener("resize", recalcMovesLayout);
nextTick(recalcMovesLayout);
});
onUnmounted(() => {
window.removeEventListener("resize", recalcMovesLayout);
});
</script>
<template>
<div class="moves-history">
<div class="moves-inner" ref="movesHistoryEl">
<div
v-for="(row, rowIndex) in moveRows"
:key="rowIndex"
class="moves-row"
:style="{ columnGap: movesColumnGap + 'px' }"
>
<span
v-for="(m, idx) in row"
:key="m.id"
class="move-pill"
:class="{
'move-pill-active': m.status === 'in_progress',
'move-pill-pending': m.status === 'pending',
}"
:ref="rowIndex === 0 && idx === 0 ? setSamplePill : null"
>
{{ m.label }}
</span>
</div>
</div>
<div class="moves-actions">
<button class="queue-action" @click="openAddModal">add</button>
<button
class="queue-action"
:class="{ 'queue-action-disabled': !hasMoves }"
:disabled="!hasMoves"
@click="copyQueueToClipboard"
>
copy
</button>
<button
class="queue-action"
:class="{ 'queue-action-disabled': !hasMoves }"
:disabled="!hasMoves"
@click="resetQueue"
>
reset
</button>
</div>
</div>
</template>
<style scoped>
.moves-history {
position: absolute;
bottom: 72px;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: calc(100vw - 360px);
overflow-x: hidden;
padding: 12px 12px 26px 12px;
background: rgba(0, 0, 0, 0.4);
border-radius: 8px;
backdrop-filter: blur(8px);
}
.moves-inner {
display: flex;
flex-direction: column;
gap: 6px;
}
.moves-row {
display: flex;
}
.move-pill {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
min-width: 16px;
min-height: 24px;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.2);
font-size: 0.8rem;
color: #fff;
white-space: nowrap;
}
.move-pill-active {
background: #ffd500;
color: #000;
border-color: #ffd500;
}
.move-pill-pending {
opacity: 0.4;
}
.moves-actions {
position: absolute;
right: 6px;
bottom: 6px;
display: flex;
gap: 0px;
}
.queue-action {
border: none;
background: transparent;
padding: 6px 6px;
color: #fff;
font-size: 0.8rem;
cursor: pointer;
}
.queue-action-disabled {
opacity: 0.35;
cursor: default;
pointer-events: none;
}
.moves-history::after {
content: none;
}
.queue-action:focus {
outline: none;
box-shadow: none;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,70 +1,110 @@
import { ref, computed } from 'vue'; import { ref, computed } from "vue";
import { Cube, COLORS, FACES } from '../utils/Cube'; import { COLORS, FACES } from "../utils/CubeModel";
// Map natural numbers (0-5) to CSS colors // Singleton logic worker
const COLOR_MAP = { const worker = new Worker(
[COLORS.WHITE]: 'white', new URL("../workers/Cube.worker.js", import.meta.url),
[COLORS.YELLOW]: 'yellow', { type: "module" },
[COLORS.ORANGE]: 'orange', );
[COLORS.RED]: 'red',
[COLORS.GREEN]: 'green', // Singleton solver worker
[COLORS.BLUE]: 'blue' const solverWorker = new Worker(
new URL("../workers/Solver.worker.js", import.meta.url),
{ type: "module" },
);
// Reactive state
const cubies = ref([]);
const deepCubeState = ref(null);
const isReady = ref(false);
const isSolverReady = ref(false);
const validationResult = ref(null);
const solveResult = ref(null);
const solveError = ref(null);
worker.onmessage = (e) => {
const { type, payload } = e.data;
if (type === "STATE_UPDATE") {
cubies.value = payload.cubies;
deepCubeState.value = payload.deepCubeState;
isReady.value = true;
} else if (type === "VALIDATION_RESULT") {
validationResult.value = payload;
} else if (type === "SOLVE_RESULT") {
solveResult.value = payload;
} else if (type === "ERROR") {
console.error("Logic Worker Error:", payload);
}
}; };
export function useCube() { solverWorker.onmessage = (e) => {
const cube = ref(new Cube()); const { type, payload } = e.data;
if (type === "SOLVE_RESULT") {
// Expose state for rendering (flattened for Vue template simplicity or kept as matrix) solveResult.value = payload;
// Let's expose matrix but maybe helper to flatten if needed? } else if (type === "SOLVE_ERROR") {
// The Cube class uses 3x3 matrices. // Error doesn't necessarily block execution, it just provides UI feedback
// The Vue template currently iterates `cubeState.top` (array of 9). solveError.value = payload;
// We should adapt `cubeState` to match what the template expects OR update template. } else if (type === "INIT_DONE") {
// The user asked to "Dostosować src/components/Main.vue do renderowania nowego modelu danych". isSolverReady.value = true;
// So we can expose the 3x3 matrices directly. } else if (type === "ERROR") {
console.error("Solver Worker Error:", payload);
const cubeState = computed(() => cube.value.state);
const initCube = () => {
cube.value.reset();
};
const rotateLayer = (layer, direction) => {
// layer is string 'top', 'front' etc.
// Map string to FACES constant if needed, but FACES values are 'up', 'down', etc.
// The previous implementation used 'top', 'bottom', 'left', 'right', 'front', 'back'.
// Cube.js uses 'up', 'down', 'left', 'right', 'front', 'back'.
// Map legacy layer names to new Face names
const layerMap = {
'top': FACES.UP,
'bottom': FACES.DOWN,
'left': FACES.LEFT,
'right': FACES.RIGHT,
'front': FACES.FRONT,
'back': FACES.BACK
};
const face = layerMap[layer];
if (face) {
cube.value.rotate(face, direction);
// Trigger reactivity since Cube is a class and state is internal object
// We made `cube` a ref, but mutating its internal state might not trigger update
// unless we replace the state or use reactive().
// `cube.value.rotate` mutates `this.state`.
// We should probably make `cube.value.state` reactive or trigger update.
cube.value = Object.assign(Object.create(Object.getPrototypeOf(cube.value)), cube.value);
} }
};
// Init worker
worker.postMessage({ type: "INIT" });
export function useCube() {
const initCube = () => {
worker.postMessage({ type: "RESET" });
}; };
// Helper to get flattened array for a face (if template wants it) const rotateLayer = (axis, index, direction, steps = 1) => {
// But better to update template to use 2D loop or flatten here. worker.postMessage({
// Let's provide a helper or just let template handle it. type: "ROTATE_LAYER",
payload: { axis, index, direction, steps },
});
};
const rotateSlice = (axis, direction, steps = 1) => {
worker.postMessage({
type: "ROTATE_SLICE",
payload: { axis, direction, steps },
});
};
const turn = (move) => {
worker.postMessage({ type: "TURN", payload: { move } });
};
const validate = () => {
worker.postMessage({ type: "VALIDATE" });
};
const solve = (solverType, cubeState) => {
solveResult.value = null;
solveError.value = null;
solverWorker.postMessage({
type: "SOLVE",
payload: { solverType, cubeState },
});
};
return { return {
cubeState, cubies: computed(() => cubies.value),
deepCubeState: computed(() => deepCubeState.value),
isReady: computed(() => isReady.value),
isSolverReady: computed(() => isSolverReady.value),
validationResult: computed(() => validationResult.value),
solveResult: computed(() => solveResult.value),
solveError: computed(() => solveError.value),
initCube, initCube,
rotateLayer, rotateLayer,
COLOR_MAP, rotateSlice,
FACES turn,
validate,
solve,
COLORS,
FACES,
}; };
} }

View File

@@ -1,23 +0,0 @@
import { ref } from 'vue';
const RENDERERS = {
CSS: 'CSS',
SVG: 'SVG',
CANVAS: 'Canvas'
};
const activeRenderer = ref(RENDERERS.CSS);
export function useRenderer() {
const setRenderer = (renderer) => {
if (Object.values(RENDERERS).includes(renderer)) {
activeRenderer.value = renderer;
}
};
return {
activeRenderer,
setRenderer,
RENDERERS
};
}

View File

@@ -0,0 +1,44 @@
import { ref } from "vue";
let initialCubeTranslucent = false;
try {
const stored = localStorage.getItem("cubeTranslucent");
if (stored !== null) {
initialCubeTranslucent = stored === "true";
}
} catch (e) { }
// 0 = off, 1 = simple projections, 2 = advanced (animated layers)
let initialProjectionMode = 0;
try {
const stored = localStorage.getItem("projectionMode");
if (stored !== null) {
initialProjectionMode = Math.min(2, Math.max(0, parseInt(stored, 10) || 0));
}
} catch (e) { }
const isCubeTranslucent = ref(initialCubeTranslucent);
const projectionMode = ref(initialProjectionMode);
export function useSettings() {
const toggleCubeTranslucent = () => {
isCubeTranslucent.value = !isCubeTranslucent.value;
try {
localStorage.setItem("cubeTranslucent", String(isCubeTranslucent.value));
} catch (e) { }
};
const cycleProjectionMode = () => {
projectionMode.value = (projectionMode.value + 1) % 3;
try {
localStorage.setItem("projectionMode", String(projectionMode.value));
} catch (e) { }
};
return {
isCubeTranslucent,
toggleCubeTranslucent,
projectionMode,
cycleProjectionMode,
};
}

8
src/config/settings.js Normal file
View File

@@ -0,0 +1,8 @@
export const LAYER_ANIMATION_DURATION = 200;
export const MIDDLE_SLICES_ENABLED = false;
// Distance of rear face projections from cube center (in cube-size units)
// 1.0 = one cube width, 0.5 = half cube width
export const REAR_FACE_DISTANCE = 1.0;

View File

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

View File

@@ -13,9 +13,9 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
/* --- Glassmorphism Design System (from Nonograms) --- */ /* --- Glassmorphism Design System (from Nonograms) --- */
--bg-gradient: linear-gradient(135deg, #2c3e50 0%, #000000 100%); --bg-gradient: radial-gradient(circle at center, #444 0%, #000000 100%);
--glass-bg: rgba(255, 255, 255, 0.05); --glass-bg: rgba(255, 255, 255, 0.1);
--glass-border: rgba(255, 255, 255, 0.1); --glass-border: rgba(255, 255, 255, 0.2);
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); --glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
--text-color: #ffffff; --text-color: #ffffff;
--text-strong: #ffffff; --text-strong: #ffffff;
@@ -25,10 +25,63 @@
--accent-purple: #4facfe; --accent-purple: #4facfe;
--primary-accent: #00f2fe; --primary-accent: #00f2fe;
--title-glow: rgba(0, 255, 255, 0.2); --title-glow: rgba(0, 255, 255, 0.2);
--toggle-bg: rgba(255, 255, 255, 0.08);
--toggle-border: rgba(255, 255, 255, 0.2);
--toggle-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
--toggle-btn-border: rgba(255, 255, 255, 0.2); --toggle-btn-border: rgba(255, 255, 255, 0.2);
--panel-bg: rgba(255, 255, 255, 0.05); --toggle-hover-border: #ffffff;
--toggle-active-shadow: 0 0 10px rgba(0, 242, 255, 0.3);
--panel-bg: rgba(0, 0, 0, 0.4);
--panel-border: rgba(255, 255, 255, 0.1); --panel-border: rgba(255, 255, 255, 0.1);
--panel-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); --panel-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
--button-bg: rgba(255, 255, 255, 0.1);
--button-border: rgba(255, 255, 255, 0.2);
--button-text: #ffffff;
--button-hover-bg: rgba(255, 255, 255, 0.25);
--button-hover-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
--button-active-shadow: 0 0 20px rgba(79, 172, 254, 0.4);
--cube-edge-color: #333333;
--title-gradient: linear-gradient(45deg, #00f2fe, #4facfe);
/* Cube sticker colors */
--sticker-white: #e0e0e0;
--sticker-yellow: #ffd500;
--sticker-green: #009e60;
--sticker-blue: #0051ba;
--sticker-orange: #ff5800;
--sticker-red: #c41e3a;
}
:root[data-theme="light"] {
--bg-gradient: radial-gradient(circle at center, #ffffff 0%, #cccccc 100%);
--glass-bg: rgba(255, 255, 255, 0.75);
--glass-border: rgba(15, 23, 42, 0.12);
--glass-shadow: 0 8px 32px 0 rgba(15, 23, 42, 0.12);
--text-color: #0f172a;
--text-strong: #0f172a;
--text-secondary: rgba(15, 23, 42, 0.7);
--text-muted: rgba(15, 23, 42, 0.6);
--accent-cyan: #0ea5e9;
--accent-purple: #6366f1;
--primary-accent: #0ea5e9;
--title-glow: rgba(14, 165, 233, 0.35);
--toggle-bg: rgba(255, 255, 255, 0.85);
--toggle-border: rgba(15, 23, 42, 0.12);
--toggle-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
--toggle-btn-border: rgba(15, 23, 42, 0.18);
--toggle-hover-border: rgba(15, 23, 42, 0.5);
--toggle-active-shadow: 0 0 12px rgba(14, 165, 233, 0.25);
--panel-bg: rgba(255, 255, 255, 0.7);
--panel-border: rgba(15, 23, 42, 0.12);
--panel-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
--button-bg: rgba(255, 255, 255, 0.85);
--button-border: rgba(15, 23, 42, 0.16);
--button-text: #0f172a;
--button-hover-bg: rgba(255, 255, 255, 1);
--button-hover-shadow: 0 6px 18px rgba(15, 23, 42, 0.18);
--button-active-shadow: 0 0 18px rgba(14, 165, 233, 0.25);
--cube-edge-color: #000000;
--title-gradient: linear-gradient(45deg, #0ea5e9, #6366f1);
} }
a { a {
@@ -36,6 +89,7 @@ a {
color: #646cff; color: #646cff;
text-decoration: inherit; text-decoration: inherit;
} }
a:hover { a:hover {
color: #535bf2; color: #535bf2;
} }
@@ -44,11 +98,12 @@ body {
margin: 0; margin: 0;
display: flex; display: flex;
min-width: 320px; min-width: 320px;
min-height: 100vh; height: 100vh;
flex-direction: column; flex-direction: column;
background: var(--bg-gradient); background: var(--bg-gradient);
background-attachment: fixed; background-attachment: fixed;
color: var(--text-color); color: var(--text-color);
overflow: hidden;
} }
h1 { h1 {
@@ -67,9 +122,11 @@ button {
cursor: pointer; cursor: pointer;
transition: border-color 0.25s; transition: border-color 0.25s;
} }
button:hover { button:hover {
border-color: #646cff; border-color: #646cff;
} }
button:focus, button:focus,
button:focus-visible { button:focus-visible {
outline: 4px auto -webkit-focus-ring-color; outline: 4px auto -webkit-focus-ring-color;
@@ -81,7 +138,7 @@ button:focus-visible {
#app { #app {
width: 100%; width: 100%;
min-height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -90,19 +147,66 @@ button:focus-visible {
/* Glassmorphism utility class */ /* Glassmorphism utility class */
.glass-panel { .glass-panel {
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: blur(10px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 16px;
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow); box-shadow: var(--glass-shadow);
} }
/* Button Styles */
button.btn-neon {
background: var(--button-bg);
border: 1px solid var(--button-border);
color: var(--button-text);
padding: 10px 20px;
font-size: 0.95rem;
border-radius: 30px;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(4px);
font-weight: 500;
letter-spacing: 0.5px;
text-transform: uppercase;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
outline: none;
}
button.btn-neon:hover {
background: var(--button-hover-bg);
transform: translateY(-2px);
box-shadow: var(--button-hover-shadow);
border-color: var(--toggle-hover-border);
}
button.btn-neon.active {
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-purple));
border-color: transparent;
box-shadow: var(--button-active-shadow);
font-weight: 700;
color: #fff;
}
button.btn-neon.icon-only {
padding: 10px;
border-radius: 50%;
width: 40px;
height: 40px;
}
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
color: #213547; color: #213547;
background-color: #ffffff; background-color: #ffffff;
} }
a:hover { a:hover {
color: #747bff; color: #747bff;
} }
button { button {
background-color: #f9f9f9; background-color: #f9f9f9;
} }

View File

@@ -1,252 +0,0 @@
import MatrixLib from 'matrix-js';
const Matrix = MatrixLib && MatrixLib.default ? MatrixLib.default : MatrixLib;
const mod = (n, m) => ((n % m) + m) % m;
// Enum for colors/faces
export const COLORS = {
WHITE: 0,
YELLOW: 1,
ORANGE: 2,
RED: 3,
GREEN: 4,
BLUE: 5,
};
// Faces mapping
export const FACES = {
UP: 'up',
DOWN: 'down',
LEFT: 'left',
RIGHT: 'right',
FRONT: 'front',
BACK: 'back',
};
// Initial state: Solved cube
const INITIAL_STATE = {
[FACES.UP]: Array(3).fill().map(() => Array(3).fill(COLORS.WHITE)),
[FACES.DOWN]: Array(3).fill().map(() => Array(3).fill(COLORS.YELLOW)),
[FACES.LEFT]: Array(3).fill().map(() => Array(3).fill(COLORS.ORANGE)),
[FACES.RIGHT]: Array(3).fill().map(() => Array(3).fill(COLORS.RED)),
[FACES.FRONT]: Array(3).fill().map(() => Array(3).fill(COLORS.GREEN)),
[FACES.BACK]: Array(3).fill().map(() => Array(3).fill(COLORS.BLUE)),
};
export class Cube {
constructor() {
this.reset();
}
reset() {
// Deep copy initial state
this.state = JSON.parse(JSON.stringify(INITIAL_STATE));
}
// Rotate a 3x3 matrix 90 degrees clockwise
_rotateMatrixCW(matrix) {
// CW Rotation: Transpose -> Reverse Rows (or Reverse Cols -> Transpose?)
// CW: (x,y) -> (y, -x).
// Transpose: (x,y) -> (y,x).
// Reverse rows?
// 1 2 3 1 4 7 7 4 1
// 4 5 6 -> 2 5 8 -> 8 5 2
// 7 8 9 3 6 9 9 6 3
// Transpose then reverse each row.
// Matrix-js trans() returns new matrix.
const m = Matrix(matrix);
const t = m.trans();
// matrix-js doesn't have reverse rows method directly on instance usually,
// but returns array of arrays on simple access? No, it returns object.
// Let's use basic array ops on the transposed data.
// Matrix(m) creates a matrix object.
// m.trans() returns a matrix object with transposed data.
// We need to get data back to reverse rows.
// Check matrix-js API.
// Usually it doesn't expose data directly property?
// Let's assume we can get it via simple property or method.
// Docs say: Matrix(data) -> data.
// But let's check what trans() returns.
// Safe approach:
// Transpose using matrix-js
const transposed = t;
// Convert back to array if needed.
// If matrix-js is just a wrapper, maybe it's iterable?
// Or we assume `t` is the array? No, `Matrix` is a factory.
// `Matrix(A).trans()` returns a new Matrix.
// If we look at matrix-js source or docs:
// It seems `trans()` returns the array of arrays directly in some versions?
// Or we need to access it.
// Let's assume standard behavior: we need to extract data.
// But wait, the user asked to use `matrix-js`.
// If I cannot verify API, I might break it.
// `matrix-js` 1.x:
// var Matrix = require("matrix-js");
// var A = Matrix([[1,2],[3,4]]);
// var B = A.trans();
// B is a matrix-js object? Or array?
// Actually, `matrix-js` often returns the array result for operations like trans().
// Let's assume it returns the array of arrays.
// Verify by checking if it has .map
if (Array.isArray(t)) {
return t.map(row => [...row].reverse());
}
// If it's an object, we might need to find how to extract.
// But since I installed it, I can assume standard usage.
// Most lightweight libs return arrays.
// Let's try to use it as if it returns an array.
return t.map(row => [...row].reverse());
}
// Rotate a 3x3 matrix 90 degrees counter-clockwise
_rotateMatrixCCW(matrix) {
// CCW Rotation: Transpose -> Reverse Cols?
// Or Reverse Rows -> Transpose?
// 1 2 3 3 2 1 3 6 9
// 4 5 6 -> 6 5 4 -> 2 5 8
// 7 8 9 9 8 7 1 4 7
// Reverse rows then transpose.
// Reverse rows first (manual)
const reversed = matrix.map(row => [...row].reverse());
// Then transpose using matrix-js
return Matrix(reversed).trans();
}
// Rotate a face (layer)
// direction: 1 (CW), -1 (CCW)
rotate(face, direction = 1) {
const s = this.state;
// 1. Rotate the face matrix itself
if (direction === 1) {
s[face] = this._rotateMatrixCW(s[face]);
} else {
s[face] = this._rotateMatrixCCW(s[face]);
}
// 2. Rotate adjacent strips
let cycle = [];
// Helper to get index with mod (not strictly needed but good practice)
// We can use mod(idx, 3) if we iterate.
switch (face) {
case FACES.FRONT:
cycle = [
{ face: FACES.UP, type: 'row', index: 2, reverse: false },
{ face: FACES.RIGHT, type: 'col', index: 0, reverse: false },
{ face: FACES.DOWN, type: 'row', index: 0, reverse: true }, // Reversed
{ face: FACES.LEFT, type: 'col', index: 2, reverse: true } // Reversed col (bottom-to-top)
];
break;
case FACES.BACK:
cycle = [
{ face: FACES.UP, type: 'row', index: 0, reverse: true },
{ face: FACES.LEFT, type: 'col', index: 0, reverse: false },
{ face: FACES.DOWN, type: 'row', index: 2, reverse: false },
{ face: FACES.RIGHT, type: 'col', index: 2, reverse: false }
];
break;
case FACES.UP:
cycle = [
{ face: FACES.FRONT, type: 'row', index: 0, reverse: false },
{ face: FACES.LEFT, type: 'row', index: 0, reverse: false },
{ face: FACES.BACK, type: 'row', index: 0, reverse: false },
{ face: FACES.RIGHT, type: 'row', index: 0, reverse: false }
];
break;
case FACES.DOWN:
cycle = [
{ face: FACES.FRONT, type: 'row', index: 2, reverse: false },
{ face: FACES.RIGHT, type: 'row', index: 2, reverse: false },
{ face: FACES.BACK, type: 'row', index: 2, reverse: false },
{ face: FACES.LEFT, type: 'row', index: 2, reverse: false }
];
break;
case FACES.LEFT:
cycle = [
{ face: FACES.UP, type: 'col', index: 0, reverse: false },
{ face: FACES.FRONT, type: 'col', index: 0, reverse: false },
{ face: FACES.DOWN, type: 'col', index: 0, reverse: false },
{ face: FACES.BACK, type: 'col', index: 2, reverse: true }
];
break;
case FACES.RIGHT:
cycle = [
{ face: FACES.UP, type: 'col', index: 2, reverse: false },
{ face: FACES.BACK, type: 'col', index: 0, reverse: true },
{ face: FACES.DOWN, type: 'col', index: 2, reverse: false },
{ face: FACES.FRONT, type: 'col', index: 2, reverse: false }
];
break;
}
if (direction === -1) {
cycle.reverse();
}
this._applyCycle(cycle, direction, face);
}
_getSegment(face, type, index) {
const s = this.state[face];
if (type === 'row') {
return [...s[index]];
} else {
return s.map(row => row[index]);
}
}
_setSegment(face, type, index, values) {
const s = this.state[face];
if (type === 'row') {
s[index] = [...values];
} else {
for (let i = 0; i < 3; i++) {
s[i][index] = values[i];
}
}
}
_applyCycle(cycle, direction, faceName) {
const values = cycle.map(c => {
let val = this._getSegment(c.face, c.type, c.index);
return c.reverse ? val.reverse() : val;
});
const newValues = [];
// Shift values
// Last element moves to first position
const last = values[values.length - 1];
for (let i = 0; i < values.length; i++) {
// Calculate previous index with modulo
// i=0 -> prev=3. i=1 -> prev=0.
const prevIdx = mod(i - 1, values.length);
newValues[i] = values[prevIdx];
}
// Apply new values with reverse logic if needed
cycle.forEach((c, i) => {
let val = newValues[i];
if (c.reverse) val = val.reverse();
this._setSegment(c.face, c.type, c.index, val);
});
}
}

View File

@@ -0,0 +1,68 @@
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);
}
rotateSlice(axis, direction, steps = 1) {
// A middle slice rotation (M, E, S) logically translates to rotating
// the two intersecting outer layers in the opposite direction, while
// the centers (the core abstract frame) remain perfectly stationary.
// The frontend simultaneously handles rotating the camera to complete the illusion.
this.rotateLayer(axis, 1, -direction, steps);
this.rotateLayer(axis, -1, -direction, steps);
}
toCubies() {
return this.visual.toCubies();
}
validate() {
const valid = this.state.isValid();
return { valid, errors: valid ? [] : ['Invalid cube configuration (Parity or Orientation rules violated)'] };
}
}

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

@@ -0,0 +1,398 @@
/**
* Dedicated 3x3x3 Rubik's Cube Model
*
* Representation:
* A collection of 27 Cubie objects, each with position (x, y, z) and face colors.
* Coordinate System:
* x: Left (-1) to Right (1)
* y: Bottom (-1) to Top (1)
* z: Back (-1) to Front (1)
*
* This logical model maintains the state of the cube and handles rotations.
*/
export const COLORS = {
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",
};
// Standard Face Colors (Solved State)
const SOLVED_COLORS = {
[FACES.UP]: COLORS.WHITE,
[FACES.DOWN]: COLORS.YELLOW,
[FACES.LEFT]: COLORS.ORANGE,
[FACES.RIGHT]: COLORS.RED,
[FACES.FRONT]: COLORS.GREEN,
[FACES.BACK]: COLORS.BLUE,
};
class Cubie {
constructor(id, x, y, z) {
this.id = id;
this.x = x;
this.y = y;
this.z = z;
this.faces = {
[FACES.UP]: COLORS.BLACK,
[FACES.DOWN]: COLORS.BLACK,
[FACES.LEFT]: COLORS.BLACK,
[FACES.RIGHT]: COLORS.BLACK,
[FACES.FRONT]: COLORS.BLACK,
[FACES.BACK]: COLORS.BLACK,
};
this.initColors();
}
// Set initial colors based on position in solved state
initColors() {
if (this.y === 1) this.faces[FACES.UP] = SOLVED_COLORS[FACES.UP];
if (this.y === -1) this.faces[FACES.DOWN] = SOLVED_COLORS[FACES.DOWN];
if (this.x === -1) this.faces[FACES.LEFT] = SOLVED_COLORS[FACES.LEFT];
if (this.x === 1) this.faces[FACES.RIGHT] = SOLVED_COLORS[FACES.RIGHT];
if (this.z === 1) this.faces[FACES.FRONT] = SOLVED_COLORS[FACES.FRONT];
if (this.z === -1) this.faces[FACES.BACK] = SOLVED_COLORS[FACES.BACK];
}
}
export class CubeModel {
constructor() {
this.size = 3;
this.cubies = [];
this.init();
}
init() {
this.cubies = [];
let id = 0;
for (let x = -1; x <= 1; x++) {
for (let y = -1; y <= 1; y++) {
for (let z = -1; z <= 1; z++) {
this.cubies.push(new Cubie(id++, x, y, z));
}
}
}
}
reset() {
this.init();
}
/**
* Rotates a layer around an axis.
* @param {string} axis - 'x', 'y', 'z'
* @param {number} index - -1 (Left/Bottom/Back), 0 (Middle), 1 (Right/Top/Front)
* @param {number} direction - 1 (CW), -1 (CCW) relative to axis positive direction
*/
rotateLayer(axis, index, direction) {
// Determine the relevant cubies in the slice
const slice = this.cubies.filter((c) => c[axis] === index);
// Coordinate rotation (Matrix Logic)
// 90 deg CW rotation formulas:
// X-Axis: (y, z) -> (-z, y)
// Y-Axis: (x, z) -> (z, -x)
// Z-Axis: (x, y) -> (-y, x)
// Note: direction 1 is usually CCW in math (right hand rule around axis).
// Let's verify standard:
// Right Hand Rule with Thumb along Axis: Fingers curl in Positive Rotation direction.
// X (Right): Curl from Y (Up) to Z (Front). (0,1,0)->(0,0,1).
// y' = -z, z' = y?
// Let's check (0,1): y=1, z=0 -> y'=0, z'=1. Correct.
// So if direction is 1 (Positive/CW around axis):
// X: y' = -z, z' = y
// Y: z' = -x, x' = z
// Z: x' = -y, y' = x
// If direction is -1: Inverse.
slice.forEach((cubie) => {
this._rotateCubieCoordinates(cubie, axis, direction);
this._rotateCubieFaces(cubie, axis, direction);
});
}
_rotateCubieCoordinates(cubie, axis, direction) {
const { x, y, z } = cubie;
if (axis === "x") {
if (direction === 1) {
cubie.y = -z;
cubie.z = y;
} else {
cubie.y = z;
cubie.z = -y;
}
} else if (axis === "y") {
if (direction === 1) {
cubie.z = -x;
cubie.x = z;
} else {
cubie.z = x;
cubie.x = -z;
}
} else if (axis === "z") {
if (direction === 1) {
// CW
cubie.x = -y;
cubie.y = x;
} else {
// CCW
cubie.x = y;
cubie.y = -x;
}
}
}
_rotateCubieFaces(cubie, axis, direction) {
// When a cubie rotates, its faces move to new positions.
// We swap the COLORS on the faces.
// Example: Rotate X (Roll Forward). Up Face becomes Front Face.
// So new Front Color = old Up Color.
// cubie.faces[FRONT] = old_faces[UP]
const f = { ...cubie.faces };
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
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
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
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
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
cubie.faces[FACES.RIGHT] = f[FACES.UP];
cubie.faces[FACES.DOWN] = f[FACES.RIGHT];
cubie.faces[FACES.LEFT] = f[FACES.DOWN];
cubie.faces[FACES.UP] = f[FACES.LEFT];
}
}
}
toCubies() {
// Return copy of state for rendering
// CubeCSS expects array of objects with x, y, z, faces
return this.cubies.map((c) => ({
id: c.id,
x: c.x,
y: c.y,
z: c.z,
faces: { ...c.faces },
}));
}
/**
* Applies a standard Rubik's Cube move
* @param {string} move - e.g. "U", "R'", "F2"
*/
applyMove(move) {
const base = move[0];
const modifier = move.substring(1);
let direction = -1; // Standard CW is -1 for U, L, F, B? Let's check.
// Direction Mapping based on rotateLayer Math:
// X(1) = CW (Up->Front)
// Y(1) = CW (Right->Front)
// Z(1) = CCW (Right->Up)
// R (CW around X): 1
// L (CW around -X): -1
// U (CW around Y): 1
// D (CW around -Y): -1
// F (CW around Z): -1 (since Z(1) is CCW)
// 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;
}
if (modifier === "'") direction *= -1;
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;
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;
}
}
}
// Debug printer for tests
toString() {
let out = "Cube State (3x3x3):\n";
// We can print faces.
// Order: U, D, F, B, L, R
const printFace = (face, name) => {
out += `Face ${name}:\n`;
// Grid 3x3.
// U: y=1. x from -1 to 1. z from -1 to 1.
// Coordinate mapping depends on face.
// Let's iterate standard grid rows/cols.
for (let r = 0; r < 3; r++) {
let rowStr = "";
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).
// 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.
// 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.
// 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,
);
if (cubie) {
rowStr += cubie.faces[face][0].toUpperCase() + " ";
} else {
rowStr += "? ";
}
}
out += rowStr + "\n";
}
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");
return out;
}
scramble(n = 20) {
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)
// D: y=-1, dir 1
// L: x=-1, dir -1
// R: x=1, dir 1
// F: z=1, dir 1
// B: z=-1, dir -1
// We can just generate random rotateLayer calls
for (let i = 0; i < n; i++) {
const axis = axes[Math.floor(Math.random() * axes.length)];
// Allow middle layer? Standard Scramble usually doesnt.
const index = indices[Math.floor(Math.random() * indices.length)];
const dir = Math.random() > 0.5 ? 1 : -1;
this.rotateLayer(axis, index, dir);
}
}
}

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

@@ -0,0 +1,409 @@
// 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;
}
isSolved() {
// Check if permutations are identity and orientations are zero
for (let i = 0; i < 8; i++) {
if (this.cp[i] !== i || this.co[i] !== 0) return false;
}
for (let i = 0; i < 12; i++) {
if (this.ep[i] !== i || this.eo[i] !== 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,112 +0,0 @@
export class Matrix4 {
constructor() {
this.elements = [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
];
}
static identity() {
return new Matrix4();
}
multiply(other) {
const a = this.elements;
const b = other.elements;
const result = new Matrix4();
const r = result.elements;
// a is row-major? No, CSS matrix3d is column-major.
// Let's stick to column-major as per WebGL/CSS standard.
// r[0] = a[0]*b[0] + a[4]*b[1] + a[8]*b[2] + a[12]*b[3] ...
for (let i = 0; i < 4; i++) { // Column of B
for (let j = 0; j < 4; j++) { // Row of A
let sum = 0;
for (let k = 0; k < 4; k++) {
sum += a[j + k * 4] * b[i * 4 + k]; // Correct for column-major storage
}
r[j + i * 4] = sum;
}
}
return result;
}
// Multiply this * other
multiplySelf(other) {
this.elements = this.multiply(other).elements;
return this;
}
// Multiply other * this (pre-multiply)
premultiply(other) {
this.elements = other.multiply(this).elements;
return this;
}
static translation(x, y, z) {
const m = new Matrix4();
m.elements[12] = x;
m.elements[13] = y;
m.elements[14] = z;
return m;
}
static rotationX(angleRad) {
const m = new Matrix4();
const c = Math.cos(angleRad);
const s = Math.sin(angleRad);
m.elements[5] = c;
m.elements[6] = s;
m.elements[9] = -s;
m.elements[10] = c;
return m;
}
static rotationY(angleRad) {
const m = new Matrix4();
const c = Math.cos(angleRad);
const s = Math.sin(angleRad);
m.elements[0] = c;
m.elements[2] = -s;
m.elements[8] = s;
m.elements[10] = c;
return m;
}
static rotationZ(angleRad) {
const m = new Matrix4();
const c = Math.cos(angleRad);
const s = Math.sin(angleRad);
m.elements[0] = c;
m.elements[1] = s;
m.elements[4] = -s;
m.elements[5] = c;
return m;
}
translate(x, y, z) {
return this.multiplySelf(Matrix4.translation(x, y, z));
}
rotateX(deg) {
return this.multiplySelf(Matrix4.rotationX(deg * Math.PI / 180));
}
rotateY(deg) {
return this.multiplySelf(Matrix4.rotationY(deg * Math.PI / 180));
}
rotateZ(deg) {
return this.multiplySelf(Matrix4.rotationZ(deg * Math.PI / 180));
}
toCSS() {
// CSS matrix3d takes comma-separated values
// Round to avoid scientific notation like 1e-15 which CSS hates
const rounded = this.elements.map(v => Math.abs(v) < 1e-10 ? 0 : v);
return `matrix3d(${rounded.join(',')})`;
}
}

View File

@@ -0,0 +1,58 @@
// 3D geometry helpers for cube face/axis operations and screen projection
export const getFaceNormal = (face, FACES) => {
const map = {
[FACES.FRONT]: { x: 0, y: 0, z: 1 },
[FACES.BACK]: { x: 0, y: 0, z: -1 },
[FACES.RIGHT]: { x: 1, y: 0, z: 0 },
[FACES.LEFT]: { x: -1, y: 0, z: 0 },
[FACES.UP]: { x: 0, y: 1, z: 0 },
[FACES.DOWN]: { x: 0, y: -1, z: 0 },
};
return map[face] || { x: 0, y: 0, z: 1 };
};
// Which axes can this face physically rotate along?
export const getAllowedAxes = (face, FACES) => {
switch (face) {
case FACES.FRONT:
case FACES.BACK:
return ["x", "y"];
case FACES.RIGHT:
case FACES.LEFT:
return ["z", "y"];
case FACES.UP:
case FACES.DOWN:
return ["x", "z"];
}
return [];
};
export const getAxisVector = (axis) => {
if (axis === "x") return { x: 1, y: 0, z: 0 };
if (axis === "y") return { x: 0, y: 1, z: 0 };
if (axis === "z") return { x: 0, y: 0, z: 1 };
return { x: 0, y: 0, z: 0 };
};
// Cross product: a × b
export const cross = (a, b) => ({
x: a.y * b.z - a.z * b.y,
y: a.z * b.x - a.x * b.z,
z: a.x * b.y - a.y * b.x,
});
// Project 3D vector to 2D screen space using a viewMatrix (column-major 4x4).
// Input v is in Right-Handed Math Coordinates (Y up).
// viewMatrix operates in CSS Coordinates (Y down).
// Applies T⁻¹ * M * T to maintain correct projection chirality.
export const project = (v, viewMatrix) => {
const m = viewMatrix;
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;
return { x, y: mathY };
};

23
src/utils/easing.js Normal file
View File

@@ -0,0 +1,23 @@
// Animation easing functions and their derivatives
export const easeInOutCubic = (t) => {
if (t < 0.5) return 4 * t * t * t;
return 1 - Math.pow(-2 * t + 2, 3) / 2;
};
// Derivative of standard easeInOutCubic for instantaneous velocity calculations
export const easeInOutCubicDerivative = (t) => {
if (t < 0.5) return 12 * t * t;
return 3 * Math.pow(-2 * t + 2, 2);
};
// Custom easing function that preserves initial velocity v₀
// The polynomial is P(t) = (v₀ - 2)t³ + (3 - 2v₀)t² + v₀t
export const cubicEaseWithInitialVelocity = (t, v0) => {
return (v0 - 2) * t * t * t + (3 - 2 * v0) * t * t + v0 * t;
};
// Derivative of the custom easing function
export const cubicEaseWithInitialVelocityDerivative = (t, v0) => {
return 3 * (v0 - 2) * t * t + 2 * (3 - 2 * v0) * t + v0;
};

133
src/utils/matrix.js Normal file
View File

@@ -0,0 +1,133 @@
// 4x4 matrix operations for 3D transformations (column-major, CSS/WebGL convention)
export const identityMatrix = () => [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
];
export const rotateXMatrix = (deg) => {
const rad = (deg * Math.PI) / 180;
const c = Math.cos(rad);
const s = Math.sin(rad);
return [
1, 0, 0, 0,
0, c, s, 0,
0, -s, c, 0,
0, 0, 0, 1
];
};
export const rotateYMatrix = (deg) => {
const rad = (deg * Math.PI) / 180;
const c = Math.cos(rad);
const s = Math.sin(rad);
return [
c, 0, -s, 0,
0, 1, 0, 0,
s, 0, c, 0,
0, 0, 0, 1
];
};
export 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
];
};
export const multiplyMatrices = (a, b) => {
const result = new Array(16).fill(0);
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
for (let k = 0; k < 4; k++) {
result[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
}
}
}
return result;
};
// --- Quaternion helpers for distortion-free rotation interpolation (SLERP) ---
export const matToQuat = (m) => {
const trace = m[0] + m[5] + m[10];
let w, x, y, z;
if (trace > 0) {
const s = 0.5 / Math.sqrt(trace + 1);
w = 0.25 / s;
x = (m[6] - m[9]) * s;
y = (m[8] - m[2]) * s;
z = (m[1] - m[4]) * s;
} else if (m[0] > m[5] && m[0] > m[10]) {
const s = 2 * Math.sqrt(1 + m[0] - m[5] - m[10]);
w = (m[6] - m[9]) / s;
x = 0.25 * s;
y = (m[4] + m[1]) / s;
z = (m[8] + m[2]) / s;
} else if (m[5] > m[10]) {
const s = 2 * Math.sqrt(1 + m[5] - m[0] - m[10]);
w = (m[8] - m[2]) / s;
x = (m[4] + m[1]) / s;
y = 0.25 * s;
z = (m[6] + m[9]) / s;
} else {
const s = 2 * Math.sqrt(1 + m[10] - m[0] - m[5]);
w = (m[1] - m[4]) / s;
x = (m[8] + m[2]) / s;
y = (m[6] + m[9]) / s;
z = 0.25 * s;
}
return { w, x, y, z };
};
export const slerp = (q1, q2, t) => {
let dot = q1.w * q2.w + q1.x * q2.x + q1.y * q2.y + q1.z * q2.z;
let q2n = q2;
if (dot < 0) {
q2n = { w: -q2.w, x: -q2.x, y: -q2.y, z: -q2.z };
dot = -dot;
}
if (dot > 0.9995) {
const len = Math.sqrt(
(q1.w + t * (q2n.w - q1.w)) ** 2 + (q1.x + t * (q2n.x - q1.x)) ** 2 +
(q1.y + t * (q2n.y - q1.y)) ** 2 + (q1.z + t * (q2n.z - q1.z)) ** 2
);
return {
w: (q1.w + t * (q2n.w - q1.w)) / len,
x: (q1.x + t * (q2n.x - q1.x)) / len,
y: (q1.y + t * (q2n.y - q1.y)) / len,
z: (q1.z + t * (q2n.z - q1.z)) / len,
};
}
const theta = Math.acos(dot);
const sinTheta = Math.sin(theta);
const a = Math.sin((1 - t) * theta) / sinTheta;
const b = Math.sin(t * theta) / sinTheta;
return {
w: a * q1.w + b * q2n.w,
x: a * q1.x + b * q2n.x,
y: a * q1.y + b * q2n.y,
z: a * q1.z + b * q2n.z,
};
};
export const quatToMat = (q) => {
const { w, x, y, z } = q;
const xx = x * x, yy = y * y, zz = z * z;
const xy = x * y, xz = x * z, yz = y * z;
const wx = w * x, wy = w * y, wz = w * z;
return [
1 - 2 * (yy + zz), 2 * (xy + wz), 2 * (xz - wy), 0,
2 * (xy - wz), 1 - 2 * (xx + zz), 2 * (yz + wx), 0,
2 * (xz + wy), 2 * (yz - wx), 1 - 2 * (xx + yy), 0,
0, 0, 0, 1,
];
};

116
src/utils/moveMapping.js Normal file
View File

@@ -0,0 +1,116 @@
// Move notation mapping between UI labels, internal logic axes, and solver output.
// The UI coordinate system is rotated 90° around Y from internal coordinates.
// UI button key → internal base + modifier
export const MOVE_MAP = {
U: { base: "U", modifier: "" },
"U-prime": { base: "U", modifier: "'" },
U2: { base: "U", modifier: "2" },
D: { base: "D", modifier: "" },
"D-prime": { base: "D", modifier: "'" },
D2: { base: "D", modifier: "2" },
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: "'" },
F2: { base: "L", modifier: "2" },
B: { base: "R", modifier: "" },
"B-prime": { base: "R", modifier: "'" },
B2: { base: "R", modifier: "2" },
};
// Internal face name → UI face name
export const INTERNAL_TO_UI = {
'F': 'R', 'B': 'L', 'R': 'B', 'L': 'F',
'U': 'U', 'D': 'D',
'M': 'M', 'E': 'E', 'S': 'S',
};
// Internal base → axis and layer index
export const getAxisIndexForBase = (base) => {
if (base === "U") return { axis: "y", index: 1 };
if (base === "D") return { axis: "y", index: -1 };
if (base === "L") return { axis: "x", index: -1 };
if (base === "R") return { axis: "x", index: 1 };
if (base === "F") return { axis: "z", index: 1 };
if (base === "B") return { axis: "z", index: -1 };
return { axis: "y", index: 0 };
};
// Mathematical positive rotation direction (Right-Hand Rule)
export const getMathDirectionForBase = (base) => {
if (['R', 'U', 'F', 'S'].includes(base)) return -1;
if (['L', 'D', 'B', 'M', 'E'].includes(base)) return 1;
return 1;
};
// Convert axis/index/direction to a standard Rubik's notation label (UI-facing)
export const getDragMoveLabel = (axis, index, direction, count) => {
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 },
};
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 = "'";
}
const uiBase = INTERNAL_TO_UI[mapping.base] || mapping.base;
return uiBase + modifier;
};
// Coerce rotation step count to match a desired sign direction
export const coerceStepsToSign = (steps, sign) => {
if (steps === 0) return 0;
const mod = ((steps % 4) + 4) % 4;
if (sign < 0) {
if (mod === 1) return -3;
if (mod === 2) return -2;
return -1;
}
if (mod === 1) return 1;
if (mod === 2) return 2;
return 3;
};
// Format a move label from a display base and step count
export const formatMoveLabel = (displayBase, steps) => {
const stepsMod = ((steps % 4) + 4) % 4;
if (stepsMod === 0) return '';
let modifier = "";
if (stepsMod === 1) modifier = "'";
else if (stepsMod === 2) modifier = "2";
else if (stepsMod === 3) modifier = "";
return displayBase + (modifier === "'" ? "'" : modifier === "2" ? "2" : "");
};

View File

@@ -0,0 +1,374 @@
import { DeepCube, MOVES } from "../DeepCube.js";
export class BeginnerSolver {
constructor(cube) {
this.cube = cube.clone();
this.solution = [];
}
apply(moveStr) {
if (!moveStr) return;
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 = [];
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();
this.alignUFace();
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;
}
testAlg(algStr, targetId, isCorner) {
let temp = this.cube;
const arr = algStr.split(" ").filter((m) => m);
for (const m of arr) temp = temp.multiply(MOVES[m]);
if (isCorner) {
return temp.cp[targetId] === targetId && temp.co[targetId] === 0;
} else {
return temp.ep[targetId] === targetId && temp.eo[targetId] === 0;
}
}
solveCross() {
const targets = [
{ id: 5, up: 1, ins: ["F2", "U' R' F R", "U L F' L'"] }, // DF
{ id: 4, up: 0, ins: ["R2", "U' B' R B", "U F R' F'"] }, // DR
{ id: 7, up: 3, ins: ["B2", "U' L' B L", "U R B' R'"] }, // DB
{ id: 6, up: 2, ins: ["L2", "U' F' L F", "U B L' B'"] }, // DL
];
for (let t of targets) {
let safetyCount = 0;
while (safetyCount++ < 15) {
let pos = this.cube.ep.indexOf(t.id);
if (pos === t.id && this.cube.eo[pos] === 0) break;
if ([4, 5, 6, 7].includes(pos)) {
if (pos === 5) this.apply("F2");
else if (pos === 4) this.apply("R2");
else if (pos === 7) this.apply("B2");
else if (pos === 6) this.apply("L2");
} else if ([8, 9, 10, 11].includes(pos)) {
if (pos === 8) this.apply("R U R'");
else if (pos === 9) this.apply("F U F'");
else if (pos === 10) this.apply("L U L'");
else if (pos === 11) this.apply("B U B'");
} else if ([0, 1, 2, 3].includes(pos)) {
let success = false;
for (let u = 0; u < 4; u++) {
for (let alg of t.ins) {
if (this.testAlg(alg, t.id, false)) {
this.apply(alg);
success = true;
break;
}
}
if (success) break;
this.apply("U");
}
if (success) break;
}
}
}
}
solveF2LCorners() {
const targets = [
{ id: 4, ext: "R U R'", ins: ["R U2 R' U' R U R'", "R U R'", "F' U' F"] },
{ id: 5, ext: "F U F'", ins: ["F U2 F' U' F U F'", "F U F'", "L' U' L"] },
{ id: 6, ext: "L U L'", ins: ["L U2 L' U' L U L'", "L U L'", "B' U' B"] },
{ id: 7, ext: "B U B'", ins: ["B U2 B' U' B U B'", "B U B'", "R' U' R"] },
];
for (let t of targets) {
let safetyCount = 0;
while (safetyCount++ < 15) {
let pos = this.cube.cp.indexOf(t.id);
if (pos === t.id && this.cube.co[pos] === 0) break;
if ([4, 5, 6, 7].includes(pos)) {
if (pos === 4) this.apply("R U R'");
else if (pos === 5) this.apply("F U F'");
else if (pos === 6) this.apply("L U L'");
else if (pos === 7) this.apply("B U B'");
} else if ([0, 1, 2, 3].includes(pos)) {
let success = false;
for (let u = 0; u < 4; u++) {
for (let alg of t.ins) {
if (this.testAlg(alg, t.id, true)) {
this.apply(alg);
success = true;
break;
}
}
if (success) break;
this.apply("U");
}
if (success) break;
}
}
}
}
solveF2LEdges() {
const targets = [
{
id: 8,
ext: "R U R'",
ins: ["U R U' R' U' F' U F", "U' F' U F U R U' R'"],
},
{
id: 9,
ext: "F U F'",
ins: ["U' L' U L U F U' F'", "U F U' F' U' L' U L"],
},
{
id: 10,
ext: "L U L'",
ins: ["U L U' L' U' B' U B", "U' B' U B U L U' L'"],
},
{
id: 11,
ext: "B U B'",
ins: ["U B U' B' U' R' U R", "U' R' U R U B U' B'"],
},
];
for (let t of targets) {
let safetyCount = 0;
while (safetyCount++ < 15) {
let pos = this.cube.ep.indexOf(t.id);
if (pos === t.id && this.cube.eo[pos] === 0) break;
if ([8, 9, 10, 11].includes(pos)) {
if (pos === 8)
this.apply("R U R' U' F' U' F"); // Extract standard way
else if (pos === 9) this.apply("F U F' U' L' U' L");
else if (pos === 10) this.apply("L U L' U' B' U' B");
else if (pos === 11) this.apply("B U B' U' R' U' R");
} else if ([0, 1, 2, 3].includes(pos)) {
let success = false;
for (let u = 0; u < 4; u++) {
for (let alg of t.ins) {
if (this.testAlg(alg, t.id, false)) {
this.apply(alg);
success = true;
break;
}
}
if (success) break;
this.apply("U");
}
if (success) break;
}
}
}
}
solveYellowCross() {
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) {
const [a, b] = oriented;
if (Math.abs(a - b) === 2 || (a === 0 && b === 3)) {
// Line or L-shape handling simplified
let succ = false;
for (let u = 0; u < 4; u++) {
let tmp = this.cube.clone();
let p1 = (temp) => {
let c = temp.clone();
"F R U R' U' F'"
.split(" ")
.filter((x) => x)
.forEach((m) => (c = c.multiply(MOVES[m])));
return c;
};
let p2 = (temp) => {
let c = temp.clone();
"F U R U' R' F'"
.split(" ")
.filter((x) => x)
.forEach((m) => (c = c.multiply(MOVES[m])));
return c;
};
if ([0, 1, 2, 3].filter((i) => p1(tmp).eo[i] === 0).length === 4) {
this.apply("F R U R' U' F'");
succ = true;
break;
}
if ([0, 1, 2, 3].filter((i) => p2(tmp).eo[i] === 0).length === 4) {
this.apply("F U R U' R' F'");
succ = true;
break;
}
this.apply("U");
}
if (!succ) this.apply("F R U R' U' F'"); // fallback
} else {
this.apply("U");
}
}
}
}
orientYellowCorners() {
let safetyCount = 0;
while (safetyCount++ < 25) {
if ([0, 1, 2, 3].filter((i) => this.cube.co[i] === 0).length === 4) break;
if (this.cube.co[0] === 0) this.apply("U");
else this.apply("R' D' R D R' D' R D");
}
}
permuteYellowCorners() {
let safetyCount = 0;
while (safetyCount++ < 15) {
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;
let succ = false;
for (let u = 0; u < 4; u++) {
for (let alg of [
"R' F R' B2 R F' R' B2 R2",
"R B' R F2 R' B R F2 R2",
]) {
let t = this.cube.clone();
alg.split(" ").forEach((m) => (t = t.multiply(MOVES[m])));
let tc0 = t.cp[0],
tc1 = t.cp[1],
tc2 = t.cp[2],
tc3 = t.cp[3];
if (
(tc1 - tc0 + 4) % 4 === 1 &&
(tc2 - tc1 + 4) % 4 === 1 &&
(tc3 - tc2 + 4) % 4 === 1
) {
this.apply(alg);
succ = true;
break;
}
}
if (succ) break;
this.apply("U");
}
if (succ) break;
this.apply("R' F R' B2 R F' R' B2 R2");
}
}
permuteYellowEdges() {
let s = 0;
while (this.cube.cp[0] !== 0 && s++ < 5) this.apply("U");
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;
let succ = false;
const uMoves = ["", "U ", "U2 ", "U' "];
const uMovesInv = ["", "U' ", "U2 ", "U "];
for (let u = 0; u < 4; u++) {
for (let baseAlg of [
"R U' R U R U R U' R' U' R2",
"L' U L' U' L' U' L' U L U L2",
]) {
const fullAlg = uMoves[u] + baseAlg + " " + uMovesInv[u];
let t = this.cube.clone();
fullAlg
.split(" ")
.filter((x) => x)
.forEach((m) => (t = t.multiply(MOVES[m])));
if (
t.ep[0] === 0 &&
t.ep[1] === 1 &&
t.ep[2] === 2 &&
t.ep[3] === 3
) {
this.apply(fullAlg);
succ = true;
break;
}
}
if (succ) break;
}
if (succ) break;
this.apply("R U' R U R U R U' R' U' R2"); // Fallback cycle
}
}
alignUFace() {
let s = 0;
while (this.cube.cp[0] !== 0 && s++ < 5) this.apply("U");
}
optimizeSolution() {
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]) {
const val = (m) => (m.includes("'") ? -1 : m.includes("2") ? 2 : 1);
let sum = (val(a) + val(b)) % 4;
if (sum < 0) sum += 4;
this.solution.splice(i, 2);
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,140 @@
import Cube from "cubejs";
import { DeepCube, CORNERS, EDGES } from "../DeepCube.js";
export class KociembaSolver {
static init() {
Cube.initSolver();
}
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}`,
);
}
}
}

31
src/utils/toastHelper.js Normal file
View File

@@ -0,0 +1,31 @@
import Toastify from "toastify-js";
const ICONS = {
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
alert: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
check: '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>'
};
export const createToastHtml = (text, iconName = 'info') => {
const innerHtml = ICONS[iconName] || ICONS.info;
const size = 26; // Powiększona ikona
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-${iconName}">${innerHtml}</svg>`;
return `<div style="display: flex; align-items: center; gap: 8px;">
${svg}
<span>${text}</span>
</div>`;
};
export const showToast = (text, iconName = 'info', options = {}) => {
Toastify({
text: createToastHtml(text, iconName),
escapeMarkup: false,
duration: 3000,
gravity: "top",
position: "center",
stopOnFocus: true,
...options
}).showToast();
};

36
src/utils/tokenReducer.js Normal file
View File

@@ -0,0 +1,36 @@
// Reduces consecutive same-face moves into their net rotation.
// Agnostic to move names — works with any single-letter move notation.
const EMPTY = 'E';
const MODS = [EMPTY, '', '2', "'"];
const reduceGroup = (group) => {
const sum = group.reduce((acc, curr) => acc + MODS.indexOf(curr.mod), 0);
const mod = MODS[sum % 4];
return mod === EMPTY ? '' : `${group[0].name}${mod}`;
};
export const parseToken = (token) => {
const match = token.match(/^(\w)(.?)$/);
if (!match) return null;
return { token, name: match[1], mod: match[2] };
};
export const tokenReducer = (tokens) => {
const parsed = tokens.map(parseToken).filter(Boolean);
const desc = [];
const res = [];
let lastPos = 0;
for (let i = 0; i <= parsed.length; i++) {
if (i === parsed.length || (i > lastPos && parsed[i].name !== parsed[lastPos].name)) {
const group = parsed.slice(lastPos, i);
const reduced = reduceGroup(group);
desc.push({ reduced, group });
if (reduced !== '') res.push(reduced);
lastPos = i;
}
}
return { desc, tokens: res };
};

View File

@@ -0,0 +1,71 @@
import { RubiksJSModel } from "../utils/CubeLogicAdapter.js";
const cube = new RubiksJSModel();
// Helper to send state update
const sendUpdate = () => {
try {
const cubies = cube.toCubies();
const { cp, co, ep, eo } = cube.state;
postMessage({
type: "STATE_UPDATE",
payload: {
cubies,
deepCubeState: {
cp: [...cp],
co: [...co],
ep: [...ep],
eo: [...eo],
},
},
});
} catch (e) {
console.error("[Worker] Error generating cubies:", e);
postMessage({ type: "ERROR", payload: e.message });
}
};
self.onmessage = (e) => {
const { type, payload } = e.data;
switch (type) {
case "INIT":
case "RESET":
cube.reset();
sendUpdate();
break;
case "ROTATE_LAYER": {
const { axis, index, direction, steps = 1 } = payload;
cube.rotateLayer(axis, index, direction, steps);
sendUpdate();
break;
}
case "ROTATE_SLICE": {
const { axis, direction, steps = 1 } = payload;
cube.rotateSlice(axis, direction, steps);
sendUpdate();
break;
}
case "TURN": {
const { move } = payload;
cube.applyTurn(move);
sendUpdate();
break;
}
case "VALIDATE":
const validation = cube.validate();
postMessage({
type: "VALIDATION_RESULT",
payload: { valid: validation.valid, errors: validation.errors },
});
break;
}
};

View File

@@ -0,0 +1,58 @@
import { DeepCube } from "../utils/DeepCube.js";
import { KociembaSolver } from "../utils/solvers/KociembaSolver.js";
import { BeginnerSolver } from "../utils/solvers/BeginnerSolver.js";
let isKociembaReady = false;
// Defer heavy initialization to allow the worker to be responsive initially
setTimeout(() => {
console.log("[SolverWorker] Kociemba solver initialization");
console.time("[SolverWorker] Kociemba solver initialized");
KociembaSolver.init();
console.timeEnd("[SolverWorker] Kociemba solver initialized");
isKociembaReady = true;
postMessage({ type: "INIT_DONE" });
}, 50);
self.onmessage = (e) => {
const { type, payload } = e.data;
if (type === "SOLVE") {
const { solverType, cubeState } = payload;
if (solverType === "kociemba" && !isKociembaReady) {
postMessage({ type: "SOLVE_ERROR", payload: "wait for initialize solver" });
return;
}
try {
// Reconstruct DeepCube state from payload
const dc = new DeepCube(
new Int8Array(cubeState.cp),
new Int8Array(cubeState.co),
new Int8Array(cubeState.ep),
new Int8Array(cubeState.eo)
);
let solution = [];
if (solverType === "kociemba") {
const solver = new KociembaSolver(dc);
solution = solver.solve();
} else if (solverType === "beginner") {
const solver = new BeginnerSolver(dc);
solution = solver.solve();
} else {
throw new Error(`Unknown solver type: ${solverType}`);
}
postMessage({
type: "SOLVE_RESULT",
payload: solution,
});
} catch (err) {
console.error("[SolverWorker] Solve error:", err);
postMessage({ type: "SOLVE_ERROR", payload: err.message });
postMessage({ type: "SOLVE_RESULT", payload: [] });
}
}
};

24
test/debug_kociemba.js Normal file
View File

@@ -0,0 +1,24 @@
import { DeepCube, MOVES } from "../src/utils/DeepCube.js";
import { KociembaSolver } from "../src/utils/solvers/KociembaSolver.js";
let cube = new DeepCube();
const faceletStart = new KociembaSolver(cube).toFaceletString();
console.log("Solved Facelet:");
console.log(faceletStart);
cube = cube.multiply(MOVES["R"]);
const solverR = new KociembaSolver(cube);
const faceletR = solverR.toFaceletString();
console.log("Facelet after R:");
console.log(faceletR);
["U", "D", "R", "L", "F", "B"].forEach((m) => {
let c = new DeepCube().multiply(MOVES[m]);
let solver = new KociembaSolver(c);
try {
console.log(`Solution for ${m}:`, solver.solve().join(" "));
} catch (e) {
console.log(`Error on ${m}:`, e.message);
}
});

197
test/generate_math.js Normal file
View File

@@ -0,0 +1,197 @@
const C = ["URF", "UFL", "ULB", "UBR", "DFR", "DLF", "DBL", "DRB"];
const E = [
"UR",
"UF",
"UL",
"UB",
"DR",
"DF",
"DL",
"DB",
"FR",
"FL",
"BL",
"BR",
];
// Define physical coordinates for all 6 center stickers
const faces = {
U: [0, 1, 0],
D: [0, -1, 0],
R: [1, 0, 0],
L: [-1, 0, 0],
F: [0, 0, 1],
B: [0, 0, -1],
};
// 8 corners, each with 3 stickers
// URF corner has stickers pointing U, R, F
const cornerStickers = [
["U", "R", "F"],
["U", "F", "L"],
["U", "L", "B"],
["U", "B", "R"],
["D", "F", "R"],
["D", "L", "F"],
["D", "B", "L"],
["D", "R", "B"],
];
// 12 edges, each with 2 stickers
const edgeStickers = [
["U", "R"],
["U", "F"],
["U", "L"],
["U", "B"],
["D", "R"],
["D", "F"],
["D", "L"],
["D", "B"],
["F", "R"],
["F", "L"],
["B", "L"],
["B", "R"],
];
// Rotate a 3D vector around an axis by 90 deg clockwise looking at the face
function rotate(vec, axis) {
let [x, y, z] = vec;
// Holding the face and turning clockwise:
// U (Y+): Back(-Z) -> Right(+X) -> Front(+Z) -> Left(-X) -> Back(-Z)
// So X becomes Z, Z becomes -X
// Let's test UBR (X=1, Z=-1).
// Clockwise: UBR(TopRight) -> URF(BottomRight) -> UFL(BottomLeft) -> ULB(TopLeft).
// UBR (1,-1) -> URF (1,1). We need X'=1, Z'=1 from X=1, Z=-1.
// Formula for X'=1, Z'=1: X' = -Z, Z' = X.
// Let's try URF(1,1) -> UFL(-1,1): X' = -1, Z' = 1. matches X'=-Z, Z'=X.
// So U is [-z, y, x]
// D (Y-): Looking from bottom: Front(+Z) -> Right(+X) -> Back(-Z) -> Left(-X)
// So Front(Z=1) -> Right(X=1). Z'= -X? Yes. X'=Z.
// So D is [z, y, -x]
// R (X+): Up(+Y) -> Back(-Z) -> Down(-Y) -> Front(+Z)
// So Up(Y=1) -> Back(Z=-1). Y'= -Z? Yes. Z'=Y.
// So R is [x, -z, y]
// L (X-): Up(+Y) -> Front(+Z) -> Down(-Y) -> Back(-Z)
// So Up(Y=1) -> Front(Z=1). Y'= Z. Z'= -Y.
// So L is [x, z, -y]
// F (Z+): Up(+Y) -> Right(+X) -> Down(-Y) -> Left(-X)
// So Up(Y=1) -> Right(X=1). X'=Y. Y'=-X.
// So F is [y, -x, z]
// B (Z-): Up(+Y) -> Left(-X) -> Down(-Y) -> Right(+X)
// So Up(Y=1) -> Left(X=-1). X'=-Y. Y'=X.
// So B is [-y, x, z]
if (axis === "U") return [-z, y, x];
if (axis === "D") return [z, y, -x];
if (axis === "R") return [x, z, -y];
if (axis === "L") return [x, -z, y];
if (axis === "F") return [y, -x, z];
if (axis === "B") return [-y, x, z];
}
// Map a rotated vector back to a face name
function vecToFace(vec) {
for (let f in faces) {
if (
faces[f][0] === vec[0] &&
faces[f][1] === vec[1] &&
faces[f][2] === vec[2]
)
return f;
}
}
function generateMove(axis) {
let cp = [],
co = [],
ep = [],
eo = [];
// CORNERS
for (let c = 0; c < 8; c++) {
if (!cornerStickers[c].includes(axis)) {
cp[c] = c;
co[c] = 0;
continue;
}
let pos = [0, 0, 0];
cornerStickers[c].forEach((f) => {
pos[0] += faces[f][0];
pos[1] += faces[f][1];
pos[2] += faces[f][2];
});
let newPos = rotate(pos, axis);
let targetC = -1;
for (let i = 0; i < 8; i++) {
let p2 = [0, 0, 0];
cornerStickers[i].forEach((f) => {
p2[0] += faces[f][0];
p2[1] += faces[f][1];
p2[2] += faces[f][2];
});
if (p2[0] === newPos[0] && p2[1] === newPos[1] && p2[2] === newPos[2])
targetC = i;
}
cp[targetC] = c;
let rotatedStickers = cornerStickers[c].map((f) =>
vecToFace(rotate(faces[f], axis)),
);
let ori = cornerStickers[targetC].indexOf(rotatedStickers[0]);
co[targetC] = ori;
}
// EDGES
for (let e = 0; e < 12; e++) {
if (!edgeStickers[e].includes(axis)) {
ep[e] = e;
eo[e] = 0;
continue;
}
let pos = [0, 0, 0];
edgeStickers[e].forEach((f) => {
pos[0] += faces[f][0];
pos[1] += faces[f][1];
pos[2] += faces[f][2];
});
let newPos = rotate(pos, axis);
let targetE = -1;
for (let i = 0; i < 12; i++) {
let p2 = [0, 0, 0];
edgeStickers[i].forEach((f) => {
p2[0] += faces[f][0];
p2[1] += faces[f][1];
p2[2] += faces[f][2];
});
if (p2[0] === newPos[0] && p2[1] === newPos[1] && p2[2] === newPos[2])
targetE = i;
}
ep[targetE] = e;
let rotatedStickers = edgeStickers[e].map((f) =>
vecToFace(rotate(faces[f], axis)),
);
let primarySticker = rotatedStickers[0];
let ori = primarySticker === edgeStickers[targetE][0] ? 0 : 1;
eo[targetE] = ori;
}
return { cp, co, ep, eo };
}
const moves = ["U", "R", "F", "D", "L", "B"];
moves.forEach((m) => {
const res = generateMove(m);
console.log(`MOVES['${m}'] = new DeepCube(
[${res.cp.map((e) => `CORNERS.${C[e]}`).join(", ")}],
[${res.co.join(", ")}],
[${res.ep.map((e) => `EDGES.${E[e]}`).join(", ")}],
[${res.eo.join(", ")}]
)`);
});

36
test/math_output.txt Normal file
View File

@@ -0,0 +1,36 @@
MOVES['U'] = new DeepCube(
[CORNERS.UFL, CORNERS.ULB, CORNERS.UBR, CORNERS.URF, CORNERS.DFR, CORNERS.DLF, CORNERS.DBL, CORNERS.DRB],
[0, 0, 0, 0, 0, 0, 0, 0],
[EDGES.UF, EDGES.UL, EDGES.UB, EDGES.UR, 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]
)
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]
)
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]
)
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]
)
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]
)
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]
)

126
test/simulate_moves.js Normal file
View File

@@ -0,0 +1,126 @@
import { Cube, FACES, COLORS } from "../src/utils/Cube.js";
// Helper to print face
const printFace = (matrix, name) => {
console.log(`--- ${name} ---`);
matrix.forEach((row) =>
console.log(row.map((c) => (c ? c[0].toUpperCase() : "-")).join(" ")),
);
};
// Helper to check if a face matches expected color (center color)
const checkFaceColor = (matrix, expectedColor) => {
return matrix.every((row) => row.every((c) => c === expectedColor));
};
console.log("=== RUBIK'S CUBE SIMULATION & DIAGNOSTIC ===");
const cube = new Cube();
// 1. Initial State Check
console.log("\n1. Checking Initial State...");
let state = cube.getState();
const isSolved =
checkFaceColor(state[FACES.UP], COLORS.WHITE) &&
checkFaceColor(state[FACES.DOWN], COLORS.YELLOW) &&
checkFaceColor(state[FACES.FRONT], COLORS.GREEN) &&
checkFaceColor(state[FACES.BACK], COLORS.BLUE) &&
checkFaceColor(state[FACES.LEFT], COLORS.ORANGE) &&
checkFaceColor(state[FACES.RIGHT], COLORS.RED);
if (isSolved) {
console.log("✅ Initial state is SOLVED.");
} else {
console.error("❌ Initial state is BROKEN.");
process.exit(1);
}
// 2. Simulate Move: Front Face Drag Down
// Visual: Drag Down on Front Face (Left Layer).
// Axis: X. Index: -1 (Left).
// Physical expectation: The Left slice moves "towards the user" (if looking from top) or "down" (if looking from front).
// Standard Notation: L (Left CW).
// L move: Top -> Front -> Down -> Back -> Top.
// Let's verify what L does.
// Standard L: Front gets Top color.
// If I drag Left Layer DOWN on Front face, the Front face pieces move DOWN.
// So Front gets Top pieces.
// So Drag Down = L = Front gets Top.
console.log("\n2. Simulating: Left Layer (x=-1) Rotation (L-like move)...");
// We need to find which 'direction' in our engine corresponds to L.
// Our engine: rotateLayer('x', -1, direction).
// Try direction = 1
console.log("-> Applying rotateLayer('x', -1, 1)...");
cube.rotateLayer("x", -1, 1);
state = cube.getState();
// Check result on Left Column of Front Face
// Front is Green. Top is White.
// If L (Drag Down): Front-Left-Col should be White.
const frontLeftCol = [
state[FACES.FRONT][0][0],
state[FACES.FRONT][1][0],
state[FACES.FRONT][2][0],
];
console.log("Front Left Column colors:", frontLeftCol);
if (frontLeftCol.every((c) => c === COLORS.WHITE)) {
console.log(
"✅ Result: Front got White (Top). This matches 'Drag Down' (L move).",
);
console.log("=> CONCLUSION: direction=1 corresponds to Drag Down (L).");
} else if (frontLeftCol.every((c) => c === COLORS.YELLOW)) {
console.log(
"⚠️ Result: Front got Yellow (Down). This matches 'Drag Up' (L' move).",
);
console.log("=> CONCLUSION: direction=1 corresponds to Drag Up (L').");
} else {
console.error("❌ Unexpected colors:", frontLeftCol);
}
// Reset for next test
cube.reset();
// 3. Simulate Move: Front Face Drag Right
// Visual: Drag Right on Front Face (Top Layer).
// Axis: Y. Index: 1 (Top).
// Physical expectation: Top slice moves Right.
// Standard Notation: U' (Up CCW) ? No.
// If I hold cube, drag Top Layer to Right.
// Front face pieces move to Right face.
// Standard U (CW): Front -> Left.
// So Drag Right is U' (CCW).
// Let's verify.
// Drag Right: Front -> Right.
console.log("\n3. Simulating: Top Layer (y=1) Rotation...");
// Try direction = 1
console.log("-> Applying rotateLayer('y', 1, 1)...");
cube.rotateLayer("y", 1, 1);
state = cube.getState();
// Check result on Top Row of Front Face
// Front is Green. Left is Orange. Right is Red.
// If Drag Right: Front-Top-Row should be Orange? No.
// Drag Right: The pieces move Right. So Front REPLACES Right?
// Or Front GETS Left?
// Visually: The face moves Right. So the Green pieces go to Right face.
// And Front face gets Left (Orange) pieces.
// So Front-Top-Row should be Orange.
const frontTopRow = state[FACES.FRONT][0];
console.log("Front Top Row colors:", frontTopRow);
if (frontTopRow.every((c) => c === COLORS.ORANGE)) {
console.log("✅ Result: Front got Orange (Left). This matches 'Drag Right'.");
console.log("=> CONCLUSION: direction=1 corresponds to Drag Right.");
} else if (frontTopRow.every((c) => c === COLORS.RED)) {
console.log("⚠️ Result: Front got Red (Right). This matches 'Drag Left'.");
console.log("=> CONCLUSION: direction=1 corresponds to Drag Left.");
} else {
console.error("❌ Unexpected colors:", frontTopRow);
}
console.log("\n=== END SIMULATION ===");

9
test/test_aperm.js Normal file
View File

@@ -0,0 +1,9 @@
import { DeepCube, MOVES } from '../src/utils/DeepCube.js';
let cube = new DeepCube();
const apply = (str) => { str.split(' ').filter(x => x).forEach(m => cube = cube.multiply(MOVES[m])); };
apply("R' F R' B2 R F' R' B2 R2");
console.log(`cp after A-perm:`, cube.cp.slice(0, 4));
// We want to see which two corners are swapped.
// Solved is 0,1,2,3.
// If it prints 0,1,3,2, then 2 and 3 are swapped (Back corners).

View File

@@ -0,0 +1,25 @@
import { DeepCube, MOVES } from '../src/utils/DeepCube.js';
import { BeginnerSolver } from '../src/utils/solvers/BeginnerSolver.js';
let cube = new DeepCube();
const scramble = "R U R' U' R' F R2 U' R' U' R U R' F'"; // T-perm
scramble.split(' ').forEach(move => {
cube = cube.multiply(MOVES[move]);
});
console.log('Testing BeginnerSolver with T-perm...');
const solver = new BeginnerSolver(cube);
// Add some logging to the solver's methods to trace execution
const originalApply = solver.apply.bind(solver);
solver.apply = (moveStr) => {
// console.log('Applying:', moveStr);
originalApply(moveStr);
};
try {
const solution = solver.solve();
console.log('Solution found:', solution.join(' '));
} catch (e) {
console.error('Error during solve:', e);
}

View File

@@ -0,0 +1,41 @@
import { DeepCube, MOVES } from "../src/utils/DeepCube.js";
import { BeginnerSolver } from "../src/utils/solvers/BeginnerSolver.js";
const allMoves = Object.keys(MOVES);
const getRandomScramble = (length = 20) => {
let s = [];
for (let i = 0; i < length; i++)
s.push(allMoves[Math.floor(Math.random() * allMoves.length)]);
return s.join(" ");
};
for (let i = 1; i <= 20; i++) {
let cube = new DeepCube();
const scramble = getRandomScramble();
scramble.split(" ").forEach((move) => (cube = cube.multiply(MOVES[move])));
const startTime = Date.now();
const solver = new BeginnerSolver(cube);
try {
const solution = solver.solve();
const elapsedTime = Date.now() - startTime;
console.log(
`Test ${i}: Solved in ${elapsedTime}ms. Solution length: ${solution.length}`,
);
// Verify it actually solved it
let testCube = cube.clone();
solution.forEach((m) => (testCube = testCube.multiply(MOVES[m])));
if (!solver.isSolvedState(testCube)) {
console.error(
`ERROR: Test ${i} failed to fully solve the cube mathematically!`,
);
process.exit(1);
}
} catch (e) {
console.error(`ERROR: Test ${i} threw an exception:`, e);
process.exit(1);
}
}
console.log("All 20 tests passed flawlessly!");

34
test/test_diagnostics.js Normal file
View File

@@ -0,0 +1,34 @@
import { DeepCube, MOVES } from '../src/utils/DeepCube.js';
import { BeginnerSolver } from '../src/utils/solvers/BeginnerSolver.js';
const allMoves = Object.keys(MOVES);
const getRandomScramble = (length = 20) => {
let s = [];
for (let i = 0; i < length; i++) s.push(allMoves[Math.floor(Math.random() * allMoves.length)]);
return s.join(' ');
};
let cube = new DeepCube();
const scramble = getRandomScramble();
scramble.split(' ').forEach(move => cube = cube.multiply(MOVES[move]));
const solver = new BeginnerSolver(cube);
solver.solve();
console.log("Check Cross:");
for (let i of [4, 5, 6, 7]) console.log(`Edge ${i}: ep=${solver.cube.ep.indexOf(i)} eo=${solver.cube.eo[solver.cube.ep.indexOf(i)]}`);
console.log("Check F2L Corners:");
for (let i of [4, 5, 6, 7]) console.log(`Corner ${i}: cp=${solver.cube.cp.indexOf(i)} co=${solver.cube.co[solver.cube.cp.indexOf(i)]}`);
console.log("Check F2L Edges:");
for (let i of [8, 9, 10, 11]) console.log(`Edge ${i}: ep=${solver.cube.ep.indexOf(i)} eo=${solver.cube.eo[solver.cube.ep.indexOf(i)]}`);
console.log("Check OLL:");
console.log(`co:`, solver.cube.co.slice(0, 4));
console.log(`eo:`, solver.cube.eo.slice(0, 4));
console.log("Check PLL:");
console.log(`cp:`, solver.cube.cp.slice(0, 4));
console.log(`ep:`, solver.cube.ep.slice(0, 4));

40
test/test_diagnostics2.js Normal file
View File

@@ -0,0 +1,40 @@
import { DeepCube, MOVES } from '../src/utils/DeepCube.js';
import { BeginnerSolver } from '../src/utils/solvers/BeginnerSolver.js';
const allMoves = Object.keys(MOVES);
const getRandomScramble = (length = 20) => {
let s = [];
for (let i = 0; i < length; i++) s.push(allMoves[Math.floor(Math.random() * allMoves.length)]);
return s.join(' ');
};
for (let iter = 0; iter < 100; iter++) {
let cube = new DeepCube();
const scramble = getRandomScramble();
scramble.split(' ').forEach(move => cube = cube.multiply(MOVES[move]));
const solver = new BeginnerSolver(cube);
solver.solve();
if (!solver.isSolvedState(solver.cube)) {
console.log("FAILED ON SCRAMBLE:", scramble);
console.log("Check Cross:");
for (let i of [4, 5, 6, 7]) console.log(`Edge ${i}: ep=${solver.cube.ep.indexOf(i)} eo=${solver.cube.eo[solver.cube.ep.indexOf(i)]}`);
console.log("Check F2L Corners:");
for (let i of [4, 5, 6, 7]) console.log(`Corner ${i}: cp=${solver.cube.cp.indexOf(i)} co=${solver.cube.co[solver.cube.cp.indexOf(i)]}`);
console.log("Check F2L Edges:");
for (let i of [8, 9, 10, 11]) console.log(`Edge ${i}: ep=${solver.cube.ep.indexOf(i)} eo=${solver.cube.eo[solver.cube.ep.indexOf(i)]}`);
console.log("Check OLL:");
console.log(`co:`, solver.cube.co.slice(0, 4));
console.log(`eo:`, solver.cube.eo.slice(0, 4));
console.log("Check PLL:");
console.log(`cp:`, solver.cube.cp.slice(0, 4));
console.log(`ep:`, solver.cube.ep.slice(0, 4));
process.exit(1);
}
}
console.log("All 100 tests passed!");

24
test/test_macros.js Normal file
View File

@@ -0,0 +1,24 @@
import { DeepCube, MOVES } from '../src/utils/DeepCube.js';
let cube = new DeepCube();
const apply = (str) => {
str.split(' ').forEach(m => {
cube = cube.multiply(MOVES[m]);
});
};
apply("R U R'");
console.log("Piece 4 (R U R') is at position:", cube.cp.indexOf(4), "Orientation:", cube.co[cube.cp.indexOf(4)]);
cube = new DeepCube();
apply("R U' R' U R U2 R'");
console.log("Piece 4 (Up-face extraction) position:", cube.cp.indexOf(4), "Orientation:", cube.co[cube.cp.indexOf(4)]);
cube = new DeepCube();
apply("R U R'"); // insert front facing
console.log("Piece 4 (Front-face extraction) position:", cube.cp.indexOf(4), "Orientation:", cube.co[cube.cp.indexOf(4)]);
cube = new DeepCube();
apply("F' U' F"); // insert left facing
console.log("Piece 4 (Side-face extraction) position:", cube.cp.indexOf(4), "Orientation:", cube.co[cube.cp.indexOf(4)]);

24
test/test_macros2.js Normal file
View File

@@ -0,0 +1,24 @@
import { DeepCube, MOVES } from '../src/utils/DeepCube.js';
let cube;
const apply = (str) => {
str.split(' ').forEach(m => {
cube = cube.multiply(MOVES[m]);
});
};
const check = (name, alg, initPos, initOri) => {
cube = new DeepCube();
apply(alg);
// We applied alg to a SOLVED cube.
// The piece that WAS at 4 (DFR) is now at some position P with orientation O.
// To solve it, we would need to reverse the alg.
// So if we find a piece at P with orientation O, we apply the reverse alg!
console.log(`${name}: Extraction piece 4 is at pos ${cube.cp.indexOf(4)} ori ${cube.co[cube.cp.indexOf(4)]}`);
};
check("R U R'", "R U R'");
check("R U' R'", "R U' R'");
check("F' U' F", "F' U' F");
check("R U2 R' U' R U R'", "R U' R' U R U2 R'");

10
test/test_macros3.js Normal file
View File

@@ -0,0 +1,10 @@
import { DeepCube, MOVES } from '../src/utils/DeepCube.js';
let cube = new DeepCube();
const apply = (str) => { str.split(' ').forEach(m => { cube = cube.multiply(MOVES[m]); }); };
cube = new DeepCube(); apply("F' U F");
console.log("F' U F reverse puts piece 4 at pos:", cube.cp.indexOf(4), "ori:", cube.co[cube.cp.indexOf(4)]);
cube = new DeepCube(); apply("U' F' U F");
console.log("U' F' U F reverse puts piece 4 at pos:", cube.cp.indexOf(4), "ori:", cube.co[cube.cp.indexOf(4)]);

22
test/test_macros4.js Normal file
View File

@@ -0,0 +1,22 @@
import { DeepCube, MOVES } from '../src/utils/DeepCube.js';
let cube = new DeepCube();
const apply = (str) => { str.split(' ').forEach(m => { cube = cube.multiply(MOVES[m]); }); };
const check = (name, alg, expectedPos, expectedOri) => {
cube = new DeepCube();
apply(alg); // reverse of extraction
let p5 = cube.cp.indexOf(5); let o5 = cube.co[p5];
console.log(`${name}: pos 5 is ${p5} (expected ${expectedPos}), ori ${o5} (expected ${expectedOri})`);
};
// DLF (5) Target UFL (1)
check("F' U' F reverse", "F' U F", 1, 2); // if reverse puts it at pos 1 ori 2, then if at pos 1 ori 2 use F' U' F!
check("L U L' reverse", "L U' L'", 1, 1);
check("L' U' L reverse", "L' U L", 1, 1); // wait, L' moves DLF to UBL(2)? Let's find out!
// Check extraction from 5
cube = new DeepCube(); apply("L U L'");
console.log("Extract DLF (5) with L U L' gives pos:", cube.cp.indexOf(5), "ori:", cube.co[cube.cp.indexOf(5)]);
cube = new DeepCube(); apply("F' U' F");
console.log("Extract DLF (5) with F' U' F gives pos:", cube.cp.indexOf(5), "ori:", cube.co[cube.cp.indexOf(5)]);

22
test/test_moves.js Normal file
View File

@@ -0,0 +1,22 @@
import { DeepCube, MOVES } from '../src/utils/DeepCube.js';
let cube = new DeepCube();
const apply = (str) => {
str.split(' ').forEach(m => {
cube = cube.multiply(MOVES[m]);
});
};
// We want to verify `R U R'` extracts piece 4 (DFR) to U layer.
apply("R U R'");
console.log("Piece 4 is at position:", cube.cp.indexOf(4), "Orientation:", cube.co[cube.cp.indexOf(4)]);
cube = new DeepCube();
// What if piece 4 is at URF (position 0)? We want to insert it to DFR (position 4).
// If Yellow is UP, co=0.
// Let's create a state where DFR is at URF with co=0.
// We can do this by applying R U2 R' U' R U R' IN REVERSE to extract it.
// Reverse of R U2 R' U' R U R' is: R U' R' U R U2 R'
apply("R U' R' U R U2 R'");
console.log("Extraction -> Piece 4 position:", cube.cp.indexOf(4), "Orientation:", cube.co[cube.cp.indexOf(4)]);

70
test/test_new_model.js Normal file
View File

@@ -0,0 +1,70 @@
import { CubeModel, FACES, COLORS } from "../src/utils/CubeModel.js";
console.log("Running CubeModel Rotation Logic Tests...");
const cube1 = new CubeModel();
const cube2 = new CubeModel();
const compareCubes = (c1, c2, message) => {
const s1 = c1.toString();
const s2 = c2.toString();
if (s1 === s2) {
console.log(`✅ PASS: ${message}`);
return true;
} else {
console.error(`❌ FAIL: ${message}`);
console.log("Expected (Standard Move):");
console.log(s2);
console.log("Actual (Layer Rotation):");
console.log(s1);
return false;
}
};
// Test 1: Top Layer (y=1) CW vs U
cube1.reset();
cube2.reset();
console.log("Testing Top Layer CW vs U...");
cube1.rotateLayer("y", 1, 1); // Top CW
cube2.applyMove("U");
compareCubes(cube1, cube2, "Top Layer CW matches U");
// Test 2: Bottom Layer (y=-1) CW vs D
cube1.reset();
cube2.reset();
console.log("Testing Bottom Layer CW vs D...");
cube1.rotateLayer("y", -1, -1); // Bottom CW (CW around -Y is CCW around Y)
cube2.applyMove("D");
compareCubes(cube1, cube2, "Bottom Layer CW matches D");
// Test 3: Left Layer (x=-1) CW vs L
cube1.reset();
cube2.reset();
console.log("Testing Left Layer CW vs L...");
cube1.rotateLayer("x", -1, -1); // Left CW (CW around -X is CCW around X)
cube2.applyMove("L");
compareCubes(cube1, cube2, "Left Layer CW matches L");
// Test 4: Right Layer (x=1) CW vs R
cube1.reset();
cube2.reset();
console.log("Testing Right Layer CW vs R...");
cube1.rotateLayer("x", 1, 1); // Right CW
cube2.applyMove("R");
compareCubes(cube1, cube2, "Right Layer CW matches R");
// Test 5: Front Layer (z=1) CW vs F
cube1.reset();
cube2.reset();
console.log("Testing Front Layer CW vs F...");
cube1.rotateLayer("z", 1, 1); // Front CW
cube2.applyMove("F");
compareCubes(cube1, cube2, "Front Layer CW matches F");
// Test 6: Back Layer (z=-1) CW vs B
cube1.reset();
cube2.reset();
console.log("Testing Back Layer CW vs B...");
cube1.rotateLayer("z", -1, -1); // Back CW (CW around -Z is CCW around Z)
cube2.applyMove("B");
compareCubes(cube1, cube2, "Back Layer CW matches B");

70
test/tokenReducer.test.js Normal file
View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { tokenReducer, parseToken } from '../src/utils/tokenReducer.js';
describe('parseToken', () => {
it('parses simple move', () => {
expect(parseToken('D')).toEqual({ token: 'D', name: 'D', mod: '' });
});
it('parses prime move', () => {
expect(parseToken("U'")).toEqual({ token: "U'", name: 'U', mod: "'" });
});
it('parses double move', () => {
expect(parseToken('R2')).toEqual({ token: 'R2', name: 'R', mod: '2' });
});
});
describe('tokenReducer', () => {
it('user example: mixed faces', () => {
const result = tokenReducer(['D', 'U2', 'U2', 'B2', "B'", 'B2', "U'", 'U2']);
expect(result.tokens).toEqual(['D', "B'", 'U']);
});
it('cancellation: same move 4 times = identity', () => {
expect(tokenReducer(['R', 'R', 'R', 'R']).tokens).toEqual([]);
});
it('cancellation: move + inverse = identity', () => {
expect(tokenReducer(["F'", 'F']).tokens).toEqual([]);
});
it('cancellation: double move twice = identity', () => {
expect(tokenReducer(['D2', 'D2']).tokens).toEqual([]);
});
it('merge: move + move = double', () => {
expect(tokenReducer(['U', 'U']).tokens).toEqual(['U2']);
});
it('merge: double + move = prime', () => {
expect(tokenReducer(['R2', 'R']).tokens).toEqual(["R'"]);
});
it('D2 D2 D\' D cancels to empty', () => {
expect(tokenReducer(['D2', 'D2', "D'", 'D']).tokens).toEqual([]);
});
it('preserves non-adjacent different faces', () => {
expect(tokenReducer(['R', 'U', 'R']).tokens).toEqual(['R', 'U', 'R']);
});
it('reduces only consecutive same-face groups', () => {
expect(tokenReducer(['F', 'F', 'U', "U'"]).tokens).toEqual(['F2']);
});
it('handles single move unchanged', () => {
expect(tokenReducer(['B']).tokens).toEqual(['B']);
});
it('handles empty input', () => {
expect(tokenReducer([]).tokens).toEqual([]);
});
it('desc contains group info', () => {
const result = tokenReducer(['R', 'R']);
expect(result.desc).toHaveLength(1);
expect(result.desc[0].reduced).toBe('R2');
expect(result.desc[0].group).toHaveLength(2);
});
});

41
test/verify_integrity.js Normal file
View File

@@ -0,0 +1,41 @@
import { DeepCube, MOVES } from "../src/utils/DeepCube.js";
function runStressTest(iterations) {
console.log(`Starting DeepCube Stress Test (${iterations} moves)...`);
let cube = new DeepCube(); // Solved
const moveNames = Object.keys(MOVES);
const startTime = Date.now();
for (let i = 1; i <= iterations; i++) {
const randomMove = moveNames[Math.floor(Math.random() * moveNames.length)];
cube = cube.multiply(MOVES[randomMove]);
if (!cube.isValid()) {
console.error(`\n❌ INVALID STATE DETECTED AT MOVE ${i}!`);
console.error(`Move applied: ${randomMove}`);
console.error(`CP:`, cube.cp);
console.error(`CO:`, cube.co);
console.error(`EP:`, cube.ep);
console.error(`EO:`, cube.eo);
process.exit(1);
}
if (i % 100000 === 0) {
process.stdout.write(
`\r${i} moves verified (${((i / iterations) * 100).toFixed(0)}%)`,
);
}
}
const duration = Date.now() - startTime;
console.log(
`\n🎉 Success! Mathematical integrity held over ${iterations} random moves.`,
);
console.log(
`⏱️ Time taken: ${duration} ms (${(iterations / (duration / 1000)).toFixed(0)} moves/sec)`,
);
}
runStressTest(1000000);

75
test/verify_solvers.js Normal file
View File

@@ -0,0 +1,75 @@
import { DeepCube, MOVES } from "../src/utils/DeepCube.js";
import { KociembaSolver } from "../src/utils/solvers/KociembaSolver.js";
function generateScramble(length = 20) {
const moveNames = Object.keys(MOVES);
const scramble = [];
for (let i = 0; i < length; i++) {
scramble.push(moveNames[Math.floor(Math.random() * moveNames.length)]);
}
return scramble;
}
function runSolverTests(iterations) {
console.log(`Starting KociembaSolver tests (${iterations} scrambles)...`);
let successCount = 0;
let totalMoves = 0;
for (let i = 0; i < iterations; i++) {
let cube = new DeepCube();
const scramble = generateScramble(30);
scramble.forEach((m) => {
cube = cube.multiply(MOVES[m]);
});
const solver = new KociembaSolver(cube);
try {
const solution = solver.solve();
// Apply solution to verify
let testCube = cube.clone();
solution.forEach((m) => {
if (!MOVES[m]) console.error("MISSING MOVE FROM SOLVER:", m);
testCube = testCube.multiply(MOVES[m]);
});
if (testCube.isValid() && isSolvedState(testCube)) {
successCount++;
totalMoves += solution.length;
if (i % 10 === 0) process.stdout.write(`\r${i} solves complete.`);
} else {
console.error(`\n❌ Solver failed validation on scramble ${i}!`);
console.error(`Scramble: ${scramble.join(" ")}`);
console.error(`Solution: ${solution.join(" ")}`);
console.error(`CP:`, testCube.cp);
console.error(`CO:`, testCube.co);
console.error(`EP:`, testCube.ep);
console.error(`EO:`, testCube.eo);
process.exit(1);
}
} catch (e) {
console.error(`\n❌ Solver threw error on scramble ${i}!`);
console.error(`Scramble: ${scramble.join(" ")}`);
console.error(e);
process.exit(1);
}
}
console.log(
`\n🎉 Success! KociembaSolver solved ${successCount}/${iterations} cubes optimally.`,
);
console.log(
`📊 Average shortest path: ${(totalMoves / iterations).toFixed(1)} moves.`,
);
}
function 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;
}
runSolverTests(100);

21
test_beginner_solver.js Normal file
View File

@@ -0,0 +1,21 @@
import { DeepCube } from "./src/utils/DeepCube.js";
import { BeginnerSolver } from "./src/utils/solvers/BeginnerSolver.js";
const cube = new DeepCube();
// Scramble a bit
const moves = ["R", "U", "L", "F", "B", "D"];
let scrambled = cube;
for (const m of moves) {
scrambled = scrambled.multiply(import("./src/utils/DeepCube.js").then(m => m.MOVES[m]));
}
// This won't work easily with dynamic imports in a script.
// Let's just use the constructor.
console.log("Testing BeginnerSolver...");
try {
const solver = new BeginnerSolver(new DeepCube());
const sol = solver.solve();
console.log("Solution length:", sol.length);
} catch (e) {
console.error("BeginnerSolver failed:", e);
}

View File

@@ -1,9 +1,13 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import pkg from './package.json'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
define: {
'__APP_VERSION__': JSON.stringify(pkg.version),
},
server: { server: {
port: 5174, port: 5174,
hmr: { hmr: {