Merge branch 'master' of https://github.com/molstar/molstar into pr/corredD/1822

This commit is contained in:
Alexander Rose
2026-05-09 16:03:21 -07:00
23 changed files with 403 additions and 81 deletions

View File

@@ -6,6 +6,12 @@ Note that since we don't clearly distinguish between a public and private interf
## [Unreleased]
- Fix empty transforms default in `ShapeFromPly`
- Use morton order for spheres in dot visual with lod-levels
- Add `Camera.changed` event and rotation/translation setter/getter
- Add `instanceGranularity: 'auto'` as a memory guard
- Honor `instanceGranularity` in `Visual.getLoci`
- Add mesoscale representation preset
- Add presets option to `ObjectList` param definition
- Fix memory leak in `State.dispose()` not invoking transformer `dispose` callbacks for live cells
## [v5.9.0] - 2026-05-03
- Fix edge case when `PluginSpec.animations` is empty

View File

@@ -74,7 +74,7 @@
"js"
],
"transform": {
"\\.ts$": "esbuild-jest-transform"
"\\.ts$": ["esbuild-jest-transform", { "tsconfigRaw": "{\"compilerOptions\":{\"useDefineForClassFields\":false}}" }]
},
"moduleDirectories": [
"node_modules",

View File

@@ -22,7 +22,6 @@ import { Hcl } from '../../../mol-util/color/spaces/hcl';
import { StateObjectCell, StateObjectRef, StateSelection } from '../../../mol-state';
import { ShapeRepresentation3D, StructureRepresentation3D } from '../../../mol-plugin-state/transforms/representation';
import { SpacefillRepresentationProvider } from '../../../mol-repr/structure/representation/spacefill';
import { assertUnreachable } from '../../../mol-util/type-helpers';
import { MesoscaleExplorerState } from '../app';
import { saturate } from '../../../mol-math/interpolate';
import { Material } from '../../../mol-util/material';
@@ -322,38 +321,7 @@ export function getMesoscaleGroupParams(graphicsMode: GraphicsMode): MesoscaleGr
export type LodLevels = typeof SpacefillRepresentationProvider.defaultValues['lodLevels']
export function getLodLevels(graphicsMode: Exclude<GraphicsMode, 'custom'>): LodLevels {
switch (graphicsMode) {
case 'performance':
return [
{ minDistance: 1, maxDistance: 300, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 300, maxDistance: 2000, overlap: 0, stride: 40, scaleBias: 3 },
{ minDistance: 2000, maxDistance: 6000, overlap: 0, stride: 150, scaleBias: 3 },
{ minDistance: 6000, maxDistance: 10000000, overlap: 0, stride: 300, scaleBias: 2.5 },
];
case 'balanced':
return [
{ minDistance: 1, maxDistance: 500, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 500, maxDistance: 2000, overlap: 0, stride: 15, scaleBias: 3 },
{ minDistance: 2000, maxDistance: 6000, overlap: 0, stride: 70, scaleBias: 2.7 },
{ minDistance: 6000, maxDistance: 10000000, overlap: 0, stride: 200, scaleBias: 2.5 },
];
case 'quality':
return [
{ minDistance: 1, maxDistance: 1000, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 1000, maxDistance: 4000, overlap: 0, stride: 10, scaleBias: 3 },
{ minDistance: 4000, maxDistance: 10000, overlap: 0, stride: 50, scaleBias: 2.7 },
{ minDistance: 10000, maxDistance: 10000000, overlap: 0, stride: 200, scaleBias: 2.3 },
];
case 'ultra':
return [
{ minDistance: 1, maxDistance: 5000, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 5000, maxDistance: 10000, overlap: 0, stride: 10, scaleBias: 3 },
{ minDistance: 10000, maxDistance: 30000, overlap: 0, stride: 50, scaleBias: 2.5 },
{ minDistance: 30000, maxDistance: 10000000, overlap: 0, stride: 200, scaleBias: 2 },
];
default:
assertUnreachable(graphicsMode);
}
return Spheres.LodLevelsPresets[graphicsMode];
}
export type GraphicsMode = 'ultra' | 'quality' | 'balanced' | 'performance' | 'custom';

View File

@@ -7,7 +7,7 @@
import { Viewport, cameraProject, cameraUnproject } from './camera/util';
import { CameraTransitionManager } from './camera/transition';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, Subject } from 'rxjs';
import { Scene } from '../mol-gl/scene';
import { assertUnreachable } from '../mol-util/type-helpers';
import { Ray3D } from '../mol-math/geometry/primitives/ray3d';
@@ -15,6 +15,7 @@ import { Mat4 } from '../mol-math/linear-algebra/3d/mat4';
import { Vec4 } from '../mol-math/linear-algebra/3d/vec4';
import { Vec3 } from '../mol-math/linear-algebra/3d/vec3';
import { EPSILON } from '../mol-math/linear-algebra/3d/common';
import { Euler } from '../mol-math/linear-algebra/3d/euler';
export type { ICamera };
@@ -42,6 +43,12 @@ interface ICamera {
}
const tmpClip = Vec4();
const tmpForward = Vec3();
const tmpRight = Vec3();
const tmpUp = Vec3();
const tmpBack = Vec3();
const tmpDelta = Vec3();
const tmpRotMat = Mat4.identity();
export class Camera implements ICamera {
readonly view: Mat4 = Mat4.identity();
@@ -70,6 +77,8 @@ export class Camera implements ICamera {
readonly transition: CameraTransitionManager = new CameraTransitionManager(this);
readonly stateChanged = new BehaviorSubject<Partial<Camera.Snapshot>>(this.state);
/** Fires whenever update() produces a changed view/projection (covers all mutations, including direct ones from controls). */
readonly changed = new Subject<void>();
get position() { return this.state.position; }
set position(v: Vec3) { Vec3.copy(this.state.position, v); }
@@ -123,6 +132,7 @@ export class Camera implements ICamera {
Mat4.copy(this.prevView, this.view);
Mat4.copy(this.prevProjection, this.projection);
this.changed.next();
}
return changed;
@@ -237,6 +247,57 @@ export class Camera implements ICamera {
return out;
}
/** How much the camera is rotated around its target. Uses 'ZYX' order. */
getRotation(out: Euler) {
const { position, target, up } = this.state;
Vec3.normalize(tmpForward, Vec3.sub(tmpForward, target, position));
Vec3.normalize(tmpRight, Vec3.cross(tmpRight, tmpForward, up));
Vec3.cross(tmpUp, tmpRight, tmpForward);
Mat4.setIdentity(tmpRotMat);
tmpRotMat[0] = tmpRight[0]; tmpRotMat[1] = tmpRight[1]; tmpRotMat[2] = tmpRight[2];
tmpRotMat[4] = tmpUp[0]; tmpRotMat[5] = tmpUp[1]; tmpRotMat[6] = tmpUp[2];
tmpRotMat[8] = -tmpForward[0]; tmpRotMat[9] = -tmpForward[1]; tmpRotMat[10] = -tmpForward[2];
return Euler.fromMat4(out, tmpRotMat, 'ZYX');
}
/** Set the camera rotation around its target. Expects 'ZYX' order. */
setRotation(rotation: Euler, durationMs?: number) {
const snapshot = this.state as Camera.Snapshot;
const distance = Vec3.distance(snapshot.position, snapshot.target);
Mat4.fromEuler(tmpRotMat, rotation, 'ZYX');
// back = R * (0,0,1) → column 2 of R
Vec3.set(tmpBack, tmpRotMat[8], tmpRotMat[9], tmpRotMat[10]);
// up = R * (0,1,0) → column 1 of R
Vec3.set(tmpUp, tmpRotMat[4], tmpRotMat[5], tmpRotMat[6]);
const state = Camera.copySnapshot(Camera.createDefaultSnapshot(), snapshot);
Vec3.scaleAndAdd(state.position, snapshot.target, tmpBack, distance);
Vec3.copy(state.up, tmpUp);
this.setState(state, durationMs);
}
/** Translation of the camera target relative to world origin (0, 0, 0) */
getTranslation(out: Vec3) {
return Vec3.copy(out, this.state.target);
}
/** Set the camera target to the given translation, moving position by the same delta so orientation/distance are preserved */
setTranslation(translation: Vec3, durationMs?: number) {
const snapshot = this.state as Camera.Snapshot;
Vec3.sub(tmpDelta, translation, snapshot.target);
const state = Camera.copySnapshot(Camera.createDefaultSnapshot(), snapshot);
Vec3.add(state.position, snapshot.position, tmpDelta);
Vec3.copy(state.target, translation);
this.setState(state, durationMs);
}
constructor(state?: Partial<Camera.Snapshot>, viewport = Viewport.create(0, 0, 128, 128)) {
this.viewport = viewport;
Camera.copySnapshot(this.state, state);

View File

@@ -72,6 +72,25 @@ export function getColorSmoothingProps(smoothColors: PD.Values<ColorSmoothingPar
//
export type InstanceGranularityValue = true | false | 'auto'
export const InstanceGranularityOptions: [InstanceGranularityValue, string][] = [[true, 'On'], [false, 'Off'], ['auto', 'Auto']];
/**
* Threshold (in `groupCount * instanceCount`, e.g. number of marker-texture
* slots) above which `instanceGranularity: 'auto'` resolves to `true`.
*/
export const AutoInstanceGranularityThreshold = 50_000_000;
/**
* Resolves the `instanceGranularity` param value to a boolean.
*/
export function resolveInstanceGranularity(value: InstanceGranularityValue, groupCount: number, instanceCount: number): boolean {
if (value === 'auto') return groupCount * instanceCount > AutoInstanceGranularityThreshold;
return value;
}
//
export namespace BaseGeometry {
export const MaterialCategory: PD.Info = { category: 'Material' };
export const ShadingCategory: PD.Info = { category: 'Shading' };
@@ -88,7 +107,7 @@ export namespace BaseGeometry {
clip: PD.Group(Clip.Params),
emissive: PD.Numeric(0, { min: 0, max: 1, step: 0.01 }),
density: PD.Numeric(0.2, { min: 0, max: 1, step: 0.01 }, { description: 'Density value to estimate object thickness.' }),
instanceGranularity: PD.Boolean(false, { description: 'Use instance granularity for marker, transparency, clipping, overpaint, substance data to save memory.' }),
instanceGranularity: PD.Select<InstanceGranularityValue>('auto', InstanceGranularityOptions, { description: 'Use instance granularity for marker, transparency, clipping, overpaint, substance data to save memory. When set to `auto`, granularity is enabled if `groupCount * instanceCount` exceeds `AutoInstanceGranularityThreshold`.' }),
lod: PD.Vec3(Vec3(), undefined, { ...CullingLodCategory, description: 'Level of detail.', fieldLabels: { x: 'Min Distance', y: 'Max Distance', z: 'Overlap (Shader)' } }),
cellSize: PD.Numeric(200, { min: 0, max: 5000, step: 100 }, { ...CullingLodCategory, description: 'Instance grid cell size.' }),
batchSize: PD.Numeric(2000, { min: 0, max: 50000, step: 500 }, { ...CullingLodCategory, description: 'Instance grid batch size.' }),
@@ -130,7 +149,7 @@ export namespace BaseGeometry {
uClipObjectScale: ValueCell.create(clip.objects.scale),
uClipObjectTransform: ValueCell.create(clip.objects.transform),
instanceGranularity: ValueCell.create(props.instanceGranularity),
instanceGranularity: ValueCell.create(resolveInstanceGranularity(props.instanceGranularity, counts.groupCount, counts.instanceCount)),
uLod: ValueCell.create(Vec4.create(props.lod[0], props.lod[1], props.lod[2], 0)),
};
}
@@ -153,7 +172,7 @@ export namespace BaseGeometry {
ValueCell.update(values.uClipObjectScale, clip.objects.scale);
ValueCell.update(values.uClipObjectTransform, clip.objects.transform);
ValueCell.updateIfChanged(values.instanceGranularity, props.instanceGranularity);
ValueCell.updateIfChanged(values.instanceGranularity, resolveInstanceGranularity(props.instanceGranularity, values.uGroupCount.ref.value, values.instanceCount.ref.value));
ValueCell.update(values.uLod, Vec4.set(values.uLod.ref.value, props.lod[0], props.lod[1], props.lod[2], 0));
}

View File

@@ -19,7 +19,7 @@ import { calculateInvariantBoundingSphere, calculateTransformBoundingSphere } fr
import { Sphere3D } from '../../../mol-math/geometry';
import { Theme } from '../../../mol-theme/theme';
import { Color } from '../../../mol-util/color';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createEmptyOverpaint } from '../overpaint-data';
import { createEmptyTransparency } from '../transparency-data';
import { hashFnv32a } from '../../../mol-data/util';
@@ -225,7 +225,7 @@ export namespace Cylinders {
const color = createColors(locationIt, positionIt, theme.color);
const size = createSizes(locationIt, positionIt, theme.size);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -17,7 +17,7 @@ import { ValueCell } from '../../../mol-util';
import { Color } from '../../../mol-util/color';
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { Box } from '../../primitive/box';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createColors } from '../color-data';
import { GeometryUtils } from '../geometry';
import { createMarkers } from '../marker-data';
@@ -228,7 +228,7 @@ export namespace DirectVolume {
const positionIt = createPositionIterator(directVolume, transform);
const color = createColors(locationIt, positionIt, theme.color);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -14,7 +14,7 @@ import { Theme } from '../../../mol-theme/theme';
import { ValueCell } from '../../../mol-util';
import { Color } from '../../../mol-util/color';
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createColors } from '../color-data';
import { GeometryUtils } from '../geometry';
import { createMarkers } from '../marker-data';
@@ -201,7 +201,7 @@ namespace Image {
const positionIt = createPositionIterator(image, transform);
const color = createColors(locationIt, positionIt, theme.color);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -21,7 +21,7 @@ import { calculateInvariantBoundingSphere, calculateTransformBoundingSphere } fr
import { Sphere3D } from '../../../mol-math/geometry';
import { Theme } from '../../../mol-theme/theme';
import { Color } from '../../../mol-util/color';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createEmptyOverpaint } from '../overpaint-data';
import { createEmptyTransparency } from '../transparency-data';
import { hashFnv32a } from '../../../mol-data/util';
@@ -232,7 +232,7 @@ export namespace Lines {
const color = createColors(locationIt, positionIt, theme.color);
const size = createSizes(locationIt, positionIt, theme.size);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -20,7 +20,7 @@ import { calculateInvariantBoundingSphere, calculateTransformBoundingSphere } fr
import { Theme } from '../../../mol-theme/theme';
import { MeshValues } from '../../../mol-gl/renderable/mesh';
import { Color } from '../../../mol-util/color';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createEmptyOverpaint } from '../overpaint-data';
import { createEmptyTransparency } from '../transparency-data';
import { createEmptyClipping } from '../clipping-data';
@@ -684,7 +684,7 @@ export namespace Mesh {
const positionIt = createPositionIterator(mesh, transform);
const color = createColors(locationIt, positionIt, theme.color);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -20,7 +20,7 @@ import { Theme } from '../../../mol-theme/theme';
import { PointsValues } from '../../../mol-gl/renderable/points';
import { RenderableState } from '../../../mol-gl/renderable';
import { Color } from '../../../mol-util/color';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createEmptyOverpaint } from '../overpaint-data';
import { createEmptyTransparency } from '../transparency-data';
import { hashFnv32a } from '../../../mol-data/util';
@@ -178,7 +178,7 @@ export namespace Points {
const color = createColors(locationIt, positionIt, theme.color);
const size = createSizes(locationIt, positionIt, theme.size);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -17,7 +17,7 @@ import { TextureImage, calculateInvariantBoundingSphere, calculateTransformBound
import { Sphere3D } from '../../../mol-math/geometry';
import { createSizes, getMaxSize } from '../size-data';
import { Color } from '../../../mol-util/color';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createEmptyOverpaint } from '../overpaint-data';
import { createEmptyTransparency } from '../transparency-data';
import { hashFnv32a } from '../../../mol-data/util';
@@ -249,6 +249,33 @@ export namespace Spheres {
return lodLevels.map(l => getAdjustedStride(l, sizeFactor)).reverse();
}
export const LodLevelsPresets: { [key in 'performance' | 'balanced' | 'quality' | 'ultra']: LodLevels } = {
performance: [
{ minDistance: 1, maxDistance: 300, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 300, maxDistance: 2000, overlap: 0, stride: 40, scaleBias: 3 },
{ minDistance: 2000, maxDistance: 6000, overlap: 0, stride: 150, scaleBias: 3 },
{ minDistance: 6000, maxDistance: 10000000, overlap: 0, stride: 300, scaleBias: 2.5 },
],
balanced: [
{ minDistance: 1, maxDistance: 500, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 500, maxDistance: 2000, overlap: 0, stride: 15, scaleBias: 3 },
{ minDistance: 2000, maxDistance: 6000, overlap: 0, stride: 70, scaleBias: 2.7 },
{ minDistance: 6000, maxDistance: 10000000, overlap: 0, stride: 200, scaleBias: 2.5 },
],
quality: [
{ minDistance: 1, maxDistance: 1000, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 1000, maxDistance: 4000, overlap: 0, stride: 10, scaleBias: 3 },
{ minDistance: 4000, maxDistance: 10000, overlap: 0, stride: 50, scaleBias: 2.7 },
{ minDistance: 10000, maxDistance: 10000000, overlap: 0, stride: 200, scaleBias: 2.3 },
],
ultra: [
{ minDistance: 1, maxDistance: 5000, overlap: 0, stride: 1, scaleBias: 1 },
{ minDistance: 5000, maxDistance: 10000, overlap: 0, stride: 10, scaleBias: 3 },
{ minDistance: 10000, maxDistance: 30000, overlap: 0, stride: 50, scaleBias: 2.5 },
{ minDistance: 30000, maxDistance: 10000000, overlap: 0, stride: 200, scaleBias: 2 },
],
};
export const Params = {
...BaseGeometry.Params,
sizeFactor: PD.Numeric(1, { min: 0, max: 10, step: 0.1 }),
@@ -273,7 +300,8 @@ export namespace Spheres {
scaleBias: PD.Numeric(3, { min: 0.1, max: 10, step: 0.1 }),
}, o => `${o.stride}`, {
...BaseGeometry.CullingLodCategory,
defaultValue: [] as LodLevels
defaultValue: [] as LodLevels,
presets: Object.entries(LodLevelsPresets).map(([k, v]) => [v, k])
})
};
export type Params = typeof Params
@@ -314,7 +342,7 @@ export namespace Spheres {
const color = createColors(locationIt, positionIt, theme.color);
const size = createSizes(locationIt, positionIt, theme.size);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -25,7 +25,7 @@ import { FontAtlasParams } from './font-atlas';
import { RenderableState } from '../../../mol-gl/renderable';
import { clamp } from '../../../mol-math/interpolate';
import { createRenderObject as _createRenderObject } from '../../../mol-gl/render-object';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createEmptyOverpaint } from '../overpaint-data';
import { createEmptyTransparency } from '../transparency-data';
import { hashFnv32a } from '../../../mol-data/util';
@@ -219,7 +219,7 @@ export namespace Text {
const color = createColors(locationIt, positionIt, theme.color);
const size = createSizes(locationIt, positionIt, theme.size);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -15,7 +15,7 @@ import { createMarkers } from '../marker-data';
import { GeometryUtils } from '../geometry';
import { Theme } from '../../../mol-theme/theme';
import { Color } from '../../../mol-util/color';
import { BaseGeometry } from '../base';
import { BaseGeometry, resolveInstanceGranularity } from '../base';
import { createEmptyOverpaint } from '../overpaint-data';
import { createEmptyTransparency } from '../transparency-data';
import { TextureMeshValues } from '../../../mol-gl/renderable/texture-mesh';
@@ -203,7 +203,7 @@ export namespace TextureMesh {
const positionIt = Utils.createPositionIterator(textureMesh, transform);
const color = createColors(locationIt, positionIt, theme.color);
const marker = props.instanceGranularity
const marker = resolveInstanceGranularity(props.instanceGranularity, groupCount, instanceCount)
? createMarkers(instanceCount, 'instance')
: createMarkers(instanceCount * groupCount, 'groupInstance');
const overpaint = createEmptyOverpaint();

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2019-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2019-2026 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>
@@ -26,6 +26,7 @@ import { StructConn } from '../../../mol-model-formats/structure/property/bonds/
import { StructureRepresentationRegistry } from '../../../mol-repr/structure/registry';
import { assertUnreachable } from '../../../mol-util/type-helpers';
import { Vec3 } from '../../../mol-math/linear-algebra/3d/vec3';
import { Spheres } from '../../../mol-geo/geometry/spheres/spheres';
export interface StructureRepresentationPresetProvider<P = any, S extends _Result = _Result> extends PresetProvider<PluginStateObject.Molecule.Structure, P, S> { }
export function StructureRepresentationPresetProvider<P, S extends _Result>(repr: StructureRepresentationPresetProvider<P, S>) { return repr; }
@@ -495,6 +496,61 @@ const autoLod = StructureRepresentationPresetProvider({
}
});
type MesoscaleGraphicsMode = keyof typeof Spheres.LodLevelsPresets
const MesoscaleGraphicsOptions = PD.arrayToOptions(Object.keys(Spheres.LodLevelsPresets) as MesoscaleGraphicsMode[]);
function getMesoscaleLodLevels(mode: MesoscaleGraphicsMode) {
return Spheres.LodLevelsPresets[mode];
}
const mesoscale = StructureRepresentationPresetProvider({
id: 'preset-structure-representation-mesoscale',
display: {
name: 'Mesoscale', group: 'Miscellaneous',
description: 'Show everything in spacefill representation with instance-granularity and level-of-detail tuned for large particle scenes.'
},
params: () => ({
...CommonParams,
graphics: PD.Select<MesoscaleGraphicsMode>('quality', MesoscaleGraphicsOptions),
}),
async apply(ref, params, plugin) {
const structureCell = StateObjectRef.resolveAndCheck(plugin.state.data, ref);
if (!structureCell) return {};
const components = {
all: await presetStaticComponent(plugin, structureCell, 'all'),
};
const structure = structureCell.obj!.data;
const { update, builder, typeParams, color } = reprBuilder(plugin, params, structure);
const graphics: MesoscaleGraphicsMode = params.graphics ?? 'quality';
const lodLevels = getMesoscaleLodLevels(graphics);
const approximate = graphics !== 'quality' && graphics !== 'ultra';
const alphaThickness = graphics === 'performance' ? 15 : 12;
const representations = {
all: builder.buildRepresentation(update, components.all, {
type: 'spacefill',
typeParams: {
...typeParams,
instanceGranularity: true,
lodLevels,
approximate,
alphaThickness,
clipPrimitive: true,
},
color: color || 'entity-id',
}, { tag: 'all' }),
};
await update.commit({ revertOnError: true });
await updateFocusRepr(plugin, structure, params.theme?.focus?.name ?? color, params.theme?.focus?.params);
return { components, representations };
}
});
export function presetStaticComponent(plugin: PluginContext, structure: StateObjectRef<PluginStateObject.Molecule.Structure>, type: StaticStructureComponentType, params?: { label?: string, tags?: string[] }) {
return plugin.builders.structure.tryCreateComponentStatic(structure, type, params);
}
@@ -514,5 +570,6 @@ export const PresetStructureRepresentations = {
illustrative,
'molecular-surface': molecularSurface,
'auto-lod': autoLod,
mesoscale,
};
export type PresetStructureRepresentations = typeof PresetStructureRepresentations;

View File

@@ -1414,8 +1414,8 @@ class ObjectListItem extends React.PureComponent<ObjectListItemProps, { isExpand
}
}
export class ObjectListControl extends React.PureComponent<ParamProps<PD.ObjectList>, { isExpanded: boolean }> {
state = { isExpanded: false };
export class ObjectListControl extends React.PureComponent<ParamProps<PD.ObjectList>, { isExpanded: boolean, showPresets: boolean }> {
state = { isExpanded: false, showPresets: false };
change(value: any) {
this.props.onChange({ name: this.props.name, param: this.props.param, value });
@@ -1459,12 +1459,29 @@ export class ObjectListControl extends React.PureComponent<ParamProps<PD.ObjectL
e.currentTarget.blur();
};
toggleShowPresets = () => this.setState({ showPresets: !this.state.showPresets });
presetItems = memoizeLatest((param: PD.ObjectList) => ActionMenu.createItemsFromSelectOptions(param.presets ?? []));
onSelectPreset: ActionMenu.OnSelect = item => {
this.setState({ showPresets: false });
this.change(item?.value);
};
render() {
const v = this.props.value;
const label = this.props.param.label || camelCaseToWords(this.props.name);
const value = `${v.length} item${v.length !== 1 ? 's' : ''}`;
const hasPresets = !!this.props.param.presets;
const control = hasPresets
? <div className='msp-flex-row'>
<button onClick={this.toggleExpanded} disabled={this.props.isDisabled}>{value}</button>
<IconButton svg={BookmarksOutlinedSvg} onClick={this.toggleShowPresets} toggleState={this.state.showPresets} title='Presets' disabled={this.props.isDisabled} />
</div>
: <button onClick={this.toggleExpanded} disabled={this.props.isDisabled}>{value}</button>;
return <>
<ControlRow label={label} control={<button onClick={this.toggleExpanded} disabled={this.props.isDisabled}>{value}</button>} />
<ControlRow label={label} control={control} />
{hasPresets && this.state.showPresets && <ActionMenu items={this.presetItems(this.props.param)} onSelect={this.onSelectPreset} />}
{this.state.isExpanded && <div className='msp-control-offset'>
{this.props.value.map((v, i) => <ObjectListItem key={i} param={this.props.param} value={v} index={i} actions={this.actions} isDisabled={this.props.isDisabled} />)}
<ControlGroup header='New Item'>

View File

@@ -5,6 +5,7 @@
*/
import { Geometry, GeometryUtils } from '../../mol-geo/geometry/geometry';
import { resolveInstanceGranularity } from '../../mol-geo/geometry/base';
import { Representation } from '../representation';
import { Shape, ShapeGroup } from '../../mol-model/shape';
import { Subject } from 'rxjs';
@@ -129,7 +130,7 @@ export function ShapeRepresentation<D, G extends Geometry, P extends Geometry.Pa
// console.log('update transform')
locationIt = Shape.groupIterator(_shape);
const { instanceCount, groupCount } = locationIt;
if (props.instanceGranularity) {
if (resolveInstanceGranularity(newProps.instanceGranularity, groupCount, instanceCount)) {
createMarkers(instanceCount, 'instance', _renderObject.values);
} else {
createMarkers(instanceCount * groupCount, 'groupInstance', _renderObject.values);
@@ -197,14 +198,15 @@ export function ShapeRepresentation<D, G extends Geometry, P extends Geometry.Pa
}
function lociApply(loci: Loci, apply: (interval: Interval) => boolean) {
const instanceGranularity = resolveInstanceGranularity(currentProps.instanceGranularity, _shape.groupCount, _shape.transforms.length);
if (isEveryLoci(loci) || (Shape.isLoci(loci) && loci.shape === _shape)) {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return apply(Interval.ofBounds(0, _shape.transforms.length));
} else {
return apply(Interval.ofBounds(0, _shape.groupCount * _shape.transforms.length));
}
} else {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return eachInstance(loci, _shape, apply);
} else {
return eachShapeGroup(loci, _shape, apply);
@@ -226,7 +228,8 @@ export function ShapeRepresentation<D, G extends Geometry, P extends Geometry.Pa
getLoci(pickingId: PickingId) {
const { objectId, groupId, instanceId } = pickingId;
if (_renderObject && _renderObject.id === objectId) {
if (groupId === PickingId.Null) {
const instanceGranularity = resolveInstanceGranularity(currentProps.instanceGranularity, _shape.groupCount, _shape.transforms.length);
if (groupId === PickingId.Null || instanceGranularity) {
return Shape.Loci(_shape);
} else {
return ShapeGroup.Loci(_shape, [{ ids: OrderedSet.ofSingleton(groupId), instance: instanceId }]);

View File

@@ -32,6 +32,7 @@ import { Text } from '../../mol-geo/geometry/text/text';
import { SizeTheme } from '../../mol-theme/size';
import { DirectVolume } from '../../mol-geo/geometry/direct-volume/direct-volume';
import { createMarkers } from '../../mol-geo/geometry/marker-data';
import { resolveInstanceGranularity } from '../../mol-geo/geometry/base';
import { StructureParams, StructureMeshParams, StructureTextParams, StructureDirectVolumeParams, StructureLinesParams, StructureCylindersParams, StructureTextureMeshParams, StructureSpheresParams, StructurePointsParams, StructureImageParams } from './params';
import { Clipping } from '../../mol-theme/clipping';
import { TextureMesh } from '../../mol-geo/geometry/texture-mesh/texture-mesh';
@@ -173,7 +174,7 @@ export function ComplexVisual<G extends Geometry, P extends StructureParams & Ge
if (updateState.updateTransform) {
// console.log('update transform')
const { instanceCount, groupCount } = locationIt;
if (newProps.instanceGranularity) {
if (resolveInstanceGranularity(newProps.instanceGranularity, groupCount, instanceCount)) {
createMarkers(instanceCount, 'instance', renderObject.values);
} else {
createMarkers(instanceCount * groupCount, 'groupInstance', renderObject.values);
@@ -237,14 +238,15 @@ export function ComplexVisual<G extends Geometry, P extends StructureParams & Ge
}
function lociApply(loci: Loci, apply: (interval: Interval) => boolean, isMarking: boolean) {
const instanceGranularity = resolveInstanceGranularity(currentProps.instanceGranularity, locationIt.groupCount, locationIt.instanceCount);
if (lociIsSuperset(loci)) {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return apply(Interval.ofBounds(0, locationIt.instanceCount));
} else {
return apply(Interval.ofBounds(0, locationIt.groupCount * locationIt.instanceCount));
}
} else {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return eachInstance(loci, currentStructure, apply);
} else {
return eachLocation(loci, currentStructure, apply, isMarking);
@@ -279,7 +281,11 @@ export function ComplexVisual<G extends Geometry, P extends StructureParams & Ge
finalize(ctx);
},
getLoci(pickingId: PickingId) {
return renderObject ? getLoci(pickingId, currentStructure, renderObject.id) : EmptyLoci;
if (!renderObject) return EmptyLoci;
if (resolveInstanceGranularity(currentProps.instanceGranularity, locationIt.groupCount, locationIt.instanceCount)) {
pickingId = { ...pickingId, groupId: PickingId.Null };
}
return getLoci(pickingId, currentStructure, renderObject.id);
},
eachLocation(cb: LocationCallback) {
locationIt.reset();

View File

@@ -20,6 +20,7 @@ import { Interval } from '../../mol-data/int';
import { LocationCallback, VisualUpdateState } from '../util';
import { ColorTheme } from '../../mol-theme/color';
import { createMarkers } from '../../mol-geo/geometry/marker-data';
import { resolveInstanceGranularity } from '../../mol-geo/geometry/base';
import { MarkerAction } from '../../mol-util/marker-action';
import { ValueCell, deepEqual } from '../../mol-util';
import { createSizes } from '../../mol-geo/geometry/size-data';
@@ -214,7 +215,7 @@ export function UnitsVisual<G extends Geometry, P extends StructureParams & Geom
if (updateState.updateTransform) {
// console.log('update transform');
const { instanceCount, groupCount } = locationIt;
if (newProps.instanceGranularity) {
if (resolveInstanceGranularity(newProps.instanceGranularity, groupCount, instanceCount)) {
createMarkers(instanceCount, 'instance', renderObject.values);
} else {
createMarkers(instanceCount * groupCount, 'groupInstance', renderObject.values);
@@ -313,14 +314,15 @@ export function UnitsVisual<G extends Geometry, P extends StructureParams & Geom
}
function lociApply(loci: Loci, apply: (interval: Interval) => boolean, isMarking: boolean) {
const instanceGranularity = resolveInstanceGranularity(currentProps.instanceGranularity, locationIt.groupCount, locationIt.instanceCount);
if (lociIsSuperset(loci)) {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return apply(Interval.ofBounds(0, locationIt.instanceCount));
} else {
return apply(Interval.ofBounds(0, locationIt.groupCount * locationIt.instanceCount));
}
} else {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return eachInstance(loci, currentStructureGroup, apply);
} else {
return eachLocation(loci, currentStructureGroup, apply, isMarking);
@@ -355,7 +357,11 @@ export function UnitsVisual<G extends Geometry, P extends StructureParams & Geom
finalize(ctx);
},
getLoci(pickingId: PickingId) {
return renderObject ? getLoci(pickingId, currentStructureGroup, renderObject.id) : EmptyLoci;
if (!renderObject) return EmptyLoci;
if (resolveInstanceGranularity(currentProps.instanceGranularity, locationIt.groupCount, locationIt.instanceCount)) {
pickingId = { ...pickingId, groupId: PickingId.Null };
}
return getLoci(pickingId, currentStructureGroup, renderObject.id);
},
eachLocation(cb: LocationCallback) {
locationIt.reset();

View File

@@ -33,7 +33,7 @@ import { Emissive } from '../../mol-theme/emissive';
import { Wiggle } from '../../mol-theme/wiggle';
import { SizeTheme } from '../../mol-theme/size';
import { Sphere3D } from '../../mol-math/geometry/primitives/sphere3d';
import { BaseGeometry } from '../../mol-geo/geometry/base';
import { BaseGeometry, resolveInstanceGranularity } from '../../mol-geo/geometry/base';
export const VolumeParams = {
...BaseGeometry.Params,
@@ -182,7 +182,7 @@ export function VolumeVisual<G extends Geometry, P extends VolumeParams & Geomet
if (updateState.updateTransform || updateState.updateLocation) {
// console.log('update transform');
const { instanceCount, groupCount } = locationIt;
if (newProps.instanceGranularity) {
if (resolveInstanceGranularity(newProps.instanceGranularity, groupCount, instanceCount)) {
createMarkers(instanceCount, 'instance', renderObject.values);
} else {
createMarkers(instanceCount * groupCount, 'groupInstance', renderObject.values);
@@ -279,14 +279,15 @@ export function VolumeVisual<G extends Geometry, P extends VolumeParams & Geomet
}
function lociApply(loci: Loci, apply: (interval: Interval) => boolean) {
const instanceGranularity = resolveInstanceGranularity(currentProps.instanceGranularity, locationIt.groupCount, locationIt.instanceCount);
if (isEveryLoci(loci)) {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return apply(Interval.ofBounds(0, locationIt.instanceCount));
} else {
return apply(Interval.ofBounds(0, locationIt.groupCount * locationIt.instanceCount));
}
} else {
if (currentProps.instanceGranularity) {
if (instanceGranularity) {
return eachInstance(loci, currentVolume, currentKey, apply);
} else {
return eachLocation(loci, currentVolume, currentKey, currentProps, apply, geometry);
@@ -308,7 +309,11 @@ export function VolumeVisual<G extends Geometry, P extends VolumeParams & Geomet
}
},
getLoci(pickingId: PickingId) {
return renderObject ? getLoci(pickingId, currentVolume, currentKey, currentProps, renderObject.id, geometry) : EmptyLoci;
if (!renderObject) return EmptyLoci;
if (resolveInstanceGranularity(currentProps.instanceGranularity, locationIt.groupCount, locationIt.instanceCount)) {
pickingId = { ...pickingId, groupId: PickingId.Null };
}
return getLoci(pickingId, currentVolume, currentKey, currentProps, renderObject.id, geometry);
},
eachLocation(cb: LocationCallback) {
locationIt.reset();

View File

@@ -0,0 +1,126 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*/
import { State, StateObject, StateTransformer } from '../../mol-state';
import { Task } from '../../mol-task';
interface TypeInfo { name: string; typeClass: 'Root' | 'Data' }
const Create = StateObject.factory<TypeInfo>();
class Root extends Create({ name: 'Root', typeClass: 'Root' }) { }
class Leaf extends Create<{ value: number }>({ name: 'Leaf', typeClass: 'Data' }) { }
const NS = 'state-dispose-spec';
let counter = 0;
function leafTransformer(spy: () => void) {
return StateTransformer.create<Root, Leaf, { value: number }>(NS, {
name: `create-leaf-${counter++}`,
from: [Root],
to: [Leaf],
display: { name: 'Create Leaf' },
params: () => ({} as any),
apply({ params }) { return new Leaf({ value: params.value }); },
dispose() { spy(); }
});
}
function chainedTransformer(spy: () => void) {
return StateTransformer.create<Leaf, Leaf, {}>(NS, {
name: `chained-leaf-${counter++}`,
from: [Leaf],
to: [Leaf],
display: { name: 'Chained Leaf' },
apply({ a }) { return new Leaf({ value: a.data.value + 1 }); },
dispose() { spy(); }
});
}
function newState() {
return State.create(new Root({}), { runTask: <T>(t: Task<T>) => t.run() });
}
describe('State.dispose', () => {
it('calls transformer.dispose for every live cell', async () => {
const leafSpy = jest.fn();
const chainSpy = jest.fn();
const A = leafTransformer(leafSpy);
const B = chainedTransformer(chainSpy);
const state = newState();
const builder = state.build();
builder.toRoot<Root>().apply(A as any, { value: 1 }).apply(B as any, {});
await state.runTask(state.updateTree(builder));
// root + 2 transformer outputs.
expect(state.cells.size).toBe(3);
state.dispose();
expect(leafSpy).toHaveBeenCalledTimes(1);
expect(chainSpy).toHaveBeenCalledTimes(1);
});
it('disposes all sibling subtrees', async () => {
const spyA = jest.fn();
const spyB = jest.fn();
const A = leafTransformer(spyA);
const B = leafTransformer(spyB);
const state = newState();
const builder = state.build();
builder.toRoot<Root>().apply(A as any, { value: 1 });
builder.toRoot<Root>().apply(B as any, { value: 2 });
await state.runTask(state.updateTree(builder));
state.dispose();
expect(spyA).toHaveBeenCalledTimes(1);
expect(spyB).toHaveBeenCalledTimes(1);
});
it('does not throw when a transformer dispose throws', async () => {
const goodSpy = jest.fn();
const Throwing = StateTransformer.create<Root, Leaf, { value: number }>(NS, {
name: `throwing-leaf-${counter++}`,
from: [Root],
to: [Leaf],
display: { name: 'Throwing Leaf' },
apply({ params }) { return new Leaf({ value: params.value }); },
dispose() { throw new Error('boom'); }
});
const Good = leafTransformer(goodSpy);
const state = newState();
const builder = state.build();
builder.toRoot<Root>().apply(Throwing as any, { value: 1 });
builder.toRoot<Root>().apply(Good as any, { value: 2 });
await state.runTask(state.updateTree(builder));
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
try {
expect(() => state.dispose()).not.toThrow();
} finally {
warn.mockRestore();
}
expect(goodSpy).toHaveBeenCalledTimes(1);
});
it('is a no-op for transformers without a dispose definition', async () => {
const NoDispose = StateTransformer.create<Root, Leaf, { value: number }>(NS, {
name: `no-dispose-${counter++}`,
from: [Root],
to: [Leaf],
display: { name: 'No-dispose Leaf' },
apply({ params }) { return new Leaf({ value: params.value }); }
});
const state = newState();
const builder = state.build();
builder.toRoot<Root>().apply(NoDispose as any, { value: 1 });
await state.runTask(state.updateTree(builder));
expect(() => state.dispose()).not.toThrow();
});
});

View File

@@ -159,6 +159,23 @@ class State {
}
dispose() {
// Dispose every still-live cell so transformer dispose callbacks
// (e.g. WebGL/GL buffer cleanup) actually run. Without this,
// calling dispose() on a State that still has cells leaks any
// resources held by transformer dispose callbacks because they
// would only fire on per-cell deletion (see updateNode/findDeletes).
const refs: StateTransform.Ref[] = [];
StateTree.doPostOrder(this._tree, this._tree.root, { refs }, (n, _, s) => { s.refs.push(n.ref); });
for (let i = refs.length - 1; i >= 0; i--) {
const cell = (this.cells as Map<StateTransform.Ref, StateObjectCell>).get(refs[i]);
if (!cell) continue;
try {
dispose(cell.transform, cell.obj, cell.transform.params, cell.cache, this.globalContext);
} catch (e) {
console.warn('Error in transformer dispose during State.dispose', e);
}
}
this.ev.dispose();
this.actions.dispose();
}

View File

@@ -295,10 +295,13 @@ export namespace ParamDefinition {
type: 'object-list',
element: Params,
ctor(): T,
getLabel(t: T): string
getLabel(t: T): string,
presets?: Select<T[]>['options']
}
export function ObjectList<T>(element: For<T>, getLabel: (e: T) => string, info?: Info & { defaultValue?: T[], ctor?: () => T }): ObjectList<Normalize<T>> {
return setInfo<ObjectList<Normalize<T>>>({ type: 'object-list', element: element as any as Params, getLabel, ctor: _defaultObjectListCtor, defaultValue: (info?.defaultValue) || [] }, info);
export function ObjectList<T>(element: For<T>, getLabel: (e: T) => string, info?: Info & { defaultValue?: T[], ctor?: () => T, presets?: Select<T[]>['options'] }): ObjectList<Normalize<T>> {
const ret = setInfo<ObjectList<Normalize<T>>>({ type: 'object-list', element: element as any as Params, getLabel, ctor: _defaultObjectListCtor, defaultValue: (info?.defaultValue) || [] }, info);
if (info?.presets) ret.presets = info.presets as any;
return ret;
}
function _defaultObjectListCtor(this: ObjectList) { return getDefaultValues(this.element) as any; }