lab / index.html
volodymyrsvirhun's picture
Lab: truncate long variant names in the list, keeping the hash (#4)
57e4d51
Raw History Blame Contribute Delete
31.4 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>projectsim Lab</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
color-scheme: light;
--canvas: #fbf9f4; --surface: #fcfbf8; --page: #f5f0eb;
--subtle: #efe9de; --border: #eae4dc;
--ink: #2c1a1d; --body: #5c5048; --ink-32: #2c1a1d52;
--accent: #d0503d; --accent-strong: #7a2d26; --accent-soft: #f2cbaa;
--success: #3f8f6b;
--sans: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
--code: "Fira Code", ui-monospace, monospace;
}
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.5 var(--sans);
background: var(--canvas); color: var(--body); height: 100vh;
display: flex; flex-direction: column; overflow: hidden;
-webkit-font-smoothing: antialiased; }
header { padding: 10px 20px; display: flex; align-items: center; gap: 16px;
background: var(--surface); border-bottom: 1px solid var(--border);
z-index: 2; flex-wrap: wrap; }
header h1 { margin: 0; font-size: 17px; font-weight: 700;
color: var(--ink); white-space: nowrap; }
#tabs { display: flex; gap: 6px; }
.tab { padding: 6px 16px; border: 1px solid var(--border);
border-radius: 999px; background: var(--surface); cursor: pointer;
font: 500 13px var(--sans); color: var(--ink); }
.tab { transition: background .15s, border-color .15s; }
.tab:hover { border-color: var(--accent); }
.tab.active { background: var(--accent-strong); color: #fff;
border-color: var(--accent-strong); }
.tab.active:hover { background: var(--accent);
border-color: var(--accent); }
header .links { margin-left: auto; display: flex; gap: 8px; }
header .links a { padding: 6px 16px; border: 0; border-radius: 999px;
background: var(--accent-strong); color: #fff;
font: 600 13px var(--sans); text-decoration: none;
transition: background .15s; }
header .links a:hover { background: var(--accent); }
header .links a:active { transform: scale(.95); }
main { flex: 1; display: flex; min-height: 0; }
#itemlist { width: 212px; overflow-y: auto; padding: 10px;
background: var(--page); }
.itembtn { display: block; width: 100%; text-align: left; margin-bottom: 6px;
padding: 8px 10px; border: 1px solid var(--border);
border-radius: 12px; background: var(--surface); cursor: pointer;
font: 500 12px var(--code); color: var(--ink); }
.itembtn small { display: block; color: var(--body);
font: 11px var(--sans); }
.itembtn { transition: background .15s, border-color .15s; }
.itembtn:hover { border-color: var(--accent); }
.itembtn.active { border-color: var(--accent-strong);
background: var(--accent-strong); color: #fff; }
.itembtn.active:hover { background: var(--accent);
border-color: var(--accent); }
.itembtn.active small { color: var(--accent-soft); }
/* Long ids truncate in the middle: the trailing hash is what tells variants apart */
.itemname { display: flex; min-width: 0; white-space: nowrap; }
.itemname span:first-child { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.itemname span + span { flex-shrink: 0; }
#stage { flex: 1; position: relative; min-width: 0; }
#stage canvas { display: block; }
#hint { position: absolute; left: 12px; bottom: 10px; color: var(--ink-32);
font-size: 11.5px; pointer-events: none; }
#panel { width: 278px; padding: 14px; background: var(--surface);
border-left: 1px solid var(--border); overflow-y: auto; }
#panel h2 { margin: 0 0 4px; font-size: 14px; font-weight: 700;
color: var(--ink); }
#panel .meta { color: var(--body); font-size: 12px; margin-bottom: 10px; }
.joint { margin-bottom: 10px; }
.joint label { display: flex; justify-content: space-between;
font-size: 12px; color: var(--body); gap: 6px; }
.joint label span:first-child { overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; font-family: var(--code); }
.joint input { width: 100%; accent-color: var(--accent); }
.row { display: flex; gap: 8px; margin: 12px 0; }
.row button { flex: 1; padding: 7px 0; border: 0; border-radius: 999px;
background: var(--accent-strong); color: #fff; cursor: pointer;
font: 600 13px var(--sans); transition: background .15s; }
.row button:hover { background: var(--accent); }
.row button:active { transform: scale(.95); }
.row button.ghost { background: var(--subtle); color: var(--ink); }
.row button.ghost:hover { background: var(--page); }
#panel pre { background: var(--ink); color: var(--accent-soft);
padding: 10px; border-radius: 12px; font: 10px/1.5 var(--code);
white-space: pre-wrap; word-break: break-all;
max-height: 150px; overflow-y: auto; }
#panel .copy { margin-top: 2px; padding: 6px 14px; border: 0;
border-radius: 999px; background: var(--accent-strong);
color: #fff; cursor: pointer; font: 600 12px var(--sans);
transition: background .15s; }
#panel .copy:hover { background: var(--accent); }
#panel .copy:active { transform: scale(.95); }
#panel .copy.ok { background: var(--success); }
#panel .hdr { display: flex; align-items: baseline; gap: 8px; margin-top: 14px; }
#panel .hdr h2 { margin: 0; }
#panel .fmt { margin-left: auto; font: 500 10px var(--code); color: var(--body); }
#panel .codewrap { position: relative; }
#panel .codewrap pre { margin: 0; padding-right: 36px; }
#panel .copybtn { position: absolute; top: 7px; right: 7px; width: 26px;
height: 26px; padding: 0; border: 0; border-radius: 8px;
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; background: rgba(255,255,255,.10);
color: var(--accent-soft); transition: background .15s, color .15s; }
#panel .copybtn:hover { background: rgba(255,255,255,.22); color: #fff; }
#panel .copybtn:active { transform: scale(.92); }
#panel .copybtn .i-ok { display: none; }
#panel .copybtn.ok { color: var(--success); background: rgba(255,255,255,.16); }
#panel .copybtn.ok .i-copy { display: none; }
#panel .copybtn.ok .i-ok { display: inline; }
[hidden] { display: none !important; }
#apibanner { display: flex; align-items: center; justify-content: center;
gap: 10px; padding: 7px 16px; flex-wrap: wrap;
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
color: #fff; font: 500 13px var(--sans); text-align: center; }
#apibanner b { font-weight: 700; }
#apibanner a { padding: 3px 12px; border-radius: 999px; background: #fff;
color: var(--accent-strong); font: 700 12px var(--sans);
text-decoration: none; white-space: nowrap;
transition: background .15s; }
#apibanner a:hover { background: var(--accent-soft); }
#panel .apinote { margin: 6px 0 8px; font: 500 10.5px var(--sans); color: var(--body); }
#panel .apilinks a { display: block; margin: 6px 0; padding: 7px 12px;
border-radius: 999px; background: var(--accent-strong);
color: #fff; text-decoration: none; text-align: center;
font: 600 12px var(--sans); transition: background .15s; }
#panel .apilinks a:hover { background: var(--accent); }
#panel .apilinks a.ghost { background: var(--subtle); color: var(--ink); }
#panel .apilinks a.ghost:hover { background: var(--page); }
</style>
<style id="scene-loader-style">
#scene-loader { position: absolute; inset: 0; display: none;
align-items: center; justify-content: center; pointer-events: none;
font: 14px system-ui, sans-serif; color: #555; z-index: 30; }
#scene-loader.active { display: flex; }
#scene-loader .spin { width: 22px; height: 22px; margin-right: 10px;
border: 3px solid #ccc; border-top-color: #7a1a1a; border-radius: 50%;
animation: sl-spin 0.9s linear infinite; }
@keyframes sl-spin { to { transform: rotate(360deg); } }
</style>
<style id="scene-links-style">
body.class-static #panel .row,
body.class-static #panel #joints,
body.class-scene #panel #apiblock { display: none !important; }
body:not(.class-scene) #scene-links { display: none; }
#scene-links { padding: 4px 0; }
#scene-links h2 { margin: 12px 0 6px; font-size: 15px; }
#scene-links a { display: block; padding: 8px 12px; margin: 4px 0;
background: #f4f2ee; border-radius: 8px; color: #7a1a1a;
text-decoration: none; font: 500 13px system-ui, sans-serif; }
#scene-links a:hover { background: #ece8de; }
#scene-links .note { color: #666; font: 12px system-ui; margin-top: 12px; }
</style>
</head>
<body>
<div id="scene-loader"><div class="spin"></div><span id="scene-loader-text">loading…</span></div>
<div id="apibanner">
<span>Every asset in this lab was generated by the <b>projectsim API</b> — make a set of your own.</span>
<a href="https://docs.projectsim.ai/getting-started/quickstart?utm_source=hf_space&utm_medium=banner&utm_content=lab"
target="_blank" rel="noopener">Generate your own →</a>
</div>
<header>
<h1>projectsim Lab</h1>
<div id="tabs"></div>
<nav class="links">
<a id="dslink" href="https://huggingface.co/projectsim"
target="_blank" rel="noopener">Dataset</a>
<a href="https://docs.projectsim.ai/?utm_source=hf_space&utm_medium=header&utm_content=lab"
target="_blank" rel="noopener">API docs</a>
</nav>
</header>
<main>
<div id="itemlist"></div>
<div id="stage"><div id="hint">grab a part to articulate it · drag background to orbit · scroll zoom</div></div>
<aside id="panel">
<h2 id="title">loading…</h2>
<div class="meta" id="meta"></div>
<div class="row">
<button id="openall">Open all</button>
<button id="closeall" class="ghost">Close</button>
<button id="anim" class="ghost">▶ Loop</button>
</div>
<div id="joints"></div>
<div id="apiblock">
<div class="hdr"><h2>Take it home</h2><span class="fmt">projectsim API</span></div>
<div class="meta">Generate a set like this one with the projectsim API:</div>
<div class="codewrap">
<pre id="output"></pre>
<button class="copybtn" id="copy" title="Copy" aria-label="Copy">
<svg class="i-copy" viewBox="0 0 24 24" width="14" height="14" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path
d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"/></svg>
<svg class="i-ok" viewBox="0 0 24 24" width="15" height="15" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</button>
</div>
<div class="apinote">USD output · 10–500 variants per call · 1 credit per variant</div>
<div class="apilinks">
<a id="api-token" href="https://dashboard.projectsim.ai/?utm_source=hf_space&utm_medium=take_it_home&utm_content=lab" target="_blank" rel="noopener">Get an API token →</a>
<a id="api-docs" href="https://docs.projectsim.ai/creating-assets/procedural-variation-set?utm_source=hf_space&utm_medium=take_it_home&utm_content=lab" target="_blank" rel="noopener">Read the API docs →</a>
<a id="api-files" class="ghost" href="https://huggingface.co/projectsim" target="_blank" rel="noopener">Or download this asset from the dataset →</a>
</div>
</div>
<div id="scene-links">
<h2>Take it home</h2>
<a id="scene-usd-link" href="#" target="_blank" rel="noopener">Full USD (with Gaussian splat) on Hugging Face →</a>
<a id="scene-glb-link" href="#" target="_blank" rel="noopener">This scene as GLB →</a>
<a id="scene-dataset-link" href="https://huggingface.co/datasets/projectsim/scenes-and-worlds" target="_blank" rel="noopener">Browse the scenes-and-worlds dataset →</a>
<a id="scene-request-link" href="https://www.projectsim.ai/access?dataset=scenes-and-worlds&utm_source=hf_space&utm_medium=take_it_home&utm_content=scenes" target="_blank" rel="noopener">Need scenes at scale? Request a larger dataset →</a>
<div class="note" id="scene-note">USD ships the full room including the Gaussian splat backdrop; the GLB is a lightweight preview (splat dropped, textures baked).</div>
</div>
</aside>
</main>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
} }
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";
// Data comes from the class DATASETS (viewer/ GLBs + catalog). ?base=URL
// overrides for local testing against a plain directory layout.
const LOCAL_BASE = new URLSearchParams(location.search).get("base");
const HFDS = "https://huggingface.co/datasets/projectsim";
// Gated datasets: viewer GLBs come from the public preview mirror; downloads stay gated.
const PREVIEW = "https://huggingface.co/datasets/projectsim/lab-preview/resolve/main";
const GATED = new Set(["articulated-storage-and-appliances", "bottles-and-containers", "industrial-parts-and-packaging", "scenes-and-worlds", "warehouse-manipulation-and-states"]);
const dsUrl = (ds, path) => LOCAL_BASE
? `${LOCAL_BASE}/${ds}/${path}`
: GATED.has(ds) ? `${PREVIEW}/${ds}/${path}`
: `${HFDS}/${ds}/resolve/main/${path}`;
const stage = document.getElementById("stage");
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
stage.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf5f0eb);
const camera = new THREE.PerspectiveCamera(40, 1, 0.01, 100);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xfff8ef, 0x8a8078, 1.1));
const sun = new THREE.DirectionalLight(0xffffff, 1.6);
sun.position.set(2, 4, 3);
scene.add(sun);
scene.add(new THREE.GridHelper(6, 30, 0xd9d0c3, 0xe8e1d6));
function resize() {
const w = stage.clientWidth, h = stage.clientHeight;
renderer.setSize(w, h); camera.aspect = w / h;
camera.updateProjectionMatrix();
}
addEventListener("resize", resize);
const loader = new GLTFLoader();
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath("https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/libs/draco/");
loader.setDRACOLoader(dracoLoader);
let model = null;
let rig = [];
let grabMeshes = [];
let drag = null;
let animating = false, tAnim = 0;
let loadGen = 0; // discards async loads that finish after a newer selection
function clearModel() {
if (model) { scene.remove(model); model = null; }
rig = []; grabMeshes = []; drag = null;
document.getElementById("joints").innerHTML = "";
}
function fmtVal(r, v) {
return r.unit === "deg" ? `${Math.round(v)}°` : `${(v * 100).toFixed(1)} cm`;
}
function setJoint(r, v) {
v = Math.min(r.hi, Math.max(r.lo, v));
r.val = v;
if (r.type === "revolute") {
r.group.quaternion.setFromAxisAngle(r.axis, THREE.MathUtils.degToRad(v));
} else {
r.group.position.copy(r.basePos).addScaledVector(r.axis, v);
}
r.slider.value = v;
r.out.textContent = fmtVal(r, v);
}
// ---- pointer: grab a part, swing (revolute) or pull (prismatic) --------------
const ray = new THREE.Raycaster();
const ptr = new THREE.Vector2();
function ptrRay(e) {
const rect = renderer.domElement.getBoundingClientRect();
ptr.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
ptr.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
ray.setFromCamera(ptr, camera);
}
renderer.domElement.addEventListener("pointerdown", e => {
if (!grabMeshes.length) return;
ptrRay(e);
const hits = ray.intersectObjects(grabMeshes, false);
if (!hits.length) return;
const r = hits[0].object.userData.rigEntry;
const pWorld = r.group.getWorldPosition(new THREE.Vector3());
const aWorld = r.axis.clone()
.transformDirection(r.group.parent.matrixWorld).normalize();
animating = false;
if (r.type === "revolute") {
const v0 = hits[0].point.clone().sub(pWorld).projectOnPlane(aWorld);
if (v0.lengthSq() < 1e-8) return;
drag = { kind: "rev", r, pWorld, aWorld, v0: v0.normalize(),
start: r.val || 0 };
} else {
const n = camera.getWorldDirection(new THREE.Vector3());
drag = { kind: "pris", r, aWorld, start: r.val || 0,
plane: new THREE.Plane().setFromNormalAndCoplanarPoint(
n, hits[0].point),
p0: hits[0].point.clone() };
}
controls.enabled = false;
renderer.domElement.setPointerCapture(e.pointerId);
renderer.domElement.style.cursor = "grabbing";
});
renderer.domElement.addEventListener("pointermove", e => {
if (!drag) {
if (!grabMeshes.length) return;
ptrRay(e);
renderer.domElement.style.cursor =
ray.intersectObjects(grabMeshes, false).length ? "grab" : "";
return;
}
ptrRay(e);
const hit = new THREE.Vector3();
if (drag.kind === "rev") {
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(
drag.aWorld, drag.pWorld);
if (!ray.ray.intersectPlane(plane, hit)) return;
const v1 = hit.sub(drag.pWorld).projectOnPlane(drag.aWorld);
if (v1.lengthSq() < 1e-8) return;
v1.normalize();
const cross = new THREE.Vector3().crossVectors(drag.v0, v1);
const delta = Math.atan2(cross.dot(drag.aWorld), drag.v0.dot(v1));
setJoint(drag.r, drag.start + THREE.MathUtils.radToDeg(delta));
} else {
if (!ray.ray.intersectPlane(drag.plane, hit)) return;
const delta = hit.sub(drag.p0).dot(drag.aWorld);
setJoint(drag.r, drag.start + delta);
}
});
window.__lab = { camera, ray, ptrRay, scene,
get drag() { return drag; },
get rig() { return rig; },
get grabMeshes() { return grabMeshes; },
setJoint };
addEventListener("pointerup", () => {
if (drag) { drag = null; controls.enabled = true;
renderer.domElement.style.cursor = ""; }
});
// ---- Take-it-home: the projectsim API call that generates a set like this ----
// Every tab's items came out of the procedural pipeline, so "take it home" is the
// POST /v1/variation-sets/procedural call that makes more of them. Seeds live in
// classes.json (api.seed, or api.seeds keyed by an item-id substring when one tab
// mixes object types). Scenes have no endpoint yet and keep their file links.
const API = "https://api.projectsim.ai";
const utm = (medium, content) =>
`utm_source=hf_space&utm_medium=${medium}&utm_content=${encodeURIComponent(content)}`;
let curCls = null, curItem = null;
function seedFor(cls, item) {
const a = cls.api;
if (!a) return null;
if (a.seed) return a.seed;
const k = Object.keys(a.seeds || {}).find(k => item.id.includes(k));
return k ? a.seeds[k] : null;
}
function apiFor(cls, item) {
const seed = seedFor(cls, item);
if (!seed) return null;
// Same shape as robotics-server's CreateProceduralVariationSetRequest: only
// what changes the result. `params.physics.articulated` defaults to true
// (body rigid, simulator isaac_sim_physx, collision auto), so it's sent
// only for jointless props.
const body = { name: cls.class.replace(/_/g, " "), count: 10, seed: { text: seed } };
if (!cls.api.articulated) body.params = { physics: { articulated: false } };
// the body sits in a single-quoted shell string: escape any ' in it
const json = JSON.stringify(body, null, 2).replace(/'/g, `'\\''`);
return `# export PROJECTSIM_TOKEN=kps_live_... (from dashboard.projectsim.ai)\n\n` +
`# 1. order 10 variants like this one (10 credits)\n` +
`curl -X POST "${API}/v1/variation-sets/procedural" \\\n` +
` -H "Authorization: Bearer $PROJECTSIM_TOKEN" \\\n` +
` -H "Content-Type: application/json" \\\n` +
` -H "Idempotency-Key: $(uuidgen)" \\\n` +
` -d '${json}'\n\n` +
`# 2. poll the job_id from the response until "complete"\n` +
`curl -H "Authorization: Bearer $PROJECTSIM_TOKEN" \\\n` +
` "${API}/v1/jobs/$JOB_ID"\n\n` +
`# 3. list the set (its id is in step 1's response); each\n` +
`# files[].url is a presigned USD download\n` +
`curl -H "Authorization: Bearer $PROJECTSIM_TOKEN" \\\n` +
` "${API}/v1/variation-sets/$SET_ID/assets?limit=100"`;
}
function renderCmd() {
if (!curItem || !curCls) return;
document.getElementById("output").textContent = apiFor(curCls, curItem) || "";
const c = curCls.class;
document.getElementById("api-token").href =
`https://dashboard.projectsim.ai/?${utm("take_it_home", c)}`;
document.getElementById("api-docs").href =
`https://docs.projectsim.ai/creating-assets/procedural-variation-set?${utm("take_it_home", c)}`;
const dir = curItem.glb ? curItem.glb.replace(/\/[^/]*$/, "")
: (curCls.items_dir || `assets/${c}`);
document.getElementById("api-files").href = `${HFDS}/${curCls.dataset}/tree/main/${dir}`;
}
// ---- load one item -----------------------------------------------------------
async function loadItem(cls, item) {
clearModel();
const gen = ++loadGen;
document.getElementById("title").textContent = item.id;
const d = item.dims_m;
document.getElementById("meta").textContent =
`${d[0].toFixed(2)} × ${d[1].toFixed(2)} × ${d[2].toFixed(2)} m · ` +
`${item.mass_kg.toFixed(1)} kg · ${item.tris} tris · ` +
`${item.joints.length} joints`;
curCls = cls; curItem = item;
if (cls.static) {
// Per-item folder = the GLB's directory (lets one tab span several classes).
const base = `${HFDS}/${cls.dataset}/tree/main/${item.glb.replace(/\/[^/]*$/, "")}`;
const glbUrl = dsUrl(cls.dataset, item.glb);
document.getElementById("scene-usd-link").href = base;
document.getElementById("scene-glb-link").href = glbUrl;
const isScene = cls.class === "scenes";
document.getElementById("scene-usd-link").textContent = isScene
? "Full USD (with Gaussian splat) on Hugging Face \u2192"
: "Full USD on Hugging Face \u2192";
document.getElementById("scene-glb-link").textContent = "This " + (isScene ? "scene" : "asset") + " as GLB \u2192";
document.getElementById("scene-note").textContent = isScene
? "USD ships the full room including the Gaussian splat backdrop; the GLB is a lightweight preview (splat dropped, textures baked)."
: "USD carries the sim physics (mass, inertia, colliders) and per-zone PBR maps; the GLB is a static textured preview.";
document.getElementById("scene-dataset-link").href = `${HFDS}/${cls.dataset}/tree/main/${cls.items_dir || ("assets/" + cls.class)}`;
document.getElementById("scene-dataset-link").textContent = `Browse ${cls.class} in the ${cls.dataset} dataset \u2192`;
}
renderCmd();
const _sl = document.getElementById("scene-loader");
const _slt = document.getElementById("scene-loader-text");
if (_sl) { _slt.textContent = `loading ${item.id}…`; _sl.classList.add("active"); }
let gltf;
try { gltf = await loader.loadAsync(dsUrl(cls.dataset, item.glb)); }
catch (err) {
console.error(`GLB load failed for ${item.id}:`, err);
if (_sl) { _slt.textContent = `couldn\u2019t load ${item.id}`; }
return;
}
finally { if (_sl && !_sl.classList.contains("error")) _sl.classList.remove("active"); }
if (gen !== loadGen) return; // user already switched away — drop this load
model = new THREE.Group();
// Shipped GLBs are Y-up (Blender glTF export); catalog pivots/axes are also
// Y-up (see build_viewer_catalog.py::_zup_to_yup). No fix-up rotation.
const inner = new THREE.Group();
inner.add(gltf.scene);
model.add(inner);
scene.add(model);
const jdiv = document.getElementById("joints");
const groupByChild = {}; // link name -> pivot group (for joint chains)
for (const j of item.joints) {
const nodes = [];
gltf.scene.traverse(n => {
if (j.meshes.some(m => n.name === m || n.name === m.replaceAll(".", "_")))
nodes.push(n);
});
const parent = groupByChild[j.parent_link] || inner;
const group = new THREE.Group();
group.position.set(...j.pivot);
inner.add(group);
if (parent !== inner) parent.attach(group); // ride the parent joint
for (const n of nodes) group.attach(n);
groupByChild[j.child_link] = group;
const el = document.createElement("div");
el.className = "joint";
const step = j.unit === "deg" ? 1 : 0.005;
el.innerHTML = `<label><span>${j.name}</span><span class="out"></span></label>
<input type="range" min="${j.limits[0]}" max="${j.limits[1]}"
value="${j.limits[0]}" step="${step}">`;
jdiv.appendChild(el);
const r = { group, type: j.type, unit: j.unit,
axis: new THREE.Vector3(...j.axis).normalize(),
basePos: group.position.clone(), val: j.limits[0],
lo: j.limits[0], hi: j.limits[1], phase: j.phase || 0,
slider: el.querySelector("input"),
out: el.querySelector(".out") };
r.out.textContent = fmtVal(r, r.val);
r.slider.oninput = () => { animating = false;
setJoint(r, parseFloat(r.slider.value)); };
for (const n of nodes) {
n.traverse(c => {
if (c.isMesh) { c.userData.rigEntry = r; grabMeshes.push(c); }
});
}
rig.push(r);
}
const bb = new THREE.Box3().setFromObject(model);
const c = bb.getCenter(new THREE.Vector3());
const size = bb.getSize(new THREE.Vector3()).length();
camera.position.set(c.x + size * 0.9, c.y + size * 0.6, c.z + size * 1.1);
controls.target.copy(c);
camera.near = size / 100; camera.far = size * 50;
camera.updateProjectionMatrix();
}
document.getElementById("openall").onclick = () => {
animating = false;
rig.forEach(r => setJoint(r, r.lo + (r.hi - r.lo) * 0.85));
};
document.getElementById("closeall").onclick = () => {
animating = false;
rig.forEach(r => setJoint(r, r.lo));
};
document.getElementById("anim").onclick = () => { animating = !animating; };
(function initCopy() {
const btn = document.getElementById("copy");
btn.onclick = async () => {
const text = document.getElementById("output").textContent;
let ok = false;
try {
await navigator.clipboard.writeText(text);
ok = true;
} catch (err) {
// clipboard API blocked (embedded iframe / permission): legacy fallback
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { ok = document.execCommand("copy"); } catch (e2) {}
ta.remove();
}
btn.classList.toggle("ok", ok);
btn.title = ok ? "Copied" : "Copy failed";
clearTimeout(btn._t);
btn._t = setTimeout(() => { btn.classList.remove("ok"); btn.title = "Copy"; }, 1400);
};
})();
const smooth = t => t * t * (3 - 2 * t);
function tick() {
requestAnimationFrame(tick);
if (animating && rig.length) {
tAnim += 0.008;
const cyc = (Math.sin(tAnim) + 1) / 2;
const twoPhase = rig.some(r => r.phase === 1);
for (const r of rig) {
let local = cyc;
if (twoPhase) local = r.phase === 0 ? Math.min(1, cyc * 2)
: Math.max(0, cyc * 2 - 1);
setJoint(r, r.lo + (r.hi - r.lo) * 0.85 * smooth(local));
}
}
controls.update();
renderer.render(scene, camera);
}
// ---- catalog / tabs / item list ----------------------------------------------
fetch("classes.json")
.then(r => r.json())
.then(classes => Promise.all(classes.map(c =>
fetch(c.catalog_path || "viewer/catalog.json")
.then(r => r.json())
.then(v => ({ ...c, items_dir: v.items_dir, items: v.items })))))
.then(catalog => {
const tabs = document.getElementById("tabs");
const list = document.getElementById("itemlist");
function selectClass(cls) {
document.querySelectorAll(".tab").forEach(t =>
t.classList.toggle("active", t.dataset.cls === cls.class));
// Deep-link the header button to this class's folder in its bundled
// dataset (tabs are many-to-one onto datasets, so the repo root would
// land on the wrong README).
const dsDir = cls.items_dir || `assets/${cls.class}`;
const dslink = document.getElementById("dslink");
dslink.href = `${HFDS}/${cls.dataset}/tree/main/${dsDir}`;
dslink.textContent = `Dataset \u00b7 ${cls.class}`;
dslink.title = `projectsim/${cls.dataset} \u2192 ${dsDir}`;
if (!LOCAL_BASE) {
const q = new URLSearchParams(location.search);
q.set("class", cls.class); q.delete("item");
history.replaceState(null, "", `?${q}`);
}
document.body.classList.toggle("class-static", !!cls.static);
document.body.classList.toggle("class-scene", !!cls.scene);
list.innerHTML = "";
cls.items.forEach((item, i) => {
const btn = document.createElement("button");
btn.className = "itembtn";
const d = item.dims_m;
const label = item.id.replace(/^[a-z_]+_(?=\d)/, "#");
const [, head, tail] = label.match(/^(.*?)(_[0-9a-f]{6})?$/);
btn.title = item.id;
btn.innerHTML = `<span class="itemname"><span>${head}</span>${tail ? `<span>${tail}</span>` : ""}</span>
<small>${d[0].toFixed(2)}×${d[1].toFixed(2)}×${d[2].toFixed(2)} m ·
${item.mass_kg.toFixed(1)} kg · ${item.joints.length} j</small>`;
btn.onclick = () => {
document.querySelectorAll(".itembtn").forEach(x =>
x.classList.remove("active"));
btn.classList.add("active");
loadItem(cls, item);
};
list.appendChild(btn);
if (i === 0) btn.click();
});
}
for (const cls of catalog) {
const t = document.createElement("button");
t.className = "tab";
t.dataset.cls = cls.class;
t.textContent = cls.label;
t.onclick = () => selectClass(cls);
tabs.appendChild(t);
}
// ?class=<class>[&item=<id>] deep-links into a tab (used by the dataset
// cards' "Open in Lab" links). The URL is kept in sync on tab change.
const want = new URLSearchParams(location.search);
const startCls = catalog.find(c => c.class === want.get("class")) || catalog[0];
selectClass(startCls);
if (want.get("item")) {
const idx = startCls.items.findIndex(it => it.id === want.get("item"));
if (idx > 0) list.children[idx]?.click();
}
resize();
tick();
});
</script>
</body>
</html>