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();