diff --git a/.cirrus.yml b/.cirrus.yml index 4c7702ec..71db21a8 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -1,4 +1,18 @@ +docs_task: + skip: $CIRRUS_BRANCH =~ '.*\.tmp' + container: + image: python:3.7-slim + + install_script: + - apt update && apt install make + - pip install --upgrade-strategy eager -U -r docs-requirements.txt + - pip install -e . + + script: + - make -C docs/ html + lint_task: + skip: $CIRRUS_BRANCH =~ '.*\.tmp' container: image: python:3.7-slim @@ -15,6 +29,7 @@ lint_task: FreeBSD_task: + skip: $CIRRUS_BRANCH =~ '.*\.tmp' freebsd_instance: image: freebsd-12-0-release-amd64 env: @@ -37,6 +52,7 @@ FreeBSD_task: - ./test.sh Linux_task: + skip: $CIRRUS_BRANCH =~ '.*\.tmp' allow_failures: $CIRRUS_TASK_NAME =~ '.*-rc-.*' container: matrix: @@ -56,6 +72,7 @@ Linux_task: - ./test.sh macOS_task: + skip: $CIRRUS_BRANCH =~ '.*\.tmp' osx_instance: image: mojave-base env: @@ -81,6 +98,7 @@ macOS_task: - ./test.sh Windows_task: + skip: $CIRRUS_BRANCH =~ '.*\.tmp' allow_failures: $CIRRUS_TASK_NAME =~ '.*-rc-.*' windows_container: os_version: 2019 diff --git a/README.md b/README.md index dc597138..fabb40cb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # ppb-vector -The 2D Vector Class for the PursuedPyBear project. +The immutable, 2D vector class for the PursuedPyBear project. + +`Vector2` implements many convenience features, as well as +useful mathematical operations for 2D geometry. ## Install @@ -17,204 +20,7 @@ pip install 'ppb-vector' >>> Vector2(3, 4) Vector2(3.0, 4.0) -`Vector2` implements many convenience features, as well as -useful mathematical operations for 2D geometry and linear algebra. - - -## Convenience functions - -### Unpacking - - >>> x, y = Vector2(1, 3) - >>> print(x) - 1.0 - >>> print(y) - 3.0 - -### Access Values - -Convenient access to `Vector2` members via dot notation, indexes, or keys. - - >>> my_vector = Vector2(2, 3) - >>> my_vector.x - 2.0 - >>> my_vector[1] - 3.0 - >>> my_vector["x"] - 2.0 - -Also iterable for translation between Vector2 and other sequence types. - - >>> tuple(Vector2(2, 3)) - (2.0, 3.0) - - -## Mathematical operators - -In addition to `Vector2`, operators also accepts, as second operand, -vector-like objects such as `tuple`, `list`, and `dict`. - - >>> Vector2(1, 1) + [1, 3] - Vector2(2.0, 4.0) - - >>> Vector2(1, 1) + (2, 4) - Vector2(3.0, 5.0) - - >>> Vector2(1, 1) + {"x": 3, "y": 5} - Vector2(4.0, 6.0) - - -### Addition - - >>> Vector2(1, 0) + (0, 1) - Vector2(1.0, 1.0) - -### Subtraction - - >>> Vector2(3, 3) - (1, 1) - Vector2(2.0, 2.0) - -### Equality - -Vectors are equal if their coordinates are equal. - - >>> Vector2(1, 0) == (0, 1) - False - -### Scalar Multiplication - -Multiply a `Vector2` by a scalar to get a scaled `Vector2`: - - >>> 3 * Vector2(1, 1) - Vector2(3.0, 3.0) - - >>> Vector2(1, 1) * 3 - Vector2(3.0, 3.0) - - >>> Vector2(1, 1).scale_by(3) - Vector2(3.0, 3.0) - - -It is also possible to divide a `Vector2` by a scalar: - - >>> Vector2(3, 3) / 3 - Vector2(1.0, 1.0) - -### Dot Product - -Multiply a `Vector2` by another `Vector2` to get the dot product. - - >>> Vector2(1, 1) * (-1, -1) - -2.0 - -### Vector Length - - >>> Vector2(45, 60).length - 75.0 - -### Negation - -Negating a `Vector2` is equivalent to multiplying it by -1. - - >>> -Vector2(1, 1) - Vector2(-1.0, -1.0) - - -## Methods - -Useful functions for game development. - -### isclose(vector) - -Perform an approximate comparison of two vectors. - - >>> Vector2(1, 0).isclose((1, 1e-10)) - True - -`Vector2.isclose` takes optional, keyword arguments, akin to those of -`math.isclose`: -- `abs_tol` (absolute tolerance) is the minimum magnitude (of the difference - vector) under which two inputs are considered close, without consideration for - (the magnitude of) the input values. -- `rel_tol` (relative tolerance) is the relative error: if the length of the - difference vector is less than `rel_tol * input.length` for any `input`, - the two vectors are considered close. -- `rel_to` is an iterable of additional vector-likes whose length (times - `rel_tol`) is compared to the length of the difference vector. - -By default, `abs_tol = 1e-3`, `rel_tol = 1e-6`, and `rel_to = []`. - -### rotate(deg) - -Rotate a vector in relation to its own origin and return a new `Vector2`. - - >>> Vector2(1, 0).rotate(90) - Vector2(0.0, 1.0) - -Positive rotation is counter/anti-clockwise. - -### angle(vector) - -Compute the angle between two vectors, expressed as a scalar in degrees. - - >>> Vector2(1, 0).angle( (0, 1) ) - 90.0 - -As with `rotate()`, angles are signed, and refer to a direct coordinate system -(i.e. positive rotations are counter-clockwise). - -`Vector2.angle` is guaranteed to produce an angle between -180° and 180°. - -### normalize() - -Return a vector with the same direction, and unit length. - - >>> Vector2(3, 4).normalize() - Vector2(0.6, 0.8) - -### scale(scalar) - -Scale given `Vector2` to a given length. - - >>> Vector2(7, 24).scale(2) - Vector2(0.56, 1.92) - -Note that `Vector2.normalize()` is equivalent to `Vector2.scale(1)`. - - >>> Vector2(7, 24).normalize() - Vector2(0.28, 0.96) - >>> Vector2(7, 24).scale(1) - Vector2(0.28, 0.96) - -### truncate(scalar) - -Scale a given `Vector2` down to a given length, if it is larger. - - >>> Vector2(7, 24).truncate(3) - Vector2(0.84, 2.88) - -Note that `Vector2.scale` is equivalent to `Vector2.truncate` when `scalar` is -less than length. - - >>> Vector2(3, 4).scale(4) - Vector2(2.4, 3.2) - >>> Vector2(3, 4).truncate(4) - Vector2(2.4, 3.2) - - >>> Vector2(3, 4).scale(6) - Vector2(3.6, 4.8) - >>> Vector2(3, 4).truncate(6) - Vector2(3.0, 4.0) - -Note: `x.truncate(max_length)` may sometimes be slightly-larger than - `max_length`, due to floating-point rounding effects. - -### reflect(surface_normal) - -Reflect a `Vector2` based on a given surface normal. - >>> Vector2(5, 3).reflect( (-1, 0) ) - Vector2(-5.0, 3.0) +See the [online documentation] for an overview of the functionality. - >>> Vector2(5, 3).reflect( Vector2(-1, -2).normalize() ) - Vector2(0.5999999999999996, -5.800000000000001) +[online documentation]: https://ppb-vector.readthedocs.io/en/latest/ diff --git a/bors.toml b/bors.toml new file mode 100644 index 00000000..da626e82 --- /dev/null +++ b/bors.toml @@ -0,0 +1,12 @@ +status = [ + "docs", + "FreeBSD PYTHON:3.6", + "FreeBSD PYTHON:3.7", + "lint", + "Linux container:python:3.6-slim", + "Linux container:python:3.7-slim", + "Windows container:python:3.6-windowsservercore-1809", + "Windows container:python:3.7-windowsservercore-1809", + "macOS PYTHON:3.6.8", + "macOS PYTHON:3.7.2", +] diff --git a/docs-requirements.txt b/docs-requirements.txt new file mode 100644 index 00000000..f1b2b6a6 --- /dev/null +++ b/docs-requirements.txt @@ -0,0 +1,3 @@ +sphinx +sphinx-autodoc-annotation +sphinx_rtd_theme diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..ba65b13a --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +/_build/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..298ea9e2 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,19 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..9b5ffab2 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = 'ppb-vector' +copyright = '2019, Piper Thunstrom, Jamie Bliss' +author = 'Piper Thunstrom, Jamie Bliss' + +# The short X.Y version +version = '' +# The full version, including alpha/beta/rc tags +release = '' + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.mathjax', + 'sphinx.ext.viewcode', + 'sphinx.ext.intersphinx', + 'sphinx_autodoc_annotation', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = None + + +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), +} + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'ppb-vectordoc' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'ppb-vector.tex', 'ppb-vector Documentation', + 'Piper Thunstrom, Jamie Bliss', 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'ppb-vector', 'ppb-vector Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'ppb-vector', 'ppb-vector Documentation', + author, 'ppb-vector', 'One line description of project.', + 'Miscellaneous'), +] + + +# -- Options for Epub output ------------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# +# epub_identifier = '' + +# A unique identification for the text. +# +# epub_uid = '' + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + + +# -- Extension configuration ------------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..008887b7 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,27 @@ +PPB's 2D Vector class +===================== + +.. py:data:: ppb_vector.vector2.Vector + :annotation: = typing.TypeVar('Vector', bound='Vector2') + + In the following, :py:data:`Vector` is a type variable (an instance of + :py:class:`TypeVar `) that denotes either + :py:class:`Vector2` or any of its subclasses. Implicitely, it is the type of + ``self``: a method whose return type is :py:data:`Vector` will return a + vector of the same type that it was called on. + + +.. autoclass:: ppb_vector.Vector2 + :members: + :special-members: + :exclude-members: __init__, __repr__, __weakref__, scale + + .. autoattribute:: x + :annotation: : float + + The X coordinate of the vector + + .. autoattribute:: y + :annotation: : float + + The Y coordinate of the vector diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..7893348a --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/ppb_vector/vector2.py b/ppb_vector/vector2.py index b77cd30e..9228f543 100644 --- a/ppb_vector/vector2.py +++ b/ppb_vector/vector2.py @@ -9,11 +9,11 @@ # Vector or subclass -VectorOrSub = typing.TypeVar('VectorOrSub', bound='Vector2') +Vector = typing.TypeVar('Vector', bound='Vector2') # Anything convertable to a Vector, including lists, tuples, and dicts VectorLike = typing.Union[ - 'Vector2', # Or subclasses, unconnected to the VectorOrSub typevar above + 'Vector2', # Or subclasses, unconnected to the Vector typevar above typing.Tuple[typing.SupportsFloat, typing.SupportsFloat], typing.Sequence[typing.SupportsFloat], # TODO: Length 2 typing.Mapping[str, typing.SupportsFloat], # TODO: Length 2, keys 'x', 'y' @@ -51,6 +51,45 @@ def _find_lowest_vector(left: typing.Type, right: typing.Type) -> typing.Type: @dataclass(eq=False, frozen=True, init=False, repr=False) class Vector2: + """The immutable, 2D vector class of the PursuedPyBear project. + + :py:class:`Vector2` is an immutable 2D Vector, which can be instantiated as + expected: + + >>> from ppb_vector import Vector2 + >>> Vector2(3, 4) + Vector2(3.0, 4.0) + + :py:class:`Vector2` implements many convenience features, as well as + useful mathematical operations for 2D geometry and linear algebra. + + :py:class:`Vector2` acts as an iterable and a sequence, allowing usage like + converting, indexing, and unpacking: + + >>> v = Vector2(-3, -5) + >>> list(v) + [-3.0, -5.0] + >>> tuple(v) + (-3.0, -5.0) + + >>> v[0] + -3.0 + + >>> x, y = Vector2(1, 2) + >>> x + 1.0 + + >>> print( *Vector2(1, 2) ) + 1.0 2.0 + + It also acts mostly like a mapping, when it does not conflict with being a + sequence. In particular, the coordinates may be accessed by subscripting: + + >>> v["y"] + -5.0 + >>> v["x"] + -3.0 + """ x: float y: float @@ -95,12 +134,25 @@ def __init__(self, *args, **kwargs): except ValueError: raise TypeError(f"{type(y).__name__} object not convertable to float") + #: Return a new :py:class:`Vector2` replacing specified fields with new values. update = dataclasses.replace @classmethod - def convert(cls: typing.Type[VectorOrSub], value: VectorLike) -> VectorOrSub: - """ - Constructs a vector from a vector-like. Does not perform a copy. + def convert(cls: typing.Type[Vector], value: VectorLike) -> Vector: + """Constructs a vector from a vector-like. + + A vector-like can be: + + - a length-2 :py:class:`Sequence `, whose + contents are interpreted as the ``x`` and ``y`` coordinates like ``(4, 2)`` + + - a length-2 :py:class:`Mapping `, whose keys + are ``x`` and ``y`` like ``{'x': 4, 'y': 2}`` + + - any instance of :py:class:`Vector2` or any subclass. + + :py:meth:`convert` does not perform a copy when ``value`` already has the + right type. """ # Use Vector2.convert() instead of type(self).convert() so that # _find_lowest_vector() can resolve things well. @@ -117,17 +169,40 @@ def convert(cls: typing.Type[VectorOrSub], value: VectorLike) -> VectorOrSub: @property def length(self) -> float: + """Compute the length of a vector. + + >>> Vector2(45, 60).length + 75.0 + """ # Surprisingly, caching this value provides no descernable performance # benefit, according to microbenchmarks. return hypot(self.x, self.y) def asdict(self) -> typing.Mapping[str, float]: + """Convert a vector to a vector-like dictionary. + + >>> v = Vector2(42, 69) + >>> v.asdict() + {'x': 42.0, 'y': 69.0} + + The conversion can be reversed by :py:meth:`convert`: + + >>> assert v == Vector2.convert(v.asdict()) + """ return {'x': self.x, 'y': self.y} - def __len__(self: VectorOrSub) -> int: + def __len__(self: Vector) -> int: return 2 - def __add__(self: VectorOrSub, other: VectorLike) -> VectorOrSub: + def __add__(self: Vector, other: VectorLike) -> Vector: + """Add two vectors. + + :param other: A :py:class:`Vector2` or a vector-like. + For a description of vector-likes, see :py:func:`convert`. + + >>> Vector2(1, 0) + (0, 1) + Vector2(1.0, 1.0) + """ rtype = _find_lowest_vector(type(other), type(self)) try: other = Vector2.convert(other) @@ -135,7 +210,15 @@ def __add__(self: VectorOrSub, other: VectorLike) -> VectorOrSub: return NotImplemented return rtype(self.x + other.x, self.y + other.y) - def __sub__(self: VectorOrSub, other: VectorLike) -> VectorOrSub: + def __sub__(self: Vector, other: VectorLike) -> Vector: + """Subtract one vector from another. + + :param other: A :py:class:`Vector2` or a vector-like. + For a description of vector-likes, see :py:func:`convert`. + + >>> Vector2(3, 3) - (1, 1) + Vector2(2.0, 2.0) + """ rtype = _find_lowest_vector(type(other), type(self)) try: other = Vector2.convert(other) @@ -143,29 +226,64 @@ def __sub__(self: VectorOrSub, other: VectorLike) -> VectorOrSub: return NotImplemented return rtype(self.x - other.x, self.y - other.y) - def dot(self: VectorOrSub, other: VectorLike) -> float: - """ - Return the dot product of two vectors. + def dot(self: Vector, other: VectorLike) -> float: + """Dot product of two vectors. + + :param other: A :py:class:`Vector2` or a vector-like. + For a description of vector-likes, see :py:func:`convert`. """ other = Vector2.convert(other) return self.x * other.x + self.y * other.y - def scale_by(self: VectorOrSub, other: typing.SupportsFloat) -> VectorOrSub: - """ - Scale by the given amount. + def scale_by(self: Vector, scalar: typing.SupportsFloat) -> Vector: + """Scalar multiplication. + + >>> Vector2(1, 2).scale_by(3) + Vector2(3.0, 6.0) + + Can also be expressed with :py:meth:`* <__mul__>`: + + >>> 3 * Vector2(1, 2) + Vector2(3.0, 6.0) """ - other = float(other) - return type(self)(self.x * other, self.y * other) + scalar = float(scalar) + return type(self)(scalar * self.x, scalar * self.y) @typing.overload - def __mul__(self: VectorOrSub, other: VectorLike) -> float: pass + def __mul__(self: Vector, other: VectorLike) -> float: pass @typing.overload - def __mul__(self: VectorOrSub, other: typing.SupportsFloat) -> VectorOrSub: pass + def __mul__(self: Vector, other: typing.SupportsFloat) -> Vector: pass def __mul__(self, other): - """ - Performs a dot product or scale based on other. + """Performs a dot product or scalar product, based on the parameter type. + + :param other: If ``other`` is a scalar (an instance of + :py:class:`typing.SupportsFloat`), return + :py:meth:`self.scale_by(other) `. + + >>> 3 * Vector2(1, 1) + Vector2(3.0, 3.0) + + >>> Vector2(1, 1) * 3 + Vector2(3.0, 3.0) + + >>> Vector2(1, 1).scale_by(3) + Vector2(3.0, 3.0) + + It is also possible to divide a :py:class:`Vector2` by a scalar: + + >>> Vector2(3, 3) / 3 + Vector2(1.0, 1.0) + + + :param other: If ``other`` is a vector-like, return + :py:meth:`self.dot(other) `. + + >>> Vector2(1, 1) * (-1, -1) + -2.0 + + Vector-likes are defined in :py:meth:`convert`. """ if isinstance(other, (float, int)): return self.scale_by(other) @@ -176,20 +294,24 @@ def __mul__(self, other): return NotImplemented @typing.overload - def __rmul__(self: VectorOrSub, other: VectorLike) -> float: pass + def __rmul__(self: Vector, other: VectorLike) -> float: pass @typing.overload - def __rmul__(self: VectorOrSub, other: typing.SupportsFloat) -> VectorOrSub: pass + def __rmul__(self: Vector, other: typing.SupportsFloat) -> Vector: pass def __rmul__(self, other): return self.__mul__(other) - def __truediv__(self: VectorOrSub, other: typing.SupportsFloat) -> VectorOrSub: - """Perform a division between a vector and a scalar.""" + def __truediv__(self: Vector, other: typing.SupportsFloat) -> Vector: + """Perform a division between a vector and a scalar. + + >>> Vector2(3, 3) / 3 + Vector2(1.0, 1.0) + """ other = float(other) return type(self)(self.x / other, self.y / other) - def __getitem__(self: VectorOrSub, item: typing.Union[str, int]) -> float: + def __getitem__(self: Vector, item: typing.Union[str, int]) -> float: if hasattr(item, '__index__'): item = item.__index__() # type: ignore if isinstance(item, str): @@ -209,10 +331,18 @@ def __getitem__(self: VectorOrSub, item: typing.Union[str, int]) -> float: else: raise TypeError - def __repr__(self: VectorOrSub) -> str: + def __repr__(self: Vector) -> str: return f"{type(self).__name__}({self.x}, {self.y})" - def __eq__(self: VectorOrSub, other: typing.Any) -> bool: + def __eq__(self: Vector, other: typing.Any) -> bool: + """Test wheter two vectors are equal. + + :param other: A :py:class:`Vector2` or a vector-like. + For a description of vector-likes, see :py:func:`convert`. + + >>> Vector2(1, 0) == (0, 1) + False + """ try: other = Vector2.convert(other) except (TypeError, ValueError): @@ -220,14 +350,35 @@ def __eq__(self: VectorOrSub, other: typing.Any) -> bool: else: return self.x == other.x and self.y == other.y - def __iter__(self: VectorOrSub) -> typing.Iterator[float]: + def __iter__(self: Vector) -> typing.Iterator[float]: yield self.x yield self.y - def __neg__(self: VectorOrSub) -> VectorOrSub: + def __neg__(self: Vector) -> Vector: + """Negate a vector. + + Negating a :py:class:`Vector2` produces one with identical length and opposite + direction. It is equivalent to multiplying it by -1. + + >>> -Vector2(1, 1) + Vector2(-1.0, -1.0) + """ return self.scale_by(-1) - def angle(self: VectorOrSub, other: VectorLike) -> float: + def angle(self: Vector, other: VectorLike) -> float: + """Compute the angle between two vectors, expressed in degrees. + + :param other: A :py:class:`Vector2` or a vector-like. + For a description of vector-likes, see :py:func:`convert`. + + >>> Vector2(1, 0).angle( (0, 1) ) + 90.0 + + As with :py:meth:`rotate`, angles are signed, and refer to a direct + coordinate system (i.e. positive rotations are counter-clockwise). + + :py:meth:`angle` is guaranteed to produce an angle between -180° and 180°. + """ other = Vector2.convert(other) rv = degrees(atan2(other.x, -other.y) - atan2(self.x, -self.y)) @@ -240,25 +391,29 @@ def angle(self: VectorOrSub, other: VectorLike) -> float: return rv - def isclose(self: VectorOrSub, other: VectorLike, *, + def isclose(self: Vector, other: VectorLike, *, abs_tol: typing.SupportsFloat = 1e-09, rel_tol: typing.SupportsFloat = 1e-09, rel_to: typing.Sequence[VectorLike] = ()) -> bool: - """ - Determine whether two vectors are close in value. + """Perform an approximate comparison of two vectors. + + :param other: A :py:class:`Vector2` or a vector-like. + For a description of vector-likes, see :py:func:`convert`. + + >>> assert Vector2(1, 0).isclose((1, 1e-10)) - rel_tol - maximum difference for being considered "close", relative to the - magnitude of the input values - rel_to - additional input values to consider in rel_tol - abs_tol - maximum difference for being considered "close", regardless of the - magnitude of the input values + :py:meth:`isclose` takes optional, keyword arguments, akin to those of + :py:func:`math.isclose`: - Return True if self is close in value to other, and False otherwise. + :param abs_tol: the absolute tolerance is the minimum magnitude (of the + difference vector) under which two inputs are considered close, + without consideration for (the magnitude of) the input values. - For the values to be considered close, the difference between them - must be smaller than at least one of the tolerances. + :param rel_tol: the relative tolerance: if the length of the difference + vector is less than ``rel_tol * input.length`` for any ``input``, the + two vectors are considered close. + + :param rel_to: an iterable of additional vector-likes which are + considered to be inputs, for the purpose of the relative tolerance. """ abs_tol, rel_tol = float(abs_tol), float(rel_tol) if abs_tol < 0 or rel_tol < 0: @@ -293,26 +448,72 @@ def _trig(angle: typing.SupportsFloat) -> typing.Tuple[float, float]: return r_cos, r_sin - def rotate(self: VectorOrSub, angle: typing.SupportsFloat) -> VectorOrSub: + def rotate(self: Vector, angle: typing.SupportsFloat) -> Vector: + """Rotate a vector. + + Rotate a vector in relation to the origin and return a new :py:class:`Vector2`. + + >>> Vector2(1, 0).rotate(90) + Vector2(0.0, 1.0) + + Positive rotation is counter/anti-clockwise. + """ r_cos, r_sin = Vector2._trig(angle) x = self.x * r_cos - self.y * r_sin y = self.x * r_sin + self.y * r_cos return type(self)(x, y) - def normalize(self: VectorOrSub) -> VectorOrSub: + def normalize(self: Vector) -> Vector: + """Return a vector with the same direction and unit length. + + >>> Vector2(3, 4).normalize() + Vector2(0.6, 0.8) + + Note that :py:meth:`normalize()` is equivalent to :py:meth:`scale(1) `: + + >>> assert Vector2(7, 24).normalize() == Vector2(7, 24).scale_to(1) + """ return self.scale(1) - def truncate(self: VectorOrSub, max_length: typing.SupportsFloat) -> VectorOrSub: + def truncate(self: Vector, max_length: typing.SupportsFloat) -> Vector: + """Scale a given :py:class:`Vector2` down to a given length, if it is larger. + + >>> Vector2(7, 24).truncate(3) + Vector2(0.84, 2.88) + + It produces a vector with the same direction, but possibly a different + length. + + Note that :py:meth:`vector.scale(max_length) ` is equivalent to + :py:meth:`vector.truncate(max_length) ` when + :py:meth:`max_length \< vector.length `. + + >>> Vector2(3, 4).scale(4) + Vector2(2.4, 3.2) + >>> Vector2(3, 4).truncate(4) + Vector2(2.4, 3.2) + + >>> Vector2(3, 4).scale(6) + Vector2(3.6, 4.8) + >>> Vector2(3, 4).truncate(6) + Vector2(3.0, 4.0) + + Note: :py:meth:`x.truncate(max_length) ` may sometimes be + slightly-larger than ``max_length``, due to floating-point rounding + effects. + """ max_length = float(max_length) if self.length <= max_length: return self return self.scale_to(max_length) - def scale_to(self: VectorOrSub, length: typing.SupportsFloat) -> VectorOrSub: - """ - Scale the vector to the given length + def scale_to(self: Vector, length: typing.SupportsFloat) -> Vector: + """Scale a given :py:class:`Vector2` to a certain length. + + >>> Vector2(7, 24).scale_to(2) + Vector2(0.56, 1.92) """ length = float(length) if length < 0: @@ -325,9 +526,20 @@ def scale_to(self: VectorOrSub, length: typing.SupportsFloat) -> VectorOrSub: scale = scale_to - def reflect(self: VectorOrSub, surface_normal: VectorLike) -> VectorOrSub: - """ - Calculate the reflection of the vector against a given surface normal + def reflect(self: Vector, surface_normal: VectorLike) -> Vector: + """Reflect a vector against a surface. + + :param other: A :py:class:`Vector2` or a vector-like. + For a description of vector-likes, see :py:func:`convert`. + + Compute the reflection of a :py:class:`Vector2` on a surface going + through the origin, described by its normal vector. + + >>> Vector2(5, 3).reflect( (-1, 0) ) + Vector2(-5.0, 3.0) + + >>> Vector2(5, 3).reflect( Vector2(-1, -2).normalize() ) + Vector2(0.5999999999999996, -5.800000000000001) """ surface_normal = Vector2.convert(surface_normal) if not isclose(surface_normal.length, 1): diff --git a/test.sh b/test.sh index b7cd0700..f3340ded 100755 --- a/test.sh +++ b/test.sh @@ -10,5 +10,5 @@ else fi -run ${PY} -m doctest README.md +run ${PY} -m doctest README.md ppb_vector/vector2.py run ${PY} -m pytest "${PYTEST_OPTIONS[@]}"