// SPDX-License-Identifier: Apache-2.0 // CT volume view: a cube of the raw X-ray scan (up to 256 voxels a side, 128 on phones) around a point picked // on the papyrus, drawn beside the 3D scroll as a maximum intensity projection (MIP) inside an oriented slab. // The slab starts perpendicular to the papyrus surface at the picked point, so the MIP looks across the sheet. import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { volumeFor, readRegion, levelMeta } from './ctsource.js'; const $ = (id) => document.getElementById(id); const MOBILE = matchMedia('(pointer: coarse)').matches || innerWidth < 720; const SIZES = [64, 128, 192, 256]; const st = { open: false, loading: false, loaded: false, error: null, scroll: null, source: null, point: null, voxel: null, normal: [0, 0, 1], level: 1, size: MOBILE ? 128 : 256, start: null, shape: null, slab: { on: true, thickness: 16, tiltX: 0, tiltY: 0, offset: 0 }, win: { low: 0, high: 1, auto: true }, chunksLoaded: 0, chunksTotal: 0, loadMs: 0, bytes: 0, renders: 0, lastRenderMs: 0, }; const setting = (k, d) => { try { return window.unrollView.settings.get(k); } catch { return d; } }; const sizeSetting = () => { const v = setting('ctVolSize', 'auto'); return v === 'auto' ? (MOBILE ? 128 : 256) : +v; }; const levelSetting = () => { const v = setting('ctVolDetail', 'auto'); return v === 'auto' ? (MOBILE ? 2 : 1) : +v; }; // ---------------------------------------------------------------- scan coordinates of a surface point // The mesh grids hold scan voxels minus the roll axis center (tools/build.py). UV -> fractional grid cell -> // bilinear voxel position; the surface normal comes from the row and column tangents. function scanPoint(winding, u, v) { const G = window.unrollView?.scanGeom?.(); if (!G?.all || !G.axis) return null; const w = G.wins.find((q) => q.id === winding); if (!w) return null; const a = G.all.subarray(w.offset / 2, (w.offset + w.bytes) / 2), C = w.cols, R = w.rows; const idx = (arr, f) => { let k = 0; while (k < arr.length - 2 && arr[k + 1] <= f) k++; return k + Math.max(0, Math.min(1, (f - arr[k]) / Math.max(1, arr[k + 1] - arr[k]))); }; const fc = idx(w.colIdx, u * w.gridW - 0.5), fr = idx(w.rowIdx, v * w.gridH - 0.5); const at = (r, c) => { r = Math.max(0, Math.min(R - 1, r)); c = Math.max(0, Math.min(C - 1, c)); const k = (r * C + c) * 3; return a[k] === -32768 ? null : [a[k], a[k + 1], a[k + 2]]; }; // nearest valid vertex when the 2x2 cell is incomplete const r0 = Math.floor(fr), c0 = Math.floor(fc), tr = fr - r0, tc = fc - c0; const q = [at(r0, c0), at(r0, c0 + 1), at(r0 + 1, c0), at(r0 + 1, c0 + 1)]; let p; if (q.every(Boolean)) p = [0, 1, 2].map((i) => (q[0][i] * (1 - tc) + q[1][i] * tc) * (1 - tr) + (q[2][i] * (1 - tc) + q[3][i] * tc) * tr); else { for (let rad = 0; rad < 6 && !p; rad++) for (let dr = -rad; dr <= rad && !p; dr++) for (let dc = -rad; dc <= rad && !p; dc++) p = at(Math.round(fr) + dr, Math.round(fc) + dc); } if (!p) return null; const c = G.axis.centre, voxel = [p[0] + c[0], p[1] + c[1], p[2] + c[2]]; const rr = Math.round(fr), cc = Math.round(fc); const du = sub(at(rr, cc + 1) || at(rr, cc), at(rr, cc - 1) || at(rr, cc)), dv = sub(at(rr + 1, cc) || at(rr, cc), at(rr - 1, cc) || at(rr, cc)); let n = du && dv ? norm(cross(du, dv)) : [0, 0, 1]; if (!n.every(Number.isFinite)) n = [0, 0, 1]; return { winding, u, v, voxel: voxel.map((x) => +x.toFixed(1)), normal: n.map((x) => +x.toFixed(4)) }; } const sub = (a, b) => (a && b ? [a[0] - b[0], a[1] - b[1], a[2] - b[2]] : null); const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; const norm = (a) => { const l = Math.hypot(...a) || 1; return a.map((x) => x / l); }; // ---------------------------------------------------------------- renderer (own canvas, drawn on demand) let R = null, scene, camera, controls, mat, box, volTex = null, labTex = null, palTex = null; const VERT = /* glsl */` uniform vec3 uCam; out vec3 vOrigin; out vec3 vDir; void main() { vOrigin = uCam; vDir = position - uCam; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`; // Raymarched MIP in a unit cube centered on the origin (texture coordinate = position + 0.5). The ray is clipped // to the box and, when the slab is on, to |dot(p, n) - offset| <= half thickness. The brightest sample wins; its // position picks the label color when the label volume is on (lib/ctlabels.js). const FRAG = /* glsl */` precision highp float; precision highp sampler3D; uniform sampler3D uVol, uLab; uniform sampler2D uPal; uniform vec3 uN; uniform float uHalf, uOff, uSlabOn, uSteps, uLabOn, uActive; uniform vec2 uWin; in vec3 vOrigin; in vec3 vDir; out vec4 outColor; vec2 hitBox(vec3 o, vec3 d) { vec3 inv = 1.0 / d, a = (vec3(-0.5) - o) * inv, b = (vec3(0.5) - o) * inv; vec3 lo = min(a, b), hi = max(a, b); return vec2(max(max(lo.x, lo.y), lo.z), min(min(hi.x, hi.y), hi.z)); } void main() { vec3 d = normalize(vDir); vec2 t = hitBox(vOrigin, d); t.x = max(t.x, 0.0); if (uSlabOn > 0.5) { float dn = dot(d, uN), pn = dot(vOrigin, uN) - uOff; if (abs(dn) < 1e-5) { if (abs(pn) > uHalf) discard; } else { float a = (-uHalf - pn) / dn, b = (uHalf - pn) / dn; t.x = max(t.x, min(a, b)); t.y = min(t.y, max(a, b)); } } if (t.x >= t.y) discard; float dt = 1.0 / uSteps; float j = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) * dt; // jitter against banding float mx = 0.0; vec3 at = vOrigin + d * t.x + 0.5; float lab = 0.0, hitActive = 0.0; for (int i = 0; i < 1024; i++) { float s = t.x + j + float(i) * dt; if (s > t.y) break; vec3 p = vOrigin + d * s + 0.5; float v = texture(uVol, p).r; if (v > mx) { mx = v; at = p; } if (uLabOn > 0.5) { float l = floor(texture(uLab, p).r * 255.0 + 0.5); if (l > 0.5 && lab < 0.5) lab = l; if (uActive > 0.0 && abs(l - uActive) < 0.5) hitActive = 1.0; } } float g = clamp((mx - uWin.x) / max(uWin.y - uWin.x, 0.001), 0.0, 1.0); vec3 col = vec3(g); if (uLabOn > 0.5) { if (uActive > 0.0 && hitActive > 0.5) lab = uActive; if (lab > 0.5) { vec3 pc = texture(uPal, vec2((lab + 0.5) / 256.0, 0.5)).rgb; float k = uActive > 0.0 ? (abs(lab - uActive) < 0.5 ? 0.7 : 0.12) : 0.4; col = mix(col, pc * (0.3 + 0.7 * g), k); } } outColor = vec4(col, 1.0); }`; function ensureRenderer() { if (R) return; const cv = $('ctVolCanvas'); R = new THREE.WebGLRenderer({ canvas: cv, antialias: false, powerPreference: 'high-performance', preserveDrawingBuffer: true }); R.setPixelRatio(Math.min(devicePixelRatio, MOBILE ? 1.5 : 2)); scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera(35, 1, 0.01, 20); controls = new OrbitControls(camera, cv); controls.enableDamping = false; controls.enablePan = false; controls.minDistance = 0.8; controls.maxDistance = 6; controls.addEventListener('change', draw); const empty = new THREE.Data3DTexture(new Uint8Array(1), 1, 1, 1); empty.format = THREE.RedFormat; empty.needsUpdate = true; palTex = new THREE.DataTexture(new Uint8Array(256 * 4), 256, 1); palTex.needsUpdate = true; mat = new THREE.ShaderMaterial({ glslVersion: THREE.GLSL3, vertexShader: VERT, fragmentShader: FRAG, side: THREE.BackSide, uniforms: { uVol: { value: empty }, uLab: { value: empty }, uPal: { value: palTex }, uCam: { value: new THREE.Vector3() }, uN: { value: new THREE.Vector3(0, 0, 1) }, uHalf: { value: 0.03 }, uOff: { value: 0 }, uSlabOn: { value: 1 }, uSteps: { value: 256 }, uLabOn: { value: 0 }, uActive: { value: 0 }, uWin: { value: new THREE.Vector2(0, 1) } }, }); box = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), mat); scene.add(box); const edges = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1, 1, 1)), new THREE.LineBasicMaterial({ color: 0x5a6672 })); scene.add(edges); new ResizeObserver(() => { resize(); draw(); }).observe(cv); resize(); } function resize() { const cv = $('ctVolCanvas'); if (!cv || !R) return; const w = cv.clientWidth || 300, h = cv.clientHeight || 300; R.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); } let pending = false; function draw() { if (!R || pending) return; pending = true; requestAnimationFrame(() => { pending = false; renderNow(); }); } function renderNow() { if (!R) return 0; const U = mat.uniforms, s = st.slab; U.uCam.value.copy(camera.position); U.uN.value.fromArray(slabNormal()); U.uHalf.value = s.thickness / 2 / st.size; U.uOff.value = s.offset / st.size; U.uSlabOn.value = s.on ? 1 : 0; U.uSteps.value = st.size * 1.25; U.uWin.value.set(st.win.low, st.win.high); const t0 = performance.now(); R.render(scene, camera); st.renders++; st.lastRenderMs = +(performance.now() - t0).toFixed(2); return st.lastRenderMs; } // the slab normal in texture space (x, y, z = scan x, y, z): the surface normal turned by the two tilt angles function slabNormal() { const n = new THREE.Vector3().fromArray(st.normal).normalize(); const a = Math.abs(n.z) < 0.9 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(1, 0, 0); const e1 = new THREE.Vector3().crossVectors(n, a).normalize(), e2 = new THREE.Vector3().crossVectors(n, e1).normalize(); n.applyAxisAngle(e1, THREE.MathUtils.degToRad(st.slab.tiltX)).applyAxisAngle(e2, THREE.MathUtils.degToRad(st.slab.tiltY)); return n.normalize().toArray(); } function faceSlab() { // look along the slab normal; scan z (slice index) points up the screen const n = new THREE.Vector3().fromArray(slabNormal()); camera.position.copy(n.multiplyScalar(1.9)); camera.up.set(0, 0, 1); if (Math.abs(n.normalize().z) > 0.95) camera.up.set(0, 1, 0); camera.lookAt(0, 0, 0); controls.target.set(0, 0, 0); controls.update(); } // ---------------------------------------------------------------- loading let ctrl = null; async function load() { const src = st.source; if (!src || !st.voxel) return; ctrl?.abort(); ctrl = new AbortController(); const my = ctrl; st.loading = true; st.loaded = false; st.error = null; st.chunksLoaded = 0; ui(); const t0 = performance.now(); try { const m = await levelMeta(src.zarr, st.level); const N = st.size, f = 2 ** st.level; st.shape = m.shape; const c = [st.voxel[2] / f, st.voxel[1] / f, st.voxel[0] / f]; // z, y, x at this level st.start = c.map((x, i) => Math.max(0, Math.min(m.shape[i] - N, Math.round(x - N / 2)))); const { data, chunks } = await readRegion(src.zarr, st.level, st.start, [N, N, N], (p) => { st.chunksLoaded = p.done; st.chunksTotal = p.total; st.bytes += p.bytes; ui(); }, my.signal); if (my !== ctrl) return; setVolume(data, N); st.chunksTotal = chunks; if (st.win.auto) autoWindow(); st.loaded = true; st.loadMs = Math.round(performance.now() - t0); dispatchEvent(new CustomEvent('unroll:ctvolume', { detail: { type: 'loaded', state: state() } })); } catch (e) { if (my === ctrl && e.name !== 'AbortError') { st.error = String(e.message || e); console.warn('ct volume', e); } } finally { if (my === ctrl) st.loading = false; ui(); draw(); } } let VOL = null; function setVolume(data, N) { VOL = data; volTex?.dispose(); volTex = new THREE.Data3DTexture(data, N, N, N); volTex.format = THREE.RedFormat; volTex.type = THREE.UnsignedByteType; volTex.minFilter = volTex.magFilter = THREE.LinearFilter; volTex.unpackAlignment = 1; volTex.needsUpdate = true; mat.uniforms.uVol.value = volTex; } // label volume (lib/ctlabels.js): one byte per voxel, 0 = none, 1..255 = segment index; palette is 256 RGBA function setLabels(data, pal, active = 0) { if (!mat) return false; labTex?.dispose(); labTex = null; if (data) { const N = st.size; labTex = new THREE.Data3DTexture(data, N, N, N); labTex.format = THREE.RedFormat; labTex.type = THREE.UnsignedByteType; labTex.minFilter = labTex.magFilter = THREE.NearestFilter; labTex.unpackAlignment = 1; labTex.needsUpdate = true; } if (pal) { palTex.image.data.set(pal); palTex.needsUpdate = true; } mat.uniforms.uLab.value = labTex || mat.uniforms.uVol.value; mat.uniforms.uLabOn.value = labTex ? 1 : 0; mat.uniforms.uActive.value = active | 0; draw(); return true; } function setActive(a) { if (!mat) return; mat.uniforms.uActive.value = a | 0; draw(); } function autoWindow(pl = 1, ph = 99.5) { if (!VOL) return null; const h = new Uint32Array(256); for (let i = 0; i < VOL.length; i += 7) if (VOL[i]) h[VOL[i]]++; // 0 = masked or missing let n = 0; for (const x of h) n += x; if (!n) return null; const at = (p) => { const k = n * p / 100; let s = 0; for (let i = 0; i < 256; i++) { s += h[i]; if (s >= k) return i / 255; } return 1; }; st.win = { low: at(pl), high: Math.max(at(ph), at(pl) + 0.01), auto: true }; ui(); draw(); return { ...st.win }; } // ---------------------------------------------------------------- public actions async function open(where = {}) { const v = window.unrollView; const G = v?.scanGeom?.(); st.scroll = G?.scroll || null; st.source = volumeFor(st.scroll, null); if (!st.source) { st.error = `no CT volume is known for ${st.scroll}`; showPanel(true); ui(); return { ok: false, error: st.error }; } let p = null; if (Array.isArray(where.voxel)) p = { voxel: where.voxel.map(Number), normal: where.normal || [0, 0, 1] }; else if (where.winding) p = scanPoint(where.winding, +(where.u ?? 0.5), +(where.v ?? 0.5)); else if (where.x !== undefined && where.y !== undefined) { const h = v.scanPick(where.x, where.y); p = h && scanPoint(h.winding, h.u, h.v); } if (!p) return { ok: false, error: 'no papyrus at that point' }; st.point = p; st.voxel = p.voxel; st.normal = p.normal; if (where.level !== undefined) st.level = Math.max(0, Math.min(5, +where.level | 0)); else st.level = levelSetting(); if (where.size !== undefined) st.size = SIZES.includes(+where.size) ? +where.size : st.size; else st.size = sizeSetting(); st.slab.tiltX = st.slab.tiltY = st.slab.offset = 0; showPanel(true); ensureRenderer(); faceSlab(); dispatchEvent(new CustomEvent('unroll:ctvolume', { detail: { type: 'open', point: p } })); load(); return { ok: true, ...p, level: st.level, size: st.size }; } function setSlab(o = {}) { const s = st.slab; if ('on' in o) s.on = !!o.on; if ('thickness' in o) s.thickness = Math.max(1, Math.min(st.size, +o.thickness < 1 ? +o.thickness * st.size : +o.thickness)); if ('tiltX' in o) s.tiltX = Math.max(-90, Math.min(90, +o.tiltX)); if ('tiltY' in o) s.tiltY = Math.max(-90, Math.min(90, +o.tiltY)); if ('offset' in o) s.offset = Math.max(-st.size / 2, Math.min(st.size / 2, +o.offset)); if (o.face) faceSlab(); ui(); draw(); return { ...s }; } function setWindow(width, level) { const w = Math.max(0.005, Math.min(1, +width)); st.win = { low: Math.max(0, +level - w / 2), high: Math.min(1, +level + w / 2), auto: false }; ui(); draw(); return { ...st.win }; } function setLevel(l) { st.level = Math.max(0, Math.min(5, +l | 0)); if (st.voxel) load(); ui(); return st.level; } function setSize(n) { if (!SIZES.includes(+n)) return false; st.size = +n; if (st.voxel) load(); ui(); return st.size; } function close() { ctrl?.abort(); showPanel(false); st.open = false; return true; } function state() { const um = st.source?.voxelUM || 9.362, f = 2 ** st.level; return { open: st.open, loading: st.loading, loaded: st.loaded, error: st.error, scroll: st.scroll, source: st.source?.zarr || null, point: st.point, level: st.level, size: st.size, voxelUM: +(um * f).toFixed(3), fieldMM: +(um * f * st.size / 1000).toFixed(2), start: st.start, levelShape: st.shape, slab: { ...st.slab, normal: slabNormal().map((x) => +x.toFixed(3)) }, window: { ...st.win }, chunksLoaded: st.chunksLoaded, chunksTotal: st.chunksTotal, loadMs: st.loadMs, gpuMB: +((st.size ** 3) * (labTex ? 2 : 1) / 1e6).toFixed(1), renders: st.renders, lastRenderMs: st.lastRenderMs }; } // render cost: n synchronous renders (readPixels waits for the GPU) function bench(n = 10) { if (!R) return null; const gl = R.getContext(), px = new Uint8Array(4); renderNow(); gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); const t0 = performance.now(); for (let i = 0; i < n; i++) { renderNow(); gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); } const c = $('ctVolCanvas'); return { msPerFrame: +((performance.now() - t0) / n).toFixed(1), canvas: [c.width, c.height], steps: Math.round(st.size * 1.25), size: st.size }; } // ---------------------------------------------------------------- panel (floating, beside the scroll) and pick mode // One scroll bar at a time (27 Oct 2026): the panel fits the window and the picture gives up height before the // controls would scroll. When they still have to (labels on, or a short window), the body gets ct-scrolls and the // main control panel holds still until they fit again (ui.css), so two boxes never scroll at once. function oneScroll() { const p = $('ctVol'), b = p && p.querySelector('.cv-body'); document.body.classList.toggle('ct-scrolls', !!b && !p.hidden && b.scrollHeight > b.clientHeight + 2); } const CSS = ` #ctVol{position:fixed;left:16px;top:96px;width:min(420px,42vw);max-height:calc(100vh - 112px);display:flex;flex-direction:column;z-index:40;background:rgba(10,12,14,.94);border:1px solid #2a3138;border-radius:10px;color:#d8dde2;font:13px/1.4 var(--mono,ui-monospace,monospace);box-shadow:0 8px 32px rgba(0,0,0,.5)} #ctVol[hidden]{display:none} #ctVol .cv-head{flex:none;display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:1px solid #222a31} #ctVol .cv-head b{flex:1;font-weight:600;letter-spacing:.04em} #ctVolCanvas{display:block;width:100%;aspect-ratio:1/1;flex:0 1000 auto;min-height:36vh;background:#000;touch-action:none} #ctVol .cv-body{padding:8px 10px;display:grid;gap:6px;flex:0 1 auto;min-height:0;overflow:auto} #ctVol label{display:grid;grid-template-columns:1fr 1.3fr;align-items:center;gap:8px} #ctVol .cv-row{display:flex;gap:6px;flex-wrap:wrap} #ctVol .cv-stat{color:#8b96a0;font-size:12px} #ctVol button{font:inherit} body.ct-picking canvas#gl{cursor:crosshair} @media (max-width:720px){#ctVol{left:8px;right:8px;top:auto;bottom:88px;width:auto}#ctVolCanvas{aspect-ratio:auto;height:34vh;min-height:0}#ctVol .cv-body{max-height:22vh}}`; function buildPanel() { if ($('ctVol')) return; const style = document.createElement('style'); style.textContent = CSS; document.head.append(style); const p = document.createElement('section'); p.id = 'ctVol'; p.hidden = true; p.setAttribute('aria-label', 'CT volume'); p.setAttribute('role', 'dialog'); p.innerHTML = `
Click a point on the papyrus.
'); P.body.append(g); P.addAction?.('ctvol.pick', { id: 'ctvolPick', label: 'CT volume: pick a point on the papyrus', run: (on) => pickMode(on ?? true) }); P.addAction?.('ctvol.show', { id: 'ctvolBtn', label: 'CT volume panel on or off', toggle: true }); P.addAction?.('ctvol.close', { id: 'cvClose', label: 'Close the CT volume', run: () => close() }); P.measure?.(); } const currentWinding = () => (document.getElementById('rWind')?.textContent || '').trim() || window.unrollView?.scanGeom?.()?.wins?.[0]?.id; function init() { const v = window.unrollView; if (!v?.scanGeom || !v.settings) return setTimeout(init, 150); addEventListener('click', onPointer, true); addEventListener('pointerdown', onPointer, true); addEventListener('keydown', (e) => { if (e.key === 'Escape' && picking) pickMode(false); }); v.ct = v.ct || {}; v.ct.volume = { open, close, state, setSlab, setWindow, setLevel, setSize, auto: autoWindow, pick: pickMode, scanPoint, bench, face: () => setSlab({ face: true }), render: () => renderNow(), image: () => { renderNow(); return $('ctVolCanvas').toDataURL('image/png'); }, _internal: { st, setLabels, setActive, get mat() { return mat; }, get renderer() { return R; }, setVolume, draw, ui, buildPanel } }; if (window.unrollPanel) buildGroup(window.unrollPanel); else addEventListener('unroll:panel', (e) => buildGroup(e.detail), { once: true }); // URL: ?ctvol=w035,0.5,0.5 (winding, u, v) or ?ctvol=x,y,z (scan voxels), with &ctlod=1 &ctsize=128 &ctslab=16 &cttilt=10,0 const q = new URLSearchParams(location.search); if (q.has('ctvol')) { const a = q.get('ctvol').split(','), o = /^w/i.test(a[0]) ? { winding: a[0], u: +a[1] || 0.5, v: +a[2] || 0.5 } : { voxel: a.map(Number) }; if (q.has('ctlod')) o.level = +q.get('ctlod'); if (q.has('ctsize')) o.size = +q.get('ctsize'); const go = () => (window.unrollView.introActive?.() ? setTimeout(go, 300) : open(o).then(() => { const sl = {}; if (q.has('ctslab')) sl.thickness = +q.get('ctslab'); if (q.has('cttilt')) { const [x, y] = q.get('cttilt').split(',').map(Number); sl.tiltX = x || 0; sl.tiltY = y || 0; } if (Object.keys(sl).length) setSlab({ ...sl, face: true }); })); go(); } } init();