diff --git a/frontend/src/components/RugnuxStructureViewer.tsx b/frontend/src/components/RugnuxStructureViewer.tsx index 10f1eca74..dc538da6a 100644 --- a/frontend/src/components/RugnuxStructureViewer.tsx +++ b/frontend/src/components/RugnuxStructureViewer.tsx @@ -1,35 +1,54 @@ -import {useEffect, useRef, useState} from "react"; -import {Alert, Box, Checkbox, FormControlLabel, Slider, Stack, Typography} from "@mui/material"; -import {PluginContext} from "molstar/lib/mol-plugin/context"; -import {DefaultPluginSpec} from "molstar/lib/mol-plugin/spec"; -import {StateTransforms} from "molstar/lib/mol-plugin-state/transforms"; -import {createVolumeRepresentationParams} from "molstar/lib/mol-plugin-state/helpers/volume-representation-params"; -import {setSubtreeVisibility} from "molstar/lib/mol-plugin/behavior/static/state"; -import {Volume} from "molstar/lib/mol-model/volume"; -import {Color} from "molstar/lib/mol-util/color"; -import {Mat4, Vec3} from "molstar/lib/mol-math/linear-algebra"; -import {Sphere3D} from "molstar/lib/mol-math/geometry"; +import React, {useCallback, useEffect, useRef, useState} from "react"; +import {Alert, Box, Slider, Switch, Typography} from "@mui/material"; +import type {PluginContext} from "molstar/lib/mol-plugin/context"; import {authHeaders, noteResponse} from "../auth"; +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"; -// The model rugnux live placed, with its 2mFo-DFc, mFo-DFc and (where there are Bijvoet pairs) anomalous -// difference maps, in a Mol* viewer (headless plugin: the -// render core and state tree, no Mol* UI). Mol* is only loaded with this component (React.lazy in -// RugnuxPage). The broker serves the maps with the whole cell's mean and -// RMS in their headers, so the contour levels in sigma are the usual crystallographic ones. -// -// The broker serves one unit cell of each map, centred on the model (more where the model is wider than the -// cell). As in Coot, the view opens with the maps drawn only within 10 A of the centre of view, which follows -// the camera (a click on an atom centres it); radius 0 draws the whole cell. +// The model rugnux live placed, with its 2mFo-DFc, mFo-DFc and (where the merge kept the Bijvoet pairs) anomalous +// difference maps, 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, panel and defaults - on a black stage. Mol* is only loaded with this component +// (React.lazy in RugnuxPage), and every Mol* import lives in ./structure. -const COLOR_2FOFC = 0x3b6fd6; -const COLOR_FOFC_POS = 0x2ca02c; -const COLOR_FOFC_NEG = 0xd62728; -const COLOR_ANOM = 0xd61ad6; +type Kind = 'twofofc' | 'fofc' | 'anom'; -interface MapLayer { - volume: string; - reprs: string[]; // one for 2mFo-DFc, +/- for mFo-DFc -} +const hex = (c: number) => '#' + c.toString(16).padStart(6, '0'); + +const SectionTitle: React.FC<{ children: React.ReactNode }> = ({children}) => ( + {children} +); + +// one map row: colored dot · name · switch · σ slider + readout (aaredb StructureView.tsx:47-77) +const MapRow: React.FC<{ + label: string; + dotColor: string; + on: boolean; + onToggle: (v: boolean) => void; + sigma: number; + min: number; + max: number; + step: number; + onSigma: (v: number) => void; + disabled?: boolean; +}> = ({label, dotColor, on, onToggle, sigma, min, max, step, onSigma, disabled}) => ( + + + + {label} + + {sigma.toFixed(1)} σ + onToggle(e.target.checked)}/> + + onSigma(v as number)} aria-label={`${label} contour level`} + sx={{mx: 0.5, width: 'calc(100% - 8px)', color: dotColor, '& .MuiSlider-thumb': {width: 12, height: 12}}}/> + +); async function fetchBytes(path: string, signal: AbortSignal): Promise { const res = await fetch(path, {headers: authHeaders(), signal}); @@ -41,142 +60,34 @@ async function fetchBytes(path: string, signal: AbortSignal): Promise 0 ? [{ - type: 'sphere' as const, - invert: true, // keep the inside - position: Vec3.clone(centre), - rotation: {axis: Vec3.create(1, 0, 0), angle: 0}, - scale: Vec3.create(2 * radius, 2 * radius, 2 * radius), // the diameter - transform: Mat4.identity(), - }] : [], - }; -} - -async function addIsosurface(plugin: PluginContext, volume: string, sigma: number, color: number, - clip: ReturnType) { - const cell = plugin.state.data.select(volume)[0]; - const repr = plugin.build().to(volume).apply(StateTransforms.Representation.VolumeRepresentation3D, - createVolumeRepresentationParams(plugin, cell?.obj?.data, { - type: 'isosurface', - typeParams: {isoValue: Volume.IsoValue.relative(sigma), visuals: ['wireframe'], clip}, - color: 'uniform', - colorParams: {value: Color(color)}, - })); - await repr.commit(); - return repr.ref; -} - -async function loadMap(plugin: PluginContext, bytes: ArrayBuffer, label: string, sigmas: number[], - colors: number[], clip: ReturnType): Promise { - const data = await plugin.builders.data.rawData({data: new Uint8Array(bytes), label}); - const parsed = await plugin.dataFormats.get('ccp4')!.parse(plugin, data); - const volume: string = parsed.volume.ref; - const reprs: string[] = []; - for (let i = 0; i < sigmas.length; i++) - reprs.push(await addIsosurface(plugin, volume, sigmas[i], colors[i], clip)); - return {volume, reprs}; -} - -async function setSigma(plugin: PluginContext, layer: MapLayer | undefined, sigmas: number[]) { - if (!layer) - return; - const b = plugin.build(); - layer.reprs.forEach((ref, i) => - b.to(ref).update((old: { type: { params: { isoValue: Volume.IsoValue } } }) => { - old.type.params.isoValue = Volume.IsoValue.relative(sigmas[i]); - })); - await b.commit(); -} - -async function setClip(plugin: PluginContext, layers: (MapLayer | undefined)[], clip: ReturnType) { - const b = plugin.build(); - for (const layer of layers) - layer?.reprs.forEach(ref => - b.to(ref).update((old: { type: { params: { clip: unknown } } }) => { - old.type.params.clip = clip; - })); - await b.commit(); -} - -function setVisible(plugin: PluginContext, layer: MapLayer | undefined, visible: boolean) { - if (layer) - setSubtreeVisibility(plugin.state.data, layer.volume, !visible); // the flag is "hidden" -} - // resultKey: changes when a new result is there to load; anomalous: it has an anomalous difference map. function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anomalous: boolean }) { - const containerRef = useRef(null); - const [plugin, setPlugin] = useState(); + const pluginRef = useRef(null); + const [pluginReady, setPluginReady] = useState(0); const [error, setError] = useState(); const [loading, setLoading] = useState(false); - const maps = useRef<{ twofofc?: MapLayer, fofc?: MapLayer, anom?: MapLayer }>({}); - const allMaps = () => [maps.current.twofofc, maps.current.fofc, maps.current.anom]; - const [hasFofc, setHasFofc] = useState(false); - const [sigma2, setSigma2] = useState(1.0); - const [sigma1, setSigma1] = useState(3.0); - const [show2, setShow2] = useState(true); - const [show1, setShow1] = useState(true); - const [hasAnom, setHasAnom] = useState(false); - const [sigmaA, setSigmaA] = useState(4.0); - const [showA, setShowA] = useState(true); - const [radius, setRadius] = useState(10); - // The levels as they are when a map is loaded, without making the loading depend on them. - const levels = useRef({sigma2, sigma1, sigmaA, show2, show1, showA, radius}); - levels.current = {sigma2, sigma1, sigmaA, show2, show1, showA, radius}; - const centre = useRef(Vec3.zero()); + const layersRef = useRef>>({}); + const modelRefsRef = useRef({}); + const sigmaTimer = useRef | null>(null); + const [hasMap, setHasMap] = useState>({twofofc: false, fofc: false, anom: false}); - // The plugin, once per mount. A canvas of its own every time: dispose() loses the WebGL context of the - // canvas it ran on, so a second plugin on the same one (React StrictMode mounts twice) would start dead. - useEffect(() => { - const container = containerRef.current; - if (!container) - return; - let disposed = false; - let instance: PluginContext | undefined; - const canvas = document.createElement('canvas'); - canvas.style.width = '100%'; - canvas.style.height = '100%'; - canvas.style.display = 'block'; - container.appendChild(canvas); - (async () => { - try { - const p = new PluginContext(DefaultPluginSpec()); - await p.init(); - if (!await p.initViewerAsync(canvas, container)) { - p.dispose(); - throw new Error("WebGL is not available"); - } - if (disposed) { - p.dispose(); - return; - } - // The camera is moved by this component alone: by default Mol* resets it whenever the scene's - // bounds change, i.e. as each map arrives, which undoes the zoom on the centre of view. - p.canvas3d?.setProps({renderer: {backgroundColor: Color(0xffffff)}, camera: {manualReset: true}}); - instance = p; - setPlugin(p); - } catch (e) { - if (!disposed) - setError(e instanceof Error ? e.message : String(e)); - } - })(); - const resize = new ResizeObserver(() => instance?.handleResize()); - resize.observe(container); - return () => { - disposed = true; - resize.disconnect(); - instance?.dispose(); - canvas.remove(); - setPlugin(undefined); - }; + // aaredb factory defaults (state/viewerDefaults.ts:16-23); anomalous as a difference map, +4 sigma + const [show, setShow] = useState>({twofofc: true, fofc: true, anom: true}); + const [sigma, setSigma] = useState>({twofofc: 1.0, fofc: 3.0, anom: 4.0}); + const [reprMode, setReprMode] = useState('cartoon'); + const [showWaters, setShowWaters] = useState(false); + const displayRef = useRef({show, sigma, reprMode, showWaters}); + displayRef.current = {show, sigma, reprMode, showWaters}; + + const onPluginReady = useCallback((plugin: PluginContext) => { + pluginRef.current = plugin; + setPluginReady(n => n + 1); }, []); - // The model and its maps, again for every new result. + // The model, then its maps (aaredb StructureView.tsx maybeLoad / computeAndAttachMaps, 212-295): 2Fo-Fc cropped + // to the model's box + 6 A and carved 4.5 A around the atoms; the difference maps cropped only. useEffect(() => { + const plugin = pluginRef.current; if (!plugin) return; const abort = new AbortController(); @@ -184,10 +95,9 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom setLoading(true); setError(undefined); try { - await plugin.clear(); - maps.current = {}; - setHasFofc(false); - setHasAnom(false); + await clearScene(plugin); + layersRef.current = {}; + setHasMap({twofofc: false, fofc: false, anom: false}); const [cif, map2, map1, mapA] = await Promise.all([ fetchBytes("/rugnux/model.cif", abort.signal), fetchBytes("/rugnux/map.ccp4?type=2fofc", abort.signal), @@ -199,49 +109,26 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom setError("No placed model"); return; } - const data = await plugin.builders.data.rawData({data: new TextDecoder().decode(cif), label: "model"}); - const trajectory = await plugin.builders.structure.parseTrajectory(data, 'mmcif'); - const model = await plugin.builders.structure.createModel(trajectory); - const structure = await plugin.builders.structure.createStructure(model); - const polymer = await plugin.builders.structure.tryCreateComponentStatic(structure, 'polymer'); - if (polymer) - await plugin.builders.structure.representation.addRepresentation(polymer, - {type: 'cartoon', color: 'chain-id'}); - for (const kind of ['ligand', 'ion'] as const) { - const c = await plugin.builders.structure.tryCreateComponentStatic(structure, kind); - if (c) - await plugin.builders.structure.representation.addRepresentation(c, - {type: 'ball-and-stick', color: 'element-symbol'}); + const loaded = await loadModel(plugin, cif, 'model', {resetCamera: true, format: 'mmcif'}); + modelRefsRef.current = loaded.refs; + const d = displayRef.current; + applyModelDisplay(plugin, loaded.refs, d.reprMode, d.showWaters); + const box = getStructureBox(loaded.structure); + const crop = box ? {...box, marginA: 6} : null; + const atoms = getStructureAtoms(loaded.structure); + const maps: [Kind, '2fofc' | 'fofc' | 'anom', ArrayBuffer | undefined][] = + [['twofofc', '2fofc', map2], ['fofc', 'fofc', map1], ['anom', 'anom', mapA]]; + for (const [kind, name, bytes] of maps) { + if (!bytes || abort.signal.aborted) + continue; + const computed = prepareMap(name, fullCellFromCcp4(bytes), crop, + kind === 'twofofc' && atoms.length ? atoms : null); + const layer = await loadMapLayer(plugin, computed, name, d.sigma[kind]); + layersRef.current[kind] = layer; + if (!displayRef.current.show[kind]) + setLayerVisible(plugin, layer, false); + setHasMap(h => ({...h, [kind]: true})); } - // The model's centre, which a map radius is first drawn around. - plugin.managers.camera.reset(undefined, 0); - const target = plugin.canvas3d?.camera.state.target; - if (target) - Vec3.copy(centre.current, target); - const l = levels.current; - const clip = sphereClip(centre.current, l.radius); - if (map2) { - maps.current.twofofc = await loadMap(plugin, map2, "2mFo-DFc", [l.sigma2], [COLOR_2FOFC], clip); - setVisible(plugin, maps.current.twofofc, l.show2); - } - if (map1) { - maps.current.fofc = await loadMap(plugin, map1, "mFo-DFc", [l.sigma1, -l.sigma1], - [COLOR_FOFC_POS, COLOR_FOFC_NEG], clip); - setVisible(plugin, maps.current.fofc, l.show1); - setHasFofc(true); - } - if (mapA) { - maps.current.anom = await loadMap(plugin, mapA, "anomalous", [l.sigmaA], [COLOR_ANOM], clip); - setVisible(plugin, maps.current.anom, l.showA); - setHasAnom(true); - } - // The view opens on all of it - the cell of map the broker serves and the model - or, with a map - // radius set, on the sphere of map around the model's centre. - if (l.radius > 0) - plugin.managers.camera.focusSphere(Sphere3D.create(Vec3.clone(centre.current), l.radius + 4), - {durationMs: 0}); - else - plugin.managers.camera.reset(undefined, 0); } catch (e) { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : String(e)); @@ -251,103 +138,97 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom } })(); return () => abort.abort(); - }, [plugin, resultKey, anomalous]); + }, [pluginReady, resultKey, anomalous]); - // The maps follow the centre of view - it moves on a pan or a click on an atom, not on a rotation or zoom. - useEffect(() => { - const camera = plugin?.canvas3d?.camera; - if (!plugin || !camera) - return; - let timer: ReturnType | undefined; - const sub = camera.stateChanged.subscribe(() => { - if (Vec3.distance(camera.state.target, centre.current) < 0.5 || timer !== undefined) - return; - timer = setTimeout(() => { - timer = undefined; - Vec3.copy(centre.current, camera.state.target); - setClip(plugin, allMaps(), - sphereClip(centre.current, levels.current.radius)).catch(() => undefined); - }, 150); - }); - return () => { - sub.unsubscribe(); - if (timer !== undefined) - clearTimeout(timer); - }; - }, [plugin]); - useEffect(() => { - if (plugin) - setClip(plugin, allMaps(), sphereClip(centre.current, radius)) - .catch(() => undefined); - }, [plugin, radius]); - useEffect(() => { - if (plugin) - setSigma(plugin, maps.current.twofofc, [sigma2]).catch(() => undefined); - }, [plugin, sigma2]); - useEffect(() => { - if (plugin) - setSigma(plugin, maps.current.fofc, [sigma1, -sigma1]).catch(() => undefined); - }, [plugin, sigma1]); - useEffect(() => { - if (plugin) - setVisible(plugin, maps.current.twofofc, show2); - }, [plugin, show2]); - useEffect(() => { - if (plugin) - setVisible(plugin, maps.current.fofc, show1); - }, [plugin, show1]); - useEffect(() => { - if (plugin) - setSigma(plugin, maps.current.anom, [sigmaA]).catch(() => undefined); - }, [plugin, sigmaA]); - useEffect(() => { - if (plugin) - setVisible(plugin, maps.current.anom, showA); - }, [plugin, showA]); + // σ sliders: debounced param-only updates (re-mesh, no recompute) - aaredb StructureView.tsx:321-330 + const applySigma = useCallback((kind: Kind, value: number) => { + setSigma(s => ({...s, [kind]: value})); + if (sigmaTimer.current) + clearTimeout(sigmaTimer.current); + sigmaTimer.current = setTimeout(() => { + const plugin = pluginRef.current; + const layer = layersRef.current[kind]; + if (plugin && layer) + void updateLayerSigma(plugin, layer, value); + }, 90); + }, []); + const toggleLayer = useCallback((kind: Kind, visible: boolean) => { + setShow(s => ({...s, [kind]: visible})); + const plugin = pluginRef.current; + const layer = layersRef.current[kind]; + if (plugin && layer) + setLayerVisible(plugin, layer, visible); + }, []); + + const setDisplay = useCallback((mode: ReprMode, waters: boolean) => { + setReprMode(mode); + setShowWaters(waters); + const plugin = pluginRef.current; + if (plugin) + applyModelDisplay(plugin, modelRefsRef.current, mode, waters); + }, []); + + const mapsDisabled = loading; return ( - - - - {loading && + + + + {loading && Loading model and maps...} - {error && {error}} - - - setShow2(e.target.checked)}/>} - label={ - 2mFo-DFc {sigma2.toFixed(1)} σ}/> - setSigma2(v as number)} aria-label="2mFo-DFc contour level"/> - - {hasFofc && - setShow1(e.target.checked)}/>} - label={ - mFo-DFc ±{sigma1.toFixed(1)} σ}/> - setSigma1(v as number)} aria-label="mFo-DFc contour level"/> - } - {hasAnom && - setShowA(e.target.checked)}/>} - label={ - anomalous {sigmaA.toFixed(1)} σ}/> - setSigmaA(v as number)} aria-label="Anomalous contour level"/> - } - - - - Maps within {radius > 0 ? `${radius} Å of the centre` : "the whole cell"} - setRadius(v as number)} aria-label="Map radius"/> - - - Left drag rotates, right drag pans, wheel zooms, a click on an atom centres it. The maps cover one unit - cell around the model; with a radius set they are drawn only that far around the centre of view. - - + + {error && {error}} + Model + + {(['cartoon', 'sticks', 'both'] as const).map((m) => ( + setDisplay(m, showWaters)} + onKeyDown={(e) => { if (e.key === 'Enter') setDisplay(m, showWaters); }} + sx={{fontSize: '0.8125rem', px: 1, py: '3px', borderRadius: '8px', + border: '1px solid', cursor: 'pointer', + color: reprMode === m ? 'primary.main' : 'text.secondary', + borderColor: reprMode === m ? 'primary.main' : 'divider'}} + >{m === 'both' ? 'cartoon + sticks' : m} + ))} + + + Waters + setDisplay(reprMode, e.target.checked)}/> + + + Maps + {hasMap.twofofc && toggleLayer('twofofc', v)} sigma={sigma.twofofc} + min={0.5} max={4} step={0.1} onSigma={(v) => applySigma('twofofc', v)} + disabled={mapsDisabled}/>} + {hasMap.fofc && toggleLayer('fofc', v)} sigma={sigma.fofc} + min={1} max={8} step={0.5} onSigma={(v) => applySigma('fofc', v)} + disabled={mapsDisabled}/>} + {hasMap.anom && toggleLayer('anom', v)} sigma={sigma.anom} + min={1} max={8} step={0.5} onSigma={(v) => applySigma('anom', v)} + disabled={mapsDisabled}/>} + {!loading && !hasMap.twofofc && + No maps} + + Controls + {([ + ['left drag', 'rotate'], + ['right drag', 'zoom'], + ['middle drag', 'pan'], + ['middle click', 'center on residue'], + ['scroll', 'clip'], + ] as const).map(([k, v]) => ( + + {k} + {v} + + ))} + + ); } diff --git a/frontend/src/components/structure/MolstarCanvas.tsx b/frontend/src/components/structure/MolstarCanvas.tsx new file mode 100644 index 000000000..7ee9c5459 --- /dev/null +++ b/frontend/src/components/structure/MolstarCanvas.tsx @@ -0,0 +1,107 @@ +// Adapted from aaredb (gitea.psi.ch/mx/aaredb, the PSI MX group's own code), frontend/src/components/Structure/MolstarCanvas.tsx at 8ece2c2. +import React, { useEffect, useRef, useState } from 'react'; +import { Box, Typography } from '@mui/material'; +import type { PluginContext } from 'molstar/lib/mol-plugin/context'; +import { createStructurePlugin } from './createPlugin'; +import { applyCootBindings, wireCootRotate, wireMiddleClickRecenter, wireScrollClip, wireModifierDragRestart } from './cootControls'; + +// A Mol* WebGL surface: owns the plugin lifecycle (init → onReady → dispose on +// unmount), resize handling and context-loss recovery. At most TWO mount at a +// time (the viewer + its side-by-side compare pane — WebGL contexts are a +// limited browser resource); cards and lists show snapshots only. + +const MolstarCanvas: React.FC<{ + onReady: (plugin: PluginContext) => void; +}> = ({ onReady }) => { + const containerRef = useRef(null); + const pluginRef = useRef(null); + const [contextLost, setContextLost] = useState(false); + const [initError, setInitError] = useState(null); + + useEffect(() => { + let disposed = false; + const container = containerRef.current; + if (!container) return undefined; + + // A FRESH canvas per effect run: Mol*'s dispose() deliberately loses the + // WebGL context, so under StrictMode's mount→unmount→mount the second + // init on a reused would start dead ("context lost"). + const canvas = document.createElement('canvas'); + canvas.style.width = '100%'; + canvas.style.height = '100%'; + canvas.style.display = 'block'; + container.appendChild(canvas); + + let unwireControls: (() => void) | null = null; + (async () => { + try { + // One-tick deferral: StrictMode's throwaway first pass unmounts + // synchronously, so this skips its WebGL init entirely (and with + // it the ResizeObserver noise from aborting Mol* mid-attach). + await new Promise((r) => setTimeout(r, 0)); + if (disposed) return; + const plugin = await createStructurePlugin(canvas, container); + if (disposed) { + plugin.dispose(); + return; + } + pluginRef.current = plugin; + // coot control profile: left rotate · right zoom · middle pan · + // middle-click recenter on residue · scroll clip + applyCootBindings(plugin); + const unRecenter = wireMiddleClickRecenter(plugin); + const unClip = wireScrollClip(plugin); + const unRestart = wireModifierDragRestart(canvas); + const unRotate = wireCootRotate(plugin); + unwireControls = () => { unRecenter(); unClip(); unRestart(); unRotate(); }; + if (import.meta.env.DEV) { + (window as unknown as Record).__structurePlugin = plugin; + } + onReady(plugin); + } catch (e) { + if (!disposed) setInitError(e instanceof Error ? e.message : 'viewer init failed'); + } + })(); + + const onLost = (e: Event) => { + e.preventDefault(); + if (!disposed) setContextLost(true); + }; + canvas.addEventListener('webglcontextlost', onLost); + + const ro = new ResizeObserver(() => pluginRef.current?.handleResize()); + ro.observe(container); + + return () => { + disposed = true; + ro.disconnect(); + canvas.removeEventListener('webglcontextlost', onLost); + unwireControls?.(); + pluginRef.current?.dispose(); + pluginRef.current = null; + canvas.remove(); + setContextLost(false); + setInitError(null); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {(contextLost || initError) && ( + + window.location.reload() : undefined} + sx={{ fontSize: '0.875rem', color: '#dfe7ef', cursor: contextLost ? 'pointer' : 'default' }} + > + {contextLost ? 'Graphics context lost — click to reload the viewer' : `Viewer failed to start: ${initError}`} + + + )} + + ); +}; + +export default MolstarCanvas; diff --git a/frontend/src/components/structure/cootControls.ts b/frontend/src/components/structure/cootControls.ts new file mode 100644 index 000000000..5a8e9f200 --- /dev/null +++ b/frontend/src/components/structure/cootControls.ts @@ -0,0 +1,198 @@ +// Adapted from aaredb (gitea.psi.ch/mx/aaredb, the PSI MX group's own code), frontend/src/components/Structure/molstar/cootControls.ts (camera sync and residue pick left out) at 8ece2c2. +import { PluginContext } from 'molstar/lib/mol-plugin/context'; +import { DefaultTrackballBindings } from 'molstar/lib/mol-canvas3d/controls/trackball'; +import { Binding } from 'molstar/lib/mol-util/binding'; +import { ButtonsType, ModifiersKeys } from 'molstar/lib/mol-util/input/input-observer'; +import { StructureElement } from 'molstar/lib/mol-model/structure'; +import { Loci } from 'molstar/lib/mol-model/loci'; +import { Vec3, Quat } from 'molstar/lib/mol-math/linear-algebra'; +import { cootTrackball, toTrackballCoords } from './cootTrackball'; + +// The "coot" control profile — the mouse language crystallographers already +// know from Coot: +// left drag rotate — Coot 0.9.8's virtual trackball (wireCootRotate) +// right drag zoom in/out +// middle drag pan (translate) +// middle CLICK recenter on the clicked residue (distance/orientation kept) +// scroll clip (slab through the density around the center) +// shift+scroll zoom (kept as an escape hatch) +// Future settings can add other profiles; this is the default. + +const B = ButtonsType; +const M = ModifiersKeys; +const T = Binding.Trigger; + +export const COOT_TRACKBALL_BINDINGS = { + ...DefaultTrackballBindings, // keep key/gamepad bindings intact + // plain left drag is rotate, but NOT via Mol*'s trackball (linear, ~2× faster + // than Coot and never rolls) — wireCootRotate below runs Coot 0.9.8's instead. + dragRotate: Binding.Empty, + dragRotateZ: Binding( + [T(B.Flag.Primary, M.create({ shift: true, control: true }))], + 'Rotate around z-axis (roll)', 'Drag using ${triggers}', + ), + dragPan: Binding( + [T(B.Flag.Auxilary, M.create()), T(B.Flag.Primary, M.create({ control: true }))], + 'Pan', 'Drag using ${triggers}', + ), + dragZoom: Binding([T(B.Flag.Secondary, M.create())], 'Zoom', 'Drag using ${triggers}'), + dragFocus: Binding.Empty, + dragFocusZoom: Binding.Empty, + scrollZoom: Binding([T(B.Flag.Auxilary, M.create({ shift: true }))], 'Zoom', 'Scroll using ${triggers}'), + // plain scroll = clip, implemented in wireScrollClip below (the trackball's + // own scrollFocus accumulator proved unreliable; direct camera radius + // updates are deterministic) — keep it Empty here to avoid double handling. + scrollFocus: Binding.Empty, + scrollFocusZoom: Binding.Empty, +}; + +export function applyCootBindings(plugin: PluginContext): void { + // Bindings are TrackballControls ATTRIBS (construction-time config), not + // props — setProps silently ignores them; setAttribs is the live setter + // (Canvas3D.setAttribs → controls.setAttribs → Object.assign). + plugin.canvas3d?.setAttribs({ trackball: { bindings: COOT_TRACKBALL_BINDINGS } }); + // Negative zoomSpeed flips the drag→zoom direction to match Coot's feel + // (Mol*'s default is the opposite); shift+scroll zoom flips with it, which + // keeps the whole zoom gesture family consistent. The PD min/max on this + // param are UI hints only — runtime accepts the negative value. + plugin.canvas3d?.setProps({ trackball: { zoomSpeed: -7 } }); +} + +/** Left-drag rotation with Coot 0.9.8's virtual trackball (cootTrackball.ts): + * spherical surface, incremental from the previous pointer position, x and y + * normalised by the canvas width and height like Coot's glarea. Mutates the + * camera the way Mol*'s own controls do (position/up in place + requestDraw), + * so camera.changed still fires and the split-view mirror keeps working. + * Returns an unsubscribe. */ +export function wireCootRotate(plugin: PluginContext): () => void { + const canvas3d = plugin.canvas3d; + const input = canvas3d?.input; + if (!canvas3d || !input) return () => undefined; + const camera = canvas3d.camera; + let prev: [number, number] | null = null; + const eye = Vec3(), eyeDir = Vec3(), up = Vec3(), side = Vec3(), axis = Vec3(); + const q = Quat(); + const sub = input.drag.subscribe((e) => { + const m = e.modifiers; + if (e.buttons !== B.Flag.Primary || m.shift || m.control || m.alt || m.meta) { prev = null; return; } + // e.x/e.y are CSS px from the canvas top-left; input.width/height are device px + const pr = input.pixelRatio || 1; + const cur = toTrackballCoords(e.x, e.y, input.width / pr, input.height / pr); + if (e.isStart || !prev) { prev = cur; return; } + const rot = cootTrackball(prev[0], prev[1], cur[0], cur[1]); + prev = cur; + if (!rot) return; + // screen basis in world space: x right, y up, z toward the viewer + Vec3.sub(eye, camera.position, camera.target); + Vec3.normalize(eyeDir, eye); + Vec3.normalize(up, camera.up); + Vec3.normalize(side, Vec3.cross(side, up, eyeDir)); + Vec3.scale(axis, side, rot.axis[0]); + Vec3.scaleAndAdd(axis, axis, up, rot.axis[1]); + Vec3.scaleAndAdd(axis, axis, eyeDir, rot.axis[2]); + // the scene turns by +angle about the axis, so the camera turns by the inverse + Quat.setAxisAngle(q, axis, -rot.angle); + Vec3.transformQuat(eye, eye, q); + Vec3.transformQuat(camera.up, camera.up, q); + Vec3.add(camera.position, camera.target, eye); + canvas3d.requestDraw(); + }); + const end = input.interactionEnd.subscribe(() => { prev = null; }); + return () => { sub.unsubscribe(); end.unsubscribe(); }; +} + +/** Plain scroll adjusts the clip slab (Coot-style): shrinks/grows the visible + * radius around the camera target so the user can focus where the center is. + * Returns an unsubscribe. */ +export function wireScrollClip(plugin: PluginContext): () => void { + const input = plugin.canvas3d?.input; + if (!input) return () => undefined; + const sub = input.wheel.subscribe(({ spinY, modifiers }) => { + // shift+scroll stays zoom (trackball binding) + if (modifiers.shift || modifiers.control || modifiers.alt || modifiers.meta) return; + const camera = plugin.canvas3d?.camera; + if (!camera || !spinY) return; + const r = camera.state.radius; + const max = camera.state.radiusMax || r; + const next = Math.min(max, Math.max(1.5, r * (1 - spinY * 0.08))); + if (next !== r) camera.setState({ radius: next }); + }); + return () => sub.unsubscribe(); +} + +/** Coot-style recenter: move the camera target to `center`, preserving + * orientation — and distance, unless `distanceA` asks for a specific one + * (blob deep-links arrive zoomed to the scene, not at overview distance). + * Shared by middle-click and the sequence strip. */ +export function recenterCamera(plugin: PluginContext, center: Vec3 | number[], distanceA?: number): void { + const camera = plugin.canvas3d?.camera; + if (!camera) return; + const c = Vec3.create(center[0], center[1], center[2]); + const { target, position } = camera.state; + const dir = Vec3.sub(Vec3(), position, target); + if (distanceA) Vec3.setMagnitude(dir, dir, distanceA); + camera.setState( + { + target: c, + position: Vec3.add(Vec3(), c, dir), + // an explicit distance is "frame this spot": clip a coot-like slab + // around it too, so the point of interest isn't buried in density + ...(distanceA ? { radius: distanceA * 0.45 } : {}), + }, + 250, + ); +} + +/** Mol*'s trackball re-matches drag bindings per event but initializes a + * gesture's anchor only on isStart — pressing/releasing a modifier MID-drag + * (e.g. Ctrl while rotating) switches rotate→pan against a stale anchor and + * the view jumps (measured 18 Å per 10 px). Restart the drag synthetically + * when a modifier toggles while a button is down, so the newly-matched + * gesture starts from the current pointer position. */ +export function wireModifierDragRestart(el: HTMLElement): () => void { + let buttons = 0; + let last: { x: number; y: number } | null = null; + const down = (e: MouseEvent) => { buttons = e.buttons; last = { x: e.clientX, y: e.clientY }; }; + const move = (e: MouseEvent) => { if (e.buttons) { buttons = e.buttons; last = { x: e.clientX, y: e.clientY }; } }; + const up = () => { buttons = 0; last = null; }; + const restart = (e: KeyboardEvent) => { + if (!buttons || !last) return; + if (!['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return; + const mods = { ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey }; + const base = { bubbles: true, clientX: last.x, clientY: last.y, ...mods }; + // primary button of the held combination — enough for our bindings. + // Mol*'s InputObserver listens to MOUSE events (not pointer events). + const button = buttons & 4 ? 1 : buttons & 2 ? 2 : 0; + el.dispatchEvent(new MouseEvent('mouseup', { ...base, button, buttons: 0 })); + el.dispatchEvent(new MouseEvent('mousedown', { ...base, button, buttons })); + }; + el.addEventListener('mousedown', down); + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + window.addEventListener('keydown', restart); + window.addEventListener('keyup', restart); + return () => { + el.removeEventListener('mousedown', down); + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + window.removeEventListener('keydown', restart); + window.removeEventListener('keyup', restart); + }; +} + +/** Middle-click recenters the view on the clicked residue, Coot-style: the + * camera target moves, orientation and distance stay. Returns an unsubscribe. */ +export function wireMiddleClickRecenter(plugin: PluginContext): () => void { + const sub = plugin.behaviors.interaction.click.subscribe((e) => { + if (!(e.button & B.Flag.Auxilary)) return; + let loci = e.current?.loci; + if (!loci || Loci.isEmpty(loci)) return; + if (StructureElement.Loci.is(loci)) { + loci = StructureElement.Loci.extendToWholeResidues(loci); + } + const sphere = Loci.getBoundingSphere(loci); + if (!sphere) return; + recenterCamera(plugin, sphere.center); + }); + return () => sub.unsubscribe(); +} diff --git a/frontend/src/components/structure/cootTrackball.ts b/frontend/src/components/structure/cootTrackball.ts new file mode 100644 index 000000000..eca09462b --- /dev/null +++ b/frontend/src/components/structure/cootTrackball.ts @@ -0,0 +1,55 @@ +// Adapted from aaredb (gitea.psi.ch/mx/aaredb, the PSI MX group's own code), frontend/src/components/Structure/molstar/cootTrackball.ts at 8ece2c2. +// Coot 0.9.8's virtual trackball — a port of the SGI trackball.c call Coot made +// on every mouse-motion event (globjects.cc, glarea_motion_notify): spherical +// surface, trackball_size 0.8, incremental from the previous pointer position. +// Coot 1.x kept the very same code but defaults to a "flat" surface +// (trackball_size 12.8, graphics-info-statics.cc), which is near-linear, about +// twice as fast per pixel and never rolls — that is what makes new Coot feel +// different. This module is the old feel. +// +// Coordinates are Coot's: x, y in [-1, 1] with (0, 0) at the window centre and +// +y up; x is normalised by the width and y by the height, so on a wide window a +// vertical drag turns more per pixel than a horizontal one — exactly like Coot. + +export const COOT_TRACKBALL_SIZE = 0.8; + +export type TrackballRotation = { + /** unit axis in screen space: x right, y up, z toward the viewer */ + axis: [number, number, number]; + /** radians; the scene turns by this about `axis` (the near face follows the pointer) */ + angle: number; +}; + +/** Coot's window → trackball mapping for a pointer at (x, y) CSS px from the top-left. */ +export function toTrackballCoords(x: number, y: number, width: number, height: number): [number, number] { + return [(2 * x - width) / width, (height - 2 * y) / height]; +} + +/** SGI's tb_project_to_sphere: a sphere of radius r out to r/√2, then a + * hyperbolic sheet, so the surface never ends at the window edge. */ +export function projectToSphere(r: number, x: number, y: number): number { + const d = Math.hypot(x, y); + const t = r * Math.SQRT1_2; + return d < t ? Math.sqrt(r * r - d * d) : (t * t) / d; +} + +/** The scene rotation for a pointer move p1 → p2. Returns null when the pointer + * did not move. Angle = 2·asin(|p1 − p2|·(size − 0.3)/size), clamped, as in + * Coot's trackball() with its d_mult fudge. */ +export function cootTrackball( + p1x: number, p1y: number, p2x: number, p2y: number, size = COOT_TRACKBALL_SIZE, +): TrackballRotation | null { + if (p1x === p2x && p1y === p2y) return null; + const p1z = projectToSphere(size, p1x, p1y); + const p2z = projectToSphere(size, p2x, p2y); + // p1 × p2: for a rightward drag from the centre this is +y, and turning the + // scene about +y by a positive angle moves its near face to the right. + const ax = p1y * p2z - p1z * p2y; + const ay = p1z * p2x - p1x * p2z; + const az = p1x * p2y - p1y * p2x; + const len = Math.hypot(ax, ay, az); + if (!len) return null; + const d = Math.hypot(p1x - p2x, p1y - p2y, p1z - p2z); + const t = Math.max(-1, Math.min(1, (d * (size - 0.3)) / size)); + return { axis: [ax / len, ay / len, az / len], angle: 2 * Math.asin(t) }; +} diff --git a/frontend/src/components/structure/createPlugin.ts b/frontend/src/components/structure/createPlugin.ts new file mode 100644 index 000000000..8d0acd5b1 --- /dev/null +++ b/frontend/src/components/structure/createPlugin.ts @@ -0,0 +1,37 @@ +// Adapted from aaredb (gitea.psi.ch/mx/aaredb, the PSI MX group's own code), frontend/src/components/Structure/molstar/createPlugin.ts at 8ece2c2. +// Headless Mol* plugin (no mol-plugin-ui): the render core + state tree + +// builders, driven entirely by our own charter-styled controls. ALL molstar +// imports in the app live under components/Structure/molstar/ so the lazy +// chunk boundary holds and a version upgrade touches one directory. +// molstar is pinned EXACTLY (5.11.0) — these are lib/ internal paths. +import { PluginContext } from 'molstar/lib/mol-plugin/context'; +import { DefaultPluginSpec } from 'molstar/lib/mol-plugin/spec'; +import { Color } from 'molstar/lib/mol-util/color'; + +export type { PluginContext }; + +export const CANVAS_BG = 0x000000; // black (aaredb: 0x0d1a26, steel/cryo dark stage) + +export async function createStructurePlugin( + canvas: HTMLCanvasElement, + container: HTMLDivElement, +): Promise { + const spec = DefaultPluginSpec(); + const plugin = new PluginContext(spec); + await plugin.init(); + const ok = await plugin.initViewerAsync(canvas, container); + if (!ok) { + plugin.dispose(); + throw new Error('WebGL initialization failed'); + } + plugin.canvas3d?.setProps({ + renderer: { backgroundColor: Color(CANVAS_BG) }, + // No automatic camera resets when the scene's bounds change: the maps + // and the blob marker land seconds after the model (async worker), and + // the default behaviour zoomed the camera back out — losing a blob + // deep-link position and its clip slab. All camera moves are ours + // (loadModel's explicit reset, recenterCamera, flyToBlob). + camera: { manualReset: true }, + }); + return plugin; +} diff --git a/frontend/src/components/structure/densityCrop.ts b/frontend/src/components/structure/densityCrop.ts new file mode 100644 index 000000000..36fb3bbf9 --- /dev/null +++ b/frontend/src/components/structure/densityCrop.ts @@ -0,0 +1,249 @@ +// The density preparation of aaredb's density worker (gitea.psi.ch/mx/aaredb, the PSI MX group's own code, +// frontend/public/structure-worker/density-worker.js at 8ece2c2): crop the periodic map to the model's box plus a +// margin, carve the 2Fo-Fc map around the atoms, encode as CCP4 with the full cell's mean and RMS. aaredb computes +// the full-cell map from an MTZ with gemmi wasm; here the broker serves it (/rugnux/map.ccp4), so it is first read +// back into aaredb's full-cell z-fastest grid (fullCellFromCcp4). + +export interface ComputedMap { + name: string; + ccp4: ArrayBuffer; + rmsd: number; + mean: number; + dims: [number, number, number]; +} + +export interface Cell { a: number; b: number; c: number; alpha: number; beta: number; gamma: number } + +export interface CropBox { + min: number[]; + max: number[]; + marginA: number; +} + +export interface FullCellMap { + data: Float32Array; // value(u,v,w) = data[w + nz*(v + ny*u)] + n: [number, number, number]; + cell: Cell; + mean: number; + rms: number; +} + +// The broker's CCP4 map (any axis order, any start, at least one period along each axis, mean and RMS of the +// whole cell in the header) as the full periodic cell. +export function fullCellFromCcp4(bytes: ArrayBuffer): FullCellMap { + const i32 = new Int32Array(bytes, 0, 256); + const f32 = new Float32Array(bytes, 0, 256); + const count = [i32[0], i32[1], i32[2]]; // NC NR NS + const start = [i32[4], i32[5], i32[6]]; + const n: [number, number, number] = [i32[7], i32[8], i32[9]]; // MX MY MZ + const axis = [i32[16] - 1, i32[17] - 1, i32[18] - 1]; // MAPC MAPR MAPS -> x/y/z + const values = new Float32Array(bytes, 1024 + i32[23], count[0] * count[1] * count[2]); + const data = new Float32Array(n[0] * n[1] * n[2]); + const wrap = (i: number, nn: number) => ((i % nn) + nn) % nn; + const g = [0, 0, 0]; + let p = 0; + for (let s = 0; s < count[2]; s++) + for (let r = 0; r < count[1]; r++) + for (let c = 0; c < count[0]; c++) { + g[axis[0]] = wrap(start[0] + c, n[axis[0]]); + g[axis[1]] = wrap(start[1] + r, n[axis[1]]); + g[axis[2]] = wrap(start[2] + s, n[axis[2]]); + data[g[2] + n[2] * (g[1] + n[1] * g[0])] = values[p++]; + } + return { + data, n, + cell: {a: f32[10], b: f32[11], c: f32[12], alpha: f32[13], beta: f32[14], gamma: f32[15]}, + mean: f32[21], rms: f32[54], + }; +} + +function encodeCcp4(data: Float32Array, dims: number[], start: number[], full: number[], cell: Cell, + mean: number, rms: number): ArrayBuffer { + // dims/start are [x,y,z] grid extents/origins of the (possibly cropped) + // block; full = full-cell sampling counts. + const nx = dims[0], ny = dims[1], nz = dims[2]; + const header = new ArrayBuffer(1024); + const i32 = new Int32Array(header); + const f32 = new Float32Array(header); + i32[0] = nz; i32[1] = ny; i32[2] = nx; // NC NR NS (cols=Z fastest) + i32[3] = 2; // MODE 2 = float32 + i32[4] = start[2]; i32[5] = start[1]; i32[6] = start[0]; // NCSTART NRSTART NSSTART + i32[7] = full[0]; i32[8] = full[1]; i32[9] = full[2]; // MX MY MZ (full-cell sampling) + f32[10] = cell.a; f32[11] = cell.b; f32[12] = cell.c; + f32[13] = cell.alpha; f32[14] = cell.beta; f32[15] = cell.gamma; + i32[16] = 3; i32[17] = 2; i32[18] = 1; // MAPC MAPR MAPS + let mn = Infinity, mx = -Infinity; + for (let i = 0; i < data.length; i++) { + const v = data[i]; + if (v < mn) mn = v; + if (v > mx) mx = v; + } + f32[19] = mn; f32[20] = mx; f32[21] = mean; // DMIN DMAX DMEAN + i32[22] = 1; i32[23] = 0; // ISPG=P1 NSYMBT=0 + const u8 = new Uint8Array(header); + u8[208] = 0x4d; u8[209] = 0x41; u8[210] = 0x50; u8[211] = 0x20; // 'MAP ' + u8[212] = 0x44; u8[213] = 0x41; u8[214] = 0x00; u8[215] = 0x00; // machst LE + f32[54] = rms; // RMS + i32[55] = 0; // NLABL + const out = new ArrayBuffer(1024 + data.length * 4); + new Uint8Array(out).set(u8, 0); + new Float32Array(out, 1024).set(data); + return out; +} + +/* Fractionalization matrix (upper-triangular inverse of the standard + * orthogonalization: a along x, b in the xy-plane). */ +function fracMatrix(cell: Cell) { + const d = Math.PI / 180; + const ca = Math.cos(cell.alpha * d), cb = Math.cos(cell.beta * d); + const cg = Math.cos(cell.gamma * d), sg = Math.sin(cell.gamma * d); + const ax = cell.a, bx = cell.b * cg, by = cell.b * sg; + const cx = cell.c * cb, cy = cell.c * (ca - cb * cg) / sg; + const cz = Math.sqrt(Math.max(0, cell.c * cell.c - cx * cx - cy * cy)); + return { + m00: 1 / ax, m01: -bx / (ax * by), m02: (bx * cy - by * cx) / (ax * by * cz), + m11: 1 / by, m12: -cy / (by * cz), + m22: 1 / cz, + }; +} + +function toFrac(M: ReturnType, x: number, y: number, z: number) { + return [ + M.m00 * x + M.m01 * y + M.m02 * z, + M.m11 * y + M.m12 * z, + M.m22 * z, + ]; +} + +/* Resample the periodic full-cell grid over the model's bounding box (+margin). + * The FFT grid covers exactly one unit cell, but a refined model routinely + * sits partly OUTSIDE the [0,1) box — without this wrap-around crop the mesh + * renders as a displaced cell-shaped block next to the model. Returns + * { data, start: [i0,j0,k0], dims: [ni,nj,nk] } in grid indices (x,y,z axes). */ +function cropToBox(data: Float32Array, nx: number, ny: number, nz: number, cell: Cell, box: CropBox) { + const M = fracMatrix(cell); + const m = box.marginA; + const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity]; + for (let corner = 0; corner < 8; corner++) { + const x = (corner & 1 ? box.max[0] + m : box.min[0] - m); + const y = (corner & 2 ? box.max[1] + m : box.min[1] - m); + const z = (corner & 4 ? box.max[2] + m : box.min[2] - m); + const f = toFrac(M, x, y, z); + for (let t = 0; t < 3; t++) { + if (f[t] < lo[t]) lo[t] = f[t]; + if (f[t] > hi[t]) hi[t] = f[t]; + } + } + const n = [nx, ny, nz]; + const start: number[] = [], count: number[] = []; + for (let u = 0; u < 3; u++) { + const i0 = Math.floor(lo[u] * n[u]) - 1; + const i1 = Math.ceil(hi[u] * n[u]) + 1; + // The block is anchored on the MODEL (starts may be negative) and may + // span MORE than one period: refined models routinely extend across + // cell boundaries (a real case spans 1.2 cells along a and b), and the + // map is periodic so repeated samples are correct. Cap at 3 periods as + // a memory sanity bound. + const cnt = Math.min(i1 - i0 + 1, 3 * n[u]); + start.push(i0); + count.push(cnt); + } + const wrap = (i: number, nn: number) => ((i % nn) + nn) % nn; + const out = new Float32Array(count[0] * count[1] * count[2]); + let p = 0; + // output stays z-fastest to match the full-grid layout + for (let iu = 0; iu < count[0]; iu++) { + const gu = wrap(start[0] + iu, nx); + for (let iv = 0; iv < count[1]; iv++) { + const gv = wrap(start[1] + iv, ny); + const rowBase = nz * (gv + ny * gu); + for (let iw = 0; iw < count[2]; iw++) { + out[p++] = data[wrap(start[2] + iw, nz) + rowBase]; + } + } + } + return { data: out, start, dims: count }; +} + +/* Orthogonalization matrix (fractional -> Cartesian), same convention as + * fracMatrix (a along x, b in the xy-plane). */ +function orthoMatrix(cell: Cell) { + const d = Math.PI / 180; + const ca = Math.cos(cell.alpha * d), cb = Math.cos(cell.beta * d); + const cg = Math.cos(cell.gamma * d), sg = Math.sin(cell.gamma * d); + const cx = cell.c * cb; + const cy = cell.c * (ca - cb * cg) / sg; + const cz = Math.sqrt(Math.max(0, cell.c * cell.c - cx * cx - cy * cy)); + return { ax: cell.a, bx: cell.b * cg, by: cell.b * sg, cx, cy, cz }; +} + +/* Carve: zero all block values farther than radiusA from every model atom, so + * the mesh hugs the model instead of filling the whole crop box (the classic + * carved map — also a large triangle-count win). Atoms come as a flat xyz + * Float32Array in Cartesian Å. Masked values become exactly 0, which crosses + * neither a positive nor a negative iso level. */ +function carveBlock(block: Float32Array, start: number[], dims: number[], n: number[], cell: Cell, + atoms: Float32Array, radiusA: number) { + const M = fracMatrix(cell); + const O = orthoMatrix(cell); + const mask = new Uint8Array(block.length); + const stepR = [ + Math.ceil(radiusA / (cell.a / n[0])) + 1, + Math.ceil(radiusA / (cell.b / n[1])) + 1, + Math.ceil(radiusA / (cell.c / n[2])) + 1, + ]; + const r2 = radiusA * radiusA; + for (let a = 0; a < atoms.length; a += 3) { + const x = atoms[a], y = atoms[a + 1], z = atoms[a + 2]; + const f = toFrac(M, x, y, z); + const gi = [f[0] * n[0] - start[0], f[1] * n[1] - start[1], f[2] * n[2] - start[2]]; + const lo0 = Math.max(0, Math.floor(gi[0] - stepR[0])); + const hi0 = Math.min(dims[0] - 1, Math.ceil(gi[0] + stepR[0])); + const lo1 = Math.max(0, Math.floor(gi[1] - stepR[1])); + const hi1 = Math.min(dims[1] - 1, Math.ceil(gi[1] + stepR[1])); + const lo2 = Math.max(0, Math.floor(gi[2] - stepR[2])); + const hi2 = Math.min(dims[2] - 1, Math.ceil(gi[2] + stepR[2])); + for (let iu = lo0; iu <= hi0; iu++) { + const fu = (start[0] + iu) / n[0]; + for (let iv = lo1; iv <= hi1; iv++) { + const fv = (start[1] + iv) / n[1]; + const rowBase = dims[2] * (iv + dims[1] * iu); + for (let iw = lo2; iw <= hi2; iw++) { + const idx = rowBase + iw; + if (mask[idx]) continue; + const fw = (start[2] + iw) / n[2]; + const px = O.ax * fu + O.bx * fv + O.cx * fw; + const py = O.by * fv + O.cy * fw; + const pz = O.cz * fw; + const dx = px - x, dy = py - y, dz = pz - z; + if (dx * dx + dy * dy + dz * dz <= r2) mask[idx] = 1; + } + } + } + } + for (let i = 0; i < block.length; i++) { + if (!mask[i]) block[i] = 0; + } +} + +/* One map as the worker's onmessage loop makes it: cropped to `crop`, carved around `carveAtoms` (2Fo-Fc only - + * never a difference map: unmodelled blobs sit away from every atom), with the full cell's mean and RMS. */ +export function prepareMap(name: string, full: FullCellMap, crop: CropBox | null, carveAtoms: Float32Array | null, + carveRadiusA = 4.5): ComputedMap { + const [nx, ny, nz] = full.n; + let data = full.data; + let start = [0, 0, 0]; + let dims = [nx, ny, nz]; + if (crop) { + const cropped = cropToBox(data, nx, ny, nz, full.cell, crop); + data = cropped.data; + start = cropped.start; + dims = cropped.dims; + } else { + data = data.slice(); + } + if (carveAtoms) + carveBlock(data, start, dims, [nx, ny, nz], full.cell, carveAtoms, carveRadiusA); + const ccp4 = encodeCcp4(data, dims, start, [nx, ny, nz], full.cell, full.mean, full.rms); + return { name, ccp4, rmsd: full.rms, mean: full.mean, dims: [dims[0], dims[1], dims[2]] }; +} diff --git a/frontend/src/components/structure/loadMaps.ts b/frontend/src/components/structure/loadMaps.ts new file mode 100644 index 000000000..b15c64bf0 --- /dev/null +++ b/frontend/src/components/structure/loadMaps.ts @@ -0,0 +1,109 @@ +// Adapted from aaredb (gitea.psi.ch/mx/aaredb, the PSI MX group's own code), frontend/src/components/Structure/molstar/loadMaps.ts (+ the anomalous layer) at 8ece2c2. +import { PluginContext } from 'molstar/lib/mol-plugin/context'; +import { StateTransforms } from 'molstar/lib/mol-plugin-state/transforms'; +import { createVolumeRepresentationParams } from 'molstar/lib/mol-plugin-state/helpers/volume-representation-params'; +import { Volume } from 'molstar/lib/mol-model/volume'; +import { Color } from 'molstar/lib/mol-util/color'; +import { setSubtreeVisibility } from 'molstar/lib/mol-plugin/behavior/static/state'; +import type { ComputedMap } from './densityCrop'; + +// Density volumes in the Mol* state tree. σ sliders map user-σ to ABSOLUTE iso +// values via the map's full-grid mean/rms (reported by the worker) — Mol*'s own +// relative sigma would re-derive stats from whatever grid it holds, which stops +// being crystallographically meaningful the moment we crop. A σ change is a +// representation param update (re-mesh only — no FFT, no re-upload). + +export const MAP_COLORS = { + twofofc: 0x4d94d6, // steel-blue + fofcPos: 0x35b96a, // green + fofcNeg: 0xd6564d, // red + anom: 0xe040e0, // magenta (anomalous difference map, +sigma only) +} as const; + +export interface MapLayer { + volumeRef: string; + reprRefs: string[]; // one for 2fofc and anom, two (+/−) for fofc + rmsd: number; + mean: number; +} + +const abs = (map: { mean: number; rmsd: number }, sigma: number) => + Volume.IsoValue.absolute(map.mean + sigma * map.rmsd); + +async function addIso( + plugin: PluginContext, + volumeRef: string, + map: ComputedMap, + sigma: number, + color: number, +): Promise { + const volumeCell = plugin.state.data.select(volumeRef)[0]; + const repr = plugin + .build() + .to(volumeRef) + .apply( + StateTransforms.Representation.VolumeRepresentation3D, + createVolumeRepresentationParams(plugin, volumeCell?.obj?.data, { + type: 'isosurface', + typeParams: { isoValue: abs(map, sigma), visuals: ['wireframe'], sizeFactor: 1 }, + color: 'uniform', + colorParams: { value: Color(color) }, + }), + ); + await repr.commit(); + // The mesh must NOT be pickable: a pickable isosurface shields the model, + // so residue clicks and middle-click recenter would hit map cells instead + // of atoms. Pickability is runtime state on the INNER representation + // (cell.obj.data.repr — the cell data itself has no setState), and the + // pick buffer needs a redraw to drop the mesh. + const reprCell = plugin.state.data.select(repr.ref)[0]; + const reprObj = (reprCell?.obj?.data as { repr?: { setState?: (s: { pickable: boolean }) => void } })?.repr; + reprObj?.setState?.({ pickable: false }); + plugin.canvas3d?.requestDraw?.(); + return repr.ref; +} + +export async function loadMapLayer( + plugin: PluginContext, + map: ComputedMap, + kind: '2fofc' | 'fofc' | 'anom', + sigma: number, +): Promise { + const data = await plugin.builders.data.rawData({ + data: new Uint8Array(map.ccp4), + label: kind, + }); + const parsed = await plugin.dataFormats.get('ccp4')!.parse(plugin, data); + const volumeRef: string = parsed.volume.ref; + + const reprRefs: string[] = []; + if (kind === '2fofc') { + reprRefs.push(await addIso(plugin, volumeRef, map, sigma, MAP_COLORS.twofofc)); + } else if (kind === 'anom') { + reprRefs.push(await addIso(plugin, volumeRef, map, sigma, MAP_COLORS.anom)); + } else { + reprRefs.push(await addIso(plugin, volumeRef, map, +sigma, MAP_COLORS.fofcPos)); + reprRefs.push(await addIso(plugin, volumeRef, map, -sigma, MAP_COLORS.fofcNeg)); + } + return { volumeRef, reprRefs, rmsd: map.rmsd, mean: map.mean }; +} + +/** σ slider update: param-only change, re-mesh without recompute. */ +export async function updateLayerSigma( + plugin: PluginContext, + layer: MapLayer, + sigma: number, +): Promise { + const sigmas = layer.reprRefs.length === 2 ? [+sigma, -sigma] : [sigma]; + const b = plugin.build(); + layer.reprRefs.forEach((ref, i) => { + b.to(ref).update((old: { type: { params: { isoValue: unknown } } }) => { + old.type.params.isoValue = abs(layer, sigmas[i]); + }); + }); + await b.commit(); +} + +export function setLayerVisible(plugin: PluginContext, layer: MapLayer, visible: boolean): void { + setSubtreeVisibility(plugin.state.data, layer.volumeRef, !visible); // arg = isHidden +} diff --git a/frontend/src/components/structure/loadModel.ts b/frontend/src/components/structure/loadModel.ts new file mode 100644 index 000000000..1d10d28da --- /dev/null +++ b/frontend/src/components/structure/loadModel.ts @@ -0,0 +1,129 @@ +// Adapted from aaredb (gitea.psi.ch/mx/aaredb, the PSI MX group's own code), frontend/src/components/Structure/molstar/loadModel.ts (format option, atom coordinates) at 8ece2c2. +import { PluginContext } from 'molstar/lib/mol-plugin/context'; +import { StateObjectSelector } from 'molstar/lib/mol-state'; +import { setSubtreeVisibility } from 'molstar/lib/mol-plugin/behavior/static/state'; +import { Color } from 'molstar/lib/mol-util/color'; + +// Chain palette chosen to stay clear of the density colors: no greens (Fo-Fc+), +// no reds/oranges (Fo-Fc− reads orange on some screens) and no strong blues +// (2Fo-Fc). Silver → violet → pink → sand → slate, cycled per chain. +const CHAIN_COLORS = [0xc2cbd6, 0xb18ae0, 0xe08ac2, 0xd8c9a3, 0x8291a5].map(Color); +const CHAIN_PALETTE = { + palette: { + name: 'colors' as const, + params: { list: { kind: 'set' as const, colors: CHAIN_COLORS } }, + }, +}; + +// 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. + +export type ReprMode = 'cartoon' | 'sticks' | 'both'; + +export interface LoadedModel { + structure: StateObjectSelector; + refs: { + polymerCartoon?: string; + polymerSticks?: string; + ligand?: string; + water?: string; + }; +} + +export async function loadModel( + plugin: PluginContext, + bytes: ArrayBuffer, + label: string, + opts?: { resetCamera?: boolean, format?: 'pdb' | 'mmcif' }, +): Promise { + const text = new TextDecoder().decode(bytes); + const data = await plugin.builders.data.rawData({ data: text, label }); + const trajectory = await plugin.builders.structure.parseTrajectory(data, opts?.format ?? 'pdb'); + const model = await plugin.builders.structure.createModel(trajectory); + const structure = await plugin.builders.structure.createStructure(model); + + const refs: LoadedModel['refs'] = {}; + + const polymer = await plugin.builders.structure.tryCreateComponentStatic(structure, 'polymer'); + if (polymer) { + const cartoon = await plugin.builders.structure.representation.addRepresentation(polymer, { + type: 'cartoon', + color: 'chain-id', + colorParams: CHAIN_PALETTE, + }); + refs.polymerCartoon = cartoon.ref; + const sticks = await plugin.builders.structure.representation.addRepresentation(polymer, { + type: 'ball-and-stick', + color: 'element-symbol', + }); + refs.polymerSticks = sticks.ref; + } + const ligand = await plugin.builders.structure.tryCreateComponentStatic(structure, 'ligand'); + if (ligand) { + const r = await plugin.builders.structure.representation.addRepresentation(ligand, { + type: 'ball-and-stick', + }); + refs.ligand = r.ref; + } + const water = await plugin.builders.structure.tryCreateComponentStatic(structure, 'water'); + if (water) { + const r = await plugin.builders.structure.representation.addRepresentation(water, { + type: 'ball-and-stick', + }); + refs.water = r.ref; + } + + // defaults: cartoon + ligand sticks; polymer sticks and waters hidden + if (refs.polymerSticks) setSubtreeVisibility(plugin.state.data, refs.polymerSticks, true); + if (refs.water) setSubtreeVisibility(plugin.state.data, refs.water, true); + + // Skippable so a pipeline/pair switch keeps the user's view and clip — + // the models of one run share the same crystal frame (assuming they align). + if (opts?.resetCamera !== false) plugin.managers.camera.reset(); + return { structure, refs }; +} + +/** Apply a representation mode + waters flag as visibility flips. */ +export function applyModelDisplay( + plugin: PluginContext, + refs: LoadedModel['refs'], + mode: ReprMode, + showWaters: boolean, +): void { + const s = plugin.state.data; + // setSubtreeVisibility's boolean is "isHidden" + if (refs.polymerCartoon) setSubtreeVisibility(s, refs.polymerCartoon, mode === 'sticks'); + if (refs.polymerSticks) setSubtreeVisibility(s, refs.polymerSticks, mode === 'cartoon'); + if (refs.water) setSubtreeVisibility(s, refs.water, !showWaters); +} + +export async function clearScene(plugin: PluginContext): Promise { + await plugin.clear(); +} + +/** Cartesian bounding box (Å) of a loaded structure — the density worker crops + * the periodic map to this region (+margin) so the mesh hugs the model instead + * of drawing one displaced unit-cell block. */ +export function getStructureBox( + structure: StateObjectSelector, +): { min: number[]; max: number[] } | null { + const data = (structure as { data?: { boundary?: { box?: { min: ArrayLike; max: ArrayLike } } } }).data; + const box = data?.boundary?.box; + if (!box) return null; + return { min: Array.from(box.min).slice(0, 3), max: Array.from(box.max).slice(0, 3) }; +} +/** Cartesian coordinates (A) of every atom of a loaded structure, xyz flat - what the 2Fo-Fc map is carved + * around (aaredb reads them from the PDB text, parsePdbAtoms; here the model comes as mmCIF). */ +export function getStructureAtoms(structure: StateObjectSelector): Float32Array { + const s = (structure as { data?: { units: ReadonlyArray<{ elements: ArrayLike, conformation: { + x: (e: number) => number, y: (e: number) => number, z: (e: number) => number } }> } }).data; + if (!s) return new Float32Array(0); + const xyz: number[] = []; + for (const unit of s.units) + for (let i = 0; i < unit.elements.length; i++) { + const e = unit.elements[i]; + xyz.push(unit.conformation.x(e), unit.conformation.y(e), unit.conformation.z(e)); + } + return new Float32Array(xyz); +}