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