diff --git a/CHANGELOG.md b/CHANGELOG.md index 01ab7f59764b54b1f1b5c7fbed8736c3562e68dd..3f5d08389e1573ecdc83f6612e6f5776dff80d41 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/src/mol-math/linear-algebra/3d/mat3.ts b/src/mol-math/linear-algebra/3d/mat3.ts index 1505ea03574e02bf4c06aed1c95c807b06fe496f..59899d3e3db474ff835951ebd41de0eeed9c3238 100644 --- a/src/mol-math/linear-algebra/3d/mat3.ts +++ b/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 }; \ No newline at end of file diff --git a/src/mol-plugin-state/manager/camera.ts b/src/mol-plugin-state/manager/camera.ts index 60dbc65c247b6080f5b3baa1801ea83d64474f2e..12592da0794fb3f412cd5521d9ea33bd315f25e8 100644 --- a/src/mol-plugin-state/manager/camera.ts +++ b/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 }); diff --git a/src/mol-plugin-state/manager/focus-camera/orient-axes.ts b/src/mol-plugin-state/manager/focus-camera/orient-axes.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f6cd25ddc776f0b2d39815b775e398dacb3f417 --- /dev/null +++ b/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 }; +} diff --git a/src/mol-plugin-ui/left-panel.tsx b/src/mol-plugin-ui/left-panel.tsx index 62db9be7c613c82d0d1e8974d3523437aca376cf..e32c708df51d5ae1c3b3f0f2fdd985bb373ed224 100644 --- a/src/mol-plugin-ui/left-panel.tsx +++ b/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(); } diff --git a/src/mol-plugin-ui/skin/base/components/viewport.scss b/src/mol-plugin-ui/skin/base/components/viewport.scss index 2a5f46d556eb13e94942a5305e20647fb992447c..16f8c00ec75da8f5991a743574cbf7e75ad5c418 100644 --- a/src/mol-plugin-ui/skin/base/components/viewport.scss +++ b/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; -} +} \ No newline at end of file diff --git a/src/mol-plugin-ui/viewport.tsx b/src/mol-plugin-ui/viewport.tsx index 0dff3983fb17f77158a2456548ddeb34f6e1dc19..4bca881977d732e89cb480750ac871e45888f77e 100644 --- a/src/mol-plugin-ui/viewport.tsx +++ b/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' /> diff --git a/src/mol-plugin-ui/viewport/simple-settings.tsx b/src/mol-plugin-ui/viewport/simple-settings.tsx index ddf7fcf54304c5503f428ec8b31c42e488a832e8..eefa6a519043d4eaa0138b5f04010faa2c751fd3 100644 --- a/src/mol-plugin-ui/viewport/simple-settings.tsx +++ b/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(); } diff --git a/src/mol-plugin/behavior/static/camera.ts b/src/mol-plugin/behavior/static/camera.ts index d0a0ec17e111bf145886a5ef0e5d1cb9924fcd68..5d6981f07335fd24fa6e53ff164b17d3d9846901 100644 --- a/src/mol-plugin/behavior/static/camera.ts +++ b/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); }); -} \ No newline at end of file +} + +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); + }); +} diff --git a/src/mol-plugin/commands.ts b/src/mol-plugin/commands.ts index e2921b6cee0819a265072e59d5d81c61a7155468..013d8c3e7aa904dd89a90836e3db586d70f97d1a 100644 --- a/src/mol-plugin/commands.ts +++ b/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) }>(),