Browse Source

Merge pull request #271 from molstar/pick-atom

Picking improvements
Alexander Rose 3 years ago
parent
commit
07322819f0

+ 9 - 2
CHANGELOG.md

@@ -6,6 +6,14 @@ Note that since we don't clearly distinguish between a public and private interf
 
 ## [Unreleased]
 
+- Fix pickScale not taken into account in line/point shader
+- Add pixel-scale, pick-scale & pick-padding GET params to Viewer app
+- Fix selecting bonds not adding their atoms in selection manager
+- Add ``preferAtoms`` option to SelectLoci/HighlightLoci behaviors
+- Make the implicit atoms of bond visuals pickable
+    - Add ``preferAtomPixelPadding`` to Canvas3dInteractionHelper
+- Add points visual to Line representation
+- Add ``pickPadding`` config option (look around in case target pixel is empty)
 
 ## [v2.3.3] - 2021-10-01
 
@@ -15,11 +23,10 @@ Note that since we don't clearly distinguish between a public and private interf
 
 - Prefer WebGL1 on iOS devices until WebGL2 support has stabilized.
 
-
 ## [v2.3.1] - 2021-09-28
 
 - Add Charmm saccharide names
-- Treat missing occupancy column as occupany of 1
+- Treat missing occupancy column as occupancy of 1
 - Fix line shader not accounting for aspect ratio
 - [Breaking] Fix point repr & shader
     - Was unusable with ``wboit``

+ 7 - 1
src/apps/viewer/index.html

@@ -53,6 +53,9 @@
             var pdbProvider = getParam('pdb-provider', '[^&]+').trim().toLowerCase();
             var emdbProvider = getParam('emdb-provider', '[^&]+').trim().toLowerCase();
             var mapProvider = getParam('map-provider', '[^&]+').trim().toLowerCase();
+            var pixelScale = getParam('pixel-scale', '[^&]+').trim();
+            var pickScale = getParam('pick-scale', '[^&]+').trim();
+            var pickPadding = getParam('pick-padding', '[^&]+').trim();
             var viewer = new molstar.Viewer('app', {
                 layoutShowControls: !hideControls,
                 viewportShowExpand: false,
@@ -61,7 +64,10 @@
                 emdbProvider: emdbProvider || 'pdbe',
                 volumeStreamingServer: (mapProvider || 'pdbe') === 'rcsb'
                     ? 'https://maps.rcsb.org'
-                    : 'https://www.ebi.ac.uk/pdbe/densities'
+                    : 'https://www.ebi.ac.uk/pdbe/densities',
+                pixelScale: parseFloat(pixelScale) || 1,
+                pickScale: parseFloat(pickScale) || 0.25,
+                pickPadding: isNaN(parseFloat(pickPadding)) ? 1 : parseFloat(pickPadding),
             });
 
             var snapshotId = getParam('snapshot-id', '[^&]+').trim();

+ 8 - 4
src/apps/viewer/index.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2021 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>
@@ -71,9 +71,11 @@ const DefaultViewerOptions = {
     layoutShowLog: true,
     layoutShowLeftPanel: true,
     collapseLeftPanel: false,
-    disableAntialiasing: false,
-    pixelScale: 1,
-    enableWboit: true,
+    disableAntialiasing: PluginConfig.General.DisableAntialiasing.defaultValue,
+    pixelScale: PluginConfig.General.PixelScale.defaultValue,
+    pickScale: PluginConfig.General.PickScale.defaultValue,
+    pickPadding: PluginConfig.General.PickPadding.defaultValue,
+    enableWboit: PluginConfig.General.EnableWboit.defaultValue,
 
     viewportShowExpand: PluginConfig.Viewport.ShowExpand.defaultValue,
     viewportShowControls: PluginConfig.Viewport.ShowControls.defaultValue,
@@ -130,6 +132,8 @@ export class Viewer {
             config: [
                 [PluginConfig.General.DisableAntialiasing, o.disableAntialiasing],
                 [PluginConfig.General.PixelScale, o.pixelScale],
+                [PluginConfig.General.PickScale, o.pickScale],
+                [PluginConfig.General.PickPadding, o.pickPadding],
                 [PluginConfig.General.EnableWboit, o.enableWboit],
                 [PluginConfig.Viewport.ShowExpand, o.viewportShowExpand],
                 [PluginConfig.Viewport.ShowControls, o.viewportShowControls],

+ 25 - 3
src/mol-canvas3d/camera.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2021 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>
@@ -27,6 +27,10 @@ interface ICamera {
     readonly fogNear: number,
 }
 
+const tmpPos1 = Vec3();
+const tmpPos2 = Vec3();
+const tmpClip = Vec4();
+
 class Camera implements ICamera {
     readonly view: Mat4 = Mat4.identity();
     readonly projection: Mat4 = Mat4.identity();
@@ -155,14 +159,32 @@ class Camera implements ICamera {
         }
     }
 
+    /** Transform point into 2D window coordinates. */
     project(out: Vec4, point: Vec3) {
         return cameraProject(out, point, this.viewport, this.projectionView);
     }
 
-    unproject(out: Vec3, point: Vec3) {
+    /**
+     * Transform point from screen space to 3D coordinates.
+     * The point must have `x` and `y` set to 2D window coordinates
+     * and `z` between 0 (near) and 1 (far); the optional `w` is not used.
+     */
+    unproject(out: Vec3, point: Vec3 | Vec4) {
         return cameraUnproject(out, point, this.viewport, this.inverseProjectionView);
     }
 
+    /** World space pixel size at given `point` */
+    getPixelSize(point: Vec3) {
+        // project -> unproject of `point` does not exactly return the same
+        // to get a sufficiently accurate measure we unproject the original
+        // clip position in addition to the one shifted bey one pixel
+        this.project(tmpClip, point);
+        this.unproject(tmpPos1, tmpClip);
+        tmpClip[0] += 1;
+        this.unproject(tmpPos2, tmpClip);
+        return Vec3.distance(tmpPos1, tmpPos2);
+    }
+
     constructor(state?: Partial<Camera.Snapshot>, viewport = Viewport.create(0, 0, 128, 128), props: Partial<{ pixelScale: number }> = {}) {
         this.viewport = viewport;
         this.pixelScale = props.pixelScale || 1;
@@ -178,7 +200,7 @@ namespace Camera {
     /**
      * Sets an offseted view in a larger frustum. This is useful for
      * - multi-window or multi-monitor/multi-machine setups
-     * - jittering the camera position for
+     * - jittering the camera position for sampling
      */
     export interface ViewOffset {
         enabled: boolean,

+ 16 - 18
src/mol-canvas3d/camera/util.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -55,14 +55,11 @@ namespace Viewport {
 
 //
 
-const NEAR_RANGE = 0;
-const FAR_RANGE = 1;
-
 const tmpVec4 = Vec4();
 
 /** Transform point into 2D window coordinates. */
 export function cameraProject(out: Vec4, point: Vec3, viewport: Viewport, projectionView: Mat4) {
-    const { x: vX, y: vY, width: vWidth, height: vHeight } = viewport;
+    const { x, y, width, height } = viewport;
 
     // clip space -> NDC -> window coordinates, implicit 1.0 for w component
     Vec4.set(tmpVec4, point[0], point[1], point[2], 1.0);
@@ -78,27 +75,28 @@ export function cameraProject(out: Vec4, point: Vec3, viewport: Viewport, projec
         tmpVec4[2] /= w;
     }
 
-    // transform into window coordinates, set fourth component is (1/clip.w) as in gl_FragCoord.w
-    out[0] = vX + vWidth / 2 * tmpVec4[0] + (0 + vWidth / 2);
-    out[1] = vY + vHeight / 2 * tmpVec4[1] + (0 + vHeight / 2);
-    out[2] = (FAR_RANGE - NEAR_RANGE) / 2 * tmpVec4[2] + (FAR_RANGE + NEAR_RANGE) / 2;
+    // transform into window coordinates, set fourth component to 1 / clip.w as in gl_FragCoord.w
+    out[0] = (tmpVec4[0] + 1) * width * 0.5 + x;
+    out[1] = (1 - tmpVec4[1]) * height * 0.5 + y; // flip Y
+    out[2] = (tmpVec4[2] + 1) * 0.5;
     out[3] = w === 0 ? 0 : 1 / w;
     return out;
 }
 
 /**
  * Transform point from screen space to 3D coordinates.
- * The point must have x and y set to 2D window coordinates and z between 0 (near) and 1 (far).
+ * The point must have `x` and `y` set to 2D window coordinates
+ * and `z` between 0 (near) and 1 (far); the optional `w` is not used.
  */
-export function cameraUnproject(out: Vec3, point: Vec3, viewport: Viewport, inverseProjectionView: Mat4) {
-    const { x: vX, y: vY, width: vWidth, height: vHeight } = viewport;
+export function cameraUnproject(out: Vec3, point: Vec3 | Vec4, viewport: Viewport, inverseProjectionView: Mat4) {
+    const { x, y, width, height } = viewport;
 
-    const x = point[0] - vX;
-    const y = (vHeight - point[1] - 1) - vY;
-    const z = point[2];
+    const px = point[0] - x;
+    const py = (height - point[1] - 1) - y;
+    const pz = point[2];
 
-    out[0] = (2 * x) / vWidth - 1;
-    out[1] = (2 * y) / vHeight - 1;
-    out[2] = 2 * z - 1;
+    out[0] = (2 * px) / width - 1;
+    out[1] = (2 * py) / height - 1;
+    out[2] = 2 * pz - 1;
     return Vec3.transformMat4(out, out, inverseProjectionView);
 }

+ 8 - 3
src/mol-canvas3d/canvas3d.ts

@@ -23,7 +23,7 @@ import { Camera } from './camera';
 import { ParamDefinition as PD } from '../mol-util/param-definition';
 import { DebugHelperParams } from './helper/bounding-sphere-helper';
 import { SetUtils } from '../mol-util/set';
-import { Canvas3dInteractionHelper } from './helper/interaction-events';
+import { Canvas3dInteractionHelper, Canvas3dInteractionHelperParams } from './helper/interaction-events';
 import { PostprocessingParams } from './passes/postprocessing';
 import { MultiSampleHelper, MultiSampleParams, MultiSamplePass } from './passes/multi-sample';
 import { PickData } from './passes/pick';
@@ -84,6 +84,7 @@ export const Canvas3DParams = {
     marking: PD.Group(MarkingParams),
     renderer: PD.Group(RendererParams),
     trackball: PD.Group(TrackballControlsParams),
+    interaction: PD.Group(Canvas3dInteractionHelperParams),
     debug: PD.Group(DebugHelperParams),
     handle: PD.Group(HandleHelperParams),
 };
@@ -115,6 +116,8 @@ namespace Canvas3DContext {
         preserveDrawingBuffer: true,
         pixelScale: 1,
         pickScale: 0.25,
+        /** extra pixels to around target to check in case target is empty */
+        pickPadding: 1,
         enableWboit: true,
         preferWebGl1: false
     };
@@ -307,8 +310,8 @@ namespace Canvas3D {
         const renderer = Renderer.create(webgl, p.renderer);
         const helper = new Helper(webgl, scene, p);
 
-        const pickHelper = new PickHelper(webgl, renderer, scene, helper, passes.pick, { x, y, width, height });
-        const interactionHelper = new Canvas3dInteractionHelper(identify, getLoci, input, camera);
+        const pickHelper = new PickHelper(webgl, renderer, scene, helper, passes.pick, { x, y, width, height }, attribs.pickPadding);
+        const interactionHelper = new Canvas3dInteractionHelper(identify, getLoci, input, camera, p.interaction);
         const multiSampleHelper = new MultiSampleHelper(passes.multiSample);
 
         let cameraResetRequested = false;
@@ -644,6 +647,7 @@ namespace Canvas3D {
                 multiSample: { ...p.multiSample },
                 renderer: { ...renderer.props },
                 trackball: { ...controls.props },
+                interaction: { ...interactionHelper.props },
                 debug: { ...helper.debug.props },
                 handle: { ...helper.handle.props },
             };
@@ -780,6 +784,7 @@ namespace Canvas3D {
                 if (props.multiSample) Object.assign(p.multiSample, props.multiSample);
                 if (props.renderer) renderer.setProps(props.renderer);
                 if (props.trackball) controls.setProps(props.trackball);
+                if (props.interaction) interactionHelper.setProps(props.interaction);
                 if (props.debug) helper.debug.setProps(props.debug);
                 if (props.handle) helper.handle.setProps(props.handle);
 

+ 47 - 5
src/mol-canvas3d/helper/interaction-events.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2021 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>
@@ -11,6 +11,8 @@ import { InputObserver, ModifiersKeys, ButtonsType } from '../../mol-util/input/
 import { RxEventHelper } from '../../mol-util/rx-event-helper';
 import { Vec2, Vec3 } from '../../mol-math/linear-algebra';
 import { Camera } from '../camera';
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import { Bond } from '../../mol-model/structure';
 
 type Canvas3D = import('../canvas3d').Canvas3D
 type HoverEvent = import('../canvas3d').Canvas3D.HoverEvent
@@ -19,6 +21,17 @@ type ClickEvent = import('../canvas3d').Canvas3D.ClickEvent
 
 const enum InputEvent { Move, Click, Drag }
 
+const tmpPosA = Vec3();
+const tmpPos = Vec3();
+const tmpNorm = Vec3();
+
+export const Canvas3dInteractionHelperParams = {
+    maxFps: PD.Numeric(30, { min: 10, max: 60, step: 10 }),
+    preferAtomPixelPadding: PD.Numeric(3, { min: 0, max: 20, step: 1 }, { description: 'Number of extra pixels at which to prefer atoms over bonds.' }),
+};
+export type Canvas3dInteractionHelperParams = typeof Canvas3dInteractionHelperParams
+export type Canvas3dInteractionHelperProps = PD.Values<Canvas3dInteractionHelperParams>
+
 export class Canvas3dInteractionHelper {
     private ev = RxEventHelper.create();
 
@@ -48,6 +61,12 @@ export class Canvas3dInteractionHelper {
     private button: ButtonsType.Flag = ButtonsType.create(0);
     private modifiers: ModifiersKeys = ModifiersKeys.None;
 
+    readonly props: Canvas3dInteractionHelperProps;
+
+    setProps(props: Partial<Canvas3dInteractionHelperProps>) {
+        Object.assign(this.props, props);
+    }
+
     private identify(e: InputEvent, t: number) {
         const xyChanged = this.startX !== this.endX || this.startY !== this.endY;
 
@@ -70,7 +89,7 @@ export class Canvas3dInteractionHelper {
         }
 
         if (e === InputEvent.Click) {
-            const loci = this.getLoci(this.id);
+            const loci = this.getLoci(this.id, this.position);
             this.events.click.next({ current: loci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, page: Vec2.create(this.endX, this.endY), position: this.position });
             this.prevLoci = loci;
             return;
@@ -78,13 +97,13 @@ export class Canvas3dInteractionHelper {
 
         if (!this.inside || this.currentIdentifyT !== t || !xyChanged || this.outsideViewport(this.endX, this.endY)) return;
 
-        const loci = this.getLoci(this.id);
+        const loci = this.getLoci(this.id, this.position);
         this.events.hover.next({ current: loci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, page: Vec2.create(this.endX, this.endY), position: this.position });
         this.prevLoci = loci;
     }
 
     tick(t: number) {
-        if (this.inside && t - this.prevT > 1000 / this.maxFps) {
+        if (this.inside && t - this.prevT > 1000 / this.props.maxFps) {
             this.prevT = t;
             this.currentIdentifyT = t;
             this.identify(this.isInteracting ? InputEvent.Drag : InputEvent.Move, t);
@@ -144,11 +163,34 @@ export class Canvas3dInteractionHelper {
         );
     }
 
+    private getLoci(pickingId: PickingId | undefined, position: Vec3 | undefined) {
+        const { repr, loci } = this.lociGetter(pickingId);
+        if (position && repr && Bond.isLoci(loci) && loci.bonds.length === 2) {
+            const { aUnit, aIndex } = loci.bonds[0];
+            aUnit.conformation.position(aUnit.elements[aIndex], tmpPosA);
+            Vec3.sub(tmpNorm, this.camera.state.position, this.camera.state.target);
+            Vec3.projectPointOnPlane(tmpPos, position, tmpNorm, tmpPosA);
+            const pixelSize = this.camera.getPixelSize(tmpPos);
+            let radius = repr.theme.size.size(loci.bonds[0]) * (repr.props.sizeFactor ?? 1);
+            if (repr.props.lineSizeAttenuation === false) {
+                // divide by two to get radius
+                radius *= pixelSize / 2;
+            }
+            radius += this.props.preferAtomPixelPadding * pixelSize;
+            if (Vec3.distance(tmpPos, tmpPosA) < radius) {
+                return { repr, loci: Bond.toFirstStructureElementLoci(loci) };
+            }
+        }
+        return { repr, loci };
+    }
+
     dispose() {
         this.ev.dispose();
     }
 
-    constructor(private canvasIdentify: Canvas3D['identify'], private getLoci: Canvas3D['getLoci'], private input: InputObserver, private camera: Camera, private maxFps: number = 30) {
+    constructor(private canvasIdentify: Canvas3D['identify'], private lociGetter: Canvas3D['getLoci'], private input: InputObserver, private camera: Camera, props: Partial<Canvas3dInteractionHelperProps> = {}) {
+        this.props = { ...PD.getDefaultValues(Canvas3dInteractionHelperParams), ...props };
+
         input.drag.subscribe(({ x, y, buttons, button, modifiers }) => {
             this.isInteracting = true;
             // console.log('drag');

+ 1 - 0
src/mol-canvas3d/passes/draw.ts

@@ -362,6 +362,7 @@ export class DrawPass {
     render(renderer: Renderer, camera: Camera | StereoCamera, scene: Scene, helper: Helper, toDrawingBuffer: boolean, transparentBackground: boolean, postprocessingProps: PostprocessingProps, markingProps: MarkingProps) {
         renderer.setTransparentBackground(transparentBackground);
         renderer.setDrawingBufferSize(this.colorTarget.getWidth(), this.colorTarget.getHeight());
+        renderer.setPixelRatio(this.webgl.pixelRatio);
 
         if (StereoCamera.is(camera)) {
             this._render(renderer, camera.left, scene, helper, toDrawingBuffer, transparentBackground, postprocessingProps, markingProps);

+ 17 - 3
src/mol-canvas3d/passes/pick.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -11,6 +11,7 @@ import { WebGLContext } from '../../mol-gl/webgl/context';
 import { GraphicsRenderVariant } from '../../mol-gl/webgl/render-item';
 import { RenderTarget } from '../../mol-gl/webgl/render-target';
 import { Vec3 } from '../../mol-math/linear-algebra';
+import { spiral2d } from '../../mol-math/misc';
 import { decodeFloatRGB, unpackRGBAToDepth } from '../../mol-util/float-packing';
 import { Camera, ICamera } from '../camera';
 import { StereoCamera } from '../camera/stereo';
@@ -88,6 +89,7 @@ export class PickPass {
 
         this.groupPickTarget.bind();
         this.renderVariant(renderer, camera, scene, helper, 'pickGroup');
+        // printTexture(this.webgl, this.groupPickTarget.texture, { id: 'group' })
 
         this.depthPickTarget.bind();
         this.renderVariant(renderer, camera, scene, helper, 'depth');
@@ -111,6 +113,8 @@ export class PickHelper {
     private pickHeight: number
     private halfPickWidth: number
 
+    private spiral: [number, number][]
+
     private setupBuffers() {
         const bufferSize = this.pickWidth * this.pickHeight * 4;
         if (!this.objectBuffer || this.objectBuffer.length !== bufferSize) {
@@ -138,6 +142,8 @@ export class PickHelper {
 
             this.setupBuffers();
         }
+
+        this.spiral = spiral2d(Math.round(this.pickScale * this.pickPadding));
     }
 
     private syncBuffers() {
@@ -177,6 +183,7 @@ export class PickHelper {
 
         renderer.setTransparentBackground(false);
         renderer.setDrawingBufferSize(this.pickPass.objectPickTarget.getWidth(), this.pickPass.objectPickTarget.getHeight());
+        renderer.setPixelRatio(this.pickScale);
 
         if (StereoCamera.is(camera)) {
             renderer.setViewport(pickX, pickY, halfPickWidth, pickHeight);
@@ -192,7 +199,7 @@ export class PickHelper {
         this.dirty = false;
     }
 
-    identify(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
+    private identifyInternal(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
         const { webgl, pickScale } = this;
         if (webgl.isContextLost) return;
 
@@ -251,7 +258,14 @@ export class PickHelper {
         return { id: { objectId, instanceId, groupId }, position };
     }
 
-    constructor(private webgl: WebGLContext, private renderer: Renderer, private scene: Scene, private helper: Helper, private pickPass: PickPass, viewport: Viewport) {
+    identify(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
+        for (const d of this.spiral) {
+            const pickData = this.identifyInternal(x + d[0], y + d[1], camera);
+            if (pickData) return pickData;
+        }
+    }
+
+    constructor(private webgl: WebGLContext, private renderer: Renderer, private scene: Scene, private helper: Helper, private pickPass: PickPass, viewport: Viewport, readonly pickPadding = 1) {
         this.setViewport(viewport.x, viewport.y, viewport.width, viewport.height);
     }
 }

+ 1 - 1
src/mol-geo/geometry/lines/lines.ts

@@ -164,7 +164,7 @@ export namespace Lines {
 
     export const Params = {
         ...BaseGeometry.Params,
-        sizeFactor: PD.Numeric(1.5, { min: 0, max: 10, step: 0.1 }),
+        sizeFactor: PD.Numeric(3, { min: 0, max: 10, step: 0.1 }),
         lineSizeAttenuation: PD.Boolean(false),
     };
     export type Params = typeof Params

+ 1 - 1
src/mol-geo/geometry/points/points.ts

@@ -127,7 +127,7 @@ export namespace Points {
 
     export const Params = {
         ...BaseGeometry.Params,
-        sizeFactor: PD.Numeric(1.5, { min: 0, max: 10, step: 0.1 }),
+        sizeFactor: PD.Numeric(3, { min: 0, max: 10, step: 0.1 }),
         pointSizeAttenuation: PD.Boolean(false),
         pointStyle: PD.Select('square', PD.objectToOptions(StyleTypes)),
     };

+ 4 - 0
src/mol-gl/renderer.ts

@@ -62,6 +62,7 @@ interface Renderer {
     setViewport: (x: number, y: number, width: number, height: number) => void
     setTransparentBackground: (value: boolean) => void
     setDrawingBufferSize: (width: number, height: number) => void
+    setPixelRatio: (value: number) => void
 
     dispose: () => void
 }
@@ -716,6 +717,9 @@ namespace Renderer {
                     ValueCell.update(globalUniforms.uDrawingBufferSize, Vec2.set(drawingBufferSize, width, height));
                 }
             },
+            setPixelRatio: (value: number) => {
+                ValueCell.update(globalUniforms.uPixelRatio, value);
+            },
 
             props: p,
             get stats(): RendererStats {

+ 1 - 0
src/mol-gl/shader/lines.vert.ts

@@ -105,6 +105,7 @@ void main(){
     #else
         linewidth = size * uPixelRatio;
     #endif
+    linewidth = max(1.0, linewidth);
 
     // adjust for linewidth
     offset *= linewidth;

+ 1 - 0
src/mol-gl/shader/points.vert.ts

@@ -36,6 +36,7 @@ void main(){
     #else
         gl_PointSize = size * uPixelRatio;
     #endif
+    gl_PointSize = max(1.0, gl_PointSize);
 
     gl_Position = uProjection * mvPosition;
 

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

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2017-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2017-2021 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>
@@ -540,9 +540,17 @@ namespace Vec3 {
 
     /** Project `point` onto `vector` starting from `origin` */
     export function projectPointOnVector(out: Vec3, point: Vec3, vector: Vec3, origin: Vec3) {
-        sub(out, copy(out, point), origin);
+        sub(out, point, origin);
         const scalar = dot(vector, out) / squaredMagnitude(vector);
-        return add(out, scale(out, copy(out, vector), scalar), origin);
+        return add(out, scale(out, vector, scalar), origin);
+    }
+
+    const tmpProjectPlane = zero();
+    /** Project `point` onto `plane` defined by `normal` starting from `origin` */
+    export function projectPointOnPlane(out: Vec3, point: Vec3, normal: Vec3, origin: Vec3) {
+        normalize(tmpProjectPlane, normal);
+        sub(out, point, origin);
+        return sub(out, point, scale(tmpProjectPlane, tmpProjectPlane, dot(out, tmpProjectPlane)));
     }
 
     export function projectOnVector(out: Vec3, p: Vec3, vector: Vec3) {

+ 25 - 1
src/mol-math/misc.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -37,4 +37,28 @@ export function absMax(...values: number[]) {
 /** Length of an arc with angle in radians */
 export function arcLength(angle: number, radius: number) {
     return angle * radius;
+}
+
+/** Create an outward spiral of given `radius` on a 2d grid */
+export function spiral2d(radius: number) {
+    let x = 0;
+    let y = 0;
+    const delta = [0, -1];
+    const size = radius * 2 + 1;
+    const halfSize = size / 2;
+    const out: [number, number][] = [];
+
+    for (let i = Math.pow(size, 2); i > 0; --i) {
+        if ((-halfSize < x && x <= halfSize) && (-halfSize < y && y <= halfSize)) {
+            out.push([x, y]);
+        }
+
+        if (x === y || (x < 0 && x === -y) || (x > 0 && x === 1 - y)) {
+            [delta[0], delta[1]] = [-delta[1], delta[0]]; // change direction
+        }
+
+        x += delta[0];
+        y += delta[1];
+    }
+    return out;
 }

+ 3 - 3
src/mol-model/loci.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -283,8 +283,8 @@ namespace Loci {
      * Converts structure related loci to StructureElement.Loci and applies
      * granularity if given
      */
-    export function normalize(loci: Loci, granularity?: Granularity) {
-        if (granularity !== 'element' && Bond.isLoci(loci)) {
+    export function normalize(loci: Loci, granularity?: Granularity, alwaysConvertBonds = false) {
+        if ((granularity !== 'element' || alwaysConvertBonds) && Bond.isLoci(loci)) {
             // convert Bond.Loci to a StructureElement.Loci so granularity can be applied
             loci = Bond.toStructureElementLoci(loci);
         }

+ 7 - 2
src/mol-model/structure/structure/unit/bonds.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2017-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2017-2021 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>
@@ -8,7 +8,7 @@
 import { Unit, StructureElement } from '../../structure';
 import { Structure } from '../structure';
 import { BondType } from '../../model/types';
-import { SortedArray, Iterator } from '../../../../mol-data/int';
+import { SortedArray, Iterator, OrderedSet } from '../../../../mol-data/int';
 import { CentroidHelper } from '../../../../mol-math/geometry/centroid-helper';
 import { Sphere3D } from '../../../../mol-math/geometry';
 
@@ -132,6 +132,11 @@ namespace Bond {
         return StructureElement.Loci(loci.structure, elements);
     }
 
+    export function toFirstStructureElementLoci(loci: Loci): StructureElement.Loci {
+        const { aUnit, aIndex } = loci.bonds[0];
+        return StructureElement.Loci(loci.structure, [{ unit: aUnit, indices: OrderedSet.ofSingleton(aIndex) }]);
+    }
+
     export function getType(structure: Structure, location: Location<Unit.Atomic>): BondType {
         if (location.aUnit === location.bUnit) {
             const bonds = location.aUnit.bonds;

+ 12 - 10
src/mol-plugin-state/manager/interactivity.ts

@@ -101,10 +101,10 @@ namespace InteractivityManager {
             // TODO clear, then re-apply remaining providers
         }
 
-        protected normalizedLoci(reprLoci: Representation.Loci, applyGranularity = true) {
+        protected normalizedLoci(reprLoci: Representation.Loci, applyGranularity: boolean, alwaysConvertBonds = false) {
             const { loci, repr } = reprLoci;
             const granularity = applyGranularity ? this.props.granularity : undefined;
-            return { loci: Loci.normalize(loci, granularity), repr };
+            return { loci: Loci.normalize(loci, granularity, alwaysConvertBonds), repr };
         }
 
         protected mark(current: Representation.Loci, action: MarkerAction, noRender = false) {
@@ -187,7 +187,8 @@ namespace InteractivityManager {
         toggle(current: Representation.Loci, applyGranularity = true) {
             if (Loci.isEmpty(current.loci)) return;
 
-            const normalized = this.normalizedLoci(current, applyGranularity);
+            const normalized = this.normalizedLoci(current, applyGranularity, true);
+
             if (StructureElement.Loci.is(normalized.loci)) {
                 this.toggleSel(normalized);
             } else {
@@ -198,7 +199,7 @@ namespace InteractivityManager {
         toggleExtend(current: Representation.Loci, applyGranularity = true) {
             if (Loci.isEmpty(current.loci)) return;
 
-            const normalized = this.normalizedLoci(current, applyGranularity);
+            const normalized = this.normalizedLoci(current, applyGranularity, true);
             if (StructureElement.Loci.is(normalized.loci)) {
                 const loci = this.sel.tryGetRange(normalized.loci) || normalized.loci;
                 this.toggleSel({ loci, repr: normalized.repr });
@@ -206,7 +207,7 @@ namespace InteractivityManager {
         }
 
         select(current: Representation.Loci, applyGranularity = true) {
-            const normalized = this.normalizedLoci(current, applyGranularity);
+            const normalized = this.normalizedLoci(current, applyGranularity, true);
             if (StructureElement.Loci.is(normalized.loci)) {
                 this.sel.modify('add', normalized.loci);
             }
@@ -214,7 +215,7 @@ namespace InteractivityManager {
         }
 
         selectJoin(current: Representation.Loci, applyGranularity = true) {
-            const normalized = this.normalizedLoci(current, applyGranularity);
+            const normalized = this.normalizedLoci(current, applyGranularity, true);
             if (StructureElement.Loci.is(normalized.loci)) {
                 this.sel.modify('intersect', normalized.loci);
             }
@@ -222,7 +223,7 @@ namespace InteractivityManager {
         }
 
         selectOnly(current: Representation.Loci, applyGranularity = true) {
-            const normalized = this.normalizedLoci(current, applyGranularity);
+            const normalized = this.normalizedLoci(current, applyGranularity, true);
             if (StructureElement.Loci.is(normalized.loci)) {
                 // only deselect for the structure of the given loci
                 this.deselect({ loci: Structure.toStructureElementLoci(normalized.loci.structure), repr: normalized.repr }, false);
@@ -232,7 +233,7 @@ namespace InteractivityManager {
         }
 
         deselect(current: Representation.Loci, applyGranularity = true) {
-            const normalized = this.normalizedLoci(current, applyGranularity);
+            const normalized = this.normalizedLoci(current, applyGranularity, true);
             if (StructureElement.Loci.is(normalized.loci)) {
                 this.sel.modify('remove', normalized.loci);
             }
@@ -255,8 +256,9 @@ namespace InteractivityManager {
                     // do a full deselect/select for the current structure so visuals that are
                     // marked with granularity unequal to 'element' and join/intersect operations
                     // are handled properly
-                    super.mark({ loci: Structure.Loci(loci.structure) }, MarkerAction.Deselect, true);
-                    super.mark({ loci: this.sel.getLoci(loci.structure) }, MarkerAction.Select);
+                    const selLoci = this.sel.getLoci(loci.structure);
+                    super.mark({ loci: Structure.Loci(loci.structure) }, MarkerAction.Deselect, !Loci.isEmpty(selLoci));
+                    super.mark({ loci: selLoci }, MarkerAction.Select);
                 } else {
                     super.mark(current, action);
                 }

+ 26 - 10
src/mol-plugin/behavior/dynamic/representation.ts

@@ -16,7 +16,7 @@ import { ButtonsType, ModifiersKeys } from '../../../mol-util/input/input-observ
 import { Binding } from '../../../mol-util/binding';
 import { ParamDefinition as PD } from '../../../mol-util/param-definition';
 import { EmptyLoci, Loci } from '../../../mol-model/loci';
-import { Structure, StructureElement, StructureProperties } from '../../../mol-model/structure';
+import { Bond, Structure, StructureElement, StructureProperties } from '../../../mol-model/structure';
 import { arrayMax } from '../../../mol-util/array';
 import { Representation } from '../../../mol-repr/representation';
 import { LociLabel } from '../../../mol-plugin-state/manager/loci-label';
@@ -34,6 +34,7 @@ const DefaultHighlightLociBindings = {
 const HighlightLociParams = {
     bindings: PD.Value(DefaultHighlightLociBindings, { isHidden: true }),
     ignore: PD.Value<Loci['kind'][]>([], { isHidden: true }),
+    preferAtoms: PD.Boolean(false, { description: 'Always prefer atoms over bonds' }),
     mark: PD.Boolean(true)
 };
 type HighlightLociProps = PD.Values<typeof HighlightLociParams>
@@ -46,10 +47,17 @@ export const HighlightLoci = PluginBehavior.create({
             if (!this.ctx.canvas3d || !this.params.mark) return;
             this.ctx.canvas3d.mark(interactionLoci, action, noRender);
         }
+        private getLoci(loci: Loci) {
+            return this.params.preferAtoms && Bond.isLoci(loci) && loci.bonds.length === 2
+                ? Bond.toFirstStructureElementLoci(loci)
+                : loci;
+        }
         register() {
             this.subscribeObservable(this.ctx.behaviors.interaction.hover, ({ current, buttons, modifiers }) => {
                 if (!this.ctx.canvas3d || this.ctx.isBusy) return;
-                if (this.params.ignore?.indexOf(current.loci.kind) >= 0) {
+
+                const loci = this.getLoci(current.loci);
+                if (this.params.ignore?.indexOf(loci.kind) >= 0) {
                     this.ctx.managers.interactivity.lociHighlights.highlightOnly({ repr: current.repr, loci: EmptyLoci });
                     return;
                 }
@@ -58,13 +66,13 @@ export const HighlightLoci = PluginBehavior.create({
 
                 if (Binding.match(this.params.bindings.hoverHighlightOnly, buttons, modifiers)) {
                     // remove repr to highlight loci everywhere on hover
-                    this.ctx.managers.interactivity.lociHighlights.highlightOnly({ loci: current.loci });
+                    this.ctx.managers.interactivity.lociHighlights.highlightOnly({ loci });
                     matched = true;
                 }
 
                 if (Binding.match(this.params.bindings.hoverHighlightOnlyExtend, buttons, modifiers)) {
                     // remove repr to highlight loci everywhere on hover
-                    this.ctx.managers.interactivity.lociHighlights.highlightOnlyExtend({ loci: current.loci });
+                    this.ctx.managers.interactivity.lociHighlights.highlightOnlyExtend({ loci });
                     matched = true;
                 }
 
@@ -95,6 +103,7 @@ const DefaultSelectLociBindings = {
 const SelectLociParams = {
     bindings: PD.Value(DefaultSelectLociBindings, { isHidden: true }),
     ignore: PD.Value<Loci['kind'][]>([], { isHidden: true }),
+    preferAtoms: PD.Boolean(false, { description: 'Always prefer atoms over bonds' }),
     mark: PD.Boolean(true)
 };
 type SelectLociProps = PD.Values<typeof SelectLociParams>
@@ -108,6 +117,11 @@ export const SelectLoci = PluginBehavior.create({
             if (!this.ctx.canvas3d || !this.params.mark) return;
             this.ctx.canvas3d.mark({ loci: reprLoci.loci }, action, noRender);
         }
+        private getLoci(loci: Loci) {
+            return this.params.preferAtoms && Bond.isLoci(loci) && loci.bonds.length === 2
+                ? Bond.toFirstStructureElementLoci(loci)
+                : loci;
+        }
         private applySelectMark(ref: string, clear?: boolean) {
             const cell = this.ctx.state.data.cells.get(ref);
             if (cell && SO.isRepresentation3D(cell.obj)) {
@@ -123,10 +137,10 @@ export const SelectLoci = PluginBehavior.create({
             }
         }
         register() {
-            const lociIsEmpty = (current: Representation.Loci) => Loci.isEmpty(current.loci);
-            const lociIsNotEmpty = (current: Representation.Loci) => !Loci.isEmpty(current.loci);
+            const lociIsEmpty = (loci: Loci) => Loci.isEmpty(loci);
+            const lociIsNotEmpty = (loci: Loci) => !Loci.isEmpty(loci);
 
-            const actions: [keyof typeof DefaultSelectLociBindings, (current: Representation.Loci) => void, ((current: Representation.Loci) => boolean) | undefined][] = [
+            const actions: [keyof typeof DefaultSelectLociBindings, (current: Representation.Loci) => void, ((current: Loci) => boolean) | undefined][] = [
                 ['clickSelect', current => this.ctx.managers.interactivity.lociSelects.select(current), lociIsNotEmpty],
                 ['clickToggle', current => this.ctx.managers.interactivity.lociSelects.toggle(current), lociIsNotEmpty],
                 ['clickToggleExtend', current => this.ctx.managers.interactivity.lociSelects.toggleExtend(current), lociIsNotEmpty],
@@ -145,12 +159,14 @@ export const SelectLoci = PluginBehavior.create({
 
             this.subscribeObservable(this.ctx.behaviors.interaction.click, ({ current, button, modifiers }) => {
                 if (!this.ctx.canvas3d || this.ctx.isBusy || !this.ctx.selectionMode) return;
-                if (this.params.ignore?.indexOf(current.loci.kind) >= 0) return;
+
+                const loci = this.getLoci(current.loci);
+                if (this.params.ignore?.indexOf(loci.kind) >= 0) return;
 
                 // only trigger the 1st action that matches
                 for (const [binding, action, condition] of actions) {
-                    if (Binding.match(this.params.bindings[binding], button, modifiers) && (!condition || condition(current))) {
-                        action(current);
+                    if (Binding.match(this.params.bindings[binding], button, modifiers) && (!condition || condition(loci))) {
+                        action({ repr: current.repr, loci });
                         break;
                     }
                 }

+ 1 - 0
src/mol-plugin/config.ts

@@ -37,6 +37,7 @@ export const PluginConfig = {
         DisablePreserveDrawingBuffer: item('plugin-config.disable-preserve-drawing-buffer', false),
         PixelScale: item('plugin-config.pixel-scale', 1),
         PickScale: item('plugin-config.pick-scale', 0.25),
+        PickPadding: item('plugin-config.pick-padding', 3),
         EnableWboit: item('plugin-config.enable-wboit', true),
         // as of Oct 1 2021, WebGL 2 doesn't work on iOS 15.
         // TODO: check back in a few weeks to see if it was fixed

+ 2 - 0
src/mol-plugin/context.ts

@@ -196,9 +196,11 @@ export class PluginContext {
                 const preserveDrawingBuffer = !(this.config.get(PluginConfig.General.DisablePreserveDrawingBuffer) ?? false);
                 const pixelScale = this.config.get(PluginConfig.General.PixelScale) || 1;
                 const pickScale = this.config.get(PluginConfig.General.PickScale) || 0.25;
+                const pickPadding = this.config.get(PluginConfig.General.PickPadding) ?? 1;
                 const enableWboit = this.config.get(PluginConfig.General.EnableWboit) || false;
                 const preferWebGl1 = this.config.get(PluginConfig.General.PreferWebGl1) || false;
                 (this.canvas3dContext as Canvas3DContext) = Canvas3DContext.fromCanvas(canvas, { antialias, preserveDrawingBuffer, pixelScale, pickScale, enableWboit, preferWebGl1 });
+                (this.canvas3dContext as Canvas3DContext) = Canvas3DContext.fromCanvas(canvas, { antialias, preserveDrawingBuffer, pixelScale, pickScale, pickPadding, enableWboit });
             }
             (this.canvas3d as Canvas3D) = Canvas3D.create(this.canvas3dContext!);
             this.canvas3dInit.next(true);

+ 10 - 5
src/mol-repr/structure/representation/line.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -14,23 +14,28 @@ import { Representation, RepresentationParamsGetter, RepresentationContext } fro
 import { ThemeRegistryContext } from '../../../mol-theme/theme';
 import { Structure } from '../../../mol-model/structure';
 import { getUnitKindsParam } from '../params';
+import { ElementPointParams, ElementPointVisual } from '../visual/element-point';
 
 const LineVisuals = {
     'intra-bond': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, IntraUnitBondLineParams>) => UnitsRepresentation('Intra-unit bond line', ctx, getParams, IntraUnitBondLineVisual),
     'inter-bond': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, InterUnitBondLineParams>) => ComplexRepresentation('Inter-unit bond line', ctx, getParams, InterUnitBondLineVisual),
+    'element-point': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, ElementPointParams>) => UnitsRepresentation('Points', ctx, getParams, ElementPointVisual),
 };
 
 export const LineParams = {
     ...IntraUnitBondLineParams,
     ...InterUnitBondLineParams,
+    ...ElementPointParams,
     includeParent: PD.Boolean(false),
-    sizeFactor: PD.Numeric(1.5, { min: 0.01, max: 10, step: 0.01 }),
+    sizeFactor: PD.Numeric(3, { min: 0.01, max: 10, step: 0.01 }),
     unitKinds: getUnitKindsParam(['atomic']),
-    visuals: PD.MultiSelect(['intra-bond', 'inter-bond'], PD.objectToOptions(LineVisuals))
+    visuals: PD.MultiSelect(['intra-bond', 'inter-bond', 'element-point'], PD.objectToOptions(LineVisuals))
 };
 export type LineParams = typeof LineParams
 export function getLineParams(ctx: ThemeRegistryContext, structure: Structure) {
-    return PD.clone(LineParams);
+    const params = PD.clone(LineParams);
+    params.pointStyle.defaultValue = 'circle';
+    return params;
 }
 
 export type LineRepresentation = StructureRepresentation<LineParams>
@@ -41,7 +46,7 @@ export function LineRepresentation(ctx: RepresentationContext, getParams: Repres
 export const LineRepresentationProvider = StructureRepresentationProvider({
     name: 'line',
     label: 'Line',
-    description: 'Displays bonds as lines.',
+    description: 'Displays bonds as lines and atoms as points.',
     factory: LineRepresentation,
     getParams: getLineParams,
     defaultValues: PD.getDefaultValues(LineParams),