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