mirror of
https://github.com/molstar/molstar.git
synced 2026-06-04 13:30:24 +08:00
Merge branch 'master' of https://github.com/molstar/molstar into instance-granularity-improvements
This commit is contained in:
@@ -7,6 +7,9 @@ Note that since we don't clearly distinguish between a public and private interf
|
||||
- Fix empty transforms default in `ShapeFromPly`
|
||||
- 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
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"js"
|
||||
],
|
||||
"transform": {
|
||||
"\\.ts$": "esbuild-jest-transform"
|
||||
"\\.ts$": ["esbuild-jest-transform", { "tsconfigRaw": "{\"compilerOptions\":{\"useDefineForClassFields\":false}}" }]
|
||||
},
|
||||
"moduleDirectories": [
|
||||
"node_modules",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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'>
|
||||
|
||||
126
src/mol-state/_spec/state.spec.ts
Normal file
126
src/mol-state/_spec/state.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user