oneshotlm
Compare all models on this prompt →

Rubik's Cube · deepseek-v4-pro

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.

⛨ sandboxed Open in new tab ↗
transcript · 8 messages
user

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.

assistant
thinking
The user wants me to create a single-page web app (index.html) that renders an interactive 3D Rubik's Cube using three.min.js (which is already present in the current directory). The requirements are:

1. Interactive 3D Rubik's Cube
2. Clickable face rotations
3. Orbit-drag camera
4. Scramble button
5. All in one self-contained file (index.html)
6. Load three.min.js with a plain script tag

Let me build a comprehensive Rubik's Cube with:
- 26 smaller cubes (cubies) arranged in a 3x3x3 grid
- Each face with proper colors (white top, yellow bottom, red front, orange back, blue right, green left - standard color scheme)
- Click detection on faces to rotate
- Orbit controls for rotating the view
- Scramble button
- Animation for rotations

Let me think about the architecture:

1. **Cube structure**: 26 small cubes (3x3x3 minus center). Each cubie is a BoxGeometry with face colors. I'll use MeshStandardMaterial or MeshPhongMaterial with different colored faces.

2. **Rotations**: When rotating a face, I need to:
   - Identify which cubies belong to that face
   - Group them temporarily
   - Rotate the group 90 degrees
   - After animation completes, ungroup and snap to grid

3. **Click detection**: Use raycasting to detect which cubie is clicked, then determine which face to rotate based on the face normal.

4. **Orbit controls**: Since we can't use OrbitControls from CDN, I'll implement simple orbit controls manually with mouse drag.

5. **Face colors**: Standard Rubik's cube colors:
   - Front (Z+): Red
   - Back (Z-): Orange
   - Up (Y+): White
   - Down (Y-): Yellow
   - Right (X+): Blue
   - Left (X-): Green

Let me build this step by step.

For the cubies, I'll create each one as a mesh with materials for each face. Each cubie is at position (x, y, z) where x, y, z ∈ {-1, 0, 1}.

For face rotations, I need to know which cubies are on which face. For example, the right face (X+) contains all cubies where x = 1.

When the user clicks a cubie, I need to determine which face they want to rotate. I'll use the intersection point and normal to determine this.

Let me think about how to handle the rotation groups:

- When rotating a face, I temporarily reparent the cubies on that face to a pivot group
- Animate the pivot group's rotation
- After animation completes, apply the rotation to each cubie's world matrix, then reparent back

Actually, a simpler approach:
- Keep all cubies in a main group
- When a face rotation starts, create a temporary group, add the affected cubies to it
- Rotate the temporary group
- After animation, remove cubies from temp group, add back to main group, update their positions

Let me think more carefully. The cubies have positions like (-1,-1,-1) to (1,1,1). Each cubie has a world position. The cubies themselves can have their own local rotations.

For a face rotation:
1. Identify cubies on that face (e.g., for right face, all cubies with world x ≈ 1)
2. Create a pivot at origin
3. Temporarily attach those cubies to the pivot
4. Rotate pivot by 90° around the face normal axis
5. After animation, detach cubies, apply world matrix to each

Actually, let me use a different approach. I'll maintain a `cubeGroup` that contains all cubies. Each cubie is a child of this group. For face rotation:

1. Find which cubies are on the face being rotated
2. Remove them from cubeGroup, add to a pivot group
3. The pivot group is positioned at origin and rotated
4. Animate the pivot rotation
5. When done, move cubies back to cubeGroup, applying the world transform

Wait, but the pivot group needs to rotate around the face's axis. Let me think...

For the right face (X+), the rotation axis is the X axis. The pivot should be at origin with no initial rotation. The cubies on the right face have x=1. When we add them to the pivot and rotate the pivot around X by 90°, their new positions will be computed correctly.

Let me code this up. I'll keep it clean.

For orbit controls, I'll track mouse drag:
- Left button drag: rotate around origin (spherical coordinates)
- Scroll: zoom

Let me also handle face click detection properly. When the user clicks on a cubie, I need to determine which face of the cube they intended to rotate. I can use the face normal from the raycast to guide this, or I can simply determine which face the cubie belongs to based on its position and the click normal.

Actually, a common approach is: when you click a cubie, you get the face normal of the intersection. From that normal (in world space), you can determine which face of the big cube to rotate. The normal tells you which face of the cubie was clicked, which corresponds to a face of the Rubik's cube.

Let me refine: the intersection gives me the face normal in object space. I can transform it to world space. The clicked face of the Rubik's cube is determined by the world-space normal.

For example:
- If the world normal is approximately (1,0,0), rotate the right face (X+)
- If approximately (-1,0,0), rotate the left face (X-)
- If approximately (0,1,0), rotate the top face (Y+)
- etc.

But which layer? That depends on which cubie was clicked. If the right face and the cubie has x=1, rotate the rightmost layer. But we also need to handle the case where the user wants to rotate the middle slice... Typically Rubik's cube sims only rotate outer faces.

Let me simplify: 6 possible face rotations (R, L, U, D, F, B), determined by the intersection normal. The layer is determined by the cubie's position along that axis.

For a click with world normal (1,0,0) on a cubie at x=1: rotate the right face (all cubies with x=1).
For a click with world normal (1,0,0) on a cubie at x=0: rotate the middle slice? No, standard Rubik's cube notation doesn't include middle slices easily. Let me just do the outer face: if normal is (1,0,0), always rotate the x=1 layer regardless of which cubie was clicked. That makes it simpler.

Actually wait - that's not quite right either. If you click the right-facing face of a cubie at x=-1 (left side), the normal is still (1,0,0) but you probably want to rotate the left face? No, actually the cubie at x=-1 has its right face facing outward, so clicking it would give normal (1,0,0) and should rotate... hmm.

Let me simplify: the intersection gives me a point and a face normal. I'll use both to determine which face to rotate. The face normal in world space tells me the axis. The position of the clicked cubie tells me which layer.

Actually, let me just use the face normal to determine the rotation. For each cubie on the outer layer, the outward-facing normal corresponds to the face of the cube:

- Cubie at x=1: right face normal is (1,0,0) → rotate right face
- Cubie at x=-1: left face normal is (-1,0,0) → rotate left face
- But what about corner cubies? A cubie at (1,1,1) has three outward faces.

The simplest approach: use the face normal of the intersection to determine the rotation axis and direction. Then check which cubie was clicked and use its position to determine the layer. But for simplicity, I'll just use the face normal.

When the user clicks:
1. Get intersection normal in world space
2. Round it to the nearest axis direction (snap to ±x, ±y, ±z)
3. That determines which face to rotate (the outer face in that direction)
4. For example, normal (0.9, 0.1, 0) → snap to (1, 0, 0) → rotate right face

This is clean and intuitive.

Now for the rotation animation, I'll use a simple approach:
- Track if an animation is in progress
- Use requestAnimationFrame
- Rotate the pivot group over ~300ms

Let me also think about how to build the cubies with proper face colors. I'll use a BoxGeometry and set materials for each face. In THREE.js, each face of a BoxGeometry belongs to a material group (0-5). I can use an array of 6 materials.

Actually, many cubies have some faces that should be black (inner faces). For example, an edge cubie has 2 colored faces and 4 black faces. A corner cubie has 3 colored faces and 3 black faces. A center cubie has 1 colored face and 5 black faces.

For simplicity, I can make all inner faces black/dark gray and only color the outer faces. But determining which faces are "outer" depends on the cubie's position.

Let me use this approach: for each cubie, determine which of its 6 faces are on the outside of the 3x3x3 cube, and color them accordingly. Inner faces are dark gray/black.

For a cubie at position (x, y, z):
- If x == 1: right face (material index for +x) is colored blue
- If x == -1: left face (material index for -x) is colored green
- If y == 1: top face (material index for +y) is colored white
- If y == -1: bottom face (material index for -y) is colored yellow
- If z == 1: front face (material index for +z) is colored red
- If z == -1: back face (material index for -z) is colored orange
- All other faces: black

Now let me write the code. I need to be careful with Three.js material group indices for BoxGeometry:
- Group 0: +X (right)
- Group 1: -X (left)
- Group 2: +Y (top)
- Group 3: -Y (bottom)
- Group 4: +Z (front)
- Group 5: -Z (back)

Let me verify this... In Three.js BoxGeometry, the material groups are:
- 0: +X (right)
- 1: -X (left)
- 2: +Y (top)
- 3: -Y (bottom)
- 4: +Z (front)
- 5: -Z (back)

Yes, that's correct.

Now, for the orbit controls, I'll implement:
- Mouse drag (left button): rotate the camera around the origin
- Mouse wheel: zoom in/out
- I'll use spherical coordinates (theta, phi, radius)

Let me write the complete HTML file now.

One more thing: for the stickers/colors, I could either use face materials or add slightly raised colored planes on each face (stickers). Using face materials is simpler and faster. I'll go with that.

Let me also add a subtle gap between cubies for the classic Rubik's cube look. The cubies will be slightly smaller than 1 unit (e.g., 0.9) and spaced 1 unit apart.

Actually, for proper gaps, I'll make each cubie 0.85 units wide and position them at integer coordinates. This creates a 0.15 unit gap between cubies.

Wait, but when rotating faces, the cubies need to rotate around the center. If cubies are at positions (-1, -1, -1) to (1, 1, 1) with size 0.85, they rotate correctly.

Let me also think about the rotation mechanics more carefully.

When rotating the right face (X+), I need to rotate all cubies with x ≈ 1 around the X axis by ±90°. 

I'll create a pivot Object3D at origin. When starting a face rotation:
1. Find all cubies in the target layer
2. Remove them from the main group
3. Add them to the pivot
4. Set pivot rotation to target angle
5. Animate

After animation:
1. For each cubie in pivot, compute its world matrix
2. Remove from pivot, add to main group
3. Apply the world matrix (decompose to position, rotation, scale)
4. Reset pivot rotation

Actually, a cleaner approach: after animation, I update each cubie's position/rotation in the main group to match its world transform, then reparent. 

Let me use this pattern:
```
function completeRotation(pivot, cubies) {
    // For each cubie in the pivot
    cubies.forEach(cubie => {
        // Get world matrix
        cubie.updateMatrixWorld();
        const worldMatrix = cubie.matrixWorld.clone();
        
        // Remove from pivot
        pivot.remove(cubie);
        
        // Add to main group
        mainGroup.add(cubie);
        
        // Apply world transform relative to main group
        const mainGroupWorld = mainGroup.matrixWorld.clone();
        mainGroupWorld.invert();
        const localMatrix = mainGroupWorld.multiply(worldMatrix);
        
        // Decompose and apply
        const pos = new THREE.Vector3();
        const quat = new THREE.Quaternion();
        const scale = new THREE.Vector3();
        localMatrix.decompose(pos, quat, scale);
        
        cubie.position.copy(pos);
        cubie.quaternion.copy(quat);
        cubie.scale.copy(scale);
    });
    
    // Round positions to nearest integer
    cubies.forEach(cubie => {
        cubie.position.x = Math.round(cubie.position.x);
        cubie.position.y = Math.round(cubie.position.y);
        cubie.position.z = Math.round(cubie.position.z);
    });
}
```

This should properly handle the cubie positions after rotation.

Now, there's a subtlety: after multiple rotations, cubies will accumulate rotation quaternions. Their local rotations won't be axis-aligned anymore. When determining which cubies are on which face, I need to use their world position, not local position. The world position can be computed by transforming the local position by the parent's matrix.

Actually, since I'm rounding positions to integers after each rotation, and cubies rotate in 90° increments, their world positions should always be approximately integer coordinates. So I can use world position to determine which face they're on.

Let me write the complete code now.

Actually, let me reconsider the structure. Instead of reparenting cubies, I could use a different approach:

- All cubies are children of a main group
- For face rotation, I create a temporary pivot, move selected cubies to it, animate, then move them back

This is the approach I outlined. Let me also handle the case where cubies have non-identity rotations when they're added to the pivot. The pivot starts with identity rotation, so when cubies are added to it, their world transforms should be preserved. Since the pivot is at origin with no rotation, adding a cubie to the pivot should preserve its world transform (because the pivot's world matrix is identity).

Wait, that's key. The pivot needs to be positioned such that when cubies are added to it, their world transforms don't change. If the pivot is at the origin with no rotation, and the main group is also at the origin with no rotation, then moving a cubie from main group to pivot should preserve its world transform.

Then I animate the pivot's rotation (e.g., 90° around X axis). The cubies rotate with the pivot. When done, I move them back to the main group, preserving their new world transforms.

This should work! Let me code it up.

Now let me think about the animation loop. I'll use a simple animation state:

```javascript
let animationState = null; // { pivot, cubies, axis, targetAngle, currentAngle, startTime, duration }

function animate() {
    if (animationState) {
        const elapsed = Date.now() - animationState.startTime;
        const progress = Math.min(elapsed / animationState.duration, 1.0);
        // Ease in-out
        const eased = progress < 0.5 
            ? 2 * progress * progress 
            : 1 - Math.pow(-2 * progress + 2, 2) / 2;
        
        const angle = animationState.targetAngle * eased;
        // Set pivot rotation
        animationState.pivot.rotation.set(...);
        
        if (progress >= 1.0) {
            // Complete rotation
            completeRotation(...);
            animationState = null;
        }
    }
    
    renderer.render(scene, camera);
    requestAnimationFrame(animate);
}
```

This looks good. Now let me also think about preventing clicks during animation. I'll add a flag `isAnimating`.

For the scramble feature, I'll apply a series of random face rotations in quick succession. I can either:
1. Instantly apply them (no animation)
2. Queue them up and animate each one

Option 1 is simpler and the standard approach for scramble. I'll just randomly rotate the cubies' positions/rotations directly. Actually, the simplest scramble is to just apply 20 random moves instantly.

For instant moves, I'll reuse the rotation logic but skip the animation - just apply the 90° rotation immediately.

Let me also think about the direction of rotation. When clicking a face, which direction should it rotate? I'll use the standard: clockwise when looking at the face from the outside. 

For determining CW/CCW: looking from the positive axis direction, a positive rotation around that axis (right-hand rule) is CCW. Hmm, let me just pick a convention. 

Standard Rubik's cube notation:
- R: right face clockwise (when looking at right face)
- R': right face counter-clockwise
- U: top face clockwise (when looking from top)
- etc.

Clockwise when looking from the outside means:
- Right face (looking from +X): clockwise = rotation around X axis by -90° (right-hand rule: thumb points in +X, fingers curl in direction of positive rotation, which is CCW when looking from +X. So clockwise is -90°)
- Left face (looking from -X): clockwise = rotation around X axis by +90°
- Top face (looking from +Y): clockwise = rotation around Y axis by -90°
- Bottom face (looking from -Y): clockwise = rotation around Y axis by +90°
- Front face (looking from +Z): clockwise = rotation around Z axis by -90°
- Back face (looking from -Z): clockwise = rotation around Z axis by +90°

For simplicity, I'll make all clicks rotate by +90° around the axis corresponding to the face normal. That gives consistent behavior.

Actually, let me define it more carefully. The face normal points outward. I'll say a click rotates that face by +90° (right-hand rule around the normal). This means:
- Right face (+X normal): +90° around X → top goes to front, front goes to bottom, etc.
- Top face (+Y normal): +90° around Y → front goes to right, right goes to back, etc.

This is a consistent convention. Let me go with this. The user can always rotate the other way by dragging to orbit to the other side.

Alright, let me now write the full code.

One more thing: I need to handle the stickers/colors. I'll create each cubie with 6 face materials. For each cubie, I determine which faces are on the outside and color them appropriately.

For the inner faces (not on the outside of the 3x3x3), I'll use a dark color (like #111 or #1a1a1a). The outer faces get the standard Rubik's cube colors:
- Front (+Z): Red (#FF0000 or #B71234)
- Back (-Z): Orange (#FF8C00 or #FF5800)
- Up (+Y): White (#FFFFFF)
- Down (-Y): Yellow (#FFD500)
- Right (+X): Blue (#0046AD or #009B48) Wait, standard is:
  - Right: Red? No...
  
Let me use the standard Western color scheme:
- White top (U)
- Yellow bottom (D)  
- Green front (F)
- Blue back (B)
- Red right (R)
- Orange left (L)

Wait, there are different conventions. Let me just use a common one:
- Front: Green
- Back: Blue
- Up: White
- Down: Yellow
- Right: Red
- Left: Orange

Actually, the most common scheme in many simulators:
- Up: White
- Down: Yellow
- Front: Red
- Back: Orange
- Right: Blue
- Left: Green

I'll go with this: White/Yellow, Red/Orange, Blue/Green.

OK let me just write the code. I've thought about this enough.

Here's my plan for the complete HTML:

```html
<!DOCTYPE html>
<html>
<head>
    <title>Rubik's Cube</title>
    <style>
        /* styles */
    </style>
</head>
<body>
    <script src="three.min.js"></script>
    <script>
        // Three.js setup
        // Cube creation
        // Orbit controls
        // Face rotation
        // Scramble
    </script>
</body>
</html>
```

Let me write it all out now. I'll be careful with the Three.js API.

For orbit controls, I'll implement spherical coordinates:
- theta: azimuthal angle (horizontal rotation)
- phi: polar angle (vertical rotation, from top)
- radius: distance from origin

Mouse drag changes theta and phi.
Mouse wheel changes radius.

```javascript
let theta = Math.PI / 4;
let phi = Math.PI / 3;
let radius = 8;

function updateCamera() {
    camera.position.x = radius * Math.sin(phi) * Math.cos(theta);
    camera.position.y = radius * Math.cos(phi);
    camera.position.z = radius * Math.sin(phi) * Math.sin(theta);
    camera.lookAt(0, 0, 0);
}
```

For mouse interaction:
```javascript
let isDragging = false;
let prevMouse = { x: 0, y: 0 };

canvas.addEventListener('mousedown', (e) => {
    if (e.button === 0) {
        isDragging = true;
        prevMouse.x = e.clientX;
        prevMouse.y = e.clientY;
    }
});

canvas.addEventListener('mousemove', (e) => {
    if (isDragging) {
        const dx = e.clientX - prevMouse.x;
        const dy = e.clientY - prevMouse.y;
        theta -= dx * 0.01;
        phi -= dy * 0.01;
        phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi));
        prevMouse.x = e.clientX;
        prevMouse.y = e.clientY;
        updateCamera();
    }
});

canvas.addEventListener('mouseup', () => {
    isDragging = false;
});

canvas.addEventListener('wheel', (e) => {
    radius += e.deltaY * 0.01;
    radius = Math.max(4, Math.min(15, radius));
    updateCamera();
});
```

For click detection (face rotation), I need to distinguish between a drag and a click. If the mouse moved very little between mousedown and mouseup, it's a click.

```javascript
let mouseDownPos = { x: 0, y: 0 };

canvas.addEventListener('mousedown', (e) => {
    mouseDownPos.x = e.clientX;
    mouseDownPos.y = e.clientY;
    // ... drag setup
});

canvas.addEventListener('mouseup', (e) => {
    if (isDragging) {
        const dx = e.clientX - mouseDownPos.x;
        const dy = e.clientY - mouseDownPos.y;
        if (Math.abs(dx) < 3 && Math.abs(dy) < 3) {
            // It's a click, not a drag
            handleClick(e);
        }
    }
    isDragging = false;
});
```

Now let me write the full code. I'll be thorough.

Actually, I realize I should think about one more thing: when I rotate cubies around a face and then complete the rotation, their local transforms (position and quaternion) get updated. But the positions might have floating point errors after several rotations. I should snap positions to integers after each rotation.

Also, the cubie quaternions will accumulate. After rotating a cubie on the right face by 90° around X, its quaternion will be something like (sin(45°), 0, 0, cos(45°)). After multiple rotations, the quaternion could get complex. But since all rotations are multiples of 90° around cardinal axes, the quaternion should always represent a rotation that maps the cubie's geometry to axis-aligned orientations. I should snap the quaternion too, or just track the discrete rotation state.

Actually, for simplicity, let me snap the cubie's matrix after each rotation completion. I'll extract the rotation matrix, round each element to the nearest integer (since all rotations are multiples of 90°, the rotation matrix elements should be -1, 0, or 1), and reconstruct the quaternion.

Hmm, that's getting complex. Let me just use a simpler approach: after each rotation, update the cubie's position (rounded to integers) and set its rotation from a set of discrete values.

Actually, a much simpler approach: I'll compute the cubie's new position and orientation by transforming its original grid position through all the accumulated face rotations. But tracking that is complex.

The simplest robust approach: after each face rotation animation completes, extract the cubie's world matrix, decompose it to get position and quaternion, then snap the position to integers and snap the rotation to the nearest 90°-multiple quaternion.

For snapping the rotation: I can convert the quaternion to a rotation matrix, round each element to the nearest integer (-1, 0, or 1), then check that it's a valid rotation matrix and convert back to quaternion.

Actually, Three.js has `matrix.decompose()` which gives me position, quaternion, and scale. The quaternion from a valid rotation will already be clean. The issue is just floating point accumulation. Let me just round positions and not worry too much about the quaternion, since the errors will be tiny.

Or even simpler: after completing the animation, I can compute the cubie's world position, round it to integers, and set it. For rotation, I can use `matrixWorld` to extract the rotation part and snap it.

You know what, let me just use `matrix.decompose()` and then round the position, and leave the quaternion as-is. The floating point errors in the quaternion after a few dozen rotations will be negligible. And for the face detection (which cubies are on which face), I only use world position, which I round to integers.

Alright, let me write the code. Here's the full plan:

1. Scene setup (renderer, camera, lights)
2. Create cubies
3. Orbit controls
4. Click detection and face rotation
5. Animation loop
6. Scramble button

Let me also add some UI elements (scramble button, maybe move counters).

Here's a refinement on the face rotation mechanics:

When a face rotation starts:
1. Determine axis and layer from click
2. Find cubies in that layer (based on world position)
3. Create pivot Object3D at origin
4. Move cubies from mainGroup to pivot (preserving world transforms)
5. Set pivot rotation axis and animate

For finding cubies in a layer: I'll compute each cubie's world position and check if it's in the target layer. The world position is computed by transforming the cubie's local position through the mainGroup's world matrix (which is identity if mainGroup is at origin).

Since all cubies are direct children of mainGroup (which is at origin), world position = local position. After rotations, local positions will have been updated to their new integer positions.

Wait, but cubies also have rotations. The cubie's position is just its center position in the parent's coordinate system. Even after the cubie is rotated, its position stays the same. So world position = local position (since parent is at origin).

For determining if a cubie is on the right face (x=1), I just check `Math.round(cubie.position.x) === 1`. This works regardless of the cubie's rotation.

This is much simpler than I was thinking. Let me proceed.

Now for the code. I'll write it carefully.

One more consideration: the gap between cubies. I'll make each cubie a BoxGeometry(0.85, 0.85, 0.85) and position them at integer coordinates. The gap will be 0.15 units.

Actually, 0.85 might look a bit odd. Let me use 0.9 for subtle gaps. Or even 0.95 for very thin gaps. Let me use 0.85 for clearly visible gaps.

For the stickers/colors, I'll use MeshStandardMaterial with roughness 0.4 and metalness 0.1 for a nice look. The inner faces will be dark (#1a1a1a or #222).

Let me also add a subtle rounded-edge look by using RoundedBoxGeometry... no, that's not available. I'll just use regular BoxGeometry. It'll look fine with the colored faces.

Now let me code this up fully.

For the rotation animation with easing, I'll use:
- duration: 300ms
- easing: ease-in-out (quadratic)

Let me also handle the case where the user clicks during an animation - I'll just ignore it.

Here's one more detail: when I create the pivot for rotation, it should be at the world origin with no rotation. The mainGroup should also be at the world origin. When I move cubies from mainGroup to pivot, their world transforms should be preserved. Since both parent groups have identity world matrices (both at origin, no rotation), the cubies' world transforms will be preserved.

But wait: if the cubies have accumulated rotations, their local transform includes both position and rotation. When I move a cubie to the pivot, its local transform relative to the pivot equals its previous world transform (since pivot.world = identity). So its position and rotation are preserved. Good.

Then I animate the pivot's rotation. For the right face, I set `pivot.rotation.x = targetAngle`. All cubies in the pivot rotate with it. After animation completes, I reparent cubies back to mainGroup, preserving their new world transforms.

This all checks out. Let me write the code.

I also want to add a subtle shadow or ambient occlusion effect. I'll use ambient light + directional light for nice shading.

Let me also make the background a nice gradient or solid color.

OK here we go. I'll write the complete file now.

For the scramble: I'll do 20 random moves instantly. Each move is applied by directly modifying cubie positions/rotations without animation. I'll use the same layer-rotation logic but apply it instantly.

Actually, for scramble, I can just call the face rotation function 20 times with random faces, but skip the animation. Let me create a function `rotateFaceInstant(axis, layer, angle)` that does the rotation immediately.

Let me structure the code:

```javascript
// State
const cubies = [];
const mainGroup = new THREE.Group();
let isAnimating = false;
let animationData = null;

// Create cubies
function createCubies() { ... }

// Get cubies in a layer
function getLayerCubies(axis, layer) {
    return cubies.filter(c => Math.round(c.position[axis]) === layer);
}

// Rotate face (animated)
function rotateFace(axis, layer, angle) {
    if (isAnimating) return;
    isAnimating = true;
    
    const layerCubies = getLayerCubies(axis, layer);
    const pivot = new THREE.Group();
    scene.add(pivot);
    
    // Move cubies to pivot
    layerCubies.forEach(c => {
        mainGroup.remove(c);
        pivot.add(c);
    });
    
    animationData = {
        pivot,
        cubies: layerCubies,
        axis,
        angle,
        startTime: Date.now(),
    };
}

// Complete rotation
function completeRotation() {
    const { pivot, cubies: layerCubies } = animationData;
    
    layerCubies.forEach(c => {
        const worldPos = new THREE.Vector3();
        const worldQuat = new THREE.Quaternion();
        c.getWorldPosition(worldPos);
        c.getWorldQuaternion(worldQuat);
        
        pivot.remove(c);
        mainGroup.add(c);
        
        c.position.copy(worldPos);
        c.position.x = Math.round(c.position.x);
        c.position.y = Math.round(c.position.y);
        c.position.z = Math.round(c.position.z);
        c.quaternion.copy(worldQuat);
    });
    
    scene.remove(pivot);
    animationData = null;
    isAnimating = false;
}

// Instant rotation (for scramble)
function rotateFaceInstant(axis, layer, angle) {
    const layerCubies = getLayerCubies(axis, layer);
    const pivot = new THREE.Group();
    scene.add(pivot);
    
    layerCubies.forEach(c => {
        mainGroup.remove(c);
        pivot.add(c);
    });
    
    // Set rotation immediately
    pivot.rotation.set(
        axis === 'x' ? angle : 0,
        axis === 'y' ? angle : 0,
        axis === 'z' ? angle : 0
    );
    
    // Move back
    layerCubies.forEach(c => {
        const worldPos = new THREE.Vector3();
        const worldQuat = new THREE.Quaternion();
        c.getWorldPosition(worldPos);
        c.getWorldQuaternion(worldQuat);
        
        pivot.remove(c);
        mainGroup.add(c);
        
        c.position.copy(worldPos);
        c.position.x = Math.round(c.position.x);
        c.position.y = Math.round(c.position.y);
        c.position.z = Math.round(c.position.z);
        c.quaternion.copy(worldQuat);
    });
    
    scene.remove(pivot);
}

// Scramble
function scramble() {
    if (isAnimating) return;
    const axes = ['x', 'y', 'z'];
    const layers = [-1, 1];
    const angles = [Math.PI / 2, -Math.PI / 2];
    
    for (let i = 0; i < 20; i++) {
        const axis = axes[Math.floor(Math.random() * 3)];
        const layer = layers[Math.floor(Math.random() * 2)];
        const angle = angles[Math.floor(Math.random() * 2)];
        rotateFaceInstant(axis, layer, angle);
    }
}
```

This looks solid. Now let me handle the click detection for face rotation.

When the user clicks on a cubie:
1. Raycast to find the intersection
2. Get the face normal in world space
3. Determine the rotation axis from the normal
4. The layer is determined by the axis and the clicked cubie's position

Wait, for the layer: if the normal is (1,0,0), the axis is 'x'. But which layer? The cubie might be at x=1 or x=0 or x=-1. I want to rotate the outer face in the direction of the normal. So if the normal is (1,0,0), I rotate the x=1 layer. If the normal is (-1,0,0), I rotate the x=-1 layer. The clicked cubie's position doesn't matter for the layer - it's always the outer face in the direction of the normal.

But what if the user clicks on a cubie at x=0 with normal (1,0,0)? That cubie's right face is exposed (because the cubie at x=1 might have been rotated away). In this case, rotating the x=1 layer makes sense because the normal points in the +x direction.

Actually, in a standard 3x3x3 cube, you can't have a cubie at x=0 with its right face exposed, because the cubie at x=1 always covers it. Unless the right face has been rotated. After a rotation of the right face, the cubies that were at x=1 might now be at different positions, and some cubies that were at x=0 might have their right faces exposed.

So the layer to rotate should still be determined by the normal direction. If the normal is (1,0,0), rotate the outermost layer in the +x direction, which means all cubies whose world position has x ≈ 1 (or the maximum x among all cubies on that face).

Hmm, but after rotations, the cubies on the right face might be at different x positions. Let me think...

After rotating the right face (x=1 layer) by 90° around X, the cubies that were at x=1 stay at x=1 (rotation around X preserves x coordinate). So the right face is still at x=1.

After rotating the top face (y=1 layer) by 90° around Y, cubies at y=1 stay at y=1.

The key insight: a face rotation around an axis preserves the coordinate along that axis. So cubies on the right face always have x ≈ 1, cubies on the top face always have y ≈ 1, etc.

Therefore, determining the layer from the normal direction always works: normal (1,0,0) → axis 'x', layer 1. Normal (-1,0,0) → axis 'x', layer -1.

This simplifies things greatly.

Now, for the click handler, I need to determine the normal in world space. The intersection gives me `face.normal` in object space. I need to transform it to world space.

```javascript
const normal = intersection.face.normal.clone();
// Transform from object space to world space
normal.transformDirection(intersection.object.matrixWorld);
// Round to nearest axis
const absX = Math.abs(normal.x);
const absY = Math.abs(normal.y);
const absZ = Math.abs(normal.z);

let axis, layer, angle;
if (absX >= absY && absX >= absZ) {
    axis = 'x';
    layer = normal.x > 0 ? 1 : -1;
} else if (absY >= absX && absY >= absZ) {
    axis = 'y';
    layer = normal.y > 0 ? 1 : -1;
} else {
    axis = 'z';
    layer = normal.z > 0 ? 1 : -1;
}
angle = Math.PI / 2; // Always rotate +90°
```

This determines the face to rotate based on the clicked face normal.

Wait, I should also validate that there are actually cubies in that layer. But since I'm using the normal, and the normal points to a face that exists, there should be cubies there.

OK, let me also consider: what if the user clicks on an inner face (dark face) of a cubie? The normal would point in some direction, and I'd still rotate the corresponding outer face. This might not be what the user expects, but it's a reasonable behavior. The alternative would be to only respond to clicks on colored faces, but that requires knowing which faces are colored, which changes after rotations.

I think using the normal to determine the rotation is the best approach. It works intuitively: click near the right side of the cube → right face rotates. Click near the top → top face rotates.

Alright, I have a solid plan. Let me write the complete HTML file now.

One more thing: for the scramble button, I'll add a nice UI button. Let me position it at the top of the screen.

Also, I should handle touch events for mobile. But the user asked for orbit-drag, so let me add basic touch support too.

Let me finalize the code structure and write it out:

```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>
  /* Full-page canvas with overlay UI */
</style>
</head>
<body>
  <div id="ui">
    <button id="scramble">Scramble</button>
  </div>
  <script src="three.min.js"></script>
  <script>
    // All the code
  </script>
</body>
</html>
```

Let me write it now. I want to make sure the colors are vibrant and the cube looks great.

Colors:
- Right (+X): Red #C41E3A
- Left (-X): Orange #FF5800
- Up (+Y): White #FFFFFF
- Down (-Y): Yellow #FFD500
- Front (+Z): Green #009E60
- Back (-Z): Blue #0051BA

Inner faces: #111111

Actually, let me use more vibrant, modern colors:
- Right: #B71234 (ruby red)
- Left: #FF8C00 (dark orange)
- Up: #FFFFFF (white)
- Down: #FFD500 (golden yellow)
- Front: #009B48 (green)
- Back: #0046AD (blue)

These look nice. Let me go with these.

For the cubie face materials, I'll create them as follows for each cubie:

```javascript
function createCubieMaterials(x, y, z) {
    const colors = [
        x === 1 ? '#B71234' : '#111111',  // +X (right)
        x === -1 ? '#FF8C00' : '#111111', // -X (left)
        y === 1 ? '#FFFFFF' : '#111111',  // +Y (up)
        y === -1 ? '#FFD500' : '#111111', // -Y (down)
        z === 1 ? '#009B48' : '#111111',  // +Z (front)
        z === -1 ? '#0046AD' : '#111111', // -Z (back)
    ];
    return colors.map(c => new THREE.MeshStandardMaterial({
        color: c,
        roughness: 0.3,
        metalness: 0.1,
    }));
}
```

This creates the correct face colors for each cubie based on its position in the 3x3x3 grid.

Now let me also handle the edge highlights. The cubies look better with a slight border/bevel. I can achieve this by making the cubies slightly smaller and having the dark inner faces create natural borders. Or I can use a thin outline. Let me keep it simple with the gap approach.

Actually, I realize there's a subtle issue: when I create the cubies, their face materials are determined by their initial positions. After rotations, the cubies' positions change but their face materials don't. However, since I moved the cubies to new positions, the colors on their faces don't correspond to their new positions anymore.

Wait, this is the key insight! The face colors are "baked" into the cubie's geometry at creation time. When the cubie rotates, its colored faces rotate with it. This is exactly how a real Rubik's cube works - the stickers are attached to the cubies and move with them.

So after rotating a face, the cubies have new positions, and their colored faces are now on different sides. This is correct behavior!

For example, if a corner cubie starts at (1,1,1) with colors red (+X), white (+Y), green (+Z), and it gets rotated to (1,-1,1) by a right face rotation, it now has colors red (+X), white (now at some other face due to the rotation), green (+Z). Wait, the cubie itself rotated, so its white face might now be pointing in a different direction.

The cubie's local frame rotates with it. So if the cubie at (1,1,1) with red/white/green faces gets rotated 90° around +X, its white face (originally +Y) now points in +Z, and its green face (originally +Z) now points in -Y.

This is perfect - exactly how a Rubik's cube works. The colored faces are attached to the cubie's local frame.

OK, I'm confident the approach is correct. Let me write the final code now.

Actually, I need to handle one more thing: the cubie's world position vs local position. When determining which cubies are on which face, I use `cubie.position`, which is the cubie's local position relative to mainGroup. Since mainGroup is at origin with no rotation, this equals the world position. 

After rotations, the cubie's position gets updated (via matrixWorld decomposition). So `cubie.position` correctly reflects the cubie's world position at all times.

OK, here's the complete code. I'll be thorough and handle edge cases.

For the orbit controls, I need to handle both mouse and touch:
- Mouse: left button drag to rotate, scroll to zoom
- Touch: one finger drag to rotate, pinch to zoom

For touch, I'll track touches and compute deltas.

Let me also add a subtle auto-rotation when idle (maybe not, to keep it simple).

Alright, final code. Let me write it all out.

One thing I want to make sure: the animation easing. I'll use easeInOutCubic for smooth animation.

Also, I need to handle the case where a face rotation is triggered but there are no cubies in that layer (shouldn't happen with proper click detection, but just in case).

Let me also think about the initial camera position. I want the cube to be viewed from an angle that shows three faces (like an isometric-ish view). Something like:
- theta: 45° (viewing from the front-right)
- phi: 55° (slightly above)

This gives a nice 3/4 view.

Now, for the scramble animation: I could either do it instantly or animate through the moves. Let me do it instantly for speed, but maybe add a brief "shake" or reset effect. Actually, let me just do 20 instant random moves. But to make it look cool, I'll do a quick animated scramble where moves happen rapidly.

Hmm, for the most satisfying UX, let me do:
- Scramble button click → 20 moves, each animated very quickly (like 50ms each)
- This creates a cool scrambling effect

But chaining animations is complex. Let me keep it simple: 20 instant moves, which effectively randomizes the cube instantly. The user sees the cube "jump" to a scrambled state.

Actually, even simpler: I'll just do the 20 instant moves with no delay. The cube will instantly appear scrambled. This is fine.

Let me write the code now. I'll also add a "Reset" button that resets the cube to solved state. That would be a nice addition. I can reset by removing all cubies, recreating them in solved state, and adding them to the main group.

OK here's the final plan for the HTML:

```
HTML structure:
- Canvas (Three.js renderer)
- UI overlay with Scramble and Reset buttons
- Info text (maybe)

JavaScript:
1. Scene setup (scene, camera, renderer, lights)
2. Cubie creation (26 cubies with colored faces)
3. Orbit controls (mouse + touch)
4. Click detection (raycasting)
5. Face rotation animation
6. Instant face rotation (for scramble/reset)
7. Animation loop
8. Scramble and Reset functions
```

Let me write it all.

I need to be particularly careful about how `getWorldPosition` and `getWorldQuaternion` work with the pivot group approach. When I move cubies to the pivot and rotate the pivot, the cubies' world transforms change. To move them back to mainGroup:

```javascript
// Get world transform
const worldPos = new THREE.Vector3();
const worldQuat = new THREE.Quaternion();
cubie.getWorldPosition(worldPos);
cubie.getWorldQuaternion(worldQuat);

// Move to mainGroup
pivot.remove(cubie);
mainGroup.add(cubie);

// Restore world transform
// Since mainGroup is at origin with no rotation, 
// cubie's local transform = world transform
cubie.position.copy(worldPos);
cubie.quaternion.copy(worldQuat);
```

Wait, but `mainGroup.add(cubie)` might reset the cubie's transform. No, it doesn't - Three.js preserves local transforms when reparenting. The cubie's position and quaternion are relative to its parent. When I move it from pivot to mainGroup, its local position/quaternion stay the same, but now they're relative to mainGroup instead of pivot.

Since both pivot and mainGroup have different world matrices, the cubie's world transform changes. I need to explicitly set the cubie's local transform so that its world transform matches what it was before reparenting.

The correct approach:

```javascript
// Save world transform
const worldMatrix = cubie.matrixWorld.clone();

// Reparent
pivot.remove(cubie);
mainGroup.add(cubie);

// Compute new local transform
const parentWorldInverse = new THREE.Matrix4().copy(mainGroup.matrixWorld).invert();
const newLocalMatrix = parentWorldInverse.multiply(worldMatrix);

// Decompose and apply
const pos = new THREE.Vector3();
const quat = new THREE.Quaternion();
const scale = new THREE.Vector3();
newLocalMatrix.decompose(pos, quat, scale);

cubie.position.copy(pos);
cubie.quaternion.copy(quat);
```

Since mainGroup is at origin with no rotation, `mainGroup.matrixWorld` is identity, so `parentWorldInverse` is also identity, and `newLocalMatrix = worldMatrix`. Therefore `pos = worldPos` and `quat = worldQuat`. The simpler approach works.

But wait, I need to update the matrixWorld before calling `getWorldPosition`. Let me make sure the scene graph is updated. Calling `scene.updateMatrixWorld()` or `pivot.updateMatrixWorld()` should do it.

Actually, after setting `pivot.rotation`, I need to update the matrix world to get correct world positions. In the animation loop, the renderer calls `updateMatrixWorld()` automatically. But for the instant rotation, I need to call it manually.

Let me add `pivot.updateMatrixWorld()` after setting the rotation and before getting world positions.

OK, I think I've covered all the details. Let me write the complete code now.

Actually, one more optimization: for the scramble, instead of creating a new pivot for each move, I can reuse one. But for simplicity, I'll create a new one each time.

Wait, I also realize I should handle the case where `isAnimating` prevents scramble. The scramble function should work even if an animation is in progress... actually no, it's better to prevent scramble during animation to avoid state corruption.

Let me also think about what happens if the user resizes the window. I should handle the resize event to update the renderer and camera aspect ratio.

OK, truly writing the code now.

For the Reset button, I'll remove all cubies from the mainGroup, dispose of their geometries/materials, and create new ones. This is clean and simple.

Let me also add some visual feedback when hovering over cubies (like a slight highlight). This would make the interactions feel more responsive. I can use a raycaster on mousemove and highlight the hovered cubie.

For highlighting, I could change the emissive color of the hovered cubie's materials. But since each cubie has multiple materials, I'd need to change all of them. Let me just add a subtle outline or emissive glow.

Actually, the simplest hover effect: track the hovered cubie and set `emissive` on all its materials to a dark gray when hovered, and back to black when not. But this requires storing references to all materials for each cubie.

Let me simplify: I'll use a slight scale change on hover. No, that changes positions.

Let me just skip the hover effect for now and focus on getting the rotation working correctly. Hover can be added easily later.

But I will implement proper click detection that distinguishes drag from click. The key metric: if the mouse moved more than 3 pixels between mousedown and mouseup, it's a drag, not a click.

Alright, I'm writing the code now. For real this time.

Let me also think about the cubie geometry sharing. All 26 cubies share the same BoxGeometry, but each has its own set of 6 materials. I can share one BoxGeometry instance.

OK here's the code:

```javascript
// Shared geometry
const cubieGeometry = new THREE.BoxGeometry(0.85, 0.85, 0.85);

// For each position in the 3x3 grid (skipping center):
for (let x = -1; x <= 1; x++) {
    for (let y = -1; y <= 1; y++) {
        for (let z = -1; z <= 1; z++) {
            if (x === 0 && y === 0 && z === 0) continue;
            
            const materials = createCubieMaterials(x, y, z);
            const mesh = new THREE.Mesh(cubieGeometry, materials);
            mesh.position.set(x, y, z);
            // Store initial grid position for reference
            mesh.userData = { initialX: x, initialY: y, initialZ: z };
            mainGroup.add(mesh);
            cubies.push(mesh);
        }
    }
}
```

This creates 26 cubies. Each cubie's face materials are determined by its position in the solved state.

Now let me think about what happens after rotations. The cubie's `position` reflects its current grid position (e.g., after rotating the right face, a cubie might move from (1,1,1) to (1,1,-1)). Its `quaternion` reflects how it's rotated.

The materials are baked into the cubie at creation time. When the cubie rotates, its materials rotate with it (because they're attached to the mesh's local frame). So the colored faces move correctly.

This is all correct. Let me write the final code.

For the animation loop, I need to:
1. Check if animation is in progress
2. If so, update the animation
3. Render the scene

```javascript
function animate() {
    requestAnimationFrame(animate);
    
    if (animationData) {
        const elapsed = Date.now() - animationData.startTime;
        const duration = animationData.duration;
        let progress = Math.min(elapsed / duration, 1.0);
        
        // Ease in-out cubic
        progress = progress < 0.5
            ? 4 * progress * progress * progress
            : 1 - Math.pow(-2 * progress + 2, 3) / 2;
        
        const angle = animationData.targetAngle * progress;
        
        // Set pivot rotation
        animationData.pivot.rotation.set(
            animationData.axis === 'x' ? angle : 0,
            animationData.axis === 'y' ? angle : 0,
            animationData.axis === 'z' ? angle : 0
        );
        
        if (elapsed >= duration) {
            completeRotation();
        }
    }
    
    // Update orbit controls (auto-rotate?)
    
    renderer.render(scene, camera);
}
```

Now let me think about the orbit camera update. I need to call `updateCamera()` whenever theta, phi, or radius changes. In the animation loop, if no drag is happening, the camera stays still. I only need to update it on mouse events.

But `updateCamera()` sets `camera.position` and calls `camera.lookAt(0,0,0)`. I should call it initially and on every mouse event.

Let me also add a subtle ambient light and a directional light for nice 3D shading.

I think I'm ready to write the complete file. Let me do it.

Oh wait, I need to handle one edge case: when cubies are rotated multiple times, their quaternions accumulate. The `getWorldPosition` during reparenting should still work correctly because I'm reading the world matrix which includes all accumulated transforms.

But there's a potential issue with floating point errors accumulating in cubie positions. After many rotations, `cubie.position` might drift from exact integers. My rounding in `completeRotation` handles this.

For the quaternion, floating point errors can also accumulate. After enough rotations, the quaternion might not represent an exact 90° rotation. This could cause the cubie to appear slightly tilted. To prevent this, I could snap the quaternion to the nearest valid rotation after each move.

For snapping, I can convert the quaternion to a rotation matrix, round each element to the nearest integer (-1, 0, or 1), then convert back to quaternion. This ensures the rotation is always a multiple of 90°.

Let me add a `snapQuaternion` function:

```javascript
function snapQuaternion(quat) {
    const m = new THREE.Matrix4().makeRotationFromQuaternion(quat);
    const e = m.elements;
    
    // Round each element to nearest integer (-1, 0, 1)
    for (let i = 0; i < 16; i++) {
        e[i] = Math.round(e[i]);
    }
    
    // Ensure it's a valid rotation matrix
    // (rounding should preserve orthogonality for 90° rotations)
    quat.setFromRotationMatrix(m);
}
```

Actually, the matrix4 elements include translation (e[12], e[13], e[14]) and the bottom row (e[3], e[7], e[11], e[15]). I should only round the rotation part (top-left 3x3). Let me be more careful.

```javascript
function snapQuaternion(quat) {
    // Convert quaternion to rotation matrix (3x3)
    const m3 = new THREE.Matrix3().setFromMatrix4(
        new THREE.Matrix4().makeRotationFromQuaternion(quat)
    );
    const e = m3.elements;
    
    // Round each element to nearest integer
    for (let i = 0; i < 9; i++) {
        e[i] = Math.round(e[i]);
    }
    
    // Convert back to quaternion
    quat.setFromRotationMatrix(m3);
}
```

This should work. The rotation matrix for any 90° rotation around a cardinal axis has elements that are exactly -1, 0, or 1. Rounding them snaps back to the exact rotation.

OK, truly writing the code now. I'll make it polished and complete.

Let me also consider: do I want the cubies to cast shadows? Probably not, to keep it simple and performant.

One more thing: the renderer. I'll use WebGLRenderer with antialiasing and a nice background color. I'll also set the pixel ratio for sharp rendering on high-DPI screens.

```javascript
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x1a1a2e); // Dark blue-ish background
```

Or maybe a lighter background? Let me use a subtle gradient-like background. I'll just use a solid dark color: #1a1a2e or #2c3e50.

Actually, for a cleaner look, let me use a light gray background #e8e8e8 with a subtle shadow effect. Or a dark theme. Let me go with a dark, modern look: background #1a1a2e.

For the lights:
- Ambient light: soft fill light
- Directional light: main key light from upper-right-front
- Maybe a second directional light from the opposite side for fill

```javascript
const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight1.position.set(5, 10, 7);
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
dirLight2.position.set(-3, -1, -5);
```

This gives nice 3D shading.

Now, one important detail: the cubies should have a subtle bevel or rounded edges for a premium look. Without RoundedBoxGeometry, I can simulate this by adding thin dark lines at the edges. But that's complex. Let me just make the cubies have a dark base color and the faces be colored panels. 

Actually, I can create each cubie from two parts:
1. A slightly larger dark cube (the "body" of the cubie)
2. Colored planes on the outer faces

But this is complex. Let me just use BoxGeometry with face materials. The gaps between cubies (from the 0.85 size) will create natural dark borders that look like the gaps in a real Rubik's cube.

Yes, the 0.85 size with 1.0 spacing creates a 0.15 gap, which reveals the dark inner faces. This looks great.

Alright, now I'll write the complete code. I have all the pieces.

Let me draft the complete JavaScript:

```javascript
// === THREE.JS SETUP ===
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 });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x1a1a2e);
document.body.appendChild(renderer.domElement);

// Lights
scene.add(new THREE.AmbientLight(0x404040, 0.6));
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight1.position.set(5, 10, 7);
scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
dirLight2.position.set(-3, -1, -5);
scene.add(dirLight2);

// === CAMERA ORBIT ===
let theta = Math.PI / 4;
let phi = Math.PI / 3;
let radius = 7;
const target = new THREE.Vector3(0, 0, 0);

function updateCamera() {
    camera.position.x = target.x + radius * Math.sin(phi) * Math.cos(theta);
    camera.position.y = target.y + radius * Math.cos(phi);
    camera.position.z = target.z + radius * Math.sin(phi) * Math.sin(theta);
    camera.lookAt(target);
}

updateCamera();

// === CUBE CREATION ===
const mainGroup = new THREE.Group();
scene.add(mainGroup);

const cubies = [];
const cubieGeometry = new THREE.BoxGeometry(0.85, 0.85, 0.85);

const faceColors = {
    right: 0xB71234,   // Red
    left: 0xFF8C00,    // Orange
    up: 0xFFFFFF,      // White
    down: 0xFFD500,    // Yellow
    front: 0x009B48,   // Green
    back: 0x0046AD,    // Blue
};
const innerColor = 0x111111;

function createCubieMaterials(x, y, z) {
    const colors = [
        x === 1 ? faceColors.right : innerColor,   // +X
        x === -1 ? faceColors.left : innerColor,    // -X
        y === 1 ? faceColors.up : innerColor,       // +Y
        y === -1 ? faceColors.down : innerColor,    // -Y
        z === 1 ? faceColors.front : innerColor,    // +Z
        z === -1 ? faceColors.back : innerColor,    // -Z
    ];
    return colors.map(c => new THREE.MeshStandardMaterial({
        color: c,
        roughness: 0.35,
        metalness: 0.05,
    }));
}

function createCube() {
    // Clear existing
    while (mainGroup.children.length > 0) {
        const child = mainGroup.children[0];
        mainGroup.remove(child);
        if (child.geometry !== cubieGeometry) child.geometry.dispose();
        if (Array.isArray(child.material)) {
            child.material.forEach(m => m.dispose());
        }
    }
    cubies.length = 0;
    
    for (let x = -1; x <= 1; x++) {
        for (let y = -1; y <= 1; y++) {
            for (let z = -1; z <= 1; z++) {
                if (x === 0 && y === 0 && z === 0) continue;
                const materials = createCubieMaterials(x, y, z);
                const mesh = new THREE.Mesh(cubieGeometry, materials);
                mesh.position.set(x, y, z);
                mainGroup.add(mesh);
                cubies.push(mesh);
            }
        }
    }
}

createCube();

// === ANIMATION STATE ===
let isAnimating = false;
let animationData = null;

function getLayerCubies(axis, layer) {
    return cubies.filter(c => {
        const pos = new THREE.Vector3();
        c.getWorldPosition(pos);
        return Math.round(pos[axis]) === layer;
    });
}

function rotateFace(axis, layer, angle) {
    if (isAnimating) return;
    
    const layerCubies = getLayerCubies(axis, layer);
    if (layerCubies.length === 0) return;
    
    isAnimating = true;
    
    const pivot = new THREE.Group();
    scene.add(pivot);
    
    layerCubies.forEach(c => {
        mainGroup.remove(c);
        pivot.add(c);
    });
    
    // Ensure world transforms are preserved after reparenting
    // Since pivot and mainGroup both have identity world matrices,
    // the cubies' world transforms are preserved automatically.
    
    animationData = {
        pivot,
        cubies: layerCubies,
        axis,
        targetAngle: angle,
        startTime: Date.now(),
        duration: 280,
    };
}

function completeRotation() {
    const { pivot, cubies: layerCubies } = animationData;
    
    pivot.updateMatrixWorld();
    
    layerCubies.forEach(c => {
        const worldPos = new THREE.Vector3();
        const worldQuat = new THREE.Quaternion();
        c.getWorldPosition(worldPos);
        c.getWorldQuaternion(worldQuat);
        
        pivot.remove(c);
        mainGroup.add(c);
        
        c.position.copy(worldPos);
        c.position.x = Math.round(c.position.x);
        c.position.y = Math.round(c.position.y);
        c.position.z = Math.round(c.position.z);
        
        snapQuaternion(worldQuat);
        c.quaternion.copy(worldQuat);
    });
    
    scene.remove(pivot);
    animationData = null;
    isAnimating = false;
}

function snapQuaternion(quat) {
    const m4 = new THREE.Matrix4().makeRotationFromQuaternion(quat);
    const e = m4.elements;
    // Round the 3x3 rotation part
    for (let col = 0; col < 3; col++) {
        for (let row = 0; row < 3; row++) {
            e[col * 4 + row] = Math.round(e[col * 4 + row]);
        }
    }
    quat.setFromRotationMatrix(m4);
}

function rotateFaceInstant(axis, layer, angle) {
    const layerCubies = getLayerCubies(axis, layer);
    if (layerCubies.length === 0) return;
    
    const pivot = new THREE.Group();
    scene.add(pivot);
    
    layerCubies.forEach(c => {
        mainGroup.remove(c);
        pivot.add(c);
    });
    
    pivot.rotation.set(
        axis === 'x' ? angle : 0,
        axis === 'y' ? angle : 0,
        axis === 'z' ? angle : 0
    );
    pivot.updateMatrixWorld();
    
    layerCubies.forEach(c => {
        const worldPos = new THREE.Vector3();
        const worldQuat = new THREE.Quaternion();
        c.getWorldPosition(worldPos);
        c.getWorldQuaternion(worldQuat);
        
        pivot.remove(c);
        mainGroup.add(c);
        
        c.position.copy(worldPos);
        c.position.x = Math.round(c.position.x);
        c.position.y = Math.round(c.position.y);
        c.position.z = Math.round(c.position.z);
        
        snapQuaternion(worldQuat);
        c.quaternion.copy(worldQuat);
    });
    
    scene.remove(pivot);
}

// === CLICK DETECTION ===
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

function getIntersections(event) {
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
    
    raycaster.setFromCamera(mouse, camera);
    return raycaster.intersectObjects(cubies);
}

function handleClick(event) {
    if (isAnimating) return;
    
    const intersections = getIntersections(event);
    if (intersections.length === 0) return;
    
    const intersection = intersections[0];
    const normal = intersection.face.normal.clone();
    
    // Transform normal to world space
    normal.transformDirection(intersection.object.matrixWorld);
    
    // Determine axis and layer
    const absX = Math.abs(normal.x);
    const absY = Math.abs(normal.y);
    const absZ = Math.abs(normal.z);
    
    let axis, layer;
    if (absX >= absY && absX >= absZ) {
        axis = 'x';
        layer = normal.x > 0 ? 1 : -1;
    } else if (absY >= absX && absY >= absZ) {
        axis = 'y';
        layer = normal.y > 0 ? 1 : -1;
    } else {
        axis = 'z';
        layer = normal.z > 0 ? 1 : -1;
    }
    
    rotateFace(axis, layer, Math.PI / 2);
}

// === MOUSE / TOUCH CONTROLS ===
const canvas = renderer.domElement;
let isDragging = false;
let dragStart = { x: 0, y: 0 };
let prevMouse = { x: 0, y: 0 };
let hasMoved = false;

canvas.addEventListener('mousedown', (e) => {
    if (e.button === 0) {
        isDragging = true;
        hasMoved = false;
        dragStart.x = e.clientX;
        dragStart.y = e.clientY;
        prevMouse.x = e.clientX;
        prevMouse.y = e.clientY;
    }
});

canvas.addEventListener('mousemove', (e) => {
    if (!isDragging) return;
    
    const dx = e.clientX - prevMouse.x;
    const dy = e.clientY - prevMouse.y;
    
    if (Math.abs(e.clientX - dragStart.x) > 2 || Math.abs(e.clientY - dragStart.y) > 2) {
        hasMoved = true;
    }
    
    theta -= dx * 0.005;
    phi -= dy * 0.005;
    phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi));
    
    prevMouse.x = e.clientX;
    prevMouse.y = e.clientY;
    updateCamera();
});

canvas.addEventListener('mouseup', (e) => {
    if (isDragging && !hasMoved) {
        handleClick(e);
    }
    isDragging = false;
});

canvas.addEventListener('wheel', (e) => {
    e.preventDefault();
    radius += e.deltaY * 0.01;
    radius = Math.max(3, Math.min(15, radius));
    updateCamera();
}, { passive: false });

// Touch support
canvas.addEventListener('touchstart', (e) => {
    if (e.touches.length === 1) {
        isDragging = true;
        hasMoved = false;
        dragStart.x = e.touches[0].clientX;
        dragStart.y = e.touches[0].clientY;
        prevMouse.x = e.touches[0].clientX;
        prevMouse.y = e.touches[0].clientY;
    }
});

canvas.addEventListener('touchmove', (e) => {
    if (!isDragging || e.touches.length !== 1) return;
    e.preventDefault();
    
    const dx = e.touches[0].clientX - prevMouse.x;
    const dy = e.touches[0].clientY - prevMouse.y;
    
    if (Math.abs(e.touches[0].clientX - dragStart.x) > 5 || Math.abs(e.touches[0].clientY - dragStart.y) > 5) {
        hasMoved = true;
    }
    
    theta -= dx * 0.005;
    phi -= dy * 0.005;
    phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi));
    
    prevMouse.x = e.touches[0].clientX;
    prevMouse.y = e.touches[0].clientY;
    updateCamera();
}, { passive: false });

canvas.addEventListener('touchend', (e) => {
    if (isDragging && !hasMoved) {
        // Simulate click at the touch position
        const fakeEvent = {
            clientX: dragStart.x,
            clientY: dragStart.y,
        };
        handleClick(fakeEvent);
    }
    isDragging = false;
});

// === SCRAMBLE & RESET ===
function scramble() {
    if (isAnimating) return;
    
    const axes = ['x', 'y', 'z'];
    const layers = [-1, 1];
    const angles = [Math.PI / 2, -Math.PI / 2];
    
    for (let i = 0; i < 20; i++) {
        const axis = axes[Math.floor(Math.random() * 3)];
        const layer = layers[Math.floor(Math.random() * 2)];
        const angle = angles[Math.floor(Math.random() * 2)];
        rotateFaceInstant(axis, layer, angle);
    }
}

function resetCube() {
    if (isAnimating) return;
    
    // Dispose old cubies
    cubies.forEach(c => {
        mainGroup.remove(c);
        if (Array.isArray(c.material)) {
            c.material.forEach(m => m.dispose());
        }
    });
    cubies.length = 0;
    
    createCube();
}

// === UI BUTTONS ===
document.getElementById('scramble-btn').addEventListener('click', scramble);
document.getElementById('reset-btn').addEventListener('click', resetCube);

// === RESIZE ===
window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
});

// === ANIMATION LOOP ===
function animate() {
    requestAnimationFrame(animate);
    
    if (animationData) {
        const elapsed = Date.now() - animationData.startTime;
        const duration = animationData.duration;
        let progress = Math.min(elapsed / duration, 1.0);
        
        // Ease in-out cubic
        progress = progress < 0.5
            ? 4 * progress * progress * progress
            : 1 - Math.pow(-2 * progress + 2, 3) / 2;
        
        const angle = animationData.targetAngle * progress;
        
        animationData.pivot.rotation.set(
            animationData.axis === 'x' ? angle : 0,
            animationData.axis === 'y' ? angle : 0,
            animationData.axis === 'z' ? angle : 0
        );
        
        if (elapsed >= duration) {
            completeRotation();
        }
    }
    
    renderer.render(scene, camera);
}

animate();
```

This is comprehensive. Let me now wrap it in the HTML with proper styles and button IDs.

I notice I used `document.getElementById('scramble-btn')` and `'reset-btn'` but haven't defined those in the HTML yet. I'll add them to the UI.

Let me also consider adding a move counter and maybe keyboard shortcuts. But let's keep it simple with just the two buttons.

For the CSS, I'll style the buttons nicely with a modern look.

Alright, I'm now going to write the complete file. Let me make it polished.

Actually, I want to double-check one thing: the `getWorldPosition` approach during reparenting. When I move a cubie from `mainGroup` to `pivot`, Three.js preserves the cubie's local transform. But since `mainGroup` and `pivot` start with identity world matrices, the cubie's world transform doesn't change. Good.

When I rotate `pivot`, the cubies rotate with it. After rotation, I need to read the cubie's world position and quaternion, then reparent to `mainGroup` while preserving the world transform.

The code does:
```javascript
pivot.updateMatrixWorld();  // Update the scene graph
c.getWorldPosition(worldPos);  // Get position in world space
c.getWorldQuaternion(worldQuat);  // Get rotation in world space

pivot.remove(c);
mainGroup.add(c);

c.position.copy(worldPos);  // Set local position = world position (since mainGroup is identity)
c.quaternion.copy(worldQuat);  // Set local quaternion = world quaternion
```

This is correct because `mainGroup.matrixWorld` is identity. The cubie's local transform equals its world transform.

But wait, there's a subtlety: when I call `pivot.remove(c)` followed by `mainGroup.add(c)`, Three.js might fire events or trigger matrix updates. The cubie's local position/quaternion are preserved across the reparenting, but since its parent changed, its world transform changes. Then I overwrite the local position/quaternion with the saved world values. This correctly restores the world transform.

But I'm a bit concerned: after `mainGroup.add(c)`, the cubie's world matrix might be briefly incorrect. Then I set the local position/quaternion. Then on the next render, the world matrix will be recalculated. This should be fine.

Actually, let me also call `c.updateMatrixWorld()` after setting the local transform to ensure everything is in sync. No, the renderer handles that.

OK, I think this is correct. Let me also handle the edge case where `getLayerCubies` might return an empty array. I already check for that.

One more thing: when I create the pivot for an animated rotation, I add it to the scene. The pivot starts at (0,0,0) with no rotation. When cubies are reparented from mainGroup to pivot, their world transforms are preserved. Then I animate by setting `pivot.rotation`. This rotates the cubies around the world origin, which is the center of the cube. This is exactly what we want.

For the right face rotation (axis='x', layer=1), the cubies at x≈1 rotate around the X axis. Since the pivot is at origin, the rotation correctly moves the cubies in a circle around the X axis.

Perfect. Let me finalize the code.

Actually, I realize I should also handle the case where the user right-clicks or middle-clicks. I'll just ignore those for now.

I also want to make the UI buttons look good. Let me add some styling.

Let me finalize the complete HTML file now. I'm going to write it all at once.

I'll include:
- Dark theme with a beautiful background
- Styled buttons for Scramble and Reset
- The full Three.js Rubik's cube implementation
- Smooth animations
- Orbit controls
- Touch support

Let me also consider: should I handle the case where the user clicks on the dark gap between cubies? The raycaster would miss all cubies, so no intersection, and nothing happens. This is fine.

What about clicking on a cubie's inner (dark) face? The raycaster would hit it, and the normal would be used to determine the rotation axis. This should still work correctly because the dark faces are on the interior of the cube, and their normals point in the correct directions.

Alright, writing the final code now.

Let me also think about whether the cubie's geometry needs `computeVertexNormals` or anything special. BoxGeometry comes with normals precomputed, so raycasting should work fine.

One final thought: I want to make sure the camera's lookAt target stays at the cube center. I already set it to (0,0,0) in updateCamera(). Good.

Let me write it.
bash {"command":"ls /workspace/three.min.js 2>/dev/null && echo \"EXISTS\" || echo \"NOT FOUND\""}
toolResult

/workspace/three.min.js EXISTS

assistant
thinking
The three.min.js file exists. Now let me create the complete index.html file with the Rubik's Cube implementation.
write /workspace/index.html (15516 bytes)
toolResult

Successfully wrote 15516 bytes to /workspace/index.html

assistant
thinking
The file has been written. Let me verify it looks correct and double-check the key aspects:

1. Loads three.min.js correctly ✓
2. All in one file with inline CSS/JS ✓
3. Creates a 3x3x3 Rubik's cube with 26 cubies ✓
4. Face colors: Red (+X), Orange (-X), White (+Y), Yellow (-Y), Green (+Z), Blue (-Z) ✓
5. Dark inner faces ✓
6. Orbit-drag camera with mouse and touch support ✓
7. Scroll zoom ✓
8. Click detection using raycasting and face normals ✓
9. Face rotation animation with easing ✓
10. Scramble button (instant moves) ✓
11. Reset button ✓
12. Gap between cubies (0.84 size with 1.0 spacing) ✓

Let me verify the code looks correct by reading it back.
bash {"command":"cd /workspace && wc -l index.html && head -5 index.html && echo \"---\" && tail -5 index.html"}
toolResult

559 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"> --- requestAnimationFrame(animate); })(); </script> </body> </html>

assistant
thinking
The file is 559 lines and properly structured. Let me verify a few key sections to make sure there are no bugs:

1. The `getWorldPosition` call inside `getLayer` - this should work since cubies are direct children of mainGroup and mainGroup has identity world matrix. The cubie's world position equals its local position.

2. The `reparentToMain` function - this correctly handles the pivot-to-mainGroup transfer.

3. The animation loop - uses `performance.now()` for timing, ease-in-out cubic.

4. The click detection via raycasting - transforms face normal to world space.

Let me also check that the pointer capture is used correctly and there are no issues with the touch handling.

Actually, I notice one potential issue: in the `pointerdown` handler, I filter out touch events with `if (e.pointerType === 'touch') return;`. But I still call `canvas.setPointerCapture(e.pointerId)`. This is fine since the early return prevents that line from executing for touch events.

Wait, no - the `setPointerCapture` is after the early return, so it won't execute for touch. Good.

Also, I should make sure the `pointerup` handler releases capture only if we actually captured it. The `releasePointerCapture` could throw if we didn't capture. But since we only set capture for non-touch pointer events and only release for non-touch pointer events, this is fine.

Let me also verify that the `getLayer` function works correctly. It filters cubies where `Math.round(wp[axis]) === layer`. Since cubies' positions are always snapped to integers after every rotation, this should be accurate.

Everything looks good. The implementation is complete and should work correctly.

One small thing: in the `getLayer` function, we create a new `Vector3` for each cubie filter iteration. This is fine performance-wise for 26 cubies.

I'm satisfied with the code. Let me present the result to the user.

The file is complete. Here's what was built: **`/workspace/index.html`** — a fully self-contained 3D Rubik's Cube (559 lines, no external resources beyond the pre-provided `three.min.js`). ### Features | Feature | Details | |---|---| | **3D Cube** | 26 cubies (3×3×3 minus center), each with colored face materials matching standard Western scheme: white top, yellow bottom, green front, blue back, red right, orange left | | **Gaps** | Cubies are 0.84 units wide spaced at integer coordinates for visible dark gaps between them | | **Face rotations** | Click any visible face → the corresponding outer layer rotates 90° with cubic ease-in-out animation (270ms). Direction is determined by the clicked face normal in world space | | **Orbit camera** | Drag with mouse or one-finger touch to orbit around the cube. Scroll wheel zooms in/out. Clamped polar angle prevents gimbal lock | | **Click vs drag** | A 3px dead zone distinguishes clicks from camera drags | | **Scramble** | Applies 22 random instant face moves (avoids consecutive same-face moves for better mixing) | | **Reset** | Tears down and rebuilds the cube in solved state | | **Lighting** | Three-point lighting (key, fill, rim) + ambient for depth and readable face colors | | **Responsive** | Handles window resize; touch and pointer events for mobile | | **No network** | Zero CDN or external requests — fully sandboxed |

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

modeldeepseek-v4-pro
vendorDeepSeek
harnesspi-agent
generated2026-07-10

EVALUATION

3/5 working

3D Rubik's Cube renders with face rotation on click, orbit drag, and zoom via wheel; keys have no effect as expected.

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.

idle motion0.0%
on click5.5%
on drag16.8%
on wheel40.0%
on enter + space0.0%
on arrow keys0.0%
on w/a/s/d0.0%
frame spread50.5 / 255
console errors0
js errors none

METRICS

runtime470.9s
tokens in27k
tokens out24k
cached44k
cost / run$0.0323