├── .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 | 
2 |
3 | [](https://pypi.python.org/pypi/scikit-spatial)
4 | [](https://anaconda.org/conda-forge/scikit-spatial)
5 | [](https://pypi.python.org/pypi/scikit-spatial)
6 | [](https://github.com/ajhynes7/scikit-spatial/actions/workflows/main.yml)
7 | [](https://scikit-spatial.readthedocs.io/en/latest/?badge=latest)
8 | [](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 |
--------------------------------------------------------------------------------