oneshotlm
Compare all models on this prompt →

Rubik's Cube · kimi-k2.5

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.

⚠ no runnable output

This one-shot run produced no runnable artifact.

agent produced no index.html

The transcript below shows what the model did — read it to see why the one shot fell short.

transcript · 2 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 a Rubik's Cube simulation using Three.js (already provided as three.min.js). It should be:

1. Interactive 3D cube
2. Clickable face rotations
3. Orbit-drag camera (OrbitControls)
4. Scramble button

Constraints:
- Everything in one file: index.html
- Inline CSS and JS
- No external resources/CDNs except the local three.min.js
- Use global THREE from three.min.js

Structure:
- HTML skeleton
- CSS for basic styling (canvas container, UI controls)
- Three.js setup (scene, camera, renderer, lights)
- Rubik's Cube construction (27 smaller cubes in 3x3x3)
- Color scheme: Standard Rubik's colors (Red, Orange, Blue, Green, White, Yellow)
- Interaction logic:
  - Raycasting for click detection on faces
  - Determine which face/cubies to rotate based on click
  - Smooth animation for rotations
- OrbitControls for camera (need to include OrbitControls code inline or use a simplified version since we can't load external files. Wait, the user says "Using the pre-provided three.min.js (global THREE)". OrbitControls is not in three.min.js core, it's an addon. But I cannot use external resources. I have two options:
  1. Implement a simple orbit camera myself
  2. Include OrbitControls code inline in the script tag

Since OrbitControls is required for "orbit-drag camera", and I can't load it from a CDN, I should include the OrbitControls source code directly in the HTML file. However, OrbitControls is large. Maybe I can implement a simpler orbit control or just copy the necessary parts.

Actually, looking at the constraints again: "These library file(s) are ALREADY present in the current directory — load each with a plain <script src="NAME"></script> using its exact filename". So I should check if OrbitControls is present? The user only mentioned three.min.js. So I should assume only three.min.js is available.

Therefore, I need to either:
1. Write a simple orbit control implementation
2. Embed OrbitControls code directly in the script (but that might violate the spirit of "no external resources" if I'm pasting a huge block)

Actually, the safest approach is to implement a minimal OrbitControls-like functionality or just include the standard OrbitControls source code at the top of my script since it's part of Three.js ecosystem and the user expects it.

But wait, the user said "Using the pre-provided three.min.js (global THREE)". This implies I should use what's available. If OrbitControls isn't there, I need to provide it.

Let me check what the standard Rubik's cube implementation needs:

Core components:
- Scene setup
- Camera (PerspectiveCamera)
- Renderer
- Lights
- 27 Cubes (Mesh with BoxGeometry) arranged in 3x3x3
- Materials for each face color
- Grouping logic for rotation (when rotating a face, we need to rotate the 9 cubies together)

Implementation details:

**Cube Construction:**
Each small cube is 1x1x1. Positions range from -1 to 1 (or -1.05 to 1.05 with gaps).
Colors:
- Right: Red (0xff0000) - x+
- Left: Orange (0xffa500) - x-
- Top: White (0xffffff) - y+
- Bottom: Yellow (0xffff00) - y-
- Front: Green (0x00ff00) - z+
- Back: Blue (0x0000ff) - z-

Actually, standard Rubik's cube colors vary, but commonly:
- Up: White
- Down: Yellow  
- Front: Green
- Back: Blue
- Right: Red
- Left: Orange

**Interaction:**
- Raycaster to detect clicks
- When a face is clicked, determine the normal of the face clicked (which tells us which axis to rotate around)
- Also need to determine the layer (e.g., if clicking on the right face of a cube at x=1, we rotate the x=1 layer)
- Rotation should be animated (90 degrees)

**Rotation Logic:**
To rotate a face:
1. Identify the 9 cubies in that layer (e.g., all cubies with x ≈ 1 for right face)
2. Create a pivot group at the center of the layer
3. Attach the cubies to the pivot (by changing their parent or using world transforms)
4. Rotate the pivot 90 degrees
5. Detach cubies back to main scene, update their matrices
6. Update internal state representation

**State Management:**
We need to track the color state to know when solved, but the prompt doesn't require win detection. Just rotations.

**Simplification for "Clickable face rotations":**
The user wants to rotate faces by clicking. Usually, this means:
- Clicking on a specific face of the cube rotates that face 90 degrees clockwise (or based on drag direction)
- Or clicking could select the face, then use buttons to rotate

But the standard interpretation is: click on a face to rotate it. However, that doesn't give direction. Maybe:
- Click on the face to rotate it clockwise relative to the camera view
- Or right-click for counter-clockwise

But a better UX:
- Click and drag on a face to rotate it in that direction (like the actual cube)
- But that's complex.

Simpler approach for this task:
- Have 6 buttons for the 6 faces (F, B, U, D, L, R) + clockwise/counterclockwise
- Or: Click on a cubie face, and it rotates that face 90 degrees clockwise. Use shift+click for counter-clockwise.

Given the constraints of a single file and clarity, I'll implement:
- OrbitControls (simple version or embedded)
- Click on any cubie: determines which face normal was clicked (using face normal from intersection)
- Rotate the corresponding layer 90 degrees clockwise
- Shift+click for counter-clockwise
- Scramble button that performs 20 random moves

**Minimal OrbitControls Implementation:**
Since I can't rely on OrbitControls being present, and I shouldn't fetch it, I'll write a minimal version that supports:
- Left mouse drag to rotate around the cube
- Scroll to zoom
- Right mouse drag to pan (optional but good)

Actually, writing a full OrbitControls is tedious. I can include the standard Three.js OrbitControls code inline. It's about ~1000 lines but it's reliable.

Wait, the user said "Use NO external resources, CDNs or network requests". This means I should inline everything including OrbitControls if needed.

Let me check the size constraints... The output can be large.

Plan:
1. HTML structure with canvas container
2. CSS for full-screen canvas and UI overlay
3. Inline OrbitControls source (I need to copy it from Three.js examples)
4. Main script:
   - Setup scene, camera, renderer
   - Setup lights
   - Create 27 cubies
   - Raycasting logic
   - Rotation animation logic
   - Scramble function

**Cube Data Structure:**
Array of 27 objects: { mesh, x, y, z } where x,y,z are in {-1, 0, 1} representing the logical position.

**Rotation Animation:**
When rotating a face:
- Create a pivot Object3D at the center of the cube (0,0,0) but offset? No, for a layer at x=1, the pivot should be at (1,0,0) and rotate around x-axis.
- Actually, for layer x=1, we group the 9 cubies and rotate the group around the x-axis passing through (1,0,0).
- Better: Move the pivot to (0,0,0), rotate, but the cubies are at positions like (1, y, z). So we can just rotate around the axis passing through origin.
- Wait, if we rotate around world X axis, cubies at x=1 will rotate in a circle of radius sqrt(y^2+z^2) around x-axis, which is correct for a layer rotation.

So algorithm for rotating layer at x=1:
1. Identify all cubies with cubie.x === 1
2. For animation: create a temporary Group
3. Add the cubies to the group (but changing parent in Three.js changes the transform, so we need to use attach method to preserve world transform)
4. Rotate the group around world X axis (group.rotation.x += Math.PI/2)
5. On completion, detach cubies back to scene, update their logical x,y,z positions (which effectively swap based on rotation)

Actually, updating logical positions after rotation:
If we rotate layer x=1 around X axis 90 degrees clockwise (looking from positive X towards origin):
- (1, y, z) -> (1, y*cos - z*sin, y*sin + z*cos)
- For 90 degrees: (1, y, z) -> (1, -z, y)
- So a cubie at (1, 1, 0) [top center] goes to (1, 0, 1) [front center]
- This affects colors on the faces.

But if we just rotate the meshes and update their logical grid coordinates, the colors should follow because the materials are on the meshes.

Wait, the small cubes have fixed materials on their faces. When we rotate the cube mesh, the materials rotate with it, which is correct.

So after rotation:
- The cubie that was at (1,1,0) is now at (1,0,1) physically.
- Its mesh.rotation reflects the change.
- But we also need to update which logical position it occupies so future rotations affect the correct set.

So yes, we need to track the logical position of each cubie (which slice it's currently in).

**Code Structure:**

```html
<!DOCTYPE html>
<html>
<head>
<style>
  body { margin: 0; overflow: hidden; font-family: sans-serif; }
  #canvas-container { width: 100vw; height: 100vh; }
  #controls {
    position: absolute;
    top: 10px;
    left: 10px;
    background: rgba(255,255,255,0.9);
    padding: 15px;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  }
  button {
    margin: 5px;
    padding: 10px 15px;
    cursor: pointer;
    font-size: 14px;
  }
  .instructions {
    margin-top: 10px;
    font-size: 12px;
    color: #666;
  }
</style>
</head>
<body>
<div id="canvas-container"></div>
<div id="controls">
  <button id="scramble">Scramble</button>
  <button id="reset">Reset</button>
  <div class="instructions">
    Left click + drag: Rotate camera<br>
    Click face: Rotate clockwise<br>
    Shift+Click: Rotate counter-clockwise
  </div>
</div>

<!-- Three.js -->
<script src="three.min.js"></script>

<script>
// OrbitControls code here (inline)
// ... standard OrbitControls source ...

// Main Rubik's Cube code
(function() {
  // Scene setup
  // Cube creation
  // Interaction logic
  // Animation loop
})();
</script>
</body>
</html>
```

**OrbitControls Inline:**
I need to paste the OrbitControls code. Since I don't have the exact file content, I should write a simplified but functional version.

Simplified OrbitControls features:
- Rotate: left mouse drag
- Zoom: wheel
- Pan: right mouse drag

Implementation:
- Listen to pointerdown, pointermove, pointerup
- On pointerdown, record start position and button
- On pointermove, if left button: calculate delta, update spherical coordinates around target
- If right button: pan camera

Actually, I'll implement a minimal OrbitControls class:

```javascript
class OrbitControls {
  constructor(camera, domElement) {
    this.camera = camera;
    this.domElement = domElement;
    this.target = new THREE.Vector3();
    this.enableDamping = true;
    this.dampingFactor = 0.05;
    
    // Spherical coordinates
    this.spherical = new THREE.Spherical();
    this.sphericalDelta = new THREE.Spherical();
    
    // State
    this.isDragging = false;
    this.previousMouse = new THREE.Vector2();
    
    this.domElement.addEventListener('pointerdown', this.onPointerDown.bind(this));
    this.domElement.addEventListener('pointermove', this.onPointerMove.bind(this));
    this.domElement.addEventListener('pointerup', this.onPointerUp.bind(this));
    this.domElement.addEventListener('wheel', this.onWheel.bind(this));
    
    this.update();
  }
  
  onPointerDown(e) {
    this.isDragging = true;
    this.previousMouse.set(e.clientX, e.clientY);
  }
  
  onPointerMove(e) {
    if (!this.isDragging) return;
    const deltaX = e.clientX - this.previousMouse.x;
    const deltaY = e.clientY - this.previousMouse.y;
    
    this.sphericalDelta.theta -= deltaX * 0.01;
    this.sphericalDelta.phi -= deltaY * 0.01;
    
    this.previousMouse.set(e.clientX, e.clientY);
    this.update();
  }
  
  onPointerUp() {
    this.isDragging = false;
  }
  
  onWheel(e) {
    this.camera.translateZ(e.deltaY * 0.01);
    this.update();
  }
  
  update() {
    // Apply spherical rotation
    this.spherical.setFromVector3(this.camera.position.clone().sub(this.target));
    this.spherical.theta += this.sphericalDelta.theta;
    this.spherical.phi += this.sphericalDelta.phi;
    this.spherical.makeSafe();
    
    this.camera.position.setFromSpherical(this.spherical).add(this.target);
    this.camera.lookAt(this.target);
    
    if (this.enableDamping) {
      this.sphericalDelta.theta *= (1 - this.dampingFactor);
      this.sphericalDelta.phi *= (1 - this.dampingFactor);
    } else {
      this.sphericalDelta.set(0, 0, 0);
    }
  }
}
```

Wait, this is getting complicated. Maybe I should just use the standard OrbitControls source. It's safer.

Actually, looking at the prompt requirements again, it's acceptable to include OrbitControls inline as long as it's not an external request.

I'll include a properly licensed minimal OrbitControls.

**Rubik's Cube Logic Details:**

Colors:
- Materials array for each small cube: [Right, Left, Top, Bottom, Front, Back] or whatever order BoxGeometry uses.
BoxGeometry face order: +x, -x, +y, -y, +z, -z

So:
- index 0 (Right): Red 0xff0000  
- index 1 (Left): Orange 0xffa500
- index 2 (Top): White 0xffffff
- index 3 (Bottom): Yellow 0xffff00  
- index 4 (Front): Green 0x00ff00
- index 5 (Back): Blue 0x0000ff

Core colors:
```javascript
const COLORS = {
  U: 0xffffff, // White
  D: 0xffff00, // Yellow  
  F: 0x00ff00, // Green
  B: 0x0000ff, // Blue
  R: 0xff0000, // Red
  L: 0xffa500  // Orange
};
```

Creating cubies:
```javascript
const geometry = new THREE.BoxGeometry(0.95, 0.95, 0.95); // slightly smaller for gaps
// Materials for each face
const materials = [
  new THREE.MeshStandardMaterial({ color: x === 1 ? COLORS.R : 0x111111 }), // Right
  new THREE.MeshStandardMaterial({ color: x === -1 ? COLORS.L : 0x111111 }), // Left
  new THREE.MeshStandardMaterial({ color: y === 1 ? COLORS.U : 0x111111 }), // Top
  new THREE.MeshStandardMaterial({ color: y === -1 ? COLORS.D : 0x111111 }), // Bottom
  new THREE.MeshStandardMaterial({ color: z === 1 ? COLORS.F : 0x111111 }), // Front
  new THREE.MeshStandardMaterial({ color: z === -1 ? COLORS.B : 0x111111 })  // Back
];
```
Inner faces are black (0x111111) so we don't see colors inside.

**Rotation Logic:**

When user clicks:
1. Raycast to find intersected mesh
2. Get the face normal (intersection.face.normal) - this is in local space of the mesh, need to transform to world space or check which component is ±1
3. The normal tells us which axis: if normal.x is ±1, it's the X-face; if y, Y-face; if z, Z-face.
4. The position of the cubie tells us which layer: if cubie.position.x is 1, it's the right layer.

So:
- If clicked on face with normal pointing in +X direction, and cubie is at x=1, we should rotate the x=1 layer around X axis.
- Direction: click should rotate clockwise. But clockwise depends on viewing angle. Standard: looking from outside towards center.
  - For +X face (Right), clockwise is -Y to -Z to +Y to +Z, which is rotation around X by -90 degrees (or +90 depending on hand rule).

Actually, Three.js uses right-hand rule. Rotation around X:
- Positive angle: Y goes towards Z (Y rotates to Z)
- Negative angle: Y goes towards -Z

For Right face (+X), looking from +X towards origin (looking left):
- Up is +Y, Right is +Z (wait, no: looking from +X towards origin, the coordinate system: X points out of screen, Y is up, Z is right? No.)
Standard view: looking from positive X towards origin:
- Your up is +Y
- Your right is +Z
Clockwise rotation would be: Top goes to Front, Front goes to Bottom, etc.
- Top (+Y) goes to Front (+Z): This means Y becomes Z, which is positive rotation around X by 90 degrees (since Y cross Z = X, but rotation from Y to Z is... let's check.)
Rotation matrix around X by θ:
y' = y*cos(θ) - z*sin(θ)
z' = y*sin(θ) + z*cos(θ)

For θ = 90° (π/2):
y' = -z
z' = y

So +Y goes to +Z? No:
If y=1, z=0: y'=0, z'=1. Yes, +Y goes to +Z.
So a positive 90-degree rotation around X moves the top to the front (from +Y to +Z).

If we want clockwise rotation when looking from +X towards origin:
- Top (+Y) should go to Back (-Z)? No, wait.
Standard Rubik's cube notation:
- R move rotates the Right face 90 degrees clockwise looking at the Right face.
Looking at Right face (from +X):
- The Up face is at the top
- The Front face is to the right? No.
Coordinates: X right, Y up, Z towards viewer (out of screen).
Wait, standard Three.js: Z is towards viewer.
So:
- Right: +X
- Left: -X
- Up: +Y  
- Down: -Y
- Front: +Z (towards camera)
- Back: -Z (away from camera)

Looking at Right face (+X) from the right side (positive X looking towards origin):
- Up is +Y (up)
- Towards you is -X (but you're looking -X direction)
- Your right hand side corresponds to... let's think.
Position: standing at (5,0,0) looking at (0,0,0).
- Up vector is (0,1,0)
- Right vector is cross(lookDir, up) = cross((-1,0,0), (0,1,0)) = (0,0,-1). So your right is -Z.
So the front face (+Z) is to your left, and back face (-Z) is to your right.

Clockwise rotation:
- Top (+Y) should go to where? Clockwise means Top -> Right -> Bottom -> Left -> Top.
Right is -Z. So Top (+Y) should go to -Z.
From earlier: rotation +90° around X sends +Y to +Z.
So to send +Y to -Z, we need -90° rotation (or +270°).

So for Right face (+X layer), clockwise rotation is -90° around X (or +90° around -X).

Similarly:
- Left face (-X layer): looking from -X towards origin (looking right).
  - Up is +Y
  - Right is +Z (cross((1,0,0), (0,1,0)) = (0,0,1))
  - Clockwise: Top (+Y) goes to Right (+Z).
  - Rotation +90° around X sends +Y to +Z.
  - So Left face clockwise is +90° around X.

- Front face (+Z layer): looking from +Z towards origin (into the screen).
  - Up is +Y
  - Right is -X (cross((0,0,-1), (0,1,0)) = (-1,0,0))
  - Clockwise: Top (+Y) goes to Right (-X).
  - Rotation around Z: +90° sends +X to +Y, +Y to -X.
  - Wait: around Z: x' = x*cos - y*sin, y' = x*sin + y*cos.
  - For 90°: x'=-y, y'=x.
  - So +Y (0,1) goes to (-1,0) which is -X. Yes.
  - So Front face clockwise is +90° around Z.

- Back face (-Z layer): looking from -Z towards origin.
  - Up is +Y
  - Right is +X
  - Clockwise: Top (+Y) goes to +X.
  - Rotation -90° around Z (or +90° around -Z) sends +Y to +X?
  - For -90° around Z: x' = x*cos + y*sin = y, y' = -x*sin + y*cos = -x.
  - So (0,1) -> (1,0). Yes.
  - So Back face clockwise is -90° around Z.

- Up face (+Y layer): looking from +Y down.
  - Front is +Z
  - Right is +X
  - Clockwise: Front (+Z) goes to Right (+X).
  - Rotation around Y: x' = x*cos + z*sin, z' = -x*sin + z*cos.
  - For 90°: x'=z, z'=-x.
  - So (0,0,1) -> (1,0,0). Yes.
  - So Up face clockwise is +90° around Y.

- Down face (-Y layer): looking from -Y up.
  - Front is +Z
  - Right is -X
  - Clockwise: Front (+Z) goes to Right (-X).
  - Rotation around Y: need (0,0,1) -> (-1,0,0).
  - For -90°: x' = -z, z' = x.
  - So (0,0,1) -> (-1,0,0). Yes.
  - So Down face clockwise is -90° around Y.

Summary for clockwise rotation (looking at the face):
- Right (+X): rotateX(-Math.PI/2)
- Left (-X): rotateX(Math.PI/2)
- Front (+Z): rotateZ(Math.PI/2)  
- Back (-Z): rotateZ(-Math.PI/2)
- Up (+Y): rotateY(Math.PI/2)
- Down (-Y): rotateY(-Math.PI/2)

This is important for the animation.

**Animation Implementation:**
To animate a layer rotation:
1. Select the 9 cubies in the layer
2. Create a temporary pivot Group at (0,0,0) (world center)
3. Use `pivot.attach(cubie)` to add each cubie to the pivot while preserving world transform
4. Animate `pivot.rotation[axis]` from 0 to targetAngle (±Math.PI/2)
5. When done, `scene.attach(cubie)` to put back into scene
6. Update cubie.gridPosition (the logical x,y,z) based on the rotation

Step 6 is crucial. When we rotate the layer around X:
- Cubies at y,z positions get new y,z.
Formulas:
Around X by 90°:
- y' = -z
- z' = y

Around X by -90° (or 270°):
- y' = z
- z' = -y

Around Y by 90°:
- x' = z
- z' = -x

Around Y by -90°:
- x' = -z
- z' = x

Around Z by 90°:
- x' = -y
- y' = x

Around Z by -90°:
- x' = y
- y' = -x

So after rotation, for each cubie in the layer, update its (x,y,z) grid coordinates accordingly.

**Raycasting Details:**
When clicking, we get the intersection point and face.
`intersection.face.normal` gives the normal in local coordinates. Since the cubies might be rotated (after previous moves), we need to transform the normal to world space or check against the world rotation of the cubie.

Actually, `intersection.face.normal` is in the mesh's local space. We can apply the mesh's world rotation to get the world normal:
`const worldNormal = intersection.face.normal.clone().transformDirection(cubie.matrixWorld).round();`

Then check which component is ±1:
- if worldNormal.x > 0.5: right face clicked
- if worldNormal.x < -0.5: left face clicked
- if worldNormal.y > 0.5: up face
- if worldNormal.y < -0.5: down face
- if worldNormal.z > 0.5: front face
- if worldNormal.z < -0.5: back face

Also need the cubie's current position to know which layer:
`const layer = Math.round(cubie.position.x);` (or y or z)

Then we know: rotate layer `layer` on axis `X` (if normal is x) in the appropriate direction.

Direction logic:
If we want:
- Click on any face: clockwise rotation
- Shift+click: counter-clockwise

From earlier mapping:
- If right face clicked (normal +X): clockwise is -X rotation
- If left face clicked (normal -X): clockwise is +X rotation
- If front face clicked (normal +Z): clockwise is +Z rotation
- If back face clicked (normal -Z): clockwise is -Z rotation
- If up face clicked (normal +Y): clockwise is +Y rotation  
- If down face clicked (normal -Y): clockwise is -Y rotation

Notice a pattern: clockwise rotation axis is the same as the normal axis, with sign:
- normal +X -> rotation -X
- normal -X -> rotation +X
- normal +Y -> rotation +Y
- normal -Y -> rotation -Y
- normal +Z -> rotation +Z
- normal -Z -> rotation -Z

Wait:
- Up (+Y) -> +Y rotation (matches)
- Down (-Y) -> -Y rotation (matches)
- Front (+Z) -> +Z rotation (matches)
- Back (-Z) -> -Z rotation (matches)
- Right (+X) -> -X rotation (opposite!)
- Left (-X) -> +X rotation (opposite!)

So for X-axis, it's inverted compared to the others. This is because of the handedness of the coordinate system when looking at those faces.

So we can implement:
```javascript
const axis = normal.clone().round(); // Vector3 indicating axis and direction
const isX = Math.abs(axis.x) > 0.5;
const layerValue = Math.round(cubie.position[isX ? 'x' : (Math.abs(axis.y) > 0.5 ? 'y' : 'z')]);
// But we need the actual axis string for the pivot rotation
```

Actually, simpler:
```javascript
const normal = intersection.face.normal.clone().transformDirection(cubie.matrixWorld).round();
const x = Math.abs(normal.x);
const y = Math.abs(normal.y);
const z = Math.abs(normal.z);

let axis, layer, positive;
if (x > 0.5) {
  axis = 'x';
  layer = Math.round(cubie.position.x);
  positive = normal.x > 0;
  // If clicked right face (normal +X), clockwise is negative rotation
  // So if !shift (clockwise), angle = positive ? -Math.PI/2 : Math.PI/2
} else if (y > 0.5) {
  axis = 'y';
  layer = Math.round(cubie.position.y);
  positive = normal.y > 0;
  // If clicked up face (normal +Y), clockwise is positive rotation
  // So angle = positive ? Math.PI/2 : -Math.PI/2
} else {
  axis = 'z';
  layer = Math.round(cubie.position.z);
  positive = normal.z > 0;
  // If clicked front face (normal +Z), clockwise is positive rotation
  // So angle = positive ? Math.PI/2 : -Math.PI/2
}
```

Angle calculation:
```javascript
const clockwise = !e.shiftKey;
let angle = Math.PI / 2;
if (axis === 'x') {
  angle = clockwise ? (positive ? -Math.PI/2 : Math.PI/2) : (positive ? Math.PI/2 : -Math.PI/2);
} else {
  angle = clockwise ? (positive ? Math.PI/2 : -Math.PI/2) : (positive ? -Math.PI/2 : Math.PI/2);
}
```

Wait, let's verify:
- Right face (+X), clockwise: should be -X rotation. So if positive (normal +X) and clockwise, angle = -90°. Correct.
- Left face (-X), clockwise: should be +X rotation. So if !positive (normal -X) and clockwise, angle = +90°. Correct.
- Up face (+Y), clockwise: should be +Y rotation. So if positive and clockwise, angle = +90°. Correct.
- Down face (-Y), clockwise: should be -Y rotation. So if !positive and clockwise, angle = -90°. Correct.
- Front face (+Z), clockwise: should be +Z rotation. So if positive and clockwise, angle = +90°. Correct.
- Back face (-Z), clockwise: should be -Z rotation. So if !positive and clockwise, angle = -90°. Correct.

So the formula is:
- If axis is 'x': angle = clockwise ? (positive ? -1 : 1) * 90 : (positive ? 1 : -1) * 90
- If axis is 'y' or 'z': angle = clockwise ? (positive ? 1 : -1) * 90 : (positive ? -1 : 1) * 90

Or simply: angle = (clockwise ? 1 : -1) * (axis === 'x' ? -1 : 1) * (positive ? 1 : -1) * Math.PI/2
Wait, no. Let's derive:
For x: sign = positive ? -1 : 1 when clockwise
For y,z: sign = positive ? 1 : -1 when clockwise

So: sign = (positive ? 1 : -1) * (axis === 'x' ? -1 : 1) when clockwise.
If !clockwise, multiply by -1.

```javascript
let direction = (positive ? 1 : -1) * (axis === 'x' ? -1 : 1);
if (!clockwise) direction *= -1;
const angle = direction * Math.PI / 2;
```

Yes.

**Scramble:**
20 random moves. Generate random axis (0,1,2), random layer (-1,0,1), random direction (±1).
Execute with small delays between them.

**UI:**
- Scramble button: triggers scramble
- Reset button: rebuilds the cube to solved state

**Complete Implementation:**

I'll write the full HTML file now. I'll include a minimal but working OrbitControls implementation to avoid copyright issues with pasting large blocks of Three.js example code, and to keep it self-contained.

Actually, looking at the constraints, it's safer to include a working minimal orbit control rather than risk the full OrbitControls having dependencies or being too large.

Let's write the file.

Structure:
- CSS: full screen, control panel top-left
- HTML: container, controls div
- Script: 
  - Minimal OrbitControls class
  - RubiksCube class
  - Main initialization

Let me write the OrbitControls carefully:

```javascript
// Minimal OrbitControls
class OrbitControls {
  constructor(camera, domElement) {
    this.camera = camera;
    this.domElement = domElement;
    this.target = new THREE.Vector3();
    this.minDistance = 0;
    this.maxDistance = Infinity;
    this.enableDamping = false;
    this.dampingFactor = 0.05;
    
    this.spherical = new THREE.Spherical();
    this.sphericalDelta = new THREE.Spherical();
    this.scale = 1;
    this.panOffset = new THREE.Vector3();
    
    this.rotateStart = new THREE.Vector2();
    this.rotateEnd = new THREE.Vector2();
    this.rotateDelta = new THREE.Vector2();
    
    this.isDragging = false;
    this.state = 'none'; // 'rotate', 'pan'
    
    this.domElement.addEventListener('mousedown', this.onMouseDown.bind(this));
    this.domElement.addEventListener('mousemove', this.onMouseMove.bind(this));
    this.domElement.addEventListener('mouseup', this.onMouseUp.bind(this));
    this.domElement.addEventListener('wheel', this.onMouseWheel.bind(this));
    this.domElement.addEventListener('contextmenu', e => e.preventDefault());
    
    this.update();
  }
  
  onMouseDown(e) {
    if (e.button === 0) {
      this.state = 'rotate';
      this.rotateStart.set(e.clientX, e.clientY);
    } else if (e.button === 2) {
      this.state = 'pan';
    }
    this.isDragging = true;
  }
  
  onMouseMove(e) {
    if (!this.isDragging) return;
    
    if (this.state === 'rotate') {
      this.rotateEnd.set(e.clientX, e.clientY);
      this.rotateDelta.subVectors(this.rotateEnd, this.rotateStart).multiplyScalar(0.005);
      
      this.sphericalDelta.theta -= this.rotateDelta.x;
      this.sphericalDelta.phi -= this.rotateDelta.y;
      
      this.rotateStart.copy(this.rotateEnd);
      this.update();
    }
  }
  
  onMouseUp() {
    this.isDragging = false;
    this.state = 'none';
  }
  
  onMouseWheel(e) {
    if (e.deltaY < 0) {
      this.scale /= 0.95;
    } else {
      this.scale *= 0.95;
    }
    this.update();
  }
  
  update() {
    const position = this.camera.position.clone().sub(this.target);
    this.spherical.setFromVector3(position);
    
    this.spherical.theta += this.sphericalDelta.theta;
    this.spherical.phi += this.sphericalDelta.phi;
    this.spherical.radius *= this.scale;
    
    this.spherical.makeSafe();
    
    position.setFromSpherical(this.spherical).add(this.target);
    this.camera.position.copy(position);
    this.camera.lookAt(this.target);
    
    if (this.enableDamping) {
      this.sphericalDelta.theta *= (1 - this.dampingFactor);
      this.sphericalDelta.phi *= (1 - this.dampingFactor);
    } else {
      this.sphericalDelta.set(0, 0, 0);
    }
    
    this.scale = 1;
    return false;
  }
}
```

This should work for basic orbit controls.

Now the RubiksCube class:

Properties:
- scene: THREE.Scene
- camera: THREE.Camera  
- renderer: THREE.Renderer
- cubies: array of {mesh, x, y, z} where x,y,z are -1,0,1
- isAnimating: boolean to prevent multiple simultaneous rotations
- raycaster: THREE.Raycaster
- mouse: THREE.Vector2

Methods:
- init(): setup scene, lights, create cubies
- createCubie(x,y,z): create a single small cube with correct materials
- getLayerCubies(axis, layer): return array of cubies with matching x/y/z
- rotateLayer(axis, layer, angle, duration): animate rotation
- updatePositions(axis, layer, angle): update logical x,y,z after rotation
- onMouseClick(e): handle clicks
- scramble(): perform random moves
- reset(): clear and recreate

Animation loop using requestAnimationFrame.

**Pivot rotation details:**
When rotating, we need to:
1. Create a group
2. Attach cubies to group (using `group.attach(cubie)` which maintains world transform)
3. Animate group rotation
4. On complete, `scene.attach(cubie)` for each to put back in scene
5. Remove group

During animation, we set `isAnimating = true` to ignore clicks.

**Coordinate updates after rotation:**
After rotation around axis by angle (±90°), we need to update the (x,y,z) grid coordinates.

Helper function:
```javascript
updateCubieCoords(axis, layer, angle) {
  const cubies = this.getLayerCubies(axis, layer);
  const sign = Math.sign(angle); // 1 or -1
  
  cubies.forEach(cubie => {
    let {x, y, z} = cubie;
    if (axis === 'x') {
      // y' = y*cos - z*sin, z' = y*sin + z*cos
      // cos(±90) = 0, sin(±90) = ±1
      // y' = -z*sin, z' = y*sin
      // sin(90) = 1, sin(-90) = -1
      const newY = -z * sign;
      const newZ = y * sign;
      cubie.y = newY;
      cubie.z = newZ;
    } else if (axis === 'y') {
      // x' = x*cos + z*sin, z' = -x*sin + z*cos
      // x' = z*sin, z' = -x*sin
      const newX = z * sign;
      const newZ = -x * sign;
      cubie.x = newX;
      cubie.z = newZ;
    } else if (axis === 'z') {
      // x' = x*cos - y*sin, y' = x*sin + y*cos
      // x' = -y*sin, y' = x*sin
      const newX = -y * sign;
      const newY = x * sign;
      cubie.x = newX;
      cubie.y = newY;
    }
  });
}
```

Wait, check signs:
Around Y by +90° (sign=1):
- x' = z
- z' = -x
Check: (1,0,0) [right] -> (0,0,-1) [back]. Correct for +Y rotation (clockwise looking down).

Around Y by -90° (sign=-1):
- x' = -z
- z' = x
Check: (1,0,0) -> (0,0,1) [front]. Correct.

Around X by +90° (sign=1):
- y' = -z
- z' = y
Check: (0,1,0) [up] -> (0,0,1) [front]. But earlier we said +X rotation is -90° for clockwise on Right face. 
Actually, if sign=1 means +90°:
From earlier: Right face clockwise is -90°.
So if we pass angle -Math.PI/2, sign is -1.
Then y' = -z * (-1) = z
z' = y * (-1) = -y
So (0,1,0) -> (0,0,-1) [back].
Is that correct for Right face clockwise?
Right face clockwise (looking from right): top goes to back.
Yes, top (+Y) to back (-Z). So (0,1,0) -> (0,0,-1). Correct.

So the formula works with `sign = Math.round(angle / (Math.PI/2))` essentially. Or just use the angle to determine the permutation.

Actually, simpler: just use the rotation matrix logic with sin/cos of the angle.

```javascript
const cos = Math.cos(angle);
const sin = Math.sin(angle);
// For x rotation: y' = y*cos - z*sin, z' = y*sin + z*cos
```

Since angle is always ±90°, cos=0, sin=±1.

**Final checks:**
- Ensure cubies are created with gap (0.95 size)
- Ensure materials are correct
- Ensure raycaster works (updateMatrixWorld before raycasting)
- Ensure clicks are not processed during animation
- Ensure scramble doesn't start if animating

Let me write the code.

```html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>3D Rubik's Cube</title> <style> body { margin: 0; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a1a; } #canvas-container { width: 100vw; height: 100vh; } #ui { position: absolute; top: 20px; left: 20px; background: rgba(255, 255, 255, 0.95); padding: 20px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); user-select: none; } h1 { margin: 0 0 15px 0; font-size: 18px; color: #333; } button { background: #2196F3; color: white; border: none; padding: 10px 20px; margin: 5px; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600; transition: background 0.2s; } button:hover { background: #1976D2; } button:active { transform: scale(0.98); } .instructions { margin-top: 15px; font-size: 13px; line-height: 1.5; color: #555; max-width: 250px; } .status { margin-top: 10px; font-size: 12px; color: #888; font-style: italic; } </style> </head> <body> <div id="canvas-container"></div> <div id="ui"> <h1>Rubik's Cube</h1> <button id="scramble">Scramble</button> <button id="reset">Reset</button> <div class="instructions"> • Left click + drag: Rotate camera<br> • Click face: Rotate clockwise<br> • Shift + Click: Rotate counter-clockwise<br> • Right click + drag: Pan </div> <div class="status" id="status">Ready</div> </div> <script src="three.min.js"></script> <script> // Minimal OrbitControls implementation class OrbitControls { constructor(camera, domElement) { this.camera = camera; this.domElement = domElement; this.target = new THREE.Vector3(); this.enableDamping = true; this.dampingFactor = 0.05; this.minDistance = 5; this.maxDistance = 50; this.spherical = new THREE.Spherical(); this.sphericalDelta = new THREE.Spherical(); this.scale = 1; this.panOffset = new THREE.Vector3(); this.rotateStart = new THREE.Vector2(); this.rotateEnd = new THREE.Vector2(); this.rotateDelta = new THREE.Vector2(); this.isDragging = false; this.state = 'none'; this.domElement.addEventListener('mousedown', this.onMouseDown.bind(this)); this.domElement.addEventListener('mousemove', this.onMouseMove.bind(this)); this.domElement.addEventListener('mouseup', this.onMouseUp.bind(this)); this.domElement.addEventListener('wheel', this.onMouseWheel.bind(this)); this.domElement.addEventListener('contextmenu', e => e.preventDefault()); this.update(); } onMouseDown(e) { if (e.button === 0) { this.state = 'rotate'; this.rotateStart.set(e.clientX, e.clientY); } else if (e.button === 2) { this.state = 'pan'; this.panStart = new THREE.Vector2(e.clientX, e.clientY); } this.isDragging = true; } onMouseMove(e) { if (!this.isDragging) return; if (this.state === 'rotate') { this.rotateEnd.set(e.clientX, e.clientY); this.rotateDelta.subVectors(this.rotateEnd, this.rotateStart).multiplyScalar(0.005); this.sphericalDelta.theta -= this.rotateDelta.x; this.sphericalDelta.phi -= this.rotateDelta.y; this.rotateStart.copy(this.rotateEnd); } } onMouseUp() { this.isDragging = false; this.state = 'none'; } onMouseWheel(e) { if (e.deltaY < 0) { this.scale /= 0.9; } else { this.scale *= 0.9; } } update() { const offset = new THREE.Vector3(); offset.copy(this.camera.position).sub(this.target); this.spherical.setFromVector3(offset); this.spherical.theta += this.sphericalDelta.theta; this.spherical.phi += this.sphericalDelta.phi; this.spherical.radius = Math.max(this.minDistance, Math.min(this.maxDistance, this.spherical.radius * this.scale)); this.spherical.makeSafe(); offset.setFromSpherical(this.spherical); this.camera.position.copy(this.target).add(offset); this.camera.lookAt(this.target); if (this.enableDamping) { this.sphericalDelta.theta *= (1 - this.dampingFactor); this.sphericalDelta.phi *= (1 - this.dampingFactor); } else { this.sphericalDelta.set(0, 0, 0); } this.scale = 1; } } // Rubik's Cube Implementation class RubiksCube { constructor(container) { this.container = container; this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x222222); this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); this.camera.position.set(8, 8, 12); this.renderer = new THREE.WebGLRenderer({ antialias: true }); this.renderer.setSize(window.innerWidth, window.innerHeight); this.renderer.shadowMap.enabled = true; this.container.appendChild(this.renderer.domElement); this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.raycaster = new THREE.Raycaster(); this.mouse = new THREE.Vector2(); this.cubies = []; this.isAnimating = false; this.animationQueue = []; this.colors = { U: 0xffffff, // White (Up) D: 0xffff00, // Yellow (Down) F: 0x00ff00, // Green (Front) B: 0x0000ff, // Blue (Back) R: 0xff0000, // Red (Right) L: 0xffa500, // Orange (Left) core: 0x111111 // Black (inner faces) }; this.initLights(); this.createCube(); this.setupEvents(); this.animate(); } initLights() { const ambient = new THREE.AmbientLight(0xffffff, 0.6); this.scene.add(ambient); const dir = new THREE.DirectionalLight(0xffffff, 0.8); dir.position.set(10, 20, 10); dir.castShadow = true; this.scene.add(dir); const dir2 = new THREE.DirectionalLight(0xffffff, 0.4); dir2.position.set(-10, -10, -10); this.scene.add(dir2); } createCube() { // Clear existing this.cubies.forEach(c => this.scene.remove(c.mesh)); this.cubies = []; const geometry = new THREE.BoxGeometry(0.95, 0.95, 0.95); 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.MeshStandardMaterial({ color: x === 1 ? this.colors.R : this.colors.core, roughness: 0.2, metalness: 0.1 }), // Right (+X) new THREE.MeshStandardMaterial({ color: x === -1 ? this.colors.L : this.colors.core, roughness: 0.2, metalness: 0.1 }), // Left (-X) new THREE.MeshStandardMaterial({ color: y === 1 ? this.colors.U : this.colors.core, roughness: 0.2, metalness: 0.1 }), // Up (+Y) new THREE.MeshStandardMaterial({ color: y === -1 ? this.colors.D : this.colors.core, roughness: 0.2, metalness: 0.1 }), // Down (-Y) new THREE.MeshStandardMaterial({ color: z === 1 ? this.colors.F : this.colors.core, roughness: 0.2, metalness: 0.1 }), // Front (+Z) new THREE.MeshStandardMaterial({ color: z === -1 ? this.colors.B : this.colors.core, roughness: 0.2, metalness: 0.1 }) // Back (-Z) ]; const mesh = new THREE.Mesh(geometry, materials); mesh.position.set(x, y, z); mesh.castShadow = true; mesh.receiveShadow = true; this.scene.add(mesh); this.cubies.push({ mesh: mesh, x: x, y: y, z: z, initialX: x, initialY: y, initialZ: z }); } } } } getLayerCubies(axis, layer) { return this.cubies.filter(c => Math.abs(c[axis] - layer) < 0.1); } updateCubieCoordinates(axis, layer, angle) { const cubies = this.getLayerCubies(axis, layer); const sin = Math.round(Math.sin(angle)); // ±1 const cos = Math.round(Math.cos(angle)); // 0 cubies.forEach(cubie => { const x = cubie.x, y = cubie.y, z = cubie.z; if (axis === 'x') { cubie.y = y * cos - z * sin; cubie.z = y * sin + z * cos; } else if (axis === 'y') { cubie.x = x * cos + z * sin; cubie.z = -x * sin + z * cos; } else if (axis === 'z') { cubie.x = x * cos - y * sin; cubie.y = x * sin + y * cos; } }); } rotateLayer(axis, layer, angle, duration = 300) { if (this.isAnimating) return Promise.resolve(); this.isAnimating = true; const cubies = this.getLayerCubies(axis, layer); const pivot = new THREE.Group(); this.scene.add(pivot); // Attach cubies to pivot cubies.forEach(cubie => { pivot.attach(cubie.mesh); }); const startTime = Date.now(); const startRotation = pivot.rotation[axis]; return new Promise(resolve => { const animate = () => { const elapsed = Date.now() - startTime; const t = Math.min(elapsed / duration, 1); const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic pivot.rotation[axis] = startRotation + angle * eased; if (t < 1) { requestAnimationFrame(animate); } else { // Clean up pivot.rotation[axis] = startRotation + angle; cubies.forEach(cubie => { this.scene.attach(cubie.mesh); }); this.scene.remove(pivot); this.updateCubieCoordinates(axis, layer, angle); this.isAnimating = false; resolve(); } }; animate(); }); } async onMouseClick(e) { if (this.isAnimating) return; if (e.button !== 0) return; // Only left click // Calculate mouse position const rect = this.renderer.domElement.getBoundingClientRect(); this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; this.raycaster.setFromCamera(this.mouse, this.camera); const meshes = this.cubies.map(c => c.mesh); const intersects = this.raycaster.intersectObjects(meshes); if (intersects.length === 0) return; const intersect = intersects[0]; const mesh = intersect.object; const cubie = this.cubies.find(c => c.mesh === mesh); if (!cubie) return; // Get face normal in world space const normal = intersect.face.normal.clone().transformDirection(mesh.matrixWorld).round(); // Determine axis and layer let axis, layer, positive; if (Math.abs(normal.x) > 0.5) { axis = 'x'; layer = Math.round(cubie.x); positive = normal.x > 0; } else if (Math.abs(normal.y) > 0.5) { axis = 'y'; layer = Math.round(cubie.y); positive = normal.y > 0; } else { axis = 'z'; layer = Math.round(cubie.z); positive = normal.z > 0; } // Determine direction const clockwise = !e.shiftKey; let direction; if (axis === 'x') { direction = positive ? (clockwise ? -1 : 1) : (clockwise ? 1 : -1); } else { direction = positive ? (clockwise ? 1 : -1) : (clockwise ? -1 : 1); } const angle = direction * Math.PI / 2; await this.rotateLayer(axis, layer, angle); } async scramble() { if (this.isAnimating) return; const moves = 20; const axes = ['x', 'y', 'z']; const layers = [-1, 0, 1]; document.getElementById('status').textContent = 'Scrambling...'; for (let i = 0; i < moves; i++) { const axis = axes[Math.floor(Math.random() * axes.length)]; const layer = layers[Math.floor(Math.random() * layers.length)]; const angle = (Math.random() > 0.5 ? 1 : -1) * Math.PI / 2; await this.rotateLayer(axis, layer, angle, 100); } document.getElementById('status').textContent = 'Scrambled!'; setTimeout(() => { document.getElementById('status').textContent = 'Ready'; }, 1000); } setupEvents() { window.addEventListener('resize', () => { this.camera.aspect = window.innerWidth / window.innerHeight; this.camera.updateProjectionMatrix(); this.renderer.setSize(window.innerWidth, window.innerHeight); }); this.renderer.domElement.addEventListener('click', (e) => { // Only handle clicks not on controls if (e.target.closest('#ui')) return; this.onMouseClick(e); }); document.getElementById('scramble').addEventListener('click', () => { this.scramble(); }); document.getElementById('reset').addEventListener('click', () => { if (this.isAnimating) return; this.createCube(); document.getElementById('status').textContent = 'Reset'; setTimeout(() => { document.getElementById('status').textContent = 'Ready'; }, 1000); }); } animate() { requestAnimationFrame(() => this.animate()); this.controls.update(); this.renderer.render(this.scene, this.camera); } } // Initialize const container = document.getElementById('canvas-container'); new RubiksCube(container); </script> </body> </html> ```

TAKE IT TO CODE

This run produced no artifact — reuse the prompt with a different model or your own pi-agent run.

RUN

modelkimi-k2.5
vendorMoonshot · Kimi
harnesspi-agent
generated2026-07-22

METRICS

runtime
tokens in225
tokens out13k
cached1.0k
cost / run$0.0266