Download app.js from SharpMeow/unroll: direct link, hf CLI and curl.
- Browser
- Download file 129 kB
-
https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/app.js
- Command line
-
hf download hf://spaces/SharpMeow/unroll/app.js
-
curl -L -o app.js https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/app.js
129 kB
| import { VERSION } from './lib/version.js'; | |
| import { EMBED, setupEmbed, setupBridge, snippet } from './lib/embed.js'; | |
| import * as THREE from 'three'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; | |
| import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; | |
| import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; | |
| import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'; | |
| import { createTear } from './tear.js?v=20261016g'; | |
| import { loadRegistry, listLocal, getLocal, deleteLocal, localKey, remoteSource, fileSource, findManifest, buildMeshScroll, decodeImage } from './lib/scrolls.js'; | |
| import { validateManifest, peelOrder, layerWindings, layerFile, roleOf, fill, REGISTRY_LIMITS, LOCAL_LIMITS } from './lib/manifest.js'; | |
| import { setupOpenOwn, gatherFiles, filesFromURL, checkAndStore } from './lib/openown.js'; | |
| import { unpackInt16 } from './lib/formats.js'; | |
| import { createDamage, injectFrag, DMG_HEAD } from './damage.js?v=20261016g'; | |
| import { installAPI, ApiError } from './lib/api.js'; | |
| import { exampleZip } from './lib/example.js'; | |
| const $ = (id) => document.getElementById(id); | |
| // state comes from the query string and the hash (the hash wins); see lib/apispec.js URL_PARAMS | |
| const params = new URLSearchParams(location.search); | |
| for (const [k, v] of new URLSearchParams(location.hash.slice(1))) params.set(k, v); | |
| for (const [a, b] of [['scroll', 's'], ['layer', 'l']]) if (params.has(a)) params.set(b, params.get(a)); | |
| const SHOT = new URLSearchParams(location.search).has('shot'); // clean frame for the share image | |
| if (SHOT) document.body.classList.add('shot'); | |
| const MOBILE = matchMedia('(pointer: coarse)').matches || Math.min(innerWidth, innerHeight) < 600; | |
| const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches; | |
| // ---------------------------------------------------------------- loading | |
| const loaded = {}; | |
| const sizes = {}; | |
| function setLoad() { | |
| if (!$('loadbar')) return; // late loads (the ZeroGPU textures) arrive after the loader is gone | |
| const got = Object.values(loaded).reduce((a, b) => a + b, 0); | |
| const tot = Object.values(sizes).reduce((a, b) => a + b, 0) || 1; | |
| $('loadbar').style.width = `${Math.min(100, (100 * got) / tot).toFixed(1)}%`; | |
| $('loadtxt').textContent = `${(got / 1e6).toFixed(1)} / ${(tot / 1e6).toFixed(1)} MB`; | |
| } | |
| async function fetchBytes(url, guess) { | |
| sizes[url] = guess; | |
| let res; | |
| for (let i = 0; ; i++) { | |
| try { res = await fetch(url); break; } catch (e) { if (i >= 2) throw e; await new Promise((r) => setTimeout(r, 600 * (i + 1))); } | |
| } | |
| if (!res.ok) throw new Error(`${url}: ${res.status}`); | |
| const len = +res.headers.get('content-length'); | |
| if (len) sizes[url] = len; | |
| const reader = res.body.getReader(); | |
| const chunks = []; | |
| let got = 0; | |
| for (;;) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| chunks.push(value); | |
| got += value.length; | |
| loaded[url] = got; | |
| setLoad(); | |
| } | |
| const out = new Uint8Array(got); | |
| let o = 0; | |
| for (const c of chunks) { out.set(c, o); o += c.length; } | |
| return out.buffer; | |
| } | |
| async function loadTexture(url, guess) { | |
| const buf = await fetchBytes(url, guess); | |
| const bmp = await createImageBitmap(new Blob([buf])); | |
| const tex = new THREE.Texture(bmp); | |
| tex.flipY = false; | |
| tex.generateMipmaps = true; | |
| tex.minFilter = THREE.LinearMipmapLinearFilter; | |
| tex.magFilter = THREE.LinearFilter; | |
| tex.colorSpace = THREE.NoColorSpace; | |
| tex.needsUpdate = true; | |
| return tex; | |
| } | |
| function texFrom(img) { | |
| const tex = new THREE.Texture(img); | |
| tex.flipY = false; | |
| tex.generateMipmaps = true; | |
| tex.minFilter = THREE.LinearMipmapLinearFilter; | |
| tex.magFilter = THREE.LinearFilter; | |
| tex.colorSpace = THREE.NoColorSpace; | |
| tex.needsUpdate = true; | |
| return tex; | |
| } | |
| function shortLabel(l) { return l ? l.shortLabel || l.label : ''; } | |
| const um = (l) => (l.resolutionUM ? `${l.resolutionUM} µm` : ''); | |
| // keep µ lower case inside upper-case styled text (text-transform would turn it into a capital Mu, which reads as M) | |
| const muHTML = (x) => esc(x).replace(/µ/g, '<span class="mu">µ</span>'); | |
| const esc = (x) => String(x ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); | |
| // a load failure with a reason the visitor can act on; the error state in index.html shows it | |
| function loadFail(why, back) { | |
| window.__unrollWhy = why; | |
| if (back) { | |
| const b = document.createElement('button'); | |
| b.className = 'chip'; b.textContent = 'Back to the default scroll'; | |
| b.dataset.testid = 'load-back'; b.dataset.api = 'openScroll(default)'; | |
| b.onclick = () => { location.hash = ''; location.reload(); }; | |
| $('loadErr').appendChild(b); | |
| } | |
| // the scroll could not load, so the full API never installs; agents still get the state and the way out | |
| if (!window.unroll) { | |
| const fail = { ok: false, error: why, code: 'load_failed' }; | |
| const api = { | |
| version: async () => ({ ok: true, ...(await VERSION), loadError: why }), | |
| getState: async () => ({ ok: true, ready: false, loadError: why, url: location.href }), | |
| schema: async () => ({ ok: true, loadError: why, methods: { getState: {}, openScroll: {}, reload: {} } }), | |
| waitFor: async () => fail, | |
| reload: async () => { setTimeout(() => location.reload(), 0); return { ok: true }; }, | |
| openScroll: async (id) => { | |
| if (id !== undefined && typeof id !== 'string') return { ok: false, error: 'openScroll: id must be a string.', code: 'invalid' }; | |
| location.hash = id ? `s=${encodeURIComponent(id)}` : ''; setTimeout(() => location.reload(), 0); return { ok: true, id: id || null }; | |
| }, | |
| }; | |
| Object.defineProperty(window, 'unroll', { value: Object.freeze(api), configurable: false, writable: false }); | |
| } | |
| throw new Error(why); | |
| } | |
| // ---------------------------------------------------------------- which scroll: the registry, or one opened in this browser | |
| const REG = await loadRegistry(); | |
| const LOCALS = await listLocal(); | |
| const DEFAULT_ID = REG.default || REG.scrolls[0].id; | |
| let SCROLL_KEY = params.get('s') || DEFAULT_ID; | |
| let MAN, SRC, TRUSTED = true; | |
| if (localKey(SCROLL_KEY)) { | |
| const rec = await getLocal(SCROLL_KEY).catch(() => null); | |
| if (!rec) loadFail('This scroll was opened in another browser, or removed from this one. Scrolls you open stay on the device you opened them on.', true); | |
| try { | |
| const f = await findManifest(rec.files); | |
| MAN = f.manifest; SRC = fileSource(rec.files, f.path); TRUSTED = false; | |
| } catch (e) { loadFail(e.message, true); } | |
| } else { | |
| const want = SCROLL_KEY; | |
| const entry = REG.scrolls.find((x) => x.id === SCROLL_KEY) || REG.scrolls.find((x) => x.id === DEFAULT_ID); | |
| SCROLL_KEY = entry.id; | |
| if (want !== SCROLL_KEY) setTimeout(() => toast(`There is no scroll "${String(want).slice(0, 60)}" here, so ${entry.name || entry.id} opened instead. Scrolls: ${REG.scrolls.map((x) => x.id).join(', ')}.`), 600); | |
| MAN = await (await fetch(entry.manifest)).json(); | |
| SRC = remoteSource(entry.manifest, MAN, fetchBytes); | |
| } | |
| { | |
| const v = validateManifest(MAN, { trusted: TRUSTED, limits: TRUSTED ? REGISTRY_LIMITS : LOCAL_LIMITS }); | |
| if (!v.ok) loadFail(`The scroll's manifest has problems: ${v.errors.join(' ')}`, !TRUSTED); | |
| } | |
| const ORDER = peelOrder(MAN); | |
| const IDS = ORDER.map((w) => w.id); | |
| const L_CT = roleOf(MAN, 'ct'), L_INK = roleOf(MAN, 'ink'), L_OV = roleOf(MAN, 'overlay'); | |
| const OV_IDS = new Set(L_OV ? layerWindings(L_OV, IDS) : []); | |
| // the overlay's coverage is read from its winding list, so the text grows by itself as windings are added | |
| if (L_OV) { | |
| const ids = [...OV_IDS].sort(); | |
| const runs = []; | |
| for (const id of ids) { | |
| const n = +id.replace(/\D/g, ''), r = runs[runs.length - 1]; | |
| if (r && n === r.n1 + 1) { r.n1 = n; r.b = id; } else runs.push({ n0: n, n1: n, a: id, b: id }); | |
| } | |
| const span = runs.map((r) => (r.a === r.b ? r.a : `${r.a} to ${r.b}`)); | |
| const list = span.length > 1 ? `${span.slice(0, -1).join(', ')} and ${span[span.length - 1]}` : span[0] || ''; | |
| const words = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve']; | |
| const count = words[ids.length] || String(ids.length); | |
| for (const k of ['aboutHtml', 'title', 'hint']) if (typeof L_OV[k] === 'string') L_OV[k] = L_OV[k].replaceAll('{windings}', list).replaceAll('{count}', count); | |
| } | |
| const D = (TRUSTED && MAN.display) || null; | |
| $('loader').querySelector('.mono').textContent = D?.loaderKicker || `${MAN.name.toUpperCase()} · LOCAL FILE`; | |
| // ---------------------------------------------------------------- page text, credits, scroll picker, "Open your own" | |
| { | |
| // registry manifests may carry HTML; anything opened by a visitor is shown as plain text only | |
| const cr = MAN.credits || {}, dt = MAN.dataTerms || {}; | |
| const link = (t, u) => (u ? `<a href="${esc(u)}" target="_blank" rel="noopener">${esc(t)}</a>` : esc(t)); | |
| if (D) { | |
| if (D.pageTitle) document.title = D.pageTitle; | |
| if (D.kicker) document.querySelector('.hud.tl .kicker').innerHTML = D.kicker; | |
| if (D.titleHtml) document.querySelector('.hud.tl h1').innerHTML = D.titleHtml; | |
| if (D.lede) document.querySelector('.hud.tl .lede').innerHTML = D.lede; | |
| if (D.aboutHtml) document.querySelector('#about .about-body').innerHTML = D.aboutHtml.join('\n'); | |
| } else { | |
| document.title = `Unroll · ${MAN.name}`; | |
| document.querySelector('.hud.tl .kicker').textContent = `${MAN.name.toUpperCase()} / ${TRUSTED ? 'SCROLL' : 'LOCAL, NOT UPLOADED'}`; | |
| document.querySelector('.hud.tl h1').textContent = MAN.name; | |
| document.querySelector('.hud.tl .lede').textContent = `${MAN.description ? `${MAN.description} ` : ''}${TRUSTED ? '' : 'Opened from your own files, in this browser only: nothing was uploaded. '}Drag to look around. Scrub to unroll.`; | |
| const ab = document.querySelector('#about .about-body'); | |
| ab.innerHTML = ''; | |
| const P = (t, cls) => { const e = document.createElement('p'); if (cls) e.className = cls; e.textContent = t; ab.appendChild(e); return e; }; | |
| if (MAN.description) P(MAN.description); | |
| if (!TRUSTED) P('You opened this scroll from your own files. It was read and drawn by this page in your browser. Nothing was uploaded to any server; the files are kept in this browser until you remove them under Open your own.'); | |
| for (const l of MAN.layers) P(`${l.label}: ${[l.source, um(l), l.notes].filter(Boolean).join(', ')}`); | |
| if (cr.text || cr.citation) P(`Credits: ${[cr.text, cr.citation].filter(Boolean).join('. ')}${cr.url ? ` (${cr.url})` : ''}`, 'mono dim small'); | |
| if (dt.license || dt.notes) P(`Data terms: ${[dt.license, dt.notes].filter(Boolean).join('. ')}${dt.url ? ` (${dt.url})` : ''}`, 'mono dim small'); | |
| const ag = document.createElement('p'); ag.id = 'aboutGpu'; ag.hidden = true; ab.appendChild(ag); | |
| } | |
| const credit = document.querySelector('.credit'); | |
| credit.innerHTML = `Data ${link(cr.text || MAN.name, cr.url)}${dt.license ? ` · ${link(dt.license, dt.url)}` : ''}${TRUSTED ? '' : ' · <span class="local-tag">local, not uploaded</span>'}`; | |
| // the scroll picker: hidden while there is only one scroll to choose from | |
| // each registry entry may carry prize: { eligible, sets }, mirroring the badge on scrollprize.org's data browser | |
| const sel = $('scrollSel'); | |
| const isEligible = (k) => !!(REG.scrolls.find((x) => x.id === k)?.prize?.eligible); | |
| const opts = REG.scrolls.map((x) => [x.id, `${x.name || x.id}${x.prize?.eligible ? ' 🏆' : ''}`]).concat(LOCALS.map((x) => [x.key, `${x.name} (local)`])); | |
| let onlyEligible = params.get('prize') === '1'; | |
| const fill = () => { | |
| sel.innerHTML = ''; | |
| for (const [k, n] of opts) { | |
| if (onlyEligible && !isEligible(k) && k !== SCROLL_KEY) continue; | |
| const o = document.createElement('option'); o.value = k; o.textContent = n; sel.appendChild(o); | |
| } | |
| sel.value = SCROLL_KEY; | |
| }; | |
| fill(); | |
| $('scrollPick').hidden = opts.length < 2; | |
| sel.onchange = () => switchScroll(sel.value); | |
| const badge = $('prizeBadge'), cur = REG.scrolls.find((x) => x.id === SCROLL_KEY); | |
| badge.hidden = !isEligible(SCROLL_KEY); | |
| if (cur?.prize?.sets?.length) badge.dataset.sets = cur.prize.sets.join(', '); | |
| const tog = $('prizeOnly'); | |
| tog.hidden = !REG.scrolls.some((x) => x.prize?.eligible); | |
| tog.setAttribute('aria-pressed', String(onlyEligible)); | |
| const setOnly = (v) => { onlyEligible = !!v; tog.setAttribute('aria-pressed', String(onlyEligible)); tog.classList.toggle('on', onlyEligible); fill(); }; | |
| setOnly(onlyEligible); | |
| tog.onclick = () => setOnly(!onlyEligible); | |
| window.unrollScrolls = { | |
| list: () => REG.scrolls.map((x) => ({ id: x.id, name: x.name, prizeEligible: !!x.prize?.eligible, prizeSets: x.prize?.sets || [] })), | |
| current: () => SCROLL_KEY, | |
| isPrizeEligible: (id = SCROLL_KEY) => isEligible(id), | |
| setPrizeEligibleOnly: setOnly, | |
| prizeEligibleOnly: () => onlyEligible, | |
| visible: () => [...sel.options].map((o) => o.value), | |
| open: (id) => switchScroll(id), | |
| }; | |
| } | |
| // the text for the "Why N ink layers?" box, from a list of ink layers (role ink or overlay); null for fewer than two | |
| function inkLayerWhy(inks) { | |
| if (inks.length < 2) return null; | |
| const words = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten']; | |
| const n = words[inks.length] || String(inks.length); | |
| const plain = (t) => String(t || '').replace(/<[^>]*>/g, ''); | |
| const parts = inks.map((l) => { | |
| const res = l.resolutionUM ? ` on the ${l.resolutionUM} µm scan` : ''; | |
| return `${plain(l.label)}: ${plain(l.why || `${l.source || 'ink detection'}${res}`)}.`.replace(/\.\.$/, '.'); | |
| }); | |
| const r = inks.map((l) => l.resolutionUM).filter((x) => x > 0); | |
| let tail = ''; | |
| if (r.length > 1 && Math.max(...r) / Math.min(...r) >= 1.5) { | |
| tail = ` The finest is ${Math.min(...r)} µm and the coarsest ${Math.max(...r)} µm, about ${Math.round(Math.max(...r) / Math.min(...r))}x apart: finer scans give sharper ink, coarser ones show what cheaper scans can recover.`; | |
| } | |
| return { summary: `Why ${n} ink layers?`, text: `This scroll has ${n} ink layers. ${parts.join(' ')}${tail}` }; | |
| } | |
| // a short message at the bottom of the page, read out by screen readers | |
| function toast(msg, ms = 7000) { | |
| let el = document.getElementById('toast'); | |
| if (!el) { el = document.createElement('div'); el.id = 'toast'; el.className = 'toast mono'; el.setAttribute('role', 'status'); el.setAttribute('aria-live', 'polite'); document.body.appendChild(el); } | |
| el.textContent = msg; el.hidden = false; clearTimeout(toast.t); toast.t = setTimeout(() => { el.hidden = true; }, ms); | |
| } | |
| function switchScroll(key) { | |
| const h = new URLSearchParams(key && key !== DEFAULT_ID ? { s: key } : {}); | |
| location.hash = h.toString(); | |
| location.reload(); | |
| } | |
| const OPEN = setupOpenOwn({ $, onOpen: switchScroll, currentKey: SCROLL_KEY }); | |
| $('btnOpen').onclick = () => { lastOpener = $('btnOpen'); OPEN.open(); }; | |
| window.__unroll = { scroll: () => ({ key: SCROLL_KEY, id: MAN.id, trusted: TRUSTED, windings: IDS, layers: MAN.layers.map((l) => l.role), inkLayers: MAN.layers.filter((l) => l.role !== 'ct').length, prizeEligible: window.unrollScrolls?.isPrizeEligible() || false }), open: OPEN, scrolls: window.unrollScrolls, inkLayerWhy }; | |
| let M, binBuf = null, texInk, texCT, MESHB = null; | |
| const blankImg = () => { const c = document.createElement('canvas'); c.width = c.height = 4; return c; }; | |
| // a lighter copy of the mesh (geometry.lite) for phones and low-power devices; ?mesh=full or ?mesh=lite overrides | |
| const LITE = MAN.geometry.format === 'grid-int16' && !!MAN.geometry.lite && (params.get('mesh') === 'lite' || (MOBILE && params.get('mesh') !== 'full')); | |
| if (MAN.geometry.format === 'grid-int16') { | |
| const g = LITE ? { ...MAN.geometry, ...MAN.geometry.lite } : MAN.geometry; | |
| M = JSON.parse(new TextDecoder().decode(await SRC.bytes(g.index, 6e4))); | |
| // atlases: WebP first, and the JPG fallback if this browser cannot decode WebP or the file does not arrive | |
| const atl = async (l) => { | |
| if (!l) return texFrom(blankImg()); | |
| try { return texFrom(await createImageBitmap(new Blob([await SRC.bytes(l.atlas, l.sizeHint || 3e6)]))); } | |
| catch (e) { | |
| if (!l.atlasFallback) throw e; | |
| return texFrom(await createImageBitmap(new Blob([await SRC.bytes(l.atlasFallback, l.sizeHint || 3e6)]))); | |
| } | |
| }; | |
| // geometry: the packed int16 file first (about half the bytes), the plain .bin if it is missing or cannot be unpacked | |
| const geom = async () => { | |
| if (g.binPacked && typeof DecompressionStream === 'function') { | |
| try { return await unpackInt16(await SRC.bytes(g.binPacked, g.packedSizeHint || 1.7e6)); } catch (e) { console.warn('packed geometry failed, using', g.bin, e); } | |
| } | |
| return SRC.bytes(g.bin, g.sizeHint || 3.5e6); | |
| }; | |
| [binBuf, texInk, texCT] = await Promise.all([geom(), atl(L_INK), atl(L_CT)]); | |
| } else { | |
| try { | |
| MESHB = await buildMeshScroll(MAN, SRC, { limits: TRUSTED ? REGISTRY_LIMITS : LOCAL_LIMITS, atlasSide: MOBILE ? 2048 : 4096 }); | |
| } catch (e) { loadFail(e.message, !TRUSTED); } | |
| texInk = texFrom(MESHB.inkCanvas); texCT = texFrom(MESHB.ctCanvas); | |
| M = { voxelMM: MAN.voxelMM, cellMM: MESHB.cellMM, windings: MESHB.windings.map((w) => ({ ...w, seg: (ORDER.find((o) => o.id === w.id) || {}).mesh || '' })) }; | |
| } | |
| // per-winding stats for the overlay layer (registry scrolls only: it may carry HTML) | |
| let GPU = null; | |
| if (L_OV && L_OV.stats && TRUSTED) { | |
| try { GPU = JSON.parse(new TextDecoder().decode(await SRC.bytes(L_OV.stats, 4e3))); } catch (e) { /* no stats */ } | |
| } | |
| // the overlay layer's textures load the first time it is shown, winding by winding | |
| const gpuTex = {}; | |
| let ovLoading = null; | |
| const ovUploads = []; | |
| function drainOverlayUploads() { | |
| const t0 = performance.now(); | |
| while (ovUploads.length && (performance.now() - t0 < 6)) { | |
| const { id, bmp, done } = ovUploads.shift(); | |
| gpuTex[id] = texFrom(bmp); | |
| gpuTex[id].anisotropy = Math.min(8, maxAniso); | |
| renderer.initTexture(gpuTex[id]); | |
| const m = meshes.find((x) => x.userData.w.id === id); | |
| if (m) m.userData.own.uGpuTex.value = gpuTex[id]; | |
| done(); | |
| } | |
| } | |
| function loadOverlay() { | |
| if (!L_OV || ovLoading) return ovLoading; | |
| ovLoading = Promise.all([...OV_IDS].map(async (id) => { | |
| try { | |
| const p = layerFile(L_OV, id); | |
| const bmp = SRC.kind === 'files' ? await decodeImage(await SRC.blob(p), p) : await createImageBitmap(new Blob([await SRC.bytes(p, L_OV.sizeHint || 1.5e5)])); | |
| // decoded off the main thread; the GPU upload (and mipmaps) goes through a queue, a few ms per frame | |
| await new Promise((done) => ovUploads.push({ id, bmp, done })); | |
| } catch (e) { console.warn('overlay', id, e); } | |
| })); | |
| return ovLoading; | |
| } | |
| const blank = new THREE.DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1); | |
| blank.needsUpdate = true; | |
| // ---------------------------------------------------------------- renderer and scene | |
| const canvas = $('gl'); | |
| const renderer = new THREE.WebGLRenderer({ canvas, antialias: !MOBILE, powerPreference: 'high-performance' }); | |
| renderer.setPixelRatio(Math.min(devicePixelRatio, MOBILE ? 1.5 : 2)); | |
| renderer.setSize(innerWidth, innerHeight); | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 1.05; | |
| renderer.setClearColor(0x030303, 1); | |
| const maxAniso = renderer.capabilities.getMaxAnisotropy(); | |
| texInk.anisotropy = texCT.anisotropy = Math.min(8, maxAniso); | |
| const scene = new THREE.Scene(); | |
| const camera = new THREE.PerspectiveCamera(32, innerWidth / innerHeight, 1, 20000); | |
| const controls = new OrbitControls(camera, canvas); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.07; | |
| controls.minDistance = 25; | |
| controls.maxDistance = 9000; | |
| controls.zoomSpeed = 0.9; | |
| const composer = new EffectComposer(renderer); | |
| composer.addPass(new RenderPass(scene, camera)); | |
| const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.55, 0.45, 0.82); | |
| if (MOBILE) bloom.resolution.set(innerWidth / 2, innerHeight / 2); | |
| composer.addPass(bloom); | |
| composer.addPass(new OutputPass()); | |
| // ---------------------------------------------------------------- geometry | |
| const VOX = M.voxelMM; | |
| const CELL = M.cellMM; | |
| const all = binBuf ? new Int16Array(binBuf) : null; | |
| const shared = { | |
| uInk: { value: texInk }, uCT: { value: texCT }, | |
| uRot: { value: 0 }, uRoll: { value: new THREE.Vector3() }, | |
| uSweep: { value: 999 }, uLayer: { value: 1 }, uGpuMix: { value: 0 }, | |
| uTime: { value: 0 }, uLift: { value: 3.5 }, | |
| uDmgT: { value: 0 }, uPickW: { value: 0 }, uCrumble: { value: 0 }, | |
| uCTWin: { value: new THREE.Vector2(0, 1) }, // X-ray window (low, high), set by lib/ctwl.js | |
| }; | |
| const VERT = /* glsl */` | |
| uniform sampler2D uDmg; uniform float uDmgOn; | |
| attribute vec2 aLocal; | |
| uniform float uP, uSoft, uRot, uLift; | |
| uniform vec3 uRoll, uFlatO; | |
| uniform vec2 uSize; | |
| varying vec2 vLocal; | |
| varying vec3 vN; | |
| varying vec3 vW; | |
| varying float vE; | |
| void main() { | |
| float e = clamp((uP * (1.0 + uSoft) - aLocal.x) / uSoft, 0.0, 1.0); | |
| e = e * e * (3.0 - 2.0 * e); | |
| float c = cos(uRot), s = sin(uRot); | |
| vec3 pc = vec3(position.x * c + position.z * s, position.y, -position.x * s + position.z * c) + uRoll; | |
| vec3 nc = vec3(normal.x * c + normal.z * s, normal.y, -normal.x * s + normal.z * c); | |
| vec3 pf = uFlatO + vec3(aLocal.x * uSize.x, -aLocal.y * uSize.y, 0.0); | |
| vec3 p = mix(pc, pf, e); | |
| p.z -= sin(3.14159265 * e) * uLift; | |
| // soaked papyrus softens and sags | |
| // soaked charcoal swells a little and slumps where it turns to mush; it does not sag like wet paper | |
| if (uDmgOn > 0.5) { float wv = texture2D(uDmg, aLocal).a; p.z += wv * 0.35; p.y -= wv * wv * 0.35; } | |
| vec3 nm = mix(nc, vec3(0.0, 0.0, 1.0), e); | |
| vN = dot(nm, nm) > 1e-8 ? normalize(nm) : vec3(0.0, 0.0, 1.0); // a zero normal would normalize to NaN | |
| vLocal = aLocal; | |
| vE = e; | |
| vec4 w = modelMatrix * vec4(p, 1.0); | |
| vW = w.xyz; | |
| gl_Position = projectionMatrix * viewMatrix * w; | |
| }`; | |
| const FRAG = /* glsl */` | |
| uniform sampler2D uInk, uCT, uGpuTex; | |
| uniform vec4 uAtlas; | |
| uniform float uGpuOn, uGpuMix, uLayer, uSweep, uTime, uHi; | |
| uniform vec2 uCTWin; | |
| varying vec2 vLocal; | |
| varying vec3 vN; | |
| varying vec3 vW; | |
| varying float vE; | |
| void main() { | |
| vec2 auv = uAtlas.xy + vLocal * (uAtlas.zw - uAtlas.xy); | |
| float ct = texture2D(uCT, auv).r; | |
| float ink = texture2D(uInk, auv).r; | |
| float gink = texture2D(uGpuTex, vLocal).r; | |
| if (ct < 0.012 && ink < 0.02) discard; // outside the traced surface | |
| ct = clamp((ct - uCTWin.x) / max(uCTWin.y - uCTWin.x, 0.001), 0.0, 1.0); // window/level (lib/ctwl.js) | |
| vec3 n = normalize(vN); | |
| vec3 v = normalize(cameraPosition - vW); | |
| float ndl = abs(dot(n, normalize(vec3(-0.35, 0.75, 0.55)))); | |
| float ndv = abs(dot(n, v)); | |
| float rim = pow(max(1.0 - ndv, 0.0), 3.0); | |
| float diff = 0.28 + 0.72 * ndl; | |
| // X-ray: the CT intensity on the surface, cool and clinical | |
| vec3 xray = mix(vec3(0.015, 0.017, 0.02), vec3(0.58, 0.62, 0.66), pow(ct, 1.3)) * (0.55 + 0.45 * diff); | |
| xray += rim * vec3(0.25, 0.35, 0.45) * 0.35; | |
| // Papyrus: carbon-dark, the fibers still faintly there | |
| vec3 papy = mix(vec3(0.020, 0.015, 0.012), vec3(0.30, 0.22, 0.16), pow(ct, 1.6)) * diff; | |
| papy += rim * vec3(1.0, 0.45, 0.16) * 0.18; | |
| // a soft waxy sheen from the key light and a cool fill from below, so the carbon reads as a surface | |
| float spec = pow(abs(dot(n, normalize(normalize(vec3(-0.35, 0.75, 0.55)) + v))), 28.0); | |
| papy += spec * vec3(0.20, 0.15, 0.10) * (0.35 + ct); | |
| papy += vec3(0.010, 0.013, 0.018) * (0.5 - 0.5 * n.y); | |
| // far away the mipmaps average the strokes away; lower the thresholds so the glow survives | |
| float k = clamp(log2(max(length(fwidth(auv)) * 4096.0, 1.0)) / 3.0, 0.0, 1.0); | |
| float kg = clamp(log2(max(length(fwidth(vLocal)) * 900.0, 1.0)) / 3.0, 0.0, 1.0); | |
| float iv = smoothstep(mix(0.40, 0.21, k), mix(0.88, 0.52, k), ink); | |
| float gv = smoothstep(mix(0.36, 0.20, kg), mix(0.86, 0.50, kg), gink) * uGpuOn; | |
| vec3 amber = vec3(1.0, 0.56, 0.18); | |
| vec3 cyan = vec3(0.25, 0.90, 1.0); | |
| float dimAmber = mix(1.0, uGpuOn > 0.5 ? 0.0 : 0.22, uGpuMix); | |
| vec3 inked = papy + amber * iv * 1.45 * dimAmber + cyan * gv * 1.6 * uGpuMix; | |
| inked += cyan * (0.035 + rim * 0.25) * uGpuOn * uGpuMix; | |
| inked += vec3(1.0, 0.85, 0.6) * pow(iv, 3.0) * 0.45 * dimAmber; | |
| // the reveal: a scan line sweeps down; above it the ink shows | |
| float revealed = smoothstep(uSweep - 1.5, uSweep + 1.5, vW.y); | |
| float layer = uLayer * revealed; | |
| vec3 col = mix(xray, inked, layer); | |
| // pow() with a negative base is undefined in GLSL: real GPUs return NaN there, which the bloom pass smears over the | |
| // whole frame (color flashes, then black) during the intro sweep. Square it by hand. | |
| float sd = (vW.y - uSweep) / 0.35; | |
| float line = exp(-sd * sd); | |
| col += vec3(1.0, 0.7, 0.35) * line * 1.6; | |
| col += vec3(1.0, 0.6, 0.25) * uHi * 0.12; | |
| gl_FragColor = vec4(col, 1.0); | |
| }`; | |
| // picking: winding id and surface UV (or world position when uPickW is set); holes in the damage mask let picks through to deeper wraps | |
| const PICK_FRAG = DMG_HEAD + /* glsl */` | |
| uniform float uId, uPickW; | |
| uniform sampler2D uCT; | |
| uniform vec4 uAtlas; | |
| varying vec2 vLocal; | |
| varying vec3 vW; | |
| void main() { | |
| vec2 auv = uAtlas.xy + vLocal * (uAtlas.zw - uAtlas.xy); | |
| if (texture2D(uCT, auv).r < 0.012) discard; | |
| if (uCrumble > 0.0 && dCrumbleD(vLocal, uDmgMM) < uCrumble) discard; | |
| if (uDmgOn > 0.5) { float r = texture2D(uDmg, vLocal).r; if (r > 0.004 && dField(r, dFib(vLocal * uDmgMM), vLocal * uDmgMM) > 0.5) discard; } | |
| gl_FragColor = uPickW > 0.5 ? vec4(vW, 1.0) : vec4(uId / 255.0, vLocal, 1.0); | |
| }`; | |
| const FRAG_D = injectFrag(FRAG, 'vec3 n = normalize(vN);'); | |
| // windings in peel order: outermost first | |
| const wins = ORDER.map((o) => M.windings.find((w) => w.id === o.id)); | |
| if (wins.some((w) => !w)) loadFail(`The geometry file has no data for ${ORDER.filter((o, i) => !wins[i]).map((o) => o.id).join(', ')}.`, !TRUSTED); | |
| let S = 0; | |
| const gpuIds = OV_IDS; | |
| const meshes = []; | |
| const decim = MOBILE && !LITE ? 2 : 1; // the lite mesh is already coarse: use every vertex | |
| const GSTEP = M.step || 3; // grid step of the built mesh, in cells | |
| let Rmax = 0; | |
| // Keep only the real sheet: drop triangle islands (connected through shared vertices) that are tiny next to the | |
| // largest piece of the winding. Such islands are stray bits of the trace, often above the top edge, that float | |
| // free of the sheet once it is unrolled flat. The vertices themselves are untouched, so every kept triangle maps | |
| // rolled to flat exactly as before. | |
| function pruneIslands(index, nVerts, id, minFrac = 0.02, minTris = 120) { | |
| const nt = index.length / 3, parent = new Int32Array(nVerts); | |
| for (let i = 0; i < nVerts; i++) parent[i] = i; | |
| const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }; | |
| for (let t = 0; t < nt; t++) { | |
| const a = find(index[t * 3]), b = find(index[t * 3 + 1]), c = find(index[t * 3 + 2]); | |
| if (a !== b) parent[a] = b; | |
| const b2 = find(b); if (find(c) !== b2) parent[find(c)] = b2; | |
| } | |
| const count = new Map(); | |
| for (let t = 0; t < nt; t++) { const r = find(index[t * 3]); count.set(r, (count.get(r) || 0) + 1); } | |
| let big = 0; for (const v of count.values()) big = Math.max(big, v); | |
| const keepMin = Math.min(big, Math.max(minTris, big * minFrac)); | |
| const out = []; | |
| let dropped = 0; | |
| for (let t = 0; t < nt; t++) { | |
| if (count.get(find(index[t * 3])) >= keepMin) out.push(index[t * 3], index[t * 3 + 1], index[t * 3 + 2]); | |
| else dropped++; | |
| } | |
| islandStats.dropped += dropped; islandStats.kept += out.length / 3; | |
| if (dropped) islandStats.windings[id] = dropped; | |
| return out; | |
| } | |
| const islandStats = { dropped: 0, kept: 0, windings: {}, opened: {} }; | |
| function meshGeometry(w) { | |
| const geo = new THREE.BufferGeometry(); | |
| geo.setAttribute('position', new THREE.BufferAttribute(w.pos, 3)); | |
| geo.setAttribute('aLocal', new THREE.BufferAttribute(w.loc, 2)); | |
| geo.setIndex(pruneIslands(w.index, w.loc.length / 2, w.id)); | |
| geo.computeVertexNormals(); | |
| geo.computeBoundingSphere(); | |
| return { geo, ytop: w.ytop, colAng: w.colAng }; | |
| } | |
| function gridGeometry(w) { | |
| const R = w.rows, C = w.cols; | |
| const a = all.subarray(w.offset / 2, (w.offset + w.bytes) / 2); | |
| const pos = new Float32Array(R * C * 3); | |
| const loc = new Float32Array(R * C * 2); | |
| const valid = new Uint8Array(R * C); | |
| let ytop = 0, ny = 0; | |
| const colAng = new Float64Array(C); | |
| const colN = new Float64Array(C); | |
| const colX = new Float64Array(C), colZ = new Float64Array(C); | |
| for (let r = 0; r < R; r++) { | |
| for (let c = 0; c < C; c++) { | |
| const k = r * C + c; | |
| const x = a[k * 3], y = a[k * 3 + 1], z = a[k * 3 + 2]; | |
| loc[k * 2] = (w.colIdx[c] + 0.5) / w.gridW; | |
| loc[k * 2 + 1] = (w.rowIdx[r] + 0.5) / w.gridH; | |
| if (x === -32768) continue; | |
| valid[k] = 1; | |
| // scan (x, y, z) -> scene (x, -z, y): a proper rotation with row 0 at the top | |
| const X = x * VOX, Y = -z * VOX, Z = y * VOX; | |
| pos[k * 3] = X; pos[k * 3 + 1] = Y; pos[k * 3 + 2] = Z; | |
| colX[c] += X; colZ[c] += Z; colN[c] += 1; | |
| } | |
| } | |
| // top edge height for the flat layout: mean Y of the first few valid rows, extrapolated to row 0 | |
| for (let r = 0; r < R && ny < 40; r++) { | |
| for (let c = 0; c < C; c++) { | |
| const k = r * C + c; | |
| if (valid[k]) { ytop += pos[k * 3 + 1] + loc[k * 2 + 1] * w.heightMM; ny++; } | |
| } | |
| } | |
| ytop /= Math.max(ny, 1); | |
| // column angles around the axis, unwrapped | |
| let prev = null; | |
| for (let c = 0; c < C; c++) { | |
| if (!colN[c]) { colAng[c] = prev ?? 0; continue; } | |
| let ang = Math.atan2(colZ[c] / colN[c], colX[c] / colN[c]); | |
| if (prev !== null) while (ang - prev > Math.PI) ang -= 2 * Math.PI; | |
| if (prev !== null) while (ang - prev < -Math.PI) ang += 2 * Math.PI; | |
| colAng[c] = ang; | |
| prev = ang; | |
| } | |
| // the grid's border columns hold no vertices: give leading ones the first real column's angle | |
| let f0 = 0; | |
| while (f0 < C && !colN[f0]) f0++; | |
| if (f0 < C) for (let c = 0; c < f0; c++) colAng[c] = colAng[f0]; | |
| // triangles, skipping holes and tears | |
| const idx = []; | |
| const tear = CELL * GSTEP * 3.2 * decim; | |
| const d2 = (p, q) => { | |
| const dx = pos[p * 3] - pos[q * 3], dy = pos[p * 3 + 1] - pos[q * 3 + 1], dz = pos[p * 3 + 2] - pos[q * 3 + 2]; | |
| return dx * dx + dy * dy + dz * dz; | |
| }; | |
| const t2 = tear * tear; | |
| // drop thin slivers of the traced surface (one or two cells wide): they render as spikes off the edge | |
| const solid = new Uint8Array(R * C); | |
| for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) { | |
| if (!valid[r * C + c]) continue; | |
| let n = 0; | |
| for (let dr = -decim; dr <= decim; dr += decim) for (let dc = -decim; dc <= decim; dc += decim) { | |
| const rr = r + dr, cc = c + dc; | |
| if (rr >= 0 && cc >= 0 && rr < R && cc < C && valid[rr * C + cc]) n++; | |
| } | |
| solid[r * C + c] = n >= 6 ? 1 : 0; | |
| } | |
| // morphological opening (erode to points with at least 8 of their 9 neighbors, grow back one step): spikes and | |
| // hairlines one or two samples wide, which the trace ran off the real edge, are removed; the sheet keeps its shape | |
| const OPEN = 9; | |
| { | |
| const er = new Uint8Array(R * C); | |
| for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) { | |
| if (!solid[r * C + c]) continue; | |
| let n = 0; | |
| for (let dr = -decim; dr <= decim; dr += decim) for (let dc = -decim; dc <= decim; dc += decim) { | |
| const rr = r + dr, cc = c + dc; | |
| if (rr >= 0 && cc >= 0 && rr < R && cc < C && solid[rr * C + cc]) n++; | |
| } | |
| er[r * C + c] = n >= OPEN ? 1 : 0; | |
| } | |
| for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) { | |
| if (!solid[r * C + c]) continue; | |
| let any = 0; | |
| for (let dr = -decim; dr <= decim && !any; dr += decim) for (let dc = -decim; dc <= decim; dc += decim) { | |
| const rr = r + dr, cc = c + dc; | |
| if (rr >= 0 && cc >= 0 && rr < R && cc < C && er[rr * C + cc]) { any = 1; break; } | |
| } | |
| if (!any) { const q = islandStats.opened[w.id] || (islandStats.opened[w.id] = { n: 0, u: 0, v: 1 }); q.n++; if (loc[(r * C + c) * 2 + 1] < q.v) { q.v = +loc[(r * C + c) * 2 + 1].toFixed(3); q.u = +loc[(r * C + c) * 2].toFixed(3); } } | |
| solid[r * C + c] = any; | |
| } | |
| } | |
| const ok = (p, q, r) => solid[p] && solid[q] && solid[r] && d2(p, q) < t2 && d2(q, r) < t2 && d2(p, r) < t2; | |
| for (let r = 0; r + decim < R; r += decim) { | |
| for (let c = 0; c + decim < C; c += decim) { | |
| const p00 = r * C + c, p01 = r * C + c + decim, p10 = (r + decim) * C + c, p11 = (r + decim) * C + c + decim; | |
| if (ok(p00, p10, p01)) idx.push(p00, p10, p01); | |
| if (ok(p01, p10, p11)) idx.push(p01, p10, p11); | |
| } | |
| } | |
| const geo = new THREE.BufferGeometry(); | |
| geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); | |
| geo.setAttribute('aLocal', new THREE.BufferAttribute(loc, 2)); | |
| geo.setIndex(pruneIslands(idx, R * C, w.id)); | |
| geo.computeVertexNormals(); | |
| geo.computeBoundingSphere(); | |
| return { geo, ytop, colAng }; | |
| } | |
| for (const [j, w] of wins.entries()) { | |
| const { geo, ytop, colAng } = w.pos ? meshGeometry(w) : gridGeometry(w); | |
| Rmax = Math.max(Rmax, w.radiusMM); | |
| const own = { | |
| uP: { value: 0 }, uSoft: { value: 14 / w.widthMM }, | |
| uFlatO: { value: new THREE.Vector3(S, ytop, 0) }, | |
| uSize: { value: new THREE.Vector2(w.widthMM, w.heightMM) }, | |
| uAtlas: { value: new THREE.Vector4(...w.atlas) }, | |
| uGpuTex: { value: gpuTex[w.id] || blank }, uGpuOn: { value: gpuIds.has(w.id) ? 1 : 0 }, | |
| uHi: { value: 0 }, uId: { value: j + 1 }, | |
| uDmg: { value: blank }, uDmgOn: { value: 0 }, uDmgMM: { value: new THREE.Vector2(w.widthMM, w.heightMM) }, | |
| }; | |
| const mat = new THREE.ShaderMaterial({ | |
| uniforms: { ...shared, ...own }, vertexShader: VERT, fragmentShader: FRAG_D, side: THREE.DoubleSide, | |
| }); | |
| const pickMat = new THREE.ShaderMaterial({ | |
| uniforms: { ...shared, ...own }, vertexShader: VERT, fragmentShader: PICK_FRAG, side: THREE.DoubleSide, | |
| }); | |
| const mesh = new THREE.Mesh(geo, mat); | |
| mesh.frustumCulled = false; | |
| mesh.userData = { w, j, own, mat, pickMat, start: S, colAng, colIdx: w.colIdx }; | |
| scene.add(mesh); | |
| meshes.push(mesh); | |
| S += w.widthMM; | |
| } | |
| const L = S; | |
| let bandStart = 1, bandEnd = 0; | |
| for (const m of meshes) { | |
| if (!gpuIds.has(m.userData.w.id)) continue; | |
| bandStart = Math.min(bandStart, m.userData.start / L); | |
| bandEnd = Math.max(bandEnd, (m.userData.start + m.userData.w.widthMM) / L); | |
| } | |
| const stripZ = -(Rmax + 3.5); | |
| for (const m of meshes) m.userData.own.uFlatO.value.z = stripZ; | |
| // ---------------------------------------------------------------- the floor under the scroll, and surface points | |
| // The sheet is placed by the vertex shader (VERT); this is the same mapping on the CPU, so debris can start from a | |
| // point on the deformed sheet and land on a floor just below wherever the scroll is now, rolled or unrolled. | |
| const FLOOR_GAP = 8; // mm below the lowest point of the sheet; the flat sandbox uses the same gap | |
| for (const m of meshes) { | |
| const g = m.geometry, P = g.attributes.position.array, A = g.attributes.aLocal.array, n = A.length / 2; | |
| let rolledMin = Infinity, vMax = 0; | |
| for (let i = 0; i < n; i++) { rolledMin = Math.min(rolledMin, P[i * 3 + 1]); vMax = Math.max(vMax, A[i * 2 + 1]); } | |
| // nearest-vertex lookup in UV: a coarse bucket grid, each bucket holding one vertex | |
| const B = 96, bucket = new Int32Array(B * B).fill(-1); | |
| for (let i = 0; i < n; i++) { | |
| const bx = Math.min(B - 1, Math.max(0, Math.floor(A[i * 2] * B))), by = Math.min(B - 1, Math.max(0, Math.floor(A[i * 2 + 1] * B))); | |
| if (bucket[by * B + bx] < 0) bucket[by * B + bx] = i; | |
| } | |
| m.userData.surf = { rolledMin, vMax, B, bucket }; | |
| } | |
| function nearestVertex(m, u, v) { | |
| const { B, bucket } = m.userData.surf, A = m.geometry.attributes.aLocal.array; | |
| const bx = Math.min(B - 1, Math.max(0, Math.floor(u * B))), by = Math.min(B - 1, Math.max(0, Math.floor(v * B))); | |
| let best = -1, bd = Infinity; | |
| for (let r = 0; r < B && best < 0; r++) { // grow the ring until a bucket has a vertex (holes in the traced sheet) | |
| for (let y = by - r; y <= by + r; y++) for (let x = bx - r; x <= bx + r; x++) { | |
| if (x < 0 || y < 0 || x >= B || y >= B || (Math.abs(x - bx) !== r && Math.abs(y - by) !== r)) continue; | |
| const i = bucket[y * B + x]; if (i < 0) continue; | |
| const d = (A[i * 2] - u) ** 2 + (A[i * 2 + 1] - v) ** 2; | |
| if (d < bd) { bd = d; best = i; } | |
| } | |
| } | |
| return best; | |
| } | |
| // world position (and outward normal) of the sheet at UV (u, v) of winding m, as drawn right now | |
| function surfacePoint(m, u, v, out, nrm) { | |
| const i = nearestVertex(m, u, v); | |
| if (i < 0) return null; | |
| const P = m.geometry.attributes.position.array, N = m.geometry.attributes.normal.array, own = m.userData.own; | |
| const soft = own.uSoft.value, e0 = Math.min(1, Math.max(0, (own.uP.value * (1 + soft) - u) / soft)), e = e0 * e0 * (3 - 2 * e0); | |
| const c = Math.cos(shared.uRot.value), sn = Math.sin(shared.uRot.value), R = shared.uRoll.value, F = own.uFlatO.value, S = own.uSize.value; | |
| const px = P[i * 3], py = P[i * 3 + 1], pz = P[i * 3 + 2]; | |
| const cx = px * c + pz * sn + R.x, cy = py + R.y, cz = -px * sn + pz * c + R.z; | |
| const fx = F.x + u * S.x, fy = F.y - v * S.y, fz = F.z; | |
| out.set(cx + (fx - cx) * e, cy + (fy - cy) * e, cz + (fz - cz) * e - Math.sin(Math.PI * e) * shared.uLift.value); | |
| if (nrm) { | |
| const nx = N[i * 3] * c + N[i * 3 + 2] * sn, ny = N[i * 3 + 1], nz = -N[i * 3] * sn + N[i * 3 + 2] * c; | |
| nrm.set(nx + (0 - nx) * e, ny + (0 - ny) * e, nz + (1 - nz) * e).normalize(); | |
| } | |
| return out; | |
| } | |
| // lowest point of the sheet as it is drawn now: rolled part from the geometry, unrolled part from the flat layout | |
| function sheetBottom() { | |
| let lo = Infinity; | |
| for (const m of meshes) { | |
| const { own } = m.userData, { rolledMin, vMax } = m.userData.surf, p = own.uP.value; | |
| const flatMin = own.uFlatO.value.y - vMax * own.uSize.value.y; | |
| if (p < 1) lo = Math.min(lo, rolledMin + shared.uRoll.value.y); | |
| if (p > 0) lo = Math.min(lo, flatMin); | |
| } | |
| return lo - 0.4; // soaked papyrus slumps a little | |
| } | |
| let groundY = NaN; | |
| function updateGround(dt) { | |
| const want = sheetBottom() - FLOOR_GAP; | |
| // follow the scroll down at once, ease up so landed debris is lifted, never buried | |
| groundY = Number.isNaN(groundY) || want < groundY ? want : groundY + (want - groundY) * Math.min(1, dt * 4); | |
| } | |
| const floorMat = new THREE.ShaderMaterial({ | |
| transparent: true, depthWrite: false, | |
| uniforms: { uC: { value: new THREE.Vector2() }, uR: { value: 160 }, uA: { value: 0 } }, | |
| vertexShader: `varying vec2 vXZ; void main(){ vec4 w = modelMatrix * vec4(position, 1.0); vXZ = w.xz; gl_Position = projectionMatrix * viewMatrix * w; }`, | |
| fragmentShader: `uniform vec2 uC; uniform float uR, uA; varying vec2 vXZ; | |
| void main(){ float d = length((vXZ - uC) / vec2(uR, uR * 0.6)); float a = uA * (1.0 - smoothstep(0.15, 1.0, d)); | |
| if (a < 0.003) discard; gl_FragColor = vec4(vec3(0.16, 0.13, 0.10), a); }`, | |
| }); | |
| const floor = new THREE.Mesh(new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2), floorMat); | |
| floor.frustumCulled = false; floor.renderOrder = -1; | |
| scene.add(floor); | |
| const _fc = new THREE.Vector3(); | |
| // a soft pool of light on the floor under the part of the scroll in view, shown in Destroy mode | |
| function updateFloor(dt, on) { | |
| floorMat.uniforms.uA.value += ((on ? 0.22 : 0) - floorMat.uniforms.uA.value) * Math.min(1, dt * 4); | |
| floor.visible = floorMat.uniforms.uA.value > 0.004; | |
| if (!floor.visible) return; | |
| _fc.copy(controls.target); | |
| const r = Math.max(120, camera.position.distanceTo(controls.target) * 1.4); | |
| floor.position.set(_fc.x, groundY - 0.02, _fc.z); | |
| floor.scale.set(r * 2.2, 1, r * 2.2); | |
| floorMat.uniforms.uC.value.set(_fc.x, _fc.z); floorMat.uniforms.uR.value = r; | |
| } | |
| updateGround(0); | |
| const damage = createDamage({ THREE, scene, meshes, MOBILE, ground: () => groundY, uDmgT: shared.uDmgT, uCrumble: shared.uCrumble, surfacePoint }); | |
| // ---------------------------------------------------------------- tear apart (just for fun) | |
| let tear = null, tearOn = false; | |
| function setTear(on) { | |
| tearOn = on; | |
| if (on && !tear) tear = createTear({ THREE, scene, camera, controls, canvas, meshes, shared, FRAG: FRAG_D, stripZ, ctImage: texCT.image, MOBILE, damage }); | |
| for (const m of meshes) m.visible = !on; | |
| if (tear) tear.group.visible = on; | |
| $('btnSandbox').classList.toggle('on', on); | |
| $('btnSandbox').setAttribute('aria-pressed', on); | |
| $('btnShake').hidden = !on; | |
| for (const id of ['toolBurn', 'toolWater']) $(id).disabled = on; | |
| if (on && (tool === 'burn' || tool === 'water')) setTool('tear'); | |
| if (tear) tear.setCrush(tool === 'crush'); | |
| document.body.classList.toggle('tearing', on); | |
| if (on) { | |
| introDone(); autoplayAt = 0; setPlaying(false); | |
| t = tShown = 1; setTarget(1, 'tear'); | |
| // close enough to grab: a few windings around the middle of the strip | |
| mode = 'overview'; | |
| const tg = new THREE.Vector3(L * 0.5, -meshes[0].userData.w.heightMM * 0.5 + meshes[0].userData.own.uFlatO.value.y, stripZ); | |
| flyTo(tg.clone().add(new THREE.Vector3(0, 25, MOBILE ? 330 : 240)), tg, 1.8); | |
| $('tip').hidden = true; | |
| if (hover) { hover.userData.own.uHi.value = 0; hover = null; } | |
| } | |
| } | |
| // unwrap the peel angle across windings so the roll turns continuously | |
| let lastEnd = null; | |
| for (const m of meshes) { | |
| const A = m.userData.colAng; | |
| if (lastEnd !== null) { | |
| const k = Math.round((lastEnd - A[0]) / (2 * Math.PI)); | |
| for (let i = 0; i < A.length; i++) A[i] += 2 * Math.PI * k; | |
| } | |
| lastEnd = A[A.length - 1]; | |
| } | |
| // ---------------------------------------------------------------- the unroll | |
| let t = 0; // 0 rolled .. 1 unrolled | |
| let tShown = 0; | |
| let rot = null; | |
| const FRONT = -Math.PI / 2; // the peel point faces the strip, behind the roll | |
| function stateAt(tt) { | |
| const s = tt * L; | |
| let cur = meshes[meshes.length - 1]; | |
| for (const m of meshes) { | |
| const { start, w } = m.userData; | |
| if (s < start + w.widthMM) { cur = m; break; } | |
| } | |
| const { start, w, colAng, colIdx } = cur.userData; | |
| const p = Math.min(1, Math.max(0, (s - start) / w.widthMM)); | |
| const g = p * w.gridW; | |
| let i = 0; | |
| while (i < colIdx.length - 1 && colIdx[i + 1] < g) i++; | |
| const f = colIdx.length > 1 ? Math.min(1, Math.max(0, (g - colIdx[i]) / Math.max(1, (colIdx[i + 1] ?? colIdx[i]) - colIdx[i]))) : 0; | |
| const ang = colAng[i] + ((colAng[i + 1] ?? colAng[i]) - colAng[i]) * f; | |
| return { s, cur, p, rot: ang - FRONT, radius: w.radiusMM }; | |
| } | |
| function applyT(tt, dt) { | |
| const st = stateAt(tt); | |
| for (const m of meshes) { | |
| const { start, w, own } = m.userData; | |
| own.uP.value = Math.min(1, Math.max(0, (st.s - start) / w.widthMM)); | |
| } | |
| if (rot === null) rot = st.rot; | |
| const k = dt ? 1 - Math.exp(-dt * 10) : 1; | |
| rot += (st.rot - rot) * k; | |
| if (!Number.isFinite(rot)) rot = st.rot; // never stay stuck on a bad value | |
| shared.uRot.value = rot; | |
| const rz = -(Rmax - st.radius) * 0.85; | |
| shared.uRoll.value.set(st.s, 0, rz); | |
| return st; | |
| } | |
| // ---------------------------------------------------------------- UI: scrubber, layers, readout | |
| const ticks = $('ticks'); | |
| for (const m of meshes) { | |
| const i = document.createElement('i'); | |
| i.style.left = `${(100 * m.userData.start) / L}%`; | |
| if (gpuIds.has(m.userData.w.id)) i.className = 'gpu'; | |
| ticks.appendChild(i); | |
| } | |
| { | |
| const outer = wins[0].id, inner = wins[wins.length - 1].id; | |
| $('scrub').querySelector('.ends').innerHTML = `<span><span class="long">ROLLED · OUTERMOST ${esc(outer)}</span><span class="short">OUTER ${esc(outer)}</span></span><span><span class="long">INNERMOST ${esc(inner)} · UNROLLED</span><span class="short">INNER ${esc(inner)}</span></span>`; | |
| } | |
| let playing = false; | |
| let target = t; | |
| // the one place the unroll position changes: slider, scrubber drag, keys, buttons and code all go through here | |
| function setTarget(v, src) { | |
| target = Math.min(1, Math.max(0, +v || 0)); | |
| if (src !== 'slider') $('scrubIn').value = Math.round(target * 1000); | |
| } | |
| const DURATION = 70; // seconds for a full unroll | |
| function setPlaying(v) { | |
| playing = v; | |
| $('play').classList.toggle('playing', v); | |
| $('play').setAttribute('aria-pressed', v); | |
| $('play').setAttribute('aria-label', v ? 'Pause the unroll' : 'Unroll'); | |
| $('playIcon').innerHTML = v ? '<path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" fill="currentColor"/>' | |
| : '<path d="M7 5l12 7-12 7z" fill="currentColor"/>'; | |
| // replay from the end: rewind smoothly from where it is, never jump | |
| if (v && target >= 0.999) setTarget(0, 'play'); | |
| } | |
| $('play').onclick = () => { introDone(); autoplayAt = 0; setRolling(false); setPlaying(!playing); }; | |
| // roll it back up: a steady rewind to fully rolled, any time | |
| let rolling = false; | |
| function setRolling(v) { | |
| rolling = v; | |
| $('btnRollUp').textContent = v ? 'Stop' : 'Roll up'; | |
| $('btnRollUp').classList.toggle('on', v); | |
| } | |
| $('btnRollUp').onclick = () => { | |
| introDone(); autoplayAt = 0; | |
| if (rolling) { setRolling(false); return; } | |
| if (tearOn) setTear(false); | |
| setPlaying(false); | |
| if (target > 0.0005) setRolling(true); | |
| }; | |
| const scrub = $('scrub'); | |
| let scrubbing = false; | |
| function scrubTo(ev) { | |
| const r = scrub.getBoundingClientRect(); | |
| setTarget((ev.clientX - r.left) / r.width, 'drag'); | |
| } | |
| scrub.addEventListener('pointerdown', (e) => { introDone(); autoplayAt = 0; setRolling(false); scrubbing = true; setPlaying(false); scrub.setPointerCapture(e.pointerId); scrubTo(e); }); | |
| scrub.addEventListener('pointermove', (e) => { if (scrubbing) scrubTo(e); }); | |
| scrub.addEventListener('pointerup', () => { scrubbing = false; setTarget(target, 'drag'); saveHash(); }); | |
| scrub.addEventListener('pointercancel', () => { scrubbing = false; setTarget(target, 'drag'); }); | |
| addEventListener('keydown', (e) => { | |
| if (e.code === 'Escape') { closeSheets(); return; } | |
| if (e.target.closest('input,textarea,select')) return; | |
| if (!$('focus').hidden || !$('about').hidden || !$('openSheet').hidden) return; | |
| if (e.code === 'Space') { | |
| if (e.target.closest('button,a,[role=tab],[role=button]')) return; // let focused controls act natively | |
| e.preventDefault(); introDone(); autoplayAt = 0; setPlaying(!playing); | |
| } | |
| if (e.code === 'ArrowRight') { introDone(); autoplayAt = 0; setTarget(target + 0.01, 'key'); } | |
| if (e.code === 'ArrowLeft') { introDone(); autoplayAt = 0; setTarget(target - 0.01, 'key'); } | |
| }); | |
| // a native range input over the scrubber, for keyboards and screen readers | |
| const scrubIn = $('scrubIn'); | |
| // a pointer drag on the scrubber owns the position; the native slider only drives it from the keyboard or assistive tech | |
| scrubIn.addEventListener('input', () => { if (scrubbing) { scrubIn.value = Math.round(target * 1000); return; } introDone(); autoplayAt = 0; setPlaying(false); setTarget(scrubIn.value / 1000, 'slider'); }); | |
| scrubIn.addEventListener('change', saveHash); | |
| scrubIn.addEventListener('keydown', (e) => { if (e.code.startsWith('Arrow')) e.stopPropagation(); }); | |
| let layer = 'ink'; | |
| // the three layer chips map to the manifest's roles: xray = ct, ink = ink, gpu = overlay | |
| const CHIP_LAYER = { xray: L_CT, ink: L_INK, gpu: L_OV }; | |
| const LAYER_HINTS = {}; | |
| for (const [k, l] of Object.entries(CHIP_LAYER)) { | |
| const chip = document.querySelector(`.chip[data-layer="${k}"]`); | |
| if (!l) { chip.hidden = true; continue; } | |
| LAYER_HINTS[k] = l.hint || [l.label, l.source, um(l)].filter(Boolean).join(', '); | |
| chip.title = l.title || LAYER_HINTS[k]; | |
| chip.innerHTML = (k === 'gpu' ? '<i class="dot"></i>' : '') + muHTML(shortLabel(l)); | |
| chip.setAttribute('aria-label', l.label); | |
| } | |
| if (!L_INK) layer = L_OV ? 'gpu' : 'xray'; | |
| let layerT = 1, gpuT = 0; | |
| function setLayer(l) { | |
| layer = l; | |
| for (const b of document.querySelectorAll('.chip[data-layer]')) { b.classList.toggle('on', b.dataset.layer === l); b.setAttribute('aria-selected', b.dataset.layer === l); } | |
| const hint = $('layerHint'); | |
| if (hint) hint.textContent = LAYER_HINTS[l] || ''; | |
| if (l === 'gpu') loadOverlay(); | |
| saveHash(); | |
| } | |
| for (const b of document.querySelectorAll('.chip[data-layer]')) { | |
| b.onclick = () => { | |
| introDone(); | |
| setLayer(b.dataset.layer); | |
| // the ZeroGPU band sits mid-roll: unroll far enough to lay it flat | |
| if (b.dataset.layer === 'gpu' && target < bandEnd + 0.01) { autoplayAt = 0; setPlaying(false); setTarget(bandEnd + 0.015, 'layer'); } | |
| }; | |
| } | |
| $('layerHint').textContent = LAYER_HINTS[layer] || ''; | |
| // "Why N ink layers?": written from the manifest's own ink layers, so the count and the wording always match the scroll | |
| { | |
| const inks = MAN.layers.filter((l) => l.role === 'ink' || l.role === 'overlay'); | |
| const why = inkLayerWhy(inks); | |
| $('layerWhy').hidden = !why; | |
| if (why) { $('layerWhy').querySelector('summary').textContent = why.summary; $('layerWhy').querySelector('p').textContent = why.text; } | |
| } | |
| if (L_OV && OV_IDS.size) { | |
| $('gpuChip').hidden = false; | |
| const band = document.createElement('div'); | |
| band.className = 'band'; | |
| band.style.left = `${bandStart * 100}%`; | |
| band.style.width = `${(bandEnd - bandStart) * 100}%`; | |
| band.title = L_OV.bandTitle || `Windings with ${L_OV.label}`; | |
| $('scrub').appendChild(band); | |
| if (GPU) { | |
| $('rGpuRow').hidden = false; | |
| $('rGpuRow').querySelector('.dim').textContent = L_OV.readoutLabel || 'OVERLAY'; | |
| } | |
| if (GPU && (L_OV.aboutHtml || GPU.about)) { | |
| const a = $('aboutGpu'); | |
| a.hidden = false; | |
| a.innerHTML = L_OV.aboutHtml || GPU.about; | |
| } | |
| } else $('gpuChip').hidden = true; | |
| function hashState(withCam) { | |
| const h = new URLSearchParams({ ...(SCROLL_KEY !== DEFAULT_ID ? { s: SCROLL_KEY } : {}), t: target.toFixed(3), l: layer }); | |
| if (focusW && !$('focus').hidden) { | |
| h.set('focus', focusW.id); | |
| const v = focusView(); | |
| if (v === 'compare') h.set('compare', (+$('fSwipe').value / 100).toFixed(2)); else if (v !== 'main') h.set('view', v); | |
| } | |
| const pn = openPanel(); | |
| if (pn) h.set('panel', pn); | |
| if (destroyOn) h.set('destroy', '1'); | |
| if (destroyOn && tool !== 'tear') h.set('tool', tool); | |
| if (tearOn) h.set('sandbox', '1'); | |
| if (withCam) h.set('cam', [...camera.position.toArray(), ...controls.target.toArray()].map((x) => +x.toFixed(2)).join(',')); | |
| return h; | |
| } | |
| function saveHash() { | |
| history.replaceState(null, '', `#${hashState(false)}`); | |
| } | |
| // ---------------------------------------------------------------- camera choreography | |
| const look = new THREE.Vector3(); | |
| let mode = 'roll'; // roll: follow the roll; overview: the whole strip; free: an API or deep-link camera, left alone | |
| let fly = null; | |
| // view directions onto the roll; the distance and a sideways shift come from fitView(): the roll's own height and | |
| // width are fitted into the part of the screen the control panel, header and scrubber leave free | |
| const OPEN_STEP = 1 / Math.pow(0.95, controls.zoomSpeed); // about 1.047 at zoomSpeed 0.9 | |
| const ROLL_DIR = new THREE.Vector3(-105, 52, 235).normalize(); | |
| const ROLL_DIR_PORTRAIT = new THREE.Vector3(-70, 70, 330).normalize(); | |
| const ROLL_OFF = new THREE.Vector3(), ROLL_SHIFT = new THREE.Vector3(); | |
| const rollOffset = () => ROLL_OFF; | |
| let FIT = null; | |
| // roll height (robust: 0.5 and 99.5 percentiles of the drawn vertices, so stray trace points do not count) | |
| const ROLL_Y = (() => { | |
| const ys = []; | |
| for (const m of meshes) { | |
| const I = m.geometry.index.array, P = m.geometry.attributes.position.array; | |
| for (let i = 0; i < I.length; i += 23) ys.push(P[I[i] * 3 + 1]); | |
| } | |
| ys.sort((a, b) => a - b); | |
| return ys.length ? [ys[Math.floor(ys.length * 0.005)], ys[Math.floor(ys.length * 0.995)]] : [-40, 40]; | |
| })(); | |
| // the free rectangle in CSS pixels: the viewport minus the control panel (right column or bottom sheet), the header | |
| // on narrow screens and the scrubber row | |
| function freeRect() { | |
| const W = innerWidth, H = innerHeight, r = { x0: 0, y0: 0, x1: W, y1: H }; | |
| const nav = document.querySelector('nav.hud.tr'); | |
| const n = nav && getComputedStyle(nav).display !== 'none' ? nav.getBoundingClientRect() : null; | |
| if (n && n.width && n.height) { | |
| if (n.left > W * 0.45 && n.width < W * 0.6) r.x1 = Math.min(r.x1, n.left - 8); | |
| else if (n.top > H * 0.3) r.y1 = Math.min(r.y1, n.top - 8); | |
| } | |
| // on narrow screens the readout card sits right above the panel, across the roll | |
| const ro = $('readout'); | |
| if (ro && W < 720) { const q = ro.getBoundingClientRect(); if (q.height && q.top > H * 0.4) r.y1 = Math.min(r.y1, q.top - 8); } | |
| const sc = document.getElementById('scrub'); | |
| if (sc) { const q = sc.getBoundingClientRect(); if (q.height && q.top > H * 0.5) r.y1 = Math.min(r.y1, q.top - 12); } | |
| const tl = document.querySelector('.hud.tl'); | |
| if (W < 900 || camera.aspect < 1) for (const el of [document.querySelector('.hud.tl'), document.querySelector('.credit')]) { | |
| const q = el && el.getBoundingClientRect(); | |
| if (q && q.height && q.bottom < H * 0.4) r.y0 = Math.max(r.y0, q.bottom + 8); | |
| } | |
| if (r.x1 - r.x0 < W * 0.3) { r.x0 = 0; r.x1 = W; } | |
| if (r.y1 - r.y0 < H * 0.3) { r.y0 = 0; r.y1 = H; } | |
| return r; | |
| } | |
| // the world shift that puts the target at screen point (nx, ny) in NDC, for a camera at dir * D from it | |
| function ndcShift(dir, D, nx, ny, out) { | |
| const fwd = dir.clone().negate(), right = new THREE.Vector3().crossVectors(fwd, camera.up).normalize(); | |
| const up = new THREE.Vector3().crossVectors(right, fwd); | |
| const tv = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2); | |
| return out.copy(right).multiplyScalar(-nx * tv * camera.aspect * D).addScaledVector(up, -ny * tv * D); | |
| } | |
| function fitView() { | |
| const W = innerWidth, H = innerHeight, portrait = camera.aspect < 1, fr = freeRect(); | |
| const dir = portrait ? ROLL_DIR_PORTRAIT : ROLL_DIR; | |
| const tv = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2); | |
| const fw = (fr.x1 - fr.x0) / W, fh = (fr.y1 - fr.y0) / H; | |
| const rh = ROLL_Y[1] - ROLL_Y[0], rw = 2 * Rmax; | |
| // seen from a little above: the projected height is about the height plus a share of the diameter | |
| const elev = Math.asin(Math.min(1, dir.y)); | |
| const ph = rh * Math.cos(elev) + rw * Math.sin(elev); | |
| // OPEN_STEP: open one OrbitControls wheel step (at devicePixelRatio 1) farther out than the plain fit | |
| const D = OPEN_STEP * Math.max(ph / 2 / (tv * fh * 0.78), rw / 2 / (tv * camera.aspect * fw * (portrait ? 0.7 : 0.35)), 60); | |
| ROLL_OFF.copy(dir).multiplyScalar(D); | |
| // landscape: the roll sits right of the free area's center, so the emerging strip (to its left) has room | |
| const px = fr.x0 + (fr.x1 - fr.x0) * (portrait ? 0.54 : 0.62), py = (fr.y0 + fr.y1) / 2; | |
| ndcShift(dir, D, px / W * 2 - 1, 1 - py / H * 2, ROLL_SHIFT); | |
| // the intro's view down the axis, centered in the same free area | |
| const td = new THREE.Vector3(0, 120, 7), tD = OPEN_STEP * Math.max(120, td.length() * 0.5 / Math.min(fw, fh)); | |
| td.setLength(tD); | |
| const tx = (fr.x0 + fr.x1) / 2; | |
| TOP_TARGET.set(0, -2, 0).add(ndcShift(td.clone().normalize(), tD, tx / W * 2 - 1, 1 - py / H * 2, new THREE.Vector3())); | |
| TOP_POS.copy(TOP_TARGET).add(td); | |
| FIT = { free: [fr.x0, fr.y0, fr.x1, fr.y1].map(Math.round), distance: +D.toFixed(1), rollY: ROLL_Y.map((v) => +v.toFixed(1)), rollW: +rw.toFixed(1), screen: [Math.round(px), Math.round(py)] }; | |
| return FIT; | |
| } | |
| window.__unrollFrame = () => FIT; | |
| window.__unrollLite = LITE; | |
| function flyTo(pos, tgt, dur = 2.4) { | |
| fly = { p0: camera.position.clone(), t0: controls.target.clone(), p1: pos, t1: tgt, start: performance.now(), dur: dur * 1000 }; | |
| } | |
| function overview() { | |
| const vf = THREE.MathUtils.degToRad(camera.fov); | |
| const hf = 2 * Math.atan(Math.tan(vf / 2) * camera.aspect); | |
| const portrait = camera.aspect < 1; | |
| const tgt = new THREE.Vector3(L * (portrait ? 0.12 : 0.30), -6, stripZ); | |
| const pos = new THREE.Vector3(-150, portrait ? 150 : 110, stripZ + (portrait ? 520 : 360)); | |
| return [pos, tgt]; | |
| } | |
| let intro = !params.has('t') && !REDUCED && !SHOT && !localKey(SCROLL_KEY); | |
| if (!intro) shared.uSweep.value = -999; | |
| // the intro opens looking straight down the roll's axis, where the windings read as a spiral | |
| const TOP_TARGET = new THREE.Vector3(), TOP_POS = new THREE.Vector3(); | |
| fitView(); | |
| let introStart = performance.now(); | |
| function introDone() { | |
| if (!intro) return; | |
| intro = false; | |
| $('skipIntro')?.remove(); | |
| shared.uSweep.value = -999; | |
| const f = framing(stateAt(t)); | |
| flyTo(f.clone().add(rollOffset()), f, 1.6); | |
| } | |
| controls.addEventListener('start', () => { introDone(); fly = null; autoplayAt = 0; }); | |
| function framing(st) { | |
| // the roll's center (it moves along x as it unrolls and in z as it thins), shifted into the free part of the screen | |
| const rz = -(Rmax - st.radius) * 0.85; | |
| return new THREE.Vector3(st.s, (ROLL_Y[0] + ROLL_Y[1]) / 2, rz).add(ROLL_SHIFT); | |
| } | |
| if (params.has('t')) { | |
| t = tShown = Math.min(1, Math.max(0, +params.get('t') || 0)); setTarget(t, 'hash'); | |
| { const lk = { xray: 'xray', ct: 'xray', ink: 'ink', gpu: 'gpu', overlay: 'gpu' }[params.get('l')]; if (lk && CHIP_LAYER[lk]) setLayer(lk); } | |
| if (layer === 'gpu') await loadOverlay(); | |
| shared.uSweep.value = -999; | |
| } | |
| { | |
| const st = applyT(t); | |
| const f = framing(st); | |
| if (intro) { controls.target.copy(TOP_TARGET); camera.position.copy(TOP_POS); camera.lookAt(TOP_TARGET); } | |
| else { controls.target.copy(f); camera.position.copy(f).add(rollOffset()); camera.lookAt(f); } | |
| } | |
| // refit when the panel, header or scrubber settle or change size; the camera follows only until the user moves it | |
| let camTouched = false; | |
| controls.addEventListener('start', () => { camTouched = true; }); | |
| function refit() { | |
| const before = JSON.stringify(FIT); | |
| fitView(); | |
| if (JSON.stringify(FIT) === before || intro || mode !== 'roll' || camTouched || tearOn) return; | |
| const f = framing(stateAt(t)); | |
| if (fly) { fly.t1 = f; fly.p1 = f.clone().add(ROLL_OFF); } else flyTo(f.clone().add(ROLL_OFF), f, 0.6); | |
| } | |
| if (typeof ResizeObserver === 'function') { | |
| const ro = new ResizeObserver(() => refit()); | |
| for (const el of [document.querySelector('nav.hud.tr'), document.querySelector('.hud.tl'), document.querySelector('.credit'), $('readout'), $('scrub')]) if (el) ro.observe(el); | |
| } | |
| // ---------------------------------------------------------------- picking, hover, focus view | |
| const pickTarget = new THREE.WebGLRenderTarget(1, 1); | |
| const pickBuf = new Uint8Array(4); | |
| function pick(clientX, clientY) { | |
| const prevClear = renderer.getClearColor(new THREE.Color()).getHex(); // was undefined here: hover threw on every move | |
| const dpr = renderer.getPixelRatio(); | |
| camera.setViewOffset(innerWidth * dpr, innerHeight * dpr, clientX * dpr, clientY * dpr, 1, 1); | |
| for (const m of meshes) m.material = m.userData.pickMat; | |
| renderer.setRenderTarget(pickTarget); | |
| renderer.setClearColor(0x000000, 1); | |
| renderer.clear(); | |
| renderer.render(scene, camera); | |
| renderer.readRenderTargetPixels(pickTarget, 0, 0, 1, 1, pickBuf); | |
| renderer.setRenderTarget(null); | |
| renderer.setClearColor(prevClear, 1); | |
| for (const m of meshes) m.material = m.userData.mat; | |
| camera.clearViewOffset(); | |
| const id = pickBuf[0]; | |
| return id ? meshes[id - 1] : null; | |
| } | |
| let hover = null; | |
| let hoverAt = 0; | |
| let down = null; | |
| canvas.addEventListener('pointerdown', (e) => { if (tearOn || destroyOn) return; down = { x: e.clientX, y: e.clientY, t: performance.now() }; }); | |
| canvas.addEventListener('pointerup', (e) => { | |
| if (!down || tearOn) return; | |
| const moved = Math.hypot(e.clientX - down.x, e.clientY - down.y); | |
| if (moved < 6 && performance.now() - down.t < 500) { | |
| const m = pick(e.clientX, e.clientY); | |
| if (m) openFocus(m); | |
| } | |
| down = null; | |
| }); | |
| canvas.addEventListener('pointermove', (e) => { | |
| if (MOBILE || e.buttons || tearOn || destroyOn) return; | |
| const now = performance.now(); | |
| if (now - hoverAt < 70) return; | |
| hoverAt = now; | |
| const m = pick(e.clientX, e.clientY); | |
| if (hover && hover !== m) hover.userData.own.uHi.value = 0; | |
| hover = m; | |
| const tip = $('tip'); | |
| if (m) { | |
| m.userData.own.uHi.value = 1; | |
| const w = m.userData.w; | |
| tip.hidden = false; | |
| tip.style.left = `${e.clientX}px`; | |
| tip.style.top = `${e.clientY}px`; | |
| tip.innerHTML = `${w.id.toUpperCase()} · r ${w.radiusMM.toFixed(1)} mm · ${w.areaCM2.toFixed(0)} cm²${gpuIds.has(w.id) ? ` · <span style="color:var(--gpu)">${esc(L_OV.readoutLabel || L_OV.label.toUpperCase())}</span>` : ''} <span class="dim">· click to read</span>`; | |
| canvas.style.cursor = 'pointer'; | |
| } else { | |
| tip.hidden = true; | |
| canvas.style.cursor = ''; | |
| } | |
| }); | |
| canvas.addEventListener('pointerleave', () => { $('tip').hidden = true; if (hover) hover.userData.own.uHi.value = 0; hover = null; }); | |
| // pan and zoom image viewer | |
| const viewer = $('viewer'); | |
| const img = $('fImg'); | |
| let vz = { s: 1, x: 0, y: 0 }; | |
| var focusW = null; // var: saveHash may run before this line during start-up | |
| function vApply() { | |
| const tf = `translate(${vz.x}px, ${vz.y}px) scale(${vz.s})`; | |
| img.style.transform = tf; | |
| const img2 = $('fImg2'); | |
| img2.style.transform = tf; | |
| // the compare image is drawn over the right part of the picture, split at the swipe position | |
| if (!img2.hidden) { | |
| const f = +$('fSwipe').value / 100; | |
| img2.style.clipPath = `inset(0 0 0 ${f * 100}%)`; | |
| $('fSwipeLine').style.left = `${vz.x + f * img.naturalWidth * vz.s}px`; | |
| } | |
| // scale bar: a round number of millimeters about 90 to 180 px long | |
| if (focusW && img.naturalWidth) { | |
| const pxPerMM = (img.naturalWidth * vz.s) / focusW.widthMM; | |
| let mm = 1; | |
| for (const c of [0.5, 1, 2, 5, 10, 20, 50]) if (c * pxPerMM <= 180) mm = c; | |
| $('fScale').querySelector('i').style.width = `${mm * pxPerMM}px`; | |
| $('fScale').querySelector('span').textContent = `${mm} mm`; | |
| } | |
| } | |
| $('fSwipe').addEventListener('input', vApply); | |
| $('fSwipe').addEventListener('pointerdown', (e) => e.stopPropagation()); | |
| img.addEventListener('error', () => { if (img.getAttribute('src')) $('fErr').hidden = false; }); | |
| img.addEventListener('load', () => { $('fErr').hidden = true; }); | |
| function vFit() { | |
| const r = viewer.getBoundingClientRect(); | |
| const s = Math.min(r.width / img.naturalWidth, r.height / img.naturalHeight) * 0.96; | |
| vz = { s, x: (r.width - img.naturalWidth * s) / 2, y: (r.height - img.naturalHeight * s) / 2 }; | |
| vApply(); | |
| } | |
| img.onload = () => { if (!img.dataset.keep) vFit(); img.dataset.keep = ''; }; | |
| viewer.addEventListener('wheel', (e) => { | |
| e.preventDefault(); | |
| const r = viewer.getBoundingClientRect(); | |
| const mx = e.clientX - r.left, my = e.clientY - r.top; | |
| const k = Math.exp(-e.deltaY * 0.0015); | |
| vz.x = mx - (mx - vz.x) * k; vz.y = my - (my - vz.y) * k; vz.s *= k; | |
| vApply(); | |
| }, { passive: false }); | |
| const ptrs = new Map(); | |
| let pinch = null; | |
| viewer.addEventListener('pointerdown', (e) => { viewer.setPointerCapture(e.pointerId); ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY }); }); | |
| viewer.addEventListener('pointermove', (e) => { | |
| if (!ptrs.has(e.pointerId)) return; | |
| const p = ptrs.get(e.pointerId); | |
| if (ptrs.size === 1) { vz.x += e.clientX - p.x; vz.y += e.clientY - p.y; vApply(); } | |
| ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY }); | |
| if (ptrs.size === 2) { | |
| const [a, b] = [...ptrs.values()]; | |
| const d = Math.hypot(a.x - b.x, a.y - b.y); | |
| const r = viewer.getBoundingClientRect(); | |
| const cx = (a.x + b.x) / 2 - r.left, cy = (a.y + b.y) / 2 - r.top; | |
| if (pinch) { | |
| const k = d / pinch; | |
| vz.x = cx - (cx - vz.x) * k; vz.y = cy - (cy - vz.y) * k; vz.s *= k; | |
| vApply(); | |
| } | |
| pinch = d; | |
| } | |
| }); | |
| const endPtr = (e) => { ptrs.delete(e.pointerId); if (ptrs.size < 2) pinch = null; }; | |
| viewer.addEventListener('pointerup', endPtr); | |
| viewer.addEventListener('pointercancel', endPtr); | |
| viewer.addEventListener('dblclick', vFit); | |
| // which close-up tab is showing: main, overlay or compare | |
| function focusView() { | |
| const on = [...$('fSeg').children].findIndex((b) => b.classList.contains('on')); | |
| const k = $('fSeg').children[on]?.dataset.view; | |
| return k === 'gpu' ? 'overlay' : k === 'cmp' ? 'compare' : 'main'; | |
| } | |
| function openFocus(m) { | |
| introDone(); | |
| autoplayAt = 0; | |
| setPlaying(false); | |
| const w = m.userData.w; | |
| $('fTitle').textContent = w.id; | |
| const setSrc = async (el, which) => { | |
| const u = await hiURL(which, w); | |
| if (focusW === w) el.src = u; | |
| }; | |
| $('fKicker').textContent = `TRACED WINDING ${wins.length - m.userData.j} OF ${wins.length}, INNERMOST FIRST · ${w.seg}`; | |
| const seg = $('fSeg'); | |
| seg.innerHTML = ''; | |
| const g = gpuIds.has(w.id) ? (GPU && GPU.windings[w.id]) || {} : null; | |
| const base = L_INK ? 'ink' : 'ct'; | |
| const views = [['vc', closeLabel(L_INK || L_CT), base]]; | |
| if (g) views.push(['gpu', closeLabel(L_OV), 'overlay']); | |
| if (g && views.length) views.push(['cmp', 'Compare (swipe)', base]); | |
| $('fSwipeA').innerHTML = muHTML(shortLabel(L_INK || L_CT)); | |
| $('fSwipeB').innerHTML = muHTML(shortLabel(L_OV)); | |
| $('fSwipe').setAttribute('aria-label', `Swipe between ${shortLabel(L_INK || L_CT)} and ${shortLabel(L_OV)}`); | |
| focusW = w; | |
| $('fErr').hidden = true; | |
| const setCmp = (on) => { | |
| $('fImg2').hidden = $('fSwipeWrap').hidden = $('fSwipeLine').hidden = !on; | |
| if (on) { $('fImg2').onload = vApply; setSrc($('fImg2'), 'overlay'); } | |
| }; | |
| setCmp(false); | |
| for (const [k, label, src] of views) { | |
| const b = document.createElement('button'); | |
| b.className = `chip${k === 'gpu' ? ' gpu' : ''}`; | |
| b.innerHTML = muHTML(label); | |
| b.dataset.view = k; | |
| const vname = k === 'gpu' ? 'overlay' : k === 'cmp' ? 'compare' : 'main'; | |
| b.dataset.testid = `closeup-tab-${vname}`; | |
| b.dataset.api = k === 'cmp' ? 'compare(true, pos)' : `focus(winding, { view: "${vname}" })`; | |
| b.setAttribute('role', 'tab'); | |
| b.onclick = () => { | |
| for (const x of seg.children) { x.classList.toggle('on', x === b); x.setAttribute('aria-selected', x === b); } | |
| img.dataset.keep = '1'; | |
| setSrc(img, src); | |
| setCmp(k === 'cmp'); | |
| $('fMeta').innerHTML = k === 'gpu' ? ovMeta(w, g) : k === 'cmp' ? `Left of the line: ${esc(closeLabel(L_INK || L_CT))}. Right: ${esc(closeLabel(L_OV))}. Drag the swipe control to compare.` : vcMeta(w); | |
| }; | |
| seg.appendChild(b); | |
| } | |
| const start = g && layer === 'gpu' ? 1 : 0; | |
| seg.children[start].classList.add('on'); | |
| for (const x of seg.children) x.setAttribute('aria-selected', x === seg.children[start]); | |
| img.dataset.keep = ''; | |
| setSrc(img, views[start][2]); | |
| $('fMeta').innerHTML = start ? ovMeta(w, g) : vcMeta(w); | |
| $('focus').hidden = false; | |
| $('fClose').focus(); | |
| saveHash(); | |
| } | |
| const closeLabel = (l) => (l ? l.closeupLabel || [l.label, um(l)].filter(Boolean).join(' · ') : ''); | |
| // shortLabel is a hoisted function below (the chips use it before this point) | |
| // the close-up image of a layer for one winding: its "hi" file, else the layer's own texture, else the atlas rectangle | |
| const hiCache = new Map(); | |
| async function hiURL(which, w) { | |
| const key = `${which}:${w.id}`; | |
| if (hiCache.has(key)) return hiCache.get(key); | |
| const l = which === 'overlay' ? L_OV : which === 'ink' ? L_INK : L_CT; | |
| let u; | |
| if (l && l.hi) u = SRC.kind === 'files' ? await SRC.imageURL(fill(l.hi, w.id)) : SRC.url(fill(l.hi, w.id)); | |
| else if (l && l.texture !== undefined || (l && l.files)) u = await SRC.imageURL(layerFile(l, w.id)); | |
| else u = await atlasCrop(which === 'ct' ? texCT : texInk, w); | |
| hiCache.set(key, u); | |
| return u; | |
| } | |
| async function atlasCrop(tex, w) { | |
| const im = tex.image, [a, b, c, d] = w.atlas; | |
| const cv = document.createElement('canvas'); | |
| cv.width = Math.max(1, Math.round((c - a) * im.width)); cv.height = Math.max(1, Math.round((d - b) * im.height)); | |
| cv.getContext('2d').drawImage(im, a * im.width, b * im.height, cv.width, cv.height, 0, 0, cv.width, cv.height); | |
| return new Promise((ok) => cv.toBlob((bl) => ok(URL.createObjectURL(bl)), 'image/png')); | |
| } | |
| function ovMeta(w, g) { | |
| if (g && g.meta) return g.meta; | |
| return `${esc(L_OV.label)}${L_OV.source ? ` · ${esc(L_OV.source)}` : ''}${um(L_OV) ? ` · ${um(L_OV)}` : ''} · drag to pan, scroll or pinch to zoom, double-click to fit`; | |
| } | |
| function vcMeta(w) { | |
| const l = L_INK || L_CT; | |
| const note = l.notes || [l === L_INK ? 'ink' : 'X-ray', l.source].filter(Boolean).join(': '); | |
| return `${w.widthMM.toFixed(0)} × ${w.heightMM.toFixed(0)} mm · ${w.areaCM2.toFixed(1)} cm² · radius ${w.radiusMM.toFixed(1)} mm · ` + | |
| `${esc(note)} · drag to pan, scroll or pinch to zoom, double-click to fit`; | |
| } | |
| let lastOpener = null; | |
| function closeSheets() { | |
| const wasOpen = !$('focus').hidden || !$('about').hidden || !$('openSheet').hidden || !$('embedSheet').hidden; | |
| $('focus').hidden = true; | |
| $('embedSheet').hidden = true; | |
| $('about').hidden = true; | |
| $('openSheet').hidden = true; | |
| if (wasOpen && lastOpener) lastOpener.focus(); | |
| if (wasOpen) saveHash(); | |
| } | |
| $('fClose').onclick = closeSheets; | |
| $('btnRead').onclick = () => { lastOpener = $('btnRead'); openFocus(stateAt(t).cur); }; | |
| $('btnInfo').onclick = () => { | |
| introDone(); | |
| lastOpener = $('btnInfo'); | |
| $('about').hidden = !$('about').hidden; | |
| if (!$('about').hidden) $('aClose').focus(); | |
| saveHash(); | |
| }; | |
| $('aClose').onclick = closeSheets; | |
| // ---------------------------------------------------------------- capture | |
| function composeShot() { | |
| // the WebGL frame plus the page's title and credit, drawn at the canvas's own resolution | |
| if (SHOT) { // a share-image render may come before any animation frame: settle the layer blend now | |
| layerT = layer === 'xray' ? 0 : 1; | |
| gpuT = layer === 'gpu' ? 1 : 0; | |
| shared.uLayer.value = layerT; | |
| shared.uGpuMix.value = gpuT; | |
| controls.update(); | |
| } | |
| composer.render(); | |
| const src = renderer.domElement; | |
| const c = document.createElement('canvas'); | |
| c.width = src.width; c.height = src.height; | |
| const g = c.getContext('2d'); | |
| g.drawImage(src, 0, 0); | |
| const s = c.width / innerWidth; | |
| const grad = g.createRadialGradient(0, 0, 0, 0, 0, 640 * s); | |
| grad.addColorStop(0, 'rgba(6,5,5,0.78)'); | |
| grad.addColorStop(1, 'rgba(6,5,5,0)'); | |
| g.fillStyle = grad; | |
| g.fillRect(0, 0, c.width, c.height); | |
| const x = 28 * s; | |
| g.fillStyle = 'rgba(141,133,122,1)'; | |
| g.font = `${Math.round(11 * s)}px "JetBrains Mono", monospace`; | |
| const SH = D?.shot || { kicker: `${MAN.name.toUpperCase()} / OPENED LOCALLY`, line1: MAN.name, line2: '', accent: '', footer: `data: ${MAN.credits?.text || MAN.name}${MAN.dataTerms?.license ? `, ${MAN.dataTerms.license}` : ''}` }; | |
| g.fillText(SH.kicker, x, 40 * s); | |
| const fs = Math.round(Math.min(64, Math.max(34, innerWidth * 0.046)) * s); | |
| g.font = `${fs}px "Instrument Serif", Georgia, serif`; | |
| g.fillStyle = 'rgba(236,230,219,1)'; | |
| g.fillText(SH.line1, x, 40 * s + fs * 1.05); | |
| const l2 = SH.line2; | |
| g.fillText(l2, x, 40 * s + fs * 2.03); | |
| const w2 = g.measureText(l2).width; | |
| g.font = `italic ${fs}px "Instrument Serif", Georgia, serif`; | |
| g.fillStyle = '#ffb347'; | |
| g.fillText(SH.accent, x + w2, 40 * s + fs * 2.03); | |
| g.fillStyle = 'rgba(141,133,122,1)'; | |
| g.font = `${Math.round(11 * s)}px "JetBrains Mono", monospace`; | |
| g.fillText(`${(tShown * L / 1000).toFixed(2)} m of traced strip unrolled · ${SH.footer}`, x, c.height - 26 * s); | |
| return c; | |
| } | |
| $('btnShot').onclick = () => { | |
| composeShot().toBlob((b) => { | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(b); | |
| a.download = `${MAN.id.toLowerCase().replace(/[^a-z0-9_-]+/g, '-')}-unroll-${Math.round(tShown * 100)}.png`; | |
| a.click(); | |
| setTimeout(() => URL.revokeObjectURL(a.href), 4000); | |
| }, 'image/png'); | |
| }; | |
| // ---------------------------------------------------------------- Destroy: tear, crush, burn and soak at any unroll amount | |
| var destroyOn = false, tool = 'tear', act = null; // var: hashState can run before this line | |
| function setTool(tl) { | |
| tool = tl; | |
| for (const b of document.querySelectorAll('.chip.tool')) { const on = b.dataset.tool === tl; b.classList.toggle('on', on); b.setAttribute('aria-pressed', on); } | |
| document.body.classList.remove('tool-tear', 'tool-crush', 'tool-burn', 'tool-water'); | |
| if (destroyOn) document.body.classList.add(`tool-${tl}`); | |
| if (tear) tear.setCrush(tl === 'crush'); | |
| } | |
| function setDestroy(on) { | |
| destroyOn = on; | |
| $('btnTear').classList.toggle('on', on); | |
| $('btnTear').setAttribute('aria-pressed', on); | |
| for (const id of ['toolTear', 'btnCrush', 'toolBurn', 'toolWater', 'btnReset', 'btnCrumble', 'btnSandbox', 'btnSound']) $(id).hidden = !on; | |
| document.body.classList.toggle('destroy', on); | |
| if (!on && tearOn) setTear(false); | |
| if (!on) $('btnShake').hidden = true; | |
| setTool(tool); | |
| if (on) { introDone(); autoplayAt = 0; $('tip').hidden = true; if (hover) { hover.userData.own.uHi.value = 0; hover = null; } } | |
| } | |
| $('btnTear').onclick = () => setDestroy(!destroyOn); | |
| for (const b of document.querySelectorAll('.chip.tool')) b.onclick = () => setTool(b.dataset.tool); | |
| $('btnSandbox').onclick = () => setTear(!tearOn); | |
| $('btnReset').onclick = () => { damage.reset(); if (tear) tear.reset(); }; | |
| // Crumble: the whole scroll breaks up from its edges and comes down as a heap of dust (Reset restores it) | |
| function crumble() { | |
| if (tearOn) return false; | |
| if (!destroyOn) setDestroy(true); | |
| setPlaying(false); setRolling(false); autoplayAt = 0; | |
| return damage.crumble(); | |
| } | |
| $('btnCrumble').onclick = () => crumble(); | |
| // pick the surface under the pointer: winding, UV on it and world position, through any holes already made | |
| const pickF = new THREE.WebGLRenderTarget(1, 1, { type: THREE.FloatType }); | |
| const pickFB = new Float32Array(4); | |
| // GPUs that cannot render to float targets (no EXT_color_buffer_float) get a CPU pick on the deformed surface | |
| const FLOAT_PICK = renderer.extensions.has('EXT_color_buffer_float'); | |
| function cpuPick(x, y) { | |
| const p = new THREE.Vector3(), best = { d: 14 * 14, m: null, u: 0, v: 0, z: Infinity }; | |
| const test = (m, u, v) => { | |
| if (!surfacePoint(m, u, v, p)) return; | |
| const w = p.clone(); p.project(camera); | |
| if (p.z > 1) return; | |
| const dx = (p.x + 1) / 2 * innerWidth - x, dy = (1 - p.y) / 2 * innerHeight - y, d = dx * dx + dy * dy; | |
| const z = w.distanceTo(camera.position); | |
| if (d < best.d || (d < 36 && z < best.z - 2)) Object.assign(best, { d, m, u, v, z, world: w }); | |
| }; | |
| for (const m of meshes) if (m.visible) for (let i = 0; i <= 48; i++) for (let j = 0; j <= 12; j++) test(m, i / 48, j / 12); | |
| if (!best.m) return null; | |
| for (let r = 0, s = 1 / 96; r < 4; r++, s /= 2) { // refine around the best sample | |
| const { m, u, v } = best; | |
| for (const [a, b] of [[s, 0], [-s, 0], [0, s * 4], [0, -s * 4]]) test(m, Math.min(1, Math.max(0, u + a)), Math.min(1, Math.max(0, v + b))); | |
| } | |
| return { m: best.m, u: best.u, v: best.v, world: best.world }; | |
| } | |
| function pickHit(x, y) { | |
| if (!FLOAT_PICK) return cpuPick(x, y); | |
| const prevClear = renderer.getClearColor(new THREE.Color()).getHex(); | |
| const dpr = renderer.getPixelRatio(); | |
| camera.setViewOffset(innerWidth * dpr, innerHeight * dpr, x * dpr, y * dpr, 1, 1); | |
| for (const m of meshes) m.material = m.userData.pickMat; | |
| for (const o of damage.objects) o.visible = false; | |
| renderer.setRenderTarget(pickF); | |
| renderer.setClearColor(0x000000, 0); | |
| shared.uPickW.value = 0; | |
| renderer.clear(); renderer.render(scene, camera); | |
| renderer.readRenderTargetPixels(pickF, 0, 0, 1, 1, pickFB); | |
| const id = Math.round(pickFB[0] * 255), u = pickFB[1], v = pickFB[2]; | |
| let world = null; | |
| if (id) { | |
| shared.uPickW.value = 1; | |
| renderer.clear(); renderer.render(scene, camera); | |
| renderer.readRenderTargetPixels(pickF, 0, 0, 1, 1, pickFB); | |
| world = new THREE.Vector3(pickFB[0], pickFB[1], pickFB[2]); | |
| } | |
| shared.uPickW.value = 0; | |
| renderer.setRenderTarget(null); | |
| renderer.setClearColor(0x030303, 1); | |
| for (const m of meshes) m.material = m.userData.mat; | |
| for (const o of damage.objects) o.visible = true; | |
| camera.clearViewOffset(); | |
| if (!Number.isFinite(pickFB[0])) return cpuPick(x, y); | |
| return id && meshes[id - 1] ? { m: meshes[id - 1], u, v, world } : null; | |
| } | |
| function applyTool(first) { | |
| const h = pickHit(act.x, act.y); | |
| if (!h) { act.last = null; return; } | |
| const now = performance.now(); | |
| if (act.tool === 'tear') { | |
| const l = act.last; | |
| if (l && l.m === h.m) { | |
| const mm = Math.hypot((h.u - l.u) * h.m.userData.w.widthMM, (h.v - l.v) * h.m.userData.w.heightMM); | |
| if (mm < 0.4) return; | |
| if (mm < 18) { damage.tear(h.m, l.u, l.v, h.u, h.v); damage.debris(h.world, 0.8, mm > 2 ? 2 : 1, 'tear'); } | |
| } | |
| act.last = h; | |
| } else if (act.tool === 'crush') { | |
| // hold to press harder: a wider bite, and as the outer wrap gives way the next pick lands on the wrap beneath | |
| const hold = (now - act.t0) / 1000; | |
| const r = 2.2 + 1.8 * Math.min(hold, 2.5); | |
| if (!act.first) act.first = h.m; | |
| // the wraps a press has broken through to sit in shadow and crushed fiber: a crater, not a clean drill hole | |
| if (h.m !== act.first) damage.bruise(h.m, h.u, h.v, r * 1.3); | |
| damage.crush(h.m, h.u, h.v, r, 0.5 + 0.15 * Math.min(hold, 2)); | |
| damage.debris(h.world, r, MOBILE ? 2 : 4); | |
| } else if (act.tool === 'burn') { | |
| if (first) damage.ignite(h.m, h.u, h.v, h.world); | |
| } else if (act.tool === 'water') { | |
| // pouring: the longer you hold, the wider the wet patch; amounts follow wall time so slow frames pour the same | |
| const hold = (now - act.t0) / 1000, gap = Math.min(0.5, (now - (act.wt || now - 90)) / 1000); | |
| act.wt = now; | |
| damage.wetAt(h.m, h.u, h.v, 3 + 2 * Math.min(hold, 3), first ? 0.45 : Math.min(1, 0.22 * gap / 0.09)); | |
| damage.drops(h.world, first ? 3 : 1); | |
| } | |
| } | |
| function touchIcon(e) { | |
| const d = document.createElement('div'); | |
| d.className = `touch-pop ${tool}`; | |
| d.style.left = `${e.clientX}px`; d.style.top = `${e.clientY}px`; | |
| document.body.appendChild(d); | |
| setTimeout(() => d.remove(), 650); | |
| } | |
| canvas.addEventListener('pointerdown', (e) => { | |
| if (!destroyOn || tearOn || e.button > 0) return; | |
| act = { tool, x: e.clientX, y: e.clientY, t0: performance.now(), tick: performance.now(), last: null }; | |
| const h = pickHit(e.clientX, e.clientY); | |
| if (!h) { act = null; return; } | |
| e.stopImmediatePropagation(); | |
| controls.enabled = false; | |
| try { canvas.setPointerCapture(e.pointerId); } catch {} // synthetic API pointers have no capture | |
| autoplayAt = 0; setPlaying(false); setRolling(false); | |
| document.body.classList.add('pressing'); | |
| if (e.pointerType === 'touch') touchIcon(e); | |
| applyTool(true); | |
| }, true); | |
| canvas.addEventListener('pointermove', (e) => { | |
| if (!act) return; | |
| act.x = e.clientX; act.y = e.clientY; | |
| if (act.tool === 'tear' && performance.now() - act.tick > 24) { act.tick = performance.now(); applyTool(false); } | |
| }); | |
| const endAct = () => { if (!act) return; act = null; controls.enabled = true; document.body.classList.remove('pressing'); }; | |
| canvas.addEventListener('pointerup', endAct); | |
| canvas.addEventListener('pointercancel', endAct); | |
| document.body.classList.add('js-tools'); | |
| $('btnShake').onclick = () => tear && tear.shake(); | |
| $('btnSound').onclick = () => { | |
| const on = $('btnSound').getAttribute('aria-pressed') !== 'true'; | |
| $('btnSound').setAttribute('aria-pressed', on); | |
| $('btnSound').textContent = on ? 'Sound on' : 'Sound off'; | |
| $('btnSound').classList.toggle('on', on); | |
| tear && tear.setSound(on); | |
| }; | |
| // ---------------------------------------------------------------- loop | |
| addEventListener('resize', () => { | |
| camera.aspect = innerWidth / innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(innerWidth, innerHeight); | |
| composer.setSize(innerWidth, innerHeight); | |
| refit(); | |
| }); | |
| document.fonts?.ready.then(() => refit()); | |
| const clock = new THREE.Clock(); | |
| const st0 = stateAt(0); | |
| let lastHash = 0; | |
| // compile the shaders and upload the textures behind the loader, so the first visible frames do not stall | |
| try { if (renderer.compileAsync) await renderer.compileAsync(scene, camera); } catch {} | |
| try { composer.render(); } catch {} | |
| await new Promise((r) => requestAnimationFrame(() => r())); | |
| clock.getDelta(); | |
| let autoplayAt = SHOT ? 0 : performance.now() + 3000; | |
| introStart = performance.now(); | |
| $('loader').classList.add('gone'); | |
| setTimeout(() => $('loader').remove(), 1200); | |
| function frame() { | |
| const rawDt = clock.getDelta(); | |
| const dt = Math.min(0.05, rawDt); | |
| shared.uTime.value += dt; | |
| const now = performance.now(); | |
| if (ovUploads.length) drainOverlayUploads(); | |
| if (intro) { | |
| // intro, about 2.6 s at speed 1 (Settings speed scales it): the camera starts moving on the first frame with an | |
| // ease-out down to a three-quarter view (0-1.6 s) while the ink scan sweeps down (0.8-2.4 s), then the unroll starts | |
| const k = (now - introStart) / 1000 * (window.__uiSpeed || 1); | |
| const a = Math.min(1, Math.max(0, k / 1.6)); | |
| const e = 1 - Math.pow(1 - a, 3); | |
| const f = framing(st0); | |
| controls.target.lerpVectors(TOP_TARGET, f, e); | |
| camera.position.lerpVectors(TOP_POS, f.clone().add(rollOffset()), e); | |
| const sw = Math.min(1, Math.max(0, (k - 0.8) / 1.6)); | |
| shared.uSweep.value = 40 - sw * 85; | |
| if (k > 2.5) { intro = false; shared.uSweep.value = -999; autoplayAt = now + 150; $('skipIntro')?.remove(); } | |
| } else if (!playing && autoplayAt && now > autoplayAt && t === 0 && !scrubbing) { | |
| setPlaying(true); | |
| setTarget(0.05, 'play'); // ease-out kick so the first turn is visible at once | |
| } | |
| if (!intro && autoplayAt && (playing || scrubbing || t > 0)) autoplayAt = 0; | |
| if (rolling) { | |
| setTarget(target - dt * (window.__uiSpeed || 1) / 7, 'play'); | |
| if (target <= 0) setRolling(false); | |
| } | |
| if (act && (act.tool === 'crush' || act.tool === 'water') && now - act.tick > (act.tool === 'crush' ? 70 : 90)) { act.tick = now; applyTool(false); } | |
| if (playing) { | |
| setTarget(target + dt * (window.__uiSpeed || 1) / DURATION, 'play'); | |
| if (target >= 1) setPlaying(false); | |
| } | |
| t += (target - t) * (1 - Math.exp(-dt * (scrubbing ? 14 : 6))); | |
| tShown = t; | |
| const st = applyT(t, dt); | |
| if (tearOn) tear.step(dt); | |
| updateGround(dt); | |
| updateFloor(dt, destroyOn && !tearOn); | |
| damage.update(dt, shared.uTime.value, Math.min(0.5, rawDt)); | |
| // follow the roll | |
| if (mode === 'free') { /* camera stays where it was put */ } else if (!intro && mode === 'roll' && t >= 0.999 && !scrubbing) { | |
| mode = 'overview'; | |
| flyTo(...overview(), 3.2); | |
| } else if (!intro && mode === 'overview' && t < 0.97) { | |
| mode = 'roll'; | |
| const f = framing(st); | |
| flyTo(f.clone().add(rollOffset()), f, 1.6); | |
| } | |
| if (fly) { | |
| const a = Math.min(1, (now - fly.start) / fly.dur); | |
| const e = a < 0.5 ? 4 * a * a * a : 1 - Math.pow(-2 * a + 2, 3) / 2; | |
| if (mode === 'roll') fly.t1 = framing(st), fly.p1 = fly.t1.clone().add(rollOffset()); | |
| camera.position.lerpVectors(fly.p0, fly.p1, e); | |
| controls.target.lerpVectors(fly.t0, fly.t1, e); | |
| if (a >= 1) fly = null; | |
| } else if (!intro && mode === 'roll' ) { | |
| const f = framing(st); | |
| const d = f.sub(controls.target).multiplyScalar(1 - Math.exp(-dt * 3)); | |
| controls.target.add(d); | |
| camera.position.add(d); | |
| } | |
| // layers | |
| const wantLayer = layer === 'xray' ? 0 : 1; | |
| layerT += (wantLayer - layerT) * (1 - Math.exp(-dt * 6)); | |
| gpuT += ((layer === 'gpu' ? 1 : 0) - gpuT) * (1 - Math.exp(-dt * 6)); | |
| shared.uLayer.value = layerT; | |
| shared.uGpuMix.value = gpuT; | |
| // readout | |
| $('rWind').textContent = st.cur.userData.w.id; | |
| $('btnRead').textContent = `Read ${st.cur.userData.w.id}`; | |
| scrubIn.setAttribute('aria-valuetext', `${(st.s / 1000).toFixed(2)} of ${(L / 1000).toFixed(2)} meters unrolled, winding ${st.cur.userData.w.id}`); | |
| $('rRad').textContent = `${st.radius.toFixed(1)} mm`; | |
| $('rLen').textContent = `${(st.s / 1000).toFixed(2)} / ${(L / 1000).toFixed(2)} m`; | |
| if (GPU) { | |
| const g = gpuIds.has(st.cur.userData.w.id) && GPU.windings[st.cur.userData.w.id]; | |
| $('rGpu').textContent = g ? `${g.patches.toLocaleString()} pieces read · ${g.gpuSeconds} s` : '·'; | |
| $('rGpuRow').classList.toggle('live', !!g); | |
| } | |
| $('fill').style.width = `${t * 100}%`; | |
| $('knob').style.left = `${t * 100}%`; | |
| if (playing && now - lastHash > 1500) { lastHash = now; saveHash(); } | |
| controls.update(); | |
| if (!document.body.classList.contains('bench-open')) composer.render(); // paused under the Ink Bench | |
| requestAnimationFrame(frame); | |
| } | |
| requestAnimationFrame(frame); | |
| // the overlay textures load after the first frame; until then those sheets show the main ink layer | |
| requestAnimationFrame(() => loadOverlay()); | |
| // camera hooks for the keyboard shortcuts in polish.js; gfx is read by ui.js (Settings) | |
| window.unrollView = { | |
| gfx: { renderer, bloom, controls, scene, camera, composer, ctWindow: shared.uCTWin, ctTexture: () => texCT }, | |
| // lib/ctvolume.js: the int16 scan-space grids (voxels minus the axis center) and a surface pick in winding UV | |
| scanGeom: () => ({ all, wins, axis: M.axis || null, voxelMM: M.voxelMM, scroll: MAN.id }), | |
| scanPick: (x, y) => { const h = pickHit(x, y); return h ? { winding: h.m.userData.w.id, u: +h.u, v: +h.v } : null; }, | |
| pickMode: () => (FLOAT_PICK ? 'gpu-float' : 'cpu'), | |
| cpuPick: (x, y) => { const h = cpuPick(x, y); return h ? { winding: h.m.userData.w?.id, u: +h.u.toFixed(3), v: +h.v.toFixed(3) } : null; }, | |
| introActive: () => intro, | |
| skipIntro: () => { if (!intro) return false; introDone(); autoplayAt = performance.now() + 150; $('skipIntro')?.remove(); return true; }, | |
| damageStats: () => damage.stats(), | |
| viewFlat: (id, u, v, d = 40, tt = null) => { const m = meshes.find((q) => q.userData.w.id === id); if (!m) return null; const { start, w, own } = m.userData; const x = tt ?? Math.min(1, (start + w.widthMM) / L + 0.004); introDone(); autoplayAt = 0; setPlaying(false); t = tShown = x; setTarget(x, 'hash'); applyT(x, 0); mode = 'shot'; fly = null; const F = own.uFlatO.value; controls.target.set(F.x + u * w.widthMM, F.y - v * w.heightMM, F.z); camera.position.copy(controls.target).add(new THREE.Vector3(0, -d * 0.15, d)); controls.update(); return x; }, | |
| islandStats: () => islandStats, | |
| // test hook: per winding, triangle islands left after the prune (size, UV box), NaN vertices and edges longer than lim mm rolled or flat | |
| topoAudit: (lim = 3) => { const out = []; | |
| for (const m of meshes) { const g = m.geometry, P = g.attributes.position.array, A = g.attributes.aLocal.array, I = g.index.array, W = m.userData.w.widthMM, H = m.userData.w.heightMM, n = A.length / 2; | |
| const par = new Int32Array(n); for (let i = 0; i < n; i++) par[i] = i; const f = (x) => { while (par[x] !== x) { par[x] = par[par[x]]; x = par[x]; } return x; }; | |
| let nan = 0, longR = 0, longF = 0; const lx = []; | |
| for (let t = 0; t < I.length; t += 3) { for (let e = 0; e < 3; e++) { const a = I[t + e], b = I[t + (e + 1) % 3]; const ra = f(a), rb = f(b); if (ra !== rb) par[ra] = rb; | |
| if (!Number.isFinite(P[a * 3] + P[a * 3 + 1] + P[a * 3 + 2] + A[a * 2] + A[a * 2 + 1])) nan++; | |
| const d3 = Math.hypot(P[a * 3] - P[b * 3], P[a * 3 + 1] - P[b * 3 + 1], P[a * 3 + 2] - P[b * 3 + 2]), df = Math.hypot((A[a * 2] - A[b * 2]) * W, (A[a * 2 + 1] - A[b * 2 + 1]) * H); | |
| if (d3 > lim) longR++; if (df > lim) longF++; if ((d3 > lim || df > lim) && lx.length < 4) lx.push([t / 3, +d3.toFixed(2), +df.toFixed(2), +A[a * 2].toFixed(3), +A[a * 2 + 1].toFixed(3)]); } } | |
| const comp = new Map(); for (let t = 0; t < I.length; t += 3) { const r = f(I[t]); let c = comp.get(r); if (!c) comp.set(r, c = { n: 0, u0: 1, u1: 0, v0: 1, v1: 0 }); c.n++; const i = I[t]; c.u0 = Math.min(c.u0, A[i * 2]); c.u1 = Math.max(c.u1, A[i * 2]); c.v0 = Math.min(c.v0, A[i * 2 + 1]); c.v1 = Math.max(c.v1, A[i * 2 + 1]); } | |
| const cs = [...comp.values()].sort((a, b) => b.n - a.n).map((c) => [c.n, +c.u0.toFixed(3), +c.u1.toFixed(3), +c.v0.toFixed(3), +c.v1.toFixed(3)]); | |
| const N = g.attributes.normal.array, used = new Uint8Array(n); for (let t = 0; t < I.length; t++) used[I[t]] = 1; | |
| let zn = 0, degen = 0; for (let i = 0; i < n; i++) if (used[i]) { const l = Math.hypot(N[i * 3], N[i * 3 + 1], N[i * 3 + 2]); if (!(l > 1e-4)) zn++; } | |
| for (let t = 0; t < I.length; t += 3) { const a = I[t] * 3, b = I[t + 1] * 3, c = I[t + 2] * 3; const ux = P[b] - P[a], uy = P[b + 1] - P[a + 1], uz = P[b + 2] - P[a + 2], vx = P[c] - P[a], vy = P[c + 1] - P[a + 1], vz = P[c + 2] - P[a + 2]; | |
| if (!(Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx) > 1e-9)) degen++; } | |
| out.push({ id: m.userData.w.id, tris: I.length / 3, comps: cs.length, small: cs.slice(1, 6), nan, zn, degen, longR, longF, lx }); } | |
| return out; }, | |
| setT: (x) => { introDone(); autoplayAt = 0; setPlaying(false); t = tShown = x; setTarget(x, 'hash'); const st = applyT(x, 0); const f = framing(st); controls.target.copy(f); camera.position.copy(f).add(rollOffset()); fly = null; return st.cur.userData.w.id; }, | |
| // test hook: the worst stretch of any triangle mid-unroll, relative to its rolled and flat size (1 = none) | |
| morphAudit: (lim = 4) => { const out = { tot: 0, bad: 0, worst: 0, ex: [] }, c = Math.cos(shared.uRot.value), sn = Math.sin(shared.uRot.value), q = new Float32Array(9); | |
| for (const m of meshes) { const g = m.geometry, P = g.attributes.position.array, A = g.attributes.aLocal.array, I = g.index.array, own = m.userData.own, W = own.uSize.value.x, H = own.uSize.value.y, F = own.uFlatO.value, soft = own.uSoft.value; | |
| for (let t = 0; t < I.length; t += 3) { out.tot++; | |
| const uc = (A[I[t] * 2] + A[I[t + 1] * 2] + A[I[t + 2] * 2]) / 3, uP = (uc + 0.5 * soft) / (1 + soft), Rx = m.userData.start + uP * W, Rz = 0; | |
| let base = 0, mo = 0; | |
| for (let v = 0; v < 3; v++) { const i = I[t + v], u = A[i * 2], vv = A[i * 2 + 1]; | |
| const e0 = Math.min(1, Math.max(0, (uP * (1 + soft) - u) / soft)), e = e0 * e0 * (3 - 2 * e0); | |
| const cx = P[i * 3] * c + P[i * 3 + 2] * sn + Rx, cy = P[i * 3 + 1], cz = -P[i * 3] * sn + P[i * 3 + 2] * c + Rz; | |
| const fx = F.x + u * W, fy = F.y - vv * H, fz = F.z; | |
| q[v * 3] = cx + (fx - cx) * e; q[v * 3 + 1] = cy + (fy - cy) * e; q[v * 3 + 2] = cz + (fz - cz) * e - Math.sin(Math.PI * e) * shared.uLift.value; } | |
| for (let e = 0; e < 3; e++) { const a = I[t + e], b = I[t + (e + 1) % 3], f = (e + 1) % 3; | |
| base = Math.max(base, Math.hypot(P[a * 3] - P[b * 3], P[a * 3 + 1] - P[b * 3 + 1], P[a * 3 + 2] - P[b * 3 + 2]), Math.hypot((A[a * 2] - A[b * 2]) * W, (A[a * 2 + 1] - A[b * 2 + 1]) * H)); | |
| mo = Math.max(mo, Math.hypot(q[e * 3] - q[f * 3], q[e * 3 + 1] - q[f * 3 + 1], q[e * 3 + 2] - q[f * 3 + 2])); } | |
| const r = mo / (base + 0.05); | |
| if (r > out.worst) out.worst = r; | |
| if (mo > lim && r > 3) { out.bad++; if (out.ex.length < 6) out.ex.push([m.userData.w.id, t / 3, +mo.toFixed(1), +base.toFixed(2), +uc.toFixed(3)]); } } } | |
| out.worst = +out.worst.toFixed(1); return out; }, | |
| // test hook: triangles whose flat (unrolled) size is far from their rolled size stretch into spikes while unrolling | |
| geomAudit: (ratio = 3) => { let bad = 0, worst = 0, tot = 0; const ex = []; | |
| for (const m of meshes) { const g = m.geometry, P = g.attributes.position.array, A = g.attributes.aLocal.array, I = g.index.array, W = m.userData.w.widthMM, H = m.userData.w.heightMM; | |
| for (let t = 0; t < I.length; t += 3) { tot++; let rf = 0; | |
| for (let e = 0; e < 3; e++) { const a = I[t + e], b = I[t + (e + 1) % 3]; | |
| const d3 = Math.hypot(P[a * 3] - P[b * 3], P[a * 3 + 1] - P[b * 3 + 1], P[a * 3 + 2] - P[b * 3 + 2]); | |
| const df = Math.hypot((A[a * 2] - A[b * 2]) * W, (A[a * 2 + 1] - A[b * 2 + 1]) * H); | |
| rf = Math.max(rf, (Math.max(d3, df) + 0.05) / (Math.min(d3, df) + 0.05)); } | |
| if (rf > ratio) { bad++; if (ex.length < 5) ex.push([m.userData.w.id, t / 3, +rf.toFixed(1)]); } worst = Math.max(worst, rf); } } | |
| return { tot, bad, worst: +worst.toFixed(1), ex }; }, | |
| // test hook: step the unroll 0..1 and flag geometric outliers per triangle at each step, using the live uniforms | |
| sweepAudit: (steps = 60, kEdge = 8, kVert = 6) => { const hits = [], comp = {}, c0 = shared.uRot.value; let tris = 0, worstE = 0, worstV = 0; | |
| for (const m of meshes) { const I = m.geometry.index.array, n = m.geometry.attributes.position.count, par = new Int32Array(n); for (let i = 0; i < n; i++) par[i] = i; | |
| const f = (x) => { while (par[x] !== x) { par[x] = par[par[x]]; x = par[x]; } return x; }; | |
| for (let t = 0; t < I.length; t += 3) { par[f(I[t])] = f(I[t + 1]); par[f(I[t + 2])] = f(I[t + 1]); } | |
| const cnt = new Map(); for (let t = 0; t < I.length; t += 3) { const r = f(I[t]); cnt.set(r, (cnt.get(r) || 0) + 1); } | |
| const sz = [...cnt.values()].sort((a, b) => b - a); comp[m.userData.w.id] = sz.length; if (sz.length > 1) hits.push({ step: -1, w: m.userData.w.id, kind: 'component', sizes: sz.slice(0, 5) }); } | |
| const q = new Float32Array(3); | |
| for (let s = 0; s <= steps; s++) { const tt = s / steps; applyT(tt, 0); const c = Math.cos(shared.uRot.value), sn = Math.sin(shared.uRot.value), R = shared.uRoll.value, lift = shared.uLift.value; | |
| for (const m of meshes) { const g = m.geometry, P = g.attributes.position.array, A = g.attributes.aLocal.array, I = g.index.array, own = m.userData.own, W = own.uSize.value.x, H = own.uSize.value.y, F = own.uFlatO.value, soft = own.uSoft.value, uP = own.uP.value, n = P.length / 3; | |
| const X = new Float32Array(n * 3); | |
| for (let i = 0; i < n; i++) { const u = A[i * 2], v = A[i * 2 + 1]; let e = Math.min(1, Math.max(0, (uP * (1 + soft) - u) / soft)); e = e * e * (3 - 2 * e); | |
| const cx = P[i * 3] * c + P[i * 3 + 2] * sn + R.x, cy = P[i * 3 + 1] + R.y, cz = -P[i * 3] * sn + P[i * 3 + 2] * c + R.z; | |
| X[i * 3] = cx + (F.x + u * W - cx) * e; X[i * 3 + 1] = cy + (F.y - v * H - cy) * e; X[i * 3 + 2] = cz + (F.z - cz) * e - Math.sin(Math.PI * e) * lift; } | |
| const L = new Float32Array(I.length), vmax = new Float32Array(n); | |
| for (let t = 0; t < I.length; t += 3) for (let k = 0; k < 3; k++) { const a = I[t + k], b = I[t + (k + 1) % 3]; const d = Math.hypot(X[a * 3] - X[b * 3], X[a * 3 + 1] - X[b * 3 + 1], X[a * 3 + 2] - X[b * 3 + 2]); L[t + k] = d; if (d > vmax[a]) vmax[a] = d; if (d > vmax[b]) vmax[b] = d; } | |
| const so = Float32Array.from(L).sort(), med = so[so.length >> 1] || 1; tris += I.length / 3; | |
| for (let t = 0; t < I.length; t += 3) { const e = Math.max(L[t], L[t + 1], L[t + 2]) / med; if (e > worstE) worstE = e; if (e > kEdge && hits.length < 200) hits.push({ step: s, w: m.userData.w.id, kind: 'edge', tri: t / 3, ratio: +e.toFixed(1), u: +A[I[t] * 2].toFixed(3), v: +A[I[t] * 2 + 1].toFixed(3) }); } | |
| for (let i = 0; i < n; i++) { if (!vmax[i]) continue; const r = vmax[i] / med; if (r > worstV) worstV = r; if (r > kVert && hits.length < 200) hits.push({ step: s, w: m.userData.w.id, kind: 'vertex', vert: i, ratio: +r.toFixed(1), u: +A[i * 2].toFixed(3), v: +A[i * 2 + 1].toFixed(3) }); } } } | |
| applyT(t, 0); return { steps, windings: meshes.length, triSteps: tris, worstEdge: +worstE.toFixed(2), worstVert: +worstV.toFixed(2), components: comp, hits }; }, | |
| sandboxStats: () => (tear ? tear.stats() : null), | |
| sandboxSim: (n) => { if (!tear) return null; n = Math.max(0, Math.min(600, Math.floor(+n) || 0)); for (let i = 0; i < n; i++) { tear.step(1 / 60); damage.update(1 / 60, shared.uTime.value += 1 / 60, 1 / 60); } return tear.stats(); }, | |
| crumble: () => crumble(), // owner feature: crumble the whole scroll to dust; Reset restores it | |
| damageSim: (n, dt) => damage.simulate(Math.max(0, Math.min(600, Math.floor(+n) || 0)), Number.isFinite(+dt) ? Math.max(0, Math.min(0.1, +dt)) : 1 / 60), // capped: a test hook must not freeze the tab | |
| surfaceScreen: (j, u, v) => { const p = surfacePoint(meshes[j], u, v, new THREE.Vector3()); if (!p) return null; p.project(camera); return [Math.round((p.x + 1) / 2 * innerWidth), Math.round((1 - p.y) / 2 * innerHeight), meshes[j].userData.own.uP.value]; }, | |
| floorY: () => groundY, sheetBottom: () => sheetBottom(), | |
| // test hook: distance (mm) between the rendered surface under a screen point and surfacePoint() at the same UV | |
| surfaceCheck: (x, y) => { const h = pickHit(x, y); if (!h || !h.world) return null; const p = surfacePoint(h.m, h.u, h.v, new THREE.Vector3()); return p ? [+p.distanceTo(h.world).toFixed(3), h.world.toArray().map((x) => +x.toFixed(2)), p.toArray().map((x) => +x.toFixed(2))] : null; }, // test hook: step the debris without waiting for frames | |
| reset: () => cameraReset(), | |
| }; | |
| function cameraReset() { | |
| introDone(); autoplayAt = 0; | |
| if (mode === 'free') mode = target >= 0.97 ? 'overview' : 'roll'; | |
| if (mode === 'overview') flyTo(...overview(), 1.4); | |
| else { const f = framing(stateAt(t)); flyTo(f.clone().add(rollOffset()), f, 1.4); } | |
| } | |
| // ---------------------------------------------------------------- the public API (window.unroll) and deep links | |
| const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| const nextFrame = () => new Promise((r) => requestAnimationFrame(() => r())); | |
| const v3 = (a) => a.toArray().map((x) => +x.toFixed(3)); | |
| function openPanel() { | |
| if (!$('about').hidden) return 'about'; | |
| if ($('openSheet') && !$('openSheet').hidden) return 'open'; | |
| if ($('help') && !$('help').hidden) return 'help'; | |
| if (!$('embedSheet').hidden) return 'embed'; | |
| return null; | |
| } | |
| const LAYER_ALIASES = { xray: 'xray', ct: 'xray', ink: 'ink', gpu: 'gpu', overlay: 'gpu' }; | |
| function layerKey(id) { | |
| const s = String(id).toLowerCase(); | |
| let k = LAYER_ALIASES[s]; | |
| if (!k) { const l = MAN.layers.find((x) => x.id === id); if (l) k = { ct: 'xray', ink: 'ink', overlay: 'gpu' }[l.role]; } | |
| if (!k) throw new ApiError(`Unknown layer "${id}". This scroll has: ${Object.entries(CHIP_LAYER).filter(([, l]) => l).map(([c, l]) => `${c === 'gpu' ? 'overlay' : c} (${l.label})`).join(', ')}.`, 'not_found'); | |
| if (!CHIP_LAYER[k]) throw new ApiError(`This scroll has no ${k === 'gpu' ? 'overlay' : k} layer.`, 'unavailable'); | |
| return k; | |
| } | |
| const pubLayer = (k) => (k === 'gpu' ? 'overlay' : k); | |
| function meshOf(id) { | |
| if (typeof id === 'number') { // a number is a winding index (0 is the outermost listed) or the number in the id | |
| const byNum = IDS.find((x) => parseInt(x.replace(/\D+/g, ''), 10) === id); | |
| id = byNum || IDS[id] || String(id); | |
| } | |
| const m = meshes.find((x) => x.userData.w.id === id); | |
| if (!m) throw new ApiError(`Unknown winding "${id}". This scroll has ${IDS.length} windings: ${IDS.join(', ')}.`, 'not_found'); | |
| return m; | |
| } | |
| const settledNow = () => !fly && !intro && Math.abs(target - t) < 1e-3 && !playing; | |
| // tear.js counts breaks for the whole session; the API also reports them since the last reset | |
| let breakBase = 0; | |
| const tstats = () => { const st = tear.stats(); return { ...st, breaksSinceReset: st.breaks - breakBase }; }; | |
| function describeView() { | |
| const st = stateAt(t), w = st.cur.userData.w; | |
| const L1 = CHIP_LAYER[layer]; | |
| const parts = [`${MAN.name}${TRUSTED ? '' : ' (opened locally, not uploaded)'}.`]; | |
| parts.push(target <= 0.001 ? `The roll is closed: ${IDS.length} windings, outermost ${IDS[0]}.` : target >= 0.999 ? `Fully unrolled: ${IDS.length} windings laid flat side by side, ${(L / 1000).toFixed(2)} m of strip.` : `${Math.round(t * 100)} percent unrolled, ${(st.s / 1000).toFixed(2)} of ${(L / 1000).toFixed(2)} m; the roll is peeling winding ${w.id}, radius ${st.radius.toFixed(1)} mm.`); | |
| parts.push(`Layer: ${L1 ? L1.label : layer}.${layer === 'gpu' ? ` It covers ${[...OV_IDS].join(', ')}.` : ''}`); | |
| if (destroyOn) parts.push(`Destroy mode is on with the ${tool} tool${tearOn ? ', in the Sandbox (the strip laid flat with fracture physics)' : ''}.`); | |
| if (focusW && !$('focus').hidden) { | |
| const v = focusView(); | |
| parts.push(`Close-up of ${focusW.id}, ${focusW.widthMM.toFixed(0)} by ${focusW.heightMM.toFixed(0)} mm, showing ${v === 'compare' ? `a compare swipe at ${$('fSwipe').value} percent between ${shortLabel(L_INK || L_CT)} and ${shortLabel(L_OV)}` : v === 'overlay' ? shortLabel(L_OV) : shortLabel(L_INK || L_CT)}.`); | |
| } | |
| const pn = openPanel(); | |
| if (pn) parts.push(`The ${pn === 'open' ? 'Open your own' : pn === 'help' ? 'keyboard help' : 'About'} panel is open.`); | |
| return parts.join(' '); | |
| } | |
| function stateNow() { | |
| const st = stateAt(t), w = st.cur.userData.w; | |
| const sph = new THREE.Spherical().setFromVector3(camera.position.clone().sub(controls.target)); | |
| return { | |
| ready: true, | |
| settled: settledNow(), | |
| scroll: { key: SCROLL_KEY, id: MAN.id, name: MAN.name, local: !TRUSTED, windings: IDS, voxelMM: MAN.voxelMM, stripMM: +L.toFixed(1) }, | |
| t: +t.toFixed(4), target: +target.toFixed(4), playing, | |
| winding: { id: w.id, index: IDS.indexOf(w.id), radiusMM: +st.radius.toFixed(2), widthMM: w.widthMM, heightMM: w.heightMM, hasOverlay: gpuIds.has(w.id) }, | |
| unrolledMM: +st.s.toFixed(1), | |
| layer: pubLayer(layer), | |
| layers: { | |
| available: Object.entries(CHIP_LAYER).filter(([, l]) => l).map(([k, l]) => ({ id: pubLayer(k), manifestId: l.id, label: l.label, role: l.role, resolutionUM: l.resolutionUM ?? null, windings: k === 'gpu' ? [...OV_IDS] : 'all' })), | |
| overlayLoaded: !L_OV || [...OV_IDS].every((id) => gpuTex[id]), | |
| }, | |
| focus: focusW && !$('focus').hidden ? { open: true, winding: focusW.id, view: focusView(), compare: focusView() === 'compare' ? +(+$('fSwipe').value / 100).toFixed(2) : null, zoom: +vz.s.toFixed(4), imageLoaded: !!img.naturalWidth } : { open: false }, | |
| panels: { about: !$('about').hidden, help: !!$('help') && !$('help').hidden, open: !$('openSheet').hidden, why: !!$('layerWhy').open, embed: !$('embedSheet').hidden }, | |
| destroy: { on: destroyOn, tool, sandbox: tearOn, sound: $('btnSound').getAttribute('aria-pressed') === 'true', tools: destroyTools(), stats: dstats() }, | |
| rolling, | |
| camera: { mode, position: v3(camera.position), target: v3(controls.target), distance: +sph.radius.toFixed(2), azimuthDeg: +THREE.MathUtils.radToDeg(sph.theta).toFixed(2), polarDeg: +THREE.MathUtils.radToDeg(sph.phi).toFixed(2), fov: camera.fov, moving: !!fly }, | |
| fullscreen: !!document.fullscreenElement, | |
| embed: EMB.info(), | |
| readout: $('readout').innerText.replace(/\s+/g, ' ').trim(), | |
| description: describeView(), | |
| url: location.href, | |
| }; | |
| } | |
| function destroyTools() { return ['tear', 'crush', 'burn', 'water'].filter((n) => document.querySelector(`.chip.tool[data-tool="${n}"]`)); } | |
| const dstats = () => ({ damage: damage.stats(), sandbox: tear ? tstats() : null }); | |
| // a pointer drag on the 3D view, the same events a mouse makes | |
| async function dragAt(x, y, o = {}) { | |
| const dx = o.dx ?? 90, dy = o.dy ?? -200, steps = Math.max(1, Math.min(200, o.steps ?? 24)), ms = Math.max(0, Math.min(200, o.ms ?? 30)); | |
| if (x > innerWidth || y > innerHeight) throw new ApiError(`(${x}, ${y}) is outside the ${innerWidth} x ${innerHeight} view.`); | |
| const ev = (type, px, py, buttons) => canvas.dispatchEvent(new PointerEvent(type, { clientX: px, clientY: py, button: 0, buttons, pointerId: 1, pointerType: 'mouse', isPrimary: true, bubbles: true, cancelable: true })); | |
| const wasEnabled = controls.enabled; | |
| controls.enabled = false; | |
| try { | |
| ev('pointerdown', x, y, 1); | |
| for (let i = 1; i <= steps; i++) { await sleepMs(ms); if (o.frames !== false) await nextFrame(); ev('pointermove', x + (dx * i) / steps, y + (dy * i) / steps, 1); } | |
| ev('pointerup', x + dx, y + dy, 0); | |
| } finally { controls.enabled = wasEnabled; } | |
| await sleepMs(250); | |
| } | |
| function needDestroy() { if (!destroyOn) setDestroy(true); saveHash(); } | |
| function needSandbox() { needDestroy(); if (!tearOn) setTear(true); saveHash(); } | |
| const holdOf = (o, d) => { const h = o && o.hold !== undefined ? o.hold : d; if (typeof h !== 'number' || !(h >= 0 && h <= 5000)) throw new ApiError('options.hold must be a number of milliseconds from 0 to 5000.'); return h; }; | |
| // a press on the scroll with the current Destroy tool, through the same pointer events a mouse sends | |
| async function destroyAt(name, x, y, o = {}) { | |
| const hold = name === 'tear' ? 0 : holdOf(o, name === 'burn' ? 120 : name === 'water' ? 900 : 700); | |
| needDestroy(); | |
| if (tearOn) setTear(false); | |
| setTool(name); | |
| if (!pickHit(x, y)) throw new ApiError(`No papyrus at (${x}, ${y}). destroy.aim() gives a point that is on it.`, 'not_found'); | |
| const before = damage.stats(); | |
| if (name === 'tear') await dragAt(x, y, { dx: 120, dy: 30, steps: 12, ...o, frames: false }); | |
| else { | |
| // press and hold: the frame loop keeps applying the tool while the pointer is down, at its own pace | |
| const ev = (type, buttons) => canvas.dispatchEvent(new PointerEvent(type, { clientX: x, clientY: y, button: 0, buttons, pointerId: 1, pointerType: 'mouse', isPrimary: true, bubbles: true, cancelable: true })); | |
| ev('pointerdown', 1); await sleepMs(hold); ev('pointerup', 0); | |
| } | |
| await nextFrame(); | |
| return { tool: name, at: { x, y }, before, after: damage.stats() }; | |
| } | |
| // one small pick render of the whole view, then the papyrus pixel nearest the center | |
| let aimRT = null; | |
| function aimPoint() { | |
| const W = 96, H = Math.max(24, Math.round(96 * innerHeight / innerWidth)); | |
| if (!aimRT) aimRT = new THREE.WebGLRenderTarget(W, H, { type: THREE.FloatType }); | |
| aimRT.setSize(W, H); | |
| const buf = new Float32Array(W * H * 4); | |
| for (const m of meshes) m.material = m.userData.pickMat; | |
| for (const o of damage.objects) o.visible = false; | |
| shared.uPickW.value = 0; | |
| renderer.setRenderTarget(aimRT); renderer.setClearColor(0x000000, 0); renderer.clear(); renderer.render(scene, camera); | |
| renderer.readRenderTargetPixels(aimRT, 0, 0, W, H, buf); | |
| renderer.setRenderTarget(null); renderer.setClearColor(0x030303, 1); | |
| for (const m of meshes) m.material = m.userData.mat; | |
| for (const o of damage.objects) o.visible = true; | |
| let best = null, bd = Infinity; | |
| for (let py = 1; py < H - 1; py++) for (let px = 1; px < W - 1; px++) { | |
| const id = Math.round(buf[(py * W + px) * 4] * 255); | |
| if (!id || !meshes[id - 1]) continue; | |
| // keep away from edges: the four neighbors must be papyrus too | |
| const ok = [[1, 0], [-1, 0], [0, 1], [0, -1]].every(([a, b]) => Math.round(buf[((py + b) * W + px + a) * 4] * 255) > 0); | |
| if (!ok) continue; | |
| const d = (px - W / 2) ** 2 + (py - H / 2) ** 2; | |
| if (d < bd) { bd = d; best = { px, py, id }; } | |
| } | |
| if (!best) throw new ApiError('No papyrus is in view. Try camera.reset() first.', 'not_found'); | |
| const x = Math.round(((best.px + 0.5) / W) * innerWidth), y = Math.round((1 - (best.py + 0.5) / H) * innerHeight); | |
| return { x, y, winding: meshes[best.id - 1].userData.w.id }; | |
| } | |
| function setSoundBtn(on) { | |
| $('btnSound').setAttribute('aria-pressed', on); | |
| $('btnSound').textContent = on ? 'Sound on' : 'Sound off'; | |
| $('btnSound').classList.toggle('on', on); | |
| if (tear) tear.setSound(on); | |
| } | |
| function focusOpen(id, view) { | |
| const m = id === undefined ? stateAt(t).cur : meshOf(id); | |
| if (!focusW || focusW !== m.userData.w || $('focus').hidden) { lastOpener = lastOpener || $('btnRead'); openFocus(m); } | |
| if (view) { | |
| const k = { main: 'vc', overlay: 'gpu', compare: 'cmp' }[view]; | |
| if (!k) throw new ApiError(`focus: view must be "main", "overlay" or "compare"; got ${JSON.stringify(view)}.`); | |
| const b = [...$('fSeg').children].find((x) => x.dataset.view === k); | |
| if (!b) throw new ApiError(`${m.userData.w.id} has no ${view} view: the ${L_OV ? L_OV.label : 'overlay'} layer covers ${[...OV_IDS].join(', ') || 'no windings'}.`, 'unavailable'); | |
| b.click(); | |
| } | |
| saveHash(); | |
| } | |
| async function filesFromAPI(files) { | |
| if (!files.length) throw new ApiError('open.fromFiles: give at least one file.'); | |
| const out = []; | |
| for (const [i, f] of files.entries()) { | |
| if (!f || typeof f.name !== 'string' || !f.name) throw new ApiError(`open.fromFiles: file ${i} needs a "name".`); | |
| let d = f.data, blob; | |
| if (d instanceof Blob) blob = d; | |
| else if (d instanceof ArrayBuffer || ArrayBuffer.isView(d)) blob = new Blob([d]); | |
| else if (typeof d === 'string') { | |
| const b64 = d.replace(/^data:[^,]*,/, ''); | |
| if (!/^[A-Za-z0-9+/=\s]*$/.test(b64)) throw new ApiError(`open.fromFiles: ${f.name}: "data" is a string but not base64.`); | |
| const bin = atob(b64.replace(/\s/g, '')); | |
| const u8 = new Uint8Array(bin.length); | |
| for (let k = 0; k < bin.length; k++) u8[k] = bin.charCodeAt(k); | |
| blob = new Blob([u8]); | |
| } else throw new ApiError(`open.fromFiles: ${f.name}: "data" must be an ArrayBuffer, Uint8Array, Blob or base64 string.`); | |
| out.push(new File([blob], f.name.split('/').pop(), { lastModified: 0 })); | |
| out[out.length - 1].relPath = f.name; | |
| } | |
| return out; | |
| } | |
| async function storeAndOpen(getFiles) { | |
| let files; | |
| try { files = await getFiles(); } | |
| catch (e) { throw new ApiError(e.list ? `${e.message} ${e.list.join(' ')}` : e.message, 'invalid'); } | |
| let r; | |
| try { r = await checkAndStore(files); } | |
| catch (e) { throw new ApiError(e.list ? `${e.message} ${e.list.join(' ')}` : e.message, 'invalid'); } | |
| setTimeout(() => switchScroll(r.key), 50); | |
| return { key: r.key, warnings: r.warnings, reloading: true }; | |
| } | |
| function keyPress(k, o = {}) { | |
| const map = { ' ': 'Space', space: 'Space', esc: 'Escape' }; | |
| const key = map[k.toLowerCase?.()] || k; | |
| const code = key === 'Space' ? 'Space' : key.startsWith('Arrow') || ['Home', 'End', 'Escape', 'Enter'].includes(key) ? key : /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` : /^\d$/.test(key) ? `Digit${key}` : ''; | |
| const KNOWN = ['Space', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Escape', '1', '2', '3', 'r', 'v', 't', 'x', 'c', 'b', 'w', 'u', 'k', 'f', 'i', '?']; | |
| if (!KNOWN.includes(key.length === 1 ? key.toLowerCase() : key)) throw new ApiError(`key: "${k}" is not a shortcut. Shortcuts: ${KNOWN.join(', ')}.`, 'invalid'); | |
| const tgt = document.activeElement && document.activeElement !== document.body && !document.activeElement.closest('input,select,textarea') ? document.activeElement : document.body; | |
| document.body.focus(); | |
| document.body.dispatchEvent(new KeyboardEvent('keydown', { key: key === 'Space' ? ' ' : key, code, shiftKey: !!o.shift, bubbles: true, cancelable: true })); | |
| return { key, target: tgt === document.body ? 'page' : tgt.id || tgt.tagName.toLowerCase() }; | |
| } | |
| // embedding: ?embed=1 mode, the Embed panel and the postMessage bridge (lib/embed.js) | |
| const embedState = () => { const h = hashState(false); h.delete('panel'); const o = Object.fromEntries(h); if (o.s) { o.scroll = o.s; delete o.s; } if (o.l) { o.layer = o.l === 'gpu' ? 'overlay' : o.l; delete o.l; } return o; }; | |
| const EMB = setupEmbed({ $, stateParams: embedState }); | |
| $('btnEmbed').onclick = () => { lastOpener = $('btnEmbed'); IMPL.panel('embed', true); $('eClose').focus(); }; | |
| $('eClose').onclick = () => { IMPL.panel('embed', false); $('btnEmbed').focus(); }; | |
| $('btnFull').onclick = () => IMPL.fullscreen(); | |
| document.addEventListener('fullscreenchange', () => $('btnFull').setAttribute('aria-pressed', !!document.fullscreenElement)); | |
| const IMPL = { | |
| 'embed.snippet': (o = {}) => { const extra = Object.keys(o).filter((k) => !['state', 'autoplay', 'controls', 'width', 'height'].includes(k)); if (extra.length) throw new ApiError(`embed.snippet: unknown option ${extra.join(', ')}. Use { state, autoplay, controls, width, height }.`); return snippet({ state: o.state !== false, autoplay: !!o.autoplay, controls: o.controls !== false, width: o.width ?? '100%', height: o.height ?? 480, stateParams: embedState() }); }, | |
| 'embed.info': () => ({ embed: EMB.info() }), | |
| 'embed.activate': (on = true) => { if (!EMBED.on) throw new ApiError('embed.activate: the page is not in embed mode (?embed=1).', 'unavailable'); EMB.setActive(on); return { embed: EMB.info() }; }, | |
| version: async () => ({ ...(await VERSION) }), | |
| getState: () => stateNow(), | |
| describe: () => ({ text: describeView() }), | |
| links: () => ({ links: [...document.querySelectorAll('a[href]')].map((a) => ({ text: a.textContent.trim(), url: a.href, testid: a.dataset.testid })) }), | |
| deepLink: (cam) => ({ url: `${location.origin}${location.pathname}#${hashState(!!cam)}` }), | |
| reload: () => { setTimeout(() => location.reload(), 30); return { reloading: true }; }, | |
| listScrolls: async () => { | |
| const loc = await listLocal(); | |
| return { current: SCROLL_KEY, default: DEFAULT_ID, scrolls: REG.scrolls.map((x) => ({ id: x.id, name: x.name || x.id, local: false })).concat(loc.map((x) => ({ id: x.key, name: x.name, manifestId: x.id, local: true, bytes: x.size }))) }; | |
| }, | |
| openScroll: async (id) => { | |
| const loc = await listLocal(); | |
| if (!REG.scrolls.some((x) => x.id === id) && !loc.some((x) => x.key === id)) throw new ApiError(`Unknown scroll "${id}". Try: ${REG.scrolls.map((x) => x.id).concat(loc.map((x) => x.key)).join(', ')}.`, 'not_found'); | |
| if (id === SCROLL_KEY) return { id, reloading: false, note: 'already open' }; | |
| emitter.emit('scroll', { id }); | |
| setTimeout(() => switchScroll(id), 30); | |
| return { id, reloading: true }; | |
| }, | |
| setUnroll: (v, o = {}) => { | |
| introDone(); autoplayAt = 0; setPlaying(false); | |
| if (mode === 'free') mode = v >= 0.999 ? 'overview' : 'roll'; | |
| setTarget(v, 'api'); | |
| if (o.instant) { t = tShown = target; rot = null; } | |
| saveHash(); | |
| return { target: +target.toFixed(4) }; | |
| }, | |
| play: (on) => { introDone(); autoplayAt = 0; setPlaying(on === undefined ? !playing : on); return { playing }; }, | |
| setLayer: (id) => { | |
| const k = layerKey(id); | |
| introDone(); | |
| document.querySelector(`.chip[data-layer="${k}"]`).click(); | |
| return { layer: pubLayer(layer), target: +target.toFixed(4) }; | |
| }, | |
| focus: (id, o = {}) => { | |
| if (id === null) { closeSheets(); return { focus: { open: false } }; } | |
| focusOpen(id, o.view); | |
| return { focus: stateNow().focus }; | |
| }, | |
| compare: (on, pos) => { | |
| if (!on) { | |
| if (focusW && !$('focus').hidden && focusView() === 'compare') focusOpen(focusW.id, 'main'); | |
| return { focus: stateNow().focus }; | |
| } | |
| const id = focusW && !$('focus').hidden ? focusW.id : stateAt(t).cur.userData.w.id; | |
| if (!gpuIds.has(id)) throw new ApiError(`${id} has no ${L_OV ? L_OV.label : 'overlay'} layer to compare with. Windings that do: ${[...OV_IDS].join(', ') || 'none'}.`, 'unavailable'); | |
| focusOpen(id, 'compare'); | |
| if (pos !== undefined) { $('fSwipe').value = Math.round(pos * 100); vApply(); } | |
| saveHash(); | |
| return { focus: stateNow().focus }; | |
| }, | |
| closeup: (o) => { | |
| if (!focusW || $('focus').hidden) throw new ApiError('closeup: open a close-up first with focus(winding).', 'unavailable'); | |
| const extra = Object.keys(o).filter((k) => !['scale', 'x', 'y', 'fit'].includes(k)); | |
| if (extra.length) throw new ApiError(`closeup: unknown option ${extra.join(', ')}. Use { scale, x, y, fit }.`); | |
| if (o.fit) vFit(); | |
| if (o.scale !== undefined) { | |
| if (!(typeof o.scale === 'number' && o.scale > 0.01 && o.scale < 100)) throw new ApiError('closeup: scale must be a number between 0.01 and 100.'); | |
| const r = viewer.getBoundingClientRect(), mx = r.width / 2, my = r.height / 2; | |
| vz.x = mx - (mx - vz.x) * o.scale; vz.y = my - (my - vz.y) * o.scale; vz.s *= o.scale; | |
| } | |
| for (const k of ['x', 'y']) if (o[k] !== undefined) { if (typeof o[k] !== 'number' || !Number.isFinite(o[k])) throw new ApiError(`closeup: ${k} must be a number of CSS pixels.`); vz[k] += o[k]; } | |
| vApply(); | |
| return { zoom: +vz.s.toFixed(4), x: +vz.x.toFixed(1), y: +vz.y.toFixed(1) }; | |
| }, | |
| panel: (name, open) => { | |
| if (name === 'why') { const d = $('layerWhy'); if (d.hidden) throw new ApiError('This scroll has no "why two ink layers" note.', 'unavailable'); d.open = open === undefined ? !d.open : open; return { panels: stateNow().panels }; } | |
| const el = { about: $('about'), help: $('help'), open: $('openSheet'), embed: $('embedSheet') }[name]; | |
| if (!el) throw new ApiError(`The ${name} panel is not on this page.`, 'unavailable'); | |
| const show = open === undefined ? el.hidden : open; | |
| if (show) { | |
| for (const x of [$('about'), $('help'), $('openSheet'), $('embedSheet'), $('focus')]) if (x && x !== el) x.hidden = true; | |
| if (name === 'open') OPEN.open(); else el.hidden = false; | |
| if (name === 'embed') EMB.fill(); | |
| introDone(); | |
| } else el.hidden = true; | |
| saveHash(); | |
| return { panels: stateNow().panels }; | |
| }, | |
| sound: (on) => { needDestroy(); setSoundBtn(on); return { sound: on, note: 'Browsers only play audio after the person has clicked or pressed a key on the page.' }; }, | |
| fullscreen: async (on) => { | |
| const want = on === undefined ? !document.fullscreenElement : on; | |
| try { if (want && !document.fullscreenElement) await document.documentElement.requestFullscreen(); else if (!want && document.fullscreenElement) await document.exitFullscreen(); } | |
| catch (e) { throw new ApiError(`The browser refused full screen (${e.message}). It usually needs a click or key press first.`, 'refused'); } | |
| return { fullscreen: !!document.fullscreenElement }; | |
| }, | |
| screenshot: (o = {}) => { | |
| let c; | |
| if (o.clean) { composer.render(); c = renderer.domElement; } else c = composeShot(); | |
| const dataURL = c.toDataURL('image/png'); | |
| if (o.download) { const a = document.createElement('a'); a.href = dataURL; a.download = `${MAN.id.toLowerCase().replace(/[^a-z0-9_-]+/g, '-')}-unroll-${Math.round(tShown * 100)}.png`; a.click(); } | |
| return { dataURL, width: c.width, height: c.height, mime: 'image/png' }; | |
| }, | |
| key: (k, o) => keyPress(k, o), | |
| reset: async () => { | |
| closeSheets(); if ($('help')) $('help').hidden = true; | |
| damage.reset(); if (tear) tear.reset(); | |
| if (tearOn) setTear(false); | |
| if (destroyOn) setDestroy(false); | |
| setTool('tear'); setRolling(false); | |
| setPlaying(false); setTarget(0, 'api'); mode = 'roll'; | |
| if (L_INK) setLayer('ink'); else if (L_CT) setLayer('xray'); | |
| cameraReset(); saveHash(); | |
| return { state: stateNow() }; | |
| }, | |
| 'camera.get': () => stateNow().camera, | |
| 'camera.orbit': (da, dp = 0) => { | |
| introDone(); fly = null; | |
| const off = camera.position.clone().sub(controls.target); | |
| const sph = new THREE.Spherical().setFromVector3(off); | |
| sph.theta += THREE.MathUtils.degToRad(da); | |
| sph.phi = Math.min(Math.PI - 0.01, Math.max(0.01, sph.phi - THREE.MathUtils.degToRad(dp))); | |
| camera.position.copy(controls.target).add(new THREE.Vector3().setFromSpherical(sph)); | |
| camera.lookAt(controls.target); controls.update(); | |
| return stateNow().camera; | |
| }, | |
| 'camera.zoom': (f) => { | |
| introDone(); fly = null; | |
| const off = camera.position.clone().sub(controls.target); | |
| const d = Math.min(controls.maxDistance, Math.max(controls.minDistance, off.length() / f)); | |
| camera.position.copy(controls.target).add(off.setLength(d)); controls.update(); | |
| return stateNow().camera; | |
| }, | |
| 'camera.pan': (dx, dy = 0) => { | |
| introDone(); fly = null; mode = 'free'; | |
| const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0), up = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 1); | |
| const d = right.multiplyScalar(dx).add(up.multiplyScalar(dy)); | |
| camera.position.add(d); controls.target.add(d); controls.update(); | |
| return stateNow().camera; | |
| }, | |
| 'camera.set': (p, tg) => { | |
| const ok = (a) => Array.isArray(a) && a.length === 3 && a.every((x) => typeof x === 'number' && Number.isFinite(x)); | |
| if (!ok(p)) throw new ApiError('camera.set: position must be [x, y, z] numbers (scene millimeters).'); | |
| if (tg !== undefined && !ok(tg)) throw new ApiError('camera.set: target must be [x, y, z] numbers (scene millimeters).'); | |
| introDone(); fly = null; mode = 'free'; | |
| camera.position.set(...p); if (tg) controls.target.set(...tg); | |
| camera.lookAt(controls.target); controls.update(); | |
| return stateNow().camera; | |
| }, | |
| 'camera.reset': () => { cameraReset(); return { flying: true, mode }; }, | |
| 'destroy.mode': (on) => { setDestroy(on === undefined ? !destroyOn : on); saveHash(); return { on: destroyOn, tool }; }, | |
| 'destroy.tool': (name) => { if (!destroyTools().includes(name)) throw new ApiError(`The ${name} tool is not in this build. Tools: ${destroyTools().join(', ')}.`, 'unavailable'); needDestroy(); setTool(name); saveHash(); return { on: destroyOn, tool }; }, | |
| 'destroy.aim': async () => { | |
| // the scroll can still be moving into view (camera reset, unroll); retry for up to 5 s | |
| for (let t0 = performance.now(); ;) { | |
| try { return aimPoint(); } catch (e) { if (performance.now() - t0 > 5000) throw e; } | |
| await sleepMs(100); await nextFrame(); | |
| } | |
| }, | |
| 'destroy.tear': (x, y, o) => destroyAt('tear', x, y, o), | |
| 'destroy.crush': (x, y, o) => destroyAt('crush', x, y, o), | |
| 'destroy.burn': (x, y, o) => destroyAt('burn', x, y, o), | |
| 'destroy.water': (x, y, o) => destroyAt('water', x, y, o), | |
| 'destroy.reset': () => { damage.reset(); if (tear) { tear.reset(); breakBase = tear.stats().breaks; } return { stats: dstats() }; }, | |
| 'destroy.sandbox': (on) => { needDestroy(); const want = on === undefined ? !tearOn : on; if (want !== tearOn) setTear(want); saveHash(); return { sandbox: tearOn }; }, | |
| 'destroy.grab': async (x, y, o) => { | |
| needSandbox(); | |
| const before = tstats().breaks; | |
| // the strip may still be settling after the Sandbox opens: retry while nothing was picked up | |
| for (let i = 0; i < 4; i++) { await dragAt(x, y, o); if (tstats().activeSheets > 0 || tstats().breaks !== before) break; await sleepMs(600); } | |
| // the physics keeps running after the release; give slow machines a moment to show the rip | |
| for (let t0 = performance.now(); tstats().breaks === before && performance.now() - t0 < 3000;) await nextFrame(); | |
| return { breaksBefore: before, breaksAfter: tstats().breaks, stats: tstats() }; | |
| }, | |
| 'destroy.crumble': () => { needDestroy(); if (tearOn) setTear(false); crumble(); return { stats: dstats() }; }, | |
| prizeOnly: (on) => { const S = window.unrollScrolls; if (!S) throw new ApiError('The scroll picker is not ready yet.', 'unavailable'); S.setPrizeEligibleOnly(on === undefined ? !S.prizeEligibleOnly() : on); return { prizeOnly: S.prizeEligibleOnly(), visible: S.visible() }; }, | |
| 'destroy.shake': () => { if (!tearOn) throw new ApiError('Shake works in the Sandbox: call destroy.sandbox(true) first.', 'unavailable'); tear.shake(); return { stats: tstats() }; }, | |
| 'destroy.stats': () => ({ on: destroyOn, tool, sandbox: tearOn, stats: dstats() }), | |
| 'destroy.tools': () => ({ tools: destroyTools(), all: ['tear', 'crush', 'burn', 'water'], sandbox: ['grab', 'shake'] }), | |
| rollUp: (on) => { const want = on === undefined ? !rolling : on; if (want !== rolling) $('btnRollUp').click(); return { rolling, target: +target.toFixed(4) }; }, | |
| 'open.fromURL': (u) => storeAndOpen(() => filesFromURL(u)), | |
| 'open.fromFiles': (files) => storeAndOpen(async () => gatherFiles(await filesFromAPI(files))), | |
| 'open.example': async (o = {}) => { | |
| const b = await exampleZip(); | |
| const u8 = new Uint8Array(await b.arrayBuffer()); | |
| let bin = ''; for (let i = 0; i < u8.length; i += 0x8000) bin += String.fromCharCode(...u8.subarray(i, i + 0x8000)); | |
| if (o.download) { const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = 'unroll-synthetic-scroll.zip'; a.click(); } | |
| return { name: 'unroll-synthetic-scroll.zip', bytes: u8.length, base64: btoa(bin) }; | |
| }, | |
| 'open.listLocal': async () => ({ scrolls: await listLocal() }), | |
| 'open.removeLocal': async (key) => { | |
| const loc = await listLocal(); | |
| if (!loc.some((x) => x.key === key)) throw new ApiError(`No local scroll "${key}". Local scrolls: ${loc.map((x) => x.key).join(', ') || 'none'}.`, 'not_found'); | |
| await deleteLocal(key); OPEN.refreshList(); | |
| return { removed: key, current: key === SCROLL_KEY }; | |
| }, | |
| 'open.validate': (m) => { const v = validateManifest(m, { limits: LOCAL_LIMITS }); return { valid: v.ok, errors: v.errors, warnings: v.warnings, files: v.files }; }, | |
| }; | |
| const emitter = installAPI(IMPL, { stateFn: stateNow, onTick: (st) => a11yTick(st) }); | |
| const BRIDGE = setupBridge({ api: window.unroll, version: () => VERSION }); | |
| window.unroll.waitFor('ready', { timeout: 120000 }).then((r) => { | |
| if (!r.ok) return; | |
| if (EMBED.on && EMBED.autoplay) window.unroll.play(true); | |
| if (BRIDGE) BRIDGE.announce(); | |
| }); | |
| // accessibility: a live line for screen readers, a text alternative for the 3D view | |
| let lastSpoken = ''; | |
| function a11yTick(st) { | |
| const line = `${st.scroll.name}. ${st.layers.available.find((l) => l.id === st.layer)?.label || st.layer}. Winding ${st.winding.id}. ${Math.round(st.t * 100)} percent unrolled.${st.destroy.on ? ' Destroy mode.' : ''}${st.focus.open ? ` Close-up of ${st.focus.winding}, ${st.focus.view}.` : ''}`; | |
| if (st.settled && line !== lastSpoken) { $('liveState').textContent = line; lastSpoken = line; } | |
| $('viewDesc').textContent = st.description; | |
| } | |
| // keyboard for the 3D view (focus it with Tab): arrows orbit, Shift+arrows pan, + and - zoom, Enter reads the winding | |
| canvas.addEventListener('keydown', (e) => { | |
| const k = e.key; | |
| const step = e.shiftKey ? null : 6; | |
| if (k.startsWith('Arrow')) { | |
| e.preventDefault(); e.stopPropagation(); | |
| const dx = k === 'ArrowLeft' ? -1 : k === 'ArrowRight' ? 1 : 0, dy = k === 'ArrowUp' ? 1 : k === 'ArrowDown' ? -1 : 0; | |
| if (step) IMPL['camera.orbit'](-dx * step, dy * step); else IMPL['camera.pan'](dx * 10, dy * 10); | |
| } else if (k === '+' || k === '=') { e.preventDefault(); IMPL['camera.zoom'](1.2); } | |
| else if (k === '-' || k === '_') { e.preventDefault(); IMPL['camera.zoom'](1 / 1.2); } | |
| else if (k === 'Enter') { e.preventDefault(); lastOpener = canvas; openFocus(stateAt(t).cur); } | |
| }); | |
| viewer.addEventListener('keydown', (e) => { | |
| const k = e.key, o = {}; | |
| if (k === '+' || k === '=') o.scale = 1.25; else if (k === '-' || k === '_') o.scale = 0.8; else if (k === '0') o.fit = true; | |
| else if (k.startsWith('Arrow')) { o.x = k === 'ArrowLeft' ? 40 : k === 'ArrowRight' ? -40 : 0; o.y = k === 'ArrowUp' ? 40 : k === 'ArrowDown' ? -40 : 0; } | |
| else return; | |
| e.preventDefault(); e.stopPropagation(); IMPL.closeup(o); | |
| }); | |
| // links get a stable test id and point agents at links() | |
| function tagLinks() { | |
| for (const a of document.querySelectorAll('a[href]')) { | |
| if (!a.dataset.api) a.dataset.api = 'links()'; | |
| if (a.dataset.testid) continue; | |
| const scope = a.closest('[data-testid$="-panel"], header, footer, [id]'); | |
| const base = `link-${scope && scope.id ? scope.id.toLowerCase() + '-' : ''}${(a.getAttribute('href') || '').replace(/^https?:\/\//, '').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase().slice(0, 50)}`; | |
| let id = base, n = 2; | |
| while (document.querySelector(`[data-testid="${id}"]`)) id = `${base}-${n++}`; | |
| a.dataset.testid = id; | |
| } | |
| } | |
| tagLinks(); | |
| new MutationObserver(tagLinks).observe(document.body, { childList: true, subtree: true }); | |
| // deep links: restore close-up, compare, panels, Destroy mode and camera from the URL | |
| { | |
| const p = params; | |
| { const tl = p.get('tool') || (p.get('crush') === '1' ? 'crush' : null); | |
| if (p.get('destroy') === '1' || p.get('tear') === '1' || tl || p.get('sandbox') === '1') { setDestroy(true); if (tl && destroyTools().includes(tl)) setTool(tl); if (p.get('sandbox') === '1') setTear(true); } } | |
| if (p.has('cam')) { | |
| const c = p.get('cam').split(',').map(Number); | |
| if (c.length === 6 && c.every(Number.isFinite)) { setTimeout(() => IMPL['camera.set'](c.slice(0, 3), c.slice(3)), tearOn ? 2000 : 0); } | |
| } | |
| const f = p.get('focus'); | |
| try { | |
| if (p.has('compare')) { const cv = p.get('compare'); const pos = ['', 'on', 'true'].includes(cv) ? 0.5 : +cv; if (f) focusOpen(f); IMPL.compare(true, Number.isFinite(pos) ? Math.min(1, Math.max(0, pos)) : 0.5); } | |
| else if (f) focusOpen(f, ['main', 'overlay', 'compare'].includes(p.get('view')) ? p.get('view') : undefined); | |
| } catch (e) { console.warn('deep link:', e.message); } | |
| if (['about', 'help', 'open', 'why'].includes(p.get('panel'))) { | |
| const go = () => { try { IMPL.panel(p.get('panel'), true); } catch (e) { console.warn('deep link:', e.message); } }; | |
| if (p.get('panel') === 'help' && !$('help')) setTimeout(go, 300); else go(); | |
| } | |
| } | |
| // Add-ons such as lib/bench.js (loaded by index.html) register themselves with window.unroll.register, | |
| // or push onto window.unrollExtensions when they load before the API. | |
| // editing the hash in the same tab applies it without a reload (a different scroll still has to load) | |
| addEventListener('hashchange', async () => { | |
| const h = new URLSearchParams(location.hash.slice(1)); | |
| const s = h.get('s') || h.get('scroll') || DEFAULT_ID; | |
| if (s !== SCROLL_KEY) { | |
| const known = REG.scrolls.some((x) => x.id === s) || (localKey(s) && (await listLocal()).some((x) => x.key === s)); | |
| if (!known) { toast(`There is no scroll "${s.slice(0, 60)}" here. Scrolls: ${REG.scrolls.map((x) => x.id).join(', ')}.`); saveHash(); return; } | |
| return switchScroll(s); | |
| } | |
| try { | |
| const l = h.get('l') || h.get('layer'); | |
| if (l) await IMPL.setLayer(l === 'gpu' ? 'overlay' : l === 'ct' ? 'xray' : l); | |
| if (h.has('t') && Number.isFinite(+h.get('t'))) await IMPL.setUnroll(Math.min(1, Math.max(0, +h.get('t')))); | |
| } catch (e) { toast(e.message || String(e)); } | |
| saveHash(); | |
| }); | |