Compare commits

..

13 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
29 changed files with 627 additions and 808 deletions

View File

@@ -4,6 +4,8 @@ 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
@@ -15,6 +17,10 @@ Note that since we don't clearly distinguish between a public and private interf
- 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

@@ -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,12 +1,12 @@
/**
* 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 { CameraTransitionManager, CameraTransitionOptions } from './camera/transition';
import { BehaviorSubject, Subject } from 'rxjs';
import { Scene } from '../mol-gl/scene';
import { assertUnreachable } from '../mol-util/type-helpers';
@@ -138,8 +138,12 @@ export class Camera implements ICamera {
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);
}

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 }),
@@ -321,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,
@@ -371,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>
@@ -498,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;
//
@@ -878,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;
}
@@ -896,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);
@@ -938,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);
@@ -1222,7 +1238,7 @@ namespace Canvas3D {
syncVisibility: () => {
if (camera.state.radiusMax === 0) {
cameraResetRequested = true;
nextCameraResetDuration = 0;
nextCameraResetOptions.durationMs = 0;
}
if (scene.syncVisibility()) {
@@ -1251,8 +1267,7 @@ namespace Canvas3D {
resizeRequested = true;
},
requestCameraReset: options => {
nextCameraResetDuration = options?.durationMs;
nextCameraResetSnapshot = options?.snapshot;
Object.assign(nextCameraResetOptions, options);
cameraResetRequested = true;
},
camera,

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

@@ -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

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2024 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>
@@ -436,12 +436,12 @@ export const LoadTrajectory = StateAction.build({
//
// dependsOn is auto-derived from the `getDependencies` hook on TrajectoryFromModelAndCoordinates
const dependsOn = [model.ref, coordinates.ref];
const traj = state.build().toRoot()
.apply(TrajectoryFromModelAndCoordinates, {
modelRef: model.ref,
coordinatesRef: coordinates.ref
})
}, { dependsOn })
.apply(StateTransforms.Model.ModelFromTrajectory, { modelIndex: 0 });
await state.updateTree(traj).runInContext(taskCtx);

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

@@ -22,7 +22,7 @@ import { PluginContext } from '../../mol-plugin/context';
import { MolScriptBuilder } from '../../mol-script/language/builder';
import { Expression } from '../../mol-script/language/expression';
import { Script } from '../../mol-script/script';
import { StateObject, StateTransform, StateTransformer } from '../../mol-state';
import { StateObject, StateTransformer } from '../../mol-state';
import { RuntimeContext, Task } from '../../mol-task';
import { deepEqual } from '../../mol-util';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
@@ -247,12 +247,6 @@ const TrajectoryFromModelAndCoordinates = PluginStateTransform.BuiltIn({
coordinatesRef: PD.Text('', { isHidden: true }),
}
})({
getDependencies: ({ modelRef, coordinatesRef }: { modelRef: string, coordinatesRef: string }) => {
const deps: StateTransform.Ref[] = [];
if (modelRef) deps.push(modelRef as StateTransform.Ref);
if (coordinatesRef) deps.push(coordinatesRef as StateTransform.Ref);
return deps;
},
apply({ params, dependencies }) {
return Task.create('Create trajectory from model/topology and coordinates', async ctx => {
const coordinates = dependencies![params.coordinatesRef].data as Coordinates;

View File

@@ -18,7 +18,7 @@ import { volumeFromCube } from '../../mol-model-formats/volume/cube';
import { volumeFromDx } from '../../mol-model-formats/volume/dx';
import { Grid, Volume } from '../../mol-model/volume';
import { PluginContext } from '../../mol-plugin/context';
import { StateSelection, StateTransform, StateTransformer } from '../../mol-state';
import { StateSelection, StateTransformer } from '../../mol-state';
import { volumeFromSegmentationData } from '../../mol-model-formats/volume/segmentation';
import { getTransformFromParams, TransformParam, transformParamsNeedCentroid } from './helpers';
@@ -233,8 +233,7 @@ const AssignColorVolume = PluginStateTransform.BuiltIn({
const props = { label: a.label, description: 'Volume + Colors' };
return new SO.Volume.Data(volume, props);
});
},
getDependencies: ({ ref }) => ref ? [ref as StateTransform.Ref] : []
}
});
type VolumeTransform = typeof VolumeTransform;

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

@@ -1,324 +0,0 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { State, StateObject, StateObjectCell, StateTransform, StateTransformer, StateTreeCycleError } from '../../mol-state';
import { Task } from '../../mol-task';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
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-deps-spec';
let counter = 0;
const uniq = (s: string) => `${s}-${counter++}`;
function newState() {
return State.create(new Root({}), { runTask: <T>(t: Task<T>) => t.run() });
}
/** Plain leaf created from Root with a number param. */
function constLeaf() {
return StateTransformer.create<Root, Leaf, { value: number }>(NS, {
name: uniq('const-leaf'),
from: [Root],
to: [Leaf],
display: { name: 'Const Leaf' },
params: () => ({ value: PD.Numeric(0) }) as any,
apply({ params }) { return new Leaf({ value: params.value }); },
update({ oldParams, newParams }) {
return oldParams.value === newParams.value
? StateTransformer.UpdateResult.Unchanged
: StateTransformer.UpdateResult.Recreate;
}
});
}
/** Leaf whose value is read from a single explicit dependsOn ref. */
function deriveFromDep(depRef: string) {
return StateTransformer.create<Root, Leaf, {}>(NS, {
name: uniq('derive-from-dep'),
from: [Root],
to: [Leaf],
display: { name: 'Derive From Dep' },
params: () => ({}) as any,
apply({ dependencies }) {
const dep = dependencies?.[depRef] as Leaf;
if (!dep) throw new Error('missing dep');
return new Leaf({ value: dep.data.value + 100 });
},
update({ b, dependencies }) {
const dep = dependencies?.[depRef] as Leaf;
if (!dep) throw new Error('missing dep');
(b.data as { value: number }).value = dep.data.value + 100;
return StateTransformer.UpdateResult.Updated;
}
});
}
describe('State dependencies - linking', () => {
it('explicit dependsOn establishes an edge and passes the dep object to apply', async () => {
const state = newState();
const A = constLeaf();
const B = deriveFromDep('leaf-a');
const builder = state.build();
builder.toRoot<Root>().apply(A as any, { value: 7 }, { ref: 'leaf-a' });
builder.toRoot<Root>().apply(B as any, {}, { ref: 'leaf-b', dependsOn: ['leaf-a'] });
await state.runTask(state.updateTree(builder));
const b = state.cells.get('leaf-b')!;
expect(b.dependencies.dependsOn.map(c => c.transform.ref)).toEqual(['leaf-a']);
expect((b.obj as Leaf).data.value).toBe(107);
const a = state.cells.get('leaf-a')!;
expect(a.dependencies.dependentBy.map(c => c.transform.ref)).toEqual(['leaf-b']);
});
it('re-evaluates dependents when the source updates', async () => {
const state = newState();
const A = constLeaf();
const B = deriveFromDep('leaf-a');
const builder1 = state.build();
builder1.toRoot<Root>().apply(A as any, { value: 1 }, { ref: 'leaf-a' });
builder1.toRoot<Root>().apply(B as any, {}, { ref: 'leaf-b', dependsOn: ['leaf-a'] });
await state.runTask(state.updateTree(builder1));
expect((state.cells.get('leaf-b')!.obj as Leaf).data.value).toBe(101);
const builder2 = state.build();
builder2.to('leaf-a').update({ value: 5 });
await state.runTask(state.updateTree(builder2));
expect((state.cells.get('leaf-b')!.obj as Leaf).data.value).toBe(105);
});
it('throws when an explicit dependsOn references a non-existent transform', async () => {
const state = newState();
const B = deriveFromDep('missing-ref');
const builder = state.build();
builder.toRoot<Root>().apply(B as any, {}, { ref: 'leaf-b', dependsOn: ['missing-ref'] });
await expect(state.runTask(state.updateTree(builder))).rejects.toThrow(/non-existent transform/);
});
it('honors getDependencies(params) and relinks when params change', async () => {
const state = newState();
const A = constLeaf();
const A2 = constLeaf();
const PickViaParams = StateTransformer.create<Root, Leaf, { which: string }>(NS, {
name: uniq('pick-via-params'),
from: [Root],
to: [Leaf],
display: { name: 'Pick' },
params: () => ({ which: PD.Text('leaf-a') }) as any,
getDependencies(params) { return params.which ? [params.which as StateTransform.Ref] : []; },
apply({ params, dependencies }) {
const dep = dependencies?.[params.which] as Leaf;
return new Leaf({ value: dep ? dep.data.value : -1 });
},
update({ b, newParams, dependencies }) {
const dep = dependencies?.[newParams.which] as Leaf;
(b.data as { value: number }).value = dep ? dep.data.value : -1;
return StateTransformer.UpdateResult.Updated;
}
});
const builder = state.build();
builder.toRoot<Root>().apply(A as any, { value: 11 }, { ref: 'leaf-a' });
builder.toRoot<Root>().apply(A2 as any, { value: 22 }, { ref: 'leaf-a2' });
builder.toRoot<Root>().apply(PickViaParams as any, { which: 'leaf-a' }, { ref: 'pick' });
await state.runTask(state.updateTree(builder));
const pick = state.cells.get('pick')!;
expect(pick.dependencies.dependsOn.map(c => c.transform.ref)).toEqual(['leaf-a']);
expect((pick.obj as Leaf).data.value).toBe(11);
const update = state.build();
update.to('pick').update({ which: 'leaf-a2' });
await state.runTask(state.updateTree(update));
const pick2 = state.cells.get('pick')!;
expect(pick2.dependencies.dependsOn.map(c => c.transform.ref)).toEqual(['leaf-a2']);
expect((pick2.obj as Leaf).data.value).toBe(22);
// Old source no longer reverse-linked.
expect(state.cells.get('leaf-a')!.dependencies.dependentBy.length).toBe(0);
expect(state.cells.get('leaf-a2')!.dependencies.dependentBy.map(c => c.transform.ref)).toEqual(['pick']);
});
it('auto-collects refs from PD.ValueRef parameter values', async () => {
const state = newState();
const A = constLeaf();
const ViaValueRef = StateTransformer.create<Root, Leaf, { target: { ref: string, getValue: () => Leaf } }>(NS, {
name: uniq('via-value-ref'),
from: [Root],
to: [Leaf],
display: { name: 'Via ValueRef' },
params: () => ({
target: PD.ValueRef<Leaf>(() => [], (ref, getData) => getData(ref))
}) as any,
apply({ params, dependencies }) {
const dep = dependencies?.[params.target.ref] as Leaf;
return new Leaf({ value: dep ? dep.data.value * 2 : -1 });
}
});
const builder = state.build();
builder.toRoot<Root>().apply(A as any, { value: 9 }, { ref: 'leaf-a' });
builder.toRoot<Root>().apply(ViaValueRef as any, {
target: { ref: 'leaf-a', getValue: () => null as any }
}, { ref: 'vr' });
await state.runTask(state.updateTree(builder));
const vr = state.cells.get('vr')!;
expect(vr.dependencies.dependsOn.map(c => c.transform.ref)).toEqual(['leaf-a']);
expect((vr.obj as Leaf).data.value).toBe(18);
});
it('falls back to a structural scan when the schema is unavailable', async () => {
const state = newState();
const A = constLeaf();
// No `def.params` - params normalization will drop unknown fields at
// evaluation time, but link-time collection (via the structural
// fallback) still happens against the original transform.params.
const Structural = StateTransformer.create<Root, Leaf, any>(NS, {
name: uniq('structural'),
from: [Root],
to: [Leaf],
display: { name: 'Structural' },
apply({ dependencies }) {
const ref = dependencies ? Object.keys(dependencies)[0] : undefined;
const dep = ref ? dependencies![ref] as Leaf : undefined;
return new Leaf({ value: dep ? dep.data.value + 1000 : -1 });
}
});
const builder = state.build();
builder.toRoot<Root>().apply(A as any, { value: 3 }, { ref: 'leaf-a' });
builder.toRoot<Root>().apply(Structural as any, {
link: { ref: 'leaf-a', getValue: () => null }
}, { ref: 'struct' });
await state.runTask(state.updateTree(builder));
const s = state.cells.get('struct')!;
expect(s.dependencies.dependsOn.map(c => c.transform.ref)).toEqual(['leaf-a']);
expect((s.obj as Leaf).data.value).toBe(1003);
});
it('filters out self and root refs from getDependencies', async () => {
const state = newState();
const SelfRef = StateTransformer.create<Root, Leaf, {}>(NS, {
name: uniq('self-ref'),
from: [Root],
to: [Leaf],
display: { name: 'Self Ref' },
params: () => ({}) as any,
getDependencies() { return ['self', StateTransform.RootRef as any]; },
apply() { return new Leaf({ value: 42 }); }
});
const builder = state.build();
builder.toRoot<Root>().apply(SelfRef as any, {}, { ref: 'self' });
await state.runTask(state.updateTree(builder));
const cell = state.cells.get('self')!;
expect(cell.dependencies.dependsOn.length).toBe(0);
expect((cell.obj as Leaf).data.value).toBe(42);
});
});
describe('State dependencies - cycle detection', () => {
it('throws StateTreeCycleError for a direct A → B → A cycle', async () => {
const state = newState();
// Two transformers, each declaring a getDependencies pointing at the other.
const A = StateTransformer.create<Root, Leaf, {}>(NS, {
name: uniq('cycle-a'),
from: [Root],
to: [Leaf],
display: { name: 'Cycle A' },
params: () => ({}) as any,
getDependencies() { return ['cyc-b' as any]; },
apply() { return new Leaf({ value: 0 }); }
});
const B = StateTransformer.create<Root, Leaf, {}>(NS, {
name: uniq('cycle-b'),
from: [Root],
to: [Leaf],
display: { name: 'Cycle B' },
params: () => ({}) as any,
getDependencies() { return ['cyc-a' as any]; },
apply() { return new Leaf({ value: 0 }); }
});
const builder = state.build();
builder.toRoot<Root>().apply(A as any, {}, { ref: 'cyc-a' });
builder.toRoot<Root>().apply(B as any, {}, { ref: 'cyc-b' });
let caught: unknown;
try {
await state.runTask(state.updateTree(builder));
} catch (e) { caught = e; }
expect(caught).toBeInstanceOf(StateTreeCycleError);
const cycle = (caught as StateTreeCycleError).cycle;
expect(cycle[0]).toBe(cycle[cycle.length - 1]);
expect(cycle).toEqual(expect.arrayContaining(['cyc-a', 'cyc-b']));
});
});
describe('State dependencies - deferred resolution', () => {
/** Force evaluation order: place dependent subtree first under root so
* tree pre-order visits it before its dependency. */
it('resolves cross-subtree deps even when the dependent is scheduled first', async () => {
const state = newState();
const A = constLeaf();
const B = deriveFromDep('leaf-a');
const builder = state.build();
// B added FIRST - so its subtree comes before A's in tree pre-order.
builder.toRoot<Root>().apply(B as any, {}, { ref: 'leaf-b', dependsOn: ['leaf-a'] });
builder.toRoot<Root>().apply(A as any, { value: 4 }, { ref: 'leaf-a' });
await state.runTask(state.updateTree(builder));
expect((state.cells.get('leaf-a')!.obj as Leaf).data.value).toBe(4);
expect((state.cells.get('leaf-b')!.obj as Leaf).data.value).toBe(104);
});
it('propagates a clear error when a dep has errored and cannot resolve', async () => {
const state = newState();
const Boom = StateTransformer.create<Root, Leaf, {}>(NS, {
name: uniq('boom'),
from: [Root],
to: [Leaf],
display: { name: 'Boom' },
params: () => ({}) as any,
apply() { throw new Error('intentional'); }
});
const B = deriveFromDep('boom');
const builder = state.build();
builder.toRoot<Root>().apply(Boom as any, {}, { ref: 'boom' });
builder.toRoot<Root>().apply(B as any, {}, { ref: 'leaf-b', dependsOn: ['boom'] });
// The state surfaces transform errors via console.error; suppress the noise.
const err = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
await state.runTask(state.updateTree(builder));
} finally {
err.mockRestore();
}
const b: StateObjectCell = state.cells.get('leaf-b')!;
expect(b.status).toBe('error');
expect(b.errorText).toMatch(/Unresolved dependency|missing dep|intentional/);
});
});

View File

@@ -1,8 +1,7 @@
/**
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2025 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 { StateObject, StateObjectCell, StateObjectSelector } from './object';
@@ -25,22 +24,7 @@ import { arraySetAdd, arraySetRemove } from '../mol-util/array';
import { UniqueArray } from '../mol-data/generic/unique-array';
import { assignIfUndefined } from '../mol-util/object';
export { State, StateTreeCycleError };
/**
* Thrown when a cycle is detected in the state-tree dependency graph
* (the cross-edges defined by `transform.dependsOn` and effective
* dependencies derived from params). `cycle` holds the closed path,
* e.g. `['A', 'B', 'A']`.
*/
class StateTreeCycleError extends Error {
readonly cycle: StateTransform.Ref[];
constructor(cycle: StateTransform.Ref[]) {
super(`Cyclic state-tree dependency detected: ${cycle.join(' -> ')}`);
this.name = 'StateTreeCycleError';
this.cycle = cycle;
}
}
export { State };
class State {
private _tree: TransientTree;
@@ -510,21 +494,6 @@ interface UpdateContext {
wasAborted: boolean,
newCurrent?: Ref,
/**
* Refs that are scheduled to be (re-)evaluated in this update pass:
* the union of every `roots[i]` and its descendants. Used to distinguish
* "dep not yet produced this pass" (defer + retry) from "dep can never
* be produced this pass" (throw).
*/
scheduled?: Set<Ref>,
/**
* Subtree roots that were skipped because at least one of their
* dependencies hadn't been evaluated yet. Drained after the main loop
* makes a fixpoint pass.
*/
deferred?: Ref[],
getCellData: (ref: string) => any
}
@@ -604,45 +573,11 @@ async function update(ctx: UpdateContext) {
// Set status of cells that will be updated to 'pending'.
initCellStatus(ctx, roots);
// Build the set of refs that will be (re-)evaluated this pass so that
// `updateSubtree` can distinguish "dep not produced yet" (defer + retry)
// from "dep can never be produced" (throw via resolveDependencies).
ctx.scheduled = collectScheduled(ctx, roots);
// Sequentially update all the subtrees.
for (const root of roots) {
await updateSubtree(ctx, root);
}
// Drain the deferred queue: nodes whose `dependsOn` cells weren't yet
// evaluated when first visited get retried until either all succeed or
// a full pass makes no forward progress (true deadlock / unresolvable).
if (ctx.deferred && ctx.deferred.length > 0) {
while (ctx.deferred.length > 0) {
const pending = ctx.deferred;
ctx.deferred = [];
let progress = false;
for (const ref of pending) {
const before = ctx.deferred.length;
await updateSubtree(ctx, ref);
// Forward progress = this attempt did not re-defer the same ref.
const reDeferred = ctx.deferred.length > before
&& ctx.deferred[ctx.deferred.length - 1] === ref;
if (!reDeferred) progress = true;
}
if (!progress) {
const stuck = ctx.deferred.map(r => {
const c = ctx.cells.get(r);
if (!c) return r;
const blockers = pendingBlockers(ctx, c);
return `${r} (waiting on: ${blockers.length ? blockers.join(', ') : 'unknown'})`;
});
ctx.deferred = [];
throw new Error(`Unresolved dependency: ${stuck.join('; ')}`);
}
}
}
// Sync cell states
if (!ctx.editInfo) {
syncNewStates(ctx);
@@ -728,13 +663,7 @@ function setCellStatus(ctx: UpdateContext, ref: Ref, status: StateObjectCell.Sta
}
function initCellStatusVisitor(t: StateTransform, _: any, ctx: UpdateContext) {
const cell = ctx.cells.get(t.ref)!;
cell.transform = t;
if (relinkCells(cell, ctx)) {
// Edges changed (e.g. param update added/removed dependencies) —
// re-verify there is no cycle.
checkDependenciesCycle(cell);
}
ctx.cells.get(t.ref)!.transform = t;
setCellStatus(ctx, t.ref, 'pending');
}
@@ -778,10 +707,9 @@ function addCellsVisitor(transform: StateTransform, _: any, { ctx, added, visite
// type LinkCellsCtx = { ctx: UpdateContext, visited: Set<Ref>, dependent: UniqueArray<Ref, StateObjectCell> }
function linkCells(target: StateObjectCell, ctx: UpdateContext) {
const effective = StateTransform.getEffectiveDependsOn(target.transform);
if (effective.length === 0) return;
if (!target.transform.dependsOn) return;
for (const ref of effective) {
for (const ref of target.transform.dependsOn) {
const t = ctx.tree.transforms.get(ref);
if (!t) {
throw new Error(`Cannot depend on a non-existent transform.`);
@@ -793,103 +721,6 @@ function linkCells(target: StateObjectCell, ctx: UpdateContext) {
}
}
/**
* Diff the current outgoing dependency edges of `target` against the effective
* set derived from its (possibly updated) transform. Unlinks stale edges and
* links new ones so the dependency graph reflects param changes. Idempotent
* when nothing has changed. Returns `true` if any edge was added or removed.
*/
function relinkCells(target: StateObjectCell, ctx: UpdateContext): boolean {
const effective = StateTransform.getEffectiveDependsOn(target.transform);
const current = target.dependencies.dependsOn;
// Fast path: same number and all current refs are still in effective.
if (current.length === effective.length) {
let same = true;
for (const c of current) {
if (effective.indexOf(c.transform.ref) < 0) { same = false; break; }
}
if (same) return false;
}
const desired = new Set(effective);
let changed = false;
// Remove stale outgoing edges.
for (let i = current.length - 1; i >= 0; i--) {
const dep = current[i];
if (!desired.has(dep.transform.ref)) {
current.splice(i, 1);
arraySetRemove(dep.dependencies.dependentBy, target);
changed = true;
}
}
// Add new outgoing edges.
const have = new Set(current.map(c => c.transform.ref));
for (const ref of effective) {
if (have.has(ref)) continue;
const t = ctx.tree.transforms.get(ref);
if (!t) {
throw new Error(`Cannot depend on a non-existent transform.`);
}
const cell = ctx.cells.get(ref)!;
arraySetAdd(target.dependencies.dependsOn, cell);
arraySetAdd(cell.dependencies.dependentBy, target);
changed = true;
}
return changed;
}
/**
* Detect a cycle in the `dependsOn` graph reachable from `start`.
*
* Iterative DFS, returning the closed cycle path (first occurrence of the
* repeated ref appended at the end) or `undefined` if none. Operates on the
* already-linked cell graph; safe to call after `linkCells` / `relinkCells`.
*/
function detectDependenciesCycle(start: StateObjectCell): StateTransform.Ref[] | undefined {
if (start.dependencies.dependsOn.length === 0) return void 0;
const stack: { cell: StateObjectCell, idx: number }[] = [{ cell: start, idx: 0 }];
const onPath = new Set<StateTransform.Ref>([start.transform.ref]);
const fully = new Set<StateTransform.Ref>();
while (stack.length > 0) {
const top = stack[stack.length - 1];
const deps = top.cell.dependencies.dependsOn;
if (top.idx >= deps.length) {
onPath.delete(top.cell.transform.ref);
fully.add(top.cell.transform.ref);
stack.pop();
continue;
}
const next = deps[top.idx++];
const ref = next.transform.ref;
if (fully.has(ref)) continue;
if (onPath.has(ref)) {
const path: StateTransform.Ref[] = [];
let started = false;
for (const s of stack) {
if (started || s.cell.transform.ref === ref) {
started = true;
path.push(s.cell.transform.ref);
}
}
path.push(ref);
return path;
}
onPath.add(ref);
stack.push({ cell: next, idx: 0 });
}
return void 0;
}
function checkDependenciesCycle(cell: StateObjectCell) {
const cycle = detectDependenciesCycle(cell);
if (cycle) throw new StateTreeCycleError(cycle);
}
function initCells(ctx: UpdateContext, roots: Ref[]) {
const initCtx: InitCellsCtx = { ctx, visited: new Set(), added: [] };
@@ -903,12 +734,6 @@ function initCells(ctx: UpdateContext, roots: Ref[]) {
linkCells(cell, ctx);
}
// Cycle detection over the dependency cross-edges of newly added cells.
// Parent/child is already a tree, so cycles can only enter via dependsOn.
for (const cell of initCtx.added) {
checkDependenciesCycle(cell);
}
let dependent: UniqueArray<Ref, StateObjectCell>;
// Find dependent cells
@@ -1009,54 +834,7 @@ type UpdateNodeResult =
const ParentNullErrorText = 'Parent is null';
function collectScheduledVisitor(t: StateTransform, _: any, s: Set<Ref>) {
s.add(t.ref);
}
function collectScheduled(ctx: UpdateContext, roots: Ref[]): Set<Ref> {
const out = new Set<Ref>();
for (const root of roots) {
const node = ctx.tree.transforms.get(root);
if (!node) continue;
StateTree.doPreOrder(ctx.tree, node, out, collectScheduledVisitor);
}
return out;
}
/**
* Refs that `cell` depends on which are scheduled for evaluation in this
* pass but haven't produced an object yet (and aren't in an error state).
* An empty result means deps are either ready, in error, or out-of-scope —
* in any of those cases the cell should proceed (and may throw at
* `resolveDependencies` time if the dep is genuinely missing).
*/
function pendingBlockers(ctx: UpdateContext, cell: StateObjectCell): Ref[] {
const blockers: Ref[] = [];
const scheduled = ctx.scheduled;
if (!scheduled) return blockers;
for (const dep of cell.dependencies.dependsOn) {
if (dep.obj) continue;
if (dep.status === 'error') continue;
if (!scheduled.has(dep.transform.ref)) continue;
blockers.push(dep.transform.ref);
}
return blockers;
}
async function updateSubtree(ctx: UpdateContext, root: Ref) {
const cell = ctx.cells.get(root);
if (cell && cell.dependencies.dependsOn.length > 0) {
const blockers = pendingBlockers(ctx, cell);
if (blockers.length > 0) {
// A dependency hasn't been produced yet this pass — defer this
// subtree and retry after other roots have run. Children will be
// visited when the deferred re-attempt succeeds.
if (!ctx.deferred) ctx.deferred = [];
ctx.deferred.push(root);
return;
}
}
setCellStatus(ctx, root, 'processing');
let isNull = false;

View File

@@ -1,15 +1,12 @@
/**
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2025 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 { StateTransformer } from './transformer';
import { UUID } from '../mol-util';
import { hashMurmur128o } from '../mol-data/util/hash-functions';
import { ParamDefinition as PD } from '../mol-util/param-definition';
import { arraySetAdd, arraySetRemove } from '../mol-util/array';
export { Transform as StateTransform };
@@ -173,97 +170,6 @@ namespace Transform {
return true;
}
/**
* Returns the default param schema for the transform's transformer, caching
* the result on the transform instance. Returns `undefined` if the transformer
* has no `params` definition or if calling it throws.
*/
export function tryGetDefaultParamDefinition(t: Transform): PD.Params | undefined {
const any = t as any;
if (any._defaultSchemaSet) return any._defaultSchema;
any._defaultSchemaSet = true;
try {
const def = t.transformer.definition;
if (def.params) {
any._defaultSchema = def.params(undefined as any, undefined) as PD.Params;
}
} catch {
// Schema could not be obtained.
}
return any._defaultSchema;
}
/**
* Compute the effective set of sibling-like dependencies for a transform.
*
* Combines (in order, de-duplicated):
* 1. Explicit `t.dependsOn` (back-compat / non-param refs).
* 2. Refs from `transformer.definition.getDependencies(params)` if defined.
* 3. Refs collected from `PD.ValueRef` / `PD.DataRef` parameter values.
*
* Self-references and the root ref are filtered out. If the param schema
* can't be obtained, auto-derivation falls back to a structural scan of
* parameter values for `{ ref, getValue }` shaped objects.
*/
export function getEffectiveDependsOn(t: Transform): Ref[] {
const out: string[] = [];
if (t.dependsOn) {
for (const r of t.dependsOn) {
arraySetAdd(out, r);
}
}
const def = t.transformer.definition;
const params = t.params as any;
if (def.getDependencies && params) {
try {
const extra = def.getDependencies(params);
if (extra) {
for (const r of extra) {
arraySetAdd(out, r);
}
}
} catch {
// Keep reconciliation robust if a user hook misbehaves.
}
}
if (params) {
const schema = tryGetDefaultParamDefinition(t);
if (schema) {
PD.collectRefs(schema, params, out);
} else {
collectStructuralRefs(params, out);
}
}
// Filter out self-references and the root ref.
arraySetRemove(out, t.ref);
arraySetRemove(out, RootRef);
return out;
}
function collectStructuralRefs(value: any, out: string[], depth = 0): string[] {
if (!value || typeof value !== 'object' || depth > 6) return out;
if (Array.isArray(value)) {
for (const v of value) {
collectStructuralRefs(v, out, depth + 1);
}
return out;
}
const ref = (value as any).ref;
if (typeof ref === 'string' && typeof (value as any).getValue === 'function') {
arraySetAdd(out, ref);
return out;
}
for (const k of Object.keys(value)) {
collectStructuralRefs(value[k], out, depth + 1);
}
return out;
}
const _emptyParams = {};
/** Updates the version of the transform to be computed as hash of the parameters */
export function setParamsHashVersion(t: Transform) {

View File

@@ -1,8 +1,7 @@
/**
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2025 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 { Task } from '../mol-task';
@@ -119,16 +118,6 @@ namespace Transformer {
/** Custom conversion to and from JSON */
readonly customSerialization?: { toJSON(params: P, obj?: B): any, fromJSON(data: any): P }
/**
* Derive sibling-like state-tree dependencies (other cells' refs) from the
* current parameter values. Returned refs are merged with explicit
* `dependsOn` and any refs auto-collected from `PD.ValueRef` / `PD.DataRef`
* parameters to form the effective dependency set used by reconciliation.
*
* Return an empty array or undefined to opt out.
*/
getDependencies?(params: P): StateTransform.Ref[] | undefined
}
export interface Definition<A extends StateObject = StateObject, B extends StateObject = StateObject, P extends {} = {}> extends DefinitionBase<A, B, P> {

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

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2024 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>
@@ -18,7 +18,6 @@ import { getColorListFromName, ColorListName } from './color/lists';
import { Asset } from './assets';
import { ColorListEntry } from './color/color';
import { EPSILON } from '../mol-math/linear-algebra/3d/common';
import { arraySetAdd } from './array';
export namespace ParamDefinition {
export interface Info {
@@ -446,47 +445,6 @@ export namespace ParamDefinition {
}
}
function collectRefValue(p: Any, value: any, out: string[]): string[] {
if (value === undefined || value === null) return out;
if (p.type === 'value-ref' || p.type === 'data-ref') {
const v = value as ValueRef['defaultValue'];
if (v && typeof v.ref === 'string' && v.ref) {
arraySetAdd(out, v.ref);
}
} else if (p.type === 'group') {
collectRefsImpl(p.params, value, out);
} else if (p.type === 'mapped') {
const v = value as NamedParams;
if (!v) return out;
const param = p.map(v.name);
collectRefValue(param, v.params, out);
} else if (p.type === 'object-list') {
if (!hasValueRef(p.element)) return out;
for (const e of value) {
collectRefsImpl(p.element, e, out);
}
}
return out;
}
function collectRefsImpl(params: Params, values: any, out: string[]): string[] {
for (const n of Object.keys(params)) {
collectRefValue(params[n], values?.[n], out);
}
return out;
}
/**
* Collect all non-empty `ref` strings of `value-ref` and `data-ref` parameter
* values into a set. Used by `mol-state` to derive transform dependencies from
* parameter values.
*/
export function collectRefs(params: Params, values: any, out: string[]): string[] {
if (!params || !values) return out;
return collectRefsImpl(params, values, out);
}
export function setDefaultValues<T extends Params>(params: T, defaultValues: Values<T>) {
for (const k of Object.keys(params)) {
if (params[k].isOptional) continue;

View File

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