Source code for flatsurf.graphical.hyperbolic

r"""
Plotting primitives for subsets of the hyperbolic plane

EXAMPLES:

Usually, the primitives defined here should not be used directly. Instead the
:meth:`flatsurf.geometry.hyperbolic.HyperbolicConvexSet.plot` method of
hyperbolic sets internally uses these primitives::

    sage: from flatsurf import HyperbolicPlane

    sage: H = HyperbolicPlane()

    sage: geodesic = H.vertical(0)

    sage: plot = geodesic.plot()

    sage: list(plot)
    [CartesianPathPlot([CartesianPathPlotCommand(code='MOVETO', args=(0.000000000000000, 0.000000000000000)), CartesianPathPlotCommand(code='RAYTO', args=(0, 1))])]

.. NOTE::

    The need for these primitives arises because SageMath has no good
    facilities to plot infinite objects such as lines and rays. However, these are
    needed to plot subsets of the hyperbolic plane in the upper half plane model.

.. jupyter-execute::
    :hide-code:

    # Allow jupyter-execute blocks in this module to contain doctests
    import jupyter_doctest_tweaks

"""
# ****************************************************************************
#  This file is part of sage-flatsurf.
#
#        Copyright (C) 2022-2023 Julian Rüth
#
#  sage-flatsurf is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 2 of the License, or
#  (at your option) any later version.
#
#  sage-flatsurf is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with sage-flatsurf. If not, see <https://www.gnu.org/licenses/>.
# ****************************************************************************

from dataclasses import dataclass

from sage.plot.primitive import GraphicPrimitive
from sage.misc.cachefunc import cached_method
from sage.misc.decorators import options, rename_keyword

from flatsurf.geometry.hyperbolic import HyperbolicPoint, HyperbolicPlane


[docs]class CartesianPathPlot(GraphicPrimitive): r""" A plotted path in the hyperbolic plane, i.e., a sequence of commands and associated control points in the hyperbolic plane. The ``plot`` methods of most hyperbolic convex sets rely on such a path. Usually, such a path should not be produced directly. This can be considered a more generic version of ``sage.plot.line.Line`` and ``sage.plot.polygon.Polygon`` since this is not limited to finite line segments. At the same time this generalizes matplotlib's ``Path`` somewhat, again by allowing infinite rays and lines. INPUT: - ``commands`` -- a sequence of :class:`HyperbolicPathPlotCommand` describing the path. - ``options`` -- a dict or ``None`` (the default), options to affect the plotting of the path; the options accepted are the same that ``Polygon`` of :mod:`sage.plot.polygon` accepts. EXAMPLES: A geodesic plot as a single such path (wrapped in a SageMath graphics object):: sage: from flatsurf import HyperbolicPlane sage: from flatsurf.graphical.hyperbolic import CartesianPathPlot, CartesianPathPlotCommand sage: H = HyperbolicPlane() sage: P = H.vertical(0).plot() sage: isinstance(P[0], CartesianPathPlot) True The sequence of commands should always start with a move command to establish the starting point of the plot; note that coordinates are always given in the Cartesian two dimension plot coordinate system:: sage: P = CartesianPathPlot([ ....: CartesianPathPlotCommand("MOVETO", (0, 0)) ....: ]) After the initial move, a sequence of arcs can be drawn to represent objects in the upper half plane model; the parameters is the center and the end point of the arc:: sage: P = CartesianPathPlot([ ....: CartesianPathPlotCommand("MOVETO", (-1, 0)), ....: CartesianPathPlotCommand("ARCTO", ((0, 0), (0, 1))), ....: ]) We can also draw segments to represent objects in the Klein disk model:: sage: P = CartesianPathPlot([ ....: CartesianPathPlotCommand("MOVETO", (-1, 0)), ....: CartesianPathPlotCommand("LINETO", (0, 0)), ....: ]) Additionally, we can draw rays to represent verticals in the upper half plane model; the parameter is the direction of the ray, i.e., (0, 1) for a vertical:: sage: P = CartesianPathPlot([ ....: CartesianPathPlotCommand("MOVETO", (0, 0)), ....: CartesianPathPlotCommand("RAYTO", (0, 1)), ....: ]) Similarly, we can also move the cursor to an infinitely far point in a certain direction. This can be used to plot a half plane, e.g., the point with non-negative real part:: sage: P = CartesianPathPlot([ ....: CartesianPathPlotCommand("MOVETO", (0, 0)), ....: CartesianPathPlotCommand("MOVETOINFINITY", (1, 0)), ....: CartesianPathPlotCommand("MOVETOINFINITY", (0, 1)), ....: CartesianPathPlotCommand("LINETO", (0, 0)), ....: ]) In a similar way, we can also draw an actual line, here the real axis:: sage: P = CartesianPathPlot([ ....: CartesianPathPlotCommand("MOVETOINFINITY", (-1, 0)), ....: CartesianPathPlotCommand("RAYTO", (1, 0)), ....: ]) Finally, we can draw an arc in clockwise direction; here we plot the point in the upper half plane of norm between 1 and 2:: sage: P = CartesianPathPlot([ ....: CartesianPathPlotCommand("MOVETO", (-1, 0)), ....: CartesianPathPlotCommand("ARCTO", ((0, 0), (1, 0))), ....: CartesianPathPlotCommand("MOVETO", (2, 0)), ....: CartesianPathPlotCommand("RARCTO", ((0, 0), (-2, 0))), ....: CartesianPathPlotCommand("MOVETO", (-1, 0)), ....: ]) .. SEEALSO:: :func:`hyperbolic_path` to create a ``Graphics`` containing a :class:`CartesianPathPlot`, most likely you want to use that function if you want to use this functionality in plots of your own. """ def __init__(self, commands, options=None): options = options or {} valid_options = self._allowed_options() for option in options: if option not in valid_options: raise RuntimeError(f"option {option} not valid") # We don't validate the commands here. The consumers below are going to # do that implicitly. self._commands = commands super().__init__(options) def _allowed_options(self): r""" Return the options that are supported by a path. We support all the options that are understood by a SageMath polygon. EXAMPLES:: sage: from flatsurf.graphical.hyperbolic import CartesianPathPlot sage: P = CartesianPathPlot([]) sage: P._allowed_options() # random output depending on the version of SageMath {'alpha': 'How transparent the figure is.', 'edgecolor': 'The color for the border of filled polygons.', 'fill': 'Whether or not to fill the polygon.', 'hue': 'The color given as a hue.', 'legend_color': 'The color of the legend text.', 'legend_label': 'The label for this item in the legend.', 'linestyle': 'The style of the enclosing line.', 'rgbcolor': 'The color as an RGB tuple.', 'thickness': 'How thick the border line is.', 'zorder': 'The layer level in which to draw'} sage: P = CartesianPathPlot([], options={"alpha": .1}) sage: P = CartesianPathPlot([], options={"beta": .1}) Traceback (most recent call last): ... RuntimeError: option beta not valid """ from sage.plot.polygon import Polygon return Polygon([], [], {})._allowed_options() def __repr__(self): r""" Return a printable representation of this plot for debugging purposes. EXAMPLES:: sage: from flatsurf.graphical.hyperbolic import CartesianPathPlot, CartesianPathPlotCommand sage: P = CartesianPathPlot([ ....: CartesianPathPlotCommand("MOVETO", (-1, 0)), ....: CartesianPathPlotCommand("LINETO", (0, 0)), ....: ]) sage: P CartesianPathPlot([CartesianPathPlotCommand(code='MOVETO', args=(-1, 0)), CartesianPathPlotCommand(code='LINETO', args=(0, 0))]) """ return f"CartesianPathPlot({self._commands})" def _render_on_subplot(self, subplot): r""" Render this path on ``subplot``. Matplotlib was not really made to draw things that extend to infinity. The trick here is to register a callback that redraws whenever the viewbox of the plot changes, e.g., as more objects are added to the plot or as the plot is dragged around. This implements the interface required by SageMath's ``GraphicPrimitive``. INPUT: - ``subplot`` -- the axes of a subplot EXAMPLES:: sage: from flatsurf.graphical.hyperbolic import CartesianPathPlot, CartesianPathPlotCommand sage: P = CartesianPathPlot([ ....: CartesianPathPlotCommand("MOVETO", (-1, 0)), ....: CartesianPathPlotCommand("LINETO", (0, 0)), ....: ]) sage: from matplotlib.figure import Figure sage: figure = Figure() sage: subplot = figure.add_subplot(111) sage: P._render_on_subplot(subplot) sage: subplot.patches <Axes.ArtistList of 1 patches> """ matplotlib_options = { key: value for (key, value) in self.options().items() if key not in { "alpha", "legend_color", "legend_label", "linestyle", "rgbcolor", "thickness", } } from matplotlib.path import Path path = Path([(0, 0)]) from matplotlib.patches import PathPatch patch = PathPatch(path, **matplotlib_options) subplot.axes.add_patch(patch) options = self.options() fill = options.pop("fill", None) if fill: patch.set_fill(True) # Translate SageMath options to matplotlib style. if "thickness" in options: patch.set_linewidth(float(options.pop("thickness"))) if "linestyle" in options: patch.set_linestyle(options.pop("linestyle")) if "alpha" in options: patch.set_alpha(float(options["alpha"])) from sage.plot.colors import to_mpl_color color = None if "rgbcolor" in options: color = to_mpl_color(options.pop("rgbcolor")) edge_color = options.pop("edgecolor", None) if edge_color is not None: edge_color = to_mpl_color(edge_color) if edge_color is None: if color is None: pass else: patch.set_color(color) else: patch.set_edgecolor(edge_color) if color is None: pass else: patch.set_facecolor(color) if "legend_label" in options: patch.set_label(options.pop("legend_label")) def redraw(_=None): r""" Redraw after the viewport has been rescaled to make sure that infinite rays reach the end of the viewport. """ # We use ._path directly since .set_path is not available in old # versions of matplotlib patch._path = self._create_path( subplot.axes.get_xlim(), subplot.axes.get_ylim(), fill=fill ) subplot.axes.callbacks.connect("ylim_changed", redraw) subplot.axes.callbacks.connect("xlim_changed", redraw) redraw() def _create_path(self, xlim, ylim, fill): r""" Create a matplotlib path for this primitive in the bounding box given by ``xlim`` and ``ylim``. This is a helper method for :meth:`_rendor_on_subplot`. INPUT: - ``xlim`` -- a pair of floats, the lower and upper horizontal bound of the view box - ``ylim`` -- a pair of floats, the lower and upper vertical bound of the view box - ``fill`` -- a boolean; whether the area enclosed by this path should be filled EXAMPLES:: sage: from flatsurf.graphical.hyperbolic import CartesianPathPlot, CartesianPathPlotCommand sage: P = CartesianPathPlot([ ....: CartesianPathPlotCommand("MOVETO", (-1, 0)), ....: CartesianPathPlotCommand("LINETO", (0, 0)), ....: ]) sage: P._create_path([-1, 1], [-1, 1], fill=False) Path(array([[-1., 0.], [ 0., 0.]]), array([1, 2], dtype=uint8)) """ # Handle the first command. This controls how the path starts. command = self._commands[0] if command.code == "MOVETO": pos = command.args direction = None vertices = [pos] elif command.code == "MOVETOINFINITY": # We cannot draw lines yet. We don't seem to need them for hyperbolic plots at the moment. raise NotImplementedError( "cannot draw a path that starts at an infinite point yet" ) else: raise RuntimeError(f"path must not start with a {command.code} command") from matplotlib.path import Path codes = [Path.MOVETO] for command in self._commands[1:]: pos, direction = self._extend_path( vertices, codes, pos, direction, command, fill, xlim, ylim ) return Path(vertices, codes) @staticmethod def _infinity(pos, direction, xlim, ylim): r""" Return the finite coordinates of a point that ressembles infinity starting from ``pos`` and going in ``direction``. This is a helper method for :meth:`_create_path`. INPUT: - ``pos`` -- a coordinate in the plane as a tuple - ``direction`` -- a direction in the plane as a tuple - ``xlim`` -- a pair of floats, the lower and upper horizontal bound of the view box - ``ylim`` -- a pair of floats, the lower and upper vertical bound of the view box EXAMPLES:: sage: from flatsurf.graphical.hyperbolic import CartesianPathPlot sage: CartesianPathPlot._infinity((0., 0.), (0., 1.), (-1024., 1024.), (-1024., 1024.)) (0.000000000000000, 5120.00000000000) sage: CartesianPathPlot._infinity((0., 0.), (1., 1.), (-1024., 1024.), (-1024., 1024.)) (3920.30937574010, 3920.30937574010) sage: CartesianPathPlot._infinity((1., 0.), (1., 1.), (-1024., 1024.), (-1024., 1024.)) (3920.30937574010, 3919.30937574010) """ from sage.all import vector direction = vector(direction) pos = vector(pos) from sage.all import infinity if direction[0]: λx = max( (xlim[0] - pos[0]) / direction[0], (xlim[1] - pos[0]) / direction[0] ) else: λx = infinity if direction[1]: λy = max( (ylim[0] - pos[1]) / direction[1], (ylim[1] - pos[1]) / direction[1] ) else: λy = infinity λ = min(λx, λy) # Additionally, we now move out a full plot size so we are sure # that no artifacts coming from any sweeps (see below) show up in # the final plot. plot_size = (xlim[1] - xlim[0]) + (ylim[1] - ylim[0]) λ += plot_size / direction.norm() return pos + λ * direction @staticmethod def _extend_path(vertices, codes, pos, direction, command, fill, xlim, ylim): r""" Extend the matplotlib Path ``vertices`` and ``codes`` by realizing ``command``. INPUT: - ``vertices`` -- a list of control points - ``codes`` -- a list of matplotlib Path plot command - ``pos`` -- the current location of the plotted path before executing ``command`` - ``direction`` -- ``None`` or the direction if the current position is infinite - ``command`` -- a :class:`CartesianPathPlotCommand` - ``fill`` -- a boolean, whether the interior of the plotted path should be filled - ``xlim`` -- a pair of floats, the lower and upper horizontal bound of the view box - ``ylim`` -- a pair of floats, the lower and upper vertical bound of the view box OUTPUT: The new ``pos`` and ``direction`` after executing the ``command``. EXAMPLES:: sage: from flatsurf.graphical.hyperbolic import CartesianPathPlot, CartesianPathPlotCommand sage: from matplotlib.path import Path sage: vertices = [(0., 0.)] sage: codes = [Path.MOVETO] sage: pos = (0., 0.) sage: direction = None sage: command = CartesianPathPlotCommand("LINETO", (1., 1.)) sage: CartesianPathPlot._extend_path(vertices, codes, pos, direction, command, False, (-1024., 1024.), (-1024., 1024.)) ((1.00000000000000, 1.00000000000000), None) sage: vertices [(0.000000000000000, 0.000000000000000), (1.00000000000000, 1.00000000000000)] sage: codes [1, 2] """ from matplotlib.path import Path def extend(path): vertices.extend(path.vertices[1:]) codes.extend(path.codes[1:]) if command.code == "LINETO": target = command.args if direction is not None: vertices.append( CartesianPathPlot._infinity(target, direction, xlim, ylim) ) codes.append(Path.LINETO) direction = None pos = target vertices.append(pos) codes.append(Path.LINETO) elif command.code == "RAYTO": if direction is None: direction = command.args vertices.append(CartesianPathPlot._infinity(pos, direction, xlim, ylim)) codes.append(Path.LINETO) else: start = CartesianPathPlot._infinity(pos, direction, xlim, ylim) direction = command.args end = CartesianPathPlot._infinity(pos, direction, xlim, ylim) # Sweep the bounding box counterclockwise from start to end from sage.all import vector center = vector(((start[0] + end[0]) / 2, (start[1] + end[1]) / 2)) extend(CartesianPathPlot._arc_path(center, start, end)) vertices.append(end) codes.append(Path.LINETO if fill else Path.MOVETO) elif command.code == "ARCTO": target, center = command.args assert direction is None extend(CartesianPathPlot._arc_path(center, pos, target)) pos = target elif command.code == "RARCTO": target, center = command.args assert direction is None extend(CartesianPathPlot._arc_path(center, target, pos, reverse=True)) pos = target elif command.code == "MOVETO": target = command.args pos = target direction = None vertices.append(pos) codes.append(Path.MOVETO) else: raise RuntimeError(f"cannot draw {command.code} yet") return pos, direction
[docs] @cached_method def get_minmax_data(self): r""" Return a bounding box for this plot. This implements the required interface of SageMath's GraphicPrimitive. EXAMPLES:: sage: from flatsurf.graphical.hyperbolic import CartesianPathPlot, CartesianPathPlotCommand sage: P = CartesianPathPlot([ ....: CartesianPathPlotCommand("MOVETO", (-1, 0)), ....: CartesianPathPlotCommand("LINETO", (0, 0)), ....: ]) sage: P.get_minmax_data() {'xmax': 0.0, 'xmin': -1.0, 'ymax': 0.0, 'ymin': 0.0} :: sage: P = CartesianPathPlot([ ....: CartesianPathPlotCommand("MOVETO", (0, 0)), ....: CartesianPathPlotCommand("RAYTO", (0, 1)), ....: ]) sage: P.get_minmax_data()['ymax'] 1.0 """ try: from matplotlib.transforms import Bbox bbox = Bbox.null() for command in self._commands: if command.code in ["MOVETO", "LINETO"]: pos = command.args bbox.update_from_data_xy([pos], ignore=False) elif command.code in ["ARCTO", "RARCTO"]: target, center = command.args # We simplify the computation of the bounding box here to # speed things up. Since these are hyperbolic arcs, their # center must be at y=0 and the endpoints at y≥0. # If we want to use this for non-hyperbolic objects, we'd # have to use this instead: # bbox = bbox.union( # [ # bbox, # self._arc_path( # center, target, pos, reverse=command.code == "RARCTO" # ).get_extents(), # ] # ) bbox = bbox.union( [ bbox, Bbox.from_bounds(*pos, 0, 0), Bbox.from_bounds(*target, 0, 0), ] ) from sage.all import sgn if sgn(pos[0] - center[0]) != sgn(target[0] - center[0]): # The bounding box includes a point that is higher # than the endpoints of the arc. from math import sqrt bbox = bbox.union( [ bbox, Bbox.from_bounds( center[0], sqrt((pos[0] - center[0]) ** 2 + pos[1] ** 2), 0, 0, ), ] ) pos = target elif command.code in ["RAYTO", "MOVETOINFINITY"]: # We just move "1" in the direction of the ray so that # infinite rays are visible at all. If everything plotted # is extremely small, then this is not ideal. Maybe we # should make this relative to the entire plot size. from sage.all import vector direction = vector(command.args) moved = vector(pos) + direction bbox = bbox.union( [bbox, Bbox.from_bounds(pos[0], moved[0], pos[1], moved[1])] ) else: raise NotImplementedError( f"cannot determine bounding box for {command.code} command" ) from sage.plot.plot import minmax_data return minmax_data(bbox.intervalx, bbox.intervaly, dict=True) except Exception as e: raise RuntimeError(e)
@staticmethod def _arc_path(center, start, end, reverse=False): r""" Return a matplotlib path approximating an circular arc. This is a helper method for :meth:`_extend_path`. INPUT: - ``center`` -- the center point of the circle completing the arc - ``start`` -- the coordinates of the starting point of the arc - ``end`` -- the coordinates of the end point of the arc - ``reverse`` -- a boolean (default: ``False``); whether the arc should go clockwise from ``end`` to ``start`` instead of going counterclockwise from ``start`` to ``end``. EXAMPLES:: sage: from flatsurf.graphical.hyperbolic import CartesianPathPlot sage: CartesianPathPlot._arc_path((0, 0), (1, 0), (0, 1)) Path(array([[1.00000000e+00, 0.00000000e+00], ... .. NOTE:: Currently, we are using 32 Bezier segments to approximate an arc. This is probably too many for small arcs and not enough for very big arcs and should be optimized. """ from matplotlib.path import Path from math import atan2, pi from sage.all import vector unit_arc = Path.arc( atan2(start[1] - center[1], start[0] - center[0]) / pi * 180, atan2(end[1] - center[1], end[0] - center[0]) / pi * 180, n=32, ) # Scale and translate the arc arc_vertices = ( unit_arc.vertices * vector((start[0] - center[0], start[1] - center[1])).norm() + center ) if reverse: arc_vertices = arc_vertices[::-1] return Path(arc_vertices, unit_arc.codes)
[docs]@dataclass class HyperbolicPathPlotCommand: r""" A step in a hyperbolic plot. Such a step is independent of the model chosen for the plot. It merely draws a segment (``LINETO``) in the hyperbolic plan or performs a movement without drawing a segment (``MOVETO``). Each command has a parameter, the point to which the command moves. A sequence of such commands cannot be plotted directly. It is first converted into a sequence of :class:`CartesianPathPlotCommand` which realizes the commands in a specific hyperbolic model. EXAMPLES:: sage: from flatsurf import HyperbolicPlane sage: from flatsurf.graphical.hyperbolic import HyperbolicPathPlotCommand sage: H = HyperbolicPlane() sage: HyperbolicPathPlotCommand("MOVETO", H(0)) HyperbolicPathPlotCommand(code='MOVETO', target=0) """ code: str # Literal["MOVETO", "LINETO"] requires Python 3.8 target: HyperbolicPoint
[docs] def cartesian(self, model, cursor=None, fill=True, stroke=True): r""" Return the sequence of commands that realizes this plot in the Cartesian plot coordinate system. INPUT: - ``model`` -- one of ``"half_plane"`` or ``"klein"`` - ``cursor`` -- a point in the hyperbolic plane or ``None`` (the default); assume that the cursor has been positioned at ``cursor`` before this command. - ``fill`` -- a boolean; whether to return commands that produce the correct polygon to represent the area of the polygon. - ``stroke`` -- a boolean; whether to return commands that produce the correct polygon to represent the lines of the polygon. EXAMPLES:: sage: from flatsurf import HyperbolicPlane sage: from flatsurf.graphical.hyperbolic import HyperbolicPathPlotCommand sage: H = HyperbolicPlane() sage: command = HyperbolicPathPlotCommand("MOVETO", H(0)) sage: command.cartesian("half_plane") [CartesianPathPlotCommand(code='MOVETO', args=(0.000000000000000, 0.000000000000000))] sage: command.cartesian("klein") [CartesianPathPlotCommand(code='MOVETO', args=(0.000000000000000, -1.00000000000000))] sage: command = HyperbolicPathPlotCommand("LINETO", H(1)) sage: command.cartesian("half_plane", cursor=H(0)) [CartesianPathPlotCommand(code='RARCTO', args=((1.00000000000000, 0.000000000000000), (0.500000000000000, 0)))] sage: command.cartesian("klein", cursor=H(0)) [CartesianPathPlotCommand(code='LINETO', args=(1.00000000000000, 0.000000000000000))] sage: command = HyperbolicPathPlotCommand("LINETO", H(oo)) sage: command.cartesian("half_plane", cursor=H(1)) [CartesianPathPlotCommand(code='RAYTO', args=(0, 1))] sage: command.cartesian("klein", cursor=H(1)) [CartesianPathPlotCommand(code='LINETO', args=(0.000000000000000, 1.00000000000000))] """ if cursor is None: if self.code != "MOVETO": raise ValueError( "when no previous cursor position is specified, command must be MOVETO" ) if model == "half_plane" and self.target == self.target.parent().infinity(): return [CartesianPathPlotCommand("MOVETOINFINITY", (0, 1))] from sage.all import RR return [ CartesianPathPlotCommand( "MOVETO", self.target.change_ring(RR).coordinates(model=model) ) ] if self.code == "LINETO": return HyperbolicPathPlotCommand.create_segment_cartesian( cursor, self.target, model=model ) if self.code == "MOVETO": return HyperbolicPathPlotCommand.create_move_cartesian( cursor, self.target, model=model, fill=fill, stroke=stroke ) raise NotImplementedError( "cannot convert this command to a Cartesian plot command yet" )
[docs] @staticmethod def make_cartesian(commands, model, fill=True, stroke=True): r""" Return the sequence of :class:`CartesianPathPlotCommand` that realizes the hyperbolic ``commands`` in the ``model``. INPUT: - ``commands`` -- a sequence of :class:`HyperbolicPathPlotCommand`. - ``model`` -- one of ``"half_plane"`` or ``"klein"`` - ``fill`` -- a boolean; whether to return commands that produce the correct polygon to represent the area of the polygon. - ``stroke`` -- a boolean; whether to return commands that produce the correct polygon to represent the lines of the polygon. EXAMPLES:: sage: from flatsurf import HyperbolicPlane sage: from flatsurf.graphical.hyperbolic import HyperbolicPathPlotCommand sage: H = HyperbolicPlane() A finite closed triangle in the hyperbolic plane:: sage: commands = [ ....: HyperbolicPathPlotCommand("MOVETO", H(I)), ....: HyperbolicPathPlotCommand("LINETO", H(I + 1)), ....: HyperbolicPathPlotCommand("LINETO", H(2 * I)), ....: HyperbolicPathPlotCommand("LINETO", H(I)), ....: ] And its corresponding plot in different models:: sage: HyperbolicPathPlotCommand.make_cartesian(commands, model="half_plane") [CartesianPathPlotCommand(code='MOVETO', args=(0.000000000000000, 1.00000000000000)), CartesianPathPlotCommand(code='RARCTO', args=((1.00000000000000, 1.00000000000000), (0.500000000000000, 0))), CartesianPathPlotCommand(code='ARCTO', args=((0.000000000000000, 2.00000000000000), (-1.00000000000000, 0))), CartesianPathPlotCommand(code='LINETO', args=(0.000000000000000, 1.00000000000000))] sage: HyperbolicPathPlotCommand.make_cartesian(commands, model="klein") [CartesianPathPlotCommand(code='MOVETO', args=(0.000000000000000, 0.000000000000000)), CartesianPathPlotCommand(code='LINETO', args=(0.666666666666667, 0.333333333333333)), CartesianPathPlotCommand(code='LINETO', args=(0.000000000000000, 0.600000000000000)), CartesianPathPlotCommand(code='LINETO', args=(0.000000000000000, 0.000000000000000))] Asking for a polygon that works for both fill and stroke is not always possible:: sage: commands = [ ....: HyperbolicPathPlotCommand("MOVETO", H(0)), ....: HyperbolicPathPlotCommand("MOVETO", H(1)), ....: HyperbolicPathPlotCommand("LINETO", H(oo)), ....: HyperbolicPathPlotCommand("LINETO", H(0)), ....: ] sage: HyperbolicPathPlotCommand.make_cartesian(commands, model="half_plane") Traceback (most recent call last): ... ValueError: exactly one of fill & stroke must be set sage: HyperbolicPathPlotCommand.make_cartesian(commands, model="half_plane", fill=False, stroke=True) [CartesianPathPlotCommand(code='MOVETO', args=(0.000000000000000, 0.000000000000000)), CartesianPathPlotCommand(code='MOVETO', args=(1.00000000000000, 0.000000000000000)), CartesianPathPlotCommand(code='RAYTO', args=(0, 1)), CartesianPathPlotCommand(code='LINETO', args=(0.000000000000000, 0.000000000000000))] sage: HyperbolicPathPlotCommand.make_cartesian(commands, model="half_plane", fill=True, stroke=False) [CartesianPathPlotCommand(code='MOVETO', args=(0.000000000000000, 0.000000000000000)), CartesianPathPlotCommand(code='LINETO', args=(1.00000000000000, 0.000000000000000)), CartesianPathPlotCommand(code='RAYTO', args=(0, 1)), CartesianPathPlotCommand(code='LINETO', args=(0.000000000000000, 0.000000000000000))] """ cartesian_commands = [] cursor = None for command in commands: cartesian_commands.extend( command.cartesian(model=model, cursor=cursor, stroke=stroke, fill=fill) ) cursor = command.target while cartesian_commands and cartesian_commands[-1].code.startswith("MOVE"): cartesian_commands.pop() return cartesian_commands
[docs] @staticmethod def create_segment_cartesian(start, end, model): r""" Return a sequence of :class:`CartesianPathPlotCommand` that represent the closed boundary of a :class:`~flatsurf.geometry.hyperbolic.HyperbolicConvexPolygon`, namely the segment to ``end`` (from the previous position ``start``.) This is a helper function for :meth:`cartesian`. INPUT: - ``start`` -- a :class:`~flatsurf.geometry.hyperbolic.HyperbolicPoint` - ``end`` -- a :class:`~flatsurf.geometry.hyperbolic.HyperbolicPoint` - ``model`` -- one of ``"half_plane"`` or ``"klein"`` in which model to realize this segment EXAMPLES:: sage: from flatsurf import HyperbolicPlane sage: from flatsurf.graphical.hyperbolic import HyperbolicPathPlotCommand sage: H = HyperbolicPlane() A finite segment in the hyperbolic plane; note that we assume that "cursor" is at ``start``, so only the command that goes to ``end`` is returned:: sage: HyperbolicPathPlotCommand.create_segment_cartesian(H(I), H(2*I), model="half_plane") [CartesianPathPlotCommand(code='LINETO', args=(0.000000000000000, 2.00000000000000))] An infinite segment:: sage: HyperbolicPathPlotCommand.create_segment_cartesian(H(I), H(oo), model="half_plane") [CartesianPathPlotCommand(code='RAYTO', args=(0, 1))] A segment that is infinite on both ends; it looks the same because the starting point is not rendered here:: sage: HyperbolicPathPlotCommand.create_segment_cartesian(H(0), H(oo), model="half_plane") [CartesianPathPlotCommand(code='RAYTO', args=(0, 1))] Note that this is a "closed" boundary of the polygon that is left of that segment unlike the "open" version produced by :meth:`create_move_cartesian` which contains the entire positive real axis:: sage: HyperbolicPathPlotCommand.create_move_cartesian(H(0), H(oo), model="half_plane", stroke=True, fill=False) [CartesianPathPlotCommand(code='MOVETOINFINITY', args=(0, 1))] sage: HyperbolicPathPlotCommand.create_move_cartesian(H(0), H(oo), model="half_plane", stroke=False, fill=True) [CartesianPathPlotCommand(code='RAYTO', args=(1, 0)), CartesianPathPlotCommand(code='RAYTO', args=(0, 1))] The corresponding difference in the Klein model:: sage: HyperbolicPathPlotCommand.create_segment_cartesian(H(0), H(oo), model="klein") [CartesianPathPlotCommand(code='LINETO', args=(0.000000000000000, 1.00000000000000))] sage: HyperbolicPathPlotCommand.create_move_cartesian(H(0), H(oo), model="klein", stroke=True, fill=False) [CartesianPathPlotCommand(code='MOVETO', args=(0.000000000000000, 1.00000000000000))] sage: HyperbolicPathPlotCommand.create_move_cartesian(H(0), H(oo), model="klein", stroke=False, fill=True) [CartesianPathPlotCommand(code='ARCTO', args=((0.000000000000000, 1.00000000000000), (0, 0)))] .. NOTE:: Sometimes there are problems on a very small scale due to our usage of RR internally. We should probably use ball arithmetic to make things more robust. """ if start == end: raise ValueError( f"cannot draw segment from point {start} to itself ({end})" ) if model == "half_plane": from sage.all import RR if start != start.parent().infinity(): start_x, start_y = start.change_ring(RR).coordinates(model="half_plane") if end != end.parent().infinity(): end_x, end_y = end.change_ring(RR).coordinates(model="half_plane") if start == start.parent().infinity(): return [ CartesianPathPlotCommand("LINETO", (end_x, end_y)), ] if end == end.parent().infinity(): return [ CartesianPathPlotCommand( "RAYTO", (0, 1), ) ] # We should probably be more careful here and not just use a random # epsilon. if (start_x - end_x).abs() < (start_y - end_y).abs() * 1e-6: # This segment is (almost) vertical. We plot it as if it were # vertical to avoid numeric issues. return [CartesianPathPlotCommand("LINETO", (end_x, end_y))] real_hyperbolic_plane = HyperbolicPlane(RR) geodesic = real_hyperbolic_plane.geodesic( real_hyperbolic_plane.point(start_x, start_y, model="half_plane"), real_hyperbolic_plane.point(end_x, end_y, model="half_plane"), ) center = ( (geodesic.start().coordinates()[0] + geodesic.end().coordinates()[0]) / 2, 0, ) return [ CartesianPathPlotCommand( "RARCTO" if start_x < end_x else "ARCTO", ((end_x, end_y), center) ) ] elif model == "klein": from sage.all import RR return [ CartesianPathPlotCommand( "LINETO", end.change_ring(RR).coordinates(model="klein") ) ] else: raise NotImplementedError("cannot draw segment in this model")
[docs] @staticmethod def create_move_cartesian(start, end, model, stroke=True, fill=True): r""" Return a list of :class:`CartesianPathPlotCommand` that represent the open "segment" on the boundary of a polygon connecting ``start`` and ``end``. This is a helper function for :meth:`make_cartesian`. INPUT: - ``start`` -- a :class:`~flatsurf.geometry.hyperbolic.HyperbolicPoint` - ``end`` -- a :class:`~flatsurf.geometry.hyperbolic.HyperbolicPoint` - ``model`` -- one of ``"half_plane"`` or ``"klein"`` in which model to realize this segment - ``stroke`` -- a boolean (default: ``True``); whether this is part of a stroke path that is not filled - ``fill`` -- a boolean (default: ``True``); whether this is part of a filled path that is not stroked EXAMPLES:: sage: from flatsurf import HyperbolicPlane sage: from flatsurf.graphical.hyperbolic import HyperbolicPathPlotCommand sage: H = HyperbolicPlane() sage: HyperbolicPathPlotCommand.create_move_cartesian(H(0), H(1), "half_plane", stroke=True, fill=False) [CartesianPathPlotCommand(code='MOVETO', args=(1.00000000000000, 0.000000000000000))] """ if start == end: raise ValueError("cannot move from point to itself") if start.is_finite(): raise ValueError(f"starting point of move must be ideal but was {start}") if end.is_finite(): raise ValueError(f"end of move must be ideal but was {end}") if fill == stroke: raise ValueError("exactly one of fill & stroke must be set") if model == "half_plane": from sage.all import RR if not fill: if end == end.parent().infinity(): return [CartesianPathPlotCommand("MOVETOINFINITY", (0, 1))] return [ CartesianPathPlotCommand( "MOVETO", end.change_ring(RR).coordinates() ) ] if start == start.parent().infinity(): return [ CartesianPathPlotCommand("RAYTO", (-1, 0)), CartesianPathPlotCommand( "LINETO", end.change_ring(RR).coordinates() ), ] if end == end.parent().infinity(): return [ CartesianPathPlotCommand("RAYTO", (1, 0)), CartesianPathPlotCommand("RAYTO", (0, 1)), ] if ( start.change_ring(RR).coordinates()[0] < end.change_ring(RR).coordinates()[0] ): return [ CartesianPathPlotCommand( "LINETO", end.change_ring(RR).coordinates() ) ] else: return [ CartesianPathPlotCommand("RAYTO", (1, 0)), CartesianPathPlotCommand("RAYTO", (0, 1)), CartesianPathPlotCommand("RAYTO", (-1, 0)), CartesianPathPlotCommand( "LINETO", end.change_ring(RR).coordinates() ), ] elif model == "klein": from sage.all import RR if fill: return [ CartesianPathPlotCommand( "ARCTO", (end.change_ring(RR).coordinates(model="klein"), (0, 0)), ) ] else: return [ CartesianPathPlotCommand( "MOVETO", end.change_ring(RR).coordinates(model="klein") ) ] else: raise NotImplementedError("cannot move in this model")
[docs]@dataclass class CartesianPathPlotCommand: r""" A plot command in the plot coordinate system. EXAMPLES: Move the cursor to the origin of the coordinate system:: sage: from flatsurf.graphical.hyperbolic import CartesianPathPlotCommand sage: P = CartesianPathPlotCommand("MOVETO", (0, 0)) Draw a line segment to another point:: sage: P = CartesianPathPlotCommand("LINETO", (1, 1)) Draw a ray from the current position in a specific direction:: sage: P = CartesianPathPlotCommand("RAYTO", (1, 1)) Move the cursor to a point at infinity in a specific direction:: sage: P = CartesianPathPlotCommand("MOVETOINFINITY", (0, 1)) When already at a point at infinity, then this draws a line:: sage: P = CartesianPathPlotCommand("RAYTO", (0, -1)) When at a point at infinity, we can also draw a ray to a finite point:: sage: P = CartesianPathPlotCommand("LINETO", (0, 0)) Finally, we can draw counterclockwise and clockwise sectors of the circle, i.e., arcs by specifying the other endpoint and the center of the circle:: sage: P = CartesianPathPlotCommand("ARCTO", ((2, 0), (1, 0))) sage: P = CartesianPathPlotCommand("RARCTO", ((0, 0), (1, 0))) .. SEEALSO:: :class:`CartesianPathPlot` which draws a sequence of such commands with matplotlib. :meth:`HyperbolicPathPlotCommand.make_cartesian` to generate a sequence of such commands from a sequence of plot commands in the hyperbolic plane. """ code: str # Literal["MOVETO", "MOVETOINFINITY", "LINETO", "RAYTO", "ARCTO", "RARCTO"] requires Python 3.8 args: tuple
[docs]@rename_keyword(color="rgbcolor") @options( alpha=1, rgbcolor=(0, 0, 1), edgecolor=None, thickness=1, legend_label=None, legend_color=None, aspect_ratio=1.0, fill=True, ) def hyperbolic_path(commands, model="half_plane", **options): r""" Return a SageMath ``Graphics`` object that represents the hyperbolic path encoded by ``commands``. INPUT: - ``commands`` -- a sequence of :class:`HyperbolicPathPlotCommand` - ``model`` -- one of ``"half_plane"`` or ``"klein"`` Many additional keyword arguments are understood, see :class:`CartesianPathPlot` for details. EXAMPLES: .. jupyter-execute:: sage: from flatsurf.graphical.hyperbolic import HyperbolicPathPlotCommand, hyperbolic_path sage: from flatsurf import HyperbolicPlane sage: H = HyperbolicPlane() sage: hyperbolic_path([ ....: HyperbolicPathPlotCommand("MOVETO", H(0)), ....: HyperbolicPathPlotCommand("LINETO", H(I + 1)), ....: HyperbolicPathPlotCommand("LINETO", H(oo)), ....: HyperbolicPathPlotCommand("LINETO", H(I - 1)), ....: HyperbolicPathPlotCommand("LINETO", H(0)), ....: ]) ...Graphics object consisting of 2 graphics primitives .. SEEALSO:: :meth:`flatsurf.geometry.hyperbolic.HyperbolicConvexSet.plot` """ if options["thickness"] is None: if options["fill"] and options["edgecolor"] is None: options["thickness"] = 0 else: options["thickness"] = 1 from sage.plot.all import Graphics g = Graphics() g._set_extra_kwds(Graphics._extract_kwds_for_show(options)) try: if options.get("fill", None): g.add_primitive( CartesianPathPlot( HyperbolicPathPlotCommand.make_cartesian( commands, model=model, fill=True, stroke=False ), {**options, "thickness": 0}, ) ) g.add_primitive( CartesianPathPlot( HyperbolicPathPlotCommand.make_cartesian( commands, model=model, fill=False, stroke=True ), {**options, "fill": False}, ) ) except Exception as e: raise RuntimeError(f"Failed to render hyperbolic path {commands}", e) if options["legend_label"]: g.legend(True) g._legend_colors = [options["legend_color"]] return g