Spaces:
Running
Running
Download index.html from projectsim/lab: direct link, hf CLI and curl.
- Browser
- Download file 31.4 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
31.4 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); } | |
| /* 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 ; } | |
| #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 ; } | |
| 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> | |