├── tests ├── __init__.py ├── data │ ├── README.txt │ ├── voronoi.txt │ ├── voronoi_xy.txt │ ├── xy.txt │ └── triangles.txt ├── test_celltree_utils.py ├── test_demo.py ├── test_edgecelltree.py ├── test_utils.py ├── test_algorithms │ ├── test_barycentric.py │ ├── test_separating_axis.py │ ├── test_line_polygon_clip.py │ ├── test_sutherland_hodgman.py │ └── test_line_box_clip.py └── test_geometry_utils.py ├── .codecov.yml ├── docs ├── _static │ ├── theme-deltares.css │ ├── deltares-blue.svg │ ├── deltares-white.svg │ ├── celltree-logo.svg │ └── enabling-delta-life.svg ├── Makefile ├── make.bat ├── sg_execution_times.rst ├── index.rst ├── api.rst └── conf.py ├── numba_celltree ├── __init__.py ├── algorithms │ ├── __init__.py │ ├── barycentric_triangle.py │ ├── liang_barsky.py │ ├── separating_axis.py │ ├── barycentric_wachspress.py │ ├── cohen_sutherland.py │ ├── sutherland_hodgman.py │ └── cyrus_beck.py ├── cast.py ├── celltree_base.py ├── constants.py ├── utils.py ├── demo.py ├── edge_celltree.py ├── celltree.py └── creation.py ├── examples ├── README.rst ├── spatial_indexing_1d_network.py └── spatial_indexing_2d_grids.py ├── .github ├── dependabot.yml └── workflows │ ├── auto_update_pixi.yml │ └── ci.yml ├── .pre-commit-config.yaml ├── .gitignore ├── LICENSE ├── pyproject.toml └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # import for pytest-cov 2 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | github_checks: 3 | annotations: false 4 | -------------------------------------------------------------------------------- /docs/_static/theme-deltares.css: -------------------------------------------------------------------------------- 1 | /* enlarge deltares & github icon size; only works with local/url svg files; not with fa icons */ 2 | img.icon-link-image { 3 | height: 2.5em !important; 4 | } 5 | -------------------------------------------------------------------------------- /numba_celltree/__init__.py: -------------------------------------------------------------------------------- 1 | from numba_celltree.celltree import CellTree2d 2 | from numba_celltree.edge_celltree import EdgeCellTree2d 3 | 4 | __version__ = "0.4.1" 5 | 6 | __all__ = ("CellTree2d", "EdgeCellTree2d") 7 | -------------------------------------------------------------------------------- /tests/data/README.txt: -------------------------------------------------------------------------------- 1 | triangles, xy: This is the first example in: 2 | https://matplotlib.org/stable/gallery/images_contours_and_fields/triplot_demo.html 3 | 4 | voronoi: 5 | This is the voronoi tesselation of the second example. -------------------------------------------------------------------------------- /examples/README.rst: -------------------------------------------------------------------------------- 1 | .. examples-index: 2 | 3 | Examples 4 | ======== 5 | 6 | The examples in this gallery demonstrate the functionality of the 7 | ``numba_celltree`` package. Every example can be downloaded as either a Python 8 | script or a Jupyter notebook for interactive exploration. 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" # Location of package manifests 6 | schedule: 7 | interval: "weekly" 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.1.5 5 | hooks: 6 | # Run the linter. 7 | - id: ruff 8 | args: [--fix, --exit-non-zero-on-fix] 9 | # Run the formatter. 10 | - id: ruff-format -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | 4 | # Packages 5 | *.egg 6 | *.egg-info 7 | .eggs 8 | dist 9 | build 10 | eggs 11 | parts 12 | bin 13 | var 14 | sdist 15 | develop-eggs 16 | .installed.cfg 17 | lib 18 | lib64 19 | 20 | # Installer logs 21 | pip-log.txt 22 | 23 | # Built docs 24 | docs/_build 25 | docs/examples 26 | 27 | # Tox 28 | .tox 29 | 30 | # Editor settings 31 | .vscode 32 | 33 | # Coverage reports 34 | .coverage 35 | coverage.xml 36 | 37 | # Environments 38 | .pixi/ -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = imod 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=imod 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /.github/workflows/auto_update_pixi.yml: -------------------------------------------------------------------------------- 1 | name: Pixi auto update 2 | 3 | on: 4 | schedule: 5 | # At 03:00 on day 3 of the month 6 | - cron: "0 3 3 * *" 7 | # on demand 8 | workflow_dispatch: 9 | 10 | jobs: 11 | auto-update: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v6 15 | with: 16 | ssh-key: ${{ secrets.SSH_PRIVATE_KEY }} 17 | - uses: prefix-dev/setup-pixi@v0.9.3 18 | with: 19 | pixi-version: "latest" 20 | cache: false 21 | - name: Update pixi lock file 22 | run: pixi update 23 | - uses: peter-evans/create-pull-request@v8 24 | with: 25 | token: ${{ secrets.GITHUB_TOKEN }} 26 | branch: update/pixi-lock 27 | title: Update pixi lock file 28 | commit-message: "Update `pixi.lock`" 29 | body: Update pixi dependencies to the latest version. 30 | author: "GitHub " -------------------------------------------------------------------------------- /numba_celltree/algorithms/__init__.py: -------------------------------------------------------------------------------- 1 | from numba_celltree.algorithms.barycentric_triangle import barycentric_triangle_weights 2 | from numba_celltree.algorithms.barycentric_wachspress import ( 3 | barycentric_wachspress_weights, 4 | ) 5 | from numba_celltree.algorithms.cohen_sutherland import cohen_sutherland_line_box_clip 6 | from numba_celltree.algorithms.cyrus_beck import cyrus_beck_line_polygon_clip 7 | from numba_celltree.algorithms.liang_barsky import liang_barsky_line_box_clip 8 | from numba_celltree.algorithms.separating_axis import polygons_intersect 9 | from numba_celltree.algorithms.sutherland_hodgman import ( 10 | area_of_intersection, 11 | box_area_of_intersection, 12 | ) 13 | 14 | __all__ = ( 15 | "barycentric_triangle_weights", 16 | "barycentric_wachspress_weights", 17 | "cohen_sutherland_line_box_clip", 18 | "cyrus_beck_line_polygon_clip", 19 | "liang_barsky_line_box_clip", 20 | "polygons_intersect", 21 | "area_of_intersection", 22 | "box_area_of_intersection", 23 | ) 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Deltares 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/sg_execution_times.rst: -------------------------------------------------------------------------------- 1 | 2 | :orphan: 3 | 4 | .. _sphx_glr_sg_execution_times: 5 | 6 | 7 | Computation times 8 | ================= 9 | **00:00.665** total execution time for 1 file **from all galleries**: 10 | 11 | .. container:: 12 | 13 | .. raw:: html 14 | 15 | 19 | 20 | 21 | 22 | 27 | 28 | .. list-table:: 29 | :header-rows: 1 30 | :class: table table-striped sg-datatable 31 | 32 | * - Example 33 | - Time 34 | - Mem (MB) 35 | * - :ref:`sphx_glr_examples_spatial_indexing.py` (``..\examples\spatial_indexing.py``) 36 | - 00:00.665 37 | - 0.0 38 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Numba Celltree 2 | ============== 3 | 4 | Finding your way around in unstructured meshes is difficult. Numba Celltree 5 | provides methods for searching for points, lines, boxes, and cells (convex 6 | polygons) in a two dimensional unstructured mesh. 7 | 8 | .. code:: python 9 | 10 | import numpy as np 11 | from numba_celltree import CellTree2d, demo 12 | 13 | 14 | vertices, faces = demo.generate_disk(5, 5) 15 | vertices += 1.0 16 | vertices *= 5.0 17 | tree = CellTree2d(vertices, faces, -1) 18 | 19 | # Intersection with two triangles 20 | triangle_vertices = np.array( 21 | [ 22 | [5.0, 3.0], 23 | [7.0, 3.0], 24 | [7.0, 5.0], 25 | [0.0, 6.0], 26 | [4.0, 4.0], 27 | [6.0, 10.0], 28 | ] 29 | ) 30 | triangles = np.array([[0, 1, 2], [3, 4, 5]]) 31 | tri_i, cell_i, area = tree.intersect_faces(triangle_vertices, triangles, -1) 32 | 33 | # Intersection with two lines 34 | edge_coords = np.array( 35 | [ 36 | [[0.0, 0.0], [10.0, 10.0]], 37 | [[0.0, 10.0], [10.0, 0.0]], 38 | ] 39 | ) 40 | edge_i, cell_i, intersections = tree.intersect_edges(edge_coords) 41 | 42 | .. image:: _static/intersection-example.svg 43 | 44 | Installation 45 | ------------ 46 | 47 | .. code:: console 48 | 49 | pip install numba_celltree 50 | 51 | .. toctree:: 52 | :titlesonly: 53 | :hidden: 54 | 55 | examples/index 56 | api 57 | -------------------------------------------------------------------------------- /tests/test_celltree_utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from numba_celltree.celltree_base import bbox_distances, bbox_tree, default_tolerance 4 | from numba_celltree.constants import MIN_TOLERANCE, TOLERANCE_FACTOR 5 | 6 | bbox_coords = np.array( 7 | [ 8 | [1.0, 2.0, 1.0, 2.0], 9 | [4.0, 5.0, 0.0, 1.0], 10 | [4.0, 5.0, 2.0, 3.0], 11 | [-1.0, 0.0, 0.0, 4.0], 12 | [6.0, 8.0, 0.0, 4.0], 13 | [0.0, 6.0, -1.0, 0.0], 14 | [0.0, 6.0, 4.0, 5.0], 15 | ] 16 | ) 17 | 18 | 19 | def test_default_tolerance(): 20 | bb_diagonal = np.array([2.4, 0.5]) 21 | tolerance = default_tolerance(bb_diagonal) 22 | expected_value = 2.4 * TOLERANCE_FACTOR 23 | np.testing.assert_allclose( 24 | tolerance, expected_value, rtol=0, atol=MIN_TOLERANCE / 1e5 25 | ) 26 | 27 | tolerance = default_tolerance(bb_diagonal / 1e4) 28 | np.testing.assert_allclose( 29 | tolerance, MIN_TOLERANCE, rtol=0, atol=MIN_TOLERANCE / 1e5 30 | ) 31 | 32 | 33 | def test_bbox_tree(): 34 | expected_bbox = np.array([-1.0, 8.0, -1.0, 5.0]) 35 | bbox = bbox_tree(bbox_coords) 36 | np.testing.assert_allclose(bbox, expected_bbox, rtol=0, atol=1e-5) 37 | 38 | 39 | def test_bbox_distances(): 40 | expected_distances = np.array( 41 | [ 42 | [1.0, 1.0, 1.41421356], 43 | [1.0, 1.0, 1.41421356], 44 | [1.0, 1.0, 1.41421356], 45 | [1.0, 4.0, 4.12310563], 46 | [2.0, 4.0, 4.47213595], 47 | [6.0, 1.0, 6.08276253], 48 | [6.0, 1.0, 6.08276253], 49 | ] 50 | ) 51 | distances = bbox_distances(bbox_coords) 52 | np.testing.assert_allclose(distances, expected_distances, rtol=0, atol=1e-5) 53 | -------------------------------------------------------------------------------- /docs/_static/deltares-blue.svg: -------------------------------------------------------------------------------- 1 | Artboard 1 -------------------------------------------------------------------------------- /numba_celltree/algorithms/barycentric_triangle.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a straightforward implementation of barycentric interpolation for 3 | triangles. 4 | 5 | For barycentric interpolation on N-sided convex polygons, see 6 | barycentric_wachspress.py. 7 | """ 8 | 9 | import numba as nb 10 | import numpy as np 11 | 12 | from numba_celltree.constants import ( 13 | PARALLEL, 14 | FloatArray, 15 | FloatDType, 16 | IntArray, 17 | Triangle, 18 | ) 19 | from numba_celltree.geometry_utils import ( 20 | Point, 21 | as_point, 22 | as_triangle, 23 | cross_product, 24 | to_vector, 25 | ) 26 | 27 | 28 | @nb.njit(inline="always") 29 | def compute_weights(triangle: Triangle, p: Point, weights: FloatArray): 30 | ab = to_vector(triangle.a, triangle.b) 31 | ac = to_vector(triangle.a, triangle.c) 32 | ap = to_vector(triangle.a, p) 33 | Aa = abs(cross_product(ab, ap)) 34 | Ac = abs(cross_product(ac, ap)) 35 | A = abs(cross_product(ab, ac)) 36 | inv_denom = 1.0 / A 37 | w = inv_denom * Aa 38 | v = inv_denom * Ac 39 | u = 1.0 - v - w 40 | weights[0] = u 41 | weights[1] = v 42 | weights[2] = w 43 | return 44 | 45 | 46 | @nb.njit(parallel=PARALLEL, cache=True) 47 | def barycentric_triangle_weights( 48 | points: FloatArray, 49 | face_indices: IntArray, 50 | faces: IntArray, 51 | vertices: FloatArray, 52 | tolerance: float, 53 | ) -> FloatArray: 54 | n_points = len(points) 55 | weights = np.zeros((n_points, 3), dtype=FloatDType) 56 | for i in nb.prange(n_points): 57 | face_index = face_indices[i] 58 | if face_index == -1: 59 | continue 60 | face = faces[face_index] 61 | triangle = as_triangle(vertices, face) 62 | point = as_point(points[i]) 63 | compute_weights(triangle, point, weights[i]) 64 | return weights 65 | -------------------------------------------------------------------------------- /docs/_static/deltares-white.svg: -------------------------------------------------------------------------------- 1 | Artboard 1 -------------------------------------------------------------------------------- /numba_celltree/algorithms/liang_barsky.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import numba as nb 4 | import numpy as np 5 | 6 | from numba_celltree.constants import Box, Point 7 | from numba_celltree.geometry_utils import point_inside_box 8 | 9 | 10 | @nb.njit 11 | def liang_barsky_line_box_clip( 12 | a: Point, b: Point, box: Box 13 | ) -> Tuple[bool, Point, Point]: 14 | NO_INTERSECTION = False, Point(np.nan, np.nan), Point(np.nan, np.nan) 15 | dx = b.x - a.x 16 | dy = b.y - a.y 17 | 18 | if dx == 0.0 and dy == 0.0: 19 | return NO_INTERSECTION 20 | # Test whether line is fully enclosed in box 21 | if point_inside_box(a, box) and point_inside_box(b, box): 22 | return True, a, b 23 | 24 | t0 = 0.0 25 | t1 = 1.0 26 | P = (-dx, dx, -dy, dy) 27 | Q = ( 28 | a.x - box.xmin, 29 | box.xmax - a.x, 30 | a.y - box.ymin, 31 | box.ymax - a.y, 32 | ) 33 | 34 | for p_i, q_i in zip(P, Q): 35 | if p_i == 0: 36 | # Test whether line is parallel to box: 37 | # 1. no x-component (dx == 0), to the left or right 38 | # 2. no y-component (dy == 0), above or below 39 | if q_i < 0: 40 | return NO_INTERSECTION 41 | else: 42 | # Compute location on vector (t) 43 | # Compare against full length (0.0 -> 1.0) 44 | # or earlier computed values 45 | t = q_i / p_i 46 | if p_i < 0: 47 | if t > t1: 48 | return NO_INTERSECTION 49 | elif t > t0: 50 | t0 = t 51 | elif p_i > 0: 52 | if t < t0: 53 | return NO_INTERSECTION 54 | elif t < t1: 55 | t1 = t 56 | 57 | # TODO: Can this check be set as the first thing in the for loop for early 58 | # exits? 59 | if t0 == t1: 60 | return NO_INTERSECTION 61 | 62 | c = Point(a.x + t0 * dx, a.y + t0 * dy) 63 | d = Point(a.x + t1 * dx, a.y + t1 * dy) 64 | return True, c, d 65 | -------------------------------------------------------------------------------- /numba_celltree/cast.py: -------------------------------------------------------------------------------- 1 | # Ensure all types are as as statically expected. 2 | import numpy as np 3 | 4 | from numba_celltree.constants import ( 5 | FILL_VALUE, 6 | MAX_N_VERTEX, 7 | FloatArray, 8 | FloatDType, 9 | IntArray, 10 | IntDType, 11 | ) 12 | 13 | 14 | def cast_vertices(vertices: FloatArray, copy: bool = False) -> FloatArray: 15 | if isinstance(vertices, np.ndarray): 16 | vertices = vertices.astype(FloatDType, copy=copy) 17 | else: 18 | vertices = np.ascontiguousarray(vertices, dtype=FloatDType) 19 | if vertices.ndim != 2 or vertices.shape[1] != 2: 20 | raise ValueError("vertices must have shape (n_points, 2)") 21 | return vertices 22 | 23 | 24 | def cast_faces(faces: IntArray, fill_value: int) -> IntArray: 25 | if isinstance(faces, np.ndarray): 26 | faces = faces.astype(IntDType, copy=True) 27 | else: 28 | faces = np.ascontiguousarray(faces, dtype=IntDType) 29 | if faces.ndim != 2: 30 | raise ValueError("faces must have shape (n_face, n_max_vert)") 31 | _, n_max_vert = faces.shape 32 | if n_max_vert > MAX_N_VERTEX: 33 | raise ValueError( 34 | f"faces contains up to {n_max_vert} vertices for a single face. " 35 | f"numba_celltree supports a maximum of {MAX_N_VERTEX} vertices. " 36 | f"Increase MAX_N_VERTEX in the source code, or alter the mesh." 37 | ) 38 | if fill_value != FILL_VALUE: 39 | faces[faces == fill_value] = FILL_VALUE 40 | return faces 41 | 42 | 43 | def cast_bboxes(bbox_coords: FloatArray) -> FloatArray: 44 | bbox_coords = np.ascontiguousarray(bbox_coords, dtype=FloatDType) 45 | if bbox_coords.ndim != 2 or bbox_coords.shape[1] != 4: 46 | raise ValueError("bbox_coords must have shape (n_box, 4)") 47 | return bbox_coords 48 | 49 | 50 | def cast_edges(edges: FloatArray) -> FloatArray: 51 | edges = np.ascontiguousarray(edges, dtype=FloatDType) 52 | if edges.ndim != 3 or edges.shape[1] != 2 or edges.shape[2] != 2: 53 | raise ValueError("edges must have shape (n_edge, 2, 2)") 54 | return edges 55 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Check out repo 20 | uses: actions/checkout@v6 21 | - name: Set up Python 22 | uses: actions/setup-python@v6 23 | - name: Run pre-commit 24 | uses: pre-commit/action@v3.0.1 25 | test: 26 | name: ${{ matrix.pixi-environment }} - ${{ matrix.os }} 27 | runs-on: ${{ matrix.os }} 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | os: 32 | - ubuntu-latest 33 | - macOS-latest 34 | - windows-latest 35 | pixi-environment: 36 | - py313 37 | - py312 38 | - py311 39 | - py310 40 | - py309 41 | steps: 42 | - name: Check out repo 43 | uses: actions/checkout@v6 44 | - name: Setup Pixi 45 | uses: prefix-dev/setup-pixi@v0.9.3 46 | with: 47 | manifest-path: pyproject.toml 48 | - name: Run Tests 49 | run: | 50 | pixi run --environment ${{ matrix.pixi-environment }} test && 51 | pixi run --environment ${{ matrix.pixi-environment }} test-single-thread && 52 | pixi run --environment ${{ matrix.pixi-environment }} test-jit 53 | 54 | build: 55 | runs-on: ubuntu-latest 56 | steps: 57 | - name: Check out repo 58 | uses: actions/checkout@v6 59 | - name: Setup Pixi 60 | uses: prefix-dev/setup-pixi@v0.9.3 61 | with: 62 | manifest-path: pyproject.toml 63 | - name: Run Tests 64 | run: pixi run test 65 | - name: Publish Code Coverage 66 | uses: codecov/codecov-action@v5 67 | - name: Build Docs 68 | run: pixi run docs 69 | - name: Deploy to Github Pages 70 | if: github.ref == 'refs/heads/main' 71 | uses: peaceiris/actions-gh-pages@v4 72 | with: 73 | github_token: ${{ secrets.GITHUB_TOKEN }} 74 | publish_dir: ./docs/_build 75 | -------------------------------------------------------------------------------- /tests/data/voronoi.txt: -------------------------------------------------------------------------------- 1 | 13 5 10 22 -1 -1 -1 -1 -1 2 | 27 14 16 17 30 34 31 -1 -1 3 | 17 12 13 22 23 35 30 -1 -1 4 | 24 18 20 27 31 32 -1 -1 -1 5 | 34 30 35 37 39 40 42 38 -1 6 | 66 56 55 57 60 65 73 74 71 7 | 60 59 63 64 69 65 -1 -1 -1 8 | 80 8 79 -1 -1 -1 -1 -1 -1 9 | 79 8 0 2 81 -1 -1 -1 -1 10 | 2 1 3 6 82 81 -1 -1 -1 11 | 6 4 7 83 82 -1 -1 -1 -1 12 | 83 7 9 14 27 20 84 -1 -1 13 | 15 85 84 20 18 -1 -1 -1 -1 14 | 86 85 15 21 19 -1 -1 -1 -1 15 | 87 86 19 -1 -1 -1 -1 -1 -1 16 | 87 19 21 88 -1 -1 -1 -1 -1 17 | 88 21 15 18 24 89 -1 -1 -1 18 | 89 24 32 36 29 90 -1 -1 -1 19 | 90 29 91 -1 -1 -1 -1 -1 -1 20 | 91 29 36 92 -1 -1 -1 -1 -1 21 | 36 32 31 34 38 93 92 -1 -1 22 | 93 38 42 45 41 94 -1 -1 -1 23 | 94 41 95 -1 -1 -1 -1 -1 -1 24 | 96 95 41 45 46 47 48 51 -1 25 | 43 97 96 51 52 44 -1 -1 -1 26 | 97 43 98 -1 -1 -1 -1 -1 -1 27 | 98 43 44 99 -1 -1 -1 -1 -1 28 | 99 44 52 57 55 100 -1 -1 -1 29 | 50 101 100 55 56 53 -1 -1 -1 30 | 49 102 101 50 -1 -1 -1 -1 -1 31 | 102 49 103 -1 -1 -1 -1 -1 -1 32 | 103 49 50 53 104 -1 -1 -1 -1 33 | 58 105 104 53 56 66 62 61 -1 34 | 106 105 58 54 -1 -1 -1 -1 -1 35 | 106 54 107 -1 -1 -1 -1 -1 -1 36 | 107 54 58 61 108 -1 -1 -1 -1 37 | 108 61 62 68 109 -1 -1 -1 -1 38 | 109 68 110 -1 -1 -1 -1 -1 -1 39 | 68 62 66 71 72 111 110 -1 -1 40 | 111 72 112 -1 -1 -1 -1 -1 -1 41 | 72 71 74 75 113 112 -1 -1 -1 42 | 113 75 77 114 -1 -1 -1 -1 -1 43 | 77 76 78 115 114 -1 -1 -1 -1 44 | 78 116 115 -1 -1 -1 -1 -1 -1 45 | 76 117 116 78 -1 -1 -1 -1 -1 46 | 74 73 118 117 76 77 75 -1 -1 47 | 73 65 69 119 118 -1 -1 -1 -1 48 | 64 67 70 120 119 69 -1 -1 -1 49 | 70 121 120 -1 -1 -1 -1 -1 -1 50 | 67 122 121 70 -1 -1 -1 -1 -1 51 | 63 123 122 67 64 -1 -1 -1 -1 52 | 124 123 63 59 -1 -1 -1 -1 -1 53 | 52 51 48 125 124 59 60 57 -1 54 | 48 47 126 125 -1 -1 -1 -1 -1 55 | 46 127 126 47 -1 -1 -1 -1 -1 56 | 45 42 40 128 127 46 -1 -1 -1 57 | 39 129 128 40 -1 -1 -1 -1 -1 58 | 37 33 130 129 39 -1 -1 -1 -1 59 | 131 130 33 -1 -1 -1 -1 -1 -1 60 | 35 23 28 132 131 33 37 -1 -1 61 | 28 26 25 133 132 -1 -1 -1 -1 62 | 134 133 25 -1 -1 -1 -1 -1 -1 63 | 135 134 25 26 -1 -1 -1 -1 -1 64 | 22 10 11 136 135 26 28 23 -1 65 | 137 136 11 -1 -1 -1 -1 -1 -1 66 | 5 138 137 11 10 -1 -1 -1 -1 67 | 139 138 5 13 12 -1 -1 -1 -1 68 | 140 139 12 17 16 -1 -1 -1 -1 69 | 9 141 140 16 14 -1 -1 -1 -1 70 | 4 142 141 9 7 -1 -1 -1 -1 71 | 3 143 142 4 6 -1 -1 -1 -1 72 | 144 143 3 1 -1 -1 -1 -1 -1 73 | 0 145 144 1 2 -1 -1 -1 -1 74 | 80 145 0 8 -1 -1 -1 -1 -1 75 | -------------------------------------------------------------------------------- /tests/test_demo.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | import pytest 4 | 5 | from numba_celltree import demo 6 | 7 | 8 | def test_close_polygons(): 9 | faces = np.array( 10 | [ 11 | [0, 1, 2, -1, -1], 12 | [0, 1, 2, 3, -1], 13 | [0, 1, 2, 3, 4], 14 | ] 15 | ) 16 | closed = demo.close_polygons(faces, -1) 17 | expected = np.array( 18 | [ 19 | [0, 1, 2, 0, 0, 0], 20 | [0, 1, 2, 3, 0, 0], 21 | [0, 1, 2, 3, 4, 0], 22 | ] 23 | ) 24 | assert np.array_equal(closed, expected) 25 | 26 | 27 | def test_edges(): 28 | faces = np.array( 29 | [ 30 | [0, 1, 2, -1], 31 | [1, 3, 4, 2], 32 | ] 33 | ) 34 | actual = demo.edges(faces, -1) 35 | expected = np.array( 36 | [ 37 | [0, 1], 38 | [0, 2], 39 | [1, 2], 40 | [1, 3], 41 | [2, 4], 42 | [3, 4], 43 | ] 44 | ) 45 | assert np.array_equal(actual, expected) 46 | 47 | 48 | def test_plot_edges(): 49 | _, ax = plt.subplots() 50 | node_x = np.array([0.0, 1.0, 1.0, 2.0, 2.0]) 51 | node_y = np.array([0.0, 0.0, 1.0, 0.0, 1.0]) 52 | edges = np.array( 53 | [ 54 | [0, 1], 55 | [0, 2], 56 | [1, 2], 57 | [1, 3], 58 | [2, 4], 59 | [3, 4], 60 | ] 61 | ) 62 | demo.plot_edges(node_x, node_y, edges, ax) 63 | 64 | 65 | def test_plot_boxes(): 66 | boxes = np.array( 67 | [ 68 | [0.0, 1.0, 0.0, 1.0], 69 | [1.0, 2.0, 1.0, 2.0], 70 | ] 71 | ) 72 | _, ax = plt.subplots() 73 | demo.plot_boxes(boxes, ax, annotate=True) 74 | 75 | boxes = np.array( 76 | [ 77 | [0.0, 1.0, 0.0], 78 | [1.0, 2.0, 1.0], 79 | ] 80 | ) 81 | _, ax = plt.subplots() 82 | with pytest.raises(ValueError): 83 | demo.plot_boxes(boxes, ax) 84 | 85 | 86 | def test_generate_disk(): 87 | with pytest.raises(ValueError, match="partitions should be >= 3"): 88 | demo.generate_disk(2, 2) 89 | 90 | nodes, faces = demo.generate_disk(4, 1) 91 | assert nodes.shape == (5, 2) 92 | assert faces.shape == (4, 3) 93 | _, faces = demo.generate_disk(4, 2) 94 | assert faces.shape == (16, 3) 95 | -------------------------------------------------------------------------------- /numba_celltree/algorithms/separating_axis.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import numba as nb 4 | import numpy as np 5 | 6 | from numba_celltree.constants import ( 7 | FLOAT_MAX, 8 | FLOAT_MIN, 9 | PARALLEL, 10 | BoolArray, 11 | FloatArray, 12 | IntArray, 13 | ) 14 | from numba_celltree.geometry_utils import Vector, as_point, copy_vertices, dot_product 15 | 16 | 17 | @nb.njit(inline="always") 18 | def extrema_projected( 19 | norm: Vector, polygon: FloatArray, length: int 20 | ) -> Tuple[float, float]: 21 | min_proj = FLOAT_MAX 22 | max_proj = FLOAT_MIN 23 | for i in range(length): 24 | proj = dot_product(as_point(polygon[i]), norm) 25 | min_proj = min(min_proj, proj) 26 | max_proj = max(max_proj, proj) 27 | return min_proj, max_proj 28 | 29 | 30 | @nb.njit(inline="always") 31 | def is_separating_axis( 32 | norm: Vector, a: FloatArray, b: FloatArray, length_a: int, length_b: int 33 | ) -> bool: 34 | mina, maxa = extrema_projected(norm, a, length_a) 35 | minb, maxb = extrema_projected(norm, b, length_b) 36 | if maxa > minb and maxb > mina: 37 | return False 38 | else: 39 | return True 40 | 41 | 42 | @nb.njit(inline="always") 43 | def separating_axes(a: FloatArray, b: FloatArray) -> bool: 44 | length_a = len(a) 45 | length_b = len(b) 46 | p = as_point(a[length_a - 1]) 47 | for i in range(length_a): 48 | q = as_point(a[i]) 49 | norm = Vector(p.y - q.y, q.x - p.x) 50 | p = q 51 | if norm.x == 0.0 and norm.y == 0.0: 52 | continue 53 | if is_separating_axis(norm, a, b, length_a, length_b): 54 | return False 55 | return True 56 | 57 | 58 | @nb.njit(parallel=PARALLEL, cache=True) 59 | def polygons_intersect( 60 | vertices_a: FloatArray, 61 | vertices_b: FloatArray, 62 | faces_a: IntArray, 63 | faces_b: IntArray, 64 | indices_a: IntArray, 65 | indices_b: IntArray, 66 | ) -> BoolArray: 67 | n_shortlist = indices_a.size 68 | intersects = np.empty(n_shortlist, dtype=np.bool_) 69 | for i in nb.prange(n_shortlist): 70 | face_a = faces_a[indices_a[i]] 71 | face_b = faces_b[indices_b[i]] 72 | a = copy_vertices(vertices_a, face_a) 73 | b = copy_vertices(vertices_b, face_b) 74 | intersects[i] = separating_axes(a, b) and separating_axes(b, a) 75 | return intersects 76 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | CellTree2d 2 | ========== 3 | 4 | .. automodule:: numba_celltree 5 | :members: 6 | :imported-members: 7 | :undoc-members: 8 | :show-inheritance: 9 | 10 | Changelog 11 | ========= 12 | 13 | [0.4.1 2025-04-15] 14 | ------------------ 15 | 16 | Changed 17 | ~~~~~~~ 18 | 19 | - If tolerances are not provided, they are now estimated by the package. This 20 | should be a good default for most cases, but can be overridden by providing a 21 | custom value :meth:`CellTree2d.locate_points` and 22 | :meth:`EdgeCellTree2d.locate_points`. 23 | 24 | [0.4.0 2025-04-10] 25 | ------------------ 26 | 27 | Added 28 | ~~~~~ 29 | 30 | - ``tolerance`` argument to make tolerance configurable in 31 | :meth:`CellTree2d.locate_points`, 32 | :meth:`CellTree2d.compute_barycentric_weights`, and 33 | :meth:`EdgeCellTree2d.locate_points`. This allows for more lenient queries 34 | when working with datasets with large spatial coordinates. 35 | 36 | Changed 37 | ~~~~~~~ 38 | 39 | - Edge case handling has been improved. Dynamic tolerances are now 40 | automatically estimated or can be optionally provided for queries to handle 41 | floating point errors. In previous versions, the size of a cross product was 42 | compared with a static tolerance value of 1e-9, which made the tolerance 43 | effectively an area measure, or relative depending on the length of the edge 44 | rather than the perpendicular distance to the vertex. The current approach 45 | computes an actual distance, making the tolerance straightforward to 46 | interpret. The new defaults should result in fewer false positives and false 47 | negatives. 48 | 49 | [0.3.0 2025-03-25] 50 | ------------------ 51 | 52 | Added 53 | ~~~~~ 54 | 55 | - Add :class:`EdgeCellTree2d` class to support 2D queries on 1D networks and 56 | linear features. 57 | 58 | 59 | [0.2.2 2024-10-15] 60 | ------------------ 61 | 62 | Fixed 63 | ~~~~~ 64 | 65 | - :meth:`CellTree2d.intersect_edges` could hang indefinitely due to a while 66 | loop with faulty logic in 67 | :func:`numba_celltree.algorithms.cohen_sutherland_line_box_clip`. This issue 68 | seems to appears when an edge vertex lies exactly on top of a bounding box 69 | vertex of the celltree. The logic has been updated and the while loop exits 70 | correctly now. 71 | 72 | Changed 73 | ~~~~~~~ 74 | 75 | - The parallellization strategy of :meth:`CellTree2d.locate_boxes`, 76 | :meth:`CellTree2d.intersect_boxes`, :meth:`CellTree2d.locate_faces`, 77 | :meth:`CellTree2d.intersect_faces`, and :meth:`CellTree2d.intersect_edges` 78 | has been changed. Instead of querying twice -- once to count, then 79 | pre-allocate, then again to store result values -- has been replaced by 80 | manual chunking of input and dynamic allocation per chunk (thread). This 81 | should result in a net ~30% performance gain in most cases. 82 | 83 | -------------------------------------------------------------------------------- /docs/_static/celltree-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 2023-01-03T18:22:19.664862 10 | image/svg+xml 11 | 12 | 13 | Matplotlib v3.6.2, https://matplotlib.org/ 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 30 | 31 | 32 | 33 | 44 | 45 | 46 | 47 | 50 | 53 | 56 | 59 | 62 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /numba_celltree/algorithms/barycentric_wachspress.py: -------------------------------------------------------------------------------- 1 | """ 2 | Compute the Wachspress Barycentric ccoordinate for a convex polygon. These can 3 | be directly used for barycentric interpolation. 4 | """ 5 | 6 | import numba as nb 7 | import numpy as np 8 | 9 | from numba_celltree.constants import ( 10 | PARALLEL, 11 | FloatArray, 12 | FloatDType, 13 | IntArray, 14 | ) 15 | from numba_celltree.geometry_utils import ( 16 | Point, 17 | as_point, 18 | copy_vertices, 19 | cross_product, 20 | dot_product, 21 | to_vector, 22 | within_perpendicular_distance, 23 | ) 24 | 25 | 26 | @nb.njit(inline="always") 27 | def interp_edge_case(a, U, p, weights, i, j): 28 | # For the edge case, find the linear interpolation weight between the 29 | # vertices. 30 | weights[:] = 0 31 | V = to_vector(a, p) 32 | w = np.sqrt(dot_product(V, V)) / np.sqrt(dot_product(U, U)) 33 | weights[i] = 1.0 - w 34 | weights[j] = w 35 | return 36 | 37 | 38 | @nb.njit() 39 | def compute_weights( 40 | polygon: FloatArray, p: Point, weights: FloatArray, tolerance: float 41 | ) -> None: 42 | n = len(polygon) 43 | w_sum = 0.0 44 | 45 | # Initial iteration 46 | a = as_point(polygon[-1]) 47 | b = as_point(polygon[0]) 48 | U = to_vector(a, b) 49 | V = to_vector(a, p) 50 | Ai = abs(cross_product(U, V)) 51 | if within_perpendicular_distance(Ai, U, tolerance): 52 | # Note: weights may be differently sized than polygon! Hence n-1 53 | # instead of -1. 54 | interp_edge_case(a, U, p, weights, n - 1, 0) 55 | return 56 | 57 | for i in range(n): 58 | i_next = (i + 1) % n 59 | c = as_point(polygon[i_next]) 60 | 61 | W = to_vector(a, c) 62 | Ci = abs(cross_product(U, W)) 63 | 64 | U = to_vector(b, c) 65 | V = to_vector(b, p) 66 | Aj = abs(cross_product(U, V)) 67 | if within_perpendicular_distance(Aj, U, tolerance): 68 | interp_edge_case(b, U, p, weights, i, i_next) 69 | return 70 | 71 | w = 2 * Ci / (Ai * Aj) 72 | weights[i] = w 73 | w_sum += w 74 | 75 | # Setup next iteration 76 | a = b 77 | b = c 78 | # U = U 79 | Ai = Aj 80 | 81 | # normalize weights 82 | for i in range(n): 83 | weights[i] /= w_sum 84 | 85 | return 86 | 87 | 88 | @nb.njit(parallel=PARALLEL, cache=True) 89 | def barycentric_wachspress_weights( 90 | points: FloatArray, 91 | face_indices: IntArray, 92 | faces: IntArray, 93 | vertices: FloatArray, 94 | tolerance: float, 95 | ) -> FloatArray: 96 | n_points = len(points) 97 | n_max_vert = faces.shape[1] 98 | weights = np.zeros((n_points, n_max_vert), dtype=FloatDType) 99 | for i in nb.prange(n_points): 100 | face_index = face_indices[i] 101 | if face_index == -1: 102 | continue 103 | face = faces[face_index] 104 | polygon = copy_vertices(vertices, face) 105 | point = as_point(points[i]) 106 | compute_weights(polygon, point, weights[i], tolerance) 107 | return weights 108 | -------------------------------------------------------------------------------- /docs/_static/enabling-delta-life.svg: -------------------------------------------------------------------------------- 1 | Artboard 1 -------------------------------------------------------------------------------- /numba_celltree/algorithms/cohen_sutherland.py: -------------------------------------------------------------------------------- 1 | """ 2 | Slightly adapted from: 3 | https://github.com/scivision/lineclipping-python-fortran 4 | 5 | (MIT License) 6 | """ 7 | 8 | from typing import Tuple 9 | 10 | import numba as nb 11 | import numpy as np 12 | 13 | from numba_celltree.constants import Box, Point 14 | 15 | INSIDE, LEFT, RIGHT, LOWER, UPPER = 0, 1, 2, 4, 8 16 | 17 | 18 | @nb.njit(inline="always") 19 | def get_clip(a: Point, box: Box): 20 | p = INSIDE # default is inside 21 | 22 | # consider x 23 | if a.x < box.xmin: 24 | p |= LEFT 25 | elif a.x > box.xmax: 26 | p |= RIGHT 27 | 28 | # consider y 29 | if a.y < box.ymin: 30 | p |= LOWER # bitwise OR 31 | elif a.y > box.ymax: 32 | p |= UPPER # bitwise OR 33 | return p 34 | 35 | 36 | @nb.njit(inline="never") 37 | def cohen_sutherland_line_box_clip(a: Point, b: Point, box: Box) -> Tuple[Point, Point]: 38 | """ 39 | Clips a line to a rectangular area. 40 | 41 | This implements the Cohen-Sutherland line clipping algorithm. xmin, 42 | ymax, xmax and ymin denote the clipping area, into which the line 43 | defined by a.x, a.y (start point) and b.x, b.y (end point) will be 44 | clipped. 45 | 46 | If the line does not intersect with the rectangular clipping area, a 47 | boolean False and NaN-valued points will be returned. 48 | """ 49 | NO_INTERSECTION = False, Point(np.nan, np.nan), Point(np.nan, np.nan) 50 | dx = b.x - a.x 51 | dy = b.y - a.y 52 | if dx == 0.0 and dy == 0.0: 53 | return NO_INTERSECTION 54 | 55 | # check for trivially outside lines 56 | k1 = get_clip(a, box) 57 | k2 = get_clip(b, box) 58 | 59 | # examine non-trivially outside points 60 | # bitwise OR | 61 | # TODO: Figure out maximum number of iterations and replace by a for loop. 62 | while (k1 | k2) != INSIDE: 63 | # if both points are inside box (0000), 64 | # ACCEPT trivial whole line in box, exit. 65 | 66 | # if line trivially outside window, REJECT 67 | if (k1 & k2) != 0: # bitwise AND & 68 | return NO_INTERSECTION 69 | 70 | # non-trivial case, at least one point outside window 71 | # this is not a bitwise or, it's the word "or" 72 | opt = k1 or k2 # take first non-zero point, short circuit logic 73 | if opt & UPPER: # these are bitwise ANDS 74 | x = a.x + dx * (box.ymax - a.y) / dy 75 | y = box.ymax 76 | elif opt & LOWER: 77 | x = a.x + dx * (box.ymin - a.y) / dy 78 | y = box.ymin 79 | elif opt & RIGHT: 80 | y = a.y + dy * (box.xmax - a.x) / dx 81 | x = box.xmax 82 | elif opt & LEFT: 83 | y = a.y + dy * (box.xmin - a.x) / dx 84 | x = box.xmin 85 | else: 86 | raise RuntimeError("Undefined clipping state") 87 | 88 | if opt == k1: 89 | a = Point(x, y) 90 | k1 = get_clip(a, box) 91 | elif opt == k2: 92 | b = Point(x, y) 93 | k2 = get_clip(b, box) 94 | 95 | # Recompute (dx, dy) with new points. 96 | dx = b.x - a.x 97 | dy = b.y - a.y 98 | if dx == 0.0 and dy == 0.0: 99 | return NO_INTERSECTION 100 | 101 | return True, a, b 102 | -------------------------------------------------------------------------------- /examples/spatial_indexing_1d_network.py: -------------------------------------------------------------------------------- 1 | """ 2 | Spatial indexing of 1D networks and linear geometry 3 | =================================================== 4 | 5 | This example demonstrates how to use the ``numba_celltree`` package to index 1D 6 | grids. The package provides a :class:`EdgeCellTree` class that constructs a 7 | cell tree for 1D networks and linear geometries. The package currently supports 8 | the following operations: 9 | 10 | * Locating points 11 | * Locating line segments 12 | 13 | This example provides an introduction to searching a cell tree for each of 14 | these. 15 | 16 | We'll start by importing the required packages with matplotlib for plotting. 17 | """ 18 | 19 | import os 20 | 21 | import matplotlib.pyplot as plt 22 | import numpy as np 23 | from matplotlib.collections import LineCollection 24 | 25 | os.environ["NUMBA_DISABLE_JIT"] = "1" # small examples, avoid JIT overhead 26 | from numba_celltree import EdgeCellTree2d, demo # noqa E402 27 | 28 | # %% 29 | # Let's start with a simple 1D network. 30 | 31 | vertices, edges = demo.example_1d_network() 32 | 33 | node_x = vertices.T[0] 34 | node_y = vertices.T[1] 35 | 36 | fig, ax = plt.subplots() 37 | demo.plot_edges(node_x, node_y, edges, ax, color="black") 38 | 39 | # %% 40 | # Locating points 41 | # --------------- 42 | # 43 | # We'll build a cell tree first, then look for some points. 44 | 45 | tree = EdgeCellTree2d(vertices, edges) 46 | points = np.array( 47 | [ 48 | [0.25, 1.5], 49 | [0.75, 1.5], 50 | [2.0, 1.5], # This one is outside the grid 51 | ] 52 | ) 53 | i = tree.locate_points(points) 54 | i 55 | 56 | # %% 57 | # This returns the indices of the edges that contain each point, with -1 58 | # indicating points outside the network. We'll have to filter those out first. 59 | # Let's plot them: 60 | 61 | 62 | ii = i[i != -1] 63 | 64 | fig, ax = plt.subplots() 65 | ax.scatter(*points.transpose()) 66 | demo.plot_edges(node_x, node_y, edges, ax, color="black") 67 | demo.plot_edges(node_x, node_y, edges[ii], ax, color="blue", linewidth=3) 68 | 69 | # %% 70 | # Locating line segments 71 | # ----------------------- 72 | # 73 | # Let's locate some line segments on the grid. We'll start off with creating 74 | # some line segments. 75 | 76 | segments = np.array( 77 | [ 78 | [[0.0, 1.25], [1.5, 1.5]], 79 | [[1.5, 1.5], [2.25, 3.5]], 80 | ] 81 | ) 82 | 83 | fig, ax = plt.subplots() 84 | demo.plot_edges(node_x, node_y, edges, ax, color="black") 85 | ax.add_collection(LineCollection(segments, color="gray", linewidth=3)) 86 | 87 | # %% 88 | # Let's now intersect these line segments with the edges in the network. 89 | segment_index, tree_edge_index, xy_intersection = tree.intersect_edges(segments) 90 | xy_intersection 91 | 92 | # %% 93 | # The ``intersect_edges`` method returns three arrays: which input segments 94 | # intersect with the network, which network edges they intersect with, and the 95 | # coordinates of each intersection point. 96 | # 97 | # Let's plot the results: 98 | 99 | fig, ax = plt.subplots() 100 | demo.plot_edges(node_x, node_y, edges, ax, color="black") 101 | demo.plot_edges(node_x, node_y, edges[tree_edge_index], ax, color="blue", linewidth=3) 102 | ax.add_collection(LineCollection(segments, color="gray", linewidth=3)) 103 | ax.scatter(*xy_intersection.transpose(), s=60, color="darkgreen", zorder=2.5) 104 | 105 | # %% 106 | -------------------------------------------------------------------------------- /tests/test_edgecelltree.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from numba_celltree import EdgeCellTree2d 4 | from numba_celltree.constants import CellTreeData 5 | 6 | TOLERANCE_ON_EDGE = 1e-9 7 | 8 | vertices = np.array([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [2.0, 1.0]], dtype=float) 9 | edges = np.array([[0, 1], [1, 2], [2, 3]], dtype=np.int32) 10 | 11 | 12 | def test_init(): 13 | tree = EdgeCellTree2d(vertices, edges) 14 | assert tree.vertices.shape == (4, 2) 15 | assert tree.edges.shape == (3, 2) 16 | assert tree.n_buckets == 4 17 | assert tree.cells_per_leaf == 2 18 | assert tree.nodes.shape == (3,) 19 | assert tree.bb_indices.shape == (3,) 20 | assert tree.bb_coords.shape == (3, 4) 21 | assert tree.bbox.shape == (4,) 22 | assert isinstance(tree.celltree_data, CellTreeData) 23 | 24 | np.testing.assert_array_equal(tree.bb_indices, np.array([0, 1, 2], dtype=np.int32)) 25 | 26 | expected_bb_coords = np.array( 27 | [ 28 | [0.0, 1.0, 0.0, 0.0], 29 | [1.0, 2.0, 0.0, 0.0], 30 | [2.0, 2.0, 0.0, 1.0], 31 | ], 32 | dtype=float, 33 | ) 34 | np.testing.assert_allclose( 35 | tree.bb_coords, expected_bb_coords, atol=TOLERANCE_ON_EDGE 36 | ) 37 | np.testing.assert_allclose( 38 | tree.bbox, np.array([0.0, 2.0, 0.0, 1.0]), atol=TOLERANCE_ON_EDGE 39 | ) 40 | 41 | 42 | def test_locate_points(): 43 | tree = EdgeCellTree2d(vertices, edges) 44 | points = np.array([[0.5, 0.0], [1.5, 0.0], [2.0, 0.5]], dtype=float) 45 | tree_edge_indices = tree.locate_points(points) 46 | np.testing.assert_array_equal( 47 | tree_edge_indices, np.array([0, 1, 2], dtype=np.int32) 48 | ) 49 | 50 | points = np.array([[0.5, 0.5], [1.5, 0.5], [2.0, 0.5]], dtype=float) 51 | tree_edge_indices = tree.locate_points(points) 52 | np.testing.assert_array_equal( 53 | tree_edge_indices, np.array([-1, -1, 2], dtype=np.int32) 54 | ) 55 | 56 | 57 | def test_locate_points_big_coords__tolerance(): 58 | """Small test case extracted from LHM""" 59 | vertices = np.array( 60 | [[171805.657000002, 563516.366], [171889.594000001, 563437.333000001]] 61 | ) 62 | edges = np.array([[0, 1]]) 63 | tree = EdgeCellTree2d(vertices, edges) 64 | points = np.array([[171882.49385095935, 563444.0183244612]]) 65 | tree_edge_indices = tree.locate_points(points, tolerance=1e-8) 66 | np.testing.assert_array_equal(tree_edge_indices, np.array([0], dtype=np.int32)) 67 | # test with a tolerance that is too small 68 | tree_edge_indices = tree.locate_points(points, tolerance=1e-9) 69 | np.testing.assert_array_equal(tree_edge_indices, np.array([0], dtype=np.int32)) 70 | 71 | 72 | def test_intersect_edges(): 73 | tree = EdgeCellTree2d(vertices, edges) 74 | edge_coords = np.array( 75 | [ 76 | [[1.0, -1.0], [1.0, 1.0]], # 0 orthogonal, on vertex 77 | [[3.0, 1.0], [-1.0, -1.0]], # 1 two intersctions 78 | [ 79 | [0.0, -1.0], 80 | [0.0, 1.0], 81 | ], # edge case: hits start vertex tree, no intersect 82 | [[-2.0, -1.0], [-3.0, -1.0]], # no interesect 83 | ] 84 | ) 85 | actual_edge, actual_tree_edge, actual_xy = tree.intersect_edges(edge_coords) 86 | expected_edge = np.array([0, 1, 1], dtype=np.int32) 87 | expected_tree_edge = np.array([0, 1, 2], dtype=np.int32) 88 | expected_xy = np.array([[1.0, 0.0], [1.0, 0.0], [2.0, 0.5]], dtype=float) 89 | 90 | np.testing.assert_array_equal(actual_edge, expected_edge) 91 | np.testing.assert_array_equal(actual_tree_edge, expected_tree_edge) 92 | np.testing.assert_allclose(actual_xy, expected_xy, atol=TOLERANCE_ON_EDGE) 93 | -------------------------------------------------------------------------------- /numba_celltree/celltree_base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Optional 3 | 4 | import numpy as np 5 | 6 | from numba_celltree.constants import ( 7 | MIN_TOLERANCE, 8 | TOLERANCE_FACTOR, 9 | BoolArray, 10 | FloatArray, 11 | FloatDType, 12 | IntArray, 13 | ) 14 | from numba_celltree.query import ( 15 | collect_node_bounds, 16 | validate_node_bounds, 17 | ) 18 | 19 | 20 | def bbox_tree(bb_coords: FloatArray) -> FloatArray: 21 | xmin = bb_coords[:, 0].min() 22 | xmax = bb_coords[:, 1].max() 23 | ymin = bb_coords[:, 2].min() 24 | ymax = bb_coords[:, 3].max() 25 | return np.array([xmin, xmax, ymin, ymax], dtype=FloatDType) 26 | 27 | 28 | def bbox_distances(bb_coords: FloatArray) -> FloatArray: 29 | """Compute the dx, dy and dxy distances for the bounding boxes. 30 | 31 | Parameters 32 | ---------- 33 | bb_coords: np.ndarray of shape (n_nodes, 4) 34 | The bounding box coordinates of the nodes. 35 | 36 | Returns 37 | ------- 38 | distances: np.ndarray of shape (n_nodes, 3) 39 | Respectively the dx, dy and dxy distances for the bounding boxes. 40 | """ 41 | distances = np.empty((bb_coords.shape[0], 3), dtype=FloatDType) 42 | # dx 43 | distances[:, 0] = bb_coords[:, 1] - bb_coords[:, 0] 44 | # dy 45 | distances[:, 1] = bb_coords[:, 3] - bb_coords[:, 2] 46 | # dxy 47 | distances[:, 2] = np.sqrt(distances[:, 0] ** 2 + distances[:, 1] ** 2) 48 | return distances 49 | 50 | 51 | def default_tolerance(bb_diagonal: FloatArray) -> float: 52 | return max(MIN_TOLERANCE, TOLERANCE_FACTOR * max(bb_diagonal)) 53 | 54 | 55 | class CellTree2dBase(abc.ABC): 56 | @abc.abstractmethod 57 | def locate_points( 58 | self, points: FloatArray, tolerance: Optional[float] = None 59 | ) -> IntArray: 60 | pass 61 | 62 | @property 63 | def node_bounds(self): 64 | """Return the bounds (xmin, xmax, ymin, ymax) for every node of the tree.""" 65 | return collect_node_bounds(self.celltree_data) 66 | 67 | def validate_node_bounds(self) -> BoolArray: 68 | """ 69 | Traverse the tree. Check whether all children are contained in the bounding 70 | box. 71 | 72 | For the leaf nodes, check whether the bounding boxes are contained. 73 | 74 | Returns 75 | ------- 76 | node_validity: np.array of bool 77 | For each node, whether all children are fully contained by its 78 | bounds. 79 | """ 80 | return validate_node_bounds(self.celltree_data, self.node_bounds) 81 | 82 | def to_dict_of_lists(self): 83 | """ 84 | Convert the tree structure to a dict of lists. 85 | 86 | Such a dict can be ingested by e.g. NetworkX to produce visualize the 87 | tree structure. 88 | 89 | Returns 90 | ------- 91 | dict_of_lists: Dict[Int, List[Int]] 92 | Contains for every node a list with its children. 93 | 94 | Examples 95 | -------- 96 | >>> import networkx 97 | >>> from networkx.drawing.nx_pydot import graphviz_layout 98 | >>> d = celltree.to_dict_of_lists() 99 | >>> G = networkx.DiGraph(d) 100 | >>> positions = graphviz_layout(G, prog="dot") 101 | >>> networkx.draw(G, positions, with_labels=True) 102 | 103 | Note that computing the graphviz layout may be quite slow! 104 | """ 105 | dict_of_lists = {} 106 | for parent_index, node in enumerate(self.celltree_data.nodes): 107 | left_child = node["child"] 108 | if left_child == -1: 109 | dict_of_lists[parent_index] = [] 110 | else: 111 | right_child = left_child + 1 112 | dict_of_lists[parent_index] = [left_child, right_child] 113 | 114 | return dict_of_lists 115 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "numba_celltree" 7 | description = "Cell Tree Spatial Index" 8 | readme = { file = "README.rst", content-type = "text/x-rst" } 9 | dynamic = ["version"] 10 | maintainers = [ 11 | { name = "Huite Bootsma", email = "huite.bootsma@deltares.nl" } 12 | ] 13 | requires-python = ">=3.9" 14 | dependencies = [ 15 | 'numba', 16 | 'numpy', 17 | ] 18 | classifiers = [ 19 | 'Development Status :: 4 - Beta', 20 | 'Intended Audience :: Science/Research', 21 | 'License :: OSI Approved :: MIT License', 22 | 'Programming Language :: Python', 23 | 'Operating System :: OS Independent', 24 | 'Programming Language :: Python :: 3', 25 | 'Programming Language :: Python :: 3.9', 26 | 'Programming Language :: Python :: 3.10', 27 | 'Programming Language :: Python :: 3.11', 28 | 'Programming Language :: Python :: 3.12', 29 | 'Programming Language :: Python :: 3.13', 30 | 'Programming Language :: Python :: Implementation :: CPython', 31 | 'Topic :: Scientific/Engineering', 32 | ] 33 | keywords = [ 34 | 'mesh', 35 | 'spatial index', 36 | 'ugrid', 37 | 'unstructured grid', 38 | ] 39 | license = { text = "MIT" } 40 | 41 | [project.urls] 42 | Home = "https://github.com/deltares/numba_celltree" 43 | Code = "https://github.com/deltares/numba_celltree" 44 | Issues = "https://github.com/deltares/numba_celltree/issues" 45 | 46 | [project.optional-dependencies] 47 | all = ["matplotlib"] 48 | docs = ["matplotlib", "pydata-sphinx-theme", "sphinx", "sphinx-gallery"] 49 | 50 | [tool.hatch.version] 51 | path = "numba_celltree/__init__.py" 52 | 53 | [tool.hatch.build.targets.sdist] 54 | only-include = ["numba_celltree", "tests"] 55 | 56 | [tool.coverage.report] 57 | exclude_lines = [ 58 | "pragma: no cover", 59 | ] 60 | 61 | [tool.pixi.project] 62 | channels = ["conda-forge"] 63 | platforms = ["win-64", "linux-64", "osx-64", "osx-arm64"] 64 | 65 | [tool.pixi.pypi-dependencies] 66 | numba_celltree = { path = ".", editable = true } 67 | 68 | [tool.pixi.dependencies] 69 | python-build = "*" 70 | matplotlib = "*" 71 | numba = "*" 72 | numpy = "*" 73 | pip = "*" 74 | pre-commit = "*" 75 | pydata-sphinx-theme = "*" 76 | pytest = "*" 77 | pytest-cov = "*" 78 | python = ">=3.9" 79 | ruff = "*" 80 | sphinx = "*" 81 | sphinx-gallery = "*" 82 | twine = "*" 83 | pytest-cases = ">=3.8.6,<4" 84 | 85 | [tool.pixi.tasks] 86 | pre-commit = "pre-commit run --all-files" 87 | test = "NUMBA_DISABLE_JIT=1 pytest --cov=numba_celltree --cov-report xml --cov-report term" 88 | test-single-thread = "NUMBA_DISABLE_JIT=1 NUMBA_NUM_THREADS=1 pytest" 89 | test-jit = "NUMBA_DISABLE_JIT=0 pytest" 90 | docs = "NUMBA_DISABLE_JIT=1 sphinx-build docs docs/_build" 91 | all = { depends-on = ["pre-commit", "test", "docs"]} 92 | pypi-publish = "rm --recursive --force dist && python -m build && twine check dist/* && twine upload dist/*" 93 | 94 | [tool.pixi.feature.py313.dependencies] 95 | python = "3.13.*" 96 | 97 | [tool.pixi.feature.py312.dependencies] 98 | python = "3.12.*" 99 | 100 | [tool.pixi.feature.py311.dependencies] 101 | python = "3.11.*" 102 | 103 | [tool.pixi.feature.py310.dependencies] 104 | python = "3.10.*" 105 | 106 | [tool.pixi.feature.py309.dependencies] 107 | python = "3.9.*" 108 | 109 | [tool.pixi.environments] 110 | default = { features = ["py312"], solve-group = "py312" } 111 | py312 = { features = ["py312"], solve-group = "py312" } 112 | py313 = ["py313"] 113 | py311 = ["py311"] 114 | py310 = ["py310"] 115 | py309 = ["py309"] 116 | 117 | [tool.ruff.lint] 118 | # See https://docs.astral.sh/ruff/rules/ 119 | select = ["C4", "D2", "D3", "D4", "E", "F", "I", "NPY", "PD"] 120 | ignore = [ 121 | "D202", 122 | "D205", 123 | "D206", 124 | "D400", 125 | "D404", 126 | "E402", 127 | "E501", 128 | "E703", 129 | "PD002", 130 | "PD901", 131 | "PD003", 132 | "PD004", 133 | ] 134 | fixable = ["I"] 135 | ignore-init-module-imports = true 136 | 137 | [tool.ruff.lint.pydocstyle] 138 | convention = "numpy" 139 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import numba as nb 2 | import numpy as np 3 | 4 | from numba_celltree import utils as ut 5 | from numba_celltree.constants import INITIAL_STACK_LENGTH, MAX_N_VERTEX 6 | 7 | 8 | def test_grow(): 9 | a = np.arange(10) 10 | b = ut.grow(a) 11 | assert b.shape == (20,) 12 | assert np.array_equal(a, b[:10]) 13 | 14 | a = np.arange(20).reshape((10, 2)) 15 | b = ut.grow(a) 16 | assert b.shape == (20, 2) 17 | assert np.array_equal(a, b[:10]) 18 | 19 | 20 | def test_pop(): 21 | size = 3 22 | stack = np.arange(10, 10 + size) 23 | v, size = ut.pop(stack, size) 24 | assert v == 12 25 | assert size == 2 26 | v, size = ut.pop(stack, size) 27 | assert v == 11 28 | assert size == 1 29 | v, size = ut.pop(stack, size) 30 | assert v == 10 31 | assert size == 0 32 | 33 | 34 | def test_push(): 35 | size = 0 36 | stack = np.empty(3) 37 | stack, size = ut.push(stack, 0, size) 38 | stack, size = ut.push(stack, 1, size) 39 | stack, size = ut.push(stack, 2, size) 40 | assert size == 3 41 | assert np.array_equal(stack, [0, 1, 2]) 42 | 43 | 44 | def test_push_size_increases(): 45 | size = 0 46 | stack = np.empty(1) 47 | size_before = stack.size 48 | stack, size = ut.push(stack, 0, size) 49 | stack, size = ut.push(stack, 1, size) 50 | assert stack.size == 2 51 | assert size_before < stack.size 52 | stack, size = ut.push(stack, 10, size) 53 | assert stack.size == 4 54 | assert np.array_equal(stack[:3], [0, 1, 10]) 55 | 56 | 57 | def test_copy(): 58 | dst = np.zeros(5) 59 | src = np.arange(5) 60 | ut.copy(src, dst, 3) 61 | assert np.array_equal(dst, [0, 1, 2, 0, 0]) 62 | 63 | 64 | def test_double_stack(): 65 | stack = ut.allocate_double_stack() 66 | assert stack.shape == (INITIAL_STACK_LENGTH, 2) 67 | 68 | size = 1 69 | stack, size = ut.push_both(stack, 1, 2, size) 70 | assert size == 2 71 | assert len(stack) == INITIAL_STACK_LENGTH 72 | assert np.array_equal(stack[1], (1, 2)) 73 | 74 | size = INITIAL_STACK_LENGTH 75 | stack, size = ut.push_both(stack, 1, 2, size) 76 | assert len(stack) == INITIAL_STACK_LENGTH * 2 77 | assert size == INITIAL_STACK_LENGTH + 1 78 | assert np.array_equal(stack[INITIAL_STACK_LENGTH], (1, 2)) 79 | 80 | a, b, size = ut.pop_both(stack, size) 81 | assert size == INITIAL_STACK_LENGTH 82 | assert len(stack) == INITIAL_STACK_LENGTH * 2 83 | assert a == 1 84 | assert b == 2 85 | 86 | 87 | def test_triple_stack(): 88 | stack = ut.allocate_triple_stack() 89 | assert stack.shape == (INITIAL_STACK_LENGTH, 3) 90 | 91 | size = 1 92 | stack, size = ut.push_triple(stack, 1, 2, 3, size) 93 | assert size == 2 94 | assert len(stack) == INITIAL_STACK_LENGTH 95 | assert np.array_equal(stack[1], (1, 2, 3)) 96 | 97 | size = INITIAL_STACK_LENGTH 98 | stack, size = ut.push_triple(stack, 1, 2, 3, size) 99 | assert len(stack) == INITIAL_STACK_LENGTH * 2 100 | assert size == INITIAL_STACK_LENGTH + 1 101 | assert np.array_equal(stack[INITIAL_STACK_LENGTH], (1, 2, 3)) 102 | 103 | a, b, c, size = ut.pop_triple(stack, size) 104 | assert size == INITIAL_STACK_LENGTH 105 | assert len(stack) == INITIAL_STACK_LENGTH * 2 106 | assert a == 1 107 | assert b == 2 108 | assert c == 3 109 | 110 | 111 | # These array is not returned properly to dynamic python. This is OK: these 112 | # arrays are exclusively for internal use to temporarily store values. 113 | @nb.njit 114 | def do_allocate_stack(): 115 | stack = ut.allocate_stack() 116 | return (stack.size == INITIAL_STACK_LENGTH) and (stack[:5].size == 5) 117 | 118 | 119 | def test_allocate_stack(): 120 | assert do_allocate_stack() 121 | 122 | 123 | @nb.njit 124 | def do_allocate_polygon(): 125 | poly = ut.allocate_polygon() 126 | return poly.shape == (MAX_N_VERTEX, 2) and (poly[:5].size == 10) 127 | 128 | 129 | def test_allocate_polygon(): 130 | assert do_allocate_polygon() 131 | 132 | 133 | @nb.njit 134 | def do_allocate_clipper(): 135 | clipper = ut.allocate_clip_polygon() 136 | return clipper.shape == (MAX_N_VERTEX * 2, 2) and (clipper[:5].size == 10) 137 | 138 | 139 | def test_allocate_clipper(): 140 | assert do_allocate_clipper() 141 | -------------------------------------------------------------------------------- /tests/data/voronoi_xy.txt: -------------------------------------------------------------------------------- 1 | -8.166667e-02 8.770000e-01 2 | -6.966667e-02 8.800000e-01 3 | -7.566667e-02 8.823333e-01 4 | -6.266667e-02 8.816667e-01 5 | -4.966667e-02 8.833333e-01 6 | 1.066667e-02 8.880000e-01 7 | -6.000000e-02 8.863333e-01 8 | -4.566667e-02 8.886667e-01 9 | -8.933333e-02 8.756667e-01 10 | -3.733333e-02 8.863333e-01 11 | 1.600000e-02 8.936667e-01 12 | 2.100000e-02 8.930000e-01 13 | -6.000000e-03 8.906667e-01 14 | 2.000000e-03 8.943333e-01 15 | -3.466667e-02 8.956667e-01 16 | -6.633333e-02 9.006667e-01 17 | -2.366667e-02 8.913333e-01 18 | -1.633333e-02 8.986667e-01 19 | -6.100000e-02 9.060000e-01 20 | -8.333333e-02 9.006667e-01 21 | -5.300000e-02 9.026667e-01 22 | -7.733333e-02 9.036667e-01 23 | 7.333330e-03 9.000000e-01 24 | 5.000000e-03 9.086667e-01 25 | -6.500000e-02 9.146667e-01 26 | 2.800000e-02 9.133333e-01 27 | 2.266667e-02 9.093333e-01 28 | -4.400000e-02 9.066667e-01 29 | 1.466667e-02 9.146667e-01 30 | -7.400000e-02 9.226667e-01 31 | -2.066667e-02 9.150000e-01 32 | -4.633333e-02 9.176667e-01 33 | -5.933333e-02 9.223333e-01 34 | 4.666670e-03 9.273333e-01 35 | -3.566667e-02 9.233333e-01 36 | -9.000000e-03 9.196667e-01 37 | -6.466667e-02 9.263333e-01 38 | -5.666670e-03 9.296667e-01 39 | -4.166667e-02 9.350000e-01 40 | -9.333330e-03 9.383333e-01 41 | -1.700000e-02 9.446667e-01 42 | -5.466667e-02 9.496667e-01 43 | -3.100000e-02 9.430000e-01 44 | -8.100000e-02 9.550000e-01 45 | -7.866667e-02 9.596667e-01 46 | -4.066667e-02 9.513333e-01 47 | -3.333333e-02 9.600000e-01 48 | -4.000000e-02 9.663333e-01 49 | -4.866667e-02 9.686667e-01 50 | -9.366667e-02 9.683333e-01 51 | -8.966667e-02 9.710000e-01 52 | -5.833333e-02 9.623333e-01 53 | -6.700000e-02 9.650000e-01 54 | -9.133333e-02 9.773333e-01 55 | -1.043333e-01 9.823333e-01 56 | -8.066667e-02 9.763333e-01 57 | -8.466667e-02 9.823333e-01 58 | -6.966667e-02 9.770000e-01 59 | -9.900000e-02 9.836667e-01 60 | -5.200000e-02 9.826667e-01 61 | -6.266667e-02 9.860000e-01 62 | -9.933333e-02 9.880000e-01 63 | -9.766667e-02 9.910000e-01 64 | -4.800000e-02 9.866667e-01 65 | -4.900000e-02 9.950000e-01 66 | -6.666667e-02 9.960000e-01 67 | -8.933333e-02 9.900000e-01 68 | -3.966667e-02 9.960000e-01 69 | -1.053333e-01 9.966667e-01 70 | -5.700000e-02 1.001000e+00 71 | -3.666667e-02 1.002000e+00 72 | -8.866667e-02 9.986667e-01 73 | -9.666667e-02 1.004333e+00 74 | -7.000000e-02 1.001667e+00 75 | -7.866667e-02 1.003333e+00 76 | -8.200000e-02 1.013667e+00 77 | -6.333333e-02 1.016000e+00 78 | -7.500000e-02 1.017333e+00 79 | -5.766667e-02 1.020000e+00 80 | -9.033808e-02 8.775848e-01 81 | -8.933333e-02 8.720000e-01 82 | -7.665982e-02 8.845183e-01 83 | -6.037555e-02 8.891499e-01 84 | -4.945385e-02 8.935359e-01 85 | -5.186486e-02 8.958559e-01 86 | -6.711625e-02 8.981613e-01 87 | -8.303333e-02 8.985667e-01 88 | -8.733333e-02 8.986667e-01 89 | -7.763333e-02 9.057667e-01 90 | -6.900000e-02 9.146667e-01 91 | -7.354667e-02 9.201733e-01 92 | -7.596154e-02 9.241923e-01 93 | -6.489888e-02 9.287715e-01 94 | -4.946667e-02 9.376000e-01 95 | -5.640000e-02 9.462000e-01 96 | -5.843218e-02 9.530138e-01 97 | -5.696680e-02 9.572088e-01 98 | -8.074390e-02 9.526951e-01 99 | -8.700000e-02 9.550000e-01 100 | -8.250000e-02 9.635000e-01 101 | -8.511261e-02 9.731577e-01 102 | -8.574510e-02 9.700196e-01 103 | -9.366667e-02 9.650000e-01 104 | -9.700000e-02 9.683333e-01 105 | -9.467296e-02 9.791887e-01 106 | -9.796907e-02 9.813471e-01 107 | -1.043333e-01 9.800000e-01 108 | -1.060923e-01 9.833385e-01 109 | -1.032333e-01 9.893000e-01 110 | -1.060544e-01 9.954950e-01 111 | -1.049597e-01 9.978624e-01 112 | -1.003920e-01 1.003656e+00 113 | -9.764359e-02 1.007915e+00 114 | -8.851538e-02 1.015444e+00 115 | -7.500000e-02 1.021000e+00 116 | -5.776437e-02 1.021661e+00 117 | -5.200000e-02 1.020000e+00 118 | -6.204241e-02 1.012865e+00 119 | -6.533333e-02 1.006333e+00 120 | -5.700000e-02 1.005000e+00 121 | -3.666667e-02 1.005000e+00 122 | -3.100000e-02 1.002000e+00 123 | -3.533333e-02 9.916667e-01 124 | -4.286036e-02 9.829955e-01 125 | -4.837387e-02 9.775901e-01 126 | -4.772973e-02 9.742883e-01 127 | -3.788839e-02 9.722458e-01 128 | -2.424138e-02 9.638965e-01 129 | -1.386667e-02 9.509333e-01 130 | -3.040000e-03 9.430533e-01 131 | 5.466670e-03 9.276000e-01 132 | 6.205130e-03 9.270256e-01 133 | 1.379977e-02 9.220353e-01 134 | 2.983333e-02 9.151667e-01 135 | 3.234359e-02 9.108513e-01 136 | 2.514201e-02 9.033925e-01 137 | 2.297315e-02 8.958188e-01 138 | 2.360177e-02 8.900265e-01 139 | 1.121622e-02 8.847027e-01 140 | -6.545100e-03 8.835804e-01 141 | -2.319655e-02 8.833414e-01 142 | -3.472165e-02 8.804570e-01 143 | -4.998995e-02 8.802621e-01 144 | -5.950000e-02 8.785000e-01 145 | -6.966667e-02 8.760000e-01 146 | -8.069072e-02 8.748041e-01 147 | -------------------------------------------------------------------------------- /numba_celltree/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Types and constants. 3 | 4 | Note: it is not advisable to change either the IntDType or the FloatDType. 5 | 6 | In case of the floats, 64-bit has much higher precision, which may be quite 7 | relevant for geometry: more precision for edge cases, etc. 8 | 9 | In case of the integers, Python has surprisingly (ambiguous) behavior. Since 10 | numba requires static types, they've chosen to default to ``np.intp``: 11 | 12 | See: 13 | 14 | https://numba.pydata.org/numba-doc/latest/proposals/integer-typing.html 15 | 16 | This means that setting IntDType to np.int32 on a 64-bit system will break 17 | compilation: within this code, the size of an array (via ``.size``) will have 18 | an integer type of ``np.intp``. If IntDType == np.int32, the BucketDType array 19 | will expect a 32-bit integer for its index and size fields, yet receive a 20 | 64-bit integer (intp), and error during type inferencing. 21 | """ 22 | 23 | from typing import NamedTuple 24 | 25 | import numpy as np 26 | 27 | IntDType = np.intp 28 | FloatDType = np.float64 29 | BoolArray = np.ndarray 30 | IntArray = np.ndarray 31 | FloatArray = np.ndarray 32 | BucketArray = np.ndarray 33 | NodeArray = np.ndarray 34 | 35 | 36 | class Point(NamedTuple): 37 | x: float 38 | y: float 39 | 40 | 41 | class Vector(NamedTuple): 42 | x: float 43 | y: float 44 | 45 | 46 | class Interval(NamedTuple): 47 | xmin: float 48 | xmax: float 49 | 50 | 51 | class Box(NamedTuple): 52 | xmin: float 53 | xmax: float 54 | ymin: float 55 | ymax: float 56 | 57 | 58 | class Triangle(NamedTuple): 59 | a: Point 60 | b: Point 61 | c: Point 62 | 63 | 64 | class Node(NamedTuple): 65 | child: IntDType 66 | Lmax: FloatDType 67 | Rmin: FloatDType 68 | ptr: IntDType 69 | size: IntDType 70 | dim: bool 71 | 72 | 73 | class Bucket(NamedTuple): 74 | Max: FloatDType 75 | Min: FloatDType 76 | Rmin: FloatDType 77 | Lmax: FloatDType 78 | index: IntDType 79 | size: IntDType 80 | 81 | 82 | class CellTreeData(NamedTuple): 83 | elements: IntArray 84 | vertices: FloatArray 85 | nodes: NodeArray 86 | bb_indices: IntArray 87 | bb_coords: FloatArray 88 | bbox: FloatArray 89 | cells_per_leaf: int 90 | 91 | 92 | NodeDType = np.dtype( 93 | [ 94 | # Index of left child. Right child is child + 1. 95 | ("child", IntDType), 96 | # Range of the bounding boxes inside of the node. 97 | ("Lmax", FloatDType), 98 | ("Rmin", FloatDType), 99 | # Index into the bounding box index array, bb_indices. 100 | ("ptr", IntDType), 101 | # Number of bounding boxes in this node. 102 | ("size", IntDType), 103 | # False = 0 = x, True = 1 = y. 104 | ("dim", bool), 105 | ] 106 | ) 107 | 108 | 109 | BucketDType = np.dtype( 110 | [ 111 | # Range of the bucket. 112 | ("Max", FloatDType), 113 | ("Min", FloatDType), 114 | # Range of the bounding boxes inside the bucket. 115 | ("Rmin", FloatDType), 116 | ("Lmax", FloatDType), 117 | # Index into the bounding box index array, bb_indices. 118 | ("index", IntDType), 119 | # Number of bounding boxes in this bucket. 120 | ("size", IntDType), 121 | ] 122 | ) 123 | 124 | # Numba can parallellize for loops with a single keyword. 125 | PARALLEL = True 126 | # By default, Numba will allocate all arrays on the heap. For small (statically 127 | # sized) arrays, this creates a large overhead. This enables stack allocated 128 | # arrays rather than "regular" heap allocated numpy arrays. See allocate 129 | # functions in utils.py. 130 | STACK_ALLOCATE_STATIC_ARRAYS = True 131 | # 2D is still rather hard-baked in, so changing this alone to 3 will NOT 132 | # suffice to generalize it to a 3D CellTree. 133 | NDIM = 2 134 | MAX_N_VERTEX = 32 135 | FILL_VALUE = -1 136 | # Recursion in numba is somewhat slow (in case of querying), or unsupported for 137 | # AOT-compilation when creating. We can avoid recursing by manually maintaining 138 | # a stack, and pushing and popping. To estimate worst case, let's assume every 139 | # leaf (node) of the tree contains only a single cell. Then the number of cells 140 | # that can be included is given by 2 ** (depth of stack - 1). 141 | # (int(math.ceil(math.log(MAX_N_FACE, 2))) + 1) 142 | # This is only true for relatively balanced trees. MAX_N_FACE = int(2e9) 143 | # results in required stack of 32. 144 | INITIAL_STACK_LENGTH = 32 145 | # Floating point slack 146 | MIN_TOLERANCE = 1e-15 147 | TOLERANCE_FACTOR = 1e-12 148 | 149 | FLOAT_MIN = np.finfo(FloatDType).min 150 | FLOAT_MAX = np.finfo(FloatDType).max 151 | INT_MAX = np.iinfo(IntDType).max 152 | -------------------------------------------------------------------------------- /numba_celltree/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import numba as nb 4 | import numpy as np 5 | from numba import types 6 | from numba.core import cgutils 7 | from numba.extending import intrinsic 8 | 9 | from numba_celltree.constants import ( 10 | INITIAL_STACK_LENGTH, 11 | MAX_N_VERTEX, 12 | NDIM, 13 | STACK_ALLOCATE_STATIC_ARRAYS, 14 | FloatDType, 15 | IntDType, 16 | ) 17 | 18 | 19 | @nb.njit(inline="always") 20 | def allocate_stack(): 21 | return np.empty(INITIAL_STACK_LENGTH, dtype=IntDType) 22 | 23 | 24 | @nb.njit(inline="always") 25 | def allocate_double_stack(): 26 | return np.empty((INITIAL_STACK_LENGTH, 2), dtype=IntDType) 27 | 28 | 29 | @nb.njit(inline="always") 30 | def allocate_triple_stack(): 31 | return np.empty((INITIAL_STACK_LENGTH, 3), dtype=IntDType) 32 | 33 | 34 | @nb.njit(inline="always") 35 | def pop(array, size): 36 | return array[size - 1], size - 1 37 | 38 | 39 | @nb.njit(inline="always") 40 | def push(array, value, size): 41 | if size >= len(array): 42 | array = grow(array) 43 | array[size] = value 44 | return array, size + 1 45 | 46 | 47 | @nb.njit(inline="always") 48 | def copy(src, dst, n) -> None: 49 | for i in range(n): 50 | dst[i] = src[i] 51 | return 52 | 53 | 54 | @nb.njit(inline="always") 55 | def push_both(stack, a, b, size): 56 | if size >= len(stack): 57 | stack = grow(stack) 58 | stack[size, 0] = a 59 | stack[size, 1] = b 60 | return stack, size + 1 61 | 62 | 63 | @nb.njit(inline="always") 64 | def pop_both(stack, size): 65 | index = size - 1 66 | a = stack[index, 0] 67 | b = stack[index, 1] 68 | return a, b, index 69 | 70 | 71 | @nb.njit(inline="always") 72 | def push_triple(stack, a, b, c, size): 73 | if size >= len(stack): 74 | stack = grow(stack) 75 | stack[size, 0] = a 76 | stack[size, 1] = b 77 | stack[size, 2] = c 78 | return stack, size + 1 79 | 80 | 81 | @nb.njit(inline="always") 82 | def pop_triple(stack, size): 83 | index = size - 1 84 | a = stack[index, 0] 85 | b = stack[index, 1] 86 | c = stack[index, 2] 87 | return a, b, c, index 88 | 89 | 90 | @nb.njit(inline="always") 91 | def grow(array: np.ndarray) -> np.ndarray: 92 | """Double storage capacity.""" 93 | n = len(array) 94 | new_shape = (2 * n,) + array.shape[1:] 95 | new = np.empty(new_shape, dtype=array.dtype) 96 | new[:n] = array[:] 97 | return new 98 | 99 | 100 | # Ensure these are constants for numba 101 | POLYGON_SIZE = MAX_N_VERTEX * NDIM 102 | CLIP_MAX_N_VERTEX = MAX_N_VERTEX * 2 103 | CLIP_POLYGON_SIZE = 2 * POLYGON_SIZE 104 | 105 | 106 | # Make sure everything still works when calling as non-compiled Python code. 107 | # Note: these stack allocated arrays should only be used inside of numba 108 | # compiled code. They should interact NEVER with dynamic Python code: there are 109 | # no guarantees in that case, they may very well be filled with garbage. 110 | if STACK_ALLOCATE_STATIC_ARRAYS and os.environ.get("NUMBA_DISABLE_JIT", "0") == "0": 111 | 112 | @intrinsic # pragma: no cover 113 | def stack_empty(typingctx, size, dtype): 114 | def impl(context, builder, signature, args): 115 | ty = context.get_value_type(dtype.dtype) 116 | ptr = cgutils.alloca_once(builder, ty, size=args[0]) 117 | return ptr 118 | 119 | sig = types.CPointer(dtype.dtype)(types.int64, dtype) 120 | return sig, impl 121 | 122 | @nb.njit(inline="always") # pragma: no cover 123 | def allocate_polygon(): 124 | arr_ptr = stack_empty( # pylint: disable=no-value-for-parameter 125 | POLYGON_SIZE, FloatDType 126 | ) 127 | arr = nb.carray(arr_ptr, (MAX_N_VERTEX, NDIM), dtype=FloatDType) 128 | return arr 129 | 130 | @nb.njit(inline="always") # pragma: no cover 131 | def allocate_clip_polygon(): 132 | arr_ptr = stack_empty( # pylint: disable=no-value-for-parameter 133 | CLIP_POLYGON_SIZE, FloatDType 134 | ) 135 | arr = nb.carray(arr_ptr, (CLIP_MAX_N_VERTEX, NDIM), dtype=FloatDType) 136 | return arr 137 | 138 | @nb.njit(inline="always") # pragma: no cover 139 | def allocate_box_polygon(): 140 | arr_ptr = stack_empty(8, FloatDType) # pylint: disable=no-value-for-parameter 141 | arr = nb.carray(arr_ptr, (4, 2), dtype=FloatDType) 142 | return arr 143 | 144 | else: 145 | 146 | @nb.njit(inline="always") 147 | def allocate_polygon(): 148 | return np.empty((MAX_N_VERTEX, NDIM), dtype=FloatDType) 149 | 150 | @nb.njit(inline="always") 151 | def allocate_clip_polygon(): 152 | return np.empty((CLIP_MAX_N_VERTEX, NDIM), dtype=FloatDType) 153 | 154 | @nb.njit(inline="always") 155 | def allocate_box_polygon(): 156 | return np.empty((4, 2), dtype=FloatDType) 157 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/stable/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import numba_celltree 16 | 17 | # -- Project information ----------------------------------------------------- 18 | 19 | project = "Numba Celltree" 20 | copyright = "Deltares" 21 | author = "Huite Bootsma" 22 | 23 | # The short X.Y version. 24 | version = numba_celltree.__version__.split("+")[0] 25 | # The full version, including alpha/beta/rc tags. 26 | release = numba_celltree.__version__ 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | # 32 | # needs_sphinx = '1.0' 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = [ 38 | "sphinx.ext.autodoc", 39 | "sphinx.ext.viewcode", 40 | "sphinx.ext.todo", 41 | "sphinx.ext.napoleon", 42 | "sphinx_gallery.gen_gallery", 43 | ] 44 | 45 | sphinx_gallery_conf = { 46 | "examples_dirs": "../examples", # path to your example scripts 47 | "gallery_dirs": "examples", # path to generated output 48 | "filename_pattern": ".py", 49 | "abort_on_example_error": True, 50 | } 51 | 52 | # The suffix(es) of source filenames. 53 | # You can specify multiple suffix as a list of string: 54 | # 55 | # source_suffix = ['.rst', '.md'] 56 | source_suffix = ".rst" 57 | 58 | # The master toctree document. 59 | master_doc = "index" 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | # 64 | # This is also used if you do content translation via gettext catalogs. 65 | # Usually you set "language" from the command line for these cases. 66 | language = "en" 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | # This pattern also affects html_static_path and html_extra_path . 71 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 72 | 73 | # The name of the Pygments (syntax highlighting) style to use. 74 | pygments_style = "sphinx" 75 | 76 | 77 | # -- Options for HTML output ------------------------------------------------- 78 | 79 | # The theme to use for HTML and HTML Help pages. See the documentation for 80 | # a list of builtin themes. 81 | # 82 | html_theme = "pydata_sphinx_theme" 83 | # Theme options are theme-specific and customize the look and feel of a theme 84 | # further. For a list of options available for each theme, see the 85 | # documentation. 86 | # 87 | # html_theme_options = {} 88 | 89 | # Add any paths that contain custom static files (such as style sheets) here, 90 | # relative to this directory. They are copied after the builtin static files, 91 | # so a file named "default.css" will overwrite the builtin "default.css". 92 | html_static_path = ["_static"] 93 | html_css_files = ["theme-deltares.css"] 94 | html_theme_options = { 95 | "show_nav_level": 1, 96 | "navbar_align": "content", 97 | "use_edit_page_button": True, 98 | "icon_links": [ 99 | { 100 | "name": "GitHub", 101 | "url": "https://github.com/Deltares/numba_celltree", # required 102 | "icon": "https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg", 103 | "type": "url", 104 | }, 105 | { 106 | "name": "Deltares", 107 | "url": "https://www.deltares.nl/en/", 108 | "icon": "_static/deltares-blue.svg", 109 | "type": "local", 110 | }, 111 | ], 112 | "logo": { 113 | "image_light": "celltree-logo.svg", 114 | "image_dark": "celltree-logo.svg", 115 | "text": "Numba Celltree", 116 | }, 117 | "navbar_end": ["theme-switcher", "navbar-icon-links"], 118 | } 119 | 120 | html_context = { 121 | "github_url": "https://github.com", # or your GitHub Enterprise interprise 122 | "github_user": "Deltares", 123 | "github_repo": "numba_celltree", 124 | "github_version": "main", 125 | "doc_path": "docs", 126 | "default_mode": "light", 127 | } 128 | 129 | # Custom sidebar templates, must be a dictionary that maps document names 130 | # to template names. 131 | # 132 | # The default sidebars (for documents that don't match any pattern) are 133 | # defined by theme itself. Builtin themes are using these templates by 134 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 135 | # 'searchbox.html']``. 136 | # 137 | # html_sidebars = {} 138 | 139 | 140 | # -- Options for HTMLHelp output --------------------------------------------- 141 | 142 | # Output file base name for HTML help builder. 143 | htmlhelp_basename = "numba_celltree_doc" 144 | -------------------------------------------------------------------------------- /tests/test_algorithms/test_barycentric.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from numba_celltree.algorithms import barycentric_triangle as bt 5 | from numba_celltree.algorithms import barycentric_wachspress as bwp 6 | from numba_celltree.constants import Point, Triangle, Vector 7 | 8 | TOLERANCE_ON_EDGE = 1e-9 9 | 10 | 11 | def test_interp_edge_case(): 12 | def compute(a, U, p): 13 | weights = np.zeros(4) 14 | bwp.interp_edge_case(a, U, p, weights, 0, 1) 15 | return weights 16 | 17 | a = Point(0.0, 0.0) 18 | U = Vector(1.0, 0.0) 19 | assert np.allclose(compute(a, U, Point(0.0, 0.0)), [1.0, 0.0, 0.0, 0.0]) 20 | assert np.allclose(compute(a, U, Point(1.0, 0.0)), [0.0, 1.0, 0.0, 0.0]) 21 | assert np.allclose(compute(a, U, Point(0.25, 0.0)), [0.75, 0.25, 0.0, 0.0]) 22 | assert np.allclose(compute(a, U, Point(0.75, 0.0)), [0.25, 0.75, 0.0, 0.0]) 23 | 24 | 25 | def test_compute_weights_triangle(): 26 | def compute(triangle, point): 27 | weights = np.zeros(3) 28 | bt.compute_weights(triangle, point, weights) 29 | return weights 30 | 31 | triangle = Triangle( 32 | Point(0.0, 0.0), 33 | Point(1.0, 0.0), 34 | Point(1.0, 1.0), 35 | ) 36 | 37 | # Test for the vertices 38 | assert np.allclose(compute(triangle, Point(0.0, 0.0)), [1.0, 0.0, 0.0]) 39 | assert np.allclose(compute(triangle, Point(1.0, 0.0)), [0.0, 1.0, 0.0]) 40 | assert np.allclose(compute(triangle, Point(1.0, 1.0)), [0.0, 0.0, 1.0]) 41 | 42 | # Test halfway edges 43 | assert np.allclose(compute(triangle, Point(0.5, 0.0)), [0.5, 0.5, 0.0]) 44 | assert np.allclose(compute(triangle, Point(1.0, 0.5)), [0.0, 0.5, 0.5]) 45 | assert np.allclose(compute(triangle, Point(0.5, 0.5)), [0.5, 0.0, 0.5]) 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "barycentric_weights", 50 | [bt.barycentric_triangle_weights, bwp.barycentric_wachspress_weights], 51 | ) 52 | def test_barycentric_triangle_weights(barycentric_weights): 53 | points = np.array( 54 | [ 55 | [0.0, 0.0], 56 | [1.0, 0.0], 57 | [1.0, 1.0], 58 | [0.5, 0.0], 59 | [1.0, 0.5], 60 | [0.5, 0.5], 61 | [2.0, 2.0], 62 | ] 63 | ) 64 | vertices = np.array( 65 | [ 66 | [0.0, 0.0], 67 | [1.0, 0.0], 68 | [1.0, 1.0], 69 | ] 70 | ) 71 | faces = np.array( 72 | [ 73 | [0, 1, 2], 74 | ] 75 | ) 76 | face_indices = np.array([0, 0, 0, 0, 0, 0, -1]) 77 | 78 | expected = np.array( 79 | [ 80 | [1.0, 0.0, 0.0], 81 | [0.0, 1.0, 0.0], 82 | [0.0, 0.0, 1.0], 83 | [0.5, 0.5, 0.0], 84 | [0.0, 0.5, 0.5], 85 | [0.5, 0.0, 0.5], 86 | [0.0, 0.0, 0.0], 87 | ] 88 | ) 89 | actual = barycentric_weights( 90 | points, face_indices, faces, vertices, TOLERANCE_ON_EDGE 91 | ) 92 | assert np.allclose(actual, expected) 93 | 94 | 95 | def test_compute_weights_wachspress(): 96 | def compute(polygon, point): 97 | weights = np.zeros(4) 98 | bwp.compute_weights(polygon, point, weights, TOLERANCE_ON_EDGE) 99 | return weights 100 | 101 | polygon = np.array( 102 | [ 103 | [0.0, 0.0], 104 | [1.0, 0.0], 105 | [1.0, 1.0], 106 | [0.0, 1.0], 107 | ] 108 | ) 109 | assert np.allclose(compute(polygon, Point(0.0, 0.0)), [1.0, 0.0, 0.0, 0.0]) 110 | assert np.allclose(compute(polygon, Point(1.0, 0.0)), [0.0, 1.0, 0.0, 0.0]) 111 | assert np.allclose(compute(polygon, Point(1.0, 1.0)), [0.0, 0.0, 1.0, 0.0]) 112 | assert np.allclose(compute(polygon, Point(0.0, 1.0)), [0.0, 0.0, 0.0, 1.0]) 113 | assert np.allclose(compute(polygon, Point(0.25, 0.0)), [0.75, 0.25, 0.0, 0.0]) 114 | assert np.allclose(compute(polygon, Point(0.25, 1.0)), [0.0, 0.0, 0.25, 0.75]) 115 | assert np.allclose(compute(polygon, Point(0.5, 0.5)), [0.25, 0.25, 0.25, 0.25]) 116 | 117 | 118 | def test_barycentric_wachspress_weights(): 119 | vertices = np.array( 120 | [ 121 | [0.0, 0.0], 122 | [1.0, 0.0], 123 | [1.0, 1.0], 124 | [0.0, 1.0], 125 | ] 126 | ) 127 | faces = np.array( 128 | [ 129 | [0, 1, 2, 3], 130 | ] 131 | ) 132 | face_indices = np.array([0, 0, 0, 0, 0, 0, 0, -1]) 133 | points = np.array( 134 | [ 135 | [0.0, 0.0], 136 | [1.0, 0.0], 137 | [1.0, 1.0], 138 | [0.0, 1.0], 139 | [0.25, 0.0], 140 | [0.25, 1.0], 141 | [0.5, 0.5], 142 | [2.0, 2.0], 143 | ] 144 | ) 145 | expected = np.array( 146 | [ 147 | [1.0, 0.0, 0.0, 0.0], 148 | [0.0, 1.0, 0.0, 0.0], 149 | [0.0, 0.0, 1.0, 0.0], 150 | [0.0, 0.0, 0.0, 1.0], 151 | [0.75, 0.25, 0.0, 0.0], 152 | [0.0, 0.0, 0.25, 0.75], 153 | [0.25, 0.25, 0.25, 0.25], 154 | [0.0, 0.0, 0.0, 0.0], 155 | ] 156 | ) 157 | actual = bwp.barycentric_wachspress_weights( 158 | points, face_indices, faces, vertices, TOLERANCE_ON_EDGE 159 | ) 160 | assert np.allclose(actual, expected) 161 | -------------------------------------------------------------------------------- /tests/test_algorithms/test_separating_axis.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from numba_celltree.algorithms.separating_axis import ( 4 | polygons_intersect, 5 | separating_axes, 6 | ) 7 | 8 | 9 | def test_triangles_intersect(): 10 | # Identity 11 | a = np.array( 12 | [ 13 | [0.0, 0.0], 14 | [0.0, 1.0], 15 | [1.0, 1.0], 16 | ] 17 | ) 18 | b = a 19 | assert separating_axes(a, b) 20 | assert separating_axes(b, a) 21 | 22 | # No overlap 23 | b = np.array( 24 | [ 25 | [2.0, 0.0], 26 | [3.0, 1.0], 27 | [2.0, 1.0], 28 | ] 29 | ) 30 | assert not separating_axes(a, b) 31 | assert not separating_axes(b, a) 32 | 33 | # Touching: does not qualify 34 | b = np.array( 35 | [ 36 | [1.0, 0.0], 37 | [2.0, 1.0], 38 | [1.0, 1.0], 39 | ] 40 | ) 41 | assert not separating_axes(a, b) 42 | assert not separating_axes(b, a) 43 | 44 | # One inside the other 45 | a = np.array( 46 | [ 47 | [0.0, 0.0], 48 | [4.0, 0.0], 49 | [0.0, 4.0], 50 | ] 51 | ) 52 | b = np.array( 53 | [ 54 | [1.0, 1.0], 55 | [2.0, 1.0], 56 | [1.0, 2.0], 57 | ] 58 | ) 59 | assert separating_axes(a, b) 60 | assert separating_axes(b, a) 61 | 62 | # Mirrored 63 | a = np.array( 64 | [ 65 | [0.0, 0.0], 66 | [1.0, 0.0], 67 | [0.0, 1.0], 68 | ] 69 | ) 70 | b = np.array( 71 | [ 72 | [1.0, 0.0], 73 | [2.0, 0.0], 74 | [2.0, 1.0], 75 | ] 76 | ) 77 | assert not separating_axes(a, b) 78 | assert not separating_axes(b, a) 79 | 80 | # This is a case which requires testing both (a, b) and (b, a) to determine intersection 81 | # two edges of a do separate b 82 | # no edges of b separate a 83 | # => no intersection 84 | a = np.array( 85 | [ 86 | [5.0, 3.0], 87 | [7.0, 3.0], 88 | [7.0, 5.0], 89 | ] 90 | ) 91 | b = np.array( 92 | [ 93 | [6.9, 5.6], 94 | [8.0, 4.75], 95 | [7.85, 5.9], 96 | ] 97 | ) 98 | assert separating_axes(a, b) 99 | assert not separating_axes(b, a) 100 | assert not (separating_axes(a, b) and separating_axes(b, a)) 101 | 102 | 103 | def test_box_triangle(): 104 | a = np.array( 105 | [ 106 | [0.0, 0.0], 107 | [2.0, 0.0], 108 | [2.0, 2.0], 109 | [0.0, 2.0], 110 | ] 111 | ) 112 | b = np.array( 113 | [ 114 | [1.0, 1.0], 115 | [3.0, 0.0], 116 | [3.0, 1.0], 117 | ] 118 | ) 119 | assert separating_axes(a, b) 120 | assert separating_axes(b, a) 121 | 122 | # Touching 123 | b = np.array( 124 | [ 125 | [2.0, 1.0], 126 | [3.0, 0.0], 127 | [3.0, 1.0], 128 | ] 129 | ) 130 | assert not separating_axes(a, b) 131 | assert not separating_axes(b, a) 132 | 133 | # Inside 134 | b = np.array( 135 | [ 136 | [0.25, 0.25], 137 | [0.75, 0.75], 138 | [0.6, 0.6], 139 | ] 140 | ) 141 | assert separating_axes(a, b) 142 | assert separating_axes(b, a) 143 | 144 | 145 | def test_polygons_intersect(): 146 | vertices_a = np.array( 147 | [ 148 | [0.0, 0.0], 149 | [2.0, 0.0], 150 | [2.0, 2.0], 151 | [0.0, 2.0], 152 | ] 153 | ) 154 | faces_a = np.array( 155 | [ 156 | [0, 1, 2, 3], 157 | ] 158 | ) 159 | vertices_b = np.array( 160 | [ 161 | [0.25, 0.25], 162 | [0.75, 0.75], 163 | [0.6, 0.6], 164 | [2.0, 1.0], 165 | [3.0, 0.0], 166 | [3.0, 1.0], 167 | ] 168 | ) 169 | faces_b = np.array( 170 | [ 171 | [0, 1, 2, -1], 172 | [3, 4, 5, -1], 173 | ] 174 | ) 175 | indices_a = np.array([0, 0]) 176 | indices_b = np.array([0, 1]) 177 | 178 | actual = polygons_intersect( 179 | vertices_a, vertices_b, faces_a, faces_b, indices_a, indices_b 180 | ) 181 | expected = np.array([True, False]) 182 | assert np.array_equal(actual, expected) 183 | 184 | 185 | def test_triangles_intersect_hanging_nodes(): 186 | # Identity 187 | a = np.array( 188 | [ 189 | [0.0, 0.0], 190 | [0.5, 0.5], 191 | [0.0, 1.0], 192 | [1.0, 1.0], 193 | ] 194 | ) 195 | b = a 196 | assert separating_axes(a, b) 197 | assert separating_axes(b, a) 198 | 199 | # No overlap 200 | b = np.array( 201 | [ 202 | [2.0, 0.0], 203 | [3.0, 1.0], 204 | [2.5, 1.0], 205 | [2.0, 1.0], 206 | ] 207 | ) 208 | assert not separating_axes(a, b) 209 | assert not separating_axes(b, a) 210 | 211 | # Repeat node 212 | a = np.array( 213 | [ 214 | [0.0, 0.0], 215 | [0.5, 0.5], 216 | [0.5, 0.5], 217 | [0.0, 1.0], 218 | [1.0, 1.0], 219 | ] 220 | ) 221 | b = a 222 | assert separating_axes(a, b) 223 | assert separating_axes(b, a) 224 | -------------------------------------------------------------------------------- /numba_celltree/demo.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import matplotlib.tri 4 | import numpy as np 5 | from matplotlib import patches 6 | from matplotlib.collections import LineCollection 7 | 8 | from numba_celltree.constants import IntArray, IntDType 9 | 10 | 11 | def close_polygons(face_node_connectivity: IntArray, fill_value: int) -> IntArray: 12 | # Wrap around and create closed polygon: put the first node at the end of the row 13 | # In case of fill values, replace all fill values 14 | n, m = face_node_connectivity.shape 15 | closed = np.full((n, m + 1), fill_value, dtype=IntDType) 16 | closed[:, :-1] = face_node_connectivity 17 | first_node = face_node_connectivity[:, 0] 18 | # Identify fill value, and replace by first node also 19 | isfill = closed == fill_value 20 | closed.ravel()[isfill.ravel()] = np.repeat(first_node, isfill.sum(axis=1)) 21 | return closed 22 | 23 | 24 | def edges( 25 | face_node_connectivity: IntArray, fill_value: int 26 | ) -> Tuple[IntArray, IntArray]: 27 | face_node_connectivity = np.atleast_2d(face_node_connectivity) 28 | n, m = face_node_connectivity.shape 29 | # Close the polygons: [0 1 2 3] -> [0 1 2 3 0] 30 | closed = close_polygons(face_node_connectivity, fill_value) 31 | # Allocate array for edge_node_connectivity: includes duplicate edges 32 | edge_node_connectivity = np.empty((n * m, 2), dtype=IntDType) 33 | edge_node_connectivity[:, 0] = closed[:, :-1].ravel() 34 | edge_node_connectivity[:, 1] = closed[:, 1:].ravel() 35 | # Cleanup: delete invalid edges (same node to same node) 36 | # this is a result of closing the polygons 37 | edge_node_connectivity = edge_node_connectivity[ 38 | edge_node_connectivity[:, 0] != edge_node_connectivity[:, 1] 39 | ] 40 | # Now find the unique rows == unique edges 41 | edge_node_connectivity.sort(axis=1) 42 | edge_node_connectivity = np.unique(edge_node_connectivity, axis=0) 43 | return edge_node_connectivity 44 | 45 | 46 | def plot_edges(node_x, node_y, edge_nodes, ax, *args, **kwargs): 47 | """ 48 | Plot the edges at a given axes. 49 | `args` and `kwargs` will be used as parameters of the `plot` method. 50 | 51 | Parameters 52 | ---------- 53 | node_x (ndarray): A 1D double array describing the x-coordinates of the nodes. 54 | node_y (ndarray): A 1D double array describing the y-coordinates of the nodes. 55 | edge_nodes (ndarray, optional): A 2D integer array describing the nodes composing each mesh 2d edge. 56 | ax (matplotlib.axes.Axes): The axes where to plot the edges 57 | """ 58 | n_edge = len(edge_nodes) 59 | edge_coords = np.empty((n_edge, 2, 2), dtype=np.float64) 60 | node_0 = edge_nodes[:, 0] 61 | node_1 = edge_nodes[:, 1] 62 | edge_coords[:, 0, 0] = node_x[node_0] 63 | edge_coords[:, 0, 1] = node_y[node_0] 64 | edge_coords[:, 1, 0] = node_x[node_1] 65 | edge_coords[:, 1, 1] = node_y[node_1] 66 | line_segments = LineCollection(edge_coords, *args, **kwargs) 67 | ax.add_collection(line_segments) 68 | ax.set_aspect(1.0) 69 | ax.autoscale(enable=True) 70 | return 71 | 72 | 73 | def plot_boxes(box_coords, ax, annotate=False, *args, **kwargs): 74 | box_coords = np.atleast_2d(box_coords) 75 | nbox, ncoord = box_coords.shape 76 | if ncoord != 4: 77 | raise ValueError(f"four values describe a box, got instead {ncoord}") 78 | for i in range(nbox): 79 | xmin, xmax, ymin, ymax = box_coords[i] 80 | dx = xmax - xmin 81 | dy = ymax - ymin 82 | rect = patches.Rectangle((xmin, ymin), dx, dy, fill=False, *args, **kwargs) 83 | ax.add_patch(rect) 84 | if annotate: 85 | ax.annotate(i, (xmin + 0.5 * dx, ymin + 0.5 * dy)) 86 | ax.set_aspect(1.0) 87 | ax.autoscale(enable=True) 88 | return 89 | 90 | 91 | def generate_disk(partitions: int, depth: int): 92 | """ 93 | Generate a triangular mesh for the unit circle. 94 | 95 | Parameters 96 | ---------- 97 | partitions: int 98 | Number of triangles around the origin. 99 | depth: int 100 | Number of "layers" of triangles around the origin. 101 | 102 | Returns 103 | ------- 104 | vertices: np.ndarray of floats with shape ``(n_vertex, 2)`` 105 | triangles: np.ndarray of integers with shape ``(n_triangle, 3)`` 106 | """ 107 | if partitions < 3: 108 | raise ValueError("partitions should be >= 3") 109 | 110 | N = depth + 1 111 | n_per_level = partitions * np.arange(N) 112 | n_per_level[0] = 1 113 | 114 | delta_angle = (2 * np.pi) / np.repeat(n_per_level, n_per_level) 115 | index = np.repeat(np.insert(n_per_level.cumsum()[:-1], 0, 0), n_per_level) 116 | angles = delta_angle.cumsum() 117 | angles = angles - angles[index] + 0.5 * np.pi 118 | radii = np.repeat(np.linspace(0.0, 1.0, N), n_per_level) 119 | 120 | x = np.cos(angles) * radii 121 | y = np.sin(angles) * radii 122 | triang = matplotlib.tri.Triangulation(x, y) 123 | return np.column_stack((x, y)), triang.triangles 124 | 125 | 126 | def example_1d_network(): 127 | vertices = np.array( 128 | [ 129 | [0.0, 0.0], 130 | [0.25, 1.0], 131 | [1.25, 2.0], 132 | [1.5, 2.5], 133 | [2.5, 3.25], 134 | [2.5, 2.5], 135 | [2.75, 3.75], 136 | [3.0, 2.0], 137 | [0.25, 1.75], 138 | [0.5, 2.25], 139 | ], 140 | dtype=float, 141 | ) 142 | edges = np.array( 143 | [[0, 1], [1, 2], [2, 3], [3, 4], [3, 5], [4, 6], [5, 7], [1, 8], [8, 9]], 144 | dtype=np.int32, 145 | ) 146 | return vertices, edges 147 | -------------------------------------------------------------------------------- /numba_celltree/edge_celltree.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Tuple 2 | 3 | import numba as nb 4 | import numpy as np 5 | 6 | from numba_celltree.cast import cast_edges, cast_vertices 7 | from numba_celltree.celltree_base import ( 8 | CellTree2dBase, 9 | bbox_distances, 10 | bbox_tree, 11 | default_tolerance, 12 | ) 13 | from numba_celltree.constants import ( 14 | MIN_TOLERANCE, 15 | TOLERANCE_FACTOR, 16 | CellTreeData, 17 | FloatArray, 18 | IntArray, 19 | ) 20 | from numba_celltree.creation import initialize 21 | from numba_celltree.geometry_utils import build_edge_bboxes 22 | from numba_celltree.query import locate_edge_edges, locate_points_on_edge 23 | 24 | 25 | class EdgeCellTree2d(CellTree2dBase): 26 | """ 27 | Construct a cell tree from 2D vertices and an edges indexing array. 28 | 29 | Parameters 30 | ---------- 31 | vertices: ndarray of floats with shape ``(n_point, 2)`` 32 | Corner coordinates (x, y) of the cells. 33 | edges: ndarray of integers with shape ``(n_edge, 2)`` 34 | Index identifying for every edge the indices of its two nodes. 35 | n_buckets: int, optional, default: 4 36 | The number of "buckets" used in tree construction. Must be higher 37 | or equal to 2. Values over 8 provide diminishing returns. 38 | cells_per_leaf: int, optional, default: 2 39 | The number of cells in the leaf nodes of the cell tree. Can be set 40 | to only 1, but this doubles memory footprint for slightly faster 41 | lookup. Increase this to reduce memory usage at the cost of lookup 42 | performance. 43 | """ 44 | 45 | def __init__( 46 | self, 47 | vertices: FloatArray, 48 | edges: IntArray, 49 | n_buckets: int = 4, 50 | cells_per_leaf: int = 2, 51 | ): 52 | # Determine the tolerance for the bounding boxes. This is mainly 53 | # relevant when edges are axis-aligned. 54 | if n_buckets < 2: 55 | raise ValueError("n_buckets must be >= 2") 56 | if cells_per_leaf < 1: 57 | raise ValueError("cells_per_leaf must be >= 1") 58 | 59 | vertices = cast_vertices(vertices, copy=True) 60 | x, y = vertices.T 61 | dx = x.max() - x.min() 62 | dy = y.max() - y.min() 63 | global_tolerance = max(MIN_TOLERANCE, TOLERANCE_FACTOR * max(dx, dy)) 64 | bb_coords = build_edge_bboxes(edges, vertices, global_tolerance) 65 | nodes, bb_indices = initialize(edges, bb_coords, n_buckets, cells_per_leaf) 66 | self.vertices = vertices 67 | self.edges = edges 68 | self.n_buckets = n_buckets 69 | self.cells_per_leaf = cells_per_leaf 70 | self.nodes = nodes 71 | self.bb_indices = bb_indices 72 | self.bb_coords = bb_coords 73 | self.bbox = bbox_tree(bb_coords) 74 | self.bb_distances = bbox_distances(bb_coords) 75 | self.celltree_data = CellTreeData( 76 | self.edges, 77 | self.vertices, 78 | self.nodes, 79 | self.bb_indices, 80 | self.bb_coords, 81 | self.bbox, 82 | self.cells_per_leaf, 83 | ) 84 | 85 | def locate_points( 86 | self, points: FloatArray, tolerance: Optional[float] = None 87 | ) -> IntArray: 88 | """ 89 | Find the index of a face that contains a point. 90 | 91 | Parameters 92 | ---------- 93 | points: ndarray of floats with shape ``(n_point, 2)`` 94 | Coordinates of the points to be located. 95 | tolerance: float, optional 96 | The tolerance used to determine whether a point is on an edge. This 97 | is a floating point precision criterion, thus cannot be directly be 98 | interpreted as a distance. If None, the method tries to estimate an 99 | appropriate tolerance by multiplying the maximum diagonal of the 100 | bounding boxes with 1e-12. 101 | 102 | 103 | Returns 104 | ------- 105 | tree_edge_indices: ndarray of integers with shape ``(n_point,)`` 106 | For every point, the index of the edge it falls on. Points not 107 | falling on any edge are marked with a value of ``-1``. 108 | """ 109 | if tolerance is None: 110 | tolerance = default_tolerance(self.bb_distances[:, 2]) 111 | points = cast_vertices(points) 112 | return locate_points_on_edge(points, self.celltree_data, tolerance) 113 | 114 | def intersect_edges( 115 | self, 116 | edge_coords: FloatArray, 117 | ) -> Tuple[IntArray, IntArray, FloatArray]: 118 | """ 119 | Find the index of an edge intersecting with an edge. 120 | 121 | Parameters 122 | ---------- 123 | edge_coords: ndarray of floats with shape ``(n_edge, 2, 2)`` 124 | Every row containing ``((x0, y0), (x1, y1))``. 125 | 126 | Returns 127 | ------- 128 | edge_indices: ndarray of integers with shape ``(n_found,)`` 129 | Indices of the bounding box. 130 | tree_edge_indices: ndarray of integers with shape ``(n_found,)`` 131 | Indices of the edge. 132 | intersection: ndarray of floats with shape ``(n_found, 2)`` 133 | Coordinate pair of the intersection. 134 | """ 135 | edge_coords = cast_edges(edge_coords) 136 | n_chunks = nb.get_num_threads() 137 | edge_indices, tree_edge_indices, xy = locate_edge_edges( 138 | edge_coords, self.celltree_data, n_chunks 139 | ) 140 | intersection_xy = np.ascontiguousarray(xy[:, 0]) 141 | return edge_indices, tree_edge_indices, intersection_xy 142 | -------------------------------------------------------------------------------- /numba_celltree/algorithms/sutherland_hodgman.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sutherland-Hodgman clipping 3 | --------------------------- 4 | Vertices (always lower case, single letter): 5 | Clipping polygon with vertices r, s, ... 6 | Subject polgyon with vertices a, b, ... 7 | Vectors (always upper case, single letter): 8 | 9 | * U: r -> s 10 | * N: norm, orthogonal to u 11 | * V: a -> b 12 | * W: a -> r 13 | 14 | s ----- ... 15 | | 16 | | b ----- ... 17 | | / 18 | |/ 19 | x 20 | /| 21 | / | 22 | a--+-- ... 23 | | 24 | r ----- ... 25 | 26 | Floating point rounding should not be an issue, since we're only looking at 27 | finding the area of overlap of two convex polygons. 28 | In case of intersection failure, we can ignore it when going out -> in. It will 29 | occur when the outgoing point is very close the clipping edge. In that case the 30 | intersection point ~= vertex b, and we can safely skip the intersection. 31 | When going in -> out, b might be located on the edge. If intersection fails, 32 | again the intersection point ~= vertex b. We treat b as if it is just on the 33 | inside and append it. For consistency, we set b_inside to True, as it will be 34 | used as a_inside in the next iteration. 35 | """ 36 | 37 | from typing import Sequence, Tuple 38 | 39 | import numba as nb 40 | import numpy as np 41 | 42 | from numba_celltree.constants import PARALLEL, FloatArray, FloatDType, IntArray 43 | from numba_celltree.geometry_utils import ( 44 | Point, 45 | Vector, 46 | as_box, 47 | as_point, 48 | copy_box_vertices, 49 | copy_vertices, 50 | dot_product, 51 | polygon_area, 52 | ) 53 | from numba_celltree.utils import allocate_clip_polygon, copy 54 | 55 | 56 | @nb.njit(inline="always") 57 | def inside(p: Point, r: Point, U: Vector): 58 | # U: a -> b direction vector 59 | # p is point r or s 60 | return U.x * (p.y - r.y) > U.y * (p.x - r.x) 61 | 62 | 63 | @nb.njit(inline="always") 64 | def intersection(a: Point, V: Vector, r: Point, N: Vector) -> Tuple[bool, Point]: 65 | # Find the intersection with an (infinite) clipping plane 66 | W = Vector(r.x - a.x, r.y - a.y) 67 | nw = dot_product(N, W) 68 | nv = dot_product(N, V) 69 | if nv != 0: 70 | t = nw / nv 71 | return True, Point(a.x + t * V.x, a.y + t * V.y) 72 | else: 73 | # parallel lines 74 | return False, Point(np.nan, np.nan) 75 | 76 | 77 | @nb.njit(inline="always") 78 | def push_point(polygon: FloatArray, size: int, p: Point) -> int: 79 | polygon[size][0] = p.x 80 | polygon[size][1] = p.y 81 | return size + 1 82 | 83 | 84 | @nb.njit(inline="always") 85 | def polygon_polygon_clip_area(polygon: Sequence, clipper: Sequence) -> float: 86 | n_output = len(polygon) 87 | n_clip = len(clipper) 88 | subject = allocate_clip_polygon() 89 | output = allocate_clip_polygon() 90 | 91 | # Copy polygon into output 92 | copy(polygon, output, n_output) 93 | 94 | # Grab last point 95 | r = as_point(clipper[n_clip - 1]) 96 | for i in range(n_clip): 97 | s = as_point(clipper[i]) 98 | 99 | U = Vector(s.x - r.x, s.y - r.y) 100 | if U.x == 0 and U.y == 0: 101 | continue 102 | N = Vector(-U.y, U.x) 103 | 104 | # Copy output into subject 105 | length = n_output 106 | copy(output, subject, length) 107 | # Reset 108 | n_output = 0 109 | # Grab last point 110 | a = as_point(subject[length - 1]) 111 | a_inside = inside(a, r, U) 112 | for j in range(length): 113 | b = as_point(subject[j]) 114 | 115 | V = Vector(b.x - a.x, b.y - a.y) 116 | if V.x == 0 and V.y == 0: 117 | continue 118 | 119 | b_inside = inside(b, r, U) 120 | if b_inside: 121 | if not a_inside: # out, or on the edge 122 | succes, point = intersection(a, V, r, N) 123 | if succes: 124 | n_output = push_point(output, n_output, point) 125 | n_output = push_point(output, n_output, b) 126 | elif a_inside: 127 | succes, point = intersection(a, V, r, N) 128 | if succes: 129 | n_output = push_point(output, n_output, point) 130 | else: # Floating point failure 131 | # TODO: haven't come up with a test case yet to succesfully 132 | # trigger this ... 133 | b_inside = True # flip it for consistency, will be set as a 134 | n_output = push_point(output, n_output, b) # push b instead 135 | 136 | # Advance to next polygon edge 137 | a = b 138 | a_inside = b_inside 139 | 140 | # Exit early in case not enough vertices are left. 141 | if n_output < 3: 142 | return 0.0 143 | 144 | # Advance to next clipping edge 145 | r = s 146 | 147 | area = polygon_area(output[:n_output]) 148 | return area 149 | 150 | 151 | @nb.njit(parallel=PARALLEL, cache=True) 152 | def area_of_intersection( 153 | vertices_a: FloatArray, 154 | vertices_b: FloatArray, 155 | faces_a: IntArray, 156 | faces_b: IntArray, 157 | indices_a: IntArray, 158 | indices_b: IntArray, 159 | ) -> FloatArray: 160 | n_intersection = indices_a.size 161 | area = np.empty(n_intersection, dtype=FloatDType) 162 | for i in nb.prange(n_intersection): 163 | face_a = faces_a[indices_a[i]] 164 | face_b = faces_b[indices_b[i]] 165 | a = copy_vertices(vertices_a, face_a) 166 | b = copy_vertices(vertices_b, face_b) 167 | area[i] = polygon_polygon_clip_area(a, b) 168 | return area 169 | 170 | 171 | @nb.njit(parallel=PARALLEL, cache=True) 172 | def box_area_of_intersection( 173 | bbox_coords: FloatArray, 174 | vertices: FloatArray, 175 | faces: IntArray, 176 | indices_bbox: IntArray, 177 | indices_face: IntArray, 178 | ) -> FloatArray: 179 | n_intersection = indices_bbox.size 180 | area = np.empty(n_intersection, dtype=FloatDType) 181 | for i in nb.prange(n_intersection): 182 | box = as_box(bbox_coords[indices_bbox[i]]) 183 | face = faces[indices_face[i]] 184 | a = copy_box_vertices(box) 185 | b = copy_vertices(vertices, face) 186 | area[i] = polygon_polygon_clip_area(a, b) 187 | return area 188 | -------------------------------------------------------------------------------- /tests/test_algorithms/test_line_polygon_clip.py: -------------------------------------------------------------------------------- 1 | """Utilizes same boxes as test_line_box_clip""" 2 | 3 | import numpy as np 4 | 5 | from numba_celltree.algorithms.cyrus_beck import cyrus_beck_line_polygon_clip 6 | from numba_celltree.constants import Point 7 | 8 | 9 | def ab(a, b, c): 10 | """Flip the result around to compare (a, b) with (b, a)""" 11 | return (a, c, b) 12 | 13 | 14 | TOLERANCE = 1e-9 15 | 16 | 17 | def line_clip(a, b, poly): 18 | return cyrus_beck_line_polygon_clip(a, b, poly, TOLERANCE) 19 | 20 | 21 | def test_line_box_clip(): 22 | poly = np.array( 23 | [ 24 | [1.0, 3.0], 25 | [4.0, 3.0], 26 | [4.0, 5.0], 27 | [1.0, 5.0], 28 | ] 29 | ) 30 | 31 | a = Point(0.0, 0.0) 32 | b = Point(4.0, 6.0) 33 | intersects, c, d = line_clip(a, b, poly) 34 | assert intersects 35 | assert np.allclose(c, [2.0, 3.0]) 36 | assert np.allclose(d, [3.3333333333333, 5.0]) 37 | assert line_clip(a, b, poly) == ab(*line_clip(b, a, poly)) 38 | 39 | a = Point(0.0, 0.1) 40 | b = Point(0.0, 0.1) 41 | intersects, c, d = line_clip(a, b, poly) 42 | assert not intersects 43 | assert np.isnan(c).all() 44 | assert np.isnan(d).all() 45 | assert line_clip(a, b, poly)[0] == line_clip(b, a, poly)[0] 46 | 47 | a = Point(0.0, 4.0) 48 | b = Point(5.0, 4.0) 49 | intersects, c, d = line_clip(a, b, poly) 50 | assert intersects 51 | assert np.allclose(c, [1.0, 4.0]) 52 | assert np.allclose(d, [4.0, 4.0]) 53 | assert line_clip(a, b, poly) == ab(*line_clip(b, a, poly)) 54 | 55 | poly = np.array( 56 | [ 57 | [0.0, 0.0], 58 | [2.0, 0.0], 59 | [2.0, 2.0], 60 | [0.0, 2.0], 61 | ] 62 | ) 63 | a = Point(1.0, -3.0) 64 | b = Point(1.0, 3.0) 65 | intersects, c, d = line_clip(a, b, poly) 66 | assert intersects 67 | assert np.allclose(c, [1.0, 0.0]) 68 | assert np.allclose(d, [1.0, 2.0]) 69 | assert line_clip(a, b, poly) == ab(*line_clip(b, a, poly)) 70 | 71 | b = Point(1.0, 1.0) 72 | intersects, c, d = line_clip(a, b, poly) 73 | assert intersects 74 | assert np.allclose(c, [1.0, 0.0]) 75 | assert np.allclose(d, [1.0, 1.0]) 76 | assert line_clip(a, b, poly) == ab(*line_clip(b, a, poly)) 77 | 78 | a = Point(1.0, 1.0) 79 | b = Point(1.0, 3.0) 80 | intersects, c, d = line_clip(a, b, poly) 81 | assert intersects 82 | assert np.allclose(c, [1.0, 1.0]) 83 | assert np.allclose(d, [1.0, 2.0]) 84 | assert line_clip(a, b, poly) == ab(*line_clip(b, a, poly)) 85 | 86 | 87 | def test_line_box_clip_through_vertex(): 88 | a = Point(x=2.0, y=2.0) 89 | b = Point(x=1.0, y=4.0) 90 | poly = np.array([[2.0, 4.0], [1.0, 4.0], [1.0, 3.0], [2.0, 3.0]]) 91 | intersects, c, d = line_clip(a, b, poly) 92 | assert intersects 93 | assert np.allclose(c, [1.5, 3.0]) 94 | assert np.allclose(d, [1.0, 4.0]) 95 | 96 | a = Point(x=2.0, y=0.0) 97 | b = Point(x=2.0, y=2.0) 98 | poly = np.array([[2.0, 3.0], [1.0, 3.0], [1.0, 2.0], [2.0, 2.0]]) 99 | intersects, _, _ = line_clip(a, b, poly) 100 | assert not intersects 101 | 102 | poly = np.array([[3.0, 3.0], [2.0, 3.0], [2.0, 2.0], [3.0, 2.0]]) 103 | intersects, c, d = line_clip(a, b, poly) 104 | assert not intersects 105 | 106 | 107 | def test_line_triangle_clip(): 108 | # Triangle 109 | a = Point(1.0, 1.0) 110 | b = Point(3.0, 1.0) 111 | poly = np.array( 112 | [ 113 | [0.0, 0.5], 114 | [2.0, 0.0], 115 | [2.0, 2.0], 116 | ] 117 | ) 118 | intersects, c, d = line_clip(a, b, poly) 119 | assert intersects 120 | assert np.allclose(c, [1.0, 1.0]) 121 | assert np.allclose(d, [2.0, 1.0]) 122 | assert line_clip(a, b, poly) == ab(*line_clip(b, a, poly)) 123 | 124 | 125 | def test_line_triangle_clip_degeneracies(): 126 | poly = np.array( 127 | [ 128 | [0.0, 0.0], 129 | [2.0, 0.0], 130 | [2.0, 2.0], 131 | ] 132 | ) 133 | # Lower edge 134 | a = Point(0.0, 0.0) 135 | b = Point(2.0, 0.0) 136 | assert line_clip(a, b, poly)[0] 137 | assert line_clip(b, a, poly)[0] 138 | 139 | # Right edge 140 | a = Point(2.0, 0.0) 141 | b = Point(2.0, 2.0) 142 | assert line_clip(a, b, poly)[0] 143 | assert line_clip(b, a, poly)[0] 144 | 145 | # Diagonal edge 146 | a = Point(0.0, 0.0) 147 | b = Point(2.0, 2.0) 148 | assert line_clip(a, b, poly)[0] 149 | assert line_clip(b, a, poly)[0] 150 | 151 | a = Point(-1.0, -1.0) 152 | b = Point(2.0, 2.0) 153 | assert line_clip(a, b, poly)[0] 154 | assert line_clip(b, a, poly)[0] 155 | 156 | a = Point(-1.0, -1.0) 157 | b = Point(3.0, 3.0) 158 | assert line_clip(a, b, poly)[0] 159 | assert line_clip(b, a, poly)[0] 160 | 161 | a = Point(0.0, 0.0) 162 | b = Point(3.0, 3.0) 163 | assert line_clip(a, b, poly)[0] 164 | assert line_clip(b, a, poly)[0] 165 | 166 | 167 | def test_line_box_degeneracies(): 168 | nodes = np.array( 169 | [ 170 | [0.0, 0.0], 171 | [1.0, 0.0], 172 | [2.0, 0.0], 173 | [0.0, 1.0], 174 | [1.0, 1.0], 175 | [2.0, 1.0], 176 | [0.0, 2.0], 177 | [1.0, 2.0], 178 | [2.0, 2.0], 179 | ] 180 | ) 181 | faces = np.array( 182 | [ 183 | [3, 4, 6, 7], 184 | [4, 5, 8, 7], 185 | [0, 1, 4, 3], 186 | [1, 2, 5, 4], 187 | ] 188 | ) 189 | poly = nodes[faces[1]] 190 | a = Point(1.0, 1.0) 191 | b = Point(1.5, 1.25) 192 | succes, c, d = line_clip(a, b, poly) 193 | assert succes 194 | assert c == Point(1.0, 1.0) 195 | assert d == Point(1.5, 1.25) 196 | 197 | for i in [0, 2, 3]: 198 | poly = nodes[faces[i]] 199 | a = Point(1.0, 1.0) 200 | b = Point(1.5, 1.25) 201 | succes, c, d = line_clip(a, b, poly) 202 | assert not succes 203 | 204 | # This is a case where a is inside, but b is right on the vertex 205 | # point_in_poly(a, poly) == True 206 | # point_in_poly(b, poly) == False 207 | # This results in t0 == t1 in Cyrus-Beck. However, we can get the right 208 | # by setting t0 to 0.0 if a_inside, or t1 to 1.0 if b_inside. 209 | poly = nodes[faces[2]] 210 | a = Point(0.5, 0.75) 211 | b = Point(1.0, 1.0) 212 | succes, c, d = line_clip(a, b, poly) 213 | assert succes 214 | assert c == a 215 | assert d == b 216 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Numba Celltree 2 | ============== 3 | 4 | .. image:: https://img.shields.io/github/actions/workflow/status/deltares/numba_celltree/ci.yml?style=flat-square 5 | :target: https://github.com/deltares/numba_celltree/actions?query=workflows%3Aci 6 | .. image:: https://img.shields.io/codecov/c/github/deltares/numba_celltree.svg?style=flat-square 7 | :target: https://app.codecov.io/gh/deltares/numba_celltree 8 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg?style=flat-square 9 | :target: https://github.com/psf/black 10 | 11 | Finding your way around in unstructured meshes is difficult. Numba Celltree 12 | provides methods for searching for points, lines, boxes, and cells (convex 13 | polygons) in a two dimensional unstructured mesh. 14 | 15 | .. code:: python 16 | 17 | import numpy as np 18 | from numba_celltree import CellTree2d, demo 19 | 20 | vertices, faces = demo.generate_disk(5, 5) 21 | vertices += 1.0 22 | vertices *= 5.0 23 | tree = CellTree2d(vertices, faces, -1) 24 | 25 | # Intersection with two triangles 26 | triangle_vertices = np.array( 27 | [ 28 | [5.0, 3.0], 29 | [7.0, 3.0], 30 | [7.0, 5.0], 31 | [0.0, 6.0], 32 | [4.0, 4.0], 33 | [6.0, 10.0], 34 | ] 35 | ) 36 | triangles = np.array([[0, 1, 2], [3, 4, 5]]) 37 | tri_i, cell_i, area = tree.intersect_faces(triangle_vertices, triangles, -1) 38 | 39 | # Intersection with two lines 40 | edge_coords = np.array( 41 | [ 42 | [[0.0, 0.0], [10.0, 10.0]], 43 | [[0.0, 10.0], [10.0, 0.0]], 44 | ] 45 | ) 46 | edge_i, cell_i, intersections = tree.intersect_edges(edge_coords) 47 | 48 | .. image:: https://raw.githubusercontent.com/Deltares/numba_celltree/main/docs/_static/intersection-example.svg 49 | :target: https://github.com/deltares/numba_celltree 50 | 51 | Installation 52 | ------------ 53 | 54 | .. code:: console 55 | 56 | pip install numba_celltree 57 | 58 | Documentation 59 | ------------- 60 | 61 | .. image:: https://img.shields.io/github/actions/workflow/status/deltares/numba_celltree/ci.yml?style=flat-square 62 | :target: https://deltares.github.io/numba_celltree/ 63 | 64 | Background 65 | ---------- 66 | 67 | This package provides the cell tree data structure described in: 68 | 69 | Garth, C., & Joy, K. I. (2010). Fast, memory-efficient cell location in 70 | unstructured grids for visualization. IEEE Transactions on Visualization and 71 | Computer Graphics, 16(6), 1541-1550. 72 | 73 | This paper can be read `here 74 | `_. 75 | 76 | The tree building code is a direction translation of the (public domain) `C++ 77 | code 78 | `_ 79 | by Jay Hennen, which is available in the `cell_tree2d 80 | `_ python package. This 81 | implementation is currently specialized for two spatial dimensions, but 82 | extension to three dimensions is relatively straightforward. Another (BSD 83 | licensed) implementation which supports three dimensions can be found in VTK's 84 | `CellTreeLocator 85 | `_. 86 | 87 | The cell tree of the ``cell_tree2d`` currently only locates points. I've added 88 | additional methods for locating and clipping lines and convex polygons. 89 | 90 | Just-In-Time Compilation: Numba 91 | ------------------------------- 92 | 93 | This package relies on `Numba `_ to just-in-time 94 | compile Python code into fast machine code. This has the benefit of keeping 95 | this package a "pure" Python package, albeit with a dependency on Numba. 96 | 97 | With regards to performance: 98 | 99 | * Building the tree is marginally faster compared to the C++ implementation 100 | (~15%). 101 | * Serial point queries are somewhat slower (~50%), but Numba's automatic 102 | parallelization speeds things up significantly. (Of course the C++ code can 103 | be parallelized in the same manner with ``pragma omp parallel for``.) 104 | * The other queries have not been compared, as the C++ code lacks the 105 | functionality. 106 | * In traversing the tree, recursion in Numba appears to be less performant than 107 | maintaining a stack of nodes to traverse. The VTK implementation also uses 108 | a stack rather than recursion. Ideally, we would use a stack memory allocated 109 | array since this seems to result in a ~30% speed-up (especially when running 110 | multi-threaded), but these stack allocated arrays cannot be grown 111 | dynamically. 112 | * Numba (like its `LLVM JIT sister Julia `_) does not 113 | allocate small arrays on the stack automatically, like C++ and Fortran 114 | compilers do. However, it can be done `manually 115 | `_. This cuts down runtimes for 116 | some functions by at least a factor 2, more so in parallel. However, these 117 | stack allocated arrays work only in the context of Numba. They must be 118 | disabled when running in uncompiled Python -- there is some code in 119 | ``numba_celltree.utils`` which takes care of this. 120 | * Some methods like ``locate_points`` are trivially parallelizable, since 121 | there is one return value for each point. In that case, we can pre-allocate 122 | the output array immediately and apply ``nb.prange``, letting it spawn threads 123 | as needed. 124 | * Some methods, however, return an a priori unknown number of values. At the 125 | time of writing, Numba's lists are 126 | `not thread safe `_. There are 127 | two options here. The first option is to query twice: the first time we only 128 | count, then we allocate the results array(s), and the second time we store 129 | the actual values. Since parallelization generally results in speedups over a 130 | factor 2, this still results in a net gain. The second option is to chunk 131 | manually, and assign one chunk per thread. Each chunk can then allocate 132 | dynamically; we store the output of each thread in a list (of numpy arrays). 133 | This has overhead in terms of continuous bounds-checking and a final merge, 134 | but appears to be on net ~30% faster than the query-twice scheme. The net 135 | gain may disappear with a sufficiently large number of CPUs as at some point the 136 | serial merge and larger number of dynamic allocations starts dominating the 137 | total run time (on my 16 core laptop, querying once is still superior). 138 | 139 | To debug, set the environmental variable ``NUMBA_DISABLE_JIT=1``. Re-enable by 140 | setting ``NUMBA_DISABLE_JIT=0``. 141 | 142 | .. code:: bash 143 | 144 | export NUMBA_DISABLE_JIT=1 145 | 146 | In Windows Command Prompt: 147 | 148 | .. code:: console 149 | 150 | set NUMBA_DISABLE_JIT=1 151 | 152 | In Windows Powershell: 153 | 154 | .. code:: console 155 | 156 | $env:NUMBA_DISABLE_JIT=1 157 | 158 | In Python itself: 159 | 160 | .. code:: python 161 | 162 | import os 163 | 164 | os.environ["NUMBA_DISABLE_JIT"] = "1" 165 | 166 | This must be done before importing the package to have effect. 167 | -------------------------------------------------------------------------------- /tests/test_algorithms/test_sutherland_hodgman.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test data generated with: 3 | 4 | ```python 5 | import numpy as np 6 | import shapely.geometry as sg 7 | 8 | 9 | def ccw(a): 10 | # Ensure triangles are counter-clockwise 11 | for i in range(len(a)): 12 | t = a[i] 13 | normal = (t[1][0] - t[0][0])*(t[2][1]-t[0][1])-(t[1][1]-t[0][1])*(t[2][0]-t[0][0]) 14 | 15 | if normal < 0: 16 | a[i] = t[::-1] 17 | 18 | def area_of_intersection(a, b): 19 | ntriangles = a.shape[0] 20 | out = np.empty(ntriangles, dtype=np.float64) 21 | for i in range(ntriangles): 22 | aa = sg.Polygon(a[i]) 23 | bb = sg.Polygon(b[i]) 24 | out[i] = aa.intersection(bb).area 25 | return out 26 | 27 | a = np.random.rand(10, 3, 2) 28 | b = np.random.rand(10, 3, 2) 29 | ccw(a) 30 | ccw(b) 31 | expected = area_of_intersection(a, b) 32 | ``` 33 | 34 | """ 35 | 36 | import numpy as np 37 | 38 | from numba_celltree.algorithms.sutherland_hodgman import ( 39 | area_of_intersection, 40 | box_area_of_intersection, 41 | intersection, 42 | polygon_polygon_clip_area, 43 | ) 44 | from numba_celltree.constants import FloatDType, Point, Vector 45 | 46 | A = np.array( 47 | [ 48 | [[0.98599114, 0.16203056], [0.64839124, 0.6552714], [0.44528724, 0.88567472]], 49 | [[0.96182162, 0.3642742], [0.03478739, 0.54268026], [0.57582971, 0.41541277]], 50 | [[0.32556365, 0.03800701], [0.74000686, 0.04684465], [0.89527188, 0.55061165]], 51 | [[0.2988294, 0.96608896], [0.01212383, 0.00144037], [0.75113002, 0.54797261]], 52 | [[0.06522962, 0.43735202], [0.791499, 0.5229509], [0.40651803, 0.94317979]], 53 | [[0.06544202, 0.16735701], [0.67916353, 0.95843272], [0.33545733, 0.86368003]], 54 | [[0.43129575, 0.27998206], [0.49468229, 0.75438255], [0.01542992, 0.80696797]], 55 | [[0.29449023, 0.32433138], [0.46157048, 0.22492393], [0.82442969, 0.75853821]], 56 | [[0.66113797, 0.88485505], [0.70164374, 0.24393423], [0.89565423, 0.89407158]], 57 | [[0.92226655, 0.82771688], [0.42243438, 0.17562404], [0.82885357, 0.17541439]], 58 | ], 59 | ) 60 | 61 | B = np.array( 62 | [ 63 | [[0.8141854, 0.06821897], [0.37086004, 0.49067617], [0.79810508, 0.07873283]], 64 | [[0.74948185, 0.8942076], [0.59654411, 0.87755533], [0.3023107, 0.68256513]], 65 | [[0.46670989, 0.31716127], [0.68408985, 0.75792215], [0.41437824, 0.79509823]], 66 | [[0.60715923, 0.67648133], [0.40045464, 0.79676831], [0.06332723, 0.69679141]], 67 | [[0.24057248, 0.16433727], [0.58871277, 0.05499277], [0.59144784, 0.24476056]], 68 | [[0.23183198, 0.41619006], [0.66566902, 0.30110111], [0.60418791, 0.60702136]], 69 | [[0.09393344, 0.87976118], [0.994083, 0.00532686], [0.95176396, 0.79836557]], 70 | [[0.89063751, 0.5880825], [0.03881315, 0.82436939], [0.61391092, 0.45027842]], 71 | [[0.63168954, 0.75135847], [0.8726944, 0.06387274], [0.89585471, 0.92837592]], 72 | [[0.94379596, 0.64164962], [0.95787609, 0.65627618], [0.6212529, 0.89153053]], 73 | ] 74 | ) 75 | 76 | 77 | EXPECTED = np.array( 78 | [ 79 | 0.0, 80 | 0.0, 81 | 0.0, 82 | 0.0262324, 83 | 0.0, 84 | 0.00038042, 85 | 0.03629781, 86 | 0.01677156, 87 | 0.05417924, 88 | 0.00108787, 89 | ] 90 | ) 91 | 92 | 93 | def test_intersection(): 94 | # Intersection 95 | a = Point(0.0, 0.0) 96 | V = Vector(1.0, 1.0) 97 | r = Point(1.0, 0.0) 98 | s = Point(0.0, 1.0) 99 | U = Vector(s.x - r.x, s.y - r.y) 100 | N = Vector(-U.y, U.x) 101 | succes, p = intersection(a, V, r, N) 102 | assert succes 103 | assert np.allclose(p, [0.5, 0.5]) 104 | 105 | # Parallel lines, no intersection 106 | s = Point(2.0, 1.0) 107 | U = Vector(s.x - r.x, s.y - r.y) 108 | N = Vector(-U.y, U.x) 109 | succes, p = intersection(a, V, r, N) 110 | assert not succes 111 | 112 | 113 | def test_clip_area(): 114 | for a, b, expected in zip(A, B, EXPECTED): 115 | actual = polygon_polygon_clip_area(a, b) 116 | assert np.allclose(actual, expected) 117 | 118 | 119 | def test_clip_area_no_overlap(): 120 | a = np.array( 121 | [ 122 | [0.0, 0.0], 123 | [1.0, 0.0], 124 | [1.0, 1.0], 125 | ] 126 | ) 127 | b = a.copy() 128 | b += 2.0 129 | actual = polygon_polygon_clip_area(a, b) 130 | assert np.allclose(actual, 0) 131 | 132 | 133 | def test_clip_area_repeated_vertex(): 134 | a = np.array( 135 | [ 136 | [0.0, 0.0], 137 | [1.0, 0.0], 138 | [1.0, 0.0], 139 | [1.0, 1.0], 140 | ] 141 | ) 142 | # No overlap 143 | b = a.copy() 144 | b += 2.0 145 | actual = polygon_polygon_clip_area(a, b) 146 | assert np.allclose(actual, 0) 147 | 148 | b = np.array( 149 | [ 150 | [0.0, 0.0], 151 | [1.0, 0.0], 152 | [0.0, 1.0], 153 | [0.0, 1.0], 154 | ] 155 | ) 156 | actual = polygon_polygon_clip_area(a, b) 157 | 158 | 159 | def test_clip_area_epsilon(): 160 | EPS = np.finfo(FloatDType).eps 161 | a = np.array( 162 | [ 163 | [-1.0, -1.0], 164 | [1.0, -1.0], 165 | [1.0, 1.0], 166 | ] 167 | ) 168 | b = np.array( 169 | [ 170 | [-1.0 - EPS, -1.0 - EPS], 171 | [1.0 + EPS, -1.0 - EPS], 172 | [1.0 + EPS, 1.0 + EPS], 173 | ] 174 | ) 175 | actual = polygon_polygon_clip_area(a, b) 176 | assert np.allclose(actual, 2.0) 177 | 178 | EPS = -EPS 179 | b = np.array( 180 | [ 181 | [-1.0 - EPS, -1.0 - EPS], 182 | [1.0 + EPS, -1.0 - EPS], 183 | [1.0 + EPS, 1.0 + EPS], 184 | ] 185 | ) 186 | actual = polygon_polygon_clip_area(a, b) 187 | assert np.allclose(actual, 2.0) 188 | 189 | 190 | def test_area_of_intersection(): 191 | vertices_a = A.reshape(-1, 2) 192 | vertices_b = B.reshape(-1, 2) 193 | faces_a = np.arange(len(vertices_a)).reshape(-1, 3) 194 | faces_b = np.arange(len(vertices_b)).reshape(-1, 3) 195 | indices_a = np.arange(len(faces_a)) 196 | indices_b = np.arange(len(faces_a)) 197 | actual = area_of_intersection( 198 | vertices_a, vertices_b, faces_a, faces_b, indices_a, indices_b 199 | ) 200 | assert np.allclose(actual, EXPECTED) 201 | 202 | 203 | def test_box_area_of_intersection(): 204 | box_coords = np.array( 205 | [ 206 | [0.0, 1.0, 0.0, 1.0], 207 | [1.0, 2.0, 1.0, 2.0], 208 | ] 209 | ) 210 | vertices = np.array( 211 | [ 212 | [0.0, 0.0], 213 | [2.0, 0.0], 214 | [2.0, 2.0], 215 | [-2.0, 0.0], 216 | [-2.0, 2.0], 217 | ] 218 | ) 219 | faces = np.array( 220 | [ 221 | [0, 1, 2], 222 | [0, 3, 4], 223 | ] 224 | ) 225 | indices_bbox = np.array([0, 0, 1, 1]) 226 | indices_face = np.array([0, 1, 0, 1]) 227 | actual = box_area_of_intersection( 228 | box_coords, 229 | vertices, 230 | faces, 231 | indices_bbox, 232 | indices_face, 233 | ) 234 | assert np.allclose(actual, [0.5, 0.0, 0.5, 0.0]) 235 | -------------------------------------------------------------------------------- /tests/test_algorithms/test_line_box_clip.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from numba_celltree.algorithms import ( 5 | cohen_sutherland_line_box_clip, 6 | cyrus_beck_line_polygon_clip, 7 | liang_barsky_line_box_clip, 8 | ) 9 | from numba_celltree.constants import Box, Point 10 | 11 | 12 | def ab(a, b, c): 13 | """Flip the result around to compare (a, b) with (b, a)""" 14 | return (a, c, b) 15 | 16 | 17 | TOLERANCE = 1e-9 18 | 19 | 20 | def wraptol(function): 21 | def f(a, b, poly): 22 | return function(a, b, poly, TOLERANCE) 23 | 24 | return f 25 | 26 | 27 | BOX = Box(0.0, 2.0, 0.0, 2.0) 28 | POLY = np.array( 29 | [ 30 | [0.0, 0.0], 31 | [2.0, 0.0], 32 | [2.0, 2.0], 33 | [0.0, 2.0], 34 | ] 35 | ) 36 | POLY_REVERSED = POLY[::-1, :] 37 | 38 | 39 | @pytest.mark.parametrize( 40 | "line_clip, box", 41 | [ 42 | (cohen_sutherland_line_box_clip, BOX), 43 | (liang_barsky_line_box_clip, BOX), 44 | (wraptol(cyrus_beck_line_polygon_clip), POLY), 45 | ], 46 | ) 47 | def test_line_box_clip(line_clip, box): 48 | a = Point(-1.0, 0.0) 49 | b = Point(2.0, 3.0) 50 | intersects, c, d = line_clip(a, b, box) 51 | assert intersects 52 | assert np.allclose(c, [0.0, 1.0]) 53 | assert np.allclose(d, [1.0, 2.0]) 54 | 55 | a = Point(0.0, -0.1) 56 | b = Point(0.0, -0.1) 57 | intersects, c, d = line_clip(a, b, box) 58 | assert not intersects 59 | assert np.isnan(c).all() 60 | assert np.isnan(d).all() 61 | 62 | a = Point(-1.0, 1.0) 63 | b = Point(3.0, 1.0) 64 | intersects, c, d = line_clip(a, b, box) 65 | assert intersects 66 | assert np.allclose(c, [0.0, 1.0]) 67 | assert np.allclose(d, [2.0, 1.0]) 68 | 69 | a = Point(1.0, -3.0) 70 | b = Point(1.0, 3.0) 71 | intersects, c, d = line_clip(a, b, box) 72 | assert intersects 73 | assert np.allclose(c, [1.0, 0.0]) 74 | assert np.allclose(d, [1.0, 2.0]) 75 | 76 | b = Point(1.0, 1.0) 77 | intersects, c, d = line_clip(a, b, box) 78 | assert intersects 79 | assert np.allclose(c, [1.0, 0.0]) 80 | assert np.allclose(d, [1.0, 1.0]) 81 | 82 | a = Point(1.0, 1.0) 83 | b = Point(1.0, 3.0) 84 | intersects, c, d = line_clip(a, b, box) 85 | assert intersects 86 | assert np.allclose(c, [1.0, 1.0]) 87 | assert np.allclose(d, [1.0, 2.0]) 88 | 89 | a = Point(-1.0, 3.0) 90 | b = Point(3.0, 3.0) 91 | intersects, c, d = line_clip(a, b, box) 92 | assert not intersects 93 | 94 | a = Point(-1.0, 1.0) 95 | b = Point(1.0, 1.0) 96 | intersects, c, d = line_clip(a, b, box) 97 | assert intersects 98 | assert np.allclose(c, [0.0, 1.0]) 99 | assert np.allclose(d, [1.0, 1.0]) 100 | 101 | # both inside 102 | a = Point(0.5, 0.5) 103 | b = Point(1.5, 1.5) 104 | intersects, c, d = line_clip(a, b, box) 105 | assert intersects 106 | assert np.allclose(c, a) 107 | assert np.allclose(d, b) 108 | 109 | # No intersection, left 110 | a = Point(-1.5, 0.0) 111 | b = Point(-0.5, 1.0) 112 | intersects, c, d = line_clip(a, b, box) 113 | assert not intersects 114 | 115 | # No intersection, right 116 | a = Point(2.5, 0.0) 117 | b = Point(3.5, 1.0) 118 | intersects, c, d = line_clip(a, b, box) 119 | assert not intersects 120 | 121 | 122 | @pytest.mark.parametrize( 123 | "line_clip, box", 124 | [ 125 | (cohen_sutherland_line_box_clip, BOX), 126 | (liang_barsky_line_box_clip, BOX), 127 | (wraptol(cyrus_beck_line_polygon_clip), POLY), 128 | (wraptol(cyrus_beck_line_polygon_clip), POLY_REVERSED), 129 | ], 130 | ) 131 | def test_line_box_clip_degeneracy(line_clip, box): 132 | def assert_expected( 133 | a: tuple, 134 | b: tuple, 135 | intersects: bool, 136 | c: tuple = (np.nan, np.nan), 137 | d: tuple = (np.nan, np.nan), 138 | ) -> None: 139 | a = Point(*a) 140 | b = Point(*b) 141 | # c, d are the clipped points 142 | actual, actual_c, actual_d = line_clip(a, b, box) 143 | print(actual_c, c) 144 | print(actual_d, d) 145 | assert intersects is actual 146 | assert np.allclose(actual_c, c, equal_nan=True) 147 | assert np.allclose(actual_d, d, equal_nan=True) 148 | 149 | # Direction of the point doesn't change, so neither should the answer. 150 | actual, actual_c, actual_d = line_clip(a, b, box) 151 | assert intersects is actual 152 | assert np.allclose(actual_c, c, equal_nan=True) 153 | assert np.allclose(actual_d, d, equal_nan=True) 154 | 155 | # Line through vertices 156 | assert_expected( 157 | (-1.0, -1.0), 158 | (3.0, 3.0), 159 | True, 160 | (0.0, 0.0), 161 | (2.0, 2.0), 162 | ) 163 | 164 | # Identity 165 | assert_expected( 166 | (-1.0, -1.0), 167 | (-1.0, -1.0), 168 | False, 169 | ) 170 | 171 | # Line through lower edge 172 | assert_expected( 173 | (-1.0, 0.0), 174 | (3.0, 0.0), 175 | True, 176 | (0.0, 0.0), 177 | (2.0, 0.0), 178 | ) 179 | 180 | # Line through upper edge 181 | assert_expected( 182 | (-1.0, 2.0), 183 | (3.0, 2.0), 184 | True, 185 | (0.0, 2.0), 186 | (2.0, 2.0), 187 | ) 188 | 189 | # Partial line lower edge 190 | assert_expected( 191 | (-1.0, 0.0), 192 | (1.0, 0.0), 193 | True, 194 | (0.0, 0.0), 195 | (1.0, 0.0), 196 | ) 197 | 198 | # Partial line upper edge 199 | assert_expected( 200 | (-1.0, 2.0), 201 | (1.0, 2.0), 202 | True, 203 | (0.0, 2.0), 204 | (1.0, 2.0), 205 | ) 206 | 207 | # Within lower edge 208 | assert_expected( 209 | (0.5, 0.0), 210 | (1.5, 0.0), 211 | True, 212 | (0.5, 0.0), 213 | (1.5, 0.0), 214 | ) 215 | 216 | # Within upper edge 217 | assert_expected( 218 | (0.5, 2.0), 219 | (1.5, 2.0), 220 | True, 221 | (0.5, 2.0), 222 | (1.5, 2.0), 223 | ) 224 | 225 | # Identical to lower edge 226 | assert_expected( 227 | (0.0, 0.0), 228 | (2.0, 0.0), 229 | True, 230 | (0.0, 0.0), 231 | (2.0, 0.0), 232 | ) 233 | 234 | # Identical to upper edge 235 | assert_expected( 236 | (0.0, 2.0), 237 | (2.0, 2.0), 238 | True, 239 | (0.0, 2.0), 240 | (2.0, 2.0), 241 | ) 242 | 243 | # Identical to left edge 244 | assert_expected( 245 | (0.0, 0.0), 246 | (0.0, 2.0), 247 | True, 248 | (0.0, 0.0), 249 | (0.0, 2.0), 250 | ) 251 | 252 | # Identical to right edge 253 | assert_expected( 254 | (2.0, 0.0), 255 | (2.0, 2.0), 256 | True, 257 | (2.0, 0.0), 258 | (2.0, 2.0), 259 | ) 260 | 261 | # Within left edge 262 | assert_expected( 263 | (0.0, 0.5), 264 | (0.0, 1.5), 265 | True, 266 | (0.0, 0.5), 267 | (0.0, 1.5), 268 | ) 269 | 270 | # Within right edge 271 | assert_expected( 272 | (2.0, 0.5), 273 | (2.0, 1.5), 274 | True, 275 | (2.0, 0.5), 276 | (2.0, 1.5), 277 | ) 278 | 279 | # Identical to left edge 280 | assert_expected( 281 | (0.0, 0.0), 282 | (0.0, 2.0), 283 | True, 284 | (0.0, 0.0), 285 | (0.0, 2.0), 286 | ) 287 | 288 | # Identical to right edge 289 | assert_expected( 290 | (2.0, 0.0), 291 | (2.0, 2.0), 292 | True, 293 | (2.0, 0.0), 294 | (2.0, 2.0), 295 | ) 296 | 297 | # Diagonal line of length (1, 1), touching upper left corner. 298 | assert_expected( 299 | (-1.0, 1.0), 300 | (0.0, 2.0), 301 | False, 302 | (np.nan, np.nan), 303 | (np.nan, np.nan), 304 | ) 305 | -------------------------------------------------------------------------------- /numba_celltree/algorithms/cyrus_beck.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implementation based off the description in: 3 | Skala, V. (1993). An efficient algorithm for line clipping by convex polygon. 4 | Computers & Graphics, 17(4), 417-421. 5 | 6 | Available at: 7 | https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.302.5729&rep=rep1&type=pdf 8 | 9 | Also available in: 10 | Duc Huy Bui, 1999. Algorithms for Line Clipping and Their Complexity. PhD 11 | Thesis. 12 | 13 | Available at: 14 | http://graphics.zcu.cz/files/DIS_1999_Bui_Duc_Huy.pdf 15 | """ 16 | 17 | from typing import Sequence, Tuple 18 | 19 | import numba as nb 20 | import numpy as np 21 | 22 | from numba_celltree.constants import Point, Vector 23 | from numba_celltree.geometry_utils import ( 24 | as_point, 25 | cross_product, 26 | dot_product, 27 | point_in_polygon_or_on_edge, 28 | to_point, 29 | to_vector, 30 | ) 31 | 32 | NO_INTERSECTION = False, Point(np.nan, np.nan), Point(np.nan, np.nan) 33 | 34 | 35 | @nb.njit(inline="always") 36 | def compute_intersection( 37 | a: Point, s: Vector, v0: Point, v1: Point 38 | ) -> Tuple[bool, float]: 39 | # Computes intersection for parametrized vector 40 | # vector given by a & s 41 | # line given by polygon vertices v0 & v1 42 | # Due to ksi_eta check, k should never be 0 (parallel, possibly collinear) 43 | # Note: Polygon must be counter-clockwise 44 | si = to_vector(a, v0) 45 | n = Vector(-(v1.y - v0.y), (v1.x - v0.x)) 46 | n_si = dot_product(n, si) 47 | k = dot_product(n, s) 48 | t = n_si / k 49 | if n_si > 0: 50 | return True, t # Entering 51 | else: 52 | return False, t # leaving 53 | 54 | 55 | @nb.njit(inline="always") 56 | def intersections( 57 | a: Point, s: Vector, poly: Sequence[Point], length: int, k: int, i0: int, i1: int 58 | ) -> Tuple[float, float]: 59 | # Return t's for parametrized vector 60 | # Note: polygon must be counter-clockwise 61 | 62 | # A single intersection found, could be entering, could be leaving 63 | v0 = as_point(poly[i0]) 64 | v01 = as_point(poly[(i0 + 1) % length]) 65 | v1 = as_point(poly[i1]) 66 | v11 = as_point(poly[(i1 + 1) % length]) 67 | _, t0 = compute_intersection(a, s, v0, v01) 68 | enters1, t1 = compute_intersection(a, s, v1, v11) 69 | if enters1: # Swap them 70 | return t1, t0 71 | else: 72 | return t0, t1 73 | 74 | 75 | @nb.njit(inline="always") 76 | def overlap(ta: Point, tb: Point, t0: Point, t1: Point) -> bool: 77 | if ta > tb: 78 | ta, tb = tb, ta 79 | if t0 > t1: 80 | t0, t1 = t1, t0 81 | vector_overlap = max(0, min(tb, t1) - max(ta, t0)) 82 | return vector_overlap > 0.0 83 | 84 | 85 | @nb.njit(inline="always") 86 | def aligned(U: Vector, V: Vector) -> bool: 87 | # Any zero vector: always aligned. 88 | if (U.x == 0 and U.y == 0) or (V.x == 0 and V.y == 0): 89 | return True 90 | 91 | # Both x-components non-zero: 92 | if U.x != 0 and V.x != 0: 93 | return (U.x > 0) == (V.x > 0) 94 | 95 | # Both y-components non-zero: 96 | if U.y != 0 and V.y != 0: 97 | return (U.y > 0) == (V.y > 0) 98 | 99 | # One vertical, one horizontal: not aligned. 100 | return False 101 | 102 | 103 | @nb.njit(inline="always") 104 | def collinear_case(a: Point, b: Point, v0: Point, v1: Point) -> Tuple[Point, Point]: 105 | # Redefine everything relative to point a to avoid precision loss in cross 106 | # products. 107 | # _a is implicit (0.0, 0.0) 108 | _b = Point(b.x - a.x, b.y - a.y) 109 | _v0 = Point(v0.x - a.x, v0.y - a.y) 110 | _v1 = Point(v1.x - a.x, v1.y - a.y) 111 | 112 | # Check orientation 113 | U = Vector(_b.x, _b.y) 114 | V = to_vector(_v0, _v1) 115 | if not aligned(U, V): 116 | v0, v1 = v1, v0 117 | _v0, _v1 = _v1, _v0 118 | 119 | # Project on the same axis (t), take inner values 120 | n = Vector(-_b.y, _b.x) # a implicit 121 | ta = 0.0 # a implicit 122 | tb = cross_product(n, _b) 123 | t0 = cross_product(n, _v0) 124 | t1 = cross_product(n, _v1) 125 | 126 | if not overlap(ta, tb, t0, t1): 127 | return NO_INTERSECTION 128 | 129 | if t0 < ta: 130 | p0 = v0 131 | else: 132 | p0 = a 133 | 134 | if t1 > tb: 135 | p1 = v1 136 | else: 137 | p1 = b 138 | 139 | return True, p0, p1 140 | 141 | 142 | # Too big to inline. Drives compilation time through the roof for no benefit. 143 | @nb.njit(inline="never") 144 | def cyrus_beck_line_polygon_clip( 145 | a: Point, b: Point, poly: Sequence[Point], tolerance: float 146 | ) -> Tuple[bool, Point, Point]: 147 | """ 148 | In short, the basic idea: 149 | 150 | For a given segment s (a -> b), test which two edges of a convex polygon it 151 | can intersect. If it intersects, the vertices [v0, v1] of an edge are 152 | separated by the segment (s). If s separates, the cross products of a -> v0 153 | (ksi) and a -> v1 (eta) will point in opposing directions (ksi * eta < 0). 154 | If both are > 0 or both are < 0 (ksi * eta > 0), they fall on the on the 155 | same side of the line; they are parallel and possibly collinear if ksi * 156 | eta == 0. 157 | 158 | Once the number of intersections (k), and the possibly intersecting edges 159 | (i0, i1) have been identified, we can compute the intersections. This 160 | assumes the vertices of the polygons are ordered in counter-clockwise 161 | orientation. We can also tell whether a line is possibly entering or 162 | leaving the polygon by the sign of the dot product. 163 | 164 | A valid intersection falls on the domain of the parametrized segment: 165 | 0 <= t <= 1.0 166 | """ 167 | length = len(poly) 168 | s = to_vector(a, b) 169 | 170 | # Test whether points are identical 171 | if s.x == 0 and s.y == 0: 172 | return NO_INTERSECTION 173 | # Test whether line is fully enclosed in polygon 174 | a_inside = point_in_polygon_or_on_edge(a, poly, tolerance) 175 | b_inside = point_in_polygon_or_on_edge(b, poly, tolerance) 176 | if a_inside and b_inside: 177 | return True, a, b 178 | 179 | i0 = -1 180 | i1 = -1 181 | i = 0 182 | k = 0 183 | v = as_point(poly[i]) 184 | ksi = cross_product(to_vector(a, v), s) 185 | 186 | while i < length and k < 2: 187 | v0 = as_point(poly[i]) 188 | v1 = as_point(poly[(i + 1) % length]) 189 | # Check if they can cross at all 190 | eta = cross_product(to_vector(a, v1), s) 191 | 192 | # Note; ksi * eta < 0 doesn't work as well 193 | if (ksi < 0.0) ^ (eta < 0.0): 194 | if k == 0: 195 | i0 = i 196 | else: 197 | i1 = i 198 | k += 1 199 | # Calculate the area of the triangle formed by a, b, v0 200 | # if zero, then points are collinear. 201 | elif (ksi == 0.0) and (eta == 0.0): 202 | return collinear_case(a, b, v0, v1) 203 | 204 | # Don't recompute ksi 205 | ksi = eta 206 | i += 1 207 | 208 | if k == 0: 209 | return NO_INTERSECTION 210 | 211 | # Gather the intersections, given half-planes 212 | t0, t1 = intersections(a, s, poly, length, k, i0, i1) 213 | 214 | # Deal with edge cases 215 | if t0 == t1: 216 | if a_inside and t1 != 0.0: 217 | t0 = 0.0 218 | elif b_inside and t0 != 1.0: 219 | t1 = 1.0 220 | else: 221 | return NO_INTERSECTION 222 | 223 | # Swap if necessary so that t0 is the smaller 224 | if t1 < t0: 225 | t0, t1 = t1, t0 226 | 227 | # Note: 228 | # t >= 0, not > 229 | # t1 <= 1, not < 230 | valid0 = t0 >= 0 and t0 < 1 231 | valid1 = t1 > 0 and t1 <= 1 232 | 233 | # Return only the intersections that are within the segment 234 | if valid0 and valid1: 235 | return True, to_point(t0, a, s), to_point(t1, a, s) 236 | elif valid0: 237 | return True, to_point(t0, a, s), b 238 | elif valid1: 239 | return True, a, to_point(t1, a, s) 240 | else: 241 | return NO_INTERSECTION 242 | -------------------------------------------------------------------------------- /tests/data/xy.txt: -------------------------------------------------------------------------------- 1 | 2.50000e-01 0.00000e+00 2 | 3.48668e-01 3.05045e-02 3 | 4.50000e-01 0.00000e+00 4 | 5.47907e-01 4.79357e-02 5 | 6.50000e-01 0.00000e+00 6 | 7.47146e-01 6.53668e-02 7 | 8.50000e-01 0.00000e+00 8 | 9.46385e-01 8.27980e-02 9 | 2.46202e-01 4.34120e-02 10 | 3.38074e-01 9.05867e-02 11 | 4.43163e-01 7.81417e-02 12 | 5.31259e-01 1.42350e-01 13 | 6.40125e-01 1.12871e-01 14 | 7.24444e-01 1.94114e-01 15 | 8.37087e-01 1.47601e-01 16 | 9.17630e-01 2.45878e-01 17 | 2.34923e-01 8.55050e-02 18 | 3.17208e-01 1.47916e-01 19 | 4.22862e-01 1.53909e-01 20 | 4.98469e-01 2.32440e-01 21 | 6.10800e-01 2.22313e-01 22 | 6.79731e-01 3.16964e-01 23 | 7.98739e-01 2.90717e-01 24 | 8.60992e-01 4.01487e-01 25 | 2.16506e-01 1.25000e-01 26 | 2.86703e-01 2.00752e-01 27 | 3.89711e-01 2.25000e-01 28 | 4.50534e-01 3.15467e-01 29 | 5.62917e-01 3.25000e-01 30 | 6.14364e-01 4.30182e-01 31 | 7.36122e-01 4.25000e-01 32 | 7.78194e-01 5.44898e-01 33 | 1.91511e-01 1.60697e-01 34 | 2.47487e-01 2.47487e-01 35 | 3.44720e-01 2.89254e-01 36 | 3.88909e-01 3.88909e-01 37 | 4.97929e-01 4.17812e-01 38 | 5.30330e-01 5.30330e-01 39 | 6.51138e-01 5.46369e-01 40 | 6.71751e-01 6.71751e-01 41 | 1.60697e-01 1.91511e-01 42 | 2.00752e-01 2.86703e-01 43 | 2.89254e-01 3.44720e-01 44 | 3.15467e-01 4.50534e-01 45 | 4.17812e-01 4.97929e-01 46 | 4.30182e-01 6.14364e-01 47 | 5.46369e-01 6.51138e-01 48 | 5.44898e-01 7.78194e-01 49 | 1.25000e-01 2.16506e-01 50 | 1.47916e-01 3.17208e-01 51 | 2.25000e-01 3.89711e-01 52 | 2.32440e-01 4.98469e-01 53 | 3.25000e-01 5.62917e-01 54 | 3.16964e-01 6.79731e-01 55 | 4.25000e-01 7.36122e-01 56 | 4.01487e-01 8.60992e-01 57 | 8.55050e-02 2.34923e-01 58 | 9.05867e-02 3.38074e-01 59 | 1.53909e-01 4.22862e-01 60 | 1.42350e-01 5.31259e-01 61 | 2.22313e-01 6.10800e-01 62 | 1.94114e-01 7.24444e-01 63 | 2.90717e-01 7.98739e-01 64 | 2.45878e-01 9.17630e-01 65 | 4.34120e-02 2.46202e-01 66 | 3.05045e-02 3.48668e-01 67 | 7.81417e-02 4.43163e-01 68 | 4.79357e-02 5.47907e-01 69 | 1.12871e-01 6.40125e-01 70 | 6.53668e-02 7.47146e-01 71 | 1.47601e-01 8.37087e-01 72 | 8.27980e-02 9.46385e-01 73 | 1.53081e-17 2.50000e-01 74 | -3.05045e-02 3.48668e-01 75 | 2.75546e-17 4.50000e-01 76 | -4.79357e-02 5.47907e-01 77 | 3.98010e-17 6.50000e-01 78 | -6.53668e-02 7.47146e-01 79 | 5.20475e-17 8.50000e-01 80 | -8.27980e-02 9.46385e-01 81 | -4.34120e-02 2.46202e-01 82 | -9.05867e-02 3.38074e-01 83 | -7.81417e-02 4.43163e-01 84 | -1.42350e-01 5.31259e-01 85 | -1.12871e-01 6.40125e-01 86 | -1.94114e-01 7.24444e-01 87 | -1.47601e-01 8.37087e-01 88 | -2.45878e-01 9.17630e-01 89 | -8.55050e-02 2.34923e-01 90 | -1.47916e-01 3.17208e-01 91 | -1.53909e-01 4.22862e-01 92 | -2.32440e-01 4.98469e-01 93 | -2.22313e-01 6.10800e-01 94 | -3.16964e-01 6.79731e-01 95 | -2.90717e-01 7.98739e-01 96 | -4.01487e-01 8.60992e-01 97 | -1.25000e-01 2.16506e-01 98 | -2.00752e-01 2.86703e-01 99 | -2.25000e-01 3.89711e-01 100 | -3.15467e-01 4.50534e-01 101 | -3.25000e-01 5.62917e-01 102 | -4.30182e-01 6.14364e-01 103 | -4.25000e-01 7.36122e-01 104 | -5.44898e-01 7.78194e-01 105 | -1.60697e-01 1.91511e-01 106 | -2.47487e-01 2.47487e-01 107 | -2.89254e-01 3.44720e-01 108 | -3.88909e-01 3.88909e-01 109 | -4.17812e-01 4.97929e-01 110 | -5.30330e-01 5.30330e-01 111 | -5.46369e-01 6.51138e-01 112 | -6.71751e-01 6.71751e-01 113 | -1.91511e-01 1.60697e-01 114 | -2.86703e-01 2.00752e-01 115 | -3.44720e-01 2.89254e-01 116 | -4.50534e-01 3.15467e-01 117 | -4.97929e-01 4.17812e-01 118 | -6.14364e-01 4.30182e-01 119 | -6.51138e-01 5.46369e-01 120 | -7.78194e-01 5.44898e-01 121 | -2.16506e-01 1.25000e-01 122 | -3.17208e-01 1.47916e-01 123 | -3.89711e-01 2.25000e-01 124 | -4.98469e-01 2.32440e-01 125 | -5.62917e-01 3.25000e-01 126 | -6.79731e-01 3.16964e-01 127 | -7.36122e-01 4.25000e-01 128 | -8.60992e-01 4.01487e-01 129 | -2.34923e-01 8.55050e-02 130 | -3.38074e-01 9.05867e-02 131 | -4.22862e-01 1.53909e-01 132 | -5.31259e-01 1.42350e-01 133 | -6.10800e-01 2.22313e-01 134 | -7.24444e-01 1.94114e-01 135 | -7.98739e-01 2.90717e-01 136 | -9.17630e-01 2.45878e-01 137 | -2.46202e-01 4.34120e-02 138 | -3.48668e-01 3.05045e-02 139 | -4.43163e-01 7.81417e-02 140 | -5.47907e-01 4.79357e-02 141 | -6.40125e-01 1.12871e-01 142 | -7.47146e-01 6.53668e-02 143 | -8.37087e-01 1.47601e-01 144 | -9.46385e-01 8.27980e-02 145 | -2.50000e-01 3.06162e-17 146 | -3.48668e-01 -3.05045e-02 147 | -4.50000e-01 5.51091e-17 148 | -5.47907e-01 -4.79357e-02 149 | -6.50000e-01 7.96020e-17 150 | -7.47146e-01 -6.53668e-02 151 | -8.50000e-01 1.04095e-16 152 | -9.46385e-01 -8.27980e-02 153 | -2.46202e-01 -4.34120e-02 154 | -3.38074e-01 -9.05867e-02 155 | -4.43163e-01 -7.81417e-02 156 | -5.31259e-01 -1.42350e-01 157 | -6.40125e-01 -1.12871e-01 158 | -7.24444e-01 -1.94114e-01 159 | -8.37087e-01 -1.47601e-01 160 | -9.17630e-01 -2.45878e-01 161 | -2.34923e-01 -8.55050e-02 162 | -3.17208e-01 -1.47916e-01 163 | -4.22862e-01 -1.53909e-01 164 | -4.98469e-01 -2.32440e-01 165 | -6.10800e-01 -2.22313e-01 166 | -6.79731e-01 -3.16964e-01 167 | -7.98739e-01 -2.90717e-01 168 | -8.60992e-01 -4.01487e-01 169 | -2.16506e-01 -1.25000e-01 170 | -2.86703e-01 -2.00752e-01 171 | -3.89711e-01 -2.25000e-01 172 | -4.50534e-01 -3.15467e-01 173 | -5.62917e-01 -3.25000e-01 174 | -6.14364e-01 -4.30182e-01 175 | -7.36122e-01 -4.25000e-01 176 | -7.78194e-01 -5.44898e-01 177 | -1.91511e-01 -1.60697e-01 178 | -2.47487e-01 -2.47487e-01 179 | -3.44720e-01 -2.89254e-01 180 | -3.88909e-01 -3.88909e-01 181 | -4.97929e-01 -4.17812e-01 182 | -5.30330e-01 -5.30330e-01 183 | -6.51138e-01 -5.46369e-01 184 | -6.71751e-01 -6.71751e-01 185 | -1.60697e-01 -1.91511e-01 186 | -2.00752e-01 -2.86703e-01 187 | -2.89254e-01 -3.44720e-01 188 | -3.15467e-01 -4.50534e-01 189 | -4.17812e-01 -4.97929e-01 190 | -4.30182e-01 -6.14364e-01 191 | -5.46369e-01 -6.51138e-01 192 | -5.44898e-01 -7.78194e-01 193 | -1.25000e-01 -2.16506e-01 194 | -1.47916e-01 -3.17208e-01 195 | -2.25000e-01 -3.89711e-01 196 | -2.32440e-01 -4.98469e-01 197 | -3.25000e-01 -5.62917e-01 198 | -3.16964e-01 -6.79731e-01 199 | -4.25000e-01 -7.36122e-01 200 | -4.01487e-01 -8.60992e-01 201 | -8.55050e-02 -2.34923e-01 202 | -9.05867e-02 -3.38074e-01 203 | -1.53909e-01 -4.22862e-01 204 | -1.42350e-01 -5.31259e-01 205 | -2.22313e-01 -6.10800e-01 206 | -1.94114e-01 -7.24444e-01 207 | -2.90717e-01 -7.98739e-01 208 | -2.45878e-01 -9.17630e-01 209 | -4.34120e-02 -2.46202e-01 210 | -3.05045e-02 -3.48668e-01 211 | -7.81417e-02 -4.43163e-01 212 | -4.79357e-02 -5.47907e-01 213 | -1.12871e-01 -6.40125e-01 214 | -6.53668e-02 -7.47146e-01 215 | -1.47601e-01 -8.37087e-01 216 | -8.27980e-02 -9.46385e-01 217 | -4.59243e-17 -2.50000e-01 218 | 3.05045e-02 -3.48668e-01 219 | -8.26637e-17 -4.50000e-01 220 | 4.79357e-02 -5.47907e-01 221 | -1.19403e-16 -6.50000e-01 222 | 6.53668e-02 -7.47146e-01 223 | -1.56142e-16 -8.50000e-01 224 | 8.27980e-02 -9.46385e-01 225 | 4.34120e-02 -2.46202e-01 226 | 9.05867e-02 -3.38074e-01 227 | 7.81417e-02 -4.43163e-01 228 | 1.42350e-01 -5.31259e-01 229 | 1.12871e-01 -6.40125e-01 230 | 1.94114e-01 -7.24444e-01 231 | 1.47601e-01 -8.37087e-01 232 | 2.45878e-01 -9.17630e-01 233 | 8.55050e-02 -2.34923e-01 234 | 1.47916e-01 -3.17208e-01 235 | 1.53909e-01 -4.22862e-01 236 | 2.32440e-01 -4.98469e-01 237 | 2.22313e-01 -6.10800e-01 238 | 3.16964e-01 -6.79731e-01 239 | 2.90717e-01 -7.98739e-01 240 | 4.01487e-01 -8.60992e-01 241 | 1.25000e-01 -2.16506e-01 242 | 2.00752e-01 -2.86703e-01 243 | 2.25000e-01 -3.89711e-01 244 | 3.15467e-01 -4.50534e-01 245 | 3.25000e-01 -5.62917e-01 246 | 4.30182e-01 -6.14364e-01 247 | 4.25000e-01 -7.36122e-01 248 | 5.44898e-01 -7.78194e-01 249 | 1.60697e-01 -1.91511e-01 250 | 2.47487e-01 -2.47487e-01 251 | 2.89254e-01 -3.44720e-01 252 | 3.88909e-01 -3.88909e-01 253 | 4.17812e-01 -4.97929e-01 254 | 5.30330e-01 -5.30330e-01 255 | 5.46369e-01 -6.51138e-01 256 | 6.71751e-01 -6.71751e-01 257 | 1.91511e-01 -1.60697e-01 258 | 2.86703e-01 -2.00752e-01 259 | 3.44720e-01 -2.89254e-01 260 | 4.50534e-01 -3.15467e-01 261 | 4.97929e-01 -4.17812e-01 262 | 6.14364e-01 -4.30182e-01 263 | 6.51138e-01 -5.46369e-01 264 | 7.78194e-01 -5.44898e-01 265 | 2.16506e-01 -1.25000e-01 266 | 3.17208e-01 -1.47916e-01 267 | 3.89711e-01 -2.25000e-01 268 | 4.98469e-01 -2.32440e-01 269 | 5.62917e-01 -3.25000e-01 270 | 6.79731e-01 -3.16964e-01 271 | 7.36122e-01 -4.25000e-01 272 | 8.60992e-01 -4.01487e-01 273 | 2.34923e-01 -8.55050e-02 274 | 3.38074e-01 -9.05867e-02 275 | 4.22862e-01 -1.53909e-01 276 | 5.31259e-01 -1.42350e-01 277 | 6.10800e-01 -2.22313e-01 278 | 7.24444e-01 -1.94114e-01 279 | 7.98739e-01 -2.90717e-01 280 | 9.17630e-01 -2.45878e-01 281 | 2.46202e-01 -4.34120e-02 282 | 3.48668e-01 -3.05045e-02 283 | 4.43163e-01 -7.81417e-02 284 | 5.47907e-01 -4.79357e-02 285 | 6.40125e-01 -1.12871e-01 286 | 7.47146e-01 -6.53668e-02 287 | 8.37087e-01 -1.47601e-01 288 | 9.46385e-01 -8.27980e-02 289 | -------------------------------------------------------------------------------- /tests/data/triangles.txt: -------------------------------------------------------------------------------- 1 | 178 171 179 2 | 179 171 180 3 | 167 166 159 4 | 174 167 175 5 | 166 167 174 6 | 188 179 180 7 | 188 187 179 8 | 196 187 188 9 | 111 110 103 10 | 79 87 86 11 | 86 87 94 12 | 6 287 7 13 | 13 22 21 14 | 63 62 55 15 | 255 262 254 16 | 143 151 150 17 | 182 174 175 18 | 180 171 172 19 | 159 166 158 20 | 166 157 158 21 | 158 151 159 22 | 158 157 149 23 | 149 150 158 24 | 158 150 151 25 | 205 206 214 26 | 155 162 154 27 | 131 138 130 28 | 147 155 154 29 | 195 187 196 30 | 170 171 178 31 | 178 169 170 32 | 199 206 198 33 | 190 191 198 34 | 198 191 199 35 | 238 239 246 36 | 246 237 238 37 | 231 239 238 38 | 124 115 116 39 | 116 117 124 40 | 118 111 119 41 | 110 111 118 42 | 126 118 119 43 | 117 118 126 44 | 123 115 124 45 | 124 132 123 46 | 123 132 131 47 | 122 115 123 48 | 123 130 122 49 | 131 130 123 50 | 108 116 107 51 | 107 116 115 52 | 95 94 87 53 | 38 37 29 54 | 38 31 39 55 | 39 46 38 56 | 38 46 37 57 | 30 29 21 58 | 23 31 30 59 | 21 22 30 60 | 30 22 23 61 | 30 38 29 62 | 31 38 30 63 | 11 10 3 64 | 283 4 3 65 | 21 29 28 66 | 22 13 14 67 | 14 13 5 68 | 14 6 7 69 | 5 6 14 70 | 285 6 5 71 | 5 4 285 72 | 11 3 12 73 | 12 3 4 74 | 5 13 12 75 | 12 4 5 76 | 86 94 85 77 | 71 79 78 78 | 78 79 86 79 | 246 239 247 80 | 247 254 246 81 | 255 254 247 82 | 270 261 262 83 | 269 261 270 84 | 262 261 253 85 | 253 254 262 86 | 268 267 259 87 | 268 261 269 88 | 259 267 266 89 | 181 188 180 90 | 181 182 190 91 | 183 191 190 92 | 190 182 183 93 | 183 182 175 94 | 166 174 165 95 | 165 172 164 96 | 164 157 165 97 | 165 157 166 98 | 174 182 173 99 | 180 172 173 100 | 173 165 174 101 | 172 165 173 102 | 173 181 180 103 | 182 181 173 104 | 207 206 199 105 | 207 214 206 106 | 215 214 207 107 | 162 170 161 108 | 161 170 169 109 | 176 169 177 110 | 177 169 178 111 | 178 179 186 112 | 179 187 186 113 | 186 177 178 114 | 185 177 186 115 | 122 130 121 116 | 146 147 154 117 | 154 145 146 118 | 148 147 139 119 | 139 138 131 120 | 147 146 139 121 | 139 146 138 122 | 164 155 156 123 | 155 147 156 124 | 156 147 148 125 | 149 157 156 126 | 156 157 164 127 | 156 148 149 128 | 219 211 220 129 | 220 211 212 130 | 204 195 196 131 | 205 212 204 132 | 162 155 163 133 | 163 170 162 134 | 171 170 163 135 | 164 172 163 136 | 163 172 171 137 | 163 155 164 138 | 196 188 189 139 | 190 198 189 140 | 189 181 190 141 | 188 181 189 142 | 243 251 250 143 | 243 244 252 144 | 252 251 243 145 | 227 226 219 146 | 234 226 227 147 | 246 254 245 148 | 252 244 245 149 | 245 253 252 150 | 254 253 245 151 | 245 237 246 152 | 245 244 237 153 | 231 238 230 154 | 230 223 231 155 | 117 116 109 156 | 109 118 117 157 | 109 116 108 158 | 110 118 109 159 | 127 126 119 160 | 125 132 124 161 | 124 117 125 162 | 117 126 125 163 | 125 133 132 164 | 104 105 112 165 | 114 105 106 166 | 114 115 122 167 | 106 107 114 168 | 114 107 115 169 | 103 110 102 170 | 94 95 102 171 | 102 95 103 172 | 54 62 53 173 | 55 62 54 174 | 45 44 37 175 | 37 46 45 176 | 46 54 45 177 | 45 52 44 178 | 53 52 45 179 | 45 54 53 180 | 267 268 276 181 | 269 277 276 182 | 276 268 269 183 | 11 19 18 184 | 18 10 11 185 | 19 26 18 186 | 18 9 10 187 | 19 28 27 188 | 34 26 27 189 | 27 26 19 190 | 37 44 36 191 | 29 37 36 192 | 36 28 29 193 | 36 27 28 194 | 23 22 15 195 | 22 14 15 196 | 15 14 7 197 | 20 19 11 198 | 20 28 19 199 | 11 12 20 200 | 20 13 21 201 | 21 28 20 202 | 20 12 13 203 | 99 107 106 204 | 99 100 108 205 | 108 107 99 206 | 48 41 49 207 | 86 85 77 208 | 77 78 86 209 | 62 63 70 210 | 70 63 71 211 | 71 78 70 212 | 53 62 61 213 | 62 70 61 214 | 263 262 255 215 | 271 270 263 216 | 263 270 262 217 | 274 266 267 218 | 260 268 259 219 | 259 251 260 220 | 260 251 252 221 | 260 253 261 222 | 252 253 260 223 | 261 268 260 224 | 258 251 259 225 | 259 266 258 226 | 250 251 258 227 | 43 52 51 228 | 44 52 43 229 | 142 135 143 230 | 143 150 142 231 | 153 161 160 232 | 153 145 154 233 | 154 162 153 234 | 162 161 153 235 | 168 169 176 236 | 160 161 168 237 | 168 161 169 238 | 185 186 194 239 | 187 195 194 240 | 194 195 202 241 | 194 186 187 242 | 112 105 113 243 | 122 121 113 244 | 105 114 113 245 | 113 114 122 246 | 129 130 138 247 | 129 121 130 248 | 213 220 212 249 | 213 212 205 250 | 205 214 213 251 | 221 220 213 252 | 212 211 203 253 | 195 204 203 254 | 203 204 212 255 | 202 195 203 256 | 211 210 203 257 | 203 210 202 258 | 197 198 206 259 | 197 204 196 260 | 196 189 197 261 | 197 189 198 262 | 197 206 205 263 | 205 204 197 264 | 237 244 236 265 | 234 227 235 266 | 244 243 235 267 | 227 236 235 268 | 235 236 244 269 | 126 127 134 270 | 134 125 126 271 | 134 127 135 272 | 133 125 134 273 | 135 142 134 274 | 134 142 133 275 | 94 102 93 276 | 93 85 94 277 | 108 100 101 278 | 101 102 110 279 | 101 109 108 280 | 110 109 101 281 | 100 93 101 282 | 101 93 102 283 | 51 52 60 284 | 60 52 53 285 | 60 61 68 286 | 53 61 60 287 | 47 46 39 288 | 47 54 46 289 | 55 54 47 290 | 286 285 277 291 | 286 279 287 292 | 287 6 286 293 | 6 285 286 294 | 278 277 269 295 | 271 279 278 296 | 269 270 278 297 | 278 270 271 298 | 278 286 277 299 | 279 286 278 300 | 267 276 275 301 | 275 274 267 302 | 277 285 284 303 | 284 285 4 304 | 284 276 277 305 | 284 4 283 306 | 284 275 276 307 | 283 275 284 308 | 88 89 96 309 | 98 89 90 310 | 98 99 106 311 | 92 93 100 312 | 85 93 92 313 | 73 72 65 314 | 65 74 73 315 | 75 74 67 316 | 42 43 50 317 | 50 43 51 318 | 41 42 50 319 | 50 49 41 320 | 48 49 56 321 | 76 67 68 322 | 75 67 76 323 | 242 233 234 324 | 242 241 233 325 | 250 241 242 326 | 242 243 250 327 | 242 235 243 328 | 234 235 242 329 | 283 3 2 330 | 2 3 10 331 | 249 241 250 332 | 250 258 249 333 | 35 42 34 334 | 34 27 35 335 | 35 43 42 336 | 27 36 35 337 | 35 36 44 338 | 44 43 35 339 | 141 142 150 340 | 149 148 141 341 | 141 150 149 342 | 133 142 141 343 | 137 146 145 344 | 138 146 137 345 | 137 129 138 346 | 145 144 137 347 | 222 214 215 348 | 222 213 214 349 | 215 223 222 350 | 221 213 222 351 | 223 230 222 352 | 222 230 221 353 | 229 238 237 354 | 237 236 229 355 | 229 230 238 356 | 221 230 229 357 | 228 236 227 358 | 219 220 228 359 | 228 227 219 360 | 228 220 221 361 | 228 229 236 362 | 221 229 228 363 | 40 41 48 364 | 8 9 16 365 | 25 26 34 366 | 104 96 97 367 | 105 104 97 368 | 97 96 89 369 | 106 105 97 370 | 89 98 97 371 | 97 98 106 372 | 90 83 91 373 | 99 98 91 374 | 91 98 90 375 | 100 99 91 376 | 83 92 91 377 | 91 92 100 378 | 72 73 80 379 | 82 73 74 380 | 82 83 90 381 | 75 83 82 382 | 82 74 75 383 | 58 50 51 384 | 49 50 58 385 | 65 72 64 386 | 69 70 78 387 | 69 76 68 388 | 68 61 69 389 | 69 61 70 390 | 77 76 69 391 | 78 77 69 392 | 84 83 75 393 | 84 92 83 394 | 75 76 84 395 | 84 77 85 396 | 85 92 84 397 | 84 76 77 398 | 233 241 240 399 | 240 232 233 400 | 176 177 184 401 | 184 177 185 402 | 193 194 202 403 | 185 194 193 404 | 226 217 218 405 | 218 210 211 406 | 219 226 218 407 | 218 211 219 408 | 216 217 224 409 | 257 264 256 410 | 256 249 257 411 | 257 258 266 412 | 257 249 258 413 | 132 133 140 414 | 133 141 140 415 | 140 141 148 416 | 131 132 140 417 | 148 139 140 418 | 140 139 131 419 | 121 129 128 420 | 145 153 152 421 | 152 153 160 422 | 152 144 145 423 | 24 25 32 424 | 33 42 41 425 | 34 42 33 426 | 33 25 34 427 | 33 40 32 428 | 41 40 33 429 | 32 25 33 430 | 90 89 81 431 | 73 82 81 432 | 81 82 90 433 | 88 80 81 434 | 81 89 88 435 | 81 80 73 436 | 59 58 51 437 | 59 60 68 438 | 51 60 59 439 | 68 67 59 440 | 49 58 57 441 | 57 64 56 442 | 57 56 49 443 | 65 64 57 444 | 248 249 256 445 | 241 249 248 446 | 248 240 241 447 | 192 184 185 448 | 192 193 200 449 | 185 193 192 450 | 209 217 216 451 | 210 218 209 452 | 209 218 217 453 | 232 224 225 454 | 233 232 225 455 | 225 224 217 456 | 234 233 225 457 | 225 226 234 458 | 225 217 226 459 | 2 281 282 460 | 274 275 282 461 | 282 275 283 462 | 283 2 282 463 | 1 9 8 464 | 1 2 10 465 | 10 9 1 466 | 1 281 2 467 | 266 274 265 468 | 265 257 266 469 | 264 257 265 470 | 112 113 120 471 | 120 113 121 472 | 121 128 120 473 | 129 137 136 474 | 136 137 144 475 | 136 128 129 476 | 17 24 16 477 | 17 16 9 478 | 25 24 17 479 | 9 18 17 480 | 17 18 26 481 | 26 25 17 482 | 66 67 74 483 | 58 59 66 484 | 66 59 67 485 | 66 74 65 486 | 66 57 58 487 | 65 57 66 488 | 208 209 216 489 | 201 193 202 490 | 202 210 201 491 | 210 209 201 492 | 201 208 200 493 | 200 193 201 494 | 209 208 201 495 | 0 280 281 496 | 281 1 0 497 | 0 1 8 498 | 264 265 272 499 | 273 282 281 500 | 274 282 273 501 | 273 265 274 502 | 280 272 273 503 | 281 280 273 504 | 273 272 265 505 | 8 16 272 506 | 216 224 272 507 | 272 64 72 508 | 256 264 272 509 | 272 168 176 510 | 272 24 32 511 | 272 104 112 512 | 88 96 272 513 | 48 56 272 514 | 272 192 200 515 | 160 168 272 516 | 272 40 48 517 | 272 96 104 518 | 272 80 88 519 | 176 184 272 520 | 272 136 144 521 | 272 208 216 522 | 32 40 272 523 | 272 152 160 524 | 72 80 272 525 | 56 64 272 526 | 232 240 272 527 | 272 248 256 528 | 272 224 232 529 | 112 120 272 530 | 144 152 272 531 | 16 24 272 532 | 240 248 272 533 | 184 192 272 534 | 272 0 8 535 | 272 120 128 536 | 128 136 272 537 | 200 208 272 538 | 280 0 272 539 | -------------------------------------------------------------------------------- /examples/spatial_indexing_2d_grids.py: -------------------------------------------------------------------------------- 1 | """ 2 | Spatial indexing of 2D grids 3 | ============================ 4 | 5 | The goal of a cell tree is to quickly locate cells of an unstructured mesh. 6 | Unstructured meshes are challenging in this regard: for a given point, we 7 | cannot simply compute a row and column number as we would for structured data 8 | such as rasters. The most straightforward procedure is checking every single 9 | cell, until we find the cell which contains the point we're looking for. This 10 | is clearly not efficient. 11 | 12 | A cell tree is bounding volume hierarchy (BVH) which may be used as a spatial 13 | index. A spatial index is a data structure to search a spatial object 14 | efficiently, without exhaustively checking every cell. The implementation in 15 | ``numba_celltree`` provides four ways to search the tree: 16 | 17 | * Locating single points 18 | * Locating bounding boxes 19 | * Locating convex polygons (e.g. cells of another mesh) 20 | * Locating line segments 21 | 22 | This example provides an introduction to searching a cell tree for each of 23 | these. 24 | 25 | We'll start by importing the required packages with matplotlib for plotting. 26 | """ 27 | 28 | import os 29 | 30 | import matplotlib.pyplot as plt 31 | import numpy as np 32 | from matplotlib.collections import LineCollection 33 | 34 | os.environ["NUMBA_DISABLE_JIT"] = "1" # small examples, avoid JIT overhead 35 | from numba_celltree import CellTree2d, demo # noqa E402 36 | 37 | # %% 38 | # Let's start with a rectangular mesh: 39 | 40 | nx = ny = 10 41 | x = y = np.linspace(0.0, 10.0, nx + 1) 42 | vertices = np.array(np.meshgrid(x, y, indexing="ij")).reshape(2, -1).T 43 | a = np.add.outer(np.arange(nx), nx * np.arange(ny)) + np.arange(ny) 44 | faces = np.array([a, a + 1, a + nx + 2, a + nx + 1]).reshape(4, -1).T 45 | 46 | # %% 47 | # Determine the edges of the cells, and plot them. 48 | 49 | node_x, node_y = vertices.transpose() 50 | edges = demo.edges(faces, -1) 51 | 52 | fig, ax = plt.subplots() 53 | demo.plot_edges(node_x, node_y, edges, ax, color="black") 54 | 55 | # %% 56 | # Locating points 57 | # --------------- 58 | # 59 | # We'll build a cell tree first, then look for some points. 60 | 61 | tree = CellTree2d(vertices, faces, -1) 62 | points = np.array( 63 | [ 64 | [-5.0, 1.0], 65 | [4.5, 2.5], 66 | [6.5, 4.5], 67 | ] 68 | ) 69 | i = tree.locate_points(points) 70 | i 71 | 72 | # %% 73 | # These numbers are the cell numbers in which we can find the points. 74 | # 75 | # A value of -1 means that a point is not located in any cell. 76 | # 77 | # Let's get rid of the -1 values, and take a look which cells have been found. 78 | # We'll color the found cells blue, and we'll draw the nodes to compare. 79 | 80 | i = i[i != -1] 81 | 82 | fig, ax = plt.subplots() 83 | ax.scatter(*points.transpose()) 84 | demo.plot_edges(node_x, node_y, edges, ax, color="black") 85 | demo.plot_edges(node_x, node_y, demo.edges(faces[i], -1), ax, color="blue", linewidth=3) 86 | 87 | # %% 88 | # Now let's try a more exotic example. 89 | vertices, faces = demo.generate_disk(5, 5) 90 | vertices += 1.0 91 | vertices *= 5.0 92 | node_x, node_y = vertices.transpose() 93 | edges = demo.edges(faces, -1) 94 | 95 | fig, ax = plt.subplots() 96 | demo.plot_edges(node_x, node_y, edges, ax, color="black") 97 | 98 | # %% 99 | # There are certainly no rows or columns to speak of! 100 | # 101 | # Let's build a new tree, and look for the same points as before. 102 | 103 | tree = CellTree2d(vertices, faces, -1) 104 | i = tree.locate_points(points) 105 | i = i[i != -1] 106 | 107 | fig, ax = plt.subplots() 108 | ax.scatter(*points.transpose()) 109 | demo.plot_edges(node_x, node_y, edges, ax, color="black") 110 | demo.plot_edges(node_x, node_y, demo.edges(faces[i], -1), ax, color="blue", linewidth=3) 111 | 112 | # %% 113 | # It should be clear by now that a point may only fall into a single cell. A 114 | # point may also be out of bounds. If a cell falls exactly on an edge, one of the 115 | # two neighbors will be chosen arbitrarily. At any rate, we can always expect 116 | # one answer per cell. 117 | # 118 | # This is not the case for line segments, bounding boxes, or convex polygons: a 119 | # line may intersect multiple cells, and a bounding box or polygon may contain 120 | # multiple cells. 121 | # 122 | # Locating bounding boxes 123 | # ----------------------- 124 | # 125 | # A search of N points will yield N answers (cell numbers). A search of N boxes 126 | # may yield M answers. To illustrate, let's look for all the cells inside of 127 | # a box. 128 | 129 | box_coords = np.array( 130 | [ 131 | [4.0, 8.0, 4.0, 6.0], # xmin, xmax, ymin, ymax 132 | ] 133 | ) 134 | box_i, cell_i = tree.locate_boxes(box_coords) 135 | 136 | fig, ax = plt.subplots() 137 | demo.plot_edges(node_x, node_y, edges, ax, color="black") 138 | demo.plot_edges( 139 | node_x, node_y, demo.edges(faces[cell_i], -1), ax, color="blue", linewidth=2 140 | ) 141 | demo.plot_boxes(box_coords, ax, color="red", linewidth=3) 142 | 143 | # %% 144 | # We can also search for multiple boxes: 145 | box_coords = np.array( 146 | [ 147 | [4.0, 8.0, 4.0, 6.0], 148 | [0.0, 8.0, 8.0, 10.0], 149 | [10.0, 13.0, 2.0, 8.0], 150 | ] 151 | ) 152 | box_i, cell_i = tree.locate_boxes(box_coords) 153 | box_i, cell_i 154 | 155 | # %% 156 | # Note that this method returns two arrays of equal length. The second array 157 | # contains the cell numbers, as usual. The first array contains the index of 158 | # the bounding box in which the respective cells fall. Note that there are only 159 | # two numbers in ``box_i``: there are no cells located in the third box, as we 160 | # can confirm visually: 161 | 162 | cells_0 = cell_i[box_i == 0] 163 | cells_1 = cell_i[box_i == 1] 164 | 165 | fig, ax = plt.subplots() 166 | demo.plot_edges(node_x, node_y, edges, ax, color="black") 167 | demo.plot_edges( 168 | node_x, node_y, demo.edges(faces[cells_0], -1), ax, color="blue", linewidth=2 169 | ) 170 | demo.plot_edges( 171 | node_x, node_y, demo.edges(faces[cells_1], -1), ax, color="green", linewidth=2 172 | ) 173 | demo.plot_boxes(box_coords, ax, color="red", linewidth=3) 174 | 175 | # %% 176 | # Locating cells 177 | # -------------- 178 | # 179 | # Similarly, we can look for other cells (convex polygons) and compute the 180 | # overlap: 181 | # 182 | # This returns three arrays of equal length: 183 | # 184 | # * the index of the face to locate 185 | # * the index of the face in the celtree 186 | # * the area of the intersection 187 | 188 | triangle_vertices = np.array( 189 | [ 190 | [5.0, 3.0], 191 | [7.0, 3.0], 192 | [7.0, 5.0], 193 | [0.0, 6.0], 194 | [4.0, 4.0], 195 | [6.0, 10.0], 196 | ] 197 | ) 198 | triangles = np.array( 199 | [ 200 | [0, 1, 2], 201 | [3, 4, 5], 202 | ] 203 | ) 204 | tri_x, tri_y = triangle_vertices.transpose() 205 | 206 | tri_i, cell_i, area = tree.intersect_faces(triangle_vertices, triangles, -1) 207 | cells_0 = cell_i[tri_i == 0] 208 | cells_1 = cell_i[tri_i == 1] 209 | 210 | fig, ax = plt.subplots() 211 | demo.plot_edges(node_x, node_y, edges, ax, color="black") 212 | demo.plot_edges( 213 | node_x, node_y, demo.edges(faces[cells_0], -1), ax, color="blue", linewidth=2 214 | ) 215 | demo.plot_edges( 216 | node_x, node_y, demo.edges(faces[cells_1], -1), ax, color="green", linewidth=2 217 | ) 218 | demo.plot_edges(tri_x, tri_y, demo.edges(triangles, -1), ax, color="red", linewidth=3) 219 | 220 | # %% 221 | # Let's color the faces of the mesh by their ratio of overlap. Because our 222 | # mesh is triangular, we can represent the triangles as two collections of 223 | # vectors (V, U). Then the area is half of the absolute value of the cross 224 | # product of U and V. 225 | 226 | intersection_faces = faces[cell_i] 227 | intersection_vertices = vertices[intersection_faces] 228 | U = intersection_vertices[:, 1] - intersection_vertices[:, 0] 229 | V = intersection_vertices[:, 2] - intersection_vertices[:, 0] 230 | full_area = 0.5 * np.abs(U[:, 0] * V[:, 1] - U[:, 1] * V[:, 0]) 231 | ratio = area / full_area 232 | 233 | fig, ax = plt.subplots() 234 | colored = ax.tripcolor( 235 | node_x, 236 | node_y, 237 | intersection_faces, 238 | ratio, 239 | ) 240 | fig.colorbar(colored) 241 | demo.plot_edges(node_x, node_y, edges, ax, color="black") 242 | demo.plot_edges(tri_x, tri_y, demo.edges(triangles, -1), ax, color="red", linewidth=3) 243 | 244 | # %% 245 | # ``CellTree2d`` also provides a method to compute overlaps between boxes and a 246 | # mesh. This may come in handy to compute overlap with a raster, for example to 247 | # rasterize a mesh. 248 | dx = 1.0 249 | xmin = 0.0 250 | xmax = 10.0 251 | 252 | dy = -1.0 253 | ymin = 0.0 254 | ymax = 10.0 255 | 256 | y, x = np.meshgrid( 257 | np.arange(ymax, ymin + dy, dy), 258 | np.arange(xmin, xmax + dx, dx), 259 | indexing="ij", 260 | ) 261 | ny = y.shape[0] - 1 262 | nx = x.shape[1] - 1 263 | coords = np.column_stack( 264 | [a.ravel() for a in [x[:-1, :-1], x[1:, 1:], y[1:, 1:], y[:-1, :-1]]] 265 | ) 266 | raster_i, cell_i, raster_overlap = tree.intersect_boxes(coords) 267 | 268 | # %% 269 | # We can construct a weight matrix with these arrays. This weight matrix stores 270 | # for every raster cell (row) the area of overlap with a triangle (column). 271 | 272 | weight_matrix = np.zeros((ny * nx, len(faces))) 273 | weight_matrix[raster_i, cell_i] = raster_overlap 274 | 275 | fig, ax = plt.subplots() 276 | colored = ax.imshow(weight_matrix) 277 | _ = fig.colorbar(colored) 278 | 279 | # %% 280 | # This weight matrix can be used for translating data from one mesh to another. 281 | # Let's generate some mock elevation data for a valley. Then, we'll compute the 282 | # area weighted mean for every raster cell. 283 | 284 | 285 | def saddle_elevation(x, y): 286 | return np.sin(0.6 * x + 2) + np.sin(0.2 * y) 287 | 288 | 289 | # Generate an elevation for every triangle 290 | centroid_x, centroid_y = vertices[faces].mean(axis=1).transpose() 291 | face_z = saddle_elevation(centroid_x, centroid_y) 292 | 293 | # Compute the weighted mean of the triangles per raster cell 294 | weighted_elevation = np.dot(weight_matrix, face_z) 295 | area_sum = np.dot(weight_matrix, np.ones(len(faces))) 296 | mean_elevation = np.full(ny * nx, np.nan) 297 | intersects = area_sum > 0 298 | mean_elevation[intersects] = weighted_elevation[intersects] / area_sum[intersects] 299 | 300 | fig = plt.figure(figsize=(10, 4)) 301 | ax0 = fig.add_subplot(1, 2, 1, projection="3d") 302 | ax0.plot_trisurf( 303 | node_x, node_y, faces, saddle_elevation(node_x, node_y), cmap="viridis" 304 | ) 305 | ax1 = fig.add_subplot(1, 2, 2) 306 | ax1.imshow(mean_elevation.reshape(ny, nx), extent=(xmin, xmax, ymin, ymax)) 307 | demo.plot_edges(node_x, node_y, edges, ax1, color="white") 308 | 309 | # %% 310 | # Such a weight matrix doesn't apply to just boxes and triangles, but to every 311 | # case of mapping one mesh to another by intersecting cell areas. Note however 312 | # that the aggregation above is not very efficient. Most of the entries in the 313 | # weight matrix are 0; a raster cell only intersects a small number triangles. 314 | # Such a matrix is much more efficiently stored and processed as a `sparse 315 | # matrix `_ (see also Scipy 316 | # `sparse `_). The 317 | # arrays returned by the ``intersect_`` methods of ``CellTree2d`` form a 318 | # coordinate list (COO). 319 | # 320 | # Such a coordinate list can also be easily used to aggregate values with 321 | # `Pandas `_, as Pandas provides 322 | # very efficient aggregation in the form of `groupby operations 323 | # `_. 324 | # 325 | # Locating lines 326 | # -------------- 327 | # 328 | # As a final example, we will compute the intersections with two lines (edges). 329 | # This once again returns three arrays of equal length: 330 | # 331 | # * the index of the line 332 | # * the index of the cell 333 | # * the location of the intersection 334 | edge_coords = np.array( 335 | [ 336 | [[0.0, 0.0], [10.0, 10.0]], 337 | [[0.0, 10.0], [10.0, 0.0]], 338 | ] 339 | ) 340 | edge_i, cell_i, intersections = tree.intersect_edges(edge_coords) 341 | edge_i, cell_i 342 | 343 | # %% 344 | # To wrap up, we'll color the intersect faces with the length of the 345 | # intersected line segments. We can easily compute the length of each segment 346 | # with the Euclidian norm (Pythagorean distance): 347 | length = np.linalg.norm(intersections[:, 1] - intersections[:, 0], axis=1) 348 | 349 | fig, ax = plt.subplots() 350 | colored = ax.tripcolor( 351 | node_x, 352 | node_y, 353 | faces[cell_i], 354 | length, 355 | ) 356 | fig.colorbar(colored) 357 | ax.add_collection(LineCollection(edge_coords, color="red", linewidth=3)) 358 | demo.plot_edges(node_x, node_y, edges, ax, color="black") 359 | 360 | # %% 361 | -------------------------------------------------------------------------------- /numba_celltree/celltree.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Tuple 2 | 3 | import numba as nb 4 | 5 | from numba_celltree.algorithms import ( 6 | area_of_intersection, 7 | barycentric_triangle_weights, 8 | barycentric_wachspress_weights, 9 | box_area_of_intersection, 10 | polygons_intersect, 11 | ) 12 | from numba_celltree.cast import cast_bboxes, cast_edges, cast_faces, cast_vertices 13 | from numba_celltree.celltree_base import ( 14 | CellTree2dBase, 15 | bbox_distances, 16 | bbox_tree, 17 | default_tolerance, 18 | ) 19 | from numba_celltree.constants import ( 20 | CellTreeData, 21 | FloatArray, 22 | IntArray, 23 | ) 24 | from numba_celltree.creation import initialize 25 | from numba_celltree.geometry_utils import build_face_bboxes, counter_clockwise 26 | from numba_celltree.query import ( 27 | locate_boxes, 28 | locate_edge_faces, 29 | locate_points, 30 | ) 31 | 32 | 33 | class CellTree2d(CellTree2dBase): 34 | """ 35 | Construct a cell tree from 2D vertices and a faces indexing array. 36 | 37 | Parameters 38 | ---------- 39 | vertices: ndarray of floats with shape ``(n_point, 2)`` 40 | Corner coordinates (x, y) of the cells. 41 | faces: ndarray of integers with shape ``(n_face, n_max_vert)`` 42 | Index identifying for every face the indices of its corner nodes. If a 43 | face has less corner nodes than ``n_max_vert``, its last indices should 44 | be equal to ``fill_value``. 45 | n_buckets: int, optional, default: 4 46 | The number of "buckets" used in tree construction. Must be higher 47 | or equal to 2. Values over 8 provide diminishing returns. 48 | cells_per_leaf: int, optional, default: 2 49 | The number of cells in the leaf nodes of the cell tree. Can be set 50 | to only 1, but this doubles memory footprint for slightly faster 51 | lookup. Increase this to reduce memory usage at the cost of lookup 52 | performance. 53 | fill_value: int, optional, default: -1 54 | Fill value marking empty nodes in ``faces``. 55 | """ 56 | 57 | def __init__( 58 | self, 59 | vertices: FloatArray, 60 | faces: IntArray, 61 | fill_value: int, 62 | n_buckets: int = 4, 63 | cells_per_leaf: int = 2, 64 | ): 65 | if n_buckets < 2: 66 | raise ValueError("n_buckets must be >= 2") 67 | if cells_per_leaf < 1: 68 | raise ValueError("cells_per_leaf must be >= 1") 69 | 70 | vertices = cast_vertices(vertices, copy=True) 71 | faces = cast_faces(faces, fill_value) 72 | counter_clockwise(vertices, faces) 73 | 74 | bb_coords = build_face_bboxes(faces, vertices) 75 | nodes, bb_indices = initialize(faces, bb_coords, n_buckets, cells_per_leaf) 76 | self.vertices = vertices 77 | self.faces = faces 78 | self.n_buckets = n_buckets 79 | self.cells_per_leaf = cells_per_leaf 80 | self.nodes = nodes 81 | self.bb_indices = bb_indices 82 | self.bb_coords = bb_coords 83 | self.bbox = bbox_tree(bb_coords) 84 | self.bb_distances = bbox_distances(bb_coords) 85 | self.celltree_data = CellTreeData( 86 | self.faces, 87 | self.vertices, 88 | self.nodes, 89 | self.bb_indices, 90 | self.bb_coords, 91 | self.bbox, 92 | self.cells_per_leaf, 93 | ) 94 | 95 | def locate_points( 96 | self, points: FloatArray, tolerance: Optional[float] = None 97 | ) -> IntArray: 98 | """ 99 | Find the index of a face that contains a point. 100 | 101 | Points that are very close near an edge of a face will also be 102 | identified as falling within that face. 103 | 104 | Parameters 105 | ---------- 106 | points: ndarray of floats with shape ``(n_point, 2)`` 107 | Coordinates of the points to be located. 108 | tolerance: float, optional 109 | The tolerance used to determine whether a point is on an edge. This 110 | is a floating point precision criterion, thus cannot be directly be 111 | interpreted as a distance. If None, the method tries to estimate an 112 | appropriate tolerance by multiplying the maximum diagonal of the 113 | bounding boxes with 1e-12. 114 | 115 | Returns 116 | ------- 117 | tree_face_indices: ndarray of integers with shape ``(n_point,)`` 118 | For every point, the index of the face it falls in. Points not 119 | falling in any faces are marked with a value of ``-1``. 120 | """ 121 | if tolerance is None: 122 | tolerance = default_tolerance(self.bb_distances[:, 2]) 123 | points = cast_vertices(points) 124 | return locate_points(points, self.celltree_data, tolerance) 125 | 126 | def locate_boxes(self, bbox_coords: FloatArray) -> Tuple[IntArray, IntArray]: 127 | """ 128 | Find the index of a face intersecting with a bounding box. 129 | 130 | Parameters 131 | ---------- 132 | bbox_coords: ndarray of floats with shape ``(n_box, 4)`` 133 | Every row containing ``(xmin, xmax, ymin, ymax)``. 134 | 135 | Returns 136 | ------- 137 | bbox_indices: ndarray of integers with shape ``(n_found,)`` 138 | Indices of the bounding box. 139 | tree_face_indices: ndarray of integers with shape ``(n_found,)`` 140 | Indices of the face. 141 | """ 142 | bbox_coords = cast_bboxes(bbox_coords) 143 | n_chunks = nb.get_num_threads() 144 | return locate_boxes(bbox_coords, self.celltree_data, n_chunks) 145 | 146 | def intersect_boxes( 147 | self, bbox_coords: FloatArray 148 | ) -> Tuple[IntArray, IntArray, FloatArray]: 149 | """ 150 | Find the index of a box intersecting with a face, and the area 151 | of intersection. 152 | 153 | Parameters 154 | ---------- 155 | bbox_coords: ndarray of floats with shape ``(n_box, 4)`` 156 | Every row containing ``(xmin, xmax, ymin, ymax)``. 157 | 158 | Returns 159 | ------- 160 | bbox_indices: ndarray of integers with shape ``(n_found,)`` 161 | Indices of the bounding box. 162 | tree_face_indices: ndarray of integers with shape ``(n_found,)`` 163 | Indices of the tree faces. 164 | area: ndarray of floats with shape ``(n_found,)`` 165 | Area of intersection between the two intersecting faces. 166 | """ 167 | bbox_coords = cast_bboxes(bbox_coords) 168 | n_chunks = nb.get_num_threads() 169 | i, j = locate_boxes(bbox_coords, self.celltree_data, n_chunks) 170 | area = box_area_of_intersection( 171 | bbox_coords=bbox_coords, 172 | vertices=self.vertices, 173 | faces=self.faces, 174 | indices_bbox=i, 175 | indices_face=j, 176 | ) 177 | # Separating axes declares polygons with shared edges as touching. 178 | # Make sure we only include actual intersections. 179 | actual = area > 0 180 | return i[actual], j[actual], area[actual] 181 | 182 | def locate_faces( 183 | self, vertices: FloatArray, faces: IntArray 184 | ) -> Tuple[IntArray, IntArray]: 185 | """ 186 | Find the index of a face intersecting with another face. 187 | 188 | Only sharing an edge also counts as an intersection, due to the use of 189 | the separating axis theorem to define intersection. The area of the 190 | overlap is zero in such a case. 191 | 192 | Parameters 193 | ---------- 194 | vertices: ndarray of floats with shape ``(n_point, 2)`` 195 | Corner coordinates (x, y) of the cells. 196 | faces: ndarray of integers with shape ``(n_face, n_max_vert)`` 197 | Index identifying for every face the indices of its corner nodes. 198 | If a face has less corner nodes than n_max_vert, its last indices 199 | should be equal to ``fill_value``. 200 | 201 | Returns 202 | ------- 203 | face_indices: ndarray of integers with shape ``(n_found,)`` 204 | Indices of the faces. 205 | tree_face_indices: ndarray of integers with shape ``(n_found,)`` 206 | Indices of the tree faces. 207 | """ 208 | counter_clockwise(vertices, faces) 209 | bbox_coords = build_face_bboxes(faces, vertices) 210 | n_chunks = nb.get_num_threads() 211 | shortlist_i, shortlist_j = locate_boxes( 212 | bbox_coords, self.celltree_data, n_chunks 213 | ) 214 | intersects = polygons_intersect( 215 | vertices_a=vertices, 216 | vertices_b=self.vertices, 217 | faces_a=faces, 218 | faces_b=self.faces, 219 | indices_a=shortlist_i, 220 | indices_b=shortlist_j, 221 | ) 222 | return shortlist_i[intersects], shortlist_j[intersects] 223 | 224 | def intersect_faces( 225 | self, vertices: FloatArray, faces: IntArray, fill_value: int 226 | ) -> Tuple[IntArray, IntArray, FloatArray]: 227 | """ 228 | Find the index of a face intersecting with another face, and the area 229 | of intersection. 230 | 231 | Parameters 232 | ---------- 233 | vertices: ndarray of floats with shape ``(n_point, 2)`` 234 | Corner coordinates (x, y) of the cells. 235 | faces: ndarray of integers with shape ``(n_face, n_max_vert)`` 236 | Index identifying for every face the indices of its corner nodes. 237 | If a face has less corner nodes than n_max_vert, its last indices 238 | should be equal to ``fill_value``. 239 | fill_value: int, optional, default: -1 240 | Fill value marking empty nodes in ``faces``. 241 | 242 | Returns 243 | ------- 244 | face_indices: ndarray of integers with shape ``(n_found,)`` 245 | Indices of the faces. 246 | tree_face_indices: ndarray of integers with shape ``(n_found,)`` 247 | Indices of the tree faces. 248 | area: ndarray of floats with shape ``(n_found,)`` 249 | Area of intersection between the two intersecting faces. 250 | """ 251 | vertices = cast_vertices(vertices) 252 | faces = cast_faces(faces, fill_value) 253 | i, j = self.locate_faces(vertices, faces) 254 | area = area_of_intersection( 255 | vertices_a=vertices, 256 | vertices_b=self.vertices, 257 | faces_a=faces, 258 | faces_b=self.faces, 259 | indices_a=i, 260 | indices_b=j, 261 | ) 262 | # Separating axes declares polygons with shared edges as touching. 263 | # Make sure we only include actual intersections. 264 | actual = area > 0 265 | return i[actual], j[actual], area[actual] 266 | 267 | def intersect_edges( 268 | self, 269 | edge_coords: FloatArray, 270 | ) -> Tuple[IntArray, IntArray, FloatArray]: 271 | """ 272 | Find the index of a face intersecting with an edge. 273 | 274 | Parameters 275 | ---------- 276 | edge_coords: ndarray of floats with shape ``(n_edge, 2, 2)`` 277 | Every row containing ``((x0, y0), (x1, y1))``. 278 | 279 | Returns 280 | ------- 281 | edge_indices: ndarray of integers with shape ``(n_found,)`` 282 | Indices of the bounding box. 283 | tree_face_indices: ndarray of integers with shape ``(n_found,)`` 284 | Indices of the face. 285 | intersection_edges: ndarray of floats with shape ``(n_found, 2, 2)`` 286 | The resulting intersected edges, every row containing: 287 | ``((x0, y0), (x1, y1))``. 288 | The length of each intersected edge can be computed with: 289 | ``np.linalg.norm(intersections[:, 1] - intersections[:, 0], axis=1)``. 290 | """ 291 | edge_coords = cast_edges(edge_coords) 292 | n_chunks = nb.get_num_threads() 293 | return locate_edge_faces(edge_coords, self.celltree_data, n_chunks) 294 | 295 | def compute_barycentric_weights( 296 | self, 297 | points: FloatArray, 298 | tolerance: Optional[float] = None, 299 | ) -> Tuple[IntArray, FloatArray]: 300 | """ 301 | Compute barycentric weights for points located inside of the grid. 302 | 303 | Parameters 304 | ---------- 305 | points: ndarray of floats with shape ``(n_point, 2)`` 306 | Coordinates of the points to be located. 307 | tolerance: float, optional 308 | The tolerance used to determine whether a point is on an edge. This 309 | is a floating point precision criterion, thus cannot be directly be 310 | interpreted as a distance. If None, the method tries to estimate an 311 | appropriate tolerance by multiplying the maximum diagonal of the 312 | bounding boxes with 1e-12. 313 | 314 | Returns 315 | ------- 316 | tree_face_indices: ndarray of integers with shape ``(n_point,)`` 317 | For every point, the index of the face it falls in. Points not 318 | falling in any faces are marked with a value of ``-1``. 319 | barycentric_weights: ndarray of integers with shape ``(n_point, n_max_vert)`` 320 | For every point, the barycentric weights of the vertices of the 321 | face in which the point is located. For points not falling in any 322 | faces, the weight of all vertices is 0. 323 | """ 324 | if tolerance is None: 325 | tolerance = default_tolerance(self.bb_distances[:, 2]) 326 | face_indices = self.locate_points(points, tolerance) 327 | n_max_vert = self.faces.shape[1] 328 | if n_max_vert > 3: 329 | f = barycentric_wachspress_weights 330 | else: 331 | f = barycentric_triangle_weights 332 | 333 | weights = f( 334 | points, 335 | face_indices, 336 | self.faces, 337 | self.vertices, 338 | tolerance, 339 | ) 340 | return face_indices, weights 341 | -------------------------------------------------------------------------------- /numba_celltree/creation.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | import numba as nb 4 | import numpy as np 5 | 6 | from numba_celltree.constants import ( 7 | FLOAT_MAX, 8 | FLOAT_MIN, 9 | INT_MAX, 10 | Bucket, 11 | BucketArray, 12 | BucketDType, 13 | FloatArray, 14 | IntArray, 15 | IntDType, 16 | Node, 17 | NodeArray, 18 | NodeDType, 19 | ) 20 | from numba_celltree.utils import ( 21 | allocate_double_stack, 22 | pop_both, 23 | push_both, 24 | ) 25 | 26 | 27 | @nb.njit(inline="always") 28 | def create_node(ptr: int, size: int, dim: bool) -> Node: 29 | return Node(-1, -1.0, -1.0, ptr, size, dim) 30 | 31 | 32 | @nb.njit(inline="always") 33 | def push_node(nodes: NodeArray, node: Node, index: int) -> int: 34 | """Push to the end of the array.""" 35 | nodes[index]["child"] = node.child 36 | nodes[index]["Lmax"] = node.Lmax 37 | nodes[index]["Rmin"] = node.Rmin 38 | nodes[index]["ptr"] = node.ptr 39 | nodes[index]["size"] = node.size 40 | nodes[index]["dim"] = node.dim 41 | return index + 1 42 | 43 | 44 | @nb.njit(inline="always") 45 | def centroid_test(bucket: np.void, box: FloatArray, dim: int): 46 | """ 47 | Test whether the centroid of the bounding box in the selected dimension falls 48 | within this bucket. 49 | """ 50 | centroid = box[2 * dim] + 0.5 * (box[2 * dim + 1] - box[2 * dim]) 51 | return (centroid >= bucket.Min) and (centroid < bucket.Max) 52 | 53 | 54 | @nb.njit(inline="never", cache=True) 55 | def stable_partition( 56 | bb_indices: IntArray, 57 | bb_coords: FloatArray, 58 | begin: int, 59 | end: int, 60 | bucket: BucketDType, 61 | dim: int, 62 | ) -> int: 63 | """ 64 | Rearrange the elements in the range(begin, end), in such a way that all 65 | the elements for which a predicate returns True precede all those for which it 66 | returns False. The relative order in each group is maintained. 67 | In this case, the predicate is a `centroid_test`. 68 | 69 | Parameters 70 | ---------- 71 | bb_indices: np.ndarray of ints 72 | Array to sort. 73 | bb_coords: np.ndarray of floats 74 | Coordinates of bounding boxes. 75 | begin, end: int 76 | Defines the range of arr in which to sort. 77 | bucket: np.void 78 | Element of BucketArray, contains data for a single bucket. 79 | dim: int 80 | Dimension number (0: x, 1: y, etc.) 81 | 82 | Returns 83 | ------- 84 | current: int 85 | Points to the first element of the second group for which predicate is True. 86 | """ 87 | # Allocates a temporary buffer, ands fill from front and back: O(N) 88 | # A swapping algorithm can be found here, O(N log(N)): 89 | # https://csjobinterview.wordpress.com/2012/03/30/array-stable-partition/ 90 | # via: https://stackoverflow.com/questions/21554635/how-is-stable-partition-an-adaptive-algorithm 91 | temp = np.empty(end - begin, dtype=bb_indices.dtype) 92 | # TODO: add statically allocated work-array? Then use views for size? 93 | 94 | count_true = 0 95 | count_false = -1 96 | for i in bb_indices[begin:end]: 97 | box = bb_coords[i] 98 | if centroid_test(bucket, box, dim): 99 | temp[count_true] = i 100 | count_true += 1 101 | else: 102 | temp[count_false] = i 103 | count_false -= 1 104 | 105 | for i in range(count_true): 106 | bb_indices[begin + i] = temp[i] 107 | 108 | start_second = begin + count_true 109 | for i in range(-1 - count_false): 110 | bb_indices[start_second + i] = temp[-i - 1] 111 | 112 | return start_second 113 | 114 | 115 | @nb.njit(inline="never", cache=True) 116 | def sort_bbox_indices( 117 | bb_indices: IntArray, 118 | bb_coords: FloatArray, 119 | buckets: BucketArray, 120 | node: NodeDType, 121 | dim: int, 122 | ): 123 | current = node.ptr 124 | end = node.ptr + node.size 125 | 126 | b = buckets[0] 127 | buckets[0] = Bucket(b.Max, b.Min, b.Rmin, b.Lmax, node.ptr, b.size) 128 | 129 | i = 1 130 | while current != end: 131 | bucket = buckets[i - 1] 132 | current = stable_partition(bb_indices, bb_coords, current, end, bucket, dim) 133 | start = bucket.index 134 | 135 | b = buckets[i - 1] 136 | buckets[i - 1] = Bucket(b.Max, b.Min, b.Rmin, b.Lmax, b.index, current - start) 137 | 138 | if i < len(buckets): 139 | b = buckets[i] 140 | buckets[i] = Bucket( 141 | b.Max, 142 | b.Min, 143 | b.Rmin, 144 | b.Lmax, 145 | buckets[i - 1].index + buckets[i - 1].size, 146 | b.size, 147 | ) 148 | 149 | start = current 150 | i += 1 151 | 152 | 153 | @nb.njit(inline="never", cache=True) 154 | def get_bounds( 155 | index: int, 156 | size: int, 157 | bb_coords: FloatArray, 158 | bb_indices: IntArray, 159 | dim: int, 160 | ): 161 | Rmin = FLOAT_MAX 162 | Lmax = FLOAT_MIN 163 | for i in range(index, index + size): 164 | data_index = bb_indices[i] 165 | value = bb_coords[data_index, 2 * dim] 166 | if value < Rmin: 167 | Rmin = value 168 | value = bb_coords[data_index, 2 * dim + 1] 169 | if value > Lmax: 170 | Lmax = value 171 | return Rmin, Lmax 172 | 173 | 174 | @nb.njit(inline="never", cache=True) 175 | def split_plane( 176 | buckets: List[Bucket], 177 | root: np.void, 178 | range_Lmax: float, 179 | range_Rmin: float, 180 | bucket_length: float, 181 | ): 182 | plane_min_cost = FLOAT_MAX 183 | plane = INT_MAX 184 | bbs_in_left = 0 185 | bbs_in_right = 0 186 | 187 | # if we split here, lmax is from bucket 0, and rmin is from bucket 1 after 188 | # computing those, we can compute the cost to split here, and if this is the 189 | # minimum, we split here. 190 | for i in range(1, len(buckets)): 191 | current_bucket = buckets[i - 1] 192 | next_bucket = buckets[i] 193 | bbs_in_left += current_bucket.size 194 | bbs_in_right = root.size - bbs_in_left 195 | left_volume = (current_bucket.Lmax - range_Rmin) / bucket_length 196 | right_volume = (range_Lmax - next_bucket.Rmin) / bucket_length 197 | plane_cost = left_volume * bbs_in_left + right_volume * bbs_in_right 198 | if plane_cost < plane_min_cost: 199 | plane_min_cost = plane_cost 200 | plane = i 201 | 202 | Lmax = FLOAT_MIN 203 | Rmin = FLOAT_MAX 204 | for i in range(plane): 205 | bLmax = buckets[i].Lmax 206 | if bLmax > Lmax: 207 | Lmax = bLmax 208 | for i in range(plane, len(buckets)): 209 | bRmin = buckets[i].Rmin 210 | if bRmin < Rmin: 211 | Rmin = bRmin 212 | 213 | return plane, Lmax, Rmin 214 | 215 | 216 | @nb.njit(cache=True) 217 | def pessimistic_n_nodes(n_elements: int): 218 | """ 219 | In the worst case, *all* branches end in a leaf with a single cell. Rather 220 | unlikely in the case of non-trivial grids, but we need a guess to 221 | pre-allocate -- overestimation is at maximum two times in case of 222 | cells_per_leaf == 2. 223 | """ 224 | n_nodes = n_elements 225 | nodes = int(np.ceil(n_elements / 2)) 226 | while nodes > 1: 227 | n_nodes += nodes 228 | nodes = int(np.ceil(nodes / 2)) 229 | # Return, add root. 230 | return n_nodes + 1 231 | 232 | 233 | @nb.njit(cache=True) 234 | def build( 235 | nodes: NodeArray, 236 | node_index: int, 237 | bb_indices: IntArray, 238 | bb_coords: FloatArray, 239 | n_buckets: int, 240 | cells_per_leaf: int, 241 | ): 242 | # Cannot compile ahead of time with Numba and recursion 243 | # Just use a stack based approach instead; store root and dim values. 244 | stack = allocate_double_stack() 245 | stack[0, 0] = 0 246 | stack[0, 1] = 0 247 | size = 1 248 | 249 | while size > 0: 250 | root_index, dim, size = pop_both(stack, size) 251 | 252 | dim_flag = dim 253 | if dim < 0: 254 | dim += 2 255 | 256 | # Fetch this root node 257 | root = Node( 258 | nodes[root_index]["child"], 259 | nodes[root_index]["Lmax"], 260 | nodes[root_index]["Rmin"], 261 | nodes[root_index]["ptr"], 262 | nodes[root_index]["size"], 263 | nodes[root_index]["dim"], 264 | ) 265 | 266 | # Is it a leaf? if so, we're done, otherwise split. 267 | if root.size <= cells_per_leaf: 268 | continue 269 | 270 | # Find bounding range of node's entire dataset in dimension 0 (x-axis). 271 | range_Rmin, range_Lmax = get_bounds( 272 | root.ptr, 273 | root.size, 274 | bb_coords, 275 | bb_indices, 276 | dim, 277 | ) 278 | bucket_length = (range_Lmax - range_Rmin) / n_buckets 279 | 280 | # Create buckets 281 | buckets = [] 282 | # Specify ranges on the buckets 283 | for i in range(n_buckets): 284 | buckets.append( 285 | Bucket( 286 | (i + 1) * bucket_length + range_Rmin, # Max 287 | i * bucket_length + range_Rmin, # Min 288 | -1.0, # Rmin 289 | -1.0, # Lmax 290 | -1, # index 291 | 0, # size 292 | ) 293 | ) 294 | # NOTE: do not change the default size (0) given to the bucket here 295 | # it is used to detect empty buckets later on. 296 | 297 | # Now that the buckets are setup, sort them 298 | sort_bbox_indices(bb_indices, bb_coords, buckets, root, dim) 299 | 300 | # Determine Lmax and Rmin for each bucket 301 | for i in range(n_buckets): 302 | Rmin, Lmax = get_bounds( 303 | buckets[i].index, buckets[i].size, bb_coords, bb_indices, dim 304 | ) 305 | b = buckets[i] 306 | buckets[i] = Bucket(b.Max, b.Min, Rmin, Lmax, b.index, b.size) 307 | 308 | # Special case: 2 bounding boxes share the same centroid, but boxes_per_leaf 309 | # is 1. This will break most of the usual bucketing code. Unless the grid has 310 | # overlapping triangles (which it shouldn't!). This is the only case to deal 311 | # with 312 | if (cells_per_leaf == 1) and (root.size == 2): 313 | nodes[root_index]["Lmax"] = range_Lmax 314 | nodes[root_index]["Rmin"] = range_Rmin 315 | left_child = create_node(root.ptr, 1, not dim) 316 | right_child = create_node(root.ptr + 1, 1, not dim) 317 | nodes[root_index]["child"] = node_index 318 | node_index = push_node(nodes, left_child, node_index) 319 | node_index = push_node(nodes, right_child, node_index) 320 | continue 321 | 322 | while buckets[0].size == 0: 323 | b = buckets[1] 324 | buckets[1] = Bucket(b.Max, buckets[0].Min, b.Rmin, b.Lmax, b.index, b.size) 325 | buckets.pop(0) 326 | 327 | i = 1 328 | while i < len(buckets): 329 | next_bucket = buckets[i] 330 | # if a empty bucket is encountered, merge it with the previous one and 331 | # continue as normal. As long as the ranges of the merged buckets are 332 | # still proper, calculating cost for empty buckets can be avoided, and 333 | # the split will still happen in the right place 334 | if next_bucket.size == 0: 335 | b = buckets[i - 1] 336 | buckets[i - 1] = Bucket( 337 | next_bucket.Max, b.Min, b.Rmin, b.Lmax, b.index, b.size 338 | ) 339 | buckets.pop(i) 340 | else: 341 | i += 1 342 | 343 | # Check if all the cells are in one bucket. If so, restart and switch 344 | # dimension. 345 | needs_continue = False 346 | for bucket in buckets: 347 | if bucket.size == root.size: 348 | needs_continue = True 349 | if dim_flag >= 0: 350 | dim_flag = (not dim) - 2 351 | nodes[root_index]["dim"] = not root.dim 352 | stack, size = push_both(stack, root_index, dim_flag, size) 353 | else: # Already split once, convert to leaf. 354 | nodes[root_index]["Lmax"] = -1 355 | nodes[root_index]["Rmin"] = -1 356 | break 357 | if needs_continue: 358 | continue 359 | 360 | # plane is the separation line to split on: 361 | # 0 [bucket0] 1 [bucket1] 2 [bucket2] 3 [bucket3] 362 | plane, Lmax, Rmin = split_plane( 363 | buckets, root, range_Lmax, range_Rmin, bucket_length 364 | ) 365 | right_index = buckets[plane].index 366 | right_size = root.ptr + root.size - right_index 367 | left_index = root.ptr 368 | left_size = root.size - right_size 369 | nodes[root_index]["Lmax"] = Lmax 370 | nodes[root_index]["Rmin"] = Rmin 371 | left_child = create_node(left_index, left_size, not dim) 372 | right_child = create_node(right_index, right_size, not dim) 373 | nodes[root_index]["child"] = node_index 374 | child_ind = node_index 375 | node_index = push_node(nodes, left_child, node_index) 376 | node_index = push_node(nodes, right_child, node_index) 377 | 378 | stack, size = push_both(stack, child_ind + 1, right_child.dim, size) 379 | stack, size = push_both(stack, child_ind, left_child.dim, size) 380 | 381 | return node_index 382 | 383 | 384 | @nb.njit(cache=True) 385 | def initialize( 386 | elements: IntArray, 387 | bb_coords: FloatArray, 388 | n_buckets: int = 4, 389 | cells_per_leaf: int = 2, 390 | ) -> Tuple[NodeArray, IntArray]: 391 | # Prepare bounding boxes for tree building. 392 | bb_indices = np.arange(len(elements), dtype=IntDType) 393 | 394 | # Pre-allocate the space for the tree. 395 | n_elements, _ = elements.shape 396 | n_nodes = pessimistic_n_nodes(n_elements) 397 | nodes = np.empty(n_nodes, dtype=NodeDType) 398 | 399 | # Insert first node 400 | node = create_node(0, bb_indices.size, False) 401 | node_index = push_node(nodes, node, 0) 402 | 403 | node_index = build( 404 | nodes, 405 | node_index, 406 | bb_indices, 407 | bb_coords, 408 | n_buckets, 409 | cells_per_leaf, 410 | ) 411 | 412 | # Remove the unused part in nodes. 413 | return nodes[:node_index], bb_indices 414 | -------------------------------------------------------------------------------- /tests/test_geometry_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import numba as nb 4 | import numpy as np 5 | from pytest_cases import parametrize_with_cases 6 | 7 | from numba_celltree import geometry_utils as gu 8 | from numba_celltree.constants import Box, Point, Triangle, Vector 9 | 10 | TOLERANCE_ON_EDGE = 1e-9 11 | 12 | 13 | def test_to_vector(): 14 | a = Point(0.0, 0.0) 15 | b = Point(1.0, 2.0) 16 | actual = gu.to_vector(a, b) 17 | assert isinstance(actual, Vector) 18 | assert actual.x == 1.0 19 | assert actual.y == 2.0 20 | 21 | 22 | def test_as_point(): 23 | a = np.array([0.0, 1.0]) 24 | actual = gu.as_point(a) 25 | assert isinstance(actual, Point) 26 | assert actual.x == 0.0 27 | assert actual.y == 1.0 28 | 29 | 30 | def test_as_box(): 31 | a = np.array([0.0, 1.0, 2.0, 3.0]) 32 | actual = gu.as_box(a) 33 | assert isinstance(actual, Box) 34 | assert actual.xmin == 0.0 35 | assert actual.xmax == 1.0 36 | assert actual.ymin == 2.0 37 | assert actual.ymax == 3.0 38 | 39 | 40 | def test_as_triangle(): 41 | vertices = np.array( 42 | [ 43 | [0.0, 0.0], 44 | [1.0, 0.0], 45 | [1.0, 1.0], 46 | ] 47 | ) 48 | face = np.array([2, 0, 1]) 49 | actual = gu.as_triangle(vertices, face) 50 | assert isinstance(actual, Triangle) 51 | assert actual.a == Point(1.0, 1.0) 52 | assert actual.b == Point(0.0, 0.0) 53 | assert actual.c == Point(1.0, 0.0) 54 | 55 | 56 | def test_to_point(): 57 | a = Point(0.0, 0.0) 58 | b = Point(1.0, 2.0) 59 | V = gu.to_vector(a, b) 60 | t = 0.0 61 | actual = gu.to_point(t, a, V) 62 | assert np.allclose(actual, a) 63 | 64 | t = 1.0 65 | actual = gu.to_point(t, a, V) 66 | assert np.allclose(actual, b) 67 | 68 | t = 0.5 69 | actual = gu.to_point(t, a, V) 70 | assert np.allclose(actual, Point(0.5, 1.0)) 71 | 72 | 73 | def test_cross_product(): 74 | u = Vector(1.0, 2.0) 75 | v = Vector(3.0, 4.0) 76 | assert np.allclose(gu.cross_product(u, v), u.x * v.y - u.y * v.x) 77 | 78 | 79 | def test_dot_product(): 80 | u = Vector(1.0, 2.0) 81 | v = Vector(3.0, 4.0) 82 | assert np.allclose(gu.dot_product(u, v), np.dot(u, v)) 83 | 84 | 85 | def test_polygon_length(): 86 | face = np.array([0, 1, 2]) 87 | assert gu.polygon_length(face) == 3 88 | assert gu.polygon_length(face) == 3 89 | face = np.array([0, 1, 2, -1, -1]) 90 | assert gu.polygon_length(face) == 3 91 | face = np.array([0, 1, 2, 3, -1]) 92 | assert gu.polygon_length(face) == 4 93 | 94 | 95 | def test_polygon_area(): 96 | # square 97 | p = np.array( 98 | [ 99 | [0.0, 0.0], 100 | [1.0, 0.0], 101 | [1.0, 1.0], 102 | [0.0, 1.0], 103 | ] 104 | ) 105 | assert np.allclose(gu.polygon_area(p), 1.0) 106 | # triangle 107 | p = np.array( 108 | [ 109 | [0.0, 0.0], 110 | [1.0, 0.0], 111 | [1.0, 1.0], 112 | ] 113 | ) 114 | assert np.allclose(gu.polygon_area(p), 0.5) 115 | # pentagon, counter-clockwise 116 | p = np.array( 117 | [ 118 | [0.0, 0.0], 119 | [1.0, 0.0], 120 | [1.0, 1.0], 121 | [0.5, 2.0], 122 | [0.0, 1.0], 123 | ] 124 | ) 125 | assert np.allclose(gu.polygon_area(p), 1.5) 126 | # clockwise 127 | assert np.allclose(gu.polygon_area(p[::-1]), 1.5) 128 | 129 | 130 | def test_point_in_polygon(): 131 | poly = np.array( 132 | [ 133 | [0.0, 0.0], 134 | [1.0, 0.0], 135 | [1.0, 1.0], 136 | ] 137 | ) 138 | assert gu.point_in_polygon(Point(0.5, 0.25), poly) 139 | assert not gu.point_in_polygon(Point(1.5, 0.25), poly) 140 | 141 | assert gu.point_in_polygon(Point(0.0, 0.0), poly) 142 | assert gu.point_in_polygon(Point(0.0, 0.0), poly[::-1]) 143 | assert gu.point_in_polygon(Point(0.5, 0.5), poly) 144 | assert gu.point_in_polygon(Point(0.5, 0.5), poly[::-1]) 145 | assert not gu.point_in_polygon(Point(1.0, 1.0), poly) 146 | assert not gu.point_in_polygon(Point(1.0, 1.0), poly[::-1]) 147 | 148 | 149 | def test_boxes_intersect(): 150 | # Identity 151 | a = Box(0.0, 1.0, 0.0, 1.0) 152 | b = a 153 | assert gu.boxes_intersect(a, b) 154 | assert gu.boxes_intersect(b, a) 155 | # Overlap 156 | b = Box(0.5, 1.5, 0.0, 1.0) 157 | assert gu.boxes_intersect(a, b) 158 | assert gu.boxes_intersect(b, a) 159 | # No overlap 160 | b = Box(1.5, 2.5, 0.5, 1.0) 161 | assert not gu.boxes_intersect(a, b) 162 | assert not gu.boxes_intersect(b, a) 163 | # Different identity 164 | b = a 165 | assert gu.boxes_intersect(a, b) 166 | assert gu.boxes_intersect(b, a) 167 | # Inside 168 | a = Box(0.0, 1.0, 0.0, 1.0) 169 | b = Box(0.25, 0.75, 0.25, 0.75) 170 | assert gu.boxes_intersect(a, b) 171 | assert gu.boxes_intersect(b, a) 172 | 173 | 174 | def test_box_contained(): 175 | a = Box(0.0, 1.0, 0.0, 1.0) 176 | b = Box(0.25, 0.75, 0.25, 0.75) 177 | assert gu.box_contained(a, a) 178 | assert gu.box_contained(b, a) 179 | assert not gu.box_contained(a, b) 180 | 181 | 182 | def test_bounding_box(): 183 | face = np.array([0, 1, 2]) 184 | vertices = np.array( 185 | [ 186 | [0.0, 1.0], 187 | [1.0, 0.0], 188 | [1.0, 1.0], 189 | ] 190 | ) 191 | assert gu.bounding_box(face, vertices) == (0.0, 1.0, 0.0, 1.0) 192 | face = np.array([0, 1, 2, -1, -1]) 193 | assert gu.bounding_box(face, vertices) == (0.0, 1.0, 0.0, 1.0) 194 | 195 | 196 | def test_build_face_bboxes(): 197 | faces = np.array( 198 | [ 199 | [0, 1, 2, -1], 200 | [0, 1, 2, 3], 201 | ] 202 | ) 203 | vertices = np.array( 204 | [ 205 | [0.0, 5.0], 206 | [5.0, 0.0], 207 | [5.0, 5.0], 208 | [0.0, 5.0], 209 | ] 210 | ) 211 | expected = np.array( 212 | [ 213 | [0.0, 5.0, 0.0, 5.0], 214 | [0.0, 5.0, 0.0, 5.0], 215 | ] 216 | ) 217 | actual = gu.build_face_bboxes(faces, vertices) 218 | assert np.array_equal(actual, expected) 219 | 220 | 221 | def test_build_edge_bboxes(): 222 | vertices = np.array([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [2.0, 1.0]], dtype=float) 223 | edges = np.array([[0, 1], [1, 2], [2, 3]], dtype=np.int32) 224 | 225 | bb_coords = gu.build_edge_bboxes(edges, vertices, tolerance=1e-9) 226 | expected_bb_coords = np.array( 227 | [ 228 | [0.0, 1.0, 0.0, 0.0], 229 | [1.0, 2.0, 0.0, 0.0], 230 | [2.0, 2.0, 0.0, 1.0], 231 | ], 232 | dtype=float, 233 | ) 234 | np.testing.assert_allclose(bb_coords, expected_bb_coords, atol=TOLERANCE_ON_EDGE) 235 | 236 | 237 | def test_copy_vertices(): 238 | """ 239 | This has to be tested inside of numba jitted function, because the vertices 240 | are copied to a stack allocated array. This array is not returned properly 241 | to dynamic python. This is OK: these arrays are exclusively for internal 242 | use to temporarily store values. 243 | """ 244 | if os.environ.get("NUMBA_DISABLE_JIT", "0") == "0": 245 | 246 | @nb.njit() 247 | def test(): 248 | face = np.array([0, 1, 2, -1, -1]) 249 | vertices = np.array( 250 | [ 251 | [0.0, 1.0], 252 | [1.0, 0.0], 253 | [1.0, 1.0], 254 | ] 255 | ) 256 | expected = vertices.copy() 257 | actual = gu.copy_vertices(vertices, face) 258 | result = True 259 | for i in range(3): 260 | result = result and actual[i, 0] == expected[i, 0] 261 | result = result and actual[i, 1] == expected[i, 1] 262 | return result 263 | 264 | assert test() 265 | 266 | else: 267 | face = np.array([0, 1, 2, -1, -1]) 268 | vertices = np.array( 269 | [ 270 | [0.0, 1.0], 271 | [1.0, 0.0], 272 | [1.0, 1.0], 273 | ] 274 | ) 275 | expected = vertices.copy() 276 | actual = gu.copy_vertices(vertices, face) 277 | assert np.array_equal(actual, expected) 278 | assert len(actual) == 3 279 | 280 | 281 | def test_copy_vertices_into(): 282 | out = np.empty((10, 2)) 283 | face = np.array([0, 1, 2, -1, -1]) 284 | vertices = np.array( 285 | [ 286 | [0.0, 1.0], 287 | [1.0, 0.0], 288 | [1.0, 1.0], 289 | ] 290 | ) 291 | expected = vertices.copy() 292 | actual = gu.copy_vertices_into(vertices, face, out) 293 | assert np.array_equal(actual, expected) 294 | assert len(actual) == 3 295 | 296 | 297 | def test_point_inside_box(): 298 | box = Box(0.0, 1.0, 0.0, 1.0) 299 | a = Point(0.5, 0.5) 300 | assert gu.point_inside_box(a, box) 301 | a = Point(-0.5, 0.5) 302 | assert not gu.point_inside_box(a, box) 303 | a = Point(0.5, -0.5) 304 | assert not gu.point_inside_box(a, box) 305 | 306 | 307 | def test_flip(): 308 | face0 = np.array([0, 1, 2, -1, -1]) 309 | face1 = np.array([0, 1, 2, 3, -1]) 310 | face2 = np.array([0, 1, 2, 3, 4]) 311 | gu.flip(face0, 3) 312 | gu.flip(face1, 4) 313 | gu.flip(face2, 5) 314 | assert np.array_equal(face0, [2, 1, 0, -1, -1]) 315 | assert np.array_equal(face1, [3, 2, 1, 0, -1]) 316 | assert np.array_equal(face2, [4, 3, 2, 1, 0]) 317 | 318 | 319 | def test_counter_clockwise(): 320 | vertices = np.array( 321 | [ 322 | [0.0, 0.0], 323 | [0.5, 0.0], # hanging node 324 | [1.0, 0.0], 325 | [1.0, 0.5], # hanging node 326 | [1.0, 1.0], 327 | [0.0, 1.0], 328 | ] 329 | ) 330 | ccw_faces = np.array( 331 | [ 332 | [0, 2, 4, 5, -1, -1], 333 | [0, 1, 2, 3, 4, 5], 334 | ] 335 | ) 336 | cw_faces = np.array( 337 | [ 338 | [5, 4, 2, 0, -1, -1], 339 | [5, 4, 3, 2, 1, 0], 340 | ] 341 | ) 342 | expected = ccw_faces.copy() 343 | # already counter clockwise should not be mutated 344 | gu.counter_clockwise(vertices, ccw_faces) 345 | assert np.array_equal(expected, ccw_faces) 346 | # clockwise should be mutated 347 | gu.counter_clockwise(vertices, cw_faces) 348 | assert np.array_equal(expected, cw_faces) 349 | 350 | 351 | offset = 2 * TOLERANCE_ON_EDGE 352 | 353 | 354 | class IntersectCases: 355 | def case_no_intersection(self): 356 | p = Point(3.0, 2.0) 357 | q = Point(3.0, 1.0) 358 | expected_intersects = False 359 | expected_intersection_point = Point(np.nan, np.nan) 360 | return p, q, expected_intersects, expected_intersection_point 361 | 362 | def case_vertex_nearly_touching_edge(self): 363 | p = Point(3.0, 3.0 - offset) 364 | q = Point(3.0, 1.0) 365 | expected_intersects = False 366 | expected_intersection_point = Point(np.nan, np.nan) 367 | return p, q, expected_intersects, expected_intersection_point 368 | 369 | def case_vertex_on_edge(self): 370 | p = Point(-1.0, 1.0) 371 | q = Point(1.0, -1.0) 372 | expected_intersects = True 373 | expected_intersection_point = Point(0.0, 0.0) 374 | return p, q, expected_intersects, expected_intersection_point 375 | 376 | def case_edge_on_edge_collinear(self): 377 | p = Point(1.0, 1.0) 378 | q = Point(3.0, 3.0) 379 | expected_intersects = True 380 | expected_intersection_point = Point(2.0, 2.0) 381 | return p, q, expected_intersects, expected_intersection_point 382 | 383 | def case_edge_on_edge_orthogonal(self): 384 | p = Point(1.0, 3.0) 385 | q = Point(3.0, 1.0) 386 | expected_intersects = True 387 | expected_intersection_point = Point(2.0, 2.0) 388 | return p, q, expected_intersects, expected_intersection_point 389 | 390 | def case_vertex_on_vertex_collinear(self): 391 | p = Point(0.0, 0.0) 392 | q = Point(-1.0, -1.0) 393 | expected_intersects = True 394 | expected_intersection_point = Point(0.0, 0.0) 395 | return p, q, expected_intersects, expected_intersection_point 396 | 397 | def case_vertex_nearly_on_vertex_collinear_no_overlap(self): 398 | p = Point(-offset, -offset) 399 | q = Point(-1.0, -1.0) 400 | expected_intersects = False 401 | expected_intersection_point = Point(np.nan, np.nan) 402 | return p, q, expected_intersects, expected_intersection_point 403 | 404 | 405 | @parametrize_with_cases( 406 | "p, q, expected_intersects, expected_intersection_point", cases=IntersectCases 407 | ) 408 | def test_lines_intersect(p, q, expected_intersects, expected_intersection_point): 409 | a = Point(0.0, 0.0) 410 | b = Point(4.0, 4.0) 411 | actual_intersects, x, y = gu.lines_intersect(a, b, p, q) 412 | assert actual_intersects == expected_intersects 413 | np.testing.assert_allclose(x, expected_intersection_point.x) 414 | np.testing.assert_allclose(y, expected_intersection_point.y) 415 | # Reverse order edges 416 | actual_intersects, x, y = gu.lines_intersect(p, q, a, b) 417 | assert actual_intersects == expected_intersects 418 | np.testing.assert_allclose(x, expected_intersection_point.x) 419 | np.testing.assert_allclose(y, expected_intersection_point.y) 420 | 421 | 422 | def test_point_in_triangle(): 423 | tol = 1e-9 424 | a = Point(0.1, 0.1) 425 | b = Point(0.7, 0.5) 426 | c = Point(0.4, 0.7) 427 | # Should work for clockwise and ccw orientation. 428 | triangle = Triangle(a, b, c) 429 | rtriangle = Triangle(c, b, a) 430 | p = Point(0.5, 0.5) 431 | assert gu.point_in_triangle(p, triangle, tol) 432 | assert gu.point_in_triangle(p, rtriangle, tol) 433 | 434 | p = Point(0.0, 0.0) 435 | assert not gu.point_in_triangle(p, triangle, tol) 436 | assert not gu.point_in_triangle(p, rtriangle, tol) 437 | 438 | 439 | def test_points_in_triangle(): 440 | vertices = np.array( 441 | [ 442 | [0.0, 0.0], 443 | [1.0, 0.0], 444 | [1.0, 1.0], 445 | [2.0, 0.0], 446 | ] 447 | ) 448 | faces = np.array( 449 | [ 450 | [0, 1, 2], 451 | [1, 3, 2], 452 | ] 453 | ) 454 | points = np.array( 455 | [ 456 | [-0.5, 0.25], 457 | [0.0, 0.0], # on vertex 458 | [0.5, 0.5], # on edge 459 | [0.5, 0.25], 460 | [1.5, 0.25], 461 | [2.5, 0.25], 462 | ] 463 | ) 464 | face_indices = np.array([0, 0, 0, 0, 1, 1]) 465 | expected = [False, True, True, True, True, False] 466 | actual = gu.points_in_triangles( 467 | points=points, 468 | face_indices=face_indices, 469 | faces=faces, 470 | vertices=vertices, 471 | tolerance=1e-9, 472 | ) 473 | assert np.array_equal(expected, actual) 474 | --------------------------------------------------------------------------------