// A small synthetic scroll, generated in the browser: three windings of a spiral with UVs, // one OBJ per winding (and one PLY), X-ray, ink and overlay images in PNG, JPG and TIFF. // Used for the "Download example package" button and for testing "Open your own". import { writeZip } from './formats.js'; const IDS = ['s03', 's02', 's01']; // outermost first const H = 30, TURN = 1.08, R0 = 13, PITCH = 2.2; function spiral(j) { // winding j (0 outermost): a little more than one turn of an Archimedean spiral, wavy like real papyrus const C = 72, R = 13, v = [], t = [], f = []; for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) { const u = c / (C - 1), h = r / (R - 1); const th = -(j + u * TURN) * 2 * Math.PI; const rad = R0 - PITCH * (j + u * TURN) + 0.25 * Math.sin(u * 23 + h * 5); v.push([rad * Math.cos(th), (0.5 - h) * H + 0.6 * Math.sin(u * 9), rad * Math.sin(th)]); t.push([u, 1 - h]); } for (let r = 0; r + 1 < R; r++) for (let c = 0; c + 1 < C; c++) { const a = r * C + c; f.push([a, a + C, a + 1], [a + 1, a + C, a + C + 1]); } return { v, t, f }; } function obj({ v, t, f }, id) { const L = [`# synthetic winding ${id}, millimeters, roll axis along y`]; for (const p of v) L.push(`v ${p.map((x) => x.toFixed(4)).join(' ')}`); for (const q of t) L.push(`vt ${q.map((x) => x.toFixed(5)).join(' ')}`); for (const k of f) L.push(`f ${k.map((i) => `${i + 1}/${i + 1}`).join(' ')}`); return new TextEncoder().encode(L.join('\n') + '\n'); } function ply({ v, t, f }) { const head = `ply\nformat binary_little_endian 1.0\ncomment synthetic winding\nelement vertex ${v.length}\nproperty float x\nproperty float y\nproperty float z\nproperty float s\nproperty float t\nelement face ${f.length}\nproperty list uchar int vertex_indices\nend_header\n`; const hb = new TextEncoder().encode(head); const buf = new ArrayBuffer(hb.length + v.length * 20 + f.length * 13); new Uint8Array(buf).set(hb); const dv = new DataView(buf); let o = hb.length; v.forEach((p, i) => { for (const x of [...p, ...t[i]]) { dv.setFloat32(o, x, true); o += 4; } }); for (const k of f) { dv.setUint8(o++, 3); for (const i of k) { dv.setInt32(o, i, true); o += 4; } } return new Uint8Array(buf); } function canvas(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return [c, c.getContext('2d')]; } let seed = 7; const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647); function ctImage(w, h) { const [c, g] = canvas(w, h); g.fillStyle = '#2a2a2a'; g.fillRect(0, 0, w, h); for (let i = 0; i < 900; i++) { const y = rnd() * h; g.strokeStyle = `rgba(${150 + rnd() * 100 | 0},${150 + rnd() * 100 | 0},${150 + rnd() * 100 | 0},${0.08 + rnd() * 0.2})`; g.lineWidth = 1 + rnd() * 2; g.beginPath(); g.moveTo(0, y); g.lineTo(w, y + (rnd() - 0.5) * 6); g.stroke(); } for (let i = 0; i < 400; i++) { const x = rnd() * w; g.strokeStyle = `rgba(200,200,200,${0.04 + rnd() * 0.1})`; g.beginPath(); g.moveTo(x, 0); g.lineTo(x + (rnd() - 0.5) * 6, h); g.stroke(); } return c; } function inkImage(w, h, text, blur) { const [c, g] = canvas(w, h); g.fillStyle = '#000'; g.fillRect(0, 0, w, h); g.fillStyle = '#fff'; g.filter = blur ? `blur(${blur}px)` : 'none'; g.font = `${Math.round(h * 0.13)}px Georgia, serif`; const lines = text.split('\n'); lines.forEach((l, i) => g.fillText(l, w * 0.06, h * (0.2 + i * 0.17))); return c; } const png = (c) => new Promise((ok) => c.toBlob(async (b) => ok(new Uint8Array(await b.arrayBuffer())), 'image/png')); const jpg = (c) => new Promise((ok) => c.toBlob(async (b) => ok(new Uint8Array(await b.arrayBuffer())), 'image/jpeg', 0.9)); function tif(c) { // 8-bit grayscale, uncompressed, one strip const g = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; const W = c.width, Hh = c.height, n = W * Hh, tags = [[256, 3, W], [257, 3, Hh], [258, 3, 8], [259, 3, 1], [262, 3, 1], [273, 4, 8 + 2 + 10 * 12 + 4], [277, 3, 1], [278, 3, Hh], [279, 4, n], [284, 3, 1]]; const buf = new ArrayBuffer(8 + 2 + tags.length * 12 + 4 + n); const dv = new DataView(buf); dv.setUint16(0, 0x4949); dv.setUint16(2, 42, true); dv.setUint32(4, 8, true); dv.setUint16(8, tags.length, true); tags.forEach(([t, ty, v], i) => { const o = 10 + i * 12; dv.setUint16(o, t, true); dv.setUint16(o + 2, ty, true); dv.setUint32(o + 4, 1, true); if (ty === 3) dv.setUint16(o + 8, v, true); else dv.setUint32(o + 8, v, true); }); const px = new Uint8Array(buf, 8 + 2 + tags.length * 12 + 4); for (let i = 0; i < n; i++) px[i] = g[i * 4]; return new Uint8Array(buf); } export const EXAMPLE_TEXT = { s03: 'ΒΙΟΣ ΓΑΡ\nΟΥ ΒΙΩΤΟΣ\nΑΝΕΞΕΤΑΣΤΟΣ', s02: 'ΗΔΟΝΗ ΚΑΙ\nΑΤΑΡΑΞΙΑ\nΦΙΛΟΔΗΜΟΣ', s01: 'ΤΕΛΟΣ ΤΟΥ\nΒΙΒΛΙΟΥ\nΕΡΡΩΣΘΕ' }; export async function exampleFiles() { seed = 7; const files = []; const add = (name, bytes) => files.push({ name: `synthetic-scroll/${name}`, bytes }); for (const [j, id] of IDS.entries()) { const s = spiral(j); add(`meshes/${id}.${id === 's02' ? 'ply' : 'obj'}`, id === 's02' ? ply(s) : obj(s, id)); const w = Math.round(2 * Math.PI * (R0 - PITCH * (j + TURN / 2)) * TURN * 8), h = H * 8; add(`xray/${id}.png`, await png(ctImage(w, h))); const ink = inkImage(w, h, EXAMPLE_TEXT[id], 0); add(`ink/${id}.${id === 's01' ? 'tif' : id === 's02' ? 'jpg' : 'png'}`, id === 's01' ? tif(ink) : id === 's02' ? await jpg(ink) : await png(ink)); if (id === 's02') add(`overlay/${id}.png`, await png(inkImage(w, h, EXAMPLE_TEXT[id], 3))); } const manifest = { schema: 'unroll-scroll/1', id: 'synthetic-example', name: 'Synthetic example scroll', description: 'Three windings of a made-up spiral, with fake X-ray fibers and Greek text as ink. For testing "Open your own".', voxelMM: 0.01, geometry: { format: 'mesh', units: 'mm', axis: 'y' }, windings: [{ id: 's03', mesh: 'meshes/s03.obj' }, { id: 's02', mesh: 'meshes/s02.ply' }, { id: 's01', mesh: 'meshes/s01.obj' }], outermost: 'first', layers: [ { id: 'xray', role: 'ct', label: 'X-ray', texture: 'xray/{id}.png', source: 'Synthetic fiber noise', resolutionUM: 125 }, { id: 'ink', role: 'ink', label: 'Ink', texture: 'ink/{id}.png', source: 'Synthetic text', resolutionUM: 125 }, { id: 'model', role: 'overlay', label: 'Blurred ink', windings: ['s02'], texture: 'overlay/{id}.png', source: 'The ink layer, blurred, to show the compare swipe', resolutionUM: 125 }, ], credits: { text: 'Generated by the Unroll page', url: 'https://huggingface.co/spaces/SharpMeow/unroll' }, dataTerms: { license: 'CC0 1.0', url: 'https://creativecommons.org/publicdomain/zero/1.0/' }, }; // the ink files use three formats on purpose: per-winding paths are listed in "files" overrides manifest.layers[1].texture = 'ink/{id}.png'; manifest.layers[1].files = { s02: 'ink/s02.jpg', s01: 'ink/s01.tif' }; add('manifest.json', new TextEncoder().encode(JSON.stringify(manifest, null, 1))); add('README.txt', new TextEncoder().encode('Synthetic example for Unroll (huggingface.co/spaces/SharpMeow/unroll).\nOpen the page, press + (Open your own) and drop this zip or the whole folder.\nEverything stays in your browser. See docs/ADDING_A_SCROLL.md for the manifest format.\n')); return files; } export async function exampleZip() { return writeZip(await exampleFiles()); }