diff --git a/CHANGELOG.md b/CHANGELOG.md index a5fd7caf87b0cdbd60641b1151a16f695f667906..82f75dd1023776b47d940f4c87294ff8ddb71e21 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/src/apps/viewer/app.ts b/src/apps/viewer/app.ts index 3d488ae366147da4cb8ae7c88d1716c322e41da5..76158c57570a5c16390c7061b8e563d7448eca91 100644 --- a/src/apps/viewer/app.ts +++ b/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), diff --git a/src/cli/structure-info/volume.ts b/src/cli/structure-info/volume.ts index 23bf618afea516c5b252966060b0a7082f04d646..86d3d5925ed1667c353377471afa6b9f147d459f 100644 --- a/src/cli/structure-info/volume.ts +++ b/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. diff --git a/src/extensions/meshes/choice.ts b/src/extensions/meshes/choice.ts new file mode 100644 index 0000000000000000000000000000000000000000..0da7350eefbd28fa49bbb75816b064bff8573cee --- /dev/null +++ b/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; +} diff --git a/src/extensions/meshes/examples.ts b/src/extensions/meshes/examples.ts new file mode 100644 index 0000000000000000000000000000000000000000..64c2476ce95a19c4a98c0e89aa17a7d6f2e7b208 --- /dev/null +++ b/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); +} \ No newline at end of file diff --git a/src/extensions/meshes/mesh-cif-schema.ts b/src/extensions/meshes/mesh-cif-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ac20317a9a75527e6b16c57399c4690c2db1567 --- /dev/null +++ b/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); diff --git a/src/extensions/meshes/mesh-extension.ts b/src/extensions/meshes/mesh-extension.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a0e70f1bad2f89ec21488c8e5ebdcfb07f33445 --- /dev/null +++ b/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; +// } diff --git a/src/extensions/meshes/mesh-streaming/behavior.ts b/src/extensions/meshes/mesh-streaming/behavior.ts new file mode 100644 index 0000000000000000000000000000000000000000..a52f0ece1ae5675487d332352fe10edede70d3ae --- /dev/null +++ b/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); + } + + } +} + diff --git a/src/extensions/meshes/mesh-streaming/server-info.ts b/src/extensions/meshes/mesh-streaming/server-info.ts new file mode 100644 index 0000000000000000000000000000000000000000..b85315a1c5618b4b9c2129b7ba52692442f54177 --- /dev/null +++ b/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>; +} diff --git a/src/extensions/meshes/mesh-streaming/transformers.ts b/src/extensions/meshes/mesh-streaming/transformers.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f64eb463d9a744d18b87dd32d3a59c1affc769b --- /dev/null +++ b/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? diff --git a/src/extensions/meshes/mesh-utils.ts b/src/extensions/meshes/mesh-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..5672872472cdb8b70b3f4a4a92f7902a033797cf --- /dev/null +++ b/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; +} + diff --git a/src/extensions/meshes/metadata.ts b/src/extensions/meshes/metadata.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba43707d7719c8e0519bc18fcde86ee165adef32 --- /dev/null +++ b/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; + + } +} \ No newline at end of file diff --git a/src/extensions/volumes-and-segmentations/entry-meshes.ts b/src/extensions/volumes-and-segmentations/entry-meshes.ts new file mode 100644 index 0000000000000000000000000000000000000000..15a75d66c956ac45af5931b13ebe82125d7e6596 --- /dev/null +++ b/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; + } +} diff --git a/src/extensions/volumes-and-segmentations/entry-models.ts b/src/extensions/volumes-and-segmentations/entry-models.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce7cbddc3b8520b5710e4435d39dc63d3be1df92 --- /dev/null +++ b/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; + } +} diff --git a/src/extensions/volumes-and-segmentations/entry-root.ts b/src/extensions/volumes-and-segmentations/entry-root.ts new file mode 100644 index 0000000000000000000000000000000000000000..76971ac66c9d192b3a70cd7d70662d460681a379 --- /dev/null +++ b/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(); + } + } + +} + + + diff --git a/src/extensions/volumes-and-segmentations/entry-segmentation.ts b/src/extensions/volumes-and-segmentations/entry-segmentation.ts new file mode 100644 index 0000000000000000000000000000000000000000..039baf6c66addd0908d71cadec8aba811771977c --- /dev/null +++ b/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 } }); + } + } + } +} \ No newline at end of file diff --git a/src/extensions/volumes-and-segmentations/entry-state.ts b/src/extensions/volumes-and-segmentations/entry-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce60ce13c4b60c6e1e773f9dfacdf4cc4f4661eb --- /dev/null +++ b/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 diff --git a/src/extensions/volumes-and-segmentations/entry-volume.ts b/src/extensions/volumes-and-segmentations/entry-volume.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ba4184ac9c9b1e0e37c37f74ed3e4fa952feb68 --- /dev/null +++ b/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; + } + } +} \ No newline at end of file diff --git a/src/extensions/volumes-and-segmentations/external-api.ts b/src/extensions/volumes-and-segmentations/external-api.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c302f3a9daabf7903de05cd6f46e75ebc037127 --- /dev/null +++ b/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; +} diff --git a/src/extensions/volumes-and-segmentations/global-state.ts b/src/extensions/volumes-and-segmentations/global-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..f05b834de7ae834d673038e787bf22b9d734b764 --- /dev/null +++ b/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; + } +} diff --git a/src/extensions/volumes-and-segmentations/helpers.ts b/src/extensions/volumes-and-segmentations/helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..576bab8abc6b0ad3e0977578f5c64009b72c2453 --- /dev/null +++ b/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; + }; +} diff --git a/src/extensions/volumes-and-segmentations/index.ts b/src/extensions/volumes-and-segmentations/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..17046dd0b5262a71bab2a77cd392d0a003936e52 --- /dev/null +++ b/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); +})); \ No newline at end of file diff --git a/src/extensions/volumes-and-segmentations/lattice-segmentation.ts b/src/extensions/volumes-and-segmentations/lattice-segmentation.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb5ddc99279e3b383c68af2b1d0311ebd08be8e4 --- /dev/null +++ b/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]); + } +} + diff --git a/src/extensions/volumes-and-segmentations/transformers.ts b/src/extensions/volumes-and-segmentations/transformers.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a0ee037fb39c3ee39357c980269eabf6c6f07d5 --- /dev/null +++ b/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; + } +}); \ No newline at end of file diff --git a/src/extensions/volumes-and-segmentations/ui.tsx b/src/extensions/volumes-and-segmentations/ui.tsx new file mode 100644 index 0000000000000000000000000000000000000000..65df31a57e310b9efb12e62fd1ea5cad66d93210 --- /dev/null +++ b/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; +} diff --git a/src/extensions/volumes-and-segmentations/volseg-api/api.ts b/src/extensions/volumes-and-segmentations/volseg-api/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1be88335e92033c15d13b7c73f2c57a49d59132 --- /dev/null +++ b/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(); + } +} diff --git a/src/extensions/volumes-and-segmentations/volseg-api/data.ts b/src/extensions/volumes-and-segmentations/volseg-api/data.ts new file mode 100644 index 0000000000000000000000000000000000000000..972b7ae2de45980f7e78b9791e85d94d1ad35daa --- /dev/null +++ b/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]; diff --git a/src/extensions/volumes-and-segmentations/volseg-api/utils.ts b/src/extensions/volumes-and-segmentations/volseg-api/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..f3576bc71cbcd95c8275799b42c5e76518f42099 --- /dev/null +++ b/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; + } + +} \ No newline at end of file diff --git a/src/mol-io/reader/cif.ts b/src/mol-io/reader/cif.ts index 8d6c2c98d8d80e874b5d5d5c09b05dadb5c50461..161a2b270edff855d8d46beb68bda5bee987aa6c 100644 --- a/src/mol-io/reader/cif.ts +++ b/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), } }; diff --git a/src/mol-io/reader/cif/schema/segmentation.ts b/src/mol-io/reader/cif/schema/segmentation.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee9f6f09015975344cf56478e13f16c01200df56 --- /dev/null +++ b/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> {} \ No newline at end of file diff --git a/src/mol-model-formats/volume/segmentation.ts b/src/mol-model-formats/volume/segmentation.ts new file mode 100644 index 0000000000000000000000000000000000000000..4676ee519607c7e4ddc1c5d47ea0bac3ebe87799 --- /dev/null +++ b/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 }; + } +} \ No newline at end of file diff --git a/src/mol-model/location.ts b/src/mol-model/location.ts index 90d83f13642df62c6dbcaa0978a2997f51e4d52a..ef7427620581758ebad859ef078964f8de5eaa5a 100644 --- a/src/mol-model/location.ts +++ b/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 \ No newline at end of file +export type Location = StructureElement.Location | Bond.Location | ShapeGroup.Location | PositionLocation | DataLocation | NullLocation | Volume.Segment.Location \ No newline at end of file diff --git a/src/mol-model/loci.ts b/src/mol-model/loci.ts index 7118d487fac8480b7d4dad5718182f37154b97bc..5e2b277730e48b756fef3388b3f3fe4d6b8649e9 100644 --- a/src/mol-model/loci.ts +++ b/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; } } diff --git a/src/mol-model/volume/volume.ts b/src/mol-model/volume/volume.ts index 741f51864665d8225117d11fda6117cb9efb1fa0..e9e7d994d8402824427d6787c69f5cd8c72d86eb 100644 --- a/src/mol-model/volume/volume.ts +++ b/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__']; + } + }; } \ No newline at end of file diff --git a/src/mol-plugin-state/formats/provider.ts b/src/mol-plugin-state/formats/provider.ts index 2c49bf70c018ba2a7af33550846f5e0128029aa7..6fe0bed66998cbfb2f99ccefdcd4e9cf2be566f7 100644 --- a/src/mol-plugin-state/formats/provider.ts +++ b/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; diff --git a/src/mol-plugin-state/formats/registry.ts b/src/mol-plugin-state/formats/registry.ts index b39f428de31a9433642b5600dbe0129d990c6d23..090ce3f321635fc345a5542e6db394658fb1c49a 100644 --- a/src/mol-plugin-state/formats/registry.ts +++ b/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; } diff --git a/src/mol-plugin-state/formats/volume.ts b/src/mol-plugin-state/formats/volume.ts index 3a04ac5e46443de0edfb33d4c96e551a7e02a662..89a3c3284f76b47f5f4924c68871bc4144856ef8 100644 --- a/src/mol-plugin-state/formats/volume.ts +++ b/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] \ No newline at end of file diff --git a/src/mol-plugin-state/transforms/representation.ts b/src/mol-plugin-state/transforms/representation.ts index a6089db15deb00ce21368622784dfcadcb3731fc..10b2ec6e8916633a470c08cc9e4fcd4e39451efa 100644 --- a/src/mol-plugin-state/transforms/representation.ts +++ b/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 } }); } diff --git a/src/mol-plugin-state/transforms/volume.ts b/src/mol-plugin-state/transforms/volume.ts index b15d79071b303e94282f3cfeb87daa9c166e37ba..95d73b625868dfc179e990b0814db4828e39eade 100644 --- a/src/mol-plugin-state/transforms/volume.ts +++ b/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', diff --git a/src/mol-plugin-ui/skin/base/components/viewport.scss b/src/mol-plugin-ui/skin/base/components/viewport.scss index b5e2098c53ff67916f8295a43a0dee463b35de26..2a5f46d556eb13e94942a5305e20647fb992447c 100644 --- a/src/mol-plugin-ui/skin/base/components/viewport.scss +++ b/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; diff --git a/src/mol-plugin-ui/structure/volume.tsx b/src/mol-plugin-ui/structure/volume.tsx index 2e1486ab9b629112382de1d249642161d78f567c..9c5666147f195ea764b3bd3e72671e201a7a4b91 100644 --- a/src/mol-plugin-ui/structure/volume.tsx +++ b/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() { diff --git a/src/mol-repr/volume/direct-volume.ts b/src/mol-repr/volume/direct-volume.ts index 24a69b3a2c87049b9490ba8690c910840507ee78..276f64971fb7af9af6177d6376bad672efeaa868 100644 --- a/src/mol-repr/volume/direct-volume.ts +++ b/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) }); \ No newline at end of file diff --git a/src/mol-repr/volume/isosurface.ts b/src/mol-repr/volume/isosurface.ts index e2f36b0c20fb663c393e05c3b74698767de30e79..5bf91dcf17d98aea233164682f09b6dcddbda696 100644 --- a/src/mol-repr/volume/isosurface.ts +++ b/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) }); \ No newline at end of file diff --git a/src/mol-repr/volume/registry.ts b/src/mol-repr/volume/registry.ts index f3828185f9dfbcffd50c490309f1ab20f679077e..eda391bb74c271201634b2ae3f5daf2b05d513bb 100644 --- a/src/mol-repr/volume/registry.ts +++ b/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 diff --git a/src/mol-repr/volume/representation.ts b/src/mol-repr/volume/representation.ts index aa2f336d6208bc5524dff06f74278084c9cdf983..d83dfad49786bbba9cd611d68a47acc17d29fcac 100644 --- a/src/mol-repr/volume/representation.ts +++ b/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)]; diff --git a/src/mol-repr/volume/segment.ts b/src/mol-repr/volume/segment.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c85355bc1485790f7674c34a14a968fae1716cb --- /dev/null +++ b/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) +}); \ No newline at end of file diff --git a/src/mol-repr/volume/slice.ts b/src/mol-repr/volume/slice.ts index d81bb7c513893aad48a17d3b94cf6f69e6cbb911..ce4137a5996d814340b3d6d6e8f08bf759911880 100644 --- a/src/mol-repr/volume/slice.ts +++ b/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) }); \ No newline at end of file diff --git a/src/mol-repr/volume/util.ts b/src/mol-repr/volume/util.ts index 1af532d6172d10a6baf573ea2eeb756a528ba46f..e5957271e8bb690b5f79cdce90e5bd0e6d9e211f 100644 --- a/src/mol-repr/volume/util.ts +++ b/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; -} \ No newline at end of file +} + +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; +} diff --git a/src/mol-theme/color.ts b/src/mol-theme/color.ts index 160d0ba066e5b65d8a6079e0c2daf12e026d4b01..90de9ea13893a9b0053db51eece4675773305a2e 100644 --- a/src/mol-theme/color.ts +++ b/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, }; diff --git a/src/mol-theme/color/volume-segment.ts b/src/mol-theme/color/volume-segment.ts new file mode 100644 index 0000000000000000000000000000000000000000..12bfd83bcd21055004a0620428afedbfc783be59 --- /dev/null +++ b/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) +}; \ No newline at end of file diff --git a/src/mol-theme/color/volume-value.ts b/src/mol-theme/color/volume-value.ts index a407eb749e4e9ab07d74a9e817ae706769e71f21..71c6c9322d0441b5872c8d45df3951b2bc4d4eac 100644 --- a/src/mol-theme/color/volume-value.ts +++ b/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), }; \ No newline at end of file diff --git a/src/mol-theme/label.ts b/src/mol-theme/label.ts index d43df4dd7e87a0d2904d8afb271e9f0303849d2f..28f50a6e0ceaddaeff29c618cd8a78fc292c44e5 100644 --- a/src/mol-theme/label.ts +++ b/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(' | '); } } diff --git a/src/mol-util/param-definition.ts b/src/mol-util/param-definition.ts index 59aee99dd0f770dfac1c40adf65bf74e901c964e..f5d2eb0a33e3e8131bc2f04e9e8ac1d626e2938b 100644 --- a/src/mol-util/param-definition.ts +++ b/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> {