unroll / lib /ctvolume.js
SharpMeow's picture
One page scroll bar and American English
757768f verified
Raw History Blame Contribute Delete
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();