Explorar el Código

Volumes & Segmentations extension (#668)

* Created CellStar state action

* CellStar: download metadata

* Right panel - CellStar UI

* CellStar: Lattice segmenatation and fitted PDB models

* CellStar: Support for source-database entry ID from metadata

* CellStar: Mesh segmentation

* CellStar: Switching between multiple entries

* CellStar: Changed default API URL

* UI updates

* CellStar: Clicking on meshes shows annotations...

* support info in Converted param

* support color/size in VolumeRepresentation3DHelpers.getDefaultParams

* support multi-visual volume representations

- repr can provide list of keys
- create visual for each key

* add volume segmentation support

- segmentation property
- segment loci & location

* add volume-segment color theme

* add volume segment representation

* use optional chaining

* add support for volume segmentation cif

* improve isosurface bounding-sphere

* fix segcif schema type

* CellStar: Highlighting segments on hover in the right panel

* CellStar: Using new Mol*-native segment visualization

* lint

* Segmentation volume can have custom segment labels

* CellStar: Segment labels for Mol*-native segments

* CellStar: Removed old implementation of segments

* CellStar: Rename CellStarLatticeSegmentationData2 -> CellStarLatticeSegmentationData

* CellStar: Default volume server is https://cellstar.ncbr.muni.cz

* CellStar: debugging

* CellStar: Fixed bug in LatticeSegmentation (scaling)

* CellStar: Partially savable state

* CellStar: WaitingSlider

* CellStar: Opacity changed via params

* Savable state for opacity

* CellStar: Changing UI in animations

* CellStar: Savable state for whole current UI

* CellStar: Savable state for segment labels

* CellStar: Source can be 'idr', CellStarVolumeServerConfig.DefaultServer

* CellStar: Select segment for lattice segmentation

* CellStar: Select segment, complete

* CellStar: Changes visible labels to "Volume & Segmentation"

* CellStar: Drop list with available entries

* CellStar: Volume type switching (partial)

* CellStar: Trying to set direct-volume control points

* CellStar: Volume visual switching

* Mesh extension: removed molstar-lib-imports

* CellStar: Global options

* CellStar: Updated file headers and `CHANGELOG.md`

* CellStar: UI controls disabled while executing change

* CellStar: Hidden state nodes, fixed bug with removed global state

* CellStar: Volume opacity slider

* CellStar extension renamed to Volseg

* UI tweaks

Co-authored-by: Alexander Rose <alexander.rose@weirdbyte.de>
midlik hace 2 años
padre
commit
c7cee63c97
Se han modificado 53 ficheros con 4639 adiciones y 108 borrados
  1. 1 0
      CHANGELOG.md
  2. 2 0
      src/apps/viewer/app.ts
  3. 1 1
      src/cli/structure-info/volume.ts
  4. 35 0
      src/extensions/meshes/choice.ts
  5. 225 0
      src/extensions/meshes/examples.ts
  6. 40 0
      src/extensions/meshes/mesh-cif-schema.ts
  7. 227 0
      src/extensions/meshes/mesh-extension.ts
  8. 335 0
      src/extensions/meshes/mesh-streaming/behavior.ts
  9. 28 0
      src/extensions/meshes/mesh-streaming/server-info.ts
  10. 218 0
      src/extensions/meshes/mesh-streaming/transformers.ts
  11. 302 0
      src/extensions/meshes/mesh-utils.ts
  12. 135 0
      src/extensions/meshes/metadata.ts
  13. 103 0
      src/extensions/volumes-and-segmentations/entry-meshes.ts
  14. 60 0
      src/extensions/volumes-and-segmentations/entry-models.ts
  15. 356 0
      src/extensions/volumes-and-segmentations/entry-root.ts
  16. 131 0
      src/extensions/volumes-and-segmentations/entry-segmentation.ts
  17. 33 0
      src/extensions/volumes-and-segmentations/entry-state.ts
  18. 181 0
      src/extensions/volumes-and-segmentations/entry-volume.ts
  19. 51 0
      src/extensions/volumes-and-segmentations/external-api.ts
  20. 65 0
      src/extensions/volumes-and-segmentations/global-state.ts
  21. 163 0
      src/extensions/volumes-and-segmentations/helpers.ts
  22. 102 0
      src/extensions/volumes-and-segmentations/index.ts
  23. 274 0
      src/extensions/volumes-and-segmentations/lattice-segmentation.ts
  24. 70 0
      src/extensions/volumes-and-segmentations/transformers.ts
  25. 254 0
      src/extensions/volumes-and-segmentations/ui.tsx
  26. 65 0
      src/extensions/volumes-and-segmentations/volseg-api/api.ts
  27. 83 0
      src/extensions/volumes-and-segmentations/volseg-api/data.ts
  28. 68 0
      src/extensions/volumes-and-segmentations/volseg-api/utils.ts
  29. 3 1
      src/mol-io/reader/cif.ts
  30. 26 0
      src/mol-io/reader/cif/schema/segmentation.ts
  31. 143 0
      src/mol-model-formats/volume/segmentation.ts
  32. 2 1
      src/mol-model/location.ts
  33. 11 2
      src/mol-model/loci.ts
  34. 83 4
      src/mol-model/volume/volume.ts
  35. 10 4
      src/mol-plugin-state/formats/provider.ts
  36. 4 4
      src/mol-plugin-state/formats/registry.ts
  37. 52 0
      src/mol-plugin-state/formats/volume.ts
  38. 5 7
      src/mol-plugin-state/transforms/representation.ts
  39. 41 1
      src/mol-plugin-state/transforms/volume.ts
  40. 9 0
      src/mol-plugin-ui/skin/base/components/viewport.scss
  41. 3 3
      src/mol-plugin-ui/structure/volume.tsx
  42. 5 5
      src/mol-repr/volume/direct-volume.ts
  43. 17 16
      src/mol-repr/volume/isosurface.ts
  44. 3 1
      src/mol-repr/volume/registry.ts
  45. 112 43
      src/mol-repr/volume/representation.ts
  46. 339 0
      src/mol-repr/volume/segment.ts
  47. 5 5
      src/mol-repr/volume/slice.ts
  48. 74 7
      src/mol-repr/volume/util.ts
  49. 2 0
      src/mol-theme/color.ts
  50. 68 0
      src/mol-theme/color/volume-segment.ts
  51. 2 1
      src/mol-theme/color/volume-value.ts
  52. 11 1
      src/mol-theme/label.ts
  53. 1 1
      src/mol-util/param-definition.ts

+ 1 - 0
CHANGELOG.md

@@ -8,6 +8,7 @@ Note that since we don't clearly distinguish between a public and private interf
 
 - Show histogram in direct volume control point settings
 - Add `solidInterior` parameter to sphere/cylinder impostors
+- Add `meshes` and `volseg` extensions
 
 ## [v3.27.0] - 2022-12-15
 

+ 2 - 0
src/apps/viewer/app.ts

@@ -9,6 +9,7 @@ import { ANVILMembraneOrientation } from '../../extensions/anvil/behavior';
 import { CellPack } from '../../extensions/cellpack';
 import { DnatcoConfalPyramids } from '../../extensions/dnatco';
 import { G3DFormat, G3dProvider } from '../../extensions/g3d/format';
+import { Volseg } from '../../extensions/volumes-and-segmentations';
 import { GeometryExport } from '../../extensions/geo-export';
 import { MAQualityAssessment } from '../../extensions/model-archive/quality-assessment/behavior';
 import { QualityAssessmentPLDDTPreset, QualityAssessmentQmeanPreset } from '../../extensions/model-archive/quality-assessment/behavior';
@@ -56,6 +57,7 @@ const CustomFormats = [
 ];
 
 const Extensions = {
+    'volseg': PluginSpec.Behavior(Volseg),
     'backgrounds': PluginSpec.Behavior(Backgrounds),
     'cellpack': PluginSpec.Behavior(CellPack),
     'dnatco-confal-pyramids': PluginSpec.Behavior(DnatcoConfalPyramids),

+ 1 - 1
src/cli/structure-info/volume.ts

@@ -38,7 +38,7 @@ function print(volume: Volume) {
 }
 
 async function doMesh(volume: Volume, filename: string) {
-    const mesh = await Task.create('', runtime => createVolumeIsosurfaceMesh({ runtime }, volume, Theme.createEmpty(), { isoValue: Volume.IsoValue.absolute(1.5) })).run();
+    const mesh = await Task.create('', runtime => createVolumeIsosurfaceMesh({ runtime }, volume, -1, Theme.createEmpty(), { isoValue: Volume.IsoValue.absolute(1.5) })).run();
     console.log({ vc: mesh.vertexCount, tc: mesh.triangleCount });
 
     // Export the mesh in OBJ format.

+ 35 - 0
src/extensions/meshes/choice.ts

@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+
+
+/**
+ * Represents a set of values to choose from, with a default value. Example:
+ * ```
+ * export const MyChoice = new Choice({ yes: 'I agree', no: 'Nope' }, 'yes');
+ * export type MyChoiceType = Choice.Values<typeof MyChoice>; // 'yes'|'no'
+ * ```
+ */
+export class Choice<T extends string, D extends T> {
+    readonly defaultValue: D;
+    readonly options: [T, string][];
+    private readonly nameDict: { [value in T]: string };
+    constructor(opts: { [value in T]: string }, defaultValue: D) {
+        this.defaultValue = defaultValue;
+        this.options = Object.keys(opts).map(k => [k as T, opts[k as T]]);
+        this.nameDict = opts;
+    }
+    PDSelect(defaultValue?: T, info?: PD.Info): PD.Select<T> {
+        return PD.Select<T>(defaultValue ?? this.defaultValue, this.options, info);
+    }
+    prettyName(value: T): string {
+        return this.nameDict[value];
+    }
+}
+export namespace Choice {
+    export type Values<T extends Choice<any, any>> = T extends Choice<infer R, any> ? R : any;
+}

+ 225 - 0
src/extensions/meshes/examples.ts

@@ -0,0 +1,225 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+/** Testing examples for using mesh-extension.ts. */
+
+import { ParseMeshlistTransformer, MeshShapeTransformer, MeshlistData } from './mesh-extension';
+import * as MeshUtils from './mesh-utils';
+import { BACKGROUND_OPACITY, FOREROUND_OPACITY, InitMeshStreaming } from './mesh-streaming/transformers';
+import { MeshServerInfo } from './mesh-streaming/server-info';
+import { PluginUIContext } from '../../mol-plugin-ui/context';
+import { PluginContext } from '../../mol-plugin/context';
+import { StateObjectRef, StateObjectSelector } from '../../mol-state';
+import { Color } from '../../mol-util/color';
+import { Download } from '../../mol-plugin-state/transforms/data';
+import { StateTransforms } from '../../mol-plugin-state/transforms';
+import { Box3D } from '../../mol-math/geometry';
+import { ShapeRepresentation3D } from '../../mol-plugin-state/transforms/representation';
+import { ParamDefinition } from '../../mol-util/param-definition';
+import { PluginStateObject } from '../../mol-plugin-state/objects';
+import { createStructureRepresentationParams } from '../../mol-plugin-state/helpers/structure-representation-params';
+import { Volume } from '../../mol-model/volume';
+import { createVolumeRepresentationParams } from '../../mol-plugin-state/helpers/volume-representation-params';
+import { Asset } from '../../mol-util/assets';
+import { CIF } from '../../mol-io/reader/cif';
+
+
+export const DB_URL = '/db'; // local
+
+
+export async function runMeshExtensionExamples(plugin: PluginUIContext, db_url: string = DB_URL) {
+    console.time('TIME MESH EXAMPLES');
+    // await runIsosurfaceExample(plugin, db_url);
+    // await runMolsurfaceExample(plugin);
+
+    // Focused Ion Beam-Scanning Electron Microscopy of mitochondrial reticulum in murine skeletal muscle: https://www.ebi.ac.uk/empiar/EMPIAR-10070/
+    // await runMeshExample(plugin, 'all', db_url);
+    // await runMeshExample(plugin, 'fg', db_url);
+    // await runMultimeshExample(plugin, 'fg', 'worst', db_url);
+    // await runCifMeshExample(plugin);
+    // await runMeshExample2(plugin, 'fg');
+    await runMeshStreamingExample(plugin);
+
+    console.timeEnd('TIME MESH EXAMPLES');
+}
+
+/** Example for downloading multiple separate segments, each containing 1 mesh. */
+export async function runMeshExample(plugin: PluginUIContext, segments: 'fg' | 'all', db_url: string = DB_URL) {
+    const detail = 2;
+    const segmentIds = (segments === 'all') ?
+        [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17] // segment-16 has no detail-2
+        : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 17]; // segment-13 and segment-15 are quasi background
+
+    for (const segmentId of segmentIds) {
+        await createMeshFromUrl(plugin, `${db_url}/empiar-10070-mesh-rounded/segment-${segmentId}/detail-${detail}`, segmentId, detail, true, undefined);
+    }
+}
+
+/** Example for downloading multiple separate segments, each containing 1 mesh. */
+export async function runMeshExample2(plugin: PluginUIContext, segments: 'one' | 'few' | 'fg' | 'all') {
+    const detail = 1;
+    const segmentIds = (segments === 'one') ? [15]
+        : (segments === 'few') ? [1, 4, 7, 10, 16]
+            : (segments === 'all') ? [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17] // segment-16 has no detail-2
+                : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 17]; // segment-13 and segment-15 are quasi background
+
+    for (const segmentId of segmentIds) {
+        await createMeshFromUrl(plugin, `http://localhost:9000/v2/empiar/empiar-10070/mesh_bcif/${segmentId}/${detail}`, segmentId, detail, false, undefined);
+    }
+}
+
+/** Example for downloading a single segment containing multiple meshes. */
+export async function runMultimeshExample(plugin: PluginUIContext, segments: 'fg' | 'all', detailChoice: 'best' | 'worst', db_url: string = DB_URL) {
+    const urlDetail = (detailChoice === 'best') ? '2' : 'worst';
+    const numDetail = (detailChoice === 'best') ? 2 : 1000;
+    await createMeshFromUrl(plugin, `${db_url}/empiar-10070-multimesh-rounded/segments-${segments}/detail-${urlDetail}`, 0, numDetail, false, undefined);
+}
+
+/** Download data and create state tree hierarchy down to visual representation. */
+export async function createMeshFromUrl(plugin: PluginContext, meshDataUrl: string, segmentId: number, detail: number,
+    collapseTree: boolean, color?: Color, parent?: StateObjectSelector | StateObjectRef, transparentIfBboxAbove?: number,
+    name?: string, ownerId?: string) {
+
+    const update = parent ? plugin.build().to(parent) : plugin.build().toRoot();
+    const rawDataNodeRef = update.apply(Download,
+        { url: meshDataUrl, isBinary: true, label: `Downloaded Data ${segmentId}` },
+        { state: { isCollapsed: collapseTree } }
+    ).ref;
+    const parsedDataNode = await update.to(rawDataNodeRef)
+        .apply(StateTransforms.Data.ParseCif)
+        .apply(ParseMeshlistTransformer,
+            { label: undefined, segmentId: segmentId, segmentName: name ?? `Segment ${segmentId}`, detail: detail, ownerId: ownerId },
+            {}
+        )
+        .commit();
+
+    let transparent = false;
+    if (transparentIfBboxAbove !== undefined && parsedDataNode.data) {
+        const bbox = MeshlistData.bbox(parsedDataNode.data) || Box3D.zero();
+        transparent = Box3D.volume(bbox) > transparentIfBboxAbove;
+    }
+
+    await plugin.build().to(parsedDataNode)
+        .apply(MeshShapeTransformer, { color: color },)
+        .apply(ShapeRepresentation3D,
+            { alpha: transparent ? BACKGROUND_OPACITY : FOREROUND_OPACITY },
+            { tags: ['mesh-segment-visual', `segment-${segmentId}`] }
+        )
+        .commit();
+
+    return rawDataNodeRef;
+}
+
+export async function runMeshStreamingExample(plugin: PluginUIContext, source: MeshServerInfo.MeshSource = 'empiar', entryId: string = 'empiar-10070', serverUrl?: string, parent?: StateObjectSelector) {
+    const params = ParamDefinition.getDefaultValues(MeshServerInfo.Params);
+    if (serverUrl) params.serverUrl = serverUrl;
+    params.source = source;
+    params.entryId = entryId;
+    await plugin.runTask(plugin.state.data.applyAction(InitMeshStreaming, params, parent?.ref), { useOverlay: false });
+}
+
+/** Example for downloading a protein structure and visualizing molecular surface. */
+export async function runMolsurfaceExample(plugin: PluginUIContext) {
+    const entryId = 'pdb-7etq';
+
+    // Node "https://www.ebi.ac.uk/pdbe/entry-files/download/7etq.bcif" ("transformer": "ms-plugin.download") -> var data
+    const data = await plugin.builders.data.download({ url: 'https://www.ebi.ac.uk/pdbe/entry-files/download/7etq.bcif', isBinary: true }, { state: { isGhost: false } });
+    console.log('formats:', plugin.dataFormats.list);
+
+    // Node "CIF File" ("transformer": "ms-plugin.parse-cif")
+    // Node "7ETQ 1 model" ("transformer": "ms-plugin.trajectory-from-mmcif") -> var trajectory
+    const parsed = await plugin.dataFormats.get('mmcif')!.parse(plugin, data, { entryId });
+    const trajectory: StateObjectSelector<PluginStateObject.Molecule.Trajectory> = parsed.trajectory;
+    console.log('parsed', parsed);
+    console.log('trajectory', trajectory);
+
+    // Node "Model 1" ("transformer": "ms-plugin.model-from-trajectory") -> var model
+    const model = await plugin.build().to(trajectory).apply(StateTransforms.Model.ModelFromTrajectory).commit();
+    console.log('model:', model);
+
+    // Node "Model 91 elements" ("transformer": "ms-plugin.structure-from-model") -> var structure
+    const structure = await plugin.build().to(model).apply(StateTransforms.Model.StructureFromModel,).commit();
+    console.log('structure:', structure);
+
+    // Node "Molecular Surface" ("transformer": "ms-plugin.structure-representation-3d") -> var repr
+    const reprParams = createStructureRepresentationParams(plugin, undefined, { type: 'molecular-surface' });
+    const repr = await plugin.build().to(structure).apply(StateTransforms.Representation.StructureRepresentation3D, reprParams).commit();
+    console.log('repr:', repr);
+}
+
+/** Example for downloading an EMDB density data and visualizing isosurface. */
+export async function runIsosurfaceExample(plugin: PluginUIContext, db_url: string = DB_URL) {
+    const entryId = 'emd-1832';
+    const isoLevel = 2.73;
+
+    let root = await plugin.build();
+    const data = await plugin.builders.data.download({ url: `${db_url}/emd-1832-box`, isBinary: true }, { state: { isGhost: false } });
+    const parsed = await plugin.dataFormats.get('dscif')!.parse(plugin, data, { entryId });
+
+    const volume: StateObjectSelector<PluginStateObject.Volume.Data> = parsed.volumes?.[0] ?? parsed.volume;
+    const volumeData = volume.cell!.obj!.data;
+    console.log('data:', data);
+    console.log('parsed:', parsed);
+    console.log('volume:', volume);
+    console.log('volumeData:', volumeData);
+
+    root = await plugin.build();
+    console.log('root:', root);
+    console.log('to:', root.to(volume));
+    console.log('toRoot:', root.toRoot());
+
+    let volumeParams;
+    volumeParams = createVolumeRepresentationParams(plugin, volumeData, {
+        type: 'isosurface',
+        typeParams: {
+            alpha: 0.5,
+            isoValue: Volume.adjustedIsoValue(volumeData, isoLevel, 'relative'),
+            visuals: ['solid'],
+            sizeFactor: 1,
+        },
+        color: 'uniform',
+        colorParams: { value: Color(0x00aaaa) },
+
+    });
+    root.to(volume).apply(StateTransforms.Representation.VolumeRepresentation3D, volumeParams);
+
+    volumeParams = createVolumeRepresentationParams(plugin, volumeData, {
+        type: 'isosurface',
+        typeParams: {
+            alpha: 1.0,
+            isoValue: Volume.adjustedIsoValue(volumeData, isoLevel, 'relative'),
+            visuals: ['wireframe'],
+            sizeFactor: 1,
+        },
+        color: 'uniform',
+        colorParams: { value: Color(0x8800aa) },
+
+    });
+    root.to(volume).apply(StateTransforms.Representation.VolumeRepresentation3D, volumeParams);
+    await root.commit();
+}
+
+
+export async function runCifMeshExample(plugin: PluginUIContext, api: string = 'http://localhost:9000/v2',
+    source: MeshServerInfo.MeshSource = 'empiar', entryId: string = 'empiar-10070',
+    segmentId: number = 1, detail: number = 10,
+) {
+    const url = `${api}/${source}/${entryId}/mesh_bcif/${segmentId}/${detail}`;
+    getMeshFromBcif(plugin, url);
+}
+
+async function getMeshFromBcif(plugin: PluginUIContext, url: string) {
+    const urlAsset = Asset.getUrlAsset(plugin.managers.asset, url); // QUESTION how is urlAsset better than normal `fetch`
+    const asset = await plugin.runTask(plugin.managers.asset.resolve(urlAsset, 'binary'));
+    const parsed = await plugin.runTask(CIF.parseBinary(asset.data));
+    if (parsed.isError) {
+        plugin.log.error('VolumeStreaming, parsing CIF: ' + parsed.toString());
+        return;
+    }
+    console.log('blocks:', parsed.result.blocks);
+    const mesh = await MeshUtils.meshFromCif(parsed.result);
+    console.log(mesh);
+}

+ 40 - 0
src/extensions/meshes/mesh-cif-schema.ts

@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { Column, Database } from '../../mol-data/db';
+import { CifFrame } from '../../mol-io/reader/cif';
+import { toDatabase } from '../../mol-io/reader/cif/schema';
+
+
+const int = Column.Schema.int;
+const float = Column.Schema.float;
+
+
+// TODO in future, move to molstar/src/mol-io/reader/cif/schema/mesh.ts
+export const Mesh_Data_Schema = {
+    mesh: {
+        id: int,
+    },
+    mesh_vertex: {
+        mesh_id: int,
+        vertex_id: int,
+        x: float,
+        y: float,
+        z: float,
+    },
+    /** Table of triangles, 3 rows per triangle */
+    mesh_triangle: {
+        mesh_id: int,
+        /** Indices of vertices within mesh */
+        vertex_id: int,
+    }
+};
+export type Mesh_Data_Schema = typeof Mesh_Data_Schema;
+export interface Mesh_Data_Database extends Database<Mesh_Data_Schema> {}
+
+
+// TODO in future, move to molstar/src/mol-io/reader/cif.ts: CIF.schema.mesh
+export const CIF_schema_mesh = (frame: CifFrame) => toDatabase<Mesh_Data_Schema, Mesh_Data_Database>(Mesh_Data_Schema, frame);

+ 227 - 0
src/extensions/meshes/mesh-extension.ts

@@ -0,0 +1,227 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+/** Defines new types of State tree transformers for dealing with mesh data. */
+
+
+import { BaseGeometry, VisualQuality, VisualQualityOptions } from '../../mol-geo/geometry/base';
+import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
+import { CifFile } from '../../mol-io/reader/cif';
+import { Box3D } from '../../mol-math/geometry';
+import { Vec3 } from '../../mol-math/linear-algebra';
+import { Shape } from '../../mol-model/shape';
+import { ShapeProvider } from '../../mol-model/shape/provider';
+import { PluginStateObject } from '../../mol-plugin-state/objects';
+import { StateTransformer } from '../../mol-state';
+import { Task } from '../../mol-task';
+import { Color } from '../../mol-util/color';
+import { Material } from '../../mol-util/material';
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import * as MeshUtils from './mesh-utils';
+
+
+export const VolsegTransform: StateTransformer.Builder.Root = StateTransformer.builderFactory('volseg');
+
+
+// // // // // // // // // // // // // // // // // // // // // // // //
+// Parsed data
+
+/** Data type for `MeshlistStateObject` - list of meshes */
+export interface MeshlistData {
+    segmentId: number,
+    segmentName: string,
+    detail: number,
+    meshIds: number[],
+    mesh: Mesh,
+    /** Reference to the object which created this meshlist (e.g. `MeshStreaming.Behavior`) */
+    ownerId?: string,
+}
+
+export namespace MeshlistData {
+    export function empty(): MeshlistData {
+        return {
+            segmentId: 0,
+            segmentName: 'Empty',
+            detail: 0,
+            meshIds: [],
+            mesh: Mesh.createEmpty(),
+        };
+    };
+    export async function fromCIF(data: CifFile, segmentId: number, segmentName: string, detail: number): Promise<MeshlistData> {
+        const { mesh, meshIds } = await MeshUtils.meshFromCif(data);
+        return {
+            segmentId,
+            segmentName,
+            detail,
+            meshIds,
+            mesh,
+        };
+    }
+    export function stats(meshListData: MeshlistData): string {
+        return `Meshlist "${meshListData.segmentName}" (detail ${meshListData.detail}): ${meshListData.meshIds.length} meshes, ${meshListData.mesh.vertexCount} vertices, ${meshListData.mesh.triangleCount} triangles`;
+    }
+    export function getShape(data: MeshlistData, color: Color): Shape<Mesh> {
+        const mesh = data.mesh;
+        const meshShape: Shape<Mesh> = Shape.create(data.segmentName, data, mesh,
+            () => color,
+            () => 1,
+            // group => `${data.segmentName} | Segment ${data.segmentId} | Detail ${data.detail} | Mesh ${group}`,
+            group => data.segmentName,
+        );
+        return meshShape;
+    }
+
+    export function combineBBoxes(boxes: (Box3D | null)[]): Box3D | null {
+        let result = null;
+        for (const box of boxes) {
+            if (!box) continue;
+            if (result) {
+                Vec3.min(result.min, result.min, box.min);
+                Vec3.max(result.max, result.max, box.max);
+            } else {
+                result = Box3D.zero();
+                Box3D.copy(result, box);
+            }
+        }
+        return result;
+    }
+    export function bbox(data: MeshlistData): Box3D | null {
+        return MeshUtils.bbox(data.mesh);
+    }
+
+    export function allVerticesUsed(data: MeshlistData): boolean {
+        const unusedVertices = new Set();
+        for (let i = 0; i < data.mesh.vertexCount; i++) {
+            unusedVertices.add(i);
+        }
+        for (let i = 0; i < 3 * data.mesh.triangleCount; i++) {
+            const v = data.mesh.vertexBuffer.ref.value[i];
+            unusedVertices.delete(v);
+        }
+        return unusedVertices.size === 0;
+    }
+}
+
+
+
+// // // // // // // // // // // // // // // // // // // // // // // //
+// Raw Data -> Parsed data
+
+export class MeshlistStateObject extends PluginStateObject.Create<MeshlistData>({ name: 'Parsed Meshlist', typeClass: 'Object' }) { }
+// QUESTION: is typeClass just for color, or does do something?
+
+export const ParseMeshlistTransformer = VolsegTransform({
+    name: 'meshlist-from-string',
+    from: PluginStateObject.Format.Cif,
+    to: MeshlistStateObject,
+    params: {
+        label: PD.Text(MeshlistStateObject.type.name, { isHidden: true }), // QUESTION: Is this the right way to pass a value to apply() without exposing it in GUI?
+        segmentId: PD.Numeric(1, {}, { isHidden: true }),
+        segmentName: PD.Text('Segment'),
+        detail: PD.Numeric(1, {}, { isHidden: true }),
+        /** Reference to the object which manages this meshlist (e.g. `MeshStreaming.Behavior`) */
+        ownerId: PD.Text('', { isHidden: true }),
+    }
+})({
+    apply({ a, params }, globalCtx) { // `a` is the parent node, params are 2nd argument to To.apply(), `globalCtx` is the plugin
+        return Task.create('Create Parsed Meshlist', async ctx => {
+            const meshlistData = await MeshlistData.fromCIF(a.data, params.segmentId, params.segmentName, params.detail);
+            meshlistData.ownerId = params.ownerId;
+            const es = meshlistData.meshIds.length === 1 ? '' : 'es';
+            return new MeshlistStateObject(meshlistData, { label: params.label, description: `${meshlistData.segmentName} (${meshlistData.meshIds.length} mesh${es})` });
+        });
+    }
+});
+
+
+// // // // // // // // // // // // // // // // // // // // // // // //
+// Parsed data -> Shape
+
+/** Data type for PluginStateObject.Shape.Provider */
+type MeshShapeProvider = ShapeProvider<MeshlistData, Mesh, Mesh.Params>;
+namespace MeshShapeProvider {
+    export function fromMeshlistData(meshlist: MeshlistData, color?: Color): MeshShapeProvider {
+        const theColor = color ?? MeshUtils.ColorGenerator.next().value;
+        return {
+            label: 'Mesh',
+            data: meshlist,
+            params: meshParamDef, // TODO how to pass the real params correctly?
+            geometryUtils: Mesh.Utils,
+            getShape: (ctx, data: MeshlistData) => MeshlistData.getShape(data, theColor),
+        };
+    }
+}
+
+/** Params for MeshShapeTransformer */
+const meshShapeParamDef = {
+    color: PD.Value<Color | undefined>(undefined), // undefined means random color
+};
+
+const meshParamDef: Mesh.Params = {
+    // These are basically original MS.Mesh.Params:
+    // BaseGeometry.Params
+    alpha: PD.Numeric(1, { min: 0, max: 1, step: 0.01 }, { label: 'Opacity', isEssential: true, description: 'How opaque/transparent the representation is rendered.' }),
+    quality: PD.Select<VisualQuality>('auto', VisualQualityOptions, { isEssential: true, description: 'Visual/rendering quality of the representation.' }),
+    material: Material.getParam(),
+    clip: Mesh.Params.clip, // PD.Group(MS.Clip.Params),
+    instanceGranularity: PD.Boolean(false, { description: 'Use instance granularity for marker, transparency, clipping, overpaint, substance data to save memory.' }),
+    // Mesh.Params
+    doubleSided: PD.Boolean(false, BaseGeometry.CustomQualityParamInfo),
+    flipSided: PD.Boolean(false, BaseGeometry.ShadingCategory),
+    flatShaded: PD.Boolean(true, BaseGeometry.ShadingCategory), // CHANGED, default: false (set true to see the real mesh vertices and triangles)
+    ignoreLight: PD.Boolean(false, BaseGeometry.ShadingCategory),
+    xrayShaded: PD.Boolean(false, BaseGeometry.ShadingCategory), // this is like better opacity (angle-dependent), nice
+    transparentBackfaces: PD.Select('off', PD.arrayToOptions(['off', 'on', 'opaque']), BaseGeometry.ShadingCategory),
+    bumpFrequency: PD.Numeric(0, { min: 0, max: 10, step: 0.1 }, BaseGeometry.ShadingCategory),
+    bumpAmplitude: PD.Numeric(1, { min: 0, max: 5, step: 0.1 }, BaseGeometry.ShadingCategory),
+    // TODO when I change values here, it has effect, but not if I change them in GUI
+};
+
+export const MeshShapeTransformer = VolsegTransform({
+    name: 'shape-from-meshlist',
+    display: { name: 'Shape from Meshlist', description: 'Create Shape from Meshlist data' },
+    from: MeshlistStateObject,
+    to: PluginStateObject.Shape.Provider,
+    params: meshShapeParamDef
+})({
+    apply({ a, params }) {
+        // you can look for example at ShapeFromPly in mol-plugin-state/tansforms/model.ts as an example
+        const shapeProvider = MeshShapeProvider.fromMeshlistData(a.data, params.color);
+        return new PluginStateObject.Shape.Provider(shapeProvider, { label: PluginStateObject.Shape.Provider.type.name, description: a.description });
+    }
+});
+
+
+// // // // // // // // // // // // // // // // // // // // // // // //
+// Shape -> Repr
+
+// type MeshRepr = MS.PluginStateObject.Representation3DData<MS.ShapeRepresentation<MS.ShapeProvider<any,any,any>, MS.Mesh, MS.Mesh.Params>, any>;
+
+// export const CustomMeshReprTransformer = VolsegTransform({
+//     name: 'custom-repr',
+//     from: MS.PluginStateObject.Shape.Provider, // later we can change this
+//     to: MS.PluginStateObject.Shape.Representation3D,
+// })({
+//     apply({ a }, globalCtx) {
+//         const repr: MeshRepr = createRepr(a.data); // TODO implement createRepr
+//         // have a look at MS.StateTransforms.Representation.ShapeRepresentation3D if you want to try implementing yourself
+//         return new MS.PluginStateObject.Shape.Representation3D(repr)
+//     },
+// })
+
+// export async function createMeshRepr(plugin: MS.PluginContext, data: any) {
+//     await plugin.build()
+//         .toRoot()
+//         .apply(CreateMyShapeTransformer, { data })
+//         .apply(MS.StateTransforms.Representation.ShapeRepresentation3D) // this should work
+//         // or .apply(CustomMeshRepr)
+//         .commit();
+// }
+
+// export function createRepr(reprData: MS.ShapeProvider<any,any,any>): MeshRepr {
+//     throw new Error('NotImplemented');
+//     return {} as MeshRepr;
+// }

+ 335 - 0
src/extensions/meshes/mesh-streaming/behavior.ts

@@ -0,0 +1,335 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { distinctUntilChanged, map } from 'rxjs';
+
+import { CIF } from '../../../mol-io/reader/cif';
+import { Box3D } from '../../../mol-math/geometry';
+import { PluginStateObject } from '../../../mol-plugin-state/objects';
+import { PluginBehavior } from '../../../mol-plugin/behavior';
+import { PluginCommand } from '../../../mol-plugin/command';
+import { PluginCommands } from '../../../mol-plugin/commands';
+import { PluginContext } from '../../../mol-plugin/context';
+import { UUID } from '../../../mol-util';
+import { Asset } from '../../../mol-util/assets';
+import { Color } from '../../../mol-util/color';
+import { ColorNames } from '../../../mol-util/color/names';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+
+import { Choice } from '../choice';
+import { MeshlistData } from '../mesh-extension';
+import { Metadata } from '../metadata';
+import { MeshServerInfo } from './server-info';
+
+
+const DEFAULT_SEGMENT_NAME = 'Untitled segment';
+const DEFAULT_SEGMENT_COLOR = ColorNames.lightgray;
+export const NO_SEGMENT = -1;
+/** Maximum (worst) detail level available in GUI (TODO set actual maximum possible value) */
+const MAX_DETAIL = 10;
+const DEFAULT_DETAIL = 7; // TODO decide a reasonable default
+/** Segments whose bounding box volume is above this value (relative to the overall bounding box) are considered as background segments */
+export const BACKGROUND_SEGMENT_VOLUME_THRESHOLD = 0.5;
+// const DEBUG_IGNORED_SEGMENTS = new Set([13, 15]); // TODO remove
+const DEBUG_IGNORED_SEGMENTS = new Set(); // TODO remove
+
+
+export class MeshStreaming extends PluginStateObject.CreateBehavior<MeshStreaming.Behavior>({ name: 'Mesh Streaming' }) { }
+
+export namespace MeshStreaming {
+
+    export namespace Params {
+        export const ViewTypeChoice = new Choice({ off: 'Off', select: 'Select', all: 'All' }, 'select'); // TODO add camera target?
+        export type ViewType = Choice.Values<typeof ViewTypeChoice>;
+
+        export function create(options: MeshServerInfo.Data) {
+            return {
+                view: PD.MappedStatic('select', {
+                    'off': PD.Group({}),
+                    'select': PD.Group({
+                        baseDetail: PD.Numeric(DEFAULT_DETAIL, { min: 1, max: MAX_DETAIL, step: 1 }, { description: 'Detail level for the non-selected segments (lower number = better)' }),
+                        focusDetail: PD.Numeric(1, { min: 1, max: MAX_DETAIL, step: 1 }, { description: 'Detail level for the selected segment (lower number = better)' }),
+                        selectedSegment: PD.Numeric(NO_SEGMENT, {}, { isHidden: true }),
+                    }, { isFlat: true }),
+                    'all': PD.Group({
+                        detail: PD.Numeric(DEFAULT_DETAIL, { min: 1, max: MAX_DETAIL, step: 1 }, { description: 'Detail level for all segments (lower number = better)' })
+                    }, { isFlat: true }),
+                }, { description: '"Off" hides all segments. \n"Select" shows all segments in lower detail, clicked segment in better detail. "All" shows all segment in the same level.' }),
+            };
+        }
+
+        export type Definition = ReturnType<typeof create>
+        export type Values = PD.Values<Definition>
+
+        export function copyValues(params: Values): Values {
+            return {
+                view: {
+                    name: params.view.name,
+                    params: { ...params.view.params } as any,
+                }
+            };
+        }
+        export function valuesEqual(p: Values, q: Values): boolean {
+            if (p.view.name !== q.view.name) return false;
+            for (const key in p.view.params) {
+                if ((p.view.params as any)[key] !== (q.view.params as any)[key]) return false;
+            }
+            return true;
+        }
+        export function detailsEqual(p: Values, q: Values): boolean {
+            switch (p.view.name) {
+                case 'off':
+                    return q.view.name === 'off';
+                case 'select':
+                    return q.view.name === 'select' && p.view.params.baseDetail === q.view.params.baseDetail && p.view.params.focusDetail === q.view.params.focusDetail;
+                case 'all':
+                    return q.view.name === 'all' && p.view.params.detail === q.view.params.detail;
+                default:
+                    throw new Error('Not implemented');
+            }
+        }
+    }
+
+    export interface VisualInfo {
+        tag: string, // e.g. high-2, low-1 // ? remove if can be omitted
+        segmentId: number, // ? remove if unused
+        segmentName: string, // ? remove if unused
+        detailType: VisualInfo.DetailType, // ? remove if unused
+        detail: number, // ? remove if unused
+        color: Color, // move to MeshlistData?
+        visible: boolean,
+        data?: MeshlistData,
+    }
+    export namespace VisualInfo {
+        export type DetailType = 'low' | 'high';
+        export const DetailTypes: DetailType[] = ['low', 'high'];
+        export function tagFor(segmentId: number, detail: DetailType) {
+            return `${detail}-${segmentId}`;
+        }
+    }
+
+
+    export class Behavior extends PluginBehavior.WithSubscribers<Params.Values> {
+        private id: string;
+        private ref: string = '';
+        public parentData: MeshServerInfo.Data;
+        private metadata?: Metadata;
+        public visuals?: { [tag: string]: VisualInfo };
+        public backgroundSegments: { [segmentId: number]: boolean } = {};
+        private focusObservable = this.plugin.behaviors.interaction.click.pipe( // QUESTION is this OK way to get focused segment?
+            map(evt => evt.current.loci),
+            map(loci => (loci.kind === 'group-loci') ? loci.shape.sourceData as MeshlistData : null),
+            map(data => (data?.ownerId === this.id) ? data : null), // do not process shapes created by others
+            distinctUntilChanged((old, current) => old?.segmentId === current?.segmentId),
+        );
+        private focusSubscription?: PluginCommand.Subscription = undefined;
+        private backgroundSegmentsInitialized = false;
+
+        constructor(plugin: PluginContext, data: MeshServerInfo.Data, params: Params.Values) {
+            super(plugin, params);
+            this.id = UUID.create22();
+            this.parentData = data;
+        }
+
+        register(ref: string): void {
+            this.ref = ref;
+        }
+
+        unregister(): void {
+            if (this.focusSubscription) {
+                this.focusSubscription.unsubscribe();
+                this.focusSubscription = undefined;
+            }
+            // TODO empty cache here (if used)
+        }
+
+        selectSegment(segmentId: number) {
+            if (this.params.view.name === 'select') {
+                if (this.params.view.params.selectedSegment === segmentId) return;
+                const newParams = Params.copyValues(this.params);
+                if (newParams.view.name === 'select') {
+                    newParams.view.params.selectedSegment = segmentId;
+                }
+                const state = this.plugin.state.data;
+                const update = state.build().to(this.ref).update(newParams);
+                PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotUpdateCurrent: true } });
+            }
+        }
+
+        async update(params: Params.Values) {
+            const oldParams = this.params;
+            this.params = params;
+
+            if (!this.metadata) {
+                const response = await fetch(this.getMetadataUrl());
+                this.metadata = await response.json();
+            }
+
+            if (!this.visuals) {
+                this.initVisualInfos();
+            } else if (!Params.detailsEqual(this.params, oldParams)) {
+                this.updateVisualInfoDetails();
+            }
+
+            switch (params.view.name) {
+                case 'off':
+                    await this.disableVisuals();
+                    break;
+                case 'select':
+                    await this.enableVisuals(params.view.params.selectedSegment);
+                    break;
+                case 'all':
+                    await this.enableVisuals();
+                    break;
+                default:
+                    throw new Error('Not implemented');
+            }
+            if (params.view.name !== 'off' && !this.backgroundSegmentsInitialized) {
+                this.guessBackgroundSegments();
+                this.backgroundSegmentsInitialized = true;
+            }
+            if (params.view.name === 'select' && !this.focusSubscription) {
+                this.focusSubscription = this.subscribeObservable(this.focusObservable, data => { this.selectSegment(data?.segmentId ?? NO_SEGMENT); });
+            } else if (params.view.name !== 'select' && this.focusSubscription) {
+                this.focusSubscription.unsubscribe();
+                this.focusSubscription = undefined;
+            }
+            return true;
+        }
+
+        private getMetadataUrl() {
+            return `${this.parentData.serverUrl}/${this.parentData.source}/${this.parentData.entryId}/metadata`;
+        }
+
+        private getMeshUrl(segment: number, detail: number) {
+            return `${this.parentData.serverUrl}/${this.parentData.source}/${this.parentData.entryId}/mesh_bcif/${segment}/${detail}`;
+        }
+
+        private initVisualInfos() {
+            const namesAndColors = Metadata.namesAndColorsBySegment(this.metadata!);
+
+            const visuals: { [tag: string]: VisualInfo } = {};
+            for (const segid of Metadata.meshSegments(this.metadata!)) {
+                if (DEBUG_IGNORED_SEGMENTS.has(segid)) continue;
+                const name = namesAndColors[segid]?.name ?? DEFAULT_SEGMENT_NAME;
+                const color = namesAndColors[segid]?.color ?? DEFAULT_SEGMENT_COLOR;
+                for (const detailType of VisualInfo.DetailTypes) {
+                    const visual: VisualInfo = {
+                        tag: VisualInfo.tagFor(segid, detailType),
+                        segmentId: segid,
+                        segmentName: name,
+                        detailType: detailType,
+                        detail: -1, // to be set at the end
+                        color: color,
+                        visible: false,
+                        data: undefined,
+                    };
+                    visuals[visual.tag] = visual;
+                }
+            }
+            this.visuals = visuals;
+            this.updateVisualInfoDetails();
+        }
+        private updateVisualInfoDetails() {
+            let highDetail: number | undefined;
+            let lowDetail: number | undefined;
+            switch (this.params.view.name) {
+                case 'off':
+                    lowDetail = undefined;
+                    highDetail = undefined;
+                    break;
+                case 'select':
+                    lowDetail = this.params.view.params.baseDetail;
+                    highDetail = this.params.view.params.focusDetail;
+                    break;
+                case 'all':
+                    lowDetail = this.params.view.params.detail;
+                    highDetail = undefined;
+                    break;
+            }
+            for (const tag in this.visuals) {
+                const visual = this.visuals[tag];
+                const preferredDetail = (visual.detailType === 'high') ? highDetail : lowDetail;
+                if (preferredDetail !== undefined) {
+                    visual.detail = Metadata.getSufficientDetail(this.metadata!, visual.segmentId, preferredDetail);
+                }
+            }
+        }
+
+        private async enableVisuals(highDetailSegment?: number) {
+            for (const tag in this.visuals) {
+                const visual = this.visuals[tag];
+                const requiredDetailType = visual.segmentId === highDetailSegment ? 'high' : 'low';
+                if (visual.detailType === requiredDetailType) {
+                    visual.data = await this.getMeshData(visual);
+                    visual.visible = true;
+                } else {
+                    visual.visible = false;
+                }
+            }
+        }
+
+        private async disableVisuals() {
+            for (const tag in this.visuals) {
+                const visual = this.visuals[tag];
+                visual.visible = false;
+            }
+        }
+
+        /** Fetch data in current `visual.detail`, or return already fetched data (if available in the correct detail). */
+        private async getMeshData(visual: VisualInfo): Promise<MeshlistData> {
+            if (visual.data && visual.data.detail === visual.detail) {
+                // Do not recreate
+                return visual.data;
+            }
+            // TODO cache
+            const url = this.getMeshUrl(visual.segmentId, visual.detail);
+            const urlAsset = Asset.getUrlAsset(this.plugin.managers.asset, url);
+            const asset = await this.plugin.runTask(this.plugin.managers.asset.resolve(urlAsset, 'binary'));
+            const parsed = await this.plugin.runTask(CIF.parseBinary(asset.data));
+            if (parsed.isError) {
+                throw new Error(`Failed parsing CIF file from ${url}`);
+            }
+            const meshlistData = await MeshlistData.fromCIF(parsed.result, visual.segmentId, visual.segmentName, visual.detail);
+            meshlistData.ownerId = this.id;
+            // const bbox = MeshlistData.bbox(meshlistData);
+            // const bboxVolume = bbox ? MS.Box3D.volume(bbox) : 0.0;
+            // console.log(`BBox ${visual.segmentId}: ${Math.round(bboxVolume! / 1e6)} M`, bbox); // DEBUG
+            return meshlistData;
+        }
+
+        private async guessBackgroundSegments() {
+            const bboxes: { [segid: number]: Box3D } = {};
+            for (const tag in this.visuals) {
+                const visual = this.visuals[tag];
+                if (visual.detailType === 'low' && visual.data) {
+                    const bbox = MeshlistData.bbox(visual.data);
+                    if (bbox) {
+                        bboxes[visual.segmentId] = bbox;
+                    }
+                }
+            }
+            const totalBbox = MeshlistData.combineBBoxes(Object.values(bboxes));
+            const totalVolume = totalBbox ? Box3D.volume(totalBbox) : 0.0;
+            // console.log(`BBox total: ${Math.round(totalVolume! / 1e6)} M`, totalBbox); // DEBUG
+
+            const isBgSegment: { [segid: number]: boolean } = {};
+            for (const segid in bboxes) {
+                const bbox = bboxes[segid];
+                const bboxVolume = Box3D.volume(bbox);
+                isBgSegment[segid] = (bboxVolume > totalVolume * BACKGROUND_SEGMENT_VOLUME_THRESHOLD);
+                // console.log(`BBox ${segid}: ${Math.round(bboxVolume! / 1e6)} M, ${bboxVolume / totalVolume}`, bbox); // DEBUG
+            }
+            this.backgroundSegments = isBgSegment;
+        }
+
+        getDescription() {
+            return Params.ViewTypeChoice.prettyName(this.params.view.name);
+        }
+
+    }
+}
+

+ 28 - 0
src/extensions/meshes/mesh-streaming/server-info.ts

@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { PluginStateObject } from '../../../mol-plugin-state/objects';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+
+import { Choice } from '../choice';
+
+
+export const DEFAULT_MESH_SERVER = 'http://localhost:9000/v2';
+
+
+export class MeshServerInfo extends PluginStateObject.Create<MeshServerInfo.Data>({ name: 'Volume Server', typeClass: 'Object' }) { }
+
+export namespace MeshServerInfo {
+    export const MeshSourceChoice = new Choice({ empiar: 'EMPIAR', emdb: 'EMDB' }, 'empiar');
+    export type MeshSource = Choice.Values<typeof MeshSourceChoice>;
+
+    export const Params = {
+        serverUrl: PD.Text(DEFAULT_MESH_SERVER),
+        source: MeshSourceChoice.PDSelect(),
+        entryId: PD.Text(''),
+    };
+    export type Data = PD.Values<typeof Params>;
+}

+ 218 - 0
src/extensions/meshes/mesh-streaming/transformers.ts

@@ -0,0 +1,218 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
+import { PluginStateObject } from '../../../mol-plugin-state/objects';
+import { PluginContext } from '../../../mol-plugin/context';
+import { ShapeRepresentation } from '../../../mol-repr/shape/representation';
+import { StateAction, StateTransformer } from '../../../mol-state';
+import { Task } from '../../../mol-task';
+import { shallowEqualObjects } from '../../../mol-util';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+
+import { VolsegTransform, MeshlistData } from '../mesh-extension';
+import { MeshStreaming, NO_SEGMENT } from './behavior';
+import { MeshServerInfo } from './server-info';
+
+
+export const BACKGROUND_OPACITY = 0.2;
+export const FOREROUND_OPACITY = 1;
+
+
+// // // // // // // // // // // // // // // // // // // // // // // //
+
+export const MeshServerTransformer = VolsegTransform({
+    name: 'mesh-server-info',
+    from: PluginStateObject.Root,
+    to: MeshServerInfo,
+    params: MeshServerInfo.Params,
+})({
+    apply({ a, params }, plugin: PluginContext) { // `a` is the parent node, `params` are 2nd argument to To.apply()
+        params.serverUrl = params.serverUrl.replace(/\/*$/, ''); // trim trailing slash
+        const description: string = params.entryId;
+        return new MeshServerInfo({ ...params }, { label: 'Mesh Server', description: description });
+    }
+});
+
+// // // // // // // // // // // // // // // // // // // // // // // //
+
+export const MeshStreamingTransformer = VolsegTransform({
+    name: 'mesh-streaming-from-server-info',
+    display: { name: 'Mesh Streaming' },
+    from: MeshServerInfo,
+    to: MeshStreaming,
+    params: a => MeshStreaming.Params.create(a!.data),
+})({
+    canAutoUpdate() { return true; },
+    apply({ a, params }, plugin: PluginContext) {
+        return Task.create('Mesh Streaming', async ctx => {
+            const behavior = new MeshStreaming.Behavior(plugin, a.data, params);
+            await behavior.update(params);
+            return new MeshStreaming(behavior, { label: 'Mesh Streaming', description: behavior.getDescription() });
+        });
+    },
+    update({ a, b, oldParams, newParams }) {
+        return Task.create('Update Mesh Streaming', async ctx => {
+            if (a.data.source !== b.data.parentData.source || a.data.entryId !== b.data.parentData.entryId) {
+                return StateTransformer.UpdateResult.Recreate;
+            }
+            b.data.parentData = a.data;
+            await b.data.update(newParams);
+            b.description = b.data.getDescription();
+            return StateTransformer.UpdateResult.Updated;
+        });
+    }
+});
+
+// // // // // // // // // // // // // // // // // // // // // // // //
+
+interface MeshVisualGroupData {
+    opacity: number,
+}
+
+// export type MeshVisualGroupTransformer = typeof MeshVisualGroupTransformer;
+export const MeshVisualGroupTransformer = VolsegTransform({
+    name: 'mesh-visual-group-from-streaming',
+    display: { name: 'Mesh Visuals for a Segment' },
+    from: MeshStreaming,
+    to: PluginStateObject.Group,
+    params: {
+        /** Shown on the node in GUI */
+        label: PD.Text('', { isHidden: true }),
+        /** Shown on the node in GUI (gray letters) */
+        description: PD.Text(''),
+        segmentId: PD.Numeric(NO_SEGMENT, {}, { isHidden: true }),
+        opacity: PD.Numeric(-1, { min: 0, max: 1, step: 0.01 }),
+    }
+})({
+    apply({ a, params }, plugin) {
+        trySetAutoOpacity(params, a);
+        return new PluginStateObject.Group({ opacity: params.opacity }, params);
+    },
+    update({ a, b, oldParams, newParams }, plugin) {
+        if (shallowEqualObjects(oldParams, newParams)) {
+            return StateTransformer.UpdateResult.Unchanged;
+        }
+        newParams.label ||= oldParams.label; // Protect against resetting params to invalid defaults
+        if (newParams.segmentId === NO_SEGMENT) newParams.segmentId = oldParams.segmentId; // Protect against resetting params to invalid defaults
+        trySetAutoOpacity(newParams, a);
+        b.label = newParams.label;
+        b.description = newParams.description;
+        (b.data as MeshVisualGroupData).opacity = newParams.opacity;
+        return StateTransformer.UpdateResult.Updated;
+    },
+    canAutoUpdate({ oldParams, newParams }, plugin) {
+        return newParams.description === oldParams.description;
+    },
+});
+
+function trySetAutoOpacity(params: StateTransformer.Params<typeof MeshVisualGroupTransformer>, parent: MeshStreaming) {
+    if (params.opacity === -1) {
+        const isBgSegment = parent.data.backgroundSegments[params.segmentId];
+        if (isBgSegment !== undefined) {
+            params.opacity = isBgSegment ? BACKGROUND_OPACITY : FOREROUND_OPACITY;
+        }
+    }
+}
+
+
+// // // // // // // // // // // // // // // // // // // // // // // //
+
+export const MeshVisualTransformer = VolsegTransform({
+    name: 'mesh-visual-from-streaming',
+    display: { name: 'Mesh Visual from Streaming' },
+    from: MeshStreaming,
+    to: PluginStateObject.Shape.Representation3D,
+    params: {
+        /** Must be set to PluginStateObject reference to self */
+        ref: PD.Text('', { isHidden: true, isEssential: true }), // QUESTION what is isEssential
+        /** Identification of the mesh visual, e.g. 'low-2' */
+        tag: PD.Text('', { isHidden: true, isEssential: true }),
+        /** Opacity of the visual (not to be set directly, but controlled by the opacity of the parent Group, and by VisualInfo.visible) */
+        opacity: PD.Numeric(-1, { min: 0, max: 1, step: 0.01 }, { isHidden: true }),
+    }
+})({
+    apply({ a, params, spine }, plugin: PluginContext) {
+        return Task.create('Mesh Visual', async ctx => {
+            const visualInfo: MeshStreaming.VisualInfo = a.data.visuals![params.tag];
+            if (!visualInfo) throw new Error(`VisualInfo with tag '${params.tag}' is missing.`);
+            const groupData = spine.getAncestorOfType(PluginStateObject.Group)?.data as MeshVisualGroupData | undefined;
+            params.opacity = visualInfo.visible ? (groupData?.opacity ?? FOREROUND_OPACITY) : 0.0;
+            const props = PD.getDefaultValues(Mesh.Params);
+            props.flatShaded = true; // `flatShaded: true` is to see the real mesh vertices and triangles (default: false)
+            props.alpha = params.opacity;
+            const repr = ShapeRepresentation((ctx, meshlist: MeshlistData) => MeshlistData.getShape(meshlist, visualInfo.color), Mesh.Utils);
+            await repr.createOrUpdate(props, visualInfo.data ?? MeshlistData.empty()).runInContext(ctx);
+            return new PluginStateObject.Shape.Representation3D({ repr, sourceData: visualInfo.data }, { label: 'Mesh Visual', description: params.tag });
+        });
+    },
+    update({ a, b, oldParams, newParams, spine }, plugin: PluginContext) {
+        return Task.create('Update Mesh Visual', async ctx => {
+            newParams.ref ||= oldParams.ref; // Protect against resetting params to invalid defaults
+            newParams.tag ||= oldParams.tag; // Protect against resetting params to invalid defaults
+            const visualInfo: MeshStreaming.VisualInfo = a.data.visuals![newParams.tag];
+            if (!visualInfo) throw new Error(`VisualInfo with tag '${newParams.tag}' is missing.`);
+            const oldData = b.data.sourceData as MeshlistData | undefined;
+            if (visualInfo.data?.detail !== oldData?.detail) {
+                return StateTransformer.UpdateResult.Recreate;
+            }
+            const groupData = spine.getAncestorOfType(PluginStateObject.Group)?.data as MeshVisualGroupData | undefined;
+            const newOpacity = visualInfo.visible ? (groupData?.opacity ?? FOREROUND_OPACITY) : 0.0; // do not store to newParams directly, because oldParams and newParams might point to the same object!
+            if (newOpacity !== oldParams.opacity) {
+                newParams.opacity = newOpacity;
+                await b.data.repr.createOrUpdate({ alpha: newParams.opacity }).runInContext(ctx);
+                return StateTransformer.UpdateResult.Updated;
+            } else {
+                return StateTransformer.UpdateResult.Unchanged;
+            }
+        });
+    },
+    canAutoUpdate(params, globalCtx) {
+        return true;
+    },
+    dispose({ b, params }, plugin) {
+        b?.data.repr.destroy(); // QUESTION is this correct?
+    },
+});
+
+// // // // // // // // // // // // // // // // // // // // // // // //
+
+export const InitMeshStreaming = StateAction.build({
+    display: { name: 'Mesh Streaming' },
+    from: PluginStateObject.Root,
+    params: MeshServerInfo.Params,
+    isApplicable: (a, _, plugin: PluginContext) => true
+})(function (p, plugin: PluginContext) {
+    return Task.create('Mesh Streaming', async ctx => {
+        const { params } = p;
+        // p.ref
+        const serverNode = await plugin.build().to(p.ref).apply(MeshServerTransformer, params).commit();
+        // const serverNode = await plugin.build().toRoot().apply(MeshServerTransformer, params).commit();
+        const streamingNode = await plugin.build().to(serverNode).apply(MeshStreamingTransformer, {}).commit();
+        const visuals = streamingNode.data?.visuals ?? {};
+        const bgSegments = streamingNode.data?.backgroundSegments ?? {};
+
+        const segmentGroups: { [segid: number]: string } = {};
+        for (const tag in visuals) {
+            const segid = visuals[tag].segmentId;
+            if (!segmentGroups[segid]) {
+                let description = visuals[tag].segmentName;
+                if (bgSegments[segid]) description += ' (background)';
+                const group = await plugin.build().to(streamingNode).apply(MeshVisualGroupTransformer, { label: `Segment ${segid}`, description: description, segmentId: segid }, { state: { isCollapsed: true } }).commit();
+                segmentGroups[segid] = group.ref;
+            }
+        }
+        const visualsUpdate = plugin.build();
+        for (const tag in visuals) {
+            const ref = `${streamingNode.ref}-${tag}`;
+            const segid = visuals[tag].segmentId;
+            visualsUpdate.to(segmentGroups[segid]).apply(MeshVisualTransformer, { ref: ref, tag: tag }, { ref: ref }); // ref - hack to allow the node make itself invisible
+        }
+        await plugin.state.data.updateTree(visualsUpdate).runInContext(ctx); // QUESTION what is really the difference between this and `visualsUpdate.commit()`?
+    });
+});
+
+// TODO make available in GUI, in left panel or in right panel like Volume Streaming in src/mol-plugin-ui/structure/volume.tsx?

+ 302 - 0
src/extensions/meshes/mesh-utils.ts

@@ -0,0 +1,302 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+/** Helper functions for manipulation with mesh data. */
+
+import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
+import { CIF, CifFile } from '../../mol-io/reader/cif';
+import { Box3D } from '../../mol-math/geometry';
+import { Mat4, Vec3 } from '../../mol-math/linear-algebra';
+import { volumeFromDensityServerData } from '../../mol-model-formats/volume/density-server';
+import { Grid } from '../../mol-model/volume';
+import { ColorNames } from '../../mol-util/color/names';
+import { TypedArray } from '../../mol-util/type-helpers';
+
+import { CIF_schema_mesh } from './mesh-cif-schema';
+
+
+type MeshModificationParams = {
+    scale?: [number, number, number],
+    shift?: [number, number, number],
+    matrix?: Mat4,
+    group?: number,
+    invertSides?: boolean
+};
+
+/** Modify mesh in-place */
+export function modify(m: Mesh, params: MeshModificationParams) {
+    if (params.scale !== undefined) {
+        const [qx, qy, qz] = params.scale;
+        const vertices = m.vertexBuffer.ref.value;
+        for (let i = 0; i < vertices.length; i += 3) {
+            vertices[i] *= qx;
+            vertices[i + 1] *= qy;
+            vertices[i + 2] *= qz;
+        }
+    }
+    if (params.shift !== undefined) {
+        const [dx, dy, dz] = params.shift;
+        const vertices = m.vertexBuffer.ref.value;
+        for (let i = 0; i < vertices.length; i += 3) {
+            vertices[i] += dx;
+            vertices[i + 1] += dy;
+            vertices[i + 2] += dz;
+        }
+    }
+    if (params.matrix !== undefined) {
+        const r = m.vertexBuffer.ref.value;
+        const matrix = params.matrix;
+        const size = 3 * m.vertexCount;
+        for (let i = 0; i < size; i += 3) {
+            Vec3.transformMat4Offset(r, r, matrix, i, i, 0);
+        }
+    }
+    if (params.group !== undefined) {
+        const groups = m.groupBuffer.ref.value;
+        for (let i = 0; i < groups.length; i++) {
+            groups[i] = params.group;
+        }
+    }
+    if (params.invertSides) {
+        const indices = m.indexBuffer.ref.value;
+        let tmp;
+        for (let i = 0; i < indices.length; i += 3) {
+            tmp = indices[i];
+            indices[i] = indices[i + 1];
+            indices[i + 1] = tmp;
+        }
+        const normals = m.normalBuffer.ref.value;
+        for (let i = 0; i < normals.length; i++) {
+            normals[i] *= -1;
+        }
+    }
+}
+
+/** Create a copy a mesh, possibly modified */
+export function copy(m: Mesh, modification?: MeshModificationParams): Mesh {
+    const nVertices = m.vertexCount;
+    const nTriangles = m.triangleCount;
+    const vertices = new Float32Array(m.vertexBuffer.ref.value);
+    const indices = new Uint32Array(m.indexBuffer.ref.value);
+    const normals = new Float32Array(m.normalBuffer.ref.value);
+    const groups = new Float32Array(m.groupBuffer.ref.value);
+    const result = Mesh.create(vertices, indices, normals, groups, nVertices, nTriangles);
+    if (modification) {
+        modify(result, modification);
+    }
+    return result;
+}
+
+/** Join more meshes into one */
+export function concat(...meshes: Mesh[]): Mesh {
+    const nVertices = sum(meshes.map(m => m.vertexCount));
+    const nTriangles = sum(meshes.map(m => m.triangleCount));
+    const vertices = concatArrays(Float32Array, meshes.map(m => m.vertexBuffer.ref.value));
+    const normals = concatArrays(Float32Array, meshes.map(m => m.normalBuffer.ref.value));
+    const groups = concatArrays(Float32Array, meshes.map(m => m.groupBuffer.ref.value));
+    const newIndices = [];
+    let offset = 0;
+    for (const m of meshes) {
+        newIndices.push(m.indexBuffer.ref.value.map(i => i + offset));
+        offset += m.vertexCount;
+    }
+    const indices = concatArrays(Uint32Array, newIndices);
+    return Mesh.create(vertices, indices, normals, groups, nVertices, nTriangles);
+}
+
+/** Return Mesh from CIF data and mesh IDs (group IDs).
+ * Assume the CIF contains coords in grid space,
+ * transform the output mesh to `space` */
+export async function meshFromCif(data: CifFile, invertSides: boolean = true, outSpace: 'grid' | 'fractional' | 'cartesian' = 'cartesian'): Promise<{ mesh: Mesh, meshIds: number[] }> {
+    const volumeInfoBlock = data.blocks.find(b => b.header === 'VOLUME_INFO');
+    const meshesBlock = data.blocks.find(b => b.header === 'MESHES');
+    if (!volumeInfoBlock || !meshesBlock) throw new Error('Missing VOLUME_INFO or MESHES block in mesh CIF file');
+    const volumeInfoCif = CIF.schema.densityServer(volumeInfoBlock);
+    const meshCif = CIF_schema_mesh(meshesBlock);
+
+    const nVertices = meshCif.mesh_vertex._rowCount;
+    const nTriangles = Math.floor(meshCif.mesh_triangle._rowCount / 3);
+
+    const mesh_id = meshCif.mesh.id.toArray();
+    const vertex_meshId = meshCif.mesh_vertex.mesh_id.toArray();
+    const x = meshCif.mesh_vertex.x.toArray();
+    const y = meshCif.mesh_vertex.y.toArray();
+    const z = meshCif.mesh_vertex.z.toArray();
+    const triangle_meshId = meshCif.mesh_triangle.mesh_id.toArray();
+    const triangle_vertexId = meshCif.mesh_triangle.vertex_id.toArray();
+
+    // Shift indices from within-mesh indices to overall indices
+    const indices = new Uint32Array(3 * nTriangles);
+    const offsets = offsetMap(vertex_meshId);
+    for (let i = 0; i < 3 * nTriangles; i++) {
+        const offset = offsets.get(triangle_meshId[i])!;
+        indices[i] = offset + triangle_vertexId[i];
+    }
+    const vertices = flattenCoords(x, y, z);
+    const normals = new Float32Array(3 * nVertices);
+    const groups = new Float32Array(vertex_meshId);
+    const mesh = Mesh.create(vertices, indices, normals, groups, nVertices, nTriangles);
+
+    if (invertSides) {
+        modify(mesh, { invertSides: true }); // Vertex orientation convention is opposite in Volseg API and in MolStar
+    }
+
+    if (outSpace === 'cartesian') {
+        const volume = await volumeFromDensityServerData(volumeInfoCif).run();
+        const gridToCartesian = Grid.getGridToCartesianTransform(volume.grid);
+        modify(mesh, { matrix: gridToCartesian });
+    } else if (outSpace === 'fractional') {
+        const gridSize = volumeInfoCif.volume_data_3d_info.sample_count.value(0);
+        const originFract = volumeInfoCif.volume_data_3d_info.origin.value(0);
+        const dimensionFract = volumeInfoCif.volume_data_3d_info.dimensions.value(0);
+        if (dimensionFract[0] !== 1 || dimensionFract[1] !== 1 || dimensionFract[2] !== 1) throw new Error(`Asserted the fractional dimensions are [1,1,1], but are actually [${dimensionFract}]`);
+        const scale: [number, number, number] = [1 / gridSize[0], 1 / gridSize[1], 1 / gridSize[2]];
+        modify(mesh, { scale: scale, shift: Array.from(originFract) as any });
+    }
+
+    Mesh.computeNormals(mesh); // normals only necessary if flatShaded==false
+
+    // const boxMesh = makeMeshFromBox([[0,0,0], [1,1,1]], 1);
+    // const gridSize = volumeInfoCif.volume_data_3d_info.sample_count.value(0); const boxMesh = makeMeshFromBox([[0,0,0], Array.from(gridSize)] as any, 1);
+    // const cellSize = volumeInfoCif.volume_data_3d_info.spacegroup_cell_size.value(0); const boxMesh = makeMeshFromBox([[0, 0, 0], Array.from(cellSize)] as any, 1);
+    // mesh = concat(mesh, boxMesh);  // debug
+    return { mesh: mesh, meshIds: Array.from(mesh_id) };
+}
+
+function flattenCoords(x: ArrayLike<number>, y: ArrayLike<number>, z: ArrayLike<number>): Float32Array {
+    const n = x.length;
+    const out = new Float32Array(3 * n);
+    for (let i = 0; i < n; i++) {
+        out[3 * i] = x[i];
+        out[3 * i + 1] = y[i];
+        out[3 * i + 2] = z[i];
+    }
+    return out;
+}
+
+/** Get mapping of unique values to the position of their first occurrence */
+function offsetMap(values: ArrayLike<number>) {
+    const result = new Map<number, number>();
+    for (let i = 0; i < values.length; i++) {
+        if (!result.has(values[i])) {
+            result.set(values[i], i);
+        }
+    }
+    return result;
+}
+
+/** Return bounding box */
+export function bbox(mesh: Mesh): Box3D | null { // Is there no function for this?
+    const nVertices = mesh.vertexCount;
+    const coords = mesh.vertexBuffer.ref.value;
+    if (nVertices === 0) {
+        return null;
+    }
+    let minX = coords[0], minY = coords[1], minZ = coords[2];
+    let maxX = minX, maxY = minY, maxZ = minZ;
+    for (let i = 0; i < 3 * nVertices; i += 3) {
+        const x = coords[i], y = coords[i + 1], z = coords[i + 2];
+        if (x < minX) minX = x;
+        if (y < minY) minY = y;
+        if (z < minZ) minZ = z;
+        if (x > maxX) maxX = x;
+        if (y > maxY) maxY = y;
+        if (z > maxZ) maxZ = z;
+    }
+    return Box3D.create(Vec3.create(minX, minY, minZ), Vec3.create(maxX, maxY, maxZ));
+}
+
+/** Example mesh - 1 triangle */
+export function fakeFakeMesh1(): Mesh {
+    const nVertices = 3;
+    const nTriangles = 1;
+    const vertices = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
+    const indices = new Uint32Array([0, 1, 2]);
+    const normals = new Float32Array([0, 0, 1]);
+    const groups = new Float32Array([0]);
+    return Mesh.create(vertices, indices, normals, groups, nVertices, nTriangles);
+}
+
+/** Example mesh - irregular tetrahedron */
+export function fakeMesh4(): Mesh {
+    const nVertices = 4;
+    const nTriangles = 4;
+    const vertices = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]);
+    const indices = new Uint32Array([0, 2, 1, 0, 1, 3, 1, 2, 3, 2, 0, 3]);
+    const normals = new Float32Array([-1, -1, -1, 1, 0, 0, 0, 1, 0, 0, 0, 1]);
+    const groups = new Float32Array([0, 1, 2, 3]);
+    return Mesh.create(vertices, indices, normals, groups, nVertices, nTriangles);
+}
+
+/** Return a box-shaped mesh */
+export function meshFromBox(box: [[number, number, number], [number, number, number]], group: number = 0) {
+    const [[x0, y0, z0], [x1, y1, z1]] = box;
+    const vertices = new Float32Array([
+        x0, y0, z0,
+        x1, y0, z0,
+        x0, y1, z0,
+        x1, y1, z0,
+        x0, y0, z1,
+        x1, y0, z1,
+        x0, y1, z1,
+        x1, y1, z1,
+    ]);
+    const indices = new Uint32Array([
+        2, 1, 0, 1, 2, 3,
+        1, 4, 0, 4, 1, 5,
+        3, 5, 1, 5, 3, 7,
+        2, 7, 3, 7, 2, 6,
+        0, 6, 2, 6, 0, 4,
+        4, 7, 6, 7, 4, 5,
+    ]);
+    const groups = new Float32Array([group, group, group, group, group, group, group, group]);
+    const normals = new Float32Array(8);
+    const mesh = Mesh.create(vertices, indices, normals, groups, 8, 12);
+    Mesh.computeNormals(mesh); // normals only necessary if flatShaded==false
+    return mesh;
+}
+
+function sum(array: number[]): number {
+    return array.reduce((a, b) => a + b, 0);
+}
+
+function concatArrays<T extends TypedArray>(t: new (len: number) => T, arrays: T[]): T {
+    const totalLength = arrays.map(a => a.length).reduce((a, b) => a + b, 0);
+    const result: T = new t(totalLength);
+    let offset = 0;
+    for (const array of arrays) {
+        result.set(array, offset);
+        offset += array.length;
+    }
+    return result;
+}
+
+/** Generate random colors (in a cycle) */
+export const ColorGenerator = function* () {
+    const colors = shuffleArray(Object.values(ColorNames));
+    let i = 0;
+    while (true) {
+        yield colors[i];
+        i++;
+        if (i >= colors.length) i = 0;
+    }
+}();
+function shuffleArray<T>(array: T[]): T[] {
+    // Stealed from https://www.w3docs.com/snippets/javascript/how-to-randomize-shuffle-a-javascript-array.html
+    let curId = array.length;
+    // There remain elements to shuffle
+    while (0 !== curId) {
+        // Pick a remaining element
+        const randId = Math.floor(Math.random() * curId);
+        curId -= 1;
+        // Swap it with the current element.
+        const tmp = array[curId];
+        array[curId] = array[randId];
+        array[randId] = tmp;
+    }
+    return array;
+}
+

+ 135 - 0
src/extensions/meshes/metadata.ts

@@ -0,0 +1,135 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+
+import { Color } from '../../mol-util/color';
+
+
+// TODO unify with Metadata in Volseg
+
+export interface Metadata {
+    grid: {
+        general: {
+            details: string,
+        },
+        volumes: Volumes,
+        segmentation_lattices: SegmentationLattices,
+        segmentation_meshes: SegmentationMeshes,
+    },
+    annotation: Annotation,
+}
+
+export interface Volumes {
+    volume_downsamplings: number[],
+    voxel_size: { [downsampling: number]: Vector3 },
+    origin: Vector3,
+    grid_dimensions: Vector3,
+    sampled_grid_dimensions: { [downsampling: number]: Vector3 },
+    mean: { [downsampling: number]: number },
+    std: { [downsampling: number]: number },
+    min: { [downsampling: number]: number },
+    max: { [downsampling: number]: number },
+    volume_force_dtype: string,
+}
+
+export interface SegmentationLattices {
+    segmentation_lattice_ids: number[],
+    segmentation_downsamplings: { [lattice: number]: number[] },
+}
+
+export interface Annotation {
+    name: string,
+    details: string,
+    segment_list: Segment[],
+}
+
+export interface Segment {
+    id: number,
+    colour: number[],
+    biological_annotation: BiologicalAnnotation,
+}
+
+export interface BiologicalAnnotation {
+    name: string,
+    external_references: { id: number, resource: string, accession: string, label: string, description: string }[]
+}
+
+export interface SegmentationMeshes {
+    mesh_component_numbers: {
+        segment_ids?: {
+            [segId: number]: {
+                detail_lvls: {
+                    [detail: number]: {
+                        mesh_ids: {
+                            [meshId: number]: {
+                                num_triangles: number,
+                                num_vertices: number
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+    detail_lvl_to_fraction: {
+        [lvl: number]: number
+    }
+}
+
+type Vector3 = [number, number, number];
+
+
+
+export namespace Metadata {
+    export function meshSegments(metadata: Metadata): number[] {
+        const segmentIds = metadata.grid.segmentation_meshes.mesh_component_numbers.segment_ids;
+        if (segmentIds === undefined) return [];
+        return Object.keys(segmentIds).map(s => parseInt(s));
+    }
+    export function meshSegmentDetails(metadata: Metadata, segmentId: number): number[] {
+        const segmentIds = metadata.grid.segmentation_meshes.mesh_component_numbers.segment_ids;
+        if (segmentIds === undefined) return [];
+        const details = segmentIds[segmentId].detail_lvls;
+        return Object.keys(details).map(s => parseInt(s));
+    }
+    /** Get the worst available detail level that is not worse than preferredDetail.
+     * If preferredDetail is null, get the worst detail level overall.
+     * (worse = greater number) */
+    export function getSufficientDetail(metadata: Metadata, segmentId: number, preferredDetail: number | null) {
+        let availDetails = meshSegmentDetails(metadata, segmentId);
+        if (preferredDetail !== null) {
+            availDetails = availDetails.filter(det => det <= preferredDetail);
+        }
+        return Math.max(...availDetails);
+    }
+    export function annotationsBySegment(metadata: Metadata): { [id: number]: Segment } {
+        const result: { [id: number]: Segment } = {};
+        for (const segment of metadata.annotation.segment_list) {
+            if (segment.id in result) {
+                throw new Error(`Duplicate segment annotation for segment ${segment.id}`);
+            }
+            result[segment.id] = segment;
+        }
+        return result;
+    }
+    export function dropSegments(metadata: Metadata, segments: number[]): void {
+        if (metadata.grid.segmentation_meshes.mesh_component_numbers.segment_ids === undefined) return;
+        const dropSet = new Set(segments);
+        metadata.annotation.segment_list = metadata.annotation.segment_list.filter(seg => !dropSet.has(seg.id));
+        for (const seg of segments) {
+            delete metadata.grid.segmentation_meshes.mesh_component_numbers.segment_ids[seg];
+        }
+    }
+    export function namesAndColorsBySegment(metadata: Metadata) {
+        const result: { [id: number]: { name: string, color: Color } } = {};
+        for (const segment of metadata.annotation.segment_list) {
+            if (segment.id in result) throw new Error(`Duplicate segment annotation for segment ${segment.id}`);
+            result[segment.id] = { name: segment.biological_annotation.name, color: Color.fromNormalizedArray(segment.colour, 0) };
+        }
+        return result;
+
+    }
+}

+ 103 - 0
src/extensions/volumes-and-segmentations/entry-meshes.ts

@@ -0,0 +1,103 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { PluginStateObject } from '../../mol-plugin-state/objects';
+import { CreateGroup } from '../../mol-plugin-state/transforms/misc';
+import { ShapeRepresentation3D } from '../../mol-plugin-state/transforms/representation';
+import { setSubtreeVisibility } from '../../mol-plugin/behavior/static/state';
+import { PluginCommands } from '../../mol-plugin/commands';
+import { Color } from '../../mol-util/color';
+import { ColorNames } from '../../mol-util/color/names';
+
+import { createMeshFromUrl } from '../meshes/examples';
+import { BACKGROUND_SEGMENT_VOLUME_THRESHOLD } from '../meshes/mesh-streaming/behavior';
+
+import { Segment } from './volseg-api/data';
+import { VolsegEntryData } from './entry-root';
+
+
+const DEFAULT_MESH_DETAIL: number | null = 5; // null means worst
+
+
+export class VolsegMeshSegmentationData {
+    private entryData: VolsegEntryData;
+
+    constructor(rootData: VolsegEntryData) {
+        this.entryData = rootData;
+    }
+
+    async loadSegmentation() {
+        const hasMeshes = this.entryData.metadata.meshSegmentIds.length > 0;
+        if (hasMeshes) {
+            await this.showSegments(this.entryData.metadata.allSegmentIds);
+        }
+    }
+
+    updateOpacity(opacity: number) {
+        const visuals = this.entryData.findNodesByTags('mesh-segment-visual');
+        const update = this.entryData.newUpdate();
+        for (const visual of visuals) {
+            update.to(visual).update(ShapeRepresentation3D, p => { (p as any).alpha = opacity; });
+        }
+        return update.commit();
+    }
+
+    async highlightSegment(segment: Segment) {
+        const visuals = this.entryData.findNodesByTags('mesh-segment-visual', `segment-${segment.id}`);
+        for (const visual of visuals) {
+            await PluginCommands.Interactivity.Object.Highlight(this.entryData.plugin, { state: this.entryData.plugin.state.data, ref: visual.transform.ref });
+        }
+    }
+
+    async selectSegment(segment?: number) {
+        if (segment === undefined || segment < 0) return;
+        const visuals = this.entryData.findNodesByTags('mesh-segment-visual', `segment-${segment}`);
+        const reprNode: PluginStateObject.Shape.Representation3D | undefined = visuals[0]?.obj;
+        if (!reprNode) return;
+        const loci = reprNode.data.repr.getAllLoci()[0];
+        if (!loci) return;
+        this.entryData.plugin.managers.interactivity.lociSelects.select({ loci: loci, repr: reprNode.data.repr }, false);
+    }
+
+    /** Make visible the specified set of mesh segments */
+    async showSegments(segments: number[]) {
+        const segmentsToShow = new Set(segments);
+
+        const visuals = this.entryData.findNodesByTags('mesh-segment-visual');
+        for (const visual of visuals) {
+            const theTag = visual.obj?.tags?.find(tag => tag.startsWith('segment-'));
+            if (!theTag) continue;
+            const id = parseInt(theTag.split('-')[1]);
+            const visibility = segmentsToShow.has(id);
+            setSubtreeVisibility(this.entryData.plugin.state.data, visual.transform.ref, !visibility); // true means hide, ¯\_(ツ)_/¯
+            segmentsToShow.delete(id);
+        }
+
+        const segmentsToCreate = this.entryData.metadata.meshSegmentIds.filter(seg => segmentsToShow.has(seg));
+        if (segmentsToCreate.length === 0) return;
+
+        let group = this.entryData.findNodesByTags('mesh-segmentation-group')[0]?.transform.ref;
+        if (!group) {
+            const newGroupNode = await this.entryData.newUpdate().apply(CreateGroup, { label: 'Segmentation', description: 'Mesh' }, { tags: ['mesh-segmentation-group'], state: { isCollapsed: true } }).commit();
+            group = newGroupNode.ref;
+        }
+        const totalVolume = this.entryData.metadata.gridTotalVolume;
+
+        const awaiting = [];
+        for (const seg of segmentsToCreate) {
+            const segment = this.entryData.metadata.getSegment(seg);
+            if (!segment) continue;
+            const detail = this.entryData.metadata.getSufficientMeshDetail(seg, DEFAULT_MESH_DETAIL);
+            const color = segment.colour.length >= 3 ? Color.fromNormalizedArray(segment.colour, 0) : ColorNames.gray;
+            const url = this.entryData.api.meshUrl_Bcif(this.entryData.source, this.entryData.entryId, seg, detail);
+            const label = segment.biological_annotation.name ?? `Segment ${seg}`;
+            const meshPromise = createMeshFromUrl(this.entryData.plugin, url, seg, detail, true, color, group,
+                BACKGROUND_SEGMENT_VOLUME_THRESHOLD * totalVolume, `<b>${label}</b>`, this.entryData.ref);
+            awaiting.push(meshPromise);
+        }
+        for (const promise of awaiting) await promise;
+    }
+}

+ 60 - 0
src/extensions/volumes-and-segmentations/entry-models.ts

@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { Download, ParseCif } from '../../mol-plugin-state/transforms/data';
+import { CreateGroup } from '../../mol-plugin-state/transforms/misc';
+import { TrajectoryFromMmCif } from '../../mol-plugin-state/transforms/model';
+import { setSubtreeVisibility } from '../../mol-plugin/behavior/static/state';
+import { StateObjectRef, StateObjectSelector } from '../../mol-state';
+
+import { VolsegEntryData } from './entry-root';
+
+
+export class VolsegModelData {
+    private entryData: VolsegEntryData;
+
+    constructor(rootData: VolsegEntryData) {
+        this.entryData = rootData;
+    }
+
+    private async loadPdb(pdbId: string, parent: StateObjectSelector | StateObjectRef) {
+        const url = `https://www.ebi.ac.uk/pdbe/entry-files/download/${pdbId}.bcif`;
+        const dataNode = await this.entryData.plugin.build().to(parent).apply(Download, { url: url, isBinary: true }, { tags: ['fitted-model-data', `pdbid-${pdbId}`] }).commit();
+        const cifNode = await this.entryData.plugin.build().to(dataNode).apply(ParseCif).commit();
+        const trajectoryNode = await this.entryData.plugin.build().to(cifNode).apply(TrajectoryFromMmCif).commit();
+        await this.entryData.plugin.builders.structure.hierarchy.applyPreset(trajectoryNode, 'default', { representationPreset: 'polymer-cartoon' });
+        return dataNode;
+    }
+
+    async showPdbs(pdbIds: string[]) {
+        const segmentsToShow = new Set(pdbIds);
+
+        const visuals = this.entryData.findNodesByTags('fitted-model-data');
+        for (const visual of visuals) {
+            const theTag = visual.obj?.tags?.find(tag => tag.startsWith('pdbid-'));
+            if (!theTag) continue;
+            const id = theTag.split('-')[1];
+            const visibility = segmentsToShow.has(id);
+            setSubtreeVisibility(this.entryData.plugin.state.data, visual.transform.ref, !visibility); // true means hide, ¯\_(ツ)_/¯
+            segmentsToShow.delete(id);
+        }
+
+        const segmentsToCreate = Array.from(segmentsToShow);
+        if (segmentsToCreate.length === 0) return;
+
+        let group = this.entryData.findNodesByTags('fitted-models-group')[0]?.transform.ref;
+        if (!group) {
+            const newGroupNode = await this.entryData.newUpdate().apply(CreateGroup, { label: 'Fitted Models' }, { tags: ['fitted-models-group'], state: { isCollapsed: true } }).commit();
+            group = newGroupNode.ref;
+        }
+
+        const awaiting = [];
+        for (const pdbId of segmentsToCreate) {
+            awaiting.push(this.loadPdb(pdbId, group));
+        }
+        for (const promise of awaiting) await promise;
+    }
+}

+ 356 - 0
src/extensions/volumes-and-segmentations/entry-root.ts

@@ -0,0 +1,356 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { BehaviorSubject, distinctUntilChanged, Subject, throttleTime } from 'rxjs';
+import { VolsegVolumeServerConfig } from '.';
+import { Loci } from '../../mol-model/loci';
+
+import { ShapeGroup } from '../../mol-model/shape';
+import { Volume } from '../../mol-model/volume';
+import { LociLabelProvider } from '../../mol-plugin-state/manager/loci-label';
+import { PluginStateObject } from '../../mol-plugin-state/objects';
+import { PluginBehavior } from '../../mol-plugin/behavior';
+import { PluginCommands } from '../../mol-plugin/commands';
+import { PluginContext } from '../../mol-plugin/context';
+import { StateObjectCell, StateTransform } from '../../mol-state';
+import { shallowEqualObjects } from '../../mol-util';
+import { ParamDefinition } from '../../mol-util/param-definition';
+import { MeshlistData } from '../meshes/mesh-extension';
+
+import { DEFAULT_VOLUME_SERVER_V2, VolumeApiV2 } from './volseg-api/api';
+import { Segment } from './volseg-api/data';
+import { MetadataWrapper } from './volseg-api/utils';
+import { VolsegMeshSegmentationData } from './entry-meshes';
+import { VolsegModelData } from './entry-models';
+import { VolsegLatticeSegmentationData } from './entry-segmentation';
+import { VolsegState, VolsegStateData, VolsegStateParams } from './entry-state';
+import { VolsegVolumeData, SimpleVolumeParamValues } from './entry-volume';
+import * as ExternalAPIs from './external-api';
+import { VolsegGlobalStateData } from './global-state';
+import { applyEllipsis, Choice, isDefined, lazyGetter, splitEntryId } from './helpers';
+import { type VolsegStateFromEntry } from './transformers';
+
+
+export const MAX_VOXELS = 10 ** 7;
+// export const MAX_VOXELS = 10 ** 2; // DEBUG
+export const BOX: [[number, number, number], [number, number, number]] | null = null;
+// export const BOX: [[number, number, number], [number, number, number]] | null = [[-90, -90, -90], [90, 90, 90]]; // DEBUG
+
+const MAX_ANNOTATIONS_IN_LABEL = 6;
+
+
+const SourceChoice = new Choice({ emdb: 'EMDB', empiar: 'EMPIAR', idr: 'IDR' }, 'emdb');
+export type Source = Choice.Values<typeof SourceChoice>;
+
+
+export function createLoadVolsegParams(plugin?: PluginContext, entrylists: { [source: string]: string[] } = {}) {
+    const defaultVolumeServer = plugin?.config.get(VolsegVolumeServerConfig.DefaultServer) ?? DEFAULT_VOLUME_SERVER_V2;
+    return {
+        serverUrl: ParamDefinition.Text(defaultVolumeServer),
+        source: ParamDefinition.Mapped(SourceChoice.values[0], SourceChoice.options, src => entryParam(entrylists[src])),
+    };
+}
+function entryParam(entries: string[] = []) {
+    const options: [string, string][] = entries.map(e => [e, e]);
+    options.push(['__custom__', 'Custom']);
+    return ParamDefinition.Group({
+        entryId: ParamDefinition.Select(options[0][0], options, { description: 'Choose an entry from the list, or choose "Custom" and type any entry ID (useful when using other than default server).' }),
+        customEntryId: ParamDefinition.Text('', { hideIf: p => p.entryId !== '__custom__', description: 'Entry identifier, including the source prefix, e.g. "emd-1832"' }),
+    }, { isFlat: true });
+}
+type LoadVolsegParamValues = ParamDefinition.Values<ReturnType<typeof createLoadVolsegParams>>;
+
+export function createVolsegEntryParams(plugin?: PluginContext) {
+    const defaultVolumeServer = plugin?.config.get(VolsegVolumeServerConfig.DefaultServer) ?? DEFAULT_VOLUME_SERVER_V2;
+    return {
+        serverUrl: ParamDefinition.Text(defaultVolumeServer),
+        source: SourceChoice.PDSelect(),
+        entryId: ParamDefinition.Text('emd-1832', { description: 'Entry identifier, including the source prefix, e.g. "emd-1832"' }),
+    };
+}
+type VolsegEntryParamValues = ParamDefinition.Values<ReturnType<typeof createVolsegEntryParams>>;
+
+export namespace VolsegEntryParamValues {
+    export function fromLoadVolsegParamValues(params: LoadVolsegParamValues): VolsegEntryParamValues {
+        let entryId = (params.source.params as any).entryId;
+        if (entryId === '__custom__') {
+            entryId = (params.source.params as any).customEntryId;
+        }
+        return {
+            serverUrl: params.serverUrl,
+            source: params.source.name as Source,
+            entryId: entryId
+        };
+    }
+}
+
+
+export class VolsegEntry extends PluginStateObject.CreateBehavior<VolsegEntryData>({ name: 'Vol & Seg Entry' }) { }
+
+
+export class VolsegEntryData extends PluginBehavior.WithSubscribers<VolsegEntryParamValues> {
+    plugin: PluginContext;
+    ref: string = '';
+    api: VolumeApiV2;
+    source: Source;
+    /** Number part of entry ID; e.g. '1832' */
+    entryNumber: string;
+    /** Full entry ID; e.g. 'emd-1832' */
+    entryId: string;
+    metadata: MetadataWrapper;
+    pdbs: string[];
+
+    public readonly volumeData = new VolsegVolumeData(this);
+    private readonly latticeSegmentationData = new VolsegLatticeSegmentationData(this);
+    private readonly meshSegmentationData = new VolsegMeshSegmentationData(this);
+    private readonly modelData = new VolsegModelData(this);
+    private highlightRequest = new Subject<Segment | undefined>();
+
+    private getStateNode = lazyGetter(() => this.plugin.state.data.selectQ(q => q.byRef(this.ref).subtree().ofType(VolsegState))[0] as StateObjectCell<VolsegState, StateTransform<typeof VolsegStateFromEntry>>, 'Missing VolsegState node. Must first create VolsegState for this VolsegEntry.');
+    public currentState = new BehaviorSubject(ParamDefinition.getDefaultValues(VolsegStateParams));
+
+
+    private constructor(plugin: PluginContext, params: VolsegEntryParamValues) {
+        super(plugin, params);
+        this.plugin = plugin;
+        this.api = new VolumeApiV2(params.serverUrl);
+        this.source = params.source;
+        this.entryId = params.entryId;
+        this.entryNumber = splitEntryId(this.entryId).entryNumber;
+    }
+
+    private async initialize() {
+        const metadata = await this.api.getMetadata(this.source, this.entryId);
+        this.metadata = new MetadataWrapper(metadata);
+        this.pdbs = await ExternalAPIs.getPdbIdsForEmdbEntry(this.metadata.raw.grid.general.source_db_id ?? this.entryId);
+        // TODO use Asset?
+    }
+
+    static async create(plugin: PluginContext, params: VolsegEntryParamValues) {
+        const result = new VolsegEntryData(plugin, params);
+        await result.initialize();
+        return result;
+    }
+
+    async register(ref: string) {
+        this.ref = ref;
+        this.plugin.managers.lociLabels.addProvider(this.labelProvider);
+
+        try {
+            const params = this.getStateNode().obj?.data;
+            if (params) {
+                this.currentState.next(params);
+            }
+        } catch {
+            // do nothing
+        }
+
+        this.subscribeObservable(this.plugin.state.data.events.cell.stateUpdated, e => {
+            try { (this.getStateNode()); } catch { return; } // if state not does not exist yet
+            if (e.cell.transform.ref === this.getStateNode().transform.ref) {
+                const newState = this.getStateNode().obj?.data;
+                if (newState && !shallowEqualObjects(newState, this.currentState.value)) { // avoid repeated update
+                    this.currentState.next(newState);
+                }
+            }
+        });
+
+        this.subscribeObservable(this.plugin.behaviors.interaction.click, async e => {
+            const loci = e.current.loci;
+            const clickedSegment = this.getSegmentIdFromLoci(loci);
+            if (clickedSegment === undefined) return;
+            if (clickedSegment === this.currentState.value.selectedSegment) {
+                this.actionSelectSegment(undefined);
+            } else {
+                this.actionSelectSegment(clickedSegment);
+            }
+        });
+
+        this.subscribeObservable(
+            this.highlightRequest.pipe(throttleTime(50, undefined, { leading: true, trailing: true })),
+            async segment => await this.highlightSegment(segment)
+        );
+
+        this.subscribeObservable(
+            this.currentState.pipe(distinctUntilChanged((a, b) => a.selectedSegment === b.selectedSegment)),
+            async state => {
+                if (VolsegGlobalStateData.getGlobalState(this.plugin)?.selectionMode) await this.selectSegment(state.selectedSegment);
+            }
+        );
+    }
+
+    async unregister() {
+        this.plugin.managers.lociLabels.removeProvider(this.labelProvider);
+    }
+
+    async loadVolume() {
+        const result = await this.volumeData.loadVolume();
+        if (result) {
+            const isovalue = result.isovalue.kind === 'relative' ? result.isovalue.relativeValue : result.isovalue.absoluteValue;
+            await this.updateStateNode({ volumeIsovalueKind: result.isovalue.kind, volumeIsovalueValue: isovalue });
+        }
+    }
+
+    async loadSegmentations() {
+        await this.latticeSegmentationData.loadSegmentation();
+        await this.meshSegmentationData.loadSegmentation();
+        await this.actionShowSegments(this.metadata.allSegmentIds);
+    }
+
+
+    actionHighlightSegment(segment?: Segment) {
+        this.highlightRequest.next(segment);
+    }
+
+    async actionToggleSegment(segment: number) {
+        const current = this.currentState.value.visibleSegments.map(seg => seg.segmentId);
+        if (current.includes(segment)) {
+            await this.actionShowSegments(current.filter(s => s !== segment));
+        } else {
+            await this.actionShowSegments([...current, segment]);
+        }
+    }
+
+    async actionToggleAllSegments() {
+        const current = this.currentState.value.visibleSegments.map(seg => seg.segmentId);
+        if (current.length !== this.metadata.allSegments.length) {
+            await this.actionShowSegments(this.metadata.allSegmentIds);
+        } else {
+            await this.actionShowSegments([]);
+        }
+    }
+
+    async actionSelectSegment(segment?: number) {
+        if (segment !== undefined && this.currentState.value.visibleSegments.find(s => s.segmentId === segment) === undefined) {
+            // first make the segment visible if it is not
+            await this.actionToggleSegment(segment);
+        }
+        await this.updateStateNode({ selectedSegment: segment });
+    }
+
+    async actionSetOpacity(opacity: number) {
+        if (opacity === this.getStateNode().obj?.data.segmentOpacity) return;
+        this.latticeSegmentationData.updateOpacity(opacity);
+        this.meshSegmentationData.updateOpacity(opacity);
+
+        await this.updateStateNode({ segmentOpacity: opacity });
+    }
+
+    async actionShowFittedModel(pdbIds: string[]) {
+        await this.modelData.showPdbs(pdbIds);
+        await this.updateStateNode({ visibleModels: pdbIds.map(pdbId => ({ pdbId: pdbId })) });
+    }
+
+    async actionSetVolumeVisual(type: 'isosurface' | 'direct-volume' | 'off') {
+        await this.volumeData.setVolumeVisual(type);
+        await this.updateStateNode({ volumeType: type });
+    }
+
+    async actionUpdateVolumeVisual(params: SimpleVolumeParamValues) {
+        await this.volumeData.updateVolumeVisual(params);
+        await this.updateStateNode({
+            volumeType: params.volumeType,
+            volumeOpacity: params.opacity,
+        });
+    }
+
+
+    private async actionShowSegments(segments: number[]) {
+        await this.latticeSegmentationData.showSegments(segments);
+        await this.meshSegmentationData.showSegments(segments);
+        await this.updateStateNode({ visibleSegments: segments.map(s => ({ segmentId: s })) });
+    }
+
+    private async highlightSegment(segment?: Segment) {
+        await PluginCommands.Interactivity.ClearHighlights(this.plugin);
+        if (segment) {
+            await this.latticeSegmentationData.highlightSegment(segment);
+            await this.meshSegmentationData.highlightSegment(segment);
+        }
+    }
+
+    private async selectSegment(segment: number) {
+        this.plugin.managers.interactivity.lociSelects.deselectAll();
+        await this.latticeSegmentationData.selectSegment(segment);
+        await this.meshSegmentationData.selectSegment(segment);
+        await this.highlightSegment();
+    }
+
+    private async updateStateNode(params: Partial<VolsegStateData>) {
+        const oldParams = this.getStateNode().transform.params;
+        const newParams = { ...oldParams, ...params };
+        const state = this.plugin.state.data;
+        const update = state.build().to(this.getStateNode().transform.ref).update(newParams);
+        await PluginCommands.State.Update(this.plugin, { state, tree: update, options: { doNotUpdateCurrent: true } });
+    }
+
+
+    /** Find the nodes under this entry root which have all of the given tags. */
+    findNodesByTags(...tags: string[]) {
+        return this.plugin.state.data.selectQ(q => {
+            let builder = q.byRef(this.ref).subtree();
+            for (const tag of tags) builder = builder.withTag(tag);
+            return builder;
+        });
+    }
+
+    newUpdate() {
+        if (this.ref !== '') {
+            return this.plugin.build().to(this.ref);
+        } else {
+            return this.plugin.build().toRoot();
+        }
+    }
+
+    private readonly labelProvider: LociLabelProvider = {
+        label: (loci: Loci): string | undefined => {
+            const segmentId = this.getSegmentIdFromLoci(loci);
+            if (segmentId === undefined) return;
+            const segment = this.metadata.getSegment(segmentId);
+            if (!segment) return;
+            const annotLabels = segment.biological_annotation.external_references.map(annot => `${applyEllipsis(annot.label)} [${annot.resource}:${annot.accession}]`);
+            if (annotLabels.length === 0) return;
+            if (annotLabels.length > MAX_ANNOTATIONS_IN_LABEL + 1) {
+                const nHidden = annotLabels.length - MAX_ANNOTATIONS_IN_LABEL;
+                annotLabels.length = MAX_ANNOTATIONS_IN_LABEL;
+                annotLabels.push(`(${nHidden} more annotations, click on the segment to see all)`);
+            }
+            return '<hr class="msp-highlight-info-hr"/>' + annotLabels.filter(isDefined).join('<br/>');
+        }
+    };
+
+    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 (ShapeGroup.isLoci(loci)) {
+            const meshData = (loci.shape.sourceData ?? {}) as MeshlistData;
+            if (meshData.ownerId === this.ref && meshData.segmentId !== undefined) {
+                return meshData.segmentId;
+            }
+        }
+    }
+
+    async setTryUseGpu(tryUseGpu: boolean) {
+        await Promise.all([
+            this.volumeData.setTryUseGpu(tryUseGpu),
+            this.latticeSegmentationData.setTryUseGpu(tryUseGpu),
+        ]);
+    }
+    async setSelectionMode(selectSegments: boolean) {
+        if (selectSegments) {
+            await this.selectSegment(this.currentState.value.selectedSegment);
+        } else {
+            this.plugin.managers.interactivity.lociSelects.deselectAll();
+        }
+    }
+
+}
+
+
+

+ 131 - 0
src/extensions/volumes-and-segmentations/entry-segmentation.ts

@@ -0,0 +1,131 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { Volume } from '../../mol-model/volume';
+import { createVolumeRepresentationParams } from '../../mol-plugin-state/helpers/volume-representation-params';
+import { StateTransforms } from '../../mol-plugin-state/transforms';
+import { Download, ParseCif } from '../../mol-plugin-state/transforms/data';
+import { CreateGroup } from '../../mol-plugin-state/transforms/misc';
+import { VolumeFromSegmentationCif } from '../../mol-plugin-state/transforms/volume';
+import { PluginCommands } from '../../mol-plugin/commands';
+import { Color } from '../../mol-util/color';
+
+import { Segment } from './volseg-api/data';
+import { BOX, VolsegEntryData, MAX_VOXELS } from './entry-root';
+import { VolumeVisualParams } from './entry-volume';
+import { VolsegGlobalStateData } from './global-state';
+
+
+const GROUP_TAG = 'lattice-segmentation-group';
+const SEGMENT_VISUAL_TAG = 'lattice-segment-visual';
+
+const DEFAULT_SEGMENT_COLOR = Color.fromNormalizedRgb(0.8, 0.8, 0.8);
+
+
+export class VolsegLatticeSegmentationData {
+    private entryData: VolsegEntryData;
+
+    constructor(rootData: VolsegEntryData) {
+        this.entryData = rootData;
+    }
+
+    async loadSegmentation() {
+        const hasLattices = this.entryData.metadata.raw.grid.segmentation_lattices.segmentation_lattice_ids.length > 0;
+        if (hasLattices) {
+            const url = this.entryData.api.latticeUrl(this.entryData.source, this.entryData.entryId, 0, BOX, MAX_VOXELS);
+            let group = this.entryData.findNodesByTags(GROUP_TAG)[0]?.transform.ref;
+            if (!group) {
+                const newGroupNode = await this.entryData.newUpdate().apply(CreateGroup,
+                    { label: 'Segmentation', description: 'Lattice' }, { tags: [GROUP_TAG], state: { isCollapsed: true } }).commit();
+                group = newGroupNode.ref;
+            }
+            const segmentLabels = this.entryData.metadata.allSegments.map(seg => ({ id: seg.id, label: seg.biological_annotation.name ? `<b>${seg.biological_annotation.name}</b>` : '' }));
+            const volumeNode = await this.entryData.newUpdate().to(group)
+                .apply(Download, { url, isBinary: true, label: `Segmentation Data: ${url}` })
+                .apply(ParseCif)
+                .apply(VolumeFromSegmentationCif, { blockHeader: 'SEGMENTATION_DATA', segmentLabels: segmentLabels, ownerId: this.entryData.ref })
+                .commit();
+            const volumeData = volumeNode.data as Volume;
+            const segmentation = Volume.Segmentation.get(volumeData);
+            const segmentIds: number[] = Array.from(segmentation?.segments.keys() ?? []);
+            await this.entryData.newUpdate().to(volumeNode)
+                .apply(StateTransforms.Representation.VolumeRepresentation3D, createVolumeRepresentationParams(this.entryData.plugin, volumeData, {
+                    type: 'segment',
+                    typeParams: { tryUseGpu: VolsegGlobalStateData.getGlobalState(this.entryData.plugin)?.tryUseGpu },
+                    color: 'volume-segment',
+                    colorParams: { palette: this.createPalette(segmentIds) },
+                }), { tags: [SEGMENT_VISUAL_TAG] }).commit();
+        }
+    }
+
+    private createPalette(segmentIds: number[]) {
+        const colorMap = new Map<number, Color>();
+        for (const segment of this.entryData.metadata.allSegments) {
+            const color = Color.fromNormalizedArray(segment.colour, 0);
+            colorMap.set(segment.id, color);
+        }
+        if (colorMap.size === 0) return undefined;
+        for (const segid of segmentIds) {
+            colorMap.get(segid);
+        }
+        const colors = segmentIds.map(segid => colorMap.get(segid) ?? DEFAULT_SEGMENT_COLOR);
+        return { name: 'colors' as const, params: { list: { kind: 'set' as const, colors: colors } } };
+    }
+
+    async updateOpacity(opacity: number) {
+        const reprs = this.entryData.findNodesByTags(SEGMENT_VISUAL_TAG);
+        const update = this.entryData.newUpdate();
+        for (const s of reprs) {
+            update.to(s).update(StateTransforms.Representation.VolumeRepresentation3D, p => { p.type.params.alpha = opacity; });
+        }
+        return await update.commit();
+    }
+    private makeLoci(segments: number[]) {
+        const vis = this.entryData.findNodesByTags(SEGMENT_VISUAL_TAG)[0];
+        if (!vis) return undefined;
+        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 };
+    }
+    async highlightSegment(segment: Segment) {
+        const segmentLoci = this.makeLoci([segment.id]);
+        if (!segmentLoci) return;
+        this.entryData.plugin.managers.interactivity.lociHighlights.highlight(segmentLoci, false);
+    }
+    async selectSegment(segment?: number) {
+        if (segment === undefined || segment < 0) return;
+        const segmentLoci = this.makeLoci([segment]);
+        if (!segmentLoci) return;
+        this.entryData.plugin.managers.interactivity.lociSelects.select(segmentLoci, false);
+    }
+
+    /** Make visible the specified set of lattice segments */
+    async showSegments(segments: number[]) {
+        const repr = this.entryData.findNodesByTags(SEGMENT_VISUAL_TAG)[0];
+        if (!repr) return;
+        const selectedSegment = this.entryData.currentState.value.selectedSegment;
+        const mustReselect = segments.includes(selectedSegment) && !repr.params?.values.type.params.segments.includes(selectedSegment);
+        const update = this.entryData.newUpdate();
+        update.to(repr).update(StateTransforms.Representation.VolumeRepresentation3D, p => { p.type.params.segments = segments; });
+        await update.commit();
+        if (mustReselect) {
+            await this.selectSegment(this.entryData.currentState.value.selectedSegment);
+        }
+    }
+
+    async setTryUseGpu(tryUseGpu: boolean) {
+        const visuals = this.entryData.findNodesByTags(SEGMENT_VISUAL_TAG);
+        for (const visual of visuals) {
+            const oldParams: VolumeVisualParams = visual.transform.params;
+            if (oldParams.type.params.tryUseGpu === !tryUseGpu) {
+                const newParams = { ...oldParams, type: { ...oldParams.type, params: { ...oldParams.type.params, tryUseGpu: tryUseGpu } } };
+                const update = this.entryData.newUpdate().to(visual.transform.ref).update(newParams);
+                await PluginCommands.State.Update(this.entryData.plugin, { state: this.entryData.plugin.state.data, tree: update, options: { doNotUpdateCurrent: true } });
+            }
+        }
+    }
+}

+ 33 - 0
src/extensions/volumes-and-segmentations/entry-state.ts

@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { PluginStateObject } from '../../mol-plugin-state/objects';
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+
+import { Choice } from './helpers';
+
+
+export const VolumeTypeChoice = new Choice({ 'isosurface': 'Isosurface', 'direct-volume': 'Direct volume', 'off': 'Off' }, 'isosurface');
+export type VolumeType = Choice.Values<typeof VolumeTypeChoice>
+
+
+export const VolsegStateParams = {
+    volumeType: VolumeTypeChoice.PDSelect(),
+    volumeIsovalueKind: PD.Select('relative', [['relative', 'Relative'], ['absolute', 'Absolute']]),
+    volumeIsovalueValue: PD.Numeric(1),
+    volumeOpacity: PD.Numeric(0.2, { min: 0, max: 1, step: 0.05 }),
+    segmentOpacity: PD.Numeric(1, { min: 0, max: 1, step: 0.05 }),
+    selectedSegment: PD.Numeric(-1, { step: 1 }),
+    visibleSegments: PD.ObjectList({ segmentId: PD.Numeric(0) }, s => s.segmentId.toString()),
+    visibleModels: PD.ObjectList({ pdbId: PD.Text('') }, s => s.pdbId.toString()),
+};
+export type VolsegStateData = PD.Values<typeof VolsegStateParams>;
+
+
+export class VolsegState extends PluginStateObject.Create<VolsegStateData>({ name: 'Vol & Seg Entry State', typeClass: 'Data' }) { }
+
+
+export const VOLSEG_STATE_FROM_ENTRY_TRANSFORMER_NAME = 'volseg-state-from-entry'; // defined here to avoid cyclic dependency

+ 181 - 0
src/extensions/volumes-and-segmentations/entry-volume.ts

@@ -0,0 +1,181 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { Vec2 } from '../../mol-math/linear-algebra';
+import { Volume } from '../../mol-model/volume';
+import { createVolumeRepresentationParams } from '../../mol-plugin-state/helpers/volume-representation-params';
+import { PluginStateObject } from '../../mol-plugin-state/objects';
+import { StateTransforms } from '../../mol-plugin-state/transforms';
+import { Download } from '../../mol-plugin-state/transforms/data';
+import { CreateGroup } from '../../mol-plugin-state/transforms/misc';
+import { setSubtreeVisibility } from '../../mol-plugin/behavior/static/state';
+import { PluginCommands } from '../../mol-plugin/commands';
+import { StateObjectSelector } from '../../mol-state';
+import { Color } from '../../mol-util/color';
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+
+import { BOX, VolsegEntryData, MAX_VOXELS } from './entry-root';
+import { VolsegStateParams, VolumeTypeChoice } from './entry-state';
+import * as ExternalAPIs from './external-api';
+import { VolsegGlobalStateData } from './global-state';
+
+
+const GROUP_TAG = 'volume-group';
+const VOLUME_VISUAL_TAG = 'volume-visual';
+
+const DIRECT_VOLUME_RELATIVE_PEAK_HALFWIDTH = 0.5;
+
+
+export type VolumeVisualParams = ReturnType<typeof createVolumeRepresentationParams>;
+
+interface VolumeStats { min: number, max: number, mean: number, sigma: number };
+
+
+export const SimpleVolumeParams = {
+    volumeType: VolumeTypeChoice.PDSelect(),
+    opacity: PD.Numeric(0.2, { min: 0, max: 1, step: 0.05 }, { hideIf: p => p.volumeType === 'off' }),
+};
+export type SimpleVolumeParamValues = PD.Values<typeof SimpleVolumeParams>;
+
+
+export class VolsegVolumeData {
+    private entryData: VolsegEntryData;
+    private visualTypeParamCache: { [type: string]: any } = {};
+
+    constructor(rootData: VolsegEntryData) {
+        this.entryData = rootData;
+    }
+
+    async loadVolume() {
+        const hasVolumes = this.entryData.metadata.raw.grid.volumes.volume_downsamplings.length > 0;
+        if (hasVolumes) {
+            const isoLevelPromise = ExternalAPIs.getIsovalue(this.entryData.metadata.raw.grid.general.source_db_id ?? this.entryData.entryId);
+            let group = this.entryData.findNodesByTags(GROUP_TAG)[0]?.transform.ref;
+            if (!group) {
+                const newGroupNode = await this.entryData.newUpdate().apply(CreateGroup, { label: 'Volume' }, { tags: [GROUP_TAG], state: { isCollapsed: true } }).commit();
+                group = newGroupNode.ref;
+            }
+            const url = this.entryData.api.volumeUrl(this.entryData.source, this.entryData.entryId, BOX, MAX_VOXELS);
+            const data = await this.entryData.newUpdate().to(group).apply(Download, { url, isBinary: true, label: `Volume Data: ${url}` }).commit();
+            const parsed = await this.entryData.plugin.dataFormats.get('dscif')!.parse(this.entryData.plugin, data);
+            const volumeNode: StateObjectSelector<PluginStateObject.Volume.Data> = parsed.volumes?.[0] ?? parsed.volume;
+            const volumeData = volumeNode.cell!.obj!.data;
+
+            const volumeType = VolsegStateParams.volumeType.defaultValue;
+            const isovalue = await isoLevelPromise;
+            const adjustedIsovalue = Volume.adjustedIsoValue(volumeData, isovalue.value, isovalue.kind);
+            const visualParams = this.createVolumeVisualParams(volumeData, volumeType);
+            this.changeIsovalueInVolumeVisualParams(visualParams, adjustedIsovalue, volumeData.grid.stats);
+
+            await this.entryData.newUpdate()
+                .to(volumeNode)
+                .apply(StateTransforms.Representation.VolumeRepresentation3D, visualParams, { tags: [VOLUME_VISUAL_TAG], state: { isHidden: volumeType === 'off' } })
+                .commit();
+            return { isovalue: adjustedIsovalue };
+        }
+    }
+
+    async setVolumeVisual(type: 'isosurface' | 'direct-volume' | 'off') {
+        const visual = this.entryData.findNodesByTags(VOLUME_VISUAL_TAG)[0];
+        if (!visual) return;
+        const oldParams: VolumeVisualParams = visual.transform.params;
+        this.visualTypeParamCache[oldParams.type.name] = oldParams.type.params;
+        if (type === 'off') {
+            setSubtreeVisibility(this.entryData.plugin.state.data, visual.transform.ref, true); // true means hide, ¯\_(ツ)_/¯
+        } else {
+            setSubtreeVisibility(this.entryData.plugin.state.data, visual.transform.ref, false); // true means hide, ¯\_(ツ)_/¯
+            if (oldParams.type.name === type) return;
+            const newParams: VolumeVisualParams = {
+                ...oldParams,
+                type: {
+                    name: type,
+                    params: this.visualTypeParamCache[type] ?? oldParams.type.params,
+                }
+            };
+            const volumeStats = visual.obj?.data.sourceData.grid.stats;
+            if (!volumeStats) throw new Error(`Cannot get volume stats from volume visual ${visual.transform.ref}`);
+            this.changeIsovalueInVolumeVisualParams(newParams, undefined, volumeStats);
+            const update = this.entryData.newUpdate().to(visual.transform.ref).update(newParams);
+            await PluginCommands.State.Update(this.entryData.plugin, { state: this.entryData.plugin.state.data, tree: update, options: { doNotUpdateCurrent: true } });
+        }
+    }
+
+    async updateVolumeVisual(newParams: SimpleVolumeParamValues) {
+        const { volumeType, opacity } = newParams;
+        const visual = this.entryData.findNodesByTags(VOLUME_VISUAL_TAG)[0];
+        if (!visual) return;
+        const oldVisualParams: VolumeVisualParams = visual.transform.params;
+        this.visualTypeParamCache[oldVisualParams.type.name] = oldVisualParams.type.params;
+
+        if (volumeType === 'off') {
+            setSubtreeVisibility(this.entryData.plugin.state.data, visual.transform.ref, true); // true means hide, ¯\_(ツ)_/¯
+        } else {
+            setSubtreeVisibility(this.entryData.plugin.state.data, visual.transform.ref, false); // true means hide, ¯\_(ツ)_/¯
+            const newVisualParams: VolumeVisualParams = {
+                ...oldVisualParams,
+                type: {
+                    name: volumeType,
+                    params: this.visualTypeParamCache[volumeType] ?? oldVisualParams.type.params,
+                }
+            };
+            newVisualParams.type.params.alpha = opacity;
+            const volumeStats = visual.obj?.data.sourceData.grid.stats;
+            if (!volumeStats) throw new Error(`Cannot get volume stats from volume visual ${visual.transform.ref}`);
+            this.changeIsovalueInVolumeVisualParams(newVisualParams, undefined, volumeStats);
+            const update = this.entryData.newUpdate().to(visual.transform.ref).update(newVisualParams);
+            await PluginCommands.State.Update(this.entryData.plugin, { state: this.entryData.plugin.state.data, tree: update, options: { doNotUpdateCurrent: true } });
+        }
+    }
+
+    async setTryUseGpu(tryUseGpu: boolean) {
+        const visuals = this.entryData.findNodesByTags(VOLUME_VISUAL_TAG);
+        for (const visual of visuals) {
+            const oldParams: VolumeVisualParams = visual.transform.params;
+            if (oldParams.type.params.tryUseGpu === !tryUseGpu) {
+                const newParams = { ...oldParams, type: { ...oldParams.type, params: { ...oldParams.type.params, tryUseGpu: tryUseGpu } } };
+                const update = this.entryData.newUpdate().to(visual.transform.ref).update(newParams);
+                await PluginCommands.State.Update(this.entryData.plugin, { state: this.entryData.plugin.state.data, tree: update, options: { doNotUpdateCurrent: true } });
+            }
+        }
+    }
+
+    private getIsovalueFromState(): Volume.IsoValue {
+        const { volumeIsovalueKind, volumeIsovalueValue } = this.entryData.currentState.value;
+        return volumeIsovalueKind === 'relative'
+            ? Volume.IsoValue.relative(volumeIsovalueValue)
+            : Volume.IsoValue.absolute(volumeIsovalueValue);
+    }
+
+    private createVolumeVisualParams(volume: Volume, type: 'isosurface' | 'direct-volume' | 'off'): VolumeVisualParams {
+        if (type === 'off') type = 'isosurface';
+        return createVolumeRepresentationParams(this.entryData.plugin, volume, {
+            type: type,
+            typeParams: { alpha: 0.2, tryUseGpu: VolsegGlobalStateData.getGlobalState(this.entryData.plugin)?.tryUseGpu },
+            color: 'uniform',
+            colorParams: { value: Color(0x121212) },
+        });
+    }
+
+    private changeIsovalueInVolumeVisualParams(params: VolumeVisualParams, isovalue: Volume.IsoValue | undefined, stats: VolumeStats) {
+        isovalue ??= this.getIsovalueFromState();
+        switch (params.type.name) {
+            case 'isosurface':
+                params.type.params.isoValue = isovalue;
+                params.type.params.tryUseGpu = VolsegGlobalStateData.getGlobalState(this.entryData.plugin)?.tryUseGpu;
+                break;
+            case 'direct-volume':
+                const absIso = Volume.IsoValue.toAbsolute(isovalue, stats).absoluteValue;
+                const fractIso = (absIso - stats.min) / (stats.max - stats.min);
+                const peakHalfwidth = DIRECT_VOLUME_RELATIVE_PEAK_HALFWIDTH * stats.sigma / (stats.max - stats.min);
+                params.type.params.controlPoints = [
+                    Vec2.create(Math.max(fractIso - peakHalfwidth, 0), 0),
+                    Vec2.create(fractIso, 1),
+                    Vec2.create(Math.min(fractIso + peakHalfwidth, 1), 0),
+                ];
+                break;
+        }
+    }
+}

+ 51 - 0
src/extensions/volumes-and-segmentations/external-api.ts

@@ -0,0 +1,51 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { splitEntryId } from './helpers';
+
+
+/** Try to get author-defined contour value for isosurface from EMDB API. Return relative value 1.0, if not applicable or fails.  */
+export async function getIsovalue(entryId: string): Promise<{ kind: 'absolute' | 'relative', value: number }> {
+    const split = splitEntryId(entryId);
+    if (split.source === 'emdb') {
+        try {
+            const response = await fetch(`https://www.ebi.ac.uk/emdb/api/entry/map/${split.entryNumber}`);
+            const json = await response.json();
+            const contours: any[] = json?.map?.contour_list?.contour;
+            if (contours && contours.length > 0) {
+                const theContour = contours.find(c => c.primary) || contours[0];
+                if (theContour.level === undefined) throw new Error('EMDB API response missing contour level.');
+                return { kind: 'absolute', value: theContour.level };
+            }
+        } catch {
+            // do nothing
+        }
+    }
+    return { kind: 'relative', value: 1.0 };
+}
+
+export async function getPdbIdsForEmdbEntry(entryId: string): Promise<string[]> {
+    const split = splitEntryId(entryId);
+    const result = [];
+    if (split.source === 'emdb') {
+        entryId = entryId.toUpperCase();
+        const apiUrl = `https://www.ebi.ac.uk/pdbe/api/emdb/entry/fitted/${entryId}`;
+        try {
+            const response = await fetch(apiUrl);
+            if (response.ok) {
+                const json = await response.json();
+                const jsonEntry = json[entryId] ?? [];
+                for (const record of jsonEntry) {
+                    const pdbs = record?.fitted_emdb_id_list?.pdb_id ?? [];
+                    result.push(...pdbs);
+                }
+            }
+        } catch (ex) {
+            // do nothing
+        }
+    }
+    return result;
+}

+ 65 - 0
src/extensions/volumes-and-segmentations/global-state.ts

@@ -0,0 +1,65 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { BehaviorSubject } from 'rxjs';
+import { PluginStateObject } from '../../mol-plugin-state/objects';
+import { PluginBehavior } from '../../mol-plugin/behavior';
+import { PluginContext } from '../../mol-plugin/context';
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import { VolsegEntry } from './entry-root';
+import { isDefined } from './helpers';
+
+
+export const VolsegGlobalStateParams = {
+    tryUseGpu: PD.Boolean(true, { description: 'Attempt using GPU for faster rendering. \nCaution: with some hardware setups, this might render some objects incorrectly or not at all.' }),
+    selectionMode: PD.Boolean(true, { description: 'Allow selecting/deselecting a segment by clicking on it.' }),
+};
+export type VolsegGlobalStateParamValues = PD.Values<typeof VolsegGlobalStateParams>;
+
+
+export class VolsegGlobalState extends PluginStateObject.CreateBehavior<VolsegGlobalStateData>({ name: 'Vol & Seg Global State' }) { }
+
+export class VolsegGlobalStateData extends PluginBehavior.WithSubscribers<VolsegGlobalStateParamValues> {
+    private ref: string;
+    currentState = new BehaviorSubject(PD.getDefaultValues(VolsegGlobalStateParams));
+
+    constructor(plugin: PluginContext, params: VolsegGlobalStateParamValues) {
+        super(plugin, params);
+        this.currentState.next(params);
+    }
+
+    register(ref: string) {
+        this.ref = ref;
+    }
+    unregister() {
+        this.ref = '';
+    }
+    isRegistered() {
+        return this.ref !== '';
+    }
+    async updateState(plugin: PluginContext, state: Partial<VolsegGlobalStateParamValues>) {
+        const oldState = this.currentState.value;
+
+        const promises = [];
+        const allEntries = plugin.state.data.selectQ(q => q.ofType(VolsegEntry)).map(cell => cell.obj?.data).filter(isDefined);
+        if (state.tryUseGpu !== undefined && state.tryUseGpu !== oldState.tryUseGpu) {
+            for (const entry of allEntries) {
+                promises.push(entry.setTryUseGpu(state.tryUseGpu));
+            }
+        }
+        if (state.selectionMode !== undefined && state.selectionMode !== oldState.selectionMode) {
+            for (const entry of allEntries) {
+                promises.push(entry.setSelectionMode(state.selectionMode));
+            }
+        }
+        await Promise.all(promises);
+        await plugin.build().to(this.ref).update(state).commit();
+    }
+
+    static getGlobalState(plugin: PluginContext): VolsegGlobalStateParamValues | undefined {
+        return plugin.state.data.selectQ(q => q.ofType(VolsegGlobalState))[0]?.obj?.data.currentState.value;
+    }
+}

+ 163 - 0
src/extensions/volumes-and-segmentations/helpers.ts

@@ -0,0 +1,163 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { Volume } from '../../mol-model/volume';
+import { PluginStateObject } from '../../mol-plugin-state/objects';
+import { setSubtreeVisibility } from '../../mol-plugin/behavior/static/state';
+import { StateBuilder, StateObjectSelector, StateTransformer } from '../../mol-state';
+import { ParamDefinition } from '../../mol-util/param-definition';
+import { Source } from './entry-root';
+
+
+/** Split entry ID (e.g. 'emd-1832') into source ('emdb') and number ('1832') */
+export function splitEntryId(entryId: string) {
+    const PREFIX_TO_SOURCE: { [prefix: string]: Source } = { 'emd': 'emdb' };
+    const [prefix, entry] = entryId.split('-');
+    return {
+        source: PREFIX_TO_SOURCE[prefix] ?? prefix,
+        entryNumber: entry
+    };
+}
+
+/** Create entry ID (e.g. 'emd-1832') for a combination of source ('emdb') and number ('1832') */
+export function createEntryId(source: Source, entryNumber: string | number) {
+    const SOURCE_TO_PREFIX: { [prefix: string]: string } = { 'emdb': 'emd' };
+    const prefix = SOURCE_TO_PREFIX[source] ?? source;
+    return `${prefix}-${entryNumber}`;
+}
+
+
+
+/**
+ * Represents a set of values to choose from, with a default value. Example:
+ * ```
+ * export const MyChoice = new Choice({ yes: 'I agree', no: 'Nope' }, 'yes');
+ * export type MyChoiceType = Choice.Values<typeof MyChoice>; // 'yes'|'no'
+ * ```
+ */
+export class Choice<T extends string, D extends T> {
+    readonly defaultValue: D;
+    readonly options: [T, string][];
+    private readonly nameDict: { [value in T]: string };
+    constructor(opts: { [value in T]: string }, defaultValue: D) {
+        this.defaultValue = defaultValue;
+        this.options = Object.keys(opts).map(k => [k as T, opts[k as T]]);
+        this.nameDict = opts;
+    }
+    PDSelect(defaultValue?: T, info?: ParamDefinition.Info): ParamDefinition.Select<T> {
+        return ParamDefinition.Select<T>(defaultValue ?? this.defaultValue, this.options, info);
+    }
+    prettyName(value: T): string {
+        return this.nameDict[value];
+    }
+    get values(): T[] {
+        return this.options.map(([value, pretty]) => value);
+    }
+}
+export namespace Choice {
+    export type Values<T extends Choice<any, any>> = T extends Choice<infer R, any> ? R : any;
+}
+
+
+export function isDefined<T>(x: T | undefined): x is T {
+    return x !== undefined;
+}
+
+
+export class NodeManager {
+    private nodes: { [key: string]: StateObjectSelector };
+
+    constructor() {
+        this.nodes = {};
+    }
+
+    private static nodeExists(node: StateObjectSelector): boolean {
+        try {
+            return node.checkValid();
+        } catch {
+            return false;
+        }
+    }
+
+    public getNode(key: string): StateObjectSelector | undefined {
+        const node = this.nodes[key];
+        if (node && !NodeManager.nodeExists(node)) {
+            delete this.nodes[key];
+            return undefined;
+        }
+        return node;
+    }
+
+    public getNodes(): StateObjectSelector[] {
+        return Object.keys(this.nodes).map(key => this.getNode(key)).filter(node => node) as StateObjectSelector[];
+    }
+
+    public deleteAllNodes(update: StateBuilder.Root) {
+        for (const node of this.getNodes()) {
+            update.delete(node);
+        }
+        this.nodes = {};
+    }
+
+    public hideAllNodes() {
+        for (const node of this.getNodes()) {
+            setSubtreeVisibility(node.state!, node.ref, true); // hide
+        }
+    }
+
+    public async showNode(key: string, factory: () => StateObjectSelector | Promise<StateObjectSelector>, forceVisible: boolean = true) {
+        let node = this.getNode(key);
+        if (node) {
+            if (forceVisible) {
+                setSubtreeVisibility(node.state!, node.ref, false); // show
+            }
+        } else {
+            node = await factory();
+            this.nodes[key] = node;
+        }
+        return node;
+    }
+}
+
+
+
+const CreateTransformer = StateTransformer.builderFactory('volseg');
+
+export const CreateVolume = CreateTransformer({
+    name: 'create-transformer',
+    from: PluginStateObject.Root,
+    to: PluginStateObject.Volume.Data,
+    params: {
+        label: ParamDefinition.Text('Volume', { isHidden: true }),
+        description: ParamDefinition.Text('', { isHidden: true }),
+        volume: ParamDefinition.Value<Volume>(undefined as any, { isHidden: true }),
+    }
+})({
+    apply({ params }) {
+        return new PluginStateObject.Volume.Data(params.volume, { label: params.label, description: params.description });
+    }
+});
+
+
+
+export function applyEllipsis(name: string, max_chars: number = 60) {
+    if (name.length <= max_chars) return name;
+    const beginning = name.substring(0, max_chars);
+    let lastSpace = beginning.lastIndexOf(' ');
+    if (lastSpace === -1) return beginning + '...';
+    if (lastSpace > 0 && ',;.'.includes(name.charAt(lastSpace - 1))) lastSpace--;
+    return name.substring(0, lastSpace) + '...';
+}
+
+
+export function lazyGetter<T>(getter: () => T, errorIfUndefined?: string) {
+    let value: T | undefined = undefined;
+    return () => {
+        if (value === undefined) value = getter();
+        if (errorIfUndefined && value === undefined) throw new Error(errorIfUndefined);
+        return value;
+    };
+}

+ 102 - 0
src/extensions/volumes-and-segmentations/index.ts

@@ -0,0 +1,102 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { PluginStateObject as SO } from '../../mol-plugin-state/objects';
+import { PluginBehavior } from '../../mol-plugin/behavior';
+import { PluginConfigItem } from '../../mol-plugin/config';
+import { PluginContext } from '../../mol-plugin/context';
+import { StateAction } from '../../mol-state';
+import { Task } from '../../mol-task';
+import { DEFAULT_VOLUME_SERVER_V2, VolumeApiV2 } from './volseg-api/api';
+
+import { VolsegEntryData, VolsegEntryParamValues, createLoadVolsegParams } from './entry-root';
+import { VolsegGlobalState } from './global-state';
+import { createEntryId } from './helpers';
+import { VolsegEntryFromRoot, VolsegGlobalStateFromRoot, VolsegStateFromEntry } from './transformers';
+import { VolsegUI } from './ui';
+
+
+const DEBUGGING = window.location.hostname === 'localhost';
+
+export const VolsegVolumeServerConfig = {
+    // DefaultServer: new PluginConfigItem('volseg-volume-server', DEFAULT_VOLUME_SERVER_V2),
+    DefaultServer: new PluginConfigItem('volseg-volume-server', DEBUGGING ? 'http://localhost:9000/v2' : DEFAULT_VOLUME_SERVER_V2),
+};
+
+
+export const Volseg = PluginBehavior.create<{ autoAttach: boolean, showTooltip: boolean }>({
+    name: 'volseg',
+    category: 'misc',
+    display: {
+        name: 'Volseg',
+        description: 'Volseg'
+    },
+    ctor: class extends PluginBehavior.Handler<{ autoAttach: boolean, showTooltip: boolean }> {
+        register() {
+            this.ctx.state.data.actions.add(LoadVolseg);
+            this.ctx.customStructureControls.set('volseg', VolsegUI as any);
+            this.initializeEntryLists(); // do not await
+
+            const entries = new Map<string, VolsegEntryData>();
+            this.subscribeObservable(this.ctx.state.data.events.cell.created, o => {
+                if (o.cell.obj instanceof VolsegEntryData) entries.set(o.ref, o.cell.obj);
+            });
+
+            this.subscribeObservable(this.ctx.state.data.events.cell.removed, o => {
+                if (entries.has(o.ref)) {
+                    entries.get(o.ref)!.dispose();
+                    entries.delete(o.ref);
+                }
+            });
+        }
+        unregister() {
+            this.ctx.state.data.actions.remove(LoadVolseg);
+            this.ctx.customStructureControls.delete('volseg');
+        }
+        private async initializeEntryLists() {
+            const apiUrl = this.ctx.config.get(VolsegVolumeServerConfig.DefaultServer) ?? DEFAULT_VOLUME_SERVER_V2;
+            const api = new VolumeApiV2(apiUrl);
+            const entryLists = await api.getEntryList(10 ** 6);
+            Object.values(entryLists).forEach(l => l.sort());
+            (this.ctx.customState as any).volsegAvailableEntries = entryLists;
+        }
+    }
+});
+
+
+export const LoadVolseg = StateAction.build({
+    display: { name: 'Load Volume & Segmentation' },
+    from: SO.Root,
+    params: (a, plugin: PluginContext) => {
+        const res = createLoadVolsegParams(plugin, (plugin.customState as any).volsegAvailableEntries);
+        return res;
+    },
+})(({ params, state }, ctx: PluginContext) => Task.create('Loading Volume & Segmentation', taskCtx => {
+    return state.transaction(async () => {
+        const entryParams = VolsegEntryParamValues.fromLoadVolsegParamValues(params);
+        if (entryParams.entryId.trim().length === 0) {
+            alert('Must specify Entry Id!');
+            throw new Error('Specify Entry Id');
+        }
+        if (!entryParams.entryId.includes('-')) {
+            // add source prefix if the user omitted it (e.g. 1832 -> emd-1832)
+            entryParams.entryId = createEntryId(entryParams.source, entryParams.entryId);
+        }
+        ctx.behaviors.layout.leftPanelTabName.next('data');
+
+        const globalStateNode = ctx.state.data.selectQ(q => q.ofType(VolsegGlobalState))[0];
+        if (!globalStateNode) {
+            await state.build().toRoot().apply(VolsegGlobalStateFromRoot, {}, { state: { isGhost: !DEBUGGING } }).commit();
+        }
+
+        const entryNode = await state.build().toRoot().apply(VolsegEntryFromRoot, entryParams).commit();
+        await state.build().to(entryNode).apply(VolsegStateFromEntry, {}, { state: { isGhost: !DEBUGGING } }).commit();
+        if (entryNode.data) {
+            await entryNode.data.loadVolume();
+            await entryNode.data.loadSegmentations();
+        }
+    }).runInContext(taskCtx);
+}));

+ 274 - 0
src/extensions/volumes-and-segmentations/lattice-segmentation.ts

@@ -0,0 +1,274 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { CIF, CifBlock } from '../../mol-io/reader/cif';
+import { Box3D } from '../../mol-math/geometry';
+import { Tensor, Vec3 } from '../../mol-math/linear-algebra';
+import { volumeFromDensityServerData } from '../../mol-model-formats/volume/density-server';
+import { CustomProperties } from '../../mol-model/custom-property';
+import { Grid, Volume } from '../../mol-model/volume';
+import { Segment } from './volseg-api/data';
+import { lazyGetter } from './helpers';
+
+
+export class LatticeSegmentation {
+    private segments: number[];
+    private sets: number[];
+    /** Maps setId to a set of segmentIds*/
+    private segmentMap: Map<number, Set<number>>; // computations with objects might be actually faster than with Maps and Sets?
+    /** Maps segmentId to a set of setIds*/
+    private inverseSegmentMap: Map<number, Set<number>>;
+    private grid: Grid;
+
+    private constructor(segmentationDataBlock: CifBlock, grid: Grid) {
+        const segmentationValues = segmentationDataBlock!.categories['segmentation_data_3d'].getField('values')?.toIntArray()!;
+        this.segmentMap = LatticeSegmentation.makeSegmentMap(segmentationDataBlock);
+        this.inverseSegmentMap = LatticeSegmentation.invertMultimap(this.segmentMap);
+        this.segments = Array.from(this.inverseSegmentMap.keys());
+        this.sets = Array.from(this.segmentMap.keys());
+        this.grid = grid;
+        this.grid.cells.data = Tensor.Data1(segmentationValues);
+    }
+
+    public static async fromCifBlock(segmentationDataBlock: CifBlock) {
+        const densityServerCif = CIF.schema.densityServer(segmentationDataBlock);
+        const volume = await volumeFromDensityServerData(densityServerCif).run();
+        const grid = volume.grid;
+        return new LatticeSegmentation(segmentationDataBlock, grid);
+    }
+
+    public createSegment_old(segId: number): Volume {
+        // console.time('createSegment_old');
+        const n = this.grid.cells.data.length;
+        const newData = new Float32Array(n);
+
+        for (let i = 0; i < n; i++) {
+            newData[i] = this.segmentMap.get(this.grid.cells.data[i])?.has(segId) ? 1 : 0;
+        }
+
+        const result: Volume = {
+            sourceData: { kind: 'custom', name: 'test', data: newData as any },
+            customProperties: new CustomProperties(),
+            _propertyData: {},
+            grid: {
+                ...this.grid,
+                // stats: { min: 0, max: 1, mean: newMean, sigma: arrayRms(newData) },
+                stats: { min: 0, max: 1, mean: 0, sigma: 1 },
+                cells: {
+                    ...this.grid.cells,
+                    data: newData as any,
+                }
+            }
+        };
+        // console.timeEnd('createSegment_old');
+        return result;
+    }
+
+    public hasSegment(segId: number): boolean {
+        return this.inverseSegmentMap.has(segId);
+    }
+    public createSegment(seg: Segment, propertyData?: {[key: string]: any}): Volume {
+        const { space, data }: Tensor = this.grid.cells;
+        const [nx, ny, nz] = space.dimensions;
+        const axisOrder = [...space.axisOrderSlowToFast];
+        const get = space.get;
+        const cell = Box.create(0, nx, 0, ny, 0, nz);
+
+        const EXPAND_START = 2; // We need to add 2 layers of zeros, probably because of a bug in GPU marching cubes implementation
+        const EXPAND_END = 1;
+        let bbox = this.getSegmentBoundingBoxes()[seg.id];
+        bbox = Box.expand(bbox, EXPAND_START, EXPAND_END);
+        bbox = Box.confine(bbox, cell);
+        const [ox, oy, oz] = Box.origin(bbox);
+        const [mx, my, mz] = Box.size(bbox);
+        // n, i refer to original box; m, j to the new box
+
+        const newSpace = Tensor.Space([mx, my, mz], axisOrder, Uint8Array);
+        const newTensor = Tensor.create(newSpace, newSpace.create());
+        const newData = newTensor.data;
+        const newSet = newSpace.set;
+
+        const sets = this.inverseSegmentMap.get(seg.id);
+        if (!sets) throw new Error(`This LatticeSegmentation does not contain segment ${seg.id}`);
+
+        for (let jz = 0; jz < mz; jz++) {
+            for (let jy = 0; jy < my; jy++) {
+                for (let jx = 0; jx < mx; jx++) {
+                    // Iterating in ZYX order is faster (probably fewer cache misses)
+                    const setId = get(data, ox + jx, oy + jy, oz + jz);
+                    const value = sets.has(setId) ? 1 : 0;
+                    newSet(newData, jx, jy, jz, value);
+                }
+            }
+        }
+
+        const transform = this.grid.transform;
+        let newTransform: Grid.Transform;
+        if (transform.kind === 'matrix') {
+            throw new Error('Not implemented for transform of kind "matrix"'); // TODO ask if this is really needed
+        } else if (transform.kind === 'spacegroup') {
+            const newFractionalBox = Box.toFractional(bbox, cell);
+            const origFractSize = Vec3.sub(Vec3.zero(), transform.fractionalBox.max, transform.fractionalBox.min);
+            Vec3.mul(newFractionalBox.min, newFractionalBox.min, origFractSize);
+            Vec3.mul(newFractionalBox.max, newFractionalBox.max, origFractSize);
+            Vec3.add(newFractionalBox.min, newFractionalBox.min, transform.fractionalBox.min);
+            Vec3.add(newFractionalBox.max, newFractionalBox.max, transform.fractionalBox.min);
+            newTransform = { ...transform, fractionalBox: newFractionalBox };
+        } else {
+            throw new Error(`Unknown transform kind: ${transform}`);
+        }
+        const result = {
+            sourceData: { kind: 'custom', name: 'test', data: newTensor.data as any },
+            label: seg.biological_annotation.name ?? `Segment ${seg.id}`,
+            customProperties: new CustomProperties(),
+            _propertyData: propertyData ?? {},
+            grid: {
+                stats: { min: 0, max: 1, mean: 0, sigma: 1 },
+                cells: newTensor,
+                transform: newTransform,
+            }
+        } as Volume;
+        return result;
+    }
+
+    private static _getSegmentBoundingBoxes(self: LatticeSegmentation) {
+        const { space, data }: Tensor = self.grid.cells;
+        const [nx, ny, nz] = space.dimensions;
+        const get = space.get;
+
+        const setBoxes: { [setId: number]: Box } = {}; // with object this is faster than with Map
+        self.sets.forEach(setId => setBoxes[setId] = Box.create(nx, -1, ny, -1, nz, -1));
+
+        for (let iz = 0; iz < nz; iz++) {
+            for (let iy = 0; iy < ny; iy++) {
+                for (let ix = 0; ix < nx; ix++) {
+                    // Iterating in ZYX order is faster (probably fewer cache misses)
+                    const setId = get(data, ix, iy, iz);
+                    Box.addPoint_InclusiveEnd(setBoxes[setId], ix, iy, iz);
+                }
+            }
+        }
+
+        const segmentBoxes: { [segmentId: number]: Box } = {};
+        self.segments.forEach(segmentId => segmentBoxes[segmentId] = Box.create(nx, -1, ny, -1, nz, -1));
+        self.inverseSegmentMap.forEach((setIds, segmentId) => {
+            setIds.forEach(setId => {
+                segmentBoxes[segmentId] = Box.cover(segmentBoxes[segmentId], setBoxes[setId]);
+            });
+        });
+
+        for (const segmentId in segmentBoxes) {
+            if (segmentBoxes[segmentId][5] === -1) { // segment's box left unchanged -> contains no voxels
+                segmentBoxes[segmentId] = Box.create(0, 1, 0, 1, 0, 1);
+            } else {
+                segmentBoxes[segmentId] = Box.expand(segmentBoxes[segmentId], 0, 1); // inclusive end -> exclusive end
+            }
+        }
+        return segmentBoxes;
+    }
+    private getSegmentBoundingBoxes = lazyGetter(() => LatticeSegmentation._getSegmentBoundingBoxes(this));
+
+    private static invertMultimap<K, V>(map: Map<K, Set<V>>): Map<V, Set<K>> {
+        const inverted = new Map<V, Set<K>>();
+        map.forEach((values, key) => {
+            values.forEach(value => {
+                if (!inverted.has(value)) inverted.set(value, new Set<K>());
+                inverted.get(value)?.add(key);
+            });
+        });
+        return inverted;
+    }
+
+    private static makeSegmentMap(segmentationDataBlock: CifBlock): Map<number, Set<number>> {
+        const setId = segmentationDataBlock.categories['segmentation_data_table'].getField('set_id')?.toIntArray()!;
+        const segmentId = segmentationDataBlock.categories['segmentation_data_table'].getField('segment_id')?.toIntArray()!;
+        const map = new Map<number, Set<number>>();
+        for (let i = 0; i < segmentId.length; i++) {
+            if (!map.has(setId[i])) {
+                map.set(setId[i], new Set());
+            }
+            map.get(setId[i])!.add(segmentId[i]);
+        }
+        return map;
+    }
+
+    public benchmark(segment: Segment) {
+        const N = 100;
+
+        console.time(`createSegment ${segment.id} ${N}x`);
+        for (let i = 0; i < N; i++) {
+            this.getSegmentBoundingBoxes = lazyGetter(() => LatticeSegmentation._getSegmentBoundingBoxes(this));
+            this.createSegment(segment);
+        }
+        console.timeEnd(`createSegment ${segment.id} ${N}x`);
+    }
+}
+
+
+type Box = [number, number, number, number, number, number];
+
+/** Represents a 3D box in integer coordinates. xFrom... is inclusive, xTo... is exclusive. */
+namespace Box {
+    export function create(xFrom: number, xTo: number, yFrom: number, yTo: number, zFrom: number, zTo: number): Box {
+        return [xFrom, xTo, yFrom, yTo, zFrom, zTo];
+    }
+    export function expand(box: Box, expandFrom: number, expandTo: number): Box {
+        const [xFrom, xTo, yFrom, yTo, zFrom, zTo] = box;
+        return [xFrom - expandFrom, xTo + expandTo, yFrom - expandFrom, yTo + expandTo, zFrom - expandFrom, zTo + expandTo];
+    }
+    export function confine(box1: Box, box2: Box): Box {
+        const [xFrom1, xTo1, yFrom1, yTo1, zFrom1, zTo1] = box1;
+        const [xFrom2, xTo2, yFrom2, yTo2, zFrom2, zTo2] = box2;
+        return [
+            Math.max(xFrom1, xFrom2), Math.min(xTo1, xTo2),
+            Math.max(yFrom1, yFrom2), Math.min(yTo1, yTo2),
+            Math.max(zFrom1, zFrom2), Math.min(zTo1, zTo2)
+        ];
+    }
+    export function cover(box1: Box, box2: Box): Box {
+        const [xFrom1, xTo1, yFrom1, yTo1, zFrom1, zTo1] = box1;
+        const [xFrom2, xTo2, yFrom2, yTo2, zFrom2, zTo2] = box2;
+        return [
+            Math.min(xFrom1, xFrom2), Math.max(xTo1, xTo2),
+            Math.min(yFrom1, yFrom2), Math.max(yTo1, yTo2),
+            Math.min(zFrom1, zFrom2), Math.max(zTo1, zTo2)
+        ];
+    }
+    export function size(box: Box): [number, number, number] {
+        const [xFrom, xTo, yFrom, yTo, zFrom, zTo] = box;
+        return [xTo - xFrom, yTo - yFrom, zTo - zFrom];
+    }
+    export function origin(box: Box): [number, number, number] {
+        const xFrom = box[0];
+        const yFrom = box[2];
+        const zFrom = box[4];
+        return [xFrom, yFrom, zFrom];
+    }
+    export function log(name: string, box: Box): void {
+        const [xFrom, xTo, yFrom, yTo, zFrom, zTo] = box;
+        console.log(`Box ${name}: [${xFrom}:${xTo}, ${yFrom}:${yTo}, ${zFrom}:${zTo}], size: ${size(box)}`);
+    }
+    export function toFractional(box: Box, relativeTo: Box): Box3D {
+        const [xFrom, xTo, yFrom, yTo, zFrom, zTo] = box;
+        const [x0, y0, z0] = origin(relativeTo);
+        const [sizeX, sizeY, sizeZ] = size(relativeTo);
+        const min = Vec3.create((xFrom - x0) / sizeX, (yFrom - y0) / sizeY, (zFrom - z0) / sizeZ);
+        const max = Vec3.create((xTo - x0) / sizeX, (yTo - y0) / sizeY, (zTo - z0) / sizeZ);
+        return Box3D.create(min, max);
+    }
+    export function addPoint_InclusiveEnd(box: Box, x: number, y: number, z: number): void {
+        if (x < box[0]) box[0] = x;
+        if (x > box[1]) box[1] = x;
+        if (y < box[2]) box[2] = y;
+        if (y > box[3]) box[3] = y;
+        if (z < box[4]) box[4] = z;
+        if (z > box[5]) box[5] = z;
+    }
+    export function equal(box1: Box, box2: Box): boolean {
+        return box1.every((value, i) => value === box2[i]);
+    }
+}
+

+ 70 - 0
src/extensions/volumes-and-segmentations/transformers.ts

@@ -0,0 +1,70 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { PluginStateObject, PluginStateTransform } from '../../mol-plugin-state/objects';
+import { PluginContext } from '../../mol-plugin/context';
+import { StateTransformer } from '../../mol-state';
+import { Task } from '../../mol-task';
+
+import { VolsegEntry, VolsegEntryData, createVolsegEntryParams } from './entry-root';
+import { VolsegState, VolsegStateParams, VOLSEG_STATE_FROM_ENTRY_TRANSFORMER_NAME } from './entry-state';
+import { VolsegGlobalState, VolsegGlobalStateData, VolsegGlobalStateParams } from './global-state';
+
+
+export const VolsegEntryFromRoot = PluginStateTransform.BuiltIn({
+    name: 'volseg-entry-from-root',
+    display: { name: 'Vol & Seg Entry', description: 'Vol & Seg Entry' },
+    from: PluginStateObject.Root,
+    to: VolsegEntry,
+    params: (a, plugin: PluginContext) => createVolsegEntryParams(plugin),
+})({
+    apply({ a, params }, plugin: PluginContext) {
+        return Task.create('Load Vol & Seg Entry', async () => {
+            const data = await VolsegEntryData.create(plugin, params);
+            return new VolsegEntry(data, { label: data.entryId, description: 'Vol & Seg Entry' });
+        });
+    },
+    update({ b, oldParams, newParams }) {
+        Object.assign(newParams, oldParams);
+        console.error('Changing params of existing VolsegEntry node is not allowed');
+        return StateTransformer.UpdateResult.Unchanged;
+    }
+});
+
+
+export const VolsegStateFromEntry = PluginStateTransform.BuiltIn({
+    name: VOLSEG_STATE_FROM_ENTRY_TRANSFORMER_NAME,
+    display: { name: 'Vol & Seg Entry State', description: 'Vol & Seg Entry State' },
+    from: VolsegEntry,
+    to: VolsegState,
+    params: VolsegStateParams,
+})({
+    apply({ a, params }, plugin: PluginContext) {
+        return Task.create('Create Vol & Seg Entry State', async () => {
+            return new VolsegState(params, { label: 'State' });
+        });
+    }
+});
+
+
+export const VolsegGlobalStateFromRoot = PluginStateTransform.BuiltIn({
+    name: 'volseg-global-state-from-root',
+    display: { name: 'Vol & Seg Global State', description: 'Vol & Seg Global State' },
+    from: PluginStateObject.Root,
+    to: VolsegGlobalState,
+    params: VolsegGlobalStateParams,
+})({
+    apply({ a, params }, plugin: PluginContext) {
+        return Task.create('Create Vol & Seg Global State', async () => {
+            const data = new VolsegGlobalStateData(plugin, params);
+            return new VolsegGlobalState(data, { label: 'Global State', description: 'Vol & Seg Global State' });
+        });
+    },
+    update({ b, oldParams, newParams }) {
+        b.data.currentState.next(newParams);
+        return StateTransformer.UpdateResult.Updated;
+    }
+});

+ 254 - 0
src/extensions/volumes-and-segmentations/ui.tsx

@@ -0,0 +1,254 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { CollapsableControls, CollapsableState } from '../../mol-plugin-ui/base';
+import { Button, ControlRow, ExpandGroup, IconButton } from '../../mol-plugin-ui/controls/common';
+import * as Icons from '../../mol-plugin-ui/controls/icons';
+import { ParameterControls } from '../../mol-plugin-ui/controls/parameters';
+import { Slider } from '../../mol-plugin-ui/controls/slider';
+import { useBehavior } from '../../mol-plugin-ui/hooks/use-behavior';
+import { PluginContext } from '../../mol-plugin/context';
+import { shallowEqualArrays } from '../../mol-util';
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import { sleep } from '../../mol-util/sleep';
+
+import { VolsegEntry, VolsegEntryData } from './entry-root';
+import { SimpleVolumeParams, SimpleVolumeParamValues } from './entry-volume';
+import { VolsegGlobalState, VolsegGlobalStateData, VolsegGlobalStateParams } from './global-state';
+import { isDefined } from './helpers';
+
+
+interface VolsegUIData {
+    globalState?: VolsegGlobalStateData,
+    availableNodes: VolsegEntry[],
+    activeNode?: VolsegEntry,
+}
+namespace VolsegUIData {
+    export function changeAvailableNodes(data: VolsegUIData, newNodes: VolsegEntry[]): VolsegUIData {
+        const newActiveNode = newNodes.length > data.availableNodes.length ?
+            newNodes[newNodes.length - 1]
+            : newNodes.find(node => node.data.ref === data.activeNode?.data.ref) ?? newNodes[0];
+        return { ...data, availableNodes: newNodes, activeNode: newActiveNode };
+    }
+    export function changeActiveNode(data: VolsegUIData, newActiveRef: string): VolsegUIData {
+        const newActiveNode = data.availableNodes.find(node => node.data.ref === newActiveRef) ?? data.availableNodes[0];
+        return { ...data, availableNodes: data.availableNodes, activeNode: newActiveNode };
+    }
+    export function equals(data1: VolsegUIData, data2: VolsegUIData) {
+        return shallowEqualArrays(data1.availableNodes, data2.availableNodes) && data1.activeNode === data2.activeNode && data1.globalState === data2.globalState;
+    }
+}
+
+export class VolsegUI extends CollapsableControls<{}, { data: VolsegUIData }> {
+    protected defaultState(): CollapsableState & { data: VolsegUIData } {
+        return {
+            header: 'Volume & Segmentation',
+            isCollapsed: true,
+            brand: { accent: 'orange', svg: Icons.ExtensionSvg },
+            data: {
+                globalState: undefined,
+                availableNodes: [],
+                activeNode: undefined,
+            }
+        };
+    }
+    protected renderControls(): JSX.Element | null {
+        return <VolsegControls plugin={this.plugin} data={this.state.data} setData={d => this.setState({ data: d })} />;
+    }
+    componentDidMount(): void {
+        this.setState({ isHidden: true, isCollapsed: false });
+        this.subscribe(this.plugin.state.data.events.changed, e => {
+            const nodes = e.state.selectQ(q => q.ofType(VolsegEntry)).map(cell => cell?.obj).filter(isDefined);
+            const isHidden = nodes.length === 0;
+            const newData = VolsegUIData.changeAvailableNodes(this.state.data, nodes);
+            if (!this.state.data.globalState?.isRegistered()) {
+                const globalState = e.state.selectQ(q => q.ofType(VolsegGlobalState))[0]?.obj?.data;
+                if (globalState) newData.globalState = globalState;
+            }
+            if (!VolsegUIData.equals(this.state.data, newData) || this.state.isHidden !== isHidden) {
+                this.setState({ data: newData, isHidden: isHidden });
+            }
+        });
+    }
+}
+
+
+function VolsegControls({ plugin, data, setData }: { plugin: PluginContext, data: VolsegUIData, setData: (d: VolsegUIData) => void }) {
+    const entryData = data.activeNode?.data;
+    if (!entryData) {
+        return <p>No data!</p>;
+    }
+    if (!data.globalState) {
+        return <p>No global state!</p>;
+    }
+
+    const params = {
+        /** Reference to the active VolsegEntry node */
+        entry: PD.Select(data.activeNode!.data.ref, data.availableNodes.map(entry => [entry.data.ref, entry.data.entryId]))
+    };
+    const values: PD.Values<typeof params> = {
+        entry: data.activeNode!.data.ref,
+    };
+
+    const globalState = useBehavior(data.globalState.currentState);
+
+    return <>
+        <ParameterControls params={params} values={values} onChangeValues={next => setData(VolsegUIData.changeActiveNode(data, next.entry))} />
+
+        <ExpandGroup header='Global options'>
+            <WaitingParameterControls params={VolsegGlobalStateParams} values={globalState} onChangeValues={async next => await data.globalState?.updateState(plugin, next)} />
+        </ExpandGroup>
+
+        <VolsegEntryControls entryData={entryData} key={entryData.ref} />
+    </>;
+}
+
+function VolsegEntryControls({ entryData }: { entryData: VolsegEntryData }) {
+    const state = useBehavior(entryData.currentState);
+
+    const allSegments = entryData.metadata.allSegments;
+    const selectedSegment = entryData.metadata.getSegment(state.selectedSegment);
+    const visibleSegments = state.visibleSegments.map(seg => seg.segmentId);
+    const visibleModels = state.visibleModels.map(model => model.pdbId);
+    const allPdbs = entryData.pdbs;
+
+    const volumeValues: SimpleVolumeParamValues = {
+        volumeType: state.volumeType,
+        opacity: state.volumeOpacity,
+    };
+
+    return <>
+        {/* Title */}
+        <div style={{ fontWeight: 'bold', padding: 8, paddingTop: 6, paddingBottom: 4, overflow: 'hidden' }}>
+            {entryData.metadata.raw.annotation?.name ?? 'Unnamed Annotation'}
+        </div>
+
+        {/* Fitted models */}
+        {allPdbs.length > 0 && <ExpandGroup header='Fitted models in PDB' initiallyExpanded>
+            {allPdbs.map(pdb =>
+                <WaitingButton key={pdb} onClick={() => entryData.actionShowFittedModel(visibleModels.includes(pdb) ? [] : [pdb])}
+                    style={{ fontWeight: visibleModels.includes(pdb) ? 'bold' : undefined, textAlign: 'left', marginTop: 1 }}>
+                    {pdb}
+                </WaitingButton>
+            )}
+        </ExpandGroup>}
+
+        {/* Volume */}
+        <ExpandGroup header='Volume data' initiallyExpanded>
+            <WaitingParameterControls params={SimpleVolumeParams} values={volumeValues} onChangeValues={async next => { await sleep(20); await entryData.actionUpdateVolumeVisual(next); }} />
+        </ExpandGroup>
+
+        <ExpandGroup header='Segmentation data' initiallyExpanded>
+            {/* Segment opacity slider */}
+            <ControlRow label='Opacity' control={
+                <WaitingSlider min={0} max={1} value={state.segmentOpacity} step={0.05} onChange={async v => await entryData.actionSetOpacity(v)} />
+            } />
+
+            {/* Segment toggles */}
+            {allSegments.length > 0 && <>
+                <WaitingButton onClick={async () => { await sleep(20); await entryData.actionToggleAllSegments(); }} style={{ marginTop: 1 }}>
+                    Toggle All segments
+                </WaitingButton>
+                <div style={{ maxHeight: 200, overflow: 'hidden', overflowY: 'auto', marginBlock: 1 }}>
+                    {allSegments.map(segment =>
+                        <div style={{ display: 'flex', marginBottom: 1 }} key={segment.id}
+                            onMouseEnter={() => entryData.actionHighlightSegment(segment)}
+                            onMouseLeave={() => entryData.actionHighlightSegment()}>
+                            <Button onClick={() => entryData.actionSelectSegment(segment !== selectedSegment ? segment.id : undefined)}
+                                style={{ fontWeight: segment.id === selectedSegment?.id ? 'bold' : undefined, marginRight: 1, flexGrow: 1, textAlign: 'left' }}>
+                                <div title={segment.biological_annotation.name ?? 'Unnamed segment'} style={{ maxWidth: 240, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
+                                    {segment.biological_annotation.name ?? 'Unnamed segment'} ({segment.id})
+                                </div>
+                            </Button>
+                            <IconButton svg={visibleSegments.includes(segment.id) ? Icons.VisibilityOutlinedSvg : Icons.VisibilityOffOutlinedSvg}
+                                onClick={() => entryData.actionToggleSegment(segment.id)} />
+                        </div>
+                    )}
+                </div>
+            </>}
+        </ExpandGroup>
+
+        {/* Segment annotations */}
+        <ExpandGroup header='Selected segment annotation' initiallyExpanded>
+            <div style={{ paddingTop: 4, paddingRight: 8, maxHeight: 300, overflow: 'hidden', overflowY: 'auto' }}>
+                {!selectedSegment && 'No segment selected'}
+                {selectedSegment && <b>Segment {selectedSegment.id}:<br />{selectedSegment.biological_annotation.name ?? 'Unnamed segment'}</b>}
+                {selectedSegment?.biological_annotation.external_references.map(ref =>
+                    <p key={ref.id} style={{ marginTop: 4 }}>
+                        <small>{ref.resource}:{ref.accession}</small><br />
+                        <b>{capitalize(ref.label)}</b><br />
+                        {ref.description}
+                    </p>)}
+            </div>
+        </ExpandGroup>
+    </>;
+}
+
+type ComponentParams<T extends React.Component<any, any, any> | ((props: any) => JSX.Element)> =
+    T extends React.Component<infer P, any, any> ? P : T extends (props: infer P) => JSX.Element ? P : never;
+
+function WaitingSlider({ value, onChange, ...etc }: { value: number, onChange: (value: number) => any } & ComponentParams<Slider>) {
+    const [changing, sliderValue, execute] = useAsyncChange(value);
+
+    return <Slider value={sliderValue} disabled={changing} onChange={newValue => execute(onChange, newValue)} {...etc} />;
+}
+
+function WaitingButton({ onClick, ...etc }: { onClick: () => any } & ComponentParams<typeof Button>) {
+    const [changing, _, execute] = useAsyncChange(undefined);
+
+    return <Button disabled={changing} onClick={() => execute(onClick, undefined)} {...etc}>
+        {etc.children}
+    </Button>;
+}
+
+function WaitingParameterControls<T extends PD.Params>({ values, onChangeValues, ...etc }: { values: PD.ValuesFor<T>, onChangeValues: (values: PD.ValuesFor<T>) => any } & ComponentParams<ParameterControls<T>>) {
+    const [changing, currentValues, execute] = useAsyncChange(values);
+
+    return <ParameterControls isDisabled={changing} values={currentValues} onChangeValues={newValue => execute(onChangeValues, newValue)} {...etc} />;
+}
+
+function capitalize(text: string) {
+    const first = text.charAt(0);
+    const rest = text.slice(1);
+    return first.toUpperCase() + rest;
+}
+
+function useAsyncChange<T>(initialValue: T) {
+    const [isExecuting, setIsExecuting] = useState(false);
+    const [value, setValue] = useState(initialValue);
+    const isMounted = useRef(false);
+
+    useEffect(() => setValue(initialValue), [initialValue]);
+
+    useEffect(() => {
+        isMounted.current = true;
+        return () => { isMounted.current = false; };
+    }, []);
+
+    const execute = useCallback(
+        async (func: (val: T) => Promise<any>, val: T) => {
+            setIsExecuting(true);
+            setValue(val);
+            try {
+                await func(val);
+            } catch (err) {
+                if (isMounted.current) {
+                    setValue(initialValue);
+                }
+                throw err;
+            } finally {
+                if (isMounted.current) {
+                    setIsExecuting(false);
+                }
+            }
+        },
+        []
+    );
+
+    return [isExecuting, value, execute] as const;
+}

+ 65 - 0
src/extensions/volumes-and-segmentations/volseg-api/api.ts

@@ -0,0 +1,65 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { type Metadata } from './data';
+
+
+export const DEFAULT_VOLUME_SERVER_V2 = 'https://molstarvolseg.ncbr.muni.cz/v2';
+
+
+export class VolumeApiV2 {
+    public volumeServerUrl: string;
+
+    public constructor(volumeServerUrl: string = DEFAULT_VOLUME_SERVER_V2) {
+        this.volumeServerUrl = volumeServerUrl.replace(/\/$/, ''); // trim trailing slash
+    }
+
+    public entryListUrl(maxEntries: number, keyword?: string): string {
+        return `${this.volumeServerUrl}/list_entries/${maxEntries}/${keyword ?? ''}`;
+    }
+
+    public metadataUrl(source: string, entryId: string): string {
+        return `${this.volumeServerUrl}/${source}/${entryId}/metadata`;
+    }
+    public volumeUrl(source: string, entryId: string, box: [[number, number, number], [number, number, number]] | null, maxPoints: number): string {
+        if (box) {
+            const [[a1, a2, a3], [b1, b2, b3]] = box;
+            return `${this.volumeServerUrl}/${source}/${entryId}/volume/box/${a1}/${a2}/${a3}/${b1}/${b2}/${b3}?max_points=${maxPoints}`;
+        } else {
+            return `${this.volumeServerUrl}/${source}/${entryId}/volume/cell?max_points=${maxPoints}`;
+        }
+    }
+    public latticeUrl(source: string, entryId: string, segmentation: number, box: [[number, number, number], [number, number, number]] | null, maxPoints: number): string {
+        if (box) {
+            const [[a1, a2, a3], [b1, b2, b3]] = box;
+            return `${this.volumeServerUrl}/${source}/${entryId}/segmentation/box/${segmentation}/${a1}/${a2}/${a3}/${b1}/${b2}/${b3}?max_points=${maxPoints}`;
+        } else {
+            return `${this.volumeServerUrl}/${source}/${entryId}/segmentation/cell/${segmentation}?max_points=${maxPoints}`;
+        }
+    }
+    public meshUrl_Json(source: string, entryId: string, segment: number, detailLevel: number): string {
+        return `${this.volumeServerUrl}/${source}/${entryId}/mesh/${segment}/${detailLevel}`;
+    }
+
+    public meshUrl_Bcif(source: string, entryId: string, segment: number, detailLevel: number): string {
+        return `${this.volumeServerUrl}/${source}/${entryId}/mesh_bcif/${segment}/${detailLevel}`;
+    }
+    public volumeInfoUrl(source: string, entryId: string): string {
+        return `${this.volumeServerUrl}/${source}/${entryId}/volume_info`;
+    }
+
+    public async getEntryList(maxEntries: number, keyword?: string): Promise<{ [source: string]: string[] }> {
+        const response = await fetch(this.entryListUrl(maxEntries, keyword));
+        return await response.json();
+    }
+
+    public async getMetadata(source: string, entryId: string): Promise<Metadata> {
+        const url = this.metadataUrl(source, entryId);
+        const response = await fetch(url);
+        if (!response.ok) throw new Error(`Failed to fetch metadata from ${url}`);
+        return await response.json();
+    }
+}

+ 83 - 0
src/extensions/volumes-and-segmentations/volseg-api/data.ts

@@ -0,0 +1,83 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+export interface Metadata {
+    grid: {
+        general: {
+            details: string,
+            source_db_name: string,
+            source_db_id: string,
+        },
+        volumes: Volumes,
+        segmentation_lattices: SegmentationLattices,
+        segmentation_meshes: SegmentationMeshes,
+    },
+    annotation: Annotation | null,
+}
+
+export interface Volumes {
+    volume_downsamplings: number[],
+    voxel_size: { [downsampling: number]: Vector3 },
+    origin: Vector3,
+    grid_dimensions: Vector3,
+    sampled_grid_dimensions: { [downsampling: number]: Vector3 },
+    mean: { [downsampling: number]: number },
+    std: { [downsampling: number]: number },
+    min: { [downsampling: number]: number },
+    max: { [downsampling: number]: number },
+    volume_force_dtype: string,
+}
+
+export interface SegmentationLattices {
+    segmentation_lattice_ids: number[],
+    segmentation_downsamplings: { [lattice: number]: number[] },
+}
+
+export interface SegmentationMeshes {
+    mesh_component_numbers: {
+        segment_ids?: {
+            [segId: number]: {
+                detail_lvls: {
+                    [detail: number]: {
+                        mesh_ids: {
+                            [meshId: number]: {
+                                num_triangles: number,
+                                num_vertices: number
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+    detail_lvl_to_fraction: {
+        [lvl: number]: number
+    }
+}
+
+export interface Annotation {
+    name: string,
+    details: string,
+    segment_list: Segment[],
+}
+
+export interface Segment {
+    id: number,
+    colour: number[],
+    biological_annotation: BiologicalAnnotation,
+}
+
+export interface BiologicalAnnotation {
+    name: string,
+    external_references: ExternalReference[]
+}
+
+export interface ExternalReference {
+    id: number, resource: string, accession: string, label: string,
+    description: string
+}
+
+type Vector3 = [number, number, number];

+ 68 - 0
src/extensions/volumes-and-segmentations/volseg-api/utils.ts

@@ -0,0 +1,68 @@
+/**
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { Metadata, Segment } from './data';
+
+
+export class MetadataWrapper {
+    raw: Metadata;
+    private segmentMap?: { [id: number]: Segment };
+
+    constructor(rawMetadata: Metadata) {
+        this.raw = rawMetadata;
+    }
+
+    get allSegments() {
+        return this.raw.annotation?.segment_list ?? [];
+    }
+
+    get allSegmentIds() {
+        return this.allSegments.map(segment => segment.id);
+    }
+
+    getSegment(segmentId: number): Segment | undefined {
+        if (!this.segmentMap) {
+            this.segmentMap = {};
+            for (const segment of this.allSegments) {
+                this.segmentMap[segment.id] = segment;
+            }
+        }
+        return this.segmentMap[segmentId];
+    }
+
+    /** Get the list of detail levels available for the given mesh segment. */
+    getMeshDetailLevels(segmentId: number): number[] {
+        const segmentIds = this.raw.grid.segmentation_meshes.mesh_component_numbers.segment_ids;
+        if (!segmentIds) return [];
+        const details = segmentIds[segmentId].detail_lvls;
+        return Object.keys(details).map(s => parseInt(s));
+    }
+
+    /** Get the worst available detail level that is not worse than preferredDetail.
+     * If preferredDetail is null, get the worst detail level overall.
+     * (worse = greater number) */
+    getSufficientMeshDetail(segmentId: number, preferredDetail: number | null) {
+        let availDetails = this.getMeshDetailLevels(segmentId);
+        if (preferredDetail !== null) {
+            availDetails = availDetails.filter(det => det <= preferredDetail);
+        }
+        return Math.max(...availDetails);
+    }
+
+    /** IDs of all segments available as meshes */
+    get meshSegmentIds() {
+        const segmentIds = this.raw.grid.segmentation_meshes.mesh_component_numbers.segment_ids;
+        if (!segmentIds) return [];
+        return Object.keys(segmentIds).map(s => parseInt(s));
+    }
+
+    get gridTotalVolume() {
+        const [vx, vy, vz] = this.raw.grid.volumes.voxel_size[1];
+        const [gx, gy, gz] = this.raw.grid.volumes.grid_dimensions;
+        return vx * vy * vz * gx * gy * gz;
+    }
+
+}

+ 3 - 1
src/mol-io/reader/cif.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2017-2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2017-2022 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>
@@ -15,6 +15,7 @@ import { BIRD_Schema, BIRD_Database } from './cif/schema/bird';
 import { dic_Schema, dic_Database } from './cif/schema/dic';
 import { DensityServer_Data_Schema, DensityServer_Data_Database } from './cif/schema/density-server';
 import { CifCore_Database, CifCore_Schema, CifCore_Aliases } from './cif/schema/cif-core';
+import { Segmentation_Data_Database, Segmentation_Data_Schema } from './cif/schema/segmentation';
 
 export const CIF = {
     parse: (data: string|Uint8Array) => typeof data === 'string' ? parseCifText(data) : parseCifBinary(data),
@@ -29,6 +30,7 @@ export const CIF = {
         dic: (frame: CifFrame) => toDatabase<dic_Schema, dic_Database>(dic_Schema, frame),
         cifCore: (frame: CifFrame) => toDatabase<CifCore_Schema, CifCore_Database>(CifCore_Schema, frame, CifCore_Aliases),
         densityServer: (frame: CifFrame) => toDatabase<DensityServer_Data_Schema, DensityServer_Data_Database>(DensityServer_Data_Schema, frame),
+        segmentation: (frame: CifFrame) => toDatabase<Segmentation_Data_Schema, Segmentation_Data_Database>(Segmentation_Data_Schema, frame),
     }
 };
 

+ 26 - 0
src/mol-io/reader/cif/schema/segmentation.ts

@@ -0,0 +1,26 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { Column, Database } from '../../../../mol-data/db';
+import { DensityServer_Data_Schema } from './density-server';
+
+import Schema = Column.Schema
+
+const int = Schema.int;
+
+export const Segmentation_Data_Schema = {
+    volume_data_3d_info: DensityServer_Data_Schema.volume_data_3d_info,
+    segmentation_data_table: {
+        set_id: int,
+        segment_id: int,
+    },
+    segmentation_data_3d: {
+        values: int
+    }
+};
+
+export type Segmentation_Data_Schema = typeof Segmentation_Data_Schema;
+export interface Segmentation_Data_Database extends Database<Segmentation_Data_Schema> {}

+ 143 - 0
src/mol-model-formats/volume/segmentation.ts

@@ -0,0 +1,143 @@
+/**
+ * Copyright (c) 2018-2022 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 { Volume } from '../../mol-model/volume';
+import { Task } from '../../mol-task';
+import { SpacegroupCell, Box3D } from '../../mol-math/geometry';
+import { Tensor, Vec3 } from '../../mol-math/linear-algebra';
+import { ModelFormat } from '../format';
+import { CustomProperties } from '../../mol-model/custom-property';
+import { Segmentation_Data_Database } from '../../mol-io/reader/cif/schema/segmentation';
+import { objectForEach } from '../../mol-util/object';
+
+export function volumeFromSegmentationData(source: Segmentation_Data_Database, params?: Partial<{ label: string, segmentLabels: { [id: number]: string }, ownerId: string }>): Task<Volume> {
+    return Task.create<Volume>('Create Segmentation Volume', async ctx => {
+        const { volume_data_3d_info: info, segmentation_data_3d: values } = source;
+        const cell = SpacegroupCell.create(
+            info.spacegroup_number.value(0),
+            Vec3.ofArray(info.spacegroup_cell_size.value(0)),
+            Vec3.scale(Vec3(), Vec3.ofArray(info.spacegroup_cell_angles.value(0)), Math.PI / 180)
+        );
+
+        const axis_order_fast_to_slow = info.axis_order.value(0);
+
+        const normalizeOrder = Tensor.convertToCanonicalAxisIndicesFastToSlow(axis_order_fast_to_slow);
+
+        // sample count is in "axis order" and needs to be reordered
+        const sample_count = normalizeOrder(info.sample_count.value(0));
+        const tensorSpace = Tensor.Space(sample_count, Tensor.invertAxisOrder(axis_order_fast_to_slow), Float32Array);
+
+        const t = Tensor.create(tensorSpace, Tensor.Data1(values.values.toArray({ array: Float32Array })));
+
+        // origin and dimensions are in "axis order" and need to be reordered
+        const origin = Vec3.ofArray(normalizeOrder(info.origin.value(0)));
+        const dimensions = Vec3.ofArray(normalizeOrder(info.dimensions.value(0)));
+
+        const v: Volume = {
+            label: params?.label,
+            entryId: undefined,
+            grid: {
+                transform: {
+                    kind: 'spacegroup',
+                    cell,
+                    fractionalBox: Box3D.create(origin, Vec3.add(Vec3(), origin, dimensions))
+                },
+                cells: t,
+                stats: {
+                    min: 0, max: 1, mean: 0, sigma: 1
+                },
+            },
+            sourceData: SegcifFormat.create(source),
+            customProperties: new CustomProperties(),
+            _propertyData: { ownerId: params?.ownerId },
+        };
+
+        Volume.PickingGranularity.set(v, 'object');
+
+        const segments = new Map<number, Set<number>>();
+        const sets = new Map<number, Set<number>>();
+        const { segment_id, set_id } = source.segmentation_data_table;
+        for (let i = 0, il = segment_id.rowCount; i < il; ++i) {
+            const segment = segment_id.value(i);
+            const set = set_id.value(i);
+            if (set === 0 || segment === 0) continue;
+
+            if (!sets.has(set)) sets.set(set, new Set());
+            sets.get(set)!.add(segment);
+        }
+        sets.forEach((segs, set) => {
+            segs.forEach(seg => {
+                if (!segments.has(seg)) segments.set(seg, new Set());
+                segments.get(seg)!.add(set);
+            });
+        });
+
+        const c = [0, 0, 0];
+        const getCoords = t.space.getCoords;
+        const d = t.data;
+        const [xn, yn, zn] = v.grid.cells.space.dimensions;
+        const xn1 = xn - 1;
+        const yn1 = yn - 1;
+        const zn1 = zn - 1;
+
+        const setBounds: { [k: number]: [number, number, number, number, number, number] } = {};
+        sets.forEach((v, k) => {
+            setBounds[k] = [xn1, yn1, zn1, -1, -1, -1];
+        });
+
+        for (let i = 0, il = d.length; i < il; ++i) {
+            const v = d[i];
+            if (v === 0) continue;
+
+            getCoords(i, c);
+            const b = setBounds[v];
+            if (c[0] < b[0]) b[0] = c[0];
+            if (c[1] < b[1]) b[1] = c[1];
+            if (c[2] < b[2]) b[2] = c[2];
+            if (c[0] > b[3]) b[3] = c[0];
+            if (c[1] > b[4]) b[4] = c[1];
+            if (c[2] > b[5]) b[5] = c[2];
+        }
+
+        const bounds: { [k: number]: Box3D } = {};
+        segments.forEach((v, k) => {
+            bounds[k] = Box3D.create(Vec3.create(xn1, yn1, zn1), Vec3.create(-1, -1, -1));
+        });
+
+        objectForEach(setBounds, (b, s) => {
+            sets.get(parseInt(s))!.forEach(seg => {
+                const sb = bounds[seg];
+                if (b[0] < sb.min[0]) sb.min[0] = b[0];
+                if (b[1] < sb.min[1]) sb.min[1] = b[1];
+                if (b[2] < sb.min[2]) sb.min[2] = b[2];
+                if (b[3] > sb.max[0]) sb.max[0] = b[3];
+                if (b[4] > sb.max[1]) sb.max[1] = b[4];
+                if (b[5] > sb.max[2]) sb.max[2] = b[5];
+            });
+        });
+
+        Volume.Segmentation.set(v, { segments, sets, bounds, labels: params?.segmentLabels ?? {} });
+
+        return v;
+    });
+}
+
+//
+
+export { SegcifFormat };
+
+type SegcifFormat = ModelFormat<Segmentation_Data_Database>
+
+namespace SegcifFormat {
+    export function is(x?: ModelFormat): x is SegcifFormat {
+        return x?.kind === 'segcif';
+    }
+
+    export function create(segcif: Segmentation_Data_Database): SegcifFormat {
+        return { kind: 'segcif', name: segcif._name, data: segcif };
+    }
+}

+ 2 - 1
src/mol-model/location.ts

@@ -8,6 +8,7 @@ import { StructureElement } from './structure';
 import { Bond } from './structure/structure/unit/bonds';
 import { ShapeGroup } from './shape/shape';
 import { PositionLocation } from '../mol-geo/util/location-iterator';
+import { Volume } from './volume';
 
 /** A null value Location */
 export const NullLocation = { kind: 'null-location' as const };
@@ -30,4 +31,4 @@ export function isDataLocation(x: any): x is DataLocation {
     return !!x && x.kind === 'data-location';
 }
 
-export type Location = StructureElement.Location | Bond.Location | ShapeGroup.Location | PositionLocation | DataLocation | NullLocation
+export type Location = StructureElement.Location | Bond.Location | ShapeGroup.Location | PositionLocation | DataLocation | NullLocation | Volume.Segment.Location

+ 11 - 2
src/mol-model/loci.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -64,7 +64,7 @@ export function DataLoci<T = unknown, E = unknown>(tag: string, data: T, element
 
 export { Loci };
 
-type Loci = StructureElement.Loci | Structure.Loci | Bond.Loci | EveryLoci | EmptyLoci | DataLoci | Shape.Loci | ShapeGroup.Loci | Volume.Loci | Volume.Isosurface.Loci | Volume.Cell.Loci
+type Loci = StructureElement.Loci | Structure.Loci | Bond.Loci | EveryLoci | EmptyLoci | DataLoci | Shape.Loci | ShapeGroup.Loci | Volume.Loci | Volume.Isosurface.Loci | Volume.Cell.Loci | Volume.Segment.Loci
 
 namespace Loci {
     export interface Bundle<L extends number> { loci: FiniteArray<Loci, L> }
@@ -109,6 +109,9 @@ namespace Loci {
         if (Volume.Cell.isLoci(lociA) && Volume.Cell.isLoci(lociB)) {
             return Volume.Cell.areLociEqual(lociA, lociB);
         }
+        if (Volume.Segment.isLoci(lociA) && Volume.Segment.isLoci(lociB)) {
+            return Volume.Segment.areLociEqual(lociA, lociB);
+        }
         return false;
     }
 
@@ -128,6 +131,7 @@ namespace Loci {
         if (Volume.isLoci(loci)) return Volume.isLociEmpty(loci);
         if (Volume.Isosurface.isLoci(loci)) return Volume.Isosurface.isLociEmpty(loci);
         if (Volume.Cell.isLoci(loci)) return Volume.Cell.isLociEmpty(loci);
+        if (Volume.Segment.isLoci(loci)) return Volume.Segment.isLociEmpty(loci);
         return false;
     }
 
@@ -167,6 +171,8 @@ namespace Loci {
             return Volume.Isosurface.getBoundingSphere(loci.volume, loci.isoValue, boundingSphere);
         } else if (loci.kind === 'cell-loci') {
             return Volume.Cell.getBoundingSphere(loci.volume, loci.indices, boundingSphere);
+        } else if (loci.kind === 'segment-loci') {
+            return Volume.Segment.getBoundingSphere(loci.volume, loci.segments, boundingSphere);
         }
     }
 
@@ -204,6 +210,9 @@ namespace Loci {
         } else if (loci.kind === 'cell-loci') {
             // TODO
             return void 0;
+        } else if (loci.kind === 'segment-loci') {
+            // TODO
+            return void 0;
         }
     }
 

+ 83 - 4
src/mol-model/volume/volume.ts

@@ -5,8 +5,8 @@
  */
 
 import { Grid } from './grid';
-import { OrderedSet } from '../../mol-data/int';
-import { Sphere3D } from '../../mol-math/geometry';
+import { OrderedSet, SortedArray } from '../../mol-data/int';
+import { Box3D, Sphere3D } from '../../mol-math/geometry';
 import { Vec3, Mat4 } from '../../mol-math/linear-algebra';
 import { BoundaryHelper } from '../../mol-math/geometry/boundary-helper';
 import { CubeFormat } from '../../mol-model-formats/volume/cube';
@@ -181,9 +181,35 @@ export namespace Volume {
         export function areLociEqual(a: Loci, b: Loci) { return a.volume === b.volume && Volume.IsoValue.areSame(a.isoValue, b.isoValue, a.volume.grid.stats); }
         export function isLociEmpty(loci: Loci) { return loci.volume.grid.cells.data.length === 0; }
 
+        const bbox = Box3D();
         export function getBoundingSphere(volume: Volume, isoValue: Volume.IsoValue, boundingSphere?: Sphere3D) {
-            // TODO get bounding sphere for subgrid with values >= isoValue
-            return Volume.getBoundingSphere(volume, boundingSphere);
+            const value = Volume.IsoValue.toAbsolute(isoValue, volume.grid.stats).absoluteValue;
+            const neg = value < 0;
+
+            const c = [0, 0, 0];
+            const getCoords = volume.grid.cells.space.getCoords;
+            const d = volume.grid.cells.data;
+            const [xn, yn, zn] = volume.grid.cells.space.dimensions;
+
+            let minx = xn - 1, miny = yn - 1, minz = zn - 1;
+            let maxx = 0, maxy = 0, maxz = 0;
+            for (let i = 0, il = d.length; i < il; ++i) {
+                if ((neg && d[i] <= value) || (!neg && d[i] >= value)) {
+                    getCoords(i, c);
+                    if (c[0] < minx) minx = c[0];
+                    if (c[1] < miny) miny = c[1];
+                    if (c[2] < minz) minz = c[2];
+                    if (c[0] > maxx) maxx = c[0];
+                    if (c[1] > maxy) maxy = c[1];
+                    if (c[2] > maxz) maxz = c[2];
+                }
+            }
+
+            Vec3.set(bbox.min, minx - 1, miny - 1, minz - 1);
+            Vec3.set(bbox.max, maxx + 1, maxy + 1, maxz + 1);
+            const transform = Grid.getGridToCartesianTransform(volume.grid);
+            Box3D.transform(bbox, bbox, transform);
+            return Sphere3D.fromBox3D(boundingSphere || Sphere3D(), bbox);
         }
     }
 
@@ -220,6 +246,44 @@ export namespace Volume {
         }
     }
 
+    export namespace Segment {
+        export interface Loci { readonly kind: 'segment-loci', readonly volume: Volume, readonly segments: SortedArray }
+        export function Loci(volume: Volume, segments: ArrayLike<number>): Loci { return { kind: 'segment-loci', volume, segments: SortedArray.ofUnsortedArray(segments) }; }
+        export function isLoci(x: any): x is Loci { return !!x && x.kind === 'segment-loci'; }
+        export function areLociEqual(a: Loci, b: Loci) { return a.volume === b.volume && SortedArray.areEqual(a.segments, b.segments); }
+        export function isLociEmpty(loci: Loci) { return loci.volume.grid.cells.data.length === 0 || loci.segments.length === 0; }
+
+        const bbox = Box3D();
+        export function getBoundingSphere(volume: Volume, segments: ArrayLike<number>, boundingSphere?: Sphere3D) {
+            const segmentation = Volume.Segmentation.get(volume);
+            if (segmentation) {
+                Box3D.setEmpty(bbox);
+                for (let i = 0, il = segments.length; i < il; ++i) {
+                    const b = segmentation.bounds[segments[i]];
+                    Box3D.add(bbox, b.min);
+                    Box3D.add(bbox, b.max);
+                }
+                const transform = Grid.getGridToCartesianTransform(volume.grid);
+                Box3D.transform(bbox, bbox, transform);
+                return Sphere3D.fromBox3D(boundingSphere || Sphere3D(), bbox);
+            } else {
+                return Volume.getBoundingSphere(volume, boundingSphere);
+            }
+        }
+
+        export interface Location {
+            readonly kind: 'segment-location',
+            volume: Volume
+            segment: number
+        }
+        export function Location(volume?: Volume, segment?: number): Location {
+            return { kind: 'segment-location', volume: volume as any, segment: segment as any };
+        }
+        export function isLocation(x: any): x is Location {
+            return !!x && x.kind === 'segment-location';
+        }
+    }
+
     export type PickingGranularity = 'volume' | 'object' | 'voxel';
     export const PickingGranularity = {
         set(volume: Volume, granularity: PickingGranularity) {
@@ -229,4 +293,19 @@ export namespace Volume {
             return volume._propertyData['__picking_granularity__'] ?? 'voxel';
         }
     };
+
+    export type Segmentation = {
+        segments: Map<number, Set<number>>
+        sets: Map<number, Set<number>>
+        bounds: { [k: number]: Box3D }
+        labels: { [k: number]: string }
+    };
+    export const Segmentation = {
+        set(volume: Volume, segmentation: Segmentation) {
+            volume._propertyData['__segmentation__'] = segmentation;
+        },
+        get(volume: Volume): Segmentation | undefined {
+            return volume._propertyData['__segmentation__'];
+        }
+    };
 }

+ 10 - 4
src/mol-plugin-state/formats/provider.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2022 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>
@@ -24,17 +24,23 @@ export interface DataFormatProvider<P = any, R = any, V = any> {
 
 export function DataFormatProvider<P extends DataFormatProvider>(provider: P): P { return provider; }
 
-type cifVariants = 'dscif' | 'coreCif' | -1
+type cifVariants = 'dscif' | 'segcif' | 'coreCif' | -1
 export function guessCifVariant(info: FileInfo, data: Uint8Array | string): cifVariants {
     if (info.ext === 'bcif') {
         try {
             // TODO: find a way to run msgpackDecode only once
             //      now it is run twice, here and during file parsing
-            if (decodeMsgPack(data as Uint8Array).encoder.startsWith('VolumeServer')) return 'dscif';
-        } catch { }
+            const { encoder } = decodeMsgPack(data as Uint8Array);
+            if (encoder.startsWith('VolumeServer')) return 'dscif';
+            // TODO: assumes volseg-volume-server only serves segments
+            if (encoder.startsWith('volseg-volume-server')) return 'segcif';
+        } catch (e) {
+            console.error(e);
+        }
     } else if (info.ext === 'cif') {
         const str = data as string;
         if (str.startsWith('data_SERVER\n#\n_density_server_result')) return 'dscif';
+        if (str.startsWith('data_SERVER\n#\ndata_SEGMENTATION_DATA')) return 'segcif';
         if (str.includes('atom_site_fract_x') || str.includes('atom_site.fract_x')) return 'coreCif';
     }
     return -1;

+ 4 - 4
src/mol-plugin-state/formats/registry.ts

@@ -80,12 +80,12 @@ export class DataFormatRegistry {
 
     auto(info: FileInfo, dataStateObject: PluginStateObject.Data.Binary | PluginStateObject.Data.String) {
         for (let i = 0, il = this.list.length; i < il; ++i) {
-            const { provider } = this._list[i];
+            const p = this._list[i].provider;
 
             let hasExt = false;
-            if (provider.binaryExtensions && provider.binaryExtensions.indexOf(info.ext) >= 0) hasExt = true;
-            else if (provider.stringExtensions && provider.stringExtensions.indexOf(info.ext) >= 0) hasExt = true;
-            if (hasExt && (!provider.isApplicable || provider.isApplicable(info, dataStateObject.data))) return provider;
+            if (p.binaryExtensions?.includes(info.ext)) hasExt = true;
+            else if (p.stringExtensions?.includes(info.ext)) hasExt = true;
+            if (hasExt && (!p.isApplicable || p.isApplicable(info, dataStateObject.data))) return p;
         }
         return;
     }

+ 52 - 0
src/mol-plugin-state/formats/volume.ts

@@ -246,12 +246,64 @@ export const DscifProvider = DataFormatProvider({
     }
 });
 
+export const SegcifProvider = DataFormatProvider({
+    label: 'Segmentation CIF',
+    description: 'Segmentation CIF',
+    category: VolumeFormatCategory,
+    stringExtensions: ['cif'],
+    binaryExtensions: ['bcif'],
+    isApplicable: (info, data) => {
+        return guessCifVariant(info, data) === 'segcif' ? true : false;
+    },
+    parse: async (plugin, data) => {
+        const cifCell = await plugin.build().to(data).apply(StateTransforms.Data.ParseCif).commit();
+        const b = plugin.build().to(cifCell);
+        const blocks = cifCell.obj!.data.blocks;
+
+        if (blocks.length === 0) throw new Error('no data blocks');
+
+        const volumes: StateObjectSelector<PluginStateObject.Volume.Data>[] = [];
+        for (const block of blocks) {
+            // Skip "server" data block.
+            if (block.header.toUpperCase() === 'SERVER') continue;
+
+            if (block.categories['volume_data_3d_info']?.rowCount > 0) {
+                volumes.push(b.apply(StateTransforms.Volume.VolumeFromSegmentationCif, { blockHeader: block.header }).selector);
+            }
+        }
+
+        await b.commit();
+
+        return { volumes };
+    },
+    visuals: async (plugin, data: { volumes: StateObjectSelector<PluginStateObject.Volume.Data>[] }) => {
+        const { volumes } = data;
+        const tree = plugin.build();
+        const visuals: StateObjectSelector<PluginStateObject.Volume.Representation3D>[] = [];
+
+        if (volumes.length > 0) {
+            const segmentation = Volume.Segmentation.get(volumes[0].data!);
+            if (segmentation) {
+                visuals[visuals.length] = tree
+                    .to(volumes[0])
+                    .apply(StateTransforms.Representation.VolumeRepresentation3D, VolumeRepresentation3DHelpers.getDefaultParams(plugin, 'segment', volumes[0].data!, { alpha: 1, instanceGranularity: true }, 'volume-segment', { }))
+                    .selector;
+            }
+        }
+
+        await tree.commit();
+
+        return visuals;
+    }
+});
+
 export const BuiltInVolumeFormats = [
     ['ccp4', Ccp4Provider] as const,
     ['dsn6', Dsn6Provider] as const,
     ['cube', CubeProvider] as const,
     ['dx', DxProvider] as const,
     ['dscif', DscifProvider] as const,
+    ['segcif', SegcifProvider] as const,
 ] as const;
 
 export type BuildInVolumeFormat = (typeof BuiltInVolumeFormats)[number][0]

+ 5 - 7
src/mol-plugin-state/transforms/representation.ts

@@ -9,7 +9,6 @@ import { Structure, StructureElement } from '../../mol-model/structure';
 import { Volume } from '../../mol-model/volume';
 import { PluginContext } from '../../mol-plugin/context';
 import { VolumeRepresentationRegistry } from '../../mol-repr/volume/registry';
-import { VolumeParams } from '../../mol-repr/volume/representation';
 import { StateTransformer, StateObject } from '../../mol-state';
 import { Task } from '../../mol-task';
 import { ColorTheme } from '../../mol-theme/color';
@@ -806,17 +805,16 @@ const ThemeStrengthRepresentation3D = PluginStateTransform.BuiltIn({
 //
 
 export namespace VolumeRepresentation3DHelpers {
-    export function getDefaultParams(ctx: PluginContext, name: VolumeRepresentationRegistry.BuiltIn, volume: Volume, volumeParams?: Partial<PD.Values<VolumeParams>>): StateTransformer.Params<VolumeRepresentation3D> {
+    export function getDefaultParams(ctx: PluginContext, name: VolumeRepresentationRegistry.BuiltIn, volume: Volume, volumeParams?: Partial<PD.Values<PD.Params>>, colorName?: ColorTheme.BuiltIn, colorParams?: Partial<ColorTheme.Props>, sizeName?: SizeTheme.BuiltIn, sizeParams?: Partial<SizeTheme.Props>): StateTransformer.Params<VolumeRepresentation3D> {
         const type = ctx.representation.volume.registry.get(name);
 
-        const themeDataCtx = { volume };
-        const colorParams = ctx.representation.volume.themes.colorThemeRegistry.get(type.defaultColorTheme.name).getParams(themeDataCtx);
-        const sizeParams = ctx.representation.volume.themes.sizeThemeRegistry.get(type.defaultSizeTheme.name).getParams(themeDataCtx);
+        const colorType = ctx.representation.volume.themes.colorThemeRegistry.get(colorName || type.defaultColorTheme.name);
+        const sizeType = ctx.representation.volume.themes.sizeThemeRegistry.get(sizeName || type.defaultSizeTheme.name);
         const volumeDefaultParams = PD.getDefaultValues(type.getParams(ctx.representation.volume.themes, volume));
         return ({
             type: { name, params: volumeParams ? { ...volumeDefaultParams, ...volumeParams } : volumeDefaultParams },
-            colorTheme: { name: type.defaultColorTheme.name, params: PD.getDefaultValues(colorParams) },
-            sizeTheme: { name: type.defaultSizeTheme.name, params: PD.getDefaultValues(sizeParams) }
+            colorTheme: { name: colorType.name, params: colorParams ? { ...colorType.defaultValues, ...colorParams } : colorType.defaultValues },
+            sizeTheme: { name: sizeType.name, params: sizeParams ? { ...sizeType.defaultValues, ...sizeParams } : sizeType.defaultValues }
         });
     }
 

+ 41 - 1
src/mol-plugin-state/transforms/volume.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2022 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>
@@ -18,6 +18,7 @@ import { volumeFromDx } from '../../mol-model-formats/volume/dx';
 import { Volume } from '../../mol-model/volume';
 import { PluginContext } from '../../mol-plugin/context';
 import { StateSelection } from '../../mol-state';
+import { volumeFromSegmentationData } from '../../mol-model-formats/volume/segmentation';
 
 export { VolumeFromCcp4 };
 export { VolumeFromDsn6 };
@@ -25,6 +26,7 @@ export { VolumeFromCube };
 export { VolumeFromDx };
 export { AssignColorVolume };
 export { VolumeFromDensityServerCif };
+export { VolumeFromSegmentationCif };
 
 type VolumeFromCcp4 = typeof VolumeFromCcp4
 const VolumeFromCcp4 = PluginStateTransform.BuiltIn({
@@ -160,6 +162,44 @@ const VolumeFromDensityServerCif = PluginStateTransform.BuiltIn({
     }
 });
 
+type VolumeFromSegmentationCif = typeof VolumeFromSegmentationCif
+const VolumeFromSegmentationCif = PluginStateTransform.BuiltIn({
+    name: 'volume-from-segmentation-cif',
+    display: { name: 'Volume from Segmentation CIF' },
+    from: SO.Format.Cif,
+    to: SO.Volume.Data,
+    params(a) {
+        const blocks = a?.data.blocks.slice(1);
+        const blockHeaderParam = blocks ?
+            PD.Optional(PD.Select(blocks[0] && blocks[0].header, blocks.map(b => [b.header, b.header] as [string, string]), { description: 'Header of the block to parse' }))
+            : PD.Optional(PD.Text(void 0, { description: 'Header of the block to parse. If none is specifed, the 1st data block in the file is used.' }));
+        return {
+            blockHeader: blockHeaderParam,
+            segmentLabels: PD.ObjectList({ id: PD.Numeric(-1), label: PD.Text('') }, s => `${s.id} = ${s.label}`, { description: 'Mapping of segment IDs to segment labels' }),
+            ownerId: PD.Text('', { isHidden: true, description: 'Reference to the object which manages this volume' }),
+        };
+    }
+})({
+    isApplicable: a => a.data.blocks.length > 0,
+    apply({ a, params }) {
+        return Task.create('Parse segmentation CIF', async ctx => {
+            const header = params.blockHeader || a.data.blocks[1].header; // zero block contains query meta-data
+            const block = a.data.blocks.find(b => b.header === header);
+            if (!block) throw new Error(`Data block '${[header]}' not found.`);
+            const segmentationCif = CIF.schema.segmentation(block);
+            const segmentLabels: { [id: number]: string } = {};
+            for (const segment of params.segmentLabels) segmentLabels[segment.id] = segment.label;
+            const volume = await volumeFromSegmentationData(segmentationCif, { segmentLabels, ownerId: params.ownerId }).runInContext(ctx);
+            const [x, y, z] = volume.grid.cells.space.dimensions;
+            const props = { label: segmentationCif.volume_data_3d_info.name.value(0), description: `Segmentation ${x}\u00D7${y}\u00D7${z}` };
+            return new SO.Volume.Data(volume, props);
+        });
+    },
+    dispose({ b }) {
+        b?.data.customProperties.dispose();
+    }
+});
+
 type AssignColorVolume = typeof AssignColorVolume
 const AssignColorVolume = PluginStateTransform.BuiltIn({
     name: 'assign-color-volume',

+ 9 - 0
src/mol-plugin-ui/skin/base/components/viewport.scss

@@ -114,6 +114,7 @@
     color: $highlight-info-font-color;
     padding: $info-vertical-padding $control-spacing;
     background: $default-background; //$highlight-info-background;
+    opacity: 90%;
 
     // min-height: $row-height;
     text-align: right;
@@ -121,6 +122,14 @@
     @include non-selectable;
 }
 
+.msp-highlight-info-hr {
+    margin-inline: 0px;
+    margin-block: 3px;
+    border: none;
+    height: 1px;
+    background-color: $highlight-info-font-color;
+}
+
 .msp-highlight-info-additional {
     font-size: 85%;
     display: inline-block;

+ 3 - 3
src/mol-plugin-ui/structure/volume.tsx

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-2022 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>
@@ -294,9 +294,9 @@ class VolumeRepresentationControls extends PurePluginUIComponent<{ representatio
 
     focus = () => {
         const repr = this.props.representation;
-        const objects = this.props.representation.cell.obj?.data.repr.renderObjects;
+        const lociList = repr.cell.obj?.data.repr.getAllLoci();
         if (repr.cell.state.isHidden) this.plugin.managers.volume.hierarchy.toggleVisibility([this.props.representation], 'show');
-        this.plugin.managers.camera.focusRenderObjects(objects, { extraRadius: 1 });
+        if (lociList) this.plugin.managers.camera.focusLoci(lociList, { extraRadius: 1 });
     };
 
     render() {

+ 5 - 5
src/mol-repr/volume/direct-volume.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -96,7 +96,7 @@ export function createDirectVolume3d(ctx: RuntimeContext, webgl: WebGLContext, v
 
 //
 
-export async function createDirectVolume(ctx: VisualContext, volume: Volume, theme: Theme, props: PD.Values<DirectVolumeParams>, directVolume?: DirectVolume) {
+export async function createDirectVolume(ctx: VisualContext, volume: Volume, key: number, theme: Theme, props: PD.Values<DirectVolumeParams>, directVolume?: DirectVolume) {
     const { runtime, webgl } = ctx;
     if (webgl === undefined) throw new Error('DirectVolumeVisual requires `webgl` in props');
 
@@ -109,7 +109,7 @@ function getLoci(volume: Volume, props: PD.Values<DirectVolumeParams>) {
     return Volume.Loci(volume);
 }
 
-export function getDirectVolumeLoci(pickingId: PickingId, volume: Volume, props: DirectVolumeProps, id: number) {
+export function getDirectVolumeLoci(pickingId: PickingId, volume: Volume, key: number, props: DirectVolumeProps, id: number) {
     const { objectId, groupId } = pickingId;
     if (id === objectId) {
         return Volume.Cell.Loci(volume, Interval.ofSingleton(groupId as Volume.CellIndex));
@@ -117,7 +117,7 @@ export function getDirectVolumeLoci(pickingId: PickingId, volume: Volume, props:
     return EmptyLoci;
 }
 
-export function eachDirectVolume(loci: Loci, volume: Volume, props: DirectVolumeProps, apply: (interval: Interval) => boolean) {
+export function eachDirectVolume(loci: Loci, volume: Volume, key: number, props: DirectVolumeProps, apply: (interval: Interval) => boolean) {
     return eachVolumeLoci(loci, volume, undefined, apply);
 }
 
@@ -164,5 +164,5 @@ export const DirectVolumeRepresentationProvider = VolumeRepresentationProvider({
     defaultValues: PD.getDefaultValues(DirectVolumeParams),
     defaultColorTheme: { name: 'volume-value' },
     defaultSizeTheme: { name: 'uniform' },
-    isApplicable: (volume: Volume) => !Volume.isEmpty(volume)
+    isApplicable: (volume: Volume) => !Volume.isEmpty(volume) && !Volume.Segmentation.get(volume)
 });

+ 17 - 16
src/mol-repr/volume/isosurface.ts

@@ -11,7 +11,7 @@ import { VisualContext } from '../visual';
 import { Theme, ThemeRegistryContext } from '../../mol-theme/theme';
 import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
 import { computeMarchingCubesMesh, computeMarchingCubesLines } from '../../mol-geo/util/marching-cubes/algorithm';
-import { VolumeVisual, VolumeRepresentation, VolumeRepresentationProvider } from './representation';
+import { VolumeVisual, VolumeRepresentation, VolumeRepresentationProvider, VolumeKey } from './representation';
 import { LocationIterator } from '../../mol-geo/util/location-iterator';
 import { NullLocation } from '../../mol-model/location';
 import { VisualUpdateState } from '../util';
@@ -53,7 +53,7 @@ function suitableForGpu(volume: Volume, webgl: WebGLContext) {
     return powerOfTwoSize <= webgl.maxTextureSize / 2;
 }
 
-export function IsosurfaceVisual(materialId: number, volume: Volume, props: PD.Values<IsosurfaceMeshParams>, webgl?: WebGLContext) {
+export function IsosurfaceVisual(materialId: number, volume: Volume, key: number, props: PD.Values<IsosurfaceMeshParams>, webgl?: WebGLContext) {
     if (props.tryUseGpu && webgl && gpuSupport(webgl) && suitableForGpu(volume, webgl)) {
         return IsosurfaceTextureMeshVisual(materialId);
     }
@@ -64,7 +64,7 @@ function getLoci(volume: Volume, props: VolumeIsosurfaceProps) {
     return Volume.Isosurface.Loci(volume, props.isoValue);
 }
 
-function getIsosurfaceLoci(pickingId: PickingId, volume: Volume, props: VolumeIsosurfaceProps, id: number) {
+function getIsosurfaceLoci(pickingId: PickingId, volume: Volume, key: number, props: VolumeIsosurfaceProps, id: number) {
     const { objectId, groupId } = pickingId;
 
     if (id === objectId) {
@@ -80,13 +80,13 @@ function getIsosurfaceLoci(pickingId: PickingId, volume: Volume, props: VolumeIs
     return EmptyLoci;
 }
 
-export function eachIsosurface(loci: Loci, volume: Volume, props: VolumeIsosurfaceProps, apply: (interval: Interval) => boolean) {
-    return eachVolumeLoci(loci, volume, props.isoValue, apply);
+export function eachIsosurface(loci: Loci, volume: Volume, key: number, props: VolumeIsosurfaceProps, apply: (interval: Interval) => boolean) {
+    return eachVolumeLoci(loci, volume, { isoValue: props.isoValue }, apply);
 }
 
 //
 
-export async function createVolumeIsosurfaceMesh(ctx: VisualContext, volume: Volume, theme: Theme, props: VolumeIsosurfaceProps, mesh?: Mesh) {
+export async function createVolumeIsosurfaceMesh(ctx: VisualContext, volume: Volume, key: number, theme: Theme, props: VolumeIsosurfaceProps, mesh?: Mesh) {
     ctx.runtime.update({ message: 'Marching cubes...' });
 
     const ids = fillSerial(new Int32Array(volume.grid.cells.data.length));
@@ -108,7 +108,7 @@ export async function createVolumeIsosurfaceMesh(ctx: VisualContext, volume: Vol
         ValueCell.updateIfChanged(surface.varyingGroup, true);
     }
 
-    surface.setBoundingSphere(Volume.getBoundingSphere(volume));
+    surface.setBoundingSphere(Volume.Isosurface.getBoundingSphere(volume, props.isoValue));
 
     return surface;
 }
@@ -133,8 +133,8 @@ export function IsosurfaceMeshVisual(materialId: number): VolumeVisual<Isosurfac
             if (!Volume.IsoValue.areSame(newProps.isoValue, currentProps.isoValue, volume.grid.stats)) state.createGeometry = true;
         },
         geometryUtils: Mesh.Utils,
-        mustRecreate: (volume: Volume, props: PD.Values<IsosurfaceMeshParams>, webgl?: WebGLContext) => {
-            return props.tryUseGpu && !!webgl && suitableForGpu(volume, webgl);
+        mustRecreate: (volumekey: VolumeKey, props: PD.Values<IsosurfaceMeshParams>, webgl?: WebGLContext) => {
+            return props.tryUseGpu && !!webgl && suitableForGpu(volumekey.volume, webgl);
         }
     }, materialId);
 }
@@ -181,7 +181,7 @@ namespace VolumeIsosurfaceTexture {
     }
 }
 
-async function createVolumeIsosurfaceTextureMesh(ctx: VisualContext, volume: Volume, theme: Theme, props: VolumeIsosurfaceProps, textureMesh?: TextureMesh) {
+async function createVolumeIsosurfaceTextureMesh(ctx: VisualContext, volume: Volume, key: number, theme: Theme, props: VolumeIsosurfaceProps, textureMesh?: TextureMesh) {
     if (!ctx.webgl) throw new Error('webgl context required to create volume isosurface texture-mesh');
 
     if (volume.grid.cells.data.length <= 1) {
@@ -200,7 +200,8 @@ async function createVolumeIsosurfaceTextureMesh(ctx: VisualContext, volume: Vol
     const gv = extractIsosurface(ctx.webgl, texture, gridDimension, gridTexDim, gridTexScale, transform, isoLevel, value < 0, false, axisOrder, true, buffer?.vertex, buffer?.group, buffer?.normal);
 
     const groupCount = volume.grid.cells.data.length;
-    const surface = TextureMesh.create(gv.vertexCount, groupCount, gv.vertexTexture, gv.groupTexture, gv.normalTexture, Volume.getBoundingSphere(volume), textureMesh);
+    const boundingSphere = Volume.getBoundingSphere(volume); // getting isosurface bounding-sphere is too expensive here
+    const surface = TextureMesh.create(gv.vertexCount, groupCount, gv.vertexTexture, gv.groupTexture, gv.normalTexture, boundingSphere, textureMesh);
     surface.meta.webgl = ctx.webgl;
 
     return surface;
@@ -217,8 +218,8 @@ export function IsosurfaceTextureMeshVisual(materialId: number): VolumeVisual<Is
             if (!Volume.IsoValue.areSame(newProps.isoValue, currentProps.isoValue, volume.grid.stats)) state.createGeometry = true;
         },
         geometryUtils: TextureMesh.Utils,
-        mustRecreate: (volume: Volume, props: PD.Values<IsosurfaceMeshParams>, webgl?: WebGLContext) => {
-            return !props.tryUseGpu || !webgl || !suitableForGpu(volume, webgl);
+        mustRecreate: (volumeKey: VolumeKey, props: PD.Values<IsosurfaceMeshParams>, webgl?: WebGLContext) => {
+            return !props.tryUseGpu || !webgl || !suitableForGpu(volumeKey.volume, webgl);
         },
         dispose: (geometry: TextureMesh) => {
             geometry.vertexTexture.ref.value.destroy();
@@ -231,7 +232,7 @@ export function IsosurfaceTextureMeshVisual(materialId: number): VolumeVisual<Is
 
 //
 
-export async function createVolumeIsosurfaceWireframe(ctx: VisualContext, volume: Volume, theme: Theme, props: VolumeIsosurfaceProps, lines?: Lines) {
+export async function createVolumeIsosurfaceWireframe(ctx: VisualContext, volume: Volume, key: number, theme: Theme, props: VolumeIsosurfaceProps, lines?: Lines) {
     ctx.runtime.update({ message: 'Marching cubes...' });
 
     const ids = fillSerial(new Int32Array(volume.grid.cells.data.length));
@@ -245,7 +246,7 @@ export async function createVolumeIsosurfaceWireframe(ctx: VisualContext, volume
     const transform = Grid.getGridToCartesianTransform(volume.grid);
     Lines.transform(wireframe, transform);
 
-    wireframe.setBoundingSphere(Volume.getBoundingSphere(volume));
+    wireframe.setBoundingSphere(Volume.Isosurface.getBoundingSphere(volume, props.isoValue));
 
     return wireframe;
 }
@@ -306,5 +307,5 @@ export const IsosurfaceRepresentationProvider = VolumeRepresentationProvider({
     defaultValues: PD.getDefaultValues(IsosurfaceParams),
     defaultColorTheme: { name: 'uniform' },
     defaultSizeTheme: { name: 'uniform' },
-    isApplicable: (volume: Volume) => !Volume.isEmpty(volume)
+    isApplicable: (volume: Volume) => !Volume.isEmpty(volume) && !Volume.Segmentation.get(volume)
 });

+ 3 - 1
src/mol-repr/volume/registry.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -10,6 +10,7 @@ import { IsosurfaceRepresentationProvider } from './isosurface';
 import { objectForEach } from '../../mol-util/object';
 import { SliceRepresentationProvider } from './slice';
 import { DirectVolumeRepresentationProvider } from './direct-volume';
+import { SegmentRepresentationProvider } from './segment';
 
 export class VolumeRepresentationRegistry extends RepresentationRegistry<Volume, Representation.State> {
     constructor() {
@@ -26,6 +27,7 @@ export namespace VolumeRepresentationRegistry {
         'isosurface': IsosurfaceRepresentationProvider,
         'slice': SliceRepresentationProvider,
         'direct-volume': DirectVolumeRepresentationProvider,
+        'segment': SegmentRepresentationProvider,
     };
 
     type _BuiltIn = typeof BuiltIn

+ 112 - 43
src/mol-repr/volume/representation.ts

@@ -13,21 +13,21 @@ import { Theme } from '../../mol-theme/theme';
 import { createIdentityTransform } from '../../mol-geo/geometry/transform-data';
 import { createRenderObject, getNextMaterialId, GraphicsRenderObject } from '../../mol-gl/render-object';
 import { PickingId } from '../../mol-geo/geometry/picking';
-import { Loci, isEveryLoci, EmptyLoci } from '../../mol-model/loci';
-import { Interval } from '../../mol-data/int';
+import { Loci, isEveryLoci, EmptyLoci, isEmptyLoci } from '../../mol-model/loci';
+import { Interval, SortedArray } from '../../mol-data/int';
 import { getQualityProps, VisualUpdateState } from '../util';
 import { ColorTheme } from '../../mol-theme/color';
 import { ValueCell } from '../../mol-util';
 import { createSizes } from '../../mol-geo/geometry/size-data';
 import { createColors } from '../../mol-geo/geometry/color-data';
 import { MarkerAction } from '../../mol-util/marker-action';
-import { Mat4 } from '../../mol-math/linear-algebra';
+import { EPSILON, Mat4 } from '../../mol-math/linear-algebra';
 import { Overpaint } from '../../mol-theme/overpaint';
 import { Transparency } from '../../mol-theme/transparency';
 import { Representation, RepresentationProvider, RepresentationContext, RepresentationParamsGetter } from '../representation';
 import { BaseGeometry } from '../../mol-geo/geometry/base';
 import { Subject } from 'rxjs';
-import { Task } from '../../mol-task';
+import { RuntimeContext, Task } from '../../mol-task';
 import { SizeValues } from '../../mol-gl/renderable/schema';
 import { Clipping } from '../../mol-theme/clipping';
 import { WebGLContext } from '../../mol-gl/webgl/context';
@@ -35,7 +35,8 @@ import { isPromiseLike } from '../../mol-util/type-helpers';
 import { Substance } from '../../mol-theme/substance';
 import { createMarkers } from '../../mol-geo/geometry/marker-data';
 
-export interface VolumeVisual<P extends VolumeParams> extends Visual<Volume, P> { }
+export type VolumeKey = { volume: Volume, key: number }
+export interface VolumeVisual<P extends VolumeParams> extends Visual<VolumeKey, P> { }
 
 function createVolumeRenderObject<G extends Geometry>(volume: Volume, geometry: G, locationIt: LocationIterator, theme: Theme, props: PD.Values<Geometry.Params<G>>, materialId: number) {
     const { createValues, createRenderableState } = Geometry.getUtils(geometry);
@@ -47,12 +48,12 @@ function createVolumeRenderObject<G extends Geometry>(volume: Volume, geometry:
 
 interface VolumeVisualBuilder<P extends VolumeParams, G extends Geometry> {
     defaultProps: PD.Values<P>
-    createGeometry(ctx: VisualContext, volume: Volume, theme: Theme, props: PD.Values<P>, geometry?: G): Promise<G> | G
-    createLocationIterator(volume: Volume): LocationIterator
-    getLoci(pickingId: PickingId, volume: Volume, props: PD.Values<P>, id: number): Loci
-    eachLocation(loci: Loci, volume: Volume, props: PD.Values<P>, apply: (interval: Interval) => boolean): boolean
+    createGeometry(ctx: VisualContext, volume: Volume, key: number, theme: Theme, props: PD.Values<P>, geometry?: G): Promise<G> | G
+    createLocationIterator(volume: Volume, key: number): LocationIterator
+    getLoci(pickingId: PickingId, volume: Volume, key: number, props: PD.Values<P>, id: number): Loci
+    eachLocation(loci: Loci, volume: Volume, key: number, props: PD.Values<P>, apply: (interval: Interval) => boolean): boolean
     setUpdateState(state: VisualUpdateState, volume: Volume, newProps: PD.Values<P>, currentProps: PD.Values<P>, newTheme: Theme, currentTheme: Theme): void
-    mustRecreate?: (volume: Volume, props: PD.Values<P>) => boolean
+    mustRecreate?: (volumeKey: VolumeKey, props: PD.Values<P>) => boolean
     dispose?: (geometry: G) => void
 }
 
@@ -70,17 +71,19 @@ export function VolumeVisual<G extends Geometry, P extends VolumeParams & Geomet
     let newProps: PD.Values<P>;
     let newTheme: Theme;
     let newVolume: Volume;
+    let newKey: number;
 
     let currentProps: PD.Values<P> = Object.assign({}, defaultProps);
     let currentTheme: Theme = Theme.createEmpty();
     let currentVolume: Volume;
+    let currentKey: number;
 
     let geometry: G;
     let geometryVersion = -1;
     let locationIt: LocationIterator;
     let positionIt: LocationIterator;
 
-    function prepareUpdate(theme: Theme, props: Partial<PD.Values<P>>, volume: Volume) {
+    function prepareUpdate(theme: Theme, props: Partial<PD.Values<P>>, volume: Volume, key: number) {
         if (!volume && !currentVolume) {
             throw new Error('missing volume');
         }
@@ -88,12 +91,13 @@ export function VolumeVisual<G extends Geometry, P extends VolumeParams & Geomet
         newProps = Object.assign({}, currentProps, props);
         newTheme = theme;
         newVolume = volume;
+        newKey = key;
 
         VisualUpdateState.reset(updateState);
 
         if (!renderObject) {
             updateState.createNew = true;
-        } else if (!currentVolume || !Volume.areEquivalent(newVolume, currentVolume)) {
+        } else if (!Volume.areEquivalent(newVolume, currentVolume) || newKey !== currentKey) {
             updateState.createNew = true;
         }
 
@@ -117,7 +121,7 @@ export function VolumeVisual<G extends Geometry, P extends VolumeParams & Geomet
 
     function update(newGeometry?: G) {
         if (updateState.createNew) {
-            locationIt = createLocationIterator(newVolume);
+            locationIt = createLocationIterator(newVolume, newKey);
             if (newGeometry) {
                 renderObject = createVolumeRenderObject(newVolume, newGeometry, locationIt, newTheme, newProps, materialId);
                 positionIt = createPositionIterator(newGeometry, renderObject.values);
@@ -131,7 +135,7 @@ export function VolumeVisual<G extends Geometry, P extends VolumeParams & Geomet
 
             if (updateState.updateTransform) {
                 // console.log('update transform');
-                locationIt = createLocationIterator(newVolume);
+                locationIt = createLocationIterator(newVolume, newKey);
                 const { instanceCount, groupCount } = locationIt;
                 if (newProps.instanceGranularity) {
                     createMarkers(instanceCount, 'instance', renderObject.values);
@@ -175,18 +179,25 @@ export function VolumeVisual<G extends Geometry, P extends VolumeParams & Geomet
         currentProps = newProps;
         currentTheme = newTheme;
         currentVolume = newVolume;
+        currentKey = newKey;
         if (newGeometry) {
             geometry = newGeometry;
             geometryVersion += 1;
         }
     }
 
-    function eachInstance(loci: Loci, volume: Volume, apply: (interval: Interval) => boolean) {
+    function eachInstance(loci: Loci, volume: Volume, key: number, apply: (interval: Interval) => boolean) {
         let changed = false;
-        if (!Volume.Cell.isLoci(loci)) return false;
-        if (Volume.Cell.isLociEmpty(loci)) return false;
-        if (!Volume.areEquivalent(loci.volume, volume)) return false;
-        if (apply(Interval.ofSingleton(0))) changed = true;
+        if (Volume.Cell.isLoci(loci)) {
+            if (Volume.Cell.isLociEmpty(loci)) return false;
+            if (!Volume.areEquivalent(loci.volume, volume)) return false;
+            if (apply(Interval.ofSingleton(0))) changed = true;
+        } else if (Volume.Segment.isLoci(loci)) {
+            if (Volume.Segment.isLociEmpty(loci)) return false;
+            if (!Volume.areEquivalent(loci.volume, volume)) return false;
+            if (!SortedArray.has(loci.segments, key)) return false;
+            if (apply(Interval.ofSingleton(0))) changed = true;
+        }
         return changed;
     }
 
@@ -199,9 +210,9 @@ export function VolumeVisual<G extends Geometry, P extends VolumeParams & Geomet
             }
         } else {
             if (currentProps.instanceGranularity) {
-                return eachInstance(loci, currentVolume, apply);
+                return eachInstance(loci, currentVolume, currentKey, apply);
             } else {
-                return eachLocation(loci, currentVolume, currentProps, apply);
+                return eachLocation(loci, currentVolume, currentKey, currentProps, apply);
             }
         }
     }
@@ -210,17 +221,17 @@ export function VolumeVisual<G extends Geometry, P extends VolumeParams & Geomet
         get groupCount() { return locationIt ? locationIt.count : 0; },
         get renderObject() { return renderObject; },
         get geometryVersion() { return geometryVersion; },
-        async createOrUpdate(ctx: VisualContext, theme: Theme, props: Partial<PD.Values<P>> = {}, volume?: Volume) {
-            prepareUpdate(theme, props, volume || currentVolume);
+        async createOrUpdate(ctx: VisualContext, theme: Theme, props: Partial<PD.Values<P>> = {}, volumeKey?: VolumeKey) {
+            prepareUpdate(theme, props, volumeKey?.volume || currentVolume, volumeKey?.key || currentKey);
             if (updateState.createGeometry) {
-                const newGeometry = createGeometry(ctx, newVolume, newTheme, newProps, geometry);
+                const newGeometry = createGeometry(ctx, newVolume, newKey, newTheme, newProps, geometry);
                 return isPromiseLike(newGeometry) ? newGeometry.then(update) : update(newGeometry);
             } else {
                 update();
             }
         },
         getLoci(pickingId: PickingId) {
-            return renderObject ? getLoci(pickingId, currentVolume, currentProps, renderObject.id) : EmptyLoci;
+            return renderObject ? getLoci(pickingId, currentVolume, currentKey, currentProps, renderObject.id) : EmptyLoci;
         },
         mark(loci: Loci, action: MarkerAction) {
             return Visual.mark(renderObject, loci, action, lociApply);
@@ -278,7 +289,7 @@ export const VolumeParams = {
 };
 export type VolumeParams = typeof VolumeParams
 
-export function VolumeRepresentation<P extends VolumeParams>(label: string, ctx: RepresentationContext, getParams: RepresentationParamsGetter<Volume, P>, visualCtor: (materialId: number, volume: Volume, props: PD.Values<P>, webgl?: WebGLContext) => VolumeVisual<P>, getLoci: (volume: Volume, props: PD.Values<P>) => Loci): VolumeRepresentation<P> {
+export function VolumeRepresentation<P extends VolumeParams>(label: string, ctx: RepresentationContext, getParams: RepresentationParamsGetter<Volume, P>, visualCtor: (materialId: number, volume: Volume, key: number, props: PD.Values<P>, webgl?: WebGLContext) => VolumeVisual<P>, getLoci: (volume: Volume, props: PD.Values<P>) => Loci, getKeys: (props: PD.Values<P>) => ArrayLike<number> = () => [-1]): VolumeRepresentation<P> {
     let version = 0;
     const { webgl } = ctx;
     const updated = new Subject<number>();
@@ -286,13 +297,27 @@ export function VolumeRepresentation<P extends VolumeParams>(label: string, ctx:
     const materialId = getNextMaterialId();
     const renderObjects: GraphicsRenderObject[] = [];
     const _state = Representation.createState();
-    let visual: VolumeVisual<P> | undefined;
+    const visuals = new Map<number, VolumeVisual<P>>();
 
     let _volume: Volume;
+    let _keys: ArrayLike<number>;
     let _params: P;
     let _props: PD.Values<P>;
     let _theme = Theme.createEmpty();
 
+    async function visual(runtime: RuntimeContext, key: number) {
+        let visual = visuals.get(key);
+        if (!visual) {
+            visual = visualCtor(materialId, _volume, key, _props, webgl);
+            visuals.set(key, visual);
+        } else if (visual.mustRecreate?.({ volume: _volume, key }, _props, webgl)) {
+            visual.destroy();
+            visual = visualCtor(materialId, _volume, key, _props, webgl);
+            visuals.set(key, visual);
+        }
+        return visual.createOrUpdate({ webgl, runtime }, _theme, _props, { volume: _volume, key });
+    }
+
     function createOrUpdate(props: Partial<PD.Values<P>> = {}, volume?: Volume) {
         if (volume && volume !== _volume) {
             _params = getParams(ctx, volume);
@@ -301,22 +326,28 @@ export function VolumeRepresentation<P extends VolumeParams>(label: string, ctx:
         }
         const qualityProps = getQualityProps(Object.assign({}, _props, props), _volume);
         Object.assign(_props, props, qualityProps);
+        _keys = getKeys(_props);
 
         return Task.create('Creating or updating VolumeRepresentation', async runtime => {
-            if (!visual) {
-                visual = visualCtor(materialId, _volume, _props, webgl);
-            } else if (visual.mustRecreate?.(_volume, _props, webgl)) {
-                visual.destroy();
-                visual = visualCtor(materialId, _volume, _props, webgl);
+            const toDelete = new Set(visuals.keys());
+            for (let i = 0, il = _keys.length; i < il; ++i) {
+                const segment = _keys[i];
+                toDelete.delete(segment);
+                const promise = visual(runtime, segment);
+                if (promise) await promise;
             }
-            const promise = visual.createOrUpdate({ webgl, runtime }, _theme, _props, volume);
-            if (promise) await promise;
+            toDelete.forEach(segment => {
+                visuals.get(segment)?.destroy();
+                visuals.delete(segment);
+            });
             // update list of renderObjects
             renderObjects.length = 0;
-            if (visual && visual.renderObject) {
-                renderObjects.push(visual.renderObject);
-                geometryState.add(visual.renderObject.id, visual.geometryVersion);
-            }
+            visuals.forEach(visual => {
+                if (visual.renderObject) {
+                    renderObjects.push(visual.renderObject);
+                    geometryState.add(visual.renderObject.id, visual.geometryVersion);
+                }
+            });
             geometryState.snapshot();
             // increment version
             updated.next(version++);
@@ -324,16 +355,44 @@ export function VolumeRepresentation<P extends VolumeParams>(label: string, ctx:
     }
 
     function mark(loci: Loci, action: MarkerAction) {
-        return visual ? visual.mark(loci, action) : false;
+        let changed = false;
+        visuals.forEach(visual => {
+            changed = visual.mark(loci, action) || changed;
+        });
+        return changed;
     }
 
-    function setState(state: Partial<Representation.State>) {
+    function setVisualState(visual: VolumeVisual<P>, state: Partial<Representation.State>) {
         if (state.visible !== undefined && visual) visual.setVisibility(state.visible);
         if (state.alphaFactor !== undefined && visual) visual.setAlphaFactor(state.alphaFactor);
         if (state.pickable !== undefined && visual) visual.setPickable(state.pickable);
         if (state.overpaint !== undefined && visual) visual.setOverpaint(state.overpaint);
         if (state.transparency !== undefined && visual) visual.setTransparency(state.transparency);
+        if (state.substance !== undefined && visual) visual.setSubstance(state.substance);
+        if (state.clipping !== undefined && visual) visual.setClipping(state.clipping);
         if (state.transform !== undefined && visual) visual.setTransform(state.transform);
+        if (state.themeStrength !== undefined && visual) visual.setThemeStrength(state.themeStrength);
+    }
+
+    function setState(state: Partial<Representation.State>) {
+        const { visible, alphaFactor, pickable, overpaint, transparency, substance, clipping, transform, themeStrength, syncManually, markerActions } = state;
+        const newState: Partial<Representation.State> = {};
+
+        if (visible !== _state.visible) newState.visible = visible;
+        if (alphaFactor !== _state.alphaFactor) newState.alphaFactor = alphaFactor;
+        if (pickable !== _state.pickable) newState.pickable = pickable;
+        if (overpaint !== undefined) newState.overpaint = overpaint;
+        if (transparency !== undefined) newState.transparency = transparency;
+        if (substance !== undefined) newState.substance = substance;
+        if (clipping !== undefined) newState.clipping = clipping;
+        if (themeStrength !== undefined) newState.themeStrength = themeStrength;
+        if (transform !== undefined && !Mat4.areEqual(transform, _state.transform, EPSILON)) {
+            newState.transform = transform;
+        }
+        if (syncManually !== _state.syncManually) newState.syncManually = syncManually;
+        if (markerActions !== _state.markerActions) newState.markerActions = markerActions;
+
+        visuals.forEach(visual => setVisualState(visual, newState));
 
         Representation.updateState(_state, state);
     }
@@ -343,13 +402,18 @@ export function VolumeRepresentation<P extends VolumeParams>(label: string, ctx:
     }
 
     function destroy() {
-        if (visual) visual.destroy();
+        visuals.forEach(visual => visual.destroy());
+        visuals.clear();
     }
 
     return {
         label,
         get groupCount() {
-            return visual ? visual.groupCount : 0;
+            let groupCount = 0;
+            visuals.forEach(visual => {
+                if (visual.renderObject) groupCount += visual.groupCount;
+            });
+            return groupCount;
         },
         get props() { return _props; },
         get params() { return _params; },
@@ -362,7 +426,12 @@ export function VolumeRepresentation<P extends VolumeParams>(label: string, ctx:
         setState,
         setTheme,
         getLoci: (pickingId: PickingId): Loci => {
-            return visual ? visual.getLoci(pickingId) : EmptyLoci;
+            let loci: Loci = EmptyLoci;
+            visuals.forEach(visual => {
+                const _loci = visual.getLoci(pickingId);
+                if (!isEmptyLoci(_loci)) loci = _loci;
+            });
+            return loci;
         },
         getAllLoci: (): Loci[] => {
             return [getLoci(_volume, _props)];

+ 339 - 0
src/mol-repr/volume/segment.ts

@@ -0,0 +1,339 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import { Grid, Volume } from '../../mol-model/volume';
+import { VisualContext } from '../visual';
+import { Theme, ThemeRegistryContext } from '../../mol-theme/theme';
+import { Mesh } from '../../mol-geo/geometry/mesh/mesh';
+import { computeMarchingCubesMesh } from '../../mol-geo/util/marching-cubes/algorithm';
+import { VolumeVisual, VolumeRepresentation, VolumeRepresentationProvider, VolumeKey } from './representation';
+import { LocationIterator } from '../../mol-geo/util/location-iterator';
+import { VisualUpdateState } from '../util';
+import { RepresentationContext, RepresentationParamsGetter, Representation } from '../representation';
+import { PickingId } from '../../mol-geo/geometry/picking';
+import { EmptyLoci, Loci } from '../../mol-model/loci';
+import { Interval, SortedArray } from '../../mol-data/int';
+import { Mat4, Tensor, Vec2, Vec3 } from '../../mol-math/linear-algebra';
+import { fillSerial } from '../../mol-util/array';
+import { createSegmentTexture2d, eachVolumeLoci, getVolumeTexture2dLayout } from './util';
+import { TextureMesh } from '../../mol-geo/geometry/texture-mesh/texture-mesh';
+import { WebGLContext } from '../../mol-gl/webgl/context';
+import { BaseGeometry } from '../../mol-geo/geometry/base';
+import { ValueCell } from '../../mol-util/value-cell';
+import { extractIsosurface } from '../../mol-gl/compute/marching-cubes/isosurface';
+import { Box3D } from '../../mol-math/geometry/primitives/box3d';
+
+export const VolumeSegmentParams = {
+    segments: PD.Converted(
+        (v: number[]) => v.map(x => `${x}`),
+        (v: string[]) => v.map(x => parseInt(x)),
+        PD.MultiSelect(['0'], PD.arrayToOptions(['0']), {
+            isEssential: true
+        })
+    )
+};
+export type VolumeSegmentParams = typeof VolumeSegmentParams
+export type VolumeSegmentProps = PD.Values<VolumeSegmentParams>
+
+function gpuSupport(webgl: WebGLContext) {
+    return webgl.extensions.colorBufferFloat && webgl.extensions.textureFloat && webgl.extensions.drawBuffers;
+}
+
+const Padding = 1;
+
+function suitableForGpu(volume: Volume, webgl: WebGLContext) {
+    // small volumes are about as fast or faster on CPU vs integrated GPU
+    if (volume.grid.cells.data.length < Math.pow(10, 3)) return false;
+    // the GPU is much more memory contraint, especially true for integrated GPUs,
+    // fallback to CPU for large volumes
+    const gridDim = volume.grid.cells.space.dimensions as Vec3;
+    const { powerOfTwoSize } = getVolumeTexture2dLayout(gridDim, Padding);
+    return powerOfTwoSize <= webgl.maxTextureSize / 2;
+}
+
+const _translate = Mat4();
+function getSegmentTransform(grid: Grid, segmentBox: Box3D) {
+    const transform = Grid.getGridToCartesianTransform(grid);
+    const translate = Mat4.fromTranslation(_translate, segmentBox.min);
+    return Mat4.mul(Mat4(), transform, translate);
+}
+
+export function SegmentVisual(materialId: number, volume: Volume, key: number, props: PD.Values<SegmentMeshParams>, webgl?: WebGLContext) {
+    if (props.tryUseGpu && webgl && gpuSupport(webgl) && suitableForGpu(volume, webgl)) {
+        return SegmentTextureMeshVisual(materialId);
+    }
+    return SegmentMeshVisual(materialId);
+}
+
+function getLoci(volume: Volume, props: VolumeSegmentProps) {
+    return Volume.Segment.Loci(volume, props.segments);
+}
+
+function getSegmentLoci(pickingId: PickingId, volume: Volume, key: number, props: VolumeSegmentProps, id: number) {
+    const { objectId, groupId } = pickingId;
+
+    if (id === objectId) {
+        const granularity = Volume.PickingGranularity.get(volume);
+        if (granularity === 'volume') {
+            return Volume.Loci(volume);
+        } else if (granularity === 'object') {
+            return Volume.Segment.Loci(volume, [key]);
+        } else {
+            return Volume.Cell.Loci(volume, Interval.ofSingleton(groupId as Volume.CellIndex));
+        }
+    }
+    return EmptyLoci;
+}
+
+export function eachSegment(loci: Loci, volume: Volume, key: number, props: VolumeSegmentProps, apply: (interval: Interval) => boolean) {
+    const segments = SortedArray.ofSingleton(key);
+    return eachVolumeLoci(loci, volume, { segments }, apply);
+}
+
+//
+
+function getSegmentCells(set: number[], bbox: Box3D, cells: Tensor): Tensor {
+    const data = cells.data;
+    const o = cells.space.dataOffset;
+
+    const dim = Box3D.size(Vec3(), bbox);
+    const [xn, yn, zn] = dim;
+    const xn1 = xn - 1;
+    const yn1 = yn - 1;
+    const zn1 = zn - 1;
+
+    const [minx, miny, minz] = bbox.min;
+    const [maxx, maxy, maxz] = bbox.max;
+
+    const axisOrder = [...cells.space.axisOrderSlowToFast];
+    const segmentSpace = Tensor.Space(dim, axisOrder, Uint8Array);
+    const segmentCells = Tensor.create(segmentSpace, segmentSpace.create());
+
+    const segData = segmentCells.data;
+    const segSet = segmentSpace.set;
+
+    for (let z = 0; z < zn; ++z) {
+        for (let y = 0; y < yn; ++y) {
+            for (let x = 0; x < xn; ++x) {
+                const v0 = set.includes(data[o(x + minx, y + miny, z + minz)]) ? 255 : 0;
+                const xp = set.includes(data[o(Math.min(xn1 + maxx, x + 1 + minx), y + miny, z + minz)]) ? 255 : 0;
+                const xn = set.includes(data[o(Math.max(0, x - 1 + minx), y + miny, z + minz)]) ? 255 : 0;
+                const yp = set.includes(data[o(x + minx, Math.min(yn1 + maxy, y + 1 + miny), z + minz)]) ? 255 : 0;
+                const yn = set.includes(data[o(x + minx, Math.max(0, y - 1 + miny), z + minz)]) ? 255 : 0;
+                const zp = set.includes(data[o(x + minx, y + miny, Math.min(zn1 + maxz, z + 1 + minz))]) ? 255 : 0;
+                const zn = set.includes(data[o(x + minx, y + miny, Math.max(0, z - 1 + minz))]) ? 255 : 0;
+
+                segSet(segData, x, y, z, Math.round((v0 + v0 + xp + xn + yp + yn + zp + zn) / 8));
+            }
+        }
+    }
+
+    return segmentCells;
+}
+
+export async function createVolumeSegmentMesh(ctx: VisualContext, volume: Volume, key: number, theme: Theme, props: VolumeSegmentProps, mesh?: Mesh) {
+    const segmentation = Volume.Segmentation.get(volume);
+    if (!segmentation) throw new Error('missing volume segmentation');
+
+    ctx.runtime.update({ message: 'Marching cubes...' });
+
+    const bbox = Box3D.clone(segmentation.bounds[key]);
+    Box3D.expand(bbox, bbox, Vec3.create(2, 2, 2));
+
+    const set = Array.from(segmentation.segments.get(key)!.values());
+    const cells = getSegmentCells(set, bbox, volume.grid.cells);
+    const ids = fillSerial(new Int32Array(cells.data.length));
+
+    const surface = await computeMarchingCubesMesh({
+        isoLevel: 128,
+        scalarField: cells,
+        idField: Tensor.create(cells.space, Tensor.Data1(ids))
+    }, mesh).runAsChild(ctx.runtime);
+
+    const transform = getSegmentTransform(volume.grid, bbox);
+    Mesh.transform(surface, transform);
+    if (ctx.webgl && !ctx.webgl.isWebGL2) {
+        // 2nd arg means not to split triangles based on group id. Splitting triangles
+        // is too expensive if each cell has its own group id as is the case here.
+        Mesh.uniformTriangleGroup(surface, false);
+        ValueCell.updateIfChanged(surface.varyingGroup, false);
+    } else {
+        ValueCell.updateIfChanged(surface.varyingGroup, true);
+    }
+
+    surface.setBoundingSphere(Volume.Segment.getBoundingSphere(volume, [key]));
+
+    return surface;
+}
+
+export const SegmentMeshParams = {
+    ...Mesh.Params,
+    ...TextureMesh.Params,
+    ...VolumeSegmentParams,
+    quality: { ...Mesh.Params.quality, isEssential: false },
+    tryUseGpu: PD.Boolean(true),
+};
+export type SegmentMeshParams = typeof SegmentMeshParams
+
+export function SegmentMeshVisual(materialId: number): VolumeVisual<SegmentMeshParams> {
+    return VolumeVisual<Mesh, SegmentMeshParams>({
+        defaultProps: PD.getDefaultValues(SegmentMeshParams),
+        createGeometry: createVolumeSegmentMesh,
+        createLocationIterator: (volume: Volume, key: number) => {
+            const l = Volume.Segment.Location(volume, key);
+            return LocationIterator(volume.grid.cells.data.length, 1, 1, () => l);
+        },
+        getLoci: getSegmentLoci,
+        eachLocation: eachSegment,
+        setUpdateState: (state: VisualUpdateState, volume: Volume, newProps: PD.Values<SegmentMeshParams>, currentProps: PD.Values<SegmentMeshParams>) => {
+        },
+        geometryUtils: Mesh.Utils,
+        mustRecreate: (volumeKey: VolumeKey, props: PD.Values<SegmentMeshParams>, webgl?: WebGLContext) => {
+            return props.tryUseGpu && !!webgl && suitableForGpu(volumeKey.volume, webgl);
+        }
+    }, materialId);
+}
+
+//
+
+const SegmentTextureName = 'segment-texture';
+
+function getSegmentTexture(volume: Volume, segment: number, webgl: WebGLContext) {
+    const segmentation = Volume.Segmentation.get(volume);
+    if (!segmentation) throw new Error('missing volume segmentation');
+
+    const { resources } = webgl;
+
+    const bbox = Box3D.clone(segmentation.bounds[segment]);
+    Box3D.expand(bbox, bbox, Vec3.create(2, 2, 2));
+
+    const transform = getSegmentTransform(volume.grid, bbox);
+    const gridDimension = Box3D.size(Vec3(), bbox);
+    const { width, height, powerOfTwoSize: texDim } = getVolumeTexture2dLayout(gridDimension, Padding);
+    const gridTexDim = Vec3.create(width, height, 0);
+    const gridTexScale = Vec2.create(width / texDim, height / texDim);
+    // console.log({ texDim, width, height, gridDimension });
+
+    if (texDim > webgl.maxTextureSize / 2) {
+        throw new Error('volume too large for gpu segment extraction');
+    }
+
+    if (!webgl.namedTextures[SegmentTextureName]) {
+        webgl.namedTextures[SegmentTextureName] = resources.texture('image-uint8', 'alpha', 'ubyte', 'linear');
+    }
+    const texture = webgl.namedTextures[SegmentTextureName];
+
+    texture.define(texDim, texDim);
+    // load volume into sub-section of texture
+    const set = Array.from(segmentation.segments.get(segment)!.values());
+    texture.load(createSegmentTexture2d(volume, set, bbox, Padding), true);
+
+    gridDimension[0] += Padding;
+    gridDimension[1] += Padding;
+
+    return {
+        texture,
+        transform,
+        gridDimension,
+        gridTexDim,
+        gridTexScale
+    };
+}
+
+async function createVolumeSegmentTextureMesh(ctx: VisualContext, volume: Volume, segment: number, theme: Theme, props: VolumeSegmentProps, textureMesh?: TextureMesh) {
+    if (!ctx.webgl) throw new Error('webgl context required to create volume segment texture-mesh');
+
+    if (volume.grid.cells.data.length <= 1) {
+        return TextureMesh.createEmpty(textureMesh);
+    }
+
+    const { texture, gridDimension, gridTexDim, gridTexScale, transform } = getSegmentTexture(volume, segment, ctx.webgl);
+
+    const axisOrder = volume.grid.cells.space.axisOrderSlowToFast as Vec3;
+    const buffer = textureMesh?.doubleBuffer.get();
+    const gv = extractIsosurface(ctx.webgl, texture, gridDimension, gridTexDim, gridTexScale, transform, 0.5, false, false, axisOrder, true, buffer?.vertex, buffer?.group, buffer?.normal);
+
+    const groupCount = volume.grid.cells.data.length;
+    const surface = TextureMesh.create(gv.vertexCount, groupCount, gv.vertexTexture, gv.groupTexture, gv.normalTexture, Volume.Segment.getBoundingSphere(volume, [segment]), textureMesh);
+
+    return surface;
+}
+
+export function SegmentTextureMeshVisual(materialId: number): VolumeVisual<SegmentMeshParams> {
+    return VolumeVisual<TextureMesh, SegmentMeshParams>({
+        defaultProps: PD.getDefaultValues(SegmentMeshParams),
+        createGeometry: createVolumeSegmentTextureMesh,
+        createLocationIterator: (volume: Volume, segment: number) => {
+            const l = Volume.Segment.Location(volume, segment);
+            return LocationIterator(volume.grid.cells.data.length, 1, 1, () => l);
+        },
+        getLoci: getSegmentLoci,
+        eachLocation: eachSegment,
+        setUpdateState: (state: VisualUpdateState, volume: Volume, newProps: PD.Values<SegmentMeshParams>, currentProps: PD.Values<SegmentMeshParams>) => {
+        },
+        geometryUtils: TextureMesh.Utils,
+        mustRecreate: (volumeKey: VolumeKey, props: PD.Values<SegmentMeshParams>, webgl?: WebGLContext) => {
+            return !props.tryUseGpu || !webgl || !suitableForGpu(volumeKey.volume, webgl);
+        },
+        dispose: (geometry: TextureMesh) => {
+            geometry.vertexTexture.ref.value.destroy();
+            geometry.groupTexture.ref.value.destroy();
+            geometry.normalTexture.ref.value.destroy();
+            geometry.doubleBuffer.destroy();
+        }
+    }, materialId);
+}
+
+//
+
+function getSegments(props: VolumeSegmentProps): SortedArray {
+    return SortedArray.ofUnsortedArray(props.segments);
+}
+
+const SegmentVisuals = {
+    'segment': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Volume, SegmentMeshParams>) => VolumeRepresentation('Segment mesh', ctx, getParams, SegmentVisual, getLoci, getSegments),
+};
+
+export const SegmentParams = {
+    ...SegmentMeshParams,
+    visuals: PD.MultiSelect(['segment'], PD.objectToOptions(SegmentVisuals)),
+    bumpFrequency: PD.Numeric(1, { min: 0, max: 10, step: 0.1 }, BaseGeometry.ShadingCategory),
+};
+export type SegmentParams = typeof SegmentParams
+export function getSegmentParams(ctx: ThemeRegistryContext, volume: Volume) {
+    const p = PD.clone(SegmentParams);
+
+    const segmentation = Volume.Segmentation.get(volume);
+    if (segmentation) {
+        const segments = Array.from(segmentation.segments.keys());
+        p.segments = PD.Converted(
+            (v: number[]) => v.map(x => `${x}`),
+            (v: string[]) => v.map(x => parseInt(x)),
+            PD.MultiSelect(segments.map(x => `${x}`), PD.arrayToOptions(segments.map(x => `${x}`)), {
+                isEssential: true
+            })
+        );
+    }
+    return p;
+}
+
+export type SegmentRepresentation = VolumeRepresentation<SegmentParams>
+export function SegmentRepresentation(ctx: RepresentationContext, getParams: RepresentationParamsGetter<Volume, SegmentParams>): SegmentRepresentation {
+    return Representation.createMulti('Segment', ctx, getParams, Representation.StateBuilder, SegmentVisuals as unknown as Representation.Def<Volume, SegmentParams>);
+}
+
+export const SegmentRepresentationProvider = VolumeRepresentationProvider({
+    name: 'segment',
+    label: 'Segment',
+    description: 'Displays a triangulated segment of volumetric data.',
+    factory: SegmentRepresentation,
+    getParams: getSegmentParams,
+    defaultValues: PD.getDefaultValues(SegmentParams),
+    defaultColorTheme: { name: 'volume-segment' },
+    defaultSizeTheme: { name: 'uniform' },
+    isApplicable: (volume: Volume) => !Volume.isEmpty(volume) && !!Volume.Segmentation.get(volume)
+});

+ 5 - 5
src/mol-repr/volume/slice.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -24,7 +24,7 @@ import { ColorTheme } from '../../mol-theme/color';
 import { packIntToRGBArray } from '../../mol-util/number-packing';
 import { eachVolumeLoci } from './util';
 
-export async function createImage(ctx: VisualContext, volume: Volume, theme: Theme, props: PD.Values<SliceParams>, image?: Image) {
+export async function createImage(ctx: VisualContext, volume: Volume, key: number, theme: Theme, props: PD.Values<SliceParams>, image?: Image) {
     const { dimension: { name: dim }, isoValue } = props;
 
     const { space, data } = volume.grid.cells;
@@ -147,7 +147,7 @@ function getLoci(volume: Volume, props: PD.Values<SliceParams>) {
     return Volume.Cell.Loci(volume, SortedArray.ofUnsortedArray(groupArray));
 }
 
-function getSliceLoci(pickingId: PickingId, volume: Volume, props: PD.Values<SliceParams>, id: number) {
+function getSliceLoci(pickingId: PickingId, volume: Volume, key: number, props: PD.Values<SliceParams>, id: number) {
     const { objectId, groupId } = pickingId;
     if (id === objectId) {
         const granularity = Volume.PickingGranularity.get(volume);
@@ -162,7 +162,7 @@ function getSliceLoci(pickingId: PickingId, volume: Volume, props: PD.Values<Sli
     return EmptyLoci;
 }
 
-function eachSlice(loci: Loci, volume: Volume, props: PD.Values<SliceParams>, apply: (interval: Interval) => boolean) {
+function eachSlice(loci: Loci, volume: Volume, key: number, props: PD.Values<SliceParams>, apply: (interval: Interval) => boolean) {
     return eachVolumeLoci(loci, volume, undefined, apply);
 }
 
@@ -237,5 +237,5 @@ export const SliceRepresentationProvider = VolumeRepresentationProvider({
     defaultValues: PD.getDefaultValues(SliceParams),
     defaultColorTheme: { name: 'uniform' },
     defaultSizeTheme: { name: 'uniform' },
-    isApplicable: (volume: Volume) => !Volume.isEmpty(volume)
+    isApplicable: (volume: Volume) => !Volume.isEmpty(volume) && !Volume.Segmentation.get(volume)
 });

+ 74 - 7
src/mol-repr/volume/util.ts

@@ -1,15 +1,17 @@
 /**
- * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
 import { Volume } from '../../mol-model/volume';
 import { Loci } from '../../mol-model/loci';
-import { Interval, OrderedSet } from '../../mol-data/int';
+import { Interval, OrderedSet, SortedArray } from '../../mol-data/int';
 import { equalEps } from '../../mol-math/linear-algebra/3d/common';
 import { Vec3 } from '../../mol-math/linear-algebra/3d/vec3';
 import { packIntToRGBArray } from '../../mol-util/number-packing';
+import { SetUtils } from '../../mol-util/set';
+import { Box3D } from '../../mol-math/geometry';
 
 // avoiding namespace lookup improved performance in Chrome (Aug 2020)
 const v3set = Vec3.set;
@@ -19,18 +21,17 @@ const v3addScalar = Vec3.addScalar;
 const v3scale = Vec3.scale;
 const v3toArray = Vec3.toArray;
 
-export function eachVolumeLoci(loci: Loci, volume: Volume, isoValue: Volume.IsoValue | undefined, apply: (interval: Interval) => boolean) {
+export function eachVolumeLoci(loci: Loci, volume: Volume, props: { isoValue?: Volume.IsoValue, segments?: SortedArray } | undefined, apply: (interval: Interval) => boolean) {
     let changed = false;
     if (Volume.isLoci(loci)) {
         if (!Volume.areEquivalent(loci.volume, volume)) return false;
         if (apply(Interval.ofLength(volume.grid.cells.data.length))) changed = true;
     } else if (Volume.Isosurface.isLoci(loci)) {
         if (!Volume.areEquivalent(loci.volume, volume)) return false;
-        if (isoValue) {
-            if (!Volume.IsoValue.areSame(loci.isoValue, isoValue, volume.grid.stats)) return false;
+        if (props?.isoValue) {
+            if (!Volume.IsoValue.areSame(loci.isoValue, props.isoValue, volume.grid.stats)) return false;
             if (apply(Interval.ofLength(volume.grid.cells.data.length))) changed = true;
         } else {
-            // TODO find a cheaper way?
             const { stats, cells: { data } } = volume.grid;
             const eps = stats.sigma;
             const v = Volume.IsoValue.toAbsolute(loci.isoValue, stats).absoluteValue;
@@ -49,6 +50,27 @@ export function eachVolumeLoci(loci: Loci, volume: Volume, isoValue: Volume.IsoV
                 if (apply(Interval.ofSingleton(v))) changed = true;
             });
         }
+    } else if (Volume.Segment.isLoci(loci)) {
+        if (!Volume.areEquivalent(loci.volume, volume)) return false;
+        if (props?.segments) {
+            if (!SortedArray.areIntersecting(loci.segments, props.segments)) return false;
+            if (apply(Interval.ofLength(volume.grid.cells.data.length))) changed = true;
+        } else {
+            const segmentation = Volume.Segmentation.get(volume);
+            if (segmentation) {
+                const set = new Set<number>();
+                for (let i = 0, il = loci.segments.length; i < il; ++i) {
+                    SetUtils.add(set, segmentation.segments.get(loci.segments[i])!);
+                }
+                const s = Array.from(set.values());
+                const d = volume.grid.cells.data;
+                for (let i = 0, il = d.length; i < il; ++i) {
+                    if (s.includes(d[i])) {
+                        if (apply(Interval.ofSingleton(i))) changed = true;
+                    }
+                }
+            }
+        }
     }
     return changed;
 }
@@ -179,4 +201,49 @@ export function createVolumeTexture3d(volume: Volume) {
     }
 
     return textureVolume;
-}
+}
+
+export function createSegmentTexture2d(volume: Volume, set: number[], bbox: Box3D, padding = 0) {
+    const data = volume.grid.cells.data;
+    const dim = Box3D.size(Vec3(), bbox);
+    const o = volume.grid.cells.space.dataOffset;
+    const { width, height } = getVolumeTexture2dLayout(dim, padding);
+
+    const itemSize = 1;
+    const array = new Uint8Array(width * height * itemSize);
+    const textureImage = { array, width, height };
+
+    const [xn, yn, zn] = dim;
+    const xn1 = xn - 1;
+    const yn1 = yn - 1;
+    const zn1 = zn - 1;
+
+    const xnp = xn + padding;
+    const ynp = yn + padding;
+
+    const [minx, miny, minz] = bbox.min;
+    const [maxx, maxy, maxz] = bbox.max;
+
+    for (let z = 0; z < zn; ++z) {
+        for (let y = 0; y < yn; ++y) {
+            for (let x = 0; x < xn; ++x) {
+                const column = Math.floor(((z * xnp) % width) / xnp);
+                const row = Math.floor((z * xnp) / width);
+                const px = column * xnp + x;
+                const index = itemSize * ((row * ynp * width) + (y * width) + px);
+
+                const v0 = set.includes(data[o(x + minx, y + miny, z + minz)]) ? 255 : 0;
+                const xp = set.includes(data[o(Math.min(xn1 + maxx, x + 1 + minx), y + miny, z + minz)]) ? 255 : 0;
+                const xn = set.includes(data[o(Math.max(0, x - 1 + minx), y + miny, z + minz)]) ? 255 : 0;
+                const yp = set.includes(data[o(x + minx, Math.min(yn1 + maxy, y + 1 + miny), z + minz)]) ? 255 : 0;
+                const yn = set.includes(data[o(x + minx, Math.max(0, y - 1 + miny), z + minz)]) ? 255 : 0;
+                const zp = set.includes(data[o(x + minx, y + miny, Math.min(zn1 + maxz, z + 1 + minz))]) ? 255 : 0;
+                const zn = set.includes(data[o(x + minx, y + miny, Math.max(0, z - 1 + minz))]) ? 255 : 0;
+
+                array[index] = Math.round((v0 + v0 + xp + xn + yp + yn + zp + zn) / 8);
+            }
+        }
+    }
+
+    return textureImage;
+}

+ 2 - 0
src/mol-theme/color.ts

@@ -40,6 +40,7 @@ import { VolumeValueColorThemeProvider } from './color/volume-value';
 import { Vec3, Vec4 } from '../mol-math/linear-algebra';
 import { ModelIndexColorThemeProvider } from './color/model-index';
 import { StructureIndexColorThemeProvider } from './color/structure-index';
+import { VolumeSegmentColorThemeProvider } from './color/volume-segment';
 import { ExternalVolumeColorThemeProvider } from './color/external-volume';
 
 export type LocationColor = (location: Location, isSecondary: boolean) => Color
@@ -152,6 +153,7 @@ namespace ColorTheme {
         'uncertainty': UncertaintyColorThemeProvider,
         'unit-index': UnitIndexColorThemeProvider,
         'uniform': UniformColorThemeProvider,
+        'volume-segment': VolumeSegmentColorThemeProvider,
         'volume-value': VolumeValueColorThemeProvider,
         'external-volume': ExternalVolumeColorThemeProvider,
     };

+ 68 - 0
src/mol-theme/color/volume-segment.ts

@@ -0,0 +1,68 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { Color } from '../../mol-util/color';
+import { Location } from '../../mol-model/location';
+import { ColorTheme, LocationColor } from '../color';
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import { ThemeDataContext } from '../../mol-theme/theme';
+import { getPaletteParams, getPalette } from '../../mol-util/color/palette';
+import { TableLegend, ScaleLegend } from '../../mol-util/legend';
+import { Volume } from '../../mol-model/volume/volume';
+
+const DefaultColor = Color(0xCCCCCC);
+const Description = 'Gives every volume segment a unique color.';
+
+export const VolumeSegmentColorThemeParams = {
+    ...getPaletteParams({ type: 'colors', colorList: 'many-distinct' }),
+};
+export type VolumeSegmentColorThemeParams = typeof VolumeSegmentColorThemeParams
+export function getVolumeSegmentColorThemeParams(ctx: ThemeDataContext) {
+    return PD.clone(VolumeSegmentColorThemeParams);
+}
+
+export function VolumeSegmentColorTheme(ctx: ThemeDataContext, props: PD.Values<VolumeSegmentColorThemeParams>): ColorTheme<VolumeSegmentColorThemeParams> {
+    let color: LocationColor;
+    let legend: ScaleLegend | TableLegend | undefined;
+
+    const segmentation = ctx.volume && Volume.Segmentation.get(ctx.volume);
+
+    if (segmentation) {
+        const size = segmentation.segments.size;
+        const segments = Array.from(segmentation.segments.keys());
+
+        const palette = getPalette(size, props);
+        legend = palette.legend;
+
+        color = (location: Location): Color => {
+            if (Volume.Segment.isLocation(location)) {
+                return palette.color(segments.indexOf(location.segment));
+            }
+            return DefaultColor;
+        };
+    } else {
+        color = () => DefaultColor;
+    }
+
+    return {
+        factory: VolumeSegmentColorTheme,
+        granularity: 'instance',
+        color,
+        props,
+        description: Description,
+        legend
+    };
+}
+
+export const VolumeSegmentColorThemeProvider: ColorTheme.Provider<VolumeSegmentColorThemeParams, 'volume-segment'> = {
+    name: 'volume-segment',
+    label: 'Volume Segment',
+    category: ColorTheme.Category.Misc,
+    factory: VolumeSegmentColorTheme,
+    getParams: getVolumeSegmentColorThemeParams,
+    defaultValues: PD.getDefaultValues(VolumeSegmentColorThemeParams),
+    isApplicable: (ctx: ThemeDataContext) => !!ctx.volume && !!Volume.Segmentation.get(ctx.volume)
+};

+ 2 - 1
src/mol-theme/color/volume-value.ts

@@ -10,6 +10,7 @@ import { ParamDefinition as PD } from '../../mol-util/param-definition';
 import { ThemeDataContext } from '../theme';
 import { ColorNames } from '../../mol-util/color/names';
 import { ColorTypeDirect } from '../../mol-geo/geometry/color-data';
+import { Volume } from '../../mol-model/volume/volume';
 
 const Description = 'Assign color based on the given value of a volume cell.';
 
@@ -57,5 +58,5 @@ export const VolumeValueColorThemeProvider: ColorTheme.Provider<VolumeValueColor
     factory: VolumeValueColorTheme,
     getParams: getVolumeValueColorThemeParams,
     defaultValues: PD.getDefaultValues(VolumeValueColorThemeParams),
-    isApplicable: (ctx: ThemeDataContext) => !!ctx.volume,
+    isApplicable: (ctx: ThemeDataContext) => !!ctx.volume && !Volume.Segmentation.get(ctx.volume),
 };

+ 11 - 1
src/mol-theme/label.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2022 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>
@@ -66,6 +66,16 @@ export function lociLabel(loci: Loci, options: Partial<LabelOptions> = {}): stri
                 label.push(`${Volume.IsoValue.toString(absVal)} (${Volume.IsoValue.toString(relVal)})`);
             }
             return label.join(' | ');
+        case 'segment-loci':
+            const segmentLabels = Volume.Segmentation.get(loci.volume)?.labels;
+            if (segmentLabels && loci.segments.length === 1) {
+                const label = segmentLabels[loci.segments[0]];
+                if (label) return label;
+            }
+            return [
+                `${loci.volume.label || 'Volume'}`,
+                `${loci.segments.length === 1 ? `Segment ${loci.segments[0]}` : `${loci.segments.length} Segments`}`
+            ].join(' | ');
     }
 }
 

+ 1 - 1
src/mol-util/param-definition.ts

@@ -317,7 +317,7 @@ export namespace ParamDefinition {
         toValue(v: C): T
     }
     export function Converted<T, C extends Any>(fromValue: (v: T) => C['defaultValue'], toValue: (v: C['defaultValue']) => T, converted: C): Converted<T, C['defaultValue']> {
-        return { type: 'converted', defaultValue: toValue(converted.defaultValue), converted, fromValue, toValue };
+        return setInfo({ type: 'converted', defaultValue: toValue(converted.defaultValue), converted, fromValue, toValue }, converted);
     }
 
     export interface Conditioned<T, P extends Base<T>, C = { [k: string]: P }> extends Base<T> {