Jelajahi Sumber

Added "Zoom All", "Orient Axes", "Reset Axes" buttons (#776)

* Added "Zoom All", "Orient Axes", "Reset Axes" buttons

* Addressed PR776 feedback
midlik 2 tahun lalu
induk
melakukan
033c613c89

+ 1 - 0
CHANGELOG.md

@@ -9,6 +9,7 @@ Note that since we don't clearly distinguish between a public and private interf
 - Avoid `renderMarkingDepth` for fully transparent renderables
 - Remove `camera.far` doubeling workaround
 - Add `ModifiersKeys.areNone` helper function
+- Add "Zoom All", "Orient Axes", "Reset Axes" buttons to the "Reset Camera" button
 
 ## [v3.33.0] - 2023-04-02
 

+ 8 - 0
src/mol-math/linear-algebra/3d/mat3.ts

@@ -454,6 +454,14 @@ namespace Mat3 {
     }
 
     export const Identity: ReadonlyMat3 = identity();
+
+    /** Return the Frobenius inner product of two matrices (= dot product of the flattened matrices).
+     * Can be used as a measure of similarity between two rotation matrices. */
+    export function innerProduct(a: Mat3, b: Mat3) {
+        return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
+            + a[3] * b[3] + a[4] * b[4] + a[5] * b[5]
+            + a[6] * b[6] + a[7] * b[7] + a[8] * b[8];
+    }
 }
 
 export { Mat3 };

+ 30 - 6
src/mol-plugin-state/manager/camera.ts

@@ -4,18 +4,22 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  * @author Ke Ma <mark.ma@rcsb.org>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
-import { Sphere3D } from '../../mol-math/geometry';
-import { PluginContext } from '../../mol-plugin/context';
-import { PrincipalAxes } from '../../mol-math/linear-algebra/matrix/principal-axes';
 import { Camera } from '../../mol-canvas3d/camera';
-import { Loci } from '../../mol-model/loci';
-import { BoundaryHelper } from '../../mol-math/geometry/boundary-helper';
 import { GraphicsRenderObject } from '../../mol-gl/render-object';
-import { StructureElement } from '../../mol-model/structure';
+import { Sphere3D } from '../../mol-math/geometry';
+import { BoundaryHelper } from '../../mol-math/geometry/boundary-helper';
+import { Mat3 } from '../../mol-math/linear-algebra';
 import { Vec3 } from '../../mol-math/linear-algebra/3d/vec3';
+import { PrincipalAxes } from '../../mol-math/linear-algebra/matrix/principal-axes';
+import { Loci } from '../../mol-model/loci';
+import { Structure, StructureElement } from '../../mol-model/structure';
+import { PluginContext } from '../../mol-plugin/context';
+import { PluginStateObject } from '../objects';
 import { pcaFocus } from './focus-camera/focus-first-residue';
+import { changeCameraRotation, structureLayingTransform } from './focus-camera/orient-axes';
 
 // TODO: make this customizable somewhere?
 const DefaultCameraFocusOptions = {
@@ -125,6 +129,26 @@ export class CameraManager {
         }
     }
 
+    /** Align PCA axes of `structures` (default: all loaded structures) to the screen axes. */
+    orientAxes(structures?: Structure[], durationMs?: number) {
+        if (!this.plugin.canvas3d) return;
+        if (!structures) {
+            const structCells = this.plugin.state.data.selectQ(q => q.ofType(PluginStateObject.Molecule.Structure));
+            const rootStructCells = structCells.filter(cell => cell.obj && !cell.transform.transformer.definition.isDecorator && !cell.obj.data.parent);
+            structures = rootStructCells.map(cell => cell.obj?.data).filter(struct => !!struct) as Structure[];
+        }
+        const { rotation } = structureLayingTransform(structures);
+        const newSnapshot = changeCameraRotation(this.plugin.canvas3d.camera.getSnapshot(), rotation);
+        this.setSnapshot(newSnapshot, durationMs);
+    }
+
+    /** Align Cartesian axes to the screen axes (X right, Y up). */
+    resetAxes(durationMs?: number) {
+        if (!this.plugin.canvas3d) return;
+        const newSnapshot = changeCameraRotation(this.plugin.canvas3d.camera.getSnapshot(), Mat3.Identity);
+        this.setSnapshot(newSnapshot, durationMs);
+    }
+
     setSnapshot(snapshot: Partial<Camera.Snapshot>, durationMs?: number) {
         // TODO: setState and requestCameraReset are very similar now: unify them?
         this.plugin.canvas3d?.requestCameraReset({ snapshot, durationMs });

+ 218 - 0
src/mol-plugin-state/manager/focus-camera/orient-axes.ts

@@ -0,0 +1,218 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { Camera } from '../../../mol-canvas3d/camera';
+import { Mat3, Vec3 } from '../../../mol-math/linear-algebra';
+import { PrincipalAxes } from '../../../mol-math/linear-algebra/matrix/principal-axes';
+import { Structure, StructureElement, StructureProperties } from '../../../mol-model/structure';
+
+
+/** Minimum number of atoms necessary for running PCA.
+ * If enough atoms cannot be selected, XYZ axes will be used instead of PCA axes. */
+const MIN_ATOMS_FOR_PCA = 3;
+
+/** Rotation matrices for the basic rotations by 90 degrees */
+export const ROTATION_MATRICES = {
+    // The order of elements in the matrices in column-wise (F-style)
+    identity: Mat3.create(1, 0, 0, 0, 1, 0, 0, 0, 1),
+    rotX90: Mat3.create(1, 0, 0, 0, 0, 1, 0, -1, 0),
+    rotY90: Mat3.create(0, 0, -1, 0, 1, 0, 1, 0, 0),
+    rotZ90: Mat3.create(0, 1, 0, -1, 0, 0, 0, 0, 1),
+    rotX270: Mat3.create(1, 0, 0, 0, 0, -1, 0, 1, 0),
+    rotY270: Mat3.create(0, 0, 1, 0, 1, 0, -1, 0, 0),
+    rotZ270: Mat3.create(0, -1, 0, 1, 0, 0, 0, 0, 1),
+    rotX180: Mat3.create(1, 0, 0, 0, -1, 0, 0, 0, -1),
+    rotY180: Mat3.create(-1, 0, 0, 0, 1, 0, 0, 0, -1),
+    rotZ180: Mat3.create(-1, 0, 0, 0, -1, 0, 0, 0, 1),
+};
+
+
+/** Return transformation which will align the PCA axes of an atomic structure
+ * (or multiple structures) to the Cartesian axes x, y, z
+ * (transformed = rotation * (coords - origin)).
+ *
+ * There are always 4 equally good rotations to do this (4 flips).
+ * If `referenceRotation` is provided, select the one nearest to `referenceRotation`.
+ * Otherwise use arbitrary rules to ensure the orientation after transform does not depend on the original orientation.
+ */
+export function structureLayingTransform(structures: Structure[], referenceRotation?: Mat3): { rotation: Mat3, origin: Vec3 } {
+    const coords = smartSelectCoords(structures, MIN_ATOMS_FOR_PCA);
+    return layingTransform(coords, referenceRotation);
+}
+
+/** Return transformation which will align the PCA axes of a sequence
+ * of points to the Cartesian axes x, y, z
+ * (transformed = rotation * (coords - origin)).
+ *
+ * `coords` is a flattened array of 3D coordinates (i.e. the first 3 values are x, y, and z of the first point etc.).
+ *
+ * There are always 4 equally good rotations to do this (4 flips).
+ * If `referenceRotation` is provided, select the one nearest to `referenceRotation`.
+ * Otherwise use arbitrary rules to ensure the orientation after transform does not depend on the original orientation.
+ */
+export function layingTransform(coords: number[], referenceRotation?: Mat3): { rotation: Mat3, origin: Vec3 } {
+    if (coords.length === 0) {
+        console.warn('Skipping PCA, no atoms');
+        return { rotation: ROTATION_MATRICES.identity, origin: Vec3.zero() };
+    }
+    const axes = PrincipalAxes.calculateMomentsAxes(coords);
+    const normAxes = PrincipalAxes.calculateNormalizedAxes(axes);
+    const R = mat3FromRows(normAxes.dirA, normAxes.dirB, normAxes.dirC);
+    avoidMirrorRotation(R); // The SVD implementation seems to always provide proper rotation, but just to be sure
+    const flip = referenceRotation ? minimalFlip(R, referenceRotation) : canonicalFlip(coords, R, axes.origin);
+    Mat3.mul(R, flip, R);
+    return { rotation: R, origin: normAxes.origin };
+}
+
+/** Try these selection strategies until having at least `minAtoms` atoms:
+ * 1. only trace atoms (e.g. C-alpha and O3')
+ * 2. all non-hydrogen atoms with exception of water (HOH)
+ * 3. all atoms
+ * Return the coordinates in a flattened array (in triples).
+ * If the total number of atoms is less than `minAtoms`, return only those. */
+function smartSelectCoords(structures: Structure[], minAtoms: number): number[] {
+    let coords: number[];
+    coords = selectCoords(structures, { onlyTrace: true });
+    if (coords.length >= 3 * minAtoms) return coords;
+
+    coords = selectCoords(structures, { skipHydrogens: true, skipWater: true });
+    if (coords.length >= 3 * minAtoms) return coords;
+
+    coords = selectCoords(structures, {});
+    return coords;
+}
+
+/** Select coordinates of atoms in `structures` as a flattened array (in triples).
+ * If `onlyTrace`, include only trace atoms (CA, O3');
+ * if `skipHydrogens`, skip all hydrogen atoms;
+ * if `skipWater`, skip all water residues. */
+function selectCoords(structures: Structure[], options: { onlyTrace?: boolean, skipHydrogens?: boolean, skipWater?: boolean }): number[] {
+    const { onlyTrace, skipHydrogens, skipWater } = options;
+    const { x, y, z, type_symbol, label_comp_id } = StructureProperties.atom;
+    const coords: number[] = [];
+    for (const struct of structures) {
+        const loc = StructureElement.Location.create(struct);
+        for (const unit of struct.units) {
+            loc.unit = unit;
+            const elements = onlyTrace ? unit.polymerElements : unit.elements;
+            for (let i = 0; i < elements.length; i++) {
+                loc.element = elements[i];
+                if (skipHydrogens && type_symbol(loc) === 'H') continue;
+                if (skipWater && label_comp_id(loc) === 'HOH') continue;
+                coords.push(x(loc), y(loc), z(loc));
+            }
+        }
+    }
+    return coords;
+}
+
+/** Return a flip around XYZ axes which minimizes the difference between flip*rotation and referenceRotation. */
+function minimalFlip(rotation: Mat3, referenceRotation: Mat3): Mat3 {
+    let bestFlip = ROTATION_MATRICES.identity;
+    let bestScore = 0; // there will always be at least one positive score
+    const aux = Mat3();
+    for (const flip of [ROTATION_MATRICES.identity, ROTATION_MATRICES.rotX180, ROTATION_MATRICES.rotY180, ROTATION_MATRICES.rotZ180]) {
+        const score = Mat3.innerProduct(Mat3.mul(aux, flip, rotation), referenceRotation);
+        if (score > bestScore) {
+            bestFlip = flip;
+            bestScore = score;
+        }
+    }
+    return bestFlip;
+}
+
+/** Return a rotation matrix (flip) that should be applied to `coords` (after being rotated by `rotation`)
+ * to ensure a deterministic "canonical" rotation.
+ * There are 4 flips to choose from (one identity and three 180-degree rotations around the X, Y, and Z axes).
+ * One of these 4 possible results is selected so that:
+ *   1) starting and ending coordinates tend to be more in front (z > 0), middle more behind (z < 0).
+ *   2) starting coordinates tend to be more left-top (x < y), ending more right-bottom (x > y).
+ * These rules are arbitrary, but try to avoid ties for at least some basic symmetries.
+ * Provided `origin` parameter MUST be the mean of the coordinates, otherwise it will not work!
+ */
+function canonicalFlip(coords: number[], rotation: Mat3, origin: Vec3): Mat3 {
+    const pcaX = Vec3.create(Mat3.getValue(rotation, 0, 0), Mat3.getValue(rotation, 0, 1), Mat3.getValue(rotation, 0, 2));
+    const pcaY = Vec3.create(Mat3.getValue(rotation, 1, 0), Mat3.getValue(rotation, 1, 1), Mat3.getValue(rotation, 1, 2));
+    const pcaZ = Vec3.create(Mat3.getValue(rotation, 2, 0), Mat3.getValue(rotation, 2, 1), Mat3.getValue(rotation, 2, 2));
+    const n = Math.floor(coords.length / 3);
+    const v = Vec3();
+    let xCum = 0;
+    let yCum = 0;
+    let zCum = 0;
+    for (let i = 0; i < n; i++) {
+        Vec3.fromArray(v, coords, 3 * i);
+        Vec3.sub(v, v, origin);
+        xCum += i * Vec3.dot(v, pcaX);
+        yCum += i * Vec3.dot(v, pcaY);
+        zCum += veeSlope(i, n) * Vec3.dot(v, pcaZ);
+        // Thanks to subtracting `origin` from `coords` the slope functions `i` and `veeSlope(i, n)`
+        // don't have to have zero sum (can be shifted up or down):
+        //     sum{(slope[i]+shift)*(coords[i]-origin).PCA} =
+        //     = sum{slope[i]*coords[i].PCA - slope[i]*origin.PCA + shift*coords[i].PCA - shift*origin.PCA} =
+        //     = sum{slope[i]*(coords[i]-origin).PCA} + shift*sum{coords[i]-origin}.PCA =
+        //     = sum{slope[i]*(coords[i]-origin).PCA}
+    }
+    const wrongFrontBack = zCum < 0;
+    const wrongLeftTopRightBottom = wrongFrontBack ? xCum + yCum < 0 : xCum - yCum < 0;
+    if (wrongLeftTopRightBottom && wrongFrontBack) {
+        return ROTATION_MATRICES.rotY180; // flip around Y = around X then Z
+    } else if (wrongFrontBack) {
+        return ROTATION_MATRICES.rotX180; // flip around X
+    } else if (wrongLeftTopRightBottom) {
+        return ROTATION_MATRICES.rotZ180; // flip around Z
+    } else {
+        return ROTATION_MATRICES.identity; // do not flip
+    }
+}
+
+/** Auxiliary function defined for i in [0, n), linearly decreasing from 0 to n/2
+ * and then increasing back from n/2 to n, resembling letter V. */
+function veeSlope(i: number, n: number) {
+    const mid = Math.floor(n / 2);
+    if (i < mid) {
+        if (n % 2) return mid - i;
+        else return mid - i - 1;
+    } else {
+        return i - mid;
+    }
+}
+
+function mat3FromRows(row0: Vec3, row1: Vec3, row2: Vec3): Mat3 {
+    const m = Mat3();
+    Mat3.setValue(m, 0, 0, row0[0]);
+    Mat3.setValue(m, 0, 1, row0[1]);
+    Mat3.setValue(m, 0, 2, row0[2]);
+    Mat3.setValue(m, 1, 0, row1[0]);
+    Mat3.setValue(m, 1, 1, row1[1]);
+    Mat3.setValue(m, 1, 2, row1[2]);
+    Mat3.setValue(m, 2, 0, row2[0]);
+    Mat3.setValue(m, 2, 1, row2[1]);
+    Mat3.setValue(m, 2, 2, row2[2]);
+    return m;
+}
+
+/** Check if a rotation matrix includes mirroring and invert Z axis in such case, to ensure a proper rotation (in-place). */
+function avoidMirrorRotation(rot: Mat3) {
+    if (Mat3.determinant(rot) < 0) {
+        Mat3.setValue(rot, 2, 0, -Mat3.getValue(rot, 2, 0));
+        Mat3.setValue(rot, 2, 1, -Mat3.getValue(rot, 2, 1));
+        Mat3.setValue(rot, 2, 2, -Mat3.getValue(rot, 2, 2));
+    }
+}
+
+/** Return a new camera snapshot with the same target and camera distance from the target as `old`
+ * but with diferent orientation.
+ * The actual rotation applied to the camera is the inverse of `rotation`,
+ * which creates the same effect as if `rotation` were applied to the whole scene without moving the camera.
+ * The rotation is relative to the default camera orientation (not to the current orientation). */
+export function changeCameraRotation(old: Camera.Snapshot, rotation: Mat3): Camera.Snapshot {
+    const cameraRotation = Mat3.invert(Mat3(), rotation);
+    const dist = Vec3.distance(old.position, old.target);
+    const relPosition = Vec3.transformMat3(Vec3(), Vec3.create(0, 0, dist), cameraRotation);
+    const newUp = Vec3.transformMat3(Vec3(), Vec3.create(0, 1, 0), cameraRotation);
+    const newPosition = Vec3.add(Vec3(), old.target, relPosition);
+    return { ...old, position: newPosition, up: newUp };
+}

+ 3 - 2
src/mol-plugin-ui/left-panel.tsx

@@ -6,6 +6,7 @@
  */
 
 import * as React from 'react';
+import { throttleTime } from 'rxjs';
 import { Canvas3DParams } from '../mol-canvas3d/canvas3d';
 import { PluginCommands } from '../mol-plugin/commands';
 import { LeftPanelTabName } from '../mol-plugin/layout';
@@ -13,12 +14,12 @@ import { StateTransform } from '../mol-state';
 import { ParamDefinition as PD } from '../mol-util/param-definition';
 import { PluginUIComponent } from './base';
 import { IconButton, SectionHeader } from './controls/common';
+import { AccountTreeOutlinedSvg, DeleteOutlinedSvg, HelpOutlineSvg, HomeOutlinedSvg, SaveOutlinedSvg, TuneSvg } from './controls/icons';
 import { ParameterControls } from './controls/parameters';
 import { StateObjectActions } from './state/actions';
 import { RemoteStateSnapshots, StateSnapshots } from './state/snapshots';
 import { StateTree } from './state/tree';
 import { HelpContent } from './viewport/help';
-import { HomeOutlinedSvg, AccountTreeOutlinedSvg, TuneSvg, HelpOutlineSvg, SaveOutlinedSvg, DeleteOutlinedSvg } from './controls/icons';
 
 export class CustomImportControls extends PluginUIComponent<{ initiallyCollapsed?: boolean }> {
     componentDidMount() {
@@ -142,7 +143,7 @@ class FullSettings extends PluginUIComponent {
         this.subscribe(this.plugin.layout.events.updated, () => this.forceUpdate());
 
         if (this.plugin.canvas3d) {
-            this.subscribe(this.plugin.canvas3d.camera.stateChanged, state => {
+            this.subscribe(this.plugin.canvas3d.camera.stateChanged.pipe(throttleTime(500, undefined, { leading: true, trailing: true })), state => {
                 if (state.radiusMax !== undefined || state.radius !== undefined) {
                     this.forceUpdate();
                 }

+ 31 - 4
src/mol-plugin-ui/skin/base/components/viewport.scss

@@ -7,7 +7,7 @@
     background: $default-background;
 
     .msp-btn-link {
-        background: rgba(0,0,0,0.2);
+        background: rgba(0, 0, 0, 0.2);
     }
 
 }
@@ -25,14 +25,14 @@
     bottom: 0;
     -webkit-user-select: none;
     user-select: none;
-    -webkit-tap-highlight-color: rgba(0,0,0,0);
+    -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
     -webkit-touch-callout: none;
     touch-action: manipulation;
 
     > canvas {
         background-color: $default-background;
         background-image: linear-gradient(45deg, lightgrey 25%, transparent 25%, transparent 75%, lightgrey 75%, lightgrey),
-        linear-gradient(45deg, lightgrey 25%, transparent 25%, transparent 75%, lightgrey 75%, lightgrey);
+            linear-gradient(45deg, lightgrey 25%, transparent 25%, transparent 75%, lightgrey 75%, lightgrey);
         background-size: 60px 60px;
         background-position: 0 0, 30px 30px;
     }
@@ -82,6 +82,33 @@
     height: 100%;
 }
 
+.msp-hover-box-wrapper {
+    position: relative;
+
+    .msp-hover-box-body {
+        visibility: hidden;
+        position: absolute;
+        right: $row-height + 4px;
+        top: 0;
+        width: 100px;
+        background-color: $default-background;
+    }
+
+    .msp-hover-box-spacer {
+        visibility: hidden;
+        position: absolute;
+        right: $row-height;
+        top: 0;
+        width: 4px;
+        height: $row-height;
+    }
+
+    &:hover .msp-hover-box-body,
+    &:hover .msp-hover-box-spacer {
+        visibility: visible;
+    }
+}
+
 .msp-viewport-controls-panel {
     width: 290px;
     top: 0;
@@ -134,4 +161,4 @@
     font-size: 85%;
     display: inline-block;
     color: $highlight-info-additional-font-color;
-}
+}

+ 44 - 8
src/mol-plugin-ui/viewport.tsx

@@ -3,14 +3,16 @@
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  * @author David Sehnal <david.sehnal@gmail.com>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
 import * as React from 'react';
+import { throttleTime } from 'rxjs';
 import { PluginCommands } from '../mol-plugin/commands';
 import { PluginConfig } from '../mol-plugin/config';
 import { ParamDefinition as PD } from '../mol-util/param-definition';
 import { PluginUIComponent } from './base';
-import { ControlGroup, IconButton } from './controls/common';
+import { Button, ControlGroup, IconButton } from './controls/common';
 import { AutorenewSvg, BuildOutlinedSvg, CameraOutlinedSvg, CloseSvg, FullscreenSvg, TuneSvg } from './controls/icons';
 import { ToggleSelectionModeButton } from './structure/selection';
 import { ViewportCanvas } from './viewport/canvas';
@@ -19,19 +21,23 @@ import { SimpleSettingsControl } from './viewport/simple-settings';
 
 interface ViewportControlsState {
     isSettingsExpanded: boolean,
-    isScreenshotExpanded: boolean
+    isScreenshotExpanded: boolean,
+    isCameraResetEnabled: boolean
 }
 
 interface ViewportControlsProps {
 }
 
 export class ViewportControls extends PluginUIComponent<ViewportControlsProps, ViewportControlsState> {
-    private allCollapsedState: ViewportControlsState = {
+    private allCollapsedState = {
         isSettingsExpanded: false,
-        isScreenshotExpanded: false
+        isScreenshotExpanded: false,
     };
 
-    state = { ...this.allCollapsedState } as ViewportControlsState;
+    state: ViewportControlsState = {
+        ...this.allCollapsedState,
+        isCameraResetEnabled: true,
+    };
 
     resetCamera = () => {
         PluginCommands.Camera.Reset(this.plugin, {});
@@ -39,7 +45,7 @@ export class ViewportControls extends PluginUIComponent<ViewportControlsProps, V
 
     private toggle(panel: keyof ViewportControlsState) {
         return (e?: React.MouseEvent<HTMLButtonElement>) => {
-            this.setState({ ...this.allCollapsedState, [panel]: !this.state[panel] });
+            this.setState(old => ({ ...old, ...this.allCollapsedState, [panel]: !this.state[panel] }));
             e?.currentTarget.blur();
         };
     }
@@ -67,9 +73,19 @@ export class ViewportControls extends PluginUIComponent<ViewportControlsProps, V
         this.plugin.helpers.viewportScreenshot?.download();
     };
 
+    enableCameraReset = (enable: boolean) => {
+        this.setState(old => ({ ...old, isCameraResetEnabled: enable }));
+    };
+
     componentDidMount() {
         this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => this.forceUpdate());
         this.subscribe(this.plugin.layout.events.updated, () => this.forceUpdate());
+        if (this.plugin.canvas3d) {
+            this.subscribe(
+                this.plugin.canvas3d.camera.stateChanged.pipe(throttleTime(500, undefined, { leading: true, trailing: true })),
+                snapshot => this.enableCameraReset(snapshot.radius !== 0 && snapshot.radiusMax !== 0)
+            );
+        }
     }
 
     icon(icon: React.FC, onClick: (e: React.MouseEvent<HTMLButtonElement>) => void, title: string, isOn = true) {
@@ -79,9 +95,29 @@ export class ViewportControls extends PluginUIComponent<ViewportControlsProps, V
     render() {
         return <div className={'msp-viewport-controls'}>
             <div className='msp-viewport-controls-buttons'>
-                <div>
+                <div className='msp-hover-box-wrapper'>
                     <div className='msp-semi-transparent-background' />
-                    {this.icon(AutorenewSvg, this.resetCamera, 'Reset Camera')}
+                    {this.icon(AutorenewSvg, this.resetCamera, 'Reset Zoom')}
+                    <div className='msp-hover-box-body'>
+                        <div className='msp-flex-column'>
+                            <div className='msp-flex-row'>
+                                <Button onClick={() => this.resetCamera()} disabled={!this.state.isCameraResetEnabled} title='Set camera zoom to fit the visible scene into view'>
+                                    Reset Zoom
+                                </Button>
+                            </div>
+                            <div className='msp-flex-row'>
+                                <Button onClick={() => PluginCommands.Camera.OrientAxes(this.plugin)} disabled={!this.state.isCameraResetEnabled} title='Align principal component axes of the loaded structures to the screen axes (“lay flat”)'>
+                                    Orient Axes
+                                </Button>
+                            </div>
+                            <div className='msp-flex-row'>
+                                <Button onClick={() => PluginCommands.Camera.ResetAxes(this.plugin)} disabled={!this.state.isCameraResetEnabled} title='Align Cartesian axes to the screen axes'>
+                                    Reset Axes
+                                </Button>
+                            </div>
+                        </div>
+                    </div>
+                    <div className='msp-hover-box-spacer'></div>
                 </div>
                 <div>
                     <div className='msp-semi-transparent-background' />

+ 2 - 1
src/mol-plugin-ui/viewport/simple-settings.tsx

@@ -6,6 +6,7 @@
  */
 
 import { produce } from 'immer';
+import { throttleTime } from 'rxjs';
 import { Canvas3DParams, Canvas3DProps } from '../../mol-canvas3d/canvas3d';
 import { PluginCommands } from '../../mol-plugin/commands';
 import { PluginConfig } from '../../mol-plugin/config';
@@ -26,7 +27,7 @@ export class SimpleSettingsControl extends PluginUIComponent {
 
         this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => this.forceUpdate());
 
-        this.subscribe(this.plugin.canvas3d!.camera.stateChanged, state => {
+        this.subscribe(this.plugin.canvas3d!.camera.stateChanged.pipe(throttleTime(500, undefined, { leading: true, trailing: true })), state => {
             if (state.radiusMax !== undefined || state.radius !== undefined) {
                 this.forceUpdate();
             }

+ 17 - 2
src/mol-plugin/behavior/static/camera.ts

@@ -1,7 +1,8 @@
 /**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 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 { PluginContext } from '../../../mol-plugin/context';
@@ -11,6 +12,8 @@ export function registerDefault(ctx: PluginContext) {
     Reset(ctx);
     Focus(ctx);
     SetSnapshot(ctx);
+    OrientAxes(ctx);
+    ResetAxes(ctx);
 }
 
 export function Reset(ctx: PluginContext) {
@@ -30,4 +33,16 @@ export function Focus(ctx: PluginContext) {
         ctx.managers.camera.focusSphere({ center, radius }, { durationMs });
         ctx.events.canvas3d.settingsUpdated.next(void 0);
     });
-}
+}
+
+export function OrientAxes(ctx: PluginContext) {
+    PluginCommands.Camera.OrientAxes.subscribe(ctx, ({ structures, durationMs }) => {
+        ctx.managers.camera.orientAxes(structures, durationMs);
+    });
+}
+
+export function ResetAxes(ctx: PluginContext) {
+    PluginCommands.Camera.ResetAxes.subscribe(ctx, ({ durationMs }) => {
+        ctx.managers.camera.resetAxes(durationMs);
+    });
+}

+ 6 - 3
src/mol-plugin/commands.ts

@@ -1,8 +1,9 @@
 /**
- * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 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 { Camera } from '../mol-canvas3d/camera';
@@ -10,7 +11,7 @@ import { PluginCommand } from './command';
 import { StateTransform, State, StateAction } from '../mol-state';
 import { Canvas3DProps } from '../mol-canvas3d/canvas3d';
 import { PluginLayoutStateProps } from './layout';
-import { StructureElement } from '../mol-model/structure';
+import { Structure, StructureElement } from '../mol-model/structure';
 import { PluginState } from './state';
 import { PluginToast } from './util/toast';
 import { Vec3 } from '../mol-math/linear-algebra';
@@ -62,7 +63,9 @@ export const PluginCommands = {
     Camera: {
         Reset: PluginCommand<{ durationMs?: number, snapshot?: Partial<Camera.Snapshot> }>(),
         SetSnapshot: PluginCommand<{ snapshot: Partial<Camera.Snapshot>, durationMs?: number }>(),
-        Focus: PluginCommand<{ center: Vec3, radius: number, durationMs?: number }>()
+        Focus: PluginCommand<{ center: Vec3, radius: number, durationMs?: number }>(),
+        OrientAxes: PluginCommand<{ structures?: Structure[], durationMs?: number }>(),
+        ResetAxes: PluginCommand<{ durationMs?: number }>(),
     },
     Canvas3D: {
         SetSettings: PluginCommand<{ settings: Partial<Canvas3DProps> | ((old: Canvas3DProps) => Partial<Canvas3DProps> | void) }>(),