├── .coveragerc ├── .dockerignore ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── AUTHORS.rst ├── CITATION.cff ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── README.md ├── docs └── source │ ├── api_reference │ ├── skspatial.measurement.rst │ ├── skspatial.objects.Circle.rst │ ├── skspatial.objects.Cylinder.rst │ ├── skspatial.objects.Line.rst │ ├── skspatial.objects.LineSegment.rst │ ├── skspatial.objects.Plane.rst │ ├── skspatial.objects.Point.rst │ ├── skspatial.objects.Points.rst │ ├── skspatial.objects.Sphere.rst │ ├── skspatial.objects.Triangle.rst │ ├── skspatial.objects.Vector.rst │ ├── skspatial.objects.rst │ ├── skspatial.transformation.rst │ └── toc.rst │ ├── conf.py │ ├── images │ └── logo.svg │ ├── index.rst │ ├── objects │ ├── circle.rst │ ├── cylinder.rst │ ├── line.rst │ ├── line_segment.rst │ ├── plane.rst │ ├── point_vector.rst │ ├── points.rst │ ├── sphere.rst │ ├── toc.rst │ └── triangle.rst │ ├── plotting.rst │ └── requirements.txt ├── examples ├── README.txt ├── fitting │ ├── README.txt │ ├── plot_line_2d.py │ ├── plot_line_3d.py │ └── plot_plane.py ├── intersection │ ├── README.txt │ ├── plot_circle_circle.py │ ├── plot_cylinder_line.py │ ├── plot_line_circle.py │ ├── plot_line_line_2d.py │ ├── plot_line_line_3d.py │ ├── plot_line_plane.py │ ├── plot_plane_plane.py │ └── plot_sphere_line.py ├── projection │ ├── README.txt │ ├── plot_line_plane.py │ ├── plot_point_line.py │ ├── plot_point_plane.py │ ├── plot_vector_line.py │ ├── plot_vector_plane.py │ └── plot_vector_vector.py └── triangle │ ├── README.txt │ ├── plot_normal.py │ └── plot_orthocenter.py ├── images └── logo.svg ├── mypy.ini ├── pyproject.toml ├── src └── skspatial │ ├── __init__.py │ ├── _functions.py │ ├── measurement.py │ ├── objects │ ├── __init__.py │ ├── _base_array.py │ ├── _base_line_plane.py │ ├── _base_spatial.py │ ├── _base_sphere.py │ ├── _mixins.py │ ├── circle.py │ ├── cylinder.py │ ├── line.py │ ├── line_segment.py │ ├── plane.py │ ├── point.py │ ├── points.py │ ├── sphere.py │ ├── triangle.py │ └── vector.py │ ├── plotting.py │ ├── py.typed │ ├── transformation.py │ └── typing.py ├── tests ├── __init__.py └── unit │ ├── __init__.py │ ├── objects │ ├── __init__.py │ ├── test_all_objects.py │ ├── test_base_array.py │ ├── test_base_array_1d.py │ ├── test_base_array_2d.py │ ├── test_base_line_plane.py │ ├── test_circle.py │ ├── test_cylinder.py │ ├── test_line.py │ ├── test_line_plane.py │ ├── test_line_segment.py │ ├── test_plane.py │ ├── test_point.py │ ├── test_points.py │ ├── test_sphere.py │ ├── test_triangle.py │ └── test_vector.py │ ├── test_functions.py │ └── test_measurement.py ├── tox.ini └── uv.lock /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = 3 | src/ 4 | omit = 5 | */plotting.py 6 | 7 | [report] 8 | exclude_lines = 9 | def *plot* 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/*.ipynb 2 | **/*.md5 3 | **/*.pickle 4 | **/*.pyc 5 | docs/source/gallery 6 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: scikit-spatial 2 | 3 | on: 4 | pull_request: 5 | push: 6 | tags: 7 | - "v*.*.*" 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - name: Install uv 21 | uses: astral-sh/setup-uv@v3 22 | with: 23 | version: "0.4.18" 24 | 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | 30 | - name: Install tox 31 | run: uv tool install tox --with tox-uv --with tox-gh 32 | 33 | - name: Run tox 34 | run: tox 35 | 36 | - name: Upload coverage report to codecov 37 | if: matrix.python-version == '3.12' 38 | uses: codecov/codecov-action@v4 39 | with: 40 | fail_ci_if_error: true 41 | verbose: true 42 | token: ${{ secrets.CODECOV_TOKEN }} 43 | 44 | publish: 45 | needs: build 46 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 47 | 48 | runs-on: ubuntu-latest 49 | 50 | steps: 51 | - uses: actions/checkout@v2 52 | 53 | - name: Install uv 54 | uses: astral-sh/setup-uv@v3 55 | with: 56 | version: "0.4.18" 57 | 58 | - name: Build and publish to PyPI 59 | run: | 60 | uv build 61 | uv publish --token ${{ secrets.PYPI_API_TOKEN }} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.ipynb 3 | *.pyc 4 | *.xml 5 | .*/ 6 | .coverage 7 | .python-version 8 | build/ 9 | dist/ 10 | docs/build/ 11 | docs/source/api_reference/*/ 12 | docs/source/computations/*/ 13 | docs/source/gallery/ 14 | htmlcov/ 15 | 16 | !.github/ 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-ast 6 | - id: check-builtin-literals 7 | - id: check-docstring-first 8 | - id: check-merge-conflict 9 | - id: check-yaml 10 | - id: end-of-file-fixer 11 | - id: trailing-whitespace 12 | - repo: https://github.com/pre-commit/mirrors-prettier 13 | rev: v3.0.0-alpha.4 14 | hooks: 15 | - id: prettier 16 | - repo: https://github.com/astral-sh/ruff-pre-commit 17 | rev: v0.2.1 18 | hooks: 19 | - id: ruff 20 | args: [--fix, --preview] 21 | - id: ruff-format 22 | args: [--preview] 23 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.8" 7 | 8 | sphinx: 9 | configuration: docs/source/conf.py 10 | 11 | python: 12 | install: 13 | - requirements: docs/source/requirements.txt 14 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Andrew Hynes (https://github.com/ajhynes7) 9 | 10 | 11 | Contributors 12 | ------------ 13 | 14 | * Marcelo Moreno (https://github.com/martxelo) 15 | 16 | * Cristiano Pizzamiglio (https://github.com/CristianoPizzamiglio) 17 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | title: "scikit-spatial: Spatial objects and computations based on NumPy arrays" 3 | message: "If you use this software, please cite it using the metadata from this file." 4 | type: software 5 | authors: 6 | - given-names: Andrew 7 | family-names: Hynes 8 | email: andrewjhynes@gmail.com 9 | license: BSD-3-Clause 10 | url: https://scikit-spatial.readthedocs.io 11 | repository-code: https://github.com/ajhynes7/scikit-spatial 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | You can contribute in many ways: 6 | 7 | Types of Contributions 8 | ---------------------- 9 | 10 | Report Bugs 11 | ~~~~~~~~~~~ 12 | 13 | Report bugs at https://github.com/ajhynes7/scikit-spatial/issues. 14 | 15 | If you are reporting a bug, please include: 16 | 17 | * Your operating system name and version. 18 | * Any details about your local setup that might be helpful in troubleshooting. 19 | * Detailed steps to reproduce the bug. 20 | 21 | Fix Bugs 22 | ~~~~~~~~ 23 | 24 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 25 | wanted" is open to whoever wants to implement it. 26 | 27 | Implement Features 28 | ~~~~~~~~~~~~~~~~~~ 29 | 30 | Look through the GitHub issues for features. Anything tagged with "enhancement" 31 | and "help wanted" is open to whoever wants to implement it. 32 | 33 | Submit Feedback 34 | ~~~~~~~~~~~~~~~ 35 | 36 | The best way to send feedback is to file an issue at https://github.com/ajhynes7/scikit-spatial/issues. 37 | 38 | 39 | Get Started! 40 | ------------ 41 | 42 | Ready to contribute? Here's how to set up `scikit-spatial` for local development. 43 | 44 | 1. Fork the `scikit-spatial` repo on GitHub. 45 | 46 | 2. Create a branch for local development:: 47 | 48 | $ git checkout -b name-of-your-bugfix-or-feature 49 | 50 | Now you can make your changes locally. 51 | 52 | 3. When you're done making changes, check that your changes pass linting and tests. 53 | See ``.travis.yml`` for test commands. 54 | 55 | 4. Commit your changes and push your branch to GitHub:: 56 | 57 | $ git add . 58 | $ git commit -m "Your detailed description of your changes." 59 | $ git push origin name-of-your-bugfix-or-feature 60 | 61 | 5. Submit a pull request through the GitHub website. 62 | 63 | 64 | Pull Request Guidelines 65 | ----------------------- 66 | 67 | Before you submit a pull request, check that it meets these guidelines: 68 | 69 | 1. The pull request should include tests. 70 | 2. If the pull request adds functionality, the docs should be updated. Put 71 | your new functionality into a function with a docstring. 72 | 3. Check https://travis-ci.org/ajhynes7/scikit-spatial/pull_requests 73 | and make sure that the tests pass for all supported Python versions. 74 | 75 | 76 | Deploying 77 | --------- 78 | 79 | A reminder for the maintainers on how to deploy. 80 | Make sure all your changes are committed (including an entry in HISTORY.rst). 81 | Then run:: 82 | 83 | $ bumpversion patch # possible: major / minor / patch 84 | $ git push 85 | $ git push --tags 86 | 87 | Travis will then deploy to PyPI if tests pass. 88 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 9.0.1 (2025-04-16) 6 | ------------------ 7 | 8 | Docs 9 | ~~~~ 10 | - Include single quotes in command: `pip install 'scikit-spatial[plotting]'` 11 | 12 | 13 | 9.0.0 (2025-04-16) 14 | ------------------ 15 | 16 | Breaking Changes 17 | ~~~~~~~~~~~~~~~~ 18 | - Make matplotlib an optional dependency. It can be installed by `pip install 'scikit-spatial[plotting]'` 19 | 20 | 21 | 8.1.0 (2024-12-23) 22 | ------------------ 23 | 24 | Features 25 | ~~~~~~~~ 26 | - Add option to return error for line and plane of best fit. 27 | 28 | 29 | 8.0.0 (2024-10-03) 30 | ------------------ 31 | 32 | Features 33 | ~~~~~~~~ 34 | - Support NumPy 2.0. 35 | 36 | 37 | 7.2.2 (2024-03-27) 38 | ------------------ 39 | 40 | Fixes 41 | ~~~~~ 42 | - Change reference in `Plane.best_fit` docstring as the previous link was broken. 43 | - Change `README.rst` to `README.md` as GitHub was not rendering the former well. 44 | 45 | 46 | 7.2.1 (2024-02-17) 47 | ------------------ 48 | 49 | Docs 50 | ~~~~ 51 | Include new methods in the documentation: 52 | - `Line.project_points` 53 | - `Line.distance_points` 54 | - `Plane.project_points` 55 | - `Plane.distance_points` 56 | 57 | 58 | 7.2.0 (2024-02-12) 59 | ------------------ 60 | 61 | Features 62 | ~~~~~~~~ 63 | - Add new methods: 64 | - `Line.project_points` 65 | - `Line.distance_points` 66 | - `Plane.project_points` 67 | - `Plane.distance_points` 68 | 69 | 70 | 7.1.1 (2024-01-29) 71 | ------------------ 72 | 73 | Fixes 74 | ~~~~~ 75 | - Restore import of `importlib_metadata`. 76 | 77 | 78 | 7.1.0 (2024-01-28) 79 | ------------------ 80 | 81 | Features 82 | ~~~~~~~~ 83 | - Add support for Python 3.12. 84 | 85 | 86 | 7.0.0 (2023-03-26) 87 | ------------------ 88 | 89 | Breaking Changes 90 | ~~~~~~~~~~~~~~~~ 91 | - Drop support for Python 3.7. 92 | - Increase minimum NumPy version to 1.17.3 (to be compatible with the new dependency SciPy). 93 | 94 | Features 95 | ~~~~~~~~ 96 | - Add `Cylinder.best_fit` method. 97 | 98 | 99 | 6.8.1 (2023-03-07) 100 | ------------------ 101 | 102 | Fixes 103 | ~~~~~ 104 | - Add missing `plotter` method to `LineSegment`. 105 | 106 | 107 | 6.8.0 (2023-01-28) 108 | ------------------ 109 | 110 | Features 111 | ~~~~~~~~ 112 | - Add `Circle.from_points` method. 113 | - Add `check_coplanar` kwarg to `Line.intersect_line`. 114 | - Lower minimum NumPy version to 1.16. 115 | 116 | 117 | 6.7.0 (2022-12-28) 118 | ------------------ 119 | 120 | Features 121 | ~~~~~~~~ 122 | - Add `Circle.intersect_circle` method. 123 | 124 | 125 | 6.6.0 (2022-11-20) 126 | ------------------ 127 | 128 | Features 129 | ~~~~~~~~ 130 | - Add `Vector.angle_signed_3d` method. 131 | 132 | 133 | 6.5.0 (2022-09-05) 134 | ------------------ 135 | 136 | Features 137 | ~~~~~~~~ 138 | - Add `LineSegment` class. 139 | 140 | Docs 141 | ~~~~ 142 | - Add plot of Cylinder-Line Intersection to the gallery. 143 | 144 | 145 | 6.4.1 (2022-06-21) 146 | ------------------ 147 | 148 | Fixes 149 | ~~~~~ 150 | - Update the `dimension` value of a slice, instead of using the value of the original array. 151 | - Fix the output radius of `Cylinder.to_mesh`. 152 | 153 | 154 | 6.4.0 (2022-04-07) 155 | ------------------ 156 | 157 | Features 158 | ~~~~~~~~ 159 | - Add `Plane.project_line` method to project a line onto a plane. 160 | 161 | 162 | 6.3.0 (2022-02-26) 163 | ------------------ 164 | 165 | Features 166 | ~~~~~~~~ 167 | - Add `Circle.best_fit` method to fit a circle to 2D points. 168 | - Add `area_signed` function to compute the signed area of a polygon using the shoelace algorithm. 169 | 170 | 171 | 6.2.1 (2022-01-08) 172 | ------------------ 173 | 174 | Fixes 175 | ~~~~~ 176 | - Allow for versions of `importlib-metadata` above 1. 177 | 178 | 179 | 6.2.0 (2021-10-06) 180 | ------------------ 181 | 182 | Features 183 | ~~~~~~~~ 184 | - Add `infinite` keyword argument to `Cylinder.intersect_line` with a default value of `True`. 185 | Now the line can be intersected with a finite cylinder by passing `infinite=False`. 186 | 187 | Fixes 188 | ~~~~~ 189 | - Fix the return type hint of `Plane.intersect_line` (from Plane to Point). 190 | 191 | 192 | 6.1.1 (2021-09-11) 193 | ------------------ 194 | 195 | Fixes 196 | ~~~~~ 197 | - Add code to `skspatial.__init__.py` to keep the __version__ attribute in sync with the version in `pyproject.toml`. 198 | 199 | 200 | 6.1.0 (2021-07-25) 201 | ------------------ 202 | 203 | Features 204 | ~~~~~~~~ 205 | - Add `lateral_surface_area` and `surface_area` methods to `Cylinder`. 206 | 207 | Improvements 208 | ~~~~~~~~~~~~ 209 | - Remove unnecessary `np.copy` from `Circle.intersect_line`. 210 | - Complete the docstring for `Line.distance_point`. 211 | 212 | 213 | 6.0.1 (2021-03-25) 214 | ------------------ 215 | 216 | Fixes 217 | ~~~~~ 218 | * Wrap `filterwarnings("error")` in a `catch_warnings` context manager, in `__BaseArray.__new__()`. 219 | Now the warning level is reset at the end of the context manager. 220 | 221 | 222 | 6.0.0 (2021-03-21) 223 | ------------------ 224 | 225 | Breaking changes 226 | ~~~~~~~~~~~~~~~~ 227 | * Require NumPy >= 1.20 to make use of the static types introduced in 1.20. 228 | Now numpy-stubs doesn't need to be installed for static type checking. 229 | * Move tests outside of package, and move package under ``src`` directory. 230 | This ensures that tox is running the tests with the installed package. 231 | * Switch from ``setup.py`` to ``pyproject.toml``. 232 | * Add more ValueErrors for clarity, such as "The lines must have the same dimension" 233 | ValueError in ``Line.intersect_line``. 234 | 235 | Features 236 | ~~~~~~~~ 237 | * Add ``Cylinder`` class. 238 | * Add ``Vector.different_direction`` method. 239 | * Add ``Sphere.best_fit`` method. 240 | 241 | Refactoring 242 | ~~~~~~~~~~~ 243 | * Delete ``Vector.dot`` method. The ``dot`` method is already inherited from NumPy. 244 | 245 | 246 | 5.2.0 (2020-12-19) 247 | ------------------ 248 | * Add keyword arguments to ``Plane.best_fit`` and ``Line.best_fit``. 249 | These are passed to ``np.linalg.svd``. 250 | 251 | 252 | 5.1.0 (2020-12-07) 253 | ------------------ 254 | * Edit type annotations to support Python 3.6. 255 | * CI now tests Python versions 3.6-3.9. 256 | 257 | 258 | 5.0.0 (2020-11-23) 259 | ------------------ 260 | * Return regular ``ndarray`` from inherited NumPy functions, e.g. ``vector.sum()`` 261 | - This prevents getting spatial objects with disallowed dimensions, such as a 0-D vector. 262 | - This fixes broken examples in the README. 263 | * Test README examples with doctest. 264 | * Replace tox with Docker. 265 | - Docker multi-stage builds are a convenient feature for isolating test environments. 266 | * Organize requirements into multiple files. 267 | - This makes it easy to install only what's needed for each test environment. 268 | 269 | 270 | 4.0.1 (2020-02-01) 271 | ------------------ 272 | * Fix to replace Python 3.6 with 3.8 in the setup.py file. 273 | 274 | 275 | 4.0.0 (2020-02-01) 276 | ------------------ 277 | * Drop support for Python 3.6 (this allows for postponed evaluation of type annotations, introduced in Python 3.7). 278 | * Add Triangle class. 279 | 280 | 281 | 3.0.0 (2019-11-02) 282 | ------------------ 283 | * Add `Points.normalize_distance` method to fit points inside a unit sphere. 284 | * Change `Points.mean_center` to only return the centroid of the points if specified. 285 | This allows for chaining with other transformations on points, like `normalize_distance`. 286 | * Add `to_array` method to convert an array based object to a regular NumPy array. 287 | 288 | 289 | 2.0.1 (2019-08-15) 290 | ------------------ 291 | * Use installation of numpy-stubs from its GitHub repository instead of a custom numpy stubs folder. 292 | * Introduce 'array_like' type annotation as the union of np.ndarray and Sequence. 293 | * Add py.typed file so that annotations can be used when scikit-spatial is installed. 294 | 295 | 296 | 2.0.0 (2019-07-20) 297 | ------------------ 298 | * Replace some NumPy functions with ones from Python math module. The math functions are faster than NumPy when the inputs are scalars. 299 | The tolerances for isclose are now rel_tol and abs_tol instead of rtol and atol. 300 | The math.isclose function is preferable to np.isclose for three main reasons: 301 | * It is symmetric (isclose(a, b) == isclose(b, a)). 302 | * It has a default absolute tolerance of zero. 303 | * It does not correlate the absolute and relative tolerances. 304 | * Add type annotations to methods and run mypy in Travis CI. 305 | * Add round method to array objects (Point, Points and Vector). Now a Vector is returned when a Vector is rounded. 306 | * Add methods to return coordinates on the surface of a Plane or Sphere. The coordinates are used for 3D plotting. 307 | * Improve Plane plotting so that vertical planes can be plotted. 308 | 309 | 310 | 1.5.0 (2019-07-04) 311 | ------------------ 312 | * Add Circle and Sphere spatial objects. 313 | * Add scalar keyword argument to Vector plot methods. 314 | * Improve plotting of Plane. The x and y limits now treat the plane point as the origin. 315 | 316 | 317 | 1.4.2 (2019-06-21) 318 | ------------------ 319 | * Extra release because regex for version tags was incorrect in Travis. 320 | 321 | 322 | 1.4.1 (2019-06-21) 323 | ------------------ 324 | * Extra release because Travis did not deploy the last one. 325 | 326 | 327 | 1.4.0 (2019-06-21) 328 | ------------------ 329 | * Add functions `plot_2d` and `plot_3d` to facilitate plotting multiple spatial objects. 330 | * Change `_plotting` module name to `plotting`, because it now contains some public functions. 331 | 332 | 333 | 1.3.0 (2019-06-19) 334 | ------------------ 335 | * Remove dpcontracts as a dependency. The contracts were causing performance issues. 336 | * Add 'dimension' attribute to all spatial objects. 337 | * Add Vector.angle_signed method. 338 | * Add Line.from_slope method. 339 | 340 | 341 | 1.2.0 (2019-06-11) 342 | ------------------ 343 | * Move tests into skspatial directory. This allows for importing custom hypothesis strategies for testing other projects. 344 | * Drop support for Python 3.5 (matplotlib requires >= 3.6). 345 | 346 | 347 | 1.1.0 (2019-05-04) 348 | ------------------ 349 | * Add methods for 2D and 3D plotting. 350 | * Rename private modules and functions to include leading underscore. 351 | 352 | 353 | 1.0.1 (2019-03-29) 354 | ------------------ 355 | * Support Python versions 3.5-3.7. 356 | 357 | 358 | 1.0.0 (2019-03-26) 359 | ------------------ 360 | * Change Vector and Point to be subclasses of the NumPy `ndarray`. 361 | * Change all spatial objects to accept `array_like` inputs, such as a list or tuple. 362 | * Add the Points class to represent multiple points in space. This is also an `ndarray` subclass. 363 | * The dimension of the objects is no longer automatically set to 3D. Points and vectors can be 2D and up. 364 | 365 | 366 | 0.1.0 (2019-02-27) 367 | ------------------ 368 | * First release on PyPI. 369 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | 3 | BSD License 4 | 5 | Copyright (c) 2019, Andrew Hynes 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without modification, 9 | are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, this 12 | list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, this 15 | list of conditions and the following disclaimer in the documentation and/or 16 | other materials provided with the distribution. 17 | 18 | * Neither the name of the copyright holder nor the names of its 19 | contributors may be used to endorse or promote products derived from this 20 | software without specific prior written permission. 21 | 22 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 23 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 24 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 25 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 26 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 27 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 28 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 29 | OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 30 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 31 | OF THE POSSIBILITY OF SUCH DAMAGE. 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](images/logo.svg) 2 | 3 | [![image](https://img.shields.io/pypi/v/scikit-spatial.svg)](https://pypi.python.org/pypi/scikit-spatial) 4 | [![image](https://anaconda.org/conda-forge/scikit-spatial/badges/version.svg)](https://anaconda.org/conda-forge/scikit-spatial) 5 | [![image](https://img.shields.io/pypi/pyversions/scikit-spatial.svg)](https://pypi.python.org/pypi/scikit-spatial) 6 | [![image](https://github.com/ajhynes7/scikit-spatial/actions/workflows/main.yml/badge.svg)](https://github.com/ajhynes7/scikit-spatial/actions/workflows/main.yml) 7 | [![Documentation Status](https://readthedocs.org/projects/scikit-spatial/badge/?version=latest)](https://scikit-spatial.readthedocs.io/en/latest/?badge=latest) 8 | [![image](https://codecov.io/gh/ajhynes7/scikit-spatial/branch/master/graph/badge.svg)](https://codecov.io/gh/ajhynes7/scikit-spatial) 9 | 10 | # Introduction 11 | 12 | This package provides spatial objects based on NumPy arrays, as well as 13 | computations using these objects. The package includes computations for 14 | 2D, 3D, and higher-dimensional space. 15 | 16 | The following spatial objects are provided: 17 | 18 | - Point 19 | - Points 20 | - Vector 21 | - Line 22 | - LineSegment 23 | - Plane 24 | - Circle 25 | - Sphere 26 | - Triangle 27 | - Cylinder 28 | 29 | Most of the computations fall into the following categories: 30 | 31 | - Measurement 32 | - Comparison 33 | - Projection 34 | - Intersection 35 | - Fitting 36 | - Transformation 37 | 38 | All spatial objects are equipped with plotting methods based on 39 | `matplotlib`. Both 2D and 3D plotting are supported. Spatial 40 | computations can be easily visualized by plotting multiple objects at 41 | once. 42 | 43 | ## Why this instead of `scipy.spatial` or `sympy.geometry`? 44 | 45 | This package has little to no overlap with the functionality of 46 | `scipy.spatial`. It can be viewed as an object-oriented extension. 47 | 48 | While similar spatial objects and computations exist in the 49 | `sympy.geometry` module, `scikit-spatial` is based on NumPy rather than 50 | symbolic math. The primary objects of `scikit-spatial` (`Point`, 51 | `Points`, and `Vector`) are actually subclasses of the NumPy _ndarray_. 52 | This gives them all the regular functionality of the _ndarray_, plus 53 | additional methods from this package. 54 | 55 | ```py 56 | >>> from skspatial.objects import Vector 57 | 58 | >>> vector = Vector([2, 0, 0]) 59 | 60 | ``` 61 | 62 | Behaviour inherited from NumPy: 63 | 64 | ```py 65 | >>> vector.size 66 | 3 67 | 68 | >>> vector.mean().round(3) 69 | np.float64(0.667) 70 | 71 | ``` 72 | 73 | Additional methods from `scikit-spatial`: 74 | 75 | ```py 76 | >>> vector.norm() 77 | np.float64(2.0) 78 | 79 | >>> vector.unit() 80 | Vector([1., 0., 0.]) 81 | 82 | ``` 83 | 84 | Because `Point` and `Vector` are both subclasses of `ndarray`, a `Vector` can be added to a `Point`. This produces a new `Point`. 85 | 86 | ```py 87 | >>> from skspatial.objects import Point 88 | 89 | >>> Point([1, 2]) + Vector([3, 4]) 90 | Point([4, 6]) 91 | 92 | ``` 93 | 94 | `Point` and `Vector` are based on a 1D NumPy array, and `Points` is 95 | based on a 2D NumPy array, where each row represents a point in space. 96 | The `Line` and `Plane` objects have `Point` and `Vector` objects as 97 | attributes. 98 | 99 | Note that most methods inherited from NumPy return a regular NumPy object, 100 | instead of the spatial object class. 101 | 102 | ```py 103 | >>> vector.sum() 104 | np.int64(2) 105 | 106 | ``` 107 | 108 | This is to avoid getting a spatial object with a forbidden shape, like a 109 | zero dimension `Vector`. Trying to convert this back to a `Vector` 110 | causes an exception. 111 | 112 | ```py 113 | >>> Vector(vector.sum()) 114 | Traceback (most recent call last): 115 | ValueError: The array must be 1D. 116 | 117 | ``` 118 | 119 | Because the computations of `scikit-spatial` are also based on NumPy, 120 | keyword arguments can be passed to NumPy functions. For example, a 121 | tolerance can be specified while testing for collinearity. The `tol` 122 | keyword is passed to `numpy.linalg.matrix_rank`. 123 | 124 | ```py 125 | >>> from skspatial.objects import Points 126 | 127 | >>> points = Points([[1, 2, 3], [4, 5, 6], [7, 8, 8]]) 128 | 129 | >>> points.are_collinear() 130 | False 131 | 132 | >>> points.are_collinear(tol=1) 133 | True 134 | 135 | ``` 136 | 137 | # Installation 138 | 139 | The package can be installed with pip. 140 | 141 | ```bash 142 | $ pip install scikit-spatial 143 | 144 | ``` 145 | 146 | It can also be installed with conda. 147 | 148 | ```bash 149 | $ conda install scikit-spatial -c conda-forge 150 | 151 | ``` 152 | 153 | The `matplotlib` dependency is optional. To enable plotting, you can install scikit-spatial with the extra `plotting`. 154 | 155 | ```bash 156 | $ pip install 'scikit-spatial[plotting]' 157 | 158 | ``` 159 | 160 | # Example Usage 161 | 162 | ## Measurement 163 | 164 | Measure the cosine similarity between two vectors. 165 | 166 | ```py 167 | >>> from skspatial.objects import Vector 168 | 169 | >>> Vector([1, 0]).cosine_similarity([1, 1]).round(3) 170 | np.float64(0.707) 171 | 172 | ``` 173 | 174 | ## Comparison 175 | 176 | Check if multiple points are collinear. 177 | 178 | ```py 179 | >>> from skspatial.objects import Points 180 | 181 | >>> points = Points([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]) 182 | 183 | >>> points.are_collinear() 184 | True 185 | 186 | ``` 187 | 188 | ## Projection 189 | 190 | Project a point onto a line. 191 | 192 | ```py 193 | >>> from skspatial.objects import Line 194 | 195 | >>> line = Line(point=[0, 0, 0], direction=[1, 1, 0]) 196 | 197 | >>> line.project_point([5, 6, 7]) 198 | Point([5.5, 5.5, 0. ]) 199 | 200 | ``` 201 | 202 | ## Intersection 203 | 204 | Find the intersection of two planes. 205 | 206 | ```py 207 | >>> from skspatial.objects import Plane 208 | 209 | >>> plane_a = Plane(point=[0, 0, 0], normal=[0, 0, 1]) 210 | >>> plane_b = Plane(point=[5, 16, -94], normal=[1, 0, 0]) 211 | 212 | >>> plane_a.intersect_plane(plane_b) 213 | Line(point=Point([5., 0., 0.]), direction=Vector([0, 1, 0])) 214 | 215 | ``` 216 | 217 | An error is raised if the computation is undefined. 218 | 219 | ```py 220 | >>> plane_b = Plane(point=[0, 0, 1], normal=[0, 0, 1]) 221 | 222 | >>> plane_a.intersect_plane(plane_b) 223 | Traceback (most recent call last): 224 | ValueError: The planes must not be parallel. 225 | 226 | ``` 227 | 228 | ## Fitting 229 | 230 | Find the plane of best fit for multiple points. 231 | 232 | ```py 233 | >>> points = [[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]] 234 | 235 | >>> Plane.best_fit(points) 236 | Plane(point=Point([0.5, 0.5, 0. ]), normal=Vector([0., 0., 1.])) 237 | 238 | ``` 239 | 240 | ## Transformation 241 | 242 | Transform multiple points to 1D coordinates along a line. 243 | 244 | ```py 245 | >>> line = Line(point=[0, 0, 0], direction=[1, 2, 0]) 246 | >>> points = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] 247 | 248 | >>> line.transform_points(points).round(3) 249 | array([ 2.236, 6.261, 10.286]) 250 | 251 | ``` 252 | 253 | # Acknowledgment 254 | 255 | This package was created with [Cookiecutter](https://github.com/audreyr/cookiecutter) and the [audreyr/cookiecutter-pypackage](https://github.com/audreyr/cookiecutter-pypackage) project template. 256 | -------------------------------------------------------------------------------- /docs/source/api_reference/skspatial.measurement.rst: -------------------------------------------------------------------------------- 1 | 2 | skspatial.measurement 3 | ===================== 4 | 5 | .. automodule:: skspatial.measurement 6 | 7 | 8 | .. autosummary:: 9 | :toctree: measurement/functions 10 | 11 | area_signed 12 | area_triangle 13 | volume_tetrahedron 14 | -------------------------------------------------------------------------------- /docs/source/api_reference/skspatial.objects.Circle.rst: -------------------------------------------------------------------------------- 1 | 2 | skspatial.objects.Circle 3 | ======================== 4 | 5 | Methods 6 | ------- 7 | .. autosummary:: 8 | :toctree: Circle/methods 9 | 10 | ~skspatial.objects.Circle.area 11 | ~skspatial.objects.Circle.best_fit 12 | ~skspatial.objects.Circle.circumference 13 | ~skspatial.objects.Circle.from_points 14 | ~skspatial.objects.Circle.intersect_circle 15 | ~skspatial.objects.Circle.intersect_line 16 | ~skspatial.objects.Circle.plot_2d 17 | -------------------------------------------------------------------------------- /docs/source/api_reference/skspatial.objects.Cylinder.rst: -------------------------------------------------------------------------------- 1 | 2 | skspatial.objects.Cylinder 3 | ========================== 4 | 5 | Methods 6 | ------- 7 | .. autosummary:: 8 | :toctree: Cylinder/methods 9 | 10 | ~skspatial.objects.Cylinder.from_points 11 | ~skspatial.objects.Cylinder.intersect_line 12 | ~skspatial.objects.Cylinder.is_point_within 13 | ~skspatial.objects.Cylinder.lateral_surface_area 14 | ~skspatial.objects.Cylinder.length 15 | ~skspatial.objects.Cylinder.plot_3d 16 | ~skspatial.objects.Cylinder.surface_area 17 | ~skspatial.objects.Cylinder.to_mesh 18 | ~skspatial.objects.Cylinder.to_points 19 | ~skspatial.objects.Cylinder.volume 20 | -------------------------------------------------------------------------------- /docs/source/api_reference/skspatial.objects.Line.rst: -------------------------------------------------------------------------------- 1 | 2 | skspatial.objects.Line 3 | ====================== 4 | 5 | Methods 6 | ------- 7 | .. autosummary:: 8 | :toctree: Line/methods 9 | 10 | ~skspatial.objects.Line.best_fit 11 | ~skspatial.objects.Line.distance_line 12 | ~skspatial.objects.Line.distance_point 13 | ~skspatial.objects.Line.distance_points 14 | ~skspatial.objects.Line.from_points 15 | ~skspatial.objects.Line.from_slope 16 | ~skspatial.objects.Line.intersect_line 17 | ~skspatial.objects.Line.is_coplanar 18 | ~skspatial.objects.Line.plot_2d 19 | ~skspatial.objects.Line.plot_3d 20 | ~skspatial.objects.Line.project_point 21 | ~skspatial.objects.Line.project_points 22 | ~skspatial.objects.Line.project_vector 23 | ~skspatial.objects.Line.side_point 24 | ~skspatial.objects.Line.to_point 25 | ~skspatial.objects.Line.transform_points 26 | -------------------------------------------------------------------------------- /docs/source/api_reference/skspatial.objects.LineSegment.rst: -------------------------------------------------------------------------------- 1 | 2 | skspatial.objects.LineSegment 3 | ============================= 4 | 5 | Methods 6 | ------- 7 | .. autosummary:: 8 | :toctree: LineSegment/methods 9 | 10 | ~skspatial.objects.LineSegment.contains_point 11 | ~skspatial.objects.LineSegment.intersect_line_segment 12 | ~skspatial.objects.LineSegment.plot_2d 13 | ~skspatial.objects.LineSegment.plot_3d 14 | -------------------------------------------------------------------------------- /docs/source/api_reference/skspatial.objects.Plane.rst: -------------------------------------------------------------------------------- 1 | 2 | skspatial.objects.Plane 3 | ======================= 4 | 5 | Methods 6 | ------- 7 | .. autosummary:: 8 | :toctree: Plane/methods 9 | 10 | ~skspatial.objects.Plane.best_fit 11 | ~skspatial.objects.Plane.cartesian 12 | ~skspatial.objects.Plane.distance_point 13 | ~skspatial.objects.Plane.distance_point_signed 14 | ~skspatial.objects.Plane.distance_points 15 | ~skspatial.objects.Plane.distance_points_signed 16 | ~skspatial.objects.Plane.from_points 17 | ~skspatial.objects.Plane.from_vectors 18 | ~skspatial.objects.Plane.intersect_line 19 | ~skspatial.objects.Plane.intersect_plane 20 | ~skspatial.objects.Plane.plot_3d 21 | ~skspatial.objects.Plane.project_line 22 | ~skspatial.objects.Plane.project_point 23 | ~skspatial.objects.Plane.project_points 24 | ~skspatial.objects.Plane.project_vector 25 | ~skspatial.objects.Plane.side_point 26 | ~skspatial.objects.Plane.to_mesh 27 | ~skspatial.objects.Plane.to_points 28 | -------------------------------------------------------------------------------- /docs/source/api_reference/skspatial.objects.Point.rst: -------------------------------------------------------------------------------- 1 | 2 | skspatial.objects.Point 3 | ======================= 4 | 5 | Methods 6 | ------- 7 | .. autosummary:: 8 | :toctree: Point/methods 9 | 10 | ~skspatial.objects.Point.distance_point 11 | ~skspatial.objects.Point.plot_2d 12 | ~skspatial.objects.Point.plot_3d 13 | -------------------------------------------------------------------------------- /docs/source/api_reference/skspatial.objects.Points.rst: -------------------------------------------------------------------------------- 1 | 2 | skspatial.objects.Points 3 | ======================== 4 | 5 | Methods 6 | ------- 7 | .. autosummary:: 8 | :toctree: Points/methods 9 | 10 | ~skspatial.objects.Points.affine_rank 11 | ~skspatial.objects.Points.are_collinear 12 | ~skspatial.objects.Points.are_concurrent 13 | ~skspatial.objects.Points.are_coplanar 14 | ~skspatial.objects.Points.centroid 15 | ~skspatial.objects.Points.is_close 16 | ~skspatial.objects.Points.mean_center 17 | ~skspatial.objects.Points.plot_2d 18 | ~skspatial.objects.Points.plot_3d 19 | ~skspatial.objects.Points.unique 20 | -------------------------------------------------------------------------------- /docs/source/api_reference/skspatial.objects.Sphere.rst: -------------------------------------------------------------------------------- 1 | 2 | skspatial.objects.Sphere 3 | ======================== 4 | 5 | Methods 6 | ------- 7 | .. autosummary:: 8 | :toctree: Sphere/methods 9 | 10 | ~skspatial.objects.Sphere.best_fit 11 | ~skspatial.objects.Sphere.intersect_line 12 | ~skspatial.objects.Sphere.plot_3d 13 | ~skspatial.objects.Sphere.surface_area 14 | ~skspatial.objects.Sphere.to_mesh 15 | ~skspatial.objects.Sphere.to_points 16 | ~skspatial.objects.Sphere.volume 17 | -------------------------------------------------------------------------------- /docs/source/api_reference/skspatial.objects.Triangle.rst: -------------------------------------------------------------------------------- 1 | 2 | skspatial.objects.Triangle 3 | ========================== 4 | 5 | Methods 6 | ------- 7 | .. autosummary:: 8 | :toctree: Triangle/methods 9 | 10 | ~skspatial.objects.Triangle.altitude 11 | ~skspatial.objects.Triangle.angle 12 | ~skspatial.objects.Triangle.area 13 | ~skspatial.objects.Triangle.centroid 14 | ~skspatial.objects.Triangle.classify 15 | ~skspatial.objects.Triangle.is_right 16 | ~skspatial.objects.Triangle.length 17 | ~skspatial.objects.Triangle.line 18 | ~skspatial.objects.Triangle.multiple 19 | ~skspatial.objects.Triangle.normal 20 | ~skspatial.objects.Triangle.orthocenter 21 | ~skspatial.objects.Triangle.perimeter 22 | ~skspatial.objects.Triangle.plot_2d 23 | ~skspatial.objects.Triangle.plot_3d 24 | ~skspatial.objects.Triangle.point 25 | -------------------------------------------------------------------------------- /docs/source/api_reference/skspatial.objects.Vector.rst: -------------------------------------------------------------------------------- 1 | 2 | skspatial.objects.Vector 3 | ======================== 4 | 5 | Methods 6 | ------- 7 | .. autosummary:: 8 | :toctree: Vector/methods 9 | 10 | ~skspatial.objects.Vector.angle_between 11 | ~skspatial.objects.Vector.angle_signed 12 | ~skspatial.objects.Vector.angle_signed_3d 13 | ~skspatial.objects.Vector.cosine_similarity 14 | ~skspatial.objects.Vector.cross 15 | ~skspatial.objects.Vector.different_direction 16 | ~skspatial.objects.Vector.from_points 17 | ~skspatial.objects.Vector.is_parallel 18 | ~skspatial.objects.Vector.is_perpendicular 19 | ~skspatial.objects.Vector.is_zero 20 | ~skspatial.objects.Vector.norm 21 | ~skspatial.objects.Vector.plot_2d 22 | ~skspatial.objects.Vector.plot_3d 23 | ~skspatial.objects.Vector.project_vector 24 | ~skspatial.objects.Vector.scalar_projection 25 | ~skspatial.objects.Vector.side_vector 26 | ~skspatial.objects.Vector.unit 27 | -------------------------------------------------------------------------------- /docs/source/api_reference/skspatial.objects.rst: -------------------------------------------------------------------------------- 1 | skspatial.objects 2 | ================= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | skspatial.objects.Point 8 | skspatial.objects.Points 9 | skspatial.objects.Vector 10 | skspatial.objects.Line 11 | skspatial.objects.LineSegment 12 | skspatial.objects.Plane 13 | skspatial.objects.Circle 14 | skspatial.objects.Sphere 15 | skspatial.objects.Triangle 16 | skspatial.objects.Cylinder 17 | -------------------------------------------------------------------------------- /docs/source/api_reference/skspatial.transformation.rst: -------------------------------------------------------------------------------- 1 | 2 | skspatial.transformation 3 | ======================== 4 | 5 | .. automodule:: skspatial.transformation 6 | 7 | 8 | .. autosummary:: 9 | :toctree: transformation/functions 10 | 11 | transform_coordinates 12 | -------------------------------------------------------------------------------- /docs/source/api_reference/toc.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ------------- 3 | 4 | Spatial objects 5 | ~~~~~~~~~~~~~~~ 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | skspatial.objects 11 | 12 | 13 | Modules 14 | ~~~~~~~ 15 | 16 | .. toctree:: 17 | :maxdepth: 2 18 | 19 | skspatial.measurement 20 | skspatial.transformation 21 | -------------------------------------------------------------------------------- /docs/source/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/master/config 8 | # -- Path setup -------------------------------------------------------------- 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | import sphinx_bootstrap_theme 17 | from sphinx_gallery.sorting import ExplicitOrder 18 | 19 | sys.path.insert(0, os.path.abspath(os.path.join('..', '..'))) 20 | 21 | import skspatial # noqa 22 | 23 | 24 | # -- Project information ----------------------------------------------------- 25 | 26 | project = 'scikit-spatial' 27 | copyright = '2019, Andrew Hynes' # noqa 28 | author = 'Andrew Hynes' 29 | 30 | # The short X.Y version 31 | version = skspatial.__version__ 32 | 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 | 'matplotlib.sphinxext.plot_directive', 39 | 'numpydoc', 40 | 'sphinx.ext.autodoc', 41 | 'sphinx.ext.autosummary', 42 | 'sphinx.ext.coverage', 43 | 'sphinx.ext.doctest', 44 | 'sphinx.ext.githubpages', 45 | 'sphinx.ext.intersphinx', 46 | 'sphinx.ext.mathjax', 47 | 'sphinx.ext.viewcode', 48 | 'sphinx_gallery.gen_gallery', 49 | ] 50 | 51 | intersphinx_mapping = { 52 | 'numpy': ('http://docs.scipy.org/doc/numpy/', None), 53 | 'matplotlib': ('http://matplotlib.org/', None), 54 | } 55 | 56 | sphinx_gallery_conf = { 57 | 'examples_dirs': '../../examples', # Path to example scripts 58 | 'gallery_dirs': 'gallery', # Path to save generated examples 59 | 'download_all_examples': False, 60 | 'subsection_order': ExplicitOrder( 61 | [ 62 | '../../examples/projection', 63 | '../../examples/intersection', 64 | '../../examples/fitting', 65 | '../../examples/triangle', 66 | ], 67 | ), 68 | } 69 | 70 | autosummary_generate = True 71 | 72 | # Prevent warnings about nonexisting documents 73 | numpydoc_show_class_members = False 74 | 75 | # Add any paths that contain templates here, relative to this directory. 76 | templates_path = ['_templates'] 77 | 78 | # The suffix(es) of source filenames. 79 | # You can specify multiple suffix as a list of string: 80 | source_suffix = '.rst' 81 | 82 | # The master toctree document. 83 | master_doc = 'index' 84 | 85 | # The language for content autogenerated by Sphinx. Refer to documentation 86 | # for a list of supported languages. 87 | # 88 | # This is also used if you do content translation via gettext catalogs. 89 | # Usually you set "language" from the command line for these cases. 90 | language = "en" 91 | 92 | # List of patterns, relative to source directory, that match files and 93 | # directories to ignore when looking for source files. 94 | # This pattern also affects html_static_path and html_extra_path. 95 | exclude_patterns = [] 96 | 97 | # The name of the Pygments (syntax highlighting) style to use. 98 | pygments_style = None 99 | 100 | 101 | # -- Options for HTML output ------------------------------------------------- 102 | 103 | # The theme to use for HTML and HTML Help pages. See the documentation for 104 | # a list of builtin themes. 105 | # 106 | html_theme = 'bootstrap' 107 | html_theme_path = sphinx_bootstrap_theme.get_html_theme_path() 108 | 109 | 110 | # Theme options are theme-specific and customize the look and feel of a theme 111 | # further. For a list of options available for each theme, see the 112 | # documentation. 113 | # 114 | html_theme_options = { 115 | 'bootswatch_theme': 'cosmo', 116 | 'globaltoc_depth': -1, 117 | 'navbar_links': [ 118 | ('Objects', 'objects/toc'), 119 | ('Plotting', 'plotting'), 120 | ('Gallery', 'gallery/index'), 121 | ('API', 'api_reference/toc'), 122 | ], 123 | 'navbar_pagenav': False, 124 | 'navbar_sidebarrel': False, 125 | 'source_link_position': None, 126 | } 127 | 128 | # Add any paths that contain custom static files (such as style sheets) here, 129 | # relative to this directory. They are copied after the builtin static files, 130 | # so a file named "default.css" will overwrite the builtin "default.css". 131 | html_static_path = [] 132 | 133 | # Custom sidebar templates, must be a dictionary that maps document names 134 | # to template names. 135 | # 136 | # The default sidebars (for documents that don't match any pattern) are 137 | # defined by theme itself. Builtin themes are using these templates by 138 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 139 | # 'searchbox.html']``. 140 | # 141 | 142 | 143 | # -- Options for HTMLHelp output --------------------------------------------- 144 | 145 | # Output file base name for HTML help builder. 146 | htmlhelp_basename = 'scikit-spatialdoc' 147 | 148 | 149 | # -- Options for LaTeX output ------------------------------------------------ 150 | 151 | # Grouping the document tree into LaTeX files. List of tuples 152 | # (source start file, target name, title, 153 | # author, documentclass [howto, manual, or own class]). 154 | latex_documents = [ 155 | (master_doc, 'scikit-spatial.tex', 'scikit-spatial Documentation', 'Andrew Hynes', 'manual'), 156 | ] 157 | 158 | 159 | # -- Options for manual page output ------------------------------------------ 160 | 161 | # One entry per manual page. List of tuples 162 | # (source start file, name, description, authors, manual section). 163 | man_pages = [(master_doc, 'scikit-spatial', 'scikit-spatial Documentation', [author], 1)] 164 | 165 | 166 | # -- Options for Texinfo output ---------------------------------------------- 167 | 168 | # Grouping the document tree into Texinfo files. List of tuples 169 | # (source start file, target name, title, author, 170 | # dir menu entry, description, category) 171 | texinfo_documents = [ 172 | ( 173 | master_doc, 174 | 'scikit-spatial', 175 | 'scikit-spatial Documentation', 176 | author, 177 | 'scikit-spatial', 178 | 'One line description of project.', 179 | 'Miscellaneous', 180 | ), 181 | ] 182 | 183 | 184 | # -- Options for Epub output ------------------------------------------------- 185 | 186 | # Bibliographic Dublin Core info. 187 | epub_title = project 188 | 189 | # A list of files that should not be packed into the epub file. 190 | epub_exclude_files = ['search.html'] 191 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | .. figure:: images/logo.svg 4 | :align: left 5 | :width: 70% 6 | 7 | 8 | Introduction 9 | ~~~~~~~~~~~~ 10 | 11 | ``scikit-spatial`` is a Python library that provides spatial objects and computations between them. The basic objects -- points and vectors -- are subclasses of the NumPy :class:`~numpy.ndarray`. Other objects such as lines, planes, and circles have points and/or vectors as attributes. 12 | 13 | 14 | Computations can be performed after instantiating a spatial object. For example, a point can be projected onto a plane. 15 | 16 | >>> from skspatial.objects import Plane 17 | 18 | >>> plane = Plane(point=[0, 0, 0], normal=[1, 1, 1]) 19 | 20 | >>> plane.project_point([0, 5, 10]) 21 | Point([-5., 0., 5.]) 22 | 23 | 24 | Most of the computations fall into the following categories: 25 | 26 | - Measurement 27 | - Comparison 28 | - Projection 29 | - Intersection 30 | - Fitting 31 | - Transformation 32 | 33 | The spatial objects can also be visualized on 2D or 3D plots using `matplotlib `_. See :ref:`plotting` for a brief introduction and the :ref:`sphx_glr_gallery` for full examples with code. 34 | 35 | The library has four main objectives: 36 | 37 | 1. Provide an intuitive, object-oriented API for spatial computations. 38 | 2. Provide efficient computations by leveraging NumPy functionality whenever possible. 39 | 3. Integrate seamlessly with other libraries in the `scientific Python ecosystem `_. 40 | 4. Facilitate the visualization of spatial objects in 2D or 3D space. 41 | 42 | 43 | Installation 44 | ~~~~~~~~~~~~ 45 | 46 | The package can be installed via pip. 47 | 48 | .. code-block:: bash 49 | 50 | $ pip install scikit-spatial 51 | 52 | 53 | 54 | Contents 55 | ~~~~~~~~ 56 | 57 | .. toctree:: 58 | :maxdepth: 1 59 | 60 | objects/toc 61 | plotting 62 | gallery/index 63 | api_reference/toc 64 | -------------------------------------------------------------------------------- /docs/source/objects/circle.rst: -------------------------------------------------------------------------------- 1 | 2 | Circle 3 | ------ 4 | 5 | A :class:`~skspatial.objects.Circle` object is defined by a 2D point and a radius. The point is the center of the circle. 6 | 7 | >>> from skspatial.objects import Circle 8 | 9 | >>> circle = Circle([0, 0], 1) 10 | 11 | >>> circle 12 | Circle(point=Point([0, 0]), radius=1) 13 | 14 | 15 | The circumference and area of the circle can be calculated. 16 | 17 | >>> circle.circumference().round(3) 18 | 6.283 19 | 20 | >>> circle.area().round(3) 21 | 3.142 22 | 23 | 24 | An error is raised if the point is not 2D. 25 | 26 | >>> Circle([0, 0, 0], 1) 27 | Traceback (most recent call last): 28 | ... 29 | ValueError: The point must be 2D. 30 | -------------------------------------------------------------------------------- /docs/source/objects/cylinder.rst: -------------------------------------------------------------------------------- 1 | Cylinder 2 | -------- 3 | 4 | A :class:`~skspatial.objects.Cylinder` object is defined by a point, a vector, and a radius. 5 | 6 | The point is the centre of the cylinder base. The vector is normal to the base, and the length of the cylinder is the length of this vector. 7 | The point and vector must be 3D. 8 | 9 | >>> from skspatial.objects import Cylinder 10 | 11 | >>> cylinder = Cylinder(point=[0, 0, 0], vector=[0, 0, 5], radius=1) 12 | 13 | >>> cylinder 14 | Cylinder(point=Point([0, 0, 0]), vector=Vector([0, 0, 5]), radius=1) 15 | 16 | >>> cylinder.length() 17 | 5.0 18 | 19 | >>> cylinder.volume().round(3) 20 | 15.708 21 | 22 | You can check if a point is inside (or on the surface of) the cylinder. 23 | 24 | >>> cylinder.is_point_within([0, 0, 0]) 25 | True 26 | >>> cylinder.is_point_within([0, 0, 5]) 27 | True 28 | >>> cylinder.is_point_within([1, 0, 3]) 29 | True 30 | >>> cylinder.is_point_within([2, 0, 3]) 31 | False 32 | -------------------------------------------------------------------------------- /docs/source/objects/line.rst: -------------------------------------------------------------------------------- 1 | 2 | Line 3 | ---- 4 | 5 | A :class:`~skspatial.objects.Line` object is defined by a point and a direction vector. 6 | 7 | >>> from skspatial.objects import Line 8 | 9 | >>> line_1 = Line(point=[0, 0], direction=[5, 0]) 10 | 11 | >>> line_1 12 | Line(point=Point([0, 0]), direction=Vector([5, 0])) 13 | 14 | 15 | Alternatively, a line can be defined by two points. 16 | 17 | >>> line_2 = Line.from_points([0, 0], [100, 0]) 18 | 19 | >>> line_1.is_close(line_2) 20 | True 21 | 22 | 23 | The ``is_close`` method checks if two lines are equal within a tolerance. 24 | 25 | Lines with different points and directions can still be equal. One line must contain the other line's point, and their vectors must be parallel. 26 | 27 | >>> line_1 = Line([0, 0], [1, 0]) 28 | >>> line_2 = Line([10, 0], [-5, 0]) 29 | 30 | >>> line_1.is_close(line_2) 31 | True 32 | 33 | The distance from a point to a line can be found. 34 | 35 | >>> line_1.distance_point([20, 75]) 36 | 75.0 37 | 38 | A point can be projected onto a line, returning a new :class:`~skspatial.objects.Point` object. 39 | 40 | >>> line_1.project_point([50, 20]) 41 | Point([50., 0.]) 42 | -------------------------------------------------------------------------------- /docs/source/objects/line_segment.rst: -------------------------------------------------------------------------------- 1 | 2 | LineSegment 3 | ----------- 4 | 5 | A :class:`~skspatial.objects.LineSegment` object is defined by two points, which are the endpoints of the segment. 6 | 7 | >>> from skspatial.objects import LineSegment 8 | 9 | >>> segment_1 = LineSegment([0, 0], [1, 0]) 10 | 11 | >>> segment_1 12 | LineSegment(point_a=Point([0, 0]), point_b=Point([1, 0])) 13 | 14 | 15 | The segment contains the two endpoints, but not points beyond the endpoints on the same line, nor points off the line. 16 | 17 | >>> segment_1.contains_point([0, 0]) 18 | True 19 | >>> segment_1.contains_point([0.5, 0]) 20 | True 21 | >>> segment_1.contains_point([1, 0]) 22 | True 23 | 24 | >>> segment_1.contains_point([0, 2]) 25 | False 26 | >>> segment_1.contains_point([0, 1]) 27 | False 28 | 29 | 30 | The intersection of two segments can be found. An exception will be raised if the segments do not intersect. 31 | 32 | >>> segment_2 = LineSegment([0.5, 1], [0.5, -1]) 33 | 34 | >>> segment_1.intersect_line_segment(segment_2) 35 | Point([0.5, 0. ]) 36 | -------------------------------------------------------------------------------- /docs/source/objects/plane.rst: -------------------------------------------------------------------------------- 1 | 2 | Plane 3 | ----- 4 | 5 | A :class:`~skspatial.objects.Plane` object is defined by a point and a normal vector. 6 | 7 | >>> from skspatial.objects import Plane 8 | 9 | >>> plane_1 = Plane(point=[0, 0, 0], normal=[0, 0, 23]) 10 | 11 | >>> plane_1 12 | Plane(point=Point([0, 0, 0]), normal=Vector([ 0, 0, 23])) 13 | 14 | Alternatively, a plane can be defined by three points. 15 | 16 | >>> point_a, point_b, point_c = [0, 0], [10, -2], [50, 500] 17 | >>> plane_2 = Plane.from_points(point_a, point_b, point_c) 18 | 19 | >>> plane_2 20 | Plane(point=Point([0, 0, 0]), normal=Vector([ 0, 0, 5100])) 21 | 22 | >>> plane_1.is_close(plane_2) 23 | True 24 | 25 | Changing the order of the points can reverse the direction of the normal vector. 26 | 27 | >>> plane_3 = Plane.from_points(point_a, point_c, point_b) 28 | 29 | >>> plane_3 30 | Plane(point=Point([0, 0, 0]), normal=Vector([ 0, 0, -5100])) 31 | 32 | The planes will still be equal. 33 | 34 | >>> plane_1.is_close(plane_3) 35 | True 36 | -------------------------------------------------------------------------------- /docs/source/objects/point_vector.rst: -------------------------------------------------------------------------------- 1 | 2 | Point and Vector 3 | ---------------- 4 | 5 | The two basic spatial objects are the :class:`~skspatial.objects.Point`, which represents a position in space, and the :class:`~skspatial.objects.Vector`, which represents an arrow through space. 6 | 7 | They are instantiated with an ``array_like`` object, which is an object that can be passed to :func:`numpy.array`. 8 | 9 | >>> import numpy as np 10 | >>> from skspatial.objects import Point 11 | 12 | >>> point_1 = Point([1, 2]) 13 | >>> point_2 = Point((1, 2)) 14 | >>> point_3 = Point(np.array([1, 2])) 15 | 16 | >>> np.array_equal(point_1, point_2) 17 | True 18 | 19 | >>> np.array_equal(point_1, point_3) 20 | True 21 | 22 | 23 | :class:`~skspatial.objects.Point` and :class:`~skspatial.objects.Vector` are both subclasses of the NumPy :class:`~numpy.ndarray`, which gives them all the functionality of a regular NumPy array. 24 | 25 | >>> point_1 26 | Point([1, 2]) 27 | 28 | >>> point_1.size 29 | 2 30 | 31 | >>> point_1.shape 32 | (2,) 33 | 34 | 35 | A :class:`~skspatial.objects.Vector` can be added to a :class:`~skspatial.objects.Point`, producing a new :class:`~skspatial.objects.Point`. 36 | 37 | >>> Point([1, 2]) + Vector([3, 4]) 38 | Point([4, 6]) 39 | 40 | 41 | The magnitude of a vector is found with the :meth:`~skspatial.objects.Vector.norm` method. 42 | 43 | >>> from skspatial.objects import Vector 44 | 45 | >>> vector = Vector([1, 1]) 46 | >>> vector.norm().round(3) 47 | 1.414 48 | 49 | The unit vector can also be obtained. 50 | 51 | >>> vector_unit = vector.unit() 52 | 53 | >>> vector_unit.round(3) 54 | Vector([0.707, 0.707]) 55 | 56 | One vector can be projected onto another. 57 | 58 | >>> vector_u = Vector([1, 0]) 59 | >>> vector_v = Vector([5, 9]) 60 | 61 | >>> vector_u.project_vector(vector_v) # Project vector v onto vector u. 62 | Vector([5., 0.]) 63 | -------------------------------------------------------------------------------- /docs/source/objects/points.rst: -------------------------------------------------------------------------------- 1 | 2 | Points 3 | ------ 4 | 5 | The :class:`~skspatial.objects.Points` class represents multiple points in space. 6 | 7 | While :class:`~skspatial.objects.Point` and :class:`~skspatial.objects.Vector` objects are instantiated with a 1D array, a :class:`~skspatial.objects.Points` object is instantiated with a 2D array. 8 | 9 | >>> from skspatial.objects import Points 10 | 11 | >>> points = Points([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) 12 | 13 | 14 | The centroid of the points is a :class:`~skspatial.objects.Point`. 15 | 16 | >>> points.centroid() 17 | Point([4., 5., 6.]) 18 | 19 | 20 | The points can be mean-centered, meaning that the centroid is treated as the origin of a new coordinate system. 21 | The original centroid can also be returned by the method. 22 | 23 | >>> points_centered, centroid = points.mean_center(return_centroid=True) 24 | 25 | >>> points_centered 26 | Points([[-3., -3., -3.], 27 | [ 0., 0., 0.], 28 | [ 3., 3., 3.]]) 29 | 30 | >>> centroid 31 | Point([4., 5., 6.]) 32 | 33 | 34 | The affine rank is the dimension of the smallest affine space that contains all the points. 35 | For example, if the points are contained by a line, the affine rank is one. 36 | 37 | >>> points.affine_rank() 38 | 1 39 | 40 | The affine rank is used to test for concurrency, collinearity and coplanarity. 41 | 42 | >>> points.are_concurrent() 43 | False 44 | >>> points.are_collinear() 45 | True 46 | >>> points.are_coplanar() 47 | True 48 | -------------------------------------------------------------------------------- /docs/source/objects/sphere.rst: -------------------------------------------------------------------------------- 1 | 2 | Sphere 3 | ------ 4 | 5 | A :class:`~skspatial.objects.Sphere` object is defined by a 3D point and a radius. The point is the center of the sphere. 6 | 7 | >>> from skspatial.objects import Sphere 8 | 9 | >>> sphere = Sphere([0, 0, 0], 1) 10 | 11 | >>> sphere 12 | Sphere(point=Point([0, 0, 0]), radius=1) 13 | 14 | 15 | The surface area and volume of the sphere can be calculated. 16 | 17 | >>> sphere.surface_area().round(3) 18 | 12.566 19 | 20 | >>> sphere.volume().round(3) 21 | 4.189 22 | 23 | 24 | An error is raised if the point is not 2D. 25 | 26 | >>> Sphere([0, 0], 1) 27 | Traceback (most recent call last): 28 | ... 29 | ValueError: The point must be 3D. 30 | -------------------------------------------------------------------------------- /docs/source/objects/toc.rst: -------------------------------------------------------------------------------- 1 | 2 | Spatial Objects 3 | --------------- 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | point_vector 9 | points 10 | line 11 | line_segment 12 | plane 13 | circle 14 | sphere 15 | triangle 16 | cylinder 17 | -------------------------------------------------------------------------------- /docs/source/objects/triangle.rst: -------------------------------------------------------------------------------- 1 | 2 | Triangle 3 | -------- 4 | 5 | A :class:`~skspatial.objects.Triangle` object is defined by three points. 6 | 7 | >>> from skspatial.objects import Triangle 8 | 9 | >>> triangle = Triangle([0, 0], [1, 0], [0, 1]) 10 | 11 | >>> triangle 12 | Triangle(point_a=Point([0, 0]), point_b=Point([1, 0]), point_c=Point([0, 1])) 13 | 14 | 15 | The triangle can be classified as equilateral, isosceles, or scalene. 16 | 17 | >>> triangle.classify() 18 | 'isosceles' 19 | 20 | You can also check if it's a right triangle. 21 | 22 | >>> triangle.is_right() 23 | True 24 | 25 | 26 | Parametrized methods 27 | ~~~~~~~~~~~~~~~~~~~~ 28 | 29 | Several methods are parametrized to specify a side or vertex of the triangle. For example, the `angle` method is passed a character 'A', 'B', or 'C' to denote the vertex. The angle is returned in radians. 30 | 31 | >>> triangle.angle('A').round(3) 32 | 1.571 33 | 34 | Because this is a common pattern for the triangle, a `multiple` method is provided to make multiple calls of the same method, such as 'angle'. This library uses the convention of vertex 'A' being across from side 'a', vertex 'B' being across from side 'b', etc. 35 | 36 | >>> [x.round(3) for x in triangle.multiple('angle', 'ABC')] 37 | [1.571, 0.785, 0.785] 38 | 39 | The following line returns the three lengths of the triangle. 40 | 41 | >>> [x.round(3) for x in triangle.multiple('length', 'abc')] 42 | [1.414, 1.0, 1.0] 43 | 44 | 45 | Other spatial objects 46 | ~~~~~~~~~~~~~~~~~~~~~ 47 | 48 | Some triangle methods return other spatial objects. 49 | 50 | >>> triangle.normal() 51 | Vector([0, 0, 1]) 52 | 53 | >>> triangle.altitude('A') 54 | Line(point=Point([0, 0]), direction=Vector([0.5, 0.5])) 55 | 56 | >>> triangle.orthocenter() 57 | Point([0., 0.]) 58 | -------------------------------------------------------------------------------- /docs/source/plotting.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _plotting: 3 | 4 | Plotting 5 | -------- 6 | 7 | This library uses ``matplotlib`` to enable plotting of all of its spatial objects. Each object has a ``plot_2d`` and/or ``plot_3d`` method. For example, a :class:`Point` object can be plotted in 2D or 3D, while a :class:`Sphere` object can only be plotted in 3D. 8 | 9 | The ``matplotlib`` dependency is optional. To install it, you can install scikit-spatial with the extra `plotting`. 10 | 11 | .. code-block:: console 12 | 13 | $ pip install 'scikit-spatial[plotting]' 14 | 15 | 16 | The ``plot_2d`` methods require an instance of :class:`~matplotlib.axes.Axes` as the first argument, while the ``plot_3d`` methods require an instance of :class:`~mpl_toolkits.mplot3d.axes3d.Axes3D`. This allows for placing multiple spatial objects on the same plot, which is useful for visualizing computations such as projection or intersection. 17 | 18 | The methods also pass keyword arguments to ``matplotlib`` functions. For example, ``Point.plot_2d`` uses :meth:`~matplotlib.axes.Axes.scatter` under the hood, so any keyword arguments to :meth:`~matplotlib.axes.Axes.scatter` can also be input to the method. Some plotting methods have additional keyword arguments that are not passed to ``matplotlib``, such as ``Line.plot_2d``, which takes parameters ``t_1`` and ``t_2`` to determine the start and end points of the line. 19 | 20 | 21 | Let's project a 2D point onto a 2D line and plot the result with ``plot_2d`` methods. 22 | 23 | .. plot:: 24 | :include-source: 25 | 26 | >>> import matplotlib.pyplot as plt 27 | 28 | >>> from skspatial.objects import Point, Line 29 | 30 | >>> point = Point([0, 5]) 31 | >>> line = Line(point=[0, 0], direction=[1, 1]) 32 | 33 | >>> point_projected = line.project_point(point) 34 | 35 | >>> _, ax = plt.subplots() 36 | 37 | >>> line.plot_2d(ax, t_2=5, c='k') 38 | 39 | >>> point.plot_2d(ax, s=50) 40 | >>> point_projected.plot_2d(ax, c='r', s=50, zorder=3) 41 | 42 | >>> limits = ax.axis('equal') 43 | 44 | 45 | For convenience, the ``skspatial.plotting`` module contains ``plot_2d`` and ``plot_3d`` functions as well. These functions can place an arbitrary number of spatial objects on the same plot so that ``matplotlib`` doesn't need to be imported directly. All spatial objects have a ``plotter`` method which is simply used to bundle keyword arguments for the ``plot_2d`` or ``plot_3d`` methods. 46 | 47 | Let's make the same plot as before with the ``plot_2d`` function. The function returns the standard ``matplotlib`` figure and axes objects so the plot can be easily customized. 48 | 49 | 50 | .. plot:: 51 | :include-source: 52 | 53 | >>> from skspatial.objects import Point, Line 54 | >>> from skspatial.plotting import plot_2d 55 | 56 | >>> point = Point([0, 5]) 57 | >>> line = Line(point=[0, 0], direction=[1, 1]) 58 | 59 | >>> point_projected = line.project_point(point) 60 | 61 | >>> _, ax = plot_2d( 62 | ... point.plotter(s=50), 63 | ... point_projected.plotter(c='r', s=50, zorder=3), 64 | ... line.plotter(t_2=5, c='k'), 65 | ... ) 66 | 67 | >>> limits = ax.axis('equal') 68 | -------------------------------------------------------------------------------- /docs/source/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx==5.3.0 2 | numpydoc==1.5.0 3 | scikit-spatial[plotting]==9.0.1 4 | setuptools==70.0.0 5 | sphinx-bootstrap-theme==0.8.1 6 | sphinx-gallery==0.9.0 7 | -------------------------------------------------------------------------------- /examples/README.txt: -------------------------------------------------------------------------------- 1 | 2 | Example Gallery 3 | =============== 4 | -------------------------------------------------------------------------------- /examples/fitting/README.txt: -------------------------------------------------------------------------------- 1 | 2 | Fitting 3 | ------- 4 | -------------------------------------------------------------------------------- /examples/fitting/plot_line_2d.py: -------------------------------------------------------------------------------- 1 | """ 2 | 2D Line of Best Fit 3 | =================== 4 | 5 | Fit a line to multiple 2D points. 6 | 7 | """ 8 | 9 | from skspatial.objects import Line, Points 10 | from skspatial.plotting import plot_2d 11 | 12 | points = Points( 13 | [ 14 | [0, 0], 15 | [0, 1], 16 | [1, 2], 17 | [3, 3], 18 | [4, 3], 19 | [6, 5], 20 | [5, 6], 21 | [7, 8], 22 | ], 23 | ) 24 | 25 | line_fit = Line.best_fit(points) 26 | 27 | 28 | plot_2d( 29 | line_fit.plotter(t_1=-7, t_2=7, c='k'), 30 | points.plotter(c='k'), 31 | ) 32 | -------------------------------------------------------------------------------- /examples/fitting/plot_line_3d.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3D Line of Best Fit 3 | =================== 4 | 5 | Fit a line to multiple 3D points. 6 | 7 | """ 8 | 9 | from skspatial.objects import Line, Points 10 | from skspatial.plotting import plot_3d 11 | 12 | points = Points( 13 | [ 14 | [0, 0, 0], 15 | [1, 1, 0], 16 | [2, 3, 2], 17 | [3, 2, 3], 18 | [4, 5, 4], 19 | [6, 5, 5], 20 | [6, 6, 5], 21 | [7, 6, 7], 22 | ], 23 | ) 24 | 25 | line_fit = Line.best_fit(points) 26 | 27 | 28 | plot_3d( 29 | line_fit.plotter(t_1=-7, t_2=7, c='k'), 30 | points.plotter(c='b', depthshade=False), 31 | ) 32 | -------------------------------------------------------------------------------- /examples/fitting/plot_plane.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3D Plane of Best Fit 3 | ==================== 4 | 5 | Fit a plane to multiple 3D points. 6 | 7 | """ 8 | 9 | from skspatial.objects import Plane, Points 10 | from skspatial.plotting import plot_3d 11 | 12 | points = Points([[0, 0, 0], [1, 3, 5], [-5, 6, 3], [3, 6, 7], [-2, 6, 7]]) 13 | 14 | plane = Plane.best_fit(points) 15 | 16 | 17 | plot_3d( 18 | points.plotter(c='k', s=50, depthshade=False), 19 | plane.plotter(alpha=0.2, lims_x=(-5, 5), lims_y=(-5, 5)), 20 | ) 21 | -------------------------------------------------------------------------------- /examples/intersection/README.txt: -------------------------------------------------------------------------------- 1 | 2 | Intersection 3 | ------------ 4 | -------------------------------------------------------------------------------- /examples/intersection/plot_circle_circle.py: -------------------------------------------------------------------------------- 1 | """ 2 | Circle-Circle Intersection 3 | ========================== 4 | 5 | """ 6 | 7 | from skspatial.objects import Circle 8 | from skspatial.plotting import plot_2d 9 | 10 | circle_a = Circle([0, 0], 2) 11 | circle_b = Circle([2, 0], 1) 12 | 13 | point_a, point_b = circle_a.intersect_circle(circle_b) 14 | 15 | 16 | _, ax = plot_2d( 17 | circle_a.plotter(fill=False), 18 | circle_b.plotter(fill=False), 19 | point_a.plotter(c='r', s=100, edgecolor='k', zorder=3), 20 | point_b.plotter(c='r', s=100, edgecolor='k', zorder=3), 21 | ) 22 | 23 | ax.set_xlim(-4, 4) 24 | ax.set_ylim(-3, 3) 25 | -------------------------------------------------------------------------------- /examples/intersection/plot_cylinder_line.py: -------------------------------------------------------------------------------- 1 | """ 2 | Cylinder-Line Intersection 3 | ========================== 4 | 5 | """ 6 | 7 | from skspatial.objects import Cylinder, Line 8 | from skspatial.plotting import plot_3d 9 | 10 | cylinder = Cylinder([0, 0, 0], [0, 0, 1], 1) 11 | line = Line([0, 0, 0], [1, 0, 0.7]) 12 | 13 | point_a, point_b = cylinder.intersect_line(line, infinite=False) 14 | 15 | 16 | plot_3d( 17 | line.plotter(c='k'), 18 | cylinder.plotter(alpha=0.2), 19 | point_a.plotter(c='r', s=100), 20 | point_b.plotter(c='r', s=100), 21 | ) 22 | -------------------------------------------------------------------------------- /examples/intersection/plot_line_circle.py: -------------------------------------------------------------------------------- 1 | """ 2 | Circle-Line Intersection 3 | ======================== 4 | 5 | """ 6 | 7 | from skspatial.objects import Circle, Line 8 | from skspatial.plotting import plot_2d 9 | 10 | circle = Circle([0, 0], 5) 11 | line = Line([0, 0], [1, 1]) 12 | 13 | point_a, point_b = circle.intersect_line(line) 14 | 15 | 16 | _, ax = plot_2d( 17 | circle.plotter(fill=False), 18 | line.plotter(t_1=-5, t_2=5, c='k'), 19 | point_a.plotter(c='r', s=100, edgecolor='k', zorder=3), 20 | point_b.plotter(c='r', s=100, edgecolor='k', zorder=3), 21 | ) 22 | 23 | ax.axis('equal') 24 | -------------------------------------------------------------------------------- /examples/intersection/plot_line_line_2d.py: -------------------------------------------------------------------------------- 1 | """ 2 | 2D Line-Line Intersection 3 | ========================= 4 | 5 | """ 6 | 7 | from skspatial.objects import Line 8 | from skspatial.plotting import plot_2d 9 | 10 | line_a = Line(point=[0, 0], direction=[1, 1.5]) 11 | line_b = Line(point=[5, 0], direction=[-1, 1]) 12 | 13 | point_intersection = line_a.intersect_line(line_b) 14 | 15 | 16 | plot_2d( 17 | line_a.plotter(t_1=3), 18 | line_b.plotter(t_1=4), 19 | point_intersection.plotter(c='k', s=75, zorder=3), 20 | ) 21 | -------------------------------------------------------------------------------- /examples/intersection/plot_line_line_3d.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3D Line-Line Intersection 3 | ========================= 4 | 5 | """ 6 | 7 | from skspatial.objects import Line 8 | from skspatial.plotting import plot_3d 9 | 10 | line_a = Line(point=[0, 0, 0], direction=[1, 1, 1]) 11 | line_b = Line(point=[1, 1, 0], direction=[-1, -1, 1]) 12 | 13 | point_intersection = line_a.intersect_line(line_b) 14 | 15 | 16 | plot_3d( 17 | line_a.plotter(), 18 | line_b.plotter(), 19 | point_intersection.plotter(c='k', s=75), 20 | ) 21 | -------------------------------------------------------------------------------- /examples/intersection/plot_line_plane.py: -------------------------------------------------------------------------------- 1 | """ 2 | Plane-Line Intersection 3 | ======================= 4 | 5 | """ 6 | 7 | from skspatial.objects import Line, Plane 8 | from skspatial.plotting import plot_3d 9 | 10 | plane = Plane(point=[0, 0, 0], normal=[1, 1, 1]) 11 | line = Line(point=[-1, -1, 0], direction=[0, 0, 1]) 12 | 13 | point_intersection = plane.intersect_line(line) 14 | 15 | 16 | plot_3d( 17 | plane.plotter(lims_x=[-2, 2], lims_y=[-2, 2], alpha=0.2), 18 | line.plotter(t_2=5), 19 | point_intersection.plotter(c='k', s=75), 20 | ) 21 | -------------------------------------------------------------------------------- /examples/intersection/plot_plane_plane.py: -------------------------------------------------------------------------------- 1 | """ 2 | Plane-Plane Intersection 3 | ======================== 4 | 5 | """ 6 | 7 | from skspatial.objects import Plane 8 | from skspatial.plotting import plot_3d 9 | 10 | plane_a = Plane([0, 0, 0], [1, 0, 0]) 11 | plane_b = Plane([0, 0, 0], [1, 0, 1]) 12 | 13 | line_intersection = plane_a.intersect_plane(plane_b) 14 | 15 | 16 | plot_3d( 17 | plane_a.plotter(alpha=0.2), 18 | plane_b.plotter(alpha=0.2), 19 | line_intersection.plotter(t_1=-1, c='k'), 20 | ) 21 | -------------------------------------------------------------------------------- /examples/intersection/plot_sphere_line.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sphere-Line Intersection 3 | ======================== 4 | 5 | """ 6 | 7 | from skspatial.objects import Line, Sphere 8 | from skspatial.plotting import plot_3d 9 | 10 | sphere = Sphere([0, 0, 0], 1) 11 | line = Line([0, 0, 0], [1, 1, 1]) 12 | 13 | point_a, point_b = sphere.intersect_line(line) 14 | 15 | 16 | plot_3d( 17 | line.plotter(t_1=-1, c='k'), 18 | sphere.plotter(alpha=0.2), 19 | point_a.plotter(c='r', s=100), 20 | point_b.plotter(c='r', s=100), 21 | ) 22 | -------------------------------------------------------------------------------- /examples/projection/README.txt: -------------------------------------------------------------------------------- 1 | 2 | Projection 3 | ---------- 4 | -------------------------------------------------------------------------------- /examples/projection/plot_line_plane.py: -------------------------------------------------------------------------------- 1 | """ 2 | Line-Plane Projection 3 | ======================= 4 | 5 | Project a line onto a plane. 6 | 7 | """ 8 | 9 | from skspatial.objects import Line, Plane 10 | from skspatial.plotting import plot_3d 11 | 12 | plane = Plane([0, 1, 0], [0, 1, 0]) 13 | line = Line([0, -1, 0], [1, -2, 0]) 14 | 15 | line_projected = plane.project_line(line) 16 | 17 | 18 | plot_3d( 19 | plane.plotter(lims_x=(-5, 5), lims_y=(-5, 5), alpha=0.3), 20 | line.plotter(t_1=-2, t_2=2, color='k'), 21 | line_projected.plotter(t_1=-2, t_2=4, color='r'), 22 | ) 23 | -------------------------------------------------------------------------------- /examples/projection/plot_point_line.py: -------------------------------------------------------------------------------- 1 | """ 2 | 2D Point-Line Projection 3 | ======================== 4 | 5 | Project a point onto a line. 6 | 7 | """ 8 | 9 | from skspatial.objects import Line, Point 10 | from skspatial.plotting import plot_2d 11 | 12 | line = Line(point=[0, 0], direction=[1, 1]) 13 | point = Point([1, 4]) 14 | 15 | point_projected = line.project_point(point) 16 | line_projection = Line.from_points(point, point_projected) 17 | 18 | _, ax = plot_2d( 19 | line.plotter(t_2=5, c='k'), 20 | line_projection.plotter(c='k', linestyle='--'), 21 | point.plotter(s=75, c='k'), 22 | point_projected.plotter(c='r', s=75, zorder=3), 23 | ) 24 | 25 | ax.axis('equal') 26 | -------------------------------------------------------------------------------- /examples/projection/plot_point_plane.py: -------------------------------------------------------------------------------- 1 | """ 2 | Point-Plane Projection 3 | ====================== 4 | 5 | Project a point onto a plane. 6 | 7 | """ 8 | 9 | from skspatial.objects import Plane, Point, Vector 10 | from skspatial.plotting import plot_3d 11 | 12 | plane = Plane(point=[0, 0, 2], normal=[1, 0, 2]) 13 | point = Point([5, 9, 3]) 14 | 15 | point_projected = plane.project_point(point) 16 | vector_projection = Vector.from_points(point, point_projected) 17 | 18 | 19 | plot_3d( 20 | plane.plotter(lims_x=(0, 10), lims_y=(0, 15), alpha=0.3), 21 | point.plotter(s=75, c='k'), 22 | point_projected.plotter(c='r', s=75, zorder=3), 23 | vector_projection.plotter(point=point, c='k', linestyle='--'), 24 | ) 25 | -------------------------------------------------------------------------------- /examples/projection/plot_vector_line.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3D Vector-Line Projection 3 | ========================= 4 | 5 | Project a vector onto a line. 6 | 7 | """ 8 | 9 | from skspatial.objects import Line, Vector 10 | from skspatial.plotting import plot_3d 11 | 12 | line = Line([0, 0, 0], [1, 1, 2]) 13 | vector = Vector([1, 1, 0.1]) 14 | 15 | vector_projected = line.project_vector(vector) 16 | 17 | 18 | plot_3d( 19 | line.plotter(t_1=-1, c='k', linestyle='--'), 20 | vector.plotter(point=line.point, color='k'), 21 | vector_projected.plotter(point=line.point, color='r', linewidth=2, zorder=3), 22 | ) 23 | -------------------------------------------------------------------------------- /examples/projection/plot_vector_plane.py: -------------------------------------------------------------------------------- 1 | """ 2 | Vector-Plane Projection 3 | ======================= 4 | 5 | Project a vector onto a plane. 6 | 7 | """ 8 | 9 | from skspatial.objects import Plane, Vector 10 | from skspatial.plotting import plot_3d 11 | 12 | plane = Plane([0, 0, 0], [0, 0, 1]) 13 | vector = Vector([1, 1, 1]) 14 | 15 | vector_projected = plane.project_vector(vector) 16 | 17 | 18 | _, ax = plot_3d( 19 | plane.plotter(lims_x=(-5, 5), lims_y=(-5, 5), alpha=0.3), 20 | vector.plotter(point=plane.point, color='k'), 21 | vector_projected.plotter(point=plane.point, color='r', linewidth=2, zorder=3), 22 | ) 23 | 24 | ax.set_zlim([-1, 1]) 25 | -------------------------------------------------------------------------------- /examples/projection/plot_vector_vector.py: -------------------------------------------------------------------------------- 1 | """ 2 | 2D Vector-Vector Projection 3 | =========================== 4 | 5 | Project a vector onto another vector. 6 | 7 | """ 8 | 9 | from skspatial.objects import Vector 10 | from skspatial.plotting import plot_2d 11 | 12 | vector_a = Vector([1, 1]) 13 | vector_b = Vector([2, 0]) 14 | 15 | vector_projected = vector_b.project_vector(vector_a) 16 | 17 | 18 | _, ax = plot_2d( 19 | vector_a.plotter(color='k', head_width=0.1), 20 | vector_b.plotter(color='k', head_width=0.1), 21 | vector_projected.plotter(color='r', head_width=0.1), 22 | ) 23 | 24 | ax.axis([-0.5, 2.5, -0.5, 1.5]) 25 | -------------------------------------------------------------------------------- /examples/triangle/README.txt: -------------------------------------------------------------------------------- 1 | 2 | Triangle 3 | -------- 4 | -------------------------------------------------------------------------------- /examples/triangle/plot_normal.py: -------------------------------------------------------------------------------- 1 | """ 2 | Triangle with Normal Vector 3 | =========================== 4 | 5 | Plotting a triangle with its normal vector. The tail of the vector is set to be the triangle centroid. 6 | 7 | """ 8 | 9 | from skspatial.objects import Triangle 10 | from skspatial.plotting import plot_3d 11 | 12 | triangle = Triangle([0, 0, 1], [1, 1, 0], [0, 2, 1]) 13 | 14 | centroid = triangle.centroid() 15 | 16 | plot_3d( 17 | triangle.plotter(c='k', zorder=3), 18 | centroid.plotter(c='r'), 19 | triangle.normal().plotter(point=centroid, scalar=0.2, c='r'), 20 | *[x.plotter(c='k', zorder=3) for x in triangle.multiple('line', 'abc')], 21 | ) 22 | -------------------------------------------------------------------------------- /examples/triangle/plot_orthocenter.py: -------------------------------------------------------------------------------- 1 | """ 2 | Triangle with Altitudes and Orthocenter 3 | ======================================= 4 | 5 | Plotting a triangle with its three altitudes and their intersection point, the orthocenter. 6 | 7 | """ 8 | 9 | from skspatial.objects import Triangle 10 | from skspatial.plotting import plot_2d 11 | 12 | triangle = Triangle([0, 0], [2, 0], [1, 2]) 13 | 14 | plot_2d( 15 | triangle.plotter(c='k', zorder=3), 16 | triangle.orthocenter().plotter(c='r', edgecolor='k', s=100, zorder=3), 17 | *[x.plotter(c='k', zorder=3) for x in triangle.multiple('line', 'abc')], 18 | *[x.plotter() for x in triangle.multiple('altitude', 'ABC')], 19 | ) 20 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | 3 | [mypy-mpl_toolkits.*] 4 | ignore_missing_imports = true 5 | 6 | [mypy-scipy.*] 7 | ignore_missing_imports = true 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "scikit-spatial" 7 | version = "9.0.1" 8 | description = "Spatial objects and computations based on NumPy arrays." 9 | 10 | license = { text = "BSD-3-Clause" } 11 | authors = [ 12 | { name = "Andrew Hynes", email = "andrewjhynes@gmail.com" }, 13 | ] 14 | 15 | readme = "README.md" 16 | keywords = ["NumPy", "matplotlib", "visualization", "spatial", "linear algebra"] 17 | 18 | classifiers = [ 19 | "Development Status :: 5 - Production/Stable", 20 | "Intended Audience :: Science/Research", 21 | "License :: OSI Approved :: BSD License", 22 | "Natural Language :: English", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Topic :: Scientific/Engineering", 29 | ] 30 | 31 | requires-python = ">=3.8" 32 | 33 | dependencies = [ 34 | "numpy>1.24; python_version >= '3.12'", 35 | "numpy>=1; python_version < '3.12'", 36 | "scipy>1.11; python_version >= '3.12'", 37 | "scipy>=1; python_version < '3.12'", 38 | ] 39 | 40 | [project.optional-dependencies] 41 | plotting = ["matplotlib>=3"] 42 | 43 | [project.urls] 44 | repository = "https://github.com/ajhynes7/scikit-spatial" 45 | documentation = "https://scikit-spatial.readthedocs.io" 46 | 47 | [tool.hatch.build.targets.wheel] 48 | packages = ["src/skspatial"] 49 | 50 | [tool.uv] 51 | dev-dependencies = [ 52 | "mypy>=1.14.0", 53 | "pytest-cov>=5.0.0", 54 | "pytest>=8.3.3", 55 | "ruff>=0.8.4", 56 | ] 57 | 58 | [tool.pytest.ini_options] 59 | pythonpath = ["src"] 60 | 61 | [tool.ruff] 62 | line-length = 120 63 | 64 | [tool.ruff.lint] 65 | select = [ 66 | # pydocstyle 67 | "D", 68 | # flake8-bugbear 69 | "B", 70 | # flake8-builtins 71 | "A", 72 | # flake8-commas 73 | "COM", 74 | # flake8-comprehensions 75 | "C4", 76 | # flake8-eradicate 77 | "ERA", 78 | # flake8-print 79 | "T20", 80 | # flake8-pytest-style 81 | "PT", 82 | # flake8-simplify 83 | "SIM", 84 | # flake8-unused-arguments 85 | "ARG", 86 | # isort 87 | "I", 88 | # pycodestyle 89 | "E", "W", 90 | # pyflakes 91 | "F", 92 | # NumPy 2 deprecation 93 | "NPY201", 94 | ] 95 | 96 | ignore = [ 97 | "D104", 98 | "D105", 99 | ] 100 | 101 | [tool.ruff.lint.pydocstyle] 102 | convention = "numpy" 103 | 104 | [tool.ruff.lint.per-file-ignores] 105 | "tests/*" = ["D"] 106 | "examples/*" = ["D"] 107 | "docs/*" = ["D"] 108 | 109 | [tool.ruff.format] 110 | quote-style = "preserve" 111 | -------------------------------------------------------------------------------- /src/skspatial/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for scikit-spatial.""" 2 | 3 | __author__ = "Andrew Hynes" 4 | __email__ = "andrewjhynes@gmail.com" 5 | 6 | try: 7 | import importlib.metadata as importlib_metadata # type: ignore 8 | 9 | except ModuleNotFoundError: 10 | import importlib_metadata # type: ignore 11 | 12 | __version__ = importlib_metadata.version("scikit-spatial") 13 | -------------------------------------------------------------------------------- /src/skspatial/_functions.py: -------------------------------------------------------------------------------- 1 | """Private functions for some spatial computations.""" 2 | 3 | from __future__ import annotations 4 | 5 | import math 6 | from functools import wraps 7 | from typing import Any, Callable, Optional 8 | 9 | import numpy as np 10 | 11 | from skspatial.typing import array_like 12 | 13 | 14 | def _contains_point(obj: Any, point: array_like, **kwargs: float) -> bool: 15 | """ 16 | Check if the object contains a point. 17 | 18 | Returns True if the distance from the point to the object is close to zero. 19 | 20 | Parameters 21 | ---------- 22 | obj: Object 23 | Spatial object (e.g. Line). 24 | point : array_like 25 | Input point. 26 | kwargs : dict, optional 27 | Additional keywords passed to :func:`math.isclose`. 28 | 29 | Returns 30 | ------- 31 | bool 32 | True if the spatial object contains the input point. 33 | 34 | Notes 35 | ----- 36 | Setting an absolute tolerance is useful when comparing a value to zero. 37 | 38 | """ 39 | distance = obj.distance_point(point) 40 | 41 | return math.isclose(distance, 0, **kwargs) 42 | 43 | 44 | def _sum_squares(obj: Any, points: array_like) -> np.float64: 45 | """Return the sum of squared distances from points to a spatial object.""" 46 | distances_squared = np.apply_along_axis(obj.distance_point, 1, points) ** 2 47 | 48 | return distances_squared.sum() 49 | 50 | 51 | def _mesh_to_points(X: array_like, Y: array_like, Z: array_like) -> np.ndarray: 52 | """Convert a mesh into an (N, 3) array of N points.""" 53 | return np.vstack([*map(np.ravel, [X, Y, Z])]).T 54 | 55 | 56 | def np_float(func: Callable) -> Callable[..., np.float64]: 57 | """ 58 | Cast the output type as np.float64. 59 | 60 | Outputs with type np.float64 have a useful round() method. 61 | 62 | """ 63 | 64 | # wraps() is needed so that sphinx generates 65 | # the docstring of functions with this decorator. 66 | @wraps(func) 67 | def wrapper(*args, **kwargs): 68 | return np.float64(func(*args, **kwargs)) 69 | 70 | return wrapper 71 | 72 | 73 | def _solve_quadratic(a: float, b: float, c: float, n_digits: Optional[int] = None) -> np.ndarray: 74 | """ 75 | Solve a quadratic equation. 76 | 77 | The equation has the form 78 | 79 | .. math:: ax^2 + bx + c = 0 80 | 81 | Parameters 82 | ---------- 83 | a, b, c : float 84 | Coefficients of the quadratic equation. 85 | n_digits : int, optional 86 | Additional keyword passed to :func:`round` (default None). 87 | 88 | Returns 89 | ------- 90 | np.ndarray 91 | Array containing the two solutions to the quadratic. 92 | 93 | Raises 94 | ------ 95 | ValueError 96 | If the coefficient `a` is zero. 97 | If the discriminant is negative. 98 | 99 | Examples 100 | -------- 101 | >>> from skspatial._functions import _solve_quadratic 102 | 103 | >>> _solve_quadratic(-1, 1, 1).round(3) 104 | array([ 1.618, -0.618]) 105 | 106 | >>> _solve_quadratic(0, 1, 1) 107 | Traceback (most recent call last): 108 | ... 109 | ValueError: The coefficient `a` must be non-zero. 110 | 111 | >>> _solve_quadratic(1, 1, 1) 112 | Traceback (most recent call last): 113 | ... 114 | ValueError: The discriminant must not be negative. 115 | 116 | """ 117 | if n_digits: 118 | a = round(a, n_digits) 119 | b = round(b, n_digits) 120 | c = round(c, n_digits) 121 | 122 | if a == 0: 123 | raise ValueError("The coefficient `a` must be non-zero.") 124 | 125 | discriminant = b**2 - 4 * a * c 126 | 127 | if discriminant < 0: 128 | raise ValueError("The discriminant must not be negative.") 129 | 130 | pm = np.array([-1, 1]) # Array to compute minus/plus. 131 | 132 | X = (-b + pm * math.sqrt(discriminant)) / (2 * a) 133 | 134 | return X 135 | 136 | 137 | _allclose = np.vectorize(math.isclose) 138 | -------------------------------------------------------------------------------- /src/skspatial/measurement.py: -------------------------------------------------------------------------------- 1 | """Measurements using spatial objects.""" 2 | 3 | import numpy as np 4 | 5 | from skspatial.objects import Points, Vector 6 | from skspatial.typing import array_like 7 | 8 | 9 | def area_triangle(point_a: array_like, point_b: array_like, point_c: array_like) -> np.float64: 10 | """ 11 | Return the area of a triangle defined by three points. 12 | 13 | The points are the vertices of the triangle. They must be 3D or less. 14 | 15 | Parameters 16 | ---------- 17 | point_a, point_b, point_c : array_like 18 | The three vertices of the triangle. 19 | 20 | Returns 21 | ------- 22 | np.float64 23 | The area of the triangle. 24 | 25 | References 26 | ---------- 27 | http://mathworld.wolfram.com/TriangleArea.html 28 | 29 | Examples 30 | -------- 31 | >>> from skspatial.measurement import area_triangle 32 | 33 | >>> area_triangle([0, 0], [0, 1], [1, 0]) 34 | np.float64(0.5) 35 | 36 | >>> area_triangle([0, 0], [0, 2], [1, 1]) 37 | np.float64(1.0) 38 | 39 | >>> area_triangle([3, -5, 1], [5, 2, 1], [9, 4, 2]).round(2) 40 | np.float64(12.54) 41 | 42 | """ 43 | vector_ab = Vector.from_points(point_a, point_b) 44 | vector_ac = Vector.from_points(point_a, point_c) 45 | 46 | # Normal vector of plane defined by the three points. 47 | vector_normal = vector_ab.cross(vector_ac) 48 | 49 | return 0.5 * vector_normal.norm() 50 | 51 | 52 | def volume_tetrahedron( 53 | point_a: array_like, 54 | point_b: array_like, 55 | point_c: array_like, 56 | point_d: array_like, 57 | ) -> np.float64: 58 | """ 59 | Return the volume of a tetrahedron defined by four points. 60 | 61 | The points are the vertices of the tetrahedron. They must be 3D or less. 62 | 63 | Parameters 64 | ---------- 65 | point_a, point_b, point_c, point_d : array_like 66 | The four vertices of the tetrahedron. 67 | 68 | Returns 69 | ------- 70 | np.float64 71 | The volume of the tetrahedron. 72 | 73 | References 74 | ---------- 75 | http://mathworld.wolfram.com/Tetrahedron.html 76 | 77 | Examples 78 | -------- 79 | >>> from skspatial.measurement import volume_tetrahedron 80 | 81 | >>> volume_tetrahedron([0, 0], [3, 2], [-3, 5], [1, 8]) 82 | np.float64(0.0) 83 | 84 | >>> volume_tetrahedron([0, 0, 0], [2, 0, 0], [1, 1, 0], [0, 0, 1]).round(3) 85 | np.float64(0.333) 86 | 87 | >>> volume_tetrahedron([0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]).round(3) 88 | np.float64(0.167) 89 | 90 | """ 91 | vector_ab = Vector.from_points(point_a, point_b) 92 | vector_ac = Vector.from_points(point_a, point_c) 93 | vector_ad = Vector.from_points(point_a, point_d) 94 | 95 | vector_cross = vector_ac.cross(vector_ad) 96 | 97 | # Set the dimension to 3 so it matches the cross product. 98 | vector_ab = vector_ab.set_dimension(3) 99 | 100 | return 1 / 6 * abs(vector_ab.dot(vector_cross)) 101 | 102 | 103 | def area_signed(points: array_like) -> float: 104 | """ 105 | Return the signed area of a simple polygon given the 2D coordinates of its veritces. 106 | 107 | The signed area is computed using the shoelace algorithm. A positive area is 108 | returned for a polygon whose vertices are given by a counter-clockwise 109 | sequence of points. 110 | 111 | Parameters 112 | ---------- 113 | points : array_like 114 | Input 2D points. 115 | 116 | Returns 117 | ------- 118 | area_signed : float 119 | The signed area of the polygon. 120 | 121 | Raises 122 | ------ 123 | ValueError 124 | If the points are not 2D. 125 | If there are fewer than three points. 126 | 127 | References 128 | ---------- 129 | https://en.wikipedia.org/wiki/Shoelace_formula 130 | https://alexkritchevsky.com/2018/08/06/oriented-area.html 131 | https://rosettacode.org/wiki/Shoelace_formula_for_polygonal_area#Python 132 | 133 | Examples 134 | -------- 135 | >>> from skspatial.measurement import area_signed 136 | 137 | >>> area_signed([[0, 0], [1, 0], [0, 1]]) 138 | np.float64(0.5) 139 | 140 | >>> area_signed([[0, 0], [0, 1], [1, 0]]) 141 | np.float64(-0.5) 142 | 143 | >>> area_signed([[0, 0], [0, 1], [1, 2], [2, 1], [2, 0]]) 144 | np.float64(-3.0) 145 | 146 | """ 147 | points = Points(points) 148 | n_points = points.shape[0] 149 | 150 | if points.dimension != 2: 151 | raise ValueError("The points must be 2D.") 152 | 153 | if n_points < 3: 154 | raise ValueError("There must be at least 3 points.") 155 | 156 | X = np.array(points[:, 0]) 157 | Y = np.array(points[:, 1]) 158 | 159 | indices = np.arange(n_points) 160 | indices_offset = indices - 1 161 | 162 | return 0.5 * np.sum(X[indices_offset] * Y[indices] - X[indices] * Y[indices_offset]) 163 | -------------------------------------------------------------------------------- /src/skspatial/objects/__init__.py: -------------------------------------------------------------------------------- 1 | """Package containing spatial objects.""" 2 | 3 | from skspatial.objects.circle import Circle 4 | from skspatial.objects.cylinder import Cylinder 5 | from skspatial.objects.line import Line 6 | from skspatial.objects.line_segment import LineSegment 7 | from skspatial.objects.plane import Plane 8 | from skspatial.objects.point import Point 9 | from skspatial.objects.points import Points 10 | from skspatial.objects.sphere import Sphere 11 | from skspatial.objects.triangle import Triangle 12 | from skspatial.objects.vector import Vector 13 | 14 | __all__ = ['Circle', 'Cylinder', 'Line', 'LineSegment', 'Plane', 'Point', 'Points', 'Sphere', 'Triangle', 'Vector'] 15 | -------------------------------------------------------------------------------- /src/skspatial/objects/_base_array.py: -------------------------------------------------------------------------------- 1 | """Private base classes for arrays.""" 2 | 3 | from typing import Type, TypeVar 4 | 5 | import numpy as np 6 | 7 | from skspatial._functions import _allclose 8 | from skspatial.objects._base_spatial import _BaseSpatial 9 | from skspatial.typing import array_like 10 | 11 | # Create generic variables that can be 'Parent' or any subclass. 12 | Array = TypeVar('Array', bound='_BaseArray') 13 | 14 | Array1D = TypeVar('Array1D', bound='_BaseArray1D') 15 | 16 | Array2D = TypeVar('Array2D', bound='_BaseArray2D') 17 | 18 | 19 | class _BaseArray(np.ndarray, _BaseSpatial): 20 | """Private base class for spatial objects based on a single NumPy array.""" 21 | 22 | def __new__(cls: Type[Array], array: array_like) -> Array: 23 | if np.size(array) == 0: 24 | raise ValueError("The array must not be empty.") 25 | 26 | if not np.isfinite(array).all(): 27 | raise ValueError("The values must all be finite.") 28 | 29 | # We cast the input array to be our class type. 30 | obj = np.asarray(array).view(cls) 31 | 32 | return obj 33 | 34 | def __array_wrap__(self, array, context=None, return_scalar=False) -> np.ndarray: 35 | """ 36 | Return regular :class:`numpy.ndarray` when default NumPy method is called. 37 | 38 | >>> from skspatial.objects import Vector 39 | >>> vector = Vector([1.234, 2.1234, 3.1234]) 40 | 41 | >>> vector.sum().round() 42 | np.float64(6.0) 43 | 44 | >>> vector.mean().round(2) 45 | np.float64(2.16) 46 | 47 | """ 48 | if return_scalar: 49 | return array[()] 50 | 51 | try: 52 | return type(self)(array) 53 | except Exception: 54 | return array 55 | 56 | def to_array(self) -> np.ndarray: 57 | """ 58 | Convert the object to a regular NumPy ndarray. 59 | 60 | Examples 61 | -------- 62 | >>> from skspatial.objects import Point 63 | 64 | >>> point = Point([1, 2, 3]) 65 | 66 | >>> point.to_array() 67 | array([1, 2, 3]) 68 | 69 | """ 70 | return np.array(self) 71 | 72 | def is_close(self, other: array_like, **kwargs: float) -> bool: 73 | """ 74 | Check if the array is close to another. 75 | 76 | Parameters 77 | ---------- 78 | other : array_like 79 | Other array. 80 | kwargs : dict, optional 81 | Additional keywords passed to :func:`math.isclose`. 82 | 83 | Returns 84 | ------- 85 | bool 86 | True if the arrays are close; false otherwise. 87 | 88 | """ 89 | return bool(_allclose(self, other, **kwargs).all()) 90 | 91 | def is_equal(self, other: array_like) -> bool: 92 | """ 93 | Check if the array is equal to another. 94 | 95 | Parameters 96 | ---------- 97 | other : array_like 98 | Other array. 99 | 100 | Returns 101 | ------- 102 | bool 103 | True if the arrays are equal; false otherwise. 104 | 105 | """ 106 | return np.array_equal(self, other) 107 | 108 | def round(self, decimals: int = 0): # type: ignore[override] 109 | """ 110 | Round the array to the given number of decimals. 111 | 112 | Refer to :func:`np.around` for the full documentation. 113 | 114 | Examples 115 | -------- 116 | >>> from skspatial.objects import Point, Vector 117 | 118 | >>> Vector([1, 1, 1]).unit().round(3) 119 | Vector([0.577, 0.577, 0.577]) 120 | 121 | >>> Point([1, 2, 3.532]).round(2) 122 | Point([1. , 2. , 3.53]) 123 | 124 | """ 125 | return np.around(self, decimals=decimals, out=self) 126 | 127 | 128 | class _BaseArray1D(_BaseArray): 129 | """Private base class for spatial objects based on a single 1D NumPy array.""" 130 | 131 | def __new__(cls, array_like): 132 | array = np.array(array_like) 133 | 134 | if array.ndim != 1: 135 | raise ValueError("The array must be 1D.") 136 | 137 | return super().__new__(cls, array_like) 138 | 139 | def __array_finalize__(self, _): 140 | self.dimension = self.size 141 | 142 | def set_dimension(self: Array1D, dim: int) -> Array1D: 143 | """ 144 | Set the dimension (length) of the 1D array. 145 | 146 | Parameters 147 | ---------- 148 | dim : int 149 | Desired dimension. 150 | Must be greater than or equal to the current dimension. 151 | 152 | Returns 153 | ------- 154 | ndarray 155 | (dim,) array. 156 | 157 | Raises 158 | ------ 159 | ValueError 160 | If the desired dimension is less than the current dimension. 161 | 162 | Examples 163 | -------- 164 | >>> from skspatial.objects import Point 165 | 166 | >>> Point([1]).set_dimension(2) 167 | Point([1, 0]) 168 | 169 | >>> Point([1, 2]).set_dimension(4) 170 | Point([1, 2, 0, 0]) 171 | 172 | >>> Point([1, 2, 3]).set_dimension(2) 173 | Traceback (most recent call last): 174 | ... 175 | ValueError: The desired dimension cannot be less than the current dimension. 176 | 177 | """ 178 | if dim < self.dimension: 179 | raise ValueError("The desired dimension cannot be less than the current dimension.") 180 | 181 | n_zeros = dim - self.size 182 | array_padded = np.pad(self, (0, n_zeros), 'constant') 183 | 184 | return self.__class__(array_padded) 185 | 186 | 187 | class _BaseArray2D(_BaseArray): 188 | """Private base class for spatial objects based on a single 2D NumPy array.""" 189 | 190 | def __new__(cls, array_like): 191 | array = np.array(array_like) 192 | 193 | if array.ndim != 2: 194 | raise ValueError("The array must be 2D.") 195 | 196 | return super().__new__(cls, array) 197 | 198 | def __array_finalize__(self, _): 199 | try: 200 | self.dimension = self.shape[1] 201 | except IndexError: 202 | self.dimension = None 203 | 204 | def set_dimension(self: Array2D, dim: int) -> Array2D: 205 | """ 206 | Set the dimension (width) of the 2D array. 207 | 208 | E.g., each row of the array represents a point in space. 209 | The width of the array is the dimension of the points. 210 | 211 | Parameters 212 | ---------- 213 | dim : int 214 | Desired dimension. 215 | Must be greater than or equal to the current dimension. 216 | 217 | Returns 218 | ------- 219 | ndarray 220 | (N, dim) array. 221 | 222 | Raises 223 | ------ 224 | ValueError 225 | If the desired dimension is less than the current dimension. 226 | 227 | Examples 228 | -------- 229 | >>> from skspatial.objects import Points 230 | 231 | >>> points = Points([[1, 0], [2, 3]]) 232 | 233 | >>> points.set_dimension(3) 234 | Points([[1, 0, 0], 235 | [2, 3, 0]]) 236 | 237 | >>> points.set_dimension(5) 238 | Points([[1, 0, 0, 0, 0], 239 | [2, 3, 0, 0, 0]]) 240 | 241 | >>> Points([[1, 2, 3], [4, 5, 6]]).set_dimension(2) 242 | Traceback (most recent call last): 243 | ... 244 | ValueError: The desired dimension cannot be less than the current dimension. 245 | 246 | """ 247 | if dim < self.dimension: 248 | raise ValueError("The desired dimension cannot be less than the current dimension.") 249 | 250 | array_padded = np.pad(self, ((0, 0), (0, dim - self.dimension)), 'constant') 251 | 252 | return self.__class__(array_padded) 253 | -------------------------------------------------------------------------------- /src/skspatial/objects/_base_line_plane.py: -------------------------------------------------------------------------------- 1 | """Module for private parent class of Line and Plane.""" 2 | 3 | import inspect 4 | 5 | import numpy as np 6 | 7 | from skspatial._functions import _contains_point, _sum_squares 8 | from skspatial.objects._base_spatial import _BaseSpatial 9 | from skspatial.objects.point import Point 10 | from skspatial.objects.vector import Vector 11 | from skspatial.typing import array_like 12 | 13 | 14 | class _BaseLinePlane(_BaseSpatial): 15 | """Private parent class for Line and Plane.""" 16 | 17 | def __init__(self, point: array_like, vector: array_like, **kwargs): 18 | self.point = Point(point) 19 | self.vector = Vector(vector) 20 | 21 | if self.point.dimension != self.vector.dimension: 22 | raise ValueError("The point and vector must have the same dimension.") 23 | 24 | if self.vector.is_zero(**kwargs): 25 | raise ValueError("The vector must not be the zero vector.") 26 | 27 | self.dimension = self.point.dimension 28 | 29 | def __repr__(self) -> str: 30 | name_class = type(self).__name__ 31 | name_vector = inspect.getfullargspec(type(self)).args[-1] 32 | 33 | repr_point = np.array_repr(self.point) 34 | repr_vector = np.array_repr(self.vector) 35 | 36 | return f"{name_class}(point={repr_point}, {name_vector}={repr_vector})" 37 | 38 | def contains_point(self, point: array_like, **kwargs: float) -> bool: 39 | """Check if the line/plane contains a point.""" 40 | return _contains_point(self, point, **kwargs) 41 | 42 | def is_close(self, other: array_like, **kwargs: float) -> bool: 43 | """ 44 | Check if the line/plane is almost equivalent to another line/plane. 45 | 46 | The points must be close and the vectors must be parallel. 47 | 48 | Parameters 49 | ---------- 50 | other : object 51 | Line or Plane. 52 | kwargs : dict, optional 53 | Additional keywords passed to :func:`math.isclose`. 54 | 55 | Returns 56 | ------- 57 | bool 58 | True if the objects are almost equivalent; false otherwise. 59 | 60 | Raises 61 | ------ 62 | TypeError 63 | If the input doesn't have the same type as the object. 64 | 65 | Examples 66 | -------- 67 | >>> from skspatial.objects import Line, Plane 68 | 69 | >>> line_a = Line(point=[0, 0], direction=[1, 0]) 70 | >>> line_b = Line(point=[0, 0], direction=[-2, 0]) 71 | >>> line_a.is_close(line_b) 72 | True 73 | 74 | >>> line_b = Line(point=[50, 0], direction=[-4, 0]) 75 | >>> line_a.is_close(line_b) 76 | True 77 | 78 | >>> line_b = Line(point=[50, 29], direction=[-4, 0]) 79 | >>> line_a.is_close(line_b) 80 | False 81 | 82 | >>> plane_a = Plane(point=[0, 0, 0], normal=[0, 0, 5]) 83 | >>> plane_b = Plane(point=[23, 45, 0], normal=[0, 0, -20]) 84 | >>> plane_a.is_close(plane_b) 85 | True 86 | 87 | >>> line_a.is_close(plane_a) 88 | Traceback (most recent call last): 89 | ... 90 | TypeError: The input must have the same type as the object. 91 | 92 | """ 93 | if not isinstance(other, type(self)): 94 | raise TypeError("The input must have the same type as the object.") 95 | 96 | contains_point = self.contains_point(other.point, **kwargs) 97 | is_parallel = self.vector.is_parallel(other.vector, **kwargs) 98 | 99 | return contains_point and is_parallel 100 | 101 | def sum_squares(self, points: array_like) -> np.float64: 102 | return _sum_squares(self, points) 103 | -------------------------------------------------------------------------------- /src/skspatial/objects/_base_spatial.py: -------------------------------------------------------------------------------- 1 | class _PlotterMixin: 2 | dimension: int 3 | 4 | def plotter(self, **kwargs): 5 | """Return a function that plots the object when passed a matplotlib axes.""" 6 | if self.dimension == 2: 7 | if not hasattr(self, 'plot_2d'): 8 | raise ValueError("The object cannot be plotted in 2D.") 9 | 10 | return lambda ax: self.plot_2d(ax, **kwargs) 11 | 12 | if self.dimension == 3: 13 | if not hasattr(self, 'plot_3d'): 14 | raise ValueError("The object cannot be plotted in 3D.") 15 | 16 | return lambda ax: self.plot_3d(ax, **kwargs) 17 | 18 | raise ValueError("The dimension must be 2 or 3.") 19 | 20 | 21 | class _BaseSpatial(_PlotterMixin): 22 | """Private base class for all spatial objects.""" 23 | 24 | ... 25 | -------------------------------------------------------------------------------- /src/skspatial/objects/_base_sphere.py: -------------------------------------------------------------------------------- 1 | """Module for base class of Circle and Sphere.""" 2 | 3 | from typing import cast 4 | 5 | import numpy as np 6 | 7 | from skspatial._functions import _contains_point 8 | from skspatial.objects._base_spatial import _BaseSpatial 9 | from skspatial.objects.point import Point 10 | from skspatial.objects.vector import Vector 11 | from skspatial.typing import array_like 12 | 13 | 14 | class _BaseSphere(_BaseSpatial): 15 | """Private parent class for Circle and Sphere.""" 16 | 17 | def __init__(self, point: array_like, radius: float): 18 | if radius <= 0: 19 | raise ValueError("The radius must be positive.") 20 | 21 | self.point = Point(point) 22 | self.radius = radius 23 | 24 | self.dimension = self.point.dimension 25 | 26 | def __repr__(self) -> str: 27 | name_class = type(self).__name__ 28 | 29 | repr_point = np.array_repr(self.point) 30 | 31 | return f"{name_class}(point={repr_point}, radius={self.radius})" 32 | 33 | def distance_point(self, point: array_like) -> np.float64: 34 | """Return the distance from a point to the circle/sphere.""" 35 | distance_to_center = self.point.distance_point(point) 36 | 37 | return abs(distance_to_center - self.radius) 38 | 39 | def contains_point(self, point: array_like, **kwargs: float) -> bool: 40 | """Check if the circle/sphere contains a point.""" 41 | return _contains_point(self, point, **kwargs) 42 | 43 | def project_point(self, point: array_like) -> Point: 44 | """ 45 | Project a point onto the circle or sphere. 46 | 47 | Parameters 48 | ---------- 49 | point : array_like 50 | Input point. 51 | 52 | Returns 53 | ------- 54 | Point 55 | Point projected onto the circle or sphere. 56 | 57 | Raises 58 | ------ 59 | ValueError 60 | If the input point is the center of the circle or sphere. 61 | 62 | Examples 63 | -------- 64 | >>> from skspatial.objects import Circle 65 | 66 | >>> circle = Circle([0, 0], 1) 67 | 68 | >>> circle.project_point([1, 1]).round(3) 69 | Point([0.707, 0.707]) 70 | 71 | >>> circle.project_point([-6, 3]).round(3) 72 | Point([-0.894, 0.447]) 73 | 74 | >>> circle.project_point([0, 0]) 75 | Traceback (most recent call last): 76 | ... 77 | ValueError: The point must not be the center of the circle or sphere. 78 | 79 | >>> from skspatial.objects import Sphere 80 | 81 | >>> Sphere([0, 0, 0], 2).project_point([1, 2, 3]).round(3) 82 | Point([0.535, 1.069, 1.604]) 83 | 84 | """ 85 | if self.point.is_equal(point): 86 | raise ValueError("The point must not be the center of the circle or sphere.") 87 | 88 | vector_to_point = Vector.from_points(self.point, point) 89 | 90 | return cast(Point, self.point + self.radius * vector_to_point.unit()) 91 | -------------------------------------------------------------------------------- /src/skspatial/objects/_mixins.py: -------------------------------------------------------------------------------- 1 | """Mixin classes.""" 2 | 3 | from typing import Callable, Tuple 4 | 5 | import numpy as np 6 | 7 | from skspatial._functions import _mesh_to_points 8 | from skspatial.objects.points import Points 9 | 10 | 11 | class _ToPointsMixin: 12 | to_mesh: Callable[..., Tuple[np.ndarray, np.ndarray, np.ndarray]] 13 | 14 | def to_points(self, **kwargs) -> Points: 15 | """ 16 | Return points on the surface of the object. 17 | 18 | Parameters 19 | ---------- 20 | kwargs: dict, optional 21 | Additional keywords passed to the `to_mesh` method of the class. 22 | 23 | Returns 24 | ------- 25 | Points 26 | Points on the surface of the object. 27 | 28 | Examples 29 | -------- 30 | >>> from skspatial.objects import Sphere 31 | 32 | >>> sphere = Sphere([0, 0, 0], 1) 33 | 34 | >>> sphere.to_points(n_angles=3).round().unique() 35 | Points([[ 0., -1., 0.], 36 | [ 0., 0., -1.], 37 | [ 0., 0., 1.], 38 | [ 0., 1., 0.]]) 39 | 40 | >>> sphere.to_points(n_angles=4).round(3).unique() 41 | Points([[-0.75 , -0.433, -0.5 ], 42 | [-0.75 , -0.433, 0.5 ], 43 | [ 0. , 0. , -1. ], 44 | [ 0. , 0. , 1. ], 45 | [ 0. , 0.866, -0.5 ], 46 | [ 0. , 0.866, 0.5 ], 47 | [ 0.75 , -0.433, -0.5 ], 48 | [ 0.75 , -0.433, 0.5 ]]) 49 | 50 | """ 51 | X, Y, Z = self.to_mesh(**kwargs) 52 | points = _mesh_to_points(X, Y, Z) 53 | 54 | return Points(points) 55 | -------------------------------------------------------------------------------- /src/skspatial/objects/line_segment.py: -------------------------------------------------------------------------------- 1 | """Module for the LineSegment class.""" 2 | 3 | from __future__ import annotations 4 | 5 | import math 6 | 7 | import numpy as np 8 | 9 | from skspatial.objects._base_spatial import _BaseSpatial 10 | from skspatial.objects.line import Line 11 | from skspatial.objects.point import Point 12 | from skspatial.objects.vector import Vector 13 | from skspatial.typing import array_like 14 | 15 | 16 | class LineSegment(_BaseSpatial): 17 | """ 18 | A line segment in space. 19 | 20 | The line segment is defined by two points. 21 | 22 | Parameters 23 | ---------- 24 | point_a, point_b : array_like 25 | The two endpoints of the line segment. 26 | 27 | Attributes 28 | ---------- 29 | point_a, point_b : Point 30 | The two endpoints of the line segment. 31 | 32 | Raises 33 | ------ 34 | ValueError 35 | If the two endpoints are equal. 36 | 37 | Examples 38 | -------- 39 | >>> from skspatial.objects import LineSegment 40 | 41 | >>> segment = LineSegment([0, 0], [1, 0]) 42 | 43 | >>> segment 44 | LineSegment(point_a=Point([0, 0]), point_b=Point([1, 0])) 45 | 46 | >>> LineSegment([0, 0], [0, 0]) 47 | Traceback (most recent call last): 48 | ... 49 | ValueError: The endpoints must not be equal. 50 | 51 | """ 52 | 53 | def __init__(self, point_a: array_like, point_b: array_like): 54 | self.point_a = Point(point_a) 55 | self.point_b = Point(point_b) 56 | 57 | if self.point_a.is_close(self.point_b): 58 | raise ValueError("The endpoints must not be equal.") 59 | 60 | def __repr__(self) -> str: 61 | repr_point_a = np.array_repr(self.point_a) 62 | repr_point_b = np.array_repr(self.point_b) 63 | 64 | return f"LineSegment(point_a={repr_point_a}, point_b={repr_point_b})" 65 | 66 | def contains_point(self, point: array_like, **kwargs) -> bool: 67 | """ 68 | Check if a point is on the line segment. 69 | 70 | Parameters 71 | ---------- 72 | point : array_like 73 | 74 | Returns 75 | ------- 76 | bool 77 | True if the point is on the line segment; false otherwise. 78 | 79 | Examples 80 | -------- 81 | >>> from skspatial.objects import LineSegment 82 | 83 | >>> segment = LineSegment([0, 0], [1, 0]) 84 | 85 | >>> segment.contains_point([0, 0]) 86 | True 87 | >>> segment.contains_point([0.5, 0]) 88 | True 89 | >>> segment.contains_point([2, 0]) 90 | False 91 | >>> segment.contains_point([0, 1]) 92 | False 93 | 94 | """ 95 | vector_a = Vector.from_points(point, self.point_a) 96 | vector_b = Vector.from_points(point, self.point_b) 97 | 98 | if vector_a.is_zero(**kwargs) or vector_b.is_zero(**kwargs): 99 | return True 100 | 101 | similarity = vector_a.cosine_similarity(vector_b) 102 | 103 | return math.isclose(similarity, -1, **kwargs) 104 | 105 | def intersect_line_segment(self, other: LineSegment, **kwargs) -> Point: 106 | """ 107 | Intersect the line segment with another. 108 | 109 | Parameters 110 | ---------- 111 | other : LineSegment 112 | 113 | kwargs : dict, optional 114 | Additional keyword arguments passed to :meth:`Line.intersect_line`. 115 | 116 | Returns 117 | ------- 118 | Point 119 | The intersection point of the two line segments. 120 | 121 | Raises 122 | ------ 123 | ValueError 124 | If the line segments do not intersect. 125 | 126 | Examples 127 | -------- 128 | >>> from skspatial.objects import LineSegment 129 | 130 | >>> segment_a = LineSegment([-1, 0], [1, 0]) 131 | >>> segment_b = LineSegment([0, -1], [0, 1]) 132 | 133 | >>> segment_a.intersect_line_segment(segment_b) 134 | Point([0., 0.]) 135 | 136 | >>> segment_b = LineSegment([0, 1], [0, 2]) 137 | 138 | >>> segment_a.intersect_line_segment(segment_b) 139 | Traceback (most recent call last): 140 | ... 141 | ValueError: The line segments must intersect. 142 | 143 | """ 144 | line_a = Line.from_points(self.point_a, self.point_b) 145 | line_b = Line.from_points(other.point_a, other.point_b) 146 | 147 | point_intersection = line_a.intersect_line(line_b, **kwargs) 148 | 149 | point_on_segment_a = self.contains_point(point_intersection) 150 | point_on_segment_b = other.contains_point(point_intersection) 151 | 152 | if not (point_on_segment_a and point_on_segment_b): 153 | raise ValueError("The line segments must intersect.") 154 | 155 | return point_intersection 156 | 157 | def plot_2d(self, ax_2d, **kwargs) -> None: 158 | """ 159 | Plot a 2D line segment. 160 | 161 | The line segment is plotted by connecting two 2D points. 162 | 163 | Parameters 164 | ---------- 165 | ax_2d : Axes 166 | Instance of :class:`~matplotlib.axes.Axes`. 167 | kwargs : dict, optional 168 | Additional keywords passed to :meth:`~matplotlib.axes.Axes.plot`. 169 | 170 | Examples 171 | -------- 172 | .. plot:: 173 | :include-source: 174 | 175 | >>> import matplotlib.pyplot as plt 176 | >>> from skspatial.objects import LineSegment 177 | 178 | >>> _, ax = plt.subplots() 179 | 180 | >>> segment = LineSegment([0, 0], [1, 1]) 181 | 182 | >>> segment.plot_2d(ax, c='k') 183 | 184 | >>> segment.point_a.plot_2d(ax, c='b', s=100, zorder=3) 185 | >>> segment.point_b.plot_2d(ax, c='r', s=100, zorder=3) 186 | 187 | >>> grid = ax.grid() 188 | 189 | """ 190 | from skspatial.plotting import _connect_points_2d 191 | 192 | _connect_points_2d(ax_2d, self.point_a, self.point_b, **kwargs) 193 | 194 | def plot_3d(self, ax_3d, **kwargs) -> None: 195 | """ 196 | Plot a 3D line segment. 197 | 198 | The line segment is plotted by connecting two 3D points. 199 | 200 | Parameters 201 | ---------- 202 | ax_3d : Axes3D 203 | Instance of :class:`~mpl_toolkits.mplot3d.axes3d.Axes3D`. 204 | kwargs : dict, optional 205 | Additional keywords passed to :meth:`~mpl_toolkits.mplot3d.axes3d.Axes3D.plot`. 206 | 207 | Examples 208 | -------- 209 | .. plot:: 210 | :include-source: 211 | 212 | >>> import matplotlib.pyplot as plt 213 | >>> from mpl_toolkits.mplot3d import Axes3D 214 | 215 | >>> from skspatial.objects import LineSegment 216 | 217 | >>> fig = plt.figure() 218 | >>> ax = fig.add_subplot(111, projection='3d') 219 | 220 | >>> segment = LineSegment([1, 2, 3], [0, 1, 1]) 221 | 222 | >>> segment.point_a.plot_3d(ax, c='b', s=100, zorder=3) 223 | >>> segment.point_b.plot_3d(ax, c='r', s=100, zorder=3) 224 | 225 | >>> segment.plot_3d(ax, c='k') 226 | 227 | """ 228 | from skspatial.plotting import _connect_points_3d 229 | 230 | _connect_points_3d(ax_3d, self.point_a, self.point_b, **kwargs) 231 | -------------------------------------------------------------------------------- /src/skspatial/objects/point.py: -------------------------------------------------------------------------------- 1 | """Module for the Point class.""" 2 | 3 | import numpy as np 4 | 5 | from skspatial.objects._base_array import _BaseArray1D 6 | from skspatial.objects.vector import Vector 7 | from skspatial.typing import array_like 8 | 9 | 10 | class Point(_BaseArray1D): 11 | """ 12 | A point in space implemented as a 1D array. 13 | 14 | The array is a subclass of :class:`numpy.ndarray`. 15 | 16 | Parameters 17 | ---------- 18 | array : array_like 19 | Input array. 20 | 21 | Attributes 22 | ---------- 23 | dimension : int 24 | Dimension of the point. 25 | 26 | Raises 27 | ------ 28 | ValueError 29 | If the array is empty, the values are not finite, 30 | or the dimension is not one. 31 | 32 | Examples 33 | -------- 34 | >>> from skspatial.objects import Point 35 | 36 | >>> point = Point([1, 2, 3]) 37 | 38 | >>> point.dimension 39 | 3 40 | 41 | The object inherits methods from :class:`numpy.ndarray`. 42 | 43 | >>> point.mean() 44 | np.float64(2.0) 45 | 46 | >>> Point([]) 47 | Traceback (most recent call last): 48 | ... 49 | ValueError: The array must not be empty. 50 | 51 | >>> import numpy as np 52 | 53 | >>> Point([1, 2, np.nan]) 54 | Traceback (most recent call last): 55 | ... 56 | ValueError: The values must all be finite. 57 | 58 | >>> Point([[1, 2], [3, 4]]) 59 | Traceback (most recent call last): 60 | ... 61 | ValueError: The array must be 1D. 62 | 63 | """ 64 | 65 | def distance_point(self, other: array_like) -> np.float64: 66 | """ 67 | Return the distance to another point. 68 | 69 | Parameters 70 | ---------- 71 | other : array_like 72 | Other point. 73 | 74 | Returns 75 | ------- 76 | np.float64 77 | Distance between the points. 78 | 79 | Examples 80 | -------- 81 | >>> from skspatial.objects import Point 82 | 83 | >>> point = Point([1, 2]) 84 | >>> point.distance_point([1, 2]) 85 | np.float64(0.0) 86 | 87 | >>> point.distance_point([-1, 2]) 88 | np.float64(2.0) 89 | 90 | >>> Point([1, 2, 0]).distance_point([1, 2, 3]) 91 | np.float64(3.0) 92 | 93 | """ 94 | vector = Vector.from_points(self, other) 95 | 96 | return vector.norm() 97 | 98 | def plot_2d(self, ax_2d, **kwargs) -> None: 99 | """ 100 | Plot the point on a 2D scatter plot. 101 | 102 | Parameters 103 | ---------- 104 | ax_2d : Axes 105 | Instance of :class:`~matplotlib.axes.Axes`. 106 | kwargs : dict, optional 107 | Additional keywords passed to :meth:`~matplotlib.axes.Axes.scatter`. 108 | 109 | Examples 110 | -------- 111 | .. plot:: 112 | :include-source: 113 | 114 | >>> import matplotlib.pyplot as plt 115 | >>> from skspatial.objects import Point 116 | 117 | >>> _, ax = plt.subplots() 118 | 119 | >>> Point([1, 2]).plot_2d(ax, c='k', s=100) 120 | 121 | """ 122 | from skspatial.plotting import _scatter_2d 123 | 124 | _scatter_2d(ax_2d, self.reshape(1, -1), **kwargs) 125 | 126 | def plot_3d(self, ax_3d, **kwargs) -> None: 127 | """ 128 | Plot the point on a 3D scatter plot. 129 | 130 | Parameters 131 | ---------- 132 | ax_3d : Axes3D 133 | Instance of :class:`~mpl_toolkits.mplot3d.axes3d.Axes3D`. 134 | kwargs : dict, optional 135 | Additional keywords passed to :meth:`~mpl_toolkits.mplot3d.axes3d.Axes3D.scatter`. 136 | 137 | Examples 138 | -------- 139 | .. plot:: 140 | :include-source: 141 | 142 | >>> import matplotlib.pyplot as plt 143 | >>> from mpl_toolkits.mplot3d import Axes3D 144 | 145 | >>> from skspatial.objects import Point 146 | 147 | >>> fig = plt.figure() 148 | >>> ax = fig.add_subplot(111, projection='3d') 149 | 150 | >>> Point([1, 2, 3]).plot_3d(ax, c='k', s=100) 151 | 152 | """ 153 | from skspatial.plotting import _scatter_3d 154 | 155 | _scatter_3d(ax_3d, self.reshape(1, -1), **kwargs) 156 | -------------------------------------------------------------------------------- /src/skspatial/objects/points.py: -------------------------------------------------------------------------------- 1 | """Module for the Points class.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | 7 | import numpy as np 8 | from numpy.linalg import matrix_rank 9 | 10 | from skspatial.objects._base_array import _BaseArray2D 11 | from skspatial.objects.point import Point 12 | 13 | 14 | class Points(_BaseArray2D): 15 | """ 16 | Multiple points in space implemented as a 2D array. 17 | 18 | The array is a subclass of :class:`numpy.ndarray`. 19 | Each row in the array represents a point in space. 20 | 21 | Parameters 22 | ---------- 23 | points : array_like 24 | (N, D) array representing N points with dimension D. 25 | 26 | Raises 27 | ------ 28 | ValueError 29 | If the array is empty, the values are not finite, 30 | or the dimension is not two. 31 | 32 | Examples 33 | -------- 34 | >>> from skspatial.objects import Points 35 | 36 | >>> points = Points([[1, 2, 0], [5, 4, 3]]) 37 | 38 | >>> points 39 | Points([[1, 2, 0], 40 | [5, 4, 3]]) 41 | 42 | >>> points.dimension 43 | 3 44 | 45 | The object inherits methods from :class:`numpy.ndarray`. 46 | 47 | >>> points.mean(axis=0) 48 | array([3. , 3. , 1.5]) 49 | 50 | >>> Points([[]]) 51 | Traceback (most recent call last): 52 | ... 53 | ValueError: The array must not be empty. 54 | 55 | >>> import numpy as np 56 | 57 | >>> Points([[1, 2], [1, np.nan]]) 58 | Traceback (most recent call last): 59 | ... 60 | ValueError: The values must all be finite. 61 | 62 | >>> Points([1, 2, 3]) 63 | Traceback (most recent call last): 64 | ... 65 | ValueError: The array must be 2D. 66 | 67 | """ 68 | 69 | def unique(self) -> Points: 70 | """ 71 | Return unique points. 72 | 73 | The output contains the unique rows of the original array. 74 | 75 | Returns 76 | ------- 77 | Points 78 | (N, D) array of N unique points with dimension D. 79 | 80 | Examples 81 | -------- 82 | >>> from skspatial.objects import Points 83 | 84 | >>> points = Points([[1, 2, 3], [2, 3, 4], [1, 2, 3]]) 85 | 86 | >>> points.unique() 87 | Points([[1, 2, 3], 88 | [2, 3, 4]]) 89 | 90 | """ 91 | return Points(np.unique(self, axis=0)) 92 | 93 | def centroid(self) -> Point: 94 | """ 95 | Return the centroid of the points. 96 | 97 | Returns 98 | ------- 99 | Point 100 | Centroid of the points. 101 | 102 | Examples 103 | -------- 104 | >>> from skspatial.objects import Points 105 | 106 | >>> Points([[1, 2, 3], [2, 2, 3]]).centroid() 107 | Point([1.5, 2. , 3. ]) 108 | 109 | """ 110 | centroid_ = cast(np.ndarray, self.mean(axis=0)) 111 | 112 | return Point(centroid_) 113 | 114 | def mean_center(self, return_centroid: bool = False): 115 | """ 116 | Mean-center the points by subtracting the centroid. 117 | 118 | Parameters 119 | ---------- 120 | return_centroid : bool, optional 121 | If True, also return the original centroid of the points. 122 | 123 | Returns 124 | ------- 125 | points_centered : (N, D) Points 126 | Array of N mean-centered points with dimension D. 127 | centroid : (D,) Point, optional 128 | Original centroid of the points. Only provided if `return_centroid` is True. 129 | 130 | Examples 131 | -------- 132 | >>> from skspatial.objects import Points 133 | 134 | >>> points_centered, centroid = Points([[4, 4, 4], [2, 2, 2]]).mean_center(return_centroid=True) 135 | >>> points_centered 136 | Points([[ 1., 1., 1.], 137 | [-1., -1., -1.]]) 138 | 139 | >>> centroid 140 | Point([3., 3., 3.]) 141 | 142 | The centroid of the centered points is the origin. 143 | 144 | >>> points_centered.centroid() 145 | Point([0., 0., 0.]) 146 | 147 | """ 148 | centroid = self.centroid() 149 | points_centered = self - centroid 150 | 151 | if return_centroid: 152 | return points_centered, centroid 153 | 154 | return points_centered 155 | 156 | def normalize_distance(self) -> Points: 157 | """ 158 | Normalize the distances of the points from the origin. 159 | 160 | The normalized points lie within a unit sphere centered on the origin. 161 | 162 | Returns 163 | ------- 164 | Points 165 | Normalized points. 166 | 167 | Examples 168 | -------- 169 | >>> from skspatial.objects import Points 170 | 171 | >>> points = Points([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) 172 | 173 | >>> points.normalize_distance().round(3) 174 | Points([[0.072, 0.144, 0.215], 175 | [0.287, 0.359, 0.431], 176 | [0.503, 0.574, 0.646]]) 177 | 178 | The transformation can be chained with mean centering. 179 | 180 | >>> points.mean_center().normalize_distance().round(3) 181 | Points([[-0.577, -0.577, -0.577], 182 | [ 0. , 0. , 0. ], 183 | [ 0.577, 0.577, 0.577]]) 184 | 185 | """ 186 | distances_to_points = np.linalg.norm(self, axis=1) 187 | 188 | return self / distances_to_points.max() 189 | 190 | def affine_rank(self, **kwargs) -> np.int64: 191 | """ 192 | Return the affine rank of the points. 193 | 194 | The affine rank is the dimension of the smallest affine space that contains the points. 195 | A rank of 1 means the points are collinear, and a rank of 2 means they are coplanar. 196 | 197 | Parameters 198 | ---------- 199 | kwargs : dict, optional 200 | Additional keywords passed to :func:`numpy.linalg.matrix_rank` 201 | 202 | Returns 203 | ------- 204 | np.int64 205 | Affine rank of the points. 206 | 207 | Examples 208 | -------- 209 | >>> from skspatial.objects import Points 210 | 211 | >>> Points([[5, 5], [5, 5]]).affine_rank() 212 | np.int64(0) 213 | 214 | >>> Points([[5, 3], [-6, 20]]).affine_rank() 215 | np.int64(1) 216 | 217 | >>> Points([[0, 0], [1, 1], [2, 2]]).affine_rank() 218 | np.int64(1) 219 | 220 | >>> Points([[0, 0], [1, 0], [2, 2]]).affine_rank() 221 | np.int64(2) 222 | 223 | >>> Points([[0, 1, 0], [1, 1, 0], [2, 2, 2]]).affine_rank() 224 | np.int64(2) 225 | 226 | >>> Points([[0, 0], [0, 1], [1, 0], [1, 1]]).affine_rank() 227 | np.int64(2) 228 | 229 | >>> Points([[1, 3, 2], [3, 4, 5], [2, 1, 5], [5, 9, 8]]).affine_rank() 230 | np.int64(3) 231 | 232 | """ 233 | # Remove duplicate points so they do not affect the centroid. 234 | points_centered = self.unique().mean_center() 235 | 236 | return matrix_rank(points_centered, **kwargs) 237 | 238 | def are_concurrent(self, **kwargs) -> bool: 239 | """ 240 | Check if the points are all contained in one point. 241 | 242 | Parameters 243 | ---------- 244 | kwargs : dict, optional 245 | Additional keywords passed to :func:`numpy.linalg.matrix_rank` 246 | 247 | Returns 248 | ------- 249 | bool 250 | True if points are concurrent; false otherwise. 251 | 252 | Examples 253 | -------- 254 | >>> from skspatial.objects import Points 255 | 256 | >>> Points([[0, 0], [1, 1], [1, 1]]).are_concurrent() 257 | False 258 | 259 | >>> Points([[1, 1], [1, 1], [1, 1]]).are_concurrent() 260 | True 261 | 262 | """ 263 | return bool(self.affine_rank(**kwargs) == 0) 264 | 265 | def are_collinear(self, **kwargs) -> bool: 266 | """ 267 | Check if the points are all contained in one line. 268 | 269 | Parameters 270 | ---------- 271 | kwargs : dict, optional 272 | Additional keywords passed to :func:`numpy.linalg.matrix_rank` 273 | 274 | Returns 275 | ------- 276 | bool 277 | True if points are collinear; false otherwise. 278 | 279 | Examples 280 | -------- 281 | >>> from skspatial.objects import Points 282 | 283 | >>> Points(([0, 0, 0], [1, 2, 3], [2, 4, 6])).are_collinear() 284 | True 285 | 286 | >>> Points(([0, 0, 0], [1, 2, 3], [5, 2, 0])).are_collinear() 287 | False 288 | 289 | >>> Points(([0, 0], [1, 2], [5, 2], [6, 3])).are_collinear() 290 | False 291 | 292 | """ 293 | return bool(self.affine_rank(**kwargs) <= 1) 294 | 295 | def are_coplanar(self, **kwargs) -> bool: 296 | """ 297 | Check if the points are all contained in one plane. 298 | 299 | Parameters 300 | ---------- 301 | kwargs : dict, optional 302 | Additional keywords passed to :func:`numpy.linalg.matrix_rank` 303 | 304 | Returns 305 | ------- 306 | bool 307 | True if points are coplanar; false otherwise. 308 | 309 | Examples 310 | -------- 311 | >>> from skspatial.objects import Points 312 | 313 | >>> Points([[1, 2], [9, -18], [12, 4], [2, 1]]).are_coplanar() 314 | True 315 | 316 | >>> Points([[1, 2], [9, -18], [12, 4], [2, 2]]).are_coplanar() 317 | True 318 | 319 | >>> Points([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]]).are_coplanar() 320 | False 321 | 322 | """ 323 | return bool(self.affine_rank(**kwargs) <= 2) 324 | 325 | def plot_2d(self, ax_2d, **kwargs) -> None: 326 | """ 327 | Plot the points on a 2D scatter plot. 328 | 329 | Parameters 330 | ---------- 331 | ax_2d : Axes 332 | Instance of :class:`~matplotlib.axes.Axes`. 333 | kwargs : dict, optional 334 | Additional keywords passed to :meth:`~matplotlib.axes.Axes.scatter`. 335 | 336 | Examples 337 | -------- 338 | .. plot:: 339 | :include-source: 340 | 341 | >>> import matplotlib.pyplot as plt 342 | 343 | >>> from skspatial.objects import Points 344 | 345 | >>> fig, ax = plt.subplots() 346 | >>> points = Points([[1, 2], [3, 4], [-4, 2], [-2, 3]]) 347 | >>> points.plot_2d(ax, c='k') 348 | 349 | """ 350 | from skspatial.plotting import _scatter_2d 351 | 352 | _scatter_2d(ax_2d, self, **kwargs) 353 | 354 | def plot_3d(self, ax_3d, **kwargs) -> None: 355 | """ 356 | Plot the points on a 3D scatter plot. 357 | 358 | Parameters 359 | ---------- 360 | ax_3d : Axes3D 361 | Instance of :class:`~mpl_toolkits.mplot3d.axes3d.Axes3D`. 362 | kwargs : dict, optional 363 | Additional keywords passed to :meth:`~mpl_toolkits.mplot3d.axes3d.Axes3D.scatter`. 364 | 365 | Examples 366 | -------- 367 | .. plot:: 368 | :include-source: 369 | 370 | >>> import matplotlib.pyplot as plt 371 | >>> from mpl_toolkits.mplot3d import Axes3D 372 | 373 | >>> from skspatial.objects import Points 374 | 375 | >>> fig = plt.figure() 376 | >>> ax = fig.add_subplot(111, projection='3d') 377 | 378 | >>> points = Points([[1, 2, 1], [3, 2, -7], [-4, 2, 2], [-2, 3, 1]]) 379 | >>> points.plot_3d(ax, s=75, depthshade=False) 380 | 381 | """ 382 | from skspatial.plotting import _scatter_3d 383 | 384 | _scatter_3d(ax_3d, self, **kwargs) 385 | -------------------------------------------------------------------------------- /src/skspatial/objects/sphere.py: -------------------------------------------------------------------------------- 1 | """Module for the Sphere class.""" 2 | 3 | from __future__ import annotations 4 | 5 | import math 6 | from typing import Tuple 7 | 8 | import numpy as np 9 | 10 | from skspatial._functions import np_float 11 | from skspatial.objects._base_sphere import _BaseSphere 12 | from skspatial.objects._mixins import _ToPointsMixin 13 | from skspatial.objects.line import Line 14 | from skspatial.objects.point import Point 15 | from skspatial.objects.points import Points 16 | from skspatial.objects.vector import Vector 17 | from skspatial.typing import array_like 18 | 19 | 20 | class Sphere(_BaseSphere, _ToPointsMixin): 21 | """ 22 | A sphere in 3D space. 23 | 24 | The sphere is defined by a 3D point and a radius. 25 | 26 | Parameters 27 | ---------- 28 | point : (3,) array_like 29 | Center of the sphere. 30 | radius : {int, float} 31 | Radius of the sphere. 32 | 33 | Attributes 34 | ---------- 35 | point : (3,) Point 36 | Center of the sphere. 37 | radius : {int, float} 38 | Radius of the sphere. 39 | dimension : int 40 | Dimension of the sphere. 41 | 42 | Raises 43 | ------ 44 | ValueError 45 | If the radius is not positive. 46 | If the point is not 3D. 47 | 48 | Examples 49 | -------- 50 | >>> from skspatial.objects import Sphere 51 | 52 | >>> sphere = Sphere([1, 2, 3], 5) 53 | 54 | >>> sphere 55 | Sphere(point=Point([1, 2, 3]), radius=5) 56 | 57 | >>> sphere.dimension 58 | 3 59 | 60 | >>> sphere.surface_area().round(2) 61 | np.float64(314.16) 62 | 63 | >>> Sphere([0, 0], 0) 64 | Traceback (most recent call last): 65 | ... 66 | ValueError: The radius must be positive. 67 | 68 | >>> Sphere([0, 0, 0, 0], 1) 69 | Traceback (most recent call last): 70 | ... 71 | ValueError: The point must be 3D. 72 | 73 | """ 74 | 75 | def __init__(self, point: array_like, radius: float): 76 | super().__init__(point, radius) 77 | 78 | if self.point.dimension != 3: 79 | raise ValueError("The point must be 3D.") 80 | 81 | @np_float 82 | def surface_area(self) -> float: 83 | r""" 84 | Return the surface area of the sphere. 85 | 86 | The surface area :math:`A` of a sphere with radius :math:`r` is 87 | 88 | .. math:: A = 4 \pi r ^ 2 89 | 90 | Returns 91 | ------- 92 | np.float64 93 | Surface area of the sphere. 94 | 95 | Examples 96 | -------- 97 | >>> from skspatial.objects import Sphere 98 | 99 | >>> Sphere([0, 0, 0], 1).surface_area().round(2) 100 | np.float64(12.57) 101 | 102 | >>> Sphere([0, 0, 0], 2).surface_area().round(2) 103 | np.float64(50.27) 104 | 105 | """ 106 | return 4 * np.pi * self.radius**2 107 | 108 | @np_float 109 | def volume(self) -> float: 110 | r""" 111 | Return the volume of the sphere. 112 | 113 | The volume :math:`V` of a sphere with radius :math:`r` is 114 | 115 | .. math:: V = \frac{4}{3} \pi r ^ 3 116 | 117 | Returns 118 | ------- 119 | np.float64 120 | Volume of the sphere. 121 | 122 | Examples 123 | -------- 124 | >>> from skspatial.objects import Sphere 125 | 126 | >>> Sphere([0, 0, 0], 1).volume().round(2) 127 | np.float64(4.19) 128 | 129 | >>> Sphere([0, 0, 0], 2).volume().round(2) 130 | np.float64(33.51) 131 | 132 | """ 133 | return 4 / 3 * np.pi * self.radius**3 134 | 135 | def intersect_line(self, line: Line) -> Tuple[Point, Point]: 136 | """ 137 | Intersect the sphere with a line. 138 | 139 | A line intersects a sphere at two points. 140 | 141 | Parameters 142 | ---------- 143 | line : Line 144 | Input line. 145 | 146 | Returns 147 | ------- 148 | point_a, point_b : Point 149 | The two points of intersection. 150 | 151 | Examples 152 | -------- 153 | >>> from skspatial.objects import Sphere, Line 154 | 155 | >>> sphere = Sphere([0, 0, 0], 1) 156 | 157 | >>> sphere.intersect_line(Line([0, 0, 0], [1, 0, 0])) 158 | (Point([-1., 0., 0.]), Point([1., 0., 0.])) 159 | 160 | >>> sphere.intersect_line(Line([0, 0, 1], [1, 0, 0])) 161 | (Point([0., 0., 1.]), Point([0., 0., 1.])) 162 | 163 | >>> sphere.intersect_line(Line([0, 0, 2], [1, 0, 0])) 164 | Traceback (most recent call last): 165 | ... 166 | ValueError: The line does not intersect the sphere. 167 | 168 | """ 169 | vector_to_line = Vector.from_points(self.point, line.point) 170 | vector_unit = line.direction.unit() 171 | 172 | dot = vector_unit.dot(vector_to_line) 173 | 174 | discriminant = dot**2 - (vector_to_line.norm() ** 2 - self.radius**2) 175 | 176 | if discriminant < 0: 177 | raise ValueError("The line does not intersect the sphere.") 178 | 179 | pm = np.array([-1, 1]) # Array to compute minus/plus. 180 | distances = -dot + pm * math.sqrt(discriminant) 181 | 182 | point_a, point_b = line.point + distances.reshape(-1, 1) * vector_unit 183 | 184 | return Point(point_a), Point(point_b) 185 | 186 | @classmethod 187 | def best_fit(cls, points: array_like) -> Sphere: 188 | """ 189 | Return the sphere of best fit for a set of 3D points. 190 | 191 | Parameters 192 | ---------- 193 | points : array_like 194 | Input 3D points. 195 | 196 | Returns 197 | ------- 198 | Sphere 199 | The sphere of best fit. 200 | 201 | Raises 202 | ------ 203 | ValueError 204 | If the points are not 3D. 205 | If there are fewer than four points. 206 | If the points lie in a plane. 207 | 208 | Examples 209 | -------- 210 | >>> import numpy as np 211 | 212 | >>> from skspatial.objects import Sphere 213 | 214 | >>> points = [[1, 0, 1], [0, 1, 1], [1, 2, 1], [1, 1, 2]] 215 | >>> sphere = Sphere.best_fit(points) 216 | 217 | >>> sphere.point 218 | Point([1., 1., 1.]) 219 | 220 | >>> np.round(sphere.radius, 2) 221 | np.float64(1.0) 222 | 223 | """ 224 | points = Points(points) 225 | 226 | if points.dimension != 3: 227 | raise ValueError("The points must be 3D.") 228 | 229 | if points.shape[0] < 4: 230 | raise ValueError("There must be at least 4 points.") 231 | 232 | if points.affine_rank() != 3: 233 | raise ValueError("The points must not be in a plane.") 234 | 235 | n = points.shape[0] 236 | A = np.hstack((2 * points, np.ones((n, 1)))) 237 | b = (points**2).sum(axis=1) 238 | 239 | c, _, _, _ = np.linalg.lstsq(A, b, rcond=None) 240 | 241 | center = c[:3] 242 | radius = float(np.sqrt(np.dot(center, center) + c[3])) 243 | 244 | return cls(center, radius) 245 | 246 | def to_mesh(self, n_angles: int = 30) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: 247 | """ 248 | Return coordinate matrices for the 3D surface of the sphere. 249 | 250 | Parameters 251 | ---------- 252 | n_angles: int 253 | Number of angles used to generate the coordinate matrices. 254 | 255 | Returns 256 | ------- 257 | X, Y, Z: (n_angles, n_angles) ndarray 258 | Coordinate matrices. 259 | 260 | Examples 261 | -------- 262 | >>> from skspatial.objects import Sphere 263 | 264 | >>> X, Y, Z = Sphere([0, 0, 0], 1).to_mesh(5) 265 | 266 | >>> X.round(3) 267 | array([[ 0. , 0. , 0. , 0. , 0. ], 268 | [ 0. , 0.707, 0. , -0.707, -0. ], 269 | [ 0. , 1. , 0. , -1. , -0. ], 270 | [ 0. , 0.707, 0. , -0.707, -0. ], 271 | [ 0. , 0. , 0. , -0. , -0. ]]) 272 | 273 | >>> Y.round(3) 274 | array([[ 0. , 0. , 0. , 0. , 0. ], 275 | [ 0.707, 0. , -0.707, -0. , 0.707], 276 | [ 1. , 0. , -1. , -0. , 1. ], 277 | [ 0.707, 0. , -0.707, -0. , 0.707], 278 | [ 0. , 0. , -0. , -0. , 0. ]]) 279 | 280 | >>> Z.round(3) 281 | array([[ 1. , 1. , 1. , 1. , 1. ], 282 | [ 0.707, 0.707, 0.707, 0.707, 0.707], 283 | [ 0. , 0. , 0. , 0. , 0. ], 284 | [-0.707, -0.707, -0.707, -0.707, -0.707], 285 | [-1. , -1. , -1. , -1. , -1. ]]) 286 | 287 | """ 288 | angles_a = np.linspace(0, np.pi, n_angles) 289 | angles_b = np.linspace(0, 2 * np.pi, n_angles) 290 | 291 | sin_angles_a = np.sin(angles_a) 292 | cos_angles_a = np.cos(angles_a) 293 | 294 | sin_angles_b = np.sin(angles_b) 295 | cos_angles_b = np.cos(angles_b) 296 | 297 | X = self.point[0] + self.radius * np.outer(sin_angles_a, sin_angles_b) 298 | Y = self.point[1] + self.radius * np.outer(sin_angles_a, cos_angles_b) 299 | Z = self.point[2] + self.radius * np.outer(cos_angles_a, np.ones_like(angles_b)) 300 | 301 | return X, Y, Z 302 | 303 | def plot_3d(self, ax_3d, n_angles: int = 30, **kwargs) -> None: 304 | """ 305 | Plot the sphere in 3D. 306 | 307 | Parameters 308 | ---------- 309 | ax_3d : Axes3D 310 | Instance of :class:`~mpl_toolkits.mplot3d.axes3d.Axes3D`. 311 | kwargs : dict, optional 312 | Additional keywords passed to :meth:`~mpl_toolkits.mplot3d.axes3d.Axes3D.plot_surface`. 313 | 314 | Examples 315 | -------- 316 | .. plot:: 317 | :include-source: 318 | 319 | >>> import matplotlib.pyplot as plt 320 | >>> from mpl_toolkits.mplot3d import Axes3D 321 | 322 | >>> from skspatial.objects import Sphere 323 | 324 | >>> fig = plt.figure() 325 | >>> ax = fig.add_subplot(111, projection='3d') 326 | 327 | >>> sphere = Sphere([1, 2, 3], 2) 328 | 329 | >>> sphere.plot_3d(ax, alpha=0.2) 330 | >>> sphere.point.plot_3d(ax, s=100) 331 | 332 | """ 333 | X, Y, Z = self.to_mesh(n_angles) 334 | 335 | ax_3d.plot_surface(X, Y, Z, **kwargs) 336 | -------------------------------------------------------------------------------- /src/skspatial/plotting.py: -------------------------------------------------------------------------------- 1 | """Private functions used for plotting spatial objects with Matplotlib.""" 2 | 3 | from typing import Callable, Tuple 4 | 5 | import numpy as np 6 | 7 | try: 8 | import matplotlib.pyplot as plt 9 | from matplotlib.axes import Axes 10 | from mpl_toolkits.mplot3d import Axes3D 11 | except ImportError as error: 12 | raise ImportError( 13 | "Matplotlib is required for plotting.\n" 14 | "Install scikit-spatial with plotting support: " 15 | "pip install 'scikit-spatial[plotting]'", 16 | ) from error 17 | 18 | 19 | from skspatial.typing import array_like 20 | 21 | 22 | def _scatter_2d(ax_2d: Axes, points: array_like, **kwargs) -> None: 23 | """ 24 | Plot points on a 2D scatter plot. 25 | 26 | Parameters 27 | ---------- 28 | ax_2d : Axes 29 | Instance of :class:`~matplotlib.axes.Axes`. 30 | points : array_like 31 | 2D points. 32 | kwargs : dict, optional 33 | Additional keywords passed to :meth:`~matplotlib.axes.Axes.scatter`. 34 | 35 | """ 36 | array = np.array(points) 37 | ax_2d.scatter(array[:, 0], array[:, 1], **kwargs) 38 | 39 | 40 | def _scatter_3d(ax_3d: Axes3D, points: array_like, **kwargs) -> None: 41 | """ 42 | Plot points on a 3D scatter plot. 43 | 44 | Parameters 45 | ---------- 46 | ax_3d : Axes3D 47 | Instance of :class:`~mpl_toolkits.mplot3d.axes3d.Axes3D`. 48 | points : array_like 49 | 3D points. 50 | kwargs : dict, optional 51 | Additional keywords passed to :meth:`~mpl_toolkits.mplot3d.axes3d.Axes3D.scatter`. 52 | 53 | Raises 54 | ------ 55 | ValueError 56 | If the axis is not an instance of Axes3D. 57 | 58 | """ 59 | if not isinstance(ax_3d, Axes3D): 60 | raise ValueError("Axis must be instance of class Axes3D.") 61 | 62 | array = np.array(points) 63 | ax_3d.scatter(array[:, 0], array[:, 1], array[:, 2], **kwargs) 64 | 65 | 66 | def _connect_points_2d(ax_2d: Axes, point_a: array_like, point_b: array_like, **kwargs) -> None: 67 | """ 68 | Plot a line between two 2D points. 69 | 70 | Parameters 71 | ---------- 72 | ax_2d : Axes 73 | Instance of :class:`~matplotlib.axes.Axes`. 74 | point_a, point_b : array_like 75 | The two 2D points to be connected. 76 | kwargs : dict, optional 77 | Additional keywords passed to :meth:`~matplotlib.axes.Axes.plot`. 78 | 79 | """ 80 | xs = [point_a[0], point_b[0]] 81 | ys = [point_a[1], point_b[1]] 82 | 83 | ax_2d.plot(xs, ys, **kwargs) 84 | 85 | 86 | def _connect_points_3d(ax_3d: Axes3D, point_a: array_like, point_b: array_like, **kwargs) -> None: 87 | """ 88 | Plot a line between two 3D points. 89 | 90 | Parameters 91 | ---------- 92 | ax_3d : Axes3D 93 | Instance of :class:`~mpl_toolkits.mplot3d.axes3d.Axes3D`. 94 | point_a, point_b : array_like 95 | The two 3D points to be connected. 96 | kwargs : dict, optional 97 | Additional keywords passed to :meth:`~mpl_toolkits.mplot3d.axes3d.Axes3D.plot`. 98 | 99 | Raises 100 | ------ 101 | ValueError 102 | If the axis is not an instance of Axes3D. 103 | 104 | """ 105 | if not isinstance(ax_3d, Axes3D): 106 | raise ValueError("Axis must be instance of class Axes3D.") 107 | 108 | xs = [point_a[0], point_b[0]] 109 | ys = [point_a[1], point_b[1]] 110 | zs = [point_a[2], point_b[2]] 111 | 112 | ax_3d.plot(xs, ys, zs, **kwargs) 113 | 114 | 115 | def plot_2d(*plotters: Callable) -> Tuple: 116 | """Plot multiple spatial objects in 2D.""" 117 | fig, ax = plt.subplots() 118 | 119 | for plotter in plotters: 120 | plotter(ax) 121 | 122 | return fig, ax 123 | 124 | 125 | def plot_3d(*plotters: Callable) -> Tuple: 126 | """Plot multiple spatial objects in 3D.""" 127 | fig = plt.figure() 128 | ax = fig.add_subplot(111, projection="3d") 129 | 130 | for plotter in plotters: 131 | plotter(ax) 132 | 133 | return fig, ax 134 | -------------------------------------------------------------------------------- /src/skspatial/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajhynes7/scikit-spatial/ac7bc723956c753a11a8cb4f887319f942b2d08e/src/skspatial/py.typed -------------------------------------------------------------------------------- /src/skspatial/transformation.py: -------------------------------------------------------------------------------- 1 | """Spatial transformations.""" 2 | 3 | from typing import Sequence, cast 4 | 5 | import numpy as np 6 | 7 | from skspatial.typing import array_like 8 | 9 | 10 | def transform_coordinates(points: array_like, point_origin: array_like, vectors_basis: Sequence) -> np.ndarray: 11 | """ 12 | Transform points into a new coordinate system. 13 | 14 | Parameters 15 | ---------- 16 | points : (N, D) array_like 17 | Array of N points with dimension D. 18 | point_origin : (D,) array_like 19 | Origin of the new coordinate system. 20 | Array for one point with dimension D. 21 | vectors_basis : sequence 22 | Basis vectors of the new coordinate system. 23 | Sequence of N_bases vectors. 24 | Each vector is an array_like with D elements. 25 | 26 | Returns 27 | ------- 28 | ndarray 29 | Coordinates in the new coordinate system. 30 | (N, N_bases) array. 31 | 32 | Examples 33 | -------- 34 | >>> from skspatial.transformation import transform_coordinates 35 | 36 | >>> points = [[1, 2], [3, 4], [5, 6]] 37 | >>> vectors_basis = [[1, 0], [1, 1]] 38 | 39 | >>> transform_coordinates(points, [0, 0], vectors_basis) 40 | array([[ 1, 3], 41 | [ 3, 7], 42 | [ 5, 11]]) 43 | 44 | >>> points = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] 45 | >>> vectors_basis = [[1, 0, 0], [-1, 1, 0]] 46 | 47 | >>> transform_coordinates(points, [0, 0, 0], vectors_basis) 48 | array([[1, 1], 49 | [4, 1], 50 | [7, 1]]) 51 | 52 | """ 53 | vectors_to_points = np.subtract(points, point_origin) 54 | array_transformed = np.matmul(vectors_to_points, np.transpose(vectors_basis)) 55 | 56 | return cast(np.ndarray, array_transformed) 57 | -------------------------------------------------------------------------------- /src/skspatial/typing.py: -------------------------------------------------------------------------------- 1 | """Custom types for annotations.""" 2 | 3 | from typing import Sequence, Union 4 | 5 | import numpy as np 6 | 7 | array_like = Union[np.ndarray, Sequence] 8 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajhynes7/scikit-spatial/ac7bc723956c753a11a8cb4f887319f942b2d08e/tests/__init__.py -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajhynes7/scikit-spatial/ac7bc723956c753a11a8cb4f887319f942b2d08e/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/objects/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajhynes7/scikit-spatial/ac7bc723956c753a11a8cb4f887319f942b2d08e/tests/unit/objects/__init__.py -------------------------------------------------------------------------------- /tests/unit/objects/test_all_objects.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from skspatial.objects import Circle, Cylinder, Line, Plane, Point, Points, Sphere, Triangle, Vector 4 | from skspatial.objects.line_segment import LineSegment 5 | 6 | 7 | @pytest.mark.parametrize( 8 | ("obj_spatial", "repr_expected"), 9 | [ 10 | (Point([0]), "Point([0])"), 11 | (Point([0, 0]), "Point([0, 0])"), 12 | (Point([0.5, 0]), "Point([0.5, 0. ])"), 13 | (Point([-11, 0]), "Point([-11, 0])"), 14 | (Vector([-11, 0]), "Vector([-11, 0])"), 15 | (Vector([-11.0, 0.0]), "Vector([-11., 0.])"), 16 | (Vector([0, 0]), "Vector([0, 0])"), 17 | (Vector([0.5, 0]), "Vector([0.5, 0. ])"), 18 | (Points([[1.5, 2], [5, 3]]), "Points([[1.5, 2. ],\n [5. , 3. ]])"), 19 | (Line([0, 0], [1, 0]), "Line(point=Point([0, 0]), direction=Vector([1, 0]))"), 20 | (Line([-1, 2, 3], [5, 4, 2]), "Line(point=Point([-1, 2, 3]), direction=Vector([5, 4, 2]))"), 21 | (Line(np.zeros(2), [1, 0]), "Line(point=Point([0., 0.]), direction=Vector([1, 0]))"), 22 | (LineSegment([0, 0], [1, 0]), "LineSegment(point_a=Point([0, 0]), point_b=Point([1, 0]))"), 23 | (LineSegment([-1, 2, 3], [5, 4, 2]), "LineSegment(point_a=Point([-1, 2, 3]), point_b=Point([5, 4, 2]))"), 24 | (Plane([0, 0], [1, 0]), "Plane(point=Point([0, 0]), normal=Vector([1, 0]))"), 25 | (Plane([-1, 2, 3], [5, 4, 2]), "Plane(point=Point([-1, 2, 3]), normal=Vector([5, 4, 2]))"), 26 | (Circle([0, 0], 1), "Circle(point=Point([0, 0]), radius=1)"), 27 | (Circle([0, 0], 2.5), "Circle(point=Point([0, 0]), radius=2.5)"), 28 | (Sphere([0, 0, 0], 1), "Sphere(point=Point([0, 0, 0]), radius=1)"), 29 | ( 30 | Triangle([0, 0], [0, 1], [1, 0]), 31 | "Triangle(point_a=Point([0, 0]), point_b=Point([0, 1]), point_c=Point([1, 0]))", 32 | ), 33 | (Cylinder([0, 0, 0], [0, 0, 1], 1), "Cylinder(point=Point([0, 0, 0]), vector=Vector([0, 0, 1]), radius=1)"), 34 | ], 35 | ) 36 | def test_repr(obj_spatial, repr_expected): 37 | assert repr(obj_spatial) == repr_expected 38 | 39 | 40 | @pytest.mark.parametrize( 41 | "class_spatial", 42 | [Point, Points, Vector, Line, LineSegment, Plane, Circle, Sphere, Triangle, Cylinder], 43 | ) 44 | def test_plotter(class_spatial): 45 | assert callable(class_spatial.plotter) 46 | -------------------------------------------------------------------------------- /tests/unit/objects/test_base_array.py: -------------------------------------------------------------------------------- 1 | """Test functionality of objects based on a NumPy array (Point, Vector, and Points).""" 2 | 3 | import numpy as np 4 | import pytest 5 | from skspatial.objects import Point, Points, Vector 6 | 7 | 8 | @pytest.mark.parametrize("class_spatial", [Point, Vector, Points]) 9 | @pytest.mark.parametrize( 10 | "array", 11 | [ 12 | [[0], [0, 0]], 13 | [[0, 0], [0, 0, 0]], 14 | [[0, 0, 0], [0, 0, 0], [0]], 15 | ], 16 | ) 17 | def test_failure_from_different_lengths(class_spatial, array): 18 | with pytest.raises(ValueError): # noqa: PT011 19 | class_spatial(array) 20 | 21 | 22 | @pytest.mark.parametrize( 23 | ("class_spatial", "array", "message_expected"), 24 | [ 25 | (Point, [], "The array must not be empty."), 26 | (Vector, [], "The array must not be empty."), 27 | (Points, [], "The array must be 2D."), 28 | ], 29 | ) 30 | def test_failure_from_empty_array(class_spatial, array, message_expected): 31 | with pytest.raises(ValueError, match=message_expected): 32 | class_spatial(array) 33 | 34 | 35 | @pytest.mark.parametrize( 36 | ("class_spatial", "array"), 37 | [ 38 | (Point, [np.nan, 0]), 39 | (Vector, [np.nan, 0]), 40 | (Point, [1, np.inf]), 41 | (Vector, [1, np.inf]), 42 | (Points, [[1, 1], [1, np.nan]]), 43 | ], 44 | ) 45 | def test_failure_from_infinite_values(class_spatial, array): 46 | message_expected = "The values must all be finite." 47 | 48 | with pytest.raises(ValueError, match=message_expected): 49 | class_spatial(array) 50 | -------------------------------------------------------------------------------- /tests/unit/objects/test_base_array_1d.py: -------------------------------------------------------------------------------- 1 | """Test functionality of objects based on a single 1D NumPy array (Point and Vector).""" 2 | 3 | import numpy as np 4 | import pytest 5 | from numpy.testing import assert_array_equal 6 | from skspatial.objects import Point, Vector 7 | 8 | 9 | @pytest.mark.parametrize("class_spatial", [Point, Vector]) 10 | @pytest.mark.parametrize("array", [[1, 0], [1, 2], [1, 2, 3], [1, 2, 3, 4], [1, 2, 3, 4, 5]]) 11 | def test_equality(class_spatial, array): 12 | assert_array_equal(array, class_spatial(array)) 13 | 14 | 15 | @pytest.mark.parametrize("class_spatial", [Point, Vector]) 16 | @pytest.mark.parametrize( 17 | "array", 18 | [ 19 | [[0]], 20 | [[0], [0]], 21 | [[0, 0], [0, 0]], 22 | [[1, 2, 3]], 23 | ], 24 | ) 25 | def test_failure(class_spatial, array): 26 | message_expected = "The array must be 1D." 27 | 28 | with pytest.raises(ValueError, match=message_expected): 29 | class_spatial(array) 30 | 31 | 32 | @pytest.mark.parametrize("class_spatial", [Point, Vector]) 33 | @pytest.mark.parametrize( 34 | ("array", "dim", "array_expected"), 35 | [ 36 | ([0, 0], 2, [0, 0]), 37 | ([0, 0], 3, [0, 0, 0]), 38 | ([0, 0], 5, [0, 0, 0, 0, 0]), 39 | ([6, 3, 7], 4, [6, 3, 7, 0]), 40 | ], 41 | ) 42 | def test_set_dimension(class_spatial, array, dim, array_expected): 43 | object_spatial = class_spatial(array).set_dimension(dim) 44 | assert object_spatial.is_close(array_expected) 45 | 46 | 47 | @pytest.mark.parametrize("class_spatial", [Point, Vector]) 48 | @pytest.mark.parametrize( 49 | ("array", "dimension"), 50 | [ 51 | (np.zeros(3), 2), 52 | (np.zeros(2), 1), 53 | (np.zeros(1), 0), 54 | ], 55 | ) 56 | def test_dimension_failure(class_spatial, array, dimension): 57 | message_expected = "The desired dimension cannot be less than the current dimension." 58 | 59 | object_spatial = class_spatial(array) 60 | 61 | with pytest.raises(ValueError, match=message_expected): 62 | object_spatial.set_dimension(dimension) 63 | 64 | 65 | @pytest.mark.parametrize("class_spatial", [Point, Vector]) 66 | def test_dimension_of_slice(class_spatial): 67 | object_spatial = class_spatial([0, 0, 0]) 68 | 69 | assert object_spatial.dimension == 3 70 | assert object_spatial[:3].dimension == 3 71 | assert object_spatial[:2].dimension == 2 72 | assert object_spatial[:1].dimension == 1 73 | -------------------------------------------------------------------------------- /tests/unit/objects/test_base_array_2d.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from skspatial.objects import Points 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ("array", "dimension"), 8 | [ 9 | (np.zeros((3, 1)), 0), 10 | (np.zeros((3, 2)), 1), 11 | (np.zeros((3, 3)), 2), 12 | ], 13 | ) 14 | def test_dimension_failure(array, dimension): 15 | message_expected = "The desired dimension cannot be less than the current dimension." 16 | 17 | points = Points(array) 18 | 19 | with pytest.raises(ValueError, match=message_expected): 20 | points.set_dimension(dimension) 21 | -------------------------------------------------------------------------------- /tests/unit/objects/test_base_line_plane.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from skspatial.objects import Line, Plane 3 | 4 | 5 | @pytest.mark.parametrize("class_spatial", [Line, Plane]) 6 | @pytest.mark.parametrize( 7 | ("point", "vector", "dim_expected"), 8 | [([0, 0], [1, 0], 2), ([0, 0, 0], [1, 0, 0], 3), ([0, 0, 0, 0], [1, 0, 0, 0], 4)], 9 | ) 10 | def test_dimension(class_spatial, point, vector, dim_expected): 11 | object_spatial = class_spatial(point, vector) 12 | assert object_spatial.dimension == dim_expected 13 | -------------------------------------------------------------------------------- /tests/unit/objects/test_circle.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import numpy as np 4 | import pytest 5 | from skspatial.objects import Circle, Line, Points 6 | 7 | POINT_MUST_BE_2D = "The point must be 2D." 8 | RADIUS_MUST_BE_POSITIVE = "The radius must be positive." 9 | 10 | POINTS_MUST_BE_2D = "The points must be 2D." 11 | POINTS_MUST_NOT_BE_COLLINEAR = "The points must not be collinear." 12 | 13 | CIRCLE_CENTRES_ARE_COINCIDENT = "The centres of the circles are coincident." 14 | CIRCLES_ARE_SEPARATE = "The circles do not intersect. These circles are separate." 15 | CIRCLE_CONTAINED_IN_OTHER = "The circles do not intersect. One circle is contained within the other." 16 | 17 | 18 | @pytest.mark.parametrize( 19 | ("point", "radius", "message_expected"), 20 | [ 21 | ([0, 0, 0], 1, POINT_MUST_BE_2D), 22 | ([1, 2, 3], 1, POINT_MUST_BE_2D), 23 | ([0, 0], 0, RADIUS_MUST_BE_POSITIVE), 24 | ([0, 0], -1, RADIUS_MUST_BE_POSITIVE), 25 | ([0, 0], -5, RADIUS_MUST_BE_POSITIVE), 26 | ], 27 | ) 28 | def test_failure(point, radius, message_expected): 29 | with pytest.raises(ValueError, match=message_expected): 30 | Circle(point, radius) 31 | 32 | 33 | @pytest.mark.parametrize( 34 | ("point_a", "point_b", "point_c", "circle_expected"), 35 | [ 36 | ([0, -1], [1, 0], [0, 1], Circle([0, 0], 1)), 37 | ([0, -2], [2, 0], [0, 2], Circle([0, 0], 2)), 38 | ([1, -1], [2, 0], [1, 1], Circle([1, 0], 1)), 39 | ], 40 | ) 41 | def test_from_points(point_a, point_b, point_c, circle_expected): 42 | circle = Circle.from_points(point_a, point_b, point_c) 43 | 44 | assert circle.point.is_close(circle_expected.point) 45 | assert math.isclose(circle.radius, circle_expected.radius) 46 | 47 | 48 | @pytest.mark.parametrize( 49 | ("point_a", "point_b", "point_c", "message_expected"), 50 | [ 51 | ([1, 0, 0], [1, 0], [1, 0], POINTS_MUST_BE_2D), 52 | ([1, 0], [1, 0, 0], [1, 0], POINTS_MUST_BE_2D), 53 | ([1, 0], [0, 0], [1, 0, 1], POINTS_MUST_BE_2D), 54 | ([0, 0], [0, 0], [0, 0], POINTS_MUST_NOT_BE_COLLINEAR), 55 | ([0, 0], [1, 1], [2, 2], POINTS_MUST_NOT_BE_COLLINEAR), 56 | ], 57 | ) 58 | def test_from_points_failure(point_a, point_b, point_c, message_expected): 59 | with pytest.raises(ValueError, match=message_expected): 60 | Circle.from_points(point_a, point_b, point_c) 61 | 62 | 63 | @pytest.mark.parametrize( 64 | ("radius", "circumference_expected", "area_expected"), 65 | [ 66 | (1, 2 * np.pi, np.pi), 67 | (2, 4 * np.pi, 4 * np.pi), 68 | (3, 6 * np.pi, 9 * np.pi), 69 | (4.5, 9 * np.pi, 20.25 * np.pi), 70 | (10, 20 * np.pi, 100 * np.pi), 71 | ], 72 | ) 73 | def test_circumference_area(radius, circumference_expected, area_expected): 74 | circle = Circle([0, 0], radius) 75 | 76 | assert math.isclose(circle.circumference(), circumference_expected) 77 | assert math.isclose(circle.area(), area_expected) 78 | 79 | 80 | @pytest.mark.parametrize( 81 | ("circle", "point", "dist_expected"), 82 | [ 83 | (Circle([0, 0], 1), [0, 0], 1), 84 | (Circle([0, 0], 1), [0.5, 0], 0.5), 85 | (Circle([0, 0], 1), [1, 0], 0), 86 | (Circle([0, 0], 1), [0, 1], 0), 87 | (Circle([0, 0], 1), [-1, 0], 0), 88 | (Circle([0, 0], 1), [0, -1], 0), 89 | (Circle([0, 0], 1), [2, 0], 1), 90 | (Circle([0, 0], 1), [1, 1], math.sqrt(2) - 1), 91 | (Circle([1, 1], 1), [0, 0], math.sqrt(2) - 1), 92 | (Circle([0, 0], 2), [0, 5], 3), 93 | ], 94 | ) 95 | def test_distance_point(circle, point, dist_expected): 96 | assert math.isclose(circle.distance_point(point), dist_expected) 97 | 98 | 99 | @pytest.mark.parametrize( 100 | ("circle", "point", "bool_expected"), 101 | [ 102 | (Circle([0, 0], 1), [1, 0], True), 103 | (Circle([0, 0], 1), [0, 1], True), 104 | (Circle([0, 0], 1), [-1, 0], True), 105 | (Circle([0, 0], 1), [0, -1], True), 106 | (Circle([0, 0], 1), [0, 0], False), 107 | (Circle([0, 0], 1), [1, 1], False), 108 | (Circle([0, 0], 2), [1, 0], False), 109 | (Circle([1, 0], 1), [1, 0], False), 110 | (Circle([0, 0], math.sqrt(2)), [1, 1], True), 111 | ], 112 | ) 113 | def test_contains_point(circle, point, bool_expected): 114 | assert circle.contains_point(point) is bool_expected 115 | 116 | 117 | @pytest.mark.parametrize( 118 | ("circle", "point", "point_expected"), 119 | [ 120 | (Circle([0, 0], 1), [1, 0], [1, 0]), 121 | (Circle([0, 0], 1), [2, 0], [1, 0]), 122 | (Circle([0, 0], 1), [-2, 0], [-1, 0]), 123 | (Circle([0, 0], 1), [0, 2], [0, 1]), 124 | (Circle([0, 0], 1), [0, -2], [0, -1]), 125 | (Circle([0, 0], 5), [0, -2], [0, -5]), 126 | (Circle([0, 1], 5), [0, -2], [0, -4]), 127 | (Circle([0, 0], 1), [1, 1], math.sqrt(2) / 2 * np.ones(2)), 128 | (Circle([0, 0], 2), [1, 1], math.sqrt(2) * np.ones(2)), 129 | ], 130 | ) 131 | def test_project_point(circle, point, point_expected): 132 | point_projected = circle.project_point(point) 133 | assert point_projected.is_close(point_expected) 134 | 135 | 136 | @pytest.mark.parametrize( 137 | ("circle", "point"), 138 | [ 139 | (Circle([0, 0], 1), [0, 0]), 140 | (Circle([0, 0], 5), [0, 0]), 141 | (Circle([7, -1], 5), [7, -1]), 142 | ], 143 | ) 144 | def test_project_point_failure(circle, point): 145 | message_expected = "The point must not be the center of the circle or sphere." 146 | 147 | with pytest.raises(ValueError, match=message_expected): 148 | circle.project_point(point) 149 | 150 | 151 | @pytest.mark.parametrize( 152 | ("points", "circle_expected"), 153 | [ 154 | ([[1, 1], [2, 2], [3, 1]], Circle(point=[2, 1], radius=1)), 155 | ([[2, 0], [-2, 0], [0, 2]], Circle(point=[0, 0], radius=2)), 156 | ([[1, 0], [0, 1], [1, 2]], Circle(point=[1, 1], radius=1)), 157 | ], 158 | ) 159 | def test_best_fit(points, circle_expected): 160 | points = Points(points) 161 | circle_fit = Circle.best_fit(points) 162 | 163 | assert circle_fit.point.is_close(circle_expected.point, abs_tol=1e-9) 164 | assert math.isclose(circle_fit.radius, circle_expected.radius) 165 | 166 | 167 | @pytest.mark.parametrize( 168 | ("points", "message_expected"), 169 | [ 170 | ([[1, 0, 0], [-1, 0, 0], [0, 1, 0]], "The points must be 2D."), 171 | ([[2, 0], [-2, 0]], "There must be at least 3 points."), 172 | ([[0, 0], [1, 1], [2, 2]], "The points must not be collinear."), 173 | ], 174 | ) 175 | def test_best_fit_failure(points, message_expected): 176 | with pytest.raises(ValueError, match=message_expected): 177 | Circle.best_fit(points) 178 | 179 | 180 | @pytest.mark.parametrize( 181 | ("circle_a", "circle_b", "point_a_expected", "point_b_expected"), 182 | [ 183 | (Circle([0, 0], 1), Circle([2, 0], 1), [1, 0], [1, 0]), 184 | (Circle([1, 0], 1), Circle([3, 0], 1), [2, 0], [2, 0]), 185 | (Circle([0, 0], 2), Circle([1, 0], 1), [2, 0], [2, 0]), 186 | (Circle([0, 0], 2), Circle([3, 0], 1), [2, 0], [2, 0]), 187 | (Circle([0, 0], 1), Circle([0, 2], 1), [0, 1], [0, 1]), 188 | (Circle([0, 0], 2), Circle([2, 0], 1), [1.75, math.sqrt(0.9375)], [1.75, -math.sqrt(0.9375)]), 189 | (Circle([0, 0], 1), Circle([1, 0], 1), [0.5, math.sqrt(3) / 2], [0.5, -math.sqrt(3) / 2]), 190 | ], 191 | ) 192 | def test_intersect_circle(circle_a, circle_b, point_a_expected, point_b_expected): 193 | point_a, point_b = circle_a.intersect_circle(circle_b) 194 | 195 | assert point_a.is_close(point_a_expected) 196 | assert point_b.is_close(point_b_expected) 197 | 198 | 199 | @pytest.mark.parametrize( 200 | ("circle_a", "circle_b", "message_expected"), 201 | [ 202 | (Circle([0, 0], 1), Circle([0, 0], 1), CIRCLE_CENTRES_ARE_COINCIDENT), 203 | (Circle([0, 0], 1), Circle([0, 0], 2), CIRCLE_CENTRES_ARE_COINCIDENT), 204 | (Circle([4, -3], 1), Circle([4, -3], 1), CIRCLE_CENTRES_ARE_COINCIDENT), 205 | (Circle([0, 0], 3), Circle([1, 0], 1), CIRCLE_CONTAINED_IN_OTHER), 206 | (Circle([0, 0], 1), Circle([0, 3], 1), CIRCLES_ARE_SEPARATE), 207 | (Circle([1, 1], 1), Circle([5, 0], 1), CIRCLES_ARE_SEPARATE), 208 | ], 209 | ) 210 | def test_intersect_circle_failure(circle_a, circle_b, message_expected): 211 | with pytest.raises(ValueError, match=message_expected): 212 | circle_a.intersect_circle(circle_b) 213 | 214 | 215 | @pytest.mark.parametrize( 216 | ("circle", "line", "point_a_expected", "point_b_expected"), 217 | [ 218 | (Circle([0, 0], 1), Line([0, 0], [1, 0]), [-1, 0], [1, 0]), 219 | (Circle([0, 0], 1), Line([0, 0], [0, 1]), [0, -1], [0, 1]), 220 | (Circle([0, 0], 1), Line([0, 1], [1, 0]), [0, 1], [0, 1]), 221 | ( 222 | Circle([0, 0], 1), 223 | Line([0, 0.5], [1, 0]), 224 | [-math.sqrt(3) / 2, 0.5], 225 | [math.sqrt(3) / 2, 0.5], 226 | ), 227 | (Circle([1, 0], 1), Line([0, 0], [1, 0]), [0, 0], [2, 0]), 228 | (Circle([1.5, 0], 1), Line([0, 0], [1, 0]), [0.5, 0], [2.5, 0]), 229 | ], 230 | ) 231 | def test_intersect_line(circle, line, point_a_expected, point_b_expected): 232 | point_a, point_b = circle.intersect_line(line) 233 | 234 | assert point_a.is_close(point_a_expected) 235 | assert point_b.is_close(point_b_expected) 236 | 237 | 238 | @pytest.mark.parametrize( 239 | ("circle", "line"), 240 | [ 241 | (Circle([0, 0], 1), Line([0, 2], [1, 0])), 242 | (Circle([0, 0], 1), Line([0, -2], [1, 0])), 243 | (Circle([0, 0], 1), Line([2, 0], [0, 1])), 244 | (Circle([0, 0], 1), Line([3, 0], [1, 1])), 245 | (Circle([0, 0], 0.5), Line([0, 1], [1, 1])), 246 | (Circle([0, 1], 0.5), Line([0, 0], [1, 0])), 247 | (Circle([5, 2], 1), Line([2, -1], [1, 0])), 248 | ], 249 | ) 250 | def test_intersect_line_failure(circle, line): 251 | message_expected = "The line does not intersect the circle." 252 | 253 | with pytest.raises(ValueError, match=message_expected): 254 | circle.intersect_line(line) 255 | -------------------------------------------------------------------------------- /tests/unit/objects/test_cylinder.py: -------------------------------------------------------------------------------- 1 | import math 2 | from math import isclose, pi, sqrt 3 | 4 | import pytest 5 | from skspatial.objects import Cylinder, Line, Point, Points, Vector 6 | 7 | LINE_DOES_NOT_INTERSECT_CYLINDER = "The line does not intersect the cylinder." 8 | LINE_MUST_BE_3D = "The line must be 3D." 9 | 10 | 11 | @pytest.mark.parametrize( 12 | ("point", "vector", "radius", "message_expected"), 13 | [ 14 | ([0, 0], [1, 0, 0], 1, "The point must be 3D."), 15 | ([0, 0, 0], [1, 0], 1, "The vector must be 3D."), 16 | ([0, 0, 0], [0, 0, 0], 1, "The vector must not be the zero vector."), 17 | ([0, 0, 0], [0, 0, 1], 0, "The radius must be positive."), 18 | ], 19 | ) 20 | def test_failure(point, vector, radius, message_expected): 21 | with pytest.raises(ValueError, match=message_expected): 22 | Cylinder(point, vector, radius) 23 | 24 | 25 | @pytest.mark.parametrize( 26 | ("array_a", "array_b", "radius", "cylinder_expected"), 27 | [ 28 | ([0, 0, 0], [0, 0, 1], 1, Cylinder([0, 0, 0], [0, 0, 1], 1)), 29 | ([0, 0, 1], [0, 0, 2], 1, Cylinder([0, 0, 1], [0, 0, 1], 1)), 30 | ([0, 0, 0], [1, 1, 1], 1, Cylinder([0, 0, 0], [1, 1, 1], 1)), 31 | ([2, 2, 2], [1, 1, 1], 5, Cylinder([2, 2, 2], [-1, -1, -1], 5)), 32 | ], 33 | ) 34 | def test_from_points(array_a, array_b, radius, cylinder_expected): 35 | cylinder_from_points = Cylinder.from_points(array_a, array_b, radius) 36 | 37 | assert cylinder_from_points.vector.is_close(cylinder_expected.vector) 38 | assert cylinder_from_points.point.is_close(cylinder_expected.point) 39 | assert cylinder_from_points.radius == cylinder_expected.radius 40 | 41 | 42 | @pytest.mark.parametrize( 43 | ("cylinder", "length_expected", "volume_expected"), 44 | [ 45 | (Cylinder([0, 0, 0], [0, 0, 1], 1), 1, pi), 46 | (Cylinder([0, 0, 0], [0, 0, 1], 2), 1, 4 * pi), 47 | (Cylinder([0, 0, 0], [0, 0, 2], 1), 2, 2 * pi), 48 | (Cylinder([0, 0, 0], [0, 0, 2], 2), 2, 8 * pi), 49 | (Cylinder([1, 1, 1], [0, 0, 2], 2), 2, 8 * pi), 50 | (Cylinder([0, 0, 0], [0, 1, 1], 1), sqrt(2), sqrt(2) * pi), 51 | (Cylinder([0, 0, 0], [1, 1, 1], 1), sqrt(3), sqrt(3) * pi), 52 | (Cylinder([0, 0, 0], [5, 5, 5], 2), 5 * sqrt(3), 20 * sqrt(3) * pi), 53 | ], 54 | ) 55 | def test_properties(cylinder, length_expected, volume_expected): 56 | assert isclose(cylinder.length(), length_expected) 57 | assert isclose(cylinder.volume(), volume_expected) 58 | 59 | 60 | @pytest.mark.parametrize( 61 | ("cylinder", "lateral_surface_area_expected", "surface_area_expected"), 62 | [ 63 | (Cylinder([0, 0, 0], [0, 0, 1], 1), 2 * pi, 4 * pi), 64 | (Cylinder([0, 0, 0], [0, 0, 2], 1), 4 * pi, 6 * pi), 65 | (Cylinder([0, 0, 0], [0, 0, 1], 2), 4 * pi, 12 * pi), 66 | (Cylinder([0, 0, 0], [0, 0, 2], 2), 8 * pi, 16 * pi), 67 | (Cylinder([0, 0, 0], [0, 0, -2], 2), 8 * pi, 16 * pi), 68 | ], 69 | ) 70 | def test_surface_area(cylinder, lateral_surface_area_expected, surface_area_expected): 71 | assert isclose(cylinder.lateral_surface_area(), lateral_surface_area_expected) 72 | assert isclose(cylinder.surface_area(), surface_area_expected) 73 | 74 | 75 | @pytest.mark.parametrize( 76 | ("cylinder", "point", "bool_expected"), 77 | [ 78 | (Cylinder([0, 0, 0], [0, 0, 1], 1), [0, 0, 0], True), 79 | (Cylinder([0, 0, 0], [0, 0, 1], 1), [0, 0, 1], True), 80 | (Cylinder([0, 0, 0], [0, 0, 1], 1), [0, 0, 0.9], True), 81 | (Cylinder([0, 0, 0], [0, 0, 1], 1), [0, 0, 1.1], False), 82 | (Cylinder([0, 0, 0], [0, 0, 1], 1), [0, 0, -0.1], False), 83 | (Cylinder([0, 0, 0], [0, 0, 1], 1), [1, 0, 0], True), 84 | (Cylinder([0, 0, 0], [0, 0, 1], 1), [2, 0, 0], False), 85 | (Cylinder([0, 0, 0], [0, 0, 1], 1), [-1, 0, 0], True), 86 | (Cylinder([0, 0, 0], [0, 0, 1], 1), [-2, 0, 0], False), 87 | (Cylinder([0, 0, 0], [0, 0, 1], 1), [1, 1, 0], False), 88 | ( 89 | Cylinder([0, 0, 0], [0, 0, 1], 1), 90 | [sqrt(2) / 2, sqrt(2) / 2, 0], 91 | True, 92 | ), 93 | ], 94 | ) 95 | def test_cylinder_is_point_within(cylinder, point, bool_expected): 96 | assert cylinder.is_point_within(point) is bool_expected 97 | 98 | 99 | @pytest.mark.parametrize( 100 | ("cylinder", "line", "array_expected_a", "array_expected_b"), 101 | [ 102 | ( 103 | Cylinder([0, 0, 0], [0, 0, 1], 1), 104 | Line([0, 0, 0], [1, 0, 0]), 105 | [-1, 0, 0], 106 | [1, 0, 0], 107 | ), 108 | ( 109 | Cylinder([0, 0, 0], [0, 0, 1], 1), 110 | Line([0, 0, 0.5], [1, 0, 0]), 111 | [-1, 0, 0.5], 112 | [1, 0, 0.5], 113 | ), 114 | ( 115 | Cylinder([0, 0, 0], [0, 0, 1], 2), 116 | Line([0, 0, 0], [1, 0, 0]), 117 | [-2, 0, 0], 118 | [2, 0, 0], 119 | ), 120 | ( 121 | Cylinder([0, 0, 0], [0, 0, 5], 1), 122 | Line([0, 0, 0], [1, 0, 0]), 123 | [-1, 0, 0], 124 | [1, 0, 0], 125 | ), 126 | ( 127 | Cylinder([0, 0, 0], [0, 0, 1], 1), 128 | Line([0, 0, 0], [1, 1, 0]), 129 | [-sqrt(2) / 2, -sqrt(2) / 2, 0], 130 | [sqrt(2) / 2, sqrt(2) / 2, 0], 131 | ), 132 | ( 133 | Cylinder([0, 0, 0], [0, 0, 1], 1), 134 | Line([0, 0, 0], [1, 1, 1]), 135 | 3 * [-sqrt(2) / 2], 136 | 3 * [sqrt(2) / 2], 137 | ), 138 | ( 139 | Cylinder([0, 0, 0], [0, 0, 1], 1), 140 | Line([0, -1, 0], [1, 0, 0]), 141 | [0, -1, 0], 142 | [0, -1, 0], 143 | ), 144 | ( 145 | Cylinder([0, 0, 0], [0, 0, 1], 1), 146 | Line([0, 1, 0], [1, 0, 0]), 147 | [0, 1, 0], 148 | [0, 1, 0], 149 | ), 150 | ( 151 | Cylinder([1, 0, 0], [0, 0, 1], 1), 152 | Line([0, -1, 0], [1, 0, 0]), 153 | [1, -1, 0], 154 | [1, -1, 0], 155 | ), 156 | ], 157 | ) 158 | def test_intersect_cylinder_line(cylinder, line, array_expected_a, array_expected_b): 159 | point_a, point_b = cylinder.intersect_line(line, n_digits=9) 160 | 161 | point_expected_a = Point(array_expected_a) 162 | point_expected_b = Point(array_expected_b) 163 | 164 | assert point_a.is_close(point_expected_a) 165 | assert point_b.is_close(point_expected_b) 166 | 167 | 168 | @pytest.mark.parametrize( 169 | ("cylinder", "line", "array_expected_a", "array_expected_b"), 170 | [ 171 | # The line is parallel to the cylinder axis. 172 | ( 173 | Cylinder([0, 0, 0], [0, 0, 1], 1), 174 | Line([0, 0, 0], [0, 0, 1]), 175 | [0, 0, 0], 176 | [0, 0, 1], 177 | ), 178 | # The line is perpendicular to the cylinder axis. 179 | ( 180 | Cylinder([0, 0, 0], [0, 0, 2], 1), 181 | Line([0, 0, 1], [1, 0, 0]), 182 | [-1, 0, 1], 183 | [1, 0, 1], 184 | ), 185 | # The line touches the rim of one cylinder cap. 186 | ( 187 | Cylinder([0, 0, 0], [0, 0, 1], 1), 188 | Line([1, 0, 0], [1, 0, 1]), 189 | [1, 0, 0], 190 | [1, 0, 0], 191 | ), 192 | # The line touches the edge of the lateral surface. 193 | ( 194 | Cylinder([0, 0, 0], [0, 0, 2], 1), 195 | Line([-1, 0, 1], [0, 1, 0]), 196 | [-1, 0, 1], 197 | [-1, 0, 1], 198 | ), 199 | ( 200 | Cylinder([0, 0, 0], [0, 0, 2], 1), 201 | Line([-1, 0, 1], [0, 1, 1]), 202 | [-1, 0, 1], 203 | [-1, 0, 1], 204 | ), 205 | # The line intersects one cap and the lateral surface. 206 | ( 207 | Cylinder([0, 0, 0], [0, 0, 5], 1), 208 | Line([0, 0, 0], [1, 0, 1]), 209 | [0, 0, 0], 210 | [1, 0, 1], 211 | ), 212 | ( 213 | Cylinder([0, 0, 0], [0, 0, 5], 1), 214 | Line([0, 0, 5], [1, 0, -1]), 215 | [0, 0, 5], 216 | [1, 0, 4], 217 | ), 218 | ], 219 | ) 220 | def test_intersect_cylinder_line_with_caps(cylinder, line, array_expected_a, array_expected_b): 221 | point_a, point_b = cylinder.intersect_line(line, infinite=False) 222 | 223 | point_expected_a = Point(array_expected_a) 224 | point_expected_b = Point(array_expected_b) 225 | 226 | assert point_a.is_close(point_expected_a) 227 | assert point_b.is_close(point_expected_b) 228 | 229 | 230 | @pytest.mark.parametrize( 231 | ("cylinder", "line", "message_expected"), 232 | [ 233 | ( 234 | Cylinder([0, 0, 0], [0, 0, 1], 1), 235 | Line([0, -2, 0], [1, 0, 0]), 236 | LINE_DOES_NOT_INTERSECT_CYLINDER, 237 | ), 238 | ( 239 | Cylinder([0, 0, 0], [0, 0, 1], 1), 240 | Line([0, -2, 0], [1, 0, 1]), 241 | LINE_DOES_NOT_INTERSECT_CYLINDER, 242 | ), 243 | ( 244 | Cylinder([3, 10, 4], [-1, 2, -3], 3), 245 | Line([0, -2, 0], [1, 0, 1]), 246 | LINE_DOES_NOT_INTERSECT_CYLINDER, 247 | ), 248 | ( 249 | Cylinder([3, 10, 4], [-1, 2, -3], 3), 250 | Line([0, 0], [1, 0]), 251 | LINE_MUST_BE_3D, 252 | ), 253 | ( 254 | Cylinder([3, 10, 4], [-1, 2, -3], 3), 255 | Line(4 * [0], [1, 0, 0, 0]), 256 | LINE_MUST_BE_3D, 257 | ), 258 | ], 259 | ) 260 | def test_intersect_cylinder_line_failure(cylinder, line, message_expected): 261 | with pytest.raises(ValueError, match=message_expected): 262 | cylinder.intersect_line(line) 263 | 264 | 265 | @pytest.mark.parametrize( 266 | ("cylinder", "line"), 267 | [ 268 | ( 269 | Cylinder([0, 0, 0], [0, 0, 1], 1), 270 | Line([0, 0, -1], [1, 0, 0]), 271 | ), 272 | ], 273 | ) 274 | def test_intersect_cylinder_line_with_caps_failure(cylinder, line): 275 | message_expected = "The line does not intersect the cylinder." 276 | 277 | with pytest.raises(ValueError, match=message_expected): 278 | cylinder.intersect_line(line, infinite=False) 279 | 280 | 281 | @pytest.mark.parametrize( 282 | ("cylinder", "n_along_axis", "n_angles", "points_expected"), 283 | [ 284 | ( 285 | Cylinder([0, 0, 0], [0, 0, 1], 1), 286 | 1, 287 | 1, 288 | [[-1, 0, 0]], 289 | ), 290 | ( 291 | Cylinder([0, 0, 0], [0, 0, 1], 1), 292 | 3, 293 | 2, 294 | [[-1, 0, 0], [-1, 0, 0.5], [-1, 0, 1]], 295 | ), 296 | ( 297 | Cylinder([0, 0, 0], [0.707, 0.707, 0], 1), 298 | 1, 299 | 3, 300 | [[-0.707, 0.707, 0], [0.707, -0.707, -0]], 301 | ), 302 | ], 303 | ) 304 | def test_to_points(cylinder, n_along_axis, n_angles, points_expected): 305 | array_rounded = cylinder.to_points(n_along_axis=n_along_axis, n_angles=n_angles).round(3) 306 | points_unique = Points(array_rounded).unique() 307 | 308 | assert points_unique.is_close(points_expected) 309 | 310 | 311 | @pytest.mark.parametrize( 312 | ("points", "vector_expected", "radius_expected"), 313 | [ 314 | ([[2, 0, 0], [0, 2, 0], [0, -2, 0], [2, 0, 4], [0, 2, 4], [0, -2, 4]], Vector([0, 0, 4]), 2.0), 315 | ([[-2, 0, 1], [-2, 1, 0], [-2, -1, 0], [3, 0, 1], [3, 1, 0], [3, -1, 0]], Vector([5, 0, 0]), 1.0), 316 | ([[-3, 3, 0], [0, 3, 3], [0, 3, -3], [-3, -12, 0], [0, -12, 3], [0, -12, -3]], Vector([0, -15, 0]), 3.0), 317 | ], 318 | ) 319 | def test_best_fit(points, vector_expected, radius_expected): 320 | cylinder = Cylinder.best_fit(points) 321 | 322 | assert isclose(cylinder.vector.norm(), vector_expected.norm()) 323 | assert cylinder.vector.is_parallel(vector_expected) 324 | assert math.isclose(cylinder.radius, radius_expected) 325 | 326 | 327 | @pytest.mark.parametrize( 328 | ("points", "message_expected"), 329 | [ 330 | ([[1, 0], [-1, 0], [0, 1]], "The points must be 3D."), 331 | ([[2, 0, 1], [-2, 0, -3]], "There must be at least 6 points."), 332 | ([[0, 0, 1], [1, 1, 1], [2, 1, 1], [3, 3, 1], [4, 4, 1], [5, 5, 1]], "The points must not be coplanar."), 333 | ], 334 | ) 335 | def test_best_fit_failure(points, message_expected): 336 | with pytest.raises(ValueError, match=message_expected): 337 | Cylinder.best_fit(points) 338 | -------------------------------------------------------------------------------- /tests/unit/objects/test_line_plane.py: -------------------------------------------------------------------------------- 1 | """Test features related to both the Line and Plane.""" 2 | 3 | import pytest 4 | from skspatial.objects import Line, Plane 5 | 6 | 7 | @pytest.mark.parametrize("class_spatial", [Line, Plane]) 8 | @pytest.mark.parametrize( 9 | ("point", "vector"), 10 | [([0, 0], [0, 0]), ([1, 1], [0, 0]), ([1, 1, 1], [0, 0, 0]), ([4, 5, 2, 3], [0, 0, 0, 0])], 11 | ) 12 | def test_zero_vector_failure(class_spatial, point, vector): 13 | with pytest.raises(ValueError, match="The vector must not be the zero vector."): 14 | class_spatial(point, vector) 15 | 16 | 17 | @pytest.mark.parametrize("class_spatial", [Line, Plane]) 18 | @pytest.mark.parametrize(("point", "vector"), [([0, 0, 1], [1, 1]), ([0, 0], [1]), ([1], [0, 1])]) 19 | def test_dimension_failure(class_spatial, point, vector): 20 | with pytest.raises(ValueError, match="The point and vector must have the same dimension."): 21 | class_spatial(point, vector) 22 | 23 | 24 | @pytest.mark.parametrize( 25 | ("obj_1", "obj_2", "bool_expected"), 26 | [ 27 | (Line([0, 0], [1, 0]), Line([0, 0], [1, 0]), True), 28 | (Line([0, 0], [1, 0]), Line([1, 0], [1, 0]), True), 29 | (Line([0, 0], [1, 0]), Line([-5, 0], [1, 0]), True), 30 | (Line([0, 0], [1, 0]), Line([-5, 0], [7, 0]), True), 31 | (Line([0, 0], [1, 0]), Line([-5, 0], [-20, 0]), True), 32 | (Line([0, 0], [1, 0]), Line([-5, 1], [1, 0]), False), 33 | (Plane([0, 0, 0], [0, 0, 1]), Plane([0, 0, 0], [0, 0, 1]), True), 34 | (Plane([0, 0, 0], [0, 0, 1]), Plane([0, 0, 0], [0, 0, 2]), True), 35 | (Plane([0, 0, 0], [0, 0, 1]), Plane([0, 0, 0], [0, 0, -10]), True), 36 | (Plane([0, 0, 0], [0, 0, 1]), Plane([0, 0, 0], [1, 0, -10]), False), 37 | ], 38 | ) 39 | def test_is_close(obj_1, obj_2, bool_expected): 40 | assert obj_1.is_close(obj_2) is bool_expected 41 | 42 | 43 | @pytest.mark.parametrize( 44 | ("obj_1", "obj_2"), 45 | [ 46 | (Line([0, 0], [1, 0]), Plane([0, 0], [1, 0])), 47 | (Plane([0, 0], [1, 0]), Line([0, 0], [1, 0])), 48 | ], 49 | ) 50 | def test_is_close_failure(obj_1, obj_2): 51 | with pytest.raises(TypeError, match="The input must have the same type as the object."): 52 | obj_1.is_close(obj_2) 53 | -------------------------------------------------------------------------------- /tests/unit/objects/test_line_segment.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from skspatial.objects import LineSegment, Point 3 | 4 | from tests.unit.objects.test_line import ( 5 | LINES_MUST_BE_COPLANAR, 6 | LINES_MUST_HAVE_SAME_DIMENSION, 7 | LINES_MUST_NOT_BE_PARALLEL, 8 | ) 9 | 10 | LINE_SEGMENTS_MUST_INTERSECT = "The line segments must intersect." 11 | 12 | 13 | @pytest.mark.parametrize(("point_a", "point_b"), [([0, 0], [1, 0]), ([-1, -1], [2, -1]), ([1, 2, 3], [4, 5, 6])]) 14 | def test_initialize(point_a, point_b): 15 | segment = LineSegment(point_a, point_b) 16 | 17 | assert isinstance(segment.point_a, Point) 18 | assert isinstance(segment.point_b, Point) 19 | 20 | assert segment.point_a.is_close(point_a) 21 | assert segment.point_b.is_close(point_b) 22 | 23 | 24 | @pytest.mark.parametrize( 25 | ("point_a", "point_b"), 26 | [([0, 0], [0, 0]), ([-1, -1], [-1, -1]), ([2, 2, 2], [2, 2, 2])], 27 | ) 28 | def test_failure(point_a, point_b): 29 | with pytest.raises(ValueError, match="The endpoints must not be equal."): 30 | LineSegment(point_a, point_b) 31 | 32 | 33 | @pytest.mark.parametrize( 34 | ("segment", "point", "bool_expected"), 35 | [ 36 | (LineSegment([0, 0], [1, 0]), [0, 0], True), 37 | (LineSegment([0, 0], [1, 0]), [1, 0], True), 38 | (LineSegment([0, 0], [1, 0]), [0.5, 0], True), 39 | (LineSegment([0, 0], [1, 0]), [2, 0], False), 40 | (LineSegment([0, 0], [1, 0]), [-2, 0], False), 41 | (LineSegment([0, 0], [1, 0]), [0, 1], False), 42 | (LineSegment([0, 0], [1, 0]), [1, 1], False), 43 | (LineSegment([0, 0], [1, 0]), [0.5, 1], False), 44 | (LineSegment([2, 4], [3, 3]), [2, 4], True), 45 | (LineSegment([2, 4], [3, 3]), [3, 3], True), 46 | (LineSegment([2, 4], [3, 3]), [2.5, 3.5], True), 47 | (LineSegment([2, 4], [3, 3]), [3, 4], False), 48 | ], 49 | ) 50 | def test_contains_point(segment, point, bool_expected): 51 | assert segment.contains_point(point) is bool_expected 52 | 53 | 54 | @pytest.mark.parametrize( 55 | ("segment", "point", "bool_expected"), 56 | [ 57 | (LineSegment([0, 0], [1, 0]), [1e-3, 0], True), 58 | (LineSegment([0, 0], [1, 0]), [-1e-3, 0], True), 59 | (LineSegment([0, 0], [1, 0]), [1, 1e-3], True), 60 | (LineSegment([0, 0], [1, 0]), [1, -1e-3], True), 61 | (LineSegment([0, 0], [2, 0]), [1, 1e-3], True), 62 | (LineSegment([0, 0], [2, 0]), [1, -1e-3], True), 63 | ], 64 | ) 65 | def test_contains_point_with_tolerance(segment, point, bool_expected): 66 | assert segment.contains_point(point, abs_tol=1e-1) is bool_expected 67 | 68 | 69 | @pytest.mark.parametrize( 70 | ("segment_a", "segment_b", "array_expected"), 71 | [ 72 | (LineSegment([0, 0], [1, 0]), LineSegment([0, 0], [0, 1]), [0, 0]), 73 | (LineSegment([-1, 0], [1, 0]), LineSegment([0, -1], [0, 1]), [0, 0]), 74 | (LineSegment([0, 0], [2, 0]), LineSegment([1, 0], [1, 1]), [1, 0]), 75 | ], 76 | ) 77 | def test_intersect_line_segment(segment_a, segment_b, array_expected): 78 | point_intersection = segment_a.intersect_line_segment(segment_b) 79 | assert point_intersection.is_close(array_expected) 80 | 81 | 82 | @pytest.mark.parametrize( 83 | ("segment_a", "segment_b", "message_expected"), 84 | [ 85 | (LineSegment([0, 0], [2, 0]), LineSegment([1, 1], [1, 2]), LINE_SEGMENTS_MUST_INTERSECT), 86 | (LineSegment([1, 1], [1, 2]), LineSegment([0, 0], [2, 0]), LINE_SEGMENTS_MUST_INTERSECT), 87 | (LineSegment([0, 0], [2, 0]), LineSegment([1, -1], [1, -2]), LINE_SEGMENTS_MUST_INTERSECT), 88 | (LineSegment([0, 0], [1, 0]), LineSegment([0, 1], [1, 1]), LINES_MUST_NOT_BE_PARALLEL), 89 | (LineSegment([0, 0, 0], [1, 0, 0]), LineSegment([0, 0], [1, 0]), LINES_MUST_HAVE_SAME_DIMENSION), 90 | (LineSegment([0, 0, 0], [1, 1, 1]), LineSegment([0, 1, 0], [-1, 1, 0]), LINES_MUST_BE_COPLANAR), 91 | ], 92 | ) 93 | def test_intersect_line_segment_failure(segment_a, segment_b, message_expected): 94 | with pytest.raises(ValueError, match=message_expected): 95 | segment_a.intersect_line_segment(segment_b) 96 | -------------------------------------------------------------------------------- /tests/unit/objects/test_point.py: -------------------------------------------------------------------------------- 1 | from math import isclose, sqrt 2 | 3 | import pytest 4 | from skspatial.objects import Point 5 | 6 | 7 | @pytest.mark.parametrize( 8 | ("array_a", "array_b", "dist_expected"), 9 | [ 10 | ([0], [-5], 5), 11 | ([0], [5], 5), 12 | ([0, 0], [0, 0], 0), 13 | ([0, 0], [1, 0], 1), 14 | ([0, 0], [-1, 0], 1), 15 | ([0, 0], [1, 1], sqrt(2)), 16 | ([0, 0], [5, 5], 5 * sqrt(2)), 17 | ([0, 0], [-5, 5], 5 * sqrt(2)), 18 | ([0, 0, 0], [1, 1, 1], sqrt(3)), 19 | ([0, 0, 0], [5, 5, 5], 5 * sqrt(3)), 20 | ([1, 5, 3], [1, 5, 4], 1), 21 | (4 * [0], 4 * [1], sqrt(4)), 22 | ], 23 | ) 24 | def test_distance_point(array_a, array_b, dist_expected): 25 | point_a = Point(array_a) 26 | assert isclose(point_a.distance_point(array_b), dist_expected) 27 | -------------------------------------------------------------------------------- /tests/unit/objects/test_points.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from numpy.testing import assert_array_almost_equal, assert_array_equal 4 | from skspatial.objects import Point, Points 5 | 6 | 7 | @pytest.mark.parametrize( 8 | "array", 9 | [ 10 | [0], 11 | [5], 12 | [0, 1], 13 | [0, 1, 2], 14 | ], 15 | ) 16 | def test_failure(array): 17 | message_expected = "The array must be 2D." 18 | 19 | with pytest.raises(ValueError, match=message_expected): 20 | Points(array) 21 | 22 | 23 | @pytest.mark.parametrize( 24 | ("points", "dim_expected"), 25 | [ 26 | (Points([[0, 0], [1, 1]]), 2), 27 | (Points([[0, 0], [0, 0], [0, 0]]), 2), 28 | (Points([[0, 0, 1], [1, 2, 1]]), 3), 29 | (Points([[4, 3, 9, 1], [3, 7, 8, 1]]), 4), 30 | ], 31 | ) 32 | def test_dimension(points, dim_expected): 33 | assert points.dimension == dim_expected 34 | 35 | 36 | @pytest.mark.parametrize( 37 | ("points", "dim", "points_expected"), 38 | [ 39 | (Points([[0, 0], [1, 1]]), 3, Points([[0, 0, 0], [1, 1, 0]])), 40 | (Points([[0, 0], [1, 1]]), 4, Points([[0, 0, 0, 0], [1, 1, 0, 0]])), 41 | # The same dimension is allowed (nothing is changed). 42 | (Points([[0, 0, 0], [1, 1, 1]]), 3, Points([[0, 0, 0], [1, 1, 1]])), 43 | ], 44 | ) 45 | def test_set_dimension(points, dim, points_expected): 46 | assert_array_equal(points.set_dimension(dim), points_expected) 47 | 48 | 49 | def test_dimension_of_slice(): 50 | points = Points([[0, 0, 0], [0, 0, 0]]) 51 | 52 | assert points.dimension == 3 53 | assert points[:, :3].dimension == 3 54 | assert points[:, :2].dimension == 2 55 | assert points[:, :1].dimension == 1 56 | 57 | assert points[:0, :].dimension == 3 58 | assert points[:1, :].dimension == 3 59 | assert points[:2, :].dimension == 3 60 | 61 | # The dimension value is None here because the points are no longer a 2D array, 62 | # so the normal method of finding the dimension fails. 63 | assert points[:, 0].dimension is None 64 | assert points[0, :].dimension is None 65 | 66 | 67 | @pytest.mark.parametrize( 68 | ("array_points", "array_centered_expected", "centroid_expected"), 69 | [ 70 | ([[0, 1]], [[0, 0]], [0, 1]), 71 | ([[1, 1], [2, 2]], [[-0.5, -0.5], [0.5, 0.5]], [1.5, 1.5]), 72 | ([[0, 0], [2, 2]], [[-1, -1], [1, 1]], [1, 1]), 73 | ( 74 | [[1, 2], [-1.3, 11], [24, 5], [7, 3]], 75 | [[-6.675, -3.25], [-8.975, 5.75], [16.325, -0.25], [-0.675, -2.25]], 76 | [7.675, 5.25], 77 | ), 78 | ([[-2, 0, 2, 5], [4, 1, -3, 2.1]], [[-3, -0.5, 2.5, 1.45], [3, 0.5, -2.5, -1.45]], [1, 0.5, -0.5, 3.55]), 79 | ], 80 | ) 81 | def test_mean_center(array_points, array_centered_expected, centroid_expected): 82 | points = Points(array_points) 83 | points_centered, centroid = points.mean_center(return_centroid=True) 84 | 85 | assert isinstance(points_centered, Points) 86 | assert isinstance(centroid, Point) 87 | 88 | assert_array_almost_equal(points_centered, array_centered_expected) 89 | assert_array_almost_equal(centroid, centroid_expected) 90 | 91 | 92 | @pytest.mark.parametrize( 93 | ("array_points", "array_points_expected"), 94 | [ 95 | ([[0, 0], [1, 0]], [[0, 0], [1, 0]]), 96 | ([[0, 0], [1, 1]], [[0, 0], np.sqrt(2) / 2 * np.ones(2)]), 97 | ([[0, 0], [5, 0], [-1, 0]], [[0, 0], [1, 0], [-0.2, 0]]), 98 | (9 * np.ones((3, 3)), np.sqrt(3) / 3 * np.ones((3, 3))), 99 | ], 100 | ) 101 | def test_normalize_distance(array_points, array_points_expected): 102 | points_normalized = Points(array_points).normalize_distance() 103 | 104 | assert_array_almost_equal(points_normalized, array_points_expected) 105 | 106 | 107 | @pytest.mark.parametrize( 108 | ("points", "bool_expected"), 109 | [ 110 | ([[0, 0], [0, 0], [0, 0]], True), 111 | ([[1, 0], [1, 0], [1, 0]], True), 112 | ([[0, 0], [0, 1], [0, 2]], True), 113 | ([[0, 0], [0, 1], [1, 2]], False), 114 | ([[0, 1], [0, 0], [0, 2]], True), 115 | ([[0, 0], [-1, 0], [10, 0]], True), 116 | ([[0, 0], [1, 1], [2, 2], [-4, -4], [5, 5]], True), 117 | ([[0, 0, 0], [1, 1, 1], [2, 2, 2]], True), 118 | ([[0, 0, 0], [1, 1, 1], [2, 2, 2.5]], False), 119 | ([[0, 0, 0], [1, 1, 0], [2, 2, 0], [-4, -4, 10], [5, 5, 0]], False), 120 | ], 121 | ) 122 | def test_are_collinear(points, bool_expected): 123 | """Test checking if multiple points are collinear.""" 124 | 125 | assert Points(points).are_collinear() is bool_expected 126 | -------------------------------------------------------------------------------- /tests/unit/objects/test_sphere.py: -------------------------------------------------------------------------------- 1 | import math 2 | from math import sqrt 3 | 4 | import numpy as np 5 | import pytest 6 | from skspatial.objects import Line, Points, Sphere 7 | 8 | POINT_MUST_BE_3D = "The point must be 3D." 9 | RADIUS_MUST_BE_POSITIVE = "The radius must be positive." 10 | 11 | 12 | @pytest.mark.parametrize( 13 | ("point", "radius", "message_expected"), 14 | [ 15 | ([0], 1, POINT_MUST_BE_3D), 16 | ([0, 0], 1, POINT_MUST_BE_3D), 17 | ([0, 0, 0, 0], 1, POINT_MUST_BE_3D), 18 | ([1, 2, 3, 4], 1, POINT_MUST_BE_3D), 19 | ([0, 0, 0], 0, RADIUS_MUST_BE_POSITIVE), 20 | ([0, 0, 0], -1, RADIUS_MUST_BE_POSITIVE), 21 | ([0, 0, 0], -5, RADIUS_MUST_BE_POSITIVE), 22 | ], 23 | ) 24 | def test_failure(point, radius, message_expected): 25 | with pytest.raises(ValueError, match=message_expected): 26 | Sphere(point, radius) 27 | 28 | 29 | @pytest.mark.parametrize( 30 | ("radius", "surface_area_expected", "volume_expected"), 31 | [ 32 | (1, 4 * np.pi, 4 / 3 * np.pi), 33 | (2, 16 * np.pi, 32 / 3 * np.pi), 34 | (3, 36 * np.pi, 36 * np.pi), 35 | (4.5, 81 * np.pi, 121.5 * np.pi), 36 | (10, 400 * np.pi, 4000 / 3 * np.pi), 37 | ], 38 | ) 39 | def test_surface_area_volume(radius, surface_area_expected, volume_expected): 40 | sphere = Sphere([0, 0, 0], radius) 41 | 42 | assert math.isclose(sphere.surface_area(), surface_area_expected) 43 | assert math.isclose(sphere.volume(), volume_expected) 44 | 45 | 46 | @pytest.mark.parametrize( 47 | ("sphere", "point", "dist_expected"), 48 | [ 49 | (Sphere([0, 0, 0], 1), [0, 0, 0], 1), 50 | (Sphere([0, 0, 0], 1), [1, 0, 0], 0), 51 | (Sphere([0, 0, 0], 1), [0, -1, 0], 0), 52 | (Sphere([0, 0, 0], 2), [0, 0, 0], 2), 53 | (Sphere([0, 0, 0], 1), [1, 1, 1], math.sqrt(3) - 1), 54 | (Sphere([0, 0, 0], 2), [1, 1, 1], 2 - math.sqrt(3)), 55 | (Sphere([1, 0, 0], 2), [0, 0, 0], 1), 56 | ], 57 | ) 58 | def test_distance_point(sphere, point, dist_expected): 59 | assert math.isclose(sphere.distance_point(point), dist_expected) 60 | 61 | 62 | @pytest.mark.parametrize( 63 | ("sphere", "point", "bool_expected"), 64 | [ 65 | (Sphere([0, 0, 0], 1), [1, 0, 0], True), 66 | (Sphere([0, 0, 0], 1), [0, 1, 0], True), 67 | (Sphere([0, 0, 0], 1), [0, 0, 1], True), 68 | (Sphere([0, 0, 0], 1), [-1, 0, 0], True), 69 | (Sphere([0, 0, 0], 1), [0, -1, 0], True), 70 | (Sphere([0, 0, 0], 1), [0, 0, -1], True), 71 | (Sphere([0, 0, 0], 1), [1, 1, 0], False), 72 | (Sphere([1, 0, 0], 1), [1, 0, 0], False), 73 | (Sphere([1, 0, 0], 1), [2, 0, 0], True), 74 | (Sphere([0, 0, 0], 2), [0, 2, 0], True), 75 | (Sphere([0, 0, 0], math.sqrt(3)), [1, 1, 1], True), 76 | ], 77 | ) 78 | def test_contains_point(sphere, point, bool_expected): 79 | assert sphere.contains_point(point) is bool_expected 80 | 81 | 82 | @pytest.mark.parametrize( 83 | ("sphere", "point", "point_expected"), 84 | [ 85 | (Sphere([0, 0, 0], 1), [1, 0, 0], [1, 0, 0]), 86 | (Sphere([0, 0, 0], 2), [1, 0, 0], [2, 0, 0]), 87 | (Sphere([0, 0, 0], 0.1), [1, 0, 0], [0.1, 0, 0]), 88 | (Sphere([-1, 0, 0], 1), [1, 0, 0], [0, 0, 0]), 89 | (Sphere([0, 0, 0], 1), [1, 1, 1], math.sqrt(3) / 3 * np.ones(3)), 90 | (Sphere([0, 0, 0], 3), [1, 1, 1], math.sqrt(3) * np.ones(3)), 91 | ], 92 | ) 93 | def test_project_point(sphere, point, point_expected): 94 | point_projected = sphere.project_point(point) 95 | assert point_projected.is_close(point_expected) 96 | 97 | 98 | @pytest.mark.parametrize( 99 | ("sphere", "point"), 100 | [ 101 | (Sphere([0, 0, 0], 1), [0, 0, 0]), 102 | (Sphere([0, 0, 0], 5), [0, 0, 0]), 103 | (Sphere([5, 2, -6], 5), [5, 2, -6]), 104 | ], 105 | ) 106 | def test_project_point_failure(sphere, point): 107 | message_expected = "The point must not be the center of the circle or sphere." 108 | 109 | with pytest.raises(ValueError, match=message_expected): 110 | sphere.project_point(point) 111 | 112 | 113 | @pytest.mark.parametrize( 114 | ("points", "sphere_expected"), 115 | [ 116 | ([[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, 0, 1]], Sphere(point=[0, 0, 0], radius=1)), 117 | ([[2, 0, 0], [-2, 0, 0], [0, 2, 0], [0, 0, 2]], Sphere(point=[0, 0, 0], radius=2)), 118 | ([[1, 0, 1], [0, 1, 1], [1, 2, 1], [1, 1, 2]], Sphere(point=[1, 1, 1], radius=1)), 119 | ], 120 | ) 121 | def test_best_fit(points, sphere_expected): 122 | points = Points(points) 123 | sphere_fit = Sphere.best_fit(points) 124 | 125 | assert sphere_fit.point.is_close(sphere_expected.point, abs_tol=1e-9) 126 | assert math.isclose(sphere_fit.radius, sphere_expected.radius) 127 | 128 | 129 | @pytest.mark.parametrize( 130 | ("points", "message_expected"), 131 | [ 132 | ([[1, 0], [-1, 0], [0, 1], [0, 0]], "The points must be 3D."), 133 | ([[2, 0, 0], [-2, 0, 0], [0, 2, 0]], "There must be at least 4 points."), 134 | ([[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0]], "The points must not be in a plane."), 135 | ], 136 | ) 137 | def test_best_fit_failure(points, message_expected): 138 | with pytest.raises(ValueError, match=message_expected): 139 | Sphere.best_fit(points) 140 | 141 | 142 | @pytest.mark.parametrize( 143 | ("sphere", "line", "point_a_expected", "point_b_expected"), 144 | [ 145 | (Sphere([0, 0, 0], 1), Line([0, 0, 0], [1, 0, 0]), [-1, 0, 0], [1, 0, 0]), 146 | ( 147 | Sphere([0, 0, 0], 1), 148 | Line([0, 0, 0], [1, 1, 0]), 149 | -sqrt(2) / 2 * np.array([1, 1, 0]), 150 | sqrt(2) / 2 * np.array([1, 1, 0]), 151 | ), 152 | ( 153 | Sphere([0, 0, 0], 1), 154 | Line([0, 0, 0], [1, 1, 1]), 155 | -sqrt(3) / 3 * np.ones(3), 156 | sqrt(3) / 3 * np.ones(3), 157 | ), 158 | (Sphere([1, 0, 0], 1), Line([0, 0, 0], [1, 0, 0]), [0, 0, 0], [2, 0, 0]), 159 | (Sphere([0, 0, 0], 1), Line([1, 0, 0], [0, 0, 1]), [1, 0, 0], [1, 0, 0]), 160 | ], 161 | ) 162 | def test_intersect_line(sphere, line, point_a_expected, point_b_expected): 163 | point_a, point_b = sphere.intersect_line(line) 164 | 165 | assert point_a.is_close(point_a_expected) 166 | assert point_b.is_close(point_b_expected) 167 | 168 | 169 | @pytest.mark.parametrize( 170 | ("sphere", "line"), 171 | [ 172 | (Sphere([0, 0, 0], 1), Line([0, 0, 2], [1, 0, 0])), 173 | (Sphere([0, 0, 0], 1), Line([0, 0, -2], [1, 0, 0])), 174 | (Sphere([0, 2, 0], 1), Line([0, 0, 0], [1, 0, 0])), 175 | (Sphere([0, -2, 0], 1), Line([0, 0, 0], [1, 0, 0])), 176 | (Sphere([5, 0, 0], 1), Line([0, 0, 0], [1, 1, 1])), 177 | ], 178 | ) 179 | def test_intersect_line_failure(sphere, line): 180 | message_expected = "The line does not intersect the sphere." 181 | 182 | with pytest.raises(ValueError, match=message_expected): 183 | sphere.intersect_line(line) 184 | 185 | 186 | @pytest.mark.parametrize( 187 | ("sphere", "n_angles", "points_expected"), 188 | [ 189 | (Sphere([0, 0, 0], 1), 1, [[0, 0, 1]]), 190 | (Sphere([0, 0, 0], 1), 2, [[0, 0, -1], [0, 0, 1]]), 191 | (Sphere([0, 0, 0], 1), 3, [[0, -1, 0], [0, 0, -1], [0, 0, 1], [0, 1, 0]]), 192 | (Sphere([0, 0, 0], 2), 3, [[0, -2, 0], [0, 0, -2], [0, 0, 2], [0, 2, 0]]), 193 | (Sphere([1, 0, 0], 1), 3, [[1, -1, 0], [1, 0, -1], [1, 0, 1], [1, 1, 0]]), 194 | (Sphere([1, 1, 1], 1), 3, [[1, 0, 1], [1, 1, 0], [1, 1, 2], [1, 2, 1]]), 195 | ], 196 | ) 197 | def test_to_points(sphere, n_angles, points_expected): 198 | array_rounded = sphere.to_points(n_angles=n_angles).round(3) 199 | points_unique = Points(array_rounded).unique() 200 | 201 | assert points_unique.is_close(points_expected) 202 | -------------------------------------------------------------------------------- /tests/unit/objects/test_triangle.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from math import atan, degrees, isclose, radians, sqrt 3 | 4 | import pytest 5 | from skspatial.objects import Line, Triangle 6 | from skspatial.typing import array_like 7 | 8 | 9 | @dataclass 10 | class TriangleTester: 11 | """Triangle object for unit testing.""" 12 | 13 | points: tuple 14 | 15 | area: float 16 | perimeter: float 17 | 18 | lengths: tuple 19 | angles: tuple 20 | altitudes: tuple 21 | 22 | normal: array_like 23 | centroid: array_like 24 | orthocenter: array_like 25 | 26 | classification: str 27 | is_right: bool 28 | 29 | 30 | list_test_cases = [ 31 | TriangleTester( 32 | points=[[0, 0], [1, 0], [0, 1]], 33 | area=0.5, 34 | perimeter=2 + sqrt(2), 35 | lengths=(sqrt(2), 1, 1), 36 | angles=(90, 45, 45), 37 | centroid=[1 / 3, 1 / 3], 38 | orthocenter=[0, 0], 39 | normal=[0, 0, 1], 40 | classification='isosceles', 41 | is_right=True, 42 | altitudes=(Line([0, 0], [0.5, 0.5]), Line([1, 0], [-1, 0]), Line([0, 1], [0, -1])), 43 | ), 44 | TriangleTester( 45 | points=[[0, 0], [1, 1], [2, 0]], 46 | area=1, 47 | perimeter=2 + 2 * sqrt(2), 48 | lengths=(sqrt(2), 2, sqrt(2)), 49 | angles=(45, 90, 45), 50 | centroid=[1, 1 / 3], 51 | orthocenter=[1, 1], 52 | normal=[0, 0, -2], 53 | classification='isosceles', 54 | is_right=True, 55 | altitudes=(Line([0, 0], [1, 1]), Line([1, 1], [0, -1]), Line([2, 0], [-1, 1])), 56 | ), 57 | TriangleTester( 58 | points=[[0, 0], [1, 0], [0.5, sqrt(3) / 2]], 59 | area=sqrt(3) / 4, 60 | perimeter=3, 61 | lengths=(1, 1, 1), 62 | angles=(60, 60, 60), 63 | centroid=[0.5, sqrt(3) / 6], 64 | orthocenter=[0.5, sqrt(3) / 6], 65 | normal=[0, 0, sqrt(3) / 2], 66 | classification='equilateral', 67 | is_right=False, 68 | altitudes=( 69 | Line([0, 0], [0.75, sqrt(3) / 4]), 70 | Line([1, 0], [-0.75, sqrt(3) / 4]), 71 | Line([0.5, sqrt(3) / 2], [0, -sqrt(3) / 2]), 72 | ), 73 | ), 74 | TriangleTester( 75 | points=[[0, 0], [1, 0], [0, 2]], 76 | area=1, 77 | perimeter=3 + sqrt(5), 78 | lengths=(sqrt(5), 2, 1), 79 | angles=(90, degrees(atan(2)), degrees(atan(1 / 2))), 80 | centroid=[1 / 3, 2 / 3], 81 | orthocenter=[0, 0], 82 | normal=[0, 0, 2], 83 | classification='scalene', 84 | is_right=True, 85 | altitudes=(Line([0, 0], [0.8, 0.4]), Line([1, 0], [-1, 0]), Line([0, 2], [0, -2])), 86 | ), 87 | TriangleTester( 88 | points=[[0, 0], [3, 0], [0, 4]], 89 | area=6, 90 | perimeter=12, 91 | lengths=(5, 4, 3), 92 | angles=(90, degrees(atan(4 / 3)), degrees(atan(3 / 4))), 93 | centroid=[1, 4 / 3], 94 | orthocenter=[0, 0], 95 | normal=[0, 0, 12], 96 | classification='scalene', 97 | is_right=True, 98 | altitudes=(Line([0, 0], [1.92, 1.44]), Line([3, 0], [-3, 0]), Line([0, 4], [0, -4])), 99 | ), 100 | ] 101 | 102 | 103 | @pytest.mark.parametrize('test_case', list_test_cases) 104 | def test_triangle(test_case): 105 | triangle = Triangle(*test_case.points) 106 | 107 | assert triangle.area() == test_case.area 108 | assert triangle.perimeter() == test_case.perimeter 109 | 110 | points_a = triangle.multiple('point', 'ABC') 111 | points_b = test_case.points 112 | assert all(a.is_equal(b) for a, b in zip(points_a, points_b)) 113 | 114 | lengths_a = triangle.multiple('length', 'abc') 115 | lengths_b = test_case.lengths 116 | assert all(isclose(a, b) for a, b in zip(lengths_a, lengths_b)) 117 | 118 | angles_a = triangle.multiple('angle', 'ABC') 119 | angles_b = tuple(map(radians, test_case.angles)) 120 | assert all(isclose(a, b, abs_tol=1e-3) for a, b in zip(angles_a, angles_b)) 121 | 122 | assert triangle.normal().is_close(test_case.normal) 123 | assert triangle.centroid().is_close(test_case.centroid) 124 | assert triangle.orthocenter().is_close(test_case.orthocenter) 125 | 126 | altitudes_a = triangle.multiple('altitude', 'ABC') 127 | altitudes_b = test_case.altitudes 128 | assert all(a.is_close(b, abs_tol=1e-3) for a, b in zip(altitudes_a, altitudes_b)) 129 | 130 | assert triangle.classify() == test_case.classification 131 | assert triangle.is_right() == test_case.is_right 132 | 133 | 134 | @pytest.mark.parametrize( 135 | ("array_a", "array_b", "array_c"), 136 | [([1], [1, 0], [1, 0]), ([1, 0, 0], [1, 0], [1, 0]), ([1, 0], [1, 0], [1, 0, 0]), ([1, 0, 0], [1, 0], [1, 0, 0])], 137 | ) 138 | def test_failure_different_dimensions(array_a, array_b, array_c): 139 | with pytest.raises(ValueError, match="The points must have the same dimension."): 140 | Triangle(array_a, array_b, array_c) 141 | 142 | 143 | @pytest.mark.parametrize( 144 | ("array_a", "array_b", "array_c"), 145 | [ 146 | ([1], [2], [3]), 147 | ([1, 0], [1, 0], [1, 0]), 148 | ([1, 2, 3], [1, 2, 3], [1, 2, 3]), 149 | ([1, 2, 3], [1, 2, 3], [2, 3, -10]), # Two points are the same. 150 | ([1, 2, 3], [4, 5, 6], [7, 8, 9]), 151 | ([1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]), 152 | ], 153 | ) 154 | def test_failure_collinear_points(array_a, array_b, array_c): 155 | with pytest.raises(ValueError, match="The points must not be collinear."): 156 | Triangle(array_a, array_b, array_c) 157 | 158 | 159 | @pytest.fixture() 160 | def basic_triangle(): 161 | return Triangle([0, 0], [0, 1], [1, 0]) 162 | 163 | 164 | @pytest.mark.parametrize("string", ['a', 'b', 'c', 'd', 'D']) 165 | def test_failure_point(basic_triangle, string): 166 | message = "The vertex must be 'A', 'B', or 'C'." 167 | 168 | with pytest.raises(ValueError, match=message): 169 | basic_triangle.point(string) 170 | 171 | with pytest.raises(ValueError, match=message): 172 | basic_triangle.angle(string) 173 | 174 | with pytest.raises(ValueError, match=message): 175 | basic_triangle.altitude(string) 176 | 177 | 178 | @pytest.mark.parametrize("string", ['A', 'B', 'C', 'D']) 179 | def test_failure_line(basic_triangle, string): 180 | message = "The side must be 'a', 'b', or 'c'." 181 | 182 | with pytest.raises(ValueError, match=message): 183 | basic_triangle.line(string) 184 | 185 | with pytest.raises(ValueError, match=message): 186 | basic_triangle.length(string) 187 | -------------------------------------------------------------------------------- /tests/unit/objects/test_vector.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import numpy as np 4 | import pytest 5 | from numpy.testing import assert_array_equal 6 | from skspatial.objects import Vector 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ("array_a", "array_b", "vector_expected"), 11 | [ 12 | ([0, 0], [1, 0], Vector([1, 0])), 13 | ([1, 0], [1, 0], Vector([0, 0])), 14 | ([1, 0], [2, 0], Vector([1, 0])), 15 | ([8, 3, -5], [3, 7, 1], Vector([-5, 4, 6])), 16 | ([5, 7, 8, 9], [2, 5, 3, -4], Vector([-3, -2, -5, -13])), 17 | ], 18 | ) 19 | def test_from_points(array_a, array_b, vector_expected): 20 | assert_array_equal(Vector.from_points(array_a, array_b), vector_expected) 21 | 22 | 23 | @pytest.mark.parametrize( 24 | ("array", "array_unit_expected"), 25 | [ 26 | ([1, 0], [1, 0]), 27 | ([2, 0], [1, 0]), 28 | ([-1, 0], [-1, 0]), 29 | ([0, 0, 5], [0, 0, 1]), 30 | ([1, 1], [math.sqrt(2) / 2, math.sqrt(2) / 2]), 31 | ([1, 1, 1], [math.sqrt(3) / 3, math.sqrt(3) / 3, math.sqrt(3) / 3]), 32 | ([2, 0, 0, 0], [1, 0, 0, 0]), 33 | ([3, 3, 0, 0], [math.sqrt(2) / 2, math.sqrt(2) / 2, 0, 0]), 34 | ], 35 | ) 36 | def test_unit(array, array_unit_expected): 37 | assert Vector(array).unit().is_close(array_unit_expected) 38 | 39 | 40 | @pytest.mark.parametrize( 41 | "array", 42 | [[0], [0, 0], [0, 0, 0]], 43 | ) 44 | def test_unit_failure(array): 45 | with pytest.raises(ValueError, match="The magnitude must not be zero."): 46 | Vector(array).unit() 47 | 48 | 49 | @pytest.mark.parametrize( 50 | ("array", "kwargs", "bool_expected"), 51 | [ 52 | ([0, 0], {}, True), 53 | ([0, 0, 0], {}, True), 54 | ([0, 1], {}, False), 55 | # The tolerance affects the output. 56 | ([0, 0, 1e-4], {}, False), 57 | ([0, 0, 1e-4], {'abs_tol': 1e-3}, True), 58 | ([0, 0, 0, 0], {}, True), 59 | ([7, 0, 2, 0], {}, False), 60 | ], 61 | ) 62 | def test_is_zero(array, kwargs, bool_expected): 63 | assert Vector(array).is_zero(**kwargs) is bool_expected 64 | 65 | 66 | @pytest.mark.parametrize( 67 | ("array_u", "array_v", "similarity_expected"), 68 | [ 69 | ([1, 0], [1, 0], 1), 70 | ([1, 0], [0, 1], 0), 71 | ([1, 0], [-1, 0], -1), 72 | ([1, 0], [0, -1], 0), 73 | ([1, 0], [1, 1], math.sqrt(2) / 2), 74 | ([1, 0], [-1, 1], -math.sqrt(2) / 2), 75 | ([1, 0], [-1, -1], -math.sqrt(2) / 2), 76 | ([1, 0], [1, -1], math.sqrt(2) / 2), 77 | ([1, 0], [0.5, math.sqrt(3) / 2], 0.5), 78 | ([1, 0], [math.sqrt(3) / 2, 0.5], math.sqrt(3) / 2), 79 | ], 80 | ) 81 | def test_cosine_similarity(array_u, array_v, similarity_expected): 82 | similarity = Vector(array_u).cosine_similarity(array_v) 83 | assert math.isclose(similarity, similarity_expected) 84 | 85 | 86 | @pytest.mark.parametrize( 87 | ("array_u", "array_v"), 88 | [ 89 | ([1, 1], [0, 0]), 90 | ([0, 0], [1, 1]), 91 | ], 92 | ) 93 | def test_cosine_similarity_failure(array_u, array_v): 94 | with pytest.raises(ValueError, match="The vectors must have non-zero magnitudes."): 95 | Vector(array_u).cosine_similarity(array_v) 96 | 97 | 98 | @pytest.mark.parametrize( 99 | ("array_u", "array_v", "angle_expected"), 100 | [ 101 | ([1, 0], [1, 0], 0), 102 | ([1, 0], [math.sqrt(3) / 2, 0.5], np.pi / 6), 103 | ([1, 0], [1, 1], np.pi / 4), 104 | ([1, 0], [0, 1], np.pi / 2), 105 | ([1, 0], [0, -1], np.pi / 2), 106 | ([1, 0], [-1, 0], np.pi), 107 | ([1, 0, 0], [0, 1, 0], np.pi / 2), 108 | ], 109 | ) 110 | def test_angle_between(array_u, array_v, angle_expected): 111 | """Test finding the angle between vectors u and v.""" 112 | 113 | angle = Vector(array_u).angle_between(array_v) 114 | assert math.isclose(angle, angle_expected) 115 | 116 | 117 | @pytest.mark.parametrize( 118 | ("array_u", "array_v", "angle_expected"), 119 | [ 120 | ([1, 0], [1, 0], 0), 121 | ([1, 0], [1, 1], np.pi / 4), 122 | ([1, 0], [0, 1], np.pi / 2), 123 | ([1, 0], [-1, 1], 3 * np.pi / 4), 124 | ([1, 0], [-1, 0], np.pi), 125 | ([1, 0], [-1, -1], -3 * np.pi / 4), 126 | ([1, 0], [0, -1], -np.pi / 2), 127 | ([1, 0], [1, -1], -np.pi / 4), 128 | ([1, 1], [0, 1], np.pi / 4), 129 | ([1, 1], [1, 0], -np.pi / 4), 130 | ], 131 | ) 132 | def test_angle_signed(array_u, array_v, angle_expected): 133 | angle = Vector(array_u).angle_signed(array_v) 134 | assert math.isclose(angle, angle_expected) 135 | 136 | 137 | @pytest.mark.parametrize( 138 | ("array_u", "array_v"), 139 | [ 140 | ([0], [0]), 141 | ([1, 1, 1], [1, 0, 0]), 142 | (np.ones(4), np.ones(4)), 143 | ], 144 | ) 145 | def test_angle_signed_failure(array_u, array_v): 146 | with pytest.raises(ValueError, match="The vectors must be 2D."): 147 | Vector(array_u).angle_signed(array_v) 148 | 149 | 150 | @pytest.mark.parametrize( 151 | ("array_u", "array_v", "direction_positive", "angle_expected"), 152 | [ 153 | ([1, 0, 0], [1, 0, 0], [1, 2, 3], 0), 154 | ([1, 0, 0], [-1, 0, 0], [1, 2, 3], np.pi), 155 | ([-1, 0, 0], [1, 0, 0], [1, 2, 3], np.pi), 156 | ([3, 0, 0], [0, 2, 0], [0, 0, -4], -np.pi / 2), 157 | ([3, 0, 0], [0, 2, 0], [0, 0, 5], np.pi / 2), 158 | ([-4, 0, 0], [1, 1, 0], [0, 0, 2], -3 * np.pi / 4), 159 | ], 160 | ) 161 | def test_angle_signed_3d(array_u, array_v, direction_positive, angle_expected): 162 | angle = Vector(array_u).angle_signed_3d(array_v, direction_positive) 163 | assert math.isclose(angle, angle_expected) 164 | 165 | 166 | @pytest.mark.parametrize( 167 | ("array_u", "array_v", "direction_positive", "message_expected"), 168 | [ 169 | ([1, 0], [1, 0], [0, 0, 3], "The vectors must be 3D."), 170 | ([2, -1, 0], [0, 2, 0], [1, 1], "The vectors must be 3D."), 171 | (np.ones(4), np.ones(4), np.ones(4), "The vectors must be 3D."), 172 | ( 173 | [3, 0, 0], 174 | [0, 2, 0], 175 | [0, 1, 1], 176 | "The positive direction vector must be perpendicular to the plane formed by the two main input vectors.", 177 | ), 178 | ], 179 | ) 180 | def test_angle_signed_3d_failure(array_u, array_v, direction_positive, message_expected): 181 | with pytest.raises(ValueError, match=message_expected): 182 | Vector(array_u).angle_signed_3d(array_v, direction_positive) 183 | 184 | 185 | @pytest.mark.parametrize( 186 | ("array_u", "array_v", "bool_expected"), 187 | [ 188 | ([1, 0], [0, 1], True), 189 | ([0, 1], [-1, 0], True), 190 | ([-1, 0], [0, -1], True), 191 | ([1, 1], [-1, -1], False), 192 | ([1, 1], [1, 1], False), 193 | # The zero vector is perpendicular to all vectors. 194 | ([0, 0], [-1, 5], True), 195 | ([0, 0, 0], [1, 1, 1], True), 196 | ], 197 | ) 198 | def test_is_perpendicular(array_u, array_v, bool_expected): 199 | """Test checking if vector u is perpendicular to vector v.""" 200 | vector_u = Vector(array_u) 201 | 202 | assert vector_u.is_perpendicular(array_v) is bool_expected 203 | 204 | 205 | @pytest.mark.parametrize( 206 | ("array_u", "array_v", "bool_expected"), 207 | [ 208 | ([0, 1], [0, 1], True), 209 | ([1, 0], [0, 1], False), 210 | ([0, 1], [4, 0], False), 211 | ([0, 1], [0, 5], True), 212 | ([1, 1], [-1, -1], True), 213 | ([1, 1], [-5, -5], True), 214 | ([0, 1], [0, -1], True), 215 | ([0.1, 5, 4], [3, 2, 0], False), 216 | ([1, 1, 1, 1], [-2, -2, -2, 4], False), 217 | ([1, 1, 1, 1], [-2, -2, -2, -2], True), 218 | ([5, 0, -6, 7], [0, 1, 6, 3], False), 219 | ([6, 0, 1, 0], [-12, 0, -2, 0], True), 220 | # The zero vector is parallel to all vectors. 221 | ([0, 0], [1, 1], True), 222 | ([5, 2], [0, 0], True), 223 | ([5, -3, 2, 6], [0, 0, 0, 0], True), 224 | ], 225 | ) 226 | def test_is_parallel(array_u, array_v, bool_expected): 227 | """Test checking if vector u is parallel to vector v.""" 228 | vector_u = Vector(array_u) 229 | 230 | assert vector_u.is_parallel(array_v) is bool_expected 231 | 232 | 233 | @pytest.mark.parametrize( 234 | ("array_a", "array_b", "value_expected"), 235 | [ 236 | ([0, 0], [0, 0], 0), 237 | ([0, 0], [0, 1], 0), 238 | ([0, 0], [1, 1], 0), 239 | ([0, 1], [0, 1], 0), 240 | ([0, 1], [0, 9], 0), 241 | ([0, 1], [0, -20], 0), 242 | ([0, 1], [1, 1], 1), 243 | ([0, 1], [38, 29], 1), 244 | ([0, 1], [1, 0], 1), 245 | ([0, 1], [1, -100], 1), 246 | ([0, 1], [-1, 1], -1), 247 | ([0, 1], [-1, 20], -1), 248 | ([0, 1], [-1, -20], -1), 249 | ([0, 1], [-5, 50], -1), 250 | ], 251 | ) 252 | def test_side_vector(array_a, array_b, value_expected): 253 | assert Vector(array_a).side_vector(array_b) == value_expected 254 | 255 | 256 | @pytest.mark.parametrize( 257 | ("array_a", "array_b"), 258 | [ 259 | ([0], [1]), 260 | ([0, 0, 0], [1, 1, 1]), 261 | ([0, 0, 0, 0], [1, 1, 1, 1]), 262 | ], 263 | ) 264 | def test_side_vector_failure(array_a, array_b): 265 | message_expected = "The vectors must be 2D." 266 | 267 | with pytest.raises(ValueError, match=message_expected): 268 | Vector(array_a).side_vector(array_b) 269 | 270 | 271 | @pytest.mark.parametrize( 272 | ("vector_u", "vector_v", "vector_expected"), 273 | [ 274 | ([1, 1], [1, 0], [1, 0]), 275 | ([1, 5], [1, 0], [1, 0]), 276 | ([5, 5], [1, 0], [5, 0]), 277 | # Scaling v by a non-zero scalar doesn't change the projection. 278 | ([0, 1], [0, 1], [0, 1]), 279 | ([0, 1], [0, -5], [0, 1]), 280 | ([0, 1], [0, 15], [0, 1]), 281 | # The projection is the zero vector if u and v are perpendicular. 282 | ([1, 0], [0, 1], [0, 0]), 283 | ([5, 0], [0, 9], [0, 0]), 284 | # The projection of the zero vector onto v is the zero vector. 285 | ([0, 0], [0, 1], [0, 0]), 286 | ], 287 | ) 288 | def test_project_vector(vector_u, vector_v, vector_expected): 289 | """Test projecting vector u onto vector v.""" 290 | 291 | vector_u_projected = Vector(vector_v).project_vector(vector_u) 292 | 293 | assert vector_u_projected.is_close(vector_expected) 294 | 295 | 296 | @pytest.mark.parametrize( 297 | ("array", "array_expected"), 298 | [ 299 | ([1], [-1]), 300 | ([5], [-1]), 301 | ([-5], [1]), 302 | ([0, 1], [1, 0]), 303 | ([1, 0], [0, 1]), 304 | ([2, 0], [0, 1]), 305 | ([5, 0], [0, 1]), 306 | ([0, 2], [1, 0]), 307 | ([0, 5], [1, 0]), 308 | ([0, 0, 1], [1, 0, 0]), 309 | ([1, 0, 0], [0, 1, 0]), 310 | ([1, 0, 1], [1, 0, 0]), 311 | ([1, 1, 0], [1, 0, 0]), 312 | ([0, 0, 1, 1], [1, 0, 0, 0]), 313 | ([5, 0, 1, 1], [1, 0, 0, 0]), 314 | ], 315 | ) 316 | def test_different_direction(array, array_expected): 317 | vector = Vector(array) 318 | vector_expected = Vector(array_expected) 319 | 320 | assert vector.different_direction().is_equal(vector_expected) 321 | 322 | 323 | @pytest.mark.parametrize( 324 | "array", 325 | [ 326 | ([0]), 327 | ([0] * 2), 328 | ([0] * 3), 329 | ([0] * 4), 330 | ], 331 | ) 332 | def test_different_direction_failure(array): 333 | message_expected = "The vector must not be the zero vector." 334 | 335 | with pytest.raises(ValueError, match=message_expected): 336 | Vector(array).different_direction() 337 | -------------------------------------------------------------------------------- /tests/unit/test_functions.py: -------------------------------------------------------------------------------- 1 | from math import isclose, sqrt 2 | 3 | import pytest 4 | from skspatial._functions import _solve_quadratic 5 | 6 | A_MUST_BE_NON_ZERO = "The coefficient `a` must be non-zero." 7 | DISCRIMINANT_MUST_NOT_BE_NEGATIVE = "The discriminant must not be negative." 8 | 9 | 10 | @pytest.mark.parametrize( 11 | ("a", "b", "c", "x1_expected", "x2_expected"), 12 | [ 13 | (1, 0, 0, 0, 0), 14 | (-1, 0, 0, 0, 0), 15 | (-1, 1, 0, 1, 0), 16 | (1, -1, -1, (1 - sqrt(5)) / 2, (1 + sqrt(5)) / 2), 17 | ], 18 | ) 19 | def test_solve_quadratic(a, b, c, x1_expected, x2_expected): 20 | x1, x2 = _solve_quadratic(a, b, c) 21 | 22 | assert isclose(x1, x1_expected) 23 | assert isclose(x2, x2_expected) 24 | 25 | 26 | @pytest.mark.parametrize( 27 | ("a", "b", "c", "message_expected"), 28 | [ 29 | (0, 0, 0, A_MUST_BE_NON_ZERO), 30 | (0, 1, 1, A_MUST_BE_NON_ZERO), 31 | (1, 0, 1, DISCRIMINANT_MUST_NOT_BE_NEGATIVE), 32 | (1, 2, 2, DISCRIMINANT_MUST_NOT_BE_NEGATIVE), 33 | (1, -2, 2, DISCRIMINANT_MUST_NOT_BE_NEGATIVE), 34 | (-1, 0, -1, DISCRIMINANT_MUST_NOT_BE_NEGATIVE), 35 | ], 36 | ) 37 | def test_solve_quadratic_failure(a, b, c, message_expected): 38 | with pytest.raises(ValueError, match=message_expected): 39 | _solve_quadratic(a, b, c) 40 | -------------------------------------------------------------------------------- /tests/unit/test_measurement.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import numpy as np 4 | import pytest 5 | from skspatial.measurement import area_signed, area_triangle, volume_tetrahedron 6 | from skspatial.objects import Points 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ("array_a", "array_b", "array_c", "area_expected"), 11 | [ 12 | ([0, 0], [1, 0], [0, 1], 0.5), 13 | ([0, 0], [1, 1], [2, 0], 1), 14 | ([0, 0], [1, 10], [2, 0], 10), 15 | ([0, 0], [1, 0], [2, 0], 0), 16 | ([0, 0], [-5, -2], [5, 2], 0), 17 | ([1, 0, 0], [0, 1, 0], [0, 0, 1], math.sin(np.pi / 3)), 18 | ([2, 0, 0], [0, 2, 0], [0, 0, 2], 4 * math.sin(np.pi / 3)), 19 | ], 20 | ) 21 | def test_area_triangle(array_a, array_b, array_c, area_expected): 22 | area = area_triangle(array_a, array_b, array_c) 23 | assert math.isclose(area, area_expected) 24 | 25 | 26 | @pytest.mark.parametrize( 27 | ("array_a", "array_b", "array_c", "array_d", "volume_expected"), 28 | [ 29 | ([0, 0], [2, 0], [1, 1], [10, -7], 0), 30 | ([0, 0, 0], [2, 0, 0], [1, 1, 0], [0, 0, 1], 1 / 3), 31 | ([0, 0, 0], [2, 0, 0], [1, 1, 0], [0, 0, -1], 1 / 3), 32 | ([0, 0, 0], [2, 0, 0], [1, 1, 0], [0, 0, 2], 2 / 3), 33 | ([0, 0, 0], [2, 0, 0], [1, 1, 0], [0, 0, 3], 1), 34 | ([0, 0, 0], [2, 0, 0], [1, 1, 0], [-56, 10, 3], 1), 35 | ([0, 1, 1], [0, 1, 5], [0, -5, 7], [0, 5, 2], 0), 36 | ], 37 | ) 38 | def test_volume_tetrahedron(array_a, array_b, array_c, array_d, volume_expected): 39 | volume = volume_tetrahedron(array_a, array_b, array_c, array_d) 40 | assert math.isclose(volume, volume_expected) 41 | 42 | 43 | @pytest.mark.parametrize( 44 | ("points", "area_expected"), 45 | [ 46 | # Counter-clockwise triangle 47 | ([[2, 2], [6, 2], [4, 5]], 6), 48 | # Clockwise triangle 49 | ([[1, 3], [-4, 3], [-3, 4]], -2.5), 50 | # Counter-clockwise square 51 | ([[-1, 2], [2, 5], [-1, 8], [-4, 5]], 18), 52 | # Clockwise irregular convex pentagon 53 | ([[-2, 2], [-5, 2], [-8, 5], [-4, 8], [-1, 5]], -25.5), 54 | # Counter-clockwise irregular convex hexagon 55 | ([[3, -2], [6, -3], [10, -1], [8, 4], [4, 3], [1, 1]], 39.5), 56 | # Clockwise non-convex polygon 57 | ([[5, -2], [1, -1], [0, 4], [6, 6], [3, 3]], -22), 58 | # Self-overlapping polygon 59 | ([[-4, 4], [-4, 1], [2, 4], [2, 1]], 0), 60 | ], 61 | ) 62 | def test_area_signed(points, area_expected): 63 | points = Points(points) 64 | area = area_signed(points) 65 | 66 | assert area == area_expected 67 | 68 | 69 | @pytest.mark.parametrize( 70 | ("points", "message_expected"), 71 | [ 72 | ([[1, 0, 0], [-1, 0, 0], [0, 1, 0]], "The points must be 2D."), 73 | ([[2, 0], [-2, 0]], "There must be at least 3 points."), 74 | ], 75 | ) 76 | def test_area_signed_failure(points, message_expected): 77 | with pytest.raises(ValueError, match=message_expected): 78 | area_signed(points) 79 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | 3 | [gh] 4 | python = 5 | 3.8 = 3.8 6 | 3.9 = 3.9 7 | 3.10 = 3.10 8 | 3.11 = 3.11 9 | 3.12 = 3.12, type, readme, doctests, docs 10 | 11 | [testenv] 12 | deps = 13 | pytest==8.3.3 14 | pytest-cov==5.0.0 15 | description = Run unit tests 16 | commands = 17 | pytest tests/unit/ --cov=skspatial --cov-report=xml 18 | 19 | [testenv:lint] 20 | deps = 21 | pre-commit==3.8.0 22 | commands = 23 | pre-commit run --all-files 24 | 25 | [testenv:type] 26 | deps = 27 | matplotlib==3.10.1 28 | mypy==1.15.0 29 | commands = 30 | mypy src/ 31 | 32 | [testenv:readme] 33 | commands = 34 | python -m doctest README.md 35 | 36 | [testenv:doctests] 37 | deps = 38 | pytest==8.3.3 39 | matplotlib==3.10.1 40 | commands = 41 | pytest --doctest-modules src/ 42 | 43 | [testenv:docs] 44 | deps = 45 | Sphinx==5.3.0 46 | matplotlib==3.10.1 47 | numpydoc==1.5.0 48 | setuptools==69.0.3 49 | sphinx-bootstrap-theme==0.8.1 50 | sphinx-gallery==0.9.0 51 | commands = 52 | sphinx-build docs/source/ docs/build/ 53 | --------------------------------------------------------------------------------