diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +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) diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..3b9258b --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,9 @@ +============== +Public classes +============== + +.. toctree:: + :maxdepth: 2 + :glob: + + public/* diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..52c31d2 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,64 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- 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('..')) +import sphinx_rtd_theme +import numpydoc + + +# -- Project information ----------------------------------------------------- + +project = 'Foronoi' +copyright = '2021, Jeroen van Hoof' +author = 'Jeroen van Hoof' + +# The full version, including alpha/beta/rc tags +release = '1.0.0' + + +# -- General configuration --------------------------------------------------- + +# 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_rtd_theme', + 'sphinx.ext.napoleon', + 'sphinx.ext.intersphinx', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# 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'] + + +# -- 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' + +# 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'] + + +autoclass_content = 'both' diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..76b31fc --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,48 @@ +.. Foronoi documentation master file, created by + sphinx-quickstart on Sun Apr 4 20:32:46 2021. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to Foronoi's documentation! +=================================== + +Foronoi is a Python implementation of the Fortune's algorithm based on the description of "Computational Geometry: +Algorithms and Applications" by de Berg et al. + +This algorithm is a sweep line algorithm that scans top down over the +cell points and traces out the lines via breakpoints in between parabola's (arcs). Once a new point is inserted, a check +is done to see if it will converge with the lines on the left or right. If that's the case, it will insert a so-called +circle-event which causes a new vertex (i.e. a cross-way between edges) to be created in the middle of the circle. + +The algorithm keeps track of the status (everything above the line is handled) in a so-called status-structure. This +status-structure is a balanced binary search tree that keeps track of the positions of the arcs (in its leaf nodes) and +the breakpoints (in its internal nodes). This data structure allows for fast look-up times, so that the entire +algorithm can run in `O(n log n)` time. + +This implementation includes some additional features to the standard algorithm. For example, this implementation is +able to clip the diagram to a bounding box in different shapes. And it will clean up zero-length edges that occur in +edge-cases where two events happen at the same time so that it is more practical to use. + + +.. image:: ../voronoi.gif + :width: 800 + :alt: Voronoi diagram under construction + +Table of contents ++++++++++++++++++ + +.. toctree:: + :maxdepth: 2 + :glob: + + installation + api + private + + +Indices and tables +++++++++++++++++++ + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..ffe2b01 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,12 @@ +Installation +=================== + +Manual +++++++ +First, clone the repository and then install the package. + +.. code-block:: bash + + git clone https://github.com/Yatoom/voronoi.git + cd voronoi + python setup.py install diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..2119f51 --- /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% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/private.rst b/docs/private.rst new file mode 100644 index 0000000..156803c --- /dev/null +++ b/docs/private.rst @@ -0,0 +1,9 @@ +============== +Private classes +============== + +.. toctree:: + :maxdepth: 2 + :glob: + + private/* diff --git a/docs/private/arc.rst b/docs/private/arc.rst new file mode 100644 index 0000000..ed501cb --- /dev/null +++ b/docs/private/arc.rst @@ -0,0 +1,6 @@ +.. _arc: + +Arc +========= +.. autoclass:: voronoi.nodes.Arc + :members: \ No newline at end of file diff --git a/docs/public/algorithm.rst b/docs/public/algorithm.rst new file mode 100644 index 0000000..42dd321 --- /dev/null +++ b/docs/public/algorithm.rst @@ -0,0 +1,7 @@ +.. _algorithm: + +Algorithm +========= +.. autoclass:: voronoi.algorithm.Algorithm + :members: + diff --git a/docs/public/coordinate.rst b/docs/public/coordinate.rst new file mode 100644 index 0000000..9b584f4 --- /dev/null +++ b/docs/public/coordinate.rst @@ -0,0 +1,7 @@ +.. _coordinate: + +Coordinate +========== +.. autoclass:: voronoi.graph.Coordinate + :members: + diff --git a/docs/public/half_edge.rst b/docs/public/half_edge.rst new file mode 100644 index 0000000..c30182d --- /dev/null +++ b/docs/public/half_edge.rst @@ -0,0 +1,6 @@ +.. _halfedge: + +HalfEdge +========= +.. autoclass:: voronoi.graph.HalfEdge + :members: \ No newline at end of file diff --git a/docs/public/point.rst b/docs/public/point.rst new file mode 100644 index 0000000..08fe021 --- /dev/null +++ b/docs/public/point.rst @@ -0,0 +1,6 @@ +.. _point: + +Point +========= +.. autoclass:: voronoi.graph.Point + :members: \ No newline at end of file diff --git a/docs/public/polygon.rst b/docs/public/polygon.rst new file mode 100644 index 0000000..821f6dd --- /dev/null +++ b/docs/public/polygon.rst @@ -0,0 +1,6 @@ +.. _polygon: + +Polygon +======= +.. autoclass:: voronoi.graph.Polygon + :members: \ No newline at end of file diff --git a/docs/public/vertex.rst b/docs/public/vertex.rst new file mode 100644 index 0000000..f3c6534 --- /dev/null +++ b/docs/public/vertex.rst @@ -0,0 +1,6 @@ +.. _vertex: + +Vertex +========= +.. autoclass:: voronoi.graph.Vertex + :members: \ No newline at end of file diff --git a/docs/public/visualization.rst b/docs/public/visualization.rst new file mode 100644 index 0000000..77b3a42 --- /dev/null +++ b/docs/public/visualization.rst @@ -0,0 +1,6 @@ +.. _visualization: + +Visualizer +========== +.. automodule:: voronoi.visualization.visualizer + :members: \ No newline at end of file diff --git a/docs/public/voronoi.rst b/docs/public/voronoi.rst new file mode 100644 index 0000000..82c2871 --- /dev/null +++ b/docs/public/voronoi.rst @@ -0,0 +1,6 @@ +.. _vertex: + +Voronoi +========= +.. autoclass:: voronoi.Voronoi + :members: \ No newline at end of file diff --git a/examples/bounding_polygon.py b/examples/bounding_polygon.py index c276fd6..db4f354 100644 --- a/examples/bounding_polygon.py +++ b/examples/bounding_polygon.py @@ -67,5 +67,5 @@ # Visualize the tree TreeVisualizer() \ - .plot(v.beach_line) \ + .plot(v.status_tree) \ .render("output/tree.dot", view=True) diff --git a/examples/observers.py b/examples/observers.py index f9c0b75..932caf8 100644 --- a/examples/observers.py +++ b/examples/observers.py @@ -31,7 +31,8 @@ # Callback that saves the figure every step # If no callback is provided, it will simply display the figure in a matplotlib window - callback=lambda observer, figure: figure.savefig(f"output/voronoi/{observer.n_messages:02d}.png") + callback=lambda observer, figure: figure.savefig(f"output/voronoi/{observer.n_messages:02d}.png"), + visualize_before_clipping=True ) ) diff --git a/examples/profiling_performance.py b/examples/profiling_performance.py new file mode 100644 index 0000000..78b9b0e --- /dev/null +++ b/examples/profiling_performance.py @@ -0,0 +1,41 @@ +from voronoi import Voronoi, Polygon +import cProfile +import pstats + + +def profiler(command, filename="profile.stats", n_stats=20): + """Profiler for a python program + + Runs cProfile and outputs ordered statistics that describe + how often and for how long various parts of the program are executed. + + Parameters + ---------- + command: str + Command string to be executed. + filename: str + Name under which to store the stats. + n_stats: int or None + Number of top stats to show. + """ + + cProfile.run(command, filename) + stats = pstats.Stats(filename).strip_dirs().sort_stats("cumtime") + return stats.print_stats(n_stats or {}) + + +# Define some points (a.k.a sites or cell points) +points = [ + (2.5, 2.5), (4, 7.5), (7.5, 2.5), (6, 7.5), (4, 4), (3, 3), (6, 3) +] + +# Define a bounding box / polygon +polygon = Polygon([ + (2.5, 10), (5, 10), (10, 5), (10, 2.5), (5, 0), (2.5, 0), (0, 2.5), (0, 5) +]) + +# Initialize the algorithm +v = Voronoi(polygon) + +# Profile the construction of the voronoi diagram +profiler('v.create_diagram(points=points)') diff --git a/voronoi/algorithm.py b/voronoi/algorithm.py index 20b568a..294ac65 100644 --- a/voronoi/algorithm.py +++ b/voronoi/algorithm.py @@ -1,4 +1,5 @@ from queue import PriorityQueue +from typing import List from voronoi.observers.message import Message from voronoi.observers.subject import Subject @@ -13,12 +14,43 @@ from voronoi.nodes.internal_node import InternalNode from voronoi.events.circle_event import CircleEvent from voronoi.events.site_event import SiteEvent -from voronoi.tree.smart_node import SmartNode -from voronoi.tree.smart_tree import SmartTree +from voronoi.tree.node import Node +from voronoi.tree.tree import Tree class Algorithm(Subject): def __init__(self, bounding_poly: Polygon = None, remove_zero_length_edges=True): + """ + A Python implementation of Fortune's algorithm based on the description of "Computational Geometry: + Algorithms and Applications" by de Berg et al. + + Parameters + ---------- + bounding_poly: Polygon + The bounding box or bounding polygon around the voronoi diagram + remove_zero_length_edges: bool + Removes zero length edges and combines vertices with the same location into one + + Attributes + ---------- + bounding_poly: Polygon + The bounding box (or polygon) around the edge + event_queue: PriorityQueue + Event queue for upcoming site and circle events + status_tree: Node + The status structure is a data structure that stores the relevant situation at the current position of + the sweep line. This attribute points to the root of the balanced binary search tree that functions as a + status structure which represents the beach line as a balanced binary search tree. + sweep_line: Decimal + The y-coordinate + arcs: list(:class:`voronoi.nodes.Arc`) + List of arcs + sites: list(:class:`voronoi.graph.Point`) + List of points + vertices: list(:class:`voronoi.graph.Vertex`) + List of vertices + + """ super().__init__() # The bounding box around the edge @@ -30,7 +62,7 @@ def __init__(self, bounding_poly: Polygon = None, remove_zero_length_edges=True) self.event = None # Root of beach line - self.beach_line: SmartNode = None + self.status_tree: Node = None # Doubly connected edge list self.doubly_connected_edge_list = [] @@ -39,21 +71,42 @@ def __init__(self, bounding_poly: Polygon = None, remove_zero_length_edges=True) self.sweep_line = float("inf") # Store arcs for visualization - self.arcs = [] + self._arcs = set() # Store points for visualization self.sites = None # Half edges for visualization - self.edges = [] + self.edges = list() # List of vertices - self.vertices = [] + self._vertices = set() # Whether to remove zero length edges self.remove_zero_length_edges = remove_zero_length_edges + @property + def arcs(self) -> List[Arc]: + return list(self._arcs) + + @property + def vertices(self) -> List[Vertex]: + return list(self._vertices) + def initialize(self, points): + """ + Initialize the event queue `event_queue` with all site events. + + Parameters + ---------- + points: list(Point) + The list of cell points to initialize + + Returns + ------- + event_queue: PriorityQueue + Event queue for upcoming site and circle events + """ # Store the points for visualization self.sites = points @@ -70,7 +123,29 @@ def create_diagram(self, points: list): """ Create the Voronoi diagram. - :param points: (list) The list of cell points to make the diagram for + The overall structure of the algorithm is as follows. + + 1. Initialize the event queue `event_queue` with all site events, initialize an empty status structure + `status_tree` and an empty doubly-connected edge list `D`. + 2. **while** `event_queue` is not empty. + 3.  **do** Remove the event with largest `y`-coordinate from `event_queue`. + 4.   **if** the event is a site event, occurring at site `point` + 5.    **then** :func:`~handle_site_event` + 6.    **else** :func:`handle_circle_event` + 7. The internal nodes still present in `status_tree` correspond to the half-infinite edges of the Voronoi + diagram. Compute a bounding box (or polygon) that contains all vertices of bounding box by updating the + doubly-connected edge list appropriately. + 8. **If** `remove_zero_length_edges` is true. + 9.  Call :func:`~clean_up_zero_length_edges` which removes zero length edges and combines vertices with the same location into one. + + Parameters + ---------- + points: list(Point) + A set of point sites in the plane. + + Returns + ------- + Output. The Voronoi diagram `Vor(P)` given inside a bounding box in a doublyconnected edge list `D`. """ points = [Point(x, y) for x, y in points] @@ -133,10 +208,11 @@ def create_diagram(self, points: list): self.notify_observers(Message.SWEEP_FINISHED) # Finish with the bounding box - self.edges, polygon_vertices = self.bounding_poly.finish_edges( - edges=self.edges, vertices=self.vertices, points=self.sites, event_queue=self.event_queue + self.edges = self.bounding_poly.finish_edges( + edges=self.edges, vertices=self._vertices, points=self.sites, event_queue=self.event_queue ) - self.edges, self.vertices = self.bounding_poly.finish_polygon(self.edges, self.vertices, self.sites) + + self.edges, self._vertices = self.bounding_poly.finish_polygon(self.edges, self._vertices, self.sites) if self.remove_zero_length_edges: self.clean_up_zero_length_edges() @@ -146,26 +222,53 @@ def create_diagram(self, points: list): self.notify_observers(Message.VORONOI_FINISHED) def handle_site_event(self, event: SiteEvent): + """ + Handle a site event. + + 1. Let :obj:`point_i = event.point`. If :attr:`status_tree` is empty, insert :obj:`point_i` into it (so that + :attr:`status_tree` consists of a single leaf storing :obj:`point_i`) and return. Otherwise, continue with + steps 2– 5. + 2. Search in :attr:`status_tree` for the arc :obj:`α` vertically above :obj:`point_i`. If the leaf + representing :obj:`α` has a pointer to a circle event in :attr:`event_queue`, then this circle event is a + false alarm and it must be deleted from :attr:`status_tree`. + 3. Replace the leaf of :attr:`status_tree` that represents :obj:`α` with a subtree having three leaves. + The middle leaf stores the new site :obj:`point_i` and the other two leaves store the site + :obj:`point_j` that was originally stored with :obj:`α`. Store the breakpoints + (:obj:`point_j`, :obj:`point_i`) and (:obj:`point_i`, :obj:`point_j`) representing the new breakpoints at the + two new internal nodes. Perform rebalancing operations on :attr:`status_tree` if necessary. + 4. Create new half-edge records in the Voronoi diagram structure for the + edge separating the faces for :obj:`point_i` and :obj:`point_j`, which will be traced out by the two new + breakpoints. + 5. Check the triple of consecutive arcs where the new arc for pi is the left arc + to see if the breakpoints converge. If so, insert the circle event into :attr:`status_tree` and + add pointers between the node in :attr:`status_tree` and the node in :attr:`event_queue`. Do the same for the + triple where the new arc is the right arc. + + Parameters + ---------- + event: SiteEvent + The site event to handle. + """ # Create a new arc - new_point = event.point - new_arc = Arc(origin=new_point) - self.arcs.append(new_arc) + point_i = event.point + new_arc = Arc(origin=point_i) + self._arcs.add(new_arc) # 1. If the beach line tree is empty, we insert point - if self.beach_line is None: - self.beach_line = LeafNode(new_arc) + if self.status_tree is None: + self.status_tree = LeafNode(new_arc) return # 2. Search the beach line tree for the arc above the point - arc_node_above_point = SmartTree.find_leaf_node(self.beach_line, key=new_point.xd, sweep_line=self.sweep_line) + arc_node_above_point = Tree.find_leaf_node(self.status_tree, key=point_i.xd, sweep_line=self.sweep_line) arc_above_point = arc_node_above_point.get_value() - # 3. Remove potential false alarm + # Remove potential false alarm if arc_above_point.circle_event is not None: arc_above_point.circle_event.remove() - # 4. Replace leaf with new sub tree that represents the two new intersections on the arc above the point + # 3. Replace leaf with new sub tree that represents the two new intersections on the arc above the point # # (p_j, p_i) # / \ @@ -174,7 +277,6 @@ def handle_site_event(self, event: SiteEvent): # / \ # / \ # p_i p_j - point_i = new_point point_j = arc_above_point.origin breakpoint_left = Breakpoint(breakpoint=(point_j, point_i)) breakpoint_right = Breakpoint(breakpoint=(point_i, point_j)) @@ -190,9 +292,9 @@ def handle_site_event(self, event: SiteEvent): else: root.right = LeafNode(new_arc) - self.beach_line = arc_node_above_point.replace_leaf(replacement=root, root=self.beach_line) + self.status_tree = arc_node_above_point.replace_leaf(replacement=root, root=self.status_tree) - # 5. Create half edge records + # 4. Create half edge records A, B = point_j, point_i AB = breakpoint_left BA = breakpoint_right @@ -210,7 +312,7 @@ def handle_site_event(self, event: SiteEvent): B.first_edge = B.first_edge or AB.edge A.first_edge = A.first_edge or BA.edge - # 6. Check if breakpoints are going to converge with the arcs to the left and to the right + # 5. Check if breakpoints are going to converge with the arcs to the left and to the right # # (p_j, p_i) # \ / \ @@ -228,24 +330,52 @@ def handle_site_event(self, event: SiteEvent): node_a, node_b, node_c = root.left.predecessor, root.left, root.right.left node_c, node_d, node_e = node_c, root.right.right, root.right.right.successor - self.check_circles((node_a, node_b, node_c), (node_c, node_d, node_e)) + self._check_circles((node_a, node_b, node_c), (node_c, node_d, node_e)) - # 7. Rebalance the tree - self.beach_line = SmartTree.balance_and_propagate(root) + # X. Rebalance the tree + self.status_tree = Tree.balance_and_propagate(root) def handle_circle_event(self, event: CircleEvent): + """ + Handle a circle event. + + 1. Delete the leaf :obj:`γ` that represents the disappearing arc :obj:`α` from :attr:`status_tree`. Update + the tuples representing the breakpoints at the internal nodes. Perform + rebalancing operations on :attr:`status_tree` if necessary. Delete all circle events involving + :obj:`α` from :attr:`event_queue`; these can be found using the pointers from the predecessor and + the successor of :obj:`γ` in :attr:`status_tree`. (The circle event where :obj:`α` is the middle arc is + currently being handled, and has already been deleted from :attr:`event_queue`.) + 2. Add the center of the circle causing the event as a vertex record to the + doubly-connected edge list :obj:`D` storing the Voronoi diagram under construction. Create two half-edge + records corresponding to the new breakpoint + of the beach line. Set the pointers between them appropriately. Attach the + three new records to the half-edge records that end at the vertex. + 3. Check the new triple of consecutive arcs that has the former left neighbor + of :obj:`α` as its middle arc to see if the two breakpoints of the triple converge. + If so, insert the corresponding circle event into :attr:`event_queue`. and set pointers between + the new circle event in :attr:`event_queue` and the corresponding leaf of :attr:`status_tree`. Do the same + for the triple where the former right neighbor is the middle arc. + + Parameters + ---------- + event + + Returns + ------- + + """ # 1. Delete the leaf γ that represents the disappearing arc α from T. arc = event.arc_pointer.data - if arc in self.arcs: - self.arcs.remove(arc) + if arc in self._arcs: + self._arcs.remove(arc) arc_node: LeafNode = event.arc_pointer predecessor = arc_node.predecessor successor = arc_node.successor # Update breakpoints - self.beach_line, updated, removed, left, right = self.update_breakpoints( - self.beach_line, self.sweep_line, arc_node, predecessor, successor) + self.status_tree, updated, removed, left, right = self._update_breakpoints( + self.status_tree, self.sweep_line, arc_node, predecessor, successor) if updated is None: # raise Exception("Oh.") @@ -271,7 +401,7 @@ def remove(neighbor_event): # if self.bounding_poly.inside(event.center): # Create a vertex v = Vertex(convergence_point.xd, convergence_point.yd) - self.vertices.append(v) + self._vertices.add(v) # Connect the two old edges to the vertex updated.edge.origin = v @@ -308,9 +438,9 @@ def remove(neighbor_event): node_a, node_b, node_c = former_left.predecessor, former_left, former_left.successor node_d, node_e, node_f = former_right.predecessor, former_right, former_right.successor - self.check_circles((node_a, node_b, node_c), (node_d, node_e, node_f)) + self._check_circles((node_a, node_b, node_c), (node_d, node_e, node_f)) - def check_circles(self, triple_left, triple_right): + def _check_circles(self, triple_left, triple_right): node_a, node_b, node_c = triple_left node_d, node_e, node_f = triple_right @@ -348,7 +478,7 @@ def check_circles(self, triple_left, triple_right): return left_event, right_event @staticmethod - def update_breakpoints(root, sweep_line, arc_node, predecessor, successor): + def _update_breakpoints(root, sweep_line, arc_node, predecessor, successor): # If the arc node is a left child, then its parent is the node with right_breakpoint if arc_node.is_left_child(): @@ -361,13 +491,13 @@ def update_breakpoints(root, sweep_line, arc_node, predecessor, successor): right = removed # Rebalance the tree - root = SmartTree.balance_and_propagate(root) + root = Tree.balance_and_propagate(root) # Find the left breakpoint left_breakpoint = Breakpoint(breakpoint=(predecessor.get_value().origin, arc_node.get_value().origin)) query = InternalNode(left_breakpoint) compare = lambda x, y: hasattr(x, "breakpoint") and x.breakpoint == y.breakpoint - breakpoint: InternalNode = SmartTree.find_value(root, query, compare, sweep_line=sweep_line) + breakpoint: InternalNode = Tree.find_value(root, query, compare, sweep_line=sweep_line) # Update the breakpoint # assert(breakpoint is not None) @@ -389,13 +519,13 @@ def update_breakpoints(root, sweep_line, arc_node, predecessor, successor): left = removed # Rebalance the tree - root = SmartTree.balance_and_propagate(root) + root = Tree.balance_and_propagate(root) # Find the right breakpoint right_breakpoint = Breakpoint(breakpoint=(arc_node.get_value().origin, successor.get_value().origin)) query = InternalNode(right_breakpoint) compare = lambda x, y: hasattr(x, "breakpoint") and x.breakpoint == y.breakpoint - breakpoint: InternalNode = SmartTree.find_value(root, query, compare, sweep_line=sweep_line) + breakpoint: InternalNode = Tree.find_value(root, query, compare, sweep_line=sweep_line) # Update the breakpoint # assert(breakpoint is not None) @@ -431,7 +561,7 @@ def clean_up_zero_length_edges(self): v2.connected_edges.append(connected) # Remove vertex v1 - self.vertices.remove(v1) + self._vertices.remove(v1) # Delete the edge edge.delete() diff --git a/voronoi/contrib/bounding_circle.py b/voronoi/contrib/bounding_circle.py index 6f2af16..ef00af5 100644 --- a/voronoi/contrib/bounding_circle.py +++ b/voronoi/contrib/bounding_circle.py @@ -88,7 +88,7 @@ def finish_edges(self, edges, vertices=None, points=None, event_queue=None): .show() # Re-order polygon vertices - self.polygon_vertices = self.get_ordered_vertices(self.polygon_vertices) + self.polygon_vertices = self._get_ordered_vertices(self.polygon_vertices) if DEBUG: Visualizer(self.voronoi, 1) \ diff --git a/voronoi/graph/coordinate.py b/voronoi/graph/coordinate.py index 017f856..9fd7a45 100644 --- a/voronoi/graph/coordinate.py +++ b/voronoi/graph/coordinate.py @@ -4,12 +4,17 @@ class Coordinate: def __init__(self, x=None, y=None): """ - A point in 2D space. - :param x: (float) The x-coordinate - :param y: (float) The y-coordinate + A point in 2D space + + Parameters + ---------- + x: float + The x-coordinate + y: float + The y-coordinate """ - self._xd: Decimal = Coordinate.to_dec(x) - self._yd: Decimal = Coordinate.to_dec(y) + self._xd: Decimal = Coordinate._to_dec(x) + self._yd: Decimal = Coordinate._to_dec(y) def __sub__(self, other): return Coordinate(x=self.xd - other.xd, y=self.yd - other.yd) @@ -18,41 +23,97 @@ def __repr__(self): return f"Coord({self.xd:.2f}, {self.yd:.2f})" @staticmethod - def to_dec(value): + def _to_dec(value): return Decimal(str(value)) if value is not None else None @property def x(self): + """ + Get the x-coordinate as float + + Returns + ------- + x: float + The x-coordinate + """ return float(self._xd) @property def y(self): + """ + Get the y-coordinate as float + + Returns + ------- + y: float + The y-coordinate + """ return float(self._yd) @x.setter def x(self, value): - self._xd = Coordinate.to_dec(value) + """ + Stores the x-coordinate as Decimal + + Parameters + ---------- + value: float + The x-coordinate as float + """ + self._xd = Coordinate._to_dec(value) @y.setter def y(self, value): - self._yd = Coordinate.to_dec(value) + """ + Stores the y-coordinate as Decimal + + Parameters + ---------- + value: float + The y-coordinate as float + """ + self._yd = Coordinate._to_dec(value) @property def xy(self): + """ + Get a (x, y) tuple + + Parameters + ---------- + xy: (float, float) + A tuple of the (x, y)-coordinate + """ return self.x, self.y @property def xd(self): + """ + Get the x-coordinate as Decimal + + Returns + ------- + x: Decimal + The x-coordinate + """ return self._xd @xd.setter def xd(self, value: float): - self._xd = Coordinate.to_dec(value) + self._xd = Coordinate._to_dec(value) @property def yd(self): + """ + Get the y-coordinate as Decimal + + Returns + ------- + y: Decimal + The y-coordinate + """ return self._yd @yd.setter def yd(self, value: float): - self._yd = Coordinate.to_dec(value) + self._yd = Coordinate._to_dec(value) diff --git a/voronoi/graph/half_edge.py b/voronoi/graph/half_edge.py index ec3a6e0..873dd0e 100644 --- a/voronoi/graph/half_edge.py +++ b/voronoi/graph/half_edge.py @@ -4,6 +4,58 @@ class HalfEdge: def __init__(self, incident_point, twin=None, origin=None): + """ + Edges are normally treated as undirected and shared between faces. However, for some tasks (such as simplifying + or cleaning geometry) it is useful to view faces as each having their own edges. + You can think of this as splitting each shared undirected edge along its length into two half edges. + (Boundary edges of course will only have one "half-edge".) + Each half-edge is directed (it has a start vertex and an end vertex). + + The half-edge properties let you quickly find a half-edge’s source and destination vertex, the next half-edge, + get the other half-edge from the same edge, find all half-edges sharing a given point, and other manipulations. + + Examples + -------- + Get the half-edge's source + + >>> edge.origin + + Get the half-edge's destination + + >>> edge.target # or edge.twin.origin + + Get the previous and next half-edge + + >>> edge.prev + >>> edge.next + + Get the other half-edge from the same edge + + >>> edge.twin + + Find all half-edges sharing a given point + + >>> edge.origin.connected_edges + + Parameters + ---------- + incident_point: Point + The cell point of which this edge is the border + twin: HalfEdge + The other half-edge from the same edge + origin: Breakpoint or Vertex + The origin of the half edge. Can be a Breakpoint or a Vertex during construction, and only Vertex when + the diagram is finished. + + Attributes + ---------- + origin: :class:`Breakpoint` or :class:`Vertex` + Pointer to the origin. Can be breakpoint or vertex. + next: :class:`HalfEdge` + Pointer to the next edge + prev: :class:`HalfEdge` + Pointer to the previous edge + """ # Pointer to the origin. Can be breakpoint or vertex. self.origin = origin @@ -25,18 +77,35 @@ def __repr__(self): return f"{self.incident_point}/{self.twin.incident_point or '-'}" def set_next(self, next): + """ + Update the `next`-property for this edge and set the `prev`-property on the `next`-edge to the current edge. + + Parameters + ---------- + next: HalfEdge + The next edge + """ if next: - next.prev_edge = self + next.prev = self self.next = next def get_origin(self, y=None, max_y=None): """ - Get the point of origin. - - :param y: Sweep line (only used when the Voronoi diagram is under construction and we need to calculate - where it currently is) - :param max_y: Bounding box top for clipping infinite breakpoints - :return: The point of origin, or None + Get the coordinates of the edge's origin. + During construction of the Voronoi diagram, the origin can be a vertex, which has a fixed location, or a + breakpoint, which is a breakpoint between two moving arcs. In the latter case, we need to calculate the + position based on the `y`-coordinate of the sweep line. + + Parameters + ---------- + y: Decimal + The y-coordinate of the sweep line. + max_y: + Bounding box top for clipping infinitely highly positioned breakpoints. + + Returns + ------- + origin: Coordinate """ if isinstance(self.origin, Vertex): if self.origin.xd is None or self.origin.yd is None: @@ -50,11 +119,17 @@ def get_origin(self, y=None, max_y=None): @property def twin(self): + """ + Get the other half-edge from the same edge + + Returns + ------- + twin: HalfEdge + """ return self._twin @twin.setter def twin(self, twin): - if twin is not None: twin._twin = self @@ -62,6 +137,13 @@ def twin(self, twin): @property def target(self): + """ + The twin's origin. + + Returns + ------- + vertex: Vertex + """ if self.twin is None: return None return self.twin.origin diff --git a/voronoi/graph/point.py b/voronoi/graph/point.py index 61ace4f..477fadd 100644 --- a/voronoi/graph/point.py +++ b/voronoi/graph/point.py @@ -6,21 +6,43 @@ class Point(Coordinate): - def __init__(self, x=None, y=None, metadata=None, name=None, first_edge=None): + def __init__(self, x=None, y=None, name=None, first_edge=None): """ - A point in 2D space. - :param x: (float) The x-coordinate - :param y: (float) The y-coordinate - :param metadata: (dict) Optional metadata stored in a dictionary - :param name: (str) A one-letter string (assigned automatically by algorithm) - :param first_edge: (HalfEdge) Pointer to the first edge (assigned automatically by the algorithm) + A cell point a.k.a. a site. Extends the :class:`Coordinate` class. + + Examples + -------- + Site operations + + >>> size: float = site.area() # The area of the cell + >>> borders: List[HalfEdge] = site.borders() # Borders around this cell point + >>> vertices: List[Vertex] = site.vertices() # Vertices around this cell point + >>> site_x: float = site.x # X-coordinate of the site + >>> site_xy: [float, float] = site.xy # (x, y)-coordinates of the site + >>> first_edge: HalfEdge = site.first_edge # First edge of the site's border + + Parameters + ---------- + x: Decimal + The x-coordinate of the point + y: Decimal + They y-coordinate of the point + metadata: dict + Optional metadata stored in a dictionary + name: str + A name to easily identify this point + first_edge: HalfEdge + Pointer to the first edge + + Attributes + ---------- + name: str + A name to easily identify this point + first_edge: HalfEdge + Pointer to the first edge """ super().__init__(x, y) - if metadata is None: - metadata = {} - - self.metadata = metadata self.name = name self.first_edge = first_edge @@ -31,9 +53,18 @@ def __repr__(self): def area(self, digits=None): """ - Calculate cell size if the point is a site. - :param digits: (int) number of digits to round to - :return: (float) the area of the cell + Calculate the cell size of the cell that this point is the cell point of. + Under the hood, the shoelace algorithm is used. + + Parameters + ---------- + digits: int + The number of digits to round to + + Returns + ------- + area: float + The area of the cell """ x, y = self._get_xy() @@ -43,18 +74,36 @@ def area(self, digits=None): return float(self._shoelace(x, y)) def borders(self): + """ + Get a list of all the borders that surround this cell point. + + Returns + ------- + edges: list(HalfEdge) or None + The list of borders, or None if not all borders are present (when the voronoi diagram is under construction) + """ + if self.first_edge is None: - return None + return [] edge = self.first_edge edges = [edge] while edge.next != self.first_edge: if edge.next is None: - return None + return edges edge = edge.next edges.append(edge) return edges def vertices(self): + """ + Get a list of all the vertices that surround this cell point. + + Returns + ------- + vertices: list(Vertex) or None + The list of vertices, or None if not all borders are present (when the voronoi diagram is under + construction) + """ borders = self.borders() if borders is None: return None diff --git a/voronoi/graph/polygon.py b/voronoi/graph/polygon.py index 1d0c150..ba8da32 100644 --- a/voronoi/graph/polygon.py +++ b/voronoi/graph/polygon.py @@ -8,6 +8,15 @@ class Polygon(Subject): def __init__(self, tuples): + """ + A bounding polygon that will clip the edges and fit around the Voronoi diagram. + + Parameters + ---------- + tuples: (float, float) + x,y-coordinates of the polygon's vertices + """ + super().__init__() points = [Coordinate(x, y) for x, y in tuples] self.points = points @@ -18,31 +27,50 @@ def __init__(self, tuples): center = Coordinate((max_x + min_x) / 2, (max_y + min_y) / 2) self.min_y, self.min_x, self.max_y, self.max_x, self.center = min_y, min_x, max_y, max_x, center - self.points = self.order_points(self.points) + self.points = self._order_points(self.points) self.polygon_vertices = [] for point in self.points: self.polygon_vertices.append(Vertex(point.xd, point.yd)) - def order_points(self, points): + def _order_points(self, points): clockwise = sorted(points, key=lambda point: (-180 - Algebra.calculate_angle(point, self.center)) % 360) return clockwise - def get_ordered_vertices(self, vertices): + def _get_ordered_vertices(self, vertices): vertices = [vertex for vertex in vertices if vertex.xd is not None] clockwise = sorted(vertices, key=lambda vertex: (-180 - Algebra.calculate_angle(vertex, self.center)) % 360) return clockwise @staticmethod - def get_closest_point(position, points): + def _get_closest_point(position, points): distances = [Algebra.distance(position, p) for p in points] index = np.argmin(distances) return points[index] def finish_polygon(self, edges, existing_vertices, points): - vertices = self.get_ordered_vertices(self.polygon_vertices) - vertices = vertices + [vertices[0]] # <- The extra vertex added here, should be removed later - cell = self.get_closest_point(vertices[0], points) + """ + Creates half-edges on the bounding polygon that link with Voronoi diagram's half-edges and existing vertices. + + Parameters + ---------- + edges: list(HalfEdge) + The list of clipped edges from the Voronoi diagram + existing_vertices: set(Vertex) + The list of vertices that already exists in the clipped Voronoi diagram, and vertices + points: set(Point) + The list of cell points + + Returns + ------- + edges: list(HalfEdge) + The list of all edges including the bounding polygon's edges + vertices: list(Vertex) + The list of all vertices including the + """ + vertices = self._get_ordered_vertices(self.polygon_vertices) + vertices = list(vertices) + [vertices[0]] # <- The extra vertex added here, should be removed later + cell = self._get_closest_point(vertices[0], points) previous_edge = None for index in range(0, len(vertices) - 1): @@ -87,14 +115,28 @@ def get_coordinates(self): return [(i.xd, i.yd) for i in self.points] def finish_edges(self, edges, **kwargs): - resulting_edges = [] + """ + Clip the edges to the bounding box/polygon, and remove edges and vertices that are fully outside. + Inserts vertices at the clipped edges' endings. + + Parameters + ---------- + edges: list(HalfEdge) + A list of edges in the Voronoi diagram. Every edge should be presented only by one half edge. + + Returns + ------- + clipped_edges: list(HalfEdge) + A list of clipped edges + """ + resulting_edges = list() for edge in edges: if edge.get_origin() is None or not self.inside(edge.get_origin()): - self.finish_edge(edge) + self._finish_edge(edge) if edge.twin.get_origin() is None or not self.inside(edge.twin.get_origin()): - self.finish_edge(edge.twin) + self._finish_edge(edge.twin) if edge.get_origin() is not None and edge.twin.get_origin() is not None: resulting_edges.append(edge) @@ -103,12 +145,9 @@ def finish_edges(self, edges, **kwargs): edge.twin.delete() self.notify_observers(Message.DEBUG, payload=f"Edges {edge} and {edge.twin} deleted!") - # Re-order polygon vertices - self.polygon_vertices = self.get_ordered_vertices(self.polygon_vertices) + return resulting_edges - return resulting_edges, self.polygon_vertices - - def finish_edge(self, edge): + def _finish_edge(self, edge): # Sweep line position sweep_line = self.min_y - abs(self.max_y) @@ -119,7 +158,7 @@ def finish_edge(self, edge): end = edge.twin.get_origin(y=sweep_line, max_y=self.max_y) # Get point of intersection - point = self.get_intersection_point(end, start) + point = self._get_intersection_point(end, start) # Create vertex v = Vertex(point.x, point.y) if point is not None else Vertex(None, None) @@ -129,7 +168,7 @@ def finish_edge(self, edge): return edge - def on_edge(self, point): + def _on_edge(self, point): vertices = self.points + self.points[0:1] for i in range(0, len(vertices) - 1): dxc = point.xd - vertices[i].xd @@ -144,12 +183,19 @@ def on_edge(self, point): return False def inside(self, point): - # if self.on_edge(point): - # return False + """Tests whether a point is inside a polygon. + Based on the Javascript implementation from https://github.com/substack/point-in-polygon + + Parameters + ---------- + point: Point + The point for which to check if it it is inside the polygon - # Ray-casting algorithm based on - # http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html - # Javascript implementation from https://github.com/substack/point-in-polygon + Returns + ------- + inside: bool + Whether the point is inside or not + """ vertices = self.points + self.points[0:1] @@ -170,7 +216,7 @@ def inside(self, point): return inside - def get_intersection_point(self, orig, end): + def _get_intersection_point(self, orig, end): p = self.points + [self.points[0]] points = [] diff --git a/voronoi/graph/vertex.py b/voronoi/graph/vertex.py index 98368cf..1b789f4 100644 --- a/voronoi/graph/vertex.py +++ b/voronoi/graph/vertex.py @@ -3,6 +3,32 @@ class Vertex(Coordinate): def __init__(self, x, y, connected_edges=None): + """ + A vertex is a fixed cross point between borders. Extends the :class:`Coordinate` class. + + Examples + -------- + Vertex operations + + >>> connected_edges: List[HalfEdge] = vertex.connected_edges # All connected edges + >>> vertex_x: float = vertex.x # x-coordinate + >>> vertex_xy: [float, float] = vertex.xy # (x, y)-coordinates + + Parameters + ---------- + x: Decimal + x-coordinate + y: Decimal + y-coordinate + connected_edges: list(:class:`HalfEdge`) + List of edges connected to this vertex. + + Attributes + ---------- + connected_edges: list(:class:`HalfEdge`) + List of edges connected to this vertex. + """ + super().__init__(x, y) self.connected_edges = connected_edges or [] diff --git a/voronoi/nodes/arc.py b/voronoi/nodes/arc.py index b0c3aa9..7ed70c8 100644 --- a/voronoi/nodes/arc.py +++ b/voronoi/nodes/arc.py @@ -4,17 +4,27 @@ class Arc: - """ - Each leaf of beach line, representing an arc α, stores one pointer to a node in the event queue, namely, the node - that represents the circle event in which α will disappear. This pointer is None if no circle event exists where α - will disappear, or this circle event has not been detected yet. - """ - def __init__(self, origin: Coordinate, circle_event=None): """ - :param origin: The point that caused the arc - :param circle_event: The pointer to the circle event in which the arc will disappear + Each leaf of beach line, representing an arc `α`, stores one pointer to a node in the event queue, namely, the + node that represents the circle event in which `α` will disappear. This pointer is None if no circle event + exists where `α` will disappear, or this circle event has not been detected yet. + + Parameters + ---------- + origin: Point + The point that caused the arc + circle_event: CircleEvent + The pointer to the circle event in which the arc will disappear + + Attributes + ---------- + origin: Point + The point that caused the arc + circle_event: CircleEvent + The pointer to the circle event in which the arc will disappear """ + self.origin = origin self.circle_event = circle_event @@ -23,11 +33,18 @@ def __repr__(self): def get_plot(self, x, sweep_line): """ - Method for plotting the arc. - Will return the y-coordinates for all the x coordinates that are given as input. - :param x: The input x-coordinates - :param sweep_line: The y-coordinate of the sweep line - :return: A list of y-values + Computes all `y`-coordinates for given `x`-coordinates and the sweep line's `y`-coordinate. + + Parameters + ---------- + x: np.array + The input x-coordinates + sweep_line: Decimal, float + The y-coordinate of the sweep line + Returns + ------- + y: number, array-like + A list of y-values """ sweep_line = float(sweep_line) i = self.origin diff --git a/voronoi/nodes/internal_node.py b/voronoi/nodes/internal_node.py index 351edf2..3c51060 100644 --- a/voronoi/nodes/internal_node.py +++ b/voronoi/nodes/internal_node.py @@ -1,7 +1,7 @@ -from voronoi.tree.smart_node import SmartNode +from voronoi.tree.node import Node -class InternalNode(SmartNode): +class InternalNode(Node): def __init__(self, data: "Breakpoint"): super().__init__(data) diff --git a/voronoi/nodes/leaf_node.py b/voronoi/nodes/leaf_node.py index 5441a84..0fd331b 100644 --- a/voronoi/nodes/leaf_node.py +++ b/voronoi/nodes/leaf_node.py @@ -1,8 +1,8 @@ from voronoi.nodes import Arc -from voronoi.tree.smart_node import SmartNode +from voronoi.tree.node import Node -class LeafNode(SmartNode): +class LeafNode(Node): def __init__(self, data: "Arc"): super().__init__(data) diff --git a/voronoi/observers/tree_observer.py b/voronoi/observers/tree_observer.py index e908d76..77e1936 100644 --- a/voronoi/observers/tree_observer.py +++ b/voronoi/observers/tree_observer.py @@ -22,13 +22,13 @@ def update(self, subject: Algorithm, message: Message, **kwargs): (message == Message.VORONOI_FINISHED and self.visualize_result) or \ (message == Message.SWEEP_FINISHED and self.visualize_before_clipping): if self.text_based: - visualized_in_text = subject.beach_line.visualize() + visualized_in_text = subject.status_tree.visualize() if self.callback is not None: self.callback(visualized_in_text) else: print(visualized_in_text) else: - self.visualize(subject.beach_line) + self.visualize(subject.status_tree) self.n_messages += 1 self.messages.append(message) diff --git a/voronoi/tree/__init__.py b/voronoi/tree/__init__.py index 8b36f36..79c774f 100644 --- a/voronoi/tree/__init__.py +++ b/voronoi/tree/__init__.py @@ -1,2 +1,2 @@ -from voronoi.tree.smart_node import SmartNode -from voronoi.tree.smart_tree import SmartTree \ No newline at end of file +from voronoi.tree.node import Node +from voronoi.tree.tree import Tree \ No newline at end of file diff --git a/voronoi/tree/smart_node.py b/voronoi/tree/node.py similarity index 94% rename from voronoi/tree/smart_node.py rename to voronoi/tree/node.py index 5937c54..3238218 100644 --- a/voronoi/tree/smart_node.py +++ b/voronoi/tree/node.py @@ -1,4 +1,4 @@ -class SmartNode: +class Node: def __init__(self, data): """ A smart tree node with some extra functionality over standard nodes. @@ -14,11 +14,11 @@ def __repr__(self): return f"Node({self.data}, left={self.left}, right={self.right})" @property - def left(self) -> "SmartNode": + def left(self) -> "Node": return self._left @property - def right(self) -> "SmartNode": + def right(self) -> "Node": return self._right @property @@ -125,7 +125,7 @@ def is_leaf(self): def minimum(self): """ Determines the node with the smallest key in the subtree rooted by this node. - :return: (SmartNode) Node with the smallest key + :return: (Node) Node with the smallest key """ current = self while current.left is not None: @@ -135,7 +135,7 @@ def minimum(self): def maximum(self): """ Determines the node with the largest key in the subtree rooted by this node. - :return: (SmartNode) Node with the largest key + :return: (Node) Node with the largest key """ current = self while current.right is not None: @@ -193,9 +193,9 @@ def replace_leaf(self, replacement, root): Replace the node by a replacement tree. Requires the current node to be a leaf. - :param replacement: (SmartNode) The root node of the replacement sub tree - :param root: (SmartNode) The root of the tree - :return: (SmartNode) The root of the updated tree + :param replacement: (Node) The root node of the replacement sub tree + :param root: (Node) The root of the tree + :return: (Node) The root of the updated tree """ # Give the parent of the node to the replacement diff --git a/voronoi/tree/smart_tree.py b/voronoi/tree/tree.py similarity index 75% rename from voronoi/tree/smart_tree.py rename to voronoi/tree/tree.py index e2ff8f8..b364a83 100644 --- a/voronoi/tree/smart_tree.py +++ b/voronoi/tree/tree.py @@ -1,14 +1,14 @@ from voronoi.nodes import Arc, Breakpoint -from voronoi.tree.smart_node import SmartNode +from voronoi.tree.node import Node -class SmartTree: +class Tree: """ Self-balancing Binary Search Tree. """ @staticmethod - def find(root: SmartNode, key, **kwargs): + def find(root: Node, key, **kwargs): node = root while node is not None: @@ -23,16 +23,16 @@ def find(root: SmartNode, key, **kwargs): return node @staticmethod - def find_value(root: SmartNode, query: SmartNode, compare=lambda x, y: x == y, **kwargs): + def find_value(root: Node, query: Node, compare=lambda x, y: x == y, **kwargs): """ Find an item using a query node and a comparison function. - :param root: (SmartNode) The root to start searching from + :param root: (Node) The root to start searching from :param query: The query :param compare: (lambda) Lambda expression to compare the node against the query. Will be called as compare(node.data, query.data). :param kwargs: Optional arguments to be passed to the get_key() functions - :return: (SmartNode or None) Returns the node that corresponds to the query or None + :return: (Node or None) Returns the node that corresponds to the query or None """ key = query.get_key(**kwargs) node = root @@ -42,9 +42,9 @@ def find_value(root: SmartNode, query: SmartNode, compare=lambda x, y: x == y, * if compare(node.data, query.data): return node - left = SmartTree.find_value(node.left, query, compare, **kwargs) + left = Tree.find_value(node.left, query, compare, **kwargs) if left is None: - right = SmartTree.find_value(node.right, query, compare, **kwargs) + right = Tree.find_value(node.right, query, compare, **kwargs) return right return left @@ -53,26 +53,26 @@ def find_value(root: SmartNode, query: SmartNode, compare=lambda x, y: x == y, * # Normally, the three should go left and find the correct value there, # but due to rounding errors, it sometimes takes the wrong turn. So if the left # branch doesn't get a result, we try the other branch. - return SmartTree.find_value(node.left, query, compare, **kwargs) or \ - SmartTree.find_value(node.right, query, compare, **kwargs) + return Tree.find_value(node.left, query, compare, **kwargs) or \ + Tree.find_value(node.right, query, compare, **kwargs) else: # Normally, the three should go right and find the correct value there, # but due to rounding errors, it sometimes takes the wrong turn. So if the right # branch doesn't get a result, we try the other branch. - return SmartTree.find_value(node.right, query, compare, **kwargs) or \ - SmartTree.find_value(node.left, query, compare, **kwargs) + return Tree.find_value(node.right, query, compare, **kwargs) or \ + Tree.find_value(node.left, query, compare, **kwargs) @staticmethod - def find_leaf_node(root: SmartNode, key, **kwargs): + def find_leaf_node(root: Node, key, **kwargs): """ Follows a path downward between the internal nodes using the key until it reaches a leaf node. If it is unclear which path to take, the left path is taken. - :param root: (SmartNode) The root of the (sub)tree to travel down + :param root: (Node) The root of the (sub)tree to travel down :param key: The key to use to determine the path :param kwargs: Optional arguments passed to the get_key() functions - :return: (SmartNode) The node found at the end of the journey + :return: (Node) The node found at the end of the journey """ node = root @@ -102,7 +102,7 @@ def find_leaf_node(root: SmartNode, key, **kwargs): return node @staticmethod - def insert(root: SmartNode, node: SmartNode, **kwargs): + def insert(root: Node, node: Node, **kwargs): # Get keys once node_key = node.get_key(**kwargs) if node is not None else None @@ -112,48 +112,48 @@ def insert(root: SmartNode, node: SmartNode, **kwargs): if root is None: return node elif node_key < root_key: - root.left = SmartTree.insert(root.left, node, **kwargs) + root.left = Tree.insert(root.left, node, **kwargs) else: - root.right = SmartTree.insert(root.right, node, **kwargs) + root.right = Tree.insert(root.right, node, **kwargs) # Update the height of the ancestor node root.update_height() # If the node is unbalanced, then try out the 4 cases balance = root.balance - # root = SmartTree.balance(root) + # root = Tree.balance(root) # Case 1 - Left Left if balance > 1 and node_key < root.left.get_key(**kwargs): - return SmartTree.rotate_right(root) + return Tree.rotate_right(root) # Case 2 - Right Right if balance < -1 and node_key > root.right.get_key(**kwargs): - return SmartTree.rotate_left(root) + return Tree.rotate_left(root) # Case 3 - Left Right if balance > 1 and node_key > root.left.get_key(**kwargs): - root.left = SmartTree.rotate_left(root.left) - return SmartTree.rotate_right(root) + root.left = Tree.rotate_left(root.left) + return Tree.rotate_right(root) # Case 4 - Right Left if balance < -1 and node_key < root.right.get_key(**kwargs): - root.right = SmartTree.rotate_right(root.right) - return SmartTree.rotate_left(root) + root.right = Tree.rotate_right(root.right) + return Tree.rotate_left(root) return root @staticmethod - def delete(root: SmartNode, key: int, **kwargs): + def delete(root: Node, key: int, **kwargs): if root is None: return root elif key < root.get_key(): - root.left = SmartTree.delete(root.left, key) + root.left = Tree.delete(root.left, key) elif key > root.get_key(): - root.right = SmartTree.delete(root.right, key) + root.right = Tree.delete(root.right, key) else: if root.left is None: @@ -164,7 +164,7 @@ def delete(root: SmartNode, key: int, **kwargs): temp = root.right.minimum() root.data = temp.data - root.right = SmartTree.delete(root.right, temp.value.get_key(**kwargs)) + root.right = Tree.delete(root.right, temp.value.get_key(**kwargs)) # If the tree has only one node, simply return it if root is None: @@ -174,7 +174,7 @@ def delete(root: SmartNode, key: int, **kwargs): root.update_height() # Balance the tree - root = SmartTree.balance(root) + root = Tree.balance(root) return root @@ -187,12 +187,12 @@ def balance_and_propagate(node): :return: The root of the balanced tree """ - node = SmartTree.balance(node) + node = Tree.balance(node) if node.parent is None: return node - return SmartTree.balance_and_propagate(node.parent) + return Tree.balance_and_propagate(node.parent) @staticmethod def balance(node): @@ -206,21 +206,21 @@ def balance(node): # Case 1 - Left Left if node.balance > 1 and node.left.balance >= 0: - return SmartTree.rotate_right(node) + return Tree.rotate_right(node) # Case 2 - Right Right if node.balance < -1 and node.right.balance <= 0: - return SmartTree.rotate_left(node) + return Tree.rotate_left(node) # Case 3 - Left Right if node.balance > 1 and node.left.balance < 0: - node.left = SmartTree.rotate_left(node.left) - return SmartTree.rotate_right(node) + node.left = Tree.rotate_left(node.left) + return Tree.rotate_right(node) # Case 4 - Right Left if node.balance < -1 and node.right.balance > 0: - node.right = SmartTree.rotate_right(node.right) - return SmartTree.rotate_left(node) + node.right = Tree.rotate_right(node.right) + return Tree.rotate_left(node) return node @@ -307,3 +307,20 @@ def rotate_right(z): # Return the new root return y + + @staticmethod + def get_leaves(root: Node, leaves=None): + if leaves is None: + leaves = [] + + # Base case + if root.is_leaf(): + leaves.append(root) + return leaves + + # Step + if root.left is not None: + leaves += Tree.get_leaves(root.left, None) + if root.right is not None: + leaves += Tree.get_leaves(root.right, None) + return leaves diff --git a/voronoi/visualization/visualizer.py b/voronoi/visualization/visualizer.py index bebfba7..3f2ba48 100644 --- a/voronoi/visualization/visualizer.py +++ b/voronoi/visualization/visualizer.py @@ -41,25 +41,89 @@ class Presets: class Visualizer: + """ + Visualizer + """ def __init__(self, voronoi, canvas_offset=1, figsize=(8, 8)): + """ + A visualizer for your voronoi diagram. + + Examples + -------- + Quickly plot individual components of the graph. + + >>> vis = Visualizer(voronoi, canvas_offset=1) + >>> vis.plot_sites(show_labels=True) + >>> vis.plot_edges(show_labels=False) + >>> vis.plot_vertices() + >>> vis.plot_border_to_site() + >>> vis.show() + + Chaining commands + + >>> Visualizer(voronoi, 1).plot_sites().plot_edges().plot_vertices().show() + + Plot all components that are useful to visualize during construction of the diagram + + >>> from voronoi.visualization import Presets + >>> Visualizer(voronoi, 1).plot_all(**Presets.construction) + + Plot all components that are useful to visualize when the diagram is constructed + + >>> Visualizer(voronoi, 1).plot_all() + + Parameters + ---------- + voronoi: Voronoi + The voronoi object + canvas_offset: Int + The space around the bounding object + figsize: float, float + Width, height in inches + """ self.voronoi = voronoi - self.min_x, self.max_x, self.min_y, self.max_y = self.canvas_size(voronoi.bounding_poly, canvas_offset) + self.min_x, self.max_x, self.min_y, self.max_y = self._canvas_size(voronoi.bounding_poly, canvas_offset) plt.close("all") # Prevents previous created plots from showing up fig, ax = plt.subplots(figsize=figsize) self.canvas = ax - def set_limits(self): + def _set_limits(self): self.canvas.set_ylim(self.min_y, self.max_y) self.canvas.set_xlim(self.min_x, self.max_x) return self def get_canvas(self): - self.set_limits() + """ + Retrieve the figure. + + Returns + ------- + Figure: matplotlib.figure.Figure + """ + self._set_limits() return self.canvas.figure def show(self, block=True, **kwargs): - self.set_limits() + """ + Display all open figures. + + Parameters + ---------- + block : bool, optional + + If `True` block and run the GUI main loop until all windows + are closed. + + If `False` ensure that all windows are displayed and return + immediately. In this case, you are responsible for ensuring + that the event loop is running to have responsive figures. + + Returns + ------- + self: Visualizer + """ + self._set_limits() plt.show(block=block, **kwargs) return self @@ -67,6 +131,52 @@ def plot_all(self, polygon=False, edges=True, vertices=True, sites=True, outgoing_edges=False, border_to_site=False, scale=1, edge_labels=False, site_labels=False, triangles=False, arcs=False, sweep_line=False, events=False, arc_labels=False, beach_line=False): + """ + Convenience method that calls other methods to display parts of the diagram. + + Parameters + ---------- + polygon: bool + Display the polygon outline. + *Only useful during construction.* + edges: bool + Display the borders of the cells. + vertices: bool + Display the intersections of the edges. + sites: bool + Display the cell points (a.k.a. sites) + outgoing_edges: bool + Show arrows of length `scale` in the direction of the outgoing edges for each vertex. + border_to_site: bool + Indicate with dashed line to which site a border belongs. The site's first edge is colored green. + scale: float + Used to set the length of the `outgoing_edges`. + edge_labels: bool + Display edge labels of format "`A/B`", where the edge is `A`'s border and the edge's twin is `B`'s border. + site_labels: bool + Display the labels of the cell points, of format "`P#`", where `#` is the `n`th point from top to bottom. + triangles: bool + Display the triangle of the 3 points responsible for causing a circle event. + *Only useful during construction.* + arcs: bool + Display each arc for each point. Only used if `beach_line` is also `True`. + *Only useful during construction.* + sweep_line: bool + Display the sweep line. + *Only useful during construction.* + events: bool + Display circles for circle events. + *Only useful during construction.* + arc_labels: bool + Display labels on the arcs. + *Only useful during construction.* + beach_line: bool + Display the beach line. + *Only useful during construction.* + Returns + ------- + self: Visualizer + """ self.plot_sweep_line() if sweep_line else False self.plot_polygon() if polygon else False @@ -77,10 +187,18 @@ def plot_all(self, polygon=False, edges=True, vertices=True, sites=True, self.plot_outgoing_edges(scale=scale) if outgoing_edges else False self.plot_event(triangles) if events else False self.plot_arcs(plot_arcs=arcs, show_labels=arc_labels) if beach_line else False - self.set_limits() + self._set_limits() return self def plot_polygon(self): + """ + Display the polygon outline. + *Only useful during construction.* + + Returns + ------- + self: Visualizer + """ if hasattr(self.voronoi.bounding_poly, 'radius'): # Draw bounding box self.canvas.add_patch( @@ -98,6 +216,18 @@ def plot_polygon(self): return self def plot_vertices(self, vertices=None, **kwargs): + """ + Display the intersections of the edges. + + Parameters + ---------- + vertices: list(:class:`voronoi.graph.Vertex`), optional + The vertices to display. By default, the `voronoi`'s vertices will be used. + + Returns + ------- + self: Visualizer + """ vertices = vertices or self.voronoi.vertices xs = [vertex.xd for vertex in vertices] @@ -109,6 +239,21 @@ def plot_vertices(self, vertices=None, **kwargs): return self def plot_outgoing_edges(self, vertices=None, scale=0.5, **kwargs): + """ + Show arrows of length `scale` in the direction of the outgoing edges for each vertex. + + Parameters + ---------- + vertices: list(:class:`voronoi.graph.Vertex`), optional + The vertices for which to display the outgoing edges. By default, the `voronoi`'s vertices will be used. + scale: float + Used to set the length of the `outgoing_edges`. + kwargs + Optional arguments that are passed to arrowprops + Returns + ------- + self: Visualizer + """ vertices = vertices or self.voronoi.vertices scale = Decimal(str(scale)) @@ -137,6 +282,24 @@ def plot_outgoing_edges(self, vertices=None, scale=0.5, **kwargs): return self def plot_sites(self, points=None, show_labels=True, color=Colors.CELL_POINTS, zorder=10): + """ + Display the cell points (a.k.a. sites). + + Parameters + ---------- + points: list(:class:`voronoi.graph.Point`), optional + The vertices to display. By default, the `voronoi`'s vertices will be used. + show_labels: bool + Display the labels of the cell points, of format "`P#`", where `#` is the `n`th point from top to bottom. + color: str + Color of the sites in hex format (e.g. "#bdc3c7"). + zorder: int + Higher order will be shown on top of a lower layer. + + Returns + ------- + self: Visualizer + """ points = points or self.voronoi.sites xs = [point.xd for point in points] @@ -153,6 +316,25 @@ def plot_sites(self, points=None, show_labels=True, color=Colors.CELL_POINTS, zo return self def plot_edges(self, edges=None, sweep_line=None, show_labels=True, color=Colors.EDGE, **kwargs): + """ + Display the borders of the cells. + + Parameters + ---------- + edges: list(:class:`voronoi.graph.HalfEdge`), optional + The edges to display. By default, the `voronoi`'s edges will be used. + sweep_line: Decimal + The y-coordinate of the sweep line, used to calculate the positions of unfinished edges. By default, the + `voronoi`'s sweep_line will be used. + show_labels: bool + Display edge labels of format "`A/B`", where the edge is `A`'s border and the edge's twin is `B`'s border. + color: str + Color of the sites in hex format (e.g. "#636e72"). + + Returns + ------- + self: Visualizer + """ edges = edges or self.voronoi.edges sweep_line = sweep_line or self.voronoi.sweep_line for edge in edges: @@ -162,6 +344,22 @@ def plot_edges(self, edges=None, sweep_line=None, show_labels=True, color=Colors return self def plot_border_to_site(self, edges=None, sweep_line=None): + """ + Indicate with dashed line to which site a border belongs. The site's first edge is colored green. + + Parameters + ---------- + edges: list(:class:`voronoi.graph.HalfEdge`), optional + The edges to display. By default, the `voronoi`'s edges will be used. + + sweep_line: Decimal + The y-coordinate of the sweep line, used to calculate the positions of unfinished edges. By default, the + `voronoi`'s sweep_line will be used. + + Returns + ------- + self: Visualizer + """ edges = edges or self.voronoi.edges sweep_line = sweep_line or self.voronoi.sweep_line for edge in edges: @@ -171,6 +369,26 @@ def plot_border_to_site(self, edges=None, sweep_line=None): return self def plot_arcs(self, arcs=None, sweep_line=None, plot_arcs=False, show_labels=True): + """ + Display each arc for each point. Only used if `beach_line` is also `True`. + *Only useful during construction.* + + Parameters + ---------- + arcs: list(:ref:`Arc`) + sweep_line: Decimal + The y-coordinate of the sweep line, used to calculate the positions of the arcs. By default, the + `voronoi`'s sweep_line will be used. + plot_arcs: bool + Display each arc for each point + show_labels: bool + Display labels on the arcs. + + Returns + ------- + self: Visualizer + + """ arcs = arcs or self.voronoi.arcs sweep_line = sweep_line or self.voronoi.sweep_line @@ -217,6 +435,18 @@ def _plot_arc_labels(self, x, plot_lines, bottom, sweep_line, arcs): return self def plot_sweep_line(self, sweep_line=None): + """ + Plot the sweep line. + + Parameters + ---------- + sweep_line: Decimal + The y-coordinate of the sweep line. By default, the `voronoi`'s sweep_line will be used. + + Returns + ------- + self: Visualizer + """ sweep_line = sweep_line or self.voronoi.sweep_line # Get axis limits @@ -227,6 +457,21 @@ def plot_sweep_line(self, sweep_line=None): return self def plot_event(self, event=None, triangles=False): + """ + Display circles for circle events. + *Only useful during construction.* + + Parameters + ---------- + event: Event + A circle event. Other events will be ignored. + triangles: bool + Display the triangle of the 3 points responsible for causing a circle event. + + Returns + ------- + self: Visualizer + """ event = event or self.voronoi.event if isinstance(event, CircleEvent): self._plot_circle(event, show_triangle=triangles) @@ -299,7 +544,7 @@ def _origins(self, edge, sweep_line=None): return start, end @staticmethod - def canvas_size(bounding_polygon, offset): + def _canvas_size(bounding_polygon, offset): max_y = bounding_polygon.max_y + offset max_x = bounding_polygon.max_x + offset min_x = bounding_polygon.min_x - offset