Browse Source

Merge branch 'master' of https://github.com/molstar/molstar into approx-spheres

Alexander Rose 1 year ago
parent
commit
96d5bf2447

+ 7 - 0
CHANGELOG.md

@@ -7,8 +7,15 @@ Note that since we don't clearly distinguish between a public and private interf
 ## [Unreleased]
 
 - Fix display issue with SIFTS mapping
+- Add 'NH2', 'FOR', 'FMT' to `CommonProteinCaps`
+- Add `opened` event to `PluginStateSnapshotManager`
 - Properly switch-off fog
 - Add `approximate` option for spheres rendering
+- Reduce `Spheres` memory usage
+    - Derive mapping from VertexID
+    - Pull position and group from texture
+- Add `Euler` math primitive
+- Add stride option to element sphere & point visuals
 
 ## [v3.37.1] - 2023-06-20
 

+ 4 - 4
src/extensions/geo-export/mesh-exporter.ts

@@ -465,13 +465,13 @@ export abstract class MeshExporter<D extends RenderObjectExportData> implements
     private async addSpheres(values: SpheresValues, webgl: WebGLContext, ctx: RuntimeContext) {
         const center = Vec3();
 
-        const aPosition = values.aPosition.ref.value;
-        const aGroup = values.aGroup.ref.value;
+        const aPosition = values.centerBuffer.ref.value;
+        const aGroup = values.groupBuffer.ref.value;
         const instanceCount = values.instanceCount.ref.value;
         const vertexCount = values.uVertexCount.ref.value;
         const meshes: Mesh[] = [];
 
-        const sphereCount = vertexCount / 4 * instanceCount;
+        const sphereCount = vertexCount / 6 * instanceCount;
         let detail: number;
         switch (this.options.primitivesQuality) {
             case 'auto':
@@ -495,7 +495,7 @@ export abstract class MeshExporter<D extends RenderObjectExportData> implements
         for (let instanceIndex = 0; instanceIndex < instanceCount; ++instanceIndex) {
             const state = MeshBuilder.createState(512, 256);
 
-            for (let i = 0; i < vertexCount; i += 4) {
+            for (let i = 0; i < sphereCount; ++i) {
                 v3fromArray(center, aPosition, i * 3);
 
                 const group = aGroup[i];

+ 2 - 2
src/mol-geo/geometry/geometry.ts

@@ -1,5 +1,5 @@
 /**
- * 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 Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -81,7 +81,7 @@ export namespace Geometry {
         switch (geometry.kind) {
             case 'mesh': return geometry.vertexCount;
             case 'points': return geometry.pointCount;
-            case 'spheres': return geometry.sphereCount * 4;
+            case 'spheres': return geometry.sphereCount * 6;
             case 'cylinders': return geometry.cylinderCount * 6;
             case 'text': return geometry.charCount * 4;
             case 'lines': return geometry.lineCount * 4;

+ 4 - 29
src/mol-geo/geometry/spheres/spheres-builder.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -7,21 +7,8 @@
 import { ChunkedArray } from '../../../mol-data/util';
 import { Spheres } from './spheres';
 
-const quadMapping = new Float32Array([
-    -1.0, 1.0,
-    -1.0, -1.0,
-    1.0, 1.0,
-    1.0, -1.0
-]);
-
-const quadIndices = new Uint16Array([
-    0, 1, 2,
-    1, 3, 2
-]);
-
 // avoiding namespace lookup improved performance in Chrome (Aug 2020)
 const caAdd3 = ChunkedArray.add3;
-const caAdd2 = ChunkedArray.add2;
 const caAdd = ChunkedArray.add;
 
 export interface SpheresBuilder {
@@ -31,30 +18,18 @@ export interface SpheresBuilder {
 
 export namespace SpheresBuilder {
     export function create(initialCount = 2048, chunkSize = 1024, spheres?: Spheres): SpheresBuilder {
-        initialCount *= 4;
-        chunkSize *= 4;
         const centers = ChunkedArray.create(Float32Array, 3, chunkSize, spheres ? spheres.centerBuffer.ref.value : initialCount);
-        const mappings = ChunkedArray.create(Float32Array, 2, chunkSize, spheres ? spheres.mappingBuffer.ref.value : initialCount);
-        const indices = ChunkedArray.create(Uint32Array, 3, chunkSize / 2, spheres ? spheres.indexBuffer.ref.value : initialCount / 2);
         const groups = ChunkedArray.create(Float32Array, 1, chunkSize, spheres ? spheres.groupBuffer.ref.value : initialCount);
 
         return {
             add: (x: number, y: number, z: number, group: number) => {
-                const offset = centers.elementCount;
-                for (let i = 0; i < 4; ++i) {
-                    caAdd3(centers, x, y, z);
-                    caAdd2(mappings, quadMapping[i * 2], quadMapping[i * 2 + 1]);
-                    caAdd(groups, group);
-                }
-                caAdd3(indices, offset + quadIndices[0], offset + quadIndices[1], offset + quadIndices[2]);
-                caAdd3(indices, offset + quadIndices[3], offset + quadIndices[4], offset + quadIndices[5]);
+                caAdd3(centers, x, y, z);
+                caAdd(groups, group);
             },
             getSpheres: () => {
                 const cb = ChunkedArray.compact(centers, true) as Float32Array;
-                const mb = ChunkedArray.compact(mappings, true) as Float32Array;
-                const ib = ChunkedArray.compact(indices, true) as Uint32Array;
                 const gb = ChunkedArray.compact(groups, true) as Float32Array;
-                return Spheres.create(cb, mb, ib, gb, centers.elementCount / 4, spheres);
+                return Spheres.create(cb, gb, centers.elementCount, spheres);
             }
         };
     }

+ 32 - 29
src/mol-geo/geometry/spheres/spheres.ts

@@ -13,7 +13,7 @@ import { Theme } from '../../../mol-theme/theme';
 import { SpheresValues } from '../../../mol-gl/renderable/spheres';
 import { createColors } from '../color-data';
 import { createMarkers } from '../marker-data';
-import { calculateInvariantBoundingSphere, calculateTransformBoundingSphere } from '../../../mol-gl/renderable/util';
+import { TextureImage, calculateInvariantBoundingSphere, calculateTransformBoundingSphere, createTextureImage } from '../../../mol-gl/renderable/util';
 import { Sphere3D } from '../../../mol-math/geometry';
 import { createSizes, getMaxSize } from '../size-data';
 import { Color } from '../../../mol-util/color';
@@ -23,7 +23,7 @@ import { createEmptyTransparency } from '../transparency-data';
 import { hashFnv32a } from '../../../mol-data/util';
 import { GroupMapping, createGroupMapping } from '../../util';
 import { createEmptyClipping } from '../clipping-data';
-import { Vec3, Vec4 } from '../../../mol-math/linear-algebra';
+import { Vec2, Vec3, Vec4 } from '../../../mol-math/linear-algebra';
 import { RenderableState } from '../../../mol-gl/renderable';
 import { createEmptySubstance } from '../substance-data';
 
@@ -35,10 +35,6 @@ export interface Spheres {
 
     /** Center buffer as array of xyz values wrapped in a value cell */
     readonly centerBuffer: ValueCell<Float32Array>,
-    /** Mapping buffer as array of xy values wrapped in a value cell */
-    readonly mappingBuffer: ValueCell<Float32Array>,
-    /** Index buffer as array of center index triplets wrapped in a value cell */
-    readonly indexBuffer: ValueCell<Uint32Array>,
     /** Group buffer as array of group ids for each vertex wrapped in a value cell */
     readonly groupBuffer: ValueCell<Float32Array>,
 
@@ -51,29 +47,27 @@ export interface Spheres {
 }
 
 export namespace Spheres {
-    export function create(centers: Float32Array, mappings: Float32Array, indices: Uint32Array, groups: Float32Array, sphereCount: number, spheres?: Spheres): Spheres {
+    export function create(centers: Float32Array, groups: Float32Array, sphereCount: number, spheres?: Spheres): Spheres {
         return spheres ?
-            update(centers, mappings, indices, groups, sphereCount, spheres) :
-            fromArrays(centers, mappings, indices, groups, sphereCount);
+            update(centers, groups, sphereCount, spheres) :
+            fromArrays(centers, groups, sphereCount);
     }
 
     export function createEmpty(spheres?: Spheres): Spheres {
         const cb = spheres ? spheres.centerBuffer.ref.value : new Float32Array(0);
-        const mb = spheres ? spheres.mappingBuffer.ref.value : new Float32Array(0);
-        const ib = spheres ? spheres.indexBuffer.ref.value : new Uint32Array(0);
         const gb = spheres ? spheres.groupBuffer.ref.value : new Float32Array(0);
-        return create(cb, mb, ib, gb, 0, spheres);
+        return create(cb, gb, 0, spheres);
     }
 
     function hashCode(spheres: Spheres) {
         return hashFnv32a([
             spheres.sphereCount,
-            spheres.centerBuffer.ref.version, spheres.mappingBuffer.ref.version,
-            spheres.indexBuffer.ref.version, spheres.groupBuffer.ref.version
+            spheres.centerBuffer.ref.version,
+            spheres.groupBuffer.ref.version
         ]);
     }
 
-    function fromArrays(centers: Float32Array, mappings: Float32Array, indices: Uint32Array, groups: Float32Array, sphereCount: number): Spheres {
+    function fromArrays(centers: Float32Array, groups: Float32Array, sphereCount: number): Spheres {
 
         const boundingSphere = Sphere3D();
         let groupMapping: GroupMapping;
@@ -85,8 +79,6 @@ export namespace Spheres {
             kind: 'spheres' as const,
             sphereCount,
             centerBuffer: ValueCell.create(centers),
-            mappingBuffer: ValueCell.create(mappings),
-            indexBuffer: ValueCell.create(indices),
             groupBuffer: ValueCell.create(groups),
             get boundingSphere() {
                 const newHash = hashCode(spheres);
@@ -112,17 +104,23 @@ export namespace Spheres {
         return spheres;
     }
 
-    function update(centers: Float32Array, mappings: Float32Array, indices: Uint32Array, groups: Float32Array, sphereCount: number, spheres: Spheres) {
-        if (sphereCount > spheres.sphereCount) {
-            ValueCell.update(spheres.mappingBuffer, mappings);
-            ValueCell.update(spheres.indexBuffer, indices);
-        }
+    function update(centers: Float32Array, groups: Float32Array, sphereCount: number, spheres: Spheres) {
         spheres.sphereCount = sphereCount;
         ValueCell.update(spheres.centerBuffer, centers);
         ValueCell.update(spheres.groupBuffer, groups);
         return spheres;
     }
 
+    function setPositionGroup(out: TextureImage<Float32Array>, centers: Float32Array, groups: Float32Array, count: number) {
+        const { array } = out;
+        for (let i = 0; i < count; ++i) {
+            array[i * 4 + 0] = centers[i * 3 + 0];
+            array[i * 4 + 1] = centers[i * 3 + 1];
+            array[i * 4 + 2] = centers[i * 3 + 2];
+            array[i * 4 + 3] = groups[i];
+        }
+    }
+
     export const Params = {
         ...BaseGeometry.Params,
         sizeFactor: PD.Numeric(1, { min: 0, max: 10, step: 0.1 }),
@@ -150,7 +148,7 @@ export namespace Spheres {
     };
 
     function createPositionIterator(spheres: Spheres, transform: TransformData): LocationIterator {
-        const groupCount = spheres.sphereCount * 4;
+        const groupCount = spheres.sphereCount;
         const instanceCount = transform.instanceCount.ref.value;
         const location = PositionLocation();
         const p = location.position;
@@ -164,7 +162,7 @@ export namespace Spheres {
             }
             return location;
         };
-        return LocationIterator(groupCount, instanceCount, 4, getLocation);
+        return LocationIterator(groupCount, instanceCount, 1, getLocation);
     }
 
     function createValues(spheres: Spheres, transform: TransformData, locationIt: LocationIterator, theme: Theme, props: PD.Values<Params>): SpheresValues {
@@ -181,19 +179,21 @@ export namespace Spheres {
         const material = createEmptySubstance();
         const clipping = createEmptyClipping();
 
-        const counts = { drawCount: spheres.sphereCount * 2 * 3, vertexCount: spheres.sphereCount * 4, groupCount, instanceCount };
+        const counts = { drawCount: spheres.sphereCount * 2 * 3, vertexCount: spheres.sphereCount * 6, groupCount, instanceCount };
 
         const padding = spheres.boundingSphere.radius ? getMaxSize(size) * props.sizeFactor : 0;
         const invariantBoundingSphere = Sphere3D.expand(Sphere3D(), spheres.boundingSphere, padding);
         const boundingSphere = calculateTransformBoundingSphere(invariantBoundingSphere, transform.aTransform.ref.value, instanceCount, 0);
 
+        const positionGroupTexture = createTextureImage(spheres.sphereCount, 4, Float32Array);
+        setPositionGroup(positionGroupTexture, spheres.centerBuffer.ref.value, spheres.groupBuffer.ref.value, spheres.sphereCount);
+
         return {
             dGeometryType: ValueCell.create('spheres'),
 
-            aPosition: spheres.centerBuffer,
-            aMapping: spheres.mappingBuffer,
-            aGroup: spheres.groupBuffer,
-            elements: spheres.indexBuffer,
+            uTexDim: ValueCell.create(Vec2.create(positionGroupTexture.width, positionGroupTexture.height)),
+            tPositionGroup: ValueCell.create(positionGroupTexture),
+
             boundingSphere: ValueCell.create(boundingSphere),
             invariantBoundingSphere: ValueCell.create(invariantBoundingSphere),
             uInvariantBoundingSphere: ValueCell.create(Vec4.ofSphere(invariantBoundingSphere)),
@@ -218,6 +218,9 @@ export namespace Spheres {
             dApproximate: ValueCell.create(props.approximate),
             uBumpFrequency: ValueCell.create(props.bumpFrequency),
             uBumpAmplitude: ValueCell.create(props.bumpAmplitude),
+
+            centerBuffer: spheres.centerBuffer,
+            groupBuffer: spheres.groupBuffer,
         };
     }
 

+ 3 - 2
src/mol-gl/_spec/gl.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2021-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -17,10 +17,11 @@ export function getGLContext(width: number, height: number) {
     return createContext(gl);
 }
 
-export function tryGetGLContext(width: number, height: number, requiredExtensions?: { fragDepth?: boolean }) {
+export function tryGetGLContext(width: number, height: number, requiredExtensions?: { fragDepth?: boolean, textureFloat?: boolean }) {
     try {
         const ctx = getGLContext(width, height);
         if (requiredExtensions?.fragDepth && !ctx.extensions.fragDepth) return;
+        if (requiredExtensions?.textureFloat && !ctx.extensions.textureFloat) return;
         return ctx;
     } catch (e) {
         return;

+ 2 - 2
src/mol-gl/_spec/spheres.spec.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2021-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -21,7 +21,7 @@ export function createSpheres() {
 }
 
 describe('spheres', () => {
-    const ctx = tryGetGLContext(32, 32, { fragDepth: true });
+    const ctx = tryGetGLContext(32, 32, { fragDepth: true, textureFloat: true });
 
     (ctx ? it : it.skip)('basic', async () => {
         const ctx = getGLContext(32, 32);

+ 7 - 5
src/mol-gl/renderable/spheres.ts

@@ -7,17 +7,16 @@
 import { Renderable, RenderableState, createRenderable } from '../renderable';
 import { WebGLContext } from '../webgl/context';
 import { createGraphicsRenderItem, GraphicsRenderVariant } from '../webgl/render-item';
-import { GlobalUniformSchema, BaseSchema, AttributeSpec, Values, InternalSchema, SizeSchema, InternalValues, ElementsSpec, ValueSpec, DefineSpec, GlobalTextureSchema, UniformSpec } from './schema';
+import { GlobalUniformSchema, BaseSchema, Values, InternalSchema, SizeSchema, InternalValues, ValueSpec, DefineSpec, GlobalTextureSchema, UniformSpec, TextureSpec } from './schema';
 import { SpheresShaderCode } from '../shader-code';
 import { ValueCell } from '../../mol-util';
 
 export const SpheresSchema = {
     ...BaseSchema,
     ...SizeSchema,
-    aGroup: AttributeSpec('float32', 1, 0),
-    aPosition: AttributeSpec('float32', 3, 0),
-    aMapping: AttributeSpec('float32', 2, 0),
-    elements: ElementsSpec('uint32'),
+
+    uTexDim: UniformSpec('v2'),
+    tPositionGroup: TextureSpec('image-float32', 'rgba', 'float', 'nearest'),
 
     padding: ValueSpec('number'),
     uDoubleSided: UniformSpec('b', 'material'),
@@ -28,6 +27,9 @@ export const SpheresSchema = {
     dApproximate: DefineSpec('boolean'),
     uBumpFrequency: UniformSpec('f', 'material'),
     uBumpAmplitude: UniformSpec('f', 'material'),
+
+    centerBuffer: ValueSpec('float32'),
+    groupBuffer: ValueSpec('float32'),
 };
 export type SpheresSchema = typeof SpheresSchema
 export type SpheresValues = Values<SpheresSchema>

+ 1 - 2
src/mol-gl/shader/spheres.frag.ts

@@ -19,7 +19,6 @@ precision highp int;
 uniform mat4 uInvView;
 
 varying float vRadius;
-varying float vRadiusSq;
 varying vec3 vPoint;
 varying vec3 vPointViewPosition;
 
@@ -37,7 +36,7 @@ bool SphereImpostor(out vec3 modelPos, out vec3 cameraPos, out vec3 cameraNormal
     vec3 cameraSphereDir = mix(cameraSpherePos, rayOrigin - cameraSpherePos, uIsOrtho);
 
     float B = dot(rayDirection, cameraSphereDir);
-    float det = B * B + vRadiusSq - dot(cameraSphereDir, cameraSphereDir);
+    float det = B * B + vRadius * vRadius - dot(cameraSphereDir, cameraSphereDir);
 
     if (det < 0.0) return false;
 

+ 26 - 12
src/mol-gl/shader/spheres.vert.ts

@@ -18,14 +18,13 @@ precision highp int;
 uniform mat4 uModelView;
 uniform mat4 uInvProjection;
 
-attribute vec3 aPosition;
-attribute vec2 aMapping;
+uniform vec2 uTexDim;
+uniform sampler2D tPositionGroup;
+
 attribute mat4 aTransform;
 attribute float aInstance;
-attribute float aGroup;
 
 varying float vRadius;
-varying float vRadiusSq;
 varying vec3 vPoint;
 varying vec3 vPointViewPosition;
 
@@ -43,7 +42,7 @@ const mat4 D = mat4(
  * "GPU-Based Ray-Casting of Quadratic Surfaces" http://dl.acm.org/citation.cfm?id=2386396
  * by Christian Sigg, Tim Weyrich, Mario Botsch, Markus Gross.
  */
-void quadraticProjection(const in float radius, const in vec3 position){
+void quadraticProjection(const in float radius, const in vec3 position, const in vec2 mapping){
     vec2 xbc, ybc;
 
     mat4 T = mat4(
@@ -69,13 +68,29 @@ void quadraticProjection(const in float radius, const in vec3 position){
     float sy = abs(ybc[0] - ybc[1]) * 0.5;
 
     gl_Position.xy = vec2(0.5 * (xbc.x + xbc.y), 0.5 * (ybc.x + ybc.y));
-    gl_Position.xy -= aMapping * vec2(sx, sy);
+    gl_Position.xy -= mapping * vec2(sx, sy);
     gl_Position.xy *= gl_Position.w;
 }
 
-
 void main(void){
-    #include assign_group
+    vec2 mapping = vec2(1.0, 1.0); // vertices 2 and 5
+    #if __VERSION__ == 100
+        int m = imod(VertexID, 6);
+    #else
+        int m = VertexID % 6;
+    #endif
+    if (m == 0) {
+        mapping = vec2(-1.0, 1.0);
+    } else if (m == 1 || m == 3) {
+        mapping = vec2(-1.0, -1.0);
+    } else if (m == 4) {
+        mapping = vec2(1.0, -1.0);
+    }
+
+    vec4 positionGroup = readFromTexture(tPositionGroup, VertexID / 6, uTexDim);
+    vec3 position = positionGroup.rgb;
+    float group = positionGroup.a;
+
     #include assign_color_varying
     #include assign_marker_varying
     #include assign_clipping_varying
@@ -83,19 +98,18 @@ void main(void){
 
     vRadius = size * matrixScale(uModelView);
 
-    vec4 position4 = vec4(aPosition, 1.0);
+    vec4 position4 = vec4(position, 1.0);
     vec4 mvPosition = uModelView * aTransform * position4;
 
     #ifdef dApproximate
         vec4 mvCorner = vec4(mvPosition.xyz, 1.0);
-        mvCorner.xy += aMapping * vRadius;
+        mvCorner.xy += mapping * vRadius;
         gl_Position = uProjection * mvCorner;
     #else
         gl_Position = uProjection * vec4(mvPosition.xyz, 1.0);
-        quadraticProjection(vRadius, aPosition);
+        quadraticProjection(vRadius, position, mapping);
     #endif
 
-    vRadiusSq = vRadius * vRadius;
     vec4 vPoint4 = uInvProjection * gl_Position;
     vPoint = vPoint4.xyz / vPoint4.w;
     vPointViewPosition = -mvPosition.xyz / mvPosition.w;

+ 167 - 0
src/mol-math/linear-algebra/3d/euler.ts

@@ -0,0 +1,167 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ *
+ * This code has been modified from https://github.com/mrdoob/three.js/,
+ * copyright (c) 2010-2023 three.js authors. MIT License
+ */
+
+import { Mat4 } from './mat4';
+import { assertUnreachable, NumberArray } from '../../../mol-util/type-helpers';
+import { Quat } from './quat';
+import { Vec3 } from './vec3';
+import { clamp } from '../../interpolate';
+
+interface Euler extends Array<number> { [d: number]: number, '@type': 'euler', length: 3 }
+
+function Euler() {
+    return Euler.zero();
+}
+
+namespace Euler {
+    export type Order = 'XYZ' | 'YXZ' | 'ZXY' | 'ZYX' | 'YZX' | 'XZY'
+
+    export function zero(): Euler {
+        // force double backing array by 0.1.
+        const ret = [0.1, 0, 0];
+        ret[0] = 0.0;
+        return ret as any;
+    }
+
+    export function create(x: number, y: number, z: number): Euler {
+        const out = zero();
+        out[0] = x;
+        out[1] = y;
+        out[2] = z;
+        return out;
+    }
+
+    export function set(out: Euler, x: number, y: number, z: number) {
+        out[0] = x;
+        out[0] = y;
+        out[0] = z;
+        return out;
+    }
+
+    export function clone(a: Euler): Euler {
+        const out = zero();
+        out[0] = a[0];
+        out[1] = a[1];
+        out[2] = a[2];
+        return out;
+    }
+
+    export function copy(out: Euler, a: Euler) {
+        out[0] = a[0];
+        out[1] = a[1];
+        out[2] = a[2];
+        return out;
+    }
+
+    /**
+     * Assumes the upper 3x3 of m is a pure rotation matrix (i.e, unscaled)
+     */
+    export function fromMat4(out: Euler, m: Mat4, order: Order): Euler {
+        const m11 = m[0], m12 = m[4], m13 = m[8];
+        const m21 = m[1], m22 = m[5], m23 = m[9];
+        const m31 = m[2], m32 = m[6], m33 = m[10];
+
+        switch (order) {
+            case 'XYZ':
+                out[1] = Math.asin(clamp(m13, -1, 1));
+                if (Math.abs(m13) < 0.9999999) {
+                    out[0] = Math.atan2(-m23, m33);
+                    out[2] = Math.atan2(-m12, m11);
+                } else {
+                    out[0] = Math.atan2(m32, m22);
+                    out[2] = 0;
+                }
+                break;
+            case 'YXZ':
+                out[0] = Math.asin(-clamp(m23, -1, 1));
+                if (Math.abs(m23) < 0.9999999) {
+                    out[1] = Math.atan2(m13, m33);
+                    out[2] = Math.atan2(m21, m22);
+                } else {
+                    out[1] = Math.atan2(-m31, m11);
+                    out[2] = 0;
+                }
+                break;
+            case 'ZXY':
+                out[0] = Math.asin(clamp(m32, -1, 1));
+                if (Math.abs(m32) < 0.9999999) {
+                    out[1] = Math.atan2(-m31, m33);
+                    out[2] = Math.atan2(-m12, m22);
+                } else {
+                    out[1] = 0;
+                    out[2] = Math.atan2(m21, m11);
+                }
+                break;
+            case 'ZYX':
+                out[1] = Math.asin(-clamp(m31, -1, 1));
+                if (Math.abs(m31) < 0.9999999) {
+                    out[0] = Math.atan2(m32, m33);
+                    out[2] = Math.atan2(m21, m11);
+                } else {
+                    out[0] = 0;
+                    out[2] = Math.atan2(-m12, m22);
+                }
+                break;
+            case 'YZX':
+                out[2] = Math.asin(clamp(m21, -1, 1));
+                if (Math.abs(m21) < 0.9999999) {
+                    out[0] = Math.atan2(-m23, m22);
+                    out[1] = Math.atan2(-m31, m11);
+                } else {
+                    out[0] = 0;
+                    out[1] = Math.atan2(m13, m33);
+                }
+                break;
+            case 'XZY':
+                out[2] = Math.asin(-clamp(m12, -1, 1));
+                if (Math.abs(m12) < 0.9999999) {
+                    out[0] = Math.atan2(m32, m22);
+                    out[1] = Math.atan2(m13, m11);
+                } else {
+                    out[0] = Math.atan2(-m23, m33);
+                    out[1] = 0;
+                }
+                break;
+            default:
+                assertUnreachable(order);
+        }
+
+        return out;
+    }
+
+    const _mat4 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] as Mat4;
+    export function fromQuat(out: Euler, q: Quat, order: Order) {
+        Mat4.fromQuat(_mat4, q);
+        return fromMat4(out, _mat4, order);
+    }
+
+    export function fromVec3(out: Euler, v: Vec3) {
+        return set(out, v[0], v[1], v[2]);
+    }
+
+    export function exactEquals(a: Euler, b: Euler) {
+        return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
+    }
+
+    export function fromArray(e: Euler, array: ArrayLike<number>, offset: number) {
+        e[0] = array[offset + 0];
+        e[1] = array[offset + 1];
+        e[2] = array[offset + 2];
+        return e;
+    }
+
+    export function toArray<T extends NumberArray>(e: Euler, out: T, offset: number) {
+        out[offset + 0] = e[0];
+        out[offset + 1] = e[1];
+        out[offset + 2] = e[2];
+        return out;
+    }
+}
+
+export { Euler };

+ 165 - 0
src/mol-math/linear-algebra/3d/mat4.ts

@@ -23,6 +23,7 @@ import { Quat } from './quat';
 import { degToRad } from '../../misc';
 import { NumberArray } from '../../../mol-util/type-helpers';
 import { Mat3 } from './mat3';
+import { Euler } from './euler';
 
 interface Mat4 extends Array<number> { [d: number]: number, '@type': 'mat4', length: 16 }
 interface ReadonlyMat4 extends Array<number> { readonly [d: number]: number, '@type': 'mat4', length: 16 }
@@ -717,6 +718,82 @@ namespace Mat4 {
         return out;
     }
 
+    export function compose(out: Mat4, position: Vec3, quaternion: Quat, scale: Vec3) {
+        const [x, y, z, w] = quaternion;
+        const x2 = x + x,	y2 = y + y, z2 = z + z;
+        const xx = x * x2, xy = x * y2, xz = x * z2;
+        const yy = y * y2, yz = y * z2, zz = z * z2;
+        const wx = w * x2, wy = w * y2, wz = w * z2;
+
+        const [sx, sy, sz] = scale;
+
+        out[0] = (1 - (yy + zz)) * sx;
+        out[1] = (xy + wz) * sx;
+        out[2] = (xz - wy) * sx;
+        out[3] = 0;
+
+        out[4] = (xy - wz) * sy;
+        out[5] = (1 - (xx + zz)) * sy;
+        out[6] = (yz + wx) * sy;
+        out[7] = 0;
+
+        out[8] = (xz + wy) * sz;
+        out[9] = (yz - wx) * sz;
+        out[10] = (1 - (xx + yy)) * sz;
+        out[11] = 0;
+
+        out[12] = position[0];
+        out[13] = position[1];
+        out[14] = position[2];
+        out[15] = 1;
+
+        return out;
+    }
+
+    const _v3 = [0, 0, 0] as Vec3;
+    const _m4 = zero();
+    export function decompose(m: Mat4, position: Vec3, quaternion: Quat, scale: Vec3) {
+
+        let sx = Vec3.magnitude(Vec3.set(_v3, m[0], m[1], m[2]));
+        const sy = Vec3.magnitude(Vec3.set(_v3, m[4], m[5], m[6]));
+        const sz = Vec3.magnitude(Vec3.set(_v3, m[8], m[9], m[10]));
+
+        // if determine is negative, we need to invert one scale
+        const det = determinant(m);
+        if (det < 0) sx = -sx;
+
+        position[0] = m[12];
+        position[1] = m[13];
+        position[2] = m[14];
+
+        // scale the rotation part
+        copy(_m4, m);
+
+        const invSX = 1 / sx;
+        const invSY = 1 / sy;
+        const invSZ = 1 / sz;
+
+        _m4[0] *= invSX;
+        _m4[1] *= invSX;
+        _m4[2] *= invSX;
+
+        _m4[4] *= invSY;
+        _m4[5] *= invSY;
+        _m4[6] *= invSY;
+
+        _m4[8] *= invSZ;
+        _m4[9] *= invSZ;
+        _m4[10] *= invSZ;
+
+        getRotation(quaternion, _m4);
+
+        scale[0] = sx;
+        scale[1] = sy;
+        scale[2] = sz;
+
+        return m;
+    }
+
     export function makeTable(m: Mat4) {
         let ret = '';
         for (let i = 0; i < 4; i++) {
@@ -851,6 +928,94 @@ namespace Mat4 {
         return out;
     }
 
+    export function fromEuler(out: Mat4, euler: Euler, order: Euler.Order) {
+        const x = euler[0], y = euler[1], z = euler[2];
+        const a = Math.cos(x), b = Math.sin(x);
+        const c = Math.cos(y), d = Math.sin(y);
+        const e = Math.cos(z), f = Math.sin(z);
+
+        if (order === 'XYZ') {
+            const ae = a * e, af = a * f, be = b * e, bf = b * f;
+            out[0] = c * e;
+            out[4] = - c * f;
+            out[8] = d;
+            out[1] = af + be * d;
+            out[5] = ae - bf * d;
+            out[9] = - b * c;
+            out[2] = bf - ae * d;
+            out[6] = be + af * d;
+            out[10] = a * c;
+        } else if (order === 'YXZ') {
+            const ce = c * e, cf = c * f, de = d * e, df = d * f;
+            out[0] = ce + df * b;
+            out[4] = de * b - cf;
+            out[8] = a * d;
+            out[1] = a * f;
+            out[5] = a * e;
+            out[9] = - b;
+            out[2] = cf * b - de;
+            out[6] = df + ce * b;
+            out[10] = a * c;
+        } else if (order === 'ZXY') {
+            const ce = c * e, cf = c * f, de = d * e, df = d * f;
+            out[0] = ce - df * b;
+            out[4] = - a * f;
+            out[8] = de + cf * b;
+            out[1] = cf + de * b;
+            out[5] = a * e;
+            out[9] = df - ce * b;
+            out[2] = - a * d;
+            out[6] = b;
+            out[10] = a * c;
+        } else if (order === 'ZYX') {
+            const ae = a * e, af = a * f, be = b * e, bf = b * f;
+            out[0] = c * e;
+            out[4] = be * d - af;
+            out[8] = ae * d + bf;
+            out[1] = c * f;
+            out[5] = bf * d + ae;
+            out[9] = af * d - be;
+            out[2] = - d;
+            out[6] = b * c;
+            out[10] = a * c;
+        } else if (order === 'YZX') {
+            const ac = a * c, ad = a * d, bc = b * c, bd = b * d;
+            out[0] = c * e;
+            out[4] = bd - ac * f;
+            out[8] = bc * f + ad;
+            out[1] = f;
+            out[5] = a * e;
+            out[9] = - b * e;
+            out[2] = - d * e;
+            out[6] = ad * f + bc;
+            out[10] = ac - bd * f;
+        } else if (order === 'XZY') {
+            const ac = a * c, ad = a * d, bc = b * c, bd = b * d;
+            out[0] = c * e;
+            out[4] = - f;
+            out[8] = d * e;
+            out[1] = ac * f + bd;
+            out[5] = a * e;
+            out[9] = ad * f - bc;
+            out[2] = bc * f - ad;
+            out[6] = b * e;
+            out[10] = bd * f + ac;
+        }
+
+        // bottom row
+        out[3] = 0;
+        out[7] = 0;
+        out[11] = 0;
+
+        // last column
+        out[12] = 0;
+        out[13] = 0;
+        out[14] = 0;
+        out[15] = 1;
+
+        return out;
+    }
+
     /**
      * Generates a perspective projection (frustum) matrix with the given bounds
      */

+ 64 - 2
src/mol-math/linear-algebra/3d/quat.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2017-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2017-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>
@@ -25,7 +25,8 @@
 import { Mat3 } from './mat3';
 import { Vec3 } from './vec3';
 import { EPSILON } from './common';
-import { NumberArray } from '../../../mol-util/type-helpers';
+import { assertUnreachable, NumberArray } from '../../../mol-util/type-helpers';
+import { Euler } from './euler';
 
 interface Quat extends Array<number> { [d: number]: number, '@type': 'quat', length: 4 }
 interface ReadonlyQuat extends Array<number> { readonly [d: number]: number, '@type': 'quat', length: 4 }
@@ -238,6 +239,10 @@ namespace Quat {
         return out;
     }
 
+    export function dot(a: Quat, b: Quat) {
+        return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
+    }
+
     /**
      * Creates a quaternion from the given 3x3 rotation matrix.
      *
@@ -277,6 +282,63 @@ namespace Quat {
         return out;
     }
 
+    export function fromEuler(out: Quat, euler: Euler, order: Euler.Order) {
+        const [x, y, z] = euler;
+
+        // http://www.mathworks.com/matlabcentral/fileexchange/20696-function-to-convert-between-dcm-euler-angles-quaternions-and-euler-vectors/content/SpinCalc.m
+
+        const c1 = Math.cos(x / 2);
+        const c2 = Math.cos(y / 2);
+        const c3 = Math.cos(z / 2);
+
+        const s1 = Math.sin(x / 2);
+        const s2 = Math.sin(y / 2);
+        const s3 = Math.sin(z / 2);
+
+        switch (order) {
+            case 'XYZ':
+                out[0] = s1 * c2 * c3 + c1 * s2 * s3;
+                out[1] = c1 * s2 * c3 - s1 * c2 * s3;
+                out[2] = c1 * c2 * s3 + s1 * s2 * c3;
+                out[3] = c1 * c2 * c3 - s1 * s2 * s3;
+                break;
+            case 'YXZ':
+                out[0] = s1 * c2 * c3 + c1 * s2 * s3;
+                out[1] = c1 * s2 * c3 - s1 * c2 * s3;
+                out[2] = c1 * c2 * s3 - s1 * s2 * c3;
+                out[3] = c1 * c2 * c3 + s1 * s2 * s3;
+                break;
+            case 'ZXY':
+                out[0] = s1 * c2 * c3 - c1 * s2 * s3;
+                out[1] = c1 * s2 * c3 + s1 * c2 * s3;
+                out[2] = c1 * c2 * s3 + s1 * s2 * c3;
+                out[3] = c1 * c2 * c3 - s1 * s2 * s3;
+                break;
+            case 'ZYX':
+                out[0] = s1 * c2 * c3 - c1 * s2 * s3;
+                out[1] = c1 * s2 * c3 + s1 * c2 * s3;
+                out[2] = c1 * c2 * s3 - s1 * s2 * c3;
+                out[3] = c1 * c2 * c3 + s1 * s2 * s3;
+                break;
+            case 'YZX':
+                out[0] = s1 * c2 * c3 + c1 * s2 * s3;
+                out[1] = c1 * s2 * c3 + s1 * c2 * s3;
+                out[2] = c1 * c2 * s3 - s1 * s2 * c3;
+                out[3] = c1 * c2 * c3 - s1 * s2 * s3;
+                break;
+            case 'XZY':
+                out[0] = s1 * c2 * c3 - c1 * s2 * s3;
+                out[1] = c1 * s2 * c3 - s1 * c2 * s3;
+                out[2] = c1 * c2 * s3 + s1 * s2 * c3;
+                out[3] = c1 * c2 * c3 + s1 * s2 * s3;
+                break;
+            default:
+                assertUnreachable(order);
+        }
+
+        return out;
+    }
+
     const fromUnitVec3Temp = [0, 0, 0] as Vec3;
     /** Quaternion from two normalized unit vectors. */
     export function fromUnitVec3(out: Quat, a: Vec3, b: Vec3) {

+ 35 - 0
src/mol-math/linear-algebra/_spec/euler.spec.ts

@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { Mat4 } from '../3d/mat4';
+import { Euler } from '../3d/euler';
+import { Quat } from '../3d/quat';
+
+const t = [
+    [Euler.create(0, 0, 0), 'XYZ'],
+    [Euler.create(1, 0, 0), 'XYZ'],
+    [Euler.create(0, 1, 0), 'ZYX'],
+] as const;
+
+describe('Euler', () => {
+    it('fromMat4', () => {
+        for (const [e, o] of t) {
+            const m = Mat4.fromEuler(Mat4(), e, o);
+            const e2 = Euler.fromMat4(Euler(), m, o);
+            const m2 = Mat4.fromEuler(Mat4(), e2, o);
+            expect(Mat4.areEqual(m, m2, 0.0001)).toBe(true);
+        }
+    });
+
+    it('fromQuat', () => {
+        for (const [e, o] of t) {
+            const q = Quat.fromEuler(Quat(), e, o);
+            const e2 = Euler.fromQuat(Euler(), q, o);
+            const q2 = Quat.fromEuler(Quat(), e2, o);
+            expect(Quat.equals(q, q2)).toBe(true);
+        }
+    });
+});

+ 6 - 1
src/mol-model/structure/model/types.ts

@@ -286,7 +286,12 @@ export const AminoAcidNamesD = new Set([
 export const AminoAcidNames = SetUtils.unionMany(AminoAcidNamesL, AminoAcidNamesD);
 
 export const CommonProteinCaps = new Set([
-    'NME', 'ACE'
+    'NME', 'ACE', 'NH2', 'FOR', 'FMT'
+    // not including the following
+    // 'E1H' GFP backbone fragmentation in 2G16
+    // 'HOA' complexes zinc
+    // 'NEH' ubiquitine linker
+    // 'MOH' part of peptidomimetics
 ]);
 
 export const RnaBaseNames = new Set([

+ 7 - 5
src/mol-plugin-state/manager/snapshots.ts

@@ -31,7 +31,8 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<{
     private entryMap = new Map<string, PluginStateSnapshotManager.Entry>();
 
     readonly events = {
-        changed: this.ev()
+        changed: this.ev(),
+        opened: this.ev(),
     };
 
     getIndex(e: PluginStateSnapshotManager.Entry) {
@@ -242,11 +243,11 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<{
                 const snapshot = JSON.parse(data);
 
                 if (PluginStateSnapshotManager.isStateSnapshot(snapshot)) {
-                    return this.setStateSnapshot(snapshot);
+                    await this.setStateSnapshot(snapshot);
                 } else if (PluginStateSnapshotManager.isStateSnapshot(snapshot.data)) {
-                    return this.setStateSnapshot(snapshot.data);
+                    await this.setStateSnapshot(snapshot.data);
                 } else {
-                    this.plugin.state.setSnapshot(snapshot);
+                    await this.plugin.state.setSnapshot(snapshot);
                 }
             } else {
                 const data = await this.plugin.runTask(readFromFile(file, 'zip'));
@@ -270,8 +271,9 @@ class PluginStateSnapshotManager extends StatefulPluginComponent<{
                 }
 
                 const snapshot = JSON.parse(stateData);
-                return this.setStateSnapshot(snapshot);
+                await this.setStateSnapshot(snapshot);
             }
+            this.events.opened.next(void 0);
         } catch (e) {
             console.error(e);
             this.plugin.log.error('Error reading state');

+ 4 - 2
src/mol-repr/structure/visual/element-point.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2022 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 Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -25,6 +25,7 @@ export const ElementPointParams = {
     ignoreHydrogens: PD.Boolean(false),
     ignoreHydrogensVariant: PD.Select('all', PD.arrayToOptions(['all', 'non-polar'] as const)),
     traceOnly: PD.Boolean(false),
+    stride: PD.Numeric(1, { min: 1, max: 100, step: 1 }),
 };
 export type ElementPointParams = typeof ElementPointParams
 
@@ -91,7 +92,8 @@ export function ElementPointVisual(materialId: number): UnitsVisual<ElementPoint
             state.createGeometry = (
                 newProps.ignoreHydrogens !== currentProps.ignoreHydrogens ||
                 newProps.ignoreHydrogensVariant !== currentProps.ignoreHydrogensVariant ||
-                newProps.traceOnly !== currentProps.traceOnly
+                newProps.traceOnly !== currentProps.traceOnly ||
+                newProps.stride !== currentProps.stride
             );
         }
     }, materialId);

+ 7 - 4
src/mol-repr/structure/visual/element-sphere.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2021 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 Alexander Rose <alexander.rose@weirdbyte.de>
  * @author David Sehnal <david.sehnal@gmail.com>
@@ -23,11 +23,12 @@ export const ElementSphereParams = {
     ignoreHydrogensVariant: PD.Select('all', PD.arrayToOptions(['all', 'non-polar'] as const)),
     traceOnly: PD.Boolean(false),
     tryUseImpostor: PD.Boolean(true),
+    stride: PD.Numeric(1, { min: 1, max: 100, step: 1 }),
 };
 export type ElementSphereParams = typeof ElementSphereParams
 
 export function ElementSphereVisual(materialId: number, structure: Structure, props: PD.Values<ElementSphereParams>, webgl?: WebGLContext) {
-    return props.tryUseImpostor && webgl && webgl.extensions.fragDepth
+    return props.tryUseImpostor && webgl && webgl.extensions.fragDepth && webgl.extensions.textureFloat
         ? ElementSphereImpostorVisual(materialId)
         : ElementSphereMeshVisual(materialId);
 }
@@ -43,7 +44,8 @@ export function ElementSphereImpostorVisual(materialId: number): UnitsVisual<Ele
             state.createGeometry = (
                 newProps.ignoreHydrogens !== currentProps.ignoreHydrogens ||
                 newProps.ignoreHydrogensVariant !== currentProps.ignoreHydrogensVariant ||
-                newProps.traceOnly !== currentProps.traceOnly
+                newProps.traceOnly !== currentProps.traceOnly ||
+                newProps.stride !== currentProps.stride
             );
         },
         mustRecreate: (structureGroup: StructureGroup, props: PD.Values<ElementSphereParams>, webgl?: WebGLContext) => {
@@ -65,7 +67,8 @@ export function ElementSphereMeshVisual(materialId: number): UnitsVisual<Element
                 newProps.detail !== currentProps.detail ||
                 newProps.ignoreHydrogens !== currentProps.ignoreHydrogens ||
                 newProps.ignoreHydrogensVariant !== currentProps.ignoreHydrogensVariant ||
-                newProps.traceOnly !== currentProps.traceOnly
+                newProps.traceOnly !== currentProps.traceOnly ||
+                newProps.stride !== currentProps.stride
             );
         },
         mustRecreate: (structureGroup: StructureGroup, props: PD.Values<ElementSphereParams>, webgl?: WebGLContext) => {

+ 2 - 2
src/mol-repr/structure/visual/polymer-backbone-sphere.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2021-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -34,7 +34,7 @@ export const PolymerBackboneSphereParams = {
 export type PolymerBackboneSphereParams = typeof PolymerBackboneSphereParams
 
 export function PolymerBackboneSphereVisual(materialId: number, structure: Structure, props: PD.Values<PolymerBackboneSphereParams>, webgl?: WebGLContext) {
-    return props.tryUseImpostor && webgl && webgl.extensions.fragDepth
+    return props.tryUseImpostor && webgl && webgl.extensions.fragDepth && webgl.extensions.textureFloat
         ? PolymerBackboneSphereImpostorVisual(materialId)
         : PolymerBackboneSphereMeshVisual(materialId);
 }

+ 8 - 3
src/mol-repr/structure/visual/util/element.ts

@@ -29,6 +29,7 @@ type ElementProps = {
     ignoreHydrogens: boolean,
     ignoreHydrogensVariant: 'all' | 'non-polar',
     traceOnly: boolean,
+    stride?: number
 }
 
 export type ElementSphereMeshProps = {
@@ -61,7 +62,7 @@ export function createElementSphereMesh(ctx: VisualContext, unit: Unit, structur
     const childUnit = child?.unitMap.get(unit.id);
     if (child && !childUnit) return Mesh.createEmpty(mesh);
 
-    const { detail, sizeFactor } = props;
+    const { detail, sizeFactor, stride } = props;
 
     const { elements } = unit;
     const elementCount = elements.length;
@@ -78,6 +79,7 @@ export function createElementSphereMesh(ctx: VisualContext, unit: Unit, structur
     let count = 0;
 
     for (let i = 0; i < elementCount; i++) {
+        if (stride && i % stride !== 0) continue;
         if (ignore && ignore(elements[i])) continue;
 
         pos(elements[i], v);
@@ -118,6 +120,8 @@ export function createElementSphereImpostor(ctx: VisualContext, unit: Unit, stru
     const childUnit = child?.unitMap.get(unit.id);
     if (child && !childUnit) return Spheres.createEmpty(spheres);
 
+    const { sizeFactor, stride } = props;
+
     const { elements } = unit;
     const elementCount = elements.length;
     const builder = SpheresBuilder.create(elementCount, elementCount / 2, spheres);
@@ -132,8 +136,9 @@ export function createElementSphereImpostor(ctx: VisualContext, unit: Unit, stru
     let maxSize = 0;
     let count = 0;
 
-    if (ignore || theme.size.granularity !== 'uniform') {
+    if ((stride && stride > 1) || ignore || theme.size.granularity !== 'uniform') {
         for (let i = 0; i < elementCount; i++) {
+            if (stride && i % stride !== 0) continue;
             if (ignore && ignore(elements[i])) continue;
 
             pos(elements[i], v);
@@ -165,7 +170,7 @@ export function createElementSphereImpostor(ctx: VisualContext, unit: Unit, stru
     if (oldBoundingSphere && Vec3.distance(center, oldBoundingSphere.center) / oldBoundingSphere.radius < 1.0) {
         boundingSphere = oldBoundingSphere;
     } else {
-        boundingSphere = Sphere3D.expand(Sphere3D(), (childUnit ?? unit).boundary.sphere, maxSize * props.sizeFactor + 0.05);
+        boundingSphere = Sphere3D.expand(Sphere3D(), (childUnit ?? unit).boundary.sphere, maxSize * sizeFactor + 0.05);
     }
     s.setBoundingSphere(boundingSphere);