From 352ac9a491624b211ed56cc58ac129705fb4f98a Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Fri, 1 Apr 2022 12:25:59 +1000 Subject: [PATCH 1/3] Implement data defined min/max scales --- animation_controller.py | 55 +++++++++++- animation_workbench.py | 74 +++++++++++++++- ui/animation_workbench_base.ui | 157 ++++++++++++++++++++++++++------- 3 files changed, 249 insertions(+), 37 deletions(-) diff --git a/animation_controller.py b/animation_controller.py index 3a706a7..781d600 100644 --- a/animation_controller.py +++ b/animation_controller.py @@ -28,7 +28,9 @@ QgsRectangle, QgsFeature, QgsMapLayerUtils, - Qgis + Qgis, + QgsPropertyDefinition, + QgsPropertyCollection ) from .render_queue import RenderJob @@ -50,6 +52,14 @@ class AnimationController(QObject): normal_message = pyqtSignal(str) verbose_message = pyqtSignal(str) + PROPERTY_MIN_SCALE = 1 + PROPERTY_MAX_SCALE = 2 + + DYNAMIC_PROPERTIES = { + PROPERTY_MIN_SCALE: QgsPropertyDefinition('min_scale', 'Minimum scale', QgsPropertyDefinition.DoublePositive), + PROPERTY_MAX_SCALE: QgsPropertyDefinition('max_scale', 'Maximum scale', QgsPropertyDefinition.DoublePositive), + } + @staticmethod def create_fixed_extent_controller( map_settings: QgsMapSettings, @@ -95,6 +105,10 @@ def create_moving_extent_controller( controller.feature_layer = feature_layer controller.total_feature_count = feature_layer.featureCount() + context = map_settings.expressionContext() + context.appendScope(feature_layer.createExpressionContextScope()) + map_settings.setExpressionContext(context) + # Subtract one because we already start at the first feature controller.total_frame_count = (controller.total_feature_count - 1) * ( dwell_frames + travel_frames @@ -117,6 +131,8 @@ def __init__(self, map_mode: MapMode, map_settings: QgsMapSettings): self.map_settings: QgsMapSettings = map_settings self.map_mode: MapMode = map_mode + self.data_defined_properties = QgsPropertyCollection() + self.feature_layer: Optional[QgsVectorLayer] = None self.layer_to_map_transform: Optional[QgsCoordinateTransform] = None self.total_feature_count: int = 0 @@ -128,6 +144,9 @@ def __init__(self, map_mode: MapMode, map_settings: QgsMapSettings): self.max_scale: float = 0 self.min_scale: float = 0 + self._evaluated_min_scale = None + self._evaluated_max_scale = None + self.pan_easing: Optional[QEasingCurve] = None self.zoom_easing: Optional[QEasingCurve] = None @@ -141,6 +160,7 @@ def __init__(self, map_mode: MapMode, map_settings: QgsMapSettings): self.previous_feature: Optional[QgsFeature] = None self.reuse_cache: bool = False + self.flying_up = False def create_job_for_frame(self, frame: int) -> Optional[RenderJob]: """ @@ -183,8 +203,20 @@ def create_fixed_extent_job(self) -> Iterator[RenderJob]: yield job def create_moving_extent_job(self) -> Iterator[RenderJob]: + self._evaluated_max_scale = self.max_scale + self._evaluated_min_scale = self.min_scale + self.set_to_scale(self.min_scale) for feature in self.feature_layer.getFeatures(): + context = self.map_settings.expressionContext() + context.setFeature(feature) + self.map_settings.setExpressionContext(context) + + # update min scale as soon as we move to the next feature + self._evaluated_min_scale = self.min_scale + if self.data_defined_properties.hasActiveProperties(): + self._evaluated_min_scale,_ = self.data_defined_properties.valueAsDouble(AnimationController.PROPERTY_MIN_SCALE, context, self.min_scale) + if self.previous_feature is not None: for job in self.fly_feature_to_feature( self.previous_feature, feature @@ -267,7 +299,7 @@ def dwell_at_feature(self, feature) -> Iterator[RenderJob]: center = self.layer_to_map_transform.transform(center) self.set_extent_center(center.x(), center.y()) - self.set_to_scale(self.max_scale) + self.set_to_scale(self._evaluated_max_scale) # Change CRS if needed if self.map_mode == MapMode.SPHERE: definition = """ +proj=ortho \ @@ -351,18 +383,33 @@ def fly_feature_to_feature( zoom_factor = self.zoom_easing.valueForProgress( progress_fraction * 2 ) + self.flying_up = True else: # flying down # take progress from 0.5 -> 1.0 and scale to 1 ->0 # before apply easing + + if self.flying_up: + # update max scale at the half way point + context = self.map_settings.expressionContext() + context.setFeature(end_feature) + self.map_settings.setExpressionContext(context) + + self._evaluated_max_scale = self.max_scale + if self.data_defined_properties.hasActiveProperties(): + self._evaluated_max_scale, _ = self.data_defined_properties.valueAsDouble( + AnimationController.PROPERTY_MAX_SCALE, context, self.max_scale) + + self.flying_up = False + zoom_factor = self.zoom_easing.valueForProgress( (1 - progress_fraction) * 2 ) zoom_factor = self.zoom_easing.valueForProgress(zoom_factor) scale = ( - self.min_scale - self.max_scale - ) * zoom_factor + self.max_scale + self._evaluated_min_scale - self._evaluated_max_scale + ) * zoom_factor + self._evaluated_max_scale self.set_to_scale(scale) # Change CRS if needed diff --git a/animation_workbench.py b/animation_workbench.py index efbeca9..578629a 100644 --- a/animation_workbench.py +++ b/animation_workbench.py @@ -35,8 +35,15 @@ QgsMapLayerProxyModel, QgsReferencedRectangle, QgsApplication, + QgsExpressionContextGenerator, + QgsPropertyCollection, + QgsExpressionContext, + QgsVectorLayer +) +from qgis.gui import ( + QgsExtentWidget, + QgsPropertyOverrideButton ) -from qgis.gui import QgsExtentWidget from .settings import set_setting, setting from .utilities import get_ui_class, resources_path @@ -50,6 +57,24 @@ FORM_CLASS = get_ui_class("animation_workbench_base.ui") +class DialogExpressionContextGenerator(QgsExpressionContextGenerator): + + def __init__(self): + super().__init__() + self.layer = None + + def set_layer(self, layer: QgsVectorLayer): + self.layer = layer + + def createExpressionContext(self) -> QgsExpressionContext: + context = QgsExpressionContext() + context.appendScope(QgsExpressionContextUtils.globalScope()) + context.appendScope(QgsExpressionContextUtils.projectScope(QgsProject.instance())) + if self.layer: + context.appendScope(self.layer.createExpressionContextScope()) + return context + + class AnimationWorkbench(QDialog, FORM_CLASS): """Dialog implementation class Animation Workbench class.""" @@ -68,6 +93,8 @@ def __init__(self, parent=None, iface=None, render_queue=None): QDialog.__init__(self, parent) self.setupUi(self) + self.expression_context_generator = DialogExpressionContextGenerator() + self.extent_group_box = QgsExtentWidget( None, QgsExtentWidget.ExpandedStyle ) @@ -82,6 +109,8 @@ def __init__(self, parent=None, iface=None, render_queue=None): self.parent = parent self.iface = iface + self.data_defined_properties = QgsPropertyCollection() + self.extent_group_box.setMapCanvas(self.iface.mapCanvas()) self.scale_range.setMapCanvas(self.iface.mapCanvas()) @@ -126,6 +155,7 @@ def __init__(self, parent=None, iface=None, render_queue=None): | QgsMapLayerProxyModel.LineLayer | QgsMapLayerProxyModel.PolygonLayer ) + self.layer_combo.layerChanged.connect(self._layer_changed) prev_layer_id, ok = QgsProject.instance().readEntry( "animation", "layer_id" @@ -349,6 +379,9 @@ def __init__(self, parent=None, iface=None, render_queue=None): self.show_preview_for_frame ) + self.register_data_defined_button(self.scale_min_dd_btn, AnimationController.PROPERTY_MIN_SCALE) + self.register_data_defined_button(self.scale_max_dd_btn, AnimationController.PROPERTY_MAX_SCALE) + def close(self): self.save_state() self.reject() @@ -357,6 +390,44 @@ def closeEvent(self, event): self.save_state() self.reject() + def _layer_changed(self, layer): + """ + Triggered when the layer is changed + """ + self.expression_context_generator.set_layer(layer) + + buttons = self.findChildren(QgsPropertyOverrideButton) + for button in buttons: + button.setVectorLayer(layer) + + def register_data_defined_button(self, button, property_key: int): + """ + Registers a new data defined button, linked to the given property key (see values in AnimationController) + """ + button.init(property_key, self.data_defined_properties, AnimationController.DYNAMIC_PROPERTIES, None, False) + button.changed.connect(self._update_property) + button.registerExpressionContextGenerator(self.expression_context_generator) + button.setVectorLayer(self.layer_combo.currentLayer()) + + def _update_property(self): + """ + Triggered when a property override button value is changed + """ + button = self.sender() + self.data_defined_properties.setProperty(button.propertyKey(), button.toProperty()) + + def update_data_defined_button(self, button): + """ + Updates the current state of a property override button to reflect the current + property value + """ + if button.propertyKey() < 0: + return + + button.blockSignals(True) + button.setToProperty(self.data_defined_properties.property(button.propertyKey())) + button.blockSignals(False) + def show_message(self, message): self.output_log_text_edit.append(message) @@ -637,6 +708,7 @@ def create_controller(self) -> Optional[AnimationController]: self.output_log_text_edit.append(f"Processing halted: {e}") return None + controller.data_defined_properties=QgsPropertyCollection(self.data_defined_properties) return controller def processing_completed(self, success: bool): diff --git a/ui/animation_workbench_base.ui b/ui/animation_workbench_base.ui index 8ff91c1..be2de70 100644 --- a/ui/animation_workbench_base.ui +++ b/ui/animation_workbench_base.ui @@ -164,34 +164,7 @@ zoom to each point. 6 - - - - true - - - The scale range that the animation should -move through. The smallest scale will be -the zenith of the animation when it zooms -out while travelling between points, and the -largest scale will be the scale used when -we arrive at each point. - - - Zoom Range - - - - - - Qt::StrongFocus - - - - - - - + Animation Frames @@ -278,6 +251,33 @@ how many frames per second to use. + + + + true + + + The scale range that the animation should +move through. The smallest scale will be +the zenith of the animation when it zooms +out while travelling between points, and the +largest scale will be the scale used when +we arrive at each point. + + + Zoom Range + + + + + + Qt::StrongFocus + + + + + + @@ -290,6 +290,94 @@ how many frames per second to use. + + + + Data Defined Settings + + + + + + + 0 + 0 + + + + Maximum + + + + + + + + + + + + + + + 0 + 0 + + + + Scale + + + + + + + + 0 + 0 + + + + Minimum + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + @@ -669,8 +757,8 @@ how many frames per second to use. <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> <html><head><meta name="qrichtext" content="1" /><style type="text/css"> p, li { white-space: pre-wrap; } -</style></head><body style=" font-family:'Cantarell'; font-size:11pt; font-weight:400; font-style:normal;"> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p></body></html> +</style></head><body style=" font-family:'Fira Sans'; font-size:11pt; font-weight:400; font-style:normal;"> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'Cantarell';"><br /></p></body></html> @@ -700,12 +788,12 @@ p, li { white-space: pre-wrap; } QgsMapLayerComboBox QComboBox -
qgsmaplayercombobox.h
+
qgis.gui
QgsScaleRangeWidget QWidget -
qgsscalerangewidget.h
+
qgis.gui
EasingPreview @@ -713,6 +801,11 @@ p, li { white-space: pre-wrap; }
QGISAnimationWorkbench.easing_preview
1
+ + QgsPropertyOverrideButton + QToolButton +
qgis.gui
+
scale_range From c5af9da17437c13e891c84bbf466a069c8e01cb1 Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Fri, 1 Apr 2022 13:29:54 +1000 Subject: [PATCH 2/3] Fix data defined scale logic and add @to_feature, @from_feature variables for min scale property --- animation_controller.py | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/animation_controller.py b/animation_controller.py index 781d600..1364d74 100644 --- a/animation_controller.py +++ b/animation_controller.py @@ -30,7 +30,8 @@ QgsMapLayerUtils, Qgis, QgsPropertyDefinition, - QgsPropertyCollection + QgsPropertyCollection, + QgsExpressionContext ) from .render_queue import RenderJob @@ -101,14 +102,14 @@ def create_moving_extent_controller( if not feature_layer: raise InvalidAnimationParametersException("No animation layer set") - controller = AnimationController(mode, map_settings) - controller.feature_layer = feature_layer - controller.total_feature_count = feature_layer.featureCount() - context = map_settings.expressionContext() context.appendScope(feature_layer.createExpressionContextScope()) map_settings.setExpressionContext(context) + controller = AnimationController(mode, map_settings) + controller.feature_layer = feature_layer + controller.total_feature_count = feature_layer.featureCount() + # Subtract one because we already start at the first feature controller.total_frame_count = (controller.total_feature_count - 1) * ( dwell_frames + travel_frames @@ -131,6 +132,8 @@ def __init__(self, map_mode: MapMode, map_settings: QgsMapSettings): self.map_settings: QgsMapSettings = map_settings self.map_mode: MapMode = map_mode + self.expression_context = self.map_settings.expressionContext() + self.data_defined_properties = QgsPropertyCollection() self.feature_layer: Optional[QgsVectorLayer] = None @@ -203,19 +206,35 @@ def create_fixed_extent_job(self) -> Iterator[RenderJob]: yield job def create_moving_extent_job(self) -> Iterator[RenderJob]: - self._evaluated_max_scale = self.max_scale self._evaluated_min_scale = self.min_scale self.set_to_scale(self.min_scale) for feature in self.feature_layer.getFeatures(): - context = self.map_settings.expressionContext() + if self.previous_feature is None: + # first feature, need to evaluate the starting scale + context = QgsExpressionContext(self.expression_context) + context.setFeature(feature) + self.map_settings.setExpressionContext(context) + + self._evaluated_max_scale = self.max_scale + if self.data_defined_properties.hasActiveProperties(): + self._evaluated_max_scale, _ = self.data_defined_properties.valueAsDouble( + AnimationController.PROPERTY_MAX_SCALE, context, self.max_scale) + + context = QgsExpressionContext(self.expression_context) context.setFeature(feature) + + scope = QgsExpressionContextScope() + scope.setVariable('from_feature', self.previous_feature, True) + scope.setVariable('to_feature', feature, True) + context.appendScope(scope) + self.map_settings.setExpressionContext(context) - # update min scale as soon as we move to the next feature + # update min scale as soon as we are ready to move to the next feature self._evaluated_min_scale = self.min_scale if self.data_defined_properties.hasActiveProperties(): - self._evaluated_min_scale,_ = self.data_defined_properties.valueAsDouble(AnimationController.PROPERTY_MIN_SCALE, context, self.min_scale) + self._evaluated_min_scale, _ = self.data_defined_properties.valueAsDouble(AnimationController.PROPERTY_MIN_SCALE, context, self.min_scale) if self.previous_feature is not None: for job in self.fly_feature_to_feature( @@ -391,7 +410,7 @@ def fly_feature_to_feature( if self.flying_up: # update max scale at the half way point - context = self.map_settings.expressionContext() + context = QgsExpressionContext(self.expression_context) context.setFeature(end_feature) self.map_settings.setExpressionContext(context) From 61172823990d3f2a02fd596c93e274613b3f12df Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Fri, 1 Apr 2022 13:37:38 +1000 Subject: [PATCH 3/3] Remember data defined properties in project --- animation_controller.py | 2 +- animation_workbench.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/animation_controller.py b/animation_controller.py index 1364d74..571a083 100644 --- a/animation_controller.py +++ b/animation_controller.py @@ -220,7 +220,7 @@ def create_moving_extent_job(self) -> Iterator[RenderJob]: if self.data_defined_properties.hasActiveProperties(): self._evaluated_max_scale, _ = self.data_defined_properties.valueAsDouble( AnimationController.PROPERTY_MAX_SCALE, context, self.max_scale) - + context = QgsExpressionContext(self.expression_context) context.setFeature(feature) diff --git a/animation_workbench.py b/animation_workbench.py index 578629a..77a81ca 100644 --- a/animation_workbench.py +++ b/animation_workbench.py @@ -28,6 +28,10 @@ QGridLayout, QVBoxLayout, ) +from qgis.PyQt.QtXml import ( + QDomDocument, + QDomElement +) from qgis.core import ( QgsPointXY, QgsExpressionContextUtils, @@ -165,6 +169,15 @@ def __init__(self, parent=None, iface=None, render_queue=None): if layer: self.layer_combo.setLayer(layer) + prev_data_defined_properties_xml, _ = QgsProject.instance().readEntry( + "animation", "data_defined_properties" + ) + if prev_data_defined_properties_xml: + doc = QDomDocument() + doc.setContent(prev_data_defined_properties_xml.encode()) + elem = doc.firstChildElement("data_defined_properties") + self.data_defined_properties.readXml(elem, AnimationController.DYNAMIC_PROPERTIES) + self.extent_group_box.setOutputCrs(QgsProject.instance().crs()) self.extent_group_box.setOutputExtentFromUser( self.iface.mapCanvas().extent(), QgsProject.instance().crs() @@ -579,6 +592,13 @@ def save_state(self): ) else: QgsProject.instance().removeEntry("animation", "layer_id") + temp_doc = QDomDocument() + dd_elem = temp_doc.createElement('data_defined_properties') + self.data_defined_properties.writeXml(dd_elem, AnimationController.DYNAMIC_PROPERTIES) + temp_doc.appendChild(dd_elem) + QgsProject.instance().writeEntry( + "animation", "data_defined_properties", temp_doc.toString() + ) # Prevent the slot being called twize @pyqtSlot()