Browse Source

Merge branch 'master' into spheres-mem

Alexander Rose 1 year ago
parent
commit
de660cc233

+ 4 - 0
CHANGELOG.md

@@ -7,10 +7,14 @@ 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
 - 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
 

+ 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);

+ 5 - 2
src/mol-repr/structure/visual/element-sphere.ts

@@ -23,6 +23,7 @@ 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
 
@@ -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) => {

+ 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);