Download lib/scrolls.js from SharpMeow/unroll: direct link, hf CLI and curl.
- Browser
- Download file 13.1 kB
-
https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/lib/scrolls.js
- Command line
-
hf download hf://spaces/SharpMeow/unroll/lib/scrolls.js
-
curl -L -o scrolls.js https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/lib/scrolls.js
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 }; | |