Spaces:
Running
Running
Download index.html from projectsim/lab: direct link, hf CLI and curl.
- Browser
- Download file 37.2 kB
-
https://huggingface.co/spaces/projectsim/lab/resolve/main/index.html
- Command line
-
hf download hf://spaces/projectsim/lab/index.html
-
curl -L -o index.html https://huggingface.co/spaces/projectsim/lab/resolve/main/index.html
37.2 kB
| <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 ; } | |
| </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 ; } | |
| 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> | |