diff --git a/CHANGELOG.md b/CHANGELOG.md index ea5c72d0c..39b93620f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Note that since we don't clearly distinguish between a public and private interf ## [Unreleased] - Fix empty transforms default in `ShapeFromPly` - 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 diff --git a/package.json b/package.json index 19c42d62a..39b3071e5 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "js" ], "transform": { - "\\.ts$": "esbuild-jest-transform" + "\\.ts$": ["esbuild-jest-transform", { "tsconfigRaw": "{\"compilerOptions\":{\"useDefineForClassFields\":false}}" }] }, "moduleDirectories": [ "node_modules", diff --git a/src/apps/mesoscale-explorer/data/state.ts b/src/apps/mesoscale-explorer/data/state.ts index 40121e4ae..db31d26cd 100644 --- a/src/apps/mesoscale-explorer/data/state.ts +++ b/src/apps/mesoscale-explorer/data/state.ts @@ -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): 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'; diff --git a/src/mol-geo/geometry/base.ts b/src/mol-geo/geometry/base.ts index f5711dbca..45cc63898 100644 --- a/src/mol-geo/geometry/base.ts +++ b/src/mol-geo/geometry/base.ts @@ -72,6 +72,25 @@ export function getColorSmoothingProps(smoothColors: PD.Values 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('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)); } diff --git a/src/mol-geo/geometry/cylinders/cylinders.ts b/src/mol-geo/geometry/cylinders/cylinders.ts index 2d44e7aa6..43b6bea0b 100644 --- a/src/mol-geo/geometry/cylinders/cylinders.ts +++ b/src/mol-geo/geometry/cylinders/cylinders.ts @@ -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(); diff --git a/src/mol-geo/geometry/direct-volume/direct-volume.ts b/src/mol-geo/geometry/direct-volume/direct-volume.ts index d9fa75c2a..e80e73bf2 100644 --- a/src/mol-geo/geometry/direct-volume/direct-volume.ts +++ b/src/mol-geo/geometry/direct-volume/direct-volume.ts @@ -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(); diff --git a/src/mol-geo/geometry/image/image.ts b/src/mol-geo/geometry/image/image.ts index fff42c10f..b45f38f68 100644 --- a/src/mol-geo/geometry/image/image.ts +++ b/src/mol-geo/geometry/image/image.ts @@ -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(); diff --git a/src/mol-geo/geometry/lines/lines.ts b/src/mol-geo/geometry/lines/lines.ts index 75b52eb8f..4d744d296 100644 --- a/src/mol-geo/geometry/lines/lines.ts +++ b/src/mol-geo/geometry/lines/lines.ts @@ -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(); diff --git a/src/mol-geo/geometry/mesh/mesh.ts b/src/mol-geo/geometry/mesh/mesh.ts index 49f5f57cc..c6843a855 100644 --- a/src/mol-geo/geometry/mesh/mesh.ts +++ b/src/mol-geo/geometry/mesh/mesh.ts @@ -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(); diff --git a/src/mol-geo/geometry/points/points.ts b/src/mol-geo/geometry/points/points.ts index 797a04757..11f6b15b4 100644 --- a/src/mol-geo/geometry/points/points.ts +++ b/src/mol-geo/geometry/points/points.ts @@ -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(); diff --git a/src/mol-geo/geometry/spheres/spheres.ts b/src/mol-geo/geometry/spheres/spheres.ts index 3d99f4b9a..6750ee0bb 100644 --- a/src/mol-geo/geometry/spheres/spheres.ts +++ b/src/mol-geo/geometry/spheres/spheres.ts @@ -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(); diff --git a/src/mol-geo/geometry/text/text.ts b/src/mol-geo/geometry/text/text.ts index 77050d336..0abfb01c3 100644 --- a/src/mol-geo/geometry/text/text.ts +++ b/src/mol-geo/geometry/text/text.ts @@ -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(); diff --git a/src/mol-geo/geometry/texture-mesh/texture-mesh.ts b/src/mol-geo/geometry/texture-mesh/texture-mesh.ts index 3649decfe..e567b9638 100644 --- a/src/mol-geo/geometry/texture-mesh/texture-mesh.ts +++ b/src/mol-geo/geometry/texture-mesh/texture-mesh.ts @@ -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(); diff --git a/src/mol-plugin-state/builder/structure/representation-preset.ts b/src/mol-plugin-state/builder/structure/representation-preset.ts index 3173c963a..8ebcd8f55 100644 --- a/src/mol-plugin-state/builder/structure/representation-preset.ts +++ b/src/mol-plugin-state/builder/structure/representation-preset.ts @@ -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 * @author Alexander Rose @@ -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

extends PresetProvider { } export function StructureRepresentationPresetProvider(repr: StructureRepresentationPresetProvider) { 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('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, 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; \ No newline at end of file diff --git a/src/mol-plugin-ui/controls/parameters.tsx b/src/mol-plugin-ui/controls/parameters.tsx index 4904dc26e..3c87252a7 100644 --- a/src/mol-plugin-ui/controls/parameters.tsx +++ b/src/mol-plugin-ui/controls/parameters.tsx @@ -1414,8 +1414,8 @@ class ObjectListItem extends React.PureComponent, { isExpanded: boolean }> { - state = { isExpanded: false }; +export class ObjectListControl extends React.PureComponent, { 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 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 + ?

+ + +
+ : ; return <> - {value}} /> + + {hasPresets && this.state.showPresets && } {this.state.isExpanded &&
{this.props.value.map((v, i) => )} diff --git a/src/mol-repr/shape/representation.ts b/src/mol-repr/shape/representation.ts index ff326232f..a36e8dd50 100644 --- a/src/mol-repr/shape/representation.ts +++ b/src/mol-repr/shape/representation.ts @@ -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 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 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 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 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(); + +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(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(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: Task) => 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().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().apply(A as any, { value: 1 }); + builder.toRoot().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(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().apply(Throwing as any, { value: 1 }); + builder.toRoot().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(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().apply(NoDispose as any, { value: 1 }); + await state.runTask(state.updateTree(builder)); + + expect(() => state.dispose()).not.toThrow(); + }); +}); diff --git a/src/mol-state/state.ts b/src/mol-state/state.ts index 4c93c74d6..fede7bc2b 100644 --- a/src/mol-state/state.ts +++ b/src/mol-state/state.ts @@ -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).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(); } diff --git a/src/mol-util/param-definition.ts b/src/mol-util/param-definition.ts index fa99b0585..63c939d95 100644 --- a/src/mol-util/param-definition.ts +++ b/src/mol-util/param-definition.ts @@ -295,10 +295,13 @@ export namespace ParamDefinition { type: 'object-list', element: Params, ctor(): T, - getLabel(t: T): string + getLabel(t: T): string, + presets?: Select['options'] } - export function ObjectList(element: For, getLabel: (e: T) => string, info?: Info & { defaultValue?: T[], ctor?: () => T }): ObjectList> { - return setInfo>>({ type: 'object-list', element: element as any as Params, getLabel, ctor: _defaultObjectListCtor, defaultValue: (info?.defaultValue) || [] }, info); + export function ObjectList(element: For, getLabel: (e: T) => string, info?: Info & { defaultValue?: T[], ctor?: () => T, presets?: Select['options'] }): ObjectList> { + const ret = setInfo>>({ 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; }