volodymyrsvirhun commited on
Commit
2bb57a5
·
verified ·
1 Parent(s): d7317c9
Files changed (4) hide show
  1. .gitattributes +1 -35
  2. README.md +16 -6
  3. classes.json +22 -0
  4. index.html +374 -17
.gitattributes CHANGED
@@ -1,35 +1 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.glb filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,10 +1,20 @@
1
  ---
2
- title: Procedural Lab
3
- emoji: 🐨
4
- colorFrom: yellow
5
- colorTo: red
6
  sdk: static
7
- pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: ProjectSim Procedural Lab
3
+ emoji: 📦
4
+ colorFrom: gray
5
+ colorTo: yellow
6
  sdk: static
7
+ pinned: true
8
+ license: mit
9
  ---
10
 
11
+ # ProjectSim Procedural Lab
12
+
13
+ In-browser 3D playground for the Kaedim class datasets — boxes, desks, cages, wardrobes: pick a class tab, pick an item, orbit it,
14
+ and articulate every joint — grab a flap/door/drawer/wheel and drag it, or
15
+ use the sliders (revolute and prismatic, joint chains like wheels on swivel
16
+ casters). Web-optimized GLBs (1K textures) in `assets/<class>/`; the full
17
+ joint metadata in `catalog.json`. three.js from CDN — no build step, no
18
+ backend.
19
+
20
+ Code mirror: [github.com/Kaedim/robotics-procedural-lab](https://github.com/Kaedim/robotics-procedural-lab) · Code & loaders: [github.com/Kaedim/robotics-sim-ready](https://github.com/Kaedim/robotics-sim-ready)
classes.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "class": "boxes",
4
+ "label": "Boxes",
5
+ "dataset": "articulated-boxes"
6
+ },
7
+ {
8
+ "class": "desks",
9
+ "label": "Desks",
10
+ "dataset": "articulated-desks"
11
+ },
12
+ {
13
+ "class": "cages",
14
+ "label": "Cages",
15
+ "dataset": "articulated-logistics-cages"
16
+ },
17
+ {
18
+ "class": "articulated-wardrobes",
19
+ "label": "Wardrobes",
20
+ "dataset": "articulated-wardrobes"
21
+ }
22
+ ]
index.html CHANGED
@@ -1,19 +1,376 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>ProjectSim Procedural Lab</title>
7
+ <style>
8
+ :root { color-scheme: light; }
9
+ * { box-sizing: border-box; }
10
+ body { margin: 0; font: 14px/1.5 -apple-system, "Segoe UI", sans-serif;
11
+ background: #ececef; color: #1c1c1e; height: 100vh; display: flex;
12
+ flex-direction: column; overflow: hidden; }
13
+ header { padding: 10px 20px; display: flex; align-items: center; gap: 16px;
14
+ background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.08); z-index: 2;
15
+ flex-wrap: wrap; }
16
+ header h1 { margin: 0; font-size: 17px; white-space: nowrap; }
17
+ #tabs { display: flex; gap: 6px; }
18
+ .tab { padding: 6px 14px; border: 1px solid #ddd; border-radius: 18px;
19
+ background: #fff; cursor: pointer; font-size: 13px; }
20
+ .tab.active { background: #1c1c1e; color: #fff; border-color: #1c1c1e; }
21
+ header .links { margin-left: auto; font-size: 12.5px; }
22
+ header .links a { margin-left: 14px; text-decoration: none; }
23
+ main { flex: 1; display: flex; min-height: 0; }
24
+ #itemlist { width: 172px; overflow-y: auto; padding: 10px; background: #f6f6f8; }
25
+ .itembtn { display: block; width: 100%; text-align: left; margin-bottom: 6px;
26
+ padding: 8px 10px; border: 1px solid #ddd; border-radius: 8px;
27
+ background: #fff; cursor: pointer; font: 12px ui-monospace, monospace; }
28
+ .itembtn small { display: block; color: #888; font: 11px -apple-system, sans-serif; }
29
+ .itembtn.active { border-color: #1c1c1e; background: #1c1c1e; color: #fff; }
30
+ .itembtn.active small { color: #bbb; }
31
+ #stage { flex: 1; position: relative; min-width: 0; }
32
+ #stage canvas { display: block; }
33
+ #hint { position: absolute; left: 12px; bottom: 10px; color: #999;
34
+ font-size: 11.5px; pointer-events: none; }
35
+ #panel { width: 274px; padding: 14px; background: #fff; overflow-y: auto;
36
+ box-shadow: -1px 0 3px rgba(0,0,0,.06); }
37
+ #panel h2 { margin: 0 0 4px; font-size: 14px; }
38
+ #panel .meta { color: #666; font-size: 12px; margin-bottom: 10px; }
39
+ .joint { margin-bottom: 10px; }
40
+ .joint label { display: flex; justify-content: space-between;
41
+ font-size: 12px; color: #444; gap: 6px; }
42
+ .joint label span:first-child { overflow: hidden; text-overflow: ellipsis;
43
+ white-space: nowrap; }
44
+ .joint input { width: 100%; accent-color: #1c1c1e; }
45
+ .row { display: flex; gap: 8px; margin: 12px 0; }
46
+ .row button { flex: 1; padding: 7px 0; border: 0; border-radius: 7px;
47
+ background: #1c1c1e; color: #fff; cursor: pointer; }
48
+ .row button.ghost { background: #e6e6ea; color: #1c1c1e; }
49
+ #panel pre { background: #16161a; color: #e8e8ea; padding: 9px;
50
+ border-radius: 6px; font-size: 10px; white-space: pre-wrap;
51
+ word-break: break-all; max-height: 130px; overflow-y: auto; }
52
+ #panel .copy { margin-top: 2px; padding: 5px 10px; border: 0; border-radius: 6px;
53
+ background: #1c1c1e; color: #fff; cursor: pointer; font-size: 12px; }
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <header>
58
+ <h1>ProjectSim Procedural Lab</h1>
59
+ <div id="tabs"></div>
60
+ <nav class="links">
61
+ <a id="dslink" href="https://huggingface.co/projectsim">Dataset</a>
62
+ <a href="https://docs.projectsim.ai">API docs</a>
63
+ <a href="https://www.projectsim.ai">www.projectsim.ai</a>
64
+ </nav>
65
+ </header>
66
+ <main>
67
+ <div id="itemlist"></div>
68
+ <div id="stage"><div id="hint">grab a part to articulate it · drag background to orbit · scroll zoom</div></div>
69
+ <aside id="panel">
70
+ <h2 id="title">loading…</h2>
71
+ <div class="meta" id="meta"></div>
72
+ <div class="row">
73
+ <button id="openall">Open all</button>
74
+ <button id="closeall" class="ghost">Close</button>
75
+ <button id="anim" class="ghost">▶ Loop</button>
76
+ </div>
77
+ <div id="joints"></div>
78
+ <h2 style="margin-top:14px">Take it home</h2>
79
+ <div class="meta">Same asset, full 4K, real physics — Blender one paste:</div>
80
+ <pre id="oneliner"></pre>
81
+ <button class="copy" id="copy">Copy one-liner</button>
82
+ </aside>
83
+ </main>
84
+ <script type="importmap">
85
+ { "imports": {
86
+ "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
87
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
88
+ } }
89
+ </script>
90
+ <script type="module">
91
+ import * as THREE from "three";
92
+ import { OrbitControls } from "three/addons/controls/OrbitControls.js";
93
+ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
94
+
95
+ // Data comes from the class DATASETS (viewer/ GLBs + catalog). ?base=URL
96
+ // overrides for local testing against a plain directory layout.
97
+ const LOCAL_BASE = new URLSearchParams(location.search).get("base");
98
+ const HFDS = "https://huggingface.co/datasets/projectsim";
99
+ const dsUrl = (ds, path) => LOCAL_BASE
100
+ ? `${LOCAL_BASE}/${ds}/${path}`
101
+ : `${HFDS}/${ds}/resolve/main/${path}`;
102
+ const stage = document.getElementById("stage");
103
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
104
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
105
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
106
+ stage.appendChild(renderer.domElement);
107
+ const scene = new THREE.Scene();
108
+ scene.background = new THREE.Color(0xececef);
109
+ const camera = new THREE.PerspectiveCamera(40, 1, 0.01, 100);
110
+ const controls = new OrbitControls(camera, renderer.domElement);
111
+ controls.enableDamping = true;
112
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x8a8a92, 1.1));
113
+ const sun = new THREE.DirectionalLight(0xffffff, 1.6);
114
+ sun.position.set(2, 4, 3);
115
+ scene.add(sun);
116
+ scene.add(new THREE.GridHelper(6, 30, 0xbbbbc2, 0xd8d8de));
117
+
118
+ function resize() {
119
+ const w = stage.clientWidth, h = stage.clientHeight;
120
+ renderer.setSize(w, h); camera.aspect = w / h;
121
+ camera.updateProjectionMatrix();
122
+ }
123
+ addEventListener("resize", resize);
124
+
125
+ const loader = new GLTFLoader();
126
+ let model = null;
127
+ let rig = [];
128
+ let grabMeshes = [];
129
+ let drag = null;
130
+ let animating = false, tAnim = 0;
131
+
132
+ function clearModel() {
133
+ if (model) { scene.remove(model); model = null; }
134
+ rig = []; grabMeshes = []; drag = null;
135
+ document.getElementById("joints").innerHTML = "";
136
+ }
137
+
138
+ function fmtVal(r, v) {
139
+ return r.unit === "deg" ? `${Math.round(v)}°` : `${(v * 100).toFixed(1)} cm`;
140
+ }
141
+
142
+ function setJoint(r, v) {
143
+ v = Math.min(r.hi, Math.max(r.lo, v));
144
+ r.val = v;
145
+ if (r.type === "revolute") {
146
+ r.group.quaternion.setFromAxisAngle(r.axis, THREE.MathUtils.degToRad(v));
147
+ } else {
148
+ r.group.position.copy(r.basePos).addScaledVector(r.axis, v);
149
+ }
150
+ r.slider.value = v;
151
+ r.out.textContent = fmtVal(r, v);
152
+ }
153
+
154
+ // ---- pointer: grab a part, swing (revolute) or pull (prismatic) --------------
155
+ const ray = new THREE.Raycaster();
156
+ const ptr = new THREE.Vector2();
157
+ function ptrRay(e) {
158
+ const rect = renderer.domElement.getBoundingClientRect();
159
+ ptr.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
160
+ ptr.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
161
+ ray.setFromCamera(ptr, camera);
162
+ }
163
+
164
+ renderer.domElement.addEventListener("pointerdown", e => {
165
+ if (!grabMeshes.length) return;
166
+ ptrRay(e);
167
+ const hits = ray.intersectObjects(grabMeshes, false);
168
+ if (!hits.length) return;
169
+ const r = hits[0].object.userData.rigEntry;
170
+ const pWorld = r.group.getWorldPosition(new THREE.Vector3());
171
+ const aWorld = r.axis.clone()
172
+ .transformDirection(r.group.parent.matrixWorld).normalize();
173
+ animating = false;
174
+ if (r.type === "revolute") {
175
+ const v0 = hits[0].point.clone().sub(pWorld).projectOnPlane(aWorld);
176
+ if (v0.lengthSq() < 1e-8) return;
177
+ drag = { kind: "rev", r, pWorld, aWorld, v0: v0.normalize(),
178
+ start: r.val || 0 };
179
+ } else {
180
+ const n = camera.getWorldDirection(new THREE.Vector3());
181
+ drag = { kind: "pris", r, aWorld, start: r.val || 0,
182
+ plane: new THREE.Plane().setFromNormalAndCoplanarPoint(
183
+ n, hits[0].point),
184
+ p0: hits[0].point.clone() };
185
+ }
186
+ controls.enabled = false;
187
+ renderer.domElement.setPointerCapture(e.pointerId);
188
+ renderer.domElement.style.cursor = "grabbing";
189
+ });
190
+
191
+ renderer.domElement.addEventListener("pointermove", e => {
192
+ if (!drag) {
193
+ if (!grabMeshes.length) return;
194
+ ptrRay(e);
195
+ renderer.domElement.style.cursor =
196
+ ray.intersectObjects(grabMeshes, false).length ? "grab" : "";
197
+ return;
198
+ }
199
+ ptrRay(e);
200
+ const hit = new THREE.Vector3();
201
+ if (drag.kind === "rev") {
202
+ const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(
203
+ drag.aWorld, drag.pWorld);
204
+ if (!ray.ray.intersectPlane(plane, hit)) return;
205
+ const v1 = hit.sub(drag.pWorld).projectOnPlane(drag.aWorld);
206
+ if (v1.lengthSq() < 1e-8) return;
207
+ v1.normalize();
208
+ const cross = new THREE.Vector3().crossVectors(drag.v0, v1);
209
+ const delta = Math.atan2(cross.dot(drag.aWorld), drag.v0.dot(v1));
210
+ setJoint(drag.r, drag.start + THREE.MathUtils.radToDeg(delta));
211
+ } else {
212
+ if (!ray.ray.intersectPlane(drag.plane, hit)) return;
213
+ const delta = hit.sub(drag.p0).dot(drag.aWorld);
214
+ setJoint(drag.r, drag.start + delta);
215
+ }
216
+ });
217
+
218
+ window.__lab = { camera, ray, ptrRay, scene,
219
+ get drag() { return drag; },
220
+ get rig() { return rig; },
221
+ get grabMeshes() { return grabMeshes; },
222
+ setJoint };
223
+
224
+ addEventListener("pointerup", () => {
225
+ if (drag) { drag = null; controls.enabled = true;
226
+ renderer.domElement.style.cursor = ""; }
227
+ });
228
+
229
+ // ---- load one item -----------------------------------------------------------
230
+ async function loadItem(cls, item) {
231
+ clearModel();
232
+ document.getElementById("title").textContent = item.id;
233
+ const d = item.dims_m;
234
+ document.getElementById("meta").textContent =
235
+ `${d[0].toFixed(2)} × ${d[1].toFixed(2)} × ${d[2].toFixed(2)} m · ` +
236
+ `${item.mass_kg.toFixed(1)} kg · ${item.tris} tris · ` +
237
+ `${item.joints.length} joints`;
238
+ document.getElementById("oneliner").textContent =
239
+ `blender --python-expr "import bpy,urllib.request,tempfile,os; ` +
240
+ `p=os.path.join(tempfile.gettempdir(),'kaedim_asset.blend'); ` +
241
+ `urllib.request.urlretrieve('${HFDS}/${cls.dataset}/resolve/main/` +
242
+ `${cls.items_dir}/${item.id}/${item.id}.blend',p); ` +
243
+ `bpy.ops.wm.open_mainfile(filepath=p)"`;
244
+ const gltf = await loader.loadAsync(dsUrl(cls.dataset, item.glb));
245
+ model = new THREE.Group();
246
+ model.rotation.x = -Math.PI / 2; // assets are Z-up
247
+ const inner = new THREE.Group();
248
+ inner.add(gltf.scene);
249
+ model.add(inner);
250
+ scene.add(model);
251
+ const jdiv = document.getElementById("joints");
252
+ const groupByChild = {}; // link name -> pivot group (for joint chains)
253
+ for (const j of item.joints) {
254
+ const nodes = [];
255
+ gltf.scene.traverse(n => {
256
+ if (j.meshes.some(m => n.name === m || n.name === m.replaceAll(".", "_")))
257
+ nodes.push(n);
258
+ });
259
+ const parent = groupByChild[j.parent_link] || inner;
260
+ const group = new THREE.Group();
261
+ group.position.set(...j.pivot);
262
+ inner.add(group);
263
+ if (parent !== inner) parent.attach(group); // ride the parent joint
264
+ for (const n of nodes) group.attach(n);
265
+ groupByChild[j.child_link] = group;
266
+ const el = document.createElement("div");
267
+ el.className = "joint";
268
+ const step = j.unit === "deg" ? 1 : 0.005;
269
+ el.innerHTML = `<label><span>${j.name}</span><span class="out"></span></label>
270
+ <input type="range" min="${j.limits[0]}" max="${j.limits[1]}"
271
+ value="${j.limits[0]}" step="${step}">`;
272
+ jdiv.appendChild(el);
273
+ const r = { group, type: j.type, unit: j.unit,
274
+ axis: new THREE.Vector3(...j.axis).normalize(),
275
+ basePos: group.position.clone(), val: j.limits[0],
276
+ lo: j.limits[0], hi: j.limits[1], phase: j.phase || 0,
277
+ slider: el.querySelector("input"),
278
+ out: el.querySelector(".out") };
279
+ r.out.textContent = fmtVal(r, r.val);
280
+ r.slider.oninput = () => { animating = false;
281
+ setJoint(r, parseFloat(r.slider.value)); };
282
+ for (const n of nodes) {
283
+ n.traverse(c => {
284
+ if (c.isMesh) { c.userData.rigEntry = r; grabMeshes.push(c); }
285
+ });
286
+ }
287
+ rig.push(r);
288
+ }
289
+ const bb = new THREE.Box3().setFromObject(model);
290
+ const c = bb.getCenter(new THREE.Vector3());
291
+ const size = bb.getSize(new THREE.Vector3()).length();
292
+ camera.position.set(c.x + size * 0.9, c.y + size * 0.6, c.z + size * 1.1);
293
+ controls.target.copy(c);
294
+ camera.near = size / 100; camera.far = size * 50;
295
+ camera.updateProjectionMatrix();
296
+ }
297
+
298
+ document.getElementById("openall").onclick = () => {
299
+ animating = false;
300
+ rig.forEach(r => setJoint(r, r.lo + (r.hi - r.lo) * 0.85));
301
+ };
302
+ document.getElementById("closeall").onclick = () => {
303
+ animating = false;
304
+ rig.forEach(r => setJoint(r, r.lo));
305
+ };
306
+ document.getElementById("anim").onclick = () => { animating = !animating; };
307
+ document.getElementById("copy").onclick = async () => {
308
+ try { await navigator.clipboard.writeText(
309
+ document.getElementById("oneliner").textContent); } catch (e) {}
310
+ };
311
+
312
+ const smooth = t => t * t * (3 - 2 * t);
313
+ function tick() {
314
+ requestAnimationFrame(tick);
315
+ if (animating && rig.length) {
316
+ tAnim += 0.008;
317
+ const cyc = (Math.sin(tAnim) + 1) / 2;
318
+ const twoPhase = rig.some(r => r.phase === 1);
319
+ for (const r of rig) {
320
+ let local = cyc;
321
+ if (twoPhase) local = r.phase === 0 ? Math.min(1, cyc * 2)
322
+ : Math.max(0, cyc * 2 - 1);
323
+ setJoint(r, r.lo + (r.hi - r.lo) * 0.85 * smooth(local));
324
+ }
325
+ }
326
+ controls.update();
327
+ renderer.render(scene, camera);
328
+ }
329
+
330
+ // ---- catalog / tabs / item list ----------------------------------------------
331
+ fetch("classes.json")
332
+ .then(r => r.json())
333
+ .then(classes => Promise.all(classes.map(c =>
334
+ fetch(dsUrl(c.dataset, "viewer/catalog.json"))
335
+ .then(r => r.json())
336
+ .then(v => ({ ...c, items_dir: v.items_dir, items: v.items })))))
337
+ .then(catalog => {
338
+ const tabs = document.getElementById("tabs");
339
+ const list = document.getElementById("itemlist");
340
+ function selectClass(cls) {
341
+ document.querySelectorAll(".tab").forEach(t =>
342
+ t.classList.toggle("active", t.dataset.cls === cls.class));
343
+ document.getElementById("dslink").href = `${HFDS}/${cls.dataset}`;
344
+ list.innerHTML = "";
345
+ cls.items.forEach((item, i) => {
346
+ const btn = document.createElement("button");
347
+ btn.className = "itembtn";
348
+ const d = item.dims_m;
349
+ btn.innerHTML = `${item.id.replace(/^[a-z_]+_(?=\d)/, "#")}
350
+ <small>${d[0].toFixed(2)}×${d[1].toFixed(2)}×${d[2].toFixed(2)} m ·
351
+ ${item.mass_kg.toFixed(1)} kg · ${item.joints.length} j</small>`;
352
+ btn.onclick = () => {
353
+ document.querySelectorAll(".itembtn").forEach(x =>
354
+ x.classList.remove("active"));
355
+ btn.classList.add("active");
356
+ loadItem(cls, item);
357
+ };
358
+ list.appendChild(btn);
359
+ if (i === 0) btn.click();
360
+ });
361
+ }
362
+ for (const cls of catalog) {
363
+ const t = document.createElement("button");
364
+ t.className = "tab";
365
+ t.dataset.cls = cls.class;
366
+ t.textContent = cls.label;
367
+ t.onclick = () => selectClass(cls);
368
+ tabs.appendChild(t);
369
+ }
370
+ selectClass(catalog[0]);
371
+ resize();
372
+ tick();
373
+ });
374
+ </script>
375
+ </body>
376
  </html>