Repository: pydicom/pydicom

Issue:
Handling of DS too long to be encoded in explicit encoding
<!-- Instructions For Filing a Bug: https://github.com/pydicom/pydicom/blob/master/CONTRIBUTING.md#filing-bugs -->

#### Description
This is probably not a bug, but I'm not sure about the wanted behavior.
An RTPlan dataset encoded as Little Endian Implicit contains multiple values in the DS tag DHV Data (3004,0058) with an overall length not fitting into 2 bytes. Trying to write this as explicit Little Endian fails with an exception (`"ushort format requires 0 &lt;= number &lt;= (0x7fff * 2 + 1)"`) which is raised by the `pack` call in `write_leUS` while trying to write the length.

The standard says for this case in PS3.5, Table 6.2-1 (for VR DS):
```
Note
Data Elements with multiple values using this VR may not be properly encoded if Explicit-VR transfer syntax is used and the VL of this attribute exceeds 65534 bytes.
```
So, as I understand it, this is valid DICOM, that cannot be converted to explicit encoding without data loss.
The question is how to handle this. What comes to mind:
- truncate the value and log a warning
- raise a meaningful exception
- adapt the behavior depending on some config setting

Any thoughts?

<!-- Example: Attribute Error thrown when printing (0x0010, 0x0020) patient Id> 0-->

#### Steps/Code to Reproduce
<!--
Example:
```py
from io import BytesIO
from pydicom import dcmread

bytestream = b'\x02\x00\x02\x00\x55\x49\x16\x00\x31\x2e\x32\x2e\x38\x34\x30\x2e\x31' \
             b'\x30\x30\x30\x38\x2e\x35\x2e\x31\x2e\x31\x2e\x39\x00\x02\x00\x10\x00' \
             b'\x55\x49\x12\x00\x31\x2e\x32\x2e\x38\x34\x30\x2e\x31\x30\x30\x30\x38' \
             b'\x2e\x31\x2e\x32\x00\x20\x20\x10\x00\x02\x00\x00\x00\x01\x00\x20\x20' \
             b'\x20\x00\x06\x00\x00\x00\x4e\x4f\x52\x4d\x41\x4c'

fp = BytesIO(bytestream)
ds = dcmread(fp, force=True)

print(ds.PatientID)
```
If the code is too long, feel free to put it in a public gist and link
it in the issue: https://gist.github.com

When possible use pydicom testing examples to reproduce the errors. Otherwise, provide
an anonymous version of the data in order to replicate the errors.
-->

#### Expected Results
<!-- Please paste or describe the expected results.
Example: No error is thrown and the name of the patient is printed.-->

#### Actual Results
<!-- Please paste or specifically describe the actual output or traceback.
(Use %xmode to deactivate ipython's trace beautifier)
Example: ```AttributeError: 'FileDataset' object has no attribute 'PatientID'```
-->

#### Versions
<!--
Please run the following snippet and paste the output below.
import platform; print(platform.platform())
import sys; print("Python", sys.version)
import pydicom; print("pydicom", pydicom.__version__)
-->


<!-- Thanks for contributing! -->


Discussion:

Patch:
diff --git a/doc/whatsnew/v1.4.0.rst b/doc/whatsnew/v1.4.0.rst
index ca25f8ed8..4804559ee 100644
--- a/doc/whatsnew/v1.4.0.rst
+++ b/doc/whatsnew/v1.4.0.rst
@@ -10,4 +10,6 @@ Fixes
 * Prevent exception if assigning `None` to UI element (:issue:`894`)
 * Fixed print output for numeric multi-value elements (:issue:`892`)
 * Fixed testing PN values for truthiness (:issue:`891`)
+* Fixed handling of data too large to written in explicit transfer syntax
 * Fixed handling of known tags with VR UN (:issue:`899`)
+
diff --git a/pydicom/dataelem.py b/pydicom/dataelem.py
index 0661192fd..226240f07 100644
--- a/pydicom/dataelem.py
+++ b/pydicom/dataelem.py
@@ -10,6 +10,7 @@ A DataElement has a tag,
 from __future__ import absolute_import
 
 import base64
+import json
 import warnings
 from collections import namedtuple
 
@@ -192,6 +193,8 @@ class DataElement(object):
 
         Parameters
         ----------
+        dataset_class: Dataset derived class
+            class used to create sequence items
         tag: pydicom.tag.Tag
             data element tag
         vr: str
@@ -298,21 +301,116 @@ class DataElement(object):
             logger.warning('missing value for data element "{}"'.format(tag))
             elem_value = ''
 
-        elem_value = jsonrep._convert_to_python_number(elem_value, vr)
+        elem_value = jsonrep.convert_to_python_number(elem_value, vr)
 
         try:
             if compat.in_py2 and vr == "PN":
-
                 elem_value = PersonNameUnicode(elem_value, 'UTF8')
             return DataElement(tag=tag, value=elem_value, VR=vr)
         except Exception:
-            raise
             raise ValueError(
                 'Data element "{}" could not be loaded from JSON: {}'.format(
                     tag, elem_value
                     )
             )
 
+    def to_json(self, bulk_data_element_handler,
+                bulk_data_threshold, dump_handler):
+        """Converts a DataElement to JSON representation.
+
+        Parameters
+        ----------
+        bulk_data_element_handler: Union[Callable, None]
+            callable that accepts a bulk data element and returns the
+            "BulkDataURI" for retrieving the value of the data element
+            via DICOMweb WADO-RS
+        bulk_data_threshold: int
+            size of base64 encoded data element above which a value will be
+            provided in form of a "BulkDataURI" rather than "InlineBinary"
+
+        Returns
+        -------
+        dict
+            mapping representing a JSON encoded data element
+
+        Raises
+        ------
+        TypeError
+            when size of encoded data element exceeds `bulk_data_threshold`
+            but `bulk_data_element_handler` is ``None`` and hence not callable
+
+        """
+        # TODO: Determine whether more VRs need to be converted to strings
+        _VRs_TO_QUOTE = ['AT', ]
+        json_element = {'vr': self.VR, }
+        if self.VR in jsonrep.BINARY_VR_VALUES:
+            if self.value is not None:
+                binary_value = self.value
+                encoded_value = base64.b64encode(binary_value).decode('utf-8')
+                if len(encoded_value) > bulk_data_threshold:
+                    if bulk_data_element_handler is None:
+                        raise TypeError(
+                            'No bulk data element handler provided to '
+                            'generate URL for value of data element "{}".'
+                            .format(self.name)
+                        )
+                    json_element['BulkDataURI'] = bulk_data_element_handler(
+                        self
+                    )
+                else:
+                    logger.info(
+                        'encode bulk data element "{}" inline'.format(
+                            self.name
+                        )
+                    )
+                    json_element['InlineBinary'] = encoded_value
+        elif self.VR == 'SQ':
+            # recursive call to co-routine to format sequence contents
+            value = [
+                json.loads(e.to_json(
+                    bulk_data_element_handler=bulk_data_element_handler,
+                    bulk_data_threshold=bulk_data_threshold,
+                    dump_handler=dump_handler
+                ))
+                for e in self
+            ]
+            json_element['Value'] = value
+        elif self.VR == 'PN':
+            elem_value = self.value
+            if elem_value is not None:
+                if compat.in_py2:
+                    elem_value = PersonNameUnicode(elem_value, 'UTF8')
+                if len(elem_value.components) > 2:
+                    json_element['Value'] = [
+                        {'Phonetic': elem_value.components[2], },
+                    ]
+                elif len(elem_value.components) > 1:
+                    json_element['Value'] = [
+                        {'Ideographic': elem_value.components[1], },
+                    ]
+                else:
+                    json_element['Value'] = [
+                        {'Alphabetic': elem_value.components[0], },
+                    ]
+        else:
+            if self.value is not None:
+                is_multivalue = isinstance(self.value, MultiValue)
+                if self.VM > 1 or is_multivalue:
+                    value = self.value
+                else:
+                    value = [self.value]
+                # ensure it's a list and not another iterable
+                # (e.g. tuple), which would not be JSON serializable
+                if self.VR in _VRs_TO_QUOTE:
+                    json_element['Value'] = [str(v) for v in value]
+                else:
+                    json_element['Value'] = [v for v in value]
+        if hasattr(json_element, 'Value'):
+            json_element['Value'] = jsonrep.convert_to_python_number(
+                json_element['Value'], self.VR
+            )
+        return json_element
+
     @property
     def value(self):
         """Return the element's `value`."""
diff --git a/pydicom/dataset.py b/pydicom/dataset.py
index efc56d935..dd4415875 100644
--- a/pydicom/dataset.py
+++ b/pydicom/dataset.py
@@ -15,7 +15,6 @@ Dataset (dict subclass)
             contains its own DataElements, and so on in a recursive manner.
 """
 
-import base64
 import inspect  # for __dir__
 import io
 import json
@@ -27,7 +26,7 @@ from itertools import takewhile
 import pydicom  # for dcmwrite
 import pydicom.charset
 import pydicom.config
-from pydicom import compat, datadict
+from pydicom import compat, datadict, jsonrep
 from pydicom._version import __version_info__
 from pydicom.charset import default_encoding, convert_encodings
 from pydicom.config import logger
@@ -35,7 +34,6 @@ from pydicom.datadict import dictionary_VR
 from pydicom.datadict import (tag_for_keyword, keyword_for_tag,
                               repeater_has_keyword)
 from pydicom.dataelem import DataElement, DataElement_from_raw, RawDataElement
-from pydicom.multival import MultiValue
 from pydicom.pixel_data_handlers.util import (convert_color_space,
                                               reshape_pixel_array)
 from pydicom.tag import Tag, BaseTag, tag_in_exception
@@ -45,7 +43,6 @@ from pydicom.uid import (ExplicitVRLittleEndian, ImplicitVRLittleEndian,
 
 if compat.in_py2:
     from pkgutil import find_loader as have_package
-    from pydicom.valuerep import PersonNameUnicode
 else:
     from importlib.util import find_spec as have_package
 
@@ -343,13 +340,6 @@ class Dataset(dict):
     """
     indent_chars = "   "
 
-    _BINARY_VR_VALUES = ['OW', 'OB', 'OD', 'OF', 'OL', 'UN',
-                         'OB or OW', 'US or OW', 'US or SS or OW']
-    _VRs_TO_BE_FLOATS = ['DS', 'FL', 'FD', ]
-    _VRs_TO_BE_INTS = ['IS', 'SL', 'SS', 'UL', 'US', 'US or SS']
-
-    # Order of keys is significant!
-    _JSON_VALUE_KEYS = ('Value', 'BulkDataURI', 'InlineBinary', )
 
     # Python 2: Classes defining __eq__ should flag themselves as unhashable
     __hash__ = None
@@ -1400,7 +1390,7 @@ class Dataset(dict):
                                getattr(data_element, x))
                               for x in dir(data_element)
                               if not x.startswith("_")
-                              and x != "from_json"])
+                              and x not in ('from_json', 'to_json')])
             if data_element.VR == "SQ":
                 yield sequence_element_format % elem_dict
             else:
@@ -1776,43 +1766,11 @@ class Dataset(dict):
                     for dataset in sequence:
                         dataset.walk(callback)
 
-    @classmethod
-    def _convert_to_python_number(cls, value, vr):
-        """Makes sure that values are either ints or floats
-        based on their value representation.
-
-        Parameters
-        ----------
-        value: Union[Union[str, int, float], List[Union[str, int, float]]]
-            value of data element
-        vr: str
-            value representation of data element
-
-        Returns
-        -------
-        Union[Union[str, int, float], List[Union[str, int, float]]]
-
-        """
-        if value is None:
-            return None
-        number_type = None
-        if vr in cls._VRs_TO_BE_INTS:
-            number_type = int
-        if vr in cls._VRs_TO_BE_FLOATS:
-            number_type = float
-        if number_type is not None:
-            if isinstance(value, (list, tuple, )):
-                value = [number_type(e) for e in value]
-            else:
-                value = number_type(value)
-        return value
-
     @classmethod
     def from_json(cls, json_dataset, bulk_data_uri_handler=None,
                   encodings=None):
         """Loads DICOM Data Set in DICOM JSON format.
-        See:
-        http://dicom.nema.org/medical/dicom/current/output/chtml/part18/chapter_F.html
+        See PS 3.18, Annex F.
 
         Parameters
         ----------
@@ -1836,7 +1794,7 @@ class Dataset(dict):
         for tag, mapping in json_dataset.items():
             vr = mapping['vr']
             unique_value_keys = tuple(
-                set(mapping.keys()) & set(cls._JSON_VALUE_KEYS)
+                set(mapping.keys()) & set(jsonrep.JSON_VALUE_KEYS)
             )
             if len(unique_value_keys) == 0:
                 value_key = None
@@ -1850,119 +1808,18 @@ class Dataset(dict):
             dataset.add(data_element)
         return dataset
 
-    def _data_element_to_json(self, data_element,
-                              bulk_data_element_handler,
-                              bulk_data_threshold, dump_handler):
-        """Converts a DataElement to JSON representation.
-
-        Parameters
-        ----------
-        data_element: pydicom.dataelem.DataElement
-            data element
-        bulk_data_element_handler: Union[Callable, None]
-            callable that accepts a bulk data element and returns the
-            "BulkDataURI" for retrieving the value of the data element
-            via DICOMweb WADO-RS
-        bulk_data_threshold: int
-            size of base64 encoded data element above which a value will be
-            provided in form of a "BulkDataURI" rather than "InlineBinary"
-
-        Returns
-        -------
-        dict
-            mapping representing a JSON encoded data element
-
-        Raises
-        ------
-        TypeError
-            when size of encoded data element exceeds `bulk_data_threshold`
-            but `bulk_data_element_handler` is ``None`` and hence not callable
-
-        """
-        # TODO: Determine whether more VRs need to be converted to strings
-        _VRs_TO_QUOTE = ['AT', ]
-        json_element = {'vr': data_element.VR, }
-        if data_element.VR in Dataset._BINARY_VR_VALUES:
-            if data_element.value is not None:
-                binary_value = data_element.value
-                encoded_value = base64.b64encode(binary_value).decode('utf-8')
-                if len(encoded_value) > bulk_data_threshold:
-                    if bulk_data_element_handler is None:
-                        raise TypeError(
-                            'No bulk data element handler provided to generate '
-                            'URL for value of data element "{}".'.format(
-                                data_element.name
-                            )
-                        )
-                    json_element['BulkDataURI'] = bulk_data_element_handler(
-                        data_element
-                    )
-                else:
-                    logger.info(
-                        'encode bulk data element "{}" inline'.format(
-                            data_element.name
-                        )
-                    )
-                    json_element['InlineBinary'] = encoded_value
-        elif data_element.VR == 'SQ':
-            # recursive call to co-routine to format sequence contents
-            value = [
-                json.loads(e.to_json(
-                    bulk_data_element_handler=bulk_data_element_handler,
-                    bulk_data_threshold=bulk_data_threshold,
-                    dump_handler=dump_handler
-                ))
-                for e in data_element
-            ]
-            json_element['Value'] = value
-        elif data_element.VR == 'PN':
-            elem_value = data_element.value
-            if elem_value is not None:
-                if compat.in_py2:
-                    elem_value = PersonNameUnicode(elem_value, 'UTF8')
-                if len(elem_value.components) > 2:
-                    json_element['Value'] = [
-                        {'Phonetic': elem_value.components[2], },
-                    ]
-                elif len(elem_value.components) > 1:
-                    json_element['Value'] = [
-                        {'Ideographic': elem_value.components[1], },
-                    ]
-                else:
-                    json_element['Value'] = [
-                        {'Alphabetic': elem_value.components[0], },
-                    ]
-        else:
-            if data_element.value is not None:
-                is_multivalue = isinstance(data_element.value, MultiValue)
-                if data_element.VM > 1 or is_multivalue:
-                    value = data_element.value
-                else:
-                    value = [data_element.value]
-                # ensure it's a list and not another iterable
-                # (e.g. tuple), which would not be JSON serializable
-                if data_element.VR in _VRs_TO_QUOTE:
-                    json_element['Value'] = [str(v) for v in value]
-                else:
-                    json_element['Value'] = [v for v in value]
-        if hasattr(json_element, 'Value'):
-            json_element['Value'] = self._convert_to_python_number(
-                json_element['Value'], data_element.VR
-            )
-        return json_element
-
     def to_json(self, bulk_data_threshold=1, bulk_data_element_handler=None,
                 dump_handler=None):
         """Converts the data set into JSON representation based on the
-        DICOM JSON Model
-        http://dicom.nema.org/medical/dicom/current/output/chtml/part18/chapter_F.html.
+        DICOM JSON Model (see PS 3.18, Annex F).
 
         Parameters
         ----------
         bulk_data_threshold: int, optional
             threshold for the length of a base64-encoded binary data element
-            above which the element should be considered bulk data and the value
-            provided as a URI rather than included inline (default: ``1``)
+            above which the element should be considered bulk data and the
+            value provided as a URI rather than included inline
+            (default: ``1``)
         bulk_data_element_handler: Union[Callable, None], optional
             callable that accepts a bulk data element and returns a JSON
             representation of the data element (dictionary including the "vr"
@@ -1980,7 +1837,7 @@ class Dataset(dict):
         --------
         >>> def my_json_dumps(data):
         ...     return json.dumps(data, indent=4)
-        >>> dataset.to_json(dump_handler=my_json_dumps)
+        >>> Dataset.to_json(dump_handler=my_json_dumps)
 
         """
         if dump_handler is None:
@@ -1997,8 +1854,7 @@ class Dataset(dict):
                 )
                 continue
             data_element = self[key]
-            json_dataset[json_key] = self._data_element_to_json(
-                data_element,
+            json_dataset[json_key] = data_element.to_json(
                 bulk_data_element_handler=bulk_data_element_handler,
                 bulk_data_threshold=bulk_data_threshold,
                 dump_handler=dump_handler
diff --git a/pydicom/filewriter.py b/pydicom/filewriter.py
index c0bb8cb58..5d6fc9e14 100644
--- a/pydicom/filewriter.py
+++ b/pydicom/filewriter.py
@@ -3,7 +3,7 @@
 
 from __future__ import absolute_import
 
-import struct
+import warnings
 from struct import pack
 
 from pydicom import compat
@@ -439,27 +439,19 @@ def write_data_element(fp, data_element, encodings=None):
     # Write element's tag
     fp.write_tag(data_element.tag)
 
-    # If explicit VR, write the VR
-    VR = data_element.VR
-    if not fp.is_implicit_VR:
-        if len(VR) != 2:
-            msg = ("Cannot write ambiguous VR of '{}' for data element with "
-                   "tag {}.\nSet the correct VR before writing, or use an "
-                   "implicit VR transfer syntax".format(
-                       VR, repr(data_element.tag)))
-            raise ValueError(msg)
-        if not in_py2:
-            fp.write(bytes(VR, default_encoding))
-        else:
-            fp.write(VR)
-        if VR in extra_length_VRs:
-            fp.write_US(0)  # reserved 2 bytes
-
     # write into a buffer to avoid seeking back which can be expansive
     buffer = DicomBytesIO()
     buffer.is_little_endian = fp.is_little_endian
     buffer.is_implicit_VR = fp.is_implicit_VR
 
+    VR = data_element.VR
+    if not fp.is_implicit_VR and len(VR) != 2:
+        msg = ("Cannot write ambiguous VR of '{}' for data element with "
+               "tag {}.\nSet the correct VR before writing, or use an "
+               "implicit VR transfer syntax".format(
+                   VR, repr(data_element.tag)))
+        raise ValueError(msg)
+
     if data_element.is_raw:
         # raw data element values can be written as they are
         buffer.write(data_element.value)
@@ -496,18 +488,29 @@ def write_data_element(fp, data_element, encodings=None):
                              'start with an item tag')
 
     value_length = buffer.tell()
+    if (not fp.is_implicit_VR and VR not in extra_length_VRs and
+            not is_undefined_length and value_length > 0xffff):
+        # see PS 3.5, section 6.2.2 for handling of this case
+        msg = ('The value for the data element {} exceeds the size '
+               'of 64 kByte and cannot be written in an explicit transfer '
+               'syntax. The data element VR is changed from "{}" to "UN" '
+               'to allow saving the data.'
+               .format(data_element.tag, VR))
+        warnings.warn(msg)
+        VR = 'UN'
+
+    # write the VR for explicit transfer syntax
+    if not fp.is_implicit_VR:
+        if not in_py2:
+            fp.write(bytes(VR, default_encoding))
+        else:
+            fp.write(VR)
+        if VR in extra_length_VRs:
+            fp.write_US(0)  # reserved 2 bytes
+
     if (not fp.is_implicit_VR and VR not in extra_length_VRs and
             not is_undefined_length):
-        try:
-            fp.write_US(value_length)  # Explicit VR length field is 2 bytes
-        except struct.error:
-            msg = ('The value for the data element {} exceeds the size '
-                   'of 64 kByte and cannot be written in an explicit transfer '
-                   'syntax. You can save it using Implicit Little Endian '
-                   'transfer syntax, or you have to truncate the value to not '
-                   'exceed the maximum size of 64 kByte.'
-                   .format(data_element.tag))
-            raise ValueError(msg)
+        fp.write_US(value_length)  # Explicit VR length field is 2 bytes
     else:
         # write the proper length of the data_element in the length slot,
         # unless is SQ with undefined length.
diff --git a/pydicom/jsonrep.py b/pydicom/jsonrep.py
index 665d709c1..f6b12635e 100644
--- a/pydicom/jsonrep.py
+++ b/pydicom/jsonrep.py
@@ -2,11 +2,15 @@
 """Methods for converting Datasets and DataElements to/from json"""
 
 # Order of keys is significant!
-JSON_VALUE_KEYS = ('Value', 'BulkDataURI', 'InlineBinary', )
-VRs_TO_BE_FLOATS = ["DS", "FL", "FD", ]
-VRs_TO_BE_INTS = ["IS", "SL", "SS", "UL", "US", ]
+JSON_VALUE_KEYS = ('Value', 'BulkDataURI', 'InlineBinary',)
 
-def _convert_to_python_number(value, vr):
+BINARY_VR_VALUES = ['OW', 'OB', 'OD', 'OF', 'OL', 'UN',
+                    'OB or OW', 'US or OW', 'US or SS or OW']
+VRs_TO_BE_FLOATS = ['DS', 'FL', 'FD', ]
+VRs_TO_BE_INTS = ['IS', 'SL', 'SS', 'UL', 'US', 'US or SS']
+
+
+def convert_to_python_number(value, vr):
     """Makes sure that values are either ints or floats
     based on their value representation.
 
@@ -30,7 +34,7 @@ def _convert_to_python_number(value, vr):
     if vr in VRs_TO_BE_FLOATS:
         number_type = float
     if number_type is not None:
-        if isinstance(value, (list, tuple, )):
+        if isinstance(value, (list, tuple,)):
             value = [number_type(e) for e in value]
         else:
             value = number_type(value)


Test patch:
diff --git a/pydicom/tests/test_filewriter.py b/pydicom/tests/test_filewriter.py
index 0a4ba3ee3..48efabb6b 100644
--- a/pydicom/tests/test_filewriter.py
+++ b/pydicom/tests/test_filewriter.py
@@ -2513,20 +2513,31 @@ class TestWriteUndefinedLengthPixelData(object):
                                  large_value,
                                  is_undefined_length=False)
         write_data_element(self.fp, pixel_data)
+        self.fp.seek(0)
+        ds = read_dataset(self.fp, True, True)
+        assert 'DS' == ds[0x30040058].VR
 
         self.fp = DicomBytesIO()
         self.fp.is_little_endian = True
         self.fp.is_implicit_VR = False
-        # shall raise if trying to write it with explicit transfer syntax,
-        # where the length field is 2 bytes long
-        expected_message = (r'The value for the data element \(3004, 0058\) '
-                            r'exceeds the size of 64 kByte ')
-        with pytest.raises(ValueError, match=expected_message):
+
+        msg = (r'The value for the data element \(3004, 0058\) exceeds the '
+               r'size of 64 kByte and cannot be written in an explicit '
+               r'transfer syntax. The data element VR is changed from '
+               r'"DS" to "UN" to allow saving the data.')
+
+        with pytest.warns(UserWarning, match=msg):
             write_data_element(self.fp, pixel_data)
+        self.fp.seek(0)
+        ds = read_dataset(self.fp, False, True)
+        assert 'UN' == ds[0x30040058].VR
 
+        # we expect the same behavior in Big Endian transfer syntax
         self.fp = DicomBytesIO()
         self.fp.is_little_endian = False
         self.fp.is_implicit_VR = False
-        # we expect the same behavior in Big Endian transfer syntax
-        with pytest.raises(ValueError, match=expected_message):
+        with pytest.warns(UserWarning, match=msg):
             write_data_element(self.fp, pixel_data)
+        self.fp.seek(0)
+        ds = read_dataset(self.fp, False, False)
+        assert 'UN' == ds[0x30040058].VR