mirror of
https://github.com/molstar/molstar.git
synced 2026-06-05 05:44:23 +08:00
Compare commits
28 Commits
kinemages
...
optimize-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa63209384 | ||
|
|
f238b00ef9 | ||
|
|
1ac4980348 | ||
|
|
7ade6ab59b | ||
|
|
cb0fb2b0b5 | ||
|
|
206ee19138 | ||
|
|
43ce4ab498 | ||
|
|
57cce9f80f | ||
|
|
9be847c74b | ||
|
|
055dfd4946 | ||
|
|
9de8334af5 | ||
|
|
57580a5e6b | ||
|
|
7da4a85459 | ||
|
|
27f251e8e4 | ||
|
|
8d2a44983e | ||
|
|
63a585d88a | ||
|
|
a4b5a16fcd | ||
|
|
86bf859a63 | ||
|
|
1b8117d3f1 | ||
|
|
400e2bbc45 | ||
|
|
e2e26c7e9c | ||
|
|
5ca9020cbf | ||
|
|
ea4c411d5c | ||
|
|
ba7e3fe827 | ||
|
|
ca5a50bd53 | ||
|
|
bccf54fabe | ||
|
|
fb912036af | ||
|
|
19fec3bbc1 |
10
CHANGELOG.md
10
CHANGELOG.md
@@ -4,13 +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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
184
src/mol-math/linear-algebra/3d/optimize-direction.ts
Normal file
184
src/mol-math/linear-algebra/3d/optimize-direction.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
27
src/mol-math/linear-algebra/_spec/optimize-direction.spec.ts
Normal file
27
src/mol-math/linear-algebra/_spec/optimize-direction.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
59
src/mol-repr/volume/_spec/dot.spec.ts
Normal file
59
src/mol-repr/volume/_spec/dot.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
export const VERSION = '0.9.12';
|
||||
export const VERSION = '0.9.13';
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"module": "CommonJS",
|
||||
"outDir": "lib/commonjs"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user