Compare commits

...

42 Commits

Author SHA1 Message Date
dsehnal
fa63209384 refactoring 2026-05-30 17:02:09 +02:00
dsehnal
f238b00ef9 transition easing, refactoring 2026-05-29 12:04:07 +02:00
dsehnal
1ac4980348 add comment 2026-05-29 10:55:35 +02:00
dsehnal
7ade6ab59b udpate ts config 2026-05-28 16:27:00 +02:00
dsehnal
cb0fb2b0b5 fix build 2026-05-28 15:56:48 +02:00
dsehnal
206ee19138 camera focus "zoom out" 2026-05-28 15:47:51 +02:00
dsehnal
43ce4ab498 improvements 2026-05-28 12:28:08 +02:00
dsehnal
57cce9f80f changelog 2026-05-27 18:27:36 +02:00
dsehnal
9be847c74b optimize camera direction 2026-05-27 18:20:25 +02:00
Alexander Rose
055dfd4946 Merge pull request #1840 from giagitom/fix-premul-rgb
Fix exported image artifacts on transparent background
2026-05-26 21:58:08 -07:00
giagitom
9de8334af5 Fix exported image artifacts on transparent background 2026-05-26 13:05:24 +02:00
Alexander Rose
57580a5e6b Merge pull request #1836 from giagitom/fix-cel-shading-ambient-color
Fix cel-shaded ambient color being stripped to luminance
2026-05-23 21:50:07 -07:00
giagitom
7da4a85459 Fix cel-shaded ambient color being stripped to luminance 2026-05-19 16:43:00 +02:00
Alexander Rose
27f251e8e4 Merge pull request #1832 from molstar/ssao-multi-fix
Fix SSAO half/quarter resolution textures for multi-scale
2026-05-17 20:15:09 -07:00
Alexander Rose
8d2a44983e remove superfluous enableAnimation param 2026-05-16 22:29:55 -07:00
Alexander Rose
63a585d88a Merge pull request #1830 from josemduarte/ms-fix-omitwater
Fix ModelServer bugs for omitWater param in surroundingLigands endpoint
2026-05-16 22:24:58 -07:00
Alexander Rose
a4b5a16fcd Merge branch 'master' into ms-fix-omitwater 2026-05-16 22:22:42 -07:00
Alexander Rose
86bf859a63 Fix SSAO half/quarter resolution textures for multi-scale 2026-05-16 22:15:12 -07:00
Alexander Rose
1b8117d3f1 Fix Volume and Isosurface getBoundingSphere ignoring instances 2026-05-10 17:18:18 -07:00
Alexander Rose
400e2bbc45 Merge pull request #1822 from corredD/codex/dot-morton-spheres 2026-05-10 08:42:22 -07:00
Jose Duarte
e2e26c7e9c Updating changelog 2026-05-09 22:28:38 -07:00
Jose Duarte
5ca9020cbf mol-model: fix water leak in surroundingLigands query
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:21:49 -07:00
Jose Duarte
ea4c411d5c model-server: fix omit_water boolean parsing for REST GET requests
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:21:49 -07:00
Alexander Rose
ba7e3fe827 Merge branch 'master' of https://github.com/molstar/molstar into pr/corredD/1822 2026-05-09 16:03:21 -07:00
Alexander Rose
8f20571a17 Merge pull request #1827 from molstar/camera-changed-event
Camera helpers
2026-05-09 16:02:30 -07:00
Alexander Rose
c25a4247e6 Merge branch 'master' of https://github.com/molstar/molstar into camera-changed-event 2026-05-09 15:57:47 -07:00
Alexander Rose
1071d3d8ba Merge pull request #1828 from molstar/instance-granularity-improvements
Instance granularity improvements
2026-05-09 15:56:36 -07:00
Alexander Rose
e8dc046570 Merge branch 'master' of https://github.com/molstar/molstar into instance-granularity-improvements 2026-05-09 15:54:00 -07:00
Alexander Rose
27f9c2aa67 Merge pull request #1829 from molstar/mesoscale-preset
Mesoscale preset
2026-05-09 15:53:29 -07:00
Alexander Rose
a4962231c8 revert 2026-05-09 15:51:05 -07:00
Alexander Rose
8833f29ce5 Merge branch 'master' of https://github.com/molstar/molstar into mesoscale-preset 2026-05-09 15:44:01 -07:00
Alexander Rose
40b6038380 type tweak 2026-05-09 15:43:04 -07:00
Armando Pellegrini
59e16e0187 Fix State.dispose() not invoking transformer dispose for live cells (#1826)
`Transformer.Definition.dispose` is documented as "automatically called
on deleting an object," but `State.dispose()` only disposed its own event
subjects and action manager — it never iterated still-live cells to call
their per-transformer dispose. Cells holding GL buffers, mesh data, etc.
only had their dispose fired on explicit deletion (e.g. `clear()`), so
any consumer that called `plugin.dispose()` without first awaiting
`plugin.clear()` retained the callback chain, the GL buffers it points
at, and any closures captured by it.

In a long-running single-page app where the user navigates between
routes that mount/unmount a Mol* viewer, this leaked roughly 25–50 MB
of process RSS per cycle even with `plugin.dispose()` correctly called.
A 20-cycle E2E mount/unmount harness on a 1AKE structure measured a
+541 MB RSS / +266 MB JS-heap delta in the unconditional-`dispose()`
case; calling `await plugin.clear()` before `plugin.dispose()` halved
the residual leak, confirming the per-cell dispose path was missing on
the unconditional `dispose()` route.

This change walks the cell tree once (post-order via the existing
`StateTree.doPostOrder` helper) and invokes the per-transformer dispose
for every still-live cell, swallowing+warning on errors so a single
faulty transformer can't prevent siblings from cleaning up. The
existing per-cell `dispose` helper is reused for consistency with
`updateNode`/`findDeletes` semantics.

Tests cover: chained transformers, sibling subtrees, throwing-dispose
isolation, and transformers without a dispose definition.

Also adds `useDefineForClassFields: false` to the jest esbuild
transform so tests can construct `State` (the `TransientTree` parameter
property + class field pattern relies on legacy class-field semantics,
which `tsc` honors via `target: es2018` but esbuild's default `esnext`
target does not).

Fixes #1825

Co-authored-by: Armando Pellegrini <tech.tools@boltz.bio>
2026-05-09 22:17:38 +02:00
Alexander Rose
ca5a50bd53 changelog 2026-05-09 12:36:38 -07:00
Alexander Rose
bccf54fabe avoid extra allocations 2026-05-09 12:36:32 -07:00
Alexander Rose
57a790544c Add mesoscale representation preset 2026-05-09 12:31:26 -07:00
Alexander Rose
df0669598c Add presets option to ObjectList param definition 2026-05-09 12:31:11 -07:00
Alexander Rose
fb912036af Merge branch 'master' of https://github.com/molstar/molstar into pr/corredD/1822 2026-05-09 08:25:26 -07:00
Alexander Rose
9efb5cd126 Add Camera.changed event and rotation/translation setter/getter 2026-05-09 08:24:26 -07:00
Alexander Rose
08a56ad6ab Instance granularity improvements
- Add `instanceGranularity: 'auto'` as a memory guard
- Honor `instanceGranularity` in `Visual.getLoci`
2026-05-09 08:10:58 -07:00
Alexander Rose
d7ad5a6e9f Fix empty transforms default in ShapeFromPly 2026-05-04 23:14:02 -07:00
Ludovic Autin
19fec3bbc1 Order DOT spheres by Morton index
Add DOT sphere impostors in Morton order so sphere LOD stride sampling remains spatially distributed.
2026-05-02 10:48:31 -07:00
55 changed files with 1240 additions and 261 deletions

View File

@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file, following t
Note that since we don't clearly distinguish between a public and private interfaces there will be changes in non-major versions that are potentially breaking. If we make breaking changes to less used interfaces we will highlight it in here.
## [Unreleased]
- Fix exported image artifacts on transparent background with emissive, bloom, or antialiasing
- Fix cel-shaded ambient color being stripped to luminance (now uses full RGB, matching the classic lighting path)
- 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
- Fix bugs in ModelServer surroundingLigands endpoint, resulting in omitWater not honored
- Fix `Volume` and `Isosurface` getBoundingSphere ignoring instances
- Fix SSAO half/quarter resolution textures for multi-scale
- Camera improvements
- Add the option to approximate "least obstructed direction" when focusing camera, accessibe via `PluginContext.managers.camera.focusLoci` with `optimizeDirection` option
- Add `CameraFocusOptions.zoomOut` option that zooms out to to make the entire scene visible before focusing on the target
- Add easing support in camera transtion
## [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

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-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>
@@ -51,7 +51,7 @@ export { consoleStats, isDebugMode, isProductionMode, isTimingMode, setDebugMode
import { decodeColor } from '../../mol-util/color/utils';
import '../../mol-util/polyfill';
import { ViewerAutoPreset } from './presets';
import { CameraFocusOptions } from '../../mol-plugin-state/manager/camera';
import { CameraFocusLociOptions } from '../../mol-plugin-state/manager/camera';
import { PluginSpec } from '../../mol-plugin/spec';
import { NoPrimaryFocusLociBindings } from '../../mol-plugin/behavior/dynamic/camera';
@@ -535,26 +535,33 @@ export class Viewer {
* If neither `expression` nor `elements` are provided, all selections/highlights
* will be cleared based on the specified `action`.
*/
structureInteractivity({ expression, elements, action, applyGranularity = false, filterStructure, focusOptions }: {
structureInteractivity({ expression, elements, action: action_, applyGranularity = false, filterStructure, focusOptions }: {
expression?: (queryBuilder: typeof MolScriptBuilder) => Expression,
elements?: StructureElement.Schema,
action: 'highlight' | 'select' | 'focus',
action: 'highlight' | 'select' | 'focus' | ('highlight' | 'select' | 'focus')[],
applyGranularity?: boolean,
filterStructure?: (structure: Structure) => boolean,
focusOptions?: Partial<CameraFocusOptions>
focusOptions?: Partial<CameraFocusLociOptions>
}) {
const plugin = this.plugin;
const actions = Array.isArray(action_) ? action_ : [action_];
if (!expression && !elements) {
if (action === 'select') {
if (actions.includes('select')) {
plugin.managers.interactivity.lociSelects.deselectAll();
} else if (action === 'highlight') {
}
if (actions.includes('highlight')) {
plugin.managers.interactivity.lociHighlights.clearHighlights();
}
return;
}
if (actions.includes('select')) {
plugin.managers.interactivity.lociSelects.deselectAll();
}
const structures = this.plugin.state.data.selectQ(Q => Q.rootsOfType(PluginStateObject.Molecule.Structure));
let focused = false;
for (const s of structures) {
if (!s.obj?.data) continue;
@@ -564,13 +571,16 @@ export class Viewer {
? StructureElement.Loci.fromExpression(s.obj.data, expression)
: StructureElement.Loci.fromSchema(s.obj.data, elements!);
if (action === 'select') {
plugin.managers.interactivity.lociSelects.select({ loci }, applyGranularity);
} else if (action === 'highlight') {
plugin.managers.interactivity.lociHighlights.highlight({ loci }, applyGranularity);
} else if (action === 'focus' && !StructureElement.Loci.isEmpty(loci)) {
plugin.managers.camera.focusLoci(loci, focusOptions);
return;
for (const action of actions) {
if (action === 'select') {
plugin.managers.interactivity.lociSelects.select({ loci }, applyGranularity);
} else if (action === 'highlight') {
plugin.managers.interactivity.lociHighlights.highlight({ loci }, applyGranularity);
} else if (action === 'focus' && !StructureElement.Loci.isEmpty(loci) && !focused) {
plugin.managers.camera.focusLoci(loci, focusOptions);
focused = true;
if (actions.length === 1) return; // if only focusing, focus the first matching structure and return immediately
}
}
}
}

View File

@@ -6,7 +6,7 @@
*/
import { SortedArray } from '../../../mol-data/int';
import * as EasingFns from '../../../mol-math/easing';
import { EasingFunctions } from '../../../mol-math/easing';
import { clamp, lerp } from '../../../mol-math/interpolate';
import { EPSILON, Mat3, Mat4, Quat, Vec3 } from '../../../mol-math/linear-algebra';
import { RuntimeContext } from '../../../mol-task';
@@ -65,27 +65,7 @@ export async function generateStateTransition(ctx: RuntimeContext, snapshot: Sna
return { tree, frametimeMs: dt, frames };
}
const EasingFnMap: Record<MVSAnimationEasing, (t: number) => number> = {
'linear': t => t,
'bounce-in': EasingFns.bounceIn,
'bounce-out': EasingFns.bounceOut,
'bounce-in-out': EasingFns.bounceInOut,
'circle-in': EasingFns.circleIn,
'circle-out': EasingFns.circleOut,
'circle-in-out': EasingFns.circleInOut,
'cubic-in': EasingFns.cubicIn,
'cubic-out': EasingFns.cubicOut,
'cubic-in-out': EasingFns.cubicInOut,
'exp-in': EasingFns.expIn,
'exp-out': EasingFns.expOut,
'exp-in-out': EasingFns.expInOut,
'quad-in': EasingFns.quadIn,
'quad-out': EasingFns.quadOut,
'quad-in-out': EasingFns.quadInOut,
'sin-in': EasingFns.sinIn,
'sin-out': EasingFns.sinOut,
'sin-in-out': EasingFns.sinInOut,
};
const EasingFnMap: Record<MVSAnimationEasing, (t: number) => number> = EasingFunctions;
interface InterpolationCacheEntry {
paletteFn?: (value: number) => Color,

View File

@@ -1,13 +1,13 @@
/**
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-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>
*/
import { Viewport, cameraProject, cameraUnproject } from './camera/util';
import { CameraTransitionManager } from './camera/transition';
import { BehaviorSubject } from 'rxjs';
import { CameraTransitionManager, CameraTransitionOptions } from './camera/transition';
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,13 +132,18 @@ export class Camera implements ICamera {
Mat4.copy(this.prevView, this.view);
Mat4.copy(this.prevProjection, this.projection);
this.changed.next();
}
return changed;
}
setState(snapshot: Partial<Camera.Snapshot>, durationMs?: number) {
this.transition.apply(snapshot, durationMs);
setState(
snapshot: Partial<Camera.Snapshot>,
durationMs?: number,
options?: CameraTransitionOptions
) {
this.transition.apply(snapshot, durationMs, undefined, options);
this.stateChanged.next(snapshot);
}
@@ -237,6 +251,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

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2024 Mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2026 Mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
@@ -8,9 +8,17 @@ import { Camera } from '../camera';
import { lerp } from '../../mol-math/interpolate';
import { Quat } from '../../mol-math/linear-algebra/3d/quat';
import { Vec3 } from '../../mol-math/linear-algebra/3d/vec3';
import { EasingFunction, getEasingFn } from '../../mol-math/easing';
export { CameraTransitionManager };
export interface CameraTransitionOptions {
/** If present, approximates the transion between, [current] -> [keyframes] -> -> [target] */
keyframes?: CameraTransitionManager.TransitionKeyframes,
/** Global easing, if easing is specified for keyframes, the "end" frame value is used */
easing?: EasingFunction
}
class CameraTransitionManager {
private t = 0;
@@ -20,12 +28,18 @@ class CameraTransitionManager {
private durationMs = 0;
private _source: Camera.Snapshot = Camera.createDefaultSnapshot();
private _target: Camera.Snapshot = Camera.createDefaultSnapshot();
private _options: CameraTransitionOptions | undefined = void 0;
private _current = Camera.createDefaultSnapshot();
get source(): Readonly<Camera.Snapshot> { return this._source; }
get target(): Readonly<Camera.Snapshot> { return this._target; }
apply(to: Partial<Camera.Snapshot>, durationMs: number = 0, transition?: CameraTransitionManager.TransitionFunc) {
apply(
to: Partial<Camera.Snapshot>,
durationMs: number = 0,
transition?: CameraTransitionManager.TransitionFunc,
options?: CameraTransitionOptions,
) {
if (!this.inTransition || durationMs > 0) {
Camera.copySnapshot(this._source, this.camera.state);
}
@@ -50,6 +64,7 @@ class CameraTransitionManager {
this.inTransition = true;
this.func = transition || CameraTransitionManager.defaultTransition;
this._options = options;
if (!this.inTransition || durationMs > 0) {
this.start = this.t;
@@ -76,7 +91,7 @@ class CameraTransitionManager {
return;
}
this.func(this._current, normalized, this._source, this._target);
this.func(this._current, normalized, this._source, this._target, this._options);
Camera.copySnapshot(this.camera.state, this._current);
}
@@ -86,7 +101,8 @@ class CameraTransitionManager {
}
namespace CameraTransitionManager {
export type TransitionFunc = (out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot) => void
export type TransitionKeyframes = { t: number, snapshot: Partial<Camera.Snapshot>, easing?: EasingFunction }[]
export type TransitionFunc = (out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot, options?: { keyframes?: TransitionKeyframes }) => void
const _rotUp = Quat.identity();
const _rotDist = Quat.identity();
@@ -94,7 +110,58 @@ namespace CameraTransitionManager {
const _sourcePosition = Vec3();
const _targetPosition = Vec3();
export function defaultTransition(out: Camera.Snapshot, t: number, source: Camera.Snapshot, target: Camera.Snapshot): void {
let _tempSource: Camera.Snapshot | undefined = void 0;
let _tempTarget: Camera.Snapshot | undefined = void 0;
export function defaultTransition(
out: Camera.Snapshot,
t_: number,
source_: Camera.Snapshot,
target_: Camera.Snapshot,
options?: CameraTransitionOptions
): void {
let sourcePartial: Partial<Camera.Snapshot> = source_;
let targetPartial: Partial<Camera.Snapshot> = target_;
let tStart = 0;
let tEnd = 1;
let easingKind = options?.easing;
const keyframes = options?.keyframes;
if (keyframes && keyframes.length > 0) {
for (let i = 0; i < keyframes.length; i++) {
const keyframe = keyframes[i];
if (t_ >= keyframe.t) {
sourcePartial = keyframe.snapshot;
tStart = keyframe.t;
break;
}
}
for (let i = 0; i < keyframes.length; i++) {
const keyframe = keyframes[i];
if (keyframe.t >= t_) {
targetPartial = keyframe.snapshot;
tEnd = keyframe.t;
easingKind = keyframe.easing ?? easingKind;
break;
}
}
}
const easing = getEasingFn(easingKind);
const t = easing((t_ - tStart) / (tEnd - tStart));
if (!_tempSource) _tempSource = Camera.createDefaultSnapshot();
if (!_tempTarget) _tempTarget = Camera.createDefaultSnapshot();
Camera.copySnapshot(_tempSource, source_);
Camera.copySnapshot(_tempSource, sourcePartial);
Camera.copySnapshot(_tempTarget, target_);
Camera.copySnapshot(_tempTarget, targetPartial);
const source = _tempSource;
const target = _tempTarget;
Camera.copySnapshot(out, target);
// Rotate up

View File

@@ -53,6 +53,8 @@ import { RayHelper } from './helper/ray-helper';
import { produce } from '../mol-util/produce';
import { ShaderManager } from './helper/shader-manager';
import { toFixed } from '../mol-util/number';
import type { CameraTransitionManager } from './camera/transition';
import { EasingFunction } from '../mol-math/easing';
export const CameraFogParams = {
intensity: PD.Numeric(15, { min: 1, max: 100, step: 1 }),
@@ -98,7 +100,6 @@ export const Canvas3DParams = {
transparentBackground: PD.Boolean(false),
checkeredTransparentBackground: PD.Boolean(false),
dpoitIterations: PD.Numeric(2, { min: 1, max: 10, step: 1 }),
enableAnimation: PD.Boolean(true, { description: 'Enable GPU time-based animations (wiggle/tumble).' }),
pickPadding: PD.Numeric(3, { min: 0, max: 10, step: 1 }, { description: 'Extra pixels to around target to check in case target is empty.' }),
userInteractionReleaseMs: PD.Numeric(250, { min: 0, max: 1000, step: 1 }, { description: 'The time before the user is not considered interacting anymore.' }),
@@ -322,6 +323,13 @@ namespace Canvas3DContext {
export { Canvas3D };
export interface Canvas3DCameraResetOptions {
durationMs?: number,
snapshot?: Camera.SnapshotProvider,
keyframes?: CameraTransitionManager.TransitionKeyframes,
easing?: EasingFunction,
}
interface Canvas3D {
readonly webgl: WebGLContext,
@@ -372,7 +380,7 @@ interface Canvas3D {
/** performs handleResize on the next animation frame */
requestResize(): void
/** Focuses camera on scene's bounding sphere, centered and zoomed. */
requestCameraReset(options?: { durationMs?: number, snapshot?: Camera.SnapshotProvider }): void
requestCameraReset(options?: Canvas3DCameraResetOptions): void
readonly camera: Camera
readonly boundingSphere: Readonly<Sphere3D>
readonly boundingSphereVisible: Readonly<Sphere3D>
@@ -480,7 +488,6 @@ namespace Canvas3D {
const hiZ = new HiZPass(webgl, passes.draw, canvas, p.hiZ);
const renderer = Renderer.create(webgl, p.renderer);
renderer.setProps({ enableAnimation: p.enableAnimation });
renderer.setOcclusionTest(hiZ.isOccluded);
const shaderManager = new ShaderManager(webgl, scene);
@@ -500,8 +507,12 @@ namespace Canvas3D {
});
let cameraResetRequested = false;
let nextCameraResetDuration: number | undefined = void 0;
let nextCameraResetSnapshot: Camera.SnapshotProvider | undefined = void 0;
const nextCameraResetOptions: Canvas3DCameraResetOptions = {
durationMs: undefined,
snapshot: undefined,
keyframes: undefined,
easing: undefined,
};
let resizeRequested = false;
//
@@ -677,7 +688,7 @@ namespace Canvas3D {
const xrChanged = xrManager.update(xrFrame);
if (!xrChanged && xrFrame) return false;
const activeAnimation = p.enableAnimation && scene.hasAnimation;
const activeAnimation = renderer.props.enableAnimation && scene.hasAnimation;
const shouldRender = force || cameraChanged || resized || forceNextRender || xrChanged || activeAnimation;
forceNextRender = false;
@@ -880,15 +891,18 @@ namespace Canvas3D {
}
if (radius > 0) {
const duration = nextCameraResetDuration === undefined ? p.cameraResetDurationMs : nextCameraResetDuration;
const duration = nextCameraResetOptions.durationMs === undefined ? p.cameraResetDurationMs : nextCameraResetOptions.durationMs;
const focus = camera.getFocus(center, radius);
const next = typeof nextCameraResetSnapshot === 'function' ? nextCameraResetSnapshot(scene, camera) : nextCameraResetSnapshot;
const next = typeof nextCameraResetOptions.snapshot === 'function' ? nextCameraResetOptions.snapshot(scene, camera) : nextCameraResetOptions.snapshot;
const snapshot = next ? { ...focus, ...next } : focus;
camera.setState({ ...snapshot, radiusMax: getSceneRadius() }, duration);
camera.setState({ ...snapshot, radiusMax: getSceneRadius() }, duration, { keyframes: nextCameraResetOptions.keyframes, easing: nextCameraResetOptions.easing });
}
nextCameraResetDuration = void 0;
nextCameraResetSnapshot = void 0;
nextCameraResetOptions.durationMs = void 0;
nextCameraResetOptions.snapshot = void 0;
nextCameraResetOptions.keyframes = void 0;
nextCameraResetOptions.easing = void 0;
cameraResetRequested = false;
}
@@ -898,7 +912,7 @@ namespace Canvas3D {
function shouldResetCamera() {
if (camera.state.radiusMax === 0) return true;
if (camera.transition.inTransition || nextCameraResetSnapshot) return false;
if (camera.transition.inTransition || nextCameraResetOptions.snapshot) return false;
let cameraSphereOverlapsNone = true, isEmpty = true;
Sphere3D.set(cameraSphere, camera.state.target, camera.state.radius);
@@ -940,7 +954,7 @@ namespace Canvas3D {
if (!p.camera.manualReset && (reprCount.value === 0 || shouldResetCamera())) {
cameraResetRequested = true;
}
if (oldBoundingSphereVisible.radius === 0) nextCameraResetDuration = 0;
if (oldBoundingSphereVisible.radius === 0) nextCameraResetOptions.durationMs = 0;
if (!p.camera.manualReset) camera.setState({ radiusMax: getSceneRadius() }, 0);
reprCount.next(reprRenderObjects.size);
@@ -1071,7 +1085,6 @@ namespace Canvas3D {
transparentBackground: p.transparentBackground,
checkeredTransparentBackground: p.checkeredTransparentBackground,
dpoitIterations: p.dpoitIterations,
enableAnimation: p.enableAnimation,
pickPadding: p.pickPadding,
userInteractionReleaseMs: p.userInteractionReleaseMs,
viewport: p.viewport,
@@ -1225,7 +1238,7 @@ namespace Canvas3D {
syncVisibility: () => {
if (camera.state.radiusMax === 0) {
cameraResetRequested = true;
nextCameraResetDuration = 0;
nextCameraResetOptions.durationMs = 0;
}
if (scene.syncVisibility()) {
@@ -1254,8 +1267,7 @@ namespace Canvas3D {
resizeRequested = true;
},
requestCameraReset: options => {
nextCameraResetDuration = options?.durationMs;
nextCameraResetSnapshot = options?.snapshot;
Object.assign(nextCameraResetOptions, options);
cameraResetRequested = true;
},
camera,
@@ -1317,10 +1329,6 @@ namespace Canvas3D {
if (props.transparentBackground !== undefined) p.transparentBackground = props.transparentBackground;
if (props.checkeredTransparentBackground !== undefined) p.checkeredTransparentBackground = props.checkeredTransparentBackground;
if (props.dpoitIterations !== undefined) p.dpoitIterations = props.dpoitIterations;
if (props.enableAnimation !== undefined) {
p.enableAnimation = props.enableAnimation;
renderer.setProps({ enableAnimation: p.enableAnimation });
}
if (props.pickPadding !== undefined) {
p.pickPadding = props.pickPadding;
pickHelper.setPickPadding(p.pickPadding);

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 Alexander Rose <alexander.rose@weirdbyte.de>
* @author Áron Samuel Kovács <aron.kovacs@mail.muni.cz>
@@ -484,27 +484,45 @@ export class SsaoPass {
if (isTimingMode) this.webgl.timer.markEnd('SSAO.downsample');
}
if (isTimingMode) this.webgl.timer.mark('SSAO.half');
if (multiScale) {
// half-resolution viewport (matches dimensions of depthHalfTarget*)
const hsx = Math.floor(sx * 0.5);
const hsy = Math.floor(sy * 0.5);
const hsw = Math.ceil(sw * 0.5);
const hsh = Math.ceil(sh * 0.5);
state.viewport(hsx, hsy, hsw, hsh);
state.scissor(hsx, hsy, hsw, hsh);
if (isTimingMode) this.webgl.timer.mark('SSAO.half');
this.depthHalfTargetOpaque.bind();
this.depthHalfRenderableOpaque.render();
}
if (multiScale && includeTransparent) {
this.depthHalfTargetTransparent.bind();
this.depthHalfRenderableTransparent.render();
}
if (isTimingMode) this.webgl.timer.markEnd('SSAO.half');
if (includeTransparent) {
this.depthHalfTargetTransparent.bind();
this.depthHalfRenderableTransparent.render();
}
if (isTimingMode) this.webgl.timer.markEnd('SSAO.half');
if (isTimingMode) this.webgl.timer.mark('SSAO.quarter');
if (multiScale) {
// quarter-resolution viewport (matches dimensions of depthQuarterTarget*)
const qsx = Math.floor(sx * 0.25);
const qsy = Math.floor(sy * 0.25);
const qsw = Math.ceil(sw * 0.25);
const qsh = Math.ceil(sh * 0.25);
state.viewport(qsx, qsy, qsw, qsh);
state.scissor(qsx, qsy, qsw, qsh);
if (isTimingMode) this.webgl.timer.mark('SSAO.quarter');
this.depthQuarterTargetOpaque.bind();
this.depthQuarterRenderableOpaque.render();
if (includeTransparent) {
this.depthQuarterTargetTransparent.bind();
this.depthQuarterRenderableTransparent.render();
}
if (isTimingMode) this.webgl.timer.markEnd('SSAO.quarter');
// restore full-scale viewport for SSAO + blur passes
state.viewport(sx, sy, sw, sh);
state.scissor(sx, sy, sw, sh);
}
if (multiScale && includeTransparent) {
this.depthQuarterTargetTransparent.bind();
this.depthQuarterRenderableTransparent.render();
}
if (isTimingMode) this.webgl.timer.markEnd('SSAO.quarter');
if (isTimingMode) this.webgl.timer.mark('SSAO.opaque');
this.ssaoDepthTexture.attachFramebuffer(this.framebuffer, 'color0');

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

@@ -78,7 +78,7 @@ export const apply_light_color = `
}
#pragma unroll_loop_end
outgoingLight += physicalMaterial.diffuseColor * luminance(uAmbientColor);
outgoingLight += physicalMaterial.diffuseColor * uAmbientColor;
#else
ReflectedLight reflectedLight = ReflectedLight(vec3(0.0), vec3(0.0), vec3(0.0), vec3(0.0));

View File

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-26 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author David Sehnal <david.sehnal@gmail.com>
*
* adapted from https://github.com/d3/d3-ease
*/
@@ -103,3 +104,33 @@ export function sinInOut(t: number) {
}
//
export const EasingFunctions = {
'linear': (t: number) => t,
'bounce-in': bounceIn,
'bounce-out': bounceOut,
'bounce-in-out': bounceInOut,
'circle-in': circleIn,
'circle-out': circleOut,
'circle-in-out': circleInOut,
'cubic-in': cubicIn,
'cubic-out': cubicOut,
'cubic-in-out': cubicInOut,
'exp-in': expIn,
'exp-out': expOut,
'exp-in-out': expInOut,
'quad-in': quadIn,
'quad-out': quadOut,
'quad-in-out': quadInOut,
'sin-in': sinIn,
'sin-out': sinOut,
'sin-in-out': sinInOut,
};
export type EasingKind = keyof typeof EasingFunctions;
export type EasingFunction = EasingKind | ((t: number) => number);
export function getEasingFn(easing: EasingFunction | undefined): (t: number) => number {
if (!easing) return EasingFunctions.linear;
return typeof easing === 'function' ? easing : EasingFunctions[easing] ?? EasingFunctions.linear;
}

View File

@@ -150,6 +150,10 @@ namespace Mat3 {
return areEqual(m, _id, typeof eps === 'undefined' ? EPSILON : eps);
}
export function is(a: any): a is Mat3 {
return Array.isArray(a) && a.length === 9;
}
export function hasNaN(m: Mat3) {
for (let i = 0; i < 9; i++) if (Number.isNaN(m[i])) return true;
return false;

View File

@@ -110,6 +110,10 @@ namespace Mat4 {
return areEqual(m, _id, typeof eps === 'undefined' ? EPSILON : eps);
}
export function is(a: any): a is Mat4 {
return Array.isArray(a) && a.length === 16;
}
export function hasNaN(m: Mat4) {
for (let i = 0; i < 16; i++) if (Number.isNaN(m[i])) return true;
return false;

View File

@@ -0,0 +1,184 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { Vec3 } from './vec3';
import { EVD } from '../matrix/evd';
import { Matrix } from '../matrix/matrix';
export interface LeastObstructedDirectionOptions {
/** Optional centroid/origin. If omitted, centroid is computed from the provided points. */
origin?: Vec3,
/** Optional Gaussian falloff distance. If omitted, all points have weight 1. */
sigma?: number,
/** Ignore points closer than this to the origin. */
minDistance?: number,
}
function eachPosition(points: ReadonlyArray<Vec3> | { x: ArrayLike<number>, y: ArrayLike<number>, z: ArrayLike<number> }, callback: (x: number, y: number, z: number) => void) {
if (Array.isArray(points)) {
for (const p of points) {
callback(p[0], p[1], p[2]);
}
} else {
const { x, y, z } = points as { x: ArrayLike<number>, y: ArrayLike<number>, z: ArrayLike<number> };
const n = Math.min(x.length, y.length, z.length);
for (let i = 0; i < n; i++) {
callback(x[i], y[i], z[i]);
}
}
}
/**
* Estimate a visually open camera direction around a selection.
*
* Geometric intuition:
*
* The selection centroid is treated as the origin. Each nearby obstruction
* point is converted into a unit direction on the sphere around the selection:
*
* v_i = normalize(p_i - origin)
*
* We then build the directional second-moment matrix:
*
* M = sum_i w_i v_i v_i^T
*
* For any candidate view direction `u`, the quadratic form
*
* u^T M u
*
* expands to:
*
* sum_i w_i (u · v_i)^2
*
* Since `u · v_i = cos(theta_i)`, this value is large when `u` is aligned
* with many obstruction directions and small when `u` is mostly perpendicular
* to them. Therefore, the eigenvector of `M` with the smallest eigenvalue is
* the axis that is least aligned, in a least-squares sense, with the nearby
* obstruction directions.
*
* This gives an unoriented axis: `u` and `-u` have the same score because the
* dot products are squared. To choose the camera-facing side, we compute the
* weighted mean obstruction direction:
*
* m = sum_i w_i v_i
*
* and return the sign of the axis that points away from this mean direction.
*
* In short:
*
* - project nearby points onto a sphere around the selection;
* - find the sparsest angular axis using the smallest eigenvector of their
* second-moment matrix;
* - choose the side of that axis opposite the average obstruction direction.
*
* This is a fast, deterministic heuristic. It minimizes average squared
* angular alignment with nearby points; it is not the exact largest-empty-cone
* or maximum-clearance solution.
*
* The returned vector is a unit direction from the selection centroid toward
* the camera.
*/
export function leastObstructedDirection(
points: ReadonlyArray<Vec3> | { x: ArrayLike<number>, y: ArrayLike<number>, z: ArrayLike<number> },
options: LeastObstructedDirectionOptions = {}
): Vec3 | undefined {
const origin = options.origin;
const minDistance = options.minDistance ?? 1e-6;
const minDistanceSq = minDistance * minDistance;
const sigma = options.sigma;
const useWeights = sigma !== void 0 && sigma > 0;
const twoSigmaSq = useWeights ? 2 * sigma * sigma : 1;
// Directional second moment:
// M = sum_i w_i v_i v_i^T
const evd = EVD.createCache(3);
const M = evd.matrix;
Matrix.makeZero(M);
// Weighted mean direction, used only to choose sign.
const mean = Vec3.zero();
let count = 0;
let weightSum = 0;
eachPosition(points, (x_, y_, z_) => {
let x = x_, y = y_, z = z_;
if (origin) {
x -= origin[0];
y -= origin[1];
z -= origin[2];
}
const dSq = x * x + y * y + z * z;
if (dSq <= minDistanceSq) return;
const d = Math.sqrt(dSq);
const invD = 1 / d;
// Unit obstruction direction v.
x *= invD;
y *= invD;
z *= invD;
const w = useWeights ? Math.exp(-dSq / twoSigmaSq) : 1;
// Accumulate symmetric matrix.
//
// M = [
// xx xy xz
// xy yy yz
// xz yz zz
// ]
Matrix.add(M, 0, 0, w * x * x);
Matrix.add(M, 0, 1, w * x * y);
Matrix.add(M, 0, 2, w * x * z);
Matrix.add(M, 1, 0, w * y * x);
Matrix.add(M, 1, 1, w * y * y);
Matrix.add(M, 1, 2, w * y * z);
Matrix.add(M, 2, 0, w * z * x);
Matrix.add(M, 2, 1, w * z * y);
Matrix.add(M, 2, 2, w * z * z);
mean[0] += w * x;
mean[1] += w * y;
mean[2] += w * z;
count++;
weightSum += w;
});
if (count === 0 || weightSum <= 0) {
return undefined;
}
EVD.compute(evd);
// EVD sorts eigenvalues ascending, so column 0 is the smallest eigenvector.
const dir = Vec3.create(
Matrix.get(M, 0, 0),
Matrix.get(M, 1, 0),
Matrix.get(M, 2, 0)
);
if (Vec3.magnitude(dir) < 1e-6) {
return undefined;
}
Vec3.normalize(dir, dir);
// Pick the less-obstructed side of the axis:
// choose the sign opposite the weighted mean obstruction direction.
if (Vec3.dot(dir, mean) > 0) {
Vec3.scale(dir, dir, -1);
}
return dir;
}

View File

@@ -71,6 +71,10 @@ namespace Quat {
return out;
}
export function is(a: any): a is Quat {
return Array.isArray(a) && a.length === 4;
}
export function setAxisAngle(out: Quat, axis: Vec3, rad: number) {
rad = rad * 0.5;
const s = Math.sin(rad);

View File

@@ -58,6 +58,10 @@ namespace Vec2 {
return Number.isNaN(a[0]) || Number.isNaN(a[1]);
}
export function is(a: any): a is Vec2 {
return Array.isArray(a) && a.length === 2;
}
export function toArray<T extends NumberArray>(a: Vec2, out: T, offset: number) {
out[offset + 0] = a[0];
out[offset + 1] = a[1];

View File

@@ -48,6 +48,10 @@ export namespace Vec3 {
return out;
}
export function is(a: any): a is Vec3 {
return Array.isArray(a) && a.length === 3;
}
export function isFinite(a: Vec3): boolean {
return _isFinite(a[0]) && _isFinite(a[1]) && _isFinite(a[2]);
}

View File

@@ -71,6 +71,10 @@ namespace Vec4 {
return Number.isNaN(a[0]) || Number.isNaN(a[1]) || Number.isNaN(a[2]) || Number.isNaN(a[3]);
}
export function is(a: any): a is Vec4 {
return Array.isArray(a) && a.length === 4;
}
export function toArray<T extends NumberArray>(a: Vec4, out: T, offset: number) {
out[offset + 0] = a[0];
out[offset + 1] = a[1];

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { Vec3 } from '../3d/vec3';
import { leastObstructedDirection } from '../3d/optimize-direction';
describe('OptimizeDirection', () => {
it('works more or less as expected', () => {
const points: Vec3[] = [
Vec3.create(1, 0, 0),
Vec3.create(-1, 0, 0),
Vec3.create(0, 1, 0),
Vec3.create(0, -1, 0),
Vec3.create(0, 0, 1),
];
const dir = leastObstructedDirection(points);
console.log('dir', dir);
expect(dir).toBeDefined();
expect(dir[0]).toBeCloseTo(0, 6);
expect(dir[1]).toBeCloseTo(0, 6);
expect(dir[2]).toBeCloseTo(-1, 6);
});
});

View File

@@ -167,9 +167,9 @@ namespace Loci {
} else if (loci.kind === 'data-loci') {
return loci.getBoundingSphere?.(boundingSphere);
} else if (loci.kind === 'volume-loci') {
return Volume.getBoundingSphere(loci.volume, boundingSphere);
return Volume.getBoundingSphere(loci.volume, loci.instances, boundingSphere);
} else if (loci.kind === 'isosurface-loci') {
return Volume.Isosurface.getBoundingSphere(loci.volume, loci.isoValue, boundingSphere);
return Volume.Isosurface.getBoundingSphere(loci.volume, loci.isoValue, loci.instances, boundingSphere);
} else if (loci.kind === 'cell-loci') {
return Volume.Cell.getBoundingSphere(loci.volume, loci.elements, boundingSphere);
} else if (loci.kind === 'segment-loci') {

View File

@@ -545,6 +545,12 @@ export function surroundingLigands({ query, radius, includeWater }: SurroundingL
continue;
}
// Water is handled exclusively by the `includeWater` 3D-lookup branch below.
// A single water pulled in via a struct_conn metalc/covale edge would
// otherwise match every other water in the chain (all share label_seq_id
// and label_comp_id) and leak the entire chain.
if (StructureProperties.entity.type(l) === 'water') continue;
residuesIt.setSegment(chainSegment);
while (residuesIt.hasNext) {
const residueSegment = residuesIt.move();

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2017-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2017-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>
@@ -572,6 +572,42 @@ export namespace Loci {
return Loci(loci.structure, elements);
}
export function extendToRadius(loci: Loci, radius: number): Loci {
const elementsByUnit = new Map<number, Set<UnitIndex>>();
const lookup = loci.structure.lookup3d;
const pos = Vec3();
forEachLocation(loci, loc => {
loc.unit.conformation.position(loc.element, pos);
const result = lookup.find(pos[0], pos[1], pos[2], radius);
for (let i = 0, il = result.count; i < il; ++i) {
const unit = result.units[i];
const unitIdx = result.indices[i];
let set: Set<UnitIndex> = elementsByUnit.get(unit.id) as Set<UnitIndex>;
if (!set) {
set = new Set();
elementsByUnit.set(unit.id, set);
}
set.add(unitIdx);
}
});
const elements: Element[] = [];
for (const [unitId, indexSet] of elementsByUnit.entries()) {
const unit = loci.structure.unitMap.get(unitId)!;
const indices = Array.from(indexSet) as UnitIndex[];
indices.sort((a, b) => a - b);
elements.push({ unit, indices: makeIndexSet(indices) });
}
return {
kind: 'element-loci',
structure: loci.structure,
elements,
};
}
//
const boundaryHelper = new BoundaryHelper('98');

View File

@@ -68,6 +68,36 @@ namespace Grid {
return Sphere3D.fromDimensionsAndTransform(boundingSphere, dimensions, transform);
}
const _isoBbox = Box3D();
export function getIsosurfaceBoundingSphere(grid: Grid, isoValue: number, boundingSphere?: Sphere3D) {
const neg = isoValue < 0;
const c = [0, 0, 0];
const getCoords = grid.cells.space.getCoords;
const d = grid.cells.data;
const [xn, yn, zn] = grid.cells.space.dimensions;
let minx = xn - 1, miny = yn - 1, minz = zn - 1;
let maxx = 0, maxy = 0, maxz = 0;
for (let i = 0, il = d.length; i < il; ++i) {
if ((neg && d[i] <= isoValue) || (!neg && d[i] >= isoValue)) {
getCoords(i, c);
if (c[0] < minx) minx = c[0];
if (c[1] < miny) miny = c[1];
if (c[2] < minz) minz = c[2];
if (c[0] > maxx) maxx = c[0];
if (c[1] > maxy) maxy = c[1];
if (c[2] > maxz) maxz = c[2];
}
}
Vec3.set(_isoBbox.min, minx - 1, miny - 1, minz - 1);
Vec3.set(_isoBbox.max, maxx + 1, maxy + 1, maxz + 1);
const transform = Grid.getGridToCartesianTransform(grid);
Box3D.transform(_isoBbox, _isoBbox, transform);
return Sphere3D.fromBox3D(boundingSphere || Sphere3D(), _isoBbox);
}
/**
* Compute histogram with given bin count.
* Cached on the Grid object.

View File

@@ -6,7 +6,7 @@
*/
import { Grid } from './grid';
import { OrderedSet } from '../../mol-data/int';
import { Interval, OrderedSet } from '../../mol-data/int';
import { Box3D, Sphere3D } from '../../mol-math/geometry';
import { Vec3, Mat4 } from '../../mol-math/linear-algebra';
import { BoundaryHelper } from '../../mol-math/geometry/boundary-helper';
@@ -191,14 +191,14 @@ export namespace Volume {
export function isLociEmpty(loci: Loci) { return isEmpty(loci.volume) || OrderedSet.isEmpty(loci.instances); }
const boundaryHelper = new BoundaryHelper('98');
export function getBoundingSphere(volume: Volume, boundingSphere?: Sphere3D) {
export function getBoundingSphere(volume: Volume, instances: OrderedSet<InstanceIndex>, boundingSphere?: Sphere3D) {
const gs = Grid.getBoundingSphere(volume.grid);
if (!boundingSphere) boundingSphere = Sphere3D();
if (volume.instances.length === 0) return Sphere3D.copy(boundingSphere, gs);
if (OrderedSet.isEmpty(instances)) return Sphere3D.copy(boundingSphere, gs);
const spheres: Sphere3D[] = [];
for (let i = 0, il = volume.instances.length; i < il; ++i) {
const { transform } = volume.instances[i];
for (let i = 0, il = OrderedSet.size(instances); i < il; ++i) {
const { transform } = volume.instances[OrderedSet.getAt(instances, i)];
spheres.push(Sphere3D.transform(Sphere3D(), gs, transform));
}
@@ -220,35 +220,23 @@ export namespace Volume {
export function areLociEqual(a: Loci, b: Loci) { return a.volume === b.volume && Volume.IsoValue.areSame(a.isoValue, b.isoValue, a.volume.grid.stats) && OrderedSet.areEqual(a.instances, b.instances); }
export function isLociEmpty(loci: Loci) { return isEmpty(loci.volume) || OrderedSet.isEmpty(loci.instances); }
const bbox = Box3D();
export function getBoundingSphere(volume: Volume, isoValue: Volume.IsoValue, boundingSphere?: Sphere3D) {
const boundaryHelper = new BoundaryHelper('98');
export function getBoundingSphere(volume: Volume, isoValue: Volume.IsoValue, instances: OrderedSet<InstanceIndex>, boundingSphere?: Sphere3D) {
const value = Volume.IsoValue.toAbsolute(isoValue, volume.grid.stats).absoluteValue;
const neg = value < 0;
const gs = Grid.getIsosurfaceBoundingSphere(volume.grid, value);
const c = [0, 0, 0];
const getCoords = volume.grid.cells.space.getCoords;
const d = volume.grid.cells.data;
const [xn, yn, zn] = volume.grid.cells.space.dimensions;
if (OrderedSet.isEmpty(instances)) return Sphere3D.copy(boundingSphere || Sphere3D(), gs);
let minx = xn - 1, miny = yn - 1, minz = zn - 1;
let maxx = 0, maxy = 0, maxz = 0;
for (let i = 0, il = d.length; i < il; ++i) {
if ((neg && d[i] <= value) || (!neg && d[i] >= value)) {
getCoords(i, c);
if (c[0] < minx) minx = c[0];
if (c[1] < miny) miny = c[1];
if (c[2] < minz) minz = c[2];
if (c[0] > maxx) maxx = c[0];
if (c[1] > maxy) maxy = c[1];
if (c[2] > maxz) maxz = c[2];
}
const spheres: Sphere3D[] = [];
for (let i = 0, il = OrderedSet.size(instances); i < il; ++i) {
spheres.push(Sphere3D.transform(Sphere3D(), gs, volume.instances[OrderedSet.getAt(instances, i)].transform));
}
Vec3.set(bbox.min, minx - 1, miny - 1, minz - 1);
Vec3.set(bbox.max, maxx + 1, maxy + 1, maxz + 1);
const transform = Grid.getGridToCartesianTransform(volume.grid);
Box3D.transform(bbox, bbox, transform);
return Sphere3D.fromBox3D(boundingSphere || Sphere3D(), bbox);
boundaryHelper.reset();
for (const s of spheres) boundaryHelper.includeSphere(s);
boundaryHelper.finishedIncludeStep();
for (const s of spheres) boundaryHelper.radiusSphere(s);
return boundaryHelper.getSphere(boundingSphere);
}
}
@@ -416,7 +404,7 @@ export namespace Volume {
}
return Sphere3D.fromBox3D(boundingSphere || Sphere3D(), bbox);
} else {
return Volume.getBoundingSphere(volume, boundingSphere);
return Volume.getBoundingSphere(volume, Interval.ofLength(volume.instances.length as InstanceIndex), boundingSphere);
}
}

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

@@ -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>
@@ -12,10 +12,11 @@ import { GraphicsRenderObject } from '../../mol-gl/render-object';
import { Sphere3D } from '../../mol-math/geometry';
import { BoundaryHelper } from '../../mol-math/geometry/boundary-helper';
import { Mat3 } from '../../mol-math/linear-algebra';
import { leastObstructedDirection } from '../../mol-math/linear-algebra/3d/optimize-direction';
import { Vec3 } from '../../mol-math/linear-algebra/3d/vec3';
import { PrincipalAxes } from '../../mol-math/linear-algebra/matrix/principal-axes';
import { Loci } from '../../mol-model/loci';
import { Structure, StructureElement } from '../../mol-model/structure';
import { Structure, StructureElement, StructureProperties } from '../../mol-model/structure';
import { PluginContext } from '../../mol-plugin/context';
import { PluginState } from '../../mol-plugin/state';
import { PluginStateObject } from '../objects';
@@ -23,15 +24,25 @@ import { pcaFocus } from './focus-camera/focus-first-residue';
import { getFocusSnapshot } from './focus-camera/focus-object';
import { changeCameraRotation, structureLayingTransform } from './focus-camera/orient-axes';
// TODO: make this customizable somewhere?
const DefaultCameraFocusOptions = {
export const DefaultCameraFocusOptions = {
minRadius: 1,
extraRadius: 4,
durationMs: 250,
// When set, zooms out to the current scene bounding sphere before focusing on the target.
zoomOut: false,
zoomOutOptions: {
durationFactor: 3.5,
}
};
export type CameraFocusOptions = typeof DefaultCameraFocusOptions
export const DefaultCameraFocusLociOptions = {
...DefaultCameraFocusOptions,
optimizeDirection: false,
optimizeDirectionUp: 'current' as 'current' | 'default' | Vec3,
};
export type CameraFocusOptions = typeof DefaultCameraFocusOptions;
export type CameraFocusLociOptions = typeof DefaultCameraFocusLociOptions;
export class CameraManager {
private boundaryHelper = new BoundaryHelper('98');
@@ -57,10 +68,7 @@ export class CameraManager {
this.focusSpheres(spheres, s => s, options);
}
focusLoci(loci: Loci | Loci[], options?: Partial<CameraFocusOptions>) {
// TODO: allow computation of principal axes here?
// perhaps have an optimized function, that does exact axes small Loci and approximate/sampled from big ones?
private getFocusSphere(loci: Loci | Loci[]) {
let sphere: Sphere3D | undefined;
if (Array.isArray(loci) && loci.length > 1) {
@@ -88,9 +96,84 @@ export class CameraManager {
sphere = Loci.getBoundingSphere(this.transformedLoci(loci));
}
if (sphere) {
this.focusSphere(sphere, options);
return sphere;
}
private focusLociOptimized(loci: Loci | Loci[], options?: Partial<CameraFocusLociOptions>) {
const { canvas3d } = this.plugin;
if (!canvas3d) return;
const sphere = this.getFocusSphere(loci);
if (!sphere) return;
const lociArray = Array.isArray(loci) ? loci : [loci];
const positions: { x: number[], y: number[], z: number[] } = { x: [], y: [], z: [] };
const t = Vec3();
const { extraRadius, minRadius } = { ...DefaultCameraFocusOptions, ...options };
const radius = Math.max(sphere.radius + extraRadius, minRadius);
if (radius <= 1e-3) {
return this.getFocusSphereSnapshot(sphere, options);
}
const entityType = StructureProperties.entity.type;
for (const l of lociArray) {
if (!StructureElement.Loci.is(l)) continue;
const extended = StructureElement.Loci.extendToRadius(l, radius);
StructureElement.Loci.forEachLocation(extended, loc => {
if (entityType(loc) === 'water') return;
loc.unit.conformation.position(loc.element, t);
positions.x.push(t[0]);
positions.y.push(t[1]);
positions.z.push(t[2]);
});
}
if (positions.x.length === 0) {
return this.getFocusSphereSnapshot(sphere, options);
}
const direction = leastObstructedDirection(positions, {
origin: sphere.center,
minDistance: 1e-3,
sigma: sphere.radius,
});
if (!direction) {
return this.getFocusSphereSnapshot(sphere, options);
}
Vec3.negate(direction, direction);
const upVector = options?.optimizeDirectionUp === 'default'
? Vec3.unitY
: Vec3.is(options?.optimizeDirectionUp) ? options.optimizeDirectionUp : undefined;
if (upVector) {
return canvas3d.camera.getInvariantFocus(sphere.center, radius, upVector as Vec3, direction);
}
return canvas3d.camera.getFocus(sphere.center, radius, undefined, direction);
}
private focusLociBase(loci: Loci | Loci[], options?: Partial<CameraFocusOptions>) {
const sphere = this.getFocusSphere(loci);
if (sphere) {
return this.getFocusSphereSnapshot(sphere, options);
}
}
focusLoci(loci: Loci | Loci[], options?: Partial<CameraFocusLociOptions>) {
if (!this.plugin.canvas3d) return;
const options_ = { ...DefaultCameraFocusLociOptions, ...options };
let snapshot: Partial<Camera.Snapshot> | undefined;
if (options_.optimizeDirection) {
snapshot = this.focusLociOptimized(loci, options_);
} else {
snapshot = this.focusLociBase(loci, options_);
}
this.focusSnapshot(snapshot, options_);
}
focusSpheres<T>(xs: ReadonlyArray<T>, sphere: (t: T) => Sphere3D | undefined, options?: Partial<CameraFocusOptions> & { principalAxes?: PrincipalAxes, positionToFlip?: Vec3 }) {
@@ -115,21 +198,59 @@ export class CameraManager {
this.focusSphere(this.boundaryHelper.getSphere(), options);
}
private getFocusSphereSnapshot(sphere: Sphere3D, options?: Partial<CameraFocusOptions> & { principalAxes?: PrincipalAxes, positionToFlip?: Vec3 }) {
const { canvas3d } = this.plugin;
if (!canvas3d) return;
const { extraRadius, minRadius } = { ...DefaultCameraFocusOptions, ...options };
const radius = Math.max(sphere.radius + extraRadius, minRadius);
if (options?.principalAxes) {
return pcaFocus(this.plugin, radius, options as { principalAxes: PrincipalAxes, positionToFlip?: Vec3 });
} else {
return canvas3d.camera.getFocus(sphere.center, radius);
}
}
private focusSnapshot(snapshot: Partial<Camera.Snapshot> | undefined, options?: Partial<CameraFocusOptions>) {
if (!this.plugin.canvas3d || !snapshot) return;
const durationMs = options?.durationMs ?? DefaultCameraFocusOptions.durationMs;
if (!options?.zoomOut) {
this.plugin.canvas3d.requestCameraReset({ snapshot, durationMs });
return;
}
const sphere = this.plugin.canvas3d.boundingSphere;
const zoomOut = this.getFocusSphereSnapshot(sphere, options) as Camera.Snapshot;
const current = this.plugin.canvas3d?.camera.getSnapshot()!;
const distA = Vec3.distance(current.position, zoomOut.position);
const distB = Vec3.distance(zoomOut.position, snapshot.position!);
const t = distA / (distA + distB);
const durationFactor = options?.zoomOutOptions?.durationFactor ?? DefaultCameraFocusOptions.zoomOutOptions.durationFactor;
const df = 1 + durationFactor * Math.min(t, 0.5);
this.plugin.canvas3d.requestCameraReset({
snapshot,
durationMs: df * durationMs,
keyframes: t > 0.05 ? [
{ t, snapshot: zoomOut, easing: 'cubic-out' },
{ t: 1, snapshot, easing: 'cubic-in' },
] : undefined
});
}
focusSphere(sphere: Sphere3D, options?: Partial<CameraFocusOptions> & { principalAxes?: PrincipalAxes, positionToFlip?: Vec3 }) {
const { canvas3d } = this.plugin;
if (!canvas3d) return;
const { extraRadius, minRadius, durationMs } = { ...DefaultCameraFocusOptions, ...options };
const radius = Math.max(sphere.radius + extraRadius, minRadius);
const snapshot = this.getFocusSphereSnapshot(sphere, options);
if (!snapshot) return;
if (options?.principalAxes) {
const snapshot = pcaFocus(this.plugin, radius, options as { principalAxes: PrincipalAxes, positionToFlip?: Vec3 });
this.plugin.canvas3d?.requestCameraReset({ durationMs, snapshot });
} else {
const snapshot = canvas3d.camera.getFocus(sphere.center, radius);
canvas3d.requestCameraReset({ durationMs, snapshot });
}
}
this.focusSnapshot(snapshot, options);
}
/** Focus on a set of plugin state object cells (if `options.targets` is non-empty) or on the whole scene (if `options.targets` is empty). */
focusObject(options: PluginState.SnapshotFocusInfo & { minRadius?: number, durationMs?: number }) {
@@ -139,7 +260,7 @@ export class CameraManager {
targets: options.targets?.map(t => ({ ...t, extraRadius: t.extraRadius ?? DefaultCameraFocusOptions.extraRadius })),
minRadius: options.minRadius ?? DefaultCameraFocusOptions.minRadius,
});
this.plugin.canvas3d.requestCameraReset({ snapshot, durationMs: options.durationMs ?? DefaultCameraFocusOptions.durationMs });
this.focusSnapshot(snapshot, options);
}
/** Align PCA axes of `structures` (default: all loaded structures) to the screen axes. */

View File

@@ -1304,7 +1304,7 @@ const ShapeFromPly = PluginStateTransform.BuiltIn({
to: SO.Shape.Provider,
params(a) {
return {
transforms: PD.Optional(PD.Value<Mat4[]>([], { isHidden: true })),
transforms: PD.Optional(PD.Value([Mat4.identity()], { isHidden: true })),
label: PD.Optional(PD.Text('', { isHidden: true }))
};
}

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

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2020-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { OrderedSet, SortedArray } from '../../mol-data/int';
@@ -184,14 +185,23 @@ export class StructureFocusControls extends PluginUIComponent<{}, StructureFocus
} else {
this.plugin.managers.structure.focus.set(f);
}
this.focusCamera();
this.focusCamera(true);
};
focusCamera(optimizeDirection?: boolean) {
const { current } = this.plugin.managers.structure.focus;
if (!current) return;
this.plugin.managers.camera.focusLoci(current.loci, {
optimizeDirection,
});
}
toggleAction = () => this.setState({ showAction: !this.state.showAction });
focusCamera = () => {
const { current } = this.plugin.managers.structure.focus;
if (current) this.plugin.managers.camera.focusLoci(current.loci);
focusCameraClick = () => {
this.focusCamera(false);
};
clear = () => {
@@ -231,7 +241,7 @@ export class StructureFocusControls extends PluginUIComponent<{}, StructureFocus
return <>
<div className='msp-flex-row'>
<Button noOverflow onClick={this.focusCamera} title={title} onMouseEnter={this.highlightCurrent} onMouseLeave={this.clearHighlights} disabled={this.isDisabled || !current}
<Button noOverflow onClick={this.focusCameraClick} title={title} onMouseEnter={this.highlightCurrent} onMouseLeave={this.clearHighlights} disabled={this.isDisabled || !current}
style={{ textAlignLast: current ? 'left' : void 0 }}>
{label}
</Button>

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

@@ -0,0 +1,59 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Ludovic Autin <autin@scripps.edu>
*/
import { CustomProperties } from '../../../mol-model/custom-property';
import { Grid, Volume } from '../../../mol-model/volume';
import { Mat4, Tensor } from '../../../mol-math/linear-algebra';
import { createVolumeSphereImpostor } from '../dot';
function createTestVolume(dimensions: [number, number, number], data: number[]): Volume {
return {
grid: {
transform: { kind: 'matrix', matrix: Mat4.identity() },
cells: Tensor.create(Tensor.Space(dimensions, [2, 1, 0]), Tensor.Data1(data)),
stats: { min: 0, max: 1, mean: 0.5, sigma: 0.5 },
} satisfies Grid,
instances: [{ transform: Mat4.identity() }],
sourceData: { kind: 'test', name: 'test', data: {} } as any,
customProperties: new CustomProperties(),
_propertyData: Object.create(null),
_localPropertyData: Object.create(null),
};
}
describe('volume dot representation', () => {
it('adds sphere impostor dots in Morton order for LOD sampling', () => {
const volume = createTestVolume([2, 2, 2], [
1, 1,
1, 1,
1, 1,
1, 1,
]);
const spheres = createVolumeSphereImpostor(undefined as any, volume, 0, undefined as any, {
isoValue: Volume.IsoValue.absolute(0.5),
perturbPositions: false,
lodLevels: [{ minDistance: 0, maxDistance: 0, overlap: 0, stride: 0, scaleBias: 3 }],
} as any);
expect(Array.from(spheres.groupBuffer.ref.value)).toEqual([0, 4, 2, 6, 1, 5, 3, 7]);
});
it('adds sphere impostor dots in row-major order when no LOD levels are configured', () => {
const volume = createTestVolume([2, 2, 2], [
1, 1,
1, 1,
1, 1,
1, 1,
]);
const spheres = createVolumeSphereImpostor(undefined as any, volume, 0, undefined as any, {
isoValue: Volume.IsoValue.absolute(0.5),
perturbPositions: false,
lodLevels: [],
} as any);
expect(Array.from(spheres.groupBuffer.ref.value)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
});
});

View File

@@ -67,7 +67,8 @@ export function VolumeSphereImpostorVisual(materialId: number): VolumeVisual<Vol
setUpdateState: (state: VisualUpdateState, newVolume: Volume, currentVolume: Volume, newProps: PD.Values<VolumeSphereParams>, currentProps: PD.Values<VolumeSphereParams>, newTheme: Theme, currentTheme: Theme) => {
state.createGeometry = (
!Volume.IsoValue.areSame(newProps.isoValue, currentProps.isoValue, newVolume.grid.stats) ||
newProps.perturbPositions !== currentProps.perturbPositions
newProps.perturbPositions !== currentProps.perturbPositions ||
newProps.lodLevels.length > 0 && currentProps.lodLevels.length === 0
);
},
geometryUtils: Spheres.Utils,
@@ -128,38 +129,71 @@ export function createVolumeSphereImpostor(ctx: VisualContext, volume: Volume, k
const p = Vec3();
const [xn, yn, zn] = space.dimensions;
const count = Math.ceil((xn * yn * zn) / 10);
const builder = SpheresBuilder.create(count, Math.ceil(count / 2), spheres);
const invert = isoVal < 0;
// Precompute basis vectors and largest cell axis length
const basis = props.perturbPositions ? getBasis(gridToCartn) : undefined;
for (let z = 0; z < zn; ++z) {
for (let y = 0; y < yn; ++y) {
for (let x = 0; x < xn; ++x) {
const value = space.get(data, x, y, z);
if (!invert && value < isoVal || invert && value > isoVal) continue;
const count = Math.ceil((xn * yn * zn) / 10);
const builder = SpheresBuilder.create(count, Math.ceil(count / 2), spheres);
const cellIdx = space.dataOffset(x, y, z);
if (basis) {
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
const offset = getRandomOffsetFromBasis(basis);
Vec3.add(p, p, offset);
} else {
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
const add = (x: number, y: number, z: number) => {
const value = space.get(data, x, y, z);
if (!invert && value < isoVal || invert && value > isoVal) return;
const cellIdx = space.dataOffset(x, y, z);
Vec3.set(p, x, y, z);
Vec3.transformMat4(p, p, gridToCartn);
if (basis) {
Vec3.add(p, p, getRandomOffsetFromBasis(basis));
}
builder.add(p[0], p[1], p[2], cellIdx);
};
// Morton ordering keeps stride-based LOD sampling spatially balanced.
// Only worthwhile when LOD levels are configured; otherwise use the
// direct row-major path to avoid the extra allocations and sort.
const useMortonOrder = props.lodLevels.length > 0;
if (useMortonOrder) {
// Recursive octree traversal over the bounding power-of-two cube,
// visiting children in Morton order (octant bit2=x, bit1=y, bit0=z).
// Octants whose origin already exceeds the grid extent are pruned,
// so out-of-range subtrees of non-cube grids cost ~O(log) per skip.
let size = 1;
while (size < xn || size < yn || size < zn) size <<= 1;
const visit = (x0: number, y0: number, z0: number, s: number): void => {
if (x0 >= xn || y0 >= yn || z0 >= zn) return;
if (s === 1) {
add(x0, y0, z0);
return;
}
const h = s >> 1;
visit(x0, y0, z0, h);
visit(x0, y0, z0 + h, h);
visit(x0, y0 + h, z0, h);
visit(x0, y0 + h, z0 + h, h);
visit(x0 + h, y0, z0, h);
visit(x0 + h, y0, z0 + h, h);
visit(x0 + h, y0 + h, z0, h);
visit(x0 + h, y0 + h, z0 + h, h);
};
visit(0, 0, 0, size);
} else {
for (let z = 0; z < zn; ++z) {
for (let y = 0; y < yn; ++y) {
for (let x = 0; x < xn; ++x) {
add(x, y, z);
}
builder.add(p[0], p[1], p[2], cellIdx);
}
}
}
const s = builder.getSpheres();
s.setBoundingSphere(Volume.Isosurface.getBoundingSphere(volume, props.isoValue));
s.setBoundingSphere(Grid.getIsosurfaceBoundingSphere(volume.grid, Volume.IsoValue.toAbsolute(props.isoValue, volume.grid.stats).absoluteValue));
return s;
}
@@ -209,7 +243,7 @@ export function createVolumeSphereMesh(ctx: VisualContext, volume: Volume, key:
}
const m = MeshBuilder.getMesh(builderState);
m.setBoundingSphere(Volume.Isosurface.getBoundingSphere(volume, props.isoValue));
m.setBoundingSphere(Grid.getIsosurfaceBoundingSphere(volume.grid, Volume.IsoValue.toAbsolute(props.isoValue, volume.grid.stats).absoluteValue));
return m;
}
@@ -277,7 +311,7 @@ export function createVolumePoint(ctx: VisualContext, volume: Volume, key: numbe
}
const pt = builder.getPoints();
pt.setBoundingSphere(Volume.Isosurface.getBoundingSphere(volume, props.isoValue));
pt.setBoundingSphere(Grid.getIsosurfaceBoundingSphere(volume.grid, Volume.IsoValue.toAbsolute(props.isoValue, volume.grid.stats).absoluteValue));
return pt;
}
@@ -320,6 +354,7 @@ const DotVisuals = {
export const DotParams = {
...VolumeSphereParams,
...VolumePointParams,
sizeFactor: PD.Numeric(1, { min: 0, max: 10, step: 0.1 }),
visuals: PD.MultiSelect(['sphere'], PD.objectToOptions(DotVisuals)),
bumpFrequency: PD.Numeric(1, { min: 0, max: 10, step: 0.1 }, BaseGeometry.ShadingCategory),
};
@@ -346,4 +381,4 @@ export const DotRepresentationProvider = VolumeRepresentationProvider({
defaultSizeTheme: { name: 'uniform' },
locationKinds: ['cell-location', 'position-location'],
isApplicable: (volume: Volume) => !Volume.isEmpty(volume) && !Volume.Segmentation.get(volume)
});
});

View File

@@ -136,7 +136,7 @@ export async function createVolumeIsosurfaceMesh(ctx: VisualContext, volume: Vol
ValueCell.updateIfChanged(surface.varyingGroup, true);
}
surface.setBoundingSphere(Volume.Isosurface.getBoundingSphere(volume, props.isoValue));
surface.setBoundingSphere(Grid.getIsosurfaceBoundingSphere(volume.grid, Volume.IsoValue.toAbsolute(props.isoValue, volume.grid.stats).absoluteValue));
return surface;
}
@@ -318,7 +318,7 @@ export async function createVolumeIsosurfaceWireframe(ctx: VisualContext, volume
const transform = Grid.getGridToCartesianTransform(volume.grid);
Lines.transform(wireframe, transform);
wireframe.setBoundingSphere(Volume.Isosurface.getBoundingSphere(volume, props.isoValue));
wireframe.setBoundingSphere(Grid.getIsosurfaceBoundingSphere(volume.grid, Volume.IsoValue.toAbsolute(props.isoValue, volume.grid.stats).absoluteValue));
return wireframe;
}

View File

@@ -306,7 +306,7 @@ function getSampledImage(volume: Volume, theme: Theme, info: SamplingInfo, isoVa
const isoLevel = clamp(normalize(Volume.IsoValue.toAbsolute(isoValue, stats).absoluteValue, min, max), 0, 1);
const im = Image.create(imageTexture, corners, groupTexture, valueTexture, trim, isoLevel, image);
im.setBoundingSphere(Volume.isPeriodic(volume) ? Volume.getBoundingSphere(volume) : Grid.getBoundingSphere(volume.grid));
im.setBoundingSphere(Volume.isPeriodic(volume) ? Volume.getBoundingSphere(volume, Interval.ofLength(volume.instances.length as Volume.InstanceIndex)) : Grid.getBoundingSphere(volume.grid));
im.meta.mapping = mapping;
return im;
@@ -480,7 +480,7 @@ async function createGridImage(ctx: VisualContext, volume: Volume, key: number,
const isoLevel = clamp(normalize(Volume.IsoValue.toAbsolute(isoValue, stats).absoluteValue, min, max), 0, 1);
const im = Image.create(imageTexture, corners, groupTexture, valueTexture, trim, isoLevel, image);
im.setBoundingSphere(Volume.isPeriodic(volume) ? Volume.getBoundingSphere(volume) : Grid.getBoundingSphere(volume.grid));
im.setBoundingSphere(Volume.isPeriodic(volume) ? Volume.getBoundingSphere(volume, Interval.ofLength(volume.instances.length as Volume.InstanceIndex)) : Grid.getBoundingSphere(volume.grid));
im.meta.mapping = mapping;
return im;

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

@@ -2,6 +2,7 @@
* Copyright (c) 2019-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author Gianluca Tomasello <giagitom@gmail.com>
*/
export { PixelData };
@@ -37,12 +38,14 @@ namespace PixelData {
/** to undo pre-multiplied alpha */
export function divideByAlpha(pixelData: PixelData): PixelData {
const { array } = pixelData;
const factor = (array instanceof Uint8Array) ? 255 : 1;
// clamp: emissive, bloom and antialiasing can lift premul RGB above alpha; without it Uint8Array silently wraps.
const max = (array instanceof Uint8Array) ? 255 : 1;
for (let i = 0, il = array.length; i < il; i += 4) {
const a = array[i + 3] / factor;
array[i] /= a;
array[i + 1] /= a;
array[i + 2] /= a;
const a = array[i + 3] / max;
if (a === 0) continue;
array[i] = Math.min(max, array[i] / a);
array[i + 1] = Math.min(max, array[i + 1] / a);
array[i + 2] = Math.min(max, array[i + 2] / a);
}
return pixelData;
}

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; }

View File

@@ -1,3 +1,7 @@
# 0.9.13
* /surroundingLigands: honor `omit_water=true|false` for REST GET requests (boolean parser previously coerced both to `false`)
* /surroundingLigands: stop leaking the asymmetric unit's water chain into the result when `omit_water=true` (water residues pulled in via struct_conn covale/metalc edges no longer match every other water in the chain)
# 0.9.12
* add `health-check` endpoint + `healthCheckPath` config prop to report service health

View File

@@ -295,7 +295,7 @@ function _normalizeQueryParams(params: { [p: string]: string }, paramList: Query
case QueryParamType.String: el = value; break;
case QueryParamType.Integer: el = parseInt(value); break;
case QueryParamType.Float: el = parseFloat(value); break;
case QueryParamType.Boolean: el = Boolean(+value); break;
case QueryParamType.Boolean: el = isTrue(value); break;
}
if (p.validation) p.validation(el);

View File

@@ -4,4 +4,4 @@
* @author David Sehnal <david.sehnal@gmail.com>
*/
export const VERSION = '0.9.12';
export const VERSION = '0.9.13';

View File

@@ -1,6 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"moduleResolution": "node",
"module": "CommonJS",
"outDir": "lib/commonjs"
}