sanggusti commited on
Commit
f4bb1f5
·
1 Parent(s): 1369aeb

Placeholder Rubics Cube on Gradio

Browse files
.gitignore CHANGED
@@ -216,3 +216,10 @@ __marimo__/
216
 
217
  # Streamlit
218
  .streamlit/secrets.toml
 
 
 
 
 
 
 
 
216
 
217
  # Streamlit
218
  .streamlit/secrets.toml
219
+
220
+ # Node / Vite (frontend)
221
+ node_modules/
222
+ frontend/node_modules/
223
+ # We commit the built bundle so HF Spaces doesn't need Node:
224
+ !frontend/dist/
225
+ !frontend/dist/**
app.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face Space entrypoint.
2
+
3
+ Re-exports the FastAPI app (which has Gradio mounted at ``/``) from
4
+ ``frontend.main`` so HF Spaces can launch it directly.
5
+ """
6
+
7
+ from frontend.main import app
8
+
9
+ if __name__ == "__main__":
10
+ import uvicorn
11
+
12
+ uvicorn.run(app, host="0.0.0.0", port=7860)
frontend/dist/assets/index-8JIoHBJc.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/dist/assets/index-Dku5yfD6.css ADDED
@@ -0,0 +1 @@
 
 
1
+ .threejs-canvas,.pixi-canvas{position:fixed;top:0;left:0;outline:none}html,body{margin:0;padding:0;height:100%;background:#999;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#111;overflow:hidden}#app{width:100vw;height:100vh}#debugger{position:fixed;top:12px;right:12px;background:#141414c7;color:#eee;padding:12px 14px;border-radius:8px;font-size:12px;line-height:1.4;min-width:220px;max-width:280px;box-shadow:0 6px 20px #00000059;user-select:none;z-index:10}#debugger h3{margin:0 0 6px;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#9cf}#debugger .row{display:flex;justify-content:space-between;gap:8px}#debugger .solved-yes{color:#6f6}#debugger .solved-no{color:#f88}#debugger .faces{display:grid;grid-template-columns:repeat(4,auto);gap:6px;margin:8px 0}#debugger .face{display:grid;grid-template-columns:repeat(3,10px);grid-template-rows:repeat(3,10px);gap:1px;padding:2px;background:#000;border-radius:2px}#debugger .face .label{grid-column:1 / span 3;text-align:center;font-size:9px;color:#aaa;margin-bottom:1px}#debugger .sticker{width:10px;height:10px;border-radius:1px}#debugger .history{max-height:80px;overflow-y:auto;font-size:11px;background:#00000059;padding:4px 6px;border-radius:4px;white-space:normal;word-break:break-word}#help{position:fixed;bottom:12px;left:12px;background:#141414c7;color:#ddd;padding:8px 12px;border-radius:6px;font-size:11px;line-height:1.5;z-index:10}#help b{color:#9cf}
frontend/dist/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
6
+ <title>Rubik's Cube Instructor</title>
7
+ <script type="module" crossorigin src="./assets/index-8JIoHBJc.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-Dku5yfD6.css">
9
+ </head>
10
+ <body>
11
+ <div id="app"></div>
12
+ </body>
13
+ </html>
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
6
+ <title>Rubik's Cube Instructor</title>
7
+ <link rel="stylesheet" href="./src/style.css" />
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <script type="module" src="/src/main.ts"></script>
12
+ </body>
13
+ </html>
frontend/main.py CHANGED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio entry that serves the built Three.js Rubik's Cube app.
2
+
3
+ The Vite-built bundle in ``frontend/dist/`` is mounted as a FastAPI
4
+ ``StaticFiles`` route, then embedded into the Gradio UI via an iframe.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import gradio as gr
12
+ from fastapi import FastAPI
13
+ from fastapi.staticfiles import StaticFiles
14
+
15
+ FRONTEND_DIR = Path(__file__).resolve().parent
16
+ DIST_DIR = FRONTEND_DIR / "dist"
17
+
18
+ app = FastAPI()
19
+
20
+ if DIST_DIR.is_dir():
21
+ app.mount("/cube", StaticFiles(directory=str(DIST_DIR), html=True), name="cube")
22
+ IFRAME_HTML = (
23
+ '<iframe src="/cube/index.html" '
24
+ 'style="width:100%;height:88vh;border:0;border-radius:8px;background:#999" '
25
+ 'allow="fullscreen"></iframe>'
26
+ )
27
+ else:
28
+ IFRAME_HTML = (
29
+ '<div style="padding:24px;font-family:monospace;background:#fee;border:1px solid #c66;'
30
+ 'border-radius:6px">Frontend bundle not found at <code>frontend/dist/</code>. '
31
+ 'Run <code>npm install &amp;&amp; npm run build</code> in <code>frontend/</code>.</div>'
32
+ )
33
+
34
+ with gr.Blocks(title="Rubik's Cube Instructor", fill_height=True) as demo:
35
+ gr.Markdown("# Rubik's Cube Instructor")
36
+ gr.HTML(IFRAME_HTML)
37
+
38
+ app = gr.mount_gradio_app(app, demo, path="/")
39
+
40
+
41
+ if __name__ == "__main__":
42
+ import uvicorn
43
+
44
+ uvicorn.run(app, host="0.0.0.0", port=7860)
frontend/package-lock.json ADDED
@@ -0,0 +1,1068 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "hf-rubic-instructor-frontend",
3
+ "version": "0.0.1",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "hf-rubic-instructor-frontend",
9
+ "version": "0.0.1",
10
+ "dependencies": {
11
+ "@tweenjs/tween.js": "^25.0.0",
12
+ "three": "^0.166.0"
13
+ },
14
+ "devDependencies": {
15
+ "@types/three": "^0.166.0",
16
+ "typescript": "^5.4.5",
17
+ "vite": "^5.3.1"
18
+ }
19
+ },
20
+ "node_modules/@esbuild/aix-ppc64": {
21
+ "version": "0.21.5",
22
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
23
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
24
+ "cpu": [
25
+ "ppc64"
26
+ ],
27
+ "dev": true,
28
+ "license": "MIT",
29
+ "optional": true,
30
+ "os": [
31
+ "aix"
32
+ ],
33
+ "engines": {
34
+ "node": ">=12"
35
+ }
36
+ },
37
+ "node_modules/@esbuild/android-arm": {
38
+ "version": "0.21.5",
39
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
40
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
41
+ "cpu": [
42
+ "arm"
43
+ ],
44
+ "dev": true,
45
+ "license": "MIT",
46
+ "optional": true,
47
+ "os": [
48
+ "android"
49
+ ],
50
+ "engines": {
51
+ "node": ">=12"
52
+ }
53
+ },
54
+ "node_modules/@esbuild/android-arm64": {
55
+ "version": "0.21.5",
56
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
57
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
58
+ "cpu": [
59
+ "arm64"
60
+ ],
61
+ "dev": true,
62
+ "license": "MIT",
63
+ "optional": true,
64
+ "os": [
65
+ "android"
66
+ ],
67
+ "engines": {
68
+ "node": ">=12"
69
+ }
70
+ },
71
+ "node_modules/@esbuild/android-x64": {
72
+ "version": "0.21.5",
73
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
74
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
75
+ "cpu": [
76
+ "x64"
77
+ ],
78
+ "dev": true,
79
+ "license": "MIT",
80
+ "optional": true,
81
+ "os": [
82
+ "android"
83
+ ],
84
+ "engines": {
85
+ "node": ">=12"
86
+ }
87
+ },
88
+ "node_modules/@esbuild/darwin-arm64": {
89
+ "version": "0.21.5",
90
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
91
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
92
+ "cpu": [
93
+ "arm64"
94
+ ],
95
+ "dev": true,
96
+ "license": "MIT",
97
+ "optional": true,
98
+ "os": [
99
+ "darwin"
100
+ ],
101
+ "engines": {
102
+ "node": ">=12"
103
+ }
104
+ },
105
+ "node_modules/@esbuild/darwin-x64": {
106
+ "version": "0.21.5",
107
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
108
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
109
+ "cpu": [
110
+ "x64"
111
+ ],
112
+ "dev": true,
113
+ "license": "MIT",
114
+ "optional": true,
115
+ "os": [
116
+ "darwin"
117
+ ],
118
+ "engines": {
119
+ "node": ">=12"
120
+ }
121
+ },
122
+ "node_modules/@esbuild/freebsd-arm64": {
123
+ "version": "0.21.5",
124
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
125
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
126
+ "cpu": [
127
+ "arm64"
128
+ ],
129
+ "dev": true,
130
+ "license": "MIT",
131
+ "optional": true,
132
+ "os": [
133
+ "freebsd"
134
+ ],
135
+ "engines": {
136
+ "node": ">=12"
137
+ }
138
+ },
139
+ "node_modules/@esbuild/freebsd-x64": {
140
+ "version": "0.21.5",
141
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
142
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
143
+ "cpu": [
144
+ "x64"
145
+ ],
146
+ "dev": true,
147
+ "license": "MIT",
148
+ "optional": true,
149
+ "os": [
150
+ "freebsd"
151
+ ],
152
+ "engines": {
153
+ "node": ">=12"
154
+ }
155
+ },
156
+ "node_modules/@esbuild/linux-arm": {
157
+ "version": "0.21.5",
158
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
159
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
160
+ "cpu": [
161
+ "arm"
162
+ ],
163
+ "dev": true,
164
+ "license": "MIT",
165
+ "optional": true,
166
+ "os": [
167
+ "linux"
168
+ ],
169
+ "engines": {
170
+ "node": ">=12"
171
+ }
172
+ },
173
+ "node_modules/@esbuild/linux-arm64": {
174
+ "version": "0.21.5",
175
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
176
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
177
+ "cpu": [
178
+ "arm64"
179
+ ],
180
+ "dev": true,
181
+ "license": "MIT",
182
+ "optional": true,
183
+ "os": [
184
+ "linux"
185
+ ],
186
+ "engines": {
187
+ "node": ">=12"
188
+ }
189
+ },
190
+ "node_modules/@esbuild/linux-ia32": {
191
+ "version": "0.21.5",
192
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
193
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
194
+ "cpu": [
195
+ "ia32"
196
+ ],
197
+ "dev": true,
198
+ "license": "MIT",
199
+ "optional": true,
200
+ "os": [
201
+ "linux"
202
+ ],
203
+ "engines": {
204
+ "node": ">=12"
205
+ }
206
+ },
207
+ "node_modules/@esbuild/linux-loong64": {
208
+ "version": "0.21.5",
209
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
210
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
211
+ "cpu": [
212
+ "loong64"
213
+ ],
214
+ "dev": true,
215
+ "license": "MIT",
216
+ "optional": true,
217
+ "os": [
218
+ "linux"
219
+ ],
220
+ "engines": {
221
+ "node": ">=12"
222
+ }
223
+ },
224
+ "node_modules/@esbuild/linux-mips64el": {
225
+ "version": "0.21.5",
226
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
227
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
228
+ "cpu": [
229
+ "mips64el"
230
+ ],
231
+ "dev": true,
232
+ "license": "MIT",
233
+ "optional": true,
234
+ "os": [
235
+ "linux"
236
+ ],
237
+ "engines": {
238
+ "node": ">=12"
239
+ }
240
+ },
241
+ "node_modules/@esbuild/linux-ppc64": {
242
+ "version": "0.21.5",
243
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
244
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
245
+ "cpu": [
246
+ "ppc64"
247
+ ],
248
+ "dev": true,
249
+ "license": "MIT",
250
+ "optional": true,
251
+ "os": [
252
+ "linux"
253
+ ],
254
+ "engines": {
255
+ "node": ">=12"
256
+ }
257
+ },
258
+ "node_modules/@esbuild/linux-riscv64": {
259
+ "version": "0.21.5",
260
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
261
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
262
+ "cpu": [
263
+ "riscv64"
264
+ ],
265
+ "dev": true,
266
+ "license": "MIT",
267
+ "optional": true,
268
+ "os": [
269
+ "linux"
270
+ ],
271
+ "engines": {
272
+ "node": ">=12"
273
+ }
274
+ },
275
+ "node_modules/@esbuild/linux-s390x": {
276
+ "version": "0.21.5",
277
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
278
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
279
+ "cpu": [
280
+ "s390x"
281
+ ],
282
+ "dev": true,
283
+ "license": "MIT",
284
+ "optional": true,
285
+ "os": [
286
+ "linux"
287
+ ],
288
+ "engines": {
289
+ "node": ">=12"
290
+ }
291
+ },
292
+ "node_modules/@esbuild/linux-x64": {
293
+ "version": "0.21.5",
294
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
295
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
296
+ "cpu": [
297
+ "x64"
298
+ ],
299
+ "dev": true,
300
+ "license": "MIT",
301
+ "optional": true,
302
+ "os": [
303
+ "linux"
304
+ ],
305
+ "engines": {
306
+ "node": ">=12"
307
+ }
308
+ },
309
+ "node_modules/@esbuild/netbsd-x64": {
310
+ "version": "0.21.5",
311
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
312
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
313
+ "cpu": [
314
+ "x64"
315
+ ],
316
+ "dev": true,
317
+ "license": "MIT",
318
+ "optional": true,
319
+ "os": [
320
+ "netbsd"
321
+ ],
322
+ "engines": {
323
+ "node": ">=12"
324
+ }
325
+ },
326
+ "node_modules/@esbuild/openbsd-x64": {
327
+ "version": "0.21.5",
328
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
329
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
330
+ "cpu": [
331
+ "x64"
332
+ ],
333
+ "dev": true,
334
+ "license": "MIT",
335
+ "optional": true,
336
+ "os": [
337
+ "openbsd"
338
+ ],
339
+ "engines": {
340
+ "node": ">=12"
341
+ }
342
+ },
343
+ "node_modules/@esbuild/sunos-x64": {
344
+ "version": "0.21.5",
345
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
346
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
347
+ "cpu": [
348
+ "x64"
349
+ ],
350
+ "dev": true,
351
+ "license": "MIT",
352
+ "optional": true,
353
+ "os": [
354
+ "sunos"
355
+ ],
356
+ "engines": {
357
+ "node": ">=12"
358
+ }
359
+ },
360
+ "node_modules/@esbuild/win32-arm64": {
361
+ "version": "0.21.5",
362
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
363
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
364
+ "cpu": [
365
+ "arm64"
366
+ ],
367
+ "dev": true,
368
+ "license": "MIT",
369
+ "optional": true,
370
+ "os": [
371
+ "win32"
372
+ ],
373
+ "engines": {
374
+ "node": ">=12"
375
+ }
376
+ },
377
+ "node_modules/@esbuild/win32-ia32": {
378
+ "version": "0.21.5",
379
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
380
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
381
+ "cpu": [
382
+ "ia32"
383
+ ],
384
+ "dev": true,
385
+ "license": "MIT",
386
+ "optional": true,
387
+ "os": [
388
+ "win32"
389
+ ],
390
+ "engines": {
391
+ "node": ">=12"
392
+ }
393
+ },
394
+ "node_modules/@esbuild/win32-x64": {
395
+ "version": "0.21.5",
396
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
397
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
398
+ "cpu": [
399
+ "x64"
400
+ ],
401
+ "dev": true,
402
+ "license": "MIT",
403
+ "optional": true,
404
+ "os": [
405
+ "win32"
406
+ ],
407
+ "engines": {
408
+ "node": ">=12"
409
+ }
410
+ },
411
+ "node_modules/@rollup/rollup-android-arm-eabi": {
412
+ "version": "4.61.1",
413
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
414
+ "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
415
+ "cpu": [
416
+ "arm"
417
+ ],
418
+ "dev": true,
419
+ "license": "MIT",
420
+ "optional": true,
421
+ "os": [
422
+ "android"
423
+ ]
424
+ },
425
+ "node_modules/@rollup/rollup-android-arm64": {
426
+ "version": "4.61.1",
427
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
428
+ "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
429
+ "cpu": [
430
+ "arm64"
431
+ ],
432
+ "dev": true,
433
+ "license": "MIT",
434
+ "optional": true,
435
+ "os": [
436
+ "android"
437
+ ]
438
+ },
439
+ "node_modules/@rollup/rollup-darwin-arm64": {
440
+ "version": "4.61.1",
441
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
442
+ "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
443
+ "cpu": [
444
+ "arm64"
445
+ ],
446
+ "dev": true,
447
+ "license": "MIT",
448
+ "optional": true,
449
+ "os": [
450
+ "darwin"
451
+ ]
452
+ },
453
+ "node_modules/@rollup/rollup-darwin-x64": {
454
+ "version": "4.61.1",
455
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
456
+ "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
457
+ "cpu": [
458
+ "x64"
459
+ ],
460
+ "dev": true,
461
+ "license": "MIT",
462
+ "optional": true,
463
+ "os": [
464
+ "darwin"
465
+ ]
466
+ },
467
+ "node_modules/@rollup/rollup-freebsd-arm64": {
468
+ "version": "4.61.1",
469
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
470
+ "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
471
+ "cpu": [
472
+ "arm64"
473
+ ],
474
+ "dev": true,
475
+ "license": "MIT",
476
+ "optional": true,
477
+ "os": [
478
+ "freebsd"
479
+ ]
480
+ },
481
+ "node_modules/@rollup/rollup-freebsd-x64": {
482
+ "version": "4.61.1",
483
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
484
+ "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
485
+ "cpu": [
486
+ "x64"
487
+ ],
488
+ "dev": true,
489
+ "license": "MIT",
490
+ "optional": true,
491
+ "os": [
492
+ "freebsd"
493
+ ]
494
+ },
495
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
496
+ "version": "4.61.1",
497
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
498
+ "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
499
+ "cpu": [
500
+ "arm"
501
+ ],
502
+ "dev": true,
503
+ "license": "MIT",
504
+ "optional": true,
505
+ "os": [
506
+ "linux"
507
+ ]
508
+ },
509
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
510
+ "version": "4.61.1",
511
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
512
+ "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
513
+ "cpu": [
514
+ "arm"
515
+ ],
516
+ "dev": true,
517
+ "license": "MIT",
518
+ "optional": true,
519
+ "os": [
520
+ "linux"
521
+ ]
522
+ },
523
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
524
+ "version": "4.61.1",
525
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
526
+ "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
527
+ "cpu": [
528
+ "arm64"
529
+ ],
530
+ "dev": true,
531
+ "license": "MIT",
532
+ "optional": true,
533
+ "os": [
534
+ "linux"
535
+ ]
536
+ },
537
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
538
+ "version": "4.61.1",
539
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
540
+ "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
541
+ "cpu": [
542
+ "arm64"
543
+ ],
544
+ "dev": true,
545
+ "license": "MIT",
546
+ "optional": true,
547
+ "os": [
548
+ "linux"
549
+ ]
550
+ },
551
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
552
+ "version": "4.61.1",
553
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
554
+ "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
555
+ "cpu": [
556
+ "loong64"
557
+ ],
558
+ "dev": true,
559
+ "license": "MIT",
560
+ "optional": true,
561
+ "os": [
562
+ "linux"
563
+ ]
564
+ },
565
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
566
+ "version": "4.61.1",
567
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
568
+ "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
569
+ "cpu": [
570
+ "loong64"
571
+ ],
572
+ "dev": true,
573
+ "license": "MIT",
574
+ "optional": true,
575
+ "os": [
576
+ "linux"
577
+ ]
578
+ },
579
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
580
+ "version": "4.61.1",
581
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
582
+ "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
583
+ "cpu": [
584
+ "ppc64"
585
+ ],
586
+ "dev": true,
587
+ "license": "MIT",
588
+ "optional": true,
589
+ "os": [
590
+ "linux"
591
+ ]
592
+ },
593
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
594
+ "version": "4.61.1",
595
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
596
+ "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
597
+ "cpu": [
598
+ "ppc64"
599
+ ],
600
+ "dev": true,
601
+ "license": "MIT",
602
+ "optional": true,
603
+ "os": [
604
+ "linux"
605
+ ]
606
+ },
607
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
608
+ "version": "4.61.1",
609
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
610
+ "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
611
+ "cpu": [
612
+ "riscv64"
613
+ ],
614
+ "dev": true,
615
+ "license": "MIT",
616
+ "optional": true,
617
+ "os": [
618
+ "linux"
619
+ ]
620
+ },
621
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
622
+ "version": "4.61.1",
623
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
624
+ "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
625
+ "cpu": [
626
+ "riscv64"
627
+ ],
628
+ "dev": true,
629
+ "license": "MIT",
630
+ "optional": true,
631
+ "os": [
632
+ "linux"
633
+ ]
634
+ },
635
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
636
+ "version": "4.61.1",
637
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
638
+ "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
639
+ "cpu": [
640
+ "s390x"
641
+ ],
642
+ "dev": true,
643
+ "license": "MIT",
644
+ "optional": true,
645
+ "os": [
646
+ "linux"
647
+ ]
648
+ },
649
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
650
+ "version": "4.61.1",
651
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
652
+ "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
653
+ "cpu": [
654
+ "x64"
655
+ ],
656
+ "dev": true,
657
+ "license": "MIT",
658
+ "optional": true,
659
+ "os": [
660
+ "linux"
661
+ ]
662
+ },
663
+ "node_modules/@rollup/rollup-linux-x64-musl": {
664
+ "version": "4.61.1",
665
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
666
+ "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
667
+ "cpu": [
668
+ "x64"
669
+ ],
670
+ "dev": true,
671
+ "license": "MIT",
672
+ "optional": true,
673
+ "os": [
674
+ "linux"
675
+ ]
676
+ },
677
+ "node_modules/@rollup/rollup-openbsd-x64": {
678
+ "version": "4.61.1",
679
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
680
+ "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
681
+ "cpu": [
682
+ "x64"
683
+ ],
684
+ "dev": true,
685
+ "license": "MIT",
686
+ "optional": true,
687
+ "os": [
688
+ "openbsd"
689
+ ]
690
+ },
691
+ "node_modules/@rollup/rollup-openharmony-arm64": {
692
+ "version": "4.61.1",
693
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
694
+ "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
695
+ "cpu": [
696
+ "arm64"
697
+ ],
698
+ "dev": true,
699
+ "license": "MIT",
700
+ "optional": true,
701
+ "os": [
702
+ "openharmony"
703
+ ]
704
+ },
705
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
706
+ "version": "4.61.1",
707
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
708
+ "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
709
+ "cpu": [
710
+ "arm64"
711
+ ],
712
+ "dev": true,
713
+ "license": "MIT",
714
+ "optional": true,
715
+ "os": [
716
+ "win32"
717
+ ]
718
+ },
719
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
720
+ "version": "4.61.1",
721
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
722
+ "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
723
+ "cpu": [
724
+ "ia32"
725
+ ],
726
+ "dev": true,
727
+ "license": "MIT",
728
+ "optional": true,
729
+ "os": [
730
+ "win32"
731
+ ]
732
+ },
733
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
734
+ "version": "4.61.1",
735
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
736
+ "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
737
+ "cpu": [
738
+ "x64"
739
+ ],
740
+ "dev": true,
741
+ "license": "MIT",
742
+ "optional": true,
743
+ "os": [
744
+ "win32"
745
+ ]
746
+ },
747
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
748
+ "version": "4.61.1",
749
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
750
+ "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
751
+ "cpu": [
752
+ "x64"
753
+ ],
754
+ "dev": true,
755
+ "license": "MIT",
756
+ "optional": true,
757
+ "os": [
758
+ "win32"
759
+ ]
760
+ },
761
+ "node_modules/@tweenjs/tween.js": {
762
+ "version": "25.0.0",
763
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
764
+ "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
765
+ "license": "MIT"
766
+ },
767
+ "node_modules/@types/estree": {
768
+ "version": "1.0.9",
769
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
770
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
771
+ "dev": true,
772
+ "license": "MIT"
773
+ },
774
+ "node_modules/@types/stats.js": {
775
+ "version": "0.17.4",
776
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
777
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
778
+ "dev": true,
779
+ "license": "MIT"
780
+ },
781
+ "node_modules/@types/three": {
782
+ "version": "0.166.0",
783
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.166.0.tgz",
784
+ "integrity": "sha512-FHMnpcdhdbdOOIYbfkTkUVpYMW53odxbTRwd0/xJpYnTzEsjnVnondGAvHZb4z06UW0vo6WPVuvH0/9qrxKx7g==",
785
+ "dev": true,
786
+ "license": "MIT",
787
+ "dependencies": {
788
+ "@tweenjs/tween.js": "~23.1.2",
789
+ "@types/stats.js": "*",
790
+ "@types/webxr": "*",
791
+ "fflate": "~0.8.2",
792
+ "meshoptimizer": "~0.18.1"
793
+ }
794
+ },
795
+ "node_modules/@types/three/node_modules/@tweenjs/tween.js": {
796
+ "version": "23.1.3",
797
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
798
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
799
+ "dev": true,
800
+ "license": "MIT"
801
+ },
802
+ "node_modules/@types/webxr": {
803
+ "version": "0.5.24",
804
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
805
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
806
+ "dev": true,
807
+ "license": "MIT"
808
+ },
809
+ "node_modules/esbuild": {
810
+ "version": "0.21.5",
811
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
812
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
813
+ "dev": true,
814
+ "hasInstallScript": true,
815
+ "license": "MIT",
816
+ "bin": {
817
+ "esbuild": "bin/esbuild"
818
+ },
819
+ "engines": {
820
+ "node": ">=12"
821
+ },
822
+ "optionalDependencies": {
823
+ "@esbuild/aix-ppc64": "0.21.5",
824
+ "@esbuild/android-arm": "0.21.5",
825
+ "@esbuild/android-arm64": "0.21.5",
826
+ "@esbuild/android-x64": "0.21.5",
827
+ "@esbuild/darwin-arm64": "0.21.5",
828
+ "@esbuild/darwin-x64": "0.21.5",
829
+ "@esbuild/freebsd-arm64": "0.21.5",
830
+ "@esbuild/freebsd-x64": "0.21.5",
831
+ "@esbuild/linux-arm": "0.21.5",
832
+ "@esbuild/linux-arm64": "0.21.5",
833
+ "@esbuild/linux-ia32": "0.21.5",
834
+ "@esbuild/linux-loong64": "0.21.5",
835
+ "@esbuild/linux-mips64el": "0.21.5",
836
+ "@esbuild/linux-ppc64": "0.21.5",
837
+ "@esbuild/linux-riscv64": "0.21.5",
838
+ "@esbuild/linux-s390x": "0.21.5",
839
+ "@esbuild/linux-x64": "0.21.5",
840
+ "@esbuild/netbsd-x64": "0.21.5",
841
+ "@esbuild/openbsd-x64": "0.21.5",
842
+ "@esbuild/sunos-x64": "0.21.5",
843
+ "@esbuild/win32-arm64": "0.21.5",
844
+ "@esbuild/win32-ia32": "0.21.5",
845
+ "@esbuild/win32-x64": "0.21.5"
846
+ }
847
+ },
848
+ "node_modules/fflate": {
849
+ "version": "0.8.3",
850
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
851
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
852
+ "dev": true,
853
+ "license": "MIT"
854
+ },
855
+ "node_modules/fsevents": {
856
+ "version": "2.3.3",
857
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
858
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
859
+ "dev": true,
860
+ "hasInstallScript": true,
861
+ "license": "MIT",
862
+ "optional": true,
863
+ "os": [
864
+ "darwin"
865
+ ],
866
+ "engines": {
867
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
868
+ }
869
+ },
870
+ "node_modules/meshoptimizer": {
871
+ "version": "0.18.1",
872
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
873
+ "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
874
+ "dev": true,
875
+ "license": "MIT"
876
+ },
877
+ "node_modules/nanoid": {
878
+ "version": "3.3.12",
879
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
880
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
881
+ "dev": true,
882
+ "funding": [
883
+ {
884
+ "type": "github",
885
+ "url": "https://github.com/sponsors/ai"
886
+ }
887
+ ],
888
+ "license": "MIT",
889
+ "bin": {
890
+ "nanoid": "bin/nanoid.cjs"
891
+ },
892
+ "engines": {
893
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
894
+ }
895
+ },
896
+ "node_modules/picocolors": {
897
+ "version": "1.1.1",
898
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
899
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
900
+ "dev": true,
901
+ "license": "ISC"
902
+ },
903
+ "node_modules/postcss": {
904
+ "version": "8.5.15",
905
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
906
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
907
+ "dev": true,
908
+ "funding": [
909
+ {
910
+ "type": "opencollective",
911
+ "url": "https://opencollective.com/postcss/"
912
+ },
913
+ {
914
+ "type": "tidelift",
915
+ "url": "https://tidelift.com/funding/github/npm/postcss"
916
+ },
917
+ {
918
+ "type": "github",
919
+ "url": "https://github.com/sponsors/ai"
920
+ }
921
+ ],
922
+ "license": "MIT",
923
+ "dependencies": {
924
+ "nanoid": "^3.3.12",
925
+ "picocolors": "^1.1.1",
926
+ "source-map-js": "^1.2.1"
927
+ },
928
+ "engines": {
929
+ "node": "^10 || ^12 || >=14"
930
+ }
931
+ },
932
+ "node_modules/rollup": {
933
+ "version": "4.61.1",
934
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
935
+ "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
936
+ "dev": true,
937
+ "license": "MIT",
938
+ "dependencies": {
939
+ "@types/estree": "1.0.9"
940
+ },
941
+ "bin": {
942
+ "rollup": "dist/bin/rollup"
943
+ },
944
+ "engines": {
945
+ "node": ">=18.0.0",
946
+ "npm": ">=8.0.0"
947
+ },
948
+ "optionalDependencies": {
949
+ "@rollup/rollup-android-arm-eabi": "4.61.1",
950
+ "@rollup/rollup-android-arm64": "4.61.1",
951
+ "@rollup/rollup-darwin-arm64": "4.61.1",
952
+ "@rollup/rollup-darwin-x64": "4.61.1",
953
+ "@rollup/rollup-freebsd-arm64": "4.61.1",
954
+ "@rollup/rollup-freebsd-x64": "4.61.1",
955
+ "@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
956
+ "@rollup/rollup-linux-arm-musleabihf": "4.61.1",
957
+ "@rollup/rollup-linux-arm64-gnu": "4.61.1",
958
+ "@rollup/rollup-linux-arm64-musl": "4.61.1",
959
+ "@rollup/rollup-linux-loong64-gnu": "4.61.1",
960
+ "@rollup/rollup-linux-loong64-musl": "4.61.1",
961
+ "@rollup/rollup-linux-ppc64-gnu": "4.61.1",
962
+ "@rollup/rollup-linux-ppc64-musl": "4.61.1",
963
+ "@rollup/rollup-linux-riscv64-gnu": "4.61.1",
964
+ "@rollup/rollup-linux-riscv64-musl": "4.61.1",
965
+ "@rollup/rollup-linux-s390x-gnu": "4.61.1",
966
+ "@rollup/rollup-linux-x64-gnu": "4.61.1",
967
+ "@rollup/rollup-linux-x64-musl": "4.61.1",
968
+ "@rollup/rollup-openbsd-x64": "4.61.1",
969
+ "@rollup/rollup-openharmony-arm64": "4.61.1",
970
+ "@rollup/rollup-win32-arm64-msvc": "4.61.1",
971
+ "@rollup/rollup-win32-ia32-msvc": "4.61.1",
972
+ "@rollup/rollup-win32-x64-gnu": "4.61.1",
973
+ "@rollup/rollup-win32-x64-msvc": "4.61.1",
974
+ "fsevents": "~2.3.2"
975
+ }
976
+ },
977
+ "node_modules/source-map-js": {
978
+ "version": "1.2.1",
979
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
980
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
981
+ "dev": true,
982
+ "license": "BSD-3-Clause",
983
+ "engines": {
984
+ "node": ">=0.10.0"
985
+ }
986
+ },
987
+ "node_modules/three": {
988
+ "version": "0.166.1",
989
+ "resolved": "https://registry.npmjs.org/three/-/three-0.166.1.tgz",
990
+ "integrity": "sha512-LtuafkKHHzm61AQA1be2MAYIw1IjmhOUxhBa0prrLpEMWbV7ijvxCRHjSgHPGp2493wLBzwKV46tA9nivLEgKg==",
991
+ "license": "MIT"
992
+ },
993
+ "node_modules/typescript": {
994
+ "version": "5.9.3",
995
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
996
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
997
+ "dev": true,
998
+ "license": "Apache-2.0",
999
+ "bin": {
1000
+ "tsc": "bin/tsc",
1001
+ "tsserver": "bin/tsserver"
1002
+ },
1003
+ "engines": {
1004
+ "node": ">=14.17"
1005
+ }
1006
+ },
1007
+ "node_modules/vite": {
1008
+ "version": "5.4.21",
1009
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1010
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1011
+ "dev": true,
1012
+ "license": "MIT",
1013
+ "dependencies": {
1014
+ "esbuild": "^0.21.3",
1015
+ "postcss": "^8.4.43",
1016
+ "rollup": "^4.20.0"
1017
+ },
1018
+ "bin": {
1019
+ "vite": "bin/vite.js"
1020
+ },
1021
+ "engines": {
1022
+ "node": "^18.0.0 || >=20.0.0"
1023
+ },
1024
+ "funding": {
1025
+ "url": "https://github.com/vitejs/vite?sponsor=1"
1026
+ },
1027
+ "optionalDependencies": {
1028
+ "fsevents": "~2.3.3"
1029
+ },
1030
+ "peerDependencies": {
1031
+ "@types/node": "^18.0.0 || >=20.0.0",
1032
+ "less": "*",
1033
+ "lightningcss": "^1.21.0",
1034
+ "sass": "*",
1035
+ "sass-embedded": "*",
1036
+ "stylus": "*",
1037
+ "sugarss": "*",
1038
+ "terser": "^5.4.0"
1039
+ },
1040
+ "peerDependenciesMeta": {
1041
+ "@types/node": {
1042
+ "optional": true
1043
+ },
1044
+ "less": {
1045
+ "optional": true
1046
+ },
1047
+ "lightningcss": {
1048
+ "optional": true
1049
+ },
1050
+ "sass": {
1051
+ "optional": true
1052
+ },
1053
+ "sass-embedded": {
1054
+ "optional": true
1055
+ },
1056
+ "stylus": {
1057
+ "optional": true
1058
+ },
1059
+ "sugarss": {
1060
+ "optional": true
1061
+ },
1062
+ "terser": {
1063
+ "optional": true
1064
+ }
1065
+ }
1066
+ }
1067
+ }
1068
+ }
frontend/package.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "hf-rubic-instructor-frontend",
3
+ "private": true,
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc --noEmit && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "three": "^0.166.0",
13
+ "@tweenjs/tween.js": "^25.0.0"
14
+ },
15
+ "devDependencies": {
16
+ "@types/three": "^0.166.0",
17
+ "typescript": "^5.4.5",
18
+ "vite": "^5.3.1"
19
+ }
20
+ }
frontend/src/README.md ADDED
File without changes
frontend/src/configs/debug-config.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // import { GAME_TYPE } from "../../../scene/game-boy-scene/game-boy-games/data/games-config";
2
+ // import { SPACE_INVADERS_SCREEN_TYPE } from "../../../scene/game-boy-scene/game-boy-games/games/space-invaders/data/space-invaders-data";
3
+ // import { TETRIS_SCREEN_TYPE } from "../../../scene/game-boy-scene/game-boy-games/games/tetris/data/tetris-data";
4
+
5
+ const DEBUG_CONFIG = {
6
+ fpsMeter: false,
7
+ rendererStats: false,
8
+ orbitControls: false,
9
+ withoutUIMode: false,
10
+ startState: {
11
+ disableIntro: false,
12
+ zoomIn: false,
13
+ enableGameBoy: false,
14
+ loadGame: null, // GAME_TYPE.Tetris
15
+ // loadGame: GAME_TYPE.SpaceInvaders,
16
+ startScreen: null, // SPACE_INVADERS_SCREEN_TYPE.Round
17
+ // startScreen: TETRIS_SCREEN_TYPE.Gameplay,
18
+ },
19
+ };
20
+
21
+ export default DEBUG_CONFIG;
frontend/src/configs/global-light-config.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ const GLOBAL_LIGHT_CONFIG = {
2
+ ambient: {
3
+ enabled: false,
4
+ color: 0xFFEFE4,
5
+ intensity: 2,
6
+ },
7
+ }
8
+
9
+ export { GLOBAL_LIGHT_CONFIG };
frontend/src/configs/scene-config.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const SCENE_CONFIG = {
2
+ backgroundColor: 0x999999,
3
+ antialias: false,
4
+ fxaaPass: false,
5
+ maxPixelRatio: 2,
6
+ isMobile: false,
7
+ outlinePass: {
8
+ enabled: true,
9
+ color: '#ffffff',
10
+ edgeGlow: 1,
11
+ edgeStrength: 4,
12
+ edgeThickness: 1,
13
+ pulsePeriod: 4,
14
+ },
15
+ };
16
+
17
+ export default SCENE_CONFIG;
frontend/src/configs/sounds-config.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ const SOUNDS_CONFIG = {
2
+ enabled: true,
3
+ masterVolume: 0.5,
4
+ gameBoyVolume: 0.5,
5
+ }
6
+
7
+ export { SOUNDS_CONFIG };
frontend/src/core/README.md ADDED
File without changes
frontend/src/core/events.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ type Listener<T> = (payload: T) => void;
2
+
3
+ export class Emitter<T> {
4
+ private listeners = new Set<Listener<T>>();
5
+ on(fn: Listener<T>): () => void {
6
+ this.listeners.add(fn);
7
+ return () => this.listeners.delete(fn);
8
+ }
9
+ emit(payload: T): void {
10
+ for (const fn of this.listeners) fn(payload);
11
+ }
12
+ }
frontend/src/core/state.ts ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Logical Rubik's cube state, decoupled from Three.js.
2
+ // Faces: U(up), D(down), L(left), R(right), F(front), B(back).
3
+ // Each face is a flat array of 9 sticker colors (same letter as its solved face).
4
+ // Sticker indices, from the viewer's perspective looking at that face:
5
+ // 0 1 2
6
+ // 3 4 5
7
+ // 6 7 8
8
+
9
+ export type FaceKey = 'U' | 'D' | 'L' | 'R' | 'F' | 'B';
10
+ export type Color = FaceKey;
11
+
12
+ export type State = Record<FaceKey, Color[]>;
13
+
14
+ export function solvedState(): State {
15
+ const mk = (c: Color): Color[] => Array(9).fill(c);
16
+ return { U: mk('U'), D: mk('D'), L: mk('L'), R: mk('R'), F: mk('F'), B: mk('B') };
17
+ }
18
+
19
+ function rotateFaceCW(face: Color[]): Color[] {
20
+ const f = face;
21
+ return [f[6], f[3], f[0], f[7], f[4], f[1], f[8], f[5], f[2]];
22
+ }
23
+ function rotateFaceCCW(face: Color[]): Color[] {
24
+ const f = face;
25
+ return [f[2], f[5], f[8], f[1], f[4], f[7], f[0], f[3], f[6]];
26
+ }
27
+
28
+ // A "cycle" describes which (face, indices) move where for a CW quarter turn of a face layer.
29
+ // Each entry: 4 ordered groups of stickers; each turn shifts group i -> group i+1 (mod 4).
30
+ type Cycle = Array<[FaceKey, number[]]>;
31
+
32
+ const CYCLES: Record<FaceKey, Cycle> = {
33
+ U: [
34
+ ['F', [0, 1, 2]],
35
+ ['L', [0, 1, 2]],
36
+ ['B', [0, 1, 2]],
37
+ ['R', [0, 1, 2]]
38
+ ],
39
+ D: [
40
+ ['F', [6, 7, 8]],
41
+ ['R', [6, 7, 8]],
42
+ ['B', [6, 7, 8]],
43
+ ['L', [6, 7, 8]]
44
+ ],
45
+ R: [
46
+ ['U', [2, 5, 8]],
47
+ ['B', [6, 3, 0]],
48
+ ['D', [2, 5, 8]],
49
+ ['F', [2, 5, 8]]
50
+ ],
51
+ L: [
52
+ ['U', [0, 3, 6]],
53
+ ['F', [0, 3, 6]],
54
+ ['D', [0, 3, 6]],
55
+ ['B', [8, 5, 2]]
56
+ ],
57
+ F: [
58
+ ['U', [6, 7, 8]],
59
+ ['R', [0, 3, 6]],
60
+ ['D', [2, 1, 0]],
61
+ ['L', [8, 5, 2]]
62
+ ],
63
+ B: [
64
+ ['U', [2, 1, 0]],
65
+ ['L', [0, 3, 6]],
66
+ ['D', [6, 7, 8]],
67
+ ['R', [8, 5, 2]]
68
+ ]
69
+ };
70
+
71
+ function applyFaceTurn(state: State, face: FaceKey, prime: boolean): void {
72
+ state[face] = prime ? rotateFaceCCW(state[face]) : rotateFaceCW(state[face]);
73
+ const cycle = CYCLES[face];
74
+ const order = prime ? [3, 2, 1, 0] : [0, 1, 2, 3];
75
+ const groups = order.map(i => cycle[i]);
76
+ const tmp = groups[0][1].map(idx => state[groups[0][0]][idx]);
77
+ for (let i = 0; i < 3; i++) {
78
+ const [fromFace, fromIdx] = groups[i + 1];
79
+ const [toFace, toIdx] = groups[i];
80
+ for (let k = 0; k < 3; k++) state[toFace][toIdx[k]] = state[fromFace][fromIdx[k]];
81
+ }
82
+ const [lastFace, lastIdx] = groups[3];
83
+ for (let k = 0; k < 3; k++) state[lastFace][lastIdx[k]] = tmp[k];
84
+ }
85
+
86
+ // Slice and whole-cube rotations are expressed via face turns.
87
+ const COMPOUND: Record<string, string[]> = {
88
+ M: ["L", "R'", "x'"], // middle layer follows L
89
+ "M'": ["L'", "R", "x"],
90
+ E: ["D", "U'", "y'"], // equatorial follows D
91
+ "E'": ["D'", "U", "y"],
92
+ S: ["F'", "B", "z"], // standing follows F
93
+ "S'": ["F", "B'", "z'"],
94
+ // Whole-cube rotations re-color faces (leave logical state shape intact via face permutations).
95
+ // We model x/y/z as cycles of *labels* by applying face turns + relabel; here we approximate via face permutations:
96
+ };
97
+
98
+ // Whole-cube rotation: permute face arrays + rotate each face.
99
+ // x: rotate around R axis (clockwise looking from +X). F->U, U->B, B->D, D->F. L,R rotate.
100
+ function rotX(state: State, prime: boolean): void {
101
+ const s = state;
102
+ if (!prime) {
103
+ const F = s.F, U = s.U, B = s.B, D = s.D;
104
+ s.U = F;
105
+ s.B = rotateFaceCW(rotateFaceCW(U));
106
+ s.D = rotateFaceCW(rotateFaceCW(B));
107
+ s.F = D;
108
+ s.R = rotateFaceCW(s.R);
109
+ s.L = rotateFaceCCW(s.L);
110
+ } else {
111
+ const F = s.F, U = s.U, B = s.B, D = s.D;
112
+ s.F = U;
113
+ s.U = rotateFaceCW(rotateFaceCW(B));
114
+ s.B = rotateFaceCW(rotateFaceCW(D));
115
+ s.D = F;
116
+ s.R = rotateFaceCCW(s.R);
117
+ s.L = rotateFaceCW(s.L);
118
+ }
119
+ }
120
+ // y: rotate around U axis (clockwise looking from +Y). F->L, L->B, B->R, R->F.
121
+ function rotY(state: State, prime: boolean): void {
122
+ const s = state;
123
+ if (!prime) {
124
+ const F = s.F, L = s.L, B = s.B, R = s.R;
125
+ s.L = F; s.B = L; s.R = B; s.F = R;
126
+ s.U = rotateFaceCW(s.U);
127
+ s.D = rotateFaceCCW(s.D);
128
+ } else {
129
+ const F = s.F, L = s.L, B = s.B, R = s.R;
130
+ s.F = L; s.L = B; s.B = R; s.R = F;
131
+ s.U = rotateFaceCCW(s.U);
132
+ s.D = rotateFaceCW(s.D);
133
+ }
134
+ }
135
+ // z: rotate around F axis (clockwise looking from +Z). U->R, R->D, D->L, L->U.
136
+ function rotZ(state: State, prime: boolean): void {
137
+ const s = state;
138
+ if (!prime) {
139
+ const U = s.U, R = s.R, D = s.D, L = s.L;
140
+ s.R = rotateFaceCW(U);
141
+ s.D = rotateFaceCW(R);
142
+ s.L = rotateFaceCW(D);
143
+ s.U = rotateFaceCW(L);
144
+ s.F = rotateFaceCW(s.F);
145
+ s.B = rotateFaceCCW(s.B);
146
+ } else {
147
+ const U = s.U, R = s.R, D = s.D, L = s.L;
148
+ s.L = rotateFaceCCW(U);
149
+ s.U = rotateFaceCCW(R);
150
+ s.R = rotateFaceCCW(D);
151
+ s.D = rotateFaceCCW(L);
152
+ s.F = rotateFaceCCW(s.F);
153
+ s.B = rotateFaceCW(s.B);
154
+ }
155
+ }
156
+
157
+ export function applyMove(state: State, move: string): void {
158
+ if (!move) return;
159
+ if (COMPOUND[move]) {
160
+ for (const m of COMPOUND[move]) applyMove(state, m);
161
+ return;
162
+ }
163
+ const prime = move.endsWith("'");
164
+ const base = prime ? move.slice(0, -1) : move;
165
+ if (base === 'x') return rotX(state, prime);
166
+ if (base === 'y') return rotY(state, prime);
167
+ if (base === 'z') return rotZ(state, prime);
168
+ if ('UDLRFB'.includes(base)) return applyFaceTurn(state, base as FaceKey, prime);
169
+ }
170
+
171
+ export function isSolved(state: State): boolean {
172
+ return (Object.keys(state) as FaceKey[]).every(f =>
173
+ state[f].every(c => c === state[f][4])
174
+ );
175
+ }
176
+
177
+ export function cloneState(s: State): State {
178
+ return {
179
+ U: [...s.U], D: [...s.D], L: [...s.L], R: [...s.R], F: [...s.F], B: [...s.B]
180
+ };
181
+ }
frontend/src/main.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import './style.css';
2
+ import { createScene } from './scene/scene';
3
+ import { CubeMesh } from './scene/cube/cube';
4
+ import { MoveAnimator } from './scene/cube/animator';
5
+ import { attachKeyboard } from './ui/controls/keyboard';
6
+ import { attachDragControls } from './ui/controls/drag-controls';
7
+ import { DebuggerPanel } from './ui/debugger';
8
+ import { applyMove, solvedState, type State } from './core/state';
9
+ import DEBUG_CONFIG from './configs/debug-config';
10
+
11
+ const app = document.getElementById('app')!;
12
+ const ctx = createScene(app);
13
+
14
+ const cube = new CubeMesh();
15
+ ctx.scene.add(cube.root);
16
+
17
+ const animator = new MoveAnimator(cube, cube.root);
18
+
19
+ let state: State = solvedState();
20
+
21
+ const debuggerPanel = !DEBUG_CONFIG.withoutUIMode ? new DebuggerPanel(document.body) : null;
22
+ debuggerPanel?.render(state);
23
+
24
+ animator.onMoveComplete = (name) => {
25
+ applyMove(state, name);
26
+ debuggerPanel?.pushMove(name);
27
+ debuggerPanel?.render(state);
28
+ };
29
+
30
+ function resetCube(): void {
31
+ if (animator.isBusy()) return;
32
+ cube.root.clear();
33
+ cube.cubies.length = 0;
34
+ const fresh = new CubeMesh();
35
+ for (const c of fresh.cubies) {
36
+ cube.root.add(c.mesh);
37
+ cube.cubies.push(c);
38
+ }
39
+ state = solvedState();
40
+ debuggerPanel?.reset();
41
+ debuggerPanel?.render(state);
42
+ }
43
+
44
+ attachKeyboard(animator, {
45
+ onReset: resetCube
46
+ });
47
+
48
+ attachDragControls(cube, animator, ctx.camera, ctx.renderer.domElement);
49
+
50
+ renderHelp();
51
+
52
+ function tick(now: number): void {
53
+ animator.update(now);
54
+ ctx.controls?.update();
55
+ ctx.renderer.render(ctx.scene, ctx.camera);
56
+ requestAnimationFrame(tick);
57
+ }
58
+ requestAnimationFrame(tick);
59
+
60
+ function renderHelp(): void {
61
+ if (DEBUG_CONFIG.withoutUIMode) return;
62
+ const help = document.createElement('div');
63
+ help.id = 'help';
64
+ help.innerHTML = `
65
+ <div><b>Drag</b> a face to rotate that layer</div>
66
+ <div><b>U/D/L/R/F/B</b> face turns &nbsp; <b>M/E/S</b> middle slices &nbsp; <b>X/Y/Z</b> whole cube</div>
67
+ <div><b>Shift</b>+key = prime (counter-clockwise) &nbsp; <b>Space</b> = scramble &nbsp; <b>Enter</b> = reset</div>
68
+ `;
69
+ document.body.appendChild(help);
70
+ }
frontend/src/scene/README.md ADDED
File without changes
frontend/src/scene/cube/animator.ts ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { CubeMesh, type Axis, type Cubie } from './cube';
3
+
4
+ export interface MoveSpec {
5
+ axis: Axis;
6
+ // Which logical slices (-1, 0, 1) this move rotates.
7
+ slices: number[];
8
+ // Direction multiplier: +1 = clockwise looking from +axis toward origin.
9
+ // For face moves, signs are chosen so move name without prime matches standard CW-from-outside.
10
+ dir: 1 | -1;
11
+ }
12
+
13
+ // Mapping for base moves (no prime). Prime is handled by the caller (multiply dir by -1).
14
+ const BASE_MOVES: Record<string, MoveSpec> = {
15
+ // Face turns. CW looking from outside the named face.
16
+ // U: top layer, viewed from +Y => CW around +Y => dir +1.
17
+ U: { axis: 'y', slices: [1], dir: 1 },
18
+ D: { axis: 'y', slices: [-1], dir: -1 },
19
+ R: { axis: 'x', slices: [1], dir: 1 },
20
+ L: { axis: 'x', slices: [-1], dir: -1 },
21
+ F: { axis: 'z', slices: [1], dir: 1 },
22
+ B: { axis: 'z', slices: [-1], dir: -1 },
23
+ // Slice turns: middle layer. M follows L (dir matches L), E follows D, S follows F.
24
+ M: { axis: 'x', slices: [0], dir: -1 },
25
+ E: { axis: 'y', slices: [0], dir: -1 },
26
+ S: { axis: 'z', slices: [0], dir: 1 },
27
+ // Whole-cube rotations.
28
+ x: { axis: 'x', slices: [-1, 0, 1], dir: 1 },
29
+ y: { axis: 'y', slices: [-1, 0, 1], dir: 1 },
30
+ z: { axis: 'z', slices: [-1, 0, 1], dir: 1 }
31
+ };
32
+
33
+ export function parseMove(move: string): MoveSpec | null {
34
+ const prime = move.endsWith("'");
35
+ const base = prime ? move.slice(0, -1) : move;
36
+ const spec = BASE_MOVES[base];
37
+ if (!spec) return null;
38
+ return { axis: spec.axis, slices: spec.slices, dir: (prime ? -spec.dir : spec.dir) as 1 | -1 };
39
+ }
40
+
41
+ const AXIS_VEC: Record<Axis, THREE.Vector3> = {
42
+ x: new THREE.Vector3(1, 0, 0),
43
+ y: new THREE.Vector3(0, 1, 0),
44
+ z: new THREE.Vector3(0, 0, 1)
45
+ };
46
+
47
+ // Rotate logical lattice coords by a quarter turn around `axis` with `dir`.
48
+ // Assumes coords are integer (-1,0,1).
49
+ function rotateCoord(coord: THREE.Vector3, axis: Axis, dir: 1 | -1): void {
50
+ const c = coord;
51
+ if (axis === 'x') {
52
+ const y = c.y, z = c.z;
53
+ if (dir === 1) { c.y = -z; c.z = y; }
54
+ else { c.y = z; c.z = -y; }
55
+ } else if (axis === 'y') {
56
+ const x = c.x, z = c.z;
57
+ if (dir === 1) { c.x = z; c.z = -x; }
58
+ else { c.x = -z; c.z = x; }
59
+ } else {
60
+ const x = c.x, y = c.y;
61
+ if (dir === 1) { c.x = -y; c.y = x; }
62
+ else { c.x = y; c.y = -x; }
63
+ }
64
+ }
65
+
66
+ export interface MoveJob {
67
+ name: string;
68
+ spec: MoveSpec;
69
+ affected: Cubie[];
70
+ }
71
+
72
+ export function buildMoveJob(cube: CubeMesh, name: string): MoveJob | null {
73
+ const spec = parseMove(name);
74
+ if (!spec) return null;
75
+ const affected: Cubie[] = [];
76
+ for (const slice of spec.slices) affected.push(...cube.cubiesOnLayer(spec.axis, slice));
77
+ return { name, spec, affected };
78
+ }
79
+
80
+ // Animator: keeps a FIFO queue of moves. Each move:
81
+ // 1. Reparents affected cubies under a temporary pivot Group (preserving world transforms).
82
+ // 2. Tweens pivot.rotation by ±π/2 around the axis.
83
+ // 3. On complete: bakes pivot transform into each cubie, reparents back to cube.root,
84
+ // and updates each cubie's logical lattice coord.
85
+
86
+ const QUARTER = Math.PI / 2;
87
+
88
+ interface ActiveMove {
89
+ job: MoveJob;
90
+ pivot: THREE.Group;
91
+ // Cached pre-tween transforms (in cube.root frame), so we can restore exactly on finish
92
+ // and then apply the analytic quarter-turn — avoids float drift from attach()/detach().
93
+ preState: { cubie: Cubie; position: THREE.Vector3; quaternion: THREE.Quaternion }[];
94
+ start: number;
95
+ to: number;
96
+ }
97
+
98
+ export class MoveAnimator {
99
+ private queue: MoveJob[] = [];
100
+ private current: ActiveMove | null = null;
101
+ readonly durationMs: number;
102
+ onMoveStart?: (name: string) => void;
103
+ onMoveComplete?: (name: string) => void;
104
+
105
+ constructor(private cube: CubeMesh, private parent: THREE.Object3D, durationMs = 220) {
106
+ this.durationMs = durationMs;
107
+ }
108
+
109
+ enqueue(name: string): boolean {
110
+ const job = buildMoveJob(this.cube, name);
111
+ if (!job) return false;
112
+ this.queue.push(job);
113
+ return true;
114
+ }
115
+
116
+ enqueueMany(moves: string[]): void {
117
+ for (const m of moves) this.enqueue(m);
118
+ }
119
+
120
+ isBusy(): boolean { return this.current !== null || this.queue.length > 0; }
121
+
122
+ update(now: number): void {
123
+ if (!this.current && this.queue.length > 0) this.beginNext(now);
124
+ if (!this.current) return;
125
+ const c = this.current;
126
+ const t = Math.min(1, (now - c.start) / this.durationMs);
127
+ const eased = easeInOutCubic(t);
128
+ c.pivot.rotation[c.job.spec.axis] = c.to * eased;
129
+ if (t >= 1) this.finishCurrent();
130
+ }
131
+
132
+ private beginNext(now: number): void {
133
+ const job = this.queue.shift()!;
134
+ const pivot = new THREE.Group();
135
+ this.parent.add(pivot);
136
+ const preState = job.affected.map(cubie => ({
137
+ cubie,
138
+ position: cubie.mesh.position.clone(),
139
+ quaternion: cubie.mesh.quaternion.clone()
140
+ }));
141
+ for (const cubie of job.affected) pivot.attach(cubie.mesh);
142
+ this.current = { job, pivot, preState, start: now, to: job.spec.dir * QUARTER };
143
+ this.onMoveStart?.(job.name);
144
+ }
145
+
146
+ private finishCurrent(): void {
147
+ const c = this.current!;
148
+ const axis = AXIS_VEC[c.job.spec.axis];
149
+ const turn = new THREE.Quaternion().setFromAxisAngle(axis, c.to);
150
+
151
+ for (const entry of c.preState) {
152
+ // Reparent back to root and apply analytic quarter-turn to the pre-tween transform.
153
+ this.parent.add(entry.cubie.mesh);
154
+ const newPos = entry.position.clone().applyQuaternion(turn);
155
+ const newQuat = turn.clone().multiply(entry.quaternion);
156
+ entry.cubie.mesh.position.copy(newPos);
157
+ entry.cubie.mesh.quaternion.copy(newQuat);
158
+ rotateCoord(entry.cubie.coord, c.job.spec.axis, c.job.spec.dir);
159
+ }
160
+ this.parent.remove(c.pivot);
161
+ const finishedName = c.job.name;
162
+ this.current = null;
163
+ this.onMoveComplete?.(finishedName);
164
+ }
165
+ }
166
+
167
+ function easeInOutCubic(t: number): number {
168
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
169
+ }
170
+
171
+ // Utility kept for tests/debug.
172
+ export const _internal = { rotateCoord };
frontend/src/scene/cube/cube.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import type { FaceKey } from '../../core/state';
3
+
4
+ // Standard Rubik's color scheme.
5
+ export const FACE_COLORS: Record<FaceKey, number> = {
6
+ U: 0xffffff, // white
7
+ D: 0xffd500, // yellow
8
+ L: 0xff8c00, // orange
9
+ R: 0xc41e3a, // red
10
+ F: 0x009e60, // green
11
+ B: 0x0051ba // blue
12
+ };
13
+
14
+ export const CUBIE_SIZE = 1;
15
+ export const CUBIE_GAP = 0.02;
16
+
17
+ const STICKER_INSET = 0.06;
18
+ const STICKER_DEPTH = 0.51;
19
+
20
+ export type Axis = 'x' | 'y' | 'z';
21
+
22
+ export interface Cubie {
23
+ mesh: THREE.Object3D;
24
+ // Logical lattice coordinates in {-1,0,1}^3 (updated after each move).
25
+ coord: THREE.Vector3;
26
+ }
27
+
28
+ export class CubeMesh {
29
+ readonly root: THREE.Group;
30
+ readonly cubies: Cubie[] = [];
31
+
32
+ constructor() {
33
+ this.root = new THREE.Group();
34
+ const step = CUBIE_SIZE + CUBIE_GAP;
35
+
36
+ const blackMat = new THREE.MeshBasicMaterial({ color: 0x111111 });
37
+ const geom = new THREE.BoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE);
38
+
39
+ for (let x = -1; x <= 1; x++) {
40
+ for (let y = -1; y <= 1; y++) {
41
+ for (let z = -1; z <= 1; z++) {
42
+ const cubie = new THREE.Group();
43
+ const body = new THREE.Mesh(geom, blackMat);
44
+ cubie.add(body);
45
+
46
+ if (x === 1) addSticker(cubie, 'R', 'x', 1);
47
+ if (x === -1) addSticker(cubie, 'L', 'x', -1);
48
+ if (y === 1) addSticker(cubie, 'U', 'y', 1);
49
+ if (y === -1) addSticker(cubie, 'D', 'y', -1);
50
+ if (z === 1) addSticker(cubie, 'F', 'z', 1);
51
+ if (z === -1) addSticker(cubie, 'B', 'z', -1);
52
+
53
+ cubie.position.set(x * step, y * step, z * step);
54
+ this.root.add(cubie);
55
+ this.cubies.push({ mesh: cubie, coord: new THREE.Vector3(x, y, z) });
56
+ }
57
+ }
58
+ }
59
+ }
60
+
61
+ cubiesOnLayer(axis: Axis, slice: number): Cubie[] {
62
+ return this.cubies.filter(c => Math.round(c.coord[axis]) === slice);
63
+ }
64
+ }
65
+
66
+ function addSticker(parent: THREE.Object3D, face: FaceKey, axis: Axis, sign: 1 | -1) {
67
+ const size = CUBIE_SIZE - STICKER_INSET * 2;
68
+ const plane = new THREE.PlaneGeometry(size, size);
69
+ const mat = new THREE.MeshBasicMaterial({ color: FACE_COLORS[face], side: THREE.DoubleSide });
70
+ const mesh = new THREE.Mesh(plane, mat);
71
+ mesh.userData.face = face;
72
+ mesh.userData.isSticker = true;
73
+ if (axis === 'x') {
74
+ mesh.rotation.y = sign === 1 ? Math.PI / 2 : -Math.PI / 2;
75
+ mesh.position.x = sign * STICKER_DEPTH;
76
+ } else if (axis === 'y') {
77
+ mesh.rotation.x = sign === 1 ? -Math.PI / 2 : Math.PI / 2;
78
+ mesh.position.y = sign * STICKER_DEPTH;
79
+ } else {
80
+ if (sign === -1) mesh.rotation.y = Math.PI;
81
+ mesh.position.z = sign * STICKER_DEPTH;
82
+ }
83
+ parent.add(mesh);
84
+ }
frontend/src/scene/cube/scramble.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const FACES = ['U', 'D', 'L', 'R', 'F', 'B'];
2
+ const SUFFIXES = ['', "'", '2'];
3
+
4
+ // 2 means double turn (we expand to two quarter turns since the animator handles only quarters).
5
+ function expand(move: string): string[] {
6
+ if (move.endsWith('2')) {
7
+ const base = move.slice(0, -1);
8
+ return [base, base];
9
+ }
10
+ return [move];
11
+ }
12
+
13
+ export function generateScramble(length = 20): string[] {
14
+ const moves: string[] = [];
15
+ let lastFace = '';
16
+ let beforeLastFace = '';
17
+ while (moves.length < length) {
18
+ const face = FACES[Math.floor(Math.random() * FACES.length)];
19
+ if (face === lastFace) continue;
20
+ if (face === beforeLastFace && areOpposite(face, lastFace)) continue;
21
+ const suf = SUFFIXES[Math.floor(Math.random() * SUFFIXES.length)];
22
+ moves.push(face + suf);
23
+ beforeLastFace = lastFace;
24
+ lastFace = face;
25
+ }
26
+ return moves.flatMap(expand);
27
+ }
28
+
29
+ function areOpposite(a: string, b: string): boolean {
30
+ const pairs: Record<string, string> = { U: 'D', D: 'U', L: 'R', R: 'L', F: 'B', B: 'F' };
31
+ return pairs[a] === b;
32
+ }
frontend/src/scene/scene.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
3
+ import SCENE_CONFIG from '../configs/scene-config';
4
+ import DEBUG_CONFIG from '../configs/debug-config';
5
+
6
+ export interface SceneCtx {
7
+ scene: THREE.Scene;
8
+ camera: THREE.PerspectiveCamera;
9
+ renderer: THREE.WebGLRenderer;
10
+ controls: OrbitControls | null;
11
+ }
12
+
13
+ export function createScene(container: HTMLElement): SceneCtx {
14
+ const scene = new THREE.Scene();
15
+ scene.background = new THREE.Color(SCENE_CONFIG.backgroundColor);
16
+
17
+ const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
18
+ camera.position.set(5, 5, 7);
19
+ camera.lookAt(0, 0, 0);
20
+
21
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
22
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, SCENE_CONFIG.maxPixelRatio));
23
+ renderer.setSize(window.innerWidth, window.innerHeight);
24
+ renderer.domElement.classList.add('threejs-canvas');
25
+ container.appendChild(renderer.domElement);
26
+
27
+ let controls: OrbitControls | null = null;
28
+ if (DEBUG_CONFIG.orbitControls) {
29
+ controls = new OrbitControls(camera, renderer.domElement);
30
+ controls.enableDamping = true;
31
+ controls.enablePan = false;
32
+ }
33
+
34
+ window.addEventListener('resize', () => {
35
+ camera.aspect = window.innerWidth / window.innerHeight;
36
+ camera.updateProjectionMatrix();
37
+ renderer.setSize(window.innerWidth, window.innerHeight);
38
+ });
39
+
40
+ return { scene, camera, renderer, controls };
41
+ }
frontend/src/style.css ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .threejs-canvas
2
+ {
3
+ position: fixed;
4
+ top: 0;
5
+ left: 0;
6
+ outline: none;
7
+ }
8
+
9
+ .pixi-canvas
10
+ {
11
+ position: fixed;
12
+ top: 0;
13
+ left: 0;
14
+ outline: none;
15
+ }
16
+
17
+ html, body {
18
+ margin: 0;
19
+ padding: 0;
20
+ height: 100%;
21
+ background: #999;
22
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
23
+ color: #111;
24
+ overflow: hidden;
25
+ }
26
+
27
+ #app { width: 100vw; height: 100vh; }
28
+
29
+ #debugger {
30
+ position: fixed;
31
+ top: 12px;
32
+ right: 12px;
33
+ background: rgba(20, 20, 20, 0.78);
34
+ color: #eee;
35
+ padding: 12px 14px;
36
+ border-radius: 8px;
37
+ font-size: 12px;
38
+ line-height: 1.4;
39
+ min-width: 220px;
40
+ max-width: 280px;
41
+ box-shadow: 0 6px 20px rgba(0,0,0,.35);
42
+ user-select: none;
43
+ z-index: 10;
44
+ }
45
+
46
+ #debugger h3 {
47
+ margin: 0 0 6px 0;
48
+ font-size: 12px;
49
+ letter-spacing: .08em;
50
+ text-transform: uppercase;
51
+ color: #9cf;
52
+ }
53
+
54
+ #debugger .row { display: flex; justify-content: space-between; gap: 8px; }
55
+ #debugger .solved-yes { color: #6f6; }
56
+ #debugger .solved-no { color: #f88; }
57
+
58
+ #debugger .faces {
59
+ display: grid;
60
+ grid-template-columns: repeat(4, auto);
61
+ gap: 6px;
62
+ margin: 8px 0;
63
+ }
64
+ #debugger .face {
65
+ display: grid;
66
+ grid-template-columns: repeat(3, 10px);
67
+ grid-template-rows: repeat(3, 10px);
68
+ gap: 1px;
69
+ padding: 2px;
70
+ background: #000;
71
+ border-radius: 2px;
72
+ }
73
+ #debugger .face .label {
74
+ grid-column: 1 / span 3;
75
+ text-align: center;
76
+ font-size: 9px;
77
+ color: #aaa;
78
+ margin-bottom: 1px;
79
+ }
80
+ #debugger .sticker { width: 10px; height: 10px; border-radius: 1px; }
81
+
82
+ #debugger .history {
83
+ max-height: 80px;
84
+ overflow-y: auto;
85
+ font-size: 11px;
86
+ background: rgba(0,0,0,0.35);
87
+ padding: 4px 6px;
88
+ border-radius: 4px;
89
+ white-space: normal;
90
+ word-break: break-word;
91
+ }
92
+
93
+ #help {
94
+ position: fixed;
95
+ bottom: 12px;
96
+ left: 12px;
97
+ background: rgba(20,20,20,0.78);
98
+ color: #ddd;
99
+ padding: 8px 12px;
100
+ border-radius: 6px;
101
+ font-size: 11px;
102
+ line-height: 1.5;
103
+ z-index: 10;
104
+ }
105
+ #help b { color: #9cf; }
frontend/src/ui/README.md ADDED
File without changes
frontend/src/ui/controls/drag-controls.ts ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from 'three';
2
+ import type { CubeMesh, Cubie, Axis } from '../../scene/cube/cube';
3
+ import type { MoveAnimator } from '../../scene/cube/animator';
4
+ import type { FaceKey } from '../../core/state';
5
+
6
+ interface PendingDrag {
7
+ startScreen: THREE.Vector2;
8
+ hitFace: FaceKey;
9
+ hitCubie: Cubie;
10
+ hitWorldPoint: THREE.Vector3;
11
+ }
12
+
13
+ export interface DragOptions {
14
+ onMove?: (move: string) => void;
15
+ // Pixels of drag below which we ignore the gesture.
16
+ threshold?: number;
17
+ }
18
+
19
+ export function attachDragControls(
20
+ cube: CubeMesh,
21
+ animator: MoveAnimator,
22
+ camera: THREE.Camera,
23
+ domEl: HTMLElement,
24
+ opts: DragOptions = {}
25
+ ): () => void {
26
+ const threshold = opts.threshold ?? 14;
27
+ const raycaster = new THREE.Raycaster();
28
+ const ndc = new THREE.Vector2();
29
+ let pending: PendingDrag | null = null;
30
+
31
+ function ndcFromEvent(ev: PointerEvent): void {
32
+ const rect = domEl.getBoundingClientRect();
33
+ ndc.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
34
+ ndc.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
35
+ }
36
+
37
+ function findStickerHit(): { face: FaceKey; point: THREE.Vector3; cubie: Cubie } | null {
38
+ raycaster.setFromCamera(ndc, camera);
39
+ // Collect sticker meshes for raycasting.
40
+ const stickers: THREE.Object3D[] = [];
41
+ for (const c of cube.cubies) {
42
+ c.mesh.traverse(child => {
43
+ if ((child as any).userData?.isSticker) stickers.push(child);
44
+ });
45
+ }
46
+ const hits = raycaster.intersectObjects(stickers, false);
47
+ if (!hits.length) return null;
48
+ const hit = hits[0];
49
+ const face = hit.object.userData.face as FaceKey;
50
+ const cubie = findOwningCubie(cube, hit.object);
51
+ if (!cubie) return null;
52
+ return { face, point: hit.point.clone(), cubie };
53
+ }
54
+
55
+ function onPointerDown(ev: PointerEvent): void {
56
+ if (animator.isBusy()) return;
57
+ ndcFromEvent(ev);
58
+ const hit = findStickerHit();
59
+ if (!hit) return;
60
+ pending = {
61
+ startScreen: new THREE.Vector2(ev.clientX, ev.clientY),
62
+ hitFace: hit.face,
63
+ hitCubie: hit.cubie,
64
+ hitWorldPoint: hit.point
65
+ };
66
+ (ev.target as Element).setPointerCapture?.(ev.pointerId);
67
+ }
68
+
69
+ function onPointerUp(ev: PointerEvent): void {
70
+ if (!pending) return;
71
+ const dx = ev.clientX - pending.startScreen.x;
72
+ const dy = ev.clientY - pending.startScreen.y;
73
+ if (Math.hypot(dx, dy) < threshold) { pending = null; return; }
74
+
75
+ const move = resolveDragToMove(camera, pending, dx, dy);
76
+ pending = null;
77
+ if (move && animator.enqueue(move)) opts.onMove?.(move);
78
+ }
79
+
80
+ function onPointerCancel(): void { pending = null; }
81
+
82
+ domEl.addEventListener('pointerdown', onPointerDown);
83
+ window.addEventListener('pointerup', onPointerUp);
84
+ window.addEventListener('pointercancel', onPointerCancel);
85
+ return () => {
86
+ domEl.removeEventListener('pointerdown', onPointerDown);
87
+ window.removeEventListener('pointerup', onPointerUp);
88
+ window.removeEventListener('pointercancel', onPointerCancel);
89
+ };
90
+ }
91
+
92
+ function findOwningCubie(cube: CubeMesh, sticker: THREE.Object3D): Cubie | null {
93
+ let n: THREE.Object3D | null = sticker;
94
+ while (n) {
95
+ const found = cube.cubies.find(c => c.mesh === n);
96
+ if (found) return found;
97
+ n = n.parent;
98
+ }
99
+ return null;
100
+ }
101
+
102
+ // Standard normal vectors for each face in cube-local (root) coords.
103
+ const FACE_NORMAL: Record<FaceKey, THREE.Vector3> = {
104
+ R: new THREE.Vector3(1, 0, 0),
105
+ L: new THREE.Vector3(-1, 0, 0),
106
+ U: new THREE.Vector3(0, 1, 0),
107
+ D: new THREE.Vector3(0, -1, 0),
108
+ F: new THREE.Vector3(0, 0, 1),
109
+ B: new THREE.Vector3(0, 0, -1)
110
+ };
111
+
112
+ // In-plane axes for each face: two perpendicular unit vectors lying in the face plane.
113
+ // Each entry maps a face to two rotation axes that quarter-turns of a layer along this face can pivot around.
114
+ const FACE_PLANE_AXES: Record<FaceKey, [THREE.Vector3, THREE.Vector3]> = {
115
+ U: [new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 1)],
116
+ D: [new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 1)],
117
+ R: [new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 1)],
118
+ L: [new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 1)],
119
+ F: [new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 1, 0)],
120
+ B: [new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 1, 0)]
121
+ };
122
+
123
+ function resolveDragToMove(
124
+ camera: THREE.Camera,
125
+ pending: PendingDrag,
126
+ dx: number,
127
+ dy: number
128
+ ): string | null {
129
+ // Project the two in-plane axes from the hit point into screen space; pick the one whose
130
+ // screen direction better aligns with the drag vector. The cross product of that axis with
131
+ // the face normal selects the rotation axis of the layer being swiped, and the sign tells us
132
+ // which direction.
133
+ const screenDrag = new THREE.Vector2(dx, -dy); // y inverted for screen space
134
+ const [a1, a2] = FACE_PLANE_AXES[pending.hitFace];
135
+ const s1 = projectDirToScreen(camera, pending.hitWorldPoint, a1);
136
+ const s2 = projectDirToScreen(camera, pending.hitWorldPoint, a2);
137
+ const dot1 = Math.abs(s1.dot(screenDrag));
138
+ const dot2 = Math.abs(s2.dot(screenDrag));
139
+ const dragAxis = dot1 >= dot2 ? a1 : a2;
140
+ const dragScreen = dot1 >= dot2 ? s1 : s2;
141
+ const sign = Math.sign(dragScreen.dot(screenDrag)) as 1 | -1 | 0;
142
+ if (sign === 0) return null;
143
+
144
+ // The swiped layer rotates around the axis perpendicular to (face normal, dragAxis):
145
+ // rotAxis = faceNormal × dragAxis.
146
+ const faceNormal = FACE_NORMAL[pending.hitFace];
147
+ const rotAxisVec = new THREE.Vector3().crossVectors(faceNormal, dragAxis).normalize();
148
+ const rotAxis = dominantAxis(rotAxisVec);
149
+ if (!rotAxis) return null;
150
+ const rotAxisSign = (rotAxisVec[rotAxis] >= 0 ? 1 : -1) as 1 | -1;
151
+
152
+ // The slice is the cubie's coord on this rotation axis.
153
+ const slice = Math.round(pending.hitCubie.coord[rotAxis]);
154
+
155
+ // Build the move name from (rotAxis, slice). Direction = positive screen drag along dragAxis
156
+ // means rotation by +sign * rotAxisSign around rotAxisVec, i.e., dir = +sign * rotAxisSign.
157
+ const dir = (sign * rotAxisSign) as 1 | -1;
158
+ return moveFromAxisSlice(rotAxis, slice, dir);
159
+ }
160
+
161
+ function projectDirToScreen(
162
+ camera: THREE.Camera,
163
+ worldPoint: THREE.Vector3,
164
+ dir: THREE.Vector3
165
+ ): THREE.Vector2 {
166
+ const a = worldPoint.clone().project(camera);
167
+ const b = worldPoint.clone().add(dir).project(camera);
168
+ return new THREE.Vector2(b.x - a.x, b.y - a.y);
169
+ }
170
+
171
+ function dominantAxis(v: THREE.Vector3): Axis | null {
172
+ const ax = Math.abs(v.x), ay = Math.abs(v.y), az = Math.abs(v.z);
173
+ if (ax > ay && ax > az) return 'x';
174
+ if (ay > ax && ay > az) return 'y';
175
+ if (az > ax && az > ay) return 'z';
176
+ return null;
177
+ }
178
+
179
+ // Map (axis, slice, dir) → move name. dir convention matches MoveAnimator (CW from +axis).
180
+ function moveFromAxisSlice(axis: Axis, slice: number, dir: 1 | -1): string | null {
181
+ // Lookup: for each (axis, slice) what is the canonical move and which dir is its base?
182
+ // Use BASE_MOVES table from animator.ts — replicated here to keep this module decoupled.
183
+ // axis y, slice +1 → U (base dir +1); slice -1 → D (base dir -1); slice 0 → E (base dir -1).
184
+ // axis x, slice +1 → R (+1); -1 → L (-1); 0 → M (-1).
185
+ // axis z, slice +1 → F (+1); -1 → B (-1); 0 → S (+1).
186
+ const TABLE: Record<Axis, Record<number, { name: string; baseDir: 1 | -1 }>> = {
187
+ y: { 1: { name: 'U', baseDir: 1 }, [-1]: { name: 'D', baseDir: -1 }, 0: { name: 'E', baseDir: -1 } },
188
+ x: { 1: { name: 'R', baseDir: 1 }, [-1]: { name: 'L', baseDir: -1 }, 0: { name: 'M', baseDir: -1 } },
189
+ z: { 1: { name: 'F', baseDir: 1 }, [-1]: { name: 'B', baseDir: -1 }, 0: { name: 'S', baseDir: 1 } }
190
+ };
191
+ const entry = TABLE[axis][slice];
192
+ if (!entry) return null;
193
+ // If dir matches baseDir, plain move; otherwise prime.
194
+ return dir === entry.baseDir ? entry.name : entry.name + "'";
195
+ }
frontend/src/ui/controls/keyboard.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MoveAnimator } from '../../scene/cube/animator';
2
+ import { generateScramble } from '../../scene/cube/scramble';
3
+
4
+ export interface KeyboardOptions {
5
+ onScramble?: () => void;
6
+ onReset?: () => void;
7
+ // Triggered for any move enqueued through the keyboard.
8
+ onMove?: (move: string) => void;
9
+ }
10
+
11
+ const NOTATION_KEYS = new Set([
12
+ 'U', 'D', 'L', 'R', 'F', 'B', 'M', 'E', 'S', 'X', 'Y', 'Z'
13
+ ]);
14
+
15
+ export function attachKeyboard(animator: MoveAnimator, opts: KeyboardOptions = {}): () => void {
16
+ function onKey(ev: KeyboardEvent): void {
17
+ if (ev.repeat) return;
18
+ if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
19
+ const key = ev.key;
20
+ if (key === ' ') {
21
+ ev.preventDefault();
22
+ const seq = generateScramble(20);
23
+ for (const m of seq) animator.enqueue(m);
24
+ opts.onScramble?.();
25
+ return;
26
+ }
27
+ if (key === 'Enter') {
28
+ opts.onReset?.();
29
+ return;
30
+ }
31
+ const upper = key.toUpperCase();
32
+ if (!NOTATION_KEYS.has(upper)) return;
33
+ // X/Y/Z are whole-cube rotations expressed lowercase in our move grammar.
34
+ const base = (upper === 'X' || upper === 'Y' || upper === 'Z') ? upper.toLowerCase() : upper;
35
+ const move = ev.shiftKey ? base + "'" : base;
36
+ if (animator.enqueue(move)) opts.onMove?.(move);
37
+ }
38
+ window.addEventListener('keydown', onKey);
39
+ return () => window.removeEventListener('keydown', onKey);
40
+ }
frontend/src/ui/debugger.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { State, FaceKey } from '../core/state';
2
+ import { isSolved } from '../core/state';
3
+ import { FACE_COLORS } from '../scene/cube/cube';
4
+
5
+ const FACE_ORDER: FaceKey[] = ['U', 'L', 'F', 'R', 'B', 'D'];
6
+ const HISTORY_LIMIT = 20;
7
+
8
+ export class DebuggerPanel {
9
+ private el: HTMLDivElement;
10
+ private historyEl!: HTMLDivElement;
11
+ private statusEl!: HTMLSpanElement;
12
+ private faceEls = new Map<FaceKey, HTMLDivElement[]>(); // 9 stickers per face
13
+ private history: string[] = [];
14
+
15
+ constructor(parent: HTMLElement) {
16
+ this.el = document.createElement('div');
17
+ this.el.id = 'debugger';
18
+ parent.appendChild(this.el);
19
+
20
+ const title = document.createElement('h3');
21
+ title.textContent = "Rubik's Debugger";
22
+ this.el.appendChild(title);
23
+
24
+ const status = document.createElement('div');
25
+ status.className = 'row';
26
+ status.innerHTML = `<span>Solved:</span><span id="dbg-solved" class="solved-no">no</span>`;
27
+ this.el.appendChild(status);
28
+ this.statusEl = status.querySelector('#dbg-solved') as HTMLSpanElement;
29
+
30
+ const facesWrap = document.createElement('div');
31
+ facesWrap.className = 'faces';
32
+ for (const face of FACE_ORDER) {
33
+ const fEl = document.createElement('div');
34
+ fEl.className = 'face';
35
+ const label = document.createElement('div');
36
+ label.className = 'label';
37
+ label.textContent = face;
38
+ fEl.appendChild(label);
39
+ const stickers: HTMLDivElement[] = [];
40
+ for (let i = 0; i < 9; i++) {
41
+ const s = document.createElement('div');
42
+ s.className = 'sticker';
43
+ fEl.appendChild(s);
44
+ stickers.push(s);
45
+ }
46
+ this.faceEls.set(face, stickers);
47
+ facesWrap.appendChild(fEl);
48
+ }
49
+ this.el.appendChild(facesWrap);
50
+
51
+ const histTitle = document.createElement('div');
52
+ histTitle.style.marginTop = '6px';
53
+ histTitle.textContent = 'History:';
54
+ this.el.appendChild(histTitle);
55
+
56
+ this.historyEl = document.createElement('div');
57
+ this.historyEl.className = 'history';
58
+ this.el.appendChild(this.historyEl);
59
+ }
60
+
61
+ pushMove(move: string): void {
62
+ this.history.push(move);
63
+ if (this.history.length > HISTORY_LIMIT) this.history.splice(0, this.history.length - HISTORY_LIMIT);
64
+ this.historyEl.textContent = this.history.join(' ');
65
+ }
66
+
67
+ reset(): void {
68
+ this.history = [];
69
+ this.historyEl.textContent = '';
70
+ }
71
+
72
+ render(state: State): void {
73
+ for (const face of FACE_ORDER) {
74
+ const stickers = this.faceEls.get(face)!;
75
+ for (let i = 0; i < 9; i++) {
76
+ const color = FACE_COLORS[state[face][i]];
77
+ stickers[i].style.background = '#' + color.toString(16).padStart(6, '0');
78
+ }
79
+ }
80
+ const solved = isSolved(state);
81
+ this.statusEl.textContent = solved ? 'yes' : 'no';
82
+ this.statusEl.className = solved ? 'solved-yes' : 'solved-no';
83
+ }
84
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "noUnusedLocals": true,
8
+ "noUnusedParameters": true,
9
+ "noImplicitReturns": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "isolatedModules": true,
15
+ "lib": ["ES2020", "DOM", "DOM.Iterable"]
16
+ },
17
+ "include": ["src"]
18
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite';
2
+
3
+ export default defineConfig({
4
+ base: './',
5
+ build: {
6
+ outDir: 'dist',
7
+ emptyOutDir: true,
8
+ target: 'es2020'
9
+ }
10
+ });
requirements.txt CHANGED
@@ -1 +1,3 @@
1
- gradio
 
 
 
1
+ gradio
2
+ fastapi
3
+ uvicorn