Download lib/ctvolume.js from SharpMeow/unroll: direct link, hf CLI and curl.
- Browser
- Download file 27.2 kB
-
https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/lib/ctvolume.js
- Command line
-
hf download hf://spaces/SharpMeow/unroll/lib/ctvolume.js
-
curl -L -o ctvolume.js https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/lib/ctvolume.js
27.2 kB
| // 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 = `<div class="cv-head"><b>CT VOLUME · MIP</b><span class="cv-stat" id="cvWhere"></span><button type="button" class="icon" id="cvClose" aria-label="Close the CT volume" data-action="ctvol.close">✕</button></div> | |
| <canvas id="ctVolCanvas" aria-label="Maximum intensity projection of the X-ray scan around the picked point; drag to turn it"></canvas> | |
| <div class="cv-body"> | |
| <div class="cv-stat" id="cvStat" role="status" aria-live="polite"></div> | |
| <label for="cvSlabOn"><span>Slab clip</span><input type="checkbox" id="cvSlabOn" checked aria-label="Clip the projection to a slab"></label> | |
| <label for="cvThick"><span>Slab thickness <output id="cvThickOut"></output></span><input type="range" id="cvThick" min="1" max="256" step="1" aria-label="Slab thickness in voxels"></label> | |
| <label for="cvOff"><span>Slab offset <output id="cvOffOut"></output></span><input type="range" id="cvOff" min="-128" max="128" step="1" aria-label="Slab offset along its normal, in voxels"></label> | |
| <label for="cvTiltX"><span>Tilt across <output id="cvTiltXOut"></output></span><input type="range" id="cvTiltX" min="-90" max="90" step="1" aria-label="Tilt the slab about its first axis, degrees"></label> | |
| <label for="cvTiltY"><span>Tilt along <output id="cvTiltYOut"></output></span><input type="range" id="cvTiltY" min="-90" max="90" step="1" aria-label="Tilt the slab about its second axis, degrees"></label> | |
| <label for="cvWin"><span>Window <output id="cvWinOut"></output></span><input type="range" id="cvWin" min="0.02" max="1" step="0.01" aria-label="CT volume window (contrast range)"></label> | |
| <label for="cvLev"><span>Level <output id="cvLevOut"></output></span><input type="range" id="cvLev" min="0" max="1" step="0.01" aria-label="CT volume level (center brightness)"></label> | |
| <label for="cvSize"><span>Cube size</span><select id="cvSize" aria-label="Cube size in voxels">${SIZES.map((s) => `<option value="${s}">${s} voxels</option>`).join('')}</select></label> | |
| <label for="cvLevel"><span>Detail level</span><select id="cvLevel" aria-label="Detail level: 0 is the full 9.4 µm scan, each step halves the resolution">${[0, 1, 2, 3, 4, 5].map((l) => `<option value="${l}">${l}: ${(9.362 * 2 ** l).toFixed(1)} µm</option>`).join('')}</select></label> | |
| <div class="cv-row"><button type="button" class="chip" id="cvAuto" data-action="ctvol.auto">Auto contrast</button><button type="button" class="chip" id="cvFace" data-action="ctvol.face">Face the slab</button><button type="button" class="chip" id="cvRepick" data-action="ctvol.pick">Pick another point</button></div> | |
| </div>`; | |
| document.body.append(p); | |
| const body = p.querySelector('.cv-body'); | |
| new ResizeObserver(oneScroll).observe(body); | |
| new MutationObserver(oneScroll).observe(body, { childList: true, subtree: true }); | |
| addEventListener('resize', oneScroll); | |
| $('cvClose').onclick = close; | |
| $('cvSlabOn').onchange = (e) => setSlab({ on: e.target.checked }); | |
| $('cvThick').oninput = (e) => setSlab({ thickness: +e.target.value }); | |
| $('cvOff').oninput = (e) => setSlab({ offset: +e.target.value }); | |
| $('cvTiltX').oninput = (e) => setSlab({ tiltX: +e.target.value }); | |
| $('cvTiltY').oninput = (e) => setSlab({ tiltY: +e.target.value }); | |
| const wl = () => setWindow($('cvWin').value, $('cvLev').value); $('cvWin').oninput = wl; $('cvLev').oninput = wl; | |
| $('cvSize').onchange = (e) => setSize(+e.target.value); | |
| $('cvLevel').onchange = (e) => setLevel(+e.target.value); | |
| $('cvAuto').onclick = () => autoWindow(); $('cvFace').onclick = () => setSlab({ face: true }); $('cvRepick').onclick = () => pickMode(true); | |
| } | |
| function showPanel(on) { buildPanel(); $('ctVol').hidden = !on; st.open = on; $('ctvolBtn')?.setAttribute('aria-pressed', on); if (on) { ensureRenderer(); resize(); draw(); } oneScroll(); } | |
| function ui() { | |
| if (!$('ctVol')) return; | |
| const s = state(), sl = st.slab; | |
| $('cvWhere').textContent = st.point?.winding ? `${st.point.winding} · u ${(+st.point.u).toFixed(2)} v ${(+st.point.v).toFixed(2)}` : ''; | |
| $('cvStat').textContent = st.error ? `Error: ${st.error}` : st.loading ? `Loading ${st.chunksLoaded} / ${st.chunksTotal || '?'} chunks…` : | |
| st.loaded ? `${s.size}³ voxels at ${s.voxelUM} µm (${s.fieldMM} mm) · ${s.chunksTotal} chunks in ${(s.loadMs / 1000).toFixed(1)} s` : ''; | |
| $('cvSlabOn').checked = sl.on; | |
| $('cvThick').max = st.size; $('cvThick').value = sl.thickness; $('cvThickOut').textContent = `${sl.thickness} vx`; | |
| $('cvOff').min = -st.size / 2; $('cvOff').max = st.size / 2; $('cvOff').value = sl.offset; $('cvOffOut').textContent = `${sl.offset} vx`; | |
| $('cvTiltX').value = sl.tiltX; $('cvTiltXOut').textContent = `${sl.tiltX}°`; $('cvTiltY').value = sl.tiltY; $('cvTiltYOut').textContent = `${sl.tiltY}°`; | |
| $('cvWin').value = st.win.high - st.win.low; $('cvLev').value = (st.win.high + st.win.low) / 2; | |
| $('cvWinOut').textContent = Math.round((st.win.high - st.win.low) * 100) + '%'; $('cvLevOut').textContent = Math.round((st.win.high + st.win.low) * 50) + '%'; | |
| $('cvSize').value = st.size; $('cvLevel').value = st.level; | |
| } | |
| let picking = false; | |
| function pickMode(on = !picking) { | |
| picking = !!on; document.body.classList.toggle('ct-picking', picking); | |
| $('ctvolPick')?.setAttribute('aria-pressed', picking); | |
| const h = $('ctvolHint'); if (h) h.hidden = !picking; | |
| return picking; | |
| } | |
| function onPointer(e) { | |
| if (!picking || e.target.id !== 'gl') return; | |
| e.stopImmediatePropagation(); e.preventDefault(); | |
| if (e.type !== 'click') return; | |
| pickMode(false); open({ x: e.clientX, y: e.clientY }); | |
| } | |
| function buildGroup(P) { | |
| if ($('uiCTVol')) return; | |
| const g = P.group('CT volume', 'ui-ctvol'); g.id = 'uiCTVol'; | |
| const grid = document.createElement('div'); grid.className = 'ui-grid'; | |
| const pick = P.mk('ctvolPick', 'Pick a point', 'Click a point on the papyrus to open the raw X-ray scan around it (Esc cancels)', () => pickMode()); | |
| pick.setAttribute('aria-pressed', 'false'); | |
| const show = P.mk('ctvolBtn', 'Show volume', 'Show or hide the CT volume panel', () => (st.open ? close() : st.voxel ? showPanel(true) : open({ winding: currentWinding(), u: 0.5, v: 0.5 }))); | |
| show.setAttribute('aria-pressed', 'false'); | |
| grid.append(pick, show); g.append(grid); | |
| g.insertAdjacentHTML('beforeend', '<p class="mono dim small" id="ctvolHint" hidden>Click a point on the papyrus.</p>'); | |
| 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(); | |