# coding: utf-8
# /*##########################################################################
#
# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""
This module provides base classes to implement models for 3D scene content
"""

from __future__ import absolute_import, division

__authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "24/04/2018"


import functools
import logging
import weakref

import numpy

from silx.third_party import six

from ...utils._image import convertArrayToQImage
from ...colors import preferredColormaps
from ... import qt, icons
from .. import items
from ..items.volume import Isosurface, CutPlane


from .core import AngleDegreeRow, BaseRow, ColorProxyRow, ProxyRow, StaticRow


_logger = logging.getLogger(__name__)


class _DirectionalLightProxy(qt.QObject):
    """Proxy to handle directional light with angles rather than vector.
    """

    sigAzimuthAngleChanged = qt.Signal()
    """Signal sent when the azimuth angle has changed."""

    sigAltitudeAngleChanged = qt.Signal()
    """Signal sent when altitude angle has changed."""

    def __init__(self, light):
        super(_DirectionalLightProxy, self).__init__()
        self._light = light
        light.addListener(self._directionUpdated)
        self._azimuth = 0.
        self._altitude = 0.

    def getAzimuthAngle(self):
        """Returns the signed angle in the horizontal plane.

         Unit: degrees.
        The 0 angle corresponds to the axis perpendicular to the screen.

        :rtype: float
        """
        return self._azimuth

    def getAltitudeAngle(self):
        """Returns the signed vertical angle from the horizontal plane.

        Unit: degrees.
        Range: [-90, +90]

        :rtype: float
        """
        return self._altitude

    def setAzimuthAngle(self, angle):
        """Set the horizontal angle.

        :param float angle: Angle from -z axis in zx plane in degrees.
        """
        if angle != self._azimuth:
            self._azimuth = angle
            self._updateLight()
            self.sigAzimuthAngleChanged.emit()

    def setAltitudeAngle(self, angle):
        """Set the horizontal angle.

        :param float angle: Angle from -z axis in zy plane in degrees.
        """
        if angle != self._altitude:
            self._altitude = angle
            self._updateLight()
            self.sigAltitudeAngleChanged.emit()

    def _directionUpdated(self, *args, **kwargs):
        """Handle light direction update in the scene"""
        # Invert direction to manipulate the 'source' pointing to
        # the center of the viewport
        x, y, z = - self._light.direction

        # Horizontal plane is plane xz
        azimuth = numpy.degrees(numpy.arctan2(x, z))
        altitude = numpy.degrees(numpy.pi/2. - numpy.arccos(y))

        if (abs(azimuth - self.getAzimuthAngle()) > 0.01 and
                abs(abs(altitude) - 90.) >= 0.001):  # Do not update when at zenith
            self.setAzimuthAngle(azimuth)

        if abs(altitude - self.getAltitudeAngle()) > 0.01:
            self.setAltitudeAngle(altitude)

    def _updateLight(self):
        """Update light direction in the scene"""
        azimuth = numpy.radians(self._azimuth)
        delta = numpy.pi/2. - numpy.radians(self._altitude)
        z = - numpy.sin(delta) * numpy.cos(azimuth)
        x = - numpy.sin(delta) * numpy.sin(azimuth)
        y = - numpy.cos(delta)
        self._light.direction = x, y, z


class Settings(StaticRow):
    """Subtree for :class:`SceneWidget` style parameters.

    :param SceneWidget sceneWidget: The widget to control
    """

    def __init__(self, sceneWidget):
        background = ColorProxyRow(
            name='Background',
            fget=sceneWidget.getBackgroundColor,
            fset=sceneWidget.setBackgroundColor,
            notify=sceneWidget.sigStyleChanged)

        foreground = ColorProxyRow(
            name='Foreground',
            fget=sceneWidget.getForegroundColor,
            fset=sceneWidget.setForegroundColor,
            notify=sceneWidget.sigStyleChanged)

        text = ColorProxyRow(
            name='Text',
            fget=sceneWidget.getTextColor,
            fset=sceneWidget.setTextColor,
            notify=sceneWidget.sigStyleChanged)

        highlight = ColorProxyRow(
            name='Highlight',
            fget=sceneWidget.getHighlightColor,
            fset=sceneWidget.setHighlightColor,
            notify=sceneWidget.sigStyleChanged)

        axesIndicator = ProxyRow(
            name='Axes Indicator',
            fget=sceneWidget.isOrientationIndicatorVisible,
            fset=sceneWidget.setOrientationIndicatorVisible,
            notify=sceneWidget.sigStyleChanged)

        # Light direction

        self._lightProxy = _DirectionalLightProxy(sceneWidget.viewport.light)

        azimuthNode = ProxyRow(
            name='Azimuth',
            fget=self._lightProxy.getAzimuthAngle,
            fset=self._lightProxy.setAzimuthAngle,
            notify=self._lightProxy.sigAzimuthAngleChanged,
            editorHint=(-90, 90))

        altitudeNode = ProxyRow(
            name='Altitude',
            fget=self._lightProxy.getAltitudeAngle,
            fset=self._lightProxy.setAltitudeAngle,
            notify=self._lightProxy.sigAltitudeAngleChanged,
            editorHint=(-90, 90))

        lightDirection = StaticRow(('Light Direction', None),
                                   children=(azimuthNode, altitudeNode))

        # Settings row
        children = (background, foreground, text, highlight,
                    axesIndicator, lightDirection)
        super(Settings, self).__init__(('Settings', None), children=children)


class Item3DRow(StaticRow):
    """Represents an :class:`Item3D` with checkable visibility

    :param Item3D item: The scene item to represent.
    :param str name: The optional name of the item
    """

    def __init__(self, item, name=None):
        if name is None:
            name = item.getLabel()
        super(Item3DRow, self).__init__((name, None))

        self.setFlags(
            self.flags(0) | qt.Qt.ItemIsUserCheckable | qt.Qt.ItemIsSelectable,
            0)
        self.setFlags(self.flags(1) | qt.Qt.ItemIsSelectable, 1)

        self._item = weakref.ref(item)
        item.sigItemChanged.connect(self._itemChanged)

    def _itemChanged(self, event):
        """Handle visibility change"""
        if event == items.ItemChangedType.VISIBLE:
            model = self.model()
            if model is not None:
                index = self.index(column=1)
                model.dataChanged.emit(index, index)

    def item(self):
        """Returns the :class:`Item3D` item or None"""
        return self._item()

    def data(self, column, role):
        if column == 0 and role == qt.Qt.CheckStateRole:
            item = self.item()
            if item is not None and item.isVisible():
                return qt.Qt.Checked
            else:
                return qt.Qt.Unchecked
        elif column == 0 and role == qt.Qt.DecorationRole:
            return icons.getQIcon('item-3dim')
        else:
            return super(Item3DRow, self).data(column, role)

    def setData(self, column, value, role):
        if column == 0 and role == qt.Qt.CheckStateRole:
            item = self.item()
            if item is not None:
                item.setVisible(value == qt.Qt.Checked)
                return True
            else:
                return False
        return super(Item3DRow, self).setData(column, value, role)


class DataItem3DBoundingBoxRow(ProxyRow):
    """Represents :class:`DataItem3D` bounding box visibility

    :param DataItem3D item: The item for which to display/control bounding box
    """

    def __init__(self, item):
        super(DataItem3DBoundingBoxRow, self).__init__(
            name='Bounding box',
            fget=item.isBoundingBoxVisible,
            fset=item.setBoundingBoxVisible,
            notify=item.sigItemChanged)


class MatrixProxyRow(ProxyRow):
    """Proxy for a row of a DataItem3D 3x3 matrix transform

    :param DataItem3D item:
    :param int index: Matrix row index
    """

    def __init__(self, item, index):
        self._item = weakref.ref(item)
        self._index = index

        super(MatrixProxyRow, self).__init__(
            name='',
            fget=self._getMatrixRow,
            fset=self._setMatrixRow,
            notify=item.sigItemChanged)

    def _getMatrixRow(self):
        """Returns the matrix row.

        :rtype: QVector3D
        """
        item = self._item()
        if item is not None:
            matrix = item.getMatrix()
            return qt.QVector3D(*matrix[self._index, :])
        else:
            return None

    def _setMatrixRow(self, row):
        """Set the row of the matrix

        :param QVector3D row: Row values to set
        """
        item = self._item()
        if item is not None:
            matrix = item.getMatrix()
            matrix[self._index, :] = row.x(), row.y(), row.z()
            item.setMatrix(matrix)

    def data(self, column, role):
        data = super(MatrixProxyRow, self).data(column, role)

        if column == 1 and role == qt.Qt.DisplayRole:
            # Convert QVector3D to text
            data = "%g; %g; %g" % (data.x(), data.y(), data.z())

        return data


class DataItem3DTransformRow(StaticRow):
    """Represents :class:`DataItem3D` transform parameters

    :param DataItem3D item: The item for which to display/control transform
    """

    _ROTATION_CENTER_OPTIONS = 'Origin', 'Lower', 'Center', 'Upper'

    def __init__(self, item):
        super(DataItem3DTransformRow, self).__init__(('Transform', None))
        self._item = weakref.ref(item)

        translation = ProxyRow(name='Translation',
                               fget=item.getTranslation,
                               fset=self._setTranslation,
                               notify=item.sigItemChanged,
                               toModelData=lambda data: qt.QVector3D(*data))
        self.addRow(translation)

        # Here to keep a reference
        self._xSetCenter = functools.partial(self._setCenter, index=0)
        self._ySetCenter = functools.partial(self._setCenter, index=1)
        self._zSetCenter = functools.partial(self._setCenter, index=2)

        rotateCenter = StaticRow(
            ('Center', None),
            children=(
                ProxyRow(name='X axis',
                         fget=item.getRotationCenter,
                         fset=self._xSetCenter,
                         notify=item.sigItemChanged,
                         toModelData=functools.partial(
                             self._centerToModelData, index=0),
                         editorHint=self._ROTATION_CENTER_OPTIONS),
                ProxyRow(name='Y axis',
                         fget=item.getRotationCenter,
                         fset=self._ySetCenter,
                         notify=item.sigItemChanged,
                         toModelData=functools.partial(
                             self._centerToModelData, index=1),
                         editorHint=self._ROTATION_CENTER_OPTIONS),
                ProxyRow(name='Z axis',
                         fget=item.getRotationCenter,
                         fset=self._zSetCenter,
                         notify=item.sigItemChanged,
                         toModelData=functools.partial(
                             self._centerToModelData, index=2),
                         editorHint=self._ROTATION_CENTER_OPTIONS),
            ))

        rotate = StaticRow(
            ('Rotation', None),
            children=(
                AngleDegreeRow(name='Angle',
                               fget=item.getRotation,
                               fset=self._setAngle,
                               notify=item.sigItemChanged,
                               toModelData=lambda data: data[0]),
                ProxyRow(name='Axis',
                         fget=item.getRotation,
                         fset=self._setAxis,
                         notify=item.sigItemChanged,
                         toModelData=lambda data: qt.QVector3D(*data[1])),
                rotateCenter
            ))
        self.addRow(rotate)

        scale = ProxyRow(name='Scale',
                         fget=item.getScale,
                         fset=self._setScale,
                         notify=item.sigItemChanged,
                         toModelData=lambda data: qt.QVector3D(*data))
        self.addRow(scale)

        matrix = StaticRow(
            ('Matrix', None),
            children=(MatrixProxyRow(item, 0),
                      MatrixProxyRow(item, 1),
                      MatrixProxyRow(item, 2)))
        self.addRow(matrix)

    def item(self):
        """Returns the :class:`Item3D` item or None"""
        return self._item()

    @staticmethod
    def _centerToModelData(center, index):
        """Convert rotation center information from scene to model.

        :param center: The center info from the scene
        :param int index: dimension to convert
        """
        value = center[index]
        if isinstance(value, six.string_types):
            return value.title()
        elif value == 0.:
            return 'Origin'
        else:
            return six.text_type(value)

    def _setCenter(self, value, index):
        """Set one dimension of the rotation center.

        :param value: Value received through the model.
        :param int index: dimension to set
        """
        item = self.item()
        if item is not None:
            if value == 'Origin':
                value = 0.
            elif value not in self._ROTATION_CENTER_OPTIONS:
                value = float(value)
            else:
                value = value.lower()

            center = list(item.getRotationCenter())
            center[index] = value
            item.setRotationCenter(*center)

    def _setAngle(self, angle):
        """Set rotation angle.

        :param float angle:
        """
        item = self.item()
        if item is not None:
            _, axis = item.getRotation()
            item.setRotation(angle, axis)

    def _setAxis(self, axis):
        """Set rotation axis.

        :param QVector3D axis:
        """
        item = self.item()
        if item is not None:
            angle, _ = item.getRotation()
            item.setRotation(angle, (axis.x(), axis.y(), axis.z()))

    def _setTranslation(self, translation):
        """Set translation transform.

        :param QVector3D translation:
        """
        item = self.item()
        if item is not None:
            item.setTranslation(translation.x(), translation.y(), translation.z())

    def _setScale(self, scale):
        """Set scale transform.

        :param QVector3D scale:
        """
        item = self.item()
        if item is not None:
            sx, sy, sz = scale.x(), scale.y(), scale.z()
            if sx == 0. or sy == 0. or sz == 0.:
                _logger.warning('Cannot set scale to 0: ignored')
            else:
                item.setScale(scale.x(), scale.y(), scale.z())


class GroupItemRow(Item3DRow):
    """Represents a :class:`GroupItem` with transforms and children

    :param GroupItem item: The scene group to represent.
    :param str name: The optional name of the group
    """

    _CHILDREN_ROW_OFFSET = 2
    """Number of rows for group parameters. Children are added after"""

    def __init__(self, item, name=None):
        super(GroupItemRow, self).__init__(item, name)
        self.addRow(DataItem3DBoundingBoxRow(item))
        self.addRow(DataItem3DTransformRow(item))

        item.sigItemAdded.connect(self._itemAdded)
        item.sigItemRemoved.connect(self._itemRemoved)

        for child in item.getItems():
            self.addRow(nodeFromItem(child))

    def _itemAdded(self, item):
        """Handle item addition to the group and add it to the model.

        :param Item3D item: added item
        """
        group = self.item()
        if group is None:
            return

        row = group.getItems().index(item)
        self.addRow(nodeFromItem(item), row + self._CHILDREN_ROW_OFFSET)

    def _itemRemoved(self, item):
        """Handle item removal from the group and remove it from the model.

        :param Item3D item: removed item
        """
        group = self.item()
        if group is None:
            return

        # Find item
        for row in self.children():
            if isinstance(row, Item3DRow) and row.item() is item:
                self.removeRow(row)
                break  # Got it
        else:
            raise RuntimeError("Model does not correspond to scene content")


class InterpolationRow(ProxyRow):
    """Represents :class:`InterpolationMixIn` property.

    :param Item3D item: Scene item with interpolation property
    """

    def __init__(self, item):
        modes = [mode.title() for mode in item.INTERPOLATION_MODES]
        super(InterpolationRow, self).__init__(
            name='Interpolation',
            fget=item.getInterpolation,
            fset=item.setInterpolation,
            notify=item.sigItemChanged,
            toModelData=lambda mode: mode.title(),
            fromModelData=lambda mode: mode.lower(),
            editorHint=modes)


class _ColormapBaseProxyRow(ProxyRow):
    """Base class for colormap model row

    This class handle synchronization and signals from the item and the colormap
    """

    _sigColormapChanged = qt.Signal()
    """Signal used internally to notify colormap (or data) update"""

    def __init__(self, item, *args, **kwargs):
        self._dataRange = None
        self._item = weakref.ref(item)
        self._colormap = item.getColormap()

        ProxyRow.__init__(self, *args, **kwargs)

        self._colormap.sigChanged.connect(self._colormapChanged)
        item.sigItemChanged.connect(self._itemChanged)
        self._sigColormapChanged.connect(self._modelUpdated)

    def item(self):
        """Returns the :class:`ColormapMixIn` item or None"""
        return self._item()

    def _getColormapRange(self):
        """Returns the range of the colormap for the current data.

        :return: Colormap range (min, max)
        """
        if self._dataRange is None:
            item = self.item()
            if item is not None and self._colormap is not None:
                if hasattr(item, 'getDataRange'):
                    data = item.getDataRange()
                else:
                    data = item.getData(copy=False)

                self._dataRange = self._colormap.getColormapRange(data)

            else:  # Fallback
                self._dataRange = 1, 100
        return self._dataRange

    def _modelUpdated(self, *args, **kwargs):
        """Emit dataChanged in the model"""
        topLeft = self.index(column=0)
        bottomRight = self.index(column=1)
        model = self.model()
        if model is not None:
            model.dataChanged.emit(topLeft, bottomRight)

    def _colormapChanged(self):
        self._sigColormapChanged.emit()

    def _itemChanged(self, event):
        """Handle change of colormap or data in the item.

        :param ItemChangedType event:
        """
        if event == items.ItemChangedType.COLORMAP:
            self._sigColormapChanged.emit()
            if self._colormap is not None:
                self._colormap.sigChanged.disconnect(self._colormapChanged)

            item = self.item()
            if item is not None:
                self._colormap = item.getColormap()
                self._colormap.sigChanged.connect(self._colormapChanged)
            else:
                self._colormap = None

        elif event == items.ItemChangedType.DATA:
            self._dataRange = None
            self._sigColormapChanged.emit()


class _ColormapBoundRow(_ColormapBaseProxyRow):
    """ProxyRow for colormap min or max

    :param ColormapMixIn item: The item to handle
    :param str name: Name of the raw
    :param int index: 0 for Min and 1 of Max
    """

    def __init__(self, item, name, index):
        self._index = index
        _ColormapBaseProxyRow.__init__(
            self,
            item,
            name=name,
            fget=self._getBound,
            fset=self._setBound)

        self.setToolTip('Colormap %s bound:\n'
                        'Check to set bound manually, '
                        'uncheck for autoscale' % name.lower())

    def _getRawBound(self):
        """Proxy to get raw colormap bound

        :rtype: float or None
        """
        if self._colormap is None:
            return None
        elif self._index == 0:
            return self._colormap.getVMin()
        else:  # self._index == 1
            return self._colormap.getVMax()

    def _getBound(self):
        """Proxy to get colormap effective bound value

        :rtype: float
        """
        if self._colormap is not None:
            bound = self._getRawBound()

            if bound is None:
                bound = self._getColormapRange()[self._index]
            return bound
        else:
            return 1.  # Fallback

    def _setBound(self, value):
        """Proxy to set colormap bound.

        :param float value:
        """
        if self._colormap is not None:
            if self._index == 0:
                min_ = value
                max_ = self._colormap.getVMax()
            else:  # self._index == 1
                min_ = self._colormap.getVMin()
                max_ = value

            if max_ is not None and min_ is not None and min_ > max_:
                min_, max_ = max_, min_
            self._colormap.setVRange(min_, max_)

    def flags(self, column):
        if column == 0:
            return qt.Qt.ItemIsEnabled | qt.Qt.ItemIsUserCheckable

        elif column == 1:
            if self._getRawBound() is not None:
                flags = qt.Qt.ItemIsEditable | qt.Qt.ItemIsEnabled
            else:
                flags = qt.Qt.NoItemFlags  # Disabled if autoscale
            return flags

        else:  # Never event
            return super(_ColormapBoundRow, self).flags(column)

    def data(self, column, role):
        if column == 0 and role == qt.Qt.CheckStateRole:
            if self._getRawBound() is None:
                return qt.Qt.Unchecked
            else:
                return qt.Qt.Checked

        else:
            return super(_ColormapBoundRow, self).data(column, role)

    def setData(self, column, value, role):
        if column == 0 and role == qt.Qt.CheckStateRole:
            if self._colormap is not None:
                bound = self._getBound() if value == qt.Qt.Checked else None
                self._setBound(bound)
                return True
            else:
                return False

        return super(_ColormapBoundRow, self).setData(column, value, role)


class ColormapRow(_ColormapBaseProxyRow):
    """Represents :class:`ColormapMixIn` property.

    :param Item3D item: Scene item with colormap property
    """

    def __init__(self, item):
        super(ColormapRow, self).__init__(
            item,
            name='Colormap',
            fget=self._get)

        self._colormapImage = None

        self._colormapsMapping = {}
        for cmap in preferredColormaps():
            self._colormapsMapping[cmap.title()] = cmap

        self.addRow(ProxyRow(
            name='Name',
            fget=self._getName,
            fset=self._setName,
            notify=self._sigColormapChanged,
            editorHint=list(self._colormapsMapping.keys())))

        norms = [norm.title() for norm in self._colormap.NORMALIZATIONS]
        self.addRow(ProxyRow(
            name='Normalization',
            fget=self._getNormalization,
            fset=self._setNormalization,
            notify=self._sigColormapChanged,
            editorHint=norms))

        self.addRow(_ColormapBoundRow(item, name='Min.', index=0))
        self.addRow(_ColormapBoundRow(item, name='Max.', index=1))

        self._sigColormapChanged.connect(self._updateColormapImage)

    def _get(self):
        """Getter for ProxyRow subclass"""
        return None

    def _getName(self):
        """Proxy for :meth:`Colormap.getName`"""
        if self._colormap is not None and self._colormap.getName() is not None:
            return self._colormap.getName().title()
        else:
            return ''

    def _setName(self, name):
        """Proxy for :meth:`Colormap.setName`"""
        # Convert back from titled to name if possible
        if self._colormap is not None:
            name = self._colormapsMapping.get(name, name)
            self._colormap.setName(name)

    def _getNormalization(self):
        """Proxy for :meth:`Colormap.getNormalization`"""
        if self._colormap is not None:
            return self._colormap.getNormalization().title()
        else:
            return ''

    def _setNormalization(self, normalization):
        """Proxy for :meth:`Colormap.setNormalization`"""
        if self._colormap is not None:
            return self._colormap.setNormalization(normalization.lower())

    def _updateColormapImage(self, *args, **kwargs):
        """Notify colormap update to update the image in the tree"""
        if self._colormapImage is not None:
            self._colormapImage = None
            model = self.model()
            if model is not None:
                index = self.index(column=1)
                model.dataChanged.emit(index, index)

    def data(self, column, role):
        if column == 1 and role == qt.Qt.DecorationRole:
            if self._colormapImage is None:
                image = numpy.zeros((16, 130, 3), dtype=numpy.uint8)
                image[1:-1, 1:-1] = self._colormap.getNColors(image.shape[1] - 2)[:, :3]
                self._colormapImage = convertArrayToQImage(image)
            return self._colormapImage

        return super(ColormapRow, self).data(column, role)


class SymbolRow(ProxyRow):
    """Represents :class:`SymbolMixIn` symbol property.

    :param Item3D item: Scene item with symbol property
    """

    def __init__(self, item):
        names = [item.getSymbolName(s) for s in item.getSupportedSymbols()]
        super(SymbolRow, self).__init__(
             name='Marker',
             fget=item.getSymbolName,
             fset=item.setSymbol,
             notify=item.sigItemChanged,
             editorHint=names)


class SymbolSizeRow(ProxyRow):
    """Represents :class:`SymbolMixIn` symbol size property.

    :param Item3D item: Scene item with symbol size property
    """

    def __init__(self, item):
        super(SymbolSizeRow, self).__init__(
            name='Marker size',
            fget=item.getSymbolSize,
            fset=item.setSymbolSize,
            notify=item.sigItemChanged,
            editorHint=(1, 20))  # TODO link with OpenGL max point size


class PlaneRow(ProxyRow):
    """Represents :class:`PlaneMixIn` property.

    :param Item3D item: Scene item with plane equation property
    """

    def __init__(self, item):
        super(PlaneRow, self).__init__(
            name='Equation',
            fget=item.getParameters,
            fset=item.setParameters,
            notify=item.sigItemChanged,
            toModelData=lambda data: qt.QVector4D(*data),
            fromModelData=lambda data: (data.x(), data.y(), data.z(), data.w()))
        self._item = weakref.ref(item)

    def data(self, column, role):
        if column == 1 and role == qt.Qt.DisplayRole:
            item = self._item()
            if item is not None:
                params = item.getParameters()
                return ('%gx %+gy %+gz %+g = 0' %
                        (params[0], params[1], params[2], params[3]))
        return super(PlaneRow, self).data(column, role)


class RemoveIsosurfaceRow(BaseRow):
    """Class for Isosurface Delete button

    :param Isosurface isosurface: The isosurface item to attach the button to.
    """

    def __init__(self, isosurface):
        super(RemoveIsosurfaceRow, self).__init__()
        self._isosurface = weakref.ref(isosurface)

    def createEditor(self):
        """Specific editor factory provided to the model"""
        editor = qt.QWidget()
        layout = qt.QHBoxLayout(editor)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)

        removeBtn = qt.QToolButton()
        removeBtn.setText('Delete')
        removeBtn.setToolButtonStyle(qt.Qt.ToolButtonTextOnly)
        layout.addWidget(removeBtn)
        removeBtn.clicked.connect(self._removeClicked)

        layout.addStretch(1)
        return editor

    def isosurface(self):
        """Returns the controlled isosurface

        :rtype: Isosurface
        """
        return self._isosurface()

    def data(self, column, role):
        if column == 0 and role == qt.Qt.UserRole:  # editor hint
            return self.createEditor

        return super(RemoveIsosurfaceRow, self).data(column, role)

    def flags(self, column):
        flags = super(RemoveIsosurfaceRow, self).flags(column)
        if column == 0:
            flags |= qt.Qt.ItemIsEditable
        return flags

    def _removeClicked(self):
        """Handle Delete button clicked"""
        isosurface = self.isosurface()
        if isosurface is not None:
            scalarField3D = isosurface.parent()
            if scalarField3D is not None:
                scalarField3D.removeIsosurface(isosurface)


class IsosurfaceRow(Item3DRow):
    """Represents an :class:`Isosurface` item.

    :param Isosurface item: Isosurface item
    """

    _LEVEL_SLIDER_RANGE = 0, 1000
    """Range given as editor hint"""

    def __init__(self, item):
        super(IsosurfaceRow, self).__init__(item, name=item.getLevel())

        self.setFlags(self.flags(1) | qt.Qt.ItemIsEditable, 1)

        item.sigItemChanged.connect(self._levelChanged)

        self.addRow(ProxyRow(
            name='Level',
            fget=self._getValueForLevelSlider,
            fset=self._setLevelFromSliderValue,
            notify=item.sigItemChanged,
            editorHint=self._LEVEL_SLIDER_RANGE))

        self.addRow(ColorProxyRow(
            name='Color',
            fget=self._rgbColor,
            fset=self._setRgbColor,
            notify=item.sigItemChanged))

        self.addRow(ProxyRow(
            name='Opacity',
            fget=self._opacity,
            fset=self._setOpacity,
            notify=item.sigItemChanged,
            editorHint=(0, 255)))

        self.addRow(RemoveIsosurfaceRow(item))

    def _getValueForLevelSlider(self):
        """Convert iso level to slider value.

        :rtype: int
        """
        item = self.item()
        if item is not None:
            scalarField3D = item.parent()
            if scalarField3D is not None:
                dataRange = scalarField3D.getDataRange()
                if dataRange is not None:
                    dataMin, dataMax = dataRange[0], dataRange[-1]
                    offset = (item.getLevel() - dataMin) / (dataMax - dataMin)

                    sliderMin, sliderMax = self._LEVEL_SLIDER_RANGE
                    value = sliderMin + (sliderMax - sliderMin) * offset
                    return value
        return 0

    def _setLevelFromSliderValue(self, value):
        """Convert slider value to isolevel.

        :param int value:
        """
        item = self.item()
        if item is not None:
            scalarField3D = item.parent()
            if scalarField3D is not None:
                dataRange = scalarField3D.getDataRange()
                if dataRange is not None:
                    sliderMin, sliderMax = self._LEVEL_SLIDER_RANGE
                    offset = (value - sliderMin) / (sliderMax - sliderMin)

                    dataMin, dataMax = dataRange[0], dataRange[-1]
                    level = dataMin + (dataMax - dataMin) * offset
                    item.setLevel(level)

    def _rgbColor(self):
        """Proxy to get the isosurface's RGB color without transparency

        :rtype: QColor
        """
        item = self.item()
        if item is None:
            return None
        else:
            color = item.getColor()
            color.setAlpha(255)
            return color

    def _setRgbColor(self, color):
        """Proxy to set the isosurface's RGB color without transparency

        :param QColor color:
        """
        item = self.item()
        if item is not None:
            color.setAlpha(item.getColor().alpha())
            item.setColor(color)

    def _opacity(self):
        """Proxy to get the isosurface's transparency

        :rtype: int
        """
        item = self.item()
        return 255 if item is None else item.getColor().alpha()

    def _setOpacity(self, opacity):
        """Proxy to set the isosurface's transparency.

        :param int opacity:
        """
        item = self.item()
        if item is not None:
            color = item.getColor()
            color.setAlpha(opacity)
            item.setColor(color)

    def _levelChanged(self, event):
        """Handle isosurface level changed and notify model

        :param ItemChangedType event:
        """
        if event == items.Item3DChangedType.ISO_LEVEL:
            model = self.model()
            if model is not None:
                index = self.index(column=1)
                model.dataChanged.emit(index, index)

    def data(self, column, role):
        if column == 0:  # Show color as decoration, not text
            if role == qt.Qt.DisplayRole:
                return None
            elif role == qt.Qt.DecorationRole:
                return self._rgbColor()

        elif column == 1 and role in (qt.Qt.DisplayRole, qt.Qt.EditRole):
                item = self.item()
                return None if item is None else item.getLevel()

        return super(IsosurfaceRow, self).data(column, role)

    def setData(self, column, value, role):
        if column == 1 and role == qt.Qt.EditRole:
            item = self.item()
            if item is not None:
                item.setLevel(value)
            return True

        return super(IsosurfaceRow, self).setData(column, value, role)


class AddIsosurfaceRow(BaseRow):
    """Class for Isosurface create button

    :param ScalarField3D scalarField3D:
        The ScalarField3D item to attach the button to.
    """

    def __init__(self, scalarField3D):
        super(AddIsosurfaceRow, self).__init__()
        self._scalarField3D = weakref.ref(scalarField3D)

    def createEditor(self):
        """Specific editor factory provided to the model"""
        editor = qt.QWidget()
        layout = qt.QHBoxLayout(editor)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)

        addBtn = qt.QToolButton()
        addBtn.setText('+')
        addBtn.setToolButtonStyle(qt.Qt.ToolButtonTextOnly)
        layout.addWidget(addBtn)
        addBtn.clicked.connect(self._addClicked)

        layout.addStretch(1)
        return editor

    def scalarField3D(self):
        """Returns the controlled ScalarField3D

        :rtype: ScalarField3D
        """
        return self._scalarField3D()

    def data(self, column, role):
        if column == 0 and role == qt.Qt.UserRole:  # editor hint
            return self.createEditor

        return super(AddIsosurfaceRow, self).data(column, role)

    def flags(self, column):
        flags = super(AddIsosurfaceRow, self).flags(column)
        if column == 0:
            flags |= qt.Qt.ItemIsEditable
        return flags

    def _addClicked(self):
        """Handle Delete button clicked"""
        scalarField3D = self.scalarField3D()
        if scalarField3D is not None:
            dataRange = scalarField3D.getDataRange()
            if dataRange is None:
                dataRange = 0., 1.

            scalarField3D.addIsosurface(
                numpy.mean((dataRange[0], dataRange[-1])),
                '#0000FF')


class ScalarField3DIsoSurfacesRow(StaticRow):
    """Represents  :class:`ScalarFieldView`'s isosurfaces

    :param ScalarFieldView scalarField3D: ScalarFieldView to control
    """

    def __init__(self, scalarField3D):
        super(ScalarField3DIsoSurfacesRow, self).__init__(
            ('Isosurfaces', None))
        self._scalarField3D = weakref.ref(scalarField3D)

        scalarField3D.sigIsosurfaceAdded.connect(self._isosurfaceAdded)
        scalarField3D.sigIsosurfaceRemoved.connect(self._isosurfaceRemoved)

        for item in scalarField3D.getIsosurfaces():
            self.addRow(nodeFromItem(item))

        self.addRow(AddIsosurfaceRow(scalarField3D))

    def scalarField3D(self):
        """Returns the controlled ScalarField3D

        :rtype: ScalarField3D
        """
        return self._scalarField3D()

    def _isosurfaceAdded(self, item):
        """Handle isosurface addition

        :param Isosurface item: added isosurface
        """
        scalarField3D = self.scalarField3D()
        if scalarField3D is None:
            return

        row = scalarField3D.getIsosurfaces().index(item)
        self.addRow(nodeFromItem(item), row)

    def _isosurfaceRemoved(self, item):
        """Handle isosurface removal

        :param Isosurface item: removed isosurface
        """
        scalarField3D = self.scalarField3D()
        if scalarField3D is None:
            return

        # Find item
        for row in self.children():
            if row.item() is item:
                self.removeRow(row)
                break  # Got it
        else:
            raise RuntimeError("Model does not correspond to scene content")


class Scatter2DPropertyMixInRow(object):
    """Mix-in class that enable/disable row according to Scatter2D mode.

    :param Scatter2D item:
    :param str propertyName: Name of the Scatter2D property of this row
    """

    def __init__(self, item, propertyName):
        assert propertyName in ('lineWidth', 'symbol', 'symbolSize')
        self.__propertyName = propertyName

        self.__isEnabled = item.isPropertyEnabled(propertyName)
        self.__updateFlags()

        item.sigItemChanged.connect(self.__itemChanged)

    def data(self, column, role):
        if column == 1 and not self.__isEnabled:
            # Discard data and editorHint if disabled
            return None
        else:
            return super(Scatter2DPropertyMixInRow, self).data(column, role)

    def __updateFlags(self):
        """Update model flags"""
        if self.__isEnabled:
            self.setFlags(qt.Qt.ItemIsEnabled, 0)
            self.setFlags(qt.Qt.ItemIsEnabled | qt.Qt.ItemIsEditable, 1)
        else:
            self.setFlags(qt.Qt.NoItemFlags)

    def __itemChanged(self, event):
        """Set flags to enable/disable the row"""
        if event == items.ItemChangedType.VISUALIZATION_MODE:
            item = self.sender()
            if item is not None:  # This occurs with PySide/python2.7
                self.__isEnabled = item.isPropertyEnabled(self.__propertyName)
                self.__updateFlags()

            # Notify model
            model = self.model()
            if model is not None:
                begin = self.index(column=0)
                end = self.index(column=1)
                model.dataChanged.emit(begin, end)


class Scatter2DSymbolRow(Scatter2DPropertyMixInRow, SymbolRow):
    """Specific class for Scatter2D symbol.

    It is enabled/disabled according to visualization mode.

    :param Scatter2D item:
    """

    def __init__(self, item):
        SymbolRow.__init__(self, item)
        Scatter2DPropertyMixInRow.__init__(self, item, 'symbol')


class Scatter2DSymbolSizeRow(Scatter2DPropertyMixInRow, SymbolSizeRow):
    """Specific class for Scatter2D symbol size.

    It is enabled/disabled according to visualization mode.

    :param Scatter2D item:
    """

    def __init__(self, item):
        SymbolSizeRow.__init__(self, item)
        Scatter2DPropertyMixInRow.__init__(self, item, 'symbolSize')


class Scatter2DLineWidth(Scatter2DPropertyMixInRow, ProxyRow):
    """Specific class for Scatter2D symbol size.

    It is enabled/disabled according to visualization mode.

    :param Scatter2D item:
    """

    def __init__(self, item):
        # TODO link editorHint with OpenGL max line width
        ProxyRow.__init__(self,
                          name='Line width',
                          fget=item.getLineWidth,
                          fset=item.setLineWidth,
                          notify=item.sigItemChanged,
                          editorHint=(1, 10))
        Scatter2DPropertyMixInRow.__init__(self, item, 'lineWidth')


def initScatter2DNode(node, item):
    """Specific node init for Scatter2D to set order of parameters

    :param Item3DRow node: The model node to setup
    :param Scatter2D item: The Scatter2D the node is representing
    """
    node.addRow(ProxyRow(
        name='Mode',
        fget=item.getVisualization,
        fset=item.setVisualization,
        notify=item.sigItemChanged,
        editorHint=[m.title() for m in item.supportedVisualizations()],
        toModelData=lambda data: data.title(),
        fromModelData=lambda data: data.lower()))

    node.addRow(ProxyRow(
        name='Height map',
        fget=item.isHeightMap,
        fset=item.setHeightMap,
        notify=item.sigItemChanged))

    node.addRow(ColormapRow(item))

    node.addRow(Scatter2DSymbolRow(item))
    node.addRow(Scatter2DSymbolSizeRow(item))

    node.addRow(Scatter2DLineWidth(item))


def initScalarField3DNode(node, item):
    """Specific node init for ScalarField3D

    :param Item3DRow node: The model node to setup
    :param ScalarField3D item: The ScalarField3D the node is representing
    """
    node.addRow(nodeFromItem(item.getCutPlanes()[0]))  # Add cut plane
    node.addRow(ScalarField3DIsoSurfacesRow(item))


def initScalarField3DCutPlaneNode(node, item):
    """Specific node init for ScalarField3D CutPlane

    :param Item3DRow node: The model node to setup
    :param CutPlane item: The CutPlane the node is representing
    """
    node.addRow(PlaneRow(item))

    node.addRow(ColormapRow(item))

    node.addRow(ProxyRow(
        name='Values<=Min',
        fget=item.getDisplayValuesBelowMin,
        fset=item.setDisplayValuesBelowMin,
        notify=item.sigItemChanged))

    node.addRow(InterpolationRow(item))


NODE_SPECIFIC_INIT = [  # class, init(node, item)
    (items.Scatter2D, initScatter2DNode),
    (items.ScalarField3D, initScalarField3DNode),
    (CutPlane, initScalarField3DCutPlaneNode),
]
"""List of specific node init for different item class"""


def nodeFromItem(item):
    """Create :class:`Item3DRow` subclass corresponding to item

    :param Item3D item: The item fow which to create the node
    :rtype: Item3DRow
    """
    assert isinstance(item, items.Item3D)

    # Item with specific model row class
    if isinstance(item, (items.GroupItem, items.GroupWithAxesItem)):
        return GroupItemRow(item)
    elif isinstance(item, Isosurface):
        return IsosurfaceRow(item)

    # Create Item3DRow and populate it
    node = Item3DRow(item)

    if isinstance(item, items.DataItem3D):
        node.addRow(DataItem3DBoundingBoxRow(item))
        node.addRow(DataItem3DTransformRow(item))

    # Specific extra init
    for cls, specificInit in NODE_SPECIFIC_INIT:
        if isinstance(item, cls):
            specificInit(node, item)
            break

    else:  # Generic case: handle mixins
        for cls in item.__class__.__mro__:
            if cls is items.ColormapMixIn:
                node.addRow(ColormapRow(item))

            elif cls is items.InterpolationMixIn:
                node.addRow(InterpolationRow(item))

            elif cls is items.SymbolMixIn:
                node.addRow(SymbolRow(item))
                node.addRow(SymbolSizeRow(item))

            elif cls is items.PlaneMixIn:
                node.addRow(PlaneRow(item))

    return node
