From 3ed58be9164cf446ba11affe0072fec9ae329113 Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Wed, 21 Aug 2024 15:02:00 +1000 Subject: [PATCH 1/5] [feature] Add "Linear Referencing" symbol layer type This new symbol layer type allows placing text labels at regular intervals along a line (or at positions corresponding to existing vertices). Positions can be calculated using Cartesian distances, or interpolated from z/m values. Functionality includes: - Labels can be placed using fixed cartesian 2d distances, at regular linearly interpolated spacing calculated using the Z or M values in geometries, or at existing vertices - Labels can show either the running total distance, or the linearly interpolated Z/M value - Uses text rendered to draw labels, so the full range of functionality is available for the labels (including buffers, shadows, etc) - Uses the QGIS numeric format classes to format numbers as strings, so users have full range of customisation options for eg decimal places - An optional "skip multiples of" setting. If set, then labels which are a multiple of this value will be skipped over. This allows construction of complex referencing labels, eg where a symbol has two linear referencing symbol layers, one set to label every 100m in a small font, skipping multiples of 1000, and a second set to label every 1000m in a big bold font - Labels are rendered using an angle calculated by averaging the linestring, so sharp tiny jaggies don't result in unslightly label rotation - Optionally, markers can be placed at referenced points in the line string, using a full QGIS marker symbol (this allows eg showing a cross-hatch at the labeled point, for a "ruler" style line) - Data defined control over the placement intervals, skip multiples setting, marker visibility and average angle calculation length Notes: - When using the distance-based placement or labels, the distances are calculated using 2D only, Cartesian calculations based on the original layer CRS. This could potentially be extended in future to expose options for 3D Cartesian distances, or ellipsoidal distance calculations. Sponsored by the Swiss QGIS User Group --- python/PyQt6/core/auto_additions/qgis.py | 32 + .../qgslinearreferencingsymbollayer.py | 6 + .../core/auto_additions/qgssymbollayer.py | 14 + python/PyQt6/core/auto_generated/qgis.sip.in | 15 + .../qgslinearreferencingsymbollayer.sip.in | 326 ++++++ .../symbology/qgssymbollayer.sip.in | 4 + python/PyQt6/core/core_auto.sip | 1 + .../auto_additions/qgssymbollayerwidget.py | 5 + .../symbology/qgssymbollayerwidget.sip.in | 40 + python/core/auto_additions/qgis.py | 32 + .../qgslinearreferencingsymbollayer.py | 6 + python/core/auto_additions/qgssymbollayer.py | 14 + python/core/auto_generated/qgis.sip.in | 15 + .../qgslinearreferencingsymbollayer.sip.in | 326 ++++++ .../symbology/qgssymbollayer.sip.in | 4 + python/core/core_auto.sip | 1 + .../auto_additions/qgssymbollayerwidget.py | 5 + .../symbology/qgssymbollayerwidget.sip.in | 40 + src/core/CMakeLists.txt | 2 + src/core/qgis.h | 27 + .../qgslinearreferencingsymbollayer.cpp | 1001 +++++++++++++++++ .../qgslinearreferencingsymbollayer.h | 334 ++++++ src/core/symbology/qgssymbollayer.cpp | 2 + src/core/symbology/qgssymbollayer.h | 4 + src/core/symbology/qgssymbollayerregistry.cpp | 3 + .../symbology/qgslayerpropertieswidget.cpp | 1 + src/gui/symbology/qgssymbollayerwidget.cpp | 241 ++++ src/gui/symbology/qgssymbollayerwidget.h | 46 +- src/gui/symbology/qgssymbolselectordialog.cpp | 3 +- ...slinearreferencingsymbollayerwidgetbase.ui | 439 ++++++++ tests/src/python/CMakeLists.txt | 1 + .../test_qgslinearreferencingsymbollayer.py | 759 +++++++++++++ .../expected_distance_2d.png | Bin 0 -> 6584 bytes .../expected_distance_with_m.png | Bin 0 -> 10031 bytes .../expected_distance_with_z.png | Bin 0 -> 10449 bytes .../expected_marker/expected_marker.png | Bin 0 -> 7124 bytes .../expected_marker_no_rotate.png | Bin 0 -> 6838 bytes .../expected_multiline/expected_multiline.png | Bin 0 -> 6537 bytes .../expected_no_rotate/expected_no_rotate.png | Bin 0 -> 6244 bytes .../expected_numeric_format.png | Bin 0 -> 12623 bytes .../expected_placement_by_m_distance.png | Bin 0 -> 8227 bytes .../expected_placement_by_m_m.png | Bin 0 -> 9117 bytes .../expected_placement_by_m_z.png | Bin 0 -> 9422 bytes .../expected_placement_by_z_distance.png | Bin 0 -> 7709 bytes .../expected_placement_by_z_m.png | Bin 0 -> 6670 bytes .../expected_placement_by_z_z.png | Bin 0 -> 8189 bytes .../expected_polygon/expected_polygon.png | Bin 0 -> 14988 bytes .../expected_skip_multiples.png | Bin 0 -> 5110 bytes .../expected_vertex_distance.png | Bin 0 -> 6564 bytes .../expected_vertex_m/expected_vertex_m.png | Bin 0 -> 7101 bytes .../expected_vertex_z/expected_vertex_z.png | Bin 0 -> 8057 bytes 51 files changed, 3746 insertions(+), 3 deletions(-) create mode 100644 python/PyQt6/core/auto_additions/qgslinearreferencingsymbollayer.py create mode 100644 python/PyQt6/core/auto_generated/symbology/qgslinearreferencingsymbollayer.sip.in create mode 100644 python/core/auto_additions/qgslinearreferencingsymbollayer.py create mode 100644 python/core/auto_generated/symbology/qgslinearreferencingsymbollayer.sip.in create mode 100644 src/core/symbology/qgslinearreferencingsymbollayer.cpp create mode 100644 src/core/symbology/qgslinearreferencingsymbollayer.h create mode 100644 src/ui/symbollayer/qgslinearreferencingsymbollayerwidgetbase.ui create mode 100644 tests/src/python/test_qgslinearreferencingsymbollayer.py create mode 100644 tests/testdata/control_images/symbol_linearref/expected_distance_2d/expected_distance_2d.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_distance_with_m/expected_distance_with_m.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_distance_with_z/expected_distance_with_z.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_marker/expected_marker.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_marker_no_rotate/expected_marker_no_rotate.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_multiline/expected_multiline.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_no_rotate/expected_no_rotate.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_numeric_format/expected_numeric_format.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_placement_by_m_distance/expected_placement_by_m_distance.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_placement_by_m_m/expected_placement_by_m_m.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_placement_by_m_z/expected_placement_by_m_z.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_placement_by_z_distance/expected_placement_by_z_distance.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_placement_by_z_m/expected_placement_by_z_m.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_placement_by_z_z/expected_placement_by_z_z.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_polygon/expected_polygon.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_skip_multiples/expected_skip_multiples.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_vertex_distance/expected_vertex_distance.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_vertex_m/expected_vertex_m.png create mode 100644 tests/testdata/control_images/symbol_linearref/expected_vertex_z/expected_vertex_z.png diff --git a/python/PyQt6/core/auto_additions/qgis.py b/python/PyQt6/core/auto_additions/qgis.py index 1e9ffc3a0cdc..510065f34965 100644 --- a/python/PyQt6/core/auto_additions/qgis.py +++ b/python/PyQt6/core/auto_additions/qgis.py @@ -5366,6 +5366,38 @@ Qgis.MarkerLinePlacements = lambda flags=0: Qgis.MarkerLinePlacement(flags) Qgis.MarkerLinePlacements.baseClass = Qgis MarkerLinePlacements = Qgis # dirty hack since SIP seems to introduce the flags in module +# monkey patching scoped based enum +Qgis.LinearReferencingPlacement.IntervalCartesian2D.__doc__ = "Place labels at regular intervals, using Cartesian distance calculations on a 2D plane" +Qgis.LinearReferencingPlacement.IntervalZ.__doc__ = "Place labels at regular intervals, linearly interpolated using Z values" +Qgis.LinearReferencingPlacement.IntervalM.__doc__ = "Place labels at regular intervals, linearly interpolated using M values" +Qgis.LinearReferencingPlacement.Vertex.__doc__ = "Place labels on every vertex in the line" +Qgis.LinearReferencingPlacement.__doc__ = """Defines how/where the labels should be placed in a linear referencing symbol layer. + +.. versionadded:: 3.40 + +* ``IntervalCartesian2D``: Place labels at regular intervals, using Cartesian distance calculations on a 2D plane +* ``IntervalZ``: Place labels at regular intervals, linearly interpolated using Z values +* ``IntervalM``: Place labels at regular intervals, linearly interpolated using M values +* ``Vertex``: Place labels on every vertex in the line + +""" +# -- +Qgis.LinearReferencingPlacement.baseClass = Qgis +# monkey patching scoped based enum +Qgis.LinearReferencingLabelSource.CartesianDistance2D.__doc__ = "Distance along line, calculated using Cartesian calculations on a 2D plane." +Qgis.LinearReferencingLabelSource.Z.__doc__ = "Z values" +Qgis.LinearReferencingLabelSource.M.__doc__ = "M values" +Qgis.LinearReferencingLabelSource.__doc__ = """Defines what quantity to use for the labels shown in a linear referencing symbol layer. + +.. versionadded:: 3.40 + +* ``CartesianDistance2D``: Distance along line, calculated using Cartesian calculations on a 2D plane. +* ``Z``: Z values +* ``M``: M values + +""" +# -- +Qgis.LinearReferencingLabelSource.baseClass = Qgis QgsGradientFillSymbolLayer.GradientColorType = Qgis.GradientColorSource # monkey patching scoped based enum QgsGradientFillSymbolLayer.SimpleTwoColor = Qgis.GradientColorSource.SimpleTwoColor diff --git a/python/PyQt6/core/auto_additions/qgslinearreferencingsymbollayer.py b/python/PyQt6/core/auto_additions/qgslinearreferencingsymbollayer.py new file mode 100644 index 000000000000..c876d0fe5649 --- /dev/null +++ b/python/PyQt6/core/auto_additions/qgslinearreferencingsymbollayer.py @@ -0,0 +1,6 @@ +# The following has been generated automatically from src/core/symbology/qgslinearreferencingsymbollayer.h +QgsLinearReferencingSymbolLayer.create = staticmethod(QgsLinearReferencingSymbolLayer.create) +try: + QgsLinearReferencingSymbolLayer.__group__ = ['symbology'] +except NameError: + pass diff --git a/python/PyQt6/core/auto_additions/qgssymbollayer.py b/python/PyQt6/core/auto_additions/qgssymbollayer.py index dab9c6412419..885cad610201 100644 --- a/python/PyQt6/core/auto_additions/qgssymbollayer.py +++ b/python/PyQt6/core/auto_additions/qgssymbollayer.py @@ -280,6 +280,12 @@ QgsSymbolLayer.Property.PropertyLineClipping = QgsSymbolLayer.Property.LineClipping QgsSymbolLayer.PropertyLineClipping.is_monkey_patched = True QgsSymbolLayer.PropertyLineClipping.__doc__ = "Line clipping mode \n.. versionadded:: 3.24" +QgsSymbolLayer.SkipMultiples = QgsSymbolLayer.Property.SkipMultiples +QgsSymbolLayer.SkipMultiples.is_monkey_patched = True +QgsSymbolLayer.SkipMultiples.__doc__ = "Skip multiples of \n.. versionadded:: 3.40" +QgsSymbolLayer.ShowMarker = QgsSymbolLayer.Property.ShowMarker +QgsSymbolLayer.ShowMarker.is_monkey_patched = True +QgsSymbolLayer.ShowMarker.__doc__ = "Show markers \n.. versionadded:: 3.40" QgsSymbolLayer.Property.__doc__ = """Data definable properties. * ``Size``: Symbol size @@ -592,6 +598,14 @@ Available as ``QgsSymbolLayer.PropertyLineClipping`` in older QGIS releases. +* ``SkipMultiples``: Skip multiples of + + .. versionadded:: 3.40 + +* ``ShowMarker``: Show markers + + .. versionadded:: 3.40 + """ # -- diff --git a/python/PyQt6/core/auto_generated/qgis.sip.in b/python/PyQt6/core/auto_generated/qgis.sip.in index 3906b737d0cf..6361d796e310 100644 --- a/python/PyQt6/core/auto_generated/qgis.sip.in +++ b/python/PyQt6/core/auto_generated/qgis.sip.in @@ -1628,6 +1628,21 @@ The development version typedef QFlags MarkerLinePlacements; + enum class LinearReferencingPlacement /BaseType=IntFlag/ + { + IntervalCartesian2D, + IntervalZ, + IntervalM, + Vertex, + }; + + enum class LinearReferencingLabelSource /BaseType=IntEnum/ + { + CartesianDistance2D, + Z, + M, + }; + enum class GradientColorSource /BaseType=IntEnum/ { SimpleTwoColor, diff --git a/python/PyQt6/core/auto_generated/symbology/qgslinearreferencingsymbollayer.sip.in b/python/PyQt6/core/auto_generated/symbology/qgslinearreferencingsymbollayer.sip.in new file mode 100644 index 000000000000..9bd10e6ba006 --- /dev/null +++ b/python/PyQt6/core/auto_generated/symbology/qgslinearreferencingsymbollayer.sip.in @@ -0,0 +1,326 @@ +/************************************************************************ + * This file has been generated automatically from * + * * + * src/core/symbology/qgslinearreferencingsymbollayer.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.py again * + ************************************************************************/ + + + +class QgsLinearReferencingSymbolLayer : QgsLineSymbolLayer +{ +%Docstring(signature="appended") +Line symbol layer used for decorating accordingly to linear referencing. + +This symbol layer type allows placing text labels at regular intervals along +a line (or at positions corresponding to existing vertices). Positions +can be calculated using Cartesian distances, or interpolated from z or m values. + +.. versionadded:: 3.40 +%End + +%TypeHeaderCode +#include "qgslinearreferencingsymbollayer.h" +%End + public: + QgsLinearReferencingSymbolLayer(); + ~QgsLinearReferencingSymbolLayer(); + + static QgsSymbolLayer *create( const QVariantMap &properties = QVariantMap() ) /Factory/; +%Docstring +Creates a new QgsLinearReferencingSymbolLayer, using the specified ``properties``. + +The caller takes ownership of the returned object. +%End + + virtual QgsLinearReferencingSymbolLayer *clone() const /Factory/; + + virtual QVariantMap properties() const; + + virtual QString layerType() const; + + virtual Qgis::SymbolLayerFlags flags() const; + + virtual QgsSymbol *subSymbol(); + + virtual bool setSubSymbol( QgsSymbol *symbol /Transfer/ ); + + virtual void startRender( QgsSymbolRenderContext &context ); + + virtual void stopRender( QgsSymbolRenderContext &context ); + + virtual void renderPolyline( const QPolygonF &points, QgsSymbolRenderContext &context ); + + + QgsTextFormat textFormat() const; +%Docstring +Returns the text format used to render the layer. + +.. seealso:: :py:func:`setTextFormat` +%End + + void setTextFormat( const QgsTextFormat &format ); +%Docstring +Sets the text ``format`` used to render the layer. + +.. seealso:: :py:func:`textFormat` +%End + + QgsNumericFormat *numericFormat() const; +%Docstring +Returns the numeric format used to format the labels for the layer. + +.. seealso:: :py:func:`setNumericFormat` +%End + + void setNumericFormat( QgsNumericFormat *format /Transfer/ ); +%Docstring +Sets the numeric ``format`` used to format the labels for the layer. + +Ownership of ``format`` is transferred to the layer. + +.. seealso:: :py:func:`numericFormat` +%End + + double interval() const; +%Docstring +Returns the interval between labels. + +Units are always in the original layer CRS units. + +.. seealso:: :py:func:`setInterval` +%End + + void setInterval( double interval ); +%Docstring +Sets the ``interval`` between labels. + +Units are always in the original layer CRS units. + +.. seealso:: :py:func:`setInterval` +%End + + double skipMultiplesOf() const; +%Docstring +Returns the multiple distance to skip labels for. + +If this value is non-zero, then any labels which are integer multiples of the returned +value will be skipped. This allows creation of advanced referencing styles where a single +:py:class:`QgsSymbol` has multiple :py:class:`QgsLinearReferencingSymbolLayer` symbol layers, eg allowing +labeling every 100 in a normal font and every 1000 in a bold, larger font. + +.. seealso:: :py:func:`setSkipMultiplesOf` +%End + + void setSkipMultiplesOf( double multiple ); +%Docstring +Sets the ``multiple`` distance to skip labels for. + +If this value is non-zero, then any labels which are integer multiples of the returned +value will be skipped. This allows creation of advanced referencing styles where a single +:py:class:`QgsSymbol` has multiple :py:class:`QgsLinearReferencingSymbolLayer` symbol layers, eg allowing +labeling every 100 in a normal font and every 1000 in a bold, larger font. + +.. seealso:: :py:func:`skipMultiplesOf` +%End + + bool rotateLabels() const; +%Docstring +Returns ``True`` if the labels and symbols are to be rotated to match their line segment orientation. + +.. seealso:: :py:func:`setRotateLabels` +%End + + void setRotateLabels( bool rotate ); +%Docstring +Sets whether the labels and symbols should be rotated to match their line segment orientation. + +.. seealso:: :py:func:`rotateLabels` +%End + + QPointF labelOffset() const; +%Docstring +Returns the offset between the line and linear referencing labels. + +The unit for the offset is retrievable via :py:func:`~QgsLinearReferencingSymbolLayer.labelOffsetUnit`. + +.. seealso:: :py:func:`setLabelOffset` + +.. seealso:: :py:func:`labelOffsetUnit` +%End + + void setLabelOffset( const QPointF &offset ); +%Docstring +Sets the ``offset`` between the line and linear referencing labels. + +The unit for the offset is set via :py:func:`~QgsLinearReferencingSymbolLayer.setLabelOffsetUnit`. + +.. seealso:: :py:func:`labelOffset` + +.. seealso:: :py:func:`setLabelOffsetUnit` +%End + + Qgis::RenderUnit labelOffsetUnit() const; +%Docstring +Returns the unit used for the offset between the line and linear referencing labels. + +.. seealso:: :py:func:`setLabelOffsetUnit` + +.. seealso:: :py:func:`labelOffset` +%End + + void setLabelOffsetUnit( Qgis::RenderUnit unit ); +%Docstring +Sets the ``unit`` used for the offset between the line and linear referencing labels. + +.. seealso:: :py:func:`labelOffsetUnit` + +.. seealso:: :py:func:`setLabelOffset` +%End + + const QgsMapUnitScale &labelOffsetMapUnitScale() const; +%Docstring +Returns the map unit scale used for calculating the offset between the line and linear referencing labels. + +.. seealso:: :py:func:`setLabelOffsetMapUnitScale` +%End + + void setLabelOffsetMapUnitScale( const QgsMapUnitScale &scale ); +%Docstring +Sets the map unit ``scale`` used for calculating the offset between the line and linear referencing labels. + +.. seealso:: :py:func:`labelOffsetMapUnitScale` +%End + + bool showMarker() const; +%Docstring +Returns ``True`` if a marker symbol should be shown corresponding to the labeled point on line. + +The marker symbol is set using :py:func:`~QgsLinearReferencingSymbolLayer.setSubSymbol` + +.. seealso:: :py:func:`setShowMarker` +%End + + void setShowMarker( bool show ); +%Docstring +Sets whether a marker symbol should be shown corresponding to the labeled point on line. + +The marker symbol is set using :py:func:`~QgsLinearReferencingSymbolLayer.setSubSymbol` + +.. seealso:: :py:func:`showMarker` +%End + + Qgis::LinearReferencingPlacement placement() const; +%Docstring +Returns the placement mode for the labels. + +.. seealso:: :py:func:`setPlacement` +%End + + void setPlacement( Qgis::LinearReferencingPlacement placement ); +%Docstring +Sets the ``placement`` mode for the labels. + +.. seealso:: :py:func:`placement` +%End + + Qgis::LinearReferencingLabelSource labelSource() const; +%Docstring +Returns the label source, which dictates what quantity to use for the labels shown. + +.. seealso:: :py:func:`setLabelSource` +%End + + void setLabelSource( Qgis::LinearReferencingLabelSource source ); +%Docstring +Sets the label ``source``, which dictates what quantity to use for the labels shown. + +.. seealso:: :py:func:`labelSource` +%End + + double averageAngleLength() const; +%Docstring +Returns the length of line over which the line's direction is averaged when +calculating individual label angles. Longer lengths smooth out angles from jagged lines to a greater extent. + +Units are retrieved through :py:func:`~QgsLinearReferencingSymbolLayer.averageAngleUnit` + +.. seealso:: :py:func:`setAverageAngleLength` + +.. seealso:: :py:func:`averageAngleUnit` + +.. seealso:: :py:func:`averageAngleMapUnitScale` +%End + + void setAverageAngleLength( double length ); +%Docstring +Sets the ``length`` of line over which the line's direction is averaged when +calculating individual label angles. Longer lengths smooth out angles from jagged lines to a greater extent. + +Units are set through :py:func:`~QgsLinearReferencingSymbolLayer.setAverageAngleUnit` + +.. seealso:: :py:func:`averageAngleLength` + +.. seealso:: :py:func:`setAverageAngleUnit` + +.. seealso:: :py:func:`setAverageAngleMapUnitScale` +%End + + void setAverageAngleUnit( Qgis::RenderUnit unit ); +%Docstring +Sets the ``unit`` for the length over which the line's direction is averaged when +calculating individual label angles. + +.. seealso:: :py:func:`averageAngleUnit` + +.. seealso:: :py:func:`setAverageAngleLength` + +.. seealso:: :py:func:`setAverageAngleMapUnitScale` +%End + + Qgis::RenderUnit averageAngleUnit() const; +%Docstring +Returns the unit for the length over which the line's direction is averaged when +calculating individual label angles. + +.. seealso:: :py:func:`setAverageAngleUnit` + +.. seealso:: :py:func:`averageAngleLength` + +.. seealso:: :py:func:`averageAngleMapUnitScale` +%End + + void setAverageAngleMapUnitScale( const QgsMapUnitScale &scale ); +%Docstring +Sets the map unit ``scale`` for the length over which the line's direction is averaged when +calculating individual label angles. + +.. seealso:: :py:func:`averageAngleMapUnitScale` + +.. seealso:: :py:func:`setAverageAngleLength` + +.. seealso:: :py:func:`setAverageAngleUnit` +%End + + const QgsMapUnitScale &averageAngleMapUnitScale() const; +%Docstring +Returns the map unit scale for the length over which the line's direction is averaged when +calculating individual label angles. + +.. seealso:: :py:func:`setAverageAngleMapUnitScale` + +.. seealso:: :py:func:`averageAngleLength` + +.. seealso:: :py:func:`averageAngleUnit` +%End + +}; + +/************************************************************************ + * This file has been generated automatically from * + * * + * src/core/symbology/qgslinearreferencingsymbollayer.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.py again * + ************************************************************************/ diff --git a/python/PyQt6/core/auto_generated/symbology/qgssymbollayer.sip.in b/python/PyQt6/core/auto_generated/symbology/qgssymbollayer.sip.in index 0e06ee544cd1..38767f5942ec 100644 --- a/python/PyQt6/core/auto_generated/symbology/qgssymbollayer.sip.in +++ b/python/PyQt6/core/auto_generated/symbology/qgssymbollayer.sip.in @@ -60,6 +60,8 @@ class QgsSymbolLayer sipType = sipType_QgsRasterLineSymbolLayer; else if ( sipCpp->layerType() == "Lineburst" ) sipType = sipType_QgsLineburstSymbolLayer; + else if ( sipCpp->layerType() == "LinearReferencing" ) + sipType = sipType_QgsLinearReferencingSymbolLayer; else sipType = sipType_QgsLineSymbolLayer; break; @@ -167,6 +169,8 @@ class QgsSymbolLayer RandomOffsetX, RandomOffsetY, LineClipping, + SkipMultiples, + ShowMarker, }; static const QgsPropertiesDefinition &propertyDefinitions(); diff --git a/python/PyQt6/core/core_auto.sip b/python/PyQt6/core/core_auto.sip index bce7760b497d..46a81dd90d7b 100644 --- a/python/PyQt6/core/core_auto.sip +++ b/python/PyQt6/core/core_auto.sip @@ -690,6 +690,7 @@ %Include auto_generated/symbology/qgsinterpolatedlinerenderer.sip %Include auto_generated/symbology/qgsinvertedpolygonrenderer.sip %Include auto_generated/symbology/qgslegendsymbolitem.sip +%Include auto_generated/symbology/qgslinearreferencingsymbollayer.sip %Include auto_generated/symbology/qgslinesymbol.sip %Include auto_generated/symbology/qgslinesymbollayer.sip %Include auto_generated/symbology/qgsmapinfosymbolconverter.sip diff --git a/python/PyQt6/gui/auto_additions/qgssymbollayerwidget.py b/python/PyQt6/gui/auto_additions/qgssymbollayerwidget.py index c13cebff625c..ed1060b48c09 100644 --- a/python/PyQt6/gui/auto_additions/qgssymbollayerwidget.py +++ b/python/PyQt6/gui/auto_additions/qgssymbollayerwidget.py @@ -24,6 +24,7 @@ QgsRandomMarkerFillSymbolLayerWidget.create = staticmethod(QgsRandomMarkerFillSymbolLayerWidget.create) QgsFontMarkerSymbolLayerWidget.create = staticmethod(QgsFontMarkerSymbolLayerWidget.create) QgsCentroidFillSymbolLayerWidget.create = staticmethod(QgsCentroidFillSymbolLayerWidget.create) +QgsLinearReferencingSymbolLayerWidget.create = staticmethod(QgsLinearReferencingSymbolLayerWidget.create) QgsGeometryGeneratorSymbolLayerWidget.create = staticmethod(QgsGeometryGeneratorSymbolLayerWidget.create) try: QgsSymbolLayerWidget.__group__ = ['symbology'] @@ -113,6 +114,10 @@ QgsCentroidFillSymbolLayerWidget.__group__ = ['symbology'] except NameError: pass +try: + QgsLinearReferencingSymbolLayerWidget.__group__ = ['symbology'] +except NameError: + pass try: QgsGeometryGeneratorSymbolLayerWidget.__group__ = ['symbology'] except NameError: diff --git a/python/PyQt6/gui/auto_generated/symbology/qgssymbollayerwidget.sip.in b/python/PyQt6/gui/auto_generated/symbology/qgssymbollayerwidget.sip.in index c6d22437a572..a3be5562aa1b 100644 --- a/python/PyQt6/gui/auto_generated/symbology/qgssymbollayerwidget.sip.in +++ b/python/PyQt6/gui/auto_generated/symbology/qgssymbollayerwidget.sip.in @@ -990,6 +990,46 @@ Creates a new QgsCentroidFillSymbolLayerWidget. + +class QgsLinearReferencingSymbolLayerWidget : QgsSymbolLayerWidget +{ +%Docstring(signature="appended") +Widget for controlling the properties of a :py:class:`QgsLinearReferencingSymbolLayer`. + +.. versionadded:: 3.40 +%End + +%TypeHeaderCode +#include "qgssymbollayerwidget.h" +%End + public: + + QgsLinearReferencingSymbolLayerWidget( QgsVectorLayer *vl, QWidget *parent /TransferThis/ = 0 ); +%Docstring +Constructor for QgsLinearReferencingSymbolLayerWidget. +%End + + ~QgsLinearReferencingSymbolLayerWidget(); + + static QgsSymbolLayerWidget *create( QgsVectorLayer *vl ) /Factory/; +%Docstring +Creates a new QgsLinearReferencingSymbolLayerWidget. + +:param vl: associated vector layer +%End + + virtual void setSymbolLayer( QgsSymbolLayer *layer ); + + virtual QgsSymbolLayer *symbolLayer(); + + virtual void setContext( const QgsSymbolWidgetContext &context ); + + +}; + + + + class QgsGeometryGeneratorSymbolLayerWidget : QgsSymbolLayerWidget { diff --git a/python/core/auto_additions/qgis.py b/python/core/auto_additions/qgis.py index ca1231fcf2fb..61736503b871 100644 --- a/python/core/auto_additions/qgis.py +++ b/python/core/auto_additions/qgis.py @@ -5316,6 +5316,38 @@ Qgis.MarkerLinePlacement.baseClass = Qgis Qgis.MarkerLinePlacements.baseClass = Qgis MarkerLinePlacements = Qgis # dirty hack since SIP seems to introduce the flags in module +# monkey patching scoped based enum +Qgis.LinearReferencingPlacement.IntervalCartesian2D.__doc__ = "Place labels at regular intervals, using Cartesian distance calculations on a 2D plane" +Qgis.LinearReferencingPlacement.IntervalZ.__doc__ = "Place labels at regular intervals, linearly interpolated using Z values" +Qgis.LinearReferencingPlacement.IntervalM.__doc__ = "Place labels at regular intervals, linearly interpolated using M values" +Qgis.LinearReferencingPlacement.Vertex.__doc__ = "Place labels on every vertex in the line" +Qgis.LinearReferencingPlacement.__doc__ = """Defines how/where the labels should be placed in a linear referencing symbol layer. + +.. versionadded:: 3.40 + +* ``IntervalCartesian2D``: Place labels at regular intervals, using Cartesian distance calculations on a 2D plane +* ``IntervalZ``: Place labels at regular intervals, linearly interpolated using Z values +* ``IntervalM``: Place labels at regular intervals, linearly interpolated using M values +* ``Vertex``: Place labels on every vertex in the line + +""" +# -- +Qgis.LinearReferencingPlacement.baseClass = Qgis +# monkey patching scoped based enum +Qgis.LinearReferencingLabelSource.CartesianDistance2D.__doc__ = "Distance along line, calculated using Cartesian calculations on a 2D plane." +Qgis.LinearReferencingLabelSource.Z.__doc__ = "Z values" +Qgis.LinearReferencingLabelSource.M.__doc__ = "M values" +Qgis.LinearReferencingLabelSource.__doc__ = """Defines what quantity to use for the labels shown in a linear referencing symbol layer. + +.. versionadded:: 3.40 + +* ``CartesianDistance2D``: Distance along line, calculated using Cartesian calculations on a 2D plane. +* ``Z``: Z values +* ``M``: M values + +""" +# -- +Qgis.LinearReferencingLabelSource.baseClass = Qgis QgsGradientFillSymbolLayer.GradientColorType = Qgis.GradientColorSource # monkey patching scoped based enum QgsGradientFillSymbolLayer.SimpleTwoColor = Qgis.GradientColorSource.SimpleTwoColor diff --git a/python/core/auto_additions/qgslinearreferencingsymbollayer.py b/python/core/auto_additions/qgslinearreferencingsymbollayer.py new file mode 100644 index 000000000000..c876d0fe5649 --- /dev/null +++ b/python/core/auto_additions/qgslinearreferencingsymbollayer.py @@ -0,0 +1,6 @@ +# The following has been generated automatically from src/core/symbology/qgslinearreferencingsymbollayer.h +QgsLinearReferencingSymbolLayer.create = staticmethod(QgsLinearReferencingSymbolLayer.create) +try: + QgsLinearReferencingSymbolLayer.__group__ = ['symbology'] +except NameError: + pass diff --git a/python/core/auto_additions/qgssymbollayer.py b/python/core/auto_additions/qgssymbollayer.py index b598c65d339a..fb131e769354 100644 --- a/python/core/auto_additions/qgssymbollayer.py +++ b/python/core/auto_additions/qgssymbollayer.py @@ -280,6 +280,12 @@ QgsSymbolLayer.Property.PropertyLineClipping = QgsSymbolLayer.Property.LineClipping QgsSymbolLayer.PropertyLineClipping.is_monkey_patched = True QgsSymbolLayer.PropertyLineClipping.__doc__ = "Line clipping mode \n.. versionadded:: 3.24" +QgsSymbolLayer.SkipMultiples = QgsSymbolLayer.Property.SkipMultiples +QgsSymbolLayer.SkipMultiples.is_monkey_patched = True +QgsSymbolLayer.SkipMultiples.__doc__ = "Skip multiples of \n.. versionadded:: 3.40" +QgsSymbolLayer.ShowMarker = QgsSymbolLayer.Property.ShowMarker +QgsSymbolLayer.ShowMarker.is_monkey_patched = True +QgsSymbolLayer.ShowMarker.__doc__ = "Show markers \n.. versionadded:: 3.40" QgsSymbolLayer.Property.__doc__ = """Data definable properties. * ``Size``: Symbol size @@ -592,6 +598,14 @@ Available as ``QgsSymbolLayer.PropertyLineClipping`` in older QGIS releases. +* ``SkipMultiples``: Skip multiples of + + .. versionadded:: 3.40 + +* ``ShowMarker``: Show markers + + .. versionadded:: 3.40 + """ # -- diff --git a/python/core/auto_generated/qgis.sip.in b/python/core/auto_generated/qgis.sip.in index effc72c91ebd..1c2170dc22c6 100644 --- a/python/core/auto_generated/qgis.sip.in +++ b/python/core/auto_generated/qgis.sip.in @@ -1628,6 +1628,21 @@ The development version typedef QFlags MarkerLinePlacements; + enum class LinearReferencingPlacement + { + IntervalCartesian2D, + IntervalZ, + IntervalM, + Vertex, + }; + + enum class LinearReferencingLabelSource + { + CartesianDistance2D, + Z, + M, + }; + enum class GradientColorSource { SimpleTwoColor, diff --git a/python/core/auto_generated/symbology/qgslinearreferencingsymbollayer.sip.in b/python/core/auto_generated/symbology/qgslinearreferencingsymbollayer.sip.in new file mode 100644 index 000000000000..9bd10e6ba006 --- /dev/null +++ b/python/core/auto_generated/symbology/qgslinearreferencingsymbollayer.sip.in @@ -0,0 +1,326 @@ +/************************************************************************ + * This file has been generated automatically from * + * * + * src/core/symbology/qgslinearreferencingsymbollayer.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.py again * + ************************************************************************/ + + + +class QgsLinearReferencingSymbolLayer : QgsLineSymbolLayer +{ +%Docstring(signature="appended") +Line symbol layer used for decorating accordingly to linear referencing. + +This symbol layer type allows placing text labels at regular intervals along +a line (or at positions corresponding to existing vertices). Positions +can be calculated using Cartesian distances, or interpolated from z or m values. + +.. versionadded:: 3.40 +%End + +%TypeHeaderCode +#include "qgslinearreferencingsymbollayer.h" +%End + public: + QgsLinearReferencingSymbolLayer(); + ~QgsLinearReferencingSymbolLayer(); + + static QgsSymbolLayer *create( const QVariantMap &properties = QVariantMap() ) /Factory/; +%Docstring +Creates a new QgsLinearReferencingSymbolLayer, using the specified ``properties``. + +The caller takes ownership of the returned object. +%End + + virtual QgsLinearReferencingSymbolLayer *clone() const /Factory/; + + virtual QVariantMap properties() const; + + virtual QString layerType() const; + + virtual Qgis::SymbolLayerFlags flags() const; + + virtual QgsSymbol *subSymbol(); + + virtual bool setSubSymbol( QgsSymbol *symbol /Transfer/ ); + + virtual void startRender( QgsSymbolRenderContext &context ); + + virtual void stopRender( QgsSymbolRenderContext &context ); + + virtual void renderPolyline( const QPolygonF &points, QgsSymbolRenderContext &context ); + + + QgsTextFormat textFormat() const; +%Docstring +Returns the text format used to render the layer. + +.. seealso:: :py:func:`setTextFormat` +%End + + void setTextFormat( const QgsTextFormat &format ); +%Docstring +Sets the text ``format`` used to render the layer. + +.. seealso:: :py:func:`textFormat` +%End + + QgsNumericFormat *numericFormat() const; +%Docstring +Returns the numeric format used to format the labels for the layer. + +.. seealso:: :py:func:`setNumericFormat` +%End + + void setNumericFormat( QgsNumericFormat *format /Transfer/ ); +%Docstring +Sets the numeric ``format`` used to format the labels for the layer. + +Ownership of ``format`` is transferred to the layer. + +.. seealso:: :py:func:`numericFormat` +%End + + double interval() const; +%Docstring +Returns the interval between labels. + +Units are always in the original layer CRS units. + +.. seealso:: :py:func:`setInterval` +%End + + void setInterval( double interval ); +%Docstring +Sets the ``interval`` between labels. + +Units are always in the original layer CRS units. + +.. seealso:: :py:func:`setInterval` +%End + + double skipMultiplesOf() const; +%Docstring +Returns the multiple distance to skip labels for. + +If this value is non-zero, then any labels which are integer multiples of the returned +value will be skipped. This allows creation of advanced referencing styles where a single +:py:class:`QgsSymbol` has multiple :py:class:`QgsLinearReferencingSymbolLayer` symbol layers, eg allowing +labeling every 100 in a normal font and every 1000 in a bold, larger font. + +.. seealso:: :py:func:`setSkipMultiplesOf` +%End + + void setSkipMultiplesOf( double multiple ); +%Docstring +Sets the ``multiple`` distance to skip labels for. + +If this value is non-zero, then any labels which are integer multiples of the returned +value will be skipped. This allows creation of advanced referencing styles where a single +:py:class:`QgsSymbol` has multiple :py:class:`QgsLinearReferencingSymbolLayer` symbol layers, eg allowing +labeling every 100 in a normal font and every 1000 in a bold, larger font. + +.. seealso:: :py:func:`skipMultiplesOf` +%End + + bool rotateLabels() const; +%Docstring +Returns ``True`` if the labels and symbols are to be rotated to match their line segment orientation. + +.. seealso:: :py:func:`setRotateLabels` +%End + + void setRotateLabels( bool rotate ); +%Docstring +Sets whether the labels and symbols should be rotated to match their line segment orientation. + +.. seealso:: :py:func:`rotateLabels` +%End + + QPointF labelOffset() const; +%Docstring +Returns the offset between the line and linear referencing labels. + +The unit for the offset is retrievable via :py:func:`~QgsLinearReferencingSymbolLayer.labelOffsetUnit`. + +.. seealso:: :py:func:`setLabelOffset` + +.. seealso:: :py:func:`labelOffsetUnit` +%End + + void setLabelOffset( const QPointF &offset ); +%Docstring +Sets the ``offset`` between the line and linear referencing labels. + +The unit for the offset is set via :py:func:`~QgsLinearReferencingSymbolLayer.setLabelOffsetUnit`. + +.. seealso:: :py:func:`labelOffset` + +.. seealso:: :py:func:`setLabelOffsetUnit` +%End + + Qgis::RenderUnit labelOffsetUnit() const; +%Docstring +Returns the unit used for the offset between the line and linear referencing labels. + +.. seealso:: :py:func:`setLabelOffsetUnit` + +.. seealso:: :py:func:`labelOffset` +%End + + void setLabelOffsetUnit( Qgis::RenderUnit unit ); +%Docstring +Sets the ``unit`` used for the offset between the line and linear referencing labels. + +.. seealso:: :py:func:`labelOffsetUnit` + +.. seealso:: :py:func:`setLabelOffset` +%End + + const QgsMapUnitScale &labelOffsetMapUnitScale() const; +%Docstring +Returns the map unit scale used for calculating the offset between the line and linear referencing labels. + +.. seealso:: :py:func:`setLabelOffsetMapUnitScale` +%End + + void setLabelOffsetMapUnitScale( const QgsMapUnitScale &scale ); +%Docstring +Sets the map unit ``scale`` used for calculating the offset between the line and linear referencing labels. + +.. seealso:: :py:func:`labelOffsetMapUnitScale` +%End + + bool showMarker() const; +%Docstring +Returns ``True`` if a marker symbol should be shown corresponding to the labeled point on line. + +The marker symbol is set using :py:func:`~QgsLinearReferencingSymbolLayer.setSubSymbol` + +.. seealso:: :py:func:`setShowMarker` +%End + + void setShowMarker( bool show ); +%Docstring +Sets whether a marker symbol should be shown corresponding to the labeled point on line. + +The marker symbol is set using :py:func:`~QgsLinearReferencingSymbolLayer.setSubSymbol` + +.. seealso:: :py:func:`showMarker` +%End + + Qgis::LinearReferencingPlacement placement() const; +%Docstring +Returns the placement mode for the labels. + +.. seealso:: :py:func:`setPlacement` +%End + + void setPlacement( Qgis::LinearReferencingPlacement placement ); +%Docstring +Sets the ``placement`` mode for the labels. + +.. seealso:: :py:func:`placement` +%End + + Qgis::LinearReferencingLabelSource labelSource() const; +%Docstring +Returns the label source, which dictates what quantity to use for the labels shown. + +.. seealso:: :py:func:`setLabelSource` +%End + + void setLabelSource( Qgis::LinearReferencingLabelSource source ); +%Docstring +Sets the label ``source``, which dictates what quantity to use for the labels shown. + +.. seealso:: :py:func:`labelSource` +%End + + double averageAngleLength() const; +%Docstring +Returns the length of line over which the line's direction is averaged when +calculating individual label angles. Longer lengths smooth out angles from jagged lines to a greater extent. + +Units are retrieved through :py:func:`~QgsLinearReferencingSymbolLayer.averageAngleUnit` + +.. seealso:: :py:func:`setAverageAngleLength` + +.. seealso:: :py:func:`averageAngleUnit` + +.. seealso:: :py:func:`averageAngleMapUnitScale` +%End + + void setAverageAngleLength( double length ); +%Docstring +Sets the ``length`` of line over which the line's direction is averaged when +calculating individual label angles. Longer lengths smooth out angles from jagged lines to a greater extent. + +Units are set through :py:func:`~QgsLinearReferencingSymbolLayer.setAverageAngleUnit` + +.. seealso:: :py:func:`averageAngleLength` + +.. seealso:: :py:func:`setAverageAngleUnit` + +.. seealso:: :py:func:`setAverageAngleMapUnitScale` +%End + + void setAverageAngleUnit( Qgis::RenderUnit unit ); +%Docstring +Sets the ``unit`` for the length over which the line's direction is averaged when +calculating individual label angles. + +.. seealso:: :py:func:`averageAngleUnit` + +.. seealso:: :py:func:`setAverageAngleLength` + +.. seealso:: :py:func:`setAverageAngleMapUnitScale` +%End + + Qgis::RenderUnit averageAngleUnit() const; +%Docstring +Returns the unit for the length over which the line's direction is averaged when +calculating individual label angles. + +.. seealso:: :py:func:`setAverageAngleUnit` + +.. seealso:: :py:func:`averageAngleLength` + +.. seealso:: :py:func:`averageAngleMapUnitScale` +%End + + void setAverageAngleMapUnitScale( const QgsMapUnitScale &scale ); +%Docstring +Sets the map unit ``scale`` for the length over which the line's direction is averaged when +calculating individual label angles. + +.. seealso:: :py:func:`averageAngleMapUnitScale` + +.. seealso:: :py:func:`setAverageAngleLength` + +.. seealso:: :py:func:`setAverageAngleUnit` +%End + + const QgsMapUnitScale &averageAngleMapUnitScale() const; +%Docstring +Returns the map unit scale for the length over which the line's direction is averaged when +calculating individual label angles. + +.. seealso:: :py:func:`setAverageAngleMapUnitScale` + +.. seealso:: :py:func:`averageAngleLength` + +.. seealso:: :py:func:`averageAngleUnit` +%End + +}; + +/************************************************************************ + * This file has been generated automatically from * + * * + * src/core/symbology/qgslinearreferencingsymbollayer.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.py again * + ************************************************************************/ diff --git a/python/core/auto_generated/symbology/qgssymbollayer.sip.in b/python/core/auto_generated/symbology/qgssymbollayer.sip.in index 88eeb881c723..a5de52ff9a13 100644 --- a/python/core/auto_generated/symbology/qgssymbollayer.sip.in +++ b/python/core/auto_generated/symbology/qgssymbollayer.sip.in @@ -60,6 +60,8 @@ class QgsSymbolLayer sipType = sipType_QgsRasterLineSymbolLayer; else if ( sipCpp->layerType() == "Lineburst" ) sipType = sipType_QgsLineburstSymbolLayer; + else if ( sipCpp->layerType() == "LinearReferencing" ) + sipType = sipType_QgsLinearReferencingSymbolLayer; else sipType = sipType_QgsLineSymbolLayer; break; @@ -167,6 +169,8 @@ class QgsSymbolLayer RandomOffsetX, RandomOffsetY, LineClipping, + SkipMultiples, + ShowMarker, }; static const QgsPropertiesDefinition &propertyDefinitions(); diff --git a/python/core/core_auto.sip b/python/core/core_auto.sip index bce7760b497d..46a81dd90d7b 100644 --- a/python/core/core_auto.sip +++ b/python/core/core_auto.sip @@ -690,6 +690,7 @@ %Include auto_generated/symbology/qgsinterpolatedlinerenderer.sip %Include auto_generated/symbology/qgsinvertedpolygonrenderer.sip %Include auto_generated/symbology/qgslegendsymbolitem.sip +%Include auto_generated/symbology/qgslinearreferencingsymbollayer.sip %Include auto_generated/symbology/qgslinesymbol.sip %Include auto_generated/symbology/qgslinesymbollayer.sip %Include auto_generated/symbology/qgsmapinfosymbolconverter.sip diff --git a/python/gui/auto_additions/qgssymbollayerwidget.py b/python/gui/auto_additions/qgssymbollayerwidget.py index c13cebff625c..ed1060b48c09 100644 --- a/python/gui/auto_additions/qgssymbollayerwidget.py +++ b/python/gui/auto_additions/qgssymbollayerwidget.py @@ -24,6 +24,7 @@ QgsRandomMarkerFillSymbolLayerWidget.create = staticmethod(QgsRandomMarkerFillSymbolLayerWidget.create) QgsFontMarkerSymbolLayerWidget.create = staticmethod(QgsFontMarkerSymbolLayerWidget.create) QgsCentroidFillSymbolLayerWidget.create = staticmethod(QgsCentroidFillSymbolLayerWidget.create) +QgsLinearReferencingSymbolLayerWidget.create = staticmethod(QgsLinearReferencingSymbolLayerWidget.create) QgsGeometryGeneratorSymbolLayerWidget.create = staticmethod(QgsGeometryGeneratorSymbolLayerWidget.create) try: QgsSymbolLayerWidget.__group__ = ['symbology'] @@ -113,6 +114,10 @@ QgsCentroidFillSymbolLayerWidget.__group__ = ['symbology'] except NameError: pass +try: + QgsLinearReferencingSymbolLayerWidget.__group__ = ['symbology'] +except NameError: + pass try: QgsGeometryGeneratorSymbolLayerWidget.__group__ = ['symbology'] except NameError: diff --git a/python/gui/auto_generated/symbology/qgssymbollayerwidget.sip.in b/python/gui/auto_generated/symbology/qgssymbollayerwidget.sip.in index c6d22437a572..a3be5562aa1b 100644 --- a/python/gui/auto_generated/symbology/qgssymbollayerwidget.sip.in +++ b/python/gui/auto_generated/symbology/qgssymbollayerwidget.sip.in @@ -990,6 +990,46 @@ Creates a new QgsCentroidFillSymbolLayerWidget. + +class QgsLinearReferencingSymbolLayerWidget : QgsSymbolLayerWidget +{ +%Docstring(signature="appended") +Widget for controlling the properties of a :py:class:`QgsLinearReferencingSymbolLayer`. + +.. versionadded:: 3.40 +%End + +%TypeHeaderCode +#include "qgssymbollayerwidget.h" +%End + public: + + QgsLinearReferencingSymbolLayerWidget( QgsVectorLayer *vl, QWidget *parent /TransferThis/ = 0 ); +%Docstring +Constructor for QgsLinearReferencingSymbolLayerWidget. +%End + + ~QgsLinearReferencingSymbolLayerWidget(); + + static QgsSymbolLayerWidget *create( QgsVectorLayer *vl ) /Factory/; +%Docstring +Creates a new QgsLinearReferencingSymbolLayerWidget. + +:param vl: associated vector layer +%End + + virtual void setSymbolLayer( QgsSymbolLayer *layer ); + + virtual QgsSymbolLayer *symbolLayer(); + + virtual void setContext( const QgsSymbolWidgetContext &context ); + + +}; + + + + class QgsGeometryGeneratorSymbolLayerWidget : QgsSymbolLayerWidget { diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 80a6f1fe69e6..c5f7f6d69369 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -107,6 +107,7 @@ set(QGIS_CORE_SRCS symbology/qgsinterpolatedlinerenderer.cpp symbology/qgsinvertedpolygonrenderer.cpp symbology/qgslegendsymbolitem.cpp + symbology/qgslinearreferencingsymbollayer.cpp symbology/qgslinesymbol.cpp symbology/qgslinesymbollayer.cpp symbology/qgsmapinfosymbolconverter.cpp @@ -1938,6 +1939,7 @@ set(QGIS_CORE_HDRS symbology/qgsinterpolatedlinerenderer.h symbology/qgsinvertedpolygonrenderer.h symbology/qgslegendsymbolitem.h + symbology/qgslinearreferencingsymbollayer.h symbology/qgslinesymbol.h symbology/qgslinesymbollayer.h symbology/qgsmapinfosymbolconverter.h diff --git a/src/core/qgis.h b/src/core/qgis.h index a5231089182a..bd2d2c088ace 100644 --- a/src/core/qgis.h +++ b/src/core/qgis.h @@ -2791,6 +2791,33 @@ class CORE_EXPORT Qgis Q_DECLARE_FLAGS( MarkerLinePlacements, MarkerLinePlacement ) Q_FLAG( MarkerLinePlacements ) + /** + * Defines how/where the labels should be placed in a linear referencing symbol layer. + * + * \since QGIS 3.40 + */ + enum class LinearReferencingPlacement : int SIP_ENUM_BASETYPE( IntFlag ) + { + IntervalCartesian2D = 1 << 0, //!< Place labels at regular intervals, using Cartesian distance calculations on a 2D plane + IntervalZ = 1 << 1, //!< Place labels at regular intervals, linearly interpolated using Z values + IntervalM = 1 << 2, //!< Place labels at regular intervals, linearly interpolated using M values + Vertex = 1 << 3, //!< Place labels on every vertex in the line + }; + Q_ENUM( LinearReferencingPlacement ) + + /** + * Defines what quantity to use for the labels shown in a linear referencing symbol layer. + * + * \since QGIS 3.40 + */ + enum class LinearReferencingLabelSource : int + { + CartesianDistance2D, //!< Distance along line, calculated using Cartesian calculations on a 2D plane. + Z, //!< Z values + M, //!< M values + }; + Q_ENUM( LinearReferencingLabelSource ) + /** * Gradient color sources. * diff --git a/src/core/symbology/qgslinearreferencingsymbollayer.cpp b/src/core/symbology/qgslinearreferencingsymbollayer.cpp new file mode 100644 index 000000000000..ca3ad2bb521e --- /dev/null +++ b/src/core/symbology/qgslinearreferencingsymbollayer.cpp @@ -0,0 +1,1001 @@ +/*************************************************************************** + qgslinearreferencingsymbollayer.h + --------------------- + begin : August 2024 + copyright : (C) 2024 by Nyall Dawson + email : nyall dot dawson at gmail dot com + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "qgslinearreferencingsymbollayer.h" +#include "qgsrendercontext.h" +#include "qgstextrenderer.h" +#include "qgslinestring.h" +#include "qgspolygon.h" +#include "qgsmarkersymbol.h" +#include "qgsnumericformatregistry.h" +#include "qgsapplication.h" +#include "qgsbasicnumericformat.h" +#include "qgsgeometryutils.h" +#include "qgsunittypes.h" +#include "qgssymbollayerutils.h" + +QgsLinearReferencingSymbolLayer::QgsLinearReferencingSymbolLayer() + : QgsLineSymbolLayer() +{ + mNumericFormat = std::make_unique< QgsBasicNumericFormat >(); +} + +QgsLinearReferencingSymbolLayer::~QgsLinearReferencingSymbolLayer() = default; + +QgsSymbolLayer *QgsLinearReferencingSymbolLayer::create( const QVariantMap &properties ) +{ + std::unique_ptr< QgsLinearReferencingSymbolLayer > res = std::make_unique< QgsLinearReferencingSymbolLayer >(); + res->setPlacement( qgsEnumKeyToValue( properties.value( QStringLiteral( "placement" ) ).toString(), Qgis::LinearReferencingPlacement::IntervalCartesian2D ) ); + res->setLabelSource( qgsEnumKeyToValue( properties.value( QStringLiteral( "source" ) ).toString(), Qgis::LinearReferencingLabelSource::CartesianDistance2D ) ); + bool ok = false; + const double interval = properties.value( QStringLiteral( "interval" ) ).toDouble( &ok ); + if ( ok ) + res->setInterval( interval ); + const double skipMultiples = properties.value( QStringLiteral( "skip_multiples" ) ).toDouble( &ok ); + if ( ok ) + res->setSkipMultiplesOf( skipMultiples ); + res->setRotateLabels( properties.value( QStringLiteral( "rotate" ), true ).toBool() ); + res->setShowMarker( properties.value( QStringLiteral( "show_marker" ), false ).toBool() ); + + // it's impossible to get the project's path resolver here :( + // TODO QGIS 4.0 -- force use of QgsReadWriteContext in create methods + QgsReadWriteContext rwContext; + //rwContext.setPathResolver( QgsProject::instance()->pathResolver() ); + + const QString textFormatXml = properties.value( QStringLiteral( "text_format" ) ).toString(); + if ( !textFormatXml.isEmpty() ) + { + QDomDocument doc; + QDomElement elem; + doc.setContent( textFormatXml ); + elem = doc.documentElement(); + + QgsTextFormat textFormat; + textFormat.readXml( elem, rwContext ); + res->setTextFormat( textFormat ); + } + + const QString numericFormatXml = properties.value( QStringLiteral( "numeric_format" ) ).toString(); + if ( !numericFormatXml.isEmpty() ) + { + QDomDocument doc; + doc.setContent( numericFormatXml ); + res->setNumericFormat( QgsApplication::numericFormatRegistry()->createFromXml( doc.documentElement(), rwContext ) ); + } + + if ( properties.contains( QStringLiteral( "label_offset" ) ) ) + { + res->setLabelOffset( QgsSymbolLayerUtils::decodePoint( properties[QStringLiteral( "label_offset" )].toString() ) ); + } + if ( properties.contains( QStringLiteral( "label_offset_unit" ) ) ) + { + res->setLabelOffsetUnit( QgsUnitTypes::decodeRenderUnit( properties[QStringLiteral( "label_offset_unit" )].toString() ) ); + } + if ( properties.contains( ( QStringLiteral( "label_offset_map_unit_scale" ) ) ) ) + { + res->setLabelOffsetMapUnitScale( QgsSymbolLayerUtils::decodeMapUnitScale( properties[QStringLiteral( "label_offset_map_unit_scale" )].toString() ) ); + } + if ( properties.contains( QStringLiteral( "average_angle_length" ) ) ) + { + res->setAverageAngleLength( properties[QStringLiteral( "average_angle_length" )].toDouble() ); + } + if ( properties.contains( QStringLiteral( "average_angle_unit" ) ) ) + { + res->setAverageAngleUnit( QgsUnitTypes::decodeRenderUnit( properties[QStringLiteral( "average_angle_unit" )].toString() ) ); + } + if ( properties.contains( ( QStringLiteral( "average_angle_map_unit_scale" ) ) ) ) + { + res->setAverageAngleMapUnitScale( QgsSymbolLayerUtils::decodeMapUnitScale( properties[QStringLiteral( "average_angle_map_unit_scale" )].toString() ) ); + } + + return res.release(); +} + +QgsLinearReferencingSymbolLayer *QgsLinearReferencingSymbolLayer::clone() const +{ + std::unique_ptr< QgsLinearReferencingSymbolLayer > res = std::make_unique< QgsLinearReferencingSymbolLayer >(); + res->setPlacement( mPlacement ); + res->setLabelSource( mLabelSource ); + res->setInterval( mInterval ); + res->setSkipMultiplesOf( mSkipMultiplesOf ); + res->setRotateLabels( mRotateLabels ); + res->setLabelOffset( mLabelOffset ); + res->setLabelOffsetUnit( mLabelOffsetUnit ); + res->setLabelOffsetMapUnitScale( mLabelOffsetMapUnitScale ); + res->setShowMarker( mShowMarker ); + res->setAverageAngleLength( mAverageAngleLength ); + res->setAverageAngleUnit( mAverageAngleLengthUnit ); + res->setAverageAngleMapUnitScale( mAverageAngleLengthMapUnitScale ); + + res->mTextFormat = mTextFormat; + res->mMarkerSymbol.reset( mMarkerSymbol ? mMarkerSymbol->clone() : nullptr ); + if ( mNumericFormat ) + res->mNumericFormat.reset( mNumericFormat->clone() ); + + copyDataDefinedProperties( res.get() ); + copyPaintEffect( res.get() ); + + return res.release(); +} + +QVariantMap QgsLinearReferencingSymbolLayer::properties() const +{ + QDomDocument textFormatDoc; + // it's impossible to get the project's path resolver here :( + // TODO QGIS 4.0 -- force use of QgsReadWriteContext in properties methods + QgsReadWriteContext rwContext; + // rwContext.setPathResolver( QgsProject::instance()->pathResolver() ); + const QDomElement textElem = mTextFormat.writeXml( textFormatDoc, rwContext ); + textFormatDoc.appendChild( textElem ); + + QDomDocument numericFormatDoc; + QDomElement numericFormatElem = numericFormatDoc.createElement( QStringLiteral( "numericFormat" ) ); + mNumericFormat->writeXml( numericFormatElem, numericFormatDoc, rwContext ); + numericFormatDoc.appendChild( numericFormatElem ); + + QVariantMap res + { + { + QStringLiteral( "placement" ), qgsEnumValueToKey( mPlacement ) + }, + { + QStringLiteral( "source" ), qgsEnumValueToKey( mLabelSource ) + }, + { + QStringLiteral( "interval" ), mInterval + }, + { + QStringLiteral( "rotate" ), mRotateLabels + }, + { + QStringLiteral( "show_marker" ), mShowMarker + }, + { + QStringLiteral( "text_format" ), textFormatDoc.toString() + }, + { + QStringLiteral( "numeric_format" ), numericFormatDoc.toString() + }, + { + QStringLiteral( "label_offset" ), QgsSymbolLayerUtils::encodePoint( mLabelOffset ) + }, + { + QStringLiteral( "label_offset_unit" ), QgsUnitTypes::encodeUnit( mLabelOffsetUnit ) + }, + { + QStringLiteral( "label_offset_map_unit_scale" ), QgsSymbolLayerUtils::encodeMapUnitScale( mLabelOffsetMapUnitScale ) + }, + { + QStringLiteral( "average_angle_length" ), mAverageAngleLength + }, + { + QStringLiteral( "average_angle_unit" ), QgsUnitTypes::encodeUnit( mAverageAngleLengthUnit ) + }, + { + QStringLiteral( "average_angle_map_unit_scale" ), QgsSymbolLayerUtils::encodeMapUnitScale( mAverageAngleLengthMapUnitScale ) + }, + }; + + if ( mSkipMultiplesOf >= 0 ) + { + res.insert( QStringLiteral( "skip_multiples" ), mSkipMultiplesOf ); + } + + return res; +} + +QString QgsLinearReferencingSymbolLayer::layerType() const +{ + return QStringLiteral( "LinearReferencing" ); +} + +Qgis::SymbolLayerFlags QgsLinearReferencingSymbolLayer::flags() const +{ + return Qgis::SymbolLayerFlag::DisableFeatureClipping; +} + +QgsSymbol *QgsLinearReferencingSymbolLayer::subSymbol() +{ + return mShowMarker ? mMarkerSymbol.get() : nullptr; +} + +bool QgsLinearReferencingSymbolLayer::setSubSymbol( QgsSymbol *symbol ) +{ + if ( symbol && symbol->type() == Qgis::SymbolType::Marker ) + { + mMarkerSymbol.reset( qgis::down_cast( symbol ) ); + return true; + } + delete symbol; + return false; +} + +void QgsLinearReferencingSymbolLayer::startRender( QgsSymbolRenderContext &context ) +{ + if ( mMarkerSymbol ) + { + Qgis::SymbolRenderHints hints = mMarkerSymbol->renderHints() | Qgis::SymbolRenderHint::IsSymbolLayerSubSymbol; + if ( mRotateLabels ) + hints |= Qgis::SymbolRenderHint::DynamicRotation; + mMarkerSymbol->setRenderHints( hints ); + + mMarkerSymbol->startRender( context.renderContext(), context.fields() ); + } +} + +void QgsLinearReferencingSymbolLayer::stopRender( QgsSymbolRenderContext &context ) +{ + if ( mMarkerSymbol ) + { + mMarkerSymbol->stopRender( context.renderContext() ); + } +} + +void QgsLinearReferencingSymbolLayer::renderGeometryPart( QgsSymbolRenderContext &context, const QgsAbstractGeometry *geometry, double labelOffsetPainterUnitsX, double labelOffsetPainterUnitsY, double skipMultiples, double averageAngleDistancePainterUnits, bool showMarker ) +{ + if ( const QgsLineString *line = qgsgeometry_cast< const QgsLineString * >( geometry ) ) + { + renderLineString( context, line, labelOffsetPainterUnitsX, labelOffsetPainterUnitsY, skipMultiples, averageAngleDistancePainterUnits, showMarker ); + } + else if ( const QgsPolygon *polygon = qgsgeometry_cast< const QgsPolygon * >( geometry ) ) + { + renderLineString( context, qgsgeometry_cast< const QgsLineString *>( polygon->exteriorRing() ), labelOffsetPainterUnitsX, labelOffsetPainterUnitsY, skipMultiples, averageAngleDistancePainterUnits, showMarker ); + for ( int i = 0; i < polygon->numInteriorRings(); ++i ) + { + renderLineString( context, qgsgeometry_cast< const QgsLineString *>( polygon->interiorRing( i ) ), labelOffsetPainterUnitsX, labelOffsetPainterUnitsY, skipMultiples, averageAngleDistancePainterUnits, showMarker ); + } + } +} + +void QgsLinearReferencingSymbolLayer::renderLineString( QgsSymbolRenderContext &context, const QgsLineString *line, double labelOffsetPainterUnitsX, double labelOffsetPainterUnitsY, double skipMultiples, double averageAngleDistancePainterUnits, bool showMarker ) +{ + if ( !line ) + return; + + switch ( mPlacement ) + { + case Qgis::LinearReferencingPlacement::IntervalCartesian2D: + case Qgis::LinearReferencingPlacement::IntervalZ: + case Qgis::LinearReferencingPlacement::IntervalM: + renderPolylineInterval( line, context, skipMultiples, QPointF( labelOffsetPainterUnitsX, labelOffsetPainterUnitsY ), averageAngleDistancePainterUnits, showMarker ); + break; + + case Qgis::LinearReferencingPlacement::Vertex: + renderPolylineVertex( line, context, skipMultiples, QPointF( labelOffsetPainterUnitsX, labelOffsetPainterUnitsY ), averageAngleDistancePainterUnits, showMarker ); + break; + } +} + +void QgsLinearReferencingSymbolLayer::renderPolyline( const QPolygonF &points, QgsSymbolRenderContext &context ) +{ + QPainter *p = context.renderContext().painter(); + if ( !p ) + { + return; + } + + double skipMultiples = mSkipMultiplesOf; + if ( mDataDefinedProperties.isActive( QgsSymbolLayer::Property::SkipMultiples ) ) + { + context.setOriginalValueVariable( mSkipMultiplesOf ); + skipMultiples = mDataDefinedProperties.valueAsDouble( QgsSymbolLayer::Property::SkipMultiples, context.renderContext().expressionContext(), mSkipMultiplesOf ); + } + + double labelOffsetX = mLabelOffset.x(); + double labelOffsetY = mLabelOffset.y(); + + double averageOver = mAverageAngleLength; + if ( mDataDefinedProperties.isActive( QgsSymbolLayer::Property::AverageAngleLength ) ) + { + context.setOriginalValueVariable( mAverageAngleLength ); + averageOver = mDataDefinedProperties.valueAsDouble( QgsSymbolLayer::Property::AverageAngleLength, context.renderContext().expressionContext(), mAverageAngleLength ); + } + + bool showMarker = mShowMarker; + if ( mDataDefinedProperties.isActive( QgsSymbolLayer::Property::ShowMarker ) ) + { + context.setOriginalValueVariable( showMarker ); + showMarker = mDataDefinedProperties.valueAsBool( QgsSymbolLayer::Property::ShowMarker, context.renderContext().expressionContext(), mShowMarker ); + } + + const double labelOffsetPainterUnitsX = context.renderContext().convertToPainterUnits( labelOffsetX, mLabelOffsetUnit, mLabelOffsetMapUnitScale ); + const double labelOffsetPainterUnitsY = context.renderContext().convertToPainterUnits( labelOffsetY, mLabelOffsetUnit, mLabelOffsetMapUnitScale ); + const double averageAngleDistancePainterUnits = context.renderContext().convertToPainterUnits( averageOver, mAverageAngleLengthUnit, mAverageAngleLengthMapUnitScale ) / 2; + + // TODO (maybe?): if we don't have an original geometry, convert points to linestring and scale distance to painter units? + // in reality this line type makes no sense for rendering non-real feature geometries... + ( void )points; + const QgsAbstractGeometry *geometry = context.renderContext().geometry(); + if ( !geometry ) + return; + + for ( auto partIt = geometry->const_parts_begin(); partIt != geometry->const_parts_end(); ++partIt ) + { + renderGeometryPart( context, *partIt, labelOffsetPainterUnitsX, labelOffsetPainterUnitsY, skipMultiples, averageAngleDistancePainterUnits, showMarker ); + } +} + + +double calculateAveragedAngle( double targetPointDistanceAlongSegment, double segmentLengthPainterUnits, + double averageAngleLengthPainterUnits, double prevXPainterUnits, double prevYPainterUnits, + double thisXPainterUnits, double thisYPainterUnits, const double *xPainterUnits, + const double *yPainterUnits, int totalPoints, int i ) +{ + + // track forward by averageAngleLengthPainterUnits + double painterDistRemaining = averageAngleLengthPainterUnits + targetPointDistanceAlongSegment; + double startAverageSegmentX = prevXPainterUnits; + double startAverageSegmentY = prevYPainterUnits; + double endAverageSegmentX = thisXPainterUnits; + double endAverageSegmentY = thisYPainterUnits; + double averagingSegmentLengthPainterUnits = segmentLengthPainterUnits; + const double *xAveragingData = xPainterUnits; + const double *yAveragingData = yPainterUnits; + + int j = i; + while ( painterDistRemaining > averagingSegmentLengthPainterUnits ) + { + if ( j >= totalPoints - 1 ) + break; + + painterDistRemaining -= averagingSegmentLengthPainterUnits; + startAverageSegmentX = endAverageSegmentX; + startAverageSegmentY = endAverageSegmentY; + + endAverageSegmentX = *xAveragingData++; + endAverageSegmentY = *yAveragingData++; + j++; + averagingSegmentLengthPainterUnits = QgsGeometryUtilsBase::distance2D( startAverageSegmentX, startAverageSegmentY, endAverageSegmentX, endAverageSegmentY ); + } + // fits on this same segment + double endAverageXPainterUnits; + double endAverageYPainterUnits; + if ( painterDistRemaining < averagingSegmentLengthPainterUnits ) + { + QgsGeometryUtilsBase::pointOnLineWithDistance( startAverageSegmentX, startAverageSegmentY, endAverageSegmentX, endAverageSegmentY, painterDistRemaining, endAverageXPainterUnits, endAverageYPainterUnits, + nullptr, nullptr, nullptr, + nullptr, nullptr, nullptr ); + } + else + { + endAverageXPainterUnits = endAverageSegmentX; + endAverageYPainterUnits = endAverageSegmentY; + } + + // also track back by averageAngleLengthPainterUnits + j = i; + painterDistRemaining = ( segmentLengthPainterUnits - targetPointDistanceAlongSegment ) + averageAngleLengthPainterUnits; + startAverageSegmentX = thisXPainterUnits; + startAverageSegmentY = thisYPainterUnits; + endAverageSegmentX = prevXPainterUnits; + endAverageSegmentY = prevYPainterUnits; + averagingSegmentLengthPainterUnits = segmentLengthPainterUnits; + xAveragingData = xPainterUnits - 2; + yAveragingData = yPainterUnits - 2; + while ( painterDistRemaining > averagingSegmentLengthPainterUnits ) + { + if ( j < 1 ) + break; + + painterDistRemaining -= averagingSegmentLengthPainterUnits; + startAverageSegmentX = endAverageSegmentX; + startAverageSegmentY = endAverageSegmentY; + + endAverageSegmentX = *xAveragingData--; + endAverageSegmentY = *yAveragingData--; + j--; + averagingSegmentLengthPainterUnits = QgsGeometryUtilsBase::distance2D( startAverageSegmentX, startAverageSegmentY, endAverageSegmentX, endAverageSegmentY ); + } + // fits on this same segment + double startAverageXPainterUnits; + double startAverageYPainterUnits; + if ( painterDistRemaining < averagingSegmentLengthPainterUnits ) + { + QgsGeometryUtilsBase::pointOnLineWithDistance( startAverageSegmentX, startAverageSegmentY, endAverageSegmentX, endAverageSegmentY, painterDistRemaining, startAverageXPainterUnits, startAverageYPainterUnits, + nullptr, nullptr, nullptr, + nullptr, nullptr, nullptr ); + } + else + { + startAverageXPainterUnits = endAverageSegmentX; + startAverageYPainterUnits = endAverageSegmentY; + } + + double calculatedAngle = std::fmod( QgsGeometryUtilsBase::azimuth( startAverageXPainterUnits, startAverageYPainterUnits, endAverageXPainterUnits, endAverageYPainterUnits ) + 360, 360 ); + if ( calculatedAngle > 90 && calculatedAngle < 270 ) + calculatedAngle += 180; + + return calculatedAngle; +} + +typedef std::function VisitPointFunction; +typedef std::function< void( const QgsLineString *, const QgsLineString *, bool, double, double, const VisitPointFunction & ) > VisitPointAtDistanceFunction; + +void visitPointsByRegularDistance( const QgsLineString *line, const QgsLineString *linePainterUnits, bool emitFirstPoint, const double distance, const double averageAngleLengthPainterUnits, const VisitPointFunction &visitPoint ) +{ + if ( distance < 0 ) + return; + + double distanceTraversed = 0; + const int totalPoints = line->numPoints(); + if ( totalPoints == 0 ) + return; + + const double *x = line->xData(); + const double *y = line->yData(); + const double *z = line->is3D() ? line->zData() : nullptr; + const double *m = line->isMeasure() ? line->mData() : nullptr; + + const double *xPainterUnits = linePainterUnits->xData(); + const double *yPainterUnits = linePainterUnits->yData(); + + double prevX = *x++; + double prevY = *y++; + double prevZ = z ? *z++ : 0.0; + double prevM = m ? *m++ : 0.0; + + double prevXPainterUnits = *xPainterUnits++; + double prevYPainterUnits = *yPainterUnits++; + + if ( qgsDoubleNear( distance, 0.0 ) ) + { + visitPoint( prevX, prevY, prevZ, prevM, 0, 0 ); + return; + } + + double pZ = std::numeric_limits::quiet_NaN(); + double pM = std::numeric_limits::quiet_NaN(); + double nextPointDistance = emitFirstPoint ? 0 : distance; + for ( int i = 1; i < totalPoints; ++i ) + { + double thisX = *x++; + double thisY = *y++; + double thisZ = z ? *z++ : 0.0; + double thisM = m ? *m++ : 0.0; + double thisXPainterUnits = *xPainterUnits++; + double thisYPainterUnits = *yPainterUnits++; + + double angle = std::fmod( QgsGeometryUtilsBase::azimuth( prevXPainterUnits, prevYPainterUnits, thisXPainterUnits, thisYPainterUnits ) + 360, 360 ); + if ( angle > 90 && angle < 270 ) + angle += 180; + + const double segmentLength = QgsGeometryUtilsBase::distance2D( thisX, thisY, prevX, prevY ); + const double segmentLengthPainterUnits = QgsGeometryUtilsBase::distance2D( thisXPainterUnits, thisYPainterUnits, prevXPainterUnits, prevYPainterUnits ); + + while ( nextPointDistance < distanceTraversed + segmentLength || qgsDoubleNear( nextPointDistance, distanceTraversed + segmentLength ) ) + { + // point falls on this segment - truncate to segment length if qgsDoubleNear test was actually > segment length + const double distanceToPoint = std::min( nextPointDistance - distanceTraversed, segmentLength ); + double pX, pY; + QgsGeometryUtilsBase::pointOnLineWithDistance( prevX, prevY, thisX, thisY, distanceToPoint, pX, pY, + z ? &prevZ : nullptr, z ? &thisZ : nullptr, z ? &pZ : nullptr, + m ? &prevM : nullptr, m ? &thisM : nullptr, m ? &pM : nullptr ); + + double calculatedAngle = angle; + if ( averageAngleLengthPainterUnits > 0 ) + { + const double targetPointFractionAlongSegment = distanceToPoint / segmentLength; + const double targetPointDistanceAlongSegment = targetPointFractionAlongSegment * segmentLengthPainterUnits; + + calculatedAngle = calculateAveragedAngle( targetPointDistanceAlongSegment, segmentLengthPainterUnits, + averageAngleLengthPainterUnits, prevXPainterUnits, prevYPainterUnits, + thisXPainterUnits, thisYPainterUnits, xPainterUnits, + yPainterUnits, totalPoints, i ); + } + + if ( !visitPoint( pX, pY, pZ, pM, nextPointDistance, calculatedAngle ) ) + return; + + nextPointDistance += distance; + } + + distanceTraversed += segmentLength; + prevX = thisX; + prevY = thisY; + prevZ = thisZ; + prevM = thisM; + prevXPainterUnits = thisXPainterUnits; + prevYPainterUnits = thisYPainterUnits; + } +} + +double interpolateValue( double a, double b, double fraction ) +{ + return a + ( b - a ) * fraction; +} + + +void visitPointsByInterpolatedZM( const QgsLineString *line, const QgsLineString *linePainterUnits, bool emitFirstPoint, const double step, const double averageAngleLengthPainterUnits, const VisitPointFunction &visitPoint, bool useZ ) +{ + if ( step < 0 ) + return; + + double distanceTraversed = 0; + const int totalPoints = line->numPoints(); + if ( totalPoints < 2 ) + return; + + const double *x = line->xData(); + const double *y = line->yData(); + const double *z = line->is3D() ? line->zData() : nullptr; + const double *m = line->isMeasure() ? line->mData() : nullptr; + + const double *xPainterUnits = linePainterUnits->xData(); + const double *yPainterUnits = linePainterUnits->yData(); + + double prevX = *x++; + double prevY = *y++; + double prevZ = z ? *z++ : 0.0; + double prevM = m ? *m++ : 0.0; + + double prevXPainterUnits = *xPainterUnits++; + double prevYPainterUnits = *yPainterUnits++; + + if ( qgsDoubleNear( step, 0.0 ) ) + { + visitPoint( prevX, prevY, prevZ, prevM, 0, 0 ); + return; + } + + double prevValue = useZ ? prevZ : prevM; + bool isFirstPoint = true; + for ( int i = 1; i < totalPoints; ++i ) + { + double thisX = *x++; + double thisY = *y++; + double thisZ = z ? *z++ : 0.0; + double thisM = m ? *m++ : 0.0; + const double thisValue = useZ ? thisZ : thisM; + double thisXPainterUnits = *xPainterUnits++; + double thisYPainterUnits = *yPainterUnits++; + + double angle = std::fmod( QgsGeometryUtilsBase::azimuth( prevXPainterUnits, prevYPainterUnits, thisXPainterUnits, thisYPainterUnits ) + 360, 360 ); + if ( angle > 90 && angle < 270 ) + angle += 180; + + const double segmentLength = QgsGeometryUtilsBase::distance2D( thisX, thisY, prevX, prevY ); + const double segmentLengthPainterUnits = QgsGeometryUtilsBase::distance2D( thisXPainterUnits, thisYPainterUnits, prevXPainterUnits, prevYPainterUnits ); + + // direction for this segment + const int direction = ( thisValue > prevValue ) ? 1 : ( thisValue < prevValue ) ? -1 : 0; + if ( direction != 0 ) + { + // non-constant segment + double nextStepValue = direction > 0 ? std::ceil( prevValue / step ) * step + : std::floor( prevValue / step ) * step; + + while ( ( direction > 0 && ( nextStepValue <= thisValue || qgsDoubleNear( nextStepValue, thisValue ) ) ) || + ( direction < 0 && ( nextStepValue >= thisValue || qgsDoubleNear( nextStepValue, thisValue ) ) ) ) + { + const double targetPointFractionAlongSegment = ( nextStepValue - prevValue ) / ( thisValue - prevValue ); + const double targetPointDistanceAlongSegment = targetPointFractionAlongSegment * segmentLengthPainterUnits; + + double pX, pY; + QgsGeometryUtilsBase::pointOnLineWithDistance( prevX, prevY, thisX, thisY, targetPointFractionAlongSegment * segmentLength, pX, pY ); + + const double pZ = useZ ? nextStepValue : interpolateValue( prevZ, thisZ, targetPointFractionAlongSegment ); + const double pM = useZ ? interpolateValue( prevM, thisM, targetPointFractionAlongSegment ) : nextStepValue; + + double calculatedAngle = angle; + if ( averageAngleLengthPainterUnits > 0 ) + { + calculatedAngle = calculateAveragedAngle( + targetPointDistanceAlongSegment, + segmentLengthPainterUnits, averageAngleLengthPainterUnits, + prevXPainterUnits, prevYPainterUnits, thisXPainterUnits, thisYPainterUnits, + xPainterUnits, yPainterUnits, + totalPoints, i ); + } + + if ( !qgsDoubleNear( targetPointFractionAlongSegment, 0 ) || isFirstPoint ) + { + if ( !visitPoint( pX, pY, pZ, pM, distanceTraversed + segmentLength * targetPointFractionAlongSegment, calculatedAngle ) ) + return; + } + + nextStepValue += direction * step; + } + } + else if ( isFirstPoint && emitFirstPoint ) + { + if ( !visitPoint( prevX, prevY, prevZ, prevM, distanceTraversed, + std::fmod( QgsGeometryUtilsBase::azimuth( prevXPainterUnits, prevYPainterUnits, thisXPainterUnits, thisYPainterUnits ) + 360, 360 ) ) ) + return; + } + isFirstPoint = false; + + prevX = thisX; + prevY = thisY; + prevZ = thisZ; + prevM = thisM; + prevXPainterUnits = thisXPainterUnits; + prevYPainterUnits = thisYPainterUnits; + prevValue = thisValue; + distanceTraversed += segmentLength; + } +} + +void visitPointsByInterpolatedZ( const QgsLineString *line, const QgsLineString *linePainterUnits, bool emitFirstPoint, const double distance, const double averageAngleLengthPainterUnits, const VisitPointFunction &visitPoint ) +{ + visitPointsByInterpolatedZM( line, linePainterUnits, emitFirstPoint, distance, averageAngleLengthPainterUnits, visitPoint, true ); +} + +void visitPointsByInterpolatedM( const QgsLineString *line, const QgsLineString *linePainterUnits, bool emitFirstPoint, const double distance, const double averageAngleLengthPainterUnits, const VisitPointFunction &visitPoint ) +{ + visitPointsByInterpolatedZM( line, linePainterUnits, emitFirstPoint, distance, averageAngleLengthPainterUnits, visitPoint, false ); +} + +QPointF QgsLinearReferencingSymbolLayer::pointToPainter( QgsSymbolRenderContext &context, double x, double y, double z ) +{ + QPointF pt; + if ( context.renderContext().coordinateTransform().isValid() ) + { + context.renderContext().coordinateTransform().transformInPlace( x, y, z ); + pt = QPointF( x, y ); + + } + else + { + pt = QPointF( x, y ); + } + + context.renderContext().mapToPixel().transformInPlace( pt.rx(), pt.ry() ); + return pt; +} + +void QgsLinearReferencingSymbolLayer::renderPolylineInterval( const QgsLineString *line, QgsSymbolRenderContext &context, double skipMultiples, const QPointF &labelOffsetPainterUnits, double averageAngleLengthPainterUnits, bool showMarker ) +{ + double distance = mInterval; + if ( mDataDefinedProperties.isActive( QgsSymbolLayer::Property::Interval ) ) + { + context.setOriginalValueVariable( mInterval ); + distance = mDataDefinedProperties.valueAsDouble( QgsSymbolLayer::Property::Interval, context.renderContext().expressionContext(), mInterval ); + } + + QgsNumericFormatContext numericContext; + + std::unique_ptr< QgsLineString > painterUnitsGeometry( line->clone() ); + if ( context.renderContext().coordinateTransform().isValid() ) + { + painterUnitsGeometry->transform( context.renderContext().coordinateTransform() ); + } + painterUnitsGeometry->transform( context.renderContext().mapToPixel().transform() ); + + const bool hasZ = line->is3D(); + const bool hasM = line->isMeasure(); + const bool emitFirstPoint = mLabelSource != Qgis::LinearReferencingLabelSource::CartesianDistance2D; + + VisitPointAtDistanceFunction func = nullptr; + + switch ( mPlacement ) + { + case Qgis::LinearReferencingPlacement::IntervalCartesian2D: + func = visitPointsByRegularDistance; + break; + + case Qgis::LinearReferencingPlacement::IntervalZ: + func = visitPointsByInterpolatedZ; + break; + + case Qgis::LinearReferencingPlacement::IntervalM: + func = visitPointsByInterpolatedM; + break; + + case Qgis::LinearReferencingPlacement::Vertex: + return; + } + + func( line, painterUnitsGeometry.get(), emitFirstPoint, distance, averageAngleLengthPainterUnits, [&context, &numericContext, skipMultiples, showMarker, + labelOffsetPainterUnits, hasZ, hasM, this]( double x, double y, double z, double m, double distanceFromStart, double angle ) -> bool + { + if ( context.renderContext().renderingStopped() ) + return false; + + double labelValue = 0; + bool labelVertex = true; + switch ( mLabelSource ) + { + case Qgis::LinearReferencingLabelSource::CartesianDistance2D: + labelValue = distanceFromStart; + break; + case Qgis::LinearReferencingLabelSource::Z: + labelValue = z; + labelVertex = hasZ && !std::isnan( labelValue ); + break; + case Qgis::LinearReferencingLabelSource::M: + labelValue = m; + labelVertex = hasM && !std::isnan( labelValue ); + break; + } + + if ( !labelVertex ) + return true; + + if ( skipMultiples > 0 && qgsDoubleNear( std::fmod( labelValue, skipMultiples ), 0 ) ) + return true; + + const QPointF pt = pointToPainter( context, x, y, z ); + + if ( mMarkerSymbol && showMarker ) + { + if ( mRotateLabels ) + mMarkerSymbol->setLineAngle( 90 - angle ); + mMarkerSymbol->renderPoint( pt, context.feature(), context.renderContext() ); + } + + const double angleRadians = ( mRotateLabels ? angle : 0 ) * M_PI / 180.0; + const double dx = labelOffsetPainterUnits.x() * std::sin( angleRadians + M_PI_2 ) + + labelOffsetPainterUnits.y() * std::sin( angleRadians ); + const double dy = labelOffsetPainterUnits.x() * std::cos( angleRadians + M_PI_2 ) + + labelOffsetPainterUnits.y() * std::cos( angleRadians ); + + QgsTextRenderer::drawText( QPointF( pt.x() + dx, pt.y() + dy ), angleRadians, Qgis::TextHorizontalAlignment::Left, { mNumericFormat->formatDouble( labelValue, numericContext ) }, context.renderContext(), mTextFormat ); + + return true; + } ); +} + +void QgsLinearReferencingSymbolLayer::renderPolylineVertex( const QgsLineString *line, QgsSymbolRenderContext &context, double skipMultiples, const QPointF &labelOffsetPainterUnits, double averageAngleLengthPainterUnits, bool showMarker ) +{ + // let's simplify the logic by ALWAYS using the averaging approach for angles, and just + // use a very small distance if the user actually set this to 0. It'll be identical + // results anyway... + averageAngleLengthPainterUnits = std::max( averageAngleLengthPainterUnits, 0.1 ); + + QgsNumericFormatContext numericContext; + + const double *xData = line->xData(); + const double *yData = line->yData(); + const double *zData = line->is3D() ? line->zData() : nullptr; + const double *mData = line->isMeasure() ? line->mData() : nullptr; + const int size = line->numPoints(); + if ( size < 2 ) + return; + + std::unique_ptr< QgsLineString > painterUnitsGeometry( line->clone() ); + if ( context.renderContext().coordinateTransform().isValid() ) + { + painterUnitsGeometry->transform( context.renderContext().coordinateTransform() ); + } + painterUnitsGeometry->transform( context.renderContext().mapToPixel().transform() ); + const double *xPainterUnits = painterUnitsGeometry->xData(); + const double *yPainterUnits = painterUnitsGeometry->yData(); + + double currentDistance = 0; + double prevX = *xData; + double prevY = *yData; + + for ( int i = 0; i < size; ++i ) + { + if ( context.renderContext().renderingStopped() ) + break; + + double thisX = *xData++; + double thisY = *yData++; + double thisZ = zData ? *zData++ : 0; + double thisM = mData ? *mData++ : 0; + double thisXPainterUnits = *xPainterUnits++; + double thisYPainterUnits = *yPainterUnits++; + + const double thisSegmentLength = QgsGeometryUtilsBase::distance2D( prevX, prevY, thisX, thisY ); + currentDistance += thisSegmentLength; + + if ( skipMultiples > 0 && qgsDoubleNear( std::fmod( currentDistance, skipMultiples ), 0 ) ) + { + prevX = thisX; + prevY = thisY; + continue; + } + + const QPointF pt = pointToPainter( context, thisX, thisY, thisZ ); + + double calculatedAngle = 0; + + // track forward by averageAngleLengthPainterUnits + double painterDistRemaining = averageAngleLengthPainterUnits; + double startAverageSegmentX = thisXPainterUnits; + double startAverageSegmentY = thisYPainterUnits; + + const double *xAveragingData = xPainterUnits; + const double *yAveragingData = yPainterUnits; + double endAverageSegmentX = *xAveragingData; + double endAverageSegmentY = *yAveragingData; + double averagingSegmentLengthPainterUnits = QgsGeometryUtilsBase::distance2D( startAverageSegmentX, startAverageSegmentY, endAverageSegmentX, endAverageSegmentY ); + + int j = i; + while ( ( j < size - 1 ) && ( painterDistRemaining > averagingSegmentLengthPainterUnits ) ) + { + painterDistRemaining -= averagingSegmentLengthPainterUnits; + startAverageSegmentX = endAverageSegmentX; + startAverageSegmentY = endAverageSegmentY; + + endAverageSegmentX = *xAveragingData++; + endAverageSegmentY = *yAveragingData++; + j++; + averagingSegmentLengthPainterUnits = QgsGeometryUtilsBase::distance2D( startAverageSegmentX, startAverageSegmentY, endAverageSegmentX, endAverageSegmentY ); + } + // fits on this same segment + double endAverageXPainterUnits = thisXPainterUnits; + double endAverageYPainterUnits = thisYPainterUnits; + if ( ( j < size - 1 ) && painterDistRemaining < averagingSegmentLengthPainterUnits ) + { + QgsGeometryUtilsBase::pointOnLineWithDistance( startAverageSegmentX, startAverageSegmentY, endAverageSegmentX, endAverageSegmentY, painterDistRemaining, endAverageXPainterUnits, endAverageYPainterUnits, + nullptr, nullptr, nullptr, + nullptr, nullptr, nullptr ); + } + else if ( i < size - 2 ) + { + endAverageXPainterUnits = endAverageSegmentX; + endAverageYPainterUnits = endAverageSegmentY; + } + + // also track back by averageAngleLengthPainterUnits + j = i; + painterDistRemaining = averageAngleLengthPainterUnits; + startAverageSegmentX = thisXPainterUnits; + startAverageSegmentY = thisYPainterUnits; + + xAveragingData = xPainterUnits - 2; + yAveragingData = yPainterUnits - 2; + + endAverageSegmentX = *xAveragingData; + endAverageSegmentY = *yAveragingData; + averagingSegmentLengthPainterUnits = QgsGeometryUtilsBase::distance2D( startAverageSegmentX, startAverageSegmentY, endAverageSegmentX, endAverageSegmentY ); + + while ( j > 0 && painterDistRemaining > averagingSegmentLengthPainterUnits ) + { + painterDistRemaining -= averagingSegmentLengthPainterUnits; + startAverageSegmentX = endAverageSegmentX; + startAverageSegmentY = endAverageSegmentY; + + endAverageSegmentX = *xAveragingData--; + endAverageSegmentY = *yAveragingData--; + j--; + averagingSegmentLengthPainterUnits = QgsGeometryUtilsBase::distance2D( startAverageSegmentX, startAverageSegmentY, endAverageSegmentX, endAverageSegmentY ); + } + // fits on this same segment + double startAverageXPainterUnits = thisXPainterUnits; + double startAverageYPainterUnits = thisYPainterUnits; + if ( j > 0 && painterDistRemaining < averagingSegmentLengthPainterUnits ) + { + QgsGeometryUtilsBase::pointOnLineWithDistance( startAverageSegmentX, startAverageSegmentY, endAverageSegmentX, endAverageSegmentY, painterDistRemaining, startAverageXPainterUnits, startAverageYPainterUnits, + nullptr, nullptr, nullptr, + nullptr, nullptr, nullptr ); + } + else if ( j > 1 ) + { + startAverageXPainterUnits = endAverageSegmentX; + startAverageYPainterUnits = endAverageSegmentY; + } + + calculatedAngle = std::fmod( QgsGeometryUtilsBase::azimuth( startAverageXPainterUnits, startAverageYPainterUnits, endAverageXPainterUnits, endAverageYPainterUnits ) + 360, 360 ); + + if ( calculatedAngle > 90 && calculatedAngle < 270 ) + calculatedAngle += 180; + + if ( mMarkerSymbol && showMarker ) + { + if ( mRotateLabels ) + mMarkerSymbol->setLineAngle( 90 - calculatedAngle ); + mMarkerSymbol->renderPoint( pt, context.feature(), context.renderContext() ); + } + + const double angleRadians = mRotateLabels ? ( calculatedAngle * M_PI / 180.0 ) : 0; + const double dx = labelOffsetPainterUnits.x() * std::sin( angleRadians + M_PI_2 ) + + labelOffsetPainterUnits.y() * std::sin( angleRadians ); + const double dy = labelOffsetPainterUnits.x() * std::cos( angleRadians + M_PI_2 ) + + labelOffsetPainterUnits.y() * std::cos( angleRadians ); + + double labelValue = 0; + bool labelVertex = true; + switch ( mLabelSource ) + { + case Qgis::LinearReferencingLabelSource::CartesianDistance2D: + labelValue = currentDistance; + break; + case Qgis::LinearReferencingLabelSource::Z: + labelValue = thisZ; + labelVertex = static_cast< bool >( zData ) && !std::isnan( labelValue ); + break; + case Qgis::LinearReferencingLabelSource::M: + labelValue = thisM; + labelVertex = static_cast< bool >( mData ) && !std::isnan( labelValue ); + break; + } + + if ( !labelVertex ) + continue; + + QgsTextRenderer::drawText( QPointF( pt.x() + dx, pt.y() + dy ), angleRadians, Qgis::TextHorizontalAlignment::Left, { mNumericFormat->formatDouble( labelValue, numericContext ) }, context.renderContext(), mTextFormat ); + + prevX = thisX; + prevY = thisY; + } +} + +QgsTextFormat QgsLinearReferencingSymbolLayer::textFormat() const +{ + return mTextFormat; +} + +void QgsLinearReferencingSymbolLayer::setTextFormat( const QgsTextFormat &format ) +{ + mTextFormat = format; +} + +QgsNumericFormat *QgsLinearReferencingSymbolLayer::numericFormat() const +{ + return mNumericFormat.get(); +} + +void QgsLinearReferencingSymbolLayer::setNumericFormat( QgsNumericFormat *format ) +{ + mNumericFormat.reset( format ); +} + +double QgsLinearReferencingSymbolLayer::interval() const +{ + return mInterval; +} + +void QgsLinearReferencingSymbolLayer::setInterval( double interval ) +{ + mInterval = interval; +} + +double QgsLinearReferencingSymbolLayer::skipMultiplesOf() const +{ + return mSkipMultiplesOf; +} + +void QgsLinearReferencingSymbolLayer::setSkipMultiplesOf( double skipMultiplesOf ) +{ + mSkipMultiplesOf = skipMultiplesOf; +} + +bool QgsLinearReferencingSymbolLayer::showMarker() const +{ + return mShowMarker; +} + +void QgsLinearReferencingSymbolLayer::setShowMarker( bool show ) +{ + mShowMarker = show; + if ( show && !mMarkerSymbol ) + { + mMarkerSymbol.reset( QgsMarkerSymbol::createSimple( {} ) ); + } +} + +Qgis::LinearReferencingPlacement QgsLinearReferencingSymbolLayer::placement() const +{ + return mPlacement; +} + +void QgsLinearReferencingSymbolLayer::setPlacement( Qgis::LinearReferencingPlacement placement ) +{ + mPlacement = placement; +} + +Qgis::LinearReferencingLabelSource QgsLinearReferencingSymbolLayer::labelSource() const +{ + return mLabelSource; +} + +void QgsLinearReferencingSymbolLayer::setLabelSource( Qgis::LinearReferencingLabelSource source ) +{ + mLabelSource = source; +} + diff --git a/src/core/symbology/qgslinearreferencingsymbollayer.h b/src/core/symbology/qgslinearreferencingsymbollayer.h new file mode 100644 index 000000000000..20baac175dd2 --- /dev/null +++ b/src/core/symbology/qgslinearreferencingsymbollayer.h @@ -0,0 +1,334 @@ +/*************************************************************************** + qgslinearreferencingsymbollayer.h + --------------------- + begin : August 2024 + copyright : (C) 2024 by Nyall Dawson + email : nyall dot dawson at gmail dot com + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ +#ifndef QGSLINEARREFERENCINGSYMBOLLAYER_H +#define QGSLINEARREFERENCINGSYMBOLLAYER_H + +#include "qgis_core.h" +#include "qgis.h" +#include "qgssymbollayer.h" +#include "qgstextformat.h" + +class QgsNumericFormat; + +/** + * \ingroup core + * \brief Line symbol layer used for decorating accordingly to linear referencing. + * + * This symbol layer type allows placing text labels at regular intervals along + * a line (or at positions corresponding to existing vertices). Positions + * can be calculated using Cartesian distances, or interpolated from z or m values. + * + * \since QGIS 3.40 + */ +class CORE_EXPORT QgsLinearReferencingSymbolLayer : public QgsLineSymbolLayer +{ + public: + QgsLinearReferencingSymbolLayer(); + ~QgsLinearReferencingSymbolLayer() override; + + /** + * Creates a new QgsLinearReferencingSymbolLayer, using the specified \a properties. + * + * The caller takes ownership of the returned object. + */ + static QgsSymbolLayer *create( const QVariantMap &properties = QVariantMap() ) SIP_FACTORY; + + QgsLinearReferencingSymbolLayer *clone() const override SIP_FACTORY; + QVariantMap properties() const override; + QString layerType() const override; + Qgis::SymbolLayerFlags flags() const override; + QgsSymbol *subSymbol() override; + bool setSubSymbol( QgsSymbol *symbol SIP_TRANSFER ) override; + void startRender( QgsSymbolRenderContext &context ) override; + void stopRender( QgsSymbolRenderContext &context ) override; + void renderPolyline( const QPolygonF &points, QgsSymbolRenderContext &context ) override; + + /** + * Returns the text format used to render the layer. + * + * \see setTextFormat() + */ + QgsTextFormat textFormat() const; + + /** + * Sets the text \a format used to render the layer. + * + * \see textFormat() + */ + void setTextFormat( const QgsTextFormat &format ); + + /** + * Returns the numeric format used to format the labels for the layer. + * + * \see setNumericFormat() + */ + QgsNumericFormat *numericFormat() const; + + /** + * Sets the numeric \a format used to format the labels for the layer. + * + * Ownership of \a format is transferred to the layer. + * + * \see numericFormat() + */ + void setNumericFormat( QgsNumericFormat *format SIP_TRANSFER ); + + /** + * Returns the interval between labels. + * + * Units are always in the original layer CRS units. + * + * \see setInterval() + */ + double interval() const; + + /** + * Sets the \a interval between labels. + * + * Units are always in the original layer CRS units. + * + * \see setInterval() + */ + void setInterval( double interval ); + + /** + * Returns the multiple distance to skip labels for. + * + * If this value is non-zero, then any labels which are integer multiples of the returned + * value will be skipped. This allows creation of advanced referencing styles where a single + * QgsSymbol has multiple QgsLinearReferencingSymbolLayer symbol layers, eg allowing + * labeling every 100 in a normal font and every 1000 in a bold, larger font. + * + * \see setSkipMultiplesOf() + */ + double skipMultiplesOf() const; + + /** + * Sets the \a multiple distance to skip labels for. + * + * If this value is non-zero, then any labels which are integer multiples of the returned + * value will be skipped. This allows creation of advanced referencing styles where a single + * QgsSymbol has multiple QgsLinearReferencingSymbolLayer symbol layers, eg allowing + * labeling every 100 in a normal font and every 1000 in a bold, larger font. + * + * \see skipMultiplesOf() + */ + void setSkipMultiplesOf( double multiple ); + + /** + * Returns TRUE if the labels and symbols are to be rotated to match their line segment orientation. + * + * \see setRotateLabels() + */ + bool rotateLabels() const { return mRotateLabels; } + + /** + * Sets whether the labels and symbols should be rotated to match their line segment orientation. + * + * \see rotateLabels() + */ + void setRotateLabels( bool rotate ) { mRotateLabels = rotate; } + + /** + * Returns the offset between the line and linear referencing labels. + * + * The unit for the offset is retrievable via labelOffsetUnit(). + * + * \see setLabelOffset() + * \see labelOffsetUnit() + */ + QPointF labelOffset() const { return mLabelOffset; } + + /** + * Sets the \a offset between the line and linear referencing labels. + * + * The unit for the offset is set via setLabelOffsetUnit(). + * + * \see labelOffset() + * \see setLabelOffsetUnit() + */ + void setLabelOffset( const QPointF &offset ) { mLabelOffset = offset; } + + /** + * Returns the unit used for the offset between the line and linear referencing labels. + * + * \see setLabelOffsetUnit() + * \see labelOffset() + */ + Qgis::RenderUnit labelOffsetUnit() const { return mLabelOffsetUnit; } + + /** + * Sets the \a unit used for the offset between the line and linear referencing labels. + * + * \see labelOffsetUnit() + * \see setLabelOffset() + */ + void setLabelOffsetUnit( Qgis::RenderUnit unit ) { mLabelOffsetUnit = unit; } + + /** + * Returns the map unit scale used for calculating the offset between the line and linear referencing labels. + * + * \see setLabelOffsetMapUnitScale() + */ + const QgsMapUnitScale &labelOffsetMapUnitScale() const { return mLabelOffsetMapUnitScale; } + + /** + * Sets the map unit \a scale used for calculating the offset between the line and linear referencing labels. + * + * \see labelOffsetMapUnitScale() + */ + void setLabelOffsetMapUnitScale( const QgsMapUnitScale &scale ) { mLabelOffsetMapUnitScale = scale; } + + /** + * Returns TRUE if a marker symbol should be shown corresponding to the labeled point on line. + * + * The marker symbol is set using setSubSymbol() + * + * \see setShowMarker() + */ + bool showMarker() const; + + /** + * Sets whether a marker symbol should be shown corresponding to the labeled point on line. + * + * The marker symbol is set using setSubSymbol() + * + * \see showMarker() + */ + void setShowMarker( bool show ); + + /** + * Returns the placement mode for the labels. + * + * \see setPlacement() + */ + Qgis::LinearReferencingPlacement placement() const; + + /** + * Sets the \a placement mode for the labels. + * + * \see placement() + */ + void setPlacement( Qgis::LinearReferencingPlacement placement ); + + /** + * Returns the label source, which dictates what quantity to use for the labels shown. + * + * \see setLabelSource() + */ + Qgis::LinearReferencingLabelSource labelSource() const; + + /** + * Sets the label \a source, which dictates what quantity to use for the labels shown. + * + * \see labelSource() + */ + void setLabelSource( Qgis::LinearReferencingLabelSource source ); + + /** + * Returns the length of line over which the line's direction is averaged when + * calculating individual label angles. Longer lengths smooth out angles from jagged lines to a greater extent. + * + * Units are retrieved through averageAngleUnit() + * + * \see setAverageAngleLength() + * \see averageAngleUnit() + * \see averageAngleMapUnitScale() + */ + double averageAngleLength() const { return mAverageAngleLength; } + + /** + * Sets the \a length of line over which the line's direction is averaged when + * calculating individual label angles. Longer lengths smooth out angles from jagged lines to a greater extent. + * + * Units are set through setAverageAngleUnit() + * + * \see averageAngleLength() + * \see setAverageAngleUnit() + * \see setAverageAngleMapUnitScale() + */ + void setAverageAngleLength( double length ) { mAverageAngleLength = length; } + + /** + * Sets the \a unit for the length over which the line's direction is averaged when + * calculating individual label angles. + * + * \see averageAngleUnit() + * \see setAverageAngleLength() + * \see setAverageAngleMapUnitScale() + */ + void setAverageAngleUnit( Qgis::RenderUnit unit ) { mAverageAngleLengthUnit = unit; } + + /** + * Returns the unit for the length over which the line's direction is averaged when + * calculating individual label angles. + * + * \see setAverageAngleUnit() + * \see averageAngleLength() + * \see averageAngleMapUnitScale() + */ + Qgis::RenderUnit averageAngleUnit() const { return mAverageAngleLengthUnit; } + + /** + * Sets the map unit \a scale for the length over which the line's direction is averaged when + * calculating individual label angles. + * + * \see averageAngleMapUnitScale() + * \see setAverageAngleLength() + * \see setAverageAngleUnit() + */ + void setAverageAngleMapUnitScale( const QgsMapUnitScale &scale ) { mAverageAngleLengthMapUnitScale = scale; } + + /** + * Returns the map unit scale for the length over which the line's direction is averaged when + * calculating individual label angles. + * + * \see setAverageAngleMapUnitScale() + * \see averageAngleLength() + * \see averageAngleUnit() + */ + const QgsMapUnitScale &averageAngleMapUnitScale() const { return mAverageAngleLengthMapUnitScale; } + + private: + void renderPolylineInterval( const QgsLineString *line, QgsSymbolRenderContext &context, double skipMultiples, const QPointF &labelOffsetPainterUnits, double averageAngleLengthPainterUnits, bool showMarker ); + void renderPolylineVertex( const QgsLineString *line, QgsSymbolRenderContext &context, double skipMultiples, const QPointF &labelOffsetPainterUnits, double averageAngleLengthPainterUnits, bool showMarker ); + static QPointF pointToPainter( QgsSymbolRenderContext &context, double x, double y, double z ); + + Qgis::LinearReferencingPlacement mPlacement = Qgis::LinearReferencingPlacement::IntervalCartesian2D; + Qgis::LinearReferencingLabelSource mLabelSource = Qgis::LinearReferencingLabelSource::CartesianDistance2D; + + double mInterval = 1000; + double mSkipMultiplesOf = 0; + bool mRotateLabels = true; + + QPointF mLabelOffset{ 1, 0 }; + Qgis::RenderUnit mLabelOffsetUnit = Qgis::RenderUnit::Millimeters; + QgsMapUnitScale mLabelOffsetMapUnitScale; + + QgsTextFormat mTextFormat; + std::unique_ptr mNumericFormat; + + bool mShowMarker = false; + std::unique_ptr mMarkerSymbol; + + double mAverageAngleLength = 4; + Qgis::RenderUnit mAverageAngleLengthUnit = Qgis::RenderUnit::Millimeters; + QgsMapUnitScale mAverageAngleLengthMapUnitScale; + + void renderGeometryPart( QgsSymbolRenderContext &context, const QgsAbstractGeometry *geometry, double labelOffsetPainterUnitsX, double labelOffsetPainterUnitsY, double skipMultiples, double averageAngleDistancePainterUnits, bool showMarker ); + void renderLineString( QgsSymbolRenderContext &context, const QgsLineString *line, double labelOffsetPainterUnitsX, double labelOffsetPainterUnitsY, double skipMultiples, double averageAngleDistancePainterUnits, bool showMarker ); +}; + +#endif // QGSLINEARREFERENCINGSYMBOLLAYER_H diff --git a/src/core/symbology/qgssymbollayer.cpp b/src/core/symbology/qgssymbollayer.cpp index 139602681be6..b0a9cc928e32 100644 --- a/src/core/symbology/qgssymbollayer.cpp +++ b/src/core/symbology/qgssymbollayer.cpp @@ -116,6 +116,8 @@ void QgsSymbolLayer::initPropertyDefinitions() { static_cast< int >( QgsSymbolLayer::Property::RandomOffsetX ), QgsPropertyDefinition( "randomOffsetX", QObject::tr( "Horizontal random offset" ), QgsPropertyDefinition::Double, origin )}, { static_cast< int >( QgsSymbolLayer::Property::RandomOffsetY ), QgsPropertyDefinition( "randomOffsetY", QObject::tr( "Vertical random offset" ), QgsPropertyDefinition::Double, origin )}, { static_cast< int >( QgsSymbolLayer::Property::LineClipping ), QgsPropertyDefinition( "lineClipping", QgsPropertyDefinition::DataTypeString, QObject::tr( "Line clipping mode" ), QObject::tr( "string " ) + QLatin1String( "[no|during_render|before_render]" ), origin )}, + { static_cast< int >( QgsSymbolLayer::Property::SkipMultiples ), QgsPropertyDefinition( "skipMultiples", QObject::tr( "Skip multiples of" ), QgsPropertyDefinition::DoublePositive, origin )}, + { static_cast< int >( QgsSymbolLayer::Property::ShowMarker ), QgsPropertyDefinition( "showMarker", QObject::tr( "Show marker" ), QgsPropertyDefinition::Boolean, origin )}, }; } diff --git a/src/core/symbology/qgssymbollayer.h b/src/core/symbology/qgssymbollayer.h index fdc2946bdeb5..c67374f3c385 100644 --- a/src/core/symbology/qgssymbollayer.h +++ b/src/core/symbology/qgssymbollayer.h @@ -100,6 +100,8 @@ class CORE_EXPORT QgsSymbolLayer sipType = sipType_QgsRasterLineSymbolLayer; else if ( sipCpp->layerType() == "Lineburst" ) sipType = sipType_QgsLineburstSymbolLayer; + else if ( sipCpp->layerType() == "LinearReferencing" ) + sipType = sipType_QgsLinearReferencingSymbolLayer; else sipType = sipType_QgsLineSymbolLayer; break; @@ -212,6 +214,8 @@ class CORE_EXPORT QgsSymbolLayer RandomOffsetX SIP_MONKEYPATCH_COMPAT_NAME( PropertyRandomOffsetX ), //!< Random offset X \since QGIS 3.24 RandomOffsetY SIP_MONKEYPATCH_COMPAT_NAME( PropertyRandomOffsetY ), //!< Random offset Y \since QGIS 3.24 LineClipping SIP_MONKEYPATCH_COMPAT_NAME( PropertyLineClipping ), //!< Line clipping mode \since QGIS 3.24 + SkipMultiples, //!< Skip multiples of \since QGIS 3.40 + ShowMarker, //!< Show markers \since QGIS 3.40 }; // *INDENT-ON* diff --git a/src/core/symbology/qgssymbollayerregistry.cpp b/src/core/symbology/qgssymbollayerregistry.cpp index 8a3f9363ca74..356cde81db9a 100644 --- a/src/core/symbology/qgssymbollayerregistry.cpp +++ b/src/core/symbology/qgssymbollayerregistry.cpp @@ -24,6 +24,7 @@ #include "qgsmasksymbollayer.h" #include "qgsgeometrygeneratorsymbollayer.h" #include "qgsinterpolatedlinerenderer.h" +#include "qgslinearreferencingsymbollayer.h" QgsSymbolLayerRegistry::QgsSymbolLayerRegistry() { @@ -44,6 +45,8 @@ QgsSymbolLayerRegistry::QgsSymbolLayerRegistry() QgsLineburstSymbolLayer::create ) ); addSymbolLayerType( new QgsSymbolLayerMetadata( QStringLiteral( "FilledLine" ), QObject::tr( "Filled Line" ), Qgis::SymbolType::Line, QgsFilledLineSymbolLayer::create ) ); + addSymbolLayerType( new QgsSymbolLayerMetadata( QStringLiteral( "LinearReferencing" ), QObject::tr( "Linear Referencing" ), Qgis::SymbolType::Line, + QgsLinearReferencingSymbolLayer::create ) ); addSymbolLayerType( new QgsSymbolLayerMetadata( QStringLiteral( "SimpleMarker" ), QObject::tr( "Simple Marker" ), Qgis::SymbolType::Marker, QgsSimpleMarkerSymbolLayer::create, QgsSimpleMarkerSymbolLayer::createFromSld ) ); diff --git a/src/gui/symbology/qgslayerpropertieswidget.cpp b/src/gui/symbology/qgslayerpropertieswidget.cpp index 16602e724ec7..72b2654b4d94 100644 --- a/src/gui/symbology/qgslayerpropertieswidget.cpp +++ b/src/gui/symbology/qgslayerpropertieswidget.cpp @@ -83,6 +83,7 @@ static void _initWidgetFunctions() _initWidgetFunction( QStringLiteral( "RasterLine" ), QgsRasterLineSymbolLayerWidget::create ); _initWidgetFunction( QStringLiteral( "Lineburst" ), QgsLineburstSymbolLayerWidget::create ); _initWidgetFunction( QStringLiteral( "FilledLine" ), QgsFilledLineSymbolLayerWidget::create ); + _initWidgetFunction( QStringLiteral( "LinearReferencing" ), QgsLinearReferencingSymbolLayerWidget::create ); _initWidgetFunction( QStringLiteral( "SimpleMarker" ), QgsSimpleMarkerSymbolLayerWidget::create ); _initWidgetFunction( QStringLiteral( "FilledMarker" ), QgsFilledMarkerSymbolLayerWidget::create ); diff --git a/src/gui/symbology/qgssymbollayerwidget.cpp b/src/gui/symbology/qgssymbollayerwidget.cpp index e5b489042621..21c8b061b6a2 100644 --- a/src/gui/symbology/qgssymbollayerwidget.cpp +++ b/src/gui/symbology/qgssymbollayerwidget.cpp @@ -42,6 +42,8 @@ #include "qgsmarkersymbol.h" #include "qgsfillsymbol.h" #include "qgsiconutils.h" +#include "qgslinearreferencingsymbollayer.h" +#include "qgsnumericformatselectorwidget.h" #include #include @@ -5458,3 +5460,242 @@ QgsSymbolLayer *QgsFilledLineSymbolLayerWidget::symbolLayer() { return mLayer; } + +// +// QgsLinearReferencingSymbolLayerWidget +// + +QgsLinearReferencingSymbolLayerWidget::QgsLinearReferencingSymbolLayerWidget( QgsVectorLayer *vl, QWidget *parent ) + : QgsSymbolLayerWidget( parent, vl ) +{ + mLayer = nullptr; + + setupUi( this ); + + mComboPlacement->addItem( tr( "Interval (Cartesian 2D Distances)" ), QVariant::fromValue( Qgis::LinearReferencingPlacement::IntervalCartesian2D ) ); + mComboPlacement->addItem( tr( "Interval (Z Values)" ), QVariant::fromValue( Qgis::LinearReferencingPlacement::IntervalZ ) ); + mComboPlacement->addItem( tr( "Interval (M Values)" ), QVariant::fromValue( Qgis::LinearReferencingPlacement::IntervalM ) ); + mComboPlacement->addItem( tr( "On Every Vertex" ), QVariant::fromValue( Qgis::LinearReferencingPlacement::Vertex ) ); + + mComboQuantity->addItem( tr( "Cartesian 2D Distance" ), QVariant::fromValue( Qgis::LinearReferencingLabelSource::CartesianDistance2D ) ); + mComboQuantity->addItem( tr( "Z Values" ), QVariant::fromValue( Qgis::LinearReferencingLabelSource::Z ) ); + mComboQuantity->addItem( tr( "M Values" ), QVariant::fromValue( Qgis::LinearReferencingLabelSource::M ) ); + + mSpinSkipMultiples->setClearValue( 0, tr( "Not set" ) ); + mSpinLabelOffsetX->setClearValue( 0 ); + mSpinLabelOffsetY->setClearValue( 0 ); + mSpinAverageAngleLength->setClearValue( 4.0 ); + mLabelOffsetUnitWidget->setUnits( QgsUnitTypes::RenderUnitList() << Qgis::RenderUnit::Millimeters << Qgis::RenderUnit::MetersInMapUnits << Qgis::RenderUnit::MapUnits << Qgis::RenderUnit::Pixels + << Qgis::RenderUnit::Points << Qgis::RenderUnit::Inches ); + mAverageAngleUnit->setUnits( QgsUnitTypes::RenderUnitList() << Qgis::RenderUnit::Millimeters << Qgis::RenderUnit::MetersInMapUnits << Qgis::RenderUnit::MapUnits << Qgis::RenderUnit::Pixels + << Qgis::RenderUnit::Points << Qgis::RenderUnit::Inches ); + + connect( mComboQuantity, qOverload< int >( &QComboBox::currentIndexChanged ), this, [ = ] + { + if ( mLayer && !mBlockChangesSignal ) + { + mLayer->setLabelSource( mComboQuantity->currentData().value< Qgis::LinearReferencingLabelSource >() ); + emit changed(); + } + } ); + connect( mTextFormatButton, &QgsFontButton::changed, this, [ = ] + { + if ( mLayer && !mBlockChangesSignal ) + { + mLayer->setTextFormat( mTextFormatButton->textFormat() ); + emit changed(); + } + } ); + connect( spinInterval, qOverload< double >( &QgsDoubleSpinBox::valueChanged ), this, [ = ] + { + if ( mLayer && !mBlockChangesSignal ) + { + mLayer->setInterval( spinInterval->value() ); + emit changed(); + } + } ); + connect( mSpinSkipMultiples, qOverload< double >( &QgsDoubleSpinBox::valueChanged ), this, [ = ] + { + if ( mLayer && !mBlockChangesSignal ) + { + mLayer->setSkipMultiplesOf( mSpinSkipMultiples->value() ); + emit changed(); + } + } ); + connect( mCheckRotate, &QCheckBox::toggled, this, [ = ]( bool checked ) + { + if ( mLayer && !mBlockChangesSignal ) + { + mLayer->setRotateLabels( checked ); + emit changed(); + } + mSpinAverageAngleLength->setEnabled( checked ); + mAverageAngleUnit->setEnabled( mSpinAverageAngleLength->isEnabled() ); + } ); + connect( mCheckShowMarker, &QCheckBox::toggled, this, [ = ]( bool checked ) + { + if ( mLayer && !mBlockChangesSignal ) + { + mLayer->setShowMarker( checked ); + emit symbolChanged(); + } + } ); + + connect( mSpinLabelOffsetX, qOverload< double >( &QgsDoubleSpinBox::valueChanged ), this, [ = ] + { + if ( mLayer && !mBlockChangesSignal ) + { + mLayer->setLabelOffset( QPointF( mSpinLabelOffsetX->value(), mSpinLabelOffsetY->value() ) ); + emit changed(); + } + } ); + connect( mSpinLabelOffsetY, qOverload< double >( &QgsDoubleSpinBox::valueChanged ), this, [ = ] + { + if ( mLayer && !mBlockChangesSignal ) + { + mLayer->setLabelOffset( QPointF( mSpinLabelOffsetX->value(), mSpinLabelOffsetY->value() ) ); + emit changed(); + } + } ); + connect( mLabelOffsetUnitWidget, &QgsUnitSelectionWidget::changed, this, [ = ] + { + if ( mLayer && !mBlockChangesSignal ) + { + mLayer->setLabelOffsetUnit( mLabelOffsetUnitWidget->unit() ); + mLayer->setLabelOffsetMapUnitScale( mLabelOffsetUnitWidget->getMapUnitScale() ); + emit changed(); + } + } ); + + connect( mComboPlacement, qOverload< int>( &QComboBox::currentIndexChanged ), this, [ = ] + { + if ( mLayer && !mBlockChangesSignal ) + { + const Qgis::LinearReferencingPlacement placement = mComboPlacement->currentData().value< Qgis::LinearReferencingPlacement >(); + mLayer->setPlacement( placement ); + switch ( placement ) + { + case Qgis::LinearReferencingPlacement::IntervalCartesian2D: + case Qgis::LinearReferencingPlacement::IntervalZ: + case Qgis::LinearReferencingPlacement::IntervalM: + mIntervalWidget->show(); + break; + case Qgis::LinearReferencingPlacement::Vertex: + mIntervalWidget->hide(); + break; + } + emit changed(); + } + } ); + + connect( mSpinAverageAngleLength, qOverload< double >( &QgsDoubleSpinBox::valueChanged ), this, [ = ] + { + if ( mLayer && !mBlockChangesSignal ) + { + mLayer->setAverageAngleLength( mSpinAverageAngleLength->value() ); + emit changed(); + } + } ); + connect( mAverageAngleUnit, &QgsUnitSelectionWidget::changed, this, [ = ] + { + if ( mLayer && !mBlockChangesSignal ) + { + mLayer->setAverageAngleUnit( mAverageAngleUnit->unit() ); + mLayer->setAverageAngleMapUnitScale( mAverageAngleUnit->getMapUnitScale() ); + emit changed(); + } + } ); + + connect( mNumberFormatPushButton, &QPushButton::clicked, this, &QgsLinearReferencingSymbolLayerWidget::changeNumberFormat ); + + mTextFormatButton->registerExpressionContextGenerator( this ); +} + +QgsLinearReferencingSymbolLayerWidget::~QgsLinearReferencingSymbolLayerWidget() = default; + + +void QgsLinearReferencingSymbolLayerWidget::setSymbolLayer( QgsSymbolLayer *layer ) +{ + if ( !layer || layer->layerType() != QLatin1String( "LinearReferencing" ) ) + return; + + // layer type is correct, we can do the cast + mLayer = qgis::down_cast( layer ); + + mBlockChangesSignal = true; + + mComboPlacement->setCurrentIndex( mComboPlacement->findData( QVariant::fromValue( mLayer->placement() ) ) ); + switch ( mLayer->placement() ) + { + case Qgis::LinearReferencingPlacement::IntervalCartesian2D: + case Qgis::LinearReferencingPlacement::IntervalZ: + case Qgis::LinearReferencingPlacement::IntervalM: + mIntervalWidget->show(); + break; + case Qgis::LinearReferencingPlacement::Vertex: + mIntervalWidget->hide(); + break; + } + + mComboQuantity->setCurrentIndex( mComboQuantity->findData( QVariant::fromValue( mLayer->labelSource() ) ) ); + + mTextFormatButton->setTextFormat( mLayer->textFormat() ); + spinInterval->setValue( mLayer->interval() ); + mSpinSkipMultiples->setValue( mLayer->skipMultiplesOf() ); + mCheckRotate->setChecked( mLayer->rotateLabels() ); + mCheckShowMarker->setChecked( mLayer->showMarker() ); + mSpinLabelOffsetX->setValue( mLayer->labelOffset().x() ); + mSpinLabelOffsetY->setValue( mLayer->labelOffset().y() ); + mLabelOffsetUnitWidget->setUnit( mLayer->labelOffsetUnit() ); + mLabelOffsetUnitWidget->setMapUnitScale( mLayer->labelOffsetMapUnitScale() ); + + mAverageAngleUnit->setUnit( mLayer->averageAngleUnit() ); + mAverageAngleUnit->setMapUnitScale( mLayer->averageAngleMapUnitScale() ); + mSpinAverageAngleLength->setValue( mLayer->averageAngleLength() ); + + mSpinAverageAngleLength->setEnabled( mCheckRotate->isChecked() ); + mAverageAngleUnit->setEnabled( mSpinAverageAngleLength->isEnabled() ); + + registerDataDefinedButton( mIntervalDDBtn, QgsSymbolLayer::Property::Interval ); + registerDataDefinedButton( mAverageAngleDDBtn, QgsSymbolLayer::Property::AverageAngleLength ); + registerDataDefinedButton( mSkipMultiplesDDBtn, QgsSymbolLayer::Property::SkipMultiples ); + registerDataDefinedButton( mShowMarkerDDBtn, QgsSymbolLayer::Property::ShowMarker ); + + mBlockChangesSignal = false; +} + +QgsSymbolLayer *QgsLinearReferencingSymbolLayerWidget::symbolLayer() +{ + return mLayer; +} + +void QgsLinearReferencingSymbolLayerWidget::setContext( const QgsSymbolWidgetContext &context ) +{ + QgsSymbolLayerWidget::setContext( context ); + mTextFormatButton->setMapCanvas( context.mapCanvas() ); + mTextFormatButton->setMessageBar( context.messageBar() ); +} + +void QgsLinearReferencingSymbolLayerWidget::changeNumberFormat() +{ + QgsPanelWidget *panel = QgsPanelWidget::findParentPanel( this ); + if ( panel && panel->dockMode() ) + { + QgsNumericFormatSelectorWidget *widget = new QgsNumericFormatSelectorWidget( this ); + widget->setPanelTitle( tr( "Number Format" ) ); + widget->setFormat( mLayer->numericFormat() ); + connect( widget, &QgsNumericFormatSelectorWidget::changed, this, [ = ] + { + if ( !mBlockChangesSignal ) + { + mLayer->setNumericFormat( widget->format() ); + emit changed(); + } + } ); + panel->openPanel( widget ); + } + else + { + // TODO!! dialog mode + } +} diff --git a/src/gui/symbology/qgssymbollayerwidget.h b/src/gui/symbology/qgssymbollayerwidget.h index 5d4351d12c57..37554a1fcb35 100644 --- a/src/gui/symbology/qgssymbollayerwidget.h +++ b/src/gui/symbology/qgssymbollayerwidget.h @@ -144,7 +144,6 @@ class GUI_EXPORT QgsSimpleLineSymbolLayerWidget : public QgsSymbolLayerWidget, p */ static QgsSymbolLayerWidget *create( QgsVectorLayer *vl ) SIP_FACTORY { return new QgsSimpleLineSymbolLayerWidget( vl ); } - // from base class void setSymbolLayer( QgsSymbolLayer *layer ) override; QgsSymbolLayer *symbolLayer() override; void setContext( const QgsSymbolWidgetContext &context ) override; @@ -1291,6 +1290,51 @@ class GUI_EXPORT QgsCentroidFillSymbolLayerWidget : public QgsSymbolLayerWidget, }; +/////////// + +#include "ui_qgslinearreferencingsymbollayerwidgetbase.h" + +class QgsLinearReferencingSymbolLayer; + +/** + * \ingroup gui + * \class QgsLinearReferencingSymbolLayerWidget + * \brief Widget for controlling the properties of a QgsLinearReferencingSymbolLayer. + * \since QGIS 3.40 + */ +class GUI_EXPORT QgsLinearReferencingSymbolLayerWidget : public QgsSymbolLayerWidget, private Ui::QgsLinearReferencingSymbolLayerWidgetBase +{ + Q_OBJECT + + public: + + /** + * Constructor for QgsLinearReferencingSymbolLayerWidget. + */ + QgsLinearReferencingSymbolLayerWidget( QgsVectorLayer *vl, QWidget *parent SIP_TRANSFERTHIS = nullptr ); + + ~QgsLinearReferencingSymbolLayerWidget() override; + + /** + * Creates a new QgsLinearReferencingSymbolLayerWidget. + * \param vl associated vector layer + */ + static QgsSymbolLayerWidget *create( QgsVectorLayer *vl ) SIP_FACTORY { return new QgsLinearReferencingSymbolLayerWidget( vl ); } + + void setSymbolLayer( QgsSymbolLayer *layer ) override; + QgsSymbolLayer *symbolLayer() override; + void setContext( const QgsSymbolWidgetContext &context ) override; + + private slots: + void changeNumberFormat(); + + private: + + QgsLinearReferencingSymbolLayer *mLayer = nullptr; + bool mBlockChangesSignal = false; +}; + + #include "ui_qgsgeometrygeneratorwidgetbase.h" #include "qgis_gui.h" diff --git a/src/gui/symbology/qgssymbolselectordialog.cpp b/src/gui/symbology/qgssymbolselectordialog.cpp index dec8f4395a30..5f85c8126fa8 100644 --- a/src/gui/symbology/qgssymbolselectordialog.cpp +++ b/src/gui/symbology/qgssymbolselectordialog.cpp @@ -814,9 +814,8 @@ void QgsSymbolSelectorWidget::duplicateLayer() void QgsSymbolSelectorWidget::changeLayer( QgsSymbolLayer *newLayer ) { SymbolLayerItem *item = currentLayerItem(); - QgsSymbolLayer *layer = item->layer(); - if ( layer->subSymbol() ) + if ( item->rowCount() > 0 ) { item->removeRow( 0 ); } diff --git a/src/ui/symbollayer/qgslinearreferencingsymbollayerwidgetbase.ui b/src/ui/symbollayer/qgslinearreferencingsymbollayerwidgetbase.ui new file mode 100644 index 000000000000..e76b3cb7b9f1 --- /dev/null +++ b/src/ui/symbollayer/qgslinearreferencingsymbollayerwidgetbase.ui @@ -0,0 +1,439 @@ + + + QgsLinearReferencingSymbolLayerWidgetBase + + + + 0 + 0 + 388 + 419 + + + + Qt::FocusPolicy::WheelFocus + + + Linear Referencing Symbol Layer + + + + 0 + + + 0 + + + 0 + + + 9 + + + + + Number format + + + true + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + x + + + + + + + + 1 + 0 + + + + 6 + + + -99999999.989999994635582 + + + 99999999.989999994635582 + + + 0.200000000000000 + + + + + + + y + + + + + + + + 1 + 0 + + + + 6 + + + -99999999.989999994635582 + + + 99999999.989999994635582 + + + 0.200000000000000 + + + + + + + + 0 + 0 + + + + Qt::FocusPolicy::StrongFocus + + + + + + + + + Label offset + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + 0 + 0 + + + + Text format + + + + + + + Average angle over + + + + + + + Qt::FocusPolicy::NoFocus + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Interval + + + + + + + 0 + + + + + + 1 + 0 + + + + 6 + + + 10000000.000000000000000 + + + 0.200000000000000 + + + 1.000000000000000 + + + false + + + + + + + … + + + + + + + + + + + + 0 + + + + + + 1 + 0 + + + + 6 + + + 10000000.000000000000000 + + + 0.200000000000000 + + + 1.000000000000000 + + + true + + + + + + + … + + + + + + + + + Customize + + + + + + + 0 + + + + + + 1 + 0 + + + + 6 + + + 10000000.000000000000000 + + + 0.200000000000000 + + + 1.000000000000000 + + + + + + + + 20 + 0 + + + + Qt::FocusPolicy::TabFocus + + + + + + + … + + + + + + + + + Measure placement + + + + + + + Skip multiples of + + + + + + + Rotate labels to follow line direction + + + + + + + Quantity + + + + + + + + + + + + + Text format + + + + + + + 0 + + + + + Show marker symbols + + + + + + + … + + + + + + + + + + QgsPropertyOverrideButton + QToolButton +
qgspropertyoverridebutton.h
+
+ + QgsDoubleSpinBox + QDoubleSpinBox +
qgsdoublespinbox.h
+
+ + QgsUnitSelectionWidget + QWidget +
qgsunitselectionwidget.h
+ 1 +
+ + QgsFontButton + QToolButton +
qgsfontbutton.h
+
+
+ + mComboPlacement + spinInterval + mIntervalDDBtn + mComboQuantity + mTextFormatButton + mNumberFormatPushButton + mSpinSkipMultiples + mSkipMultiplesDDBtn + mSpinLabelOffsetX + mLabelOffsetUnitWidget + mSpinLabelOffsetY + mCheckShowMarker + mShowMarkerDDBtn + mCheckRotate + mSpinAverageAngleLength + mAverageAngleUnit + mAverageAngleDDBtn + + + +
diff --git a/tests/src/python/CMakeLists.txt b/tests/src/python/CMakeLists.txt index a3acc4c9fbcc..dfff3ca6ec07 100644 --- a/tests/src/python/CMakeLists.txt +++ b/tests/src/python/CMakeLists.txt @@ -158,6 +158,7 @@ ADD_PYTHON_TEST(PyQgsLayoutSnapper test_qgslayoutsnapper.py) ADD_PYTHON_TEST(PyQgsLayoutTable test_qgslayouttable.py) ADD_PYTHON_TEST(PyQgsLegendPatchShape test_qgslegendpatchshape.py) ADD_PYTHON_TEST(PyQgsLegendRenderer test_qgslegendrenderer.py) +ADD_PYTHON_TEST(PyQgsLinearReferencingSymbolLayer test_qgslinearreferencingsymbollayer.py) ADD_PYTHON_TEST(PyQgsLineSegment test_qgslinesegment.py) ADD_PYTHON_TEST(PyQgsLineString test_qgslinestring.py) ADD_PYTHON_TEST(PyQgsLineSymbolLayers test_qgslinesymbollayers.py) diff --git a/tests/src/python/test_qgslinearreferencingsymbollayer.py b/tests/src/python/test_qgslinearreferencingsymbollayer.py new file mode 100644 index 000000000000..10dd9d2e35cc --- /dev/null +++ b/tests/src/python/test_qgslinearreferencingsymbollayer.py @@ -0,0 +1,759 @@ +""" +*************************************************************************** + test_qgslinearreferencingsymbollayer.py + --------------------- + Date : August 2024 + Copyright : (C) 2024 by Nyall Dawson + Email : nyall dot dawson at gmail dot com +*************************************************************************** +* * +* This program is free software; you can redistribute it and/or modify * +* it under the terms of the GNU General Public License as published by * +* the Free Software Foundation; either version 2 of the License, or * +* (at your option) any later version. * +* * +*************************************************************************** +""" +import unittest + +from qgis.PyQt.QtCore import QPointF +from qgis.PyQt.QtGui import QColor, QImage, QPainter +from qgis.core import ( + Qgis, + QgsFeature, + QgsGeometry, + QgsLineSymbol, + QgsMapSettings, + QgsRenderContext, + QgsLinearReferencingSymbolLayer, + QgsTextFormat, + QgsFontUtils, + QgsBasicNumericFormat, + QgsMarkerSymbol, + QgsFillSymbol +) +from qgis.testing import start_app, QgisTestCase + +from utilities import unitTestDataPath + +start_app() +TEST_DATA_DIR = unitTestDataPath() + + +class TestQgsSimpleLineSymbolLayer(QgisTestCase): + + @classmethod + def control_path_prefix(cls): + return 'symbol_linearref' + + def test_distance_2d(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalCartesian2D) + linear_ref.setInterval(1) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'distance_2d', + 'distance_2d', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_distance_2d_with_z(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalCartesian2D) + linear_ref.setInterval(1) + linear_ref.setLabelSource(Qgis.LinearReferencingLabelSource.Z) + + number_format = QgsBasicNumericFormat() + number_format.setNumberDecimalPlaces(1) + number_format.setShowTrailingZeros(False) + linear_ref.setNumericFormat(number_format) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'distance_with_z', + 'distance_with_z', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_distance_2d_with_m(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalCartesian2D) + linear_ref.setInterval(1) + linear_ref.setLabelSource(Qgis.LinearReferencingLabelSource.M) + + number_format = QgsBasicNumericFormat() + number_format.setNumberDecimalPlaces(1) + number_format.setShowTrailingZeros(False) + linear_ref.setNumericFormat(number_format) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'distance_with_m', + 'distance_with_m', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_interpolate_by_z_with_distance(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalZ) + linear_ref.setInterval(0.3) + linear_ref.setLabelSource(Qgis.LinearReferencingLabelSource.CartesianDistance2D) + + number_format = QgsBasicNumericFormat() + number_format.setNumberDecimalPlaces(1) + number_format.setShowTrailingZeros(False) + linear_ref.setNumericFormat(number_format) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'placement_by_z_distance', + 'placement_by_z_distance', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_interpolate_by_z_with_z(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalZ) + linear_ref.setInterval(0.3) + linear_ref.setLabelSource(Qgis.LinearReferencingLabelSource.Z) + + number_format = QgsBasicNumericFormat() + number_format.setNumberDecimalPlaces(1) + number_format.setShowTrailingZeros(False) + linear_ref.setNumericFormat(number_format) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'placement_by_z_z', + 'placement_by_z_z', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_interpolate_by_z_with_m(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalZ) + linear_ref.setInterval(0.3) + linear_ref.setLabelSource(Qgis.LinearReferencingLabelSource.M) + + number_format = QgsBasicNumericFormat() + number_format.setNumberDecimalPlaces(1) + number_format.setShowTrailingZeros(False) + linear_ref.setNumericFormat(number_format) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'placement_by_z_m', + 'placement_by_z_m', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_interpolate_by_m_with_distance(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalM) + linear_ref.setInterval(0.3) + linear_ref.setLabelSource(Qgis.LinearReferencingLabelSource.CartesianDistance2D) + + number_format = QgsBasicNumericFormat() + number_format.setNumberDecimalPlaces(1) + number_format.setShowTrailingZeros(False) + linear_ref.setNumericFormat(number_format) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'placement_by_m_distance', + 'placement_by_m_distance', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_interpolate_by_m_with_z(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalM) + linear_ref.setInterval(0.3) + linear_ref.setLabelSource(Qgis.LinearReferencingLabelSource.Z) + + number_format = QgsBasicNumericFormat() + number_format.setNumberDecimalPlaces(1) + number_format.setShowTrailingZeros(False) + linear_ref.setNumericFormat(number_format) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'placement_by_m_z', + 'placement_by_m_z', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_interpolate_by_m_with_m(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalM) + linear_ref.setInterval(0.3) + linear_ref.setLabelSource(Qgis.LinearReferencingLabelSource.M) + + number_format = QgsBasicNumericFormat() + number_format.setNumberDecimalPlaces(1) + number_format.setShowTrailingZeros(False) + linear_ref.setNumericFormat(number_format) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'placement_by_m_m', + 'placement_by_m_m', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_at_vertex_with_distance(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.Vertex) + linear_ref.setLabelSource(Qgis.LinearReferencingLabelSource.CartesianDistance2D) + + number_format = QgsBasicNumericFormat() + number_format.setNumberDecimalPlaces(1) + number_format.setShowTrailingZeros(False) + linear_ref.setNumericFormat(number_format) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'vertex_distance', + 'vertex_distance', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_at_vertex_with_z(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.Vertex) + linear_ref.setLabelSource(Qgis.LinearReferencingLabelSource.Z) + + number_format = QgsBasicNumericFormat() + number_format.setNumberDecimalPlaces(1) + number_format.setShowTrailingZeros(False) + linear_ref.setNumericFormat(number_format) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'vertex_z', + 'vertex_z', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_at_vertex_with_m(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.Vertex) + linear_ref.setLabelSource(Qgis.LinearReferencingLabelSource.M) + + number_format = QgsBasicNumericFormat() + number_format.setNumberDecimalPlaces(1) + number_format.setShowTrailingZeros(False) + linear_ref.setNumericFormat(number_format) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'vertex_m', + 'vertex_m', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_distance_2d_skip_multiples(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalCartesian2D) + linear_ref.setInterval(1) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + linear_ref.setSkipMultiplesOf(2) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'skip_multiples', + 'skip_multiples', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_distance_2d_numeric_format(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalCartesian2D) + linear_ref.setInterval(1) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + number_format = QgsBasicNumericFormat() + number_format.setNumberDecimalPlaces(2) + number_format.setShowTrailingZeros(True) + linear_ref.setNumericFormat(number_format) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'numeric_format', + 'numeric_format', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_distance_2d_no_rotate(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalCartesian2D) + linear_ref.setInterval(1) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + linear_ref.setRotateLabels(False) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'no_rotate', + 'no_rotate', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_distance_2d_marker(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalCartesian2D) + linear_ref.setInterval(1) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + linear_ref.setShowMarker(True) + linear_ref.setSubSymbol( + QgsMarkerSymbol.createSimple( + {'color': '#00ff00', 'outline_style': 'no', 'size': '8', 'name': 'arrow'} + ) + ) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'marker', + 'marker', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_distance_2d_marker_no_rotate(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalCartesian2D) + linear_ref.setInterval(1) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + linear_ref.setShowMarker(True) + linear_ref.setSubSymbol( + QgsMarkerSymbol.createSimple( + {'color': '#00ff00', 'outline_style': 'no', 'size': '8', 'name': 'arrow'} + ) + ) + linear_ref.setRotateLabels(False) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4))')) + self.assertTrue( + self.image_check( + 'marker_no_rotate', + 'marker_no_rotate', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_multiline(self): + s = QgsLineSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalCartesian2D) + linear_ref.setInterval(1) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'MultiLineStringZM ((6 2 0.2 1.2, 9 2 0.7 0.2, 9 3 0.4 0, 11 5 0.8 0.4),' + '(16 12 0.2 1.2, 19 12 0.7 0.2))')) + self.assertTrue( + self.image_check( + 'multiline', + 'multiline', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def test_polygon(self): + s = QgsFillSymbol.createSimple( + {'outline_color': '#ff0000', 'outline_width': '2'}) + + linear_ref = QgsLinearReferencingSymbolLayer() + linear_ref.setPlacement( + Qgis.LinearReferencingPlacement.IntervalCartesian2D) + linear_ref.setInterval(1) + + font = QgsFontUtils.getStandardTestFont('Bold', 18) + text_format = QgsTextFormat.fromQFont(font) + text_format.setColor(QColor(255, 255, 255)) + linear_ref.setTextFormat(text_format) + + linear_ref.setLabelOffset(QPointF(3, -1)) + + s.appendSymbolLayer(linear_ref) + + rendered_image = self.renderGeometry(s, + QgsGeometry.fromWkt( + 'Polygon ((6 1, 10 1, 10 -3, 6 -3, 6 1),(7 0, 7 -2, 9 -2, 9 0, 7 0))')) + self.assertTrue( + self.image_check( + 'polygon', + 'polygon', + rendered_image, + color_tolerance=2, + allowed_mismatch=20 + ) + ) + + def renderGeometry(self, symbol, geom): + f = QgsFeature() + f.setGeometry(geom) + + image = QImage(800, 800, QImage.Format.Format_RGB32) + + painter = QPainter() + ms = QgsMapSettings() + extent = geom.get().boundingBox() + # buffer extent by 10% + if extent.width() > 0: + extent = extent.buffered((extent.height() + extent.width()) / 20.0) + else: + extent = extent.buffered(10) + + ms.setExtent(extent) + ms.setOutputSize(image.size()) + context = QgsRenderContext.fromMapSettings(ms) + context.setPainter(painter) + context.setScaleFactor(96 / 25.4) # 96 DPI + + painter.begin(image) + try: + image.fill(QColor(0, 0, 0)) + symbol.startRender(context) + symbol.renderFeature(f, context) + symbol.stopRender(context) + finally: + painter.end() + + return image + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/testdata/control_images/symbol_linearref/expected_distance_2d/expected_distance_2d.png b/tests/testdata/control_images/symbol_linearref/expected_distance_2d/expected_distance_2d.png new file mode 100644 index 0000000000000000000000000000000000000000..52f450f00206d88d350f6d1d204f66d6f2926cdc GIT binary patch literal 6584 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 literal 0 HcmV?d00001 diff --git a/tests/testdata/control_images/symbol_linearref/expected_distance_with_z/expected_distance_with_z.png b/tests/testdata/control_images/symbol_linearref/expected_distance_with_z/expected_distance_with_z.png new file mode 100644 index 0000000000000000000000000000000000000000..9b2f70797270722348a275efd670caf803038229 GIT binary patch literal 10449 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 literal 0 HcmV?d00001 diff --git a/tests/testdata/control_images/symbol_linearref/expected_marker/expected_marker.png b/tests/testdata/control_images/symbol_linearref/expected_marker/expected_marker.png new file mode 100644 index 0000000000000000000000000000000000000000..bad254466aa612e65637592adb2389572ed7e06e GIT binary patch literal 7124 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 JG%fqke*s*lTBHB~ literal 0 HcmV?d00001 diff --git a/tests/testdata/control_images/symbol_linearref/expected_marker_no_rotate/expected_marker_no_rotate.png b/tests/testdata/control_images/symbol_linearref/expected_marker_no_rotate/expected_marker_no_rotate.png new file mode 100644 index 0000000000000000000000000000000000000000..e96b7fea29f5d949178947559076d0f4d135b0f8 GIT binary patch literal 6838 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 wGU>5;6H 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 OZqU^-)GYnYHvC^~*bKb@ literal 0 HcmV?d00001 diff --git a/tests/testdata/control_images/symbol_linearref/expected_no_rotate/expected_no_rotate.png b/tests/testdata/control_images/symbol_linearref/expected_no_rotate/expected_no_rotate.png new file mode 100644 index 0000000000000000000000000000000000000000..807cf528e337dfed9013c4cc10a920827f2c7206 GIT binary patch literal 6244 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

B%olBARj1}ILK z!&8cKVY7v=9{Vm2w0?T&@)`2Fgk|U_Ud%tt48U5xXTYspF9C4{x%s=|;#0kUAsTM= zN!NFWnbz)obA#NN7QW8nCf>S)9{&f})+7M=RvOWnp7 z78ZV)-3MO>cCN1}Mm#!27sAtEWH3@t499foAz70vbEe$Xh)A7^QPq2d(O|UWWTTM151I` z8epd$5Ez$g639Soo7WD%#t zD+u9d19vvyeLcooyJMRONy1J?rivW+Jg{z1Keqwj;A)qOrLJbFs?F7raF@QKV-%iV zg_Y~-8mHe<6Ew;)E>uB&I+Uo^M?y~bm-Uf`%v zh3585z4uvJd&=tmfF|XEQ5lR6b~EvGz@~$rh7UORWlsp zmn}^J;Mq#_V`F12T^mKtpKtZKX{N+4An)8yPQRS6>W)ps!UR-R_`Uu))?M1jCnz>N zzseFY7lePcOEB`%VJzZ+B7Tf_eaOzi0ZDvSU#;GFch$DXiIJnFn$^@#o&s^ZaP0($ zZY^f2{`&^kC$qh#+T%{&z0o7m&Q8TB{F(OATSv&pfIFajd2)K8n&8i5{+MD!Bi`b6)Pm&3g-tqQQH)4R&}8mH4qS9Hv5EkK4xLISnelyEYxm-1TEK@*=+i zc=V}3TcfL2WdTqzoKtUmAFz}y)Y;ss5Jbzmhs)xg)x!G)Kvpjwnhh{C(MhwQPMtUo z*mvW-@UB5s3r*rWgVp@u^(im_u^BgWsgMhmEAZVV@q~VmK9qzxaIT*a*^o%248`c*%1rrEml=S8b5+2W zflCe4$>jGp^&nb<#IL~YZ*z72pKE*^^?Y9Dc`Y!L@b5CQTbgJWU1~ojDG8E(<>TFv zo2`l5i%Jt|(PwkxGd^n2v+jXO7X-*lRnt}AKp;1|{j*+`M-9$9%^f-_ zB{c|YV=JRG8z=8i4860*80p`I=^7Sy?idcdf@n#Y5_QOi(vx4d{e6|eY6hrWcoc?{ zy_NnO#LcZrxOe4nk>*O6S$B1GP!`{0<>ppE71c<-Oad*CItH^j%-4BQdcOAz&O*C~ zLoP&-KI8y_5H>)vKABxyj9EzK;U|YaOGYQL0URej{dEYy(GLc+%=}6pfg54O^*$%Z z9QFttk_DuSFz)Xoh!*~3Alf!FMzjS%Q7 zzMZgc=L^`#@PSt)6`(XLMHdv*x#M)m4pTNd|H_?M`d*Rdm&eN}1W`S(72^Lm)DI`&n~D`reQnu;(laj=xORE?4U91FecmIQ=E;_~@Q6UW zZ-`%_!5-9p%ecx)X;BQHqNg9QP~CIBg9iGD^3XRgl?a8-W$8CB={W-hBNJP55tH1y zcVgbXeS53v#WB!8;Afq>rl?W8ETHO_8L%0Om8-Q<8P3b_=Jk@!c!16L6c3LC1BoV) z&q~Alg|OX-D8nk?`q~#ZJzn4``^==SGOyWSexIYI1@MLgH%A0ER%Qh(!jP<5^@D!Q zM2~(3ufGCgieshaAeR4vtX=-$qPJZL#R5Dy1brOk~=joY`UzsAWV z%qZD3L|iWrDR>Rz=hD1e-%~r5V9>HY^?tT9yL)wh#5&&?h7yp?@)(36HRbg5bRfn? zUoY6u4tKkDS({!+2_pJ`K>&Yss|#3kb8u(Na!YZvKPaZTKL|E*zf}hS)YlYM)lwx$ zx8Y?Tx|U-cX^zpaQu6lcUl5vrvxc7ZwtBH!&2X2*4anbgCHD%bC{avTpMZUmEB%(K zmjaTkYc>yzj+EaJBh*&2k3Et-UoE^0{zAe8rT|eQib=m6X8KFPJ&=JviU4t(jJWIp zhW#Olk7|a5@Lk}3utEMn<*&2*p+yU06R?I8usfAs9^LeR8hn zb^DqgYY}e_`ZR${!Z@OqrA1xp@ntG>WiF!7zv2SqV> zx1KL##~2y`1^`K)ENj*N*|W8h1pJBXvJhy;Hf;XzdLs#G~Tk8_-cNBKrtu+17Wi_<;4X9r|Dg6fWz%Fbd-f zT1OZv0L{fIg?s>Q53<8%vgBkQUH>REYrL|e7p5-8foZ_)`ol?r2if;Q;+#LfvmNsj zvM)nj1rgYbvdh)f^!3EdZ0sz=Qp!c%yBZ6-I=L$69%1nhG=&l+uLeX$eg~T=6k}X2 zCwhG+@opVA2L}h-eMT-xr@=~phbfThDNb5HrvtvJ(}C-GjFUiW$p>U^YUA;E=}RqfGEllIjY}z; zqoP4eAoCc?v=IReK@4v93Jy9$8$z1xjainP6Ck z1nVRGAvha&29hRBC#mAW)iCiZou*78w9t+T0ho!vX%FZPHr>-g6cWLm*h@1S2g)J| z&US#%2hDd6Y95zs%Herl?F<#8pebZinDzo>0RZYPv~&4ct^l}-Tn&`?2Pcw@lN7x$ zJ_EzSWp0%N3GA3uU@r(pr4dK3Bel&O~TUR+z7d) zgvb8iI_WMV|wKY}ObU3NAgSxTRfM>4tk#WBN5P;rC) zD+55djdE~;N8=tG2y766Nts3AO`V8DV);T-M;F9t%EwJ(Vq-BEq#g{Tj8nJHfJ&-4 zKn3HtJ<``qNs~WHW+C}CZ102=sA9HqBV(Cc_STheKJ&F)b`_q8U@ix;7@KC^9)s^S zXzv772Fduj7686C-Q%d3m=6ei1HHRUd}n0SZcw3xPY_`9AI^llPCQo*PVJ0}s%q-s zfTY6em4DKp*LO<>Jk3~>$-0(MzXKlKc>6)g&CgtwtQPrKZed4q2sck+&ZxAgNY>c7 zw_t3vF}fZ??Y_0Z?Qxjxj;o5sj?Ay_yU07ArRV<+0!hfWHz0N^2I`dHWQJi06m4^J zb5L{O*2anyC~rB34q(R^&zZ?uE=wmsrUkqxxI2(>N-xLqC%sD|C+=BnU6Ka*roV;E;t4Os}$aB(VEJGnNMNsnJK6suwITq z;~W@L()-FnsQ(Yp?I3upWSG)`KAeH5;q>3HGG@h2aZXR`N3g)zl!Tqwfw92B@+~+B zqf5j>her{P=x==pz>d;9wED@oL})&;B=Zay zcCEEL14=ZLRxjBx6Q%PvHiF6X_(()=W7J@%;A_^$Pwr)JoqQ8SZHEZa7cV! z{ojpArl2I@bQv$WE#!R*I>25Y4@vO|Jo^klLr=Q8g;vTHX2LDZR?d7JdCbgb3RW8A r0tgwPlwdrZDE;4`|4Td2GP*;vvF_@od*&+CGD4c?bv1}*uRr)d1kFkd literal 0 HcmV?d00001 diff --git a/tests/testdata/control_images/symbol_linearref/expected_placement_by_m_distance/expected_placement_by_m_distance.png b/tests/testdata/control_images/symbol_linearref/expected_placement_by_m_distance/expected_placement_by_m_distance.png new file mode 100644 index 0000000000000000000000000000000000000000..f01ec88c73d1e258fb3761f5fdf6091d25c1e58e GIT binary patch literal 8227 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 HWft^b-M**- literal 0 HcmV?d00001 diff --git a/tests/testdata/control_images/symbol_linearref/expected_placement_by_m_m/expected_placement_by_m_m.png b/tests/testdata/control_images/symbol_linearref/expected_placement_by_m_m/expected_placement_by_m_m.png new file mode 100644 index 0000000000000000000000000000000000000000..0ea480cffb6a9b927025d2fc13035afa598f2f1f GIT binary patch literal 9117 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 literal 0 HcmV?d00001 diff --git a/tests/testdata/control_images/symbol_linearref/expected_placement_by_m_z/expected_placement_by_m_z.png b/tests/testdata/control_images/symbol_linearref/expected_placement_by_m_z/expected_placement_by_m_z.png new file mode 100644 index 0000000000000000000000000000000000000000..be72b57a058d269a017486b79f7cf4aaebbbb837 GIT binary patch literal 9422 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 OK`!cC)}~yr3;rMR!LP0W literal 0 HcmV?d00001 diff --git a/tests/testdata/control_images/symbol_linearref/expected_placement_by_z_m/expected_placement_by_z_m.png b/tests/testdata/control_images/symbol_linearref/expected_placement_by_z_m/expected_placement_by_z_m.png new file mode 100644 index 0000000000000000000000000000000000000000..e9bb5775dab0128e4cb981588850820179fa56b0 GIT binary patch literal 6670 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 Aj{pDw literal 0 HcmV?d00001 diff --git a/tests/testdata/control_images/symbol_linearref/expected_placement_by_z_z/expected_placement_by_z_z.png b/tests/testdata/control_images/symbol_linearref/expected_placement_by_z_z/expected_placement_by_z_z.png new file mode 100644 index 0000000000000000000000000000000000000000..4f776437b3136dce2b3ac9bbfb77662cd0175c62 GIT binary patch literal 8189 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 CJ|-3b literal 0 HcmV?d00001 diff --git a/tests/testdata/control_images/symbol_linearref/expected_skip_multiples/expected_skip_multiples.png b/tests/testdata/control_images/symbol_linearref/expected_skip_multiples/expected_skip_multiples.png new file mode 100644 index 0000000000000000000000000000000000000000..5c3562e3d583e92c9b7ecf37aa36f73d1fafc778 GIT binary patch literal 5110 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 literal 0 HcmV?d00001 diff --git a/tests/testdata/control_images/symbol_linearref/expected_vertex_distance/expected_vertex_distance.png b/tests/testdata/control_images/symbol_linearref/expected_vertex_distance/expected_vertex_distance.png new file mode 100644 index 0000000000000000000000000000000000000000..0d0a564742c96a5139d132390ad20378ab8001f9 GIT binary patch literal 6564 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 OGwW#SpQCBm1pfyC6iomC literal 0 HcmV?d00001 diff --git a/tests/testdata/control_images/symbol_linearref/expected_vertex_z/expected_vertex_z.png b/tests/testdata/control_images/symbol_linearref/expected_vertex_z/expected_vertex_z.png new file mode 100644 index 0000000000000000000000000000000000000000..4c1ccaa6a3e026ccb38750cd7ef90c217633d842 GIT binary patch literal 8057 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 Date: Thu, 29 Aug 2024 14:13:00 +1000 Subject: [PATCH 2/5] Set expression context for numeric format --- src/core/symbology/qgslinearreferencingsymbollayer.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/symbology/qgslinearreferencingsymbollayer.cpp b/src/core/symbology/qgslinearreferencingsymbollayer.cpp index ca3ad2bb521e..01cc1c49fa5f 100644 --- a/src/core/symbology/qgslinearreferencingsymbollayer.cpp +++ b/src/core/symbology/qgslinearreferencingsymbollayer.cpp @@ -665,6 +665,7 @@ void QgsLinearReferencingSymbolLayer::renderPolylineInterval( const QgsLineStrin } QgsNumericFormatContext numericContext; + numericContext.setExpressionContext( context.renderContext().expressionContext() ); std::unique_ptr< QgsLineString > painterUnitsGeometry( line->clone() ); if ( context.renderContext().coordinateTransform().isValid() ) @@ -755,6 +756,7 @@ void QgsLinearReferencingSymbolLayer::renderPolylineVertex( const QgsLineString averageAngleLengthPainterUnits = std::max( averageAngleLengthPainterUnits, 0.1 ); QgsNumericFormatContext numericContext; + numericContext.setExpressionContext( context.renderContext().expressionContext() ); const double *xData = line->xData(); const double *yData = line->yData(); From 05b2e259cbaed278926af0c61fc59f02f85d1ece Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Thu, 29 Aug 2024 14:21:19 +1000 Subject: [PATCH 3/5] Pass proper expression context on to numeric format widget --- .../qgsnumericformatselectorwidget.sip.in | 8 ++++++++ .../qgsnumericformatwidget.sip.in | 15 +++++++++++++-- .../qgsnumericformatselectorwidget.sip.in | 8 ++++++++ .../qgsnumericformatwidget.sip.in | 15 +++++++++++++-- .../qgsnumericformatselectorwidget.cpp | 8 ++++++++ .../qgsnumericformatselectorwidget.h | 10 ++++++++++ .../numericformats/qgsnumericformatwidget.cpp | 15 ++++++++++++++- .../numericformats/qgsnumericformatwidget.h | 18 ++++++++++++++++-- src/gui/symbology/qgssymbollayerwidget.cpp | 1 + 9 files changed, 91 insertions(+), 7 deletions(-) diff --git a/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in b/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in index 678bbf90496d..d7dad870c07c 100644 --- a/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in +++ b/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in @@ -40,6 +40,14 @@ Sets the format to show in the widget. Returns a new format object representing the settings currently configured in the widget. The caller takes ownership of the returned object. +%End + + void registerExpressionContextGenerator( QgsExpressionContextGenerator *generator ); +%Docstring +Register an expression context generator class that will be used to retrieve +an expression context for the widget when required. + +.. versionadded:: 3.40 %End signals: diff --git a/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatwidget.sip.in b/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatwidget.sip.in index 13e3fedfae7e..50005baee795 100644 --- a/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatwidget.sip.in +++ b/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatwidget.sip.in @@ -8,7 +8,7 @@ -class QgsNumericFormatWidget : QgsPanelWidget +class QgsNumericFormatWidget : QgsPanelWidget, QgsExpressionContextGenerator { %Docstring(signature="appended") Base class for widgets which allow control over the properties of :py:class:`QgsNumericFormat` subclasses @@ -42,6 +42,17 @@ Ownership of the returned object is transferred to the caller .. seealso:: :py:func:`setFormat` %End + void registerExpressionContextGenerator( QgsExpressionContextGenerator *generator ); +%Docstring +Register an expression context generator class that will be used to retrieve +an expression context for the widget when required. + +.. versionadded:: 3.40 +%End + + virtual QgsExpressionContext createExpressionContext() const; + + signals: void changed(); @@ -305,7 +316,7 @@ Constructor for QgsFractionNumericFormatWidget, initially showing the specified -class QgsExpressionBasedNumericFormatWidget : QgsNumericFormatWidget, QgsExpressionContextGenerator +class QgsExpressionBasedNumericFormatWidget : QgsNumericFormatWidget { %Docstring(signature="appended") A widget which allow control over the properties of a :py:class:`QgsExpressionBasedNumericFormat`. diff --git a/python/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in b/python/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in index 678bbf90496d..d7dad870c07c 100644 --- a/python/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in +++ b/python/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in @@ -40,6 +40,14 @@ Sets the format to show in the widget. Returns a new format object representing the settings currently configured in the widget. The caller takes ownership of the returned object. +%End + + void registerExpressionContextGenerator( QgsExpressionContextGenerator *generator ); +%Docstring +Register an expression context generator class that will be used to retrieve +an expression context for the widget when required. + +.. versionadded:: 3.40 %End signals: diff --git a/python/gui/auto_generated/numericformats/qgsnumericformatwidget.sip.in b/python/gui/auto_generated/numericformats/qgsnumericformatwidget.sip.in index 13e3fedfae7e..50005baee795 100644 --- a/python/gui/auto_generated/numericformats/qgsnumericformatwidget.sip.in +++ b/python/gui/auto_generated/numericformats/qgsnumericformatwidget.sip.in @@ -8,7 +8,7 @@ -class QgsNumericFormatWidget : QgsPanelWidget +class QgsNumericFormatWidget : QgsPanelWidget, QgsExpressionContextGenerator { %Docstring(signature="appended") Base class for widgets which allow control over the properties of :py:class:`QgsNumericFormat` subclasses @@ -42,6 +42,17 @@ Ownership of the returned object is transferred to the caller .. seealso:: :py:func:`setFormat` %End + void registerExpressionContextGenerator( QgsExpressionContextGenerator *generator ); +%Docstring +Register an expression context generator class that will be used to retrieve +an expression context for the widget when required. + +.. versionadded:: 3.40 +%End + + virtual QgsExpressionContext createExpressionContext() const; + + signals: void changed(); @@ -305,7 +316,7 @@ Constructor for QgsFractionNumericFormatWidget, initially showing the specified -class QgsExpressionBasedNumericFormatWidget : QgsNumericFormatWidget, QgsExpressionContextGenerator +class QgsExpressionBasedNumericFormatWidget : QgsNumericFormatWidget { %Docstring(signature="appended") A widget which allow control over the properties of a :py:class:`QgsExpressionBasedNumericFormat`. diff --git a/src/gui/numericformats/qgsnumericformatselectorwidget.cpp b/src/gui/numericformats/qgsnumericformatselectorwidget.cpp index 214f212981a6..3331dd243458 100644 --- a/src/gui/numericformats/qgsnumericformatselectorwidget.cpp +++ b/src/gui/numericformats/qgsnumericformatselectorwidget.cpp @@ -75,6 +75,13 @@ QgsNumericFormat *QgsNumericFormatSelectorWidget::format() const return mCurrentFormat->clone(); } +void QgsNumericFormatSelectorWidget::registerExpressionContextGenerator( QgsExpressionContextGenerator *generator ) +{ + mExpressionContextGenerator = generator; + if ( QgsNumericFormatWidget *w = qobject_cast< QgsNumericFormatWidget * >( stackedWidget->currentWidget() ) ) + w->registerExpressionContextGenerator( mExpressionContextGenerator ); +} + void QgsNumericFormatSelectorWidget::formatTypeChanged() { const QString newId = mCategoryCombo->currentData().toString(); @@ -142,6 +149,7 @@ void QgsNumericFormatSelectorWidget::updateFormatWidget() stackedWidget->setCurrentWidget( w ); // start receiving updates from widget connect( w, &QgsNumericFormatWidget::changed, this, &QgsNumericFormatSelectorWidget::formatChanged ); + w->registerExpressionContextGenerator( mExpressionContextGenerator ); } else { diff --git a/src/gui/numericformats/qgsnumericformatselectorwidget.h b/src/gui/numericformats/qgsnumericformatselectorwidget.h index b5585dc6c2fe..7bdddef0d747 100644 --- a/src/gui/numericformats/qgsnumericformatselectorwidget.h +++ b/src/gui/numericformats/qgsnumericformatselectorwidget.h @@ -23,6 +23,7 @@ class QgsNumericFormat; class QgsBasicNumericFormat; +class QgsExpressionContextGenerator; /** @@ -56,6 +57,13 @@ class GUI_EXPORT QgsNumericFormatSelectorWidget : public QgsPanelWidget, private */ QgsNumericFormat *format() const SIP_TRANSFERBACK; + /** + * Register an expression context generator class that will be used to retrieve + * an expression context for the widget when required. + * \since QGIS 3.40 + */ + void registerExpressionContextGenerator( QgsExpressionContextGenerator *generator ); + signals: /** @@ -75,6 +83,8 @@ class GUI_EXPORT QgsNumericFormatSelectorWidget : public QgsPanelWidget, private std::unique_ptr< QgsNumericFormat > mCurrentFormat; std::unique_ptr< QgsBasicNumericFormat > mPreviewFormat; + + QgsExpressionContextGenerator *mExpressionContextGenerator = nullptr; }; #endif //QGSNUMERICFORMATSELECTORWIDGET_H diff --git a/src/gui/numericformats/qgsnumericformatwidget.cpp b/src/gui/numericformats/qgsnumericformatwidget.cpp index 6ae4b1ec4b67..aef12dca079e 100644 --- a/src/gui/numericformats/qgsnumericformatwidget.cpp +++ b/src/gui/numericformats/qgsnumericformatwidget.cpp @@ -26,6 +26,18 @@ #include "qgis.h" #include +void QgsNumericFormatWidget::registerExpressionContextGenerator( QgsExpressionContextGenerator *generator ) +{ + mExpressionContextGenerator = generator; +} + +QgsExpressionContext QgsNumericFormatWidget::createExpressionContext() const +{ + if ( mExpressionContextGenerator ) + return mExpressionContextGenerator->createExpressionContext(); + return QgsExpressionContext(); +} + // // QgsBasicNumericFormatWidget // @@ -629,7 +641,7 @@ QgsExpressionBasedNumericFormatWidget::QgsExpressionBasedNumericFormatWidget( co QgsExpressionContext QgsExpressionBasedNumericFormatWidget::createExpressionContext() const { - QgsExpressionContext context; + QgsExpressionContext context = QgsNumericFormatWidget::createExpressionContext(); QgsExpressionContextScope *scope = new QgsExpressionContextScope(); scope->addVariable( QgsExpressionContextScope::StaticVariable( QStringLiteral( "value" ), 1234.5678 ) ); @@ -653,3 +665,4 @@ QgsNumericFormat *QgsExpressionBasedNumericFormatWidget::format() { return mFormat->clone(); } + diff --git a/src/gui/numericformats/qgsnumericformatwidget.h b/src/gui/numericformats/qgsnumericformatwidget.h index aa4c8f25cfda..69461fd69a7e 100644 --- a/src/gui/numericformats/qgsnumericformatwidget.h +++ b/src/gui/numericformats/qgsnumericformatwidget.h @@ -31,7 +31,7 @@ class QgsExpressionBasedNumericFormat; * \brief Base class for widgets which allow control over the properties of QgsNumericFormat subclasses * \since QGIS 3.12 */ -class GUI_EXPORT QgsNumericFormatWidget : public QgsPanelWidget +class GUI_EXPORT QgsNumericFormatWidget : public QgsPanelWidget, public QgsExpressionContextGenerator { Q_OBJECT @@ -59,6 +59,16 @@ class GUI_EXPORT QgsNumericFormatWidget : public QgsPanelWidget */ virtual QgsNumericFormat *format() = 0 SIP_TRANSFERBACK; + /** + * Register an expression context generator class that will be used to retrieve + * an expression context for the widget when required. + * + * \since QGIS 3.40 + */ + void registerExpressionContextGenerator( QgsExpressionContextGenerator *generator ); + + QgsExpressionContext createExpressionContext() const override; + signals: /** @@ -66,6 +76,10 @@ class GUI_EXPORT QgsNumericFormatWidget : public QgsPanelWidget */ void changed(); + private: + + QgsExpressionContextGenerator *mExpressionContextGenerator = nullptr; + }; @@ -367,7 +381,7 @@ class GUI_EXPORT QgsFractionNumericFormatWidget : public QgsNumericFormatWidget, * \brief A widget which allow control over the properties of a QgsExpressionBasedNumericFormat. * \since QGIS 3.40 */ -class GUI_EXPORT QgsExpressionBasedNumericFormatWidget : public QgsNumericFormatWidget, public QgsExpressionContextGenerator, private Ui::QgsExpressionBasedNumericFormatWidgetBase +class GUI_EXPORT QgsExpressionBasedNumericFormatWidget : public QgsNumericFormatWidget, private Ui::QgsExpressionBasedNumericFormatWidgetBase { Q_OBJECT diff --git a/src/gui/symbology/qgssymbollayerwidget.cpp b/src/gui/symbology/qgssymbollayerwidget.cpp index 21c8b061b6a2..652d5f28e09a 100644 --- a/src/gui/symbology/qgssymbollayerwidget.cpp +++ b/src/gui/symbology/qgssymbollayerwidget.cpp @@ -5684,6 +5684,7 @@ void QgsLinearReferencingSymbolLayerWidget::changeNumberFormat() QgsNumericFormatSelectorWidget *widget = new QgsNumericFormatSelectorWidget( this ); widget->setPanelTitle( tr( "Number Format" ) ); widget->setFormat( mLayer->numericFormat() ); + widget->registerExpressionContextGenerator( this ); connect( widget, &QgsNumericFormatSelectorWidget::changed, this, [ = ] { if ( !mBlockChangesSignal ) From 22ba4a4462a6304fffec605ff870d14d0cb6bdb9 Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Wed, 4 Sep 2024 14:47:32 +1000 Subject: [PATCH 4/5] Address review --- .../qgsnumericformatselectorwidget.sip.in | 4 +++- .../qgsnumericformatselectorwidget.sip.in | 4 +++- .../qgslinearreferencingsymbollayer.cpp | 20 +++++++++---------- .../qgslinearreferencingsymbollayer.h | 5 +++-- .../qgsnumericformatselectorwidget.h | 5 ++++- 5 files changed, 22 insertions(+), 16 deletions(-) diff --git a/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in b/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in index d7dad870c07c..98bddbe3cccb 100644 --- a/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in +++ b/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in @@ -44,9 +44,11 @@ The caller takes ownership of the returned object. void registerExpressionContextGenerator( QgsExpressionContextGenerator *generator ); %Docstring -Register an expression context generator class that will be used to retrieve +Register an expression context ``generator`` class that will be used to retrieve an expression context for the widget when required. +Ownership is not transferred, and the ``generator`` must exist for the lifetime of this widget. + .. versionadded:: 3.40 %End diff --git a/python/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in b/python/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in index d7dad870c07c..98bddbe3cccb 100644 --- a/python/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in +++ b/python/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in @@ -44,9 +44,11 @@ The caller takes ownership of the returned object. void registerExpressionContextGenerator( QgsExpressionContextGenerator *generator ); %Docstring -Register an expression context generator class that will be used to retrieve +Register an expression context ``generator`` class that will be used to retrieve an expression context for the widget when required. +Ownership is not transferred, and the ``generator`` must exist for the lifetime of this widget. + .. versionadded:: 3.40 %End diff --git a/src/core/symbology/qgslinearreferencingsymbollayer.cpp b/src/core/symbology/qgslinearreferencingsymbollayer.cpp index 01cc1c49fa5f..6d68572bc4a9 100644 --- a/src/core/symbology/qgslinearreferencingsymbollayer.cpp +++ b/src/core/symbology/qgslinearreferencingsymbollayer.cpp @@ -1,5 +1,5 @@ /*************************************************************************** - qgslinearreferencingsymbollayer.h + qgslinearreferencingsymbollayer.cpp --------------------- begin : August 2024 copyright : (C) 2024 by Nyall Dawson @@ -286,6 +286,13 @@ void QgsLinearReferencingSymbolLayer::renderPolyline( const QPolygonF &points, Q return; } + // TODO (maybe?): if we don't have an original geometry, convert points to linestring and scale distance to painter units? + // in reality this line type makes no sense for rendering non-real feature geometries... + ( void )points; + const QgsAbstractGeometry *geometry = context.renderContext().geometry(); + if ( !geometry ) + return; + double skipMultiples = mSkipMultiplesOf; if ( mDataDefinedProperties.isActive( QgsSymbolLayer::Property::SkipMultiples ) ) { @@ -314,13 +321,6 @@ void QgsLinearReferencingSymbolLayer::renderPolyline( const QPolygonF &points, Q const double labelOffsetPainterUnitsY = context.renderContext().convertToPainterUnits( labelOffsetY, mLabelOffsetUnit, mLabelOffsetMapUnitScale ); const double averageAngleDistancePainterUnits = context.renderContext().convertToPainterUnits( averageOver, mAverageAngleLengthUnit, mAverageAngleLengthMapUnitScale ) / 2; - // TODO (maybe?): if we don't have an original geometry, convert points to linestring and scale distance to painter units? - // in reality this line type makes no sense for rendering non-real feature geometries... - ( void )points; - const QgsAbstractGeometry *geometry = context.renderContext().geometry(); - if ( !geometry ) - return; - for ( auto partIt = geometry->const_parts_begin(); partIt != geometry->const_parts_end(); ++partIt ) { renderGeometryPart( context, *partIt, labelOffsetPainterUnitsX, labelOffsetPainterUnitsY, skipMultiples, averageAngleDistancePainterUnits, showMarker ); @@ -803,8 +803,6 @@ void QgsLinearReferencingSymbolLayer::renderPolylineVertex( const QgsLineString const QPointF pt = pointToPainter( context, thisX, thisY, thisZ ); - double calculatedAngle = 0; - // track forward by averageAngleLengthPainterUnits double painterDistRemaining = averageAngleLengthPainterUnits; double startAverageSegmentX = thisXPainterUnits; @@ -882,7 +880,7 @@ void QgsLinearReferencingSymbolLayer::renderPolylineVertex( const QgsLineString startAverageYPainterUnits = endAverageSegmentY; } - calculatedAngle = std::fmod( QgsGeometryUtilsBase::azimuth( startAverageXPainterUnits, startAverageYPainterUnits, endAverageXPainterUnits, endAverageYPainterUnits ) + 360, 360 ); + double calculatedAngle = std::fmod( QgsGeometryUtilsBase::azimuth( startAverageXPainterUnits, startAverageYPainterUnits, endAverageXPainterUnits, endAverageYPainterUnits ) + 360, 360 ); if ( calculatedAngle > 90 && calculatedAngle < 270 ) calculatedAngle += 180; diff --git a/src/core/symbology/qgslinearreferencingsymbollayer.h b/src/core/symbology/qgslinearreferencingsymbollayer.h index 20baac175dd2..95f01c07917f 100644 --- a/src/core/symbology/qgslinearreferencingsymbollayer.h +++ b/src/core/symbology/qgslinearreferencingsymbollayer.h @@ -304,6 +304,9 @@ class CORE_EXPORT QgsLinearReferencingSymbolLayer : public QgsLineSymbolLayer private: void renderPolylineInterval( const QgsLineString *line, QgsSymbolRenderContext &context, double skipMultiples, const QPointF &labelOffsetPainterUnits, double averageAngleLengthPainterUnits, bool showMarker ); void renderPolylineVertex( const QgsLineString *line, QgsSymbolRenderContext &context, double skipMultiples, const QPointF &labelOffsetPainterUnits, double averageAngleLengthPainterUnits, bool showMarker ); + void renderGeometryPart( QgsSymbolRenderContext &context, const QgsAbstractGeometry *geometry, double labelOffsetPainterUnitsX, double labelOffsetPainterUnitsY, double skipMultiples, double averageAngleDistancePainterUnits, bool showMarker ); + void renderLineString( QgsSymbolRenderContext &context, const QgsLineString *line, double labelOffsetPainterUnitsX, double labelOffsetPainterUnitsY, double skipMultiples, double averageAngleDistancePainterUnits, bool showMarker ); + static QPointF pointToPainter( QgsSymbolRenderContext &context, double x, double y, double z ); Qgis::LinearReferencingPlacement mPlacement = Qgis::LinearReferencingPlacement::IntervalCartesian2D; @@ -327,8 +330,6 @@ class CORE_EXPORT QgsLinearReferencingSymbolLayer : public QgsLineSymbolLayer Qgis::RenderUnit mAverageAngleLengthUnit = Qgis::RenderUnit::Millimeters; QgsMapUnitScale mAverageAngleLengthMapUnitScale; - void renderGeometryPart( QgsSymbolRenderContext &context, const QgsAbstractGeometry *geometry, double labelOffsetPainterUnitsX, double labelOffsetPainterUnitsY, double skipMultiples, double averageAngleDistancePainterUnits, bool showMarker ); - void renderLineString( QgsSymbolRenderContext &context, const QgsLineString *line, double labelOffsetPainterUnitsX, double labelOffsetPainterUnitsY, double skipMultiples, double averageAngleDistancePainterUnits, bool showMarker ); }; #endif // QGSLINEARREFERENCINGSYMBOLLAYER_H diff --git a/src/gui/numericformats/qgsnumericformatselectorwidget.h b/src/gui/numericformats/qgsnumericformatselectorwidget.h index 7bdddef0d747..f95ef33e04dc 100644 --- a/src/gui/numericformats/qgsnumericformatselectorwidget.h +++ b/src/gui/numericformats/qgsnumericformatselectorwidget.h @@ -58,8 +58,11 @@ class GUI_EXPORT QgsNumericFormatSelectorWidget : public QgsPanelWidget, private QgsNumericFormat *format() const SIP_TRANSFERBACK; /** - * Register an expression context generator class that will be used to retrieve + * Register an expression context \a generator class that will be used to retrieve * an expression context for the widget when required. + * + * Ownership is not transferred, and the \a generator must exist for the lifetime of this widget. + * * \since QGIS 3.40 */ void registerExpressionContextGenerator( QgsExpressionContextGenerator *generator ); From f7930bd33f97ac01e35f00a5e71b89542015b46e Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Wed, 4 Sep 2024 15:19:20 +1000 Subject: [PATCH 5/5] Fix numeric format configuration in dialog mode --- .../qgsnumericformatselectorwidget.py | 4 ++ .../qgsnumericformatselectorwidget.sip.in | 47 +++++++++++++++- .../qgsnumericformatselectorwidget.py | 4 ++ .../qgsnumericformatselectorwidget.sip.in | 47 +++++++++++++++- .../qgsnumericformatselectorwidget.cpp | 44 ++++++++++++++- .../qgsnumericformatselectorwidget.h | 53 ++++++++++++++++++- src/gui/symbology/qgssymbollayerwidget.cpp | 9 +++- 7 files changed, 202 insertions(+), 6 deletions(-) diff --git a/python/PyQt6/gui/auto_additions/qgsnumericformatselectorwidget.py b/python/PyQt6/gui/auto_additions/qgsnumericformatselectorwidget.py index 91e97506a762..c0df5a700d28 100644 --- a/python/PyQt6/gui/auto_additions/qgsnumericformatselectorwidget.py +++ b/python/PyQt6/gui/auto_additions/qgsnumericformatselectorwidget.py @@ -7,3 +7,7 @@ QgsNumericFormatSelectorWidget.__group__ = ['numericformats'] except NameError: pass +try: + QgsNumericFormatSelectorDialog.__group__ = ['numericformats'] +except NameError: + pass diff --git a/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in b/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in index 98bddbe3cccb..79ad3c01f125 100644 --- a/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in +++ b/python/PyQt6/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in @@ -9,7 +9,6 @@ - class QgsNumericFormatSelectorWidget : QgsPanelWidget { %Docstring(signature="appended") @@ -61,6 +60,52 @@ Emitted whenever the format configured55 in the widget is changed. }; + +class QgsNumericFormatSelectorDialog : QDialog +{ +%Docstring(signature="appended") +A simple dialog for customizing a numeric format. + +.. seealso:: :py:class:`QgsNumericFormatSelectorWidget` + +.. versionadded:: 3.40 +%End + +%TypeHeaderCode +#include "qgsnumericformatselectorwidget.h" +%End + public: + + QgsNumericFormatSelectorDialog( QWidget *parent /TransferThis/ = 0, Qt::WindowFlags flags = QgsGuiUtils::ModalDialogFlags ); +%Docstring +Constructor for QgsNumericFormatSelectorDialog. + +:param parent: parent widget +:param flags: window flags for dialog +%End + + void setFormat( const QgsNumericFormat *format ); +%Docstring +Sets the format to show in the dialog. +%End + + QgsNumericFormat *format() const /TransferBack/; +%Docstring +Returns a new format object representing the settings currently configured in the dialog. + +The caller takes ownership of the returned object. +%End + + void registerExpressionContextGenerator( QgsExpressionContextGenerator *generator ); +%Docstring +Register an expression context ``generator`` class that will be used to retrieve +an expression context for the dialog when required. + +Ownership is not transferred, and the ``generator`` must exist for the lifetime of this dialog. +%End + +}; + /************************************************************************ * This file has been generated automatically from * * * diff --git a/python/gui/auto_additions/qgsnumericformatselectorwidget.py b/python/gui/auto_additions/qgsnumericformatselectorwidget.py index 91e97506a762..c0df5a700d28 100644 --- a/python/gui/auto_additions/qgsnumericformatselectorwidget.py +++ b/python/gui/auto_additions/qgsnumericformatselectorwidget.py @@ -7,3 +7,7 @@ QgsNumericFormatSelectorWidget.__group__ = ['numericformats'] except NameError: pass +try: + QgsNumericFormatSelectorDialog.__group__ = ['numericformats'] +except NameError: + pass diff --git a/python/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in b/python/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in index 98bddbe3cccb..79ad3c01f125 100644 --- a/python/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in +++ b/python/gui/auto_generated/numericformats/qgsnumericformatselectorwidget.sip.in @@ -9,7 +9,6 @@ - class QgsNumericFormatSelectorWidget : QgsPanelWidget { %Docstring(signature="appended") @@ -61,6 +60,52 @@ Emitted whenever the format configured55 in the widget is changed. }; + +class QgsNumericFormatSelectorDialog : QDialog +{ +%Docstring(signature="appended") +A simple dialog for customizing a numeric format. + +.. seealso:: :py:class:`QgsNumericFormatSelectorWidget` + +.. versionadded:: 3.40 +%End + +%TypeHeaderCode +#include "qgsnumericformatselectorwidget.h" +%End + public: + + QgsNumericFormatSelectorDialog( QWidget *parent /TransferThis/ = 0, Qt::WindowFlags flags = QgsGuiUtils::ModalDialogFlags ); +%Docstring +Constructor for QgsNumericFormatSelectorDialog. + +:param parent: parent widget +:param flags: window flags for dialog +%End + + void setFormat( const QgsNumericFormat *format ); +%Docstring +Sets the format to show in the dialog. +%End + + QgsNumericFormat *format() const /TransferBack/; +%Docstring +Returns a new format object representing the settings currently configured in the dialog. + +The caller takes ownership of the returned object. +%End + + void registerExpressionContextGenerator( QgsExpressionContextGenerator *generator ); +%Docstring +Register an expression context ``generator`` class that will be used to retrieve +an expression context for the dialog when required. + +Ownership is not transferred, and the ``generator`` must exist for the lifetime of this dialog. +%End + +}; + /************************************************************************ * This file has been generated automatically from * * * diff --git a/src/gui/numericformats/qgsnumericformatselectorwidget.cpp b/src/gui/numericformats/qgsnumericformatselectorwidget.cpp index 3331dd243458..1cd20bf5e1e0 100644 --- a/src/gui/numericformats/qgsnumericformatselectorwidget.cpp +++ b/src/gui/numericformats/qgsnumericformatselectorwidget.cpp @@ -23,8 +23,8 @@ #include "qgsnumericformatguiregistry.h" #include "qgsreadwritecontext.h" #include "qgsbasicnumericformat.h" -#include - +#include +#include QgsNumericFormatSelectorWidget::QgsNumericFormatSelectorWidget( QWidget *parent ) : QgsPanelWidget( parent ) @@ -166,3 +166,43 @@ void QgsNumericFormatSelectorWidget::updateSampleText() .arg( QChar( 0x2192 ) ) .arg( mCurrentFormat->formatDouble( sampleValue, QgsNumericFormatContext() ) ) ); } + +// +// QgsNumericFormatSelectorDialog +// + +QgsNumericFormatSelectorDialog::QgsNumericFormatSelectorDialog( QWidget *parent, Qt::WindowFlags fl ) + : QDialog( parent, fl ) +{ + setWindowTitle( tr( "Numeric Format" ) ); + + mFormatWidget = new QgsNumericFormatSelectorWidget( this ); + mFormatWidget->layout()->setContentsMargins( 0, 0, 0, 0 ); + + QVBoxLayout *layout = new QVBoxLayout( this ); + layout->addWidget( mFormatWidget ); + + mButtonBox = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel | QDialogButtonBox::Help, Qt::Horizontal, this ); + layout->addWidget( mButtonBox ); + + setLayout( layout ); + QgsGui::enableAutoGeometryRestore( this ); + + connect( mButtonBox->button( QDialogButtonBox::Ok ), &QAbstractButton::clicked, this, &QDialog::accept ); + connect( mButtonBox->button( QDialogButtonBox::Cancel ), &QAbstractButton::clicked, this, &QDialog::reject ); +} + +void QgsNumericFormatSelectorDialog::setFormat( const QgsNumericFormat *format ) +{ + mFormatWidget->setFormat( format ); +} + +QgsNumericFormat *QgsNumericFormatSelectorDialog::format() const +{ + return mFormatWidget->format(); +} + +void QgsNumericFormatSelectorDialog::registerExpressionContextGenerator( QgsExpressionContextGenerator *generator ) +{ + mFormatWidget->registerExpressionContextGenerator( generator ); +} diff --git a/src/gui/numericformats/qgsnumericformatselectorwidget.h b/src/gui/numericformats/qgsnumericformatselectorwidget.h index f95ef33e04dc..8ca4ce7d3692 100644 --- a/src/gui/numericformats/qgsnumericformatselectorwidget.h +++ b/src/gui/numericformats/qgsnumericformatselectorwidget.h @@ -18,13 +18,15 @@ #include "qgis_gui.h" #include "qgis_sip.h" +#include "qgsguiutils.h" #include "ui_qgsnumericformatselectorbase.h" #include +#include class QgsNumericFormat; class QgsBasicNumericFormat; class QgsExpressionContextGenerator; - +class QDialogButtonBox; /** * \ingroup gui @@ -90,4 +92,53 @@ class GUI_EXPORT QgsNumericFormatSelectorWidget : public QgsPanelWidget, private QgsExpressionContextGenerator *mExpressionContextGenerator = nullptr; }; + +/** + * \class QgsNumericFormatSelectorDialog + * \ingroup gui + * \brief A simple dialog for customizing a numeric format. + * + * \see QgsNumericFormatSelectorWidget() + * \since QGIS 3.40 + */ +class GUI_EXPORT QgsNumericFormatSelectorDialog : public QDialog +{ + Q_OBJECT + + public: + + /** + * Constructor for QgsNumericFormatSelectorDialog. + * \param parent parent widget + * \param flags window flags for dialog + */ + QgsNumericFormatSelectorDialog( QWidget *parent SIP_TRANSFERTHIS = nullptr, Qt::WindowFlags flags = QgsGuiUtils::ModalDialogFlags ); + + /** + * Sets the format to show in the dialog. + */ + void setFormat( const QgsNumericFormat *format ); + + /** + * Returns a new format object representing the settings currently configured in the dialog. + * + * The caller takes ownership of the returned object. + */ + QgsNumericFormat *format() const SIP_TRANSFERBACK; + + /** + * Register an expression context \a generator class that will be used to retrieve + * an expression context for the dialog when required. + * + * Ownership is not transferred, and the \a generator must exist for the lifetime of this dialog. + */ + void registerExpressionContextGenerator( QgsExpressionContextGenerator *generator ); + + private: + + QgsNumericFormatSelectorWidget *mFormatWidget = nullptr; + QDialogButtonBox *mButtonBox = nullptr; + +}; + #endif //QGSNUMERICFORMATSELECTORWIDGET_H diff --git a/src/gui/symbology/qgssymbollayerwidget.cpp b/src/gui/symbology/qgssymbollayerwidget.cpp index 652d5f28e09a..42671bb8883a 100644 --- a/src/gui/symbology/qgssymbollayerwidget.cpp +++ b/src/gui/symbology/qgssymbollayerwidget.cpp @@ -5697,6 +5697,13 @@ void QgsLinearReferencingSymbolLayerWidget::changeNumberFormat() } else { - // TODO!! dialog mode + QgsNumericFormatSelectorDialog dialog( this ); + dialog.setFormat( mLayer->numericFormat() ); + dialog.registerExpressionContextGenerator( this ); + if ( dialog.exec() ) + { + mLayer->setNumericFormat( dialog.format() ); + emit changed(); + } } }