Rugnux live structure view: step through the Fo-Fc peaks

A bar over the Mol* stage carries previous/next arrows through the refined model's difference-map blobs
(already in the live result, highest peak first), with the peak's index, height and reading, and a button
back to the whole model; the Fo-Fc toggle moves into the same bar. At a peak the camera looks at it from the
side with the fewest atoms in front, 26 A away, with the clip planes 5 A either side of it; the maps are drawn
uncarved within 10 A of it (the carved maps about the model would hide unmodelled density such as a missing
cofactor) and the model as sticks over the cartoon.

Smaller view fixes: stick carbons take the chain colours instead of Mol*'s default greens, which read as
Fo-Fc density; riding hydrogens are not drawn; the 2Fo-Fc mesh is dimmed (alpha 0.35 over the whole model,
0.5 at a peak) so the cartoon and the difference density show through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQsU5a8AJUdJPgWevAK
This commit is contained in:
leonarski_fandClaude Opus 5.5 committed 2026-11-13 09:41:34 +01:00
1 parent 564565ef45
commit 6573f48444
5 files changed
+234 -44

No files matched your search

+1 -1
View File
@@ -141,7 +141,7 @@ function Centre({s, pal}: { s: rugnux_live_status, pal: LivePalette }) {
<Division sx={{flexGrow: 1}} label={s.refined_model ? "Model · refined" : "Model · placed"}>
<Box sx={{flexGrow: 1, minHeight: 0}}>
<Suspense fallback={<Later text="Loading viewer..."/>}>
<RugnuxStructureViewer resultKey={resultKey} anomalous={s.anomalous_map} fill
<RugnuxStructureViewer resultKey={resultKey} anomalous={s.anomalous_map} fill peaks={m.refinement?.blobs}
anomalousSignal={s.anomalous_signal}/>
</Suspense>
</Box>
+151 -37
View File
@@ -1,13 +1,18 @@
import React, {useCallback, useEffect, useRef, useState} from "react";
import {Alert, Box, Tooltip, Typography} from "@mui/material";
import {Alert, Box, IconButton, Tooltip, Typography} from "@mui/material";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import ZoomOutMapIcon from "@mui/icons-material/ZoomOutMap";
import type {PluginContext} from "molstar/lib/mol-plugin/context";
import {authHeaders, noteResponse} from "../auth";
import type {rugnux_live_blob} from "../client";
import MolstarCanvas from "./structure/MolstarCanvas";
import {applyModelDisplay, clearScene, getStructureAtoms, getStructureBox, LoadedModel, loadModel, ReprMode}
from "./structure/loadModel";
import {loadMapLayer, MAP_COLORS, MapLayer, setLayerVisible, updateLayerSigma} from "./structure/loadMaps";
import {fullCellFromCcp4, prepareMap} from "./structure/densityCrop";
import {loadMapLayer, MAP_COLORS, MapLayer, removeMapLayer, setLayerVisible} from "./structure/loadMaps";
import {FullCellMap, fullCellFromCcp4, prepareMap} from "./structure/densityCrop";
import {revealCamera} from "./structure/reveal";
import {PEAK_MAP_RADIUS_A, viewPeak} from "./structure/peakView";
// A glance at the model rugnux live placed - refined, where the run refined it - in its density: cartoon,
@@ -15,11 +20,25 @@ import {revealCamera} from "./structure/reveal";
// map at 4 sigma, in Mol*. Set up as aaredb's structure viewer (gitea.psi.ch/mx/aaredb, pages/StructureView.tsx
// and components/Structure/, the PSI MX group's own code): its plugin, Coot mouse controls, model presets, map
// meshes, crop and carve - on a black stage. It is not a viewer to work in: what it shows is gone with the next
// collection, so the only control is a toggle for the difference map; every map is carved within the same
// radius of the atoms (aaredb carves 2Fo-Fc only), and the first model makes an entrance (reveal.ts). Mol* is
// only loaded with this component (React.lazy in RugnuxPage), and every Mol* import lives in ./structure.
// collection, so the controls are a toggle for the difference map and arrows through the refined model's
// difference-map peaks (the blobs of the live result, highest first), in a bar over the stage. Every map is carved
// within the same radius of the atoms (aaredb carves 2Fo-Fc only); at a peak the maps are drawn uncarved within
// a sphere about it instead, and the camera frames it in a slab (peakView.ts). The first model makes an entrance
// (reveal.ts). Mol* is only loaded with this component (React.lazy in RugnuxPage), and every Mol* import lives in
// ./structure.
type Kind = 'twofofc' | 'fofc' | 'anom';
const MAP_NAME: Record<Kind, '2fofc' | 'fofc' | 'anom'> = {twofofc: '2fofc', fofc: 'fofc', anom: 'anom'};
// The 2Fo-Fc mesh dimmed, so that the cartoon (whole model) and the difference density (at a peak) show through it
const TWOFOFC_ALPHA = {model: 0.35, peak: 0.5};
const KIND_WORD: Record<rugnux_live_blob['kind'], string> = {
ION: 'ion', WATER: 'water', LIGAND: 'ligand', MODEL_ERROR: 'model error', UNASSIGNED: 'unassigned'};
// ["peak 1/20 · 23.4 σ", "ion · NE2 HIS A19 1.9 Å"]
function peakLabel(p: rugnux_live_blob, i: number, n: number): [string, string] {
const near = p.nearest_atom_distance_A != null ? `${p.nearest_atom} ${p.nearest_atom_distance_A.toFixed(1)} Å` : '';
return [`peak ${i + 1}/${n} · ${p.peak_sigma.toFixed(1)} σ`, [KIND_WORD[p.kind], near].filter(Boolean).join(' · ')];
}
const hex = (c: number) => '#' + c.toString(16).padStart(6, '0');
@@ -39,9 +58,11 @@ const nextFrames = () => new Promise(r => requestAnimationFrame(() => requestAni
// resultKey: changes when a new result is there to load; anomalous: it has an anomalous difference map;
// anomalousSignal: the data carry usable anomalous signal, so that map is worth showing from the start;
// fill: take the parent's height (the live page's stage) instead of the viewer's own.
function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, fill}:
{ resultKey: string, anomalous: boolean, anomalousSignal: boolean, fill?: boolean }) {
// fill: take the parent's height (the live page's stage) instead of the viewer's own; peaks: the blobs of the
// refined model's difference map, in the model's frame.
function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, fill, peaks}:
{ resultKey: string, anomalous: boolean, anomalousSignal: boolean, fill?: boolean,
peaks?: rugnux_live_blob[] }) {
const pluginRef = useRef<PluginContext | null>(null);
const [pluginReady, setPluginReady] = useState(0);
const [error, setError] = useState<string>();
@@ -49,7 +70,19 @@ function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, fill}:
const [mapsLoading, setMapsLoading] = useState(false);
const layersRef = useRef<Partial<Record<Kind, MapLayer>>>({});
const modelRefsRef = useRef<LoadedModel['refs']>({});
const sigmaTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// The full-cell maps and the atoms, for the maps about a peak; the layers drawn about the peak shown
const fullRef = useRef<Partial<Record<Kind, FullCellMap>>>({});
const atomsRef = useRef<Float32Array>(new Float32Array(0));
const regionRef = useRef<Partial<Record<Kind, MapLayer>>>({});
// The peak shown (index into `sorted`), null for the whole model. Steps run one after another; a step
// overtaken by a later click is skipped.
const [peak, setPeak] = useState<number | null>(null);
const peakRef = useRef<number | null>(null);
const stepQueue = useRef<Promise<void>>(Promise.resolve());
const stepToken = useRef(0);
const sorted = [...(peaks ?? [])].sort((a, b) => b.peak_sigma - a.peak_sigma);
const sortedRef = useRef(sorted);
sortedRef.current = sorted;
// First load frames the molecule; later results keep the user's camera and clip (aaredb StructureView.tsx:117-121)
const sceneLoadedRef = useRef(false);
const [hasMap, setHasMap] = useState<Record<Kind, boolean>>({twofofc: false, fofc: false, anom: false});
@@ -80,8 +113,13 @@ function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, fill}:
setMapsLoading(false);
setError(undefined);
try {
stepToken.current++;
peakRef.current = null;
setPeak(null);
await clearScene(plugin);
layersRef.current = {};
regionRef.current = {};
fullRef.current = {};
setHasMap({twofofc: false, fofc: false, anom: false});
const [cif, map2, map1, mapA] = await Promise.all([
fetchBytes("/rugnux/model.cif", abort.signal),
@@ -102,6 +140,7 @@ function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, fill}:
const box = getStructureBox(loaded.structure);
const crop = box ? {...box, marginA: 6} : null;
const atoms = getStructureAtoms(loaded.structure);
atomsRef.current = atoms;
setModelLoading(false);
setMapsLoading(true);
const maps: [Kind, '2fofc' | 'fofc' | 'anom', ArrayBuffer | undefined][] =
@@ -109,8 +148,10 @@ function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, fill}:
for (const [kind, name, bytes] of maps) {
if (!bytes || abort.signal.aborted)
continue;
const computed = prepareMap(name, fullCellFromCcp4(bytes), crop, atoms.length ? atoms : null);
const layer = await loadMapLayer(plugin, computed, name, SIGMA[kind]);
const full = fullCellFromCcp4(bytes);
fullRef.current[kind] = full;
const computed = prepareMap(name, full, crop, atoms.length ? atoms : null);
const layer = await loadMapLayer(plugin, computed, name, SIGMA[kind], TWOFOFC_ALPHA.model);
layersRef.current[kind] = layer;
if (!showRef.current[kind])
setLayerVisible(plugin, layer, false);
@@ -138,38 +179,111 @@ function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, fill}:
const toggleLayer = useCallback((kind: Kind, visible: boolean) => {
setShow(s => ({...s, [kind]: visible}));
const plugin = pluginRef.current;
const layer = layersRef.current[kind];
const layer = peakRef.current === null ? layersRef.current[kind] : regionRef.current[kind];
if (plugin && layer)
setLayerVisible(plugin, layer, visible);
}, []);
// Show peak i (null: the whole model): the maps about the model give way to the maps about the peak, and the
// camera frames it.
const showPeak = useCallback(async (i: number | null) => {
const plugin = pluginRef.current;
if (!plugin)
return;
for (const layer of Object.values(regionRef.current))
await removeMapLayer(plugin, layer);
regionRef.current = {};
for (const [kind, layer] of Object.entries(layersRef.current) as [Kind, MapLayer][])
setLayerVisible(plugin, layer, i === null && showRef.current[kind]);
// at a peak the side chains about it matter: sticks over the cartoon
applyModelDisplay(plugin, modelRefsRef.current, i === null ? REPR : 'both', false);
if (i === null) {
plugin.managers.camera.reset(undefined, reducedMotion() ? 0 : 400);
return;
}
const p = sortedRef.current[i];
const centre = [p.x, p.y, p.z];
viewPeak(plugin, centre, atomsRef.current, reducedMotion() ? 0 : 400);
for (const [kind, full] of Object.entries(fullRef.current) as [Kind, FullCellMap][]) {
const map = prepareMap(MAP_NAME[kind], full, {min: centre, max: centre, marginA: PEAK_MAP_RADIUS_A},
Float32Array.from(centre), PEAK_MAP_RADIUS_A);
const layer = await loadMapLayer(plugin, map, MAP_NAME[kind], SIGMA[kind], TWOFOFC_ALPHA.peak);
regionRef.current[kind] = layer;
if (!showRef.current[kind])
setLayerVisible(plugin, layer, false);
}
}, []);
const step = useCallback((i: number | null) => {
const token = ++stepToken.current;
peakRef.current = i;
setPeak(i);
stepQueue.current = stepQueue.current
.then(() => token === stepToken.current ? showPeak(i) : undefined)
.catch(e => setError(e instanceof Error ? e.message : String(e)));
}, [showPeak]);
const n = sorted.length;
const busy = modelLoading || mapsLoading;
const [label, detail] = peak === null
? [`${n} Fo−Fc peak${n === 1 ? '' : 's'}`, `highest ${sorted[0]?.peak_sigma.toFixed(1)} σ`]
: peakLabel(sorted[peak], peak, n);
const arrow = {color: '#c9d3e6', '&.Mui-disabled': {color: 'rgba(154,165,196,.3)'}};
return (
<Box sx={{position: 'relative', bgcolor: '#000', borderRadius: 1, overflow: 'hidden',
<Box sx={{display: 'flex', flexDirection: 'column', bgcolor: '#000', borderRadius: 1, overflow: 'hidden',
height: fill ? '100%' : {xs: '55vh', md: 'clamp(520px, 64vh, 820px)'}, minHeight: fill ? 0 : undefined}}>
<Box sx={{position: 'absolute', inset: 0, opacity: revealed ? 1 : 0,
transition: 'opacity 900ms ease-out', '@media (prefers-reduced-motion: reduce)': {transition: 'none'}}}>
<MolstarCanvas onReady={onPluginReady}/>
</Box>
{(modelLoading || (mapsLoading && !revealed)) && !error && <Box sx={{position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
justifyContent: 'center', pointerEvents: 'none'}}>
<Typography sx={{fontSize: '0.875rem', color: '#8aa0b5'}}>
{modelLoading ? 'loading model…' : 'computing density maps…'}</Typography>
</Box>}
{error && <Alert severity="warning" sx={{position: 'absolute', top: 8, left: 8}}>{error}</Alert>}
{/* the one control: the difference map on or off */}
{hasMap.fofc && (
<Tooltip title={`mFo−DFc at ±${SIGMA.fofc.toFixed(0)} σ (green positive, red negative); 2mFo−DFc at ${SIGMA.twofofc.toFixed(0)} σ in blue`}>
<Box component="button" type="button" onClick={() => toggleLayer('fofc', !show.fofc)} disabled={mapsLoading}
sx={{position: 'absolute', top: 8, right: 8, display: 'inline-flex', alignItems: 'center', gap: 0.75,
px: 1, height: 26, borderRadius: '13px', border: '1px solid', cursor: 'pointer',
borderColor: show.fofc ? 'primary.main' : 'rgba(154,165,196,.3)', bgcolor: 'rgba(8,12,26,.8)',
color: show.fofc ? '#e9edf7' : '#9aa5c4', fontSize: '0.75rem', fontFamily: 'inherit'}}>
<Box sx={{width: 8, height: 8, borderRadius: '2px', bgcolor: hex(MAP_COLORS.fofcPos)}}/>
Fo−Fc
{/* the bar over the stage: the difference-map peaks, highest first, and the difference map on or off */}
<Box sx={{display: 'flex', flexWrap: 'wrap', alignItems: 'center', columnGap: 0.5, px: 0.5, minHeight: 40,
flexShrink: 0, bgcolor: '#0b1020', borderBottom: '1px solid rgba(154,165,196,.2)'}}>
<Box sx={{display: 'flex', alignItems: 'center', gap: 0.5, flex: '1 1 250px', minWidth: 0}}>
{n > 0 && <>
<IconButton size="small" aria-label="previous peak" sx={arrow}
disabled={busy || peak === null || peak === 0} onClick={() => step(peak! - 1)}>
<ChevronLeftIcon fontSize="small"/>
</IconButton>
<Box title={`${label} · ${detail}`} sx={{minWidth: 0, lineHeight: 1.25, fontVariantNumeric: 'tabular-nums'}}>
<Typography noWrap sx={{fontSize: '0.8rem', fontWeight: 600, color: '#e9edf7'}}>{label}</Typography>
<Typography noWrap sx={{fontSize: '0.72rem', color: '#9aa5c4'}}>{detail}</Typography>
</Box>
</Tooltip>)}
{!modelLoading && !mapsLoading && !hasMap.twofofc && !error &&
<Typography sx={{position: 'absolute', bottom: 8, right: 12, fontSize: '0.8rem', color: '#8aa0b5'}}>No maps</Typography>}
<IconButton size="small" aria-label="next peak" sx={arrow}
disabled={busy || peak === n - 1} onClick={() => step(peak === null ? 0 : peak + 1)}>
<ChevronRightIcon fontSize="small"/>
</IconButton>
{peak !== null &&
<Tooltip title="Whole model">
<IconButton size="small" aria-label="whole model" sx={arrow} onClick={() => step(null)}>
<ZoomOutMapIcon fontSize="small"/>
</IconButton>
</Tooltip>}
</>}
</Box>
{hasMap.fofc && (
<Tooltip title={`mFo−DFc at ±${SIGMA.fofc.toFixed(0)} σ (green positive, red negative); 2mFo−DFc at ${SIGMA.twofofc.toFixed(0)} σ in blue`}>
<Box component="button" type="button" onClick={() => toggleLayer('fofc', !show.fofc)} disabled={mapsLoading}
sx={{display: 'inline-flex', alignItems: 'center', gap: 0.75, flexShrink: 0, ml: 'auto', mr: 0.5, my: 0.5,
px: 1, height: 26, borderRadius: '13px', border: '1px solid', cursor: 'pointer',
borderColor: show.fofc ? 'primary.main' : 'rgba(154,165,196,.3)', bgcolor: 'rgba(8,12,26,.8)',
color: show.fofc ? '#e9edf7' : '#9aa5c4', fontSize: '0.75rem', fontFamily: 'inherit'}}>
<Box sx={{width: 8, height: 8, borderRadius: '2px', bgcolor: hex(MAP_COLORS.fofcPos)}}/>
Fo−Fc
</Box>
</Tooltip>)}
</Box>
<Box sx={{position: 'relative', flexGrow: 1, minHeight: 0}}>
<Box sx={{position: 'absolute', inset: 0, opacity: revealed ? 1 : 0,
transition: 'opacity 900ms ease-out', '@media (prefers-reduced-motion: reduce)': {transition: 'none'}}}>
<MolstarCanvas onReady={onPluginReady}/>
</Box>
{(modelLoading || (mapsLoading && !revealed)) && !error && <Box sx={{position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
justifyContent: 'center', pointerEvents: 'none'}}>
<Typography sx={{fontSize: '0.875rem', color: '#8aa0b5'}}>
{modelLoading ? 'loading model…' : 'computing density maps…'}</Typography>
</Box>}
{error && <Alert severity="warning" sx={{position: 'absolute', top: 8, left: 8}}>{error}</Alert>}
{!modelLoading && !mapsLoading && !hasMap.twofofc && !error &&
<Typography sx={{position: 'absolute', bottom: 8, right: 12, fontSize: '0.8rem', color: '#8aa0b5'}}>No maps</Typography>}
</Box>
</Box>
);
}
+13 -6
View File
@@ -21,6 +21,7 @@ export const MAP_COLORS = {
} as const;
export interface MapLayer {
dataRef: string; // the raw bytes the volume is parsed from: removing it removes the layer
volumeRef: string;
reprRefs: string[]; // one for 2fofc and anom, two (+/−) for fofc
rmsd: number;
@@ -36,6 +37,7 @@ async function addIso(
map: ComputedMap,
sigma: number,
color: number,
alpha: number,
): Promise<string> {
const volumeCell = plugin.state.data.select(volumeRef)[0];
const repr = plugin
@@ -45,7 +47,7 @@ async function addIso(
StateTransforms.Representation.VolumeRepresentation3D,
createVolumeRepresentationParams(plugin, volumeCell?.obj?.data, {
type: 'isosurface',
typeParams: { isoValue: abs(map, sigma), visuals: ['wireframe'], sizeFactor: 1 },
typeParams: { isoValue: abs(map, sigma), visuals: ['wireframe'], sizeFactor: 1, alpha },
color: 'uniform',
colorParams: { value: Color(color) },
}),
@@ -68,6 +70,7 @@ export async function loadMapLayer(
map: ComputedMap,
kind: '2fofc' | 'fofc' | 'anom',
sigma: number,
alpha = 1, // of the 2Fo-Fc mesh, which can be dimmed so the model and the difference map show through it
): Promise<MapLayer> {
const data = await plugin.builders.data.rawData({
data: new Uint8Array(map.ccp4),
@@ -78,14 +81,14 @@ export async function loadMapLayer(
const reprRefs: string[] = [];
if (kind === '2fofc') {
reprRefs.push(await addIso(plugin, volumeRef, map, sigma, MAP_COLORS.twofofc));
reprRefs.push(await addIso(plugin, volumeRef, map, sigma, MAP_COLORS.twofofc, alpha));
} else if (kind === 'anom') {
reprRefs.push(await addIso(plugin, volumeRef, map, sigma, MAP_COLORS.anom));
reprRefs.push(await addIso(plugin, volumeRef, map, sigma, MAP_COLORS.anom, 1));
} else {
reprRefs.push(await addIso(plugin, volumeRef, map, +sigma, MAP_COLORS.fofcPos));
reprRefs.push(await addIso(plugin, volumeRef, map, -sigma, MAP_COLORS.fofcNeg));
reprRefs.push(await addIso(plugin, volumeRef, map, +sigma, MAP_COLORS.fofcPos, 1));
reprRefs.push(await addIso(plugin, volumeRef, map, -sigma, MAP_COLORS.fofcNeg, 1));
}
return { volumeRef, reprRefs, rmsd: map.rmsd, mean: map.mean };
return { dataRef: data.ref, volumeRef, reprRefs, rmsd: map.rmsd, mean: map.mean };
}
/** σ slider update: param-only change, re-mesh without recompute. */
@@ -107,3 +110,7 @@ export async function updateLayerSigma(
export function setLayerVisible(plugin: PluginContext, layer: MapLayer, visible: boolean): void {
setSubtreeVisibility(plugin.state.data, layer.volumeRef, !visible); // arg = isHidden
}
export async function removeMapLayer(plugin: PluginContext, layer: MapLayer): Promise<void> {
await plugin.build().delete(layer.dataRef).commit();
}
@@ -15,6 +15,10 @@ const CHAIN_PALETTE = {
},
};
// Sticks: carbons in the chain's colour, not Mol*'s default palette, whose greens read as Fo-Fc density; the
// riding hydrogens a refined model carries are left out.
const STICK_COLORS = { carbonColor: { name: 'chain-id' as const, params: { ...CHAIN_PALETTE, asymId: 'auth' as const } } };
// Load a PDB model from bytes into the plugin's state tree. All representations
// are built up front with stable refs; "representation mode" and waters are
// pure visibility flips (setSubtreeVisibility), so switching is instant.
@@ -55,7 +59,9 @@ export async function loadModel(
refs.polymerCartoon = cartoon.ref;
const sticks = await plugin.builders.structure.representation.addRepresentation(polymer, {
type: 'ball-and-stick',
typeParams: { ignoreHydrogens: true },
color: 'element-symbol',
colorParams: STICK_COLORS,
});
refs.polymerSticks = sticks.ref;
}
@@ -63,6 +69,9 @@ export async function loadModel(
if (ligand) {
const r = await plugin.builders.structure.representation.addRepresentation(ligand, {
type: 'ball-and-stick',
typeParams: { ignoreHydrogens: true },
color: 'element-symbol',
colorParams: STICK_COLORS,
});
refs.ligand = r.ref;
}
@@ -0,0 +1,60 @@
import { PluginContext } from 'molstar/lib/mol-plugin/context';
import { Vec3 } from 'molstar/lib/mol-math/linear-algebra';
// Framing one difference-map peak: the camera looks at it from the side where the least of the model lies in
// front of it, close enough for the blob and what surrounds it (a haem is ~12 A across) to fill the view, and the
// clip planes cut a slab through it so that nothing further in front hides it and nothing further behind clutters it.
export const PEAK_DISTANCE_A = 26; // camera to peak: a 45 degree field of view shows ~11 A above and below it
export const PEAK_SLAB_A = 5; // the near and far clip planes this far in front of and behind the peak
export const PEAK_MAP_RADIUS_A = 10; // the maps are drawn within this radius of the peak, uncarved
// The 26 directions to the corners, edges and faces of a cube
const DIRECTIONS: Vec3[] = [];
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++)
if (x || y || z)
DIRECTIONS.push(Vec3.normalize(Vec3(), Vec3.create(x, y, z)));
// Atoms between the peak and the near clip plane, within 3 A of the line of sight
function atomsInFront(atoms: Float32Array, c: Vec3, dir: Vec3): number {
let n = 0;
for (let i = 0; i < atoms.length; i += 3) {
const dx = atoms[i] - c[0], dy = atoms[i + 1] - c[1], dz = atoms[i + 2] - c[2];
const t = dx * dir[0] + dy * dir[1] + dz * dir[2];
if (t > 1.5 && t < PEAK_SLAB_A && dx * dx + dy * dy + dz * dz - t * t < 9)
n++;
}
return n;
}
export function viewPeak(plugin: PluginContext, center: number[], atoms: Float32Array, durationMs: number): void {
const camera = plugin.canvas3d?.camera;
if (!camera) return;
const c = Vec3.create(center[0], center[1], center[2]);
// the current direction first, so that it is kept where no other is clearer
const current = Vec3.normalize(Vec3(), Vec3.sub(Vec3(), camera.state.position, camera.state.target));
let best = current;
let bestCount = atomsInFront(atoms, c, current);
let bestDot = 1;
for (const d of DIRECTIONS) {
const n = atomsInFront(atoms, c, d);
const dot = Vec3.dot(d, current);
if (n < bestCount || (n === bestCount && dot > bestDot)) {
best = d;
bestCount = n;
bestDot = dot;
}
}
// "up" as close to the current one as the new direction allows
const up = Vec3.scaleAndAdd(Vec3(), camera.state.up, best, -Vec3.dot(camera.state.up, best));
if (Vec3.magnitude(up) < 1e-3)
Vec3.cross(up, best, Math.abs(best[0]) < 0.9 ? Vec3.unitX : Vec3.unitY);
Vec3.normalize(up, up);
camera.setState({
target: c,
position: Vec3.scaleAndAdd(Vec3(), c, best, PEAK_DISTANCE_A),
up,
radius: PEAK_SLAB_A,
}, durationMs);
}