Browse Source

Merge pull request #530 from midlik/volume-streaming-camera-target

Volume streaming camera target (without caching)
David Sehnal 2 years ago
parent
commit
9ff8becd62

+ 2 - 0
CHANGELOG.md

@@ -7,6 +7,8 @@ Note that since we don't clearly distinguish between a public and private interf
 ## [Unreleased]
 
 - Fix wboit in Safari >=15 (add missing depth renderbuffer to wboit pass)
+- Add 'Around Camera' option to Volume streaming
+- Avoid queuing more than one update in Volume streaming
 
 ## [v3.14.0] - 2022-08-20
 

+ 2 - 1
package.json

@@ -89,7 +89,8 @@
     "Ludovic Autin <autin@scripps.edu>",
     "Michal Malý <michal.maly@ibt.cas.cz>",
     "Jiří Černý <jiri.cerny@ibt.cas.cz>",
-    "Panagiotis Tourlas <panagiot_tourlov@hotmail.com>"
+    "Panagiotis Tourlas <panagiot_tourlov@hotmail.com>",
+    "Adam Midlik <midlik@gmail.com>"
   ],
   "license": "MIT",
   "devDependencies": {

+ 1 - 1
src/mol-math/linear-algebra/3d/vec3.ts

@@ -35,7 +35,7 @@ function Vec3() {
 
 namespace Vec3 {
     export function zero(): Vec3 {
-        const out = [0.1, 0.0, 0.0];
+        const out = [0.1, 0.0, 0.0];  // ensure backing array of type double
         out[0] = 0;
         return out as any;
     }

+ 29 - 8
src/mol-plugin-ui/custom/volume.tsx

@@ -1,7 +1,8 @@
 /**
- * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
 import { PluginUIComponent } from '../base';
@@ -199,6 +200,9 @@ export class VolumeStreamingCustomControls extends PluginUIComponent<StateTransf
             const viewParams = { ...oldView };
             if (value.name === 'selection-box') {
                 viewParams.radius = value.params.radius;
+            } else if (value.name === 'camera-target') {
+                viewParams.radius = value.params.radius;
+                viewParams.dynamicDetailLevel = value.params.dynamicDetailLevel;
             } else if (value.name === 'box') {
                 viewParams.bottomLeft = value.params.bottomLeft;
                 viewParams.topRight = value.params.topRight;
@@ -240,13 +244,23 @@ export class VolumeStreamingCustomControls extends PluginUIComponent<StateTransf
         const pivot = isEM ? 'em' : '2fo-fc';
 
         const params = this.props.params as VolumeStreaming.Params;
-        const entry = ((this.props.info.params as VolumeStreaming.ParamDefinition)
-            .entry.map(params.entry.name) as PD.Group<VolumeStreaming.EntryParamDefinition>);
+        const entry = (this.props.info.params as VolumeStreaming.ParamDefinition)
+            .entry.map(params.entry.name) as PD.Group<VolumeStreaming.EntryParamDefinition>;
         const detailLevel = entry.params.detailLevel;
-        const isRelative = ((params.entry.params.channels as any)[pivot].isoValue as Volume.IsoValue).kind === 'relative';
+        const dynamicDetailLevel = {
+            ...detailLevel,
+            label: 'Dynamic Detail',
+            defaultValue: (entry.params.view as any).map('camera-target').params.dynamicDetailLevel.defaultValue,
+        }
+        const selectionDetailLevel = {
+            ...detailLevel,
+            label: 'Selection Detail',
+            defaultValue: (entry.params.view as any).map('auto').params.selectionDetailLevel.defaultValue,
+        }
 
         const sampling = b.info.header.sampling[0];
-
+        
+        const isRelative = ((params.entry.params.channels as any)[pivot].isoValue as Volume.IsoValue).kind === 'relative';
         const isRelativeParam = PD.Boolean(isRelative, { description: 'Use normalized or absolute isocontour scale.', label: 'Normalized' });
 
         const isUnbounded = !!(params.entry.params.view.params as any).isUnbounded;
@@ -274,6 +288,13 @@ export class VolumeStreamingCustomControls extends PluginUIComponent<StateTransf
                     isRelative: isRelativeParam,
                     isUnbounded: isUnboundedParam,
                 }, { description: 'Box around focused element.' }),
+                'camera-target': PD.Group({
+                    radius: PD.Numeric(0.5, { min: 0, max: 1, step: 0.05 }, { description: 'Radius within which the volume is shown (relative to the field of view).' }),
+                    detailLevel: {...detailLevel, isHidden: true},
+                    dynamicDetailLevel: dynamicDetailLevel,
+                    isRelative: isRelativeParam,
+                    isUnbounded: isUnboundedParam,
+                }, { description: 'Box around camera target.' }),
                 'cell': PD.Group({
                     detailLevel,
                     isRelative: isRelativeParam,
@@ -282,12 +303,11 @@ export class VolumeStreamingCustomControls extends PluginUIComponent<StateTransf
                 'auto': PD.Group({
                     radius: PD.Numeric(5, { min: 0, max: 50, step: 0.5 }, { description: 'Radius in \u212B within which the volume is shown.' }),
                     detailLevel,
-                    selectionDetailLevel: { ...detailLevel, label: 'Selection Detail' },
+                    selectionDetailLevel: selectionDetailLevel,
                     isRelative: isRelativeParam,
                     isUnbounded: isUnboundedParam,
                 }, { description: 'Box around focused element.' }),
-                // 'auto': PD.Group({  }), // TODO based on camera distance/active selection/whatever, show whole structure or slice.
-            }, { options: VolumeStreaming.ViewTypeOptions, description: 'Controls what of the volume is displayed. "Off" hides the volume alltogether. "Bounded box" shows the volume inside the given box. "Around Focus" shows the volume around the element/atom last interacted with. "Whole Structure" shows the volume for the whole structure.' })
+            }, { options: VolumeStreaming.ViewTypeOptions, description: 'Controls what of the volume is displayed. "Off" hides the volume alltogether. "Bounded box" shows the volume inside the given box. "Around Focus" shows the volume around the element/atom last interacted with. "Around Camera" shows the volume around the point the camera is targeting. "Whole Structure" shows the volume for the whole structure.' })
         };
         const options = {
             entry: params.entry.name,
@@ -299,6 +319,7 @@ export class VolumeStreamingCustomControls extends PluginUIComponent<StateTransf
                     bottomLeft: (params.entry.params.view.params as any).bottomLeft,
                     topRight: (params.entry.params.view.params as any).topRight,
                     selectionDetailLevel: (params.entry.params.view.params as any).selectionDetailLevel,
+                    dynamicDetailLevel: (params.entry.params.view.params as any).dynamicDetailLevel,
                     isRelative,
                     isUnbounded
                 }

+ 14 - 3
src/mol-plugin/behavior/behavior.ts

@@ -1,7 +1,8 @@
 /**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
 import { PluginStateTransform, PluginStateObject } from '../../mol-plugin-state/objects';
@@ -144,8 +145,18 @@ namespace PluginBehavior {
         protected subscribeCommand<T>(cmd: PluginCommand<T>, action: PluginCommand.Action<T>) {
             this.subs.push(cmd.subscribe(this.plugin, action));
         }
-        protected subscribeObservable<T>(o: Observable<T>, action: (v: T) => void) {
-            this.subs.push(o.subscribe(action));
+        protected subscribeObservable<T>(o: Observable<T>, action: (v: T) => void): PluginCommand.Subscription {
+            const sub = o.subscribe(action)
+            this.subs.push(sub);
+            return { 
+                unsubscribe: () => {
+                    const idx = this.subs.indexOf(sub);
+                    if (idx >= 0){
+                        this.subs.splice(idx, 1);
+                        sub.unsubscribe();
+                    }
+                } 
+            }
         }
         dispose(): void {
             for (const s of this.subs) s.unsubscribe();

+ 163 - 84
src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts

@@ -1,8 +1,9 @@
 /**
- * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
 import { ParamDefinition as PD } from '../../../../mol-util/param-definition';
@@ -24,6 +25,10 @@ import { PluginContext } from '../../../context';
 import { EmptyLoci, Loci, isEmptyLoci } from '../../../../mol-model/loci';
 import { Asset } from '../../../../mol-util/assets';
 import { GlobalModelTransformInfo } from '../../../../mol-model/structure/model/properties/global-transform';
+import { distinctUntilChanged, filter, map, Observable, throttleTime } from 'rxjs';
+import { Camera } from '../../../../mol-canvas3d/camera';
+import { PluginCommand } from '../../../command';
+import { SingleAsyncQueue } from '../../../../mol-util/single-async-queue';
 
 export class VolumeStreaming extends PluginStateObject.CreateBehavior<VolumeStreaming.Behavior>({ name: 'Volume Streaming' }) { }
 
@@ -53,7 +58,7 @@ export namespace VolumeStreaming {
         valuesInfo: [{ mean: 0, min: -1, max: 1, sigma: 0.1 }, { mean: 0, min: -1, max: 1, sigma: 0.1 }]
     };
 
-    export function createParams(options: { data?: VolumeServerInfo.Data, defaultView?: ViewTypes, channelParams?: DefaultChannelParams } = { }) {
+    export function createParams(options: { data?: VolumeServerInfo.Data, defaultView?: ViewTypes, channelParams?: DefaultChannelParams } = {}) {
         const { data, defaultView, channelParams } = options;
         const map = new Map<string, VolumeServerInfo.EntryData>();
         if (data) data.entries.forEach(d => map.set(d.dataId, d));
@@ -68,7 +73,7 @@ export namespace VolumeStreaming {
     export type EntryParams = PD.Values<EntryParamDefinition>
 
     export function createEntryParams(options: { entryData?: VolumeServerInfo.EntryData, defaultView?: ViewTypes, structure?: Structure, channelParams?: DefaultChannelParams }) {
-        const { entryData, defaultView, structure, channelParams = { } } = options;
+        const { entryData, defaultView, structure, channelParams = {} } = options;
 
         // fake the info
         const info = entryData || { kind: 'em', header: { sampling: [fakeSampling], availablePrecisions: [{ precision: 0, maxVoxels: 0 }] }, emDefaultContourLevel: Volume.IsoValue.relative(0) };
@@ -86,19 +91,24 @@ export namespace VolumeStreaming {
                     bottomLeft: PD.Vec3(Vec3.create(0, 0, 0), {}, { isHidden: true }),
                     topRight: PD.Vec3(Vec3.create(0, 0, 0), {}, { isHidden: true }),
                 }, { description: 'Box around focused element.', isFlat: true }),
+                'camera-target': PD.Group({
+                    radius: PD.Numeric(0.5, { min: 0, max: 1, step: 0.05 }, { description: 'Radius within which the volume is shown (relative to the field of view).' }),
+                    // Minimal detail level for the inside of the zoomed region (real detail can be higher, depending on the region size)
+                    dynamicDetailLevel: createDetailParams(info.header.availablePrecisions, 0, { label: 'Dynamic Detail' }),
+                    bottomLeft: PD.Vec3(Vec3.create(0, 0, 0), {}, { isHidden: true }),
+                    topRight: PD.Vec3(Vec3.create(0, 0, 0), {}, { isHidden: true }),
+                }, { description: 'Box around camera target.', isFlat: true }),
                 'cell': PD.Group<{}>({}),
                 // Show selection-box if available and cell otherwise.
                 'auto': PD.Group({
                     radius: PD.Numeric(5, { min: 0, max: 50, step: 0.5 }, { description: 'Radius in \u212B within which the volume is shown.' }),
-                    selectionDetailLevel: PD.Select<number>(Math.min(6, info.header.availablePrecisions.length - 1),
-                        info.header.availablePrecisions.map((p, i) => [i, `${i + 1} [ ${Math.pow(p.maxVoxels, 1 / 3) | 0}^3 cells ]`] as [number, string]), { label: 'Selection Detail', description: 'Determines the maximum number of voxels. Depending on the size of the volume options are in the range from 0 (0.52M voxels) to 6 (25.17M voxels).' }),
+                    selectionDetailLevel: createDetailParams(info.header.availablePrecisions, 6, { label: 'Selection Detail' }),
                     isSelection: PD.Boolean(false, { isHidden: true }),
                     bottomLeft: PD.Vec3(box.min, {}, { isHidden: true }),
                     topRight: PD.Vec3(box.max, {}, { isHidden: true }),
                 }, { description: 'Box around focused element.', isFlat: true })
             }, { options: ViewTypeOptions, description: 'Controls what of the volume is displayed. "Off" hides the volume alltogether. "Bounded box" shows the volume inside the given box. "Around Interaction" shows the volume around the focused element/atom. "Whole Structure" shows the volume for the whole structure.' }),
-            detailLevel: PD.Select<number>(Math.min(3, info.header.availablePrecisions.length - 1),
-                info.header.availablePrecisions.map((p, i) => [i, `${i + 1} [ ${Math.pow(p.maxVoxels, 1 / 3) | 0}^3 cells ]`] as [number, string]), { description: 'Determines the maximum number of voxels. Depending on the size of the volume options are in the range from 0 (0.52M voxels) to 6 (25.17M voxels).' }),
+            detailLevel: createDetailParams(info.header.availablePrecisions, 3),
             channels: info.kind === 'em'
                 ? PD.Group({
                     'em': channelParam('EM', Color(0x638F8F), info.emDefaultContourLevel || Volume.IsoValue.relative(1), info.header.sampling[0].valuesInfo[0], channelParams['em'])
@@ -111,13 +121,40 @@ export namespace VolumeStreaming {
         };
     }
 
-    export const ViewTypeOptions = [['off', 'Off'], ['box', 'Bounded Box'], ['selection-box', 'Around Focus'], ['cell', 'Whole Structure'], ['auto', 'Auto']] as [ViewTypes, string][];
+    function createDetailParams(availablePrecisions: VolumeServerHeader.DetailLevel[], preferredPrecision: number, info?: PD.Info) {
+        return PD.Select<number>(Math.min(preferredPrecision, availablePrecisions.length - 1),
+            availablePrecisions.map((p, i) => [i, `${i + 1} [ ${Math.pow(p.maxVoxels, 1 / 3) | 0}^3 cells ]`] as [number, string]),
+            {
+                description: 'Determines the maximum number of voxels. Depending on the size of the volume options are in the range from 1 (0.52M voxels) to 7 (25.17M voxels).',
+                ...info
+            }
+        );
+    }
 
-    export type ViewTypes = 'off' | 'box' | 'selection-box' | 'cell' | 'auto'
+    export function copyParams(origParams: Params): Params {
+        return {
+            entry: {
+                name: origParams.entry.name,
+                params: {
+                    detailLevel: origParams.entry.params.detailLevel,
+                    channels: origParams.entry.params.channels,
+                    view: {
+                        name: origParams.entry.params.view.name,
+                        params: { ...origParams.entry.params.view.params } as any,
+                    }
+                }
+            }
+        };
+    }
+
+    export const ViewTypeOptions = [['off', 'Off'], ['box', 'Bounded Box'], ['selection-box', 'Around Focus'], ['camera-target', 'Around Camera'], ['cell', 'Whole Structure'], ['auto', 'Auto']] as [ViewTypes, string][];
+
+    export type ViewTypes = 'off' | 'box' | 'selection-box' | 'camera-target' | 'cell' | 'auto'
 
     export type ParamDefinition = ReturnType<typeof createParams>
     export type Params = PD.Values<ParamDefinition>
 
+
     type ChannelsInfo = { [name in ChannelType]?: { isoValue: Volume.IsoValue, color: Color, wireframe: boolean, opacity: number } }
     type ChannelsData = { [name in 'EM' | '2FO-FC' | 'FO-FC']?: Volume }
 
@@ -140,6 +177,14 @@ export namespace VolumeStreaming {
         private lastLoci: StructureElement.Loci | EmptyLoci = EmptyLoci;
         private ref: string = '';
         public infoMap: Map<string, VolumeServerInfo.EntryData>;
+        private updateQueue: SingleAsyncQueue;
+        private cameraTargetObservable = this.plugin.canvas3d!.didDraw!.pipe(
+            throttleTime(500, undefined, { 'leading': true, 'trailing': true }),
+            map(() => this.plugin.canvas3d?.camera.getSnapshot()),
+            distinctUntilChanged((a, b) => this.isCameraTargetSame(a, b)),
+            filter(a => a !== undefined),
+        ) as Observable<Camera.Snapshot>;
+        private cameraTargetSubscription?: PluginCommand.Subscription = undefined;
 
         channels: Channels = {};
 
@@ -163,6 +208,9 @@ export namespace VolumeStreaming {
             if (this.params.entry.params.view.name === 'auto' && this.params.entry.params.view.params.isSelection) {
                 detail = this.params.entry.params.view.params.selectionDetailLevel;
             }
+            if (this.params.entry.params.view.name === 'camera-target' && box) {
+                detail = this.decideDetail(box, this.params.entry.params.view.params.dynamicDetailLevel);
+            }
 
             url += `?detail=${detail}`;
 
@@ -201,58 +249,21 @@ export namespace VolumeStreaming {
             return ret;
         }
 
-        private updateSelectionBoxParams(box: Box3D) {
-            if (this.params.entry.params.view.name !== 'selection-box') return;
-
-            const state = this.plugin.state.data;
-            const newParams: Params = {
-                ...this.params,
-                entry: {
-                    name: this.params.entry.name,
-                    params: {
-                        ...this.params.entry.params,
-                        view: {
-                            name: 'selection-box' as const,
-                            params: {
-                                radius: this.params.entry.params.view.params.radius,
-                                bottomLeft: box.min,
-                                topRight: box.max
-                            }
-                        }
-                    }
-                }
-            };
-            const update = state.build().to(this.ref).update(newParams);
-
-            PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotUpdateCurrent: true } });
-        }
-
-        private updateAutoParams(box: Box3D | undefined, isSelection: boolean) {
-            if (this.params.entry.params.view.name !== 'auto') return;
+        private async updateParams(box: Box3D | undefined, autoIsSelection: boolean = false) {
+            const newParams = copyParams(this.params);
+            const viewType = newParams.entry.params.view.name;
+            if (viewType !== 'off' && viewType !== 'cell') {
+                newParams.entry.params.view.params.bottomLeft = box?.min || Vec3.zero();
+                newParams.entry.params.view.params.topRight = box?.max || Vec3.zero();
+            }
+            if (viewType === 'auto') {
+                newParams.entry.params.view.params.isSelection = autoIsSelection;
+            }
 
             const state = this.plugin.state.data;
-            const newParams: Params = {
-                ...this.params,
-                entry: {
-                    name: this.params.entry.name,
-                    params: {
-                        ...this.params.entry.params,
-                        view: {
-                            name: 'auto' as const,
-                            params: {
-                                radius: this.params.entry.params.view.params.radius,
-                                selectionDetailLevel: this.params.entry.params.view.params.selectionDetailLevel,
-                                isSelection,
-                                bottomLeft: box?.min || Vec3.zero(),
-                                topRight: box?.max || Vec3.zero()
-                            }
-                        }
-                    }
-                }
-            };
             const update = state.build().to(this.ref).update(newParams);
 
-            PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotUpdateCurrent: true } });
+            await PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotUpdateCurrent: true } });
         }
 
         private getStructureRoot() {
@@ -303,6 +314,18 @@ export namespace VolumeStreaming {
             }
         }
 
+        private isCameraTargetSame(a?: Camera.Snapshot, b?: Camera.Snapshot): boolean {
+            if (!a || !b) return false;
+            const targetSame = Vec3.equals(a.target, b.target);
+            const sqDistA = Vec3.squaredDistance(a.target, a.position);
+            const sqDistB = Vec3.squaredDistance(b.target, b.position);
+            const distanceSame = Math.abs(sqDistA - sqDistB) / sqDistA < 1e-3;
+            return targetSame && distanceSame;
+        }
+        private cameraTargetDistance(snapshot: Camera.Snapshot): number {
+            return Vec3.distance(snapshot.target, snapshot.position);
+        }
+
         private _invTransform: Mat4 = Mat4();
         private getBoxFromLoci(loci: StructureElement.Loci | EmptyLoci): Box3D {
             if (Loci.isEmpty(loci) || isEmptyLoci(loci)) {
@@ -328,39 +351,82 @@ export namespace VolumeStreaming {
         }
 
         private updateAuto(loci: StructureElement.Loci | EmptyLoci) {
-            // if (Loci.areEqual(this.lastLoci, loci)) {
-            //     this.lastLoci = EmptyLoci;
-            //     this.updateSelectionBoxParams(Box3D.empty());
-            //     return;
-            // }
-
-            this.lastLoci = loci;
-
-            if (isEmptyLoci(loci)) {
-                this.updateAutoParams(this.info.kind === 'x-ray' ? this.data.structure.boundary.box : void 0, false);
-                return;
-            }
-
-            const box = this.getBoxFromLoci(loci);
-            this.updateAutoParams(box, true);
+            this.updateQueue.enqueue(async () => {
+                this.lastLoci = loci;
+                if (isEmptyLoci(loci)) {
+                    await this.updateParams(this.info.kind === 'x-ray' ? this.data.structure.boundary.box : void 0, false);
+                } else {
+                    await this.updateParams(this.getBoxFromLoci(loci), true);
+                }
+            });
         }
 
         private updateSelectionBox(loci: StructureElement.Loci | EmptyLoci) {
-            if (Loci.areEqual(this.lastLoci, loci)) {
-                this.lastLoci = EmptyLoci;
-                this.updateSelectionBoxParams(Box3D());
-                return;
-            }
+            this.updateQueue.enqueue(async () => {
+                if (Loci.areEqual(this.lastLoci, loci)) {
+                    this.lastLoci = EmptyLoci;
+                } else {
+                    this.lastLoci = loci;
+                }
+                const box = this.getBoxFromLoci(this.lastLoci);
+                await this.updateParams(box);
+            });
+        }
 
-            this.lastLoci = loci;
+        private updateCameraTarget(snapshot: Camera.Snapshot) {
+            this.updateQueue.enqueue(async () => {
+                const origManualReset = this.plugin.canvas3d?.props.camera.manualReset;
+                try {
+                    if (!origManualReset) this.plugin.canvas3d?.setProps({ camera: { manualReset: true } });
+                    const box = this.boxFromCameraTarget(snapshot, true);
+                    await this.updateParams(box);
+                } finally {
+                    if (!origManualReset) this.plugin.canvas3d?.setProps({ camera: { manualReset: origManualReset } });
+                }
+            });
+        }
 
-            if (isEmptyLoci(loci)) {
-                this.updateSelectionBoxParams(Box3D());
-                return;
+        private boxFromCameraTarget(snapshot: Camera.Snapshot, boundByBoundarySize: boolean): Box3D {
+            const target = snapshot.target;
+            const distance = this.cameraTargetDistance(snapshot);
+            const top = Math.tan(0.5 * snapshot.fov) * distance;
+            let radius = top;
+            const viewport = this.plugin.canvas3d?.camera.viewport;
+            if (viewport && viewport.width > viewport.height) {
+                radius *= viewport.width / viewport.height;
+            }
+            const relativeRadius = this.params.entry.params.view.name === 'camera-target' ? this.params.entry.params.view.params.radius : 0.5;
+            radius *= relativeRadius;
+            let radiusX, radiusY, radiusZ;
+            if (boundByBoundarySize) {
+                let bBoxSize = Vec3.zero();
+                Box3D.size(bBoxSize, this.data.structure.boundary.box);
+                radiusX = Math.min(radius, 0.5 * bBoxSize[0]);
+                radiusY = Math.min(radius, 0.5 * bBoxSize[1]);
+                radiusZ = Math.min(radius, 0.5 * bBoxSize[2]);
+            } else {
+                radiusX = radiusY = radiusZ = radius;
             }
+            return Box3D.create(
+                Vec3.create(target[0] - radiusX, target[1] - radiusY, target[2] - radiusZ),
+                Vec3.create(target[0] + radiusX, target[1] + radiusY, target[2] + radiusZ)
+            );
+        }
 
-            const box = this.getBoxFromLoci(loci);
-            this.updateSelectionBoxParams(box);
+        private decideDetail(box: Box3D, baseDetail: number): number {
+            const cellVolume = this.info.kind === 'x-ray'
+                ? Box3D.volume(this.data.structure.boundary.box)
+                : this.info.header.spacegroup.size.reduce((a, b) => a * b, 1);
+            const boxVolume = Box3D.volume(box);
+            let ratio = boxVolume / cellVolume;
+            const maxDetail = this.info.header.availablePrecisions.length - 1;
+            let detail = baseDetail;
+            while (ratio <= 0.5 && detail < maxDetail) {
+                ratio *= 2;
+                detail += 1;
+            }
+            // console.log(`Decided dynamic detail: ${detail}, (base detail: ${baseDetail}, box/cell volume ratio: ${boxVolume / cellVolume})`);
+            return detail;
         }
 
         async update(params: Params) {
@@ -369,6 +435,11 @@ export namespace VolumeStreaming {
             this.params = params;
             let box: Box3D | undefined = void 0, emptyData = false;
 
+            if (params.entry.params.view.name !== 'camera-target' && this.cameraTargetSubscription) {
+                this.cameraTargetSubscription.unsubscribe();
+                this.cameraTargetSubscription = undefined;
+            }
+
             switch (params.entry.params.view.name) {
                 case 'off':
                     emptyData = true;
@@ -388,6 +459,12 @@ export namespace VolumeStreaming {
                     Box3D.expand(box, box, Vec3.create(r, r, r));
                     break;
                 }
+                case 'camera-target':
+                    if (!this.cameraTargetSubscription) {
+                        this.cameraTargetSubscription = this.subscribeObservable(this.cameraTargetObservable, (e) => this.updateCameraTarget(e));
+                    }
+                    box = this.boxFromCameraTarget(this.plugin.canvas3d!.camera.getSnapshot(), true);
+                    break;
                 case 'cell':
                     box = this.info.kind === 'x-ray'
                         ? this.data.structure.boundary.box
@@ -439,6 +516,7 @@ export namespace VolumeStreaming {
 
         getDescription() {
             if (this.params.entry.params.view.name === 'selection-box') return 'Selection';
+            if (this.params.entry.params.view.name === 'camera-target') return 'Camera';
             if (this.params.entry.params.view.name === 'box') return 'Static Box';
             if (this.params.entry.params.view.name === 'cell') return 'Cell';
             return '';
@@ -449,6 +527,7 @@ export namespace VolumeStreaming {
 
             this.infoMap = new Map<string, VolumeServerInfo.EntryData>();
             this.data.entries.forEach(info => this.infoMap.set(info.dataId, info));
+            this.updateQueue = new SingleAsyncQueue();
         }
     }
-}
+}

+ 3 - 1
src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts

@@ -1,8 +1,9 @@
 /**
- * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
 import { PluginStateObject as SO, PluginStateTransform } from '../../../../mol-plugin-state/objects';
@@ -219,6 +220,7 @@ const CreateVolumeStreamingBehavior = PluginStateTransform.BuiltIn({
     canAutoUpdate: ({ oldParams, newParams }) => {
         return oldParams.entry.params.view === newParams.entry.params.view
             || newParams.entry.params.view.name === 'selection-box'
+            || newParams.entry.params.view.name === 'camera-target'
             || newParams.entry.params.view.name === 'off';
     },
     apply: ({ a, params }, plugin: PluginContext) => Task.create('Volume streaming', async _ => {

+ 41 - 0
src/mol-util/single-async-queue.ts

@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+
+/** Job queue that allows at most one running and one pending job. 
+ * A newly enqueued job will cancel any other pending jobs. */
+export class SingleAsyncQueue {
+    private isRunning: boolean;
+    private queue: { id: number, func: () => any }[];
+    private counter: number;
+    private log: boolean;
+    constructor(log: boolean = false) {
+        this.isRunning = false;
+        this.queue = [];
+        this.counter = 0;
+        this.log = log;
+    }
+    enqueue(job: () => any) {
+        if (this.log) console.log('SingleAsyncQueue enqueue', this.counter);
+        this.queue[0] = { id: this.counter, func: job };
+        this.counter++;
+        this.run();  // do not await
+    }
+    private async run() {
+        if (this.isRunning) return;
+        const job = this.queue.pop();
+        if (!job) return;
+        this.isRunning = true;
+        try {
+            if (this.log) console.log('SingleAsyncQueue run', job.id);
+            await job.func();
+            if (this.log) console.log('SingleAsyncQueue complete', job.id);
+        } finally {
+            this.isRunning = false;
+            this.run();
+        }
+    }
+}