Spaces:
Running
Running
Download app.js from codepawl/simready-glb-viewer: direct link, hf CLI and curl.
- Browser
- Download file 19.4 kB
-
https://huggingface.co/spaces/codepawl/simready-glb-viewer/resolve/main/app.js
- Command line
-
hf download hf://spaces/codepawl/simready-glb-viewer/app.js
-
curl -L -o app.js https://huggingface.co/spaces/codepawl/simready-glb-viewer/resolve/main/app.js
19.4 kB
| // Browse the codepawl/simready-glb dataset and orbit any model. Thumbnails and glb files load straight from the dataset. | |
| // ?base=<url> points at another copy of the dataset files (used to test against a local folder before upload). | |
| import * as THREE from 'three'; | |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; | |
| import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'; | |
| const DATASET_BASE = 'https://huggingface.co/datasets/codepawl/simready-glb/resolve/main/'; | |
| const SOURCE_BASE = 'https://huggingface.co/datasets/nvidia/simready-assets/tree/main/'; | |
| const fileBase = new URLSearchParams(location.search).get('base') ?? DATASET_BASE; | |
| const PAGE_SIZE = 120; | |
| // Thumbnails come from sprite sheets built by batch/thumbs/build_atlas.mjs: sheet n holds assets n*100 to n*100+99 | |
| // in assets.json order, 10 per row. One request per sheet instead of one per tile. | |
| const ATLAS_TILES_PER_ROW = 10; | |
| const ATLAS_TILES_PER_SHEET = ATLAS_TILES_PER_ROW * ATLAS_TILES_PER_ROW; | |
| const elements = { | |
| browse: document.getElementById('browse'), | |
| detail: document.getElementById('detail'), | |
| query: document.getElementById('query'), | |
| category: document.getElementById('category'), | |
| movingOnly: document.getElementById('moving-only'), | |
| count: document.getElementById('count'), | |
| grid: document.getElementById('grid'), | |
| sentinel: document.getElementById('sentinel'), | |
| empty: document.getElementById('empty'), | |
| canvas: document.getElementById('view'), | |
| stageStatus: document.getElementById('stage-status'), | |
| assetName: document.getElementById('asset-name'), | |
| assetCategory: document.getElementById('asset-category'), | |
| ratioValue: document.getElementById('ratio-value'), | |
| stats: document.getElementById('stats'), | |
| download: document.getElementById('download'), | |
| downloadLabel: document.getElementById('download-label'), | |
| copyCode: document.getElementById('copy-code'), | |
| copyLabel: document.getElementById('copy-label'), | |
| sourceLink: document.getElementById('source-link'), | |
| parts: document.getElementById('parts'), | |
| partList: document.getElementById('part-list'), | |
| partsOpen: document.getElementById('parts-open'), | |
| partsReset: document.getElementById('parts-reset'), | |
| }; | |
| // ---------- data ---------- | |
| /** "handTool_hammer_a01" -> "handTool". */ | |
| function categoryOf(name) { | |
| return name.split('_')[0]; | |
| } | |
| /** "handTool" -> "Hand tool", "IV" stays "IV". */ | |
| function humanize(word) { | |
| const spaced = word.replace(/([a-z0-9])([A-Z])/g, '$1 $2'); | |
| if (spaced === spaced.toUpperCase()) return spaced; | |
| const lower = spaced.toLowerCase(); | |
| return lower.charAt(0).toUpperCase() + lower.slice(1); | |
| } | |
| function toAsset(row, index) { | |
| const [name, blenderBytes, bytes, blenderTriangles, triangles, blenderDrawCalls, drawCalls, textures, blenderTextureEdge, joints = 0] = row; | |
| const category = categoryOf(name); | |
| return { | |
| index, name, blenderBytes, bytes, blenderTriangles, triangles, blenderDrawCalls, drawCalls, textures, blenderTextureEdge, joints, category, | |
| searchText: `${name} ${humanize(name.replace(/_/g, ' '))}`.toLowerCase(), | |
| }; | |
| } | |
| const assets = (await (await fetch('./assets.json')).json()).map(toAsset); | |
| const assetsByName = new Map(assets.map((asset) => [asset.name, asset])); | |
| function formatBytes(bytes) { | |
| if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`; | |
| if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(bytes >= 1e8 ? 0 : 1)} MB`; | |
| return `${Math.max(1, Math.round(bytes / 1e3))} KB`; | |
| } | |
| const numberFormat = new Intl.NumberFormat('en-US'); | |
| /** Fills an element with an asset name that may wrap after each underscore, never inside a word. */ | |
| function setBreakableName(element, name) { | |
| const parts = name.split('_'); | |
| element.replaceChildren(); | |
| parts.forEach((part, index) => { | |
| element.append(index < parts.length - 1 ? `${part}_` : part); | |
| if (index < parts.length - 1) element.append(document.createElement('wbr')); | |
| }); | |
| } | |
| function fileUrl(path) { | |
| return `${fileBase}${path}`; | |
| } | |
| // ---------- browse ---------- | |
| function fillCategories() { | |
| const counts = new Map(); | |
| for (const asset of assets) counts.set(asset.category, (counts.get(asset.category) ?? 0) + 1); | |
| const sorted = [...counts.entries()].sort((first, second) => second[1] - first[1] || first[0].localeCompare(second[0])); | |
| const options = [`<option value="">All categories</option>`]; | |
| for (const [category, count] of sorted) options.push(`<option value="${category}">${humanize(category)} (${count})</option>`); | |
| elements.category.innerHTML = options.join(''); | |
| } | |
| let filtered = assets; | |
| let shownCount = 0; | |
| function applyFilters() { | |
| const terms = elements.query.value.trim().toLowerCase().split(/\s+/).filter(Boolean); | |
| const category = elements.category.value; | |
| const movingOnly = elements.movingOnly.getAttribute('aria-pressed') === 'true'; | |
| filtered = assets.filter((asset) => (!category || asset.category === category) | |
| && (!movingOnly || asset.joints > 0) | |
| && terms.every((term) => asset.searchText.includes(term))); | |
| elements.count.textContent = `${numberFormat.format(filtered.length)} models`; | |
| elements.empty.hidden = filtered.length > 0; | |
| elements.grid.replaceChildren(); | |
| shownCount = 0; | |
| showMore(); | |
| } | |
| function makeTile(asset) { | |
| const item = document.createElement('li'); | |
| item.className = 'tile'; | |
| const link = document.createElement('a'); | |
| link.href = `#/m/${encodeURIComponent(asset.name)}`; | |
| link.title = asset.name; | |
| const image = document.createElement('div'); | |
| image.className = 'thumb'; | |
| image.setAttribute('role', 'img'); | |
| image.setAttribute('aria-label', humanize(asset.name.replace(/_/g, ' '))); | |
| const sheet = Math.floor(asset.index / ATLAS_TILES_PER_SHEET); | |
| const cell = asset.index % ATLAS_TILES_PER_SHEET; | |
| const column = cell % ATLAS_TILES_PER_ROW; | |
| const row = Math.floor(cell / ATLAS_TILES_PER_ROW); | |
| const step = 100 / (ATLAS_TILES_PER_ROW - 1); | |
| image.style.backgroundImage = `url(./atlas/${sheet}.webp)`; | |
| image.style.backgroundPosition = `${column * step}% ${row * step}%`; | |
| const meta = document.createElement('div'); | |
| meta.className = 'tile-meta'; | |
| const name = document.createElement('span'); | |
| name.className = 'tile-name'; | |
| setBreakableName(name, asset.name); | |
| const size = document.createElement('span'); | |
| size.className = 'tile-size'; | |
| size.textContent = formatBytes(asset.bytes); | |
| meta.append(name, size); | |
| link.append(image, meta); | |
| item.append(link); | |
| return item; | |
| } | |
| function showMore() { | |
| const next = filtered.slice(shownCount, shownCount + PAGE_SIZE); | |
| elements.grid.append(...next.map(makeTile)); | |
| shownCount += next.length; | |
| } | |
| new IntersectionObserver((entries) => { | |
| if (entries.some((entry) => entry.isIntersecting) && shownCount < filtered.length && !elements.browse.hidden) showMore(); | |
| }, { rootMargin: '800px' }).observe(elements.sentinel); | |
| elements.query.addEventListener('input', applyFilters); | |
| elements.category.addEventListener('change', applyFilters); | |
| elements.movingOnly.addEventListener('click', () => { | |
| const pressed = elements.movingOnly.getAttribute('aria-pressed') === 'true'; | |
| elements.movingOnly.setAttribute('aria-pressed', String(!pressed)); | |
| applyFilters(); | |
| }); | |
| elements.movingOnly.hidden = !assets.some((asset) => asset.joints > 0); | |
| // ---------- 3D view ---------- | |
| let viewer = null; | |
| function createViewer() { | |
| const renderer = new THREE.WebGLRenderer({ canvas: elements.canvas, antialias: true, alpha: true }); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.setClearColor(0x000000, 0); | |
| const scene = new THREE.Scene(); | |
| const environmentGenerator = new THREE.PMREMGenerator(renderer); | |
| scene.environment = environmentGenerator.fromScene(new RoomEnvironment(), 0.04).texture; | |
| const keyLight = new THREE.DirectionalLight(0xfff2e0, 1.4); | |
| keyLight.position.set(3.5, 5.5, 4.5); | |
| scene.add(keyLight); | |
| const camera = new THREE.PerspectiveCamera(30, 1, 0.01, 1000); | |
| const controls = new OrbitControls(camera, elements.canvas); | |
| controls.enableDamping = true; | |
| const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder); | |
| function resize() { | |
| const { clientWidth, clientHeight } = elements.canvas; | |
| if (clientWidth === 0 || clientHeight === 0) return; | |
| renderer.setSize(clientWidth, clientHeight, false); | |
| camera.aspect = clientWidth / clientHeight; | |
| camera.updateProjectionMatrix(); | |
| } | |
| new ResizeObserver(resize).observe(elements.canvas); | |
| renderer.setAnimationLoop(() => { | |
| if (elements.detail.hidden) return; | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| }); | |
| return { renderer, scene, camera, controls, loader, model: null, loadToken: 0 }; | |
| } | |
| function disposeTree(root) { | |
| root.traverse((node) => { | |
| if (!node.isMesh) return; | |
| node.geometry.dispose(); | |
| const materials = Array.isArray(node.material) ? node.material : [node.material]; | |
| for (const material of materials) { | |
| for (const value of Object.values(material)) { | |
| if (value && value.isTexture) value.dispose(); | |
| } | |
| material.dispose(); | |
| } | |
| }); | |
| } | |
| /** Seen from the front-right and a bit above, at a distance that fits the bounding sphere. */ | |
| function frameModel(root) { | |
| const { camera, controls } = viewer; | |
| const box = new THREE.Box3().setFromObject(root); | |
| const sphere = box.getBoundingSphere(new THREE.Sphere()); | |
| const distance = sphere.radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2)) * 0.95; | |
| const direction = new THREE.Vector3(Math.sin(0.61) * Math.cos(0.38), Math.sin(0.38), Math.cos(0.61) * Math.cos(0.38)); | |
| camera.position.copy(sphere.center).addScaledVector(direction, distance); | |
| camera.near = sphere.radius / 100; | |
| camera.far = sphere.radius * 100; | |
| camera.updateProjectionMatrix(); | |
| controls.target.copy(sphere.center); | |
| controls.minDistance = sphere.radius * 0.3; | |
| controls.maxDistance = sphere.radius * 8; | |
| controls.update(); | |
| } | |
| const FAILED_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 2 20h20L12 3z"/><path d="M12 10v4m0 3h.01"/></svg>'; | |
| function setStageStatus(state, text) { | |
| elements.stageStatus.className = `stage-status ${state}`; | |
| if (state === 'loading') elements.stageStatus.innerHTML = `<span class="spinner"></span><span>${text}</span>`; | |
| else if (state === 'failed') elements.stageStatus.innerHTML = `${FAILED_ICON}<span>${text}</span>`; | |
| else elements.stageStatus.replaceChildren(); | |
| } | |
| async function loadModel(asset) { | |
| viewer ??= createViewer(); | |
| const token = ++viewer.loadToken; | |
| showParts(null); | |
| if (viewer.model) { | |
| viewer.scene.remove(viewer.model); | |
| disposeTree(viewer.model); | |
| viewer.model = null; | |
| } | |
| setStageStatus('loading', 'Loading'); | |
| try { | |
| const gltf = await viewer.loader.loadAsync(fileUrl(`glb/${encodeURIComponent(asset.name)}.glb`), (event) => { | |
| if (token !== viewer.loadToken) return; | |
| const total = event.total || asset.bytes; | |
| setStageStatus('loading', `Loading ${Math.min(100, Math.round((event.loaded / total) * 100))}%`); | |
| }); | |
| if (token !== viewer.loadToken) { | |
| disposeTree(gltf.scene); | |
| return; | |
| } | |
| viewer.model = gltf.scene; | |
| viewer.scene.add(gltf.scene); | |
| frameModel(gltf.scene); | |
| showParts(gltf.scene); | |
| setStageStatus('done', ''); | |
| } catch (error) { | |
| if (token !== viewer.loadToken) return; | |
| console.error(error); | |
| setStageStatus('failed', 'This model did not load. Try again or download the file.'); | |
| } | |
| } | |
| // ---------- moving parts ---------- | |
| // add_physics.mjs mirrors each SimReady joint into the moving part's node extras (three.js userData.joints): | |
| // { type, parent, pivot, axis, min, max, unit } with pivot and axis in scene space at rest. A part's pose is its | |
| // parent's pose times its own joint motion, so a glass pane fixed to a door swings with the door. | |
| const FREE_RANGE = { revolute: [-180, 180], prismatic: [-0.5, 0.5] }; | |
| let rig = null; | |
| /** "door_04_obj_00" -> "Door 04", "sm_door1_a01_obj_00" -> "Door1". */ | |
| function partLabel(name) { | |
| return humanize(name.replace(/_obj_\d+$/, '').replace(/^sm_/, '').replace(/_[a-z]\d\d$/, '').replace(/_/g, ' ')); | |
| } | |
| function buildRig(model) { | |
| model.updateMatrixWorld(true); | |
| const parts = []; | |
| model.traverse((object) => { | |
| const joint = object.userData.joints?.find((item) => item.type !== 'fixed') ?? object.userData.joints?.[0]; | |
| if (!joint) return; | |
| const [low, high] = FREE_RANGE[joint.type] ?? [0, 0]; | |
| parts.push({ | |
| object, | |
| joint, | |
| parent: joint.parent ? model.getObjectByName(joint.parent) : null, | |
| rest: object.matrixWorld.clone(), | |
| pivot: new THREE.Vector3(...joint.pivot).applyMatrix4(model.matrixWorld), | |
| axis: new THREE.Vector3(...joint.axis).transformDirection(model.matrixWorld), | |
| min: joint.min ?? low, | |
| max: joint.max ?? high, | |
| value: 0, | |
| depth: 0, | |
| }); | |
| }); | |
| for (const part of parts) { | |
| for (let object = part.object.parent; object; object = object.parent) part.depth += 1; | |
| part.value = THREE.MathUtils.clamp(0, part.min, part.max); | |
| } | |
| parts.sort((first, second) => first.depth - second.depth); | |
| return { model, parts, byObject: new Map(parts.map((part) => [part.object, part])) }; | |
| } | |
| /** Motion of a part in scene space: its parent's motion, then its own joint. */ | |
| function partMotion(part, cache) { | |
| if (cache.has(part)) return cache.get(part); | |
| const parentPart = part.parent ? rig.byObject.get(part.parent) : null; | |
| const motion = parentPart ? partMotion(parentPart, cache).clone() : new THREE.Matrix4(); | |
| if (part.joint.type === 'revolute') { | |
| const rotation = new THREE.Matrix4().makeRotationAxis(part.axis, THREE.MathUtils.degToRad(part.value)); | |
| motion.multiply(new THREE.Matrix4().makeTranslation(part.pivot)) | |
| .multiply(rotation) | |
| .multiply(new THREE.Matrix4().makeTranslation(part.pivot.clone().negate())); | |
| } else if (part.joint.type === 'prismatic') { | |
| motion.multiply(new THREE.Matrix4().makeTranslation(part.axis.clone().multiplyScalar(part.value))); | |
| } | |
| cache.set(part, motion); | |
| return motion; | |
| } | |
| function poseRig() { | |
| const cache = new Map(); | |
| for (const part of rig.parts) { | |
| const world = partMotion(part, cache).clone().multiply(part.rest); | |
| part.object.parent.updateMatrixWorld(true); | |
| const local = part.object.parent.matrixWorld.clone().invert().multiply(world); | |
| local.decompose(part.object.position, part.object.quaternion, part.object.scale); | |
| part.object.updateMatrixWorld(true); | |
| } | |
| } | |
| function formatJointValue(part) { | |
| if (part.joint.type === 'revolute') return `${Math.round(part.value)}°`; | |
| if (part.max - part.min < 0.05) return `${(part.value * 1000).toFixed(1)} mm`; | |
| return `${Math.round(part.value * 100)} cm`; | |
| } | |
| function showParts(model) { | |
| rig = model ? buildRig(model) : null; | |
| const sliders = rig ? rig.parts.filter((part) => part.joint.type === 'revolute' || part.joint.type === 'prismatic') : []; | |
| elements.parts.hidden = sliders.length === 0; | |
| elements.partList.replaceChildren(...sliders.map((part, index) => { | |
| const item = document.createElement('li'); | |
| item.className = 'part'; | |
| const id = `part-${index}`; | |
| const label = document.createElement('label'); | |
| label.htmlFor = id; | |
| const name = document.createElement('span'); | |
| name.textContent = partLabel(part.object.name); | |
| const output = document.createElement('output'); | |
| output.textContent = formatJointValue(part); | |
| label.append(name, output); | |
| const input = document.createElement('input'); | |
| input.type = 'range'; | |
| input.id = id; | |
| input.min = part.min; | |
| input.max = part.max; | |
| input.step = part.joint.type === 'revolute' ? 1 : 0.001; | |
| input.value = part.value; | |
| input.addEventListener('input', () => { | |
| part.value = Number(input.value); | |
| output.textContent = formatJointValue(part); | |
| poseRig(); | |
| }); | |
| part.input = input; | |
| part.output = output; | |
| item.append(label, input); | |
| return item; | |
| })); | |
| } | |
| function setAllParts(pick) { | |
| if (!rig) return; | |
| for (const part of rig.parts) { | |
| if (!part.input) continue; | |
| part.value = pick(part); | |
| part.input.value = part.value; | |
| part.output.textContent = formatJointValue(part); | |
| } | |
| poseRig(); | |
| } | |
| elements.partsOpen.addEventListener('click', () => setAllParts((part) => (Math.abs(part.max) >= Math.abs(part.min) ? part.max : part.min))); | |
| elements.partsReset.addEventListener('click', () => setAllParts((part) => THREE.MathUtils.clamp(0, part.min, part.max))); | |
| // ---------- detail page ---------- | |
| function statRow(label, before, after) { | |
| const beforeText = before === null ? '' : `<span class="before">${before} → </span>`; | |
| return `<dt>${label}</dt><dd>${beforeText}${after}</dd>`; | |
| } | |
| function snippetFor(asset) { | |
| return [ | |
| "import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';", | |
| "import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';", | |
| '', | |
| 'const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);', | |
| `loader.load('${DATASET_BASE}glb/${asset.name}.glb', (gltf) => scene.add(gltf.scene));`, | |
| ].join('\n'); | |
| } | |
| let currentAsset = null; | |
| function showDetail(asset) { | |
| currentAsset = asset; | |
| document.title = `${asset.name} · SimReady GLB Viewer`; | |
| setBreakableName(elements.assetName, asset.name); | |
| elements.assetCategory.textContent = humanize(asset.category); | |
| elements.ratioValue.textContent = `${Math.round(asset.blenderBytes / asset.bytes)}× smaller`; | |
| elements.stats.innerHTML = [ | |
| statRow('File size', formatBytes(asset.blenderBytes), formatBytes(asset.bytes)), | |
| statRow('Triangles', numberFormat.format(asset.blenderTriangles), numberFormat.format(asset.triangles)), | |
| statRow('Draw calls', numberFormat.format(asset.blenderDrawCalls), numberFormat.format(asset.drawCalls)), | |
| statRow('Textures', null, numberFormat.format(asset.textures)), | |
| statRow('Largest texture', `${asset.blenderTextureEdge} px`, `${Math.min(asset.blenderTextureEdge, 1024)} px`), | |
| ].join(''); | |
| elements.download.href = `${DATASET_BASE}glb/${encodeURIComponent(asset.name)}.glb?download=true`; | |
| elements.downloadLabel.textContent = `Download .glb (${formatBytes(asset.bytes)})`; | |
| elements.copyLabel.textContent = 'Copy three.js code'; | |
| elements.sourceLink.href = `${SOURCE_BASE}${encodeURIComponent(asset.name)}`; | |
| loadModel(asset); | |
| } | |
| elements.copyCode.addEventListener('click', async () => { | |
| if (!currentAsset) return; | |
| try { | |
| await navigator.clipboard.writeText(snippetFor(currentAsset)); | |
| elements.copyLabel.textContent = 'Copied'; | |
| } catch { | |
| elements.copyLabel.textContent = 'Copy blocked by the browser'; | |
| } | |
| }); | |
| // ---------- routing ---------- | |
| let browseScroll = 0; | |
| function route() { | |
| const match = location.hash.match(/^#\/m\/(.+)$/); | |
| const asset = match ? assetsByName.get(decodeURIComponent(match[1])) : null; | |
| if (asset) { | |
| if (!elements.browse.hidden) browseScroll = window.scrollY; | |
| elements.browse.hidden = true; | |
| elements.detail.hidden = false; | |
| window.scrollTo(0, 0); | |
| showDetail(asset); | |
| return; | |
| } | |
| document.title = 'SimReady GLB Viewer'; | |
| elements.detail.hidden = true; | |
| elements.browse.hidden = false; | |
| window.scrollTo(0, browseScroll); | |
| } | |
| fillCategories(); | |
| applyFilters(); | |
| window.addEventListener('hashchange', route); | |
| route(); | |