Переглянути джерело

Merge branch 'master' into gl-lines

Alexander Rose 6 роки тому
батько
коміт
021b8755e9

+ 1 - 1
src/mol-plugin/behavior/behavior.ts

@@ -43,7 +43,7 @@ namespace PluginBehavior {
 
     export function create<P>(params: CreateParams<P>) {
         // TODO: cache groups etc
-        return PluginStateTransform.Create<Root, Behavior, P>({
+        return PluginStateTransform.CreateBuiltIn<Root, Behavior, P>({
             name: params.name,
             display: params.display,
             from: [Root],

+ 15 - 13
src/mol-plugin/state/actions/basic.ts

@@ -12,7 +12,7 @@ import { StateSelection } from 'mol-state/state/selection';
 import { CartoonParams } from 'mol-repr/structure/representation/cartoon';
 import { BallAndStickParams } from 'mol-repr/structure/representation/ball-and-stick';
 import { Download } from '../transforms/data';
-import { StateTree } from 'mol-state';
+import { StateTree, Transformer } from 'mol-state';
 import { StateTreeBuilder } from 'mol-state/tree/builder';
 import { PolymerIdColorThemeParams } from 'mol-theme/color/polymer-id';
 import { UniformSizeThemeParams } from 'mol-theme/size/uniform';
@@ -43,7 +43,7 @@ namespace ObtainStructureHelpers {
     ];
 
     export function getControls(key: string) { return (ControlMap as any)[key]; }
-    export function getUrl(src: DownloadStructure.Source): Download.Params {
+    export function getUrl(src: DownloadStructure.Source): Transformer.Params<Download> {
         switch (src.name) {
             case 'url': return src.params;
             case 'pdbe-updated': return { url: `https://www.ebi.ac.uk/pdbe/static/entry/${src.params.toLowerCase()}_updated.cif`, isBinary: false, label: `PDBe: ${src.params}` };
@@ -81,13 +81,14 @@ const DownloadStructure = StateAction.create<PluginStateObject.Root, void, Downl
     }
 });
 
-export const OpenStructure = StateAction.create<PluginStateObject.Root, void, { file: File }>({
-    from: [PluginStateObject.Root],
+export const OpenStructure = StateAction.build({
+    from: PluginStateObject.Root,
+    params: { file: PD.File({ accept: '.cif,.bcif' }) }
+})({
     display: {
         name: 'Open Structure',
         description: 'Load a structure from file and create its default Assembly and visual'
     },
-    params: () => ({ file: PD.File({ accept: '.cif,.bcif' }) }),
     apply({ params, state }) {
         const b = state.build();
         const data = b.toRoot().apply(StateTransforms.Data.ReadFile, { file: params.file, isBinary: /\.bcif$/i.test(params.file.name) });
@@ -130,8 +131,9 @@ function complexRepresentation(root: StateTreeBuilder.To<PluginStateObject.Molec
         // TODO: create spheres visual
 }
 
-export const CreateComplexRepresentation = StateAction.create<PluginStateObject.Molecule.Structure, void, {}>({
-    from: [PluginStateObject.Molecule.Structure],
+export const CreateComplexRepresentation = StateAction.build({
+    from: PluginStateObject.Molecule.Structure
+})({
     display: {
         name: 'Create Complex',
         description: 'Split the structure into Sequence/Water/Ligands/... '
@@ -143,15 +145,15 @@ export const CreateComplexRepresentation = StateAction.create<PluginStateObject.
     }
 });
 
-export const UpdateTrajectory = StateAction.create<PluginStateObject.Root, void, { action: 'advance' | 'reset', by?: number }>({
-    from: [],
+export const UpdateTrajectory = StateAction.build({
+    params: () => ({
+        action: PD.Select<'advance' | 'reset'>('advance', [['advance', 'Advance'], ['reset', 'Reset']]),
+        by: PD.makeOptional(PD.Numeric(1, { min: -1, max: 1, step: 1 }))
+    })
+})({
     display: {
         name: 'Update Trajectory'
     },
-    params: () => ({
-        action: PD.Select('advance', [['advance', 'Advance'], ['reset', 'Reset']]),
-        by: PD.Numeric(1, { min: -1, max: 1, step: 1 }, { isOptional: true })
-    }),
     apply({ params, state }) {
         const models = state.select(q => q.rootsOfType(PluginStateObject.Molecule.Model)
             .filter(c => c.transform.transformer === StateTransforms.Model.ModelFromTrajectory));

+ 7 - 3
src/mol-plugin/state/objects.ts

@@ -46,8 +46,6 @@ export namespace PluginStateObject {
     export namespace Data {
         export class String extends Create<string>({ name: 'String Data', typeClass: 'Data', }) { }
         export class Binary extends Create<Uint8Array>({ name: 'Binary Data', typeClass: 'Data' }) { }
-        export class Json extends Create<any>({ name: 'JSON Data', typeClass: 'Data' }) { }
-        export class Cif extends Create<CifFile>({ name: 'CIF File', typeClass: 'Data' }) { }
 
         // TODO
         // export class MultipleRaw extends Create<{
@@ -55,6 +53,11 @@ export namespace PluginStateObject {
         // }>({ name: 'Data', typeClass: 'Data', shortName: 'MD', description: 'Multiple Keyed Data.' }) { }
     }
 
+    export namespace Format {
+        export class Json extends Create<any>({ name: 'JSON Data', typeClass: 'Data' }) { }
+        export class Cif extends Create<CifFile>({ name: 'CIF File', typeClass: 'Data' }) { }
+    }
+
     export namespace Molecule {
         export class Trajectory extends Create<ReadonlyArray<_Model>>({ name: 'Trajectory', typeClass: 'Object' }) { }
         export class Model extends Create<_Model>({ name: 'Model', typeClass: 'Object' }) { }
@@ -69,5 +72,6 @@ export namespace PluginStateObject {
 }
 
 export namespace PluginStateTransform {
-    export const Create = Transformer.factory('ms-plugin');
+    export const CreateBuiltIn = Transformer.factory('ms-plugin');
+    export const BuiltIn = Transformer.builderFactory('ms-plugin');
 }

+ 27 - 24
src/mol-plugin/state/transforms/data.ts

@@ -14,20 +14,21 @@ import { Transformer } from 'mol-state';
 import { readFromFile } from 'mol-util/data-source';
 
 export { Download }
-namespace Download { export interface Params { url: string, isBinary?: boolean, label?: string } }
-const Download = PluginStateTransform.Create<SO.Root, SO.Data.String | SO.Data.Binary, Download.Params>({
+type Download = typeof Download
+const Download = PluginStateTransform.BuiltIn({
     name: 'download',
+    from: [SO.Root],
+    to: [SO.Data.String, SO.Data.Binary],
+    params: {
+        url: PD.Text('https://www.ebi.ac.uk/pdbe/static/entry/1cbs_updated.cif', { description: 'Resource URL. Must be the same domain or support CORS.' }),
+        label: PD.makeOptional(PD.Text('')),
+        isBinary: PD.makeOptional(PD.Boolean(false, { description: 'If true, download data as binary (string otherwise)' }))
+    }
+})({
     display: {
         name: 'Download',
         description: 'Download string or binary data from the specified URL'
     },
-    from: [SO.Root],
-    to: [SO.Data.String, SO.Data.Binary],
-    params: () => ({
-        url: PD.Text('https://www.ebi.ac.uk/pdbe/static/entry/1cbs_updated.cif', { description: 'Resource URL. Must be the same domain or support CORS.' }),
-        label: PD.Text('', { isOptional: true }),
-        isBinary: PD.Boolean(false, { description: 'If true, download data as binary (string otherwise)', isOptional: true })
-    }),
     apply({ params: p }, globalCtx: PluginContext) {
         return Task.create('Download', async ctx => {
             const data = await globalCtx.fetch(p.url, p.isBinary ? 'binary' : 'string').runInContext(ctx);
@@ -47,21 +48,22 @@ const Download = PluginStateTransform.Create<SO.Root, SO.Data.String | SO.Data.B
 });
 
 export { ReadFile }
-namespace ReadFile { export interface Params { file: File, isBinary?: boolean, label?: string } }
-const ReadFile = PluginStateTransform.Create<SO.Root, SO.Data.String | SO.Data.Binary, ReadFile.Params>({
+type ReadFile = typeof ReadFile
+const ReadFile = PluginStateTransform.BuiltIn({
     name: 'read-file',
+    from: SO.Root,
+    to: [SO.Data.String, SO.Data.Binary],
+    params: {
+        file: PD.File(),
+        label: PD.makeOptional(PD.Text('')),
+        isBinary: PD.makeOptional(PD.Boolean(false, { description: 'If true, open file as as binary (string otherwise)' }))
+    },
+})({
     display: {
         name: 'Read File',
         description: 'Read string or binary data from the specified file'
     },
-    from: [SO.Root],
-    to: [SO.Data.String, SO.Data.Binary],
-    params: () => ({
-        file: PD.File(),
-        label: PD.Text('', { isOptional: true }),
-        isBinary: PD.Boolean(false, { description: 'If true, open file as as binary (string otherwise)', isOptional: true })
-    }),
-    apply({ params: p }, globalCtx: PluginContext) {
+    apply({ params: p }) {
         return Task.create('Open File', async ctx => {
             const data = await readFromFile(p.file, p.isBinary ? 'binary' : 'string').runInContext(ctx);
             return p.isBinary
@@ -80,20 +82,21 @@ const ReadFile = PluginStateTransform.Create<SO.Root, SO.Data.String | SO.Data.B
 });
 
 export { ParseCif }
-namespace ParseCif { export interface Params { } }
-const ParseCif = PluginStateTransform.Create<SO.Data.String | SO.Data.Binary, SO.Data.Cif, ParseCif.Params>({
+type ParseCif = typeof ParseCif
+const ParseCif = PluginStateTransform.BuiltIn({
     name: 'parse-cif',
+    from: [SO.Data.String, SO.Data.Binary],
+    to: SO.Format.Cif
+})({
     display: {
         name: 'Parse CIF',
         description: 'Parse CIF from String or Binary data'
     },
-    from: [SO.Data.String, SO.Data.Binary],
-    to: [SO.Data.Cif],
     apply({ a }) {
         return Task.create('Parse CIF', async ctx => {
             const parsed = await (SO.Data.String.is(a) ? CIF.parse(a.data) : CIF.parseBinary(a.data)).runInContext(ctx);
             if (parsed.isError) throw new Error(parsed.message);
-            return new SO.Data.Cif(parsed.result);
+            return new SO.Format.Cif(parsed.result);
         });
     }
 });

+ 48 - 42
src/mol-plugin/state/transforms/model.ts

@@ -16,22 +16,22 @@ import { StateObject } from 'mol-state';
 import { PluginContext } from 'mol-plugin/context';
 
 export { TrajectoryFromMmCif }
-namespace TrajectoryFromMmCif { export interface Params { blockHeader?: string } }
-const TrajectoryFromMmCif = PluginStateTransform.Create<SO.Data.Cif, SO.Molecule.Trajectory, TrajectoryFromMmCif.Params>({
+type TrajectoryFromMmCif = typeof TrajectoryFromMmCif
+const TrajectoryFromMmCif = PluginStateTransform.BuiltIn({
     name: 'trajectory-from-mmcif',
-    display: {
-        name: 'Models from mmCIF',
-        description: 'Identify and create all separate models in the specified CIF data block'
-    },
-    from: [SO.Data.Cif],
-    to: [SO.Molecule.Trajectory],
+    from: SO.Format.Cif,
+    to: SO.Molecule.Trajectory,
     params(a) {
         const { blocks } = a.data;
-        if (blocks.length === 0) return { };
         return {
-            blockHeader: PD.Select(blocks[0].header, blocks.map(b => [b.header, b.header] as [string, string]), { description: 'Header of the block to parse' })
+            blockHeader: PD.makeOptional(PD.Select(blocks[0] && blocks[0].header, blocks.map(b => [b.header, b.header] as [string, string]), { description: 'Header of the block to parse' }))
         };
     },
+})({
+    display: {
+        name: 'Models from mmCIF',
+        description: 'Identify and create all separate models in the specified CIF data block'
+    },
     isApplicable: a => a.data.blocks.length > 0,
     apply({ a, params }) {
         return Task.create('Parse mmCIF', async ctx => {
@@ -48,16 +48,17 @@ const TrajectoryFromMmCif = PluginStateTransform.Create<SO.Data.Cif, SO.Molecule
 
 export { ModelFromTrajectory }
 const plus1 = (v: number) => v + 1, minus1 = (v: number) => v - 1;
-namespace ModelFromTrajectory { export interface Params { modelIndex: number } }
-const ModelFromTrajectory = PluginStateTransform.Create<SO.Molecule.Trajectory, SO.Molecule.Model, ModelFromTrajectory.Params>({
+type ModelFromTrajectory = typeof ModelFromTrajectory
+const ModelFromTrajectory = PluginStateTransform.BuiltIn({
     name: 'model-from-trajectory',
+    from: SO.Molecule.Trajectory,
+    to: SO.Molecule.Model,
+    params: a => ({ modelIndex: PD.Converted(plus1, minus1, PD.Numeric(1, { min: 1, max: a.data.length, step: 1 }, { description: 'Model Index' })) })
+})({
     display: {
         name: 'Model from Trajectory',
         description: 'Create a molecular structure from the specified model.'
     },
-    from: [SO.Molecule.Trajectory],
-    to: [SO.Molecule.Model],
-    params: a => ({ modelIndex: PD.Converted(plus1, minus1, PD.Numeric(1, { min: 1, max: a.data.length, step: 1 }, { description: 'Model Index' })) }),
     isApplicable: a => a.data.length > 0,
     apply({ a, params }) {
         if (params.modelIndex < 0 || params.modelIndex >= a.data.length) throw new Error(`Invalid modelIndex ${params.modelIndex}`);
@@ -68,15 +69,16 @@ const ModelFromTrajectory = PluginStateTransform.Create<SO.Molecule.Trajectory,
 });
 
 export { StructureFromModel }
-namespace StructureFromModel { export interface Params { } }
-const StructureFromModel = PluginStateTransform.Create<SO.Molecule.Model, SO.Molecule.Structure, StructureFromModel.Params>({
+type StructureFromModel = typeof StructureFromModel
+const StructureFromModel = PluginStateTransform.BuiltIn({
     name: 'structure-from-model',
+    from: SO.Molecule.Model,
+    to: SO.Molecule.Structure,
+})({
     display: {
         name: 'Structure from Model',
         description: 'Create a molecular structure from the specified model.'
     },
-    from: [SO.Molecule.Model],
-    to: [SO.Molecule.Structure],
     apply({ a }) {
         let s = Structure.ofModel(a.data);
         const label = { label: a.data.label, description: s.elementCount === 1 ? '1 element' : `${s.elementCount} elements` };
@@ -89,19 +91,20 @@ function structureDesc(s: Structure) {
 }
 
 export { StructureAssemblyFromModel }
-namespace StructureAssemblyFromModel { export interface Params { /** if not specified, use the 1st */ id?: string } }
-const StructureAssemblyFromModel = PluginStateTransform.Create<SO.Molecule.Model, SO.Molecule.Structure, StructureAssemblyFromModel.Params>({
+type StructureAssemblyFromModel = typeof StructureAssemblyFromModel
+const StructureAssemblyFromModel = PluginStateTransform.BuiltIn({
     name: 'structure-assembly-from-model',
-    display: {
-        name: 'Structure Assembly',
-        description: 'Create a molecular structure assembly.'
-    },
-    from: [SO.Molecule.Model],
-    to: [SO.Molecule.Structure],
+    from: SO.Molecule.Model,
+    to: SO.Molecule.Structure,
     params(a) {
         const model = a.data;
         const ids = model.symmetry.assemblies.map(a => [a.id, a.id] as [string, string]);
-        return { id: PD.Select(ids.length ? ids[0][0] : '', ids, { label: 'Asm Id', description: 'Assembly Id' }) };
+        return { id: PD.makeOptional(PD.Select(ids.length ? ids[0][0] : '', ids, { label: 'Asm Id', description: 'Assembly Id' })) };
+    }
+})({
+    display: {
+        name: 'Structure Assembly',
+        description: 'Create a molecular structure assembly.'
     },
     apply({ a, params }, plugin: PluginContext) {
         return Task.create('Build Assembly', async ctx => {
@@ -126,19 +129,20 @@ const StructureAssemblyFromModel = PluginStateTransform.Create<SO.Molecule.Model
 });
 
 export { StructureSelection }
-namespace StructureSelection { export interface Params { query: Expression, label?: string } }
-const StructureSelection = PluginStateTransform.Create<SO.Molecule.Structure, SO.Molecule.Structure, StructureSelection.Params>({
+type StructureSelection = typeof StructureSelection
+const StructureSelection = PluginStateTransform.BuiltIn({
     name: 'structure-selection',
+    from: SO.Molecule.Structure,
+    to: SO.Molecule.Structure,
+    params: () => ({
+        query: PD.Value<Expression>(MolScriptBuilder.struct.generator.all, { isHidden: true }),
+        label: PD.makeOptional(PD.Text('', { isHidden: true }))
+    })
+})({
     display: {
         name: 'Structure Selection',
         description: 'Create a molecular structure from the specified model.'
     },
-    from: [SO.Molecule.Structure],
-    to: [SO.Molecule.Structure],
-    params: () => ({
-        query: PD.Value<Expression>(MolScriptBuilder.struct.generator.all, { isHidden: true }),
-        label: PD.Text('', { isOptional: true, isHidden: true })
-    }),
     apply({ a, params }) {
         // TODO: use cache, add "update"
         const compiled = compile<Sel>(params.query);
@@ -150,16 +154,18 @@ const StructureSelection = PluginStateTransform.Create<SO.Molecule.Structure, SO
 });
 
 export { StructureComplexElement }
-namespace StructureComplexElement { export interface Params { type: 'atomic-sequence' | 'water' | 'atomic-het' | 'spheres' } }
-const StructureComplexElement = PluginStateTransform.Create<SO.Molecule.Structure, SO.Molecule.Structure, StructureComplexElement.Params>({
+namespace StructureComplexElement { export type Types = 'atomic-sequence' | 'water' | 'atomic-het' | 'spheres' }
+type StructureComplexElement = typeof StructureComplexElement
+const StructureComplexElement = PluginStateTransform.BuiltIn({
     name: 'structure-complex-element',
+    from: SO.Molecule.Structure,
+    to: SO.Molecule.Structure,
+    params: () => ({ type: PD.Text<StructureComplexElement.Types>('atomic-sequence', { isHidden: true }) }),
+})({
     display: {
         name: 'Complex Element',
         description: 'Create a molecular structure from the specified model.'
     },
-    from: [SO.Molecule.Structure],
-    to: [SO.Molecule.Structure],
-    params: () => ({ type: PD.Text('sequence', { isHidden: true }) }),
     apply({ a, params }) {
         // TODO: update function.
 
@@ -169,7 +175,7 @@ const StructureComplexElement = PluginStateTransform.Create<SO.Molecule.Structur
             case 'water': query = Queries.internal.water(); label = 'Water'; break;
             case 'atomic-het': query = Queries.internal.atomicHet(); label = 'HET Groups/Ligands'; break;
             case 'spheres': query = Queries.internal.spheres(); label = 'Coarse Spheres'; break;
-            default: throw new Error(`${params.type} is a valid complex element.`);
+            default: throw new Error(`${params.type} is a not valid complex element.`);
         }
 
         const result = query(new QueryContext(a.data));

+ 11 - 16
src/mol-plugin/state/transforms/representation.ts

@@ -14,36 +14,31 @@ import { ParamDefinition as PD } from 'mol-util/param-definition';
 import { createTheme } from 'mol-theme/theme';
 
 export { StructureRepresentation3D }
-namespace StructureRepresentation3D {
-    export interface Params {
-        type: { name: string, params: any /** TODO is there "common type" */ },
-        colorTheme: { name: string, params: any /** TODO is there "common type" */ },
-        sizeTheme: { name: string, params: any /** TODO is there "common type" */ },
-    }
-}
-const StructureRepresentation3D = PluginStateTransform.Create<SO.Molecule.Structure, SO.Molecule.Representation3D, StructureRepresentation3D.Params>({
+type StructureRepresentation3D = typeof StructureRepresentation3D
+const StructureRepresentation3D = PluginStateTransform.BuiltIn({
     name: 'structure-representation-3d',
-    display: { name: '3D Representation' },
-    from: [SO.Molecule.Structure],
-    to: [SO.Molecule.Representation3D],
+    from: SO.Molecule.Structure,
+    to: SO.Molecule.Representation3D,
     params: (a, ctx: PluginContext) => ({
-        type: PD.Mapped(
+        type: PD.Mapped<any>(
             ctx.structureRepresentation.registry.default.name,
             ctx.structureRepresentation.registry.types,
             name => PD.Group<any>(ctx.structureRepresentation.registry.get(name).getParams(ctx.structureRepresentation.themeCtx, a.data))),
-        colorTheme: PD.Mapped(
+        colorTheme: PD.Mapped<any>(
             // TODO how to get a default color theme dependent on the repr type?
             ctx.structureRepresentation.themeCtx.colorThemeRegistry.default.name,
             ctx.structureRepresentation.themeCtx.colorThemeRegistry.types,
             name => PD.Group<any>(ctx.structureRepresentation.themeCtx.colorThemeRegistry.get(name).getParams({ structure: a.data }))
         ),
-        sizeTheme: PD.Mapped(
+        sizeTheme: PD.Mapped<any>(
             // TODO how to get a default size theme dependent on the repr type?
             ctx.structureRepresentation.themeCtx.sizeThemeRegistry.default.name,
             ctx.structureRepresentation.themeCtx.sizeThemeRegistry.types,
             name => PD.Group<any>(ctx.structureRepresentation.themeCtx.sizeThemeRegistry.get(name).getParams({ structure: a.data }))
-        ),
-    }),
+        )
+    })
+})({
+    display: { name: '3D Representation' },
     canAutoUpdate({ oldParams, newParams }) {
         // TODO: allow for small molecules
         return oldParams.type.name === newParams.type.name;

+ 2 - 2
src/mol-plugin/ui/plugin.tsx

@@ -82,13 +82,13 @@ export class State extends PluginComponent {
 
     render() {
         const kind = this.plugin.state.behavior.kind.value;
-        return <>
+        return <div className='msp-scrollable-container'>
             <div className='msp-btn-row-group msp-data-beh'>
                 <button className='msp-btn msp-btn-block msp-form-control' onClick={() => this.set('data')} style={{ fontWeight: kind === 'data' ? 'bold' : 'normal'}}>Data</button>
                 <button className='msp-btn msp-btn-block msp-form-control' onClick={() => this.set('behavior')} style={{ fontWeight: kind === 'behavior' ? 'bold' : 'normal'}}>Behavior</button>
             </div>
             <StateTree state={kind === 'data' ? this.plugin.state.dataState : this.plugin.state.behaviorState} />
-        </>
+        </div>
     }
 }
 

+ 39 - 4
src/mol-state/action.ts

@@ -38,8 +38,7 @@ namespace StateAction {
         params: P
     }
 
-    export interface Definition<A extends StateObject = StateObject, T = any, P extends {} = {}> {
-        readonly from: StateObject.Ctor[],
+    export interface DefinitionBase<A extends StateObject = StateObject, T = any, P extends {} = {}> {
         readonly display?: { readonly name: string, readonly description?: string },
 
         /**
@@ -47,12 +46,15 @@ namespace StateAction {
          */
         apply(params: ApplyParams<A, P>, globalCtx: unknown): T | Task<T>,
 
-        params?(a: A, globalCtx: unknown): { [K in keyof P]: PD.Any },
-
         /** Test if the transform can be applied to a given node */
         isApplicable?(a: A, globalCtx: unknown): boolean
     }
 
+    export interface Definition<A extends StateObject = StateObject, T = any, P extends {} = {}> extends DefinitionBase<A, T, P> {
+        readonly from: StateObject.Ctor[],
+        params?(a: A, globalCtx: unknown): { [K in keyof P]: PD.Any }
+    }
+
     export function create<A extends StateObject, T, P extends {} = {}>(definition: Definition<A, T, P>): StateAction<A, T, P> {
         const action: StateAction<A, T, P> = {
             create(params) { return { action, params }; },
@@ -74,4 +76,37 @@ namespace StateAction {
             }
         })
     }
+
+    export namespace Builder {
+        export interface Type<A extends StateObject.Ctor, P extends { }> {
+            from?: A | A[],
+            params?: PD.For<P> | ((a: StateObject.From<A>, globalCtx: any) => PD.For<P>)
+        }
+
+        export interface Root {
+            <A extends StateObject.Ctor, P extends { }>(info: Type<A, P>): Define<StateObject.From<A>, PD.Normalize<P>>
+        }
+
+        export interface Define<A extends StateObject, P> {
+            <T>(def: DefinitionBase<A, T, P>): StateAction<A, T, P>
+        }
+
+        function root(info: Type<any, any>): Define<any, any> {
+            return def => create({
+                from: info.from instanceof Array
+                    ? info.from
+                    : !!info.from ? [info.from] : [],
+                params: typeof info.params === 'object'
+                    ? () => info.params as any
+                    : !!info.params
+                    ? info.params as any
+                    : void 0,
+                ...def
+            });
+        }
+
+        export const build: Root = (info: any) => root(info);
+    }
+
+    export const build = Builder.build;
 }

+ 4 - 3
src/mol-state/object.ts

@@ -23,12 +23,13 @@ namespace StateObject {
     }
 
     export type Type<Cls extends string = string> = { name: string, typeClass: Cls }
-    export type Ctor = { new(...args: any[]): StateObject, type: any }
+    export type Ctor<T extends StateObject = StateObject> = { new(...args: any[]): T, type: any }
+    export type From<C extends Ctor> = C extends Ctor<infer T> ? T : never
 
     export function create<Data, T extends Type>(type: T) {
-        return class implements StateObject<Data, T> {
+        return class O implements StateObject<Data, T> {
             static type = type;
-            static is(obj?: StateObject): obj is StateObject<Data, T> { return !!obj && type === obj.type; }
+            static is(obj?: StateObject): obj is O { return !!obj && type === obj.type; }
             id = UUID.create22();
             type = type;
             label: string;

+ 5 - 4
src/mol-state/state.ts

@@ -311,11 +311,12 @@ function findUpdateRoots(cells: Map<Transform.Ref, StateObjectCell>, tree: State
 
 function findUpdateRootsVisitor(n: Transform, _: any, s: { roots: Ref[], cells: Map<Ref, StateObjectCell> }) {
     const cell = s.cells.get(n.ref);
-    if (cell && cell.obj === StateObject.Null) return false;
     if (!cell || cell.version !== n.version || cell.status === 'error') {
         s.roots.push(n.ref);
         return false;
     }
+    // nothing below a Null object can be an update root
+    if (cell && cell.obj === StateObject.Null) return false;
     return true;
 }
 
@@ -473,13 +474,13 @@ async function updateSubtree(ctx: UpdateContext, root: Ref) {
         ctx.results.push(update);
         if (update.action === 'created') {
             isNull = update.obj === StateObject.Null;
-            ctx.parent.events.log.next(LogEntry.info(`Created ${update.obj.label} in ${formatTimespan(time)}.`));
+            if (!isNull) ctx.parent.events.log.next(LogEntry.info(`Created ${update.obj.label} in ${formatTimespan(time)}.`));
         } else if (update.action === 'updated') {
             isNull = update.obj === StateObject.Null;
-            ctx.parent.events.log.next(LogEntry.info(`Updated ${update.obj.label} in ${formatTimespan(time)}.`));
+            if (!isNull) ctx.parent.events.log.next(LogEntry.info(`Updated ${update.obj.label} in ${formatTimespan(time)}.`));
         } else if (update.action === 'replaced') {
             isNull = update.obj === StateObject.Null;
-            ctx.parent.events.log.next(LogEntry.info(`Updated ${update.obj.label} in ${formatTimespan(time)}.`));
+            if (!isNull) ctx.parent.events.log.next(LogEntry.info(`Updated ${update.obj.label} in ${formatTimespan(time)}.`));
         }
     } catch (e) {
         ctx.changed = true;

+ 51 - 6
src/mol-state/transformer.ts

@@ -56,10 +56,7 @@ export namespace Transformer {
     /** Specify default control descriptors for the parameters */
     // export type ParamsDefinition<A extends StateObject = StateObject, P = any> = (a: A, globalCtx: unknown) => { [K in keyof P]: PD.Any }
 
-    export interface Definition<A extends StateObject = StateObject, B extends StateObject = StateObject, P extends {} = {}> {
-        readonly name: string,
-        readonly from: StateObject.Ctor[],
-        readonly to: StateObject.Ctor[],
+    export interface DefinitionBase<A extends StateObject = StateObject, B extends StateObject = StateObject, P extends {} = {}> {
         readonly display?: { readonly name: string, readonly description?: string },
 
         /**
@@ -78,8 +75,6 @@ export namespace Transformer {
         /** Determine if the transformer can be applied automatically on UI change. Default is false. */
         canAutoUpdate?(params: AutoUpdateParams<A, B, P>, globalCtx: unknown): boolean,
 
-        params?(a: A, globalCtx: unknown): { [K in keyof P]: PD.Any },
-
         /** Test if the transform can be applied to a given node */
         isApplicable?(a: A, globalCtx: unknown): boolean,
 
@@ -90,6 +85,13 @@ export namespace Transformer {
         readonly customSerialization?: { toJSON(params: P, obj?: B): any, fromJSON(data: any): P }
     }
 
+    export interface Definition<A extends StateObject = StateObject, B extends StateObject = StateObject, P extends {} = {}> extends DefinitionBase<A, B, P> {
+        readonly name: string,
+        readonly from: StateObject.Ctor[],
+        readonly to: StateObject.Ctor[],
+        params?(a: A, globalCtx: unknown): { [K in keyof P]: PD.Any },
+    }
+
     const registry = new Map<Id, Transformer<any, any>>();
     const fromTypeIndex: Map<StateObject.Type, Transformer[]> = new Map();
 
@@ -140,6 +142,49 @@ export namespace Transformer {
         return <A extends StateObject, B extends StateObject, P extends {} = {}>(definition: Definition<A, B, P>) => create(namespace, definition);
     }
 
+    export function builderFactory(namespace: string) {
+        return Builder.build(namespace);
+    }
+
+    export namespace Builder {
+        export interface Type<A extends StateObject.Ctor, B extends StateObject.Ctor, P extends { }> {
+            name: string,
+            from: A | A[],
+            to: B | B[],
+            params?: PD.For<P> | ((a: StateObject.From<A>, globalCtx: any) => PD.For<P>)
+        }
+
+        export interface Root {
+            <A extends StateObject.Ctor, B extends StateObject.Ctor, P extends { }>(info: Type<A, B, P>): Define<StateObject.From<A>, StateObject.From<B>, PD.Normalize<P>>
+        }
+
+        export interface Define<A extends StateObject, B extends StateObject, P> {
+            (def: DefinitionBase<A, B, P>): Transformer<A, B, P>
+        }
+
+        function root(namespace: string, info: Type<any, any, any>): Define<any, any, any> {
+            return def => create(namespace, {
+                name: info.name,
+                from: info.from instanceof Array ? info.from : [info.from],
+                to: info.to instanceof Array ? info.to : [info.to],
+                params: typeof info.params === 'object'
+                    ? () => info.params as any
+                    : !!info.params
+                    ? info.params as any
+                    : void 0,
+                ...def
+            });
+        }
+
+        export function build(namespace: string): Root {
+            return (info: any) => root(namespace, info);
+        }
+    }
+
+    export function build(namespace: string): Builder.Root {
+        return Builder.build(namespace);
+    }
+
     export const ROOT = create<any, any, {}>('build-in', {
         name: 'root',
         from: [],

+ 2 - 2
src/mol-state/tree/builder.ts

@@ -59,9 +59,9 @@ namespace StateTreeBuilder {
             return new To(this.state, t.ref, this.root);
         }
 
-        update<T extends Transformer<A, any, any>>(transformer: T, params: (old: Transformer.Params<T>) => Transformer.Params<T>): Root
+        update<T extends Transformer<any, A, any>>(transformer: T, params: (old: Transformer.Params<T>) => Transformer.Params<T>): Root
         update(params: any): Root
-        update<T extends Transformer<A, any, any>>(paramsOrTransformer: T, provider?: (old: Transformer.Params<T>) => Transformer.Params<T>) {
+        update<T extends Transformer<any, A, any>>(paramsOrTransformer: T, provider?: (old: Transformer.Params<T>) => Transformer.Params<T>) {
             let params: any;
             if (provider) {
                 const old = this.state.tree.transforms.get(this.ref)!;

+ 1 - 1
src/mol-theme/color.ts

@@ -56,7 +56,7 @@ namespace ColorTheme {
         readonly factory: (ctx: ThemeDataContext, props: PD.Values<P>) => ColorTheme<P>
         readonly getParams: (ctx: ThemeDataContext) => P
     }
-    export const EmptyProvider: Provider<{}> = { label: '', factory:EmptyFactory, getParams: () => ({}) }
+    export const EmptyProvider: Provider<{}> = { label: '', factory: EmptyFactory, getParams: () => ({}) }
 
     export class Registry {
         private _list: { name: string, provider: Provider<any> }[] = []

+ 32 - 6
src/mol-util/param-definition.ts

@@ -14,23 +14,27 @@ export namespace ParamDefinition {
     export interface Info {
         label?: string,
         description?: string,
-        isOptional?: boolean,
-        isHidden?: boolean
+        isHidden?: boolean,
     }
 
     function setInfo<T extends Info>(param: T, info?: Info): T {
         if (!info) return param;
         if (info.description) param.description = info.description;
         if (info.label) param.label = info.label;
-        if (info.isOptional) param.isOptional = info.isOptional;
         if (info.isHidden) param.isHidden = info.isHidden;
         return param;
     }
 
     export interface Base<T> extends Info {
+        isOptional?: boolean,
         defaultValue: T
     }
 
+    export function makeOptional<T>(p: Base<T>): Base<T | undefined> {
+        p.isOptional = true;
+        return p;
+    }
+
     export interface Value<T> extends Base<T> {
         type: 'value'
     }
@@ -63,11 +67,11 @@ export namespace ParamDefinition {
         return setInfo<Boolean>({ type: 'boolean', defaultValue }, info)
     }
 
-    export interface Text extends Base<string> {
+    export interface Text<T extends string = string> extends Base<T> {
         type: 'text'
     }
-    export function Text(defaultValue: string = '', info?: Info): Text {
-        return setInfo<Text>({ type: 'text', defaultValue }, info)
+    export function Text<T extends string = string>(defaultValue: string = '', info?: Info): Text<T> {
+        return setInfo<Text<T>>({ type: 'text', defaultValue: defaultValue as any }, info)
     }
 
     export interface Color extends Base<ColorData> {
@@ -177,6 +181,11 @@ export namespace ParamDefinition {
     export type Params = { [k: string]: Any }
     export type Values<T extends Params> = { [k in keyof T]: T[k]['defaultValue'] }
 
+    type Optionals<P> = { [K in keyof P]-?: undefined extends P[K] ? K : never }[keyof P]
+    type NonOptionals<P> = { [K in keyof P]-?: undefined extends P[K] ? never: K }[keyof P]
+    export type Normalize<P> = Pick<P, NonOptionals<P>> & Partial<Pick<P, Optionals<P>>>
+    export type For<P> = { [K in keyof P]-?: Base<P[K]> }
+
     export function getDefaultValues<T extends Params>(params: T) {
         const d: { [k: string]: any } = {}
         for (const k of Object.keys(params)) {
@@ -227,6 +236,23 @@ export namespace ParamDefinition {
             if (u.name !== v.name) return false;
             const map = p.map(u.name);
             return isParamEqual(map, u.params, v.params);
+        } else if (p.type === 'multi-select') {
+            const u = a as MultiSelect<any>['defaultValue'], v = b as MultiSelect<any>['defaultValue'];
+            if (u.length !== v.length) return false;
+            if (u.length < 10) {
+                for (let i = 0, _i = u.length; i < _i; i++) {
+                    if (u[i] === v[i]) continue;
+                    if (v.indexOf(u[i]) < 0) return false;
+                }
+            } else {
+                // TODO: should the value of multiselect be a set?
+                const vSet = new Set(v);
+                for (let i = 0, _i = u.length; i < _i; i++) {
+                    if (u[i] === v[i]) continue;
+                    if (!vSet.has(u[i])) return false;
+                }
+            }
+            return true;
         } else if (p.type === 'interval') {
             return a[0] === b[0] && a[1] === b[1];
         } else if (p.type === 'line-graph') {