Rubik's Cube · step-3.7-flash
Using the pre-provided three.min.js (global THREE), render an interactive 3D Rubik's Cube with clickable face rotations, orbit-drag camera, and a scramble button.
transcript · 34 messages
Using the pre-provided three.min.js (global THREE), render an interactive 3D Rubik's Cube with clickable face rotations, orbit-drag camera, and a scramble button.
thinking
Let me build a complete interactive 3D Rubik's Cube in a single index.html file using Three.js. I need to:
1. Create a 3x3x3 Rubik's cube with proper face colors
2. Implement clickable face rotations (click and drag on cube faces)
3. Orbit camera controls (drag to rotate view)
4. Scramble button
5. All self-contained in index.html
Let me think about the architecture:
- 27 small cubes (cubies) in a 3x3x3 grid, each slightly smaller than 1 unit with a small gap
- Each cubie is a group containing 6 colored faces (or outer faces colored, inner faces black)
- Click detection on which face is clicked, then drag direction to determine which axis to rotate
- Group cubies by the slice being rotated, animate rotation, then update positions
- Orbit camera using Three.js OrbitControls-like implementation (since we can't use CDN, I'll implement basic orbit)
Actually, since we have three.min.js available locally, let me check if it includes OrbitControls or if I need to implement orbit myself. The user said to load three.min.js. OrbitControls is typically a separate file. I'll implement a simple orbit camera manually.
Let me think about the Rubik's cube structure:
- 27 cubies at positions (-1,-1,-1) to (1,1,1)
- Each cubie has colored faces based on position
- Outer faces: up=white, down=yellow, left=orange, right=red, front=green, back=blue
- Inner faces: dark/black
For click detection:
- Raycaster to find which cubie face was clicked
- Then track mouse movement to determine rotation axis and direction
- If click on a face whose normal aligns with X axis, dragging up/down rotates around Y, left/right rotates around X... wait let me think.
Actually, for Rubik's cube, easier approach:
- Detect which cubie face was clicked
- Based on the face normal and the drag direction, determine which slice to rotate
Standard approach:
1. Click on a face, get the cubie, the face normal
2. As mouse drags, project camera direction to determine rotation axis perpendicular to both camera view direction and the face normal
Actually let me use a cleaner approach:
- On mousedown, raycast to find intersected cubie face
- Store the initial mouse position, cubie, face normal
- On mousemove, compute drag direction in screen space
- The screen-space drag direction corresponds to a rotation axis in 3D
Simpler:
- If face normal is along X (left/right face), rotating the layer means rotating around X axis
- The direction of drag along Y or Z determines the direction
- Need to convert screen drag to world-space drag direction
Let me use a approach where:
- Get the face normal in world space
- The rotation axis is perpendicular to both the face normal and the camera's view direction
- Rotation direction is determined by drag direction projected onto the rotation axis
Actually, let me think about this more carefully. In a Rubik's cube, if you click on the right face (normal +X), you can rotate the slice in two ways:
- Rotate around X axis (spin horizontally), which would also appear as rotating around X
- Or rotate around... wait no. If you click on the +X face, the only meaningful slice rotation is around the X axis. Drag left/right on that face corresponds to checking if the drag is around X or some other axis.
Hmm, let me reconsider. The standard way Rubik's cubes work in software:
- Click on a face, then drag in a direction perpendicular to that face's normal
- The drag direction determines which face plane you're dragging on, which gives you the rotation axis
For the +X face:
- Drag up/down → rotates around Z axis (vertical on face = rotation in YZ plane)
- Drag left/right → rotates around Y axis (horizontal on face = rotation in XY plane)
Wait, that's the standard. Let me verify: On the right face of a Rubik's cube, if you drag up, the top row of that face moves left, which is a rotation around the Z axis. Yes.
So algorithm:
1. Raycast, find face of cubie and its normal N in world space
2. Determine which side of N the face is on (sign of N)
3. Conert mouse drag from screen space to world space direction D (projected onto plane perpendicular to camera)
4. Find rotation axis: the axis in world space that is perpendicular to both N and D
5. More precisely: rotation axis = N × D (cross product), but we snap to one of the cube axes (X, Y, Z)
6. Determine direction based on sign of D projected onto rotation axis
Actually simpler: since every Rubik's cube rotation is around one of the 3 axes, and the face normal tells us which set of cubies to rotate, we can determine the rotation axis by:
- The face normal N tells us the position of the slice
- The drag direction in screen space maps to a direction perpendicular to N
- The cross product of N and D gives us the rotation axis direction
Let me simplify further. Since the cube is axis-aligned:
- The face normal is always along one of ±X, ±Y, ±Z (approximately, after camera rotation the normals rotate with the cubies... wait no, the outer faces' normals are in the cubie's local space but after all rotations the positions get updated)
Hmm, the tricky part is that after rotating slices, the cubies move and rotate, so their face normals change. I need to track the logical position of each cubie, not its visual orientation.
Better approach: track each cubie's abstract position (x, y, z) as integers -1, 0, 1. The visual mesh can be the cubie with its local face colors, but after a rotation I update the logical position and reorient.
Actually, let me use an even cleaner architecture:
- Each cubie is a THREE.Group (or Mesh) at an integer position
- Family is tracked by its world-space logical position
- Cubies have their 6 face colors as materials
- When rotating a face (e.g., rotating all cubies where x=1 around X axis by 90°), I:
1. Create a pivot at origin
2. Add those cubies as children of pivot
3. Animate pivot rotation
4. Update each cubie's position (and world matrix)
5. Detach cubies from pivot, attach to scene, update world matrices
6. Update logical position tracking
For face detection on click: I need to know which logical face was clicked. After rotations, the cubies have been reoriented. So I should check the intersection face normal in the cubie's local space.
Let me think about how to structure each cubie:
Each cubie is a box mesh. I'll use 6 materials (one per face). But since each cubie needs different colors per face, and each material is used for one face of one cubie... that's 27 cubies × 6 faces = 162 materials. That's a lot but handles it.
Actually easier: each cubie is a group containing 6 small plane meshes (or a single box with 6 materials). With 27 cubies, using BoxGeometry with material array.
For colors:
- outer +Y = white
- outer -Y = yellow
- outer -X = orange
- outer +X = red
- outer +Z = green
- outer -Z = blue
- inner faces = black (or dark gray)
I'll use MeshStandardMaterial with the color array.
For orbit camera, I'll write simple orbit controls:
- mousedown + drag → rotate camera around origin (spherical coordinates)
- mousedown on cube → start face rotation instead
The challenge: distinguishing "orbit the camera" drag from "rotate a cube face" drag.
Solution:
- If mouse down is ON the cube (raycaster hits), and then drags significantly, do face rotation
- If mouse down is NOT on the cube, do orbit rotation
- If mouse down is on cube but drag is small or situations differ, could orbit... but simpler: hit cube = face rotation drag, no hit = camera orbit
For middle mouse or if not over cube, orbit. Actually let me make it:
- Left click on cube → face rotation
- Left click on background (or hold shift) → orbit camera
- Middle mouse drag → orbit camera
- Scroll → zoom
Or even simpler for UX:
- Left click+drag on cube face → rotate that face
- Left click+drag on empty background → orbit camera
- Scroll → zoom
Let me code this up.
Let me plan:
```
index.html:
- <style>: full page, no scroll, centered canvas if needed
- <script src="three.min.js">
- <script>: main app code
- Constants for colors
- Scene, camera, renderer setup
- Orbit camera implementation
- Create 27 cubies
- Raycaster for click detection
- Face rotation logic
- Animation
- UI: scramble button
Cubie structure:
- Each cubie is { group (THREE.Group), x, y, z }
- The group is at integer position (x,y,z) in the 3x3x3 grid
- Contains a box mesh with 6 colored materials based on position
Face rotation:
- On mousedown, raycast to find intersected face
- Record: cubie, faceIndex, faceNormal (world space)
- On mousemove, compute drag direction
- Determine rotation axis (one of world X, Y, Z)
- Determine rotation angle sign
- Collect all cubies in that slice
- Create pivot, animate, update positions
After rotation of cubies, need to update x,y,z for each cubie and also potentially rotate their materials if we want turns on other faces. Actually since cubies move together and rotate as a group, the materials rotate with them automatically. We just need to update the abstract positions (x, y, z).
For the pivot rotation:
- Create a pivot Object3D at origin
- Add all cubies in slice to pivot
- Animate rotation
- After animation, for each cubie:
- Get its new world position, snap to integer
- Update cubie's x, y, z
- Remove from pivot, add to scene
- Apply world transform to local transform
Actually after removing from pivot to scene, positions reset. Need to:
- For each cubie in slice, compute its new world transform (rotation + translation)
- Remove from pivot, add to scene
- Apply world matrix to local (scene) transform
- Then round positions to integers
Let me think about this more carefully.
After pivot rotation (say 90° around X):
```javascript
pivot.updateMatrixWorld();
for each child cubie in pivot:
cubie.updateMatrixWorld();
// detach
scene.attach(cubie); // this preserves world transform while changing parent
cubie.position.x = Math.round(cubie.position.x);
cubie.position.y = Math.round(cubie.position.y);
cubie.position.z = Math.round(cubie.position.z);
cubie.updateMatrix();
```
scene.attach() is a Three.js method that's very useful for this.
Then update the cubie's logical position.
Actually, I realize scene.attach might not exist in older Three.js versions. three.min.js could be an older version. Let me check what version it might be... actually the user just says three.min.js is in the current directory. I'll just use it and check if attach exists, or implement manually.
Manual attach:
```javascript
// Convert world transform to local in new parent
function detachKeepWorld(object, newParent) {
const worldPos = new THREE.Vector3();
const worldQuat = new THREE.Quaternion();
const worldScale = new THREE.Vector3();
object.getWorldPosition(worldPos);
object.getWorldQuaternion(worldQuat);
object.getWorldScale(worldScale);
newParent.attach(object);
// Wait, attach() should do this. If not available:
object.position.copy(worldPos);
object.quaternion.copy(worldQuat);
object.scale.copy(worldScale);
}
```
OK let me just assume THREE.js modern enough. Let me proceed.
For detection of rotation axis from drag:
- Get intersection face normal N_world (transformed to world space)
- Get camera direction C_world
- Get drag direction in world space D (projected onto view plane)
Actually simpler approach:
- N_face is the face normal in world space
- N_face is approximately one of: ±X, ±Y, ±Z
- The excluded axis: idx = argmax(abs(N_face)) gives us which axis the normal is along
Wait, let me think. If I click on a face whose normal is +X, then the cubies being rotated are those with x = 1. The rotation axis is a vector perpendicular to +X, i.e., lies in the YZ plane. The screen-space drag direction maps to either Y or Z rotation.
Hmm, this is getting complex. Let me use a proven algorithm:
1. Click face, get face center in world space and face normal in world space
2. on drag, compute screen delta → world delta
3. Axis = N_face × world_delta (cross product). This gives a vector perpendicular to both, normalized to nearest cardinal direction.
4. Axis will be one of World X, Y, or Z (±)
5. Rotate all cubies whose position on axis = the slice coordinate
Let me verify: If I click +X face, N = (1,0,0). Drag up on screen → world_delta ≈ (0, 1, 0). Axis = N × D = (1,0,0) × (0,1,0) = (0,0,1). So rotation around Z. Correct!
Drag left on screen → world_delta ≈ (-1, 0, 0)... wait that would give axis near 0. Hmm, that's not right because dragging left on the +X face should rotate around Y.
Actually, after clicking on +X face, dragging left means world_delta is approximately (0, 0, -1) because we're looking at the +X face... not necessarily. The screen direction maps to world direction depending on camera angle.
Let me recode. The standard approach is:
On mousedown on a face:
- Record: faceNormal (world), facePoint (world), cubie
- Determine slice: the axis perpendicular to faceNormal (the position on faceNormal's axis determines which slice)
- Wait, if faceNormal = (1,0,0) and cubie is at x=1, then we're rotating the x=1 slice
- Generally slice axis is the direction of faceNormal, slice value is cubie's coordinate along that axis
- Hmm, but cubie could be at x=-1 and clicked on a left face → rotating x=-1 slice. That makes sense.
Actually: faceNormal indicates which outer face we clicked. Parity of normal sign determines slice.
- Face normal (+1, 0, 0) and cubie at (1,y,z) → rotating x=1 slice
- Face normal (-1, 0, 0) and cubie at (-1,y,z) → rotating x=-1 slice
- Generally the slice is the cubie's position on the axis aligned with the face normal
On drag:
- Use the face center as reference
- Project screen drag to world-space drag vector
- Compute cross product of faceNormal with drag vector → this is the rotation axis
- Normalize the axis to one of (±X, ±Y, ±Z)
- Direction is determined by dot product of drag with axis (or use right-hand rule)
Let me code this up. I'm going to write the full thing now.
```javascript
const COLORS = {
right: 0xb90000, // +X red
left: 0xff5900, // -X orange
top: 0xffffff, // +Y white
bottom:0xffd500, // -Y yellow
front: 0x009e60, // +Z green
back: 0x0051ba, // -Z blue
inner: 0x111111, // inner black
};
```
Each cubie has its box mesh with material array:
- materials[4] = +X
- materials[5] = -X (Wait, BoxGeometry face order is +X, -X, +Y, -Y, +Z, -Z)
THREE.BoxGeometry materials: right, left, top, bottom, front, back ← matching my COLORS exactly.
For each cubie at position (x,y,z):
- If x === 1, materials[0] = red
- If x === -1, materials[1] = orange
- etc.
- If x !== ±1, materials[0 and 1] = black
For click detection:
- Use Raycaster with mouse coords
- Raycast against all cubie meshes
- Get intersected face via `intersection.face.normal` → this is in local space
- Transform to world: `normal.applyQuaternion(mesh.getWorldQuaternion(new THREE.Quaternion()))`
Wait, `intersection.face` has `normal` (local) and `materialIndex`. With a BoxGeometry and material array, each face has a materialIndex that corresponds to the face direction (0=+X, 1=-X, 2=+Y, etc.).
Actually, for determining world-space face normal from BoxGeometry: the face normal in world space is the local face normal transformed by the mesh/cubie's world rotation.
Let me use: `intersection.face.normal.clone().applyQuaternion(intersection.object.getWorldQuaternion(new THREE.Quaternion())).normalize()`
OK let me start writing the full HTML.
I'll need: OrbitControls? Let me implement simple orbit.
Camera orbit implementation:
- θ (azimuth/polar) and φ (elevation) angles, radius R
- mousedown → start orbit if not on cube
- mousemove while down → update θ, φ
- Wheel → change R
Since we're inside the cube (roughly), camera moves around origin.
For the scramble button, I'll animate random rotations quickly.
For the face rotation animation:
- Use requestAnimationFrame
- Track state: isAnimating, current animation
- If animating, ignore input
Let me think about how to update cubie positions efficiently.
Each active cubie: `{ mesh: THREE.Mesh, group: parent object, x, y, z }`
After rotation:
1. Get all cubies in slice
2. Create pivot (THREE.Group) at origin
3. `pivot.rotation.set(...);` and `pivot.updateMatrixWorld()`
4. For each cubie in slice:
- On cubie mesh's parent chain, detach and attach to scene keeping world transform
- After attaching to scene, round positions to integers, update cubie.x/y/z
Actually wait, each cubie is just a mesh attached directly to the scene (or, during animation, to a pivot). Let me use this pattern:
```javascript
const cubies = []; // Array of { mesh, x, y, z }
function createCubies() {
for (let x = -1; x <= 1; x++) {
for (let y = -1; y <= 1; y++) {
for (let z = -1; z <= 1; z++) {
const materials = buildMaterials(x, y, z);
const geo = new THREE.BoxGeometry(0.93, 0.93, 0.93);
const mesh = new THREE.Mesh(geo, materials);
mesh.position.set(x, y, z);
scene.add(mesh);
cubies.push({ mesh, x, y, z });
}
}
}
}
function buildMaterials(x, y, z) {
return [
x===1 ? COLORS.right : COLORS.inner,
x===-1 ? COLORS.left : COLORS.inner,
y===1 ? COLORS.top : COLORS.inner,
y===-1 ? COLORS.bottom : COLORS.inner,
z===1 ? COLORS.front : COLORS.inner,
z===-1 ? COLORS.back : COLORS.inner,
];
}
```
When rotating slice around X by 90° clockwise (when looking from +X):
- New positions: (x, y, z) → (x, z, -y)
- Sign: positive rotation when looking from +X toward origin corresponds to y→z, z→-y (right-hand rule: thumb along +X, fingers curl from +Y toward +Z)
Let me verify: +X direction, thumb pointing +X, fingers curl from +Y to +Z. So +Y becomes +Z, +Z becomes -Y.
(x,y,z) → (x, z, -y). Yes.
For -90° around X (or -90° clockwise from +X view):
(x,y,z) → (x, -z, y)
Rotation around Y by +90° (looking from +Y):
(x,y,z) → (z, y, -x)
Rotation around -Y by +90°:
(x,y,z) → (-z, y, x)
Rotation around Z by +90° (looking from +Z toward origin):
(x,y,z) → (-y, x, z)
Now for the animation, I need to:
1. Determine slice (e.g., all cubies with x=1), axis (X), angle (Math.PI/2 or -PI/2)
2. Create pivot at origin
3. Add all slice cubies to pivot
4. Animate pivot rotation
5. After animation, for each cubie:
- `scene.attach(cubie.mesh)` — wait, this attaches relative to scene preserving world transform
- Actually the mesh's current parent is pivot, so changing to scene should keep world transform
- Then snap positions and update x/y/z
Actually, `scene.attach(cubie.mesh)` should work. Let me verify it exists. Yes, THREE.Object3D has `.attach()` in modern Three.js. It does world transform preservation.
Hmm but if Three.js version is older (e.g., before r58), attach might not exist. Let me write a helper:
```javascript
function detachKeepWorld(obj) {
const parent = obj.parent;
if (!parent) return null;
const worldPos = new THREE.Vector3();
const worldQuat = new THREE.Quaternion();
const worldScale = new THREE.Vector3();
obj.getWorldPosition(worldPos);
obj.getWorldQuaternion(worldQuat);
obj.getWorldScale(worldScale);
parent.remove(obj);
// Scene is scene globally
scene.attach(obj); // Or manually add and update
return worldPos;
}
```
Hmm, if attach doesn't exist, manual version:
```javascript
function moveToParent(obj, newParent) {
// Get world transform
const worldMat = new THREE.Matrix4();
obj.updateMatrixWorld();
worldMat.copy(obj.matrixWorld);
// Get inverse of new parent world matrix
newParent.updateMatrixWorld();
const invParent = new THREE.Matrix4().copy(newParent.matrixWorld).invert();
// New local matrix = invParent * worldMat
const localMat = new THREE.Matrix4().multiplyMatrices(invParent, worldMat);
obj.matrix.copy(localMat);
obj.matrix.decompose(obj.position, obj.quaternion, obj.scale);
// Reparent
if (obj.parent) obj.parent.remove(obj);
newParent.add(obj);
}
```
Alright. Let me also think about the face rotation animation code. Since I want buttery animation, I'll use a manual animation driver with lerp or tweening.
```javascript
let animation = null;
function rotateSlice(axis, slice, angle, duration = 200) {
if (animation) return; // blocked
animation = { axis, slice, targetAngle: angle, elapsed: 0, duration, currentAngle: 0 };
}
function animateSlice(dt) {
if (!animation) return;
animation.elapsed += dt;
let t = Math.min(1, animation.elapsed / animation.duration);
// Ease in-out
t = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
animation.currentAngle = animation.targetAngle * t;
// Apply to pivot
pivot.rotation.set(0, 0, 0);
if (animation.axis === 'x') pivot.rotation.x = animation.currentAngle;
if (animation.axis === 'y') pivot.rotation.y = animation.currentAngle;
if (animation.axis === 'z') pivot.rotation.z = animation.currentAngle;
pivot.updateMatrixWorld();
if (animation.elapsed >= animation.duration) {
// Finalize
pivot.rotation.set(0, 0, 0);
for (const cubie of sliceCubies) {
moveToParent(cubie.mesh, scene);
cubie.mesh.position.x = Math.round(cubie.mesh.position.x);
cubie.mesh.position.y = Math.round(cubie.mesh.position.y);
cubie.mesh.position.z = Math.round(cubie.mesh.position.z);
cubie.x = cubie.mesh.position.x;
cubie.y = cubie.mesh.position.y;
cubie.z = cubie.mesh.position.z;
}
pivot.rotation.set(0, 0, 0); // clear
animation = null;
}
}
```
Wait, I need `sliceCubies` to be accessible after animation. I'll store it on animation.
Also I need to handle the case where I'm animating rotation around an axis that's not the world axis (but cube is axis-aligned always because pivot rotates around world axis, so it's fine).
Now for determining the rotation axis and direction from drag, when all cubies share a world-aligned orientation:
Hmm, after multiple rotations, the cubies are rotated in world space (e.g., their local axes point in different directions), but the logical positions maintain a grid. The outer faces of cubies closest to each cardinal direction are what we see.
For click detection, the face normal in world space should be in whatever direction the face points. After rotation, the face with e.g., red material (+X in local) might point in some rotated world direction... no wait. When we animate the rotation, we're rotating cubies in world space. The materials rotate with the cubie mesh. So after several turns, a cubie that was originally at position (1,1,1) with red on +X might now have red pointing in some world direction.
But the important thing: the face that was clicked has a world-space normal. Its normal indicates which logical face it is (which direction from the cube center it's pointing). From that, we derive:
- Which cubie was clicked (to determine the slice position along faceNormal axis)
- The face normal itself tells us the rotation axis is perpendicular to it (any axis in the plane of the face acts as rotation axis, determined by drag)
Let me code the drag → rotation axis conversion:
```javascript
const N = faceNormal.clone().normalize(); // world-space face normal
const drag = worldDrag.clone().normalize(); // world-space drag direction
const axis = new THREE.Vector3().crossVectors(N, drag).normalize();
// Snap to nearest axis
const signX = axis.x > 0 ? 1 : -1;
const absX = Math.abs(axis.x);
const signY = axis.y > 0 ? 1 : -1;
const absY = Math.abs(axis.y);
const signZ = axis.z > 0 ? 1 : -1;
const absZ = Math.abs(axis.z);
let snappedAxis;
if (absX > absY && absX > absZ) {
snappedAxis = { axis: 'x', dir: 1 };
} else if (absY > absX && absY > absZ) {
snappedAxis = { axis: 'y', dir: 1 };
} else {
snappedAxis = { axis: 'z', dir: 1 };
}
// dir determined by dot product of drag with snapped axis direction
// e.g., if snapped axis is +X, dot(drag, (1,0,0)) > 0 means positive rotation
```
Hmm let me reconsider the rotation direction. For a Rubik's cube, if I click the +X face and drag up, the top row of the X=1 slice should move toward -Z (from viewer's perspective on +X face). With right-hand rule, this is a rotation around +Z that moves +Y toward +Z, which is a negative rotation around Z? Let's think.
Looking down +X (from positive X toward origin):
- Y axis points up
- Z axis points right (or left?)
- Standard right-hand rule: thumb +X, index +Y, middle +Z
Wait, in Three.js default coordinate system, looking from +X toward origin, +Y is up and +Z is... hmm it depends on the view. Three.js is right-handed. If I look from +X toward origin, then +Y is up, and +Z is... to my left (because if I extend thumb +X, index +Y, middle points +Z which from that viewpoint is to the left).
If I drag "up" (toward +Y) with mouse on +X face, I want the topmost row (y=max in that face's space) to rotate toward... well, in Rubik's cube the top of the right face moves away from me. That's -Z direction. So the top moves in -Z direction, which in terms of right-hand rotation is negative rotation around Z (where +Z is "into the right face").
Actually I think the convention is: twisting the outermost layer clockwise (when looking at it) means positive rotation. Let me just set it up and tweak signs if needed.
Let me now code this up. I'll implement:
1. Scene with lighting
2. 27 cubies
3. Orbit camera (right-click or background drag)
4. Click face to drag rotate
5. Scramble button
For orbit, I'll use left drag on background, right drag on cube face for face rotation... no let's keep it simple:
- Click + drag on CUBE = face rotation
- Click + drag on EMPTY = orbit camera
- Scroll = zoom
For rotations, I'll round the cubie positions to integers to avoid floating point drift.
Let me also think about parent chain. Each cubie mesh is directly under scene. After rotations, I detach and reattach.
I realize a potential issue: if a cubie has moved far and been reattached, its world quaternion has the rotation baked in. The face normals in world space (from intersection.face.normal applied to world quat) should still work.
Let me also smooth out rounding—after a rotation, the cubie's position might be (0.999, 1.001, -1.0) due to floating point. I round to (1, 1, -1). Each cubie tracks its integer x/y/z in cubie object. After reattach, I update x/y/z.
```javascript
const cubies = [];
function createCubies() {
for (let x = -1; x <= 1; x++) {
for (let y = -1; y <= 1; y++) {
for (let z = -1; z <= 1; z++) {
const materials = [
new THREE.MeshPhongMaterial({ color: x===1?COLORS.right:COLORS.inner, flatShading:true }),
...
];
...
}
}
}
}
```
Let me think about responsiveness. The cube is 3x3x3 units with gaps. So it spans from -1.5 to +1.5 in each direction approximately. Camera at distance ~6.
Let me also add rounded edges. Actually, for simplicity, box geometry is fine. But real Rubik's cube has rounded inner corners. I could use RoundedBoxGeometry but that requires extra code. Let me just use regular boxes with slight bevel look - actually the 0.93 size with black gaps looks decent.
Let me also think about the colors. Standard Rubik's cube colors:
- White (U), Yellow (D), Blue (F), Green (B), Orange (L), Red (R)
- Or: White = U, Yellow = D, Red = F, Orange = B, Blue = R, Green = L
I'll use:
- +Y = White (U)
- -Y = Yellow (D)
- +X = Red (R)
- -X = Orange (L)
- +Z = Green (F)
- -Z = Blue (B)
These are the WCA standard:
White opposite Yellow, Red opposite Orange, Blue opposite Green.
Wait, WCA standard is:
- White opposite Yellow
- Red opposite Orange
- Green opposite Blue
And when looking at white on top, green faces front, red faces right. So:
- U = +Y = White
- D = -Y = Yellow
- R = +X = Red
- L = -X = Orange
- F = +Z = Green (or -Z depending on convention)
- B = -Z = Blue
I think +Z = Green, -Z = Blue is fine.
Let me also make the scene look nice with:
- A gradient background (or dark)
- Good lighting (ambient + directional)
- A center or just the cube
- Axis indicator maybe
For the orbit controls, I need to convert mouse deltas to spherical angle changes.
```javascript
// Orbit state
let theta = Math.PI / 4; // azimuthal
let phi = Math.PI / 3; // elevation from +Y
let radius = 5;
function updateCamera() {
const x = radius * Math.sin(phi) * Math.cos(theta);
const y = radius * Math.cos(phi);
const z = radius * Math.sin(phi) * Math.sin(theta);
camera.position.set(x, y, z);
camera.lookAt(0, 0, 0);
}
// On mouse drag (not on cube):
function onMouseMoveOrbit(dx, dy) {
theta -= dx * 0.005;
phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi + dy * 0.005));
updateCamera();
}
```
For zoom: `radius = Math.max(2, Math.min(20, radius - dy * 0.01));`
Now let me write the full code. I need to be careful with the face click detection.
For each cubie mesh, the mesh has materials array. Each face of BoxGeometry is a triangle face. face.normal is the local normal. Since each box face has consistent normals (e.g., right faces all have normal (1,0,0) local), the intersection.face.normal tells us the local face.
However, for material indices: intersection.face.materialIndex gives us which side (0-5). This can be directly used to know which were clicked.
For world-space normal, the local normal rotated by the mesh's world orientation.
```javascript
const localNormal = intersection.face.normal.clone();
const worldNormal = localNormal.applyQuaternion(mesh.getWorldQuaternion(new THREE.Quaternion()));
```
Wait I need to be careful. `intersection.face.normal` - is this a THREE.Vector3? Yes. But the geometry might or might not have indexed faces. BoxGeometry has non-indexed BufferGeometry.
For BoxGeometry, faces are triangles, and each face's normal points outward. Face index maps to the box side. The materialIndex for a BufferGeometry in Three.js corresponds to the color/material.
To get the actual face direction (which face of the cube was hit), I can use `intersection.face.materialIndex` which for BoxGeometry corresponds to: 0=+X, 1=-X, 2=+Y, 3=-Y, 4=+Z, 5=-Z.
I can use this to determine world normal without computing the quaternion:
```javascript
const matIdx = intersection.face.materialIndex;
const localNormals = [
new THREE.Vector3(1, 0, 0), // +X
new THREE.Vector3(-1, 0, 0), // -X
new THREE.Vector3(0, 1, 0), // +Y
new THREE.Vector3(0, -1, 0), // -Y
new THREE.Vector3(0, 0, 1), // +Z
new THREE.Vector3(0, 0, -1), // -Z
];
const worldNormal = localNormals[matIdx].clone().applyQuaternion(mesh.getWorldQuaternion(new THREE.Quaternion())).normalize();
```
Now for the drag:
```javascript
let dragStart = null;
let clickedFace = null; // { mesh, worldNormal, worldPoint, cubie }
let isDragging = false;
function onMouseDown(e) {
if (animating) return;
const mouse = getNDC(e);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(allCubieMeshes);
if (intersects.length > 0) {
// Start face rotation
const hit = intersects[0];
const matIdx = hit.face.materialIndex;
const worldNormal = localNormals[matIdx].clone().applyQuaternion(hit.object.getWorldQuaternion(new THREE.Quaternion()));
const worldPoint = hit.point.clone();
clickedFace = {
mesh: hit.object,
worldNormal,
worldPoint,
cubie: findCubie(hit.object),
};
dragStart = { x: e.clientX, y: e.clientY };
} else {
// Start orbit
orbitStart = { x: e.clientX, y: e.clientY };
clickedFace = null;
}
}
function onMouseMove(e) {
if (clickedFace) {
if (!isDragging) {
const dx = e.clientX - dragStart.x;
const dy = e.clientY - dragStart.y;
if (dx*dx + dy*dy > 25) {
isDragging = true;
lastMouse = { x: e.clientX, y: e.clientY };
}
}
if (isDragging) {
const dx = e.clientX - lastMouse.x;
const dy = e.clientY - lastMouse.y;
lastMouse = { x: e.clientX, y: e.clientY };
// Convert screen delta to world delta
// Use camera's right/up vectors projected on viewing plane
const forward = camera.getWorldDirection(new THREE.Vector3());
const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0,1,0)).normalize();
const up = new THREE.Vector3().crossVectors(right, forward).normalize();
// World drag: dx along right * (-1 for screen-to-world x flip), dy along up * (-1 for screen-to-world y flip)
const worldDX = -dx * 0.01;
const worldDY = -dy * 0.01;
const worldDrag = new THREE.Vector3().copy(right).multiplyScalar(worldDX).add(up.clone().multiplyScalar(worldDY));
// Axis = N × worldDrag
const axisVec = new THREE.Vector3().crossVectors(clickedFace.worldNormal, worldDrag).normalize();
// Snap to nearest cardinal axis
const abs = [Math.abs(axisVec.x), Math.abs(axisVec.y), Math.abs(axisVec.z)];
const maxIdx = abs.indexOf(Math.max(...abs));
const axisNames = ['x', 'y', 'z'];
const axis = axisNames[maxIdx];
// Direction: dot of worldDrag with axis direction
const dirSign = (maxIdx === 0 ? axisVec.x : maxIdx === 1 ? axisVec.y : axisVec.z) > 0 ? 1 : -1;
// Now determine the angle
// When the user moves mouse, rotate slice by current drag delta projected
// Better: use total drag to compute desired rotation angle
// We want to rotate continuously as user drags
// Use the sign and accumulate angle
const sign = dirSign; // tweak if needed
const angle = sign * (worldDrag.length()) * 0.5; // approximate
// Actually it's simpler: snap to the nearest 90° once the user has dragged enough
// or have fine rotation that snaps
// Let's do: determine target rotation based on sign, and have it be 90° snap
// But for real-time dragging feel, do incremental:
requestFaceRotationByDrag(axis, clickCubiePositionOnAxis, sign);
}
} else if (orbitStart) {
const dx = e.clientX - orbitStart.x;
const dy = e.clientY - orbitStart.y;
orbitStart = { x: e.clientX, y: e.clientY };
theta -= dx * 0.005;
phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi + dy * 0.005));
updateCamera();
}
}
```
Hmm this is getting complicated. Let me think of a more robust way.
Alternative approach for face rotation: instead of incremental, when drag starts on a face:
1. Determine slice axis and rotation axis from face normal and screen direction (once, at start)
2. The drag magnitude determines the total angle wanted (or snap once user drags enough)
3. For smooth feel, animate from 0 to whatever
Actually, the simplest robust algorithm:
- As user drags, project drag onto two possible directions on the face
- The one that dominates determines rotation axis
- The accumulated drag in that direction determines the angle
Even simpler: use a "magnet" that snaps to nearest 90° but tracks where we started.
I'll use this: once the user starts dragging on a face, compute the intended rotation axis from the initial face normal + current drag direction. Then accumulate angle from drag delta. Initialize angle at 0 when starting drag, animate pivot to current angle, then on mouse up snap to ±90°.
Let me code this:
```javascript
let dragState = null;
function startFaceDrag(faceInfo) {
dragState = {
cubie: faceInfo.cubie,
faceNormal: faceInfo.worldNormal.clone(),
pivotAxis: null, // determined on first significant drag
pivotPos: null, // e.g., 1 for x=1 slice
angle: 0, // current accumulated angle (radians)
};
}
function updateFaceDrag(dx, dy) {
if (!dragState) return;
// Convert screen delta to world
const forward = camera.getWorldDirection(new THREE.Vector3());
const right = new THREE.Vector3().crossVectors(new THREE.Vector3(0,1,0), forward).normalize();
const up = new THREE.Vector3().crossVectors(forward, right).normalize();
const worldDrag = new THREE.Vector3()
.addScaledVector(right, dx * 0.005)
.addScaledVector(up, dy * 0.005);
if (!dragState.pivotAxis) {
// Determine rotation axis
const cross = new THREE.Vector3().crossVectors(dragState.faceNormal, worldDrag);
const abs = [Math.abs(cross.x), Math.abs(cross.y), Math.abs(cross.z)];
const idx = abs.indexOf(Math.max(...abs));
const axisNames = ['x', 'y', 'z'];
dragState.pivotAxis = axisNames[idx];
// Determine slice position (cubie coordinate along faceNormal axis)
const normalAxisMap = { 'x': 0, 'y': 1, 'z': 2 };
const normalComps = [dragState.faceNormal.x, dragState.faceNormal.y, dragState.faceNormal.z];
const normalIdx = [0,1,2][abs.indexOf(Math.max(...abs.map(Math.abs)))];
dragState.normalAxis = ['x','y','z'][normalIdx];
dragState.sliceValue = dragState.cubie[dragState.normalAxis];
// Determine rotation direction sign (clockwise when facing the face)
// We'll snap based on where user drags; sign is determined by dot product
}
// Accumulate angle
const sign = getSignForDrag(worldDrag);
const delta = sign * 0.05; // magnitude depends on drag
dragState.angle += delta;
// Apply
renderDrag();
}
function renderDrag() {
if (!dragState || !dragState.pivotAxis) return;
pivot.rotation.set(0,0,0);
pivot.rotation[dragState.pivotAxis] = dragState.angle;
pivot.updateMatrixWorld();
}
```
This is getting complicated for the helper. Let me step back and simplify.
Idea: For face rotation, just detect which face was clicked, rotate by 90° in the direction the user dragged. Don't do incremental per-pixel. Use thresholds.
When mouse is clicked on face:
1. Trace intersection, get face normal in world space
2. As user drags, determine if drag is along one of the two "natural" directions perpendicular to face normal
3. Once drag above threshold (say 50 pixels), commit to that axis and direction
4. Complete the rotation to 90° (animate)
5. Sign: rotate 90° based on drag direction
Let me sketch:
```javascript
let pendingRotation = null; // { axis, slice, angle }
function onMouseDown(e) {
// ...
// click on cube → set faceDrag info
faceDrag = { mesh: hit.object, normal: worldNormal, point: worldPoint, cubie: cubie };
}
function onMouseMove(e) {
if (faceDrag && !pendingRotation) {
const dx = e.clientX - startClient.x;
const dy = e.clientY - startClient.y;
// Convert to actually rotated screen space (account for camera)
// Just using height/width ratio is fine for threshold
// Merge angles:
// Normal in screen space: project normal to screen
// Drag 1 perpendicular to normal project 1 (candidate dir 1)
// Opposite dir 2
// Whichever is closer to drag makes the choice
// Simpler: convert dx, dy to rotated world-space as I thought before
// crossN(D) gives axis
// then sign based on dot product
}
}
```
Let me simplify even more. I'll use a world-space delta but with a direct geometric projection.
```javascript
function screenDeltaToWorld(dx, dy) {
// Project screen direction to world, plane-aligned
const v = new THREE.Vector3(-dx, dy, 0); // y up, x right in screen (Three.js screen coords)
v.applyQuaternion(camera.quaternion); // transform into world space - but screen is not exactly this
// Actually camera quaternion aligns local axes with world...
// Camera's local +Z points in -view direction... this is tricky.
// Simpler: compute right/up of camera
const camRight = new THREE.Vector3();
const camUp = new THREE.Vector3();
camera.matrixWorld.extractBasis(camRight, camUp, new THREE.Vector3());
return camRight.multiplyScalar(-dx * scale).add(camUp.clone().multiplyScalar(dy * scale));
}
```
Hmm, in Three.js camera coordinates, moving mouse right moves against camera's lateral axis. Let's just use:
```javascript
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
const worldDrag = right.clone().multiplyScalar(-dx * factor).add(up.clone().multiplyScalar(-dy * factor));
```
Wait, screen-right is along camera's +X (right vector of camera), screen-up is along camera's +Y. Dragging right maps to camera +X direction in world. So `worldDrag = right * dx + up * dy` with positive factor. Or perhaps flip y since screen y goes down but world y goes up.
Let me use: `worldDrag = right.clone().multiplyScalar(dx).add(up.clone().multiplyScalar(-dy));` (negate y because screen y increases downward).
But the factor depends on zoom. Let me use a fixed factor like 0.005.
Let me also note: if I use right-hand rule for face rotation:
- Face normal N, drag D
- Axis = N × D
- Sign = how much D aligns with Axis direction... wait.
For positive rotation (right-hand rule), thumb along axis, fingers curl from N toward... hmm.
Let me just test empirically. I'll pick a convention and if the face rotates opposite to what I expect, I'll multiply by -1.
OK let me draft the full code. I'll try to make it clean.
Actually let me think about pivot rotation update. I want to rotate one layer at a time, and the layer is a set of cubies.
For the slice to rotate:
- `normalAxis`: the axis perpendicular to the face normal
- `sliceValue`: e.g., 1 if facenormal = +X, -1 if facenormal = -X
- `pivotAxis`: the in-plane rotation axis determined by drag (X, Y, or Z)
For animation:
```javascript
function rotateSliceLayer(normalAxis, sliceValue, pivotAxis, angle, duration = 200) {
if (animation) return;
const members = cubies.filter(c => c[normalAxis] === sliceValue);
if (members.length === 0) return;
pivot.rotation.set(0, 0, 0);
scene.add(pivot);
for (const c of members) pivot.attach(c.mesh);
pivot.updateMatrixWorld();
animation = {
axis: pivotAxis,
startAngle: 0,
endAngle: angle,
elapsed: 0,
members,
duration,
};
}
```
Hmm wait, but we need to know which cubies are in the slice for logical position tracking. The cubies' x,y,z properties track that as integers based on initial position. After rotation, we snap and update.
Hold on - should I also consider performance? 27 cubies × 6 materials = 162 materials. That's fine.
For the scramble button, I'll loop 20 times and rotate random faces.
```javascript
function scramble(count = 20) {
if (animating) return;
const axes = ['x', 'y', 'z'];
const slices = [-1, 0, 1];
let moves = 0;
const total = count;
function nextMove() {
const axis = axes[Math.floor(Math.random() * 3)];
const slice = slices[Math.floor(Math.random() * 3)];
const dir = Math.random() > 0.5 ? 1 : -1;
// compute angle in world:
// rotate around axis by +90 means right-hand rule
const angle = dir * Math.PI / 2;
rotateSliceLayer(
axis, slice, axis, angle, 80,
() => { moves++; if (moves < total) nextMove(); }
);
}
nextMove();
}
```
Wait, rotateSliceLayer syntax:
```javascript
function rotateSliceLayer(normalAxis, sliceValue, pivotAxis, angle, duration, onDone) {
```
For a standard face: if I want to rotate the x=1 face, normalAxis='x', sliceValue=1. The rotation is around X axis: pivotAxis='x'.
For a standard face: if I want to rotate the y=-1 face (bottom), the rotation is around Y axis: pivotAxis='y'.
For a standard face: if I want to rotate the z=1 face (front), the rotation is around Z axis: pivotAxis='z'.
For x=1 face, normalAxis='x', sliceValue=1, pivotAxis='x'.
For y=-1 face, normalAxis='y', sliceValue=-1, pivotAxis='y'.
For z=1 face, normalAxis='z', sliceValue=1, pivotAxis='z'.
OK so in all these cases pivotAxis = normalAxis.
Now for face rotation by drag:
Face was clicked with world normal N (say +X).
N defines normalAxis = 'x', sliceValue = 1.
Drag determines pivotAxis and sign.
If drag is along Y (up/down), pivotAxis = 'z' and sign tells direction.
If drag is along Z (left/right), pivotAxis = 'y' and sign tells direction.
Let me verify this from my earlier analysis:
- N = +X. Drag up in world: worldDrag = +Y.
- cross(N, D) = (1,0,0) × (0,1,0) = (0, 0, 1). pivotAxis = 'z'.
- This rotates x=1 slice around Z axis. Dragging up moves top row left, which is indeed rotation around Z axis letting +Y go to +X (from face perspective). ✓
- N = +X. Drag right in world: worldDrag = +Z (roughly for the face).
- Actually if right-handed system where +X right, +Y up, +Z toward viewer, then dragging right on +X face means moving left in screen which corresponds to... hmm.
Let me use concrete numbers. Looking at a standard scene with camera at (3, 2, 3) looking at origin. +X face on the right of the cube. Screen "right" direction corresponds to camera moving right, which is roughly -Z in world. So screen-right ≈ -Z. Screen-up ≈ +Y (roughly).
So on +X face:
- Drag up (screen up = world +Y): cross(N=+X, D=+Y) = +Z. Rotate around +Z. ✓
- Drag right (screen right = world -Z): cross(N=+X, D=-Z) = -Y. Rotate around -Y. ✓
OK that matches. Now for the sign of direction:
- Drag up → rotate around +Z. From the perspective of someone looking at +X face from +X direction, dragging up moves face contents toward -Z. That's clockwise (if we want clockwise = positive). Or counter-clockwise depending on convention.
For Rubik's cube: twisting with palm on face, thumb up (fingers curl)... whichever, let me just use the sign from the dot product. Let me pick a convention and if it's wrong, I'll flip signs.
Alright I think I've analyzed enough. Let me write the code.
One thing: after rotation I need to update cubie.x, y, z. Also after reattaching to scene, I need to round positions.
Also: the mesh's world position after reattachment might need to be rounded. The mesh is at (cubie.x, cubie.y, cubie.z) in world. After rotation, it's at a rotated position. Reattach keeps world transform, then I round.
```javascript
function finalizeRotation(members) {
for (const cubie of members) {
scene.attach(cubie.mesh); // keep world transform
const p = cubie.mesh.position;
cubie.x = Math.round(p.x);
cubie.y = Math.round(p.y);
cubie.z = Math.round(p.z);
cubie.mesh.position.set(cubie.x, cubie.y, cubie.z);
// Round quaternion to nearest 90° multiples
const q = cubie.mesh.quaternion;
// ... hmm this is tricky, but cubies that rotate should land on 90° increments
// Since all our rotations are 90°, q should be integer multiples of 90°.
// But floating point drifts. Let me snap.
}
}
```
For snapping rotation: each cubie's rotation should be exactly π/2 multiples. After moving to scene with world transform preserved, the rotation is the accumulated rotation. We can snap it or just trust it's clean. Since Three.js uses euler internally by default, and we only rotate by π/2 on integer axes, the result should be clean. Let's see: `euler.setFromQuaternion(q)` and it might have floating point drift. Hmm.
Actually, each cubie after reattach has a quaternion. The quaternion might be off by tiny epsilons. That's fine for display.
Let me also add: after finalize, the cubies.x/y/z might drift slightly from snap (e.g., 0.999). Rounding fixes.
Let me also consider that after rotations, we might want to update the cubie's materials to match its new logical faces... wait, no. The materials are on the cubie's mesh and rotate with it. The red face stays on +X local regardless of where the cubie moves. After reattachment, the cubie's mesh has been reoriented. The red face now points in whatever world direction +X rotated to. That's fine.
Now, let me add a nice UI:
- Scramble button at top-left
- Maybe "Reset" button
- A title
- Styling with frosted glass/neumorphism
The HTML:
- full screen canvas
- overlay UI
- button for scramble
Let me now write the full thing. I'll keep the code clean.
Wait, I should check that three.min.js exists in the current directory first.I'll build the full Rubik's Cube app. Let me verify the Three.js file is present first.
{"command":"ls -la three.min.js"}-rw-r--r-- 1 node node 603445 Jul 22 17:56 three.min.js
thinking
Good, three.min.js is present. Now I'll write the complete index.html.
Let me plan the complete architecture:
1. Scene setup
2. Lighting (ambient + directional)
3. Build 27 cubies with face colors based on position
4. Camera controls (orbit via mouse drag when not on cube)
5. Face rotation on drag
6. Animation system
7. Scramble button
8. UI styling
Let me write clean, well-organized JS.
For the face drag → rotation axis:
- On mousedown on cubie, record worldNormal and cubie info
- On mousemove, get world-space drag direction
- axisVec = cross(N_world, worldDrag).normalize()
- Snap axisVec to nearest (x, y, or z)
- The direction (+/-) of the axis gives sense
- Set pivotAxis and slice
For animation of full 90°:
- Once drag is committed by threshold, start rotating slice to 90°
- Or just rotate to the accumulated angle then snap to nearest 90° on mouse up
Actually let me use the simpler approach: when user drags past threshold, start animation to rotate by ±90°. The sign determines which way. On animation, rotate pivot smoothly. On complete, detach cubies and update logical positions.
Let me code this carefully.
```javascript
// Rotation logic helpers
function getSliceAxisValueFromNormal(normal) {
// The face normal points outward. The axis it aligns with is normalAxis, value is ±1.
// For Rubik, exactly one component of normal will be near ±1.
const map = [
{ n: 'x', v: normal.x },
{ n: 'y', v: normal.y },
{ n: 'z', v: normal.z },
];
map.sort((a, b) => Math.abs(b.v) - Math.abs(a.v));
return { axis: map[0].n, value: Math.round(map[0].v) };
}
```
Then for click:
```
const sliceInfo = getSliceAxisValueFromNormal(worldNormal); // e.g., { axis: 'x', value: 1 }
```
This is the normalAxis and sliceValue.
For drag:
- face normal
- determine pivot axis from drag
- sign from direction of drag
Let me also think: after face rotation, when we reattach cubies, the mesh's world position might be e.g., (0.9999, 1.001, -1.0001). Rounding to integer is good.
For the animation callback, we need to know which cubies were in the slice for position updating.
Let me structure the animation:
```javascript
let activeSlice = null;
let animState = null;
function commitRotation(sliceAxis, sliceValue, pivotAxis, targetAngle) {
if (animState) return;
const members = cubies.filter(c => c[sliceAxis] === sliceValue);
if (!members.length) return;
pivot.rotation.set(0,0,0);
scene.add(pivot);
members.forEach(c => pivot.attach(c.mesh));
pivot.updateMatrixWorld();
pivot.rotation[pivotAxis] = 0; // just to be sure at start
animState = {
axis: pivotAxis,
angle: targetAngle,
elapsed: 0,
duration: 150,
members,
};
activeSlice = null;
}
function tickAnimation(dt) {
if (!animState) return;
animState.elapsed += dt;
let t = Math.min(1, animState.elapsed / animState.duration);
// ease in-out
t = t < 0.5 ? 2*t*t : -1+(4-2*t)*t;
pivot.rotation[animState.axis] = t * animState.angle;
pivot.updateMatrixWorld();
if (animState.elapsed >= animState.duration) {
pivot.rotation[animState.axis] = 0;
for (const c of animState.members) {
scene.attach(c.mesh);
const p = c.mesh.position;
c.x = Math.round(p.x);
c.y = Math.round(p.y);
c.z = Math.round(p.z);
c.mesh.position.set(c.x, c.y, c.z);
}
pivot.rotation.set(0,0,0);
animState = null;
}
}
```
For mouse drag commit:
```javascript
if (!faceDrag) return;
faceDrag.accumulatedDist += dragMag;
if (Math.abs(faceDrag.accumulatedDist) > 30 && !committed) {
// commit
const worldDrag = ...;
const axisVec = cross(N, worldDrag).normalize();
// snap
let pivotAxis, pivotSign;
if (Math.abs(axisVec.x) > Math.abs(axisVec.y) && Math.abs(axisVec.x) > Math.abs(axisVec.z)) {
pivotAxis = 'x'; pivotSign = axisVec.x > 0 ? 1 : -1;
} else if (Math.abs(axisVec.y) > Math.abs(axisVec.x) && Math.abs(axisVec.y) > Math.abs(axisVec.z)) {
pivotAxis = 'y'; pivotSign = axisVec.y > 0 ? 1 : -1;
} else {
pivotAxis = 'z'; pivotSign = axisVec.z > 0 ? 1 : -1;
}
const targetAngle = pivotSign * (faceDrag.accumulatedDist > 0 ? 1 : -1) * Math.PI/2;
commitRotation(sliceInfo.axis, sliceInfo.value, pivotAxis, targetAngle);
committed = true;
}
```
Wait, but the drag direction and axis direction need to be reconciled. Let me think about sign.
Suppose face normal N = +X (right face).
Drag up (screen) → worldDrag ≈ +Y.
cross(N, D) = cross((1,0,0),(0,1,0)) = (0,0,1). pivotAxis = 'z', pivotSign = +1.
So rotate around +Z by +π/2. From observer looking from +X (right side), +Z is pointing to the left (when using right-hand rule with +X thumb, fingers curl from +Y to +Z). Rotating +π/2 around +Z means +Y goes to +Z. From the viewer's perspective on +X face, the top of the cube (which faces +Y) moves toward the viewer, which is the "outward" direction... Actually no, from +X viewpoint, +Z is to the left. So +Y→+Z means the top goes to the left. That's a clockwise rotation from the viewer's perspective. OK that seems right: dragging up on the right face rotates the layer clockwise. ✓
Now check: drag right on +X face. worldDrag ≈ -Z.
cross((1,0,0), (0,0,-1)) = (0*(-1) - 0*0, 0*0 - 1*(-1), 1*0 - 0*0) = (0, 1, 0). pivotAxis = 'y', pivotSign = +1.
Rotate around +Y by +π/2. Looking from +Y down, +Y is down toward origin, +X is to the right... let's just accept this.
Sign: pivotSign > 0 → positive angle, < 0 → negative angle.
Now does dragging right on the +X face produce the intuitive result? Dragging right makes the rightmost column of the face move toward viewer (or toward back). Hmm, let me accept it and check by eye.
Actually, let me think once more. The overall approach:
1. On mousedown on cube, store the face info (cubie, faceNormal world, facePoint world).
2. On mousemove, if not yet committed:
- Convert screen delta to world delta.
- cross(N, worldDelta) gives rotation axis direction (with sign).
- Normalize to nearest axis.
- Determine target angle = ±π/2 based on direction of drag.
But direction (sign) matters. The sign of the target angle:
- If the user drags up on +X face, I want positive +Z rotation, which is +π/2.
- cross gave us +Z (positive pivotSign).
- target angle should be +π/2. ✓
If user drags right on +X face:
- worldDrag = -Z (assuming standard camera).
- cross = +Y, pivotSign = +1.
- Rotate +Y by +π/2. ✓ seems consistent.
Let me just code as positive pivotSign → +π/2, and trust the right-hand rule.
Now for the cube styling: I'll make gaps between cubies, and maybe inner faces have black color. Each cubie has small size (0.9 or 0.93) with 0.1 gap? Actually total span from -1.5 to 1.5 with cubes at -1, 0, 1 size 0.9 means gaps of 0.1 on each side → each cube is 0.9×0.9×0.9.
Actually 3 cubies × 0.9 = 2.7, but span from center is 1. Cubies at -1, 0, 1 with size 0.9 means the inner gap between faces is 0.1 at most. Actually the cube at -1 has left face at -1-0.45=-1.45 and right At -1+0.45=-0.55. Cube at 0 has left at -0.45 and right at +0.45. Cube at 1 has left at 0.55 and right at 1.45. Gap between cube at -1 right face and cube at 0 left face: -0.55 to -0.45 → gap 0.1. OK.
So use size 0.9. Actually standard is 0.95 with rounded look. Let me use 0.9 to make gaps obvious (black inner edges visible).
Now let me code the full thing.
For edge bevel/rounded corners: Three.js doesn't support this natively for BoxGeometry. A real Rubik's cube has rounded corners. I can simulate this by placing smaller cubes with bevel boxes, but that's complex. I'll keep it boxy but with good colors. Or I can use a custom rounded box geometry (somewhat involved).
Actually, I'll add a slight visual polish: each cubie has inset face planes (a thinner box on each face) to give a slight bevel/rim look. But that's very complex. Skip.
Let me keep it simple: plain box geometry, nice colors.
Lighting:
- Ambient for base illumination
- Directional from top-right-front
- Another directional from bottom-left
Camera start position: (4, 3, 5) looking at origin.
Let me also think about the raycaster. For rotation-in-progress, the meshes are attached to pivot. When raycasting, can intersectObjects traverse through pivot to children? Yes, recursive raycaster with scene.traverse or overriding... actually raycaster.intersectObjects(array, recursive=true) should find grandchildren.
I should keep a flat array of all cubie meshes for raycasting. But during animation, meshes are reparented to pivot. Still, they have their own world transforms. As long as I pass `recursive: true`, the raycaster should find them.
Actually, if I do `raycaster.intersectObjects(cubieMeshes, true)` and meshes are nested under pivot → scene, the intersections will work.
OK let me also think about what happens if user mouses down on empty space during animation. That should be ignored.
Let me also handle touch events for mobile. I'll add touchstart, touchmove, touchend mapping to mouse equivalents.
Actually the problem says "clickable face rotations" and "orbit-drag camera"—I'll add mouse events. Mobile can be extended but isn't strictly required.
Now for the UI. A scramble button in bottom-right or top-left. Let me put top-left. Maybe with a nice frosted glass style.
```html
<div id="ui">
<h1>Rubik's Cube</h1>
<button id="scrambleBtn">Scramble</button>
<button id="resetBtn">Reset</button>
</div>
```
And for instruction text:
```html
<p class="hint">Click + drag cube faces to rotate · Drag background to orbit · Scroll to zoom</p>
```
OK, let me write the full code. It's going to be long.
I need to be careful with:
- Updating cubie logical x/y/z after rotation
- Clearing pivot rotation after animation
- Multiple scrambles don't stack-trap
Let me also add a right-click to re-orient? No, orbit already covers this.
For the scramble, I'll call rotateSliceLayer many times in chain, with a small delay between.
```javascript
function scrambleSequence(count = 25) {
if (animState || isScrambling) return;
isScrambling = true;
let i = 0;
const axes = ['x', 'y', 'z'];
const slices = [-1, 0, 1];
function step() {
if (i >= count) {
isScrambling = false;
return;
}
const axis = axes[i % 3];
const slice = slices[Math.floor(Math.random() * 3)];
const dir = Math.random() > 0.5 ? -1 : 1;
const angle = dir * Math.PI / 2;
rotateSliceLayer(axis, slice, axis, angle, 80, () => {
i++;
setTimeout(step, 30);
});
}
step();
}
```
Let me refine rotateSliceLayer:
```javascript
function rotateSliceLayer(normalAxis, sliceValue, pivotAxis, targetAngle, duration, onDone) {
// Actually: rotate by exactly targetAngle from current
if (animState) { if (onDone) setTimeout(onDone, 0); return; }
const members = cubies.filter(c => c[normalAxis] === sliceValue);
if (!members.length) { if (onDone) onDone(); return; }
pivot.rotation.set(0, 0, 0);
scene.add(pivot);
members.forEach(c => pivot.attach(c.mesh));
pivot.updateMatrixWorld();
animState = {
axis: pivotAxis,
startAngle: 0,
targetAngle,
elapsed: 0,
duration: duration || 200,
members,
onDone,
};
}
```
For the animation tick: interpolate from 0 to targetAngle.
Wait, what if the angle should be interpreted correctly with sign? If targetAngle is -π/2, that works naturally.
OK let me now write. Some edge cases:
- When cubie reattached to scene, if we use `scene.attach(mesh)`, it should preserve world transform. But Three.js THREE.Object3D has.attach() which handles this. If it doesn't exist, I'll fall back.
- I should ensure `cubies` stays in sync.
Let me code everything:
```javascript
// === STATE ===
const cubies = [];
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true });
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// Orbit state
let theta = Math.PI / 3;
let phi = Math.PI / 3;
let radius = 6;
let orbitDrag = null;
// Face rotation state
let faceDrag = null; // { cubie, normal, point, worldMouseStart }
let isRotating = false; // drag turned into rotation
// Animation state
let animState = null;
let isScrambling = false;
// Pivot
const pivot = new THREE.Object3D();
function initScene() { ... }
function buildCubies() { ... }
function onPointerDown(e) { ... }
function onPointerMove(e) { ... }
function onPointerUp(e) { ... }
function commitRotation(sliceAxis, sliceValue, pivotAxis, targetAngle, duration, onDone) { ... }
function tickAnim(dt) { ... }
function tickOrbit() { ... }
function scramble(n) { ... }
function animate() { ... }
```
One thing to keep in mind: Three.js version in three.min.js might not have Object3D.attach (added r52). Let me assume it does. If not, I'll use my own helper.
```javascript
function keepWorldAttach(obj, newParent) {
if (scene.attach) {
scene.attach(obj);
} else {
const wPos = new THREE.Vector3();
const wQuat = new THREE.Quaternion();
const wScl = new THREE.Vector3();
obj.getWorldPosition(wPos);
obj.getWorldQuaternion(wQuat);
obj.getWorldScale(wScl);
if (obj.parent) obj.parent.remove(obj);
newParent.add(obj);
obj.position.copy(wPos);
obj.quaternion.copy(wQuat);
obj.scale.copy(wScl);
}
}
```
Actually, `scene.attach` might not exist on the scene itself but on the Object3D class. Let me check: yes, `THREE.Object3D` has `.attach(object)`.
Wait no, `Object3D.attach` attaches `object` as child of this, preserving world transform.
Usage:
```javascript
scene.attach(cubie.mesh);
// OR
pivot.attach(cubie.mesh);
```
OK now, when cubies are in pivot and I want to move back to scene:
```javascript
scene.attach(cubie.mesh);
```
Perfect.
Let me also consider: during animation, the cubies' parent is pivot. When raycasting, they're reachable. But during orbit (no cubies in pivot), all cubies are in scene.
Another consideration: when we reattach to scene, the cubie mesh might have a rotation that's e.g., (0, π/2, 0) in euler. That's fine for rendering. The next rotation via pivot should be clean.
Let me also think about: after a rotation, the cubie's local position gets moved to world position and back. Its local quaternion is now the world quaternion (since new parent is scene whose world quaternion is identity). So the mesh has a "baked-in" rotation. Next time we attach to pivot, that rotation is preserved and pivot rotates it more. Good.
But the position snapping: after scene.attach, position is the world position. We round and reapply. But rounding after scene.attach doesn't change the world position because scene's transform is identity. So `mesh.position.set(cubie.x, cubie.y, cubie.z)` sets local = world. Good.
One more thing: if we do `pivot.attach(cubie.mesh)`, cubie's world position is preserved. After pivot rotates, the mesh's world position follows. Good.
Now let me also handle: when user rotates a face, the cubies in the slice are attached to pivot. But the user should not be able to start a new rotation on the same cubies that are animating. The `animState` flag blocks new rotations.
However, the user should be able to orbit. So during animState, only orbit works and faceDrag should be ignored (or we block pointerdown).
Let me add: if animState or isScrambling, don't start face rotation. But we can still orbit.
For the mouse events, I'll listen on window.
Let me also add: after mouse up (canceling face drag before commit), reset state.
OK. Now one more consideration: let's make the transition between being able to start faceDrag and orbitDrag clean. If mousedown finds a cubie intersection, enter faceDrag mode. If not, enter orbit mode. If mousemove hasn't crossed threshold before mouseup, it's a click (no action).
Let me also add: allow drag > 30° rotation on face, and if released before 30°, cancel.
Let me now write the HTML.
Animation loop:
- requestAnimationFrame
- tickOrbit for smooth camera (if orbit in progress)
- tickAnimation
- renderer.render
Actually, tickOrbit should just update camera based on spherical coords (theta, phi, radius). And tickAnimation updates pivot.
Let me now finalize the code. I'll use:
- MeshPhongMaterial with flatShading: true for flat faces (like a real Rubik's cube with visible facets).
- Actually standard material with flatShading looks too "low-poly". Let me use MeshStandardMaterial or MeshPhongMaterial with flatShading: false (smooth) or flatShading: true. Either works - a cube has perfect flat shading naturally if the geometry has no vertex normals shared between faces. BoxGeometry has separate normal attributes per face by default, so flat shading looks like standard blocky cube. So MeshStandardMaterial with flatShading: false is OK (default). I'll use MeshPhongMaterial flatShading for speed.
Let me check: THREE.js standard BoxGeometry uses each face with separate triangles, with normals per vertex (each triangle has 3 vertices with same normal). So even without flatShading:true, the faces look flat. So flatShading flag doesn't matter.
Let me use MeshStandardMaterial with roughness around 0.8 and metalness 0 for plastic-like look. Basic shading is fine.
Actually MeshPhongMaterial is faster and looks good.
Let me add stage:
- Background color: dark
- Maybe a grid floor? No, keep it minimal.
- Ambient + directional lights.
- Enable renderer.setPixelRatio for retina.
Now let me code it out. I think I've planned enough. Time to write it.
Actually wait, one more thing. For the World Delta from screen delta, I want to account for the depth of the dragged object. Since we want to measure rotation direction, not a 3D offset, plane-of-view projection is sufficient. So just projecting the screen delta onto the camera's local XY plane works.
```javascript
function getWorldDrag(dx, dy) {
// Approx: screen movement maps to camera-local movement
// RHS Three.js: camera looks at -Z. Right is +X, Up is +Y.
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
return right.clone().multiplyScalar(dx * 0.005).add(up.clone().multiplyScalar(dy * 0.005));
}
```
Hmm, the factor 0.005 is arbitrary. Let me tune to feel responsive.
Actually I realize screen drag delta doesn't naturally map to world rotation delta with a fixed factor. The relationship depends on camera distance and FOV. For responsiveness at any zoom, the factor should consider both distance and FOV.
But simpler: the face rotation is always ±90°. So it'll snap. I need to detect direction primarily, not rotate proportionally. So factor isn't critical - any factor that makes cross(N, worldDrag) capture the right axis works.
Let me use 0.01.
Actually for the drag threshold: 30 pixels screen movement is typical. With factor 0.01, that's worldDrag length 0.3.
N = ±1. cross(N, worldDrag) has length |worldDrag|. That's enough to determine axis direction.
Let me just use factor = 0.005.
One more thing: the cube needs a background. Let's set scene.background to a soft color or use CSS background for the canvas, and make canvas transparent.
For the UI, I want:
- Clean modern style
- Frosted glass effect for a floating panel
- Scramble and Reset buttons
- Instruction hint
Colors: I'll use a dark theme.
Alright, writing code now.
Actually I just realized: after `pivot.attach(cubie.mesh)`, cubie.mesh might still have its initial rotation. The reattachment preserves world rotation. OK.
But wait: during rotation animation, we're rotating pivot, not moving cubies. After animation, when we detach from pivot to scene, the mesh has a new world rotation (world quat of cubie becomes the combined rotation of original quat + pivot rotation). Then we round position but keep rotation as-is. That rotation is exactly the original cubie orientation rotated by a multiple of 90°. Visually, the cubies' faces might point in weird directions after many turns. That's correct for a Rubik's cube.
But the click detection uses worldNormal = localNormal rotated by world quat. That correctly determines which logical face we clicked.
Great.
Now let me also think: should I use flatShading: false (default) or true? BoxGeometry has all its own normals. flatShading only matters for geometry where faces share vertices but have different normals. For box, faces have their own vertices. Either way looks blocky. I'll leave out the flag.
Now starting to write. This will be large.
```javascript
(function() {
// Generate HTML output
})();
```
No, I'll just use plain JS in the script tag.
One more consideration: on window resize, update camera and renderer.
```javascript
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
```
Alright, here we go writing the file. It'll be around 500-700 lines.
Let me write more precisely.
For the cross product and snapping, I'll define helper:
```javascript
function cross(a, b) {
return new THREE.Vector3(
a.y*b.z - a.z*b.y,
a.z*b.x - a.x*b.z,
a.x*b.y - a.y*b.x
);
}
```
Now let me think about the drag direction more precisely.
Given mousedown on face and current pointer position:
- Δscreen = (dx, dy) in pixels
- worldDrag = right_cam * (-dx) * scale + up_cam * dy * scale (note: screen x increases right, screen y increases down; right_cam is from origin pointing to right of screen)
Wait. If mouse moves right by dx > 0, in world that aligns with camera's right vector pointing toward the right side of the view. So we want worldDrag.x to be positive for dx > 0. Thus `right.clone().multiplyScalar(dx * scale)`. And `up.clone().multiplyScalar(-dy * scale)` because screen y down corresponds to world negative y.
```javascript
function screenToWorldDrag(dx, dy) {
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
const scale = 0.005;
return right.clone().multiplyScalar(dx * scale).add(up.clone().multiplyScalar(-dy * scale));
}
```
Cross(N, worldDrag):
- N is world normal of clicked face (unit vector)
- worldDrag is a small vector
- Their cross product has magnitude |N||worldDrag|sinθ ≈ |worldDrag| × angle_between_N_and_worldDrag
- The direction of cross product is the rotation axis
Let me test cases once more:
- Camera roughly at (4,3,5) looking at origin. +X face right side of cube.
- Its normal N ≈ (1,0,0) in world.
- Drag up: worldDrag is roughly +Y. cross = (1,0,0)×(0,1,0) = (0,0,1). Rotation axis +Z. ✓
- Drag right: worldDrag is roughly -X? No, from that camera angle, screen right is not -X. Let me compute.
- Camera position dir = (4,3,5)/norm ≈ (0.5077, 0.3808, 0.7715).
- Up is (0,1,0). Right = forward × up = cross(fwd, up).
- fwd = (-0.5077, -0.3808, -0.7715).
- cross(fwd, up) = cross((-0.5, -0.38, -0.77), (0,1,0)) = ((-0.38*0 - (-0.77)*1, (-0.77)*0 - (-0.5)*0, (-0.5)*1 - (-0.38)*0)) = (0.77, 0, -0.5).
- So screen-right ≈ (0.77, 0, -0.5). Normalized ≈ (0.84, 0, -0.61).
- Drag right on +X face: worldDrag ≈ (0.84, 0, -0.61). cross(N=+X, worldDrag) = cross((1,0,0),(0.84,0,-0.61)) = (0×(-0.61)-0×0, 0×0.84-1×(-0.61), 1×0-0×0.84) = (0, 0.61, 0). Rotation axis +Y. ✓ Dragging right in that camera angle results in rotating around Y axis.
OK.
Now for the sign of pivotSign:
- cross gave (0, 0.61, 0) → positive Y → pivotSign = +1. So targetAngle = +π/2.
- Rotating x=1 slice around Y by +π/2.
From +Y above, looking down: +X goes to +Z (right-hand rule: thumb +Y, +X curled to +Z). For the slice x=1, that means the layer rotates such that its +X face goes to +Z direction... wait, I'm confusing myself.
Actually easier: with positive rotation around Y by π/2, a point at (1, y, z) transforms to (z, y, -1). (+X goes to +Z's coordinate... no wait: (x,y,z) → (z,y,-x). So (1,y,z) → (z,y,-1). A cubie at (1,0,0) goes to (0,0,-1). OK so the +X face of the cube moves to -Z from +X's view. Whatever.
The point is: from the viewer on +X, dragging right should produce intuitive rotation. +π/2 might or might not be intuitive. Let me think:
On the +X face, from viewer perspective:
- +Y is up (roughly), +Z is to the left (-1 cross right-hand gives... actually for +X viewer, +Y up, +Z to viewer's left (=world +Z)
- Dragging right in screen = roughly -Z in world.
- The crossed axis is +Y. Positive rotation around +Y (right-hand, thumb +Y) curls +X toward +Z.
- From +X viewer: this is a counter-clockwise rotation of the face (going from +Y up toward +Z left).
Is that what the user expects when dragging right on +X face? Probably yes—pushing the face to the right makes the right side come forward and left side go back. That's counter-clockwise. OK.
Actually: on a real Rubik's cube, you'd push on the face. Dragging right means the face moves right. Pushing the right side of the right face makes it rotate... hmm. It depends on how you visualize it.
You know what, I'll just pick a convention and if the sign is wrong it can be flipped. The code is straightforward to flip.
OK let me write the full code now.
One last thing: the raycaster for mouse picking. Use 3D position and NDC mouse.
```javascript
function getIntersection(event) {
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left)/rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top)/rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
return raycaster.intersectObjects(cubieMeshes, true);
}
```
The `true` for recursive ensures we hit meshes nested under pivot.
Now let me write!
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rubik's Cube</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0f172a;
font-family: 'Segoe UI', system-ui, sans-serif; color: white; }
canvas { display: block; }
#ui {
position: fixed;
top: 20px;
left: 20px;
background: rgba(15, 23, 42, 0.65);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 16px;
padding: 20px;
min-width: 180px;
box-shadow: 0 10px 40px rgba(0,0,0,0.35);
}
#ui h1 { font-size: 18px; font-weight: 600; margin-bottom: 4px; letter-spacing: 0.3px; }
#ui .sub { font-size: 11px; color: #94a3b8; margin-bottom: 12px; }
button {
display: block;
width: 100%;
padding: 10px;
margin-top: 8px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.06);
color: #e2e8f0;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
button:hover { background: rgba(255,255,255,0.12); transform: translateY(-1px); }
button:active { transform: translateY(0); }
.hint {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: #64748b;
pointer-events: none;
background: rgba(15,23,42,0.4);
padding: 6px 14px;
border-radius: 999px;
}
</style>
</head>
<body>
<div id="ui">
<h1>Rubik's Cube</h1>
<div class="sub">3×3×3 Interactive</div>
<button id="scrambleBtn">⚡ Scramble</button>
<button id="resetBtn">↺ Reset</button>
</div>
<div class="hint">Drag on faces to twist · Drag background to orbit · Scroll to zoom</div>
<script src="three.min.js"></script>
<script>
(() => {
// === CONSTANTS ===
const COLORS = {
right: 0xb71234,
left: 0xff5800,
top: 0xffffff,
bottom: 0xffd500,
front: 0x009b48,
back: 0x0046ad,
inner: 0x1a1a1a,
};
// Right +X, Left -X, Top +Y, Bottom -Y, Front +Z, Back -Z
const FACE_MAP = {
'+x': COLORS.right, '-x': COLORS.left,
'+y': COLORS.top, '-y': COLORS.bottom,
'+z': COLORS.front, '-z': COLORS.back,
};
// Material for each face.
// For a cubie at (x,y,z):
// +X face = right
// -X = left, +Y = top, -Y = bottom, +Z = front, -Z = back
function buildMaterials(x, y, z) {
const mk = (s) => new THREE.MeshStandardMaterial({
color: s === 'inner' ? COLORS.inner : (FACE_MAP['+'+s]),
roughness: 0.6,
metalness: 0.05,
});
return [
x === 1 ? mk('x') : mk('inner'),
x === -1 ? mk('x') : mk('inner'),
y === 1 ? mk('y') : mk('inner'),
y === -1 ? mk('y') : mk('inner'),
z === 1 ? mk('z') : mk('inner'),
z === -1 ? mk('z') : mk('inner'),
];
}
// === SCENE ===
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0f172a');
const camera = new THREE.PerspectiveCamera(42, window.innerWidth / window.innerHeight, 0.1, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
document.body.appendChild(renderer.domElement);
// Lighting
scene.add(new THREE.AmbientLight('#ffffff', 0.55));
const dirA = new THREE.DirectionalLight('#ffffff', 0.9);
dirA.position.set(5, 8, 5);
scene.add(dirA);
const dirB = new THREE.DirectionalLight('#aaccff', 0.35);
dirB.position.set(-5, -2, -5);
scene.add(dirB);
const dirC = new THREE.DirectionalLight('#ffffff', 0.25);
dirC.position.set(-2, 8, 4);
scene.add(dirC);
// === CUBIES ===
const cubies = []; // { mesh, x, y, z }
const cubieMeshes = []; // flat for raycasting
function buildCubies() {
// cleanup
cubies.forEach(c => {
if (c.mesh.parent) c.mesh.parent.remove(c.mesh);
});
cubies.length = 0;
cubieMeshes.length = 0;
const geo = new THREE.BoxGeometry(0.92, 0.92, 0.92);
for (let x = -1; x <= 1; x++) {
for (let y = -1; y <= 1; y++) {
for (let z = -1; z <= 1; z++) {
const mesh = new THREE.Mesh(geo, buildMaterials(x, y, z));
mesh.position.set(x, y, z);
scene.add(mesh);
const c = { mesh, x, y, z };
cubies.push(c);
cubieMeshes.push(mesh);
}
}
}
}
// === ROTATION ===
const pivot = new THREE.Object3D();
scene.add(pivot);
let animState = null;
let isScrambling = false;
// Y/Z treat +Z as shallow (front). Standard WCA orientation: white +Y, green +Z, red +X
function rotateSliceLayer(normalAxis, sliceValue, pivotAxis, targetAngle, duration, onDone) {
if (animState) {
// Queue later? For now, collapse.
setTimeout(() => onDone && onDone(), 0);
return;
}
const members = cubies.filter(c => c[normalAxis] === sliceValue);
if (!members.length) { if (onDone) onDone(); return; }
pivot.rotation.set(0, 0, 0);
pivot.updateMatrixWorld();
scene.add(pivot);
members.forEach(c => {
if (c.mesh.parent) c.mesh.parent.remove(c.mesh);
pivot.attach(c.mesh);
});
pivot.updateMatrixWorld();
animState = {
axis: pivotAxis,
targetAngle,
elapsed: 0,
duration: duration || 180,
members,
onDone,
};
}
function finalizeSliceRotation() {
if (!animState) return;
const { members, onDone } = animState;
for (const c of members) {
scene.attach(c.mesh);
const p = c.mesh.position;
c.x = Math.round(p.x);
c.y = Math.round(p.y);
c.z = Math.round(p.z);
c.mesh.position.set(c.x, c.y, c.z);
}
// Clear pivot transform
pivot.rotation.set(0, 0, 0);
pivot.updateMatrixWorld();
const done = onDone;
animState = null;
if (done) done();
}
function tickAnimation(dt) {
if (!animState) return;
const state = animState;
state.elapsed += dt;
let t = Math.min(1, state.elapsed / state.duration);
// easeInOutCubic
t = t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3) / 2;
pivot.rotation[state.axis] = t * state.targetAngle;
pivot.updateMatrixWorld();
if (state.elapsed >= state.duration) {
finalizeSliceRotation();
}
}
// === CAMERA ORBIT ===
let orbitDrag = null; // { startX, startY, startTheta, startPhi }
let radius = 6.2; // distance from origin
let phi = Math.PI / 3; // elevation
let theta = Math.PI / 4; // azimuth
function applyCamera() {
// Clamp phi to avoid flipping
phi = Math.max(0.05, Math.min(Math.PI - 0.05, phi));
const x = radius * Math.sin(phi) * Math.cos(theta);
const y = radius * Math.cos(phi);
const z = radius * Math.sin(phi) * Math.sin(theta);
camera.position.set(x, y, z);
camera.lookAt(0, 0, 0);
}
applyCamera();
function handleOrbitStart(sx, sy) {
orbitDrag = { startX: sx, startY: sy, startTheta: theta, startPhi: phi };
}
function handleOrbitMove(sx, sy) {
if (!orbitDrag) return;
const dx = sx - orbitDrag.startX;
const dy = sy - orbitDrag.startY;
theta = orbitDrag.startTheta - dx * 0.005;
phi = orbitDrag.startPhi + dy * 0.005;
applyCamera();
}
function handleOrbitEnd() {
orbitDrag = null;
}
function handleZoom(delta) {
radius = Math.max(2.8, Math.min(12, radius + delta * 0.01));
applyCamera();
}
// === FACE DRAG INTERACTION ===
const mouse = new THREE.Vector2();
const raycaster = new THREE.Raycaster();
let pointerDown = null; // null or "face" | "orbit"
let faceDragState = null;
let orbitState = null;
let uncommitted = {
startX: 0,
startY: 0,
totalDist: 0,
currentAxis: null,
currentSign: 0,
};
// Helper to get world normal from materialIndex
const LOCAL_NORMALS = [
new THREE.Vector3(1, 0, 0),
new THREE.Vector3(-1, 0, 0),
new THREE.Vector3(0, 1, 0),
new THREE.Vector3(0, -1, 0),
new THREE.Vector3(0, 0, 1),
new THREE.Vector3(0, 0, -1),
];
function getIntersection(event) {
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
return raycaster.intersectObjects(cubieMeshes, true);
}
function getCubieByMesh(mesh) {
return cubies.find(c => c.mesh === mesh);
}
function worldDragFromDelta(dx, dy) {
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
return right.clone().multiplyScalar(dx * 0.0035).add(up.clone().multiplyScalar(-dy * 0.0035));
}
function onPointerDown(e) {
if (animState) return;
e.preventDefault();
const isTouch = e.pointerType === 'touch';
const sx = e.clientX;
const sy = e.clientY;
const hits = getIntersection(e);
if (hits.length > 0) {
const hit = hits[0];
const mesh = hit.object;
const matIdx = hit.face.materialIndex;
const worldNormal = LOCAL_NORMALS[matIdx].clone().applyQuaternion(mesh.getWorldQuaternion(new THREE.Quaternion())).normalize();
const cubie = getCubieByMesh(mesh);
if (!cubie) return;
pointerDown = 'face';
faceDragState = {
cubie,
worldNormal,
normalAxis: ['x','y','z'][[Math.abs(worldNormal.x), Math.abs(worldNormal.y), Math.abs(worldNormal.z)].indexOf(Math.max(Math.abs(worldNormal.x), Math.abs(worldNormal.y), Math.abs(worldNormal.z)))],
sliceValue: Math.round(worldNormal[['x','y','z'][[Math.abs(worldNormal.x), Math.abs(worldNormal.y), Math.abs(worldNormal.z)].indexOf(Math.max(Math.abs(worldNormal.x), Math.abs(worldNormal.y), Math.abs(worldNormal.z)))]]),
};
uncommitted.startX = sx;
uncommitted.startY = sy;
uncommitted.totalDist = 0;
uncommitted.currentAxis = null;
uncommitted.currentSign = 0;
} else {
pointerDown = 'orbit';
orbitState = { startX: sx, startY: sy, startTheta: theta, startPhi: phi };
}
}
function onPointerMove(e) {
if (!pointerDown) return;
e.preventDefault();
const sx = e.clientX;
const sy = e.clientY;
if (pointerDown === 'orbit' && orbitState) {
handleOrbitMove(sx, sy);
} else if (pointerDown === 'face' && faceDragState) {
const dx = sx - uncommitted.startX;
const dy = sy - uncommitted.startY;
uncommitted.totalDist = Math.hypot(dx, dy);
if (!uncommitted.currentAxis && uncommitted.totalDist > 15) {
const wd = worldDragFromDelta(dx, dy);
const crossVec = new THREE.Vector3().crossVectors(faceDragState.worldNormal, wd).normalize();
const comps = [Math.abs(crossVec.x), Math.abs(crossVec.y), Math.abs(crossVec.z)];
const maxIdx = comps.indexOf(Math.max(...comps));
const axes = ['x','y','z'];
if (comps[maxIdx] < 0.4) return; // ambiguous, wait for more drag
uncommitted.currentAxis = axes[maxIdx];
uncommitted.currentSign = (crossVec[axes[maxIdx]] > 0 ? 1 : -1);
// Apply live rotation visualization
pivot.rotation.set(0,0,0);
pivot.rotation[uncommitted.currentAxis] = 0;
pivot.updateMatrixWorld();
}
if (uncommitted.currentAxis) {
// Live preview: rotate small amount proportional to mouse movement
// Use a live targetAngle based on drag progress
const wd = worldDragFromDelta(dx, dy);
const crossVec = new THREE.Vector3().crossVectors(faceDragState.worldNormal, wd).normalize();
const comps = [Math.abs(crossVec.x), Math.abs(crossVec.y), Math.abs(crossVec.z)];
const maxIdx = comps.indexOf(Math.max(...comps));
const axes = ['x','y','z'];
let sign = (crossVec[axes[maxIdx]] > 0 ? 1 : -1);
// make sure sign is consistent
if (uncommitted.currentAxis === axes[maxIdx]) {
sign = uncommitted.currentSign;
} else {
sign = uncommitted.currentSign;
}
// Track derivative for "magnet" snap
// Actually simpler: just set pivot rotation directly proportional to drag
const angle = sign * (uncommitted.totalDist * 0.01) * (uncommitted.currentAxis === axes[maxIdx] ? 1 : 1);
pivot.rotation.set(0,0,0);
pivot.rotation[faceDragState.normalAxis] = 0; // no axis use correct axis
pivot.rotation[uncommitted.currentAxis] = (angle / (Math.PI/2)) * (Math.PI/2);
pivot.updateMatrixWorld();
}
}
}
function onPointerUp(e) {
if (!pointerDown) return;
e.preventDefault();
if (pointerDown === 'orbit') {
orbitState = null;
} else if (pointerDown === 'face') {
if (uncommitted.currentAxis && !animState) {
// Commit
const sign = uncommitted.currentSign;
const angle = sign * Math.PI / 2;
rotateSliceLayer(
faceDragState.normalAxis,
faceDragState.sliceValue,
uncommitted.currentAxis,
angle,
180,
null
);
}
// reset pivot if not committed
pivot.rotation.set(0,0,0);
pivot.updateMatrixWorld();
}
pointerDown = null;
faceDragState = null;
uncommitted.currentAxis = null;
}
renderer.domElement.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
renderer.domElement.addEventListener('wheel', (e) => {
handleZoom(e.deltaY);
e.preventDefault();
}, { passive: false });
buildCubies();
applyCamera();
// === SCRAMBLE ===
function scramble(count = 25) {
if (animState || isScrambling) return;
isScrambling = true;
const axes = ['x', 'y', 'z'];
const slices = [-1, 0, 1];
let i = 0;
function step() {
if (i >= count) { isScrambling = false; return; }
const axis = axes[i % 3];
const slice = slices[Math.floor(Math.random()*3)];
const dir = Math.random() > 0.5 ? 1 : -1;
const angle = dir * Math.PI/2;
rotateSliceLayer(axis, slice, axis, angle, 60, () => {
i++;
setTimeout(step, 10);
});
}
step();
}
// === RESET ===
function resetCube() {
if (animState) {
// Quick force: clear pivot and rebuild
pivot.rotation.set(0,0,0);
pivot.updateMatrixWorld();
animState = null;
isScrambling = false;
}
pivot.rotation.set(0,0,0);
animState = null;
isScrambling = false;
buildCubies();
applyCamera();
}
document.getElementById('scrambleBtn').addEventListener('click', scramble);
document.getElementById('resetBtn').addEventListener('click', resetCube);
// === RESIZE ===
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// === LOOP ===
let lastTime = performance.now();
function loop() {
const now = performance.now();
const dt = Math.min(100, now - lastTime) / 1000;
lastTime = now;
tickAnimation(dt * 1000);
renderer.render(scene, camera);
requestAnimationFrame(loop);
}
loop();
})();
</script>
</body>
</html>
```
Hmm I realize there's a bug in my face drag handling. Let me re-examine.
Issue 1: I'm not handling the trigger-once-beyond-threshold and then ignore further changes in commit. Once threshold passed, I commit immediately (on that move event). The sign is fixed.
Issue 2: The "live preview" part where I set pivot rotation during drag is wrong because pivot's members haven't been populated yet. I need to attach cubies to pivot first for the drag preview.
Let me revise:
- On pointerdown on cube, no pivot usage. Just store state.
- On pointermove, when drag exceeds threshold:
- Determine axis and sign
- Commit rotation immediately (to 90°)
- Also: can't have live preview without starting a rotation.
Alternative: start a live rotation on threshold. That is, attach cubies to pivot and rotate pivot as the drag continues. On release (or when drag amount reaches 90°), finalize.
This is more complex but gives better UX. For the MVP, I'll go with: once drag threshold crossed, automatically start the rotateSliceLayer animation.
Simpler approach:
- Stage 1 (pre-threshold):他只是记录起始点和当前偏移
- Once dx²+dy² > 900: determine axis & sign by current drag → start rotateSliceLayer animation → user can't "cancel"
Actually, a very clean way: as soon as threshold crossed, start animation to rotate that slice. Direction is determined by the current drag direction. The animation will run to completion, so we don't need to track `currentAxis` or `currentSign` after that (except that we need to know which slice was committed so we don't double-commit).
Let me restructure:
```javascript
let pendingFace = null; // info for drag
let isDragging = false;
let rafReady = false;
function onPointerDown(e) {
if (animState) return;
// ...
}
function onPointerMove(e) {
if (!pointerDown) return;
if (pointerDown === 'orbit' && orbitState) { handleOrbitMove(e.clientX, e.clientY); return; }
if (pointerDown !== 'face' || !pendingFace) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const dist = Math.hypot(dx, dy);
if (dist > 25 && !isRotating) {
const wd = worldDragFromDelta(dx, dy);
const crossVec = new THREE.Vector3().crossVectors(pendingFace.worldNormal, wd).normalize();
const comps = [Math.abs(crossVec.x), Math.abs(crossVec.y), Math.abs(crossVec.z)];
const maxIdx = comps.indexOf(Math.max(...comps));
const axes = ['x','y','z'];
if (comps[maxIdx] < 0.4) return; // ambiguous
const axis = axes[maxIdx];
const sign = crossVec[axis] > 0 ? 1 : -1;
isRotating = true;
startFaceRotation(pendingFace.normalAxis, pendingFace.sliceValue, axis, sign * Math.PI/2, 180);
}
}
function onPointerUp(e) {
if (pointerDown === 'face' && !isRotating) {
// Was just a click, ignore
}
isRotating = false;
pointerDown = null;
pendingFace = null;
}
```
Hmm but if isRotating is set, we start the animation via `startFaceRotation` which sets `animState`. While animating, pointerdown returns early because `animState` is truthy.
After animation completes (finalizeSliceRotation sets animState = null), isRotating is still true. Should clear. Actually in onPointerUp we set isRotating = false and pendingFace = null. Good.
But what if user releases mouse while animation is still running? Then onPointerUp fires, isRotating = false, pendingFace = null. Animation continues in background. Good.
OK this is cleaner. But I lose the "live drag preview" during the threshold. That's acceptable.
Wait, there's another issue. During drag threshold, if animState was set by another mechanism (e.g., programmatic), the pointerdown returns early. That's fine.
Let me also think: if threshold event fires but animState is already set (shouldn't happen from animState cleared after animation). OK.
One more thing: with isRotating, we can only have one active drag → only one animation. But what if user mouses down on cube, drags, starts rotation, then before animation finishes tries again? The second pointerdown is ignored because animState still running. Good.
Let me now also think about a live preview while dragging. The threshold approach might feel sluggish without feedback. Better to add a tiny bit: once drag is large enough, attach cubies to pivot and rotate pivot proportionally (or with a spring). When release, snap to 90° or back to 0°.
Actually, many web Rubik's cubes do this:
1. On start drag: record
2. Any drag movement: attach cubies to pivot immediately, rotate pivot proportionally
3. When release or drag > 90°, snap to nearest 90°
This is great UX. Let me implement this.
Algorithm:
- pointerdown on cube: record state, don't attach yet
- pointermove:
- If not attached yet, compute cross(N, worldDrag).plane-normal → attach to pivot. Need to determine axis first, which requires some drag.
- Actually: attach on first move. The axis alone determines which cubies and which pivot direction. The angle is proportional to drag.
Wait, the axis is determined by drag direction perpendicularto face normal. So we can attach cubies to pivot and rotate, even before the drag is large.
Flow:
- pointerdown on cube → pendingFace stored.
- pointermove:
- compute worldDrag = from current drag delta
- cross(N, worldDrag) gives tentative axis
- If no axis committed yet (or if axis changes - unlikely), recompute:
- Candidates: any axis perpendicular to N
- Pick the one with largest component in cross(N, worldDrag)
- For that axis, determine sign
- Attach cubies to pivot if not already
- Set pivot rotation = drag_strength_in_axis * factor
- When pointerup: snap pivot rotation to nearest ±π/2 relative to 0, finalize.
But I need to know the "attachment" state. Let me use:
```javascript
let pivotMembers = [];
let pivotAxis = null; // 'x', 'y', or 'z'
function attachFaceToPivot(normalAxis, sliceValue, pivotAxisForThisSlice) {
pivot.rotation.set(0,0,0);
pivot.updateMatrixWorld();
pivotMembers = cubies.filter(c => c[normalAxis] === sliceValue);
if (pivotMembers.length) {
pivotMembers.forEach(c => pivot.attach(c.mesh));
scene.add(pivot);
}
pivotAxis = pivotAxisForThisSlice;
}
function detachFace() {
pivot.rotation.set(0,0,0);
pivot.updateMatrixWorld();
for (const c of pivotMembers) {
scene.attach(c.mesh);
const p = c.mesh.position;
c.x = Math.round(p.x);
c.y = Math.round(p.y);
c.z = Math.round(p.z);
c.mesh.position.set(c.x, c.y, c.z);
}
pivotMembers = [];
pivotAxis = null;
}
```
So in pointermove:
```javascript
if (pivotMembers.length === 0) {
// compute axis...
// attachFaceToPivot(normalAxis, sliceValue, pivotAxis)
}
// update pivot rotation based on current drag
```
When pointerup:
```javascript
if (pivotMembers.length) {
// Snap pivot rotation to nearest 90°
let angle = pivot.rotation[pivotAxis];
const target = Math.sign(angle) * Math.PI/2;
// But angle could be 0 or very small. If very small, don't rotate (cancel).
if (Math.abs(angle) > 0.15) {
// Animate to target.
animateToTarget(target);
} else {
detachFace(); // cancel
}
}
```
For snap animation: either use animateSlice which animates from current angle to ±π/2, OR we can reuse the animation system with targetAngle = target + offset... no my current animation system always animates from 0.
Modified: I'll add `animateToAngle(angle)` which starts from current angle and animates to target.
Or simpler: in onPointerUp with pending pivot rotation, just finalize with whatever the snap target is. Use my existing animation but startAngle at 0 and targetAngle = snap.
Wait, if the pivot is at 0.3 radians and I want to end at +π/2, but pivot is now at 0.3 (baked into mesh orientation), I could:
- Record the current pivot rotation as start offset
- Animate pivot from start to target
- On finish: detach and snap positions
Let me update:
```javascript
function snapFaceDrag() {
if (!pivotMembers.length || !pivotAxis) {
detachFace();
return;
}
const current = pivot.rotation[pivotAxis];
let target = Math.sign(current) * Math.PI / 2;
if (Math.abs(current) < 0.2) {
// Animate back to 0
target = 0;
}
if (animState) detachFace(); // fallback
pivot.rotation[pivotAxis] = 0; // reset but keep cubies attached (they retain world transform)
pivot.updateMatrixWorld();
animState = {
axis: pivotAxis,
startBase: 0, // starts at 0 for simplicity
targetAngle: target,
elapsed: 0,
duration: 120,
members: pivotMembers,
onDone: null,
};
pivotMembers = []; // consumed by anim onDone but not until finish
}
```
Hmm wait, when I set pivot.rotation[pivotAxis] = 0 and updateMatrixWorld, that reorients all cubies back. Then I animate from 0 to target. That's fine.
But I need to make sure the cubies are attached to pivot throughout.
Let me simplify and make a unified animation that can handle any pivot axial rotation to a target.
For the interaction phase:
- While dragging, just set pivot rotation directly (no animation).
- On release (before finalize):
- Decide whether to snap to +π/2, -π/2, or back to 0.
- Set animState to animate from current pivot rotation to target.
- On finish, finalize.
Actually even simpler:
- while dragging, adjust pivot rotation live based on drag.
- On release:
- If |pivot.rotation[axis]| > threshold: animate to nearest ±π/2 then finalize.
- Else: animate to 0 then detach.
Let me re-approach.
```javascript
let livePivot = {
active: false,
axis: null,
normalAxis: null,
sliceValue: 0,
angle: 0,
};
function updatePivotLive(dx, dy) {
if (!livePivot.active) {
// attach on first drag
const N = livePivot.worldNormal;
const wd = worldDragFromDelta(dx, dy);
const crossVec = new THREE.Vector3().crossVectors(N, wd).normalize();
const comps = [Math.abs(crossVec.x), Math.abs(crossVec.y), Math.abs(crossVec.z)];
const maxIdx = comps.indexOf(Math.max(...comps));
if (comps[maxIdx] < 0.3) return; // need more
const axes = ['x','y','z'];
livePivot.axis = axes[maxIdx];
livePivot.sign = crossVec[livePivot.axis] > 0 ? 1 : -1;
// Attach
livePivot.normalAxis = findAxis(N);
livePivot.sliceValue = Math.round(N[livePivot.normalAxis]);
pivot.rotation.set(0,0,0);
scene.add(pivot);
const members = cubies.filter(c => c[livePivot.normalAxis] === livePivot.sliceValue);
members.forEach(c => pivot.attach(c.mesh));
pivot.updateMatrixWorld();
livePivot.members = members;
livePivot.active = true;
}
if (!livePivot.active) return;
// Update live angle
// angle proportional to HOW MUCH the drag is oriented in the right direction
// A simple heuristic: project worldDrag onto cross(N, pivotAxis) direction
const wd = worldDragFromDelta(dx, dy);
const N = livePivot.worldNormal;
const pivotAxisVec = new THREE.Vector3(livePivot.axis==='x'?1:0, livePivot.axis==='y'?1:0, livePivot.axis==='z'?1:0);
// Actually angle should be: component of wd in direction of (N × pivotAxisVec) ... no.
// Better: the amount of rotation is proportional to total drag along the right direction
// Let's just use the screen projected component perpendicular to face
// From face normal, two possible in-plane screen directions — pick the one that matches the pivot axis direction
// Actually simpler: compute current position of dragged point relative to start projected onto screen
// and map its distance to rotation angle using a fixed factor
const angle = Math.sign(livePivot.sign) * Math.hypot(dx, dy) * 0.008;
pivot.rotation[livePivot.axis] = angle;
livePivot.angle = angle;
pivot.updateMatrixWorld();
}
function finishPivotDrag() {
if (!livePivot.active) {
livePivot.active = false;
resetPivot();
return;
}
const current = pivot.rotation[livePivot.axis];
let target;
if (Math.abs(current) < 0.3) {
// Cancel: animate back to 0
const axis = livePivot.axis;
const members = livePivot.members;
// Animate to 0
if (animState) { cancelAnimation(); }
animState = {
axis,
startBase: current,
targetAngle: 0,
elapsed: 0,
duration: 120,
members,
onDone: () => {
// detach
for (const c of members) {
scene.attach(c.mesh);
const p = c.mesh.position;
c.x = Math.round(p.x);
c.y = Math.round(p.y);
c.z = Math.round(p.z);
c.mesh.position.set(c.x, c.y, c.z);
}
pivot.rotation.set(0,0,0);
pivot.updateMatrixWorld();
animState = null;
resetPivot();
},
};
} else {
// Confirm: snap to nearest ±π/2
const target = Math.sign(current) * Math.PI/2;
const axis = livePivot.axis;
const members = livePivot.members;
const normalAxis = livePivot.normalAxis;
const sliceValue = livePivot.sliceValue;
if (animState) { cancelAnimation(); }
animState = {
axis,
startBase: current,
targetAngle: target,
elapsed: 0,
duration: 120,
members,
onDone: () => {
for (const c of members) {
scene.attach(c.mesh);
const p = c.mesh.position;
c.x = Math.round(p.x);
c.y = Math.round(p.y);
c.z = Math.round(p.z);
c.mesh.position.set(c.x, c.y, c.z);
// Update logical position
c.x = Math.round(p.x);
c.y = Math.round(p.y);
c.z = Math.round(p.z);
}
pivot.rotation.set(0,0,0);
pivot.updateMatrixWorld();
animState = null;
resetPivot();
},
};
livePivot = { active: false, members: [], axis: null };
}
}
function resetPivot() {
livePivot = { active: false, members[], axis: null, angle: 0 };
}
function cancelAnimation() {
if (!animState) return;
const { members, onDone } = animState;
for (const c of members) {
scene.attach(c.mesh);
const p = c.mesh.position;
c.x = Math.round(p.x);
c.y = Math.round(p.y);
c.z = Math.round(p.z);
c.mesh.position.set(c.x, c.y, c.z);
}
pivot.rotation.set(0,0,0);
pivot.updateMatrixWorld();
const done = onDone;
animState = null;
if (done) done();
}
```
Hmm this is getting messy. Let me simplify drastically by implementing a small state machine for interaction.
States:
- IDLE
- FACE_DRAG (no live preview, just track; on threshold → start animation)
- ANIMATING
- ORBIT
- SCRAMBLING_QUEUE
Actually re-examining what makes a good Rubik's web demo: having a live preview (cubies move with finger/mouse as you drag, then snap on release) is the gold standard. But it's complex.
The simpler threshold-based with fan-out animation is acceptable and much simpler.
Let me go with:
- pointerdown on cube: record
- pointermove:
- if dx²+dy² > 30: compute axis & sign, start rotateSliceLayer animation, set faceDragStateLocked = true
- ignore further moves
- pointerup: reset if not started
- animation: runs independently
This is manageable. If animState already set, ignore.
Let me just do this clean:
```javascript
let faceDrag = null; // { cubie, normal, startX, startY }
let dragLocked = false;
function onPointerDown(e) {
if (animState) return;
const hits = getIntersection(e);
if (hits.length > 0) {
const hit = hits[0];
const mesh = hit.object;
const worldNormal = LOCAL_NORMALS[hit.face.materialIndex].clone()
.applyQuaternion(mesh.getWorldQuaternion(new THREE.Quaternion()))
.normalize();
const cubie = getCubieByMesh(mesh);
if (!cubie) return;
faceDrag = { cubie, worldNormal, startX: e.clientX, startY: e.clientY };
dragLocked = false;
} else {
faceDrag = null;
orbitStart(e.clientX, e.clientY, theta, phi);
}
}
function onPointerMove(e) {
if (faceDrag && !dragLocked && !animState) {
const dx = e.clientX - faceDrag.startX;
const dy = e.clientY - faceDrag.startY;
const dist = Math.hypot(dx, dy);
if (dist > 30) {
const wd = worldDragFromDelta(dx, dy);
const crossVec = new THREE.Vector3().crossVectors(faceDrag.worldNormal, wd).normalize();
const comps = [Math.abs(crossVec.x), Math.abs(crossVec.y), Math.abs(crossVec.z)];
const maxIdx = comps.indexOf(Math.max(...comps));
if (comps[maxIdx] >= 0.35) {
const axisNames = ['x','y','z'];
const axis = axisNames[maxIdx];
const sign = crossVec[axis] > 0 ? 1 : -1;
const norm = faceDrag.worldNormal;
const compN = [Math.abs(norm.x), Math.abs(norm.y), Math.abs(norm.z)];
const nIdx = compN.indexOf(Math.max(...compN));
const nAxis = axisNames[nIdx];
const sliceValue = Math.round(norm[nAxis]);
dragLocked = true;
rotateSliceLayer(nAxis, sliceValue, axis, sign * Math.PI/2, 180);
}
}
} else if (!faceDrag) {
orbitMove(e.clientX, e.clientY);
}
}
function onPointerUp() {
faceDrag = null;
dragLocked = false;
if (!faceDrag) orbitEnd();
}
```
Wait I'm confused. Let me just rename things:
```javascript
let faceDrag = null; // active drag on cube, before threshold
let isFaceDragLocked = false; // animation started, ignore moves
function onPointerDown(e) {
if (animState) return;
e.preventDefault();
// ...
if (hits.length > 0) {
const hit = hits[0];
const mesh = hit.object;
const worldNormal = LOCAL_NORMALS[hit.face.materialIndex].clone()
.applyQuaternion(mesh.getWorldQuaternion(new THREE.Quaternion()))
.normalize();
const cubie = getCubieByMesh(mesh);
faceDrag = { cubie, worldNormal, startX: e.clientX, startY: e.clientY };
} else {
faceDrag = null;
orbitDrag = { startX: e.clientX, startY: e.clientY, startTheta: theta, startPhi: phi };
}
}
function onPointerMove(e) {
if (!faceDrag) {
orbitDrag && orbitMove(e.clientX, e.clientY);
return;
}
if (isFaceDragLocked) return;
const dx = e.clientX - faceDrag.startX;
const dy = e.clientY - faceDrag.startY;
const dist = Math.hypot(dx, dy);
if (dist > 25) {
const wd = worldDragFromDelta(dx, dy);
const N = faceDrag.worldNormal.clone().normalize();
const crossVec = new THREE.Vector3().crossVectors(N, wd).normalize();
const comps = [Math.abs(crossVec.x), Math.abs(crossVec.y), Math.abs(crossVec.z)];
const maxIdx = comps.indexOf(Math.max(...comps));
if (comps[maxIdx] >= 0.35) {
const axes = ['x','y','z'];
const axis = axes[maxIdx];
const sign = crossVec[maxIdx] > 0 ? 1 : -1;
// Actually Math.abs(crossVec.x) > something - crossVec[maxIdx] can be negative
// Need sign from the actual value
const sign2 = (crossVec[axis] > 0) ? 1 : -1;
const normAbs = [Math.abs(N.x), Math.abs(N.y), Math.abs(N.z)];
const nIdx = normAbs.indexOf(Math.max(...normAbs));
const nAxis = axes[nIdx];
const sliceValue = Math.round(N[nAxis]);
isFaceDragLocked = true;
rotateSliceLayer(nAxis, sliceValue, axis, sign2 * Math.PI/2, 180);
}
}
}
function onPointerUp(e) {
faceDrag = null;
isFaceDragLocked = false;
orbitDrag = null;
}
```
Wait, there's a subtle bug: `Math.hypot` at threshold, if drag is small but in the right direction, we still rotate. 25 pixels is fine.
Another thing: when rotating live, `rotateSliceLayer` creates the pivot, attaches cubies, and sets `animState`. Animation runs to completion. So cubies are locked for 180ms.
After animState finishes, `finalizeSliceRotation` detaches cubies, snaps positions, and clears animState. Good.
But I need to be careful: after animation completes, we clear isFaceDragLocked. That happens in our onPointerUp: we set it to false. But if pointer is already up, it's already false since onPointerUp set it. Hmm. Actually onPointerUp sets both to false regardless. If animation is still running during pointerup, that's fine.
Actually wait: if animState is running and user lifts finger, onPointerUp sets faceDrag=null, isFaceDragLocked=false. The animState continues running. That's fine. After it completes, animState is null, and the user is ready to drag again.
The current design has a 180ms animation window where new drags are blocked (`if (animState) return;` on pointerdown). So user has to wait a bit.
Now for the orbit, during animation it's still allowed? The current code: `if (animState) return;` blocks everything. That might be annoying. Let me allow orbit during animation by checking `if (animState && faceDrag) return;` or something.
Actually `faceDrag` would be null if we lifted. But initial pointerdown during animation also blocked. Hmm I think it's fine: 180ms is fast enough that users won't notice blocking. Let me leave it.
One more thing: the scrub trick. During scramble, `isScrambling = true`, but `animState` is also set. So it's blocked similarly, but the scramble loop handles the sequencing with setTimeout, so it's fine.
Let me also add: a quick visual cue when hover over cube (cursor: pointer). Use `renderer.domElement.style.cursor = 'pointer'` when over cube, else 'grab'.
Now let me also make sure that the `handleOrbit...` etc are named consistently.
OK let me now code the final version carefully. I'll consolidate the state.
Also, let me add a pointerleave listener to release state.
Actually let me think of another subtlety: on a mobile device, touch → pointer events are OK. Touch has one pointer. Should work.
Also, for the live drag preview - I'm going to add it because it makes the experience 10x better. But with threshold and snap-on-release, the UX is OK.
Let me also consider: when I call `pivot.attach(cubie.mesh)`, does it preserve the world transformation? Yes, because Object3D.attach does that. But wait, attaching to pivot while the pivot is at origin with no rotation keeps the world transform faithful: the mesh's local position becomes its world position (before attachment), since pivot's world matrix is identity. So after the user drags and starts the live pivot (which I'm not doing), the mesh's local position would correspond to its original grid position. That's stable.
OK. I'll add the live pivot preview as optional and keep the code clean. Let me skip it for clarity and stick with threshold → commit → animate.
One more: for the threshold axis detection, I use cross(N, wd). That might fail if the drag exactly parallels N. But since user is dragging perpendicular to face, N is approximately perpendicular to wd, so crossVec is large. Good.
Now for the colors. Standard Rubik colors:
- White (U), Yellow (D), Red (R), Orange (L), Blue (B), Green (F)
My map: R=+X, L=-X, U=+Y, D=-Y, F=+Z, B=-Z.
That's: White top, yellow bottom, red right, orange left, green front, blue back.
Standard orientation when white on top and green in front: red should be on the right. With my mapping: +X is right, from observer looking at +Z with +Y up, +X is right. ✓
Great. WCA standard colors.
Now for rounded inner faces. The inner faces of cubies are black. When two adjacent cubies are next to each other, their inner faces touch at the seam, showing black. That mimics the inner plastic. With 0.92 cube size and 0.1 gap, the seam shows the "background" color through the gap. If I make the gap small (0.92 size), there's a visible gap. If background is dark, it looks OK.
Alternative: use slightly larger cubies so they overlap/occlude the gaps. But since we rotate pivots, we want gaps visible. So 0.92 is fine.
Even better: I can make the inner faces (and "wasted" faces) a color very close to background (#111111 vs #0f172a). OK.
For the beveled look: each cubie could have a black rim. Since we're using simple boxes, let me add a slight inset plane on each face to give a rim. Hmm this is complex. Skip.
Actually I could use a `RoundedBoxGeometry` that's sometimes included with Three.js examples, but I don't have it. I'll skip.
Let me now write the final HTML. I'll include:
1. The complete scene setup
2. Build cubies
3. Face-drag handling
4. Orbit controls
5. Animation system
6. Scramble button
7. Reset button
8. UI strip below with buttons, nice frosted glass style.
Final code ahead. I'll write it now.
Oh wait, check if scene.attach exists. Three.js r122+ has Object3D.attach. The bundled three.min.js might be an older version. If Object3D.attach doesn't exist, my code breaks.
Let me add a helper:
```javascript
function attachPreserveWorld(child, parent) {
if (child.attach) {
parent.attach(child);
} else {
const m = new THREE.Matrix4();
child.updateMatrixWorld();
m.copy(child.matrixWorld);
parent.attach(child);
// actually if attach doesn't exist, need manual:
if (child.parent !== parent) {
const wPos = new THREE.Vector3();
const wQuat = new THREE.Quaternion();
const wScl = new THREE.Vector3();
child.getWorldPosition(wPos);
child.getWorldQuaternion(wQuat);
child.getWorldScale(wScl);
if (child.parent) child.parent.remove(child);
parent.add(child);
child.position.copy(wPos);
child.quaternion.copy(wQuat);
child.scale.copy(wScl);
}
}
}
```
Actually in modern Three.js, `parent.attach(child)` exists regardless of animation loop. But to be safe, I'll use the manual approach which is simple and always works.
```javascript
function attachPreserveWorld(child, newParent) {
const worldPos = new THREE.Vector3();
const worldQuat = new THREE.Quaternion();
const worldScl = new THREE.Vector3();
child.getWorldPosition(worldPos);
child.getWorldQuaternion(worldQuat);
child.getWorldScale(worldScl);
if (child.parent) child.parent.remove(child);
newParent.add(child);
child.position.copy(worldPos);
child.quaternion.copy(worldQuat);
child.scale.copy(worldScl);
}
```
Wait, this overwrites child's local position/quaternion with world values. Since newParent might not be scene (might have non-identity transform), this is wrong. The child's new local position should be `newParent.worldToLocal(worldPos.clone())`, and quaternion should be `newParent.getWorldQuaternion(new THREE.Quaternion()).invert().multiply(worldQuat)`.
Hmm. Let me just rely on Three.js's `.attach()` method. I'll check: `THREE.Object3D.prototype.attach` — it's been in Three.js for a long time. Let me assume it exists. If it breaks in this specific three.min.js, the user will get an error and I can fix.
Actually let me verify by reading the three.min.js header... no that's 600KB. I'll assume modern enough. But to be safe, I'll use Object3D's attach but with a guard.
```javascript
function attachPreserveWorld(child, newParent) {
if (typeof child.attach === 'function') {
newParent.attach(child);
} else {
const worldPos = new THREE.Vector3();
const worldQuat = new THREE.Quaternion();
const worldScl = new THREE.Vector3();
child.getWorldPosition(worldPos);
child.getWorldQuaternion(worldQuat);
child.getWorldScale(worldScl);
if (child.parent) child.parent.remove(child);
newParent.add(child);
const invParentMat = new THREE.Matrix4().copy(newParent.matrixWorld).invert();
child.position.copy(worldPos).applyMatrix4(invParentMat);
child.quaternion.copy(invParentMat.decompose? no that's complicated ...)
}
}
```
Just let Three.js handle it. Use `newParent.attach(child)`.
But `newParent` is either pivot or scene. Both are Object3D. Good.
Now, when detaching from pivot to scene for finalization:
```javascript
scene.attach(cubie.mesh);
```
This preserves world transform (scene has identity matrixWorld before updateMatrixWorld). Good.
Alright, final plan locked. Writing file.
Critically, I need to handle the edge case where rotateSliceLayer is called while animState is set (e.g., during scramble and another one is queued too aggressively). I'll check and return in rotateSliceLayer.
For scramble, I use setTimeout(step, 15) after each to chain. A blocky 60ms animation + 40ms gap = 100ms per move. 25 moves = 2.5s. Audible speed.
Also: in tickAnimation, if animState was set to short duration (60ms for scramble), it should be quick.
Wait, I realize my tickAnimation uses milliseconds:
```javascript
state.elapsed += dt;
```
where dt = elapsed in ms (1000/fps). average 16ms per frame at 60fps. With duration 60ms, only ~4 frames. Probably OK.
Let me also handle: the cubie mesh might not have been positioned back in correct logical location if there are micro-errors after multiple turns. Math.round() handles floats near integers.
For sign computation, I need to be careful. Let me re-examine:
For threshold determination:
```javascript
const crossVec = new THREE.Vector3().crossVectors(Normal, wd).normalize();
const comps = [Math.abs(crossVec.x), Math.abs(crossVec.y), Math.abs(crossVec.z)];
```
If Normal is +X and wd is +Y, cross = +Z. `crossVec.z > 0` means positive pivotSign. Then angle = +π/2.
If Normal is +X and wd is -Z, cross = +Y. `crossVec.y > 0` → pivotSign = +1. Angle = +π/2.
If Normal is -X and wd is +Y, cross = -Z (because N=(-1,0,0)×(0,1,0) = (0*0-0*1, 0*0-(-1)*0, (-1)*1-0*0) = (0,0,-1)). `crossVec.z = -1 < 0` → pivotSign = -1. Angle = -π/2.
Hmm, interesting.
For Normal = -X, drag up: cross = -Z, sign = -1, angle = -π/2.
What does -π/2 around Z mean for slice x=-1?
Right-hand: thumb -Z, fingers curl from +X toward -Y (or from -X toward +Y... actually right-hand with thumb along -Z: +X to -Y). Hmm.
Let me just trust the geometry and not over-think. The standard is: drag on the face, twist in the direction of drag. My cross product captures this.
One more edge case: if the dragged world direction is near opposite to face normal (user drags toward the face itself). But our worldDrag is in the plane of the screen, so it's already perpendicular to view direction. The face normal is mostly perpendicular to view direction for visible faces, so cross entropy is nearly always large. Good.
Let me also think about what if crossVec is near zero because wd happens to be parallel to N. N is the face normal (perpendicular to face). wd is screen drag direction. They are perpendicular when face is edge-on to camera (face normal perpendicular to screen). Then crossVec is small. But in that case, the face is barely visible, user wouldn't click it effectively. OK.
Now coding. Finalizing.
Let me also ensure pivot's children transform is set up. Actually my `rotateSliceLayer`:
1. pivot.rotation.set(0,0,0); pivot.updateMatrixWorld();
2. scene.add(pivot);
3. members.forEach(c => pivot.attach(c.mesh));
But scene.add(pivot) is only needed if pivot is not already in scene. One-time at start is enough. Let me just add pivot once at init. OK I did: `scene.add(pivot);`.
Then in rotateSliceLayer: checking and setting to zero should be enough (world transform identity).
```javascript
function rotateSliceLayer(normalAxis, sliceValue, pivotAxis, targetAngle, duration, onDone) {
if (animState) { if (onDone) setTimeout(onDone, 0); return; }
const members = cubies.filter(c => c[normalAxis] === sliceValue);
if (!members.length) { if (onDone) onDone(); return; }
pivot.rotation.set(0,0,0);
pivot.updateMatrixWorld();
scene.add(pivot); // already in scene but redundancy
members.forEach(c => {
c.mesh.parent && c.mesh.parent.remove(c.mesh);
pivot.attach(c.mesh);
});
pivot.updateMatrixWorld();
animState = { axis: pivotAxis, targetAngle, elapsed: 0, duration: duration || 180, members, onDone };
}
```
For finalize:
```javascript
function finalizeSliceRotation() {
if (!animState) return;
const { members, onDone } = animState;
for (const c of members) {
scene.attach(c.mesh);
const p = c.mesh.position;
c.x = Math.round(p.x);
c.y = Math.round(p.y);
c.z = Math.round(p.z);
c.mesh.position.set(c.x, c.y, c.z);
}
pivot.rotation.set(0,0,0);
pivot.updateMatrixWorld();
const cb = onDone;
animState = null;
if (cb) cb();
}
```
tick:
```javascript
function tickAnimation(dtMs) {
if (!animState) return;
const a = animState;
a.elapsed += dtMs;
let t = Math.min(1, a.elapsed / a.duration);
t = t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3)/2; // easeInOutCubic
pivot.rotation[a.axis] = t * a.targetAngle;
pivot.updateMatrixWorld();
if (a.elapsed >= a.duration) {
finalizeSliceAnimation();
}
}
```
Note: by setting pivot rotation to identity after finalization, what happens to cubies that were reattached to scene? Their world quaternion has been baked in from the rotation. Good.
Wait, but I set `pivot.rotation[a.axis] = t * a.targetAngle` and on finish, before finalizeSlice, I should set pivot.rotation[a.axis] = a.targetAngle to ensure they land exactly. Actually t reaches 1 at end, so it's already at target. That's fine.
But after finalization, I set pivot rotation to 0. Since members are no longer children of pivot, that doesn't affect them.
Now, final thing: the pivot children's world matrix after pivot rotation. The pivot.position might not be at the origin. I initialize pivot.position to (0,0,0). After animation, pivot still at (0,0,0). Good.
OK, writing the HTML file now. I'll try to keep it concise but readable.
Let me think of one more fine-tuning: when mouse is on cube, show grab cursor. On background, show rotate/zoom. I can update this on pointermove.
```javascript
function updateCursor(e) {
if (animState) { renderer.domElement.style.cursor = 'wait'; return; }
const hits = getIntersection(e);
renderer.domElement.style.cursor = hits.length > 0 ? (orbitDrag ? 'grabbing' : 'grab') : 'grab';
}
```
Hmm orbitDrag indicates active orbit. Initial state is `cursor: grab`, on orbit start: `cursor: grabbing`.
Actually let's use: `'grab'` when hovering cube, `'default'` otherwise, and `'grabbing'` when orbiting.
Implement by adding pointermove check. But every pointermove will raycast, which is O(n) on 27 meshes = 27 ray-box tests = fine.
OK let me include this in onPointerMove.
Alright, final writing.
One more: I should handle the page getting focus/popup. Let me add a pointercancel listener.
One more consideration: what if the user middle clicks or right-clicks? I'll ignore or treat as orbit. The pointerType for mouse is 'mouse'. I'll just treat left button as the only button for rotation, others as orbit.
Actually I'll use `e.button === 0` for face/orbit. Other buttons ignore.
OK let's do it.
Also, the code needs to handle the case where `three.min.js` is loaded. The `THREE` global is available. Let me also check if MeshStandardMaterial exists (it might not in very old Three.js). MeshPhongMaterial is always there. I'll use MeshPhongMaterial for compatibility.
Actually I'll use MeshStandardMaterial, it's been in Three.js since r72+. three.min.js of unknown version. Let me just use MeshPhongMaterial and it'll look fine.
```javascript
new THREE.MeshPhongMaterial({ color, flatShading: false, shininess: 10 });
```
Or with roughness not applicable in Phong. Standard is definitely in modern Three.js. I'll use Standard but fallback. Let me just make sure. Actually MeshStandardMaterial was added in r72, three.min.js is probably a fairly recent version. I'll use it.
Now, for the colors, I'll use MeshStandardMaterial with roughness/metalness for decent plastic look.
One more: the inner faces should be dark and slightly reflective.
OK writing now.
Let me consolidate and write:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Rubik's Cube 3D</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0b1120;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: #e2e8f0; }
canvas { display: block; touch-action: none; }
#ui {
position: fixed; top: 16px; left: 16px;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
padding: 16px 18px;
box-shadow: 0 10px 40px rgba(0,0,0,0.35);
z-index: 10;
}
#ui h1 { font-size: 16px; font-weight: 700; letter-spacing: 0.4px; margin-bottom: 2px; }
#ui .sub { font-size: 11px; color: #94a3b8; margin-bottom: 10px; }
#ui button {
display: block; width: 100%;
padding: 9px 12px; border-radius: 10px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.06);
color: #e2e8f0; font-size: 13px; font-weight: 600;
cursor: pointer; transition: transform .1s ease, background .15s ease;
margin-top: 6px;
}
#ui button:hover { background: rgba(255,255,255,0.12); }
#ui button:active { transform: scale(0.97); }
.hint {
position: fixed; bottom: 12px; left: 50%; transform: translateX(-50%);
font-size: 11px; color: #64748b; pointer-events: none; z-index: 10;
background: rgba(15,23,42,0.4); padding: 5px 12px; border-radius: 999px;
}
</style>
</head>
<body>
<div id="ui">
<h1>Rubik's Cube</h1>
<div class="sub">3×3×3 interactive</div>
<button id="scrambleBtn">⚡ Scramble</button>
<button id="resetBtn">↺ Reset</button>
</div>
<div class="hint">Drag on faces to twist · Drag background to orbit · Scroll to zoom</div>
<script src="three.min.js"></script>
<script>
(() => {
'use strict';
// --- Theme colors (WCA standard) ---
const COL = {
x_pos: 0xb90000, // right (+X) Red
x_neg: 0xff5900, // left (-X) Orange
y_pos: 0xffffff, // top (+Y) White
y_neg: 0xffd500, // bottom (-Y) Yellow
z_pos: 0x009e60, // front (+Z) Green
z_neg: 0x0051ba, // back (-Z) Blue
inner: 0x1a1c20,
};
// --- Scene setup ---
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0b1120');
const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
// Lights
scene.add(new THREE.AmbientLight('#cbd5e1', 0.6));
const d1 = new THREE.DirectionalLight('#ffffff', 0.9);
d1.position.set(5, 8, 4);
scene.add(d1);
const d2 = new THREE.DirectionalLight('#aaccff', 0.3);
d2.position.set(-4, -2, -6);
scene.add(d2);
const d3 = new THREE.DirectionalLight('#ffffff', 0.25);
d3.position.set(-3, 8, 2);
scene.add(d3);
// --- Cubies ---
const cubies = []; // { mesh, x, y, z }
const allMeshes = [];
const pivot = new THREE.Object3D();
scene.add(pivot);
function makeMaterials(x, y, z) {
const mk = (c) => new THREE.MeshStandardMaterial({
color: c, roughness: 0.5, metalness: 0.05
});
return [
x===1 ? mk(COL.x_pos) : mk(COL.inner),
x===-1 ? mk(COL.x_neg) : mk(COL.inner),
y===1 ? mk(COL.y_pos) : mk(COL.inner),
y===-1 ? mk(COL.y_neg) : mk(COL.inner),
z===1 ? mk(COL.z_pos) : mk(COL.inner),
z===-1 ? mk(COL.z_neg) : mk(COL.inner),
];
}
function buildCubies() {
for (const c of cubies) {
if (c.mesh.parent) c.mesh.parent.remove(c.mesh);
c.mesh.geometry && c.mesh.geometry !== THREE.BoxGeometry && c.mesh.geometry.dispose(); // skip default box if we want
}
cubies.length = 0;
allMeshes.length = 0;
const geo = new THREE.BoxGeometry(0.92, 0.92, 0.92);
for (let x = -1; x <= 1; x++) {
for (let y = -1; y <= 1; y++) {
for (let z = -1; z <= 1; z++) {
const mesh = new THREE.Mesh(geo, makeMaterials(x,y,z));
mesh.position.set(x, y, z);
scene.add(mesh);
const c = { mesh, x, y, z };
cubies.push(c);
allMeshes.push(mesh);
}
}
}
}
// --- Orbit camera ---
let theta = Math.PI / 4;
let phi = Math.PI / 3;
let radius = 6;
function updateCamera() {
phi = Math.max(0.05, Math.min(Math.PI - 0.05, phi));
camera.position.set(
radius * Math.sin(phi) * Math.cos(theta),
radius * Math.cos(phi),
radius * Math.sin(phi) * Math.sin(theta)
);
camera.lookAt(0, 0, 0);
}
updateCamera();
// --- Animation ---
let animState = null;
function rotateSlice(normalAxis, sliceValue, pivotAxis, targetAngle, duration) {
if (animState) return;
const members = cubies.filter(c => c[normalAxis] === sliceValue);
if (!members.length) return;
pivot.rotation.set(0,0,0);
pivot.updateMatrixWorld();
members.forEach(c => {
if (c.mesh.parent) c.mesh.parent.remove(c.mesh);
pivot.attach(c.mesh);
});
pivot.updateMatrixWorld();
animState = { axis: pivotAxis, targetAngle, elapsed: 0, duration: duration||180, members };
}
function finalizeRotation() {
if (!animState) return;
const { members } = animState;
for (const c of members) {
scene.attach(c.mesh);
const p = c.mesh.position;
c.x = Math.round(p.x);
c.y = Math.round(p.y);
c.z = Math.round(p.z);
c.mesh.position.set(c.x, c.y, c.z);
}
pivot.rotation.set(0,0,0);
pivot.updateMatrixWorld();
animState = null;
}
function tickAnim(dt) {
if (!animState) return;
const a = animState;
a.elapsed += dt;
let t = Math.min(1, a.elapsed / a.duration);
t = t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2,3)/2; // easeInOutCubic
pivot.rotation[a.axis] = t * a.targetAngle;
pivot.updateMatrixWorld();
if (a.elapsed >= a.duration) finalizeRotation();
}
// --- Face drag ---
const raycaster = new THREE.Raycaster();
const mouseNdc = new THREE.Vector2();
const LOCAL_NORMALS = [
new THREE.Vector3(1,0,0), new THREE.Vector3(-1,0,0),
new THREE.Vector3(0,1,0), new THREE.Vector3(0,-1,0),
new THREE.Vector3(0,0,1), new THREE.Vector3(0,0,-1),
];
const AXES = ['x','y','z'];
let faceDrag = null; // { cubie, normal, startX, startY }
let faceDragLocked = false;
function getHit(event) {
const r = renderer.domElement.getBoundingClientRect();
mouseNdc.x = ((event.clientX - r.left) / r.width) * 2 - 1;
mouseNdc.y = -((event.clientY - r.top) / r.height) * 2 + 1;
raycaster.setFromCamera(mouseNdc, camera);
return raycaster.intersectObjects(allMeshes, true);
}
function byMesh(mesh) {
return cubies.find(c => c.mesh === mesh);
}
function worldDeltaScreen(dx, dy) {
// Drag right → camera right; drag up → camera up
const r = new THREE.Vector3(1,0,0).applyQuaternion(camera.quaternion);
const u = new THREE.Vector3(0,1,0).applyQuaternion(camera.quaternion);
const f = 0.004;
return r.clone().multiplyScalar(dx * f).add(u.clone().multiplyScalar(-dy * f));
}
function onDown(e) {
if (animState) return;
// only primary button
if (e.button && e.button !== 0) {
orbitStart(e.clientX, e.clientY);
return;
}
e.preventDefault();
const hit = getHit(e);
if (hit.length > 0) {
const m = hit[0].object;
const norm = LOCAL_NORMALS[hit[0].face.materialIndex].clone()
.applyQuaternion(m.getWorldQuaternion(new THREE.Quaternion())).normalize();
const cubie = byMesh(m);
if (!cubie) return;
faceDrag = { cubie, normal: norm, startX: e.clientX, startY: e.clientY };
faceDragLocked = false;
renderer.domElement.style.cursor = 'grabbing';
} else {
faceDrag = null;
orbitStart(e.clientX, e.clientY);
}
}
function onMove(e) {
if (faceDrag && !faceDragLocked && !animState) {
const dx = e.clientX - faceDrag.startX;
const dy = e.clientY - faceDrag.startY;
const d = Math.hypot(dx, dy);
if (d > 25) {
const wd = worldDeltaScreen(dx, dy);
const N = faceDrag.normal;
const cross = new THREE.Vector3().crossVectors(N, wd).normalize();
const comp = [Math.abs(cross.x), Math.abs(cross.y), Math.abs(cross.z)];
const mx = comp.indexOf(Math.max(...comp));
if (comp[mx] > 0.35) {
const pAxis = AXES[mx];
const sgn = cross[pAxis] > 0 ? 1 : -1;
const nAbs = [Math.abs(N.x), Math.abs(N.y), Math.abs(N.z)];
const n = nAbs.indexOf(Math.max(...nAbs));
const nAxis = AXES[n];
const slice = Math.round(N[nAxis]);
faceDragLocked = true;
rotateSlice(nAxis, slice, pAxis, sgn * Math.PI/2, 180);
}
}
} else if (!faceDrag) {
orbitMove(e.clientX, e.clientY);
}
}
function onUp() {
faceDrag = null;
faceDragLocked = false;
orbitEnd();
}
renderer.domElement.addEventListener('pointerdown', onDown);
window.addEventListener('pointermove', onMove, { passive: false });
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
renderer.domElement.addEventListener('wheel', (e) => {
radius = Math.max(2.5, Math.min(12, radius + e.deltaY * 0.005));
updateCamera();
e.preventDefault();
}, { passive: false });
// --- Orbit ---
let orbitDrag = null;
function orbitStart(sx, sy) {
orbitDrag = { sx, sy, th: theta, ph: phi };
}
function orbitMove(sx, sy) {
if (!orbitDrag) return;
theta = orbitDrag.th - (sx - orbitDrag.sx) * 0.005;
phi = orbitDrag.ph + (sy - orbitDrag.sy) * 0.005;
updateCamera();
}
function orbitEnd() {
orbitDrag = null;
}
// --- Scramble ---
let isScrambling = false;
function scramble() {
if (animState || isScrambling) return;
isScrambling = true;
const axes = ['x','y','z'];
const slices = [-1,0,1];
let i = 0;
const steps = 20;
function step() {
if (i >= steps) { isScrambling = false; return; }
const a = axes[i%3], sl = slices[Math.floor(Math.random()*3)];
const dir = Math.random() > 0.5 ? 1 : -1;
rotateSlice(a, sl, a, dir * Math.PI/2, 70, () => {
i++;
setTimeout(step, 18);
});
}
step();
}
// --- Reset ---
function resetCube() {
if (animState) {
pivot.rotation.set(0,0,0);
pivot.updateMatrixWorld();
animState = null;
isScrambling = false;
}
pivot.rotation.set(0,0,0);
buildCubies();
updateCamera();
}
// --- UI ---
document.getElementById('scrambleBtn').addEventListener('click', scramble);
document.getElementById('resetBtn').addEventListener('click', resetCube);
// --- Cursor ---
function updateCursor(e) {
if (animState) { renderer.domElement.style.cursor = 'wait'; return; }
const hit = getHit(e);
renderer.domElement.style.cursor = hit.length > 0 ? 'grab' : (orbitDrag ? 'grabbing' : 'grab');
}
renderer.domElement.addEventListener('pointermove', updateCursor, { passive: true });
// --- Resize ---
window.addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// --- Init ---
buildCubies();
updateCamera();
// --- Loop ---
let last = performance.now();
(function loop() {
const now = performance.now();
tickAnim(now - last);
last = now;
renderer.render(scene, camera);
requestAnimationFrame(loop);
})();
})();
</script>
</body>
</html>
```
Wait there's a problem in rotateSlice: I declared 4 params but scramble passes 5 (with onDone). Let me fix.
```javascript
function rotateSlice(normalAxis, sliceValue, pivotAxis, targetAngle, duration) {
// ...
animState = { axis: pivotAxis, targetAngle, elapsed: 0, duration: duration||180, members };
}
```
For scramble's callback: just restart step via setTimeout on the outer function, not via callback.
Actually the simplest: rotateSlice just sets animState. scramble's step just calls rotateSlice then `setTimeout(step, duration+20)`.
```javascript
function step() {
if (i >= steps) { isScrambling = false; return; }
const a = axes[i%3], sl = slices[Math.floor(Math.random()*3)];
const dir = Math.random() > 0.5 ? 1 : -1;
const dur = 70;
rotateSlice(a, sl, a, dir * Math.PI/2, dur);
i++;
setTimeout(step, dur + 20);
}
```
But need to be careful: rotateSlice silently does nothing if animState is set. The setTimeout still advances i. If we advance i, the next rotation is delayed by the setTimeout. But while animState is set, subsequent rotateSlice calls would be ignored. That's OK because i gets incremented anyway. However, if i reaches steps prematurely without all 20 happening, the scramble ends early.
To handle: only increment i after the rotation actually starts (in rotateSlice) or use a callback.
Let me use internal callback:
```javascript
function rotateSlice(normalAxis, sliceValue, pivotAxis, targetAngle, duration, onDone) {
if (animState) { if (onDone) setTimeout(onDone, 50); return; }
// ...
animState = {axis:..., members:...}; // no onDone
// We rely on finalizeRotation to null animState; then the next step's setTimeout fires.
}
```
Simpler: don't pass onDone. Just check animState in step before proceeding.
```javascript
function step() {
// no need for i management if we just try again
if (i >= steps) { isScrambling = false; return; }
const a = axes[i%3], sl = slices[Math.floor(Math.random()*3)];
const dir = Math.random() > 0.5 ? 1 : -1;
const dur = 70;
rotateSlice(a, sl, a, dir * Math.PI/2, dur);
i++;
setTimeout(step, dur + 30);
}
```
But if rotateSlice was blocked by animState (rare because duration is small), the next call in setTimeout still happens and i has advanced. So we just lose one move effectively. No big deal.
Alright. Let me also think about the cursor. `updateCursor` uses getHit which raycasts. If a DOM element might not have raycaster set, etc. It's fine.
Let me also handle the situation where `three.min.js` doesn't support renderer.outputColorSpace. Older Three.js uses `renderer.outputEncoding = THREE.sRGBEncoding`. So let me skip that line or guard it.
```javascript
if (renderer.outputColorSpace !== undefined) renderer.outputColorSpace = THREE.SRGBColorSpace;
```
Or just omit. The default in modern Three.js is SRGB, so it's fine.
Another issue: in `tickAnim`, I add `dt` which is in ms. tickAnim receives `now - last` which is in ms. tickAnim checks `>= duration`. Since duration is in ms, this is consistent. OK.
Let me also use `performance.now()` correctly.
Now let me finalize and write the file. I'll consolidate everything into one clean file.
Oh! Another consideration: the `Mesh` geometry. `buildCubies` uses `THREE.BoxGeometry` which is typically imported. In modern non-module Three.js, `THREE.BoxGeometry` is available directly. Yes.
Alright, writing now.
Final tiny check: geometry reuse. Using a single geo for all meshes saves memory. Good.
Let me also make sure the `easeInOutCubic` is right:
t ∈ [0,1].
t<0.5: 4t³ (grows slowly then fast)
t≥0.5: 1-(-2t+2)³/2 = 1-(2-2t)³/2 = 1-(8-24t+24t²-8t³)/2 = 1-(4-12t+12t²-4t³) = -3+12t-12t²+4t³
At t=0.5: 4*0.125=0.5. At t=0.5 from second: -3+6-3+0.5=0.5. ✓ Good.
Now writing.Now I'll write the complete single-file app.
/workspace/index.html (13668 bytes)Successfully wrote 13668 bytes to /workspace/index.html
thinking
I wrote the file. Now let me review it carefully to make sure it compiles correctly. Let me read it back and look for any issues. I noticed a few potential problems: 1. `cross[mx || mx]` - that's a typo, should just be `cross[rotAxis]` 2. The `onUp` resets `orbitDrag` implicitly by not calling endOrbit()... wait actually I do call endOrbit. 3. I need to verify `three.min.js` has `MeshStandardMaterial`, `MeshStandardMaterial`, `MeshPhongMaterial` etc. 4. Need to make sure the file is valid. Let me read the file to check.
{"path":"/workspace/index.html"}<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <title>Rubik's Cube 3D</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; overflow: hidden; background: #0b1120; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: #e2e8f0; } canvas { display: block; touch-action: none; } #ui { position: fixed; top: 16px; left: 16px; background: rgba(15,23,42,0.55); backdrop-filter: blur(14px) saturate(140%); -webkit-backdrop-filter: blur(14px) saturate(140%); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 14px 16px; box-shadow: 0 10px 40px rgba(0,0,0,0.35); z-index: 10; min-width: 140px; } #ui h1 { font-size: 16px; font-weight: 700; letter-spacing: 0.4px; margin-bottom: 2px; } #ui .sub { font-size: 10px; color: #94a3b8; margin-bottom: 8px; } #ui button { display: block; width: 100%; padding: 9px 10px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.06); color: #e2e8f0; font-size: 13px; font-weight: 600; cursor: pointer; transition: transform .1s, background .15s; margin-top: 6px; } #ui button:hover { background: rgba(255,255,255,0.12); } #ui button:active { transform: scale(0.97); } .hint { position: fixed; bottom: 12px; left: 50%; transform: translateX(-50%); font-size: 11px; color: #64748b; pointer-events: none; z-index: 10; background: rgba(15,23,42,0.4); padding: 5px 12px; border-radius: 999px; } </style> </head> <body> <div id="ui"> <h1>Rubik's Cube</h1> <div class="sub">3×3×3 interactive</div> <button id="scrambleBtn">⚡ Scramble</button> <button id="resetBtn">↺ Reset</button> </div> <div class="hint">Drag on faces to twist · Drag background to orbit · Scroll to zoom</div> <script src="three.min.js"></script> <script> (() => { 'use strict'; // ---------- WCA standard colors ---------- const COL = { x_pos: 0xb90000, // +X Right Red x_neg: 0xff5900, // -X Left Orange y_pos: 0xffffff, // +Y Top White y_neg: 0xffd500, // -Y Bottom Yellow z_pos: 0x009b47, // +Z Front Green z_neg: 0x0046ad, // -Z Back Blue inner: 0x18191c, }; // ---------- Scene ---------- const scene = new THREE.Scene(); scene.background = new THREE.Color('#0b1120'); const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 100); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setSize(innerWidth, innerHeight); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.1; if (renderer.outputColorSpace !== undefined) { renderer.outputColorSpace = THREE.SRGBColorSpace; } document.body.appendChild(renderer.domElement); // Lighting scene.add(new THREE.AmbientLight('#cbd5e1', 0.55)); const d1 = new THREE.DirectionalLight('#ffffff', 0.9); d1.position.set(5, 8, 5); scene.add(d1); const d2 = new THREE.DirectionalLight('#aaccff', 0.3); d2.position.set(-5, -2, -5); scene.add(d2); const d3 = new THREE.DirectionalLight('#ffffff', 0.25); d3.position.set(-3, 8, 4); scene.add(d3); // ---------- Cubies ---------- const cubies = []; // { mesh, x, y, z } const allMeshes = []; // flat for raycasting const pivot = new THREE.Object3D(); scene.add(pivot); function makeFaceColor(xPos, yPos, zPos, axis) { if (axis === 'x') return xPos === 1 ? COL.x_pos : xPos === -1 ? COL.x_neg : COL.inner; if (axis === 'y') return yPos === 1 ? COL.y_pos : yPos === -1 ? COL.y_neg : COL.inner; return zPos === 1 ? COL.z_pos : zPos === -1 ? COL.z_neg : COL.inner; } function makeMaterials(x, y, z) { return [0,1,2,3,4,5].map(i => { const color = [x,y,z,-x,-y,-z][i]; // issue — let me use explicit mapping }); } // BoxGeometry sides order: +X -X +Y -Y +Z -Z function makeMaterials(x, y, z) { return [ new THREE.MeshStandardMaterial({ color: x===1 ? COL.x_pos : COL.inner, roughness: 0.5, metalness: 0.05 }), new THREE.MeshStandardMaterial({ color: x===-1 ? COL.x_neg : COL.inner, roughness: 0.5, metalness: 0.05 }), new THREE.MeshStandardMaterial({ color: y===1 ? COL.y_pos : COL.inner, roughness: 0.5, metalness: 0.05 }), new THREE.MeshStandardMaterial({ color: y===-1 ? COL.y_neg : COL.inner, roughness: 0.5, metalness: 0.05 }), new THREE.MeshStandardMaterial({ color: z===1 ? COL.z_pos : COL.inner, roughness: 0.5, metalness: 0.05 }), new THREE.MeshStandardMaterial({ color: z===-1 ? COL.z_neg : COL.inner, roughness: 0.5, metalness: 0.05 }), ]; } function buildCubies() { for (const c of cubies) { if (c.mesh.parent) c.mesh.parent.remove(c.mesh); } cubies.length = 0; allMeshes.length = 0; const geo = new THREE.BoxGeometry(0.92, 0.92, 0.92); for (let x = -1; x <= 1; x++) { for (let y = -1; y <= 1; y++) { for (let z = -1; z <= 1; z++) { const mesh = new THREE.Mesh(geo, makeMaterials(x, y, z)); mesh.position.set(x, y, z); scene.add(mesh); const c = { mesh, x, y, z }; cubies.push(c); allMeshes.push(mesh); } } } } // ---------- Camera orbit ---------- let theta = Math.PI / 4; let phi = Math.PI / 3; let radius = 6; function applyCamera() { phi = Math.max(0.05, Math.min(Math.PI - 0.05, phi)); camera.position.set( radius * Math.sin(phi) * Math.cos(theta), radius * Math.cos(phi), radius * Math.sin(phi) * Math.sin(theta) ); camera.lookAt(0, 0, 0); } applyCamera(); let orbitDrag = null; function startOrbit(sx, sy) { orbitDrag = { sx, sy, startTheta: theta, startPhi: phi }; renderer.domElement.style.cursor = 'grabbing'; } function moveOrbit(sx, sy) { if (!orbitDrag) return; theta = orbitDrag.startTheta - (sx - orbitDrag.sx) * 0.005; phi = orbitDrag.startPhi + (sy - orbitDrag.sy) * 0.005; applyCamera(); } function endOrbit() { orbitDrag = null; } function zoom(dy) { radius = Math.max(2.5, Math.min(12, radius + dy * 0.005)); applyCamera(); } // ---------- Slice rotation animation ---------- let animState = null; function rotateSlice(normalAxis, sliceValue, pivotAxis, targetAngle, duration) { if (animState) return; const members = cubies.filter(c => c[normalAxis] === sliceValue); if (!members.length) return; // collapse into pivot pivot.rotation.set(0, 0, 0); pivot.updateMatrixWorld(); members.forEach(c => { if (c.mesh.parent) c.mesh.parent.remove(c.mesh); pivot.attach(c.mesh); }); pivot.updateMatrixWorld(); animState = { axis: pivotAxis, targetAngle, elapsed: 0, duration: duration || 200, members, }; } function finalizeRotation() { if (!animState) return; const { members } = animState; for (const c of members) { scene.attach(c.mesh); const p = c.mesh.position; c.x = Math.round(p.x); c.y = Math.round(p.y); c.z = Math.round(p.z); c.mesh.position.set(c.x, c.y, c.z); } pivot.rotation.set(0, 0, 0); pivot.updateMatrixWorld(); animState = null; } function tickAnim(dtMs) { if (!animState) return; const a = animState; a.elapsed += dtMs; let t = Math.min(1, a.elapsed / a.duration); // easeInOutCubic t = t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2; pivot.rotation[a.axis] = t * a.targetAngle; pivot.updateMatrixWorld(); if (a.elapsed >= a.duration) finalizeRotation(); } // ---------- Face drag ---------- const raycaster = new THREE.Raycaster(); const mouseNdc = new THREE.Vector2(); const LOCAL_NORMALS = [ new THREE.Vector3( 1, 0, 0), new THREE.Vector3(-1, 0, 0), new THREE.Vector3( 0, 1, 0), new THREE.Vector3( 0,-1, 0), new THREE.Vector3( 0, 0, 1), new THREE.Vector3( 0, 0,-1), ]; let faceDrag = null; let dragLocked = false; function getHit(e) { const r = renderer.domElement.getBoundingClientRect(); mouseNdc.x = ((e.clientX - r.left) / r.width) * 2 - 1; mouseNdc.y = -((e.clientY - r.top) / r.height) * 2 + 1; raycaster.setFromCamera(mouseNdc, camera); return raycaster.intersectObjects(allMeshes, true); } function byMesh(mesh) { return cubies.find(c => c.mesh === mesh); } function worldDeltaScreen(dx, dy) { const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion); const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion); const f = 0.004; return right.clone().multiplyScalar(dx * f).add(up.clone().multiplyScalar(-dy * f)); } function onDown(e) { if (animState) return; if (e.button !== undefined && e.button !== 0) { startOrbit(e.clientX, e.clientY); return; } e.preventDefault(); const hit = getHit(e); if (hit.length > 0) { const m = hit[0].object; const norm = LOCAL_NORMALS[hit[0].face.materialIndex].clone() .applyQuaternion(m.getWorldQuaternion(new THREE.Quaternion())).normalize(); const c = byMesh(m); if (!c) return; faceDrag = { cubie: c, normal: norm, startX: e.clientX, startY: e.clientY }; dragLocked = false; renderer.domElement.style.cursor = 'grabbing'; } else { faceDrag = null; startOrbit(e.clientX, e.clientY); } } function onMove(e) { if (faceDrag && !dragLocked && !animState) { const dx = e.clientX - faceDrag.startX; const dy = e.clientY - faceDrag.startY; if (Math.hypot(dx, dy) > 25) { const wd = worldDeltaScreen(dx, dy); const N = faceDrag.normal; const cross = new THREE.Vector3().crossVectors(N.clone().normalize(), wd).normalize(); const comp = [Math.abs(cross.x), Math.abs(cross.y), Math.abs(cross.z)]; const mx = comp.indexOf(Math.max(...comp)); if (comp[mx] > 0.35) { const axss = ['x','y','z']; const rotAxis = axss[mx]; const sgn = cross[mx || mx] > 0 ? 1 : -1; // Better: use sign of crossVec[axis] const s = cross[rotAxis] > 0 ? 1 : -1; const nAbs = [Math.abs(N.x), Math.abs(N.y), Math.abs(N.z)]; const n = nAbs.indexOf(Math.max(...nAbs)); const nAxis = axss[n]; const sliceVal = Math.round(N[nAxis]); dragLocked = true; rotateSlice(nAxis, sliceVal, rotAxis, s * Math.PI/2, 180); } } } else if (!faceDrag) { moveOrbit(e.clientX, e.clientY); } } function onUp() { faceDrag = null; dragLocked = false; endOrbit(); } renderer.domElement.addEventListener('pointerdown', onDown); window.addEventListener('pointermove', onMove, { passive: false }); window.addEventListener('pointerup', onUp); window.addEventListener('pointercancel', onUp); renderer.domElement.addEventListener('wheel', (e) => { zoom(e.deltaY); e.preventDefault(); }, { passive: false }); // ---------- Scramble ---------- let isScrambling = false; function scramble() { if (animState || isScrambling) return; isScrambling = true; const axes = ['x','y','z']; const slices = [-1,0,1]; let i = 0; const steps = 24; function step() { if (i >= steps) { isScrambling = false; return; } const a = axes[i % 3]; const sl = slices[Math.floor(Math.random() * 3)]; const dir = Math.random() > 0.5 ? 1 : -1; const dur = 70; rotateSlice(a, sl, a, dir * Math.PI/2, dur); i++; setTimeout(step, dur + 25); } step(); } // ---------- Reset ---------- function resetCube() { pivot.rotation.set(0,0,0); pivot.updateMatrixWorld(); animState = null; isScrambling = false; buildCubies(); applyCamera(); } document.getElementById('scrambleBtn').addEventListener('click', scramble); document.getElementById('resetBtn').addEventListener('click', resetCube); // ---------- Cursor ---------- renderer.domElement.addEventListener('pointermove', (e) => { if (animState || faceDrag) return; const hit = getHit(e); renderer.domElement.style.cursor = hit.length > 0 ? 'grab' : 'default'; }, { passive: true }); // ---------- Resize ---------- window.addEventListener('resize', () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); }); // ---------- Loop ---------- let last = performance.now(); buildCubies(); applyCamera(); (function loop() { const now = performance.now(); tickAnim(now - last); last = now; renderer.render(scene, camera); requestAnimationFrame(loop); })(); })(); </script> </body> </html>
thinking
I notice a few issues from my reading:
1. There's some leftover code from a previous draft: `const sgn = cross[mx || mx] > 0 ? 1 : -1; // ...` is dead code but harmless.
2. `makeFaceColor` is defined but unused (harmless).
3. The `getHit` raycaster uses `allMeshes` but during animation, cubies are attached to `pivot`. `raycaster.intersectObjects(allMeshes, true)` passes `recursive=true` but `allMeshes` only contains direct children of scene - not children of pivot. So during animation, we'd miss hits! I need to use `scene.children` or a flattened list.
Actually when cubies are attached to pivot, they're no longer in `allMeshes` as direct scene children. The raycaster with `allMeshes, true` would still skip them because they're not in the array — the `recursive` flag means it searches descendants of the provided objects, but the provided objects themselves must still be in the array.
Wait, `intersectObjects(allMeshes, true)` takes an array of objects to test. If a cubie mesh is no longer a direct child of scene, it's still in the `allMeshes` array. The function checks each object in the array and their descendants. So even if the cubie mesh is now nested under pivot, it's still in `allMeshes` and will be tested. The `recursive` flag tells it to also test objects nested under each of the provided objects. Since the cubie itself is in the list, it will be tested. And since their children (none) would also be tested, it doesn't matter. So this should be fine.
Actually wait — Three.js `intersectObjects` works like: iterate through the array, call `object.raycast()` on each, and if `recursive=true`, also iterate through `object.children` and test them. Since each cubie mesh has no children, only the mesh itself is tested. So it's fine.
Wait but what if the mesh is still in allMeshes but its world matrix wasn't updated because it's nested in pivot? The raycaster uses the object's world matrix. If the mesh's world matrix is stale (because we didn't call updateMatrixWorld on the mesh itself, only on pivot), then it might be wrong. But typically Three.js updateMatrixWorld is called recursively by the renderer before rendering. Since we don't call scene.updateMatrixWorld() before raycaster in our onDown/onMove, the world matrices could be stale.
To fix, I should call `scene.updateMatrixWorld()` before raycaster tests, or at least ensure all objects are up to date.
Better: in `getHit`, call `scene.updateMatrixWorld()` before raycasting.
```javascript
function getHit(e) {
const r = renderer.domElement.getBoundingClientRect();
mouseNdc.x = ((e.clientX - r.left) / r.width) * 2 - 1;
mouseNdc.y = -((e.clientY - r.top) / r.height) * 2 + 1;
raycaster.setFromCamera(mouseNdc, camera);
scene.updateMatrixWorld();
return raycaster.intersectObjects(allMeshes, true);
}
```
Or simpler: use `pivot.attach(cubie.mesh)` which updates matrices automatically? Hmm, `attach` does updateMatrixWorld on the parent, but I'm not sure it cascades to siblings.
Let me just add `scene.updateMatrixWorld(true)` in getHit. This ensures world matrices are fresh.
Actually a cheaper approach: after attaching/detaching in rotateSlice, I call `pivot.updateMatrixWorld()`. The pivots' children's world matrices are updated. But the cubies inside pivot might not have their own updateMatrixWorld called. The raycaster on the cubie mesh will call `object.raycast()`. The raycast uses the mesh's world matrix, which is `mesh.matrixWorld`. If not explicitly updated, it could be stale.
In `rotateSlice`, I call `pivot.updateMatrixWorld()` after attaching children. This updates pivot and all its children's world matrices. So the meshes in `allMeshes` would be correctly transformed.
After `scene.attach(c.mesh)` in `finalizeRotation`, the mesh's parent becomes scene (identity), and its local position becomes its world position (since we call `scene.attach` which is... hmm, `scene` might not have `.attach`. Actually it's `THREE.Object3D.prototype.attach`, inherited by scene. So it should preserve world transform.
After that, `pivot.updateMatrixWorld()` clears the pivot rotation. That's enough.
I think the matrices should be OK for most cases. But adding `scene.updateMatrixWorld(true)` before raycaster adds a tiny bit of overhead and avoids stale matrix bugs. I'll add it for safety.
Next issue: the `makeFaceColor` function is unused. Harmless.
Another concern: in onDown, `e.preventDefault()` might fail with touch events if not in passive mode? But I didn't mark pointerdown as passive. It's fine.
Another potential issue: `renderer.domElement.style.cursor` in onMove. But if faceDrag is set and dragLocked=true, onMove skips the cursor update. Actually in the code, `onMove` doesn't update cursor while faceDrag is active. The cursor was already set to 'grabbing'. After release, onUp resets to default. That's fine though the cursor might be 'grabbing' even when hovering background after releasing during animation... but `faceDrag` is reset in onUp. Hmm, but onUp stops holding down. The cursor style might not reset immediately unless another event triggers it. Let me add cursor reset in onUp.
Actually after onUp, faceDrag is null. The next pointermove will call `getHit(e)` and set cursor appropriately. But if there is no pointermove after onUp, the cursor stays 'grabbing'. Minor issue, but let me fix.
Add in `onUp`: `renderer.domElement.style.cursor = 'default';`
Or in the cursor pointermove handler, if not faceDrag and not animState, set based on hit. If animState, set wait. This handler runs at every pointermove, so after the pointer up, the next move updates.
Actually since `orbitDrag` is also null after onUp, and `animState` might be set, and `faceDrag` is null, the cursor pointermove would:
- if animState → wait
- else based on hit → grab/default
So on move it's fine. Just the moment right after onUp before next move, cursor is grabbing. That's a one-frame issue essentially.
Let me fix this by not special-setting cursor in onDown/onMove, but only through the dedicated cursor pointermove handler:
1. Remove cursor changes from onDown, onMove, startOrbit.
2. Always update cursor via pointermove handler that covers all states.
Update cursor handler:
```javascript
renderer.domElement.addEventListener('pointermove', (e) => {
let cursor;
if (animState) {
cursor = 'wait';
} else if (faceDrag) {
cursor = orbitDrag ? 'grabbing' : 'grabbing';
} else {
const hit = getHit(e);
cursor = hit.length > 0 ? 'grab' : 'default';
}
if (orbitDrag && !faceDrag) cursor = 'grabbing';
if (renderer.domElement.style.cursor !== cursor) {
renderer.domElement.style.cursor = cursor;
}
}, { passive: true });
```
Actually let me simplify. Just have one place set cursor: the global pointermove.
OK let me make a more thorough cleanup pass.
Also I see a bigger logic issue: the second `makeMaterials` function in my first draft has a dead first definition. The dead def is harmless but shows I had a mistake. Let me remove it.
```javascript
function makeMaterials(x, y, z) {
return [0,1,2,3,4,5].map(i => {
const color = [x,y,z,-x,-y,-z][i]; // issue — let me use explicit mapping
});
}
// BoxGeometry sides order: +X -X +Y -Y +Z -Z
function makeMaterials(x, y, z) {
...
}
```
The first one returns undefined since map callback doesn't return. Overwritten by second. Harmless but ugly. Let me remove first.
Another potential issue: the `onMove` handler during face drag uses `worldDeltaScreen(dx, dy)` where `dx, dy` are the delta from faceDrag start. The threshold is 25. Once crossed, we determine axis. But if user continues dragging, we don't use the new deltas. That's fine — we commit once.
What if the user's drag direction changes? They drag mostly up, threshold crosses, we lock in +Z axis. If their intent was to eventually drag right, too late. That's acceptable for this level of interaction. In a more advanced version, we'd do live preview.
Actually maybe we should wait for the user to also cross in the dominant direction after the drag enters the threshold zone? Specifically, wait a bit and see if the angle of drag is stable. But simplest is OK.
One more concern: `intersectObjects(allMeshes, true)` — `allMeshes` has direct scene children only. After `pivot.attach(cubie.mesh)`, the mesh is no longer in the scene's children list. But it's still in `allMeshes` array. Does the raycaster still test it? Yes! Because `intersectObjects` iterates the provided array. Each mesh in `allMeshes` is individually tested, regardless of parent. The `true` flag is only for testing descendants of the provided objects, not needed here since we provide all meshes directly. Good.
But wait: after `pivot.attach(mesh)`, does `mesh.raycast` work correctly? The mesh's geometry/transform are still valid. The world matrix is updated by pivot.updateMatrixWorld() in rotateSlice. So yes, it works.
However, there's a subtle issue: during live drag (which we don't have), if the mesh is being reoriented, we need world matrix to be fresh. For our approach, we only test on pointerdown/pointermove (before anim starts), so matrices should be up to date since no rotation is happening during that phase.
Alright, let me also check: the raycaster uses face normal. BoxGeometry has face normals as local. For a BoxGeometry, each triangle face's normal is in the local coordinate of the geometry (pointing outward). We transform it with the mesh's world quaternion to get world normal. That's fine.
One more issue: `getHit` returns the first hit. If there are overlapping hits (e.g. inner faces behind outer faces), we get the face of the closest intersection. For a Rubik's cube with a gap, the closest face is the outer face. Good.
But what about the inner faces of other cubies? If a cubie is at (1,0,0) and its right face is at x=1.46, the cubie at (0,0,0) might have its right face at x=0.46. From outside, you'd never see the inner face of another cubie through the outer face because the outer face is solid. Raycaster gives the first opaque surface (we don't use backface culling matters? Actually MeshStandardMaterial default side is FrontSide. The ray only hits front face. So for a box with front faces pointing out, rays from outside hit the front faces only. Good.
Wait — when rotating, the cubies at (1,y,z) are in the pivot. From outside, we might see their "-X" face if they're at +X layer? No, their outer face is +X which is on the outside of the cube. Rays hit that. Good.
Now: `getHit` also gets called in the cursor pointermove. If animState is active, the cursor is set to 'wait'. The cursor handler also calls getHit, which updates matrixWorld unnecessarily. But that's only once per pointermove, quickly. OK.
Actually, a cleaner cursor handler:
```javascript
renderer.domElement.addEventListener('pointermove', (e) => {
if (animState || faceDrag) {
renderer.domElement.style.cursor = animState ? 'wait' : 'grabbing';
return;
}
const hit = getHit(e);
renderer.domElement.style.cursor = hit.length > 0 ? 'grab' : 'default';
}, { passive: true });
```
This removes redundant getHit when animating or dragging.
One more issue: if animState is active and the user moves the mouse over the cube, the cursor is 'wait' and they can't drag. That's expected.
Now let me also consider the touch experience. We use pointer events which unifies touch and mouse. Touch has one contact point. Orbit on touch would be... requires two fingers. Our current code doesn't distinguish. For simplicity, single-touch = drag/orbit, two-touch would be harder. I'll leave it; single touch works.
Let me also make sure that `scene.attach` is preserved.
Now I want to test the cube visually to make sure colors are right. The standard (WCA) is:
- White opposite Yellow
- Red opposite Orange
- Blue opposite Green
And when White is up (+Y), Red on right (+X), Green in front (+Z). From observer looking at +Z, +Y up, +X right. Let's verify:
- +Y = White (U)
- -Y = Yellow (D)
- +X = Red (R)
- -X = Orange (L)
- +Z = Green (F)
- -Z = Blue (B)
Yes.
Now: are our rotations consistent with WCA?
Pressing the top layer clockwise when looking at it = clockwise around +Y when looking from above. Right-hand rule: thumb along +Y, fingers curl from +X to +Z. So (1, y, -1) → (-1, y, 1) ... formula: (x, y, z) → (-z, y, x). So +X goes to +Z, +Z goes to -X. Looking down, +X (right) moves toward +Z (front/bottom screen). Clockwise rotation around +Y: yes, right moves to front, front moves to left, left moves to back. That's clockwise from above. ✓
Now let me also verify: if user clicks upper face and drags right (their right on screen = world -Z roughly from our camera angle), cross(N=+Y, wd=-Z) = cross((0,1,0),(0,0,-1)) = (1*(-1)-0*0, 0*0-0*(-1), 0*0-1*0) = (-1, 0, 0). pivotAxis = 'x', crossVec.x = -1 < 0 → sgn = -1. Angle = -π/2 around X.
Rotating around X by -π/2: from right viewpoint (looking from +X), thumb -X, fingers curl from -Z to +Y... hmm hard to visualize. But the mathematical consistency is fine; the real test is whether it "feels" like twisting the face in the direction of drag. The sign might be inverted. If so, flipping the sgn logic fixes it.
Let me now finalize by fixing the minor issues and rewriting the file cleaner.
Key fixes:
1. Remove dead `makeFaceColor`
2. Remove dead `const sgn = ...`
3. Simplify `sgn` calculation
4. Add scene.updateMatrixWorld in getHit
5. Clean up cursor handler
6. Update onUp to reset cursor
Let me write a cleaner final version.
Wait — I also realized there could be an issue with `pivot.attach` vs `attachPreserveWorld`. In Three.js r125+, `Object3D.attach` keeps world transform. In older versions (r52-r99?) it might not exist. The three.min.js in the cwd might be old. Let me check by looking at it... it's 603KB, likely a fairly recent version (r120-ish maybe).
Actually I can check by doing `grep -m1 "r" /workspace/three.min.js | head -c 100`... actually grep for a version number in the file header or footer.
Let me grep the Three.js build info.{"command":"head -c 200 /workspace/three.min.js; echo; tail -c 100 /workspace/three.min.js"}/** * @license * Copyright 2010-2021 Three.js Authors * SPDX-License-Identifier: MIT */ !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&de opeEnding=V,t.ZeroStencilOp=0,t.sRGBEncoding=Y,Object.defineProperty(t,"__esModule",{value:!0})}));
thinking
It's a modern-ish Three.js (2021 version). The `outputColorSpace` property may or may not exist. It uses `sRGBEncoding` for older versions. Let me just guard with a check. Also, `outputColorSpace` was introduced in r152 maybe. For 2021 version, it might still use `outputEncoding`. Let me keep the check.
Also, three.min.js is a full build including everything. Standard materials, box geometry, etc. should all be there.
Now let me clean up the code and fix the minor issues. I'll rewrite the file properly this time.
One more consideration: `e.button` check. For pointer events, `e.button` might be undefined for touch. For pen it might also be 0. I'll just check `e.button && e.button !== 0`.
Also important: in `onDown`, `e.preventDefault()` — for pointer events on canvas, this doesn't prevent embeds. But it prevents default browser touch actions. Good.
Now, let me make sure the cube doesn't flip its normals incorrectly when rotating. The normals in `LOCAL_NORMALS` are for the box geometry (+X, -X, etc.). When we raycast, we hit a face whose normal in local space is one of these. We apply mesh world quaternion to get world normal. Since we attach mesh to pivot and pivot rotates, the world quaternion reflects that. Good.
Another thing: after `scene.attach(cubie.mesh)`, the mesh's world matrix is preserved. But its local position is set to the world position (since scene is identity). So `c.x = Math.round(p.x)` correctly snaps to the axis-aligned grid position. ✓
When we then call `pivot.rotation.set(0,0,0)` and `pivot.updateMatrixWorld()`, this doesn't affect cubies that are no longer children of pivot. So their positions remain. ✓
During the animation with `tickAnim`, `pivot.rotation[axis] = t * targetAngle`. This means the pivot is gradually rotating. The cubies attached to it are visible. Their world matrices are refreshed because `pivot.updateMatrixWorld()` is called after setting rotation. ✓
Now let me also think about resetCube: when reset is called, buildCubies is called which removes old meshes from parent and creates new ones. That effectively reinitializes the entire cube. The old pivot rotation was cleared, animState was nulled. Good.
Let me also ensure that buildCubies cleans up the geometry. Each cubie shares one BoxGeometry. On reset, we remove the mesh but we don't dispose the geometry or materials. Memory leak minor but acceptable for this app (it's never run long enough for this to matter).
Now let me write a clean, corrected version.
Let me also think about the "live preview during drag" question. Is there a clean way to implement it?
- On pointerdown, record faceDrag info
- On first pointermove (or immediately), attach cubies to pivot, set pivot.rotation to 0
- On subsequent moves, update pivot rotation proportionally to drag magnitude
- On pointerup:
- If |angle| > threshold: snap to ±π/2 and animate from current to target
- Else animate to 0 and detach
This makes the app feel much more responsive. Let me see if I can add this with reasonable code.
```javascript
let livePivot = {
active: false,
axis: null,
normalAxis: null,
sliceValue: 0,
members: [],
sign: 1,
};
function updateLiveDrag(dx, dy) {
// Called on pointermove while faceDrag is active
if (!faceDrag) return;
if (!livePivot.active) {
// Determine rotation axis, attach cubies
const N = faceDrag.normal.clone().normalize();
const wd = worldDeltaScreen(dx, dy);
const crossVec = new THREE.Vector3().crossVectors(N, wd).normalize();
const comp = [Math.abs(crossVec.x), Math.abs(crossVec.y), Math.abs(crossVec.z)];
const mx = comp.indexOf(Math.max(...comp));
if (comp[mx] < 0.35) return;
const axss = ['x','y','z'];
const pAxis = axss[mx];
const pSign = crossVec[pAxis] > 0 ? 1 : -1;
const nAbs = [Math.abs(N.x), Math.abs(N.y), Math.abs(N.z)];
const n = nAbs.indexOf(Math.max(...nAbs));
const nAxis = axss[n];
const sliceValue = Math.round(N[nAxis]);
// Attach
livePivot = {
active: true,
axis: pAxis,
normalAxis: nAxis,
sliceValue,
members: cubies.filter(c => c[nAxis] === sliceValue),
sign: pSign,
};
pivot.rotation.set(0,0,0);
if (livePivot.members.length) {
livePivot.members.forEach(c => {
if (c.mesh.parent) c.mesh.parent.remove(c.mesh);
pivot.attach(c.mesh);
});
pivot.updateMatrixWorld();
}
}
if (!livePivot.active) return;
// Update live rotation
const angle = Math.hypot(dx, dy) * 0.005 * livePivot.sign;
pivot.rotation[livePivot.axis] = angle;
pivot.updateMatrixWorld();
}
function finishLiveDrag() {
if (livePivot.active) {
const cur = pivot.rotation[livePivot.axis];
const tgt = Math.abs(cur) > 0.2 ? Math.sign(cur) * Math.PI/2 : 0;
// Animate from current to target
pivot.rotation[livePivot.axis] = 0; // reset for clean anim from 0
pivot.updateMatrixWorld();
animState = {
axis: livePivot.axis,
targetAngle: tgt,
elapsed: 0,
duration: 120,
members: livePivot.members,
};
livePivot = { active: false, axis: null, normalAxis: null, sliceValue: 0, members: [], sign: 1 };
}
}
```
And in onMove:
```javascript
function onMove(e) {
if (faceDrag && !livePivot.active && !animState && !dragLocked) {
const dx = e.clientX - faceDrag.startX;
const dy = e.clientY - faceDrag.startY;
if (Math.hypot(dx, dy) > 5) { // quick threshold
updateLiveDrag(dx, dy);
}
} else if (faceDrag && livePivot.active && !animState) {
const dx = e.clientX - faceDrag.startX;
const dy = e.clientY - faceDrag.startY;
updateLiveDrag(dx, dy);
} else if (!faceDrag) {
moveOrbit(e.clientX, e.clientY);
}
}
```
But this is more complex and the current simpler version is already decent. I'll stick with the simpler version for now and just clean up the bugs.
Wait, actually I realized a faster/simpler improvement: instead of threshold at 25 pixels and then a 180ms animation, I can implement live preview by just attaching to pivot immediately on first significant move and then animating on release. The code is actually not that long. Let me try including it because it makes the cube feel much better.
Plan:
- faceDrag = { cubie, normal, startX, startY }
- onMove:
- if faceDrag && !livePivot.active && !animState:
- small drag → compute axis, attach, set livePivot
- if livePivot now active → update pivot rotation
- if faceDrag && livePivot.active && !animState:
- update pivot rotation
- else if !faceDrag: orbit
- onUp:
- if livePivot.active → snap (animate to ±π/2 or back to 0)
- else if faceDrag → release (no action)
This is essentially what I sketched above. Let me implement it.
However, there's a complication: the current `livePivot` state needs to be cleared when modifiable by `finalizeRotation` only if the animation was started from livePivot. The animation is driven by `animState`. When we start `animState` from `finishLiveDrag`, the members are consumed by `finalizeRotation`. After `finalizeRotation`, `animState = null` and `livePivot.active = false` is cleared. Good.
But there's a conflict: `animState` can be set from other places too (scramble). During scramble, pointer events are ignored because `onDown` checks animState. But onUp might fire during a scramble animation. In that case, `finishLiveDrag` would see `livePivot.active = false` (since we didn't start it during scramble), and do nothing. Good.
Let me also consider: if user does a live drag to some angle, then rapid click-up before animState is set, another pointerdown might start before the snap animation finishes? No, because onDown checks `if (animState) return`. While snap animation is running (`animState` set), subsequent pointerdown is blocked. Good.
Now, let me also think about: what if user touches the cube with two fingers quickly? Each pointerdown gets an ID. We only have one livePivot at a time. Second pointerdown while first is live-pivoting would be ignored, which is fine.
Alright, let me add the live preview code. It's a bit more code but makes the UX 3x better.
Actually, you know what, let me just stick with the simpler version (threshold-commit-animate). The live preview adds complexity and possible bugs that might not be worth it. The user asked for an interactive 3D Rubik's Cube. The simpler version works.
Let me finalize by cleaning up the version I have and testing it mentally:
1. onDown on cube → faceDrag set, cursor = 'grabbing'
2. onMove:
- calculate dx, dy
- if dist > 25, compute cross, snap to axis
- start rotateSlice which attaches cubies to pivot
- sets animState
3. onUp resets faceDrag
4. tickAnim rotates pivot
5. after duration, finalizeRotation detaches and snaps
One subtle bug: after step 2 starts animState, if user keeps moving the mouse, onMove does nothing (because `faceDrag && !dragLocked && !animState` requires !animState). But in my code, `onMove` checks:
```javascript
if (faceDrag && !dragLocked && !animState) {
// ...
}
```
After animState is set (by rotateSlice), this condition is false. Good.
On up, faceDrag is cleared. animState becomes null after finalizeRotation (which takes 180ms). During those 180ms, user can't interact. Good.
Now one thing: `dragLocked` becomes true immediately after threshold. It's never reset while animState runs. Once animState clears, dragLocked is reset in onUp. Good.
What happens if user presses down on cube face and doesn't drag, then releases? faceDrag is set, no threshold, onUp resets. Nothing happens. OK.
Let me also make sure: if user presses down on cube face, starts drag, then returns to start (dx²+dy² < 25)... no, once threshold crossed, committed regardless. Hmm. 25 pixels is small enough that it's a quick flick anyway.
Actually, 25 is quite small, meaning almost any intentional drag commits. That's fine.
Let me now rewrite the file with the fixes. I'll also add the explicit face material helper that was dead and remove it.
Actually, you know what, the dead code is fine — it's not hurting anything. But it's messy. Let me remove it.
Also `cross[mx || mx]` is dead; the next line with `const s = cross[rotAxis] > 0 ? 1 : -1;` is what we actually use. Let me remove the dead variable.
Also in `onDown`:
```javascript
if (e.button && e.button !== 0) {
startOrbit(e.clientX, e.clientY);
return;
}
```
This means for button=0 (primary), it continues normally. For button=2 (right) or button=1 (middle), it orbits. OK.
OK, final version coming up. I'll write it out.
Actually wait — one more critical thought. When I use `intersectObjects(allMeshes, true)`, the `allMeshes` array contains 27 cubie meshes. As we rotate, some cubies are reparented to pivot. But they're still in the `allMeshes` array. Raycaster tests each mesh regardless of parent, using their world matrix (which should be up to date since we call `pivot.updateMatrixWorld()` in rotateSlice). So hit detection works.
BUT there's one issue: if the user sees the cube during animation, raycaster tests are stale (world matrices not updated between frames unless we call updateMatrixWorld). I'll fix this by adding `scene.updateMatrixWorld()` to `getHit`.
Also in `onDown`, by the time the user has moved 25 pixels, the animation has been going for... 180ms total, but they were just dragging in pre-animation phase. The animation started on threshold crossing, so when we call `getHit` we're outside animState. OK.
Alright, here's my final plan for the code (rewriting the whole thing cleanly):
```html
<!DOCTYPE html>
<html lang="en">
<head>
...
</head>
<body>
...
<script src="three.min.js"></script>
<script>
(() => {
// Colors
// Scene, camera, renderer
// Lights
// Build cubies (helper makeMaterials)
// Orbit controls (start/move/end, zoom)
// Slice rotation (rotateSlice, finalizeRotation, tickAnim)
// Face drag (getHit, onDown/onMove/onUp)
// Scramble
// Reset
// UI buttons
// Cursor
// Resize
// Loop
})();
</script>
</body>
</html>
```
Writing now.I see a few cleanup opportunities in the current file: dead variable declarations, stale matrix warnings, and a more consistent cursor update. Let me rewrite a cleaner, corrected version:
/workspace/index.html (12850 bytes)Successfully wrote 12850 bytes to /workspace/index.html
thinking
Good. The file has been written. Let me verify it works by checking for any syntax errors using node.
{"command":"node --check -e \"$(sed -n '/<script>/,/<\\/script>/p' /workspace/index.html | sed 's/<script>//' | sed 's/<\\/script>//')\" 2>&node: either --check or --eval can be used, not both
thinking
Let me just extract the script and check it with node --check.
{"command":"sed -n '/<script>/,/<\\/script>/p' /workspace/index.html | sed '1d;$d' > /tmp/test_slice.js && node --check /tmp/test_slice.js 2(no output)
thinking
The script parses cleanly. Let me verify the file exists and is complete.
{"command":"ls -la /workspace/index.html && wc -l /workspace/index.html"}-rw-r--r-- 1 node node 12858 Jul 22 18:03 /workspace/index.html 356 /workspace/index.html
thinking
The file is there and the JavaScript parses. Let me also do a quick sanity check: open it in a browser? No browser here. But I can verify structure with grep checks.
Let me make sure:
- three.min.js is loaded via script src
- No external CDN links
- All CSS is inline
- All JS is inline
- Scramble and reset buttons exist
Also, one more thing: there's a subtle issue with `tickAnim` and the `last` timestamp. `last` is initialized to `performance.now()` after `buildCubies()` and `applyCamera()`. On the first frame, `dtMs = now - last` which is tiny. OK.
However, in `finalizeRotation`, after detaching cubies and resetting pivot, I don't call `c.mesh.updateMatrixWorld()`. Not needed because rendering will update.
One more concern: during animation, the cubies' parent is `pivot`. The pivot has a dynamic rotation. When raycasting (for, example, in cursor pointermove), the matrices need to be up-to-date. I added `scene.updateMatrixWorld(true)`. But during animation, the raycaster is called only in cursor pointermove which checks `animState` first and returns early with 'wait'. So getHit is not called during animation. Good.
But what if during a live drag (not implemented yet), we'd call getHit? Not needed.
Let me also double-check that after `pivot.attach(cubie.mesh)` the cubie's world position remains. Yes, `attach` does that.
Final check: make sure `rotateSlice(a, sl, a, ...)` is correct for scramble. For the X layer, we want to rotate around X. `normalAxis = 'x'`, `sliceValue = -1/0/1`, `pivotAxis = 'x'`. Yes.
Wait, I realize that in `rotateSlice`, I pivot.rotate the members. When rotating around axis `pivotAxis` (e.g., 'x'), that uses pivot's local rotation which is world-aligned. So the cubies rotate around the world X axis. Since the cubies are always axis-aligned on the grid (even though they're visually rotated), rotating around world axis is correct.
Actually, is the resulting rotation orientation correct? Let's trace a scenario:
- Cubie at (1, 0, 0) with red on +X face (in local).
- User clicks +X face. N = +X. Drag triggers rotation around pivotAxis.
- In this case, `pivotAxis` could be 'x' (if drag was horizontal) or 'z' (if drag was vertical).
Wait, if the user clicks the +X face:
- Normal = +X. `nAxis = 'x'`, `sliceValue = 1`.
- drag up (world Drag ~ +Y). cross(N=+X, wd=+Y) = +Z. pivotAxis = 'z' (sign positive).
- So we rotate around Z by +π/2.
Effect on member cubies (where x=1):
- After +π/2 around Z: (1, y, z) → (z, y, -1)... wait no, standard rotation around Z:
x' = x cosθ - y sinθ
y' = x sinθ + y cosθ
z' = z
For θ = π/2: (1, y, z) → (-y, 1, z).
Wait that's wrong. Let me recalculate.
Rotation around Z by +π/2 (right-hand rule, thumb +Z):
x' = x cos(π/2) - y sin(π/2) = 0 - y*1 = -y
y' = x sin(π/2) + y cos(π/2) = x*1 + 0 = x
z' = z
So (1, y, z) → (-y, 1, z).
Now the cubie at (1,0,0) → (0,1,0). So it's now at the top middle. Its local +X face was red. After rotation, its world transform is rotated by π/2 around Z. The +X face (red) now points in world +Y direction.
After `scene.attach`, the mesh's local position is (0,1,0). Its local quaternion has Z rotation baked in. Next time we click its +Y face (white top), the face normal (extracted via world quaternion) would be world +Y. User drags up → worldDrag ~ +Y. cross(N=+Y, wd=+Y) ≈ 0. That fails the axis test.
Hmm, but visually y=1 row has white tops. The top face of the cubie after rotation should show white. In the initial state, +Y faces are white. After the Slice rotates around Z, the cubie rotates 90° around Z. What was the +Y face now points... in world +Y? Wait, +Y was the top of the cubie. Rotating around Z by 90° doesn't change +Y direction (since Y is in the XY plane, Z is the axis). Actually +Y becomes +X in world. Yes, +Y goes to +X. So now the red face (previously +X) pointed in world +Y. And the white face (previously +Y) pointed in world +X.
So after the rotation, the cubie at (0,1,0) has its white face pointing world +X, and red face pointing world +Y.
If the user then clicks the +Y-facing face of this cubie (white material), how does hit detection work?
On click, raycaster hits the face. `hit.face.materialIndex` returns local face index for white face, which is index 2 (+Y in BoxGeometry). We then apply mesh's world quaternion to get world normal: LOCAL_NORMALS[2] = (0,1,0) rotated by current world quternion = (sin, cos, 0)... some world direction.
After rotation by π/2 around Z, the mesh's world quaternion corresponds to that rotation. (0,1,0) rotated around Z by π/2 → (1,0,0) in world = +X.
So worldNormal = +X. This means the user "clicked" on the +X face of the cube.
Then user drags to rotate this face... well, the logical face is +X of the cube (the side). So rotating around the X layer is appropriate.
This all makes sense and works correctly because we always derive the face normal from the mesh's world orientation.
OK the logic is sound.
One more thing: after many rotations, the cubie's local quaternion will have accumulated rotations from multiple axis-aligned 90° turns. The quaternion values might drift from exact values (π/2 multiples) due to floating point. After pnly a few turns, drift is tiny. Visually, boxes rotate correctly. After 100s of turns, maybe tiny artifacts. Not a concern for this app.
Now, let me also check for a bug: `localNormal.applyQuaternion().normalize()` — `applyQuaternion` modifies in-place. `LOCAL_NORMALS` is a constant array of Vector3. In `onDown`, I do `LOCAL_NORMALS[hit[0].face.materialIndex].clone().applyQuaternion(...).normalize()`. I clone first, so LOCAL_NORMALS is preserved. Good.
Another edge case: if user clicks a face whose materialIndex is undefined (shouldn't happen for BoxGeometry). If it is undefined, LOCAL_NORMALS[undefined] is undefined, crash. But for BoxGeometry, faces always have materialIndex. Good.
Let me also test: do we have the right orientation of standard Three.js Material index? BoxGeometry generates sides in order: +X -X +Y -Y +Z -Z. Yes. And LOCAL_NORMALS array order matches.
What about non-indexed face normals? BoxGeometry in Three.js supports both indexed and non-indexed. By default, newer Three.js creates a non-indexed BufferGeometry for BoxGeometry. Each face (tri) has its own normal. The normal for +X faces is (1,0,0). So intersection.face.normal matches. Good.
In fact, `intersection.face` is a THREE.Face3 (in deprecated Geometry) or a face from BufferGeometry. In BufferGeometry, `intersection.face` is a `{a, b, c, materialIndex, normal}` object. Wait, no: in BufferGeometry, faces are triangles with indices a,b,c. The normal is in `intersection.face.normal`. For BufferGeometry with non-indexed faces, the face normal is per triangle. For a box, each quad has 2 triangles. The normals of both triangles should be the same (outward). So materialIndex is also correct.
Actually in Three.js BufferGeometry, `intersection.face.normal` isn't always populated directly for indexed geometry. Let me check: `Raycaster.intersectObject` creates `intersection.face` as an object with `normal`, `materialIndex`, etc. For BufferGeometry, there's `face.normal` computed. I think it's populated in recent versions. If not, we can use `intersection.face.normal` or fall back to `intersection.object.geometry.faces[i]`.
Actually, in modern Three.js (r125+), `intersection.face.normal` is available and is `THREE.Vector3`. The code should work.
Alternative: for robustness, I can use `intersection.face.materialIndex` to determine the face direction directly, bypassing normal computation. That's actually what I'm doing: `LOCAL_NORMALS[hit[0].face.materialIndex]` gives the local normal. Then I apply world quaternion. So even if `intersection.face.normal` is missing or wrong, the materialIndex approach works. Good.
One thing: I'm not actually using `intersection.face.normal`. I'm using `materialIndex`. Good.
Now, just to be thorough, let me check that `intersection.face.materialIndex` exists for BoxGeometry in three.js. Yes, in BufferGeometry, each face group has a materialIndex attribute (if multi-material). BoxGeometry with an array of 6 materials creates groups with indices. When intersecting, the face's materialIndex is set. Good.
Let me also double-check: the `intersection.face.materialIndex` for a BoxGeometry with an array of materials, returned by `raycaster.intersectObjects`. In Three.js r125+, yes, it returns the materialIndex of the intersected face (the group). Hmm actually it may return -1 or something if the face normal is `null`. Let me not worry about it; modern Three.js handles this.
Alright, the code looks good. Let me do one last quick check — the file contents look correct. Let me just make sure the hint text is right and all HTML tags are closed.
Reading a bit of the file to confirm structure:
- `<!DOCTYPE html>` ✓
- `<html lang="en">` ✓
- `<head>...<style>...</style></head>` ✓
- `<body>`
- `<div id="ui">...buttons...</div>`
- `<div class="hint">...</div>`
- `<script src="three.min.js"></script>`
- `<script>...main app...</script>`
- `</body></html>`
Looks good. The script is self-contained.
Actually, one more minor thing: when `event.pointerType` is 'pen' and button check is needed, `e.button` is 0 for pen primary contact. Our check `e.button !== 0` would treat pen like mouse. Fine.
Also for touch, `e.button` is 0 for the primary pointer. Same. No issues.
I think the file is complete and functional. The answer can be summarized as "index.html has been created at /workspace/index.html".The file is in place and syntax-checked. I’m appending a quick self-test snippet to verify the runtime logic works correctly without a browser:
{"command":"sed -i '1a <script>console.log && console.log(\"index.html written:\", new Date());</script>' /workspace/index.html && node -e \File size: 12930 bytes Has three.min.js script tag: true Has NO CDN links: true Has internal <style>: true Has internal <script>: true Has Scramble button: true Has Reset button: true Has rotateSlice: true Has orbitDrag: true Has raycaster: true Has WCA colors: true
thinking
Oops, I added a script tag at the top in the <head>/before content with `sed -i '1a ...'`. That's bad - it inserted a script tag before the doctype. Let me fix it.
{"limit":10,"path":"/workspace/index.html"}<!DOCTYPE html> <script>console.log && console.log("index.html written:", new Date());</script> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <title>Rubik's Cube 3D</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; overflow: hidden; background: #0b1120; [348 more lines in file. Use offset=11 to continue.]
thinking
I need to remove the injected script tag. Let me edit it out.
{"edits":[{"newText":"<!DOCTYPE html>\n<html lang=\"en\">","oldText":"<!DOCTYPE html>\n<script>console.log && console.log(\"index.html writtSuccessfully replaced 1 block(s) in /workspace/index.html.
thinking
Let me verify the fix.
{"limit":3,"path":"/workspace/index.html"}<!DOCTYPE html> <html lang="en"> <head> [354 more lines in file. Use offset=4 to continue.]
thinking
Good, cleaned up. Let me verify the script tag wasn't needed - the file should just be the original content.
{"command":"wc -l /workspace/index.html && head -n 1 /workspace/index.html && grep -c 'three.min.js' /workspace/index.html"}356 /workspace/index.html <!DOCTYPE html> 1
thinking
Wait, wc -l shows 356 lines but the file originally had 356 lines too. Actually the sed insertion added a line then I removed it, so it should be back to original. But 356 lines includes the script tag at the bottom. Let me check if the structure is correct. Actually the problem is that the wc command earlier before my sed was 356 lines, then after adding and removing I'm back to 356 lines. Hmm, that's suspicious - maybe the sed insertion was offset by some other change? Let me just check the structure of the file. But wait, when I wrote the file before it was 356 lines, then I appended a line at position 1a, then edited it out. So it should be back to exactly what I wrote originally. Actually, looking again at the file count: earlier wc -l said 356. That was after writing. Then I appended a line = 357. Then I removed = 356. So it's back to original line count, which is fine. Let me verify the HTML structure is intact by checking the end of the file.
{"command":"tail -n 5 /workspace/index.html"}})(); </script> </body> </html>
thinking
The file structure is intact. It ends with the closing `</script>`, `</body>`, `</html>`. Let me do a final comprehensive verification to make sure everything is in order.
{"command":"sed -n '/<script>/,/<\\/script>/p' /workspace/index.html | sed '1d;$d' > /tmp/test_slice.js && node --check /tmp/test_slice.js 2JS valid
thinking
The file is complete and valid JavaScript. Let me summarize what was built. The task was: - Single-page web app - All inline in one index.html file - CSS in <style>, JS in <script>, no external resources/CDNs - Uses the locally-provided three.min.js with <script src="NAME"></script> - Interactive 3D Rubik's Cube - Features: clickable face rotations, orbit-drag camera, scramble button What I built: - WCA standard colored 3x3x3 Rubik's cube using Three.js - Each cubie is a THREE.Mesh with 6 materials (colors or inner black) - Orbit camera: background drag rotates view, scroll zooms - Face rotation: click on a face and drag past threshold; the cross product of face normal and screen drag determines pivot axis; rotates slice 90° with ease-in-out-cubic animation over 180ms - Scramble: randomized 24 moves with 70ms per move and 95ms total interval - Reset: rebuilds cubies - Cursor updates based on state (grab/grabbing/wait) - Responsive: resize listener - Clean modern UI with frosted glass panel, hint at bottom Everything is inline and self-contained. Actually wait, let me also quickly verify there are no external URLs.
{"command":"grep -iE 'https?://|//cdn|//unpkg|//googleapis' /workspace/index.html || echo \"No external URLs found\""}No external URLs found
thinking
No external URLs. The file is complete and clean. I should summarize what was built and how to use it.
`/workspace/index.html` is complete. Here’s a summary of what was built: **Overview** A self-contained, single-file interactive 3D Rubik's Cube using the local `three.min.js` via `<script src="three.min.js"></script>`. All CSS lives in a `<style>` tag and all logic in a `<script>` tag — no external resources, no CDNs, no network requests. **Rubik's Cube structure** - 27 cubies arranged in a 3×3×3 grid, each a `BoxGeometry(0.92, 0.92, 0.92)` with 6 materials. - WCA standard coloring: White (+Y), Yellow (−Y), Red (+X), Orange (−X), Green (+Z), Blue (−Z). - Inner faces are dark so the internal seams read as the black plastic of a real cube. **Interactivity** - **Face rotations**: `Raycaster` detects the clicked face; the face normal (transformed to world space) plus the screen drag vector determine the slice and pivot axis. Dragging past a 25 px threshold commits a smooth 90° rotation animated with an ease-in-out cubic curve. - **Orbit camera**: Drag on empty space orbits the camera around the cube using spherical coordinates (θ/φ/radius). Scroll wheel zooms. - **Scramble button**: Chains 24 randomized single-layer moves at ~70 ms each. - **Reset button**: Rebuilds the cube to its solved state. **Rendering** - Multi-directional `MeshStandardMaterial` with `ACESFilmicToneMapping` for a clean plastic look. - Key light from the upper-right front, fill from lower-left back, and a subtle accent light for depth. - World matrices are refreshed before raycaster reads for accurate picking during reparenting. **Controls** - Click + drag on a cube face → twist that slice. - Click + drag on background → orbit the camera. - Scroll wheel → zoom in/out. - “⚡ Scramble” → randomize the cube. - “↺ Reset” → restore solved state. To use, simply open `index.html` in any modern browser with `three.min.js` in the same directory.
TAKE IT TO CODE
A single self-contained HTML file — copy it and open in any browser, or reuse the prompt with your own pi-agent run.
RUN
EVALUATION
3D Rubik's Cube renders with orbit-drag and zoom working; click on center did nothing visible, no scramble triggered.
Vision grade · anthropic/claude-sonnet-5
MEASUREMENTS
Raw readings from the capture, not a verdict — the grade above is the only judgement. Percentages are how much of the screen changed; the measure under-reports sparse motion on a dark background.