Skip to content

Instantly share code, notes, and snippets.

@qpwo
Last active June 10, 2025 18:57
Show Gist options
  • Save qpwo/6c4044aec35534631e8ccac91c5c30a5 to your computer and use it in GitHub Desktop.
Save qpwo/6c4044aec35534631e8ccac91c5c30a5 to your computer and use it in GitHub Desktop.
colliding cubes index html. and interlocked donuts
<!DOCTYPE html>
<html>
<head>
<title>Bouncing 3D Cubes</title>
<style>
body { margin: 0; overflow: hidden; background-color: #000; }
#content {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
color: #fff;
font-family: sans-serif;
max-width: 600px;
text-align: center;
padding: 20px;
background: rgba(0,0,0,0.5);
border-radius: 8px;
pointer-events: none; /* Make text non-interactive */
}
#refresh-indicator {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
font-size: 10vw;
color: white;
font-weight: bold;
font-family: sans-serif;
opacity: 1;
transition: opacity 1s ease-out;
pointer-events: none;
}
</style>
</head>
<body>
<div id="refresh-indicator">REFRESH</div>
<div id="content">
<h1>Lorem Ipsum</h1>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
let scene, camera, renderer, cubes = [];
const numCubes = 40;
function init() {
// Scene
scene = new THREE.Scene();
// Camera
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.z = 600;
// Renderer
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.insertBefore(renderer.domElement, document.getElementById('content'));
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.9);
directionalLight.position.set(1, 1, 1);
scene.add(directionalLight);
// Cubes
for (let i = 0; i < numCubes; i++) {
const size = Math.random() * 30 + 10;
const geometry = new THREE.BoxGeometry(size, size, size);
geometry.computeBoundingSphere(); // Important for collision detection
const material = new THREE.MeshLambertMaterial({ color: Math.random() * 0xffffff });
const cube = new THREE.Mesh(geometry, material);
cube.position.set(
(Math.random() - 0.5) * 600,
(Math.random() - 0.5) * 600,
(Math.random() - 0.5) * 600
);
cube.velocity = new THREE.Vector3(
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2
);
cube.rotationSpeed = new THREE.Vector3(
Math.random() * 0.02,
Math.random() * 0.02,
Math.random() * 0.02
);
// Assign spin direction
cube.spinDirection = (Math.random() < 0.25) ? -1 : 1;
cube.mass = size; // Mass proportional to size
cubes.push(cube);
scene.add(cube);
}
animate();
}
function animate() {
requestAnimationFrame(animate);
const gravityCenter = new THREE.Vector3(0, 0, 0);
const gravityForce = 0.4;
const terminalVelocity = 12;
for (let i = 0; i < cubes.length; i++) {
const cubeA = cubes[i];
// Physics
let direction = gravityCenter.clone().sub(cubeA.position);
let distance = direction.length();
direction.normalize().multiplyScalar(gravityForce);
cubeA.velocity.add(direction);
if (cubeA.velocity.length() > terminalVelocity) {
cubeA.velocity.clampLength(0, terminalVelocity);
}
let tangent = new THREE.Vector3(-cubeA.position.y, cubeA.position.x, 0).normalize();
cubeA.velocity.add(tangent.multiplyScalar(0.25 * cubeA.spinDirection));
// Collisions
for (let j = i + 1; j < cubes.length; j++) {
const cubeB = cubes[j];
const dist = cubeA.position.distanceTo(cubeB.position);
const rA = cubeA.geometry.boundingSphere.radius;
const rB = cubeB.geometry.boundingSphere.radius;
if (dist < rA + rB) {
// Simplified elastic collision response
const normal = cubeA.position.clone().sub(cubeB.position).normalize();
const vA = cubeA.velocity.clone();
const vB = cubeB.velocity.clone();
cubeA.velocity = vB;
cubeB.velocity = vA;
// Move them apart to prevent sticking
const overlap = (rA + rB) - dist;
cubeA.position.add(normal.clone().multiplyScalar(overlap / 2));
cubeB.position.add(normal.clone().negate().multiplyScalar(overlap / 2));
}
}
cubeA.position.add(cubeA.velocity);
cubeA.rotation.x += cubeA.rotationSpeed.x;
cubeA.rotation.y += cubeA.rotationSpeed.y;
cubeA.rotation.z += cubeA.rotationSpeed.z;
// Boundary bounce
const boundaryRadius = 500;
if (cubeA.position.length() > boundaryRadius) {
const boundaryNormal = cubeA.position.clone().normalize();
cubeA.velocity.reflect(boundaryNormal);
cubeA.position.clampLength(0, boundaryRadius);
}
}
renderer.render(scene, camera);
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}, false);
init();
// Flash REFRESH text
const refreshIndicator = document.getElementById('refresh-indicator');
setTimeout(() => {
refreshIndicator.style.opacity = '0';
}, 100);
setTimeout(() => {
refreshIndicator.remove();
}, 1100);
</script>
</body>
</html>
<!DOCTYPE html>
<!--
GOALS & CONSTRAINTS:
1. Simulation: Create a physics-based simulation of multiple interlocked donut pairs.
2. Initial Placement: Each pair must start perfectly interlocked.
- Geometric Definition: The central circle (major radius) of each donut in a pair must pass through the center of the other. The planes containing these circles must be perpendicular.
3. Physics Model:
- Each donut is a completely independent object after initialization.
- Collision detection must use a "pearl necklace" model (a ring of discrete spheres) for accuracy.
- Donuts are influenced by a central gravity-like force.
- A tangential force must be added to create orbital motion and prevent clumping at the center.
- Donuts collide with each other and bounce off a spherical world boundary.
FEEDBACK HISTORY & CORRECTIONS:
- Initial attempts used flawed math for interlocking and inaccurate collision boxes.
- A version that grouped pairs was rejected; donuts must be simulated separately.
- The "pearl necklace" collision model was introduced, but interlocking math was still incorrect, and a new "clumping" gravity issue appeared.
- The gravity/clumping issue was fixed by reintroducing tangential velocity.
- The placement logic was improved by using a temporary group but was still flawed, offsetting a donut by its radius, which is incorrect.
- This version implements the definitive method for initial placement:
a. Two donuts are created in a local group, SHARING THE SAME CENTER (0,0,0).
b. One donut is rotated to lie on one plane (e.g., XY), and the other is rotated to lie on a perpendicular plane (e.g., XZ). This creates the perfect interlock.
c. This entire local group is then given a random position and orientation in the world.
d. The final world coordinates are extracted for each donut, and they are then simulated independently.
-->
<html>
<head>
<title>Interlocked Donuts</title>
<style>
body { margin: 0; overflow: hidden; background-color: #000; }
#content {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
color: #fff;
font-family: sans-serif;
max-width: 600px;
text-align: center;
padding: 20px;
background: rgba(0,0,0,0.5);
border-radius: 8px;
pointer-events: none;
}
#refresh-indicator {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
font-size: 10vw;
color: white;
font-weight: bold;
font-family: sans-serif;
opacity: 1;
transition: opacity 1s ease-out;
pointer-events: none;
}
</style>
</head>
<body>
<div id="refresh-indicator">REFRESH</div>
<div id="content">
<h1>Interlocked Donuts</h1>
<p>A complex physics simulation where each donut is an independent object with a "pearl necklace" collision model.</p>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
let scene, camera, renderer, donuts = [];
const numPairs = 20;
const collisionSpheresPerDonut = 12;
function init() {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.z = 700;
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.insertBefore(renderer.domElement, document.getElementById('content'));
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
directionalLight.position.set(1, 1, 1);
scene.add(directionalLight);
for (let i = 0; i < numPairs; i++) {
const centerPos = new THREE.Vector3( (Math.random() - 0.5) * 1000, (Math.random() - 0.5) * 1000, (Math.random() - 0.5) * 1000 );
const initialOrientation = new THREE.Quaternion().setFromEuler(new THREE.Euler(Math.random() * Math.PI * 2, Math.random() * Math.PI * 2, Math.random() * Math.PI * 2));
const torusRadius = Math.random() * 20 + 15;
const tubeRadius = torusRadius * 0.4;
const initialVel = new THREE.Vector3( (Math.random() - 0.5) * 2, (Math.random() - 0.5) * 2, (Math.random() - 0.5) * 2 );
const initialAngVel = new THREE.Vector3().random().subScalar(0.5).multiplyScalar(0.02);
const spinDirection = (Math.random() < 0.5) ? -1 : 1;
const material1 = new THREE.MeshStandardMaterial({ color: Math.random() * 0xffffff, metalness: 0.4, roughness: 0.5 });
const material2 = new THREE.MeshStandardMaterial({ color: Math.random() * 0xffffff, metalness: 0.4, roughness: 0.5 });
const geometry = new THREE.TorusGeometry(torusRadius, tubeRadius, 16, 32);
const donut1 = new THREE.Mesh(geometry, material1);
const donut2 = new THREE.Mesh(geometry, material2);
// --- Correct Interlocking Placement via a Temporary Group ---
const group = new THREE.Group();
// 1. Place donuts at the group's origin and rotate them into perpendicular planes.
// Donut 1 is left on the default XY plane.
// Donut 2 is rotated 90 degrees around the X-axis to lie on the XZ plane.
donut2.rotation.x = Math.PI / 2;
group.add(donut1, donut2);
// 2. Apply random world position and orientation to the whole pair.
group.position.copy(centerPos);
group.quaternion.copy(initialOrientation);
group.updateMatrixWorld(true); // Crucial: updates children's world matrices.
// 3. Extract the final world transforms and add donuts to the scene as independent objects.
scene.add(donut1, donut2);
donut1.position.setFromMatrixPosition(donut1.matrixWorld);
donut1.quaternion.setFromRotationMatrix(donut1.matrixWorld);
donut2.position.setFromMatrixPosition(donut2.matrixWorld);
donut2.quaternion.setFromRotationMatrix(donut2.matrixWorld);
[donut1, donut2].forEach(donut => {
donut.velocity = initialVel.clone();
donut.angularVelocity = initialAngVel.clone();
donut.torusRadius = torusRadius;
donut.tubeRadius = tubeRadius;
donut.spinDirection = spinDirection;
donut.collisionSpheres = [];
for (let j = 0; j < collisionSpheresPerDonut; j++) {
const angle = (j / collisionSpheresPerDonut) * Math.PI * 2;
donut.collisionSpheres.push(new THREE.Vector3(Math.cos(angle) * torusRadius, Math.sin(angle) * torusRadius, 0));
}
donuts.push(donut);
});
}
animate();
}
function animate() {
requestAnimationFrame(animate);
const gravityCenter = new THREE.Vector3(0, 0, 0);
const gravityForce = 0.2;
const terminalVelocity = 15;
donuts.forEach(donut => {
let direction = gravityCenter.clone().sub(donut.position);
donut.velocity.add(direction.normalize().multiplyScalar(gravityForce));
let tangent = new THREE.Vector3(-donut.position.y, donut.position.x, 0).normalize();
donut.velocity.add(tangent.multiplyScalar(0.2 * donut.spinDirection));
if (donut.velocity.length() > terminalVelocity) donut.velocity.clampLength(0, terminalVelocity);
donut.position.add(donut.velocity);
const deltaRotation = new THREE.Quaternion().setFromEuler(new THREE.Euler(donut.angularVelocity.x, donut.angularVelocity.y, donut.angularVelocity.z));
donut.quaternion.multiplyQuaternions(deltaRotation, donut.quaternion);
});
for (let i = 0; i < donuts.length; i++) {
for (let j = i + 1; j < donuts.length; j++) {
const d1 = donuts[i];
const d2 = donuts[j];
if (d1.position.distanceTo(d2.position) > d1.torusRadius + d1.tubeRadius + d2.torusRadius + d2.tubeRadius) continue;
for (const s1_local of d1.collisionSpheres) {
const s1_world = s1_local.clone().applyQuaternion(d1.quaternion).add(d1.position);
for (const s2_local of d2.collisionSpheres) {
const s2_world = s2_local.clone().applyQuaternion(d2.quaternion).add(d2.position);
const dist = s1_world.distanceTo(s2_world);
if (dist < d1.tubeRadius + d2.tubeRadius) {
const v1 = d1.velocity.clone();
const v2 = d2.velocity.clone();
d1.velocity.lerp(v2, 0.5);
d2.velocity.lerp(v1, 0.5);
const normal = s1_world.clone().sub(s2_world).normalize();
const overlap = (d1.tubeRadius + d2.tubeRadius) - dist;
d1.position.add(normal.clone().multiplyScalar(overlap / 2));
d2.position.add(normal.clone().negate().multiplyScalar(overlap / 2));
goto_next_donut_pair_check:;
}
}
}
if (false) goto_next_donut_pair_check:;
}
}
donuts.forEach(donut => {
const boundaryRadius = 600;
if (donut.position.length() > boundaryRadius) {
donut.velocity.reflect(donut.position.clone().normalize());
donut.position.clampLength(0, boundaryRadius);
}
});
renderer.render(scene, camera);
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}, false);
init();
const refreshIndicator = document.getElementById('refresh-indicator');
setTimeout(() => { refreshIndicator.style.opacity = '0'; }, 100);
setTimeout(() => { refreshIndicator.remove(); }, 1100);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment