mirror of
https://github.com/molstar/molstar.git
synced 2026-06-04 21:34:23 +08:00
Compare commits
117 Commits
v4.16.0
...
v5.0.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f879519700 | ||
|
|
c6e175e5da | ||
|
|
add75bf9c9 | ||
|
|
57cbcd5fbf | ||
|
|
0a33936e06 | ||
|
|
7291025e09 | ||
|
|
86da258280 | ||
|
|
477a80d1ca | ||
|
|
86b68018a9 | ||
|
|
da095d6ef9 | ||
|
|
dc304b9e08 | ||
|
|
c905fa17c4 | ||
|
|
a06c64e8e0 | ||
|
|
f5441290dd | ||
|
|
9f23124317 | ||
|
|
8299cd638c | ||
|
|
50cb08e74d | ||
|
|
89552652ba | ||
|
|
37ce577813 | ||
|
|
4d9a003141 | ||
|
|
6f0311a53f | ||
|
|
bfd2d6b055 | ||
|
|
3072e60709 | ||
|
|
62ed8d10e3 | ||
|
|
13d3c34864 | ||
|
|
cac433efca | ||
|
|
b25ffe7151 | ||
|
|
31074dc74c | ||
|
|
c98c01a076 | ||
|
|
8966fc9396 | ||
|
|
fdbdc551e8 | ||
|
|
bb232ac3a4 | ||
|
|
735c25ef8d | ||
|
|
298043313a | ||
|
|
77cd181b91 | ||
|
|
b5bee042e8 | ||
|
|
4faf17ddc7 | ||
|
|
28774b2277 | ||
|
|
6a7444f44e | ||
|
|
15bfa8416a | ||
|
|
e6895ec833 | ||
|
|
2099ad728a | ||
|
|
72ae3fae65 | ||
|
|
bb5ad78681 | ||
|
|
f10e88612f | ||
|
|
a2e582d4a9 | ||
|
|
572874f4ae | ||
|
|
b9c0347497 | ||
|
|
089148198f | ||
|
|
6fc04c3294 | ||
|
|
dc55577e22 | ||
|
|
f7ba7c0511 | ||
|
|
ed5374fab9 | ||
|
|
9a04b4f0df | ||
|
|
9350e539b6 | ||
|
|
c38377af46 | ||
|
|
9804febd95 | ||
|
|
7936dc1840 | ||
|
|
a033a8be36 | ||
|
|
4b84c6dcba | ||
|
|
309d792fdb | ||
|
|
c437254680 | ||
|
|
6fbf7c7a22 | ||
|
|
86a7520b90 | ||
|
|
cd10043447 | ||
|
|
146e95cb23 | ||
|
|
13b1e5d59c | ||
|
|
ae3efa53d6 | ||
|
|
2e67fbe870 | ||
|
|
56df6f82a7 | ||
|
|
fdd874b7a6 | ||
|
|
f142c3ef1b | ||
|
|
978b53e7d8 | ||
|
|
2f3197479d | ||
|
|
6536d0ab91 | ||
|
|
3bee224e7d | ||
|
|
3e63137977 | ||
|
|
38d6bc6c27 | ||
|
|
fafe22d56b | ||
|
|
a6a92bcf91 | ||
|
|
82c681f445 | ||
|
|
fbbd58b4db | ||
|
|
2dc13f082c | ||
|
|
ab5eb5993d | ||
|
|
2384003f5d | ||
|
|
3675c0afe0 | ||
|
|
d9bae488e9 | ||
|
|
e31e5321ba | ||
|
|
8c7f8b8a56 | ||
|
|
e4dfb5148c | ||
|
|
39e2591b60 | ||
|
|
f8a5237024 | ||
|
|
6c2d5b9da7 | ||
|
|
e128d85356 | ||
|
|
08a929bb2f | ||
|
|
5a54b3ef66 | ||
|
|
a0c897547a | ||
|
|
89ce8394fd | ||
|
|
ea0331e95c | ||
|
|
9f220b55c2 | ||
|
|
acf248d58f | ||
|
|
c83b859766 | ||
|
|
33a2564893 | ||
|
|
d409c4f5ea | ||
|
|
ab61e31230 | ||
|
|
ae9c2dd9d8 | ||
|
|
c17edb4928 | ||
|
|
528377eb47 | ||
|
|
c9819369d0 | ||
|
|
cdbbbfa6dd | ||
|
|
a1e31c79e9 | ||
|
|
e027fe46c1 | ||
|
|
05c4006e9d | ||
|
|
191ea65c9d | ||
|
|
3c1ee16376 | ||
|
|
9ac34ee13b | ||
|
|
6778452d07 |
@@ -1,4 +0,0 @@
|
||||
node_modules/*
|
||||
build/*
|
||||
docs/site/*
|
||||
lib/*
|
||||
122
.eslintrc.json
122
.eslintrc.json
@@ -1,122 +0,0 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018,
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"impliedStrict": true
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"indent": "off",
|
||||
"arrow-parens": [
|
||||
"off",
|
||||
"as-needed"
|
||||
],
|
||||
"brace-style": "off",
|
||||
"comma-spacing": "off",
|
||||
"space-infix-ops": "off",
|
||||
"comma-dangle": "off",
|
||||
"eqeqeq": [
|
||||
"error",
|
||||
"smart"
|
||||
],
|
||||
"import/order": "off",
|
||||
"no-eval": "warn",
|
||||
"no-new-wrappers": "warn",
|
||||
"no-trailing-spaces": "error",
|
||||
"no-unsafe-finally": "warn",
|
||||
"no-var": "error",
|
||||
"spaced-comment": "error",
|
||||
"semi": "warn",
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
"selector": "ExportDefaultDeclaration",
|
||||
"message": "Default exports are not allowed"
|
||||
}
|
||||
],
|
||||
"no-throw-literal": "error",
|
||||
"key-spacing": "error",
|
||||
"object-curly-spacing": ["error", "always"],
|
||||
"array-bracket-spacing": "error",
|
||||
"space-in-parens": "error",
|
||||
"computed-property-spacing": "error",
|
||||
"prefer-const": ["error", {
|
||||
"destructuring": "all",
|
||||
"ignoreReadBeforeAssign": false
|
||||
}],
|
||||
"space-before-function-paren": "off",
|
||||
"func-call-spacing": "off",
|
||||
"no-multi-spaces": "error",
|
||||
"block-spacing": "error",
|
||||
"keyword-spacing": "off",
|
||||
"space-before-blocks": "error",
|
||||
"semi-spacing": "error",
|
||||
"no-constant-binary-expression": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.ts", "**/*.tsx"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": ["tsconfig.json", "tsconfig.commonjs.json"],
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/ban-types": "off",
|
||||
"@typescript-eslint/class-name-casing": "off",
|
||||
"@typescript-eslint/indent": [
|
||||
"error",
|
||||
4
|
||||
],
|
||||
"@typescript-eslint/member-delimiter-style": [
|
||||
"off",
|
||||
{
|
||||
"multiline": {
|
||||
"delimiter": "none",
|
||||
"requireLast": true
|
||||
},
|
||||
"singleline": {
|
||||
"delimiter": "semi",
|
||||
"requireLast": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/prefer-namespace-keyword": "warn",
|
||||
"@typescript-eslint/quotes": [
|
||||
"error",
|
||||
"single",
|
||||
{
|
||||
"avoidEscape": true,
|
||||
"allowTemplateLiterals": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/semi": [
|
||||
"off",
|
||||
null
|
||||
],
|
||||
"@typescript-eslint/type-annotation-spacing": "error",
|
||||
"@typescript-eslint/brace-style": [
|
||||
"error",
|
||||
"1tbs", { "allowSingleLine": true }
|
||||
],
|
||||
"@typescript-eslint/comma-spacing": "error",
|
||||
"@typescript-eslint/space-infix-ops": "error",
|
||||
"@typescript-eslint/space-before-function-paren": ["error", {
|
||||
"anonymous": "always",
|
||||
"named": "never",
|
||||
"asyncArrow": "always"
|
||||
}],
|
||||
"@typescript-eslint/func-call-spacing": ["error"],
|
||||
"@typescript-eslint/keyword-spacing": ["error"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
build/
|
||||
deploy/
|
||||
lib/
|
||||
docs/site/
|
||||
|
||||
@@ -13,3 +14,6 @@ tsconfig.commonjs.tsbuildinfo
|
||||
|
||||
.DS_Store
|
||||
tmp/
|
||||
|
||||
dev.pem
|
||||
dev-key.pem
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -6,9 +6,4 @@
|
||||
"*.vert.ts": "glsl",
|
||||
"*.gql.ts": "graphql"
|
||||
},
|
||||
"eslint.options": {
|
||||
"overrideConfig": {
|
||||
"ignorePatterns": ["webpack.config.js", "scripts/*"],
|
||||
},
|
||||
}
|
||||
}
|
||||
81
CHANGELOG.md
81
CHANGELOG.md
@@ -4,6 +4,87 @@ All notable changes to this project will be documented in this file, following t
|
||||
Note that since we don't clearly distinguish between a public and private interfaces there will be changes in non-major versions that are potentially breaking. If we make breaking changes to less used interfaces we will highlight it in here.
|
||||
|
||||
## [Unreleased]
|
||||
- [Breaking] Renamed some color schemes ('inferno' -> 'inferno-no-black', 'magma' -> 'magma-no-black', 'turbo' -> 'turbo-no-black', 'rainbow' -> 'simple-rainbow')
|
||||
- [Breaking] `Box3D.nearestIntersectionWithRay` -> `Ray3D.intersectBox3D`
|
||||
- [Breaking] `Plane3D.distanceToSpher3D` -> `distanceToSphere3D` (fix spelling)
|
||||
- [Breaking] fix typo `MarchinCubes` -> `MarchingCubes`
|
||||
- [Breaking] `PluginContext.initViewer/initContainer/mount` are now async and have been renamed to include `Async` postfix
|
||||
- [Breaking] Add `Volume.instances` support and a `VolumeInstances` transform to dynamically assign it
|
||||
- This change is breaking because all volume objects require the `instances` field now.
|
||||
- [Breaking] `Canvas3D.identify` now expects `Vec2` or `Ray3D`
|
||||
- Update production build to use `esbuild`
|
||||
- Emit explicit paths in `import`s in `lib/`
|
||||
- Fix outlines on opaque elements using illumination mode
|
||||
- Change `Representation.Empty` to a lazy property to avoid issue with some bundlers
|
||||
- MolViewSpec extension:
|
||||
- Generic color schemes (`palette` parameter for color_from_* nodes)
|
||||
- Annotation field remapping (`field_remapping` parameter for color_from_* nodes)
|
||||
- Representation node: support custom property `molstar_reprepresentation_params`,
|
||||
- Canvas node: support custom properties `molstar_enable_outline`, `molstar_enable_shadow`, `molstar_enable_ssao`
|
||||
- `clip` node support for structure and volume representations
|
||||
- `grid_slice` representation support for volumes
|
||||
- Support tethers and background for primitive labels
|
||||
- Support `snapshot_key` parameter on primitives that enables transition between states via clicking on 3D objects
|
||||
- Inline selectors and MVS annotations support `instance_id`
|
||||
- Support `matrix` on transform params
|
||||
- Add `instance` node type
|
||||
- Support transforming and instancing of structures, components, and volumes
|
||||
- Added new color schemes, synchronized with D3.js ('inferno', 'magma', 'turbo', 'rainbow', 'sinebow', 'warm', 'cool', 'cubehelix-default', 'category-10', 'observable-10', 'tableau-10')
|
||||
- Snapshot Markdown improvements
|
||||
- Add `MarkdownExtensionManager` (`PluginContext.managers.markdownExtensions`)
|
||||
- Support custom markdown commands to control the plugin via the `[link](!command)` pattern
|
||||
- Support rendering custom elements via the `` pattern
|
||||
- Support tables
|
||||
- Support loading images from MVSX files
|
||||
- Indicate external links with ⤴
|
||||
- Avoid calculating rings for coarse-grained structures
|
||||
- Fix isosurface compute shader normals when transformation matrix is applied to volume
|
||||
- Symmetry operator naming for spacegroup symmetry - parenthesize multi-character indices (1_111-1 -> 1_(11)1(-1))
|
||||
- Add `SymmetryOperator.instanceId` that corresponds to a canonical operator name (e.g. ASM-1, ASM-X0-1 for assemblies, 1_555, 1_(11)1(-1) for crystals)
|
||||
- Mol2 Reader
|
||||
- Fix column count parsing
|
||||
- Add support for substructure
|
||||
- Fix shader error when clipping flags are set without clip objects present
|
||||
- Fix wrong group count calculation on geometry update (#1562)
|
||||
- Fix wrong instance index in `calcMeshColorSmoothing`
|
||||
- Add `Ray3D` object and helpers
|
||||
- Volume slice representation: add `relativeX/Y/Z` options for dimension
|
||||
- Add `StructureInstances` transform
|
||||
- `mvs-stories` app
|
||||
- Add `story-id` URL arg support
|
||||
- Add "Download MVS State" link
|
||||
- Add ray-based picking
|
||||
- Render narrow view of scene scene from ray origin & direction to a few pixel sized viewport
|
||||
- Cast ray on every input as opposed to the standard "whole screen" picking
|
||||
- Can be enabled with new `Canvas3dInteractionHelperParams.convertCoordsToRay` param
|
||||
- Allows to have input methods that are 3D pointers in the scene
|
||||
- Add `ray: Ray3D` property to `DragInput`, `ClickInput`, and `MoveInput`
|
||||
- Add async, non-blocking picking (only WebGL2)
|
||||
- Refactor `Canvas3dInteractionHelper` internals to use async picking for move events
|
||||
- Add `enable` param for post-processing effects. If false, no effects are applied.
|
||||
|
||||
## [v4.18.0] - 2025-06-08
|
||||
- MolViewSpec extension:
|
||||
- Support for label_comp_id and auth_comp_id in annotations
|
||||
- Geometric primitives - do not render if position refers to empty substructure
|
||||
- Primitive arrow - nicer default cap size (relative to tube_radius)
|
||||
- Primitive angle_measurement - added vector_radius param
|
||||
- Fix MVSX file assets being disposed in multi-snapshot states
|
||||
- Add `mol-utils/camera.ts` with `fovAdjustedPosition` and `fovNormalizedCameraPosition`
|
||||
- Show FOV normalized position in `CameraInfo` UI and use it in "Copy MVS State"
|
||||
- Support static resources in `AssetManager`
|
||||
- General:
|
||||
- Use `isolatedModules` tsconfig flag
|
||||
- Fix TurboPack build when using ES6 modules
|
||||
- Support `pickingAlphaThreshold` when `xrayShaded` is enabled
|
||||
- Support sampling from arbitrary planes for structure plane and volume slice representations
|
||||
- Refactor SCSS to not use `@import` (fixes deprecation warnings)
|
||||
|
||||
## [v4.17.0] - 2025-05-22
|
||||
- Remove `xhr2` dependency for NodeJS, use `fetch`
|
||||
- Add `mvs-stories` app included in the `molstar` NPM package
|
||||
- Use the app in the corresponding example
|
||||
- Interactions extension: remove `salt-bridge` interaction kind (since `ionic` is supported too)
|
||||
|
||||
## [v4.16.0] - 2025-05-20
|
||||
- Load potentially big text files as `StringLike` to bypass string size limit
|
||||
|
||||
@@ -190,9 +190,14 @@ To get syntax highlighting for shader files add the following to Visual Code's s
|
||||
npm publish
|
||||
|
||||
## Deploy
|
||||
To prepare apps and demos for https://molstar.org deploy, run:
|
||||
|
||||
npm run test
|
||||
npm run build
|
||||
node ./scripts/deploy.js # currently updates the viewer on molstar.org/viewer
|
||||
npm run deploy:local
|
||||
|
||||
To commit these changes remotely to the `molstar/molstar.github.io` repo:
|
||||
|
||||
npm run deploy:remote
|
||||
|
||||
## Contributing
|
||||
Just open an issue or make a pull request. All contributions are welcome.
|
||||
|
||||
217
build-dev.mjs
217
build-dev.mjs
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
* @author Eric E <etongfu@@outlook.com>
|
||||
*/
|
||||
import * as esbuild from 'esbuild';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as argparse from 'argparse';
|
||||
import { sassPlugin } from 'esbuild-sass-plugin';
|
||||
import * as os from 'os';
|
||||
|
||||
const AllApps = [
|
||||
'viewer',
|
||||
'docking-viewer',
|
||||
'mesoscale-explorer'
|
||||
];
|
||||
|
||||
const AllExamples = [
|
||||
'proteopedia-wrapper',
|
||||
'basic-wrapper',
|
||||
'lighting',
|
||||
'alpha-orbitals',
|
||||
'alphafolddb-pae',
|
||||
'mvs-stories',
|
||||
'ihm-restraints',
|
||||
'interactions',
|
||||
'ligand-editor',
|
||||
];
|
||||
|
||||
function mkDir(dir) {
|
||||
try {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to create directory ${dir}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileError(error, operation, path) {
|
||||
console.error(`Failed to ${operation} ${path}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function fileLoaderPlugin(options) {
|
||||
mkDir(options.out);
|
||||
|
||||
return {
|
||||
name: 'file-loader',
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /\.jpg$/ }, async (args) => {
|
||||
try {
|
||||
const name = path.basename(args.path);
|
||||
mkDir(path.resolve(options.out, 'images'));
|
||||
await fs.promises.copyFile(args.path, path.resolve(options.out, 'images', name));
|
||||
return {
|
||||
contents: `images/${name}`,
|
||||
loader: 'text',
|
||||
};
|
||||
} catch (error) {
|
||||
handleFileError(error, 'copy', args.path);
|
||||
}
|
||||
});
|
||||
build.onLoad({ filter: /\.(html|ico)$/ }, async (args) => {
|
||||
const name = path.basename(args.path);
|
||||
await fs.promises.copyFile(args.path, path.resolve(options.out, name));
|
||||
return {
|
||||
contents: '',
|
||||
loader: 'empty',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function examplesCssRenamePlugin({ root }) {
|
||||
return {
|
||||
name: 'example-css-rename',
|
||||
setup(build) {
|
||||
build.onEnd(async () => {
|
||||
if (fs.existsSync(path.resolve(root, 'index.css'))) {
|
||||
await fs.promises.rename(
|
||||
path.resolve(root, 'index.css'),
|
||||
path.resolve(root, 'molstar.css')
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function watch(name, kind) {
|
||||
const prefix = kind === 'app'
|
||||
? `./build/${name}`
|
||||
: `./build/examples/${name}`;
|
||||
|
||||
let entry = `./src/${kind}s/${name}/index.ts`;
|
||||
if (!fs.existsSync(entry)) {
|
||||
entry = `./src/${kind}s/${name}/index.tsx`;
|
||||
}
|
||||
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: [entry],
|
||||
tsconfig: './tsconfig.json',
|
||||
bundle: true,
|
||||
globalName: 'molstar',
|
||||
outfile: kind === 'app'
|
||||
? `./build/${name}/molstar.js`
|
||||
: `./build/examples/${name}/index.js`,
|
||||
plugins: [
|
||||
fileLoaderPlugin({ out: prefix }),
|
||||
sassPlugin({
|
||||
type: 'css',
|
||||
silenceDeprecations: ['import'],
|
||||
logger: {
|
||||
warn: (msg) => console.warn(msg),
|
||||
debug: () => { },
|
||||
}
|
||||
}),
|
||||
...(kind === 'example' ? [examplesCssRenamePlugin({ root: prefix })] : []),
|
||||
],
|
||||
external: ['crypto', 'fs', 'path', 'stream'],
|
||||
loader: {
|
||||
},
|
||||
color: true,
|
||||
logLevel: 'info',
|
||||
});
|
||||
|
||||
await ctx.rebuild();
|
||||
await ctx.watch();
|
||||
}
|
||||
|
||||
const argParser = new argparse.ArgumentParser({
|
||||
add_help: true,
|
||||
description: 'Mol* development build'
|
||||
});
|
||||
argParser.add_argument('--apps', '-a', {
|
||||
help: 'Apps to build.',
|
||||
required: false,
|
||||
nargs: '*',
|
||||
});
|
||||
argParser.add_argument('--examples', '-e', {
|
||||
help: 'Examples to build.',
|
||||
required: false,
|
||||
nargs: '*',
|
||||
});
|
||||
argParser.add_argument('--port', '-p', {
|
||||
help: 'Port.',
|
||||
required: false,
|
||||
default: 1338,
|
||||
type: 'int',
|
||||
});
|
||||
|
||||
argParser.add_argument('--host', {
|
||||
help: 'Show all available host addresses.',
|
||||
required: false,
|
||||
action: 'store_true',
|
||||
});
|
||||
|
||||
const args = argParser.parse_args();
|
||||
|
||||
const apps = (!args.apps ? [] : (args.apps.length ? args.apps : AllApps)).filter(a => AllApps.includes(a));
|
||||
const examples = (!args.examples ? [] : (args.examples.length ? args.examples : AllExamples)).filter(e => AllExamples.includes(e));
|
||||
|
||||
console.log('Apps:', apps);
|
||||
console.log('Examples:', examples);
|
||||
console.log('');
|
||||
|
||||
function getLocalIPs() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
const ips = [];
|
||||
|
||||
for (const name of Object.keys(interfaces)) {
|
||||
for (const iface of interfaces[name]) {
|
||||
// Skip internal and non-IPv4 addresses
|
||||
if (iface.internal || iface.family !== 'IPv4') continue;
|
||||
ips.push(iface.address);
|
||||
}
|
||||
}
|
||||
|
||||
return ips;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const promises = [];
|
||||
for (const app of apps) promises.push(watch(app, 'app'));
|
||||
for (const example of examples) promises.push(watch(example, 'example'));
|
||||
|
||||
console.log('Initial build...');
|
||||
|
||||
await Promise.all(promises);
|
||||
console.log('Done.');
|
||||
|
||||
const ctx = await esbuild.context({});
|
||||
ctx.serve({
|
||||
servedir: './',
|
||||
port: args.port,
|
||||
host: '0.0.0.0', // Always listen on all interfaces
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log(`Server URL: http://localhost:${args.port}`);
|
||||
if (args.host) {
|
||||
console.log('Available host addresses:');
|
||||
const ips = getLocalIPs();
|
||||
ips.forEach(ip => console.log(` http://${ip}:${args.port}`));
|
||||
}
|
||||
console.log('');
|
||||
console.log('Watching for changes...');
|
||||
console.log('');
|
||||
console.log('Press Ctrl+C to stop.');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -24,7 +24,7 @@ npm install
|
||||
Afterwards, build the project source:
|
||||
|
||||
```
|
||||
npm run build-tsc
|
||||
npm run build:lib
|
||||
```
|
||||
|
||||
and run the server by
|
||||
|
||||
@@ -94,7 +94,7 @@ The extension uses several transformations to process and visualize tunnel data:
|
||||
To help users understand how to use these transformations in practice, include detailed examples:
|
||||
|
||||
### Visualizing Multiple Tunnels
|
||||
This example ([runVisualizeTunnels](../../../src/extensions/sb-ncbr/tunnels/examples.ts#L19)) demonstrates how to visualize multiple tunnels from a fetched dataset.
|
||||
This example (see `src/extensions/sb-ncbr/tunnels/examples.ts#L19`) demonstrates how to visualize multiple tunnels from a fetched dataset.
|
||||
```typescript
|
||||
update.toRoot()
|
||||
.apply(TunnelsFromRawData, { data: tunnels })
|
||||
@@ -104,7 +104,7 @@ update.toRoot()
|
||||
```
|
||||
|
||||
### Visualizing a Single Tunnel
|
||||
This example ([runVisualizeTunnel](../../../src/extensions/sb-ncbr/tunnels/examples.ts#L46)) shows how to visualize a single tunnel.
|
||||
This example (see `src/extensions/sb-ncbr/tunnels/examples.ts#L46`) shows how to visualize a single tunnel.
|
||||
```typescript
|
||||
update.toRoot()
|
||||
.apply(TunnelFromRawData, {
|
||||
|
||||
@@ -141,7 +141,7 @@ export async function loadStructure(plugin: PluginUIContext, url: string, option
|
||||
```
|
||||
- Create `src/style.scss`:
|
||||
```scss
|
||||
@import '../node_modules/molstar/lib/mol-plugin-ui/skin/light.scss';
|
||||
@use '../node_modules/molstar/lib/mol-plugin-ui/skin/light.scss';
|
||||
```
|
||||
- Create `build/ui.html`:
|
||||
```html
|
||||
|
||||
@@ -247,7 +247,7 @@ async function init() {
|
||||
const canvas = <HTMLCanvasElement> document.getElementById('molstar-canvas');
|
||||
const parent = <HTMLDivElement> document.getElementById('molstar-parent');
|
||||
|
||||
if (!plugin.initViewer(canvas, parent)) {
|
||||
if (!(await plugin.initViewer(canvas, parent))) {
|
||||
console.error('Failed to init Mol*');
|
||||
return;
|
||||
}
|
||||
|
||||
94
docs/docs/plugin/managers/markdown-extensions.md
Normal file
94
docs/docs/plugin/managers/markdown-extensions.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Markdown Extension Manager
|
||||
|
||||
The `markdownExtensions` manager in `PluginContext.manager` allows customizing
|
||||
the `Markdown` React component to enable executing commands and rendering custom content.
|
||||
|
||||
The main use case of this is enriching [MolViewSpec](`https://molstar.org/mol-view-spec`) support.
|
||||
|
||||
## API
|
||||
|
||||
- `PluginContext.manager.markdownExtensions.register*` functions can be used to register extensions and state/data resolvers to make the the manager work with plugin extension
|
||||
- `PluginContext.manager.markdownExtensions.remove*` can be used to dynamically remove the above
|
||||
|
||||
## Commands
|
||||
|
||||
Extends Markdown Hyperlink syntax to support expressions of the form `[title](!c1=v1&c2=v2&...)` into an executable command. The command can be executed either on click, mouse enter, or mouse leave.
|
||||
|
||||
### Built-in Commands
|
||||
|
||||
- `center-camera` - Centers the camera
|
||||
- `apply-snapshot=key` - Loads snapshots with the provided key
|
||||
- `focus-refs=ref1,ref2,...` - On click, focuses nodes with the provided refs
|
||||
- `highlight-refs=ref1,ref2,...` - On mouse over, highlights the provided refs
|
||||
|
||||
## Custom Content
|
||||
|
||||
Extends Markdown Image syntax to support expressions of the form `` to render custom elements instead.
|
||||
|
||||
### Built-in Custom Content
|
||||
- `color-swatch=color` - Renders a box with the provided color
|
||||
- Color palettes:
|
||||
- `color-palette-name=name` - Renders a gradient with the provivided named color palette (see `mol-util/color/lists.ts` for supported color schemes)
|
||||
- `color-palette-colors=color1,color2` - Renders a gradient with the provided colors
|
||||
- `color-palette-width=CCS-value` - Specifies the width of the element, defaults to `150px`
|
||||
- `color-palette-height=CCS-value` - Specified the height of the element, defaults to `0.5em`
|
||||
- `color-palette-discrete` - Renders discrete color list instead of interpolating
|
||||
|
||||
|
||||
## Example
|
||||
|
||||
```markdown
|
||||
### Highlight/Focus:
|
||||
-  [polymer](!highlight-refs=polymer&focus-refs=polymer)
|
||||
-  [ligand](!highlight-refs=ligand&focus-refs=ligand)
|
||||
- [both](!highlight-refs=polymer,ligand&focus-refs=polymer,ligand)
|
||||
|
||||
### Color Palettes
|
||||
|name|visual|
|
||||
|---:|---|
|
||||
|viridis||
|
||||
|rainbow (discrete)||
|
||||
|custom|)|
|
||||
|
||||
### Camera controls
|
||||
- [center](!center-camera)
|
||||
|
||||
### Image embedded in MVSX file
|
||||

|
||||
```
|
||||
|
||||
This works with the MolViewSpec state built by:
|
||||
|
||||
```py
|
||||
import molviewspec as mvs
|
||||
|
||||
builder = mvs.create_builder()
|
||||
|
||||
assets = {
|
||||
"1cbs.cif": "https://files.wwpdb.org/download/1cbs.cif",
|
||||
"logo.png": "https://molstar.org/img/molstar-logo.png",
|
||||
}
|
||||
|
||||
model = (
|
||||
builder.download(url="1cbs.cif")
|
||||
.parse(format="mmcif")
|
||||
.model_structure()
|
||||
)
|
||||
(
|
||||
model.component(selector="polymer")
|
||||
.representation(ref="polymer")
|
||||
.color(color="blue")
|
||||
)
|
||||
(
|
||||
model.component(selector="ligand")
|
||||
.representation(ref="ligand")
|
||||
.color(color="red")
|
||||
)
|
||||
|
||||
mvsx = mvs.MVSX(
|
||||
data=builder.get_state(
|
||||
description="""...""" # inline the code above
|
||||
),
|
||||
assets=assets
|
||||
)
|
||||
```
|
||||
@@ -25,7 +25,6 @@ markdown_extensions:
|
||||
generic: true
|
||||
# Scripts for rendering Latex equations (in addition to pymdownx.arithmatex):
|
||||
extra_javascript:
|
||||
- https://polyfill.io/v3/polyfill.min.js?features=es6
|
||||
- https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js
|
||||
nav:
|
||||
- 'index.md'
|
||||
@@ -38,6 +37,8 @@ nav:
|
||||
- Data State: 'plugin/data-state.md'
|
||||
- File Formats: 'plugin/file-formats.md'
|
||||
- CIF Schemas: 'plugin/cif-schemas.md'
|
||||
- Managers:
|
||||
- Markdown Extensions: 'plugin/managers/markdown-extensions.md'
|
||||
- State Transforms:
|
||||
- Custom Trajectory: 'plugin/transforms/custom-trajectory.md'
|
||||
- Custom Conformation: 'plugin/transforms/custom-conformation.md'
|
||||
@@ -59,5 +60,5 @@ nav:
|
||||
- Interactions: 'extensions/interactions.md'
|
||||
- Misc:
|
||||
- Interesting PDB entries: misc/interesting-pdb-entries.md
|
||||
- Exporting component data: exporting-components.md
|
||||
- Exporting component data: misc/exporting-components.md
|
||||
repo_url: https://github.com/molstar/docs
|
||||
|
||||
110
eslint.config.mjs
Normal file
110
eslint.config.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
import { defineConfig } from "eslint/config";
|
||||
import globals from "globals";
|
||||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
|
||||
export default defineConfig([{
|
||||
ignores: [
|
||||
"node_modules/*",
|
||||
"build/*",
|
||||
"deploy/*",
|
||||
"docs/site/*",
|
||||
"lib/*",
|
||||
"eslint.config.mjs",
|
||||
"build.mjs",
|
||||
]
|
||||
},{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
|
||||
ecmaVersion: 2018,
|
||||
sourceType: "module",
|
||||
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
impliedStrict: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
indent: "off",
|
||||
"arrow-parens": ["off", "as-needed"],
|
||||
"brace-style": ["error", "1tbs", {
|
||||
allowSingleLine: true,
|
||||
}],
|
||||
"comma-spacing": "off",
|
||||
"space-infix-ops": "off",
|
||||
"comma-dangle": "off",
|
||||
eqeqeq: ["error", "smart"],
|
||||
"import/order": "off",
|
||||
"no-eval": "warn",
|
||||
"no-extend-native": "warn",
|
||||
"no-new-wrappers": "warn",
|
||||
"no-trailing-spaces": "error",
|
||||
"no-unsafe-finally": "warn",
|
||||
"no-self-compare": "warn",
|
||||
"no-var": "error",
|
||||
"spaced-comment": "error",
|
||||
semi: "warn",
|
||||
"no-restricted-syntax": ["error", {
|
||||
selector: "ExportDefaultDeclaration",
|
||||
message: "Default exports are not allowed",
|
||||
}],
|
||||
"no-throw-literal": "error",
|
||||
"key-spacing": "error",
|
||||
"object-curly-spacing": ["error", "always"],
|
||||
"array-bracket-spacing": "error",
|
||||
"space-in-parens": "error",
|
||||
"computed-property-spacing": "error",
|
||||
"prefer-const": ["error", {
|
||||
destructuring: "all",
|
||||
ignoreReadBeforeAssign: false,
|
||||
}],
|
||||
"space-before-function-paren": "off",
|
||||
"func-call-spacing": "off",
|
||||
"no-multi-spaces": "error",
|
||||
"block-spacing": "error",
|
||||
"keyword-spacing": "warn",
|
||||
"space-before-blocks": "error",
|
||||
"semi-spacing": "error",
|
||||
"no-constant-binary-expression": "error",
|
||||
},
|
||||
}, {
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
|
||||
plugins: {
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 5,
|
||||
sourceType: "module",
|
||||
|
||||
parserOptions: {
|
||||
project: ["tsconfig.eslint.json"],
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
"@typescript-eslint/ban-types": "off",
|
||||
"@typescript-eslint/class-name-casing": "off",
|
||||
"@typescript-eslint/member-delimiter-style": ["off", {
|
||||
multiline: {
|
||||
delimiter: "none",
|
||||
requireLast: true,
|
||||
},
|
||||
|
||||
singleline: {
|
||||
delimiter: "semi",
|
||||
requireLast: false,
|
||||
},
|
||||
}],
|
||||
"@typescript-eslint/prefer-namespace-keyword": "warn",
|
||||
"@typescript-eslint/semi": ["off", null],
|
||||
},
|
||||
}]);
|
||||
4052
examples/mvs/kinase-story.mvsj
Normal file
4052
examples/mvs/kinase-story.mvsj
Normal file
File diff suppressed because it is too large
Load Diff
34475
package-lock.json
generated
34475
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
85
package.json
85
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "molstar",
|
||||
"version": "4.16.0",
|
||||
"version": "5.0.0-dev.2",
|
||||
"description": "A comprehensive macromolecular library.",
|
||||
"homepage": "https://github.com/molstar/molstar#readme",
|
||||
"repository": {
|
||||
@@ -18,29 +18,22 @@
|
||||
"lint-fix": "eslint . --fix",
|
||||
"test": "npm install --no-save \"gl@^6.0.2\" && npm run lint && jest",
|
||||
"jest": "jest",
|
||||
"build": "npm run build-tsc && npm run build-extra && npm run build-webpack",
|
||||
"clean": "node ./scripts/clean.js",
|
||||
"clean": "node ./scripts/clean.js --all",
|
||||
"clean:build": "node ./scripts/clean.js --build",
|
||||
"build": "npm run build:apps && npm run build:lib",
|
||||
"build:apps": "node ./scripts/build.mjs -a -e --prd",
|
||||
"build:lib": "concurrently \"tsc --incremental\" \"tsc --build tsconfig.commonjs.json --incremental\" && npm run build:lib-extra",
|
||||
"build:lib-extra": "node scripts/write-version.mjs && cpx \"src/**/*.{scss,html,ico,jpg}\" lib/ && cpx \"src/**/*.{scss,html,ico,jpg}\" lib/commonjs/ && tsc-alias -p tsconfig.json",
|
||||
"rebuild": "npm run clean && npm run build",
|
||||
"build-viewer": "npm run build-tsc && npm run build-extra && npm run build-webpack-viewer",
|
||||
"build-tsc": "concurrently \"tsc --incremental\" \"tsc --build tsconfig.commonjs.json --incremental\"",
|
||||
"build-extra": "cpx \"src/**/*.{scss,html,ico,jpg}\" lib/",
|
||||
"build-webpack": "webpack --mode production --config ./webpack.config.production.js",
|
||||
"build-webpack-viewer": "webpack --mode production --config ./webpack.config.viewer.js",
|
||||
"watch": "concurrently -c \"green,green,gray,gray\" --names \"tsc,srv,ext,wpc\" --kill-others \"npm:watch-tsc\" \"npm:watch-servers\" \"npm:watch-extra\" \"npm:watch-webpack\"",
|
||||
"watch-viewer": "concurrently -c \"green,gray,gray\" --names \"tsc,ext,wpc\" --kill-others \"npm:watch-tsc\" \"npm:watch-extra\" \"npm:watch-webpack-viewer\"",
|
||||
"watch-viewer-debug": "concurrently -c \"green,gray,gray\" --names \"tsc,ext,wpc\" --kill-others \"npm:watch-tsc\" \"npm:watch-extra\" \"npm:watch-webpack-viewer-debug\"",
|
||||
"watch-tsc": "tsc --watch --incremental",
|
||||
"watch-servers": "tsc --build tsconfig.commonjs.json --watch --incremental",
|
||||
"watch-extra": "cpx \"src/**/*.{scss,html,ico,jpg}\" lib/ --watch",
|
||||
"watch-webpack": "webpack -w --mode development --stats minimal",
|
||||
"watch-webpack-viewer": "webpack -w --mode development --stats minimal --config ./webpack.config.viewer.js",
|
||||
"watch-webpack-viewer-debug": "webpack -w --mode development --stats minimal --config ./webpack.config.viewer.debug.js",
|
||||
"dev": "node build-dev.mjs",
|
||||
"dev:all": "node build-dev.mjs -a -e",
|
||||
"dev:viewer": "node build-dev.mjs -a viewer",
|
||||
"dev:apps": "node build-dev.mjs -a",
|
||||
"dev:examples": "node build-dev.mjs -e",
|
||||
"dev": "node ./scripts/build.mjs",
|
||||
"dev:all": "node ./scripts/build.mjs -a -e -bt",
|
||||
"dev:viewer": "node ./scripts/build.mjs -a viewer",
|
||||
"dev:apps": "node ./scripts/build.mjs -a",
|
||||
"dev:examples": "node ./scripts/build.mjs -e",
|
||||
"dev:browser-tests": "node ./scripts/build.mjs -bt",
|
||||
"serve": "http-server -p 1338 -g",
|
||||
"deploy:local": "npm run clean:build && npm run build:apps && node ./scripts/deploy.js --local",
|
||||
"deploy:remote": "npm run clean:build && npm run build:apps && node ./scripts/deploy.js",
|
||||
"model-server": "node lib/commonjs/servers/model/server.js",
|
||||
"model-server-watch": "nodemon --watch lib lib/commonjs/servers/model/server.js",
|
||||
"volume-server-test": "node lib/commonjs/servers/volume/server.js --idMap em 'test/${id}.mdb' --defaultPort 1336",
|
||||
@@ -51,7 +44,8 @@
|
||||
},
|
||||
"files": [
|
||||
"lib/",
|
||||
"build/viewer/"
|
||||
"build/viewer/",
|
||||
"build/mvs-stories/"
|
||||
],
|
||||
"bin": {
|
||||
"cif2bcif": "lib/commonjs/cli/cif2bcif/index.js",
|
||||
@@ -126,53 +120,44 @@
|
||||
"Ventura Rivera <venturaxrivera@gmail.com>",
|
||||
"Andy Turner <agdturner@gmail.com>",
|
||||
"Lukáš Polák <admin@lukaspolak.cz>",
|
||||
"Chetan Mishra <chetan.s115@gmail.com>"
|
||||
"Chetan Mishra <chetan.s115@gmail.com>",
|
||||
"Zach Charlop-Powers <zach.charlop.powers@gmail.com>"
|
||||
],
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.18",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/gl": "^6.0.5",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/react": "^18.3.21",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.34.0",
|
||||
"@typescript-eslint/parser": "^8.34.0",
|
||||
"benchmark": "^2.1.4",
|
||||
"concurrently": "^9.1.2",
|
||||
"cpx2": "^8.0.0",
|
||||
"crypto-browserify": "^3.12.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"esbuild": "^0.25.4",
|
||||
"esbuild": "^0.25.5",
|
||||
"esbuild-sass-plugin": "^3.3.1",
|
||||
"eslint": "^8.57.1",
|
||||
"extra-watch-webpack-plugin": "^1.0.3",
|
||||
"file-loader": "^6.2.0",
|
||||
"eslint": "^9.29.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"http-server": "^14.1.1",
|
||||
"jest": "^29.7.0",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"mini-css-extract-plugin": "^2.9.2",
|
||||
"path-browserify": "^1.0.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"sass": "^1.89.0",
|
||||
"sass-loader": "^16.0.5",
|
||||
"simple-git": "^3.27.0",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"style-loader": "^4.0.0",
|
||||
"sass": "^1.89.1",
|
||||
"simple-git": "^3.28.0",
|
||||
"ts-jest": "^29.3.4",
|
||||
"typescript": "^5.8.3",
|
||||
"webpack": "^5.99.8",
|
||||
"webpack-cli": "^6.0.1"
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/argparse": "^2.0.17",
|
||||
"@types/benchmark": "^2.1.5",
|
||||
"@types/compression": "1.7.5",
|
||||
"@types/express": "^5.0.2",
|
||||
"@types/node": "^18.19.101",
|
||||
"@types/compression": "1.8.1",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^18.19.111",
|
||||
"@types/node-fetch": "^2.6.12",
|
||||
"@types/swagger-ui-dist": "3.30.5",
|
||||
"argparse": "^2.0.1",
|
||||
@@ -185,11 +170,11 @@
|
||||
"io-ts": "^2.2.22",
|
||||
"node-fetch": "^2.7.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"swagger-ui-dist": "^5.21.0",
|
||||
"swagger-ui-dist": "^5.24.0",
|
||||
"tslib": "^2.8.1",
|
||||
"util.promisify": "^1.1.3",
|
||||
"xhr2": "^0.2.1"
|
||||
"util.promisify": "^1.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@google-cloud/storage": "^7.14.0",
|
||||
|
||||
307
scripts/build.mjs
Normal file
307
scripts/build.mjs
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Copyright (c) 2017-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
* @author Eric E <etongfu@@outlook.com>
|
||||
*/
|
||||
import * as esbuild from 'esbuild';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as argparse from 'argparse';
|
||||
import { sassPlugin } from 'esbuild-sass-plugin';
|
||||
import * as os from 'os';
|
||||
|
||||
const Apps = [
|
||||
// Apps
|
||||
{ kind: 'app', name: 'viewer' },
|
||||
{ kind: 'app', name: 'docking-viewer' },
|
||||
{ kind: 'app', name: 'mesoscale-explorer' },
|
||||
{ kind: 'app', name: 'mvs-stories', globalName: 'mvsStories', filename: 'mvs-stories.js' },
|
||||
|
||||
// Examples
|
||||
{ kind: 'example', name: 'proteopedia-wrapper' },
|
||||
{ kind: 'example', name: 'basic-wrapper' },
|
||||
{ kind: 'example', name: 'lighting' },
|
||||
{ kind: 'example', name: 'alpha-orbitals' },
|
||||
{ kind: 'example', name: 'alphafolddb-pae' },
|
||||
{ kind: 'example', name: 'mvs-stories' },
|
||||
{ kind: 'example', name: 'ihm-restraints' },
|
||||
{ kind: 'example', name: 'interactions' },
|
||||
{ kind: 'example', name: 'ligand-editor' },
|
||||
];
|
||||
|
||||
function findApp(name, kind) {
|
||||
return Apps.find(a => a.name === name && a.kind === kind);
|
||||
}
|
||||
|
||||
function mkDir(dir) {
|
||||
try {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to create directory ${dir}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileError(error, operation, path) {
|
||||
console.error(`Failed to ${operation} ${path}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function fileLoaderPlugin(options) {
|
||||
mkDir(options.out);
|
||||
|
||||
return {
|
||||
name: 'file-loader',
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /\.jpg$/ }, async (args) => {
|
||||
try {
|
||||
const name = path.basename(args.path);
|
||||
mkDir(path.resolve(options.out, 'images'));
|
||||
await fs.promises.copyFile(args.path, path.resolve(options.out, 'images', name));
|
||||
return {
|
||||
contents: `images/${name}`,
|
||||
loader: 'text',
|
||||
};
|
||||
} catch (error) {
|
||||
handleFileError(error, 'copy', args.path);
|
||||
}
|
||||
});
|
||||
build.onLoad({ filter: /\.(html|ico)$/ }, async (args) => {
|
||||
const name = path.basename(args.path);
|
||||
await fs.promises.copyFile(args.path, path.resolve(options.out, name));
|
||||
return {
|
||||
contents: '',
|
||||
loader: 'empty',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function examplesCssRenamePlugin({ root }) {
|
||||
return {
|
||||
name: 'example-css-rename',
|
||||
setup(build) {
|
||||
build.onEnd(async () => {
|
||||
if (fs.existsSync(path.resolve(root, 'index.css'))) {
|
||||
await fs.promises.rename(
|
||||
path.resolve(root, 'index.css'),
|
||||
path.resolve(root, 'molstar.css')
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEntryPath(path) {
|
||||
if (!fs.existsSync(path)) {
|
||||
return path + 'x'; // fallback to .tsx
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function getPaths(app) {
|
||||
if (app.kind === 'app') {
|
||||
return {
|
||||
prefix: `./build/${app.name}`,
|
||||
entry: resolveEntryPath(`./src/apps/${app.name}/index.ts`),
|
||||
outfile: `./build/${app.name}/${app.filename || 'molstar.js'}`,
|
||||
};
|
||||
}
|
||||
if (app.kind === 'example') {
|
||||
return {
|
||||
prefix: `./build/examples/${app.name}`,
|
||||
entry: resolveEntryPath(`./src/examples/${app.name}/index.ts`),
|
||||
outfile: `./build/examples/${app.name}/${app.filename || 'index.js'}`,
|
||||
};
|
||||
}
|
||||
if (app.kind === 'browser-test') {
|
||||
return {
|
||||
prefix: `./build/tests/browser`,
|
||||
entry: resolveEntryPath(`./src/tests/browser/${app.name}.ts`),
|
||||
outfile: `./build/tests/browser/${app.name}.js`,
|
||||
};
|
||||
}
|
||||
throw new Error(`Unknown app kind: ${app.kind}`);
|
||||
}
|
||||
|
||||
async function createBundle(app) {
|
||||
const { name, kind } = app;
|
||||
|
||||
const { prefix, entry, outfile } = getPaths(app);
|
||||
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: [entry],
|
||||
tsconfig: './tsconfig.json',
|
||||
bundle: true,
|
||||
minify: isProduction,
|
||||
minifyIdentifiers: false,
|
||||
sourcemap: includeSourceMap,
|
||||
globalName: app.globalName || 'molstar',
|
||||
outfile,
|
||||
plugins: [
|
||||
fileLoaderPlugin({ out: prefix }),
|
||||
sassPlugin({
|
||||
type: 'css',
|
||||
silenceDeprecations: ['import'],
|
||||
logger: {
|
||||
warn: (msg) => console.warn(msg),
|
||||
debug: () => { },
|
||||
}
|
||||
}),
|
||||
...(kind === 'example' ? [examplesCssRenamePlugin({ root: prefix })] : []),
|
||||
],
|
||||
external: ['crypto', 'fs', 'path', 'stream'],
|
||||
loader: {
|
||||
},
|
||||
color: true,
|
||||
logLevel: 'info',
|
||||
define: {
|
||||
'process.env.DEBUG': JSON.stringify(process.env.DEBUG || false),
|
||||
__MOLSTAR_PLUGIN_VERSION__: JSON.stringify(VERSION),
|
||||
__MOLSTAR_BUILD_TIMESTAMP__: `${TIMESTAMP}`,
|
||||
},
|
||||
});
|
||||
|
||||
await ctx.rebuild();
|
||||
|
||||
if (!isProduction) await ctx.watch();
|
||||
}
|
||||
|
||||
function findBrowserTests(names) {
|
||||
const dir = path.resolve('./src', 'tests', 'browser');
|
||||
let files = fs.readdirSync(dir).filter(file => file.endsWith('.ts')).map(file => file.replace('.ts', ''));
|
||||
if (names.length) {
|
||||
files = files.filter(file => names.includes(file));
|
||||
}
|
||||
return files.map(name => ({ kind: 'browser-test', name }));
|
||||
}
|
||||
|
||||
const argParser = new argparse.ArgumentParser({
|
||||
add_help: true,
|
||||
description: 'Mol* Build'
|
||||
});
|
||||
argParser.add_argument('--prd', {
|
||||
help: 'Create a production build.',
|
||||
required: false,
|
||||
action: 'store_true',
|
||||
});
|
||||
argParser.add_argument('--no-src-map', {
|
||||
help: 'Do not include source map.',
|
||||
required: false,
|
||||
action: 'store_true',
|
||||
});
|
||||
argParser.add_argument('--apps', '-a', {
|
||||
help: 'Apps to build.',
|
||||
required: false,
|
||||
nargs: '*',
|
||||
});
|
||||
argParser.add_argument('--examples', '-e', {
|
||||
help: 'Examples to build.',
|
||||
required: false,
|
||||
nargs: '*',
|
||||
});
|
||||
argParser.add_argument('--browser-tests', '-bt', {
|
||||
help: 'Browser Tests to build.',
|
||||
required: false,
|
||||
nargs: '*',
|
||||
});
|
||||
argParser.add_argument('--port', '-p', {
|
||||
help: 'Port.',
|
||||
required: false,
|
||||
default: 1338,
|
||||
type: 'int',
|
||||
});
|
||||
|
||||
argParser.add_argument('--host', {
|
||||
help: 'Show all available host addresses.',
|
||||
required: false,
|
||||
action: 'store_true',
|
||||
});
|
||||
|
||||
const args = argParser.parse_args();
|
||||
|
||||
|
||||
const isProduction = !!args.prd;
|
||||
const includeSourceMap = !args.no_src_map;
|
||||
|
||||
const VERSION = isProduction ? JSON.parse(fs.readFileSync('./package.json', 'utf8')).version : '(dev build)';
|
||||
const TIMESTAMP = Date.now();
|
||||
|
||||
const apps = (!args.apps ? [] : (args.apps.length ? args.apps.map(a => findApp(a, 'app')).filter(a => a) : Apps.filter(a => a.kind === 'app')));
|
||||
const examples = (!args.examples ? [] : (args.examples.length ? args.examples.map(e => findApp(e, 'example')).filter(a => a) : Apps.filter(a => a.kind === 'example')));
|
||||
const browserTests = (!args.browser_tests ? [] : findBrowserTests(args.browser_tests));
|
||||
|
||||
console.log('Apps:', apps.map(a => a.name));
|
||||
console.log('Examples:', examples.map(e => e.name));
|
||||
console.log('Browser Tests', browserTests.map(e => e.name));
|
||||
console.log('');
|
||||
|
||||
function getLocalIPs() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
const ips = [];
|
||||
|
||||
for (const name of Object.keys(interfaces)) {
|
||||
for (const iface of interfaces[name]) {
|
||||
// Skip internal and non-IPv4 addresses
|
||||
if (iface.internal || iface.family !== 'IPv4') continue;
|
||||
ips.push(iface.address);
|
||||
}
|
||||
}
|
||||
|
||||
return ips;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const promises = [];
|
||||
console.log(isProduction ? 'Building apps...' : 'Initial build...');
|
||||
|
||||
for (const app of apps) promises.push(createBundle(app));
|
||||
for (const example of examples) promises.push(createBundle(example));
|
||||
for (const browserTest of browserTests) promises.push(createBundle(browserTest));
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
if (isProduction) {
|
||||
console.log('Done.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('Initial build complete.');
|
||||
|
||||
const certfile = './dev.pem';
|
||||
const keyfile = './dev-key.pem';
|
||||
|
||||
const sslEnabled = fs.existsSync(certfile) && fs.existsSync(keyfile);
|
||||
const protocol = sslEnabled ? 'https' : 'http';
|
||||
|
||||
const ctx = await esbuild.context({});
|
||||
ctx.serve({
|
||||
servedir: './',
|
||||
port: args.port,
|
||||
host: '0.0.0.0', // Always listen on all interfaces
|
||||
certfile: sslEnabled ? certfile : undefined,
|
||||
keyfile: sslEnabled ? keyfile : undefined,
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log(`Server URL: ${protocol}://localhost:${args.port}`);
|
||||
if (args.host) {
|
||||
console.log('Available host addresses:');
|
||||
const ips = getLocalIPs();
|
||||
ips.forEach(ip => console.log(` ${protocol}://${ip}:${args.port}`));
|
||||
}
|
||||
console.log('');
|
||||
console.log('Watching for changes...');
|
||||
console.log('');
|
||||
console.log('Press Ctrl+C to stop.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const argparse = require('argparse');
|
||||
|
||||
function removeDir(dirPath) {
|
||||
for (const ent of fs.readdirSync(dirPath)) {
|
||||
@@ -24,11 +25,29 @@ function remove(entryPath) {
|
||||
fs.unlinkSync(entryPath);
|
||||
}
|
||||
|
||||
const toClean = [
|
||||
path.resolve(__dirname, '../build'),
|
||||
path.resolve(__dirname, '../lib'),
|
||||
path.resolve(__dirname, '../tsconfig.tsbuildinfo'),
|
||||
];
|
||||
const argParser = new argparse.ArgumentParser({
|
||||
add_help: true,
|
||||
description: 'Clean Script'
|
||||
});
|
||||
argParser.add_argument('--build', { required: false, action: 'store_true' });
|
||||
argParser.add_argument('--lib', { required: false, action: 'store_true' });
|
||||
argParser.add_argument('--all', { required: false, action: 'store_true' });
|
||||
const args = argParser.parse_args();
|
||||
|
||||
const toClean = [];
|
||||
|
||||
if (args.build || args.all) {
|
||||
toClean.push(path.resolve(__dirname, '../build'));
|
||||
toClean.push(path.resolve(__dirname, '../deploy/data'));
|
||||
}
|
||||
if (args.lib || args.all) {
|
||||
toClean.push(
|
||||
path.resolve(__dirname, '../lib'),
|
||||
path.resolve(__dirname, '../tsconfig.tsbuildinfo'),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n###', 'cleaning', toClean.join(', '));
|
||||
|
||||
toClean.forEach(ph => {
|
||||
if (fs.existsSync(ph)) {
|
||||
|
||||
@@ -2,20 +2,24 @@
|
||||
* Copyright (c) 2019-2025 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>
|
||||
*/
|
||||
|
||||
const git = require('simple-git');
|
||||
const path = require('path');
|
||||
const fs = require("fs");
|
||||
const fse = require("fs-extra");
|
||||
const argparse = require('argparse');
|
||||
|
||||
const VERSION = require(path.resolve(__dirname, '../package.json')).version;
|
||||
const MVS_STORIES_VERSION = require(path.resolve(__dirname, '../src/apps/mvs-stories/version.ts')).VERSION;
|
||||
|
||||
const remoteUrl = "https://github.com/molstar/molstar.github.io.git";
|
||||
const dataDir = path.resolve(__dirname, '../data/');
|
||||
const buildDir = path.resolve(__dirname, '../build/');
|
||||
const deployDir = path.resolve(buildDir, 'deploy/');
|
||||
const localPath = path.resolve(deployDir, 'molstar.github.io/');
|
||||
const deployDir = path.resolve(__dirname, '../deploy/');
|
||||
const localPath = path.resolve(deployDir, 'data/');
|
||||
const repositoryPath = path.resolve(deployDir, 'molstar.github.io/');
|
||||
|
||||
const analyticsTag = /<!-- __MOLSTAR_ANALYTICS__ -->/g;
|
||||
const analyticsCode = `<!-- Cloudflare Web Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "c414cbae2d284ea995171a81e4a3e721"}'></script><!-- End Cloudflare Web Analytics --><iframe src="https://web3dsurvey.com/collector-iframe.html" style="width: 1px; height: 1px;"></iframe>`;
|
||||
@@ -80,54 +84,106 @@ function copyMe() {
|
||||
addAnalytics(path.resolve(meDeployPath, 'index.html'));
|
||||
}
|
||||
|
||||
function copyMVSStories() {
|
||||
console.log('\n###', 'copy MVS stories files');
|
||||
const mvsStoriesBuildPath = path.resolve(buildDir, 'mvs-stories/');
|
||||
const mvsStoriesDeployPath = path.resolve(localPath, `stories-viewer/v${MVS_STORIES_VERSION}/`);
|
||||
fse.copySync(mvsStoriesBuildPath, mvsStoriesDeployPath, { overwrite: true });
|
||||
addAnalytics(path.resolve(mvsStoriesDeployPath, 'index.html'));
|
||||
|
||||
// TODO: add PWA
|
||||
// addManifest(path.resolve(mvsStoriesDeployPath, 'index.html'));
|
||||
// addPwa(path.resolve(mvsStoriesDeployPath, 'index.html'));
|
||||
}
|
||||
|
||||
function copyDemo(name) {
|
||||
console.log('\n###', `copy demo files for ${name}`);
|
||||
const demoBuildPath = path.resolve(buildDir, `examples/${name}/`);
|
||||
const demoDeployPath = path.resolve(localPath, `demos/${name}/`);
|
||||
fse.copySync(demoBuildPath, demoDeployPath, { overwrite: true });
|
||||
addAnalytics(path.resolve(demoDeployPath, 'index.html'));
|
||||
}
|
||||
|
||||
function copyDemos() {
|
||||
console.log('\n###', 'copy demos files');
|
||||
const lightingBuildPath = path.resolve(buildDir, 'examples/lighting/');
|
||||
const lightingDeployPath = path.resolve(localPath, 'demos/lighting/');
|
||||
fse.copySync(lightingBuildPath, lightingDeployPath, { overwrite: true });
|
||||
addAnalytics(path.resolve(lightingDeployPath, 'index.html'));
|
||||
|
||||
const orbitalsBuildPath = path.resolve(buildDir, 'examples/alpha-orbitals/');
|
||||
const orbitalsDeployPath = path.resolve(localPath, 'demos/alpha-orbitals/');
|
||||
fse.copySync(orbitalsBuildPath, orbitalsDeployPath, { overwrite: true });
|
||||
addAnalytics(path.resolve(orbitalsDeployPath, 'index.html'));
|
||||
copyDemo('lighting');
|
||||
copyDemo('alpha-orbitals');
|
||||
copyDemo('mvs-stories');
|
||||
}
|
||||
|
||||
function copyFiles() {
|
||||
try {
|
||||
copyViewer();
|
||||
copyMe();
|
||||
copyMVSStories();
|
||||
copyDemos();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function copyToRepository() {
|
||||
console.log('\n###', 'copy repository files');
|
||||
fse.copySync(localPath, repositoryPath, { overwrite: true });
|
||||
}
|
||||
|
||||
function syncRepository() {
|
||||
console.log('\n###', 'sync repository');
|
||||
if (!fs.existsSync(path.resolve(repositoryPath, '.git/'))) {
|
||||
console.log('\n###', 'clone repository');
|
||||
git()
|
||||
.outputHandler(log)
|
||||
.clone(remoteUrl, repositoryPath)
|
||||
.fetch(['--all'])
|
||||
.exec(copyToRepository);
|
||||
} else {
|
||||
console.log('\n###', 'update repository');
|
||||
git()
|
||||
.outputHandler(log)
|
||||
.fetch(['--all'])
|
||||
.reset(['--hard', 'origin/master'])
|
||||
.exec(copyToRepository);
|
||||
}
|
||||
}
|
||||
|
||||
function commit() {
|
||||
console.log('\n###', 'commit changes');
|
||||
git()
|
||||
.outputHandler(log)
|
||||
.add(['-A'])
|
||||
.commit(`Updated Apps and Demos
|
||||
- Mol* version: ${VERSION}
|
||||
- MVS Stories version: ${MVS_STORIES_VERSION}`)
|
||||
.push();
|
||||
}
|
||||
|
||||
if (!fs.existsSync(localPath)) {
|
||||
console.log('\n###', 'create localPath');
|
||||
fs.mkdirSync(localPath, { recursive: true });
|
||||
}
|
||||
|
||||
process.chdir(localPath);
|
||||
const argParser = new argparse.ArgumentParser({
|
||||
add_help: true,
|
||||
description: 'Mol* Deploy'
|
||||
});
|
||||
argParser.add_argument('--local',{
|
||||
help: 'Do not commit to remote repository.',
|
||||
required: false,
|
||||
action: 'store_true',
|
||||
});
|
||||
const args = argParser.parse_args();
|
||||
|
||||
if (!fs.existsSync(path.resolve(localPath, '.git/'))) {
|
||||
console.log('\n###', 'clone repository');
|
||||
git()
|
||||
.outputHandler(log)
|
||||
.clone(remoteUrl, localPath)
|
||||
.fetch(['--all'])
|
||||
.exec(copyFiles)
|
||||
.add(['-A'])
|
||||
.commit('updated viewer & demos')
|
||||
.push();
|
||||
} else {
|
||||
console.log('\n###', 'update repository');
|
||||
git()
|
||||
.outputHandler(log)
|
||||
.fetch(['--all'])
|
||||
.reset(['--hard', 'origin/master'])
|
||||
.exec(copyFiles)
|
||||
.add(['-A'])
|
||||
.commit('updated viewer & demos')
|
||||
.push();
|
||||
}
|
||||
copyFiles();
|
||||
|
||||
if (args.local) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(repositoryPath)) {
|
||||
console.log('\n###', 'create repositoryPath');
|
||||
fs.mkdirSync(repositoryPath, { recursive: true });
|
||||
}
|
||||
|
||||
process.chdir(repositoryPath);
|
||||
syncRepository();
|
||||
commit();
|
||||
16
scripts/write-version.mjs
Normal file
16
scripts/write-version.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
|
||||
const VERSION = JSON.parse(fs.readFileSync('./package.json', 'utf8')).version;
|
||||
const TIMESTAMP = Date.now();
|
||||
const file = `export var PLUGIN_VERSION = '${VERSION}';\nexport var PLUGIN_VERSION_DATE = new Date(${TIMESTAMP})`;
|
||||
const files = ['./lib/mol-plugin/version.js', './lib/commonjs/mol-plugin/version.js'];
|
||||
for (const f of files) {
|
||||
if (!fs.existsSync(f)) continue;
|
||||
fs.writeFileSync(f, file);
|
||||
}
|
||||
@@ -1,32 +1,28 @@
|
||||
@use "sass:color";
|
||||
|
||||
$default-background: #2D3E50;
|
||||
$font-color: #EDF1F2;
|
||||
$hover-font-color: #3B9AD9;
|
||||
$entity-current-font-color: #FFFFFF;
|
||||
$msp-btn-remove-background: #BF3A31;
|
||||
$msp-btn-remove-hover-font-color:#ffffff;
|
||||
$msp-btn-commit-on-font-color: #ffffff;
|
||||
$entity-badge-font-color: #ccd4e0;
|
||||
@use '../../mol-plugin-ui/skin/base/colors' with (
|
||||
$default-background: #2D3E50,
|
||||
$font-color: #EDF1F2,
|
||||
$hover-font-color: #3B9AD9,
|
||||
$entity-current-font-color: #FFFFFF,
|
||||
$msp-btn-remove-background: #BF3A31,
|
||||
$msp-btn-remove-hover-font-color:#ffffff,
|
||||
$msp-btn-commit-on-font-color: #ffffff,
|
||||
$entity-badge-font-color: #ccd4e0,
|
||||
|
||||
// used in LOG
|
||||
$log-message: #0CCA5D;
|
||||
$log-info: #5E3673;
|
||||
$log-warning: #FCC937;
|
||||
$log-error: #FD354B;
|
||||
// used in LOG
|
||||
$log-message: #0CCA5D,
|
||||
$log-info: #5E3673,
|
||||
$log-warning: #FCC937,
|
||||
$log-error: #FD354B,
|
||||
|
||||
$logo-background: rgba(0,0,0,0.75);
|
||||
$logo-background: rgba(0,0,0,0.75),
|
||||
|
||||
@function color-lower-contrast($color, $amount) {
|
||||
@return color.adjust($color, $lightness: -$amount, $space: hsl);
|
||||
}
|
||||
$color-adjust-sign: -1,
|
||||
);
|
||||
|
||||
@function color-increase-contrast($color, $amount) {
|
||||
@return color.adjust($color, $lightness: $amount, $space: hsl);
|
||||
}
|
||||
|
||||
@import '../../mol-plugin-ui/skin/base/base';
|
||||
@import '../../mol-plugin-ui/skin/base/variables';
|
||||
@use '../../mol-plugin-ui/skin/base/base';
|
||||
@use '../../mol-plugin-ui/skin/base/vars' as *;
|
||||
|
||||
a {
|
||||
color: $font-color;
|
||||
|
||||
39
src/apps/mvs-stories/context.ts
Normal file
39
src/apps/mvs-stories/context.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { MVSData } from '../../extensions/mvs/mvs-data';
|
||||
import type { MVSStoriesViewerModel } from './elements/viewer';
|
||||
|
||||
export type MVSStoriesCommand =
|
||||
| { kind: 'load-mvs', format?: 'mvsj' | 'mvsx', url?: string, data?: MVSData | string | Uint8Array }
|
||||
|
||||
|
||||
export class MVSStoriesContext {
|
||||
commands = new BehaviorSubject<MVSStoriesCommand | undefined>(undefined);
|
||||
state = {
|
||||
viewers: new BehaviorSubject<{ name?: string, model: MVSStoriesViewerModel }[]>([]),
|
||||
currentStoryData: new BehaviorSubject<string | Uint8Array | undefined>(undefined),
|
||||
isLoading: new BehaviorSubject(false),
|
||||
};
|
||||
|
||||
dispatch(command: MVSStoriesCommand) {
|
||||
this.commands.next(command);
|
||||
}
|
||||
|
||||
constructor(public name?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getMVSStoriesContext(options?: { name?: string, container?: object }): MVSStoriesContext {
|
||||
const container: any = options?.container ?? window;
|
||||
container.componentContexts ??= {};
|
||||
const name = options?.name ?? '<default>';
|
||||
if (!container.componentContexts[name]) {
|
||||
container.componentContexts[name] = new MVSStoriesContext(options?.name);
|
||||
}
|
||||
return container.componentContexts[name];
|
||||
}
|
||||
2
src/apps/mvs-stories/elements/index.ts
Normal file
2
src/apps/mvs-stories/elements/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import './snapshot-markdown';
|
||||
import './viewer';
|
||||
@@ -6,17 +6,17 @@
|
||||
|
||||
import { BehaviorSubject, distinctUntilChanged, map } from 'rxjs';
|
||||
import { PluginComponent } from '../../../mol-plugin-state/component';
|
||||
import { getMolComponentContext, MolComponentContext } from '../context';
|
||||
import { MolComponentViewerModel } from './viewer';
|
||||
import Markdown from 'react-markdown';
|
||||
import { getMVSStoriesContext, MVSStoriesContext } from '../context';
|
||||
import { MVSStoriesViewerModel } from './viewer';
|
||||
import { useBehavior } from '../../../mol-plugin-ui/hooks/use-behavior';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { PluginStateSnapshotManager } from '../../../mol-plugin-state/manager/snapshots';
|
||||
import { MarkdownAnchor } from '../../../mol-plugin-ui/controls';
|
||||
import { PluginReactContext } from '../../../mol-plugin-ui/base';
|
||||
import { CSSProperties } from 'react';
|
||||
import { Markdown } from '../../../mol-plugin-ui/controls/markdown';
|
||||
|
||||
export class MolComponentSnapshotMarkdownModel extends PluginComponent {
|
||||
readonly context: MolComponentContext;
|
||||
export class MVSStoriesSnapshotMarkdownModel extends PluginComponent {
|
||||
readonly context: MVSStoriesContext;
|
||||
root: HTMLElement | undefined = undefined;
|
||||
|
||||
state = new BehaviorSubject<{
|
||||
@@ -26,7 +26,7 @@ export class MolComponentSnapshotMarkdownModel extends PluginComponent {
|
||||
}>({ all: [] });
|
||||
|
||||
get viewer() {
|
||||
return this.context.behavior.viewers.value?.find(v => this.options?.viewerName === v.name);
|
||||
return this.context.state.viewers.value?.find(v => this.options?.viewerName === v.name);
|
||||
}
|
||||
|
||||
sync() {
|
||||
@@ -41,11 +41,11 @@ export class MolComponentSnapshotMarkdownModel extends PluginComponent {
|
||||
async mount(root: HTMLElement) {
|
||||
this.root = root;
|
||||
|
||||
createRoot(root).render(<MolComponentSnapshotMarkdownUI model={this} />);
|
||||
createRoot(root).render(<MVSStoriesSnapshotMarkdownUI model={this} />);
|
||||
|
||||
let currentViewer: MolComponentViewerModel | undefined = undefined;
|
||||
let currentViewer: MVSStoriesViewerModel | undefined = undefined;
|
||||
let sub: { unsubscribe: () => void } | undefined = undefined;
|
||||
this.subscribe(this.context.behavior.viewers.pipe(
|
||||
this.subscribe(this.context.state.viewers.pipe(
|
||||
map(xs => xs.find(v => this.options?.viewerName === v.name)),
|
||||
distinctUntilChanged((a, b) => a?.model === b?.model)
|
||||
), viewer => {
|
||||
@@ -66,21 +66,31 @@ export class MolComponentSnapshotMarkdownModel extends PluginComponent {
|
||||
constructor(private options?: { context?: { name?: string, container?: object }, viewerName?: string }) {
|
||||
super();
|
||||
|
||||
this.context = getMolComponentContext(options?.context);
|
||||
this.context = getMVSStoriesContext(options?.context);
|
||||
}
|
||||
}
|
||||
|
||||
export function MolComponentSnapshotMarkdownUI({ model }: { model: MolComponentSnapshotMarkdownModel }) {
|
||||
export function MVSStoriesSnapshotMarkdownUI({ model }: { model: MVSStoriesSnapshotMarkdownModel }) {
|
||||
const state = useBehavior(model.state);
|
||||
const isLoading = useBehavior(model.context.state.isLoading);
|
||||
|
||||
if (state.all.length === 0) {
|
||||
return <div>
|
||||
<i>No snapshot loaded</i>
|
||||
const style: CSSProperties = { display: 'flex', flexDirection: 'column', height: '100%' };
|
||||
const className = 'mvs-stories-markdown-explanation';
|
||||
|
||||
if (isLoading) {
|
||||
return <div style={style} className={className}>
|
||||
<i>Loading...</i>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', width: '100%', gap: '8px' }} className='mc-snapshot-markdown-header'>
|
||||
if (state.all.length === 0) {
|
||||
return <div style={style} className={className}>
|
||||
<i>No snapshot loaded or no description available</i>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div style={style} className={className}>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', width: '100%', gap: '8px' }}>
|
||||
<span style={{ lineHeight: '38px', minWidth: 60, maxWidth: 60, flexShrink: 0 }}>{typeof state.index === 'number' ? state.index + 1 : '-'}/{state.all.length}</span>
|
||||
<button onClick={() => model.viewer?.model.plugin?.managers.snapshot.applyNext(-1)} style={{ flexGrow: 1, flexShrink: 0 }}>Prev</button>
|
||||
<button onClick={() => model.viewer?.model.plugin?.managers.snapshot.applyNext(1)} style={{ flexGrow: 1, flexShrink: 0 }}>Next</button>
|
||||
@@ -88,18 +98,18 @@ export function MolComponentSnapshotMarkdownUI({ model }: { model: MolComponentS
|
||||
<div style={{ flexGrow: 1, overflow: 'hidden', overflowY: 'auto', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<PluginReactContext.Provider value={model.viewer?.model.plugin as any}>
|
||||
<Markdown skipHtml components={{ a: MarkdownAnchor }}>{state.entry?.description ?? 'Description not available'}</Markdown>
|
||||
<Markdown>{state.entry?.description ?? 'Description not available'}</Markdown>
|
||||
</PluginReactContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export class MolComponentSnapshotMarkdownViewer extends HTMLElement {
|
||||
private model: MolComponentSnapshotMarkdownModel | undefined = undefined;
|
||||
export class MVSStoriesSnapshotMarkdownViewer extends HTMLElement {
|
||||
private model: MVSStoriesSnapshotMarkdownModel | undefined = undefined;
|
||||
|
||||
async connectedCallback() {
|
||||
this.model = new MolComponentSnapshotMarkdownModel({
|
||||
this.model = new MVSStoriesSnapshotMarkdownModel({
|
||||
context: { name: this.getAttribute('context-name') ?? undefined },
|
||||
viewerName: this.getAttribute('viewer-name') ?? undefined,
|
||||
});
|
||||
@@ -116,4 +126,4 @@ export class MolComponentSnapshotMarkdownViewer extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('mc-snapshot-markdown', MolComponentSnapshotMarkdownViewer);
|
||||
window.customElements.define('mvs-stories-snapshot-markdown', MVSStoriesSnapshotMarkdownViewer);
|
||||
@@ -5,20 +5,21 @@
|
||||
*/
|
||||
|
||||
import { MolViewSpec } from '../../../extensions/mvs/behavior';
|
||||
import { loadMVS } from '../../../extensions/mvs/load';
|
||||
import { loadMVSData } from '../../../extensions/mvs/components/formats';
|
||||
import { MVSData } from '../../../extensions/mvs/mvs-data';
|
||||
import { StringLike } from '../../../mol-io/common/string-like';
|
||||
import { PluginComponent } from '../../../mol-plugin-state/component';
|
||||
import { createPluginUI } from '../../../mol-plugin-ui';
|
||||
import { renderReact18 } from '../../../mol-plugin-ui/react18';
|
||||
import { DefaultPluginUISpec } from '../../../mol-plugin-ui/spec';
|
||||
import { PluginCommands } from '../../../mol-plugin/commands';
|
||||
import { PluginConfig } from '../../../mol-plugin/config';
|
||||
import { PluginContext } from '../../../mol-plugin/context';
|
||||
import { PluginSpec } from '../../../mol-plugin/spec';
|
||||
import { getMolComponentContext, MolComponentContext } from '../context';
|
||||
import { getMVSStoriesContext, MVSStoriesContext } from '../context';
|
||||
|
||||
export class MolComponentViewerModel extends PluginComponent {
|
||||
readonly context: MolComponentContext;
|
||||
export class MVSStoriesViewerModel extends PluginComponent {
|
||||
readonly context: MVSStoriesContext;
|
||||
plugin?: PluginContext = undefined;
|
||||
|
||||
async mount(root: HTMLElement) {
|
||||
@@ -52,36 +53,52 @@ export class MolComponentViewerModel extends PluginComponent {
|
||||
});
|
||||
|
||||
this.subscribe(this.context.commands, async (cmd) => {
|
||||
if (!cmd) return;
|
||||
if (!cmd || !this.plugin) return;
|
||||
|
||||
if (cmd.kind === 'load-mvs') {
|
||||
if (cmd.url) {
|
||||
const data = await this.plugin!.runTask(this.plugin!.fetch({ url: cmd.url, type: 'string' }));
|
||||
const mvsData = MVSData.fromMVSJ(StringLike.toString(data));
|
||||
await loadMVS(this.plugin!, mvsData, { sanityChecks: true, sourceUrl: cmd.url });
|
||||
} else if (cmd.data) {
|
||||
await loadMVS(this.plugin!, cmd.data, { sanityChecks: true });
|
||||
try {
|
||||
this.context.state.isLoading.next(true);
|
||||
if (cmd.kind === 'load-mvs') {
|
||||
let loadedData: MVSData | StringLike | Uint8Array | undefined;
|
||||
if (cmd.url) {
|
||||
const data = await this.plugin.runTask(this.plugin.fetch({ url: cmd.url, type: cmd.format === 'mvsx' ? 'binary' : 'string' }));
|
||||
loadedData = await loadMVSData(this.plugin, data, cmd.format ?? 'mvsj', { sourceUrl: cmd.url });
|
||||
} else if (cmd.data) {
|
||||
loadedData = await loadMVSData(this.plugin, cmd.data, cmd.format ?? 'mvsj');
|
||||
}
|
||||
if (StringLike.is(loadedData) || loadedData instanceof Uint8Array) {
|
||||
this.context.state.currentStoryData.next(loadedData as string | Uint8Array);
|
||||
} else if (loadedData) {
|
||||
this.context.state.currentStoryData.next(JSON.stringify(loadedData));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
PluginCommands.Toast.Show(
|
||||
this.plugin,
|
||||
{ key: '<mvsload>', title: 'Error', message: e?.message ? `${e?.message}` : `${e}`, timeoutMs: 10000 }
|
||||
);
|
||||
} finally {
|
||||
this.context.state.isLoading.next(false);
|
||||
}
|
||||
});
|
||||
|
||||
const viewers = this.context.behavior.viewers.value;
|
||||
const viewers = this.context.state.viewers.value;
|
||||
const next = [...viewers, { name: this.options?.name, model: this }];
|
||||
this.context.behavior.viewers.next(next);
|
||||
this.context.state.viewers.next(next);
|
||||
}
|
||||
|
||||
constructor(private options?: { context?: { name?: string, container?: object }, name?: string }) {
|
||||
super();
|
||||
|
||||
this.context = getMolComponentContext(options?.context);
|
||||
this.context = getMVSStoriesContext(options?.context);
|
||||
|
||||
const viewers = this.context.behavior.viewers.value;
|
||||
const viewers = this.context.state.viewers.value;
|
||||
const index = viewers.findIndex(v => v.name === options?.name);
|
||||
if (index >= 0) {
|
||||
const next = [...viewers];
|
||||
next[index].model.dispose();
|
||||
next.splice(index, 0);
|
||||
this.context.behavior.viewers.next(next);
|
||||
this.context.state.viewers.next(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,11 +107,11 @@ function EmptyDescription() {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export class MolComponentViewer extends HTMLElement {
|
||||
private model: MolComponentViewerModel | undefined = undefined;
|
||||
export class MVSStoriesViewer extends HTMLElement {
|
||||
private model: MVSStoriesViewerModel | undefined = undefined;
|
||||
|
||||
async connectedCallback() {
|
||||
this.model = new MolComponentViewerModel({
|
||||
this.model = new MVSStoriesViewerModel({
|
||||
name: this.getAttribute('name') ?? undefined,
|
||||
context: { name: this.getAttribute('context-name') ?? undefined },
|
||||
});
|
||||
@@ -111,4 +128,4 @@ export class MolComponentViewer extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('mc-viewer', MolComponentViewer);
|
||||
window.customElements.define('mvs-stories-viewer', MVSStoriesViewer);
|
||||
BIN
src/apps/mvs-stories/favicon.ico
Normal file
BIN
src/apps/mvs-stories/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
121
src/apps/mvs-stories/index.html
Normal file
121
src/apps/mvs-stories/index.html
Normal file
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
|
||||
<link rel="icon" href="./favicon.ico" type="image/x-icon">
|
||||
<title>Molecular Stories</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#viewer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 34%;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
#controls {
|
||||
position: absolute;
|
||||
left: 66%;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 16px;
|
||||
padding-bottom: 20px;
|
||||
border: 1px solid #ccc;
|
||||
border-left: none;
|
||||
background: #F6F5F3;
|
||||
z-index: -2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#links {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 8px;
|
||||
font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 0.6rem;
|
||||
z-index: -1;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
#links a {
|
||||
color: #666;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (orientation:portrait) {
|
||||
#viewer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 40%;
|
||||
}
|
||||
|
||||
#controls {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 60%;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.msp-viewport-controls-buttons {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" type="text/css" href="mvs-stories.css" />
|
||||
<script type="text/javascript" src="mvs-stories.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- the context-name parameter is optional and useful when embedding multiple stories in a single page -->
|
||||
<div id="viewer">
|
||||
<mvs-stories-viewer context-name="story1" ></mvs-stories-viewer>
|
||||
</div>
|
||||
<div id="controls">
|
||||
<mvs-stories-snapshot-markdown context-name="story1" style="flex-grow: 1;" ></mvs-stories-snapshot-markdown>
|
||||
</div>
|
||||
|
||||
<div id="links">
|
||||
<a href="#" id="mvs-data">Download MVS State</a> | <a href="https://github.com/molstar/molstar/tree/master/src/apps/mvs-stories" id="mvs-data" target="_blank" rel="noopener noreferrer">Source Code</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var urlParams = new URLSearchParams(window.location.search);
|
||||
var storyId = urlParams.get('story-id');
|
||||
var storyUrl = urlParams.get('story-url');
|
||||
var format = urlParams.get('data-format');
|
||||
|
||||
// For testing purposes:
|
||||
// if (!storyUrl) {
|
||||
// storyUrl = 'https://raw.githubusercontent.com/molstar/molstar/master/examples/mvs/kinase-story.mvsj';
|
||||
// }
|
||||
|
||||
if (storyId) {
|
||||
mvsStories.loadFromID(storyId, { format: format || 'mvsj', contextName: 'story1' });
|
||||
} else if (storyUrl) {
|
||||
mvsStories.loadFromURL(storyUrl, { format: format || 'mvsj', contextName: 'story1' });
|
||||
}
|
||||
|
||||
document.getElementById('mvs-data').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
mvsStories.downloadCurrentStory({ contextName: 'story1' });
|
||||
});
|
||||
</script>
|
||||
<!-- __MOLSTAR_ANALYTICS__ -->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
64
src/apps/mvs-stories/index.tsx
Normal file
64
src/apps/mvs-stories/index.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { getMVSStoriesContext } from './context';
|
||||
import './elements';
|
||||
import { MVSData } from '../../extensions/mvs/mvs-data';
|
||||
import { download } from '../../mol-util/download';
|
||||
|
||||
import './favicon.ico';
|
||||
import '../../mol-plugin-ui/skin/light.scss';
|
||||
import './styles.scss';
|
||||
import './index.html';
|
||||
|
||||
export function getContext(name?: string) {
|
||||
return getMVSStoriesContext({ name });
|
||||
}
|
||||
|
||||
export function loadFromURL(url: string, options?: { format: 'mvsx' | 'mvsj', contextName?: string }) {
|
||||
setTimeout(() => {
|
||||
getContext(options?.contextName).dispatch({
|
||||
kind: 'load-mvs',
|
||||
format: options?.format ?? 'mvsj',
|
||||
url,
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function loadFromData(data: MVSData | string | Uint8Array, options?: { format: 'mvsx' | 'mvsj', contextName?: string }) {
|
||||
setTimeout(() => {
|
||||
getContext(options?.contextName).dispatch({
|
||||
kind: 'load-mvs',
|
||||
format: options?.format ?? 'mvsj',
|
||||
data,
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function getStoryUrlFromId(id: string, format: 'mvsx' | 'mvsj' = 'mvsj') {
|
||||
return `https://stories.molstar.org/api/story/${id}/data`;
|
||||
}
|
||||
|
||||
export function loadFromID(id: string, options?: { format?: 'mvsx' | 'mvsj', contextName?: string }) {
|
||||
loadFromURL(
|
||||
getStoryUrlFromId(id, options?.format),
|
||||
{ format: options?.format ?? 'mvsj', contextName: options?.contextName },
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadCurrentStory(options?: { contextName?: string, filename?: string }) {
|
||||
const story = getContext(options?.contextName).state.currentStoryData.value;
|
||||
if (!story) return;
|
||||
|
||||
const isMVSJ = typeof story === 'string';
|
||||
const filename = `${options?.filename ?? 'story'}.${isMVSJ ? 'mvsj' : 'mvsx'}`;
|
||||
download(
|
||||
new Blob([typeof story === 'string' ? story : story.buffer], { type: isMVSJ ? 'application/json' : 'application/octet-stream' }),
|
||||
filename
|
||||
);
|
||||
};
|
||||
|
||||
export { MVSData };
|
||||
66
src/apps/mvs-stories/readme.md
Normal file
66
src/apps/mvs-stories/readme.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# MolViewSpec Stories App
|
||||
|
||||
An app that defines `mvs-stories-snapshot-markdown` and `mvs-stories-viewer` web components that can be used to view MolViewSpec molecular stories.
|
||||
|
||||
See the [mvs-stories](../../examples/mvs-stories) example that includes specific stories.
|
||||
|
||||
### Usage
|
||||
|
||||
- Get `mvs-stories.css` and `mvs-stories.js` from `build/mvs-stories` and include these to your HTML page
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" type="text/css" href="mvs-stories.css" />
|
||||
<script type="text/javascript" src="mvs-stories.js"></script>
|
||||
```
|
||||
|
||||
Can also use `https://cdn.jsdelivr.net/npm/molstar@latest/build/mvs-stories/mvs-stories.js` (and `.css`). `latest` can be substituted by specific version.
|
||||
|
||||
- Place the components in your page wrapper in `<div>` elements to set up positioning:
|
||||
|
||||
```html
|
||||
<div class="viewer">
|
||||
<mvs-stories-viewer />
|
||||
</div>
|
||||
<div class="snapshot">
|
||||
<mvs-stories-snapshot-markdown />
|
||||
</div>
|
||||
```
|
||||
|
||||
- Load MolViewSpec state:
|
||||
|
||||
```html
|
||||
<script>
|
||||
mvsStories.loadFromURL('https://raw.githubusercontent.com/molstar/molstar/master/examples/mvs/1cbs.mvsj');
|
||||
</script>
|
||||
```
|
||||
|
||||
- See [index.html](./index.html) for full example of how to embed the app.
|
||||
|
||||
- For interactive development build (for production use `npm run build`) of the example that immediately reflects changes use:
|
||||
|
||||
```bash
|
||||
npm run dev -- -a mvs-stories
|
||||
```
|
||||
|
||||
### Multiple Stories on a Single Page
|
||||
|
||||
To support multiple instances of stories, use the `context-name='unique-name'` attribute on the `mvs-` components together with `loadFromURL/Data(..., { contextName: 'unique-name' })`.
|
||||
|
||||
For example (simplified to not include layout):
|
||||
|
||||
```html
|
||||
<div>
|
||||
<mvs-stories-viewer context-name="1" />
|
||||
<mvs-stories-snapshot-markdown context-name="1" />
|
||||
</div>
|
||||
<div>
|
||||
<mvs-stories-viewer context-name="2" />
|
||||
<mvs-stories-snapshot-markdown context-name="2" />
|
||||
</div>
|
||||
|
||||
<script>
|
||||
mvsStories.loadFromURL('1.mvsj', { format: 'mvsj', contextName: '1' });
|
||||
mvsStories.loadFromURL('2.mvsj', { format: 'mvsj', contextName: '2' });
|
||||
</script>
|
||||
|
||||
```
|
||||
@@ -1,22 +1,6 @@
|
||||
.select-story {
|
||||
select {
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
height: 38px;
|
||||
padding: 0 8px;
|
||||
color: #555;
|
||||
line-height: 38px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
background-color: transparent;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #bbb;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
@use '../../mol-plugin-ui/skin/base/components/markdown.scss';
|
||||
|
||||
.markdown-explanation {
|
||||
.mvs-stories-markdown-explanation {
|
||||
// Adapted from skeleton.css, The MIT License (MIT), Copyright (c) 2011-2014 Dave Gamache
|
||||
line-height: 1.4;
|
||||
font-weight: 400;
|
||||
@@ -179,4 +163,33 @@
|
||||
border-width: 0;
|
||||
border-top: 1px solid #E1E1E1;
|
||||
}
|
||||
|
||||
table {
|
||||
border: 1px solid #E1E1E1;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #E1E1E1;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation:portrait) {
|
||||
.mvs-stories-markdown-explanation {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mvs-stories-markdown-explanation h3 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
7
src/apps/mvs-stories/version.ts
Normal file
7
src/apps/mvs-stories/version.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
export const VERSION = 1;
|
||||
@@ -17,7 +17,7 @@ import { QualityAssessment } from '../../extensions/model-archive/quality-assess
|
||||
import { ModelExport } from '../../extensions/model-export';
|
||||
import { Mp4Export } from '../../extensions/mp4-export';
|
||||
import { MolViewSpec } from '../../extensions/mvs/behavior';
|
||||
import { loadMVSX } from '../../extensions/mvs/components/formats';
|
||||
import { loadMVSData, loadMVSX } from '../../extensions/mvs/components/formats';
|
||||
import { loadMVS, MolstarLoadingExtension } from '../../extensions/mvs/load';
|
||||
import { MVSData } from '../../extensions/mvs/mvs-data';
|
||||
import { PDBeStructureQualityReport } from '../../extensions/pdbe';
|
||||
@@ -536,27 +536,8 @@ export class Viewer {
|
||||
/** 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,'. */
|
||||
async loadMvsData(data: string | Uint8Array, format: 'mvsj' | 'mvsx', options?: { appendSnapshots?: boolean, keepCamera?: boolean, extensions?: MolstarLoadingExtension<any>[] }) {
|
||||
if (typeof data === 'string' && data.startsWith('base64')) {
|
||||
data = Uint8Array.from(atob(data.substring(7)), c => c.charCodeAt(0)); // Decode base64 string to Uint8Array
|
||||
}
|
||||
if (format === 'mvsj') {
|
||||
if (typeof data !== 'string') {
|
||||
data = new TextDecoder().decode(data); // Decode Uint8Array to string using UTF8
|
||||
}
|
||||
const mvsData = MVSData.fromMVSJ(data);
|
||||
await loadMVS(this.plugin, mvsData, { sanityChecks: true, sourceUrl: undefined, ...options });
|
||||
} else if (format === 'mvsx') {
|
||||
if (typeof data === 'string') {
|
||||
throw new Error("loadMvsData: if `format` is 'mvsx', then `data` must be a Uint8Array or a base64-encoded string prefixed with 'base64,'.");
|
||||
}
|
||||
await this.plugin.runTask(Task.create('Load MVSX file', async ctx => {
|
||||
const parsed = await loadMVSX(this.plugin, ctx, data as Uint8Array);
|
||||
await loadMVS(this.plugin, parsed.mvsData, { sanityChecks: true, sourceUrl: parsed.sourceUrl, ...options });
|
||||
}));
|
||||
} else {
|
||||
throw new Error(`Unknown MolViewSpec format: ${format}`);
|
||||
}
|
||||
loadMvsData(data: string | Uint8Array, format: 'mvsj' | 'mvsx', options?: { appendSnapshots?: boolean, keepCamera?: boolean, extensions?: MolstarLoadingExtension<any>[] }) {
|
||||
return loadMVSData(this.plugin, data, format, options);
|
||||
}
|
||||
|
||||
loadFiles(files: File[]) {
|
||||
@@ -641,7 +622,7 @@ export const ViewerAutoPreset = StructureRepresentationPresetProvider({
|
||||
|
||||
export const PluginExtensions = {
|
||||
wwPDBStructConn: wwPDBStructConnExtensionFunctions,
|
||||
mvs: { MVSData, loadMVS },
|
||||
mvs: { MVSData, loadMVS, loadMVSData },
|
||||
modelArchive: {
|
||||
qualityAssessment: {
|
||||
config: MAQualityAssessmentConfig
|
||||
|
||||
@@ -43,7 +43,7 @@ function paramInfo(param: PD.Any, offset: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
function oToS(options: readonly (readonly [string, string] | readonly [string, string, string | undefined])[]) {
|
||||
function oToS(options: readonly PD.SelectOption<any>[]) {
|
||||
return options.map(o => `'${o[0]}'`).join(', ');
|
||||
}
|
||||
|
||||
|
||||
@@ -297,11 +297,10 @@ async function loadTestAllExample(plugin: PluginContext) {
|
||||
basic('weak-hydrogen-bond', 7),
|
||||
basic('hydrophobic', 8),
|
||||
basic('metal-coordination', 9),
|
||||
basic('salt-bridge', 10),
|
||||
covalent(1, 11),
|
||||
covalent(2, 12),
|
||||
covalent(3, 13),
|
||||
covalent(-1, 14), // aromatic
|
||||
covalent(1, 10),
|
||||
covalent(2, 11),
|
||||
covalent(3, 12),
|
||||
covalent(-1, 13), // aromatic
|
||||
basic('unknown', [0, 1, 2, 3, 13, 14], 'Testing centroid for atom set'),
|
||||
]
|
||||
}, { dependsOn: refs });
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { MVSData } from '../../extensions/mvs/mvs-data';
|
||||
import type { MolComponentViewerModel } from './elements/viewer';
|
||||
|
||||
export type MolComponentCommand =
|
||||
| { kind: 'load-mvs', format?: 'mvsj' | 'mvsx', url?: string, data?: MVSData }
|
||||
|
||||
|
||||
export class MolComponentContext {
|
||||
commands = new BehaviorSubject<MolComponentCommand | undefined>(undefined);
|
||||
behavior = {
|
||||
viewers: new BehaviorSubject<{ name?: string, model: MolComponentViewerModel }[]>([]),
|
||||
};
|
||||
|
||||
dispatch(command: MolComponentCommand) {
|
||||
this.commands.next(command);
|
||||
}
|
||||
|
||||
constructor(public name?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getMolComponentContext(options?: { name?: string, container?: object }) {
|
||||
const container: any = options?.container ?? window;
|
||||
container.componentContexts ??= {};
|
||||
const name = options?.name ?? '<default>';
|
||||
if (!container.componentContexts[name]) {
|
||||
container.componentContexts[name] = new MolComponentContext(options?.name);
|
||||
}
|
||||
return container.componentContexts[name];
|
||||
}
|
||||
BIN
src/examples/mvs-stories/favicon.ico
Normal file
BIN
src/examples/mvs-stories/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -4,6 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
|
||||
<link rel="icon" href="./favicon.ico" type="image/x-icon">
|
||||
<title>Molecular Stories</title>
|
||||
<style>
|
||||
* {
|
||||
@@ -70,35 +71,41 @@
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.markdown-explanation {
|
||||
font-size: 0.9rem !important;
|
||||
}
|
||||
|
||||
.markdown-explanation h3 {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
|
||||
.msp-viewport-controls-buttons {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.select-story select {
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
height: 38px;
|
||||
padding: 0 8px;
|
||||
color: #555;
|
||||
line-height: 38px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
background-color: transparent;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #bbb;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" type="text/css" href="molstar.css" />
|
||||
<script type="text/javascript" src="./index.js"></script>
|
||||
<script type="text/javascript" src="index.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="viewer">
|
||||
<mc-viewer name="v1" />
|
||||
<mvs-stories-viewer></mvs-stories-viewer>
|
||||
</div>
|
||||
<div id="controls">
|
||||
<div id="select-story" class="select-story"></div>
|
||||
<div class="markdown-explanation" style="flex-grow: 1;">
|
||||
<mc-snapshot-markdown viewer-name="v1" />
|
||||
</div>
|
||||
<mvs-stories-snapshot-markdown style="flex-grow: 1;"></mvs-stories-snapshot-markdown>
|
||||
</div>
|
||||
<div id="links">
|
||||
<a href="#" id="mvs-data" filename="kinase-story.mvsj">Download MVS State</a> | <a href="https://github.com/molstar/molstar/tree/master/src/examples/mvs-stories" id="mvs-data" target="_blank" rel="noopener noreferrer">Source Code</a>
|
||||
<a href="#" id="mvs-data">Download MVS State</a> | <a href="https://github.com/molstar/molstar/tree/master/src/examples/mvs-stories" id="mvs-data" target="_blank" rel="noopener noreferrer">Source Code</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -111,6 +118,7 @@
|
||||
window.initStories();
|
||||
}, 0);
|
||||
</script>
|
||||
<!-- __MOLSTAR_ANALYTICS__ -->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -4,26 +4,23 @@
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { getMolComponentContext } from './context';
|
||||
import './index.html';
|
||||
import './elements/snapshot-markdown';
|
||||
import './elements/viewer';
|
||||
import '../../mol-plugin-ui/skin/light.scss';
|
||||
import './styles.scss';
|
||||
import { download } from '../../mol-util/download';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { Stories } from './stories';
|
||||
import { useBehavior } from '../../mol-plugin-ui/hooks/use-behavior';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { getMVSStoriesContext } from '../../apps/mvs-stories/context';
|
||||
import '../../apps/mvs-stories/elements';
|
||||
|
||||
export class MolComponents {
|
||||
getContext(name?: string) {
|
||||
return getMolComponentContext({ name });
|
||||
}
|
||||
import './favicon.ico';
|
||||
import '../../mol-plugin-ui/skin/light.scss';
|
||||
import '../../apps/mvs-stories/styles.scss';
|
||||
import './index.html';
|
||||
|
||||
function getContext(name?: string) {
|
||||
return getMVSStoriesContext({ name });
|
||||
}
|
||||
|
||||
const MC = new MolComponents();
|
||||
|
||||
type Story = { kind: 'built-in', id: string } | { kind: 'url', url: string, format: 'mvsx' | 'mvsj' } | undefined;
|
||||
const CurrentStory = new BehaviorSubject<Story>(undefined);
|
||||
|
||||
@@ -50,7 +47,7 @@ function init() {
|
||||
history.replaceState({}, '', '');
|
||||
} else if (story.kind === 'url') {
|
||||
history.replaceState({}, '', story ? `?story-url=${encodeURIComponent(story.url)}&data-format=${story.format}` : '');
|
||||
MC.getContext().dispatch({
|
||||
getContext().dispatch({
|
||||
kind: 'load-mvs',
|
||||
format: story.format,
|
||||
url: story.url,
|
||||
@@ -59,7 +56,7 @@ function init() {
|
||||
history.replaceState({}, '', story ? `?story=${story.id}` : '');
|
||||
const s = Stories.find(s => s.id === story.id);
|
||||
if (s) {
|
||||
MC.getContext().dispatch({
|
||||
getContext().dispatch({
|
||||
kind: 'load-mvs',
|
||||
data: s.buildStory(),
|
||||
});
|
||||
@@ -86,14 +83,13 @@ function init() {
|
||||
createRoot(document.getElementById('select-story')!).render(<SelectStoryUI subject={CurrentStory} />);
|
||||
}
|
||||
|
||||
(window as any).mc = MC;
|
||||
(window as any).downloadStory = () => {
|
||||
if (CurrentStory.value?.kind !== 'built-in') return;
|
||||
const id = CurrentStory.value.id;
|
||||
const story = Stories.find(s => s.id === id);
|
||||
if (!story) return;
|
||||
const data = JSON.stringify(story.buildStory(), null, 2);
|
||||
download(new Blob([data], { type: 'application/json' }), 'story.mvsj');
|
||||
download(new Blob([data], { type: 'application/json' }), `${id}-story.mvsj`);
|
||||
};
|
||||
(window as any).initStories = init;
|
||||
(window as any).CurrentStory = CurrentStory;
|
||||
@@ -1,10 +1,8 @@
|
||||
# MolViewSpec Stories Example
|
||||
|
||||
This example illustrates:
|
||||
This example illustrates using the `mvs-stories` app to tell molecular stories built with MolViewSpec.
|
||||
|
||||
- Using MolViewSpec to tell a story
|
||||
- A proof of concept for separating Mol* into a ready-to-use web component library.
|
||||
- Ability to load MVS states
|
||||
See the [mvs-stories](../../apps/mvs-stories) app for more info about how to use this app separately.
|
||||
|
||||
### Usage
|
||||
|
||||
@@ -16,39 +14,7 @@ This example illustrates:
|
||||
npm build
|
||||
```
|
||||
|
||||
- Get `molstar.css` and `index.js` from `build/examples/mvs-stories` and include these to your HTML page
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" type="text/css" href="molstar.css" />
|
||||
<script type="text/javascript" src="index.js"></script>
|
||||
```
|
||||
|
||||
- Plate the components in your page wrapper in `<div>` elements to set up positioning:
|
||||
|
||||
```html
|
||||
<div class="viewer">
|
||||
<mc-viewer name="v1" />
|
||||
</div>
|
||||
<div class="snapshot">
|
||||
<mc-snapshot-markdown viewer-name="v1" />
|
||||
</div>
|
||||
```
|
||||
|
||||
- Load MolViewSpec state:
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.mc.getContext().dispatch({
|
||||
kind: 'load-mvs',
|
||||
format: 'mvsj',
|
||||
url: 'https://path/to/file.mvsj',
|
||||
// or provide data directly
|
||||
// data: mvsJSON
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
See [index.html](./index.html) for a full example.
|
||||
- See [index.html](./index.html) for example usage.
|
||||
|
||||
- For interactive development build (for production use `npm run build`) of the example that immediately reflects changes use:
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ export interface CubeGrid {
|
||||
|
||||
export type CubeGridFormat = ModelFormat<CubeGrid>;
|
||||
|
||||
// eslint-disable-next-line
|
||||
export function CubeGridFormat(grid: CubeGrid): CubeGridFormat {
|
||||
return { name: 'custom grid', kind: 'cube-grid', data: grid };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2020-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
@@ -18,7 +18,7 @@ import { StateTransformer } from '../../mol-state';
|
||||
import { VolumeRepresentation3DHelpers } from '../../mol-plugin-state/transforms/representation';
|
||||
import { AlphaOrbital, Basis, CubeGrid, CubeGridFormat, isCubeGridData } from './data-model';
|
||||
import { createSphericalCollocationDensityGrid } from './density';
|
||||
import { Tensor } from '../../mol-math/linear-algebra';
|
||||
import { Mat4, Tensor } from '../../mol-math/linear-algebra';
|
||||
import { Theme } from '../../mol-theme/theme';
|
||||
|
||||
export class BasisAndOrbitals extends PluginStateObject.Create<{ basis: Basis, order: SphericalBasisOrder, orbitals: AlphaOrbital[] }>({ name: 'Basis', typeClass: 'Object' }) { }
|
||||
@@ -114,6 +114,7 @@ export const CreateOrbitalVolume = PluginStateTransform.BuiltIn({
|
||||
}, a.data.orbitals[params.index], plugin.canvas3d?.webgl).runInContext(ctx);
|
||||
const volume: Volume = {
|
||||
grid: data.grid,
|
||||
instances: [{ transform: Mat4.identity() }],
|
||||
sourceData: CubeGridFormat(data),
|
||||
customProperties: new CustomProperties(),
|
||||
_propertyData: Object.create(null),
|
||||
@@ -146,6 +147,7 @@ export const CreateOrbitalDensityVolume = PluginStateTransform.BuiltIn({
|
||||
}, a.data.orbitals, plugin.canvas3d?.webgl).runInContext(ctx);
|
||||
const volume: Volume = {
|
||||
grid: data.grid,
|
||||
instances: [{ transform: Mat4.identity() }],
|
||||
sourceData: CubeGridFormat(data),
|
||||
customProperties: new CustomProperties(),
|
||||
_propertyData: Object.create(null),
|
||||
|
||||
@@ -47,11 +47,12 @@ export const ConfalPyramidsPreset = StructureRepresentationPresetProvider({
|
||||
}
|
||||
});
|
||||
|
||||
const RemoveNewline = /\r?\n/g;
|
||||
export function confalPyramidLabel(step: DnatcoTypes.Step) {
|
||||
return `
|
||||
<b>${step.auth_asym_id_1}</b> |
|
||||
<b>${step.label_comp_id_1} ${step.auth_seq_id_1}${step.PDB_ins_code_1}${step.label_alt_id_1.length > 0 ? ` (alt ${step.label_alt_id_1})` : ''}
|
||||
${step.label_comp_id_2} ${step.auth_seq_id_2}${step.PDB_ins_code_2}${step.label_alt_id_2.length > 0 ? ` (alt ${step.label_alt_id_2})` : ''} </b><br />
|
||||
<i>NtC:</i> ${step.NtC} | <i>Confal score:</i> ${step.confal_score} | <i>RMSD:</i> ${step.rmsd.toFixed(2)}
|
||||
`;
|
||||
`.replace(RemoveNewline, '');
|
||||
}
|
||||
|
||||
@@ -47,11 +47,12 @@ export const NtCTubePreset = StructureRepresentationPresetProvider({
|
||||
}
|
||||
});
|
||||
|
||||
const RemoveNewline = /\r?\n/g;
|
||||
export function NtCTubeSegmentLabel(step: DnatcoTypes.Step) {
|
||||
return `
|
||||
<b>${step.auth_asym_id_1}</b> |
|
||||
<b>${step.label_comp_id_1} ${step.auth_seq_id_1}${step.PDB_ins_code_1}${step.label_alt_id_1.length > 0 ? ` (alt ${step.label_alt_id_1})` : ''}
|
||||
${step.label_comp_id_2} ${step.auth_seq_id_2}${step.PDB_ins_code_2}${step.label_alt_id_2.length > 0 ? ` (alt ${step.label_alt_id_2})` : ''} </b><br />
|
||||
<i>NtC:</i> ${step.NtC} | <i>Confal score:</i> ${step.confal_score} | <i>RMSD:</i> ${step.rmsd.toFixed(2)}
|
||||
`;
|
||||
`.replace(RemoveNewline, '');
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ export type InteractionElementSchema =
|
||||
| { kind: 'weak-hydrogen-bond' } & InteractionElementSchemaBase
|
||||
| { kind: 'hydrophobic' } & InteractionElementSchemaBase
|
||||
| { kind: 'metal-coordination' } & InteractionElementSchemaBase
|
||||
| { kind: 'salt-bridge' } & InteractionElementSchemaBase
|
||||
| { kind: 'covalent', degree?: 'aromatic' | 1 | 2 | 3 | 4 } & InteractionElementSchemaBase
|
||||
|
||||
export type InteractionKind = InteractionElementSchema['kind']
|
||||
@@ -40,7 +39,6 @@ export const InteractionKinds: InteractionKind[] = [
|
||||
'weak-hydrogen-bond',
|
||||
'hydrophobic',
|
||||
'metal-coordination',
|
||||
'salt-bridge',
|
||||
'covalent',
|
||||
];
|
||||
|
||||
@@ -54,7 +52,6 @@ export type InteractionInfo =
|
||||
| { kind: 'weak-hydrogen-bond', hydrogenStructureRef?: string, hydrogen?: StructureElement.Loci }
|
||||
| { kind: 'hydrophobic' }
|
||||
| { kind: 'metal-coordination' }
|
||||
| { kind: 'salt-bridge' }
|
||||
| { kind: 'covalent', degree?: 'aromatic' | 1 | 2 | 3 | 4 }
|
||||
|
||||
export interface StructureInteractionElement {
|
||||
|
||||
@@ -47,7 +47,6 @@ export const InteractionVisualParams = {
|
||||
'weak-hydrogen-bond': hydrogenVisualParams({ color: Color(0x0) }),
|
||||
'hydrophobic': visualParams({ color: Color(0x555555) }),
|
||||
'metal-coordination': visualParams({ color: Color(0x952e8f) }),
|
||||
'salt-bridge': visualParams({ color: Color(0xF54029) }),
|
||||
'covalent': PD.Group({
|
||||
color: PD.Color(Color(0x999999)),
|
||||
radius: PD.Numeric(0.1, { min: 0.01, max: 1, step: 0.01 }),
|
||||
|
||||
@@ -12,9 +12,10 @@ import { LociLabelProvider } from '../../mol-plugin-state/manager/loci-label';
|
||||
import { PluginBehavior } from '../../mol-plugin/behavior/behavior';
|
||||
import { PluginContext } from '../../mol-plugin/context';
|
||||
import { StructureRepresentationProvider } from '../../mol-repr/structure/representation';
|
||||
import { StateAction } from '../../mol-state';
|
||||
import { StateAction, StateObjectCell, StateTree } from '../../mol-state';
|
||||
import { Task } from '../../mol-task';
|
||||
import { ColorTheme } from '../../mol-theme/color';
|
||||
import { fileToDataUri } from '../../mol-util/file';
|
||||
import { ParamDefinition as PD } from '../../mol-util/param-definition';
|
||||
import { MVSAnnotationColorThemeProvider } from './components/annotation-color-theme';
|
||||
import { MVSAnnotationLabelRepresentationProvider } from './components/annotation-label/representation';
|
||||
@@ -109,6 +110,39 @@ export const MolViewSpec = PluginBehavior.create<{ autoAttach: boolean }>({
|
||||
for (const action of this.registrables.actions ?? []) {
|
||||
this.ctx.state.data.actions.add(action);
|
||||
}
|
||||
|
||||
this.ctx.managers.markdownExtensions.registerRefResolver('mvs', (plugin, refs) => {
|
||||
const mvsRefs = new Set(refs.map(ref => `mvs-ref:${ref}`));
|
||||
return StateTree.doPreOrder(
|
||||
plugin.state.data.tree,
|
||||
plugin.state.data.tree.root,
|
||||
{ mvsRefs, plugin, cells: [] as StateObjectCell[] },
|
||||
(n, _, s) => {
|
||||
if (!n.tags) return;
|
||||
for (const tag of n.tags) {
|
||||
if (!s.mvsRefs.has(tag)) continue;
|
||||
const cell = s.plugin.state.data.cells.get(n.ref);
|
||||
if (cell) {
|
||||
s.cells.push(cell);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}).cells;
|
||||
});
|
||||
|
||||
this.ctx.managers.markdownExtensions.registerUriResolver('mvs', (plugin, uri) => {
|
||||
const { assets } = plugin.managers.asset;
|
||||
const asset = assets.find(a => a.file.name === uri);
|
||||
if (!asset) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return fileToDataUri(asset.file);
|
||||
} catch (e) {
|
||||
console.error(`MVS: Failed to convert asset file to data URI for '${uri}'`, e);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
update(p: { autoAttach: boolean }) {
|
||||
const updated = this.params.autoAttach !== p.autoAttach;
|
||||
@@ -146,6 +180,7 @@ export const MolViewSpec = PluginBehavior.create<{ autoAttach: boolean }>({
|
||||
for (const action of this.registrables.actions ?? []) {
|
||||
this.ctx.state.data.actions.remove(action);
|
||||
}
|
||||
this.ctx.managers.markdownExtensions.removeRefResolver('mvs');
|
||||
}
|
||||
},
|
||||
params: () => ({
|
||||
|
||||
@@ -7,17 +7,22 @@
|
||||
|
||||
import { Camera } from '../../mol-canvas3d/camera';
|
||||
import { Canvas3DParams, Canvas3DProps } from '../../mol-canvas3d/canvas3d';
|
||||
import { OutlineParams } from '../../mol-canvas3d/passes/outline';
|
||||
import { ShadowParams } from '../../mol-canvas3d/passes/shadow';
|
||||
import { SsaoParams } from '../../mol-canvas3d/passes/ssao';
|
||||
import { Vec3 } from '../../mol-math/linear-algebra';
|
||||
import { getFocusSnapshot, getPluginBoundingSphere } from '../../mol-plugin-state/manager/focus-camera/focus-object';
|
||||
import { PluginCommands } from '../../mol-plugin/commands';
|
||||
import { PluginContext } from '../../mol-plugin/context';
|
||||
import { PluginState } from '../../mol-plugin/state';
|
||||
import { StateObjectSelector } from '../../mol-state';
|
||||
import { fovAdjustedPosition } from '../../mol-util/camera';
|
||||
import { ColorNames } from '../../mol-util/color/names';
|
||||
import { ParamDefinition } from '../../mol-util/param-definition';
|
||||
import { decodeColor } from './helpers/utils';
|
||||
import { MolstarLoadingContext } from './load';
|
||||
import { SnapshotMetadata } from './mvs-data';
|
||||
import { MolstarNodeParams } from './tree/molstar/molstar-tree';
|
||||
import { MolstarNode, MolstarNodeParams } from './tree/molstar/molstar-tree';
|
||||
import { MVSTreeSchema } from './tree/mvs/mvs-tree';
|
||||
|
||||
|
||||
@@ -98,24 +103,6 @@ function resetSceneRadiusFactor(plugin: PluginContext) {
|
||||
plugin.canvas3d?.setProps({ sceneRadiusFactor });
|
||||
}
|
||||
|
||||
/** Return the distance adjustment ratio for conversion from the "reference camera"
|
||||
* to a camera with an arbitrary field of view `fov`. */
|
||||
function distanceAdjustment(mode: Camera.Mode, fov: number) {
|
||||
if (mode === 'orthographic') return 1 / (2 * Math.tan(fov / 2));
|
||||
else return 1 / (2 * Math.sin(fov / 2));
|
||||
}
|
||||
|
||||
/** Return the position for a camera with an arbitrary field of view `fov`
|
||||
* necessary to just fit into view the same sphere (with center at `target`)
|
||||
* as the "reference camera" placed at `refPosition` would fit, while keeping the camera orientation.
|
||||
* The "reference camera" is a camera which can just fit into view a sphere of radius R with center at distance 2R
|
||||
* (this corresponds to FOV = 2 * asin(1/2) in perspective mode or FOV = 2 * atan(1/2) in orthographic mode). */
|
||||
function fovAdjustedPosition(target: Vec3, refPosition: Vec3, mode: Camera.Mode, fov: number) {
|
||||
const delta = Vec3.sub(Vec3(), refPosition, target);
|
||||
const adjustment = distanceAdjustment(mode, fov);
|
||||
return Vec3.scaleAndAdd(delta, target, delta, adjustment); // return target + delta * adjustment
|
||||
}
|
||||
|
||||
/** Create object for PluginState.Snapshot.camera based on tree loading context and MVS snapshot metadata */
|
||||
export function createPluginStateSnapshotCamera(plugin: PluginContext, context: MolstarLoadingContext, metadata: SnapshotMetadata & { previousTransitionDurationMs?: number }): PluginState.Snapshot['camera'] {
|
||||
const camera: PluginState.Snapshot['camera'] = {
|
||||
@@ -132,16 +119,34 @@ export function createPluginStateSnapshotCamera(plugin: PluginContext, context:
|
||||
return camera;
|
||||
}
|
||||
|
||||
/** Set canvas properties based on a canvas node params. */
|
||||
export function setCanvas(plugin: PluginContext, params: MolstarNodeParams<'canvas'> | undefined) {
|
||||
plugin.canvas3d?.setProps(old => modifyCanvasProps(old, params));
|
||||
/** Set canvas properties based on a canvas node. */
|
||||
export function setCanvas(plugin: PluginContext, node: MolstarNode<'canvas'> | undefined) {
|
||||
plugin.canvas3d?.setProps(old => modifyCanvasProps(old, node));
|
||||
}
|
||||
|
||||
/** Create a deep copy of `oldCanvasProps` with values modified according to a canvas node params. */
|
||||
export function modifyCanvasProps(oldCanvasProps: Canvas3DProps, params: MolstarNodeParams<'canvas'> | undefined): Canvas3DProps {
|
||||
export function modifyCanvasProps(oldCanvasProps: Canvas3DProps, canvasNode: MolstarNode<'canvas'> | undefined, custom?: Record<string, any>): Canvas3DProps {
|
||||
const params = canvasNode?.params;
|
||||
const backgroundColor = decodeColor(params?.background_color) ?? DefaultCanvasBackgroundColor;
|
||||
|
||||
const outline = !!canvasNode?.custom?.molstar_enable_outline;
|
||||
const shadow = !!canvasNode?.custom?.molstar_enable_shadow;
|
||||
const occlusion = !!canvasNode?.custom?.molstar_enable_ssao;
|
||||
|
||||
return {
|
||||
...oldCanvasProps,
|
||||
postprocessing: {
|
||||
...oldCanvasProps.postprocessing,
|
||||
outline: outline
|
||||
? { name: 'on', params: ParamDefinition.getDefaultValues(OutlineParams) }
|
||||
: oldCanvasProps.postprocessing.outline,
|
||||
shadow: shadow
|
||||
? { name: 'on', params: ParamDefinition.getDefaultValues(ShadowParams) }
|
||||
: oldCanvasProps.postprocessing.shadow,
|
||||
occlusion: occlusion
|
||||
? { name: 'on', params: ParamDefinition.getDefaultValues(SsaoParams) }
|
||||
: oldCanvasProps.postprocessing.occlusion,
|
||||
},
|
||||
renderer: {
|
||||
...oldCanvasProps.renderer,
|
||||
backgroundColor: backgroundColor,
|
||||
|
||||
@@ -1,32 +1,85 @@
|
||||
/**
|
||||
* Copyright (c) 2023-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
*/
|
||||
|
||||
import { SortedArray } from '../../../mol-data/int';
|
||||
import { Location } from '../../../mol-model/location';
|
||||
import { Bond, StructureElement } from '../../../mol-model/structure';
|
||||
import type { ColorTheme, LocationColor } from '../../../mol-theme/color';
|
||||
import type { ThemeDataContext } from '../../../mol-theme/theme';
|
||||
import { Color } from '../../../mol-util/color';
|
||||
import { ColorNames } from '../../../mol-util/color/names';
|
||||
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
|
||||
import { MaybeFloatParamDefinition } from '../helpers/param-definition';
|
||||
import { decodeColor } from '../helpers/utils';
|
||||
import { getMVSAnnotationForStructure } from './annotation-prop';
|
||||
import { getMVSAnnotationForStructure, MVSAnnotation } from './annotation-prop';
|
||||
import { isMVSStructure } from './is-mvs-model-prop';
|
||||
|
||||
|
||||
export const MVSCategoricalPaletteParams = {
|
||||
colors: PD.MappedStatic('list', {
|
||||
list: PD.ColorList('category-10', { description: 'List of colors.', presetKind: 'set' }),
|
||||
dictionary: PD.ObjectList({
|
||||
value: PD.Text(),
|
||||
color: PD.Color(ColorNames.white),
|
||||
}, e => `${e.value}: ${Color.toHexStyle(e.color)}`, { description: 'Mapping of annotation values to colors.' }),
|
||||
}),
|
||||
repeatColorList: PD.Boolean(false, { hideIf: g => g.colors.name !== 'list', description: 'Repeat color list once all colors are depleted (only applies if `colors` is a list).' }),
|
||||
sort: PD.Select('none', [['none', 'None'], ['lexical', 'Lexical'], ['numeric', 'Numeric']] as const, { hideIf: g => g.colors.name !== 'list', description: 'Sort actual annotation values before assigning colors from a list (none = take values in order of their first occurrence).' }),
|
||||
sortDirection: PD.Select('ascending', [['ascending', 'Ascending'], ['descending', 'Descending']] as const, { hideIf: g => g.colors.name !== 'list', description: 'Sort direction.' }),
|
||||
caseInsensitive: PD.Boolean(false, { description: 'Treat annotation values as case-insensitive strings.' }),
|
||||
setMissingColor: PD.Boolean(false, { description: 'Allow setting a color for missing values.' }),
|
||||
missingColor: PD.Color(ColorNames.white, { hideIf: g => !g.setMissingColor, description: 'Color to use when (a) `colors` is a dictionary and given key is not present, or (b) `color` is a list and there are more actual annotation values than listed colors and `repeat_color_list` is not true.' }),
|
||||
};
|
||||
export type MVSCategoricalPaletteParams = typeof MVSCategoricalPaletteParams
|
||||
export type MVSCategoricalPaletteProps = PD.Values<MVSCategoricalPaletteParams>
|
||||
|
||||
export const MVSDiscretePaletteParams = {
|
||||
colors: PD.ObjectList({
|
||||
color: PD.Color(ColorNames.white),
|
||||
fromValue: PD.Numeric(-Infinity),
|
||||
toValue: PD.Numeric(Infinity),
|
||||
}, e => `${Color.toHexStyle(e.color)} [${e.fromValue}, ${e.toValue}]`, { description: 'Mapping of annotation value ranges to colors.' }),
|
||||
mode: PD.Select('normalized', [['normalized', 'Normalized'], ['absolute', 'Absolute']] as const, { description: 'Defines whether the annotation values should be normalized before assigning color based on checkpoints in `colors` (`x_normalized = (x - x_min) / (x_max - x_min)`, where `[x_min, x_max]` are either `value_domain` if provided, or the lowest and the highest value encountered in the annotation).' }),
|
||||
xMin: MaybeFloatParamDefinition({ hideIf: g => g.mode !== 'normalized', placeholder: 'auto', description: 'Defines `x_min` for normalization of annotation values. If not provided, minimum of the actual values will be used. Only used when `mode` is `"normalized"' }),
|
||||
xMax: MaybeFloatParamDefinition({ hideIf: g => g.mode !== 'normalized', placeholder: 'auto', description: 'Defines `x_max` for normalization of annotation values. If not provided, maximum of the actual values will be used. Only used when `mode` is `"normalized"' }),
|
||||
};
|
||||
export type MVSDiscretePaletteParams = typeof MVSDiscretePaletteParams
|
||||
export type MVSDiscretePaletteProps = PD.Values<MVSDiscretePaletteParams>
|
||||
|
||||
export const MVSContinuousPaletteParams = {
|
||||
colors: PD.ColorList('yellow-green', { description: 'List of colors, with optional checkpoints.', presetKind: 'scale', offsets: true }),
|
||||
mode: PD.Select('normalized', [['normalized', 'Normalized'], ['absolute', 'Absolute']] as const, { description: 'Defines whether the annotation values should be normalized before assigning color based on checkpoints in `colors` (`x_normalized = (x - x_min) / (x_max - x_min)`, where `[x_min, x_max]` are either `value_domain` if provided, or the lowest and the highest value encountered in the annotation).' }),
|
||||
xMin: MaybeFloatParamDefinition({ hideIf: g => g.mode !== 'normalized', placeholder: 'auto', description: 'Defines `x_min` for normalization of annotation values. If not provided, minimum of the actual values will be used. Only used when `mode` is `"normalized"' }),
|
||||
xMax: MaybeFloatParamDefinition({ hideIf: g => g.mode !== 'normalized', placeholder: 'auto', description: 'Defines `x_max` for normalization of annotation values. If not provided, maximum of the actual values will be used. Only used when `mode` is `"normalized"' }),
|
||||
setUnderflowColor: PD.Boolean(false, { description: 'Allow setting a color for values below the lowest checkpoint.' }),
|
||||
underflowColor: PD.Color(ColorNames.white, { hideIf: g => !g.setUnderflowColor, description: 'Color for values below the lowest checkpoint.' }),
|
||||
setOverflowColor: PD.Boolean(false, { description: 'Allow setting a color for values above the highest checkpoint.' }),
|
||||
overflowColor: PD.Color(ColorNames.white, { hideIf: g => !g.setOverflowColor, description: 'Color for values above the highest checkpoint.' }),
|
||||
};
|
||||
export type MVSContinuousPaletteParams = typeof MVSContinuousPaletteParams
|
||||
export type MVSContinuousPaletteProps = PD.Values<MVSContinuousPaletteParams>
|
||||
|
||||
|
||||
/** Parameter definition for color theme "MVS Annotation" */
|
||||
export const MVSAnnotationColorThemeParams = {
|
||||
annotationId: PD.Text('', { description: 'Reference to "Annotation" custom model property' }),
|
||||
fieldName: PD.Text('color', { description: 'Annotation field (column) from which to take color values' }),
|
||||
background: PD.Color(ColorNames.gainsboro, { description: 'Color for elements without annotation' }),
|
||||
palette: PD.MappedStatic('direct', {
|
||||
'direct': PD.EmptyGroup(),
|
||||
'categorical': PD.Group(MVSCategoricalPaletteParams),
|
||||
'discrete': PD.Group(MVSDiscretePaletteParams),
|
||||
'continuous': PD.Group(MVSContinuousPaletteParams),
|
||||
}),
|
||||
};
|
||||
export type MVSAnnotationColorThemeParams = typeof MVSAnnotationColorThemeParams
|
||||
|
||||
/** Parameter values for color theme "MVS Annotation" */
|
||||
export type MVSAnnotationColorThemeProps = PD.Values<MVSAnnotationColorThemeParams>
|
||||
|
||||
|
||||
/** Return color theme that assigns colors based on an annotation file.
|
||||
* The annotation file itself is handled by a custom model property (`MVSAnnotationsProvider`),
|
||||
* the color theme then just uses this property. */
|
||||
@@ -36,9 +89,12 @@ export function MVSAnnotationColorTheme(ctx: ThemeDataContext, props: MVSAnnotat
|
||||
if (ctx.structure && !ctx.structure.isEmpty) {
|
||||
const { annotation } = getMVSAnnotationForStructure(ctx.structure, props.annotationId);
|
||||
if (annotation) {
|
||||
const paletteFunction = makePaletteFunction(props.palette, annotation, props.fieldName);
|
||||
|
||||
const colorForStructureElementLocation = (location: StructureElement.Location) => {
|
||||
// if (annot.getAnnotationForLocation(location)?.color !== annot.getAnnotationForLocation_Reference(location)?.color) throw new Error('AssertionError');
|
||||
return decodeColor(annotation?.getValueForLocation(location, props.fieldName)) ?? props.background;
|
||||
const annotValue = annotation?.getValueForLocation(location, props.fieldName);
|
||||
const color = annotValue !== undefined ? paletteFunction(annotValue) : undefined;
|
||||
return color ?? props.background;
|
||||
};
|
||||
const auxLocation = StructureElement.Location.create(ctx.structure);
|
||||
|
||||
@@ -60,7 +116,7 @@ export function MVSAnnotationColorTheme(ctx: ThemeDataContext, props: MVSAnnotat
|
||||
|
||||
return {
|
||||
factory: MVSAnnotationColorTheme,
|
||||
granularity: 'group',
|
||||
granularity: 'groupInstance',
|
||||
preferSmoothing: true,
|
||||
color: color,
|
||||
props: props,
|
||||
@@ -79,3 +135,124 @@ export const MVSAnnotationColorThemeProvider: ColorTheme.Provider<MVSAnnotationC
|
||||
defaultValues: PD.getDefaultValues(MVSAnnotationColorThemeParams),
|
||||
isApplicable: (ctx: ThemeDataContext) => !!ctx.structure && isMVSStructure(ctx.structure),
|
||||
};
|
||||
|
||||
|
||||
function makePaletteFunction(props: MVSAnnotationColorThemeProps['palette'], annotation: MVSAnnotation, fieldName: string): (value: string) => Color | undefined {
|
||||
if (props.name === 'direct') return decodeColor;
|
||||
if (props.name === 'categorical') return makePaletteFunctionCategorical(props.params, annotation, fieldName);
|
||||
if (props.name === 'discrete') return makePaletteFunctionDiscrete(props.params as MVSDiscretePaletteProps, annotation, fieldName);
|
||||
if (props.name === 'continuous') return makePaletteFunctionContinuous(props.params as MVSContinuousPaletteProps, annotation, fieldName);
|
||||
throw new Error(`NotImplementedError: makePaletteFunction for ${(props as any).name}`);
|
||||
}
|
||||
|
||||
function makePaletteFunctionCategorical(props: MVSCategoricalPaletteProps, annotation: MVSAnnotation, fieldName: string): (value: string) => Color | undefined {
|
||||
const colorMap: { [value: string]: Color } = {};
|
||||
if (props.colors.name === 'dictionary') {
|
||||
for (const { value, color } of props.colors.params) {
|
||||
const key = props.caseInsensitive ? value.toUpperCase() : value;
|
||||
colorMap[key] = color;
|
||||
}
|
||||
} else if (props.colors.name === 'list') {
|
||||
const values = annotation.getDistinctValuesInField(fieldName, props.caseInsensitive);
|
||||
if (props.sort === 'lexical') values.sort();
|
||||
else if (props.sort === 'numeric') values.sort((a, b) => Number.parseFloat(a) - Number.parseFloat(b));
|
||||
if (props.sortDirection === 'descending') values.reverse();
|
||||
|
||||
const colorList = props.colors.params.colors.map(Color.fromColorListEntry);
|
||||
let next = 0;
|
||||
for (const value of values) {
|
||||
colorMap[value] = colorList[next++];
|
||||
if (next >= colorList.length && props.repeatColorList) next = 0; // else will get index-out-of-range and assign undefined
|
||||
}
|
||||
}
|
||||
const missingColor = props.setMissingColor ? props.missingColor : undefined;
|
||||
if (props.caseInsensitive) {
|
||||
return (value: string) => colorMap[value.toUpperCase()] ?? missingColor;
|
||||
} else {
|
||||
return (value: string) => colorMap[value] ?? missingColor;
|
||||
}
|
||||
}
|
||||
|
||||
function makePaletteFunctionDiscrete(props: MVSDiscretePaletteProps, annotation: MVSAnnotation, fieldName: string): (value: string) => Color | undefined {
|
||||
if (props.colors.length === 0) return () => undefined;
|
||||
|
||||
const scale = makeNumericPaletteScale(props, annotation, fieldName);
|
||||
|
||||
return (value: string) => {
|
||||
const xAbs = parseFloat(value);
|
||||
if (isNaN(xAbs)) return undefined;
|
||||
const x = scale(xAbs);
|
||||
|
||||
for (let i = props.colors.length - 1; i >= 0; i--) {
|
||||
const { color, fromValue, toValue } = props.colors[i];
|
||||
if (fromValue <= x && x <= toValue) return color;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function makePaletteFunctionContinuous(props: MVSContinuousPaletteProps, annotation: MVSAnnotation, fieldName: string): (value: string) => Color | undefined {
|
||||
const { colors, checkpoints } = makeContinuousPaletteCheckpoints(props);
|
||||
if (colors.length === 0) return () => undefined;
|
||||
|
||||
const scale = makeNumericPaletteScale(props, annotation, fieldName);
|
||||
const underflowColor = props.setUnderflowColor ? props.underflowColor : undefined;
|
||||
const overflowColor = props.setOverflowColor ? props.overflowColor : undefined;
|
||||
|
||||
return (value: string) => {
|
||||
const xAbs = parseFloat(value);
|
||||
if (isNaN(xAbs)) return undefined;
|
||||
const x = scale(xAbs);
|
||||
const gteIdx = SortedArray.findPredecessorIndex(checkpoints, x); // Index of the first greater or equal checkpoint
|
||||
if (gteIdx === 0) {
|
||||
if (x === checkpoints[0]) return colors[0];
|
||||
else return underflowColor;
|
||||
}
|
||||
if (gteIdx === checkpoints.length) {
|
||||
return overflowColor;
|
||||
}
|
||||
const q = (x - checkpoints[gteIdx - 1]) / (checkpoints[gteIdx] - checkpoints[gteIdx - 1]);
|
||||
return Color.interpolate(colors[gteIdx - 1], colors[gteIdx], q);
|
||||
};
|
||||
}
|
||||
|
||||
function makeNumericPaletteScale(props: MVSContinuousPaletteProps | MVSDiscretePaletteProps, annotation: MVSAnnotation, fieldName: string): (x: number) => number {
|
||||
if (props.mode === 'normalized') {
|
||||
// Mode normalized
|
||||
let xMin = props.xMin;
|
||||
let xMax = props.xMax;
|
||||
if (xMin === null || xMax === null) {
|
||||
const values = annotation.getDistinctValuesInField(fieldName, false).map(parseFloat).filter(x => !isNaN(x));
|
||||
if (values.length > 0) {
|
||||
xMin ??= values.reduce((a, b) => a < b ? a : b); // xMin ??= min(values)
|
||||
xMax ??= values.reduce((a, b) => a > b ? a : b); // xMax ??= max(values)
|
||||
} else {
|
||||
xMin ??= 0;
|
||||
xMax ??= 1;
|
||||
}
|
||||
}
|
||||
if (xMin === xMax) {
|
||||
return x => (x < xMin ? -0.5 : x === xMin ? 0.5 : 1.5);
|
||||
} else {
|
||||
return x => (x - xMin) / (xMax - xMin);
|
||||
}
|
||||
} else {
|
||||
// Mode absolute
|
||||
return x => x;
|
||||
}
|
||||
}
|
||||
|
||||
function makeContinuousPaletteCheckpoints(props: MVSContinuousPaletteProps) {
|
||||
if (props.colors.colors.every(x => Array.isArray(x))) {
|
||||
// Explicit checkpoints
|
||||
const sorted = props.colors.colors.sort((a, b) => a[1] - b[1]);
|
||||
const colors = sorted.map(Color.fromColorListEntry);
|
||||
const checkpoints = SortedArray.ofSortedArray(sorted.map(t => t[1]));
|
||||
return { colors, checkpoints };
|
||||
} else {
|
||||
// Auto checkpoints (linspace 0 to 1)
|
||||
const colors = props.colors.colors.map(Color.fromColorListEntry);
|
||||
const n = colors.length - 1;
|
||||
const checkpoints = SortedArray.ofSortedArray(colors.map((_, i) => i / n));
|
||||
return { colors, checkpoints };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Copyright (c) 2023-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
*/
|
||||
|
||||
import { Column, Table } from '../../../mol-data/db';
|
||||
import { Column } from '../../../mol-data/db';
|
||||
import { CIF, CifBlock, CifCategory, CifFile } from '../../../mol-io/reader/cif';
|
||||
import { toTable } from '../../../mol-io/reader/cif/schema';
|
||||
import { MmcifFormat } from '../../../mol-model-formats/structure/mmcif';
|
||||
@@ -13,19 +13,17 @@ import { CustomProperty } from '../../../mol-model-props/common/custom-property'
|
||||
import { CustomPropertyDescriptor } from '../../../mol-model/custom-property';
|
||||
import { Model } from '../../../mol-model/structure';
|
||||
import { Structure, StructureElement } from '../../../mol-model/structure/structure';
|
||||
import { UUID } from '../../../mol-util';
|
||||
import { arrayExtend } from '../../../mol-util/array';
|
||||
import { Asset } from '../../../mol-util/assets';
|
||||
import { Jsonable, canonicalJsonString } from '../../../mol-util/json';
|
||||
import { pickObjectKeys, promiseAllObj } from '../../../mol-util/object';
|
||||
import { objectOfArraysToArrayOfObjects, pickObjectKeysWithRemapping, promiseAllObj } from '../../../mol-util/object';
|
||||
import { Choice } from '../../../mol-util/param-choice';
|
||||
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
|
||||
import { AtomRanges } from '../helpers/atom-ranges';
|
||||
import { IndicesAndSortings } from '../helpers/indexing';
|
||||
import { MaybeStringParamDefinition } from '../helpers/param-definition';
|
||||
import { MVSAnnotationRow, MVSAnnotationSchema, getCifAnnotationSchema } from '../helpers/schemas';
|
||||
import { atomQualifies, getAtomRangesForRow } from '../helpers/selections';
|
||||
import { Maybe, safePromise } from '../helpers/utils';
|
||||
import { getAtomRangesForRow } from '../helpers/selections';
|
||||
import { Maybe, isDefined, safePromise } from '../helpers/utils';
|
||||
|
||||
|
||||
/** Allowed values for the annotation format parameter */
|
||||
@@ -50,7 +48,11 @@ export const MVSAnnotationsParams = {
|
||||
index: PD.Group({ index: PD.Numeric(0, { min: 0, step: 1 }, { description: '0-based index of the block' }) }),
|
||||
header: PD.Group({ header: PD.Text(undefined, { description: 'Block header' }) }),
|
||||
}, { description: 'Specify which CIF block contains annotation data (only relevant when format=cif or format=bcif)' }),
|
||||
cifCategory: MaybeStringParamDefinition(undefined, { description: 'Specify which CIF category contains annotation data (only relevant when format=cif or format=bcif)' }),
|
||||
cifCategory: MaybeStringParamDefinition({ placeholder: 'Take first category', description: 'Specify which CIF category contains annotation data (only relevant when format=cif or format=bcif)' }),
|
||||
fieldRemapping: PD.ObjectList({
|
||||
standardName: PD.Text('', { placeholder: ' ', description: 'Standard name of the selector field (e.g. label_asym_id)' }),
|
||||
actualName: MaybeStringParamDefinition({ placeholder: 'Ignore field', description: 'Actual name of the field in the annotation data (e.g. spam_chain_id), null to ignore the field with standard name' }),
|
||||
}, e => `"${e.standardName}": ${e.actualName === null ? 'null' : `"${e.actualName}"`}`, { description: 'Optional remapping of annotation field names { standardName1: actualName1, ... }. Use { "label_asym_id": "X" } to load actual field "X" as "label_asym_id". Use { "label_asym_id": null } to ignore actual field "label_asym_id". Fields not mentioned here are mapped implicitely (i.e. actual name = standard name).' }),
|
||||
id: PD.Text('', { description: 'Arbitrary identifier that can be referenced by MVSAnnotationColorTheme' }),
|
||||
},
|
||||
obj => obj.id
|
||||
@@ -137,16 +139,26 @@ export function getMVSAnnotationForStructure(structure: Structure, annotationId:
|
||||
return { annotation: undefined, model: undefined };
|
||||
}
|
||||
|
||||
type FieldRemapping = Record<string, string | null>;
|
||||
|
||||
/** Mapping `ElementIndex` -> annotation row index for all elements in a `Model`.
|
||||
* `-1` means no row applies to the element.
|
||||
* `null` means no row applies to any element. */
|
||||
type IndexedModel = number[] | null;
|
||||
|
||||
/** Main class for processing MVS annotation */
|
||||
export class MVSAnnotation {
|
||||
/** Store mapping `ElementIndex` -> annotation row index for each `Model`, -1 means no row applies */
|
||||
private indexedModels = new Map<UUID, number[]>();
|
||||
private rows: MVSAnnotationRow[] | undefined = undefined;
|
||||
|
||||
/** Number of annotation rows. */
|
||||
public nRows: number;
|
||||
|
||||
constructor(
|
||||
public data: MVSAnnotationData,
|
||||
public schema: MVSAnnotationSchema,
|
||||
) { }
|
||||
public fieldRemapping: FieldRemapping,
|
||||
) {
|
||||
this.nRows = getRowCount(data);
|
||||
}
|
||||
|
||||
/** Create a new `MVSAnnotation` based on specification `spec`. Use `file` if provided, otherwise download the file.
|
||||
* Throw error if download fails or problem with data. */
|
||||
@@ -165,7 +177,7 @@ export class MVSAnnotation {
|
||||
switch (blockSpec.name) {
|
||||
case 'header':
|
||||
const foundBlock = file.data.blocks.find(b => b.header === blockSpec.params.header);
|
||||
if (!foundBlock) throw new Error(`CIF block with header ${blockSpec.params.header} not found`);
|
||||
if (!foundBlock) throw new Error(`CIF block with header "${blockSpec.params.header}" not found`);
|
||||
block = foundBlock;
|
||||
break;
|
||||
case 'index':
|
||||
@@ -176,32 +188,21 @@ export class MVSAnnotation {
|
||||
const categoryName = spec.cifCategory ?? Object.keys(block.categories)[0];
|
||||
if (!categoryName) throw new Error('There are no categories in CIF block');
|
||||
const category = block.categories[categoryName];
|
||||
if (!category) throw new Error(`CIF category ${categoryName} not found`);
|
||||
if (!category) throw new Error(`CIF category "${categoryName}" not found`);
|
||||
data = { format: 'cif', data: category };
|
||||
break;
|
||||
}
|
||||
return new MVSAnnotation(data, spec.schema);
|
||||
return new MVSAnnotation(data, spec.schema, Object.fromEntries(spec.fieldRemapping.map(e => [e.standardName, e.actualName])));
|
||||
}
|
||||
|
||||
static createEmpty(schema: MVSAnnotationSchema): MVSAnnotation {
|
||||
return new MVSAnnotation({ format: 'json', data: [] }, schema);
|
||||
}
|
||||
|
||||
/** Reference implementation of `getAnnotationForLocation`, just for checking, DO NOT USE DIRECTLY */
|
||||
getAnnotationForLocation_Reference(loc: StructureElement.Location): MVSAnnotationRow | undefined {
|
||||
const model = loc.unit.model;
|
||||
const iAtom = loc.element;
|
||||
let result: MVSAnnotationRow | undefined = undefined;
|
||||
for (const row of this.getRows()) {
|
||||
if (atomQualifies(model, iAtom, row)) result = row;
|
||||
}
|
||||
return result;
|
||||
return new MVSAnnotation({ format: 'json', data: [] }, schema, {});
|
||||
}
|
||||
|
||||
/** Return value of field `fieldName` assigned to location `loc`, if any */
|
||||
getValueForLocation(loc: StructureElement.Location, fieldName: string): string | undefined {
|
||||
const indexedModel = this.getIndexedModel(loc.unit.model);
|
||||
const iRow = indexedModel[loc.element];
|
||||
const indexedModel = this.getIndexedModel(loc.unit.model, loc.unit.conformation.operator.instanceId);
|
||||
const iRow = (indexedModel !== null) ? indexedModel[loc.element] : -1;
|
||||
return this.getValueForRow(iRow, fieldName);
|
||||
}
|
||||
/** Return value of field `fieldName` assigned to `i`-th annotation row, if any */
|
||||
@@ -218,40 +219,69 @@ export class MVSAnnotation {
|
||||
}
|
||||
|
||||
/** Return cached `ElementIndex` -> `MVSAnnotationRow` mapping for `Model` (or create it if not cached yet) */
|
||||
private getIndexedModel(model: Model): number[] {
|
||||
const key = model.id;
|
||||
if (!this.indexedModels.has(key)) {
|
||||
const result = this.getRowForEachAtom(model);
|
||||
this.indexedModels.set(key, result);
|
||||
private getIndexedModel(model: Model, instanceId: string): IndexedModel {
|
||||
const key = this.hasInstanceIds() ? `${model.id}:${instanceId}` : model.id;
|
||||
if (!this._indexedModels.has(key)) {
|
||||
const result = this.getRowForEachAtom(model, instanceId);
|
||||
this._indexedModels.set(key, result);
|
||||
}
|
||||
return this.indexedModels.get(key)!;
|
||||
return this._indexedModels.get(key)!;
|
||||
}
|
||||
/** Cached `IndexedModel` per `Model.id` (if annotation contains no instanceIds)
|
||||
* or per `Model.id:instanceId` combination (if at least one row contains instanceId). */
|
||||
private _indexedModels = new Map<string, IndexedModel>();
|
||||
|
||||
/** Create `ElementIndex` -> `MVSAnnotationRow` mapping for `Model` */
|
||||
private getRowForEachAtom(model: Model): number[] {
|
||||
private getRowForEachAtom(model: Model, instanceId: string): IndexedModel {
|
||||
const indices = IndicesAndSortings.get(model);
|
||||
const nAtoms = model.atomicHierarchy.atoms._rowCount;
|
||||
const result: number[] = Array(nAtoms).fill(-1);
|
||||
let result: IndexedModel = null;
|
||||
const rows = this.getRows();
|
||||
for (let i = 0, nRows = rows.length; i < nRows; i++) {
|
||||
const atomRanges = getAtomRangesForRow(model, rows[i], indices);
|
||||
AtomRanges.foreach(atomRanges, (from, to) => result.fill(i, from, to));
|
||||
const row = rows[i];
|
||||
const atomRanges = getAtomRangesForRow(row, model, instanceId, indices);
|
||||
if (AtomRanges.count(atomRanges) === 0) continue;
|
||||
result ??= Array(nAtoms).fill(-1);
|
||||
AtomRanges.foreach(atomRanges, (from, to) => result!.fill(i, from, to));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Parse and return all annotation rows in this annotation, or return cached result if available */
|
||||
getRows(): readonly MVSAnnotationRow[] {
|
||||
return this._rows ??= this._getRows();
|
||||
}
|
||||
/** Cached annotation rows. Do not use directly, use `getRows` instead. */
|
||||
private _rows: MVSAnnotationRow[] | undefined = undefined;
|
||||
/** Parse and return all annotation rows in this annotation */
|
||||
private _getRows(): MVSAnnotationRow[] {
|
||||
switch (this.data.format) {
|
||||
case 'json':
|
||||
return getRowsFromJson(this.data.data, this.schema);
|
||||
return getRowsFromJson(this.data.data, this.schema, this.fieldRemapping);
|
||||
case 'cif':
|
||||
return getRowsFromCif(this.data.data, this.schema);
|
||||
return getRowsFromCif(this.data.data, this.schema, this.fieldRemapping);
|
||||
}
|
||||
}
|
||||
/** Parse and return all annotation rows in this annotation, or return cached result if available */
|
||||
getRows(): readonly MVSAnnotationRow[] {
|
||||
return this.rows ??= this._getRows();
|
||||
|
||||
/** Return `true` if some rows in the annotation contain `instance_id` field. */
|
||||
private hasInstanceIds(): boolean {
|
||||
return this._hasInstanceIds ??= this.getRows().some(row => isDefined(row.instance_id));
|
||||
}
|
||||
private _hasInstanceIds?: boolean = undefined;
|
||||
|
||||
/** Return list of all distinct values appearing in field `fieldName`, in order of first occurrence. Ignores special values `.` and `?`. If `caseInsensitive`, make all values uppercase. */
|
||||
getDistinctValuesInField(fieldName: string, caseInsensitive: boolean): string[] {
|
||||
const seen = new Set<string | undefined>();
|
||||
const out = [];
|
||||
for (let i = 0; i < this.nRows; i++) {
|
||||
let value = this.getValueForRow(i, fieldName);
|
||||
if (caseInsensitive) value = value?.toUpperCase();
|
||||
if (value !== undefined && !seen.has(value)) {
|
||||
seen.add(value);
|
||||
out.push(value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,55 +302,80 @@ function getValueFromCif(rowIndex: number, fieldName: string, data: CifCategory)
|
||||
return column.str(rowIndex);
|
||||
}
|
||||
|
||||
function getRowsFromJson(data: Jsonable, schema: MVSAnnotationSchema): MVSAnnotationRow[] {
|
||||
/** Return number of rows in this annotation (without parsing all the data) */
|
||||
function getRowCount(data: MVSAnnotationData): number {
|
||||
switch (data.format) {
|
||||
case 'json':
|
||||
return getRowCountFromJson(data.data);
|
||||
case 'cif':
|
||||
return getRowCountFromCif(data.data);
|
||||
}
|
||||
}
|
||||
function getRowCountFromJson(data: Jsonable): number {
|
||||
const js = data as any;
|
||||
const cifSchema = getCifAnnotationSchema(schema);
|
||||
if (Array.isArray(js)) {
|
||||
// array of objects
|
||||
return js.map(row => pickObjectKeys(row, Object.keys(cifSchema)));
|
||||
return js.length;
|
||||
} else {
|
||||
// object of arrays
|
||||
const rows: MVSAnnotationRow[] = [];
|
||||
const keys = Object.keys(js).filter(key => Object.hasOwn(cifSchema, key as any));
|
||||
const keys = Object.keys(js);
|
||||
if (keys.length > 0) {
|
||||
const n = js[keys[0]].length;
|
||||
if (keys.some(key => js[key].length !== n)) throw new Error('FormatError: arrays must have the same length.');
|
||||
for (let i = 0; i < n; i++) {
|
||||
const item: { [key: string]: any } = {};
|
||||
for (const key of keys) {
|
||||
item[key] = js[key][i];
|
||||
}
|
||||
rows.push(item);
|
||||
}
|
||||
return js[keys[0]].length;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
function getRowCountFromCif(data: CifCategory): number {
|
||||
return data.rowCount;
|
||||
}
|
||||
|
||||
function getRowsFromCif(data: CifCategory, schema: MVSAnnotationSchema): MVSAnnotationRow[] {
|
||||
const rows: MVSAnnotationRow[] = [];
|
||||
function getRowsFromJson(data: Jsonable, schema: MVSAnnotationSchema, fieldRemapping: FieldRemapping): MVSAnnotationRow[] {
|
||||
const js = data as any;
|
||||
const cifSchema = getCifAnnotationSchema(schema);
|
||||
const table = toTable(cifSchema, data);
|
||||
arrayExtend(rows, getRowsFromTable(table)); // Avoiding Table.getRows(table) as it replaces . and ? fields by 0 or ''
|
||||
return rows;
|
||||
const cifSchemaKeys = Object.keys(cifSchema);
|
||||
if (Array.isArray(js)) {
|
||||
// array of objects
|
||||
return js.map(row => pickObjectKeysWithRemapping(row, cifSchemaKeys, fieldRemapping));
|
||||
} else {
|
||||
// object of arrays
|
||||
const selectedFields: Record<string, any[]> = pickObjectKeysWithRemapping(js, cifSchemaKeys, fieldRemapping);
|
||||
return objectOfArraysToArrayOfObjects(selectedFields);
|
||||
}
|
||||
}
|
||||
|
||||
/** Same as `Table.getRows` but omits `.` and `?` fields (instead of using type defaults) */
|
||||
function getRowsFromTable<S extends Table.Schema>(table: Table<S>): Partial<Table.Row<S>>[] {
|
||||
const rows: Partial<Table.Row<S>>[] = [];
|
||||
const columns = table._columns;
|
||||
const nRows = table._rowCount;
|
||||
const Present = Column.ValueKind.Present;
|
||||
for (let iRow = 0; iRow < nRows; iRow++) {
|
||||
const row: Partial<Table.Row<S>> = {};
|
||||
for (const col of columns) {
|
||||
if (table[col].valueKind(iRow) === Present) {
|
||||
row[col as keyof S] = table[col].value(iRow);
|
||||
}
|
||||
}
|
||||
rows[iRow] = row;
|
||||
function getRowsFromCif(data: CifCategory, schema: MVSAnnotationSchema, fieldRemapping: FieldRemapping): MVSAnnotationRow[] {
|
||||
const cifSchema = getCifAnnotationSchema(schema);
|
||||
const cifSchemaKeys = Object.keys(cifSchema) as (keyof typeof cifSchema)[];
|
||||
const columns: Partial<Record<keyof typeof cifSchema, any[]>> = {};
|
||||
for (const key of cifSchemaKeys) {
|
||||
let srcKey = fieldRemapping[key];
|
||||
if (srcKey === null) continue; // Ignore key
|
||||
if (srcKey === undefined) srcKey = key; // Implicit key mapping
|
||||
const columnArray = getArrayFromCifCategory(data, srcKey, cifSchema[key]); // Avoiding `column.toArray` as it replaces . and ? fields by 0 or ''
|
||||
if (columnArray) columns[key] = columnArray;
|
||||
}
|
||||
return rows;
|
||||
return objectOfArraysToArrayOfObjects(columns);
|
||||
}
|
||||
|
||||
/** Load data from a specific column in a CIF category into an array. Load `.` and `?` as undefined. */
|
||||
function getArrayFromCifCategory<T>(data: CifCategory, columnName: string, columnSchema: Column.Schema): (T | undefined)[] | undefined {
|
||||
if (data.getField(columnName) === undefined) return undefined;
|
||||
|
||||
const table = toTable({ [columnName]: columnSchema }, data); // a bit dumb, I don't know how to make column directly
|
||||
const column = table[columnName];
|
||||
return getArrayFromCifColumn(column); // Avoiding `column.toArray` as it replaces . and ? fields by 0 or ''
|
||||
}
|
||||
|
||||
/** Same as `column.toArray` but reads `.` and `?` as undefined (instead of using type defaults) */
|
||||
function getArrayFromCifColumn<T>(column: Column<T>): (T | undefined)[] {
|
||||
const nRows = column.rowCount;
|
||||
const Present = Column.ValueKind.Present;
|
||||
const out: (T | undefined)[] = new Array(nRows);
|
||||
for (let iRow = 0; iRow < nRows; iRow++) {
|
||||
out[iRow] = column.valueKind(iRow) === Present ? column.value(iRow) : undefined;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function getFileFromSource(ctx: CustomProperty.Context, source: MVSAnnotationSource, model?: Model): Promise<MVSAnnotationFile> {
|
||||
|
||||
@@ -75,7 +75,7 @@ function createLabelText(ctx: VisualContext, structure: Structure, theme: Theme,
|
||||
break;
|
||||
case 'selection':
|
||||
const substructure = substructureFromSelector(structure, item.position.params.selector);
|
||||
const p = textPropsForSelection(substructure, theme.size.size, {});
|
||||
const p = textPropsForSelection(substructure, theme.size.size, [{}]);
|
||||
const group = serialIndexOfSubstructure(structure, substructure) ?? 0;
|
||||
if (p) builder.add(item.text, p.center[0], p.center[1], p.center[2], p.depth, p.scale, group);
|
||||
break;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2023-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { hashFnv32a } from '../../../mol-data/util';
|
||||
@@ -15,7 +16,7 @@ import { RuntimeContext, Task } from '../../../mol-task';
|
||||
import { Asset, AssetManager } from '../../../mol-util/assets';
|
||||
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
|
||||
import { unzip } from '../../../mol-util/zip/zip';
|
||||
import { loadMVS } from '../load';
|
||||
import { loadMVS, MVSLoadOptions } from '../load';
|
||||
import { MVSData } from '../mvs-data';
|
||||
import { MVSTransform } from './annotation-structure-component';
|
||||
|
||||
@@ -112,6 +113,11 @@ export const MVSXFormatProvider: DataFormatProvider<{}, StateObjectRef<Mvs>, any
|
||||
* and parse the main file in the archive as MVSJ.
|
||||
* Return parsed MVS data and `sourceUrl` for resolution of relative URIs. */
|
||||
export async function loadMVSX(plugin: PluginContext, runtimeCtx: RuntimeContext, data: Uint8Array, mainFilePath: string = 'index.mvsj'): Promise<{ mvsData: MVSData, sourceUrl: string }> {
|
||||
// Ensure at most one generation of MVSX file assets exists in the asset manager.
|
||||
// Hopefully, this is a reasonable compromise to ensure MVSX files work in multi-snapshot
|
||||
// states.
|
||||
clearMVSXFileAssets(plugin);
|
||||
|
||||
const archiveId = `ni,fnv1a;${hashFnv32a(data)}`;
|
||||
let files: { [path: string]: Uint8Array };
|
||||
try {
|
||||
@@ -122,7 +128,8 @@ export async function loadMVSX(plugin: PluginContext, runtimeCtx: RuntimeContext
|
||||
}
|
||||
for (const path in files) {
|
||||
const url = arcpUri(archiveId, path);
|
||||
ensureUrlAsset(plugin.managers.asset, url, files[path]);
|
||||
// Need to use static assets so they persist accross snapsho
|
||||
ensureUrlAsset(plugin.managers.asset, url, files[path], { isFile: true });
|
||||
}
|
||||
const mainFile = files[mainFilePath];
|
||||
if (!mainFile) throw new Error(`File ${mainFilePath} not found in the MVSX archive`);
|
||||
@@ -131,6 +138,42 @@ export async function loadMVSX(plugin: PluginContext, runtimeCtx: RuntimeContext
|
||||
return { mvsData, sourceUrl };
|
||||
}
|
||||
|
||||
export async function loadMVSData(plugin: PluginContext, data: MVSData | StringLike | Uint8Array, format: 'mvsj' | 'mvsx', options?: MVSLoadOptions) {
|
||||
if (typeof data === 'string' && data.startsWith('base64')) {
|
||||
data = Uint8Array.from(atob(data.substring(7)), c => c.charCodeAt(0)); // Decode base64 string to Uint8Array
|
||||
}
|
||||
|
||||
if (format === 'mvsj') {
|
||||
if ((data as Uint8Array).BYTES_PER_ELEMENT && (data as Uint8Array).buffer) {
|
||||
data = new TextDecoder().decode(data as Uint8Array); // Decode Uint8Array to string using UTF8
|
||||
}
|
||||
|
||||
let mvsData: MVSData;
|
||||
if (typeof data === 'string') {
|
||||
mvsData = MVSData.fromMVSJ(data);
|
||||
} else {
|
||||
mvsData = data as MVSData;
|
||||
}
|
||||
await loadMVS(plugin, mvsData, { sanityChecks: true, sourceUrl: undefined, ...options });
|
||||
} else if (format === 'mvsx') {
|
||||
if (typeof data === 'string') {
|
||||
throw new Error("loadMvsData: if `format` is 'mvsx', then `data` must be a Uint8Array or a base64-encoded string prefixed with 'base64,'.");
|
||||
}
|
||||
await plugin.runTask(Task.create('Load MVSX file', async ctx => {
|
||||
const parsed = await loadMVSX(plugin, ctx, data as Uint8Array);
|
||||
await loadMVS(plugin, parsed.mvsData, { sanityChecks: true, ...options, sourceUrl: parsed.sourceUrl });
|
||||
}));
|
||||
} else {
|
||||
throw new Error(`Unknown MolViewSpec format: ${format}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function clearMVSXFileAssets(plugin: PluginContext) {
|
||||
plugin.managers.asset.clearTag('mvsx-file');
|
||||
}
|
||||
|
||||
/** If the PluginStateObject `pso` comes from a Download transform, try to get its `url` parameter. */
|
||||
function tryGetDownloadUrl(pso: SO.Data.String, plugin: PluginContext): string | undefined {
|
||||
const theCell = plugin.state.data.selectQ(q => q.ofTransformer(Download)).find(cell => cell.obj === pso);
|
||||
@@ -147,11 +190,13 @@ function arcpUri(archiveId: string, path: string): string {
|
||||
|
||||
/** Add a URL asset to asset manager.
|
||||
* Skip if an asset with the same URL already exists. */
|
||||
function ensureUrlAsset(manager: AssetManager, url: string, data: Uint8Array) {
|
||||
function ensureUrlAsset(manager: AssetManager, url: string, data: Uint8Array, options?: { isFile?: boolean }) {
|
||||
const asset = Asset.getUrlAsset(manager, url);
|
||||
if (!manager.has(asset)) {
|
||||
const filename = url.split('/').pop() ?? 'file';
|
||||
manager.set(asset, new File([data], filename));
|
||||
// We need to mark files as static resources to prevent deleting them
|
||||
// when changing state snapshots.
|
||||
manager.set(asset, new File([data], filename), options?.isFile ? { isStatic: true, tag: 'mvsx-file' } : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,17 +4,19 @@
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
*/
|
||||
|
||||
import { ColorTypeLocation } from '../../../mol-geo/geometry/color-data';
|
||||
import { Location } from '../../../mol-model/location';
|
||||
import { Bond, Structure, StructureElement } from '../../../mol-model/structure';
|
||||
import { ColorTheme, LocationColor } from '../../../mol-theme/color';
|
||||
import { ColorThemeCategory } from '../../../mol-theme/color/categories';
|
||||
import { ThemeDataContext } from '../../../mol-theme/theme';
|
||||
import { deepEqual } from '../../../mol-util';
|
||||
import { Color } from '../../../mol-util/color';
|
||||
import { ColorNames } from '../../../mol-util/color/names';
|
||||
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
|
||||
import { stringToWords } from '../../../mol-util/string';
|
||||
import { isMVSStructure } from './is-mvs-model-prop';
|
||||
import { ElementSet, SelectorParams, isSelectorAll } from './selector';
|
||||
import { ElementSet, SelectorParams, isSelectorAll, substructureFromSelector } from './selector';
|
||||
|
||||
|
||||
/** Special value that can be used as color with null-like semantic (i.e. "no color provided").
|
||||
@@ -70,32 +72,7 @@ export const DefaultMultilayerColorThemeProps: MultilayerColorThemeProps = { lay
|
||||
* If a nested theme provider has `ensureCustomProperties` methods, these will not be called automatically
|
||||
* (the caller must ensure that any required custom properties be attached). */
|
||||
function makeMultilayerColorTheme(ctx: ThemeDataContext, props: MultilayerColorThemeProps, colorThemeRegistry: ColorTheme.Registry): ColorTheme<MultilayerColorThemeParams> {
|
||||
const colorLayers: { color: LocationColor, elementSet: ElementSet | undefined }[] = []; // undefined elementSet means 'all'
|
||||
for (let i = props.layers.length - 1; i >= 0; i--) { // iterate from end to get top layer first, bottom layer last
|
||||
const layer = props.layers[i];
|
||||
const themeProvider = colorThemeRegistry.get(layer.theme.name);
|
||||
if (!themeProvider) {
|
||||
console.warn(`Skipping color theme '${layer.theme.name}', cannot find it in registry.`);
|
||||
continue;
|
||||
}
|
||||
if (themeProvider.ensureCustomProperties?.attach) {
|
||||
console.warn(`Multilayer color theme: layer "${themeProvider.name}" has ensureCustomProperties.attach method, but Multilayer color theme does not call it. If the layer does not work, make sure you call ensureCustomProperties.attach somewhere.`);
|
||||
}
|
||||
const theme = themeProvider.factory(ctx, layer.theme.params);
|
||||
switch (theme.granularity) {
|
||||
case 'uniform':
|
||||
case 'instance':
|
||||
case 'group':
|
||||
case 'groupInstance':
|
||||
case 'vertex':
|
||||
case 'vertexInstance':
|
||||
const elementSet = isSelectorAll(layer.selection) ? undefined : ElementSet.fromSelector(ctx.structure, layer.selection); // treating 'all' specially for performance reasons (it's expected to be used most often)
|
||||
colorLayers.push({ color: theme.color, elementSet });
|
||||
break;
|
||||
default:
|
||||
console.warn(`Skipping color theme '${layer.theme.name}', cannot process granularity '${theme.granularity}'`);
|
||||
}
|
||||
};
|
||||
const { colorLayers, granularity } = makeLayers(ctx, props, colorThemeRegistry);
|
||||
|
||||
function structureElementColor(loc: StructureElement.Location, isSecondary: boolean): Color {
|
||||
for (const layer of colorLayers) {
|
||||
@@ -123,7 +100,7 @@ function makeMultilayerColorTheme(ctx: ThemeDataContext, props: MultilayerColorT
|
||||
|
||||
return {
|
||||
factory: (ctx_, props_) => makeMultilayerColorTheme(ctx_, props_, colorThemeRegistry),
|
||||
granularity: 'group',
|
||||
granularity,
|
||||
preferSmoothing: true,
|
||||
color: color,
|
||||
props: props,
|
||||
@@ -132,6 +109,117 @@ function makeMultilayerColorTheme(ctx: ThemeDataContext, props: MultilayerColorT
|
||||
}
|
||||
|
||||
|
||||
const GRAN_INSTANCE = 1, GRAN_GROUP = 2, GRAN_VERTEX = 4;
|
||||
|
||||
const granularityFlagsFromName = {
|
||||
'uniform': 0,
|
||||
'instance': GRAN_INSTANCE,
|
||||
'group': GRAN_GROUP,
|
||||
'groupInstance': GRAN_GROUP | GRAN_INSTANCE,
|
||||
'vertex': GRAN_VERTEX,
|
||||
'vertexInstance': GRAN_VERTEX | GRAN_INSTANCE,
|
||||
} satisfies { [name in ColorTypeLocation]: number };
|
||||
|
||||
function granularityNameFromFlags(flags: number): ColorTypeLocation {
|
||||
if (flags & GRAN_VERTEX) return flags & GRAN_INSTANCE ? 'vertexInstance' : 'vertex';
|
||||
if (flags & GRAN_GROUP) return flags & GRAN_INSTANCE ? 'groupInstance' : 'group';
|
||||
return flags & GRAN_INSTANCE ? 'instance' : 'uniform';
|
||||
}
|
||||
|
||||
interface ColorLayer {
|
||||
/** Substructure to which the layer is applied, undefined means 'all' */
|
||||
elementSet: ElementSet | undefined,
|
||||
/** Color theme for the layer */
|
||||
color: LocationColor,
|
||||
}
|
||||
|
||||
function makeLayers(ctx: ThemeDataContext, props: MultilayerColorThemeProps, colorThemeRegistry: ColorTheme.Registry) {
|
||||
const colorLayers: ColorLayer[] = [];
|
||||
let granularityFlags = 0;
|
||||
for (let i = props.layers.length - 1; i >= 0; i--) { // iterate from the end to get top layer first, bottom layer last
|
||||
const layer = props.layers[i];
|
||||
const themeProvider = colorThemeRegistry.get(layer.theme.name);
|
||||
if (!themeProvider) {
|
||||
console.warn(`Skipping color theme '${layer.theme.name}', cannot find it in registry.`);
|
||||
continue;
|
||||
}
|
||||
if (themeProvider.ensureCustomProperties?.attach) {
|
||||
console.warn(`Multilayer color theme: layer "${themeProvider.name}" has ensureCustomProperties.attach method, but Multilayer color theme does not call it. If the layer does not work, make sure you call ensureCustomProperties.attach somewhere.`);
|
||||
}
|
||||
const theme = themeProvider.factory(ctx, layer.theme.params);
|
||||
switch (theme.granularity) {
|
||||
case 'uniform':
|
||||
case 'instance':
|
||||
case 'group':
|
||||
case 'groupInstance':
|
||||
case 'vertex':
|
||||
case 'vertexInstance':
|
||||
let elementSet: ElementSet | undefined;
|
||||
let selectionGranularity: 'uniform' | 'instance' | 'group' | 'groupInstance';
|
||||
if (!ctx.structure) {
|
||||
elementSet = {};
|
||||
selectionGranularity = 'uniform';
|
||||
} else if (isSelectorAll(layer.selection)) {
|
||||
// Treating 'all' specially for performance reasons (it's expected to be used most often)
|
||||
elementSet = undefined;
|
||||
selectionGranularity = 'uniform';
|
||||
} else {
|
||||
const substructure = substructureFromSelector(ctx.structure, layer.selection);
|
||||
elementSet = ElementSet.fromStructure(substructure);
|
||||
selectionGranularity = getSubstructureGranularity(ctx.structure, substructure);
|
||||
}
|
||||
colorLayers.push({ elementSet, color: theme.color });
|
||||
granularityFlags |= granularityFlagsFromName[selectionGranularity];
|
||||
granularityFlags |= granularityFlagsFromName[theme.granularity];
|
||||
break;
|
||||
default:
|
||||
console.warn(`Skipping color theme '${layer.theme.name}', cannot process granularity '${theme.granularity}'`);
|
||||
}
|
||||
}
|
||||
return { colorLayers, granularity: granularityNameFromFlags(granularityFlags) };
|
||||
}
|
||||
|
||||
|
||||
function getSubstructureGranularity(parent: Structure, substructure: Structure) {
|
||||
const parentCounts: { [instance: string]: number } = {};
|
||||
for (const unit of parent.units) {
|
||||
const instance = unit.conformation.operator.instanceId;
|
||||
parentCounts[instance] ??= 0;
|
||||
parentCounts[instance] += unit.elements.length;
|
||||
}
|
||||
|
||||
const childCounts: { [instance: string]: number } = {};
|
||||
const elementsPerInstance: { [instance: string]: { [invariantId: number]: StructureElement.Set } } = {};
|
||||
for (const unit of substructure.units) {
|
||||
const instance = unit.conformation.operator.instanceId;
|
||||
childCounts[instance] ??= 0;
|
||||
childCounts[instance] += unit.elements.length;
|
||||
(elementsPerInstance[instance] ??= {})[unit.invariantId] = unit.elements;
|
||||
}
|
||||
|
||||
const parentInstances = Object.keys(parentCounts);
|
||||
const childInstances = Object.keys(childCounts);
|
||||
const groupGranularity = !childInstances.every(inst => childCounts[inst] === parentCounts[inst]);
|
||||
let instanceGranularity: boolean;
|
||||
|
||||
if (childInstances.length === 0) {
|
||||
instanceGranularity = false;
|
||||
} else if (childInstances.length < parentInstances.length) {
|
||||
instanceGranularity = true;
|
||||
} else {
|
||||
instanceGranularity = false;
|
||||
for (let i = 1; i < childInstances.length; i++) {
|
||||
if (!deepEqual(elementsPerInstance[childInstances[0]], elementsPerInstance[childInstances[i]])) {
|
||||
instanceGranularity = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (groupGranularity) return instanceGranularity ? 'groupInstance' : 'group';
|
||||
else return instanceGranularity ? 'instance' : 'uniform';
|
||||
}
|
||||
|
||||
|
||||
/** Unique name for "Multilayer" color theme */
|
||||
export const MultilayerColorThemeName = 'mvs-multilayer';
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
*/
|
||||
|
||||
import { BaseGeometry } from '../../../mol-geo/geometry/base';
|
||||
import { Lines } from '../../../mol-geo/geometry/lines/lines';
|
||||
import { LinesBuilder } from '../../../mol-geo/geometry/lines/lines-builder';
|
||||
import { addFixedCountDashedCylinder, addSimpleCylinder, BasicCylinderProps } from '../../../mol-geo/geometry/mesh/builder/cylinder';
|
||||
@@ -25,17 +26,19 @@ import { Structure, StructureElement, StructureSelection } from '../../../mol-mo
|
||||
import { StructureQueryHelper } from '../../../mol-plugin-state/helpers/structure-query';
|
||||
import { PluginStateObject as SO } from '../../../mol-plugin-state/objects';
|
||||
import { PluginContext } from '../../../mol-plugin/context';
|
||||
import { ShapeRepresentation } from '../../../mol-repr/shape/representation';
|
||||
import { Expression } from '../../../mol-script/language/expression';
|
||||
import { StateObject } from '../../../mol-state';
|
||||
import { StateObject, StateTransformer } from '../../../mol-state';
|
||||
import { Task } from '../../../mol-task';
|
||||
import { round } from '../../../mol-util';
|
||||
import { range } from '../../../mol-util/array';
|
||||
import { Asset } from '../../../mol-util/assets';
|
||||
import { Color } from '../../../mol-util/color';
|
||||
import { MarkerActions } from '../../../mol-util/marker-action';
|
||||
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
|
||||
import { capitalize } from '../../../mol-util/string';
|
||||
import { rowsToExpression, rowToExpression } from '../helpers/selections';
|
||||
import { collectMVSReferences, decodeColor } from '../helpers/utils';
|
||||
import { collectMVSReferences, decodeColor, isDefined } from '../helpers/utils';
|
||||
import { MolstarNode, MolstarSubtree } from '../tree/molstar/molstar-tree';
|
||||
import { MVSNode } from '../tree/mvs/mvs-tree';
|
||||
import { isComponentExpression, isPrimitiveComponentExpressions, isVector3, PrimitivePositionT } from '../tree/mvs/param-types';
|
||||
@@ -131,6 +134,8 @@ export const MVSBuildPrimitiveShape = MVSTransform({
|
||||
const structureRefs = dependencies ? collectMVSReferences([SO.Molecule.Structure], dependencies) : {};
|
||||
const context: PrimitiveBuilderContext = { ...a.data, structureRefs };
|
||||
|
||||
const snapshotKey = { snapshotKey: { ...SnapshotKey, defaultValue: a.data.options?.snapshot_key ?? '' } };
|
||||
|
||||
const label = capitalize(params.kind);
|
||||
if (params.kind === 'mesh') {
|
||||
if (!hasPrimitiveKind(a.data, 'mesh')) return StateObject.Null;
|
||||
@@ -138,18 +143,33 @@ export const MVSBuildPrimitiveShape = MVSTransform({
|
||||
return new SO.Shape.Provider({
|
||||
label,
|
||||
data: context,
|
||||
params: PD.withDefaults(Mesh.Params, { alpha: a.data.options?.opacity ?? 1 }),
|
||||
params: {
|
||||
...PD.withDefaults(Mesh.Params, { alpha: a.data.options?.opacity ?? 1 }),
|
||||
...snapshotKey,
|
||||
},
|
||||
getShape: (_, data, __, prev: any) => buildPrimitiveMesh(data, prev?.geometry),
|
||||
geometryUtils: Mesh.Utils,
|
||||
}, { label });
|
||||
} else if (params.kind === 'labels') {
|
||||
if (!hasPrimitiveKind(a.data, 'label')) return StateObject.Null;
|
||||
|
||||
const options = a.data.options;
|
||||
const bgColor = options?.label_background_color;
|
||||
return new SO.Shape.Provider({
|
||||
label,
|
||||
data: context,
|
||||
params: PD.withDefaults(DefaultLabelParams, { alpha: a.data.options?.label_opacity ?? 1 }),
|
||||
getShape: (_, data, __, prev: any) => buildPrimitiveLabels(data, prev?.geometry),
|
||||
params: {
|
||||
...PD.withDefaults(DefaultLabelParams, {
|
||||
alpha: a.data.options?.label_opacity ?? 1,
|
||||
attachment: options?.label_attachment ?? 'middle-center',
|
||||
tether: options?.label_show_tether ?? false,
|
||||
tetherLength: options?.label_tether_length ?? 1,
|
||||
background: isDefined(bgColor),
|
||||
backgroundColor: isDefined(bgColor) ? decodeColor(bgColor) : undefined,
|
||||
}),
|
||||
...snapshotKey,
|
||||
},
|
||||
getShape: (_, data, props, prev: any) => buildPrimitiveLabels(data, prev?.geometry, props),
|
||||
geometryUtils: Text.Utils,
|
||||
}, { label });
|
||||
} else if (params.kind === 'lines') {
|
||||
@@ -158,7 +178,10 @@ export const MVSBuildPrimitiveShape = MVSTransform({
|
||||
return new SO.Shape.Provider({
|
||||
label,
|
||||
data: context,
|
||||
params: PD.withDefaults(Lines.Params, { alpha: a.data.options?.opacity ?? 1 }),
|
||||
params: {
|
||||
...PD.withDefaults(Lines.Params, { alpha: a.data.options?.opacity ?? 1 }),
|
||||
...snapshotKey,
|
||||
},
|
||||
getShape: (_, data, __, prev: any) => buildPrimitiveLines(data, prev?.geometry),
|
||||
geometryUtils: Lines.Utils,
|
||||
}, { label });
|
||||
@@ -168,6 +191,50 @@ export const MVSBuildPrimitiveShape = MVSTransform({
|
||||
}
|
||||
});
|
||||
|
||||
export const MVSShapeRepresentation3D = MVSTransform({
|
||||
name: 'shape-representation-3d',
|
||||
display: '3D Representation',
|
||||
from: SO.Shape.Provider,
|
||||
to: SO.Shape.Representation3D,
|
||||
params: (a, ctx: PluginContext) => {
|
||||
return a ? a.data.params : BaseGeometry.Params;
|
||||
}
|
||||
})({
|
||||
canAutoUpdate() {
|
||||
return true;
|
||||
},
|
||||
apply({ a, params }) {
|
||||
return Task.create('Shape Representation', async ctx => {
|
||||
const props = { ...PD.getDefaultValues(a.data.params), ...params };
|
||||
const repr = ShapeRepresentation(a.data.getShape, a.data.geometryUtils);
|
||||
await repr.createOrUpdate(props, a.data.data).runInContext(ctx);
|
||||
|
||||
const pickable = !!(params as any).snapshotKey?.trim();
|
||||
if (pickable) {
|
||||
repr.setState({ pickable, markerActions: MarkerActions.Highlighting });
|
||||
}
|
||||
|
||||
return new SO.Shape.Representation3D({ repr, sourceData: a.data }, { label: a.data.label });
|
||||
});
|
||||
},
|
||||
update({ a, b, newParams }) {
|
||||
return Task.create('Shape Representation', async ctx => {
|
||||
const props = { ...b.data.repr.props, ...newParams };
|
||||
await b.data.repr.createOrUpdate(props, a.data.data).runInContext(ctx);
|
||||
b.data.sourceData = a.data;
|
||||
|
||||
const pickable = !!(newParams as any).snapshotKey?.trim();
|
||||
if (pickable) {
|
||||
b.data.repr.setState({ pickable, markerActions: MarkerActions.Highlighting });
|
||||
}
|
||||
|
||||
return StateTransformer.UpdateResult.Updated;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const SnapshotKey = PD.Text('', { isEssential: true, disableInteractiveUpdates: true, description: 'Activate the snapshot with the provided key when clicking on the label' });
|
||||
|
||||
/* **************************************************** */
|
||||
|
||||
class GroupManager {
|
||||
@@ -216,8 +283,16 @@ interface PrimitiveBuilderContext {
|
||||
structureRefs: Record<string, Structure | undefined>;
|
||||
primitives: MolstarNode<'primitive'>[];
|
||||
options: PrimitivesParams;
|
||||
positionCache: Map<string, [Sphere3D, Box3D]>;
|
||||
positionCache: Map<string, [isDefined: boolean, Sphere3D, Box3D]>;
|
||||
instances: Mat4[] | undefined;
|
||||
emptySelectionWarningPrinted?: boolean;
|
||||
}
|
||||
|
||||
function printEmptySelectionWarning(ctx: PrimitiveBuilderContext, position: PrimitivePositionT): void {
|
||||
if (!ctx.emptySelectionWarningPrinted) {
|
||||
console.warn('Some primitives use positions which refer to empty substructure, not showing these primitives.', position, '(There may be more)');
|
||||
ctx.emptySelectionWarningPrinted = true;
|
||||
}
|
||||
}
|
||||
|
||||
interface MeshBuilderState {
|
||||
@@ -373,14 +448,20 @@ function hasPrimitiveKind(context: PrimitiveBuilderContext, kind: 'mesh' | 'line
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveBasePosition(context: PrimitiveBuilderContext, position: PrimitivePositionT, targetPosition: Vec3) {
|
||||
/** Save resolved position into `targetPosition`.
|
||||
* Return `true` if the resolved position is defined (i.e. vector or non-empty selection);
|
||||
* return `false` if the resolved position is not defined (i.e. empty selection). */
|
||||
function resolveBasePosition(context: PrimitiveBuilderContext, position: PrimitivePositionT, targetPosition: Vec3): boolean {
|
||||
return resolvePosition(context, position, targetPosition, undefined, undefined);
|
||||
}
|
||||
|
||||
const _EmptySphere = Sphere3D.zero();
|
||||
const _EmptyBox = Box3D.zero();
|
||||
|
||||
function resolvePosition(context: PrimitiveBuilderContext, position: PrimitivePositionT, targetPosition: Vec3 | undefined, targetSphere: Sphere3D | undefined, targetBox: Box3D | undefined) {
|
||||
/** Save resolved position into `targetPosition`, `targetSphere`, `targetBox`.
|
||||
* Return `true` if the resolved position is defined (i.e. vector or non-empty selection);
|
||||
* return `false` if the resolved position is not defined (i.e. empty selection). */
|
||||
function resolvePosition(context: PrimitiveBuilderContext, position: PrimitivePositionT, targetPosition: Vec3 | undefined, targetSphere: Sphere3D | undefined, targetBox: Box3D | undefined): boolean {
|
||||
let expr: Expression | undefined;
|
||||
let pivotRef: string | undefined;
|
||||
|
||||
@@ -388,7 +469,7 @@ function resolvePosition(context: PrimitiveBuilderContext, position: PrimitivePo
|
||||
if (targetPosition) Vec3.copy(targetPosition, position as any);
|
||||
if (targetSphere) Sphere3D.set(targetSphere, position as any, 0);
|
||||
if (targetBox) Box3D.set(targetBox, position as any, position as any);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isPrimitiveComponentExpressions(position)) {
|
||||
@@ -409,36 +490,41 @@ function resolvePosition(context: PrimitiveBuilderContext, position: PrimitivePo
|
||||
throw new Error(`Structure with ref '${pivotRef ?? '<default>'}' not found.`);
|
||||
}
|
||||
|
||||
const cackeKey = JSON.stringify(position);
|
||||
if (context.positionCache.has(cackeKey)) {
|
||||
const cached = context.positionCache.get(cackeKey)!;
|
||||
if (targetPosition) Vec3.copy(targetPosition, cached[0].center);
|
||||
if (targetSphere) Sphere3D.copy(targetSphere, cached[0]);
|
||||
if (targetBox) Box3D.copy(targetBox, cached[1]);
|
||||
return;
|
||||
const cacheKey = JSON.stringify(position);
|
||||
if (context.positionCache.has(cacheKey)) {
|
||||
const [isDefined, sphere, box] = context.positionCache.get(cacheKey)!;
|
||||
if (targetPosition) Vec3.copy(targetPosition, sphere.center);
|
||||
if (targetSphere) Sphere3D.copy(targetSphere, sphere);
|
||||
if (targetBox) Box3D.copy(targetBox, box);
|
||||
return isDefined;
|
||||
}
|
||||
|
||||
const { selection } = StructureQueryHelper.createAndRun(pivot, expr);
|
||||
|
||||
let box: Box3D;
|
||||
let sphere: Sphere3D;
|
||||
let isDefined: boolean;
|
||||
|
||||
if (StructureSelection.isEmpty(selection)) {
|
||||
if (targetPosition) Vec3.set(targetPosition, 0, 0, 0);
|
||||
box = _EmptyBox;
|
||||
sphere = _EmptySphere;
|
||||
isDefined = false;
|
||||
printEmptySelectionWarning(context, position);
|
||||
} else {
|
||||
const loci = StructureSelection.toLociWithSourceUnits(selection);
|
||||
const boundary = StructureElement.Loci.getBoundary(loci);
|
||||
if (targetPosition) Vec3.copy(targetPosition, boundary.sphere.center);
|
||||
box = boundary.box;
|
||||
sphere = boundary.sphere;
|
||||
isDefined = true;
|
||||
}
|
||||
|
||||
if (targetSphere) Sphere3D.copy(targetSphere, sphere);
|
||||
if (targetBox) Box3D.copy(targetBox, box);
|
||||
|
||||
context.positionCache.set(cackeKey, [sphere, box]);
|
||||
context.positionCache.set(cacheKey, [isDefined, sphere, box]);
|
||||
return isDefined;
|
||||
}
|
||||
|
||||
function getInstances(options: PrimitivesParams | undefined): Mat4[] | undefined {
|
||||
@@ -514,8 +600,11 @@ function buildPrimitiveLines(context: PrimitiveBuilderContext, prev?: Lines): Sh
|
||||
);
|
||||
}
|
||||
|
||||
function buildPrimitiveLabels(context: PrimitiveBuilderContext, prev?: Text): Shape<Text> {
|
||||
const labelsBuilder = TextBuilder.create(BaseLabelProps, 1024, 1024, prev);
|
||||
function buildPrimitiveLabels(context: PrimitiveBuilderContext, prev: Text | undefined, props: PD.Values<Text.Params>): Shape<Text> {
|
||||
const labelsBuilder = TextBuilder.create({
|
||||
...BaseLabelProps,
|
||||
...props,
|
||||
}, 1024, 1024, prev);
|
||||
const state: LabelBuilderState = { groups: new GroupManager(), labels: labelsBuilder };
|
||||
|
||||
for (const c of context.primitives) {
|
||||
@@ -631,8 +720,9 @@ const lEnd = Vec3.zero();
|
||||
|
||||
function addTubeMesh(context: PrimitiveBuilderContext, { groups, mesh }: MeshBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'tube'>, options?: { skipResolvePosition?: boolean }) {
|
||||
if (!options?.skipResolvePosition) {
|
||||
resolveBasePosition(context, params.start, lStart);
|
||||
resolveBasePosition(context, params.end, lEnd);
|
||||
const startDefined = resolveBasePosition(context, params.start, lStart);
|
||||
const endDefined = resolveBasePosition(context, params.end, lEnd);
|
||||
if (!startDefined || !endDefined) return;
|
||||
}
|
||||
const radius = params.radius;
|
||||
|
||||
@@ -665,13 +755,17 @@ const ArrowState = {
|
||||
};
|
||||
|
||||
function addArrowMesh(context: PrimitiveBuilderContext, { groups, mesh }: MeshBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'arrow'>) {
|
||||
resolveBasePosition(context, params.start, ArrowState.start);
|
||||
if (params.end) {
|
||||
resolveBasePosition(context, params.end, ArrowState.end);
|
||||
}
|
||||
const startDefined = resolveBasePosition(context, params.start, ArrowState.start);
|
||||
if (!startDefined) return;
|
||||
|
||||
if (params.direction) {
|
||||
if (params.end) {
|
||||
const endDefined = resolveBasePosition(context, params.end, ArrowState.end);
|
||||
if (!endDefined) return;
|
||||
} else if (params.direction) {
|
||||
Vec3.add(ArrowState.end, ArrowState.start, params.direction as any as Vec3);
|
||||
} else {
|
||||
console.warn(`Primitive arrow does not contain "end" nor "distance". Not showing.`);
|
||||
return;
|
||||
}
|
||||
|
||||
Vec3.sub(ArrowState.dir, ArrowState.end, ArrowState.start);
|
||||
@@ -696,9 +790,10 @@ function addArrowMesh(context: PrimitiveBuilderContext, { groups, mesh }: MeshBu
|
||||
groups.updateColor(mesh.currentGroup, params.color);
|
||||
groups.updateTooltip(mesh.currentGroup, params.tooltip);
|
||||
|
||||
const startRadius = params.start_cap_radius ?? tubeRadius;
|
||||
if (params.show_start_cap) {
|
||||
Vec3.scaleAndAdd(ArrowState.startCap, ArrowState.start, ArrowState.dir, startRadius);
|
||||
const startRadius = params.start_cap_radius ?? 2 * tubeRadius;
|
||||
const startCapLength = params.start_cap_length ?? 2 * startRadius;
|
||||
Vec3.scaleAndAdd(ArrowState.startCap, ArrowState.start, ArrowState.dir, startCapLength);
|
||||
addSimpleCylinder(mesh, ArrowState.startCap, ArrowState.start, {
|
||||
radiusBottom: startRadius,
|
||||
radiusTop: 0,
|
||||
@@ -710,9 +805,10 @@ function addArrowMesh(context: PrimitiveBuilderContext, { groups, mesh }: MeshBu
|
||||
Vec3.copy(ArrowState.startCap, ArrowState.start);
|
||||
}
|
||||
|
||||
const endRadius = params.end_cap_radius ?? tubeRadius;
|
||||
if (params.show_end_cap) {
|
||||
Vec3.scaleAndAdd(ArrowState.endCap, ArrowState.end, ArrowState.dir, -endRadius);
|
||||
const endRadius = params.end_cap_radius ?? 2 * tubeRadius;
|
||||
const endCapLength = params.end_cap_length ?? 2 * endRadius;
|
||||
Vec3.scaleAndAdd(ArrowState.endCap, ArrowState.end, ArrowState.dir, -endCapLength);
|
||||
addSimpleCylinder(mesh, ArrowState.endCap, ArrowState.end, {
|
||||
radiusBottom: endRadius,
|
||||
radiusTop: 0,
|
||||
@@ -736,19 +832,26 @@ function addArrowMesh(context: PrimitiveBuilderContext, { groups, mesh }: MeshBu
|
||||
}
|
||||
|
||||
|
||||
function getDistanceLabel(context: PrimitiveBuilderContext, params: PrimitiveParams<'distance_measurement'>) {
|
||||
resolveBasePosition(context, params.start, lStart);
|
||||
resolveBasePosition(context, params.end, lEnd);
|
||||
/** Return distance in angstroms, or `undefined` if any of the endpoints corresponds to empty substructure.
|
||||
* This function also sets `lStart`, `lEnd` globals. */
|
||||
function computeDistance(context: PrimitiveBuilderContext, start: PrimitivePositionT, end: PrimitivePositionT): number | undefined {
|
||||
const startDefined = resolveBasePosition(context, start, lStart);
|
||||
const endDefined = resolveBasePosition(context, end, lEnd);
|
||||
if (startDefined && endDefined) return Vec3.distance(lStart, lEnd);
|
||||
else return undefined;
|
||||
}
|
||||
|
||||
const dist = Vec3.distance(lStart, lEnd);
|
||||
const distance = `${round(dist, 2)} Å`;
|
||||
const label = typeof params.label_template === 'string' ? params.label_template.replace('{{distance}}', distance) : distance;
|
||||
|
||||
return label;
|
||||
// /** Return text for distance measurement label/tooltip. */
|
||||
function distanceLabel(distance: number, params: PrimitiveParams<'distance_measurement'>): string {
|
||||
const distStr = `${round(distance, 2)} Å`;
|
||||
if (typeof params.label_template === 'string') return params.label_template.replace('{{distance}}', distStr);
|
||||
else return distStr;
|
||||
}
|
||||
|
||||
function addDistanceMesh(context: PrimitiveBuilderContext, state: MeshBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'distance_measurement'>) {
|
||||
const tooltip = getDistanceLabel(context, params);
|
||||
const distance = computeDistance(context, params.start, params.end); // sets lStart, lEnd
|
||||
if (distance === undefined) return; // empty substructure in measurement
|
||||
const tooltip = distanceLabel(distance, params);
|
||||
addTubeMesh(context, state, node, { ...params, tooltip } as any, { skipResolvePosition: true });
|
||||
}
|
||||
|
||||
@@ -756,12 +859,8 @@ const labelPos = Vec3.zero();
|
||||
|
||||
function addDistanceLabel(context: PrimitiveBuilderContext, state: LabelBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'distance_measurement'>) {
|
||||
const { labels, groups } = state;
|
||||
resolveBasePosition(context, params.start, lStart);
|
||||
resolveBasePosition(context, params.end, lEnd);
|
||||
|
||||
const dist = Vec3.distance(lStart, lEnd);
|
||||
const distance = `${round(dist, 2)} Å`;
|
||||
const label = typeof params.label_template === 'string' ? params.label_template.replace('{{distance}}', distance) : distance;
|
||||
const dist = computeDistance(context, params.start, params.end); // sets lStart, lEnd
|
||||
if (dist === undefined) return; // empty substructure in measurement
|
||||
|
||||
let size: number | undefined;
|
||||
if (typeof params.label_size === 'number') {
|
||||
@@ -777,30 +876,36 @@ function addDistanceLabel(context: PrimitiveBuilderContext, state: LabelBuilderS
|
||||
groups.updateColor(group, params.label_color);
|
||||
groups.updateSize(group, size);
|
||||
|
||||
labels.add(label, labelPos[0], labelPos[1], labelPos[2], 1.05 * (params.radius), 1, group);
|
||||
labels.add(distanceLabel(dist, params), labelPos[0], labelPos[1], labelPos[2], 1.05 * (params.radius), 1, group);
|
||||
}
|
||||
|
||||
|
||||
const AngleState = {
|
||||
isDefined: false,
|
||||
a: Vec3(),
|
||||
b: Vec3(),
|
||||
c: Vec3(),
|
||||
ba: Vec3(),
|
||||
bc: Vec3(),
|
||||
labelPos: Vec3(),
|
||||
/** Sector radius */
|
||||
radius: 0,
|
||||
label: '',
|
||||
};
|
||||
|
||||
function syncAngleState(context: PrimitiveBuilderContext, params: PrimitiveParams<'angle_measurement'>) {
|
||||
resolveBasePosition(context, params.a, AngleState.a);
|
||||
resolveBasePosition(context, params.b, AngleState.b);
|
||||
resolveBasePosition(context, params.c, AngleState.c);
|
||||
function syncAngleState(context: PrimitiveBuilderContext, params: PrimitiveParams<'angle_measurement'>): void {
|
||||
const aDefined = resolveBasePosition(context, params.a, AngleState.a);
|
||||
const bDefined = resolveBasePosition(context, params.b, AngleState.b);
|
||||
const cDefined = resolveBasePosition(context, params.c, AngleState.c);
|
||||
AngleState.isDefined = aDefined && bDefined && cDefined;
|
||||
if (!AngleState.isDefined) return;
|
||||
|
||||
Vec3.sub(AngleState.ba, AngleState.a, AngleState.b);
|
||||
Vec3.sub(AngleState.bc, AngleState.c, AngleState.b);
|
||||
const value = radToDeg(Vec3.angle(AngleState.ba, AngleState.bc));
|
||||
|
||||
const angle = `${round(value, 2)}\u00B0`;
|
||||
const label = typeof params.label_template === 'string' ? params.label_template.replace('{{angle}}', angle) : angle;
|
||||
AngleState.label = typeof params.label_template === 'string' ? params.label_template.replace('{{angle}}', angle) : angle;
|
||||
|
||||
if (typeof params.section_radius === 'number') {
|
||||
AngleState.radius = params.section_radius;
|
||||
@@ -810,16 +915,16 @@ function syncAngleState(context: PrimitiveBuilderContext, params: PrimitiveParam
|
||||
AngleState.radius *= params.section_radius_scale;
|
||||
}
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
function addAngleMesh(context: PrimitiveBuilderContext, state: MeshBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'angle_measurement'>) {
|
||||
const label = syncAngleState(context, params);
|
||||
syncAngleState(context, params);
|
||||
if (!AngleState.isDefined) return; // empty substructure in measurement
|
||||
|
||||
const { groups, mesh } = state;
|
||||
|
||||
if (params.show_vector) {
|
||||
const radius = 0.01;
|
||||
const radius = params.vector_radius ?? 0.05;
|
||||
const cylinderProps: BasicCylinderProps = {
|
||||
radiusBottom: radius,
|
||||
radiusTop: radius,
|
||||
@@ -829,7 +934,7 @@ function addAngleMesh(context: PrimitiveBuilderContext, state: MeshBuilderState,
|
||||
|
||||
mesh.currentGroup = groups.allocateSingle(node);
|
||||
groups.updateColor(mesh.currentGroup, params.vector_color);
|
||||
groups.updateTooltip(mesh.currentGroup, label);
|
||||
groups.updateTooltip(mesh.currentGroup, AngleState.label);
|
||||
|
||||
let count = Math.ceil(Vec3.magnitude(AngleState.ba) / (2 * radius));
|
||||
addFixedCountDashedCylinder(mesh, AngleState.a, AngleState.b, 1.0, count, true, cylinderProps);
|
||||
@@ -857,14 +962,15 @@ function addAngleMesh(context: PrimitiveBuilderContext, state: MeshBuilderState,
|
||||
theta_start: 0,
|
||||
theta_end: angle,
|
||||
color: params.section_color,
|
||||
tooltip: label,
|
||||
tooltip: AngleState.label,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addAngleLabel(context: PrimitiveBuilderContext, state: LabelBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'angle_measurement'>) {
|
||||
const { labels, groups } = state;
|
||||
const label = syncAngleState(context, params);
|
||||
syncAngleState(context, params);
|
||||
if (!AngleState.isDefined) return; // empty substructure in measurement
|
||||
|
||||
Vec3.normalize(AngleState.ba, AngleState.ba);
|
||||
Vec3.normalize(AngleState.bc, AngleState.bc);
|
||||
@@ -887,7 +993,7 @@ function addAngleLabel(context: PrimitiveBuilderContext, state: LabelBuilderStat
|
||||
groups.updateColor(group, params.label_color);
|
||||
groups.updateSize(group, size);
|
||||
|
||||
labels.add(label, AngleState.labelPos[0], AngleState.labelPos[1], AngleState.labelPos[2], 1, 1, group);
|
||||
labels.add(AngleState.label, AngleState.labelPos[0], AngleState.labelPos[1], AngleState.labelPos[2], 1, 1, group);
|
||||
}
|
||||
|
||||
function resolveLabelRefs(params: PrimitiveParams<'label'>, refs: Set<string>) {
|
||||
@@ -901,7 +1007,8 @@ const PrimitiveLabelState = {
|
||||
|
||||
function addPrimitiveLabel(context: PrimitiveBuilderContext, state: LabelBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'label'>) {
|
||||
const { labels, groups } = state;
|
||||
resolvePosition(context, params.position, PrimitiveLabelState.position, PrimitiveLabelState.sphere, undefined);
|
||||
const positionDefined = resolvePosition(context, params.position, PrimitiveLabelState.position, PrimitiveLabelState.sphere, undefined);
|
||||
if (!positionDefined) return;
|
||||
|
||||
const group = groups.allocateSingle(node);
|
||||
groups.updateColor(group, params.label_color);
|
||||
@@ -951,17 +1058,20 @@ function addEllipseMesh(context: PrimitiveBuilderContext, state: MeshBuilderStat
|
||||
const circle = getCircle({ thetaStart: params.theta_start, thetaEnd: params.theta_end });
|
||||
if (!circle) return;
|
||||
|
||||
resolvePosition(context, params.center, EllipseState.centerPos, undefined, undefined);
|
||||
const centerDefined = resolvePosition(context, params.center, EllipseState.centerPos, undefined, undefined);
|
||||
if (!centerDefined) return;
|
||||
|
||||
if (params.major_axis_endpoint) {
|
||||
resolvePosition(context, params.major_axis_endpoint, EllipseState.majorPos, undefined, undefined);
|
||||
const endpointDefined = resolvePosition(context, params.major_axis_endpoint, EllipseState.majorPos, undefined, undefined);
|
||||
if (!endpointDefined) return;
|
||||
Vec3.sub(EllipseState.majorAxis, EllipseState.majorPos, EllipseState.centerPos);
|
||||
} else {
|
||||
Vec3.copy(EllipseState.majorAxis, params.major_axis as any as Vec3);
|
||||
}
|
||||
|
||||
if (params.minor_axis_endpoint) {
|
||||
resolvePosition(context, params.minor_axis_endpoint, EllipseState.minorPos, undefined, undefined);
|
||||
const endpointDefined = resolvePosition(context, params.minor_axis_endpoint, EllipseState.minorPos, undefined, undefined);
|
||||
if (!endpointDefined) return;
|
||||
Vec3.sub(EllipseState.minorAxis, EllipseState.minorPos, EllipseState.centerPos);
|
||||
} else {
|
||||
Vec3.copy(EllipseState.minorAxis, params.minor_axis as any as Vec3);
|
||||
@@ -1016,10 +1126,12 @@ const EllipsoidState = {
|
||||
|
||||
|
||||
function addEllipsoidMesh(context: PrimitiveBuilderContext, state: MeshBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'ellipsoid'>) {
|
||||
resolvePosition(context, params.center, EllipsoidState.centerPos, EllipsoidState.sphere, undefined);
|
||||
const centerDefined = resolvePosition(context, params.center, EllipsoidState.centerPos, EllipsoidState.sphere, undefined);
|
||||
if (!centerDefined) return;
|
||||
|
||||
if (params.major_axis_endpoint) {
|
||||
resolvePosition(context, params.major_axis_endpoint, EllipsoidState.majorPos, undefined, undefined);
|
||||
const endpointDefined = resolvePosition(context, params.major_axis_endpoint, EllipsoidState.majorPos, undefined, undefined);
|
||||
if (!endpointDefined) return;
|
||||
Vec3.sub(EllipsoidState.majorAxis, EllipsoidState.majorPos, EllipsoidState.centerPos);
|
||||
} else if (params.major_axis) {
|
||||
Vec3.copy(EllipsoidState.majorAxis, params.major_axis as any as Vec3);
|
||||
@@ -1028,7 +1140,8 @@ function addEllipsoidMesh(context: PrimitiveBuilderContext, state: MeshBuilderSt
|
||||
}
|
||||
|
||||
if (params.minor_axis_endpoint) {
|
||||
resolvePosition(context, params.minor_axis_endpoint, EllipsoidState.minorPos, undefined, undefined);
|
||||
const endpointDefined = resolvePosition(context, params.minor_axis_endpoint, EllipsoidState.minorPos, undefined, undefined);
|
||||
if (!endpointDefined) return;
|
||||
Vec3.sub(EllipsoidState.minorAxis, EllipsoidState.minorPos, EllipsoidState.centerPos);
|
||||
} else if (params.minor_axis) {
|
||||
Vec3.copy(EllipsoidState.minorAxis, params.minor_axis as any as Vec3);
|
||||
@@ -1082,7 +1195,8 @@ const BoxState = {
|
||||
function addBoxMesh(context: PrimitiveBuilderContext, state: MeshBuilderState, node: MVSNode<'primitive'>, params: PrimitiveParams<'box'>) {
|
||||
if (!params.show_edges && !params.show_faces) return;
|
||||
|
||||
resolvePosition(context, params.center, BoxState.center, undefined, BoxState.boundary);
|
||||
const positionDefined = resolvePosition(context, params.center, BoxState.center, undefined, BoxState.boundary);
|
||||
if (!positionDefined) return;
|
||||
if (params.extent) {
|
||||
Box3D.expand(BoxState.boundary, BoxState.boundary, params.extent as unknown as Vec3);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import { StaticStructureComponentTypes, createStructureComponent } from '../../.
|
||||
import { PluginStateObject } from '../../../mol-plugin-state/objects';
|
||||
import { MolScriptBuilder } from '../../../mol-script/language/builder';
|
||||
import { Expression } from '../../../mol-script/language/expression';
|
||||
import { UUID } from '../../../mol-util';
|
||||
import { arrayExtend, sortIfNeeded } from '../../../mol-util/array';
|
||||
import { mapArrayToObject, pickObjectKeys } from '../../../mol-util/object';
|
||||
import { Choice } from '../../../mol-util/param-choice';
|
||||
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
|
||||
@@ -47,28 +45,27 @@ export function isSelectorAll(props: Selector): props is typeof SelectorAll {
|
||||
|
||||
|
||||
/** Data structure for fast lookup of a structure element location in a substructure */
|
||||
export type ElementSet = { [modelId: UUID]: SortedArray<ElementIndex> }
|
||||
export type ElementSet = { [unitId: number]: SortedArray<ElementIndex> }
|
||||
|
||||
export const ElementSet = {
|
||||
/** Create an `ElementSet` from a structure */
|
||||
fromStructure(structure: Structure | undefined): ElementSet {
|
||||
if (!structure) return {};
|
||||
const out: ElementSet = {};
|
||||
for (const unit of structure.units) {
|
||||
out[unit.id] = unit.elements;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
/** Create an `ElementSet` from the substructure of `structure` defined by `selector` */
|
||||
fromSelector(structure: Structure | undefined, selector: Selector): ElementSet {
|
||||
if (!structure) return {};
|
||||
const arrays: { [modelId: UUID]: ElementIndex[] } = {};
|
||||
const selection = substructureFromSelector(structure, selector); // using `getAtomRangesForRow` might (might not) be faster here
|
||||
for (const unit of selection.units) {
|
||||
arrayExtend(arrays[unit.model.id] ??= [], unit.elements);
|
||||
}
|
||||
const result: { [modelId: UUID]: SortedArray<ElementIndex> } = {};
|
||||
for (const modelId in arrays) {
|
||||
const array = arrays[modelId as UUID];
|
||||
sortIfNeeded(array, (a, b) => a - b);
|
||||
result[modelId as UUID] = SortedArray.ofSortedArray(array);
|
||||
}
|
||||
return result;
|
||||
return this.fromStructure(selection);
|
||||
},
|
||||
/** Decide if the element set `set` contains structure element location `location` */
|
||||
has(set: ElementSet, location: StructureElement.Location): boolean {
|
||||
const array = set[location.unit.model.id];
|
||||
const array = set[location.unit.id];
|
||||
return array ? SortedArray.has(array, location.element) : false;
|
||||
},
|
||||
};
|
||||
|
||||
145
src/extensions/mvs/helpers/colors.ts
Normal file
145
src/extensions/mvs/helpers/colors.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
*/
|
||||
|
||||
import { ElementSymbolColors } from '../../../mol-theme/color/element-symbol';
|
||||
import { ResidueNameColors } from '../../../mol-theme/color/residue-name';
|
||||
import { SecondaryStructureColors as SecStrColors } from '../../../mol-theme/color/secondary-structure';
|
||||
import { Color } from '../../../mol-util/color';
|
||||
import { ColorList } from '../../../mol-util/color/color';
|
||||
import { ColorLists } from '../../../mol-util/color/lists';
|
||||
import { omitObjectKeys } from '../../../mol-util/object';
|
||||
import { ColorDictNameT, ColorListNameT } from '../tree/mvs/param-types';
|
||||
import { decodeColor } from './utils';
|
||||
|
||||
|
||||
/** Colors for amino acid groups, based on Clustal (https://www.jalview.org/help/html/colourSchemes/clustal.html) */
|
||||
const AminoGroupColors = {
|
||||
aromatic: decodeColor('#15A4A4')!,
|
||||
hydrophobic: decodeColor('#80A0F0')!,
|
||||
polar: decodeColor('#15C015')!,
|
||||
positive: decodeColor('#F01505')!,
|
||||
negative: decodeColor('#C048C0')!,
|
||||
proline: decodeColor('#C0C000')!,
|
||||
cysteine: decodeColor('#F08080')!,
|
||||
glycine: decodeColor('#F09048')!,
|
||||
};
|
||||
|
||||
/** Colors for individual amino acids, based on Clustal (https://www.jalview.org/help/html/colourSchemes/clustal.html), plus Jmol colors for nucleotides (http://jmol.sourceforge.net/jscolors/) */
|
||||
const ResiduePropertyColors = {
|
||||
...ResidueNameColors,
|
||||
HIS: AminoGroupColors.aromatic,
|
||||
TYR: AminoGroupColors.aromatic,
|
||||
ALA: AminoGroupColors.hydrophobic,
|
||||
VAL: AminoGroupColors.hydrophobic,
|
||||
LEU: AminoGroupColors.hydrophobic,
|
||||
ILE: AminoGroupColors.hydrophobic,
|
||||
MET: AminoGroupColors.hydrophobic,
|
||||
PHE: AminoGroupColors.hydrophobic,
|
||||
TRP: AminoGroupColors.hydrophobic,
|
||||
SER: AminoGroupColors.polar,
|
||||
THR: AminoGroupColors.polar,
|
||||
ASN: AminoGroupColors.polar,
|
||||
GLN: AminoGroupColors.polar,
|
||||
LYS: AminoGroupColors.positive,
|
||||
ARG: AminoGroupColors.positive,
|
||||
ASP: AminoGroupColors.negative,
|
||||
GLU: AminoGroupColors.negative,
|
||||
PRO: AminoGroupColors.proline,
|
||||
CYS: AminoGroupColors.cysteine,
|
||||
GLY: AminoGroupColors.glycine,
|
||||
};
|
||||
|
||||
/** Colors for secondary structure types, based on Jmol colors (http://jmol.sourceforge.net/jscolors/) */
|
||||
const SecondaryStructureColors = {
|
||||
// Simple categories
|
||||
helix: SecStrColors.alphaHelix,
|
||||
strand: SecStrColors.betaStrand,
|
||||
turn: SecStrColors.betaTurn,
|
||||
bend: SecStrColors.bend,
|
||||
|
||||
// DSSP categories
|
||||
H: SecStrColors.alphaHelix,
|
||||
B: SecStrColors.betaStrand,
|
||||
E: SecStrColors.betaStrand,
|
||||
G: SecStrColors.threeTenHelix,
|
||||
I: SecStrColors.piHelix,
|
||||
P: Color(0xA00000), // Polyproline II helix, Jmol has no color for it
|
||||
T: SecStrColors.betaTurn,
|
||||
S: SecStrColors.bend,
|
||||
};
|
||||
|
||||
export const MvsNamedColorDicts: Record<ColorDictNameT, Record<string, Color>> = {
|
||||
ElementSymbol: omitObjectKeys(ElementSymbolColors, ['C']), // ommitting carbon color to allow easier combination of multiple color layers
|
||||
ResidueName: ResidueNameColors,
|
||||
ResidueProperties: ResiduePropertyColors,
|
||||
SecondaryStructure: SecondaryStructureColors,
|
||||
};
|
||||
|
||||
export const MvsNamedColorLists: Record<ColorListNameT, ColorList> = {
|
||||
// Sequential single-hue
|
||||
Reds: ColorLists['reds'],
|
||||
Oranges: ColorLists['oranges'],
|
||||
Greens: ColorLists['greens'],
|
||||
Blues: ColorLists['blues'],
|
||||
Purples: ColorLists['purples'],
|
||||
Greys: ColorLists['greys'],
|
||||
|
||||
// Sequential multi-hue
|
||||
OrRd: ColorLists['orange-red'],
|
||||
BuGn: ColorLists['blue-green'],
|
||||
PuBuGn: ColorLists['purple-blue-green'],
|
||||
GnBu: ColorLists['green-blue'],
|
||||
PuBu: ColorLists['purple-blue'],
|
||||
BuPu: ColorLists['blue-purple'],
|
||||
RdPu: ColorLists['red-purple'],
|
||||
PuRd: ColorLists['purple-red'],
|
||||
YlOrRd: ColorLists['yellow-orange-red'],
|
||||
YlOrBr: ColorLists['yellow-orange-brown'],
|
||||
YlGn: ColorLists['yellow-green'],
|
||||
YlGnBu: ColorLists['yellow-green-blue'],
|
||||
|
||||
Magma: ColorLists['magma'],
|
||||
Inferno: ColorLists['inferno'],
|
||||
Plasma: ColorLists['plasma'],
|
||||
Viridis: ColorLists['viridis'],
|
||||
Cividis: ColorLists['cividis'],
|
||||
Turbo: ColorLists['turbo'],
|
||||
Warm: ColorLists['warm'],
|
||||
Cool: ColorLists['cool'],
|
||||
CubehelixDefault: ColorLists['cubehelix-default'],
|
||||
|
||||
// Cyclical
|
||||
Rainbow: ColorLists['rainbow'],
|
||||
Sinebow: ColorLists['sinebow'],
|
||||
|
||||
// Diverging
|
||||
RdBu: ColorLists['red-blue'],
|
||||
RdGy: ColorLists['red-grey'],
|
||||
PiYG: ColorLists['pink-yellow-green'],
|
||||
BrBG: ColorLists['brown-white-green'],
|
||||
PRGn: ColorLists['purple-green'],
|
||||
PuOr: ColorLists['purple-orange'],
|
||||
RdYlGn: ColorLists['red-yellow-green'],
|
||||
RdYlBu: ColorLists['red-yellow-blue'],
|
||||
Spectral: ColorLists['spectral'],
|
||||
|
||||
// Categorical
|
||||
Category10: ColorLists['category-10'],
|
||||
Observable10: ColorLists['observable-10'],
|
||||
Tableau10: ColorLists['tableau-10'],
|
||||
|
||||
Set1: ColorLists['set-1'],
|
||||
Set2: ColorLists['set-2'],
|
||||
Set3: ColorLists['set-3'],
|
||||
Pastel1: ColorLists['pastel-1'],
|
||||
Pastel2: ColorLists['pastel-2'],
|
||||
Dark2: ColorLists['dark-2'],
|
||||
Paired: ColorLists['paired'],
|
||||
Accent: ColorLists['accent'],
|
||||
|
||||
// Additional lists, not standard for visualization in general, but commonly used for structures
|
||||
Chainbow: ColorLists['turbo-no-black'],
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
*/
|
||||
@@ -19,6 +19,12 @@ export interface IndicesAndSortings {
|
||||
residuesSortedByLabelSeqId: Mapping<ChainIndex, Sorting<ResidueIndex, number>>,
|
||||
residuesSortedByAuthSeqId: Mapping<ChainIndex, Sorting<ResidueIndex, number>>,
|
||||
residuesByInsCode: Mapping<ChainIndex, Mapping<string, readonly ResidueIndex[]>>,
|
||||
residuesByLabelCompId: Mapping<ChainIndex, Mapping<string, readonly ResidueIndex[]>>,
|
||||
/** Indicates if each residue is listed only once in `residuesByLabelCompId` (i.e. if each residue has only one label_comp_id) */
|
||||
residuesByLabelCompIdIsPure: boolean,
|
||||
residuesByAuthCompId: Mapping<ChainIndex, Mapping<string, readonly ResidueIndex[]>>,
|
||||
/** Indicates if each residue is listed only once in `residuesByAuthCompId` (i.e. if each residue has only one auth_comp_id) */
|
||||
residuesByAuthCompIdIsPure: boolean,
|
||||
atomsById: Mapping<number, ElementIndex>,
|
||||
atomsByIndex: Mapping<number, ElementIndex>,
|
||||
}
|
||||
@@ -36,6 +42,7 @@ export const IndicesAndSortings = {
|
||||
const nChains = h.chains._rowCount;
|
||||
const { label_entity_id, label_asym_id, auth_asym_id } = h.chains;
|
||||
const { label_seq_id, auth_seq_id, pdbx_PDB_ins_code } = h.residues;
|
||||
const { label_comp_id, auth_comp_id } = h.atoms;
|
||||
const { Present } = Column.ValueKind;
|
||||
|
||||
const chainsByLabelEntityId = new MultiMap<string, ChainIndex>();
|
||||
@@ -44,9 +51,16 @@ export const IndicesAndSortings = {
|
||||
const residuesSortedByLabelSeqId = new Map<ChainIndex, Sorting<ResidueIndex, number>>();
|
||||
const residuesSortedByAuthSeqId = new Map<ChainIndex, Sorting<ResidueIndex, number>>();
|
||||
const residuesByInsCode = new Map<ChainIndex, MultiMap<string, ResidueIndex>>();
|
||||
const residuesByLabelCompId = new Map<ChainIndex, MultiMap<string, ResidueIndex>>();
|
||||
let residuesByLabelCompIdIsPure = true;
|
||||
const residuesByAuthCompId = new Map<ChainIndex, MultiMap<string, ResidueIndex>>();
|
||||
let residuesByAuthCompIdIsPure = true;
|
||||
const atomsById = new NumberMap<number, ElementIndex>(nAtoms + 1);
|
||||
const atomsByIndex = new NumberMap<number, ElementIndex>(nAtoms);
|
||||
|
||||
const _labelCompIdSet = new Set<string>();
|
||||
const _authCompIdSet = new Set<string>();
|
||||
|
||||
for (let iChain = 0 as ChainIndex; iChain < nChains; iChain++) {
|
||||
chainsByLabelEntityId.add(label_entity_id.value(iChain), iChain);
|
||||
chainsByLabelAsymId.add(label_asym_id.value(iChain), iChain);
|
||||
@@ -62,12 +76,28 @@ export const IndicesAndSortings = {
|
||||
residuesSortedByAuthSeqId.set(iChain, Sorting.create(residuesWithAuthSeqId, auth_seq_id.value));
|
||||
|
||||
const residuesHereByInsCode = new MultiMap<string, ResidueIndex>();
|
||||
const residuesHereByLabelCompId = new MultiMap<string, ResidueIndex>();
|
||||
const residuesHereByAuthCompId = new MultiMap<string, ResidueIndex>();
|
||||
for (let iRes = iResFrom; iRes < iResTo; iRes++) {
|
||||
if (pdbx_PDB_ins_code.valueKind(iRes) === Present) {
|
||||
residuesHereByInsCode.add(pdbx_PDB_ins_code.value(iRes), iRes);
|
||||
}
|
||||
const iAtomFrom = h.residueAtomSegments.offsets[iRes];
|
||||
const iAtomTo = h.residueAtomSegments.offsets[iRes + 1];
|
||||
for (let iAtom = iAtomFrom; iAtom < iAtomTo; iAtom++) {
|
||||
_labelCompIdSet.add(label_comp_id.value(iAtom));
|
||||
_authCompIdSet.add(auth_comp_id.value(iAtom));
|
||||
}
|
||||
if (_labelCompIdSet.size > 1) residuesByLabelCompIdIsPure = false;
|
||||
if (_authCompIdSet.size > 1) residuesByAuthCompIdIsPure = false;
|
||||
for (const labelCompId of _labelCompIdSet) residuesHereByLabelCompId.add(labelCompId, iRes);
|
||||
for (const authCompId of _authCompIdSet) residuesHereByAuthCompId.add(authCompId, iRes);
|
||||
_labelCompIdSet.clear();
|
||||
_authCompIdSet.clear();
|
||||
}
|
||||
residuesByInsCode.set(iChain, residuesHereByInsCode);
|
||||
residuesByLabelCompId.set(iChain, residuesHereByLabelCompId);
|
||||
residuesByAuthCompId.set(iChain, residuesHereByAuthCompId);
|
||||
}
|
||||
|
||||
const atomId = model.atomicConformation.atomId.value;
|
||||
@@ -80,6 +110,7 @@ export const IndicesAndSortings = {
|
||||
return {
|
||||
chainsByLabelEntityId, chainsByLabelAsymId, chainsByAuthAsymId,
|
||||
residuesSortedByLabelSeqId, residuesSortedByAuthSeqId, residuesByInsCode,
|
||||
residuesByLabelCompId, residuesByLabelCompIdIsPure, residuesByAuthCompId, residuesByAuthCompIdIsPure,
|
||||
atomsById, atomsByIndex,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
*/
|
||||
@@ -7,13 +7,13 @@
|
||||
import { Sphere3D } from '../../../mol-math/geometry';
|
||||
import { BoundaryHelper } from '../../../mol-math/geometry/boundary-helper';
|
||||
import { Vec3 } from '../../../mol-math/linear-algebra';
|
||||
import { ElementIndex, Model, Structure, StructureElement, StructureProperties } from '../../../mol-model/structure';
|
||||
import { UUID } from '../../../mol-util';
|
||||
import { ElementIndex, Model, Structure, StructureElement, StructureProperties, Unit } from '../../../mol-model/structure';
|
||||
import { arrayExtend } from '../../../mol-util/array';
|
||||
import { AtomRanges } from './atom-ranges';
|
||||
import { IndicesAndSortings } from './indexing';
|
||||
import { MVSAnnotationRow } from './schemas';
|
||||
import { getAtomRangesForRows } from './selections';
|
||||
import { isDefined } from './utils';
|
||||
|
||||
|
||||
/** Properties describing position, size, etc. of a text in 3D */
|
||||
@@ -34,9 +34,25 @@ const boundaryHelper = new BoundaryHelper('98');
|
||||
const outAtoms: ElementIndex[] = [];
|
||||
const outFirstAtomIndex: { value?: number } = {};
|
||||
|
||||
/** Helper for caching atom ranges qualifying to a group of annotation rows, per `Unit`. */
|
||||
class AtomRangesCache {
|
||||
private readonly cache: { [key: string]: AtomRanges } = {};
|
||||
private readonly hasOperators: boolean;
|
||||
|
||||
constructor(private readonly rows: MVSAnnotationRow[]) {
|
||||
this.hasOperators = rows.some(row => isDefined(row.instance_id));
|
||||
}
|
||||
|
||||
get(unit: Unit): AtomRanges {
|
||||
const instanceId = unit.conformation.operator.instanceId;
|
||||
const key = this.hasOperators ? `${unit.model.id}:${instanceId}` : unit.model.id;
|
||||
return this.cache[key] ??= getAtomRangesForRows(this.rows, unit.model, instanceId, IndicesAndSortings.get(unit.model));
|
||||
}
|
||||
}
|
||||
|
||||
/** Return `TextProps` (position, size, etc.) for a text that is to be bound to a substructure of `structure` defined by union of `rows`.
|
||||
* Derives `center` and `depth` from the boundary sphere of the substructure, `scale` from the number of heavy atoms in the substructure. */
|
||||
export function textPropsForSelection(structure: Structure, sizeFunction: (location: StructureElement.Location) => number, rows: MVSAnnotationRow | MVSAnnotationRow[], onlyInModel?: Model): TextProps | undefined {
|
||||
export function textPropsForSelection(structure: Structure, sizeFunction: (location: StructureElement.Location) => number, rows: MVSAnnotationRow[], onlyInModel?: Model): TextProps | undefined {
|
||||
const loc = StructureElement.Location.create(structure);
|
||||
const { units } = structure;
|
||||
const { type_symbol } = StructureProperties.atom;
|
||||
@@ -45,11 +61,11 @@ export function textPropsForSelection(structure: Structure, sizeFunction: (locat
|
||||
let includedHeavyAtoms = 0;
|
||||
let group: number | undefined = undefined;
|
||||
let atomSize: number | undefined = undefined;
|
||||
const rangesByModel: { [modelId: UUID]: AtomRanges } = {};
|
||||
const atomRangesCache = new AtomRangesCache(rows);
|
||||
for (let iUnit = 0, nUnits = units.length; iUnit < nUnits; iUnit++) {
|
||||
const unit = units[iUnit];
|
||||
if (onlyInModel && unit.model.id !== onlyInModel.id) continue;
|
||||
const ranges = rangesByModel[unit.model.id] ??= getAtomRangesForRows(unit.model, rows, IndicesAndSortings.get(unit.model));
|
||||
const ranges = atomRangesCache.get(unit);
|
||||
loc.unit = unit;
|
||||
AtomRanges.selectAtomsInRanges(unit.elements, ranges, outAtoms, outFirstAtomIndex);
|
||||
for (const atom of outAtoms) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
*/
|
||||
@@ -7,29 +7,44 @@
|
||||
import { ParamDefinition as PD } from '../../../mol-util/param-definition';
|
||||
|
||||
|
||||
/** Similar to `PD.Numeric` but allows leaving empty field in UI (treated as `undefined`) */
|
||||
export function MaybeIntegerParamDefinition(defaultValue?: number, info?: PD.Info): PD.Base<number | undefined> {
|
||||
return PD.Converted<number | undefined, PD.Text>(stringifyMaybeInt, parseMaybeInt, PD.Text(stringifyMaybeInt(defaultValue), info));
|
||||
/** Similar to `PD.Numeric` but allows leaving empty field in UI (treated as `undefined`), and can only have integer values */
|
||||
export function MaybeIntegerParamDefinition(info?: PD.Info & { placeholder?: string }): PD.Converted<number | null, string> {
|
||||
const defaultValue = null; // the default must be null, otherwise real nulls would be replaced by the default
|
||||
return PD.Converted(stringifyMaybeInt, parseMaybeInt, PD.Text(stringifyMaybeInt(defaultValue), { ...info, disableInteractiveUpdates: true, placeholder: info?.placeholder ?? 'null' }));
|
||||
}
|
||||
/** The magic with negative zero looks crazy, but it's needed if we want to be able to write negative numbers, LOL. Please help if you know a better solution. */
|
||||
function parseMaybeInt(input: string): number | undefined {
|
||||
if (input.trim() === '-') return -0;
|
||||
function parseMaybeInt(input: string): number | null {
|
||||
const num = parseInt(input);
|
||||
return isNaN(num) ? undefined : num;
|
||||
return isNaN(num) ? null : num;
|
||||
}
|
||||
function stringifyMaybeInt(num: number | undefined): string {
|
||||
if (num === undefined) return '';
|
||||
if (Object.is(num, -0)) return '-';
|
||||
function stringifyMaybeInt(num: number | null): string {
|
||||
if (num === null) return '';
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
|
||||
/** Similar to `PD.Numeric` but allows leaving empty field in UI (treated as `undefined`) */
|
||||
export function MaybeFloatParamDefinition(info?: PD.Info & { placeholder?: string }): PD.Converted<number | null, string> {
|
||||
const defaultValue = null; // the default must be null, otherwise real nulls would be replaced by the default
|
||||
return PD.Converted(stringifyMaybeFloat, parseMaybeFloat, PD.Text(stringifyMaybeFloat(defaultValue), { ...info, disableInteractiveUpdates: true, placeholder: info?.placeholder ?? 'null' }));
|
||||
}
|
||||
function parseMaybeFloat(input: string): number | null {
|
||||
const num = parseFloat(input);
|
||||
return isNaN(num) ? null : num;
|
||||
}
|
||||
function stringifyMaybeFloat(num: number | null): string {
|
||||
if (num === null) return '';
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
|
||||
/** Similar to `PD.Text` but leaving empty field in UI is treated as `undefined` */
|
||||
export function MaybeStringParamDefinition(defaultValue?: string, info?: PD.Info): PD.Base<string | undefined> {
|
||||
return PD.Converted<string | undefined, PD.Text>(stringifyMaybeString, parseMaybeString, PD.Text(stringifyMaybeString(defaultValue), info));
|
||||
export function MaybeStringParamDefinition(info?: PD.Info & { placeholder?: string }): PD.Converted<string | null, string> {
|
||||
const defaultValue = null; // the default must be null, otherwise real nulls would be replaced by the default
|
||||
return PD.Converted(stringifyMaybeString, parseMaybeString, PD.Text(stringifyMaybeString(defaultValue), { ...info, placeholder: info?.placeholder ?? 'null' }));
|
||||
}
|
||||
function parseMaybeString(input: string): string | undefined {
|
||||
return input === '' ? undefined : input;
|
||||
function parseMaybeString(input: string): string | null {
|
||||
return input === '' ? null : input;
|
||||
}
|
||||
function stringifyMaybeString(str: string | undefined): string {
|
||||
return str === undefined ? '' : str;
|
||||
function stringifyMaybeString(str: string | null): string {
|
||||
return str === null ? '' : str;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ const AllAtomicCifAnnotationSchema = {
|
||||
beg_auth_seq_id: int,
|
||||
/** Maximum auth_seq_id (inclusive) */
|
||||
end_auth_seq_id: int,
|
||||
label_comp_id: str,
|
||||
auth_comp_id: str,
|
||||
// residue_index: int, // 0-based residue index in the source file // TODO this is defined in Python builder but not supported by Molstar yet
|
||||
|
||||
/** Atom name like 'CA', 'N', 'O'... */
|
||||
label_atom_id: str,
|
||||
@@ -74,6 +77,9 @@ const AllAtomicCifAnnotationSchema = {
|
||||
atom_id: int,
|
||||
/** 0-based index of the atom in the source data */
|
||||
atom_index: int,
|
||||
/** Instance identifier to distinguish instances of the same chain created by applying different symmetry operators,
|
||||
* like 'ASM-X0-1' for assemblies or '1_555' for crystals */
|
||||
instance_id: str,
|
||||
} satisfies Table.Schema;
|
||||
|
||||
/** Allowed fields (i.e. CIF columns or JSON keys) for each annotation schema
|
||||
|
||||
@@ -19,13 +19,19 @@ const EmptyArray: readonly any[] = [];
|
||||
|
||||
|
||||
/** Return atom ranges in `model` which satisfy criteria given by `row` */
|
||||
export function getAtomRangesForRow(model: Model, row: MVSAnnotationRow, indices: IndicesAndSortings): AtomRanges {
|
||||
export function getAtomRangesForRow(row: MVSAnnotationRow, model: Model, instanceId: string, indices: IndicesAndSortings): AtomRanges {
|
||||
if (isDefined(row.instance_id) && row.instance_id !== instanceId) return AtomRanges.empty();
|
||||
|
||||
const h = model.atomicHierarchy;
|
||||
const nAtoms = h.atoms._rowCount;
|
||||
|
||||
const hasAtomIds = isAnyDefined(row.atom_id, row.atom_index);
|
||||
const hasAtomFilter = isAnyDefined(row.label_atom_id, row.auth_atom_id, row.type_symbol);
|
||||
const hasResidueFilter = isAnyDefined(row.label_seq_id, row.auth_seq_id, row.pdbx_PDB_ins_code, row.beg_label_seq_id, row.end_label_seq_id, row.beg_auth_seq_id, row.end_auth_seq_id);
|
||||
const hasAtomFilter = isAnyDefined(row.label_atom_id, row.auth_atom_id, row.type_symbol)
|
||||
|| isDefined(row.label_comp_id) && !indices.residuesByLabelCompIdIsPure
|
||||
|| isDefined(row.auth_comp_id) && !indices.residuesByAuthCompIdIsPure;
|
||||
const hasResidueFilter = isAnyDefined(row.label_seq_id, row.auth_seq_id, row.pdbx_PDB_ins_code,
|
||||
row.beg_label_seq_id, row.end_label_seq_id, row.beg_auth_seq_id, row.end_auth_seq_id,
|
||||
row.label_comp_id, row.auth_comp_id);
|
||||
const hasChainFilter = isAnyDefined(row.label_asym_id, row.auth_asym_id, row.label_entity_id);
|
||||
|
||||
if (hasAtomIds) {
|
||||
@@ -66,12 +72,8 @@ export function getAtomRangesForRow(model: Model, row: MVSAnnotationRow, indices
|
||||
}
|
||||
|
||||
/** Return atom ranges in `model` which satisfy criteria given by any of `rows` (atoms that satisfy more rows are still included only once) */
|
||||
export function getAtomRangesForRows(model: Model, rows: MVSAnnotationRow | MVSAnnotationRow[], indices: IndicesAndSortings): AtomRanges {
|
||||
if (Array.isArray(rows)) {
|
||||
return AtomRanges.union(rows.map(row => getAtomRangesForRow(model, row, indices)));
|
||||
} else {
|
||||
return getAtomRangesForRow(model, rows, indices);
|
||||
}
|
||||
export function getAtomRangesForRows(rows: MVSAnnotationRow[], model: Model, instanceId: string, indices: IndicesAndSortings): AtomRanges {
|
||||
return AtomRanges.union(rows.map(row => getAtomRangesForRow(row, model, instanceId, indices)));
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +105,8 @@ function getQualifyingChains(model: Model, row: MVSAnnotationRow, indices: Indic
|
||||
/** Return an array of residue indexes which satisfy criteria given by `row` */
|
||||
function getQualifyingResidues(model: Model, row: MVSAnnotationRow, indices: IndicesAndSortings, fromChains: readonly ChainIndex[]): ResidueIndex[] {
|
||||
const { label_seq_id, auth_seq_id, pdbx_PDB_ins_code } = model.atomicHierarchy.residues;
|
||||
const { label_comp_id, auth_comp_id } = model.atomicHierarchy.atoms;
|
||||
const { residueAtomSegments, chainAtomSegments } = model.atomicHierarchy;
|
||||
const { Present } = Column.ValueKind;
|
||||
const result: ResidueIndex[] = [];
|
||||
for (const iChain of fromChains) {
|
||||
@@ -152,8 +156,37 @@ function getQualifyingResidues(model: Model, row: MVSAnnotationRow, indices: Ind
|
||||
residuesHere = Sorting.getKeysWithValueInRange(sorting, row.beg_auth_seq_id, row.end_auth_seq_id);
|
||||
}
|
||||
}
|
||||
if (isDefined(row.label_comp_id)) {
|
||||
if (residuesHere) {
|
||||
if (indices.residuesByLabelCompIdIsPure) {
|
||||
residuesHere = residuesHere.filter(i => label_comp_id.value(residueAtomSegments.offsets[i]) === row.label_comp_id);
|
||||
} else {
|
||||
residuesHere = residuesHere.filter(i => {
|
||||
for (let iAtom = residueAtomSegments.offsets[i], stop = residueAtomSegments.offsets[i + 1]; iAtom < stop; iAtom++) {
|
||||
if (label_comp_id.value(iAtom) === row.label_comp_id) return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
residuesHere = indices.residuesByLabelCompId.get(iChain)!.get(row.label_comp_id) ?? EmptyArray;
|
||||
}
|
||||
}
|
||||
if (isDefined(row.auth_comp_id)) {
|
||||
if (residuesHere) {
|
||||
if (indices.residuesByAuthCompIdIsPure) {
|
||||
residuesHere = residuesHere.filter(i => auth_comp_id.value(residueAtomSegments.offsets[i]) === row.auth_comp_id);
|
||||
} else {
|
||||
residuesHere = residuesHere.filter(i => {
|
||||
for (let iAtom = residueAtomSegments.offsets[i], stop = residueAtomSegments.offsets[i + 1]; iAtom < stop; iAtom++) {
|
||||
if (auth_comp_id.value(iAtom) === row.auth_comp_id) return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
residuesHere = indices.residuesByAuthCompId.get(iChain)!.get(row.auth_comp_id) ?? EmptyArray;
|
||||
}
|
||||
}
|
||||
if (!residuesHere) {
|
||||
const { residueAtomSegments, chainAtomSegments } = model.atomicHierarchy;
|
||||
const firstResidueForChain = residueAtomSegments.index[chainAtomSegments.offsets[iChain]];
|
||||
const firstResidueAfterChain = residueAtomSegments.index[chainAtomSegments.offsets[iChain + 1] - 1] + 1;
|
||||
residuesHere = range(firstResidueForChain, firstResidueAfterChain) as ResidueIndex[];
|
||||
@@ -165,7 +198,7 @@ function getQualifyingResidues(model: Model, row: MVSAnnotationRow, indices: Ind
|
||||
|
||||
/** Return an array of atom indexes which satisfy criteria given by `row` */
|
||||
function getQualifyingAtoms(model: Model, row: MVSAnnotationRow, indices: IndicesAndSortings, fromResidues: readonly ResidueIndex[]): ElementIndex[] {
|
||||
const { label_atom_id, auth_atom_id, type_symbol } = model.atomicHierarchy.atoms;
|
||||
const { label_atom_id, auth_atom_id, type_symbol, label_comp_id, auth_comp_id } = model.atomicHierarchy.atoms;
|
||||
const residueAtomSegments_offsets = model.atomicHierarchy.residueAtomSegments.offsets;
|
||||
const result: ElementIndex[] = [];
|
||||
for (const iRes of fromResidues) {
|
||||
@@ -179,6 +212,12 @@ function getQualifyingAtoms(model: Model, row: MVSAnnotationRow, indices: Indice
|
||||
if (isDefined(row.type_symbol)) {
|
||||
filterInPlace(atomIdcs, iAtom => type_symbol.value(iAtom) === row.type_symbol?.toUpperCase());
|
||||
}
|
||||
if (isDefined(row.label_comp_id) && !indices.residuesByLabelCompIdIsPure) {
|
||||
filterInPlace(atomIdcs, iAtom => label_comp_id.value(iAtom) === row.label_comp_id);
|
||||
}
|
||||
if (isDefined(row.auth_comp_id) && !indices.residuesByAuthCompIdIsPure) {
|
||||
filterInPlace(atomIdcs, iAtom => auth_comp_id.value(iAtom) === row.auth_comp_id);
|
||||
}
|
||||
arrayExtend(result, atomIdcs);
|
||||
}
|
||||
return result;
|
||||
@@ -228,11 +267,15 @@ export function atomQualifies(model: Model, iAtom: ElementIndex, row: MVSAnnotat
|
||||
if (!matchesRange(row.beg_label_seq_id, row.end_label_seq_id, label_seq_id)) return false;
|
||||
if (!matchesRange(row.beg_auth_seq_id, row.end_auth_seq_id, auth_seq_id)) return false;
|
||||
|
||||
const label_comp_id = h.atoms.label_comp_id.value(iAtom);
|
||||
const auth_comp_id = h.atoms.auth_comp_id.value(iAtom);
|
||||
const label_atom_id = h.atoms.label_atom_id.value(iAtom);
|
||||
const auth_atom_id = h.atoms.auth_atom_id.value(iAtom);
|
||||
const type_symbol = h.atoms.type_symbol.value(iAtom);
|
||||
const atom_id = model.atomicConformation.atomId.value(iAtom);
|
||||
const atom_index = h.atomSourceIndex.value(iAtom);
|
||||
if (!matches(row.label_comp_id, label_comp_id)) return false;
|
||||
if (!matches(row.auth_comp_id, auth_comp_id)) return false;
|
||||
if (!matches(row.label_atom_id, label_atom_id)) return false;
|
||||
if (!matches(row.auth_atom_id, auth_atom_id)) return false;
|
||||
if (!matches(row.type_symbol?.toUpperCase(), type_symbol)) return false;
|
||||
@@ -287,7 +330,7 @@ export function groupRows(rows: readonly MVSAnnotationRow[]): GroupedArray<numbe
|
||||
const groups: number[] = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const group_id = rows[i].group_id;
|
||||
if (group_id === undefined) {
|
||||
if (!isDefined(group_id)) {
|
||||
groups.push(counter++);
|
||||
} else {
|
||||
const groupIndex = groupMap.get(group_id);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2023-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
@@ -9,6 +9,7 @@ import { hashString } from '../../../mol-data/util';
|
||||
import { StateObject } from '../../../mol-state';
|
||||
import { Color } from '../../../mol-util/color';
|
||||
import { ColorNames } from '../../../mol-util/color/names';
|
||||
import { decodeColor as _decodeColor } from '../../../mol-util/color/utils';
|
||||
|
||||
|
||||
/** Represents either the result or the reason of failure of an operation that might have failed */
|
||||
@@ -75,7 +76,7 @@ export function isDefined<T>(value: T | undefined | null): value is T {
|
||||
}
|
||||
/** Return `true` if at least one of `values` is not `undefined` or `null`. */
|
||||
export function isAnyDefined(...values: any[]): boolean {
|
||||
return values.some(v => isDefined(v));
|
||||
return values.some(isDefined);
|
||||
}
|
||||
/** Return filtered array containing all original elements except `undefined` or `null`. */
|
||||
export function filterDefined<T>(elements: (T | undefined | null)[]): T[] {
|
||||
@@ -100,19 +101,7 @@ export type ElementOfSet<S> = S extends Set<infer T> ? T : never
|
||||
/** Convert `colorString` (either X11 color name like 'magenta' or hex code like '#ff00ff') to Color.
|
||||
* Return `undefined` if `colorString` cannot be converted. */
|
||||
export function decodeColor(colorString: string | undefined | null): Color | undefined {
|
||||
if (colorString === undefined || colorString === null) return undefined;
|
||||
let result: Color | undefined;
|
||||
if (HexColor.is(colorString)) {
|
||||
if (colorString.length === 4) {
|
||||
// convert short form to full form (#f0f -> #ff00ff)
|
||||
colorString = `#${colorString[1]}${colorString[1]}${colorString[2]}${colorString[2]}${colorString[3]}${colorString[3]}`;
|
||||
}
|
||||
result = Color.fromHexStyle(colorString);
|
||||
if (result !== undefined && !isNaN(result)) return result;
|
||||
}
|
||||
result = ColorNames[colorString.toLowerCase() as keyof typeof ColorNames];
|
||||
if (result !== undefined) return result;
|
||||
return undefined;
|
||||
return _decodeColor(colorString);
|
||||
}
|
||||
|
||||
/** Regular expression matching a hexadecimal color string, e.g. '#FF1100' or '#f10' */
|
||||
|
||||
@@ -5,16 +5,20 @@
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { Mat3, Mat4, Vec3 } from '../../mol-math/linear-algebra';
|
||||
import { Mat3, Mat4, Quat, Vec3 } from '../../mol-math/linear-algebra';
|
||||
import { Volume } from '../../mol-model/volume';
|
||||
import { StructureComponentParams } from '../../mol-plugin-state/helpers/structure-component';
|
||||
import { StructureFromModel, TransformStructureConformation } from '../../mol-plugin-state/transforms/model';
|
||||
import { StructureFromModel, StructureInstances, TransformStructureConformation } from '../../mol-plugin-state/transforms/model';
|
||||
import { StructureRepresentation3D, VolumeRepresentation3D } from '../../mol-plugin-state/transforms/representation';
|
||||
import { VolumeInstances, VolumeTransform } from '../../mol-plugin-state/transforms/volume';
|
||||
import { StateTransformer } from '../../mol-state';
|
||||
import { arrayDistinct } from '../../mol-util/array';
|
||||
import { Clip } from '../../mol-util/clip';
|
||||
import { Color } from '../../mol-util/color';
|
||||
import { ColorListEntry } from '../../mol-util/color/color';
|
||||
import { canonicalJsonString } from '../../mol-util/json';
|
||||
import { stringToWords } from '../../mol-util/string';
|
||||
import { MVSAnnotationColorThemeProps, MVSAnnotationColorThemeProvider } from './components/annotation-color-theme';
|
||||
import { MVSAnnotationColorThemeProps, MVSAnnotationColorThemeProvider, MVSCategoricalPaletteProps, MVSContinuousPaletteProps, MVSDiscretePaletteProps } from './components/annotation-color-theme';
|
||||
import { MVSAnnotationLabelRepresentationProvider } from './components/annotation-label/representation';
|
||||
import { MVSAnnotationSpec } from './components/annotation-prop';
|
||||
import { MVSAnnotationStructureComponentProps } from './components/annotation-structure-component';
|
||||
@@ -23,13 +27,16 @@ import { CustomLabelTextProps } from './components/custom-label/visual';
|
||||
import { CustomTooltipsProps } from './components/custom-tooltips-prop';
|
||||
import { MultilayerColorThemeName, MultilayerColorThemeProps, NoColor } from './components/multilayer-color-theme';
|
||||
import { SelectorAll } from './components/selector';
|
||||
import { MvsNamedColorDicts, MvsNamedColorLists } from './helpers/colors';
|
||||
import { rowToExpression, rowsToExpression } from './helpers/selections';
|
||||
import { ElementOfSet, decodeColor, isDefined, stringHash } from './helpers/utils';
|
||||
import { MolstarLoadingContext } from './load';
|
||||
import { mvsRefTags, UpdateTarget } from './load-generic';
|
||||
import { Subtree, getChildren } from './tree/generic/tree-schema';
|
||||
import { dfs, formatObject } from './tree/generic/tree-utils';
|
||||
import { MolstarKind, MolstarNode, MolstarNodeParams, MolstarSubtree, MolstarTree } from './tree/molstar/molstar-tree';
|
||||
import { DefaultColor } from './tree/mvs/mvs-tree';
|
||||
import { CategoricalPalette, CategoricalPaletteDefaults, ColorDictNameT, ColorListNameT, ContinuousPalette, ContinuousPaletteDefaults, DiscretePalette, DiscretePaletteDefaults } from './tree/mvs/param-types';
|
||||
|
||||
|
||||
export const AnnotationFromUriKinds = new Set(['color_from_uri', 'component_from_uri', 'label_from_uri', 'tooltip_from_uri'] satisfies MolstarKind[]);
|
||||
@@ -71,14 +78,43 @@ const _tmpVecX = Vec3();
|
||||
const _tmpVecY = Vec3();
|
||||
const _tmpVecZ = Vec3();
|
||||
|
||||
export function transformAndInstantiateStructure(
|
||||
target: UpdateTarget,
|
||||
node: MolstarSubtree<'structure' | 'component' | 'component_from_source' | 'component_from_uri'>,
|
||||
) {
|
||||
return applyTransformAndInstances(target, node, TransformStructureConformation, StructureInstances);
|
||||
}
|
||||
|
||||
export function transformAndInstantiateVolume(target: UpdateTarget, node: MolstarSubtree<'volume'>) {
|
||||
return applyTransformAndInstances(target, node, VolumeTransform, VolumeInstances);
|
||||
}
|
||||
|
||||
function applyTransformAndInstances(target: UpdateTarget, node: MolstarSubtree, transform: StateTransformer, instantiate: StateTransformer) {
|
||||
let modified = target;
|
||||
for (const { params, ref } of transformProps(node, 'transform')) {
|
||||
modified = UpdateTarget.apply(modified, transform, params);
|
||||
UpdateTarget.tag(modified, mvsRefTags(ref));
|
||||
}
|
||||
|
||||
const instances = transformProps(node, 'instance');
|
||||
if (instances.length > 0) {
|
||||
modified = UpdateTarget.apply(modified, instantiate, { transforms: instances.map(i => i.params) });
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
/** Create an array of props for `TransformStructureConformation` transformers from all 'transform' nodes applied to a 'structure' node. */
|
||||
export function transformProps(node: MolstarSubtree<'structure'>): StateTransformer.Params<TransformStructureConformation>[] {
|
||||
const result = [] as StateTransformer.Params<TransformStructureConformation>[];
|
||||
const transforms = getChildren(node).filter(c => c.kind === 'transform') as MolstarNode<'transform'>[];
|
||||
function transformProps(node: MolstarSubtree, kind: 'transform' | 'instance') {
|
||||
const result = [] as { params: StateTransformer.Params<TransformStructureConformation>, ref?: string }[];
|
||||
const transforms = getChildren(node).filter(c => c.kind === kind) as MolstarNode<'transform'>[];
|
||||
for (const transform of transforms) {
|
||||
const { rotation, translation } = transform.params;
|
||||
const matrix = transformFromRotationTranslation(rotation, translation);
|
||||
result.push({ transform: { name: 'matrix', params: { data: matrix, transpose: false } } });
|
||||
let matrix: Mat4 | undefined = transform.params.matrix as Mat4 | undefined;
|
||||
if (!matrix) {
|
||||
const { rotation, translation } = transform.params;
|
||||
matrix = transformFromRotationTranslation(rotation, translation);
|
||||
}
|
||||
result.push({ params: { transform: { name: 'matrix', params: { data: matrix, transpose: false } } }, ref: transform.ref });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -90,10 +126,18 @@ export function collectAnnotationReferences(tree: Subtree<MolstarTree>, context:
|
||||
let spec: Omit<MVSAnnotationSpec, 'id'> | undefined = undefined;
|
||||
if (AnnotationFromUriKinds.has(node.kind as any)) {
|
||||
const p = (node as MolstarNode<AnnotationFromUriKind>).params;
|
||||
spec = { source: { name: 'url', params: { url: p.uri, format: p.format } }, schema: p.schema, cifBlock: blockSpec(p.block_header, p.block_index), cifCategory: p.category_name ?? undefined };
|
||||
spec = {
|
||||
source: { name: 'url', params: { url: p.uri, format: p.format } }, schema: p.schema,
|
||||
cifBlock: blockSpec(p.block_header, p.block_index), cifCategory: p.category_name,
|
||||
fieldRemapping: Object.entries(p.field_remapping).map(([key, value]) => ({ standardName: key, actualName: value })),
|
||||
};
|
||||
} else if (AnnotationFromSourceKinds.has(node.kind as any)) {
|
||||
const p = (node as MolstarNode<AnnotationFromSourceKind>).params;
|
||||
spec = { source: { name: 'source-cif', params: {} }, schema: p.schema, cifBlock: blockSpec(p.block_header, p.block_index), cifCategory: p.category_name ?? undefined };
|
||||
spec = {
|
||||
source: { name: 'source-cif', params: {} }, schema: p.schema,
|
||||
cifBlock: blockSpec(p.block_header, p.block_index), cifCategory: p.category_name,
|
||||
fieldRemapping: Object.entries(p.field_remapping).map(([key, value]) => ({ standardName: key, actualName: value })),
|
||||
};
|
||||
}
|
||||
if (spec) {
|
||||
const key = canonicalJsonString(spec as any);
|
||||
@@ -282,7 +326,7 @@ export function componentFromXProps(node: MolstarNode<'component_from_uri' | 'co
|
||||
}
|
||||
|
||||
/** Create props for `StructureRepresentation3D` transformer from a representation node. */
|
||||
export function representationProps(node: MolstarSubtree<'representation'>): Partial<StateTransformer.Params<StructureRepresentation3D>> {
|
||||
function representationPropsBase(node: MolstarSubtree<'representation'>): Partial<StateTransformer.Params<StructureRepresentation3D>> {
|
||||
const alpha = alphaForNode(node);
|
||||
const params = node.params;
|
||||
switch (params.type) {
|
||||
@@ -314,6 +358,18 @@ export function representationProps(node: MolstarSubtree<'representation'>): Par
|
||||
}
|
||||
}
|
||||
|
||||
export function representationProps(node: MolstarSubtree<'representation'>): Partial<StateTransformer.Params<StructureRepresentation3D>> {
|
||||
const base = representationPropsBase(node);
|
||||
const clip = clippingForNode(node);
|
||||
if (clip) {
|
||||
base.type!.params = { ...base.type?.params, clip };
|
||||
}
|
||||
if (node.custom?.molstar_reprepresentation_params) {
|
||||
base.type!.params = { ...base.type!.params, ...node.custom.molstar_reprepresentation_params };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Create value for `type.params.alpha` prop for `StructureRepresentation3D` transformer from a representation node based on 'opacity' nodes in its subtree. */
|
||||
export function alphaForNode(node: MolstarSubtree<'representation' | 'volume_representation'>): number {
|
||||
const children = getChildren(node).filter(c => c.kind === 'opacity');
|
||||
@@ -324,6 +380,67 @@ export function alphaForNode(node: MolstarSubtree<'representation' | 'volume_rep
|
||||
}
|
||||
}
|
||||
|
||||
function getCommonClipParams(node: MolstarNode<'clip'>): Pick<Clip.Props['objects'][number], 'invert' | 'transform'> {
|
||||
return {
|
||||
invert: !!node.params.invert,
|
||||
transform: node.params.check_transform ? Mat4.fromArray(Mat4(), node.params.check_transform, 0) : Mat4.identity(),
|
||||
};
|
||||
}
|
||||
|
||||
function getClipObject(node: MolstarNode<'clip'>): Clip.Props['objects'][number] | undefined {
|
||||
switch (node.params.type) {
|
||||
case 'sphere':
|
||||
return {
|
||||
type: 'sphere',
|
||||
position: Vec3.ofArray(node.params.center),
|
||||
scale: typeof node.params.radius === 'number'
|
||||
? Vec3.create(2 * node.params.radius, 2 * node.params.radius, 2 * node.params.radius)
|
||||
: Vec3.create(2, 2, 2),
|
||||
rotation: { axis: Vec3.create(1, 0, 0), angle: 0 },
|
||||
...getCommonClipParams(node),
|
||||
};
|
||||
case 'plane': {
|
||||
const up = Vec3.create(0, 1, 0);
|
||||
const n = Vec3.normalize(Vec3(), Vec3.ofArray(node.params.normal));
|
||||
const axis = Vec3.cross(Vec3(), up, n);
|
||||
const isSingular = Vec3.magnitude(axis) < 1e-6;
|
||||
return {
|
||||
type: 'plane',
|
||||
position: Vec3.ofArray(node.params.point),
|
||||
scale: Vec3.create(1, 1, 1),
|
||||
rotation: {
|
||||
axis: isSingular ? Vec3.unitX : axis,
|
||||
angle: isSingular ? 0 : Vec3.angle(up, n) * 180 / Math.PI,
|
||||
},
|
||||
...getCommonClipParams(node),
|
||||
};
|
||||
}
|
||||
case 'box':
|
||||
const q = Quat.fromMat3(Quat(), Mat3.fromArray(Mat3(), node.params.rotation, 0));
|
||||
const axis = Vec3();
|
||||
const angle = Quat.getAxisAngle(axis, q) * 180 / Math.PI;
|
||||
return {
|
||||
type: 'cube',
|
||||
position: Vec3.ofArray(node.params.center),
|
||||
scale: Vec3.ofArray(node.params.size),
|
||||
rotation: { axis, angle },
|
||||
...getCommonClipParams(node),
|
||||
};
|
||||
default:
|
||||
console.warn(`Mol* MVS: Unsupported clip type "${(node as MolstarNode<'clip'>).params.type}" in node ${node.ref}.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function clippingForNode(node: MolstarSubtree<'representation' | 'volume_representation'>): Clip.Props | undefined {
|
||||
const children = getChildren(node).filter(c => c.kind === 'clip');
|
||||
if (!children.length) return;
|
||||
|
||||
const variant = children[0].params.variant === 'object' ? 'instance' : 'pixel';
|
||||
const objects: Clip.Props['objects'] = children.map(getClipObject).filter(o => !!o);
|
||||
|
||||
return { variant, objects } satisfies Clip.Props;
|
||||
}
|
||||
|
||||
function hasMolStarUseDefaultColoring(node: MolstarNode): boolean {
|
||||
if (!node.custom) return false;
|
||||
return 'molstar_use_default_coloring' in node.custom || 'molstar_color_theme_name' in node.custom;
|
||||
@@ -361,31 +478,27 @@ export function colorThemeForNode(node: MolstarSubtree<'color' | 'color_from_uri
|
||||
};
|
||||
}
|
||||
}
|
||||
let annotationId: string | undefined = undefined;
|
||||
let fieldName: string | undefined = undefined;
|
||||
let color: string | undefined = undefined;
|
||||
switch (node?.kind) {
|
||||
case 'color_from_uri':
|
||||
case 'color_from_source':
|
||||
annotationId = context.annotationMap.get(node);
|
||||
fieldName = node.params.field_name;
|
||||
break;
|
||||
case 'color':
|
||||
color = node.params.color;
|
||||
break;
|
||||
}
|
||||
if (annotationId) {
|
||||
return {
|
||||
name: MVSAnnotationColorThemeProvider.name,
|
||||
params: { annotationId, fieldName, background: NoColor } satisfies Partial<MVSAnnotationColorThemeProps>,
|
||||
};
|
||||
} else {
|
||||
if (node?.kind === 'color') {
|
||||
return {
|
||||
name: 'uniform',
|
||||
params: { value: decodeColor(color) },
|
||||
params: { value: decodeColor(node.params.color) },
|
||||
};
|
||||
}
|
||||
if (node?.kind === 'color_from_uri' || node?.kind === 'color_from_source') {
|
||||
const annotationId = context.annotationMap.get(node);
|
||||
if (annotationId === undefined) return {
|
||||
name: 'uniform',
|
||||
params: {},
|
||||
};
|
||||
|
||||
const fieldName = node.params.field_name;
|
||||
return {
|
||||
name: MVSAnnotationColorThemeProvider.name,
|
||||
params: { annotationId, fieldName, background: NoColor, palette: palettePropsFromMVSPalette(node.params.palette) } satisfies Partial<MVSAnnotationColorThemeProps>,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function appliesColorToWholeRepr(node: MolstarNode<'color' | 'color_from_uri' | 'color_from_source'>): boolean {
|
||||
if (node.kind === 'color') {
|
||||
return !isDefined(node.params.selector) || node.params.selector === 'all';
|
||||
@@ -394,6 +507,153 @@ function appliesColorToWholeRepr(node: MolstarNode<'color' | 'color_from_uri' |
|
||||
}
|
||||
}
|
||||
|
||||
const FALLBACK_COLOR = decodeColor(DefaultColor)!;
|
||||
|
||||
function palettePropsFromMVSPalette(palette: MolstarNode<'color_from_uri' | 'color_from_source'>['params']['palette']): MVSAnnotationColorThemeProps['palette'] {
|
||||
if (!palette) {
|
||||
return { name: 'direct', params: {} };
|
||||
}
|
||||
if (palette.kind === 'categorical') {
|
||||
const fullParams: Required<CategoricalPalette> = objMerge(CategoricalPaletteDefaults, palette);
|
||||
return {
|
||||
name: 'categorical',
|
||||
params: {
|
||||
colors: categoricalPalettePropsFromMVSColors(fullParams.colors),
|
||||
repeatColorList: fullParams.repeat_color_list,
|
||||
sort: fullParams.sort,
|
||||
sortDirection: fullParams.sort_direction,
|
||||
caseInsensitive: fullParams.case_insensitive,
|
||||
setMissingColor: !!fullParams.missing_color,
|
||||
missingColor: decodeColor(fullParams.missing_color) ?? FALLBACK_COLOR,
|
||||
} satisfies MVSCategoricalPaletteProps,
|
||||
};
|
||||
}
|
||||
if (palette.kind === 'discrete') {
|
||||
const fullParams: Required<DiscretePalette> = objMerge(DiscretePaletteDefaults, palette);
|
||||
return {
|
||||
name: 'discrete',
|
||||
params: {
|
||||
colors: discretePalettePropsFromMVSColors(fullParams.colors, fullParams.reverse),
|
||||
mode: fullParams.mode,
|
||||
xMin: fullParams.value_domain[0],
|
||||
xMax: fullParams.value_domain[1],
|
||||
} satisfies MVSDiscretePaletteProps,
|
||||
};
|
||||
}
|
||||
if (palette.kind === 'continuous') {
|
||||
const fullParams: Required<ContinuousPalette> = objMerge(ContinuousPaletteDefaults, palette);
|
||||
const colors = continuousPalettePropsFromMVSColors(fullParams.colors, fullParams.reverse);
|
||||
return {
|
||||
name: 'continuous',
|
||||
params: {
|
||||
colors: colors,
|
||||
mode: fullParams.mode,
|
||||
xMin: fullParams.value_domain[0],
|
||||
xMax: fullParams.value_domain[1],
|
||||
setUnderflowColor: !!fullParams.underflow_color,
|
||||
underflowColor: (fullParams.underflow_color === 'auto' ? minColor(colors.colors) : decodeColor(fullParams.underflow_color)) ?? FALLBACK_COLOR,
|
||||
setOverflowColor: !!fullParams.overflow_color,
|
||||
overflowColor: (fullParams.overflow_color === 'auto' ? maxColor(colors.colors) : decodeColor(fullParams.overflow_color)) ?? FALLBACK_COLOR,
|
||||
} satisfies MVSContinuousPaletteProps,
|
||||
};
|
||||
}
|
||||
throw new Error(`NotImplementedError: palettePropsFromMVSPalette is not implemented for palette kind "${(palette as any).kind}"`);
|
||||
}
|
||||
|
||||
/** Merge properties of two object into a new object. Property values from `second` override those from `first`, but `undefined` is treated as if property missing while `null` as a regular value. */
|
||||
function objMerge<T extends object, U extends object>(first: T, second: U): T & U {
|
||||
const out: Partial<T & U> = { ...first };
|
||||
for (const key in second) {
|
||||
const value = second[key];
|
||||
if (value !== undefined) out[key] = value as any;
|
||||
}
|
||||
return out as T & U;
|
||||
}
|
||||
|
||||
function categoricalPalettePropsFromMVSColors(colors: Required<CategoricalPalette>['colors']): MVSCategoricalPaletteProps['colors'] {
|
||||
if (typeof colors === 'string') {
|
||||
if (colors in MvsNamedColorLists) {
|
||||
const colorList = MvsNamedColorLists[colors as ColorListNameT];
|
||||
return { name: 'list', params: { kind: 'set', colors: colorList.list } };
|
||||
}
|
||||
if (colors in MvsNamedColorDicts) {
|
||||
const colorDict = MvsNamedColorDicts[colors as ColorDictNameT];
|
||||
return { name: 'dictionary', params: Object.entries(colorDict).map(([value, color]) => ({ value, color })) };
|
||||
}
|
||||
console.warn(`Could not find named color palette "${colors}"`);
|
||||
}
|
||||
if (Array.isArray(colors)) {
|
||||
return { name: 'list', params: { kind: 'set', colors: colors.map(c => decodeColor(c) ?? FALLBACK_COLOR) } };
|
||||
}
|
||||
if (typeof colors === 'object') {
|
||||
return { name: 'dictionary', params: Object.entries(colors).map(([value, color]) => ({ value, color: decodeColor(color) ?? FALLBACK_COLOR })) };
|
||||
}
|
||||
return { name: 'list', params: { kind: 'set', colors: [] } };
|
||||
}
|
||||
|
||||
function discretePalettePropsFromMVSColors(colors: Required<DiscretePalette>['colors'], reverse: boolean): MVSDiscretePaletteProps['colors'] {
|
||||
if (typeof colors === 'string') {
|
||||
if (colors in MvsNamedColorLists) {
|
||||
const colorList = MvsNamedColorLists[colors];
|
||||
const list = reverse ? colorList.list.slice().reverse() : colorList.list;
|
||||
const sectionLength = 1 / list.length;
|
||||
return list.map((e, i) => ({ color: Color.fromColorListEntry(e), fromValue: i * sectionLength, toValue: (i + 1) * sectionLength }));
|
||||
}
|
||||
console.warn(`Could not find named color palette "${colors}"`);
|
||||
}
|
||||
if (Array.isArray(colors) && colors.every(t => typeof t === 'string')) {
|
||||
const list = reverse ? colors.slice().reverse() : colors;
|
||||
const sectionLength = 1 / colors.length;
|
||||
return list.map((c, i) => ({ color: decodeColor(c) ?? NoColor, fromValue: i * sectionLength, toValue: (i + 1) * sectionLength }));
|
||||
}
|
||||
if (Array.isArray(colors) && colors.every(t => Array.isArray(t) && t.length === 2)) {
|
||||
return colors.map((t, i) => ({ color: decodeColor(t[0]) ?? NoColor, fromValue: t[1], toValue: colors[i + 1]?.[1] ?? Infinity }));
|
||||
}
|
||||
if (Array.isArray(colors) && colors.every(t => Array.isArray(t) && t.length === 3)) {
|
||||
return colors.map(t => ({ color: decodeColor(t[0]) ?? NoColor, fromValue: t[1] ?? -Infinity, toValue: t[2] ?? Infinity }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function continuousPalettePropsFromMVSColors(colors: Required<ContinuousPalette>['colors'], reverse: boolean): MVSContinuousPaletteProps['colors'] {
|
||||
if (typeof colors === 'string') {
|
||||
// Named color list
|
||||
if (colors in MvsNamedColorLists) {
|
||||
const colorList = MvsNamedColorLists[colors];
|
||||
const list = reverse ? colorList.list.slice().reverse() : colorList.list;
|
||||
const n = list.length - 1;
|
||||
return { kind: 'interpolate', colors: list.map((col, i) => [Color.fromColorListEntry(col), i / n]) };
|
||||
}
|
||||
console.warn(`Could not find named color palette "${colors}"`);
|
||||
}
|
||||
if (Array.isArray(colors)) {
|
||||
if (colors.every(t => Array.isArray(t))) {
|
||||
// Color list with checkpoints
|
||||
// Not applying `reverse` here, as it would have no effect
|
||||
return { kind: 'interpolate', colors: colors.map(t => [decodeColor(t[0]) ?? FALLBACK_COLOR, t[1]]) };
|
||||
} else {
|
||||
// Color list without checkpoints
|
||||
const list = reverse ? colors.slice().reverse() : colors;
|
||||
const n = list.length - 1;
|
||||
return { kind: 'interpolate', colors: list.map((col, i) => [decodeColor(col) ?? FALLBACK_COLOR, i / n]) };
|
||||
}
|
||||
}
|
||||
return { kind: 'interpolate', colors: [] };
|
||||
}
|
||||
|
||||
/** Return the color with the lowest checkpoint, or the first color if checkpoints not available. */
|
||||
function minColor(colors: ColorListEntry[]): Color | undefined {
|
||||
if (colors.length === 0) return undefined;
|
||||
if (colors.every(t => Array.isArray(t))) return Color.fromColorListEntry(colors.reduce((a, b) => a[1] < b[1] ? a : b));
|
||||
return Color.fromColorListEntry(colors[0]);
|
||||
}
|
||||
/** Return the color with the highest checkpoint, or the last color if checkpoints not available. */
|
||||
function maxColor(colors: ColorListEntry[]): Color | undefined {
|
||||
if (colors.length === 0) return undefined;
|
||||
if (colors.every(t => Array.isArray(t))) return Color.fromColorListEntry(colors.reduce((a, b) => a[1] > b[1] ? a : b));
|
||||
return Color.fromColorListEntry(colors[colors.length - 1]);
|
||||
}
|
||||
|
||||
/** Create a mapping of nearest representation nodes for each node in the tree
|
||||
* (to transfer coloring to label nodes smartly).
|
||||
* Only considers nodes within the same 'structure' subtree. */
|
||||
@@ -420,15 +680,26 @@ export function makeNearestReprMap(root: MolstarTree) {
|
||||
/** Create props for `VolumeRepresentation3D` transformer from a representation node. */
|
||||
export function volumeRepresentationProps(node: MolstarSubtree<'volume_representation'>): Partial<StateTransformer.Params<VolumeRepresentation3D>> {
|
||||
const alpha = alphaForNode(node);
|
||||
const clip = clippingForNode(node);
|
||||
const params = node.params;
|
||||
|
||||
const isoValue = typeof params.absolute_isovalue === 'number' ? Volume.IsoValue.absolute(params.absolute_isovalue) : Volume.IsoValue.relative(params.relative_isovalue ?? 0);
|
||||
switch (params.type) {
|
||||
case 'isosurface':
|
||||
const isoValue = typeof params.absolute_isovalue === 'number' ? Volume.IsoValue.absolute(params.absolute_isovalue) : Volume.IsoValue.relative(params.relative_isovalue ?? 0);
|
||||
const visuals: ('wireframe' | 'solid')[] = [];
|
||||
if (params.show_wireframe) visuals.push('wireframe');
|
||||
if (params.show_faces) visuals.push('solid');
|
||||
return {
|
||||
type: { name: 'isosurface', params: { alpha, isoValue, visuals } },
|
||||
type: { name: 'isosurface', params: { alpha, isoValue, visuals, clip } },
|
||||
};
|
||||
case 'grid_slice':
|
||||
const isRelative = params.relative_index !== undefined;
|
||||
const dimension = {
|
||||
name: isRelative ? `relative${params.dimension.toUpperCase()}` : params.dimension,
|
||||
params: params.relative_index ?? params.relative_index
|
||||
};
|
||||
return {
|
||||
type: { name: 'slice', params: { alpha, dimension, isoValue, clip } },
|
||||
};
|
||||
default:
|
||||
throw new Error('NotImplementedError');
|
||||
@@ -448,4 +719,4 @@ export function volumeColorThemeForNode(node: MolstarSubtree<'volume_representat
|
||||
} if (children.length === 1) {
|
||||
return colorThemeForNode(children[0], context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
import { PluginStateSnapshotManager } from '../../mol-plugin-state/manager/snapshots';
|
||||
import { PluginStateObject } from '../../mol-plugin-state/objects';
|
||||
import { Download, ParseCcp4, ParseCif } from '../../mol-plugin-state/transforms/data';
|
||||
import { CustomModelProperties, CustomStructureProperties, ModelFromTrajectory, StructureComponent, StructureFromModel, TrajectoryFromMmCif, TrajectoryFromPDB, TransformStructureConformation } from '../../mol-plugin-state/transforms/model';
|
||||
import { ShapeRepresentation3D, StructureRepresentation3D, VolumeRepresentation3D } from '../../mol-plugin-state/transforms/representation';
|
||||
import { CustomModelProperties, CustomStructureProperties, ModelFromTrajectory, StructureComponent, StructureFromModel, TrajectoryFromMmCif, TrajectoryFromPDB } from '../../mol-plugin-state/transforms/model';
|
||||
import { StructureRepresentation3D, VolumeRepresentation3D } from '../../mol-plugin-state/transforms/representation';
|
||||
import { VolumeFromCcp4, VolumeFromDensityServerCif } from '../../mol-plugin-state/transforms/volume';
|
||||
import { PluginCommands } from '../../mol-plugin/commands';
|
||||
import { PluginContext } from '../../mol-plugin/context';
|
||||
@@ -23,16 +23,16 @@ import { MVSAnnotationTooltipsProvider } from './components/annotation-tooltips-
|
||||
import { CustomLabelProps, CustomLabelRepresentationProvider } from './components/custom-label/representation';
|
||||
import { CustomTooltipsProvider } from './components/custom-tooltips-prop';
|
||||
import { IsMVSModelProps, IsMVSModelProvider } from './components/is-mvs-model-prop';
|
||||
import { getPrimitiveStructureRefs, MVSBuildPrimitiveShape, MVSDownloadPrimitiveData, MVSInlinePrimitiveData } from './components/primitives';
|
||||
import { getPrimitiveStructureRefs, MVSBuildPrimitiveShape, MVSDownloadPrimitiveData, MVSInlinePrimitiveData, MVSShapeRepresentation3D } from './components/primitives';
|
||||
import { IsHiddenCustomStateExtension } from './load-extensions/is-hidden-custom-state';
|
||||
import { NonCovalentInteractionsExtension } from './load-extensions/non-covalent-interactions';
|
||||
import { LoadingActions, LoadingExtension, loadTreeVirtual, UpdateTarget } from './load-generic';
|
||||
import { AnnotationFromSourceKind, AnnotationFromUriKind, collectAnnotationReferences, collectAnnotationTooltips, collectInlineLabels, collectInlineTooltips, colorThemeForNode, componentFromXProps, componentPropsFromSelector, isPhantomComponent, labelFromXProps, makeNearestReprMap, prettyNameFromSelector, representationProps, structureProps, transformProps, volumeColorThemeForNode, volumeRepresentationProps } from './load-helpers';
|
||||
import { AnnotationFromSourceKind, AnnotationFromUriKind, collectAnnotationReferences, collectAnnotationTooltips, collectInlineLabels, collectInlineTooltips, colorThemeForNode, componentFromXProps, componentPropsFromSelector, isPhantomComponent, labelFromXProps, makeNearestReprMap, prettyNameFromSelector, representationProps, structureProps, transformAndInstantiateStructure, transformAndInstantiateVolume, volumeColorThemeForNode, volumeRepresentationProps } from './load-helpers';
|
||||
import { MVSData, MVSData_States, SnapshotMetadata } from './mvs-data';
|
||||
import { validateTree } from './tree/generic/tree-schema';
|
||||
import { convertMvsToMolstar, mvsSanityCheck } from './tree/molstar/conversion';
|
||||
import { MolstarNode, MolstarNodeParams, MolstarSubtree, MolstarTree, MolstarTreeSchema } from './tree/molstar/molstar-tree';
|
||||
import { MVSTreeSchema } from './tree/mvs/mvs-tree';
|
||||
import { type MVSTree, MVSTreeSchema } from './tree/mvs/mvs-tree';
|
||||
|
||||
|
||||
export interface MVSLoadOptions {
|
||||
@@ -65,7 +65,13 @@ export async function loadMVS(plugin: PluginContext, data: MVSData, options: MVS
|
||||
if (options.sanityChecks) mvsSanityCheck(snapshot.root);
|
||||
const molstarTree = convertMvsToMolstar(snapshot.root, options.sourceUrl);
|
||||
validateTree(MolstarTreeSchema, molstarTree, 'Converted Molstar');
|
||||
const entry = molstarTreeToEntry(plugin, molstarTree, { ...snapshot.metadata, previousTransitionDurationMs: previousSnapshot.metadata.transition_duration_ms }, options);
|
||||
const entry = molstarTreeToEntry(
|
||||
plugin,
|
||||
molstarTree,
|
||||
snapshot.root,
|
||||
{ ...snapshot.metadata, previousTransitionDurationMs: previousSnapshot.metadata.transition_duration_ms },
|
||||
options
|
||||
);
|
||||
entries.push(entry);
|
||||
}
|
||||
if (!options.appendSnapshots) {
|
||||
@@ -96,11 +102,17 @@ export async function loadMVS(plugin: PluginContext, data: MVSData, options: MVS
|
||||
}
|
||||
|
||||
|
||||
function molstarTreeToEntry(plugin: PluginContext, tree: MolstarTree, metadata: SnapshotMetadata & { previousTransitionDurationMs?: number }, options?: { keepCamera?: boolean, extensions?: MolstarLoadingExtension<any>[] }) {
|
||||
function molstarTreeToEntry(
|
||||
plugin: PluginContext,
|
||||
tree: MolstarTree,
|
||||
mvsTree: MVSTree,
|
||||
metadata: SnapshotMetadata & { previousTransitionDurationMs?: number },
|
||||
options: { keepCamera?: boolean, extensions?: MolstarLoadingExtension<any>[] }
|
||||
) {
|
||||
const context = MolstarLoadingContext.create();
|
||||
const snapshot = loadTreeVirtual(plugin, tree, MolstarLoadingActions, context, { replaceExisting: true, extensions: options?.extensions ?? BuiltinLoadingExtensions });
|
||||
snapshot.canvas3d = {
|
||||
props: plugin.canvas3d ? modifyCanvasProps(plugin.canvas3d.props, context.canvas) : undefined,
|
||||
props: plugin.canvas3d ? modifyCanvasProps(plugin.canvas3d.props, context.canvas, mvsTree.custom) : undefined,
|
||||
};
|
||||
if (!options?.keepCamera) {
|
||||
snapshot.camera = createPluginStateSnapshotCamera(plugin, context, metadata);
|
||||
@@ -127,7 +139,7 @@ export interface MolstarLoadingContext {
|
||||
cameraParams?: MolstarNodeParams<'camera'>,
|
||||
focuses: { target: StateObjectSelector, params: MolstarNodeParams<'focus'> }[],
|
||||
},
|
||||
canvas?: MolstarNodeParams<'canvas'>,
|
||||
canvas?: MolstarNode<'canvas'>,
|
||||
}
|
||||
export const MolstarLoadingContext = {
|
||||
create(): MolstarLoadingContext {
|
||||
@@ -198,10 +210,7 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
|
||||
structure(updateParent: UpdateTarget, node: MolstarSubtree<'structure'>, context: MolstarLoadingContext): UpdateTarget {
|
||||
const props = structureProps(node);
|
||||
const struct = UpdateTarget.apply(updateParent, StructureFromModel, props);
|
||||
let transformed = struct;
|
||||
for (const t of transformProps(node)) {
|
||||
transformed = UpdateTarget.apply(transformed, TransformStructureConformation, t); // applying to the result of previous transform, to get the correct transform order
|
||||
}
|
||||
const transformed = transformAndInstantiateStructure(struct, node);
|
||||
const annotationTooltips = collectAnnotationTooltips(node, context);
|
||||
const inlineTooltips = collectInlineTooltips(node, context);
|
||||
if (annotationTooltips.length + inlineTooltips.length > 0) {
|
||||
@@ -227,7 +236,7 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
|
||||
colorTheme: colorThemeForNode(nearestReprNode, context),
|
||||
});
|
||||
}
|
||||
return struct;
|
||||
return transformed;
|
||||
},
|
||||
tooltip: undefined, // No action needed, already loaded in `structure`
|
||||
tooltip_from_uri: undefined, // No action needed, already loaded in `structure`
|
||||
@@ -237,21 +246,21 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
|
||||
return updateParent;
|
||||
}
|
||||
const selector = node.params.selector;
|
||||
return UpdateTarget.apply(updateParent, StructureComponent, {
|
||||
return transformAndInstantiateStructure(UpdateTarget.apply(updateParent, StructureComponent, {
|
||||
type: componentPropsFromSelector(selector),
|
||||
label: prettyNameFromSelector(selector),
|
||||
nullIfEmpty: false,
|
||||
});
|
||||
}), node);
|
||||
},
|
||||
component_from_uri(updateParent: UpdateTarget, node: MolstarSubtree<'component_from_uri'>, context: MolstarLoadingContext): UpdateTarget | undefined {
|
||||
if (isPhantomComponent(node)) return undefined;
|
||||
const props = componentFromXProps(node, context);
|
||||
return UpdateTarget.apply(updateParent, MVSAnnotationStructureComponent, props);
|
||||
return transformAndInstantiateStructure(UpdateTarget.apply(updateParent, MVSAnnotationStructureComponent, props), node);
|
||||
},
|
||||
component_from_source(updateParent: UpdateTarget, node: MolstarSubtree<'component_from_source'>, context: MolstarLoadingContext): UpdateTarget | undefined {
|
||||
if (isPhantomComponent(node)) return undefined;
|
||||
const props = componentFromXProps(node, context);
|
||||
return UpdateTarget.apply(updateParent, MVSAnnotationStructureComponent, props);
|
||||
return transformAndInstantiateStructure(UpdateTarget.apply(updateParent, MVSAnnotationStructureComponent, props), node);
|
||||
},
|
||||
representation(updateParent: UpdateTarget, node: MolstarNode<'representation'>, context: MolstarLoadingContext): UpdateTarget {
|
||||
return UpdateTarget.apply(updateParent, StructureRepresentation3D, {
|
||||
@@ -260,14 +269,16 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
|
||||
});
|
||||
},
|
||||
volume(updateParent: UpdateTarget, node: MolstarNode<'volume'>): UpdateTarget | undefined {
|
||||
let volume: UpdateTarget;
|
||||
if (updateParent.transformer?.definition.to.includes(PluginStateObject.Format.Ccp4)) {
|
||||
return UpdateTarget.apply(updateParent, VolumeFromCcp4, {});
|
||||
volume = UpdateTarget.apply(updateParent, VolumeFromCcp4, {});
|
||||
} else if (updateParent.transformer?.definition.to.includes(PluginStateObject.Format.Cif)) {
|
||||
return UpdateTarget.apply(updateParent, VolumeFromDensityServerCif, { blockHeader: node.params.channel_id || undefined });
|
||||
volume = UpdateTarget.apply(updateParent, VolumeFromDensityServerCif, { blockHeader: node.params.channel_id || undefined });
|
||||
} else {
|
||||
console.error(`Unsupported volume format`);
|
||||
return undefined;
|
||||
}
|
||||
return transformAndInstantiateVolume(volume, node);
|
||||
},
|
||||
volume_representation(updateParent: UpdateTarget, node: MolstarNode<'volume_representation'>, context: MolstarLoadingContext): UpdateTarget {
|
||||
return UpdateTarget.apply(updateParent, VolumeRepresentation3D, {
|
||||
@@ -296,7 +307,7 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
|
||||
return updateParent;
|
||||
},
|
||||
canvas(updateParent: UpdateTarget, node: MolstarNode<'canvas'>, context: MolstarLoadingContext): UpdateTarget {
|
||||
context.canvas = node.params;
|
||||
context.canvas = node;
|
||||
return updateParent;
|
||||
},
|
||||
primitives(updateParent: UpdateTarget, tree: MolstarSubtree<'primitives'>, context: MolstarLoadingContext): UpdateTarget {
|
||||
@@ -312,11 +323,11 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
|
||||
|
||||
function applyPrimitiveVisuals(data: UpdateTarget, refs: Set<string>) {
|
||||
const mesh = UpdateTarget.setMvsDependencies(UpdateTarget.apply(data, MVSBuildPrimitiveShape, { kind: 'mesh' }, { state: { isGhost: true } }), refs);
|
||||
UpdateTarget.apply(mesh, ShapeRepresentation3D);
|
||||
UpdateTarget.apply(mesh, MVSShapeRepresentation3D);
|
||||
const labels = UpdateTarget.setMvsDependencies(UpdateTarget.apply(data, MVSBuildPrimitiveShape, { kind: 'labels' }, { state: { isGhost: true } }), refs);
|
||||
UpdateTarget.apply(labels, ShapeRepresentation3D);
|
||||
UpdateTarget.apply(labels, MVSShapeRepresentation3D);
|
||||
const lines = UpdateTarget.setMvsDependencies(UpdateTarget.apply(data, MVSBuildPrimitiveShape, { kind: 'lines' }, { state: { isGhost: true } }), refs);
|
||||
UpdateTarget.apply(lines, ShapeRepresentation3D);
|
||||
UpdateTarget.apply(lines, MVSShapeRepresentation3D);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2023-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
*/
|
||||
@@ -48,7 +48,7 @@ describe('fieldValidationIssues', () => {
|
||||
expect(fieldValidationIssues(numberParam, undefined)).toBeTruthy();
|
||||
});
|
||||
it('fieldValidationIssues union', async () => {
|
||||
const stringOrNumberParam = RequiredField(union([str, float]), 'Testing required field stringOrNumberParam');
|
||||
const stringOrNumberParam = RequiredField(union(str, float), 'Testing required field stringOrNumberParam');
|
||||
expect(fieldValidationIssues(stringOrNumberParam, 1)).toBeUndefined();
|
||||
expect(fieldValidationIssues(stringOrNumberParam, 2)).toBeUndefined();
|
||||
expect(fieldValidationIssues(stringOrNumberParam, 'hello')).toBeUndefined();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) 2023-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import * as iots from 'io-ts';
|
||||
import { PathReporter } from 'io-ts/PathReporter';
|
||||
import { PathReporter } from "io-ts/lib/PathReporter.js";
|
||||
import { onelinerJsonString } from '../../../../mol-util/json';
|
||||
|
||||
|
||||
@@ -26,34 +26,74 @@ export const bool = iots.boolean;
|
||||
export const tuple = iots.tuple;
|
||||
/** Type definition for a list/array, e.g. `list(str)` */
|
||||
export const list = iots.array;
|
||||
/** Type definition for union types, e.g. `union([str, int])` means string or integer */
|
||||
export const union = iots.union;
|
||||
/** Type definition used to create objects */
|
||||
export const obj = iots.type;
|
||||
/** Type definition used to create partial objects */
|
||||
export const partial = iots.partial;
|
||||
/** Type definition for a dictionary/mapping/record, e.g. `dict(str, float)` means type `{ [K in string]: number }` */
|
||||
export const dict = iots.record;
|
||||
|
||||
/** Type definition used to create objects, e.g. `object({ name: str, age: float }, { address: str })` means type `{ name: string, age: number, address?: string }` */
|
||||
export function object<P extends iots.Props, Q extends iots.Props>(props: P, optionalProps: undefined, name?: string): iots.TypeC<P>;
|
||||
export function object<P extends iots.Props, Q extends iots.Props>(props: P, optionalProps: Q, name?: string): iots.IntersectionC<[iots.TypeC<P>, iots.PartialC<Q>]>;
|
||||
export function object<P extends iots.Props, Q extends iots.Props>(props: P, optionalProps?: Q, name?: string) {
|
||||
if (!optionalProps) {
|
||||
return iots.type(props, name);
|
||||
}
|
||||
|
||||
if (name === undefined) {
|
||||
const nameChunks = [];
|
||||
for (const key in props) {
|
||||
nameChunks.push(`${key}: ${props[key].name}`);
|
||||
}
|
||||
for (const key in optionalProps) {
|
||||
nameChunks.push(`${key}?: ${optionalProps[key].name}`);
|
||||
}
|
||||
name = `{ ${nameChunks.join(', ')} }`;
|
||||
}
|
||||
return iots.intersection([iots.type(props), iots.partial(optionalProps)], name);
|
||||
}
|
||||
|
||||
/** Type definition used to create partial objects, e.g. `partial({ name: str, age: float })` means type `{ name?: string, age?: number }` */
|
||||
export function partial<P extends iots.Props>(props: P, name?: string) {
|
||||
if (name === undefined) {
|
||||
const nameChunks = [];
|
||||
for (const key in props) {
|
||||
nameChunks.push(`${key}?: ${props[key].name}`);
|
||||
}
|
||||
name = `{ ${nameChunks.join(', ')} }`;
|
||||
}
|
||||
return iots.partial(props, name);
|
||||
}
|
||||
|
||||
/** Type definition for union types, e.g. `union(str, int)` means string or integer */
|
||||
export function union<T1 extends iots.Mixed, T2 extends iots.Mixed, TOthers extends iots.Mixed[]>(first: T1, second: T2, ...others: TOthers): iots.UnionC<[T1, T2, ...TOthers]> {
|
||||
const baseTypes: iots.Mixed[] = [];
|
||||
for (const type of [first, second, ...others]) {
|
||||
if (type instanceof iots.UnionType) {
|
||||
baseTypes.push(...type.types);
|
||||
} else {
|
||||
baseTypes.push(type);
|
||||
}
|
||||
}
|
||||
return iots.union(baseTypes as any);
|
||||
}
|
||||
|
||||
/** Type definition for nullable types, e.g. `nullable(str)` means string or `null` */
|
||||
export function nullable<T extends iots.Type<any>>(type: T) {
|
||||
return union([type, iots.null]);
|
||||
export function nullable<V>(type: iots.Type<V>): iots.Type<V | null> {
|
||||
return union(type, iots.null);
|
||||
}
|
||||
|
||||
/** Type definition for literal types, e.g. `literal('red', 'green', 'blue')` means 'red' or 'green' or 'blue' */
|
||||
export function literal<V extends string | number | boolean>(...values: V[]) {
|
||||
if (values.length === 0) {
|
||||
throw new Error(`literal type must have at least one value`);
|
||||
}
|
||||
const typeName = `(${values.map(v => onelinerJsonString(v)).join(' | ')})`;
|
||||
const typeName = values.length === 1 ? onelinerJsonString(values[0]) : `(${values.map(v => onelinerJsonString(v)).join(' | ')})`;
|
||||
const valueSet = new Set(values);
|
||||
return new iots.Type<V>(
|
||||
typeName,
|
||||
((value: any) => values.includes(value)) as any,
|
||||
(value, ctx) => values.includes(value as any) ? { _tag: 'Right', right: value as any } : { _tag: 'Left', left: [{ value: value, context: ctx, message: `"${value}" is not a valid value for literal type ${typeName}` }] },
|
||||
((value: any) => valueSet.has(value)) as any,
|
||||
(value, ctx) => valueSet.has(value as any) ? { _tag: 'Right', right: value as any } : { _tag: 'Left', left: [{ value: value, context: ctx, message: `"${value}" is not a valid value for literal type ${typeName}` }] },
|
||||
value => value
|
||||
);
|
||||
}
|
||||
/** Type definition for mapping between two types, e.g. `mapping(str, float)` means type `{ [key in string]: number }` */
|
||||
export function mapping<A extends iots.Type<any>, B extends iots.Type<any>>(from: A, to: B) {
|
||||
return iots.record(from, to);
|
||||
}
|
||||
|
||||
|
||||
interface FieldBase<V extends AllowedValueTypes = any, R extends boolean = boolean> {
|
||||
|
||||
@@ -160,7 +160,7 @@ export class Parse extends _Base<'parse'> {
|
||||
|
||||
|
||||
/** MVS builder pointing to a 'structure' node */
|
||||
export class Structure extends _Base<'structure'> implements PrimitivesMixin {
|
||||
export class Structure extends _Base<'structure'> implements PrimitivesMixin, TransformMixin {
|
||||
/** Add a 'component' node and return builder pointing to it. 'component' node instructs to create a component (i.e. a subset of the parent structure). */
|
||||
component(params: Partial<MVSNodeParams<'component'>> & CustomAndRef = {}): Component {
|
||||
const fullParams = { ...params, selector: params.selector ?? 'all' };
|
||||
@@ -194,21 +194,15 @@ export class Structure extends _Base<'structure'> implements PrimitivesMixin {
|
||||
this.addChild('tooltip_from_source', params);
|
||||
return this;
|
||||
}
|
||||
/** Add a 'transform' node and return builder pointing back to the structure node. 'transform' node instructs to rotate and/or translate structure coordinates. */
|
||||
transform(params: MVSNodeParams<'transform'> & CustomAndRef = {}): Structure {
|
||||
if (params.rotation && params.rotation.length !== 9) {
|
||||
throw new Error('ValueError: `rotation` parameter must be an array of 9 numbers');
|
||||
}
|
||||
this.addChild('transform', params);
|
||||
return this;
|
||||
}
|
||||
transform = bindMethod(this, TransformMixinImpl, 'transform');
|
||||
instance = bindMethod(this, TransformMixinImpl, 'instance');
|
||||
primitives = bindMethod(this, PrimitivesMixinImpl, 'primitives');
|
||||
primitives_from_uri = bindMethod(this, PrimitivesMixinImpl, 'primitives_from_uri');
|
||||
}
|
||||
|
||||
|
||||
/** MVS builder pointing to a 'component' or 'component_from_uri' or 'component_from_source' node */
|
||||
export class Component extends _Base<'component' | 'component_from_uri' | 'component_from_source'> implements FocusMixin {
|
||||
export class Component extends _Base<'component' | 'component_from_uri' | 'component_from_source'> implements FocusMixin, TransformMixin {
|
||||
/** Add a 'representation' node and return builder pointing to it. 'representation' node instructs to create a visual representation of a component. */
|
||||
representation(params: Partial<MVSNodeParams<'representation'>> & CustomAndRef = {}): Representation {
|
||||
const fullParams: MVSNodeParams<'representation'> = { ...params, type: params.type ?? 'cartoon' };
|
||||
@@ -225,6 +219,8 @@ export class Component extends _Base<'component' | 'component_from_uri' | 'compo
|
||||
return this;
|
||||
}
|
||||
focus = bindMethod(this, FocusMixinImpl, 'focus');
|
||||
transform = bindMethod(this, TransformMixinImpl, 'transform');
|
||||
instance = bindMethod(this, TransformMixinImpl, 'instance');
|
||||
}
|
||||
|
||||
|
||||
@@ -250,17 +246,26 @@ export class Representation extends _Base<'representation'> {
|
||||
this.addChild('opacity', params);
|
||||
return this;
|
||||
}
|
||||
/** Add a 'clip' node and return builder pointing back to the representation node. 'clip' node instructs to apply clipping to a visual representation. */
|
||||
clip(params: MVSNodeParams<'clip'> & CustomAndRef): Representation {
|
||||
this.addChild('clip', params);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** MVS builder pointing to a 'component' or 'component_from_uri' or 'component_from_source' node */
|
||||
export class Volume extends _Base<'volume'> implements FocusMixin {
|
||||
export class Volume extends _Base<'volume'> implements FocusMixin, TransformMixin {
|
||||
/** Add a 'representation' node and return builder pointing to it. 'representation' node instructs to create a visual representation of a component. */
|
||||
representation(params: Partial<MVSNodeParams<'volume_representation'>> & CustomAndRef = {}): VolumeRepresentation {
|
||||
const fullParams: MVSNodeParams<'volume_representation'> = { ...params, type: params.type ?? 'isosurface' };
|
||||
return new VolumeRepresentation(this._root, this.addChild('volume_representation', fullParams));
|
||||
representation(params?: MVSNodeParams<'volume_representation'> & CustomAndRef): VolumeRepresentation {
|
||||
if (!params) {
|
||||
params = { type: 'isosurface' };
|
||||
}
|
||||
return new VolumeRepresentation(this._root, this.addChild('volume_representation', params));
|
||||
}
|
||||
focus = bindMethod(this, FocusMixinImpl, 'focus');
|
||||
transform = bindMethod(this, TransformMixinImpl, 'transform');
|
||||
instance = bindMethod(this, TransformMixinImpl, 'instance');
|
||||
}
|
||||
|
||||
|
||||
@@ -277,6 +282,11 @@ export class VolumeRepresentation extends _Base<'volume_representation'> impleme
|
||||
return this;
|
||||
}
|
||||
focus = bindMethod(this, FocusMixinImpl, 'focus');
|
||||
/** Add a 'clip' node and return builder pointing back to the representation node. 'clip' node instructs to apply clipping to a visual representation. */
|
||||
clip(params: MVSNodeParams<'clip'> & CustomAndRef): VolumeRepresentation {
|
||||
this.addChild('clip', params);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -380,6 +390,37 @@ class PrimitivesMixinImpl extends _Base<MVSKind> implements PrimitivesMixin {
|
||||
}
|
||||
};
|
||||
|
||||
interface TransformMixin {
|
||||
/** Add a 'transform' node and return builder pointing back to this node. 'transform' node instructs to rotate and/or translate coordinates. */
|
||||
transform(params: MVSNodeParams<'transform'> & CustomAndRef): this
|
||||
/** Add an 'instance' node and return builder pointing back to this node. 'instance' node instructs to create a new instance of the object. */
|
||||
instance(params: MVSNodeParams<'instance'> & CustomAndRef): this
|
||||
};
|
||||
class TransformMixinImpl extends _Base<MVSKind> implements TransformMixin {
|
||||
transform(params: MVSNodeParams<'transform'> & CustomAndRef = {}): any {
|
||||
validateTransformParams(params);
|
||||
this.addChild('transform', params);
|
||||
return this;
|
||||
}
|
||||
|
||||
instance(params: MVSNodeParams<'instance'> & CustomAndRef = {}): any {
|
||||
validateTransformParams(params);
|
||||
this.addChild('instance', params);
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
function validateTransformParams(params: MVSNodeParams<'transform' | 'instance'> & CustomAndRef) {
|
||||
if (params.rotation && params.rotation.length !== 9) {
|
||||
throw new Error('ValueError: `rotation` parameter must be an array of 9 numbers');
|
||||
}
|
||||
if (params.matrix && params.matrix.length !== 16) {
|
||||
throw new Error('ValueError: `matrix` parameter must be an array of 16 numbers');
|
||||
}
|
||||
if (params.matrix && (params.translation || params.rotation)) {
|
||||
throw new Error('ValueError: `matrix` parameter cannot be used together with `translation` or `rotation` parameters');
|
||||
}
|
||||
}
|
||||
|
||||
/** Demonstration of usage of MVS builder */
|
||||
export function builderDemo() {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Copyright (c) 2023-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
*/
|
||||
|
||||
import { bool, float, int, mapping, nullable, OptionalField, RequiredField, str, union } from '../generic/field-schema';
|
||||
import { bool, dict, float, int, nullable, OptionalField, RequiredField, str, union } from '../generic/field-schema';
|
||||
import { SimpleParamsSchema, UnionParamsSchema } from '../generic/params-schema';
|
||||
import { ColorT, FloatList, IntList, PrimitivePositionT, Vector3 } from './param-types';
|
||||
|
||||
@@ -31,9 +31,9 @@ const MeshParams = {
|
||||
/** Assign a number to each triangle to group them. If not specified, each triangle is considered a separate group (triangle i = group i). */
|
||||
triangle_groups: OptionalField(nullable(IntList), null, 'Assign a number to each triangle to group them. If not specified, each triangle is considered a separate group (triangle i = group i).'),
|
||||
/** Assign a color to each group. Where not assigned, uses `color`. */
|
||||
group_colors: OptionalField(mapping(int, ColorT), {}, 'Assign a color to each group. Where not assigned, uses `color`.'),
|
||||
group_colors: OptionalField(dict(int, ColorT), {}, 'Assign a color to each group. Where not assigned, uses `color`.'),
|
||||
/** Assign a tooltip to each group. Where not assigned, uses `tooltip`. */
|
||||
group_tooltips: OptionalField(mapping(int, str), {}, 'Assign a tooltip to each group. Where not assigned, uses `tooltip`.'),
|
||||
group_tooltips: OptionalField(dict(int, str), {}, 'Assign a tooltip to each group. Where not assigned, uses `tooltip`.'),
|
||||
/** Color of the triangles and wireframe. Can be overwritten by `group_colors`. If not specified, uses the parent primitives group `color`. */
|
||||
color: OptionalField(nullable(ColorT), null, 'Color of the triangles and wireframe. Can be overwritten by `group_colors`. If not specified, uses the parent primitives group `color`.'),
|
||||
/** Tooltip shown when hovering over the mesh. Can be overwritten by `group_tooltips`. If not specified, uses the parent primitives group `tooltip`. */
|
||||
@@ -56,11 +56,11 @@ const LinesParams = {
|
||||
/** Assign a number to each triangle to group them. If not specified, each line is considered a separate group (line i = group i). */
|
||||
line_groups: OptionalField(nullable(IntList), null, 'Assign a number to each triangle to group them. If not specified, each line is considered a separate group (line i = group i).'),
|
||||
/** Assign a color to each group. Where not assigned, uses `color`. */
|
||||
group_colors: OptionalField(mapping(int, ColorT), {}, 'Assign a color to each group. Where not assigned, uses `color`.'),
|
||||
group_colors: OptionalField(dict(int, ColorT), {}, 'Assign a color to each group. Where not assigned, uses `color`.'),
|
||||
/** Assign a tooltip to each group. Where not assigned, uses `tooltip`. */
|
||||
group_tooltips: OptionalField(mapping(int, str), {}, 'Assign a tooltip to each group. Where not assigned, uses `tooltip`.'),
|
||||
group_tooltips: OptionalField(dict(int, str), {}, 'Assign a tooltip to each group. Where not assigned, uses `tooltip`.'),
|
||||
/** Assign a line width to each group. Where not assigned, uses `width`. */
|
||||
group_widths: OptionalField(mapping(int, float), {}, 'Assign a line width to each group. Where not assigned, uses `width`.'),
|
||||
group_widths: OptionalField(dict(int, float), {}, 'Assign a line width to each group. Where not assigned, uses `width`.'),
|
||||
/** Color of the lines. Can be overwritten by `group_colors`. If not specified, uses the parent primitives group `color`. */
|
||||
color: OptionalField(nullable(ColorT), null, 'Color of the lines. Can be overwritten by `group_colors`. If not specified, uses the parent primitives group `color`.'),
|
||||
/** Tooltip shown when hovering over the lines. Can be overwritten by `group_tooltips`. If not specified, uses the parent primitives group `tooltip`. */
|
||||
@@ -87,15 +87,15 @@ const ArrowParams = {
|
||||
/** Draw a cap at the start of the arrow. */
|
||||
show_start_cap: OptionalField(bool, false, 'Draw a cap at the start of the arrow.'),
|
||||
/** Length of the start cap. */
|
||||
start_cap_length: OptionalField(float, 0.1, 'Length of the start cap.'),
|
||||
start_cap_length: OptionalField(nullable(float), null, 'Length of the start cap. If not provided, will be 2 * start_cap_radius.'),
|
||||
/** Radius of the start cap. */
|
||||
start_cap_radius: OptionalField(float, 0.1, 'Radius of the start cap.'),
|
||||
start_cap_radius: OptionalField(nullable(float), null, 'Radius of the start cap. If not provided, will be 2 * tube_radius.'),
|
||||
/** Draw an arrow at the end of the arrow. */
|
||||
show_end_cap: OptionalField(bool, false, 'Draw a cap at the end of the arrow.'),
|
||||
/** Height of the arrow at the end. */
|
||||
end_cap_length: OptionalField(float, 0.1, 'Length of the end cap.'),
|
||||
end_cap_length: OptionalField(nullable(float), null, 'Length of the end cap. If not provided, will be 2 * end_cap_radius.'),
|
||||
/** Radius of the arrow at the end. */
|
||||
end_cap_radius: OptionalField(float, 0.1, 'Radius of the end cap.'),
|
||||
end_cap_radius: OptionalField(nullable(float), null, 'Radius of the end cap. If not provided, will be 2 * tube_radius.'),
|
||||
/** Draw a tube connecting the start and end points. */
|
||||
show_tube: OptionalField(bool, true, 'Draw a tube connecting the start and end points.'),
|
||||
/** Tube radius (in Angstroms). */
|
||||
@@ -143,6 +143,8 @@ const AngleMeasurementParams = {
|
||||
show_vector: OptionalField(bool, true, 'Draw vectors between (a, b) and (b, c).'),
|
||||
/** Color of the vectors. */
|
||||
vector_color: OptionalField(nullable(ColorT), null, 'Color of the vectors.'),
|
||||
/** Radius of the vectors. */
|
||||
vector_radius: OptionalField(float, 0.05, 'Radius of the vectors.'),
|
||||
/** Draw a filled circle section representing the angle. */
|
||||
show_section: OptionalField(bool, true, 'Draw a filled circle section representing the angle.'),
|
||||
/** Color of the angle section. If not specified, the primitives group color is used. */
|
||||
@@ -207,9 +209,9 @@ const EllipsoidParams = {
|
||||
/** Minor axis endpoint. If specified, overrides minor axis to be minor_axis_endpoint - center. */
|
||||
minor_axis_endpoint: OptionalField(nullable(PrimitivePositionT), null, 'Minor axis endpoint. If specified, overrides minor axis to be minor_axis_endpoint - center.'),
|
||||
/** Radii of the ellipsoid along each axis. */
|
||||
radius: OptionalField(nullable(union([Vector3, float])), null, 'Radii of the ellipsoid along each axis.'),
|
||||
radius: OptionalField(nullable(union(Vector3, float)), null, 'Radii of the ellipsoid along each axis.'),
|
||||
/** Added to the radii of the ellipsoid along each axis. */
|
||||
radius_extent: OptionalField(nullable(union([Vector3, float])), null, 'Added to the radii of the ellipsoid along each axis.'),
|
||||
radius_extent: OptionalField(nullable(union(Vector3, float)), null, 'Added to the radii of the ellipsoid along each axis.'),
|
||||
/** Tooltip to show when hovering over the tube. If not specified, uses the parent primitives group `tooltip`. */
|
||||
tooltip: OptionalField(nullable(str), null, 'Tooltip to show when hovering over the tube. If not specified, uses the parent primitives group `tooltip`.'),
|
||||
};
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { bool, float, nullable, OptionalField } from '../generic/field-schema';
|
||||
import { bool, float, int, literal, nullable, OptionalField, RequiredField } from '../generic/field-schema';
|
||||
import { SimpleParamsSchema, UnionParamsSchema } from '../generic/params-schema';
|
||||
import { Matrix, Vector3 } from './param-types';
|
||||
|
||||
const Cartoon = {
|
||||
/** Scales the corresponding visuals */
|
||||
@@ -63,10 +64,63 @@ const VolumeIsoSurface = {
|
||||
show_faces: OptionalField(bool, true, 'Show mesh faces. Defaults to true.'),
|
||||
};
|
||||
|
||||
const VolumeGridSlice = {
|
||||
/** Dimension of the grid slice, i.e. 'x', 'y', or 'z'. */
|
||||
dimension: RequiredField(literal('x', 'y', 'z'), 'Dimension of the grid slice, i.e. \'x\', \'y\', or \'z\'.'),
|
||||
/** Index of the grid slice in the specified dimension. 0-based index, i.e. 0 is the first slice. */
|
||||
absolute_index: OptionalField(nullable(int), null, 'Index of the grid slice in the specified dimension. 0-based index, i.e. 0 is the first slice.'),
|
||||
/** Relative index of the grid slice in the specified dimension. 0.0 is the first slice, 1.0 is the last slice. Overrides `absolute_index`. */
|
||||
relative_index: OptionalField(nullable(float), null, 'Relative index of the grid slice in the specified dimension. 0.0 is the first slice, 1.0 is the last slice. Overrides `absolute_index`.'),
|
||||
/** Relative isovalue. */
|
||||
relative_isovalue: OptionalField(nullable(float), null, 'Relative isovalue.'),
|
||||
/** Absolute isovalue. Overrides `relative_isovalue`. */
|
||||
absolute_isovalue: OptionalField(nullable(float), null, 'Absolute isovalue. Overrides `relative_isovalue`.'),
|
||||
};
|
||||
|
||||
export const MVSVolumeRepresentationParams = UnionParamsSchema(
|
||||
'type',
|
||||
'Representation type',
|
||||
{
|
||||
'isosurface': SimpleParamsSchema(VolumeIsoSurface),
|
||||
'grid_slice': SimpleParamsSchema(VolumeGridSlice),
|
||||
},
|
||||
);
|
||||
|
||||
const ClipParamsBase = {
|
||||
/** Transformation matrix to applied to each point before clipping. For example, can be used to clip volumes in the grid/fractional space. Default is null. */
|
||||
check_transform: OptionalField(nullable(Matrix), null, 'Transformation matrix to applied to each point before clipping. For example, can be used to clip volumes in the grid/fractional space. Default is null.'),
|
||||
/** Inverts the clipping region. Default is false. */
|
||||
invert: OptionalField(bool, false, 'Inverts the clipping region. Default is false'),
|
||||
/** Variant of the clip node, either "object" or "pixel". */
|
||||
variant: OptionalField(literal('object', 'pixel'), 'pixel', 'Variant of the clip node, either "object" or "pixel"'),
|
||||
};
|
||||
|
||||
export const MVSClipParams = UnionParamsSchema(
|
||||
'type',
|
||||
'Clip type',
|
||||
{
|
||||
plane: SimpleParamsSchema({
|
||||
...ClipParamsBase,
|
||||
/** Normal vector of the clipping plane. */
|
||||
normal: RequiredField(Vector3, 'Normal vector of the clipping plane.'),
|
||||
/** Point on the clipping plane. */
|
||||
point: RequiredField(Vector3, 'Point on the clipping plane.'),
|
||||
}),
|
||||
sphere: SimpleParamsSchema({
|
||||
...ClipParamsBase,
|
||||
/** Center of the clipping sphere. */
|
||||
center: RequiredField(Vector3, 'Center of the clipping sphere.'),
|
||||
/** Radius of the clipping sphere. */
|
||||
radius: OptionalField(float, 1, 'Radius of the clipping sphere.'),
|
||||
}),
|
||||
box: SimpleParamsSchema({
|
||||
...ClipParamsBase,
|
||||
/** Center of the clipping box. */
|
||||
center: RequiredField(Vector3, 'Center of the clipping box.'),
|
||||
/** Size of the clipping box. */
|
||||
size: OptionalField(Vector3, [1, 1, 1], 'Size of the clipping box.'),
|
||||
/** Rotation matrix (3x3 matrix flattened in column major format (j*3+i indexing), this is equivalent to Fortran-order in numpy). This matrix will multiply the structure coordinates from the left. The default value is the identity matrix (corresponds to no rotation). */
|
||||
rotation: OptionalField(Matrix, [1, 0, 0, 0, 1, 0, 0, 0, 1], 'Rotation matrix (3x3 matrix flattened in column major format (j*3+i indexing), this is equivalent to Fortran-order in numpy). This matrix will multiply the structure coordinates from the left. The default value is the identity matrix (corresponds to no rotation).'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -5,12 +5,12 @@
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
*/
|
||||
|
||||
import { float, int, list, literal, nullable, OptionalField, RequiredField, str, tuple, union } from '../generic/field-schema';
|
||||
import { bool, dict, float, int, list, literal, nullable, OptionalField, RequiredField, str, tuple, union } from '../generic/field-schema';
|
||||
import { SimpleParamsSchema } from '../generic/params-schema';
|
||||
import { NodeFor, ParamsOfKind, SubtreeOfKind, TreeFor, TreeSchema, TreeSchemaWithAllRequired } from '../generic/tree-schema';
|
||||
import { MVSRepresentationParams, MVSVolumeRepresentationParams } from './mvs-tree-representations';
|
||||
import { MVSClipParams, MVSRepresentationParams, MVSVolumeRepresentationParams } from './mvs-tree-representations';
|
||||
import { MVSPrimitiveParams } from './mvs-tree-primitives';
|
||||
import { ColorT, ComponentExpressionT, ComponentSelectorT, Matrix, ParseFormatT, SchemaFormatT, SchemaT, StrList, StructureTypeT, Vector3 } from './param-types';
|
||||
import { ColorT, ComponentExpressionT, ComponentSelectorT, Matrix, Palette, ParseFormatT, SchemaFormatT, SchemaT, StrList, StructureTypeT, Vector3 } from './param-types';
|
||||
|
||||
|
||||
const _DataFromUriParams = {
|
||||
@@ -28,6 +28,8 @@ const _DataFromUriParams = {
|
||||
category_name: OptionalField(nullable(str), null, 'Name of the CIF category to read annotation from (only applies when `format` is `"cif"` or `"bcif"`). If `null`, the first category in the block is used.'),
|
||||
/** Name of the column in CIF or field name (key) in JSON that contains the dependent variable (color/label/tooltip/component_id...). The default value is 'color'/'label'/'tooltip'/'component' depending on the node type */
|
||||
field_name: RequiredField(str, 'Name of the column in CIF or field name (key) in JSON that contains the dependent variable (color/label/tooltip/component_id...).'),
|
||||
/** Optional remapping of annotation field names `{ standardName1: actualName1, ... }`. Use `{ "label_asym_id": "X" }` to load actual field "X" as "label_asym_id". Use `{ "label_asym_id": null }` to ignore actual field "label_asym_id". Fields not mentioned here are mapped implicitely (i.e. actual name = standard name). */
|
||||
field_remapping: OptionalField(dict(str, nullable(str)), {}, 'Optional remapping of annotation field names `{ standardName1: actualName1, ... }`. Use `{ "label_asym_id": "X" }` to load actual field "X" as "label_asym_id". Use `{ "label_asym_id": null }` to ignore actual field "label_asym_id". Fields not mentioned here are mapped implicitely (i.e. actual name = standard name).'),
|
||||
};
|
||||
|
||||
const _DataFromSourceParams = {
|
||||
@@ -41,11 +43,24 @@ const _DataFromSourceParams = {
|
||||
category_name: OptionalField(nullable(str), null, 'Name of the CIF category to read annotation from. If `null`, the first category in the block is used.'),
|
||||
/** Name of the column in CIF or field name (key) in JSON that contains the dependent variable (color/label/tooltip/component_id...). The default value is 'color'/'label'/'tooltip'/'component' depending on the node type */
|
||||
field_name: RequiredField(str, 'Name of the column in CIF or field name (key) in JSON that contains the dependent variable (color/label/tooltip/component_id...).'),
|
||||
/** Optional remapping of annotation field names `{ standardName1: actualName1, ... }`. Use `{ "label_asym_id": "X" }` to load actual field "X" as "label_asym_id". Use `{ "label_asym_id": null }` to ignore actual field "label_asym_id". Fields not mentioned here are mapped implicitely (i.e. actual name = standard name). */
|
||||
field_remapping: OptionalField(dict(str, nullable(str)), {}, 'Optional remapping of annotation field names `{ standardName1: actualName1, ... }`. Use `{ "label_asym_id": "X" }` to load actual field "X" as "label_asym_id". Use `{ "label_asym_id": null }` to ignore actual field "label_asym_id". Fields not mentioned here are mapped implicitely (i.e. actual name = standard name).'),
|
||||
};
|
||||
|
||||
/** Color to be used e.g. for representations without 'color' node */
|
||||
export const DefaultColor = 'white';
|
||||
|
||||
const LabelAttachments = literal('bottom-left', 'bottom-center', 'bottom-right', 'middle-left', 'middle-center', 'middle-right', 'top-left', 'top-center', 'top-right');
|
||||
|
||||
const TransformParams = SimpleParamsSchema({
|
||||
/** Rotation matrix (3x3 matrix flattened in column major format (j*3+i indexing), this is equivalent to Fortran-order in numpy). This matrix will multiply the structure coordinates from the left. The default value is the identity matrix (corresponds to no rotation). */
|
||||
rotation: OptionalField(Matrix, [1, 0, 0, 0, 1, 0, 0, 0, 1], 'Rotation matrix (3x3 matrix flattened in column major format (j*3+i indexing), this is equivalent to Fortran-order in numpy). This matrix will multiply the structure coordinates from the left. The default value is the identity matrix (corresponds to no rotation).'),
|
||||
/** Translation vector, applied to the structure coordinates after rotation. The default value is the zero vector (corresponds to no translation). */
|
||||
translation: OptionalField(Vector3, [0, 0, 0], 'Translation vector, applied to the structure coordinates after rotation. The default value is the zero vector (corresponds to no translation).'),
|
||||
/** Transform matrix (4x4 matrix flattened in column major format (j*4+i indexing), this is equivalent to Fortran-order in numpy). This matrix will multiply the structure coordinates from the left. Takes precedence over `rotation` and `translation`. */
|
||||
matrix: OptionalField(nullable(Matrix), null, 'Transform matrix (4x4 matrix flattened in column major format (j*4+i indexing), this is equivalent to Fortran-order in numpy). This matrix will multiply the structure coordinates from the left. Takes precedence over `rotation` and `translation`.'),
|
||||
});
|
||||
|
||||
/** Schema for `MVSTree` (MolViewSpec tree) */
|
||||
export const MVSTreeSchema = TreeSchema({
|
||||
rootKind: 'root',
|
||||
@@ -100,14 +115,15 @@ export const MVSTreeSchema = TreeSchema({
|
||||
},
|
||||
/** This node instructs to rotate and/or translate structure coordinates. */
|
||||
transform: {
|
||||
description: 'This node instructs to rotate and/or translate structure coordinates.',
|
||||
parent: ['structure'],
|
||||
params: SimpleParamsSchema({
|
||||
/** Rotation matrix (3x3 matrix flattened in column major format (j*3+i indexing), this is equivalent to Fortran-order in numpy). This matrix will multiply the structure coordinates from the left. The default value is the identity matrix (corresponds to no rotation). */
|
||||
rotation: OptionalField(Matrix, [1, 0, 0, 0, 1, 0, 0, 0, 1], 'Rotation matrix (3x3 matrix flattened in column major format (j*3+i indexing), this is equivalent to Fortran-order in numpy). This matrix will multiply the structure coordinates from the left. The default value is the identity matrix (corresponds to no rotation).'),
|
||||
/** Translation vector, applied to the structure coordinates after rotation. The default value is the zero vector (corresponds to no translation). */
|
||||
translation: OptionalField(Vector3, [0, 0, 0], 'Translation vector, applied to the structure coordinates after rotation. The default value is the zero vector (corresponds to no translation).'),
|
||||
}),
|
||||
description: 'This node instructs to rotate and/or translate coordinates OR provide a transformation matrix.',
|
||||
parent: ['structure', 'component', 'volume'],
|
||||
params: TransformParams,
|
||||
},
|
||||
/** This node allows instantiation using the provided transformation parameters. */
|
||||
instance: {
|
||||
description: 'This node allows instantiation using the provided transformation parameters.',
|
||||
parent: ['structure', 'component', 'volume'],
|
||||
params: TransformParams,
|
||||
},
|
||||
/** This node instructs to create a component (i.e. a subset of the parent structure). */
|
||||
component: {
|
||||
@@ -115,7 +131,7 @@ export const MVSTreeSchema = TreeSchema({
|
||||
parent: ['structure'],
|
||||
params: SimpleParamsSchema({
|
||||
/** Defines what part of the parent structure should be included in this component. */
|
||||
selector: RequiredField(union([ComponentSelectorT, ComponentExpressionT, list(ComponentExpressionT)]), 'Defines what part of the parent structure should be included in this component.'),
|
||||
selector: RequiredField(union(ComponentSelectorT, ComponentExpressionT, list(ComponentExpressionT)), 'Defines what part of the parent structure should be included in this component.'),
|
||||
}),
|
||||
},
|
||||
/** This node instructs to create a component defined by an external annotation resource. */
|
||||
@@ -170,7 +186,7 @@ export const MVSTreeSchema = TreeSchema({
|
||||
/** Color to apply to the representation. Can be either an X11 color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`). */
|
||||
color: OptionalField(ColorT, DefaultColor, 'Color to apply to the representation. Can be either an X11 color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`).'),
|
||||
/** Defines to what part of the representation this color should be applied. */
|
||||
selector: OptionalField(union([ComponentSelectorT, ComponentExpressionT, list(ComponentExpressionT)]), 'all', 'Defines to what part of the representation this color should be applied.'),
|
||||
selector: OptionalField(union(ComponentSelectorT, ComponentExpressionT, list(ComponentExpressionT)), 'all', 'Defines to what part of the representation this color should be applied.'),
|
||||
}),
|
||||
},
|
||||
/** This node instructs to apply colors to a visual representation. The colors are defined by an external annotation resource. */
|
||||
@@ -181,6 +197,8 @@ export const MVSTreeSchema = TreeSchema({
|
||||
..._DataFromUriParams,
|
||||
/** Name of the column in CIF or field name (key) in JSON that contains the color. */
|
||||
field_name: OptionalField(str, 'color', 'Name of the column in CIF or field name (key) in JSON that contains the color.'),
|
||||
/** Customize mapping of annotation values to colors. */
|
||||
palette: OptionalField(nullable(Palette), null, 'Customize mapping of annotation values to colors.'),
|
||||
}),
|
||||
},
|
||||
/** This node instructs to apply colors to a visual representation. The colors are defined by an annotation resource included in the same file this structure was loaded from. Only applicable if the structure was loaded from an mmCIF or BinaryCIF file. */
|
||||
@@ -191,8 +209,16 @@ export const MVSTreeSchema = TreeSchema({
|
||||
..._DataFromSourceParams,
|
||||
/** Name of the column in CIF or field name (key) in JSON that contains the color. */
|
||||
field_name: OptionalField(str, 'color', 'Name of the column in CIF or field name (key) in JSON that contains the color.'),
|
||||
/** Customize mapping of annotation values to colors. */
|
||||
palette: OptionalField(nullable(Palette), null, 'Customize mapping of annotation values to colors.'),
|
||||
}),
|
||||
},
|
||||
/** This node instructs to apply clipping to a visual representation. */
|
||||
clip: {
|
||||
description: 'This node instructs to apply clipping to a visual representation.',
|
||||
parent: ['representation', 'volume_representation'],
|
||||
params: MVSClipParams,
|
||||
},
|
||||
/** This node instructs to apply opacity/transparency to a visual representation. */
|
||||
opacity: {
|
||||
description: 'This node instructs to apply opacity/transparency to a visual representation.',
|
||||
@@ -313,6 +339,16 @@ export const MVSTreeSchema = TreeSchema({
|
||||
opacity: OptionalField(float, 1, 'Opacity of primitive geometry in this group.'),
|
||||
/** Opacity of primitive labels in this group. */
|
||||
label_opacity: OptionalField(float, 1, 'Opacity of primitive labels in this group.'),
|
||||
/** Whether to show a tether line between the label and the target. Defaults to false. */
|
||||
label_show_tether: OptionalField(bool, false, 'Whether to show a tether line between the label and the target. Defaults to false.'),
|
||||
/** Length of the tether line between the label and the target. Defaults to 1 (Angstrom). */
|
||||
label_tether_length: OptionalField(float, 1, 'Length of the tether line between the label and the target. Defaults to 1 (Angstrom).'),
|
||||
/** How to attach the label to the target. Defaults to "middle-center". */
|
||||
label_attachment: OptionalField(LabelAttachments, 'middle-center', 'How to attach the label to the target. Defaults to "middle-center".'),
|
||||
/** Background color of the label. Defaults to none/transparent. */
|
||||
label_background_color: OptionalField(nullable(ColorT), null, 'Background color of the label. Defaults to none/transparent.'),
|
||||
/** Load snapshot with the provided key when interacting with this primitives group. */
|
||||
snapshot_key: OptionalField(nullable(str), null, 'Load snapshot with the provided key when interacting with this primitives group.'),
|
||||
/** Instances of this primitive group defined as 4x4 column major (j * 4 + i indexing) transformation matrices. */
|
||||
instances: OptionalField(nullable(list(Matrix)), null, 'Instances of this primitive group defined as 4x4 column major (j * 4 + i indexing) transformation matrices.'),
|
||||
}),
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
*/
|
||||
|
||||
import * as iots from 'io-ts';
|
||||
import { HexColor, ColorName } from '../../helpers/utils';
|
||||
import { ValueFor, float, int, list, literal, str, tuple, union } from '../generic/field-schema';
|
||||
import { ColorNames } from '../../../../mol-util/color/names';
|
||||
import { ColorName, HexColor } from '../../helpers/utils';
|
||||
import { ValueFor, bool, dict, float, int, list, literal, nullable, object, partial, str, tuple, union } from '../generic/field-schema';
|
||||
|
||||
|
||||
/** `format` parameter values for `parse` node in MVS tree */
|
||||
@@ -26,7 +25,7 @@ export const StructureTypeT = literal('model', 'assembly', 'symmetry', 'symmetry
|
||||
export const ComponentSelectorT = literal('all', 'polymer', 'protein', 'nucleic', 'branched', 'ligand', 'ion', 'water', 'coarse');
|
||||
|
||||
/** `selector` parameter values for `component` node in MVS tree */
|
||||
export const ComponentExpressionT = iots.partial({
|
||||
export const ComponentExpressionT = partial({
|
||||
label_entity_id: str,
|
||||
label_asym_id: str,
|
||||
auth_asym_id: str,
|
||||
@@ -37,11 +36,17 @@ export const ComponentExpressionT = iots.partial({
|
||||
end_label_seq_id: int,
|
||||
beg_auth_seq_id: int,
|
||||
end_auth_seq_id: int,
|
||||
label_comp_id: str,
|
||||
auth_comp_id: str,
|
||||
// residue_index: int, // 0-based residue index in the source file // TODO this is defined in Python builder but not supported by Molstar yet
|
||||
label_atom_id: str,
|
||||
auth_atom_id: str,
|
||||
type_symbol: str,
|
||||
atom_id: int,
|
||||
atom_index: int,
|
||||
/** Instance identifier to distinguish instances of the same chain created by applying different symmetry operators,
|
||||
* like 'ASM-X0-1' for assemblies or '1_555' for crystals */
|
||||
instance_id: str,
|
||||
});
|
||||
export type ComponentExpressionT = ValueFor<typeof ComponentExpressionT>
|
||||
|
||||
@@ -59,9 +64,9 @@ export type Vector3 = ValueFor<typeof Vector3>
|
||||
export const Matrix = list(float);
|
||||
|
||||
/** Primitives-related types */
|
||||
export const PrimitiveComponentExpressionT = iots.partial({ structure_ref: str, expression_schema: SchemaT, expressions: list(ComponentExpressionT) });
|
||||
export const PrimitiveComponentExpressionT = partial({ structure_ref: str, expression_schema: SchemaT, expressions: list(ComponentExpressionT) });
|
||||
export type PrimitiveComponentExpressionT = ValueFor<typeof PrimitiveComponentExpressionT>
|
||||
export const PrimitivePositionT = iots.union([Vector3, ComponentExpressionT, PrimitiveComponentExpressionT]);
|
||||
export const PrimitivePositionT = union(Vector3, ComponentExpressionT, PrimitiveComponentExpressionT);
|
||||
export type PrimitivePositionT = ValueFor<typeof PrimitivePositionT>
|
||||
|
||||
export const FloatList = list(float);
|
||||
@@ -81,15 +86,12 @@ export const HexColorT = new iots.Type<HexColor>(
|
||||
export const ColorNameT = new iots.Type<ColorName>(
|
||||
'ColorName',
|
||||
((value: any) => typeof value === 'string') as any,
|
||||
(value, ctx) => ColorName.is(value) ? { _tag: 'Right', right: value } : { _tag: 'Left', left: [{ value: value, context: ctx, message: `"${value}" is not a valid hex color string` }] },
|
||||
(value, ctx) => ColorName.is(value) ? { _tag: 'Right', right: value } : { _tag: 'Left', left: [{ value: value, context: ctx, message: `"${value}" is not a valid color name` }] },
|
||||
value => value
|
||||
);
|
||||
|
||||
/** `color` parameter values for `color` node in MVS tree */
|
||||
export const ColorNamesT = literal(...Object.keys(ColorNames) as (keyof ColorNames)[]);
|
||||
|
||||
/** `color` parameter values for `color` node in MVS tree */
|
||||
export const ColorT = union([ColorNameT, HexColorT]);
|
||||
export const ColorT = union(ColorNameT, HexColorT);
|
||||
export type ColorT = ValueFor<typeof ColorT>
|
||||
|
||||
/** Type helpers */
|
||||
@@ -104,3 +106,146 @@ export function isPrimitiveComponentExpressions(x: any): x is PrimitiveComponent
|
||||
export function isComponentExpression(x: any): x is ComponentExpressionT {
|
||||
return !!x && typeof x === 'object' && !x.expressions;
|
||||
}
|
||||
|
||||
|
||||
export const ColorListNameT = literal(
|
||||
// Color lists from https://observablehq.com/@d3/color-schemes (definitions: https://colorbrewer2.org/export/colorbrewer.js)
|
||||
// Sequential single-hue
|
||||
'Reds', 'Oranges', 'Greens', 'Blues', 'Purples', 'Greys',
|
||||
// Sequential multi-hue
|
||||
'OrRd', 'BuGn', 'PuBuGn', 'GnBu', 'PuBu', 'BuPu', 'RdPu', 'PuRd', 'YlOrRd', 'YlOrBr', 'YlGn', 'YlGnBu',
|
||||
'Magma', 'Inferno', 'Plasma', 'Viridis', 'Cividis', 'Turbo', 'Warm', 'Cool', 'CubehelixDefault',
|
||||
// Cyclical
|
||||
'Rainbow', 'Sinebow',
|
||||
// Diverging
|
||||
'RdBu', 'RdGy', 'PiYG', 'BrBG', 'PRGn', 'PuOr', 'RdYlGn', 'RdYlBu', 'Spectral',
|
||||
// Categorical
|
||||
'Category10', 'Observable10', 'Tableau10',
|
||||
'Set1', 'Set2', 'Set3', 'Pastel1', 'Pastel2', 'Dark2', 'Paired', 'Accent',
|
||||
|
||||
// Additional lists, not standard for visualization in general, but commonly used for structures
|
||||
'Chainbow',
|
||||
);
|
||||
export type ColorListNameT = ValueFor<typeof ColorListNameT>;
|
||||
|
||||
export const ColorDictNameT = literal('ElementSymbol', 'ResidueName', 'ResidueProperties', 'SecondaryStructure');
|
||||
export type ColorDictNameT = ValueFor<typeof ColorDictNameT>;
|
||||
|
||||
|
||||
export const CategoricalPalette = object(
|
||||
{
|
||||
kind: literal('categorical'),
|
||||
},
|
||||
// Optionals:
|
||||
{
|
||||
colors: union(
|
||||
ColorListNameT,
|
||||
ColorDictNameT,
|
||||
list(ColorT),
|
||||
dict(str, ColorT),
|
||||
),
|
||||
/** Repeat color list once all colors are depleted (only applies if `colors` is a list or a color list name). */
|
||||
repeat_color_list: bool,
|
||||
/** Sort actual annotation values before assigning colors from a list (none = take values in order of their first occurrence). */
|
||||
sort: literal('none', 'lexical', 'numeric'),
|
||||
/** Sort direction. */
|
||||
sort_direction: literal('ascending', 'descending'),
|
||||
/** Treat annotation values as case-insensitive strings. */
|
||||
case_insensitive: bool,
|
||||
/** Color to use when a) `colors` is a dictionary (or a color dictionary name) and given key is not present, or b) `colors` is a list (or a color list name) and there are more actual annotation values than listed colors and `repeat_color_list` is not true. */
|
||||
missing_color: nullable(ColorT),
|
||||
}
|
||||
);
|
||||
export type CategoricalPalette = ValueFor<typeof CategoricalPalette>;
|
||||
|
||||
export const CategoricalPaletteDefaults: Required<CategoricalPalette> = {
|
||||
kind: 'categorical',
|
||||
colors: 'Category10', // this is also default for categorical in Matplotlib
|
||||
repeat_color_list: false,
|
||||
sort: 'none',
|
||||
sort_direction: 'ascending',
|
||||
case_insensitive: false,
|
||||
missing_color: null,
|
||||
};
|
||||
|
||||
|
||||
export const DiscretePalette = object(
|
||||
{
|
||||
kind: literal('discrete'),
|
||||
},
|
||||
// Optionals:
|
||||
{
|
||||
/** Define colors for the discrete color palette and optionally corresponding checkpoints.
|
||||
* Checkpoints refer to the values normalized to interval [0, 1] if `mode` is `"normalized"` (default), or to the values directly if `mode` is `"absolute"`.
|
||||
* If checkpoints are not provided, they will created automatically (uniformly distributed over interval [0, 1]).
|
||||
* If 1 checkpoint is provided for each color, then the color applies to values from this checkpoint (inclusive) until the next listed checkpoint (exclusive); the last color applies until Infinity.
|
||||
* If 2 checkpoints are provided for each color, then the color applies to values from the first until the second checkpoint (inclusive); null means +/-Infinity; if ranges overlap, the later listed takes precedence.
|
||||
*/
|
||||
colors: union(
|
||||
ColorListNameT,
|
||||
list(ColorT),
|
||||
list(tuple([ColorT, float])),
|
||||
list(tuple([nullable(ColorT), nullable(float), nullable(float)])),
|
||||
),
|
||||
/** Reverse order of `colors` list. Only has effect when `colors` is a color list name or a color list without explicit checkpoints. */
|
||||
reverse: bool,
|
||||
/** Defines whether the annotation values should be normalized before assigning color based on checkpoints in `colors` (`x_normalized = (x - x_min) / (x_max - x_min)`, where `[x_min, x_max]` are either `value_domain` if provided, or the lowest and the highest value encountered in the annotation). Default is `"normalized"`. */
|
||||
mode: literal('normalized', 'absolute'),
|
||||
/** Defines `x_min` and `x_max` for normalization of annotation values. Either can be `null`, meaning that minimum/maximum of the actual values will be used. Only used when `mode` is `"normalized"`. */
|
||||
value_domain: tuple([nullable(float), nullable(float)]),
|
||||
}
|
||||
);
|
||||
export type DiscretePalette = ValueFor<typeof DiscretePalette>;
|
||||
|
||||
export const DiscretePaletteDefaults: Required<DiscretePalette> = {
|
||||
kind: 'discrete',
|
||||
colors: 'YlGn', // YlGn was selected as default because (a) Matplotlib's default Viridis looks ugly in 3D and (b) YlGn does not contain white, so it's easier to see that it's doing something even when values are in wrong range
|
||||
reverse: false,
|
||||
mode: 'normalized',
|
||||
value_domain: [null, null],
|
||||
};
|
||||
|
||||
|
||||
export const ContinuousPalette = object(
|
||||
{
|
||||
kind: literal('continuous'),
|
||||
|
||||
},
|
||||
// Optionals:
|
||||
{
|
||||
/** Define colors for the continuous color palette and optionally corresponding checkpoints (i.e. annotation values that are mapped to each color).
|
||||
* Checkpoints refer to the values normalized to interval [0, 1] if `mode` is `"normalized"` (default), or to the values directly if `mode` is `"absolute"`.
|
||||
* If checkpoints are not provided, they will created automatically (uniformly distributed over interval [0, 1]). */
|
||||
colors: union(
|
||||
ColorListNameT,
|
||||
list(ColorT),
|
||||
list(tuple([ColorT, float])),
|
||||
),
|
||||
/** Reverse order of `colors` list. Only has effect when `colors` is a color list name or a color list without explicit checkpoints. */
|
||||
reverse: bool,
|
||||
/** Defines whether the annotation values should be normalized before assigning color based on checkpoints in `colors` (`x_normalized = (x - x_min) / (x_max - x_min)`, where `[x_min, x_max]` are either `value_domain` if provided, or the lowest and the highest value encountered in the annotation). Default is `"normalized"`. */
|
||||
mode: literal('normalized', 'absolute'),
|
||||
/** Defines `x_min` and `x_max` for normalization of annotation values. Either can be `null`, meaning that minimum/maximum of the actual values will be used. Only used when `mode` is `"normalized"`. */
|
||||
value_domain: tuple([nullable(float), nullable(float)]),
|
||||
/** Color to use for values below the lowest checkpoint. 'auto' means color of the lowest checkpoint. */
|
||||
underflow_color: nullable(union(literal('auto'), ColorT)),
|
||||
/** Color to use for values above the highest checkpoint. 'auto' means color of the highest checkpoint. */
|
||||
overflow_color: nullable(union(literal('auto'), ColorT)),
|
||||
}
|
||||
);
|
||||
export type ContinuousPalette = ValueFor<typeof ContinuousPalette>;
|
||||
|
||||
export const ContinuousPaletteDefaults: Required<ContinuousPalette> = {
|
||||
kind: 'continuous',
|
||||
colors: 'YlGn', // YlGn was selected as default because (a) Matplotlib's default Viridis looks ugly in 3D and (b) YlGn does not contain white, so it's easier to see that it's doing something even when values are in wrong range
|
||||
reverse: false,
|
||||
mode: 'normalized',
|
||||
value_domain: [null, null],
|
||||
underflow_color: null,
|
||||
overflow_color: null,
|
||||
};
|
||||
|
||||
// TODO consider spreading the palette param directly into color_from_uri/color_from_source params (though this will be tricky)
|
||||
// TODO consider implementing some kind of recursion for object-typed params to achieve smart error messages and default value handling
|
||||
|
||||
export const Palette = union(CategoricalPalette, DiscretePalette, ContinuousPalette);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
*/
|
||||
@@ -34,6 +34,7 @@ import { VolsegGlobalStateData } from './global-state';
|
||||
import { applyEllipsis, isDefined, lazyGetter, splitEntryId } from './helpers';
|
||||
import { type VolsegStateFromEntry } from './transformers';
|
||||
import { StateTransforms } from '../../mol-plugin-state/transforms';
|
||||
import { OrderedSet } from '../../mol-data/int';
|
||||
|
||||
|
||||
export const MAX_VOXELS = 10 ** 7;
|
||||
@@ -346,8 +347,8 @@ export class VolsegEntryData extends PluginBehavior.WithSubscribers<VolsegEntryP
|
||||
|
||||
private getSegmentIdFromLoci(loci: Loci): number | undefined {
|
||||
if (Volume.Segment.isLoci(loci) && loci.volume._propertyData.ownerId === this.ref) {
|
||||
if (loci.segments.length === 1) {
|
||||
return loci.segments[0];
|
||||
if (loci.elements.length === 1 && OrderedSet.size(loci.elements[0].segments) === 1) {
|
||||
return OrderedSet.start(loci.elements[0].segments);
|
||||
}
|
||||
}
|
||||
if (ShapeGroup.isLoci(loci)) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Adam Midlik <midlik@gmail.com>
|
||||
*/
|
||||
@@ -17,6 +17,8 @@ import { Segment } from './volseg-api/data';
|
||||
import { BOX, VolsegEntryData, MAX_VOXELS } from './entry-root';
|
||||
import { VolumeVisualParams } from './entry-volume';
|
||||
import { VolsegGlobalStateData } from './global-state';
|
||||
import { Interval } from '../../mol-data/int/interval';
|
||||
import { SortedArray } from '../../mol-data/int';
|
||||
|
||||
|
||||
const GROUP_TAG = 'lattice-segmentation-group';
|
||||
@@ -89,7 +91,11 @@ export class VolsegLatticeSegmentationData {
|
||||
const repr = vis.obj?.data.repr;
|
||||
const wholeLoci = repr.getAllLoci()[0];
|
||||
if (!wholeLoci || !Volume.Segment.isLoci(wholeLoci)) return undefined;
|
||||
return { loci: Volume.Segment.Loci(wholeLoci.volume, segments), repr: repr };
|
||||
const elements = [{
|
||||
segments: SortedArray.ofUnsortedArray<Volume.SegmentIndex>(segments),
|
||||
instances: Interval.ofLength(wholeLoci.volume.instances.length as Volume.InstanceIndex)
|
||||
}];
|
||||
return { loci: Volume.Segment.Loci(wholeLoci.volume, elements), repr: repr };
|
||||
}
|
||||
async highlightSegment(segment: Segment) {
|
||||
const segmentLoci = this.makeLoci([segment.id]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
@@ -11,8 +11,9 @@ import { CameraTransitionManager } from './camera/transition';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { Scene } from '../mol-gl/scene';
|
||||
import { assertUnreachable } from '../mol-util/type-helpers';
|
||||
import { Ray3D } from '../mol-math/geometry/primitives/ray3d';
|
||||
|
||||
export { ICamera, Camera };
|
||||
export type { ICamera };
|
||||
|
||||
interface ICamera {
|
||||
readonly viewport: Viewport,
|
||||
@@ -26,15 +27,17 @@ interface ICamera {
|
||||
readonly near: number,
|
||||
readonly fogFar: number,
|
||||
readonly fogNear: number,
|
||||
readonly headRotation: Mat4,
|
||||
}
|
||||
|
||||
const tmpClip = Vec4();
|
||||
|
||||
class Camera implements ICamera {
|
||||
export class Camera implements ICamera {
|
||||
readonly view: Mat4 = Mat4.identity();
|
||||
readonly projection: Mat4 = Mat4.identity();
|
||||
readonly projectionView: Mat4 = Mat4.identity();
|
||||
readonly inverseProjectionView: Mat4 = Mat4.identity();
|
||||
readonly headRotation: Mat4 = Mat4.zero();
|
||||
|
||||
readonly viewport: Viewport;
|
||||
readonly state: Readonly<Camera.Snapshot> = Camera.createDefaultSnapshot();
|
||||
@@ -69,7 +72,7 @@ class Camera implements ICamera {
|
||||
return false;
|
||||
}
|
||||
|
||||
const height = 2 * Math.tan(snapshot.fov / 2) * Vec3.distance(snapshot.position, snapshot.target);
|
||||
const height = 2 * Math.tan(snapshot.fov / 2) * Vec3.distance(snapshot.position, snapshot.target) * this.state.scale;
|
||||
this.zoom = this.viewport.height / height;
|
||||
|
||||
updateClip(this);
|
||||
@@ -191,13 +194,29 @@ class Camera implements ICamera {
|
||||
return (2 / w) / (rx * Math.abs(P00));
|
||||
}
|
||||
|
||||
getRay(out: Ray3D, x: number, y: number) {
|
||||
if (this.state.mode === 'orthographic') {
|
||||
Vec3.set(out.origin, x, y, 0);
|
||||
this.unproject(out.origin, out.origin);
|
||||
Vec3.normalize(out.direction, Vec3.sub(out.direction, this.target, this.position));
|
||||
Vec3.scaleAndAdd(out.origin, out.origin, out.direction, -this.near);
|
||||
} else {
|
||||
Vec3.copy(out.origin, this.state.position);
|
||||
Vec3.scale(out.origin, out.origin, this.state.scale);
|
||||
Vec3.set(out.direction, x, y, 0.5);
|
||||
this.unproject(out.direction, out.direction);
|
||||
Vec3.normalize(out.direction, Vec3.sub(out.direction, out.direction, out.origin));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
constructor(state?: Partial<Camera.Snapshot>, viewport = Viewport.create(0, 0, 128, 128)) {
|
||||
this.viewport = viewport;
|
||||
Camera.copySnapshot(this.state, state);
|
||||
}
|
||||
}
|
||||
|
||||
namespace Camera {
|
||||
export namespace Camera {
|
||||
export type Mode = 'perspective' | 'orthographic'
|
||||
|
||||
export type SnapshotProvider = Partial<Snapshot> | ((scene: Scene, camera: Camera) => Partial<Snapshot>)
|
||||
@@ -270,6 +289,8 @@ namespace Camera {
|
||||
clipFar: true,
|
||||
minNear: 5,
|
||||
minFar: 0,
|
||||
|
||||
scale: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -287,6 +308,8 @@ namespace Camera {
|
||||
clipFar: boolean
|
||||
minNear: number
|
||||
minFar: number
|
||||
|
||||
scale: number
|
||||
}
|
||||
|
||||
export function copySnapshot(out: Snapshot, source?: Partial<Snapshot>) {
|
||||
@@ -306,6 +329,8 @@ namespace Camera {
|
||||
if (typeof source.minNear !== 'undefined') out.minNear = source.minNear;
|
||||
if (typeof source.minFar !== 'undefined') out.minFar = source.minFar;
|
||||
|
||||
if (typeof source.scale !== 'undefined') out.scale = source.scale;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -318,12 +343,26 @@ namespace Camera {
|
||||
&& a.clipFar === b.clipFar
|
||||
&& a.minNear === b.minNear
|
||||
&& a.minFar === b.minFar
|
||||
&& a.scale === b.scale
|
||||
&& Vec3.exactEquals(a.position, b.position)
|
||||
&& Vec3.exactEquals(a.up, b.up)
|
||||
&& Vec3.exactEquals(a.target, b.target);
|
||||
}
|
||||
}
|
||||
|
||||
const tmpPosition = Vec3();
|
||||
const tmpTarget = Vec3();
|
||||
|
||||
function updateView(camera: Camera) {
|
||||
if (camera.state.scale === 1) {
|
||||
Mat4.lookAt(camera.view, camera.state.position, camera.state.target, camera.state.up);
|
||||
} else {
|
||||
Vec3.scale(tmpPosition, camera.state.position, camera.state.scale);
|
||||
Vec3.scale(tmpTarget, camera.state.target, camera.state.scale);
|
||||
Mat4.lookAt(camera.view, tmpPosition, tmpTarget, camera.state.up);
|
||||
}
|
||||
}
|
||||
|
||||
function updateOrtho(camera: Camera) {
|
||||
const { viewport, zoom, near, far, viewOffset } = camera;
|
||||
|
||||
@@ -357,7 +396,7 @@ function updateOrtho(camera: Camera) {
|
||||
Mat4.ortho(camera.projection, left, right, top, bottom, near, far);
|
||||
|
||||
// build view matrix
|
||||
Mat4.lookAt(camera.view, camera.position, camera.target, camera.up);
|
||||
updateView(camera);
|
||||
}
|
||||
|
||||
function updatePers(camera: Camera) {
|
||||
@@ -381,15 +420,23 @@ function updatePers(camera: Camera) {
|
||||
Mat4.perspective(camera.projection, left, left + width, top, top - height, near, far);
|
||||
|
||||
// build view matrix
|
||||
Mat4.lookAt(camera.view, camera.position, camera.target, camera.up);
|
||||
updateView(camera);
|
||||
}
|
||||
|
||||
function updateClip(camera: Camera) {
|
||||
let { radius, radiusMax, mode, fog, clipFar, minNear, minFar } = camera.state;
|
||||
if (radius < 0.01) radius = 0.01;
|
||||
let { radius, radiusMax, mode, fog, clipFar, minNear, minFar, scale } = camera.state;
|
||||
radiusMax *= scale;
|
||||
minFar *= scale;
|
||||
minNear *= scale;
|
||||
radius *= scale;
|
||||
|
||||
const minRadius = 0.01 * scale;
|
||||
if (radius < minRadius) radius = minRadius;
|
||||
|
||||
const normalizedFar = Math.max(clipFar ? radius : radiusMax, minFar);
|
||||
const cameraDistance = Vec3.distance(camera.position, camera.target);
|
||||
Vec3.scale(tmpTarget, camera.state.target, scale);
|
||||
Vec3.scale(tmpPosition, camera.state.position, scale);
|
||||
const cameraDistance = Vec3.distance(tmpPosition, tmpTarget);
|
||||
let near = cameraDistance - radius;
|
||||
let far = cameraDistance + normalizedFar;
|
||||
|
||||
@@ -405,7 +452,7 @@ function updateClip(camera: Camera) {
|
||||
|
||||
if (near === far) {
|
||||
// make sure near and far are not identical to avoid Infinity in the projection matrix
|
||||
far = near + 0.01;
|
||||
far = near + 0.01 * scale;
|
||||
}
|
||||
|
||||
const fogNearFactor = -(50 - fog) / 50;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2020-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
@@ -61,6 +61,7 @@ class EyeCamera implements ICamera {
|
||||
projection = Mat4();
|
||||
projectionView = Mat4();
|
||||
inverseProjectionView = Mat4();
|
||||
headRotation = Mat4();
|
||||
state: Readonly<Camera.Snapshot> = Camera.createDefaultSnapshot();
|
||||
viewOffset: Readonly<Camera.ViewOffset> = Camera.ViewOffset();
|
||||
far: number = 0;
|
||||
@@ -69,30 +70,29 @@ class EyeCamera implements ICamera {
|
||||
fogNear: number = 0;
|
||||
}
|
||||
|
||||
const eyeLeft = Mat4.identity(), eyeRight = Mat4.identity();
|
||||
const tmpEyeLeft = Mat4.identity();
|
||||
const tmpEyeRight = Mat4.identity();
|
||||
|
||||
function copyStates(parent: Camera, eye: EyeCamera) {
|
||||
Viewport.copy(eye.viewport, parent.viewport);
|
||||
Mat4.copy(eye.view, parent.view);
|
||||
Mat4.copy(eye.projection, parent.projection);
|
||||
Mat4.copy(eye.headRotation, parent.headRotation);
|
||||
Camera.copySnapshot(eye.state, parent.state);
|
||||
Camera.copyViewOffset(eye.viewOffset, parent.viewOffset);
|
||||
eye.far = parent.far;
|
||||
eye.near = parent.near;
|
||||
eye.fogFar = parent.fogFar;
|
||||
eye.fogNear = parent.fogNear;
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
function update(camera: Camera, props: StereoCameraProps, left: EyeCamera, right: EyeCamera) {
|
||||
// Copy the states
|
||||
|
||||
Viewport.copy(left.viewport, camera.viewport);
|
||||
Mat4.copy(left.view, camera.view);
|
||||
Mat4.copy(left.projection, camera.projection);
|
||||
Camera.copySnapshot(left.state, camera.state);
|
||||
Camera.copyViewOffset(left.viewOffset, camera.viewOffset);
|
||||
left.far = camera.far;
|
||||
left.near = camera.near;
|
||||
left.fogFar = camera.fogFar;
|
||||
left.fogNear = camera.fogNear;
|
||||
|
||||
Viewport.copy(right.viewport, camera.viewport);
|
||||
Mat4.copy(right.view, camera.view);
|
||||
Mat4.copy(right.projection, camera.projection);
|
||||
Camera.copySnapshot(right.state, camera.state);
|
||||
Camera.copyViewOffset(right.viewOffset, camera.viewOffset);
|
||||
right.far = camera.far;
|
||||
right.near = camera.near;
|
||||
right.fogFar = camera.fogFar;
|
||||
right.fogNear = camera.fogNear;
|
||||
copyStates(camera, left);
|
||||
copyStates(camera, right);
|
||||
|
||||
// update the view offsets
|
||||
|
||||
@@ -112,8 +112,8 @@ function update(camera: Camera, props: StereoCameraProps, left: EyeCamera, right
|
||||
|
||||
// translate xOffset
|
||||
|
||||
eyeLeft[12] = -eyeSepHalf;
|
||||
eyeRight[12] = eyeSepHalf;
|
||||
tmpEyeLeft[12] = -eyeSepHalf;
|
||||
tmpEyeRight[12] = eyeSepHalf;
|
||||
|
||||
// for left eye
|
||||
|
||||
@@ -123,7 +123,7 @@ function update(camera: Camera, props: StereoCameraProps, left: EyeCamera, right
|
||||
left.projection[0] = 2 * camera.near / (xmax - xmin);
|
||||
left.projection[8] = (xmax + xmin) / (xmax - xmin);
|
||||
|
||||
Mat4.mul(left.view, left.view, eyeLeft);
|
||||
Mat4.mul(left.view, left.view, tmpEyeLeft);
|
||||
Mat4.mul(left.projectionView, left.projection, left.view);
|
||||
Mat4.invert(left.inverseProjectionView, left.projectionView);
|
||||
|
||||
@@ -135,7 +135,7 @@ function update(camera: Camera, props: StereoCameraProps, left: EyeCamera, right
|
||||
right.projection[0] = 2 * camera.near / (xmax - xmin);
|
||||
right.projection[8] = (xmax + xmin) / (xmax - xmin);
|
||||
|
||||
Mat4.mul(right.view, right.view, eyeRight);
|
||||
Mat4.mul(right.view, right.view, tmpEyeRight);
|
||||
Mat4.mul(right.projectionView, right.projection, right.view);
|
||||
Mat4.invert(right.inverseProjectionView, right.projectionView);
|
||||
}
|
||||
@@ -28,8 +28,8 @@ import { SetUtils } from '../mol-util/set';
|
||||
import { Canvas3dInteractionHelper, Canvas3dInteractionHelperParams } from './helper/interaction-events';
|
||||
import { PostprocessingParams } from './passes/postprocessing';
|
||||
import { MultiSampleHelper, MultiSampleParams, MultiSamplePass } from './passes/multi-sample';
|
||||
import { PickData } from './passes/pick';
|
||||
import { PickHelper } from './passes/pick';
|
||||
import { AsyncPickData, DefaultPickOptions, PickData } from './passes/pick';
|
||||
import { PickHelper } from './helper/pick-helper';
|
||||
import { ImagePass, ImageProps } from './passes/image';
|
||||
import { Sphere3D } from '../mol-math/geometry';
|
||||
import { addConsoleStatsProvider, isDebugMode, isTimingMode, removeConsoleStatsProvider } from '../mol-util/debug';
|
||||
@@ -47,6 +47,8 @@ import { deepClone } from '../mol-util/object';
|
||||
import { HiZParams, HiZPass } from './passes/hi-z';
|
||||
import { IlluminationParams } from './passes/illumination';
|
||||
import { isMobileBrowser } from '../mol-util/browser';
|
||||
import { Ray3D } from '../mol-math/geometry/primitives/ray3d';
|
||||
import { RayHelper } from './helper/ray-helper';
|
||||
|
||||
export const Canvas3DParams = {
|
||||
camera: PD.Group({
|
||||
@@ -57,6 +59,7 @@ export const Canvas3DParams = {
|
||||
off: PD.Group({})
|
||||
}, { cycle: true, hideIf: p => p?.mode !== 'perspective' }),
|
||||
fov: PD.Numeric(45, { min: 10, max: 130, step: 1 }, { label: 'Field of View' }),
|
||||
scale: PD.Numeric(1, { min: 0.001, max: 1, step: 0.001 }, { label: 'Scene scale' }),
|
||||
manualReset: PD.Boolean(false, { isHidden: true }),
|
||||
}, { pivot: 'mode' }),
|
||||
cameraFog: PD.MappedStatic('on', {
|
||||
@@ -330,7 +333,8 @@ interface Canvas3D {
|
||||
pause(noDraw?: boolean): void
|
||||
/** Sets drawPaused = false without starting the built in animation loop */
|
||||
resume(): void
|
||||
identify(x: number, y: number): PickData | undefined
|
||||
identify(target: Vec2 | Ray3D): PickData | undefined
|
||||
asyncIdentify(target: Vec2 | Ray3D): AsyncPickData | undefined
|
||||
mark(loci: Representation.Loci, action: MarkerAction): void
|
||||
getLoci(pickingId: PickingId | undefined): Representation.Loci
|
||||
|
||||
@@ -388,7 +392,7 @@ namespace Canvas3D {
|
||||
const commited = new BehaviorSubject<now.Timestamp>(0 as now.Timestamp);
|
||||
const commitQueueSize = new BehaviorSubject<number>(0);
|
||||
|
||||
const { gl, contextRestored } = webgl;
|
||||
const { contextRestored } = webgl;
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
@@ -412,6 +416,7 @@ namespace Canvas3D {
|
||||
clipFar: p.cameraClipping.far,
|
||||
minNear: p.cameraClipping.minNear,
|
||||
fov: degToRad(p.camera.fov),
|
||||
scale: p.camera.scale,
|
||||
}, { x, y, width, height });
|
||||
const stereoCamera = new StereoCamera(camera, p.camera.stereo.params);
|
||||
|
||||
@@ -422,8 +427,13 @@ namespace Canvas3D {
|
||||
const renderer = Renderer.create(webgl, p.renderer);
|
||||
renderer.setOcclusionTest(hiZ.isOccluded);
|
||||
|
||||
const pickHelper = new PickHelper(webgl, renderer, scene, helper, passes.pick, { x, y, width, height }, p.pickPadding);
|
||||
const interactionHelper = new Canvas3dInteractionHelper(identify, getLoci, input, camera, controls, p.interaction);
|
||||
const pickOptions = {
|
||||
pickPadding: p.pickPadding,
|
||||
maxAsyncReadLag: DefaultPickOptions.maxAsyncReadLag,
|
||||
};
|
||||
const pickHelper = new PickHelper(webgl, renderer, scene, helper, passes.pick, { x, y, width, height }, pickOptions);
|
||||
const rayHelper = new RayHelper(webgl, renderer, scene, helper, pickOptions);
|
||||
const interactionHelper = new Canvas3dInteractionHelper(identify, asyncIdentify, getLoci, input, camera, controls, p.interaction);
|
||||
const multiSampleHelper = new MultiSampleHelper(passes.multiSample);
|
||||
|
||||
passes.draw.postprocessing.background.update(camera, p.postprocessing.background, changed => {
|
||||
@@ -510,8 +520,9 @@ namespace Canvas3D {
|
||||
resized = true;
|
||||
}
|
||||
|
||||
if (x > gl.drawingBufferWidth || x + width < 0 ||
|
||||
y > gl.drawingBufferHeight || y + height < 0
|
||||
const drs = webgl.getDrawingBufferSize();
|
||||
if (x > drs.width || x + width < 0 ||
|
||||
y > drs.height || y + height < 0
|
||||
) return false;
|
||||
|
||||
if (fenceSync !== null) {
|
||||
@@ -648,9 +659,26 @@ namespace Canvas3D {
|
||||
animationFrameHandle = 0;
|
||||
}
|
||||
|
||||
function identify(x: number, y: number): PickData | undefined {
|
||||
const cam = p.camera.stereo.name === 'on' ? stereoCamera : camera;
|
||||
return webgl.isContextLost ? undefined : pickHelper.identify(x, y, cam);
|
||||
function identify(target: Vec2 | Ray3D): PickData | undefined {
|
||||
if (webgl.isContextLost) return undefined;
|
||||
|
||||
if ('origin' in target) {
|
||||
return rayHelper.identify(target, camera);
|
||||
} else {
|
||||
const cam = (p.camera.stereo.name === 'on') ? stereoCamera : camera;
|
||||
return pickHelper.identify(target[0], target[1], cam);
|
||||
}
|
||||
}
|
||||
|
||||
function asyncIdentify(target: Vec2 | Ray3D): AsyncPickData | undefined {
|
||||
if (webgl.isContextLost) return undefined;
|
||||
|
||||
if ('origin' in target) {
|
||||
return rayHelper.asyncIdentify(target, camera);
|
||||
} else {
|
||||
const cam = (p.camera.stereo.name === 'on') ? stereoCamera : camera;
|
||||
return pickHelper.asyncIdentify(target[0], target[1], cam);
|
||||
}
|
||||
}
|
||||
|
||||
function commit(isSynchronous: boolean = false) {
|
||||
@@ -840,6 +868,7 @@ namespace Canvas3D {
|
||||
helper: { ...helper.camera.props },
|
||||
stereo: { ...p.camera.stereo },
|
||||
fov: Math.round(radToDeg(camera.state.fov)),
|
||||
scale: camera.state.scale,
|
||||
manualReset: !!p.camera.manualReset
|
||||
},
|
||||
cameraFog: camera.state.fog > 0
|
||||
@@ -874,6 +903,10 @@ namespace Canvas3D {
|
||||
});
|
||||
|
||||
const contextRestoredSub = contextRestored.subscribe(() => {
|
||||
pickHelper.reset();
|
||||
rayHelper.reset();
|
||||
hiZ.reset();
|
||||
|
||||
scene.forEach(r => {
|
||||
if (r.values.meta?.ref.value.reset) {
|
||||
r.values.meta.ref.value.reset();
|
||||
@@ -951,7 +984,7 @@ namespace Canvas3D {
|
||||
input.click.subscribe(e => {
|
||||
if (!e.modifiers.control || e.button !== 2) return;
|
||||
|
||||
const p = identify(e.x, e.y);
|
||||
const p = identify(Vec2.create(e.x, e.y));
|
||||
if (!p) {
|
||||
occlusionLoci = undefined;
|
||||
printOcclusion(occlusionLoci);
|
||||
@@ -1019,6 +1052,7 @@ namespace Canvas3D {
|
||||
pause,
|
||||
resume: () => { drawPaused = false; },
|
||||
identify,
|
||||
asyncIdentify,
|
||||
mark,
|
||||
getLoci,
|
||||
|
||||
@@ -1059,6 +1093,9 @@ namespace Canvas3D {
|
||||
if (props.camera && props.camera.fov !== undefined && props.camera.fov !== oldFov) {
|
||||
cameraState.fov = degToRad(props.camera.fov);
|
||||
}
|
||||
if (props.camera && props.camera.scale !== undefined && props.camera.scale !== cameraState.scale) {
|
||||
cameraState.scale = props.camera.scale;
|
||||
}
|
||||
if (props.cameraFog !== undefined && props.cameraFog.params) {
|
||||
const newFog = props.cameraFog.name === 'on' ? props.cameraFog.params.intensity : 0;
|
||||
if (newFog !== camera.state.fog) cameraState.fog = newFog;
|
||||
@@ -1169,6 +1206,9 @@ namespace Canvas3D {
|
||||
renderer.dispose();
|
||||
interactionHelper.dispose();
|
||||
hiZ.dispose();
|
||||
pickHelper.dispose();
|
||||
rayHelper.dispose();
|
||||
|
||||
if (fenceSync !== null) {
|
||||
webgl.deleteSync(fenceSync);
|
||||
fenceSync = null;
|
||||
@@ -1180,22 +1220,23 @@ namespace Canvas3D {
|
||||
|
||||
function updateViewport() {
|
||||
const oldX = x, oldY = y, oldWidth = width, oldHeight = height;
|
||||
const drs = webgl.getDrawingBufferSize();
|
||||
|
||||
if (p.viewport.name === 'canvas') {
|
||||
x = 0;
|
||||
y = 0;
|
||||
width = gl.drawingBufferWidth;
|
||||
height = gl.drawingBufferHeight;
|
||||
width = drs.width;
|
||||
height = drs.height;
|
||||
} else if (p.viewport.name === 'static-frame') {
|
||||
x = p.viewport.params.x * webgl.pixelRatio;
|
||||
height = p.viewport.params.height * webgl.pixelRatio;
|
||||
y = gl.drawingBufferHeight - height - p.viewport.params.y * webgl.pixelRatio;
|
||||
y = drs.height - height - p.viewport.params.y * webgl.pixelRatio;
|
||||
width = p.viewport.params.width * webgl.pixelRatio;
|
||||
} else if (p.viewport.name === 'relative-frame') {
|
||||
x = Math.round(p.viewport.params.x * gl.drawingBufferWidth);
|
||||
height = Math.round(p.viewport.params.height * gl.drawingBufferHeight);
|
||||
y = Math.round(gl.drawingBufferHeight - height - p.viewport.params.y * gl.drawingBufferHeight);
|
||||
width = Math.round(p.viewport.params.width * gl.drawingBufferWidth);
|
||||
x = Math.round(p.viewport.params.x * drs.width);
|
||||
height = Math.round(p.viewport.params.height * drs.height);
|
||||
y = Math.round(drs.height - height - p.viewport.params.y * drs.height);
|
||||
width = Math.round(p.viewport.params.width * drs.width);
|
||||
}
|
||||
|
||||
if (oldX !== x || oldY !== y || oldWidth !== width || oldHeight !== height) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
@@ -14,14 +14,14 @@ import { Camera } from '../camera';
|
||||
import { ParamDefinition as PD } from '../../mol-util/param-definition';
|
||||
import { Bond } from '../../mol-model/structure';
|
||||
import { TrackballControls } from '../controls/trackball';
|
||||
import { Ray3D } from '../../mol-math/geometry/primitives/ray3d';
|
||||
import { AsyncPickData } from '../passes/pick';
|
||||
|
||||
type Canvas3D = import('../canvas3d').Canvas3D
|
||||
type HoverEvent = import('../canvas3d').Canvas3D.HoverEvent
|
||||
type DragEvent = import('../canvas3d').Canvas3D.DragEvent
|
||||
type ClickEvent = import('../canvas3d').Canvas3D.ClickEvent
|
||||
|
||||
enum InputEvent { Move, Click, Drag }
|
||||
|
||||
const tmpPosA = Vec3();
|
||||
const tmpPos = Vec3();
|
||||
const tmpNorm = Vec3();
|
||||
@@ -29,6 +29,7 @@ const tmpNorm = Vec3();
|
||||
export const Canvas3dInteractionHelperParams = {
|
||||
maxFps: PD.Numeric(30, { min: 10, max: 60, step: 10 }),
|
||||
preferAtomPixelPadding: PD.Numeric(3, { min: 0, max: 20, step: 1 }, { description: 'Number of extra pixels at which to prefer atoms over bonds.' }),
|
||||
convertCoordsToRay: PD.Boolean(false, { description: 'Convert screen coordinates to ray for picking.' }),
|
||||
};
|
||||
export type Canvas3dInteractionHelperParams = typeof Canvas3dInteractionHelperParams
|
||||
export type Canvas3dInteractionHelperProps = PD.Values<Canvas3dInteractionHelperParams>
|
||||
@@ -47,10 +48,11 @@ export class Canvas3dInteractionHelper {
|
||||
private endX = -1;
|
||||
private endY = -1;
|
||||
|
||||
private id: PickingId | undefined = void 0;
|
||||
private ray: Ray3D | undefined = void 0;
|
||||
|
||||
private pickData: AsyncPickData | undefined = void 0;
|
||||
private position: Vec3 | undefined = void 0;
|
||||
|
||||
private currentIdentifyT = 0;
|
||||
private isInteracting = false;
|
||||
|
||||
private prevLoci: Representation.Loci = Representation.Loci.Empty;
|
||||
@@ -68,46 +70,66 @@ export class Canvas3dInteractionHelper {
|
||||
Object.assign(this.props, props);
|
||||
}
|
||||
|
||||
private identify(e: InputEvent, t: number) {
|
||||
const xyChanged = this.startX !== this.endX || this.startY !== this.endY || (this.input.pointerLock && !this.controls.isMoving);
|
||||
|
||||
if (e === InputEvent.Drag) {
|
||||
if (xyChanged && !this.outsideViewport(this.startX, this.startY)) {
|
||||
this.events.drag.next({ current: this.prevLoci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, pageStart: Vec2.create(this.startX, this.startY), pageEnd: Vec2.create(this.endX, this.endY) });
|
||||
|
||||
this.startX = this.endX;
|
||||
this.startY = this.endY;
|
||||
}
|
||||
return;
|
||||
private getTarget(): Vec2 | Ray3D {
|
||||
if (this.ray) {
|
||||
return this.ray;
|
||||
} else if (this.props.convertCoordsToRay) {
|
||||
return this.camera.getRay(Ray3D(), this.endX, this.input.height - this.endY);
|
||||
} else {
|
||||
return Vec2.create(this.endX, this.endY);
|
||||
}
|
||||
}
|
||||
|
||||
private handleMove() {
|
||||
const xyChanged = this.startX !== this.endX || this.startY !== this.endY || (this.input.pointerLock && !this.controls.isMoving);
|
||||
if (xyChanged) {
|
||||
const pickData = this.canvasIdentify(this.endX, this.endY);
|
||||
this.id = pickData?.id;
|
||||
this.position = pickData?.position;
|
||||
this.pickData = this.canvasAsyncIdentify(this.getTarget());
|
||||
this.startX = this.endX;
|
||||
this.startY = this.endY;
|
||||
}
|
||||
}
|
||||
|
||||
if (e === InputEvent.Click) {
|
||||
const loci = this.getLoci(this.id, this.position);
|
||||
this.events.click.next({ current: loci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, page: Vec2.create(this.endX, this.endY), position: this.position });
|
||||
this.prevLoci = loci;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.inside || this.currentIdentifyT !== t || !xyChanged || this.outsideViewport(this.endX, this.endY)) return;
|
||||
|
||||
const loci = this.getLoci(this.id, this.position);
|
||||
this.events.hover.next({ current: loci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, page: Vec2.create(this.endX, this.endY), position: this.position });
|
||||
private handleClick() {
|
||||
const pickData = this.canvasIdentify(this.getTarget());
|
||||
const loci = this.getLoci(pickData?.id, pickData?.position);
|
||||
this.events.click.next({ current: loci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, page: Vec2.create(this.endX, this.endY), position: pickData?.position });
|
||||
this.prevLoci = loci;
|
||||
}
|
||||
|
||||
private handleDrag() {
|
||||
const xyChanged = this.startX !== this.endX || this.startY !== this.endY || (this.input.pointerLock && !this.controls.isMoving);
|
||||
|
||||
if (xyChanged && !this.outsideViewport(this.startX, this.startY, this.ray)) {
|
||||
this.events.drag.next({ current: this.prevLoci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, pageStart: Vec2.create(this.startX, this.startY), pageEnd: Vec2.create(this.endX, this.endY) });
|
||||
|
||||
this.startX = this.endX;
|
||||
this.startY = this.endY;
|
||||
}
|
||||
}
|
||||
|
||||
tick(t: number) {
|
||||
if (this.inside && t - this.prevT > 1000 / this.props.maxFps) {
|
||||
if (!this.inside) return;
|
||||
|
||||
if (this.pickData) {
|
||||
const pickData = this.pickData.tryGet();
|
||||
if (pickData !== 'pending') {
|
||||
this.position = pickData?.position;
|
||||
if (this.inside) {
|
||||
const loci = this.getLoci(pickData?.id, pickData?.position);
|
||||
this.events.hover.next({ current: loci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, page: Vec2.create(this.endX, this.endY), position: pickData?.position });
|
||||
this.prevLoci = loci;
|
||||
}
|
||||
this.pickData = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (t - this.prevT > 1000 / this.props.maxFps) {
|
||||
this.prevT = t;
|
||||
this.currentIdentifyT = t;
|
||||
this.identify(this.isInteracting ? InputEvent.Drag : InputEvent.Move, t);
|
||||
if (this.isInteracting) {
|
||||
this.handleDrag();
|
||||
} else {
|
||||
this.handleMove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,22 +141,24 @@ export class Canvas3dInteractionHelper {
|
||||
}
|
||||
}
|
||||
|
||||
private move(x: number, y: number, buttons: ButtonsType, button: ButtonsType.Flag, modifiers: ModifiersKeys) {
|
||||
private move(x: number, y: number, buttons: ButtonsType, button: ButtonsType.Flag, modifiers: ModifiersKeys, ray?: Ray3D) {
|
||||
this.inside = true;
|
||||
this.buttons = buttons;
|
||||
this.button = button;
|
||||
this.modifiers = modifiers;
|
||||
this.ray = ray;
|
||||
this.endX = x;
|
||||
this.endY = y;
|
||||
}
|
||||
|
||||
private click(x: number, y: number, buttons: ButtonsType, button: ButtonsType.Flag, modifiers: ModifiersKeys) {
|
||||
private click(x: number, y: number, buttons: ButtonsType, button: ButtonsType.Flag, modifiers: ModifiersKeys, ray?: Ray3D) {
|
||||
this.endX = x;
|
||||
this.endY = y;
|
||||
this.buttons = buttons;
|
||||
this.button = button;
|
||||
this.modifiers = modifiers;
|
||||
this.identify(InputEvent.Click, 0);
|
||||
this.ray = ray;
|
||||
this.handleClick();
|
||||
}
|
||||
|
||||
private drag(x: number, y: number, buttons: ButtonsType, button: ButtonsType.Flag, modifiers: ModifiersKeys) {
|
||||
@@ -143,7 +167,7 @@ export class Canvas3dInteractionHelper {
|
||||
this.buttons = buttons;
|
||||
this.button = button;
|
||||
this.modifiers = modifiers;
|
||||
this.identify(InputEvent.Drag, 0);
|
||||
this.handleDrag();
|
||||
}
|
||||
|
||||
private modify(modifiers: ModifiersKeys) {
|
||||
@@ -152,7 +176,9 @@ export class Canvas3dInteractionHelper {
|
||||
this.events.hover.next({ current: this.prevLoci, buttons: this.buttons, button: this.button, modifiers: this.modifiers, page: Vec2.create(this.endX, this.endY), position: this.position });
|
||||
}
|
||||
|
||||
private outsideViewport(x: number, y: number) {
|
||||
private outsideViewport(x: number, y: number, ray?: Ray3D) {
|
||||
if (ray) return false;
|
||||
|
||||
const { input, camera: { viewport } } = this;
|
||||
x *= input.pixelRatio;
|
||||
y *= input.pixelRatio;
|
||||
@@ -189,7 +215,7 @@ export class Canvas3dInteractionHelper {
|
||||
this.ev.dispose();
|
||||
}
|
||||
|
||||
constructor(private canvasIdentify: Canvas3D['identify'], private lociGetter: Canvas3D['getLoci'], private input: InputObserver, private camera: Camera, private controls: TrackballControls, props: Partial<Canvas3dInteractionHelperProps> = {}) {
|
||||
constructor(private canvasIdentify: Canvas3D['identify'], private canvasAsyncIdentify: Canvas3D['asyncIdentify'], private lociGetter: Canvas3D['getLoci'], private input: InputObserver, private camera: Camera, private controls: TrackballControls, props: Partial<Canvas3dInteractionHelperProps> = {}) {
|
||||
this.props = { ...PD.getDefaultValues(Canvas3dInteractionHelperParams), ...props };
|
||||
|
||||
input.drag.subscribe(({ x, y, buttons, button, modifiers }) => {
|
||||
@@ -198,14 +224,14 @@ export class Canvas3dInteractionHelper {
|
||||
this.drag(x, y, buttons, button, modifiers);
|
||||
});
|
||||
|
||||
input.move.subscribe(({ x, y, inside, buttons, button, modifiers, onElement }) => {
|
||||
input.move.subscribe(({ x, y, inside, buttons, button, modifiers, onElement, ray }) => {
|
||||
if (!inside || this.isInteracting) return;
|
||||
if (!onElement) {
|
||||
this.leave();
|
||||
return;
|
||||
}
|
||||
// console.log('move');
|
||||
this.move(x, y, buttons, button, modifiers);
|
||||
this.move(x, y, buttons, button, modifiers, ray);
|
||||
});
|
||||
|
||||
input.leave.subscribe(() => {
|
||||
@@ -213,10 +239,10 @@ export class Canvas3dInteractionHelper {
|
||||
this.leave();
|
||||
});
|
||||
|
||||
input.click.subscribe(({ x, y, buttons, button, modifiers }) => {
|
||||
if (this.outsideViewport(x, y)) return;
|
||||
input.click.subscribe(({ x, y, buttons, button, modifiers, ray }) => {
|
||||
if (this.outsideViewport(x, y, ray)) return;
|
||||
// console.log('click');
|
||||
this.click(x, y, buttons, button, modifiers);
|
||||
this.click(x, y, buttons, button, modifiers, ray);
|
||||
});
|
||||
|
||||
input.interactionEnd.subscribe(() => {
|
||||
|
||||
208
src/mol-canvas3d/helper/pick-helper.ts
Normal file
208
src/mol-canvas3d/helper/pick-helper.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Copyright (c) 2019-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
|
||||
import { Renderer } from '../../mol-gl/renderer';
|
||||
import { Scene } from '../../mol-gl/scene';
|
||||
import { WebGLContext } from '../../mol-gl/webgl/context';
|
||||
import { Vec3 } from '../../mol-math/linear-algebra/3d/vec3';
|
||||
import { spiral2d } from '../../mol-math/misc';
|
||||
import { isTimingMode } from '../../mol-util/debug';
|
||||
import { Camera } from '../camera';
|
||||
import { StereoCamera } from '../camera/stereo';
|
||||
import { cameraUnproject, Viewport } from '../camera/util';
|
||||
import { Helper } from '../helper/helper';
|
||||
import { AsyncPickData, AsyncPickStatus, checkAsyncPickingSupport, PickBuffers, PickData, PickOptions, PickPass } from '../passes/pick';
|
||||
|
||||
export class PickHelper {
|
||||
dirty = true;
|
||||
|
||||
private pickPadding: number;
|
||||
private buffers = new PickBuffers(this.webgl, this.pickPass);
|
||||
private viewport = Viewport();
|
||||
|
||||
private pickRatio: number;
|
||||
private pickX: number;
|
||||
private pickY: number;
|
||||
private pickWidth: number;
|
||||
private pickHeight: number;
|
||||
private halfPickWidth: number;
|
||||
|
||||
private spiral: [number, number][];
|
||||
|
||||
setViewport(x: number, y: number, width: number, height: number) {
|
||||
Viewport.set(this.viewport, x, y, width, height);
|
||||
this.update();
|
||||
}
|
||||
|
||||
setPickPadding(pickPadding: number) {
|
||||
if (this.pickPadding !== pickPadding) {
|
||||
this.pickPadding = pickPadding;
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
private update() {
|
||||
const { x, y, width, height } = this.viewport;
|
||||
|
||||
this.pickRatio = this.pickPass.pickRatio;
|
||||
this.pickX = Math.ceil(x * this.pickRatio);
|
||||
this.pickY = Math.ceil(y * this.pickRatio);
|
||||
|
||||
const pickWidth = Math.floor(width * this.pickRatio);
|
||||
const pickHeight = Math.floor(height * this.pickRatio);
|
||||
|
||||
if (pickWidth !== this.pickWidth || pickHeight !== this.pickHeight) {
|
||||
this.pickWidth = pickWidth;
|
||||
this.pickHeight = pickHeight;
|
||||
this.halfPickWidth = Math.floor(this.pickWidth / 2);
|
||||
|
||||
this.buffers.setViewport(this.pickX, this.pickY, this.pickWidth, this.pickHeight);
|
||||
}
|
||||
|
||||
this.spiral = spiral2d(Math.ceil(this.pickRatio * this.pickPadding));
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
private render(camera: Camera | StereoCamera) {
|
||||
if (isTimingMode) this.webgl.timer.mark('PickHelper.render', { captureStats: true });
|
||||
const { pickX, pickY, pickWidth, pickHeight, halfPickWidth } = this;
|
||||
const { renderer, scene, helper } = this;
|
||||
|
||||
renderer.setTransparentBackground(false);
|
||||
renderer.setDrawingBufferSize(pickWidth, pickHeight);
|
||||
renderer.setPixelRatio(this.pickRatio);
|
||||
|
||||
if (StereoCamera.is(camera)) {
|
||||
renderer.setViewport(pickX, pickY, halfPickWidth, pickHeight);
|
||||
this.pickPass.render(renderer, camera.left, scene, helper);
|
||||
|
||||
renderer.setViewport(pickX + halfPickWidth, pickY, pickWidth - halfPickWidth, pickHeight);
|
||||
this.pickPass.render(renderer, camera.right, scene, helper);
|
||||
} else {
|
||||
renderer.setViewport(pickX, pickY, pickWidth, pickHeight);
|
||||
this.pickPass.render(renderer, camera, scene, helper);
|
||||
}
|
||||
|
||||
this.dirty = false;
|
||||
if (isTimingMode) this.webgl.timer.markEnd('PickHelper.render');
|
||||
}
|
||||
|
||||
private identifyInternal(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
|
||||
if (this.webgl.isContextLost) return;
|
||||
|
||||
const { webgl, pickRatio } = this;
|
||||
if (webgl.isContextLost) return;
|
||||
|
||||
x *= webgl.pixelRatio;
|
||||
y *= webgl.pixelRatio;
|
||||
y = this.pickPass.drawingBufferHeight - y; // flip y
|
||||
|
||||
const { viewport } = this;
|
||||
|
||||
// check if within viewport
|
||||
if (x < viewport.x ||
|
||||
y < viewport.y ||
|
||||
x > viewport.x + viewport.width ||
|
||||
y > viewport.y + viewport.height
|
||||
) return;
|
||||
|
||||
const xv = x - viewport.x;
|
||||
const yv = y - viewport.y;
|
||||
|
||||
const xp = Math.floor(xv * pickRatio);
|
||||
const yp = Math.floor(yv * pickRatio);
|
||||
|
||||
const pickingId = this.buffers.getPickingId(xp, yp);
|
||||
if (pickingId === undefined) return;
|
||||
|
||||
const z = this.buffers.getDepth(xp, yp);
|
||||
const position = Vec3.create(x, y, z);
|
||||
if (StereoCamera.is(camera)) {
|
||||
const halfWidth = Math.floor(viewport.width / 2);
|
||||
if (x > viewport.x + halfWidth) {
|
||||
position[0] = viewport.x + (xv - halfWidth) * 2;
|
||||
cameraUnproject(position, position, viewport, camera.right.inverseProjectionView);
|
||||
} else {
|
||||
position[0] = viewport.x + xv * 2;
|
||||
cameraUnproject(position, position, viewport, camera.left.inverseProjectionView);
|
||||
}
|
||||
} else {
|
||||
cameraUnproject(position, position, viewport, camera.inverseProjectionView);
|
||||
}
|
||||
|
||||
return { id: pickingId, position };
|
||||
}
|
||||
|
||||
private prepare() {
|
||||
if (this.pickRatio !== this.pickPass.pickRatio) {
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
private getPickData(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
|
||||
for (const d of this.spiral) {
|
||||
const pickData = this.identifyInternal(x + d[0], y + d[1], camera);
|
||||
if (pickData) return pickData;
|
||||
}
|
||||
}
|
||||
|
||||
identify(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
|
||||
this.prepare();
|
||||
|
||||
if (this.dirty) {
|
||||
if (isTimingMode) this.webgl.timer.mark('PickHelper.identify');
|
||||
this.render(camera);
|
||||
this.buffers.read();
|
||||
if (isTimingMode) this.webgl.timer.markEnd('PickHelper.identify');
|
||||
}
|
||||
|
||||
return this.getPickData(x, y, camera);
|
||||
}
|
||||
|
||||
asyncIdentify(x: number, y: number, camera: Camera | StereoCamera): AsyncPickData | undefined {
|
||||
this.prepare();
|
||||
|
||||
if (this.dirty) {
|
||||
if (isTimingMode) this.webgl.timer.mark('PickHelper.asyncIdentify');
|
||||
this.render(camera);
|
||||
this.buffers.asyncRead();
|
||||
if (isTimingMode) this.webgl.timer.markEnd('PickHelper.asyncIdentify');
|
||||
}
|
||||
|
||||
return {
|
||||
tryGet: () => {
|
||||
const status = this.buffers.check();
|
||||
if (status === AsyncPickStatus.Resolved) {
|
||||
return this.getPickData(x, y, camera);
|
||||
} else if (status === AsyncPickStatus.Pending) {
|
||||
return 'pending';
|
||||
} else if (status === AsyncPickStatus.Failed) {
|
||||
this.dirty = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.buffers.reset();
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.buffers.dispose();
|
||||
}
|
||||
|
||||
constructor(private webgl: WebGLContext, private renderer: Renderer, private scene: Scene, private helper: Helper, private pickPass: PickPass, viewport: Viewport, options: PickOptions) {
|
||||
this.setViewport(viewport.x, viewport.y, viewport.width, viewport.height);
|
||||
this.pickPadding = options.pickPadding;
|
||||
|
||||
if (!checkAsyncPickingSupport(webgl)) {
|
||||
this.asyncIdentify = (x, y, camera) => ({
|
||||
tryGet: () => this.identify(x, y, camera)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
205
src/mol-canvas3d/helper/ray-helper.ts
Normal file
205
src/mol-canvas3d/helper/ray-helper.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
|
||||
import { Renderer } from '../../mol-gl/renderer';
|
||||
import { Scene } from '../../mol-gl/scene';
|
||||
import { WebGLContext } from '../../mol-gl/webgl/context';
|
||||
import { Ray3D } from '../../mol-math/geometry/primitives/ray3d';
|
||||
import { Mat4, Quat, Vec3 } from '../../mol-math/linear-algebra';
|
||||
import { degToRad, spiral2d } from '../../mol-math/misc';
|
||||
import { isTimingMode } from '../../mol-util/debug';
|
||||
import { Camera } from '../camera';
|
||||
import { cameraUnproject } from '../camera/util';
|
||||
import { Viewport } from '../camera/util';
|
||||
import { Helper } from './helper';
|
||||
import { AsyncPickData, PickBuffers, PickData, PickPass, PickOptions, checkAsyncPickingSupport, AsyncPickStatus } from '../passes/pick';
|
||||
import { Sphere3D } from '../../mol-math/geometry/primitives/sphere3d';
|
||||
|
||||
export class RayHelper {
|
||||
private viewport = Viewport();
|
||||
private size: number;
|
||||
private spiral: [number, number][];
|
||||
|
||||
private pickPadding: number;
|
||||
private camera: Camera;
|
||||
private pickPass: PickPass;
|
||||
private buffers: PickBuffers;
|
||||
|
||||
setPickPadding(pickPadding: number) {
|
||||
if (this.pickPadding !== pickPadding) {
|
||||
this.pickPadding = pickPadding;
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
private update() {
|
||||
const size = this.pickPadding * 2 + 1;
|
||||
Viewport.set(this.viewport, 0, 0, size, size);
|
||||
this.buffers.setViewport(0, 0, size, size);
|
||||
|
||||
this.spiral = spiral2d(this.pickPadding);
|
||||
this.size = size;
|
||||
|
||||
this.pickPass.setSize(size, size);
|
||||
}
|
||||
|
||||
private render(camera: Camera) {
|
||||
if (isTimingMode) this.webgl.timer.mark('RayHelper.render', { captureStats: true });
|
||||
const { renderer, scene, helper } = this;
|
||||
|
||||
renderer.setTransparentBackground(false);
|
||||
renderer.setDrawingBufferSize(this.size, this.size);
|
||||
renderer.setPixelRatio(1);
|
||||
|
||||
renderer.setViewport(0, 0, this.size, this.size);
|
||||
this.pickPass.render(renderer, camera, scene, helper);
|
||||
|
||||
if (isTimingMode) this.webgl.timer.markEnd('RayHelper.render');
|
||||
}
|
||||
|
||||
private identifyInternal(x: number, y: number): PickData | undefined {
|
||||
if (this.webgl.isContextLost) return;
|
||||
|
||||
const { viewport } = this;
|
||||
|
||||
const pickingId = this.buffers.getPickingId(x, y);
|
||||
if (pickingId === undefined) return;
|
||||
|
||||
const z = this.buffers.getDepth(x, y);
|
||||
const position = Vec3.create(x, y, z);
|
||||
cameraUnproject(position, position, viewport, this.camera.inverseProjectionView);
|
||||
|
||||
return { id: pickingId, position };
|
||||
}
|
||||
|
||||
private prepare(ray: Ray3D, cam: Camera) {
|
||||
this.camera.far = cam.far;
|
||||
this.camera.near = cam.near;
|
||||
this.camera.fogFar = cam.fogFar;
|
||||
this.camera.fogNear = cam.fogNear;
|
||||
Viewport.copy(this.camera.viewport, this.viewport);
|
||||
Camera.copySnapshot(this.camera.state, { ...cam.state, mode: 'orthographic' });
|
||||
|
||||
updateOrthoRayCamera(this.camera, ray);
|
||||
Mat4.mul(this.camera.projectionView, this.camera.projection, this.camera.view);
|
||||
Mat4.tryInvert(this.camera.inverseProjectionView, this.camera.projectionView);
|
||||
}
|
||||
|
||||
private getPickData(): PickData | undefined {
|
||||
const c = this.pickPadding;
|
||||
for (const d of this.spiral) {
|
||||
const pickData = this.identifyInternal(c + d[0], c + d[1]);
|
||||
if (pickData) return pickData;
|
||||
}
|
||||
}
|
||||
|
||||
sphere = Sphere3D();
|
||||
|
||||
private intersectsScene(ray: Ray3D, scale: number): boolean {
|
||||
Sphere3D.scaleNX(this.sphere, this.scene.boundingSphereVisible, scale);
|
||||
return Ray3D.isInsideSphere3D(ray, this.sphere) || Ray3D.isIntersectingSphere3D(ray, this.sphere);
|
||||
}
|
||||
|
||||
identify(ray: Ray3D, cam: Camera): PickData | undefined {
|
||||
if (!this.intersectsScene(ray, cam.state.scale)) return;
|
||||
|
||||
this.prepare(ray, cam);
|
||||
|
||||
if (isTimingMode) this.webgl.timer.mark('RayHelper.identify');
|
||||
this.render(this.camera);
|
||||
this.buffers.read();
|
||||
if (isTimingMode) this.webgl.timer.markEnd('RayHelper.identify');
|
||||
|
||||
return this.getPickData();
|
||||
}
|
||||
|
||||
asyncIdentify(ray: Ray3D, cam: Camera): AsyncPickData | undefined {
|
||||
if (!this.intersectsScene(ray, cam.state.scale)) return;
|
||||
|
||||
this.prepare(ray, cam);
|
||||
|
||||
if (isTimingMode) this.webgl.timer.mark('RayHelper.asyncIdentify');
|
||||
this.render(this.camera);
|
||||
this.buffers.asyncRead();
|
||||
if (isTimingMode) this.webgl.timer.markEnd('RayHelper.asyncIdentify');
|
||||
|
||||
return {
|
||||
tryGet: () => {
|
||||
const status = this.buffers.check();
|
||||
if (status === AsyncPickStatus.Resolved) {
|
||||
return this.getPickData();
|
||||
} else if (status === AsyncPickStatus.Pending) {
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.buffers.reset();
|
||||
this.pickPass.reset();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.buffers.dispose();
|
||||
this.pickPass.dispose();
|
||||
}
|
||||
|
||||
constructor(private webgl: WebGLContext, private renderer: Renderer, private scene: Scene, private helper: Helper, options: PickOptions) {
|
||||
const size = options.pickPadding * 2 + 1;
|
||||
|
||||
this.camera = new Camera();
|
||||
this.pickPass = new PickPass(webgl, size, size, 1);
|
||||
this.buffers = new PickBuffers(this.webgl, this.pickPass, options.maxAsyncReadLag);
|
||||
this.pickPadding = options.pickPadding;
|
||||
|
||||
this.update();
|
||||
|
||||
if (!checkAsyncPickingSupport(webgl)) {
|
||||
this.asyncIdentify = (ray, cam) => ({
|
||||
tryGet: () => this.identify(ray, cam)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
function updateOrthoRayCamera(camera: Camera, ray: Ray3D) {
|
||||
const { near, far, viewport } = camera;
|
||||
|
||||
const height = 2 * Math.tan(degToRad(0.1) / 2) * Vec3.distance(camera.position, camera.target) * camera.state.scale;
|
||||
const zoom = viewport.height / height;
|
||||
|
||||
const fullLeft = -viewport.width / 2;
|
||||
const fullRight = viewport.width / 2;
|
||||
const fullTop = viewport.height / 2;
|
||||
const fullBottom = -viewport.height / 2;
|
||||
|
||||
const dx = (fullRight - fullLeft) / (2 * zoom);
|
||||
const dy = (fullTop - fullBottom) / (2 * zoom);
|
||||
const cx = (fullRight + fullLeft) / 2;
|
||||
const cy = (fullTop + fullBottom) / 2;
|
||||
|
||||
const left = cx - dx;
|
||||
const right = cx + dx;
|
||||
const top = cy + dy;
|
||||
const bottom = cy - dy;
|
||||
|
||||
// build projection matrix
|
||||
Mat4.ortho(camera.projection, left, right, top, bottom, near, far);
|
||||
|
||||
const direction = Vec3.normalize(Vec3(), ray.direction);
|
||||
const r = Quat.fromUnitVec3(Quat(), direction, Vec3.negUnitZ);
|
||||
Quat.invert(r, r);
|
||||
|
||||
const eye = Vec3.clone(ray.origin);
|
||||
const up = Vec3.transformQuat(Vec3(), Vec3.unitY, r);
|
||||
const target = Vec3.add(Vec3(), eye, direction);
|
||||
|
||||
// build view matrix
|
||||
Mat4.lookAt(camera.view, eye, target, up);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2022-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2022-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
@@ -26,6 +26,7 @@ import { Vec4 } from '../../mol-math/linear-algebra/3d/vec4';
|
||||
import { degToRad, isPowerOfTwo } from '../../mol-math/misc';
|
||||
import { Mat3 } from '../../mol-math/linear-algebra/3d/mat3';
|
||||
import { Euler } from '../../mol-math/linear-algebra/3d/euler';
|
||||
import { PostprocessingProps } from './postprocessing';
|
||||
|
||||
const SharedParams = {
|
||||
opacity: PD.Numeric(1, { min: 0.0, max: 1.0, step: 0.01 }),
|
||||
@@ -172,10 +173,14 @@ export class BackgroundPass {
|
||||
}
|
||||
|
||||
const m = this.renderable.values.uViewDirectionProjectionInverse.ref.value;
|
||||
Vec3.sub(this.dir, cam.state.position, cam.state.target);
|
||||
Vec3.setMagnitude(this.dir, this.dir, 0.1);
|
||||
Vec3.copy(this.position, this.dir);
|
||||
Mat4.lookAt(m, this.position, this.target, cam.state.up);
|
||||
if (Mat4.isZero(camera.headRotation)) {
|
||||
Vec3.sub(this.dir, cam.state.position, cam.state.target);
|
||||
Vec3.setMagnitude(this.dir, this.dir, 0.1);
|
||||
Vec3.copy(this.position, this.dir);
|
||||
Mat4.lookAt(m, this.position, this.target, cam.state.up);
|
||||
} else {
|
||||
Mat4.invert(m, camera.headRotation);
|
||||
}
|
||||
Mat4.mul(m, cam.projection, m);
|
||||
Mat4.invert(m, m);
|
||||
ValueCell.update(this.renderable.values.uViewDirectionProjectionInverse, m);
|
||||
@@ -292,7 +297,7 @@ export class BackgroundPass {
|
||||
ValueCell.update(this.renderable.values.uViewport, Vec4.set(this.renderable.values.uViewport.ref.value, x, y, width, height));
|
||||
}
|
||||
|
||||
isEnabled(props: BackgroundProps) {
|
||||
private _isEnabled(props: BackgroundProps) {
|
||||
return !!(
|
||||
(this.skybox && this.skybox.loaded) ||
|
||||
(this.image && this.image.loaded) ||
|
||||
@@ -301,6 +306,10 @@ export class BackgroundPass {
|
||||
);
|
||||
}
|
||||
|
||||
isEnabled(props: PostprocessingProps) {
|
||||
return props.enabled && this._isEnabled(props.background);
|
||||
}
|
||||
|
||||
private isReady() {
|
||||
return !!(
|
||||
(this.skybox && this.skybox.loaded) ||
|
||||
@@ -315,7 +324,7 @@ export class BackgroundPass {
|
||||
clear(props: BackgroundProps, transparentBackground: boolean, backgroundColor: Color) {
|
||||
const { gl, state } = this.webgl;
|
||||
|
||||
if (this.isEnabled(props)) {
|
||||
if (this._isEnabled(props)) {
|
||||
if (transparentBackground) {
|
||||
state.clearColor(0, 0, 0, 0);
|
||||
} else {
|
||||
@@ -332,7 +341,7 @@ export class BackgroundPass {
|
||||
}
|
||||
|
||||
render(props: BackgroundProps) {
|
||||
if (!this.isEnabled(props) || !this.isReady()) return;
|
||||
if (!this._isEnabled(props) || !this.isReady()) return;
|
||||
|
||||
if (this.renderable.values.dVariant.ref.value === 'image') {
|
||||
this.updateImageScaling();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2024-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*
|
||||
@@ -38,7 +38,7 @@ export type BloomProps = PD.Values<typeof BloomParams>
|
||||
|
||||
export class BloomPass {
|
||||
static isEnabled(props: PostprocessingProps) {
|
||||
return props.bloom.name === 'on';
|
||||
return props.enabled && props.bloom.name === 'on';
|
||||
}
|
||||
|
||||
readonly emissiveTarget: RenderTarget;
|
||||
@@ -207,7 +207,7 @@ export class BloomPass {
|
||||
if (target) {
|
||||
target.bind();
|
||||
} else {
|
||||
this.webgl.unbindFramebuffer();
|
||||
this.webgl.bindDrawingBuffer();
|
||||
}
|
||||
state.enable(gl.BLEND);
|
||||
state.blendFunc(gl.ONE, gl.ONE);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
@@ -79,7 +79,7 @@ export class CasPass {
|
||||
if (target) {
|
||||
target.bind();
|
||||
} else {
|
||||
this.webgl.unbindFramebuffer();
|
||||
this.webgl.bindDrawingBuffer();
|
||||
}
|
||||
this.updateState(viewport);
|
||||
this.renderable.render();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2024-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Ludovic Autin <autin@scripps.edu>
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
@@ -37,7 +37,7 @@ export type DofProps = PD.Values<typeof DofParams>
|
||||
|
||||
export class DofPass {
|
||||
static isEnabled(props: PostprocessingProps) {
|
||||
return props.dof.name !== 'off';
|
||||
return props.enabled && props.dof.name !== 'off';
|
||||
}
|
||||
|
||||
readonly target: RenderTarget;
|
||||
@@ -119,18 +119,18 @@ export class DofPass {
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
const wolrdCenter = (props.center === 'scene-center' ? sphere.center : camera.state.target);
|
||||
const distance = Vec3.distance(camera.state.position, wolrdCenter);
|
||||
const worldCenter = (props.center === 'scene-center' ? sphere.center : camera.state.target);
|
||||
const distance = Vec3.distance(camera.state.position, worldCenter);
|
||||
const inFocus = distance + props.inFocus;
|
||||
ValueCell.updateIfChanged(this.renderable.values.uInFocus, inFocus);
|
||||
ValueCell.updateIfChanged(this.renderable.values.uInFocus, inFocus * camera.state.scale);
|
||||
|
||||
// transform center in view space
|
||||
const center = this.renderable.values.uCenter.ref.value;
|
||||
Vec3.transformMat4(center, wolrdCenter, camera.view);
|
||||
Vec3.transformMat4(center, worldCenter, camera.view);
|
||||
ValueCell.update(this.renderable.values.uCenter, center);
|
||||
|
||||
ValueCell.updateIfChanged(this.renderable.values.uBlurSpread, props.blurSpread);
|
||||
ValueCell.updateIfChanged(this.renderable.values.uPPM, props.PPM);
|
||||
ValueCell.updateIfChanged(this.renderable.values.uPPM, props.PPM * camera.state.scale);
|
||||
|
||||
if (needsUpdate) {
|
||||
this.renderable.update();
|
||||
@@ -142,7 +142,7 @@ export class DofPass {
|
||||
if (target) {
|
||||
target.bind();
|
||||
} else {
|
||||
this.webgl.unbindFramebuffer();
|
||||
this.webgl.bindDrawingBuffer();
|
||||
}
|
||||
this.updateState(viewport);
|
||||
this.renderable.render();
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { WebGLContext } from '../../mol-gl/webgl/context';
|
||||
import { createNullRenderTarget, RenderTarget } from '../../mol-gl/webgl/render-target';
|
||||
import { RenderTarget } from '../../mol-gl/webgl/render-target';
|
||||
import { Renderer } from '../../mol-gl/renderer';
|
||||
import { Scene } from '../../mol-gl/scene';
|
||||
import { Texture } from '../../mol-gl/webgl/texture';
|
||||
@@ -90,7 +90,7 @@ export class DrawPass {
|
||||
|
||||
constructor(private webgl: WebGLContext, assetManager: AssetManager, width: number, height: number, transparency: 'wboit' | 'dpoit' | 'blended') {
|
||||
const { extensions, resources, isWebGL2 } = webgl;
|
||||
this.drawTarget = createNullRenderTarget(webgl.gl);
|
||||
this.drawTarget = webgl.createDrawTarget();
|
||||
this.colorTarget = webgl.createRenderTarget(width, height, true, 'uint8', 'linear');
|
||||
this.transparentColorTarget = webgl.createRenderTarget(width, height, false, 'uint8', 'linear');
|
||||
|
||||
@@ -377,6 +377,7 @@ export class DrawPass {
|
||||
const antialiasingEnabled = AntialiasingPass.isEnabled(props.postprocessing);
|
||||
const markingEnabled = MarkingPass.isEnabled(props.marking);
|
||||
const dofEnabled = DofPass.isEnabled(props.postprocessing);
|
||||
const bloomEnabled = BloomPass.isEnabled(props.postprocessing);
|
||||
|
||||
const { x, y, width, height } = camera.viewport;
|
||||
renderer.setViewport(x, y, width, height);
|
||||
@@ -446,7 +447,7 @@ export class DrawPass {
|
||||
needsTargetCopy = true;
|
||||
}
|
||||
|
||||
if (props.postprocessing.dof.name === 'on') {
|
||||
if (dofEnabled && props.postprocessing.dof.name === 'on') {
|
||||
const input = AntialiasingPass.isEnabled(props.postprocessing)
|
||||
? this.antialiasing.target.texture
|
||||
: PostprocessingPass.isEnabled(props.postprocessing)
|
||||
@@ -469,7 +470,7 @@ export class DrawPass {
|
||||
}
|
||||
}
|
||||
|
||||
if (props.postprocessing.bloom.name === 'on') {
|
||||
if (bloomEnabled && props.postprocessing.bloom.name === 'on') {
|
||||
const emissiveBloom = props.postprocessing.bloom.params.mode === 'emissive';
|
||||
|
||||
if (emissiveBloom && scene.emissiveAverage > 0) {
|
||||
@@ -493,7 +494,7 @@ export class DrawPass {
|
||||
const { renderer, camera, scene, helper } = ctx;
|
||||
|
||||
this.postprocessing.setTransparentBackground(props.transparentBackground);
|
||||
const transparentBackground = props.transparentBackground || this.postprocessing.background.isEnabled(props.postprocessing.background);
|
||||
const transparentBackground = props.transparentBackground || this.postprocessing.background.isEnabled(props.postprocessing);
|
||||
|
||||
renderer.setTransparentBackground(transparentBackground);
|
||||
renderer.setDrawingBufferSize(this.colorTarget.getWidth(), this.colorTarget.getHeight());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2020-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
@@ -88,7 +88,7 @@ export class FxaaPass {
|
||||
if (target) {
|
||||
target.bind();
|
||||
} else {
|
||||
this.webgl.unbindFramebuffer();
|
||||
this.webgl.bindDrawingBuffer();
|
||||
}
|
||||
this.updateState(viewport);
|
||||
this.renderable.render();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2023-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
@@ -20,7 +20,7 @@ import { Camera } from '../camera';
|
||||
import { Viewport } from '../camera/util';
|
||||
import { DrawPass } from './draw';
|
||||
import { ParamDefinition as PD } from '../../mol-util/param-definition';
|
||||
import { getBuffer } from '../../mol-gl/webgl/buffer';
|
||||
import { PixelPackBuffer } from '../../mol-gl/webgl/buffer';
|
||||
|
||||
// avoiding namespace lookup improved performance in Chrome (Aug 2020)
|
||||
const v3transformMat4 = Vec3.transformMat4;
|
||||
@@ -128,7 +128,7 @@ export class HiZPass {
|
||||
|
||||
private readonly levelData: LevelData = [];
|
||||
private readonly fb: Framebuffer;
|
||||
private readonly buf: WebGLBuffer;
|
||||
private readonly buf: PixelPackBuffer;
|
||||
private readonly tex: Texture;
|
||||
private readonly renderable: HiZRenderable;
|
||||
private readonly supported: boolean;
|
||||
@@ -221,10 +221,7 @@ export class HiZPass {
|
||||
const hw = this.tex.getWidth();
|
||||
const hh = this.tex.getHeight();
|
||||
|
||||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.buf);
|
||||
gl.bufferData(gl.PIXEL_PACK_BUFFER, this.buffer.byteLength, gl.STREAM_READ);
|
||||
gl.readPixels(0, 0, hw, hh, gl.RED, gl.FLOAT, 0);
|
||||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
|
||||
this.buf.read(0, 0, hw, hh);
|
||||
|
||||
this.sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
|
||||
gl.flush();
|
||||
@@ -249,9 +246,7 @@ export class HiZPass {
|
||||
this.frameLag += 1;
|
||||
// console.log(`waiting for buffer data for ${this.frameLag} frames`);
|
||||
} else {
|
||||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.buf);
|
||||
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, this.buffer);
|
||||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
|
||||
this.buf.getSubData(this.buffer);
|
||||
// console.log(`got buffer data after ${this.frameLag + 1} frames`);
|
||||
gl.deleteSync(this.sync);
|
||||
this.sync = null;
|
||||
@@ -510,6 +505,16 @@ export class HiZPass {
|
||||
|
||||
//
|
||||
|
||||
reset() {
|
||||
this.sync = null;
|
||||
this.ready = false;
|
||||
this.frameLag = 0;
|
||||
this.levelData.length = 0;
|
||||
|
||||
const { x, y, width, height } = this.viewport;
|
||||
this.setViewport(x, y, width, height);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (!this.supported) return;
|
||||
|
||||
@@ -517,7 +522,7 @@ export class HiZPass {
|
||||
|
||||
this.fb.destroy();
|
||||
this.tex.destroy();
|
||||
this.webgl.gl.deleteBuffer(this.buf);
|
||||
this.buf.destroy();
|
||||
this.renderable.dispose();
|
||||
|
||||
for (const td of this.levelData) {
|
||||
@@ -527,6 +532,8 @@ export class HiZPass {
|
||||
}
|
||||
|
||||
constructor(private webgl: WebGLContext, private drawPass: DrawPass, canvas: HTMLCanvasElement | undefined, props: Partial<HiZProps>) {
|
||||
this.props = { ...PD.getDefaultValues(HiZParams), ...props };
|
||||
|
||||
const { gl, extensions } = webgl;
|
||||
if (!isWebGL2(gl) || !extensions.colorBufferFloat) {
|
||||
if (isDebugMode) {
|
||||
@@ -552,8 +559,7 @@ export class HiZPass {
|
||||
}
|
||||
|
||||
this.supported = true;
|
||||
this.props = { ...PD.getDefaultValues(HiZParams), ...props };
|
||||
this.buf = getBuffer(gl);
|
||||
this.buf = webgl.resources.pixelPack('alpha', 'float');
|
||||
this.renderable = createHiZRenderable(webgl, this.drawPass.depthTextureOpaque);
|
||||
|
||||
if (isDebugMode && canvas) {
|
||||
|
||||
@@ -33,6 +33,8 @@ import { JitterVectors, MultiSampleProps } from './multi-sample';
|
||||
import { compose_frag as multiSample_compose_frag } from '../../mol-gl/shader/compose.frag';
|
||||
import { clamp, lerp } from '../../mol-math/interpolate';
|
||||
import { SsaoProps } from './ssao';
|
||||
import { OutlinePass } from './outline';
|
||||
import { BloomPass } from './bloom';
|
||||
|
||||
type Props = {
|
||||
transparentBackground: boolean;
|
||||
@@ -313,8 +315,11 @@ export class IlluminationPass {
|
||||
|
||||
const orthographic = camera.state.mode === 'orthographic' ? 1 : 0;
|
||||
|
||||
const outlinesEnabled = props.postprocessing.outline.name === 'on' && !props.illumination.ignoreOutline;
|
||||
const antialiasingEnabled = AntialiasingPass.isEnabled(props.postprocessing);
|
||||
const outlinesEnabled = OutlinePass.isEnabled(props.postprocessing) && !props.illumination.ignoreOutline;
|
||||
const occlusionEnabled = PostprocessingPass.isTransparentSsaoEnabled(scene, props.postprocessing);
|
||||
const bloomEnabled = BloomPass.isEnabled(props.postprocessing);
|
||||
const dofEnabled = DofPass.isEnabled(props.postprocessing);
|
||||
|
||||
const markingEnabled = MarkingPass.isEnabled(props.marking);
|
||||
const hasTransparent = scene.opacityAverage < 1;
|
||||
@@ -327,7 +332,7 @@ export class IlluminationPass {
|
||||
ValueCell.update(this.composeRenderable.values.dOutlineEnable, outlinesEnabled);
|
||||
}
|
||||
|
||||
if (props.postprocessing.outline.name === 'on') {
|
||||
if (outlinesEnabled && props.postprocessing.outline.name === 'on') {
|
||||
const { transparentOutline, outlineScale } = this.drawPass.postprocessing.outline.update(camera, props.postprocessing.outline.params, this.drawPass.depthTargetTransparent.texture, this.drawPass.depthTextureOpaque);
|
||||
this.drawPass.postprocessing.outline.render();
|
||||
|
||||
@@ -348,7 +353,7 @@ export class IlluminationPass {
|
||||
ValueCell.update(this.composeRenderable.values.dOcclusionEnable, occlusionEnabled);
|
||||
}
|
||||
|
||||
if (props.postprocessing.occlusion.name === 'on') {
|
||||
if (occlusionEnabled && props.postprocessing.occlusion.name === 'on') {
|
||||
ValueCell.update(this.composeRenderable.values.uOcclusionColor, Color.toVec3Normalized(this.composeRenderable.values.uOcclusionColor.ref.value, props.postprocessing.occlusion.params.color));
|
||||
}
|
||||
|
||||
@@ -370,9 +375,9 @@ export class IlluminationPass {
|
||||
|
||||
// background
|
||||
|
||||
const _toDrawingBuffer = toDrawingBuffer && !AntialiasingPass.isEnabled(props.postprocessing) && props.postprocessing.dof.name === 'off';
|
||||
const _toDrawingBuffer = toDrawingBuffer && !antialiasingEnabled && !dofEnabled;
|
||||
if (_toDrawingBuffer) {
|
||||
this.webgl.unbindFramebuffer();
|
||||
this.webgl.bindDrawingBuffer();
|
||||
} else {
|
||||
this.tracing.composeTarget.bind();
|
||||
}
|
||||
@@ -384,7 +389,7 @@ export class IlluminationPass {
|
||||
|
||||
// compose
|
||||
|
||||
ValueCell.updateIfChanged(this.composeRenderable.values.uTransparentBackground, props.transparentBackground || this.drawPass.postprocessing.background.isEnabled(props.postprocessing.background));
|
||||
ValueCell.updateIfChanged(this.composeRenderable.values.uTransparentBackground, props.transparentBackground || this.drawPass.postprocessing.background.isEnabled(props.postprocessing));
|
||||
if (this.composeRenderable.values.dDenoise.ref.value !== props.illumination.denoise) {
|
||||
ValueCell.update(this.composeRenderable.values.dDenoise, props.illumination.denoise);
|
||||
needsUpdateCompose = true;
|
||||
@@ -421,8 +426,8 @@ export class IlluminationPass {
|
||||
let targetIsDrawingbuffer = false;
|
||||
let swapTarget = this.outputTarget;
|
||||
|
||||
if (AntialiasingPass.isEnabled(props.postprocessing)) {
|
||||
const _toDrawingBuffer = toDrawingBuffer && props.postprocessing.dof.name === 'off';
|
||||
if (antialiasingEnabled) {
|
||||
const _toDrawingBuffer = toDrawingBuffer && !dofEnabled;
|
||||
this.drawPass.antialiasing.render(camera, this.tracing.composeTarget.texture, _toDrawingBuffer ? true : this.outputTarget, props.postprocessing);
|
||||
|
||||
if (_toDrawingBuffer) {
|
||||
@@ -433,13 +438,13 @@ export class IlluminationPass {
|
||||
}
|
||||
}
|
||||
|
||||
if (props.postprocessing.bloom.name === 'on') {
|
||||
const _toDrawingBuffer = (toDrawingBuffer && props.postprocessing.dof.name === 'off') || targetIsDrawingbuffer;
|
||||
if (bloomEnabled && props.postprocessing.bloom.name === 'on') {
|
||||
const _toDrawingBuffer = (toDrawingBuffer && !dofEnabled) || targetIsDrawingbuffer;
|
||||
this.drawPass.bloom.update(this.tracing.colorTextureOpaque, this.tracing.normalTextureOpaque, this.drawPass.depthTextureOpaque, props.postprocessing.bloom.params);
|
||||
this.drawPass.bloom.render(camera.viewport, _toDrawingBuffer ? undefined : this._colorTarget);
|
||||
}
|
||||
|
||||
if (props.postprocessing.dof.name === 'on') {
|
||||
if (dofEnabled && props.postprocessing.dof.name === 'on') {
|
||||
const _toDrawingBuffer = toDrawingBuffer || targetIsDrawingbuffer;
|
||||
this.drawPass.dof.update(camera, this._colorTarget.texture, this.drawPass.depthTextureOpaque, this.drawPass.depthTargetTransparent.texture, props.postprocessing.dof.params, scene.boundingSphereVisible);
|
||||
this.drawPass.dof.render(camera.viewport, _toDrawingBuffer ? undefined : swapTarget);
|
||||
@@ -530,7 +535,7 @@ export class IlluminationPass {
|
||||
this.prevSampleIndex = sampleIndex;
|
||||
|
||||
if (toDrawingBuffer) {
|
||||
this.webgl.unbindFramebuffer();
|
||||
this.webgl.bindDrawingBuffer();
|
||||
} else {
|
||||
this.multiSampleAccumulateTarget.bind();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2021-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2021-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
@@ -121,17 +121,13 @@ export class MarkingPass {
|
||||
ValueCell.updateIfChanged(overlayValues.uSelectEdgeStrength, selectEdgeStrength);
|
||||
}
|
||||
|
||||
render(viewport: Viewport, target: RenderTarget | undefined) {
|
||||
render(viewport: Viewport, target: RenderTarget) {
|
||||
if (isTimingMode) this.webgl.timer.mark('MarkingPass.render');
|
||||
this.edgesTarget.bind();
|
||||
this.setEdgeState(viewport);
|
||||
this.edge.render();
|
||||
|
||||
if (target) {
|
||||
target.bind();
|
||||
} else {
|
||||
this.webgl.unbindFramebuffer();
|
||||
}
|
||||
target.bind();
|
||||
this.setOverlayState(viewport);
|
||||
this.overlay.render();
|
||||
if (isTimingMode) this.webgl.timer.markEnd('MarkingPass.render');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2019-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2019-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
@@ -120,7 +120,7 @@ export class MultiSamplePass {
|
||||
|
||||
private bindOutputTarget(toDrawingBuffer: boolean) {
|
||||
if (toDrawingBuffer) {
|
||||
this.webgl.unbindFramebuffer();
|
||||
this.webgl.bindDrawingBuffer();
|
||||
} else {
|
||||
this.colorTarget.bind();
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export type OutlineProps = PD.Values<typeof OutlineParams>
|
||||
|
||||
export class OutlinePass {
|
||||
static isEnabled(props: PostprocessingProps) {
|
||||
return props.outline.name !== 'off';
|
||||
return props.enabled && props.outline.name !== 'off';
|
||||
}
|
||||
|
||||
readonly target: RenderTarget;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2020-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2020-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
@@ -18,9 +18,9 @@ export class Passes {
|
||||
readonly illumination: IlluminationPass;
|
||||
|
||||
constructor(private webgl: WebGLContext, assetManager: AssetManager, attribs: Partial<{ pickScale: number, transparency: 'wboit' | 'dpoit' | 'blended' }> = {}) {
|
||||
const { gl } = webgl;
|
||||
this.draw = new DrawPass(webgl, assetManager, gl.drawingBufferWidth, gl.drawingBufferHeight, attribs.transparency || 'blended');
|
||||
this.pick = new PickPass(webgl, this.draw, attribs.pickScale || 0.25);
|
||||
const drs = this.webgl.getDrawingBufferSize();
|
||||
this.draw = new DrawPass(webgl, assetManager, drs.width, drs.height, attribs.transparency || 'blended');
|
||||
this.pick = new PickPass(webgl, drs.width, drs.height, attribs.pickScale || 0.25);
|
||||
this.multiSample = new MultiSamplePass(webgl, this.draw);
|
||||
this.illumination = new IlluminationPass(webgl, this.draw);
|
||||
}
|
||||
@@ -34,12 +34,12 @@ export class Passes {
|
||||
}
|
||||
|
||||
updateSize() {
|
||||
const { gl } = this.webgl;
|
||||
const drs = this.webgl.getDrawingBufferSize();
|
||||
// Avoid setting dimensions to 0x0 because it causes "empty textures are not allowed" error.
|
||||
const width = Math.max(gl.drawingBufferWidth, 2);
|
||||
const height = Math.max(gl.drawingBufferHeight, 2);
|
||||
const width = Math.max(drs.width, 2);
|
||||
const height = Math.max(drs.height, 2);
|
||||
this.draw.setSize(width, height);
|
||||
this.pick.syncSize();
|
||||
this.pick.setSize(width, height);
|
||||
this.multiSample.syncSize();
|
||||
this.illumination.setSize(width, height);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { PickingId } from '../../mol-geo/geometry/picking';
|
||||
import { PickType, Renderer } from '../../mol-gl/renderer';
|
||||
import { Scene } from '../../mol-gl/scene';
|
||||
import { PixelPackBuffer } from '../../mol-gl/webgl/buffer';
|
||||
import { isWebGL2 } from '../../mol-gl/webgl/compat';
|
||||
import { WebGLContext } from '../../mol-gl/webgl/context';
|
||||
import { Framebuffer } from '../../mol-gl/webgl/framebuffer';
|
||||
@@ -14,20 +15,29 @@ import { RenderTarget } from '../../mol-gl/webgl/render-target';
|
||||
import { Renderbuffer } from '../../mol-gl/webgl/renderbuffer';
|
||||
import { Texture } from '../../mol-gl/webgl/texture';
|
||||
import { Vec3 } from '../../mol-math/linear-algebra';
|
||||
import { spiral2d } from '../../mol-math/misc';
|
||||
import { isTimingMode } from '../../mol-util/debug';
|
||||
import { unpackRGBToInt, unpackRGBAToDepth } from '../../mol-util/number-packing';
|
||||
import { Camera, ICamera } from '../camera';
|
||||
import { StereoCamera } from '../camera/stereo';
|
||||
import { cameraUnproject } from '../camera/util';
|
||||
import { isDebugMode, isTimingMode } from '../../mol-util/debug';
|
||||
import { now } from '../../mol-util/now';
|
||||
import { unpackRGBAToDepth, unpackRGBToInt } from '../../mol-util/number-packing';
|
||||
import { ICamera } from '../camera';
|
||||
import { Viewport } from '../camera/util';
|
||||
import { Helper } from '../helper/helper';
|
||||
import { DrawPass } from './draw';
|
||||
|
||||
const NullId = Math.pow(2, 24) - 2;
|
||||
|
||||
export type PickData = { id: PickingId, position: Vec3 }
|
||||
|
||||
export type AsyncPickData = {
|
||||
tryGet: () => 'pending' | PickData | undefined,
|
||||
}
|
||||
|
||||
export const DefaultPickOptions = {
|
||||
pickPadding: 1,
|
||||
maxAsyncReadLag: 5,
|
||||
};
|
||||
export type PickOptions = typeof DefaultPickOptions
|
||||
|
||||
//
|
||||
|
||||
export class PickPass {
|
||||
private readonly objectPickTarget: RenderTarget;
|
||||
private readonly instancePickTarget: RenderTarget;
|
||||
@@ -51,10 +61,10 @@ export class PickPass {
|
||||
private pickWidth: number;
|
||||
private pickHeight: number;
|
||||
|
||||
constructor(private webgl: WebGLContext, private drawPass: DrawPass, private pickScale: number) {
|
||||
constructor(private webgl: WebGLContext, private width: number, private height: number, private pickScale: number) {
|
||||
const pickRatio = pickScale / webgl.pixelRatio;
|
||||
this.pickWidth = Math.ceil(drawPass.colorTarget.getWidth() * pickRatio);
|
||||
this.pickHeight = Math.ceil(drawPass.colorTarget.getHeight() * pickRatio);
|
||||
this.pickWidth = Math.ceil(width * pickRatio);
|
||||
this.pickHeight = Math.ceil(height * pickRatio);
|
||||
|
||||
const { resources, extensions: { drawBuffers }, gl } = webgl;
|
||||
|
||||
@@ -109,13 +119,36 @@ export class PickPass {
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.webgl.extensions.drawBuffers) {
|
||||
this.framebuffer.destroy();
|
||||
|
||||
this.objectPickTexture.destroy();
|
||||
this.instancePickTexture.destroy();
|
||||
this.groupPickTexture.destroy();
|
||||
this.depthPickTexture.destroy();
|
||||
|
||||
this.objectPickFramebuffer.destroy();
|
||||
this.instancePickFramebuffer.destroy();
|
||||
this.groupPickFramebuffer.destroy();
|
||||
this.depthPickFramebuffer.destroy();
|
||||
|
||||
this.depthRenderbuffer.destroy();
|
||||
} else {
|
||||
this.objectPickTarget.destroy();
|
||||
this.instancePickTarget.destroy();
|
||||
this.groupPickTarget.destroy();
|
||||
this.depthPickTarget.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
get pickRatio() {
|
||||
return this.pickScale / this.webgl.pixelRatio;
|
||||
}
|
||||
|
||||
setPickScale(pickScale: number) {
|
||||
this.pickScale = pickScale;
|
||||
this.syncSize();
|
||||
this.setSize(this.width, this.height);
|
||||
}
|
||||
|
||||
bindObject() {
|
||||
@@ -151,13 +184,16 @@ export class PickPass {
|
||||
}
|
||||
|
||||
get drawingBufferHeight() {
|
||||
return this.drawPass.colorTarget.getHeight();
|
||||
return this.height;
|
||||
}
|
||||
|
||||
syncSize() {
|
||||
setSize(width: number, height: number) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
|
||||
const pickRatio = this.pickScale / this.webgl.pixelRatio;
|
||||
const pickWidth = Math.ceil(this.drawPass.colorTarget.getWidth() * pickRatio);
|
||||
const pickHeight = Math.ceil(this.drawPass.colorTarget.getHeight() * pickRatio);
|
||||
const pickWidth = Math.ceil(this.width * pickRatio);
|
||||
const pickHeight = Math.ceil(this.height * pickRatio);
|
||||
|
||||
if (pickWidth !== this.pickWidth || pickHeight !== this.pickHeight) {
|
||||
this.pickWidth = pickWidth;
|
||||
@@ -225,6 +261,7 @@ export class PickPass {
|
||||
if (this.webgl.extensions.drawBuffers) {
|
||||
this.framebuffer.bind();
|
||||
this.renderVariant(renderer, camera, scene, helper, 'pick', PickType.None);
|
||||
// printTextureImage(readTexture(this.webgl, this.groupPickTexture, new Uint8Array(this.pickWidth * this.pickHeight * 4)), { scale: 16, id: 'group', pixelated: true, useCanvas: true, flipY: true });
|
||||
} else {
|
||||
this.objectPickTarget.bind();
|
||||
this.renderVariant(renderer, camera, scene, helper, 'pick', PickType.Object);
|
||||
@@ -234,7 +271,7 @@ export class PickPass {
|
||||
|
||||
this.groupPickTarget.bind();
|
||||
this.renderVariant(renderer, camera, scene, helper, 'pick', PickType.Group);
|
||||
// printTexture(this.webgl, this.groupPickTarget.texture, { id: 'group' })
|
||||
// printTextureImage(readTexture(this.webgl, this.groupPickTarget.texture, new Uint8Array(this.pickWidth * this.pickHeight * 4)), { scale: 16, id: 'group', pixelated: true, useCanvas: true, flipY: true });
|
||||
|
||||
this.depthPickTarget.bind();
|
||||
this.renderVariant(renderer, camera, scene, helper, 'depth', PickType.None);
|
||||
@@ -242,200 +279,220 @@ export class PickPass {
|
||||
}
|
||||
}
|
||||
|
||||
export class PickHelper {
|
||||
dirty = true;
|
||||
let AsyncPickingWarningShown = false;
|
||||
|
||||
private objectBuffer: Uint8Array;
|
||||
private instanceBuffer: Uint8Array;
|
||||
private groupBuffer: Uint8Array;
|
||||
private depthBuffer: Uint8Array;
|
||||
export function checkAsyncPickingSupport(webgl: WebGLContext): boolean {
|
||||
if (webgl.isWebGL2) return true;
|
||||
|
||||
private viewport = Viewport();
|
||||
if (isDebugMode && !AsyncPickingWarningShown) {
|
||||
console.log('WebGL2 required for async picking. Falling back to synchronous picking.');
|
||||
AsyncPickingWarningShown = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private pickRatio: number;
|
||||
private pickX: number;
|
||||
private pickY: number;
|
||||
private pickWidth: number;
|
||||
private pickHeight: number;
|
||||
private halfPickWidth: number;
|
||||
export enum AsyncPickStatus { Pending, Resolved, Failed };
|
||||
|
||||
private spiral: [number, number][];
|
||||
export class PickBuffers {
|
||||
private object: Uint8Array;
|
||||
private instance: Uint8Array;
|
||||
private group: Uint8Array;
|
||||
private depth: Uint8Array;
|
||||
|
||||
private setupBuffers() {
|
||||
const bufferSize = this.pickWidth * this.pickHeight * 4;
|
||||
if (!this.objectBuffer || this.objectBuffer.length !== bufferSize) {
|
||||
this.objectBuffer = new Uint8Array(bufferSize);
|
||||
this.instanceBuffer = new Uint8Array(bufferSize);
|
||||
this.groupBuffer = new Uint8Array(bufferSize);
|
||||
this.depthBuffer = new Uint8Array(bufferSize);
|
||||
private objectBuffer: PixelPackBuffer;
|
||||
private instanceBuffer: PixelPackBuffer;
|
||||
private groupBuffer: PixelPackBuffer;
|
||||
private depthBuffer: PixelPackBuffer;
|
||||
|
||||
private viewport = Viewport.create(0, 0, 0, 0);
|
||||
|
||||
private setup() {
|
||||
const size = this.viewport.width * this.viewport.height * 4;
|
||||
if (!this.object || this.object.length !== size) {
|
||||
this.object = new Uint8Array(size);
|
||||
this.instance = new Uint8Array(size);
|
||||
this.group = new Uint8Array(size);
|
||||
this.depth = new Uint8Array(size);
|
||||
}
|
||||
}
|
||||
|
||||
setViewport(x: number, y: number, width: number, height: number) {
|
||||
Viewport.set(this.viewport, x, y, width, height);
|
||||
this.update();
|
||||
this.setup();
|
||||
}
|
||||
|
||||
setPickPadding(pickPadding: number) {
|
||||
if (this.pickPadding !== pickPadding) {
|
||||
this.pickPadding = pickPadding;
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
private update() {
|
||||
read() {
|
||||
if (isTimingMode) this.webgl.timer.mark('PickBuffers.read');
|
||||
const { x, y, width, height } = this.viewport;
|
||||
|
||||
this.pickRatio = this.pickPass.pickRatio;
|
||||
this.pickX = Math.ceil(x * this.pickRatio);
|
||||
this.pickY = Math.ceil(y * this.pickRatio);
|
||||
|
||||
const pickWidth = Math.floor(width * this.pickRatio);
|
||||
const pickHeight = Math.floor(height * this.pickRatio);
|
||||
|
||||
if (pickWidth !== this.pickWidth || pickHeight !== this.pickHeight) {
|
||||
this.pickWidth = pickWidth;
|
||||
this.pickHeight = pickHeight;
|
||||
this.halfPickWidth = Math.floor(this.pickWidth / 2);
|
||||
|
||||
this.setupBuffers();
|
||||
}
|
||||
|
||||
this.spiral = spiral2d(Math.ceil(this.pickRatio * this.pickPadding));
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
private syncBuffers() {
|
||||
if (isTimingMode) this.webgl.timer.mark('PickHelper.syncBuffers');
|
||||
const { pickX, pickY, pickWidth, pickHeight } = this;
|
||||
|
||||
this.pickPass.bindObject();
|
||||
this.webgl.readPixels(pickX, pickY, pickWidth, pickHeight, this.objectBuffer);
|
||||
this.webgl.readPixels(x, y, width, height, this.object);
|
||||
|
||||
this.pickPass.bindInstance();
|
||||
this.webgl.readPixels(pickX, pickY, pickWidth, pickHeight, this.instanceBuffer);
|
||||
this.webgl.readPixels(x, y, width, height, this.instance);
|
||||
|
||||
this.pickPass.bindGroup();
|
||||
this.webgl.readPixels(pickX, pickY, pickWidth, pickHeight, this.groupBuffer);
|
||||
this.webgl.readPixels(x, y, width, height, this.group);
|
||||
|
||||
this.pickPass.bindDepth();
|
||||
this.webgl.readPixels(pickX, pickY, pickWidth, pickHeight, this.depthBuffer);
|
||||
if (isTimingMode) this.webgl.timer.markEnd('PickHelper.syncBuffers');
|
||||
this.webgl.readPixels(x, y, width, height, this.depth);
|
||||
|
||||
this.ready = true;
|
||||
if (isTimingMode) this.webgl.timer.markEnd('PickBuffers.read');
|
||||
}
|
||||
|
||||
private getBufferIdx(x: number, y: number): number {
|
||||
return (y * this.pickWidth + x) * 4;
|
||||
private fenceSync: WebGLSync | null = null;
|
||||
private fenceTimestamp: number = 0;
|
||||
|
||||
private ready = false;
|
||||
private lag = 0;
|
||||
|
||||
asyncRead() {
|
||||
const { gl } = this.webgl;
|
||||
if (!isWebGL2(gl)) return;
|
||||
|
||||
if (isTimingMode) this.webgl.timer.mark('PickBuffers.asyncRead');
|
||||
if (this.fenceSync !== null) {
|
||||
gl.deleteSync(this.fenceSync);
|
||||
}
|
||||
const { x, y, width, height } = this.viewport;
|
||||
|
||||
this.pickPass.bindObject();
|
||||
this.objectBuffer.read(x, y, width, height);
|
||||
|
||||
this.pickPass.bindInstance();
|
||||
this.instanceBuffer.read(x, y, width, height);
|
||||
|
||||
this.pickPass.bindGroup();
|
||||
this.groupBuffer.read(x, y, width, height);
|
||||
|
||||
this.pickPass.bindDepth();
|
||||
this.depthBuffer.read(x, y, width, height);
|
||||
|
||||
this.fenceTimestamp = now();
|
||||
this.fenceSync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
|
||||
// gl.flush();
|
||||
|
||||
this.ready = false;
|
||||
if (isTimingMode) this.webgl.timer.markEnd('PickBuffers.asyncRead');
|
||||
}
|
||||
|
||||
private getDepth(x: number, y: number): number {
|
||||
const idx = this.getBufferIdx(x, y);
|
||||
const b = this.depthBuffer;
|
||||
check(): AsyncPickStatus {
|
||||
if (this.ready) return AsyncPickStatus.Resolved;
|
||||
if (this.fenceSync === null) return AsyncPickStatus.Failed;
|
||||
|
||||
const { gl } = this.webgl;
|
||||
if (!isWebGL2(gl)) return AsyncPickStatus.Failed;
|
||||
|
||||
const res = gl.clientWaitSync(this.fenceSync, 0, 0);
|
||||
if (res === gl.WAIT_FAILED || this.lag >= this.maxAsyncReadLag) {
|
||||
// console.log(`failed to get buffer data after ${this.lag + 1} checks`);
|
||||
if (res !== gl.WAIT_FAILED && now() - this.fenceTimestamp < 1000 / 60) {
|
||||
this.lag += 1;
|
||||
return AsyncPickStatus.Pending;
|
||||
}
|
||||
gl.deleteSync(this.fenceSync);
|
||||
this.fenceSync = null;
|
||||
this.lag = 0;
|
||||
this.ready = false;
|
||||
return AsyncPickStatus.Failed;
|
||||
} else if (res === gl.TIMEOUT_EXPIRED) {
|
||||
this.lag += 1;
|
||||
// console.log(`waiting for buffer data for ${this.lag} checks`);
|
||||
return AsyncPickStatus.Pending;
|
||||
} else {
|
||||
this.objectBuffer.getSubData(this.object);
|
||||
this.instanceBuffer.getSubData(this.instance);
|
||||
this.groupBuffer.getSubData(this.group);
|
||||
this.depthBuffer.getSubData(this.depth);
|
||||
|
||||
// console.log(`got buffer data after ${this.lag + 1} checks`);
|
||||
gl.deleteSync(this.fenceSync);
|
||||
this.fenceSync = null;
|
||||
this.lag = 0;
|
||||
this.ready = true;
|
||||
|
||||
return AsyncPickStatus.Resolved;
|
||||
}
|
||||
}
|
||||
|
||||
private getIdx(x: number, y: number): number {
|
||||
return (y * this.viewport.width + x) * 4;
|
||||
}
|
||||
|
||||
getDepth(x: number, y: number): number {
|
||||
if (!this.ready) return -1;
|
||||
|
||||
const idx = this.getIdx(x, y);
|
||||
const b = this.depth;
|
||||
return unpackRGBAToDepth(b[idx], b[idx + 1], b[idx + 2], b[idx + 3]);
|
||||
}
|
||||
|
||||
private getId(x: number, y: number, buffer: Uint8Array) {
|
||||
const idx = this.getBufferIdx(x, y);
|
||||
if (!this.ready) return -1;
|
||||
|
||||
const idx = this.getIdx(x, y);
|
||||
return unpackRGBToInt(buffer[idx], buffer[idx + 1], buffer[idx + 2]);
|
||||
}
|
||||
|
||||
private render(camera: Camera | StereoCamera) {
|
||||
if (isTimingMode) this.webgl.timer.mark('PickHelper.render', { captureStats: true });
|
||||
const { pickX, pickY, pickWidth, pickHeight, halfPickWidth } = this;
|
||||
const { renderer, scene, helper } = this;
|
||||
|
||||
renderer.setTransparentBackground(false);
|
||||
renderer.setDrawingBufferSize(pickWidth, pickHeight);
|
||||
renderer.setPixelRatio(this.pickRatio);
|
||||
|
||||
if (StereoCamera.is(camera)) {
|
||||
renderer.setViewport(pickX, pickY, halfPickWidth, pickHeight);
|
||||
this.pickPass.render(renderer, camera.left, scene, helper);
|
||||
|
||||
renderer.setViewport(pickX + halfPickWidth, pickY, pickWidth - halfPickWidth, pickHeight);
|
||||
this.pickPass.render(renderer, camera.right, scene, helper);
|
||||
} else {
|
||||
renderer.setViewport(pickX, pickY, pickWidth, pickHeight);
|
||||
this.pickPass.render(renderer, camera, scene, helper);
|
||||
}
|
||||
|
||||
this.dirty = false;
|
||||
if (isTimingMode) this.webgl.timer.markEnd('PickHelper.render');
|
||||
getObjectId(x: number, y: number) {
|
||||
return this.getId(x, y, this.object);
|
||||
}
|
||||
|
||||
private identifyInternal(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
|
||||
if (this.pickRatio !== this.pickPass.pickRatio) {
|
||||
this.update();
|
||||
}
|
||||
getInstanceId(x: number, y: number) {
|
||||
return this.getId(x, y, this.instance);
|
||||
}
|
||||
|
||||
const { webgl, pickRatio } = this;
|
||||
if (webgl.isContextLost) return;
|
||||
getGroupId(x: number, y: number) {
|
||||
return this.getId(x, y, this.group);
|
||||
}
|
||||
|
||||
x *= webgl.pixelRatio;
|
||||
y *= webgl.pixelRatio;
|
||||
y = this.pickPass.drawingBufferHeight - y; // flip y
|
||||
|
||||
const { viewport } = this;
|
||||
|
||||
// check if within viewport
|
||||
if (x < viewport.x ||
|
||||
y < viewport.y ||
|
||||
x > viewport.x + viewport.width ||
|
||||
y > viewport.y + viewport.height
|
||||
) return;
|
||||
|
||||
if (this.dirty) {
|
||||
if (isTimingMode) this.webgl.timer.mark('PickHelper.identify');
|
||||
this.render(camera);
|
||||
this.syncBuffers();
|
||||
if (isTimingMode) this.webgl.timer.markEnd('PickHelper.identify');
|
||||
}
|
||||
|
||||
const xv = x - viewport.x;
|
||||
const yv = y - viewport.y;
|
||||
|
||||
const xp = Math.floor(xv * pickRatio);
|
||||
const yp = Math.floor(yv * pickRatio);
|
||||
|
||||
const objectId = this.getId(xp, yp, this.objectBuffer);
|
||||
getPickingId(x: number, y: number): PickingId | undefined {
|
||||
const objectId = this.getObjectId(x, y);
|
||||
// console.log('objectId', objectId);
|
||||
if (objectId === -1 || objectId === NullId) return;
|
||||
|
||||
const instanceId = this.getId(xp, yp, this.instanceBuffer);
|
||||
const instanceId = this.getInstanceId(x, y);
|
||||
// console.log('instanceId', instanceId);
|
||||
if (instanceId === -1 || instanceId === NullId) return;
|
||||
|
||||
const groupId = this.getId(xp, yp, this.groupBuffer);
|
||||
const groupId = this.getGroupId(x, y);
|
||||
// console.log('groupId', groupId);
|
||||
if (groupId === -1 || groupId === NullId) return;
|
||||
|
||||
const z = this.getDepth(xp, yp);
|
||||
// console.log('z', z);
|
||||
const position = Vec3.create(x, y, z);
|
||||
if (StereoCamera.is(camera)) {
|
||||
const halfWidth = Math.floor(viewport.width / 2);
|
||||
if (x > viewport.x + halfWidth) {
|
||||
position[0] = viewport.x + (xv - halfWidth) * 2;
|
||||
cameraUnproject(position, position, viewport, camera.right.inverseProjectionView);
|
||||
} else {
|
||||
position[0] = viewport.x + xv * 2;
|
||||
cameraUnproject(position, position, viewport, camera.left.inverseProjectionView);
|
||||
}
|
||||
} else {
|
||||
cameraUnproject(position, position, viewport, camera.inverseProjectionView);
|
||||
}
|
||||
|
||||
// console.log({ id: { objectId, instanceId, groupId }, position });
|
||||
return { id: { objectId, instanceId, groupId }, position };
|
||||
return { objectId, instanceId, groupId };
|
||||
}
|
||||
|
||||
identify(x: number, y: number, camera: Camera | StereoCamera): PickData | undefined {
|
||||
for (const d of this.spiral) {
|
||||
const pickData = this.identifyInternal(x + d[0], y + d[1], camera);
|
||||
if (pickData) return pickData;
|
||||
reset() {
|
||||
this.fenceSync = null;
|
||||
this.ready = false;
|
||||
this.lag = 0;
|
||||
this.fenceTimestamp = 0;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
const { gl } = this.webgl;
|
||||
if (!isWebGL2(gl)) return;
|
||||
|
||||
this.objectBuffer.destroy();
|
||||
this.instanceBuffer.destroy();
|
||||
this.groupBuffer.destroy();
|
||||
this.depthBuffer.destroy();
|
||||
|
||||
if (this.fenceSync !== null) {
|
||||
gl.deleteSync(this.fenceSync);
|
||||
this.fenceSync = null;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private webgl: WebGLContext, private renderer: Renderer, private scene: Scene, private helper: Helper, private pickPass: PickPass, viewport: Viewport, private pickPadding = 1) {
|
||||
this.setViewport(viewport.x, viewport.y, viewport.width, viewport.height);
|
||||
constructor(private webgl: WebGLContext, private pickPass: PickPass, public maxAsyncReadLag = 5) {
|
||||
if (webgl.isWebGL2) {
|
||||
this.objectBuffer = webgl.resources.pixelPack('rgba', 'ubyte');
|
||||
this.instanceBuffer = webgl.resources.pixelPack('rgba', 'ubyte');
|
||||
this.groupBuffer = webgl.resources.pixelPack('rgba', 'ubyte');
|
||||
this.depthBuffer = webgl.resources.pixelPack('rgba', 'ubyte');
|
||||
}
|
||||
|
||||
this.setup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,59 +120,60 @@ function getPostprocessingRenderable(ctx: WebGLContext, colorTexture: Texture, t
|
||||
}
|
||||
|
||||
export const PostprocessingParams = {
|
||||
enabled: PD.Boolean(true),
|
||||
occlusion: PD.MappedStatic('on', {
|
||||
on: PD.Group(SsaoParams),
|
||||
off: PD.Group({})
|
||||
}, { cycle: true, description: 'Darken occluded crevices with the ambient occlusion effect' }),
|
||||
}, { cycle: true, description: 'Darken occluded crevices with the ambient occlusion effect', hideIf: p => p.enabled === false }),
|
||||
shadow: PD.MappedStatic('off', {
|
||||
on: PD.Group(ShadowParams),
|
||||
off: PD.Group({})
|
||||
}, { cycle: true, description: 'Simplistic shadows' }),
|
||||
}, { cycle: true, description: 'Simplistic shadows', hideIf: p => p.enabled === false }),
|
||||
outline: PD.MappedStatic('off', {
|
||||
on: PD.Group(OutlineParams),
|
||||
off: PD.Group({})
|
||||
}, { cycle: true, description: 'Draw outline around 3D objects' }),
|
||||
}, { cycle: true, description: 'Draw outline around 3D objects', hideIf: p => p.enabled === false }),
|
||||
dof: PD.MappedStatic('off', {
|
||||
on: PD.Group(DofParams),
|
||||
off: PD.Group({})
|
||||
}, { cycle: true, description: 'DOF' }),
|
||||
}, { cycle: true, description: 'DOF', hideIf: p => p.enabled === false }),
|
||||
antialiasing: PD.MappedStatic('smaa', {
|
||||
fxaa: PD.Group(FxaaParams),
|
||||
smaa: PD.Group(SmaaParams),
|
||||
off: PD.Group({})
|
||||
}, { options: [['fxaa', 'FXAA'], ['smaa', 'SMAA'], ['off', 'Off']], description: 'Smooth pixel edges' }),
|
||||
}, { options: [['fxaa', 'FXAA'], ['smaa', 'SMAA'], ['off', 'Off']], description: 'Smooth pixel edges', hideIf: p => p.enabled === false }),
|
||||
sharpening: PD.MappedStatic('off', {
|
||||
on: PD.Group(CasParams),
|
||||
off: PD.Group({})
|
||||
}, { cycle: true, description: 'Contrast Adaptive Sharpening' }),
|
||||
background: PD.Group(BackgroundParams, { isFlat: true }),
|
||||
}, { cycle: true, description: 'Contrast Adaptive Sharpening', hideIf: p => p.enabled === false }),
|
||||
background: PD.Group(BackgroundParams, { isFlat: true, hideIf: p => p.enabled === false }),
|
||||
bloom: PD.MappedStatic('on', {
|
||||
on: PD.Group(BloomParams),
|
||||
off: PD.Group({})
|
||||
}, { cycle: true, description: 'Bloom' }),
|
||||
}, { cycle: true, description: 'Bloom', hideIf: p => p.enabled === false }),
|
||||
};
|
||||
|
||||
export type PostprocessingProps = PD.Values<typeof PostprocessingParams>
|
||||
|
||||
export class PostprocessingPass {
|
||||
static isEnabled(props: PostprocessingProps) {
|
||||
return SsaoPass.isEnabled(props) || ShadowPass.isEnabled(props) || OutlinePass.isEnabled(props) || props.background.variant.name !== 'off';
|
||||
return props.enabled && (SsaoPass.isEnabled(props) || ShadowPass.isEnabled(props) || OutlinePass.isEnabled(props) || props.background.variant.name !== 'off');
|
||||
}
|
||||
|
||||
static isTransparentDepthRequired(scene: Scene, props: PostprocessingProps) {
|
||||
return DofPass.isEnabled(props) || OutlinePass.isEnabled(props) && PostprocessingPass.isTransparentOutlineEnabled(props) || SsaoPass.isEnabled(props) && PostprocessingPass.isTransparentSsaoEnabled(scene, props);
|
||||
return props.enabled && (DofPass.isEnabled(props) || OutlinePass.isEnabled(props) && PostprocessingPass.isTransparentOutlineEnabled(props) || SsaoPass.isEnabled(props) && PostprocessingPass.isTransparentSsaoEnabled(scene, props));
|
||||
}
|
||||
|
||||
static isTransparentOutlineEnabled(props: PostprocessingProps) {
|
||||
return OutlinePass.isEnabled(props) && ((props.outline.params as OutlineProps).includeTransparent ?? true);
|
||||
return props.enabled && OutlinePass.isEnabled(props) && ((props.outline.params as OutlineProps).includeTransparent ?? true);
|
||||
}
|
||||
|
||||
static isTransparentSsaoEnabled(scene: Scene, props: PostprocessingProps) {
|
||||
return SsaoPass.isEnabled(props) && SsaoPass.isTransparentEnabled(scene, props.occlusion.params as SsaoProps);
|
||||
return props.enabled && SsaoPass.isEnabled(props) && SsaoPass.isTransparentEnabled(scene, props.occlusion.params as SsaoProps);
|
||||
}
|
||||
|
||||
static isSsaoEnabled(props: PostprocessingProps) {
|
||||
return SsaoPass.isEnabled(props);
|
||||
return props.enabled && SsaoPass.isEnabled(props);
|
||||
}
|
||||
|
||||
readonly target: RenderTarget;
|
||||
@@ -338,7 +339,7 @@ export class PostprocessingPass {
|
||||
}
|
||||
|
||||
if (toDrawingBuffer) {
|
||||
this.webgl.unbindFramebuffer();
|
||||
this.webgl.bindDrawingBuffer();
|
||||
} else {
|
||||
this.target.bind();
|
||||
}
|
||||
@@ -354,7 +355,7 @@ export class PostprocessingPass {
|
||||
|
||||
export class AntialiasingPass {
|
||||
static isEnabled(props: PostprocessingProps) {
|
||||
return props.antialiasing.name !== 'off';
|
||||
return props.enabled && (props.antialiasing.name !== 'off' || props.sharpening.name !== 'off');
|
||||
}
|
||||
|
||||
readonly target: RenderTarget;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2019-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2019-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
* @author Áron Samuel Kovács <aron.kovacs@mail.muni.cz>
|
||||
@@ -35,7 +35,7 @@ export type ShadowProps = PD.Values<typeof ShadowParams>
|
||||
|
||||
export class ShadowPass {
|
||||
static isEnabled(props: PostprocessingProps) {
|
||||
return props.shadow.name !== 'off';
|
||||
return props.enabled && props.shadow.name !== 'off';
|
||||
}
|
||||
|
||||
readonly target: RenderTarget;
|
||||
@@ -83,8 +83,8 @@ export class ShadowPass {
|
||||
needsUpdateShadows = true;
|
||||
}
|
||||
|
||||
ValueCell.updateIfChanged(this.renderable.values.uMaxDistance, props.maxDistance);
|
||||
ValueCell.updateIfChanged(this.renderable.values.uTolerance, props.tolerance);
|
||||
ValueCell.updateIfChanged(this.renderable.values.uMaxDistance, props.maxDistance * camera.state.scale);
|
||||
ValueCell.updateIfChanged(this.renderable.values.uTolerance, props.tolerance * camera.state.scale);
|
||||
if (this.renderable.values.dSteps.ref.value !== props.steps) {
|
||||
ValueCell.update(this.renderable.values.dSteps, props.steps);
|
||||
needsUpdateShadows = true;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2020-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
@@ -133,7 +133,7 @@ export class SmaaPass {
|
||||
if (target) {
|
||||
target.bind();
|
||||
} else {
|
||||
this.webgl.unbindFramebuffer();
|
||||
this.webgl.bindDrawingBuffer();
|
||||
}
|
||||
this.updateState(viewport);
|
||||
this.blendRenderable.render();
|
||||
|
||||
@@ -63,7 +63,7 @@ type Levels = {
|
||||
bias: number[]
|
||||
}
|
||||
|
||||
function getLevels(props: { radius: number, bias: number }[], levels?: Levels): Levels {
|
||||
function getLevels(props: { radius: number, bias: number }[], scale: number, levels?: Levels): Levels {
|
||||
const count = props.length;
|
||||
const { radius, bias } = levels || {
|
||||
radius: (new Array(count * 3)).fill(0),
|
||||
@@ -72,7 +72,7 @@ function getLevels(props: { radius: number, bias: number }[], levels?: Levels):
|
||||
props = props.slice().sort((a, b) => a.radius - b.radius);
|
||||
for (let i = 0; i < count; ++i) {
|
||||
const p = props[i];
|
||||
radius[i] = Math.pow(2, p.radius);
|
||||
radius[i] = Math.pow(2, p.radius) * scale;
|
||||
bias[i] = p.bias;
|
||||
}
|
||||
return { count, radius, bias };
|
||||
@@ -306,8 +306,8 @@ export class SsaoPass {
|
||||
ValueCell.update(this.blurFirstPassRenderable.values.uInvProjection, invProjection);
|
||||
ValueCell.update(this.blurSecondPassRenderable.values.uInvProjection, invProjection);
|
||||
|
||||
ValueCell.update(this.blurFirstPassRenderable.values.uBlurDepthBias, props.blurDepthBias);
|
||||
ValueCell.update(this.blurSecondPassRenderable.values.uBlurDepthBias, props.blurDepthBias);
|
||||
ValueCell.update(this.blurFirstPassRenderable.values.uBlurDepthBias, props.blurDepthBias * camera.state.scale);
|
||||
ValueCell.update(this.blurSecondPassRenderable.values.uBlurDepthBias, props.blurDepthBias * camera.state.scale);
|
||||
|
||||
if (this.blurFirstPassRenderable.values.dOrthographic.ref.value !== orthographic) {
|
||||
needsUpdateSsaoBlur = true;
|
||||
@@ -349,7 +349,7 @@ export class SsaoPass {
|
||||
needsUpdateSsao = true;
|
||||
|
||||
this.levels = mp.levels;
|
||||
const levels = getLevels(mp.levels);
|
||||
const levels = getLevels(mp.levels, camera.state.scale);
|
||||
ValueCell.updateIfChanged(this.renderable.values.dLevels, levels.count);
|
||||
|
||||
ValueCell.update(this.renderable.values.uLevelRadius, levels.radius);
|
||||
@@ -358,7 +358,7 @@ export class SsaoPass {
|
||||
ValueCell.updateIfChanged(this.renderable.values.uNearThreshold, mp.nearThreshold);
|
||||
ValueCell.updateIfChanged(this.renderable.values.uFarThreshold, mp.farThreshold);
|
||||
} else {
|
||||
ValueCell.updateIfChanged(this.renderable.values.uRadius, Math.pow(2, props.radius));
|
||||
ValueCell.updateIfChanged(this.renderable.values.uRadius, Math.pow(2, props.radius) * camera.state.scale);
|
||||
}
|
||||
ValueCell.updateIfChanged(this.renderable.values.uBias, props.bias);
|
||||
|
||||
|
||||
@@ -10,6 +10,6 @@ import { Table } from './db/table';
|
||||
import { Column } from './db/column';
|
||||
import * as ColumnHelpers from './db/column-helpers';
|
||||
|
||||
type DatabaseCollection<T extends Database.Schema> = { [name: string]: Database<T> }
|
||||
export type DatabaseCollection<T extends Database.Schema> = { [name: string]: Database<T> }
|
||||
|
||||
export { DatabaseCollection, Database, Table, Column, ColumnHelpers };
|
||||
export { Database, Table, Column, ColumnHelpers };
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2017 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2017-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
|
||||
import { Interval } from '../interval';
|
||||
@@ -79,4 +80,6 @@ describe('interval', () => {
|
||||
test('intersectionSize3', Interval.intersectionSize(Interval.ofRange(1, 2), Interval.ofRange(0, 5)), 2);
|
||||
test('intersectionSize4', Interval.intersectionSize(Interval.ofRange(0, 5), Interval.ofRange(3, 8)), 3);
|
||||
test('intersectionSize5', Interval.intersectionSize(Interval.ofRange(0, 5), Interval.ofRange(6, 8)), 0);
|
||||
|
||||
test('offset', Interval.offset(Interval.ofRange(0, 5), 2), Interval.ofRange(2, 7));
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2017-2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2017-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
|
||||
import { OrderedSet } from '../ordered-set';
|
||||
@@ -10,7 +11,7 @@ import { SortedArray } from '../sorted-array';
|
||||
|
||||
describe('ordered set', () => {
|
||||
function ordSetToArray(set: OrderedSet) {
|
||||
const ret = [];
|
||||
const ret: number[] = [];
|
||||
for (let i = 0, _i = OrderedSet.size(set); i < _i; i++) ret.push(OrderedSet.getAt(set, i));
|
||||
return ret;
|
||||
}
|
||||
@@ -35,6 +36,12 @@ describe('ordered set', () => {
|
||||
testEq('range', range1_4, [1, 2, 3, 4]);
|
||||
testEq('sorted array', arr136, [1, 3, 6]);
|
||||
|
||||
it('isEmpty', () => {
|
||||
expect(OrderedSet.isEmpty(empty)).toBe(true);
|
||||
expect(OrderedSet.isEmpty(singleton10)).toBe(false);
|
||||
expect(OrderedSet.isEmpty(range1_4)).toBe(false);
|
||||
});
|
||||
|
||||
it('equality', () => {
|
||||
expect(OrderedSet.areEqual(empty, singleton10)).toBe(false);
|
||||
expect(OrderedSet.areEqual(singleton10, singleton10)).toBe(true);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2017-2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
* Copyright (c) 2017-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
|
||||
*
|
||||
* @author David Sehnal <david.sehnal@gmail.com>
|
||||
* @author Alexander Rose <alexander.rose@weirdbyte.de>
|
||||
*/
|
||||
|
||||
import { IntTuple as Tuple } from '../tuple';
|
||||
@@ -65,4 +66,8 @@ export function intersect(a: Tuple, b: Tuple) {
|
||||
|
||||
export function intersectionSize(a: Tuple, b: Tuple) {
|
||||
return size(findRange(a, min(b), max(b)));
|
||||
}
|
||||
|
||||
export function offset(int: Tuple, offset: number) {
|
||||
return Tuple.create(start(int) + offset, end(int) + offset);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user