// ChemLab-Assets Explorer: a static three.js viewer for the ChemLab-Assets dataset (Hugging Face Space, sdk: static). // Adapted from the in-house asset inspector. The per-instrument bundles in data/ are pre-built; 3D models, preview // renders and downloadable files are read straight from the dataset repository (DATA_BASE). import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; /* ======================================================================================= configuration */ const DATASET_ID = 'MBZUAI/ChemLab-Assets'; const DATASET_URL = `https://huggingface.co/datasets/${DATASET_ID}`; const DEFAULT_DATA_BASE = 'https://huggingface.co/datasets/MBZUAI/ChemLab-Assets/resolve/main/'; const SPACE_ID = 'MBZUAI/ChemLab-Assets-Explorer'; const WEBSITE_URL = 'https://mbzuai-nlp.github.io/ChemLab-Assets/'; // project website; the header link stays hidden while this is empty const DEFAULT_TID = 'instrument.esco_efa5uduvw'; const HUB_ORIGIN = 'https://huggingface.co'; // the Space page that embeds this app in an iframe const QUERY = new URLSearchParams(location.search); // ?base= reads models, previews and files from another copy of the dataset (used for local testing). const DATA_BASE = (() => { const raw = QUERY.get('base'); if (raw) { try { const u = new URL(raw, location.href); if (u.protocol === 'https:' || u.protocol === 'http:') return u.href.endsWith('/') ? u.href : `${u.href}/`; } catch { /* not a URL */ } console.warn('Ignoring ?base=: only http(s) URLs are accepted.'); } return DEFAULT_DATA_BASE; })(); const ON_HUB = DATA_BASE === DEFAULT_DATA_BASE; const encPath = (p) => String(p).split('/').map(encodeURIComponent).join('/'); const dataUrl = (p) => DATA_BASE + encPath(p); const downloadUrl = (p) => dataUrl(p) + (ON_HUB ? '?download=true' : ''); const hubTree = (p) => `${DATASET_URL}/tree/main/${encPath(p)}`; const hubBlob = (p) => `${DATASET_URL}/blob/main/${encPath(p)}`; const thumbUrl = (slug) => `media/thumb/${encodeURIComponent(slug)}.webp`; /* ======================================================================================= small helpers */ const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const isNum = (v) => typeof v === 'number' && Number.isFinite(v); const num = (v, d = 3) => (!isNum(v) ? '—' : Number.isInteger(v) ? String(v) : v.toFixed(d).replace(/\.?0+$/, '').replace(/^-0$/, '0')); const NA = '—'; const fmt = (v, d = 3) => (v == null || v === '' ? NA : Array.isArray(v) ? (v.length ? v.map((x) => (isNum(x) ? num(x, d) : esc(x))).join(', ') : NA) : isNum(v) ? num(v, d) : typeof v === 'boolean' ? (v ? 'yes' : 'no') : typeof v === 'object' ? esc(JSON.stringify(v)) : esc(v)); const vec = (v, d = 3) => (Array.isArray(v) && v.length ? `(${v.map((x) => num(x, d)).join(', ')})` : '—'); const mmv = (v) => (isNum(v) ? String(Math.round(v * 1000)) : '—'); const sizeMM = (s) => (Array.isArray(s) && s.length === 3 && s.every(isNum) ? `${s.map(mmv).join(' × ')} mm` : '—'); const deg = (r) => (isNum(r) ? `${(r * 180 / Math.PI).toFixed(1)}°` : '—'); const bytes = (n) => (!isNum(n) ? '' : n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB` : `${(n / 1048576).toFixed(n < 104857600 ? 1 : 0)} MB`); const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`; const ACRONYM = { rtx: 'RTX', gc: 'GC', hplc: 'HPLC', usd: 'USD', ir: 'IR', uv: 'UV', ms: 'MS', nmr: 'NMR', fid: 'FID', lcd: 'LCD', led: 'LED', xyz: 'XYZ', icp: 'ICP', tem: 'TEM', sem: 'SEM', xrd: 'XRD', ftir: 'FTIR', dsc: 'DSC', tga: 'TGA', pcr: 'PCR', csh: 'CSH', glb: 'GLB', urdf: 'URDF', vfd: 'VFD', ph: 'pH' }; const humanize = (s) => { const t = String(s ?? '').replace(/_+/g, ' ').replace(/\s+/g, ' ').trim().split(' ').map((w) => ACRONYM[w.toLowerCase()] || w).join(' '); return t ? t[0].toUpperCase() + t.slice(1) : ''; }; const zh = (s, tag = 'span', cls = '') => (s ? `<${tag} lang="zh"${cls ? ` class="${cls}"` : ''}>${esc(s)}` : ''); const countBy = (arr, k) => arr.reduce((m, x) => { const v = x?.[k]; if (v != null) m[v] = (m[v] || 0) + 1; return m; }, {}); const nextFrame = () => new Promise((r) => requestAnimationFrame(() => r())); const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)'); const narrowMQ = matchMedia('(max-width: 899.98px)'); const darkMQ = matchMedia('(prefers-color-scheme: dark)'); const store = { get(k) { try { return localStorage.getItem(k); } catch { return null; } }, set(k, v) { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch { /* storage unavailable */ } }, }; const ICON = { download: '', close: '', sun: '', moon: '', auto: '', }; /* ---------------------------------------------------------------------------- vocabulary (English labels) */ const LABEL = { documented: 'Documented', manufacturer_datasheet: 'Manufacturer datasheet', manufacturer_verified: 'Manufacturer verified', manufacturer_specified: 'Manufacturer specified', manufacturer_drawing: 'Manufacturer drawing', manufacturer_dimensions: 'Manufacturer dimensions', photo_estimate: 'Photo estimate', photo_inference: 'Photo inference', engineering_assumption: 'Engineering assumption', simulation_assumption: 'Simulation assumption', assumption: 'Assumption', assumed: 'Assumed', estimated: 'Estimated', task_envelope: 'Task envelope', derived: 'Derived', derived_documented: 'Derived from documents', unknown: 'Unknown', not_calibrated: 'Not calibrated', schematic: 'Schematic', internal_schematic: 'Internal schematic', drawing_estimate: 'Drawing estimate', implemented: 'Implemented', verified: 'Verified', declared: 'Declared', unsupported: 'Unsupported', absent: 'Absent', not_applicable: 'Not applicable', passed: 'Passed', failed: 'Failed', not_run: 'Not run', full: 'Full port', partial: 'Partial port', bridged: 'Bridged', agent: 'Agent', evaluator: 'Evaluator', read_only: 'Read-only', read_write: 'Read / write', setpoint: 'Setpoint', process_truth: 'Process truth', sensor: 'Sensor', scene: 'Scene', lumped: 'Lumped model', numerical: 'Numerical model', qualitative: 'Qualitative', }; const TONE = { implemented: 'good', verified: 'good', passed: 'good', full: 'good', documented: 'good', declared: 'warn', partial: 'warn', unsupported: 'bad', failed: 'bad', absent: 'neutral', not_applicable: 'neutral', not_run: 'neutral', unknown: 'neutral', not_calibrated: 'neutral', bridged: 'info', agent: 'info', evaluator: 'neutral', lumped: 'neutral', numerical: 'neutral', qualitative: 'neutral', }; function toneOf(v) { const s = String(v ?? '').toLowerCase(); if (TONE[s]) return TONE[s]; if (!s || /unknown|not_/.test(s)) return 'neutral'; if (/assum|estimat|approx|photo|schematic|proxy|envelope|drawing|inferen/.test(s)) return 'warn'; if (/manufacturer|documented|verified|derived|measured|specified/.test(s)) return 'good'; return 'neutral'; } const label = (v) => LABEL[v] || humanize(v); const pill = (v, text) => (v == null || v === '' ? '' : `${esc(text || label(v))}`); const ISAAC = { full: 'Behaviour re-implemented natively in Isaac Lab (through Matterix) and checked step by step against the Python runtime.', partial: 'Core behaviour re-implemented natively in Isaac Lab (through Matterix); some capabilities are not ported.', bridged: 'The Python runtime drives the Isaac Lab scene through the Matterix bridge.', }; const isaacPill = (st, chip = false) => (st ? `Isaac Lab · ${esc(label(st))}` : ''); const KIND = { door: 'Door / lid', knob: 'Knob', button: 'Button', rotor: 'Rotor', other: 'Other' }; const KIND_GROUP = { door: 'Doors & lids', knob: 'Knobs', rotor: 'Rotors', other: 'Other joints', button: 'Buttons' }; const KIND_ORDER = ['door', 'knob', 'rotor', 'other', 'button']; const KIND_PL = { door: ['door/lid', 'doors/lids'], knob: ['knob', 'knobs'], button: ['button', 'buttons'], rotor: ['rotor', 'rotors'], other: ['other joint', 'other joints'] }; const kindOf = (j) => (KIND[j.kind] ? j.kind : 'other'); const kindText = (n, k) => { const p = KIND_PL[k] || [k, k]; return `${n} ${n === 1 ? p[0] : p[1]}`; }; const rangeText = (j) => (j.type === 'prismatic' ? `${num((j.lower ?? 0) * 1000, 1)} – ${num((j.upper ?? 0) * 1000, 1)} mm` : j.type === 'continuous' ? 'continuous' : `${deg(j.lower)} – ${deg(j.upper)}`); const LEVELS = ['process', 'host', 'scene', 'robot', 'hardware']; const LAYERS = [ { key: 'mesh', label: 'Mesh', on: true, group: 'Model' }, { key: 'wire', label: 'Wireframe', on: false, group: 'Model' }, { key: 'ghost', label: 'X-ray', on: false, group: 'Model' }, { key: 'collision', label: 'Collision proxies', on: false, group: 'Annotations', sw: ['collision'] }, { key: 'joints', label: 'Joint axes', on: true, group: 'Annotations', sw: ['door', 'rotor', 'other'] }, { key: 'buttons', label: 'Buttons & knobs', on: false, group: 'Annotations', sw: ['button', 'knob'] }, { key: 'slots', label: 'Interface points', on: true, group: 'Annotations', sw: ['slot'] }, { key: 'readouts', label: 'Panel readouts', on: false, group: 'Annotations', sw: ['readout'] }, { key: 'frames', label: 'Placement frames', on: false, group: 'Annotations' }, { key: 'layout', label: 'Footprint & clearance', on: false, group: 'Annotations', sw: ['frame', 'layout'] }, { key: 'bbox', label: 'Bounding box', on: false, group: 'Annotations', sw: ['frame', 'measured'] }, { key: 'axes', label: 'Axes', on: true, group: 'Annotations' }, { key: 'labels', label: 'Labels', on: true, group: 'Annotations' }, ]; /* ======================================================================================= state */ const S = { index: [], domains: [], byTid: new Map(), domainMap: new Map(), search: '', domain: '', view: 'viewer', tab: 'overview', tid: null, bundle: null, bundleError: null, seq: 0, selected: null, modelReady: false, bounds: null, orientation: null, layers: Object.fromEntries(LAYERS.map((l) => [l.key, l.on])), jointValues: {}, jointsOpen: false, compFilter: '', capFilter: '', sort: { key: 'chem_id', dir: 1 }, }; const domainName = (k) => S.domainMap.get(k)?.name || humanize(k || 'other'); /* ======================================================================================= theme */ const THEME_KEY = 'chemlab-explorer-theme'; const themeMode = () => { const t = document.documentElement.dataset.theme; return t === 'light' || t === 'dark' ? t : 'system'; }; const effectiveTheme = () => (themeMode() === 'system' ? (darkMQ.matches ? 'dark' : 'light') : themeMode()); function updateThemeButton() { const m = themeMode(), next = { system: 'light', light: 'dark', dark: 'system' }[m]; const btn = $('#theme-btn'); btn.innerHTML = m === 'light' ? ICON.sun : m === 'dark' ? ICON.moon : ICON.auto; const now = m === 'system' ? `follows your system (${effectiveTheme()})` : m; btn.setAttribute('aria-label', `Colour theme: ${now}. Switch to ${next === 'system' ? 'system setting' : next}.`); btn.title = `Theme: ${now}`; } function setThemeMode(m) { if (m === 'system') delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = m; store.set(THEME_KEY, m === 'system' ? null : m); themeChanged(); } function themeChanged() { updateThemeButton(); if (GL) { readColors(); repaint(); } } /* ======================================================================================= three.js setup */ const canvas = $('#gl'); let GL = null; try { GL = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' }); } catch (e) { console.warn('WebGL is not available:', e?.message || e); } const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(40, 1, 0.005, 200); camera.up.set(0, 0, 1); camera.position.set(1.2, -1.2, 0.9); let controls = null; const assetRoot = new THREE.Group(); // the asset frame: Z up, metres (URDF / contract frame) const modelWrap = new THREE.Group(); // holds the glTF scene, rotated +90° about X when the GLB is glTF Y-up assetRoot.add(modelWrap); scene.add(assetRoot); const overlay = Object.fromEntries(['collision', 'joints', 'buttons', 'slots', 'readouts', 'frames', 'layout', 'bbox', 'axes'].map((k) => [k, new THREE.Group()])); for (const g of Object.values(overlay)) assetRoot.add(g); let model = null, meshes = [], nodeToComp = new Map(), compMeshes = new Map(), pivots = new Map(); let grid = null, gridDim = 1, DASH = 0.02; const COL = {}; const OV_ROLES = ['collision', 'collision-edge', 'door', 'knob', 'button', 'rotor', 'other', 'slot', 'readout', 'readout-edge', 'frame', 'measured', 'layout', 'select']; const loader = new GLTFLoader(); let dirty = true; const invalidate = () => { dirty = true; }; function readColors() { const cs = getComputedStyle(document.documentElement); const get = (name, fallback) => { const v = cs.getPropertyValue(name).trim(); try { return new THREE.Color(v || fallback); } catch { return new THREE.Color(fallback); } }; for (const r of OV_ROLES) COL[r] = get(`--ov-${r}`, '#888888'); COL.gridMajor = get('--grid-major', '#aab7c0'); COL.gridMinor = get('--grid-minor', '#ccd5db'); } // Studio environment map (built Z-up like the rest of the view): without one, PBR metals reflect nothing and render // almost black. Metallic materials use it fully, non-metals only lightly (see setupModel). function studioEnvironment() { const env = new THREE.Scene(); const dome = new THREE.SphereGeometry(40, 48, 24); const col = []; const p = dome.attributes.position; for (let i = 0; i < p.count; i++) { const t = THREE.MathUtils.clamp((p.getZ(i) / 40 + 1) / 2, 0, 1); // 0 = floor, 1 = ceiling (world +Z) const v = t < 0.5 ? 0.16 + 0.5 * t : 0.41 + 1.0 * (t - 0.5); col.push(v, v * 1.02, v * 1.06); } dome.setAttribute('color', new THREE.Float32BufferAttribute(col, 3)); env.add(new THREE.Mesh(dome, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.BackSide }))); for (const [pos, w, h] of [[[-12, -18, 22], 16, 10], [[18, -10, 16], 10, 8], [[0, 20, 25], 20, 6]]) { // soft boxes const panel = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide })); panel.position.set(...pos); panel.lookAt(0, 0, 0); env.add(panel); } const pmrem = new THREE.PMREMGenerator(GL); const tex = pmrem.fromScene(env, 0.03).texture; pmrem.dispose(); env.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); }); return tex; } function buildGrid(maxDim) { gridDim = maxDim; if (grid) { scene.remove(grid); grid.geometry.dispose(); grid.material.dispose(); } const p = Math.pow(10, Math.floor(Math.log10(Math.max(maxDim, 0.02)))); const cell = p / 5, div = 60; // 20 cm cells for a 1–10 m instrument, 2 cm for 0.1–1 m grid = new THREE.GridHelper(cell * div, div, COL.gridMajor, COL.gridMinor); grid.rotation.x = Math.PI / 2; grid.material.transparent = true; grid.material.opacity = 0.8; grid.material.depthWrite = false; grid.renderOrder = -1; scene.add(grid); invalidate(); } if (GL) { GL.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); GL.outputColorSpace = THREE.SRGBColorSpace; GL.setClearColor(0x000000, 0); controls = new OrbitControls(camera, canvas); controls.enableDamping = !reducedMotion.matches; controls.dampingFactor = 0.12; controls.addEventListener('change', invalidate); reducedMotion.addEventListener?.('change', () => { controls.enableDamping = !reducedMotion.matches; }); scene.add(new THREE.HemisphereLight(0xffffff, 0x9aa3b0, 1.1)); const sun = new THREE.DirectionalLight(0xffffff, 1.6); sun.position.set(2, -3, 4); scene.add(sun); const fill = new THREE.DirectionalLight(0xffffff, 0.5); fill.position.set(-3, 2, 1); scene.add(fill); scene.environment = studioEnvironment(); readColors(); buildGrid(1); new ResizeObserver(invalidate).observe($('#stage')); requestAnimationFrame(frame); } function frame() { requestAnimationFrame(frame); const w = canvas.clientWidth, h = canvas.clientHeight; if (!w || !h) return; // table view or collapsed layout const pr = Math.min(window.devicePixelRatio || 1, 2); if (GL.getPixelRatio() !== pr) { GL.setPixelRatio(pr); dirty = true; } if (canvas.width !== Math.floor(w * pr) || canvas.height !== Math.floor(h * pr)) { GL.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); dirty = true; } if (controls.update()) dirty = true; // true while damping settles if (!dirty) return; // render on demand: an idle page costs nothing dirty = false; GL.render(scene, camera); updateLabels(w, h); } /* ---------------------------------------------------------------------------- HTML labels pinned to 3D points */ const labels = []; const labelLayer = $('#labels'); const _v = new THREE.Vector3(); function addLabel(l) { labels.push(l); } function clearLabels() { for (const l of labels) l.el?.remove(); labels.length = 0; } function updateLabels(w, h) { const show = S.layers.labels && S.modelReady; const k = (h / 2) / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)); for (const l of labels) { let on = show && (!l.layer || S.layers[l.layer]); if (on) { if (l.object) l.object.getWorldPosition(_v); else _v.copy(l.pos); if (l.sizeWorld) { // hide labels of things that are tiny on screen (keypads, vial racks) const px = l.sizeWorld * k / Math.max(_v.distanceTo(camera.position), 1e-4); if (px < (l.minPx || 40)) on = false; } } if (on) { _v.project(camera); if (_v.z > 1 || Math.abs(_v.x) > 1.02 || Math.abs(_v.y) > 1.02) on = false; } if (!on) { if (l.el && l.el.style.display !== 'none') l.el.style.display = 'none'; continue; } if (!l.el) { l.el = document.createElement('div'); l.el.className = `lbl ${l.cls || ''}`; l.el.textContent = l.text; l.el.title = l.title || l.text; if (l.onclick) l.el.addEventListener('click', l.onclick); labelLayer.appendChild(l.el); } l.el.style.display = ''; l.el.style.transform = `translate(${((_v.x + 1) / 2 * w).toFixed(1)}px, ${((1 - _v.y) / 2 * h).toFixed(1)}px) translate(-50%, -135%)`; l.el.classList.toggle('sel', !!l.compId && l.compId === S.selected); } } /* ---------------------------------------------------------------------------- geometry helpers */ const matsOf = (m) => (Array.isArray(m.material) ? m.material : [m.material]); function modelBounds() { const b = new THREE.Box3(); for (const m of meshes) b.expandByObject(m, true); return b; } // The dataset frame puts every instrument's front towards −Y (Z up). Camera views and axis names use that convention; // layout.front is only drawn by the layout overlay, because one asset (sft_sft110) declares +X there while its own // coordinate system and readouts face −Y. const frontVec = () => new THREE.Vector3(0, -1, 0); function layoutFront() { const fr = S.bundle?.layout?.front; const v = Array.isArray(fr) ? new THREE.Vector3(fr[0] || 0, fr[1] || 0, 0) : new THREE.Vector3(0, -1, 0); return v.lengthSq() > 1e-8 ? v.normalize() : new THREE.Vector3(0, -1, 0); } const UP = new THREE.Vector3(0, 0, 1); const rightVec = (f = frontVec()) => new THREE.Vector3().crossVectors(UP, f).normalize(); // viewer's right when facing the front // camera directions in (right, front, up) components const CAM = { iso: [1, 1, 0.8], front: [0, 1, 0.3], back: [0, -1, 0.3], left: [-1, 0, 0.3], right: [1, 0, 0.3], top: [0, 0.0001, 1] }; function viewDir(name) { const f = frontVec(), r = rightVec(f), [a, b, c] = CAM[name] || CAM.iso; return r.multiplyScalar(a).addScaledVector(f, b).addScaledVector(UP, c).normalize(); } function fitDistance(radius, margin) { const v = THREE.MathUtils.degToRad(camera.fov / 2); const hz = Math.atan(Math.tan(v) * camera.aspect); return radius / Math.sin(Math.min(v, hz)) * margin; } function fitView(name = 'iso') { if (!GL) return; const b = S.bounds && !S.bounds.isEmpty() ? S.bounds : new THREE.Box3(new THREE.Vector3(-0.3, -0.3, 0), new THREE.Vector3(0.3, 0.3, 0.5)); const c = b.getCenter(new THREE.Vector3()), r = Math.max(b.getSize(new THREE.Vector3()).length() / 2, 0.01); const d = name === 'fit' ? camera.position.clone().sub(controls.target).normalize() : viewDir(name); const dist = fitDistance(r, 1.04); camera.position.copy(c).addScaledVector(d, dist); controls.target.copy(c); camera.near = Math.max(dist / 500, 0.0005); camera.far = dist * 40; camera.updateProjectionMatrix(); controls.update(); invalidate(); } function focusOn(box) { if (!GL || !box || box.isEmpty()) return; const c = box.getCenter(new THREE.Vector3()), r = Math.max(box.getSize(new THREE.Vector3()).length() / 2, 0.01); const d = camera.position.clone().sub(controls.target).normalize(); const dist = fitDistance(r, 1.35); camera.position.copy(c).addScaledVector(d, dist); controls.target.copy(c); camera.near = Math.min(camera.near, Math.max(dist / 500, 0.0005)); camera.updateProjectionMatrix(); controls.update(); invalidate(); } const lineMat = (role, dashed = false) => (dashed ? new THREE.LineDashedMaterial({ color: COL[role], dashSize: DASH, gapSize: DASH * 0.6 }) : new THREE.LineBasicMaterial({ color: COL[role] })); const tagged = (o, role) => { o.userData.ov = role; return o; }; function mkEdges(geo, role) { return tagged(new THREE.LineSegments(new THREE.EdgesGeometry(geo), lineMat(role)), role); } function mkArrow(origin, dir, len, role, head = len * 0.25) { const d = new THREE.Vector3(...(dir || [0, 0, 1])); if (d.lengthSq() < 1e-12) d.set(0, 0, 1); return tagged(new THREE.ArrowHelper(d.normalize(), new THREE.Vector3(...origin), len, COL[role], head, head * 0.6), role); } function mkRect(x0, y0, x1, y1, z, role, dashed = false) { const pts = [[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]].map(([x, y]) => new THREE.Vector3(x, y, z)); const l = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), lineMat(role, dashed)); if (dashed) l.computeLineDistances(); return tagged(l, role); } function mkBox(min, max, role, dashed = false) { const b = new THREE.Box3(new THREE.Vector3(...min), new THREE.Vector3(...max)); const s = b.getSize(new THREE.Vector3()), c = b.getCenter(new THREE.Vector3()); const g = new THREE.BoxGeometry(Math.max(s.x, 1e-4), Math.max(s.y, 1e-4), Math.max(s.z, 1e-4)); const l = new THREE.LineSegments(new THREE.EdgesGeometry(g), lineMat(role, dashed)); g.dispose(); if (dashed) l.computeLineDistances(); l.position.copy(c); return tagged(l, role); } const rpyEuler = (rpy) => { const [r, p, y] = rpy || [0, 0, 0]; return new THREE.Euler(r, p, y, 'ZYX'); }; function disposeTree(root) { root.traverse((o) => { o.geometry?.dispose(); if (o.material) for (const m of (Array.isArray(o.material) ? o.material : [o.material])) { for (const v of Object.values(m)) if (v && v.isTexture) v.dispose(); m.dispose(); } }); } // recolour annotations after a theme change (materials keep their role in userData.ov) function repaint() { assetRoot.traverse((o) => { const c = COL[o.userData.ov]; if (!c) return; if (o.type === 'ArrowHelper') o.setColor(c); else if (o.material?.color) o.material.color.copy(c); }); buildGrid(gridDim); applyMaterials(); } /* ======================================================================================= loading an instrument */ const bundleCache = new Map(); async function loadBundle(tid) { if (bundleCache.has(tid)) { const b = bundleCache.get(tid); bundleCache.delete(tid); bundleCache.set(tid, b); return b; } const r = await fetch(`data/assets/${encodeURIComponent(tid)}.json`); if (!r.ok) throw new Error(`HTTP ${r.status}`); const b = normalizeBundle(await r.json()); bundleCache.set(tid, b); while (bundleCache.size > 10) bundleCache.delete(bundleCache.keys().next().value); return b; } function normalizeBundle(b) { for (const k of ['joints', 'slots', 'readouts', 'components', 'files', 'previews', 'limitations', 'sources', 'hidden_parts']) if (!Array.isArray(b[k])) b[k] = []; for (const k of ['capabilities', 'state_slots', 'mechanisms', 'frames', 'links', 'controls', 'checks']) if (!b[k] || typeof b[k] !== 'object' || Array.isArray(b[k])) b[k] = {}; for (const c of b.components) { if (!Array.isArray(c.collisions)) c.collisions = []; if (!Array.isArray(c.objects)) c.objects = []; } for (const j of b.joints) { if (!Array.isArray(j.axis)) j.axis = [0, 0, 1]; if (!Array.isArray(j.origin_m)) j.origin_m = [0, 0, 0]; } return b; } const glbCache = new Map(); // url -> ArrayBuffer, least recently used first let glbCacheBytes = 0, glbAbort = null; const GLB_CACHE_LIMIT = 160 * 1048576; function cacheGlb(url, buf) { if (buf.byteLength > GLB_CACHE_LIMIT / 3) return; glbCache.set(url, buf); glbCacheBytes += buf.byteLength; for (const [k, v] of glbCache) { if (glbCacheBytes <= GLB_CACHE_LIMIT) break; if (k === url) continue; glbCache.delete(k); glbCacheBytes -= v.byteLength; } } async function fetchBuffer(url, expected, onProgress, signal) { const res = await fetch(url, { signal }); if (!res.ok) throw new Error(`HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`); const total = expected || Number(res.headers.get('content-length')) || 0; if (!res.body?.getReader) { const b = await res.arrayBuffer(); onProgress(b.byteLength, total); return b; } const reader = res.body.getReader(); const parts = []; let got = 0, last = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; parts.push(value); got += value.byteLength; const now = performance.now(); if (now - last > 50) { last = now; onProgress(got, total); } } onProgress(got, total); const out = new Uint8Array(got); let o = 0; for (const p of parts) { out.set(p, o); o += p.byteLength; } return out.buffer; } function describeError(e) { const m = String(e?.message || e || 'Unknown error'); if (/^HTTP 404/.test(m)) return 'The file was not found on the server (HTTP 404).'; if (/^HTTP 40[13]/.test(m)) return `The server refused the download (${m}); the dataset may not be public yet.`; if (/^HTTP 429/.test(m)) return 'Too many downloads in a short time (HTTP 429). Wait a minute and try again.'; if (/^HTTP/.test(m)) return `The server answered ${m}.`; if (/failed to fetch|networkerror|load failed/i.test(m)) return 'The network request failed: you may be offline, or a firewall may block the download.'; return `${m.replace(/\.$/, '')}.`; } function setLoader(loaded, total, text) { if (text) $('#loader-text').textContent = text; const bar = $('#loader-bar'), track = bar.parentElement; if (total > 0) { track.classList.remove('indet'); bar.style.width = `${clamp(loaded / total * 100, 0, 100).toFixed(1)}%`; $('#loader-bytes').textContent = loaded >= total && total > 1 ? bytes(total) : `${bytes(loaded) || '0 B'} of ${bytes(total)}`; } else { track.classList.add('indet'); bar.style.width = ''; $('#loader-bytes').textContent = loaded ? bytes(loaded) : ''; } } function showLoader(a, text) { $('#viewer-error').hidden = true; const img = $('#loader-thumb'); img.hidden = !a; if (a) img.src = thumbUrl(a.slug); $('#loader').hidden = false; setLoader(0, 0, text); } const hideLoader = () => { $('#loader').hidden = true; }; function showViewerError(title, msg, { retry = false, download = null } = {}) { hideLoader(); const el = $('#viewer-error'); el.innerHTML = `

${esc(title)}

${esc(msg)}

${retry || download ? `
${retry ? '' : ''}${download ? `${ICON.download}Download the GLB` : ''}
` : ''}`; el.hidden = false; } function clearAsset() { clearLabels(); for (const g of Object.values(overlay)) { disposeTree(g); g.clear(); } const keep = new Set([modelWrap, ...Object.values(overlay)]); for (const o of [...assetRoot.children]) if (!keep.has(o)) { disposeTree(o); assetRoot.remove(o); } disposeTree(modelWrap); modelWrap.clear(); modelWrap.rotation.set(0, 0, 0); model = null; meshes = []; nodeToComp = new Map(); compMeshes = new Map(); pivots = new Map(); S.selected = null; S.jointValues = {}; S.modelReady = false; S.bounds = null; S.orientation = null; $('#pick-card').hidden = true; $('#jp-body').innerHTML = ''; $('#joint-panel').hidden = true; invalidate(); } async function selectInstrument(tid, { user = false } = {}) { const a = S.byTid.get(tid); if (!a) return; if (user) afterUserSelect(); if (tid === S.tid && !S.bundleError) return; const seq = ++S.seq; glbAbort?.abort(); S.tid = tid; S.bundle = null; S.bundleError = null; writeLocation(tid, user); markActive(); renderTitle(a); clearAsset(); renderTab(); showLoader(a, 'Loading instrument data…'); let b; try { b = await loadBundle(tid); } catch (e) { if (seq !== S.seq) return; S.bundleError = e; renderTab(); showViewerError('This instrument could not be loaded', `Its data file could not be read. ${describeError(e)}`, { retry: true }); return; } if (seq !== S.seq) return; S.bundle = b; renderTab(); if (!GL) { showViewerError('3D view unavailable', 'WebGL is not available in this browser, so the model cannot be drawn. The details and downloads in the panel still work.', { download: b.glb ? downloadUrl(b.glb) : null }); return; } await loadModel(b, seq); } async function loadModel(b, seq) { if (!b.glb) { showViewerError('No 3D model', 'This instrument has no GLB file in the release.'); return; } const url = dataUrl(b.glb), name = b.glb.split('/').pop(); const expected = b.files.find((f) => f.path === b.glb)?.bytes || 0; showLoader(S.byTid.get(b.tid), `Downloading ${name}…`); setLoader(0, expected); try { let buf = glbCache.get(url); if (buf) { glbCache.delete(url); glbCache.set(url, buf); } else { glbAbort?.abort(); const ctl = new AbortController(); glbAbort = ctl; buf = await fetchBuffer(url, expected, (l, t) => { if (seq === S.seq) setLoader(l, t); }, ctl.signal); if (glbAbort === ctl) glbAbort = null; cacheGlb(url, buf); } if (seq !== S.seq) return; setLoader(1, 1, 'Building the scene…'); await nextFrame(); const gltf = await loader.parseAsync(buf, ''); if (seq !== S.seq) { disposeTree(gltf.scene); return; } setupModel(b, gltf.scene); hideLoader(); if (S.tab === 'joints' || S.tab === 'components') rerenderTab(); } catch (e) { if (e?.name === 'AbortError' || seq !== S.seq) return; console.warn('GLB load failed:', e); let host = ''; try { host = new URL(url).host; } catch { /* keep empty */ } showViewerError('The 3D model could not be loaded', `${describeError(e)}${host ? ` The model is read from ${host}.` : ''}`, { retry: true, download: downloadUrl(b.glb) }); } } function setupModel(b, root) { model = root; modelWrap.add(model); model.traverse((o) => { if (!o.isMesh) return; o.material = Array.isArray(o.material) ? o.material.map((m) => m.clone()) : o.material.clone(); for (const mm of matsOf(o)) { mm.envMapIntensity = (mm.metalness ?? 0) >= 0.5 ? 1.0 : 0.25; mm.userData.orig = { transparent: mm.transparent, opacity: mm.opacity, depthWrite: mm.depthWrite, emissive: mm.emissive ? mm.emissive.clone() : null, emissiveIntensity: mm.emissiveIntensity ?? 1 }; } meshes.push(o); }); // Orientation: glb_up says whether the GLB is glTF +Y-up (needs +90° about X) or already Z-up. A few flags in the // source data were wrong, so both placements are measured against the declared size and the better fit wins. const flagRot = b.glb_up === 'y' || (b.glb_up == null && b.glb_orientation === 'gltf_y_up_rotated'); let useRot = flagRot, dRot = null, dFlat = null; if (Array.isArray(b.size_m) && b.size_m.length === 3 && b.size_m.every(isNum)) { const measure = (rx) => { modelWrap.rotation.set(rx, 0, 0); modelWrap.updateMatrixWorld(true); return modelBounds().getSize(new THREE.Vector3()).toArray(); }; const dev = (sz) => Math.max(...sz.map((v, i) => Math.abs(v - b.size_m[i]))); dRot = dev(measure(Math.PI / 2)); dFlat = dev(measure(0)); if (Math.abs(dRot - dFlat) > 0.002) useRot = dRot < dFlat; } modelWrap.rotation.set(useRot ? Math.PI / 2 : 0, 0, 0); modelWrap.updateMatrixWorld(true); S.orientation = { flag: flagRot ? 'y-up' : 'z-up', used: useRot ? 'y-up' : 'z-up', dRot, dFlat }; mapComponents(b); buildPivots(b); S.bounds = modelBounds(); const size = S.bounds.isEmpty() ? new THREE.Vector3(0.5, 0.5, 0.5) : S.bounds.getSize(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z, 0.02); DASH = clamp(maxDim * 0.012, 0.002, 0.03); buildGrid(maxDim); buildOverlays(b, maxDim); buildJointPanel(b); S.modelReady = true; applyLayers(); fitView('iso'); } const baseName = (n) => n.replace(/\.\d{3}$/, ''); function isAncestor(a, o) { let p = o.parent; while (p) { if (p === a) return true; p = p.parent; } return false; } function mapComponents(b) { const byName = new Map(); model.traverse((o) => { if (o.name) { byName.set(o.name, o); if (!byName.has(baseName(o.name))) byName.set(baseName(o.name), o); } }); for (const c of b.components) { const objs = []; for (const n of c.objects) { const o = byName.get(n) || byName.get(baseName(n)); if (o) objs.push(o); } const tops = objs.filter((o) => !objs.some((p) => p !== o && isAncestor(p, o))); // keep only top-most nodes const ms = []; for (const t of tops) t.traverse((o) => { if (o.isMesh) ms.push(o); }); compMeshes.set(c.id, { tops, meshes: ms }); for (const m of ms) if (!nodeToComp.has(m)) nodeToComp.set(m, c.id); } } function linkComponentId(link) { const ids = new Set(S.bundle.components.map((c) => c.id)); for (const cand of [link, link.replace(/_link$/, ''), link.replace(/_joint$/, '')]) if (ids.has(cand)) return cand; return null; } // Every joint becomes a pivot at its origin; the child link's meshes are re-parented under it, so a slider moves them. // Joint origins are given in the asset frame (also for joints nested under a moving link), so a nested pivot is // placed by converting that point into its parent pivot's frame. function buildPivots(b) { const byChild = new Map(b.joints.map((j) => [j.child, j])); const made = new Set(); const make = (j) => { if (made.has(j.name)) return pivots.get(j.name)?.pivot || assetRoot; // (a cycle in the data falls back to the root) made.add(j.name); const parentJoint = byChild.get(j.parent); const parent = parentJoint && parentJoint !== j ? make(parentJoint) : assetRoot; const pv = new THREE.Object3D(); pv.name = `pivot:${j.name}`; parent.updateMatrixWorld(true); pv.position.copy(parent.worldToLocal(assetRoot.localToWorld(new THREE.Vector3(...j.origin_m)))); parent.add(pv); pv.updateMatrixWorld(true); const rest = pv.position.clone(); const cid = linkComponentId(j.child); const cm = cid ? compMeshes.get(cid) : null; let tops = cm ? cm.tops : []; if (!tops.length) { // fall back to a node-name match on the link name const hits = []; model.traverse((o) => { const segs = o.name.split('__'); if (segs[1] === j.child || o.name === j.child) hits.push(o); }); tops = hits.filter((o) => !hits.some((p) => p !== o && isAncestor(p, o))); } for (const t of tops) pv.attach(t); pivots.set(j.name, { pivot: pv, joint: j, rest, hasMesh: tops.length > 0, compId: cid }); return pv; }; for (const j of b.joints) make(j); } function setJoint(name, q) { const p = pivots.get(name); if (!p) return; S.jointValues[name] = q; const j = p.joint, axis = new THREE.Vector3(...j.axis).normalize(); if (j.type === 'prismatic') p.pivot.position.copy(p.rest).addScaledVector(axis, q); else p.pivot.quaternion.setFromAxisAngle(axis, q); invalidate(); } function compOrigin(c, byId, depth = 0) { // component frame origins chain up the parent tree (rotations are zero in the files) const o = new THREE.Vector3(...(c.origin_m || [0, 0, 0])); const p = c.parent && byId.get(c.parent); if (p && depth < 8) o.add(compOrigin(p, byId, depth + 1)); return o; } function hostForComponent(cid, byId) { // nearest moving ancestor, so annotations ride along with doors and lids for (let id = cid, n = 0; id && n < 12; n++) { for (const [, p] of pivots) if (p.compId === id) return p.pivot; id = byId.get(id)?.parent; } return assetRoot; } function hostForLink(link) { if (!link) return assetRoot; for (const [, p] of pivots) if (p.joint.child === link) return p.pivot; return assetRoot; } function addOv(layer, obj, host = assetRoot) { obj.userData.layer = layer; overlay[layer].add(obj); if (host && host !== assetRoot) host.attach(obj); return obj; } const AXIS_NAMES = [[[1, 0, 0], '+X'], [[-1, 0, 0], '−X'], [[0, 1, 0], '+Y'], [[0, -1, 0], '−Y'], [[0, 0, 1], '+Z'], [[0, 0, -1], '−Z']]; function axisName(v) { if (!Array.isArray(v)) return '—'; const u = new THREE.Vector3(...v); if (u.lengthSq() < 1e-9) return '—'; u.normalize(); for (const [a, n] of AXIS_NAMES) if (u.distanceTo(new THREE.Vector3(...a)) < 1e-3) return n; return vec(v, 2); } function sideName(axis) { // how a world axis relates to the instrument's front const f = frontVec(), r = rightVec(f), a = new THREE.Vector3(...axis); if (a.distanceTo(UP) < 1e-3) return 'up'; for (const [v, n] of [[f, 'front'], [f.clone().negate(), 'back'], [r, 'right'], [r.clone().negate(), 'left']]) if (a.distanceTo(v) < 1e-3) return n; return ''; } function buildOverlays(b, maxDim) { const byId = new Map(b.components.map((c) => [c.id, c])); const hyp = Math.hypot(...(b.size_m || [maxDim, maxDim, maxDim])); // collision proxies (primitives from the URDF / collision_proxies.json) const colMat = new THREE.MeshBasicMaterial({ color: COL.collision, transparent: true, opacity: 0.22, depthWrite: false }); for (const c of b.components) { if (!c.collisions.length) continue; const base = compOrigin(c, byId), host = hostForComponent(c.id, byId); for (const k of c.collisions) { let geo; if (k.shape === 'box') geo = new THREE.BoxGeometry(...(k.size_m || [0.01, 0.01, 0.01]).map((v) => Math.max(v, 1e-4))); else if (k.shape === 'cylinder' || k.shape === 'capsule') { geo = new THREE.CylinderGeometry(k.radius_m, k.radius_m, Math.max(k.length_m || 0, 1e-4), 24); geo.rotateX(Math.PI / 2); } else if (k.shape === 'sphere') geo = new THREE.SphereGeometry(k.radius_m || 0.005, 16, 12); else geo = new THREE.BoxGeometry(...(k.size_m || [0.01, 0.01, 0.01]).map((v) => Math.max(v, 1e-4))); const m = tagged(new THREE.Mesh(geo, colMat), 'collision'); m.position.copy(base).add(new THREE.Vector3(...(k.origin_m || [0, 0, 0]))); m.rotation.copy(rpyEuler(k.rpy_rad)); const e = mkEdges(geo, 'collision-edge'); e.position.copy(m.position); e.rotation.copy(m.rotation); addOv('collision', m, host); addOv('collision', e, host); } } // joints: axis arrows for doors, rotors and other joints; outline boxes for buttons and knobs const axisLen = Math.max(Math.min(0.2, hyp * 0.15), Math.min(0.06, hyp * 0.3)); for (const j of b.joints) { const kind = kindOf(j); const p = pivots.get(j.name); const host = p && p.pivot.parent !== assetRoot ? p.pivot.parent : assetRoot; const onclick = () => selectJoint(j.name); if (kind === 'button' || kind === 'knob') { if (!Array.isArray(j.child_bbox_min) || !Array.isArray(j.child_bbox_max)) continue; const box = addOv('buttons', mkBox(j.child_bbox_min, j.child_bbox_max, kind), host); if (kind === 'knob') addOv('buttons', mkArrow(j.origin_m, j.axis, Math.max(axisLen * 0.4, 0.02), 'knob', Math.max(axisLen * 0.1, 0.006)), host); const diag = new THREE.Vector3(...j.child_bbox_max).sub(new THREE.Vector3(...j.child_bbox_min)).length(); addLabel({ object: box, text: j.name.replace(/^btn_|^knob_|^key_/, '').replace(/_(link|joint)$/, ''), cls: `k-${kind}`, layer: 'buttons', title: `${j.name}\n${KIND[kind]} · ${j.type} · ${rangeText(j)}`, onclick, sizeWorld: diag, minPx: 30, compId: p?.compId }); continue; } addOv('joints', mkArrow(j.origin_m, j.axis, axisLen, kind), host); const s = tagged(new THREE.Mesh(new THREE.SphereGeometry(axisLen * 0.06, 12, 8), new THREE.MeshBasicMaterial({ color: COL[kind] })), kind); s.position.set(...j.origin_m); addOv('joints', s, host); const diag = Array.isArray(j.child_bbox_min) && Array.isArray(j.child_bbox_max) ? new THREE.Vector3(...j.child_bbox_max).sub(new THREE.Vector3(...j.child_bbox_min)).length() : 0; addLabel({ object: s, text: `${j.name} · ${rangeText(j)}`, cls: `k-${kind}`, layer: 'joints', title: `${j.name}: ${KIND[kind]}, ${j.type}, axis ${vec(j.axis, 3)}`, onclick, sizeWorld: diag || undefined, minPx: 22, compId: p?.compId }); } // interface points ("slots"): where things are placed, inserted or connected for (const s of b.slots) { if (!Array.isArray(s.pos)) continue; const host = hostForLink(s.link); const r = Math.max(isNum(s.radius_m) ? s.radius_m : 0.02, 0.003); const n = new THREE.Vector3(...(s.insert_axis || [0, 0, -1])); if (n.lengthSq() < 1e-9) n.set(0, 0, -1); n.normalize(); const disc = tagged(new THREE.Mesh(new THREE.CircleGeometry(r, 32), new THREE.MeshBasicMaterial({ color: COL.slot, transparent: true, opacity: 0.18, side: THREE.DoubleSide, depthWrite: false })), 'slot'); disc.position.set(...s.pos); disc.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), n); addOv('slots', disc, host); const ring = tagged(new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(Array.from({ length: 48 }, (_, i) => new THREE.Vector3(Math.cos(i / 48 * Math.PI * 2) * r, Math.sin(i / 48 * Math.PI * 2) * r, 0))), lineMat('slot')), 'slot'); ring.position.copy(disc.position); ring.quaternion.copy(disc.quaternion); addOv('slots', ring, host); let anchor = ring; if (r <= 0.05) { const sph = tagged(new THREE.Mesh(new THREE.SphereGeometry(r, 16, 10), new THREE.MeshBasicMaterial({ color: COL.slot, wireframe: true, transparent: true, opacity: 0.55 })), 'slot'); sph.position.set(...s.pos); addOv('slots', sph, host); anchor = sph; } const alen = clamp(r * 1.6, Math.min(0.02, hyp * 0.05), 0.25); addOv('slots', mkArrow(s.pos, n.clone().negate().toArray(), alen, 'slot', alen * 0.3), host); addLabel({ object: anchor, text: `${s.name} · ${humanize(s.kind || 'slot').toLowerCase()}`, cls: 'k-slot', layer: 'slots', title: `${s.name}${s.part ? ` (${s.part})` : ''}\nFits: ${(s.fits || []).join(', ') || '—'}\nRadius ${num(r * 1000, 1)} mm · capture ${isNum(s.capture_m) ? `${num(s.capture_m * 1000, 1)} mm` : '—'}${s.access?.length ? `\nNeeds: ${s.access.join(', ')}` : ''}`, sizeWorld: r * 2, minPx: 16 }); } // panel readouts (digital displays) for (const r of b.readouts) { if (!Array.isArray(r.center) || !Array.isArray(r.size_m)) continue; const [w, h] = r.size_m; const plane = tagged(new THREE.Mesh(new THREE.PlaneGeometry(Math.max(w, 1e-4), Math.max(h, 1e-4)), new THREE.MeshBasicMaterial({ color: COL.readout, transparent: true, opacity: 0.45, side: THREE.DoubleSide, depthTest: false })), 'readout'); const normal = new THREE.Vector3(...(r.normal || [0, -1, 0])).normalize(), up = new THREE.Vector3(...(r.up || [0, 0, 1])).normalize(); const right = new THREE.Vector3().crossVectors(up, normal).normalize(); if (right.lengthSq() > 0.5) plane.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(right, up, normal)); plane.position.set(...r.center); plane.renderOrder = 5; const e = mkEdges(plane.geometry, 'readout-edge'); e.position.copy(plane.position); e.quaternion.copy(plane.quaternion); const host = hostForLink(r.link); addOv('readouts', plane, host); addOv('readouts', e, host); addLabel({ object: plane, text: `${r.name} · ${r.digits ?? '?'} digits${r.unit ? ` · ${r.unit}` : ''}`, cls: 'k-readout', layer: 'readouts', title: `Readout ${r.name}\nSource: ${readoutSource(r.source)}`, sizeWorld: Math.max(w, h), minPx: 46 }); } // placement / grasp frames (the per-joint frames are skipped: they duplicate the joint origins) const frameSize = clamp(maxDim * 0.05, 0.01, 0.06); for (const [name, pos] of Object.entries(b.frames || {})) { if (name.startsWith('joint_') || !Array.isArray(pos)) continue; const ax = new THREE.AxesHelper(frameSize); ax.position.set(...pos); addOv('frames', ax); addLabel({ object: ax, text: name, cls: 'k-frame', layer: 'frames' }); } // layout: footprint, clearance (front/back/left/right relative to the declared front), robot standoff, work height if (b.layout) { const L = b.layout, bmin = b.bbox_min || [0, 0, 0], bmax = b.bbox_max || [0, 0, 0]; const cx = (bmin[0] + bmax[0]) / 2, cy = (bmin[1] + bmax[1]) / 2; const [fw, fd] = Array.isArray(L.footprint_m) ? L.footprint_m : [bmax[0] - bmin[0], bmax[1] - bmin[1]]; const lift = maxDim * 0.002; addOv('layout', mkRect(cx - fw / 2, cy - fd / 2, cx + fw / 2, cy + fd / 2, lift, 'frame')); const f = layoutFront(), rv = rightVec(f), cl = L.clearance_m || {}; let x0 = cx - fw / 2, x1 = cx + fw / 2, y0 = cy - fd / 2, y1 = cy + fd / 2; const grow = (d, amt) => { if (!isNum(amt) || !amt) return; if (Math.abs(d.x) >= Math.abs(d.y)) { if (d.x > 0) x1 += amt; else x0 -= amt; } else if (d.y > 0) y1 += amt; else y0 -= amt; }; grow(f, cl.front); grow(f.clone().negate(), cl.back); grow(rv, cl.right); grow(rv.clone().negate(), cl.left); addOv('layout', mkRect(x0, y0, x1, y1, lift * 1.5, 'layout', true)); const half = Math.abs(f.x) >= Math.abs(f.y) ? fw / 2 : fd / 2; const alen = half + Math.max(maxDim * 0.25, 0.03); addOv('layout', mkArrow([cx, cy, lift * 2], f.toArray(), alen, 'layout', Math.min(alen * 0.15, 0.06))); const tip = new THREE.Vector3(cx, cy, 0).addScaledVector(f, alen); addLabel({ pos: tip, text: `Front ${axisName(L.front || [0, -1, 0])} · ${humanize(L.mount || 'unspecified mount').toLowerCase()} · ${humanize(L.weight_class || '').toLowerCase() || 'weight class n/a'}`, cls: 'k-frame', layer: 'layout' }); if (isNum(L.robot_standoff_m) && L.robot_standoff_m > 0) { const disc = tagged(new THREE.Mesh(new THREE.CylinderGeometry(maxDim * 0.03, maxDim * 0.03, maxDim * 0.006, 24), new THREE.MeshBasicMaterial({ color: COL.layout })), 'layout'); disc.rotation.x = Math.PI / 2; disc.position.copy(new THREE.Vector3(cx, cy, 0).addScaledVector(f, half + L.robot_standoff_m)); addOv('layout', disc); addLabel({ object: disc, text: `Robot standoff ${num(L.robot_standoff_m, 3)} m`, cls: 'k-frame', layer: 'layout' }); } if (isNum(L.work_height_m) && L.work_height_m > 0) { addOv('layout', mkRect(cx - fw / 2, cy - fd / 2, cx + fw / 2, cy + fd / 2, L.work_height_m, 'measured', true)); addLabel({ pos: new THREE.Vector3(cx - fw / 2, cy - fd / 2, L.work_height_m), text: `Work height ${num(L.work_height_m, 3)} m`, cls: 'k-frame', layer: 'layout' }); } } // declared bounding box (solid) and the box measured from the GLB (dashed) if (Array.isArray(b.bbox_min) && Array.isArray(b.bbox_max)) addOv('bbox', mkBox(b.bbox_min, b.bbox_max, 'frame')); if (S.bounds && !S.bounds.isEmpty()) addOv('bbox', mkBox(S.bounds.min.toArray(), S.bounds.max.toArray(), 'measured', true)); // world axes at the asset origin, named relative to the instrument's front const axLen = clamp(maxDim * 0.3, 0.03, 0.5); addOv('axes', new THREE.AxesHelper(axLen)); for (const [axis, nm] of [[[1, 0, 0], '+X'], [[0, 1, 0], '+Y'], [[0, 0, 1], '+Z']]) { const side = sideName(axis); addLabel({ pos: new THREE.Vector3(...axis).multiplyScalar(axLen * 1.05), text: side ? `${nm} ${side}` : nm, cls: 'k-frame', layer: 'axes' }); } } function readoutSource(src) { if (!src || typeof src !== 'object') return '—'; const parts = []; if (src.bridge) parts.push(`runtime ${[].concat(src.bridge).join(', ')}`); if (src.state) parts.push(`state ${src.state}`); if (isNum(src.scale) && src.scale !== 1) parts.push(`× ${num(src.scale, 4)}`); if (isNum(src.offset) && src.offset !== 0) parts.push(`${src.offset < 0 ? '−' : '+'} ${num(Math.abs(src.offset), 3)}`); return parts.length ? parts.join(' ') : JSON.stringify(src); } function applyLayers() { for (const [k, g] of Object.entries(overlay)) g.visible = !!S.layers[k]; assetRoot.traverse((o) => { if (o.userData.layer) o.visible = !!S.layers[o.userData.layer]; }); for (const m of meshes) m.visible = !!S.layers.mesh; applyMaterials(); } // material state is derived from the original material + X-ray + wireframe + selection, so every toggle is reversible function applyMaterials() { for (const m of meshes) { const sel = !!S.selected && nodeToComp.get(m) === S.selected; for (const mat of matsOf(m)) { const o = mat.userData.orig; if (!o) continue; const ghost = !!S.layers.ghost && !sel; const transparent = ghost ? true : o.transparent; if (mat.transparent !== transparent) { mat.transparent = transparent; mat.needsUpdate = true; } mat.opacity = ghost ? Math.min(o.opacity, 0.22) : o.opacity; mat.depthWrite = ghost ? false : o.depthWrite; mat.wireframe = !!S.layers.wire; if (mat.emissive && o.emissive) { if (sel) { mat.emissive.copy(COL.select); mat.emissiveIntensity = 0.55; } else { mat.emissive.copy(o.emissive); mat.emissiveIntensity = o.emissiveIntensity; } } } } invalidate(); } /* ======================================================================================= selection */ function selectComponent(cid, { focus = false, from = 'view' } = {}) { const b = S.bundle; const c = b?.components.find((x) => x.id === cid); if (!c) return; S.selected = cid; applyMaterials(); const pc = $('#pick-card'); pc.innerHTML = `
${componentHead(c)}
${componentBody(c, true)}${S.tab !== 'components' ? '
' : ''}`; pc.hidden = false; if (focus) { const cm = compMeshes.get(cid); if (cm?.meshes.length) { const bb = new THREE.Box3(); for (const m of cm.meshes) bb.expandByObject(m); focusOn(bb); } } if (S.tab === 'components') { if (from === 'table') markSelectedRows(); else { rerenderTab(); $(`#tab-body tr[data-comp="${CSS.escape(cid)}"]`)?.scrollIntoView({ block: 'nearest' }); } } } function clearSelection() { if (!S.selected && $('#pick-card').hidden) return; S.selected = null; applyMaterials(); $('#pick-card').hidden = true; if (S.tab === 'components') rerenderTab(); } function markSelectedRows() { for (const tr of $$('#tab-body tr[data-comp]')) tr.classList.toggle('sel', tr.dataset.comp === S.selected); } function selectJoint(name) { const p = pivots.get(name); if (p?.compId) selectComponent(p.compId, { focus: true }); openJoints(true); const row = $(`#jp-body [data-joint="${CSS.escape(name)}"]`); if (row) { row.scrollIntoView({ block: 'nearest' }); row.classList.add('flash'); setTimeout(() => row.classList.remove('flash'), 1400); } } const raycaster = new THREE.Raycaster(); let downAt = null; canvas.addEventListener('pointerdown', (e) => { downAt = [e.clientX, e.clientY]; $('#hint').classList.add('gone'); closeLayers(); }); canvas.addEventListener('pointerup', (e) => { if (!GL || !downAt || !model || Math.hypot(e.clientX - downAt[0], e.clientY - downAt[1]) > 4) return; if (e.button !== 0 && e.pointerType === 'mouse') return; const r = canvas.getBoundingClientRect(); raycaster.setFromCamera(new THREE.Vector2(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1), camera); const hits = raycaster.intersectObjects(meshes.filter((m) => m.visible), false); if (!hits.length) { clearSelection(); return; } const m = hits[0].object, cid = nodeToComp.get(m); if (cid) { selectComponent(cid); return; } S.selected = null; applyMaterials(); const pc = $('#pick-card'); pc.innerHTML = `

${esc(m.name || 'Unnamed mesh')}

This mesh is not listed in the instrument's component table.

`; pc.hidden = false; }); /* ---------------------------------------------------------------------------- joint sliders */ const jointValueText = (j, q) => (j.type === 'prismatic' ? `${(q * 1000).toFixed(1)} mm` : deg(q)); function jointLimits(j) { if (j.type === 'continuous') return [-Math.PI, Math.PI]; let lo = isNum(j.lower) ? j.lower : (j.type === 'prismatic' ? 0 : -Math.PI), hi = isNum(j.upper) ? j.upper : (j.type === 'prismatic' ? 0.05 : Math.PI); if (hi < lo) [lo, hi] = [hi, lo]; if (hi === lo) hi = lo + (j.type === 'prismatic' ? 0.001 : 0.01); return [lo, hi]; } function buildJointPanel(b) { const js = b.joints.filter((j) => j.type !== 'fixed'); const panel = $('#joint-panel'); if (!js.length) { panel.hidden = true; return; } const movable = js.filter((j) => pivots.get(j.name)?.hasMesh).length; $('#jp-count').textContent = movable === js.length ? `${js.length}` : `${movable} of ${js.length}`; $('#jp-count').title = movable === js.length ? `${js.length} joints` : `${movable} of ${js.length} joints can be moved in this viewer`; let html = `
${js.some((j) => kindOf(j) === 'door' && pivots.get(j.name)?.hasMesh) ? '' : ''}
`; for (const k of KIND_ORDER) { const list = js.filter((j) => kindOf(j) === k); if (!list.length) continue; html += `
${KIND_GROUP[k]} ${list.length}
`; for (const j of list) { const has = !!pivots.get(j.name)?.hasMesh, [lo, hi] = jointLimits(j), q = S.jointValues[j.name] || 0; html += `
${esc(j.name)}${jointValueText(j, q)}
`; } } if (movable < js.length) html += '

Greyed-out joints have no separate mesh in the GLB, so they cannot be moved in this viewer.

'; $('#jp-body').innerHTML = html; panel.hidden = false; openJoints(S.jointsOpen); } function openJoints(open) { S.jointsOpen = open; $('#jp-toggle').setAttribute('aria-expanded', String(open)); $('#jp-body').hidden = !open; } $('#jp-toggle').addEventListener('click', () => openJoints(!S.jointsOpen)); $('#jp-body').addEventListener('input', (e) => { const inp = e.target.closest('input[type=range]'); if (!inp) return; const row = inp.closest('.jrow'), j = pivots.get(row.dataset.joint)?.joint; if (!j) return; const q = parseFloat(inp.value); setJoint(j.name, q); const t = jointValueText(j, q); row.querySelector('.jv').textContent = t; inp.setAttribute('aria-valuetext', t); }); $('#jp-body').addEventListener('click', (e) => { const act = e.target.closest('[data-act]')?.dataset.act; if (!act || !S.bundle) return; for (const j of S.bundle.joints) { if (j.type === 'fixed' || !pivots.get(j.name)) continue; if (act === 'zero') setJoint(j.name, 0); else if (act === 'open-doors' && kindOf(j) === 'door') setJoint(j.name, jointLimits(j)[1]); } buildJointPanel(S.bundle); }); /* ======================================================================================= header, list and filters */ function setupHeader() { const site = $('#link-website'); if (WEBSITE_URL) { site.href = WEBSITE_URL; site.hidden = false; } $('#link-dataset').href = DATASET_URL; if (!ON_HUB) { const n = $('#base-note'); n.textContent = `data: ${DATA_BASE.replace(/^https?:\/\//, '').replace(/\/$/, '')}`; n.title = `Models, previews and files are read from ${DATA_BASE} instead of the Hugging Face dataset.`; n.hidden = false; } updateThemeButton(); $('#theme-btn').addEventListener('click', () => setThemeMode({ system: 'light', light: 'dark', dark: 'system' }[themeMode()])); darkMQ.addEventListener?.('change', () => { if (themeMode() === 'system') themeChanged(); }); for (const b of $$('.seg [data-view]')) b.addEventListener('click', () => setView(b.dataset.view)); let t = null; $('#search').addEventListener('input', (e) => { clearTimeout(t); t = setTimeout(() => { S.search = e.target.value; applyFilter(); }, 90); }); $('#search').addEventListener('keydown', (e) => { if (e.key === 'Enter') { // Enter opens the first match S.search = e.target.value; applyFilter(); const first = filtered()[0]; if (first) selectInstrument(first.tid, { user: true }); } }); $('#domain').addEventListener('change', (e) => { S.domain = e.target.value; applyFilter(); }); $('#list-toggle').addEventListener('click', () => toggleList()); } function toggleList(open = !$('#sidebar').classList.contains('open')) { $('#sidebar').classList.toggle('open', open); $('#list-toggle').setAttribute('aria-expanded', String(open)); if (open) requestAnimationFrame(() => scrollActiveIntoView(true)); } const byChem = (a, b) => (a.chem_id && b.chem_id ? a.chem_id.localeCompare(b.chem_id) : a.chem_id ? -1 : b.chem_id ? 1 : a.slug.localeCompare(b.slug)); function buildDomainSelect() { $('#domain').innerHTML = `${S.domains.map((d) => ``).join('')}`; } function itemHtml(a) { const mm = [a.maker, a.model].filter(Boolean).join(' · '); return `
  • ${esc(a.name)}${esc(mm)} ${a.chem_id ? `${esc(a.chem_id)}` : '1st gen'}${plural(a.n_joints ?? 0, 'joint')}${plural(a.n_components ?? 0, 'part')}
  • `; } function buildList() { const known = new Set(S.domains.map((d) => d.key)); const groups = [...S.domains, ...[...new Set(S.index.map((a) => a.domain).filter((d) => !known.has(d)))].map((k) => ({ key: k, name: humanize(k || 'other') }))]; $('#asset-list').innerHTML = groups.map((d) => { const items = S.index.filter((a) => a.domain === d.key).sort(byChem); if (!items.length) return ''; return `
  • ${esc(d.name)}${items.length}
  • `; }).join('') + ''; } $('#asset-list').addEventListener('click', (e) => { const a = e.target.closest('a.item'); if (!a || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button > 0) return; // let modified clicks open a new tab e.preventDefault(); selectInstrument(a.dataset.tid, { user: true }); }); function filtered() { const toks = S.search.trim().toLowerCase().split(/\s+/).filter(Boolean); return S.index.filter((a) => (!S.domain || a.domain === S.domain) && toks.every((t) => a._hay.includes(t))); } function applyFilter() { const rows = filtered(), vis = new Set(rows.map((a) => a.tid)); for (const li of $$('#asset-list li[data-tid]')) li.hidden = !vis.has(li.dataset.tid); for (const g of $$('#asset-list .dgroup')) { const n = $$('li[data-tid]', g).filter((li) => !li.hidden).length; g.hidden = !n; $('.grp .n', g).textContent = n; } $('#list-empty').hidden = rows.length > 0; const txt = rows.length === S.index.length ? `${rows.length} instruments` : `${rows.length} of ${S.index.length}`; $('#asset-count').textContent = txt; $('#tv-count').textContent = txt; if (S.view === 'table') renderTable(); } function markActive() { for (const a of $$('#asset-list a.item')) a.setAttribute('aria-current', String(a.dataset.tid === S.tid)); for (const tr of $$('#tv-table tbody tr')) tr.setAttribute('aria-current', String(tr.dataset.tid === S.tid)); } function scrollActiveIntoView(force = false) { const el = $(`#asset-list a.item[aria-current="true"]`); const wrap = $('#list-wrap'); if (!el || !wrap || (narrowMQ.matches && !force)) return; const r = el.getBoundingClientRect(), w = wrap.getBoundingClientRect(); if (r.top < w.top + 30 || r.bottom > w.bottom) wrap.scrollTop += r.top - w.top - w.height / 3; } function afterUserSelect() { if (narrowMQ.matches) { toggleList(false); $('#viewer').scrollIntoView({ behavior: reducedMotion.matches ? 'auto' : 'smooth', block: 'start' }); } } function renderTitle(a) { $('#inst-id').textContent = [a.chem_id, a.tid].filter(Boolean).join(' · '); $('#inst-name').textContent = a.name || a.slug; $('#inst-mm').textContent = [a.maker, a.model].filter(Boolean).join(' · '); const z = $('#inst-zh'); z.textContent = a.name_zh || ''; z.title = a.name_zh || ''; $('#inst-chips').innerHTML = `${esc(domainName(a.domain))}${isaacPill(a.isaac_lab, true)}${a.chem_id ? '' : 'First generation'}`; document.title = `${a.name}${a.model ? ` · ${a.model}` : ''} | ChemLab-Assets Explorer`; } /* ---------------------------------------------------------------------------- deep links */ function resolveTid(s) { if (!s) return null; const t = s.trim(); if (S.byTid.has(t)) return t; if (S.byTid.has(`instrument.${t}`)) return `instrument.${t}`; return null; } function tidFromLocation() { let h = location.hash.replace(/^#/, ''); try { h = decodeURIComponent(h); } catch { /* keep raw */ } const asked = [h, QUERY.get('id')].filter(Boolean); for (const c of asked) { const t = resolveTid(c); if (t) return { tid: t }; } return { tid: null, asked: asked[0] }; } function writeLocation(tid, notifyParent) { const u = new URL(location.href); if (u.searchParams.has('id')) u.searchParams.delete('id'); u.hash = tid; try { history.replaceState(history.state, '', `${u.pathname}${u.search}${u.hash}`); } catch { /* sandboxed */ } // On huggingface.co the app runs in an iframe; this message lets the Space page update its own URL (Hub docs: // "How to handle URL parameters in Spaces"). Elsewhere the target origin does not match and nothing happens. if (notifyParent && window.parent !== window) { const qs = new URLSearchParams(u.search); for (const k of [...qs.keys()]) if (k.startsWith('__')) qs.delete(k); const q = qs.toString(); try { window.parent.postMessage({ queryString: q ? `?${q}` : '', hash: `#${tid}` }, HUB_ORIGIN); } catch { /* ignore */ } } } window.addEventListener('hashchange', () => { const { tid, asked } = tidFromLocation(); if (tid && tid !== S.tid) selectInstrument(tid); else if (!tid && asked) toast(`“${asked}” is not an instrument in this release.`); }); /* ======================================================================================= views: 3D viewer / table */ function setView(v) { S.view = v; document.body.classList.toggle('view-table', v === 'table'); $('#table-view').hidden = v !== 'table'; for (const b of $$('.seg [data-view]')) b.setAttribute('aria-pressed', String(b.dataset.view === v)); if (v === 'table') renderTable(); invalidate(); } const volume = (s) => (Array.isArray(s) && s.length === 3 ? s[0] * s[1] * s[2] : -1); const COLS = [ { key: 'chem_id', label: 'CHEM ID', sort: (a) => (a.chem_id ? parseInt(a.chem_id.replace(/\D+/g, ''), 10) || 0 : 1e6), cell: (a) => `${a.chem_id ? esc(a.chem_id) : '—'}` }, { key: 'name', label: 'Instrument', sort: (a) => (a.name || '').toLowerCase(), cell: (a) => `
    ${esc(a.name)}${esc([a.maker, a.model].filter(Boolean).join(' · '))}
    ` }, { key: 'domain', label: 'Domain', sort: (a) => domainName(a.domain), cell: (a) => `${esc(domainName(a.domain))}` }, { key: 'size', label: 'Size (mm)', r: true, title: 'Width × depth × height; sorted by volume', sort: (a) => volume(a.size_m), cell: (a) => `${esc(sizeMM(a.size_m).replace(' mm', ''))}` }, { key: 'n_components', label: 'Components', r: true }, { key: 'n_joints', label: 'Joints', r: true }, { key: 'prismatic', label: 'Prismatic', r: true, sort: (a) => a.joint_types?.prismatic || 0 }, { key: 'revolute', label: 'Revolute', r: true, title: 'Revolute and continuous joints', sort: (a) => (a.joint_types?.revolute || 0) + (a.joint_types?.continuous || 0) }, { key: 'n_state_slots', label: 'State slots', r: true }, { key: 'n_capabilities', label: 'Capabilities', r: true }, { key: 'n_readouts', label: 'Readouts', r: true }, { key: 'n_interface_points', label: 'Interface points', r: true }, { key: 'isaac_lab', label: 'Isaac Lab', sort: (a) => ({ full: 0, partial: 1, bridged: 2 }[a.isaac_lab] ?? 3), cell: (a) => `${a.isaac_lab ? pill(a.isaac_lab) : NA}` }, ]; for (const c of COLS) { if (!c.sort) c.sort = (a) => (isNum(a[c.key]) ? a[c.key] : -1); if (!c.cell) c.cell = (a) => { const v = c.sort(a); return `${v < 0 ? NA : esc(num(v))}`; }; } function renderTable() { const col = COLS.find((c) => c.key === S.sort.key) || COLS[0], dir = S.sort.dir; const rows = filtered().slice().sort((x, y) => { const a = col.sort(x), b = col.sort(y); const d = typeof a === 'number' && typeof b === 'number' ? a - b : String(a).localeCompare(String(b)); return d * dir || byChem(x, y); }); $('#tv-table thead').innerHTML = `${COLS.map((c) => { const on = c.key === S.sort.key; return ``; }).join('')}`; $('#tv-table tbody').innerHTML = rows.length ? rows.map((a) => `${COLS.map((c) => c.cell(a)).join('')}`).join('') : `No instrument matches the search or domain filter.`; } $('#tv-table').addEventListener('click', (e) => { const th = e.target.closest('button[data-sort]'); if (th) { const k = th.dataset.sort; if (S.sort.key === k) S.sort.dir = -S.sort.dir; else S.sort = { key: k, dir: COLS.find((c) => c.key === k)?.r ? -1 : 1 }; renderTable(); $(`#tv-table button[data-sort="${k}"]`)?.focus(); return; } const tr = e.target.closest('tbody tr[data-tid]'); if (tr) openFromTable(tr.dataset.tid); }); $('#tv-table').addEventListener('keydown', (e) => { const tr = e.target.closest('tbody tr[data-tid]'); if (tr && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); openFromTable(tr.dataset.tid); } }); function openFromTable(tid) { setView('viewer'); selectInstrument(tid, { user: true }); requestAnimationFrame(() => scrollActiveIntoView()); } /* ======================================================================================= right panel */ const TAB_TITLES = { overview: 'Overview', components: 'Components', joints: 'Joints & controls', interfaces: 'Interfaces & readouts', capabilities: 'Capabilities & mechanisms', files: 'Files' }; function setTab(t, focus = false) { if (!TAB_TITLES[t]) return; S.tab = t; for (const btn of $$('#tabs [role=tab]')) { const on = btn.dataset.tab === t; btn.setAttribute('aria-selected', String(on)); btn.tabIndex = on ? 0 : -1; if (on && focus) btn.focus(); } $('#tab-body').setAttribute('aria-labelledby', `tab-${t}`); renderTab(); $('#tab-body').scrollTop = 0; if (S.selected && !$('#pick-card').hidden) { const c = S.bundle?.components.find((x) => x.id === S.selected); if (c) selectComponent(c.id, { from: 'table' }); } } $('#tabs').addEventListener('click', (e) => { const b = e.target.closest('[role=tab]'); if (b) setTab(b.dataset.tab); }); $('#tabs').addEventListener('keydown', (e) => { const keys = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }; if (!(e.key in keys) && e.key !== 'Home' && e.key !== 'End') return; e.preventDefault(); const ids = Object.keys(TAB_TITLES), i = ids.indexOf(S.tab); const n = e.key === 'Home' ? 0 : e.key === 'End' ? ids.length - 1 : (i + keys[e.key] + ids.length) % ids.length; setTab(ids[n], true); }); function renderTab() { const body = $('#tab-body'), b = S.bundle; if (!b) { body.innerHTML = S.bundleError ? `

    The data for this instrument could not be loaded. ${esc(describeError(S.bundleError))}

    ` : '

    Loading…

    '; return; } const fn = { overview: tabOverview, components: tabComponents, joints: tabJoints, interfaces: tabInterfaces, capabilities: tabCapabilities, files: tabFiles }[S.tab]; body.innerHTML = fn(b); if (S.tab === 'components') applyCompFilter(); if (S.tab === 'capabilities') applyCapFilter(); } function rerenderTab() { const body = $('#tab-body'), y = body.scrollTop; renderTab(); body.scrollTop = y; } const secH = (title, aside = '') => `

    ${esc(title)}

    ${aside ? `${aside}` : ''}
    `; const fact = (k, v, small = '', wide = false) => `
    ${esc(k)}
    ${v}${small ? `${small}` : ''}
    `; function tabOverview(b) { const a = S.byTid.get(b.tid) || {}; const jt = countBy(b.joints, 'type'), jk = countBy(b.joints.map((j) => ({ k: kindOf(j) })), 'k'); const caps = Object.values(b.capabilities), av = countBy(caps, 'availability'); const cov = b.checks.coverage || {}, st = cov.status || a.isaac_lab; const legacy = !b.has_contract; const nState = Object.keys(b.state_slots).length; const front = b.layout?.front; const jointSmall = [['prismatic', 'revolute', 'continuous'].filter((k) => jt[k]).map((k) => `${jt[k]} ${k}`).join(' · '), KIND_ORDER.filter((k) => jk[k]).map((k) => kindText(jk[k], k)).join(' · ')].filter(Boolean).join('
    '); const capSmall = ['implemented', 'unsupported', 'declared'].filter((k) => av[k]).map((k) => `${av[k]} ${k}`).join(' · '); const facts = [ fact('Size W × D × H', esc(sizeMM(b.size_m))), fact('Mass', isNum(b.mass_kg) ? `${num(b.mass_kg, 2)} kg` : '—'), fact('Components', String(b.components.length)), fact('Joints', String(b.joints.length), jointSmall), fact('State slots', legacy && !nState ? '—' : String(nState), legacy && !nState ? 'kept in physics_annotations.json' : ''), fact('Capabilities', String(caps.length), capSmall), fact('Readouts', String(b.readouts.length), b.readouts.length ? esc(b.readouts.map((r) => r.name).join(', ')) : ''), fact('Interface points', String(b.slots.length), b.layout?.primary_slot ? `primary: ${esc(b.layout.primary_slot)}` : ''), fact('Frame', 'Z up · front −Y', `metres, kilograms, radians${front && axisName(front) !== '−Y' ? ` · layout lists front ${esc(axisName(front))}` : ''}`), fact('Isaac Lab', st ? pill(st) : '—', st ? esc(ISAAC[st] || '') : ''), ].join(''); const gallery = b.previews.length ? `${secH('Preview renders', `${b.previews.length} images`)}` : ''; const c = b.completeness || {}; const bar = (label, n, d) => (isNum(n) && d ? `
    ${esc(label)}${n} / ${d}
    ` : ''); const coverage = c.components ? `${secH('Annotation coverage', 'per component')}
    ${bar('Collision proxies', c.with_collisions, c.components)}${bar('Mapped to GLB meshes', c.with_objects, c.components)}${bar('Function described', c.with_function, c.components)}${bar('Mass given', c.with_mass, c.components)}
    ` : ''; const sources = b.sources.length ? `${secH('Sources', String(b.sources.length))}` : ''; return `${secH('Key facts', esc(domainName(b.domain)))}
    ${facts}
    ${legacy ? '

    First-generation asset (September 2026). It is described by physics_annotations.json and its own runtime instead of an asset contract.

    ' : ''} ${b.description_zh ? `${secH('Description', 'in Chinese')}${zh(b.description_zh, 'p', 'prose')}` : ''} ${secH('Download')}
    ${downloadLinks(b)}
    ${gallery}${coverage} ${cov.note_zh ? `${secH('Isaac Lab integration', 'notes in Chinese')}${zh(cov.note_zh, 'p', 'prose')}` : ''} ${sources}`; } function previewCaption(p) { const n = p.split('/').pop().replace(/\.[a-z0-9]+$/i, '').replace(/^preview_/, ''); return humanize(n.replace(/^isaac_rtx_/, 'isaac rtx ').replace(/env(\d)/g, 'env $1')); } function downloadLinks(b) { const size = (p) => b.files.find((f) => f.path === p)?.bytes; const items = []; if (b.glb) items.push([b.glb, 'Visual model (glTF binary)']); if (b.urdf) items.push([b.urdf, 'Articulation with collision proxies (URDF)']); if (b.contract) items.push([b.contract, 'Asset contract: parts, state slots, capabilities']); else { const pa = b.files.find((f) => /physics_annotations\.json$/.test(f.path)); if (pa) items.push([pa.path, 'Physics annotations (first-generation format)']); } if (b.runtime) items.push([b.runtime, 'Python behaviour runtime']); if (!items.length) return '

    No downloadable files are listed for this instrument.

    '; const tgt = ON_HUB ? '' : ' target="_blank" rel="noopener"'; return items.map(([p, what]) => `${ICON.download}${esc(p.split('/').pop())}${esc(what)}${esc(bytes(size(p)))}`).join(''); } function componentHead(c) { return `

    ${esc(c.id)}

    ${zh(c.name_zh, 'div', 'cc-zh')}
    `; } function collisionText(k) { const at = Array.isArray(k.origin_m) ? ` at ${vec(k.origin_m)} m` : ''; if (k.shape === 'box' && Array.isArray(k.size_m)) return `Box ${k.size_m.map(mmv).join(' × ')} mm${at}`; if ((k.shape === 'cylinder' || k.shape === 'capsule') && isNum(k.radius_m)) return `${humanize(k.shape)} r ${mmv(k.radius_m)} mm × ${mmv(k.length_m)} mm${at}`; if (k.shape === 'sphere' && isNum(k.radius_m)) return `Sphere r ${mmv(k.radius_m)} mm${at}`; return `${humanize(k.shape || 'shape')}${at}`; } function materialText(m) { if (!m) return ''; if (typeof m === 'string') return esc(m); if (typeof m !== 'object') return esc(m); const c = Array.isArray(m.base_color) ? m.base_color : null; const sw = c ? `` : ''; const bits = [isNum(m.metallic) ? `metallic ${num(m.metallic, 2)}` : '', isNum(m.roughness) ? `roughness ${num(m.roughness, 2)}` : '', isNum(m.alpha) && m.alpha < 1 ? `alpha ${num(m.alpha, 2)}` : '', m.name ? esc(m.name) : ''].filter(Boolean); return `${sw}${bits.join(' · ') || '—'}`; } function componentBody(c, compact = false) { const rows = []; const add = (k, v) => { if (v !== '' && v != null) rows.push(`
    ${esc(k)}
    ${v}
    `); }; if (c.subsystem) add('Subsystem', esc(humanize(c.subsystem))); add('Parent', `${esc(c.parent || '—')}${Array.isArray(c.origin_m) ? ` · origin ${esc(vec(c.origin_m))} m` : ''}`); if (c.joint) { const L = Array.isArray(c.joint.limits) && c.joint.limits.length === 2 && c.joint.limits.every(isNum) ? c.joint.limits : null; const unit = c.joint.limit_unit || (c.joint.type === 'prismatic' ? 'm' : 'rad'); const lim = !L ? '' : unit === 'm' ? `${num(L[0] * 1000, 1)} – ${num(L[1] * 1000, 1)} mm` : unit === 'rad' ? `${deg(L[0])} – ${deg(L[1])}` : `${num(L[0], 4)} – ${num(L[1], 4)} ${unit}`; add('Joint', `${esc(c.joint.type || '')}${Array.isArray(c.joint.axis) ? `, axis ${esc(vec(c.joint.axis, 2))}` : ''}${lim ? `, ${esc(lim)}` : ''} ${pill(c.joint.evidence)}`); } add('Mass', `${isNum(c.mass_kg) ? `${num(c.mass_kg, 3)} kg` : '—'} ${pill(c.mass_evidence)}`); if (c.material) add('Material', `${materialText(c.material)}${isNum(c.friction) ? ` · friction ${num(c.friction, 2)}` : ''}${isNum(c.restitution) ? ` · restitution ${num(c.restitution, 2)}` : ''}`); if (c.geometry_evidence || c.geometry_note_zh) add('Geometry', `${pill(c.geometry_evidence)} ${compact ? '' : zh(c.geometry_note_zh)}`); if (c.interaction) add('Interaction', `${esc(humanize(c.interaction.category || ''))} ${c.interaction.graspable ? 'graspable' : ''}${c.interaction.removable ? 'removable' : ''}`); if (c.actions?.length) add('Actions', c.actions.map((x) => `${esc(x)}`).join('')); if (c.capability_ids?.length) { const ids = compact ? c.capability_ids.slice(0, 5) : c.capability_ids; add('Capabilities', `${esc(ids.join(', '))}${ids.length < c.capability_ids.length ? ` + ${c.capability_ids.length - ids.length} more` : ''}`); } if (!compact && c.preconditions?.length) add('Preconditions', zh([].concat(...c.preconditions.map((p) => (Array.isArray(p) ? p : [typeof p === 'object' ? (p.description_zh || JSON.stringify(p)) : p]))).join(';'))); if (c.anchor_m) add('Anchor', `${esc(vec(c.anchor_m))} m (component frame)`); if (c.physical_quantities?.length) add('Quantities', c.physical_quantities.map((q) => `${esc(q.name)} [${esc(q.unit || '')}] = ${fmt(q.value)} ${pill(q.evidence)}`).join('
    ')); add(`Collision (${c.collisions.length})`, c.collisions.length ? c.collisions.slice(0, compact ? 3 : 50).map((k) => esc(collisionText(k))).join('
    ') + (compact && c.collisions.length > 3 ? `
    + ${c.collisions.length - 3} more` : '') : 'none'); const lim = compact ? 4 : 40; add(`GLB meshes (${c.objects.length})`, c.objects.length ? `${esc(c.objects.slice(0, lim).join(', '))}${c.objects.length > lim ? ' …' : ''}` : 'none found'); return `${c.function_zh ? `

    Function${zh(c.function_zh)}

    ` : ''}${!compact && c.physical_meaning_zh && c.physical_meaning_zh !== c.function_zh ? `

    Physical meaning${zh(c.physical_meaning_zh)}

    ` : ''}
    ${rows.join('')}
    `; } function tabComponents(b) { const sel = S.selected && b.components.find((c) => c.id === S.selected); const rows = b.components.map((c) => `${esc(c.id)}${zh(c.name_zh, 'span', 'sub')}${c.collisions.length || '0'}${c.objects.length || '0'}${c.joint ? esc(c.joint.type || '') : ''}${isNum(c.mass_kg) ? num(c.mass_kg, 3) : ''}`).join(''); return `
    Components

    ${b.components.length} parts. Select a row to highlight the part in 3D; click a part in the viewer to find it here.

    ${sel ? `
    ${componentHead(sel)}${componentBody(sel)}
    ` : ''}
    ${rows}
    ComponentColl.MeshesJointkg

    Assembled from ${esc(b.component_source || 'the asset files')}.

    `; } function applyCompFilter() { const q = S.compFilter.trim().toLowerCase(); for (const tr of $$('#comp-table tbody tr')) tr.hidden = !!q && !tr.textContent.toLowerCase().includes(q); } function tabJoints(b) { const jt = countBy(b.joints, 'type'), jk = countBy(b.joints.map((j) => ({ k: kindOf(j) })), 'k'); const items = b.joints.map((j) => { const p = pivots.get(j.name), k = kindOf(j); const here = !S.modelReady ? '' : p?.hasMesh ? 'movable in 3D' : 'no mesh'; return `
  • ${esc(j.name)}${esc(KIND[k])}${esc(j.type)}${here}
    ${esc(j.parent)} → ${esc(j.child)}
    ${esc(rangeText(j))}axis ${esc(vec(j.axis, 2))}at ${esc(vec(j.origin_m))} m
  • `; }).join(''); const ctl = b.controls || {}, nb = (ctl.buttons || []).length, nk = (ctl.knobs || []).length; const typeLine = ['prismatic', 'revolute', 'continuous', 'fixed'].filter((t) => jt[t]).map((t) => `${jt[t]} ${t}`).join(' · ') || 'none'; const kindLine = KIND_ORDER.filter((k) => jk[k]).map((k) => kindText(jk[k], k)).join(' · ') || 'none'; return `
    Joints & controls

    Prismatic ranges in millimetres, revolute ranges in degrees. Select a joint to find it in 3D; move it with the sliders under the viewer.

    Joint types
    ${esc(typeLine)}
    Kinds
    ${esc(kindLine)}
    ${nb || nk ? `
    Panel controls
    ${nb ? `${plural(nb, 'button')} cut from the panel as spring-loaded joints (1.5 mm travel)` : ''}${nb && nk ? '; ' : ''}${nk ? `${plural(nk, 'knob')} (±150°)` : ''} in the simulator articulation
    ` : ''} ${b.urdf ? `
    URDF
    ${esc(b.urdf.split('/').pop())}
    ` : ''}
    ${secH(`Joints (${b.joints.length})`, S.modelReady ? `${[...pivots.values()].filter((p) => p.hasMesh).length} movable in 3D` : '')} ${b.joints.length ? `` : '

    This instrument has no joints.

    '} ${nb || nk ? `
    Panel control names (${nb + nk})

    ${esc([...(ctl.buttons || []), ...(ctl.knobs || [])].join(', '))}

    ` : ''}`; } function tabInterfaces(b) { const L = b.layout; const slotItems = b.slots.map((s) => `
  • ${esc(s.name)}${esc(humanize(s.kind || 'point'))}${isNum(s.radius_m) ? `r ${esc(num(s.radius_m * 1000, 1))} mm` : ''}
    at ${esc(vec(s.pos))} m${s.part ? `part ${esc(s.part)}` : ''}
    fits ${esc((s.fits || []).join(', ') || '—')}${s.access?.length ? `needs ${esc(s.access.join(', '))}` : ''}${isNum(s.capture_m) ? `capture ${esc(num(s.capture_m * 1000, 1))} mm` : ''}
  • `).join(''); const roItems = b.readouts.map((r) => `
  • ${esc(r.name)}${isNum(r.digits) ? `${plural(r.digits, 'digit')}${r.decimals ? ` · ${plural(r.decimals, 'decimal')}` : ''}` : ''}${r.unit ? `${esc(r.unit)}` : ''}
    source ${esc(readoutSource(r.source))}${Array.isArray(r.size_m) ? `${esc(r.size_m.map(mmv).join(' × '))} mm` : ''}${r.blank_channel ? `blanked by ${esc(r.blank_channel)}` : ''}
    ${r.part ? `
    part ${esc(r.part)}
    ` : ''}
  • `).join(''); const frames = Object.entries(b.frames).filter(([k, v]) => !k.startsWith('joint_') && Array.isArray(v)); const cl = L?.clearance_m || {}; return `
    Interfaces & readouts

    Interface points are where a robot places, inserts or connects things. Readouts are the panel displays the runtime drives.

    ${secH(`Interface points (${b.slots.length})`)} ${b.slots.length ? `` : '

    No interface points: nothing can be placed in or attached to this instrument.

    '} ${b.hidden_parts.length ? `

    Modelled placeholders hidden in simulation: ${esc(b.hidden_parts.join(', '))}

    ` : ''} ${secH(`Panel readouts (${b.readouts.length})`)} ${b.readouts.length ? `` : '

    No digital readouts (the instrument has no numeric display, or uses a menu or touch screen).

    '} ${secH(`Placement frames (${frames.length})`)} ${frames.length ? `
    ${frames.map(([k, v]) => ``).join('')}
    ${esc(k)}${esc(vec(v))} m
    ` : '

    None.

    '} ${secH('Layout')} ${L ? `
    Front
    ${esc(axisName(L.front))}${L.front_source ? ` (${esc(humanize(L.front_source))})` : ''}
    Mount
    ${esc(humanize(L.mount || '—'))} · ${esc(humanize(L.weight_class || '—'))}${isNum(L.mass_kg) ? ` · ${num(L.mass_kg, 2)} kg` : ''}
    Footprint
    ${Array.isArray(L.footprint_m) ? esc(L.footprint_m.map(mmv).join(' × ')) : '—'} mm, height ${esc(mmv(L.height_m))} mm
    Clearance
    ${['front', 'back', 'left', 'right', 'top'].filter((k) => isNum(cl[k])).map((k) => `${k} ${mmv(cl[k])}`).join(' · ') || '—'} mm
    Robot
    standoff ${isNum(L.robot_standoff_m) ? `${mmv(L.robot_standoff_m)} mm` : '—'} · work height ${isNum(L.work_height_m) ? `${mmv(L.work_height_m)} mm` : '—'}
    Primary point
    ${esc(L.primary_slot || '—')}
    ${L.access?.length ? `
    Opened by
    ${esc(L.access.join(', '))}
    ` : ''}
    ${L.placement_evidence ? `
    Placement evidence

    ${esc(L.placement_evidence)}

    ` : ''}` : '

    No layout metadata.

    '}`; } /* visibility (agent / evaluator) is only in the asset contract, which is fetched when this tab is first opened */ const contracts = new Map(); function contractInfo(b) { if (!b.contract) return { status: 'none' }; let e = contracts.get(b.tid); if (!e) { e = { status: 'loading' }; contracts.set(b.tid, e); fetch(dataUrl(b.contract)) .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) .then((c) => { e.state = new Map(Object.entries(c.state_slots || {}).map(([k, v]) => [k, v?.visibility])); e.caps = new Map(Object.entries(c.capabilities || {}).map(([k, v]) => [k, v?.visibility])); e.status = 'ready'; }) .catch((err) => { e.status = 'error'; e.error = err; }) .finally(() => { if (S.bundle?.tid === b.tid && S.tab === 'capabilities') rerenderTab(); }); } return e; } function visCell(info, map, key) { if (info.status === 'ready') { const v = info[map].get(key); return v ? pill(v) : NA; } if (info.status === 'loading') return '…'; return NA; } function tabCapabilities(b) { const info = contractInfo(b); const caps = Object.entries(b.capabilities), av = countBy(caps.map(([, c]) => c), 'availability'); const cov = b.checks.coverage || {}, inIsaac = new Set(cov.implemented || []), notIsaac = new Set(cov.unsupported || []); const states = Object.entries(b.state_slots), mechs = Object.entries(b.mechanisms); const visNote = info.status === 'none' ? 'This first-generation asset has no contract, so state visibility is not recorded.' : info.status === 'loading' ? 'Reading visibility from the asset contract…' : info.status === 'error' ? `Visibility could not be read from the asset contract (${esc(describeError(info.error))}) ` : 'Visibility: agent = the robot or policy may read it; evaluator = hidden truth for scoring and set-up.'; const capItems = caps.map(([k, c]) => `
  • ${esc(k)}${pill(c.availability)}${inIsaac.has(k) ? 'Isaac Lab' : notIsaac.has(k) ? 'not in Isaac Lab' : ''}${info.status === 'ready' && info.caps.get(k) ? pill(info.caps.get(k)) : ''}
    ${c.function_zh ? zh(c.function_zh, 'div', 'cap-fn') : ''}
    ${c.kind ? `${esc(humanize(c.kind))}` : ''}${c.input?.length ? `inputs ${esc(c.input.join(', '))}` : ''}${c.implementation ? LEVELS.filter((l) => c.implementation[l]).map((l) => `${esc(humanize(l))} ${esc(label(c.implementation[l]).toLowerCase())}`).join('') : ''}
  • `).join(''); const stateRows = states.map(([k, s]) => `${esc(k)}${zh(s.meaning_zh, 'span', 'sub')}${esc(s.unit ?? '') || NA}${visCell(info, 'state', k)}${esc(label(s.access || '—'))}${esc(label(s.origin || ''))}${s.write_via ? `via ${esc(s.write_via)}` : ''}`).join(''); const mechHtml = mechs.map(([k, m]) => `

    ${esc(k)}${m.implementation ? pill(m.implementation) : ''}

    ${m.meaning_zh ? zh(m.meaning_zh, 'p', 'cc-p') : ''}${(m.equations || []).map((e) => `
    ${esc(e)}
    `).join('')} ${Object.keys(m.parameters || {}).length ? `
    Parameters (${Object.keys(m.parameters).length})
    ${Object.entries(m.parameters).map(([pk, pv]) => ``).join('')}
    ParameterValueUnitEvidence
    ${esc(pk)}${pv?.meaning_zh ? zh(pv.meaning_zh, 'span', 'sub') : ''}${fmt(pv?.value, 4)}${esc(pv?.unit || '')}${pill(pv?.evidence)}
    ` : ''}
    `).join(''); return `
    Capabilities & mechanisms

    What the behaviour runtime can do (capabilities), what it exposes (state slots) and the physics behind it (mechanisms).

    ${secH(`Capabilities (${caps.length})`, ['implemented', 'unsupported', 'declared'].filter((k) => av[k]).map((k) => `${av[k]} ${k}`).join(' · '))} ${caps.length ? `` : '

    No capabilities are listed.

    '} ${secH(`State slots (${states.length})`)}

    ${visNote}

    ${states.length ? `
    ${stateRows}
    StateUnitVisibilityAccess
    ` : `

    ${b.has_contract ? 'No state slots are listed.' : 'State is described in physics_annotations.json for this first-generation asset.'}

    `} ${secH(`Mechanisms (${mechs.length})`)} ${mechHtml || '

    No mechanisms are listed.

    '} ${b.limitations.length ? `${secH(`Known limitations (${b.limitations.length})`, 'in Chinese')}` : ''}`; } function applyCapFilter() { const q = S.capFilter.trim().toLowerCase(); for (const el of $$('#cap-list .cap, #state-table tbody tr')) el.hidden = !!q && !(el.dataset.q || '').includes(q); } const TEXT_EXT = /\.(json|urdf|md|py|txt|html|xml|csv|yaml|yml)$/i; function tabFiles(b) { const files = [...b.files].sort((x, y) => x.name.localeCompare(y.name)); const total = files.reduce((s, f) => s + (f.bytes || 0), 0); const tgt = ON_HUB ? '' : ' target="_blank" rel="noopener"'; const row = (f, note = '') => `${esc(f.name)}${note ? `${note}` : ''}${esc(bytes(f.bytes)) || NA}${TEXT_EXT.test(f.name) ? `view` : ''}`; const extra = []; if (b.runtime) extra.push(row({ name: b.runtime.split('/').pop(), path: b.runtime }, `runtime module · ${esc(b.runtime)}`)); return `
    Files

    Everything the dataset ships for this instrument. Files download from the dataset repository.

    Folder
    ${esc(b.path || b.asset_dir || '—')}
    On the Hub
    Browse the folder ↗
    ${secH(`Asset folder (${files.length})`, esc(bytes(total)))}
    ${files.map((f) => row(f, f.path.split('/').slice(0, -1).join('/') !== (b.path || '') ? `${esc(f.path)}` : '')).join('')}
    FileSizeView
    ${extra.length ? `${secH('Shared code')}
    ${extra.join('')}
    ` : ''} ${b.previews.length ? `${secH(`Preview renders (${b.previews.length})`)}

    previews/${esc(b.slug)}/ on the Hub ↗

    ` : ''}

    Assets are licensed CC BY-NC 4.0; see the dataset card for terms and citation.

    `; } // delegated events inside the panel $('#tab-body').addEventListener('click', (e) => { const t = e.target; const act = t.closest('[data-act]')?.dataset.act; if (act === 'tab-files') { setTab('files'); return; } if (act === 'retry-contract' && S.bundle) { contracts.delete(S.bundle.tid); rerenderTab(); return; } const shot = t.closest('.shot'); if (shot && S.bundle) { openLightbox(S.bundle.previews.map((p) => ({ src: dataUrl(p), cap: previewCaption(p) })), Number(shot.dataset.shot)); return; } const comp = t.closest('tr[data-comp]'); if (comp) { selectComponent(comp.dataset.comp, { focus: true, from: 'table' }); return; } const jr = t.closest('[data-joint]'); if (jr) selectJoint(jr.dataset.joint); }); $('#tab-body').addEventListener('keydown', (e) => { if (e.key !== 'Enter' && e.key !== ' ') return; const tr = e.target.closest('tr[data-comp], [data-joint]'); if (!tr || e.target !== tr) return; e.preventDefault(); tr.click(); }); $('#tab-body').addEventListener('input', (e) => { if (e.target.id === 'comp-filter') { S.compFilter = e.target.value; applyCompFilter(); } if (e.target.id === 'cap-filter') { S.capFilter = e.target.value; applyCapFilter(); } }); $('#pick-card').addEventListener('click', (e) => { const act = e.target.closest('[data-act]')?.dataset.act; if (act === 'deselect') clearSelection(); if (act === 'show-comp') { setTab('components'); $(`#tab-body tr[data-comp="${CSS.escape(S.selected || '')}"]`)?.scrollIntoView({ block: 'nearest' }); } }); $('#viewer-error').addEventListener('click', (e) => { if (e.target.closest('[data-act="retry"]')) { const tid = S.tid; S.tid = null; if (S.bundleError) bundleCache.delete(tid); selectInstrument(tid); } }); /* ======================================================================================= viewer toolbar */ function buildLayers() { const groups = [...new Set(LAYERS.map((l) => l.group))]; $('#layers').innerHTML = groups.map((g) => `
    ${esc(g)}${LAYERS.filter((l) => l.group === g).map((l) => ``).join('')}
    `).join('') + '

    Labels hide when their part is small on screen; zoom in to see them.

    '; updateLayerCount(); } function updateLayerCount() { const n = LAYERS.filter((l) => l.group === 'Annotations' && l.key !== 'labels' && S.layers[l.key]).length; $('#layers-count').textContent = String(n); $('#layers-count').title = `${n} annotation layers shown`; } function closeLayers() { if (!$('#layers').hidden) { $('#layers').hidden = true; $('#layers-btn').setAttribute('aria-expanded', 'false'); } } $('#layers-btn').addEventListener('click', () => { const open = $('#layers').hidden; $('#layers').hidden = !open; $('#layers-btn').setAttribute('aria-expanded', String(open)); }); $('#layers').addEventListener('change', (e) => { const k = e.target.dataset?.layer; if (!k) return; S.layers[k] = e.target.checked; applyLayers(); updateLayerCount(); }); document.addEventListener('pointerdown', (e) => { if (!e.target.closest('.vt-left')) closeLayers(); }); for (const b of $$('.vt-right [data-cam]')) b.addEventListener('click', () => fitView(b.dataset.cam)); document.addEventListener('keydown', (e) => { if (e.key !== 'Escape' || $('#lightbox').open) return; if (!$('#layers').hidden) { closeLayers(); $('#layers-btn').focus(); return; } clearSelection(); }); setTimeout(() => $('#hint').classList.add('gone'), 12000); /* ---------------------------------------------------------------------------- preview lightbox */ const LB = { items: [], i: 0 }; function openLightbox(items, i) { if (!items.length) return; LB.items = items; LB.i = clamp(i || 0, 0, items.length - 1); showLightboxItem(); const d = $('#lightbox'); if (!d.open) d.showModal(); } function showLightboxItem() { const it = LB.items[LB.i]; $('#lb-img').src = it.src; $('#lb-img').alt = it.cap; $('#lb-cap').textContent = `${it.cap} · ${LB.i + 1} of ${LB.items.length}`; $('#lb-prev').hidden = $('#lb-next').hidden = LB.items.length < 2; } const stepLightbox = (d) => { LB.i = (LB.i + d + LB.items.length) % LB.items.length; showLightboxItem(); }; $('#lb-prev').addEventListener('click', () => stepLightbox(-1)); $('#lb-next').addEventListener('click', () => stepLightbox(1)); $('#lb-close').addEventListener('click', () => $('#lightbox').close()); $('#lightbox').addEventListener('click', (e) => { if (e.target === e.currentTarget || e.target.classList.contains('lb-fig')) $('#lightbox').close(); }); $('#lightbox').addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft') { e.preventDefault(); stepLightbox(-1); } if (e.key === 'ArrowRight') { e.preventDefault(); stepLightbox(1); } }); $('#lightbox').addEventListener('close', () => { $('#lb-img').removeAttribute('src'); }); /* ---------------------------------------------------------------------------- toast */ let toastTimer = null; function toast(msg, ms = 6000) { const t = $('#toast'); t.textContent = msg; t.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.hidden = true; }, ms); } /* ======================================================================================= start */ async function init() { setupHeader(); buildLayers(); if (!GL) $('#hint').hidden = true; let idx; try { const r = await fetch('data/index.json'); if (!r.ok) throw new Error(`HTTP ${r.status}`); idx = await r.json(); } catch (e) { $('#asset-list').innerHTML = `
  • The instrument catalogue could not be loaded. ${esc(describeError(e))}
  • `; $('#inst-name').textContent = 'Catalogue unavailable'; $('#tab-body').innerHTML = '

    Reload the page to try again.

    '; showViewerError('The catalogue could not be loaded', `data/index.json could not be read. ${describeError(e)}`); return; } S.index = Array.isArray(idx.assets) ? idx.assets : []; S.domains = Array.isArray(idx.domains) ? idx.domains : []; for (const d of S.domains) S.domainMap.set(d.key, d); for (const a of S.index) { S.byTid.set(a.tid, a); a._hay = [a.name, a.maker, a.model, a.name_zh, a.chem_id, a.slug, a.tid, domainName(a.domain)].filter(Boolean).join(' ').toLowerCase(); } $('#tagline').textContent = `${S.index.length} simulation-ready chemistry lab instruments in 3D`; buildDomainSelect(); buildList(); applyFilter(); const { tid, asked } = tidFromLocation(); if (!tid && asked) toast(`“${asked}” is not an instrument in this release. Showing the fume hood instead.`); const start = tid || (S.byTid.has(DEFAULT_TID) ? DEFAULT_TID : S.index[0]?.tid); if (!start) return; const p = selectInstrument(start); requestAnimationFrame(() => scrollActiveIntoView()); await p; } init(); // Small read-only handle for automated checks and the browser console. window.__explorer = { version: '1.0', dataBase: DATA_BASE, space: SPACE_ID, state: S, THREE, camera, scene, assetRoot, get controls() { return controls; }, get pivots() { return pivots; }, get meshes() { return meshes; }, get compMeshes() { return compMeshes; }, setJoint, fitView, selectInstrument, selectComponent, setView, setTab, };