camera focus "zoom out"

This commit is contained in:
dsehnal
2026-05-28 15:47:51 +02:00
parent 43ce4ab498
commit 206ee19138
6 changed files with 138 additions and 35 deletions

View File

@@ -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

View File

@@ -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 <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -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) {

View File

@@ -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 <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -138,8 +138,8 @@ export class Camera implements ICamera {
return changed;
}
setState(snapshot: Partial<Camera.Snapshot>, durationMs?: number) {
this.transition.apply(snapshot, durationMs);
setState(snapshot: Partial<Camera.Snapshot>, durationMs?: number, options?: { keyframes?: CameraTransitionManager.TransitionKeyframes }) {
this.transition.apply(snapshot, durationMs, undefined, options);
this.stateChanged.next(snapshot);
}

View File

@@ -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 <david.sehnal@gmail.com>
*/
@@ -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<Camera.Snapshot> { return this._source; }
get target(): Readonly<Camera.Snapshot> { return this._target; }
apply(to: Partial<Camera.Snapshot>, durationMs: number = 0, transition?: CameraTransitionManager.TransitionFunc) {
apply(
to: Partial<Camera.Snapshot>,
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<Camera.Snapshot> }[]
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<Camera.Snapshot> = source_;
let targetPartial: Partial<Camera.Snapshot> = 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

View File

@@ -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<Sphere3D>
readonly boundingSphereVisible: Readonly<Sphere3D>
@@ -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,

View File

@@ -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<CameraFocusOptions>) {
const sphere = this.getFocusSphere(loci);
if (sphere) {
this.focusSphere(sphere, options);
return this.getFocusSphereSnapshot(sphere, options);
}
}
focusLoci(loci: Loci | Loci[], options?: Partial<CameraFocusLociOptions>) {
if (!this.plugin.canvas3d) return;
let snapshot: Partial<Camera.Snapshot> | 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<T>(xs: ReadonlyArray<T>, sphere: (t: T) => Sphere3D | undefined, options?: Partial<CameraFocusOptions> & { principalAxes?: PrincipalAxes, positionToFlip?: Vec3 }) {
@@ -184,21 +215,29 @@ export class CameraManager {
this.focusSphere(this.boundaryHelper.getSphere(), options);
}
private getFocusSphereSnapshot(sphere: Sphere3D, options?: Partial<CameraFocusOptions> & { 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<CameraFocusOptions> & { 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 }) {