Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]
- Mark the plugin as QGIS 4 compatible (`qgisMaximumVersion=4.99`) and fully scope PyQt6-required Qt enums in the map tool and settings dialog so they work on QGIS 4 / Qt 6
- Accept polygon layers as the rotation reference in both the Processing algorithm and the interactive map tool; polygon boundary rings are treated as polylines for closest-segment math. **Breaking:** the Processing parameter `LINE_LAYER` has been renamed to `REFERENCE_LAYER` (saved models referencing the old key must be updated). The map tool prefers a line under the click when both a line and polygon overlap.

## [2.0.0] - 2026-06-06
- Add an interactive map tool: pick a reference line on the canvas, then click — or drag a rectangle — to rotate individual line/polygon features (or every feature in the rectangle across editable visible layers) parallel to it
Expand Down
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ QGIS discovery chain:
PolygonsParallelToLine/__init__.py::classFactory → Plugin.initProcessing → Provider.loadAlgorithms → Algorithm
```

`Algorithm` (`src/algorithm.py`) declares the processing UI (parameter IDs `LINE_LAYER`, `POLYGON_LAYER`, `LONGEST`, `NO_MULTI`, `DISTANCE`, `ANGLE`) and delegates to `PolygonsParallelToLine` in `src/pptl.py` via a `Params` dataclass (fields `by_longest`, `no_multi`, `distance`, `angle`).
`Algorithm` (`src/algorithm.py`) declares the processing UI (parameter IDs `REFERENCE_LAYER` — accepts line or polygon layers, `POLYGON_LAYER`, `LONGEST`, `NO_MULTI`, `DISTANCE`, `ANGLE`) and delegates to `PolygonsParallelToLine` in `src/pptl.py` via a `Params` dataclass (fields `by_longest`, `no_multi`, `distance`, `angle`).

`PolygonsParallelToLine.process_polygon` per-feature pipeline:

1. Wrap feature in `Polygon` (`src/polygon.py`) — `geom`, `is_multi`, `center_xy`, `is_rotated`.
2. Wrap line source in `LineLayer` (`src/line.py`) — `QgsSpatialIndex` (with `FlagStoreFeatureGeometries`) plus an `id → QgsFeature` dict for `get_closest_line(point)`.
3. Short-circuit (no rotation) if `params.distance` is truthy and centroid→line distance exceeds it, or if `is_multi and no_multi`.
4. Hand off to `PolygonRotator` (`src/rotator.py`): finds the polygon vertex closest to the line, derives the two adjacent polygon segments, computes delta-azimuths against the closest line segment using helpers in `src/azimuth.py`.
2. Wrap reference source in `ReferenceLayer` (`src/reference.py`) — `QgsSpatialIndex` (with `FlagStoreFeatureGeometries`) plus an `id → QgsFeature` dict for `get_closest_feature(point)`. Polygon references are handled transparently: `closestSegmentWithContext` walks all rings (exterior + interior, all parts).
3. Short-circuit (no rotation) if `params.distance` is truthy and centroid→reference distance exceeds it, or if `is_multi and no_multi`.
4. Hand off to `PolygonRotator` (`src/rotator.py`): finds the polygon vertex closest to the reference, derives the two adjacent polygon segments, computes delta-azimuths against the closest reference segment using helpers in `src/azimuth.py`.
5. Rotation strategy: if both adjacent deltas are within `angle_threshold`, rotate by the longest segment (when `by_longest`) or by the smallest angle; if only one is within threshold, rotate by that one. Rotations within `ABSOLUTE_TOLERANCE` of 0 are skipped (avoids spurious `_rotated=True`).
6. Append a feature to the output sink with a `_rotated` boolean attribute (name in `src/const.py::COLUMN_NAME`).

Expand All @@ -45,7 +45,7 @@ Invariants:

## Map tool

Alongside the Processing algorithm, `Plugin.initGui` registers a dedicated "Polygons Parallel to Line" `QToolBar` (objectName `PolygonsParallelToLineToolBar`) with two `QAction`s: the interactive parallelize toggle that activates `ParallelToLineMapTool` (`src/map_tool.py`) on the canvas, and a Settings action that opens `MapToolSettingsDialog` (`src/settings_dialog.py`) to edit `MapToolSettings` (`src/settings.py`). `MapToolSettings` owns live tool state (`by_longest`, `pick_reference_segment`, `pick_target_segment`) and persists each via `QSettings` under the `PolygonsParallelToLine/map_tool/` prefix, emitting `changed` on every write so the toolbar's checkable actions and the dialog's checkboxes stay in sync; the map tool reads these flags at each operation. The tool has two states: first click sets a reference line (highlighted with a `QgsRubberBand`); after that, a single click identifies and rotates one line/polygon target, while a click-and-drag draws a selection rectangle (`QgsRubberBand` polygon) and on release rotates every line/polygon feature whose geometry intersects the rectangle across all editable visible layers — wrapped per-layer in `beginEditCommand`/`endEditCommand`. Drag-rectangle is gated on the reference being set. Single clicks use `QgsMapToolIdentify.TopDownAll` so the tool walks through identify results top-down and picks the first one whose geometry type matches the current state's needs (line when no reference is set; line or polygon when one is). All cross-CRS work goes through `QgsCoordinateTransform` against `QgsProject.instance()`: the reference is stored in the map (canvas) CRS — transformed once at set time from the source layer's CRS — and `_reference_for_layer` re-projects it into each target layer's CRS at rotation time, while filter rectangles are transformed via `transformBoundingBox`. All math is delegated to `compute_parallel_geometry` in `src/parallelizer.py`, which reuses `Line`, `Segment`, `calc_delta_azimuth`, and `PolygonRotator` from the batch pipeline — pivot is the target's centroid. Right-click or `Esc` clears the reference (or cancels an in-progress drag). The toolbar additionally exposes two checkable actions (also mirrored as checkboxes in the settings dialog) to pick a single segment of the reference / target. With pick mode on, a single click identifies the segment closest to the click point; a drag-rectangle picks the segment of the topmost matching feature (reference role) or one segment per intersecting feature (target role) via `_pick_segment_in_rect`, which picks the segment with the largest overlap with the rectangle and falls back to the segment closest to the rectangle's center when no segment actually intersects (e.g., a rectangle sitting inside a polygon's interior). When the target-segment flag is on, the chosen `Segment` is passed through as `target_segment=` to `compute_parallel_geometry`, bypassing the usual `_pick_target_segment` selection.
Alongside the Processing algorithm, `Plugin.initGui` registers a dedicated "Polygons Parallel to Line" `QToolBar` (objectName `PolygonsParallelToLineToolBar`) with two `QAction`s: the interactive parallelize toggle that activates `ParallelToLineMapTool` (`src/map_tool.py`) on the canvas, and a Settings action that opens `MapToolSettingsDialog` (`src/settings_dialog.py`) to edit `MapToolSettings` (`src/settings.py`). `MapToolSettings` owns live tool state (`by_longest`, `pick_reference_segment`, `pick_target_segment`) and persists each via `QSettings` under the `PolygonsParallelToLine/map_tool/` prefix, emitting `changed` on every write so the toolbar's checkable actions and the dialog's checkboxes stay in sync; the map tool reads these flags at each operation. The tool has two states: first click sets a reference line (highlighted with a `QgsRubberBand`); after that, a single click identifies and rotates one line/polygon target, while a click-and-drag draws a selection rectangle (`QgsRubberBand` polygon) and on release rotates every line/polygon feature whose geometry intersects the rectangle across all editable visible layers — wrapped per-layer in `beginEditCommand`/`endEditCommand`. Drag-rectangle is gated on the reference being set. Single clicks use `QgsMapToolIdentify.TopDownAll` so the tool walks through identify results top-down and picks the first one whose geometry type matches the current state's needs (line or polygon when no reference is set, with line-first preference — a line under the click wins over a polygon; line or polygon when one is). The drag-rectangle reference path applies the same line-first preference across visible layers. All cross-CRS work goes through `QgsCoordinateTransform` against `QgsProject.instance()`: the reference is stored in the map (canvas) CRS — transformed once at set time from the source layer's CRS — and `_reference_for_layer` re-projects it into each target layer's CRS at rotation time, while filter rectangles are transformed via `transformBoundingBox`. All math is delegated to `compute_parallel_geometry` in `src/parallelizer.py`, which reuses `ReferenceFeature`, `Segment`, `calc_delta_azimuth`, and `PolygonRotator` from the batch pipeline — pivot is the target's centroid. Right-click or `Esc` clears the reference (or cancels an in-progress drag). The toolbar additionally exposes two checkable actions (also mirrored as checkboxes in the settings dialog) to pick a single segment of the reference / target. With pick mode on, a single click identifies the segment closest to the click point; a drag-rectangle picks the segment of the topmost matching feature (reference role) or one segment per intersecting feature (target role) via `_pick_segment_in_rect`, which picks the segment with the largest overlap with the rectangle and falls back to the segment closest to the rectangle's center when no segment actually intersects (e.g., a rectangle sitting inside a polygon's interior). When the target-segment flag is on, the chosen `Segment` is passed through as `target_segment=` to `compute_parallel_geometry`, bypassing the usual `_pick_target_segment` selection.

## Testing

Expand Down
4 changes: 2 additions & 2 deletions PolygonsParallelToLine/metadata.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
name=Polygons Parallel to Line
qgisMinimumVersion=3.22
qgisMaximumVersion=4.99
description=Rotates polygons and lines to be parallel to a reference line, in batch (Processing) or interactively from the map canvas
description=Rotates polygons and lines to be parallel to a reference line or polygon, in batch (Processing) or interactively from the map canvas
version=0.0.0
author=Andrii Liekariev
email=elfpkck@gmail.com
hasProcessingProvider=yes

about=This plugin rotates polygons (and lines) to be parallel to the nearest line segment. Two entry points are provided: a batch Processing algorithm that operates on whole layers, and an interactive map tool that lets you pick a reference line and then click individual line or polygon features to rotate them in place. It is useful when working with streets and buildings. Before using the plugin, we strongly recommend checking your data for geometric errors. A full description can be found at https://elfpkck.github.io/polygons_parallel_to_line/
about=This plugin rotates polygons (and lines) to be parallel to the nearest reference segment; the reference can be a line or polygon layer (polygon boundary rings are treated as polylines). Two entry points are provided: a batch Processing algorithm that operates on whole layers, and an interactive map tool that lets you pick a reference line or polygon feature on the canvas and then click or drag-rectangle — individual line/polygon features to rotate them in place. It is useful when working with streets and buildings. Before using the plugin, we strongly recommend checking your data for geometric errors. A full description can be found at https://elfpkck.github.io/polygons_parallel_to_line/

tracker=https://github.com/Elfpkck/polygons_parallel_to_line/issues
repository=https://github.com/Elfpkck/polygons_parallel_to_line
Expand Down
16 changes: 10 additions & 6 deletions PolygonsParallelToLine/src/algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

class Algorithm(QgsProcessingAlgorithm):
OUTPUT_LAYER = "OUTPUT"
LINE_LAYER = "LINE_LAYER"
REFERENCE_LAYER = "REFERENCE_LAYER"
POLYGON_LAYER = "POLYGON_LAYER"
LONGEST = "LONGEST"
NO_MULTI = "NO_MULTI"
Expand All @@ -41,7 +41,7 @@ def name(self) -> str:
return "pptl_algo"

def displayName(self) -> str: # noqa: N802
return "Polygons parallel to lines"
return "Polygons parallel to a reference layer"

def group(self) -> str:
return "Algorithms for vector layers"
Expand All @@ -50,7 +50,7 @@ def groupId(self) -> str: # noqa: N802
return "pptl_group"

def shortHelpString(self) -> str: # noqa: N802
return "This plugin rotates polygons parallel to the lines"
return "Rotates polygons parallel to features in a reference layer (line or polygon)."

def helpUrl(self) -> str: # noqa: N802
return "https://elfpkck.github.io/polygons_parallel_to_line/"
Expand All @@ -63,7 +63,11 @@ def initAlgorithm(self, config: dict | None = None) -> None: # noqa: N802
)
)
self.addParameter(
QgsProcessingParameterFeatureSource(self.LINE_LAYER, "Line layer", [QgsProcessing.TypeVectorLine])
QgsProcessingParameterFeatureSource(
self.REFERENCE_LAYER,
"Reference layer",
[QgsProcessing.TypeVectorLine, QgsProcessing.TypeVectorPolygon],
)
)
self.addParameter(
QgsProcessingParameterFeatureSource(self.POLYGON_LAYER, "Polygon layer", [QgsProcessing.TypeVectorPolygon])
Expand All @@ -75,7 +79,7 @@ def initAlgorithm(self, config: dict | None = None) -> None: # noqa: N802
self.addParameter(
QgsProcessingParameterNumber(
self.DISTANCE,
"Max distance from line (in units of line layer CRS) (optional)",
"Max distance from reference (in units of reference layer CRS) (optional)",
type=QgsProcessingParameterNumber.Double,
minValue=0.0,
defaultValue=0.0,
Expand Down Expand Up @@ -112,7 +116,7 @@ def processAlgorithm( # noqa: N802
crs=polygon_layer.sourceCrs(),
)
params = Params(
line_layer=self.parameterAsSource(parameters, self.LINE_LAYER, context),
reference_layer=self.parameterAsSource(parameters, self.REFERENCE_LAYER, context),
polygon_layer=polygon_layer,
by_longest=self.parameterAsBool(parameters, self.LONGEST, context),
no_multi=self.parameterAsBool(parameters, self.NO_MULTI, context),
Expand Down
56 changes: 0 additions & 56 deletions PolygonsParallelToLine/src/line.py

This file was deleted.

Loading
Loading