From 206ee1913898787e1243cfc74e776de36c9c2b42 Mon Sep 17 00:00:00 2001 From: dsehnal Date: Thu, 28 May 2026 15:47:51 +0200 Subject: [PATCH] camera focus "zoom out" --- CHANGELOG.md | 4 +- src/apps/viewer/app.ts | 7 ++- src/mol-canvas3d/camera.ts | 6 +- src/mol-canvas3d/camera/transition.ts | 63 +++++++++++++++++-- src/mol-canvas3d/canvas3d.ts | 8 ++- src/mol-plugin-state/manager/camera.ts | 85 +++++++++++++++++++------- 6 files changed, 138 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 550418869..37ffc34b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,9 @@ Note that since we don't clearly distinguish between a public and private interf - Fix bugs in ModelServer surroundingLigands endpoint, resulting in omitWater not honored - Fix `Volume` and `Isosurface` getBoundingSphere ignoring instances - Fix SSAO half/quarter resolution textures for multi-scale -- Add the option to approximate "least obstructed direction" when focusing camera, accessibe via `PluginContext.managers.camera.focusLoci` with `optimizeDirection` option +- Camera improvements + - Add the option to approximate "least obstructed direction" when focusing camera, accessibe via `PluginContext.managers.camera.focusLoci` with `optimizeDirection` option + - Add the option to "zoom out" to entire scene before focusing camera ## [v5.9.0] - 2026-05-03 - Fix edge case when `PluginSpec.animations` is empty diff --git a/src/apps/viewer/app.ts b/src/apps/viewer/app.ts index 1ddff7e07..46571156f 100644 --- a/src/apps/viewer/app.ts +++ b/src/apps/viewer/app.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author David Sehnal * @author Alexander Rose @@ -555,6 +555,11 @@ export class Viewer { } return; } + + if (actions.includes('select')) { + plugin.managers.interactivity.lociSelects.deselectAll(); + } + const structures = this.plugin.state.data.selectQ(Q => Q.rootsOfType(PluginStateObject.Molecule.Structure)); let focused = false; for (const s of structures) { diff --git a/src/mol-canvas3d/camera.ts b/src/mol-canvas3d/camera.ts index 8a1ff7ecd..57c7e6f2a 100644 --- a/src/mol-canvas3d/camera.ts +++ b/src/mol-canvas3d/camera.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author David Sehnal * @author Alexander Rose @@ -138,8 +138,8 @@ export class Camera implements ICamera { return changed; } - setState(snapshot: Partial, durationMs?: number) { - this.transition.apply(snapshot, durationMs); + setState(snapshot: Partial, durationMs?: number, options?: { keyframes?: CameraTransitionManager.TransitionKeyframes }) { + this.transition.apply(snapshot, durationMs, undefined, options); this.stateChanged.next(snapshot); } diff --git a/src/mol-canvas3d/camera/transition.ts b/src/mol-canvas3d/camera/transition.ts index 67cdede00..9f766a16d 100644 --- a/src/mol-canvas3d/camera/transition.ts +++ b/src/mol-canvas3d/camera/transition.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2018-2024 Mol* contributors, licensed under MIT, See LICENSE file for more info. + * Copyright (c) 2018-2026 Mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author David Sehnal */ @@ -20,12 +20,18 @@ class CameraTransitionManager { private durationMs = 0; private _source: Camera.Snapshot = Camera.createDefaultSnapshot(); private _target: Camera.Snapshot = Camera.createDefaultSnapshot(); + private _keyframes: CameraTransitionManager.TransitionKeyframes | undefined = void 0; private _current = Camera.createDefaultSnapshot(); get source(): Readonly { return this._source; } get target(): Readonly { return this._target; } - apply(to: Partial, durationMs: number = 0, transition?: CameraTransitionManager.TransitionFunc) { + apply( + to: Partial, + durationMs: number = 0, + transition?: CameraTransitionManager.TransitionFunc, + options?: { keyframes?: CameraTransitionManager.TransitionKeyframes }, + ) { if (!this.inTransition || durationMs > 0) { Camera.copySnapshot(this._source, this.camera.state); } @@ -50,6 +56,7 @@ class CameraTransitionManager { this.inTransition = true; this.func = transition || CameraTransitionManager.defaultTransition; + this._keyframes = options?.keyframes; if (!this.inTransition || durationMs > 0) { this.start = this.t; @@ -76,7 +83,7 @@ class CameraTransitionManager { return; } - this.func(this._current, normalized, this._source, this._target); + this.func(this._current, normalized, this._source, this._target, { keyframes: this._keyframes }); Camera.copySnapshot(this.camera.state, this._current); } @@ -86,7 +93,8 @@ class CameraTransitionManager { } namespace CameraTransitionManager { - export type TransitionFunc = (out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot) => void + export type TransitionKeyframes = { t: number, snapshot: Partial }[] + export type TransitionFunc = (out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot, options?: { keyframes?: TransitionKeyframes }) => void const _rotUp = Quat.identity(); const _rotDist = Quat.identity(); @@ -94,7 +102,52 @@ namespace CameraTransitionManager { const _sourcePosition = Vec3(); const _targetPosition = Vec3(); - export function defaultTransition(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot): void { + + let _tempSource: Camera.Snapshot | undefined = void 0; + let _tempTarget: Camera.Snapshot | undefined = void 0; + + export function defaultTransition(out: Camera.Snapshot, t_: number, source_: Camera.Snapshot, target_: Camera.Snapshot, options?: { keyframes?: TransitionKeyframes }): void { + let sourcePartial: Partial = source_; + let targetPartial: Partial = target_; + + let tStart = 0; + let tEnd = 1; + + const keyframes = options?.keyframes; + if (keyframes && keyframes.length > 0) { + for (let i = 0; i < keyframes.length; i++) { + const keyframe = keyframes[i]; + if (t_ >= keyframe.t) { + sourcePartial = keyframe.snapshot; + tStart = keyframe.t; + break; + } + } + for (let i = keyframes.length - 1; i >= 0; i--) { + const keyframe = keyframes[i]; + if (t_ <= keyframe.t) { + targetPartial = keyframe.snapshot; + tEnd = keyframe.t; + } + if (t_ >= keyframe.t) { + break; + } + } + } + + const t = (t_ - tStart) / (tEnd - tStart); + + if (!_tempSource) _tempSource = Camera.createDefaultSnapshot(); + if (!_tempTarget) _tempTarget = Camera.createDefaultSnapshot(); + + Camera.copySnapshot(_tempSource, source_); + Camera.copySnapshot(_tempSource, sourcePartial); + Camera.copySnapshot(_tempTarget, target_); + Camera.copySnapshot(_tempTarget, targetPartial); + + const source = _tempSource; + const target = _tempTarget; + Camera.copySnapshot(out, target); // Rotate up diff --git a/src/mol-canvas3d/canvas3d.ts b/src/mol-canvas3d/canvas3d.ts index 22d909966..a9e325d74 100644 --- a/src/mol-canvas3d/canvas3d.ts +++ b/src/mol-canvas3d/canvas3d.ts @@ -53,6 +53,7 @@ import { RayHelper } from './helper/ray-helper'; import { produce } from '../mol-util/produce'; import { ShaderManager } from './helper/shader-manager'; import { toFixed } from '../mol-util/number'; +import type { CameraTransitionManager } from './camera/transition'; export const CameraFogParams = { intensity: PD.Numeric(15, { min: 1, max: 100, step: 1 }), @@ -371,7 +372,7 @@ interface Canvas3D { /** performs handleResize on the next animation frame */ requestResize(): void /** Focuses camera on scene's bounding sphere, centered and zoomed. */ - requestCameraReset(options?: { durationMs?: number, snapshot?: Camera.SnapshotProvider }): void + requestCameraReset(options?: { durationMs?: number, snapshot?: Camera.SnapshotProvider, keyframes?: CameraTransitionManager.TransitionKeyframes }): void readonly camera: Camera readonly boundingSphere: Readonly readonly boundingSphereVisible: Readonly @@ -500,6 +501,7 @@ namespace Canvas3D { let cameraResetRequested = false; let nextCameraResetDuration: number | undefined = void 0; let nextCameraResetSnapshot: Camera.SnapshotProvider | undefined = void 0; + let nextCameraResetKeyframes: CameraTransitionManager.TransitionKeyframes | undefined = void 0; let resizeRequested = false; // @@ -882,11 +884,12 @@ namespace Canvas3D { const focus = camera.getFocus(center, radius); const next = typeof nextCameraResetSnapshot === 'function' ? nextCameraResetSnapshot(scene, camera) : nextCameraResetSnapshot; const snapshot = next ? { ...focus, ...next } : focus; - camera.setState({ ...snapshot, radiusMax: getSceneRadius() }, duration); + camera.setState({ ...snapshot, radiusMax: getSceneRadius() }, duration, { keyframes: nextCameraResetKeyframes }); } nextCameraResetDuration = void 0; nextCameraResetSnapshot = void 0; + nextCameraResetKeyframes = void 0; cameraResetRequested = false; } @@ -1253,6 +1256,7 @@ namespace Canvas3D { requestCameraReset: options => { nextCameraResetDuration = options?.durationMs; nextCameraResetSnapshot = options?.snapshot; + nextCameraResetKeyframes = options?.keyframes; cameraResetRequested = true; }, camera, diff --git a/src/mol-plugin-state/manager/camera.ts b/src/mol-plugin-state/manager/camera.ts index c2b9f0e7e..7740a6d63 100644 --- a/src/mol-plugin-state/manager/camera.ts +++ b/src/mol-plugin-state/manager/camera.ts @@ -8,6 +8,7 @@ */ import { Camera } from '../../mol-canvas3d/camera'; +import { CameraTransitionManager } from '../../mol-canvas3d/camera/transition'; import { GraphicsRenderObject } from '../../mol-gl/render-object'; import { Sphere3D } from '../../mol-math/geometry'; import { BoundaryHelper } from '../../mol-math/geometry/boundary-helper'; @@ -32,7 +33,11 @@ const DefaultCameraFocusOptions = { }; export type CameraFocusOptions = typeof DefaultCameraFocusOptions -export type CameraFocusLociOptions = CameraFocusOptions & { optimizeDirection?: boolean, optimizeDirectionUp?: Vec3 } +export type CameraFocusLociOptions = CameraFocusOptions & { + optimizeDirection?: boolean, + optimizeDirectionUp?: Vec3, + zoomOut?: boolean, +} export class CameraManager { private boundaryHelper = new BoundaryHelper('98'); @@ -101,12 +106,11 @@ export class CameraManager { const positions: { x: number[], y: number[], z: number[] } = { x: [], y: [], z: [] }; const t = Vec3(); - const { extraRadius, minRadius, durationMs } = { ...DefaultCameraFocusOptions, ...options }; + const { extraRadius, minRadius } = { ...DefaultCameraFocusOptions, ...options }; const radius = Math.max(sphere.radius + extraRadius, minRadius); if (radius <= 1e-3) { - this.focusSphere(sphere, options); - return; + return this.getFocusSphereSnapshot(sphere, options); } const entityType = StructureProperties.entity.type; @@ -125,8 +129,7 @@ export class CameraManager { } if (positions.x.length === 0) { - this.focusSphere(sphere, options); - return; + return this.getFocusSphereSnapshot(sphere, options); } const direction = leastObstructedDirection(positions, { @@ -135,31 +138,59 @@ export class CameraManager { sigma: sphere.radius, }); if (!direction) { - this.focusSphere(sphere, options); - return; + return this.getFocusSphereSnapshot(sphere, options); } Vec3.negate(direction, direction); - const snapshot = canvas3d.camera.getInvariantFocus(sphere.center, radius, options?.up ?? Vec3.unitY, direction); - canvas3d.requestCameraReset({ durationMs, snapshot }); + return canvas3d.camera.getInvariantFocus(sphere.center, radius, options?.up ?? Vec3.unitY, direction); } private focusLociBase(loci: Loci | Loci[], options?: Partial) { const sphere = this.getFocusSphere(loci); if (sphere) { - this.focusSphere(sphere, options); + return this.getFocusSphereSnapshot(sphere, options); } } focusLoci(loci: Loci | Loci[], options?: Partial) { + if (!this.plugin.canvas3d) return; + + let snapshot: Partial | undefined; if (options?.optimizeDirection) { - this.focusLociOptimized(loci, { + snapshot = this.focusLociOptimized(loci, { ...options, up: options.optimizeDirectionUp, }); } else { - this.focusLociBase(loci, options); + snapshot = this.focusLociBase(loci, options); } + + if (!snapshot) return; + + const durationMs = options?.durationMs ?? DefaultCameraFocusOptions.durationMs; + + if (options?.zoomOut) { + const sphere = this.plugin.canvas3d.boundingSphere; + const mid = this.getFocusSphereSnapshot(sphere, options) as Camera.Snapshot; + const current = this.plugin.canvas3d?.camera.getSnapshot()!; + + const distA = Vec3.distance(current.position, mid.position); + const distB = Vec3.distance(mid.position, snapshot.position!); + + const t = distA / (distA + distB); + const timeFactor = 1 + 3 * Math.min(t, 0.5); + + this.plugin.canvas3d?.requestCameraReset({ + snapshot, + durationMs: timeFactor * durationMs, + keyframes: [ + { t, snapshot: mid }, + ] + }); + return; + } + + this.plugin.canvas3d.requestCameraReset({ snapshot, durationMs }); } focusSpheres(xs: ReadonlyArray, sphere: (t: T) => Sphere3D | undefined, options?: Partial & { principalAxes?: PrincipalAxes, positionToFlip?: Vec3 }) { @@ -184,21 +215,29 @@ export class CameraManager { this.focusSphere(this.boundaryHelper.getSphere(), options); } + private getFocusSphereSnapshot(sphere: Sphere3D, options?: Partial & { principalAxes?: PrincipalAxes, positionToFlip?: Vec3 }) { + const { canvas3d } = this.plugin; + if (!canvas3d) return; + + const { extraRadius, minRadius } = { ...DefaultCameraFocusOptions, ...options }; + const radius = Math.max(sphere.radius + extraRadius, minRadius); + + if (options?.principalAxes) { + return pcaFocus(this.plugin, radius, options as { principalAxes: PrincipalAxes, positionToFlip?: Vec3 }); + } else { + return canvas3d.camera.getFocus(sphere.center, radius); + } + } + focusSphere(sphere: Sphere3D, options?: Partial & { principalAxes?: PrincipalAxes, positionToFlip?: Vec3 }) { const { canvas3d } = this.plugin; if (!canvas3d) return; - const { extraRadius, minRadius, durationMs } = { ...DefaultCameraFocusOptions, ...options }; - const radius = Math.max(sphere.radius + extraRadius, minRadius); + const snapshot = this.getFocusSphereSnapshot(sphere, options); + if (!snapshot) return; - if (options?.principalAxes) { - const snapshot = pcaFocus(this.plugin, radius, options as { principalAxes: PrincipalAxes, positionToFlip?: Vec3 }); - this.plugin.canvas3d?.requestCameraReset({ durationMs, snapshot }); - } else { - const snapshot = canvas3d.camera.getFocus(sphere.center, radius); - canvas3d.requestCameraReset({ durationMs, snapshot }); - } - } + canvas3d.requestCameraReset({ durationMs: options?.durationMs ?? DefaultCameraFocusOptions.durationMs, snapshot }); + } /** Focus on a set of plugin state object cells (if `options.targets` is non-empty) or on the whole scene (if `options.targets` is empty). */ focusObject(options: PluginState.SnapshotFocusInfo & { minRadius?: number, durationMs?: number }) {