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.
@@ -0,0 +1,109 @@
1
+ import { useRef, useEffect, useState, useCallback } from 'react'
2
+ import { Canvas } from '@react-three/fiber'
3
+ import { Lights } from './primitives.jsx'
4
+ import { AutoCubeScene } from './AutoCubeScene.jsx'
5
+ import { CubeOverlay } from './CubeOverlay.jsx'
6
+ import { resolvePalette } from './palette.js'
7
+
8
+ // ══════════════════════════════════════════════════════════
9
+ // <RubiksCube /> — the drop-in component.
10
+ //
11
+ // Renders a self-contained auto-solving 3D cube. Optionally click it to open a
12
+ // fullscreen interactive overlay (drag a face to turn, or use the buttons).
13
+ // ══════════════════════════════════════════════════════════
14
+ export function RubiksCube({
15
+ size = 400,
16
+ height,
17
+ palette: paletteProp,
18
+ accent = '#E05C28',
19
+ autoplay = true,
20
+ tumble = true,
21
+ interactive = true,
22
+ scrollTilt = false,
23
+ scale = 0.72,
24
+ cameraPosition = [7, 6, 7],
25
+ fov = 24,
26
+ background = 'transparent',
27
+ ambient = 0.22,
28
+ className,
29
+ style,
30
+ onExpand,
31
+ ...rest
32
+ }) {
33
+ const scrollRef = useRef(0)
34
+ const wrapRef = useRef(null)
35
+ const [expanded, setExpanded] = useState(false)
36
+
37
+ const palette = resolvePalette(paletteProp)
38
+ const w = typeof size === 'number' ? `${size}px` : size
39
+ const h = height != null ? (typeof height === 'number' ? `${height}px` : height) : w
40
+
41
+ // Optional scroll-driven tilt: 0..1 progress as the element scrolls up.
42
+ useEffect(() => {
43
+ if (!scrollTilt) return
44
+ const el = wrapRef.current
45
+ const update = () => {
46
+ if (!el) return
47
+ const rect = el.getBoundingClientRect()
48
+ const vh = window.innerHeight || 1
49
+ scrollRef.current = Math.max(0, Math.min(1, (vh - rect.top) / (vh + rect.height) - 0.1))
50
+ }
51
+ update()
52
+ window.addEventListener('scroll', update, { passive: true })
53
+ window.addEventListener('resize', update)
54
+ return () => {
55
+ window.removeEventListener('scroll', update)
56
+ window.removeEventListener('resize', update)
57
+ }
58
+ }, [scrollTilt])
59
+
60
+ // Lock body scroll while the overlay is open + close on Escape.
61
+ useEffect(() => {
62
+ if (!expanded) return
63
+ const scrollY = window.scrollY
64
+ document.body.style.position = 'fixed'
65
+ document.body.style.top = `-${scrollY}px`
66
+ document.body.style.width = '100%'
67
+ const onKey = e => e.key === 'Escape' && setExpanded(false)
68
+ window.addEventListener('keydown', onKey)
69
+ return () => {
70
+ document.body.style.position = ''
71
+ document.body.style.top = ''
72
+ document.body.style.width = ''
73
+ window.scrollTo(0, scrollY)
74
+ window.removeEventListener('keydown', onKey)
75
+ }
76
+ }, [expanded])
77
+
78
+ const open = useCallback(() => {
79
+ setExpanded(true)
80
+ onExpand?.()
81
+ }, [onExpand])
82
+ const close = useCallback(() => setExpanded(false), [])
83
+
84
+ return (
85
+ <>
86
+ <div ref={wrapRef} className={className} style={{ position: 'relative', width: w, height: h, flexShrink: 0, ...style }} {...rest}>
87
+ <Canvas flat camera={{ position: cameraPosition, fov }} gl={{ antialias: true, alpha: background === 'transparent' }} style={{ background }}>
88
+ <Lights ambient={ambient} />
89
+ <AutoCubeScene palette={palette} scrollRef={scrollTilt ? scrollRef : null} scale={scale} tumble={tumble} autoplay={autoplay} />
90
+ </Canvas>
91
+
92
+ {interactive && (
93
+ <div
94
+ style={{ position: 'absolute', inset: 0, cursor: 'pointer', zIndex: 1 }}
95
+ onClick={open}
96
+ role="button"
97
+ aria-label="Explore cube"
98
+ tabIndex={0}
99
+ onKeyDown={e => e.key === 'Enter' && open()}
100
+ />
101
+ )}
102
+ </div>
103
+
104
+ {interactive && expanded && <CubeOverlay onClose={close} palette={palette} accent={accent} />}
105
+ </>
106
+ )
107
+ }
108
+
109
+ export default RubiksCube
package/src/index.d.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { CSSProperties, HTMLAttributes } from 'react'
2
+
3
+ export interface FaceMaterial {
4
+ color: string
5
+ roughness: number
6
+ metalness: number
7
+ }
8
+
9
+ export interface Palette {
10
+ right: FaceMaterial
11
+ left: FaceMaterial
12
+ up: FaceMaterial
13
+ down: FaceMaterial
14
+ front: FaceMaterial
15
+ back: FaceMaterial
16
+ inner: FaceMaterial
17
+ }
18
+
19
+ /** A partial palette. `base` selects which built-in palette to merge onto. */
20
+ export type PaletteInput = Partial<{
21
+ base: 'brand' | 'classic'
22
+ right: Partial<FaceMaterial>
23
+ left: Partial<FaceMaterial>
24
+ up: Partial<FaceMaterial>
25
+ down: Partial<FaceMaterial>
26
+ front: Partial<FaceMaterial>
27
+ back: Partial<FaceMaterial>
28
+ inner: Partial<FaceMaterial>
29
+ }>
30
+
31
+ export interface RubiksCubeProps extends Omit<HTMLAttributes<HTMLDivElement>, 'style'> {
32
+ /** Width in px (number) or any CSS size (string). Default 400. */
33
+ size?: number | string
34
+ /** Height. Defaults to `size` (square). */
35
+ height?: number | string
36
+ /** Palette override, merged onto the `brand` (default) or `classic` base. */
37
+ palette?: PaletteInput
38
+ /** Accent colour for the overlay's active mode toggle. Default '#E05C28'. */
39
+ accent?: string
40
+ /** Run the scramble → solve loop. Default true. */
41
+ autoplay?: boolean
42
+ /** Slowly tumble the whole cube. Default true. */
43
+ tumble?: boolean
44
+ /** Click to open the fullscreen interactive overlay. Default true. */
45
+ interactive?: boolean
46
+ /** Tilt the cube as the element scrolls through the viewport. Default false. */
47
+ scrollTilt?: boolean
48
+ /** Cube scale inside the canvas. Default 0.72. */
49
+ scale?: number
50
+ /** Camera position [x, y, z]. Default [7, 6, 7]. */
51
+ cameraPosition?: [number, number, number]
52
+ /** Camera field of view. Default 24. */
53
+ fov?: number
54
+ /** Canvas background. Default 'transparent'. */
55
+ background?: string
56
+ /** Ambient light intensity. Default 0.22. */
57
+ ambient?: number
58
+ style?: CSSProperties
59
+ /** Called when the interactive overlay opens. */
60
+ onExpand?: () => void
61
+ }
62
+
63
+ export declare function RubiksCube(props: RubiksCubeProps): JSX.Element
64
+ export default RubiksCube
65
+
66
+ export interface Move {
67
+ axis: 'x' | 'y' | 'z'
68
+ /** -1, 0, 1 for a layer; null for a whole-cube rotation. */
69
+ layer: -1 | 0 | 1 | null
70
+ dir: 1 | -1
71
+ }
72
+
73
+ export interface AutoCubeSceneProps {
74
+ palette: Palette
75
+ scrollRef?: { current: number } | null
76
+ scale?: number
77
+ tumble?: boolean
78
+ autoplay?: boolean
79
+ }
80
+ export declare function AutoCubeScene(props: AutoCubeSceneProps): JSX.Element
81
+
82
+ export interface InteractiveCubeSceneProps {
83
+ palette: Palette
84
+ queueRef: { current: Move[] }
85
+ modeRef?: { current: 'orbit' | 'move' }
86
+ autoReturnAfter?: number
87
+ idleSpin?: number
88
+ }
89
+ export declare function InteractiveCubeScene(props: InteractiveCubeSceneProps): JSX.Element
90
+
91
+ export interface CubeOverlayProps {
92
+ onClose: () => void
93
+ palette: Palette
94
+ accent?: string
95
+ cameraPosition?: [number, number, number]
96
+ fov?: number
97
+ }
98
+ export declare function CubeOverlay(props: CubeOverlayProps): JSX.Element
99
+
100
+ export declare const MOVE_GROUPS: Array<{
101
+ id: string
102
+ label: string
103
+ accent: string
104
+ pairs: Array<Array<{ n: string } & Move>>
105
+ }>
106
+
107
+ export declare function Cubelet(props: { px: number; py: number; pz: number; palette: Palette }): JSX.Element
108
+ export declare function Lights(props: { ambient?: number }): JSX.Element
109
+ export declare function useCubePositions(): [number, number, number][]
110
+ export declare function smoother(t: number): number
111
+ export declare const CUBELET_GEO: import('three').BufferGeometry
112
+
113
+ export declare const BRAND_PALETTE: Palette
114
+ export declare const CLASSIC_PALETTE: Palette
115
+ export declare const FACE_ORDER: Array<keyof Palette>
116
+ export declare function resolvePalette(palette?: PaletteInput): Palette
package/src/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // Public API
2
+ export { RubiksCube, default } from './RubiksCube.jsx'
3
+
4
+ // Advanced building blocks — bring your own <Canvas>.
5
+ export { AutoCubeScene } from './AutoCubeScene.jsx'
6
+ export { InteractiveCubeScene, MOVE_GROUPS } from './InteractiveCubeScene.jsx'
7
+ export { CubeOverlay } from './CubeOverlay.jsx'
8
+ export { Cubelet, Lights, CUBELET_GEO, smoother, useCubePositions } from './primitives.jsx'
9
+
10
+ // Theming
11
+ export { BRAND_PALETTE, CLASSIC_PALETTE, FACE_ORDER, resolvePalette } from './palette.js'
package/src/palette.js ADDED
@@ -0,0 +1,48 @@
1
+ // ── Palettes ──────────────────────────────────────────────
2
+ // A palette describes the six sticker faces plus the inner body.
3
+ // Faces map to axes: right (+x), left (-x), up (+y), down (-y),
4
+ // front (+z), back (-z). Each entry is { color, roughness, metalness }.
5
+ // metalness stays 0 by default — there is no environment map, so any
6
+ // metalness only deadens the diffuse colour. The plastic sheen comes from
7
+ // crisp specular highlights of the lights at low roughness.
8
+
9
+ // Warm editorial brand palette (the original cubex look): a "warm side /
10
+ // cool side" read rather than a random rainbow. Opposite faces hold the
11
+ // strongest contrast pairs.
12
+ export const BRAND_PALETTE = {
13
+ right: { color: '#E05C28', roughness: 0.34, metalness: 0 }, // accent orange
14
+ left: { color: '#00C9A7', roughness: 0.34, metalness: 0 }, // mint teal
15
+ up: { color: '#EDE3D1', roughness: 0.44, metalness: 0 }, // ink cream
16
+ down: { color: '#C8963E', roughness: 0.36, metalness: 0 }, // gold
17
+ front: { color: '#B23E22', roughness: 0.36, metalness: 0 }, // deep terracotta
18
+ back: { color: '#0B7A6E', roughness: 0.38, metalness: 0 }, // deep teal
19
+ inner: { color: '#1C1A17', roughness: 0.82, metalness: 0 }, // dark body
20
+ }
21
+
22
+ // Classic Rubik's colours for a traditional look.
23
+ export const CLASSIC_PALETTE = {
24
+ right: { color: '#B71234', roughness: 0.3, metalness: 0 }, // red
25
+ left: { color: '#FF5800', roughness: 0.3, metalness: 0 }, // orange
26
+ up: { color: '#FFFFFF', roughness: 0.4, metalness: 0 }, // white
27
+ down: { color: '#FFD500', roughness: 0.32, metalness: 0 }, // yellow
28
+ front: { color: '#009B48', roughness: 0.32, metalness: 0 }, // green
29
+ back: { color: '#0046AD', roughness: 0.32, metalness: 0 }, // blue
30
+ inner: { color: '#111111', roughness: 0.85, metalness: 0 }, // black body
31
+ }
32
+
33
+ // Face order used everywhere geometry is built. This is the order
34
+ // meshStandardMaterial slots map to on a BoxGeometry:
35
+ // 0 = +x (right), 1 = -x (left), 2 = +y (up),
36
+ // 3 = -y (down), 4 = +z (front), 5 = -z (back)
37
+ export const FACE_ORDER = ['right', 'left', 'up', 'down', 'front', 'back']
38
+
39
+ // Merge a user-supplied partial palette over a base, per-face.
40
+ export function resolvePalette(palette) {
41
+ if (!palette) return BRAND_PALETTE
42
+ const base = palette.base === 'classic' ? CLASSIC_PALETTE : BRAND_PALETTE
43
+ const out = {}
44
+ for (const key of [...FACE_ORDER, 'inner']) {
45
+ out[key] = { ...base[key], ...(palette[key] || {}) }
46
+ }
47
+ return out
48
+ }
@@ -0,0 +1,66 @@
1
+ import { useMemo } from 'react'
2
+ import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js'
3
+ import { FACE_ORDER } from './palette.js'
4
+
5
+ // ── Shared geometry ───────────────────────────────────────
6
+ // One rounded-box geometry reused by all 27 cubelets. Module-level so it is
7
+ // created once per bundle, never per mount.
8
+ export const CUBELET_GEO = new RoundedBoxGeometry(0.95, 0.95, 0.95, 4, 0.045)
9
+
10
+ // Smootherstep (Perlin) — zero velocity AND acceleration at both ends, so
11
+ // every turn starts and stops with no perceptible jerk.
12
+ export const smoother = t => {
13
+ t = Math.max(0, Math.min(1, t))
14
+ return t * t * t * (t * (t * 6 - 15) + 10)
15
+ }
16
+
17
+ // One cubelet: outer faces get sticker materials, inner faces get the body.
18
+ export function Cubelet({ px, py, pz, palette }) {
19
+ // Which of the 6 BoxGeometry face-slots are on the cube's outer surface.
20
+ const outer = useMemo(
21
+ () => [px === 1, px === -1, py === 1, py === -1, pz === 1, pz === -1],
22
+ [px, py, pz],
23
+ )
24
+ return (
25
+ <mesh position={[px, py, pz]} geometry={CUBELET_GEO}>
26
+ {outer.map((isOuter, i) => {
27
+ const m = isOuter ? palette[FACE_ORDER[i]] : palette.inner
28
+ return (
29
+ <meshStandardMaterial
30
+ key={i}
31
+ attach={`material-${i}`}
32
+ color={m.color}
33
+ roughness={m.roughness}
34
+ metalness={m.metalness}
35
+ />
36
+ )
37
+ })}
38
+ </mesh>
39
+ )
40
+ }
41
+
42
+ // Studio lighting tuned for flat (no tone-mapping) rendering: a warm key, a
43
+ // soft warm fill, a cool rim to carve edges out of a dark background, an
44
+ // ambient floor, and a warm front specular for the plastic sheen.
45
+ export function Lights({ ambient = 0.22 }) {
46
+ return (
47
+ <>
48
+ <directionalLight position={[5, 8, 4]} intensity={2.4} color="#fff6ee" />
49
+ <directionalLight position={[-5, -3, -4]} intensity={0.6} color="#ffe0cf" />
50
+ <directionalLight position={[-4, 6, -6]} intensity={1.5} color="#cfe4ff" />
51
+ <ambientLight intensity={ambient} />
52
+ <pointLight position={[-1, 1, 5]} intensity={0.85} color="#fff5ee" distance={18} />
53
+ </>
54
+ )
55
+ }
56
+
57
+ // The 27 integer grid positions of a 3×3×3 cube.
58
+ export function useCubePositions() {
59
+ return useMemo(() => {
60
+ const list = []
61
+ for (let x = -1; x <= 1; x++)
62
+ for (let y = -1; y <= 1; y++)
63
+ for (let z = -1; z <= 1; z++) list.push([x, y, z])
64
+ return list
65
+ }, [])
66
+ }