├── .github └── workflows │ └── test_plugin.yaml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── REQUIREMENTS_TESTING.txt ├── __init__.py ├── chordal_axis_algorithm.py ├── chordal_axis_unittest.py ├── geo_sim_processing.py ├── geo_sim_processing_provider.py ├── geo_sim_util.py ├── i18n └── af.ts ├── image ├── Figure6-abc.png ├── chordal_axis_figure.gpkg ├── figure1.png ├── figure2.png ├── figure3.png ├── figure4.png ├── figure5a.png ├── figure5b.png ├── figure5c.png ├── figure5d.png ├── figure5e.png ├── figure5f.png ├── figure6.docx ├── figure6a.png ├── figure6b.png ├── sherbend_figures.gpkg └── sherbend_figures.qgz ├── logo.png ├── metadata.txt ├── plugin_upload.py ├── pylintrc ├── reduce_bend_algorithm.py ├── reduce_bend_unittest.py ├── simplify_algorithm.py └── simplify_unittest.py /.github/workflows/test_plugin.yaml: -------------------------------------------------------------------------------- 1 | name: Test plugin 2 | 3 | on: 4 | push: 5 | paths: 6 | - "**" 7 | - "/geo_sim_processing/**" 8 | - ".github/workflows/test_plugin.yaml" 9 | pull_request: 10 | paths: 11 | - "**" 12 | - "/geo_sim_processing/**" 13 | - ".github/workflows/test_plugin.yaml" 14 | 15 | env: 16 | # plugin name/directory where the code for the plugin is stored 17 | PLUGIN_NAME: geo_sim_processing 18 | # python notation to test running inside plugin 19 | # TESTS_RUN_FUNCTION: test.test_suite.test_package 20 | # Docker settings 21 | DOCKER_IMAGE: qgis/qgis 22 | 23 | 24 | jobs: 25 | 26 | Test-plugin-geo_simplification: 27 | 28 | runs-on: ubuntu-latest 29 | 30 | strategy: 31 | matrix: 32 | docker_tags: [release-3_16] 33 | # docker_tags: [release-3_16, release-3_18, latest] 34 | 35 | steps: 36 | 37 | - name: Checkout 38 | uses: actions/checkout@v2 39 | 40 | - name: Docker pull and create qgis-testing-environment 41 | run: | 42 | docker pull "$DOCKER_IMAGE":${{ matrix.docker_tags }} 43 | docker run -d --name qgis-testing-environment -v "$GITHUB_WORKSPACE":/tests_directory -e DISPLAY=:99 "$DOCKER_IMAGE":${{ matrix.docker_tags }} 44 | # - name: Docker set up QGIS 45 | # run: | 46 | # docker exec qgis-testing-environment sh -c "qgis_setup.sh $PLUGIN_NAME" 47 | # docker exec qgis-testing-environment sh -c "rm -f /root/.local/share/QGIS/QGIS3/profiles/default/python/plugins/$PLUGIN_NAME" 48 | # docker exec qgis-testing-environment sh -c "ln -s /tests_directory/$PLUGIN_NAME /root/.local/share/QGIS/QGIS3/profiles/default/python/plugins/$PLUGIN_NAME" 49 | # 50 | # docker exec qgis-testing-environment sh -c "pip3 install -r /tests_directory/REQUIREMENTS_TESTING.txt" 51 | # # docker exec qgis-testing-environment sh -c "apt-get update" 52 | # # docker exec qgis-testing-environment sh -c "apt-get install -y python3-pyqt5.qtwebkit" 53 | - name: Docker run plugin tests 54 | run: | 55 | docker exec qgis-testing-environment sh -c "ls -l /tests_directory" 56 | docker exec qgis-testing-environment sh -c "cd tests_directory;python3 -m unittest reduce_bend_unittest.py" 57 | docker exec qgis-testing-environment sh -c "cd tests_directory;python3 -m unittest chordal_axis_unittest.py" 58 | # docker exec qgis-testing-environment sh -c "qgis_testrunner.sh $TESTS_RUN_FUNCTION" 59 | Check-code-quality: 60 | runs-on: ubuntu-latest 61 | steps: 62 | 63 | - name: Install Python 64 | uses: actions/setup-python@v1 65 | with: 66 | python-version: '3.8' 67 | architecture: 'x64' 68 | 69 | - name: Checkout 70 | uses: actions/checkout@v2 71 | 72 | - name: Install packages 73 | run: | 74 | ls -l REQUIREMENTS_TESTING.txt 75 | pip install -r REQUIREMENTS_TESTING.txt 76 | pip install pylint pycodestyle 77 | - name: Pylint 78 | run: make pylint 79 | 80 | - name: Pycodestyle 81 | run: make pycodestyle 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *__pycache__* 2 | .idea 3 | .git 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Geo Simplification Tools 2 | Copyright (C) 2020 Natural Resources canada 3 | 4 | This program is free software; you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation; either version 2 of the License, or (at 7 | your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, but 10 | WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program; if not, write to the Free Software 16 | Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 17 | USA. 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #/*************************************************************************** 2 | # GeoSimplification 3 | # 4 | # This plugin contains different tools for line simplification 5 | # ------------------- 6 | # begin : 2021-01-27 7 | # git sha : $Format:%H$ 8 | # copyright : (C) 2021 by NRCan 9 | # email : daniel.pilon@canada.ca 10 | # ***************************************************************************/ 11 | # 12 | #/*************************************************************************** 13 | # * * 14 | # * This program is free software; you can redistribute it and/or modify * 15 | # * it under the terms of the GNU General Public License as published by * 16 | # * the Free Software Foundation; either version 2 of the License, or * 17 | # * (at your option) any later version. * 18 | # * * 19 | # ***************************************************************************/ 20 | 21 | ################################################# 22 | # Edit the following to match your sources lists 23 | ################################################# 24 | 25 | 26 | # Add iso code for any locales you want to support here (space separated) 27 | # default is no locales 28 | # LOCALES = af 29 | LOCALES = 30 | 31 | # If locales are enabled, set the name of the lrelease binary on your system. If 32 | # you have trouble compiling the translations, you may have to specify the full path to 33 | # lrelease 34 | #LRELEASE ?= lrelease-qt5 35 | 36 | # QGIS3 default 37 | QGISDIR=.local/share/QGIS/QGIS3/profiles/default 38 | 39 | 40 | # translation 41 | #SOURCES = 42 | 43 | PLUGIN_NAME = geo_sim_processing 44 | 45 | EXTRAS = metadata.txt icon.png 46 | 47 | EXTRA_DIRS = 48 | 49 | PEP8EXCLUDE=reduce_bend_unittest.py,chordal_axis_unittest.py 50 | 51 | VERSION=$(shell grep "^version" metadata.txt | cut -d'=' -f2) 52 | ZIP_FILE_NAME=$(PLUGIN_NAME)-$(VERSION).zip 53 | 54 | default: 55 | 56 | %.qm : %.ts 57 | $(LRELEASE) $< 58 | 59 | test: 60 | @echo 61 | @echo "----------------------" 62 | @echo "Regression Test Suite" 63 | @echo "----------------------" 64 | 65 | @# Preceding dash means that make will continue in case of errors 66 | @-export PYTHONPATH=`pwd`:$(PYTHONPATH); \ 67 | export QGIS_DEBUG=0; \ 68 | export QGIS_LOG_FILE=/dev/null; \ 69 | nosetests3 -v -s --with-id --with-coverage --cover-package=slyr_community \ 70 | 3>&1 1>&2 2>&3 3>&- || true 71 | @echo "----------------------" 72 | @echo "If you get a 'no module named qgis.core error, try sourcing" 73 | @echo "the helper script we have provided first then run make test." 74 | @echo "e.g. source run-env-linux.sh ; make test" 75 | @echo "----------------------" 76 | 77 | 78 | deploy: 79 | @echo 80 | @echo "------------------------------------------" 81 | @echo "Deploying (symlinking) plugin to your qgis3 directory." 82 | @echo "------------------------------------------" 83 | # The deploy target only works on unix like operating system where 84 | # the Python plugin directory is located at: 85 | # $HOME/$(QGISDIR)/python/plugins 86 | ln -s `pwd`/$(PLUGIN_NAME) $(HOME)/$(QGISDIR)/python/plugins/${PWD##*/} 87 | 88 | 89 | transup: 90 | @echo 91 | @echo "------------------------------------------------" 92 | @echo "Updating translation files with any new strings." 93 | @echo "------------------------------------------------" 94 | @chmod +x scripts/update-strings.sh 95 | @scripts/update-strings.sh $(LOCALES) 96 | 97 | transcompile: 98 | @echo 99 | @echo "----------------------------------------" 100 | @echo "Compiled translation files to .qm files." 101 | @echo "----------------------------------------" 102 | @chmod +x scripts/compile-strings.sh 103 | @scripts/compile-strings.sh $(LRELEASE) $(LOCALES) 104 | 105 | transclean: 106 | @echo 107 | @echo "------------------------------------" 108 | @echo "Removing compiled translation files." 109 | @echo "------------------------------------" 110 | rm -f i18n/*.qm 111 | 112 | pylint: 113 | @echo 114 | @echo "-----------------" 115 | @echo "Pylint violations" 116 | @echo "-----------------" 117 | @pylint --reports=n --rcfile=pylintrc reduce_bend_algorithm.py chordal_axis_algorithm; echo "pylint return code: " $? 118 | @echo 119 | @echo "----------------------" 120 | @echo "If you get a 'no module named qgis.core' error, try sourcing" 121 | @echo "the helper script we have provided first then run make pylint." 122 | @echo "e.g. source run-env-linux.sh ; make pylint" 123 | @echo "----------------------" 124 | 125 | 126 | # Run pep8/pycodestyle style checking 127 | #http://pypi.python.org/pypi/pep8 128 | pycodestyle: 129 | @echo 130 | @echo "-----------" 131 | @echo "pycodestyle PEP8 issues" 132 | @echo "-----------" 133 | @pycodestyle --repeat --ignore=E203,E121,E122,E123,E124,E125,E126,E127,E128,E402,E501,W504 --exclude $(PEP8EXCLUDE) *.py; echo "pycodestyle return code: " $? 134 | @echo "-----------" 135 | @echo "Ignored in PEP8 check:" 136 | @echo $(PEP8EXCLUDE) 137 | 138 | zip: 139 | # At the root of geo_sim_processing folder type "make zip" 140 | # The zip target creates a zip file with only the needed deployed 141 | # content. You can then upload the zip file on http://plugins.qgis.org 142 | @echo 143 | @echo "---------------------------" 144 | @echo "Creating plugin zip bundle." 145 | @echo "---------------------------" 146 | cd ..; rm -f $(ZIP_FILE_NAME) 147 | cd ..; zip -9 -r $(ZIP_FILE_NAME) $(PLUGIN_NAME) \ 148 | -x '*.git*' \ 149 | -x '*__pycache__*' \ 150 | -x '*unittest*.py' \ 151 | -x '*.pyc' \ 152 | -x '$(PLUGIN_NAME)/REQUIREMENTS_TESTING.txt' \ 153 | -x '$(PLUGIN_NAME)/pylintrc' \ 154 | -x '$(PLUGIN_NAME)/Makefile' 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # geo_sim_processing 2 | 3 | geo_sim_processing is a QGIS plugin that aims to simplify/generalize line and polygon features. It is composed of 3 processing tools: 4 | - [Reduce Bend](#Reduce-Bend) for line simplification and generalization 5 | - [Chordal Axis](#Chordal-Axis) for polygon to line simplification (skeletonization) 6 | - [Simplify](#Simplify) for line simplification 7 | 8 | ## Requirements 9 | - [QGIS](https://www.qgis.org) >3.14 10 | 11 | ## QGIS plugin installation 12 | 13 | From the GitHub repo download the zip file of the latest tag (or the tag you wish to install) and unzip the content in the QGIS plugin directory _geo_sim_processing_ and reload the plugin geo_sim_processing. If the _Plugin Reloader_ is not present install it from the menu Plugins > Manage and Install Plugins 14 | 15 | Plugin directory in Linux: /home/_usename_/.local/share/QGIS/QGIS3/profiles/default/plugins/geo_sim_processing 16 | 17 | Plugin directory in Windows: C:\Users\\_usename_\AppData\Roaming\QGIS\QGIS3\profiles\default\plugins\geo_sim_processing 18 | 19 | Note: Other locations are possible, but these are the default one 20 | 21 | # Reduce Bend 22 | 23 | Reduce Bend is a geospatial simplification and generalization tool for lines and polygons. Reduce Bend is an implementation, and an improvement of the algorithm described in the paper "Line Generalization Based on Analysis of Shape Characteristics, Zeshen Wang and Jean-Claude Müller, 1998" often known as "Bend Simplify" or "Wang Algorithm". The particularity of this algorithm is that for each line it analyzes its bends (curves) and decides which one needs to be simplified, trying to emulate what a cartographer would do manually to simplify or generalize a line. Reduce Bend will accept lines and polygons as input. 24 | 25 | ## Usage 26 | 27 | Reduce Bend is a processing script discoverable in the QGIS Processing Tool Box under Geo Simplification 28 | 29 | **Input vector layer**: Input vector feature to simplify (LineString or Polygon) 30 | 31 | **Smooth line**: If you want to smooth the reduced bends (when possible). 32 | 33 | **Diameter tolerance**: Diameter of the minimum adjusted area bend to simplify (to remove) in ground units 34 | 35 | **Exclude polygon**: Exclude (delete) polygons exteriors below the minimum adjusted area (delete also any interior holes if present) 36 | 37 | **Exclude hole**: Exclude (delete) polygon rings (interior holes) below the minimum adjusted area 38 | 39 | **Output vector layer**: Output vector feature simplified 40 | 41 | ## Line Simplification versus Line Generalization 42 | 43 | *Line Simplification* is the process of removing vertices in a line while trying to keep the maximum number of details within the line whereas *Line Generalization* is the process of removing meaningless (unwanted) details in a line usually for scaling down. The well known Douglas-Peucker algorithm is a very good example of line simplification tool and Reduce Bend falls more in the category of line generalization tools. Keep in mind they both algorithms can be complementary because Reduce Bend will not remove unnecessary vertices in the case of very high densities of vertices. It may be a good idea to use [Simplify](#Simplify) before Reduce Bend in the case of very densed geometries. 44 | 45 | ## How it works 46 | Reduce Bend will simplify (generalize) lines as well as polygons. Reduce Bend consists of three main steps: detect bends, determine which bends to simplify and preserve the topological (spatial) relationships. These 3 steps are detailed below. 47 | 48 | * __Detecting bends__ - 49 | For each line and ring composing polygon features, Reduce Bend will detect the position of each bend. Wang and Müller defined a bend as being the part of a line which contains a number of subsequent vertices with the inflection angles on all vertices being in opposite sign. 50 | 51 | Figure 1a shows a line. Figure 1b depicts the same line with inflection signs on ech vertice. Figure 1c shows the position of the 3 bends each forming an area. 52 | 53 | * __Determining the bends to simplify__ - 54 | For each bend of a line or polygon ring, Reduce Bend calculates an adjusted area value using the following formula: *\.75\*A/cmpi* where *A* is the area of the bend *(1)* and *cmpi* the compactness index of the bend. The compactness index is computed using the following formula: *4\*π\*A/p\*\*2* where *A* is the area of the bend and *p* is the perimeter of the bend. The compactness index varies between \[0..1]. The more circular the bend, the closer the index to 1. Conversely, the flatter the bend, the closer the index to 0. The Reduce Bend Diameter tolerance: 4 represents the diameter of a theoretical circle to define the minimum adjusted area value using *\.75\*2\*π\*r\*\*2/cmpi* where *r* is d/2. Finally, each bend of a line that is below the minimum adjusted area value is replaced by a straight line. Figure 1d shows the result with the middle bend of the line removed (simplified). 55 | 56 | *(1)* The computations are always done in map unit: meters, feet, degrees... 57 | 58 | ![Figure1](/image/figure1.png) 59 | 60 | ## Preserving Topological Relationship 61 | Before any bend simplification is applied, Reduce Bend will always analyze the following 3 topological relationships to ensure they are not affected by the simplification operation: simplicity, intersection and sidedness. If simplification alters any of those relationships, then it is not performed. Thereby Reduce Bend preserves the existing relative topology between the geospatial features to simplify. 62 | 63 | ### Simplicity 64 | Reduce Bend will not simplify a bend, if the simplified bend (dashed line in figure 2a) creates a self intersection. 65 | 66 | ### Intersection 67 | Reduce Bend will not simplify a bend, if the simplified bend creates an intersection between 2 existing features (figure 2b). Conflicting features can be a line with another line or a line with a polygon ring. 68 | 69 | ### Sidedness 70 | Reduce Bend will not simplify a bend, if simplifying the bend creates a sidedness or relative position error between 2 features. Two examples of sidedness issues are shown in figures 2c and 2d. The preservation of the sidedness topological relationship is particularly important when it comes to simplifying polygon rings. In figure 2c, simplifying the polygon as shown (dashed line) would make what was an inner hole "pop out" and become external to the new polygon. In figure 2d, simplifying the bend in the line segment (dashed line) would result in the point feature changing its location relative to the original line. If for example the original line represents a river and the point represents a building, it would mean the building would find itself on the other side of the river after simplification. Conflicting features can be a line with a point or a line with a line or a line with a polygon ring. 71 | 72 | Note: For any given line or polygon ring, only those bends the simplification of which do not cause any topological issues as expressed above will be simplified. 73 | 74 | ![Figure2](/image/figure2.png) 75 | 76 | ### Rule of thumb for the diameter 77 | Reduce Bend can be used for line simplifying often in the context of line generalization. The big question will often be what diameter should we use? A good starting point is the following cartographic rule of thumb -- the * 0.5 mm on the map* -- which says that the minimum distance between two lines should be greater than 0.5 mm on a paper map. So to simplify (generalize) a line for representation at a scale of 1:50 000 for example a diameter of 25 m should be a good starting point... 78 | 79 | # Chordal Axis 80 | 81 | ChordalAxis is a geospatial tool that uses polygon to create triangles (usually the result of a constraint Delaunay triangulation) in order to extract a skeleton (the center line). ChordalAxis is an improvement of the algorithm based of the paper "Rectification of the Chordal Axis Transform and a New Criterion for Shape 82 | Decomposition", Lakshman Prasad, 2005". 83 | 84 | ## Medial Axis Versus Chordal Axis 85 | 86 | The skeleton (center line) is a linear feature representation of a polygonized feature. In computational geometry, it is known as the medial axis and many algorithms are approximating it very well. A major issue with those algorithms is the possible instability for very irregular complex polygons such as dense river or road network polygons. (Figure 5). The Chordal Axis has shown excellent stability in very complex and irregular polygons while extracting a good approximation of the skeleton. 87 | 88 | ## Usage 89 | 90 | Chordal Axis is a processing script discoverable in the QGIS Processing Tool Box under Geo Simplification 91 | 92 | **Input vector layer**: Input polygon vector feature used to create the chordal axis (skeleton) 93 | 94 | **Correction**: Flag to correct the skeleton for small centre line, T junction and X junction. Useful in the case of long any narrow polygon. 95 | 96 | **Output vector layer**: Output line string vector feature for the skeleton 97 | 98 | **Output triangulation**: Output vector feature representing the result of the tessellation (QGIS 3d:tessellate) 99 | 100 | ## How it works 101 | 102 | The processing plugin creates the triangulation from the input polygons using the constraints Delaunay triangulation tool (QGIS 3d:tessellate). QGIS tessellate tool is known to describe polygons well and to be very robust and stable. The resulting triangles are the input for the Chordal Axis program. The Chordal Axis algorithm will analyze each triangle, determine its type based on the number of adjacent triangles and build the appropriate skeleton (centre line). All triangles fall within one of the following four types: 1) _isolated triangle_, when a triangle has no adjacent triangle; 2) _terminal triangle_, when a triangle has only one adjacent triangle; 3) _sleeve triangle_, when a triangle has 2 adjacent triangles; 4) _junction triangle_, when a triangle has 3 adjacent triangles. Each of the four triangle types will produce a specific centre line. For the _isolated triangle_, (Figure 3a) no center line (degenerated case) is created; for the _terminal triangle_ (Figure 3b) the mid point of the adjacent side is connected with the opposite angle; for _sleeve triangle_ (Figure 3c) the mid point of the two adjacent sides are connected; for the _junction triangle_ (Figure 3d) the mid points of each side are connected to the centre point of the triangle. After centre line creation all the centre lines are merged together. The Chordal Axis transform will preserve [Simplicity](#Simplicity) and [Intersection](#Intersection) topological relationships between the lines forming the skeleton and the outer and inner boundaries of the polygon. 103 | 104 | ![figure3](/image/figure3.png) 105 | 106 | ## Correction 107 | The Chordal Axis algorithm gives a very good approximation of the true medial axis of a polygon but it produces unwanted artifacts when it creates the skeleton especially in the case of long and narrow polygons (figure 5) . The main artifact types are: meaningless small centre line (figure 4a); wrongly formed "T junctions" (figure 4c) and "X junctions" (crossing junction) (figure 4e). When the correction parameter is set, the skeleton will be pruned of the meaningless small centre line (4b); it will correct "T junctions" and rectify the normal direction of the line (figure 4d); and, it will rectify the "X crossing" by merging two T junctions that are adjacent (figure 4f). 108 | 109 | ![figure5a](/image/figure5a.png "Figure 5a") ![figure5b](/image/figure5b.png "Figure 5b") ![figure5c](/image/figure5c.png "Figure 5c") ![figure5d](/image/figure5d.png "Figure 5d") ![figure5e](/image/figure5e.png "Figure 5e") ![figure5f](/image/figure5f.png "Figure 5f") 110 | 111 |   Figure 4a    Figure 4b     Figure 4c    Figure 4d    Figure 4e    Figure 4f 112 | 113 | ## Rule of thumb for the use of Chordal Axis 114 | Chordal Axis can be used for skeleton extraction and polygon to line transformation in the context of polygon generalization. Often the quality of the skeleton produced will depend on the density of polygon vertices and therefore overall quantity of triangles ingested by Chordal Axis : the more vertices, the higher the number of generated triangles and the better the skeleton (at the price of increased computation time). Equilateral triangles produce the best skeleton while highly obtuse and/or acute triangles will produce a jagged line that can then be simplified. The vertex density should not result in either over- or under-simplified features. Delaunay triangulation and Chordal Axis will give excellent results in very complex situations like a densely polygonized road network such as the one shown in Figure 5 in which all road segments belong to the same polygon! 115 | 116 | ![figure4](/image/figure4.png) 117 | 118 |  Figure 5 119 | 120 | 121 | # Simplify 122 | 123 | Simplify is a geospatial simplification (generalization) tool for lines and polygons. Simplify implements an improved version of the classic Douglas-Peucker algorithm with spatial constraints validation during geometry simplification. Simplify will preserve the following [topological relationships](#Preserving-Topological-Relationship): Simplicity (within the geometry), Intersection (with other geometries) and Sidedness (with other geometries). 124 | 125 | The figure 6 below shows the differences between the regular and the improved version of the classic Douglas-Peucker algorithm. Figure 6a represents the original contours. Figure 6b represents the results of the simplified contours using the classic Douglas-Peucker algorithm with line intersections identified by the red dots. Figure 6c represents the results of the simplified contours using the improved version of the Douglas-Peucker algorithm without line intersection. Results of Figure 6b and 6c used the same simplification tolerance. 126 | 127 | ![figure6a](/image/Figure6-abc.png "Figure 6abc") 128 |      Figure 6a: Original contour        Figure 6b: Classic Douglas-Peucker     Figure 6c: Improved Douglas-Peucker 129 | 130 | ## Usage 131 | 132 | Simplify is a processing script discoverable in the QGIS Processing Tool Box under Geo Simplification 133 | 134 | **Input layer**: The Line String or Polygon layer to simplify 135 | 136 | **Tolerance**: The tolerance in ground unit used by the Douglas-Peucker algorithm 137 | 138 | **Simplified**: The simplified Line String or Polygon Layer 139 | 140 | ## Rule of thumb for the use of Simplify 141 | 142 | Simplify (Douglas-Peucker) is an excellent tool to remove vertices on features with high vertex densities while preserving a maximum of details within the geometries. Try it with small tolerance value and then use [Reduce Bend](#Reduce-Bend) to [generalize features](#Line-Simplification-versus-Line-Generalization). 143 | -------------------------------------------------------------------------------- /REQUIREMENTS_TESTING.txt: -------------------------------------------------------------------------------- 1 | # For tests execution: 2 | deepdiff 3 | mock 4 | flake8 5 | pep257 6 | pylint 7 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | GeoSimplification 5 | A QGIS plugin 6 | This plugin contains different tools for line simplification 7 | Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/ 8 | ------------------- 9 | begin : 2021-01-27 10 | copyright : (C) 2021 by Natural Resouces Canada 11 | email : daniel.pilon@canada.ca 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | This script initializes the plugin, making it known to QGIS. 23 | """ 24 | 25 | 26 | # noinspection PyPep8Naming 27 | def classFactory(iface): # pylint: disable=invalid-name 28 | """Load GeoSimplification class from file GeoSimplification. 29 | 30 | :param iface: A QGIS interface instance. 31 | :type iface: QgsInterface 32 | """ 33 | # 34 | from .geo_sim_processing import GeoSimplificationPlugin 35 | return GeoSimplificationPlugin() 36 | -------------------------------------------------------------------------------- /chordal_axis_unittest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # /*************************************************************************** 4 | # chordal_axis_unittest.py 5 | # ---------- 6 | # Date : January 2021 7 | # copyright : (C) 2020 by Natural Resources Canada 8 | # email : daniel.pilon@canada.ca 9 | # 10 | # ***************************************************************************/ 11 | # 12 | # /*************************************************************************** 13 | # * * 14 | # * This program is free software; you can redistribute it and/or modify * 15 | # * it under the terms of the GNU General Public License as published by * 16 | # * the Free Software Foundation; either version 2 of the License, or * 17 | # * (at your option) any later version. * 18 | # * * 19 | # ***************************************************************************/ 20 | 21 | 22 | import unittest 23 | from chordal_axis_algorithm import ChordalAxis, GenUtil 24 | from qgis.core import QgsPoint, QgsLineString, QgsPolygon, QgsFeature, QgsGeometry, QgsProcessingFeedback, \ 25 | QgsVectorLayer 26 | from qgis.analysis import QgsNativeAlgorithms 27 | import processing 28 | from qgis.core import QgsApplication 29 | from qgis._3d import Qgs3DAlgorithms 30 | 31 | 32 | 33 | def qgs_line_string_to_xy(qgs_line_string): 34 | 35 | qgs_points = qgs_line_string.points() 36 | lst_x = [] 37 | lst_y = [] 38 | for qgs_point in qgs_points: 39 | lst_x.append(qgs_point.x()) 40 | lst_y.append(qgs_point.y()) 41 | 42 | return (lst_x, lst_y) 43 | 44 | def plot_lines(qgs_line_string, qgs_new_line): 45 | 46 | line0_lst_x, line0_lst_y = qgs_line_string_to_xy(qgs_line_string) 47 | # line1_lst_x, line1_lst_y = qgs_line_string_to_xy(qgs_new_line) 48 | 49 | import matplotlib.pyplot as plt 50 | plt.plot(line0_lst_x, line0_lst_y, 'b') 51 | # plt.plot(line1_lst_x, line1_lst_y, 'r') 52 | plt.show() 53 | 54 | 55 | def build_and_launch(title, qgs_geom_pol, correction=False): 56 | 57 | print(title) 58 | vl = QgsVectorLayer("Polygon", "temporary_polygon", "memory") 59 | pr = vl.dataProvider() 60 | fet = QgsFeature() 61 | fet.setId(1) 62 | fet.setGeometry(qgs_geom_pol) 63 | pr.addFeatures([fet]) 64 | 65 | # update layer's extent when new features have been added 66 | # because change of extent in provider is not propagated to the layer 67 | vl.updateExtents() 68 | feedback = QgsProcessingFeedback() 69 | qgs_multi_triangles = ChordalAxis.tessellate_polygon(vl, feedback) 70 | 71 | ca = ChordalAxis(qgs_multi_triangles[0], GenUtil.ZERO) 72 | if correction: 73 | ca.correct_skeleton() 74 | centre_lines = ca.get_skeleton() 75 | 76 | return centre_lines 77 | 78 | def create_line(coords, ret_geom=True): 79 | 80 | qgs_points = [] 81 | for coord in coords: 82 | qgs_points.append(create_point(coord, False)) 83 | 84 | if ret_geom: 85 | ret_val = QgsGeometry(QgsLineString(qgs_points)) 86 | else: 87 | ret_val = QgsLineString(qgs_points).clone() 88 | 89 | return ret_val 90 | 91 | def create_point(coord, ret_geom=True): 92 | 93 | qgs_point = QgsPoint(coord[0], coord[1]) 94 | if ret_geom: 95 | ret_val = QgsGeometry(qgs_point) 96 | else: 97 | ret_val = qgs_point.clone() 98 | 99 | return ret_val 100 | 101 | def create_polygon(outer, inners): 102 | 103 | outer_line = create_line(outer, False) 104 | qgs_pol = QgsPolygon() 105 | qgs_pol.setExteriorRing(outer_line) 106 | for inner in inners: 107 | inner_line = create_line(inner, False) 108 | qgs_pol.addInteriorRing(inner_line) 109 | qgs_geom = QgsGeometry(qgs_pol) 110 | 111 | return qgs_geom 112 | 113 | def coords_shift(pos, coords): 114 | 115 | new_coords = coords[pos:] + coords[0:pos-1] + [coords[pos]] 116 | 117 | return new_coords 118 | 119 | def create_polygon(outer, inners): 120 | 121 | outer_line = create_line(outer, False) 122 | qgs_pol = QgsPolygon() 123 | qgs_pol.setExteriorRing(outer_line) 124 | for inner in inners: 125 | inner_line = create_line(inner, False) 126 | qgs_pol.addInteriorRing(inner_line) 127 | qgs_geom = QgsGeometry(qgs_pol) 128 | 129 | return qgs_geom 130 | 131 | class Test(unittest.TestCase): 132 | """ 133 | Class allowing to test the algorithm 134 | """ 135 | 136 | def test_case01(self): 137 | title = "Test 01 - Triangle - No skeleton produced" 138 | qgs_geom_pol = create_polygon([(0,0), (10,10), (20,0), (0,0)], []) 139 | centre_lines = build_and_launch(title, qgs_geom_pol, correction=False) 140 | self.assertTrue(len(centre_lines)==0, title) 141 | 142 | def test_case02(self): 143 | title = "Test 02 Small square - 2 triangles terminal without correction" 144 | qgs_geom_pol = create_polygon([(0,0), (10,0), (10,10), (0,10), (0,0)], []) 145 | 146 | centre_lines = build_and_launch(title, qgs_geom_pol, correction=False) 147 | qgs_geom0 = create_line([(0, 0), (5,5), (10,10)]) 148 | val0 = qgs_geom0.equals(QgsGeometry(centre_lines[0].clone())) 149 | self.assertTrue(val0, title) 150 | 151 | def test_case03(self): 152 | title = "Test 03 Small square - 2 triangles terminal with correction" 153 | qgs_geom_pol = create_polygon([(0,0), (10,0), (10,10), (0,10), (0,0)], []) 154 | 155 | centre_lines = build_and_launch(title, qgs_geom_pol, correction=False) 156 | qgs_geom0 = create_line([(0, 0), (5,5), (10,10)]) 157 | val0 = qgs_geom0.equals(QgsGeometry(centre_lines[0].clone())) 158 | self.assertTrue(val0, title) 159 | 160 | def test_case04(self): 161 | title = "Test 04 Long rectangle - 2 triangles terminal and 2 sleeves without correction" 162 | qgs_geom_pol = create_polygon([(0,0), (0,10), (10,10), (20,10), (20,0), (10,0), (0,0)], []) 163 | 164 | centre_lines = build_and_launch(title, qgs_geom_pol, correction=False) 165 | qgs_geom0 = create_line([(0,0), (5,5), (10,5), (15,5), (20,10)]) 166 | val0 = qgs_geom0.equals(QgsGeometry(centre_lines[0].clone())) 167 | self.assertTrue(val0, title) 168 | 169 | def test_case05(self): 170 | title = "Test 05 Long rectangle - 2 triangles terminal and 2 sleeves with correction" 171 | qgs_geom_pol = create_polygon([(0,0), (0,10), (10,10), (20,10), (20,0), (10,0), (0,0)], []) 172 | 173 | centre_lines = build_and_launch(title, qgs_geom_pol, correction=True) 174 | qgs_geom0 = create_line([(0,0), (5,5), (10,5), (15,5), (20,10)]) 175 | val0 = qgs_geom0.equals(QgsGeometry(centre_lines[0].clone())) 176 | self.assertTrue(val0, title) 177 | 178 | def test_case06(self): 179 | title = "Test 06 Long rectangle - with junction terminal without correction" 180 | qgs_geom_pol = create_polygon( [(0, 0), (0, 10), (9, 10), (10, 11), (11, 10), (20, 10), (20, 0), (10, 0), (0, 0)], []) 181 | centre_lines = build_and_launch(title, qgs_geom_pol, correction=False) 182 | qgs_geom0 = create_line([(10,6.66666666666666696), (9.5,5), (4.5,5), (0,10)]) 183 | qgs_geom1 = create_line([(10,6.66666666666666696), (10,10), (10,11)]) 184 | qgs_geom2 = create_line([(10,6.66666666666666696), (10.5,5), (15.5,5), (20,10)]) 185 | val0 = qgs_geom0.equals(QgsGeometry(centre_lines[0].clone())) 186 | val1 = qgs_geom1.equals(QgsGeometry(centre_lines[1].clone())) 187 | val2 = qgs_geom2.equals(QgsGeometry(centre_lines[2].clone())) 188 | self.assertTrue(val0 and val1 and val2, title) 189 | 190 | def test_case07(self): 191 | title = "Test 07 Long rectangle - with junction terminal with correction" 192 | qgs_geom_pol = create_polygon([(0,0), (0,10), (9,10), (10,11), (11,10), (20,10), (20,0), (10,0), (0,0)], []) 193 | centre_lines = build_and_launch(title, qgs_geom_pol, correction=True) 194 | qgs_geom0 = create_line([(0,10), (4.5,5), (9.5,5), (10.5,5), (15.5,5), (20,10)]) 195 | val0 = qgs_geom0.equals(QgsGeometry(centre_lines[0].clone())) 196 | self.assertTrue(val0, title) 197 | 198 | def test_case08(self): 199 | title = "Test 08 Narrow T Junction without correction" 200 | qgs_geom_pol = create_polygon([(0,0), (0,10), (25,10), (50,10), (50,0), (30,0), (30,-30), (20,-30), (20,0), (0,0)], []) 201 | centre_lines = build_and_launch(title, qgs_geom_pol, correction=False) 202 | qgs_geom0 = create_line([(0, 0), (10, 5), (22.5, 5), (25, 3.33333333333333348)]) 203 | qgs_geom1 = create_line([(20, -30), (25, -15), (25, 0), (25, 3.33333333333333348)]) 204 | qgs_geom2 = create_line([(25, 3.33333333333333348), (27.5, 5), (40, 5), (50, 0)]) 205 | val0 = qgs_geom0.equals(QgsGeometry(centre_lines[0].clone())) 206 | val1 = qgs_geom1.equals(QgsGeometry(centre_lines[1].clone())) 207 | val2 = qgs_geom2.equals(QgsGeometry(centre_lines[2].clone())) 208 | self.assertTrue(val0 and val1 and val2, title) 209 | 210 | def test_case09(self): 211 | title = "Test 09 Narrow T Junction with correction" 212 | qgs_geom_pol = create_polygon([(0,0), (0,10), (25,10), (50,10), (50,0), (30,0), (30,-30), (20,-30), (20,0), (0,0)], []) 213 | centre_lines = build_and_launch(title, qgs_geom_pol, correction=True) 214 | qgs_geom0 = create_line([(0, 0), (10, 5), (22.5, 5), (25, 5)]) 215 | qgs_geom1 = create_line([(20, -30), (25, -15), (25, 0), (25, 5)]) 216 | qgs_geom2 = create_line([(25, 5), (27.5, 5), (40, 5), (50, 0)]) 217 | val0 = qgs_geom0.equals(QgsGeometry(centre_lines[0].clone())) 218 | val1 = qgs_geom1.equals(QgsGeometry(centre_lines[1].clone())) 219 | val2 = qgs_geom2.equals(QgsGeometry(centre_lines[2].clone())) 220 | self.assertTrue(val0 and val1 and val2, title) 221 | 222 | def test_case10(self): 223 | title = "Test 10 Narrow X Junction without correction" 224 | qgs_geom_pol = create_polygon([(0,0), (0,10), (20,10), (20,40),(30,40),(30,10), (50,10), (50,0), (30,0), (30,-30), (20,-30), (20,0), (0,0)], []) 225 | centre_lines = build_and_launch(title, qgs_geom_pol, correction=False) 226 | qgs_geom0 = create_line([(0, 0), (10, 5), (20, 5), (23.33333333333333215, 3.33333333333333348)]) 227 | qgs_geom1 = create_line([(20, -30), (25, -15), (25, 0), (23.33333333333333215, 3.33333333333333348)]) 228 | qgs_geom2 = create_line([(23.33333333333333215, 3.33333333333333348), (25, 5), (26.66666666666666785, 6.66666666666666696)]) 229 | qgs_geom3 = create_line([(26.66666666666666785, 6.66666666666666696), (25, 10), (25, 25), (30, 40)]) 230 | qgs_geom4 = create_line([(26.66666666666666785, 6.66666666666666696), (30, 5), (40, 5), (50, 10)]) 231 | val0 = qgs_geom0.equals(QgsGeometry(centre_lines[0].clone())) 232 | val1 = qgs_geom1.equals(QgsGeometry(centre_lines[1].clone())) 233 | val2 = qgs_geom2.equals(QgsGeometry(centre_lines[2].clone())) 234 | val3 = qgs_geom3.equals(QgsGeometry(centre_lines[3].clone())) 235 | val4 = qgs_geom4.equals(QgsGeometry(centre_lines[4].clone())) 236 | self.assertTrue(val0 and val1 and val2 and val3 and val4, title) 237 | 238 | def test_case11(self): 239 | title = "Test 11 Narrow X Junction with correction" 240 | qgs_geom_pol = create_polygon([(0,0), (0,10), (20,10), (20,40),(30,40),(30,10), (50,10), (50,0), (30,0), (30,-30), (20,-30), (20,0), (0,0)], []) 241 | centre_lines = build_and_launch(title, qgs_geom_pol, correction=True) 242 | qgs_geom0 = create_line([(0, 0), (10, 5), (20, 5), (25, 5)]) 243 | qgs_geom1 = create_line([(20, -30), (25, -15), (25, 0), (25, 5)]) 244 | qgs_geom2 = create_line([(25, 5), (30, 5), (40, 5), (50, 10)]) 245 | qgs_geom3 = create_line([(25, 5), (25, 10), (25, 25), (30, 40)]) 246 | val0 = qgs_geom0.equals(QgsGeometry(centre_lines[0].clone())) 247 | val1 = qgs_geom1.equals(QgsGeometry(centre_lines[1].clone())) 248 | val2 = qgs_geom2.equals(QgsGeometry(centre_lines[2].clone())) 249 | val3 = qgs_geom3.equals(QgsGeometry(centre_lines[3].clone())) 250 | self.assertTrue(val0 and val1 and val2 and val3, title) 251 | 252 | 253 | QgsApplication.processingRegistry().addProvider(Qgs3DAlgorithms(QgsApplication.processingRegistry())) 254 | QgsApplication.setPrefixPath("/usr/bin/qgis", False) 255 | profile_folder = '.' 256 | 257 | # Create a reference to the QgsApplication. Setting the second argument to False disables the GUI. 258 | app = QgsApplication([], False, profile_folder) 259 | 260 | # Load providers and init QGIS 261 | app.initQgis() 262 | QgsApplication.processingRegistry().addProvider(Qgs3DAlgorithms(QgsApplication.processingRegistry())) 263 | from processing.core.Processing import Processing 264 | Processing.initialize() 265 | QgsApplication.processingRegistry().addProvider(QgsNativeAlgorithms()) -------------------------------------------------------------------------------- /geo_sim_processing.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # /*************************************************************************** 4 | # geo_sim_processing.py 5 | # ---------- 6 | # Date : January 2021 7 | # copyright : (C) 2020 by Natural Resources Canada 8 | # email : daniel.pilon@canada.ca 9 | # 10 | # ***************************************************************************/ 11 | # 12 | # /*************************************************************************** 13 | # * * 14 | # * This program is free software; you can redistribute it and/or modify * 15 | # * it under the terms of the GNU General Public License as published by * 16 | # * the Free Software Foundation; either version 2 of the License, or * 17 | # * (at your option) any later version. * 18 | # * * 19 | # ***************************************************************************/ 20 | 21 | 22 | __author__ = 'Daniel Pilon' 23 | __date__ = '2021-01-27' 24 | __copyright__ = '(C) 2021 by Daniel Pilon' 25 | 26 | # This will get replaced with a git SHA1 when you do a git archive 27 | 28 | __revision__ = '$Format:%H$' 29 | 30 | import os 31 | import sys 32 | import inspect 33 | 34 | from qgis.core import QgsProcessingAlgorithm, QgsApplication 35 | from .geo_sim_processing_provider import GeoSimplificationProvider 36 | 37 | cmd_folder = os.path.split(inspect.getfile(inspect.currentframe()))[0] 38 | 39 | if cmd_folder not in sys.path: 40 | sys.path.insert(0, cmd_folder) 41 | 42 | 43 | class GeoSimplificationPlugin(object): 44 | 45 | def __init__(self): 46 | self.provider = None 47 | 48 | def initProcessing(self): 49 | """Init Processing provider for QGIS >= 3.8.""" 50 | self.provider = GeoSimplificationProvider() 51 | QgsApplication.processingRegistry().addProvider(self.provider) 52 | 53 | def initGui(self): 54 | self.initProcessing() 55 | 56 | def unload(self): 57 | QgsApplication.processingRegistry().removeProvider(self.provider) 58 | -------------------------------------------------------------------------------- /geo_sim_processing_provider.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # /*************************************************************************** 4 | # geo_sim_processing_provider.py 5 | # ---------- 6 | # Date : January 2021 7 | # copyright : (C) 2020 by Natural Resources Canada 8 | # email : daniel.pilon@canada.ca 9 | # 10 | # ***************************************************************************/ 11 | # 12 | # /*************************************************************************** 13 | # * * 14 | # * This program is free software; you can redistribute it and/or modify * 15 | # * it under the terms of the GNU General Public License as published by * 16 | # * the Free Software Foundation; either version 2 of the License, or * 17 | # * (at your option) any later version. * 18 | # * * 19 | # ***************************************************************************/ 20 | 21 | 22 | __author__ = 'Daniel Pilon' 23 | __date__ = '2021-01-27' 24 | __copyright__ = '(C) 2021 by Daniel Pilon' 25 | 26 | # This will get replaced with a git SHA1 when you do a git archive 27 | 28 | __revision__ = '$Format:%H$' 29 | 30 | from qgis.core import QgsProcessingProvider 31 | from .reduce_bend_algorithm import ReduceBendAlgorithm 32 | from .simplify_algorithm import SimplifyAlgorithm 33 | from .chordal_axis_algorithm import ChordalAxisAlgorithm 34 | import os 35 | 36 | import inspect 37 | from qgis.PyQt.QtGui import QIcon 38 | 39 | 40 | class GeoSimplificationProvider(QgsProcessingProvider): 41 | 42 | def __init__(self): 43 | """ 44 | Default constructor. 45 | """ 46 | QgsProcessingProvider.__init__(self) 47 | 48 | def unload(self): 49 | """ 50 | Unloads the provider. Any tear-down steps required by the provider 51 | should be implemented here. 52 | """ 53 | pass 54 | 55 | def loadAlgorithms(self): 56 | """ 57 | Loads all algorithms belonging to this provider. 58 | """ 59 | self.addAlgorithm(ChordalAxisAlgorithm()) 60 | self.addAlgorithm(ReduceBendAlgorithm()) 61 | self.addAlgorithm(SimplifyAlgorithm()) 62 | 63 | # add additional algorithms here 64 | # self.addAlgorithm(MyOtherAlgorithm()) 65 | 66 | def id(self): 67 | """ 68 | Returns the unique provider id, used for identifying the provider. This 69 | string should be a unique, short, character only string, eg "qgis" or 70 | "gdal". This string should not be localised. 71 | """ 72 | return 'geo_sim_processing' 73 | 74 | def name(self): 75 | """ 76 | Returns the provider name, which is used to describe the provider 77 | within the GUI. 78 | 79 | This string should be short (e.g. "Lastools") and localised. 80 | """ 81 | return self.tr('Geo Simplification') 82 | 83 | def icon(self): 84 | """ 85 | Should return a QIcon which is used for your provider inside 86 | the Processing toolbox. 87 | """ 88 | cmd_folder = os.path.split(inspect.getfile(inspect.currentframe()))[0] 89 | icon = QIcon(os.path.join(os.path.join(cmd_folder, 'logo.png'))) 90 | return icon 91 | 92 | def longName(self): 93 | """ 94 | Returns the a longer version of the provider name, which can include 95 | extra details such as version numbers. E.g. "Lastools LIDAR tools 96 | (version 2.2.1)". This string should be localised. The default 97 | implementation returns the same string as name(). 98 | """ 99 | return self.name() 100 | -------------------------------------------------------------------------------- /geo_sim_util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=no-name-in-module 3 | # pylint: disable=too-many-lines 4 | # pylint: disable=useless-return 5 | # pylint: disable=too-few-public-methods 6 | 7 | # /*************************************************************************** 8 | # reduce_bend_algorithm.py 9 | # ---------- 10 | # Date : January 2021 11 | # copyright : (C) 2020 by Natural Resources Canada 12 | # email : daniel.pilon@canada.ca 13 | # 14 | # ***************************************************************************/ 15 | # 16 | # /*************************************************************************** 17 | # * * 18 | # * This program is free software; you can redistribute it and/or modify * 19 | # * it under the terms of the GNU General Public License as published by * 20 | # * the Free Software Foundation; either version 2 of the License, or * 21 | # * (at your option) any later version. * 22 | # * * 23 | # ***************************************************************************/ 24 | 25 | """ 26 | QGIS Plugin for Bend reduction 27 | """ 28 | 29 | import math 30 | import sys 31 | from abc import ABC, abstractmethod 32 | from qgis.core import (QgsLineString, QgsWkbTypes, QgsSpatialIndex, QgsGeometry, QgsPolygon, 33 | QgsGeometryUtils, QgsRectangle, QgsProcessingException) 34 | 35 | 36 | class Epsilon: 37 | """Class defining the value of the zero""" 38 | 39 | ZERO_RELATIVE = None 40 | ZERO_ABSOLUTE = None 41 | ZERO_ANGLE = None 42 | 43 | __slots__ = '_zero_relative', '_zero_absolute', '_zero_angle', '_map_range' 44 | 45 | def __init__(self, features): 46 | """Constructor that initialize the Epsilon (near zero) object. 47 | 48 | The dynamic (range) of the feature can vary a lot. We calculate the dynamic of the bounding box of all 49 | the features and we use it to estimate an epsilon (zero). when the range of the bounding box is very small 50 | the epsilon can be very small and the opposite when the bigger the bounding box is. 51 | 52 | :param: [QgsFeatures] features: List of QgsFeature to process. 53 | :return: None 54 | :rtype: None 55 | """ 56 | 57 | if len(features) >= 1: 58 | b_box = features[0].geometry().boundingBox() # Initialize the bounding box 59 | else: 60 | b_box = QgsRectangle(0, 0, 1, 1) # Manage empty list of feature 61 | 62 | for feature in features: 63 | b_box.combineExtentWith(feature.geometry().boundingBox()) # Update the bbox 64 | 65 | delta_x = abs(b_box.xMinimum()) + abs(b_box.xMaximum()) 66 | delta_y = abs(b_box.yMinimum()) + abs(b_box.yMaximum()) 67 | dynamic_xy = max(delta_x, delta_y) # Dynamic of the bounding box 68 | if dynamic_xy == 0.0: 69 | dynamic_xy = 1.0E-15 70 | log_loss = int(math.log(dynamic_xy, 10)+1) 71 | max_digit = 15 # Number of meaningful digits for real number 72 | security = 2 # Keep 2 order of magnitude of security 73 | abs_digit = max_digit - security 74 | rel_digit = max_digit - log_loss - security 75 | self._zero_relative = (1. / (10**rel_digit)) 76 | self._zero_absolute = (1. / (10**abs_digit)) 77 | self._zero_angle = math.radians(.0001) # Angle used to decide a flat angle 78 | 79 | def set_class_variables(self): 80 | """Set the different epsilon values. 81 | 82 | :return: None 83 | :rtype: None 84 | """ 85 | 86 | Epsilon.ZERO_RELATIVE = self._zero_relative 87 | Epsilon.ZERO_ABSOLUTE = self._zero_absolute 88 | Epsilon.ZERO_ANGLE = self._zero_angle 89 | 90 | return 91 | 92 | 93 | class ProgressBar: 94 | """Class used for managing the progress bar in the QGIS desktop 95 | 96 | """ 97 | 98 | def __init__(self, feedback, max_value, message=None): 99 | """Constructor of the ProgressBar class 100 | 101 | :param: feedback: feedback handle for interaction with the QGIS desktop 102 | :param: max_value: Integer of the maximum value """ 103 | 104 | self.feedback = feedback 105 | self.max_value = max_value 106 | self.progress_bar_value = 0 107 | self.feedback.setProgress(self.progress_bar_value) 108 | if message is not None or message != "": 109 | self.feedback.pushInfo(message) 110 | 111 | def set_value(self, value): 112 | """Set the value of the progress bar 113 | 114 | :param: value: Integer value to use to set the progress bar 115 | 116 | """ 117 | 118 | percent_value = int(value/self.max_value*100.) 119 | if percent_value != self.progress_bar_value: 120 | self.progress_bar_value = percent_value 121 | self.feedback.setProgress(self.progress_bar_value) 122 | 123 | 124 | 125 | 126 | class GsCollection: 127 | """Class used for managing the QgsFeature spatially. 128 | 129 | QgsSpatialIndex class is used to store and retrieve the features. 130 | """ 131 | 132 | __slots__ = ('_spatial_index', '_dict_qgs_segment', '_id_qgs_segment') 133 | 134 | def __init__(self): 135 | """Constructor that initialize the GsCollection. 136 | 137 | """ 138 | 139 | self._spatial_index = QgsSpatialIndex() 140 | self._dict_qgs_segment = {} # Contains a reference to the original geometry 141 | self._id_qgs_segment = 0 142 | 143 | def _get_next_id_segment(self): 144 | """Increment the id of the segment. 145 | 146 | :return: Value of the next ID 147 | :rtype: int 148 | """ 149 | 150 | self._id_qgs_segment += 1 151 | 152 | return self._id_qgs_segment 153 | 154 | def _create_rectangle(self, geom_id, qgs_geom): 155 | """Creates a new QgsRectangle to load in the QgsSpatialIndex. 156 | 157 | :param: geom_id: Integer ID of the geometry 158 | :param: qgs_geom: QgsGeometry to use for bounding box extraction 159 | :return: The feature created 160 | :rtype: QgsFeature 161 | """ 162 | 163 | id_segment = self._get_next_id_segment() 164 | self._dict_qgs_segment[id_segment] = (geom_id, qgs_geom) # Reference to the RbGeom ID and geometry 165 | 166 | return id_segment, qgs_geom.boundingBox() 167 | 168 | def add_features(self, rb_geoms, feedback): 169 | """Add a RbGeom object in the spatial index. 170 | 171 | For the LineString geometries. The geometry is broken into each line segment that are individually 172 | loaded in the QgsSpatialIndex. This strategy accelerate the validation of the spatial constraints. 173 | 174 | :param: rb_geoms: List of RbGeom to load in the QgsSpatialIndex 175 | :feedback: QgsFeedback handle used to update the progress bar 176 | """ 177 | 178 | progress_bar = ProgressBar(feedback, len(rb_geoms), "Building internal structure...") 179 | for val, rb_geom in enumerate(rb_geoms): 180 | progress_bar.set_value(val) 181 | qgs_rectangles = [] 182 | if rb_geom.qgs_geom.wkbType() == QgsWkbTypes.Point: 183 | qgs_rectangles.append(self._create_rectangle(rb_geom.id, rb_geom.qgs_geom)) 184 | else: 185 | qgs_points = rb_geom.qgs_geom.constGet().points() 186 | for i in range(0, (len(qgs_points)-1)): 187 | qgs_geom = QgsGeometry(QgsLineString(qgs_points[i], qgs_points[i+1])) 188 | qgs_rectangles.append(self._create_rectangle(rb_geom.id, qgs_geom)) 189 | 190 | for geom_id, qgs_rectangle in qgs_rectangles: 191 | self._spatial_index.addFeature(geom_id, qgs_rectangle) 192 | 193 | return 194 | 195 | def get_segment_intersect(self, qgs_geom_id, qgs_rectangle, qgs_geom_subline): 196 | """Find the feature that intersects the bounding box. 197 | 198 | Once the line string intersecting the bounding box are found. They are separated into 2 lists. 199 | The first one being the line string with the same id (same line) the second one all the others line string. 200 | 201 | :param qgs_geom_id: ID of the line string that is being simplified 202 | :param qgs_rectangle: QgsRectangle used for feature intersection 203 | :param qgs_geom_subline: LineString used to remove line segment superimposed to this line string 204 | :return: Two lists of line string segment. First: Line string with same id; Second all the others 205 | :rtype: tuple of 2 lists 206 | """ 207 | 208 | qgs_geoms_with_itself = [] 209 | qgs_geoms_with_others = [] 210 | qgs_rectangle.grow(Epsilon.ZERO_RELATIVE*100.) # Always increase the b_box to avoid degenerated b_box 211 | ids = self._spatial_index.intersects(qgs_rectangle) 212 | for geom_id in ids: 213 | target_qgs_geom_id, target_qgs_geom = self._dict_qgs_segment[geom_id] 214 | if target_qgs_geom_id is None: 215 | # Nothing to do; segment was deleted 216 | pass 217 | else: 218 | if target_qgs_geom_id == qgs_geom_id: 219 | # Test that the segment is not part of qgs_subline 220 | if not target_qgs_geom.within(qgs_geom_subline): 221 | qgs_geoms_with_itself.append(target_qgs_geom) 222 | else: 223 | qgs_geoms_with_others.append(target_qgs_geom) 224 | 225 | return qgs_geoms_with_itself, qgs_geoms_with_others 226 | 227 | def _delete_segment(self, qgs_geom_id, qgs_pnt0, qgs_pnt1): 228 | """Delete a line segment in the spatial index based on start/end points. 229 | 230 | To minimise the number of feature returned we search for a very small bounding box located in the middle 231 | of the line segment. Usually only one line segment is returned. 232 | 233 | :param qgs_geom_id: Integer ID of the geometry 234 | :param qgs_pnt0 : QgsPoint start point of the target line segment. 235 | :param qgs_pnt1 : QgsPoint end point of the target line segment. 236 | """ 237 | 238 | qgs_geom_to_delete = QgsGeometry(QgsLineString(qgs_pnt0, qgs_pnt1)) 239 | qgs_mid_point = QgsGeometryUtils.midpoint(qgs_pnt0, qgs_pnt1) 240 | qgs_rectangle = qgs_mid_point.boundingBox() 241 | qgs_rectangle.grow(Epsilon.ZERO_RELATIVE*100) 242 | deleted = False 243 | ids = self._spatial_index.intersects(qgs_rectangle) 244 | for geom_id in ids: 245 | target_qgs_geom_id, target_qgs_geom = self._dict_qgs_segment[geom_id] # Extract id and geometry 246 | if qgs_geom_id == target_qgs_geom_id: 247 | # Only check for the same ID 248 | if target_qgs_geom.equals(qgs_geom_to_delete): # Check if it's the same geometry 249 | deleted = True 250 | self._dict_qgs_segment[geom_id] = (None, None) # Delete from the internal structure 251 | break 252 | 253 | if not deleted: 254 | raise Exception(QgsProcessingException("Internal structure corruption...")) 255 | 256 | return 257 | 258 | def _delete_vertex(self, rb_geom, v_id_start, v_id_end): 259 | """Delete consecutive vertex in the line and update the spatial index. 260 | 261 | When a vertex in a line string is deleted. Two line segments are deleted and one line segment is 262 | created in the spatial index. Cannot delete the first/last vertex of a line string 263 | 264 | :param rb_geom: LineString object to update. 265 | :param v_id_start: start of the vertex to delete. 266 | :param v_id_end: end of the vertex to delete. 267 | """ 268 | 269 | is_closed = rb_geom.qgs_geom.constGet().isClosed() 270 | v_ids_to_del = list(range(v_id_start, v_id_end+1)) 271 | if v_id_start == 0 and is_closed: 272 | # Special case for closed line where we simulate a circular array 273 | nbr_vertice = rb_geom.qgs_geom.constGet().numPoints() 274 | v_ids_to_del.insert(0, nbr_vertice - 2) 275 | else: 276 | v_ids_to_del.insert(0, v_ids_to_del[0]-1) 277 | v_ids_to_del.append(v_ids_to_del[-1]+1) 278 | 279 | # Delete the line segment in the spatial index 280 | for i in range(len(v_ids_to_del)-1): 281 | qgs_pnt0 = rb_geom.qgs_geom.vertexAt(v_ids_to_del[i]) 282 | qgs_pnt1 = rb_geom.qgs_geom.vertexAt(v_ids_to_del[i+1]) 283 | self._delete_segment(rb_geom.id, qgs_pnt0, qgs_pnt1) 284 | 285 | # Add the new line segment in the spatial index 286 | qgs_pnt0 = rb_geom.qgs_geom.vertexAt(v_ids_to_del[0]) 287 | qgs_pnt1 = rb_geom.qgs_geom.vertexAt(v_ids_to_del[-1]) 288 | qgs_geom_segment = QgsGeometry(QgsLineString(qgs_pnt0, qgs_pnt1)) 289 | geom_id, qgs_rectangle = self._create_rectangle(rb_geom.id, qgs_geom_segment) 290 | self._spatial_index.addFeature(geom_id, qgs_rectangle) 291 | 292 | # Delete the vertex in the line string geometry 293 | for v_id_to_del in reversed(range(v_id_start, v_id_end+1)): 294 | rb_geom.qgs_geom.deleteVertex(v_id_to_del) 295 | if v_id_start == 0 and is_closed: 296 | # Special case for closed line where we simulate a circular array 297 | nbr_vertice = rb_geom.qgs_geom.constGet().numPoints() 298 | qgs_pnt_first = rb_geom.qgs_geom.vertexAt(0) 299 | rb_geom.qgs_geom.insertVertex(qgs_pnt_first, nbr_vertice-1) 300 | rb_geom.qgs_geom.deleteVertex(nbr_vertice) 301 | 302 | return 303 | 304 | def delete_vertex(self, rb_geom, v_id_start, v_id_end): 305 | """Manage deletion of consecutives vertex. 306 | 307 | If v_id_start is greater than v_id_end the delete is broken into up to 3 calls 308 | 309 | :param rb_geom: LineString object to update. 310 | :param v_id_start: start of the vertex to delete. 311 | :param v_id_end: end of the vertex to delete. 312 | """ 313 | 314 | num_points = rb_geom.qgs_geom.constGet().numPoints() 315 | # Manage closes line where first/last vertice are the same 316 | if v_id_start == num_points-1: 317 | v_id_start = 0 # Last point is the same as the first vertice 318 | if v_id_end == -1: 319 | v_id_end = num_points -2 # Preceding point the first/last vertice 320 | 321 | if v_id_start <= v_id_end: 322 | self._delete_vertex(rb_geom, v_id_start, v_id_end) 323 | else: 324 | self._delete_vertex(rb_geom, v_id_start, num_points-2) 325 | self._delete_vertex(rb_geom, 0, 0) 326 | if v_id_end > 0: 327 | self._delete_vertex(rb_geom, 1, v_id_end) 328 | # lst_vertex_to_del = list(range(v_id_start, num_points)) + list(range(0, v_id_end+1)) 329 | # for vertex_to_del in lst_vertex_to_del: 330 | # self._delete_vertex(rb_geom, vertex_to_del, vertex_to_del) 331 | 332 | # num_points = rb_geom.qgs_geom.constGet().numPoints() 333 | # lst_vertex_to_del = list(range(v_id_start, num_points)) + list(range(0, v_id_end + 1)) 334 | # for vertex_to_del in lst_vertex_to_del: 335 | # self._delete_vertex(rb_geom, vertex_to_del, vertex_to_del) 336 | 337 | def add_vertex(self, rb_geom, bend_i, bend_j, qgs_geom_new_subline): 338 | """Update the line segment in the spatial index 339 | 340 | :param rb_geom: RbGeom line to update 341 | :param bend_i: Start of the bend to delete 342 | :param bend_j: End of the bend to delete (always bend_i + 1) 343 | :param qgs_geom_new_subline: New sub line string to add in the spatial index 344 | :return: 345 | """ 346 | 347 | # Delete the base of the bend 348 | qgs_pnt0 = rb_geom.qgs_geom.vertexAt(bend_i) 349 | qgs_pnt1 = rb_geom.qgs_geom.vertexAt(bend_j) 350 | self._delete_segment(rb_geom.id, qgs_pnt0, qgs_pnt1) 351 | 352 | qgs_points = qgs_geom_new_subline.constGet().points() 353 | tmp_qgs_points = qgs_points[1:-1] # Drop first/last item 354 | # Insert the new vertex in the QgsGeometry. Work reversely to facilitate insertion 355 | for qgs_point in reversed(tmp_qgs_points): 356 | rb_geom.qgs_geom.insertVertex(qgs_point, bend_j) 357 | 358 | # Add the new segment in the spatial container 359 | for i in range(len(qgs_points)-1): 360 | qgs_geom_segment = QgsGeometry(QgsLineString(qgs_points[i], qgs_points[i+1])) 361 | geom_id, qgs_rectangle = self._create_rectangle(rb_geom.id, qgs_geom_segment) 362 | self._spatial_index.addFeature(geom_id, qgs_rectangle) 363 | 364 | return 365 | 366 | def validate_integrity(self, rb_geoms): 367 | """This method is used to validate the data structure at the end of the process 368 | 369 | This method is executed only when requested and for debug purpose only. It's validating the data structure 370 | by removing element from it the data structure is unusable after. Validate integrity must be the last 371 | operation before ending the program as it destroy the data structure... 372 | 373 | :param rb_geoms: Geometry contained in the spatial container 374 | :return: Flag indicating if the structure is valid. True: is valid; False: is not valid 375 | :rtype: Boolean 376 | """ 377 | 378 | is_structure_valid = True 379 | # from the geometry remove all the segment in the spatial index. 380 | for rb_geom in rb_geoms: 381 | qgs_line_string = rb_geom.qgs_geom.constGet() 382 | if qgs_line_string.wkbType() == QgsWkbTypes.LineString: 383 | qgs_points = qgs_line_string.points() 384 | for i in range(len(qgs_points)-1): 385 | self._delete_segment(rb_geom.id, qgs_points[i], qgs_points[i+1]) 386 | 387 | if is_structure_valid: 388 | # Verify that there are no other feature in the spatial index; except for QgsPoint 389 | qgs_rectangle = QgsRectangle(-sys.float_info.max, -sys.float_info.max, 390 | sys.float_info.max, sys.float_info.max) 391 | feat_ids = self._spatial_index.intersects(qgs_rectangle) 392 | for feat_id in feat_ids: 393 | qgs_geom = self._spatial_index.geometry(feat_id) 394 | if qgs_geom.wkbType() == QgsWkbTypes.Point: 395 | pass 396 | else: 397 | # Error 398 | is_structure_valid = False 399 | 400 | return is_structure_valid 401 | 402 | 403 | class GsFeature(ABC): 404 | """Contain one QgsFeature 405 | 406 | Abstract class specialized into processing specific geometries 407 | """ 408 | 409 | _id_counter = 0 # Counter of feature 410 | 411 | @staticmethod 412 | def is_point(feature_type): 413 | """Static method which determine if a QgsFeature is any kind of Point. 414 | 415 | :param feature_type: Feature type to validate. 416 | :return: True if a point False otherwise 417 | :rtype: bool 418 | """ 419 | 420 | val = feature_type in [QgsWkbTypes.Point, QgsWkbTypes.Point25D, QgsWkbTypes.PointM, QgsWkbTypes.PointZ, 421 | QgsWkbTypes.PointZM] 422 | 423 | return val 424 | 425 | @staticmethod 426 | def is_line_string(feature_type): 427 | """Static method which determine if a QgsFeature is any kind of LineString. 428 | 429 | :param feature_type: Feature type to validate. 430 | :return: True if it's a LineString; False otherwise 431 | :rtype: bool 432 | """ 433 | 434 | val = feature_type in [QgsWkbTypes.LineString, QgsWkbTypes.LineString25D, QgsWkbTypes.LineStringZ, 435 | QgsWkbTypes.LineStringM, QgsWkbTypes.LineStringZM] 436 | 437 | return val 438 | 439 | @staticmethod 440 | def is_polygon(feature_type): 441 | """Static method which determine if a QgsFeature is any kind of Polygon. 442 | 443 | :param feature_type: Feature type to validate. 444 | :return: True if a Polygon False otherwise 445 | :rtype: bool 446 | """ 447 | val = feature_type in [QgsWkbTypes.Polygon, QgsWkbTypes.Polygon25D, QgsWkbTypes.PolygonZ, QgsWkbTypes.PolygonM, 448 | QgsWkbTypes.PolygonZM] 449 | 450 | return val 451 | 452 | @staticmethod 453 | def create_gs_feature(qgs_in_features): 454 | """Create the different GsFeatures from the QgsFeatures. 455 | 456 | :param: qgs_in_features: List of QgsFeature to process 457 | :return: List of rb_features 458 | :rtype: [GsFeature] 459 | """ 460 | 461 | rb_features = [] 462 | 463 | for qgs_feature in qgs_in_features: 464 | qgs_geom = qgs_feature.geometry() # extract the Geometry 465 | 466 | if GsFeature.is_polygon(qgs_geom.wkbType()): 467 | rb_features.append(GsPolygon(qgs_feature)) 468 | elif GsFeature.is_line_string(qgs_geom.wkbType()): 469 | rb_features.append(GsLineString(qgs_feature)) 470 | elif GsFeature.is_point(qgs_geom.wkbType()): 471 | rb_features.append(GsPoint(qgs_feature)) 472 | else: 473 | raise QgsProcessingException("Internal geometry error") 474 | 475 | return rb_features 476 | 477 | def __init__(self, qgs_feature): 478 | """Constructor of the GsFeature class. 479 | 480 | :param qgs_feature: QgsFeature to process. 481 | """ 482 | 483 | self.qgs_feature = qgs_feature 484 | self.id = GsFeature._id_counter 485 | GsFeature._id_counter += 1 486 | abs_geom = qgs_feature.geometry().constGet() 487 | self.qgs_geom = QgsGeometry(abs_geom.clone()) 488 | self.qgs_feature.clearGeometry() # Empty the geometry. Geometry to be recreated at the end 489 | 490 | @abstractmethod 491 | def get_rb_geom(self): 492 | """Define an abstract method. 493 | """ 494 | 495 | @abstractmethod 496 | def get_qgs_feature(self): 497 | """Define an abstract method. 498 | """ 499 | 500 | 501 | class GsPolygon(GsFeature): 502 | """Class description for GsPolygon""" 503 | 504 | def __init__(self, qgs_feature): 505 | """Constructor that breaks the Polygon into a list of closed LineString (RbGeom). 506 | 507 | :param qgs_feature: QgsFeature polygon to process. 508 | """ 509 | 510 | super().__init__(qgs_feature) 511 | if self.qgs_geom.wkbType() != QgsWkbTypes.Polygon: 512 | self.qgs_geom = self.qgs_geom.coerceToType(QgsWkbTypes.Polygon) # Force geometry to be a QgsPolygon 513 | # Transform geometry into a list a LineString first ring being outer ring 514 | self.qgs_geom = self.qgs_geom.coerceToType(QgsWkbTypes.LineString) 515 | # Breaks the rings into a list of closed RbGeom (LineString). The first one being the outer ring 516 | self.rb_geom = [RbGeom(qgs_geom, QgsWkbTypes.Polygon) for qgs_geom in self.qgs_geom] 517 | self.qgs_geom = None 518 | 519 | def get_rb_geom(self): 520 | """Return the RbGeom. 521 | 522 | :return: The RbGeom of the instance 523 | :rtype: List of RbGeom 524 | """ 525 | 526 | return self.rb_geom 527 | 528 | def get_qgs_feature(self): 529 | """Reconstruct the original QgsFeature with the new geometry. 530 | 531 | :return: The new QgsFeature 532 | :rtype: QgsFeature 533 | """ 534 | 535 | qgs_pol = QgsPolygon() 536 | qgs_pol.setExteriorRing(self.rb_geom[0].qgs_geom.constGet().clone()) 537 | for rb_geom in self.rb_geom[1:]: 538 | qgs_pol.addInteriorRing(rb_geom.qgs_geom.constGet().clone()) 539 | self.qgs_feature.setGeometry(qgs_pol) 540 | 541 | return self.qgs_feature 542 | 543 | 544 | class GsLineString(GsFeature): 545 | """Class managing a GsLineString. 546 | """ 547 | 548 | def __init__(self, qgs_feature): 549 | """Constructor that breaks the LineString into a list of LineString (RbGeom). 550 | 551 | :param qgs_feature: QgsFeature LineString to process. 552 | """ 553 | super().__init__(qgs_feature) 554 | if self.qgs_geom.wkbType() != QgsWkbTypes.LineString: 555 | self.qgs_geom = self.qgs_geom.coerceToType(QgsWkbTypes.LineString) # Force geometry to a QgsPoint 556 | self.rb_geom = [RbGeom(self.qgs_geom, QgsWkbTypes.LineString)] 557 | self.qgs_geom = None 558 | 559 | def get_rb_geom(self): 560 | """Return the RbGeom. 561 | 562 | :return: The RbGeom of the instance 563 | :rtype: List of RbGeom 564 | """ 565 | 566 | return self.rb_geom 567 | 568 | def get_qgs_feature(self): 569 | """Reconstruct the original QgsFeature with the new geometry. 570 | 571 | :return: The new QgsFeature 572 | :rtype: QgsFeature 573 | """ 574 | 575 | qgs_geom = QgsGeometry(self.rb_geom[0].qgs_geom.constGet().clone()) 576 | self.qgs_feature.setGeometry(qgs_geom) 577 | 578 | return self.qgs_feature 579 | 580 | 581 | class GsPoint(GsFeature): 582 | """Class managing a GsPoint 583 | """ 584 | 585 | def __init__(self, qgs_feature): 586 | """Constructor that breaks the Point into a list of Point (RbGeom). 587 | 588 | :param: qgs_feature: QgsFeature Point to process. 589 | """ 590 | 591 | super().__init__(qgs_feature) 592 | if self.qgs_geom.wkbType() != QgsWkbTypes.Point: 593 | self.qgs_geom = self.qgs_geom.coerceToType(QgsWkbTypes.Point) # Force geometry to QgsPoint 594 | self.rb_geom = [RbGeom(self.qgs_geom, QgsWkbTypes.Point)] 595 | self.rb_geom[0].is_simplest = True # A point cannot be reduced 596 | self.qgs_geom = None 597 | 598 | def get_rb_geom(self): 599 | """Return the RbGeom. 600 | 601 | :return: The RbGeom of the instance. 602 | :rtype: List of RbGeom. 603 | """ 604 | 605 | return self.rb_geom 606 | 607 | def get_qgs_feature(self): 608 | """Reconstruct the original QgsFeature with the original geometry. 609 | 610 | A Point cannot be reduced but is needed for the spatial constraints validation 611 | 612 | :return: The new QgsFeature 613 | :rtype: QgsFeature 614 | """ 615 | 616 | qgs_geom = QgsGeometry(self.rb_geom[0].qgs_geom.constGet().clone()) 617 | self.qgs_feature.setGeometry(qgs_geom) 618 | 619 | return self.qgs_feature 620 | 621 | 622 | class RbGeom: 623 | """Class defining the line string used for the bend reduction""" 624 | 625 | __slots__ = ('id', 'original_geom_type', 'is_simplest', 'qgs_geom', 'bends', 'need_pivot') 626 | 627 | _id_counter = 0 # Unique ID counter 628 | 629 | @staticmethod 630 | def next_id(): 631 | """Get the next counterID. 632 | 633 | :param: QgsMultiLineString qgs_multi_line_string: Multi line string to merge together 634 | :return: ID of the RbGeom object 635 | :rtype: int 636 | """ 637 | 638 | RbGeom._id_counter += 1 639 | 640 | return RbGeom._id_counter 641 | 642 | def __init__(self, qgs_abs_geom, original_geom_type): 643 | """Constructor that initialize a RbGeom object. 644 | 645 | :param: qgs_abs_geom: QgsAbstractGeometry to process 646 | :param: original_geom_type: Original type of the geometry 647 | 648 | """ 649 | 650 | self.id = RbGeom.next_id() 651 | self.original_geom_type = original_geom_type 652 | qgs_geometry = qgs_abs_geom.constGet() 653 | self.qgs_geom = QgsGeometry(qgs_geometry.clone()) 654 | self.is_simplest = False 655 | self.need_pivot = False 656 | self.bends = None 657 | # Set some variable depending on the geometry of the feature 658 | if self.original_geom_type == QgsWkbTypes.Point: 659 | self.is_simplest = True # A point cannot be simplified 660 | else: 661 | # Attribute setting for LineString and Polygon 662 | if qgs_geometry.length() >= Epsilon.ZERO_RELATIVE: 663 | if qgs_geometry.isClosed(): 664 | self.need_pivot = True # A closed lined string can be pivoted 665 | else: 666 | self.is_simplest = True # Degenerated LineString... Do not try to simplify... 667 | 668 | 669 | class SimGeom: 670 | """Class defining the line string used for the douglas peucker simplification""" 671 | 672 | __slots__ = ('id', 'original_geom_type', 'is_simplest', 'qgs_geom', 'furthest_index') 673 | 674 | _id_counter = 0 # Unique ID counter 675 | 676 | @staticmethod 677 | def next_id(): 678 | """Get the next counterID. 679 | 680 | :param: QgsMultiLineString qgs_multi_line_string: Multi line string to merge together 681 | :return: ID of the SimGeom object 682 | :rtype: int 683 | """ 684 | 685 | SimGeom._id_counter += 1 686 | 687 | return SimGeom._id_counter 688 | 689 | def __init__(self, qgs_abs_geom, original_geom_type): 690 | """Constructor that initialize a SimGeom object. 691 | 692 | :param: qgs_abs_geom: QgsAbstractGeometry to process 693 | :param: original_geom_type: Original type of the geometry 694 | 695 | """ 696 | 697 | self.id = SimGeom.next_id() 698 | self.original_geom_type = original_geom_type 699 | qgs_geometry = qgs_abs_geom.constGet() 700 | self.qgs_geom = QgsGeometry(qgs_geometry.clone()) 701 | self.is_simplest = False 702 | self.furthest_index = None 703 | # Set some variable depending on the attribute of the feature 704 | if self.original_geom_type == QgsWkbTypes.Point: 705 | self.is_simplest = True # A point cannot be simplified 706 | else: 707 | # Original geometry is LineString or Polygon 708 | if qgs_geometry.length() >= Epsilon.ZERO_RELATIVE: 709 | if qgs_geometry.isClosed(): # Closed LineString 710 | qgs_polygon = QgsPolygon(qgs_geometry.clone()) # Create QgsPolygon to calculate area 711 | if qgs_polygon.area() > Epsilon.ZERO_RELATIVE: 712 | if qgs_geometry.numPoints() <= 4: 713 | self.is_simplest = True # Cannot simplify a closed line with less than 4 vertices 714 | else: 715 | self.is_simplest = True # Degenerated area cannot simplify 716 | else: 717 | if qgs_geometry.numPoints() <= 2: 718 | self.is_simplest = True # Cannot simplify a line with less than 2 vertice 719 | else: 720 | self.is_simplest = True # Degenerated area cannot simplify 721 | 722 | 723 | class Bend: 724 | """Define a Bend object which is the reduction goal of this algorithm""" 725 | 726 | @staticmethod 727 | def calculate_min_adj_area(diameter_tol): 728 | """Static method to calculate the adjusted area of the maximum diameter tolerance. 729 | 730 | :param: diameter_tol: float diameter tolerance to used for bend reduction 731 | :return: Minimum adjusted area of a polygon to reduce 732 | :rtype: Real 733 | """ 734 | 735 | min_adj_area = .75 * math.pi * (diameter_tol / 2.) ** 2 736 | 737 | return min_adj_area 738 | 739 | @staticmethod 740 | def calculate_adj_area(area, perimeter): 741 | """Static method to calculate the adjusted area. 742 | 743 | The adjusted area is used to determine if a bend must be reduce. 744 | 745 | :param: real area: area of a polygon. 746 | :param: real perimeter: perimeter of a polygon. 747 | :return: Adjusted area of a polygon 748 | :rtype: Real 749 | """ 750 | 751 | try: 752 | compactness_index = 4 * area * math.pi / perimeter ** 2 753 | adj_area = area * (.75 / compactness_index) 754 | except ZeroDivisionError: 755 | # Catch division by zero 756 | adj_area = Epsilon.ZERO_RELATIVE 757 | 758 | return adj_area 759 | 760 | __slots__ = ('i', 'j', 'area', 'perimeter', 'adj_area', 'to_reduce', '_qgs_geom_new_subline', 761 | '_qgs_geom_old_subline', '_qgs_points', 'qgs_geom_bend') 762 | 763 | def __init__(self, i, j, qgs_points): 764 | """Constructor that initialize a Bend object. 765 | 766 | :param: int i: start position of the vertice in the LineString to reduce 767 | :param: int j: end position of the vertice in the LineString to reduce 768 | :param: qgs_points: List of QgsPoint defining the bend 769 | :return: None 770 | :rtype: None 771 | """ 772 | 773 | self.i = i 774 | self.j = j 775 | self._qgs_points = qgs_points 776 | self._qgs_geom_new_subline = None 777 | self._qgs_geom_old_subline = None 778 | self.qgs_geom_bend = QgsGeometry(QgsPolygon(QgsLineString(qgs_points))) # QgsPolygon will close the polygon 779 | self.area = self.qgs_geom_bend.area() 780 | self.perimeter = self.qgs_geom_bend.length() 781 | self.adj_area = Bend.calculate_adj_area(self.area, self.perimeter) 782 | self.to_reduce = False 783 | 784 | @property 785 | def qgs_geom_new_subline(self): 786 | """Late attribute evaluation as this attribute is costly to evaluate""" 787 | if self._qgs_geom_new_subline is None: 788 | self._qgs_geom_new_subline = QgsGeometry(QgsLineString(self._qgs_points[0], self._qgs_points[-1])) 789 | return self._qgs_geom_new_subline 790 | 791 | @property 792 | def qgs_geom_old_subline(self): 793 | """Late attribute evaluation as this attribute is costly to evaluate""" 794 | if self._qgs_geom_old_subline is None: 795 | self._qgs_geom_old_subline = QgsGeometry(QgsLineString(self._qgs_points)) 796 | return self._qgs_geom_old_subline 797 | 798 | 799 | class GeoSimUtil: 800 | """Class containing a list general static method""" 801 | 802 | @staticmethod 803 | def validate_simplicity(qgs_geoms_with_itself, qgs_geom_new_subline): 804 | """Validate the simplicitity constraint 805 | 806 | This constraint assure that the new sub line is not intersecting with any other segment of the same line 807 | 808 | :param: qgs_geoms_with_itself: List of QgsLineString segment to verify for self intersection 809 | :param: qgs_geom_new_subline: New QgsLineString replacement sub line. 810 | :return: Flag indicating if the spatial constraint is valid 811 | :rtype: Bool 812 | """ 813 | 814 | constraints_valid = True 815 | if qgs_geom_new_subline.length() > Epsilon.ZERO_RELATIVE: 816 | geom_engine_subline = QgsGeometry.createGeometryEngine(qgs_geom_new_subline.constGet().clone()) 817 | for qgs_geom_potential in qgs_geoms_with_itself: 818 | de_9im_pattern = geom_engine_subline.relate(qgs_geom_potential.constGet().clone()) 819 | # de_9im_pattern[0] == '0' means that their interiors intersect (crosses) 820 | # de_9im_pattern[1] == '0' means that one extremity is touching the interior of the other (touches) 821 | if de_9im_pattern[0] == '0' or de_9im_pattern[1] == '0': 822 | constraints_valid = False 823 | break 824 | else: 825 | # Special case do not validate simplicity for almost zero length line (equivalent to a point) 826 | pass 827 | 828 | return constraints_valid 829 | 830 | @staticmethod 831 | def validate_intersection(qgs_geoms_with_others, qgs_geom_new_subline): 832 | """Validate the intersection constraint 833 | 834 | This constraint assure that the new sub line is not intersecting with any other lines (not itself) 835 | 836 | :param: qgs_geoms_with_others: List of QgsLineString segment to verify for intersection 837 | :param: qgs_geom_new_subline: New QgsLineString replacement sub line. 838 | :return: Flag indicating if the spatial constraint is valid 839 | :rtype: Bool 840 | """ 841 | 842 | constraints_valid = True 843 | if len(qgs_geoms_with_others) >= 1: 844 | geom_engine_subline = QgsGeometry.createGeometryEngine(qgs_geom_new_subline.constGet().clone()) 845 | for qgs_geom_potential in qgs_geoms_with_others: 846 | de_9im_pattern = geom_engine_subline.relate(qgs_geom_potential.constGet().clone()) 847 | # de_9im_pattern[0] == '0' means that their interiors intersect (crosses) 848 | if de_9im_pattern[0] == '0': 849 | constraints_valid = False 850 | break 851 | 852 | return constraints_valid 853 | 854 | @staticmethod 855 | def validate_sidedness(qgs_geom_with_others, qgs_geom_bend): 856 | """Validate the sidedness constraint 857 | 858 | This constraint assure that the new sub line will not change the relative position of an object compared to 859 | the polygon formed by the bend to reduce. ex.: an interior ring of a polygon going outside of the exterior ring. 860 | 861 | :param: qgs_geoms_with_others: List of QgsLineString segment to verify for intersection 862 | :param: qgs_geom_bend: QgsPolygon formed by the bend to reduce 863 | :return: Flag indicating if the spatial constraint is valid 864 | :rtype: Bool 865 | """ 866 | 867 | constraints_valid = True 868 | for qgs_geom_potential in qgs_geom_with_others: 869 | if qgs_geom_bend.contains(qgs_geom_potential): 870 | # A feature is totally located inside 871 | constraints_valid = False 872 | break 873 | 874 | return constraints_valid 875 | -------------------------------------------------------------------------------- /i18n/af.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @default 5 | 6 | 7 | Good morning 8 | Goeie more 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /image/Figure6-abc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/Figure6-abc.png -------------------------------------------------------------------------------- /image/chordal_axis_figure.gpkg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/chordal_axis_figure.gpkg -------------------------------------------------------------------------------- /image/figure1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/figure1.png -------------------------------------------------------------------------------- /image/figure2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/figure2.png -------------------------------------------------------------------------------- /image/figure3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/figure3.png -------------------------------------------------------------------------------- /image/figure4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/figure4.png -------------------------------------------------------------------------------- /image/figure5a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/figure5a.png -------------------------------------------------------------------------------- /image/figure5b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/figure5b.png -------------------------------------------------------------------------------- /image/figure5c.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/figure5c.png -------------------------------------------------------------------------------- /image/figure5d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/figure5d.png -------------------------------------------------------------------------------- /image/figure5e.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/figure5e.png -------------------------------------------------------------------------------- /image/figure5f.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/figure5f.png -------------------------------------------------------------------------------- /image/figure6.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/figure6.docx -------------------------------------------------------------------------------- /image/figure6a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/figure6a.png -------------------------------------------------------------------------------- /image/figure6b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/figure6b.png -------------------------------------------------------------------------------- /image/sherbend_figures.gpkg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/sherbend_figures.gpkg -------------------------------------------------------------------------------- /image/sherbend_figures.qgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/image/sherbend_figures.qgz -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCan/geo_sim_processing/7894e6913d6ac51781e4656baf5bca17925efaec/logo.png -------------------------------------------------------------------------------- /metadata.txt: -------------------------------------------------------------------------------- 1 | # This file contains metadata for your plugin. 2 | 3 | # This file should be included when you package your plugin.# Mandatory items: 4 | 5 | [general] 6 | name=Geo Simplification (processing) 7 | qgisMinimumVersion=3.14 8 | description=This plugin contains different tools for line/polygon simplification and generalization 9 | version=1.2.0 10 | author=Natural Resources Canada 11 | email=nrcan.qgis-plugins.rncan@canada.ca 12 | 13 | about=This plugin contains the following tool for line/polygon simplification and generalization:

- Chordal Axis

- Reduce Bend

- Simplify (D Peuker+)

14 | 15 | tracker=https://github.com/NRCan/geo_sim_processing/issues 16 | repository=https://github.com/NRCan/geo_sim_processing 17 | # End of mandatory metadata 18 | 19 | # Recommended items: 20 | 21 | hasProcessingProvider=yes 22 | # Uncomment the following line and add your changelog: 23 | changelog= 24 | 1.2.0 2021-05-07 25 | - Code refactoring and major performance improvement for Reduce Bend 26 | - Progress bar status improvement 27 | - Avoid division by zero for "zero area" polygon 28 | 1.1.0 2021-04-22 29 | - Complete rewrite of Simplify processing tool 30 | - Simplify processing tool implements topological validation within and between features 31 | - Topological validation is harmonized between Simplify and Reduce Bend (same behaviour) 32 | - Reduce Bend performance improvement 33 | - Progress bar status improvement 34 | - Bug correction in ReduceBend 35 | 1.0.1 2021-04-12 36 | - Bug correction in Simplify processing script when handling polygon features 37 | - Correction some typo errors 38 | 1.0.0 2021-04-05 39 | - First publication of geo_sim_processing on QGIS repository 40 | - Code optimization of Reduce bend 41 | 0.6.1 2021-03-17 42 | - Reorganize the zip deployment file and tag numbering 43 | 0.6.0 2021-02-15 44 | - Chordal Axis now accept MultiPolygon and MultiLineString 45 | - Chordal Axis will internally create the tessellation (triangles) 46 | - Reduce Bend now accept MultiPolygon and MultiLineString 47 | - Reduce Bend can reduce bend with wave or spiral form 48 | - Simplify now accept MultiPolygon and MultiLineString 49 | 0.5.0 2021-01-27 50 | - First release as a plugin 51 | 52 | # Tags are comma separated with spaces allowed 53 | tags=vector, topography, generalization, simplification, topology, constraint 54 | 55 | homepage=https://github.com/NRCan/geo_sim_processing 56 | icon=logo.png 57 | # experimental flag 58 | experimental=False 59 | 60 | # deprecated flag (applies to the whole plugin, not just a single version) 61 | deprecated=False 62 | 63 | # Since QGIS 3.8, a comma separated list of plugins to be installed 64 | # (or upgraded) can be specified. 65 | # Check the documentation for more information. 66 | # plugin_dependencies= 67 | 68 | Category of the plugin: Raster, Vector, Database or Web 69 | category=Vector 70 | 71 | # If the plugin can run on QGIS Server. 72 | server=False 73 | 74 | -------------------------------------------------------------------------------- /plugin_upload.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding=utf-8 3 | """This script uploads a plugin package to the plugin repository. 4 | Authors: A. Pasotti, V. Picavet 5 | git sha : $TemplateVCSFormat 6 | """ 7 | 8 | import sys 9 | import getpass 10 | import xmlrpc.client 11 | from optparse import OptionParser 12 | 13 | standard_library.install_aliases() 14 | 15 | # Configuration 16 | PROTOCOL = 'https' 17 | SERVER = 'plugins.qgis.org' 18 | PORT = '443' 19 | ENDPOINT = '/plugins/RPC2/' 20 | VERBOSE = False 21 | 22 | 23 | def main(parameters, arguments): 24 | """Main entry point. 25 | 26 | :param parameters: Command line parameters. 27 | :param arguments: Command line arguments. 28 | """ 29 | address = "{protocol}://{username}:{password}@{server}:{port}{endpoint}".format( 30 | protocol=PROTOCOL, 31 | username=parameters.username, 32 | password=parameters.password, 33 | server=parameters.server, 34 | port=parameters.port, 35 | endpoint=ENDPOINT) 36 | print("Connecting to: %s" % hide_password(address)) 37 | 38 | server = xmlrpc.client.ServerProxy(address, verbose=VERBOSE) 39 | 40 | try: 41 | with open(arguments[0], 'rb') as handle: 42 | plugin_id, version_id = server.plugin.upload( 43 | xmlrpc.client.Binary(handle.read())) 44 | print("Plugin ID: %s" % plugin_id) 45 | print("Version ID: %s" % version_id) 46 | except xmlrpc.client.ProtocolError as err: 47 | print("A protocol error occurred") 48 | print("URL: %s" % hide_password(err.url, 0)) 49 | print("HTTP/HTTPS headers: %s" % err.headers) 50 | print("Error code: %d" % err.errcode) 51 | print("Error message: %s" % err.errmsg) 52 | except xmlrpc.client.Fault as err: 53 | print("A fault occurred") 54 | print("Fault code: %d" % err.faultCode) 55 | print("Fault string: %s" % err.faultString) 56 | 57 | 58 | def hide_password(url, start=6): 59 | """Returns the http url with password part replaced with '*'. 60 | 61 | :param url: URL to upload the plugin to. 62 | :type url: str 63 | 64 | :param start: Position of start of password. 65 | :type start: int 66 | """ 67 | start_position = url.find(':', start) + 1 68 | end_position = url.find('@') 69 | return "%s%s%s" % ( 70 | url[:start_position], 71 | '*' * (end_position - start_position), 72 | url[end_position:]) 73 | 74 | 75 | if __name__ == "__main__": 76 | parser = OptionParser(usage="%prog [options] plugin.zip") 77 | parser.add_option( 78 | "-w", "--password", dest="password", 79 | help="Password for plugin site", metavar="******") 80 | parser.add_option( 81 | "-u", "--username", dest="username", 82 | help="Username of plugin site", metavar="user") 83 | parser.add_option( 84 | "-p", "--port", dest="port", 85 | help="Server port to connect to", metavar="80") 86 | parser.add_option( 87 | "-s", "--server", dest="server", 88 | help="Specify server name", metavar="plugins.qgis.org") 89 | options, args = parser.parse_args() 90 | if len(args) != 1: 91 | print("Please specify zip file.\n") 92 | parser.print_help() 93 | sys.exit(1) 94 | if not options.server: 95 | options.server = SERVER 96 | if not options.port: 97 | options.port = PORT 98 | if not options.username: 99 | # interactive mode 100 | username = getpass.getuser() 101 | print("Please enter user name [%s] :" % username, end=' ') 102 | 103 | res = input() 104 | if res != "": 105 | options.username = res 106 | else: 107 | options.username = username 108 | if not options.password: 109 | # interactive mode 110 | options.password = getpass.getpass() 111 | main(options, args) 112 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Profiled execution. 11 | profile=no 12 | 13 | # Add files or directories to the blacklist. They should be base names, not 14 | # paths. 15 | ignore=CVS 16 | 17 | # Pickle collected data for later comparisons. 18 | persistent=yes 19 | 20 | # List of plugins (as comma separated values of python modules names) to load, 21 | # usually to register additional checkers. 22 | load-plugins= 23 | 24 | 25 | [MESSAGES CONTROL] 26 | 27 | # Enable the message, report, category or checker with the given id(s). You can 28 | # either give multiple identifier separated by comma (,) or put this option 29 | # multiple time. See also the "--disable" option for examples. 30 | #enable= 31 | 32 | # Disable the message, report, category or checker with the given id(s). You 33 | # can either give multiple identifiers separated by comma (,) or put this 34 | # option multiple times (only on the command line, not in the configuration 35 | # file where it should appear only once).You can also use "--disable=all" to 36 | # disable everything first and then reenable specific checks. For example, if 37 | # you want to run only the similarities checker, you can use "--disable=all 38 | # --enable=similarities". If you want to run only the classes checker, but have 39 | # no Warning level messages displayed, use"--disable=all --enable=classes 40 | # --disable=W" 41 | # see http://stackoverflow.com/questions/21487025/pylint-locally-defined-disables-still-give-warnings-how-to-suppress-them 42 | disable=locally-disabled,C0103 43 | 44 | 45 | [REPORTS] 46 | 47 | # Set the output format. Available formats are text, parseable, colorized, msvs 48 | # (visual studio) and html. You can also give a reporter class, eg 49 | # mypackage.mymodule.MyReporterClass. 50 | output-format=text 51 | 52 | # Put messages in a separate file for each module / package specified on the 53 | # command line instead of printing them on stdout. Reports (if any) will be 54 | # written in a file name "pylint_global.[txt|html]". 55 | files-output=no 56 | 57 | # Tells whether to display a full report or only the messages 58 | reports=yes 59 | 60 | # Python expression which should return a note less than 10 (10 is the highest 61 | # note). You have access to the variables errors warning, statement which 62 | # respectively contain the number of errors / warnings messages and the total 63 | # number of statements analyzed. This is used by the global evaluation report 64 | # (RP0004). 65 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 66 | 67 | # Add a comment according to your evaluation note. This is used by the global 68 | # evaluation report (RP0004). 69 | comment=no 70 | 71 | # Template used to display messages. This is a python new-style format string 72 | # used to format the message information. See doc for all details 73 | #msg-template= 74 | 75 | 76 | [BASIC] 77 | 78 | # Required attributes for module, separated by a comma 79 | required-attributes= 80 | 81 | # List of builtins function names that should not be used, separated by a comma 82 | bad-functions=map,filter,apply,input 83 | 84 | # Regular expression which should only match correct module names 85 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 86 | 87 | # Regular expression which should only match correct module level names 88 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 89 | 90 | # Regular expression which should only match correct class names 91 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 92 | 93 | # Regular expression which should only match correct function names 94 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 95 | 96 | # Regular expression which should only match correct method names 97 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 98 | 99 | # Regular expression which should only match correct instance attribute names 100 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 101 | 102 | # Regular expression which should only match correct argument names 103 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 104 | 105 | # Regular expression which should only match correct variable names 106 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 107 | 108 | # Regular expression which should only match correct attribute names in class 109 | # bodies 110 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 111 | 112 | # Regular expression which should only match correct list comprehension / 113 | # generator expression variable names 114 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 115 | 116 | # Good variable names which should always be accepted, separated by a comma 117 | good-names=i,j,k,ex,Run,_ 118 | 119 | # Bad variable names which should always be refused, separated by a comma 120 | bad-names=foo,bar,baz,toto,tutu,tata 121 | 122 | # Regular expression which should only match function or class names that do 123 | # not require a docstring. 124 | no-docstring-rgx=__.*__ 125 | 126 | # Minimum line length for functions/classes that require docstrings, shorter 127 | # ones are exempt. 128 | docstring-min-length=-1 129 | 130 | 131 | [MISCELLANEOUS] 132 | 133 | # List of note tags to take in consideration, separated by a comma. 134 | notes=FIXME,XXX,TODO 135 | 136 | 137 | [TYPECHECK] 138 | 139 | # Tells whether missing members accessed in mixin class should be ignored. A 140 | # mixin class is detected if its name ends with "mixin" (case insensitive). 141 | ignore-mixin-members=yes 142 | 143 | # List of classes names for which member attributes should not be checked 144 | # (useful for classes with attributes dynamically set). 145 | ignored-classes=SQLObject 146 | 147 | # When zope mode is activated, add a predefined set of Zope acquired attributes 148 | # to generated-members. 149 | zope=no 150 | 151 | # List of members which are set dynamically and missed by pylint inference 152 | # system, and so shouldn't trigger E0201 when accessed. Python regular 153 | # expressions are accepted. 154 | generated-members=REQUEST,acl_users,aq_parent 155 | 156 | 157 | [VARIABLES] 158 | 159 | # Tells whether we should check for unused import in __init__ files. 160 | init-import=no 161 | 162 | # A regular expression matching the beginning of the name of dummy variables 163 | # (i.e. not used). 164 | dummy-variables-rgx=_$|dummy 165 | 166 | # List of additional names supposed to be defined in builtins. Remember that 167 | # you should avoid to define new builtins when possible. 168 | additional-builtins= 169 | 170 | 171 | [FORMAT] 172 | 173 | # Maximum number of characters on a single line. 174 | max-line-length=120 175 | 176 | # Regexp for a line that is allowed to be longer than the limit. 177 | ignore-long-lines=^\s*(# )??$ 178 | 179 | # Allow the body of an if to be on the same line as the test if there is no 180 | # else. 181 | single-line-if-stmt=no 182 | 183 | # List of optional constructs for which whitespace checking is disabled 184 | no-space-check=trailing-comma,dict-separator 185 | 186 | # Maximum number of lines in a module 187 | max-module-lines=1000 188 | 189 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 190 | # tab). 191 | indent-string=' ' 192 | 193 | 194 | [SIMILARITIES] 195 | 196 | # Minimum lines number of a similarity. 197 | min-similarity-lines=4 198 | 199 | # Ignore comments when computing similarities. 200 | ignore-comments=yes 201 | 202 | # Ignore docstrings when computing similarities. 203 | ignore-docstrings=yes 204 | 205 | # Ignore imports when computing similarities. 206 | ignore-imports=no 207 | 208 | 209 | [IMPORTS] 210 | 211 | # Deprecated modules which should not be used, separated by a comma 212 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 213 | 214 | # Create a graph of every (i.e. internal and external) dependencies in the 215 | # given file (report RP0402 must not be disabled) 216 | import-graph= 217 | 218 | # Create a graph of external dependencies in the given file (report RP0402 must 219 | # not be disabled) 220 | ext-import-graph= 221 | 222 | # Create a graph of internal dependencies in the given file (report RP0402 must 223 | # not be disabled) 224 | int-import-graph= 225 | 226 | 227 | [DESIGN] 228 | 229 | # Maximum number of arguments for function / method 230 | max-args=5 231 | 232 | # Argument names that match this expression will be ignored. Default to name 233 | # with leading underscore 234 | ignored-argument-names=_.* 235 | 236 | # Maximum number of locals for function / method body 237 | max-locals=15 238 | 239 | # Maximum number of return / yield for function / method body 240 | max-returns=6 241 | 242 | # Maximum number of branch for function / method body 243 | max-branches=12 244 | 245 | # Maximum number of statements in function / method body 246 | max-statements=50 247 | 248 | # Maximum number of parents for a class (see R0901). 249 | max-parents=7 250 | 251 | # Maximum number of attributes for a class (see R0902). 252 | max-attributes=7 253 | 254 | # Minimum number of public methods for a class (see R0903). 255 | min-public-methods=2 256 | 257 | # Maximum number of public methods for a class (see R0904). 258 | max-public-methods=20 259 | 260 | 261 | [CLASSES] 262 | 263 | # List of interface methods to ignore, separated by a comma. This is used for 264 | # instance to not check methods defines in Zope's Interface base class. 265 | ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by 266 | 267 | # List of method names used to declare (i.e. assign) instance attributes. 268 | defining-attr-methods=__init__,__new__,setUp 269 | 270 | # List of valid names for the first argument in a class method. 271 | valid-classmethod-first-arg=cls 272 | 273 | # List of valid names for the first argument in a metaclass class method. 274 | valid-metaclass-classmethod-first-arg=mcs 275 | 276 | 277 | [EXCEPTIONS] 278 | 279 | # Exceptions that will emit a warning when being caught. Defaults to 280 | # "Exception" 281 | overgeneral-exceptions=Exception 282 | -------------------------------------------------------------------------------- /reduce_bend_unittest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # /*************************************************************************** 4 | # reduce_bend_unittest.py 5 | # ---------- 6 | # Date : January 2021 7 | # copyright : (C) 2020 by Natural Resources Canada 8 | # email : daniel.pilon@canada.ca 9 | # 10 | # ***************************************************************************/ 11 | # 12 | # /*************************************************************************** 13 | # * * 14 | # * This program is free software; you can redistribute it and/or modify * 15 | # * it under the terms of the GNU General Public License as published by * 16 | # * the Free Software Foundation; either version 2 of the License, or * 17 | # * (at your option) any later version. * 18 | # * * 19 | # ***************************************************************************/ 20 | 21 | 22 | """ 23 | Unit test for reduce_bend algorithm 24 | """ 25 | 26 | import unittest 27 | from qgis.core import QgsApplication 28 | from .reduce_bend_algorithm import ReduceBend 29 | from qgis.core import QgsPoint, QgsLineString, QgsPolygon, QgsFeature, QgsGeometry, QgsProcessingFeedback, \ 30 | QgsVectorLayer, QgsWkbTypes, QgsPointXY 31 | from qgis.analysis import QgsNativeAlgorithms 32 | 33 | def qgs_line_string_to_xy(qgs_line_string): 34 | 35 | qgs_points = qgs_line_string.points() 36 | lst_x = [] 37 | lst_y = [] 38 | for qgs_point in qgs_points: 39 | lst_x.append(qgs_point.x()) 40 | lst_y.append(qgs_point.y()) 41 | 42 | return (lst_x, lst_y) 43 | 44 | def plot_lines(qgs_line_string, qgs_new_line): 45 | 46 | line0_lst_x, line0_lst_y = qgs_line_string_to_xy(qgs_line_string) 47 | # line1_lst_x, line1_lst_y = qgs_line_string_to_xy(qgs_new_line) 48 | 49 | import matplotlib.pyplot as plt 50 | plt.plot(line0_lst_x, line0_lst_y, 'b') 51 | # plt.plot(line1_lst_x, line1_lst_y, 'r') 52 | plt.show() 53 | 54 | 55 | def build_and_launch(title, qgs_geoms, diameter_tol, del_pol=False, del_hole=False, smooth_line=False): 56 | 57 | print(title) 58 | qgs_features = [] 59 | feedback = QgsProcessingFeedback() 60 | for qgs_geom in qgs_geoms: 61 | qgs_feature = QgsFeature() 62 | qgs_feature.setGeometry(qgs_geom) 63 | qgs_features.append(qgs_feature) 64 | 65 | rb_results = ReduceBend.reduce(qgs_features, diameter_tol, smooth_line, del_pol, del_hole, True, feedback) 66 | log = feedback.textLog() 67 | print (log) 68 | qgs_features_out = rb_results.qgs_features_out 69 | 70 | qgs_geoms_out = [] 71 | for qgs_feature_out in qgs_features_out: 72 | qgs_geoms_out.append(qgs_feature_out.geometry()) 73 | 74 | return qgs_geoms_out 75 | 76 | def create_line(coords, ret_geom=True): 77 | 78 | qgs_points = [] 79 | for coord in coords: 80 | qgs_points.append(create_point(coord, False)) 81 | 82 | if ret_geom: 83 | ret_val = QgsGeometry(QgsLineString(qgs_points)) 84 | else: 85 | ret_val = QgsLineString(qgs_points).clone() 86 | 87 | return ret_val 88 | 89 | def create_point(coord, ret_geom=True): 90 | 91 | qgs_point = QgsPoint(coord[0], coord[1]) 92 | if ret_geom: 93 | ret_val = QgsGeometry(qgs_point) 94 | else: 95 | ret_val = qgs_point.clone() 96 | 97 | return ret_val 98 | 99 | def create_polygon(outer, inners): 100 | 101 | outer_line = create_line(outer, False) 102 | qgs_pol = QgsPolygon() 103 | qgs_pol.setExteriorRing(outer_line) 104 | for inner in inners: 105 | inner_line = create_line(inner, False) 106 | qgs_pol.addInteriorRing(inner_line) 107 | qgs_geom = QgsGeometry(qgs_pol) 108 | 109 | return qgs_geom 110 | 111 | 112 | class Test(unittest.TestCase): 113 | """ 114 | Class allowing to test the algorithm 115 | """ 116 | 117 | def test_case01(self): 118 | title = "Test 01: Empty file" 119 | 120 | 121 | qgs_feature_out = build_and_launch(title, [], 5, True, True) 122 | out_qgs_geom0 = create_polygon([(0, 10), (10, 10), (10, 0), (0, 0), (0, 10)], []) 123 | if len(qgs_feature_out) == 0: 124 | val0 = True 125 | else: 126 | val0 = False 127 | self.assertTrue(val0, title) 128 | 129 | def test_case02(self): 130 | title = "Test 02: Polygon with start/end point colinear" 131 | qgs_geom0 = create_polygon([(0,10), (5,10), (10,10), (10,0), (0,0), (0,10)], []) 132 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 300) 133 | out_qgs_geom0 = create_polygon([(0,10), (10,10), (10,0), (0,0), (0,10)], []) 134 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 135 | self.assertTrue (val0, title) 136 | 137 | def test_case03(self): 138 | title = "Test 03: Polygon with one bend the first/end vertice located on the bend to reduce" 139 | qgs_geom0 = create_polygon([(5,10), (5,11), (6,11), (6,10), (10,10), (10,0), (0,0), (0,10), (5,10)], []) 140 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 3) 141 | out_qgs_geom0 = create_polygon([(10,10), (10,0), (0,0), (0,10), (10,10)], []) 142 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 143 | self.assertTrue(val0, title) 144 | 145 | def test_case04(self): 146 | title = "Test 04: Square polygon with one bend" 147 | qgs_geom0 = create_polygon([(0,10), (5,9), (10,10), (10,0), (0,0), (0,10)], []) 148 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 149 | out_qgs_geom0 = create_polygon([(0,10), (10,10), (10,0), (0,0), (0,10)], []) 150 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 151 | self.assertTrue (val0, title) 152 | 153 | def test_case05(self): 154 | title = "Test 05: triangle polygon with one bend" 155 | qgs_geom0 = create_polygon([(0,10), (5,9), (10,10), (5,0), (0,10)], []) 156 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 3000) 157 | out_qgs_geom0 = create_polygon([(0,10), (10,10), (5,0), (0,10)], []) 158 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 159 | self.assertTrue (val0, title) 160 | 161 | def test_case06(self): 162 | title = "Test 06: A polygon with no bend. A line with no bend" 163 | qgs_geom0 = create_polygon([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)], []) 164 | qgs_geom1 = create_line([(10, 0), (20, 0)]) 165 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1], 3) 166 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 167 | val1 = qgs_geom1.equals(qgs_feature_out[1]) 168 | self.assertTrue (val0 and val1, title) 169 | 170 | def test_case07(self): 171 | title = "Test 07: 1 polygon with no bend to reduce" 172 | coords0 = [(0, 0), (0, 5), (2.5,4), (5, 5), (5, 0), (0,0)] 173 | qgs_geom0 = create_polygon(coords0, []) 174 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 3) 175 | qgs_geom0_out = create_polygon(coords0, []) 176 | val0 = qgs_geom0_out.equals(qgs_feature_out[0]) 177 | self.assertTrue (val0, title) 178 | 179 | 180 | def test_case08(self): 181 | title = "Test 08: 1 line string with one bend to simplify" 182 | qgs_geom0 = create_line([(0, 0), (1, 1), (2,0)]) 183 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 3) 184 | out_qgs_geom0 = create_line([(0, 0), (2, 0)]) 185 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 186 | self.assertTrue (val0, title) 187 | 188 | def test_case09(self): 189 | title = "Test 09: 1 point and 3 line string no simplification" 190 | qgs_geom0 = create_point((0,0)) 191 | qgs_geom1 = create_line([(0, 0), (100, 0)]) 192 | qgs_geom2 = create_line([(0, 0), (0, 100)]) 193 | qgs_geom3 = create_line([(0, 0), (100, 100)]) 194 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1, qgs_geom2, qgs_geom3], 30) 195 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 196 | val1 = qgs_geom1.equals(qgs_feature_out[1]) 197 | val2 = qgs_geom2.equals(qgs_feature_out[2]) 198 | val3 = qgs_geom3.equals(qgs_feature_out[3]) 199 | self.assertTrue (val0 and val1 and val2, title) 200 | 201 | def test_case10(self): 202 | title = "Test 10: 2 simple line segment, simple triangle and one point" 203 | qgs_geom0 = create_line([(0,0),(30,0)]) 204 | qgs_geom1 = create_polygon([(10,10),(15,20), (20,10), (10,10)], []) 205 | qgs_geom2 = create_point((0,100)) 206 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1, qgs_geom2], 3) 207 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 208 | val1 = qgs_geom1.equals(qgs_feature_out[1]) 209 | val2 = qgs_geom2.equals(qgs_feature_out[2]) 210 | self.assertTrue (val0 and val1 and val2, title) 211 | 212 | def test_case10_1_1(self): 213 | title = "Test 10_1_1: Triangle with one bend (1/4)" 214 | qgs_geom0 = create_polygon([(10,10),(15,20), (20,10), (15,11), (10,10)], []) 215 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 216 | out_qgs_geom0 = create_polygon([(10,10),(15,20), (20,10), (10,10)], []) 217 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 218 | self.assertTrue (val0, title) 219 | 220 | def test_case10_1_2(self): 221 | title = "Test 10_1_2: Triangle with one bend (2/4 pivot first vertice)" 222 | qgs_geom0 = create_polygon([(15,20), (20,10), (15,11), (10,10), (15,20)], []) 223 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 224 | out_qgs_geom0 = create_polygon([(15,20), (20,10), (10,10), (15,20)], []) 225 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 226 | self.assertTrue (val0, title) 227 | 228 | def test_case10_1_3(self): 229 | title = "Test 10_1_3: Triangle with one bend (3/4 pivot first vertice)" 230 | qgs_geom0 = create_polygon([(20,10), (15,11), (10,10), (15,20), (20,10)], []) 231 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 232 | out_qgs_geom0 = create_polygon([(20,10), (10,10), (15,20), (20,10)], []) 233 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 234 | self.assertTrue (val0, title) 235 | 236 | def test_case10_1_4(self): 237 | title = "Test 10_1_4: Triangle with one bend (4/4 pivot first vertice)" 238 | qgs_geom0 = create_polygon([(15,11), (10,10), (15,20), (20,10), (15,11)], []) 239 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 240 | out_qgs_geom0 = create_polygon([(10,10), (15,20), (20,10), (10,10)], []) 241 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 242 | self.assertTrue (val0, title) 243 | 244 | def test_case10_2_1(self): 245 | title = "Test 10_2_1: Square with 2 bends (1/4)" 246 | qgs_geom0 = create_polygon([(0,0),(1,1),(0,2),(10,2), (9,1),(10,0), (0,0)], []) 247 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 248 | out_qgs_geom0 = create_polygon([(0,0),(0,2),(10,2),(10,0), (0,0)], []) 249 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 250 | self.assertTrue (val0, title) 251 | 252 | def test_case10_2_2(self): 253 | title = "Test 10_2_2: Square with 2 bends (2/4) pivot first vertice" 254 | qgs_geom0 = create_polygon([(1,1),(0,2),(10,2), (9,1),(10,0), (0,0), (1,1)], []) 255 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 256 | out_qgs_geom0 = create_polygon([(0,2),(10,2),(10,0), (0,0), (0,2)], []) 257 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 258 | self.assertTrue (val0, title) 259 | 260 | def test_case10_2_3(self): 261 | title = "Test 10_7: Square with 2 bends (3/4) pivot first vertice" 262 | qgs_geom0 = create_polygon([(0,2),(10,2), (9,1),(10,0), (0,0), (1,1), (0,2)], []) 263 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 264 | out_qgs_geom0 = create_polygon([(0,2),(10,2),(10,0), (0,0), (0,2)], []) 265 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 266 | self.assertTrue (val0, title) 267 | 268 | def test_case10_2_4(self): 269 | title = "Test 10_8: Square with 2 bends (4/4) pivot first vertice" 270 | qgs_geom0 = create_polygon([(10,2), (9,1),(10,0), (0,0), (1,1), (0,2), (10,2)], []) 271 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 272 | out_qgs_geom0 = create_polygon([(10,2),(10,0), (0,0), (0,2), (10,2)], []) 273 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 274 | self.assertTrue (val0, title) 275 | 276 | def test_case10_3_1(self): 277 | title = "Test 10_3_1: Square with 2 bends each bend with 2 vertices (1/7)" 278 | qgs_geom0 = create_polygon([(0,0), (0,10), (4,10), (4,9), (6,9), (6,10), (10,10), (10,0), (6,0), (6,1),(4,1), (4,0), (0,0)], []) 279 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 280 | out_qgs_geom0 = create_polygon([(0,0), (0,10), (10,10), (10,0), (0,0)], []) 281 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 282 | self.assertTrue (val0, title) 283 | 284 | def test_case10_3_2(self): 285 | title = "Test 10_3_2: Square with 2 bends each bend with 2 vertices (2/7) pivot first vertice" 286 | qgs_geom0 = create_polygon([(0,10), (4,10), (4,9), (6,9), (6,10), (10,10), (10,0), (6,0), (6,1),(4,1), (4,0), (0,0), (0,10)], []) 287 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 288 | out_qgs_geom0 = create_polygon([(0,10), (10,10), (10,0), (0,0), (0,10)], []) 289 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 290 | self.assertTrue (val0, title) 291 | 292 | def test_case10_3_3(self): 293 | title = "Test 10_9_2: Square with 2 bends each bend with 2 vertices (3/7) pivot first vertice" 294 | qgs_geom0 = create_polygon([(4,10), (4,9), (6,9), (6,10), (10,10), (10,0), (6,0), (6,1),(4,1), (4,0), (0,0), (0,10), (4,10)], []) 295 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 296 | out_qgs_geom0 = create_polygon([(10,10), (10,0), (0,0), (0,10), (10,10)], []) 297 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 298 | self.assertTrue (val0, title) 299 | 300 | def test_case10_3_4(self): 301 | title = "Test 10_3_4: Square with 2 bends each bend with 2 vertices (4/7) pivot first vertice" 302 | qgs_geom0 = create_polygon([(4,9), (6,9), (6,10), (10,10), (10,0), (6,0), (6,1),(4,1), (4,0), (0,0), (0,10), (4,10), (4,9)], []) 303 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 304 | out_qgs_geom0 = create_polygon([(10,10), (10,0), (0,0), (0,10), (10,10)], []) 305 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 306 | self.assertTrue (val0, title) 307 | 308 | def test_case10_3_5(self): 309 | title = "Test 10_3_5: Square with 2 bends each bend with 2 vertices (5/7) pivot first vertice" 310 | qgs_geom0 = create_polygon([(6,9), (6,10), (10,10), (10,0), (6,0), (6,1),(4,1), (4,0), (0,0), (0,10), (4,10), (4,9), (6,9)], []) 311 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 312 | out_qgs_geom0 = create_polygon([(10,10), (10,0), (0,0), (0,10), (10,10)], []) 313 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 314 | self.assertTrue (val0, title) 315 | 316 | def test_case10_3_6(self): 317 | title = "Test 10_3_6: Square with 2 bends each bend with 2 vertices (6/7) pivot first vertice" 318 | qgs_geom0 = create_polygon([(6,10), (10,10), (10,0), (6,0), (6,1),(4,1), (4,0), (0,0), (0,10), (4,10), (4,9), (6,9), (6,10)], []) 319 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 320 | out_qgs_geom0 = create_polygon([(10,10), (10,0), (0,0), (0,10), (10,10)], []) 321 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 322 | self.assertTrue (val0, title) 323 | 324 | def test_case10_3_7(self): 325 | title = "Test 10_3_7: Square with 2 bends each bend with 2 vertices (7/7) pivot first vertice" 326 | qgs_geom0 = create_polygon([(10,10), (10,0), (6,0), (6,1),(4,1), (4,0), (0,0), (0,10), (4,10), (4,9), (6,9), (6,10), (10,10)], []) 327 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 328 | out_qgs_geom0 = create_polygon([(10,10), (10,0), (0,0), (0,10), (10,10)], []) 329 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 330 | self.assertTrue (val0, title) 331 | 332 | 333 | def test_case10_4_1(self): 334 | title = "Test 10_4_1: Square with 2 bends each bend with 3 vertices (1/7)" 335 | qgs_geom0 = create_polygon([(0,0), (0,10), (4,10), (4,9), (5,9.5), (6,9), (6,10), (10,10), (10,0), (6,0), (6,1), (5,1.5), (4,1), (4,0), (0,0)], []) 336 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 337 | out_qgs_geom0 = create_polygon([(0,0), (0,10), (10,10), (10,0), (0,0)], []) 338 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 339 | self.assertTrue (val0, title) 340 | 341 | def test_case10_4_2(self): 342 | title = "Test 10_4_2: Square with 2 bends each bend with 3 vertices (2/7)" 343 | qgs_geom0 = create_polygon([(0,10), (4,10), (4,9), (5,9.5), (6,9), (6,10), (10,10), (10,0), (6,0), (6,1), (5,1.5), (4,1), (4,0), (0,0), (0,10)], []) 344 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 345 | out_qgs_geom0 = create_polygon([(0,10), (10,10), (10,0), (0,0), (0,10)], []) 346 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 347 | self.assertTrue (val0, title) 348 | 349 | 350 | def test_case10_4_3(self): 351 | title = "Test 10_4_3: Square with 2 bends each bend with 3 vertices (3/7)" 352 | qgs_geom0 = create_polygon([(4,10), (4,9), (5,9.5), (6,9), (6,10), (10,10), (10,0), (6,0), (6,1), (5,1.5), (4,1), (4,0), (0,0), (0,10), (4,10)], []) 353 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 354 | out_qgs_geom0 = create_polygon([(10,10), (10,0), (0,0), (0,10), (10,10)], []) 355 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 356 | self.assertTrue (val0, title) 357 | 358 | def test_case10_4_4(self): 359 | title = "Test 10_4_4: Square with 2 bends each bend with 3 vertices (4/7)" 360 | qgs_geom0 = create_polygon([(4,9), (5,9.5), (6,9), (6,10), (10,10), (10,0), (6,0), (6,1), (5,1.5), (4,1), (4,0), (0,0), (0,10), (4,10), (4,9)], []) 361 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 362 | out_qgs_geom0 = create_polygon([(10,10), (10,0), (0,0), (0,10), (10,10)], []) 363 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 364 | self.assertTrue (val0, title) 365 | 366 | def test_case10_4_5(self): 367 | title = "Test 10_4_5: Square with 2 bends each bend with 3 vertices (5/7)" 368 | qgs_geom0 = create_polygon([(5,9.5), (6,9), (6,10), (10,10), (10,0), (6,0), (6,1), (5,1.5), (4,1), (4,0), (0,0), (0,10), (4,10), (4,9), (5,9.5)], []) 369 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 370 | out_qgs_geom0 = create_polygon([(10,10), (10,0), (0,0), (0,10), (10,10)], []) 371 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 372 | self.assertTrue (val0, title) 373 | 374 | def test_case10_4_6(self): 375 | title = "Test 10_4_6: Square with 2 bends each bend with 3 vertices (6/7)" 376 | qgs_geom0 = create_polygon([(6,9), (6,10), (10,10), (10,0), (6,0), (6,1), (5,1.5), (4,1), (4,0), (0,0), (0,10), (4,10), (4,9), (5,9.5), (6,9)], []) 377 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 378 | out_qgs_geom0 = create_polygon([(10,10), (10,0), (0,0), (0,10), (10,10)], []) 379 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 380 | self.assertTrue (val0, title) 381 | 382 | def test_case10_4_7(self): 383 | title = "Test 10_4_7: Square with 2 bends each bend with 3 vertices (7/7)" 384 | qgs_geom0 = create_polygon([(6,10), (10,10), (10,0), (6,0), (6,1), (5,1.5), (4,1), (4,0), (0,0), (0,10), (4,10), (4,9), (5,9.5), (6,9), (6,10)], []) 385 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 30) 386 | out_qgs_geom0 = create_polygon([(10,10), (10,0), (0,0), (0,10), (10,10)], []) 387 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 388 | self.assertTrue (val0, title) 389 | 390 | 391 | def test_case11(self): 392 | title = "Test 11: Zero length line" 393 | qgs_geom0 = create_line([(10, 10), (10, 10)]) 394 | qgs_geom1 = create_line([(20, 20), (20, 20), (20,20)]) 395 | qgs_feature_out = build_and_launch(title, [qgs_geom0, qgs_geom1], 3) 396 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 397 | val1 = qgs_geom1.equals(qgs_feature_out[1]) 398 | self.assertTrue (val0 and val1, title) 399 | 400 | def test_case12(self): 401 | title = "Test 12: Degenerated polygon" 402 | qgs_geom0 = create_line([(10, 10), (10, 20), (10,10)]) 403 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3) 404 | out_qgs_geom0 = create_line([(10, 10), (10, 20), (10,10)]) 405 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 406 | self.assertTrue(val0, title) 407 | 408 | def test_case13(self): 409 | title = "Test 13: Line with segment parrallel to itself" 410 | qgs_geom0 = create_line([(0,0),(30,0), (20,0)]) 411 | qgs_geom1 = create_line([(0, 10), (-5,10), (30, 10)]) 412 | qgs_geom2 = create_line([(0, 20), (-5, 20), (30,20), (20, 20)]) 413 | out_qgs_geom0 = create_line([(0,0), (20,0)]) 414 | out_qgs_geom1 = create_line([(0, 10), (30, 10)]) 415 | out_qgs_geom2 = create_line([(0, 20), (20, 20)]) 416 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1, qgs_geom2], 3) 417 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 418 | val1 = out_qgs_geom1.equals(qgs_feature_out[1]) 419 | val2 = out_qgs_geom2.equals(qgs_feature_out[2]) 420 | self.assertTrue (val0 and val1 and val2, title) 421 | 422 | def test_case14(self): 423 | 424 | title = "Test 14: Co-linear and alomost co-linear point" 425 | in_geom0 = create_line([(0, 0), (20, 0), (25.000000000000001, 0.0000000000001), (30, 0)]) 426 | in_geom1 = create_line([(0, 10), (30, 10), (35.000000000001, 10.00000000000001), (40, 10)]) 427 | in_geom2 = create_point((0, 100)) 428 | out_geom0 = create_line([(0, 0), (30, 0)]) 429 | out_geom1 = create_line([(0, 10), (40, 10)]) 430 | qgs_feature_out = build_and_launch(title, [in_geom0, in_geom1, in_geom2], 3) 431 | val0 = out_geom0.equals(qgs_feature_out[0]) 432 | val1 = out_geom1.equals(qgs_feature_out[1]) 433 | val2 = in_geom2.equals(qgs_feature_out[2]) 434 | self.assertTrue(val0 and val1 and val2, title) 435 | 436 | def test_case15(self): 437 | title = "Test 15: Small bend" 438 | in_geom0 = create_line([(0, 0), (30, 0)]) 439 | in_geom1 = create_line([(0, 10), (30, 10), (30, 11), (31, 11), (31, 10), (40, 10), (50, 10), (50, 11), (51, 10), (60, 10)]) 440 | in_geom2 = create_point((0, 100)) 441 | out_geom0 = create_line([(0, 0), (30, 0)]) 442 | out_geom1 = create_line([(0, 10), (60, 10)]) 443 | qgs_feature_out = build_and_launch(title, [in_geom0, in_geom1, in_geom2], 3) 444 | val0 = out_geom0.equals(qgs_feature_out[0]) 445 | val1 = out_geom1.equals(qgs_feature_out[1]) 446 | val2 = in_geom2.equals(qgs_feature_out[2]) 447 | self.assertTrue(val0 and val1 and val2, title) 448 | 449 | def test_case16(self): 450 | title = "Test 16: Polygon with bend" 451 | outer = [(0, 0), (0, 20), (10, 20), (10, 21), (11, 21), (11, 20), (20, 20), (20, 0), (0, 0)] 452 | inner = [(5, 5), (5, 6), (6, 6), (6, 5)] 453 | in_geom0 = create_polygon(outer, [inner]) 454 | qgs_feature_out = build_and_launch(title, [in_geom0], 300) 455 | outer = [(0, 0), (0,20), (20,20), (20,0), (0,0)] 456 | inner = [(5, 5), (5, 6), (6, 6), (6, 5), (5, 5)] 457 | out_geom0 = create_polygon(outer, [inner]) 458 | val0 = out_geom0.equals(qgs_feature_out[0]) 459 | self.assertTrue(val0, title) 460 | 461 | def test_case17(self): 462 | title = "Test 17: Polygon with line in bend" 463 | coord = [(0, 0), (0, 20), (10, 20), (10, 21), (11, 21), (11, 20), (20, 20), (20, 0), (0,0)] 464 | qgs_geom0 = create_polygon(coord, []) 465 | qgs_geom1 = create_line([(10.1, 20.5), (10.2, 20.6), (10.3, 20.5)]) 466 | qgs_feature_out = build_and_launch(title, [qgs_geom0, qgs_geom1], 3) 467 | out_geom0 = create_polygon(coord, []) 468 | out_geom1 = create_line([(10.1, 20.5), (10.3, 20.5)]) 469 | val0 = out_geom0.equals(qgs_feature_out[0]) 470 | val1 = out_geom1.equals(qgs_feature_out[1]) 471 | self.assertTrue(val0 and val1, title) 472 | 473 | def test_case18(self): 474 | title = "Test 18: Polygon with point in bend" 475 | coord = [(0, 0), (0, 20), (10, 20), (10, 21), (11, 21), (11, 20), (20, 20), (20, 0), (0,0)] 476 | qgs_geom0 = create_polygon(coord, []) 477 | qgs_geom1 = create_point((10.1,20.5)) 478 | qgs_feature_out = build_and_launch(title, [qgs_geom0, qgs_geom1], 3) 479 | out_geom0 = create_polygon(coord, []) 480 | val0 = out_geom0.equals(qgs_feature_out[0]) 481 | val1 = qgs_geom1.equals(qgs_feature_out[1]) 482 | self.assertTrue(val0 and val1, title) 483 | 484 | def test_case19(self): 485 | title = "Test 19: Line String self intersecting after bend reduction" 486 | coord = [(0, 20), (10, 20), (10, 21), (11, 21), (11, 20), (30, 20), (30,0), (10.5,0), (10.5,20.5)] 487 | qgs_geom0 = create_line(coord) 488 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3) 489 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 490 | self.assertTrue(val0, title) 491 | 492 | def test_case20(self): 493 | title = "Test 20: Polygon with a bend containing a hole to delete" 494 | coord0 = [(0, 0), (0, 20), (10, 20), (10, 21), (11, 21), (11, 20), (20, 20), (20, 0)] 495 | coord1 = [(10.1, 20.1), (10.1, 20.2), (10.2, 20.2), (10.2, 20.1), (10.1, 20.1)] 496 | qgs_geom0 = create_polygon(coord0, [coord1]) 497 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3, del_pol=True, del_hole=True) 498 | coord = [(0,0), (0,20), (20,20), (20,0), (0,0)] 499 | out_geom0 = create_polygon(coord, []) 500 | val0 = out_geom0.equals(qgs_feature_out[0]) 501 | self.assertTrue(val0, title) 502 | 503 | def test_case21(self): 504 | title = "Test 21: Small polygon no bend with one small hole. Hole is deleted" 505 | coord0 = [(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)] 506 | coord1 = [(0.1,0.1), (0.1,0.2), (0.2,0.2), (0.2,0.1), (0.1,0.1)] 507 | qgs_geom0 = create_polygon(coord0, [coord1]) 508 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3, del_pol=False, del_hole=True) 509 | coord0 = [(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)] 510 | qgs_geom0 = create_polygon(coord0, []) 511 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 512 | self.assertTrue(val0, title) 513 | 514 | def test_case22(self): 515 | title = "Test 22: Small polygon no bend with one small hole. Feature is deleted" 516 | coord0 = [(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)] 517 | coord1 = [(0.1,0.1), (0.1,0.2), (0.2,0.2), (0.2,0.1), (0.1,0.1)] 518 | qgs_geom0 = create_polygon(coord0, [coord1]) 519 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3, del_pol=True, del_hole=False) 520 | self.assertEqual(len(qgs_feature_out), 0, title) 521 | 522 | def test_case23(self): 523 | title = "Test 23: Small polygon no bend with one small hole. Feature is deleted" 524 | coord0 = [(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)] 525 | coord1 = [(0.1,0.1), (0.1,0.2), (0.2,0.2), (0.2,0.1), (0.1,0.1)] 526 | qgs_geom0 = create_polygon(coord0, [coord1]) 527 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3, del_pol=True, del_hole=True) 528 | self.assertEqual(len(qgs_feature_out), 0, title) 529 | 530 | def test_case24(self): 531 | title = "Test 24: A line with a bend where the length of the base is zero (non simple line)" 532 | coord0 = [(0, 0), (50, 0), (49, 1), (51, 1), (50, 0), (100, 0)] 533 | qgs_geom0 = create_line(coord0) 534 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3, del_pol=True, del_hole=True) 535 | coord0 = [(0, 0), (100, 0)] 536 | qgs_geom0 = create_line(coord0) 537 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 538 | self.assertTrue(val0, title) 539 | 540 | def test_case25(self): 541 | title = "Test 25: A line with a bend with the form of a wave" 542 | coord0 = [(0, 0), (50, 0), (50,2), (49,2), (49,1), (48,1), (48,3), (51,3), (51,0), (100,0)] 543 | qgs_geom0 = create_line(coord0) 544 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 10, del_pol=True, del_hole=True) 545 | coord0 = [(0, 0), (100, 0)] 546 | qgs_geom0 = create_line(coord0) 547 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 548 | self.assertTrue(val0, title) 549 | 550 | def test_case26(self): 551 | title = "Test 26: A line with a smooth line replacing the bend" 552 | tmp_coord = [(0, -25), (25,0), (25,1), (29,1), (29,0), (50,-25)] 553 | tmp_coord_out = [(0, -25), (25, 0), (26.33333333333333215, 0.76980035891950094), 554 | (27.66666666666666785, 0.76980035891950094), (29, 0), (50, -25)] 555 | coord0 = list(tmp_coord) 556 | qgs_geom0 = create_line(coord0) 557 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3.9, del_pol=True, del_hole=True, smooth_line=True) 558 | coord0_out = list(tmp_coord_out) 559 | qgs_geom0 = create_line(coord0_out) 560 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 561 | self.assertTrue(val0, title) 562 | # 563 | coord0 = list(tmp_coord) 564 | coord0_out = list(tmp_coord_out) 565 | coord0.reverse() 566 | coord0_out.reverse() 567 | qgs_geom0 = create_line(coord0) 568 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3.9, del_pol=True, del_hole=True, smooth_line=True) 569 | qgs_geom0 = create_line(coord0_out) 570 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 571 | self.assertTrue(val0, title) 572 | 573 | for angle in [45., 90, 135, 180, 225, 270, 300]: 574 | coord0 = list(tmp_coord) 575 | qgs_geom0 = create_line(coord0) 576 | qgs_geom0.rotate(angle, QgsPointXY(0,0)) 577 | qgs_geom0.translate(25,25) 578 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3.9, del_pol=True, del_hole=True, smooth_line=True) 579 | qgs_geom_out = qgs_feature_out[0] 580 | qgs_geom_out.translate(-25, -25) 581 | qgs_geom_out.rotate(-angle, QgsPointXY(0, 0)) 582 | grid = .0000000001 583 | coord_ref = list(tmp_coord_out) 584 | qgs_geom0 = create_line(coord_ref) 585 | qgs_geom_grid_out = qgs_geom_out.snappedToGrid (grid,grid) 586 | qgs_geom_grid_ref = qgs_geom0.snappedToGrid(grid,grid) 587 | val0 = qgs_geom_grid_out.equals(qgs_geom_grid_ref) 588 | self.assertTrue(val0, title) 589 | 590 | 591 | def test_case27(self): 592 | title = "Test 27: Bend simplification with line smoothing but smoothing breaks spatial constraints" 593 | coord0 = [(-50,-25), (0,0), (0,-1), (3,-1), (3,0), (50,-25)] 594 | coord1 = [(1.5, .1), (1.5,3)] 595 | qgs_geom0 = create_line(coord0) 596 | qgs_geom1 = create_line(coord1) 597 | qgs_feature_out = build_and_launch(title, [qgs_geom0, qgs_geom1], 3, del_pol=True, del_hole=True, smooth_line=True) 598 | coord0 = [(-50,-25), (0,0), (3,0), (50,-25)] 599 | qgs_geom0 = create_line(coord0) 600 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 601 | self.assertTrue(val0, title) 602 | 603 | def test_case28(self): 604 | title = "Test 28: Bend simplification with line smoothing but with start/end segment going in opposite direction" 605 | coord0 = [(-50,-25), (0,0), (0,-1), (3,-1), (3,0), (50,25)] 606 | qgs_geom0 = create_line(coord0) 607 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3, del_pol=True, del_hole=True, smooth_line=True) 608 | coord0 = [(-50, -25), (0, 0), (1, 0.15579156685976017), (2, -0.15579156685976017), (3, 0), (50, 25)] 609 | qgs_geom0 = create_line(coord0) 610 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 611 | self.assertTrue(val0, title) 612 | 613 | def test_case29(self): 614 | title = "Test 29: Bend simplification with line smoothing but with self intersection" 615 | coord0 = [(-50,-25), (0,0), (0,-1), (3,-1), (3,0), (50,25), (50,0.05), (-50,0.05)] 616 | qgs_geom0 = create_line(coord0) 617 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3, del_pol=True, del_hole=True, smooth_line=True) 618 | coord0 = [(-50, -25), (0, 0), (3, 0), (50, 25), (50,0.05), (-50,0.05)] 619 | qgs_geom0 = create_line(coord0) 620 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 621 | self.assertTrue(val0, title) 622 | 623 | def test_case30(self): 624 | title = "Test 30: Bend simplification with line smoothing but with start/end segment going in opposite direction" 625 | coord0 = [(-50, -25), (0, 0), (0, -1), (3, -1), (3, 0), (50, 25)] 626 | coord1 = [(.9, .1), (1.1, .1)] 627 | qgs_geom0 = create_line(coord0) 628 | qgs_geom1 = create_line(coord1) 629 | qgs_feature_out = build_and_launch(title, [qgs_geom0, qgs_geom1], 3, del_pol=True, del_hole=True, smooth_line=True) 630 | coord0 = [(-50, -25), (0, 0), (3, 0), (50, 25)] 631 | qgs_geom0 = create_line(coord0) 632 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 633 | self.assertTrue(val0, title) 634 | 635 | def test_case31(self): 636 | title = "Test 31: Co-linear vertices at first/last vertice" 637 | coord0 = [(5,0), (0,0), (0,10), (5, 10), (10,10), (10,0), (5,0)] 638 | qgs_geom0 = create_line(coord0) 639 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3, del_pol=True, del_hole=True, smooth_line=True) 640 | coord0 = [(0,0), (0,10), (10,10), (10,0), (0,0)] 641 | qgs_geom0 = create_line(coord0) 642 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 643 | self.assertTrue(val0, title) 644 | 645 | def test_case32(self): 646 | title = "Test 31: Non simple line" 647 | coord0 = [(0,0), (5,0), (4,1), (6,1), (5,0), (10,0)] 648 | qgs_geom0 = create_line(coord0) 649 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3) 650 | coord0 = [(0,0), (10,0)] 651 | qgs_geom0 = create_line(coord0) 652 | val0 = qgs_geom0.equals(qgs_feature_out[0]) 653 | self.assertTrue(val0, title) 654 | 655 | def test_case33(self): 656 | title = "Test 33: Zero area polygon" 657 | coord0 = [(0, 0), (0, 0), (0, 0), (0, 0)] 658 | qgs_geom0 = create_polygon(coord0, []) 659 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3, del_pol=True, del_hole=True) 660 | self.assertEqual(len(qgs_feature_out), 0, title) 661 | 662 | def test_case34(self): 663 | title = "Test 34: Area polygon with zero area hole" 664 | coord0 = [(0, 0), (10, 0), (10, 10), (0, 10), (0,0)] 665 | coord1 = [(5, 5), (5, 5), (5, 5), (5, 5)] 666 | qgs_geom0 = create_polygon(coord0, [coord1]) 667 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 3, del_pol=True, del_hole=True) 668 | self.assertEqual(len(qgs_feature_out), 1, title) 669 | 670 | def test_case35(self): 671 | title = "Test 35: Normalization of in vector layer" 672 | print (title) 673 | vl = QgsVectorLayer("LineString", "temporary_polygon", "memory") 674 | pr = vl.dataProvider() 675 | fet = QgsFeature() 676 | fet.setId(1) 677 | qgs_line = QgsLineString((QgsPoint(0,0,0),QgsPoint(10,10,0),QgsPoint(20,20,0))) 678 | qgs_geom = QgsGeometry(qgs_line.clone()) 679 | fet.setGeometry(qgs_geom) 680 | pr.addFeatures([fet]) 681 | vl.updateExtents() 682 | feedback = QgsProcessingFeedback() 683 | qgs_features, geom_type = ReduceBend.normalize_in_vector_layer(vl, feedback) 684 | val0 = len(qgs_features) == 1 685 | qgs_geom = qgs_features[0].geometry() 686 | val1 = qgs_geom.wkbType() == QgsWkbTypes.LineString 687 | self.assertTrue(val0 and val1, title) 688 | 689 | 690 | # Supply path to qgis install location 691 | QgsApplication.setPrefixPath("/usr/bin/qgis", True) 692 | 693 | # profile_folder = 'C:\\Users\\berge\\AppData\\Roaming\\QGIS\\QGIS3\\profiles\\test12' 694 | #profile_folder = '.' 695 | # Create a reference to the QgsApplication. Setting the second argument to False disables the GUI. 696 | app = QgsApplication([], False) 697 | 698 | # Load providers and init QGIS 699 | app.initQgis() 700 | from processing.core.Processing import Processing 701 | Processing.initialize() 702 | QgsApplication.processingRegistry().addProvider(QgsNativeAlgorithms()) 703 | -------------------------------------------------------------------------------- /simplify_algorithm.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=no-name-in-module 3 | # pylint: disable=too-many-lines 4 | # pylint: disable=useless-return 5 | # pylint: disable=too-few-public-methods 6 | # pylint: disable=relative-beyond-top-level 7 | 8 | # /*************************************************************************** 9 | # simplify_algorithm.py 10 | # ---------- 11 | # Date : April 2021 12 | # copyright : (C) 2020 by Natural Resources Canada 13 | # email : daniel.pilon@canada.ca 14 | # 15 | # ***************************************************************************/ 16 | # 17 | # /*************************************************************************** 18 | # * * 19 | # * This program is free software; you can redistribute it and/or modify * 20 | # * it under the terms of the GNU General Public License as published by * 21 | # * the Free Software Foundation; either version 2 of the License, or * 22 | # * (at your option) any later version. * 23 | # * * 24 | # ***************************************************************************/ 25 | 26 | """ 27 | QGIS Plugin for Simplification (Douglas-Peucker algorithm) 28 | """ 29 | 30 | import os 31 | import inspect 32 | from qgis.PyQt.QtCore import QCoreApplication 33 | from qgis.PyQt.QtGui import QIcon 34 | from qgis.core import (QgsProcessing, QgsProcessingAlgorithm, QgsProcessingParameterDistance, 35 | QgsProcessingParameterFeatureSource, QgsProcessingParameterFeatureSink, 36 | QgsFeatureSink, QgsFeatureRequest, QgsLineString, QgsWkbTypes, QgsGeometry, 37 | QgsProcessingException) 38 | import processing 39 | from .geo_sim_util import Epsilon, GsCollection, GeoSimUtil, GsFeature, ProgressBar 40 | 41 | 42 | class SimplifyAlgorithm(QgsProcessingAlgorithm): 43 | """Main class defining the Simplify algorithm as a QGIS processing algorithm. 44 | """ 45 | 46 | def tr(self, string): # pylint: disable=no-self-use 47 | """Returns a translatable string with the self.tr() function. 48 | """ 49 | return QCoreApplication.translate('Processing', string) 50 | 51 | def createInstance(self): # pylint: disable=no-self-use 52 | """Returns a new copy of the algorithm. 53 | """ 54 | return SimplifyAlgorithm() 55 | 56 | def name(self): # pylint: disable=no-self-use 57 | """Returns the unique algorithm name. 58 | """ 59 | return 'simplify' 60 | 61 | def displayName(self): # pylint: disable=no-self-use 62 | """Returns the translated algorithm name. 63 | """ 64 | return self.tr('Simplify') 65 | 66 | def group(self): 67 | """Returns the name of the group this algorithm belongs to. 68 | """ 69 | return self.tr(self.groupId()) 70 | 71 | def groupId(self): # pylint: disable=no-self-use 72 | """Returns the unique ID of the group this algorithm belongs to. 73 | """ 74 | return '' 75 | 76 | def shortHelpString(self): 77 | """Returns a localised short help string for the algorithm. 78 | """ 79 | help_str = """ 80 | Simplify is a geospatial simplification (generalization) tool for lines and polygons. Simplify \ 81 | implements an improved version of the classic Douglas-Peucker algorithm with spatial constraints \ 82 | validation during geometry simplification. Simplify will preserve the following topological relationships: \ 83 | Simplicity (within the geometry), Intersection (with other geometries) and Sidedness (with other geometries). 84 | 85 | Usage 86 | Input layer : Any LineString or Polygon layer. Multi geometry are transformed into single part geometry. 87 | Tolerance: Tolerance used for line simplification. 88 | Simplified : Output layer of the algorithm. 89 | 90 | Rule of thumb for the diameter tolerance 91 | Simplify (Douglas-Peucker) is an excellent tool to remove vertices on features with high vertex densities \ 92 | while preserving a maximum of details within the geometries. Try it with small tolerance value and then use \ 93 | Reduce Bend to generalize features (generalization is needed). 94 | 95 | """ 96 | 97 | return self.tr(help_str) 98 | 99 | def icon(self): # pylint: disable=no-self-use 100 | """Define the logo of the algorithm. 101 | """ 102 | 103 | cmd_folder = os.path.split(inspect.getfile(inspect.currentframe()))[0] 104 | icon = QIcon(os.path.join(os.path.join(cmd_folder, 'logo.png'))) 105 | return icon 106 | 107 | def initAlgorithm(self, config=None): # pylint: disable=unused-argument 108 | """Define the inputs and outputs of the algorithm. 109 | """ 110 | 111 | # 'INPUT' is the recommended name for the main input parameter. 112 | self.addParameter(QgsProcessingParameterFeatureSource( 113 | 'INPUT', 114 | self.tr('Input layer'), 115 | types=[QgsProcessing.TypeVectorAnyGeometry])) 116 | 117 | # 'TOLERANCE' to be used Douglas-Peucker line simplificatin 118 | self.addParameter(QgsProcessingParameterDistance( 119 | 'TOLERANCE', 120 | self.tr('Diameter tolerance'), 121 | defaultValue=0.0, 122 | parentParameterName='INPUT')) # Make distance units match the INPUT layer units 123 | 124 | # 'OUTPUT' for the results 125 | self.addParameter(QgsProcessingParameterFeatureSink( 126 | 'OUTPUT', 127 | self.tr('Simplified'))) 128 | 129 | def processAlgorithm(self, parameters, context, feedback): 130 | """Main method that extract parameters and call Simplify algorithm. 131 | """ 132 | 133 | context.setInvalidGeometryCheck(QgsFeatureRequest.GeometryNoCheck) 134 | 135 | # Extract parameter 136 | source_in = self.parameterAsSource(parameters, "INPUT", context) 137 | tolerance = self.parameterAsDouble(parameters, "TOLERANCE", context) 138 | validate_structure = self.parameterAsBool(parameters, "VALIDATE_STRUCTURE", context) 139 | 140 | if source_in is None: 141 | raise QgsProcessingException(self.invalidSourceError(parameters, "INPUT")) 142 | 143 | # Transform the in source into a vector layer 144 | vector_layer_in = source_in.materialize(QgsFeatureRequest(), feedback) 145 | 146 | # Normalize and extract QGS input features 147 | qgs_features_in, geom_type = Simplify.normalize_in_vector_layer(vector_layer_in, feedback) 148 | 149 | # Validate input geometry type 150 | if geom_type not in (QgsWkbTypes.LineString, QgsWkbTypes.Polygon): 151 | raise QgsProcessingException("Can only process: (Multi)LineString or (Multi)Polygon vector layers") 152 | 153 | (sink, dest_id) = self.parameterAsSink(parameters, "OUTPUT", context, 154 | vector_layer_in.fields(), 155 | geom_type, 156 | vector_layer_in.sourceCrs()) 157 | 158 | # Validate sink 159 | if sink is None: 160 | raise QgsProcessingException(self.invalidSinkError(parameters, "OUTPUT")) 161 | 162 | # Set progress bar to 1% 163 | feedback.setProgress(1) 164 | 165 | # Call ReduceBend algorithm 166 | rb_return = Simplify.douglas_peucker(qgs_features_in, tolerance, validate_structure, feedback) 167 | 168 | for qgs_feature_out in rb_return.qgs_features_out: 169 | sink.addFeature(qgs_feature_out, QgsFeatureSink.FastInsert) 170 | 171 | # Push some output statistics 172 | feedback.pushInfo(" ") 173 | feedback.pushInfo("Number of features in: {0}".format(rb_return.in_nbr_features)) 174 | feedback.pushInfo("Number of features out: {0}".format(rb_return.out_nbr_features)) 175 | feedback.pushInfo("Number of iteration needed: {0}".format(rb_return.nbr_pass)) 176 | feedback.pushInfo("Total vertice deleted: {0}".format(rb_return.nbr_vertice_deleted)) 177 | if validate_structure: 178 | if rb_return.is_structure_valid: 179 | status = "Valid" 180 | else: 181 | status = "Invalid" 182 | feedback.pushInfo("Debug - State of the internal data structure: {0}".format(status)) 183 | 184 | return {"OUTPUT": dest_id} 185 | 186 | 187 | # -------------------------------------------------------- 188 | # Start of the algorithm 189 | # -------------------------------------------------------- 190 | 191 | # Define global constant 192 | 193 | 194 | class RbResults: 195 | """Class defining the stats and results""" 196 | 197 | __slots__ = ('in_nbr_features', 'out_nbr_features', 'nbr_vertice_deleted', 'qgs_features_out', 'nbr_pass', 198 | 'is_structure_valid') 199 | 200 | def __init__(self): 201 | """Constructor that initialize a RbResult object. 202 | 203 | :param: None 204 | :return: None 205 | :rtype: None 206 | """ 207 | 208 | self.in_nbr_features = None 209 | self.out_nbr_features = None 210 | self.nbr_vertice_deleted = 0 211 | self.qgs_features_out = None 212 | self.nbr_pass = 0 213 | self.is_structure_valid = None 214 | 215 | 216 | class Simplify: 217 | """Main class for bend reduction""" 218 | 219 | @staticmethod 220 | def normalize_in_vector_layer(in_vector_layer, feedback): 221 | """Method used to normalize the input vector layer 222 | 223 | Two processing are used to normalized the input vector layer 224 | - execute "Multi to single part" processing in order to accept even multi features 225 | - execute "Drop Z and M values" processing as they are not useful 226 | - Validate if the resulting layer is Point LineString or Polygon 227 | 228 | :param in_vector_layer: Input vector layer to normalize 229 | :param feedback: QgsFeedback handle used to communicate with QGIS 230 | :return Output vector layer and Output geometry type 231 | :rtype Tuple of 2 values 232 | """ 233 | 234 | # Execute MultiToSinglePart processing 235 | feedback.pushInfo("Start normalizing input layer") 236 | params = {'INPUT': in_vector_layer, 237 | 'OUTPUT': 'memory:'} 238 | result_ms = processing.run("native:multiparttosingleparts", params, feedback=feedback) 239 | ms_part_layer = result_ms['OUTPUT'] 240 | 241 | # Execute Drop Z M processing 242 | params = {'INPUT': ms_part_layer, 243 | 'DROP_M_VALUES': True, 244 | 'DROP_Z_VALUES': True, 245 | 'OUTPUT': 'memory:'} 246 | result_drop_zm = processing.run("native:dropmzvalues", params, feedback=feedback) 247 | drop_zm_layer = result_drop_zm['OUTPUT'] 248 | 249 | # Extract the QgsFeature from the vector layer 250 | qgs_in_features = [] 251 | qgs_features = drop_zm_layer.getFeatures() 252 | for qgs_feature in qgs_features: 253 | qgs_in_features.append(qgs_feature) 254 | if len(qgs_in_features) > 1: 255 | geom_type = qgs_in_features[0].geometry().wkbType() 256 | else: 257 | geom_type = drop_zm_layer.wkbType() # In case of empty layer 258 | feedback.pushInfo("End normalizing input layer") 259 | 260 | return qgs_in_features, geom_type 261 | 262 | @staticmethod 263 | def douglas_peucker(qgs_in_features, tolerance, validate_structure=False, feedback=None): 264 | """Main static method used to launch the simplification of the Douglas-Peucker algorithm. 265 | 266 | :param: qgs_features: List of QgsFeatures to process. 267 | :param: tolerance: Simplification tolerance in ground unit. 268 | :param: validate_structure: Validate internal data structure after processing (for debugging only) 269 | :param: feedback: QgsFeedback handle for interaction with QGIS. 270 | :return: Statistics and results object. 271 | :rtype: RbResults 272 | """ 273 | 274 | dp = Simplify(qgs_in_features, tolerance, validate_structure, feedback) 275 | results = dp.reduce() 276 | 277 | return results 278 | 279 | @staticmethod 280 | def find_farthest_point(qgs_points, first, last, ): 281 | """Returns a tuple with the farthest point's index and it's distance from a subline section 282 | 283 | :param: qgs_points: List of QgsPoint defining the line to process 284 | :first: int: Index of the first point in qgs_points 285 | :last: int: Index of the last point in qgs_points 286 | :return: distance from the farthest point; index of the farthest point 287 | :rtype: tuple of 2 values 288 | """ 289 | 290 | if last - first >= 2: 291 | qgs_geom_first_last = QgsLineString(qgs_points[first], qgs_points[last]) 292 | qgs_geom_engine = QgsGeometry.createGeometryEngine(qgs_geom_first_last) 293 | distances = [qgs_geom_engine.distance(qgs_points[i]) for i in range(first + 1, last)] 294 | farthest_dist = max(distances) 295 | farthest_index = distances.index(farthest_dist) + first + 1 296 | else: 297 | # Not enough vertice to calculate the farthest distance 298 | farthest_dist = -1. 299 | farthest_index = first 300 | 301 | return farthest_index, farthest_dist 302 | 303 | __slots__ = ('tolerance', 'validate_structure', 'feedback', 'rb_collection', 'eps', 'rb_results', 'rb_geoms', 304 | 'gs_features') 305 | 306 | def __init__(self, qgs_in_features, tolerance, validate_structure, feedback): 307 | """Constructor for Simplify algorithm. 308 | 309 | :param: qgs_in_features: List of features to process. 310 | :param: tolerance: Float tolerance distance of the Douglas Peucker algorithm. 311 | :param: validate_structure: flag to validate internal data structure after processing (for debugging) 312 | :param: feedback: QgsFeedback handle for interaction with QGIS. 313 | """ 314 | 315 | self.tolerance = tolerance 316 | self.validate_structure = validate_structure 317 | self.feedback = feedback 318 | 319 | # Calculates the epsilon and initialize some stats and results value 320 | self.eps = Epsilon(qgs_in_features) 321 | self.eps.set_class_variables() 322 | self.rb_results = RbResults() 323 | 324 | # Create the list of GsPolygon, GsLineString and GsPoint to process 325 | self.rb_results.in_nbr_features = len(qgs_in_features) 326 | self.gs_features = GsFeature.create_gs_feature(qgs_in_features) 327 | 328 | def reduce(self): 329 | """Main method to reduce line string. 330 | 331 | :return: Statistics and result object. 332 | :rtype: RbResult 333 | """ 334 | 335 | # Code used for the profiler (uncomment if needed) 336 | # import cProfile, pstats, io 337 | # from pstats import SortKey 338 | # pr = cProfile.Profile() 339 | # pr.enable() 340 | 341 | # Calculates the epsilon and initialize some stats and results value 342 | # self.eps = Epsilon(self.qgs_in_features) 343 | # self.eps.set_class_variables() 344 | # self.rb_results = RbResults() 345 | 346 | # # Create the list of GsPolygon, GsLineString and GsPoint to process 347 | # self.gs_features = GeoSimUtil.create_gs_feature(self.qgs_in_features) 348 | 349 | # Pre process the LineString: remove to close point and co-linear points 350 | self.rb_geoms = self.pre_simplification_process() 351 | 352 | # Create the GsCollection a spatial index to accelerate search for spatial relationships 353 | self.rb_collection = GsCollection() 354 | self.rb_collection.add_features(self.rb_geoms, self.feedback) 355 | 356 | # Execute the line simplification for each LineString 357 | self._simplify_lines() 358 | 359 | # Recreate the QgsFeature 360 | qgs_features_out = [gs_feature.get_qgs_feature() for gs_feature in self.gs_features] 361 | 362 | # Set return values 363 | self.rb_results.out_nbr_features = len(qgs_features_out) 364 | self.rb_results.qgs_features_out = qgs_features_out 365 | 366 | # Validate inner spatial structure. For debug purpose only 367 | if self.rb_results.is_structure_valid: 368 | self.rb_collection.validate_integrity(self.rb_geoms) 369 | 370 | # Code used for the profiler (uncomment if needed) 371 | # pr.disable() 372 | # s = io.StringIO() 373 | # sortby = SortKey.CUMULATIVE 374 | # ps = pstats.Stats(pr, stream=s).sort_stats(sortby) 375 | # ps.print_stats() 376 | # print(s.getvalue()) 377 | 378 | return self.rb_results 379 | 380 | def pre_simplification_process(self): 381 | """This method execute the pre simplification process 382 | 383 | Pre simplification process applies only to closed line string and is used to find the 2 points that are 384 | the distant from each other using the oriented bounding box 385 | 386 | :return: List of rb_geom 387 | :rtype: [RbGeom] 388 | """ 389 | 390 | # Create the list of RbGeom ==> List of geometry to simplify 391 | sim_geoms = [] 392 | for gs_feature in self.gs_features: 393 | sim_geoms += gs_feature.get_rb_geom() 394 | 395 | return sim_geoms 396 | 397 | def _simplify_lines(self): 398 | """Loop over the geometry until there is no more subline to simplify 399 | 400 | An iterative process for line simplification is applied in order to maximise line simplification. The process 401 | will always stabilize and exit when there are no more simplification to do. 402 | """ 403 | 404 | while True: 405 | progress_bar_value = 0 406 | self.rb_results.nbr_pass += 1 407 | progress_bar = ProgressBar(self.feedback, len(self.rb_geoms), 408 | "Iteration: {0}".format(self.rb_results.nbr_pass)) 409 | nbr_vertice_deleted = 0 410 | for i, rb_geom in enumerate(self.rb_geoms): 411 | if self.feedback.isCanceled(): 412 | break 413 | progress_bar.set_value(i) 414 | if not rb_geom.is_simplest: # Only process geometry that are not at simplest form 415 | nbr_vertice_deleted += self.process_line(rb_geom) 416 | 417 | self.feedback.pushInfo("Vertice deleted: {0}".format(nbr_vertice_deleted)) 418 | 419 | # While loop breaking condition (when no vertice deleted in a loop) 420 | if nbr_vertice_deleted == 0: 421 | break 422 | self.rb_results.nbr_vertice_deleted += nbr_vertice_deleted 423 | 424 | return 425 | 426 | def validate_constraints(self, sim_geom, first, last): 427 | """Validate the spatial relationship in order maintain topological structure 428 | 429 | Three distinct spatial relation are tested in order to assure that each bend reduce will continue to maintain 430 | the topological structure in a feature between the features: 431 | - Simplicity: Adequate validation is done to make sure that the bend reduction will not cause the feature 432 | to cross itself. 433 | - Intersection : Adequate validation is done to make sure that a line from other features will not intersect 434 | the bend being reduced 435 | - Sidedness: Adequate validation is done to make sure that a line is not completely contained in the bend. 436 | This situation can happen when a ring in a polygon complete;y lie in a bend ans after bend 437 | reduction, the the ring falls outside the polygon which make it invalid. 438 | 439 | Note if the topological structure is wrong before the bend correction no correction will be done on these 440 | errors. 441 | 442 | :param: sim_geom: Geometry used to validate constraints 443 | :param: first: Index of the start vertice of the subline 444 | :param: last: Index of the last vertice of the subline 445 | :return: Flag indicating if the spatial constraints are valid for this subline simplification 446 | :rtype: Bool 447 | """ 448 | 449 | constraints_valid = True 450 | 451 | qgs_points = [sim_geom.qgs_geom.vertexAt(i) for i in range(first, last+1)] 452 | qgs_geom_new_subline = QgsGeometry(QgsLineString(qgs_points[0], qgs_points[-1])) 453 | qgs_geom_old_subline = QgsGeometry(QgsLineString(qgs_points)) 454 | qgs_geoms_with_itself, qgs_geoms_with_others = \ 455 | self.rb_collection.get_segment_intersect(sim_geom.id, qgs_geom_old_subline.boundingBox(), 456 | qgs_geom_old_subline) 457 | 458 | # First: check if the bend reduce line string is an OGC simple line 459 | constraints_valid = GeoSimUtil.validate_simplicity(qgs_geoms_with_itself, qgs_geom_new_subline) 460 | 461 | # Second: check that the new line does not intersect with any other line or points 462 | if constraints_valid and len(qgs_geoms_with_others) >= 1: 463 | constraints_valid = GeoSimUtil.validate_intersection(qgs_geoms_with_others, qgs_geom_new_subline) 464 | 465 | # Third: check that inside the subline to simplify there is no feature completely inside it. This would cause a 466 | # sidedness or relative position error 467 | if constraints_valid and len(qgs_geoms_with_others) >= 1: 468 | qgs_ls_old_subline = QgsLineString(qgs_points) 469 | qgs_ls_old_subline.addVertex(qgs_points[0]) # Close the line with the start point 470 | qgs_geom_old_subline = QgsGeometry(qgs_ls_old_subline.clone()) 471 | 472 | # Next two lines used to transform a self intersecting line into a valid MultiPolygon 473 | qgs_geom_unary = QgsGeometry.unaryUnion([qgs_geom_old_subline]) 474 | qgs_geom_polygonize = QgsGeometry.polygonize([qgs_geom_unary]) 475 | 476 | if qgs_geom_polygonize.isSimple(): 477 | constraints_valid = GeoSimUtil.validate_sidedness(qgs_geoms_with_others, qgs_geom_polygonize) 478 | else: 479 | print("Polygonize not valid") 480 | constraints_valid = False 481 | 482 | return constraints_valid 483 | 484 | @staticmethod 485 | def init_process_line_stack(is_line_closed, qgs_points): 486 | """Method that initialize the stack used to simulate recursivity to simplify the line 487 | 488 | :param: is_closed: Boolean to indicate if the feature is closed or open 489 | :param: qgs_points: List of QgsPoints forming the line string to simplify 490 | :return: Stack used to initiate the line simplification process 491 | :rtype: List of tuple 492 | """ 493 | 494 | stack = [] 495 | last_index = len(qgs_points) - 1 496 | if is_line_closed: 497 | # Initialize stack for a closed line string 498 | if last_index >= 4: 499 | x = qgs_points[0].x() 500 | y = qgs_points[0].y() 501 | lst_distance = [qgs_point.distance(x, y) for qgs_point in qgs_points] 502 | mid_index = lst_distance.index(max(lst_distance)) # Most distant vertex position 503 | 504 | (farthest_index_a, farthest_dist_a) = Simplify.find_farthest_point(qgs_points, 0, mid_index) 505 | (farthest_index_b, farthest_dist_b) = Simplify.find_farthest_point(qgs_points, mid_index, last_index) 506 | if farthest_dist_a > 0.: 507 | stack.append((0, farthest_index_a)) 508 | stack.append((farthest_index_a, mid_index)) 509 | if farthest_dist_b > 0.: 510 | stack.append((mid_index, farthest_index_b)) 511 | stack.append((farthest_index_b, last_index)) 512 | else: 513 | # Not enough vertice... nothing to simplify 514 | pass 515 | else: 516 | # Initialize stack for an open line string 517 | stack.append((0, last_index)) 518 | 519 | return stack 520 | 521 | def process_line(self, sim_geom): 522 | """This method is simplifying a line with the Douglas Peucker algorithm and spatial constraints. 523 | 524 | Important note: The line is always simplified for the end of the line to the start of the line. This helps 525 | maintain the relative position of the vertice in the line 526 | 527 | :param: sim_geom: GeoSim object to simplify 528 | :return: Number of vertice deleted 529 | :rtype: int 530 | """ 531 | 532 | qgs_line_string = sim_geom.qgs_geom.constGet() 533 | qgs_points = qgs_line_string.points() 534 | 535 | # Initialize the stack that simulate recursivity 536 | stack = Simplify.init_process_line_stack(qgs_line_string.isClosed(), qgs_points) 537 | 538 | # Loop over the stack to simplify the line 539 | sim_geom.is_simplest = True 540 | nbr_vertice_deleted = 0 541 | while stack: 542 | (first, last) = stack.pop() 543 | if first + 1 < last: # The segment to check has only 2 points 544 | (farthest_index, farthest_dist) = Simplify.find_farthest_point(qgs_points, first, last) 545 | if farthest_dist <= self.tolerance: 546 | if self.validate_constraints(sim_geom, first, last): 547 | nbr_vertice_deleted += last - first - 1 548 | self.rb_collection.delete_vertex(sim_geom, first + 1, last - 1) 549 | else: 550 | sim_geom.is_simplest = False # The line string is not at its simplest form 551 | # In case of non respect of spatial constraints split and stack again the sub lines 552 | (farthest_index, farthest_dist) = Simplify.find_farthest_point(qgs_points, first, last) 553 | if farthest_dist <= self.tolerance: 554 | # Stack for the net iteration 555 | stack.append((first, farthest_index)) 556 | stack.append((farthest_index, last)) 557 | else: 558 | # Stack for the iteration 559 | stack.append((first, farthest_index)) 560 | stack.append((farthest_index, last)) 561 | 562 | return nbr_vertice_deleted 563 | -------------------------------------------------------------------------------- /simplify_unittest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # /*************************************************************************** 4 | # simplify_unittest.py 5 | # ---------- 6 | # Date : april 2021 7 | # copyright : (C) 2020 by Natural Resources Canada 8 | # email : daniel.pilon@canada.ca 9 | # 10 | # ***************************************************************************/ 11 | # 12 | # /*************************************************************************** 13 | # * * 14 | # * This program is free software; you can redistribute it and/or modify * 15 | # * it under the terms of the GNU General Public License as published by * 16 | # * the Free Software Foundation; either version 2 of the License, or * 17 | # * (at your option) any later version. * 18 | # * * 19 | # ***************************************************************************/ 20 | 21 | 22 | """ 23 | Unit test for simplify algorithm 24 | """ 25 | 26 | import unittest 27 | from qgis.core import QgsApplication 28 | from .simplify_algorithm import Simplify 29 | from qgis.core import QgsPoint, QgsLineString, QgsPolygon, QgsFeature, QgsGeometry, QgsProcessingFeedback, \ 30 | QgsVectorLayer, QgsWkbTypes, QgsPointXY 31 | from qgis.analysis import QgsNativeAlgorithms 32 | 33 | def qgs_line_string_to_xy(qgs_line_string): 34 | 35 | qgs_points = qgs_line_string.points() 36 | lst_x = [] 37 | lst_y = [] 38 | for qgs_point in qgs_points: 39 | lst_x.append(qgs_point.x()) 40 | lst_y.append(qgs_point.y()) 41 | 42 | return (lst_x, lst_y) 43 | 44 | def plot_lines(qgs_line_string, qgs_new_line): 45 | 46 | line0_lst_x, line0_lst_y = qgs_line_string_to_xy(qgs_line_string) 47 | # line1_lst_x, line1_lst_y = qgs_line_string_to_xy(qgs_new_line) 48 | 49 | import matplotlib.pyplot as plt 50 | plt.plot(line0_lst_x, line0_lst_y, 'b') 51 | # plt.plot(line1_lst_x, line1_lst_y, 'r') 52 | plt.show() 53 | 54 | 55 | def build_and_launch(title, qgs_geoms, tolerance): 56 | 57 | print(title) 58 | qgs_features = [] 59 | feedback = QgsProcessingFeedback() 60 | for qgs_geom in qgs_geoms: 61 | qgs_feature = QgsFeature() 62 | qgs_feature.setGeometry(qgs_geom) 63 | qgs_features.append(qgs_feature) 64 | 65 | rb_results = Simplify.douglas_peucker(qgs_features, tolerance, True, feedback) 66 | log = feedback.textLog() 67 | print (log) 68 | qgs_features_out = rb_results.qgs_features_out 69 | 70 | qgs_geoms_out = [] 71 | for qgs_feature_out in qgs_features_out: 72 | qgs_geoms_out.append(qgs_feature_out.geometry()) 73 | 74 | return qgs_geoms_out 75 | 76 | def create_line(coords, ret_geom=True): 77 | 78 | qgs_points = [] 79 | for coord in coords: 80 | qgs_points.append(create_point(coord, False)) 81 | 82 | if ret_geom: 83 | ret_val = QgsGeometry(QgsLineString(qgs_points)) 84 | else: 85 | ret_val = QgsLineString(qgs_points).clone() 86 | 87 | return ret_val 88 | 89 | def create_point(coord, ret_geom=True): 90 | 91 | qgs_point = QgsPoint(coord[0], coord[1]) 92 | if ret_geom: 93 | ret_val = QgsGeometry(qgs_point) 94 | else: 95 | ret_val = qgs_point.clone() 96 | 97 | return ret_val 98 | 99 | def create_polygon(outer, inners): 100 | 101 | outer_line = create_line(outer, False) 102 | qgs_pol = QgsPolygon() 103 | qgs_pol.setExteriorRing(outer_line) 104 | for inner in inners: 105 | inner_line = create_line(inner, False) 106 | qgs_pol.addInteriorRing(inner_line) 107 | qgs_geom = QgsGeometry(qgs_pol) 108 | 109 | return qgs_geom 110 | 111 | 112 | class Test(unittest.TestCase): 113 | """ 114 | Class allowing to test the algorithm 115 | """ 116 | 117 | def test_case01(self): 118 | title = "Test 01: Empty file" 119 | qgs_feature_out = build_and_launch(title, [], 2) 120 | if len(qgs_feature_out) == 0: 121 | val0 = True 122 | else: 123 | val0 = False 124 | self.assertTrue(val0, title) 125 | 126 | def test_case02(self): 127 | title = "Test 02: Open line with 2 vertice" 128 | qgs_geom0 = create_line([(0, 0), (10,0)]) 129 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 5) 130 | out_qgs_geom0 = create_line([(0, 0), (10,0)]) 131 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 132 | self.assertTrue (val0, title) 133 | 134 | def test_case03(self): 135 | title = "Test 03: Open line with 3 vertice" 136 | qgs_geom0 = create_line([(0, 0), (5, 1), (10,0)]) 137 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 5) 138 | out_qgs_geom0 = create_line([(0, 0), (10,0)]) 139 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 140 | self.assertTrue (val0, title) 141 | 142 | def test_case04(self): 143 | title = "Test 04: Open line with 4 vertice, farthest point is index: 2" 144 | qgs_geom0 = create_line([(0, 0), (5, 3), (10,4), (20,0)]) 145 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 5) 146 | out_qgs_geom0 = create_line([(0, 0), (20,0)]) 147 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 148 | self.assertTrue (val0, title) 149 | 150 | def test_case05(self): 151 | title = "Test 05: Open line with 4 vertice, farthest point is index: 1" 152 | qgs_geom0 = create_line([(0, 0), (5, 4), (10,3), (20,0)]) 153 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 5) 154 | out_qgs_geom0 = create_line([(0, 0), (20,0)]) 155 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 156 | self.assertTrue (val0, title) 157 | 158 | def test_case06(self): 159 | title = "Test 06: Open line with 5 vertice, farthest point is in the middle" 160 | qgs_geom0 = create_line([(0, 0), (5, 3), (10,4), (15,3), (20,0)]) 161 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 5) 162 | out_qgs_geom0 = create_line([(0, 0), (20,0)]) 163 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 164 | self.assertTrue (val0, title) 165 | 166 | def test_case07(self): 167 | title = "Test 07: Open line with 5 vertice, only vertice 1 and 4 are simplified" 168 | qgs_geom0 = create_line([(0, 0), (5, 3), (10,4), (15,3), (20,0)]) 169 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 3.5) 170 | out_qgs_geom0 = create_line([(0, 0), (10,4), (20,0)]) 171 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 172 | self.assertTrue (val0, title) 173 | 174 | def test_case08(self): 175 | title = "Test 08: Open line with 5 vertice, no simplification; below tolrance" 176 | qgs_geom0 = create_line([(0, 0), (5, 3), (10,4), (15,3), (20,0)]) 177 | qgs_feature_out = build_and_launch(title,[qgs_geom0], .1) 178 | out_qgs_geom0 = create_line([(0, 0), (5, 3), (10,4), (15,3), (20,0)]) 179 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 180 | self.assertTrue (val0, title) 181 | 182 | def test_case09(self): 183 | title = "Test 09: Closed line in form of triangle, no simplification" 184 | qgs_geom0 = create_polygon([(0, 0), (5, 5), (10,0), (0,0)], []) 185 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 10) 186 | out_qgs_geom0 = create_polygon([(0, 0), (5, 5), (10,0), (0,0)], []) 187 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 188 | self.assertTrue (val0, title) 189 | 190 | def test_case10(self): 191 | title = "Test 10: Closed line in form of a square" 192 | qgs_geom0 = create_polygon([(0, 0), (0,5), (5,5), (5,0), (0,0)], []) 193 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 10) 194 | out_qgs_geom0 = create_polygon([(0, 0), (0,5), (5,5), (5,0), (0,0)], []) 195 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 196 | self.assertTrue (val0, title) 197 | 198 | def test_case11(self): 199 | title = "Test 11: Closed line in form of a square" 200 | qgs_geom0 = create_polygon([(0, 0), (0,5), (3,6), (5,5), (5,0), (0,0)], []) 201 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 10) 202 | out_qgs_geom0 = create_polygon([(0, 0), (0,5), (5,5), (5,0), (0,0)], []) 203 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 204 | self.assertTrue (val0, title) 205 | 206 | def test_case12(self): 207 | title = "Test 12: Closed line in form of a square" 208 | qgs_geom0 = create_polygon([(0, 0), (0,5), (5,5), (5,0), (2,1), (0,0)], []) 209 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 10) 210 | out_qgs_geom0 = create_polygon([(0, 0), (0,5), (5,5), (5,0), (0,0)], []) 211 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 212 | self.assertTrue (val0, title) 213 | 214 | def test_case13(self): 215 | title = "Test 13: Closed line in form of a square" 216 | qgs_geom0 = create_polygon([(0, 0), (0, 5), (3, 5), (5, 5), (5, 0), (0, 0)], []) 217 | qgs_feature_out = build_and_launch(title, [qgs_geom0], 2) 218 | out_qgs_geom0 = create_polygon([(0, 0), (0, 5), (5, 5), (5, 0), (0, 0)], []) 219 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 220 | self.assertTrue(val0, title) 221 | 222 | def test_case14(self): 223 | title = "Test 14: Closed line in form of a square" 224 | qgs_geom0 = create_polygon([(0, 0), (0,5), (5,5), (5,0), (2,1), (0,0)], []) 225 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 2) 226 | out_qgs_geom0 = create_polygon([(0, 0), (0,5), (5,5), (5,0), (0,0)], []) 227 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 228 | self.assertTrue (val0, title) 229 | 230 | def test_case15(self): 231 | title = "Test 15: Open line self intersecting" 232 | qgs_geom0 = create_line([(0, 0), (5,0), (5,2), (10,2), (10,0), (50,0), (50, -5), (7,-5), (7,1)]) 233 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 3) 234 | out_qgs_geom0 = create_line([(0, 0), (5,2), (50,0), (50, -5), (7,-5), (7,1)]) 235 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 236 | self.assertTrue (val0, title) 237 | 238 | def test_case16(self): 239 | title = "Test 16: Open line intersecting another line (no simplification done)" 240 | qgs_geom0 = create_line([(0, 0), (2,2), (4,0)]) 241 | qgs_geom1 = create_line([(2, -1), (2, 1)]) 242 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1], 3) 243 | out_qgs_geom0 = create_line([(0, 0), (2,2), (4,0)]) 244 | out_qgs_geom1 = create_line([(2, -1), (2, 1)]) 245 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 246 | val1 = out_qgs_geom1.equals(qgs_feature_out[1]) 247 | self.assertTrue (val0 and val1, title) 248 | 249 | def test_case17(self): 250 | title = "Test 17: Open line intersecting another line: simplification done" 251 | qgs_geom0 = create_line([(0, 1), (3,3), (6,1)]) 252 | qgs_geom1 = create_line([(0, 0), (3,1.5), (6,0)]) 253 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1], 3) 254 | out_qgs_geom0 = create_line([(0, 1), (6, 1)]) 255 | out_qgs_geom1 = create_line([(0, 0), (6, 0)]) 256 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 257 | val1 = out_qgs_geom1.equals(qgs_feature_out[1]) 258 | self.assertTrue (val0 and val1, title) 259 | 260 | def test_case18(self): 261 | title = "Test 18: Open line intersecting another line: simplification done" 262 | qgs_geom0 = create_line([(0, 0), (3, 1.5), (6, 0)]) 263 | qgs_geom1 = create_line([(0, 1), (3,3), (6,1)]) 264 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1], 3) 265 | out_qgs_geom0 = create_line([(0, 0), (6, 0)]) 266 | out_qgs_geom1 = create_line([(0, 1), (6, 1)]) 267 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 268 | val1 = out_qgs_geom1.equals(qgs_feature_out[1]) 269 | self.assertTrue (val0 and val1, title) 270 | 271 | def test_case19(self): 272 | title = "Test 19: Open line intersecting another line (partial line simplification done)" 273 | qgs_geom0 = create_line([(0, 0), (2,2), (4,0), (6,2), (8,0)]) 274 | qgs_geom1 = create_line([(2, -1), (2, .5)]) 275 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1], 3) 276 | out_qgs_geom0 = create_line([(0, 0), (2,2), (8,0)]) 277 | out_qgs_geom1 = create_line([(2, -1), (2, .5)]) 278 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 279 | val1 = out_qgs_geom1.equals(qgs_feature_out[1]) 280 | self.assertTrue (val0 and val1, title) 281 | 282 | def test_case20(self): 283 | title = "Test 20: Open line intersecting another line (partial line simplification done)" 284 | qgs_geom0 = create_line([(0, 0), (2,2), (4,0), (6,2.5), (8,0)]) 285 | qgs_geom1 = create_line([(6, -1), (6, .5)]) 286 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1], 3) 287 | out_qgs_geom0 = create_line([(0, 0), (6,2.5), (8,0)]) 288 | out_qgs_geom1 = create_line([(6, -1), (6, .5)]) 289 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 290 | val1 = out_qgs_geom1.equals(qgs_feature_out[1]) 291 | self.assertTrue (val0 and val1, title) 292 | 293 | def test_case21(self): 294 | title = "Test 21: Open line with sidedness problem" 295 | qgs_geom0 = create_line([(0, 0), (2,2), (4,0)]) 296 | qgs_geom1 = create_line([(2,.1), (2,.2)]) 297 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1], 3) 298 | out_qgs_geom0 = create_line([(0, 0), (2,2), (4,0)]) 299 | out_qgs_geom1 = create_line([(2,.1), (2,.2)]) 300 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 301 | val1 = out_qgs_geom1.equals(qgs_feature_out[1]) 302 | self.assertTrue (val0 and val1, title) 303 | 304 | def test_case22(self): 305 | title = "Test 22: Open line with sidedness problem" 306 | qgs_geom0 = create_line([(0, 0), (2,2), (4,0), (6,2.5), (8,0)]) 307 | qgs_geom1 = create_line([(2,.1), (2,.2)]) 308 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1], 3) 309 | out_qgs_geom0 = create_line([(0, 0), (6,2.5), (8,0)]) 310 | out_qgs_geom1 = create_line([(2,.1), (2,.2)]) 311 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 312 | val1 = out_qgs_geom1.equals(qgs_feature_out[1]) 313 | self.assertTrue (val0 and val1, title) 314 | 315 | def test_case23(self): 316 | title = "Test 23: Two disjoint open line string with extremity touching ==> simplified" 317 | qgs_geom0 = create_line([(0,0), (2,2), (4,0)]) 318 | qgs_geom1 = create_line([(6,0), (8,2), (10,0)]) 319 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1], 3) 320 | out_qgs_geom0 = create_line([(0, 0), (4,0)]) 321 | out_qgs_geom1 = create_line([(6, 0), (10,0)]) 322 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 323 | val1 = out_qgs_geom1.equals(qgs_feature_out[1]) 324 | self.assertTrue (val0 and val1, title) 325 | 326 | def test_case24(self): 327 | title = "Test 24: Two open line string with extremity touching ==> simplified" 328 | qgs_geom0 = create_line([(0,0), (2,2), (4,0)]) 329 | qgs_geom1 = create_line([(4,0), (6,2), (8,0)]) 330 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1], 3) 331 | out_qgs_geom0 = create_line([(0, 0), (4,0)]) 332 | out_qgs_geom1 = create_line([(4, 0), (8,0)]) 333 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 334 | val1 = out_qgs_geom1.equals(qgs_feature_out[1]) 335 | self.assertTrue (val0 and val1, title) 336 | 337 | def test_case25(self): 338 | title = "Test 25: Two open line string with one extremity touching the middle of the other line: simplified" 339 | qgs_geom0 = create_line([(0,0), (2,2), (4,0)]) 340 | qgs_geom1 = create_line([(-2,0), (2,0)]) 341 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1], 3) 342 | out_qgs_geom0 = create_line([(0, 0), (4,0)]) 343 | out_qgs_geom1 = create_line([(-2, 0), (2,0)]) 344 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 345 | val1 = out_qgs_geom1.equals(qgs_feature_out[1]) 346 | self.assertTrue (val0 and val1, title) 347 | 348 | def test_case26(self): 349 | title = "Test 26: Two open line with one extremity superimposed in the middle of the other line: simplified" 350 | qgs_geom0 = create_line([(0,0), (2,2), (4,0)]) 351 | qgs_geom1 = create_line([(1,0), (3,0)]) 352 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1], 3) 353 | out_qgs_geom0 = create_line([(0, 0), (4,0)]) 354 | out_qgs_geom1 = create_line([(1, 0), (3,0)]) 355 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 356 | val1 = out_qgs_geom1.equals(qgs_feature_out[1]) 357 | self.assertTrue (val0 and val1, title) 358 | 359 | def test_case27(self): 360 | title = "Test 27: One open line with with duplicate points: simplified" 361 | qgs_geom0 = create_line([(0,0), (2,2), (2,2), (4,0)]) 362 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 3) 363 | out_qgs_geom0 = create_line([(0, 0), (4,0)]) 364 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 365 | self.assertTrue (val0, title) 366 | 367 | def test_case28(self): 368 | title = "Test 28: One open line with with duplicate points: simplified" 369 | qgs_geom0 = create_line([(0,0), (0,0), (2,2), (2,2), (2,2), (4,0), (4,0)]) 370 | qgs_feature_out = build_and_launch(title,[qgs_geom0], 3) 371 | out_qgs_geom0 = create_line([(0, 0), (4,0)]) 372 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 373 | self.assertTrue (val0, title) 374 | 375 | def test_case29(self): 376 | title = "Test 29: Different degenerated line string (points identical)" 377 | qgs_geom0 = create_line([(0,0), (0,0)]) 378 | qgs_geom1 = create_line([(10, 10), (10, 10), (10,10)]) 379 | qgs_geom2 = create_line([(20, 20), (20, 20), (20, 20), (20,20)]) 380 | qgs_geom3 = create_line([(30, 30), (30, 30), (30, 30), (30, 30), (30, 30)]) 381 | qgs_geom4 = create_line([(40, 40), (40, 40), (40, 40), (40, 40), (40, 40), (40,40)]) 382 | qgs_feature_out = build_and_launch(title,[qgs_geom0, qgs_geom1, qgs_geom2, qgs_geom3, qgs_geom4,],15) 383 | out_qgs_geom0 = create_line([(0, 0), (0, 0)]) 384 | out_qgs_geom1 = create_line([(10, 10), (10, 10), (10, 10)]) 385 | out_qgs_geom2 = create_line([(20, 20), (20, 20), (20, 20), (20, 20)]) 386 | out_qgs_geom3 = create_line([(30, 30), (30, 30), (30, 30), (30, 30), (30, 30)]) 387 | out_qgs_geom4 = create_line([(40, 40), (40, 40), (40, 40), (40, 40), (40, 40), (40, 40)]) 388 | val0 = out_qgs_geom0.equals(qgs_feature_out[0]) 389 | val1 = out_qgs_geom1.equals(qgs_feature_out[1]) 390 | val2 = out_qgs_geom2.equals(qgs_feature_out[2]) 391 | val3 = out_qgs_geom3.equals(qgs_feature_out[3]) 392 | val4 = out_qgs_geom4.equals(qgs_feature_out[4]) 393 | self.assertTrue (val0 and val1 and val2 and val3 and val4, title) 394 | 395 | 396 | 397 | 398 | # Supply path to qgis install location 399 | QgsApplication.setPrefixPath("/usr/bin/qgis", True) 400 | 401 | # profile_folder = 'C:\\Users\\berge\\AppData\\Roaming\\QGIS\\QGIS3\\profiles\\test12' 402 | #profile_folder = '.' 403 | # Create a reference to the QgsApplication. Setting the second argument to False disables the GUI. 404 | app = QgsApplication([], False) 405 | 406 | # Load providers and init QGIS 407 | app.initQgis() 408 | from processing.core.Processing import Processing 409 | Processing.initialize() 410 | QgsApplication.processingRegistry().addProvider(QgsNativeAlgorithms()) 411 | --------------------------------------------------------------------------------