react-rubiks-cube 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "react-rubiks-cube",
3
+ "version": "0.1.0",
4
+ "description": "A beautiful, self-solving 3D Rubik's Cube React component — auto scramble/solve loop, drag-to-turn interaction, and a fully themeable palette. Built on react-three-fiber.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "chahe-dridi",
8
+ "homepage": "https://github.com/chahe-dridi/cubex#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/chahe-dridi/cubex.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/chahe-dridi/cubex/issues"
15
+ },
16
+ "keywords": [
17
+ "react",
18
+ "rubiks-cube",
19
+ "rubik",
20
+ "cube",
21
+ "three",
22
+ "three-js",
23
+ "react-three-fiber",
24
+ "3d",
25
+ "animation",
26
+ "hero",
27
+ "component"
28
+ ],
29
+ "main": "./dist/cubex.umd.cjs",
30
+ "module": "./dist/cubex.js",
31
+ "types": "./src/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./src/index.d.ts",
35
+ "import": "./dist/cubex.js",
36
+ "require": "./dist/cubex.umd.cjs"
37
+ }
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "src",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "sideEffects": false,
46
+ "scripts": {
47
+ "dev": "vite",
48
+ "build": "vite build",
49
+ "preview": "vite preview --outDir example-dist",
50
+ "prepare": "vite build"
51
+ },
52
+ "peerDependencies": {
53
+ "@react-three/drei": ">=9.0.0",
54
+ "@react-three/fiber": ">=8.0.0",
55
+ "framer-motion": ">=10.0.0",
56
+ "react": ">=18.0.0",
57
+ "react-dom": ">=18.0.0",
58
+ "three": ">=0.150.0"
59
+ },
60
+ "devDependencies": {
61
+ "@react-three/drei": "^9.122.0",
62
+ "@react-three/fiber": "^8.18.0",
63
+ "@vitejs/plugin-react": "^4.3.1",
64
+ "framer-motion": "^11.3.0",
65
+ "react": "^18.3.1",
66
+ "react-dom": "^18.3.1",
67
+ "three": "^0.186.1",
68
+ "vite": "^5.3.4"
69
+ }
70
+ }
@@ -0,0 +1,190 @@
1
+ import { useRef } from 'react'
2
+ import { useFrame } from '@react-three/fiber'
3
+ import * as THREE from 'three'
4
+ import { Cubelet, smoother, useCubePositions } from './primitives.jsx'
5
+
6
+ // ── Move helpers ──────────────────────────────────────────
7
+ const AXES = ['x', 'y', 'z']
8
+ const randDir = () => (Math.random() < 0.5 ? 1 : -1)
9
+
10
+ // One random legal move, avoiding an immediate inverse of the last move (which
11
+ // would read like an undo). Mostly quarter turns, occasionally a half turn.
12
+ function randMove(last) {
13
+ for (let i = 0; i < 8; i++) {
14
+ const axis = AXES[(Math.random() * 3) | 0]
15
+ const layer = [-1, 0, 1][(Math.random() * 3) | 0]
16
+ const dir = randDir()
17
+ const quarter = Math.random() < 0.82 ? 1 : 2
18
+ if (last && last.axis === axis && last.layer === layer && last.dir === -dir && last.quarter === quarter) continue
19
+ return { axis, layer, dir, quarter }
20
+ }
21
+ return { axis: 'y', layer: 1, dir: 1, quarter: 1 }
22
+ }
23
+
24
+ // Cubelets in the given layer of the given axis (null layer = all cubelets).
25
+ function layerChildren(children, axis, layer) {
26
+ if (layer === null) return children
27
+ return children.filter(c => {
28
+ const v = c.position[axis]
29
+ return layer === 0 ? Math.abs(v) < 0.5 : layer > 0 ? v > 0.5 : v < -0.5
30
+ })
31
+ }
32
+
33
+ // Reusable quaternions — never re-allocated per frame.
34
+ const _dq = new THREE.Quaternion()
35
+ const _pq1 = new THREE.Quaternion()
36
+ const _pq2 = new THREE.Quaternion()
37
+ const _pq3 = new THREE.Quaternion()
38
+
39
+ // Three fixed precession axes, chosen incommensurate so the whole-cube tumble
40
+ // never visibly repeats within a practical viewing window.
41
+ const PREC1 = new THREE.Vector3(0.58, 0.77, -0.27).normalize()
42
+ const PREC2 = new THREE.Vector3(-0.41, 0.35, 0.84).normalize()
43
+ const PREC3 = new THREE.Vector3(0.22, -0.62, 0.75).normalize()
44
+
45
+ // Build one scramble "step":
46
+ // • ~16% a 360° double-spin flourish (identity → not recorded)
47
+ // • ~38% two parallel layers of the SAME axis turning together
48
+ // • else a single quarter/half turn
49
+ // `record` is what must be reversed later to solve (null for the flourish).
50
+ function buildScrambleStep(prev) {
51
+ const roll = Math.random()
52
+ if (roll < 0.16) {
53
+ const move = { axis: AXES[(Math.random() * 3) | 0], layer: [-1, 0, 1][(Math.random() * 3) | 0], dir: randDir(), quarter: 4 }
54
+ return { moves: [move], record: null }
55
+ }
56
+ if (roll < 0.54) {
57
+ const axis = AXES[(Math.random() * 3) | 0]
58
+ const ls = [-1, 0, 1]
59
+ const i1 = (Math.random() * 3) | 0
60
+ let i2 = (Math.random() * 3) | 0
61
+ while (i2 === i1) i2 = (Math.random() * 3) | 0
62
+ const moves = [i1, i2].map(i => ({ axis, layer: ls[i], dir: randDir(), quarter: Math.random() < 0.25 ? 2 : 1 }))
63
+ return { moves, record: moves }
64
+ }
65
+ const move = randMove(prev)
66
+ return { moves: [move], record: [move] }
67
+ }
68
+
69
+ // Re-parent a layer's cubelets onto a rotation pivot and arm its tween. Each
70
+ // slice gets its own duration so two concurrent turns finish at different times.
71
+ function startSlot(slot, rotEl, cube, move) {
72
+ layerChildren(cube.children.slice(), move.axis, move.layer).forEach(c => rotEl.attach(c))
73
+ slot.ref = rotEl
74
+ slot.axis = move.axis
75
+ slot.target = (Math.PI / 2) * move.dir * move.quarter
76
+ slot.elapsed = 0
77
+ slot.duration = 0.42 * move.quarter + 0.16 + Math.random() * 0.3
78
+ slot.active = true
79
+ }
80
+
81
+ // ══════════════════════════════════════════════════════════
82
+ // Auto-playing cube: whole-cube tumble + scramble→solve loop.
83
+ // Renders inside a parent <Canvas>. `scrollRef` is an optional ref holding a
84
+ // 0..1 scroll progress used to tilt the cube.
85
+ // ══════════════════════════════════════════════════════════
86
+ export function AutoCubeScene({ palette, scrollRef, scale = 0.72, tumble = true, autoplay = true }) {
87
+ const tiltRef = useRef() // outermost: scroll tilt only
88
+ const pivotRef = useRef() // whole-cube tumble + bob + scale
89
+ const cubeRef = useRef() // holds the 27 cubelets at integer grid positions
90
+ const rotRefA = useRef() // rotation pivot for slice A
91
+ const rotRefB = useRef() // rotation pivot for slice B (concurrent turns)
92
+
93
+ const spinDir = useRef(new THREE.Vector3(0.3, 0.88, 0.36).normalize())
94
+ const slots = useRef([
95
+ { active: false, ref: null, axis: 'y', target: 0, elapsed: 0, duration: 0 },
96
+ { active: false, ref: null, axis: 'y', target: 0, elapsed: 0, duration: 0 },
97
+ ])
98
+ // Player state machine: scramble a handful of moves, then solve back, forever.
99
+ const P = useRef({ phase: 'scramble', history: [], remaining: 7, idle: 1.0, prevMove: null })
100
+
101
+ const positions = useCubePositions()
102
+
103
+ useFrame((state, delta) => {
104
+ if (!pivotRef.current || !tiltRef.current || !cubeRef.current || !rotRefA.current || !rotRefB.current) return
105
+ const t = state.clock.elapsedTime
106
+ const cube = cubeRef.current
107
+ const m = P.current
108
+ const S = slots.current
109
+
110
+ // ── Whole-cube tumble on the PARENT pivot (cancels out of slice maths) ──
111
+ if (tumble) {
112
+ _pq1.setFromAxisAngle(PREC1, 0.2 * delta)
113
+ _pq2.setFromAxisAngle(PREC2, 0.125 * delta)
114
+ _pq3.setFromAxisAngle(PREC3, 0.075 * delta)
115
+ spinDir.current.applyQuaternion(_pq1).applyQuaternion(_pq2).applyQuaternion(_pq3).normalize()
116
+
117
+ const swell = Math.sin(t * 0.11) * 0.5 + Math.sin(t * 0.047 + 1.3) * 0.3 + Math.sin(t * 0.19 + 0.6) * 0.18
118
+ const speed = 0.72 + swell * 0.34
119
+ _dq.setFromAxisAngle(spinDir.current, speed * delta)
120
+ pivotRef.current.quaternion.premultiply(_dq)
121
+ pivotRef.current.position.y = Math.sin(t * 0.6) * 0.09
122
+ }
123
+
124
+ // ── Scroll tilt on the outer wrapper ──
125
+ if (scrollRef) {
126
+ const sp = scrollRef.current || 0
127
+ const tiltK = 1 - Math.pow(0.05, delta)
128
+ tiltRef.current.rotation.x = THREE.MathUtils.lerp(tiltRef.current.rotation.x, sp * -0.36, tiltK)
129
+ }
130
+
131
+ // ── Auto slice player: scramble → solve loop, 1 or 2 turns at a time ──
132
+ const busy = S[0].active || S[1].active
133
+ if (autoplay && !busy) {
134
+ m.idle -= delta
135
+ if (m.idle <= 0) {
136
+ let moves
137
+ if (m.phase === 'scramble') {
138
+ const step = buildScrambleStep(m.prevMove)
139
+ moves = step.moves
140
+ if (step.record) m.history.push(step.record)
141
+ if (m.history.length >= m.remaining) m.phase = 'solve'
142
+ } else {
143
+ const step = m.history.pop()
144
+ moves = step.map(mm => ({ axis: mm.axis, layer: mm.layer, dir: -mm.dir, quarter: mm.quarter }))
145
+ }
146
+ m.prevMove = moves[moves.length - 1]
147
+ startSlot(S[0], rotRefA.current, cube, moves[0])
148
+ if (moves[1]) startSlot(S[1], rotRefB.current, cube, moves[1])
149
+ }
150
+ }
151
+
152
+ // Advance each active slot independently (desynced finish).
153
+ for (const s of S) {
154
+ if (!s.active) continue
155
+ s.elapsed = Math.min(s.elapsed + delta, s.duration)
156
+ s.ref.rotation[s.axis] = s.target * smoother(s.elapsed / s.duration)
157
+ if (s.elapsed >= s.duration) {
158
+ s.ref.rotation[s.axis] = s.target
159
+ s.ref.children.slice().forEach(c => cube.attach(c))
160
+ s.ref.rotation.set(0, 0, 0)
161
+ s.active = false
162
+ }
163
+ }
164
+
165
+ // Step just completed → schedule the next.
166
+ if (busy && !S[0].active && !S[1].active) {
167
+ if (m.phase === 'solve' && m.history.length === 0) {
168
+ m.phase = 'scramble'
169
+ m.remaining = 7 + ((Math.random() * 6) | 0)
170
+ m.idle = 1.8 + Math.random() * 1.5 // linger on the solved cube
171
+ } else {
172
+ m.idle = 0.12 + Math.random() * 0.4 // brief pause between turns
173
+ }
174
+ }
175
+ })
176
+
177
+ return (
178
+ <group ref={tiltRef}>
179
+ <group ref={pivotRef} scale={[scale, scale, scale]}>
180
+ <group ref={cubeRef}>
181
+ {positions.map(([x, y, z]) => (
182
+ <Cubelet key={`${x + 1}${y + 1}${z + 1}`} px={x} py={y} pz={z} palette={palette} />
183
+ ))}
184
+ </group>
185
+ <group ref={rotRefA} />
186
+ <group ref={rotRefB} />
187
+ </group>
188
+ </group>
189
+ )
190
+ }
@@ -0,0 +1,211 @@
1
+ import { useState, useRef, useCallback } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Canvas } from '@react-three/fiber'
4
+ import { OrbitControls } from '@react-three/drei'
5
+ import { AnimatePresence, motion } from 'framer-motion'
6
+ import { Lights } from './primitives.jsx'
7
+ import { InteractiveCubeScene, MOVE_GROUPS } from './InteractiveCubeScene.jsx'
8
+
9
+ // Small reusable move button with a hover colour accent.
10
+ function MoveBtn({ label, accent, onClick }) {
11
+ const [hov, setHov] = useState(false)
12
+ const isPrime = label.includes("'")
13
+ return (
14
+ <motion.button
15
+ whileTap={{ scale: 0.9 }}
16
+ onClick={onClick}
17
+ onMouseEnter={() => setHov(true)}
18
+ onMouseLeave={() => setHov(false)}
19
+ style={{
20
+ flex: 1,
21
+ border: `1px solid ${hov ? accent : 'rgba(255,255,255,0.08)'}`,
22
+ background: hov ? `${accent}22` : 'rgba(255,255,255,0.03)',
23
+ color: hov ? '#fff' : 'rgba(240,235,226,0.65)',
24
+ borderRadius: 6,
25
+ padding: '5px 0',
26
+ fontSize: 11,
27
+ fontWeight: 700,
28
+ cursor: 'pointer',
29
+ fontFamily: "'DM Mono', monospace",
30
+ transition: 'all 0.12s',
31
+ textAlign: 'center',
32
+ letterSpacing: isPrime ? '-0.02em' : '0.04em',
33
+ }}
34
+ >
35
+ {label}
36
+ </motion.button>
37
+ )
38
+ }
39
+
40
+ // ══════════════════════════════════════════════════════════
41
+ // Fullscreen interactive overlay: a large drag-to-turn cube plus a control
42
+ // panel of Rubik's-notation buttons. Rendered into document.body via a portal.
43
+ // ══════════════════════════════════════════════════════════
44
+ export function CubeOverlay({ onClose, palette, accent = '#E05C28', cameraPosition = [5, 4, 5], fov = 34 }) {
45
+ const [mode, setMode] = useState('orbit')
46
+ const modeRef = useRef('orbit')
47
+ const queueRef = useRef([])
48
+
49
+ const changeMode = useCallback(m => {
50
+ modeRef.current = m
51
+ setMode(m)
52
+ }, [])
53
+ const pushMove = useCallback(move => queueRef.current.push(move), [])
54
+
55
+ return createPortal(
56
+ <AnimatePresence>
57
+ <motion.div
58
+ key="cube-overlay"
59
+ initial={{ opacity: 0 }}
60
+ animate={{ opacity: 1 }}
61
+ exit={{ opacity: 0 }}
62
+ transition={{ duration: 0.2 }}
63
+ style={{
64
+ position: 'fixed',
65
+ inset: 0,
66
+ zIndex: 9999,
67
+ background: 'rgba(7, 6, 5, 0.65)',
68
+ backdropFilter: 'blur(20px)',
69
+ display: 'flex',
70
+ alignItems: 'center',
71
+ justifyContent: 'center',
72
+ gap: 28,
73
+ fontFamily: 'system-ui, sans-serif',
74
+ }}
75
+ onClick={e => {
76
+ if (e.target === e.currentTarget) onClose()
77
+ }}
78
+ >
79
+ <button
80
+ onClick={onClose}
81
+ aria-label="Close"
82
+ style={{
83
+ position: 'absolute',
84
+ top: 20,
85
+ right: 22,
86
+ background: 'rgba(255,255,255,0.04)',
87
+ border: '1px solid rgba(255,255,255,0.09)',
88
+ color: 'rgba(255,255,255,0.35)',
89
+ borderRadius: 9,
90
+ width: 36,
91
+ height: 36,
92
+ fontSize: 18,
93
+ cursor: 'pointer',
94
+ display: 'flex',
95
+ alignItems: 'center',
96
+ justifyContent: 'center',
97
+ transition: 'all 0.15s',
98
+ }}
99
+ >
100
+ ×
101
+ </button>
102
+
103
+ {/* 3-D canvas */}
104
+ <motion.div
105
+ initial={{ scale: 0.6, opacity: 0 }}
106
+ animate={{ scale: 1, opacity: 1 }}
107
+ exit={{ scale: 0.6, opacity: 0 }}
108
+ transition={{ type: 'spring', damping: 20, stiffness: 180 }}
109
+ style={{
110
+ width: 'min(460px, 78vw)',
111
+ height: 'min(460px, 78vw)',
112
+ flexShrink: 0,
113
+ borderRadius: 20,
114
+ cursor: mode === 'move' ? 'crosshair' : 'grab',
115
+ overflow: 'hidden',
116
+ }}
117
+ >
118
+ <Canvas flat camera={{ position: cameraPosition, fov }} gl={{ antialias: true, alpha: true }} style={{ background: 'transparent' }}>
119
+ <Lights ambient={0.4} />
120
+ <InteractiveCubeScene palette={palette} queueRef={queueRef} modeRef={modeRef} />
121
+ <OrbitControls enabled={mode === 'orbit'} autoRotate={false} enablePan={false} enableZoom={false} dampingFactor={0.08} enableDamping rotateSpeed={0.65} />
122
+ </Canvas>
123
+ </motion.div>
124
+
125
+ {/* Control panel */}
126
+ <motion.div
127
+ initial={{ x: 44, opacity: 0 }}
128
+ animate={{ x: 0, opacity: 1 }}
129
+ exit={{ x: 44, opacity: 0 }}
130
+ transition={{ type: 'spring', damping: 22, stiffness: 180, delay: 0.06 }}
131
+ style={{
132
+ width: 196,
133
+ background: 'rgba(255,255,255,0.025)',
134
+ border: '1px solid rgba(255,255,255,0.07)',
135
+ borderRadius: 16,
136
+ overflow: 'hidden',
137
+ flexShrink: 0,
138
+ }}
139
+ >
140
+ <div style={{ padding: '14px 14px 10px', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
141
+ <p style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.22em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.22)', marginBottom: 10 }}>Controls</p>
142
+ <div style={{ display: 'flex', background: 'rgba(0,0,0,0.35)', borderRadius: 9, padding: 3, gap: 2 }}>
143
+ {[
144
+ { id: 'orbit', icon: '⟳', label: 'Orbit' },
145
+ { id: 'move', icon: '✦', label: 'Move' },
146
+ ].map(({ id, icon, label }) => (
147
+ <button
148
+ key={id}
149
+ onClick={() => changeMode(id)}
150
+ style={{
151
+ flex: 1,
152
+ border: 'none',
153
+ borderRadius: 7,
154
+ padding: '6px 0',
155
+ fontSize: 10.5,
156
+ fontWeight: 700,
157
+ cursor: 'pointer',
158
+ display: 'flex',
159
+ alignItems: 'center',
160
+ justifyContent: 'center',
161
+ gap: 4,
162
+ transition: 'all 0.15s',
163
+ background: mode === id ? accent : 'transparent',
164
+ color: mode === id ? '#fff' : 'rgba(255,255,255,0.35)',
165
+ }}
166
+ >
167
+ <span style={{ fontSize: 11 }}>{icon}</span>
168
+ {label}
169
+ </button>
170
+ ))}
171
+ </div>
172
+ <p style={{ marginTop: 8, fontSize: 9.5, lineHeight: 1.45, color: 'rgba(255,255,255,0.2)' }}>
173
+ {mode === 'orbit' ? 'Drag to orbit · buttons to move' : 'Swipe a face to rotate · or use buttons'}
174
+ </p>
175
+ </div>
176
+
177
+ {MOVE_GROUPS.map(({ id, label, accent: groupAccent, pairs }, gi) => (
178
+ <div key={id} style={{ borderBottom: gi < MOVE_GROUPS.length - 1 ? '1px solid rgba(255,255,255,0.045)' : 'none', padding: '8px 10px 9px' }}>
179
+ <div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 6 }}>
180
+ <span style={{ width: 5, height: 5, borderRadius: '50%', background: groupAccent, flexShrink: 0, opacity: 0.85 }} />
181
+ <span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.2)' }}>{label}</span>
182
+ </div>
183
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
184
+ {pairs.map(([cw, ccw], pi) => (
185
+ <div key={pi} style={{ display: 'flex', gap: 3 }}>
186
+ {[cw, ccw].map(({ n, axis, layer, dir }) => (
187
+ <MoveBtn key={n} label={n} accent={groupAccent} onClick={() => pushMove({ axis, layer, dir })} />
188
+ ))}
189
+ </div>
190
+ ))}
191
+ </div>
192
+ </div>
193
+ ))}
194
+
195
+ <div style={{ padding: '8px 14px 12px', borderTop: '1px solid rgba(255,255,255,0.04)' }}>
196
+ <p style={{ fontSize: 9, color: 'rgba(255,255,255,0.15)', lineHeight: 1.5, letterSpacing: '0.04em' }}>
197
+ Notation: prime (′) = counter-clockwise
198
+ <br />
199
+ U=Up D=Down R=Right L=Left
200
+ <br />
201
+ F=Front B=Back M=Mid E=Eq S=Std
202
+ <br />
203
+ x/y/z = whole-cube rotation
204
+ </p>
205
+ </div>
206
+ </motion.div>
207
+ </motion.div>
208
+ </AnimatePresence>,
209
+ document.body,
210
+ )
211
+ }
@@ -0,0 +1,196 @@
1
+ import { useRef, useCallback } from 'react'
2
+ import { useFrame, useThree } from '@react-three/fiber'
3
+ import * as THREE from 'three'
4
+ import { Cubelet, smoother, useCubePositions } from './primitives.jsx'
5
+
6
+ // Which cubelet layer a given axis value sits in.
7
+ function layerOf(v) {
8
+ return v > 0.5 ? 1 : v < -0.5 ? -1 : 0
9
+ }
10
+
11
+ // Map (faceNormal, cubelet position, screen drag delta) → { axis, layer, dir }.
12
+ // Picks whichever in-face rotation axis best matches the drag direction on
13
+ // screen, then the layer that cubelet belongs to.
14
+ function computeSlideMove(faceNormal, cubeletPos, dragDx, dragDy, camera) {
15
+ const axes = ['x', 'y', 'z']
16
+ const faceAxis = axes.reduce((best, ax) => (Math.abs(faceNormal[ax]) > Math.abs(faceNormal[best]) ? ax : best), 'x')
17
+
18
+ const candidates = axes.filter(a => a !== faceAxis)
19
+ const len = Math.sqrt(dragDx * dragDx + dragDy * dragDy)
20
+ if (len < 1) return null
21
+
22
+ const results = candidates.map(rotAxis => {
23
+ const A = new THREE.Vector3(rotAxis === 'x' ? 1 : 0, rotAxis === 'y' ? 1 : 0, rotAxis === 'z' ? 1 : 0)
24
+ const tangent = new THREE.Vector3().crossVectors(A, faceNormal.clone().normalize())
25
+ const camT = tangent.clone().applyMatrix4(camera.matrixWorldInverse).normalize()
26
+ const dot = (camT.x * dragDx + -camT.y * dragDy) / len
27
+ return { rotAxis, dot }
28
+ })
29
+
30
+ const winner = results.reduce((a, b) => (Math.abs(a.dot) > Math.abs(b.dot) ? a : b))
31
+ const layer = layerOf(cubeletPos[winner.rotAxis])
32
+ return { axis: winner.rotAxis, layer, dir: winner.dot > 0 ? 1 : -1 }
33
+ }
34
+
35
+ // ══════════════════════════════════════════════════════════
36
+ // Interactive cube: drag a face to turn a slice, or feed moves through a
37
+ // queue ref (from external buttons). Auto-solves back to the start after
38
+ // `autoReturnAfter` seconds of idle. Renders inside a parent <Canvas>.
39
+ // ══════════════════════════════════════════════════════════
40
+ export function InteractiveCubeScene({ palette, queueRef, modeRef, autoReturnAfter = 4, idleSpin = 0.28 }) {
41
+ const orbitRef = useRef() // outer wrapper; only the idle auto-spin touches this
42
+ const cubeRef = useRef()
43
+ const rotRef = useRef()
44
+ const anim = useRef({ active: false, axis: 'y', start: 0, target: 0, elapsed: 0, duration: 0.3 })
45
+ const dragRef = useRef(null)
46
+ const spinAngle = useRef(0)
47
+ const history = useRef([]) // moves applied so far (for auto-return)
48
+ const idleTimer = useRef(0)
49
+ const isReturning = useRef(false)
50
+ const { camera } = useThree()
51
+
52
+ const positions = useCubePositions()
53
+
54
+ useFrame((_, delta) => {
55
+ const a = anim.current
56
+ const cube = cubeRef.current
57
+ const rot = rotRef.current
58
+ if (!cube || !rot) return
59
+
60
+ // Dequeue next move.
61
+ if (!a.active && queueRef.current.length > 0) {
62
+ const move = queueRef.current.shift()
63
+ const { axis, layer, dir } = move
64
+ if (!isReturning.current) {
65
+ history.current.push(move)
66
+ idleTimer.current = 0
67
+ }
68
+ const all = cube.children.slice()
69
+ const toRot =
70
+ layer === null
71
+ ? all
72
+ : all.filter(c => {
73
+ const v = c.position[axis]
74
+ return layer === 0 ? Math.abs(v) < 0.5 : layer > 0 ? v > 0.5 : v < -0.5
75
+ })
76
+ toRot.forEach(c => rot.attach(c))
77
+ anim.current = { active: true, axis, start: 0, target: (Math.PI / 2) * dir, elapsed: 0, duration: 0.42 }
78
+ }
79
+
80
+ // Tween the active move — smootherstep for a silky, jerk-free turn.
81
+ if (a.active) {
82
+ a.elapsed = Math.min(a.elapsed + delta, a.duration)
83
+ const ease = smoother(a.elapsed / a.duration)
84
+ rot.rotation[a.axis] = a.start + (a.target - a.start) * ease
85
+ if (a.elapsed >= a.duration) {
86
+ rot.rotation[a.axis] = a.target
87
+ rot.children.slice().forEach(c => cube.attach(c))
88
+ rot.rotation.set(0, 0, 0)
89
+ a.active = false
90
+ if (isReturning.current && queueRef.current.length === 0) {
91
+ isReturning.current = false
92
+ history.current = []
93
+ }
94
+ }
95
+ }
96
+
97
+ // Auto-return: after idle with scramble history, reverse every move.
98
+ if (autoReturnAfter > 0 && !a.active && queueRef.current.length === 0 && history.current.length > 0 && !isReturning.current) {
99
+ idleTimer.current += delta
100
+ if (idleTimer.current >= autoReturnAfter) {
101
+ isReturning.current = true
102
+ idleTimer.current = 0
103
+ const reversed = history.current
104
+ .slice()
105
+ .reverse()
106
+ .map(m => ({ ...m, dir: -m.dir }))
107
+ queueRef.current.push(...reversed)
108
+ }
109
+ }
110
+
111
+ // Idle auto-spin — on the OUTER wrapper only, never on cubeRef.
112
+ if (idleSpin > 0 && !a.active && queueRef.current.length === 0 && orbitRef.current) {
113
+ spinAngle.current += delta * idleSpin
114
+ orbitRef.current.rotation.y = spinAngle.current
115
+ }
116
+ })
117
+
118
+ const onPointerDown = useCallback(
119
+ e => {
120
+ if (modeRef && modeRef.current !== 'move') return
121
+ e.stopPropagation()
122
+ const normalWorld = e.face.normal.clone().transformDirection(e.object.matrixWorld)
123
+ dragRef.current = { startX: e.clientX, startY: e.clientY, normalWorld, cubeletPos: e.object.position.clone() }
124
+ },
125
+ [modeRef],
126
+ )
127
+
128
+ const onPointerUp = useCallback(
129
+ e => {
130
+ const drag = dragRef.current
131
+ if (!drag) return
132
+ dragRef.current = null
133
+ const dx = e.clientX - drag.startX
134
+ const dy = e.clientY - drag.startY
135
+ if (Math.sqrt(dx * dx + dy * dy) < 12) return
136
+ const move = computeSlideMove(drag.normalWorld, drag.cubeletPos, dx, dy, camera)
137
+ if (move) queueRef.current.push(move)
138
+ },
139
+ [camera, queueRef],
140
+ )
141
+
142
+ return (
143
+ <group ref={orbitRef}>
144
+ <group ref={cubeRef} onPointerDown={onPointerDown} onPointerUp={onPointerUp}>
145
+ {positions.map(([x, y, z]) => (
146
+ <Cubelet key={`${x + 1}${y + 1}${z + 1}`} px={x} py={y} pz={z} palette={palette} />
147
+ ))}
148
+ </group>
149
+ <group ref={rotRef} />
150
+ </group>
151
+ )
152
+ }
153
+
154
+ // Rubik's-notation move groups, exported so consumers can build their own UI.
155
+ export const MOVE_GROUPS = [
156
+ {
157
+ id: 'y',
158
+ label: 'Y · Layer',
159
+ accent: '#f59e0b',
160
+ pairs: [
161
+ [{ n: 'U', axis: 'y', layer: 1, dir: -1 }, { n: "U'", axis: 'y', layer: 1, dir: 1 }],
162
+ [{ n: 'E', axis: 'y', layer: 0, dir: 1 }, { n: "E'", axis: 'y', layer: 0, dir: -1 }],
163
+ [{ n: 'D', axis: 'y', layer: -1, dir: 1 }, { n: "D'", axis: 'y', layer: -1, dir: -1 }],
164
+ ],
165
+ },
166
+ {
167
+ id: 'x',
168
+ label: 'X · Column',
169
+ accent: '#ef4444',
170
+ pairs: [
171
+ [{ n: 'R', axis: 'x', layer: 1, dir: -1 }, { n: "R'", axis: 'x', layer: 1, dir: 1 }],
172
+ [{ n: 'M', axis: 'x', layer: 0, dir: 1 }, { n: "M'", axis: 'x', layer: 0, dir: -1 }],
173
+ [{ n: 'L', axis: 'x', layer: -1, dir: 1 }, { n: "L'", axis: 'x', layer: -1, dir: -1 }],
174
+ ],
175
+ },
176
+ {
177
+ id: 'z',
178
+ label: 'Z · Depth',
179
+ accent: '#22c55e',
180
+ pairs: [
181
+ [{ n: 'F', axis: 'z', layer: 1, dir: -1 }, { n: "F'", axis: 'z', layer: 1, dir: 1 }],
182
+ [{ n: 'S', axis: 'z', layer: 0, dir: -1 }, { n: "S'", axis: 'z', layer: 0, dir: 1 }],
183
+ [{ n: 'B', axis: 'z', layer: -1, dir: 1 }, { n: "B'", axis: 'z', layer: -1, dir: -1 }],
184
+ ],
185
+ },
186
+ {
187
+ id: 'rot',
188
+ label: 'Rotate · Whole',
189
+ accent: '#a78bfa',
190
+ pairs: [
191
+ [{ n: 'x', axis: 'x', layer: null, dir: -1 }, { n: "x'", axis: 'x', layer: null, dir: 1 }],
192
+ [{ n: 'y', axis: 'y', layer: null, dir: -1 }, { n: "y'", axis: 'y', layer: null, dir: 1 }],
193
+ [{ n: 'z', axis: 'z', layer: null, dir: -1 }, { n: "z'", axis: 'z', layer: null, dir: 1 }],
194
+ ],
195
+ },
196
+ ]