├── BezierEditing.png ├── i18n ├── bezierediting_hu.qm ├── bezierediting_ja.qm ├── bezierediting_ja.ts └── bezierediting_hu.ts ├── bezierediting.pro ├── icon ├── unspliticon.svg ├── handle.svg ├── mCrossHair.svg ├── anchor.svg ├── handle_del.svg ├── handle_add.svg ├── undoicon.svg ├── anchor_del.svg ├── anchor_add.svg ├── drawline.svg ├── spliticon.svg ├── showhandleicon.svg ├── freehandicon.svg └── beziericon.svg ├── resources.qrc ├── bezier.py ├── __init__.py ├── LICENSE ├── .gitignore ├── README.md ├── CLAUDE.md ├── metadata.txt ├── fitCurves.py ├── BezierMarker.py ├── bezierediting.py ├── BezierGeometry.py └── beziereditingtool.py /BezierEditing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmizu23/BezierEditing/HEAD/BezierEditing.png -------------------------------------------------------------------------------- /i18n/bezierediting_hu.qm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmizu23/BezierEditing/HEAD/i18n/bezierediting_hu.qm -------------------------------------------------------------------------------- /i18n/bezierediting_ja.qm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmizu23/BezierEditing/HEAD/i18n/bezierediting_ja.qm -------------------------------------------------------------------------------- /bezierediting.pro: -------------------------------------------------------------------------------- 1 | SOURCES = \ 2 | bezier.py \ 3 | bezierediting.py \ 4 | beziereditingtool.py \ 5 | BezierGeometry.py \ 6 | BezierMarker.py \ 7 | fitCurves.py \ 8 | __init__.py 9 | 10 | TRANSLATIONS = \ 11 | i18n/bezierediting_ja.ts \ 12 | i18n/bezierediting_hu.ts \ 13 | 14 | -------------------------------------------------------------------------------- /icon/unspliticon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /icon/handle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /icon/mCrossHair.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | icon/beziericon.svg 4 | icon/freehandicon.svg 5 | icon/spliticon.svg 6 | icon/unspliticon.svg 7 | icon/showhandleicon.svg 8 | icon/undoicon.svg 9 | icon/anchor.svg 10 | icon/anchor_add.svg 11 | icon/anchor_del.svg 12 | icon/handle.svg 13 | icon/handle_add.svg 14 | icon/handle_del.svg 15 | icon/drawline.svg 16 | icon/mCrossHair.svg 17 | 18 | 19 | -------------------------------------------------------------------------------- /icon/anchor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /icon/handle_del.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /icon/handle_add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /bezier.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | from numpy import * 4 | 5 | 6 | # evaluates cubic bezier at t, return point 7 | def q(ctrlPoly, t): 8 | return (1.0 - t) ** 3 * ctrlPoly[0] + 3 * (1.0 - t) ** 2 * t * ctrlPoly[1] + 3 * (1.0 - t) * t ** 2 * ctrlPoly[ 9 | 2] + t ** 3 * ctrlPoly[3] 10 | 11 | 12 | # evaluates cubic bezier first derivative at t, return point 13 | def qprime(ctrlPoly, t): 14 | return 3 * (1.0 - t) ** 2 * (ctrlPoly[1] - ctrlPoly[0]) + 6 * (1.0 - t) * t * ( 15 | ctrlPoly[2] - ctrlPoly[1]) + 3 * t ** 2 * (ctrlPoly[3] - ctrlPoly[2]) 16 | 17 | 18 | # evaluates cubic bezier second derivative at t, return point 19 | def qprimeprime(ctrlPoly, t): 20 | return 6 * (1.0 - t) * (ctrlPoly[2] - 2 * ctrlPoly[1] + ctrlPoly[0]) + 6 * (t) * ( 21 | ctrlPoly[3] - 2 * ctrlPoly[2] + ctrlPoly[1]) 22 | -------------------------------------------------------------------------------- /icon/undoicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /icon/anchor_del.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /icon/anchor_add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """" 3 | /*************************************************************************** 4 | BezierEditing 5 | -------------------------------------- 6 | Date : 01 05 2019 7 | Copyright : (C) 2019 Takayuki Mizutani 8 | Email : mizutani at ecoris dot co dot jp 9 | *************************************************************************** 10 | * * 11 | * This program is free software; you can redistribute it and/or modify * 12 | * it under the terms of the GNU General Public License as published by * 13 | * the Free Software Foundation; either version 2 of the License, or * 14 | * (at your option) any later version. * 15 | * * 16 | ***************************************************************************/ 17 | """ 18 | def classFactory(iface): 19 | from .bezierediting import BezierEditing 20 | return BezierEditing(iface) 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 BezierEditing Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /icon/drawline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | 10 | 12 | 13 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /icon/spliticon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 11 | 15 | 17 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /icon/showhandleicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /icon/freehandicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | 10 | 11 | 13 | 14 | 15 | 17 | 19 | 20 | 21 | 22 | 27 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /icon/beziericon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 11 | 13 | 16 | 19 | 24 | 26 | 27 | 28 | 30 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | BezierEditing plugin - version 1.3.10 2 | =================================== 3 | This is a [QGIS plugin](https://plugins.qgis.org/plugins/BezierEditing/) which edits features with Bezier curves. 4 | 5 | 6 | ![](https://github.com/tmizu23/BezierEditing/wiki/images/BezierEditing.png) 7 | 8 | Install 9 | ------------- 10 | 11 | You can install this plugin from QGIS menu --> plugin --> Manage and Install plugins... --> Bezier Editing 12 | 13 | Documentation 14 | ------------- 15 | 16 | [English Document](https://github.com/tmizu23/BezierEditing/wiki/Document-(English)). 17 | 18 | [日本語のドキュメント](https://github.com/tmizu23/BezierEditing/wiki/%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88%EF%BC%88Japanese%EF%BC%89). 19 | 20 | 21 | Dependent Python libraries and resources 22 | -------------------------------------------- 23 | 24 | * [fitCurves](https://github.com/volkerp/fitCurves) for fitting one or more cubic Bezier curves to a polyline. 25 | * https://github.com/tmizu23/cubic_bezier_curve/blob/master/cubic_bezier_curve.ipynb 26 | 27 | 28 | Change Log 29 | -------------------------------------------- 30 | Version 1.3.10 31 | - fixed an issue where installation failed in Linux environments. 32 | 33 | Version 1.3.9 34 | - added streaming mode for freehand tool (click-move-click drawing without dragging) 35 | - added context menu for freehand tool settings (Ctrl+right-click) 36 | - fixed reuseLastValues error on linux 37 | 38 | Version 1.3.8 39 | - fixed a bug where attributes disappear in the split tool. 40 | 41 | Version 1.3.7 42 | - fixed a bug where the tool button does not switch. 43 | - fixed a bug where the setting for disable_enter_attribute_values_dialog is not applied. 44 | - fixed a bug where the UseLastValue setting is not applied. 45 | - fixed a bug where the default values of the form are not applied. 46 | 47 | Version 1.3.6 48 | - added Hungarian translation contributed by @BathoryPeter 49 | - added detailed tooltip for Bezier Edit button 50 | - Rewording messages 51 | - fixed unsplit bug on mac 52 | 53 | Version 1.3.5 54 | - added support for reuse last value 55 | - fixed autofill of fid 56 | 57 | Version 1.3.4 58 | - fixed initGui() bug 59 | 60 | Version 1.3.3 61 | - added support for moving the both handles [drag with alt] 62 | - added support for fixing the first handle in adding anchor [click & drag with alt] 63 | - added support for fixing the second handle to the anchor [click & drag with shift] 64 | - added support for setting the number of interpolations 65 | - changed to show handles by default 66 | 67 | 68 | Contribution 69 | ======= 70 | 71 | Translation 72 | -------------------------------------------- 73 | 74 | * Open bezierediting.pro and add bezierediting_{lang}.ts to the TRANSLATION section. {lang} must be a two letter language code. 75 | * Run `pylupdate5 bezierediting.pro` which generates the translation files. On debian, you can install pylupdate `apt install pyqt5-dev-tools`. 76 | * Open the newly generated .ts in i18n directory with QtLinguist or a text editor and do the translation. 77 | * When ready, generate qm file with `lrelease bezierediting.pro`. 78 | * (optional) To test, copy the .qm file to the plugins folder in your QGIS install (on Linux _~/.local/share/QGIS/QGIS3/profiles/default/python/plugins/BezierEditing/i18n_) and start QGIS. 79 | * Create pull request on GitHub or send the .ts file. 80 | 81 | License 82 | ======= 83 | 84 | BezierEditing plugin are released under the GNU Public License (GPL) Version 2. 85 | 86 | _Copyright (c) 2019 Takayuki Mizutani_ 87 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | BezierEditing is a QGIS plugin that provides advanced digitizing tools for editing geographic features using Bezier curves. The plugin allows users to create and edit vector geometries with smooth curves using anchor points and control handles, supporting both manual Bezier drawing and freehand sketching modes. 8 | 9 | ## Development Commands 10 | 11 | ### Building Resources 12 | ```bash 13 | # Compile Qt resources (icons, UI elements) 14 | pyrcc5 -o resources.py resources.qrc 15 | ``` 16 | 17 | ### Translation Management 18 | ```bash 19 | # Extract translatable strings from Python sources 20 | pylupdate5 bezierediting.pro 21 | 22 | # Edit translations (requires Qt Linguist) 23 | linguist i18n/bezierediting_ja.ts 24 | linguist i18n/bezierediting_hu.ts 25 | 26 | # Compile translation files 27 | lrelease bezierediting.pro 28 | ``` 29 | 30 | ## Architecture 31 | 32 | ### Core Components 33 | 34 | **BezierEditing (bezierediting.py)** - Main plugin class that: 35 | - Initializes the plugin interface with QGIS 36 | - Creates toolbar and menu items 37 | - Manages tool activation states 38 | - Handles translation loading 39 | 40 | **BezierEditingTool (beziereditingtool.py)** - Primary map tool class that: 41 | - Handles all mouse events for drawing and editing 42 | - Manages tool modes (bezier, freehand, split, unsplit) 43 | - Controls snapping behavior and smart guides 44 | - Manages feature attribute dialogs 45 | - Coordinates between BezierGeometry and BezierMarker 46 | 47 | **BezierGeometry (BezierGeometry.py)** - Geometry management class that: 48 | - Stores and manipulates anchor points and control handles 49 | - Converts between Bezier representation and QGIS geometries 50 | - Handles coordinate transformations for different CRS 51 | - Implements Bezier curve interpolation algorithms 52 | - Manages undo/redo history 53 | 54 | **BezierMarker (BezierMarker.py)** - Visual feedback class that: 55 | - Renders anchor points, control handles, and Bezier curves on the map canvas 56 | - Updates visual elements during editing operations 57 | - Manages rubber band displays for curves and guides 58 | 59 | **fitCurves.py** - Bezier curve fitting algorithms for converting polylines to smooth curves 60 | 61 | ## Key Technical Details 62 | 63 | - **Coordinate Systems**: The plugin handles CRS transformations internally, converting between layer CRS and a working CRS (EPSG:3857) for calculations when dealing with geographic coordinates 64 | - **Interpolation**: Bezier curves are interpolated with a configurable number of points (default 10) between anchors 65 | - **Attribute Handling**: Supports QGIS form configurations including "Reuse Last Values" and default value expressions 66 | - **State Management**: Uses mouse_state and editing flags to track the current editing context 67 | 68 | ## Plugin Installation Path 69 | 70 | The plugin is installed in the QGIS user profile directory: 71 | `~/.local/share/QGIS/QGIS3/profiles/default/python/plugins/BezierEditing/` (Linux) 72 | `~/Library/Application Support/QGIS/QGIS3/profiles/default/python/plugins/BezierEditing/` (macOS) 73 | `%APPDATA%/QGIS/QGIS3/profiles/default/python/plugins/BezierEditing/` (Windows) 74 | 75 | ## Testing Considerations 76 | 77 | - The plugin requires an active QGIS instance with a vector layer loaded 78 | - Test with different geometry types (Point, LineString, Polygon) 79 | - Verify CRS handling with both projected and geographic coordinate systems 80 | - Check snapping behavior with various snap settings enabled -------------------------------------------------------------------------------- /metadata.txt: -------------------------------------------------------------------------------- 1 | ; the next section is mandatory 2 | [general] 3 | name=Bezier Editing 4 | qgisMinimumVersion=3.20.0 5 | description=Bezier curve and freehand digitizing tools. You can also use a tablet pen. 6 | about=This plugin digitizes features using Bezier curves. You can create Bezier curve by plotting anchors and handles. You can also draw Bezier curves freehand and use a tablet pen. 7 | category=Vector 8 | version=version 1.3.10 9 | author=Takayuki Mizutani 10 | email=mizutani@ecoris.co.jp 11 | ; end of mandatory metadata 12 | 13 | ; start of optional metadata 14 | changelog= 15 | Version 1.3.10 16 | - fixed an issue where installation failed in Linux environments. 17 | 18 | Version 1.3.9 19 | - added streaming mode for freehand tool (click-move-click drawing without dragging) 20 | - added context menu for freehand tool settings (Ctrl+right-click) 21 | - fixed reuseLastValues error on linux 22 | 23 | Version 1.3.8 24 | - fixed a bug where attributes disappear in the split tool. 25 | 26 | Version 1.3.7 27 | - fixed a bug where the tool button does not switch. 28 | - fixed a bug where the setting for disable_enter_attribute_values_dialog is not applied. 29 | - fixed a bug where the UseLastValue setting is not applied. 30 | - fixed a bug where the default values of the form are not applied. 31 | 32 | Version 1.3.6 33 | - added Hungarian translation contributed by @BathoryPeter 34 | - added detailed tooltip for Bezier Edit button 35 | - Rewording messages 36 | - fixed unsplit bug on mac 37 | 38 | Version 1.3.5 39 | - added support for reuse last value 40 | - fixed autofill of fid 41 | 42 | Version 1.3.4 43 | - fixed initGui() bug 44 | 45 | Version 1.3.3 46 | - added support for moving the both handles [drag with alt] 47 | - added support for fixing the first handle in adding anchor [click & drag with alt] 48 | - added support for fixing the second handle to the anchor [click & drag with shift] 49 | - added support for setting the number of interpolations 50 | - changed to show handles by default 51 | 52 | Version 1.3.2 53 | - fixed tool buttons cannot be toggled after drawing on Mac. 54 | - fixed error on clicking with the pen tool. 55 | - fixed polygon with only two points created. 56 | 57 | Version 1.3.1 58 | - fixed warning message that ver 1.3 or higher is required for conversion. 59 | - fixed latitude exceeded limit error. 60 | - change the behavior of the guide. 61 | 62 | Version 1.3.0 63 | - support latlon map projection. 64 | 65 | Version 1.2.2 66 | - fixed unable to edit linestrig with Z M. 67 | 68 | Version 1.2.1 69 | - support editing closed polygon. 70 | - support converting to Bezier of features created by QGIS editing tools. 71 | - fixed not to display blue marker when activated. 72 | 73 | Version 1.1.1 74 | - fixed to delete anchor error with smart guide. 75 | 76 | Version 1.1.0 77 | - support smart guide 78 | - added online document in the menu. 79 | 80 | Version 1.0.2 81 | - fixed to snap to vertex and edge. 82 | - fixed to no flip for a point with right click. 83 | - fixed the release event of alt,ctrl,shift. 84 | - fixed to deactive the tool. 85 | - fixed to convert selected features to Bezier editing first. 86 | 87 | Version 1.0.1 88 | - fixed undo error if no feature. 89 | - fixed split error if there is only one feature. 90 | 91 | Version 1.0.0 92 | - released 93 | 94 | ; tags are in comma separated value format, spaces are allowed 95 | tags=digitizing,vector,bezier,freehand 96 | 97 | homepage=https://github.com/tmizu23/BezierEditing 98 | tracker=https://github.com/tmizu23/BezierEditing/issues 99 | repository=https://github.com/tmizu23/BezierEditing 100 | icon=BezierEditing.png 101 | 102 | ; experimental flag 103 | experimental=False 104 | 105 | ; deprecated flag (applies to the whole plugin and not only to the uploaded version) 106 | deprecated=False 107 | -------------------------------------------------------------------------------- /fitCurves.py: -------------------------------------------------------------------------------- 1 | """ Python implementation of 2 | Algorithm for Automatically Fitting Digitized Curves 3 | by Philip J. Schneider 4 | "Graphics Gems", Academic Press, 1990 5 | """ 6 | 7 | from __future__ import print_function 8 | from numpy import * 9 | from . import bezier 10 | from qgis.core import * 11 | 12 | # Fit one (ore more) Bezier curves to a set of points 13 | def fitCurve(points, maxError): 14 | leftTangent = normalize(points[1] - points[0]) 15 | rightTangent = normalize(points[-2] - points[-1]) 16 | return fitCubic(points, leftTangent, rightTangent, maxError) 17 | 18 | 19 | def fitCubic(points, leftTangent, rightTangent, error): 20 | # Use heuristic if region only has two points in it 21 | if (len(points) == 2): 22 | dist = linalg.norm(points[0] - points[1]) / 3.0 23 | bezCurve = [points[0], points[0] + leftTangent * dist, points[1] + rightTangent * dist, points[1]] 24 | return [bezCurve] 25 | 26 | # Parameterize points, and attempt to fit curve 27 | u = chordLengthParameterize(points) 28 | #return leftTangent 29 | bezCurve = generateBezier(points, u, leftTangent, rightTangent) 30 | # Find max deviation of points to fitted curve 31 | maxError, splitPoint = computeMaxError(points, bezCurve, u) 32 | if maxError < error: 33 | return [bezCurve] 34 | 35 | # # If error not too large, try some reparameterization and iteration 36 | # if maxError < error**2: 37 | # for i in range(200): 38 | # uPrime = reparameterize(bezCurve, points, u) 39 | # bezCurve = generateBezier(points, uPrime, leftTangent, rightTangent) 40 | # maxError, splitPoint = computeMaxError(points, bezCurve, uPrime) 41 | # if maxError < error: 42 | # mylog("C{}".format(i)) 43 | # return [bezCurve] 44 | # u = uPrime 45 | 46 | # # Fitting failed -- split at max error point and fit recursively 47 | beziers = [] 48 | centerTangent = normalize(points[splitPoint-1] - points[splitPoint+1]) 49 | beziers += fitCubic(points[:splitPoint+1], leftTangent, centerTangent, error) 50 | beziers += fitCubic(points[splitPoint:], -centerTangent, rightTangent, error) 51 | 52 | return beziers 53 | 54 | 55 | def generateBezier(points, parameters, leftTangent, rightTangent): 56 | bezCurve = [points[0], None, None, points[-1]] 57 | 58 | # compute the A's 59 | A = zeros((len(parameters), 2, 2)) 60 | for i, u in enumerate(parameters): 61 | A[i][0] = leftTangent * 3*(1-u)**2 * u 62 | A[i][1] = rightTangent * 3*(1-u) * u**2 63 | 64 | # Create the C and X matrices 65 | C = zeros((2, 2)) 66 | X = zeros(2) 67 | 68 | for i, (point, u) in enumerate(zip(points, parameters)): 69 | C[0][0] += dot(A[i][0], A[i][0]) 70 | C[0][1] += dot(A[i][0], A[i][1]) 71 | C[1][0] += dot(A[i][0], A[i][1]) 72 | C[1][1] += dot(A[i][1], A[i][1]) 73 | 74 | tmp = point - bezier.q([points[0], points[0], points[-1], points[-1]], u) 75 | 76 | X[0] += dot(A[i][0], tmp) 77 | X[1] += dot(A[i][1], tmp) 78 | 79 | # Compute the determinants of C and X 80 | det_C0_C1 = C[0][0] * C[1][1] - C[1][0] * C[0][1] 81 | det_C0_X = C[0][0] * X[1] - C[1][0] * X[0] 82 | det_X_C1 = X[0] * C[1][1] - X[1] * C[0][1] 83 | 84 | # Finally, derive alpha values 85 | alpha_l = 0.0 if det_C0_C1 == 0 else det_X_C1 / det_C0_C1 86 | alpha_r = 0.0 if det_C0_C1 == 0 else det_C0_X / det_C0_C1 87 | 88 | # If alpha negative, use the Wu/Barsky heuristic (see text) */ 89 | # (if alpha is 0, you get coincident control points that lead to 90 | # divide by zero in any subsequent NewtonRaphsonRootFind() call. */ 91 | segLength = linalg.norm(points[0] - points[-1]) 92 | epsilon = 1.0e-6 * segLength 93 | if alpha_l < epsilon or alpha_r < epsilon: 94 | # fall back on standard (probably inaccurate) formula, and subdivide further if needed. 95 | bezCurve[1] = bezCurve[0] + leftTangent * (segLength / 3.0) 96 | bezCurve[2] = bezCurve[3] + rightTangent * (segLength / 3.0) 97 | 98 | else: 99 | # First and last control points of the Bezier curve are 100 | # positioned exactly at the first and last data points 101 | # Control points 1 and 2 are positioned an alpha distance out 102 | # on the tangent vectors, left and right, respectively 103 | bezCurve[1] = bezCurve[0] + leftTangent * alpha_l 104 | bezCurve[2] = bezCurve[3] + rightTangent * alpha_r 105 | 106 | return bezCurve 107 | 108 | 109 | def reparameterize(bezier, points, parameters): 110 | return [newtonRaphsonRootFind(bezier, point, u) for point, u in zip(points, parameters)] 111 | 112 | 113 | def newtonRaphsonRootFind(bez, point, u): 114 | """ 115 | Newton's root finding algorithm calculates f(x)=0 by reiterating 116 | x_n+1 = x_n - f(x_n)/f'(x_n) 117 | 118 | We are trying to find curve parameter u for some point p that minimizes 119 | the distance from that point to the curve. Distance point to curve is d=q(u)-p. 120 | At minimum distance the point is perpendicular to the curve. 121 | We are solving 122 | f = q(u)-p * q'(u) = 0 123 | with 124 | f' = q'(u) * q'(u) + q(u)-p * q''(u) 125 | 126 | gives 127 | u_n+1 = u_n - |q(u_n)-p * q'(u_n)| / |q'(u_n)**2 + q(u_n)-p * q''(u_n)| 128 | """ 129 | d = bezier.q(bez, u)-point 130 | numerator = (d * bezier.qprime(bez, u)).sum() 131 | denominator = (bezier.qprime(bez, u)**2 + d * bezier.qprimeprime(bez, u)).sum() 132 | 133 | if denominator == 0.0: 134 | return u 135 | else: 136 | return u - numerator/denominator 137 | 138 | 139 | def chordLengthParameterize(points): 140 | u = [0.0] 141 | for i in range(1, len(points)): 142 | u.append(u[i-1] + linalg.norm(points[i] - points[i-1])) 143 | 144 | for i, _ in enumerate(u): 145 | u[i] = u[i] / u[-1] 146 | 147 | return u 148 | 149 | 150 | def computeMaxError(points, bez, parameters): 151 | maxDist = 0.0 152 | splitPoint = len(points)/2 153 | for i, (point, u) in enumerate(zip(points, parameters)): 154 | dist = linalg.norm(bezier.q(bez, u)-point)**2 155 | if dist > maxDist: 156 | maxDist = dist 157 | splitPoint = i 158 | 159 | return maxDist, splitPoint 160 | 161 | 162 | def normalize(v): 163 | if allclose(v, array([0, 0])): 164 | v = array([0.00001, 0.00001]) 165 | return v / linalg.norm(v) 166 | 167 | def mylog(msg): 168 | QgsMessageLog.logMessage(msg, 'MyPlugin', Qgis.Info) -------------------------------------------------------------------------------- /BezierMarker.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """" 3 | /*************************************************************************** 4 | BezierEditing 5 | -------------------------------------- 6 | Date : 01 05 2019 7 | Copyright : (C) 2019 Takayuki Mizutani 8 | Email : mizutani at ecoris dot co dot jp 9 | *************************************************************************** 10 | * * 11 | * This program is free software; you can redistribute it and/or modify * 12 | * it under the terms of the GNU General Public License as published by * 13 | * the Free Software Foundation; either version 2 of the License, or * 14 | * (at your option) any later version. * 15 | * * 16 | ***************************************************************************/ 17 | """ 18 | from qgis.PyQt.QtGui import * 19 | from qgis.core import * 20 | from qgis.gui import * 21 | 22 | 23 | class BezierMarker: 24 | 25 | def __init__(self, canvas, bezier_geometry): 26 | self.canvas = canvas 27 | self.bg = bezier_geometry 28 | self.anchor_marks = [] # anchor marker list 29 | self.handle_marks = [] # handle marker list 30 | self.handle_rbls = [] # handle line list 31 | 32 | # bezier curve line 33 | self.bezier_rbl = QgsRubberBand(self.canvas, QgsWkbTypes.LineGeometry) 34 | self.bezier_rbl.setColor(QColor(255, 0, 0, 150)) 35 | self.bezier_rbl.setWidth(2) 36 | 37 | def reset(self): 38 | """ 39 | reset bezier curve 40 | """ 41 | self._removeAllMarker(self.anchor_marks) 42 | self._removeAllMarker(self.handle_marks) 43 | self._removeAllRubberBand(self.handle_rbls) 44 | self.anchor_marks = [] 45 | self.handle_marks = [] 46 | self.handle_rbls = [] 47 | self.bezier_rbl.reset(QgsWkbTypes.LineGeometry) 48 | 49 | def show(self, show_handle=None): 50 | """ 51 | show bezier curve and marker 52 | """ 53 | self.reset() 54 | for point in self.bg.getAnchorList(revert=True): 55 | self._setAnchorHandleMarker(self.anchor_marks, len(self.anchor_marks), point) 56 | self._setAnchorHandleMarker(self.handle_marks, len(self.handle_marks), point, QColor(125, 125, 125)) 57 | self._setAnchorHandleMarker(self.handle_marks, len(self.handle_marks), point, QColor(125, 125, 125)) 58 | self._setHandleLine(self.handle_rbls, len(self.handle_marks), point) 59 | self._setHandleLine(self.handle_rbls, len(self.handle_marks), point) 60 | for idx, point in enumerate(self.bg.getHandleList(revert=True)): 61 | self.handle_rbls[idx].movePoint(1, point, 0) 62 | self.handle_marks[idx].setCenter(point) 63 | self._setBezierLine(self.bg.getPointList(revert=True), self.bezier_rbl) 64 | 65 | if show_handle is not None: 66 | self.show_handle(show_handle) 67 | 68 | def add_anchor(self, idx, point): 69 | """ 70 | add anchor and update bezier curve 71 | """ 72 | self._setAnchorHandleMarker(self.anchor_marks, idx, point) 73 | self._setAnchorHandleMarker(self.handle_marks, 2 * idx, point, QColor(125, 125, 125)) 74 | self._setAnchorHandleMarker(self.handle_marks, 2 * idx, point, QColor(125, 125, 125)) 75 | self._setHandleLine(self.handle_rbls, 2 * idx, point) 76 | self._setHandleLine(self.handle_rbls, 2 * idx, point) 77 | self._setBezierLine(self.bg.getPointList(revert=True), self.bezier_rbl) 78 | 79 | # アンカーを削除してベジエ曲線の表示を更新 80 | def delete_anchor(self, idx): 81 | """ 82 | delete anchor and update bezier curve 83 | """ 84 | self._removeMarker(self.handle_marks, 2 * idx) 85 | self._removeRubberBand(self.handle_rbls, 2 * idx) 86 | self._removeMarker(self.handle_marks, 2 * idx) 87 | self._removeRubberBand(self.handle_rbls, 2 * idx) 88 | self._removeMarker(self.anchor_marks, idx) 89 | self._setBezierLine(self.bg.getPointList(revert=True), self.bezier_rbl) 90 | 91 | # アンカーを移動してベジエ曲線の表示を更新 92 | def move_anchor(self, idx, point): 93 | """ 94 | move anchor and update bezier curve 95 | """ 96 | self.anchor_marks[idx].setCenter(point) 97 | self.handle_marks[idx * 2].setCenter(self.bg.getHandle(idx * 2,revert=True)) 98 | self.handle_marks[idx * 2 + 1].setCenter(self.bg.getHandle(idx * 2 + 1,revert=True)) 99 | self.handle_rbls[idx * 2].movePoint(0, point, 0) 100 | self.handle_rbls[idx * 2 + 1].movePoint(0, point, 0) 101 | self.handle_rbls[idx * 2].movePoint(1, self.bg.getHandle(idx * 2,revert=True), 0) 102 | self.handle_rbls[idx * 2 + 1].movePoint(1, self.bg.getHandle(idx * 2 + 1,revert=True), 0) 103 | self._setBezierLine(self.bg.getPointList(revert=True), self.bezier_rbl) 104 | 105 | def move_handle(self, idx, point): 106 | """" 107 | move handle and update bezier curve 108 | """ 109 | self.handle_rbls[idx].movePoint(1, point, 0) 110 | self.handle_marks[idx].setCenter(point) 111 | self._setBezierLine(self.bg.getPointList(revert=True), self.bezier_rbl) 112 | 113 | def show_handle(self, show): 114 | """ 115 | change handle visibility 116 | """ 117 | if show: 118 | self._showAllMarker(self.handle_marks) 119 | self._showAllRubberBand(self.handle_rbls) 120 | else: 121 | self._hideAllMarker(self.handle_marks) 122 | self._hideAllRubberBand(self.handle_rbls) 123 | self.canvas.refresh() 124 | 125 | def _setBezierLine(self, points, rbl): 126 | rbl.reset(QgsWkbTypes.LineGeometry) 127 | for point in points: 128 | update = point is points[-1] 129 | rbl.addPoint(point, update) 130 | 131 | def _setAnchorHandleMarker(self, markers, idx, point, color=QColor(0, 0, 0)): 132 | # insert anchor or handle marker 133 | marker = QgsVertexMarker(self.canvas) 134 | marker.setIconType(QgsVertexMarker.ICON_BOX) 135 | marker.setColor(color) 136 | marker.setPenWidth(2) 137 | marker.setIconSize(5) 138 | marker.setCenter(point) 139 | marker.show() 140 | markers.insert(idx, marker) 141 | return markers 142 | 143 | def _setHandleLine(self, rbls, idx, point): 144 | rbl = QgsRubberBand(self.canvas, QgsWkbTypes.LineGeometry) 145 | rbl.setColor(QColor(0, 0, 0)) 146 | rbl.setWidth(1) 147 | rbl.addPoint(point) 148 | rbl.addPoint(point) 149 | rbls.insert(idx, rbl) 150 | return rbls 151 | 152 | def _removeMarker(self, markers, idx): 153 | m = markers[idx] 154 | self.canvas.scene().removeItem(m) 155 | del markers[idx] 156 | 157 | def _removeAllMarker(self, markers): 158 | for m in markers: 159 | self.canvas.scene().removeItem(m) 160 | 161 | def _showAllMarker(self, markers): 162 | for m in markers[1:-1]: 163 | m.show() 164 | 165 | def _hideAllMarker(self, markers): 166 | for m in markers: 167 | m.hide() 168 | 169 | def _removeRubberBand(self, rbls, index): 170 | rbl = rbls[index] 171 | self.canvas.scene().removeItem(rbl) 172 | del rbls[index] 173 | 174 | def _removeAllRubberBand(self, rbls): 175 | for rbl in rbls: 176 | self.canvas.scene().removeItem(rbl) 177 | 178 | def _showAllRubberBand(self, rbls): 179 | for rbl in rbls[1:-1]: 180 | rbl.setColor(QColor(0, 0, 0, 255)) 181 | 182 | def _hideAllRubberBand(self, rbls): 183 | for rbl in rbls: 184 | rbl.setColor(QColor(0, 0, 0, 0)) 185 | -------------------------------------------------------------------------------- /bezierediting.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """" 3 | /*************************************************************************** 4 | BezierEditing 5 | -------------------------------------- 6 | Date : 01 05 2019 7 | Copyright : (C) 2019 Takayuki Mizutani 8 | Email : mizutani at ecoris dot co dot jp 9 | *************************************************************************** 10 | * * 11 | * This program is free software; you can redistribute it and/or modify * 12 | * it under the terms of the GNU General Public License as published by * 13 | * the Free Software Foundation; either version 2 of the License, or * 14 | * (at your option) any later version. * 15 | * * 16 | ***************************************************************************/ 17 | """ 18 | from qgis.PyQt.QtCore import * 19 | from qgis.PyQt.QtGui import * 20 | from qgis.PyQt.QtWidgets import * 21 | from qgis.core import * 22 | from qgis.gui import * 23 | import os 24 | import webbrowser 25 | 26 | from . import resources 27 | from .beziereditingtool import BezierEditingTool 28 | 29 | 30 | class BezierEditing(object): 31 | 32 | def __init__(self, iface): 33 | self.iface = iface 34 | self.canvas = self.iface.mapCanvas() 35 | self.active = False 36 | 37 | # setup translation 38 | if QSettings().value('locale/overrideFlag', type=bool): 39 | locale = QSettings().value('locale/userLocale') 40 | else: 41 | locale = QLocale.system().name() 42 | 43 | locale_path = os.path.join( 44 | os.path.dirname(__file__), 45 | 'i18n', 46 | 'bezierediting_{}.qm'.format(locale[0:2])) 47 | 48 | if os.path.exists(locale_path): 49 | self.translator = QTranslator() 50 | self.translator.load(locale_path) 51 | QCoreApplication.installTranslator(self.translator) 52 | 53 | def initGui(self): 54 | # Init the tool 55 | self.beziertool = BezierEditingTool(self.canvas, self.iface) 56 | 57 | # create menu for this plugin 58 | self.action = QAction( 59 | QIcon(":/plugins/BezierEditing/icon/beziericon.svg"), 60 | self.tr("Online Documentation"), 61 | self.iface.mainWindow() 62 | ) 63 | # connect the action to the run method 64 | self.action.triggered.connect(self.open_browser) 65 | self.iface.addPluginToMenu(self.tr("&Bezier Editing"), self.action) 66 | 67 | # create toolbar for this plugin 68 | self.toolbar = self.iface.addToolBar(self.tr("Bezier Editing")) 69 | self.toolbar.setObjectName("bezierEditing_toolbar") 70 | 71 | # Create bezier action 72 | self.bezier_edit = QAction(QIcon(":/plugins/BezierEditing/icon/beziericon.svg"), self.tr("Bezier Edit"), 73 | self.iface.mainWindow()) 74 | self.bezier_edit.setObjectName("BezierEditing_edit") 75 | self.bezier_edit.setEnabled(False) 76 | self.bezier_edit.setCheckable(True) 77 | self.bezier_edit.setText(self.tr( 78 | """Bezier Edit

79 | Click to add anchor 80 |
- click&&drag: add curved anchor (with two hanles)
81 |
- click&&drag+Alt: add anchor moving only backward handle
82 |
- click&&drag+Shift: add anchor without forward handle
83 | Right click to commit feature
84 | Ctrl shows guide
85 | On feature: 86 |
- Right click to enter drawing mode again
87 |
- Alt+click inserts anchor
88 | On anchor: 89 |
- Alt+click pulls handle from anchor
90 |
- Shift+click deletes anchor
91 | On handle: 92 |
- Alt+drag moves both handles
93 |
- Shift+click deletes handle
94 | On first anchor, right click to flip Bezier direction.
95 | Ctrl + right click shows context menu.""" 96 | )) 97 | self.bezier_edit.triggered.connect(self.bezierediting) 98 | self.toolbar.addAction(self.bezier_edit) 99 | 100 | # Create freehand action 101 | self.freehand = QAction(QIcon(":/plugins/BezierEditing/icon/freehandicon.svg"), self.tr("Edit Bezier Freehand"), 102 | self.iface.mainWindow()) 103 | self.freehand.setObjectName("BezierEditing_freehand") 104 | self.freehand.setEnabled(False) 105 | self.freehand.setCheckable(True) 106 | self.freehand.setText(self.tr( 107 | """Edit Bezier Freehand

108 | - Drag to draw a line
109 | - Retrace a segment and the line will be modified
110 | - Right click to commit feature / enter edit mode again""" 111 | )) 112 | self.freehand.triggered.connect(self.freehandediting) 113 | self.toolbar.addAction(self.freehand) 114 | 115 | # Create split action 116 | self.split = QAction(QIcon(":/plugins/BezierEditing/icon/spliticon.svg"), self.tr("Split Bezier Curve"), 117 | self.iface.mainWindow()) 118 | self.split.setObjectName("BezierEditing_split") 119 | self.split.setEnabled(False) 120 | self.split.setCheckable(True) 121 | self.split.triggered.connect(self.spliting) 122 | self.toolbar.addAction(self.split) 123 | 124 | # Create unsplit action 125 | self.unsplit = QAction(QIcon(":/plugins/BezierEditing/icon/unspliticon.svg"), self.tr("Merge Bezier Curves"), 126 | self.iface.mainWindow()) 127 | self.unsplit.setObjectName("BezierEditing_unsplit") 128 | self.unsplit.setEnabled(False) 129 | self.unsplit.setCheckable(True) 130 | self.unsplit.setText(self.tr( 131 | """Merge Bezier Curves

132 | 1. Click or click&&drag to select features
133 | 2. Right click to merge.""" 134 | )) 135 | self.unsplit.triggered.connect(self.unspliting) 136 | self.toolbar.addAction(self.unsplit) 137 | 138 | # Create show anchor option 139 | self.show_handle = QAction(QIcon(":/plugins/BezierEditing/icon/showhandleicon.svg"), self.tr("Show Bezier Handles"), 140 | self.iface.mainWindow()) 141 | self.show_handle.setObjectName("BezierEditing_show_handle") 142 | self.show_handle.setCheckable(True) 143 | self.show_handle.setEnabled(False) 144 | self.show_handle.setChecked(True) 145 | self.show_handle.toggled.connect(self.showhandle) 146 | self.toolbar.addAction(self.show_handle) 147 | 148 | # Create undo option 149 | self.undo = QAction(QIcon( 150 | ":/plugins/BezierEditing/icon/undoicon.svg"), 151 | self.tr("Undo"), 152 | self.iface.mainWindow() 153 | ) 154 | self.undo.setObjectName("BezierEditing_undo") 155 | self.undo.setEnabled(False) 156 | self.undo.triggered.connect(self.beziertool.undo) 157 | self.toolbar.addAction(self.undo) 158 | 159 | # Connect to signals for button behaviour 160 | self.iface.layerTreeView().currentLayerChanged.connect(self.toggle) 161 | self.canvas.mapToolSet.connect(self.maptoolChanged) 162 | 163 | self.currentTool = None 164 | self.toggle() 165 | 166 | def tr(self, message): 167 | return QCoreApplication.translate('BezierEditing', message) 168 | 169 | def open_browser(self): 170 | webbrowser.open('https://github.com/tmizu23/BezierEditing/wiki') 171 | 172 | def toggleAllOff(self): 173 | self.bezier_edit.setChecked(False) 174 | self.freehand.setChecked(False) 175 | self.split.setChecked(False) 176 | self.unsplit.setChecked(False) 177 | 178 | def bezierediting(self): 179 | self.currentTool = self.beziertool 180 | self.canvas.setMapTool(self.beziertool) 181 | self.toggleAllOff() 182 | self.bezier_edit.setChecked(True) 183 | self.beziertool.mode = "bezier" 184 | 185 | def freehandediting(self): 186 | self.currentTool = self.beziertool 187 | self.canvas.setMapTool(self.beziertool) 188 | self.toggleAllOff() 189 | self.freehand.setChecked(True) 190 | self.beziertool.mode = "freehand" 191 | 192 | def spliting(self): 193 | self.currentTool = self.beziertool 194 | self.canvas.setMapTool(self.beziertool) 195 | self.toggleAllOff() 196 | self.split.setChecked(True) 197 | self.beziertool.mode = "split" 198 | 199 | def unspliting(self): 200 | self.currentTool = self.beziertool 201 | self.canvas.setMapTool(self.beziertool) 202 | self.toggleAllOff() 203 | self.unsplit.setChecked(True) 204 | self.beziertool.mode = "unsplit" 205 | 206 | def showhandle(self, checked): 207 | self.beziertool.showHandle(checked) 208 | 209 | def toggle(self): 210 | mc = self.canvas 211 | layer = mc.currentLayer() 212 | if layer is None: 213 | return 214 | 215 | if layer.isEditable() and layer.type() == QgsMapLayer.VectorLayer: 216 | self.bezier_edit.setEnabled(True) 217 | self.freehand.setEnabled(True) 218 | self.split.setEnabled(True) 219 | self.unsplit.setEnabled(True) 220 | self.show_handle.setEnabled(True) 221 | self.undo.setEnabled(True) 222 | 223 | try: 224 | layer.editingStopped.disconnect(self.toggle) 225 | except TypeError: 226 | pass 227 | layer.editingStopped.connect(self.toggle) 228 | try: 229 | layer.editingStarted.disconnect(self.toggle) 230 | except TypeError: 231 | pass 232 | else: 233 | self.bezier_edit.setEnabled(False) 234 | self.freehand.setEnabled(False) 235 | self.split.setEnabled(False) 236 | self.unsplit.setEnabled(False) 237 | self.show_handle.setEnabled(False) 238 | self.undo.setEnabled(False) 239 | 240 | if layer.type() == QgsMapLayer.VectorLayer: 241 | try: 242 | layer.editingStarted.disconnect(self.toggle) 243 | except TypeError: 244 | pass 245 | layer.editingStarted.connect(self.toggle) 246 | try: 247 | layer.editingStopped.disconnect(self.toggle) 248 | except TypeError: 249 | pass 250 | 251 | def maptoolChanged(self): 252 | self.bezier_edit.setChecked(False) 253 | self.freehand.setChecked(False) 254 | self.split.setChecked(False) 255 | self.unsplit.setChecked(False) 256 | if self.iface.mapCanvas().mapTool() != self.currentTool: 257 | self.iface.mapCanvas().unsetMapTool(self.currentTool) 258 | self.currentTool = None 259 | 260 | def unload(self): 261 | self.toolbar.removeAction(self.bezier_edit) 262 | self.toolbar.removeAction(self.freehand) 263 | self.toolbar.removeAction(self.split) 264 | self.toolbar.removeAction(self.unsplit) 265 | self.toolbar.removeAction(self.show_handle) 266 | self.toolbar.removeAction(self.undo) 267 | del self.toolbar 268 | self.iface.removePluginMenu(self.tr("&Bezier Editing"), self.action) 269 | self.iface.mapCanvas().mapToolSet.disconnect(self.maptoolChanged) 270 | 271 | def log(self, msg): 272 | QgsMessageLog.logMessage(msg, 'BezierEditing', Qgis.Info) 273 | -------------------------------------------------------------------------------- /i18n/bezierediting_ja.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BezierEditing 6 | 7 | 8 | Online Documentation 9 | オンラインドキュメント 10 | 11 | 12 | 13 | &Bezier Editing 14 | &Bezier Editing 15 | 16 | 17 | 18 | Bezier Editing 19 | Bezier Editing 20 | 21 | 22 | 23 | Bezier Edit 24 | ベジエ曲線を編集 25 | 26 | 27 | 28 | Edit Bezier Freehand 29 | ベジエ曲線をフリーハンドで編集 30 | 31 | 32 | 33 | Split Bezier Curve 34 | ベジエ曲線を分割 35 | 36 | 37 | 38 | Merge Bezier Curves 39 | ベジエ曲線を結合 40 | 41 | 42 | 43 | Show Bezier Handles 44 | ハンドル表示を切り替え 45 | 46 | 47 | 48 | Undo 49 | アンドゥ 50 | 51 | 52 | 53 | <b>Bezier Edit</b><br><br> 54 | Click to add anchor 55 | <dd>- click&&drag: add curved anchor (with two hanles)</dd> 56 | <dd>- click&&drag+Alt: add anchor moving only backward handle</dd> 57 | <dd>- click&&drag+Shift: add anchor without forward handle</dd> 58 | Right click to commit feature<br> 59 | Ctrl shows guide<br> 60 | On feature: 61 | <dd>- Right click to enter drawing mode again</dd> 62 | <dd>- Alt+click inserts anchor</dd> 63 | On anchor: 64 | <dd>- Alt+click pulls handle from anchor</dd> 65 | <dd>- Shift+click deletes anchor</dd> 66 | On handle: 67 | <dd>- Alt+drag moves both handles</dd> 68 | <dd>- Shift+click deletes handle</dd> 69 | On first anchor, right click to flip Bezier direction.<br> 70 | Ctrl + right click shows context menu. 71 | <b>ベジエ曲線を編集</b><br><br>クリックでアンカーを追加<dd>- クリック&&ドラッグ: アンカーを追加 (両側のハンドルを移動)</dd><dd>- クリック&&ドラッグ+Shift: アンカーを追加して次のハンドルは削除</dd><dd>- クリック&&ドラッグ+Alt: アンカーを追加して次のハンドルのみ移動</dd>右クリックでフィーチャーを確定<br>Ctrlでガイドを表示<br>フィーチャー上で:<dd>- 右クリック編集の再開</dd><dd>- Alt+クリック: アンカーの挿入</dd>アンカー上で:<dd>- Alt+クリック: アンカーからハンドルを引き出す</dd><dd>- Shift+クリック: アンカーを削除</dd>ハンドル上で:<dd>- Alt+ドラッグ: 両側のハンドルを移動</dd><dd>- Shift+クリック: ハンドルを削除</dd>始点のアンカーを右クリックで始点を反対にする<br>Ctrl + 右クリックでメニューを表示 72 | 73 | 74 | 75 | <b>Edit Bezier Freehand</b><br><br> 76 | - Drag to draw a line<br> 77 | - Retrace a segment and the line will be modified<br> 78 | - Right click to commit feature / enter edit mode again 79 | <b>ベジエ曲線をフリーハンドで編集</b><br><br>- ドラッグでラインを書く<br>- ラインをなぞり直すと修正される<br>- 右クリックで編集の確定/編集の再開 80 | 81 | 82 | 83 | <b>Merge Bezier Curves</b><br><br> 84 | 1. Click or click&&drag to select features<br> 85 | 2. Right click to merge. 86 | <b>ベジエラインを結合</b><br><br>1. クリック か クリック&&ドラッグでフィーチャーを選択<br>2. 右クリックで結合 87 | 88 | 89 | 90 | BezierEditingTool 91 | 92 | 93 | No feature to split. 94 | 切断できるフィーチャーがありません。 95 | 96 | 97 | 98 | Do you want to continue editing? 99 | 編集を続けますか? 100 | 101 | 102 | 103 | Close 104 | 閉じる 105 | 106 | 107 | 108 | Can't be set while editing. 109 | 編集中は設定できません。 110 | 111 | 112 | 113 | Be careful when changing values, as Bezier curves with different numbers of interpolants will not be converted accurately. 114 | 補間点数の異なるベジェ曲線は正確に変換できないので、値を変更する場合は注意してください。 115 | 116 | 117 | 118 | Count 119 | 補間点数 120 | 121 | 122 | 123 | Warning 124 | Warning 125 | 126 | 127 | 128 | Reset editing data 129 | 編集中のデータはリセットされます 130 | 131 | 132 | 133 | Geometry type is different 134 | Geometry type is different 135 | 136 | 137 | 138 | Only line geometry can be split. 139 | 分割できるのはラインだけです。 140 | 141 | 142 | 143 | No feature 144 | No feature 145 | 146 | 147 | 148 | Continue editing? 149 | Continue editing? 150 | 151 | 152 | 153 | Geometry type of the layer is different, or polygon isn't closed. Do you want to continue editing? 154 | レイヤのジオメトリタイプが違います。もしくは、ポリゴンが閉じていません。編集を続けますか? 155 | 156 | 157 | 158 | No feature found. Do you want to continue editing? 159 | フィーチャーがありません。編集を続けますか? 160 | 161 | 162 | 163 | Line 164 | ライン 165 | 166 | 167 | 168 | Curve 169 | カーブ 170 | 171 | 172 | 173 | Convert to Bezier 174 | ベジエ曲線に変換 175 | 176 | 177 | 178 | The feature isn't created by Bezier Tool or ver 1.3 higher. 179 | 180 | Do you want to convert to Bezier? 181 | 182 | Conversion can be done either to line segments or to fitting curve. 183 | Please select conversion mode. 184 | このフィーチャーはベジエツールまたはVer1.3以上で作成されていません。.\n\nベジエツールで編集できるように変換しますか?ラインでの変換とフィッティングカーブでの変換を選択できます。 185 | 186 | 187 | 188 | Not supported type 189 | Not supported type 190 | 191 | 192 | 193 | Geometry type of the layer is not supported. 194 | このレイヤのジオメトリタイプはサポートしていません。 195 | 196 | 197 | 198 | Bezier added 199 | Bezier added 200 | 201 | 202 | 203 | Bezier edited 204 | Bezier edited 205 | 206 | 207 | 208 | Reset guide 209 | ガイドの設定をリセット 210 | 211 | 212 | 213 | Set snap to angle 214 | スナップの角度 215 | 216 | 217 | 218 | Enter snap angle (degree) 219 | スナップする角度を入力してください 220 | 221 | 222 | 223 | Set snap to length 224 | スナップの長さ 225 | 226 | 227 | 228 | Enter snap length (in case of LatLon in seconds) 229 | スナップする長さを入力してください(緯度経度の場合は秒単位) 230 | 231 | 232 | 233 | Enter Interpolate Point Count (default is 10) 234 | 補間点数を入力してください(デフォルト10) 235 | 236 | 237 | 238 | Bezier unsplit 239 | Bezier unsplit 240 | 241 | 242 | 243 | Select exactly two feature. 244 | ラインを2本だけ選択してください。 245 | 246 | 247 | 248 | Select a line Layer. 249 | ラインレイヤを選択してください。 250 | 251 | 252 | 253 | Guide settings... 254 | ガイドの設定... 255 | 256 | 257 | 258 | Advanced settings... 259 | 高度な設定... 260 | 261 | 262 | 263 | -------------------------------------------------------------------------------- /i18n/bezierediting_hu.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BezierEditing 6 | 7 | 8 | Bezier Edit 9 | Szerkesztés Bézier-görbével 10 | 11 | 12 | 13 | Edit Bezier Freehand 14 | Szerkesztés szabadkézi Bézier-görbével 15 | 16 | 17 | 18 | Split Bezier Curve 19 | Bézier-görbe elvágása 20 | 21 | 22 | 23 | Show Bezier Handles 24 | Bézier-fogantyúk megjelenítése 25 | 26 | 27 | 28 | Undo 29 | Visszavonás 30 | 31 | 32 | 33 | Merge Bezier Curves 34 | Bézier-görbék egyesítése 35 | 36 | 37 | 38 | Online Documentation 39 | Online dokumentáció 40 | 41 | 42 | 43 | &Bezier Editing 44 | &Bézier Editing 45 | 46 | 47 | 48 | Bezier Editing 49 | Bézier szerkesztés 50 | 51 | 52 | 53 | <b>Bezier Edit</b><br><br> 54 | Click to add anchor 55 | <dd>- click&&drag: add curved anchor (with two hanles)</dd> 56 | <dd>- click&&drag+Alt: add anchor moving only backward handle</dd> 57 | <dd>- click&&drag+Shift: add anchor without forward handle</dd> 58 | Right click to commit feature<br> 59 | Ctrl shows guide<br> 60 | On feature: 61 | <dd>- Right click to enter drawing mode again</dd> 62 | <dd>- Alt+click inserts anchor</dd> 63 | On anchor: 64 | <dd>- Alt+click pulls handle from anchor</dd> 65 | <dd>- Shift+click deletes anchor</dd> 66 | On handle: 67 | <dd>- Alt+drag moves both handles</dd> 68 | <dd>- Shift+click deletes handle</dd> 69 | On first anchor, right click to flip Bezier direction.<br> 70 | Ctrl + right click shows context menu. 71 | <b>Bézier-görbe szerkesztése</b><br><br>Kattintson egy csomópont hozzáadásához<dd>- kattintás&&húzás: íves csomópont hozzáadása(két fogantyúval)</dd><dd>- kattingás&&húzás+Alt: csomópont hozzáadása csak hátra irányú fogantyút mozgatva</dd><dd>- kattintás&&húzás+Shift: előre irányú fogantyú nélkülicsomópont hozzáadása</dd>Jobb kattintással véglegesítheti az elemet<br>Ctrl megjeleníti a segédvonalat<br>Elemen:<dd>- Jobb kattintással újraszerkesztő módba léphet</dd><dd>- Alt+kattintás csomópontot szúr be</dd>Csomóponton:<dd>- Alt+kattintás új fogantyút húz ki a csomópontból</dd><dd>- Shift+kattintás törli a csomópontot</dd>Fogantyún:<dd>- Alt+húzás mindkét fogantyút mozgatja</dd><dd>- Shift+kattintás törli a fogantyút</dd>Jobb kattintás az első csomópontra megfirdítja a Bézier rajzolásának irányát.<br>Ctrl + jobb kattintás megnyitja a helyi menüt. 72 | 73 | 74 | 75 | <b>Edit Bezier Freehand</b><br><br> 76 | - Drag to draw a line<br> 77 | - Retrace a segment and the line will be modified<br> 78 | - Right click to commit feature / enter edit mode again 79 | <b>Bézier-görbe szerkesztése szabadkézzel</b><br><br>- Vonal rajzolásához húzza a kurzort<br>- Egy vonalszakaszt újra átrajzolva módosíthatja a vonalat<br>- Jobb kattintással véglegesítheti az elemet / újra beléphet a szerkesztő módba 80 | 81 | 82 | 83 | <b>Merge Bezier Curves</b><br><br> 84 | 1. Click or click&&drag to select features<br> 85 | 2. Right click to merge. 86 | <b>Bézier-görbék egyesítése</b><br><br>1. Kattintson vagy kattintson és húzza a kurzort az elemek kiválasztásához<br>2. Egyesítse őke jobb kattintással. 87 | 88 | 89 | 90 | BezierEditingTool 91 | 92 | 93 | No feature to split. 94 | Nincs elvágandó elem. 95 | 96 | 97 | 98 | Do you want to continue editing? 99 | Szeretné folytatni a szerkesztést? 100 | 101 | 102 | 103 | Close 104 | Mégse 105 | 106 | 107 | 108 | Can't be set while editing. 109 | Szerkesztés alatt nem állítható. 110 | 111 | 112 | 113 | Be careful when changing values, as Bezier curves with different numbers of interpolants will not be converted accurately. 114 | Az értékek módosításakor legyen óvatos, az eltérő interpolánsú Bézier-görbék nem konvertálhatóak pontosan. 115 | 116 | 117 | 118 | Count 119 | Darabszám 120 | 121 | 122 | 123 | Reset editing data 124 | Szerkesztett adatok visszállítása 125 | 126 | 127 | 128 | Geometry type is different 129 | A geometriatípus eltérő 130 | 131 | 132 | 133 | Only line geometry can be split. 134 | Csak vonalgeometriát lehet elvágni. 135 | 136 | 137 | 138 | No feature 139 | Nem található elem 140 | 141 | 142 | 143 | Geometry type of the layer is different, or polygon isn't closed. Do you want to continue editing? 144 | A réteg geometriatípusa eltérő, vagy a felület nem zárt. Szeretné folytatni a szerkesztést? 145 | 146 | 147 | 148 | No feature found. Do you want to continue editing? 149 | Nem található elem. Szeretné folytatni a szerkesztést? 150 | 151 | 152 | 153 | Convert to Bezier 154 | Átalakítás Bézier-görbévé 155 | 156 | 157 | 158 | Line 159 | Vonal 160 | 161 | 162 | 163 | Curve 164 | Görbe 165 | 166 | 167 | 168 | Not supported type 169 | Nem támogatott típus 170 | 171 | 172 | 173 | Geometry type of the layer is not supported. 174 | A réteg geometriatípusa nem támogatott. 175 | 176 | 177 | 178 | Bezier added 179 | Bézier hozzáadva 180 | 181 | 182 | 183 | Bezier edited 184 | Bézier szerkesztve 185 | 186 | 187 | 188 | Continue editing? 189 | Folytatja a szerkesztést? 190 | 191 | 192 | 193 | Reset guide 194 | Segédvonal visszaállítása 195 | 196 | 197 | 198 | Enter Interpolate Point Count (default is 10) 199 | Adja meg az interpolációs pontok számát (az alapértelmezett 10) 200 | 201 | 202 | 203 | Bezier unsplit 204 | Bézier elvágva 205 | 206 | 207 | 208 | Warning 209 | Figyelmeztetés 210 | 211 | 212 | 213 | Enter snap angle (degree) 214 | Adja meg az illesztési szöget (fok) 215 | 216 | 217 | 218 | Set snap to length 219 | Távolsághoz illesztés beállítása 220 | 221 | 222 | 223 | Select exactly two feature. 224 | Pontosan két elemet válasszon ki. 225 | 226 | 227 | 228 | Select a line Layer. 229 | Válasszon ki egy vonalréteget. 230 | 231 | 232 | 233 | Enter snap length (in case of LatLon in seconds) 234 | Adja meg az illesztési távolságot (LatLon esetén másodpercben) 235 | 236 | 237 | 238 | Set snap to angle 239 | Szöghöz illesztés beállítása 240 | 241 | 242 | 243 | The feature isn't created by Bezier Tool or ver 1.3 higher. 244 | 245 | Do you want to convert to Bezier? 246 | 247 | Conversion can be done either to line segments or to fitting curve. 248 | Please select conversion mode. 249 | Az elem nem a Bézier eszközzel vagy annak legalább 1.3-as verziójával lett létrehozva. 250 | 251 | Szeretné Bezier-görbévé alakítani? 252 | 253 | A konvertálást vonalszakaszokkal vagy görbére illesztéssel lehet elvégezni. 254 | Válassza ki az átalakítás módját. 255 | 256 | 257 | 258 | Guide settings... 259 | Segédvonal beállításai... 260 | 261 | 262 | 263 | Advanced settings... 264 | Speciális beállítások... 265 | 266 | 267 | 268 | -------------------------------------------------------------------------------- /BezierGeometry.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """" 3 | /*************************************************************************** 4 | BezierEditing 5 | -------------------------------------- 6 | Date : 01 05 2019 7 | Copyright : (C) 2019 Takayuki Mizutani 8 | Email : mizutani at ecoris dot co dot jp 9 | *************************************************************************** 10 | * * 11 | * This program is free software; you can redistribute it and/or modify * 12 | * it under the terms of the GNU General Public License as published by * 13 | * the Free Software Foundation; either version 2 of the License, or * 14 | * (at your option) any later version. * 15 | * * 16 | ***************************************************************************/ 17 | """ 18 | from qgis.core import * 19 | from .fitCurves import * 20 | import copy 21 | import math 22 | import numpy as np 23 | 24 | 25 | class BezierGeometry: 26 | INTERPOLATION = 10 # interpolation count from anchor to anchor 27 | 28 | def __init__(self, projectCRS): 29 | self.projectCRS = projectCRS 30 | self.points = [] # bezier line points list 31 | self.anchor = [] # anchor list 32 | self.handle = [] # handle list 33 | self.history = [] # undo history 34 | 35 | @classmethod 36 | def convertPointToBezier(cls, projectCRS, point): 37 | bg = cls(projectCRS) 38 | point = bg._trans(point) 39 | bg._addAnchor(0, point) 40 | return bg 41 | 42 | @classmethod 43 | def checkIsBezier(cls, projectCRS, polyline): 44 | is_bezier = True 45 | bg = cls(projectCRS) 46 | # if polyline length isn't match cause of edited other tool, points are interpolated. 47 | if len(polyline) % bg.INTERPOLATION != 1: 48 | is_bezier = False 49 | else: 50 | polyline = [bg._trans(p) for p in polyline] 51 | point_list = bg._lineToPointList(polyline) 52 | # Check if the number of points accidentally matches with the case of Bezier 53 | # if not bezier, calculation of anchor position is different from "A" and "B" 54 | for points in point_list: 55 | psA, csA, peA, ceA = bg._convertPointListToAnchorAndHandle( 56 | points, "A") 57 | psB, csB, peB, ceB = bg._convertPointListToAnchorAndHandle( 58 | points, "B") 59 | 60 | if not(abs(csA[0] - csB[0]) < 0.0001 and abs(csA[1] - csB[1]) < 0.0001 and abs(ceA[0] - ceB[0]) < 0.0001 and abs(ceA[1] - ceB[1]) < 0.0001): 61 | bg.log("{} {} {} {}".format(abs( 62 | csA[0] - csB[0]), abs(csA[1] - csB[1]), abs(ceA[0] - ceB[0]), abs(ceA[1] - ceB[1]))) 63 | is_bezier = False 64 | 65 | return is_bezier 66 | 67 | @classmethod 68 | def convertLineToBezier(cls, projectCRS, polyline, linetype="bezier"): # bezier,line,curve 69 | bg = cls(projectCRS) 70 | polyline = [bg._trans(p) for p in polyline] 71 | if linetype == "bezier": 72 | point_list = bg._lineToPointList(polyline) 73 | bg._invertBezierPointListToBezier(point_list) 74 | elif linetype == "line": 75 | point_list = bg._lineToInterpolatePointList(polyline) 76 | bg._invertBezierPointListToBezier(point_list) 77 | elif linetype == "curve": 78 | geom = QgsGeometry.fromPolylineXY(polyline) 79 | bg._convertGeometryToBezier(geom, 0, scale=1.0, last=True) 80 | 81 | return bg 82 | 83 | def setCRS(self, projectCRS): 84 | self.projectCRS = projectCRS 85 | 86 | def asGeometry(self, layer_type, layer_wkbtype): 87 | """ 88 | return a geometry of specified layer type 89 | """ 90 | result = None 91 | geom = None 92 | num_anchor = self.anchorCount() 93 | 94 | if layer_type == QgsWkbTypes.PointGeometry and num_anchor == 1: 95 | geom = QgsGeometry.fromPointXY(self.points[0]) 96 | result = True 97 | elif layer_type == QgsWkbTypes.LineGeometry and num_anchor >= 2: 98 | if QgsWkbTypes.isMultiType(layer_wkbtype): 99 | geom = QgsGeometry.fromMultiPolylineXY([self.points]) 100 | result = True 101 | else: 102 | geom = QgsGeometry.fromPolylineXY(self.points) 103 | result = True 104 | elif layer_type == QgsWkbTypes.PolygonGeometry and num_anchor >= 3 and self.points[0] == self.points[-1]: 105 | geom = QgsGeometry.fromPolygonXY([self.points]) 106 | result = True 107 | elif layer_type == QgsWkbTypes.PolygonGeometry and num_anchor >= 3 and self.points[0] != self.points[-1]: 108 | # if first point and last point is different, interpolate points. 109 | point_list = self._lineToInterpolatePointList( 110 | [self.points[-1], self.points[0]]) 111 | geom = QgsGeometry.fromPolygonXY( 112 | [self.points + point_list[0][1:-1]]) 113 | result = True 114 | elif layer_type == QgsWkbTypes.LineGeometry and num_anchor < 2: 115 | result = None 116 | elif layer_type == QgsWkbTypes.PolygonGeometry and num_anchor < 3: 117 | result = None 118 | else: 119 | result = False 120 | geom = self._transgeom(geom, revert=True) 121 | return result, geom 122 | 123 | def asPolyline(self): 124 | """ 125 | return bezier line points list 126 | """ 127 | points = [self._trans(p, revert=True) for p in self.points] 128 | return points 129 | 130 | def add_anchor(self, idx, point, undo=True): 131 | point = self._trans(point) 132 | if undo: 133 | self.history.append({"state": "add_anchor", "pointidx": idx}) 134 | self._addAnchor(idx, point) 135 | 136 | def move_anchor(self, idx, point, undo=True): 137 | point = self._trans(point) 138 | if undo: 139 | self.history.append( 140 | {"state": "move_anchor", "pointidx": idx, "point": point}) 141 | self._moveAnchor(idx, point) 142 | 143 | def move_anchor2(self, idx, point): 144 | point = self._trans(point) 145 | self.history.append( 146 | {"state": "move_anchor2", "pointidx": idx, "point": point}) 147 | self._moveAnchor(idx, point) 148 | self._moveAnchor(0, point) 149 | 150 | def delete_anchor(self, idx, point, undo=True): 151 | if undo: 152 | self.history.append( 153 | {"state": "delete_anchor", 154 | "pointidx": idx, 155 | "point": point, 156 | "ctrlpoint0": self.getHandle(idx * 2), 157 | "ctrlpoint1": self.getHandle(idx * 2 + 1) 158 | } 159 | ) 160 | self._deleteAnchor(idx) 161 | 162 | def delete_anchor2(self, idx, point): 163 | point = self._trans(point) 164 | self.history.append( 165 | {"state": "delete_anchor2", 166 | "pointidx": idx, 167 | "point": point, 168 | "ctrlpoint0": self.getHandle(1), 169 | "ctrlpoint1": self.getHandle(idx * 2) 170 | } 171 | ) 172 | self._deleteAnchor(idx) 173 | self._deleteAnchor(0) 174 | self._addAnchor(self.anchorCount(), self.getAnchor(0)) 175 | 176 | def move_handle(self, idx, point, undo=True): 177 | point = self._trans(point) 178 | if undo: 179 | self.history.append( 180 | {"state": "move_handle", "pointidx": idx, "point": point}) 181 | self._moveHandle(idx, point) 182 | 183 | def other_handle(self, handle_idx, point): 184 | point = self._trans(point) 185 | other_handle_idx = handle_idx + \ 186 | 1 if handle_idx % 2 == 0 else handle_idx - 1 187 | anchor_idx = math.floor(handle_idx/2) 188 | anchor_point = self.getAnchor(anchor_idx) 189 | other_point = QgsPointXY( 190 | anchor_point[0] - (point[0] - anchor_point[0]), anchor_point[1] - (point[1] - anchor_point[1])) 191 | other_point = self._trans(other_point, revert=True) 192 | return other_handle_idx, other_point 193 | 194 | def move_handle2(self, anchor_idx, point, fix_first=False, remove_second=False): 195 | """ 196 | move the handles on both sides of the anchor as you drag the anchor. 197 | fix the first handle with fix_first option. 198 | move the second handle to the anchor position with remove_second option. 199 | """ 200 | point = self._trans(point) 201 | handle_idx = anchor_idx * 2 202 | p = self.getAnchor(anchor_idx) 203 | pb = QgsPointXY(p[0] - (point[0] - p[0]), p[1] - (point[1] - p[1])) 204 | if remove_second: 205 | self._moveHandle(handle_idx, pb) 206 | self._moveHandle(handle_idx + 1, p) 207 | elif fix_first: 208 | self._moveHandle(handle_idx + 1, point) 209 | else: 210 | self._moveHandle(handle_idx, pb) 211 | self._moveHandle(handle_idx + 1, point) 212 | pb = self._trans(pb, revert=True) 213 | p = self._trans(p, revert=True) 214 | return handle_idx, pb, p 215 | 216 | def delete_handle(self, idx, point): 217 | point = self._trans(point) 218 | self.history.append( 219 | {"state": "delete_handle", 220 | "pointidx": idx, 221 | "point": point, 222 | } 223 | ) 224 | pnt = self.getAnchor(int(idx / 2)) 225 | self._moveHandle(idx, pnt) 226 | 227 | def flip_line(self): 228 | self.history.append({"state": "flip_line"}) 229 | self._flipBezierLine() 230 | 231 | def insert_anchor(self, point_idx, point): 232 | point = self._trans(point) 233 | anchor_idx = self._AnchorIdx(point_idx) 234 | self.history.append( 235 | {"state": "insert_anchor", 236 | "pointidx": anchor_idx, 237 | "ctrlpoint0": self.getHandle((anchor_idx - 1) * 2 + 1), 238 | "ctrlpoint1": self.getHandle((anchor_idx - 1) * 2 + 2) 239 | } 240 | ) 241 | self._insertAnchorPointToBezier(point_idx, anchor_idx, point) 242 | 243 | def modified_by_geometry(self, update_geom, layer_type, scale, snap_to_start): 244 | """ 245 | update bezier line by geometry. if no bezier line, added new. 246 | """ 247 | 248 | update_geom = self._transgeom(update_geom) 249 | dist = scale / 250 250 | bezier_line = self.points 251 | update_line = update_geom.asPolyline() 252 | bezier_geom = QgsGeometry.fromPolylineXY(bezier_line) 253 | 254 | if len(update_line) < 3: 255 | return 256 | 257 | # no bezier line or only a point. 258 | # The number of anchors is 1 instead of 0 because anchors are added on click if no bezier line. 259 | if self.anchorCount() == 1: 260 | # if there is no point and update line is a point insted of line 261 | # The number of update_line points is 2 instead of 1 because rubberband points are added two at first. 262 | if len(update_line) == 2: 263 | self._deleteAnchor(0) 264 | self.add_anchor(0, update_line[0]) 265 | # if there is no point and update line is line 266 | elif len(self.history) == 0 and len(update_line) > 2: 267 | self._deleteAnchor(0) 268 | self.history.append({"state": "start_freehand"}) 269 | geom = self._smoothingGeometry(update_line) 270 | pointnum, _, _ = self._convertGeometryToBezier( 271 | geom, 0, scale, last=True) 272 | self.history.append( 273 | {"state": "insert_geom", "pointidx": 0, "pointnum": pointnum, "cp_first": None, 274 | "cp_last": None}) 275 | self.history.append( 276 | {"state": "end_freehand", "direction": "forward"}) 277 | # if there is only a point and update line is line 278 | elif len(self.history) > 0 and len(update_line) > 2: 279 | self.history.append({"state": "start_freehand"}) 280 | geom = self._smoothingGeometry(update_line) 281 | pointnum, _, _ = self._convertGeometryToBezier( 282 | geom, 1, scale, last=True) 283 | self.history.append( 284 | {"state": "insert_geom", "pointidx": 1, "pointnum": pointnum, "cp_first": None, 285 | "cp_last": None}) 286 | self.history.append( 287 | {"state": "end_freehand", "direction": "forward"}) 288 | # there is bezier line and update line is line 289 | else: 290 | startpnt = update_line[0] 291 | lastpnt = update_line[-1] 292 | startpnt_is_near, start_anchoridx, start_vertexidx = self._closestAnchorOfGeometry( 293 | startpnt, bezier_geom, dist) 294 | lastpnt_is_near, last_anchoridx, last_vertexidx = self._closestAnchorOfGeometry( 295 | lastpnt, bezier_geom, dist) 296 | 297 | # Calculate inner product of vectors around intersection of bezier_line and update_line. 298 | # Forward if positive, backward if negative 299 | v1 = np.array(bezier_line[start_vertexidx]) - \ 300 | np.array(bezier_line[start_vertexidx - 1]) 301 | v2 = np.array(update_line[1]) - np.array(update_line[0]) 302 | direction = np.dot(v1, v2) 303 | 304 | self.history.append({"state": "start_freehand"}) 305 | # if backward, flip bezier line 306 | if direction < 0: 307 | self._flipBezierLine() 308 | reversed_geom = QgsGeometry.fromPolylineXY(bezier_line) 309 | startpnt_is_near, start_anchoridx, start_vertexidx = self._closestAnchorOfGeometry(startpnt, 310 | reversed_geom, dist) 311 | lastpnt_is_near, last_anchoridx, last_vertexidx = self._closestAnchorOfGeometry(lastpnt, reversed_geom, 312 | dist) 313 | 314 | point_list = self._lineToPointList(bezier_line) 315 | 316 | # modify middle of bezier line. 317 | if lastpnt_is_near and last_vertexidx > start_vertexidx and last_anchoridx <= len(point_list): 318 | 319 | polyline = point_list[start_anchoridx - 1][0:self._pointListIdx(start_vertexidx)] + \ 320 | update_line + \ 321 | point_list[last_anchoridx - 322 | 1][self._pointListIdx(last_vertexidx):] 323 | 324 | geom = self._smoothingGeometry(polyline) 325 | for i in range(start_anchoridx, last_anchoridx): 326 | self.history.append( 327 | {"state": "delete_anchor", 328 | "pointidx": start_anchoridx, 329 | "point": self.getAnchor(start_anchoridx), 330 | "ctrlpoint0": self.getHandle(start_anchoridx * 2), 331 | "ctrlpoint1": self.getHandle(start_anchoridx * 2 + 1) 332 | } 333 | ) 334 | self._deleteAnchor(start_anchoridx) 335 | 336 | pointnum, cp_first, cp_last = self._convertGeometryToBezier( 337 | geom, start_anchoridx, scale, last=False) 338 | self.history.append( 339 | {"state": "insert_geom", "pointidx": start_anchoridx, "pointnum": pointnum, "cp_first": cp_first, 340 | "cp_last": cp_last}) 341 | # modify polygon 342 | elif layer_type == QgsWkbTypes.PolygonGeometry and lastpnt_is_near and last_vertexidx <= start_vertexidx: 343 | polyline = point_list[start_anchoridx - 1][0:self._pointListIdx(start_vertexidx)] + update_line + \ 344 | point_list[last_anchoridx - 345 | 1][self._pointListIdx(last_vertexidx):] 346 | geom = self._smoothingGeometry(polyline) 347 | for i in range(start_anchoridx, self.anchorCount()): 348 | self.history.append( 349 | {"state": "delete_anchor", 350 | "pointidx": start_anchoridx, 351 | "point": self.getAnchor(start_anchoridx), 352 | "ctrlpoint0": self.getHandle(start_anchoridx * 2), 353 | "ctrlpoint1": self.getHandle(start_anchoridx * 2 + 1) 354 | } 355 | ) 356 | self._deleteAnchor(start_anchoridx) 357 | 358 | pointnum, cp_first, cp_last = self._convertGeometryToBezier( 359 | geom, start_anchoridx, scale, last=True) 360 | self.history.append( 361 | {"state": "insert_geom", "pointidx": start_anchoridx, "pointnum": pointnum, "cp_first": cp_first, 362 | "cp_last": cp_last}) 363 | for i in range(0, last_anchoridx): 364 | self.history.append( 365 | {"state": "delete_anchor", 366 | "pointidx": 0, 367 | "point": self.getAnchor(0), 368 | "ctrlpoint0": self.getHandle(0), 369 | "ctrlpoint1": self.getHandle(1) 370 | } 371 | ) 372 | self._deleteAnchor(0) 373 | 374 | # modify end line, return to backward, end line is near of last anchor 375 | elif not lastpnt_is_near or (lastpnt_is_near and last_vertexidx <= start_vertexidx) or last_anchoridx > len( 376 | point_list): 377 | 378 | if start_anchoridx == self.anchorCount(): 379 | polyline = update_line 380 | else: 381 | polyline = point_list[start_anchoridx - 382 | 1][0:self._pointListIdx(start_vertexidx)] + update_line 383 | last_anchoridx = self.anchorCount() 384 | 385 | geom = self._smoothingGeometry(polyline) 386 | for i in range(start_anchoridx, last_anchoridx): 387 | self.history.append( 388 | {"state": "delete_anchor", 389 | "pointidx": start_anchoridx, 390 | "point": self.getAnchor(start_anchoridx), 391 | "ctrlpoint0": self.getHandle(start_anchoridx * 2), 392 | "ctrlpoint1": self.getHandle(start_anchoridx * 2 + 1) 393 | } 394 | ) 395 | self._deleteAnchor(start_anchoridx) 396 | 397 | pointnum, cp_first, cp_last = self._convertGeometryToBezier( 398 | geom, start_anchoridx, scale, last=True) 399 | self.history.append( 400 | {"state": "insert_geom", "pointidx": start_anchoridx, "pointnum": pointnum, "cp_first": cp_first, 401 | "cp_last": cp_last}) 402 | 403 | self.history.append( 404 | {"state": "end_freehand", "direction": "forward"}) 405 | # return to direction 406 | if direction < 0: 407 | self._flipBezierLine() 408 | self.history[-1]["direction"] = "reverse" 409 | 410 | # If it was snapped to the start point, move the last point shifted to the first point for smooth processing 411 | if snap_to_start: 412 | self._moveAnchor(self.anchorCount() - 1, self.getAnchor(0)) 413 | 414 | def split_line(self, idx, point, isAnchor): 415 | """ 416 | return two bezier line split at point 417 | """ 418 | # if split position is on anchor 419 | point = self._trans(point) 420 | if isAnchor: 421 | lineA = self.points[0:self._pointsIdx(idx) + 1] 422 | lineB = self.points[self._pointsIdx(idx):] 423 | # if split position is on line, insert anchor at the position first 424 | else: 425 | anchor_idx = self._AnchorIdx(idx) 426 | self._insertAnchorPointToBezier(idx, anchor_idx, point) 427 | lineA = self.points[0:self._pointsIdx(anchor_idx) + 1] 428 | lineB = self.points[self._pointsIdx(anchor_idx):] 429 | lineA = [self._trans(p, revert=True) for p in lineA] 430 | lineB = [self._trans(p, revert=True) for p in lineB] 431 | 432 | return lineA, lineB 433 | 434 | def anchorCount(self): 435 | return len(self.anchor) 436 | 437 | def getAnchorList(self, revert=False): 438 | if revert: 439 | anchorList = [self._trans(p, revert=True) for p in self.anchor] 440 | else: 441 | anchorList = self.anchor 442 | return anchorList 443 | 444 | def getAnchor(self, idx, revert=False): 445 | p = self.anchor[idx] 446 | if revert: 447 | p = self._trans(p, revert=True) 448 | return p 449 | 450 | def getHandleList(self, revert=False): 451 | if revert: 452 | handleList = [self._trans(p, revert=True) for p in self.handle] 453 | else: 454 | handleList = self.handle 455 | return handleList 456 | 457 | def getHandle(self, idx, revert=False): 458 | p = self.handle[idx] 459 | if revert: 460 | p = self._trans(p, revert=True) 461 | return p 462 | 463 | def getPointList(self, revert=False): 464 | if revert: 465 | pointList = [self._trans(p, revert=True) for p in self.points] 466 | else: 467 | pointList = self.points 468 | return pointList 469 | 470 | def reset(self): 471 | self.points = [] 472 | self.anchor = [] 473 | self.handle = [] 474 | self.history = [] 475 | 476 | def checkSnapToAnchor(self, point, clicked_idx, d): 477 | point = self._trans(point) 478 | snapped = False 479 | snap_point = None 480 | snap_idx = None 481 | for i, p in reversed(list(enumerate(self.anchor))): 482 | near = self._eachPointIsNear(p, point, d) 483 | # if anchor is not moving 484 | if clicked_idx is None: 485 | if near: 486 | snapped = True 487 | snap_idx = i 488 | snap_point = self._trans(p, revert=True) 489 | break 490 | # if the anchor is moving, except for snapping to itself 491 | elif clicked_idx != i: 492 | if near: 493 | snapped = True 494 | snap_idx = i 495 | snap_point = self._trans(p, revert=True) 496 | break 497 | return snapped, snap_point, snap_idx 498 | 499 | def checkSnapToHandle(self, point, d): 500 | point = self._trans(point) 501 | snapped = False 502 | snap_point = None 503 | snap_idx = None 504 | for i, p in reversed(list(enumerate(self.handle))): 505 | if i == 0 or i == len(self.handle)-1: 506 | continue 507 | near = self._eachPointIsNear(p, point, d) 508 | if near: 509 | snapped = True 510 | snap_idx = i 511 | snap_point = self._trans(p, revert=True) 512 | break 513 | return snapped, snap_point, snap_idx 514 | 515 | def checkSnapToLine(self, point, d): 516 | point = self._trans(point) 517 | snapped = False 518 | snap_point = None 519 | snap_idx = None 520 | if self.anchorCount() > 1: 521 | geom = QgsGeometry.fromPolylineXY(self.points) 522 | (dist, minDistPoint, afterVertex, 523 | leftOf) = geom.closestSegmentWithContext(point) 524 | if math.sqrt(dist) < d: 525 | snapped = True 526 | snap_idx = afterVertex 527 | snap_point = self._trans(minDistPoint, revert=True) 528 | 529 | return snapped, snap_point, snap_idx 530 | 531 | def checkSnapToStart(self, point, d): 532 | point = self._trans(point) 533 | snapped = False 534 | snap_point = None 535 | snap_idx = None 536 | if self.anchorCount() > 0: 537 | start_anchor = self.getAnchor(0) 538 | near = self._eachPointIsNear(start_anchor, point, d) 539 | if near: 540 | snapped = True 541 | snap_idx = 0 542 | snap_point = self._trans(start_anchor, revert=True) 543 | 544 | return snapped, snap_point, snap_idx 545 | 546 | def undo(self): 547 | """ 548 | do invert process from history 549 | """ 550 | if len(self.history) > 0: 551 | act = self.history.pop() 552 | if act["state"] == "add_anchor": 553 | self._deleteAnchor(act["pointidx"]) 554 | elif act["state"] == "move_anchor": 555 | self._moveAnchor(act["pointidx"], act["point"]) 556 | elif act["state"] == "move_anchor2": 557 | self._moveAnchor(act["pointidx"], act["point"]) 558 | self._moveAnchor(0, act["point"]) 559 | elif act["state"] == "move_handle": 560 | self._moveHandle(act["pointidx"], act["point"]) 561 | elif act["state"] == "insert_anchor": 562 | self._deleteAnchor(act["pointidx"]) 563 | self._moveHandle((act["pointidx"] - 1) * 564 | 2 + 1, act["ctrlpoint0"]) 565 | self._moveHandle((act["pointidx"] - 1) * 566 | 2 + 2, act["ctrlpoint1"]) 567 | elif act["state"] == "delete_anchor": 568 | self._addAnchor(act["pointidx"], act["point"]) 569 | self._moveHandle(act["pointidx"] * 2, act["ctrlpoint0"]) 570 | self._moveHandle(act["pointidx"] * 2 + 1, act["ctrlpoint1"]) 571 | elif act["state"] == "delete_anchor2": 572 | self._deleteAnchor(self.anchorCount()-1) 573 | self._addAnchor(0, act["point"]) 574 | self._moveHandle(1, act["ctrlpoint0"]) 575 | self._addAnchor(act["pointidx"], act["point"]) 576 | self._moveHandle(act["pointidx"] * 2, act["ctrlpoint1"]) 577 | elif act["state"] == "delete_handle": 578 | self._moveHandle(act["pointidx"], act["point"]) 579 | elif act["state"] == "flip_line": 580 | self._flipBezierLine() 581 | self.undo() 582 | elif act["state"] == "end_freehand": 583 | direction = act["direction"] 584 | if direction == "reverse": 585 | self._flipBezierLine() 586 | act = self.history.pop() 587 | while act["state"] != "start_freehand": 588 | if act["state"] == "insert_geom": 589 | for i in range(act["pointnum"]): 590 | self._deleteAnchor(act["pointidx"]) 591 | if act["cp_first"] is not None: 592 | self._moveHandle( 593 | act["pointidx"] * 2 - 1, act["cp_first"]) 594 | if act["cp_last"] is not None: 595 | self._moveHandle( 596 | act["pointidx"] * 2, act["cp_last"]) 597 | elif act["state"] == "delete_anchor": 598 | self._addAnchor(act["pointidx"], act["point"]) 599 | self._moveHandle( 600 | act["pointidx"] * 2, act["ctrlpoint0"]) 601 | self._moveHandle( 602 | act["pointidx"] * 2 + 1, act["ctrlpoint1"]) 603 | act = self.history.pop() 604 | if direction == "reverse": 605 | self._flipBezierLine() 606 | 607 | # self.dump_history() 608 | return len(self.history) 609 | 610 | def _eachPointIsNear(self, snap_point, point, d): 611 | near = False 612 | if (snap_point.x() - d <= point.x() <= snap_point.x() + d) and ( 613 | snap_point.y() - d <= point.y() <= snap_point.y() + d): 614 | near = True 615 | return near 616 | 617 | def _insertAnchorPointToBezier(self, point_idx, anchor_idx, point): 618 | """ 619 | insert anchor to bezier line. move handle for not changing bezier curve 620 | """ 621 | c1a, c2a, c1b, c2b = self._recalcHandlePosition( 622 | point_idx, anchor_idx, point) 623 | self._addAnchor(anchor_idx, point) 624 | self._moveHandle((anchor_idx - 1) * 2 + 1, c1a) 625 | self._moveHandle((anchor_idx - 1) * 2 + 2, c2a) 626 | self._moveHandle((anchor_idx - 1) * 2 + 3, c1b) 627 | self._moveHandle((anchor_idx - 1) * 2 + 4, c2b) 628 | 629 | def _convertGeometryToBezier(self, geom, offset, scale, last=True): 630 | """ 631 | convert geometry to anchor and handle list by fitCurve, then add it to bezier line 632 | if last=F, don't insert last point 633 | """ 634 | polyline = geom.asPolyline() 635 | points = np.array(polyline) 636 | # This expression returns the same point distance at any scale. 637 | # This value was determined by a manual test. 638 | maxError = 25**(math.log(scale/2000, 5)) 639 | beziers = fitCurve(points, maxError) 640 | pointnum = 0 641 | 642 | if offset != 0: 643 | cp_first = self.getHandle(offset * 2 - 1) 644 | else: 645 | cp_first = None 646 | if last == False: 647 | cp_last = self.getHandle(offset * 2) 648 | else: 649 | cp_last = None 650 | 651 | for i, bezier in enumerate(beziers): 652 | if offset == 0: 653 | if i == 0: 654 | p0 = QgsPointXY(bezier[0][0], bezier[0][1]) 655 | self._addAnchor(0, p0) 656 | pointnum = pointnum + 1 657 | p1 = QgsPointXY(bezier[3][0], bezier[3][1]) 658 | c1 = QgsPointXY(bezier[1][0], bezier[1][1]) 659 | c2 = QgsPointXY(bezier[2][0], bezier[2][1]) 660 | self._moveHandle(i * 2 + 1, c1) 661 | self._addAnchor(i + 1, p1) 662 | self._moveHandle((i + 1) * 2, c2) 663 | pointnum = pointnum + 1 664 | 665 | elif offset > 0: 666 | p1 = QgsPointXY(bezier[3][0], bezier[3][1]) 667 | c1 = QgsPointXY(bezier[1][0], bezier[1][1]) 668 | c2 = QgsPointXY(bezier[2][0], bezier[2][1]) 669 | idx = (offset - 1 + i) * 2 + 1 670 | self._moveHandle(idx, c1) 671 | 672 | if i != len(beziers) - 1 or last: 673 | self._addAnchor(offset + i, p1) 674 | pointnum = pointnum + 1 675 | self._moveHandle(idx + 1, c2) 676 | 677 | return pointnum, cp_first, cp_last 678 | 679 | def _addAnchor(self, idx, point): 680 | """ 681 | insert anchor at idx and recalc bezier line. both handle also added at the same position of anchor 682 | """ 683 | if idx == -1: 684 | idx = self.anchorCount() 685 | self.anchor.insert(idx, point) 686 | self.handle.insert(idx * 2, point) 687 | self.handle.insert(idx * 2, point) 688 | pointsA = [] 689 | pointsB = [] 690 | # calc bezier line of right side of the anchor. 691 | if idx < self.anchorCount() - 1: 692 | p1 = self.getAnchor(idx) 693 | p2 = self.getAnchor(idx + 1) 694 | c1 = self.getHandle(idx * 2 + 1) 695 | c2 = self.getHandle(idx * 2 + 2) 696 | pointsA = self._bezier(p1, c1, p2, c2) 697 | # calc bezier line of left side of the anchor 698 | if idx >= 1: 699 | p1 = self.getAnchor(idx - 1) 700 | p2 = self.getAnchor(idx) 701 | c1 = self.getHandle(idx * 2 - 1) 702 | c2 = self.getHandle(idx * 2) 703 | pointsB = self._bezier(p1, c1, p2, c2) 704 | 705 | # first anchor 706 | if idx == 0 and len(pointsA) == 0: 707 | self.points = copy.copy(self.anchor) 708 | # the case of undo that of polygon's first anchor delete 709 | elif idx == 0 and len(pointsA) > 0: 710 | self.points = pointsA[0:-1] + self.points 711 | # second anchor 712 | elif idx == 1 and idx == self.anchorCount() - 1: 713 | self.points = pointsB 714 | # third point and after 715 | elif idx >= 2 and idx == self.anchorCount() - 1: 716 | self.points = self.points + pointsB[1:] 717 | # insert anchor 718 | else: 719 | self.points[self._pointsIdx( 720 | idx - 1):self._pointsIdx(idx) + 1] = pointsB + pointsA[1:] 721 | 722 | def _deleteAnchor(self, idx): 723 | # first anchor 724 | if idx == 0: 725 | del self.points[0:self.INTERPOLATION] 726 | # end anchor 727 | elif idx + 1 == self.anchorCount(): 728 | del self.points[self._pointsIdx(idx - 1) + 1:] 729 | else: 730 | p1 = self.getAnchor(idx - 1) 731 | p2 = self.getAnchor(idx + 1) 732 | c1 = self.getHandle((idx - 1) * 2 + 1) 733 | c2 = self.getHandle((idx + 1) * 2) 734 | points = self._bezier(p1, c1, p2, c2) 735 | self.points[self._pointsIdx( 736 | idx - 1):self._pointsIdx(idx + 1) + 1] = points 737 | self._delHandle(2 * idx) 738 | self._delHandle(2 * idx) 739 | self._delAnchor(idx) 740 | 741 | return 742 | 743 | def _moveAnchor(self, idx, point): 744 | diff = point - self.getAnchor(idx) 745 | self._setAnchor(idx, point) 746 | self._setHandle(idx * 2, self.getHandle(idx * 2) + diff) 747 | self._setHandle(idx * 2 + 1, self.getHandle(idx * 2 + 1) + diff) 748 | # if only one anchor 749 | if idx == 0 and self.anchorCount() == 1: 750 | self.points = copy.copy(self.anchor) 751 | else: 752 | # calc bezier line of right side of the anchor. 753 | if idx < self.anchorCount() - 1: 754 | p1 = self.getAnchor(idx) 755 | p2 = self.getAnchor(idx + 1) 756 | c1 = self.getHandle(idx * 2 + 1) 757 | c2 = self.getHandle(idx * 2 + 2) 758 | points = self._bezier(p1, c1, p2, c2) 759 | self.points[self._pointsIdx( 760 | idx):self._pointsIdx(idx + 1) + 1] = points 761 | # calc bezier line of left side of the anchor. 762 | if idx >= 1: 763 | p1 = self.getAnchor(idx - 1) 764 | p2 = self.getAnchor(idx) 765 | c1 = self.getHandle(idx * 2 - 1) 766 | c2 = self.getHandle(idx * 2) 767 | points = self._bezier(p1, c1, p2, c2) 768 | self.points[self._pointsIdx( 769 | idx - 1):self._pointsIdx(idx) + 1] = points 770 | 771 | def _moveHandle(self, idx, point): 772 | self._setHandle(idx, point) 773 | if self.anchorCount() > 1: 774 | # right side handle 775 | if idx % 2 == 1 and idx < self._handleCount() - 1: 776 | idxP = idx // 2 777 | p1 = self.getAnchor(idxP) 778 | p2 = self.getAnchor(idxP + 1) 779 | c1 = self.getHandle(idx) 780 | c2 = self.getHandle(idx + 1) 781 | # left side handle 782 | elif idx % 2 == 0 and idx >= 1: 783 | idxP = (idx - 1) // 2 784 | p1 = self.getAnchor(idxP) 785 | p2 = self.getAnchor(idxP + 1) 786 | c1 = self.getHandle(idx - 1) 787 | c2 = self.getHandle(idx) 788 | else: 789 | return 790 | points = self._bezier(p1, c1, p2, c2) 791 | self.points[self._pointsIdx( 792 | idxP):self._pointsIdx(idxP + 1) + 1] = points 793 | 794 | def _recalcHandlePosition(self, point_idx, anchor_idx, pnt): 795 | """ 796 | Recalculate handle positions on both sides from point list between anchors when adding anchors to Bezier curve 797 | """ 798 | bezier_idx = self._pointListIdx(point_idx) 799 | 800 | # calc handle position of left size of anchor 801 | # If point counts of left side of insert point are 4 points or more, handle position can be recalculated . 802 | if 2 < bezier_idx: 803 | pointsA = self.points[self._pointsIdx( 804 | anchor_idx - 1):point_idx] + [pnt] 805 | ps, cs, pe, ce = self._convertPointListToAnchorAndHandle(pointsA) 806 | c1a = QgsPointXY(cs[0], cs[1]) 807 | c2a = QgsPointXY(ce[0], ce[1]) 808 | # If it is less than 4 points, make the position of the handle the same as the anchor. 809 | # and then connect with a straight line 810 | else: 811 | c1a = self.points[self._pointsIdx(anchor_idx - 1)] 812 | c2a = pnt 813 | # calc handle position of right size of anchor 814 | # The way of thinking is the same as the left side 815 | if self.INTERPOLATION - 1 > bezier_idx: 816 | pointsB = [pnt] + \ 817 | self.points[point_idx:self._pointsIdx(anchor_idx) + 1] 818 | ps, cs, pe, ce = self._convertPointListToAnchorAndHandle( 819 | pointsB, type="B") 820 | c1b = QgsPointXY(cs[0], cs[1]) 821 | c2b = QgsPointXY(ce[0], ce[1]) 822 | else: 823 | c1b = pnt 824 | c2b = self.points[self._pointsIdx(anchor_idx)] 825 | 826 | return (c1a, c2a, c1b, c2b) 827 | 828 | def _bezier(self, p1, c1, p2, c2): 829 | """ 830 | Returns a list of Bezier line points defined by the start and end point anchors and handles 831 | """ 832 | points = [] 833 | for t in range(0, self.INTERPOLATION + 1): 834 | t = 1.0 * t / self.INTERPOLATION 835 | bx = (1 - t) ** 3 * p1.x() + 3 * t * (1 - t) ** 2 * \ 836 | c1.x() + 3 * t ** 2 * (1 - t) * c2.x() + t ** 3 * p2.x() 837 | by = (1 - t) ** 3 * p1.y() + 3 * t * (1 - t) ** 2 * \ 838 | c1.y() + 3 * t ** 2 * (1 - t) * c2.y() + t ** 3 * p2.y() 839 | points.append(QgsPointXY(bx, by)) 840 | return points 841 | 842 | def _invertBezierPointListToBezier(self, point_list): 843 | """ 844 | invert from the Bezier pointList to anchor and handle coordinate 845 | """ 846 | for i, points_i in enumerate(point_list): 847 | ps, cs, pe, ce = self._convertPointListToAnchorAndHandle(points_i) 848 | p0 = QgsPointXY(ps[0], ps[1]) 849 | p1 = QgsPointXY(pe[0], pe[1]) 850 | c0 = QgsPointXY(ps[0], ps[1]) 851 | c1 = QgsPointXY(cs[0], cs[1]) 852 | c2 = QgsPointXY(ce[0], ce[1]) 853 | c3 = QgsPointXY(pe[0], pe[1]) 854 | if i == 0: 855 | self._addAnchor(-1, p0) 856 | self._moveHandle(i * 2, c0) 857 | self._moveHandle(i * 2 + 1, c1) 858 | self._addAnchor(-1, p1) 859 | self._moveHandle((i + 1) * 2, c2) 860 | self._moveHandle((i + 1) * 2 + 1, c3) 861 | else: 862 | self._moveHandle(i * 2 + 1, c1) 863 | self._addAnchor(-1, p1) 864 | self._moveHandle((i + 1) * 2, c2) 865 | self._moveHandle((i + 1) * 2 + 1, c3) 866 | 867 | def _convertPointListToAnchorAndHandle(self, points, type="A"): 868 | """ 869 | convert to anchor and handle coordinate from the element of pointList 870 | the element of pointList is points between anchor to anchor 871 | it is solved the equation from the coordinates of t1 and t2 872 | type B solves a system of equations using the last two points. It is used for right side processing when inserting. 873 | """ 874 | 875 | ps = np.array(points[0]) 876 | pe = np.array(points[-1]) 877 | 878 | tnum = len(points) - 1 879 | if type == "A": 880 | t1 = 1.0 / tnum 881 | p1 = np.array(points[1]) 882 | t2 = 2.0 / tnum 883 | p2 = np.array(points[2]) 884 | elif type == "B": 885 | t1 = (tnum - 1) / tnum 886 | p1 = np.array(points[-2]) 887 | t2 = (tnum - 2) / tnum 888 | p2 = np.array(points[-3]) 889 | 890 | aa = 3 * t1 * (1 - t1) ** 2 891 | bb = 3 * t1 ** 2 * (1 - t1) 892 | cc = ps * (1 - t1) ** 3 + pe * t1 ** 3 - p1 893 | dd = 3 * t2 * (1 - t2) ** 2 894 | ee = 3 * t2 ** 2 * (1 - t2) 895 | ff = ps * (1 - t2) ** 3 + pe * t2 ** 3 - p2 896 | c0 = (bb * ff - cc * ee) / (aa * ee - bb * dd) 897 | c1 = (aa * ff - cc * dd) / (bb * dd - aa * ee) 898 | return ps, c0, pe, c1 899 | 900 | def _flipBezierLine(self): 901 | self.anchor.reverse() 902 | self.handle.reverse() 903 | self.points.reverse() 904 | 905 | def _lineToInterpolatePointList(self, polyline): 906 | 907 | return [self._bezier(polyline[i], polyline[i], polyline[i+1], polyline[i+1]) for i in range(0, len(polyline)-1)] 908 | 909 | def _lineToPointList(self, polyline): 910 | """ 911 | convert to pointList from polyline. pointList is points list between anchor to anchor 912 | The number of elements in pointList is INTERPOLATION + 1 because each anchor overlaps. 913 | """ 914 | return [polyline[i:i + self.INTERPOLATION + 1] for i in range(0, len(polyline), self.INTERPOLATION)][:-1] 915 | 916 | def _pointListIdx(self, point_idx): 917 | """ 918 | convert to pointList idx from bezier line points idx 919 | """ 920 | return (point_idx - 1) % self.INTERPOLATION + 1 921 | 922 | def _pointsIdx(self, anchor_idx): 923 | """ 924 | convert to bezier line points idx from anchor idx 925 | """ 926 | return anchor_idx * self.INTERPOLATION 927 | 928 | def _AnchorIdx(self, point_idx): 929 | """ 930 | convert to bezier anchor idx from bezier line points idx 931 | It is the first anchor behind point. 932 | """ 933 | return (point_idx - 1) // self.INTERPOLATION + 1 934 | 935 | def _setAnchor(self, idx, point): 936 | self.anchor[idx] = point 937 | 938 | def _delAnchor(self, idx): 939 | del self.anchor[idx] 940 | 941 | def _handleCount(self): 942 | return len(self.handle) 943 | 944 | def _setHandle(self, idx, point): 945 | self.handle[idx] = point 946 | 947 | def _delHandle(self, idx): 948 | del self.handle[idx] 949 | 950 | def _closestAnchorOfGeometry(self, point, geom, d): 951 | """ 952 | return anchor idx and vertex idx which is closest point with bezier line 953 | """ 954 | near = False 955 | (dist, minDistPoint, vertexidx, leftOf) = geom.closestSegmentWithContext(point) 956 | anchoridx = self._AnchorIdx(vertexidx) 957 | if math.sqrt(dist) < d: 958 | near = True 959 | return near, anchoridx, vertexidx 960 | 961 | def _smoothing(self, polyline): 962 | """ 963 | smoothing by moving average 964 | """ 965 | poly = np.reshape(polyline, (-1, 2)).T 966 | num = 8 967 | b = np.ones(num) / float(num) 968 | x_pad = np.pad(poly[0], (num - 1, 0), 'edge') 969 | y_pad = np.pad(poly[1], (num - 1, 0), 'edge') 970 | x_smooth = np.convolve(x_pad, b, mode='valid') 971 | y_smooth = np.convolve(y_pad, b, mode='valid') 972 | poly_smooth = [QgsPointXY(x, y) for x, y in zip(x_smooth, y_smooth)] 973 | return poly_smooth 974 | 975 | def _smoothingGeometry(self, polyline): 976 | """ 977 | convert polyline to smoothing geometry 978 | """ 979 | #polyline = self._smoothing(polyline) 980 | geom = QgsGeometry.fromPolylineXY(polyline) 981 | smooth_geom = geom.smooth() 982 | return smooth_geom 983 | 984 | def _trans(self, p, revert=False): 985 | if self.projectCRS.projectionAcronym() == "longlat" and revert == False: 986 | # check latitude exceeded limits 987 | if p.y() > 90: 988 | p = QgsPointXY(p.x(), 89.9999999) 989 | elif p.y() < -90: 990 | p = QgsPointXY(p.x(), -89.9999999) 991 | 992 | destCrs = QgsCoordinateReferenceSystem("EPSG:3857") 993 | if revert: 994 | tr = QgsCoordinateTransform( 995 | destCrs, self.projectCRS, QgsProject.instance()) 996 | else: 997 | tr = QgsCoordinateTransform( 998 | self.projectCRS, destCrs, QgsProject.instance()) 999 | p = tr.transform(p) 1000 | return p 1001 | 1002 | def _transgeom(self, geom, revert=False): 1003 | g = QgsGeometry(geom) 1004 | destCrs = QgsCoordinateReferenceSystem("EPSG:3857") 1005 | if revert: 1006 | tr = QgsCoordinateTransform( 1007 | destCrs, self.projectCRS, QgsProject.instance()) 1008 | else: 1009 | tr = QgsCoordinateTransform( 1010 | self.projectCRS, destCrs, QgsProject.instance()) 1011 | g.transform(tr) 1012 | return g 1013 | 1014 | # for debug 1015 | def dump_history(self): 1016 | self.log("##### history dump ######") 1017 | for h in self.history: 1018 | self.log("{}".format(h.items())) 1019 | self.log("##### end ######") 1020 | 1021 | def log(self, msg): 1022 | QgsMessageLog.logMessage(msg, 'MyPlugin', Qgis.Info) 1023 | -------------------------------------------------------------------------------- /beziereditingtool.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """" 3 | /*************************************************************************** 4 | BezierEditing 5 | -------------------------------------- 6 | Date : 01 05 2019 7 | Copyright : (C) 2019 Takayuki Mizutani 8 | Email : mizutani at ecoris dot co dot jp 9 | *************************************************************************** 10 | * * 11 | * This program is free software; you can redistribute it and/or modify * 12 | * it under the terms of the GNU General Public License as published by * 13 | * the Free Software Foundation; either version 2 of the License, or * 14 | * (at your option) any later version. * 15 | * * 16 | ***************************************************************************/ 17 | """ 18 | from qgis.PyQt.QtCore import Qt 19 | from qgis.PyQt.QtCore import QObject, QLocale, QTranslator, QCoreApplication, QPointF 20 | from qgis.PyQt.QtGui import QColor, QCursor, QPixmap, QFont, QTextDocument, QIcon 21 | from qgis.PyQt.QtWidgets import QApplication, QAction, QAbstractButton, QGraphicsItemGroup, QMenu, QInputDialog, QMessageBox, QPushButton 22 | from qgis.core import QgsSettingsRegistryCore, QgsSettingsEntryBool, QgsWkbTypes, QgsProject, QgsVectorLayer, QgsGeometry, QgsPointXY, QgsFeature, QgsEditFormConfig, QgsFeatureRequest, QgsDistanceArea, QgsRectangle, QgsVectorLayerUtils, Qgis, QgsAction, QgsApplication, QgsMapLayer, QgsCoordinateTransform, QgsExpressionContextScope, QgsSettings, QgsMarkerSymbol, QgsTextAnnotation, QgsMessageLog 23 | from qgis.gui import QgsAttributeEditorContext, QgsMapTool, QgsAttributeDialog, QgsRubberBand, QgsAttributeForm, QgsVertexMarker, QgsHighlight, QgsMapCanvasAnnotationItem 24 | from .BezierGeometry import * 25 | from .BezierMarker import * 26 | import math 27 | import numpy as np 28 | from typing import Dict, Any, List 29 | 30 | 31 | class BezierEditingTool(QgsMapTool): 32 | 33 | sLastUsedValues: Dict[str, Dict[int, Any]] = dict() 34 | 35 | def __init__(self, canvas, iface): 36 | QgsMapTool.__init__(self, canvas) 37 | 38 | # qgis interface 39 | self.iface = iface 40 | self.canvas = canvas 41 | self.canvas.destinationCrsChanged.connect(self.crsChanged) 42 | # freehand tool line 43 | self.freehand_rbl = QgsRubberBand( 44 | self.canvas, QgsWkbTypes.LineGeometry) 45 | self.freehand_rbl.setColor(QColor(255, 0, 0, 150)) 46 | self.freehand_rbl.setWidth(2) 47 | # snap marker 48 | self.snap_mark = QgsVertexMarker(self.canvas) 49 | self.snap_mark.setColor(QColor(0, 0, 255)) 50 | self.snap_mark.setPenWidth(2) 51 | self.snap_mark.setIconType(QgsVertexMarker.ICON_BOX) 52 | self.snap_mark.setIconSize(10) 53 | self.snap_mark.hide() 54 | # snap guide line 55 | self.guide_rbl = QgsRubberBand(self.canvas, QgsWkbTypes.LineGeometry) 56 | self.guide_rbl.setColor(QColor(0, 0, 255, 150)) 57 | self.guide_rbl.setWidth(1) 58 | # rectangle selection for unsplit 59 | self.rubberBand = QgsRubberBand( 60 | self.canvas, QgsWkbTypes.PolygonGeometry) 61 | self.rubberBand.setColor(QColor(255, 0, 0, 100)) 62 | self.rubberBand.setWidth(1) 63 | 64 | # cursor icon 65 | self.addanchor_cursor = QCursor( 66 | QPixmap(':/plugins/BezierEditing/icon/anchor.svg'), 1, 1) 67 | self.insertanchor_cursor = QCursor( 68 | QPixmap(':/plugins/BezierEditing/icon/anchor_add.svg'), 1, 1) 69 | self.deleteanchor_cursor = QCursor( 70 | QPixmap(':/plugins/BezierEditing/icon/anchor_del.svg'), 1, 1) 71 | self.movehandle_cursor = QCursor( 72 | QPixmap(':/plugins/BezierEditing/icon/handle.svg'), 1, 1) 73 | self.addhandle_cursor = QCursor( 74 | QPixmap(':/plugins/BezierEditing/icon/handle_add.svg'), 1, 1) 75 | self.deletehandle_cursor = QCursor( 76 | QPixmap(':/plugins/BezierEditing/icon/handle_del.svg'), 1, 1) 77 | self.drawline_cursor = QCursor( 78 | QPixmap(':/plugins/BezierEditing/icon/drawline.svg'), 1, 1) 79 | self.split_cursor = QCursor( 80 | QPixmap(':/plugins/BezierEditing/icon/mCrossHair.svg'), -1, -1) 81 | self.unsplit_cursor = QCursor(Qt.ArrowCursor) 82 | 83 | # initialize variable 84 | self.mode = "bezier" # [bezier, freehand , split, unsplit] 85 | # [free, add_anchor,move_anchor,move_handle,insert_anchor,draw_line,drawing_freehand] 86 | self.mouse_state = "free" 87 | self.editing = False # in bezier editing or not 88 | self.freehand_drawing = False # Track if we're in freehand drawing mode 89 | self.freehand_streaming = False # Streaming mode for freehand tool (will be loaded from settings) 90 | self.snapping = None # in snap setting or not 91 | self.show_handle = True # show handle or not 92 | self.editing_feature_id = None # bezier editing feature id 93 | self.editing_geom_type = None # bezier editing geom type 94 | self.clicked_idx = None # clicked anchor or handle idx 95 | self.bg = None # BezierGeometry 96 | self.bm = None # BezierMarker 97 | 98 | # smart guide 99 | self.guideLabelGroup = None 100 | self.smartGuideOn = False 101 | self.snapToLengthUnit = 0 102 | self.snapToAngleUnit = 0 103 | self.generate_menu() 104 | 105 | # interpolation number 106 | s = QgsSettings() 107 | BezierGeometry.INTERPOLATION = int( 108 | s.value("BezierEditing/INTERPOLATION", 10)) 109 | # Load streaming mode setting 110 | streaming_val = s.value("BezierEditing/freehand_streaming", False) 111 | if streaming_val is None: 112 | self.freehand_streaming = False 113 | elif isinstance(streaming_val, str): 114 | self.freehand_streaming = streaming_val.lower() == "true" 115 | else: 116 | self.freehand_streaming = bool(streaming_val) 117 | 118 | def tr(self, message): 119 | return QCoreApplication.translate('BezierEditingTool', message) 120 | 121 | def crsChanged(self): 122 | if self.bg is not None: 123 | self.iface.messageBar().pushMessage( 124 | self.tr("Warning"), self.tr("Reset editing data"), level=Qgis.Warning) 125 | self.resetEditing() 126 | self.checkCRS() 127 | 128 | def canvasPressEvent(self, event): 129 | modifiers = QApplication.keyboardModifiers() 130 | layer = self.canvas.currentLayer() 131 | if not layer or layer.type() != QgsMapLayer.VectorLayer: 132 | return 133 | self.checkSnapSetting() 134 | mouse_point, snapped, snap_point, snap_idx = self.getSnapPoint(event) 135 | # bezier tool 136 | if self.mode == "bezier": 137 | # right click 138 | if event.button() == Qt.RightButton: 139 | if bool(modifiers & Qt.ControlModifier): 140 | self.menu.exec_(QCursor.pos()) 141 | # left click 142 | elif event.button() == Qt.LeftButton: 143 | # with ctrl 144 | if bool(modifiers & Qt.ControlModifier): 145 | # if click on anchor with ctrl, force to add anchor not moving anchor 146 | if snapped[1]: 147 | if self.editing_geom_type == QgsWkbTypes.PolygonGeometry: 148 | return 149 | self.mouse_state = "add_anchor" 150 | self.clicked_idx = self.bg.anchorCount() 151 | self.bg.add_anchor(self.clicked_idx, snap_point[1]) 152 | self.bm.add_anchor(self.clicked_idx, snap_point[1]) 153 | # add the anchor snapped by guide. guide is on by ctrl 154 | else: 155 | if self.editing_geom_type == QgsWkbTypes.PolygonGeometry: 156 | return 157 | if not self.editing: 158 | self.bg = BezierGeometry(self.projectCRS) 159 | self.bm = BezierMarker(self.canvas, self.bg) 160 | self.editing = True 161 | self.mouse_state = "add_anchor" 162 | self.clicked_idx = self.bg.anchorCount() 163 | self.bg.add_anchor(self.clicked_idx, snap_point[0]) 164 | self.bm.add_anchor(self.clicked_idx, snap_point[0]) 165 | # with alt 166 | elif bool(modifiers & Qt.AltModifier): 167 | # if click on anchor with alt, move out a handle from anchor 168 | if snapped[2] and snapped[1]: 169 | self.mouse_state = "move_handle" 170 | self.clicked_idx = snap_idx[2] 171 | # if click on bezier line with alt, insert anchor in bezier line 172 | elif snapped[3] and not snapped[1]: 173 | self.mouse_state = "insert_anchor" 174 | self.bg.insert_anchor(snap_idx[3], snap_point[3]) 175 | self.bm.show() 176 | # if click on handle, move handle 177 | elif snapped[2]: 178 | self.mouse_state = "move_handle" 179 | self.clicked_idx = snap_idx[2] 180 | self.bg.move_handle(snap_idx[2], snap_point[2]) 181 | self.bm.move_handle(snap_idx[2], snap_point[2]) 182 | 183 | # with shift 184 | elif bool(modifiers & Qt.ShiftModifier): 185 | # if click on anchor with shift, delete anchor from bezier line 186 | if snapped[1]: 187 | # polygon's first anchor 188 | if self.editing_geom_type == QgsWkbTypes.PolygonGeometry and snap_idx[1] == self.bg.anchorCount()-1: 189 | self.bg.delete_anchor2(snap_idx[1], snap_point[1]) 190 | self.bm.delete_anchor(snap_idx[1]) 191 | self.bm.delete_anchor(0) 192 | self.bm.add_anchor( 193 | self.bg.anchorCount(), self.bg.getAnchor(0, revert=True)) 194 | else: 195 | self.bg.delete_anchor(snap_idx[1], snap_point[1]) 196 | self.bm.delete_anchor(snap_idx[1]) 197 | 198 | # if click on handle with shift, move handle to anchor 199 | elif snapped[2]: 200 | self.bg.delete_handle(snap_idx[2], snap_point[2]) 201 | point = self.bg.getAnchor( 202 | int(snap_idx[2] / 2), revert=True) 203 | self.bm.move_handle(snap_idx[2], point) 204 | # click with no key 205 | else: 206 | # if click on anchor, move anchor 207 | if snapped[1]: 208 | self.mouse_state = "move_anchor" 209 | self.clicked_idx = snap_idx[1] 210 | if self.editing_geom_type == QgsWkbTypes.PolygonGeometry and snap_idx[1] == (self.bg.anchorCount() - 1): 211 | self.bg.move_anchor2(snap_idx[1], snap_point[1]) 212 | self.bm.move_anchor(snap_idx[1], snap_point[1]) 213 | self.bm.move_anchor(0, snap_point[1]) 214 | else: 215 | self.bg.move_anchor(snap_idx[1], snap_point[1]) 216 | self.bm.move_anchor(snap_idx[1], snap_point[1]) 217 | 218 | # if click on handle, move handle 219 | elif snapped[2]: 220 | self.mouse_state = "move_handle" 221 | self.clicked_idx = snap_idx[2] 222 | self.bg.move_handle(snap_idx[2], snap_point[2]) 223 | self.bm.move_handle(snap_idx[2], snap_point[2]) 224 | # if click on canvas, add anchor 225 | else: 226 | if self.editing_geom_type == QgsWkbTypes.PolygonGeometry: 227 | return 228 | if not self.editing: 229 | self.bg = BezierGeometry(self.projectCRS) 230 | self.bm = BezierMarker(self.canvas, self.bg) 231 | self.editing = True 232 | self.mouse_state = "add_anchor" 233 | self.clicked_idx = self.bg.anchorCount() 234 | self.bg.add_anchor(self.clicked_idx, snap_point[0]) 235 | self.bm.add_anchor(self.clicked_idx, snap_point[0]) 236 | # freehand tool 237 | elif self.mode == "freehand": 238 | # right click with Ctrl - show context menu 239 | if event.button() == Qt.RightButton and bool(modifiers & Qt.ControlModifier): 240 | self.showFreehandContextMenu(event) 241 | return 242 | # left click 243 | elif event.button() == Qt.LeftButton: 244 | # Streaming mode behavior (click-move-click) 245 | if self.freehand_streaming: 246 | # If we're already drawing, finish the line 247 | if self.freehand_drawing: 248 | self.drawlineToBezier(snapped[4]) 249 | self.freehand_drawing = False 250 | self.mouse_state = "free" 251 | return 252 | 253 | # Start new drawing 254 | # if click on canvas, freehand drawing start 255 | if not self.editing: 256 | self.bg = BezierGeometry(self.projectCRS) 257 | self.bm = BezierMarker(self.canvas, self.bg) 258 | point = mouse_point 259 | self.bg.add_anchor(0, point, undo=False) 260 | self.editing = True 261 | # if click on bezier line, modified by freehand drawing 262 | elif self.editing and (snapped[1] or snapped[3]): 263 | if snapped[1]: 264 | point = snap_point[1] 265 | elif snapped[3]: 266 | point = snap_point[3] 267 | else: 268 | return 269 | self.freehand_drawing = True 270 | self.mouse_state = "drawing_freehand" 271 | self.freehand_rbl.reset(QgsWkbTypes.LineGeometry) 272 | self.freehand_rbl.addPoint(point) 273 | # Original drag mode behavior 274 | else: 275 | # if click on canvas, freehand drawing start 276 | if not self.editing: 277 | self.bg = BezierGeometry(self.projectCRS) 278 | self.bm = BezierMarker(self.canvas, self.bg) 279 | point = mouse_point 280 | self.bg.add_anchor(0, point, undo=False) 281 | self.editing = True 282 | # if click on bezier line, modified by freehand drawing 283 | elif self.editing and (snapped[1] or snapped[3]): 284 | if snapped[1]: 285 | point = snap_point[1] 286 | elif snapped[3]: 287 | point = snap_point[3] 288 | else: 289 | return 290 | self.mouse_state = "draw_line" 291 | self.freehand_rbl.reset(QgsWkbTypes.LineGeometry) 292 | self.freehand_rbl.addPoint(point) 293 | # split tool 294 | elif self.mode == "split": 295 | # right click 296 | if event.button() == Qt.RightButton: 297 | # if right click in editing, bezier editing finish 298 | if self.editing: 299 | self.finishEditing(layer) 300 | # if right click on feature, bezier editing start 301 | else: 302 | ok = self.startEditing(layer, mouse_point) 303 | if ok: 304 | self.editing = True 305 | # left click 306 | elif event.button() == Qt.LeftButton: 307 | # if click on bezier line, split bezier feature is created 308 | if self.editing and self.editing_feature_id is not None: 309 | type = layer.geometryType() 310 | if type == QgsWkbTypes.LineGeometry: 311 | # split on anchor 312 | if snapped[1]: 313 | lineA, lineB = self.bg.split_line( 314 | snap_idx[1], snap_point[1], isAnchor=True) 315 | # split on line 316 | elif snapped[3]: 317 | lineA, lineB = self.bg.split_line( 318 | snap_idx[3], snap_point[3], isAnchor=False) 319 | else: 320 | return 321 | 322 | if layer.wkbType() == QgsWkbTypes.LineString: 323 | geomA = QgsGeometry.fromPolylineXY(lineA) 324 | geomB = QgsGeometry.fromPolylineXY(lineB) 325 | elif layer.wkbType() == QgsWkbTypes.MultiLineString: 326 | geomA = QgsGeometry.fromMultiPolylineXY([lineA]) 327 | geomB = QgsGeometry.fromMultiPolylineXY([lineB]) 328 | 329 | feature = self.getFeatureById( 330 | layer, self.editing_feature_id) 331 | _, _ = self.createFeature( 332 | geomB, feature, editmode=False, showdlg=False) 333 | f, _ = self.createFeature( 334 | geomA, feature, editmode=True, showdlg=False) 335 | layer.removeSelection() 336 | layer.select(f.id()) 337 | self.resetEditing() 338 | 339 | else: 340 | QMessageBox.warning(None, self.tr("Geometry type is different"), self.tr( 341 | "Only line geometry can be split.")) 342 | else: 343 | QMessageBox.warning( 344 | None, self.tr("No feature"), self.tr("No feature to split.")) 345 | # unsplit tool 346 | elif self.mode == "unsplit": 347 | # if left click, feature selection 348 | if event.button() == Qt.LeftButton: 349 | self.endPoint = self.startPoint = mouse_point 350 | self.isEmittingPoint = True 351 | self.showRect(self.startPoint, self.endPoint) 352 | 353 | def canvasMoveEvent(self, event): 354 | modifiers = QApplication.keyboardModifiers() 355 | if bool(modifiers & Qt.ControlModifier): 356 | self.smartGuideOn = True 357 | else: 358 | self.smartGuideOn = False 359 | #self.smartGuideOn = self.guideAction.isChecked() 360 | layer = self.canvas.currentLayer() 361 | if not layer or layer.type() != QgsMapLayer.VectorLayer: 362 | return 363 | mouse_point, snapped, snap_point, snap_idx = self.getSnapPoint(event) 364 | # bezier tool 365 | if self.mode == "bezier": 366 | # add anchor and dragging 367 | if self.mouse_state == "add_anchor": 368 | self.canvas.setCursor(self.movehandle_cursor) 369 | withAlt = bool(modifiers & Qt.AltModifier) 370 | withShift = bool(modifiers & Qt.ShiftModifier) 371 | other_handle_idx, other_handle_point, anchor_point = self.bg.move_handle2( 372 | self.clicked_idx, mouse_point, withAlt, withShift) 373 | if withShift: 374 | self.bm.move_handle(other_handle_idx, other_handle_point) 375 | self.bm.move_handle(other_handle_idx + 1, anchor_point) 376 | elif withAlt: 377 | self.bm.move_handle(other_handle_idx + 1, mouse_point) 378 | else: 379 | self.bm.move_handle(other_handle_idx, other_handle_point) 380 | self.bm.move_handle(other_handle_idx + 1, mouse_point) 381 | 382 | # insert anchor 383 | elif self.mouse_state == "insert_anchor": 384 | pass 385 | # move handle 386 | elif self.mouse_state == "move_handle": 387 | self.canvas.setCursor(self.movehandle_cursor) 388 | point = snap_point[0] 389 | withAlt = bool(modifiers & Qt.AltModifier) 390 | if withAlt: 391 | self.bg.move_handle(self.clicked_idx, point, undo=False) 392 | self.bm.move_handle(self.clicked_idx, point) 393 | other_handle_idx, other_point = self.bg.other_handle( 394 | self.clicked_idx, point) 395 | self.bg.move_handle( 396 | other_handle_idx, other_point, undo=False) 397 | self.bm.move_handle(other_handle_idx, other_point) 398 | else: 399 | 400 | self.bg.move_handle(self.clicked_idx, point, undo=False) 401 | self.bm.move_handle(self.clicked_idx, point) 402 | # add handle 403 | elif bool(modifiers & Qt.AltModifier) and snapped[1] and snapped[2]: 404 | self.canvas.setCursor(self.addhandle_cursor) 405 | # insert anchor 406 | elif bool(modifiers & Qt.AltModifier) and snapped[3] and not snapped[1]: 407 | self.canvas.setCursor(self.insertanchor_cursor) 408 | # force to add anchor 409 | elif bool(modifiers & Qt.ControlModifier) and snapped[1]: 410 | self.canvas.setCursor(self.insertanchor_cursor) 411 | # delete anchor 412 | elif bool(modifiers & Qt.ShiftModifier) and snapped[1]: 413 | self.canvas.setCursor(self.deleteanchor_cursor) 414 | # delete handle 415 | elif bool(modifiers & Qt.ShiftModifier) and snapped[2]: 416 | self.canvas.setCursor(self.deletehandle_cursor) 417 | 418 | # move anchor 419 | elif self.mouse_state == "move_anchor": 420 | point = snap_point[0] 421 | if snapped[1]: 422 | point = snap_point[1] 423 | self.bg.move_anchor(self.clicked_idx, point, undo=False) 424 | self.bm.move_anchor(self.clicked_idx, point) 425 | if self.editing_geom_type == QgsWkbTypes.PolygonGeometry and self.clicked_idx == (self.bg.anchorCount() - 1): 426 | self.bg.move_anchor(0, point, undo=False) 427 | self.bm.move_anchor(0, point) 428 | # free moving 429 | else: 430 | # on anchor 431 | if snapped[1]: 432 | self.canvas.setCursor(self.movehandle_cursor) 433 | # on handle 434 | elif snapped[2]: 435 | self.canvas.setCursor(self.movehandle_cursor) 436 | # on canvas 437 | else: 438 | self.canvas.setCursor(self.addanchor_cursor) 439 | # freehand tool 440 | elif self.mode == "freehand": 441 | self.canvas.setCursor(self.drawline_cursor) 442 | # Streaming mode - continuously add points following mouse 443 | if self.freehand_streaming and self.freehand_drawing and self.mouse_state == "drawing_freehand": 444 | point = mouse_point 445 | # on start anchor 446 | if snapped[4]: 447 | point = snap_point[4] 448 | self.freehand_rbl.addPoint(point) 449 | # Original drag mode - add points while dragging 450 | elif not self.freehand_streaming and self.mouse_state == "draw_line": 451 | point = mouse_point 452 | # on start anchor 453 | if snapped[4]: 454 | point = snap_point[4] 455 | self.freehand_rbl.addPoint(point) 456 | # split tool 457 | elif self.mode == "split": 458 | self.canvas.setCursor(self.split_cursor) 459 | # unsplit tool 460 | elif self.mode == "unsplit": 461 | # if dragging, draw rectangle area 462 | self.canvas.setCursor(self.unsplit_cursor) 463 | if not self.isEmittingPoint: 464 | return 465 | self.endPoint = mouse_point 466 | self.showRect(self.startPoint, self.endPoint) 467 | 468 | def canvasReleaseEvent(self, event): 469 | modifiers = QApplication.keyboardModifiers() 470 | layer = self.canvas.currentLayer() 471 | if not layer or layer.type() != QgsMapLayer.VectorLayer: 472 | return 473 | mouse_point, snapped, snap_point, _ = self.getSnapPoint(event) 474 | if event.button() == Qt.LeftButton: 475 | # bezier tool 476 | if self.mode == "bezier": 477 | self.clicked_idx = None 478 | self.mouse_state = "free" 479 | # freehand tool 480 | elif self.mode == "freehand": 481 | # Streaming mode - don't process release event 482 | if self.freehand_streaming: 483 | pass 484 | # Original drag mode - convert drawing line to bezier line 485 | else: 486 | if self.mouse_state != "free": 487 | self.drawlineToBezier(snapped[4]) 488 | self.mouse_state = "free" 489 | # split tool 490 | elif self.mode == "split": 491 | self.clicked_idx = None 492 | self.mouse_state = "free" 493 | # unsplit tool 494 | elif self.mode == "unsplit": 495 | # if feature in selection, select feature 496 | self.isEmittingPoint = False 497 | r = self.rectangleArea() 498 | if r is not None: 499 | self.resetUnsplit() 500 | self.selectFeatures(mouse_point, r) 501 | else: 502 | self.selectFeatures(mouse_point) 503 | if self.bm is not None: 504 | self.bm.show_handle(self.show_handle) 505 | elif event.button() == Qt.RightButton: 506 | if self.mode == "bezier": 507 | if bool(modifiers & Qt.ControlModifier): 508 | return 509 | elif self.editing: 510 | # if right click on first anchor in editing, flip bezier line 511 | if snapped[4] and self.bg.anchorCount() > 1: 512 | self.bg.flip_line() 513 | self.bm.show(self.show_handle) 514 | # if right click in editing, bezier editing finish 515 | else: 516 | self.finishEditing(layer) 517 | # if right click on feature, bezier editing start 518 | else: 519 | ok = self.startEditing(layer, mouse_point) 520 | if ok: 521 | self.editing = True 522 | # freehand tool 523 | elif self.mode == "freehand": 524 | # Ctrl+right click is handled in canvasPressEvent 525 | if bool(modifiers & Qt.ControlModifier): 526 | return 527 | # if right click in editing, bezier editing finish 528 | elif self.editing: 529 | self.finishEditing(layer) 530 | # if right click on feature, bezier editing start 531 | else: 532 | ok = self.startEditing(layer, mouse_point) 533 | if ok: 534 | self.editing = True 535 | # if right click, selected bezier feature are unsplit 536 | elif self.mode == "unsplit": 537 | self.unsplit() 538 | 539 | def startEditing(self, layer, mouse_point): 540 | """ 541 | convert feature to bezier line and start editing 542 | """ 543 | ok = False 544 | near, feat = self.getNearFeatures(layer, mouse_point) 545 | if near: 546 | # First try to edit the selected feature. If not, edit the last feature of the table. 547 | edit_feature = feat[-1] 548 | feat_ids = [f.id() for f in feat] 549 | for selected_id in layer.selectedFeatureIds(): 550 | if selected_id in feat_ids: 551 | edit_feature = feat[feat_ids.index(selected_id)] 552 | geom_type = self.convertFeatureToBezier(edit_feature) 553 | if geom_type is not None: 554 | self.editing_feature_id = edit_feature.id() 555 | self.editing_geom_type = geom_type 556 | ok = True 557 | return ok 558 | 559 | def finishEditing(self, layer): 560 | """ 561 | convert bezier line to feature and finish editing 562 | """ 563 | layer_type = layer.geometryType() 564 | layer_wkbtype = layer.wkbType() 565 | result, geom = self.bg.asGeometry(layer_type, layer_wkbtype) 566 | # no geometry to convert 567 | if result is None: 568 | continueFlag = False 569 | # the layer geometry type is different 570 | elif result is False: 571 | reply = QMessageBox.question(None, self.tr("Continue editing?"), self.tr( 572 | "Geometry type of the layer is different, or polygon isn't closed. Do you want to continue editing?"), QMessageBox.Yes, 573 | QMessageBox.No) 574 | if reply == QMessageBox.Yes: 575 | continueFlag = True 576 | else: 577 | continueFlag = False 578 | else: 579 | # create new feature 580 | if self.editing_feature_id is None: 581 | f, continueFlag = self.createFeature( 582 | geom, None, editmode=False) 583 | # modify the feature 584 | else: 585 | feature = self.getFeatureById(layer, self.editing_feature_id) 586 | if feature is None: 587 | reply = QMessageBox.question(None, self.tr("No feature"), 588 | self.tr( 589 | "No feature found. Do you want to continue editing?"), 590 | QMessageBox.Yes, 591 | QMessageBox.No) 592 | if reply == QMessageBox.Yes: 593 | continueFlag = True 594 | else: 595 | continueFlag = False 596 | else: 597 | f, continueFlag = self.createFeature( 598 | geom, feature, editmode=True) 599 | if continueFlag is False: 600 | self.resetEditing() 601 | self.canvas.refresh() 602 | 603 | def drawlineToBezier(self, snap_to_start): 604 | """ 605 | convert drawing line to bezier line 606 | """ 607 | geom = self.freehand_rbl.asGeometry() 608 | scale = self.canvas.scale() 609 | layer = self.canvas.currentLayer() 610 | layer_type = layer.geometryType() 611 | self.bg.modified_by_geometry(geom, layer_type, scale, snap_to_start) 612 | self.bm.show() 613 | self.freehand_rbl.reset() 614 | 615 | def resetEditing(self): 616 | """ 617 | reset bezier setting 618 | """ 619 | self.bm.reset() 620 | self.bg.reset() 621 | self.bg = None 622 | self.bm = None 623 | self.editing_feature_id = None 624 | self.editing_geom_type = None 625 | self.editing = False 626 | self.freehand_drawing = False 627 | self.mouse_state = "free" 628 | 629 | def convertFeatureToBezier(self, feature): 630 | """ 631 | convert feature to bezier line 632 | """ 633 | geom_type = None 634 | geom = QgsGeometry(feature.geometry()) 635 | self.checkCRS() 636 | if self.layerCRS.srsid() != self.projectCRS.srsid(): 637 | geom.transform(QgsCoordinateTransform( 638 | self.layerCRS, self.projectCRS, QgsProject.instance())) 639 | 640 | button_line = QPushButton( 641 | QIcon(QgsApplication.getThemeIcon("/mIconLineLayer.svg")), self.tr("Line")) 642 | button_curve = QPushButton(QIcon(QgsApplication.getThemeIcon( 643 | "/mActionDigitizeWithCurve.svg")), self.tr("Curve")) 644 | msgbox_convert = QMessageBox() 645 | msgbox_convert.setWindowTitle(self.tr("Convert to Bezier")) 646 | msgbox_convert.setIcon(QMessageBox.Question) 647 | msgbox_convert.setText(self.tr( 648 | "The feature isn't created by Bezier Tool or ver 1.3 higher.\n\n" + 649 | "Do you want to convert to Bezier?\n\n" + 650 | "Conversion can be done either to line segments or to fitting curve.\nPlease select conversion mode.")) 651 | msgbox_convert.addButton(button_line, QMessageBox.ApplyRole) 652 | msgbox_convert.addButton(button_curve, QMessageBox.ApplyRole) 653 | msgbox_button_cancel = msgbox_convert.addButton(QMessageBox.Cancel) 654 | 655 | if geom.type() == QgsWkbTypes.PointGeometry: 656 | point = geom.asPoint() 657 | self.bg = BezierGeometry.convertPointToBezier( 658 | self.projectCRS, point) 659 | self.bm = BezierMarker(self.canvas, self.bg) 660 | self.bm.add_anchor(0, point) 661 | geom_type = geom.type() 662 | elif geom.type() == QgsWkbTypes.LineGeometry: 663 | geom.convertToSingleType() 664 | polyline = geom.asPolyline() 665 | is_bezier = BezierGeometry.checkIsBezier(self.projectCRS, polyline) 666 | if is_bezier: 667 | self.bg = BezierGeometry.convertLineToBezier( 668 | self.projectCRS, polyline) 669 | self.bm = BezierMarker(self.canvas, self.bg) 670 | self.bm.show(self.show_handle) 671 | geom_type = geom.type() 672 | else: 673 | msgbox_convert.exec() 674 | if msgbox_convert.clickedButton() != msgbox_button_cancel: 675 | if msgbox_convert.clickedButton() == button_line: 676 | linetype = "line" 677 | elif msgbox_convert.clickedButton() == button_curve: 678 | linetype = "curve" 679 | self.bg = BezierGeometry.convertLineToBezier( 680 | self.projectCRS, polyline, linetype) 681 | self.bm = BezierMarker(self.canvas, self.bg) 682 | self.bm.show(self.show_handle) 683 | geom_type = geom.type() 684 | 685 | elif geom.type() == QgsWkbTypes.PolygonGeometry: 686 | geom.convertToSingleType() 687 | polygon = geom.asPolygon() 688 | is_bezier = BezierGeometry.checkIsBezier( 689 | self.projectCRS, polygon[0]) 690 | if is_bezier: 691 | self.bg = BezierGeometry.convertLineToBezier( 692 | self.projectCRS, polygon[0]) 693 | self.bm = BezierMarker(self.canvas, self.bg) 694 | self.bm.show(self.show_handle) 695 | geom_type = geom.type() 696 | else: 697 | msgbox_convert.exec() 698 | if msgbox_convert.clickedButton() != msgbox_button_cancel: 699 | if msgbox_convert.clickedButton() == button_line: 700 | linetype = "line" 701 | elif msgbox_convert.clickedButton() == button_curve: 702 | linetype = "curve" 703 | self.bg = BezierGeometry.convertLineToBezier( 704 | self.projectCRS, polygon[0], linetype) 705 | self.bm = BezierMarker(self.canvas, self.bg) 706 | self.bm.show(self.show_handle) 707 | geom_type = geom.type() 708 | 709 | else: 710 | QMessageBox.warning(None, self.tr("Not supported type"), self.tr( 711 | "Geometry type of the layer is not supported.")) 712 | 713 | return geom_type 714 | 715 | def createFeature(self, geom, feature, editmode=True, showdlg=True): 716 | """ 717 | create or edit feature 718 | Referred to 719 | https://github.com/EnMAP-Box/qgispluginsupport/blob/master/qps/maptools.py#L717 720 | """ 721 | continueFlag = False 722 | layer = self.canvas.currentLayer() 723 | fields = layer.fields() 724 | 725 | self.checkCRS() 726 | if self.layerCRS.srsid() != self.projectCRS.srsid(): 727 | geom.transform(QgsCoordinateTransform( 728 | self.projectCRS, self.layerCRS, QgsProject.instance())) 729 | 730 | 731 | initialAttributeValues = dict() 732 | settings = QgsSettings() 733 | val = settings.value("qgis/digitizing/reuseLastValues", False) 734 | if val is None: 735 | reuseLastValues = False 736 | elif isinstance(val, str): 737 | reuseLastValues = val.lower() == "true" 738 | else: 739 | reuseLastValues = bool(val) 740 | 741 | lyr: QgsVectorLayer = layer 742 | for idx in range(lyr.fields().count()): 743 | if feature is not None: 744 | initialAttributeValues[idx] = feature.attributes()[idx] 745 | elif lyr.dataProvider().defaultValueClause(idx)!="": 746 | initialAttributeValues[idx] = lyr.dataProvider().defaultValueClause(idx) 747 | elif (reuseLastValues or lyr.editFormConfig().reuseLastValue(idx)) and layer.id() in self.sLastUsedValues.keys() and idx in self.sLastUsedValues[lyr.id()].keys(): 748 | lastUsed = self.sLastUsedValues[lyr.id()][idx] 749 | initialAttributeValues[idx] = lastUsed 750 | 751 | context = layer.createExpressionContext() 752 | f = QgsVectorLayerUtils.createFeature( 753 | layer, geom, initialAttributeValues, context) 754 | newFeature = QgsFeature(f) 755 | 756 | settings = QgsSettings() 757 | val = settings.value( 758 | "qgis/digitizing/disable_enter_attribute_values_dialog", False 759 | ) 760 | if val is None: 761 | disable_attributes = False 762 | elif isinstance(val, str): 763 | disable_attributes = val.lower() == "true" 764 | else: 765 | disable_attributes = bool(val) 766 | 767 | if disable_attributes or showdlg is False or fields.count() == 0: 768 | if not editmode: 769 | layer.beginEditCommand(self.tr("Bezier added")) 770 | layer.addFeature(newFeature) 771 | else: 772 | # if using changeGeometry function, crashed... it's bug? So using add and delete 773 | layer.beginEditCommand(self.tr("Bezier edited")) 774 | layer.addFeature(newFeature) 775 | layer.deleteFeature(feature.id()) 776 | layer.endEditCommand() 777 | else: 778 | if not editmode: 779 | dlg = QgsAttributeDialog(layer, newFeature, True) 780 | dlg.setAttribute(Qt.WA_DeleteOnClose) 781 | dlg.setMode(QgsAttributeEditorContext.AddFeatureMode) 782 | dlg.setEditCommandMessage(self.tr("Bezier added")) 783 | dlg.attributeForm().featureSaved.connect( 784 | lambda f, form=dlg.attributeForm(): self.onFeatureSaved(f, form)) 785 | ok = dlg.exec_() 786 | if not ok: 787 | reply = QMessageBox.question(None, self.tr("Continue editing?"), self.tr("Do you want to continue editing?"), 788 | QMessageBox.Yes, 789 | QMessageBox.No) 790 | if reply == QMessageBox.Yes: 791 | continueFlag = True 792 | else: 793 | layer.beginEditCommand("Bezier edited") 794 | dlg = self.iface.getFeatureForm(layer, feature) 795 | ok = dlg.exec_() 796 | if ok: 797 | layer.changeGeometry(feature.id(), geom) 798 | layer.endEditCommand() 799 | else: 800 | layer.destroyEditCommand() 801 | reply = QMessageBox.question(None, self.tr("Continue editing?"), self.tr("Do you want to continue editing?"), 802 | QMessageBox.Yes, 803 | QMessageBox.No) 804 | if reply == QMessageBox.Yes: 805 | continueFlag = True 806 | 807 | return newFeature, continueFlag 808 | 809 | def onFeatureSaved(self, feature: QgsFeature, form: QgsAttributeForm): 810 | form = self.sender() 811 | if not isinstance(form, QgsAttributeForm): 812 | return 813 | 814 | settings = QgsSettings() 815 | val = settings.value("qgis/digitizing/reuseLastValues", False) 816 | if val is None: 817 | reuseLastValues = False 818 | elif isinstance(val, str): 819 | reuseLastValues = val.lower() == "true" 820 | else: 821 | reuseLastValues = bool(val) 822 | lyr = self.canvas.currentLayer() 823 | fields = lyr.fields() 824 | origValues: Dict[int, Any] = self.sLastUsedValues.get( 825 | lyr.id(), dict()) 826 | newValues: List = feature.attributes() 827 | for idx in range(fields.count()): 828 | if(reuseLastValues or lyr.editFormConfig().reuseLastValue(idx)): 829 | origValues[idx] = newValues[idx] 830 | self.sLastUsedValues[lyr.id()] = origValues 831 | 832 | def undo(self): 833 | """ 834 | undo bezier editing (add, move, delete , draw) for anchor and handle 835 | """ 836 | if self.bg is not None: 837 | history_length = self.bg.undo() 838 | self.bm.show(self.show_handle) 839 | if history_length == 0: 840 | self.resetEditing() 841 | 842 | def showHandle(self, checked): 843 | """ 844 | change bezier handle visibility 845 | """ 846 | self.show_handle = checked 847 | if self.bm is not None: 848 | self.bm.show_handle(checked) 849 | 850 | def showFreehandContextMenu(self, event): 851 | """ 852 | Show context menu for freehand tool settings 853 | """ 854 | menu = QMenu() 855 | 856 | # Add streaming mode action 857 | streamingAction = QAction(self.tr("Streaming"), menu) 858 | streamingAction.setCheckable(True) 859 | streamingAction.setChecked(self.freehand_streaming) 860 | streamingAction.triggered.connect(self.toggleFreehandStreaming) 861 | menu.addAction(streamingAction) 862 | 863 | # Show menu at cursor position 864 | menu.exec_(self.canvas.mapToGlobal(event.pos())) 865 | 866 | def toggleFreehandStreaming(self): 867 | """ 868 | Toggle streaming mode for freehand tool 869 | """ 870 | self.freehand_streaming = not self.freehand_streaming 871 | # Save the setting 872 | s = QgsSettings() 873 | s.setValue("BezierEditing/freehand_streaming", self.freehand_streaming) 874 | # Reset any ongoing drawing when switching modes 875 | if self.freehand_drawing: 876 | self.freehand_drawing = False 877 | self.mouse_state = "free" 878 | self.freehand_rbl.reset() 879 | 880 | def getSnapPoint(self, event): 881 | """ 882 | return mouse point and snapped point list. 883 | snapped point list is 0:map, 1:anchor, 2:handle, 3:bezier line, 4:start anchor 884 | """ 885 | snap_idx = ["", "", "", "", "", ""] 886 | snapped = [False, False, False, False, False, False] 887 | snap_point = [None, None, None, None, None, None] 888 | 889 | self.snap_mark.hide() 890 | self.guide_rbl.reset(QgsWkbTypes.LineGeometry) 891 | if self.guideLabelGroup is not None: 892 | self.canvas.scene().removeItem(self.guideLabelGroup) 893 | self.guideLabelGroup = None 894 | self.guideLabelGroup = QGraphicsItemGroup() 895 | self.canvas.scene().addItem(self.guideLabelGroup) 896 | 897 | mouse_point = self.toMapCoordinates(event.pos()) 898 | snapped[0], snap_point[0] = self.checkSnapToPoint(event.pos()) 899 | 900 | if self.bg is not None: 901 | point = snap_point[0] 902 | snap_distance = self.canvas.scale() / 500 903 | #d = self.canvas.mapUnitsPerPixel() * 4 904 | snapped[1], snap_point[1], snap_idx[1] = self.bg.checkSnapToAnchor( 905 | point, self.clicked_idx, snap_distance) 906 | if self.show_handle and self.mode == "bezier": 907 | snapped[2], snap_point[2], snap_idx[2] = self.bg.checkSnapToHandle( 908 | point, snap_distance) 909 | snapped[3], snap_point[3], snap_idx[3] = self.bg.checkSnapToLine( 910 | point, snap_distance) 911 | snapped[4], snap_point[4], snap_idx[4] = self.bg.checkSnapToStart( 912 | point, snap_distance) 913 | 914 | if self.smartGuideOn and self.mode == "bezier" and self.bg.anchorCount() > 0 and not snapped[1]: 915 | # calc the angle from line made by point0 and point1 916 | if self.bg.anchorCount() >= 2: 917 | origin_point0 = self.bg.getAnchor(-2, revert=True) 918 | origin_point1 = self.bg.getAnchor(-1, revert=True) 919 | # calc the angle from horizontal line 920 | elif self.bg.anchorCount() == 1: 921 | origin_point0 = None 922 | origin_point1 = self.bg.getAnchor(0, revert=True) 923 | guide_point = self.smartGuide( 924 | origin_point0, origin_point1, snap_point[0], doSnap=True) 925 | snap_point[0] = guide_point 926 | snap_point[1] = guide_point 927 | # show snap marker, but didn't show to line snap 928 | for i in [0, 1, 2, 4]: 929 | if snapped[i]: 930 | self.snap_mark.setCenter(snap_point[i]) 931 | self.snap_mark.show() 932 | break 933 | 934 | return mouse_point, snapped, snap_point, snap_idx 935 | 936 | def generate_menu(self): 937 | self.menu = QMenu() 938 | self.menu.addAction(self.tr("Guide settings...") 939 | ).triggered.connect(self.guide_snap_setting) 940 | self.menu.addAction(self.tr("Reset guide") 941 | ).triggered.connect(self.clear_guide) 942 | self.menu.addSeparator() 943 | self.menu.addAction(self.tr("Advanced settings...") 944 | ).triggered.connect(self.interpolate_setting) 945 | self.menu.addSeparator() 946 | self.closeAction = self.menu.addAction(self.tr("Close")) 947 | 948 | def guide_snap_setting(self): 949 | num, ok = QInputDialog.getInt(QInputDialog(), self.tr("Set snap to angle"), self.tr( 950 | "Enter snap angle (degree)"), self.snapToAngleUnit, 0, 90) 951 | if ok: 952 | self.snapToAngleUnit = num 953 | num, ok = QInputDialog.getInt(QInputDialog(), self.tr("Set snap to length"), self.tr( 954 | "Enter snap length (in case of LatLon in seconds)"), self.snapToLengthUnit, 0) 955 | if ok: 956 | if self.projectCRS.projectionAcronym() == "longlat": 957 | self.snapToLengthUnit = num/3600 958 | else: 959 | self.snapToLengthUnit = num 960 | 961 | def clear_guide(self): 962 | self.snapToAngleUnit = 0 963 | self.snapToLengthUnit = 0 964 | 965 | def interpolate_setting(self): 966 | if self.bg is not None: 967 | QMessageBox.warning( 968 | None, self.tr("Warning"), self.tr("Can't be set while editing.")) 969 | return 970 | 971 | QMessageBox.warning( 972 | None, self.tr("Warning"), self.tr("Be careful when changing values, as Bezier curves with different numbers of interpolants will not be converted accurately.")) 973 | num, ok = QInputDialog.getInt(QInputDialog(), self.tr("Count"), self.tr( 974 | "Enter Interpolate Point Count (default is 10)"), BezierGeometry.INTERPOLATION, 5, 99) 975 | if ok: 976 | BezierGeometry.INTERPOLATION = num 977 | s = QgsSettings() 978 | s.setValue("BezierEditing/INTERPOLATION", num) 979 | 980 | def lengthSnapPoint(self, origin_point, point): 981 | v = point - origin_point 982 | theta = math.atan2(v.y(), v.x()) 983 | org_length = origin_point.distance(point) 984 | if self.snapToLengthUnit == 0: 985 | snap_length = org_length 986 | else: 987 | snap_length = ((org_length + self.snapToLengthUnit / 2.0) // 988 | self.snapToLengthUnit) * self.snapToLengthUnit 989 | snap_point = QgsPointXY(origin_point.x() + snap_length * math.cos(theta), 990 | origin_point.y() + snap_length * math.sin(theta)) 991 | return snap_point, snap_length, org_length 992 | 993 | def angleSnapPoint(self, origin_point0, origin_point1, point): 994 | 995 | if (origin_point0 is None or (origin_point1.x() == point.x() and origin_point1.y() == point.y())): 996 | v = point - origin_point1 997 | theta = math.atan2(v.y(), v.x()) 998 | org_deg = math.degrees(theta) 999 | if self.snapToAngleUnit == 0: 1000 | snap_deg = org_deg 1001 | snap_point = point 1002 | else: 1003 | snap_deg = ((org_deg + self.snapToAngleUnit / 2.0) // 1004 | self.snapToAngleUnit) * self.snapToAngleUnit 1005 | snap_theta = math.radians(snap_deg) 1006 | if snap_deg == 90 or snap_deg == -90: 1007 | snap_point = QgsPointXY( 1008 | origin_point1.x(), origin_point1.y() + v.y()) 1009 | else: 1010 | snap_point = QgsPointXY( 1011 | origin_point1.x() + v.x(), origin_point1.y() + math.tan(snap_theta) * v.x()) 1012 | 1013 | else: 1014 | v_a = origin_point1 - origin_point0 1015 | v_b = point - origin_point1 1016 | 1017 | rot_theta = math.atan2(v_a.y(), v_a.x()) 1018 | v_b_rot = QgsPointXY(math.cos(-rot_theta) * v_b.x() - math.sin(-rot_theta) * v_b.y(), 1019 | math.sin(-rot_theta) * v_b.x() + math.cos(-rot_theta) * v_b.y()) 1020 | theta = math.atan2(v_b_rot.y(), v_b_rot.x()) 1021 | org_deg = math.degrees(theta) 1022 | if self.snapToAngleUnit == 0: 1023 | snap_deg = org_deg 1024 | snap_point = point 1025 | else: 1026 | snap_deg = ((org_deg + self.snapToAngleUnit / 2.0) // 1027 | self.snapToAngleUnit) * self.snapToAngleUnit 1028 | snap_theta = math.radians(snap_deg) 1029 | if snap_deg == 90 or snap_deg == -90: 1030 | v_b_rot_snap = QgsPointXY(0, v_b_rot.y()) 1031 | else: 1032 | v_b_rot_snap = QgsPointXY( 1033 | v_b_rot.x(), math.tan(snap_theta)*v_b_rot.x()) 1034 | v_b_rot2 = QgsPointXY(math.cos(rot_theta) * v_b_rot_snap.x() - math.sin(rot_theta) * v_b_rot_snap.y(), 1035 | math.sin(rot_theta) * v_b_rot_snap.x() + math.cos(rot_theta) * v_b_rot_snap.y()) 1036 | snap_point = QgsPointXY( 1037 | v_b_rot2.x() + origin_point1.x(), v_b_rot2.y() + origin_point1.y()) 1038 | 1039 | return snap_point, snap_deg, org_deg 1040 | 1041 | def smartGuide(self, origin_point0, origin_point1, point, doSnap=False): 1042 | snapped_angle = False 1043 | snapped_length = False 1044 | 1045 | if doSnap: 1046 | snap_point, snap_deg, org_deg = self.angleSnapPoint( 1047 | origin_point0, origin_point1, point) 1048 | if self.snapToAngleUnit > 0: 1049 | guide_point = snap_point 1050 | guide_deg = snap_deg 1051 | snapped_angle = True 1052 | else: 1053 | guide_point = point 1054 | guide_deg = org_deg 1055 | 1056 | snap_point, snap_length, org_length = self.lengthSnapPoint( 1057 | origin_point1, guide_point) 1058 | snap_distance = self.canvas.scale() / 500 1059 | if self.snapToLengthUnit > 0 and abs(snap_length-org_length) < snap_distance: 1060 | guide_point = snap_point 1061 | guide_length = snap_length 1062 | snapped_length = True 1063 | else: 1064 | guide_point = guide_point 1065 | guide_length = org_length 1066 | 1067 | angle_text = "{:.1f}°".format(guide_deg) 1068 | gl = self.guideLabel(angle_text, origin_point1, snapped_angle) 1069 | self.guideLabelGroup.addToGroup(gl) 1070 | if self.projectCRS.projectionAcronym() == "longlat": 1071 | length_text = "{:.1f}″".format(guide_length*3600) 1072 | else: 1073 | length_text = "{:.1f}m".format(guide_length) 1074 | gl = self.guideLabel(length_text, origin_point1 + 1075 | (guide_point - origin_point1) / 2, snapped_length) 1076 | self.guideLabelGroup.addToGroup(gl) 1077 | 1078 | self.snap_mark.setCenter(guide_point) 1079 | self.snap_mark.show() 1080 | 1081 | v = guide_point - origin_point1 1082 | self.guide_rbl.addPoint(origin_point1 - v*(10000.0)) 1083 | self.guide_rbl.addPoint(guide_point + v*(10000.0)) 1084 | self.guide_rbl.show() 1085 | 1086 | return guide_point 1087 | 1088 | def guideLabel(self, text, position, snapped=False): 1089 | symbol = QgsMarkerSymbol() 1090 | symbol.setSize(0) 1091 | font = QFont() 1092 | font.setPointSize(12) 1093 | lbltext = QTextDocument() 1094 | lbltext.setDefaultFont(font) 1095 | if snapped: 1096 | lbltext.setHtml("" + text + "") 1097 | self.guide_rbl.setColor(QColor(255, 0, 0, 150)) 1098 | else: 1099 | lbltext.setHtml("" + text + "") 1100 | self.guide_rbl.setColor(QColor(0, 0, 255, 150)) 1101 | label = QgsTextAnnotation() 1102 | label.setMapPosition(position) 1103 | label.setFrameOffsetFromReferencePoint(QPointF(15, -30)) 1104 | label.setDocument(lbltext) 1105 | label.setFrameSize(lbltext.size()) 1106 | fs = label.fillSymbol() 1107 | fs.setOpacity(0) 1108 | label.setMarkerSymbol(symbol) 1109 | return QgsMapCanvasAnnotationItem(label, self.canvas) 1110 | 1111 | def checkSnapToPoint(self, point): 1112 | snapped = False 1113 | snap_point = self.toMapCoordinates(point) 1114 | if self.snapping: 1115 | snapper = self.canvas.snappingUtils() 1116 | snapMatch = snapper.snapToMap(point) 1117 | if snapMatch.hasVertex(): 1118 | snap_point = snapMatch.point() 1119 | snapped = True 1120 | elif snapMatch.hasEdge(): 1121 | snap_point = snapMatch.point() 1122 | snapped = True 1123 | return snapped, snap_point 1124 | 1125 | def getFeatureById(self, layer, featid): 1126 | features = [f for f in layer.getFeatures( 1127 | QgsFeatureRequest().setFilterFids([featid]))] 1128 | if len(features) != 1: 1129 | return None 1130 | else: 1131 | return features[0] 1132 | 1133 | def getNearFeatures(self, layer, point, rect=None): 1134 | if rect is None: 1135 | dist = self.canvas.mapUnitsPerPixel() * 4 1136 | rect = QgsRectangle( 1137 | (point.x() - dist), (point.y() - dist), (point.x() + dist), (point.y() + dist)) 1138 | self.checkCRS() 1139 | if self.layerCRS.srsid() != self.projectCRS.srsid(): 1140 | rectGeom = QgsGeometry.fromRect(rect) 1141 | rectGeom.transform(QgsCoordinateTransform( 1142 | self.projectCRS, self.layerCRS, QgsProject.instance())) 1143 | rect = rectGeom.boundingBox() 1144 | request = QgsFeatureRequest() 1145 | request.setFilterRect(rect) 1146 | f = [feat for feat in layer.getFeatures(request)] 1147 | if len(f) == 0: 1148 | return False, None 1149 | else: 1150 | return True, f 1151 | 1152 | def checkSnapSetting(self): 1153 | snap_cfg = self.iface.mapCanvas().snappingUtils().config() 1154 | if snap_cfg.enabled(): 1155 | self.snapping = True 1156 | 1157 | else: 1158 | self.snapping = False 1159 | 1160 | def checkCRS(self): 1161 | self.projectCRS = self.canvas.mapSettings().destinationCrs() 1162 | if self.canvas.currentLayer() is not None: 1163 | self.layerCRS = self.canvas.currentLayer().crs() 1164 | 1165 | def selectFeatures(self, point, rect=None): 1166 | # layers = QgsMapLayerRegistry.instance().mapLayers().values() 1167 | layers = QgsProject.instance().layerTreeRoot().findLayers() 1168 | for layer in layers: 1169 | if layer.layer().type() != QgsMapLayer.VectorLayer: 1170 | continue 1171 | near = self.selectNearFeature(layer.layer(), point, rect) 1172 | if near and rect is None: 1173 | break 1174 | elif not near: 1175 | layer.layer().removeSelection() 1176 | 1177 | def showRect(self, startPoint, endPoint): 1178 | self.rubberBand.reset(QgsWkbTypes.PolygonGeometry) 1179 | if startPoint.x() == endPoint.x() or startPoint.y() == endPoint.y(): 1180 | return 1181 | 1182 | point1 = QgsPointXY(startPoint.x(), startPoint.y()) 1183 | point2 = QgsPointXY(startPoint.x(), endPoint.y()) 1184 | point3 = QgsPointXY(endPoint.x(), endPoint.y()) 1185 | point4 = QgsPointXY(endPoint.x(), startPoint.y()) 1186 | 1187 | self.rubberBand.addPoint(point1, False) 1188 | self.rubberBand.addPoint(point2, False) 1189 | self.rubberBand.addPoint(point3, False) 1190 | self.rubberBand.addPoint(point4, True) # true to update canvas 1191 | self.rubberBand.show() 1192 | 1193 | def rectangleArea(self): 1194 | if self.startPoint is None or self.endPoint is None: 1195 | return None 1196 | elif self.startPoint.x() == self.endPoint.x() or self.startPoint.y() == self.endPoint.y(): 1197 | return None 1198 | 1199 | return QgsRectangle(self.startPoint, self.endPoint) 1200 | 1201 | def selectNearFeature(self, layer, point, rect=None): 1202 | if rect is not None: 1203 | layer.removeSelection() 1204 | near, features = self.getNearFeatures(layer, point, rect) 1205 | if near: 1206 | fids = [f.id() for f in features] 1207 | if rect is not None: 1208 | layer.selectByIds(fids) 1209 | else: 1210 | for fid in fids: 1211 | if self.isSelected(layer, fid): 1212 | layer.deselect(fid) 1213 | else: 1214 | layer.select(fid) 1215 | return near 1216 | 1217 | def isSelected(self, layer, fid): 1218 | for sid in layer.selectedFeatureIds(): 1219 | if sid == fid: 1220 | return True 1221 | return False 1222 | 1223 | def resetUnsplit(self): 1224 | self.startPoint = self.endPoint = None 1225 | self.isEmittingPoint = False 1226 | self.rubberBand.reset(QgsWkbTypes.PolygonGeometry) 1227 | 1228 | def distance(self, p1, p2): 1229 | dx = p1[0] - p2[0] 1230 | dy = p1[1] - p2[1] 1231 | return math.sqrt(dx * dx + dy * dy) 1232 | 1233 | def unsplit(self): 1234 | """ 1235 | unsplit selected two feature.it needs the feature can convert to bezier line. 1236 | """ 1237 | layer = self.canvas.currentLayer() 1238 | fields = layer.fields() 1239 | if layer.geometryType() == QgsWkbTypes.LineGeometry: 1240 | selected_features = layer.selectedFeatures() 1241 | if len(selected_features) == 2: 1242 | f0 = selected_features[0] 1243 | f1 = selected_features[1] 1244 | geom0 = f0.geometry() 1245 | geom0.convertToSingleType() 1246 | geom1 = f1.geometry() 1247 | geom1.convertToSingleType() 1248 | line0 = geom0.asPolyline() 1249 | line1 = geom1.asPolyline() 1250 | 1251 | # Connect points with the smallest distance from all combinations of endpoints 1252 | dist = [self.distance(li0, li1) for li0, li1 in 1253 | [(line0[-1], line1[0]), (line0[0], line1[-1]), (line0[0], line1[0]), (line0[-1], line1[-1])]] 1254 | type = dist.index(min(dist)) 1255 | if type == 0: 1256 | pass 1257 | elif type == 1: 1258 | line0.reverse() 1259 | line1.reverse() 1260 | elif type == 2: 1261 | line0.reverse() 1262 | elif type == 3: 1263 | line1.reverse() 1264 | # if endpoints are same position 1265 | if line0[-1] == line1[0]: 1266 | line = line0 + line1[1:] 1267 | # If the end points are separated, the are interpolated using Bezier line 1268 | else: 1269 | b = BezierGeometry(self.projectCRS) 1270 | b.add_anchor(0, line0[-1], undo=False) 1271 | b.add_anchor(1, line1[0], undo=False) 1272 | interporate_line = b.asPolyline() 1273 | line = line0 + interporate_line[1:] + line1[1:] 1274 | 1275 | if layer.wkbType() == QgsWkbTypes.LineString: 1276 | geom = QgsGeometry.fromPolylineXY(line) 1277 | elif layer.wkbType() == QgsWkbTypes.MultiLineString: 1278 | geom = QgsGeometry.fromMultiPolylineXY([line]) 1279 | 1280 | layer.beginEditCommand(self.tr("Bezier unsplit")) 1281 | settings = QgsSettings() 1282 | disable_val = settings.value("/qgis/digitizing/disable_enter_attribute_values_dialog", False) 1283 | if disable_val is None: 1284 | disable_attributes = False 1285 | elif isinstance(disable_val, str): 1286 | disable_attributes = disable_val.lower() == "true" 1287 | else: 1288 | disable_attributes = bool(disable_val) 1289 | if disable_attributes or fields.count() == 0: 1290 | layer.changeGeometry(f0.id(), geom) 1291 | layer.deleteFeature(f1.id()) 1292 | layer.endEditCommand() 1293 | else: 1294 | dlg = self.iface.getFeatureForm(layer, f0) 1295 | if dlg.exec_(): 1296 | layer.changeGeometry(f0.id(), geom) 1297 | layer.deleteFeature(f1.id()) 1298 | layer.endEditCommand() 1299 | else: 1300 | layer.destroyEditCommand() 1301 | self.canvas.refresh() 1302 | else: 1303 | QMessageBox.warning( 1304 | None, self.tr("Warning"), self.tr("Select exactly two feature.")) 1305 | else: 1306 | QMessageBox.warning( 1307 | None, self.tr("Warning"), self.tr("Select a line Layer.")) 1308 | 1309 | def activate(self): 1310 | self.canvas.setCursor(self.addanchor_cursor) 1311 | self.checkSnapSetting() 1312 | self.checkCRS() 1313 | self.snap_mark.hide() 1314 | self.resetUnsplit() 1315 | 1316 | def deactivate(self): 1317 | # self.canvas.unsetMapTool(self) 1318 | # QgsMapTool.deactivate(self) 1319 | # self.log("deactivate") 1320 | pass 1321 | 1322 | def isZoomTool(self): 1323 | return False 1324 | 1325 | def isTransient(self): 1326 | return False 1327 | 1328 | def isEditTool(self): 1329 | return True 1330 | 1331 | def showSettingsWarning(self): 1332 | pass 1333 | 1334 | def log(self, msg): 1335 | QgsMessageLog.logMessage(msg, 'BezierEditing', Qgis.Info) 1336 | --------------------------------------------------------------------------------