diff --git "a/app.js" "b/app.js" --- "a/app.js" +++ "b/app.js" @@ -1,25 +1,12 @@ -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 params = new URLSearchParams(location.hash.slice(1)); 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; @@ -30,7 +17,6 @@ const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches; 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)}%`; @@ -74,258 +60,23 @@ async function loadTexture(url, guess) { 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, 'µ'); -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 ? `${esc(t)}` : 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 ? '' : ' · local, not uploaded'}`; - - // 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) +const M = await (await fetch('data/scroll.json')).json(); 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 +try { + const r = await fetch('data/gpu.json'); + if (r.ok) GPU = await r.json(); +} catch (e) { /* no GPU layer yet */ } + +const [binBuf, texInk, texCT] = await Promise.all([ + fetchBytes('data/scroll.bin', 3.5e6), + loadTexture('data/atlas_ink.jpg', 2.5e6), + loadTexture('data/atlas_ct.jpg', 4.3e6), +]); 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); } +if (GPU) { + await Promise.all(Object.entries(GPU.windings).map(async ([id, g]) => { + try { gpuTex[id] = await loadTexture(g.tex, 1.5e5); } catch (e) { delete GPU.windings[id]; } })); - return ovLoading; } const blank = new THREE.DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1); blank.needsUpdate = true; @@ -362,18 +113,15 @@ composer.addPass(new OutputPass()); const VOX = M.voxelMM; const CELL = M.cellMM; -const all = binBuf ? new Int16Array(binBuf) : null; +const all = new Int16Array(binBuf); 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; @@ -391,11 +139,7 @@ void main() { 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 + vN = normalize(mix(nc, vec3(0.0, 0.0, 1.0), e)); vLocal = aLocal; vE = e; vec4 w = modelMatrix * vec4(p, 1.0); @@ -407,7 +151,6 @@ 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; @@ -418,12 +161,11 @@ void main() { 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 rim = pow(1.0 - ndv, 3.0); float diff = 0.28 + 0.72 * ndl; // X-ray: the CT intensity on the surface, cool and clinical @@ -432,10 +174,6 @@ void main() { // Papyrus: carbon-dark, the fibres 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); @@ -452,79 +190,32 @@ void main() { 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 (colour flashes, then black) during the intro sweep. Square it by hand. - float sd = (vW.y - uSweep) / 0.35; - float line = exp(-sd * sd); + float line = exp(-pow((vW.y - uSweep) / 0.35, 2.0)); 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; +const PICK_FRAG = /* glsl */` +uniform float uId; 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); + gl_FragColor = vec4(uId / 255.0, 0.0, 0.0, 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); +const wins = [...M.windings].sort((a, b) => (a.id < b.id ? 1 : -1)); let S = 0; -const gpuIds = OV_IDS; +const gpuIds = new Set(GPU ? Object.keys(GPU.windings) : []); 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 +const decim = MOBILE ? 2 : 1; 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) { +for (const [j, w] of wins.entries()) { 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); @@ -572,66 +263,25 @@ function gridGeometry(w) { 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 tear = CELL * 3 * 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 neighbours, 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); + if (valid[p00] && valid[p01] && valid[p10] && d2(p00, p01) < t2 && d2(p00, p10) < t2) idx.push(p00, p10, p01); + if (valid[p01] && valid[p10] && valid[p11] && d2(p11, p01) < t2 && d2(p11, p10) < t2) 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.setIndex(idx); 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 = { @@ -641,10 +291,9 @@ for (const [j, w] of wins.entries()) { 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, + uniforms: { ...shared, ...own }, vertexShader: VERT, fragmentShader: FRAG, side: THREE.DoubleSide, }); const pickMat = new THREE.ShaderMaterial({ uniforms: { ...shared, ...own }, vertexShader: VERT, fragmentShader: PICK_FRAG, side: THREE.DoubleSide, @@ -665,122 +314,6 @@ for (const m of meshes) { } 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; @@ -839,91 +372,48 @@ for (const m of meshes) { 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 = `ROLLED · OUTERMOST ${esc(outer)}OUTER ${esc(outer)}INNERMOST ${esc(inner)} · UNROLLEDINNER ${esc(inner)}`; -} 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 ? '' : ''; - // 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); + if (v && t >= 0.999) { t = target = 0; tShown = 0; } } -$('btnRollUp').onclick = () => { - introDone(); autoplayAt = 0; - if (rolling) { setRolling(false); return; } - if (tearOn) setTear(false); - setPlaying(false); - if (target > 0.0005) setRolling(true); -}; +$('play').onclick = () => { introDone(); autoplayAt = 0; setPlaying(!playing); }; const scrub = $('scrub'); let scrubbing = false; function scrubTo(ev) { const r = scrub.getBoundingClientRect(); - setTarget((ev.clientX - r.left) / r.width, 'drag'); + target = Math.min(1, Math.max(0, (ev.clientX - r.left) / r.width)); } -scrub.addEventListener('pointerdown', (e) => { introDone(); autoplayAt = 0; setRolling(false); scrubbing = true; setPlaying(false); scrub.setPointerCapture(e.pointerId); scrubTo(e); }); +scrub.addEventListener('pointerdown', (e) => { introDone(); autoplayAt = 0; 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'); }); +scrub.addEventListener('pointerup', () => { scrubbing = false; saveHash(); }); 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 (!$('focus').hidden || !$('about').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'); } + if (e.code === 'ArrowRight') { introDone(); autoplayAt = 0; target = Math.min(1, target + 0.01); } + if (e.code === 'ArrowLeft') { introDone(); autoplayAt = 0; target = Math.max(0, target - 0.01); } }); // 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('input', () => { introDone(); autoplayAt = 0; setPlaying(false); target = scrubIn.value / 1000; }); 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' ? '' : '') + 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(); + for (const b of document.querySelectorAll('.chip[data-layer]')) b.classList.toggle('on', b.dataset.layer === l); saveHash(); } for (const b of document.querySelectorAll('.chip[data-layer]')) { @@ -931,133 +421,38 @@ for (const b of document.querySelectorAll('.chip[data-layer]')) { 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'); } + if (b.dataset.layer === 'gpu' && target < bandEnd + 0.01) { autoplayAt = 0; setPlaying(false); target = Math.min(1, bandEnd + 0.015); } }; } -$('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) { +if (GPU) { $('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}`; + band.title = 'Windings inked live on ZeroGPU'; $('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; + $('rGpuRow').hidden = false; + const a = $('aboutGpu'); + a.hidden = false; + a.innerHTML = GPU.about; } + function saveHash() { - history.replaceState(null, '', `#${hashState(false)}`); + const h = new URLSearchParams({ t: target.toFixed(3), l: layer }); + history.replaceState(null, '', `#${h}`); } // ---------------------------------------------------------------- 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 mode = 'roll'; 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 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); - const D = 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 centre, 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, centred in the same free area - const td = new THREE.Vector3(0, 120, 7), tD = 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; +// opening distance: one OrbitControls wheel step (at devicePixelRatio 1) farther out than first shipped +const OPEN_STEP = 1 / Math.pow(0.95, controls.zoomSpeed); +const ROLL_OFFSET = new THREE.Vector3(-105, 52, 235).multiplyScalar(OPEN_STEP); +const ROLL_OFFSET_PORTRAIT = new THREE.Vector3(-70, 70, 330).multiplyScalar(OPEN_STEP); +const rollOffset = () => (camera.aspect < 1 ? ROLL_OFFSET_PORTRAIT : ROLL_OFFSET); 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 }; } @@ -1069,30 +464,29 @@ function overview() { 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); +let intro = !params.has('t') && !REDUCED && !SHOT; 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(); +const TOP_TARGET = new THREE.Vector3(innerWidth > innerHeight ? -30 : 0, -2, 0); +const TOP_POS = TOP_TARGET.clone().add(new THREE.Vector3(0, 120, 7).multiplyScalar(OPEN_STEP)); 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 centre (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); + // keep the roll a little right of centre so the emerging strip has room + const w = innerWidth / innerHeight; + const x = st.s - (w > 1 ? 60 : 10); + return new THREE.Vector3(x, -2, stripZ * 0.4); } 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(); + t = target = tShown = +params.get('t') || 0; + if (['xray', 'ink', 'gpu'].includes(params.get('l'))) setLayer(params.get('l') === 'gpu' && !GPU ? 'ink' : params.get('l')); shared.uSweep.value = -999; } { @@ -1101,27 +495,12 @@ if (params.has('t')) { 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; @@ -1131,7 +510,7 @@ function pick(clientX, clientY) { renderer.render(scene, camera); renderer.readRenderTargetPixels(pickTarget, 0, 0, 1, 1, pickBuf); renderer.setRenderTarget(null); - renderer.setClearColor(prevClear, 1); + renderer.setClearColor(0x030303, 1); for (const m of meshes) m.material = m.userData.mat; camera.clearViewOffset(); const id = pickBuf[0]; @@ -1140,9 +519,9 @@ function pick(clientX, clientY) { 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('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY, t: performance.now() }; }); canvas.addEventListener('pointerup', (e) => { - if (!down || tearOn) return; + if (!down) 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); @@ -1151,7 +530,7 @@ canvas.addEventListener('pointerup', (e) => { down = null; }); canvas.addEventListener('pointermove', (e) => { - if (MOBILE || e.buttons || tearOn || destroyOn) return; + if (MOBILE || e.buttons) return; const now = performance.now(); if (now - hoverAt < 70) return; hoverAt = now; @@ -1165,7 +544,7 @@ canvas.addEventListener('pointermove', (e) => { 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) ? ` · ${esc(L_OV.readoutLabel || L_OV.label.toUpperCase())}` : ''} · click to read`; + tip.innerHTML = `${w.id.toUpperCase()} · r ${w.radiusMM.toFixed(1)} mm · ${w.areaCM2.toFixed(0)} cm²${gpuIds.has(w.id) ? ' · ZEROGPU' : ''} · click to read`; canvas.style.cursor = 'pointer'; } else { tip.hidden = true; @@ -1178,31 +557,7 @@ canvas.addEventListener('pointerleave', () => { $('tip').hidden = true; if (hove 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 millimetres 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 vApply() { img.style.transform = `translate(${vz.x}px, ${vz.y}px) scale(${vz.s})`; } function vFit() { const r = viewer.getBoundingClientRect(); const s = Math.min(r.width / img.naturalWidth, r.height / img.naturalHeight) * 0.96; @@ -1244,109 +599,48 @@ 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); + const g = GPU && GPU.windings[w.id]; + const views = [['vc', 'Ink · 2.4 µm', w.hi]]; + if (g) views.push(['gpu', 'ZeroGPU · 9.4 µm', g.hi]); 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.textContent = label; b.onclick = () => { - for (const x of seg.children) { x.classList.toggle('on', x === b); x.setAttribute('aria-selected', x === b); } + for (const x of seg.children) x.classList.toggle('on', 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); + img.src = `data/${src}`; + $('fMeta').innerHTML = k === 'gpu' ? g.meta : 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); + img.src = `data/${views[start][2]}`; + $('fMeta').innerHTML = start ? g.meta : 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`; + `ink: Vesuvius Challenge public detection on the 2.399 µm scan · 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; + const wasOpen = !$('focus').hidden || !$('about').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); }; @@ -1355,7 +649,6 @@ $('btnInfo').onclick = () => { lastOpener = $('btnInfo'); $('about').hidden = !$('about').hidden; if (!$('about').hidden) $('aClose').focus(); - saveHash(); }; $('aClose').onclick = closeSheets; @@ -1385,187 +678,32 @@ function composeShot() { 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); + g.fillText('PHERC. 0139 / HERCULANEUM / BURIED 79 CE', 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('Unroll a scroll nobody', x, 40 * s + fs * 1.05); + const l2 = 'has opened in '; 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.fillText('1,947 years.', 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); + g.fillText(`${(tShown * L / 1000).toFixed(2)} m of traced strip unrolled · data: Vesuvius Challenge, CC BY-NC 4.0 (creativecommons.org/licenses/by-nc/4.0)`, 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.download = `pherc0139-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 fibre: 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', () => { @@ -1573,67 +711,47 @@ addEventListener('resize', () => { 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; +let autoplayAt = SHOT ? 0 : performance.now() + 9000; introStart = performance.now(); $('loader').classList.add('gone'); setTimeout(() => $('loader').remove(), 1200); function frame() { - const rawDt = clock.getDelta(); - const dt = Math.min(0.05, rawDt); + const dt = Math.min(0.05, clock.getDelta()); 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 k = (now - introStart) / 1000; + // 0-1.4 s: hold above the spiral; 1.4-4.4 s: down to a three-quarter view; 4.6-7.6 s: the ink scan sweeps down + const a = Math.min(1, Math.max(0, (k - 1.4) / 3.0)); + const e = a < 0.5 ? 4 * a * a * a : 1 - Math.pow(-2 * a + 2, 3) / 2; 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)); + const sw = Math.min(1, Math.max(0, (k - 4.6) / 3.0)); shared.uSweep.value = 40 - sw * 85; - if (k > 2.5) { intro = false; shared.uSweep.value = -999; autoplayAt = now + 150; $('skipIntro')?.remove(); } + if (k > 7.7) { intro = false; shared.uSweep.value = -999; autoplayAt = now + 1800; } } 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'); + target = Math.min(1, target + dt / DURATION); 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) { + if (!intro && mode === 'roll' && t >= 0.999 && !scrubbing) { mode = 'overview'; flyTo(...overview(), 3.2); } else if (!intro && mode === 'overview' && t < 0.97) { @@ -1648,7 +766,7 @@ function frame() { 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' ) { + } 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); @@ -1665,12 +783,13 @@ function frame() { // readout $('rWind').textContent = st.cur.userData.w.id; $('btnRead').textContent = `Read ${st.cur.userData.w.id}`; + if (document.activeElement !== scrubIn) scrubIn.value = Math.round(t * 1000); scrubIn.setAttribute('aria-valuetext', `${(st.s / 1000).toFixed(2)} of ${(L / 1000).toFixed(2)} metres 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` : '·'; + const g = GPU.windings[st.cur.userData.w.id]; + $('rGpu').textContent = g ? `${g.patches.toLocaleString()} patches · ${g.gpuSeconds} s` : '·'; $('rGpuRow').classList.toggle('live', !!g); } $('fill').style.width = `${t * 100}%`; @@ -1678,559 +797,7 @@ function frame() { if (playing && now - lastHash > 1500) { lastHash = now; saveHash(); } controls.update(); - if (!document.body.classList.contains('bench-open')) composer.render(); // paused under the Ink Bench + composer.render(); 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 centre) 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, - 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 }; }, - 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 centre -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 neighbours 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 millimetres).'); - if (tg !== undefined && !ok(tg)) throw new ApiError('camera.set: target must be [x, y, z] numbers (scene millimetres).'); - 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(); -});