Skip to content

Commit 374ade0

Browse files
committed
Refactor geometry processing, constants handling, and docstrings (#40)
- Extract `COLUMN_NAME` to a dedicated `const.py` for reusability and cleaner code. - Simplify field creation logic in `_create_output_fields` and update related references. - Adjust parameter descriptions for better clarity in the algorithm's UI. - Enhance rotation logic with tolerance checks, and improve test cases to address edge scenarios. - Update docstrings across modules for consistency and precision.
1 parent b4773ed commit 374ade0

File tree

6 files changed

+92
-89
lines changed

6 files changed

+92
-89
lines changed

PolygonsParallelToLine/src/algorithm.py

Lines changed: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
)
1515
from qgis.PyQt.QtCore import QMetaType # type: ignore
1616

17+
from .const import COLUMN_NAME
1718
from .pptl import Params, PolygonsParallelToLine
1819

1920
if TYPE_CHECKING:
@@ -46,8 +47,6 @@ class Algorithm(QgsProcessingAlgorithm):
4647
:type DISTANCE: str
4748
:ivar ANGLE: Parameter ID for specifying the maximum angle for rotation.
4849
:type ANGLE: str
49-
:ivar COLUMN_NAME: Name of the attribute column used to store rotation status for each feature.
50-
:type COLUMN_NAME: str
5150
"""
5251

5352
OUTPUT_LAYER = "OUTPUT"
@@ -57,7 +56,6 @@ class Algorithm(QgsProcessingAlgorithm):
5756
NO_MULTI = "NO_MULTI"
5857
DISTANCE = "DISTANCE"
5958
ANGLE = "ANGLE"
60-
COLUMN_NAME = "_rotated"
6159

6260
def createInstance(self) -> Algorithm: # noqa: N802
6361
return self.__class__()
@@ -88,28 +86,19 @@ def initAlgorithm(self, config: dict | None = None) -> None: # noqa: N802
8886
)
8987
)
9088
self.addParameter(
91-
QgsProcessingParameterFeatureSource(self.LINE_LAYER, "Select line layer", [QgsProcessing.TypeVectorLine])
89+
QgsProcessingParameterFeatureSource(self.LINE_LAYER, "Line layer", [QgsProcessing.TypeVectorLine])
9290
)
9391
self.addParameter(
94-
QgsProcessingParameterFeatureSource(
95-
self.POLYGON_LAYER, "Select polygon layer", [QgsProcessing.TypeVectorPolygon]
96-
)
97-
)
98-
self.addParameter(
99-
QgsProcessingParameterBoolean(
100-
self.LONGEST,
101-
"Rotate by the longest segment if both angles between polygon segments and line segment <="
102-
" 'Angle value'",
103-
defaultValue=False,
104-
)
92+
QgsProcessingParameterFeatureSource(self.POLYGON_LAYER, "Polygon layer", [QgsProcessing.TypeVectorPolygon])
10593
)
10694
self.addParameter(
107-
QgsProcessingParameterBoolean(self.NO_MULTI, "Do not rotate multipolygons", defaultValue=False)
95+
QgsProcessingParameterBoolean(self.LONGEST, "Rotate by the longest segment", defaultValue=False)
10896
)
97+
self.addParameter(QgsProcessingParameterBoolean(self.NO_MULTI, "Skip multipolygons", defaultValue=False))
10998
self.addParameter(
11099
QgsProcessingParameterNumber(
111100
self.DISTANCE,
112-
"Distance from line",
101+
"Max distance from line (in units of line layer CRS) (optional)",
113102
type=QgsProcessingParameterNumber.Double,
114103
minValue=0.0,
115104
defaultValue=0.0,
@@ -118,7 +107,7 @@ def initAlgorithm(self, config: dict | None = None) -> None: # noqa: N802
118107
self.addParameter(
119108
QgsProcessingParameterNumber(
120109
self.ANGLE,
121-
"Angle value",
110+
"Max angle (in degrees) for rotation (optional)",
122111
type=QgsProcessingParameterNumber.Double,
123112
minValue=0.0,
124113
maxValue=89.9,
@@ -128,24 +117,23 @@ def initAlgorithm(self, config: dict | None = None) -> None: # noqa: N802
128117

129118
def _create_output_fields(self, source_layer: QgsProcessingFeatureSource) -> QgsFields:
130119
"""
131-
Creates a new set of output fields for a given source layer by copying all fields from the source layer except
132-
the rotation field if it exists, then adds the rotation field to store rotation status.
120+
Creates output fields by appending a specific field if it does not already exist.
121+
122+
This function retrieves the fields from the input source layer, checks if a field with the specified name
123+
exists, and appends it if missing. The new field has a Boolean type. The resulting set of fields is then
124+
returned.
133125
134-
:param source_layer: The source layer from which fields are copied and processed.
126+
:param source_layer: The input source layer from which fields are retrieved.
135127
:type source_layer: QgsProcessingFeatureSource
136-
:return: A new set of fields copied from the source layer, excluding the column matching the specified column
137-
name, with an additional field added.
128+
:return: The updated fields containing the original fields and the new field, if added.
138129
:rtype: QgsFields
139130
"""
140-
output_fields = QgsFields()
141-
142-
for field in source_layer.fields():
143-
if self.COLUMN_NAME == field.name():
144-
continue
145-
output_fields.append(field)
131+
fields = source_layer.fields()
132+
field_idx = fields.indexFromName(COLUMN_NAME)
133+
if field_idx == -1:
134+
fields.append(QgsField(COLUMN_NAME, QMetaType.Bool))
146135

147-
output_fields.append(QgsField(self.COLUMN_NAME, QMetaType.Int))
148-
return output_fields
136+
return fields
149137

150138
def processAlgorithm( # noqa: N802
151139
self, parameters: dict[str, Any], context: QgsProcessingContext, feedback: QgsProcessingFeedback

PolygonsParallelToLine/src/const.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""
2+
Constants:
3+
COLUMN_NAME: Name of the attribute column used to store rotation status for each feature.
4+
"""
5+
6+
COLUMN_NAME = "_rotated"

PolygonsParallelToLine/src/polygon.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
from typing import TYPE_CHECKING
44

5-
from qgis.core import Qgis, QgsProcessingException
5+
from qgis.core import Qgis, QgsGeometry, QgsProcessingException
66

77
from .line import Segment
88

99
if TYPE_CHECKING:
10-
from qgis.core import QgsFeature, QgsGeometry, QgsPoint, QgsPointXY
10+
from qgis.core import QgsFeature, QgsPoint, QgsPointXY
1111

1212
from .line import Line
1313

@@ -57,22 +57,25 @@ def get_closest_vertex(self, closest_line: Line) -> QgsPoint:
5757

5858
def get_adjacent_segments(self, target_vertex: QgsPoint) -> tuple[Segment, Segment]:
5959
"""
60-
Retrieve the segments adjacent to a specified target vertex within a geometry.
61-
62-
This method iterates through the geometries in a collection and identifies the vertex that matches the given
63-
target. Upon locating the target vertex, it calculates the adjacent vertices and returns two segments, each
64-
defined by the target vertex and one of the adjacent vertices. If the target vertex is not found within
65-
the geometry, an error is raised.
66-
67-
:param target_vertex: The vertex within the geometry for which adjacent segments are to be determined.
68-
:type target_vertex: QgsPoint
69-
:return: A tuple containing two segments:
70-
- The segment between the target vertex and the previous vertex.
71-
- The segment between the target vertex and the next vertex.
60+
Finds and retrieves the two segments adjacent to a given vertex within a geometry.
61+
62+
The method processes the geometry associated with a feature by cleaning it up, removing interior rings as
63+
well as duplicate nodes, and then checking each geometry part for adjacency to the provided target vertex.
64+
It identifies the indices of the vertices adjacent to the target vertex and constructs the corresponding
65+
segments.
66+
67+
:param target_vertex: The vertex for which the adjacent segments need to be determined, represented as a
68+
QgsPoint.
69+
:return: A tuple containing two Segment objects. Each Segment represents a start and end vertex adjacent to
70+
the passed target_vertex.
7271
:rtype: tuple[Segment, Segment]
73-
:raises QgsProcessingException: If the specified target vertex is not found within the geometry of the feature.
72+
:raises QgsProcessingException: If the target_vertex is not found within the geometry of the feature.
7473
"""
75-
for part_geom in self.geom.asGeometryCollection():
74+
temp_geom = QgsGeometry(self.geom)
75+
temp_geom.removeInteriorRings()
76+
temp_geom.removeDuplicateNodes()
77+
78+
for part_geom in temp_geom.asGeometryCollection():
7679
for i, current_vertex in enumerate(part_geom.vertices()):
7780
if current_vertex == target_vertex:
7881
prev_vertex_idx, next_vertex_idx = part_geom.adjacentVertices(i)

PolygonsParallelToLine/src/pptl.py

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
QgsProcessingFeatureSource,
1212
)
1313

14+
from .const import COLUMN_NAME
1415
from .line import LineLayer
1516
from .polygon import Polygon
1617
from .rotator import PolygonRotator
@@ -148,24 +149,19 @@ def process_polygon(self, polygon: QgsFeature) -> QgsFeature:
148149

149150
def create_new_feature(self, poly: Polygon) -> QgsFeature:
150151
"""
151-
Creates a new feature based on the given polygon.
152+
Creates a new feature and sets its geometry and attribute based on the provided polygon.
152153
153-
This method initializes a new feature using predefined fields, sets its geometry based on the input polygon,
154-
and transfers attributes from the polygon to the new feature. If the polygon was rotated, an additional
155-
attribute with a value of 1 is appended to the attributes.
154+
This method creates a new QgsFeature using the provided fields, sets the geometry of the new feature to
155+
match the geometry of the provided polygon, and assigns the attribute value to indicate whether the polygon
156+
is rotated. The created QgsFeature is then returned.
156157
157-
:param poly: The input polygon containing geometry and attributes for creating the new feature.
158+
:param poly: The polygon object whose geometry and rotation status are used to define the new feature's
159+
properties.
158160
:type poly: Polygon
159-
:return: A new feature with geometry derived from the polygon and attributes, including a flag for rotation if
160-
applicable.
161+
:return: The newly created QgsFeature with its geometry and attributes set accordingly.
161162
:rtype: QgsFeature
162163
"""
163164
new_feature = QgsFeature(self.params.fields)
164165
new_feature.setGeometry(poly.geom)
165-
attrs = poly.feature.attributes()
166-
167-
if poly.is_rotated:
168-
attrs.append(1) # Add 1 if the polygon was rotated
169-
170-
new_feature.setAttributes(attrs)
166+
new_feature.setAttribute(COLUMN_NAME, poly.is_rotated)
171167
return new_feature

PolygonsParallelToLine/src/rotator.py

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import math
34
from typing import TYPE_CHECKING
45

56
from qgis.core import QgsPointXY
@@ -13,30 +14,33 @@
1314

1415
class PolygonRotator:
1516
"""
16-
Handles rotation of polygon geometries based on specific geometric relationships and constraints.
17+
Handles the behavior of rotating a polygon based on its segments and azimuth differences.
1718
18-
This class facilitates the rotation of a polygon based on the azimuth angles of its segments relative to a given
19-
reference line. The rotation can be executed according to the smallest angle of adjustment or by prioritizing
20-
the longest segment. Various geometric methods are utilized to determine the azimuths, angle differences, and
21-
relevant polygon segments.
19+
This class provides methods for determining and executing rotational adjustments on a polygon. The rotation logic
20+
accounts for the relationship between a polygon's vertices, the adjacent segments, and the closest external line
21+
segment. Decisions are based on calculated azimuth differences and configurable thresholds. Optional behavior
22+
includes determining the rotation based on either the longest polygon segment or the smallest rotation angle.
2223
23-
:ivar poly: The polygon geometry to be rotated.
24+
:ivar poly: The polygon object to be rotated.
2425
:type poly: Polygon
25-
:ivar angle_threshold: The maximum angle deviation for permissible rotation.
26+
:ivar angle_threshold: The angle threshold used to determine valid rotations.
2627
:type angle_threshold: float
27-
:ivar by_longest: A flag indicating whether the rotation prioritizes the longest segment or smallest angle.
28+
:ivar by_longest: Indicates whether rotation should be based on the longest segment.
2829
:type by_longest: bool
29-
:ivar prev_poly_segment: The polygon segment preceding the closest vertex to the reference line.
30+
:ivar prev_poly_segment: The polygon segment preceding the closest vertex.
3031
:type prev_poly_segment: Segment
31-
:ivar next_poly_segment: The polygon segment following the closest vertex to the reference line.
32+
:ivar next_poly_segment: The polygon segment following the closest vertex.
3233
:type next_poly_segment: Segment
33-
:ivar prev_delta_azimuth: The azimuth difference between the previous polygon segment and the reference
34-
line segment.
34+
:ivar prev_delta_azimuth: The azimuth difference between the line segment and the previous polygon segment.
3535
:type prev_delta_azimuth: float
36-
:ivar next_delta_azimuth: The azimuth difference between the next polygon segment and the reference line segment.
36+
:ivar next_delta_azimuth: The azimuth difference between the line segment and the next polygon segment.
3737
:type next_delta_azimuth: float
38+
:ivar ABSOLUTE_TOLERANCE: Tolerance value for numerical closeness comparison in rotation.
39+
:type ABSOLUTE_TOLERANCE: float
3840
"""
3941

42+
ABSOLUTE_TOLERANCE = 1e-8
43+
4044
def __init__(self, poly: Polygon, closest_line: Line, angle_threshold: float, by_longest: bool):
4145
self.poly = poly
4246
self.angle_threshold = angle_threshold
@@ -67,17 +71,18 @@ def rotate(self) -> None:
6771

6872
def rotate_by_angle(self, angle: float) -> None:
6973
"""
70-
Rotates the polygon by a given angle.
74+
Rotates a polygon by a specified angle if the angle is not close to zero.
7175
72-
This method updates the orientation of the polygon by rotating it around its center by the specified angle.
73-
The rotation is applied in a clockwise direction if the angle is positive and counterclockwise if the angle is
74-
negative. The angle is specified in degrees.
76+
This method checks if the provided angle is approximately zero using a tolerance value. If the angle is not
77+
close to zero, the method applies a rotation transformation to the polygon.
7578
76-
:param angle: The angle by which to rotate the polygon.
79+
:param angle: The angle, in degrees, by which the polygon should be rotated.
7780
:type angle: float
7881
:return: None
7982
"""
80-
self.poly.rotate(angle)
83+
is_close_to_zero = math.isclose(angle, 0.0, abs_tol=self.ABSOLUTE_TOLERANCE)
84+
if not is_close_to_zero:
85+
self.poly.rotate(angle)
8186

8287
def rotate_by_longest_segment(self) -> None:
8388
"""

0 commit comments

Comments
 (0)