فهرست منبع

Merge branch 'master' of https://github.com/molstar/molstar

giagitom 1 سال پیش
والد
کامیت
3fe80fe61a
63فایلهای تغییر یافته به همراه2988 افزوده شده و 1151 حذف شده
  1. 18 0
      CHANGELOG.md
  2. BIN
      docs/extensions/mvs/1cbs.png
  3. 0 566
      docs/extensions/mvs/MVS-tree-documentation.md
  4. 161 0
      docs/extensions/mvs/README.md
  5. 185 0
      docs/extensions/mvs/annotations.md
  6. 71 0
      docs/extensions/mvs/camera-settings.md
  7. 238 0
      docs/extensions/mvs/mvs-tree-schema.md
  8. 56 0
      docs/extensions/mvs/selectors.md
  9. 12 8
      examples/mvs/1cbs-focus.mvsj
  10. 20 14
      examples/mvs/1cbs.mvsj
  11. 6 2
      examples/mvs/1h9t_domain_colors.mvsj
  12. 9 9
      examples/mvs/1h9t_domain_labels.mvsj
  13. 318 148
      package-lock.json
  14. 19 15
      package.json
  15. 3 2
      src/apps/viewer/app.ts
  16. 40 0
      src/cli/mvs/mvs-print-schema.ts
  17. 5 21
      src/cli/mvs/mvs-render.ts
  18. 1 1
      src/cli/mvs/mvs-validate.ts
  19. 1 0
      src/extensions/mvs/README.md
  20. 46 0
      src/extensions/mvs/_spec/mvs-data.spec.ts
  21. 24 1
      src/extensions/mvs/behavior.ts
  22. 10 4
      src/extensions/mvs/camera.ts
  23. 9 3
      src/extensions/mvs/components/annotation-structure-component.ts
  24. 20 30
      src/extensions/mvs/components/custom-label/visual.ts
  25. 77 0
      src/extensions/mvs/components/formats.ts
  26. 2 1
      src/extensions/mvs/components/selector.ts
  27. 1 1
      src/extensions/mvs/helpers/schemas.ts
  28. 17 16
      src/extensions/mvs/helpers/utils.ts
  29. 166 21
      src/extensions/mvs/load-helpers.ts
  30. 91 87
      src/extensions/mvs/load.ts
  31. 40 7
      src/extensions/mvs/mvs-data.ts
  32. 11 5
      src/extensions/mvs/tree/generic/params-schema.ts
  33. 14 9
      src/extensions/mvs/tree/generic/tree-schema.ts
  34. 38 1
      src/extensions/mvs/tree/generic/tree-utils.ts
  35. 3 2
      src/extensions/mvs/tree/molstar/conversion.ts
  36. 19 0
      src/extensions/mvs/tree/mvs/_spec/mvs-builder.spec.ts
  37. 26 15
      src/extensions/mvs/tree/mvs/mvs-builder.ts
  38. 4 4
      src/extensions/mvs/tree/mvs/mvs-tree.ts
  39. 4 3
      src/extensions/mvs/tree/mvs/param-types.ts
  40. 1 1
      src/extensions/rcsb/graphql/types.ts
  41. 26 1
      src/mol-geo/geometry/mesh/mesh-builder.ts
  42. 29 6
      src/mol-geo/geometry/text/font-atlas.ts
  43. 1 1
      src/mol-gl/renderable/schema.ts
  44. 1 1
      src/mol-io/reader/cif/schema/bird.ts
  45. 1 1
      src/mol-io/reader/cif/schema/ccd.ts
  46. 1 1
      src/mol-io/reader/cif/schema/mmcif.ts
  47. 6 4
      src/mol-io/writer/ligand-encoder.ts
  48. 1 1
      src/mol-io/writer/mol/encoder.ts
  49. 1 1
      src/mol-io/writer/mol2/encoder.ts
  50. 13 0
      src/mol-math/linear-algebra/3d/mat3.ts
  51. 7 0
      src/mol-model-formats/structure/pdb/conect.ts
  52. 10 2
      src/mol-model-formats/structure/property/bonds/struct_conn.ts
  53. 0 1
      src/mol-repr/shape/loci/label.ts
  54. 14 4
      src/mol-repr/structure/representation/cartoon.ts
  55. 333 0
      src/mol-repr/structure/visual/nucleotide-atomic-bond.ts
  56. 297 0
      src/mol-repr/structure/visual/nucleotide-atomic-element.ts
  57. 195 0
      src/mol-repr/structure/visual/nucleotide-atomic-ring-fill.ts
  58. 26 45
      src/mol-repr/structure/visual/nucleotide-block-mesh.ts
  59. 39 79
      src/mol-repr/structure/visual/nucleotide-ring-mesh.ts
  60. 127 3
      src/mol-repr/structure/visual/util/nucleotide.ts
  61. 3 1
      src/mol-util/array.ts
  62. 69 0
      src/perf-tests/array.ts
  63. 2 2
      src/servers/model/version.ts

+ 18 - 0
CHANGELOG.md

@@ -6,6 +6,24 @@ Note that since we don't clearly distinguish between a public and private interf
 
 ## [Unreleased]
 
+- Add new `cartoon` visuals to support atomic nucleotide base with sugar
+- Add `thicknessFactor` to `cartoon` representation for scaling nucleotide block/ring/atomic-fill visuals
+- Use bonds from `_struct_conn` in mmCIF files that use `label_seq_id`
+- Fix measurement label `offsetZ` default: not needed when `scaleByRadius` is enbaled
+- Support for label rendering in HeadlessPluginContext
+- MolViewSpec extension
+  - Support all X11 colors
+  - Support relative URIs
+  - CLI tools: mvs-validate, mvs-render, mvs-print-schema
+  - Labels applied in one node
+- ModelServer SDF/MOL2 ligand export: fix atom indices when additional atoms are present
+
+## [v3.43.1] - 2023-12-04
+
+- Fix `react-markdown` dependency
+
+## [v3.43.0] - 2023-12-02
+
 - Fix `State.tryGetCellData` (return type & data check)
 - Don't change camera.target unless flyMode or pointerLock are enabled
 - Handle empty CIF files

BIN
docs/extensions/mvs/1cbs.png


+ 0 - 566
docs/extensions/mvs/MVS-tree-documentation.md

@@ -1,566 +0,0 @@
-(This documentation was auto-generated by the `treeSchemaToMarkdown` function)
-
-
-Tree schema:
-
-  - **`root`**
-
-    [Root of the tree must be of this kind]
-
-    Auxiliary node kind that only appears as the tree root.
-
-    Parent: none
-
-    Params: none
-
-  - **`download`**
-
-    This node instructs to retrieve a data resource.
-
-    Parent: `root`
-
-    Params:
-
-      - **`url: `**`string`
-
-        URL of the data resource.
-
-  - **`parse`**
-
-    This node instructs to parse a data resource.
-
-    Parent: `download`
-
-    Params:
-
-      - **`format: `**`"mmcif" | "bcif" | "pdb"`
-
-        Format of the input data resource.
-
-  - **`structure`**
-
-    This node instructs to create a structure from a parsed data resource. "Structure" refers to an internal representation of molecular coordinates without any visual representation.
-
-    Parent: `parse`
-
-    Params:
-
-      - **`kind: `**`"model" | "assembly" | "symmetry" | "symmetry_mates"`
-
-        Type of structure to be created (`"model"` for original model coordinates, `"assembly"` for assembly structure, `"symmetry"` for a set of crystal unit cells based on Miller indices, `"symmetry_mates"` for a set of asymmetric units within a radius from the original model).
-
-      - **`block_header?: `**`string | null`
-
-        Header of the CIF block to read coordinates from (only applies when the input data are from CIF or BinaryCIF). If `null`, block is selected based on `block_index`.
-
-        Default: `null`
-
-      - **`block_index?: `**`Integer`
-
-        0-based index of the CIF block to read coordinates from (only applies when the input data are from CIF or BinaryCIF and `block_header` is `null`).
-
-        Default: `0`
-
-      - **`model_index?: `**`Integer`
-
-        0-based index of model in case the input data contain multiple models.
-
-        Default: `0`
-
-      - **`assembly_id?: `**`string | null`
-
-        Assembly identifier (only applies when `kind` is `"assembly"`). If `null`, the first assembly is selected.
-
-        Default: `null`
-
-      - **`radius?: `**`number`
-
-        Distance (in Angstroms) from the original model in which asymmetric units should be included (only applies when `kind` is `"symmetry_mates"`).
-
-        Default: `5`
-
-      - **`ijk_min?: `**`[Integer, Integer, Integer]`
-
-        Miller indices of the bottom-left unit cell to be included (only applies when `kind` is `"symmetry"`).
-
-        Default: `[-1, -1, -1]`
-
-      - **`ijk_max?: `**`[Integer, Integer, Integer]`
-
-        Miller indices of the top-right unit cell to be included (only applies when `kind` is `"symmetry"`).
-
-        Default: `[1, 1, 1]`
-
-  - **`transform`**
-
-    This node instructs to rotate and/or translate structure coordinates.
-
-    Parent: `structure`
-
-    Params:
-
-      - **`rotation?: `**`Array<number>`
-
-        Rotation matrix (3x3 matrix flattened in column major format (j*3+i indexing), this is equivalent to Fortran-order in numpy). This matrix will multiply the structure coordinates from the left. The default value is the identity matrix (corresponds to no rotation).
-
-        Default: `[1, 0, 0, 0, 1, 0, 0, 0, 1]`
-
-      - **`translation?: `**`[number, number, number]`
-
-        Translation vector, applied to the structure coordinates after rotation. The default value is the zero vector (corresponds to no translation).
-
-        Default: `[0, 0, 0]`
-
-  - **`component`**
-
-    This node instructs to create a component (i.e. a subset of the parent structure).
-
-    Parent: `structure`
-
-    Params:
-
-      - **`selector: `**`("all" | "polymer" | "protein" | "nucleic" | "branched" | "ligand" | "ion" | "water") | Partial<{ label_entity_id: string, label_asym_id: string, auth_asym_id: string, label_seq_id: Integer, auth_seq_id: Integer, pdbx_PDB_ins_code: string, beg_label_seq_id: Integer, end_label_seq_id: Integer, beg_auth_seq_id: Integer, end_auth_seq_id: Integer, label_atom_id: string, auth_atom_id: string, type_symbol: string, atom_id: Integer, atom_index: Integer }> | Array<Partial<{ label_entity_id: string, label_asym_id: string, auth_asym_id: string, label_seq_id: Integer, auth_seq_id: Integer, pdbx_PDB_ins_code: string, beg_label_seq_id: Integer, end_label_seq_id: Integer, beg_auth_seq_id: Integer, end_auth_seq_id: Integer, label_atom_id: string, auth_atom_id: string, type_symbol: string, atom_id: Integer, atom_index: Integer }>>`
-
-        Defines what part of the parent structure should be included in this component.
-
-        Default: `"all"`
-
-  - **`component_from_uri`**
-
-    This node instructs to create a component defined by an external annotation resource.
-
-    Parent: `structure`
-
-    Params:
-
-      - **`uri: `**`string`
-
-        URL of the annotation resource.
-
-      - **`format: `**`"cif" | "bcif" | "json"`
-
-        Format of the annotation resource.
-
-      - **`schema: `**`"whole_structure" | "entity" | "chain" | "auth_chain" | "residue" | "auth_residue" | "residue_range" | "auth_residue_range" | "atom" | "auth_atom" | "all_atomic"`
-
-        Annotation schema defines what fields in the annotation will be taken into account.
-
-      - **`block_header?: `**`string | null`
-
-        Header of the CIF block to read annotation from (only applies when `format` is `"cif"` or `"bcif"`). If `null`, block is selected based on `block_index`.
-
-        Default: `null`
-
-      - **`block_index?: `**`Integer`
-
-        0-based index of the CIF block to read annotation from (only applies when `format` is `"cif"` or `"bcif"` and `block_header` is `null`).
-
-        Default: `0`
-
-      - **`category_name?: `**`string | null`
-
-        Name of the CIF category to read annotation from (only applies when `format` is `"cif"` or `"bcif"`). If `null`, the first category in the block is used.
-
-        Default: `null`
-
-      - **`field_name?: `**`string`
-
-        Name of the column in CIF or field name (key) in JSON that contains the dependent variable (color/label/tooltip/component_id...).
-
-        Default: `"component"`
-
-      - **`field_values?: `**`Array<string> | null`
-
-        List of component identifiers (i.e. values in the field given by `field_name`) which should be included in this component. If `null`, component identifiers are ignored (all annotation rows are included), and `field_name` field can be dropped from the annotation.
-
-        Default: `null`
-
-  - **`component_from_source`**
-
-    This node instructs to create a component defined by an annotation resource included in the same file this structure was loaded from. Only applicable if the structure was loaded from an mmCIF or BinaryCIF file.
-
-    Parent: `structure`
-
-    Params:
-
-      - **`schema: `**`"whole_structure" | "entity" | "chain" | "auth_chain" | "residue" | "auth_residue" | "residue_range" | "auth_residue_range" | "atom" | "auth_atom" | "all_atomic"`
-
-        Annotation schema defines what fields in the annotation will be taken into account.
-
-      - **`block_header?: `**`string | null`
-
-        Header of the CIF block to read annotation from. If `null`, block is selected based on `block_index`.
-
-        Default: `null`
-
-      - **`block_index?: `**`Integer`
-
-        0-based index of the CIF block to read annotation from (only applies when `block_header` is `null`).
-
-        Default: `0`
-
-      - **`category_name?: `**`string | null`
-
-        Name of the CIF category to read annotation from. If `null`, the first category in the block is used.
-
-        Default: `null`
-
-      - **`field_name?: `**`string`
-
-        Name of the column in CIF or field name (key) in JSON that contains the dependent variable (color/label/tooltip/component_id...).
-
-        Default: `"component"`
-
-      - **`field_values?: `**`Array<string> | null`
-
-        List of component identifiers (i.e. values in the field given by `field_name`) which should be included in this component. If `null`, component identifiers are ignored (all annotation rows are included), and `field_name` field can be dropped from the annotation.
-
-        Default: `null`
-
-  - **`representation`**
-
-    This node instructs to create a visual representation of a component.
-
-    Parent: `component` or `component_from_uri` or `component_from_source`
-
-    Params:
-
-      - **`type: `**`"ball_and_stick" | "cartoon" | "surface"`
-
-        Method of visual representation of the component.
-
-  - **`color`**
-
-    This node instructs to apply color to a visual representation.
-
-    Parent: `representation`
-
-    Params:
-
-      - **`color: `**`HexColor | ("white" | "gray" | "black" | "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "magenta")`
-
-        Color to apply to the representation. Can be either a color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`).
-
-      - **`selector?: `**`("all" | "polymer" | "protein" | "nucleic" | "branched" | "ligand" | "ion" | "water") | Partial<{ label_entity_id: string, label_asym_id: string, auth_asym_id: string, label_seq_id: Integer, auth_seq_id: Integer, pdbx_PDB_ins_code: string, beg_label_seq_id: Integer, end_label_seq_id: Integer, beg_auth_seq_id: Integer, end_auth_seq_id: Integer, label_atom_id: string, auth_atom_id: string, type_symbol: string, atom_id: Integer, atom_index: Integer }> | Array<Partial<{ label_entity_id: string, label_asym_id: string, auth_asym_id: string, label_seq_id: Integer, auth_seq_id: Integer, pdbx_PDB_ins_code: string, beg_label_seq_id: Integer, end_label_seq_id: Integer, beg_auth_seq_id: Integer, end_auth_seq_id: Integer, label_atom_id: string, auth_atom_id: string, type_symbol: string, atom_id: Integer, atom_index: Integer }>>`
-
-        Defines to what part of the representation this color should be applied.
-
-        Default: `"all"`
-
-  - **`color_from_uri`**
-
-    This node instructs to apply colors to a visual representation. The colors are defined by an external annotation resource.
-
-    Parent: `representation`
-
-    Params:
-
-      - **`uri: `**`string`
-
-        URL of the annotation resource.
-
-      - **`format: `**`"cif" | "bcif" | "json"`
-
-        Format of the annotation resource.
-
-      - **`schema: `**`"whole_structure" | "entity" | "chain" | "auth_chain" | "residue" | "auth_residue" | "residue_range" | "auth_residue_range" | "atom" | "auth_atom" | "all_atomic"`
-
-        Annotation schema defines what fields in the annotation will be taken into account.
-
-      - **`block_header?: `**`string | null`
-
-        Header of the CIF block to read annotation from (only applies when `format` is `"cif"` or `"bcif"`). If `null`, block is selected based on `block_index`.
-
-        Default: `null`
-
-      - **`block_index?: `**`Integer`
-
-        0-based index of the CIF block to read annotation from (only applies when `format` is `"cif"` or `"bcif"` and `block_header` is `null`).
-
-        Default: `0`
-
-      - **`category_name?: `**`string | null`
-
-        Name of the CIF category to read annotation from (only applies when `format` is `"cif"` or `"bcif"`). If `null`, the first category in the block is used.
-
-        Default: `null`
-
-      - **`field_name?: `**`string`
-
-        Name of the column in CIF or field name (key) in JSON that contains the dependent variable (color/label/tooltip/component_id...).
-
-        Default: `"color"`
-
-  - **`color_from_source`**
-
-    This node instructs to apply colors to a visual representation. The colors are defined by an annotation resource included in the same file this structure was loaded from. Only applicable if the structure was loaded from an mmCIF or BinaryCIF file.
-
-    Parent: `representation`
-
-    Params:
-
-      - **`schema: `**`"whole_structure" | "entity" | "chain" | "auth_chain" | "residue" | "auth_residue" | "residue_range" | "auth_residue_range" | "atom" | "auth_atom" | "all_atomic"`
-
-        Annotation schema defines what fields in the annotation will be taken into account.
-
-      - **`block_header?: `**`string | null`
-
-        Header of the CIF block to read annotation from. If `null`, block is selected based on `block_index`.
-
-        Default: `null`
-
-      - **`block_index?: `**`Integer`
-
-        0-based index of the CIF block to read annotation from (only applies when `block_header` is `null`).
-
-        Default: `0`
-
-      - **`category_name?: `**`string | null`
-
-        Name of the CIF category to read annotation from. If `null`, the first category in the block is used.
-
-        Default: `null`
-
-      - **`field_name?: `**`string`
-
-        Name of the column in CIF or field name (key) in JSON that contains the dependent variable (color/label/tooltip/component_id...).
-
-        Default: `"color"`
-
-  - **`label`**
-
-    This node instructs to add a label (textual visual representation) to a component.
-
-    Parent: `component` or `component_from_uri` or `component_from_source`
-
-    Params:
-
-      - **`text: `**`string`
-
-        Content of the shown label.
-
-  - **`label_from_uri`**
-
-    This node instructs to add labels (textual visual representations) to parts of a structure. The labels are defined by an external annotation resource.
-
-    Parent: `structure`
-
-    Params:
-
-      - **`uri: `**`string`
-
-        URL of the annotation resource.
-
-      - **`format: `**`"cif" | "bcif" | "json"`
-
-        Format of the annotation resource.
-
-      - **`schema: `**`"whole_structure" | "entity" | "chain" | "auth_chain" | "residue" | "auth_residue" | "residue_range" | "auth_residue_range" | "atom" | "auth_atom" | "all_atomic"`
-
-        Annotation schema defines what fields in the annotation will be taken into account.
-
-      - **`block_header?: `**`string | null`
-
-        Header of the CIF block to read annotation from (only applies when `format` is `"cif"` or `"bcif"`). If `null`, block is selected based on `block_index`.
-
-        Default: `null`
-
-      - **`block_index?: `**`Integer`
-
-        0-based index of the CIF block to read annotation from (only applies when `format` is `"cif"` or `"bcif"` and `block_header` is `null`).
-
-        Default: `0`
-
-      - **`category_name?: `**`string | null`
-
-        Name of the CIF category to read annotation from (only applies when `format` is `"cif"` or `"bcif"`). If `null`, the first category in the block is used.
-
-        Default: `null`
-
-      - **`field_name?: `**`string`
-
-        Name of the column in CIF or field name (key) in JSON that contains the dependent variable (color/label/tooltip/component_id...).
-
-        Default: `"label"`
-
-  - **`label_from_source`**
-
-    This node instructs to add labels (textual visual representations) to parts of a structure. The labels are defined by an annotation resource included in the same file this structure was loaded from. Only applicable if the structure was loaded from an mmCIF or BinaryCIF file.
-
-    Parent: `structure`
-
-    Params:
-
-      - **`schema: `**`"whole_structure" | "entity" | "chain" | "auth_chain" | "residue" | "auth_residue" | "residue_range" | "auth_residue_range" | "atom" | "auth_atom" | "all_atomic"`
-
-        Annotation schema defines what fields in the annotation will be taken into account.
-
-      - **`block_header?: `**`string | null`
-
-        Header of the CIF block to read annotation from. If `null`, block is selected based on `block_index`.
-
-        Default: `null`
-
-      - **`block_index?: `**`Integer`
-
-        0-based index of the CIF block to read annotation from (only applies when `block_header` is `null`).
-
-        Default: `0`
-
-      - **`category_name?: `**`string | null`
-
-        Name of the CIF category to read annotation from. If `null`, the first category in the block is used.
-
-        Default: `null`
-
-      - **`field_name?: `**`string`
-
-        Name of the column in CIF or field name (key) in JSON that contains the dependent variable (color/label/tooltip/component_id...).
-
-        Default: `"label"`
-
-  - **`tooltip`**
-
-    This node instructs to add a tooltip to a component. "Tooltip" is a text which is not a part of the visualization but should be presented to the users when they interact with the component (typically, the tooltip will be shown somewhere on the screen when the user hovers over a visual representation of the component).
-
-    Parent: `component` or `component_from_uri` or `component_from_source`
-
-    Params:
-
-      - **`text: `**`string`
-
-        Content of the shown tooltip.
-
-  - **`tooltip_from_uri`**
-
-    This node instructs to add tooltips to parts of a structure. The tooltips are defined by an external annotation resource.
-
-    Parent: `structure`
-
-    Params:
-
-      - **`uri: `**`string`
-
-        URL of the annotation resource.
-
-      - **`format: `**`"cif" | "bcif" | "json"`
-
-        Format of the annotation resource.
-
-      - **`schema: `**`"whole_structure" | "entity" | "chain" | "auth_chain" | "residue" | "auth_residue" | "residue_range" | "auth_residue_range" | "atom" | "auth_atom" | "all_atomic"`
-
-        Annotation schema defines what fields in the annotation will be taken into account.
-
-      - **`block_header?: `**`string | null`
-
-        Header of the CIF block to read annotation from (only applies when `format` is `"cif"` or `"bcif"`). If `null`, block is selected based on `block_index`.
-
-        Default: `null`
-
-      - **`block_index?: `**`Integer`
-
-        0-based index of the CIF block to read annotation from (only applies when `format` is `"cif"` or `"bcif"` and `block_header` is `null`).
-
-        Default: `0`
-
-      - **`category_name?: `**`string | null`
-
-        Name of the CIF category to read annotation from (only applies when `format` is `"cif"` or `"bcif"`). If `null`, the first category in the block is used.
-
-        Default: `null`
-
-      - **`field_name?: `**`string`
-
-        Name of the column in CIF or field name (key) in JSON that contains the dependent variable (color/label/tooltip/component_id...).
-
-        Default: `"tooltip"`
-
-  - **`tooltip_from_source`**
-
-    This node instructs to add tooltips to parts of a structure. The tooltips are defined by an annotation resource included in the same file this structure was loaded from. Only applicable if the structure was loaded from an mmCIF or BinaryCIF file.
-
-    Parent: `structure`
-
-    Params:
-
-      - **`schema: `**`"whole_structure" | "entity" | "chain" | "auth_chain" | "residue" | "auth_residue" | "residue_range" | "auth_residue_range" | "atom" | "auth_atom" | "all_atomic"`
-
-        Annotation schema defines what fields in the annotation will be taken into account.
-
-      - **`block_header?: `**`string | null`
-
-        Header of the CIF block to read annotation from. If `null`, block is selected based on `block_index`.
-
-        Default: `null`
-
-      - **`block_index?: `**`Integer`
-
-        0-based index of the CIF block to read annotation from (only applies when `block_header` is `null`).
-
-        Default: `0`
-
-      - **`category_name?: `**`string | null`
-
-        Name of the CIF category to read annotation from. If `null`, the first category in the block is used.
-
-        Default: `null`
-
-      - **`field_name?: `**`string`
-
-        Name of the column in CIF or field name (key) in JSON that contains the dependent variable (color/label/tooltip/component_id...).
-
-        Default: `"tooltip"`
-
-  - **`focus`**
-
-    This node instructs to set the camera focus to a component (zoom in).
-
-    Parent: `component` or `component_from_uri` or `component_from_source`
-
-    Params:
-
-      - **`direction?: `**`[number, number, number]`
-
-        Vector describing the direction of the view (camera position -> focused target).
-
-        Default: `[0, 0, -1]`
-
-      - **`up?: `**`[number, number, number]`
-
-        Vector which will be aligned with the screen Y axis.
-
-        Default: `[0, 1, 0]`
-
-  - **`camera`**
-
-    This node instructs to set the camera position and orientation.
-
-    Parent: `root`
-
-    Params:
-
-      - **`target: `**`[number, number, number]`
-
-        Coordinates of the point in space at which the camera is pointing.
-
-      - **`position: `**`[number, number, number]`
-
-        Coordinates of the camera.
-
-      - **`up?: `**`[number, number, number]`
-
-        Vector which will be aligned with the screen Y axis.
-
-        Default: `[0, 1, 0]`
-
-  - **`canvas`**
-
-    This node sets canvas properties.
-
-    Parent: `root`
-
-    Params:
-
-      - **`background_color: `**`HexColor | ("white" | "gray" | "black" | "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "magenta")`
-
-        Color of the canvas background. Can be either a color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`).

+ 161 - 0
docs/extensions/mvs/README.md

@@ -0,0 +1,161 @@
+# Mol* MolViewSpec extension
+
+**MolViewSpec (MVS)** is a tool for standardized description of reproducible molecular visualizations shareable across software applications.
+
+MolViewSpec provides a generic description of typical visual scenes that may occur as part of molecular visualizations. A tree format allows the composition of complex scene descriptors by combining reoccurring nodes that serve as building blocks.
+
+
+## More sources:
+
+- MolViewSpec home page: https://molstar.org/mol-view-spec/
+- Python library `molviewspec` for building MolViewSpec views: https://pypi.org/project/molviewspec/
+- Python library `molviewspec` in action: https://colab.research.google.com/drive/1O2TldXlS01s-YgkD9gy87vWsfCBTYuz9
+
+
+## MolViewSpec data structure
+
+MVS is based on a tree format, i.e. a molecular view is described as a tree where individual node types represent common data operations needed to create the view (e.g. download, parse, color). Each node can have parameters that provide additional details for the operation. 
+
+A simple example of a MVS tree showing PDB structure 1cbs:
+
+![Example MolViewSpec - 1cbs with labelled protein and ligand](./1cbs.png "Example MolViewSpec")
+
+```txt
+- root {}
+  - download {url: "https://www.ebi.ac.uk/pdbe/entry-files/1cbs.bcif"}
+    - parse {format: "bcif"}
+      - structure {type: "model"}
+        - component {selector: "polymer"}
+          - representation {type: "cartoon"}
+            - color {color: "green"}
+            - color {selector: {label_asym_id: "A", beg_label_seq_id: 1, end_label_seq_id: 50}, color: "#6688ff"}
+          - label {text: "Protein"}
+        - component {selector: "ligand"}
+          - representation {type: "ball_and_stick"}
+            - color {color: "#cc3399"}
+          - label {text: "Retinoic Acid"}
+  - canvas {background_color: "#ffffee"}
+  - camera {target: [17,21,27], position: [41,34,69], up: [-0.129,0.966,-0.224]}
+```
+
+(This is just a human-friendly representation of the tree, not the actual data format!)
+
+A complete list of supported node types and their parameters is described by the [MVS tree schema](./mvs-tree-schema.md).
+
+### Encoding
+
+A MolViewSpec tree can be encoded and stored in `.mvsj` format, which is basically a JSON representation of the tree with additional metadata:
+
+```json
+{
+  "metadata": {
+    "title": "Example MolViewSpec - 1cbs with labelled protein and ligand",
+    "version": "1",
+    "timestamp": "2023-11-24T10:38:17.483Z"
+  },
+  "root": {
+    "kind": "root",
+    "children": [
+      {
+        "kind": "download",
+        "params": {"url": "https://www.ebi.ac.uk/pdbe/entry-files/1cbs.bcif"},
+        "children": [
+          {
+            "kind": "parse",
+            "params": {"format": "bcif"},
+            "children": [
+    ...
+```
+Complete file: [1cbs.mvsj](../../../examples/mvs/1cbs.mvsj)
+
+
+## MolViewSpec extension functionality
+
+Mol* MolViewSpec extension provides functionality for building, validating, and visualizing MVS views.
+
+### Graphical user interface
+
+- **Drag&drop support:** The easiest way to load a MVS view into Mol* Viewer is to drag a `.mvsj` file and drop it in a browser window with Mol* Viewer.
+
+- **Load via menu:** Another way to load a MVS view is to use "Download File" or "Open Files" action, available in the "Home" tab in the left panel. For these actions, the "Format" parameter must be set to "MVSJ" (in the "Miscellaneous" category) or "Auto".
+
+- **URL parameters:** Mol* Viewer supports `mvs-url`, `mvs-data`, and `mvs-format` URL parameters to specify a MVS view to be loaded when the viewer is initialized.
+  - `mvs-url` specifies the address from which the MVS view should be retrieved.
+  - `mvs-data` specifies the MVS view data directly. Keep in mind that some characters must be escaped to be used in the URL. Also beware that URLs longer than 2000 character may not work in all browsers.
+  - `mvs-format` specifies the format of the MVS view data (from `mvs-url` or `mvs-data`). The only allowed (and default) value is `mvsj`, as this is currently the only supported format.
+  
+  Examples of URL parameter usage:
+
+  - https://molstar.org/viewer?mvs-format=mvsj&mvs-url=https://raw.githubusercontent.com/molstar/molstar/master/examples/mvs/1cbs.mvsj
+
+  - https://molstar.org/viewer?mvs-format=mvsj&mvs-data=%7B%22metadata%22%3A%7B%22title%22%3A%22Example%20MolViewSpec%20-%201cbs%20with%20labelled%20protein%20and%20ligand%22%2C%22version%22%3A%221%22%2C%22timestamp%22%3A%222023-11-24T10%3A38%3A17.483%22%7D%2C%22root%22%3A%7B%22kind%22%3A%22root%22%2C%22children%22%3A%5B%7B%22kind%22%3A%22download%22%2C%22params%22%3A%7B%22url%22%3A%22https%3A//www.ebi.ac.uk/pdbe/entry-files/1cbs.bcif%22%7D%2C%22children%22%3A%5B%7B%22kind%22%3A%22parse%22%2C%22params%22%3A%7B%22format%22%3A%22bcif%22%7D%2C%22children%22%3A%5B%7B%22kind%22%3A%22structure%22%2C%22params%22%3A%7B%22type%22%3A%22model%22%7D%2C%22children%22%3A%5B%7B%22kind%22%3A%22component%22%2C%22params%22%3A%7B%22selector%22%3A%22polymer%22%7D%2C%22children%22%3A%5B%7B%22kind%22%3A%22representation%22%2C%22params%22%3A%7B%22type%22%3A%22cartoon%22%7D%2C%22children%22%3A%5B%7B%22kind%22%3A%22color%22%2C%22params%22%3A%7B%22color%22%3A%22green%22%7D%7D%2C%7B%22kind%22%3A%22color%22%2C%22params%22%3A%7B%22selector%22%3A%7B%22label_asym_id%22%3A%22A%22%2C%22beg_label_seq_id%22%3A1%2C%22end_label_seq_id%22%3A50%7D%2C%22color%22%3A%22%236688ff%22%7D%7D%5D%7D%2C%7B%22kind%22%3A%22label%22%2C%22params%22%3A%7B%22text%22%3A%22Protein%22%7D%7D%5D%7D%2C%7B%22kind%22%3A%22component%22%2C%22params%22%3A%7B%22selector%22%3A%22ligand%22%7D%2C%22children%22%3A%5B%7B%22kind%22%3A%22representation%22%2C%22params%22%3A%7B%22type%22%3A%22ball_and_stick%22%7D%2C%22children%22%3A%5B%7B%22kind%22%3A%22color%22%2C%22params%22%3A%7B%22color%22%3A%22%23cc3399%22%7D%7D%5D%7D%2C%7B%22kind%22%3A%22label%22%2C%22params%22%3A%7B%22text%22%3A%22Retinoic%20Acid%22%7D%7D%5D%7D%5D%7D%5D%7D%5D%7D%2C%7B%22kind%22%3A%22canvas%22%2C%22params%22%3A%7B%22background_color%22%3A%22%23ffffee%22%7D%7D%2C%7B%22kind%22%3A%22camera%22%2C%22params%22%3A%7B%22target%22%3A%5B17%2C21%2C27%5D%2C%22position%22%3A%5B41%2C34%2C69%5D%2C%22up%22%3A%5B-0.129%2C0.966%2C-0.224%5D%7D%7D%5D%7D%7D
+
+
+### Programming interface
+
+Most functions for manipulation of MVS data (including parsing, encoding, validating, and building) are provided by the `MVSData` object (defined in [src/extensions/mvs/mvs-data.ts](/src/extensions/mvs/mvs-data.ts)). In TypeScript, `MVSData` is also the type for a MVS view.
+
+The `loadMVS` function (defined in [src/extensions/mvs/load.ts](/src/extensions/mvs/load.ts)) can be used to load MVS view data into Mol* Viewer.
+
+Example usage:
+
+```ts
+// Fetch a MVS, validate, and load
+const response = await fetch('https://raw.githubusercontent.com/molstar/molstar/master/examples/mvs/1cbs.mvsj');
+const rawData = await response.text();
+const mvsData: MVSData = MVSData.fromMVSJ(rawData);
+if (!MVSData.isValid(mvsData)) throw new Error(`Oh no: ${MVSData.validationIssues(mvsData)}`);
+await loadMVS(this.plugin, mvsData, { replaceExisting: true });
+console.log('Loaded this:', MVSData.toPrettyString(mvsData));
+console.log('Loaded this:', MVSData.toMVSJ(mvsData));
+
+// Build a MVS and load
+const builder = MVSData.createBuilder();
+const structure = builder
+    .download({ url: 'https://www.ebi.ac.uk/pdbe/entry-files/download/1og2_updated.cif' })
+    .parse({ format: 'mmcif' })
+    .modelStructure();
+structure
+    .component({ selector: 'polymer' })
+    .representation({ type: 'cartoon' });
+structure
+    .component({ selector: 'ligand' })
+    .representation({ type: 'ball_and_stick' })
+    .color({ color: '#aa55ff' });
+const mvsData2: MVSData = builder.getState();
+await loadMVS(this.plugin, mvsData2, { replaceExisting: false });
+```
+
+When using the pre-built Mol* plugin bundle, `MVSData` and `loadMVS` are exposed as `molstar.PluginExtensions.mvs.MVSData` and `molstar.PluginExtensions.mvs.loadMVS`. Furthermore, the `molstar.Viewer` class has `loadMvsFromUrl` and `loadMvsData` methods, providing the same functionality as `mvs-url` and `mvs-data` URL parameters.
+
+
+### Command-line utilities
+
+The MVS extension in Mol* provides a few command-line utilities, which can be executed via NodeJS:
+
+- `mvs-validate` provides validation of MolViewSpec files
+- `mvs-render` creates images based on MolViewSpec files
+- `mvs-print-schema` prints MolViewSpec tree schema (i.e. currently supported node types and their parameters)
+
+Example usage:
+
+```sh
+# Validate a MolViewSpec file `examples/mvs/1cbs.mvsj`
+node lib/commonjs/cli/mvs/mvs-validate examples/mvs/1cbs.mvsj
+
+# Render a MolViewSpec file `examples/mvs/1cbs.mvsj` to `../outputs/1cbs.png`
+npm install --no-save canvas gl jpeg-js pngjs  # Might be needed before the first execution
+node lib/commonjs/cli/mvs/mvs-render -i examples/mvs/1cbs.mvsj -o ../outputs/1cbs.png --size 800x600 --molj
+
+# Print MolViewSpec tree schema formatted as markdown
+node lib/commonjs/cli/mvs/mvs-print-schema --markdown
+```
+
+(If you installed Mol* package from the npm repository, use can just type `npx mvs-validate`...).
+
+
+## Topics
+
+- [Selectors](./selectors.md)
+- [Annotations](./annotations.md)
+- [Camera Settings](./camera-settings.md)

+ 185 - 0
docs/extensions/mvs/annotations.md

@@ -0,0 +1,185 @@
+# MVS annotations
+
+Annotations are used to define substructures (components) and apply colors, labels, or tooltips to them. In contrast to [selectors](./selectors.md), annotations are defined in a separate file, which can then be referenced in the main MVS file.
+
+
+## MVS annotation files
+
+MVS annotations can be encoded in multiple different formats, but their logic is always the same and in fact very similar to that of selectors.
+
+### JSON format
+
+The simplest example of an annotation in JSON format is just a JSON-encoded [union component expression](./selectors.md) selector. Here is a simple annotation containing 4 **annotation rows**:
+
+```json
+[
+    { "label_asym_id": "A" },
+    { "label_asym_id": "B" },
+    { "label_asym_id": "B", "beg_label_seq_id": 100, "end_label_seq_id": 200 },
+    { "label_asym_id": "B", "beg_label_seq_id": 150, "end_label_seq_id": 160 },
+]
+```
+
+However, in a typical annotation, there is at least one extra field that provides the value of the dependent variable (such as color or label) mapped to each annotation row:
+
+```json
+[
+    { "label_asym_id": "A", "color": "#00ff00" },
+    { "label_asym_id": "B", "color": "blue" },
+    { "label_asym_id": "B", "beg_label_seq_id": 100, "end_label_seq_id": 200, "color": "skyblue" }
+    { "label_asym_id": "B", "beg_label_seq_id": 150, "end_label_seq_id": 160, "color": "lightblue" }
+]
+```
+
+This particular annotation (when applied via `color_from_uri` node) will apply green color (#00ff00) to the whole chain A and three shades of blue to the chain B. Later annotation rows override earlier rows, therefore residues 1–99 will be blue, 100–149 skyblue, 150–160 lightblue, 161–200 skyblue, and 201–end blue. (Tip: to color all the rest of the structure in one color, add an annotation row with no selector fields (e.g. `{ "color": "yellow" }`) to the beginning of the annotation.)
+
+Real-life annotation files can include huge numbers of annotation rows. To avoid repeating the same field keys in every row, we can convert the array-of-objects into object-of-arrays. This will result in an equivalent annotation but smaller file size:
+
+```json
+{
+    "label_asym_id": ["A", "B", "B", "B"],
+    "beg_label_seq_id": [null, null, 100, 150],
+    "end_label_seq_id": [null, null, 200, 160],
+    "color": ["#00ff00", "blue", "skyblue", "lightblue"]
+}
+```
+
+A more complex example of JSON annotation is provided in [/examples/mvs/1h9t_domains.json](/examples/mvs/1h9t_domains.json).
+
+### CIF format
+
+Annotations can also be encoded using CIF format, a table-based format which is commonly used in structure biology to store structures or any kind of tabular data.
+
+The example from above, encoded as CIF, would look like this:
+
+```cif
+data_annotation
+loop_
+_coloring.label_asym_id
+_coloring.beg_label_seq_id
+_coloring.end_label_seq_id
+_coloring.color
+A   .   . '#00ff00'
+B   .   . 'blue'
+B 100 200 'skyblue'
+B 150 160 'lightblue'
+```
+
+An advantage of the CIF format is that it can include multiple annotation tables in the same file, organized into blocks and categories. Then the MVS file can reference individual tables using `block_header` (or `block_index`) and `category_name` parameters. The column containing the dependent variable can be specified using `field_name` parameter. In this case, we could use `"block_header": "annotation", "category_name": "coloring", "field_name": "color"`.
+
+### BCIF format
+
+This has exactly the same structure as the CIF format, but encoded using [BinaryCIF](https://github.com/molstar/BinaryCIF).
+
+
+## Referencing MVS annotations in MVS tree
+
+### From URI
+
+MVS annotations can be referenced in `color_from_uri`, `label_from_uri`, `tooltip_from_uri`, and `component_from_uri` nodes in MVS tree.
+
+For example this part of a MVS tree:
+
+```txt
+- representation {type: "cartoon"}
+  - color {selector: {label_asym_id: "A"}, color: "#00ff00"}
+  - color {selector: {label_asym_id: "B"}, color: "blue"}
+  - color {selector: {label_asym_id: "B", beg_label_seq_id: 100, end_label_seq_id: 200}, color: "skyblue"}
+  - color {selector: {label_asym_id: "B", beg_label_seq_id: 150, end_label_seq_id: 160}, color: "lightblue"}
+```
+
+can be replaced by:
+
+```txt
+- representation {type: "cartoon"}
+  - color_from_uri {uri: "https://example.org/annotations.json", format: "json", schema: "residue_range"}
+```
+
+assuming that the JSON annotation file shown in the previous section is available at `https://example.org/annotations.json`. 
+
+#### Relative URIs
+
+The `uri` parameter can also hold a URI reference (relative URI). In such cases, this URI reference is relative to the URI of the MVS file itself (e.g. if the MVS file is available from `https://example.org/spanish/inquisition/expectations.mvsj`, then the relative URI `./annotations.json` is equivalent to `https://example.org/spanish/inquisition/annotations.json`). This is however not applicable in all cases (e.g. the MVS tree can be constructed ad-hoc within a web application, therefore it has no URI; or the MVS file is loaded from a local disk using drag&drop, therefore the relative location is not accessible by the browser).
+
+### From source
+
+The MVS annotations can in fact be stored within the same mmCIF file from which the structure coordinates are loaded. To reference these annotations, we can use `color_from_source`, `label_from_source`, `tooltip_from_source`, and `component_from_source` nodes. Example:
+
+```txt
+- representation {type: "cartoon"}
+  - color_from_source {schema: "residue_range", block_header: "annotation", category_name: "coloring"}
+```
+
+
+## Annotation schemas
+
+The `schema` parameter of all `*_from_uri` and `*_from_source` nodes specifies the MVS annotation schema, i.e. a set of fields used to select a substructure. In the example above we are using `residue_range` schema, which uses columns `label_entity_id`, `label_asym_id`, `beg_label_seq_id`, and `end_label_seq_id`. (We didn't provide values for `label_entity_id`, so it is not taken into account even though the schema supports it).
+
+
+Table of selector field names supported by individual MVS annotation schemas:
+
+|Field \ Schema|whole_structure|entity|chain|residue|residue_range|atom|auth_chain|auth_residue|auth_residue_range|auth_atom|all_atomic|
+|:------------------|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
+| label_entity_id   |   | X | X | X | X | X |   |   |   |   | X |
+| label_asym_id     |   |   | X | X | X | X |   |   |   |   | X |
+| label_seq_id      |   |   |   | X |   | X |   |   |   |   | X |
+| beg_label_seq_id  |   |   |   |   | X |   |   |   |   |   | X |
+| end_label_seq_id  |   |   |   |   | X |   |   |   |   |   | X |
+| label_atom_id     |   |   |   |   |   | X |   |   |   |   | X |
+| auth_asym_id      |   |   |   |   |   |   | X | X | X | X | X |
+| auth_seq_id       |   |   |   |   |   |   |   | X |   | X | X |
+| pdbx_PDB_ins_code |   |   |   |   |   |   |   | X |   | X | X |
+| beg_auth_seq_id   |   |   |   |   |   |   |   |   | X |   | X |
+| end_auth_seq_id   |   |   |   |   |   |   |   |   | X |   | X |
+| auth_atom_id      |   |   |   |   |   |   |   |   |   | X | X |
+| type_symbol       |   |   |   |   |   | X |   |   |   | X | X |
+| atom_id           |   |   |   |   |   | X |   |   |   | X | X |
+| atom_index        |   |   |   |   |   | X |   |   |   | X | X |
+
+To include all selector field names that are present in the annotation, one can use `"schema": "all_atomic"` (we could use it in the example above and the result would be the same). In future versions of MVS, non-atomic schemas might be added, to select parts of structures that are not composed of atoms, e.g. coarse models or geometric primitives.
+
+
+## `group_id` field
+
+The `group_id` field is a special field supported by all MVS annotation schemas. It does not change the sets of atoms selected by individual rows but instead groups annotation rows together to create more complex selections. This is useful when adding labels to our visualization.
+
+The following example (when applied via `label_from_uri` node) will create 7 separate labels, each bound to a single residue:
+
+```cif
+data_annotation
+loop_
+_labels.label_asym_id
+_labels.label_seq_id
+_labels.color
+_labels.label
+A 100 pink 'Substrate binding site'
+A 150 pink 'Substrate binding site'
+A 170 pink 'Substrate binding site'
+A 200 blue 'Inhibitor binding site'
+A 220 blue 'Inhibitor binding site'
+A 300 lime 'Glycosylation site'
+A 330 lime 'Glycosylation site'
+```
+
+On the other hand, the next example will only create 4 labels ("Substrate binding site" label bound to residues 100, 150, and 170; "Inhibitor binding site" label bound to residues 200 and 220; "Glycosylation site" label bound to residue 300; and "Glycosylation site" label bound to residue 330):
+
+```cif
+data_annotation
+loop_
+_labels.group_id
+_labels.label_asym_id
+_labels.label_seq_id
+_labels.color
+_labels.label
+1 A 100 pink 'Substrate binding site'
+1 A 150 pink 'Substrate binding site'
+1 A 170 pink 'Substrate binding site'
+2 A 200 blue 'Inhibitor binding site'
+2 A 220 blue 'Inhibitor binding site'
+. A 300 lime 'Glycosylation site'
+. A 330 lime 'Glycosylation site'
+```
+
+Note: Annotation rows with empty `group_id` field (`.` in CIF, ommitted field or `null` in JSON) are always treated as separate groups.
+
+Note 2: `group_id` field has no effect on colors, tooltips, components. It only makes any difference for labels.

+ 71 - 0
docs/extensions/mvs/camera-settings.md

@@ -0,0 +1,71 @@
+# MVS camera settings
+
+Camera position and orientation in MVS views can be adjusted in two ways: using a `camera` node or a `focus` node. Global attributes of the MVS view unrelated to camera positioning can be adjusted via a `canvas` node.
+
+## `camera` node
+
+This node instructs to directly set the camera position and orientation. This is done by passing `target`, `position`, and optional `up` vector. The `camera` node is placed as a child of the `root` node (see [MVS tree schema](./mvs-tree-schema.md#camera)).
+
+However, if the `target` and `position` vectors were interpreted directly, the resulting view would wildly depend on the camera field of view (FOV). For example, assume we have a sphere with center in the point [0,0,0] and radius 10 Angstroms, and we set `target=[0,0,0]` and `position=[0,0,20]`. With a camera with vertical FOV=90&deg;, the sphere will fit into the camera's view nicely, with some margin above and under the sphere. But with a camera with vertical FOV=30&deg;, the top and bottom of sphere will be cropped. To avoid these differences, MVS always uses position of a "reference camera" instead of the real camera position.
+
+We define the "reference camera" as a camera with such FOV that a sphere with radius *R* viewed from distance 2*R* (from the center of the sphere) will just fit into view (i.e. there will be no margin but the sphere will not be cropped). This happens to be FOV = 2 arcsin(1/2) = 60&deg; for perspective projection, and FOV = 2 arctan(1/2) &approx; 53&deg; for orthographic projection.
+
+
+When using **perspective** projection, the real camera distance from target and the real camera position can be calculated using these formulas:
+
+$d _\mathrm{adj} = d _\mathrm{ref} \cdot \frac{1}{2 \sin(\alpha/2)}$
+
+$\mathbf{p} _\mathrm{adj} = \mathbf{t} + (\mathbf{p} _\mathrm{ref} - \mathbf{t}) \cdot \frac{1}{2 \sin(\alpha/2)}$
+
+Where $\alpha$ is the vertical FOV of the real camera, $d _\mathrm{ref}$ is the reference camera distance from target, $d _\mathrm{adj}$ is the real (adjusted) camera distance from target, $\mathbf{t}$ is the target position, $\mathbf{p} _\mathrm{ref}$ is the reference camera position (the actual value in the MVS file), and $\mathbf{p} _\mathrm{adj}$ is the real (adjusted) camera position.
+
+When using **orthographic** projection, the formulas are slightly different:
+
+$d _\mathrm{adj} = d _\mathrm{ref} \cdot \frac{1}{2 \tan(\alpha/2)}$
+
+$\mathbf{p} _\mathrm{adj} = \mathbf{t} + (\mathbf{p} _\mathrm{ref} - \mathbf{t}) \cdot \frac{1}{2 \tan(\alpha/2)}$
+
+
+Using the example above (`target=[0,0,0]` and `position=[0,0,20]`), we can calculate that the real camera position will have to be set to:
+
+- [0, 0, 14.14] for FOV=90&deg; (perspective projection)
+- [0, 0, 20] for FOV=60&deg; (perspective projection)
+- [0, 0, 38.68] for FOV=30&deg; (perspective projection)
+
+Note that for orthographic projection this adjustment achieves that the resulting view does not depend on the FOV value. For perspective projection, this is not possible and there will always be some "fisheye effect", but still it greatly reduces the dependence on FOV and avoids the too-much-zoomed-in and too-much-zoomed-out views when FOV changes.
+
+
+The `up` vector describes how the camera should be rotated around the position-target axis, i.e. it is the vector in 3D space that will be point up when projected on the screen. For this, the `up` vector must be perpendicular to the position-target axis. However, the MVS specification does not require that the provided `up` vector be perpendicular. This can be solved by a simple adjustment:
+
+$\mathbf{u} _\mathrm{adj} = \mathrm{normalize} ( ((\mathbf{t}-\mathbf{p}) \times \mathbf{u}) \times (\mathbf{t}-\mathbf{p}) )$
+
+Where $\mathbf{u}$ is the unadjusted up vector (the actual value in the MVS file), $\mathbf{u} _\mathrm{adj}$ is the adjusted up vector, $\mathbf{t}$ is the target position, and $\mathbf{p}$ is the camera position (can be either reference or adjusted camera position, the result will be the same).
+
+If the up vector parameter is not provided, the default value ([0, 1, 0]) will be used (after adjustment).
+
+
+## `focus` node
+
+The other way to adjust camera is to use a `focus` node. This node is placed as a child of a `component` node and instructs to set focus to the parent component (zoom in). This means that the camera target should be set to the center of the bounding sphere of the component, and the camera position should be set so that the bounding sphere just fits into view (vertically and horizontally).
+
+By default, the camera will be oriented so that the X axis points right, the Y axis points up, and the Z axis points towards the observer. This orientation can be changed using the optional vector parameters `direction` and `up` (see [MVS tree schema](./mvs-tree-schema.md#focus)). The `direction` vector describes the direction from the camera position towards the target position (default [0, 0, -1]). The meaning of the `up` vector is the same as for the `camera` node and the same adjustment applies to it (default [0, 1, 0]).
+
+
+
+The reference camera position for a `focus` node can be calculated as follows:
+
+$\mathbf{p} _\mathrm{ref} = \mathbf{t} - \mathrm{normalize}(\mathbf{d}) \cdot 2 r \cdot \max(1, \frac{h}{w})$
+
+Where $\mathbf{t}$ is the target position (center of the bounding sphere of the component), $r$ is the radius of the bounding sphere of the component, $\mathbf{d}$ is the direction vector, $h$ is the height of the viewport, $w$ is the width of the viewport, and $\mathbf{p} _\mathrm{ref}$ is the reference camera position (see explanation above).
+
+Applying the FOV-adjustment formulas from the previous section, we can easily calculate the real position that we have to set to the camera ($\mathbf{p} _\mathrm{adj}$):
+
+For perspective projection: $\mathbf{p} _\mathrm{adj} = \mathbf{t} - \mathrm{normalize}(\mathbf{d}) \cdot \frac{r}{\sin(\alpha/2)} \cdot \max(1, \frac{h}{w})$
+
+For orthographic projection: $\mathbf{p} _\mathrm{adj} = \mathbf{t} - \mathrm{normalize}(\mathbf{d}) \cdot \frac{r}{\tan(\alpha/2)} \cdot \max(1, \frac{h}{w})$
+
+## `canvas` node
+
+Attributes that apply to the MVS view as a whole, but are not related to camera positioning, can be set using a `canvas` node. This node is placed as a child of the `root` node (see [MVS tree schema](./mvs-tree-schema.md#canvas)).
+
+Currently, this only includes one parameter: `background_color`. Its value can be set to either a [X11 color](http://www.w3.org/TR/css3-color/#svg-color) (e.g. `"red"`), or a hexadecimal color code (e.g. `"#FF0011"`). If there is no `canvas` node, the background will be white.

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 238 - 0
docs/extensions/mvs/mvs-tree-schema.md


+ 56 - 0
docs/extensions/mvs/selectors.md

@@ -0,0 +1,56 @@
+# MVS selectors
+
+Selectors are used in MVS to define substructures (components) and apply colors, labels, or tooltips to them. MVS nodes that take a `selector` parameter are `component` (creates a component from the parent `structure` node) and `color` (applies coloring to a part of the parent `representation` node).
+
+There are three kinds of selectors:
+
+- **Static selector** is a string that selects a part of the structure based on entity type. The supported static selectors are these:
+
+  `"all", "polymer", "protein", "nucleic", "branched", "ligand", "ion", "water"`
+
+- **Component expression** is an object that selects a set of atoms based on their properties like chain identifier, residue number, or type symbol. The type of a component expression object is:
+
+  ```ts
+  {
+      label_entity_id?: str,    // Entity identifier
+      label_asym_id?: str,      // Chain identifier in label_* numbering
+      auth_asym_id?: str,       // Chain identifier in auth_* numbering
+      label_seq_id?: int,       // Residue number in label_* numbering
+      auth_seq_id?: int,        // Residue number in auth_* numbering
+      pdbx_PDB_ins_code?: str,  // PDB insertion code
+      beg_label_seq_id?: int,   // Minimum label_seq_id (inclusive), leave blank to start from the beginning of the chain
+      end_label_seq_id?: int,   // Maximum label_seq_id (inclusive), leave blank to go to the end of the chain
+      beg_auth_seq_id?: int,    // Minimum auth_seq_id (inclusive), leave blank to start from the beginning of the chain
+      end_auth_seq_id?: int,    // Maximum auth_seq_id (inclusive), leave blank to go to the end of the chain
+      label_atom_id?: str,      // Atom name like 'CA', 'N', 'O', in label_* numbering
+      auth_atom_id?: str,       // Atom name like 'CA', 'N', 'O', in auth_* numbering
+      type_symbol?: str,        // Element symbol like 'H', 'HE', 'LI', 'BE'
+      atom_id?: int,            // Unique atom identifier (_atom_site.id)
+      atom_index?: int,         // 0-based index of the atom in the source data
+  }
+  ```
+
+  A component expression can include any combination of the fields. An expression with multiple fields selects atoms that fulfill all fields at the same time. Examples:
+
+  ```ts
+  // Select whole chain A
+  selector: { label_asym_id: 'A' }
+
+  // Select residues 100 to 200 (inclusive) in chain B
+  selector: { label_asym_id: 'B', beg_label_seq_id: 100, end_label_seq_id: 200 }
+
+  // Select C-alpha atoms in residue 100 (using auth_* numbering) of any chain
+  selector: { auth_seq_id: 100, type_symbol: 'C', auth_atom_id: 'CA' }
+  ```
+
+- **Union component expression** is an array of simple component expressions. A union component expression is interpreted as set union, i.e. it selects all atoms that fulfill at least one of the expressions in the array. Example:
+
+  ```ts
+  // Select chains A, B, and C
+  selector: [{ label_asym_id: 'A' }, { label_asym_id: 'B' }, { label_asym_id: 'C' }]
+
+  // Select residues up to 100 (inclusive) in chain A plus all magnesium atoms
+  selector: [{ label_asym_id: 'A', end_label_seq_id: 100 }, { type_symbol: 'MG' }]
+  ```
+
+An alternative to using selectors is using [MVS annotations](./annotations.md). This means defining the selections in a separate file and referencing them from the MVS file.

+ 12 - 8
examples/mvs/1cbs-focus.mvsj

@@ -1,14 +1,12 @@
 {
- "version": 6,
+ "metadata": {
+  "title": "Example MolViewSpec - 1cbs with labelled and zoomed ligand",
+  "version": "1",
+  "timestamp": "2023-11-24T10:45:49.873Z"
+ },
  "root": {
   "kind": "root",
   "children": [
-   {
-    "kind": "canvas",
-    "params": {
-     "background_color": "#ffffee"
-    }
-   },
    {
     "kind": "download",
     "params": {
@@ -75,7 +73,7 @@
             "kind": "focus",
             "params": {
              "direction": [0.5, 0, -1],
-             "up": [0.5, 1, 0]
+             "up": [0.365, 0.913, 0.183]
             }
            },
            {
@@ -105,6 +103,12 @@
       ]
      }
     ]
+   },
+   {
+    "kind": "canvas",
+    "params": {
+     "background_color": "#ffffee"
+    }
    }
   ]
  }

+ 20 - 14
examples/mvs/1cbs.mvsj

@@ -1,21 +1,12 @@
 {
- "version": 6,
+ "metadata": {
+  "title": "Example MolViewSpec - 1cbs with labelled protein and ligand",
+  "version": "1",
+  "timestamp": "2023-11-24T10:38:17.483Z"
+ },
  "root": {
   "kind": "root",
   "children": [
-   {
-    "kind": "canvas",
-    "params": {
-     "background_color": "#ffffee"
-    }
-   },
-   {
-    "kind": "camera",
-    "params": {
-     "target": [17, 21, 27],
-     "position": [54, 41, 91]
-    }
-   },
    {
     "kind": "download",
     "params": {
@@ -57,6 +48,7 @@
               "params": {
                "selector": {
                 "label_asym_id": "A",
+                "beg_label_seq_id": 1,
                 "end_label_seq_id": 50
                },
                "color": "#6688ff"
@@ -105,6 +97,20 @@
       ]
      }
     ]
+   },
+   {
+    "kind": "canvas",
+    "params": {
+     "background_color": "#ffffee"
+    }
+   },
+   {
+    "kind": "camera",
+    "params": {
+     "target": [17, 21, 27],
+     "position": [41, 34, 69],
+     "up": [-0.129,0.966,-0.224]
+    }
    }
   ]
  }

+ 6 - 2
examples/mvs/1h9t_domain_colors.mvsj

@@ -1,5 +1,9 @@
 {
- "version": 6,
+ "metadata": {
+  "title": "Example MolViewSpec - 1h9t colored by external annotation",
+  "version": "1",
+  "timestamp": "2023-11-24T10:47:33.182Z"
+ },
  "root": {
   "kind": "root",
   "children": [
@@ -43,7 +47,7 @@
              {
               "kind": "color_from_uri",
               "params": {
-               "uri": "/examples/mvs/1h9t_domains.json",
+               "uri": "./1h9t_domains.json",
                "format": "json",
                "schema": "all_atomic"
               }

+ 9 - 9
examples/mvs/1h9t_domain_labels.mvsj

@@ -1,5 +1,9 @@
 {
- "version": 6,
+ "metadata": {
+  "title": "Example MolViewSpec - 1h9t colored and labelled by external annotation",
+  "version": "1",
+  "timestamp": "2023-11-24T10:48:28.677Z"
+ },
  "root": {
   "kind": "root",
   "children": [
@@ -43,7 +47,7 @@
              {
               "kind": "color_from_uri",
               "params": {
-               "uri": "/examples/mvs/1h9t_domains.json",
+               "uri": "./1h9t_domains.json",
                "format": "json",
                "schema": "all_atomic"
               }
@@ -74,7 +78,7 @@
              {
               "kind": "color_from_uri",
               "params": {
-               "uri": "/examples/mvs/1h9t_domains.json",
+               "uri": "./1h9t_domains.json",
                "format": "json",
                "schema": "all_atomic"
               }
@@ -98,7 +102,7 @@
              {
               "kind": "color_from_uri",
               "params": {
-               "uri": "/examples/mvs/1h9t_domains.json",
+               "uri": "./1h9t_domains.json",
                "format": "json",
                "schema": "all_atomic"
               }
@@ -557,11 +561,7 @@
            {
             "kind": "focus",
             "params": {
-             "direction": [
-              -0.3,
-              -0.1,
-              -1
-             ]
+             "direction": [-0.3, -0.1, -1]
             }
            }
           ]

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 318 - 148
package-lock.json


+ 19 - 15
package.json

@@ -1,6 +1,6 @@
 {
   "name": "molstar",
-  "version": "3.42.0",
+  "version": "3.43.1",
   "description": "A comprehensive macromolecular library.",
   "homepage": "https://github.com/molstar/molstar#readme",
   "repository": {
@@ -48,6 +48,9 @@
   "bin": {
     "cif2bcif": "lib/commonjs/cli/cif2bcif/index.js",
     "cifschema": "lib/commonjs/cli/cifschema/index.js",
+    "mvs-validate": "lib/commonjs/cli/mvs/mvs-validate.js",
+    "mvs-render": "lib/commonjs/cli/mvs/mvs-render.js",
+    "mvs-print-schema": "lib/commonjs/cli/mvs/mvs-print-schema.js",
     "model-server": "lib/commonjs/servers/model/server.js",
     "model-server-query": "lib/commonjs/servers/model/query.js",
     "model-server-preprocess": "lib/commonjs/servers/model/preprocess.js",
@@ -111,24 +114,24 @@
     "@graphql-codegen/typescript-graphql-files-modules": "^3.0.0",
     "@graphql-codegen/typescript-graphql-request": "^6.0.1",
     "@graphql-codegen/typescript-operations": "^4.0.1",
-    "@types/cors": "^2.8.16",
+    "@types/cors": "^2.8.17",
     "@types/gl": "^6.0.5",
     "@types/jpeg-js": "^0.3.7",
     "@types/pngjs": "^6.0.4",
-    "@types/jest": "^29.5.8",
-    "@types/react": "^18.2.37",
-    "@types/react-dom": "^18.2.15",
-    "@typescript-eslint/eslint-plugin": "^6.11.0",
-    "@typescript-eslint/parser": "^6.11.0",
+    "@types/jest": "^29.5.10",
+    "@types/react": "^18.2.41",
+    "@types/react-dom": "^18.2.17",
+    "@typescript-eslint/eslint-plugin": "^6.13.1",
+    "@typescript-eslint/parser": "^6.13.1",
     "benchmark": "^2.1.4",
     "concurrently": "^8.2.2",
     "cpx2": "^6.0.1",
     "crypto-browserify": "^3.12.0",
     "css-loader": "^6.8.1",
-    "eslint": "^8.54.0",
+    "eslint": "^8.55.0",
     "extra-watch-webpack-plugin": "^1.0.3",
     "file-loader": "^6.2.0",
-    "fs-extra": "^11.1.1",
+    "fs-extra": "^11.2.0",
     "graphql": "^16.8.1",
     "http-server": "^14.1.1",
     "jest": "^29.7.0",
@@ -137,23 +140,22 @@
     "raw-loader": "^4.0.2",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
-    "react-markdown": "^9.0.1",
     "sass": "^1.69.5",
     "sass-loader": "^13.3.2",
-    "simple-git": "^3.20.0",
+    "simple-git": "^3.21.0",
     "stream-browserify": "^3.0.0",
     "style-loader": "^3.3.3",
     "ts-jest": "^29.1.1",
-    "typescript": "^5.2.2",
+    "typescript": "^5.3.2",
     "webpack": "^5.89.0",
     "webpack-cli": "^5.1.4"
   },
   "dependencies": {
-    "@types/argparse": "^2.0.13",
+    "@types/argparse": "^2.0.14",
     "@types/benchmark": "^2.1.5",
     "@types/compression": "1.7.5",
     "@types/express": "^4.17.21",
-    "@types/node": "^16.18.62",
+    "@types/node": "^16.18.66",
     "@types/node-fetch": "^2.6.9",
     "@types/swagger-ui-dist": "3.30.4",
     "argparse": "^2.0.1",
@@ -166,8 +168,9 @@
     "immutable": "^4.3.4",
     "io-ts": "^2.2.20",
     "node-fetch": "^2.7.0",
+    "react-markdown": "^9.0.1",
     "rxjs": "^7.8.1",
-    "swagger-ui-dist": "^5.10.0",
+    "swagger-ui-dist": "^5.10.3",
     "tslib": "^2.6.2",
     "util.promisify": "^1.1.2",
     "xhr2": "^0.2.1"
@@ -177,6 +180,7 @@
     "react-dom": "^18.1.0 || ^17.0.2 || ^16.14.0"
   },
   "optionalDependencies": {
+    "canvas": "^2.11.2",
     "gl": "^6.0.2",
     "jpeg-js": "^0.4.4",
     "pngjs": "^6.0.0"

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

@@ -474,7 +474,7 @@ export class Viewer {
         if (format === 'mvsj') {
             const data = await this.plugin.runTask(this.plugin.fetch({ url, type: 'string' }));
             const mvsData = MVSData.fromMVSJ(data);
-            await loadMVS(this.plugin, mvsData, { sanityChecks: true });
+            await loadMVS(this.plugin, mvsData, { sanityChecks: true, sourceUrl: url });
         } else {
             throw new Error(`Unknown MolViewSpec format: ${format}`);
         }
@@ -484,7 +484,7 @@ export class Viewer {
     async loadMvsData(data: string, format: 'mvsj') {
         if (format === 'mvsj') {
             const mvsData = MVSData.fromMVSJ(data);
-            await loadMVS(this.plugin, mvsData, { sanityChecks: true });
+            await loadMVS(this.plugin, mvsData, { sanityChecks: true, sourceUrl: undefined });
         } else {
             throw new Error(`Unknown MolViewSpec format: ${format}`);
         }
@@ -556,4 +556,5 @@ export const ViewerAutoPreset = StructureRepresentationPresetProvider({
 
 export const PluginExtensions = {
     wwPDBStructConn: wwPDBStructConnExtensionFunctions,
+    mvs: { MVSData, loadMVS },
 };

+ 40 - 0
src/cli/mvs/mvs-print-schema.ts

@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ *
+ * Command-line application for printing MolViewSpec tree schema
+ * Build: npm run build
+ * Run:   node lib/commonjs/cli/mvs/mvs-print-schema
+ *        node lib/commonjs/cli/mvs/mvs-print-schema --markdown
+ */
+
+import { ArgumentParser } from 'argparse';
+import { treeSchemaToMarkdown, treeSchemaToString } from '../../extensions/mvs/tree/generic/tree-schema';
+import { MVSDefaults } from '../../extensions/mvs/tree/mvs/mvs-defaults';
+import { MVSTreeSchema } from '../../extensions/mvs/tree/mvs/mvs-tree';
+
+
+/** Command line argument values for `main` */
+interface Args {
+    markdown: boolean,
+}
+
+/** Return parsed command line arguments for `main` */
+function parseArguments(): Args {
+    const parser = new ArgumentParser({ description: 'Command-line application for printing MolViewSpec tree schema.' });
+    parser.add_argument('-m', '--markdown', { action: 'store_true', help: 'Print the schema as markdown instead of plain text.' });
+    const args = parser.parse_args();
+    return { ...args };
+}
+
+/** Main workflow for printing MolViewSpec tree schema. */
+function main(args: Args) {
+    if (args.markdown) {
+        console.log(treeSchemaToMarkdown(MVSTreeSchema, MVSDefaults));
+    } else {
+        console.log(treeSchemaToString(MVSTreeSchema, MVSDefaults));
+    }
+}
+
+main(parseArguments());

+ 5 - 21
src/examples/mvs/mvs-render.ts → src/cli/mvs/mvs-render.ts

@@ -4,9 +4,9 @@
  * @author Adam Midlik <midlik@gmail.com>
  *
  * Command-line application for rendering images from MolViewSpec files
- * Build: npm install --no-save gl jpeg-js pngjs  // these packages are not listed in Mol* dependencies for performance reasons
+ * Build: npm install --no-save canvas gl jpeg-js pngjs  // these packages are not listed in Mol* dependencies for performance reasons
  *        npm run build
- * Run:   node lib/commonjs/examples/mvs/mvs-render -i examples/mvs/1cbs.mvsj -o ../outputs/1cbs.png --size 800x600 --molj
+ * Run:   node lib/commonjs/cli/mvs/mvs-render -i examples/mvs/1cbs.mvsj -o ../outputs/1cbs.png --size 800x600 --molj
  */
 
 import { ArgumentParser } from 'argparse';
@@ -29,10 +29,11 @@ import { ParamDefinition as PD } from '../../mol-util/param-definition';
 import { MolViewSpec } from '../../extensions/mvs/behavior';
 import { loadMVS } from '../../extensions/mvs/load';
 import { MVSData } from '../../extensions/mvs/mvs-data';
-import { dfs } from '../../extensions/mvs/tree/generic/tree-utils';
+import { setCanvasModule } from '../../mol-geo/geometry/text/font-atlas';
 
 
 setFSModule(fs);
+setCanvasModule(require('canvas'));
 
 const DEFAULT_SIZE = '800x800';
 
@@ -76,9 +77,8 @@ async function main(args: Args): Promise<void> {
 
         const data = fs.readFileSync(input, { encoding: 'utf8' });
         const mvsData = MVSData.fromMVSJ(data);
-        removeLabelNodes(mvsData);
 
-        await loadMVS(plugin, mvsData, { sanityChecks: true, deletePrevious: true });
+        await loadMVS(plugin, mvsData, { sanityChecks: true, replaceExisting: true, sourceUrl: `file://${path.resolve(input)}` });
         fs.mkdirSync(path.dirname(output), { recursive: true });
         if (args.molj) {
             await plugin.saveStateSnapshot(withExtension(output, '.molj'));
@@ -126,22 +126,6 @@ function withExtension(filename: string, extension: string): string {
     return filename.slice(0, -oldExtension.length) + extension;
 }
 
-/** Remove any label* nodes from the MVS tree (in-place). Print warning if removed at least one node. */
-function removeLabelNodes(mvsData: MVSData): void {
-    let removedSomething = false;
-    dfs(mvsData.root, node => {
-        const nChildren = node.children?.length ?? 0;
-        node.children = node.children?.filter(c => !c.kind.startsWith('label'));
-        if ((node.children?.length ?? 0) !== nChildren) {
-            removedSomething = true;
-        }
-    });
-    if (removedSomething) {
-        // trying to render labels would fail because `document` is not available in Nodejs
-        console.error('Rendering labels is not yet supported in mvs-render. Skipping any label* nodes.');
-    }
-}
-
 /** Check Mol* state, print and throw error if any cell is not OK. */
 function checkState(plugin: PluginContext): void {
     const cells = Array.from(plugin.state.data.cells.values());

+ 1 - 1
src/examples/mvs/mvs-validate.ts → src/cli/mvs/mvs-validate.ts

@@ -5,7 +5,7 @@
  *
  * Command-line application for validating MolViewSpec files
  * Build: npm run build
- * Run:   node lib/commonjs/examples/mvs/mvs-validate examples/mvs/1cbs.mvsj
+ * Run:   node lib/commonjs/cli/mvs/mvs-validate examples/mvs/1cbs.mvsj
  */
 
 import { ArgumentParser } from 'argparse';

+ 1 - 0
src/extensions/mvs/README.md

@@ -0,0 +1 @@
+Find the MVS extension documentation [here](../../../docs/extensions/mvs/README.md).

+ 46 - 0
src/extensions/mvs/_spec/mvs-data.spec.ts

@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import fs from 'fs';
+import { MVSData } from '../mvs-data';
+
+
+describe('MVSData', () => {
+    it('MVSData functions work', async () => {
+        const data = fs.readFileSync('examples/mvs/1cbs.mvsj', { encoding: 'utf8' });
+        const mvsData = MVSData.fromMVSJ(data);
+        expect(mvsData).toBeTruthy();
+
+        expect(MVSData.validationIssues(mvsData)).toEqual(undefined);
+
+        expect(MVSData.isValid(mvsData)).toEqual(true);
+
+        const reencoded = MVSData.toMVSJ(mvsData);
+        expect(reencoded.replace(/\s/g, '')).toEqual(data.replace(/\s/g, ''));
+
+        const prettyString = MVSData.toPrettyString(mvsData);
+        expect(typeof prettyString).toEqual('string');
+        expect(prettyString.length).toBeGreaterThan(0);
+    });
+
+    it('MVSData builder works', async () => {
+        const builder = MVSData.createBuilder();
+        expect(builder).toBeTruthy();
+
+        const mvsData = builder.getState();
+        expect(MVSData.validationIssues(mvsData)).toEqual(undefined);
+
+        builder
+            .download({ url: 'http://example.com' })
+            .parse({ format: 'mmcif' })
+            .assemblyStructure({ assembly_id: '1' })
+            .component({ selector: 'polymer' })
+            .representation()
+            .color({ color: 'green', selector: { label_asym_id: 'A' } });
+        const mvsData2 = builder.getState();
+        expect(MVSData.validationIssues(mvsData2)).toEqual(undefined);
+    });
+});

+ 24 - 1
src/extensions/mvs/behavior.ts

@@ -6,11 +6,13 @@
 
 import { CustomModelProperty } from '../../mol-model-props/common/custom-model-property';
 import { CustomStructureProperty } from '../../mol-model-props/common/custom-structure-property';
+import { DataFormatProvider } from '../../mol-plugin-state/formats/provider';
 import { PluginDragAndDropHandler } from '../../mol-plugin-state/manager/drag-and-drop';
 import { LociLabelProvider } from '../../mol-plugin-state/manager/loci-label';
 import { PluginBehavior } from '../../mol-plugin/behavior/behavior';
 import { PluginContext } from '../../mol-plugin/context';
 import { StructureRepresentationProvider } from '../../mol-repr/structure/representation';
+import { StateAction } from '../../mol-state';
 import { ColorTheme } from '../../mol-theme/color';
 import { ParamDefinition as PD } from '../../mol-util/param-definition';
 import { MVSAnnotationColorThemeProvider } from './components/annotation-color-theme';
@@ -19,6 +21,7 @@ import { MVSAnnotationsProvider } from './components/annotation-prop';
 import { MVSAnnotationTooltipsLabelProvider, MVSAnnotationTooltipsProvider } from './components/annotation-tooltips-prop';
 import { CustomLabelRepresentationProvider } from './components/custom-label/representation';
 import { CustomTooltipsLabelProvider, CustomTooltipsProvider } from './components/custom-tooltips-prop';
+import { LoadMvsData, MVSJFormatProvider } from './components/formats';
 import { makeMultilayerColorThemeProvider } from './components/multilayer-color-theme';
 import { loadMVS } from './load';
 import { MVSData } from './mvs-data';
@@ -32,6 +35,8 @@ interface Registrables {
     colorThemes?: ColorTheme.Provider[],
     lociLabels?: LociLabelProvider[],
     dragAndDropHandlers?: DragAndDropHandler[],
+    dataFormats?: { name: string, provider: DataFormatProvider }[],
+    actions?: StateAction[],
 }
 
 
@@ -67,6 +72,12 @@ export const MolViewSpec = PluginBehavior.create<{ autoAttach: boolean }>({
             dragAndDropHandlers: [
                 MVSDragAndDropHandler,
             ],
+            dataFormats: [
+                { name: 'MVSJ', provider: MVSJFormatProvider },
+            ],
+            actions: [
+                LoadMvsData,
+            ]
         };
 
         register(): void {
@@ -88,6 +99,12 @@ export const MolViewSpec = PluginBehavior.create<{ autoAttach: boolean }>({
             for (const handler of this.registrables.dragAndDropHandlers ?? []) {
                 this.ctx.managers.dragAndDrop.addHandler(handler.name, handler.handle);
             }
+            for (const format of this.registrables.dataFormats ?? []) {
+                this.ctx.dataFormats.add(format.name, format.provider);
+            }
+            for (const action of this.registrables.actions ?? []) {
+                this.ctx.state.data.actions.add(action);
+            }
         }
         update(p: { autoAttach: boolean }) {
             const updated = this.params.autoAttach !== p.autoAttach;
@@ -119,6 +136,12 @@ export const MolViewSpec = PluginBehavior.create<{ autoAttach: boolean }>({
             for (const handler of this.registrables.dragAndDropHandlers ?? []) {
                 this.ctx.managers.dragAndDrop.removeHandler(handler.name);
             }
+            for (const format of this.registrables.dataFormats ?? []) {
+                this.ctx.dataFormats.remove(format.name);
+            }
+            for (const action of this.registrables.actions ?? []) {
+                this.ctx.state.data.actions.remove(action);
+            }
         }
     },
     params: () => ({
@@ -144,7 +167,7 @@ const MVSDragAndDropHandler: DragAndDropHandler = {
             if (file.name.toLowerCase().endsWith('.mvsj')) {
                 const data = await file.text();
                 const mvsData = MVSData.fromMVSJ(data);
-                await loadMVS(plugin, mvsData, { sanityChecks: true, deletePrevious: !applied });
+                await loadMVS(plugin, mvsData, { sanityChecks: true, replaceExisting: !applied, sourceUrl: undefined });
                 applied = true;
             }
         }

+ 10 - 4
src/extensions/mvs/camera.ts

@@ -31,12 +31,15 @@ const DefaultFocusOptions = {
 const DefaultCanvasBackgroundColor = ColorNames.white;
 
 
+const _tmpVec = Vec3();
+
 /** Set the camera based on a camera node params. */
 export async function setCamera(plugin: PluginContext, params: ParamsOfKind<MolstarTree, 'camera'>) {
     const target = Vec3.create(...params.target);
     let position = Vec3.create(...params.position);
     if (plugin.canvas3d) position = fovAdjustedPosition(target, position, plugin.canvas3d.camera.state.mode, plugin.canvas3d.camera.state.fov);
     const up = Vec3.create(...params.up);
+    Vec3.orthogonalize(up, Vec3.sub(_tmpVec, target, position), up);
     const snapshot: Partial<Camera.Snapshot> = { target, position, up, radius: 10_000, 'radiusMax': 10_000 };
     await PluginCommands.Camera.SetSnapshot(plugin, { snapshot });
 }
@@ -57,11 +60,14 @@ export async function setFocus(plugin: PluginContext, structureNodeSelector: Sta
     const boundingSphere = structure ? Loci.getBoundingSphere(Structure.Loci(structure)) : getPluginBoundingSphere(plugin);
     if (boundingSphere && plugin.canvas3d) {
         const extraRadius = structure ? DefaultFocusOptions.extraRadiusForFocus : DefaultFocusOptions.extraRadiusForZoomAll;
+        const direction = Vec3.create(...params.direction);
+        const up = Vec3.create(...params.up);
+        Vec3.orthogonalize(up, direction, up);
         const snapshot = snapshotFromSphereAndDirections(plugin.canvas3d.camera, {
             center: boundingSphere.center,
             radius: boundingSphere.radius + extraRadius,
-            up: Vec3.create(...params.up),
-            direction: Vec3.create(...params.direction),
+            up,
+            direction,
         });
         await PluginCommands.Camera.SetSnapshot(plugin, { snapshot });
     }
@@ -75,7 +81,7 @@ function snapshotFromSphereAndDirections(camera: Camera, options: { center: Vec3
     const { center, direction, up } = options;
     const radius = Math.max(options.radius, DefaultFocusOptions.minRadius);
     const distance = camera.getTargetDistance(radius);
-    const deltaDirection = Vec3.setMagnitude(Vec3(), direction, distance);
+    const deltaDirection = Vec3.setMagnitude(_tmpVec, direction, distance);
     const position = Vec3.sub(Vec3(), center, deltaDirection);
     return { target: center, position, up, radius };
 }
@@ -91,7 +97,7 @@ function distanceAdjustment(mode: Camera.Mode, fov: number) {
  * necessary to just fit into view the same sphere (with center at `target`)
  * as the "reference camera" placed at `refPosition` would fit, while keeping the camera orientation.
  * The "reference camera" is a camera which can just fit into view a sphere of radius R with center at distance 2R
- * (this corresponds to FOV = 2 * asin(1/2) in perspective mode or FOV = 2 * atan(1/2) in orthogonal mode). */
+ * (this corresponds to FOV = 2 * asin(1/2) in perspective mode or FOV = 2 * atan(1/2) in orthographic mode). */
 function fovAdjustedPosition(target: Vec3, refPosition: Vec3, mode: Camera.Mode, fov: number) {
     const delta = Vec3.sub(Vec3(), refPosition, target);
     const adjustment = distanceAdjustment(mode, fov);

+ 9 - 3
src/extensions/mvs/components/annotation-structure-component.ts

@@ -84,10 +84,16 @@ export function createMVSAnnotationStructureComponent(structure: Structure, para
     let label = params.label;
     if (label === undefined || label === '') {
         if (params.fieldValues.name === 'selected' && params.fieldValues.params.length > 0) {
-            const ellipsis = params.fieldValues.params.length > 1 ? '+...' : '';
-            label = `${params.fieldName}: "${params.fieldValues.params[0].value}"${ellipsis}`;
+            const values = params.fieldValues.params;
+            let valuesStr = `"${values[0].value}"`;
+            if (values.length === 2) {
+                valuesStr += ` + "${values[1].value}"`;
+            } else if (values.length > 2) {
+                valuesStr += ` + ${values.length - 1} more values`;
+            }
+            label = `MVS Annotation Component (${params.fieldName}: ${valuesStr})`;
         } else {
-            label = 'Component from MVS Annotation';
+            label = 'MVS Annotation Component';
         }
     }
 

+ 20 - 30
src/extensions/mvs/components/custom-label/visual.ts

@@ -4,6 +4,7 @@
  * @author Adam Midlik <midlik@gmail.com>
  */
 
+import { SortedArray } from '../../../../mol-data/int';
 import { Text } from '../../../../mol-geo/geometry/text/text';
 import { TextBuilder } from '../../../../mol-geo/geometry/text/text-builder';
 import { Structure } from '../../../../mol-model/structure';
@@ -18,7 +19,7 @@ import { ColorNames } from '../../../../mol-util/color/names';
 import { omitObjectKeys } from '../../../../mol-util/object';
 import { ParamDefinition as PD } from '../../../../mol-util/param-definition';
 import { textPropsForSelection } from '../../helpers/label-text';
-import { MaybeIntegerParamDefinition, MaybeStringParamDefinition } from '../../helpers/param-definition';
+import { SelectorParams, substructureFromSelector } from '../selector';
 
 
 /** Parameter definition for "label-text" visual in "Custom Label" representation */
@@ -35,33 +36,7 @@ export const CustomLabelTextParams = {
                     scale: PD.Numeric(1, { min: 0, max: 20, step: 0.1 })
                 }),
                 selection: PD.Group({
-                    label_entity_id: MaybeStringParamDefinition(),
-                    label_asym_id: MaybeStringParamDefinition(),
-                    auth_asym_id: MaybeStringParamDefinition(),
-
-                    label_seq_id: MaybeIntegerParamDefinition(),
-                    auth_seq_id: MaybeIntegerParamDefinition(),
-                    pdbx_PDB_ins_code: MaybeStringParamDefinition(),
-                    /** Minimum label_seq_id (inclusive) */
-                    beg_label_seq_id: MaybeIntegerParamDefinition(undefined, { description: 'Minimum label_seq_id (inclusive)' }),
-                    /** Maximum label_seq_id (inclusive) */
-                    end_label_seq_id: MaybeIntegerParamDefinition(),
-                    /** Minimum auth_seq_id (inclusive) */
-                    beg_auth_seq_id: MaybeIntegerParamDefinition(),
-                    /** Maximum auth_seq_id (inclusive) */
-                    end_auth_seq_id: MaybeIntegerParamDefinition(),
-
-                    /** Atom name like 'CA', 'N', 'O'... */
-                    label_atom_id: MaybeStringParamDefinition(),
-                    /** Atom name like 'CA', 'N', 'O'... */
-                    auth_atom_id: MaybeStringParamDefinition(),
-                    /** Element symbol like 'H', 'HE', 'LI', 'BE'... */
-                    type_symbol: MaybeStringParamDefinition(),
-                    /** Unique atom identifier across conformations (_atom_site.id) */
-                    atom_id: MaybeIntegerParamDefinition(),
-                    /** 0-base index of the atom in the source data */
-                    atom_index: MaybeIntegerParamDefinition(),
-
+                    selector: SelectorParams,
                 }),
             }),
         },
@@ -99,10 +74,25 @@ function createLabelText(ctx: VisualContext, structure: Structure, theme: Theme,
                 builder.add(item.text, item.position.params.x, item.position.params.y, item.position.params.z, scale, scale, 0);
                 break;
             case 'selection':
-                const p = textPropsForSelection(structure, theme.size.size, item.position.params);
-                if (p) builder.add(item.text, p.center[0], p.center[1], p.center[2], p.depth, p.scale, p.group);
+                const substructure = substructureFromSelector(structure, item.position.params.selector);
+                const p = textPropsForSelection(substructure, theme.size.size, {});
+                const group = serialIndexOfSubstructure(structure, substructure) ?? 0;
+                if (p) builder.add(item.text, p.center[0], p.center[1], p.center[2], p.depth, p.scale, group);
                 break;
         }
     }
     return builder.getText();
 }
+
+/** Return the serial index within `structure` of the first element of `substructure` (or `undefined` in that element is not in `structure`)  */
+function serialIndexOfSubstructure(structure: Structure, substructure: Structure): number | undefined {
+    if (substructure.isEmpty) return undefined;
+    const theUnit = substructure.units[0];
+    const theElement = theUnit.elements[0];
+    for (const unit of structure.units) {
+        if (unit.model.id === theUnit.model.id && SortedArray.has(unit.elements, theElement)) {
+            return structure.serialMapping.getSerialIndex(unit, theElement);
+        }
+    }
+    return undefined;
+}

+ 77 - 0
src/extensions/mvs/components/formats.ts

@@ -0,0 +1,77 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { DataFormatProvider } from '../../../mol-plugin-state/formats/provider';
+import { PluginStateObject as SO } from '../../../mol-plugin-state/objects';
+import { Download } from '../../../mol-plugin-state/transforms/data';
+import { PluginContext } from '../../../mol-plugin/context';
+import { StateAction, StateObjectRef } from '../../../mol-state';
+import { Task } from '../../../mol-task';
+import { Asset } from '../../../mol-util/assets';
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+import { loadMVS } from '../load';
+import { MVSData } from '../mvs-data';
+import { MVSTransform } from './annotation-structure-component';
+
+
+/** Plugin state object storing `MVSData` */
+export class Mvs extends SO.Create<{ mvsData: MVSData, sourceUrl?: string }>({ name: 'MVS Data', typeClass: 'Data' }) { }
+
+/** Transformer for parsing data in MVSJ format */
+export const ParseMVSJ = MVSTransform({
+    name: 'mvs-parse-mvsj',
+    display: { name: 'MVS Annotation Component', description: 'A molecular structure component defined by MVS annotation data.' },
+    from: SO.Data.String,
+    to: Mvs,
+})({
+    apply({ a }, plugin: PluginContext) {
+        const mvsData = MVSData.fromMVSJ(a.data);
+        const sourceUrl = tryGetDownloadUrl(a, plugin);
+        return new Mvs({ mvsData, sourceUrl });
+    },
+});
+
+/** If the PluginStateObject `pso` comes from a Download transform, try to get its `url` parameter. */
+function tryGetDownloadUrl(pso: SO.Data.String, plugin: PluginContext): string | undefined {
+    const theCell = plugin.state.data.selectQ(q => q.ofTransformer(Download)).find(cell => cell.obj === pso);
+    const urlParam = theCell?.transform.params?.url;
+    return urlParam ? Asset.getUrl(urlParam) : undefined;
+}
+
+
+/** Params for the `LoadMvsData` action */
+const LoadMvsDataParams = {
+    replaceExisting: PD.Boolean(false, { description: 'If true, the loaded MVS view will replace the current state; if false, the MVS view will be added to the current state.' }),
+};
+
+/** State action which loads a MVS view into Mol* */
+export const LoadMvsData = StateAction.build({
+    display: { name: 'Load MVS Data' },
+    from: Mvs,
+    params: LoadMvsDataParams,
+})(({ a, params }, plugin: PluginContext) => Task.create('Load MVS Data', async () => {
+    const { mvsData, sourceUrl } = a.data;
+    await loadMVS(plugin, mvsData, { replaceExisting: params.replaceExisting, sourceUrl: sourceUrl });
+}));
+
+
+/** Data format provider for MVSJ format.
+ * If Visuals:On, it will load the parsed MVS view;
+ * otherwise it will just create a plugin state object with parsed data. */
+export const MVSJFormatProvider: DataFormatProvider<{}, StateObjectRef<Mvs>, any> = DataFormatProvider({
+    label: 'MVSJ',
+    description: 'MVSJ',
+    category: 'Miscellaneous',
+    stringExtensions: ['mvsj'],
+    parse: async (plugin, data) => {
+        return plugin.state.data.build().to(data).apply(ParseMVSJ).commit();
+    },
+    visuals: async (plugin, data) => {
+        const ref = StateObjectRef.resolveRef(data);
+        const params = PD.getDefaultValues(LoadMvsDataParams);
+        return await plugin.state.data.applyAction(LoadMvsData, params, ref).run();
+    },
+});

+ 2 - 1
src/extensions/mvs/components/selector.ts

@@ -73,7 +73,8 @@ export const ElementSet = {
     },
 };
 
-function substructureFromSelector(structure: Structure, selector: Selector): Structure {
+/** Return a substructure of `structure` defined by `selector` */
+export function substructureFromSelector(structure: Structure, selector: Selector): Structure {
     const pso = (selector.name === 'annotation') ?
         createMVSAnnotationStructureComponent(structure, { ...selector.params, label: '', nullIfEmpty: false })
         : createStructureComponent(structure, { type: selector, label: '', nullIfEmpty: false }, { source: structure });

+ 1 - 1
src/extensions/mvs/helpers/schemas.ts

@@ -85,7 +85,7 @@ const FieldsForSchemas = {
     residue: ['group_id', 'label_entity_id', 'label_asym_id', 'label_seq_id'],
     auth_residue: ['group_id', 'auth_asym_id', 'auth_seq_id', 'pdbx_PDB_ins_code'],
     residue_range: ['group_id', 'label_entity_id', 'label_asym_id', 'beg_label_seq_id', 'end_label_seq_id'],
-    auth_residue_range: ['group_id', 'auth_asym_id', 'beg_auth_seq_id', 'end_auth_seq_id', 'pdbx_PDB_ins_code'],
+    auth_residue_range: ['group_id', 'auth_asym_id', 'beg_auth_seq_id', 'end_auth_seq_id'],
     atom: ['group_id', 'label_entity_id', 'label_asym_id', 'label_seq_id', 'label_atom_id', 'type_symbol', 'atom_id', 'atom_index'],
     auth_atom: ['group_id', 'auth_asym_id', 'auth_seq_id', 'pdbx_PDB_ins_code', 'auth_atom_id', 'type_symbol', 'atom_id', 'atom_index'],
     all_atomic: Object.keys(AllAtomicCifAnnotationSchema) as (keyof typeof AllAtomicCifAnnotationSchema)[],

+ 17 - 16
src/extensions/mvs/helpers/utils.ts

@@ -80,11 +80,16 @@ export function filterDefined<T>(elements: (T | undefined | null)[]): T[] {
     return elements.filter(x => x !== undefined && x !== null) as T[];
 }
 
-/** Create an 8-hex-character hash for a given input string, e.g. 'spanish inquisition' -> 'bd65e59a' */
-export function stringHash(input: string): string {
+/** Create an 8-hex-character hash for a given input string, e.g. 'spanish inquisition' -> '7f9ac4be' */
+function stringHash32(input: string): string {
     const uint32hash = hashString(input) >>> 0; // >>>0 converts to uint32, LOL
     return uint32hash.toString(16).padStart(8, '0');
 }
+/** Create an 16-hex-character hash for a given input string, e.g. 'spanish inquisition' -> '7f9ac4be544330be'*/
+export function stringHash(input: string): string {
+    const reversed = input.split('').reverse().join('');
+    return stringHash32(input) + stringHash32(reversed);
+}
 
 /** Return type of elements in a set */
 export type ElementOfSet<S> = S extends Set<infer T> ? T : never
@@ -95,7 +100,7 @@ export type ElementOfSet<S> = S extends Set<infer T> ? T : never
 export function decodeColor(colorString: string | undefined): Color | undefined {
     if (colorString === undefined) return undefined;
     let result: Color | undefined;
-    if (isHexColorString(colorString)) {
+    if (HexColor.is(colorString)) {
         if (colorString.length === 4) {
             // convert short form to full form (#f0f -> #ff00ff)
             colorString = `#${colorString[1]}${colorString[1]}${colorString[2]}${colorString[2]}${colorString[3]}${colorString[3]}`;
@@ -108,19 +113,15 @@ export function decodeColor(colorString: string | undefined): Color | undefined
     return undefined;
 }
 
-/** Hexadecimal color string, e.g. '#FF1100' */
-export type HexColor = string & { '@type': 'HexColorString' }
-export function HexColor(str: string) {
-    if (!isHexColorString(str)) {
-        throw new Error(`ValueError: "${str}" is not a valid hex color string`);
-    }
-    return str as HexColor;
-}
-
 /** Regular expression matching a hexadecimal color string, e.g. '#FF1100' or '#f10' */
 const hexColorRegex = /^#([0-9A-F]{3}){1,2}$/i;
 
-/** Decide if a string is a valid hexadecimal color string (6-digit or 3-digit, e.g. '#FF1100' or '#f10') */
-export function isHexColorString(str: any): str is HexColor {
-    return typeof str === 'string' && hexColorRegex.test(str);
-}
+/** Hexadecimal color string, e.g. '#FF1100' (the type matches more than just valid HexColor strings) */
+export type HexColor = `#${string}`
+
+export const HexColor = {
+    /** Decide if a string is a valid hexadecimal color string (6-digit or 3-digit, e.g. '#FF1100' or '#f10') */
+    is(str: any): str is HexColor {
+        return typeof str === 'string' && hexColorRegex.test(str);
+    },
+};

+ 166 - 21
src/extensions/mvs/load-helpers.ts

@@ -9,14 +9,16 @@ import { StructureComponentParams } from '../../mol-plugin-state/helpers/structu
 import { StructureFromModel, TransformStructureConformation } from '../../mol-plugin-state/transforms/model';
 import { StructureRepresentation3D } from '../../mol-plugin-state/transforms/representation';
 import { PluginContext } from '../../mol-plugin/context';
-import { StateBuilder, StateObjectSelector, StateTransformer } from '../../mol-state';
+import { StateBuilder, StateObject, StateObjectSelector, StateTransform, StateTransformer } from '../../mol-state';
 import { arrayDistinct } from '../../mol-util/array';
 import { canonicalJsonString } from '../../mol-util/json';
+import { stringToWords } from '../../mol-util/string';
 import { MVSAnnotationColorThemeProps, MVSAnnotationColorThemeProvider } from './components/annotation-color-theme';
 import { MVSAnnotationLabelRepresentationProvider } from './components/annotation-label/representation';
 import { MVSAnnotationSpec } from './components/annotation-prop';
 import { MVSAnnotationStructureComponentProps } from './components/annotation-structure-component';
 import { MVSAnnotationTooltipsProps } from './components/annotation-tooltips-prop';
+import { CustomLabelTextProps } from './components/custom-label/visual';
 import { CustomTooltipsProps } from './components/custom-tooltips-prop';
 import { MultilayerColorThemeName, MultilayerColorThemeProps, NoColor } from './components/multilayer-color-theme';
 import { SelectorAll } from './components/selector';
@@ -24,36 +26,35 @@ import { rowToExpression, rowsToExpression } from './helpers/selections';
 import { ElementOfSet, decodeColor, isDefined, stringHash } from './helpers/utils';
 import { MolstarLoadingContext } from './load';
 import { Kind, ParamsOfKind, SubTree, SubTreeOfKind, Tree, getChildren } from './tree/generic/tree-schema';
-import { dfs } from './tree/generic/tree-utils';
+import { dfs, formatObject } from './tree/generic/tree-utils';
 import { MolstarKind, MolstarNode, MolstarTree } from './tree/molstar/molstar-tree';
 import { DefaultColor } from './tree/mvs/mvs-defaults';
 
 
 /** Function responsible for loading a tree node `node` into Mol*.
- * Should apply changes within `update` but not commit them.
+ * Should apply changes within `updateParent.update` but not commit them.
  * Should modify `context` accordingly, if it is needed for loading other nodes later.
- * `msParent` is the result of loading the node's parent into Mol* state hierarchy (or the hierarchy root in case of root node). */
-export type LoadingAction<TNode extends Tree, TContext> = (update: StateBuilder.Root, msParent: StateObjectSelector, node: TNode, context: TContext) => StateObjectSelector | undefined
+ * `updateParent.selector` is the result of loading the node's parent into Mol* state hierarchy (or the hierarchy root in case of root node). */
+export type LoadingAction<TNode extends Tree, TContext> = (updateParent: UpdateTarget, node: TNode, context: TContext) => UpdateTarget | undefined
 
 /** Loading actions for loading a tree into Mol*, per node kind. */
 export type LoadingActions<TTree extends Tree, TContext> = { [kind in Kind<SubTree<TTree>>]?: LoadingAction<SubTreeOfKind<TTree, kind>, TContext> }
 
 /** Load a tree into Mol*, by applying loading actions in DFS order and then commiting at once.
- * If `deletePrevious`, remove all objects in the current Mol* state; otherwise add to the current state. */
-export async function loadTree<TTree extends Tree, TContext>(plugin: PluginContext, tree: TTree, loadingActions: LoadingActions<TTree, TContext>, context: TContext, options?: { deletePrevious?: boolean }) {
-    const mapping = new Map<SubTree<TTree>, StateObjectSelector | undefined>();
-    const update = plugin.build();
-    const msRoot = update.toRoot().selector;
-    if (options?.deletePrevious) {
-        update.currentTree.children.get(msRoot.ref).forEach(child => update.delete(child));
+ * If `options.replaceExisting`, remove all objects in the current Mol* state; otherwise add to the current state. */
+export async function loadTree<TTree extends Tree, TContext>(plugin: PluginContext, tree: TTree, loadingActions: LoadingActions<TTree, TContext>, context: TContext, options?: { replaceExisting?: boolean }) {
+    const mapping = new Map<SubTree<TTree>, UpdateTarget | undefined>();
+    const updateRoot: UpdateTarget = UpdateTarget.create(plugin, options?.replaceExisting ?? false);
+    if (options?.replaceExisting) {
+        UpdateTarget.deleteChildren(updateRoot);
     }
     dfs<TTree>(tree, (node, parent) => {
         const kind: Kind<typeof node> = node.kind;
         const action = loadingActions[kind] as LoadingAction<typeof node, TContext> | undefined;
         if (action) {
-            const msParent = parent ? mapping.get(parent) : msRoot;
-            if (msParent) {
-                const msNode = action(update, msParent, node, context);
+            const updateParent = parent ? mapping.get(parent) : updateRoot;
+            if (updateParent) {
+                const msNode = action(updateParent, node, context);
                 mapping.set(node, msNode);
             } else {
                 console.warn(`No target found for this "${node.kind}" node`);
@@ -61,7 +62,84 @@ export async function loadTree<TTree extends Tree, TContext>(plugin: PluginConte
             }
         }
     });
-    await update.commit();
+    await UpdateTarget.commit(updateRoot);
+}
+
+
+/** A wrapper for updating Mol* state, while using deterministic transform refs.
+ * ```
+ * updateTarget = UpdateTarget.create(plugin); // like update = plugin.build();
+ * UpdateTarget.apply(updateTarget, transformer, params); // like update.to(selector).apply(transformer, params);
+ * await UpdateTarget.commit(updateTarget); // like await update.commit();
+ * ```
+ */
+export interface UpdateTarget {
+    readonly update: StateBuilder.Root,
+    readonly selector: StateObjectSelector,
+    readonly refManager: RefManager,
+}
+export const UpdateTarget = {
+    /** Create a new update, with `selector` pointing to the root. */
+    create(plugin: PluginContext, replaceExisting: boolean): UpdateTarget {
+        const update = plugin.build();
+        const msTarget = update.toRoot().selector;
+        const refManager = new RefManager(plugin, replaceExisting);
+        return { update, selector: msTarget, refManager };
+    },
+    /** Add a child node to `target.selector`, return a new `UpdateTarget` pointing to the new child. */
+    apply<A extends StateObject, B extends StateObject, P extends {}>(target: UpdateTarget, transformer: StateTransformer<A, B, P>, params?: Partial<P>, options?: Partial<StateTransform.Options>): UpdateTarget {
+        let refSuffix: string = transformer.id;
+        if (transformer.id === StructureRepresentation3D.id) {
+            const reprType = (params as any)?.type?.name ?? '';
+            refSuffix += `:${reprType}`;
+        }
+        const ref = target.refManager.getChildRef(target.selector, refSuffix);
+        const msResult = target.update.to(target.selector).apply(transformer, params, { ...options, ref }).selector;
+        return { ...target, selector: msResult };
+    },
+    /** Delete all children of `target.selector`. */
+    deleteChildren(target: UpdateTarget): UpdateTarget {
+        const children = target.update.currentTree.children.get(target.selector.ref);
+        children.forEach(child => target.update.delete(child));
+        return target;
+    },
+    /** Commit all changes done in the current update. */
+    commit(target: UpdateTarget): Promise<void> {
+        return target.update.commit();
+    },
+};
+
+/** Manages transform refs in a deterministic way. Uses refs like !mvs:3ce3664304d32c5d:0 */
+class RefManager {
+    /** For each hash (e.g. 3ce3664304d32c5d), store the number of already used refs with that hash. */
+    private _counter: Record<string, number> = {};
+    constructor(plugin: PluginContext, replaceExisting: boolean) {
+        if (!replaceExisting) {
+            plugin.state.data.cells.forEach(cell => {
+                const ref = cell.transform.ref;
+                if (ref.startsWith('!mvs:')) {
+                    const [_, hash, idNumber] = ref.split(':');
+                    const nextIdNumber = parseInt(idNumber) + 1;
+                    if (nextIdNumber > (this._counter[hash] ?? 0)) {
+                        this._counter[hash] = nextIdNumber;
+                    }
+                }
+            });
+        }
+    }
+    /** Return ref for a new node with given `hash`; update the counter accordingly. */
+    private nextRef(hash: string): string {
+        this._counter[hash] ??= 0;
+        const idNumber = this._counter[hash]++;
+        return `!mvs:${hash}:${idNumber}`;
+    }
+    /** Return ref for a new node based on parent and desired suffix. */
+    getChildRef(parent: StateObjectSelector, suffix: string): string {
+        const hashBase = parent.ref.replace(/^!mvs:/, '') + ':' + suffix;
+        const hash = stringHash(hashBase);
+        const result = this.nextRef(hash);
+        return result;
+    }
 }
 
 
@@ -78,7 +156,9 @@ export function transformFromRotationTranslation(rotation: number[] | null | und
     if (translation && translation.length !== 3) throw new Error(`'translation' param for 'transform' node must be array of 3 elements, found ${translation}`);
     const T = Mat4.identity();
     if (rotation) {
-        Mat4.fromMat3(T, Mat3.fromArray(Mat3(), rotation, 0));
+        const rotMatrix = Mat3.fromArray(Mat3(), rotation, 0);
+        ensureRotationMatrix(rotMatrix, rotMatrix);
+        Mat4.fromMat3(T, rotMatrix);
     }
     if (translation) {
         Mat4.setTranslation(T, Vec3.fromArray(Vec3(), translation, 0));
@@ -87,6 +167,22 @@ export function transformFromRotationTranslation(rotation: number[] | null | und
     return T;
 }
 
+/** Adjust values in a close-to-rotation matrix `a` to ensure it is a proper rotation matrix
+ * (i.e. its columns and rows are orthonormal and determinant equal to 1, within available precission). */
+function ensureRotationMatrix(out: Mat3, a: Mat3) {
+    const x = Vec3.fromArray(_tmpVecX, a, 0);
+    const y = Vec3.fromArray(_tmpVecY, a, 3);
+    const z = Vec3.fromArray(_tmpVecZ, a, 6);
+    Vec3.normalize(x, x);
+    Vec3.orthogonalize(y, x, y);
+    Vec3.normalize(z, Vec3.cross(z, x, y));
+    Mat3.fromColumns(out, x, y, z);
+    return out;
+}
+const _tmpVecX = Vec3();
+const _tmpVecY = Vec3();
+const _tmpVecZ = Vec3();
+
 /** Create an array of props for `TransformStructureConformation` transformers from all 'transform' nodes applied to a 'structure' node. */
 export function transformProps(node: SubTreeOfKind<MolstarTree, 'structure'>): StateTransformer.Params<TransformStructureConformation>[] {
     const result = [] as StateTransformer.Params<TransformStructureConformation>[];
@@ -128,7 +224,7 @@ function blockSpec(header: string | null | undefined, index: number | null | und
 }
 
 /** Collect annotation tooltips from all nodes in `tree` and map them to annotationIds. */
-export function collectAnnotationTooltips(tree: SubTreeOfKind<MolstarTree, 'structure'>, context: MolstarLoadingContext) {
+export function collectAnnotationTooltips(tree: SubTreeOfKind<MolstarTree, 'structure'>, context: MolstarLoadingContext): MVSAnnotationTooltipsProps['tooltips'] {
     const annotationTooltips: MVSAnnotationTooltipsProps['tooltips'] = [];
     dfs(tree, node => {
         if (node.kind === 'tooltip_from_uri' || node.kind === 'tooltip_from_source') {
@@ -140,8 +236,8 @@ export function collectAnnotationTooltips(tree: SubTreeOfKind<MolstarTree, 'stru
     });
     return arrayDistinct(annotationTooltips);
 }
-/** Collect annotation tooltips from all nodes in `tree`. */
-export function collectInlineTooltips(tree: SubTreeOfKind<MolstarTree, 'structure'>, context: MolstarLoadingContext) {
+/** Collect inline tooltips from all nodes in `tree`. */
+export function collectInlineTooltips(tree: SubTreeOfKind<MolstarTree, 'structure'>, context: MolstarLoadingContext): CustomTooltipsProps['tooltips'] {
     const inlineTooltips: CustomTooltipsProps['tooltips'] = [];
     dfs(tree, (node, parent) => {
         if (node.kind === 'tooltip') {
@@ -166,10 +262,46 @@ export function collectInlineTooltips(tree: SubTreeOfKind<MolstarTree, 'structur
     });
     return inlineTooltips;
 }
+/** Collect inline labels from all nodes in `tree`. */
+export function collectInlineLabels(tree: SubTreeOfKind<MolstarTree, 'structure'>, context: MolstarLoadingContext): CustomLabelTextProps['items'] {
+    const inlineLabels: CustomLabelTextProps['items'] = [];
+    dfs(tree, (node, parent) => {
+        if (node.kind === 'label') {
+            if (parent?.kind === 'component') {
+                inlineLabels.push({
+                    text: node.params.text,
+                    position: {
+                        name: 'selection',
+                        params: {
+                            selector: componentPropsFromSelector(parent.params.selector),
+                        },
+                    },
+                });
+            } else if (parent?.kind === 'component_from_uri' || parent?.kind === 'component_from_source') {
+                const p = componentFromXProps(parent, context);
+                if (isDefined(p.annotationId) && isDefined(p.fieldName) && isDefined(p.fieldValues)) {
+                    inlineLabels.push({
+                        text: node.params.text,
+                        position: {
+                            name: 'selection',
+                            params: {
+                                selector: {
+                                    name: 'annotation',
+                                    params: { annotationId: p.annotationId, fieldName: p.fieldName, fieldValues: p.fieldValues },
+                                },
+                            },
+                        },
+                    });
+                }
+            }
+        }
+    });
+    return inlineLabels;
+}
 
 /** Return `true` for components nodes which only serve for tooltip placement (not to be created in the MolStar object hierarchy) */
 export function isPhantomComponent(node: SubTreeOfKind<MolstarTree, 'component' | 'component_from_uri' | 'component_from_source'>) {
-    return node.children && node.children.every(child => child.kind === 'tooltip' || child.kind === 'tooltip_from_uri' || child.kind === 'tooltip_from_source');
+    return node.children && node.children.every(child => child.kind === 'tooltip' || child.kind === 'label');
     // These nodes could theoretically be removed when converting MVS to Molstar tree, but would get very tricky if we allow nested components
 }
 
@@ -223,6 +355,19 @@ export function componentPropsFromSelector(selector?: ParamsOfKind<MolstarTree,
     }
 }
 
+/** Return a pretty name for a value of selector param, e.g.  "protein" -> 'Protein', {label_asym_id: "A"} -> 'Custom Selection: {label_asym_id: "A"}' */
+export function prettyNameFromSelector(selector?: ParamsOfKind<MolstarTree, 'component'>['selector']): string {
+    if (selector === undefined) {
+        return 'All';
+    } else if (typeof selector === 'string') {
+        return stringToWords(selector);
+    } else if (Array.isArray(selector)) {
+        return `Custom Selection: [${selector.map(formatObject).join(', ')}]`;
+    } else {
+        return `Custom Selection: ${formatObject(selector)}`;
+    }
+}
+
 /** Create props for `StructureRepresentation3D` transformer from a label_from_* node. */
 export function labelFromXProps(node: MolstarNode<'label_from_uri' | 'label_from_source'>, context: MolstarLoadingContext): Partial<StateTransformer.Params<StructureRepresentation3D>> {
     const annotationId = context.annotationMap?.get(node);

+ 91 - 87
src/extensions/mvs/load.ts

@@ -8,16 +8,15 @@ import { Download, ParseCif } from '../../mol-plugin-state/transforms/data';
 import { CustomModelProperties, CustomStructureProperties, ModelFromTrajectory, StructureComponent, StructureFromModel, TrajectoryFromMmCif, TrajectoryFromPDB, TransformStructureConformation } from '../../mol-plugin-state/transforms/model';
 import { StructureRepresentation3D } from '../../mol-plugin-state/transforms/representation';
 import { PluginContext } from '../../mol-plugin/context';
-import { StateBuilder, StateObjectSelector } from '../../mol-state';
-import { canonicalJsonString } from '../../mol-util/json';
+import { StateObjectSelector } from '../../mol-state';
+import { MolViewSpec } from './behavior';
+import { setCamera, setCanvas, setFocus } from './camera';
 import { MVSAnnotationsProvider } from './components/annotation-prop';
 import { MVSAnnotationStructureComponent } from './components/annotation-structure-component';
 import { MVSAnnotationTooltipsProvider } from './components/annotation-tooltips-prop';
 import { CustomLabelProps, CustomLabelRepresentationProvider } from './components/custom-label/representation';
 import { CustomTooltipsProvider } from './components/custom-tooltips-prop';
-import { MolViewSpec } from './behavior';
-import { setCamera, setCanvas, setFocus } from './camera';
-import { AnnotationFromSourceKind, AnnotationFromUriKind, LoadingActions, collectAnnotationReferences, collectAnnotationTooltips, collectInlineTooltips, colorThemeForNode, componentFromXProps, componentPropsFromSelector, isPhantomComponent, labelFromXProps, loadTree, makeNearestReprMap, representationProps, structureProps, transformProps } from './load-helpers';
+import { AnnotationFromSourceKind, AnnotationFromUriKind, LoadingActions, UpdateTarget, collectAnnotationReferences, collectAnnotationTooltips, collectInlineLabels, collectInlineTooltips, colorThemeForNode, componentFromXProps, componentPropsFromSelector, isPhantomComponent, labelFromXProps, loadTree, makeNearestReprMap, prettyNameFromSelector, representationProps, structureProps, transformProps } from './load-helpers';
 import { MVSData } from './mvs-data';
 import { ParamsOfKind, SubTreeOfKind, validateTree } from './tree/generic/tree-schema';
 import { convertMvsToMolstar, mvsSanityCheck } from './tree/molstar/conversion';
@@ -26,22 +25,28 @@ import { MVSTreeSchema } from './tree/mvs/mvs-tree';
 
 
 /** Load a MolViewSpec (MVS) tree into the Mol* plugin.
- * If `options.deletePrevious`, remove all objects in the current Mol* state; otherwise add to the current state.
- * If `options.sanityChecks`, run some sanity checks and print potential issues to the console. */
-export async function loadMVS(plugin: PluginContext, data: MVSData, options: { deletePrevious?: boolean, sanityChecks?: boolean } = {}) {
-    // console.log(`MVS tree (v${data.version}):\n${treeToString(data.root)}`);
-    validateTree(MVSTreeSchema, data.root, 'MVS');
-    if (options.sanityChecks) mvsSanityCheck(data.root);
-    const molstarTree = convertMvsToMolstar(data.root);
-    // console.log(`Converted MolStar tree:\n${treeToString(molstarTree)}`);
-    validateTree(MolstarTreeSchema, molstarTree, 'Converted Molstar');
-    await loadMolstarTree(plugin, molstarTree, options);
+ * If `options.replaceExisting`, remove all objects in the current Mol* state; otherwise add to the current state.
+ * If `options.sanityChecks`, run some sanity checks and print potential issues to the console.
+ * `options.sourceUrl` serves as the base for resolving relative URLs/URIs and may itself be relative to the window URL. */
+export async function loadMVS(plugin: PluginContext, data: MVSData, options: { replaceExisting?: boolean, sanityChecks?: boolean, sourceUrl?: string } = {}) {
+    try {
+        // console.log(`MVS tree:\n${MVSData.toPrettyString(data)}`)
+        validateTree(MVSTreeSchema, data.root, 'MVS');
+        if (options.sanityChecks) mvsSanityCheck(data.root);
+        const molstarTree = convertMvsToMolstar(data.root, options.sourceUrl);
+        // console.log(`Converted MolStar tree:\n${MVSData.toPrettyString({ root: molstarTree, metadata: { version: 'x', timestamp: 'x' } })}`)
+        validateTree(MolstarTreeSchema, molstarTree, 'Converted Molstar');
+        await loadMolstarTree(plugin, molstarTree, options);
+    } catch (err) {
+        plugin.log.error(`${err}`);
+        throw err;
+    }
 }
 
 
 /** Load a `MolstarTree` into the Mol* plugin.
- * If `deletePrevious`, remove all objects in the current Mol* state; otherwise add to the current state. */
-async function loadMolstarTree(plugin: PluginContext, tree: MolstarTree, options?: { deletePrevious?: boolean }) {
+ * If `replaceExisting`, remove all objects in the current Mol* state; otherwise add to the current state. */
+async function loadMolstarTree(plugin: PluginContext, tree: MolstarTree, options?: { replaceExisting?: boolean }) {
     const mvsExtensionLoaded = plugin.state.hasBehavior(MolViewSpec);
     if (!mvsExtensionLoaded) throw new Error('MolViewSpec extension is not loaded.');
 
@@ -72,66 +77,67 @@ export interface MolstarLoadingContext {
 
 /** Loading actions for loading a `MolstarTree`, per node kind. */
 const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext> = {
-    root(update: StateBuilder.Root, msParent: StateObjectSelector, node: MolstarNode<'root'>, context: MolstarLoadingContext): StateObjectSelector {
+    root(updateParent: UpdateTarget, node: MolstarNode<'root'>, context: MolstarLoadingContext): UpdateTarget {
         context.nearestReprMap = makeNearestReprMap(node);
-        return msParent;
+        return updateParent;
     },
-    download(update: StateBuilder.Root, msParent: StateObjectSelector, node: MolstarNode<'download'>): StateObjectSelector {
-        return update.to(msParent).apply(Download, {
+    download(updateParent: UpdateTarget, node: MolstarNode<'download'>): UpdateTarget {
+        return UpdateTarget.apply(updateParent, Download, {
             url: node.params.url,
             isBinary: node.params.is_binary,
-        }).selector;
+        });
     },
-    parse(update: StateBuilder.Root, msParent: StateObjectSelector, node: MolstarNode<'parse'>): StateObjectSelector | undefined {
+    parse(updateParent: UpdateTarget, node: MolstarNode<'parse'>): UpdateTarget | undefined {
         const format = node.params.format;
         if (format === 'cif') {
-            return update.to(msParent).apply(ParseCif, {}).selector;
+            return UpdateTarget.apply(updateParent, ParseCif, {});
         } else if (format === 'pdb') {
-            return msParent;
+            return updateParent;
         } else {
             console.error(`Unknown format in "parse" node: "${format}"`);
             return undefined;
         }
     },
-    trajectory(update: StateBuilder.Root, msParent: StateObjectSelector, node: MolstarNode<'trajectory'>): StateObjectSelector | undefined {
+    trajectory(updateParent: UpdateTarget, node: MolstarNode<'trajectory'>): UpdateTarget | undefined {
         const format = node.params.format;
         if (format === 'cif') {
-            return update.to(msParent).apply(TrajectoryFromMmCif, {
+            return UpdateTarget.apply(updateParent, TrajectoryFromMmCif, {
                 blockHeader: node.params.block_header ?? '', // Must set to '' because just undefined would get overwritten by createDefaults
                 blockIndex: node.params.block_index ?? undefined,
-            }).selector;
+            });
         } else if (format === 'pdb') {
-            return update.to(msParent).apply(TrajectoryFromPDB, {}).selector;
+            return UpdateTarget.apply(updateParent, TrajectoryFromPDB, {});
         } else {
             console.error(`Unknown format in "trajectory" node: "${format}"`);
             return undefined;
         }
     },
-    model(update: StateBuilder.Root, msParent: StateObjectSelector, node: SubTreeOfKind<MolstarTree, 'model'>, context: MolstarLoadingContext): StateObjectSelector {
+    model(updateParent: UpdateTarget, node: SubTreeOfKind<MolstarTree, 'model'>, context: MolstarLoadingContext): UpdateTarget {
         const annotations = collectAnnotationReferences(node, context);
-        return update.to(msParent)
-            .apply(ModelFromTrajectory, {
-                modelIndex: node.params.model_index,
-            })
-            .apply(CustomModelProperties, {
-                properties: {
-                    [MVSAnnotationsProvider.descriptor.name]: { annotations }
-                },
-                autoAttach: [
-                    MVSAnnotationsProvider.descriptor.name
-                ],
-            }).selector;
+        const model = UpdateTarget.apply(updateParent, ModelFromTrajectory, {
+            modelIndex: node.params.model_index,
+        });
+        UpdateTarget.apply(model, CustomModelProperties, {
+            properties: {
+                [MVSAnnotationsProvider.descriptor.name]: { annotations }
+            },
+            autoAttach: [
+                MVSAnnotationsProvider.descriptor.name
+            ],
+        });
+        return model;
     },
-    structure(update: StateBuilder.Root, msParent: StateObjectSelector, node: SubTreeOfKind<MolstarTree, 'structure'>, context: MolstarLoadingContext): StateObjectSelector {
+    structure(updateParent: UpdateTarget, node: SubTreeOfKind<MolstarTree, 'structure'>, context: MolstarLoadingContext): UpdateTarget {
         const props = structureProps(node);
-        let result: StateObjectSelector = update.to(msParent).apply(StructureFromModel, props).selector;
+        const struct = UpdateTarget.apply(updateParent, StructureFromModel, props);
+        let transformed = struct;
         for (const t of transformProps(node)) {
-            result = update.to(result).apply(TransformStructureConformation, t).selector;
+            transformed = UpdateTarget.apply(transformed, TransformStructureConformation, t); // applying to the result of previous transform, to get the correct transform order
         }
         const annotationTooltips = collectAnnotationTooltips(node, context);
         const inlineTooltips = collectInlineTooltips(node, context);
         if (annotationTooltips.length + inlineTooltips.length > 0) {
-            update.to(result).apply(CustomStructureProperties, {
+            UpdateTarget.apply(struct, CustomStructureProperties, {
                 properties: {
                     [MVSAnnotationTooltipsProvider.descriptor.name]: { tooltips: annotationTooltips },
                     [CustomTooltipsProvider.descriptor.name]: { tooltips: inlineTooltips },
@@ -142,73 +148,71 @@ const MolstarLoadingActions: LoadingActions<MolstarTree, MolstarLoadingContext>
                 ],
             });
         }
-        return result;
+        const inlineLabels = collectInlineLabels(node, context);
+        if (inlineLabels.length > 0) {
+            const nearestReprNode = context.nearestReprMap?.get(node);
+            UpdateTarget.apply(struct, StructureRepresentation3D, {
+                type: {
+                    name: CustomLabelRepresentationProvider.name,
+                    params: { items: inlineLabels } satisfies Partial<CustomLabelProps>,
+                },
+                colorTheme: colorThemeForNode(nearestReprNode, context),
+            });
+        }
+        return struct;
     },
     tooltip: undefined, // No action needed, already loaded in `structure`
     tooltip_from_uri: undefined, // No action needed, already loaded in `structure`
     tooltip_from_source: undefined, // No action needed, already loaded in `structure`
-    component(update: StateBuilder.Root, msParent: StateObjectSelector, node: SubTreeOfKind<MolstarTree, 'component'>): StateObjectSelector | undefined {
+    component(updateParent: UpdateTarget, node: SubTreeOfKind<MolstarTree, 'component'>): UpdateTarget | undefined {
         if (isPhantomComponent(node)) {
-            return msParent;
+            return updateParent;
         }
         const selector = node.params.selector;
-        return update.to(msParent).apply(StructureComponent, {
+        return UpdateTarget.apply(updateParent, StructureComponent, {
             type: componentPropsFromSelector(selector),
-            label: canonicalJsonString(selector),
+            label: prettyNameFromSelector(selector),
             nullIfEmpty: false,
-        }).selector;
+        });
     },
-    component_from_uri(update: StateBuilder.Root, msParent: StateObjectSelector, node: SubTreeOfKind<MolstarTree, 'component_from_uri'>, context: MolstarLoadingContext): StateObjectSelector | undefined {
+    component_from_uri(updateParent: UpdateTarget, node: SubTreeOfKind<MolstarTree, 'component_from_uri'>, context: MolstarLoadingContext): UpdateTarget | undefined {
         if (isPhantomComponent(node)) return undefined;
         const props = componentFromXProps(node, context);
-        return update.to(msParent).apply(MVSAnnotationStructureComponent, props).selector;
+        return UpdateTarget.apply(updateParent, MVSAnnotationStructureComponent, props);
     },
-    component_from_source(update: StateBuilder.Root, msParent: StateObjectSelector, node: SubTreeOfKind<MolstarTree, 'component_from_source'>, context: MolstarLoadingContext): StateObjectSelector | undefined {
+    component_from_source(updateParent: UpdateTarget, node: SubTreeOfKind<MolstarTree, 'component_from_source'>, context: MolstarLoadingContext): UpdateTarget | undefined {
         if (isPhantomComponent(node)) return undefined;
         const props = componentFromXProps(node, context);
-        return update.to(msParent).apply(MVSAnnotationStructureComponent, props).selector;
+        return UpdateTarget.apply(updateParent, MVSAnnotationStructureComponent, props);
     },
-    representation(update: StateBuilder.Root, msParent: StateObjectSelector, node: MolstarNode<'representation'>, context: MolstarLoadingContext): StateObjectSelector {
-        return update.to(msParent).apply(StructureRepresentation3D, {
+    representation(updateParent: UpdateTarget, node: MolstarNode<'representation'>, context: MolstarLoadingContext): UpdateTarget {
+        return UpdateTarget.apply(updateParent, StructureRepresentation3D, {
             ...representationProps(node.params),
             colorTheme: colorThemeForNode(node, context),
-        }).selector;
-    },
-    color: undefined, // No action needed, already loaded in `structure`
-    color_from_uri: undefined, // No action needed, already loaded in `structure`
-    color_from_source: undefined, // No action needed, already loaded in `structure`
-    label(update: StateBuilder.Root, msParent: StateObjectSelector, node: MolstarNode<'label'>, context: MolstarLoadingContext): StateObjectSelector {
-        const item: CustomLabelProps['items'][number] = {
-            text: node.params.text,
-            position: { name: 'selection', params: {} },
-        };
-        const nearestReprNode = context.nearestReprMap?.get(node);
-        return update.to(msParent).apply(StructureRepresentation3D, {
-            type: {
-                name: CustomLabelRepresentationProvider.name,
-                params: { items: [item] } satisfies Partial<CustomLabelProps>
-            },
-            colorTheme: colorThemeForNode(nearestReprNode, context),
-        }).selector;
+        });
     },
-    label_from_uri(update: StateBuilder.Root, msParent: StateObjectSelector, node: MolstarNode<'label_from_uri'>, context: MolstarLoadingContext): StateObjectSelector {
+    color: undefined, // No action needed, already loaded in `representation`
+    color_from_uri: undefined, // No action needed, already loaded in `representation`
+    color_from_source: undefined, // No action needed, already loaded in `representation`
+    label: undefined, // No action needed, already loaded in `structure`
+    label_from_uri(updateParent: UpdateTarget, node: MolstarNode<'label_from_uri'>, context: MolstarLoadingContext): UpdateTarget {
         const props = labelFromXProps(node, context);
-        return update.to(msParent).apply(StructureRepresentation3D, props).selector;
+        return UpdateTarget.apply(updateParent, StructureRepresentation3D, props);
     },
-    label_from_source(update: StateBuilder.Root, msParent: StateObjectSelector, node: MolstarNode<'label_from_source'>, context: MolstarLoadingContext): StateObjectSelector {
+    label_from_source(updateParent: UpdateTarget, node: MolstarNode<'label_from_source'>, context: MolstarLoadingContext): UpdateTarget {
         const props = labelFromXProps(node, context);
-        return update.to(msParent).apply(StructureRepresentation3D, props).selector;
+        return UpdateTarget.apply(updateParent, StructureRepresentation3D, props);
     },
-    focus(update: StateBuilder.Root, msParent: StateObjectSelector, node: MolstarNode<'focus'>, context: MolstarLoadingContext): StateObjectSelector {
-        context.focus = { kind: 'focus', focusTarget: msParent, params: node.params };
-        return msParent;
+    focus(updateParent: UpdateTarget, node: MolstarNode<'focus'>, context: MolstarLoadingContext): UpdateTarget {
+        context.focus = { kind: 'focus', focusTarget: updateParent.selector, params: node.params };
+        return updateParent;
     },
-    camera(update: StateBuilder.Root, msParent: StateObjectSelector, node: MolstarNode<'camera'>, context: MolstarLoadingContext): StateObjectSelector {
+    camera(updateParent: UpdateTarget, node: MolstarNode<'camera'>, context: MolstarLoadingContext): UpdateTarget {
         context.focus = { kind: 'camera', params: node.params };
-        return msParent;
+        return updateParent;
     },
-    canvas(update: StateBuilder.Root, msParent: StateObjectSelector, node: MolstarNode<'canvas'>, context: MolstarLoadingContext): StateObjectSelector {
+    canvas(updateParent: UpdateTarget, node: MolstarNode<'canvas'>, context: MolstarLoadingContext): UpdateTarget {
         context.canvas = node.params;
-        return msParent;
+        return updateParent;
     },
 };

+ 40 - 7
src/extensions/mvs/mvs-data.ts

@@ -5,6 +5,7 @@
  */
 
 import { treeValidationIssues } from './tree/generic/tree-schema';
+import { treeToString } from './tree/generic/tree-utils';
 import { Root, createMVSBuilder } from './tree/mvs/mvs-builder';
 import { MVSTree, MVSTreeSchema } from './tree/mvs/mvs-tree';
 
@@ -13,8 +14,21 @@ import { MVSTree, MVSTreeSchema } from './tree/mvs/mvs-tree';
 export interface MVSData {
     /** MolViewSpec tree */
     root: MVSTree,
-    /** Integer defining the major version of MolViewSpec format (e.g. 1 for version '1.0.8') */
-    version: number,
+    /** Associated metadata */
+    metadata: MVSMetadata,
+}
+
+interface MVSMetadata {
+    /** Version of the spec used to write this tree */
+    version: string,
+    /** Name of this view */
+    title?: string,
+    /** Detailed description of this view */
+    description?: string,
+    /** Format of the description */
+    description_format?: 'markdown' | 'plaintext',
+    /** Timestamp when this view was exported */
+    timestamp: string,
 }
 
 export const MVSData = {
@@ -24,8 +38,11 @@ export const MVSData = {
     /** Parse MVSJ (MolViewSpec-JSON) format to `MVSData`. Does not include any validation. */
     fromMVSJ(mvsjString: string): MVSData {
         const result: MVSData = JSON.parse(mvsjString);
-        if (result?.version > MVSData.SupportedVersion) {
-            console.warn(`Loaded MVS is of higher version (${result?.version}) than currently supported version (${MVSData.SupportedVersion}). Some features may not work as expected.`);
+        const major = majorVersion(result?.metadata?.version);
+        if (major === undefined) {
+            console.error('Loaded MVS does not contain valid version info.');
+        } else if (major > (majorVersion(MVSData.SupportedVersion) ?? 0)) {
+            console.warn(`Loaded MVS is of higher version (${result.metadata.version}) than currently supported version (${MVSData.SupportedVersion}). Some features may not work as expected.`);
         }
         return result;
     },
@@ -44,22 +61,38 @@ export const MVSData = {
     /** Validate `MVSData`. Return `undefined` if OK; list of issues if not OK.
      * If `options.noExtra` is true, presence of any extra node parameters is treated as an issue. */
     validationIssues(mvsData: MVSData, options: { noExtra?: boolean } = {}): string[] | undefined {
-        if (typeof mvsData.version !== 'number') return [`"version" in MVS must be a number, not ${mvsData.version}`];
+        const version = mvsData?.metadata?.version;
+        if (typeof version !== 'string') return [`"version" in MVS must be a string, not ${typeof version}: ${version}`];
         if (mvsData.root === undefined) return [`"root" missing in MVS`];
         return treeValidationIssues(MVSTreeSchema, mvsData.root, options);
     },
 
+    /** Return a human-friendly textual representation of `mvsData`. */
+    toPrettyString(mvsData: MVSData): string {
+        const title = mvsData.metadata.title !== undefined ? ` "${mvsData.metadata.title}"` : '';
+        return `MolViewSpec tree${title} (version ${mvsData.metadata.version}, created ${mvsData.metadata.timestamp}):\n${treeToString(mvsData.root)}`;
+    },
+
     /** Create a new MolViewSpec builder containing only a root node. Example of MVS builder usage:
      *
      * ```
      * const builder = MVSData.createBuilder();
      * builder.canvas({ background_color: 'white' });
      * const struct = builder.download({ url: 'https://www.ebi.ac.uk/pdbe/entry-files/download/1og2_updated.cif' }).parse({ format: 'mmcif' }).modelStructure();
-     * struct.component().representation().color({ color: HexColor('#3050F8') });
-     * console.log(JSON.stringify(builder.getState()));
+     * struct.component().representation().color({ color: '#3050F8' });
+     * console.log(MVSData.toPrettyString(builder.getState()));
      * ```
      */
     createBuilder(): Root {
         return createMVSBuilder();
     },
 };
+
+
+/** Get the major version from a semantic version string, e.g. '1.0.8' -> 1 */
+function majorVersion(semanticVersion: string | number): number | undefined {
+    if (typeof semanticVersion === 'string') return parseInt(semanticVersion.split('.')[0]);
+    if (typeof semanticVersion === 'number') return Math.floor(semanticVersion);
+    console.error(`Version should be a string, not ${typeof semanticVersion}: ${semanticVersion}`);
+    return undefined;
+}

+ 11 - 5
src/extensions/mvs/tree/generic/params-schema.ts

@@ -7,6 +7,7 @@
 import * as iots from 'io-ts';
 import { PathReporter } from 'io-ts/PathReporter';
 import { isPlainObject, mapObjectMap } from '../../../../mol-util/object';
+import { onelinerJsonString } from '../../../../mol-util/json';
 
 
 /** All types that can be used in tree node params.
@@ -32,12 +33,17 @@ export function nullable<T extends iots.Type<any>>(type: T) {
     return union([type, iots.null]);
 }
 /** Type definition for literal types, e.g. `literal('red', 'green', 'blue')` means 'red' or 'green' or 'blue'  */
-export function literal<V extends string | number | boolean>(v1: V, v2?: V, ...others: V[]) {
-    if (v2 === undefined) {
-        return iots.literal(v1);
-    } else {
-        return union([iots.literal(v1), iots.literal(v2), ...others.map(v => iots.literal(v))]);
+export function literal<V extends string | number | boolean>(...values: V[]) {
+    if (values.length === 0) {
+        throw new Error(`literal type must have at least one value`);
     }
+    const typeName = `(${values.map(v => onelinerJsonString(v)).join(' | ')})`;
+    return new iots.Type<V>(
+        typeName,
+        ((value: any) => values.includes(value)) as any,
+        (value, ctx) => values.includes(value as any) ? { _tag: 'Right', right: value as any } : { _tag: 'Left', left: [{ value: value, context: ctx, message: `"${value}" is not a valid value for literal type ${typeName}` }] },
+        value => value
+    );
 }
 
 

+ 14 - 9
src/extensions/mvs/tree/generic/tree-schema.ts

@@ -157,33 +157,38 @@ function treeSchemaToString_<S extends TreeSchema>(schema: S, defaults?: Default
     const out: string[] = [];
     const bold = (str: string) => markdown ? `**${str}**` : str;
     const code = (str: string) => markdown ? `\`${str}\`` : str;
+    const h1 = markdown ? '## ' : '  - ';
+    const p1 = markdown ? '' : '    ';
+    const h2 = markdown ? '- ' : '      - ';
+    const p2 = markdown ? '  ' : '        ';
+    const newline = markdown ? '\n\n' : '\n';
     out.push(`Tree schema:`);
     for (const kind in schema.nodes) {
         const { description, params, parent } = schema.nodes[kind];
-        out.push(`  - ${bold(code(kind))}`);
+        out.push(`${h1}${code(kind)}`);
         if (kind === schema.rootKind) {
-            out.push('    [Root of the tree must be of this kind]');
+            out.push(`${p1}[Root of the tree must be of this kind]`);
         }
         if (description) {
-            out.push(`    ${description}`);
+            out.push(`${p1}${description}`);
         }
-        out.push(`    Parent: ${!parent ? 'any' : parent.length === 0 ? 'none' : parent.map(code).join(' or ')}`);
-        out.push(`    Params:${Object.keys(params).length > 0 ? '' : ' none'}`);
+        out.push(`${p1}Parent: ${!parent ? 'any' : parent.length === 0 ? 'none' : parent.map(code).join(' or ')}`);
+        out.push(`${p1}Params:${Object.keys(params).length > 0 ? '' : ' none'}`);
         for (const key in params) {
             const field = params[key];
             let typeString = field.type.name;
             if (typeString.startsWith('(') && typeString.endsWith(')')) {
                 typeString = typeString.slice(1, -1);
             }
-            out.push(`      - ${bold(code(key + (field.required ? ': ' : '?: ')))}${code(typeString)}`);
+            out.push(`${h2}${bold(code(key + (field.required ? ': ' : '?: ')))}${code(typeString)}`);
             const defaultValue = (defaults?.[kind] as any)?.[key];
             if (field.description) {
-                out.push(`        ${field.description}`);
+                out.push(`${p2}${field.description}`);
             }
             if (defaultValue !== undefined) {
-                out.push(`        Default: ${code(onelinerJsonString(defaultValue))}`);
+                out.push(`${p2}Default: ${code(onelinerJsonString(defaultValue))}`);
             }
         }
     }
-    return out.join(markdown ? '\n\n' : '\n');
+    return out.join(newline);
 }

+ 38 - 1
src/extensions/mvs/tree/generic/tree-utils.ts

@@ -31,7 +31,7 @@ export function treeToString(tree: Tree) {
 }
 
 /** Convert object to a human-friendly string (similar to JSON.stringify but without quoting keys) */
-function formatObject(obj: {} | undefined): string {
+export function formatObject(obj: {} | undefined): string {
     if (!obj) return 'undefined';
     return JSON.stringify(obj).replace(/,("\w+":)/g, ', $1').replace(/"(\w+)":/g, '$1: ');
 }
@@ -136,3 +136,40 @@ export function addDefaults<S extends TreeSchema>(tree: TreeFor<S>, defaults: De
     }
     return convertTree(tree, rules) as any;
 }
+
+/** Resolve any URI params in a tree, in place. URI params are those listed in `uriParamNames`.
+ * Relative URIs are treated as relative to `baseUri`, which can in turn be relative to the window URL (if available). */
+export function resolveUris<T extends Tree>(tree: T, baseUri: string, uriParamNames: string[]): void {
+    dfs(tree, node => {
+        const params = node.params as Record<string, any> | undefined;
+        if (!params) return;
+        for (const name in params) {
+            if (uriParamNames.includes(name)) {
+                const uri = params[name];
+                if (typeof uri === 'string') {
+                    params[name] = resolveUri(uri, baseUri, windowUrl());
+                }
+            }
+        }
+    });
+}
+
+/** Resolve a sequence of URI references (relative URIs), where each reference is either absolute or relative to the next one
+ * (i.e. the last one is the base URI). Skip any `undefined`.
+ * E.g. `resolveUri('./unexpected.png', '/spanish/inquisition/expectations.html', 'https://example.org/spam/spam/spam')`
+ * returns `'https://example.org/spanish/inquisition/unexpected.png'`. */
+function resolveUri(...refs: (string | undefined)[]): string | undefined {
+    let result: string | undefined = undefined;
+    for (const ref of refs.reverse()) {
+        if (ref !== undefined) {
+            if (result === undefined) result = ref;
+            else result = new URL(ref, result).href;
+        }
+    }
+    return result;
+}
+
+/** Return URL of the current page when running in a browser; `undefined` when running in Node. */
+function windowUrl(): string | undefined {
+    return (typeof window !== 'undefined') ? window.location.href : undefined;
+}

+ 3 - 2
src/extensions/mvs/tree/molstar/conversion.ts

@@ -4,7 +4,7 @@
  * @author Adam Midlik <midlik@gmail.com>
  */
 
-import { ConversionRules, addDefaults, condenseTree, convertTree, dfs } from '../generic/tree-utils';
+import { ConversionRules, addDefaults, condenseTree, convertTree, dfs, resolveUris } from '../generic/tree-utils';
 import { MolstarKind, MolstarNode, MolstarTree } from './molstar-tree';
 import { FullMVSTree, MVSTree, MVSTreeSchema } from '../mvs/mvs-tree';
 import { MVSDefaults } from '../mvs/mvs-defaults';
@@ -52,8 +52,9 @@ const mvsToMolstarConversionRules: ConversionRules<FullMVSTree, MolstarTree> = {
 const molstarNodesToCondense = new Set<MolstarKind>(['download', 'parse', 'trajectory', 'model'] satisfies MolstarKind[]);
 
 /** Convert MolViewSpec tree into MolStar tree */
-export function convertMvsToMolstar(mvsTree: MVSTree): MolstarTree {
+export function convertMvsToMolstar(mvsTree: MVSTree, sourceUrl: string | undefined): MolstarTree {
     const full = addDefaults<typeof MVSTreeSchema>(mvsTree, MVSDefaults) as FullMVSTree;
+    if (sourceUrl) resolveUris(full, sourceUrl, ['uri', 'url']);
     const converted = convertTree<FullMVSTree, MolstarTree>(full, mvsToMolstarConversionRules);
     if (converted.kind !== 'root') throw new Error("Root's type is not 'root' after conversion from MVS tree to Molstar tree.");
     const condensed = condenseTree<MolstarTree>(converted, molstarNodesToCondense);

+ 19 - 0
src/extensions/mvs/tree/mvs/_spec/mvs-builder.spec.ts

@@ -0,0 +1,19 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import { treeValidationIssues } from '../../generic/tree-schema';
+import { builderDemo } from '../mvs-builder';
+import { MVSTreeSchema } from '../mvs-tree';
+
+
+describe('mvs-builder', () => {
+    it('mvs-builder demo works', async () => {
+        const mvsData = builderDemo();
+        expect(typeof mvsData.metadata.version).toEqual('string');
+        expect(typeof mvsData.metadata.timestamp).toEqual('string');
+        expect(treeValidationIssues(MVSTreeSchema, mvsData.root)).toEqual(undefined);
+    });
+});

+ 26 - 15
src/extensions/mvs/tree/mvs/mvs-builder.ts

@@ -4,8 +4,7 @@
  * @author Adam Midlik <midlik@gmail.com>
  */
 
-import { pickObjectKeys } from '../../../../mol-util/object';
-import { HexColor } from '../../helpers/utils';
+import { deepClone, pickObjectKeys } from '../../../../mol-util/object';
 import { MVSData } from '../../mvs-data';
 import { ParamsOfKind, SubTreeOfKind } from '../generic/tree-schema';
 import { MVSDefaults } from './mvs-defaults';
@@ -18,7 +17,7 @@ import { MVSKind, MVSNode, MVSTree, MVSTreeSchema } from './mvs-tree';
  * const builder = createMVSBuilder();
  * builder.canvas({ background_color: 'white' });
  * const struct = builder.download({ url: 'https://www.ebi.ac.uk/pdbe/entry-files/download/1og2_updated.cif' }).parse({ format: 'mmcif' }).modelStructure();
- * struct.component().representation().color({ color: HexColor('#3050F8') });
+ * struct.component().representation().color({ color: '#3050F8' });
  * console.log(JSON.stringify(builder.getState()));
  * ```
  */
@@ -55,8 +54,15 @@ export class Root extends _Base<'root'> {
         (this._root as Root) = this;
     }
     /** Return the current state of the builder as object in MVS format. */
-    getState(): MVSData {
-        return { version: MVSData.SupportedVersion, root: this._node };
+    getState(metadata?: Partial<Pick<MVSData['metadata'], 'title' | 'description' | 'description_format'>>): MVSData {
+        return {
+            root: deepClone(this._node),
+            metadata: {
+                ...metadata,
+                version: `${MVSData.SupportedVersion}`,
+                timestamp: utcNowISO(),
+            },
+        };
     }
     // omitting `saveState`, filesystem operations are responsibility of the caller code (platform-dependent)
 
@@ -230,21 +236,26 @@ export function builderDemo() {
     const struct = builder.download({ url: 'https://www.ebi.ac.uk/pdbe/entry-files/download/1og2_updated.cif' }).parse({ format: 'mmcif' }).modelStructure();
     struct.component().representation().color({ color: 'white' });
     struct.component({ selector: 'ligand' }).representation({ type: 'ball_and_stick' })
-        .color({ color: HexColor('#555555') })
-        .color({ selector: { type_symbol: 'N' }, color: HexColor('#3050F8') })
-        .color({ selector: { type_symbol: 'O' }, color: HexColor('#FF0D0D') })
-        .color({ selector: { type_symbol: 'S' }, color: HexColor('#FFFF30') })
-        .color({ selector: { type_symbol: 'FE' }, color: HexColor('#E06633') });
+        .color({ color: '#555555' })
+        .color({ selector: { type_symbol: 'N' }, color: '#3050F8' })
+        .color({ selector: { type_symbol: 'O' }, color: '#FF0D0D' })
+        .color({ selector: { type_symbol: 'S' }, color: '#FFFF30' })
+        .color({ selector: { type_symbol: 'FE' }, color: '#E06633' });
     builder.download({ url: 'https://www.ebi.ac.uk/pdbe/entry-files/download/1og5_updated.cif' }).parse({ format: 'mmcif' }).assemblyStructure({ assembly_id: '1' }).component().representation().color({ color: 'cyan' });
     builder.download({ url: 'https://www.ebi.ac.uk/pdbe/entry-files/download/1og5_updated.cif' }).parse({ format: 'mmcif' }).assemblyStructure({ assembly_id: '2' }).component().representation().color({ color: 'blue' });
     const cif = builder.download({ url: 'https://www.ebi.ac.uk/pdbe/entry-files/download/1wrf_updated.cif' }).parse({ format: 'mmcif' });
 
-    cif.modelStructure({ model_index: 0 }).component().representation().color({ color: HexColor('#CC0000') });
-    cif.modelStructure({ model_index: 1 }).component().representation().color({ color: HexColor('#EE7700') });
-    cif.modelStructure({ model_index: 2 }).component().representation().color({ color: HexColor('#FFFF00') });
+    cif.modelStructure({ model_index: 0 }).component().representation().color({ color: '#CC0000' });
+    cif.modelStructure({ model_index: 1 }).component().representation().color({ color: '#EE7700' });
+    cif.modelStructure({ model_index: 2 }).component().representation().color({ color: '#FFFF00' });
 
-    cif.modelStructure({ model_index: 0 }).transform({ translation: [30, 0, 0] }).component().representation().color({ color: HexColor('#ff88bb') });
-    cif.modelStructure({ model_index: 0 as any }).transform({ translation: [60, 0, 0], rotation: [0, 1, 0, -1, 0, 0, 0, 0, 1] }).component().representation().color({ color: HexColor('#aa0077') });
+    cif.modelStructure({ model_index: 0 }).transform({ translation: [30, 0, 0] }).component().representation().color({ color: '#ff88bb' });
+    cif.modelStructure({ model_index: 0 as any }).transform({ translation: [60, 0, 0], rotation: [0, 1, 0, -1, 0, 0, 0, 0, 1] }).component().representation().color({ color: '#aa0077' });
 
     return builder.getState();
 }
+
+/** Return the current universal time, in ISO format, e.g. '2023-11-24T10:45:49.873Z' */
+function utcNowISO(): string {
+    return new Date().toISOString();
+}

+ 4 - 4
src/extensions/mvs/tree/mvs/mvs-tree.ts

@@ -146,8 +146,8 @@ export const MVSTreeSchema = TreeSchema({
             description: 'This node instructs to apply color to a visual representation.',
             parent: ['representation'],
             params: {
-                /** Color to apply to the representation. Can be either a color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`). */
-                color: RequiredField(ColorT, 'Color to apply to the representation. Can be either a color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`).'),
+                /** Color to apply to the representation. Can be either an X11 color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`). */
+                color: RequiredField(ColorT, 'Color to apply to the representation. Can be either an X11 color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`).'),
                 /** Defines to what part of the representation this color should be applied. */
                 selector: OptionalField(union([ComponentSelectorT, ComponentExpressionT, list(ComponentExpressionT)]), 'Defines to what part of the representation this color should be applied.'),
             },
@@ -247,8 +247,8 @@ export const MVSTreeSchema = TreeSchema({
             description: 'This node sets canvas properties.',
             parent: ['root'],
             params: {
-                /** Color of the canvas background. Can be either a color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`). */
-                background_color: RequiredField(ColorT, 'Color of the canvas background. Can be either a color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`).'),
+                /** Color of the canvas background. Can be either an X11 color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`). */
+                background_color: RequiredField(ColorT, 'Color of the canvas background. Can be either an X11 color name (e.g. `"red"`) or a hexadecimal code (e.g. `"#FF0011"`).'),
             },
         },
     }

+ 4 - 3
src/extensions/mvs/tree/mvs/param-types.ts

@@ -5,8 +5,9 @@
  */
 
 import * as iots from 'io-ts';
-import { HexColor, isHexColorString } from '../../helpers/utils';
+import { HexColor } from '../../helpers/utils';
 import { ValueFor, float, int, list, literal, str, tuple, union } from '../generic/params-schema';
+import { ColorNames } from '../../../../mol-util/color/names';
 
 
 /** `format` parameter values for `parse` node in MVS tree */
@@ -61,12 +62,12 @@ export const Matrix = list(float);
 export const HexColorT = new iots.Type<HexColor>(
     'HexColor',
     ((value: any) => typeof value === 'string') as any,
-    (value, ctx) => isHexColorString(value) ? { _tag: 'Right', right: value } : { _tag: 'Left', left: [{ value: value, context: ctx, message: `"${value}" is not a valid hex color string` }] },
+    (value, ctx) => HexColor.is(value) ? { _tag: 'Right', right: value } : { _tag: 'Left', left: [{ value: value, context: ctx, message: `"${value}" is not a valid hex color string` }] },
     value => value
 );
 
 /** `color` parameter values for `color` node in MVS tree */
-export const ColorNamesT = literal('white', 'gray', 'black', 'red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'magenta');
+export const ColorNamesT = literal(...Object.keys(ColorNames) as (keyof ColorNames)[]);
 
 /** `color` parameter values for `color` node in MVS tree */
 export const ColorT = union([HexColorT, ColorNamesT]);

+ 1 - 1
src/extensions/rcsb/graphql/types.ts

@@ -6,7 +6,7 @@ export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?:
 export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
 export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never };
 export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
-// Generated on 2023-11-05T13:21:21-08:00
+// Generated on 2023-12-02T13:19:10-08:00
 
 /** All built-in and custom scalars, mapped to their actual values */
 export type Scalars = {

+ 26 - 1
src/mol-geo/geometry/mesh/mesh-builder.ts

@@ -67,6 +67,22 @@ export namespace MeshBuilder {
         caAdd3(indices, offset, offset + 1, offset + 2);
     }
 
+    export function addTriangleWithNormal(state: State, a: Vec3, b: Vec3, c: Vec3, n: Vec3) {
+        const { vertices, normals, indices, groups, currentGroup } = state;
+        const offset = vertices.elementCount;
+
+        // positions
+        caAdd3(vertices, a[0], a[1], a[2]);
+        caAdd3(vertices, b[0], b[1], b[2]);
+        caAdd3(vertices, c[0], c[1], c[2]);
+
+        for (let i = 0; i < 3; ++i) {
+            caAdd3(normals, n[0], n[1], n[2]); // normal
+            caAdd(groups, currentGroup); // group
+        }
+        caAdd3(indices, offset, offset + 1, offset + 2);
+    }
+
     export function addTriangleStrip(state: State, vertices: ArrayLike<number>, indices: ArrayLike<number>) {
         v3fromArray(tmpVecC, vertices, indices[0] * 3);
         v3fromArray(tmpVecD, vertices, indices[1] * 3);
@@ -89,6 +105,15 @@ export namespace MeshBuilder {
         }
     }
 
+    export function addTriangleFanWithNormal(state: State, vertices: ArrayLike<number>, indices: ArrayLike<number>, normal: Vec3) {
+        v3fromArray(tmpVecA, vertices, indices[0] * 3);
+        for (let i = 2, il = indices.length; i < il; ++i) {
+            v3fromArray(tmpVecB, vertices, indices[i - 1] * 3);
+            v3fromArray(tmpVecC, vertices, indices[i] * 3);
+            addTriangleWithNormal(state, tmpVecA, tmpVecC, tmpVecB, normal);
+        }
+    }
+
     export function addPrimitive(state: State, t: Mat4, primitive: Primitive) {
         const { vertices: va, normals: na, indices: ia } = primitive;
         const { vertices, normals, indices, groups, currentGroup } = state;
@@ -160,4 +185,4 @@ export namespace MeshBuilder {
         const gb = ChunkedArray.compact(groups, true) as Float32Array;
         return Mesh.create(vb, ib, nb, gb, state.vertices.elementCount, state.indices.elementCount, mesh);
     }
-}
+}

+ 29 - 6
src/mol-geo/geometry/text/font-atlas.ts

@@ -2,11 +2,14 @@
  * Copyright (c) 2019-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ * @author Adam Midlik <midlik@gmail.com>
  */
 
 import { ParamDefinition as PD } from '../../../mol-util/param-definition';
 import { edt } from '../../../mol-math/geometry/distance-transform';
 import { createTextureImage, TextureImage } from '../../../mol-gl/renderable/util';
+import { RUNNING_IN_NODEJS } from '../../../mol-util/nodejs-shims';
+
 
 const TextAtlasCache: { [k: string]: FontAtlas } = {};
 
@@ -61,7 +64,6 @@ export class FontAtlas {
     private z: Float64Array;
     private v: Int16Array;
 
-    private scratchCanvas: HTMLCanvasElement;
     private scratchContext: CanvasRenderingContext2D;
 
     readonly lineHeight: number;
@@ -84,11 +86,8 @@ export class FontAtlas {
         this.texture = createTextureImage(350 * this.lineHeight * this.maxWidth, 1, Uint8Array);
 
         // prepare scratch canvas
-        this.scratchCanvas = document.createElement('canvas');
-        this.scratchCanvas.width = this.maxWidth;
-        this.scratchCanvas.height = this.lineHeight;
+        this.scratchContext = createCanvasContext(this.maxWidth, this.lineHeight, { willReadFrequently: true })!;
 
-        this.scratchContext = this.scratchCanvas.getContext('2d', { willReadFrequently: true })!;
         this.scratchContext.font = `${p.fontStyle} ${p.fontVariant} ${p.fontWeight} ${fontSize}px ${p.fontFamily}`;
         this.scratchContext.fillStyle = 'black';
         this.scratchContext.textBaseline = 'middle';
@@ -175,4 +174,28 @@ export class FontAtlas {
         this.scratchW = w;
         this.scratchH = h;
     }
-}
+}
+
+/** Type of imported `canvas` module (not using `typeof import('canvas')` to avoid missing types) */
+type CanvasModule = any;
+let _canvas: CanvasModule | undefined;
+function getCanvasModule(): CanvasModule {
+    if (!_canvas) throw new Error('When running in Node.js and wanting to use Canvas API, call mol-util/data-source\'s setCanvasModule function first and pass imported `canvas` module to it.');
+    return _canvas;
+}
+/** Set `canvas` module, before using Canvas API functionality in NodeJS. Usage: `setCanvasModule(require('canvas')); // some code `*/
+export function setCanvasModule(canvas: CanvasModule) {
+    _canvas = canvas;
+}
+/** Return a newly created canvas context (using a canvas HTML element in browser, canvas module in NodeJS) */
+function createCanvasContext(width: number, height: number, options?: CanvasRenderingContext2DSettings): CanvasRenderingContext2D | null {
+    if (RUNNING_IN_NODEJS) {
+        const canvas = getCanvasModule().createCanvas(width, height);
+        return canvas.getContext('2d', options) as unknown as CanvasRenderingContext2D;
+    } else {
+        const canvas = document.createElement('canvas');
+        canvas.width = width;
+        canvas.height = height;
+        return canvas.getContext('2d', options);
+    }
+}

+ 1 - 1
src/mol-gl/renderable/schema.ts

@@ -60,7 +60,7 @@ export function splitValues(schema: RenderableSchema, values: RenderableValues)
     return { attributeValues, defineValues, textureValues, materialTextureValues, uniformValues, materialUniformValues, bufferedUniformValues };
 }
 
-export type Versions<T extends RenderableValues> = { [k in keyof T]: number }
+export type Versions<T extends RenderableValues> = { -readonly [k in keyof T]: number }
 export function getValueVersions<T extends RenderableValues>(values: T) {
     const versions: Versions<any> = {};
     Object.keys(values).forEach(k => {

+ 1 - 1
src/mol-io/reader/cif/schema/bird.ts

@@ -1,7 +1,7 @@
 /**
  * Copyright (c) 2017-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
- * Code-generated 'BIRD' schema file. Dictionary versions: mmCIF 5.381, IHM 1.23, MA 1.4.5.
+ * Code-generated 'BIRD' schema file. Dictionary versions: mmCIF 5.382, IHM 1.24, MA 1.4.5.
  *
  * @author molstar/ciftools package
  */

+ 1 - 1
src/mol-io/reader/cif/schema/ccd.ts

@@ -1,7 +1,7 @@
 /**
  * Copyright (c) 2017-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
- * Code-generated 'CCD' schema file. Dictionary versions: mmCIF 5.381, IHM 1.23, MA 1.4.5.
+ * Code-generated 'CCD' schema file. Dictionary versions: mmCIF 5.382, IHM 1.24, MA 1.4.5.
  *
  * @author molstar/ciftools package
  */

+ 1 - 1
src/mol-io/reader/cif/schema/mmcif.ts

@@ -1,7 +1,7 @@
 /**
  * Copyright (c) 2017-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
- * Code-generated 'mmCIF' schema file. Dictionary versions: mmCIF 5.381, IHM 1.23, MA 1.4.5.
+ * Code-generated 'mmCIF' schema file. Dictionary versions: mmCIF 5.382, IHM 1.24, MA 1.4.5.
  *
  * @author molstar/ciftools package
  */

+ 6 - 4
src/mol-io/writer/ligand-encoder.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2020-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Sebastian Bittrich <sebastian.bittrich@rcsb.org>
  */
@@ -88,14 +88,14 @@ export abstract class LigandEncoder implements Encoder<string> {
         return StringBuilder.getString(this.builder);
     }
 
-    protected getAtoms<Ctx>(instance: Category.Instance<Ctx>, source: any): Map<string, Atom> {
+    protected getAtoms<Ctx>(instance: Category.Instance<Ctx>, source: any, ccdAtoms: ComponentAtom.Entry['map']): Map<string, Atom> {
         const sortedFields = this.getSortedFields(instance, ['Cartn_x', 'Cartn_y', 'Cartn_z']);
         const label_atom_id = this.getField(instance, 'label_atom_id');
         const type_symbol = this.getField(instance, 'type_symbol');
-        return this._getAtoms(source, sortedFields, label_atom_id, type_symbol);
+        return this._getAtoms(source, sortedFields, label_atom_id, type_symbol, ccdAtoms);
     }
 
-    private _getAtoms(source: any, fields: Field<any, any>[], label_atom_id: Field<any, any>, type_symbol: Field<any, any>): Map<string, Atom> {
+    private _getAtoms(source: any, fields: Field<any, any>[], label_atom_id: Field<any, any>, type_symbol: Field<any, any>, ccdAtoms: ComponentAtom.Entry['map']): Map<string, Atom> {
         const atoms = new Map<string, Atom>();
         let index = 0;
 
@@ -111,6 +111,8 @@ export abstract class LigandEncoder implements Encoder<string> {
                 const key = it.move();
 
                 const lai = label_atom_id.value(key, data, index) as string;
+                // ignore all atoms not registered in the CCD
+                if (!ccdAtoms.has(lai)) continue;
                 // ignore all alternate locations after the first
                 if (atoms.has(lai)) continue;
 

+ 1 - 1
src/mol-io/writer/mol/encoder.ts

@@ -34,7 +34,7 @@ export class MolEncoder extends LigandEncoder {
         let chiral = false;
 
         // traverse once to determine all actually present atoms
-        const atoms = this.getAtoms(instance, source);
+        const atoms = this.getAtoms(instance, source, atomMap.map);
         atoms.forEach((atom1, label_atom_id1) => {
             const { index: i1, type_symbol: type_symbol1 } = atom1;
             const atomMapData1 = atomMap.map.get(label_atom_id1);

+ 1 - 1
src/mol-io/writer/mol2/encoder.ts

@@ -36,7 +36,7 @@ export class Mol2Encoder extends LigandEncoder {
         let atomCount = 0;
         let bondCount = 0;
 
-        const atoms = this.getAtoms(instance, source);
+        const atoms = this.getAtoms(instance, source, atomMap.map);
         StringBuilder.writeSafe(a, '@<TRIPOS>ATOM\n');
         StringBuilder.writeSafe(b, '@<TRIPOS>BOND\n');
         atoms.forEach((atom1, label_atom_id1) => {

+ 13 - 0
src/mol-math/linear-algebra/3d/mat3.ts

@@ -90,6 +90,19 @@ namespace Mat3 {
         return a;
     }
 
+    export function fromColumns(out: Mat3, left: Vec3, middle: Vec3, right: Vec3) {
+        out[0] = left[0];
+        out[1] = left[1];
+        out[2] = left[2];
+        out[3] = middle[0];
+        out[4] = middle[1];
+        out[5] = middle[2];
+        out[6] = right[0];
+        out[7] = right[1];
+        out[8] = right[2];
+        return out;
+    }
+
     /**
      * Copies the upper-left 3x3 values into the given mat3.
      */

+ 7 - 0
src/mol-model-formats/structure/pdb/conect.ts

@@ -2,6 +2,7 @@
  * Copyright (c) 2021-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ * @author Yakov Pechersky <ffxen158@gmail.com>
  */
 
 import { CifCategory, CifField } from '../../../mol-io/reader/cif';
@@ -20,12 +21,14 @@ export function parseConect(lines: Tokens, lineStart: number, lineEnd: number, s
     const conn_type_id: string[] = [];
 
     const ptnr1_label_asym_id: string[] = [];
+    const ptnr1_label_seq_id: number[] = [];
     const ptnr1_auth_seq_id: number[] = [];
     const ptnr1_label_atom_id: string[] = [];
     const ptnr1_label_alt_id: string[] = [];
     const ptnr1_PDB_ins_code: string[] = [];
 
     const ptnr2_label_asym_id: string[] = [];
+    const ptnr2_label_seq_id: number[] = [];
     const ptnr2_auth_seq_id: number[] = [];
     const ptnr2_label_atom_id: string[] = [];
     const ptnr2_label_alt_id: string[] = [];
@@ -59,12 +62,14 @@ export function parseConect(lines: Tokens, lineStart: number, lineEnd: number, s
             conn_type_id.push('covale');
 
             ptnr1_label_asym_id.push(sites.label_asym_id!.str(idxA));
+            ptnr1_label_seq_id.push(sites.label_seq_id!.int(idxA));
             ptnr1_auth_seq_id.push(sites.auth_seq_id!.int(idxA));
             ptnr1_label_atom_id.push(sites.label_atom_id!.str(idxA));
             ptnr1_label_alt_id.push(sites.label_alt_id!.str(idxA));
             ptnr1_PDB_ins_code.push(sites.pdbx_PDB_ins_code!.str(idxA));
 
             ptnr2_label_asym_id.push(sites.label_asym_id!.str(idxB));
+            ptnr2_label_seq_id.push(sites.label_seq_id!.int(idxB));
             ptnr2_auth_seq_id.push(sites.auth_seq_id!.int(idxB));
             ptnr2_label_atom_id.push(sites.label_atom_id!.str(idxB));
             ptnr2_label_alt_id.push(sites.label_alt_id!.str(idxB));
@@ -79,12 +84,14 @@ export function parseConect(lines: Tokens, lineStart: number, lineEnd: number, s
         conn_type_id: CifField.ofStrings(conn_type_id),
 
         ptnr1_label_asym_id: CifField.ofStrings(ptnr1_label_asym_id),
+        ptnr1_label_seq_id: CifField.ofNumbers(ptnr1_label_seq_id),
         ptnr1_auth_seq_id: CifField.ofNumbers(ptnr1_auth_seq_id),
         ptnr1_label_atom_id: CifField.ofStrings(ptnr1_label_atom_id),
         pdbx_ptnr1_label_alt_id: CifField.ofStrings(ptnr1_label_alt_id),
         pdbx_ptnr1_PDB_ins_code: CifField.ofStrings(ptnr1_PDB_ins_code),
 
         ptnr2_label_asym_id: CifField.ofStrings(ptnr2_label_asym_id),
+        ptnr2_label_seq_id: CifField.ofNumbers(ptnr2_label_seq_id),
         ptnr2_auth_seq_id: CifField.ofNumbers(ptnr2_auth_seq_id),
         ptnr2_label_atom_id: CifField.ofStrings(ptnr2_label_atom_id),
         pdbx_ptnr2_label_alt_id: CifField.ofStrings(ptnr2_label_alt_id),

+ 10 - 2
src/mol-model-formats/structure/property/bonds/struct_conn.ts

@@ -3,6 +3,7 @@
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ * @author Yakov Pechersky <ffxen158@gmail.com>
  */
 
 import { Model } from '../../../../mol-model/structure/model/model';
@@ -94,6 +95,7 @@ export namespace StructConn {
         const { conn_type_id, pdbx_dist_value, pdbx_value_order } = struct_conn;
         const p1 = {
             label_asym_id: struct_conn.ptnr1_label_asym_id,
+            label_seq_id: struct_conn.ptnr1_label_seq_id,
             auth_seq_id: struct_conn.ptnr1_auth_seq_id,
             label_atom_id: struct_conn.ptnr1_label_atom_id,
             label_alt_id: struct_conn.pdbx_ptnr1_label_alt_id,
@@ -102,6 +104,7 @@ export namespace StructConn {
         };
         const p2: typeof p1 = {
             label_asym_id: struct_conn.ptnr2_label_asym_id,
+            label_seq_id: struct_conn.ptnr2_label_seq_id,
             auth_seq_id: struct_conn.ptnr2_auth_seq_id,
             label_atom_id: struct_conn.ptnr2_label_atom_id,
             label_alt_id: struct_conn.pdbx_ptnr2_label_alt_id,
@@ -117,13 +120,18 @@ export namespace StructConn {
             // turns out "mismat" records might not have atom name value
             if (!atomName) return undefined;
 
+            // prefer auth_seq_id, but if it is absent, then fall back to label_seq_id
+            const resId = (ps.auth_seq_id.valueKind(row) === Column.ValueKind.Present) ?
+                ps.auth_seq_id.value(row) :
+                ps.label_seq_id.value(row);
+            const resInsCode = ps.ins_code.value(row);
             const altId = ps.label_alt_id.value(row);
             for (const eId of entityIds) {
                 const residueIndex = model.atomicHierarchy.index.findResidue(
                     eId,
                     asymId,
-                    ps.auth_seq_id.value(row),
-                    ps.ins_code.value(row)
+                    resId,
+                    resInsCode
                 );
                 if (residueIndex < 0) continue;
                 const atomIndex = model.atomicHierarchy.index.findAtomOnResidue(residueIndex, atomName, altId);

+ 0 - 1
src/mol-repr/shape/loci/label.ts

@@ -22,7 +22,6 @@ export interface LabelData {
 
 const TextParams = {
     ...LociLabelTextParams,
-    offsetZ: PD.Numeric(2, { min: 0, max: 10, step: 0.1 }),
 };
 type TextParams = typeof TextParams
 

+ 14 - 4
src/mol-repr/structure/representation/cartoon.ts

@@ -1,7 +1,8 @@
 /**
- * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ * @author Gianluca Tomasello <giagitom@gmail.com>
  */
 
 import { Structure, Unit } from '../../../mol-model/structure';
@@ -12,6 +13,9 @@ import { StructureRepresentation, StructureRepresentationProvider, StructureRepr
 import { UnitsRepresentation } from '../units-representation';
 import { NucleotideBlockParams, NucleotideBlockVisual } from '../visual/nucleotide-block-mesh';
 import { NucleotideRingParams, NucleotideRingVisual } from '../visual/nucleotide-ring-mesh';
+import { NucleotideAtomicRingFillParams, NucleotideAtomicRingFillVisual } from '../visual/nucleotide-atomic-ring-fill';
+import { NucleotideAtomicBondParams, NucleotideAtomicBondVisual } from '../visual/nucleotide-atomic-bond';
+import { NucleotideAtomicElementParams, NucleotideAtomicElementVisual } from '../visual/nucleotide-atomic-element';
 import { PolymerDirectionParams, PolymerDirectionVisual } from '../visual/polymer-direction-wedge';
 import { PolymerGapParams, PolymerGapVisual } from '../visual/polymer-gap-cylinder';
 import { PolymerTraceParams, PolymerTraceVisual } from '../visual/polymer-trace-mesh';
@@ -25,7 +29,10 @@ const CartoonVisuals = {
     'polymer-gap': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, PolymerGapParams>) => UnitsRepresentation('Polymer gap cylinder', ctx, getParams, PolymerGapVisual),
     'nucleotide-block': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, NucleotideBlockParams>) => UnitsRepresentation('Nucleotide block mesh', ctx, getParams, NucleotideBlockVisual),
     'nucleotide-ring': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, NucleotideRingParams>) => UnitsRepresentation('Nucleotide ring mesh', ctx, getParams, NucleotideRingVisual),
-    'direction-wedge': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, PolymerDirectionParams>) => UnitsRepresentation('Polymer direction wedge', ctx, getParams, PolymerDirectionVisual)
+    'nucleotide-atomic-ring-fill': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, NucleotideAtomicRingFillParams>) => UnitsRepresentation('Nucleotide atomic ring fill', ctx, getParams, NucleotideAtomicRingFillVisual),
+    'nucleotide-atomic-bond': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, NucleotideAtomicBondParams>) => UnitsRepresentation('Nucleotide atomic bond', ctx, getParams, NucleotideAtomicBondVisual),
+    'nucleotide-atomic-element': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, NucleotideAtomicElementParams>) => UnitsRepresentation('Nucleotide atomic element', ctx, getParams, NucleotideAtomicElementVisual),
+    'direction-wedge': (ctx: RepresentationContext, getParams: RepresentationParamsGetter<Structure, PolymerDirectionParams>) => UnitsRepresentation('Polymer direction wedge', ctx, getParams, PolymerDirectionVisual),
 };
 
 export const CartoonParams = {
@@ -33,9 +40,12 @@ export const CartoonParams = {
     ...PolymerGapParams,
     ...NucleotideBlockParams,
     ...NucleotideRingParams,
+    ...NucleotideAtomicBondParams,
+    ...NucleotideAtomicElementParams,
+    ...NucleotideAtomicRingFillParams,
     ...PolymerDirectionParams,
     sizeFactor: PD.Numeric(0.2, { min: 0, max: 10, step: 0.01 }),
-    visuals: PD.MultiSelect(['polymer-trace', 'polymer-gap', 'nucleotide-ring'], PD.objectToOptions(CartoonVisuals)),
+    visuals: PD.MultiSelect(['polymer-trace', 'polymer-gap', 'nucleotide-ring', 'nucleotide-atomic-ring-fill', 'nucleotide-atomic-bond', 'nucleotide-atomic-element'], PD.objectToOptions(CartoonVisuals)),
     bumpFrequency: PD.Numeric(2, { min: 0, max: 10, step: 0.1 }, BaseGeometry.ShadingCategory),
 };
 
@@ -83,4 +93,4 @@ export const CartoonRepresentationProvider = StructureRepresentationProvider({
             }
         }
     }
-});
+});

+ 333 - 0
src/mol-repr/structure/visual/nucleotide-atomic-bond.ts

@@ -0,0 +1,333 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Gianluca Tomasello <giagitom@gmail.com>
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+import { Vec3 } from '../../../mol-math/linear-algebra';
+import { VisualContext } from '../../visual';
+import { Unit, Structure } from '../../../mol-model/structure';
+import { Theme } from '../../../mol-theme/theme';
+import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
+import { MeshBuilder } from '../../../mol-geo/geometry/mesh/mesh-builder';
+import { Segmentation } from '../../../mol-data/int';
+import { CylinderProps } from '../../../mol-geo/primitive/cylinder';
+import { isNucleic } from '../../../mol-model/structure/model/types';
+import { addCylinder } from '../../../mol-geo/geometry/mesh/builder/cylinder';
+import { UnitsMeshParams, UnitsVisual, UnitsMeshVisual, UnitsCylindersParams, UnitsCylindersVisual } from '../units-visual';
+import { NucleotideLocationIterator, getNucleotideElementLoci, eachNucleotideElement, getNucleotideBaseType, createNucleicIndices, setSugarIndices, hasSugarIndices, setPurinIndices, hasPurinIndices, setPyrimidineIndices, hasPyrimidineIndices } from './util/nucleotide';
+import { VisualUpdateState } from '../../util';
+import { BaseGeometry } from '../../../mol-geo/geometry/base';
+import { Sphere3D } from '../../../mol-math/geometry';
+
+import { WebGLContext } from '../../../mol-gl/webgl/context';
+
+import { Cylinders } from '../../../mol-geo/geometry/cylinders/cylinders';
+import { CylindersBuilder } from '../../../mol-geo/geometry/cylinders/cylinders-builder';
+import { StructureGroup } from './util/common';
+
+const pTrace = Vec3();
+
+const pN1 = Vec3();
+const pC2 = Vec3();
+const pN3 = Vec3();
+const pC4 = Vec3();
+const pC5 = Vec3();
+const pC6 = Vec3();
+const pN7 = Vec3();
+const pC8 = Vec3();
+const pN9 = Vec3();
+
+const pC1_1 = Vec3();
+const pC2_1 = Vec3();
+const pC3_1 = Vec3();
+const pC4_1 = Vec3();
+const pO4_1 = Vec3();
+
+export const NucleotideAtomicBondParams = {
+    ...UnitsMeshParams,
+    ...UnitsCylindersParams,
+    sizeFactor: PD.Numeric(0.3, { min: 0, max: 10, step: 0.01 }),
+    radialSegments: PD.Numeric(16, { min: 2, max: 56, step: 2 }, BaseGeometry.CustomQualityParamInfo),
+    tryUseImpostor: PD.Boolean(true)
+};
+export type NucleotideAtomicBondParams = typeof NucleotideAtomicBondParams
+interface NucleotideAtomicBondImpostorProps {
+    sizeFactor: number,
+}
+
+export function NucleotideAtomicBondVisual(materialId: number, structure: Structure, props: PD.Values<NucleotideAtomicBondParams>, webgl?: WebGLContext) {
+    return props.tryUseImpostor && webgl && webgl.extensions.fragDepth
+        ? NucleotideAtomicBondImpostorVisual(materialId)
+        : NucleotideAtomicBondMeshVisual(materialId);
+}
+
+function createNucleotideAtomicBondImpostor(ctx: VisualContext, unit: Unit, structure: Structure, theme: Theme, props: NucleotideAtomicBondImpostorProps, cylinders?: Cylinders) {
+    if (!Unit.isAtomic(unit)) return Cylinders.createEmpty(cylinders);
+
+    const nucleotideElementCount = unit.nucleotideElements.length;
+    if (!nucleotideElementCount) return Cylinders.createEmpty(cylinders);
+
+    const cylindersCountEstimate = nucleotideElementCount * 15; // 15 is the average purine (17) & pirimidine (13) bonds
+    const builder = CylindersBuilder.create(cylindersCountEstimate, cylindersCountEstimate / 4, cylinders);
+
+    const { elements, model } = unit;
+    const { chainAtomSegments, residueAtomSegments } = model.atomicHierarchy;
+
+    const { moleculeType } = model.atomicHierarchy.derived.residue;
+    const pos = unit.conformation.invariantPosition;
+
+    const chainIt = Segmentation.transientSegments(chainAtomSegments, elements);
+    const residueIt = Segmentation.transientSegments(residueAtomSegments, elements);
+
+    let i = 0;
+    while (chainIt.hasNext) {
+        residueIt.setSegment(chainIt.move());
+
+        while (residueIt.hasNext) {
+            const { index: residueIndex } = residueIt.move();
+
+            if (isNucleic(moleculeType[residueIndex])) {
+                const idx = createNucleicIndices();
+
+                setSugarIndices(idx, unit, residueIndex);
+
+                if (hasSugarIndices(idx)) {
+                    pos(idx.C1_1, pC1_1); pos(idx.C2_1, pC2_1); pos(idx.C3_1, pC3_1); pos(idx.C4_1, pC4_1); pos(idx.O4_1, pO4_1);
+
+                    // trace cylinder
+                    pos(idx.trace, pTrace);
+                    builder.add(pC3_1[0], pC3_1[1], pC3_1[2], pTrace[0], pTrace[1], pTrace[2], 1, true, true, i);
+
+                    // sugar ring
+                    builder.add(pC3_1[0], pC3_1[1], pC3_1[2], pC4_1[0], pC4_1[1], pC4_1[2], 1, true, true, i);
+                    builder.add(pC4_1[0], pC4_1[1], pC4_1[2], pO4_1[0], pO4_1[1], pO4_1[2], 1, true, true, i);
+                    builder.add(pO4_1[0], pO4_1[1], pO4_1[2], pC1_1[0], pC1_1[1], pC1_1[2], 1, true, true, i);
+                    builder.add(pC1_1[0], pC1_1[1], pC1_1[2], pC2_1[0], pC2_1[1], pC2_1[2], 1, true, true, i);
+                    builder.add(pC2_1[0], pC2_1[1], pC2_1[2], pC3_1[0], pC3_1[1], pC3_1[2], 1, true, true, i);
+                }
+
+                const { isPurine, isPyrimidine } = getNucleotideBaseType(unit, residueIndex);
+
+                if (isPurine) {
+                    setPurinIndices(idx, unit, residueIndex);
+
+                    if (idx.C1_1 !== -1 && idx.N9 !== -1) {
+                        pos(idx.C1_1, pC1_1); pos(idx.N9, pN9);
+                        builder.add(pN9[0], pN9[1], pN9[2], pC1_1[0], pC1_1[1], pC1_1[2], 1, true, true, i);
+                    } else if (idx.N9 !== -1 && idx.trace !== -1) {
+                        pos(idx.N9, pN9); pos(idx.trace, pTrace);
+                        builder.add(pN9[0], pN9[1], pN9[2], pTrace[0], pTrace[1], pTrace[2], 1, true, true, i);
+                    }
+
+                    if (hasPurinIndices(idx)) {
+                        pos(idx.N1, pN1); pos(idx.C2, pC2); pos(idx.N3, pN3); pos(idx.C4, pC4); pos(idx.C5, pC5); pos(idx.C6, pC6); pos(idx.N7, pN7); pos(idx.C8, pC8); pos(idx.N9, pN9);
+
+                        // base ring
+                        builder.add(pN9[0], pN9[1], pN9[2], pC8[0], pC8[1], pC8[2], 1, true, true, i);
+                        builder.add(pC8[0], pC8[1], pC8[2], pN7[0], pN7[1], pN7[2], 1, true, true, i);
+                        builder.add(pN7[0], pN7[1], pN7[2], pC5[0], pC5[1], pC5[2], 1, true, true, i);
+                        builder.add(pC5[0], pC5[1], pC5[2], pC6[0], pC6[1], pC6[2], 1, true, true, i);
+                        builder.add(pC6[0], pC6[1], pC6[2], pN1[0], pN1[1], pN1[2], 1, true, true, i);
+                        builder.add(pN1[0], pN1[1], pN1[2], pC2[0], pC2[1], pC2[2], 1, true, true, i);
+                        builder.add(pC2[0], pC2[1], pC2[2], pN3[0], pN3[1], pN3[2], 1, true, true, i);
+                        builder.add(pN3[0], pN3[1], pN3[2], pC4[0], pC4[1], pC4[2], 1, true, true, i);
+                        builder.add(pC4[0], pC4[1], pC4[2], pC5[0], pC5[1], pC5[2], 1, true, true, i);
+                        builder.add(pC4[0], pC4[1], pC4[2], pN9[0], pN9[1], pN9[2], 1, true, true, i);
+
+                    }
+                } else if (isPyrimidine) {
+                    setPyrimidineIndices(idx, unit, residueIndex);
+
+                    if (idx.C1_1 !== -1 && idx.N1 !== -1) {
+                        pos(idx.N1, pN1); pos(idx.C1_1, pC1_1);
+                        builder.add(pN1[0], pN1[1], pN1[2], pC1_1[0], pC1_1[1], pC1_1[2], 1, true, true, i);
+                    } else if (idx.N1 !== -1 && idx.trace !== -1) {
+                        pos(idx.N1, pN1); pos(idx.trace, pTrace);
+                        builder.add(pN1[0], pN1[1], pN1[2], pTrace[0], pTrace[1], pTrace[2], 1, true, true, i);
+                    }
+
+                    if (hasPyrimidineIndices(idx)) {
+                        pos(idx.N1, pN1); pos(idx.C2, pC2); pos(idx.N3, pN3); pos(idx.C4, pC4); pos(idx.C5, pC5); pos(idx.C6, pC6);
+
+                        // base ring
+                        builder.add(pN1[0], pN1[1], pN1[2], pC6[0], pC6[1], pC6[2], 1, true, true, i);
+                        builder.add(pC6[0], pC6[1], pC6[2], pC5[0], pC5[1], pC5[2], 1, true, true, i);
+                        builder.add(pC5[0], pC5[1], pC5[2], pC4[0], pC4[1], pC4[2], 1, true, true, i);
+                        builder.add(pC4[0], pC4[1], pC4[2], pN3[0], pN3[1], pN3[2], 1, true, true, i);
+                        builder.add(pN3[0], pN3[1], pN3[2], pC2[0], pC2[1], pC2[2], 1, true, true, i);
+                        builder.add(pC2[0], pC2[1], pC2[2], pN1[0], pN1[1], pN1[2], 1, true, true, i);
+                    }
+                }
+
+                ++i;
+            }
+        }
+    }
+    const c = builder.getCylinders();
+
+    const sphere = Sphere3D.expand(Sphere3D(), unit.boundary.sphere, 1 * props.sizeFactor);
+    c.setBoundingSphere(sphere);
+
+    return c;
+}
+
+export function NucleotideAtomicBondImpostorVisual(materialId: number): UnitsVisual<NucleotideAtomicBondParams> {
+    return UnitsCylindersVisual<NucleotideAtomicBondParams>({
+        defaultProps: PD.getDefaultValues(NucleotideAtomicBondParams),
+        createGeometry: createNucleotideAtomicBondImpostor,
+        createLocationIterator: NucleotideLocationIterator.fromGroup,
+        getLoci: getNucleotideElementLoci,
+        eachLocation: eachNucleotideElement,
+        setUpdateState: (state: VisualUpdateState, newProps: PD.Values<NucleotideAtomicBondParams>, currentProps: PD.Values<NucleotideAtomicBondParams>) => {
+            state.createGeometry = (
+                newProps.sizeFactor !== currentProps.sizeFactor
+            );
+        },
+        mustRecreate: (structureGroup: StructureGroup, props: PD.Values<NucleotideAtomicBondParams>, webgl?: WebGLContext) => {
+            return !props.tryUseImpostor || !webgl;
+        }
+    }, materialId);
+}
+
+interface NucleotideAtomicBondMeshProps {
+    radialSegments: number,
+    sizeFactor: number,
+}
+
+function createNucleotideAtomicBondMesh(ctx: VisualContext, unit: Unit, structure: Structure, theme: Theme, props: NucleotideAtomicBondMeshProps, mesh?: Mesh) {
+    if (!Unit.isAtomic(unit)) return Mesh.createEmpty(mesh);
+
+    const nucleotideElementCount = unit.nucleotideElements.length;
+    if (!nucleotideElementCount) return Mesh.createEmpty(mesh);
+
+    const { sizeFactor, radialSegments } = props;
+
+    const vertexCount = nucleotideElementCount * (radialSegments * 15); // 15 is the average purine (17) & pirimidine (13) bonds
+    const builderState = MeshBuilder.createState(vertexCount, vertexCount / 4, mesh);
+
+    const { elements, model } = unit;
+    const { chainAtomSegments, residueAtomSegments } = model.atomicHierarchy;
+    const { moleculeType } = model.atomicHierarchy.derived.residue;
+    const pos = unit.conformation.invariantPosition;
+
+    const chainIt = Segmentation.transientSegments(chainAtomSegments, elements);
+    const residueIt = Segmentation.transientSegments(residueAtomSegments, elements);
+
+    const cylinderProps: CylinderProps = { radiusTop: 1 * sizeFactor, radiusBottom: 1 * sizeFactor, radialSegments };
+
+    let i = 0;
+    while (chainIt.hasNext) {
+        residueIt.setSegment(chainIt.move());
+
+        while (residueIt.hasNext) {
+            const { index: residueIndex } = residueIt.move();
+
+            if (isNucleic(moleculeType[residueIndex])) {
+                const idx = createNucleicIndices();
+
+                builderState.currentGroup = i;
+
+                setSugarIndices(idx, unit, residueIndex);
+
+                if (hasSugarIndices(idx)) {
+                    pos(idx.C1_1, pC1_1); pos(idx.C2_1, pC2_1); pos(idx.C3_1, pC3_1); pos(idx.C4_1, pC4_1); pos(idx.O4_1, pO4_1);
+
+                    // trace cylinder
+                    pos(idx.trace, pTrace);
+                    addCylinder(builderState, pC3_1, pTrace, 1, cylinderProps);
+
+                    // sugar ring
+                    addCylinder(builderState, pC3_1, pC4_1, 1, cylinderProps);
+                    addCylinder(builderState, pC4_1, pO4_1, 1, cylinderProps);
+                    addCylinder(builderState, pO4_1, pC1_1, 1, cylinderProps);
+                    addCylinder(builderState, pC1_1, pC2_1, 1, cylinderProps);
+                    addCylinder(builderState, pC2_1, pC3_1, 1, cylinderProps);
+                }
+
+                const { isPurine, isPyrimidine } = getNucleotideBaseType(unit, residueIndex);
+
+                if (isPurine) {
+                    setPurinIndices(idx, unit, residueIndex);
+
+                    if (idx.C1_1 !== -1 && idx.N9 !== -1) {
+                        pos(idx.C1_1, pC1_1); pos(idx.N9, pN9);
+                        addCylinder(builderState, pN9, pC1_1, 1, cylinderProps);
+                    } else if (idx.N9 !== -1 && idx.trace !== -1) {
+                        pos(idx.N9, pN9); pos(idx.trace, pTrace);
+                        addCylinder(builderState, pN9, pTrace, 1, cylinderProps);
+                    }
+
+                    if (hasPurinIndices(idx)) {
+                        pos(idx.N1, pN1); pos(idx.C2, pC2); pos(idx.N3, pN3); pos(idx.C4, pC4); pos(idx.C5, pC5); pos(idx.C6, pC6); pos(idx.N7, pN7); pos(idx.C8, pC8); pos(idx.N9, pN9);
+
+                        // base ring
+                        addCylinder(builderState, pN9, pC8, 1, cylinderProps);
+                        addCylinder(builderState, pC8, pN7, 1, cylinderProps);
+                        addCylinder(builderState, pN7, pC5, 1, cylinderProps);
+                        addCylinder(builderState, pC5, pC6, 1, cylinderProps);
+                        addCylinder(builderState, pC6, pN1, 1, cylinderProps);
+                        addCylinder(builderState, pN1, pC2, 1, cylinderProps);
+                        addCylinder(builderState, pC2, pN3, 1, cylinderProps);
+                        addCylinder(builderState, pN3, pC4, 1, cylinderProps);
+                        addCylinder(builderState, pC4, pC5, 1, cylinderProps);
+                        addCylinder(builderState, pC4, pN9, 1, cylinderProps);
+                    }
+                } else if (isPyrimidine) {
+                    setPyrimidineIndices(idx, unit, residueIndex);
+
+                    if (idx.C1_1 !== -1 && idx.N1 !== -1) {
+                        pos(idx.N1, pN1); pos(idx.C1_1, pC1_1);
+                        addCylinder(builderState, pN1, pC1_1, 1, cylinderProps);
+                    } else if (idx.N1 !== -1 && idx.trace !== -1) {
+                        pos(idx.N1, pN1); pos(idx.trace, pTrace);
+                        addCylinder(builderState, pN1, pTrace, 1, cylinderProps);
+                    }
+
+                    if (hasPyrimidineIndices(idx)) {
+                        pos(idx.N1, pN1); pos(idx.C2, pC2); pos(idx.N3, pN3); pos(idx.C4, pC4); pos(idx.C5, pC5); pos(idx.C6, pC6);
+
+                        // base ring
+                        addCylinder(builderState, pN1, pC6, 1, cylinderProps);
+                        addCylinder(builderState, pC6, pC5, 1, cylinderProps);
+                        addCylinder(builderState, pC5, pC4, 1, cylinderProps);
+                        addCylinder(builderState, pC4, pN3, 1, cylinderProps);
+                        addCylinder(builderState, pN3, pC2, 1, cylinderProps);
+                        addCylinder(builderState, pC2, pN1, 1, cylinderProps);
+                    }
+                }
+
+                ++i;
+            }
+        }
+    }
+
+    const m = MeshBuilder.getMesh(builderState);
+
+    const sphere = Sphere3D.expand(Sphere3D(), unit.boundary.sphere, 1 * props.sizeFactor);
+    m.setBoundingSphere(sphere);
+
+    return m;
+}
+
+
+export function NucleotideAtomicBondMeshVisual(materialId: number): UnitsVisual<NucleotideAtomicBondParams> {
+    return UnitsMeshVisual<NucleotideAtomicBondParams>({
+        defaultProps: PD.getDefaultValues(NucleotideAtomicBondParams),
+        createGeometry: createNucleotideAtomicBondMesh,
+        createLocationIterator: NucleotideLocationIterator.fromGroup,
+        getLoci: getNucleotideElementLoci,
+        eachLocation: eachNucleotideElement,
+        setUpdateState: (state: VisualUpdateState, newProps: PD.Values<NucleotideAtomicBondParams>, currentProps: PD.Values<NucleotideAtomicBondParams>) => {
+            state.createGeometry = (
+                newProps.sizeFactor !== currentProps.sizeFactor ||
+                newProps.radialSegments !== currentProps.radialSegments
+            );
+        },
+        mustRecreate: (structureGroup: StructureGroup, props: PD.Values<NucleotideAtomicBondParams>, webgl?: WebGLContext) => {
+            return props.tryUseImpostor && !!webgl;
+        }
+    }, materialId);
+}

+ 297 - 0
src/mol-repr/structure/visual/nucleotide-atomic-element.ts

@@ -0,0 +1,297 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Gianluca Tomasello <giagitom@gmail.com>
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+import { Vec3 } from '../../../mol-math/linear-algebra';
+import { VisualContext } from '../../visual';
+import { Unit, Structure } from '../../../mol-model/structure';
+import { Theme } from '../../../mol-theme/theme';
+import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
+import { MeshBuilder } from '../../../mol-geo/geometry/mesh/mesh-builder';
+import { Segmentation } from '../../../mol-data/int';
+import { isNucleic } from '../../../mol-model/structure/model/types';
+import { addSphere } from '../../../mol-geo/geometry/mesh/builder/sphere';
+import { UnitsMeshParams, UnitsVisual, UnitsMeshVisual, UnitsSpheresParams, UnitsSpheresVisual } from '../units-visual';
+import { NucleotideLocationIterator, getNucleotideElementLoci, eachNucleotideElement, getNucleotideBaseType, createNucleicIndices, setSugarIndices, hasSugarIndices, setPurinIndices, hasPurinIndices, setPyrimidineIndices, hasPyrimidineIndices } from './util/nucleotide';
+import { VisualUpdateState } from '../../util';
+import { BaseGeometry } from '../../../mol-geo/geometry/base';
+import { Sphere3D } from '../../../mol-math/geometry';
+import { WebGLContext } from '../../../mol-gl/webgl/context';
+import { Spheres } from '../../../mol-geo/geometry/spheres/spheres';
+import { sphereVertexCount } from '../../../mol-geo/primitive/sphere';
+import { SpheresBuilder } from '../../../mol-geo/geometry/spheres/spheres-builder';
+import { StructureGroup } from './util/common';
+
+const pTrace = Vec3();
+
+const pN1 = Vec3();
+const pC2 = Vec3();
+const pN3 = Vec3();
+const pC4 = Vec3();
+const pC5 = Vec3();
+const pC6 = Vec3();
+const pN7 = Vec3();
+const pC8 = Vec3();
+const pN9 = Vec3();
+
+const pC1_1 = Vec3();
+const pC2_1 = Vec3();
+const pC3_1 = Vec3();
+const pC4_1 = Vec3();
+const pO4_1 = Vec3();
+
+export const NucleotideAtomicElementParams = {
+    ...UnitsMeshParams,
+    ...UnitsSpheresParams,
+    sizeFactor: PD.Numeric(0.3, { min: 0, max: 10, step: 0.01 }),
+    detail: PD.Numeric(0, { min: 0, max: 3, step: 1 }, BaseGeometry.CustomQualityParamInfo),
+    tryUseImpostor: PD.Boolean(true)
+};
+export type NucleotideAtomicElementParams = typeof NucleotideAtomicElementParams
+interface NucleotideAtomicElementImpostorProps {
+    sizeFactor: number,
+}
+
+export function NucleotideAtomicElementVisual(materialId: number, structure: Structure, props: PD.Values<NucleotideAtomicElementParams>, webgl?: WebGLContext) {
+    return props.tryUseImpostor && webgl && webgl.extensions.fragDepth
+        ? NucleotideAtomicElementImpostorVisual(materialId)
+        : NucleotideAtomicElementMeshVisual(materialId);
+}
+
+function createNucleotideAtomicElementImpostor(ctx: VisualContext, unit: Unit, structure: Structure, theme: Theme, props: NucleotideAtomicElementImpostorProps, spheres?: Spheres) {
+    if (!Unit.isAtomic(unit)) return Spheres.createEmpty(spheres);
+
+    const nucleotideElementCount = unit.nucleotideElements.length;
+    if (!nucleotideElementCount) return Spheres.createEmpty(spheres);
+
+    const spheresCountEstimate = nucleotideElementCount * 15; // 15 is the average purine (17) & pirimidine (13) bonds
+    const builder = SpheresBuilder.create(spheresCountEstimate, spheresCountEstimate / 4, spheres);
+
+    const { elements, model } = unit;
+    const { chainAtomSegments, residueAtomSegments } = model.atomicHierarchy;
+
+    const { moleculeType } = model.atomicHierarchy.derived.residue;
+    const pos = unit.conformation.invariantPosition;
+
+    const chainIt = Segmentation.transientSegments(chainAtomSegments, elements);
+    const residueIt = Segmentation.transientSegments(residueAtomSegments, elements);
+
+    let i = 0;
+    while (chainIt.hasNext) {
+        residueIt.setSegment(chainIt.move());
+
+        while (residueIt.hasNext) {
+            const { index: residueIndex } = residueIt.move();
+
+            if (isNucleic(moleculeType[residueIndex])) {
+                const idx = createNucleicIndices();
+
+                setSugarIndices(idx, unit, residueIndex);
+
+                if (hasSugarIndices(idx)) {
+                    pos(idx.C1_1, pC1_1); pos(idx.C2_1, pC2_1); pos(idx.C3_1, pC3_1); pos(idx.C4_1, pC4_1); pos(idx.O4_1, pO4_1);
+
+                    // trace cylinder
+                    pos(idx.trace, pTrace);
+                    builder.add(pTrace[0], pTrace[1], pTrace[2], i);
+
+                    // sugar ring
+                    builder.add(pC3_1[0], pC3_1[1], pC3_1[2], i);
+                    builder.add(pC4_1[0], pC4_1[1], pC4_1[2], i);
+                    builder.add(pO4_1[0], pO4_1[1], pO4_1[2], i);
+                    builder.add(pC1_1[0], pC1_1[1], pC1_1[2], i);
+                    builder.add(pC2_1[0], pC2_1[1], pC2_1[2], i);
+                }
+
+                const { isPurine, isPyrimidine } = getNucleotideBaseType(unit, residueIndex);
+
+                if (isPurine) {
+                    setPurinIndices(idx, unit, residueIndex);
+
+                    if (hasPurinIndices(idx)) {
+                        pos(idx.N1, pN1); pos(idx.C2, pC2); pos(idx.N3, pN3); pos(idx.C4, pC4); pos(idx.C5, pC5); pos(idx.C6, pC6); pos(idx.N7, pN7); pos(idx.C8, pC8); pos(idx.N9, pN9);
+
+                        // base ring
+                        builder.add(pN9[0], pN9[1], pN9[2], i);
+                        builder.add(pC8[0], pC8[1], pC8[2], i);
+                        builder.add(pN7[0], pN7[1], pN7[2], i);
+                        builder.add(pC5[0], pC5[1], pC5[2], i);
+                        builder.add(pC6[0], pC6[1], pC6[2], i);
+                        builder.add(pN1[0], pN1[1], pN1[2], i);
+                        builder.add(pC2[0], pC2[1], pC2[2], i);
+                        builder.add(pN3[0], pN3[1], pN3[2], i);
+                        builder.add(pC4[0], pC4[1], pC4[2], i);
+                    }
+                } else if (isPyrimidine) {
+                    setPyrimidineIndices(idx, unit, residueIndex);
+
+                    if (hasPyrimidineIndices(idx)) {
+                        pos(idx.N1, pN1); pos(idx.C2, pC2); pos(idx.N3, pN3); pos(idx.C4, pC4); pos(idx.C5, pC5); pos(idx.C6, pC6);
+
+                        // base ring
+                        builder.add(pN1[0], pN1[1], pN1[2], i);
+                        builder.add(pC6[0], pC6[1], pC6[2], i);
+                        builder.add(pC5[0], pC5[1], pC5[2], i);
+                        builder.add(pC4[0], pC4[1], pC4[2], i);
+                        builder.add(pN3[0], pN3[1], pN3[2], i);
+                        builder.add(pC2[0], pC2[1], pC2[2], i);
+                    }
+                }
+
+                ++i;
+            }
+        }
+    }
+    const c = builder.getSpheres();
+
+    const sphere = Sphere3D.expand(Sphere3D(), unit.boundary.sphere, 1 * props.sizeFactor);
+    c.setBoundingSphere(sphere);
+
+    return c;
+}
+
+export function NucleotideAtomicElementImpostorVisual(materialId: number): UnitsVisual<NucleotideAtomicElementParams> {
+    return UnitsSpheresVisual<NucleotideAtomicElementParams>({
+        defaultProps: PD.getDefaultValues(NucleotideAtomicElementParams),
+        createGeometry: createNucleotideAtomicElementImpostor,
+        createLocationIterator: NucleotideLocationIterator.fromGroup,
+        getLoci: getNucleotideElementLoci,
+        eachLocation: eachNucleotideElement,
+        setUpdateState: (state: VisualUpdateState, newProps: PD.Values<NucleotideAtomicElementParams>, currentProps: PD.Values<NucleotideAtomicElementParams>) => {
+            state.createGeometry = (
+                newProps.sizeFactor !== currentProps.sizeFactor
+            );
+        },
+        mustRecreate: (structureGroup: StructureGroup, props: PD.Values<NucleotideAtomicElementParams>, webgl?: WebGLContext) => {
+            return !props.tryUseImpostor || !webgl;
+        }
+    }, materialId);
+}
+
+interface NucleotideAtomicElementMeshProps {
+    detail: number,
+    sizeFactor: number,
+}
+
+function createNucleotideAtomicElementMesh(ctx: VisualContext, unit: Unit, structure: Structure, theme: Theme, props: NucleotideAtomicElementMeshProps, mesh?: Mesh) {
+    if (!Unit.isAtomic(unit)) return Mesh.createEmpty(mesh);
+
+    const nucleotideElementCount = unit.nucleotideElements.length;
+    if (!nucleotideElementCount) return Mesh.createEmpty(mesh);
+
+    const { sizeFactor, detail } = props;
+
+    const vertexCount = nucleotideElementCount * sphereVertexCount(detail);
+    const builderState = MeshBuilder.createState(vertexCount, vertexCount / 2, mesh);
+
+    const { elements, model } = unit;
+    const { chainAtomSegments, residueAtomSegments } = model.atomicHierarchy;
+    const { moleculeType } = model.atomicHierarchy.derived.residue;
+    const pos = unit.conformation.invariantPosition;
+
+    const chainIt = Segmentation.transientSegments(chainAtomSegments, elements);
+    const residueIt = Segmentation.transientSegments(residueAtomSegments, elements);
+
+    const radius = 1 * sizeFactor;
+
+    let i = 0;
+    while (chainIt.hasNext) {
+        residueIt.setSegment(chainIt.move());
+
+        while (residueIt.hasNext) {
+            const { index: residueIndex } = residueIt.move();
+
+            if (isNucleic(moleculeType[residueIndex])) {
+                const idx = createNucleicIndices();
+
+                builderState.currentGroup = i;
+
+                setSugarIndices(idx, unit, residueIndex);
+
+                if (hasSugarIndices(idx)) {
+                    pos(idx.C1_1, pC1_1); pos(idx.C2_1, pC2_1); pos(idx.C3_1, pC3_1); pos(idx.C4_1, pC4_1); pos(idx.O4_1, pO4_1);
+
+                    // trace cylinder
+                    pos(idx.trace, pTrace);
+                    addSphere(builderState, pTrace, radius, detail);
+
+                    // sugar ring
+                    addSphere(builderState, pC4_1, radius, detail);
+                    addSphere(builderState, pO4_1, radius, detail);
+                    addSphere(builderState, pC1_1, radius, detail);
+                    addSphere(builderState, pC2_1, radius, detail);
+                    addSphere(builderState, pC3_1, radius, detail);
+                }
+
+                const { isPurine, isPyrimidine } = getNucleotideBaseType(unit, residueIndex);
+
+                if (isPurine) {
+                    setPurinIndices(idx, unit, residueIndex);
+
+                    if (hasPurinIndices(idx)) {
+                        pos(idx.N1, pN1); pos(idx.C2, pC2); pos(idx.N3, pN3); pos(idx.C4, pC4); pos(idx.C5, pC5); pos(idx.C6, pC6); pos(idx.N7, pN7); pos(idx.C8, pC8); pos(idx.N9, pN9);
+
+                        // base ring
+                        addSphere(builderState, pC8, radius, detail);
+                        addSphere(builderState, pN7, radius, detail);
+                        addSphere(builderState, pC5, radius, detail);
+                        addSphere(builderState, pC6, radius, detail);
+                        addSphere(builderState, pN1, radius, detail);
+                        addSphere(builderState, pC2, radius, detail);
+                        addSphere(builderState, pN3, radius, detail);
+                        addSphere(builderState, pC4, radius, detail);
+                        addSphere(builderState, pC5, radius, detail);
+                        addSphere(builderState, pN9, radius, detail);
+                    }
+                } else if (isPyrimidine) {
+                    setPyrimidineIndices(idx, unit, residueIndex);
+
+                    if (hasPyrimidineIndices(idx)) {
+                        pos(idx.N1, pN1); pos(idx.C2, pC2); pos(idx.N3, pN3); pos(idx.C4, pC4); pos(idx.C5, pC5); pos(idx.C6, pC6);
+
+                        // base ring
+                        addSphere(builderState, pC6, radius, detail);
+                        addSphere(builderState, pC5, radius, detail);
+                        addSphere(builderState, pC4, radius, detail);
+                        addSphere(builderState, pN3, radius, detail);
+                        addSphere(builderState, pC2, radius, detail);
+                        addSphere(builderState, pN1, radius, detail);
+                    }
+                }
+
+                ++i;
+            }
+        }
+    }
+
+    const m = MeshBuilder.getMesh(builderState);
+
+    const sphere = Sphere3D.expand(Sphere3D(), unit.boundary.sphere, 1 * props.sizeFactor);
+    m.setBoundingSphere(sphere);
+
+    return m;
+}
+
+
+export function NucleotideAtomicElementMeshVisual(materialId: number): UnitsVisual<NucleotideAtomicElementParams> {
+    return UnitsMeshVisual<NucleotideAtomicElementParams>({
+        defaultProps: PD.getDefaultValues(NucleotideAtomicElementParams),
+        createGeometry: createNucleotideAtomicElementMesh,
+        createLocationIterator: NucleotideLocationIterator.fromGroup,
+        getLoci: getNucleotideElementLoci,
+        eachLocation: eachNucleotideElement,
+        setUpdateState: (state: VisualUpdateState, newProps: PD.Values<NucleotideAtomicElementParams>, currentProps: PD.Values<NucleotideAtomicElementParams>) => {
+            state.createGeometry = (
+                newProps.sizeFactor !== currentProps.sizeFactor ||
+                newProps.detail !== currentProps.detail
+            );
+        },
+        mustRecreate: (structureGroup: StructureGroup, props: PD.Values<NucleotideAtomicElementParams>, webgl?: WebGLContext) => {
+            return props.tryUseImpostor && !!webgl;
+        }
+    }, materialId);
+}

+ 195 - 0
src/mol-repr/structure/visual/nucleotide-atomic-ring-fill.ts

@@ -0,0 +1,195 @@
+/**
+ * Copyright (c) 2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Gianluca Tomasello <giagitom@gmail.com>
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { ParamDefinition as PD } from '../../../mol-util/param-definition';
+import { Vec3 } from '../../../mol-math/linear-algebra';
+import { NumberArray } from '../../../mol-util/type-helpers';
+import { VisualContext } from '../../visual';
+import { Unit, Structure } from '../../../mol-model/structure';
+import { Theme } from '../../../mol-theme/theme';
+import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
+import { MeshBuilder } from '../../../mol-geo/geometry/mesh/mesh-builder';
+import { Segmentation } from '../../../mol-data/int';
+import { isNucleic } from '../../../mol-model/structure/model/types';
+import { UnitsMeshParams, UnitsVisual, UnitsMeshVisual } from '../units-visual';
+import { NucleotideLocationIterator, getNucleotideElementLoci, eachNucleotideElement, getNucleotideBaseType, createNucleicIndices, setSugarIndices, hasSugarIndices, setPurinIndices, hasPyrimidineIndices, setPyrimidineIndices, hasPurinIndices } from './util/nucleotide';
+import { VisualUpdateState } from '../../util';
+import { Sphere3D } from '../../../mol-math/geometry';
+
+// TODO support ring-fills for multiple locations (including from microheterogeneity)
+
+const pN1 = Vec3();
+const pC2 = Vec3();
+const pN3 = Vec3();
+const pC4 = Vec3();
+const pC5 = Vec3();
+const pC6 = Vec3();
+const pN7 = Vec3();
+const pC8 = Vec3();
+const pN9 = Vec3();
+
+const pC1_1 = Vec3();
+const pC2_1 = Vec3();
+const pC3_1 = Vec3();
+const pC4_1 = Vec3();
+const pO4_1 = Vec3();
+
+const mid = Vec3();
+const normal = Vec3();
+const shift = Vec3();
+
+export const NucleotideAtomicRingFillMeshParams = {
+    sizeFactor: PD.Numeric(0.2, { min: 0, max: 10, step: 0.01 }),
+    thicknessFactor: PD.Numeric(1, { min: 0, max: 2, step: 0.01 }),
+};
+export const DefaultNucleotideAtomicRingFillMeshProps = PD.getDefaultValues(NucleotideAtomicRingFillMeshParams);
+export type NucleotideAtomicRingFillProps = typeof DefaultNucleotideAtomicRingFillMeshProps
+
+const positionsRing5_6 = new Float32Array(2 * 9 * 3);
+const stripIndicesRing5_6 = new Uint32Array([0, 1, 2, 3, 4, 5, 6, 7, 16, 17, 14, 15, 12, 13, 8, 9, 10, 11, 0, 1]);
+const fanIndicesTopRing5_6 = new Uint32Array([8, 12, 14, 16, 6, 4, 2, 0, 10]);
+const fanIndicesBottomRing5_6 = new Uint32Array([9, 11, 1, 3, 5, 7, 17, 15, 13]);
+
+const positionsRing5 = new Float32Array(2 * 6 * 3);
+const stripIndicesRing5 = new Uint32Array([2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 2, 3]);
+const fanIndicesTopRing5 = new Uint32Array([0, 10, 8, 6, 4, 2, 10]);
+const fanIndicesBottomRing5 = new Uint32Array([1, 3, 5, 7, 9, 11, 3]);
+
+const positionsRing6 = new Float32Array(2 * 6 * 3);
+const stripIndicesRing6 = new Uint32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1]);
+const fanIndicesTopRing6 = new Uint32Array([0, 10, 8, 6, 4, 2]);
+const fanIndicesBottomRing6 = new Uint32Array([1, 3, 5, 7, 9, 11]);
+
+const tmpShiftV = Vec3();
+function shiftPositions(out: NumberArray, dir: Vec3, ...positions: Vec3[]) {
+    for (let i = 0, il = positions.length; i < il; ++i) {
+        const v = positions[i];
+        Vec3.toArray(Vec3.add(tmpShiftV, v, dir), out, (i * 2) * 3);
+        Vec3.toArray(Vec3.sub(tmpShiftV, v, dir), out, (i * 2 + 1) * 3);
+    }
+}
+
+function createNucleotideAtomicRingFillMesh(ctx: VisualContext, unit: Unit, structure: Structure, theme: Theme, props: NucleotideAtomicRingFillProps, mesh?: Mesh) {
+    if (!Unit.isAtomic(unit)) return Mesh.createEmpty(mesh);
+
+    const nucleotideElementCount = unit.nucleotideElements.length;
+    if (!nucleotideElementCount) return Mesh.createEmpty(mesh);
+
+    const { sizeFactor, thicknessFactor } = props;
+
+    const vertexCount = nucleotideElementCount * 25;
+    const builderState = MeshBuilder.createState(vertexCount, vertexCount / 4, mesh);
+
+    const { elements, model } = unit;
+    const { chainAtomSegments, residueAtomSegments } = model.atomicHierarchy;
+    const { moleculeType } = model.atomicHierarchy.derived.residue;
+    const pos = unit.conformation.invariantPosition;
+
+    const chainIt = Segmentation.transientSegments(chainAtomSegments, elements);
+    const residueIt = Segmentation.transientSegments(residueAtomSegments, elements);
+
+    const thickness = sizeFactor * thicknessFactor;
+
+    let i = 0;
+    while (chainIt.hasNext) {
+        residueIt.setSegment(chainIt.move());
+
+        while (residueIt.hasNext) {
+            const { index: residueIndex } = residueIt.move();
+
+            if (isNucleic(moleculeType[residueIndex])) {
+                const idx = createNucleicIndices();
+
+                builderState.currentGroup = i;
+
+                setSugarIndices(idx, unit, residueIndex);
+                if (hasSugarIndices(idx)) {
+                    pos(idx.C1_1, pC1_1); pos(idx.C2_1, pC2_1); pos(idx.C3_1, pC3_1); pos(idx.C4_1, pC4_1); pos(idx.O4_1, pO4_1);
+
+                    // sugar ring
+                    Vec3.triangleNormal(normal, pC3_1, pC4_1, pC1_1);
+                    Vec3.scale(mid, Vec3.add(mid, pO4_1, Vec3.add(mid, pC4_1, Vec3.add(mid, pC3_1, Vec3.add(mid, pC1_1, pC2_1)))), 0.2 /* 1 / 5 */);
+
+                    Vec3.scale(shift, normal, thickness);
+                    shiftPositions(positionsRing5, shift, mid, pC3_1, pC4_1, pO4_1, pC1_1, pC2_1);
+
+                    MeshBuilder.addTriangleStrip(builderState, positionsRing5, stripIndicesRing5);
+                    MeshBuilder.addTriangleFanWithNormal(builderState, positionsRing5, fanIndicesTopRing5, normal);
+                    Vec3.negate(normal, normal);
+                    MeshBuilder.addTriangleFanWithNormal(builderState, positionsRing5, fanIndicesBottomRing5, normal);
+                }
+
+                const { isPurine, isPyrimidine } = getNucleotideBaseType(unit, residueIndex);
+
+                if (isPurine) {
+                    setPurinIndices(idx, unit, residueIndex);
+
+                    if (hasPurinIndices(idx)) {
+                        pos(idx.N1, pN1); pos(idx.C2, pC2); pos(idx.N3, pN3); pos(idx.C4, pC4); pos(idx.C5, pC5); pos(idx.C6, pC6); pos(idx.N7, pN7); pos(idx.C8, pC8), pos(idx.N9, pN9);
+
+                        // base ring
+                        Vec3.triangleNormal(normal, pN1, pC4, pC5);
+                        Vec3.scale(shift, normal, thickness);
+                        shiftPositions(positionsRing5_6, shift, pN1, pC2, pN3, pC4, pC5, pC6, pN7, pC8, pN9);
+
+                        MeshBuilder.addTriangleStrip(builderState, positionsRing5_6, stripIndicesRing5_6);
+                        MeshBuilder.addTriangleFanWithNormal(builderState, positionsRing5_6, fanIndicesTopRing5_6, normal);
+                        Vec3.negate(normal, normal);
+                        MeshBuilder.addTriangleFanWithNormal(builderState, positionsRing5_6, fanIndicesBottomRing5_6, normal);
+                    }
+                } else if (isPyrimidine) {
+                    setPyrimidineIndices(idx, unit, residueIndex);
+
+                    if (hasPyrimidineIndices(idx)) {
+                        pos(idx.N1, pN1); pos(idx.C2, pC2); pos(idx.N3, pN3); pos(idx.C4, pC4); pos(idx.C5, pC5); pos(idx.C6, pC6);
+
+                        // base ring
+                        Vec3.triangleNormal(normal, pN1, pC4, pC5);
+                        Vec3.scale(shift, normal, thickness);
+                        shiftPositions(positionsRing6, shift, pN1, pC2, pN3, pC4, pC5, pC6);
+
+                        MeshBuilder.addTriangleStrip(builderState, positionsRing6, stripIndicesRing6);
+                        MeshBuilder.addTriangleFanWithNormal(builderState, positionsRing6, fanIndicesTopRing6, normal);
+                        Vec3.negate(normal, normal);
+                        MeshBuilder.addTriangleFanWithNormal(builderState, positionsRing6, fanIndicesBottomRing6, normal);
+                    }
+                }
+
+                ++i;
+            }
+        }
+    }
+
+    const m = MeshBuilder.getMesh(builderState);
+
+    const sphere = Sphere3D.expand(Sphere3D(), unit.boundary.sphere, thickness);
+    m.setBoundingSphere(sphere);
+
+    return m;
+}
+
+export const NucleotideAtomicRingFillParams = {
+    ...UnitsMeshParams,
+    ...NucleotideAtomicRingFillMeshParams
+};
+export type NucleotideAtomicRingFillParams = typeof NucleotideAtomicRingFillParams
+
+export function NucleotideAtomicRingFillVisual(materialId: number): UnitsVisual<NucleotideAtomicRingFillParams> {
+    return UnitsMeshVisual<NucleotideAtomicRingFillParams>({
+        defaultProps: PD.getDefaultValues(NucleotideAtomicRingFillParams),
+        createGeometry: createNucleotideAtomicRingFillMesh,
+        createLocationIterator: NucleotideLocationIterator.fromGroup,
+        getLoci: getNucleotideElementLoci,
+        eachLocation: eachNucleotideElement,
+        setUpdateState: (state: VisualUpdateState, newProps: PD.Values<NucleotideAtomicRingFillParams>, currentProps: PD.Values<NucleotideAtomicRingFillParams>) => {
+            state.createGeometry = (
+                newProps.sizeFactor !== currentProps.sizeFactor ||
+                newProps.thicknessFactor !== currentProps.thicknessFactor
+            );
+        }
+    }, materialId);
+}

+ 26 - 45
src/mol-repr/structure/visual/nucleotide-block-mesh.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -14,10 +14,10 @@ import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
 import { MeshBuilder } from '../../../mol-geo/geometry/mesh/mesh-builder';
 import { Segmentation } from '../../../mol-data/int';
 import { CylinderProps } from '../../../mol-geo/primitive/cylinder';
-import { isNucleic, isPurineBase, isPyrimidineBase } from '../../../mol-model/structure/model/types';
+import { isNucleic } from '../../../mol-model/structure/model/types';
 import { addCylinder } from '../../../mol-geo/geometry/mesh/builder/cylinder';
 import { UnitsMeshParams, UnitsVisual, UnitsMeshVisual } from '../units-visual';
-import { NucleotideLocationIterator, getNucleotideElementLoci, eachNucleotideElement } from './util/nucleotide';
+import { NucleotideLocationIterator, getNucleotideElementLoci, eachNucleotideElement, getNucleotideBaseType, createNucleicIndices, setPurinIndices, setPyrimidineIndices } from './util/nucleotide';
 import { VisualUpdateState } from '../../util';
 import { BaseGeometry } from '../../../mol-geo/geometry/base';
 import { Sphere3D } from '../../../mol-math/geometry';
@@ -29,7 +29,7 @@ const p2 = Vec3();
 const p3 = Vec3();
 const p4 = Vec3();
 const p5 = Vec3();
-const p6 = Vec3();
+const pt = Vec3();
 const v12 = Vec3();
 const v34 = Vec3();
 const vC = Vec3();
@@ -40,6 +40,7 @@ const box = Box();
 
 export const NucleotideBlockMeshParams = {
     sizeFactor: PD.Numeric(0.2, { min: 0, max: 10, step: 0.01 }),
+    thicknessFactor: PD.Numeric(1, { min: 0, max: 2, step: 0.01 }),
     radialSegments: PD.Numeric(16, { min: 2, max: 56, step: 2 }, BaseGeometry.CustomQualityParamInfo),
 };
 export const DefaultNucleotideBlockMeshProps = PD.getDefaultValues(NucleotideBlockMeshParams);
@@ -51,21 +52,24 @@ function createNucleotideBlockMesh(ctx: VisualContext, unit: Unit, structure: St
     const nucleotideElementCount = unit.nucleotideElements.length;
     if (!nucleotideElementCount) return Mesh.createEmpty(mesh);
 
-    const { sizeFactor, radialSegments } = props;
+    const { sizeFactor, thicknessFactor, radialSegments } = props;
 
     const vertexCount = nucleotideElementCount * (box.vertices.length / 3 + radialSegments * 2);
     const builderState = MeshBuilder.createState(vertexCount, vertexCount / 4, mesh);
 
     const { elements, model } = unit;
-    const { chainAtomSegments, residueAtomSegments, atoms, index: atomicIndex } = model.atomicHierarchy;
-    const { moleculeType, traceElementIndex } = model.atomicHierarchy.derived.residue;
-    const { label_comp_id } = atoms;
+    const { chainAtomSegments, residueAtomSegments } = model.atomicHierarchy;
+    const { moleculeType } = model.atomicHierarchy.derived.residue;
     const pos = unit.conformation.invariantPosition;
 
     const chainIt = Segmentation.transientSegments(chainAtomSegments, elements);
     const residueIt = Segmentation.transientSegments(residueAtomSegments, elements);
 
-    const cylinderProps: CylinderProps = { radiusTop: 1 * sizeFactor, radiusBottom: 1 * sizeFactor, radialSegments, bottomCap: true };
+    const radius = 1 * sizeFactor;
+    const width = 4.5;
+    const depth = thicknessFactor * sizeFactor * 2;
+
+    const cylinderProps: CylinderProps = { radiusTop: radius, radiusBottom: radius, radialSegments, bottomCap: true };
 
     let i = 0;
     while (chainIt.hasNext) {
@@ -75,51 +79,27 @@ function createNucleotideBlockMesh(ctx: VisualContext, unit: Unit, structure: St
             const { index: residueIndex } = residueIt.move();
 
             if (isNucleic(moleculeType[residueIndex])) {
-                const compId = label_comp_id.value(residueAtomSegments.offsets[residueIndex]);
-                let idx1: ElementIndex | -1 = -1, idx2: ElementIndex | -1 = -1, idx3: ElementIndex | -1 = -1, idx4: ElementIndex | -1 = -1, idx5: ElementIndex | -1 = -1, idx6: ElementIndex | -1 = -1;
-                const width = 4.5, depth = 2.5 * sizeFactor;
+                const idx = createNucleicIndices();
+                let idx1: ElementIndex | -1 = -1, idx2: ElementIndex | -1 = -1, idx3: ElementIndex | -1 = -1, idx4: ElementIndex | -1 = -1, idx5: ElementIndex | -1 = -1;
+
                 let height = 4.5;
 
-                let isPurine = isPurineBase(compId);
-                let isPyrimidine = isPyrimidineBase(compId);
-
-                if (!isPurine && !isPyrimidine) {
-                    // detect Purine or Pyrimidin based on geometry
-                    const idxC4 = atomicIndex.findAtomOnResidue(residueIndex, 'C4');
-                    const idxN9 = atomicIndex.findAtomOnResidue(residueIndex, 'N9');
-                    if (idxC4 !== -1 && idxN9 !== -1 && Vec3.distance(pos(idxC4, p1), pos(idxN9, p2)) < 1.6) {
-                        isPurine = true;
-                    } else {
-                        isPyrimidine = true;
-                    }
-                }
+                const { isPurine, isPyrimidine } = getNucleotideBaseType(unit, residueIndex);
 
                 if (isPurine) {
                     height = 4.5;
-                    idx1 = atomicIndex.findAtomOnResidue(residueIndex, 'N1');
-                    idx2 = atomicIndex.findAtomOnResidue(residueIndex, 'C4');
-                    idx3 = atomicIndex.findAtomOnResidue(residueIndex, 'C6');
-                    idx4 = atomicIndex.findAtomOnResidue(residueIndex, 'C2');
-                    idx5 = atomicIndex.findAtomOnResidue(residueIndex, 'N9');
-                    idx6 = traceElementIndex[residueIndex];
+                    setPurinIndices(idx, unit, residueIndex);
+                    idx1 = idx.N1; idx2 = idx.C4; idx3 = idx.C6; idx4 = idx.C2; idx5 = idx.N9;
                 } else if (isPyrimidine) {
                     height = 3.0;
-                    idx1 = atomicIndex.findAtomOnResidue(residueIndex, 'N3');
-                    idx2 = atomicIndex.findAtomOnResidue(residueIndex, 'C6');
-                    idx3 = atomicIndex.findAtomOnResidue(residueIndex, 'C4');
-                    idx4 = atomicIndex.findAtomOnResidue(residueIndex, 'C2');
-                    idx5 = atomicIndex.findAtomOnResidue(residueIndex, 'N1');
-                    if (idx5 === -1) {
-                        // modified ring, e.g. DZ
-                        idx5 = atomicIndex.findAtomOnResidue(residueIndex, 'C1');
-                    }
-                    idx6 = traceElementIndex[residueIndex];
+                    setPyrimidineIndices(idx, unit, residueIndex);
+                    idx1 = idx.N3; idx2 = idx.C6; idx3 = idx.C4; idx4 = idx.C2; idx5 = idx.N1;
                 }
 
-                if (idx5 !== -1 && idx6 !== -1) {
-                    pos(idx5, p5); pos(idx6, p6);
+                if (idx5 !== -1 && idx.trace !== -1) {
+                    pos(idx5, p5); pos(idx.trace, pt);
                     builderState.currentGroup = i;
-                    addCylinder(builderState, p5, p6, 1, cylinderProps);
+                    addCylinder(builderState, p5, pt, 1, cylinderProps);
                     if (idx1 !== -1 && idx2 !== -1 && idx3 !== -1 && idx4 !== -1) {
                         pos(idx1, p1); pos(idx2, p2); pos(idx3, p3); pos(idx4, p4);
                         Vec3.normalize(v12, Vec3.sub(v12, p2, p1));
@@ -140,7 +120,7 @@ function createNucleotideBlockMesh(ctx: VisualContext, unit: Unit, structure: St
 
     const m = MeshBuilder.getMesh(builderState);
 
-    const sphere = Sphere3D.expand(Sphere3D(), unit.boundary.sphere, 1 * props.sizeFactor);
+    const sphere = Sphere3D.expand(Sphere3D(), unit.boundary.sphere, radius);
     m.setBoundingSphere(sphere);
 
     return m;
@@ -162,6 +142,7 @@ export function NucleotideBlockVisual(materialId: number): UnitsVisual<Nucleotid
         setUpdateState: (state: VisualUpdateState, newProps: PD.Values<NucleotideBlockParams>, currentProps: PD.Values<NucleotideBlockParams>) => {
             state.createGeometry = (
                 newProps.sizeFactor !== currentProps.sizeFactor ||
+                newProps.thicknessFactor !== currentProps.thicknessFactor ||
                 newProps.radialSegments !== currentProps.radialSegments
             );
         }

+ 39 - 79
src/mol-repr/structure/visual/nucleotide-ring-mesh.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -8,37 +8,38 @@ import { ParamDefinition as PD } from '../../../mol-util/param-definition';
 import { Vec3 } from '../../../mol-math/linear-algebra';
 import { NumberArray } from '../../../mol-util/type-helpers';
 import { VisualContext } from '../../visual';
-import { Unit, Structure, ElementIndex } from '../../../mol-model/structure';
+import { Unit, Structure } from '../../../mol-model/structure';
 import { Theme } from '../../../mol-theme/theme';
 import { Mesh } from '../../../mol-geo/geometry/mesh/mesh';
 import { MeshBuilder } from '../../../mol-geo/geometry/mesh/mesh-builder';
 import { Segmentation } from '../../../mol-data/int';
 import { CylinderProps } from '../../../mol-geo/primitive/cylinder';
-import { isNucleic, isPurineBase, isPyrimidineBase } from '../../../mol-model/structure/model/types';
+import { isNucleic } from '../../../mol-model/structure/model/types';
 import { addCylinder } from '../../../mol-geo/geometry/mesh/builder/cylinder';
 import { addSphere } from '../../../mol-geo/geometry/mesh/builder/sphere';
 import { UnitsMeshParams, UnitsVisual, UnitsMeshVisual } from '../units-visual';
-import { NucleotideLocationIterator, getNucleotideElementLoci, eachNucleotideElement } from './util/nucleotide';
+import { NucleotideLocationIterator, getNucleotideElementLoci, eachNucleotideElement, getNucleotideBaseType, createNucleicIndices, setPurinIndices, setPyrimidineIndices, hasPyrimidineIndices, hasPurinIndices } from './util/nucleotide';
 import { VisualUpdateState } from '../../util';
 import { BaseGeometry } from '../../../mol-geo/geometry/base';
 import { Sphere3D } from '../../../mol-math/geometry';
 
 // TODO support rings for multiple locations (including from microheterogeneity)
 
-const pTrace = Vec3.zero();
-const pN1 = Vec3.zero();
-const pC2 = Vec3.zero();
-const pN3 = Vec3.zero();
-const pC4 = Vec3.zero();
-const pC5 = Vec3.zero();
-const pC6 = Vec3.zero();
-const pN7 = Vec3.zero();
-const pC8 = Vec3.zero();
-const pN9 = Vec3.zero();
-const normal = Vec3.zero();
+const pTrace = Vec3();
+const pN1 = Vec3();
+const pC2 = Vec3();
+const pN3 = Vec3();
+const pC4 = Vec3();
+const pC5 = Vec3();
+const pC6 = Vec3();
+const pN7 = Vec3();
+const pC8 = Vec3();
+const pN9 = Vec3();
+const normal = Vec3();
 
 export const NucleotideRingMeshParams = {
     sizeFactor: PD.Numeric(0.2, { min: 0, max: 10, step: 0.01 }),
+    thicknessFactor: PD.Numeric(1, { min: 0, max: 2, step: 0.01 }),
     radialSegments: PD.Numeric(16, { min: 2, max: 56, step: 2 }, BaseGeometry.CustomQualityParamInfo),
     detail: PD.Numeric(0, { min: 0, max: 3, step: 1 }, BaseGeometry.CustomQualityParamInfo),
 };
@@ -55,7 +56,7 @@ const stripIndicesRing6 = new Uint32Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
 const fanIndicesTopRing6 = new Uint32Array([0, 10, 8, 6, 4, 2]);
 const fanIndicesBottomRing6 = new Uint32Array([1, 3, 5, 7, 9, 11]);
 
-const tmpShiftV = Vec3.zero();
+const tmpShiftV = Vec3();
 function shiftPositions(out: NumberArray, dir: Vec3, ...positions: Vec3[]) {
     for (let i = 0, il = positions.length; i < il; ++i) {
         const v = positions[i];
@@ -70,23 +71,22 @@ function createNucleotideRingMesh(ctx: VisualContext, unit: Unit, structure: Str
     const nucleotideElementCount = unit.nucleotideElements.length;
     if (!nucleotideElementCount) return Mesh.createEmpty(mesh);
 
-    const { sizeFactor, radialSegments, detail } = props;
+    const { sizeFactor, thicknessFactor, radialSegments, detail } = props;
 
     const vertexCount = nucleotideElementCount * (26 + radialSegments * 2);
     const builderState = MeshBuilder.createState(vertexCount, vertexCount / 4, mesh);
 
     const { elements, model } = unit;
-    const { chainAtomSegments, residueAtomSegments, atoms, index: atomicIndex } = model.atomicHierarchy;
-    const { moleculeType, traceElementIndex } = model.atomicHierarchy.derived.residue;
-    const { label_comp_id } = atoms;
+    const { chainAtomSegments, residueAtomSegments } = model.atomicHierarchy;
+    const { moleculeType } = model.atomicHierarchy.derived.residue;
     const pos = unit.conformation.invariantPosition;
 
     const chainIt = Segmentation.transientSegments(chainAtomSegments, elements);
     const residueIt = Segmentation.transientSegments(residueAtomSegments, elements);
 
     const radius = 1 * sizeFactor;
-    const halfThickness = 1.25 * sizeFactor;
-    const cylinderProps: CylinderProps = { radiusTop: 1 * sizeFactor, radiusBottom: 1 * sizeFactor, radialSegments };
+    const thickness = thicknessFactor * sizeFactor;
+    const cylinderProps: CylinderProps = { radiusTop: radius, radiusBottom: radius, radialSegments };
 
     let i = 0;
     while (chainIt.hasNext) {
@@ -96,58 +96,27 @@ function createNucleotideRingMesh(ctx: VisualContext, unit: Unit, structure: Str
             const { index: residueIndex } = residueIt.move();
 
             if (isNucleic(moleculeType[residueIndex])) {
-                const compId = label_comp_id.value(residueAtomSegments.offsets[residueIndex]);
-
-                let idxTrace: ElementIndex | -1 = -1, idxN1: ElementIndex | -1 = -1, idxC2: ElementIndex | -1 = -1, idxN3: ElementIndex | -1 = -1, idxC4: ElementIndex | -1 = -1, idxC5: ElementIndex | -1 = -1, idxC6: ElementIndex | -1 = -1, idxN7: ElementIndex | -1 = -1, idxC8: ElementIndex | -1 = -1, idxN9: ElementIndex | -1 = -1;
+                const idx = createNucleicIndices();
 
                 builderState.currentGroup = i;
 
-                let isPurine = isPurineBase(compId);
-                let isPyrimidine = isPyrimidineBase(compId);
-
-                if (!isPurine && !isPyrimidine) {
-                    // detect Purine or Pyrimidin based on geometry
-                    const idxC4 = atomicIndex.findAtomOnResidue(residueIndex, 'C4');
-                    const idxN9 = atomicIndex.findAtomOnResidue(residueIndex, 'N9');
-                    if (idxC4 !== -1 && idxN9 !== -1 && Vec3.distance(pos(idxC4, pC4), pos(idxN9, pN9)) < 1.6) {
-                        isPurine = true;
-                    } else {
-                        isPyrimidine = true;
-                    }
-                }
+                const { isPurine, isPyrimidine } = getNucleotideBaseType(unit, residueIndex);
 
                 if (isPurine) {
-                    idxTrace = traceElementIndex[residueIndex];
-                    idxN1 = atomicIndex.findAtomOnResidue(residueIndex, 'N1');
-                    idxC2 = atomicIndex.findAtomOnResidue(residueIndex, 'C2');
-                    idxN3 = atomicIndex.findAtomOnResidue(residueIndex, 'N3');
-                    idxC4 = atomicIndex.findAtomOnResidue(residueIndex, 'C4');
-                    idxC5 = atomicIndex.findAtomOnResidue(residueIndex, 'C5');
-                    if (idxC5 === -1) {
-                        // modified ring, e.g. DP
-                        idxC5 = atomicIndex.findAtomOnResidue(residueIndex, 'N5');
-                    }
-                    idxC6 = atomicIndex.findAtomOnResidue(residueIndex, 'C6');
-                    idxN7 = atomicIndex.findAtomOnResidue(residueIndex, 'N7');
-                    if (idxN7 === -1) {
-                        // modified ring, e.g. DP
-                        idxN7 = atomicIndex.findAtomOnResidue(residueIndex, 'C7');
-                    }
-                    idxC8 = atomicIndex.findAtomOnResidue(residueIndex, 'C8');
-                    idxN9 = atomicIndex.findAtomOnResidue(residueIndex, 'N9');
+                    setPurinIndices(idx, unit, residueIndex);
 
-                    if (idxN9 !== -1 && idxTrace !== -1) {
-                        pos(idxN9, pN9); pos(idxTrace, pTrace);
+                    if (idx.N9 !== -1 && idx.trace !== -1) {
+                        pos(idx.N9, pN9); pos(idx.trace, pTrace);
                         builderState.currentGroup = i;
                         addCylinder(builderState, pN9, pTrace, 1, cylinderProps);
                         addSphere(builderState, pN9, radius, detail);
                     }
 
-                    if (idxN1 !== -1 && idxC2 !== -1 && idxN3 !== -1 && idxC4 !== -1 && idxC5 !== -1 && idxC6 !== -1 && idxN7 !== -1 && idxC8 !== -1 && idxN9 !== -1) {
-                        pos(idxN1, pN1); pos(idxC2, pC2); pos(idxN3, pN3); pos(idxC4, pC4); pos(idxC5, pC5); pos(idxC6, pC6); pos(idxN7, pN7); pos(idxC8, pC8);
+                    if (hasPurinIndices(idx)) {
+                        pos(idx.N1, pN1); pos(idx.C2, pC2); pos(idx.N3, pN3); pos(idx.C4, pC4); pos(idx.C5, pC5); pos(idx.C6, pC6); pos(idx.N7, pN7); pos(idx.C8, pC8);
 
                         Vec3.triangleNormal(normal, pN1, pC4, pC5);
-                        Vec3.scale(normal, normal, halfThickness);
+                        Vec3.scale(normal, normal, thickness);
                         shiftPositions(positionsRing5_6, normal, pN1, pC2, pN3, pC4, pC5, pC6, pN7, pC8, pN9);
 
                         MeshBuilder.addTriangleStrip(builderState, positionsRing5_6, stripIndicesRing5_6);
@@ -155,30 +124,20 @@ function createNucleotideRingMesh(ctx: VisualContext, unit: Unit, structure: Str
                         MeshBuilder.addTriangleFan(builderState, positionsRing5_6, fanIndicesBottomRing5_6);
                     }
                 } else if (isPyrimidine) {
-                    idxTrace = traceElementIndex[residueIndex];
-                    idxN1 = atomicIndex.findAtomOnResidue(residueIndex, 'N1');
-                    if (idxN1 === -1) {
-                        // modified ring, e.g. DZ
-                        idxN1 = atomicIndex.findAtomOnResidue(residueIndex, 'C1');
-                    }
-                    idxC2 = atomicIndex.findAtomOnResidue(residueIndex, 'C2');
-                    idxN3 = atomicIndex.findAtomOnResidue(residueIndex, 'N3');
-                    idxC4 = atomicIndex.findAtomOnResidue(residueIndex, 'C4');
-                    idxC5 = atomicIndex.findAtomOnResidue(residueIndex, 'C5');
-                    idxC6 = atomicIndex.findAtomOnResidue(residueIndex, 'C6');
-
-                    if (idxN1 !== -1 && idxTrace !== -1) {
-                        pos(idxN1, pN1); pos(idxTrace, pTrace);
+                    setPyrimidineIndices(idx, unit, residueIndex);
+
+                    if (idx.N1 !== -1 && idx.trace !== -1) {
+                        pos(idx.N1, pN1); pos(idx.trace, pTrace);
                         builderState.currentGroup = i;
                         addCylinder(builderState, pN1, pTrace, 1, cylinderProps);
                         addSphere(builderState, pN1, radius, detail);
                     }
 
-                    if (idxN1 !== -1 && idxC2 !== -1 && idxN3 !== -1 && idxC4 !== -1 && idxC5 !== -1 && idxC6 !== -1) {
-                        pos(idxC2, pC2); pos(idxN3, pN3); pos(idxC4, pC4); pos(idxC5, pC5); pos(idxC6, pC6);
+                    if (hasPyrimidineIndices(idx)) {
+                        pos(idx.C2, pC2); pos(idx.N3, pN3); pos(idx.C4, pC4); pos(idx.C5, pC5); pos(idx.C6, pC6);
 
                         Vec3.triangleNormal(normal, pN1, pC4, pC5);
-                        Vec3.scale(normal, normal, halfThickness);
+                        Vec3.scale(normal, normal, thickness);
                         shiftPositions(positionsRing6, normal, pN1, pC2, pN3, pC4, pC5, pC6);
 
                         MeshBuilder.addTriangleStrip(builderState, positionsRing6, stripIndicesRing6);
@@ -194,7 +153,7 @@ function createNucleotideRingMesh(ctx: VisualContext, unit: Unit, structure: Str
 
     const m = MeshBuilder.getMesh(builderState);
 
-    const sphere = Sphere3D.expand(Sphere3D(), unit.boundary.sphere, 1 * props.sizeFactor);
+    const sphere = Sphere3D.expand(Sphere3D(), unit.boundary.sphere, radius);
     m.setBoundingSphere(sphere);
 
     return m;
@@ -216,6 +175,7 @@ export function NucleotideRingVisual(materialId: number): UnitsVisual<Nucleotide
         setUpdateState: (state: VisualUpdateState, newProps: PD.Values<NucleotideRingParams>, currentProps: PD.Values<NucleotideRingParams>) => {
             state.createGeometry = (
                 newProps.sizeFactor !== currentProps.sizeFactor ||
+                newProps.thicknessFactor !== currentProps.thicknessFactor ||
                 newProps.radialSegments !== currentProps.radialSegments
             );
         }

+ 127 - 3
src/mol-repr/structure/visual/util/nucleotide.ts

@@ -1,16 +1,18 @@
 /**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
-import { Unit, StructureElement, Structure } from '../../../../mol-model/structure';
+import { Unit, StructureElement, Structure, ResidueIndex, ElementIndex } from '../../../../mol-model/structure';
 import { Loci, EmptyLoci } from '../../../../mol-model/loci';
 import { Interval } from '../../../../mol-data/int';
 import { LocationIterator } from '../../../../mol-geo/util/location-iterator';
 import { PickingId } from '../../../../mol-geo/geometry/picking';
 import { getResidueLoci, StructureGroup } from './common';
 import { eachAtomicUnitTracedElement } from './polymer';
+import { isPurineBase, isPyrimidineBase } from '../../../../mol-model/structure/model/types';
+import { Vec3 } from '../../../../mol-math/linear-algebra/3d/vec3';
 
 export namespace NucleotideLocationIterator {
     export function fromGroup(structureGroup: StructureGroup): LocationIterator {
@@ -68,4 +70,126 @@ export function eachNucleotideElement(loci: Loci, structureGroup: StructureGroup
         }
     }
     return changed;
-}
+}
+
+//
+
+const pC4 = Vec3();
+const pN9 = Vec3();
+
+export function getNucleotideBaseType(unit: Unit.Atomic, residueIndex: ResidueIndex) {
+    const { model } = unit;
+    const { residueAtomSegments, atoms, index: atomicIndex } = model.atomicHierarchy;
+    const { label_comp_id } = atoms;
+    const pos = unit.conformation.invariantPosition;
+
+    const compId = label_comp_id.value(residueAtomSegments.offsets[residueIndex]);
+
+    let isPurine = isPurineBase(compId);
+    let isPyrimidine = isPyrimidineBase(compId);
+
+    if (!isPurine && !isPyrimidine) {
+        // detect Purine or Pyrimidin based on geometry
+        const idxC4 = atomicIndex.findAtomOnResidue(residueIndex, 'C4');
+        const idxN9 = atomicIndex.findAtomOnResidue(residueIndex, 'N9');
+        if (idxC4 !== -1 && idxN9 !== -1 && Vec3.distance(pos(idxC4, pC4), pos(idxN9, pN9)) < 1.6) {
+            isPurine = true;
+        } else {
+            isPyrimidine = true;
+        }
+    }
+
+    return { isPurine, isPyrimidine };
+}
+
+export function createNucleicIndices() {
+    return {
+        trace: -1 as ElementIndex | -1,
+        N1: -1 as ElementIndex | -1,
+        C2: -1 as ElementIndex | -1,
+        N3: -1 as ElementIndex | -1,
+        C4: -1 as ElementIndex | -1,
+        C5: -1 as ElementIndex | -1,
+        C6: -1 as ElementIndex | -1,
+        N7: -1 as ElementIndex | -1,
+        C8: -1 as ElementIndex | -1,
+        N9: -1 as ElementIndex | -1,
+        C1_1: -1 as ElementIndex | -1,
+        C2_1: -1 as ElementIndex | -1,
+        C3_1: -1 as ElementIndex | -1,
+        C4_1: -1 as ElementIndex | -1,
+        O4_1: -1 as ElementIndex | -1,
+    };
+}
+export type NucleicIndices = ReturnType<typeof createNucleicIndices>
+
+export function setPurinIndices(idx: NucleicIndices, unit: Unit.Atomic, residueIndex: ResidueIndex) {
+    const atomicIndex = unit.model.atomicHierarchy.index;
+    const { traceElementIndex } = unit.model.atomicHierarchy.derived.residue;
+
+    idx.trace = traceElementIndex[residueIndex];
+    idx.N1 = atomicIndex.findAtomOnResidue(residueIndex, 'N1');
+    idx.C2 = atomicIndex.findAtomOnResidue(residueIndex, 'C2');
+    idx.N3 = atomicIndex.findAtomOnResidue(residueIndex, 'N3');
+    idx.C4 = atomicIndex.findAtomOnResidue(residueIndex, 'C4');
+    idx.C5 = atomicIndex.findAtomOnResidue(residueIndex, 'C5');
+    if (idx.C5 === -1) {
+        // modified ring, e.g. DP
+        idx.C5 = atomicIndex.findAtomOnResidue(residueIndex, 'N5');
+    }
+    idx.C6 = atomicIndex.findAtomOnResidue(residueIndex, 'C6');
+    idx.N7 = atomicIndex.findAtomOnResidue(residueIndex, 'N7');
+    if (idx.N7 === -1) {
+        // modified ring, e.g. DP
+        idx.N7 = atomicIndex.findAtomOnResidue(residueIndex, 'C7');
+    }
+    idx.C8 = atomicIndex.findAtomOnResidue(residueIndex, 'C8');
+    idx.N9 = atomicIndex.findAtomOnResidue(residueIndex, 'N9');
+
+    return idx;
+}
+
+export function hasPurinIndices(idx: NucleicIndices): idx is NucleicIndices & { trace: ElementIndex, N1: ElementIndex, C2: ElementIndex, N3: ElementIndex, C4: ElementIndex, C5: ElementIndex, C6: ElementIndex, N7: ElementIndex, C8: ElementIndex, N9: ElementIndex } {
+    return idx.trace !== -1 && idx.N1 !== -1 && idx.C2 !== -1 && idx.N3 !== -1 && idx.C4 !== -1 && idx.C5 !== -1 && idx.C6 !== -1 && idx.N7 !== -1 && idx.C8 !== -1 && idx.N9 !== -1;
+}
+
+export function setPyrimidineIndices(idx: NucleicIndices, unit: Unit.Atomic, residueIndex: ResidueIndex) {
+    const atomicIndex = unit.model.atomicHierarchy.index;
+    const { traceElementIndex } = unit.model.atomicHierarchy.derived.residue;
+
+    idx.trace = traceElementIndex[residueIndex];
+    idx.N1 = atomicIndex.findAtomOnResidue(residueIndex, 'N1');
+    if (idx.N1 === -1) {
+        // modified ring, e.g. DZ
+        idx.N1 = atomicIndex.findAtomOnResidue(residueIndex, 'C1');
+    }
+    idx.C2 = atomicIndex.findAtomOnResidue(residueIndex, 'C2');
+    idx.N3 = atomicIndex.findAtomOnResidue(residueIndex, 'N3');
+    idx.C4 = atomicIndex.findAtomOnResidue(residueIndex, 'C4');
+    idx.C5 = atomicIndex.findAtomOnResidue(residueIndex, 'C5');
+    idx.C6 = atomicIndex.findAtomOnResidue(residueIndex, 'C6');
+
+    return idx;
+}
+
+export function hasPyrimidineIndices(idx: NucleicIndices): idx is NucleicIndices & { trace: ElementIndex, N1: ElementIndex, C2: ElementIndex, N3: ElementIndex, C4: ElementIndex, C5: ElementIndex, C6: ElementIndex } {
+    return idx.trace !== -1 && idx.N1 !== -1 && idx.C2 !== -1 && idx.N3 !== -1 && idx.C4 !== -1 && idx.C5 !== -1 && idx.C6 !== -1;
+}
+
+export function setSugarIndices(idx: NucleicIndices, unit: Unit.Atomic, residueIndex: ResidueIndex) {
+    const atomicIndex = unit.model.atomicHierarchy.index;
+    const { traceElementIndex } = unit.model.atomicHierarchy.derived.residue;
+
+    idx.trace = traceElementIndex[residueIndex];
+    idx.C1_1 = atomicIndex.findAtomOnResidue(residueIndex, "C1'");
+    idx.C2_1 = atomicIndex.findAtomOnResidue(residueIndex, "C2'");
+    idx.C3_1 = atomicIndex.findAtomOnResidue(residueIndex, "C3'");
+    idx.C4_1 = atomicIndex.findAtomOnResidue(residueIndex, "C4'");
+    idx.O4_1 = atomicIndex.findAtomOnResidue(residueIndex, "O4'");
+
+    return idx;
+}
+
+export function hasSugarIndices(idx: NucleicIndices): idx is NucleicIndices & { trace: ElementIndex, C1_1: ElementIndex, C2_1: ElementIndex, C3_1: ElementIndex, C4_1: ElementIndex, O4_1: ElementIndex } {
+    return idx.trace !== -1 && idx.C1_1 !== -1 && idx.C2_1 !== -1 && idx.C3_1 !== -1 && idx.C4_1 !== -1 && idx.O4_1 !== -1;
+}

+ 3 - 1
src/mol-util/array.ts

@@ -177,14 +177,16 @@ export function range(start: number, end?: number): number[] {
 /** Copy all elements from `src` to the end of `dst`.
  * Equivalent to `dst.push(...src)`, but avoids storing element on call stack. Faster that `extend` from Underscore.js.
  * `extend(a, a)` will double the array.
+ * Returns the modified `dst` array.
  */
-export function arrayExtend<T>(dst: T[], src: ArrayLike<T>): void {
+export function arrayExtend<T>(dst: T[], src: ArrayLike<T>): T[] {
     const offset = dst.length;
     const nCopy = src.length;
     dst.length += nCopy;
     for (let i = 0; i < nCopy; i++) {
         dst[offset + i] = src[i];
     }
+    return dst;
 }
 
 /** Check whether `array` is sorted, sort if not. */

+ 69 - 0
src/perf-tests/array.ts

@@ -0,0 +1,69 @@
+/**
+ * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import * as B from 'benchmark';
+import { arrayExtend, range, sortIfNeeded } from '../mol-util/array';
+
+
+function randomFloats(n: number) {
+    const SCALE = 1000;
+    const data = new Array(n);
+    for (let i = 0; i < n; i++) {
+        data[i] = SCALE * Math.random();
+    }
+    return data;
+}
+
+function le(x: number, y: number) { return x - y; }
+
+interface Copies<T> {
+    init: () => T,
+    copies: T[],
+    offset: number,
+}
+const Copies = {
+    create<T>(init: () => T, nCopies: number): Copies<T> {
+        return { init, offset: 0, copies: range(nCopies).map(init) };
+    },
+    get<T>(copies: Copies<T>): T {
+        return (copies.offset < copies.copies.length) ? copies.copies[copies.offset++] : copies.init();
+    },
+};
+
+export function runBenchmarks(arrayLength: number) {
+    const _data = randomFloats(arrayLength);
+    const _sortedData = arrayExtend([], _data).sort(le);
+    const _worstData = arrayExtend([], _sortedData);
+    [_worstData[arrayLength - 1], _worstData[arrayLength - 2]] = [_worstData[arrayLength - 2], _worstData[arrayLength - 1]];
+
+    const nCopies = 100;
+    let randomData: Copies<number[]>, sortedData: Copies<number[]>, worstData: Copies<number[]>;
+
+    function prepareData() {
+        randomData = Copies.create(() => arrayExtend([], _data), nCopies);
+        sortedData = Copies.create(() => arrayExtend([], _sortedData), nCopies);
+        worstData = Copies.create(() => arrayExtend([], _worstData), nCopies);
+    }
+    prepareData();
+
+    const suite = new B.Suite();
+    suite
+        .add(`native sort (${arrayLength}, pre-sorted)`, () => Copies.get(sortedData).sort(le))
+        .add(`sortIfNeeded (${arrayLength}, pre-sorted)`, () => sortIfNeeded(Copies.get(sortedData), le))
+        .add(`native sort (${arrayLength}, not sorted)`, () => Copies.get(randomData).sort(le))
+        .add(`sortIfNeeded (${arrayLength}, not sorted)`, () => sortIfNeeded(Copies.get(randomData), le))
+        .add(`native sort (${arrayLength}, worst case)`, () => Copies.get(worstData).sort(le))
+        .add(`sortIfNeeded (${arrayLength}, worst case)`, () => sortIfNeeded(Copies.get(worstData), le))
+        .on('cycle', (e: any) => {
+            console.log(String(e.target));
+            prepareData();
+        })
+        .run();
+    console.log('---------------------');
+    console.log('`sortIfNeeded` should be faster than native `sort` on pre-sorted data, same speed on non-sorted data and worst case data (almost sorted array when only the two last elements are swapped)');
+}
+
+runBenchmarks(10 ** 6);

+ 2 - 2
src/servers/model/version.ts

@@ -1,7 +1,7 @@
 /**
- * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-export const VERSION = '0.9.10';
+export const VERSION = '0.9.11';

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است