lab / index.html
sofiayx's picture
lab: top-right link -> API docs
c76f674 verified
Raw History Blame Contribute Delete
37.2 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); }
#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 .envtabs { display: flex; flex-wrap: wrap; gap: 5px; margin: 6px 0 8px; }
#panel .envtab { padding: 4px 11px; border: 1px solid var(--border);
border-radius: 999px; background: var(--surface);
cursor: pointer; font: 500 11.5px var(--sans); color: var(--ink);
transition: background .15s, border-color .15s; }
#panel .envtab:hover { border-color: var(--accent); }
#panel .envtab.active { background: var(--accent-strong); color: #fff;
border-color: var(--accent-strong); }
#panel .envtab.active:hover { background: var(--accent); border-color: var(--accent); }
#panel .envtab .soon { opacity: .55; font-size: 8.5px; margin-left: 4px;
text-transform: uppercase; letter-spacing: .05em;
vertical-align: middle; }
#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 .prereq { margin: -2px 0 7px; font: 500 10.5px var(--sans);
color: var(--body); }
#panel .prereq.na { color: var(--accent-strong); }
#panel .envtab.na { opacity: .7; }
#panel .envtab .tag { margin-left: 4px; padding: 1px 5px; font-size: 8px;
border-radius: 6px; background: var(--subtle);
color: var(--body); letter-spacing: .04em;
text-transform: uppercase; vertical-align: middle; }
#panel .envtab.active .tag { background: rgba(255,255,255,.24); color: #fff; }
#panel .modetabs { display: flex; gap: 6px; margin: 11px 0 6px; }
#panel .modetab { padding: 5px 13px; border: 1px solid var(--border);
border-radius: 999px; background: var(--surface);
cursor: pointer; font: 600 11.5px var(--sans);
color: var(--ink); transition: background .15s, border-color .15s; }
#panel .modetab:hover { border-color: var(--accent); }
#panel .modetab.active { background: var(--accent-strong); color: #fff;
border-color: var(--accent-strong); }
#panel .modetab.active:hover { background: var(--accent); border-color: var(--accent); }
#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; }
</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-static #panel .hdr,
body.class-static #panel .modetabs,
body.class-static #panel #takeMeta,
body.class-static #panel .envtabs,
body.class-static #panel .prereq,
body.class-static #panel .codewrap { display: none !important; }
body:not(.class-static) #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>
<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" 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 class="hdr"><h2>Take it home</h2><span class="fmt" id="fmt"></span></div>
<div class="modetabs" id="modetabs">
<button class="modetab active" data-mode="manual">Manual commands</button>
<button class="modetab" data-mode="agent">Agent prompt</button>
</div>
<div class="meta" id="takeMeta"></div>
<div class="envtabs" id="envtabs"></div>
<div class="prereq" id="submeta"></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 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>
<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 auth = cls => GATED.has(cls.dataset)
? `# gated: accept access at ${HFDS}/${cls.dataset} first\nhf auth login\n\n` : "";
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: OS-aware engine selector + agent prompt ------------------
function detectOS() {
const p = ((navigator.userAgentData && navigator.userAgentData.platform) ||
navigator.platform || navigator.userAgent || "").toLowerCase();
if (p.includes("mac")) return "mac";
if (p.includes("win")) return "win";
return "linux";
}
const OS = detectOS();
const OS_LABEL = { mac: "macOS", win: "Windows", linux: "Linux" }[OS];
let curCls = null, curItem = null;
let curEnv = (() => { try { return localStorage.getItem("ps_env"); }
catch (e) { return null; } })();
let curMode = (() => { try { return localStorage.getItem("ps_mode"); }
catch (e) { return null; } })() || "manual";
// software options for MANUAL mode. "auto" self-detects at run time; os =
// platforms each runs on natively (used to flag Isaac on macOS).
const ENVS = [
{ id: "auto", label: "Auto-detect", fmt: "detects engine",
os: ["mac", "win", "linux"],
prereq: "Runs whichever engine you have installed (detected at run time)." },
{ id: "isaac", label: "Isaac Sim", fmt: "USD + textures",
os: ["win", "linux"],
prereq: "Isaac Sim — Linux/Windows + an NVIDIA RTX GPU." },
{ id: "mujoco", label: "MuJoCo", fmt: "MJCF + meshes",
os: ["mac", "win", "linux"],
prereq: "pip install mujoco · any OS." },
{ id: "usd", label: "usdview / Omniverse", fmt: "USD + textures",
os: ["mac", "win", "linux"],
prereq: "usdview (pip install usd-core) — Omniverse needs Linux/Win + RTX." },
{ id: "blender", label: "Blender", fmt: ".glb",
os: ["mac", "win", "linux"],
prereq: "Blender 4.0+ — free at blender.org/download." },
];
const envById = id => ENVS.find(e => e.id === id);
const naOn = e => e.id !== "auto" && !e.os.includes(OS);
if (!envById(curEnv)) curEnv = "auto"; // default: self-detect
const classLabel = cls =>
cls.dataset.replace(/^articulated-/, "")
.replace(/ies$/, "y").replace(/([sxz]|ch|sh)es$/, "$1").replace(/s$/, "");
// One-shot snapshot download of a subtree from the dataset via huggingface_hub.
// Files land at the same relative path they have on the hub.
function hfDl(cls, item, patterns) {
const inc = patterns.map(p => `"assets/${cls.class}/${item.id}/${p}"`).join(", ");
return `python - <<'PY'\n` +
`from huggingface_hub import snapshot_download\n` +
`snapshot_download("projectsim/${cls.dataset}", repo_type="dataset",\n` +
` allow_patterns=[${inc}], local_dir=".")\n` +
`PY\n`;
}
// The path a downloaded file lives at, relative to projectsim-assets/.
const assetPath = (cls, item, file) => `assets/${cls.class}/${item.id}/${file}`;
// the open step, aware of OS (macOS needs mjpython; Blender launcher differs)
function openCmd(env, cls, item) {
const id = item.id;
const usda = assetPath(cls, item, `${id}.usda`);
const xml = assetPath(cls, item, `mjcf/${id}.xml`);
const glb = assetPath(cls, item, `${id}.glb`);
if (env === "mujoco")
return OS === "mac"
? `mjpython -m mujoco.viewer --mjcf=${xml}`
: `python -m mujoco.viewer --mjcf=${xml}`;
if (env === "blender")
return OS === "mac" ? `open -a Blender ${glb}` : `blender ${glb}`;
if (env === "usd")
return `usdview ${usda}`;
return `"$ISAAC_SIM/python.sh" - <<PY\n` + // isaac
`from isaacsim import SimulationApp\n` +
`app = SimulationApp({"open_usd": "$PWD/${usda}"})\n` +
`while app.is_running():\n app.update()\nPY`;
}
function cmdFor(env, cls, item) {
if (env === "auto") return autoScript(cls, item);
const head = `mkdir -p projectsim-assets && cd projectsim-assets\npip install -q huggingface_hub\n\n` + auth(cls);
const naNote = naOn(envById(env))
? `# ${envById(env).label} doesn't run on ${OS_LABEL} — run this on a\n` +
`# Linux/Windows box with an NVIDIA RTX GPU (or a cloud instance).\n\n`
: "";
const patterns = {
isaac: [`${item.id}.usda`, "textures/**"],
usd: [`${item.id}.usda`, "textures/**"],
mujoco: ["mjcf/**"],
blender: [`${item.id}.glb`],
}[env];
const preOpen = env === "isaac"
? `\n# open the play-ready scene in Isaac Sim, then press Play\n` +
`# (set ISAAC_SIM to your install, e.g. ~/isaacsim)\n`
: env === "mujoco"
? `\n# self-contained model (visuals + colliders + joints + latches)\n`
: "\n";
return naNote + head + hfDl(cls, item, patterns) + preOpen + openCmd(env, cls, item);
}
// The browser can't read your disk — so this script does the detection itself,
// at run time, and launches whichever engine you actually have installed.
function autoScript(cls, item) {
const id = item.id;
const usda = assetPath(cls, item, `${id}.usda`);
const xml = assetPath(cls, item, `mjcf/${id}.xml`);
const glb = assetPath(cls, item, `${id}.glb`);
const dl = (patterns) =>
`python - <<PY\n` +
`from huggingface_hub import snapshot_download\n` +
`snapshot_download("projectsim/${cls.dataset}", repo_type="dataset",\n` +
` allow_patterns=[${patterns.map(p=>`"assets/${cls.class}/${id}/${p}"`).join(", ")}], local_dir=".")\n` +
`PY`;
return `# Paste in a macOS/Linux terminal — finds your installed engine.\n` +
`mkdir -p projectsim-assets && cd projectsim-assets\n` +
`pip install -q huggingface_hub\n\n` + auth(cls) +
`if [ -n "$ISAAC_SIM" ] && [ -x "$ISAAC_SIM/python.sh" ]; then\n` +
` echo "→ Isaac Sim"\n` +
` ${dl([`${id}.usda`, "textures/**"])}\n` +
` "$ISAAC_SIM/python.sh" - <<PY\n` +
`from isaacsim import SimulationApp\n` +
`app = SimulationApp({"open_usd": "$PWD/${usda}"})\n` +
`while app.is_running(): app.update()\nPY\n` +
`elif command -v mjpython >/dev/null 2>&1 || python -c "import mujoco" 2>/dev/null; then\n` +
` echo "→ MuJoCo"\n` +
` ${dl(["mjcf/**"])}\n` +
` { command -v mjpython >/dev/null 2>&1 && mjpython -m mujoco.viewer --mjcf=${xml}; } \\\n` +
` || python -m mujoco.viewer --mjcf=${xml}\n` +
`elif command -v usdview >/dev/null 2>&1; then\n` +
` echo "→ usdview"\n` +
` ${dl([`${id}.usda`, "textures/**"])}\n` +
` usdview ${usda}\n` +
`elif command -v blender >/dev/null 2>&1 || [ -d /Applications/Blender.app ]; then\n` +
` echo "→ Blender"\n` +
` ${dl([`${id}.glb`])}\n` +
` { command -v blender >/dev/null 2>&1 && blender ${glb}; } || open -a Blender ${glb}\n` +
`else\n` +
` echo "No engine found — easiest is: pip install mujoco (then re-run)."\n` +
`fi`;
}
function agentFor(cls, item) {
return `Download the projectsim asset "${item.id}" — a sim-ready articulated ` +
`${classLabel(cls)} (${item.mass_kg.toFixed(1)} kg, ` +
`${item.joints.length} joints) — and open it in whichever simulator is ` +
`installed here (Isaac Sim, MuJoCo, usdview, or Blender). Detect what's ` +
`available, download the matching file, and open it; if none are installed, ` +
"`pip install mujoco` and use that.\n\n" +
`A script that does exactly this — run it or adapt it:\n\n` +
autoScript(cls, item);
}
function renderCmd() {
if (!curItem) return;
const agent = curMode === "agent";
const out = document.getElementById("output");
const sub = document.getElementById("submeta");
document.getElementById("envtabs").hidden = agent; // software picker: manual only
document.getElementById("fmt").hidden = agent;
document.getElementById("takeMeta").textContent = agent
? "The agent detects your setup and opens it wherever it can:"
: "Same asset, full-res + real physics — pick your engine:";
if (agent) {
out.textContent = agentFor(curCls, curItem);
sub.textContent = "Paste into Claude Code, Cursor, or any coding agent.";
sub.classList.remove("na");
} else {
const e = envById(curEnv), na = naOn(e);
document.getElementById("fmt").textContent = e.fmt;
out.textContent = cmdFor(curEnv, curCls, curItem);
sub.textContent = (na ? `Not on ${OS_LABEL} · ` : "") + e.prereq;
sub.classList.toggle("na", na);
}
}
(function initEnvTabs() {
const box = document.getElementById("envtabs");
ENVS.forEach(e => {
const b = document.createElement("button");
const na = naOn(e);
b.className = "envtab" + (e.id === curEnv ? " active" : "") + (na ? " na" : "");
b.dataset.env = e.id;
b.innerHTML = e.label + (na ? ` <span class="tag">Linux/Win</span>` : "");
b.onclick = () => {
curEnv = e.id;
try { localStorage.setItem("ps_env", curEnv); } catch (err) {}
box.querySelectorAll(".envtab").forEach(x =>
x.classList.toggle("active", x.dataset.env === curEnv));
renderCmd();
};
box.appendChild(b);
});
})();
(function initModeTabs() {
const box = document.getElementById("modetabs");
box.querySelectorAll(".modetab").forEach(b => {
b.classList.toggle("active", b.dataset.mode === curMode);
b.onclick = () => {
curMode = b.dataset.mode;
try { localStorage.setItem("ps_mode", curMode); } catch (err) {}
box.querySelectorAll(".modetab").forEach(x =>
x.classList.toggle("active", x.dataset.mode === curMode));
renderCmd();
};
});
})();
// ---- 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);
list.innerHTML = "";
cls.items.forEach((item, i) => {
const btn = document.createElement("button");
btn.className = "itembtn";
const d = item.dims_m;
btn.innerHTML = `${item.id.replace(/^[a-z_]+_(?=\d)/, "#")}
<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>