mirror of
https://github.com/molstar/molstar.git
synced 2026-06-08 07:54:28 +08:00
Compare commits
8 Commits
obj-format
...
qol-plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a523890c4 | ||
|
|
74433bd601 | ||
|
|
af17c2eda5 | ||
|
|
530c87b553 | ||
|
|
5708908b66 | ||
|
|
fe996870cc | ||
|
|
26340bf215 | ||
|
|
60a7cab28f |
10
CHANGELOG.md
10
CHANGELOG.md
@@ -14,12 +14,22 @@ Note that since we don't clearly distinguish between a public and private interf
|
||||
- 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
|
||||
- Adds File/Open and drag-and-drop support for Kinemage files in the viewer app
|
||||
- Fix bugs in ModelServer surroundingLigands endpoint, resulting in omitWater not honored
|
||||
- Fix `Volume` and `Isosurface` getBoundingSphere ignoring instances
|
||||
- Fix aromatic ring detection not accounting for hybridization
|
||||
- Add axis param to camera spin/rock animation
|
||||
- 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
|
||||
- Non-covalent interactions: water bridge support
|
||||
- Download Structure From AlphaFoldDB allows IDs with version suffix (version is ignored)
|
||||
- Add `extensions/plugin` with several QoL improvements
|
||||
- Standalone useful loading functions previously avaiable only in the `Viewer` class
|
||||
- Standalong plugin interactivity helper function previously available only via the `Viewer` class
|
||||
- View models (and hooks) for more straightforward usafe in React (and in other UI libraries)
|
||||
|
||||
## [v5.9.0] - 2026-05-03
|
||||
- Fix edge case when `PluginSpec.animations` is empty
|
||||
|
||||
@@ -125,7 +125,8 @@
|
||||
"Kim Juho <juho_kim@outlook.com>",
|
||||
"Victoria Doshchenko <doshchenko.victoria@gmail.com>",
|
||||
"Diego del Alamo <diego.delalamo@gmail.com>",
|
||||
"Tianzhen Lin (Tangent) <tangent@usa.net>"
|
||||
"Tianzhen Lin (Tangent) <tangent@usa.net>",
|
||||
"Russ Taylor <russ@reliasolve.com>"
|
||||
],
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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>
|
||||
@@ -7,53 +7,30 @@
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
*/
|
||||
|
||||
import { AssemblySymmetryConfig } from '../../extensions/assembly-symmetry';
|
||||
import { loadMVSData, loadMVSX } from '../../extensions/mvs/components/formats';
|
||||
import { loadMVS, MolstarLoadingExtension } from '../../extensions/mvs/load';
|
||||
import { MVSData } from '../../extensions/mvs/mvs-data';
|
||||
import { StringLike } from '../../mol-io/common/string-like';
|
||||
import { Structure, StructureElement } from '../../mol-model/structure';
|
||||
import { MolstarLoadingExtension } from '../../extensions/mvs/load';
|
||||
import { applyStructureInteractivity, StructureInteractivityOptions } from '../../extensions/plugin/interactivity';
|
||||
import * as loaders from '../../extensions/plugin/loaders';
|
||||
import { Volume } from '../../mol-model/volume';
|
||||
import { OpenFiles } from '../../mol-plugin-state/actions/file';
|
||||
import { DownloadStructure, PdbDownloadProvider } from '../../mol-plugin-state/actions/structure';
|
||||
import { DownloadDensity } from '../../mol-plugin-state/actions/volume';
|
||||
import { PresetTrajectoryHierarchy } from '../../mol-plugin-state/builder/structure/hierarchy-preset';
|
||||
import { StructureRepresentationPresetProvider } from '../../mol-plugin-state/builder/structure/representation-preset';
|
||||
import { PluginComponent } from '../../mol-plugin-state/component';
|
||||
import { BuiltInCoordinatesFormat } from '../../mol-plugin-state/formats/coordinates';
|
||||
import { BuiltInTopologyFormat } from '../../mol-plugin-state/formats/topology';
|
||||
import { BuiltInTrajectoryFormat } from '../../mol-plugin-state/formats/trajectory';
|
||||
import { BuildInVolumeFormat } from '../../mol-plugin-state/formats/volume';
|
||||
import { createVolumeRepresentationParams } from '../../mol-plugin-state/helpers/volume-representation-params';
|
||||
import { PluginStateObject } from '../../mol-plugin-state/objects';
|
||||
import { StateTransforms } from '../../mol-plugin-state/transforms';
|
||||
import { TrajectoryFromModelAndCoordinates } from '../../mol-plugin-state/transforms/model';
|
||||
import { createPluginUI } from '../../mol-plugin-ui';
|
||||
import { PluginUIContext } from '../../mol-plugin-ui/context';
|
||||
import { renderReact18 } from '../../mol-plugin-ui/react18';
|
||||
import { DefaultPluginUISpec, PluginUISpec } from '../../mol-plugin-ui/spec';
|
||||
import { PluginBehaviors } from '../../mol-plugin/behavior';
|
||||
import { PluginCommands } from '../../mol-plugin/commands';
|
||||
import { PluginConfig } from '../../mol-plugin/config';
|
||||
import { PluginState } from '../../mol-plugin/state';
|
||||
import { MolScriptBuilder } from '../../mol-script/language/builder';
|
||||
import { Expression } from '../../mol-script/language/expression';
|
||||
import { StateObjectSelector } from '../../mol-state';
|
||||
import { Task } from '../../mol-task';
|
||||
import { Asset } from '../../mol-util/assets';
|
||||
import { Color } from '../../mol-util/color';
|
||||
import { ExtensionMap } from './extensions';
|
||||
import { decodeColor } from '../../mol-util/color/utils';
|
||||
import '../../mol-util/polyfill';
|
||||
import { DefaultViewerOptions, ViewerOptions } from './options';
|
||||
import { createViewerSpec } from './plugin-spec';
|
||||
import { ViewerAutoPreset } from './presets';
|
||||
|
||||
export { PLUGIN_VERSION as version } from '../../mol-plugin/version';
|
||||
export { consoleStats, isDebugMode, isProductionMode, isTimingMode, setDebugMode, setProductionMode, setTimingMode } from '../../mol-util/debug';
|
||||
|
||||
import { decodeColor } from '../../mol-util/color/utils';
|
||||
import '../../mol-util/polyfill';
|
||||
import { ViewerAutoPreset } from './presets';
|
||||
import { CameraFocusOptions } from '../../mol-plugin-state/manager/camera';
|
||||
import { PluginSpec } from '../../mol-plugin/spec';
|
||||
import { NoPrimaryFocusLociBindings } from '../../mol-plugin/behavior/dynamic/camera';
|
||||
// re-export for backwards compatibility, but these should ideally be imported from the plugin extension directly
|
||||
// TODO: consider removing these in v6.0
|
||||
export type { LoadStructureOptions, LoadTrajectoryParams, VolumeIsovalueInfo } from '../../extensions/plugin/loaders';
|
||||
|
||||
export class Viewer {
|
||||
private _events = new PluginComponent();
|
||||
@@ -64,102 +41,7 @@ export class Viewer {
|
||||
}
|
||||
|
||||
static async create(elementOrId: string | HTMLElement, options: Partial<ViewerOptions> = {}) {
|
||||
const definedOptions = {} as any;
|
||||
// filter for defined properies only so the default values
|
||||
// are property applied
|
||||
for (const p of Object.keys(options) as (keyof ViewerOptions)[]) {
|
||||
if (options[p] !== void 0) definedOptions[p] = options[p];
|
||||
}
|
||||
|
||||
const o: ViewerOptions = { ...DefaultViewerOptions, ...definedOptions };
|
||||
const defaultSpec = DefaultPluginUISpec();
|
||||
|
||||
const disabledExtension = new Set(o.disabledExtensions ?? []);
|
||||
let baseBehaviors = defaultSpec.behaviors;
|
||||
|
||||
if (o.viewportFocusBehavior === 'disabled') {
|
||||
baseBehaviors = baseBehaviors.filter(b =>
|
||||
b.transformer !== PluginBehaviors.Camera.FocusLoci
|
||||
&& b.transformer !== PluginBehaviors.Representation.FocusLoci
|
||||
);
|
||||
} else if (o.viewportFocusBehavior === 'secondary-zoom') {
|
||||
baseBehaviors = baseBehaviors.filter(b =>
|
||||
b.transformer !== PluginBehaviors.Camera.FocusLoci
|
||||
&& b.transformer !== PluginBehaviors.Representation.FocusLoci
|
||||
);
|
||||
|
||||
baseBehaviors.push(PluginSpec.Behavior(PluginBehaviors.Camera.FocusLoci, {
|
||||
bindings: NoPrimaryFocusLociBindings
|
||||
}));
|
||||
}
|
||||
|
||||
const spec: PluginUISpec = {
|
||||
canvas3d: {
|
||||
...defaultSpec.canvas3d,
|
||||
},
|
||||
actions: defaultSpec.actions,
|
||||
behaviors: [
|
||||
...baseBehaviors,
|
||||
...o.extensions.filter(e => !disabledExtension.has(e)).map(e => ExtensionMap[e]),
|
||||
],
|
||||
animations: [...defaultSpec.animations || []],
|
||||
customParamEditors: defaultSpec.customParamEditors,
|
||||
customFormats: o?.customFormats,
|
||||
layout: {
|
||||
initial: {
|
||||
isExpanded: o.layoutIsExpanded,
|
||||
showControls: o.layoutShowControls,
|
||||
controlsDisplay: o.layoutControlsDisplay,
|
||||
regionState: {
|
||||
bottom: 'full',
|
||||
left: o.collapseLeftPanel ? 'collapsed' : 'full',
|
||||
right: o.collapseRightPanel ? 'hidden' : 'full',
|
||||
top: 'full',
|
||||
}
|
||||
},
|
||||
},
|
||||
components: {
|
||||
...defaultSpec.components,
|
||||
controls: {
|
||||
...defaultSpec.components?.controls,
|
||||
top: o.layoutShowSequence ? undefined : 'none',
|
||||
bottom: o.layoutShowLog ? undefined : 'none',
|
||||
left: o.layoutShowLeftPanel ? undefined : 'none',
|
||||
},
|
||||
remoteState: o.layoutShowRemoteState ? 'default' : 'none',
|
||||
},
|
||||
config: [
|
||||
[PluginConfig.General.DisableAntialiasing, o.disableAntialiasing],
|
||||
[PluginConfig.General.PixelScale, o.pixelScale],
|
||||
[PluginConfig.General.PickScale, o.pickScale],
|
||||
[PluginConfig.General.Transparency, o.transparency],
|
||||
[PluginConfig.General.PreferWebGl1, o.preferWebgl1],
|
||||
[PluginConfig.General.AllowMajorPerformanceCaveat, o.allowMajorPerformanceCaveat],
|
||||
[PluginConfig.General.PowerPreference, o.powerPreference],
|
||||
[PluginConfig.General.ResolutionMode, o.resolutionMode],
|
||||
[PluginConfig.Viewport.ShowReset, o.viewportShowReset],
|
||||
[PluginConfig.Viewport.ShowScreenshotControls, o.viewportShowScreenshotControls],
|
||||
[PluginConfig.Viewport.ShowControls, o.viewportShowControls],
|
||||
[PluginConfig.Viewport.ShowExpand, o.viewportShowExpand],
|
||||
[PluginConfig.Viewport.ShowToggleFullscreen, o.viewportShowToggleFullscreen],
|
||||
[PluginConfig.Viewport.ShowSettings, o.viewportShowSettings],
|
||||
[PluginConfig.Viewport.ShowSelectionMode, o.viewportShowSelectionMode],
|
||||
[PluginConfig.Viewport.ShowAnimation, o.viewportShowAnimation],
|
||||
[PluginConfig.Viewport.ShowTrajectoryControls, o.viewportShowTrajectoryControls],
|
||||
[PluginConfig.State.DefaultServer, o.pluginStateServer],
|
||||
[PluginConfig.State.CurrentServer, o.pluginStateServer],
|
||||
[PluginConfig.VolumeStreaming.DefaultServer, o.volumeStreamingServer],
|
||||
[PluginConfig.VolumeStreaming.Enabled, !o.volumeStreamingDisabled],
|
||||
[PluginConfig.Download.DefaultPdbProvider, o.pdbProvider],
|
||||
[PluginConfig.Download.DefaultEmdbProvider, o.emdbProvider],
|
||||
[PluginConfig.Structure.DefaultRepresentationPreset, ViewerAutoPreset.id],
|
||||
[PluginConfig.Structure.SaccharideCompIdMapType, o.saccharideCompIdMapType],
|
||||
[AssemblySymmetryConfig.DefaultServerType, o.rcsbAssemblySymmetryDefaultServerType],
|
||||
[AssemblySymmetryConfig.DefaultServerUrl, o.rcsbAssemblySymmetryDefaultServerUrl],
|
||||
[AssemblySymmetryConfig.ApplyColors, o.rcsbAssemblySymmetryApplyColors],
|
||||
...(o.config ?? []),
|
||||
]
|
||||
};
|
||||
const spec = createViewerSpec(options);
|
||||
|
||||
const element = typeof elementOrId === 'string'
|
||||
? document.getElementById(elementOrId)
|
||||
@@ -176,9 +58,9 @@ export class Viewer {
|
||||
}
|
||||
});
|
||||
|
||||
plugin.canvas3d?.setProps({ illumination: { enabled: o.illumination } });
|
||||
if (o.viewportBackgroundColor) {
|
||||
const backgroundColor = decodeColor(o.viewportBackgroundColor);
|
||||
plugin.canvas3d?.setProps({ illumination: { enabled: options.illumination ?? DefaultViewerOptions.illumination } });
|
||||
if (options.viewportBackgroundColor ?? DefaultViewerOptions.viewportBackgroundColor) {
|
||||
const backgroundColor = decodeColor(options.viewportBackgroundColor ?? DefaultViewerOptions.viewportBackgroundColor);
|
||||
if (typeof backgroundColor === 'number') {
|
||||
plugin.canvas3d?.setProps({ renderer: { backgroundColor } });
|
||||
}
|
||||
@@ -193,63 +75,27 @@ export class Viewer {
|
||||
subscribe = this._events.subscribe.bind(this._events);
|
||||
|
||||
setRemoteSnapshot(id: string) {
|
||||
const url = `${this.plugin.config.get(PluginConfig.State.CurrentServer)}/get/${id}`;
|
||||
return PluginCommands.State.Snapshots.Fetch(this.plugin, { url });
|
||||
return loaders.setRemoteSnapshot(this.plugin, id);
|
||||
}
|
||||
|
||||
loadSnapshotFromUrl(url: string, type: PluginState.SnapshotType) {
|
||||
return PluginCommands.State.Snapshots.OpenUrl(this.plugin, { url, type });
|
||||
return loaders.loadSnapshotFromUrl(this.plugin, url, type);
|
||||
}
|
||||
|
||||
loadStructureFromUrl(url: string, format: BuiltInTrajectoryFormat = 'mmcif', isBinary = false, options?: LoadStructureOptions & { label?: string }) {
|
||||
const params = DownloadStructure.createDefaultParams(this.plugin.state.data.root.obj!, this.plugin);
|
||||
return this.plugin.runTask(this.plugin.state.data.applyAction(DownloadStructure, {
|
||||
source: {
|
||||
name: 'url',
|
||||
params: {
|
||||
url: Asset.Url(url),
|
||||
format: format as any,
|
||||
isBinary,
|
||||
label: options?.label,
|
||||
options: { ...params.source.params.options, representationParams: options?.representationParams as any },
|
||||
}
|
||||
}
|
||||
}));
|
||||
loadStructureFromUrl(url: string, format: BuiltInTrajectoryFormat = 'mmcif', isBinary = false, options?: loaders.LoadStructureOptions & { label?: string }) {
|
||||
return loaders.loadStructureFromUrl(this.plugin, url, format, isBinary, options);
|
||||
}
|
||||
|
||||
async loadAllModelsOrAssemblyFromUrl(url: string, format: BuiltInTrajectoryFormat = 'mmcif', isBinary = false, options?: LoadStructureOptions) {
|
||||
const plugin = this.plugin;
|
||||
|
||||
const data = await plugin.builders.data.download({ url, isBinary }, { state: { isGhost: true } });
|
||||
const trajectory = await plugin.builders.structure.parseTrajectory(data, format);
|
||||
|
||||
await this.plugin.builders.structure.hierarchy.applyPreset(trajectory, 'all-models', { useDefaultIfSingleModel: true, representationPresetParams: options?.representationParams });
|
||||
loadAllModelsOrAssemblyFromUrl(url: string, format: BuiltInTrajectoryFormat = 'mmcif', isBinary = false, options?: loaders.LoadStructureOptions) {
|
||||
return loaders.loadAllModelsOrAssemblyFromUrl(this.plugin, url, format, isBinary, options);
|
||||
}
|
||||
|
||||
async loadStructureFromData(data: string | number[], format: BuiltInTrajectoryFormat, options?: { dataLabel?: string }) {
|
||||
const _data = await this.plugin.builders.data.rawData({ data, label: options?.dataLabel });
|
||||
const trajectory = await this.plugin.builders.structure.parseTrajectory(_data, format);
|
||||
await this.plugin.builders.structure.hierarchy.applyPreset(trajectory, 'default');
|
||||
loadStructureFromData(data: string | number[], format: BuiltInTrajectoryFormat, options?: { dataLabel?: string }) {
|
||||
return loaders.loadStructureFromData(this.plugin, data, format, options);
|
||||
}
|
||||
|
||||
loadPdb(pdb: string, options?: LoadStructureOptions) {
|
||||
const params = DownloadStructure.createDefaultParams(this.plugin.state.data.root.obj!, this.plugin);
|
||||
const provider = this.plugin.config.get(PluginConfig.Download.DefaultPdbProvider)!;
|
||||
return this.plugin.runTask(this.plugin.state.data.applyAction(DownloadStructure, {
|
||||
source: {
|
||||
name: 'pdb' as const,
|
||||
params: {
|
||||
provider: {
|
||||
id: pdb,
|
||||
server: {
|
||||
name: provider,
|
||||
params: PdbDownloadProvider[provider].defaultValue as any
|
||||
}
|
||||
},
|
||||
options: { ...params.source.params.options, representationParams: options?.representationParams as any },
|
||||
}
|
||||
}
|
||||
}));
|
||||
loadPdb(pdb: string, options?: loaders.LoadStructureOptions) {
|
||||
return loaders.loadPdb(this.plugin, pdb, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,67 +106,19 @@ export class Viewer {
|
||||
}
|
||||
|
||||
loadPdbIhm(pdbIhm: string) {
|
||||
const params = DownloadStructure.createDefaultParams(this.plugin.state.data.root.obj!, this.plugin);
|
||||
return this.plugin.runTask(this.plugin.state.data.applyAction(DownloadStructure, {
|
||||
source: {
|
||||
name: 'pdb-ihm' as const,
|
||||
params: {
|
||||
provider: {
|
||||
id: pdbIhm,
|
||||
encoding: 'bcif',
|
||||
},
|
||||
options: params.source.params.options,
|
||||
}
|
||||
}
|
||||
}));
|
||||
return loaders.loadPdbIhm(this.plugin, pdbIhm);
|
||||
}
|
||||
|
||||
loadEmdb(emdb: string, options?: { detail?: number }) {
|
||||
const provider = this.plugin.config.get(PluginConfig.Download.DefaultEmdbProvider)!;
|
||||
return this.plugin.runTask(this.plugin.state.data.applyAction(DownloadDensity, {
|
||||
source: {
|
||||
name: 'pdb-emd-ds' as const,
|
||||
params: {
|
||||
provider: {
|
||||
id: emdb,
|
||||
server: provider,
|
||||
},
|
||||
detail: options?.detail ?? 3,
|
||||
}
|
||||
}
|
||||
}));
|
||||
return loaders.loadEmdb(this.plugin, emdb, options);
|
||||
}
|
||||
|
||||
loadAlphaFoldDb(afdb: string) {
|
||||
const params = DownloadStructure.createDefaultParams(this.plugin.state.data.root.obj!, this.plugin);
|
||||
return this.plugin.runTask(this.plugin.state.data.applyAction(DownloadStructure, {
|
||||
source: {
|
||||
name: 'alphafolddb' as const,
|
||||
params: {
|
||||
provider: {
|
||||
id: afdb,
|
||||
encoding: 'bcif'
|
||||
},
|
||||
options: {
|
||||
...params.source.params.options,
|
||||
representation: 'preset-structure-representation-ma-quality-assessment-plddt'
|
||||
},
|
||||
}
|
||||
}
|
||||
}));
|
||||
return loaders.loadAlphaFoldDb(this.plugin, afdb);
|
||||
}
|
||||
|
||||
loadModelArchive(id: string) {
|
||||
const params = DownloadStructure.createDefaultParams(this.plugin.state.data.root.obj!, this.plugin);
|
||||
return this.plugin.runTask(this.plugin.state.data.applyAction(DownloadStructure, {
|
||||
source: {
|
||||
name: 'modelarchive' as const,
|
||||
params: {
|
||||
id,
|
||||
options: params.source.params.options,
|
||||
}
|
||||
}
|
||||
}));
|
||||
return loaders.loadModelArchive(this.plugin, id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -362,76 +160,12 @@ export class Viewer {
|
||||
isLazy: true
|
||||
});
|
||||
*/
|
||||
async loadVolumeFromUrl({ url, format, isBinary }: { url: string, format: BuildInVolumeFormat, isBinary: boolean }, isovalues: VolumeIsovalueInfo[], options?: { entryId?: string | string[], isLazy?: boolean }) {
|
||||
const plugin = this.plugin;
|
||||
|
||||
if (!plugin.dataFormats.get(format)) {
|
||||
throw new Error(`Unknown density format: ${format}`);
|
||||
}
|
||||
|
||||
if (options?.isLazy) {
|
||||
const update = this.plugin.build();
|
||||
update.toRoot().apply(StateTransforms.Data.LazyVolume, {
|
||||
url,
|
||||
format,
|
||||
entryId: options?.entryId,
|
||||
isBinary,
|
||||
isovalues: isovalues.map(v => ({ alpha: 1, volumeIndex: 0, ...v }))
|
||||
});
|
||||
return update.commit();
|
||||
}
|
||||
|
||||
return plugin.dataTransaction(async () => {
|
||||
const data = await plugin.builders.data.download({ url, isBinary }, { state: { isGhost: true } });
|
||||
|
||||
const parsed = await plugin.dataFormats.get(format)!.parse(plugin, data, { entryId: options?.entryId });
|
||||
const firstVolume = (parsed.volume || parsed.volumes[0]) as StateObjectSelector<PluginStateObject.Volume.Data>;
|
||||
if (!firstVolume?.isOk) throw new Error('Failed to parse any volume.');
|
||||
|
||||
const repr = plugin.build();
|
||||
for (const iso of isovalues) {
|
||||
const volume: StateObjectSelector<PluginStateObject.Volume.Data> = parsed.volumes?.[iso.volumeIndex ?? 0] ?? parsed.volume;
|
||||
const volumeData = volume.cell!.obj!.data;
|
||||
repr
|
||||
.to(volume)
|
||||
.apply(StateTransforms.Representation.VolumeRepresentation3D, createVolumeRepresentationParams(this.plugin, firstVolume.data!, {
|
||||
type: 'isosurface',
|
||||
typeParams: { alpha: iso.alpha ?? 1, isoValue: Volume.adjustedIsoValue(volumeData, iso.value, iso.type) },
|
||||
color: 'uniform',
|
||||
colorParams: { value: iso.color }
|
||||
}));
|
||||
}
|
||||
|
||||
await repr.commit();
|
||||
});
|
||||
loadVolumeFromUrl({ url, format, isBinary }: { url: string, format: BuildInVolumeFormat, isBinary: boolean }, isovalues: loaders.VolumeIsovalueInfo[], options?: { entryId?: string | string[], isLazy?: boolean }) {
|
||||
return loaders.loadVolumeFromUrl(this.plugin, { url, format, isBinary }, isovalues, options);
|
||||
}
|
||||
|
||||
loadFullResolutionEMDBMap(emdbId: string, options: { isoValue: Volume.IsoValue, color?: Color }) {
|
||||
const plugin = this.plugin;
|
||||
const numericId = parseInt(emdbId.toUpperCase().replace('EMD-', ''));
|
||||
const url = `https://ftp.ebi.ac.uk/pub/databases/emdb/structures/EMD-${numericId}/map/emd_${numericId}.map.gz`;
|
||||
|
||||
return plugin.dataTransaction(async () => {
|
||||
const data = await plugin.build().toRoot()
|
||||
.apply(StateTransforms.Data.Download, { url, isBinary: true, label: emdbId }, { state: { isGhost: true } })
|
||||
.apply(StateTransforms.Data.DeflateData)
|
||||
.commit();
|
||||
|
||||
const parsed = await plugin.dataFormats.get('ccp4')!.parse(plugin, data, { entryId: emdbId });
|
||||
const firstVolume = (parsed.volume || parsed.volumes[0]) as StateObjectSelector<PluginStateObject.Volume.Data>;
|
||||
if (!firstVolume?.isOk) throw new Error('Failed to parse any volume.');
|
||||
|
||||
const volume: StateObjectSelector<PluginStateObject.Volume.Data> = parsed.volumes?.[0] ?? parsed.volume;
|
||||
await plugin.build()
|
||||
.to(volume)
|
||||
.apply(StateTransforms.Representation.VolumeRepresentation3D, createVolumeRepresentationParams(this.plugin, firstVolume.data!, {
|
||||
type: 'isosurface',
|
||||
typeParams: { alpha: 1, isoValue: options.isoValue },
|
||||
color: 'uniform',
|
||||
colorParams: { value: options.color ?? Color(0x33BB33) }
|
||||
}))
|
||||
.commit();
|
||||
});
|
||||
return loaders.loadFullResolutionEMDBMap(this.plugin, emdbId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -442,85 +176,23 @@ export class Viewer {
|
||||
* preset: 'all-models' // or 'default'
|
||||
* });
|
||||
*/
|
||||
async loadTrajectory(params: LoadTrajectoryParams) {
|
||||
const plugin = this.plugin;
|
||||
|
||||
let model: StateObjectSelector;
|
||||
|
||||
if (params.model.kind === 'model-data' || params.model.kind === 'model-url') {
|
||||
const data = params.model.kind === 'model-data'
|
||||
? await plugin.builders.data.rawData({ data: params.model.data, label: params.modelLabel })
|
||||
: await plugin.builders.data.download({ url: params.model.url, isBinary: params.model.isBinary, label: params.modelLabel });
|
||||
|
||||
const trajectory = await plugin.builders.structure.parseTrajectory(data, params.model.format ?? 'mmcif');
|
||||
model = await plugin.builders.structure.createModel(trajectory);
|
||||
} else {
|
||||
const data = params.model.kind === 'topology-data'
|
||||
? await plugin.builders.data.rawData({ data: params.model.data, label: params.modelLabel })
|
||||
: await plugin.builders.data.download({ url: params.model.url, isBinary: params.model.isBinary, label: params.modelLabel });
|
||||
|
||||
const provider = plugin.dataFormats.get(params.model.format);
|
||||
const parsed = await provider!.parse(plugin, data);
|
||||
model = parsed.topology;
|
||||
}
|
||||
|
||||
const data = params.coordinates.kind === 'coordinates-data'
|
||||
? await plugin.builders.data.rawData({ data: params.coordinates.data, label: params.coordinatesLabel })
|
||||
: await plugin.builders.data.download({ url: params.coordinates.url, isBinary: params.coordinates.isBinary, label: params.coordinatesLabel });
|
||||
|
||||
const provider = plugin.dataFormats.get(params.coordinates.format);
|
||||
const coords = await provider!.parse(plugin, data);
|
||||
|
||||
const trajectory = await plugin.build().toRoot()
|
||||
.apply(TrajectoryFromModelAndCoordinates, {
|
||||
modelRef: model.ref,
|
||||
coordinatesRef: coords.ref
|
||||
}, { dependsOn: [model.ref, coords.ref] })
|
||||
.commit();
|
||||
|
||||
const preset = await plugin.builders.structure.hierarchy.applyPreset(trajectory, params.preset ?? 'default');
|
||||
|
||||
return { model, coords, preset };
|
||||
loadTrajectory(params: loaders.LoadTrajectoryParams) {
|
||||
return loaders.loadTrajectory(this.plugin, params);
|
||||
}
|
||||
|
||||
async loadMvsFromUrl(url: string, format: 'mvsj' | 'mvsx', options?: { appendSnapshots?: boolean, keepCamera?: boolean, keepCameraOrientation?: boolean, extensions?: MolstarLoadingExtension<any>[] }) {
|
||||
if (format === 'mvsj') {
|
||||
const data = await this.plugin.runTask(this.plugin.fetch({ url, type: 'string' }));
|
||||
const mvsData = MVSData.fromMVSJ(StringLike.toString(data));
|
||||
await loadMVS(this.plugin, mvsData, { sanityChecks: true, sourceUrl: url, ...options });
|
||||
} else if (format === 'mvsx') {
|
||||
const data = await this.plugin.runTask(this.plugin.fetch({ url, type: 'binary' }));
|
||||
await this.plugin.runTask(Task.create('Load MVSX file', async ctx => {
|
||||
const parsed = await loadMVSX(this.plugin, ctx, data, { doNotClearAssets: options?.appendSnapshots });
|
||||
await loadMVS(this.plugin, parsed.mvsData, { sanityChecks: true, sourceUrl: parsed.sourceUrl, ...options });
|
||||
}));
|
||||
} else {
|
||||
throw new Error(`Unknown MolViewSpec format: ${format}`);
|
||||
}
|
||||
loadMvsFromUrl(url: string, format: 'mvsj' | 'mvsx', options?: { appendSnapshots?: boolean, keepCamera?: boolean, keepCameraOrientation?: boolean, extensions?: MolstarLoadingExtension<any>[] }) {
|
||||
return loaders.loadMvsFromUrl(this.plugin, url, format, options);
|
||||
}
|
||||
|
||||
/** Load MolViewSpec from `data`.
|
||||
* If `format` is 'mvsj', `data` must be a string or a Uint8Array containing a UTF8-encoded string.
|
||||
* If `format` is 'mvsx', `data` must be a Uint8Array or a string containing base64-encoded binary data prefixed with 'base64,'. */
|
||||
loadMvsData(data: string | Uint8Array<ArrayBuffer>, format: 'mvsj' | 'mvsx', options?: { appendSnapshots?: boolean, keepCamera?: boolean, keepCameraOrientation?: boolean, extensions?: MolstarLoadingExtension<any>[] }) {
|
||||
return loadMVSData(this.plugin, data, format, options);
|
||||
return loaders.loadMvsData(this.plugin, data, format, options);
|
||||
}
|
||||
|
||||
loadFiles(files: File[]) {
|
||||
const sessions = files.filter(f => {
|
||||
const fn = f.name.toLowerCase();
|
||||
return fn.endsWith('.molx') || fn.endsWith('.molj');
|
||||
});
|
||||
|
||||
if (sessions.length > 0) {
|
||||
return PluginCommands.State.Snapshots.OpenFile(this.plugin, { file: sessions[0] });
|
||||
} else {
|
||||
return this.plugin.runTask(this.plugin.state.data.applyAction(OpenFiles, {
|
||||
files: files.map(f => Asset.File(f)),
|
||||
format: { name: 'auto', params: {} },
|
||||
visuals: true
|
||||
}));
|
||||
}
|
||||
return loaders.loadFiles(this.plugin, files);
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
@@ -535,72 +207,12 @@ 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 }: {
|
||||
expression?: (queryBuilder: typeof MolScriptBuilder) => Expression,
|
||||
elements?: StructureElement.Schema,
|
||||
action: 'highlight' | 'select' | 'focus',
|
||||
applyGranularity?: boolean,
|
||||
filterStructure?: (structure: Structure) => boolean,
|
||||
focusOptions?: Partial<CameraFocusOptions>
|
||||
}) {
|
||||
const plugin = this.plugin;
|
||||
|
||||
if (!expression && !elements) {
|
||||
if (action === 'select') {
|
||||
plugin.managers.interactivity.lociSelects.deselectAll();
|
||||
} else if (action === 'highlight') {
|
||||
plugin.managers.interactivity.lociHighlights.clearHighlights();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const structures = this.plugin.state.data.selectQ(Q => Q.rootsOfType(PluginStateObject.Molecule.Structure));
|
||||
for (const s of structures) {
|
||||
if (!s.obj?.data) continue;
|
||||
|
||||
if (filterStructure && !filterStructure(s.obj.data)) continue;
|
||||
|
||||
const loci = expression
|
||||
? 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;
|
||||
}
|
||||
}
|
||||
structureInteractivity(options: StructureInteractivityOptions) {
|
||||
return applyStructureInteractivity(this.plugin, options);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._events.dispose();
|
||||
this.plugin.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadStructureOptions {
|
||||
representationParams?: StructureRepresentationPresetProvider.CommonParams
|
||||
}
|
||||
|
||||
export interface VolumeIsovalueInfo {
|
||||
type: 'absolute' | 'relative',
|
||||
value: number,
|
||||
color: Color,
|
||||
alpha?: number,
|
||||
volumeIndex?: number
|
||||
}
|
||||
|
||||
export interface LoadTrajectoryParams {
|
||||
model: { kind: 'model-url', url: string, format?: BuiltInTrajectoryFormat /* mmcif */, isBinary?: boolean }
|
||||
| { kind: 'model-data', data: string | number[] | ArrayBuffer | Uint8Array<ArrayBuffer>, format?: BuiltInTrajectoryFormat /* mmcif */ }
|
||||
| { kind: 'topology-url', url: string, format: BuiltInTopologyFormat, isBinary?: boolean }
|
||||
| { kind: 'topology-data', data: string | number[] | ArrayBuffer | Uint8Array<ArrayBuffer>, format: BuiltInTopologyFormat },
|
||||
modelLabel?: string,
|
||||
coordinates: { kind: 'coordinates-url', url: string, format: BuiltInCoordinatesFormat, isBinary?: boolean }
|
||||
| { kind: 'coordinates-data', data: string | number[] | ArrayBuffer | Uint8Array<ArrayBuffer>, format: BuiltInCoordinatesFormat },
|
||||
coordinatesLabel?: string,
|
||||
preset?: keyof PresetTrajectoryHierarchy
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
<script type="text/javascript" src="./molstar.js"></script>
|
||||
<script type="text/javascript">
|
||||
molstar.Viewer.create('app', {
|
||||
layoutIsExpanded: true,
|
||||
layoutIsExpanded: false,
|
||||
layoutShowControls: false,
|
||||
layoutShowRemoteState: false,
|
||||
layoutShowSequence: true,
|
||||
@@ -50,4 +50,5 @@
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
* @author Russ Taylor <russ@reliasolve.com>
|
||||
*/
|
||||
|
||||
import { ANVILMembraneOrientation } from '../../extensions/anvil/behavior';
|
||||
@@ -28,6 +29,11 @@ import { ZenodoImport } from '../../extensions/zenodo';
|
||||
import { PluginSpec } from '../../mol-plugin/spec';
|
||||
import { MVSData } from '../../extensions/mvs/mvs-data';
|
||||
import * as MVSUtil from '../../extensions/mvs/util';
|
||||
import { KinemageExtension } from '../../extensions/kinemage/behavior';
|
||||
import * as interactivity from '../../extensions/plugin/interactivity';
|
||||
import * as loaders from '../../extensions/plugin/loaders';
|
||||
import { PluginViewModel } from '../../extensions/plugin/view-model';
|
||||
import { PluginUIViewModel } from '../../extensions/plugin/ui-view-model';
|
||||
|
||||
export const ExtensionMap = {
|
||||
// Mol* built-in extensions
|
||||
@@ -39,6 +45,7 @@ export const ExtensionMap = {
|
||||
'geo-export': PluginSpec.Behavior(GeometryExport),
|
||||
'zenodo-import': PluginSpec.Behavior(ZenodoImport),
|
||||
'wwpdb-chemical-component-dictionary': PluginSpec.Behavior(wwPDBChemicalComponentDictionary),
|
||||
'kinemage': PluginSpec.Behavior(KinemageExtension),
|
||||
|
||||
// 3rd party extensions
|
||||
'pdbe-structure-quality-report': PluginSpec.Behavior(PDBeStructureQualityReport),
|
||||
@@ -67,5 +74,13 @@ export const PluginExtensions = {
|
||||
qualityAssessment: {
|
||||
config: MAQualityAssessmentConfig
|
||||
}
|
||||
}
|
||||
},
|
||||
plugin: {
|
||||
interactivity,
|
||||
loaders,
|
||||
models: {
|
||||
PluginViewModel,
|
||||
PluginUIViewModel,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2025-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
@@ -10,6 +10,7 @@ import { Volume } from '../../mol-model/volume';
|
||||
import { Shape, ShapeGroup } from '../../mol-model/shape';
|
||||
import * as LinearAlgebra3D from '../../mol-math/linear-algebra/3d';
|
||||
import { PluginContext } from '../../mol-plugin/context';
|
||||
import { PluginUIContext } from '../../mol-plugin-ui/context';
|
||||
import { PluginConfig } from '../../mol-plugin/config';
|
||||
import { PluginBehavior } from '../../mol-plugin/behavior';
|
||||
import { DefaultPluginSpec, PluginSpec } from '../../mol-plugin/spec';
|
||||
@@ -42,6 +43,7 @@ export const lib = {
|
||||
},
|
||||
plugin: {
|
||||
PluginContext,
|
||||
PluginUIContext,
|
||||
PluginConfig,
|
||||
PluginBehavior,
|
||||
PluginSpec,
|
||||
|
||||
116
src/apps/viewer/plugin-spec.ts
Normal file
116
src/apps/viewer/plugin-spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { AssemblySymmetryConfig } from '../../extensions/assembly-symmetry';
|
||||
import { DefaultPluginUISpec, PluginUISpec } from '../../mol-plugin-ui/spec';
|
||||
import { PluginBehaviors } from '../../mol-plugin/behavior';
|
||||
import { PluginConfig } from '../../mol-plugin/config';
|
||||
import { ExtensionMap } from './extensions';
|
||||
import { DefaultViewerOptions, ViewerOptions } from './options';
|
||||
import { NoPrimaryFocusLociBindings } from '../../mol-plugin/behavior/dynamic/camera';
|
||||
import { PluginSpec } from '../../mol-plugin/spec';
|
||||
import { ViewerAutoPreset } from './presets';
|
||||
|
||||
export function createViewerSpec(options: Partial<ViewerOptions> = {}): PluginUISpec {
|
||||
const definedOptions = {} as any;
|
||||
// filter for defined properies only so the default values
|
||||
// are property applied
|
||||
for (const p of Object.keys(options) as (keyof ViewerOptions)[]) {
|
||||
if (options[p] !== void 0) definedOptions[p] = options[p];
|
||||
}
|
||||
|
||||
const o: ViewerOptions = { ...DefaultViewerOptions, ...definedOptions };
|
||||
const defaultSpec = DefaultPluginUISpec();
|
||||
|
||||
const disabledExtension = new Set(o.disabledExtensions ?? []);
|
||||
let baseBehaviors = defaultSpec.behaviors;
|
||||
|
||||
if (o.viewportFocusBehavior === 'disabled') {
|
||||
baseBehaviors = baseBehaviors.filter(b =>
|
||||
b.transformer !== PluginBehaviors.Camera.FocusLoci
|
||||
&& b.transformer !== PluginBehaviors.Representation.FocusLoci
|
||||
);
|
||||
} else if (o.viewportFocusBehavior === 'secondary-zoom') {
|
||||
baseBehaviors = baseBehaviors.filter(b =>
|
||||
b.transformer !== PluginBehaviors.Camera.FocusLoci
|
||||
&& b.transformer !== PluginBehaviors.Representation.FocusLoci
|
||||
);
|
||||
|
||||
baseBehaviors.push(PluginSpec.Behavior(PluginBehaviors.Camera.FocusLoci, {
|
||||
bindings: NoPrimaryFocusLociBindings
|
||||
}));
|
||||
}
|
||||
|
||||
const spec: PluginUISpec = {
|
||||
canvas3d: {
|
||||
...defaultSpec.canvas3d,
|
||||
},
|
||||
actions: defaultSpec.actions,
|
||||
behaviors: [
|
||||
...baseBehaviors,
|
||||
...o.extensions.filter(e => !disabledExtension.has(e)).map(e => ExtensionMap[e]),
|
||||
],
|
||||
animations: [...defaultSpec.animations || []],
|
||||
customParamEditors: defaultSpec.customParamEditors,
|
||||
customFormats: o?.customFormats,
|
||||
layout: {
|
||||
initial: {
|
||||
isExpanded: o.layoutIsExpanded,
|
||||
showControls: o.layoutShowControls,
|
||||
controlsDisplay: o.layoutControlsDisplay,
|
||||
regionState: {
|
||||
bottom: 'full',
|
||||
left: o.collapseLeftPanel ? 'collapsed' : 'full',
|
||||
right: o.collapseRightPanel ? 'hidden' : 'full',
|
||||
top: 'full',
|
||||
}
|
||||
},
|
||||
},
|
||||
components: {
|
||||
...defaultSpec.components,
|
||||
controls: {
|
||||
...defaultSpec.components?.controls,
|
||||
top: o.layoutShowSequence ? undefined : 'none',
|
||||
bottom: o.layoutShowLog ? undefined : 'none',
|
||||
left: o.layoutShowLeftPanel ? undefined : 'none',
|
||||
},
|
||||
remoteState: o.layoutShowRemoteState ? 'default' : 'none',
|
||||
},
|
||||
config: [
|
||||
[PluginConfig.General.DisableAntialiasing, o.disableAntialiasing],
|
||||
[PluginConfig.General.PixelScale, o.pixelScale],
|
||||
[PluginConfig.General.PickScale, o.pickScale],
|
||||
[PluginConfig.General.Transparency, o.transparency],
|
||||
[PluginConfig.General.PreferWebGl1, o.preferWebgl1],
|
||||
[PluginConfig.General.AllowMajorPerformanceCaveat, o.allowMajorPerformanceCaveat],
|
||||
[PluginConfig.General.PowerPreference, o.powerPreference],
|
||||
[PluginConfig.General.ResolutionMode, o.resolutionMode],
|
||||
[PluginConfig.Viewport.ShowReset, o.viewportShowReset],
|
||||
[PluginConfig.Viewport.ShowScreenshotControls, o.viewportShowScreenshotControls],
|
||||
[PluginConfig.Viewport.ShowControls, o.viewportShowControls],
|
||||
[PluginConfig.Viewport.ShowExpand, o.viewportShowExpand],
|
||||
[PluginConfig.Viewport.ShowToggleFullscreen, o.viewportShowToggleFullscreen],
|
||||
[PluginConfig.Viewport.ShowSettings, o.viewportShowSettings],
|
||||
[PluginConfig.Viewport.ShowSelectionMode, o.viewportShowSelectionMode],
|
||||
[PluginConfig.Viewport.ShowAnimation, o.viewportShowAnimation],
|
||||
[PluginConfig.Viewport.ShowTrajectoryControls, o.viewportShowTrajectoryControls],
|
||||
[PluginConfig.State.DefaultServer, o.pluginStateServer],
|
||||
[PluginConfig.State.CurrentServer, o.pluginStateServer],
|
||||
[PluginConfig.VolumeStreaming.DefaultServer, o.volumeStreamingServer],
|
||||
[PluginConfig.VolumeStreaming.Enabled, !o.volumeStreamingDisabled],
|
||||
[PluginConfig.Download.DefaultPdbProvider, o.pdbProvider],
|
||||
[PluginConfig.Download.DefaultEmdbProvider, o.emdbProvider],
|
||||
[PluginConfig.Structure.DefaultRepresentationPreset, ViewerAutoPreset.id],
|
||||
[PluginConfig.Structure.SaccharideCompIdMapType, o.saccharideCompIdMapType],
|
||||
[AssemblySymmetryConfig.DefaultServerType, o.rcsbAssemblySymmetryDefaultServerType],
|
||||
[AssemblySymmetryConfig.DefaultServerUrl, o.rcsbAssemblySymmetryDefaultServerUrl],
|
||||
[AssemblySymmetryConfig.ApplyColors, o.rcsbAssemblySymmetryApplyColors],
|
||||
...(o.config ?? []),
|
||||
]
|
||||
};
|
||||
|
||||
return spec;
|
||||
}
|
||||
28
src/extensions/kinemage/README.md
Normal file
28
src/extensions/kinemage/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Kinemage extension
|
||||
|
||||
This extension adds support for the Kinemage molecular graphics format based on the
|
||||
[kinemage format specification](http://kinemage.biochem.duke.edu/static/files/PDFs/format-kinemage.pdf).
|
||||
|
||||
It currently supports the following features:
|
||||
- Drag-and-drop of Kinemage files into the display area
|
||||
- Open File can open Kinemage files from the local filesystem
|
||||
- Display of @ball, @sphere, @vector, @dot, @ribbon, and @triangle lists
|
||||
- Coloring of objects by vertex color, or by a single color for the entire list
|
||||
- Hovering over objects to see their labels (if present)
|
||||
- When there are views defined, controls are added to the right panel; when selected, they shift the view
|
||||
- When the view is changes, the projection is set to orthographic and the background is set to black to match Kinemage's default view
|
||||
- Control panel names are based on the @pdbfile or @caption in the Kinemage file if there is one
|
||||
- Lines are split in half, with each half colored by and labeled by the nearest vertex
|
||||
- Master and submaster selections of visible objects
|
||||
- Group and subgroup hierarchy with buttons to control visibility
|
||||
- @pointmaster lists controlling visibility of points
|
||||
- animate/2animate: First entry turned on to start, changing visibility of Animate button cycles through them
|
||||
|
||||
Currently unsupported features include:
|
||||
- @label and @ring lists
|
||||
- @hsvcolor keyword for coloring by hue, saturation, and value
|
||||
- 'fore' and 'rear' keywords for different front and back colors
|
||||
|
||||
Current limitations include:
|
||||
- Triangles are a single color, not colored by vertex (Mol* does not support per-vertex coloring for these primitives)
|
||||
- Line segments in Mol* do not support end-caps for wide lines, so there are artifacts in highly-curved lines
|
||||
188
src/extensions/kinemage/_spec/kin.spec.ts
Normal file
188
src/extensions/kinemage/_spec/kin.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Copyright (c) 2025-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author ReliaSolve <russ@reliasolve.com>
|
||||
*/
|
||||
|
||||
import { parseKin } from '../reader/parser';
|
||||
|
||||
const kinString = `@kinemage 1
|
||||
@caption probe.2.26.021123, run Tue Apr 23 14:49:17 2024
|
||||
command: C:\tmp\cctbx_phenix\build\probe\exe\probe.exe -kin -mc -het -once -wat2wat -onlybadout -stdbonds water all 1ssxFH.pdb
|
||||
@group dominant {dots}
|
||||
@subgroup dominant {once dots}
|
||||
@master {bad overlap}
|
||||
@pointmaster 'O' {Hets contacts}
|
||||
@vectorlist {x} color=red master={bad overlap}
|
||||
{ O HOH 319 A}hotpink P 'O' 31.146,32.100,-1.425 {"}hotpink 'O' 31.015,32.234,-1.324
|
||||
{"}hotpink P 'O' 31.607,32.750,-1.156 {"}hotpink 'O' 31.410,32.784,-1.097
|
||||
{"}hotpink P 'O' 31.263,32.074,-1.185 {"}hotpink 'O' 31.117,32.209,-1.122
|
||||
{ O BHOH 338 A}hotpink P 'O' 32.540,45.631,10.833 {"}hotpink 'O' 32.430,45.771,10.977
|
||||
{"}hotpink P 'O' 32.316,45.500,10.828 {"}hotpink 'O' 32.230,45.689,10.998
|
||||
{"}hotpink P 'O' 32.068,45.424,10.824 {"}hotpink 'O' 32.034,45.604,10.975
|
||||
{"}hotpink P 'O' 32.729,45.605,11.052 {"}hotpink 'O' 32.572,45.765,11.173
|
||||
`;
|
||||
|
||||
// Complex kinemage with multiple features: animate groups, pointmasters, various list types
|
||||
const kinComplexString = `@kinemage 1
|
||||
@caption Complex test kinemage with multiple features
|
||||
@text
|
||||
This is a comprehensive test kinemage file that includes:
|
||||
- Multiple groups with animate and 2animate
|
||||
- Pointmasters with tags
|
||||
- All list types: dots, vectors, balls, spheres, ribbons, triangles
|
||||
@master {main} on
|
||||
@master {secondary} off
|
||||
@master {alternate}
|
||||
@pointmaster 'ABC' {Primary atoms} on
|
||||
@pointmaster 'XY' {Secondary atoms} off
|
||||
@group {Structure} animate dominant
|
||||
@subgroup {Backbone}
|
||||
@vectorlist {CA trace} color=blue master={main}
|
||||
{CA ALA 1}blue P 'A' 10.0,20.0,30.0 {CA ALA 2}blue 'A' 11.0,21.0,31.0
|
||||
{"}blue 'A' 12.0,22.0,32.0 {CA ALA 3}blue 'A' 13.0,23.0,33.0
|
||||
@dotlist {H-bonds} color=yellow master={main}
|
||||
{HN ALA 2}yellow 'B' 10.5,20.5,30.5
|
||||
{HN ALA 3}yellow 'B' 11.5,21.5,31.5
|
||||
{HN ALA 4}yellow 'B' 12.5,22.5,32.5
|
||||
@subgroup {Sidechains}
|
||||
@balllist {CB atoms} color=green master={secondary} radius=0.5
|
||||
{CB ARG 1}green r=0.5 'C' 9.0,19.0,29.0
|
||||
{CB ARG 2}green r=0.6 'C' 10.0,20.0,30.0
|
||||
{CB ARG 3}green r=0.55 'C' 11.0,21.0,31.0
|
||||
@group {Alternate conformations} 2animate
|
||||
@subgroup {Alt A}
|
||||
@spherelist {Waters A} color=cyan master={alternate} radius=1.0
|
||||
{HOH 101}cyan r=1.0 'X' 15.0,25.0,35.0
|
||||
{HOH 102}cyan r=1.2 'X' 16.0,26.0,36.0
|
||||
@subgroup {Alt B}
|
||||
@spherelist {Waters B} color=magenta master={alternate} radius=1.0
|
||||
{HOH 101}magenta r=1.0 'Y' 15.2,25.2,35.2
|
||||
{HOH 102}magenta r=1.1 'Y' 16.1,26.1,36.1
|
||||
@group {Surface} off
|
||||
@subgroup {Ribbons}
|
||||
@ribbonlist {Alpha helix} color=red master={main}
|
||||
{ASP 5}red 14.0,24.0,34.0
|
||||
{GLU 6}red 15.0,25.0,35.0
|
||||
{LYS 7}red 16.0,26.0,36.0
|
||||
{ARG 8}red 17.0,27.0,37.0
|
||||
{THR 9}red P 18.0,28.0,38.0
|
||||
{VAL 10}red 19.0,29.0,39.0
|
||||
@subgroup {Triangles}
|
||||
@trianglelist {Surface patch} color=sky master={secondary}
|
||||
{Tri 1}sky 20.0,30.0,40.0
|
||||
{Tri 1}sky 21.0,30.0,40.0
|
||||
{Tri 1}sky 20.5,31.0,40.0
|
||||
{Tri 2}sky X 22.0,32.0,42.0
|
||||
{Tri 2}sky 23.0,32.0,42.0
|
||||
{Tri 2}sky 22.5,33.0,42.0
|
||||
@group {Contacts} animate
|
||||
@subgroup {Clashes}
|
||||
@vectorlist {Bad overlaps} color=hotpink master={main} width=4
|
||||
{O HOH 319 A}hotpink P 31.146,32.100,-1.425 {O HOH 320 A}hotpink 31.015,32.234,-1.324
|
||||
{"}hotpink P 31.607,32.750,-1.156 {"}hotpink 31.410,32.784,-1.097
|
||||
`;
|
||||
|
||||
describe('kin reader', () => {
|
||||
it('basic', async () => {
|
||||
const parsed = await parseKin(kinString).run();
|
||||
if (parsed.isError) {
|
||||
console.error('Parse error:', parsed);
|
||||
fail('Parse should not error');
|
||||
}
|
||||
if (parsed.result.length !== 1) {
|
||||
fail(`Expected 1 kinemage, got ${parsed.result.length}`);
|
||||
}
|
||||
const kinemage = parsed.result[0];
|
||||
|
||||
const vectors = kinemage.vectorLists;
|
||||
expect(vectors.length).toEqual(1);
|
||||
|
||||
const element = vectors[0];
|
||||
expect(element.name).toEqual('x');
|
||||
expect(element.position1Array.length).toEqual(7*3);
|
||||
|
||||
// Test that colors are parsed correctly
|
||||
expect(element.color1Array.length).toEqual(7);
|
||||
|
||||
// Test masters are set up
|
||||
expect(element.masterArray).toContain('bad overlap');
|
||||
|
||||
expect.assertions(5);
|
||||
});
|
||||
|
||||
it('complex', async () => {
|
||||
const parsed = await parseKin(kinComplexString).run();
|
||||
if (parsed.isError) {
|
||||
fail('Parse should not error');
|
||||
}
|
||||
|
||||
expect(parsed.result.length).toBeGreaterThan(0);
|
||||
const kinemage = parsed.result[0];
|
||||
|
||||
// Verify structure is valid
|
||||
expect(kinemage.vectorLists).toBeDefined();
|
||||
expect(kinemage.masterDict).toBeDefined();
|
||||
expect(kinemage.groupDict).toBeDefined();
|
||||
expect(kinemage.pointmasterDict).toBeDefined();
|
||||
|
||||
// Test animate groups
|
||||
expect(kinemage.groupsAnimate.length).toEqual(2);
|
||||
expect(kinemage.groupsAnimate).toContain('Structure');
|
||||
expect(kinemage.groupsAnimate).toContain('Contacts');
|
||||
expect(kinemage.activeAnimateGroup).toEqual(0);
|
||||
|
||||
// Test 2animate groups
|
||||
expect(kinemage.groupsAnimate2.length).toEqual(1);
|
||||
expect(kinemage.groupsAnimate2).toContain('Alternate conformations');
|
||||
expect(kinemage.activeAnimateGroup2).toEqual(0);
|
||||
|
||||
// Test pointmasters
|
||||
expect(Object.keys(kinemage.pointmasterDict).length).toBeGreaterThan(0);
|
||||
expect(kinemage.pointmasterDict['A']).toEqual('Primary atoms');
|
||||
expect(kinemage.pointmasterDict['B']).toEqual('Primary atoms');
|
||||
expect(kinemage.pointmasterDict['X']).toEqual('Secondary atoms');
|
||||
|
||||
// Test masters
|
||||
expect(kinemage.masterDict['main']).toBeDefined();
|
||||
expect(kinemage.masterDict['main'].visible).toEqual(true);
|
||||
expect(kinemage.masterDict['secondary']).toBeDefined();
|
||||
expect(kinemage.masterDict['secondary'].visible).toEqual(false);
|
||||
|
||||
// Test list types
|
||||
expect(kinemage.vectorLists.length).toEqual(2);
|
||||
expect(kinemage.dotLists.length).toEqual(1);
|
||||
expect(kinemage.ballLists.length).toEqual(3); // 1 balllist + 2 spherelists
|
||||
expect(kinemage.ribbonLists.length).toEqual(2); // 1 ribbonlist + 1 trianglelist
|
||||
|
||||
// Test specific list properties
|
||||
const caTrace = kinemage.vectorLists.find(v => v.name === 'CA trace');
|
||||
expect(caTrace).toBeDefined();
|
||||
expect(caTrace?.masterArray).toContain('main');
|
||||
|
||||
const hBonds = kinemage.dotLists[0];
|
||||
expect(hBonds.name).toEqual('H-bonds');
|
||||
expect(hBonds.positionArray.length).toEqual(9); // 3 dots * 3 coords
|
||||
|
||||
const cbAtoms = kinemage.ballLists.find(b => b.name === 'CB atoms');
|
||||
expect(cbAtoms).toBeDefined();
|
||||
expect(cbAtoms?.radiusArray.length).toEqual(3);
|
||||
|
||||
const helix = kinemage.ribbonLists.find(r => r.name === 'Alpha helix');
|
||||
expect(helix).toBeDefined();
|
||||
expect(helix?.pairTriangleNormals).toEqual(true); // ribbonlist
|
||||
|
||||
const surface = kinemage.ribbonLists.find(r => r.name === 'Surface patch');
|
||||
expect(surface).toBeDefined();
|
||||
expect(surface?.pairTriangleNormals).toEqual(false); // trianglelist
|
||||
|
||||
// Test groups
|
||||
expect(Object.keys(kinemage.groupDict).length).toEqual(4);
|
||||
expect(kinemage.groupDict['Structure'].animate).toEqual(true);
|
||||
expect(kinemage.groupDict['Alternate conformations']['2animate']).toEqual(true);
|
||||
expect(kinemage.groupDict['Surface'].off).toEqual(true);
|
||||
|
||||
expect.assertions(38);
|
||||
});
|
||||
|
||||
});
|
||||
663
src/extensions/kinemage/behavior.ts
Normal file
663
src/extensions/kinemage/behavior.ts
Normal file
@@ -0,0 +1,663 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Russ Taylor <russ@reliasolve.com>
|
||||
*/
|
||||
|
||||
/** Based on the ../anvil extension. */
|
||||
|
||||
import { Vec3, Mat3 } from '../../mol-math/linear-algebra';
|
||||
import { ParamDefinition as PD } from '../../mol-util/param-definition';
|
||||
import { KinemageDataProvider, KinemageData } from './prop';
|
||||
import { StateTransformer, StateBuilder } from '../../mol-state';
|
||||
import { Task } from '../../mol-task';
|
||||
import { PluginBehavior } from '../../mol-plugin/behavior';
|
||||
import { PluginDragAndDropHandler } from '../../mol-plugin-state/manager/drag-and-drop';
|
||||
import { PluginStateObject } from '../../mol-plugin-state/objects';
|
||||
import { PluginContext } from '../../mol-plugin/context';
|
||||
import { DefaultQueryRuntimeTable } from '../../mol-script/runtime/query/compiler';
|
||||
import { StateTransforms } from '../../mol-plugin-state/transforms';
|
||||
import { shapePointsFromKin, shapeLinesFromKin, shapeMeshFromKin, shapeSpheresFromKin } from './kin';
|
||||
import { Kinemage } from './reader/schema';
|
||||
import { DataFormatProvider } from '../../mol-plugin-state/formats/provider';
|
||||
import { Camera } from '../../mol-canvas3d/camera';
|
||||
import { PluginCommands } from '../../mol-plugin/commands';
|
||||
import { getPluginBoundingSphere } from '../../mol-plugin-state/manager/focus-camera/focus-object';
|
||||
import { KinemageControls } from './ui';
|
||||
import { StateObjectSelector } from '../../mol-state';
|
||||
import { Color } from '../../mol-util/color';
|
||||
|
||||
const Tag = KinemageData.Tag;
|
||||
|
||||
const Transform = StateTransformer.builderFactory('sb-kinemage');
|
||||
|
||||
/**
|
||||
* State object to hold parsed Kinemage data
|
||||
*/
|
||||
export class KinemageObject extends PluginStateObject.Create<KinemageData>({ name: 'Kinemage', typeClass: 'Object' }) { }
|
||||
|
||||
/**
|
||||
* Visibility state for kinemage elements - stores which items are VISIBLE (not hidden)
|
||||
*/
|
||||
export interface KinemageVisibilityState {
|
||||
/** Map of group name -> visibility (true = visible, false = hidden/off) */
|
||||
groupVisibility: Map<string, boolean>;
|
||||
/** Map of subgroup name -> visibility (true = visible, false = hidden/off) */
|
||||
subgroupVisibility: Map<string, boolean>;
|
||||
/** Map of master name -> visibility (true = visible, false = hidden) */
|
||||
masterVisibility: Map<string, boolean>;
|
||||
activeAnimateGroup: number;
|
||||
activeAnimateGroup2: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a saved snapshot object (from a view state node) to the plugin camera.
|
||||
* Use PluginCommands.Camera.SetSnapshot so transitions and canvas props are handled properly.
|
||||
*/
|
||||
export async function applyViewSnapshot(plugin: PluginContext, snapshot: Partial<Camera.Snapshot>) {
|
||||
if (!snapshot) return;
|
||||
|
||||
// Set background color to black
|
||||
plugin.canvas3d?.setProps({
|
||||
renderer: {
|
||||
...plugin.canvas3d.props.renderer,
|
||||
backgroundColor: Color(0x000000)
|
||||
}
|
||||
});
|
||||
|
||||
// If the snapshot provides a target, adjust the canvas `sceneRadiusFactor` so the scene isn't clipped
|
||||
// when we switch camera.
|
||||
if (snapshot.target) {
|
||||
try {
|
||||
const boundingSphere = getPluginBoundingSphere(plugin);
|
||||
if (boundingSphere && boundingSphere.radius > 0) {
|
||||
const offset = Vec3.distance(snapshot.target as Vec3, boundingSphere.center);
|
||||
const sceneRadiusFactor = (boundingSphere.radius + offset) / boundingSphere.radius;
|
||||
plugin.canvas3d?.setProps({ sceneRadiusFactor });
|
||||
}
|
||||
} catch (e) {
|
||||
// fallback: ignore errors and continue to set the camera snapshot
|
||||
console.warn('Failed to adjust sceneRadiusFactor for view snapshot', e);
|
||||
}
|
||||
}
|
||||
await PluginCommands.Camera.SetSnapshot(plugin, { snapshot });
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform to parse Kinemage data from string/data input
|
||||
*/
|
||||
export const ParseKinemage = Transform({
|
||||
name: 'sb-kinemage-parse',
|
||||
display: { name: 'Parse Kinemage' },
|
||||
from: [PluginStateObject.Data.String],
|
||||
to: KinemageObject,
|
||||
params: {
|
||||
label: PD.Optional(PD.Text('', { description: 'Label for the Kinemage data' }))
|
||||
}
|
||||
})({
|
||||
apply({ a, params }) {
|
||||
return Task.create('Parse Kinemage', async ctx => {
|
||||
const input = a.data;
|
||||
let data: KinemageData;
|
||||
|
||||
if (typeof input === 'string') {
|
||||
// Parse from string content
|
||||
const file = new File([input], 'input.kin', { type: 'text/plain' });
|
||||
data = await KinemageData.open(file);
|
||||
} else {
|
||||
throw new Error('Unsupported input type for ParseKinemage');
|
||||
}
|
||||
|
||||
// Precompute camera snapshots for all views in all kinemages
|
||||
for (const kinData of data.kinemages) {
|
||||
(kinData as any).viewSnapshots = (kinData as any).viewSnapshots || Object.create(null);
|
||||
for (const [viewKey, viewObj] of Object.entries(kinData.viewDict)) {
|
||||
const center = Vec3.create(0, 0, 0);
|
||||
if (viewObj.center) {
|
||||
Vec3.set(center, viewObj.center[0], viewObj.center[1], viewObj.center[2]);
|
||||
}
|
||||
|
||||
const orientation: Mat3 = Mat3.identity();
|
||||
if (viewObj.matrix) {
|
||||
Mat3.fromArray(orientation, viewObj.matrix, 0);
|
||||
Mat3.transpose(orientation, orientation);
|
||||
}
|
||||
|
||||
const zAxis = Vec3.create(0, 0, 1);
|
||||
Vec3.transformMat3(zAxis, zAxis, orientation);
|
||||
|
||||
const yAxis = Vec3.create(0, 1, 0);
|
||||
Vec3.transformMat3(yAxis, yAxis, orientation);
|
||||
|
||||
let distance = 100;
|
||||
if (viewObj.span) {
|
||||
distance = viewObj.span;
|
||||
}
|
||||
Vec3.scale(zAxis, zAxis, distance);
|
||||
const position = Vec3.create(0, 0, 100);
|
||||
Vec3.add(position, center, zAxis);
|
||||
|
||||
let radius = 100;
|
||||
if (viewObj.zslab) {
|
||||
const scale = viewObj.zslab / 200;
|
||||
radius = 0.5 * distance * scale;
|
||||
}
|
||||
|
||||
const snap: Camera.Snapshot = {
|
||||
mode: 'orthographic',
|
||||
fov: Math.PI / 4,
|
||||
position,
|
||||
up: yAxis,
|
||||
target: center,
|
||||
radius,
|
||||
radiusMax: 1e4,
|
||||
fog: 0,
|
||||
clipFar: true,
|
||||
minNear: 1,
|
||||
minFar: 1
|
||||
};
|
||||
|
||||
(kinData as any).viewSnapshots[viewKey] = snap;
|
||||
}
|
||||
}
|
||||
|
||||
const label = params.label || data.kinemages[0]?.caption || 'Kinemage';
|
||||
return new KinemageObject(data, { label, description: `Kinemage with ${data.kinemages.length} kinemage(s)` });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Transform to select a specific kinemage from parsed data
|
||||
*/
|
||||
export const SelectKinemage = Transform({
|
||||
name: 'sb-kinemage-select',
|
||||
display: { name: 'Select Kinemage' },
|
||||
from: KinemageObject,
|
||||
to: PluginStateObject.Format.Json,
|
||||
params: (a) => {
|
||||
const kinemages = a?.data?.kinemages || [];
|
||||
const options = kinemages.map((k: Kinemage, i: number) => [i, k.pdbfile || k.caption || `Kinemage ${i}`] as const);
|
||||
return {
|
||||
index: PD.Select(0, options, { description: 'Which kinemage to use' })
|
||||
};
|
||||
}
|
||||
})({
|
||||
apply({ a, params }) {
|
||||
return Task.create('Select Kinemage', async ctx => {
|
||||
const kinData = a.data.kinemages[params.index];
|
||||
if (!kinData) {
|
||||
throw new Error(`No kinemage found at index ${params.index}`);
|
||||
}
|
||||
|
||||
const label = kinData.pdbfile || kinData.caption || `Kinemage ${params.index}`;
|
||||
|
||||
// Store the kinemage data in a Format.Json node so downstream transforms can access it
|
||||
return new PluginStateObject.Format.Json(
|
||||
{ kinData },
|
||||
{ label, description: kinData.text || '' }
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Visibility Controller Transform - centralizes visibility state for all shape types
|
||||
* Stores visibility as key-value pairs where key is the item name and value is boolean (true = visible)
|
||||
*/
|
||||
export const KinemageVisibilityController = Transform({
|
||||
name: 'sb-kinemage-visibility-controller',
|
||||
display: { name: 'Kinemage Visibility Controller' },
|
||||
from: PluginStateObject.Format.Json,
|
||||
to: PluginStateObject.Format.Json,
|
||||
params: (a) => {
|
||||
const kinData = (a?.data as any)?.kinData as Kinemage | undefined;
|
||||
if (!kinData) {
|
||||
return {
|
||||
groupVisibility: PD.Value<{ [key: string]: boolean }>({}),
|
||||
subgroupVisibility: PD.Value<{ [key: string]: boolean }>({}),
|
||||
masterVisibility: PD.Value<{ [key: string]: boolean }>({}),
|
||||
activeAnimateGroup: PD.Numeric(0, { min: 0, max: 0, step: 1 }, { description: 'Active animate group index' }),
|
||||
activeAnimateGroup2: PD.Numeric(0, { min: 0, max: 0, step: 1 }, { description: 'Active animate2 group index' })
|
||||
};
|
||||
}
|
||||
|
||||
// Build initial visibility from parsed data
|
||||
const groupVisibility: { [key: string]: boolean } = {};
|
||||
const subgroupVisibility: { [key: string]: boolean } = {};
|
||||
const masterVisibility: { [key: string]: boolean } = {};
|
||||
|
||||
for (const [groupKey, groupInfo] of Object.entries(kinData.groupDict)) {
|
||||
groupVisibility[groupKey] = !(groupInfo as any).off;
|
||||
}
|
||||
|
||||
for (const [subgroupKey, subgroupInfo] of Object.entries(kinData.subgroupDict)) {
|
||||
subgroupVisibility[subgroupKey] = !(subgroupInfo as any).off;
|
||||
}
|
||||
|
||||
for (const [masterKey, masterInfo] of Object.entries(kinData.masterDict)) {
|
||||
masterVisibility[masterKey] = !!(masterInfo as any).visible;
|
||||
}
|
||||
|
||||
return {
|
||||
groupVisibility: PD.Value(groupVisibility, { isHidden: true }),
|
||||
subgroupVisibility: PD.Value(subgroupVisibility, { isHidden: true }),
|
||||
masterVisibility: PD.Value(masterVisibility, { isHidden: true }),
|
||||
activeAnimateGroup: PD.Numeric(0, { min: 0, max: Math.max(0, kinData.groupsAnimate.length - 1), step: 1 }, { description: 'Active animate group index', isHidden: true }),
|
||||
activeAnimateGroup2: PD.Numeric(0, { min: 0, max: Math.max(0, kinData.groupsAnimate2.length - 1), step: 1 }, { description: 'Active animate2 group index', isHidden: true })
|
||||
};
|
||||
}
|
||||
})({
|
||||
apply({ a, params }) {
|
||||
return Task.create('Kinemage Visibility Controller', async ctx => {
|
||||
const kinData = (a.data as any).kinData as Kinemage;
|
||||
if (!kinData) {
|
||||
throw new Error('No kinData found in parent Format.Json node');
|
||||
}
|
||||
|
||||
// Store visibility state alongside kinData
|
||||
const visibilityState: KinemageVisibilityState = {
|
||||
groupVisibility: new Map(Object.entries(params.groupVisibility)),
|
||||
subgroupVisibility: new Map(Object.entries(params.subgroupVisibility)),
|
||||
masterVisibility: new Map(Object.entries(params.masterVisibility)),
|
||||
activeAnimateGroup: params.activeAnimateGroup,
|
||||
activeAnimateGroup2: params.activeAnimateGroup2
|
||||
};
|
||||
|
||||
return new PluginStateObject.Format.Json(
|
||||
{ kinData, visibilityState },
|
||||
{ label: a.label, description: a.description }
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const KinemageShapePointsProvider = Transform({
|
||||
name: 'sb-kinemage-shape-points-provider',
|
||||
display: { name: 'Kinemage Shape Points Provider' },
|
||||
from: PluginStateObject.Format.Json,
|
||||
to: PluginStateObject.Shape.Provider,
|
||||
params: {}
|
||||
})({
|
||||
apply({ a }) {
|
||||
return Task.create('Kinemage Points Shape Provider', async ctx => {
|
||||
const kinData = (a.data as any).kinData as Kinemage;
|
||||
const visibilityState = (a.data as any).visibilityState as KinemageVisibilityState | undefined;
|
||||
|
||||
if (!kinData) {
|
||||
throw new Error('No kinData found in parent Format.Json node');
|
||||
}
|
||||
|
||||
const provider = await shapePointsFromKin(kinData, visibilityState, { transforms: undefined }, 'Dots').runInContext(ctx);
|
||||
return new PluginStateObject.Shape.Provider(provider as any, {
|
||||
label: kinData.pdbfile || kinData.caption || 'Kinemage Points',
|
||||
description: kinData.text || ''
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const KinemageShapeLinesProvider = Transform({
|
||||
name: 'sb-kinemage-shape-lines-provider',
|
||||
display: { name: 'Kinemage Shape Lines Provider' },
|
||||
from: PluginStateObject.Format.Json,
|
||||
to: PluginStateObject.Shape.Provider,
|
||||
params: {}
|
||||
})({
|
||||
apply({ a }) {
|
||||
return Task.create('Kinemage Lines Shape Provider', async ctx => {
|
||||
const kinData = (a.data as any).kinData as Kinemage;
|
||||
const visibilityState = (a.data as any).visibilityState as KinemageVisibilityState | undefined;
|
||||
|
||||
if (!kinData) {
|
||||
throw new Error('No kinData found in parent Format.Json node');
|
||||
}
|
||||
|
||||
const provider = await shapeLinesFromKin(kinData, visibilityState).runInContext(ctx);
|
||||
return new PluginStateObject.Shape.Provider(provider as any, {
|
||||
label: kinData.pdbfile || kinData.caption || 'Kinemage Lines',
|
||||
description: kinData.text || ''
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const KinemageShapeMeshProvider = Transform({
|
||||
name: 'sb-kinemage-shape-mesh-provider',
|
||||
display: { name: 'Kinemage Shape Mesh Provider' },
|
||||
from: PluginStateObject.Format.Json,
|
||||
to: PluginStateObject.Shape.Provider,
|
||||
params: {}
|
||||
})({
|
||||
apply({ a }) {
|
||||
return Task.create('Kinemage Mesh Shape Provider', async ctx => {
|
||||
const kinData = (a.data as any).kinData as Kinemage;
|
||||
const visibilityState = (a.data as any).visibilityState as KinemageVisibilityState | undefined;
|
||||
|
||||
if (!kinData) {
|
||||
throw new Error('No kinData found in parent Format.Json node');
|
||||
}
|
||||
|
||||
const provider = await shapeMeshFromKin(kinData, visibilityState).runInContext(ctx);
|
||||
return new PluginStateObject.Shape.Provider(provider as any, {
|
||||
label: kinData.pdbfile || kinData.caption || 'Kinemage Meshes',
|
||||
description: kinData.text || ''
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const KinemageShapeSpheresProvider = Transform({
|
||||
name: 'sb-kinemage-shape-spheres-provider',
|
||||
display: { name: 'Kinemage Shape Spheres Provider' },
|
||||
from: PluginStateObject.Format.Json,
|
||||
to: PluginStateObject.Shape.Provider,
|
||||
params: {}
|
||||
})({
|
||||
apply({ a }) {
|
||||
return Task.create('Kinemage Spheres Shape Provider', async ctx => {
|
||||
const kinData = (a.data as any).kinData as Kinemage;
|
||||
const visibilityState = (a.data as any).visibilityState as KinemageVisibilityState | undefined;
|
||||
|
||||
if (!kinData) {
|
||||
throw new Error('No kinData found in parent Format.Json node');
|
||||
}
|
||||
|
||||
const provider = await shapeSpheresFromKin(kinData, visibilityState).runInContext(ctx);
|
||||
return new PluginStateObject.Shape.Provider(provider as any, {
|
||||
label: kinData.pdbfile || kinData.caption || 'Kinemage Spheres',
|
||||
description: kinData.text || ''
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const KinemageExtension = PluginBehavior.create<{ autoAttach: boolean }>({
|
||||
name: 'kinemage-data-prop',
|
||||
category: 'custom-props',
|
||||
display: {
|
||||
name: 'Kinemage data',
|
||||
description: 'Data loaded from Kinemage.'
|
||||
},
|
||||
ctor: class extends PluginBehavior.Handler<{ autoAttach: boolean }> {
|
||||
private provider = KinemageDataProvider;
|
||||
|
||||
register(): void {
|
||||
DefaultQueryRuntimeTable.addCustomProp(this.provider.descriptor);
|
||||
|
||||
this.ctx.customStructureProperties.register(this.provider, this.params.autoAttach);
|
||||
|
||||
// Register right-panel controls for Kinemage (show in the right-hand inspector)
|
||||
this.ctx.customStructureControls.set(Tag.Representation, KinemageControls as any);
|
||||
// Some app hosts expose a global customControls registry; register there too so the card is visible
|
||||
// even when no structure is loaded. Use `any` guards to avoid type errors if customControls isn't present.
|
||||
if ((this.ctx as any).customControls && typeof (this.ctx as any).customControls.set === 'function') {
|
||||
(this.ctx as any).customControls.set('kinemage', KinemageControls as any);
|
||||
}
|
||||
|
||||
this.ctx.managers.dragAndDrop.addHandler(KinemageDragAndDropHandler.name, KinemageDragAndDropHandler.handle);
|
||||
|
||||
// Register .kin file handler so opening/dropping .kin is supported via the data formats system
|
||||
this.ctx.dataFormats.add('KIN', KINFormatProvider);
|
||||
}
|
||||
|
||||
update(p: { autoAttach: boolean }) {
|
||||
const updated = this.params.autoAttach !== p.autoAttach;
|
||||
this.params.autoAttach = p.autoAttach;
|
||||
this.ctx.customStructureProperties.setDefaultAutoAttach(this.provider.descriptor.name, this.params.autoAttach);
|
||||
return updated;
|
||||
}
|
||||
|
||||
unregister() {
|
||||
DefaultQueryRuntimeTable.removeCustomProp(this.provider.descriptor);
|
||||
|
||||
this.ctx.customStructureProperties.unregister(this.provider.descriptor.name);
|
||||
|
||||
this.ctx.genericRepresentationControls.delete(Tag.Representation);
|
||||
|
||||
this.ctx.managers.dragAndDrop.removeHandler(KinemageDragAndDropHandler.name);
|
||||
|
||||
// Unregister the .kin data format provider
|
||||
this.ctx.dataFormats.remove('KIN');
|
||||
|
||||
// Remove right-panel controls
|
||||
try { this.ctx.customStructureControls.delete(Tag.Representation); } catch { }
|
||||
if ((this.ctx as any).customControls && typeof (this.ctx as any).customControls.delete === 'function') {
|
||||
try { (this.ctx as any).customControls.delete('kinemage'); } catch { }
|
||||
}
|
||||
}
|
||||
},
|
||||
params: () => ({
|
||||
autoAttach: PD.Boolean(false)
|
||||
})
|
||||
});
|
||||
|
||||
/** Registerable method for handling dragged-and-dropped files */
|
||||
interface DragAndDropHandler {
|
||||
name: string,
|
||||
handle: PluginDragAndDropHandler,
|
||||
}
|
||||
|
||||
/** Helper function to create all shapes for a kinemage via proper transform chain */
|
||||
async function createShapesForKinemage(plugin: PluginContext, update: StateBuilder.Root, visControllerSelector: StateObjectSelector<PluginStateObject.Format.Json>) {
|
||||
const visControllerCell = plugin.state.data.cells.get(visControllerSelector.ref);
|
||||
if (!visControllerCell?.obj?.data) return;
|
||||
|
||||
const kinData = (visControllerCell.obj.data as any).kinData as Kinemage;
|
||||
if (!kinData) return;
|
||||
|
||||
// Generate all shape types that have data, each as child of the visibility controller
|
||||
if (kinData.dotLists.length > 0) {
|
||||
await update
|
||||
.to(visControllerSelector.ref)
|
||||
.apply(KinemageShapePointsProvider, {}, { state: { isGhost: true } })
|
||||
.apply(StateTransforms.Representation.ShapeRepresentation3D);
|
||||
}
|
||||
if (kinData.vectorLists.length > 0) {
|
||||
await update
|
||||
.to(visControllerSelector.ref)
|
||||
.apply(KinemageShapeLinesProvider, {}, { state: { isGhost: true } })
|
||||
.apply(StateTransforms.Representation.ShapeRepresentation3D);
|
||||
}
|
||||
if (kinData.ribbonLists.length > 0) {
|
||||
await update
|
||||
.to(visControllerSelector.ref)
|
||||
.apply(KinemageShapeMeshProvider, {}, { state: { isGhost: true } })
|
||||
.apply(StateTransforms.Representation.ShapeRepresentation3D, { doubleSided: true });
|
||||
}
|
||||
if (kinData.ballLists.length > 0) {
|
||||
await update
|
||||
.to(visControllerSelector.ref)
|
||||
.apply(KinemageShapeSpheresProvider, {}, { state: { isGhost: true } })
|
||||
.apply(StateTransforms.Representation.ShapeRepresentation3D);
|
||||
}
|
||||
}
|
||||
|
||||
/** Centralized helper to apply kinemage content into plugin state */
|
||||
async function applyKinemageToState(plugin: PluginContext, data: string, label?: string) {
|
||||
const update = plugin.state.data.build();
|
||||
|
||||
// Create String data node
|
||||
const dataNode = update
|
||||
.toRoot()
|
||||
.apply(StateTransforms.Data.RawData, { data, label: label || 'Kinemage File' });
|
||||
|
||||
// Parse into KinemageObject
|
||||
const parsedNode = dataNode
|
||||
.apply(ParseKinemage, { label });
|
||||
|
||||
await update.commit();
|
||||
|
||||
// Get the parsed kinemage object to see how many kinemages it contains
|
||||
const parsedCell = plugin.state.data.cells.get(parsedNode.ref);
|
||||
const kinemageData = parsedCell?.obj?.data as KinemageData | undefined;
|
||||
|
||||
if (!kinemageData || !kinemageData.kinemages || kinemageData.kinemages.length === 0) {
|
||||
console.warn('No kinemages found in parsed data');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Create a separate visibility controller and shapes for EACH kinemage
|
||||
const visControllerSelectors: StateObjectSelector<PluginStateObject.Format.Json>[] = [];
|
||||
|
||||
for (let i = 0; i < kinemageData.kinemages.length; i++) {
|
||||
const kinUpdate = plugin.state.data.build();
|
||||
|
||||
// Select this specific kinemage
|
||||
const selectedNode = kinUpdate
|
||||
.to(parsedNode.ref)
|
||||
.apply(SelectKinemage, { index: i });
|
||||
|
||||
// Add visibility controller for this kinemage
|
||||
const visControllerNode = selectedNode
|
||||
.apply(KinemageVisibilityController, {});
|
||||
|
||||
await kinUpdate.commit();
|
||||
|
||||
visControllerSelectors.push(visControllerNode.selector);
|
||||
}
|
||||
|
||||
// Now create shapes for all kinemages
|
||||
const shapeUpdate = plugin.state.data.build();
|
||||
for (const visControllerSelector of visControllerSelectors) {
|
||||
await createShapesForKinemage(plugin, shapeUpdate, visControllerSelector);
|
||||
}
|
||||
await shapeUpdate.commit();
|
||||
|
||||
// Wait for bounding sphere and focus camera
|
||||
async function waitForNonEmptyBoundingSphere(plugin: PluginContext, timeoutMs = 2000, pollMs = 50) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const bs = getPluginBoundingSphere(plugin);
|
||||
if (bs && bs.radius > 0) return bs;
|
||||
} catch { /* ignore */ }
|
||||
await new Promise<void>(r => setTimeout(r, pollMs));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const bs = await waitForNonEmptyBoundingSphere(plugin);
|
||||
if (bs && bs.radius > 0 && plugin.canvas3d) {
|
||||
await PluginCommands.Camera.Focus(plugin, { center: bs.center, radius: bs.radius, durationMs: 250 });
|
||||
plugin.canvas3d?.commit();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to apply initial kinemage view snapshot', e);
|
||||
}
|
||||
|
||||
return visControllerSelectors[0]; // Return first for backward compatibility
|
||||
}
|
||||
|
||||
/** Programmatic loader: load a single File (a .kin) into the plugin state.
|
||||
* Returns the ref to the first visibility controller node.
|
||||
*/
|
||||
export async function loadKinemageFile(plugin: PluginContext, file: File): Promise<StateObjectSelector<PluginStateObject.Format.Json> | undefined> {
|
||||
const content = await file.text();
|
||||
return await applyKinemageToState(plugin, content, file.name);
|
||||
}
|
||||
|
||||
/** DragAndDropHandler handler for `.kin` files */
|
||||
const KinemageDragAndDropHandler: DragAndDropHandler = {
|
||||
name: 'kin',
|
||||
async handle(files: File[], plugin: PluginContext): Promise<boolean> {
|
||||
let applied = false;
|
||||
for (const file of files) {
|
||||
if (file.name.toLowerCase().endsWith('.kin')) {
|
||||
const ref = await loadKinemageFile(plugin, file);
|
||||
applied = applied || !!ref;
|
||||
}
|
||||
}
|
||||
return applied;
|
||||
},
|
||||
};
|
||||
|
||||
const KINFormatProvider: DataFormatProvider<{}, any, any> = DataFormatProvider({
|
||||
label: 'KIN',
|
||||
description: 'Kinemage',
|
||||
category: 'Miscellaneous',
|
||||
stringExtensions: ['kin', 'KIN'],
|
||||
parse: async (plugin, data) => {
|
||||
try {
|
||||
// data is already a StateObjectRef to the raw data in the tree
|
||||
// Build the transform chain from it
|
||||
const builder = plugin.state.data.build()
|
||||
.to(data)
|
||||
.apply(ParseKinemage, {});
|
||||
|
||||
await builder.commit();
|
||||
|
||||
// Get the parsed data to see how many kinemages
|
||||
const parsedRef = builder.selector.ref;
|
||||
const parsedCell = plugin.state.data.cells.get(parsedRef);
|
||||
const kinemageData = parsedCell?.obj?.data as KinemageData | undefined;
|
||||
|
||||
if (!kinemageData || !kinemageData.kinemages || kinemageData.kinemages.length === 0) {
|
||||
console.warn('No kinemages found in parsed data');
|
||||
return {};
|
||||
}
|
||||
|
||||
// Create visibility controllers for all kinemages
|
||||
const visControllers: StateObjectSelector<PluginStateObject.Format.Json>[] = [];
|
||||
|
||||
for (let i = 0; i < kinemageData.kinemages.length; i++) {
|
||||
const kinBuilder = plugin.state.data.build();
|
||||
|
||||
const selectedKin = kinBuilder
|
||||
.to(parsedRef)
|
||||
.apply(SelectKinemage, { index: i });
|
||||
|
||||
const visController = selectedKin
|
||||
.apply(KinemageVisibilityController, {});
|
||||
|
||||
await kinBuilder.commit();
|
||||
visControllers.push(visController.selector);
|
||||
}
|
||||
|
||||
// Return all visibility controllers
|
||||
return { visControllers };
|
||||
} catch (e) {
|
||||
console.error('Failed to parse KIN file', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
visuals: async (plugin, data) => {
|
||||
if (!data?.visControllers || !Array.isArray(data.visControllers)) {
|
||||
console.warn('[Kinemage] visuals: no visControllers array provided');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create shapes for all kinemages
|
||||
const shapeBuilder = plugin.state.data.build();
|
||||
for (const visController of data.visControllers) {
|
||||
await createShapesForKinemage(plugin, shapeBuilder, visController);
|
||||
}
|
||||
await shapeBuilder.commit();
|
||||
|
||||
// Wait for bounding sphere and focus camera
|
||||
async function waitForNonEmptyBoundingSphere(plugin: PluginContext, timeoutMs = 2000, pollMs = 50) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const bs = getPluginBoundingSphere(plugin);
|
||||
if (bs && bs.radius > 0) return bs;
|
||||
} catch { /* ignore */ }
|
||||
await new Promise<void>(r => setTimeout(r, pollMs));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const bs = await waitForNonEmptyBoundingSphere(plugin);
|
||||
if (bs && bs.radius > 0 && plugin.canvas3d) {
|
||||
await PluginCommands.Camera.Focus(plugin, { center: bs.center, radius: bs.radius, durationMs: 250 });
|
||||
plugin.canvas3d?.commit();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to focus camera on kinemage', e);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
656
src/extensions/kinemage/kin.ts
Normal file
656
src/extensions/kinemage/kin.ts
Normal file
@@ -0,0 +1,656 @@
|
||||
/**
|
||||
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author ReliaSolve <russ@reliasolve.com>
|
||||
*/
|
||||
|
||||
import { RuntimeContext, Task } from '../../mol-task';
|
||||
import { ShapeProvider } from '../../mol-model/shape/provider';
|
||||
import { Color } from '../../mol-util/color';
|
||||
import { Kinemage, DotList, VectorList, RibbonObject, BallList } from './reader/schema';
|
||||
import { Lines } from '../../mol-geo/geometry/lines/lines';
|
||||
import { LinesBuilder } from '../../mol-geo/geometry/lines/lines-builder';
|
||||
import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
|
||||
import { MeshBuilder } from '../../mol-geo/geometry/mesh/mesh-builder';
|
||||
import { Points } from '../../mol-geo/geometry/points/points';
|
||||
import { PointsBuilder } from '../../mol-geo/geometry/points/points-builder';
|
||||
import { Vec3 } from '../../mol-math/linear-algebra';
|
||||
import { Spheres } from '../../mol-geo/geometry/spheres/spheres';
|
||||
import { SpheresBuilder } from '../../mol-geo/geometry/spheres/spheres-builder';
|
||||
import { Shape } from '../../mol-model/shape';
|
||||
import { ParamDefinition as PD } from '../../mol-util/param-definition';
|
||||
import { Mat4 } from '../../mol-math/linear-algebra/3d/mat4';
|
||||
import { KinemageVisibilityState } from './behavior';
|
||||
|
||||
export type KinData = {
|
||||
source: Kinemage,
|
||||
transforms?: Mat4[],
|
||||
}
|
||||
|
||||
function createKinShapePointsParams(kinemage?: Kinemage) {
|
||||
return {
|
||||
...Points.Params,
|
||||
};
|
||||
}
|
||||
export const KinShapePointsParams = createKinShapePointsParams();
|
||||
export type KinShapePointsParams = typeof KinShapePointsParams
|
||||
function createKinShapeLinesParams(kinemage?: Kinemage) {
|
||||
|
||||
return {
|
||||
...Lines.Params,
|
||||
};
|
||||
}
|
||||
export const KinShapeLinesParams = createKinShapeLinesParams();
|
||||
export type KinShapeLinesParams = typeof KinShapeLinesParams
|
||||
function createKinShapeMeshParams(kinemage?: Kinemage) {
|
||||
|
||||
return {
|
||||
...Mesh.Params,
|
||||
};
|
||||
}
|
||||
|
||||
export const KinShapeMeshParams = createKinShapeMeshParams();
|
||||
export type KinShapeMeshParams = typeof KinShapeMeshParams
|
||||
|
||||
function createKinShapeSpheresParams(kinemage?: Kinemage) {
|
||||
|
||||
return {
|
||||
...Spheres.Params,
|
||||
};
|
||||
}
|
||||
|
||||
export const KinShapeSpheresParams = createKinShapeSpheresParams();
|
||||
export type KinShapeSpheresParams = typeof KinShapeSpheresParams;
|
||||
|
||||
/**
|
||||
* Check visibility using AND logic:
|
||||
* - ALL masters must be visible
|
||||
* - AND group must be visible
|
||||
* - AND subgroup must be visible (and its parent group if it has one)
|
||||
*/
|
||||
function getVisibility(group: string, subGroup: string, masters: string[], kin: Kinemage, visibilityState?: KinemageVisibilityState) {
|
||||
// If no visibility state provided, fall back to checking the original parsed data
|
||||
if (!visibilityState) {
|
||||
let visible = true;
|
||||
|
||||
// Check masters from parsed data
|
||||
for (let m = 0; m < masters.length; m++) {
|
||||
const masterName = masters[m];
|
||||
const masterInfo = kin.masterDict[masterName];
|
||||
if (masterInfo && !masterInfo.visible) {
|
||||
visible = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check group from parsed data
|
||||
const groupInfo = kin.groupDict[group];
|
||||
if (groupInfo && (groupInfo as any).off) {
|
||||
visible = false;
|
||||
}
|
||||
|
||||
// Check subgroup from parsed data
|
||||
const subgroupInfo = kin.subgroupDict[subGroup];
|
||||
if (subgroupInfo) {
|
||||
if ((subgroupInfo as any).off) {
|
||||
visible = false;
|
||||
}
|
||||
if ((subgroupInfo as any).group) {
|
||||
const parentGroupInfo = kin.groupDict[(subgroupInfo as any).group];
|
||||
if (parentGroupInfo && (parentGroupInfo as any).off) {
|
||||
visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
// Use visibility state - all conditions must be true (AND logic)
|
||||
|
||||
// Check all masters - if ANY master is not visible, return false
|
||||
for (let m = 0; m < masters.length; m++) {
|
||||
const masterName = masters[m];
|
||||
const masterVisible = visibilityState.masterVisibility.get(masterName);
|
||||
if (masterVisible === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check group visibility
|
||||
const groupVisible = visibilityState.groupVisibility.get(group);
|
||||
if (groupVisible === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check subgroup visibility
|
||||
if (subGroup) {
|
||||
const subgroupVisible = visibilityState.subgroupVisibility.get(subGroup);
|
||||
if (subgroupVisible === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Also check if subgroup's parent group is visible
|
||||
const subgroupInfo = kin.subgroupDict[subGroup];
|
||||
if (subgroupInfo && (subgroupInfo as any).group) {
|
||||
const parentGroupVisible = visibilityState.groupVisibility.get((subgroupInfo as any).group);
|
||||
if (parentGroupVisible === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getPoints(ctx: RuntimeContext, kin: Kinemage, visibilityState?: KinemageVisibilityState) {
|
||||
const dotLists: DotList[] = kin.dotLists;
|
||||
const builderState = PointsBuilder.create();
|
||||
const colors: Color[] = [];
|
||||
const labels: string[] = [];
|
||||
|
||||
// Every dot is in its own Molstar group because they may have colors and we look that up by group.
|
||||
let index = 0;
|
||||
|
||||
for (let i = 0; i < dotLists.length; i++) {
|
||||
const dotList = dotLists[i];
|
||||
const positionArray = dotList.positionArray;
|
||||
const colorArray = dotList.colorArray;
|
||||
const labelArray = dotList.labelArray;
|
||||
const masterArray = dotList.masterArray;
|
||||
|
||||
// Check the visibility of all of our masters and skip this dot list if any of them are not visible.
|
||||
const visible = getVisibility(dotList.group, dotList.subgroup, masterArray, kin, visibilityState);
|
||||
if (!visible) { continue; }
|
||||
|
||||
const numDots = positionArray.length / 3;
|
||||
for (let j = 0; j < numDots; j++) {
|
||||
// Skip this element if any master associated with any of its pointMasters are turned off.
|
||||
const pointMasterNames = dotList.pointmasterArray[j];
|
||||
let pmVisibility = true;
|
||||
for (let pm = 0; pm < pointMasterNames.length; pm++) {
|
||||
const pointMasterName = pointMasterNames[pm];
|
||||
const masterName = kin.pointmasterDict[pointMasterName];
|
||||
if (visibilityState) {
|
||||
const masterVisible = visibilityState.masterVisibility.get(masterName);
|
||||
if (masterVisible === false) {
|
||||
pmVisibility = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const masterInfo = kin.masterDict[masterName];
|
||||
if (masterInfo && !masterInfo.visible) {
|
||||
pmVisibility = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!pmVisibility) { continue; }
|
||||
|
||||
const group = index++;
|
||||
builderState.add(positionArray[3 * j + 0], positionArray[3 * j + 1], positionArray[3 * j + 2], group);
|
||||
// colorArray may be undefined; push a default color when not provided
|
||||
colors.push(colorArray && colorArray.length > j ? colorArray[j] : Color.fromRgb(255, 255, 255));
|
||||
// labelArray may be undefined; push an empty string when not provided
|
||||
labels.push(labelArray && labelArray.length > j ? labelArray[j] : '');
|
||||
}
|
||||
}
|
||||
|
||||
const points = builderState.getPoints();
|
||||
return { points, colors, labels };
|
||||
}
|
||||
|
||||
async function getLines(ctx: RuntimeContext, kin: Kinemage, visibilityState?: KinemageVisibilityState) {
|
||||
const vectorLists: VectorList[] = kin.vectorLists;
|
||||
const builderState = LinesBuilder.create();
|
||||
const widths: number[] = [];
|
||||
const colors: Color[] = [];
|
||||
const labels: string[] = [];
|
||||
|
||||
// Every line is in its own Molstar group because they may have individual widths and we look
|
||||
// up the width based on the group is in the size function.
|
||||
let index = 0;
|
||||
|
||||
for (let i = 0; i < vectorLists.length; i++) {
|
||||
const vectorList = vectorLists[i];
|
||||
const position1Array = vectorList.position1Array;
|
||||
const position2Array = vectorList.position2Array;
|
||||
const widthArray = vectorList.width;
|
||||
const color1Array = vectorList.color1Array;
|
||||
const color2Array = vectorList.color2Array;
|
||||
const label1Array = vectorList.label1Array;
|
||||
const label2Array = vectorList.label2Array;
|
||||
const masterArray = vectorList.masterArray;
|
||||
|
||||
// Check the visibility of all of our masters and skip this vector list if any of them are not visible.
|
||||
const visible = getVisibility(vectorList.group, vectorList.subgroup, masterArray, kin, visibilityState);
|
||||
if (!visible) { continue; }
|
||||
|
||||
const numLines = position1Array.length / 3;
|
||||
for (let j = 0; j < numLines; j++) {
|
||||
// Skip this element if any master associated with any of its pointMasters are turned off.
|
||||
const pointMasterNames = vectorList.pointmasterArray[j];
|
||||
let pmVisibility = true;
|
||||
for (let pm = 0; pm < pointMasterNames.length; pm++) {
|
||||
const pointMasterName = pointMasterNames[pm];
|
||||
const masterName = kin.pointmasterDict[pointMasterName];
|
||||
if (visibilityState) {
|
||||
const masterVisible = visibilityState.masterVisibility.get(masterName);
|
||||
if (masterVisible === false) {
|
||||
pmVisibility = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const masterInfo = kin.masterDict[masterName];
|
||||
if (masterInfo && !masterInfo.visible) {
|
||||
pmVisibility = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!pmVisibility) { continue; }
|
||||
|
||||
// Find the midpoint of the line because we're going to actually make
|
||||
// two half-lines so that labels and selection work better.
|
||||
const midX = (position1Array[3 * j + 0] + position2Array[3 * j + 0]) / 2;
|
||||
const midY = (position1Array[3 * j + 1] + position2Array[3 * j + 1]) / 2;
|
||||
const midZ = (position1Array[3 * j + 2] + position2Array[3 * j + 2]) / 2;
|
||||
|
||||
// Make the first half of the line from position1 to the midpoint, labeled and colored based on position1.
|
||||
let group = index++;
|
||||
builderState.add(position1Array[3 * j + 0], position1Array[3 * j + 1], position1Array[3 * j + 2],
|
||||
midX, midY, midZ,
|
||||
group);
|
||||
// widthArray may be undefined; push NaN when width not provided
|
||||
widths.push(widthArray && widthArray.length > j ? widthArray[j] : NaN);
|
||||
// colorArray may be undefined; push a default color when not provided
|
||||
colors.push(color1Array && color1Array.length > j ? color1Array[j] : Color.fromRgb(255, 255, 255));
|
||||
// labelArray may be undefined; push an empty string when not provided
|
||||
labels.push(label1Array && label1Array.length > j ? label1Array[j] : '');
|
||||
|
||||
// Make the second half of the line from the midpoint to position2, labeled and colored based on position2.
|
||||
group = index++;
|
||||
builderState.add(midX, midY, midZ,
|
||||
position2Array[3 * j + 0], position2Array[3 * j + 1], position2Array[3 * j + 2],
|
||||
group);
|
||||
// widthArray may be undefined; push NaN when width not provided
|
||||
widths.push(widthArray && widthArray.length > j ? widthArray[j] : NaN);
|
||||
// colorArray may be undefined; push a default color when not provided
|
||||
colors.push(color2Array && color2Array.length > j ? color2Array[j] : Color.fromRgb(255, 255, 255));
|
||||
// labelArray may be undefined; push an empty string when not provided
|
||||
labels.push(label2Array && label2Array.length > j ? label2Array[j] : '');
|
||||
}
|
||||
}
|
||||
|
||||
const lines = builderState.getLines();
|
||||
return { lines, widths: new Float32Array(widths), colors, labels };
|
||||
}
|
||||
|
||||
function addOffsetTriangle(builderState: MeshBuilder.State, a: Vec3, b: Vec3, c: Vec3, n: Vec3, offset: number) {
|
||||
const aOffset = Vec3.add(Vec3(), a, Vec3.scale(Vec3(), n, offset));
|
||||
const bOffset = Vec3.add(Vec3(), b, Vec3.scale(Vec3(), n, offset));
|
||||
const cOffset = Vec3.add(Vec3(), c, Vec3.scale(Vec3(), n, offset));
|
||||
MeshBuilder.addTriangleWithNormal(builderState, aOffset, bOffset, cOffset, n);
|
||||
}
|
||||
|
||||
async function getMesh(ctx: RuntimeContext, kin: Kinemage, visibilityState?: KinemageVisibilityState) {
|
||||
const ribbonObjects: RibbonObject[] = kin.ribbonLists;
|
||||
const builderState = MeshBuilder.createState();
|
||||
const colors: Color[] = [];
|
||||
const labels: string[] = [];
|
||||
|
||||
// Every triangle is in its own Molstar group because they may have individual colors and we look
|
||||
// up the color based on the group is in the color function.
|
||||
let index = 0;
|
||||
|
||||
for (let ri = 0; ri < ribbonObjects.length; ri++) {
|
||||
const ribbonObject = ribbonObjects[ri];
|
||||
const coords = ribbonObject.positionArray;
|
||||
const colorArray = ribbonObject.colorArray;
|
||||
const labelArray = ribbonObject.labelArray;
|
||||
const masterArray = ribbonObject.masterArray;
|
||||
const pointMasterArray = ribbonObject.pointmasterArray;
|
||||
|
||||
// Check the visibility of all of our masters and skip this ribbon object if any of them are not visible.
|
||||
const visible = getVisibility(ribbonObject.group, ribbonObject.subgroup, masterArray, kin, visibilityState);
|
||||
if (!visible) { continue; }
|
||||
|
||||
builderState.currentGroup = ri; // TODO: Base this on something in the file instead?
|
||||
|
||||
// The positionArray contains 3x as many entries as there are vertices since it's a catenation of x, y, z for each vertex.
|
||||
// There are three vertices per triangle.
|
||||
// TODO: Ribbon lighting is to be set up to make each pair of triangles look like a quad with the same normal.
|
||||
const numTriangles = coords.length / 9;
|
||||
let prevTriangleNormal: Vec3 | undefined = undefined;
|
||||
for (let i = 0; i < numTriangles; i++) {
|
||||
// Skip this element if any master associated with any of its pointMasters are turned off.
|
||||
const pointMasterNames = pointMasterArray[3 * i];
|
||||
let pmVisibility = true;
|
||||
for (let pm = 0; pm < pointMasterNames.length; pm++) {
|
||||
const pointMasterName = pointMasterNames[pm];
|
||||
const masterName = kin.pointmasterDict[pointMasterName];
|
||||
if (visibilityState) {
|
||||
const masterVisible = visibilityState.masterVisibility.get(masterName);
|
||||
if (masterVisible === false) {
|
||||
pmVisibility = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const masterInfo = kin.masterDict[masterName];
|
||||
if (masterInfo && !masterInfo.visible) {
|
||||
pmVisibility = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!pmVisibility) { continue; }
|
||||
|
||||
const vertexList: Vec3[] = [];
|
||||
|
||||
// Get the vertices for the triangle out of the position array and push them onto a list.
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const v = Vec3.zero();
|
||||
v[0] = coords[3 * (3 * i + j) + 0];
|
||||
v[1] = coords[3 * (3 * i + j) + 1];
|
||||
v[2] = coords[3 * (3 * i + j) + 2];
|
||||
vertexList.push(v);
|
||||
}
|
||||
|
||||
// Set the group per triangle so that we can do per-triangle coloring.
|
||||
const group = index++;
|
||||
builderState.currentGroup = group;
|
||||
|
||||
// colorArray may be undefined; push a default color when not provided.
|
||||
// There is one color per group, even if we have two triangles in this group.
|
||||
const color = colorArray && colorArray.length > i * 3 ? colorArray[3 * i] : Color.fromRgb(255, 255, 255);
|
||||
colors.push(color);
|
||||
|
||||
// labelArray may be undefined; push an empty string when not provided
|
||||
const label = labelArray && labelArray.length > i ? labelArray[i] : '';
|
||||
labels.push(label);
|
||||
|
||||
// Find the vertics and normal for the triangle.
|
||||
const a: Vec3 = vertexList[0];
|
||||
const b: Vec3 = vertexList[1];
|
||||
const c: Vec3 = vertexList[2];
|
||||
|
||||
// Put both orientations of the triangle. Add a small amount along the normal to make them
|
||||
// not be exactly on top of each other so that we only see the front face of each.
|
||||
let n = Vec3.zero();
|
||||
Vec3.triangleNormal(n, a, b, c);
|
||||
if (i % 2 === 1) {
|
||||
// For ribbons, every other triangle is meant to be paired with the previous one to make a quad with the same normal.
|
||||
// So use the same normal for every other triangle.
|
||||
n = prevTriangleNormal || n;
|
||||
}
|
||||
prevTriangleNormal = n;
|
||||
addOffsetTriangle(builderState, a, b, c, n, 0.01);
|
||||
|
||||
// Invert the normal for the back face.
|
||||
Vec3.negate(n, n);
|
||||
addOffsetTriangle(builderState, a, c, b, n, 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
const mesh = MeshBuilder.getMesh(builderState);
|
||||
return { mesh, colors, labels };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build spheres geometry and collect per-sphere radii from the KIN BallList entries.
|
||||
* Returns an object with the Spheres geometry and a Float32Array with per-center radii (one entry per center, in the same order they were added).
|
||||
*/
|
||||
async function getSpheres(ctx: RuntimeContext, kin: Kinemage, visibilityState?: KinemageVisibilityState) {
|
||||
const balls: BallList[] = kin.ballLists;
|
||||
const builderState = SpheresBuilder.create();
|
||||
const radii: number[] = [];
|
||||
const colors: Color[] = [];
|
||||
const labels: string[] = [];
|
||||
|
||||
// Every ball is in its own Molstar group because they may have individual radii and we look
|
||||
// up the radius based on the group is in the size function.
|
||||
let index = 0;
|
||||
|
||||
for (let i = 0; i < balls.length; i++) {
|
||||
const ballList = balls[i];
|
||||
const positionArray = ballList.positionArray;
|
||||
const radiusArray = ballList.radiusArray;
|
||||
const colorArray = ballList.colorArray;
|
||||
const masterArray = ballList.masterArray;
|
||||
|
||||
// Check the visibility of all of our masters and skip this ball list if any of them are not visible.
|
||||
const visible = getVisibility(ballList.group, ballList.subgroup, masterArray, kin, visibilityState);
|
||||
if (!visible) { continue; }
|
||||
|
||||
const numBalls = positionArray.length / 3;
|
||||
for (let j = 0; j < numBalls; j++) {
|
||||
// Skip this element if any master associated with any of its pointMasters are turned off.
|
||||
const pointMasterNames = ballList.pointmasterArray[j];
|
||||
let pmVisibility = true;
|
||||
for (let pm = 0; pm < pointMasterNames.length; pm++) {
|
||||
const pointMasterName = pointMasterNames[pm];
|
||||
const masterName = kin.pointmasterDict[pointMasterName];
|
||||
if (visibilityState) {
|
||||
const masterVisible = visibilityState.masterVisibility.get(masterName);
|
||||
if (masterVisible === false) {
|
||||
pmVisibility = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const masterInfo = kin.masterDict[masterName];
|
||||
if (masterInfo && !masterInfo.visible) {
|
||||
pmVisibility = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!pmVisibility) { continue; }
|
||||
|
||||
const group = index++;
|
||||
builderState.add(positionArray[3 * j + 0], positionArray[3 * j + 1], positionArray[3 * j + 2], group);
|
||||
// radiusArray may be undefined; push NaN when radius not provided
|
||||
radii.push(radiusArray && radiusArray.length > j ? radiusArray[j] : NaN);
|
||||
// colorArray may be undefined; push a default color when not provided
|
||||
colors.push(colorArray && colorArray.length > j ? colorArray[j] : Color.fromRgb(255, 255, 255));
|
||||
// labelArray may be undefined; push an empty string when not provided
|
||||
labels.push(ballList.labelArray && ballList.labelArray.length > j ? ballList.labelArray[j] : '');
|
||||
}
|
||||
}
|
||||
|
||||
const spheres = builderState.getSpheres();
|
||||
return { spheres, radii: new Float32Array(radii), colors, labels };
|
||||
}
|
||||
|
||||
function makePointsShapeGetter(visibilityState?: KinemageVisibilityState) {
|
||||
|
||||
const getShape = async (ctx: RuntimeContext, kinData: KinData, props: PD.Values<KinShapePointsParams>, shape?: Shape<Points>) => {
|
||||
// Get our points, adding them from all of the entries in the dot lists
|
||||
const { points: _points, colors, labels } = await getPoints(ctx, kinData.source, visibilityState);
|
||||
|
||||
// Color function signature: (groupId: number, instanceId: number) => Color
|
||||
// For Lines the groupId corresponds to the line index (order added).
|
||||
const colorFn = (group: number, instance: number) => {
|
||||
return colors[group];
|
||||
};
|
||||
|
||||
// Label function signature: (groupId: number, instanceId: number) => string
|
||||
// For Lines the groupId corresponds to the line index (order added).
|
||||
const labelFn = (group: number, instance: number) => {
|
||||
return labels[group];
|
||||
};
|
||||
|
||||
const _shape = Shape.create<Points>(
|
||||
'kin-points',
|
||||
kinData.source,
|
||||
_points,
|
||||
colorFn, // color function reads per-point colors
|
||||
() => 1, // size function
|
||||
labelFn // label function reads per-point labels
|
||||
);
|
||||
return _shape;
|
||||
};
|
||||
return getShape;
|
||||
}
|
||||
|
||||
function makeLineShapeGetter(visibilityState?: KinemageVisibilityState) {
|
||||
|
||||
const getShape = async (ctx: RuntimeContext, kinData: KinData, props: PD.Values<KinShapeLinesParams>, shape?: Shape<Lines>) => {
|
||||
// Get our lines, adding them from all of the entries in the vector lists
|
||||
const { lines: _lines, widths, colors, labels } = await getLines(ctx, kinData.source, visibilityState);
|
||||
|
||||
// Size function signature: (groupId: number, instanceId: number) => number
|
||||
// For Lines the groupId corresponds to the line index (order added).
|
||||
const sizeFn = (group: number, instance: number) => {
|
||||
// We're specifying the radius, which is half the width.
|
||||
let w = widths[group] / 2.0;
|
||||
if (w < 1.0) { w = 1.0; }
|
||||
return Number.isFinite(w) ? w : 1.0;
|
||||
};
|
||||
|
||||
// Color function signature: (groupId: number, instanceId: number) => Color
|
||||
// For Lines the groupId corresponds to the line index (order added).
|
||||
const colorFn = (group: number, instance: number) => {
|
||||
return colors[group];
|
||||
};
|
||||
|
||||
// Label function signature: (groupId: number, instanceId: number) => string
|
||||
// For Lines the groupId corresponds to the line index (order added).
|
||||
const labelFn = (group: number, instance: number) => {
|
||||
return labels[group];
|
||||
};
|
||||
|
||||
const _shape = Shape.create<Lines>(
|
||||
'kin-lines',
|
||||
kinData.source,
|
||||
_lines,
|
||||
colorFn, // color function reads per-line colors
|
||||
sizeFn, // size function reads per-line widths
|
||||
labelFn // label function
|
||||
);
|
||||
return _shape;
|
||||
};
|
||||
return getShape;
|
||||
}
|
||||
|
||||
function makeMeshShapeGetter(visibilityState?: KinemageVisibilityState) {
|
||||
|
||||
const getShape = async (ctx: RuntimeContext, kinData: KinData, props: PD.Values<KinShapeMeshParams>, shape?: Shape<Mesh>) => {
|
||||
|
||||
let { mesh: _mesh, colors, labels } = await getMesh(ctx, kinData.source, visibilityState);
|
||||
// Ensure that _mesh is not undifined before we pass it to Shape.create. If it is undefined, create an empty mesh instead.
|
||||
if (!_mesh) {
|
||||
console.warn('No mesh could be created from the KIN data. Creating an empty mesh instead.');
|
||||
_mesh = Mesh.createEmpty();
|
||||
}
|
||||
|
||||
// Color function signature: (groupId: number, instanceId: number) => Color
|
||||
// For Lines the groupId corresponds to the line index (order added).
|
||||
const colorFn = (group: number, instance: number) => {
|
||||
return colors[group];
|
||||
};
|
||||
|
||||
const labelFn = (group: number, instance: number) => {
|
||||
return labels[group];
|
||||
};
|
||||
|
||||
const _shape = Shape.create<Mesh>(
|
||||
'kin-mesh',
|
||||
kinData.source,
|
||||
_mesh,
|
||||
colorFn, // color function reads per-triangle colors
|
||||
() => 1, // size function
|
||||
labelFn // label function
|
||||
);
|
||||
return _shape;
|
||||
};
|
||||
return getShape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spheres shape getter: uses per-center radii read from the KIN BallList radiusArray when available.
|
||||
*/
|
||||
function makeSpheresShapeGetter(visibilityState?: KinemageVisibilityState) {
|
||||
|
||||
const getShape = async (ctx: RuntimeContext, kinData: KinData, props: PD.Values<KinShapeSpheresParams>, shape?: Shape<Spheres>) => {
|
||||
// Build spheres geometry and collect per-center radii
|
||||
const { spheres: _spheres, radii, colors, labels } = await getSpheres(ctx, kinData.source, visibilityState);
|
||||
|
||||
// size function signature: (groupId: number, instanceId: number) => number
|
||||
// For Spheres the groupId corresponds to the center index (order added).
|
||||
const sizeFn = (group: number, instance: number) => {
|
||||
const r = radii[group];
|
||||
return Number.isFinite(r) ? r : 1.0;
|
||||
};
|
||||
|
||||
// Color function signature: (groupId: number, instanceId: number) => Color
|
||||
// For Spheres the groupId corresponds to the center index (order added).
|
||||
const colorFn = (group: number, instance: number) => {
|
||||
return colors[group];
|
||||
};
|
||||
|
||||
// Label function signature: (groupId: number, instanceId: number) => string
|
||||
// For Spheres the groupId corresponds to the center index (order added).
|
||||
const labelFn = (group: number, instance: number) => {
|
||||
return labels[group];
|
||||
};
|
||||
|
||||
const _shape = Shape.create<Spheres>(
|
||||
'kin-spheres',
|
||||
kinData.source,
|
||||
_spheres,
|
||||
colorFn, // color function reads per-center colors
|
||||
sizeFn, // size function reads per-center radii
|
||||
labelFn // label function
|
||||
);
|
||||
return _shape;
|
||||
};
|
||||
return getShape;
|
||||
}
|
||||
|
||||
export function shapePointsFromKin(source: Kinemage, visibilityState: KinemageVisibilityState | undefined, params?: { transforms?: Mat4[] }, label?: string) {
|
||||
return Task.create<ShapeProvider<KinData, Points, KinShapePointsParams>>('Kin Shape Points Provider', async ctx => {
|
||||
return {
|
||||
label: label ?? 'Points',
|
||||
data: { source, transforms: params?.transforms },
|
||||
params: createKinShapePointsParams(source),
|
||||
getShape: makePointsShapeGetter(visibilityState),
|
||||
geometryUtils: Points.Utils
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function shapeLinesFromKin(source: Kinemage, visibilityState: KinemageVisibilityState | undefined, params?: { transforms?: Mat4[] }, label?: string) {
|
||||
return Task.create<ShapeProvider<KinData, Lines, KinShapeLinesParams>>('Kin Shape Lines Provider', async ctx => {
|
||||
return {
|
||||
label: label ?? 'Lines',
|
||||
data: { source, transforms: params?.transforms },
|
||||
params: createKinShapeLinesParams(source),
|
||||
getShape: makeLineShapeGetter(visibilityState),
|
||||
geometryUtils: Lines.Utils
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function shapeMeshFromKin(source: Kinemage, visibilityState: KinemageVisibilityState | undefined, params?: { transforms?: Mat4[] }, label?: string) {
|
||||
return Task.create<ShapeProvider<KinData, Mesh, KinShapeMeshParams>>('Kin Shape Mesh Provider', async ctx => {
|
||||
return {
|
||||
label: label ?? 'Meshes',
|
||||
data: { source, transforms: params?.transforms },
|
||||
params: createKinShapeMeshParams(source),
|
||||
getShape: makeMeshShapeGetter(visibilityState),
|
||||
geometryUtils: Mesh.Utils
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function shapeSpheresFromKin(source: Kinemage, visibilityState: KinemageVisibilityState | undefined, params?: { transforms?: Mat4[] }, label?: string) {
|
||||
return Task.create<ShapeProvider<KinData, Spheres, KinShapeSpheresParams>>('Kin Shape Spheres Provider', async ctx => {
|
||||
return {
|
||||
label: label ?? 'Spheres',
|
||||
data: { source, transforms: params?.transforms },
|
||||
params: createKinShapeSpheresParams(source),
|
||||
getShape: makeSpheresShapeGetter(visibilityState),
|
||||
geometryUtils: Spheres.Utils
|
||||
};
|
||||
});
|
||||
}
|
||||
116
src/extensions/kinemage/prop.ts
Normal file
116
src/extensions/kinemage/prop.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Russ Taylor <russ@reliasolve.com>
|
||||
*/
|
||||
|
||||
/** Based on the ../anvil extension. */
|
||||
|
||||
import { Structure } from '../../mol-model/structure';
|
||||
import { ParamDefinition as PD } from '../../mol-util/param-definition';
|
||||
import { CustomPropertyDescriptor } from '../../mol-model/custom-property';
|
||||
import { CustomStructureProperty } from '../../mol-model-props/common/custom-structure-property';
|
||||
import { CustomProperty } from '../../mol-model-props/common/custom-property';
|
||||
import { Task } from '../../mol-task';
|
||||
|
||||
import { Kinemage } from './reader/schema';
|
||||
import { parseKin } from './reader/parser';
|
||||
|
||||
export const KinemageParams = {
|
||||
};
|
||||
export type KinemageParams = typeof KinemageParams
|
||||
export type KinemageProps = PD.Values<KinemageParams>
|
||||
|
||||
export const KinemageDataParams = {
|
||||
...KinemageParams
|
||||
};
|
||||
export type KinemageDataParams = typeof KinemageDataParams
|
||||
export type KinemageDataProps = PD.Values<KinemageDataParams>
|
||||
|
||||
export { KinemageData };
|
||||
|
||||
interface KinemageData {
|
||||
/**
|
||||
* List of Kinemages read from one or more files.
|
||||
*/
|
||||
readonly kinemages: Kinemage[]
|
||||
}
|
||||
|
||||
const FileSourceParams = {
|
||||
input: PD.File({ accept: '.kin', multiple: false })
|
||||
};
|
||||
type FileSourceProps = PD.Values<typeof FileSourceParams>
|
||||
|
||||
namespace KinemageData {
|
||||
export enum Tag {
|
||||
Representation = 'kinemage-3d'
|
||||
}
|
||||
|
||||
export const symbols = {
|
||||
};
|
||||
|
||||
async function loadKinemageData(data: string): Promise<Kinemage[]> {
|
||||
const task = parseKin(data);
|
||||
const result = await task.run();
|
||||
if (result.isError) {
|
||||
throw new Error('Failed to parse KIN data');
|
||||
}
|
||||
return result.result;
|
||||
}
|
||||
|
||||
export async function open(file: FileSourceProps | File): Promise<KinemageData> {
|
||||
|
||||
let fileToRead: File;
|
||||
|
||||
if (file instanceof File) {
|
||||
fileToRead = file;
|
||||
} else if (file && file.input && file.input.file) {
|
||||
fileToRead = file.input.file;
|
||||
} else {
|
||||
throw new Error('No file given');
|
||||
}
|
||||
|
||||
const task = Task.create('Load KIN file', async ctx => {
|
||||
const data = await fileToRead.text();
|
||||
const kinemages = await loadKinemageData(data);
|
||||
return kinemages;
|
||||
});
|
||||
|
||||
const kinemages = await task.run();
|
||||
return { kinemages };
|
||||
}
|
||||
}
|
||||
|
||||
export const KinemageDataProvider: CustomStructureProperty.Provider<KinemageDataParams, KinemageData> = CustomStructureProperty.createProvider({
|
||||
label: 'Kinemage',
|
||||
descriptor: CustomPropertyDescriptor({
|
||||
name: 'Kinemage_loaded_data',
|
||||
symbols: KinemageData.symbols,
|
||||
}),
|
||||
type: 'root',
|
||||
defaultParams: KinemageDataParams,
|
||||
getParams: (data: Structure) => KinemageDataParams,
|
||||
isApplicable,
|
||||
obtain: async (ctx: CustomProperty.Context, data: Structure, props: Partial<KinemageDataProps>) => {
|
||||
const p = { ...PD.getDefaultValues(KinemageDataParams), ...props };
|
||||
try {
|
||||
return { value: await computeKinemageProps(ctx, data, p) };
|
||||
} catch (e) {
|
||||
// the "Residues Embedded in Membrane" symbol may bypass isApplicable() checks
|
||||
console.warn('Failed to predict membrane orientation. This happens for short peptides and entries without amino acids.');
|
||||
return { value: undefined };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function isApplicable(structure: Structure) {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function computeKinemageProps(ctx: CustomProperty.Context, data: Structure, props: Partial<KinemageProps>): Promise<KinemageData> {
|
||||
// Return an empty KinemageData object since the actual data will be loaded asynchronously via the `open` method.
|
||||
// This allows the property to be attached to the structure without blocking on file loading.
|
||||
return {
|
||||
kinemages: []
|
||||
};
|
||||
}
|
||||
939
src/extensions/kinemage/reader/kinparser.ts
Normal file
939
src/extensions/kinemage/reader/kinparser.ts
Normal file
@@ -0,0 +1,939 @@
|
||||
/**
|
||||
* Copyright (c) 2025-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Based on earlier kin-parser.ts file from the NGL project (see second author notice below).
|
||||
* @file Ported NGL-based Kinemage file parser
|
||||
* @author ReliaSolve <russ@reliasolve.com>
|
||||
* @private
|
||||
*/
|
||||
|
||||
/**
|
||||
* file Kin Parser
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
|
||||
import { Kinemage, RibbonObject } from './schema';
|
||||
import { Hsv } from '../../../mol-util/color/spaces/hsv';
|
||||
import { Color } from '../../../mol-util/color';
|
||||
|
||||
const ColorDict: { [k: string]: Color } = {
|
||||
red: Hsv.toColor(Hsv.fromArray([0, 100, 100])),
|
||||
orange: Hsv.toColor(Hsv.fromArray([20, 100, 100])),
|
||||
gold: Hsv.toColor(Hsv.fromArray([40, 100, 100])),
|
||||
yellow: Hsv.toColor(Hsv.fromArray([60, 100, 100])),
|
||||
lime: Hsv.toColor(Hsv.fromArray([80, 100, 100])),
|
||||
green: Hsv.toColor(Hsv.fromArray([120, 80, 100])),
|
||||
sea: Hsv.toColor(Hsv.fromArray([150, 100, 100])),
|
||||
cyan: Hsv.toColor(Hsv.fromArray([180, 100, 85])),
|
||||
sky: Hsv.toColor(Hsv.fromArray([210, 75, 95])),
|
||||
blue: Hsv.toColor(Hsv.fromArray([240, 70, 100])),
|
||||
purple: Hsv.toColor(Hsv.fromArray([275, 75, 100])),
|
||||
magenta: Hsv.toColor(Hsv.fromArray([300, 95, 100])),
|
||||
hotpink: Hsv.toColor(Hsv.fromArray([335, 100, 100])),
|
||||
pink: Hsv.toColor(Hsv.fromArray([350, 55, 100])),
|
||||
peach: Hsv.toColor(Hsv.fromArray([25, 75, 100])),
|
||||
lilac: Hsv.toColor(Hsv.fromArray([275, 55, 100])),
|
||||
pinktint: Hsv.toColor(Hsv.fromArray([340, 30, 100])),
|
||||
peachtint: Hsv.toColor(Hsv.fromArray([25, 50, 100])),
|
||||
yellowtint: Hsv.toColor(Hsv.fromArray([60, 50, 100])),
|
||||
greentint: Hsv.toColor(Hsv.fromArray([135, 40, 100])),
|
||||
bluetint: Hsv.toColor(Hsv.fromArray([220, 40, 100])),
|
||||
lilactint: Hsv.toColor(Hsv.fromArray([275, 35, 100])),
|
||||
white: Hsv.toColor(Hsv.fromArray([0, 0, 100])),
|
||||
gray: Hsv.toColor(Hsv.fromArray([0, 0, 50])),
|
||||
brown: Hsv.toColor(Hsv.fromArray([20, 45, 75])),
|
||||
deadwhite: Hsv.toColor(Hsv.fromArray([0, 0, 100])),
|
||||
deadblack: Hsv.toColor(Hsv.fromArray([0, 0, 0])),
|
||||
invisible: Hsv.toColor(Hsv.fromArray([0, 0, 0]))
|
||||
};
|
||||
|
||||
const reWhitespaceComma = /[\s,]+/;
|
||||
const reCurlyWhitespace = /[^{}\s]*{[^{}]+}|[^{}\s]+/g;
|
||||
const reTrimCurly = /^{+|}+$/g;
|
||||
const reCollapseEqual = /\s*=\s*/g;
|
||||
|
||||
function parseListDef(line: string, localColorDict: { [k: string]: Color }) {
|
||||
let name;
|
||||
let defaultColor: Color = localColorDict['white']; // Default color is white, but it can be overridden by the list definition
|
||||
let radius;
|
||||
let nobutton = false;
|
||||
const master = [];
|
||||
let width = 2; // Default width is 2, but it can be overridden by the list definition
|
||||
|
||||
line = line.replace(reCollapseEqual, '=');
|
||||
|
||||
const lm = line.match(reCurlyWhitespace) as RegExpMatchArray;
|
||||
for (let j = 1; j < lm.length; ++j) {
|
||||
const e = lm[j];
|
||||
if (e[0] === '{') {
|
||||
name = e.substring(1, e.length - 1);
|
||||
} else {
|
||||
const es = e.split('=');
|
||||
if (es.length === 2) {
|
||||
if (es[0] === 'color') {
|
||||
const colorName = parseStr(es[1]);
|
||||
defaultColor = localColorDict[colorName];
|
||||
} else if (es[0] === 'width') {
|
||||
width = parseInt(es[1]);
|
||||
} else if (es[0] === 'master') {
|
||||
master.push(es[1].replace(reTrimCurly, ''));
|
||||
} else if (es[0] === 'radius') {
|
||||
radius = parseFloat(es[1]);
|
||||
} else {
|
||||
console.log('Kinemage: Unknown list definition term found: ' + es[0]);
|
||||
}
|
||||
} else if (e === 'nobutton') {
|
||||
nobutton = true;
|
||||
} else {
|
||||
console.log('Kinemage: Unknown list definition term found: ' + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
listName: name,
|
||||
listColor: defaultColor,
|
||||
listMasters: master,
|
||||
listWidth: width,
|
||||
listRadius: radius,
|
||||
nobutton: nobutton
|
||||
};
|
||||
}
|
||||
|
||||
function parseListElm(line: string, localColorDict: { [k: string]: Color }) {
|
||||
line = line.trim();
|
||||
|
||||
const idx1 = line.indexOf('{');
|
||||
const idx2 = line.indexOf('}');
|
||||
const ls = line.substr(idx2 + 1).split(reWhitespaceComma);
|
||||
|
||||
const label = line.substr(idx1 + 1, idx2 - 1);
|
||||
const position = [
|
||||
parseFloat(ls[ls.length - 3]),
|
||||
parseFloat(ls[ls.length - 2]),
|
||||
parseFloat(ls[ls.length - 1])
|
||||
];
|
||||
let color, width, radius;
|
||||
let lineBreak = false;
|
||||
let triangleBreak = false;
|
||||
const pointMasters: string[] = [];
|
||||
for (let lsindex = 4; lsindex <= ls.length; lsindex++) {
|
||||
const literal = ls[ls.length - lsindex];
|
||||
if (literal in localColorDict) {
|
||||
color = localColorDict[ls[ls.length - lsindex]];
|
||||
}
|
||||
if (literal.startsWith('width')) {
|
||||
width = parseInt(literal.substring(5));
|
||||
}
|
||||
if (literal.startsWith('r=')) {
|
||||
radius = parseFloat(literal.split('=')[1]);
|
||||
}
|
||||
if (literal.startsWith('P')) {
|
||||
lineBreak = true;
|
||||
}
|
||||
if (literal.startsWith('X')) {
|
||||
triangleBreak = true;
|
||||
}
|
||||
if (literal.startsWith("'") && literal.endsWith("'")) {
|
||||
// Handle single-character tags by putting each character into a pointMaster tag, e.g. 'ab' would be two tags, 'a' and 'b'
|
||||
const tagString: string = literal.substring(1, literal.length - 1);
|
||||
for (let i = 0; i < tagString.length; i++) {
|
||||
pointMasters.push(tagString[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: label,
|
||||
position: position,
|
||||
color: color,
|
||||
radius: radius,
|
||||
width: width,
|
||||
isLineBreak: lineBreak,
|
||||
isTriangleBreak: triangleBreak,
|
||||
pointMasters: pointMasters
|
||||
};
|
||||
}
|
||||
|
||||
function parseStr(line: string) {
|
||||
const start = line.indexOf('{');
|
||||
const end = line.indexOf('}');
|
||||
return line.substring(
|
||||
start !== -1 ? start + 1 : 0,
|
||||
end !== -1 ? end : undefined
|
||||
).trim();
|
||||
}
|
||||
|
||||
function parseFlag(line: string) {
|
||||
const end = line.indexOf('}');
|
||||
return end === -1 ? undefined : line.substr(end + 1).trim();
|
||||
}
|
||||
|
||||
function parseGroup(line: string) {
|
||||
let name: string = '';
|
||||
const master: string[] = [];
|
||||
const flags: { [k: string]: string | boolean } = {};
|
||||
|
||||
line = line.replace(reCollapseEqual, '=');
|
||||
|
||||
const lm = line.match(reCurlyWhitespace) as RegExpMatchArray;
|
||||
for (let j = 1; j < lm.length; ++j) {
|
||||
const e = lm[j];
|
||||
if (e[0] === '{') {
|
||||
name = e.substring(1, e.length - 1);
|
||||
} else {
|
||||
const es = e.split('=');
|
||||
if (es.length === 2) {
|
||||
if (es[0] === 'master') {
|
||||
master.push(es[1].replace(reTrimCurly, ''));
|
||||
} else {
|
||||
flags[es[0]] = es[1].replace(reTrimCurly, '');
|
||||
}
|
||||
} else {
|
||||
flags[es[0]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groupName: name,
|
||||
groupFlags: flags,
|
||||
groupMasters: master,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePointmaster(line: string) {
|
||||
let name: string = '';
|
||||
const tags: string[] = [];
|
||||
let on: boolean | undefined = undefined;
|
||||
|
||||
// Find the string name between curly braces, or print an error if not found
|
||||
const nameMatch = line.match(/{([^}]+)}/);
|
||||
if (nameMatch) {
|
||||
name = nameMatch[1];
|
||||
|
||||
// Find all characters between the pair of single quotes, which are the tags, and add them to the tags array
|
||||
const tagMatch = line.match(/'([^']+)'/);
|
||||
if (tagMatch) {
|
||||
const tagString: string = tagMatch[1];
|
||||
for (let i = 0; i < tagString.length; i++) {
|
||||
tags.push(tagString[i]);
|
||||
}
|
||||
|
||||
// See if the line contains the word "on" or "off" and set the on variable accordingly
|
||||
if (line.includes(' on')) {
|
||||
on = true;
|
||||
} else if (line.includes(' off')) {
|
||||
on = false;
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log('Kinemage: Pointmaster definition missing tags: ' + line);
|
||||
}
|
||||
} else {
|
||||
console.log('Kinemage: Pointmaster definition missing name: ' + line);
|
||||
}
|
||||
|
||||
return {
|
||||
name: name,
|
||||
tags: tags,
|
||||
on: on
|
||||
};
|
||||
}
|
||||
|
||||
function convertKinTriangleArrays(ribbonObject: RibbonObject) {
|
||||
// have to convert ribbons/triangle lists from stripdrawmode to normal drawmode
|
||||
// index [ 0 1 2 3 4 5 6 7 8 91011 ]
|
||||
// label/color/ptm [ 0 1 2 3 4 5 ] to [ 0 1 2 1 2 3 2 3 4 3 4 5 ]
|
||||
// convertedindex [ 0 1 2 3 4 5 6 7 8 91011121314151617181920212223242526 ]
|
||||
// index [ 0 1 2 3 4 5 6 7 8 91011121314 ] [ 0 1 2 3 4 5 6 7 8 3 4 5 6 7 8 91011 6 7 8 91011121314 ]
|
||||
// position [ 0 0 0 1 1 1 2 2 2 3 3 3 4 4 4 ] to [ 0 0 0 1 1 1 2 2 2 1 1 1 2 2 2 3 3 3 2 2 2 3 3 3 4 4 4 ]
|
||||
const { labelArray, positionArray, colorArray, breakArray } = ribbonObject;
|
||||
const convertedLabels = [];
|
||||
for (let i = 0; i < (labelArray.length - 2) * 3; ++i) {
|
||||
convertedLabels[i] = labelArray[i - Math.floor(i / 3) * 2];
|
||||
}
|
||||
const convertedColors = [];
|
||||
for (let i = 0; i < (colorArray.length - 2) * 3; ++i) {
|
||||
convertedColors[i] = colorArray[i - Math.floor(i / 3) * 2];
|
||||
}
|
||||
const convertedPMs = [];
|
||||
for (let i = 0; i < (ribbonObject.pointmasterArray.length - 2) * 3; ++i) {
|
||||
convertedPMs[i] = ribbonObject.pointmasterArray[i - Math.floor(i / 3) * 2];
|
||||
}
|
||||
const convertedBreaks = [];
|
||||
for (let i = 0; i < (breakArray.length - 2) * 3; ++i) {
|
||||
convertedBreaks[i] = breakArray[i - Math.floor(i / 3) * 2];
|
||||
}
|
||||
const convertedPositions = [];
|
||||
for (let i = 0; i < (positionArray.length / 3 - 2) * 9; ++i) {
|
||||
convertedPositions[i] = positionArray[i - Math.floor(i / 9) * 6];
|
||||
}
|
||||
const vector3Positions = [];
|
||||
for (let i = 0; i < (convertedPositions.length) / 3; ++i) {
|
||||
vector3Positions.push([convertedPositions[i * 3], convertedPositions[i * 3] + 1, convertedPositions[i * 3] + 2]);
|
||||
}
|
||||
return {
|
||||
group: ribbonObject.group,
|
||||
subgroup: ribbonObject.subgroup,
|
||||
name: ribbonObject.name,
|
||||
masterArray: ribbonObject.masterArray,
|
||||
pointmasterArray: convertedPMs,
|
||||
nobutton: ribbonObject.nobutton,
|
||||
labelArray: convertedLabels,
|
||||
positionArray: convertedPositions,
|
||||
breakArray: convertedBreaks,
|
||||
colorArray: convertedColors,
|
||||
pairTriangleNormals: ribbonObject.pairTriangleNormals
|
||||
};
|
||||
}
|
||||
|
||||
function removePointBreaksTriangleArrays(convertedRibbonObject: RibbonObject) {
|
||||
// after converting ribbon/triangle arrys to drawmode, removed point break triangles
|
||||
// label/color [ 0 1 2 3 4 5 ] to [ 0 1 2 1 2 3 2 3 4 3 4 5 ]
|
||||
// position [ 0 0 0 1 1 1 2 2 2 3 3 3 4 4 4 ] to [ 0 0 0 1 1 1 2 2 2 1 1 1 2 2 2 3 3 3 2 2 2 3 3 3 4 4 4 ]
|
||||
const { labelArray, positionArray, colorArray, breakArray } = convertedRibbonObject;
|
||||
const editedLabels = [];
|
||||
const editedPositions = [];
|
||||
const editedColors = [];
|
||||
const editedPMs = [];
|
||||
const editedBreaks = [];
|
||||
for (let i = 0; i < breakArray.length / 3; i++) {
|
||||
const breakPointer = i * 3;
|
||||
const positionPointer = i * 9;
|
||||
if (!breakArray[breakPointer + 1] && !breakArray[breakPointer + 2]) {
|
||||
editedLabels.push(labelArray[breakPointer]);
|
||||
editedLabels.push(labelArray[breakPointer + 1]);
|
||||
editedLabels.push(labelArray[breakPointer + 2]);
|
||||
editedBreaks.push(breakArray[breakPointer]);
|
||||
editedBreaks.push(breakArray[breakPointer + 1]);
|
||||
editedBreaks.push(breakArray[breakPointer + 2]);
|
||||
editedPositions.push(positionArray[positionPointer]);
|
||||
editedPositions.push(positionArray[positionPointer + 1]);
|
||||
editedPositions.push(positionArray[positionPointer + 2]);
|
||||
editedPositions.push(positionArray[positionPointer + 3]);
|
||||
editedPositions.push(positionArray[positionPointer + 4]);
|
||||
editedPositions.push(positionArray[positionPointer + 5]);
|
||||
editedPositions.push(positionArray[positionPointer + 6]);
|
||||
editedPositions.push(positionArray[positionPointer + 7]);
|
||||
editedPositions.push(positionArray[positionPointer + 8]);
|
||||
editedColors.push(colorArray[breakPointer]);
|
||||
editedColors.push(colorArray[breakPointer + 1]);
|
||||
editedColors.push(colorArray[breakPointer + 2]);
|
||||
editedPMs.push(convertedRibbonObject.pointmasterArray[breakPointer]);
|
||||
editedPMs.push(convertedRibbonObject.pointmasterArray[breakPointer + 1]);
|
||||
editedPMs.push(convertedRibbonObject.pointmasterArray[breakPointer + 2]);
|
||||
}
|
||||
}
|
||||
return {
|
||||
group: convertedRibbonObject.group,
|
||||
subgroup: convertedRibbonObject.subgroup,
|
||||
name: convertedRibbonObject.name,
|
||||
masterArray: convertedRibbonObject.masterArray,
|
||||
pointmasterArray: editedPMs,
|
||||
nobutton: convertedRibbonObject.nobutton,
|
||||
labelArray: editedLabels,
|
||||
positionArray: editedPositions,
|
||||
breakArray: editedBreaks,
|
||||
colorArray: editedColors,
|
||||
pairTriangleNormals: convertedRibbonObject.pairTriangleNormals
|
||||
};
|
||||
}
|
||||
|
||||
class KinParser {
|
||||
// @brief Property that is filled in by the constructor as it parses the file. Read by the caller.
|
||||
kinemage: Kinemage;
|
||||
|
||||
// @brief Constructor for the KinParser class.
|
||||
// @param data The string data to be parsed, including all lines in the file.
|
||||
constructor(data: string) {
|
||||
this._parse(data);
|
||||
}
|
||||
|
||||
private _parse(data: string) {
|
||||
// http://kinemage.biochem.duke.edu/software/king.php
|
||||
|
||||
const kinemage: Kinemage = {
|
||||
comments: [],
|
||||
kinemage: undefined,
|
||||
onewidth: undefined,
|
||||
viewDict: {},
|
||||
pdbfile: undefined,
|
||||
texts: [],
|
||||
text: '',
|
||||
captions: [],
|
||||
caption: '',
|
||||
groupDict: {},
|
||||
subgroupDict: {},
|
||||
masterDict: {},
|
||||
pointmasterDict: {},
|
||||
dotLists: [],
|
||||
vectorLists: [],
|
||||
ballLists: [],
|
||||
ribbonLists: [],
|
||||
groupsAnimate: [],
|
||||
activeAnimateGroup: -1,
|
||||
groupsAnimate2: [],
|
||||
activeAnimateGroup2: -1
|
||||
};
|
||||
this.kinemage = kinemage;
|
||||
|
||||
// Keep a local copy of the ColorDict that we can update with new colors defined in the file.
|
||||
const localColorDict: { [k: string]: Color } = Object.assign({}, ColorDict);
|
||||
|
||||
let currentGroup: string = '';
|
||||
let currentGroupMasters: string[];
|
||||
let currentSubgroup: string = '';
|
||||
let currentSubgroupMasters: string[];
|
||||
|
||||
let isDotList = false;
|
||||
let prevDotLabel = '';
|
||||
let dotDefaultColor: Color;
|
||||
let dotLabel: string[], dotPosition: number[], dotColor: Color[], dotPointMasters: string[][];
|
||||
|
||||
let isVectorList = false;
|
||||
let prevVecLabel = '';
|
||||
let prevVecPosition: number[] | null = null;
|
||||
let prevVecColor: Color | null = null;
|
||||
let vecDefaultColor: Color, vecDefaultWidth: number;
|
||||
let vecLabel1: string[], vecLabel2: string[], vecPosition1: number[], vecPosition2: number[], vecColor1: Color[], vecColor2: Color[];
|
||||
let vecWidth: number[], vecPointMasters: string[][];
|
||||
|
||||
let isBallList = false;
|
||||
let prevBallLabel = '';
|
||||
let ballRadius: number[], ballDefaultColor: Color, ballDefaultRadius: number;
|
||||
let ballLabel: string[], ballPosition: number[], ballColor: Color[], ballPointMasters: string[][];
|
||||
|
||||
let isRibbonList = false;
|
||||
let ribbonIsTriangles = false;
|
||||
let prevRibbonPointLabel = '';
|
||||
|
||||
let ribbonListDefaultColor: Color = localColorDict['white'];
|
||||
let ribbonPointLabelArray: string[], ribbonPointPositionArray: number[], ribbonPointBreakArray: boolean[], ribbonPointColorArray: Color[];
|
||||
let ribbonPointMasters: string[][];
|
||||
|
||||
let isText = false;
|
||||
let isCaption = false;
|
||||
|
||||
let foundAnimate = false;
|
||||
let found2Animate = false;
|
||||
|
||||
function _parseChunkOfLines(_i: number, _n: number, lines: string[]) {
|
||||
for (let i = _i; i < _n; ++i) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line[0] === '@') {
|
||||
isDotList = false;
|
||||
isVectorList = false;
|
||||
isBallList = false;
|
||||
isRibbonList = false;
|
||||
isText = false;
|
||||
isCaption = false;
|
||||
}
|
||||
|
||||
if (!line) {
|
||||
isDotList = false;
|
||||
isVectorList = false;
|
||||
isBallList = false;
|
||||
isRibbonList = false;
|
||||
} else if (line.startsWith('@dot') /* dot or dotlist */) {
|
||||
// @dotlist {x} color=white master={vdw contact} master={dots}
|
||||
|
||||
let { listColor, listName, listMasters, nobutton } = parseListDef(line, localColorDict);
|
||||
|
||||
isDotList = true;
|
||||
prevDotLabel = '';
|
||||
dotLabel = [];
|
||||
dotPosition = [];
|
||||
dotColor = [];
|
||||
dotPointMasters = [];
|
||||
dotDefaultColor = listColor;
|
||||
|
||||
if (currentGroupMasters) {
|
||||
listMasters = listMasters.concat(currentGroupMasters);
|
||||
}
|
||||
if (currentSubgroupMasters) {
|
||||
listMasters = listMasters.concat(currentSubgroupMasters);
|
||||
}
|
||||
|
||||
kinemage.dotLists.push({
|
||||
group: currentGroup,
|
||||
subgroup: currentSubgroup,
|
||||
name: listName,
|
||||
masterArray: listMasters,
|
||||
pointmasterArray: dotPointMasters,
|
||||
nobutton: nobutton,
|
||||
labelArray: dotLabel,
|
||||
positionArray: dotPosition,
|
||||
colorArray: dotColor
|
||||
});
|
||||
} else if (line.startsWith('@vector') /* vector or vectorlist */) {
|
||||
// @vectorlist {x} color=white master={small overlap} master={dots}
|
||||
|
||||
let { listMasters, listName, listWidth, listColor, nobutton } = parseListDef(line, localColorDict);
|
||||
|
||||
if (listMasters) {
|
||||
listMasters.forEach(function (name: string) {
|
||||
if (!kinemage.masterDict[name]) {
|
||||
kinemage.masterDict[name] = {
|
||||
indent: false,
|
||||
visible: true
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isVectorList = true;
|
||||
prevVecLabel = '';
|
||||
prevVecPosition = null;
|
||||
prevVecColor = null;
|
||||
vecLabel1 = [];
|
||||
vecLabel2 = [];
|
||||
vecPosition1 = [];
|
||||
vecPosition2 = [];
|
||||
vecColor1 = [];
|
||||
vecColor2 = [];
|
||||
vecWidth = [];
|
||||
vecDefaultColor = listColor;
|
||||
vecPointMasters = [];
|
||||
vecDefaultWidth = 2;
|
||||
if (listWidth) {
|
||||
vecDefaultWidth = listWidth;
|
||||
}
|
||||
|
||||
if (currentGroupMasters) {
|
||||
listMasters = listMasters.concat(currentGroupMasters);
|
||||
}
|
||||
if (currentSubgroupMasters) {
|
||||
listMasters = listMasters.concat(currentSubgroupMasters);
|
||||
}
|
||||
|
||||
kinemage.vectorLists.push({
|
||||
group: currentGroup,
|
||||
subgroup: currentSubgroup,
|
||||
name: listName,
|
||||
masterArray: listMasters,
|
||||
pointmasterArray: vecPointMasters,
|
||||
nobutton: nobutton,
|
||||
label1Array: vecLabel1,
|
||||
label2Array: vecLabel2,
|
||||
position1Array: vecPosition1,
|
||||
position2Array: vecPosition2,
|
||||
color1Array: vecColor1,
|
||||
color2Array: vecColor2,
|
||||
width: vecWidth
|
||||
});
|
||||
} else if (line.startsWith('@ball') /* ball or balllist*/ || line.startsWith('@sphere') /* sphere or spherelist */) {
|
||||
let { listName, listColor, listMasters, listRadius, nobutton } = parseListDef(line, localColorDict);
|
||||
|
||||
if (listMasters) {
|
||||
listMasters.forEach(function (name: string) {
|
||||
if (!kinemage.masterDict[name]) {
|
||||
kinemage.masterDict[name] = {
|
||||
indent: false,
|
||||
visible: true
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isBallList = true;
|
||||
|
||||
prevBallLabel = '';
|
||||
ballLabel = [];
|
||||
ballRadius = [];
|
||||
ballPosition = [];
|
||||
ballColor = [];
|
||||
ballPointMasters = [];
|
||||
ballDefaultColor = listColor;
|
||||
ballDefaultRadius = listRadius !== undefined ? listRadius : 1;
|
||||
|
||||
if (currentGroupMasters) {
|
||||
listMasters = listMasters.concat(currentGroupMasters);
|
||||
}
|
||||
if (currentSubgroupMasters) {
|
||||
listMasters = listMasters.concat(currentSubgroupMasters);
|
||||
}
|
||||
|
||||
kinemage.ballLists.push({
|
||||
group: currentGroup,
|
||||
subgroup: currentSubgroup,
|
||||
name: listName,
|
||||
masterArray: listMasters,
|
||||
pointmasterArray: ballPointMasters,
|
||||
nobutton: nobutton,
|
||||
labelArray: ballLabel,
|
||||
radiusArray: ballRadius,
|
||||
positionArray: ballPosition,
|
||||
colorArray: ballColor
|
||||
});
|
||||
} else if (line.startsWith('@ribbon') /* ribbon or ribbonlist */ || line.startsWith('@triangle') /* triangle or trianglelist */) {
|
||||
let { listMasters, listName, listColor, nobutton } = parseListDef(line, localColorDict);
|
||||
|
||||
if (listMasters) {
|
||||
listMasters.forEach(function (name: string) {
|
||||
if (!kinemage.masterDict[name]) {
|
||||
kinemage.masterDict[name] = {
|
||||
indent: false,
|
||||
visible: true
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
isRibbonList = true;
|
||||
ribbonIsTriangles = line.startsWith('@triangle'); /* triangle or trianglelist */
|
||||
prevRibbonPointLabel = '';
|
||||
ribbonPointLabelArray = [];
|
||||
ribbonPointPositionArray = [];
|
||||
ribbonPointBreakArray = [];
|
||||
ribbonPointColorArray = [];
|
||||
ribbonListDefaultColor = listColor;
|
||||
ribbonPointMasters = [];
|
||||
|
||||
if (currentGroupMasters) {
|
||||
listMasters = listMasters.concat(currentGroupMasters);
|
||||
}
|
||||
if (currentSubgroupMasters) {
|
||||
listMasters = listMasters.concat(currentSubgroupMasters);
|
||||
}
|
||||
|
||||
kinemage.ribbonLists.push({
|
||||
group: currentGroup,
|
||||
subgroup: currentSubgroup,
|
||||
name: listName,
|
||||
masterArray: listMasters,
|
||||
pointmasterArray: ribbonPointMasters,
|
||||
nobutton: nobutton,
|
||||
labelArray: ribbonPointLabelArray,
|
||||
positionArray: ribbonPointPositionArray,
|
||||
breakArray: ribbonPointBreakArray,
|
||||
colorArray: ribbonPointColorArray,
|
||||
pairTriangleNormals: !ribbonIsTriangles
|
||||
});
|
||||
} else if (line.startsWith('@text')) {
|
||||
isText = true;
|
||||
kinemage.texts.push(line.substr(5));
|
||||
} else if (line.startsWith('@caption')) {
|
||||
isCaption = true;
|
||||
kinemage.captions.push(line.substr(8));
|
||||
} else if (isDotList) {
|
||||
// { CB THR 1 A}sky 'P' 18.915,14.199,5.024
|
||||
|
||||
let { label, color, position, pointMasters } = parseListElm(line, localColorDict);
|
||||
|
||||
if (label === '"') {
|
||||
label = prevDotLabel;
|
||||
} else {
|
||||
prevDotLabel = label;
|
||||
}
|
||||
|
||||
if (color === undefined) {
|
||||
color = dotDefaultColor;
|
||||
}
|
||||
|
||||
dotLabel.push(label);
|
||||
dotPosition.push(...position);
|
||||
dotColor.push(color);
|
||||
dotPointMasters.push(pointMasters);
|
||||
} else if (isVectorList) {
|
||||
// { n thr A 1 B13.79 1crnFH} P 17.047, 14.099, 3.625 { n thr A 1 B13.79 1crnFH} L 17.047, 14.099, 3.625
|
||||
|
||||
const doubleLine = line.replace(/(?!^){/g, '\n{');
|
||||
const splitLine = doubleLine.split(/\n/);
|
||||
|
||||
for (let i2 = 0; i2 < splitLine.length; i2++) {
|
||||
const singlePointLine = splitLine[i2];
|
||||
let { label, color, width, position, isLineBreak, pointMasters } = parseListElm(singlePointLine, localColorDict);
|
||||
|
||||
if (label === '"') {
|
||||
label = prevVecLabel;
|
||||
}
|
||||
|
||||
if (color === undefined) {
|
||||
color = vecDefaultColor;
|
||||
}
|
||||
|
||||
if (!isLineBreak) {
|
||||
if (prevVecPosition !== null) {
|
||||
if (width === undefined) {
|
||||
width = vecDefaultWidth;
|
||||
}
|
||||
|
||||
vecLabel1.push(prevVecLabel);
|
||||
vecPosition1.push(...prevVecPosition);
|
||||
vecColor1.push(prevVecColor ? prevVecColor : vecDefaultColor);
|
||||
|
||||
vecLabel2.push(label);
|
||||
vecPosition2.push(...position);
|
||||
vecColor2.push(color);
|
||||
vecWidth.push(width);
|
||||
|
||||
vecPointMasters.push(pointMasters);
|
||||
}
|
||||
}
|
||||
|
||||
prevVecLabel = label;
|
||||
prevVecPosition = position;
|
||||
prevVecColor = color;
|
||||
}
|
||||
} else if (isBallList) {
|
||||
// {cb arg A 1 1.431 -106.80} r=1.431 39.085, 8.083, 22.182
|
||||
|
||||
let { label, radius, color, position, pointMasters } = parseListElm(line, localColorDict);
|
||||
|
||||
if (label === '"') {
|
||||
label = prevBallLabel;
|
||||
} else {
|
||||
prevBallLabel = label;
|
||||
}
|
||||
|
||||
if (radius === undefined) {
|
||||
radius = ballDefaultRadius;
|
||||
}
|
||||
|
||||
if (color === undefined) {
|
||||
color = ballDefaultColor;
|
||||
}
|
||||
|
||||
ballLabel.push(label);
|
||||
ballRadius.push(radius);
|
||||
ballPosition.push(...position);
|
||||
ballColor.push(color);
|
||||
ballPointMasters.push(pointMasters);
|
||||
} else if (isRibbonList) {
|
||||
let { label, color, position, isTriangleBreak, pointMasters } = parseListElm(line, localColorDict);
|
||||
|
||||
if (label === '"') {
|
||||
label = prevRibbonPointLabel;
|
||||
} else {
|
||||
prevRibbonPointLabel = label;
|
||||
}
|
||||
|
||||
if (color === undefined) {
|
||||
color = ribbonListDefaultColor;
|
||||
}
|
||||
|
||||
ribbonPointLabelArray.push(label);
|
||||
ribbonPointPositionArray.push(...position);
|
||||
ribbonPointBreakArray.push(isTriangleBreak);
|
||||
ribbonPointColorArray.push(color);
|
||||
ribbonPointMasters.push(pointMasters);
|
||||
} else if (isText) {
|
||||
kinemage.texts.push(line);
|
||||
} else if (isCaption) {
|
||||
kinemage.captions.push(line);
|
||||
} else if (line.startsWith('@kinemage')) {
|
||||
kinemage.kinemage = parseInt(line.substr(9).trim());
|
||||
} else if (line.startsWith('@onewidth')) {
|
||||
kinemage.onewidth = true;
|
||||
} else if (line.startsWith('@pdbfile')) {
|
||||
kinemage.pdbfile = parseStr(line);
|
||||
} else if (line.startsWith('@group')) {
|
||||
const { groupName, groupFlags, groupMasters } = parseGroup(line);
|
||||
if (!kinemage.groupDict[groupName as string]) {
|
||||
kinemage.groupDict[groupName as string] = {
|
||||
dominant: false,
|
||||
// If the groupFlags include animate or 2animate, set those to true in the groupDict. Otherwise, set them to false.
|
||||
animate: groupFlags['animate'] ? true : false,
|
||||
'2animate': groupFlags['2animate'] ? true : false,
|
||||
// If the foundAnimate or found2Animate flags are true, set off to true; otherwise set it to the flags value.
|
||||
off: (foundAnimate || found2Animate) ? true : groupFlags['off'] ? true : false
|
||||
};
|
||||
// If the animate or 2animate flags are found in the groupFlags, set foundAnimate
|
||||
// or found2Animate to true, respectively. Also update the list and index.
|
||||
if (groupFlags['animate']) {
|
||||
foundAnimate = true;
|
||||
kinemage.groupsAnimate.push(groupName as string);
|
||||
kinemage.activeAnimateGroup = 0;
|
||||
}
|
||||
if (groupFlags['2animate']) {
|
||||
found2Animate = true;
|
||||
kinemage.groupsAnimate2.push(groupName as string);
|
||||
kinemage.activeAnimateGroup2 = 0;
|
||||
}
|
||||
currentGroupMasters = groupMasters;
|
||||
}
|
||||
currentGroup = groupName;
|
||||
|
||||
if (currentGroupMasters) {
|
||||
currentGroupMasters.forEach(function (master) {
|
||||
if (!kinemage.masterDict[master]) {
|
||||
kinemage.masterDict[master] = {
|
||||
indent: false,
|
||||
visible: true
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const key in groupFlags as { [k: string]: boolean }) {
|
||||
kinemage.groupDict[groupName as string][key] = (groupFlags as { [k: string]: boolean })[key];
|
||||
}
|
||||
} else if (line.startsWith('@subgroup')) {
|
||||
const { groupName, groupFlags, groupMasters } = parseGroup(line);
|
||||
|
||||
const combinedName = currentGroup + ':' + groupName as string;
|
||||
if (!kinemage.subgroupDict[combinedName]) {
|
||||
kinemage.subgroupDict[combinedName] = {
|
||||
dominant: false,
|
||||
// If the groupFlag includes "off", set off to true; otherwise, set it to false.
|
||||
off: groupFlags['off'] ? true : false,
|
||||
group: currentGroup
|
||||
};
|
||||
currentSubgroupMasters = groupMasters;
|
||||
}
|
||||
currentSubgroup = combinedName;
|
||||
|
||||
if (currentSubgroupMasters) {
|
||||
currentSubgroupMasters.forEach(function (master) {
|
||||
if (!kinemage.masterDict[master]) {
|
||||
kinemage.masterDict[master] = {
|
||||
indent: false,
|
||||
visible: true
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const key in groupFlags as { [k: string]: boolean }) {
|
||||
kinemage.subgroupDict[combinedName as string][key] = (groupFlags as { [k: string]: boolean })[key];
|
||||
}
|
||||
} else if (line.startsWith('@master')) {
|
||||
const name = parseStr(line);
|
||||
const flag = parseFlag(line);
|
||||
|
||||
if (!kinemage.masterDict[name]) {
|
||||
kinemage.masterDict[name] = {
|
||||
indent: false,
|
||||
visible: true
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: There can be more than one flag on a @master line: indent, off, nobutton
|
||||
if (flag === 'on') {
|
||||
kinemage.masterDict[name].visible = true;
|
||||
} else if (flag === 'off') {
|
||||
kinemage.masterDict[name].visible = false;
|
||||
} else if (flag === 'indent') {
|
||||
kinemage.masterDict[name].indent = true;
|
||||
} else if (!flag) {
|
||||
// nothing to do
|
||||
}
|
||||
} else if (line.startsWith('@pointmaster')) {
|
||||
const { name, tags, on } = parsePointmaster(line);
|
||||
if (name.length > 0 && tags.length > 0) {
|
||||
|
||||
// Ensure that we have a masterDict entry for this pointmaster name, even though it doesn't have any flags of its own.
|
||||
if (!kinemage.masterDict[name]) {
|
||||
kinemage.masterDict[name] = {
|
||||
indent: false,
|
||||
visible: on !== false // If the on variable is explicitly false, set visible to false. Otherwise, set it to true.
|
||||
};
|
||||
}
|
||||
|
||||
// Add the mapping to point each single-character tag to the pointmaster name in the pointmasterDict.
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
kinemage.pointmasterDict[tags[i]] = name;
|
||||
}
|
||||
}
|
||||
} else if (line.startsWith('@colorset')) {
|
||||
// We have a string inside curly brackets {} followed by the name of an existing dictionary color.
|
||||
const colorName = parseStr(line);
|
||||
const colorReference = parseFlag(line);
|
||||
if (colorReference && colorReference in localColorDict) {
|
||||
localColorDict[colorName] = localColorDict[colorReference];
|
||||
}
|
||||
} else if (/^@(\d*)viewid\b/.test(line)) {
|
||||
const m = line.match(/^@(\d*)viewid\b/);
|
||||
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
|
||||
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
|
||||
kinemage.viewDict[viewCount].name = parseStr(line);
|
||||
} else if (/^@(\d*)center\b/.test(line)) {
|
||||
// Match all of the line after center as another string.
|
||||
const m = line.match(/^@(\d*)center\b\s*(.*)$/);
|
||||
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
|
||||
// Pull out the three whitespace-separated numbers after the keyword. Parse each as a float and
|
||||
// add them to a length-3 list of numbers.
|
||||
const rest = (m && m[2]) ? m[2].trim() : '';
|
||||
// Split on whitespace and take the first three tokens, parsed as floating-point numbers, as the center coordinates.
|
||||
const parts = rest.length > 0 ? rest.split(/\s+/).filter(Boolean) : [];
|
||||
const centerTokens = parts.slice(0, 3).map(parseFloat);
|
||||
// If the length is 3 and all are valid numbers, add the list of three numbers to the view dictionary.
|
||||
if (centerTokens.length === 3 && centerTokens.every(num => !isNaN(num))) {
|
||||
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
|
||||
kinemage.viewDict[viewCount].center = centerTokens;
|
||||
}
|
||||
} else if (/^@(\d*)matrix\b/.test(line)) {
|
||||
// Match all of the line after matrix as another string.
|
||||
const m = line.match(/^@(\d*)matrix\b\s*(.*)$/);
|
||||
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
|
||||
// Pull out the nine whitespace-separated numbers after the keyword. Parse each as a float and
|
||||
// add them to a length-9 list of numbers.
|
||||
const rest = (m && m[2]) ? m[2].trim() : '';
|
||||
// Split on whitespace and take the first nine tokens, parsed as floating-point numbers, as the matrix values.
|
||||
const parts = rest.length > 0 ? rest.split(/\s+/).filter(Boolean) : [];
|
||||
const matrixTokens = parts.slice(0, 9).map(parseFloat);
|
||||
// If the length is 9 and all are valid numbers, add the list of nine numbers to the view dictionary.
|
||||
if (matrixTokens.length === 9 && matrixTokens.every(num => !isNaN(num))) {
|
||||
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
|
||||
kinemage.viewDict[viewCount].matrix = matrixTokens;
|
||||
}
|
||||
} else if (/^@(\d*)span\b/.test(line)) {
|
||||
// Match all of the line after span as another string.
|
||||
const m = line.match(/^@(\d*)span\b\s*(.*)$/);
|
||||
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
|
||||
// Pull out the remainder of the line and parse it as a float.
|
||||
const rest = (m && m[2]) ? m[2].trim() : '';
|
||||
const spanValue = parseFloat(rest);
|
||||
// If it is a valid number, add it to the view dictionary.
|
||||
if (!isNaN(spanValue)) {
|
||||
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
|
||||
kinemage.viewDict[viewCount].span = spanValue;
|
||||
}
|
||||
} else if (/^@(\d*)zoom\b/.test(line)) {
|
||||
// Match all of the line after zoom as another string.
|
||||
const m = line.match(/^@(\d*)zoom\b\s*(.*)$/);
|
||||
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
|
||||
// Pull out the remainder of the line and parse it as a float.
|
||||
const rest = (m && m[2]) ? m[2].trim() : '';
|
||||
const zoomValue = parseFloat(rest);
|
||||
// If it is a valid number, add it to the view dictionary.
|
||||
if (!isNaN(zoomValue)) {
|
||||
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
|
||||
kinemage.viewDict[viewCount].zoom = zoomValue;
|
||||
}
|
||||
} else if (/^@(\d*)zslab\b/.test(line)) {
|
||||
// Match all of the line after zslab as another string.
|
||||
const m = line.match(/^@(\d*)zslab\b\s*(.*)$/);
|
||||
const viewCount = (m && m[1] && m[1].length > 0) ? parseInt(m[1], 10) : 1;
|
||||
// Pull out the remainder of the line and parse it as a float.
|
||||
const rest = (m && m[2]) ? m[2].trim() : '';
|
||||
const zslabValue = parseFloat(rest);
|
||||
// If it is a valid number, add it to the view dictionary.
|
||||
if (!isNaN(zslabValue)) {
|
||||
if (!kinemage.viewDict[viewCount]) kinemage.viewDict[viewCount] = {};
|
||||
kinemage.viewDict[viewCount].zslab = zslabValue;
|
||||
}
|
||||
} else {
|
||||
console.log('Kinemage: Unrecognized line: ' + line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Break the file into a list of lines and then parse them all.
|
||||
const lines = data.split(/\r?\n/);
|
||||
_parseChunkOfLines(0, lines.length, lines);
|
||||
|
||||
kinemage.text = kinemage.texts.join('\n').trim();
|
||||
kinemage.caption = kinemage.captions.join('\n').trim();
|
||||
if (kinemage.ribbonLists) {
|
||||
const convertedLists: RibbonObject[] = [];
|
||||
kinemage.ribbonLists.forEach(function (listObject) {
|
||||
convertedLists.push(removePointBreaksTriangleArrays(convertKinTriangleArrays(listObject)));
|
||||
});
|
||||
kinemage.ribbonLists = convertedLists;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export { KinParser };
|
||||
41
src/extensions/kinemage/reader/parser.ts
Normal file
41
src/extensions/kinemage/reader/parser.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author ReliaSolve <russ@reliasolve.com>
|
||||
*/
|
||||
|
||||
import { ReaderResult as Result } from '../../../mol-io/reader/result';
|
||||
import { Task, RuntimeContext } from '../../../mol-task';
|
||||
import { Kinemage } from './schema';
|
||||
import { KinParser } from './kinparser';
|
||||
|
||||
async function parseInternal(data: string, ctx: RuntimeContext): Promise<Result<Kinemage[]>> {
|
||||
const kinemages: Kinemage[] = [];
|
||||
// Split the data into sections based on the '@kinemage' keyword, which indicates one or more kinemages in the file.
|
||||
// Handle the case where there is no '@kinemage' keyword by parsing the entire file.
|
||||
const kinemageSections = data.split(/@kinemage\s+\d+/); // Split based on '@kinemage' keyword followed by a number
|
||||
|
||||
// If there are one or more @kinemage sections, ignore the portion before the first one.
|
||||
// This will either be an empty string (if the first section starts at the beginning of the file)
|
||||
// or header data that is not part of a particular kinemage. This has the effect of removing
|
||||
// the header data even in the case where there is a single @kinemage keyword.
|
||||
if (kinemageSections.length > 1) {
|
||||
kinemageSections.shift();
|
||||
}
|
||||
|
||||
for (const section of kinemageSections) {
|
||||
if (section.trim()) { // Ignore empty sections
|
||||
const NGLParser = new KinParser(section.trim());
|
||||
const kinData = NGLParser.kinemage;
|
||||
kinemages.push(kinData);
|
||||
}
|
||||
}
|
||||
|
||||
return Result.success(kinemages);
|
||||
}
|
||||
|
||||
export function parseKin(data: string) {
|
||||
return Task.create<Result<Kinemage[]>>('Parse KIN', async ctx => {
|
||||
return await parseInternal(data, ctx);
|
||||
});
|
||||
}
|
||||
82
src/extensions/kinemage/reader/schema.ts
Normal file
82
src/extensions/kinemage/reader/schema.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Copyright (c) 2025-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author ReliaSolve <russ@reliasolve.com>
|
||||
*/
|
||||
|
||||
import { Color } from '../../../mol-util/color';
|
||||
|
||||
export interface Kinemage {
|
||||
readonly comments: ReadonlyArray<string>
|
||||
kinemage?: number,
|
||||
onewidth?: any,
|
||||
viewDict: { [id: number]: View },
|
||||
pdbfile?: string,
|
||||
text: string,
|
||||
texts: string[],
|
||||
captions: string[],
|
||||
caption: string,
|
||||
groupDict: { [k: string]: { [k: string]: boolean } },
|
||||
subgroupDict: { [k: string]: any }, // /< Subgroup key is "GroupName:SubgroupName" to preserve tree structure
|
||||
masterDict: { [k: string]: { indent: boolean, visible: boolean } },
|
||||
pointmasterDict: { [k: string]: string }, // /< Maps from single-character name to master name for points, e.g. 'a' -> 'alta'
|
||||
dotLists: DotList[],
|
||||
vectorLists: VectorList[],
|
||||
ballLists: BallList[],
|
||||
ribbonLists: RibbonObject[],
|
||||
groupsAnimate: string[],
|
||||
activeAnimateGroup: number,
|
||||
groupsAnimate2: string[],
|
||||
activeAnimateGroup2: number,
|
||||
viewSnapshots?: {} // /< Used to store view snapshots in behavior.ts to use in ui.tsx
|
||||
}
|
||||
|
||||
/** Common base for all list-like objects in a kinemage */
|
||||
export interface KinListBase {
|
||||
name?: string, // /< Optional name of the whole List
|
||||
group: string, // /< Name of the group this List belongs to (may be '' if no group)
|
||||
subgroup: string, // /< Name of the subgroup this List belongs to (may be '' if no subgroup)
|
||||
nobutton: boolean, // /< Whether the list is a nobutton list (true if 'nobutton' keyword found)
|
||||
masterArray: any[], // /< Array of master names per List, not per element
|
||||
pointmasterArray: string[][] // /< Array of point master names per element
|
||||
}
|
||||
|
||||
export interface DotList extends KinListBase {
|
||||
labelArray: string[], // /< Array of labels per element
|
||||
positionArray: number[], // /< Catenation of x, y, z for each element, 3x as many as elements
|
||||
colorArray: Color[] // /< Color for each element, as many as elements
|
||||
}
|
||||
|
||||
export interface BallList extends KinListBase {
|
||||
labelArray: string[], // /< Array of labels per element
|
||||
positionArray: number[], // /< Catenation of x, y, z for each element, 3x as many as elements
|
||||
colorArray: Color[], // /< Color for each element, as many as elements
|
||||
radiusArray: number[] // /< A single radius per element
|
||||
}
|
||||
|
||||
export interface RibbonObject extends KinListBase {
|
||||
labelArray: string[], // /< Array of labels per element
|
||||
positionArray: number[], // /< Catenation of x, y, z for each element, 9x as many as triangles (3 vertices per triangle)
|
||||
colorArray: Color[], // /< Color for each element, as many as elements
|
||||
breakArray: boolean[], // /< A single boolean per element indicating if there is a break there
|
||||
pairTriangleNormals: boolean // /< Whether to pair every other triangle normal for lighting (true for ribbons, false for triangles)
|
||||
}
|
||||
|
||||
export interface VectorList extends KinListBase {
|
||||
label1Array: string[], // /< Array of labels for the first half of each element
|
||||
label2Array: string[], // /< Array of labels for the second half of each element
|
||||
position1Array: number[], // /< Catenation of x, y, z for each element, 3x as many as elements
|
||||
position2Array: number[], // /< Catenation of x, y, z for each element, 3x as many as elements
|
||||
color1Array: Color[], // /< Color for first half of each element, as many as elements
|
||||
color2Array: Color[], // /< Color for second half of each element, as many as elements
|
||||
width: number[] // /< A single width per element
|
||||
}
|
||||
|
||||
export interface View {
|
||||
name?: string, // /< Optional name of the View
|
||||
center?: number[], // /< X, Y, Z of the center of the view; the model rotates around this point
|
||||
matrix?: number[], // /< Specifies and orthonormal rotation matrix defining view orientation
|
||||
span?: number, // /< Specifies the (smaller of) width or height of the view in world coordinates at the center
|
||||
zoom?: number, // /< Alternate zoom specification, indicates how much of the model is visible, 1=all, 2=half
|
||||
zslab?: number // /< Distance from the center to the near and far clipping planes, 200 means same as span (half is percent of half span)
|
||||
}
|
||||
34
src/extensions/kinemage/representation.ts
Normal file
34
src/extensions/kinemage/representation.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Russ Taylor <russ@reliasolve.com>
|
||||
*/
|
||||
|
||||
/** Based on the ../anvil extension. */
|
||||
|
||||
import { ParamDefinition as PD } from '../../mol-util/param-definition';
|
||||
import { Representation, RepresentationContext, RepresentationParamsGetter } from '../../mol-repr/representation';
|
||||
import { Structure } from '../../mol-model/structure';
|
||||
import { StructureRepresentation, StructureRepresentationStateBuilder } from '../../mol-repr/structure/representation';
|
||||
import { ThemeRegistryContext } from '../../mol-theme/theme';
|
||||
|
||||
// TODO: Convert this approach to a more usual one that creates visuals during parse and shows them
|
||||
// during visuals.
|
||||
|
||||
const KinemageDataVisuals = {
|
||||
};
|
||||
|
||||
export const KinemageDataParams = {
|
||||
visuals: PD.MultiSelect([], PD.objectToOptions(KinemageDataVisuals)),
|
||||
};
|
||||
export type KinemageDataParams = typeof KinemageDataParams
|
||||
export type KinemageDataProps = PD.Values<KinemageDataParams>
|
||||
|
||||
export function getKinemageDataParams(ctx: ThemeRegistryContext, structure: Structure) {
|
||||
return PD.clone(KinemageDataParams);
|
||||
}
|
||||
|
||||
export type KinemageDataRepresentation = StructureRepresentation<KinemageDataParams>
|
||||
export function KinemageDataRepresentation(ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, KinemageDataParams>): KinemageDataRepresentation {
|
||||
return Representation.createMulti('Membrane Orientation', ctx, getParams, StructureRepresentationStateBuilder, KinemageDataVisuals as unknown as Representation.Def<Structure, KinemageDataParams>);
|
||||
}
|
||||
429
src/extensions/kinemage/ui.tsx
Normal file
429
src/extensions/kinemage/ui.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Russ Taylor <russ@reliasolve.com>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Kinemage right-panel controls (right-panel only).
|
||||
*
|
||||
* Shows kinemage views, animate buttons, and group/subgroup/master toggles in the right inspector.
|
||||
* Controls update visibility controller parameters which trigger rebuilds via the state tree.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import { CollapsableState, CollapsableControls } from '../../mol-plugin-ui/base';
|
||||
import { Camera } from '../../mol-canvas3d/camera';
|
||||
import { applyViewSnapshot } from './behavior';
|
||||
import { Kinemage } from './reader/schema';
|
||||
import { StateTransforms } from '../../mol-plugin-state/transforms';
|
||||
import { KinemageShapePointsProvider, KinemageShapeLinesProvider, KinemageShapeMeshProvider, KinemageShapeSpheresProvider } from './behavior';
|
||||
|
||||
interface KinemageControlState extends CollapsableState {
|
||||
isBusy: boolean
|
||||
}
|
||||
|
||||
function nameFromString(s: string | undefined) {
|
||||
// If this is undefined, return undefined.
|
||||
if (!s) return undefined;
|
||||
// Return up to the first 30 characters of the string.
|
||||
return s.length > 30 ? s.substring(0, 30) + '...' : s;
|
||||
}
|
||||
|
||||
export class KinemageControls extends CollapsableControls<{}, KinemageControlState> {
|
||||
protected defaultState(): KinemageControlState {
|
||||
return {
|
||||
header: 'Kinemage',
|
||||
isCollapsed: false,
|
||||
isBusy: false,
|
||||
// default hidden until a kinemage is present
|
||||
isHidden: true,
|
||||
brand: { accent: 'cyan', svg: undefined as any }
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Listen for shape/state changes: when state tree cells are created or removed the visuals changed.
|
||||
this.subscribe(this.plugin.state.data.events.cell.created, (e: any) => this.onCellCreated(e));
|
||||
this.subscribe(this.plugin.state.data.events.cell.removed, () => this.onCellRemoved());
|
||||
// also track cell state updates that may change labels / visibility
|
||||
this.subscribe(this.plugin.state.data.events.cell.stateUpdated, () => this.forceUpdate());
|
||||
|
||||
// ensure initial visibility reflects current state
|
||||
this.updateVisibility();
|
||||
}
|
||||
|
||||
private onCellCreated(e: any) {
|
||||
this.updateVisibility();
|
||||
}
|
||||
|
||||
private onCellRemoved() {
|
||||
this.updateVisibility();
|
||||
}
|
||||
|
||||
private updateVisibility() {
|
||||
const kinemages = this.getKinemageList();
|
||||
this.setState({ isHidden: kinemages.length === 0 });
|
||||
}
|
||||
|
||||
private getKinemageList(): Array<{ kinData: Kinemage, ref: string, visControllerRef: string }> {
|
||||
const result: Array<{ kinData: Kinemage, ref: string, visControllerRef: string }> = [];
|
||||
|
||||
try {
|
||||
const cells = (this.plugin.state.data as any).cells as Map<string, any>;
|
||||
for (const [ref, entry] of cells) {
|
||||
const obj = (entry as any).obj;
|
||||
// Look for Format.Json nodes that contain kinData and visibilityState (visibility controller)
|
||||
if (obj && obj.data && (obj.data as any).kinData && (obj.data as any).visibilityState) {
|
||||
result.push({
|
||||
kinData: (obj.data as any).kinData,
|
||||
ref,
|
||||
visControllerRef: ref
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to enumerate kinemage nodes', e);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private getAllDescendants(nodeRef: string): string[] {
|
||||
const result: string[] = [];
|
||||
const tree = this.plugin.state.data.tree;
|
||||
const queue = [nodeRef];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const children = tree.children.get(current);
|
||||
if (children) {
|
||||
for (const childRef of children.values()) {
|
||||
result.push(childRef);
|
||||
queue.push(childRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async applyView(kinData: Kinemage, viewKey: string) {
|
||||
const snap = (kinData as any).viewSnapshots?.[viewKey];
|
||||
if (snap) {
|
||||
await applyViewSnapshot(this.plugin, snap as Partial<Camera.Snapshot>);
|
||||
}
|
||||
}
|
||||
|
||||
private async rebuildShapes(visControllerRef: string, kinData: Kinemage) {
|
||||
const update = this.plugin.state.data.build();
|
||||
|
||||
// Delete all descendants (shape providers and representations)
|
||||
const descendants = this.getAllDescendants(visControllerRef);
|
||||
for (const nodeRef of descendants) {
|
||||
update.delete(nodeRef);
|
||||
}
|
||||
|
||||
await update.commit();
|
||||
|
||||
// Recreate shapes
|
||||
const rebuildUpdate = this.plugin.state.data.build();
|
||||
|
||||
// Generate all shape types that have data, each as child of the visibility controller
|
||||
if (kinData.dotLists.length > 0) {
|
||||
rebuildUpdate
|
||||
.to(visControllerRef)
|
||||
.apply(KinemageShapePointsProvider, {}, { state: { isGhost: true } })
|
||||
.apply(StateTransforms.Representation.ShapeRepresentation3D);
|
||||
}
|
||||
if (kinData.vectorLists.length > 0) {
|
||||
rebuildUpdate
|
||||
.to(visControllerRef)
|
||||
.apply(KinemageShapeLinesProvider, {}, { state: { isGhost: true } })
|
||||
.apply(StateTransforms.Representation.ShapeRepresentation3D);
|
||||
}
|
||||
if (kinData.ribbonLists.length > 0) {
|
||||
rebuildUpdate
|
||||
.to(visControllerRef)
|
||||
.apply(KinemageShapeMeshProvider, {}, { state: { isGhost: true } })
|
||||
.apply(StateTransforms.Representation.ShapeRepresentation3D, { doubleSided: true });
|
||||
}
|
||||
if (kinData.ballLists.length > 0) {
|
||||
rebuildUpdate
|
||||
.to(visControllerRef)
|
||||
.apply(KinemageShapeSpheresProvider, {}, { state: { isGhost: true } })
|
||||
.apply(StateTransforms.Representation.ShapeRepresentation3D);
|
||||
}
|
||||
|
||||
await rebuildUpdate.commit();
|
||||
}
|
||||
|
||||
private async toggleVisibility(visControllerRef: string, kinData: Kinemage, target: { type: 'group' | 'subgroup' | 'master', key: string }) {
|
||||
try {
|
||||
const cell = this.plugin.state.data.cells.get(visControllerRef);
|
||||
if (!cell || !cell.transform || !cell.transform.params) return;
|
||||
|
||||
const currentParams = cell.transform.params;
|
||||
const newGroupVisibility = { ...currentParams.groupVisibility };
|
||||
const newSubgroupVisibility = { ...currentParams.subgroupVisibility };
|
||||
const newMasterVisibility = { ...currentParams.masterVisibility };
|
||||
|
||||
if (target.type === 'group') {
|
||||
newGroupVisibility[target.key] = !newGroupVisibility[target.key];
|
||||
} else if (target.type === 'subgroup') {
|
||||
newSubgroupVisibility[target.key] = !newSubgroupVisibility[target.key];
|
||||
} else {
|
||||
newMasterVisibility[target.key] = !newMasterVisibility[target.key];
|
||||
}
|
||||
|
||||
const update = this.plugin.state.data.build();
|
||||
|
||||
// Update the visibility controller
|
||||
update.to(visControllerRef).update({
|
||||
groupVisibility: newGroupVisibility,
|
||||
subgroupVisibility: newSubgroupVisibility,
|
||||
masterVisibility: newMasterVisibility
|
||||
});
|
||||
|
||||
await update.commit();
|
||||
|
||||
// Rebuild all shapes to reflect new visibility
|
||||
await this.rebuildShapes(visControllerRef, kinData);
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle kinemage visibility', e);
|
||||
}
|
||||
}
|
||||
|
||||
private async triggerAnimateForKin(visControllerRef: string, kinData: Kinemage, mode: 'animate' | '2animate') {
|
||||
try {
|
||||
const cell = this.plugin.state.data.cells.get(visControllerRef);
|
||||
if (!cell || !cell.transform || !cell.transform.params) return;
|
||||
|
||||
const currentParams = cell.transform.params;
|
||||
const animateGroups = mode === 'animate' ? kinData.groupsAnimate : kinData.groupsAnimate2;
|
||||
const currentActive = mode === 'animate' ? currentParams.activeAnimateGroup : currentParams.activeAnimateGroup2;
|
||||
const nextActive = (currentActive + 1) % Math.max(1, animateGroups.length);
|
||||
|
||||
// IMPORTANT: Read the CURRENT visibility state from the controller node's data (not params)
|
||||
// to preserve any changes made through UI interactions
|
||||
const controllerCell = this.plugin.state.data.cells.get(visControllerRef);
|
||||
const currentVisibilityState = controllerCell?.obj?.data ? (controllerCell.obj.data as any).visibilityState : null;
|
||||
|
||||
// Start with current actual visibility state
|
||||
const newGroupVisibility = currentVisibilityState
|
||||
? Object.fromEntries(currentVisibilityState.groupVisibility)
|
||||
: { ...currentParams.groupVisibility };
|
||||
|
||||
// Only update the animate groups - leave everything else as-is
|
||||
for (let i = 0; i < animateGroups.length; i++) {
|
||||
newGroupVisibility[animateGroups[i]] = (i === nextActive);
|
||||
}
|
||||
|
||||
const update = this.plugin.state.data.build();
|
||||
|
||||
// Update the visibility controller with current visibility PLUS animate changes
|
||||
const updateParams: any = {
|
||||
groupVisibility: newGroupVisibility,
|
||||
};
|
||||
|
||||
if (mode === 'animate') {
|
||||
updateParams.activeAnimateGroup = nextActive;
|
||||
} else {
|
||||
updateParams.activeAnimateGroup2 = nextActive;
|
||||
}
|
||||
|
||||
// Also preserve other visibility states
|
||||
if (currentVisibilityState) {
|
||||
updateParams.subgroupVisibility = Object.fromEntries(currentVisibilityState.subgroupVisibility);
|
||||
updateParams.masterVisibility = Object.fromEntries(currentVisibilityState.masterVisibility);
|
||||
} else {
|
||||
updateParams.subgroupVisibility = currentParams.subgroupVisibility;
|
||||
updateParams.masterVisibility = currentParams.masterVisibility;
|
||||
}
|
||||
|
||||
update.to(visControllerRef).update(updateParams);
|
||||
|
||||
await update.commit();
|
||||
|
||||
// Rebuild all shapes to reflect new visibility
|
||||
await this.rebuildShapes(visControllerRef, kinData);
|
||||
} catch (e) {
|
||||
console.error('Failed to trigger animate', e);
|
||||
}
|
||||
}
|
||||
|
||||
private isVisible(visControllerRef: string, target: { type: 'group' | 'subgroup' | 'master', key: string }): boolean {
|
||||
try {
|
||||
const cell = this.plugin.state.data.cells.get(visControllerRef);
|
||||
if (!cell || !cell.transform || !cell.transform.params) return true;
|
||||
|
||||
const params = cell.transform.params;
|
||||
if (target.type === 'group') {
|
||||
return params.groupVisibility[target.key] !== false;
|
||||
} else if (target.type === 'subgroup') {
|
||||
return params.subgroupVisibility[target.key] !== false;
|
||||
} else {
|
||||
return params.masterVisibility[target.key] !== false;
|
||||
}
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
renderControls() {
|
||||
const kins = this.getKinemageList();
|
||||
if (kins.length === 0) return <div style={{ padding: '6px' }}>No Kinemage data</div>;
|
||||
|
||||
const blocks: React.ReactNode[] = [];
|
||||
for (const { kinData, visControllerRef } of kins) {
|
||||
const title = kinData.pdbfile || nameFromString(kinData.caption) || 'Kinemage';
|
||||
const kinBlock: React.ReactNode[] = [];
|
||||
|
||||
// Title
|
||||
kinBlock.push(
|
||||
<div key={'title-' + title} style={{ padding: '6px', fontWeight: 'bold', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
|
||||
// views
|
||||
const viewEntries = Object.entries(kinData.viewDict || {});
|
||||
if (viewEntries.length > 0) {
|
||||
for (const [viewKey, viewObj] of viewEntries) {
|
||||
const label = `View ${viewObj.name || `View ${viewKey}`}`;
|
||||
kinBlock.push(
|
||||
<div key={'view-' + title + '-' + viewKey} style={{ padding: '2px 6px' }}>
|
||||
<button
|
||||
className='msp-btn msp-btn-block'
|
||||
onClick={() => this.applyView(kinData, viewKey)}
|
||||
title={`Apply view: ${label}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// animate
|
||||
if (kinData.groupsAnimate && kinData.groupsAnimate.length > 0) {
|
||||
kinBlock.push(
|
||||
<div key={'anim-' + title} style={{ padding: '2px 6px' }}>
|
||||
<button
|
||||
className='msp-btn msp-btn-block'
|
||||
onClick={() => this.triggerAnimateForKin(visControllerRef, kinData, 'animate')}
|
||||
title='Cycle through animation frames'
|
||||
>
|
||||
Animate
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kinData.groupsAnimate2 && kinData.groupsAnimate2.length > 0) {
|
||||
kinBlock.push(
|
||||
<div key={'anim2-' + title} style={{ padding: '2px 6px' }}>
|
||||
<button
|
||||
className='msp-btn msp-btn-block'
|
||||
onClick={() => this.triggerAnimateForKin(visControllerRef, kinData, '2animate')}
|
||||
title='Cycle through second animation frames'
|
||||
>
|
||||
Animate2
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// groups
|
||||
for (const [groupKey, groupInfo] of Object.entries(kinData.groupDict || {})) {
|
||||
if (!(groupInfo as any).nobutton) {
|
||||
const visible = this.isVisible(visControllerRef, { type: 'group', key: groupKey });
|
||||
// If this group is in animate or animate2, then add '*' before its groupKey name to indicate that it's an animation group
|
||||
const isAnimate = (kinData.groupsAnimate?.includes(groupKey) ?? false) || (kinData.groupsAnimate2?.includes(groupKey) ?? false);
|
||||
const label = isAnimate ? `* ${groupKey}` : groupKey;
|
||||
kinBlock.push(
|
||||
<div key={'group-' + title + '-' + groupKey} style={{ padding: '2px 6px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={visible}
|
||||
onChange={() => this.toggleVisibility(visControllerRef, kinData, { type: 'group', key: groupKey })}
|
||||
style={{ marginRight: '6px' }}
|
||||
/>
|
||||
<span title={label}>{label}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// If this group is not dominant, find any subgroups of this group and show them here (indented) unless they have nobutton set
|
||||
if (!(groupInfo as any).dominant) {
|
||||
for (const [subgroupKey, subgroupInfo] of Object.entries(kinData.subgroupDict || {})) {
|
||||
if (subgroupKey.startsWith(groupKey + ':')) {
|
||||
if ((subgroupInfo as any).nobutton) continue;
|
||||
const visible = this.isVisible(visControllerRef, { type: 'subgroup', key: subgroupKey });
|
||||
const subgroupLabel = subgroupKey.split(':')[1];
|
||||
kinBlock.push(
|
||||
<div key={'subgroup-' + title + '-' + subgroupKey} style={{ padding: '2px 6px', paddingLeft: '24px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={visible}
|
||||
onChange={() => this.toggleVisibility(visControllerRef, kinData, { type: 'subgroup', key: subgroupKey })}
|
||||
style={{ marginRight: '6px' }}
|
||||
/>
|
||||
<span title={subgroupLabel}>{subgroupLabel}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// subgroups that don't belong to a group (standalone)
|
||||
for (const [subgroupKey, subgroupInfo] of Object.entries(kinData.subgroupDict || {})) {
|
||||
// if parent group present, those groups' subgroups are already shown when iterating groups
|
||||
if (subgroupKey.indexOf(':') !== -1) {
|
||||
// subgroups with parent group; skip here (shown under parent group)
|
||||
continue;
|
||||
}
|
||||
if ((subgroupInfo as any).nobutton) continue;
|
||||
const visible = this.isVisible(visControllerRef, { type: 'subgroup', key: subgroupKey });
|
||||
kinBlock.push(
|
||||
<div key={'subgroup-' + title + '-' + subgroupKey} style={{ padding: '2px 6px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={visible}
|
||||
onChange={() => this.toggleVisibility(visControllerRef, kinData, { type: 'subgroup', key: subgroupKey })}
|
||||
style={{ marginRight: '6px' }}
|
||||
/>
|
||||
<span title={subgroupKey}>{subgroupKey}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// masters
|
||||
for (const [masterKey] of Object.entries(kinData.masterDict || {})) {
|
||||
const visible = this.isVisible(visControllerRef, { type: 'master', key: masterKey });
|
||||
kinBlock.push(
|
||||
<div key={'master-' + title + '-' + masterKey} style={{ padding: '2px 6px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={visible}
|
||||
onChange={() => this.toggleVisibility(visControllerRef, kinData, { type: 'master', key: masterKey })}
|
||||
style={{ marginRight: '6px' }}
|
||||
/>
|
||||
<span title={masterKey}>{masterKey}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
blocks.push(<div key={'kin-block-' + title} className='msp-control-group-wrapper'>{kinBlock}</div>);
|
||||
}
|
||||
|
||||
return <>{blocks}</>;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
21
src/extensions/plugin/hooks/use-ui-view-model.ts
Normal file
21
src/extensions/plugin/hooks/use-ui-view-model.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { DefaultPluginUISpec, PluginUISpec } from '../../../mol-plugin-ui/spec';
|
||||
import { PluginUIViewModel } from '../ui-view-model';
|
||||
|
||||
export function useCreatePluginUIViewModel(options?: { spec?: PluginUISpec | ((defaultSpec?: PluginUISpec) => PluginUISpec) }): PluginUIViewModel {
|
||||
const model = useRef<PluginUIViewModel>();
|
||||
if (!model.current) {
|
||||
model.current = new PluginUIViewModel({
|
||||
spec: options?.spec
|
||||
? (typeof options.spec === 'function' ? options.spec(DefaultPluginUISpec()) : options.spec)
|
||||
: options?.spec as PluginUISpec
|
||||
});
|
||||
}
|
||||
return model.current;
|
||||
}
|
||||
29
src/extensions/plugin/hooks/use-view-model.ts
Normal file
29
src/extensions/plugin/hooks/use-view-model.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { MutableRefObject, useEffect, useRef } from 'react';
|
||||
import { DefaultPluginSpec, PluginSpec } from '../../../mol-plugin/spec';
|
||||
import { PluginViewModel } from '../view-model';
|
||||
|
||||
export function useCreatePluginViewModel(options?: { spec?: PluginSpec | ((defaultSpec?: PluginSpec) => PluginSpec) }): PluginViewModel {
|
||||
const model = useRef<PluginViewModel>();
|
||||
if (!model.current) {
|
||||
model.current = new PluginViewModel({
|
||||
spec: options?.spec
|
||||
? (typeof options.spec === 'function' ? options.spec(DefaultPluginSpec()) : options.spec)
|
||||
: options?.spec as PluginSpec
|
||||
});
|
||||
}
|
||||
return model.current;
|
||||
}
|
||||
|
||||
export function usePluginViewModel(view: PluginViewModel, parent: MutableRefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
if (!parent.current) return;
|
||||
view.mount(parent.current);
|
||||
return () => view.unmount();
|
||||
}, [view]);
|
||||
}
|
||||
71
src/extensions/plugin/interactivity.ts
Normal file
71
src/extensions/plugin/interactivity.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { Structure, StructureElement } from '../../mol-model/structure';
|
||||
import { PluginStateObject } from '../../mol-plugin-state/objects';
|
||||
import { MolScriptBuilder } from '../../mol-script/language/builder';
|
||||
import { Expression } from '../../mol-script/language/expression';
|
||||
import { CameraFocusLociOptions } from '../../mol-plugin-state/manager/camera';
|
||||
import { PluginContext } from '../../mol-plugin/context';
|
||||
|
||||
export interface StructureInteractivityOptions {
|
||||
expression?: (queryBuilder: typeof MolScriptBuilder) => Expression,
|
||||
elements?: StructureElement.Schema,
|
||||
action: 'highlight' | 'select' | 'focus' | ('highlight' | 'select' | 'focus')[],
|
||||
applyGranularity?: boolean,
|
||||
filterStructure?: (structure: Structure) => boolean,
|
||||
focusOptions?: Partial<CameraFocusLociOptions>
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers structure element selection or highlighting based on the provided
|
||||
* MolScript expression or StructureElement schema. Focus action will only apply to the
|
||||
* first structure that matches the criteria.
|
||||
*
|
||||
* If neither `expression` nor `elements` are provided, all selections/highlights
|
||||
* will be cleared based on the specified `action`.
|
||||
*/
|
||||
export function applyStructureInteractivity(plugin: PluginContext, { expression, elements, action: action_, applyGranularity = false, filterStructure, focusOptions }: StructureInteractivityOptions) {
|
||||
const actions = Array.isArray(action_) ? action_ : [action_];
|
||||
|
||||
if (!expression && !elements) {
|
||||
if (actions.includes('select')) {
|
||||
plugin.managers.interactivity.lociSelects.deselectAll();
|
||||
}
|
||||
if (actions.includes('highlight')) {
|
||||
plugin.managers.interactivity.lociHighlights.clearHighlights();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (actions.includes('select')) {
|
||||
plugin.managers.interactivity.lociSelects.deselectAll();
|
||||
}
|
||||
|
||||
const structures = plugin.state.data.selectQ(Q => Q.rootsOfType(PluginStateObject.Molecule.Structure));
|
||||
let focused = false;
|
||||
for (const s of structures) {
|
||||
if (!s.obj?.data) continue;
|
||||
|
||||
if (filterStructure && !filterStructure(s.obj.data)) continue;
|
||||
|
||||
const loci = expression
|
||||
? StructureElement.Loci.fromExpression(s.obj.data, expression)
|
||||
: StructureElement.Loci.fromSchema(s.obj.data, elements!);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
373
src/extensions/plugin/loaders.ts
Normal file
373
src/extensions/plugin/loaders.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { StringLike } from '../../mol-io/common/string-like';
|
||||
import { Volume } from '../../mol-model/volume';
|
||||
import { OpenFiles } from '../../mol-plugin-state/actions/file';
|
||||
import { DownloadStructure, PdbDownloadProvider } from '../../mol-plugin-state/actions/structure';
|
||||
import { DownloadDensity } from '../../mol-plugin-state/actions/volume';
|
||||
import { PresetTrajectoryHierarchy } from '../../mol-plugin-state/builder/structure/hierarchy-preset';
|
||||
import { StructureRepresentationPresetProvider } from '../../mol-plugin-state/builder/structure/representation-preset';
|
||||
import { BuiltInCoordinatesFormat } from '../../mol-plugin-state/formats/coordinates';
|
||||
import { BuiltInTopologyFormat } from '../../mol-plugin-state/formats/topology';
|
||||
import { BuiltInTrajectoryFormat } from '../../mol-plugin-state/formats/trajectory';
|
||||
import { BuildInVolumeFormat } from '../../mol-plugin-state/formats/volume';
|
||||
import { createVolumeRepresentationParams } from '../../mol-plugin-state/helpers/volume-representation-params';
|
||||
import { PluginStateObject } from '../../mol-plugin-state/objects';
|
||||
import { StateTransforms } from '../../mol-plugin-state/transforms';
|
||||
import { TrajectoryFromModelAndCoordinates } from '../../mol-plugin-state/transforms/model';
|
||||
import { PluginCommands } from '../../mol-plugin/commands';
|
||||
import { PluginConfig } from '../../mol-plugin/config';
|
||||
import { PluginContext } from '../../mol-plugin/context';
|
||||
import { PluginState } from '../../mol-plugin/state';
|
||||
import { StateObjectSelector } from '../../mol-state';
|
||||
import { Task } from '../../mol-task';
|
||||
import { Asset } from '../../mol-util/assets';
|
||||
import { Color } from '../../mol-util/color';
|
||||
import { loadMVSData, loadMVSX } from '../mvs/components/formats';
|
||||
import { loadMVS, MolstarLoadingExtension } from '../mvs/load';
|
||||
import { MVSData } from '../mvs/mvs-data';
|
||||
|
||||
export function setRemoteSnapshot(plugin: PluginContext, id: string) {
|
||||
const url = `${plugin.config.get(PluginConfig.State.CurrentServer)}/get/${id}`;
|
||||
return PluginCommands.State.Snapshots.Fetch(plugin, { url });
|
||||
}
|
||||
|
||||
export function loadSnapshotFromUrl(plugin: PluginContext, url: string, type: PluginState.SnapshotType) {
|
||||
return PluginCommands.State.Snapshots.OpenUrl(plugin, { url, type });
|
||||
}
|
||||
|
||||
export function loadStructureFromUrl(plugin: PluginContext, url: string, format: BuiltInTrajectoryFormat = 'mmcif', isBinary = false, options?: LoadStructureOptions & { label?: string }) {
|
||||
const params = DownloadStructure.createDefaultParams(plugin.state.data.root.obj!, plugin);
|
||||
return plugin.runTask(plugin.state.data.applyAction(DownloadStructure, {
|
||||
source: {
|
||||
name: 'url',
|
||||
params: {
|
||||
url: Asset.Url(url),
|
||||
format: format as any,
|
||||
isBinary,
|
||||
label: options?.label,
|
||||
options: { ...params.source.params.options, representationParams: options?.representationParams as any },
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export async function loadAllModelsOrAssemblyFromUrl(plugin: PluginContext, url: string, format: BuiltInTrajectoryFormat = 'mmcif', isBinary = false, options?: LoadStructureOptions) {
|
||||
const data = await plugin.builders.data.download({ url, isBinary }, { state: { isGhost: true } });
|
||||
const trajectory = await plugin.builders.structure.parseTrajectory(data, format);
|
||||
|
||||
await plugin.builders.structure.hierarchy.applyPreset(trajectory, 'all-models', { useDefaultIfSingleModel: true, representationPresetParams: options?.representationParams });
|
||||
}
|
||||
|
||||
export async function loadStructureFromData(plugin: PluginContext, data: string | number[], format: BuiltInTrajectoryFormat, options?: { dataLabel?: string }) {
|
||||
const _data = await plugin.builders.data.rawData({ data, label: options?.dataLabel });
|
||||
const trajectory = await plugin.builders.structure.parseTrajectory(_data, format);
|
||||
await plugin.builders.structure.hierarchy.applyPreset(trajectory, 'default');
|
||||
}
|
||||
|
||||
export function loadPdb(plugin: PluginContext, pdb: string, options?: LoadStructureOptions) {
|
||||
const params = DownloadStructure.createDefaultParams(plugin.state.data.root.obj!, plugin);
|
||||
const provider = plugin.config.get(PluginConfig.Download.DefaultPdbProvider)!;
|
||||
return plugin.runTask(plugin.state.data.applyAction(DownloadStructure, {
|
||||
source: {
|
||||
name: 'pdb' as const,
|
||||
params: {
|
||||
provider: {
|
||||
id: pdb,
|
||||
server: {
|
||||
name: provider,
|
||||
params: PdbDownloadProvider[provider].defaultValue as any
|
||||
}
|
||||
},
|
||||
options: { ...params.source.params.options, representationParams: options?.representationParams as any },
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export function loadPdbIhm(plugin: PluginContext, pdbIhm: string) {
|
||||
const params = DownloadStructure.createDefaultParams(plugin.state.data.root.obj!, plugin);
|
||||
return plugin.runTask(plugin.state.data.applyAction(DownloadStructure, {
|
||||
source: {
|
||||
name: 'pdb-ihm' as const,
|
||||
params: {
|
||||
provider: {
|
||||
id: pdbIhm,
|
||||
encoding: 'bcif',
|
||||
},
|
||||
options: params.source.params.options,
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export function loadEmdb(plugin: PluginContext, emdb: string, options?: { detail?: number }) {
|
||||
const provider = plugin.config.get(PluginConfig.Download.DefaultEmdbProvider)!;
|
||||
return plugin.runTask(plugin.state.data.applyAction(DownloadDensity, {
|
||||
source: {
|
||||
name: 'pdb-emd-ds' as const,
|
||||
params: {
|
||||
provider: {
|
||||
id: emdb,
|
||||
server: provider,
|
||||
},
|
||||
detail: options?.detail ?? 3,
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export function loadAlphaFoldDb(plugin: PluginContext, afdb: string) {
|
||||
const params = DownloadStructure.createDefaultParams(plugin.state.data.root.obj!, plugin);
|
||||
return plugin.runTask(plugin.state.data.applyAction(DownloadStructure, {
|
||||
source: {
|
||||
name: 'alphafolddb' as const,
|
||||
params: {
|
||||
provider: {
|
||||
id: afdb,
|
||||
encoding: 'bcif'
|
||||
},
|
||||
options: {
|
||||
...params.source.params.options,
|
||||
representation: 'preset-structure-representation-ma-quality-assessment-plddt'
|
||||
},
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export function loadModelArchive(plugin: PluginContext, id: string) {
|
||||
const params = DownloadStructure.createDefaultParams(plugin.state.data.root.obj!, plugin);
|
||||
return plugin.runTask(plugin.state.data.applyAction(DownloadStructure, {
|
||||
source: {
|
||||
name: 'modelarchive' as const,
|
||||
params: {
|
||||
id,
|
||||
options: params.source.params.options,
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @example Load X-ray density from volume server
|
||||
viewer.loadVolumeFromUrl({
|
||||
url: 'https://www.ebi.ac.uk/pdbe/densities/x-ray/1tqn/cell?detail=3',
|
||||
format: 'dscif',
|
||||
isBinary: true
|
||||
}, [{
|
||||
type: 'relative',
|
||||
value: 1.5,
|
||||
color: 0x3362B2
|
||||
}, {
|
||||
type: 'relative',
|
||||
value: 3,
|
||||
color: 0x33BB33,
|
||||
volumeIndex: 1
|
||||
}, {
|
||||
type: 'relative',
|
||||
value: -3,
|
||||
color: 0xBB3333,
|
||||
volumeIndex: 1
|
||||
}], {
|
||||
entryId: ['2FO-FC', 'FO-FC'],
|
||||
isLazy: true
|
||||
});
|
||||
* *********************
|
||||
* @example Load EM density from volume server
|
||||
viewer.loadVolumeFromUrl({
|
||||
url: 'https://maps.rcsb.org/em/emd-30210/cell?detail=6',
|
||||
format: 'dscif',
|
||||
isBinary: true
|
||||
}, [{
|
||||
type: 'relative',
|
||||
value: 1,
|
||||
color: 0x3377aa
|
||||
}], {
|
||||
entryId: 'EMD-30210',
|
||||
isLazy: true
|
||||
});
|
||||
*/
|
||||
export async function loadVolumeFromUrl(plugin: PluginContext, { url, format, isBinary }: { url: string, format: BuildInVolumeFormat, isBinary: boolean }, isovalues: VolumeIsovalueInfo[], options?: { entryId?: string | string[], isLazy?: boolean }) {
|
||||
if (!plugin.dataFormats.get(format)) {
|
||||
throw new Error(`Unknown density format: ${format}`);
|
||||
}
|
||||
|
||||
if (options?.isLazy) {
|
||||
const update = plugin.build();
|
||||
update.toRoot().apply(StateTransforms.Data.LazyVolume, {
|
||||
url,
|
||||
format,
|
||||
entryId: options?.entryId,
|
||||
isBinary,
|
||||
isovalues: isovalues.map(v => ({ alpha: 1, volumeIndex: 0, ...v }))
|
||||
});
|
||||
return update.commit();
|
||||
}
|
||||
|
||||
return plugin.dataTransaction(async () => {
|
||||
const data = await plugin.builders.data.download({ url, isBinary }, { state: { isGhost: true } });
|
||||
|
||||
const parsed = await plugin.dataFormats.get(format)!.parse(plugin, data, { entryId: options?.entryId });
|
||||
const firstVolume = (parsed.volume || parsed.volumes[0]) as StateObjectSelector<PluginStateObject.Volume.Data>;
|
||||
if (!firstVolume?.isOk) throw new Error('Failed to parse any volume.');
|
||||
|
||||
const repr = plugin.build();
|
||||
for (const iso of isovalues) {
|
||||
const volume: StateObjectSelector<PluginStateObject.Volume.Data> = parsed.volumes?.[iso.volumeIndex ?? 0] ?? parsed.volume;
|
||||
const volumeData = volume.cell!.obj!.data;
|
||||
repr
|
||||
.to(volume)
|
||||
.apply(StateTransforms.Representation.VolumeRepresentation3D, createVolumeRepresentationParams(plugin, firstVolume.data!, {
|
||||
type: 'isosurface',
|
||||
typeParams: { alpha: iso.alpha ?? 1, isoValue: Volume.adjustedIsoValue(volumeData, iso.value, iso.type) },
|
||||
color: 'uniform',
|
||||
colorParams: { value: iso.color }
|
||||
}));
|
||||
}
|
||||
|
||||
await repr.commit();
|
||||
});
|
||||
}
|
||||
|
||||
export function loadFullResolutionEMDBMap(plugin: PluginContext, emdbId: string, options: { isoValue: Volume.IsoValue, color?: Color }) {
|
||||
const numericId = parseInt(emdbId.toUpperCase().replace('EMD-', ''));
|
||||
const url = `https://ftp.ebi.ac.uk/pub/databases/emdb/structures/EMD-${numericId}/map/emd_${numericId}.map.gz`;
|
||||
|
||||
return plugin.dataTransaction(async () => {
|
||||
const data = await plugin.build().toRoot()
|
||||
.apply(StateTransforms.Data.Download, { url, isBinary: true, label: emdbId }, { state: { isGhost: true } })
|
||||
.apply(StateTransforms.Data.DeflateData)
|
||||
.commit();
|
||||
|
||||
const parsed = await plugin.dataFormats.get('ccp4')!.parse(plugin, data, { entryId: emdbId });
|
||||
const firstVolume = (parsed.volume || parsed.volumes[0]) as StateObjectSelector<PluginStateObject.Volume.Data>;
|
||||
if (!firstVolume?.isOk) throw new Error('Failed to parse any volume.');
|
||||
|
||||
const volume: StateObjectSelector<PluginStateObject.Volume.Data> = parsed.volumes?.[0] ?? parsed.volume;
|
||||
await plugin.build()
|
||||
.to(volume)
|
||||
.apply(StateTransforms.Representation.VolumeRepresentation3D, createVolumeRepresentationParams(plugin, firstVolume.data!, {
|
||||
type: 'isosurface',
|
||||
typeParams: { alpha: 1, isoValue: options.isoValue },
|
||||
color: 'uniform',
|
||||
colorParams: { value: options.color ?? Color(0x33BB33) }
|
||||
}))
|
||||
.commit();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* viewer.loadTrajectory({
|
||||
* model: { kind: 'model-url', url: 'villin.gro', format: 'gro' },
|
||||
* coordinates: { kind: 'coordinates-url', url: 'villin.xtc', format: 'xtc', isBinary: true },
|
||||
* preset: 'all-models' // or 'default'
|
||||
* });
|
||||
*/
|
||||
export async function loadTrajectory(plugin: PluginContext, params: LoadTrajectoryParams) {
|
||||
let model: StateObjectSelector;
|
||||
|
||||
if (params.model.kind === 'model-data' || params.model.kind === 'model-url') {
|
||||
const data = params.model.kind === 'model-data'
|
||||
? await plugin.builders.data.rawData({ data: params.model.data, label: params.modelLabel })
|
||||
: await plugin.builders.data.download({ url: params.model.url, isBinary: params.model.isBinary, label: params.modelLabel });
|
||||
|
||||
const trajectory = await plugin.builders.structure.parseTrajectory(data, params.model.format ?? 'mmcif');
|
||||
model = await plugin.builders.structure.createModel(trajectory);
|
||||
} else {
|
||||
const data = params.model.kind === 'topology-data'
|
||||
? await plugin.builders.data.rawData({ data: params.model.data, label: params.modelLabel })
|
||||
: await plugin.builders.data.download({ url: params.model.url, isBinary: params.model.isBinary, label: params.modelLabel });
|
||||
|
||||
const provider = plugin.dataFormats.get(params.model.format);
|
||||
const parsed = await provider!.parse(plugin, data);
|
||||
model = parsed.topology;
|
||||
}
|
||||
|
||||
const data = params.coordinates.kind === 'coordinates-data'
|
||||
? await plugin.builders.data.rawData({ data: params.coordinates.data, label: params.coordinatesLabel })
|
||||
: await plugin.builders.data.download({ url: params.coordinates.url, isBinary: params.coordinates.isBinary, label: params.coordinatesLabel });
|
||||
|
||||
const provider = plugin.dataFormats.get(params.coordinates.format);
|
||||
const coords = await provider!.parse(plugin, data);
|
||||
|
||||
const trajectory = await plugin.build().toRoot()
|
||||
.apply(TrajectoryFromModelAndCoordinates, {
|
||||
modelRef: model.ref,
|
||||
coordinatesRef: coords.ref
|
||||
}, { dependsOn: [model.ref, coords.ref] })
|
||||
.commit();
|
||||
|
||||
const preset = await plugin.builders.structure.hierarchy.applyPreset(trajectory, params.preset ?? 'default');
|
||||
|
||||
return { model, coords, preset };
|
||||
}
|
||||
|
||||
export async function loadMvsFromUrl(plugin: PluginContext, url: string, format: 'mvsj' | 'mvsx', options?: { appendSnapshots?: boolean, keepCamera?: boolean, keepCameraOrientation?: boolean, extensions?: MolstarLoadingExtension<any>[] }) {
|
||||
if (format === 'mvsj') {
|
||||
const data = await plugin.runTask(plugin.fetch({ url, type: 'string' }));
|
||||
const mvsData = MVSData.fromMVSJ(StringLike.toString(data));
|
||||
await loadMVS(plugin, mvsData, { sanityChecks: true, sourceUrl: url, ...options });
|
||||
} else if (format === 'mvsx') {
|
||||
const data = await plugin.runTask(plugin.fetch({ url, type: 'binary' }));
|
||||
await plugin.runTask(Task.create('Load MVSX file', async ctx => {
|
||||
const parsed = await loadMVSX(plugin, ctx, data, { doNotClearAssets: options?.appendSnapshots });
|
||||
await loadMVS(plugin, parsed.mvsData, { sanityChecks: true, sourceUrl: parsed.sourceUrl, ...options });
|
||||
}));
|
||||
} else {
|
||||
throw new Error(`Unknown MolViewSpec format: ${format}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Load MolViewSpec from `data`.
|
||||
* If `format` is 'mvsj', `data` must be a string or a Uint8Array containing a UTF8-encoded string.
|
||||
* If `format` is 'mvsx', `data` must be a Uint8Array or a string containing base64-encoded binary data prefixed with 'base64,'. */
|
||||
export function loadMvsData(plugin: PluginContext, data: string | Uint8Array<ArrayBuffer>, format: 'mvsj' | 'mvsx', options?: { appendSnapshots?: boolean, keepCamera?: boolean, keepCameraOrientation?: boolean, extensions?: MolstarLoadingExtension<any>[] }) {
|
||||
return loadMVSData(plugin, data, format, options);
|
||||
}
|
||||
|
||||
export function loadFiles(plugin: PluginContext, files: File[]) {
|
||||
const sessions = files.filter(f => {
|
||||
const fn = f.name.toLowerCase();
|
||||
return fn.endsWith('.molx') || fn.endsWith('.molj');
|
||||
});
|
||||
|
||||
if (sessions.length > 0) {
|
||||
return PluginCommands.State.Snapshots.OpenFile(plugin, { file: sessions[0] });
|
||||
} else {
|
||||
return plugin.runTask(plugin.state.data.applyAction(OpenFiles, {
|
||||
files: files.map(f => Asset.File(f)),
|
||||
format: { name: 'auto', params: {} },
|
||||
visuals: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadStructureOptions {
|
||||
representationParams?: StructureRepresentationPresetProvider.CommonParams
|
||||
}
|
||||
|
||||
export interface VolumeIsovalueInfo {
|
||||
type: 'absolute' | 'relative',
|
||||
value: number,
|
||||
color: Color,
|
||||
alpha?: number,
|
||||
volumeIndex?: number
|
||||
}
|
||||
|
||||
export interface LoadTrajectoryParams {
|
||||
model: { kind: 'model-url', url: string, format?: BuiltInTrajectoryFormat /* mmcif */, isBinary?: boolean }
|
||||
| { kind: 'model-data', data: string | number[] | ArrayBuffer | Uint8Array<ArrayBuffer>, format?: BuiltInTrajectoryFormat /* mmcif */ }
|
||||
| { kind: 'topology-url', url: string, format: BuiltInTopologyFormat, isBinary?: boolean }
|
||||
| { kind: 'topology-data', data: string | number[] | ArrayBuffer | Uint8Array<ArrayBuffer>, format: BuiltInTopologyFormat },
|
||||
modelLabel?: string,
|
||||
coordinates: { kind: 'coordinates-url', url: string, format: BuiltInCoordinatesFormat, isBinary?: boolean }
|
||||
| { kind: 'coordinates-data', data: string | number[] | ArrayBuffer | Uint8Array<ArrayBuffer>, format: BuiltInCoordinatesFormat },
|
||||
coordinatesLabel?: string,
|
||||
preset?: keyof PresetTrajectoryHierarchy
|
||||
}
|
||||
15
src/extensions/plugin/react.tsx
Normal file
15
src/extensions/plugin/react.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { type CSSProperties, useRef } from 'react';
|
||||
import type { PluginViewModel } from './view-model';
|
||||
import { usePluginViewModel } from './hooks/use-view-model';
|
||||
|
||||
export function PluginCanvas({ model, style, className }: { model: PluginViewModel, style?: CSSProperties, className?: string }) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
usePluginViewModel(model, root);
|
||||
return <div ref={root} style={style} className={className} />;
|
||||
}
|
||||
26
src/extensions/plugin/ui-view-model.ts
Normal file
26
src/extensions/plugin/ui-view-model.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { DefaultPluginUISpec, PluginUISpec } from '../../mol-plugin-ui/spec';
|
||||
import { PluginUIContext } from '../../mol-plugin-ui/context';
|
||||
|
||||
export class PluginUIViewModel {
|
||||
readonly plugin: PluginUIContext;
|
||||
|
||||
get initialized() {
|
||||
return this.plugin.initialized;
|
||||
}
|
||||
|
||||
private async init() {
|
||||
await this.plugin.init();
|
||||
}
|
||||
|
||||
constructor(options?: { spec?: PluginUISpec }) {
|
||||
const spec = options?.spec ?? DefaultPluginUISpec();
|
||||
this.plugin = new PluginUIContext(spec);
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
36
src/extensions/plugin/view-model.ts
Normal file
36
src/extensions/plugin/view-model.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { DefaultPluginSpec, PluginSpec } from '../../mol-plugin/spec';
|
||||
import { PluginContext } from '../../mol-plugin/context';
|
||||
import { SingleAsyncQueue } from '../../mol-util/single-async-queue';
|
||||
|
||||
export class PluginViewModel {
|
||||
private mountQueue = new SingleAsyncQueue();
|
||||
readonly plugin: PluginContext;
|
||||
|
||||
get initialized() {
|
||||
return this.plugin.initialized;
|
||||
}
|
||||
|
||||
private async init() {
|
||||
await this.plugin.init();
|
||||
}
|
||||
|
||||
mount(root: HTMLElement) {
|
||||
this.mountQueue.enqueue(() => this.plugin.mountAsync(root));
|
||||
}
|
||||
|
||||
unmount() {
|
||||
this.mountQueue.enqueue(() => this.plugin.unmount());
|
||||
}
|
||||
|
||||
constructor(options?: { spec?: PluginSpec }) {
|
||||
const spec = options?.spec ?? DefaultPluginSpec();
|
||||
this.plugin = new PluginContext(spec);
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -59,6 +59,10 @@ namespace Euler {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function is(a: any): a is Euler {
|
||||
return Array.isArray(a) && a.length === 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assumes the upper 3x3 of m is a pure rotation matrix (i.e, unscaled)
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
sortArray(indices);
|
||||
elements.push({ unit, indices: makeIndexSet(indices) });
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'element-loci',
|
||||
structure: loci.structure,
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
const boundaryHelper = new BoundaryHelper('98');
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* 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>
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
*/
|
||||
|
||||
import { PluginContext } from '../../mol-plugin/context';
|
||||
@@ -76,7 +77,10 @@ const DownloadStructure = StateAction.build({
|
||||
}, { isFlat: true, label: 'SWISS-MODEL', description: 'Loads the best homology model or experimental structure' }),
|
||||
'alphafolddb': PD.Group({
|
||||
provider: PD.Group({
|
||||
id: PD.Text('Q8W3K0', { label: 'UniProtKB AC(s)', description: 'One or more comma/space separated ACs.' }),
|
||||
id: PD.Text('Q8W3K0', {
|
||||
label: 'ID(s)',
|
||||
description: 'One or more comma/space separated IDs. Each ID can be either UniProt accession (e.g. Q14676, Q14676-2) or AlphaFoldDB model entity ID (e.g. AF-Q14676-F1, AF-Q14676-2-F1, AF-0000000066074510). Version suffixes (e.g. -v1) will be ignored and the newest model version will be downloaded.',
|
||||
}),
|
||||
encoding: PD.Select('bcif', PD.arrayToOptions(['cif', 'bcif'] as const)),
|
||||
}, { pivot: 'id' }),
|
||||
options
|
||||
@@ -152,7 +156,11 @@ const DownloadStructure = StateAction.build({
|
||||
case 'alphafolddb':
|
||||
downloadParams = await getDownloadParams(src.params.provider.id,
|
||||
async id => {
|
||||
const url = `https://www.alphafold.ebi.ac.uk/api/prediction/${id.toUpperCase()}`;
|
||||
// id = UniProt accession: Q14676, Q14676-4
|
||||
// id = model entity ID: AF-Q14676-F1, AF-Q14676-4-F1, AF-0000000066074510
|
||||
// id = model entity ID + version to be ignored: AF-Q14676-4-F1-v6, AF-0000000066074510-v1
|
||||
const cleanId = id.replace(/-v\d+$/i, '').toUpperCase(); // Ignore version suffix (e.g. "-v6") because it is not a part of the ID, but displayed on AFDB page and people often copy-paste it
|
||||
const url = `https://www.alphafold.ebi.ac.uk/api/prediction/${cleanId}`;
|
||||
const info = await plugin.runTask(plugin.fetch({ url, type: 'json' }));
|
||||
if (Array.isArray(info) && info.length > 0) {
|
||||
const prop = src.params.provider.encoding === 'bcif' ? 'bcifUrl' : 'cifUrl';
|
||||
@@ -436,12 +444,12 @@ export const LoadTrajectory = StateAction.build({
|
||||
|
||||
//
|
||||
|
||||
const dependsOn = [model.ref, coordinates.ref];
|
||||
// dependsOn is auto-derived from the `getDependencies` hook on TrajectoryFromModelAndCoordinates
|
||||
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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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, StateTransformer } from '../../mol-state';
|
||||
import { StateObject, StateTransform, 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,6 +247,12 @@ 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;
|
||||
|
||||
@@ -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, StateTransformer } from '../../mol-state';
|
||||
import { StateSelection, StateTransform, StateTransformer } from '../../mol-state';
|
||||
import { volumeFromSegmentationData } from '../../mol-model-formats/volume/segmentation';
|
||||
import { getTransformFromParams, TransformParam, transformParamsNeedCentroid } from './helpers';
|
||||
|
||||
@@ -233,7 +233,8 @@ 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
324
src/mol-state/_spec/dependencies.spec.ts
Normal file
324
src/mol-state/_spec/dependencies.spec.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 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/);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 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 { StateObject, StateObjectCell, StateObjectSelector } from './object';
|
||||
@@ -24,7 +25,22 @@ import { arraySetAdd, arraySetRemove } from '../mol-util/array';
|
||||
import { UniqueArray } from '../mol-data/generic/unique-array';
|
||||
import { assignIfUndefined } from '../mol-util/object';
|
||||
|
||||
export { State };
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
class State {
|
||||
private _tree: TransientTree;
|
||||
@@ -494,6 +510,21 @@ 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
|
||||
}
|
||||
|
||||
@@ -573,11 +604,45 @@ 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);
|
||||
@@ -663,7 +728,13 @@ function setCellStatus(ctx: UpdateContext, ref: Ref, status: StateObjectCell.Sta
|
||||
}
|
||||
|
||||
function initCellStatusVisitor(t: StateTransform, _: any, ctx: UpdateContext) {
|
||||
ctx.cells.get(t.ref)!.transform = t;
|
||||
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);
|
||||
}
|
||||
setCellStatus(ctx, t.ref, 'pending');
|
||||
}
|
||||
|
||||
@@ -707,9 +778,10 @@ 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) {
|
||||
if (!target.transform.dependsOn) return;
|
||||
const effective = StateTransform.getEffectiveDependsOn(target.transform);
|
||||
if (effective.length === 0) return;
|
||||
|
||||
for (const ref of target.transform.dependsOn) {
|
||||
for (const ref of effective) {
|
||||
const t = ctx.tree.transforms.get(ref);
|
||||
if (!t) {
|
||||
throw new Error(`Cannot depend on a non-existent transform.`);
|
||||
@@ -721,6 +793,103 @@ 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: [] };
|
||||
|
||||
@@ -734,6 +903,12 @@ 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
|
||||
@@ -834,7 +1009,54 @@ 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;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* 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 { 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 };
|
||||
|
||||
@@ -170,6 +173,97 @@ 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) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 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 { Task } from '../mol-task';
|
||||
@@ -118,6 +119,16 @@ 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> {
|
||||
|
||||
74
src/mol-util/color/spaces/hsv.ts
Normal file
74
src/mol-util/color/spaces/hsv.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author ReliaSolve <russ@reliasolve.com>
|
||||
*
|
||||
* Adapted from kin-parser.ts file from the NGL project:
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
* Adapted from hsl.ts in this same directory:
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import type { Color } from '../color';
|
||||
import { Rgb } from './rgb';
|
||||
|
||||
export { Hsv };
|
||||
|
||||
/** Hsv tuple: [h, s, v]
|
||||
* - h in [0,360] degrees
|
||||
* - s in [0,100] percent
|
||||
* - v in [0,100] percent
|
||||
*/
|
||||
interface Hsv extends Array<number> { [d: number]: number, '@type': 'hsv', length: 3 }
|
||||
|
||||
function Hsv() {
|
||||
return Hsv.zero();
|
||||
}
|
||||
|
||||
namespace Hsv {
|
||||
export function zero(): Hsv {
|
||||
const out = [0.0, 0.0, 0.0];
|
||||
out[0] = 0;
|
||||
return out as Hsv;
|
||||
}
|
||||
|
||||
/** Copy values from an array-like 3-tuple into `out`. */
|
||||
export function fromArray(arr: ArrayLike<number>): Hsv {
|
||||
const out = Hsv.zero();
|
||||
out[0] = arr[0] ?? 0;
|
||||
out[1] = arr[1] ?? 0;
|
||||
out[2] = arr[2] ?? 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
const _rgb = Rgb();
|
||||
export function toColor(hsv: Hsv): Color {
|
||||
toRgb(_rgb, hsv);
|
||||
return Rgb.toColor(_rgb);
|
||||
}
|
||||
|
||||
export function toRgb(out: Rgb, hsv: Hsv) {
|
||||
let [h, s, v] = hsv;
|
||||
h /= 360;
|
||||
s /= 100;
|
||||
v /= 100;
|
||||
let r = 0, g = 0, b = 0;
|
||||
const i = Math.floor(h * 6);
|
||||
const f = h * 6 - i;
|
||||
const p = v * (1 - s);
|
||||
const q = v * (1 - f * s);
|
||||
const t = v * (1 - (1 - f) * s);
|
||||
switch (i % 6) {
|
||||
case 0: r = v; g = t; b = p; break;
|
||||
case 1: r = q; g = v; b = p; break;
|
||||
case 2: r = p; g = v; b = t; break;
|
||||
case 3: r = p; g = q; b = v; break;
|
||||
case 4: r = t; g = p; b = v; break;
|
||||
case 5: r = v; g = p; b = q; break;
|
||||
}
|
||||
out[0] = r;
|
||||
out[1] = g;
|
||||
out[2] = b;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -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 Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
@@ -18,6 +18,7 @@ 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 {
|
||||
@@ -445,6 +446,47 @@ 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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"module": "commonjs",
|
||||
"outDir": "lib/commonjs"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user