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