// Where a scroll comes from (the site registry, or files and URLs opened in this browser), // and how a "mesh" format scroll becomes the geometry and texture atlases the page draws. import { validateManifest, validateRegistry, peelOrder, layerWindings, roleOf, layerFile, ext, LOCAL_LIMITS, REGISTRY_LIMITS } from './manifest.js'; import { parseOBJ, parsePLY, decodeTIFF } from './formats.js'; // ------------------------------------------------------------ registry export async function loadRegistry() { const res = await fetch('scrolls/index.json'); if (!res.ok) throw new Error(`scrolls/index.json: ${res.status}`); const reg = await res.json(); const v = validateRegistry(reg); if (!v.ok) throw new Error(v.errors.join(' ')); return reg; } // ------------------------------------------------------------ scrolls kept in this browser (IndexedDB, never uploaded) const DB = 'unroll-local', STORE = 'scrolls'; function db() { return new Promise((ok, bad) => { const r = indexedDB.open(DB, 1); r.onupgradeneeded = () => r.result.createObjectStore(STORE, { keyPath: 'key' }); r.onsuccess = () => ok(r.result); r.onerror = () => bad(r.error); }); } async function tx(mode, fn) { const d = await db(); return new Promise((ok, bad) => { const t = d.transaction(STORE, mode); const out = fn(t.objectStore(STORE)); t.oncomplete = () => ok(out && 'result' in out ? out.result : undefined); t.onerror = () => bad(t.error); t.onabort = () => bad(t.error || new Error('Storage was refused. Private browsing can block it.')); }); } export const localKey = (s) => s.startsWith('local-'); export async function listLocal() { try { return (await tx('readonly', (s) => s.getAll())).map(({ key, name, id, added, size }) => ({ key, name, id, added, size })); } catch (e) { return []; } } export const getLocal = (key) => tx('readonly', (s) => s.get(key)); export const saveLocal = (rec) => tx('readwrite', (s) => s.put(rec)); export const deleteLocal = (key) => tx('readwrite', (s) => s.delete(key)); // ------------------------------------------------------------ sources: one interface for remote files and dropped files const dirOf = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/') + 1) : ''); const norm = (p) => { const out = []; for (const s of p.split('/')) { if (!s || s === '.') continue; if (s === '..') out.pop(); else out.push(s); } return out.join('/'); }; // remote: paths are relative to the manifest's URL plus its "base" export function remoteSource(manifestURL, manifest, fetchBytes) { const root = new URL(manifest.base || '', new URL(manifestURL, location.href)); const url = (p) => new URL(p, root).href; return { kind: 'remote', url, size: () => undefined, bytes: (p, guess) => fetchBytes(url(p), guess), blob: async (p, guess) => new Blob([await fetchBytes(url(p), guess)]), async imageURL(p) { return ext(p).startsWith('tif') ? toPNGURL(await this.blob(p), p) : url(p); }, }; } // local: a list of { name, blob } (dropped, picked, unzipped or downloaded); paths are relative to the manifest export function fileSource(files, manifestPath) { const map = new Map(files.map((f) => [norm(f.name), f])); const base = dirOf(norm(manifestPath)); const byBase = new Map(); for (const f of files) { const b = norm(f.name).split('/').pop(); byBase.set(b, byBase.has(b) ? null : f); } const find = (p) => map.get(norm(base + p)) || map.get(norm(p)) || byBase.get(norm(p).split('/').pop()) || null; const urls = new Map(); return { kind: 'files', find, size: (p) => { const f = find(p); return f ? f.blob.size : null; }, async bytes(p) { const f = find(p); if (!f) throw new Error(`Missing file: ${p}`); return f.blob.arrayBuffer(); }, async blob(p) { const f = find(p); if (!f) throw new Error(`Missing file: ${p}`); return f.blob; }, async imageURL(p) { if (urls.has(p)) return urls.get(p); const b = await this.blob(p); const u = ext(p).startsWith('tif') ? await toPNGURL(b, p) : URL.createObjectURL(b); urls.set(p, u); return u; }, }; } // find the manifest among dropped files export async function findManifest(files) { const cands = files.filter((f) => ext(f.name) === 'json'); const hits = []; for (const f of cands) { if (f.blob.size > 4e6) continue; try { const j = JSON.parse(await f.blob.text()); if (j && (j.schema || j.windings)) hits.push({ f, j }); } catch (e) { if (/manifest/i.test(f.name)) throw new Error(`${f.name} is not valid JSON: ${e.message}`); } } if (!hits.length) throw new Error('No manifest found. Add a manifest.json (see the example package), or drop a zip that contains one.'); if (hits.length > 1) { const m = hits.filter((h) => /(^|\/)manifest\.json$/i.test(h.f.name)); if (m.length === 1) return { path: m[0].f.name, manifest: m[0].j }; throw new Error(`Found ${hits.length} manifests (${hits.map((h) => h.f.name).join(', ')}). Open one scroll at a time.`); } return { path: hits[0].f.name, manifest: hits[0].j }; } // ------------------------------------------------------------ images export async function decodeImage(blob, name, maxSide = LOCAL_LIMITS.maxTextureSide) { let bmp; try { if (ext(name).startsWith('tif')) bmp = await createImageBitmap(await decodeTIFF(await blob.arrayBuffer(), maxSide)); else bmp = await createImageBitmap(blob); } catch (e) { throw new Error(`${name}: could not decode the image (${e.message || 'unsupported or damaged file'}). Use PNG, JPG or TIFF.`); } if (bmp.width > maxSide || bmp.height > maxSide) throw new Error(`${name} is ${bmp.width} x ${bmp.height} px; the limit is ${maxSide} px on a side.`); return bmp; } async function toPNGURL(blob, name) { const bmp = await decodeImage(blob, name); const c = document.createElement('canvas'); c.width = bmp.width; c.height = bmp.height; c.getContext('2d').drawImage(bmp, 0, 0); return new Promise((ok) => c.toBlob((b) => ok(URL.createObjectURL(b)), 'image/png')); } // ------------------------------------------------------------ mesh scrolls export function parseMesh(buf, name) { const e = ext(name); if (e === 'obj') return parseOBJ(new TextDecoder().decode(buf)); if (e === 'ply') return parsePLY(buf); throw new Error(`${name}: meshes must be OBJ or PLY.`); } /** * Turn a "mesh" manifest into what the page draws: per winding geometry in scene millimeters, roll axis on the origin, * and CT and ink atlases (canvases) with one rectangle per winding, the same shape the registry's grid scrolls use. */ export async function buildMeshScroll(m, src, opts = {}) { const lim = opts.limits || LOCAL_LIMITS; const order = peelOrder(m); const g = m.geometry; const k = (g.units || 'voxels') === 'mm' ? 1 : m.voxelMM; const zAxis = (g.axis || 'z') === 'z'; const flipV = g.flipV !== false; const W = []; let nV = 0; for (const w of order) { let mesh; try { mesh = parseMesh(await src.bytes(w.mesh, 2e5), w.mesh); } catch (e) { throw new Error(`Winding ${w.id}: ${e.message}`); } nV += mesh.vertices; if (nV > lim.maxVertices) throw new Error(`The meshes have more than ${lim.maxVertices.toLocaleString()} vertices together; decimate them first.`); const P = mesh.pos, n = mesh.vertices; const pos = new Float32Array(n * 3), loc = new Float32Array(n * 2); for (let i = 0; i < n; i++) { const x = P[i * 3] * k, y = P[i * 3 + 1] * k, z = P[i * 3 + 2] * k; if (zAxis) { pos[i * 3] = x; pos[i * 3 + 1] = -z; pos[i * 3 + 2] = y; } else { pos[i * 3] = x; pos[i * 3 + 1] = y; pos[i * 3 + 2] = z; } loc[i * 2] = mesh.uv[i * 2]; loc[i * 2 + 1] = flipV ? 1 - mesh.uv[i * 2 + 1] : mesh.uv[i * 2 + 1]; } W.push({ id: w.id, pos, loc, index: mesh.index, n }); } // put the roll's axis on the origin: center of the bounding box const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity]; for (const w of W) for (let i = 0; i < w.n * 3; i++) { const a = i % 3; if (w.pos[i] < lo[a]) lo[a] = w.pos[i]; if (w.pos[i] > hi[a]) hi[a] = w.pos[i]; } const c = [0, 1, 2].map((a) => (lo[a] + hi[a]) / 2); if (g.center === false || g.centre === false) c.fill(0); // geometry.center: false keeps the mesh's own origin (the older British key still works) for (const w of W) for (let i = 0; i < w.n * 3; i++) w.pos[i] -= c[i % 3]; const COLS = 96; for (const w of W) { const { pos, loc, index } = w; // size of the flattened sheet: the mean stretch of the UV map, weighted by triangle area let su = 0, sv = 0, sa = 0; for (let t = 0; t < index.length; t += 3) { const a = index[t], b = index[t + 1], d = index[t + 2]; const e1 = [0, 1, 2].map((q) => pos[b * 3 + q] - pos[a * 3 + q]), e2 = [0, 1, 2].map((q) => pos[d * 3 + q] - pos[a * 3 + q]); const du1 = loc[b * 2] - loc[a * 2], dv1 = loc[b * 2 + 1] - loc[a * 2 + 1], du2 = loc[d * 2] - loc[a * 2], dv2 = loc[d * 2 + 1] - loc[a * 2 + 1]; const det = du1 * dv2 - du2 * dv1; const cx = e1[1] * e2[2] - e1[2] * e2[1], cy = e1[2] * e2[0] - e1[0] * e2[2], cz = e1[0] * e2[1] - e1[1] * e2[0]; const area = 0.5 * Math.hypot(cx, cy, cz); sa += area; if (Math.abs(det) < 1e-12) continue; const pu = [0, 1, 2].map((q) => (e1[q] * dv2 - e2[q] * dv1) / det), pv = [0, 1, 2].map((q) => (e2[q] * du1 - e1[q] * du2) / det); su += Math.hypot(...pu) * area; sv += Math.hypot(...pv) * area; } if (!(sa > 0) || !(su > 0) || !(sv > 0)) throw new Error(`Winding ${w.id}: the mesh has no area or its UVs are degenerate.`); w.widthMM = su / sa; w.heightMM = sv / sa; w.areaCM2 = sa / 100; // radius, top edge, and the angle of each slice along u for the peel let r = 0, yt = 0; const cx = new Float64Array(COLS), cz = new Float64Array(COLS), cn = new Float64Array(COLS); for (let i = 0; i < w.n; i++) { const x = pos[i * 3], y = pos[i * 3 + 1], z = pos[i * 3 + 2]; r += Math.hypot(x, z); yt += y + loc[i * 2 + 1] * w.heightMM; const b = Math.min(COLS - 1, Math.max(0, Math.round(loc[i * 2] * (COLS - 1)))); cx[b] += x; cz[b] += z; cn[b]++; } w.radiusMM = r / w.n; w.ytop = yt / w.n; const ang = new Float64Array(COLS); let prev = null; for (let b = 0; b < COLS; b++) { if (!cn[b]) { ang[b] = prev ?? 0; continue; } let a = Math.atan2(cz[b] / cn[b], cx[b] / cn[b]); if (prev !== null) { while (a - prev > Math.PI) a -= 2 * Math.PI; while (a - prev < -Math.PI) a += 2 * Math.PI; } ang[b] = a; prev = a; } let f0 = 0; while (f0 < COLS && !cn[f0]) f0++; for (let b = 0; b < f0 && f0 < COLS; b++) ang[b] = ang[f0]; w.colAng = ang; w.colIdx = Array.from({ length: COLS }, (_, i) => i); w.gridW = COLS - 1; } // atlases: shelf-pack one rectangle per winding, sized by its millimeters const S = opts.atlasSide || 4096, pad = 4; let ppmm = Math.min(...W.map((w) => (S - 2 * pad) / Math.max(w.widthMM, w.heightMM))); for (let it = 0; it < 80; it++) { let x = pad, y = pad, rowH = 0, fits = true; for (const w of W) { const rw = Math.ceil(w.widthMM * ppmm), rh = Math.ceil(w.heightMM * ppmm); if (x + rw + pad > S) { x = pad; y += rowH + pad; rowH = 0; } w.rect = [x, y, rw, rh]; x += rw + pad; rowH = Math.max(rowH, rh); if (y + rh + pad > S) fits = false; } if (fits) break; ppmm *= 0.92; } ppmm = Math.min(ppmm, opts.maxPPMM || 40); const ids = W.map((w) => w.id); const ctL = roleOf(m, 'ct'), inkL = roleOf(m, 'ink'); const mk = () => { const cv = document.createElement('canvas'); cv.width = cv.height = S; const x = cv.getContext('2d'); x.fillStyle = '#000'; x.fillRect(0, 0, S, S); return [cv, x]; }; const [ctC, ctX] = mk(), [inkC, inkX] = mk(); const ctSet = new Set(ctL ? layerWindings(ctL, ids) : []), inkSet = new Set(inkL ? layerWindings(inkL, ids) : []); for (const w of W) { const [x, y, rw, rh] = w.rect; w.atlas = [x / S, y / S, (x + rw) / S, (y + rh) / S]; if (ctSet.has(w.id)) { const p = layerFile(ctL, w.id); ctX.drawImage(await decodeImage(await src.blob(p, 3e5), p), x, y, rw, rh); } else { // no X-ray for this winding: paint the mesh's UV footprint so the page knows where papyrus is ctX.fillStyle = 'rgb(90,90,90)'; for (let t = 0; t < w.index.length; t += 3) { ctX.beginPath(); for (let q = 0; q < 3; q++) { const v = w.index[t + q]; ctX[q ? 'lineTo' : 'moveTo'](x + w.loc[v * 2] * rw, y + w.loc[v * 2 + 1] * rh); } ctX.closePath(); ctX.fill(); ctX.strokeStyle = ctX.fillStyle; ctX.lineWidth = 1; ctX.stroke(); } } if (inkSet.has(w.id)) { const p = layerFile(inkL, w.id); inkX.drawImage(await decodeImage(await src.blob(p, 3e5), p), x, y, rw, rh); } w.hasInk = inkSet.has(w.id); if (opts.progress) opts.progress(w.id); } return { windings: W, ctCanvas: ctC, inkCanvas: inkC, cellMM: Math.max(...W.map((w) => w.widthMM)) / COLS }; } export { validateManifest, LOCAL_LIMITS, REGISTRY_LIMITS };