unroll / lib /scrolls.js
SharpMeow's picture
One page scroll bar and American English
757768f verified
Raw History Blame Contribute Delete
13.1 kB
// 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 };