https://github.com/minorua/Qgis2threejs
Raw File
Tip revision: c29151dd8221c6226a744ec5bcb25cb49b8175ff authored by Minoru Akagi on 31 January 2024, 01:41:12 UTC
version 2.7.3
Tip revision: c29151d
keyframes.py
# -*- coding: utf-8 -*-
# (C) 2021 Minoru Akagi
# SPDX-License-Identifier: GPL-2.0-or-later
# begin: 2021-11-10

from PyQt5.QtCore import Qt, QSize, QUrl
from PyQt5.QtGui import QCursor, QIcon
from PyQt5.QtWidgets import (QAbstractItemView, QAction, QButtonGroup, QDialog, QInputDialog, QMenu,
                             QMessageBox, QTreeWidget, QTreeWidgetItem, QWidget)
from qgis.core import Qgis, QgsApplication, QgsFieldProxyModel

from .conf import DEBUG_MODE, DEF_SETS, PLUGIN_NAME
from .q3dconst import DEMMtlType, LayerType, ATConst
from .q3dcore import Layer
from .tools import createUid, selectImageFile, js_bool, logMessage, parseInt, pluginDir
from .ui.animationpanel import Ui_AnimationPanel
from .ui.keyframedialog import Ui_KeyframeDialog


class AnimationPanel(QWidget):

    def __init__(self, parent=None):
        QWidget.__init__(self, parent)

        self.isAnimating = False

        self.ui = Ui_AnimationPanel()
        self.ui.setupUi(self)
        self.ui.treeWidgetAnimation = AnimationTreeWidget(self)
        self.ui.treeWidgetAnimation.setObjectName("treeWidgetAnimation")
        self.ui.verticalLayout.addWidget(self.ui.treeWidgetAnimation)

        self.tree = self.ui.treeWidgetAnimation

        self.iconPlay = QIcon(pluginDir("svg", "play.svg"))    # QgsApplication.getThemeIcon("temporal_navigation/forward.svg")
        self.iconStop = QIcon(pluginDir("svg", "stop.svg"))    # QgsApplication.getThemeIcon("temporal_navigation/stop.svg")
        self.iconNarration = QgsApplication.getThemeIcon("mIconInfo.svg")
        self.iconEasing = {}

    def setup(self, wnd, settings):
        self.wnd = wnd
        self.webPage = wnd.webPage

        self.ui.toolButtonAdd.setIcon(QgsApplication.getThemeIcon("symbologyAdd.svg"))
        self.ui.toolButtonEdit.setIcon(QgsApplication.getThemeIcon("symbologyEdit.svg"))
        self.ui.toolButtonRemove.setIcon(QgsApplication.getThemeIcon("symbologyRemove.svg"))
        self.ui.toolButtonPlay.setIcon(self.iconPlay)

        self.ui.toolButtonAdd.clicked.connect(self.tree.addNewItem)
        self.ui.toolButtonEdit.clicked.connect(self.tree.onItemEdit)
        self.ui.toolButtonRemove.clicked.connect(self.tree.removeSelectedItems)
        self.ui.toolButtonPlay.clicked.connect(self.playButtonClicked)

        self.tree.setup(wnd, settings)

        self.setData(settings.animationData())

        if self.webPage:
            self.tree.currentItemChanged.connect(self.currentItemChanged)
            self.currentItemChanged(None, None)

            self.webPage.bridge.animationStopped.connect(self.animationStopped)
        else:
            self.setEnabled(False)      # animation panel gets disabled when exporter has no preview.

    def data(self):
        d = self.tree.data()
        d["repeat"] = self.ui.checkBoxLoop.isChecked()
        return d

    def setData(self, data):
        self.ui.checkBoxLoop.setChecked(data.get("repeat", False))
        self.tree.setData(data)

    def playButtonClicked(self, _):
        if self.isAnimating:
            self.stopAnimation()
        else:
            self.playAnimation(repeat=self.ui.checkBoxLoop.isChecked())

    def playAnimation(self, items=None, repeat=False):
        self.wnd.settings.setAnimationData(self.data())

        self._warnings = []

        dataList = []
        if items is None:
            for group in self.wnd.settings.enabledValidKeyframeGroups(warning_log=self._log):
                layerId = group.get("layerId")
                if layerId is None:
                    dataList.append(group)
                else:
                    layer = self.wnd.settings.getLayerByJSLayerId(layerId)
                    if layer:
                        t = group.get("type")
                        if t in (ATConst.ITEM_GRP_TEXTURE, ATConst.ITEM_GRP_GROWING_LINE):
                            self._updateLayer(layer, t)

                        dataList.append(group)
        else:
            for item in items:
                t = item.type()
                if t in (ATConst.ITEM_GRP_TEXTURE, ATConst.ITEM_GRP_GROWING_LINE):
                    mapLayerId = item.parent().data(0, ATConst.DATA_LAYER_ID)
                elif t in (ATConst.ITEM_TEXTURE, ATConst.ITEM_GROWING_LINE):
                    mapLayerId = item.parent().parent().data(0, ATConst.DATA_LAYER_ID)
                else:
                    mapLayerId = None

                if mapLayerId:
                    layer = self.wnd.settings.getLayer(mapLayerId)
                    self._updateLayer(layer, t)

                data = self.tree.transitionData(item, exclude_narration=bool(t & ATConst.ITEM_MBR))
                if data:
                    dataList.append(data)

        msg = ""
        duration = 5000
        if self._warnings:
            msg = "Animation warning{}:<br><ul>".format("s" if len(self._warnings) > 1 else "")
            for w in self._warnings:
                msg += "<li>" + w + "</li>"
            msg += "</ul>"
            duration = 0

        if len(dataList):
            if DEBUG_MODE:
                logMessage("Play: " + str(dataList))
            self.wnd.iface.requestRunScript("startAnimation(pyData(), {})".format(js_bool(repeat)), data=dataList)
            self.ui.toolButtonPlay.setIcon(self.iconStop)
            self.ui.checkBoxLoop.setEnabled(False)
            self.isAnimating = True
        else:
            if not msg:
                msg = "Animation: "
            msg += "There are no keyframe groups to play."

            self.ui.toolButtonPlay.setChecked(False)

        if msg:
            self.wnd.showMessageBar(msg, duration, warning=True)

    def _updateLayer(self, layer, groupType):
        if groupType in (ATConst.ITEM_GRP_TEXTURE, ATConst.ITEM_TEXTURE):
            layer = layer.clone()
            layer.opt.onlyMaterial = True
            layer.opt.allMaterials = True

        self.wnd.iface.requestRunScript("preview.renderEnabled = false;")
        self.wnd.iface.buildLayerRequest.emit(layer)
        self.wnd.iface.requestRunScript("preview.renderEnabled = true;")

    def _log(self, msg):
        self._warnings.append(msg)
        logMessage("Animation: " + msg)

    def stopAnimation(self):
        self.webPage.runScript("stopAnimation()")

    # @pyqtSlot()
    def animationStopped(self):
        self.ui.toolButtonPlay.setIcon(self.iconPlay)
        self.ui.toolButtonPlay.setChecked(False)
        self.ui.checkBoxLoop.setEnabled(True)
        self.isAnimating = False

    def updateKeyframeView(self):
        view = self.webPage.cameraState(flat=True)

        msg = "Are you sure you want to update the camera position and focal point of this keyframe?"
        if QMessageBox.question(self, PLUGIN_NAME, msg) == QMessageBox.Yes:
            item = self.tree.currentItem()
            item.setData(0, ATConst.DATA_CAMERA, view)

    def currentItemChanged(self, current, previous):
        self.ui.toolButtonAdd.setEnabled(bool(current))

        b = bool(current and not (current.type() & ATConst.ITEM_TOPLEVEL))
        self.ui.toolButtonEdit.setEnabled(b)
        self.ui.toolButtonRemove.setEnabled(b)

    def showNarrativeBox(self, content):
        self.webPage.runScript("showNarrativeBox(pyData())", data=content)


class AnimationTreeWidget(QTreeWidget):

    def __init__(self, parent):
        QTreeWidget.__init__(self, parent)

        self.panel = parent
        self.dialog = None

        root = self.invisibleRootItem()
        root.setFlags(root.flags() & ~Qt.ItemIsDropEnabled)

        self.header().setVisible(False)
        self.setDragDropMode(QAbstractItemView.InternalMove)
        self.setSelectionMode(QAbstractItemView.ExtendedSelection)
        self.setExpandsOnDoubleClick(False)

    def setup(self, wnd, settings):
        self.wnd = wnd
        self.webPage = wnd.webPage

        self.settings = settings

        self.icons = wnd.icons
        self.cameraIcon = QgsApplication.getThemeIcon("mIconCamera.svg") if Qgis.QGIS_VERSION_INT >= 31600 else QIcon(pluginDir("svg", "camera.svg"))
        self.keyframeIcon = QIcon(pluginDir("svg", "keyframe.svg"))
        self.effectIcon = QgsApplication.getThemeIcon("mLayoutItemPolyline.svg")

        self.setContextMenuPolicy(Qt.CustomContextMenu)
        self.customContextMenuRequested.connect(self.contextMenu)
        self.currentItemChanged.connect(self.currentTreeItemChanged)
        self.itemDoubleClicked.connect(self.onItemDoubleClicked)

        # context menu
        self.actionAdd = QAction("Add", self)           # NOTE: may be hidden
        self.actionAdd.triggered.connect(self.addNewItem)

        self.actionRemove = QAction("Remove...", self)
        self.actionRemove.triggered.connect(self.removeSelectedItems)

        self.actionEdit = QAction("Edit...", self)
        self.actionEdit.triggered.connect(self.onItemEdit)

        self.actionRename = QAction("Rename...", self)
        self.actionRename.triggered.connect(self.renameGroup)

        self.actionPlay = QAction("Play", self)
        self.actionPlay.triggered.connect(self.playAnimation)

        self.actionShowNarBox = QAction("Preview narrative content", self)
        self.actionShowNarBox.triggered.connect(self.showNarrativeBox)

        self.actionUpdateView = QAction("Set current view to this keyframe...", self)
        self.actionUpdateView.triggered.connect(self.panel.updateKeyframeView)

        self.actionOpacity = QAction("Change opacity...", self)
        self.actionOpacity.triggered.connect(self.addOpacityItem)

        self.actionTexture = QAction("Change texture...", self)
        self.actionTexture.triggered.connect(self.addTextureItem)

        self.actionGrowLine = QAction("Growing line...", self)
        self.actionGrowLine.triggered.connect(self.addGrowLineItem)

        self.actionProperties = QAction("Properties...", self)
        self.actionProperties.triggered.connect(self.showDialog)

        self.ctxMenuKeyframeGroup = QMenu(self)
        self.ctxMenuKeyframeGroup.addAction(self.actionPlay)
        self.ctxMenuKeyframeGroup.addAction(self.actionRename)
        self.ctxMenuKeyframeGroup.addSeparator()
        self.ctxMenuKeyframeGroup.addAction(self.actionAdd)
        self.ctxMenuKeyframeGroup.addAction(self.actionEdit)
        self.ctxMenuKeyframeGroup.addSeparator()
        self.ctxMenuKeyframeGroup.addAction(self.actionRemove)

        self.ctxMenuKeyframe = QMenu(self)
        self.ctxMenuKeyframe.addAction(self.actionShowNarBox)
        self.ctxMenuKeyframe.addAction(self.actionPlay)
        self.ctxMenuKeyframe.addSeparator()
        self.ctxMenuKeyframe.addAction(self.actionEdit)
        self.ctxMenuKeyframe.addAction(self.actionUpdateView)
        self.ctxMenuKeyframe.addSeparator()
        self.ctxMenuKeyframe.addAction(self.actionRemove)

        self.ctxMenuLayerAdd = QMenu(self)
        self.ctxMenuLayerAdd.addActions([self.actionOpacity, self.actionTexture, self.actionGrowLine])

        self.ctxMenuLayer = QMenu(self)
        self.ctxMenuLayer.addMenu("Add").addActions(self.ctxMenuLayerAdd.actions())
        self.ctxMenuLayer.addSeparator()
        self.ctxMenuLayer.addAction(self.actionProperties)

    def dropEvent(self, event):
        items = self.selectedItems()
        p = items[0].parent()
        dp = False
        for item in items[1:]:
            if item.parent() != p:
                dp = True

        item = items[0]
        dest = self.itemAt(event.pos())
        accept = False
        if dest and item.type() & ATConst.ITEM_MBR and not dp:
            if item.type() == dest.type():
                if item.parent().parent() == item.parent().parent():
                    accept = True
            elif item.parent().type() == dest.type():
                if item.parent().parent() == dest.parent():
                    accept = True

        if item.type() == ATConst.ITEM_GROWING_LINE:
            accept = False

        if not accept:
            event.setDropAction(Qt.IgnoreAction)
            self.wnd.ui.statusbar.showMessage("Cannot move item(s) there.", 3000)

        return QTreeWidget.dropEvent(self, event)

    def initTree(self):
        self.clear()

    def addLayer(self, id_layer):
        if isinstance(id_layer, Layer):
            layer = id_layer
            layerId = id_layer.layerId
        else:
            layerId = id_layer
            layer = self.settings.getLayer(layerId)
            if not layer:
                return

        item = QTreeWidgetItem(self, [layer.name], ATConst.ITEM_TL_LAYER)
        item.setData(0, ATConst.DATA_LAYER_ID, layerId)
        item.setFlags(Qt.ItemIsEnabled)
        item.setIcon(0, self.icons[layer.type])
        item.setExpanded(True)

        return item

    def checkedGroups(self):
        groups = []
        root = self.invisibleRootItem()
        for i in range(root.childCount()):
            top_level = root.child(i)
            if top_level.isHidden():
                continue

            for j in range(top_level.childCount()):
                g = top_level.child(j)
                if g.checkState(0):
                    groups.append(g)
        return groups

    def currentLayer(self):
        item = self.currentItem()
        if item:
            while item.parent():
                item = item.parent()

            return self.getLayerFromLayerItem(item)

    def getLayerFromLayerItem(self, item):
        layerId = item.data(0, ATConst.DATA_LAYER_ID)
        return self.settings.getLayer(layerId)

    def findLayerItem(self, layerId):
        root = self.invisibleRootItem()
        for i in range(root.childCount()):
            item = root.child(i)
            if item.type() == ATConst.ITEM_TL_LAYER and item.data(0, ATConst.DATA_LAYER_ID) == layerId:
                return item

    def setLayerHidden(self, layerId, b=True):
        item = self.findLayerItem(layerId)
        if item:
            item.setHidden(b)

    def addNewItem(self):
        item = self.currentItem()
        if item is None:
            return

        typ = item.type()
        parent = None
        if typ & ATConst.ITEM_TOPLEVEL:
            if typ == ATConst.ITEM_TL_CAMERA:
                parent = self.addKeyframeGroupItem(item, ATConst.ITEM_GRP_CAMERA)
                child = self.addKeyframeItem(parent)
                self.setCurrentItem(child)
                self.wnd.ui.statusbar.showMessage("A new keyframe group and a keyframe have been added.", 5000)
            else:
                layer = self.getLayerFromLayerItem(item)
                self.actionTexture.setVisible(layer.type == LayerType.DEM)
                self.actionGrowLine.setVisible(layer.type == LayerType.LINESTRING)
                self.ctxMenuLayerAdd.popup(QCursor.pos())
            return

        gt = typ if typ & ATConst.ITEM_GRP else typ - ATConst.ITEM_MBR + ATConst.ITEM_GRP
        if gt == ATConst.ITEM_GRP_CAMERA:
            added = self.addKeyframeItem()
            self.setCurrentItem(added)

        elif gt == ATConst.ITEM_GRP_OPACITY:
            self.addOpacityItem()

        elif gt == ATConst.ITEM_GRP_TEXTURE:
            self.addTextureItem()

        elif gt == ATConst.ITEM_GRP_GROWING_LINE:
            if typ == ATConst.ITEM_GRP_GROWING_LINE and item.childCount() == 0:
                self.addGrowLineItem()
            else:
                QMessageBox.warning(self, PLUGIN_NAME, "This group can't have more than one item.")

    def removeSelectedItems(self):
        items = self.selectedItems() or [self.currentItem()]
        if len(items) == 0:
            return
        elif len(items) == 1:
            msg = "Are you sure you want to remove '{}'?".format(items[0].text(0))
        else:
            msg = "Are you sure you want to remove {} items?".format(len(items))

        if QMessageBox.question(self, PLUGIN_NAME, msg) != QMessageBox.Yes:
            return

        for item in items:
            item.parent().removeChild(item)

    def uniqueChildName(self, parent, base_name, omit_one=True):
        n = parent.childCount()
        names = [parent.child(i).text(0) for i in range(n)]

        for i in range(n + 1):
            name = base_name
            if i or not omit_one:
                name += " {}".format(i + 1)
            if name not in names:
                return name

    def addKeyframeGroupItem(self, parent, typ, name=None, enabled=True):

        name = name or self.uniqueChildName(parent, ATConst.defaultName(typ))

        item = QTreeWidgetItem(typ)
        item.setText(0, name)
        item.setFlags(Qt.ItemIsDropEnabled | Qt.ItemIsEnabled | Qt.ItemIsUserCheckable)
        item.setCheckState(0, Qt.Checked if enabled else Qt.Unchecked)

        parent.addChild(item)
        item.setExpanded(True)

        return item

    def addKeyframeItem(self, parent=None, keyframe=None):
        if parent is None:
            item = self.currentItem()
            if not item:
                return

            t = item.type()
            if t & ATConst.ITEM_MBR:
                parent = item.parent()
                iidx = parent.indexOfChild(item) + 1
            elif t & ATConst.ITEM_GRP:
                parent = item
                iidx = 0
            elif keyframe:
                pass
            else:
                return
        else:
            iidx = 0

        keyframe = keyframe or {}
        typ = keyframe.get("type", parent.type() - ATConst.ITEM_GRP + ATConst.ITEM_MBR)
        name = keyframe.get("name") or self.uniqueChildName(parent, "keyframe", omit_one=False)

        item = QTreeWidgetItem(typ)
        item.setText(0, name)

        item.setData(0, ATConst.DATA_EASING, keyframe.get("easing", ATConst.EASING_LINEAR))
        item.setData(0, ATConst.DATA_DURATION, keyframe.get("duration", DEF_SETS.ANM_DURATION))
        item.setData(0, ATConst.DATA_DELAY, keyframe.get("delay", 0))

        icon = None
        if typ == ATConst.ITEM_CAMERA:
            item.setData(0, ATConst.DATA_CAMERA, keyframe.get("camera") or self.webPage.cameraState(flat=True))

        elif typ == ATConst.ITEM_OPACITY:
            item.setData(0, ATConst.DATA_OPACITY, keyframe.get("opacity", 1))

        elif typ == ATConst.ITEM_TEXTURE:
            item.setData(0, ATConst.DATA_MTL_ID, keyframe.get("mtlId", ""))
            item.setData(0, ATConst.DATA_EFFECT, keyframe.get("effect", 0))

        elif typ == ATConst.ITEM_GROWING_LINE:
            item.setData(0, ATConst.DATA_SEQ, keyframe.get("sequential", False))
            icon = self.effectIcon

        nar = keyframe.get("narration")
        if nar:
            item.setData(0, ATConst.DATA_NARRATION, {"id": nar["id"], "text": nar["text"]})
            icon = self.panel.iconNarration

        item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsEnabled)
        item.setIcon(0, icon if icon else self.keyframeIcon)

        if iidx:
            parent.insertChild(iidx, item)
        else:
            parent.addChild(item)

        return item

    def keyframe(self, item=None):
        item = item or self.currentItem()
        if not item or not (item.type() & ATConst.ITEM_MBR):
            return

        typ = item.type()

        k = {
            "type": typ,
            "name": item.text(0)
        }

        easing = item.data(0, ATConst.DATA_EASING)
        if easing:
            k["easing"] = easing

        k["delay"] = item.data(0, ATConst.DATA_DELAY)
        k["duration"] = item.data(0, ATConst.DATA_DURATION)

        n = item.data(0, ATConst.DATA_NARRATION)
        if n:
            k["narration"] = n

        if typ == ATConst.ITEM_CAMERA:
            k["camera"] = item.data(0, ATConst.DATA_CAMERA)

        elif typ == ATConst.ITEM_OPACITY:
            k["opacity"] = item.data(0, ATConst.DATA_OPACITY)

        elif typ == ATConst.ITEM_TEXTURE:
            layer = self.getLayerFromLayerItem(item.parent().parent())
            if layer:
                id = item.data(0, ATConst.DATA_MTL_ID)
                k["mtlId"] = id
                k["mtlIndex"] = layer.mtlIndex(id)
                k["effect"] = item.data(0, ATConst.DATA_EFFECT)

        elif typ == ATConst.ITEM_GROWING_LINE:
            k["sequential"] = item.data(0, ATConst.DATA_SEQ)

        return k

    def keyframeGroupData(self, item):
        if not item:
            return {}

        typ = item.type()
        if typ & ATConst.ITEM_GRP:
            group = item
        elif typ & ATConst.ITEM_MBR:
            group = item.parent()
        else:
            return {}

        items = [group.child(i) for i in range(group.childCount())]

        d = {
            "type": group.type(),
            "name": group.text(0),
            "enabled": bool(group.checkState(0)),
            "keyframes": [self.keyframe(item) for item in items]
        }

        if group.parent().type() == ATConst.ITEM_TL_LAYER:
            layer = self.settings.getLayer(group.parent().data(0, ATConst.DATA_LAYER_ID))
            if layer:
                d["layerId"] = layer.jsLayerId
            else:
                logMessage("[KeyframeGroup] Layer not found in export settings.", error=True)

        return d

    def layerData(self, layer=None):
        if not layer:
            return {}

        layerItem = layer if isinstance(layer, QTreeWidgetItem) else self.findLayerItem(layer)
        if layerItem is None:
            return {}

        items = [layerItem.child(i) for i in range(layerItem.childCount())]

        return {
            "enabled": not layerItem.isDisabled(),
            "groups": [self.keyframeGroupData(item) for item in items]
        }

    def setLayerData(self, layerId, data):
        if layerId is None:
            return

        layerItem = self.findLayerItem(layerId) or self.addLayer(layerId)
        if layerItem is None:
            return

        for _ in range(layerItem.childCount()):
            layerItem.removeChild(layerItem.child(0))

        for group in data.get("groups", []):
            parent = self.addKeyframeGroupItem(layerItem, group.get("type"), group.get("name"), group.get("enabled", True))
            for keyframe in group.get("keyframes", []):
                self.addKeyframeItem(parent, keyframe)

    def data(self):
        root = self.invisibleRootItem()
        parent = root.child(0)      # camera motion

        d = {
            "camera": {
                "groups": [self.keyframeGroupData(parent.child(i)) for i in range(parent.childCount())]
            }
        }

        layers = {}
        for item in [root.child(i) for i in range(1, root.childCount())]:
            layers[item.data(0, ATConst.DATA_LAYER_ID)] = self.layerData(item)

        if layers:
            d["layers"] = layers

        return d

    def transitionData(self, item=None, exclude_narration=False):
        item = item or self.currentItem()
        if not item:
            return

        typ = item.type()
        if typ & ATConst.ITEM_MBR:
            isKF = (typ != ATConst.ITEM_GROWING_LINE)
            c = 2 if isKF else 1

            p = item.parent()
            iidx = p.indexOfChild(item)

            if isKF and iidx == p.childCount() - 1:
                return

            d = self.keyframeGroupData(p)
            kfs = d["keyframes"][iidx:iidx + c]
            if exclude_narration:
                kfs[0].pop("narration", None)

            if c == 2:
                kfs[1].pop("narration", None)
            d["keyframes"] = kfs
            return d

        elif typ & ATConst.ITEM_GRP:        # NOTE: exclude_narration is ignored
            return self.keyframeGroupData(item)

    def setData(self, data):
        self.initTree()

        # camera motion
        item = QTreeWidgetItem(self, ["Camera Motion"], ATConst.ITEM_TL_CAMERA)
        item.setFlags(Qt.ItemIsEnabled)
        item.setIcon(0, self.cameraIcon)
        item.setExpanded(True)
        self.cameraTLItem = item

        for s in data.get("camera", {}).get("groups", []):
            parent = self.addKeyframeGroupItem(item, ATConst.ITEM_GRP_CAMERA, s.get("name"), s.get("enabled", True))
            for k in s.get("keyframes", []):
                self.addKeyframeItem(parent, k)

        # layers
        dp = data.get("layers", {})
        for layer in self.settings.layers():
            id = layer.layerId
            self.addLayer(layer)

            d = dp.get(id)
            if d:
                self.setLayerData(id, d)
            self.setLayerHidden(id, not layer.visible)

    def currentItemView(self):
        item = self.currentItem()
        if item and item.type() == ATConst.ITEM_CAMERA:
            return item.data(0, ATConst.DATA_CAMERA)

    def contextMenu(self, pos):
        item = self.itemAt(pos)
        if item is None:
            # blank space
            return

        m = None
        typ = item.type()
        if typ & ATConst.ITEM_TOPLEVEL:
            if typ == ATConst.ITEM_TL_LAYER:
                m = self.ctxMenuLayer

                layer = self.getLayerFromLayerItem(item)
                self.actionTexture.setVisible(layer.type == LayerType.DEM)
                self.actionGrowLine.setVisible(layer.type == LayerType.LINESTRING)
        else:
            if typ & ATConst.ITEM_GRP:
                m = self.ctxMenuKeyframeGroup
                self.actionAdd.setText("Add" if typ == ATConst.ITEM_GRP_CAMERA else "Add...")
                self.actionAdd.setVisible(bool(typ != ATConst.ITEM_GRP_GROWING_LINE))

            elif typ & ATConst.ITEM_MBR:
                m = self.ctxMenuKeyframe
                self.actionShowNarBox.setVisible(bool(item.data(0, ATConst.DATA_NARRATION)))
                self.actionUpdateView.setVisible(bool(typ == ATConst.ITEM_CAMERA))

        if m:
            m.exec_(self.mapToGlobal(pos))

    def currentTreeItemChanged(self, current, previous=None):
        if not current:
            return

        typ = current.type()
        if not (typ & ATConst.ITEM_MBR):
            return

        if typ == ATConst.ITEM_CAMERA:
            # restore the view of current keyframe
            k = self.keyframe()
            if k:
                self.webPage.setCameraState(k.get("camera") or {})

        elif typ == ATConst.ITEM_OPACITY:
            layerId = current.parent().parent().data(0, ATConst.DATA_LAYER_ID)
            layer = self.settings.getLayer(layerId)
            if layer:
                opacity = current.data(0, ATConst.DATA_OPACITY)
                self.webPage.runScript("setLayerOpacity({}, {})".format(layer.jsLayerId, opacity))

        elif typ == ATConst.ITEM_TEXTURE:
            layerId = current.parent().parent().data(0, ATConst.DATA_LAYER_ID)
            layer = self.settings.getLayer(layerId)
            if layer:
                layer = layer.clone()
                layer.properties["mtlId"] = current.data(0, ATConst.DATA_MTL_ID)
                layer.opt.onlyMaterial = True
                self.wnd.iface.buildLayerRequest.emit(layer)

    def onItemDoubleClicked(self, item=None, column=0):
        item = item or self.currentItem()
        t = item.type()
        if t != ATConst.ITEM_TL_CAMERA:
            self.showDialog(item)

    def onItemEdit(self):
        item = self.currentItem()
        if item:
            t = item.type()
            if t & ATConst.ITEM_MBR:
                self.showDialog(item)
            elif t & ATConst.ITEM_GRP:
                if item.childCount() > 0:
                    self.showDialog(item.child(0))

    def renameGroup(self, item=None):
        item = item or self.currentItem()
        if item:
            name, ok = QInputDialog.getText(self, "Rename group", "Group name", text=item.text(0))
            if ok:
                item.setText(0, name)

    def addOpacityItem(self):
        item = self.currentItem()
        if not item:
            return

        val, ok = QInputDialog.getDouble(self, "Layer Opacity", "Opacity (0 - 1)", 1, 0, 1, 2)
        if ok:
            parent = None
            if item.type() == ATConst.ITEM_TL_LAYER:
                parent = self.addKeyframeGroupItem(item, ATConst.ITEM_GRP_OPACITY)

            added = self.addKeyframeItem(parent, {
                "type": ATConst.ITEM_OPACITY,
                "name": "opacity '{}'".format(val),
                "opacity": val
            })
            self.setCurrentItem(added)

    def addTextureItem(self):
        item = self.currentItem()
        layer = self.currentLayer()
        if not item or not layer:
            return

        mtlNames = ["[{}] {}".format(i, mtl.get("name", "")) for i, mtl in enumerate(layer.properties.get("materials", [])) if mtl.get("type") != DEMMtlType.COLOR]

        if not mtlNames:
            QMessageBox.warning(self, "Texture", "The layer has no textures.")
            return

        val, ok = QInputDialog.getItem(self, "Texture", "Select a material with texture", mtlNames, 0, False)
        if ok:
            mtlIdx = int(val.split("]")[0][1:])
            mtl = layer.properties["materials"][mtlIdx]

            parent = None
            if item.type() == ATConst.ITEM_TL_LAYER:
                parent = self.addKeyframeGroupItem(item, ATConst.ITEM_GRP_TEXTURE)

            added = self.addKeyframeItem(parent, {
                "type": ATConst.ITEM_TEXTURE,
                "name": mtl.get("name", "no name"),
                "mtlId": mtl.get("id")
            })
            self.setCurrentItem(added)

    def addGrowLineItem(self):
        item = self.currentItem()
        layer = self.currentLayer()
        if not item or not layer:
            return

        parent = None
        if item.type() == ATConst.ITEM_TL_LAYER:
            parent = self.addKeyframeGroupItem(item, ATConst.ITEM_GRP_GROWING_LINE)

        added = self.addKeyframeItem(parent, {
            "type": ATConst.ITEM_GROWING_LINE,
            "name": "Growing line"
        })
        self.setCurrentItem(added)

    def showDialog(self, item=None):
        item = item or self.currentItem()
        if item is None:
            return

        t = item.type()
        if t == ATConst.ITEM_TL_LAYER:
            layerId = item.data(0, ATConst.DATA_LAYER_ID)
            layer = self.settings.getLayer(layerId)
            self.wnd.showLayerPropertiesDialog(layer)
            return

        elif t == ATConst.ITEM_TL_CAMERA:
            return

        if t & ATConst.ITEM_GRP:
            item = item.child(0)
            if item is None:
                isKF = (t != ATConst.ITEM_GRP_GROWING_LINE)
                msg = "This group has no items. Please add {}.".format("at least two keyframe items" if isKF else "an item")
                QMessageBox.warning(self, PLUGIN_NAME, msg)
                return

            t = item.type()

        isKF = (t != ATConst.ITEM_GROWING_LINE)
        if isKF:
            if item.parent().childCount() < 2:
                QMessageBox.warning(self, PLUGIN_NAME, "Two or more keyframes are necessary for animation to work. Please add a keyframe.")
                return
        else:
            # line growing doesn't work if group item is not checked
            item.parent().setCheckState(0, Qt.Checked)

        top_level = item.parent().parent()
        layer = None
        if top_level.type() == ATConst.ITEM_TL_LAYER:
            layerId = top_level.data(0, ATConst.DATA_LAYER_ID)
            layer = self.settings.getLayer(layerId)

        self.panel.setEnabled(False)

        self.dialog = KeyframeDialog(self)
        self.dialog.setup(item, layer)
        self.dialog.finished.connect(self.dialogClosed)
        self.dialog.show()
        self.dialog.exec_()

    def dialogClosed(self, result):
        self.panel.setEnabled(True)
        self.dialog = None

    def playAnimation(self):
        item = self.currentItem()
        if item:
            self.panel.playAnimation([item])

    def showNarrativeBox(self):
        item = self.currentItem()
        if item:
            nar = item.data(0, ATConst.DATA_NARRATION)
            if nar:
                self.panel.showNarrativeBox(nar["text"])

    def materialChanged(self, layer):
        layerItem = self.findLayerItem(layer.layerId)
        if not layerItem:
            return

        mtls = {mtl["id"]: mtl for mtl in layer.properties.get("materials", [])}

        for i in range(layerItem.childCount()):
            group = layerItem.child(i)
            if group.type() != ATConst.ITEM_GRP_TEXTURE:
                continue

            for idx in reversed(range(group.childCount())):
                item = group.child(idx)
                mtl = mtls.get(item.data(0, ATConst.DATA_MTL_ID))
                if mtl:
                    item.setText(0, mtl["name"])
                else:
                    logMessage("The material '{}' was removed.".format(item.text(0)), warning=False)
                    group.removeChild(item)


class KeyframeDialog(QDialog):

    def __init__(self, parent):
        QDialog.__init__(self, parent)
        self.setAttribute(Qt.WA_DeleteOnClose)

        self.ui = Ui_KeyframeDialog()
        self.ui.setupUi(self)

        self.easingButtons = {
            ATConst.EASING_NONE: self.ui.toolButtonNone,
            ATConst.EASING_LINEAR: self.ui.toolButtonLinear,
            ATConst.EASING_EASE_INOUT: self.ui.toolButtonEaseInOut,
            ATConst.EASING_EASE_IN: self.ui.toolButtonEaseIn,
            ATConst.EASING_EASE_OUT: self.ui.toolButtonEaseOut
        }

        self.ui.buttonGroup = QButtonGroup(self)
        self.ui.buttonGroup.setObjectName("buttonGroup")
        for id, btn in self.easingButtons.items():
            self.ui.buttonGroup.addButton(btn, id)

        self.panel = parent.panel
        self.narId = None
        self.isPlaying = self.isPlayingAll = False

        parent.webPage.bridge.tweenStarted.connect(self.tweenStarted)
        parent.webPage.bridge.animationStopped.connect(self.animationStopped)

    def setup(self, item, layer=None):
        self.type = t = item.type()
        if not self.type & ATConst.ITEM_MBR:
            return

        self.item = item
        self.currentItem = None
        self.isKF = (t != ATConst.ITEM_GROWING_LINE)
        self.layer = layer

        group = item.parent()
        self.kfCount = group.childCount()

        self.setWindowTitle("{} - {}".format(item.parent().text(0), layer.name if layer else "Camera Motion"))

        # set up widgets
        self.ui.toolButtonPlay.setIcon(self.panel.iconPlay)
        self.ui.pushButtonPlayAll.setIcon(self.panel.iconPlay)
        self.ui.toolButtonAddImage.setIcon(QgsApplication.getThemeIcon("mActionAddImage.svg"))
        self.ui.toolButtonPreview.setIcon(self.panel.iconNarration)

        if not self.panel.iconEasing:
            names = {
                ATConst.EASING_LINEAR: "linear",
                ATConst.EASING_EASE_INOUT: "inout",
                ATConst.EASING_EASE_IN: "in",
                ATConst.EASING_EASE_OUT: "out",
                ATConst.EASING_NONE: "none"
            }
            for id, name in names.items():
                self.panel.iconEasing[id] = QIcon(pluginDir("svg", "ease_{}.svg".format(name)))

        size = QSize(25, 18)
        for id, btn in self.easingButtons.items():
            btn.setIconSize(size)
            btn.setIcon(self.panel.iconEasing[id])

        if t == ATConst.ITEM_TEXTURE:
            self.ui.labelComboBox1.setText("Texture")

            for mtl in self.layer.properties.get("materials", []):
                if mtl.get("type") != DEMMtlType.COLOR:
                    name, id = (mtl.get("name", ""), mtl.get("id"))
                    self.ui.comboBox1.addItem(name, id)

            self.ui.labelComboBox2.setText("Effect")
            self.ui.comboBox2.addItem("Fade in", 0)

        elif t == ATConst.ITEM_GROWING_LINE:
            self.ui.labelComboBox1.setText("Animate")

            self.ui.comboBox1.addItem("all lines at once", False)
            self.ui.comboBox1.addItem("each line sequentially", True)
            self.ui.comboBox1.currentIndexChanged.connect(self.modeChanged)

            for w in [self.ui.expressionDelay, self.ui.expressionDuration]:
                w.setFilters(QgsFieldProxyModel.Numeric)
                w.setLayer(layer.mapLayer)

        wth = [self.ui.expressionDelay, self.ui.expressionDuration]
        if t not in [ATConst.ITEM_CAMERA, ATConst.ITEM_GROWING_LINE]:
            wth += [self.ui.labelName, self.ui.lineEditName]

        if t != ATConst.ITEM_OPACITY:
            wth += [self.ui.labelOpacity, self.ui.doubleSpinBoxOpacity]

        if t != ATConst.ITEM_TEXTURE:
            if t != ATConst.ITEM_GROWING_LINE:
                wth += [self.ui.labelComboBox1, self.ui.comboBox1]

            wth += [self.ui.labelComboBox2, self.ui.comboBox2]

        if t != ATConst.ITEM_CAMERA:
            wth += [self.ui.labelNarration, self.ui.toolButtonAddImage, self.ui.toolButtonPreview, self.ui.plainTextEdit]

        if t == ATConst.ITEM_GROWING_LINE:
            wth += [self.ui.widgetTopBar]

        for w in wth:
            w.setVisible(False)

        self.resize(self.minimumSize())

        # set values
        self.ui.labelKFCount.setText("/ {}".format(self.kfCount))
        self.ui.slider.setMaximum(self.kfCount - 1)

        self.easingButtons[item.data(0, ATConst.DATA_EASING)].setChecked(True)

        idxFrom = min(group.indexOfChild(item), self.kfCount - 1)
        self.ui.slider.setValue(idxFrom)
        self.currentKeyframeChanged(idxFrom)

        # signal-slot
        self.ui.slider.valueChanged.connect(self.currentKeyframeChanged)
        self.ui.toolButtonPrev.clicked.connect(self.prevKeyframe)
        self.ui.toolButtonNext.clicked.connect(self.nextKeyframe)
        self.ui.toolButtonPlay.clicked.connect(self.play)
        self.ui.pushButtonPlayAll.clicked.connect(self.playAll)
        self.ui.toolButtonAddImage.clicked.connect(self.addImage)
        self.ui.toolButtonPreview.clicked.connect(self.showNarrativeBox)

        self.ui.lineEditDelay.editingFinished.connect(self.apply)
        self.ui.lineEditDuration.editingFinished.connect(self.apply)

    def prevKeyframe(self):
        self.ui.slider.setValue(self.ui.slider.value() - 1)

    def nextKeyframe(self):
        self.ui.slider.setValue(self.ui.slider.value() + 1)

    def currentKeyframeChanged(self, value):
        self.ui.lineEditCurrentKF.setText(str(value + 1))
        self.ui.toolButtonPrev.setEnabled(value > 0)
        self.ui.toolButtonNext.setEnabled(value < self.kfCount - 1)

        hasTrans = not self.isKF or value < self.kfCount - 1
        self.ui.toolButtonPlay.setEnabled(hasTrans)

        if self.currentItem and not self.isPlaying:
            self.apply()

        p = self.item.parent()
        item = p.child(value)
        self.currentItem = item

        self.easingButtons[item.data(0, ATConst.DATA_EASING)].setChecked(True)

        delay = str(item.data(0, ATConst.DATA_DELAY))
        duration = str(item.data(0, ATConst.DATA_DURATION))

        if self.type == ATConst.ITEM_GROWING_LINE:
            d = [delay, duration, str(0), str(DEF_SETS.ANM_DURATION)]

            if item.data(0, ATConst.DATA_SEQ):
                d = d[2:4] + d[0:2]
                self.ui.comboBox1.setCurrentIndex(1)
            else:
                self.modeChanged(0)

            self.ui.lineEditDelay.setText(d[0])
            self.ui.lineEditDuration.setText(d[1])
            self.ui.expressionDelay.setExpression(d[2])
            self.ui.expressionDuration.setExpression(d[3])
        else:
            self.ui.lineEditDelay.setText(delay)
            self.ui.lineEditDuration.setText(duration)
            self.updateTime(p, value)

        if self.isKF:
            nar = item.data(0, ATConst.DATA_NARRATION) or {}
            self.narId = nar.get("id")
            self.ui.plainTextEdit.setPlainText(nar.get("text") or "")

        if self.type == ATConst.ITEM_CAMERA:
            self.ui.lineEditName.setText(item.text(0))

        elif self.type == ATConst.ITEM_OPACITY:
            self.ui.doubleSpinBoxOpacity.setValue(item.data(0, ATConst.DATA_OPACITY))

        elif self.type == ATConst.ITEM_TEXTURE:
            idx = self.ui.comboBox1.findData(item.data(0, ATConst.DATA_MTL_ID))
            if idx != -1:
                self.ui.comboBox1.setCurrentIndex(idx)

            idx = self.ui.comboBox2.findData(item.data(0, ATConst.DATA_EFFECT))
            if idx != -1:
                self.ui.comboBox2.setCurrentIndex(idx)

        elif self.type == ATConst.ITEM_GROWING_LINE:
            self.ui.lineEditName.setText(item.text(0))

        if self.isKF and value >= self.kfCount - 2:
            for w in [self.ui.labelDelay, self.ui.lineEditDelay, self.ui.labelDuration, self.ui.lineEditDuration,
                      self.ui.labelComboBox2, self.ui.comboBox2, self.ui.labelBegin, self.ui.labelEnd,
                      self.ui.labelEasing] + list(self.easingButtons.values()):
                w.setEnabled(hasTrans)

        if not self.isPlaying:
            self.panel.tree.setCurrentItem(item)

    def updateTime(self, parentItem, index):

        def setUnknown():
            t = "unknown"
            self.ui.labelTimeBegin.setText(t)
            self.ui.labelTimeEnd.setText(t)
            self.ui.labelTotal.setText(t)

        if self.useExpression():
            return setUnknown()

        fmt = "{:.0f}:{:06.3f}"
        idxEnd = self.kfCount - 1 if self.isKF else self.kfCount
        total = 0

        try:
            for i in range(0, index):
                item = parentItem.child(i)
                total += item.data(0, ATConst.DATA_DELAY) + item.data(0, ATConst.DATA_DURATION)

            if index < idxEnd:
                begin = total + parentItem.child(index).data(0, ATConst.DATA_DELAY)
                end = begin + parentItem.child(index).data(0, ATConst.DATA_DURATION)
                total = end

                for i in range(index + 1, idxEnd):
                    item = parentItem.child(i)
                    total += item.data(0, ATConst.DATA_DELAY) + item.data(0, ATConst.DATA_DURATION)

                b = fmt.format(*divmod(begin / 1000, 60))
                e = fmt.format(*divmod(end / 1000, 60))
            else:
                b = e = ""

            self.ui.labelTimeBegin.setText(b)
            self.ui.labelTimeEnd.setText(e)
            self.ui.labelTotal.setText(fmt.format(*divmod(total / 1000, 60)))
        except:
            setUnknown()

    def useExpression(self):
        return bool(self.type == ATConst.ITEM_GROWING_LINE and self.ui.comboBox1.currentIndex())

    def modeChanged(self, index):
        b = bool(index)
        self.ui.expressionDelay.setVisible(b)
        self.ui.expressionDuration.setVisible(b)
        self.ui.lineEditDelay.setVisible(not b)
        self.ui.lineEditDuration.setVisible(not b)

        self.updateTime(self.item.parent(), self.ui.slider.value())

    def apply(self):
        if not self.type & ATConst.ITEM_MBR:
            return

        item = self.currentItem

        easing = self.ui.buttonGroup.checkedId()
        item.setData(0, ATConst.DATA_EASING, easing if easing >= 0 else ATConst.EASING_LINEAR)

        if self.useExpression():
            delay = self.ui.expressionDelay.expression() or "0"
            duration = self.ui.expressionDuration.expression() or str(DEF_SETS.ANM_DURATION)
        else:
            delay = parseInt(self.ui.lineEditDelay.text(), 0)
            duration = parseInt(self.ui.lineEditDuration.text(), DEF_SETS.ANM_DURATION)

        item.setData(0, ATConst.DATA_DELAY, delay)
        item.setData(0, ATConst.DATA_DURATION, duration)

        icon = None
        if self.type == ATConst.ITEM_CAMERA:
            item.setText(0, self.ui.lineEditName.text())

        elif self.type == ATConst.ITEM_OPACITY:
            opacity = self.ui.doubleSpinBoxOpacity.value()
            item.setText(0, "opacity '{}'".format(opacity))
            item.setData(0, ATConst.DATA_OPACITY, opacity)

        elif self.type == ATConst.ITEM_TEXTURE:
            item.setText(0, self.ui.comboBox1.currentText())
            item.setData(0, ATConst.DATA_MTL_ID, self.ui.comboBox1.currentData())
            item.setData(0, ATConst.DATA_EFFECT, self.ui.comboBox2.currentData())

        elif self.type == ATConst.ITEM_GROWING_LINE:
            item.setText(0, self.ui.lineEditName.text())
            item.setData(0, ATConst.DATA_SEQ, self.ui.comboBox1.currentData())
            icon = self.panel.tree.effectIcon

        if self.isKF:
            text = self.ui.plainTextEdit.toPlainText()
            if text:
                nar = {
                    "id": self.narId or ("nar_" + createUid()),
                    "text": text
                }
                icon = self.panel.iconNarration
            else:
                nar = None

            item.setData(0, ATConst.DATA_NARRATION, nar)

        item.setIcon(0, icon if icon else self.panel.tree.keyframeIcon)

        p = item.parent()
        self.updateTime(p, p.indexOfChild(item))

    def accept(self):
        if self.type & ATConst.ITEM_MBR:
            self.apply()

        QDialog.accept(self)

    def addImage(self):
        filename = selectImageFile(self)
        if filename:
            url = QUrl.fromLocalFile(filename).toString()
            self.ui.plainTextEdit.insertPlainText('<img src="{}" width="100%">'.format(url))

    def showNarrativeBox(self):
        self.panel.showNarrativeBox(self.ui.plainTextEdit.toPlainText())

    def playAnimation(self, items):
        self.panel.playAnimation(items)

        self.panel.tree.clearSelection()

        self.ui.toolButtonPlay.setIcon(self.panel.iconStop)
        self.ui.pushButtonPlayAll.setIcon(self.panel.iconStop)
        self.ui.pushButtonPlayAll.setText("")
        self.isPlaying = True

    def stopAnimation(self):
        self.panel.stopAnimation()
        self.isPlaying = self.isPlayingAll = False

    def play(self):
        if not self.isPlaying:
            self.apply()

            if self.type & ATConst.ITEM_MBR:
                if self.currentItem:
                    self.playAnimation([self.currentItem])
        else:
            self.stopAnimation()

    def playAll(self):
        if not self.isPlaying:
            self.apply()

            if self.type & ATConst.ITEM_MBR:
                self.playAnimation([self.item.parent()])
                self.isPlayingAll = True
        else:
            self.stopAnimation()

    # @pyqtSlot()
    def animationStopped(self):
        self.ui.toolButtonPlay.setIcon(self.panel.iconPlay)
        self.ui.toolButtonPlay.setChecked(False)
        self.ui.pushButtonPlayAll.setIcon(self.panel.iconPlay)
        self.ui.pushButtonPlayAll.setText("Play All")
        self.ui.pushButtonPlayAll.setChecked(False)

        self.isPlaying = self.isPlayingAll = False

    # @pyqtSlot(int)
    def tweenStarted(self, index):
        if self.isPlayingAll:
            if DEBUG_MODE:
                logMessage("TWEENING {} ...".format(index))
            self.ui.slider.setValue(index)
back to top