Spaces:
Running
Running
File size: 19,173 Bytes
b45b023 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 | // Drops SimReady models on a floor and lets people pull their parts, using only the physics stored in each glb:
// KHR_physics_rigid_bodies motions (mass, center of mass, inertia) and colliders (convex hulls, friction,
// restitution), and the joints mirrored in node extras (userData.joints: type, parent, pivot, axis, min, max).
// ?models=a,b,c picks the models (the first one stands in the middle, the rest fall around it).
// ?base=<url> points at another copy of the dataset files, as in the viewer.
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
import RAPIER from 'rapier';
const DATASET_BASE = 'https://huggingface.co/datasets/codepawl/simready-glb/resolve/main/';
const parameters = new URLSearchParams(location.search);
const fileBase = parameters.get('base') ?? DATASET_BASE;
const DEFAULT_MODELS = [
'cabinet_file_a01',
'box_cardboard_a01',
'bucket_utility_red_a01',
'drum_plastic_blue_a01',
'box_cardboard_b01',
'chair_office_fabric_manager_a01',
];
const modelNames = (parameters.get('models') ?? DEFAULT_MODELS.join(',')).split(',').map((name) => name.trim()).filter(Boolean);
const EXTENSION = 'KHR_physics_rigid_bodies';
const HULL_POINT_LIMIT = 1500;
const FALLBACK_DENSITY = 400; // kg/m³ when a body has no mass
const STEP_SECONDS = 1 / 60;
const GRAB_STIFFNESS = 90;
const GRAB_DAMPING = 14;
const canvas = document.getElementById('world');
const statusElement = document.getElementById('status');
// ---------- scene ----------
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setClearColor(0x000000, 0);
const scene = new THREE.Scene();
scene.environment = new THREE.PMREMGenerator(renderer).fromScene(new RoomEnvironment(), 0.04).texture;
const keyLight = new THREE.DirectionalLight(0xfff2e0, 1.6);
keyLight.position.set(4, 8, 5);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(2048, 2048);
keyLight.shadow.camera.left = -6;
keyLight.shadow.camera.right = 6;
keyLight.shadow.camera.top = 6;
keyLight.shadow.camera.bottom = -6;
keyLight.shadow.bias = -0.0005;
scene.add(keyLight);
const floorMesh = new THREE.Mesh(new THREE.CircleGeometry(6, 64), new THREE.ShadowMaterial({ opacity: 0.18 }));
floorMesh.rotation.x = -Math.PI / 2;
floorMesh.receiveShadow = true;
scene.add(floorMesh);
const camera = new THREE.PerspectiveCamera(35, 1, 0.02, 200);
camera.position.set(3.6, 2.4, 4.6);
const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 0.8, 0);
controls.enableDamping = true;
controls.maxPolarAngle = Math.PI * 0.49;
function resize() {
const { clientWidth, clientHeight } = canvas;
if (clientWidth === 0 || clientHeight === 0) return;
renderer.setSize(clientWidth, clientHeight, false);
camera.aspect = clientWidth / clientHeight;
camera.updateProjectionMatrix();
}
new ResizeObserver(resize).observe(canvas);
// ---------- physics ----------
await RAPIER.init();
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
world.timestep = STEP_SECONDS;
world.createCollider(RAPIER.ColliderDesc.cuboid(30, 0.5, 30).setTranslation(0, -0.5, 0).setFriction(0.8));
const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
const models = [];
let bodies = [];
function setStatus(text) {
statusElement.className = 'stage-status loading';
statusElement.innerHTML = text ? `<span class="spinner"></span><span>${text}</span>` : '';
}
function physicsOf(object) {
return object.userData.gltfExtensions?.[EXTENSION];
}
/** Parts of the model that move on their own, in scene order; a model without any is one body. */
function bodyObjects(model) {
const found = [];
model.traverse((object) => {
if (physicsOf(object)?.motion) found.push(object);
});
return found.length > 0 ? found : [model];
}
/** Meshes that belong to a body: its subtree, without the subtrees of other bodies. */
function bodyMeshes(bodyObject, otherBodies) {
const meshes = [];
function walk(object) {
if (object !== bodyObject && otherBodies.has(object)) return;
if (object.isMesh) meshes.push(object);
for (const child of object.children) walk(child);
}
walk(bodyObject);
return meshes;
}
/** Up to HULL_POINT_LIMIT vertices of a mesh in world space, minus the body origin. */
function hullPoints(mesh, origin) {
const positions = mesh.geometry.getAttribute('position');
const step = Math.max(1, Math.floor(positions.count / HULL_POINT_LIMIT));
const points = [];
const vertex = new THREE.Vector3();
for (let index = 0; index < positions.count; index += step) {
vertex.fromBufferAttribute(positions, index).applyMatrix4(mesh.matrixWorld).sub(origin);
points.push(vertex.x, vertex.y, vertex.z);
}
return new Float32Array(points);
}
/** The collider's physics material from the glb's top-level extension, found through the collider's mesh node. */
function materialFor(mesh, physicsMaterials) {
for (let object = mesh; object; object = object.parent) {
const collider = physicsOf(object)?.collider;
if (collider) return physicsMaterials[collider.physicsMaterial] ?? {};
}
return {};
}
function toRapier(vector) {
return { x: vector.x, y: vector.y, z: vector.z };
}
/**
* Builds the Rapier bodies, colliders and joints of one placed model. Every body starts unrotated at the world
* position of its node, so pivots and axes given in model space at rest are the same in both bodies of a joint.
*/
function buildModel(model, gltfJson, collisionGroup) {
model.updateMatrixWorld(true);
const physicsMaterials = gltfJson.extensions?.[EXTENSION]?.physicsMaterials ?? [];
const objects = bodyObjects(model);
const objectSet = new Set(objects);
const records = new Map();
const groups = (collisionGroup << 16) | (0xffff & ~collisionGroup);
for (const object of objects) {
const origin = object.getWorldPosition(new THREE.Vector3());
const description = RAPIER.RigidBodyDesc.dynamic().setTranslation(origin.x, origin.y, origin.z).setCanSleep(true);
const motion = physicsOf(object)?.motion;
const worldRotation = object.getWorldQuaternion(new THREE.Quaternion());
if (motion?.mass > 0) {
const centerOfMass = new THREE.Vector3(...(motion.centerOfMass ?? [0, 0, 0])).applyQuaternion(worldRotation);
const inertia = motion.inertiaDiagonal ?? [motion.mass / 10, motion.mass / 10, motion.mass / 10];
const inertiaFrame = worldRotation.clone().multiply(new THREE.Quaternion(...(motion.inertiaOrientation ?? [0, 0, 0, 1])));
description.setAdditionalMassProperties(
motion.mass,
toRapier(centerOfMass),
{ x: inertia[0], y: inertia[1], z: inertia[2] },
{ x: inertiaFrame.x, y: inertiaFrame.y, z: inertiaFrame.z, w: inertiaFrame.w },
);
}
const body = world.createRigidBody(description);
for (const mesh of bodyMeshes(object, objectSet)) {
const colliderDescription = RAPIER.ColliderDesc.convexHull(hullPoints(mesh, origin));
if (!colliderDescription) continue;
const material = materialFor(mesh, physicsMaterials);
colliderDescription
.setDensity(motion?.mass > 0 ? 0 : FALLBACK_DENSITY)
.setFriction(material.dynamicFriction ?? 0.5)
.setRestitution(material.restitution ?? 0.1)
.setCollisionGroups(groups);
world.createCollider(colliderDescription, body);
mesh.castShadow = true;
}
records.set(object, { object, body, origin, rest: object.matrixWorld.clone(), depth: depthOf(object) });
}
for (const record of records.values()) {
for (const joint of record.object.userData.joints ?? []) addJoint(model, records, record, joint);
}
return [...records.values()];
}
function depthOf(object) {
let depth = 0;
for (let parent = object.parent; parent; parent = parent.parent) depth += 1;
return depth;
}
function addJoint(model, records, childRecord, joint) {
const parentObject = joint.parent ? model.getObjectByName(joint.parent) : null;
const parentRecord = parentObject ? records.get(parentObject) : null;
const pivot = new THREE.Vector3(...joint.pivot).applyMatrix4(model.matrixWorld);
const axis = new THREE.Vector3(...joint.axis).transformDirection(model.matrixWorld);
const parentBody = parentRecord?.body ?? world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
const parentOrigin = parentRecord?.origin ?? new THREE.Vector3();
const anchorParent = toRapier(pivot.clone().sub(parentOrigin));
const anchorChild = toRapier(pivot.clone().sub(childRecord.origin));
let data;
if (joint.type === 'revolute') data = RAPIER.JointData.revolute(anchorParent, anchorChild, toRapier(axis));
else if (joint.type === 'prismatic') data = RAPIER.JointData.prismatic(anchorParent, anchorChild, toRapier(axis));
else if (joint.type === 'spherical') data = RAPIER.JointData.spherical(anchorParent, anchorChild);
else data = RAPIER.JointData.fixed(anchorParent, { w: 1, x: 0, y: 0, z: 0 }, anchorChild, { w: 1, x: 0, y: 0, z: 0 });
if ((joint.type === 'revolute' || joint.type === 'prismatic') && Number.isFinite(joint.min) && Number.isFinite(joint.max)) {
const scale = joint.type === 'revolute' ? Math.PI / 180 : 1;
data.limitsEnabled = true;
data.limits = [joint.min * scale, joint.max * scale];
}
const created = world.createImpulseJoint(data, parentBody, childRecord.body, true);
created.setContactsEnabled(false);
if (joint.type === 'revolute' || joint.type === 'prismatic') {
// SimReady joints carry a light damping drive; a small velocity motor gives the same feel.
created.configureMotorVelocity(0, joint.type === 'revolute' ? 0.4 : 4);
}
}
/** Moves each part's node to its body's pose: body motion times the node's rest pose. */
function syncObjects() {
const motion = new THREE.Matrix4();
const back = new THREE.Matrix4();
const world = new THREE.Matrix4();
const inverseParent = new THREE.Matrix4();
for (const record of bodies) {
const position = record.body.translation();
const rotation = record.body.rotation();
motion.compose(new THREE.Vector3(position.x, position.y, position.z), new THREE.Quaternion(rotation.x, rotation.y, rotation.z, rotation.w), new THREE.Vector3(1, 1, 1));
back.makeTranslation(-record.origin.x, -record.origin.y, -record.origin.z);
world.copy(motion).multiply(back).multiply(record.rest);
record.object.parent.updateMatrixWorld(true);
inverseParent.copy(record.object.parent.matrixWorld).invert();
world.premultiply(inverseParent).decompose(record.object.position, record.object.quaternion, record.object.scale);
record.object.updateMatrixWorld(true);
}
}
// ---------- loading and placing ----------
async function loadModel(name) {
const gltf = await loader.loadAsync(`${fileBase}glb/${encodeURIComponent(name)}.glb`);
return { name, root: gltf.scene, json: gltf.parser.json };
}
/** Puts a model with its lowest point at height and its footprint centred on x, z. */
function place(root, x, height, z, turn) {
root.rotation.y = turn;
root.position.set(0, 0, 0);
root.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(root);
const center = box.getCenter(new THREE.Vector3());
root.position.set(x - center.x, height - box.min.y, z - center.z);
root.updateMatrixWorld(true);
}
function clearWorld() {
for (const record of bodies) world.removeRigidBody(record.body);
bodies = [];
for (const model of models) scene.remove(model.root);
models.length = 0;
}
/** The first model stands in the middle; the others fall onto the floor around it, one after another. */
const dropPlan = [];
async function dropAll() {
clearWorld();
setStatus('Loading');
const loaded = await Promise.all(modelNames.map((name) => loadModel(name).catch(() => null)));
dropPlan.length = 0;
loaded.filter(Boolean).forEach((model, index) => {
models.push(model);
if (index === 0) {
place(model.root, 0, 0.001, 0, 0);
scene.add(model.root);
bodies.push(...buildModel(model.root, model.json, 1 << (index % 15)));
return;
}
const angle = (index / Math.max(1, loaded.length - 1)) * Math.PI * 1.6 - Math.PI * 0.3;
const radius = 1.25 + (index % 2) * 0.35;
dropPlan.push({ model, index, x: Math.cos(angle) * radius, z: Math.sin(angle) * radius + 0.4, height: 1.6 + index * 0.25, turn: angle * 1.7, at: 0.35 + index * 0.45 });
});
bodies.sort((first, second) => first.depth - second.depth);
simulationSeconds = 0;
setStatus('');
window.__ready = true;
}
function releaseDue() {
while (dropPlan.length > 0 && dropPlan[0].at <= simulationSeconds) {
const drop = dropPlan.shift();
place(drop.model.root, drop.x, drop.height, drop.z, drop.turn);
scene.add(drop.model.root);
const created = buildModel(drop.model.root, drop.model.json, 1 << (drop.index % 15));
for (const record of created) record.body.setAngvel({ x: 0.6, y: 0.3, z: -0.4 }, true);
bodies.push(...created);
bodies.sort((first, second) => first.depth - second.depth);
}
}
// ---------- grabbing ----------
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const grabPlane = new THREE.Plane();
let grab = null;
const grabMarker = new THREE.Mesh(new THREE.SphereGeometry(0.025, 16, 12), new THREE.MeshBasicMaterial({ color: 0x0b7a5c }));
const grabLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x0b7a5c }));
grabMarker.visible = false;
grabLine.visible = false;
scene.add(grabMarker, grabLine);
function setPointer(event) {
const rectangle = canvas.getBoundingClientRect();
pointer.set(((event.clientX - rectangle.left) / rectangle.width) * 2 - 1, -((event.clientY - rectangle.top) / rectangle.height) * 2 + 1);
raycaster.setFromCamera(pointer, camera);
}
function recordOf(object) {
for (let current = object; current; current = current.parent) {
const record = bodies.find((candidate) => candidate.object === current);
if (record) return record;
}
return null;
}
function localPoint(body, worldPoint) {
const position = body.translation();
const rotation = body.rotation();
return worldPoint.clone().sub(new THREE.Vector3(position.x, position.y, position.z))
.applyQuaternion(new THREE.Quaternion(rotation.x, rotation.y, rotation.z, rotation.w).invert());
}
function worldPoint(body, local) {
const position = body.translation();
const rotation = body.rotation();
return local.clone().applyQuaternion(new THREE.Quaternion(rotation.x, rotation.y, rotation.z, rotation.w))
.add(new THREE.Vector3(position.x, position.y, position.z));
}
canvas.addEventListener('pointerdown', (event) => {
setPointer(event);
const hits = raycaster.intersectObjects(models.map((model) => model.root), true);
const hit = hits.find((candidate) => recordOf(candidate.object));
if (!hit) return;
const record = recordOf(hit.object);
grab = { record, local: localPoint(record.body, hit.point), target: hit.point.clone() };
grabPlane.setFromNormalAndCoplanarPoint(camera.getWorldDirection(new THREE.Vector3()).negate(), hit.point);
controls.enabled = false;
record.body.wakeUp();
canvas.setPointerCapture(event.pointerId);
}, { capture: true });
canvas.addEventListener('pointermove', (event) => {
if (!grab) return;
setPointer(event);
raycaster.ray.intersectPlane(grabPlane, grab.target);
});
function endGrab() {
grab = null;
controls.enabled = true;
grabMarker.visible = false;
grabLine.visible = false;
}
canvas.addEventListener('pointerup', endGrab);
canvas.addEventListener('pointercancel', endGrab);
/** A spring from the grabbed point to the cursor, pushed through Rapier as an impulse at that point. */
function pullGrabbed() {
if (!grab) return;
const body = grab.record.body;
const held = worldPoint(body, grab.local);
const velocity = body.linvel();
const mass = Math.max(body.mass(), 0.2);
const impulse = grab.target.clone().sub(held).multiplyScalar(GRAB_STIFFNESS)
.sub(new THREE.Vector3(velocity.x, velocity.y, velocity.z).multiplyScalar(GRAB_DAMPING))
.multiplyScalar(mass * STEP_SECONDS);
body.applyImpulseAtPoint(toRapier(impulse), toRapier(held), true);
grabMarker.position.copy(held);
grabLine.geometry.setFromPoints([held, grab.target]);
grabMarker.visible = true;
grabLine.visible = true;
}
// ---------- loop ----------
let simulationSeconds = 0;
let lastTime = performance.now();
let pending = 0;
renderer.setAnimationLoop(() => {
const now = performance.now();
pending = Math.min(pending + (now - lastTime) / 1000, 0.1);
lastTime = now;
while (pending >= STEP_SECONDS) {
releaseDue();
pullGrabbed();
world.step();
simulationSeconds += STEP_SECONDS;
pending -= STEP_SECONDS;
}
syncObjects();
controls.update();
renderer.render(scene, camera);
});
document.getElementById('drop-again').addEventListener('click', () => dropAll());
/**
* Screen point of a named part, for scripted recordings. along moves the point from the part's centre along its
* joint axis, in meters (a drawer's front face, a door's free edge).
*/
window.__screenPoint = (name, along = 0) => {
const object = models.map((model) => model.root.getObjectByName(name)).find(Boolean);
if (!object) return [0, 0];
const point = new THREE.Box3().setFromObject(object).getCenter(new THREE.Vector3());
const joint = object.userData.joints?.find((item) => item.type !== 'fixed');
if (joint) point.addScaledVector(new THREE.Vector3(...joint.axis), along);
const projected = point.project(camera);
const rectangle = canvas.getBoundingClientRect();
return [rectangle.left + ((projected.x + 1) / 2) * rectangle.width, rectangle.top + ((1 - projected.y) / 2) * rectangle.height];
};
/** Screen point of a world point, and the world centre of a named part, for scripted recordings. */
window.__project = (x, y, z) => {
const projected = new THREE.Vector3(x, y, z).project(camera);
const rectangle = canvas.getBoundingClientRect();
return [rectangle.left + ((projected.x + 1) / 2) * rectangle.width, rectangle.top + ((1 - projected.y) / 2) * rectangle.height];
};
window.__worldCenter = (name) => {
const object = models.map((model) => model.root.getObjectByName(name)).find(Boolean);
return object ? new THREE.Box3().setFromObject(object).getCenter(new THREE.Vector3()).toArray() : null;
};
window.__modelCenter = (name) => {
const model = models.find((item) => item.name === name);
return model ? new THREE.Box3().setFromObject(model.root).getCenter(new THREE.Vector3()).toArray() : null;
};
window.__settled = () => dropPlan.length === 0 && bodies.every((record) => record.body.isSleeping() || record.body.linvel().y > -0.05);
await dropAll();
|