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
38 changes: 38 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Bug report
description: The plugin raised an error or produced a wrong result
labels: [bug]
body:
- type: markdown
attributes:
value: |
In QGIS, **Vector › Parallelizer › Report a problem…** (or the **Report a problem…** button shown after an error) fills in the diagnostics field below for you.
Issues are public: remove anything you don't want to share.

**Wrong result without an error?** The report describes the last few actions of the interactive map tool in detail, but a Processing run only in summary. So please reproduce the problem on one of the affected features with the map tool (click the reference, then the feature), then open **Report a problem…** right away. If you can share the shapes, tick **Include the geometries involved**: they are moved to a local origin, so their real location is not included.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: What did you do, what did you expect, and what happened instead?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1. Open a line layer and a polygon layer
2. ...
validations:
required: true
- type: textarea
id: diagnostics
attributes:
label: Diagnostics report
description: From Vector › Parallelizer › Report a problem… in QGIS.
render: text
- type: textarea
id: data
attributes:
label: Sample data or screenshots
description: Optional. A small layer that reproduces the problem helps a lot. Only attach data you are allowed to share publicly.
5 changes: 5 additions & 0 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Questions and ideas
url: https://github.com/elfpkck/parallelizer/discussions
about: Ask how to use the plugin or discuss ideas.
27 changes: 26 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ jobs:
env:
QGIS_PLUGIN_TOKEN: ${{ secrets.QGIS_PLUGIN_TOKEN }}
run: ': "${QGIS_PLUGIN_TOKEN:?QGIS_PLUGIN_TOKEN secret is not set}"'
- name: Check the report email and endpoint are set
env:
REPORT_EMAIL: ${{ secrets.REPORT_EMAIL }}
REPORT_ENDPOINT: ${{ secrets.REPORT_ENDPOINT }}
run: |
: "${REPORT_EMAIL:?REPORT_EMAIL secret is not set}"
: "${REPORT_ENDPOINT:?REPORT_ENDPOINT secret is not set}"
- uses: actions/checkout@v7.0.1
with: {fetch-depth: 0}
- uses: actions/setup-python@v7
Expand All @@ -51,14 +58,32 @@ jobs:
gh release create "${GITHUB_REF_NAME}" --title "${GITHUB_REF_NAME}" --generate-notes
fi
echo "created=true" >> "${GITHUB_OUTPUT}"
# Kept out of the repository and written into the placeholder only for the package.
- name: Fill in the report contacts
env:
REPORT_EMAIL: ${{ secrets.REPORT_EMAIL }}
REPORT_ENDPOINT: ${{ secrets.REPORT_ENDPOINT }}
run: |
python - <<'PY'
import os
import pathlib

path = pathlib.Path("PolygonsParallelToLine/src/contact.py")
path.write_text(
"".join(f"{name} = {os.environ[name]!r}\n" for name in ("REPORT_EMAIL", "REPORT_ENDPOINT")),
encoding="utf-8",
)
PY
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
QGIS_PLUGIN_TOKEN: ${{ secrets.QGIS_PLUGIN_TOKEN }}
# --allow-uncommitted-changes: package the filled-in contact.py (qgis-plugin-ci builds the zip from git).
run: |
qgis-plugin-ci release "${GITHUB_REF_NAME}" \
--github-token "${GITHUB_TOKEN}" \
--qgis-token "${QGIS_PLUGIN_TOKEN}"
--qgis-token "${QGIS_PLUGIN_TOKEN}" \
--allow-uncommitted-changes
- name: Delete orphan release on failure
if: failure() && steps.create_release.outputs.created == 'true'
env:
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ jobs:
- uses: pre-commit/action@v3.0.1
with:
extra_args: --color=always --from-ref ${{ github.event.pull_request.base.sha }} --to-ref ${{ github.event.pull_request.head.sha }}
worker-tests:
name: Report Worker Tests
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.draft == false }}
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-node@v7
with:
node-version: 22
- run: node --test
working-directory: tools/report_worker
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]
- Add **Vector › Parallelizer › Report a problem…**: a diagnostics report (versions, settings, last error, recent plugin log, and a description of the layers, project and features the failed or last operation worked on, with the rotation math for the last five operations; geometries only on request, moved to a local origin, with large references clipped around each target; Python stacks from a crash or a freeze in the previous session) you can review and edit, then copy, open as a pre-filled GitHub issue, open as a pre-filled email to the developer in your own mail app, or send with one click (**Send report**, no account needed) to the developer's private report service. See the privacy note on the docs site. Nothing is sent automatically. After an unexpected error, a message offers to open it. The plugin now also logs to the Log Messages panel (*Parallelizer* tab) and a small log file in the QGIS profile folder

## [2.1.3] - 2026-09-27
- Rename the plugin on the QGIS plugin portal to "Parallelizer: Align Polygons and Lines" and rewrite its description, about text and tags so it is easier to find
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ Invariants:

Alongside the Processing algorithm, `Plugin.initGui` registers a dedicated "Parallelizer" `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 a click or drag-rectangle on a line or polygon sets the reference (`canvasReleaseEvent`; 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`. 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 `TargetRotator` 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.

## Diagnostics

`src/diagnostics.py` owns the plugin log (`logging.getLogger("parallelizer")`, set up in `Plugin.initProcessing`, torn down in `unload`): a handler to `QgsMessageLog` (tag `Parallelizer`) and a rotating file `<QGIS profile>/parallelizer/parallelizer.log`. `@capture_errors` wraps the map tool's Qt event handlers and `Algorithm.processAlgorithm`: it records unexpected exceptions (not `UserInputError`) and re-raises, and `diagnostics.notifier` makes `Plugin` offer "Report a problem…" in the message bar (queued, rate-limited). `ReportDialog` (`src/report_dialog.py`) shows `build_report()` for the user to edit, then copies it and opens a pre-filled GitHub issue (`issue_url`; `.github/ISSUE_TEMPLATE/bug_report.yml`, field `diagnostics`) or a `mailto:` link to `contact.REPORT_EMAIL` (`mailto_url`; empty in the repo and filled in from the `REPORT_EMAIL` secret by `release.yaml` only for the package, so never commit an address there; empty hides the email option; a much shorter length limit for mail clients); both go through `fit_report`, which drops the oldest log lines first. **Send report** (`src/report_sender.py`, `ReportSender`) POSTs a JSON payload (schema 1, the encoded body capped at `MAX_PAYLOAD_BYTES`) through `QgsNetworkAccessManager` to `contact.REPORT_ENDPOINT` (filled from the `REPORT_ENDPOINT` secret like the email; empty hides the button). The receiver is `tools/report_worker/` (Cloudflare Worker, `node --test`), which files a private GitHub issue and must never store or log IPs or report content; the privacy promises it has to keep are in `site/privacy.md`. Nothing is ever sent automatically; keep it that way (GDPR/ePrivacy). Log calls must never include layer names, file paths, attribute values or coordinates; counts, geometry types, CRS authids, flags and versions are fine. `scrub()` is a best-effort second line of defense, not a license to log sensitive data. Raise `diagnostics.UserInputError` (a `QgsProcessingException`) for problems with the user's input so they are not recorded as bugs; a plain `QgsProcessingException` is recorded. Exception messages must not embed coordinates either. Each map tool release and each Processing run is a `diagnostics.operation(...)`; code inside adds sections via `current_operation()` using the describers in `src/snapshot.py` (fail-safe, no names/paths/coordinates). `compute_parallel_geometry`/`TargetRotator` take an optional `trace` dict for the rotation math; Processing passes none per feature and only re-runs the failing feature with a trace (`ParallelToReference.note_failed_feature`), keeping the hot loop free. The last `MAX_KEPT_OPERATIONS` non-idle operations are kept, plus the failed one. `setup_logging` also arms `faulthandler` into `crash.log` unless someone else owns it; QGIS usually does, writing to `<temp>/qgis-python-crash-info-<pid>` for its crash dialog, and the next session reads the newest unseen such file instead and map tool operations pass `watchdog=True` to dump stacks into `hang.log` after `HANG_TIMEOUT_S`; the next session moves both to `*-previous.log`, reports them and offers "Report a problem…" when they contain plugin frames. Geometries reach the report only through `geometry_block()`, converted to one CRS and shifted to a local origin, behind an opt-in checkbox in `ReportDialog`.

## Testing

`tests/test_main_functionality.py` is the integration suite — builds in-memory `QgsVectorLayer`s from WKT, runs the pipeline via `processing.run(algOrName=Algorithm(), ...)`, compares output WKT. Extend the existing `@pytest.mark.parametrize` table for new cases rather than adding files. Other `tests/test_*.py` files are module unit tests and don't need a full processing run.
Expand Down
2 changes: 2 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ These instructions are specific to PyCharm.

Required GitHub Secrets (one-time setup, repo Settings → Secrets and variables → Actions):
- `QGIS_PLUGIN_TOKEN`: an upload token created on the plugin's page on plugins.qgis.org (Tokens)
- `REPORT_EMAIL`: the address the report dialog's **Send by email…** button writes to. The workflow writes it into `PolygonsParallelToLine/src/contact.py` only for the package, so it never appears in the repository. It is still readable inside the published zip, so prefer a forwarding alias you can replace (e.g. iCloud+ Hide My Email). Local and CI builds leave it empty, which hides the email option.
- `REPORT_ENDPOINT`: the URL of the report receiver behind **Send report** (a Cloudflare Worker, set up as described in [`tools/report_worker/README.md`](tools/report_worker/README.md)). Written into `contact.py` the same way; empty hides the button.

Uploading with a token syncs the portal's `about` text from `metadata.txt` on every release. It does not sync `name` unless a portal admin enables "Allow update name" for the plugin.

Expand Down
23 changes: 22 additions & 1 deletion PolygonsParallelToLine/src/algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
SOURCE_TYPE_LINE,
SOURCE_TYPE_POLYGON,
)
from .diagnostics import capture_errors, current_operation, operation
from .pptl import Params, ParallelToReference
from .snapshot import describe_processing, describe_project, describe_source

if TYPE_CHECKING:
from qgis.core import (
Expand Down Expand Up @@ -110,10 +112,24 @@ def _create_output_fields(self, source_layer: QgsProcessingFeatureSource) -> Qgs
fields.append(QgsField(COLUMN_NAME, BOOL_FIELD_TYPE))
return fields

@capture_errors
def processAlgorithm( # noqa: N802
self, parameters: dict[str, Any], context: QgsProcessingContext, feedback: QgsProcessingFeedback
) -> dict[str, str]:
with operation("processing run") as op:
op.add("Processing", describe_processing(parameters, context))
op.add("Project", describe_project(project=context.project()))
return self._process(parameters, context, feedback)

def _process(
self, parameters: dict[str, Any], context: QgsProcessingContext, feedback: QgsProcessingFeedback
) -> dict[str, str]:
op = current_operation()
target_layer = self.parameterAsSource(parameters, self.TARGET_LAYER, context)
op.add(
"Target layer",
describe_source(target_layer, self.parameterAsVectorLayer(parameters, self.TARGET_LAYER, context)),
)
output_fields = self._create_output_fields(target_layer)
sink, dest_id = self.parameterAsSink(
parameters=parameters,
Expand All @@ -123,8 +139,13 @@ def processAlgorithm( # noqa: N802
geometryType=target_layer.wkbType(),
crs=target_layer.sourceCrs(),
)
reference_layer = self.parameterAsSource(parameters, self.REFERENCE_LAYER, context)
op.add(
"Reference layer",
describe_source(reference_layer, self.parameterAsVectorLayer(parameters, self.REFERENCE_LAYER, context)),
)
params = Params(
reference_layer=self.parameterAsSource(parameters, self.REFERENCE_LAYER, context),
reference_layer=reference_layer,
target_layer=target_layer,
by_longest=self.parameterAsBool(parameters, self.LONGEST, context),
no_multi=self.parameterAsBool(parameters, self.NO_MULTI, context),
Expand Down
3 changes: 3 additions & 0 deletions PolygonsParallelToLine/src/const.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from pathlib import Path

from qgis.core import Qgis, QgsProcessing, QgsProcessingParameterNumber, QgsWkbTypes

COLUMN_NAME = "_rotated"
OUTPUT_LAYER_NAME = "Output layer with rotated features"
PLUGIN_DIR = Path(__file__).resolve().parent.parent

# Qgis.* scoped enums were added in QGIS 3.30/3.36; older releases fall back to the legacy enums, spelled
# with their enum class so the plugin portal's Qt6 check (a static scan) accepts them.
Expand Down
5 changes: 5 additions & 0 deletions PolygonsParallelToLine/src/contact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Filled in by the release workflow from the REPORT_EMAIL and REPORT_ENDPOINT secrets, so they stay out of the
# repository. Empty in the repository: the report dialog then hides the email and one-click send options.
REPORT_EMAIL = ""
# URL of the report receiver (tools/report_worker), which files each report as a private GitHub issue.
REPORT_ENDPOINT = ""
Loading
Loading