File size: 13,113 Bytes
f6dcaa7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
757768f
f6dcaa7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
757768f
f6dcaa7
 
 
757768f
f6dcaa7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
757768f
f6dcaa7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
// 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 };