From 92441c66052f3feb54ff122a366647dc4133a794 Mon Sep 17 00:00:00 2001
From: Paul McCarthy <pauld.mccarthy@gmail.com>
Date: Wed, 16 Dec 2015 16:50:26 +0000
Subject: [PATCH] GLVector now supports a separate clipping image, in addition
 to the modulate image. Gl14, GLLineVector not yet supported, and things in
 the GLVolume class hierarchy are broken.

---
 fsl/data/strings.py                           |   8 +-
 fsl/fsleyes/controls/overlaydisplaypanel.py   |  31 ++-
 fsl/fsleyes/controls/overlaydisplaytoolbar.py |  72 ++---
 fsl/fsleyes/displaycontext/group.py           |   5 +-
 fsl/fsleyes/displaycontext/vectoropts.py      |  84 ++++--
 fsl/fsleyes/fsleyes_parseargs.py              | 119 +++++----
 fsl/fsleyes/gl/gl21/glrgbvector_funcs.py      |  91 ++++---
 fsl/fsleyes/gl/gl21/gltensor_funcs.py         | 141 +++++-----
 fsl/fsleyes/gl/gl21/glvector_frag.glsl        |  38 ++-
 fsl/fsleyes/gl/gl21/glvolume_funcs.py         | 106 ++++----
 fsl/fsleyes/gl/glvector.py                    | 248 +++++++++++-------
 fsl/fsleyes/tooltips.py                       |  24 +-
 12 files changed, 566 insertions(+), 401 deletions(-)

diff --git a/fsl/data/strings.py b/fsl/data/strings.py
index d5f46d795..acc7f1874 100644
--- a/fsl/data/strings.py
+++ b/fsl/data/strings.py
@@ -553,8 +553,9 @@ properties = TypeDict({
     'VectorOpts.suppressX'     : 'Suppress X value',
     'VectorOpts.suppressY'     : 'Suppress Y value',
     'VectorOpts.suppressZ'     : 'Suppress Z value',
-    'VectorOpts.modulate'      : 'Modulate by',
-    'VectorOpts.modThreshold'  : 'Modulation threshold',
+    'VectorOpts.modulateImage' : 'Modulate by',
+    'VectorOpts.clipImage'     : 'Clip by',
+    'VectorOpts.clipThreshold' : 'Clipping threshold',
 
     'RGBVectorOpts.interpolation' : 'Interpolation',
 
@@ -615,7 +616,8 @@ choices = TypeDict({
     'VectorOpts.displayType.line' : 'Lines',
     'VectorOpts.displayType.rgb'  : 'RGB',
 
-    'VectorOpts.modulate.none'    : 'No modulation',
+    'VectorOpts.modulateImage.none' : 'No modulation',
+    'VectorOpts.clipImage.none'     : 'No clipping',
 
     'ModelOpts.refImage.none'     : 'No reference image',
 
diff --git a/fsl/fsleyes/controls/overlaydisplaypanel.py b/fsl/fsleyes/controls/overlaydisplaypanel.py
index 6dae3a73b..00efee699 100644
--- a/fsl/fsleyes/controls/overlaydisplaypanel.py
+++ b/fsl/fsleyes/controls/overlaydisplaypanel.py
@@ -275,8 +275,8 @@ class OverlayDisplayPanel(fslpanel.FSLEyesPanel):
 
 
 def _imageName(img):
-    """Used to generate choice labels for the :attr`.VectorOpts.modulate` and
-    :attr:`.ModelOpts.refImage` properties.
+    """Used to generate choice labels for the :attr`.VectorOpts.modulateImage`,
+    :attr`.VectorOpts.clipImage` and :attr:`.ModelOpts.refImage` properties.
     """
     if img is None: return 'None'
     else:           return img.name
@@ -333,8 +333,13 @@ _DISPLAY_PROPS = td.TypeDict({
         props.Widget('suppressX'),
         props.Widget('suppressY'),
         props.Widget('suppressZ'),
-        props.Widget('modulate', labels=_imageName),
-        props.Widget('modThreshold', showLimits=False, spin=False)],
+        props.Widget('modulateImage', labels=_imageName),
+        props.Widget('clipImage',     labels=_imageName),
+        props.Widget('clipThreshold',
+                     showLimits=False,
+                     spin=False,
+                     dependencies=['clipImage'],
+                     enabledWhen=lambda o, ci: ci is not None)],
 
     'LineVectorOpts' : [
         props.Widget('resolution', showLimits=False),
@@ -346,8 +351,13 @@ _DISPLAY_PROPS = td.TypeDict({
         props.Widget('suppressZ'),
         props.Widget('directed'),
         props.Widget('lineWidth', showLimits=False),
-        props.Widget('modulate', labels=_imageName),
-        props.Widget('modThreshold', showLimits=False, spin=False)],
+        props.Widget('modulateImage', labels=_imageName),
+        props.Widget('clipImage',     labels=_imageName),
+        props.Widget('clipThreshold',
+                     showLimits=False,
+                     spin=False,
+                     dependencies=['clipImage'],
+                     enabledWhen=lambda o, ci: ci is not None)],
 
     'ModelOpts' : [
         props.Widget('colour'),
@@ -373,8 +383,13 @@ _DISPLAY_PROPS = td.TypeDict({
         props.Widget('suppressX'),
         props.Widget('suppressY'),
         props.Widget('suppressZ'),
-        props.Widget('modulate', labels=_imageName),
-        props.Widget('modThreshold', showLimits=False, spin=False)],
+        props.Widget('modulateImage', labels=_imageName),
+        props.Widget('clipImage',     labels=_imageName),
+        props.Widget('clipThreshold',
+                     showLimits=False,
+                     spin=False,
+                     dependencies=['clipImage'],
+                     enabledWhen=lambda o, ci: ci is not None)],
 
     'LabelOpts' : [
         props.Widget('lut', labels=lambda l: l.name),
diff --git a/fsl/fsleyes/controls/overlaydisplaytoolbar.py b/fsl/fsleyes/controls/overlaydisplaytoolbar.py
index e6a4607ad..f860b7ced 100644
--- a/fsl/fsleyes/controls/overlaydisplaytoolbar.py
+++ b/fsl/fsleyes/controls/overlaydisplaytoolbar.py
@@ -330,8 +330,8 @@ class OverlayDisplayToolBar(fsltoolbar.FSLEyesToolBar):
         of the given :class:`.VectorOpts` instance.
         """        
         
-        modSpec   = _TOOLBAR_PROPS[opts]['modulate']
-        thresSpec = _TOOLBAR_PROPS[opts]['modThreshold']
+        modSpec   = _TOOLBAR_PROPS[opts]['modulateImage']
+        thresSpec = _TOOLBAR_PROPS[opts]['clipThreshold']
 
         panel = wx.Panel(self)
         sizer = wx.FlexGridSizer(2, 2)
@@ -342,8 +342,8 @@ class OverlayDisplayToolBar(fsltoolbar.FSLEyesToolBar):
         modLabel    = wx.StaticText(panel)
         thresLabel  = wx.StaticText(panel)
 
-        modLabel  .SetLabel(strings.properties[opts, 'modulate'])
-        thresLabel.SetLabel(strings.properties[opts, 'modThreshold'])
+        modLabel  .SetLabel(strings.properties[opts, 'modulateImage'])
+        thresLabel.SetLabel(strings.properties[opts, 'clipThreshold'])
 
         sizer.Add(modLabel)
         sizer.Add(modWidget,   flag=wx.EXPAND)
@@ -406,7 +406,7 @@ def _modImageLabel(img):
     """Used to generate labels for the :attr:`.VectorOpts.modulate`
     property choices.
     """
-    if img is None: return strings.choices['VectorOpts.modulate.none']
+    if img is None: return strings.choices['VectorOpts.modulateImage.none']
     else:           return img.name
 
     
@@ -432,15 +432,15 @@ _TOOLTIPS = td.TypeDict({
     'LabelOpts.outlineWidth' : fsltooltips.properties['LabelOpts.'
                                                       'outlineWidth'],
 
-    'RGBVectorOpts.modulate'     : fsltooltips.properties['VectorOpts.'
-                                                          'modulate'],
-    'RGBVectorOpts.modThreshold' : fsltooltips.properties['VectorOpts.'
-                                                          'modThreshold'],
+    'RGBVectorOpts.modulateImage' : fsltooltips.properties['VectorOpts.'
+                                                           'modulateImage'],
+    'RGBVectorOpts.clipThreshold' : fsltooltips.properties['VectorOpts.'
+                                                           'clipThreshold'],
 
-    'LineVectorOpts.modulate'     : fsltooltips.properties['VectorOpts.'
-                                                           'modulate'],
-    'LineVectorOpts.modThreshold' : fsltooltips.properties['VectorOpts.'
-                                                           'modThreshold'],
+    'LineVectorOpts.modulateImage' : fsltooltips.properties['VectorOpts.'
+                                                            'modulateImage'],
+    'LineVectorOpts.clipThreshold' : fsltooltips.properties['VectorOpts.'
+                                                            'clipThreshold'],
     'LineVectorOpts.lineWidth'    : fsltooltips.properties['LineVectorOpts.'
                                                            'lineWidth'],
 
@@ -449,10 +449,10 @@ _TOOLTIPS = td.TypeDict({
     'ModelOpts.outlineWidth' : fsltooltips.properties['ModelOpts.'
                                                       'outlineWidth'],
 
-    'TensorOpts.modulate'     : fsltooltips.properties['VectorOpts.'
-                                                       'modulate'],
-    'TensorOpts.modThreshold' : fsltooltips.properties['VectorOpts.'
-                                                       'modThreshold'], 
+    'TensorOpts.modulateImage' : fsltooltips.properties['VectorOpts.'
+                                                        'modulateImage'],
+    'TensorOpts.clipThreshold' : fsltooltips.properties['VectorOpts.'
+                                                        'clipThreshold'], 
 })
 """This dictionary contains tooltips for :class:`.Display` and
 :class:`.DisplayOpts` properties. It is referenced in the
@@ -532,26 +532,26 @@ _TOOLBAR_PROPS = td.TypeDict({
             spin=False)},
 
     'RGBVectorOpts' : {
-        'modulate'     : props.Widget(
-            'modulate',
+        'modulateImage' : props.Widget(
+            'modulateImage',
             labels=_modImageLabel,
-            tooltip=_TOOLTIPS['RGBVectorOpts.modulate']),
-        'modThreshold' : props.Widget(
-            'modThreshold',
+            tooltip=_TOOLTIPS['RGBVectorOpts.modulateImage']),
+        'clipThreshold' : props.Widget(
+            'clipThreshold',
             showLimits=False,
             spin=False,
-            tooltip=_TOOLTIPS['RGBVectorOpts.modThreshold'])},
+            tooltip=_TOOLTIPS['RGBVectorOpts.clipThreshold'])},
 
     'LineVectorOpts' : {
-        'modulate'     : props.Widget(
-            'modulate',
+        'modulateImage' : props.Widget(
+            'modulateImage',
             labels=_modImageLabel,
-            tooltip=_TOOLTIPS['LineVectorOpts.modulate']),
-        'modThreshold' : props.Widget(
-            'modThreshold',
+            tooltip=_TOOLTIPS['LineVectorOpts.modulateImage']),
+        'clipThreshold' : props.Widget(
+            'clipThreshold',
             showLimits=False,
             spin=False,
-            tooltip=_TOOLTIPS['LineVectorOpts.modThreshold']), 
+            tooltip=_TOOLTIPS['LineVectorOpts.clipThreshold']), 
         'lineWidth' : props.Widget(
             'lineWidth',
             showLimits=False,
@@ -577,16 +577,16 @@ _TOOLBAR_PROPS = td.TypeDict({
             enabledWhen=lambda i: i.outline)},
 
     'TensorOpts' : {
-        'lighting'     : props.Widget('lighting'),
-        'modulate'     : props.Widget(
-            'modulate',
+        'lighting'      : props.Widget('lighting'),
+        'modulateImage' : props.Widget(
+            'modulateImage',
             labels=_modImageLabel,
-            tooltip=_TOOLTIPS['TensorOpts.modulate']),
-        'modThreshold' : props.Widget(
-            'modThreshold',
+            tooltip=_TOOLTIPS['TensorOpts.modulateImage']),
+        'clipThreshold' : props.Widget(
+            'clipThreshold',
             showLimits=False,
             spin=False,
-            tooltip=_TOOLTIPS['TensorOpts.modThreshold'])}
+            tooltip=_TOOLTIPS['TensorOpts.clipThreshold'])}
 })
 """This dictionary defines specifications for all controls shown on an
 :class:`OverlayDisplayToolBar`. 
diff --git a/fsl/fsleyes/displaycontext/group.py b/fsl/fsleyes/displaycontext/group.py
index ff40aa36c..3485e7432 100644
--- a/fsl/fsleyes/displaycontext/group.py
+++ b/fsl/fsleyes/displaycontext/group.py
@@ -74,8 +74,9 @@ class OverlayGroup(props.HasProperties):
         'VectorOpts'     : ['suppressX',
                             'suppressY',
                             'suppressZ',
-                            'modulate',
-                            'modThreshold'],
+                            'modulateImage',
+                            'clipImage',
+                            'clipThreshold'],
         'LineVectorOpts' : ['lineWidth',
                             'directed'],
         'RGBVectorOpts'  : ['interpolation'],
diff --git a/fsl/fsleyes/displaycontext/vectoropts.py b/fsl/fsleyes/displaycontext/vectoropts.py
index 6311eeca8..2487eb2f7 100644
--- a/fsl/fsleyes/displaycontext/vectoropts.py
+++ b/fsl/fsleyes/displaycontext/vectoropts.py
@@ -49,17 +49,23 @@ class VectorOpts(volumeopts.Nifti1Opts):
     """Do not use the Z vector magnitude to colour vectors."""
 
 
-    modulate  = props.Choice()
-    """Modulate the vector colours by another image. Any image which is in the
-     :class:`.OverlayList`, and which has the same voxel dimensions as the
-     vector image can be selected for modulation.
+    modulateImage  = props.Choice()
+    """Modulate the vector colour brightness by another image. Any image which
+    is in the :class:`.OverlayList`, and which has the same voxel dimensions as
+    the vector image can be selected for modulation.
     """
 
     
-    # TODO This is currently a percentage
-    # of the modulation image data range.
-    # It should be an absolute value
-    modThreshold = props.Percentage(default=0.0)
+    clipImage = props.Choice()
+    """Clip voxels from the vector image according to another image. Any image
+    which is in the :class:`.OverlayList`, and which has the same voxel
+    dimensions as the vector image can be selected for clipping. The
+    :attr:`clipThreshold` dictates the value below which vector voxels are
+    clipped.
+    """ 
+
+    
+    clipThreshold = props.Real(default=0.0, minval=0, maxval=1)
     """Hide voxels for which the modulation value is below this threshold,
     as a percentage of the :attr:`modulate` image data range.
     """
@@ -76,8 +82,12 @@ class VectorOpts(volumeopts.Nifti1Opts):
         self.overlayList.addListener('overlays',
                                      self.name,
                                      self.__overlayListChanged)
+        self            .addListener('clipImage',
+                                     self.name,
+                                     self.__clipImageChanged)
         
         self.__overlayListChanged()
+        self.__clipImageChanged()
 
 
     def destroy(self):
@@ -93,14 +103,34 @@ class VectorOpts(volumeopts.Nifti1Opts):
         volumeopts.Nifti1Opts.destroy(self)
 
         
+    def __clipImageChanged(self, *a):
+        """Called when the :attr:`clipImage` property changes. Updates
+        the range of the :attr:`clipThreshold` property.
+        """
+
+        image = self.clipImage
+
+        if image is None:
+            return
+
+        opts   = self.displayCtx.getOpts(image)
+        minval = opts.dataMin
+        maxval = opts.dataMax
+
+        self.setConstraint('clipThreshold', 'minval',  minval)
+        self.setConstraint('clipThreshold', 'maxval',  maxval) 
+
+        
     def __overlayListChanged(self, *a):
-        """Called when the overlay list changes. Updates the :attr:`modulate`
-        property so that it contains a list of overlays which could be used
-        to modulate the vector image.
+        """Called when the overlay list changes. Updates the :attr:`modulateImage`
+        and :attr:`clipImage` properties so that they contain a list of
+        overlays which could be used to modulate the vector image.
         """
         
-        modProp  = self.getProp('modulate')
-        modVal   = self.modulate
+        modProp  = self.getProp('modulateImage')
+        clipProp = self.getProp('clipImage')
+        modVal   = self.modulateImage
+        clipVal  = self.clipImage
         overlays = self.displayCtx.getOrderedOverlays()
 
         # the image for this VectorOpts
@@ -109,29 +139,30 @@ class VectorOpts(volumeopts.Nifti1Opts):
             self.overlayList.removeListener('overlays', self.name)
             return
 
-        modOptions = [None]
+        options = [None]
 
         for overlay in overlays:
             
             # It doesn't make sense to
-            # modulate the image by itself
+            # modulate/clip the image by
+            # itself.
             if overlay is self.overlay:
                 continue
 
-            # The modulate image must
-            # be an image. Duh.
+            # The modulate/clip images
+            # must be images. 
             if not isinstance(overlay, fslimage.Image):
                 continue
 
             # an image can only be used to
-            # modulate the vector image if
-            # it shares the same dimensions
-            # as said vector image. 4D
-            # images are ok though.
+            # modulate/clip the vector image
+            # if it shares the same
+            # dimensions as said vector image.
+            # 4D images are ok though.
             if overlay.shape[:3] != self.overlay.shape[:3]:
                 continue
 
-            modOptions.append(overlay)
+            options.append(overlay)
 
             display = self.displayCtx.getDisplay(overlay)
             display.addListener('name',
@@ -139,10 +170,13 @@ class VectorOpts(volumeopts.Nifti1Opts):
                                 self.__overlayListChanged,
                                 overwrite=True)
             
-        modProp.setChoices(modOptions, instance=self)
+        modProp .setChoices(options, instance=self)
+        clipProp.setChoices(options, instance=self)
 
-        if modVal in overlays: self.modulate = modVal
-        else:                  self.modulate = None
+        if modVal  in overlays: self.modulateImage = modVal
+        else:                   self.modulateImage = None
+        if clipVal in overlays: self.clipImage     = clipVal
+        else:                   self.clipImage     = None 
 
 
 class LineVectorOpts(VectorOpts):
diff --git a/fsl/fsleyes/fsleyes_parseargs.py b/fsl/fsleyes/fsleyes_parseargs.py
index a4d98a98b..a6d3f1f30 100644
--- a/fsl/fsleyes/fsleyes_parseargs.py
+++ b/fsl/fsleyes/fsleyes_parseargs.py
@@ -205,8 +205,9 @@ OPTIONS = td.TypeDict({
                         'suppressX',
                         'suppressY',
                         'suppressZ',
-                        'modulate',
-                        'modThreshold'],
+                        'modulateImage',
+                        'clipImage',
+                        'clipThreshold'],
     'LineVectorOpts' : ['lineWidth',
                         'directed'],
     'RGBVectorOpts'  : ['interpolation'],
@@ -338,14 +339,15 @@ ARGUMENTS = td.TypeDict({
     'MaskOpts.invert'    : ('mi', 'maskInvert'),
     'MaskOpts.threshold' : ('t',  'threshold'),
 
-    'VectorOpts.xColour'     : ('xc', 'xColour'),
-    'VectorOpts.yColour'     : ('yc', 'yColour'),
-    'VectorOpts.zColour'     : ('zc', 'zColour'),
-    'VectorOpts.suppressX'   : ('xs', 'suppressX'),
-    'VectorOpts.suppressY'   : ('ys', 'suppressY'),
-    'VectorOpts.suppressZ'   : ('zs', 'suppressZ'),
-    'VectorOpts.modulate'    : ('m',  'modulate'),
-    'VectorOpts.modThreshold': ('mt', 'modThreshold'),
+    'VectorOpts.xColour'       : ('xc', 'xColour'),
+    'VectorOpts.yColour'       : ('yc', 'yColour'),
+    'VectorOpts.zColour'       : ('zc', 'zColour'),
+    'VectorOpts.suppressX'     : ('xs', 'suppressX'),
+    'VectorOpts.suppressY'     : ('ys', 'suppressY'),
+    'VectorOpts.suppressZ'     : ('zs', 'suppressZ'),
+    'VectorOpts.modulateImage' : ('md', 'modulateImage'),
+    'VectorOpts.clipImage'     : ('cl', 'clipImage'),
+    'VectorOpts.clipThreshold' : ('ct', 'clipThreshold'),
 
     'LineVectorOpts.lineWidth'    : ('lvw', 'lineWidth'),
     'LineVectorOpts.directed'     : ('lvi', 'directed'),
@@ -443,16 +445,17 @@ HELP = td.TypeDict({
     'MaskOpts.invert'    : 'Invert',
     'MaskOpts.threshold' : 'Threshold',
 
-    'VectorOpts.xColour'      : 'X colour',
-    'VectorOpts.yColour'      : 'Y colour',
-    'VectorOpts.zColour'      : 'Z colour',
-    'VectorOpts.suppressX'    : 'Suppress X magnitude',
-    'VectorOpts.suppressY'    : 'Suppress Y magnitude',
-    'VectorOpts.suppressZ'    : 'Suppress Z magnitude',
-    'VectorOpts.modulate'     : 'Modulate vector colours',
-    'VectorOpts.modThreshold' : 'Hide voxels where modulation '
-                                'value is below this threshold '
-                                '(expressed as a percentage)',
+    'VectorOpts.xColour'       : 'X colour',
+    'VectorOpts.yColour'       : 'Y colour',
+    'VectorOpts.zColour'       : 'Z colour',
+    'VectorOpts.suppressX'     : 'Suppress X magnitude',
+    'VectorOpts.suppressY'     : 'Suppress Y magnitude',
+    'VectorOpts.suppressZ'     : 'Suppress Z magnitude',
+    'VectorOpts.modulateImage' : 'Modulate vector brightness',
+    'VectorOpts.clipImage'     : 'Clip vector voxels',
+    'VectorOpts.clipThreshold' : 'Hide voxels where clip image '
+                                 'value is below this threshold '
+                                 '(expressed as a percentage)',
 
     'LineVectorOpts.lineWidth'    : 'Line width',
     'LineVectorOpts.directed'     : 'Interpret vectors as directed',
@@ -523,10 +526,11 @@ TRANSFORMS = td.TypeDict({
     # when reading in command line arguments -
     # the transform function specified here
     # is only used when generating arguments
-    'VectorOpts.modulate'   : _imageTrans,
-    'ModelOpts.refImage'    : _imageTrans,
+    'VectorOpts.modulateImage' : _imageTrans,
+    'VectorOpts.clipImage'     : _imageTrans,
+    'ModelOpts.refImage'       : _imageTrans,
 
-    'LabelOpts.lut'         : _lutTrans,
+    'LabelOpts.lut'            : _lutTrans,
 })
 """This dictionary defines any transformations for command line options
 where the value passed on the command line cannot be directly converted
@@ -722,11 +726,16 @@ def _configOverlayParser(ovlParser):
         propNames      = list(OPTIONS[target])
         specialOptions = []
         
-        # The VectorOpts.modulate
-        # option needs special treatment
-        if target == VectorOpts and 'modulate' in propNames:
-            specialOptions.append('modulate')
-            propNames.remove('modulate')
+        # The VectorOpts.modulateImage
+        # and clipImage options need
+        # special treatment
+        if target == VectorOpts and 'modulateImage' in propNames:
+            specialOptions.append('modulateImage')
+            propNames.remove('modulateImage')
+
+        if target == VectorOpts and 'clipImage' in propNames:
+            specialOptions.append('clipImage')
+            propNames.remove('clipImage') 
 
         # The same goes for the
         # ModelOpts.refImage option
@@ -870,12 +879,13 @@ def parseArgs(mainParser, argv, name, desc, toolOptsDesc='[options]'):
     # short/long arguments into a 1D list.
     fileOpts = []
 
-    # The VectorOpts.modulate option allows
-    # the user to specify another image file
-    # by which the vector image colours are
-    # to be modulated. The same goes for the
-    # ModelOpts.refImage option
-    fileOpts.extend(ARGUMENTS[fsldisplay.VectorOpts, 'modulate'])
+    # The VectorOpts.modulateImage and
+    # clipImage options allow the user
+    # to specify another image file.
+    # The same goes for the
+    # ModelOpts.refImage option.
+    fileOpts.extend(ARGUMENTS[fsldisplay.VectorOpts, 'modulateImage'])
+    fileOpts.extend(ARGUMENTS[fsldisplay.VectorOpts, 'clipImage'])
     fileOpts.extend(ARGUMENTS[fsldisplay.ModelOpts,  'refImage']) 
 
     # There is a possibility that the user
@@ -1156,12 +1166,13 @@ def applyOverlayArgs(args, overlayList, displayCtx, **kwargs):
         # DisplayOpts instance will be replaced
         opts = display.getDisplayOpts()
 
-        # VectorOpts.modulate is a Choice property,
-        # where the valid choices are defined by
-        # the current contents of the overlay list.
-        # So when the user specifies a modulation
-        # image, we need to do an explicit check
-        # to see if the specified image is vaid
+        # VectorOpts.modulateImage and clipImage
+        # are Choice properties, where the valid
+        # choices are defined by the current
+        # contents of the overlay list. So when
+        # the user specifies one of these images,
+        # we need to do an explicit check to see
+        # if the specified image is valid
         # 
         # Here, I'm loading the image, and checking
         # to see if it can be used to modulate the
@@ -1171,10 +1182,10 @@ def applyOverlayArgs(args, overlayList, displayCtx, **kwargs):
         # value. If the modulate file is not valid,
         # an error is raised.
         if isinstance(opts, fsldisplay.VectorOpts) and \
-           args.overlays[i].modulate is not None:
+           args.overlays[i].modulateImage is not None:
 
             modImage = _findOrLoad(overlayList,
-                                   args.overlays[i].modulate,
+                                   args.overlays[i].modulateImage,
                                    fslimage.Image,
                                    overlay)
 
@@ -1183,12 +1194,32 @@ def applyOverlayArgs(args, overlayList, displayCtx, **kwargs):
                     'Image {} cannot be used to modulate {} - '
                     'dimensions don\'t match'.format(modImage, overlay))
 
-            opts.modulate             = modImage
-            args.overlays[i].modulate = None
+            opts.modulateImage             = modImage
+            args.overlays[i].modulateImage = None
 
             log.debug('Set {} to be modulated by {}'.format(
                 overlay, modImage))
 
+        # Same process for VectorOpts.clipImage
+        if isinstance(opts, fsldisplay.VectorOpts) and \
+           args.overlays[i].clipImage is not None:
+
+            clipImage = _findOrLoad(overlayList,
+                                    args.overlays[i].clipImage,
+                                    fslimage.Image,
+                                    overlay)
+
+            if clipImage.shape != overlay.shape[ :3]:
+                raise RuntimeError(
+                    'Image {} cannot be used to clip {} - '
+                    'dimensions don\'t match'.format(clipImage, overlay))
+
+            opts.clipImage                 = clipImage
+            args.overlays[i].modulateImage = None
+
+            log.debug('Set {} to be clipped by {}'.format(
+                overlay, clipImage))
+ 
         # A similar process is followed for 
         # the ModelOpts.refImage property
         if isinstance(overlay, fslmodel.Model)        and \
diff --git a/fsl/fsleyes/gl/gl21/glrgbvector_funcs.py b/fsl/fsleyes/gl/gl21/glrgbvector_funcs.py
index ab0c9b7b7..d500cfec9 100644
--- a/fsl/fsleyes/gl/gl21/glrgbvector_funcs.py
+++ b/fsl/fsleyes/gl/gl21/glrgbvector_funcs.py
@@ -60,60 +60,67 @@ def compileShaders(self):
     
     self.shaders = shaders.compileShaders(vertShaderSrc, fragShaderSrc)
 
-    self.vertexPos          = gl.glGetAttribLocation( self.shaders,
-                                                      'vertex')
-    self.voxCoordPos        = gl.glGetAttribLocation( self.shaders,
-                                                      'voxCoord')
-    self.texCoordPos        = gl.glGetAttribLocation( self.shaders,
-                                                      'texCoord') 
-    self.imageTexturePos    = gl.glGetUniformLocation(self.shaders,
-                                                      'imageTexture')
-    self.modTexturePos      = gl.glGetUniformLocation(self.shaders,
-                                                      'modTexture')
-    self.xColourTexturePos  = gl.glGetUniformLocation(self.shaders,
-                                                      'xColourTexture')
-    self.yColourTexturePos  = gl.glGetUniformLocation(self.shaders,
-                                                      'yColourTexture') 
-    self.zColourTexturePos  = gl.glGetUniformLocation(self.shaders,
-                                                      'zColourTexture')
-    self.modThresholdPos    = gl.glGetUniformLocation(self.shaders,
-                                                      'modThreshold') 
-    self.useSplinePos       = gl.glGetUniformLocation(self.shaders,
-                                                      'useSpline')
-    self.imageShapePos      = gl.glGetUniformLocation(self.shaders,
-                                                      'imageShape')
-    self.voxValXformPos     = gl.glGetUniformLocation(self.shaders,
-                                                      'voxValXform')
-    self.cmapXformPos       = gl.glGetUniformLocation(self.shaders,
-                                                      'cmapXform') 
+    shaderVars   = {}
+
+    vertUniforms = []
+    vertAtts     = ['vertex', 'voxCoord', 'texCoord']
+
+    fragUniforms = ['imageTexture',   'modulateTexture', 'clipTexture',
+                    'clipThreshold',  'xColourTexture',  'yColourTexture',
+                    'zColourTexture', 'voxValXform',     'cmapXform',
+                    'imageShape',     'useSpline']
+
+    for va in vertAtts:
+        shaderVars[va] = gl.glGetAttribLocation(self.shaders, va)
+        
+    for vu in vertUniforms:
+        shaderVars[va] = gl.glGetUniformLocation(self.shaders, vu)
+
+    for fu in fragUniforms:
+        if fu in shaderVars:
+            continue
+        shaderVars[fu] = gl.glGetUniformLocation(self.shaders, fu)
+
+    self.shaderVars = shaderVars
 
 
 def updateShaderState(self):
     """Updates all shader program variables. """
 
-    opts = self.displayOpts
+    opts  = self.displayOpts
+    svars = self.shaderVars
 
     # The coordinate transformation matrices for 
     # each of the three colour textures are identical
-    voxValXform = self.imageTexture.voxValXform
-    cmapXform   = self.xColourTexture.getCoordinateTransform()
-    useSpline   = opts.interpolation == 'spline'
-    imageShape  = np.array(self.vectorImage.shape, dtype=np.float32)
-
+    voxValXform     = self.imageTexture.voxValXform
+    invClipValXform = self.clipTexture .invVoxValXform
+    cmapXform       = self.xColourTexture.getCoordinateTransform()
+    useSpline       = opts.interpolation == 'spline'
+    imageShape      = np.array(self.vectorImage.shape, dtype=np.float32)
+    clipThreshold   = opts.clipThreshold
+
+    # Transform the clip threshold into
+    # the texture value range, so the
+    # fragment shader can compare texture
+    # values directly to it.
+    clipThreshold = clipThreshold * invClipValXform[0, 0] + \
+                                    invClipValXform[3, 0]
+    
     gl.glUseProgram(self.shaders)
 
-    gl.glUniform1f( self.useSplinePos,     useSpline)
-    gl.glUniform3fv(self.imageShapePos, 1, imageShape)
+    gl.glUniform1f( svars['useSpline'],     useSpline)
+    gl.glUniform3fv(svars['imageShape'], 1, imageShape)
 
-    gl.glUniformMatrix4fv(self.voxValXformPos, 1, False, voxValXform)
-    gl.glUniformMatrix4fv(self.cmapXformPos,   1, False, cmapXform)
+    gl.glUniformMatrix4fv(svars['voxValXform'], 1, False, voxValXform)
+    gl.glUniformMatrix4fv(svars['cmapXform'],   1, False, cmapXform)
 
-    gl.glUniform1f(self.modThresholdPos,   opts.modThreshold / 100.0)
-    gl.glUniform1i(self.imageTexturePos,   0)
-    gl.glUniform1i(self.modTexturePos,     1)
-    gl.glUniform1i(self.xColourTexturePos, 2)
-    gl.glUniform1i(self.yColourTexturePos, 3)
-    gl.glUniform1i(self.zColourTexturePos, 4)
+    gl.glUniform1f(svars['clipThreshold'],   clipThreshold)
+    gl.glUniform1i(svars['imageTexture'],    0)
+    gl.glUniform1i(svars['modulateTexture'], 1)
+    gl.glUniform1i(svars['clipTexture'],     2)
+    gl.glUniform1i(svars['xColourTexture'],  3)
+    gl.glUniform1i(svars['yColourTexture'],  4)
+    gl.glUniform1i(svars['zColourTexture'],  5)
 
     gl.glUseProgram(0)
 
diff --git a/fsl/fsleyes/gl/gl21/gltensor_funcs.py b/fsl/fsleyes/gl/gl21/gltensor_funcs.py
index c999c31e8..9d1506a31 100644
--- a/fsl/fsleyes/gl/gl21/gltensor_funcs.py
+++ b/fsl/fsleyes/gl/gl21/gltensor_funcs.py
@@ -103,6 +103,8 @@ def compileShaders(self):
     
     self.shaders = shaders.compileShaders(vertShaderSrc, fragShaderSrc)
 
+    shaderVars = {}
+
     vertUniforms = ['v1Texture',       'v2Texture',  'v3Texture',
                     'l1Texture',       'l2Texture',  'l3Texture',
                     'v1ValXform',      'v2ValXform', 'v3ValXform',
@@ -113,46 +115,47 @@ def compileShaders(self):
 
     vertAtts     = ['voxel', 'vertex']
 
-    fragUniforms = ['imageTexture',   'modTexture',     'modThreshold',
-                    'xColourTexture', 'yColourTexture', 'zColourTexture',
-                    'voxValXform',    'cmapXform',      'imageShape',
-                    'useSpline']
+    fragUniforms = ['imageTexture',   'modulateTexture', 'clipTexture',
+                    'clipThreshold',  'xColourTexture',  'yColourTexture',
+                    'zColourTexture', 'voxValXform',     'cmapXform',
+                    'imageShape',     'useSpline']
 
     for vu in vertUniforms:
-        loc = gl.glGetUniformLocation(self.shaders, vu)
-        setattr(self, '{}Pos'.format(vu), loc)
+        shaderVars[vu] = gl.glGetUniformLocation(self.shaders, vu)
+        
+    for va in vertAtts:
+        shaderVars[va] = gl.glGetAttribLocation(self.shaders, va)
 
     for fu in fragUniforms:
-        if hasattr(self, '{}Pos'.format(fu)):
+        if fu in shaderVars:
             continue
-        loc = gl.glGetUniformLocation(self.shaders, fu)
-        setattr(self, '{}Pos'.format(fu), loc)
+        shaderVars[fu] = gl.glGetUniformLocation(self.shaders, fu)
 
-    for va in vertAtts:
-        loc = gl.glGetAttribLocation(self.shaders, va)
-        setattr(self, '{}Pos'.format(va), loc)
+    self.shaderVars = shaderVars
 
 
 def updateShaderState(self):
 
     gl.glUseProgram(self.shaders)
 
-    opts = self.displayOpts
+    opts  = self.displayOpts
+    svars = self.shaderVars
 
     # Textures used by the fragment shader
-    gl.glUniform1i(self.imageTexturePos,   0)
-    gl.glUniform1i(self.modTexturePos,     1)
-    gl.glUniform1i(self.xColourTexturePos, 2)
-    gl.glUniform1i(self.yColourTexturePos, 3)
-    gl.glUniform1i(self.zColourTexturePos, 4)
+    gl.glUniform1i(svars['imageTexture'],    0)
+    gl.glUniform1i(svars['modulateTexture'], 1)
+    gl.glUniform1i(svars['clipTexture'],     2)
+    gl.glUniform1i(svars['xColourTexture'],  3)
+    gl.glUniform1i(svars['yColourTexture'],  4)
+    gl.glUniform1i(svars['zColourTexture'],  5)
 
     # Textures used by the vertex shader
-    gl.glUniform1i(self.v1TexturePos, 5)
-    gl.glUniform1i(self.v2TexturePos, 6)
-    gl.glUniform1i(self.v3TexturePos, 7)
-    gl.glUniform1i(self.l1TexturePos, 8)
-    gl.glUniform1i(self.l2TexturePos, 9)
-    gl.glUniform1i(self.l3TexturePos, 10)
+    gl.glUniform1i(svars['v1Texture'], 6)
+    gl.glUniform1i(svars['v2Texture'], 7)
+    gl.glUniform1i(svars['v3Texture'], 8)
+    gl.glUniform1i(svars['l1Texture'], 9)
+    gl.glUniform1i(svars['l2Texture'], 10)
+    gl.glUniform1i(svars['l3Texture'], 11)
 
     # Texture -> value value offsets/scales
     # used by the vertex and fragment shaders
@@ -174,31 +177,35 @@ def updateShaderState(self):
     l2ValXform  = np.array(l2ValXform,  dtype=np.float32).ravel('C')
     l3ValXform  = np.array(l3ValXform,  dtype=np.float32).ravel('C')
     
-    gl.glUniformMatrix4fv(self.voxValXformPos, 1, False, voxValXform)
-    gl.glUniformMatrix4fv(self.cmapXformPos,   1, False, cmapXform)
-    gl.glUniformMatrix4fv(self.v1ValXformPos,  1, False, v1ValXform)
-    gl.glUniformMatrix4fv(self.v2ValXformPos,  1, False, v2ValXform)
-    gl.glUniformMatrix4fv(self.v3ValXformPos,  1, False, v3ValXform)
-    gl.glUniformMatrix4fv(self.l1ValXformPos,  1, False, l1ValXform)
-    gl.glUniformMatrix4fv(self.l2ValXformPos,  1, False, l2ValXform)
-    gl.glUniformMatrix4fv(self.l3ValXformPos,  1, False, l3ValXform)
+    gl.glUniformMatrix4fv(svars['voxValXform'], 1, False, voxValXform)
+    gl.glUniformMatrix4fv(svars['cmapXform'],   1, False, cmapXform)
+    gl.glUniformMatrix4fv(svars['v1ValXform'],  1, False, v1ValXform)
+    gl.glUniformMatrix4fv(svars['v2ValXform'],  1, False, v2ValXform)
+    gl.glUniformMatrix4fv(svars['v3ValXform'],  1, False, v3ValXform)
+    gl.glUniformMatrix4fv(svars['l1ValXform'],  1, False, l1ValXform)
+    gl.glUniformMatrix4fv(svars['l2ValXform'],  1, False, l2ValXform)
+    gl.glUniformMatrix4fv(svars['l3ValXform'],  1, False, l3ValXform)
 
     # Other miscellaneous uniforms
-    imageShape   = np.array(self.image.shape[:3], dtype=np.float32)
-    resolution   = opts.tensorResolution
-    modThreshold = opts.modThreshold / 100.0
-    lighting     = 1 if opts.lighting else 0
-    useSpline    = 0
-
-    l1           = self.image. L1()
-    eigValNorm   = 0.5 / abs(l1.data).max()
-
-    gl.glUniform3fv(self.imageShapePos, 1, imageShape)
-    gl.glUniform1f( self.resolutionPos,    resolution)
-    gl.glUniform1f( self.eigValNormPos,    eigValNorm)
-    gl.glUniform1f( self.lightingPos,      lighting)
-    gl.glUniform1f( self.modThresholdPos,  modThreshold)
-    gl.glUniform1f( self.useSplinePos,     useSpline)
+    imageShape    = np.array(self.image.shape[:3], dtype=np.float32)
+    resolution    = opts.tensorResolution
+    clipThreshold = opts.clipThreshold
+    lighting      = 1 if opts.lighting else 0
+    useSpline     = 0
+
+    l1         = self.image.L1()
+    eigValNorm = 0.5 / abs(l1.data).max()
+
+    invClipValXform = self.clipTexture .invVoxValXform
+    clipThreshold   = clipThreshold * invClipValXform[0, 0] + \
+                                      invClipValXform[3, 0] 
+
+    gl.glUniform3fv(svars['imageShape'], 1,  imageShape)
+    gl.glUniform1f( svars['resolution'],     resolution)
+    gl.glUniform1f( svars['eigValNorm'],     eigValNorm)
+    gl.glUniform1f( svars['lighting'],       lighting)
+    gl.glUniform1f( svars['clipThreshold'],  clipThreshold)
+    gl.glUniform1f( svars['useSpline'],      useSpline)
     
     # Vertices of a unit sphere. The vertex
     # shader will transform these vertices
@@ -229,6 +236,8 @@ def preDraw(self):
     """
     gl.glUseProgram(self.shaders)
 
+    svars = self.shaderVars
+
     # Define the light position in
     # the world coordinate system
     lightPos = np.array([1, 1, -1], dtype=np.float32)
@@ -246,19 +255,19 @@ def preDraw(self):
     # normal vectors - T(I(MV matrix))
     normalMatrix = npla.inv(mvMat).T
 
-    gl.glUniform1f(       self.zaxPos,                    self.zax)
-    gl.glUniform3fv(      self.lightPosPos,     1,        lightPos)
-    gl.glUniformMatrix3fv(self.normalMatrixPos, 1, False, normalMatrix) 
+    gl.glUniform1f(       svars['zax'],                    self.zax)
+    gl.glUniform3fv(      svars['lightPos'],     1,        lightPos)
+    gl.glUniformMatrix3fv(svars['normalMatrix'], 1, False, normalMatrix) 
     
-    self.v1Texture.bindTexture(gl.GL_TEXTURE5)
-    self.v2Texture.bindTexture(gl.GL_TEXTURE6)
-    self.v3Texture.bindTexture(gl.GL_TEXTURE7)
-    self.l1Texture.bindTexture(gl.GL_TEXTURE8)
-    self.l2Texture.bindTexture(gl.GL_TEXTURE9)
-    self.l3Texture.bindTexture(gl.GL_TEXTURE10)
+    self.v1Texture.bindTexture(gl.GL_TEXTURE6)
+    self.v2Texture.bindTexture(gl.GL_TEXTURE7)
+    self.v3Texture.bindTexture(gl.GL_TEXTURE8)
+    self.l1Texture.bindTexture(gl.GL_TEXTURE9)
+    self.l2Texture.bindTexture(gl.GL_TEXTURE10)
+    self.l3Texture.bindTexture(gl.GL_TEXTURE11)
     
-    gl.glEnableVertexAttribArray(self.voxelPos)
-    gl.glEnableVertexAttribArray(self.vertexPos)
+    gl.glEnableVertexAttribArray(svars['voxel'])
+    gl.glEnableVertexAttribArray(svars['vertex'])
 
     gl.glEnable(gl.GL_CULL_FACE)
     gl.glCullFace(gl.GL_BACK)
@@ -268,6 +277,7 @@ def draw(self, zpos, xform=None):
 
     image  = self.image
     opts   = self.displayOpts
+    svars  = self.shaderVars
     v2dMat = opts.getTransform('voxel',   'display')
     d2vMat = opts.getTransform('display', 'voxel')
 
@@ -297,21 +307,21 @@ def draw(self, zpos, xform=None):
     gl.glBufferData(
         gl.GL_ARRAY_BUFFER, voxels.nbytes, voxels, gl.GL_STATIC_DRAW)
     gl.glVertexAttribPointer(
-        self.voxelPos, 3, gl.GL_FLOAT, gl.GL_FALSE, 0, None)
+        svars['voxel'], 3, gl.GL_FLOAT, gl.GL_FALSE, 0, None)
 
     # Use one set of voxel coordinates for every sphere drawn
-    arbia.glVertexAttribDivisorARB(self.voxelPos, 1)
+    arbia.glVertexAttribDivisorARB(svars['voxel'], 1)
 
     # Bind the vertex buffer
     gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.vertexBuffer)
     gl.glVertexAttribPointer(
-        self.vertexPos, 3, gl.GL_FLOAT, gl.GL_FALSE, 0, None)
+        svars['vertex'], 3, gl.GL_FLOAT, gl.GL_FALSE, 0, None)
 
     if xform is None: xform = v2dMat
     else:             xform = transform.concat(v2dMat, xform)
     
     xform = np.array(xform, dtype=np.float32).ravel('C') 
-    gl.glUniformMatrix4fv(self.voxToDisplayMatPos, 1, False, xform)
+    gl.glUniformMatrix4fv(svars['voxToDisplayMat'], 1, False, xform)
 
     # And the vertex index buffer
     gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, self.indexBuffer)
@@ -321,6 +331,9 @@ def draw(self, zpos, xform=None):
 
 
 def postDraw(self):
+    
+    svars = self.shaderVars
+    
     gl.glUseProgram(0)
 
     gl.glDisable(gl.GL_CULL_FACE)
@@ -335,5 +348,5 @@ def postDraw(self):
     self.l2Texture.unbindTexture()
     self.l3Texture.unbindTexture()
     
-    gl.glDisableVertexAttribArray(self.voxelPos)
-    gl.glDisableVertexAttribArray(self.vertexPos)
+    gl.glDisableVertexAttribArray(svars['voxel'])
+    gl.glDisableVertexAttribArray(svars['vertex'])
diff --git a/fsl/fsleyes/gl/gl21/glvector_frag.glsl b/fsl/fsleyes/gl/gl21/glvector_frag.glsl
index dabae9d8e..6dd2fe212 100644
--- a/fsl/fsleyes/gl/gl21/glvector_frag.glsl
+++ b/fsl/fsleyes/gl/gl21/glvector_frag.glsl
@@ -18,14 +18,21 @@ uniform sampler3D imageTexture;
  * Modulation texture containing values by
  * which the vector colours are to be modulated.
  */
-uniform sampler3D modTexture;
+uniform sampler3D modulateTexture;
+
+
+/*
+ * Texture containing values which determine
+ * whether a vector voxel should be clipped.
+ */
+uniform sampler3D clipTexture;
+
 
 /*
- * If the modulation value is below this
- * threshold, the fragment is made
- * transparent.
+ * If the clipping value is below this
+ * threshold, the fragment is clipped.
  */
-uniform float modThreshold;
+uniform float clipThreshold;
 
 /*
  * Colour map for the X vector component.
@@ -106,14 +113,23 @@ void main(void) {
     voxValue = texture3D(imageTexture, fragTexCoord).xyz;
   }
 
-  /* Look up the modulation value */
+  /* Look up the modulation and clipping values */
   float modValue;
+  float clipValue;
   if (useSpline) {
-    modValue = spline_interp(modTexture, fragTexCoord, imageShape, 0);
+    modValue  = spline_interp(modulateTexture, fragTexCoord, imageShape, 0);
+    clipValue = spline_interp(clipTexture,     fragTexCoord, imageShape, 0);
   }
   else {
-    modValue = texture3D(modTexture, fragTexCoord).x;
-  }  
+    modValue  = texture3D(modulateTexture, fragTexCoord).x;
+    clipValue = texture3D(clipTexture,     fragTexCoord).x;
+  }
+
+  /* Knock out voxels where the clipping value is below the threshold */
+  if (clipValue < clipThreshold) {
+      gl_FragColor.a = 0.0;
+      return;
+  }
 
   /*
    * Transform the voxel texture values 
@@ -140,9 +156,5 @@ void main(void) {
   /* Take the highest alpha of the three colour maps */
   voxColour.a = max(max(xColour.a, yColour.a), zColour.a);
 
-  /* Knock out voxels where the modulation value is below the threshold */
-  if (modValue < modThreshold)
-      voxColour.a = 0.0;
-
   gl_FragColor = voxColour * fragColourFactor;
 }
diff --git a/fsl/fsleyes/gl/gl21/glvolume_funcs.py b/fsl/fsleyes/gl/gl21/glvolume_funcs.py
index ce9fffc15..95b2c39a4 100644
--- a/fsl/fsleyes/gl/gl21/glvolume_funcs.py
+++ b/fsl/fsleyes/gl/gl21/glvolume_funcs.py
@@ -57,35 +57,23 @@ def compileShaders(self):
     fragShaderSrc = shaders.getFragmentShader(self)
     self.shaders = shaders.compileShaders(vertShaderSrc, fragShaderSrc)
 
-    # indices of all vertex/fragment shader parameters
-    self.vertexPos           = gl.glGetAttribLocation( self.shaders,
-                                                       'vertex')
-    self.voxCoordPos         = gl.glGetAttribLocation( self.shaders,
-                                                       'voxCoord')
-    self.texCoordPos         = gl.glGetAttribLocation( self.shaders,
-                                                       'texCoord') 
-    self.imageTexturePos     = gl.glGetUniformLocation(self.shaders,
-                                                       'imageTexture')
-    self.colourTexturePos    = gl.glGetUniformLocation(self.shaders,
-                                                       'colourTexture')
-    self.negColourTexturePos = gl.glGetUniformLocation(self.shaders,
-                                                       'negColourTexture') 
-    self.useNegCmapPos       = gl.glGetUniformLocation(self.shaders,
-                                                       'useNegCmap')
-    self.texZeroPos          = gl.glGetUniformLocation(self.shaders,
-                                                       'texZero') 
-    self.imageShapePos       = gl.glGetUniformLocation(self.shaders,
-                                                       'imageShape')
-    self.useSplinePos        = gl.glGetUniformLocation(self.shaders,
-                                                       'useSpline')
-    self.voxValXformPos      = gl.glGetUniformLocation(self.shaders,
-                                                       'voxValXform')
-    self.clipLowPos          = gl.glGetUniformLocation(self.shaders,
-                                                       'clipLow')
-    self.clipHighPos         = gl.glGetUniformLocation(self.shaders,
-                                                       'clipHigh')
-    self.invertClipPos       = gl.glGetUniformLocation(self.shaders,
-                                                       'invertClip')
+    shaderVars = {}
+
+    vertAtts     = ['vertex',       'voxCoord',      'texCoord']
+    fragUniforms = ['imageTexture', 'colourTexture', 'negColourTexture',
+                    'useNegCmap',   'imageShape',    'useSpline',
+                    'voxValXform',  'clipLow',       'clipHigh',
+                    'texZero',      'invertClip']
+
+    for va in vertAtts:
+        shaderVars[va] = gl.glGetAttribLocation(self.shaders, va)
+        
+    for fu in fragUniforms:
+        if fu in shaderVars:
+            continue
+        shaderVars[fu] = gl.glGetUniformLocation(self.shaders, fu)
+
+    self.shaderVars = shaderVars
 
 
 def updateShaderState(self):
@@ -93,16 +81,10 @@ def updateShaderState(self):
     current display properties.
     """
 
-    opts = self.displayOpts
+    opts  = self.displayOpts
+    svars = self.shaderVars
 
     gl.glUseProgram(self.shaders)
-    
-    # bind the current interpolation setting,
-    # image shape, and image->screen axis
-    # mappings
-    gl.glUniform1f( self.useSplinePos,     opts.interpolation == 'spline')
-    gl.glUniform3fv(self.imageShapePos, 1, np.array(self.image.shape,
-                                                     dtype=np.float32))
 
     # The clipping range options are in the voxel value
     # range, but the shader needs them to be in image
@@ -113,12 +95,6 @@ def updateShaderState(self):
     clipHigh = opts.clippingRange[1] * xform[0, 0] + xform[3, 0]
     texZero  = 0.0                   * xform[0, 0] + xform[3, 0]
 
-    gl.glUniform1f(self.clipLowPos,    clipLow)
-    gl.glUniform1f(self.clipHighPos,   clipHigh)
-    gl.glUniform1f(self.texZeroPos,    texZero)
-    gl.glUniform1f(self.invertClipPos, opts.invertClipping)
-    gl.glUniform1f(self.useNegCmapPos, opts.useNegativeCmap)
-    
     # Bind transformation matrix to transform
     # from image texture values to voxel values,
     # and and to scale said voxel values to
@@ -126,13 +102,27 @@ def updateShaderState(self):
     vvx = transform.concat(self.imageTexture.voxValXform,
                            self.colourTexture.getCoordinateTransform())
     vvx = np.array(vvx, dtype=np.float32).ravel('C')
-    
-    gl.glUniformMatrix4fv(self.voxValXformPos, 1, False, vvx)
+
+
+    # bind the current interpolation setting,
+    # image shape, and image->screen axis
+    # mappings
+    gl.glUniform1f( svars['useSpline'],     opts.interpolation == 'spline')
+    gl.glUniform3fv(svars['imageShape'], 1, np.array(self.image.shape,
+                                                     dtype=np.float32))
+
+    gl.glUniform1f(svars['clipLow'],    clipLow)
+    gl.glUniform1f(svars['clipHigh'],   clipHigh)
+    gl.glUniform1f(svars['texZero'],    texZero)
+    gl.glUniform1f(svars['invertClip'], opts.invertClipping)
+    gl.glUniform1f(svars['useNegCmap'], opts.useNegativeCmap)
+ 
+    gl.glUniformMatrix4fv(svars['voxValXform'], 1, False, vvx)
 
     # Set up the colour and image textures
-    gl.glUniform1i(self.imageTexturePos,     0)
-    gl.glUniform1i(self.colourTexturePos,    1)
-    gl.glUniform1i(self.negColourTexturePos, 2)
+    gl.glUniform1i(svars['imageTexture'],     0)
+    gl.glUniform1i(svars['colourTexture'],    1)
+    gl.glUniform1i(svars['negColourTexture'], 2)
 
     gl.glUseProgram(0)
 
@@ -153,9 +143,9 @@ def _prepareVertexAttributes(self, vertices, voxCoords, texCoords):
     """
 
     buf    = np.zeros((vertices.shape[0] * 3, 3), dtype=np.float32)
-    verPos = self.vertexPos
-    voxPos = self.voxCoordPos
-    texPos = self.texCoordPos
+    verPos = self.shaderVars['vertex']
+    voxPos = self.shaderVars['voxCoord']
+    texPos = self.shaderVars['texCoord']
 
     # We store each of the three coordinate
     # sets in a single interleaved buffer
@@ -174,10 +164,10 @@ def _prepareVertexAttributes(self, vertices, voxCoords, texCoords):
         texPos, 3, gl.GL_FLOAT, gl.GL_FALSE, 36, ctypes.c_void_p(24))
     gl.glVertexAttribPointer(
         voxPos, 3, gl.GL_FLOAT, gl.GL_FALSE, 36, ctypes.c_void_p(12))
-    gl.glEnableVertexAttribArray(self.voxCoordPos)
-
-    gl.glEnableVertexAttribArray(self.vertexPos)
-    gl.glEnableVertexAttribArray(self.texCoordPos) 
+    
+    gl.glEnableVertexAttribArray(voxPos)
+    gl.glEnableVertexAttribArray(verPos)
+    gl.glEnableVertexAttribArray(texPos) 
 
     
 def draw(self, zpos, xform=None):
@@ -222,9 +212,9 @@ def postDraw(self):
     instance.
     """
 
-    gl.glDisableVertexAttribArray(self.vertexPos)
-    gl.glDisableVertexAttribArray(self.texCoordPos)
-    gl.glDisableVertexAttribArray(self.voxCoordPos)
+    gl.glDisableVertexAttribArray(self.shaderVars['vertex'])
+    gl.glDisableVertexAttribArray(self.shaderVars['texCoord'])
+    gl.glDisableVertexAttribArray(self.shaderVars['voxCoord'])
     
     gl.glBindBuffer(gl.GL_ARRAY_BUFFER, 0)
     gl.glUseProgram(0)
diff --git a/fsl/fsleyes/gl/glvector.py b/fsl/fsleyes/gl/glvector.py
index 6f6cdd0de..dbcccb828 100644
--- a/fsl/fsleyes/gl/glvector.py
+++ b/fsl/fsleyes/gl/glvector.py
@@ -27,9 +27,10 @@ class GLVector(globject.GLImageObject):
 
 
     The ``GLVector`` class is a base class which is not intended to be
-    instantiated directly. The :class:`.GLRGBVector` and
-    :class:`.GLLineVector` subclasses should be used instead.  These two
-    subclasses share the functionality provided by this class.
+    instantiated directly. The :class:`.GLRGBVector`,
+    :class:`.GLLineVector` and :class:`.GLTensor` subclasses should be
+    used instead.  These subclasses share the functionality provided
+    by this class.
 
 
     The :class:`.Image` is stored on the GPU as a 3D RGB
@@ -46,8 +47,13 @@ class GLVector(globject.GLImageObject):
 
 
     The colour of each vector may be modulated by another image, specified by
-    the :attr:`.VectorOpts.modulate` property.  This modulation image is
+    the :attr:`.VectorOpts.modulateImage` property.  This modulation image is
     stored as a 3D single-channel :class:`.ImageTexture`.
+
+    
+    Vector voxels may be clipped according to the values of another image,
+    specified by the :attr:`.VectorOpts.clipImage` property.  This clipping
+    image is stored as a 3D single-channel :class:`.ImageTexture`. 
     """
 
     
@@ -94,19 +100,23 @@ class GLVector(globject.GLImageObject):
 
         name = self.name
 
-        self.vectorImage    = vectorImage
-        self.xColourTexture = textures.ColourMapTexture('{}_x'.format(name))
-        self.yColourTexture = textures.ColourMapTexture('{}_y'.format(name))
-        self.zColourTexture = textures.ColourMapTexture('{}_z'.format(name))
-        self.modImage       = None
-        self.modOpts        = None
-        self.modTexture     = None
-        self.imageTexture   = None
-        self.prefilter      = prefilter
+        self.vectorImage     = vectorImage
+        self.xColourTexture  = textures.ColourMapTexture('{}_x'.format(name))
+        self.yColourTexture  = textures.ColourMapTexture('{}_y'.format(name))
+        self.zColourTexture  = textures.ColourMapTexture('{}_z'.format(name))
+        self.modulateImage   = None
+        self.clipImage       = None
+        self.modulateOpts    = None
+        self.clipOpts        = None
+        self.modulateTexture = None
+        self.clipTexture     = None
+        self.imageTexture    = None
+        self.prefilter       = prefilter
 
         self.addListeners()
         self.refreshImageTexture()
-        self.refreshModulateTexture()
+        self.refreshModClipTexture('modulate')
+        self.refreshModClipTexture('clip')
         self.refreshColourTextures()
 
         
@@ -120,16 +130,21 @@ class GLVector(globject.GLImageObject):
         self.yColourTexture.destroy()
         self.zColourTexture.destroy()
 
-        glresources.delete(self.imageTexture.getTextureName())
-        glresources.delete(self.modTexture  .getTextureName())
+        glresources.delete(self.imageTexture   .getTextureName())
+        glresources.delete(self.modulateTexture.getTextureName())
+        glresources.delete(self.clipTexture    .getTextureName())
 
         self.removeListeners()
-        self.deregisterModulateImage()
+        self.deregisterModClipImage('modulate')
+        self.deregisterModClipImage('clip')
 
-        self.imageTexture = None
-        self.modTexture   = None
-        self.modImage     = None
-        self.modOpts      = None
+        self.imageTexture    = None
+        self.modulateTexture = None
+        self.clipTexture     = None
+        self.modulateImage   = None
+        self.clipImage       = None
+        self.modulateOpts    = None
+        self.clipOpts        = None
 
         globject.GLImageObject.destroy(self)
 
@@ -148,12 +163,19 @@ class GLVector(globject.GLImageObject):
             self.onUpdate()
         
         def modUpdate( *a):
-            self.deregisterModulateImage()
-            self.registerModulateImage() 
-            self.refreshModulateTexture()
+            self.deregisterModClipImage('modulate')
+            self.registerModClipImage('modulate') 
+            self.refreshModClipTexture('modulate')
             self.updateShaderState()
             self.onUpdate()
 
+        def clipUpdate( *a):
+            self.deregisterModClipImage('clip')
+            self.registerModClipImage('clip')
+            self.refreshModClipTexture('clip')
+            self.updateShaderState()
+            self.onUpdate()
+ 
         def cmapUpdate(*a):
             self.refreshColourTextures()
             self.updateShaderState()
@@ -188,8 +210,9 @@ class GLVector(globject.GLImageObject):
         opts   .addListener('suppressX',     name, cmapUpdate,    weak=False)
         opts   .addListener('suppressY',     name, cmapUpdate,    weak=False)
         opts   .addListener('suppressZ',     name, cmapUpdate,    weak=False)
-        opts   .addListener('modulate',      name, modUpdate,     weak=False)
-        opts   .addListener('modThreshold',  name, shaderUpdate,  weak=False)
+        opts   .addListener('modulateImage', name, modUpdate,     weak=False)
+        opts   .addListener('clipImage',     name, clipUpdate,    weak=False)
+        opts   .addListener('clipThreshold', name, shaderUpdate,  weak=False)
         opts   .addListener('resolution',    name, imageUpdate,   weak=False)
         opts   .addListener('transform',     name, update,        weak=False)
 
@@ -210,20 +233,21 @@ class GLVector(globject.GLImageObject):
         opts    = self.displayOpts
         name    = self.name
 
-        display.removeListener('alpha',        name)
-        display.removeListener('brightness',   name)
-        display.removeListener('contrast',     name)
-        opts   .removeListener('xColour',      name)
-        opts   .removeListener('yColour',      name)
-        opts   .removeListener('zColour',      name)
-        opts   .removeListener('suppressX',    name)
-        opts   .removeListener('suppressY',    name)
-        opts   .removeListener('suppressZ',    name)
-        opts   .removeListener('modulate',     name)
-        opts   .removeListener('modThreshold', name)
-        opts   .removeListener('volume',       name)
-        opts   .removeListener('resolution',   name)
-        opts   .removeListener('transform' ,   name)
+        display.removeListener('alpha',         name)
+        display.removeListener('brightness',    name)
+        display.removeListener('contrast',      name)
+        opts   .removeListener('xColour',       name)
+        opts   .removeListener('yColour',       name)
+        opts   .removeListener('zColour',       name)
+        opts   .removeListener('suppressX',     name)
+        opts   .removeListener('suppressY',     name)
+        opts   .removeListener('suppressZ',     name)
+        opts   .removeListener('modulateImage', name)
+        opts   .removeListener('clipImage',     name)
+        opts   .removeListener('clipThreshold', name)
+        opts   .removeListener('volume',        name)
+        opts   .removeListener('resolution',    name)
+        opts   .removeListener('transform' ,    name)
 
         if self.__syncListenersRegistered:
             opts.removeSyncChangeListener('resolution', name)
@@ -286,50 +310,71 @@ class GLVector(globject.GLImageObject):
                                   '{} subclasses'.format(type(self).__name__))
 
 
-    def registerModulateImage(self):
-        """Called when the :attr:`.VectorOpts.modulate` property changes.
-        Registers a listener with the :attr:`.Nifti1Opts.volume` property
-        of the modulate image, so the modulate texture can be updated when
-        the image volume changes.
+
+    def registerModClipImage(self, which):
+        """Called when the :attr:`.VectorOpts.modulateImage` or
+        :attr:`.VectorOpts.clipImage` properties change. Registers a listener
+        with the :attr:`.Nifti1Opts.volume` property of the modulate/clip
+        image, so the modulate/clip textures can be updated when the image
+        volume changes.
         """
-        
-        modImage = self.displayOpts.modulate
 
-        self.modOpts = None
+        imageAttr = '{}Image'  .format(which)
+        optsAttr  = '{}Opts'   .format(which)
+        texAttr   = '{}Texture'.format(which) 
         
-        if modImage is None or modImage == 'none': self.modImage = None
-        else:                                      self.modImage = modImage
+        image = getattr(self.displayOpts, imageAttr)
+
+        if image is None or image == 'none':
+            image = None
+
+        setattr(self, optsAttr,  None)
+        setattr(self, imageAttr, image)
         
-        if self.modImage is None:
+        if image is None:
             return
 
-        self.modOpts = self.displayOpts.displayCtx.getOpts(modImage) 
+        opts = self.displayOpts.displayCtx.getOpts(image)
+        tex  = getattr(self, texAttr)
+
+        setattr(self, optsAttr, opts)
 
         def volumeChange(*a):
             
-            self.modTexture.set(volume=self.modOpts.volume)
-            self.refreshModulateTexture()
+            tex.set(volume=opts.volume)
+            self.refreshModClipTexture(which)
             self.onUpdate()
 
-        self.modOpts.addListener('volume', self.name, volumeChange, weak=False) 
+        # We set overwrite=True, because
+        # the modulate and clip images may
+        # be the same.
+        opts.addListener('volume',
+                         self.name,
+                         volumeChange,
+                         overwrite=True,
+                         weak=False) 
 
     
-    def deregisterModulateImage(self):
-        """Called when the :attr:`.VectorOpts.modulate` property changes.
+    def deregisterModClipImage(self, which):
+        """Called when the :attr:`.VectorOpts.modulateImage` or
+        :attr:`.VectorOpts.clipImage` properties change.
         Deregisters the :attr:`.Nifti1Opts.volume` listener that was
-        registered in :meth:`registerModulateImage`.
-        """ 
+        registered in :meth:`registerModClipImage`.
+        """
 
-        if self.modImage is None:
-            return
+        imageAttr = '{}Image'.format(which)
+        optsAttr  = '{}Opts' .format(which)
 
-        self.modOpts.removeListener('volume', self.name)
+        opts = getattr(self, optsAttr)
 
-        self.modImage = None
-        self.modOpts  = None
+        if opts is not None:
+            opts.removeListener('volume', self.name)
 
+        setattr(self, imageAttr, None)
+        setattr(self, optsAttr,  None)
+ 
             
-    def refreshModulateTexture(self):
+    def refreshModClipTexture(self, which):
         """Called when the :attr`.VectorOpts.modulate` property changes.
 
         Reconfigures the modulation :class:`.ImageTexture`. If no modulation
@@ -338,42 +383,49 @@ class GLVector(globject.GLImageObject):
         effect).
         """
 
-        if self.modTexture is not None:
-            glresources.delete(self.modTexture.getTextureName())
+        imageAttr = '{}Image'  .format(which)
+        optsAttr  = '{}Opts'   .format(which)
+        texAttr   = '{}Texture'.format(which)
+
+        image = getattr(self, imageAttr)
+        opts  = getattr(self, optsAttr)
+        tex   = getattr(self, texAttr)
 
-        modImage = self.modImage
-        modOpts  = self.modOpts
+        if tex is not None:
+            glresources.delete(tex.getTextureName())
 
-        if modImage is None or modImage == 'none':
-            textureData = np.zeros((5, 5, 5), dtype=np.uint8)
+        if image is None:
+            textureData    = np.zeros((5, 5, 5), dtype=np.uint8)
             textureData[:] = 255
-            modImage   = fslimage.Image(textureData)
-            norm       = False
+            image          = fslimage.Image(textureData)
+            norm           = False
             
         else:
             norm = True
 
-        texName = '{}_{}_{}_modulate'.format(
-            type(self).__name__, id(self.image), id(modImage))
+        texName = '{}_{}_{}_{}'.format(
+            type(self).__name__, id(self.image), id(image), which)
 
-        if modOpts is not None:
-            unsynced = (modOpts.getParent() is None                or
-                        not modOpts.isSyncedToParent('resolution') or
-                        not modOpts.isSyncedToParent('volume'))
+        if opts is not None:
+            unsynced = (opts.getParent() is None                or
+                        not opts.isSyncedToParent('resolution') or
+                        not opts.isSyncedToParent('volume'))
 
             # TODO If unsynced, this GLVector needs to 
-            # update the modulate texture whenever its
-            # volume/resolution properties change.
+            # update the mod/clip textures whenever
+            # their volume/resolution properties change.
             # Right?
             if unsynced:
-                texName = '{}_unsync_{}'.format(texName, id(modOpts))
+                texName = '{}_unsync_{}'.format(texName, id(opts))
  
-        self.modTexture = glresources.get(
+        tex = glresources.get(
             texName,
             textures.ImageTexture,
             texName,
-            modImage,
+            image,
             normalise=norm)
+        
+        setattr(self, texAttr, tex)
 
 
     def refreshColourTextures(self, colourRes=256):
@@ -432,26 +484,28 @@ class GLVector(globject.GLImageObject):
     def preDraw(self):
         """Must be called by subclass implementations.
 
-        Ensures that the five textures (the vector and modulation images,
-        and the three colour textures) are bound to texture units 0-4
+        Ensures that the six textures (the vector, clip, and modulation
+        images, and the three colour textures) are bound to texture units 0-5
         respectively.
         """
         
-        self.imageTexture  .bindTexture(gl.GL_TEXTURE0)
-        self.modTexture    .bindTexture(gl.GL_TEXTURE1)
-        self.xColourTexture.bindTexture(gl.GL_TEXTURE2)
-        self.yColourTexture.bindTexture(gl.GL_TEXTURE3)
-        self.zColourTexture.bindTexture(gl.GL_TEXTURE4)
+        self.imageTexture   .bindTexture(gl.GL_TEXTURE0)
+        self.modulateTexture.bindTexture(gl.GL_TEXTURE1)
+        self.clipTexture    .bindTexture(gl.GL_TEXTURE2)
+        self.xColourTexture .bindTexture(gl.GL_TEXTURE3)
+        self.yColourTexture .bindTexture(gl.GL_TEXTURE4)
+        self.zColourTexture .bindTexture(gl.GL_TEXTURE5)
 
         
     def postDraw(self):
         """Must be called by subclass implementations.
 
-        Unbindes the five GL textures.
+        Unbindes the six GL textures.
         """
 
-        self.imageTexture  .unbindTexture()
-        self.modTexture    .unbindTexture()
-        self.xColourTexture.unbindTexture()
-        self.yColourTexture.unbindTexture()
-        self.zColourTexture.unbindTexture()
+        self.imageTexture   .unbindTexture()
+        self.modulateTexture.unbindTexture()
+        self.clipTexture    .unbindTexture()
+        self.xColourTexture .unbindTexture()
+        self.yColourTexture .unbindTexture()
+        self.zColourTexture .unbindTexture()
diff --git a/fsl/fsleyes/tooltips.py b/fsl/fsleyes/tooltips.py
index de98df739..37834889d 100644
--- a/fsl/fsleyes/tooltips.py
+++ b/fsl/fsleyes/tooltips.py
@@ -135,19 +135,25 @@ properties = TypeDict({
                                     'colouring voxels.',
     'VectorOpts.suppressZ'        : 'Ignore the Z vector component when '
                                     'colouring voxels.',
-    'VectorOpts.modulate'         : 'Modulate the vector colours by another '
-                                    'image. The image selected here is '
-                                    'normalised to lie in the range (0, 1), '
-                                    'and the magnitude of each vector is '
+    'VectorOpts.modulateImage'    : 'Modulate the vector colour brightness by '
+                                    'another image. The image selected here '
+                                    'is normalised to lie in the range (0, '
+                                    '1), and the magnitude of each vector is '
                                     'scaled by the corresponding modulation '
                                     'value before it is coloured. The '
                                     'modulation image must have the same '
                                     'voxel dimensions as the vector image.',
-    'VectorOpts.modThreshold'     : 'Vector values which have a corresponding '
-                                    'modulation value that is less than this '
-                                    'threshold are not displayed. The '
-                                    'threshold is a proportion of the '
-                                    'modulation image data range.',
+    'VectorOpts.clipImage'        : 'Clip vector voxels according to the '
+                                    'values in another image. Vector voxels '
+                                    'which correspond to values in the '
+                                    'clipping image that have a value less '
+                                    'than the current clipping threshold are '
+                                    'not shown. The clipping image must have '
+                                    'the same voxel dimensions as the vector '
+                                    'image. ', 
+    'VectorOpts.clipThreshold'    : 'Vector values which have a corresponding '
+                                    'clipping image value that is less than '
+                                    'this threshold are not displayed. ',
     'LineVectorOpts.lineWidth'    : 'The width of each vector line, in '
                                     'display pixels.',
     'LineVectorOpts.directed'     : 'If unchecked, the vector data is assumed '
-- 
GitLab