Source code for neoscore.core.path

from __future__ import annotations

from math import atan, cos, pi, sin, sqrt, tan
from typing import List, Optional, Tuple, cast

from neoscore.core.brush import Brush, BrushDef
from neoscore.core.layout_controllers import NewLine
from neoscore.core.painted_object import PaintedObject
from neoscore.core.path_element import (
    ControlPoint,
    CurveTo,
    LineTo,
    MoveTo,
    PathElement,
)
from neoscore.core.pen import Pen, PenDef
from neoscore.core.point import ORIGIN, Point, PointDef
from neoscore.core.positioned_object import PositionedObject, render_cached_property
from neoscore.core.units import ZERO, Mm, Unit
from neoscore.interface.path_interface import (
    PathInterface,
    ResolvedCurveTo,
    ResolvedLineTo,
    ResolvedMoveTo,
    ResolvedPathElement,
)

_TWO_PI = pi * 2
_HALF_PI = pi / 2


[docs]class Path(PaintedObject): """A vector path whose elements can be anchored to other objects. If a ``Path`` is in a :obj:`.Flowable`, any element parents in the path should be in the same flowable. Likewise, if a path is not in a flowable, all element parts should not be in one either. """
[docs] def __init__( self, pos: PointDef, parent: Optional[PositionedObject], brush: Optional[BrushDef] = None, pen: Optional[PenDef] = None, rotation: float = 0, background_brush: Optional[BrushDef] = None, transform_origin: PointDef = ORIGIN, ): """ Args: pos: The position of the path root. parent: The parent object or None brush: The brush to fill shapes with. pen: The pen to draw outlines with. rotation: Angle in degrees. Rotated paths with flowable breaks or path elements parented to other objects are not currently supported. background_brush: Optional brush used to paint the path's bounding rect behind it. transform_origin: The origin point for rotation and scaling transforms """ super().__init__(pos, parent, brush, pen) self.background_brush = background_brush self._rotation = rotation self.elements: List[PathElement] = [] self._current_subpath_start: Optional[Tuple[Point, Optional[parent]]] = None self.transform_origin = transform_origin
[docs] @classmethod def straight_line( cls, start: PointDef, parent: Optional[PositionedObject], end: PointDef, end_parent: Optional[PositionedObject] = None, brush: Optional[BrushDef] = None, pen: Optional[PenDef] = None, ) -> Path: """Convenience for drawing a single straight line. Args: start: The position of the center of the line's start parent: A parent object end: The position of the end of the line, relative to ``end_parent`` if provided or otherwise ``start`` end_parent: An optional parent for the end point. brush: The brush to fill shapes with. pen: The pen to draw outlines with. Defaults to no pen. """ line = cls(start, parent, brush, pen) end = Point.from_def(end) line.line_to(end.x, end.y, end_parent) return line
[docs] @classmethod def rect( cls, pos: PointDef, parent: Optional[PositionedObject], width: Unit, height: Unit, brush: Optional[BrushDef] = None, pen: Optional[PenDef] = None, ): """Convenience for drawing a rectangle.""" path = cls(pos, parent, brush, pen) path.line_to(width, ZERO) path.line_to(width, height) path.line_to(ZERO, height) path.line_to(ZERO, ZERO) return path
[docs] @classmethod def ellipse( cls, pos: PointDef, parent: Optional[PositionedObject], width: Unit, height: Unit, brush: Optional[BrushDef] = None, pen: Optional[PenDef] = None, ): """Convenience for drawing an ellipse. ``pos`` indicates the top left corner of the ellipse's bounding rect. To create a path from a center point, use ``Path.ellipse_from_center``. This can also be used for drawing circles by giving the same width and height. """ # Algorithm courtesy of https://stackoverflow.com/a/2173084/5615927 path = cls(pos, parent, brush, pen) kappa = 0.5522848 ox = (width / 2) * kappa # control point offset horizontal oy = (height / 2) * kappa # control point offset vertical xe = width # x-end ye = height # y-end xm = width / 2 # x-middle ym = height / 2 # y-middle path.move_to(ZERO, ym) path.cubic_to(ZERO, ym - oy, xm - ox, ZERO, xm, ZERO) path.cubic_to(xm + ox, ZERO, xe, ym - oy, xe, ym) path.cubic_to(xe, ym + oy, xm + ox, ye, xm, ye) path.cubic_to(xm - ox, ye, ZERO, ym + oy, ZERO, ym) return path
[docs] @classmethod def ellipse_from_center( cls, center_pos: PointDef, parent: Optional[PositionedObject], width: Unit, height: Unit, brush: Optional[BrushDef] = None, pen: Optional[PenDef] = None, ): """Convenience for drawing an ellipse from its center point. The constructed path will have its ``pos`` at the ellipse bounding rect's top left corner. See also ``Path.ellipse`` """ center_pos = Point.from_def(center_pos) return Path.ellipse( Point(center_pos.x - (width / 2), center_pos.y - (height / 2)), parent, width, height, brush, pen, )
[docs] @classmethod def arc( cls, pos: PointDef, parent: Optional[PositionedObject], width: Unit, height: Unit, start_angle: float, stop_angle: float, brush: Optional[BrushDef] = None, pen: Optional[PenDef] = None, ): """Convenience for drawing an elliptical arc. Args: pos: The position of the upper left corner of the traced ellipse. parent: The parent object or None width: The traced ellipse's width height: The traced ellipse's height start_angle: The starting arc angle in radians clockwise relative to the 3 o'clock position. parent: The parent object or None width: The full ellipse width height: The full ellipse height start_angle: The start arc angle in radians clockwise relative to the 3 o'clock position. stop_angle: The stopping arc angle, defined like ``start_angle``. brush: The brush to fill shapes with. pen: The pen to draw outlines with. pen: The pen to draw outlines with. The arc definition can be most easily understood as tracing an ellipse as defined in ``Path.ellipse()``, where ``pos`` marks the top-left corner of the ellipse bounding rect. Two angles are provided in clockwise radians relative to the 3 o'clock position. The arc is traced from ``start_angle`` clockwise to ``stop_angle``. Consequently, depending on the provided angles the actually drawn path may be far from the Path's position. The provided angles are interpreted mod ``2*pi``. The angle between them must not be 0. This also means arc angles of ``2*pi``, (i.e. complete ellipses), are not supported as they are interpreted as 0. Complete ellipses should instead be drawn with ``Path.ellipse()``. Raises: ValueError: if invalid angles are given """ # This method and ``_acute_arc_to_bezier`` are adapted from Joe # Cridge's algorithm and JS implementation found at # https://www.joecridge.me/bezier.pdf. Most of the original # comments have been left in place as well. # Work in float base units, converting back to units at the end w = width.base_value h = height.base_value # Make all angles positive... while start_angle < 0: start_angle += _TWO_PI while stop_angle < 0: stop_angle += _TWO_PI # ...and confine them to the interval [0,TWO_PI). start_angle %= _TWO_PI stop_angle %= _TWO_PI # Adjust angles to counter linear scaling. if start_angle <= _HALF_PI: start_angle = atan(w / h * tan(start_angle)) elif _HALF_PI < start_angle <= 3 * _HALF_PI: start_angle = atan(w / h * tan(start_angle)) + pi else: start_angle = atan(w / h * tan(start_angle)) + _TWO_PI if stop_angle <= _HALF_PI: stop_angle = atan(w / h * tan(stop_angle)) elif _HALF_PI < stop_angle <= 3 * _HALF_PI: stop_angle = atan(w / h * tan(stop_angle)) + pi else: stop_angle = atan(w / h * tan(stop_angle)) + _TWO_PI # Exceed the interval if necessary in order to preserve the size and # orientation of the arc. if start_angle > stop_angle: stop_angle += _TWO_PI # Create curves epsilon = 0.00001 # Smallest visible angle on displays up to 4K. curves = [] while stop_angle - start_angle > epsilon: arc_to_draw = min(stop_angle - start_angle, _HALF_PI) curves.append(Path._acute_arc_to_bezier(start_angle, arc_to_draw)) start_angle += arc_to_draw if not len(curves): raise ValueError(f"Invalid arc angles {start_angle} and {stop_angle}.") # Draw curves path = cls(pos, parent, brush, pen) rx = w / 2.0 ry = h / 2.0 path.move_to(Unit(rx * curves[0]["ax"] + rx), Unit(ry * curves[0]["ay"] + ry)) for curve in curves: path.cubic_to( Unit(rx * curve["bx"] + rx), Unit(ry * curve["by"] + ry), Unit(rx * curve["cx"] + rx), Unit(ry * curve["cy"] + ry), Unit(rx * curve["dx"] + rx), Unit(ry * curve["dy"] + ry), ) return path
@staticmethod def _acute_arc_to_bezier(start: float, size: float) -> dict: """ Generate a cubic Bezier representing an arc on the unit circle of total angle ``size`` radians, beginning ``start`` radians above the x-axis. """ # Evaluate constants. alpha = size / 2.0 cos_alpha = cos(alpha) sin_alpha = sin(alpha) cot_alpha = 1.0 / tan(alpha) phi = start + alpha # This is how far the arc needs to be rotated. cos_phi = cos(phi) sin_phi = sin(phi) lambda_ = (4.0 - cos_alpha) / 3.0 mu = sin_alpha + (cos_alpha - lambda_) * cot_alpha # Return rotated waypoints. return { "ax": cos(start), "ay": sin(start), "bx": lambda_ * cos_phi + mu * sin_phi, "by": lambda_ * sin_phi - mu * cos_phi, "cx": lambda_ * cos_phi - mu * sin_phi, "cy": lambda_ * sin_phi + mu * cos_phi, "dx": cos(start + size), "dy": sin(start + size), }
[docs] @classmethod def arrow( cls, start: PointDef, parent: Optional[PositionedObject], end: PointDef, end_parent: Optional[PositionedObject] = None, brush: Optional[BrushDef] = None, pen: Optional[PenDef] = None, line_width: Unit = Mm(0.35), arrow_head_width: Unit = Mm(1.5), arrow_head_length: Unit = Mm(2.5), ) -> Path: """Convenience for drawing an arrow Args: start: The position of the center of the arrow line's start parent: A parent object end: The position of the arrow's tip, relative to ``end_parent`` if provided or ``start`` end_parent: An optional parent for the end point. brush: The brush to fill shapes with. pen: The pen to draw outlines with. Defaults to no pen. line_width: The thickness of the arrow's line arrow_head_width: The width of the arrow head extending perpendicular from the line. arrow_head_length: The length of the arrow head parallel to the line. Note that ``end_parent`` is only used for initially drawing the path. If ``end_parent`` moves relative to the path after creation, the path shape will not be automatically updated. """ # This algorithm is lightly adapted from # https://github.com/frogcat/canvas-arrow by Yuzo Matsuzawa # released under MIT. path = cls(start, parent, brush, pen or Pen.no_pen()) end = Point.from_def(end) if end_parent: end = path.map_to(end_parent) + end # The original algorithm supports diverse shapes, including # things like double-headed arrows, using this system of a # variable numbers of control points. We don't use them right # now, but since we might want to later (and since I don't # understand the code enough to refactor it) it's left in # place here. control_points = [ (0, line_width.base_value / 2), (-arrow_head_length.base_value, line_width.base_value / 2), (-arrow_head_length.base_value, arrow_head_width.base_value / 2), ] # The start pos (relative to the path) is always (0, 0), so dx # and dy are just the end position dx = end.x.base_value dy = end.y.base_value length = sqrt(dx * dx + dy * dy) sin_ = dy / length cos_ = dx / length resolved_points: List[Tuple[float, float]] = [(0, 0)] for cp in control_points: if cp[0] < 0: resolved_points.append((length + cp[0], cp[1])) else: resolved_points.append(cp) resolved_points.append((length, 0)) for cp in reversed(control_points): if cp[0] < 0: resolved_points.append((length + cp[0], -cp[1])) else: resolved_points.append((cp[0], -cp[1])) resolved_points.append((0, 0)) path.move_to(Unit(resolved_points[0][0]), Unit(resolved_points[0][1])) for rp in resolved_points[1:]: x = rp[0] * cos_ - rp[1] * sin_ y = rp[0] * sin_ + rp[1] * cos_ path.line_to(Unit(x), Unit(y)) return path
@render_cached_property def _cacheable_breakable_length(self) -> Unit: # Find the positions of every path element relative to the path min_x = Unit(float("inf")) max_x = Unit(-float("inf")) for element in self.elements: # Determine element X relative to self relative_x = self.map_x_to(element) # Now update min/max accordingly if relative_x > max_x: max_x = relative_x if relative_x < min_x: min_x = relative_x return max_x - min_x @property def breakable_length(self) -> Unit: """The breakable length of the path. This is calculated automatically from path contents. By extension, this means that by default all ``Path`` objects will automatically wrap in flowables. """ # This can be slow to calculate, so we want to render-cache it. # We can't directly annotate this method @render_cached_property # because it would violate the type signature of this inherited method, # so simply delegate to a private property which is cached. return self._cacheable_breakable_length @property def background_brush(self) -> Optional[Brush]: """An optional brush to paint over the path bounding rect's background with""" return self._background_brush @background_brush.setter def background_brush(self, value: Optional[BrushDef]): if value: self._background_brush = Brush.from_def(value) else: self._background_brush = None
[docs] def line_to(self, x: Unit, y: Unit, parent: Optional[PositionedObject] = None): """Draw a path from the current position to a new point. If the path is empty, this will add two elements, an initial ``MoveTo(ORIGIN, self)`` and the requested ``LineTo``. Args: x: The end x position y: The end y position parent: An optional parent, whose position the target coordinate will be relative to. """ if not len(self.elements): self.move_to(ZERO, ZERO) self.elements.append(LineTo(Point(x, y), parent or self))
[docs] def move_to(self, x: Unit, y: Unit, parent: Optional[PositionedObject] = None): """Close the current sub-path and start a new one. Args: x: The end x position y: The end y position parent: An optional parent, whose position the target coordinate will be relative to. """ self._current_subpath_start = (Point(x, y), parent or self) self.elements.append(MoveTo(Point(x, y), parent or self))
[docs] def close_subpath(self): """Close the current sub-path with a line. Draw a line back to the starting position (including any parent) of the current subpath. """ end_pos = Point.from_def(self._current_subpath_start[0]) end_parent = self._current_subpath_start[1] self.line_to( end_pos.x, end_pos.y, end_parent, )
[docs] def cubic_to( self, control_1_x: Unit, control_1_y: Unit, control_2_x: Unit, control_2_y: Unit, end_x: Unit, end_y: Unit, control_1_parent: Optional[PositionedObject] = None, control_2_parent: Optional[PositionedObject] = None, end_parent: Optional[PositionedObject] = None, ): """Draw a cubic bezier curve from the current position to a new point. If the path is empty, this will add two elements, an initial ``MoveTo(ORIGIN, self)`` and the requested ``CurveTo``. Args: control_1_x: The x coordinate of the first control point. control_1_y: The y coordinate of the first control point. control_2_x: The x coordinate of the second control point. control_2_y: The y coordinate of the second control point. end_x: The x coordinate of the curve target. end_y: The y coordinate of the curve target. control_1_parent: An optional parent for the first control point. Defaults to ``self``. control_2_parent: An optional parent for the second control point. Defaults to ``self``. end_parent: An optional parent for the curve target. Defaults to ``self``. """ c1 = ControlPoint( Point(control_1_x, control_1_y), control_1_parent or self, ) c2 = ControlPoint( Point(control_2_x, control_2_y), control_2_parent or self, ) if not len(self.elements): self.move_to(ZERO, ZERO) self.elements.append(CurveTo(Point(end_x, end_y), end_parent or self, c1, c2))
def _relative_element_pos(self, element: PositionedObject) -> Point: return self.map_to(element) def _resolve_path_elements(self) -> List[ResolvedPathElement]: resolved: List[ResolvedPathElement] = [] for element in self.elements: # Interface drawing methods expect coordinates # relative to PathInterface root pos = self._relative_element_pos(element) if isinstance(element, LineTo): resolved.append(ResolvedLineTo(pos.x, pos.y)) elif isinstance(element, MoveTo): resolved.append(ResolvedMoveTo(pos.x, pos.y)) elif isinstance(element, CurveTo): element = cast(CurveTo, element) resolved_c1_pos = self._relative_element_pos(element.control_1) resolved_c2_pos = self._relative_element_pos(element.control_2) resolved.append( ResolvedCurveTo( resolved_c1_pos.x, resolved_c1_pos.y, resolved_c2_pos.x, resolved_c2_pos.y, pos.x, pos.y, ) ) else: raise TypeError("Unknown PathElement type") return resolved def _render_slice( self, pos: Point, inside_flowable: bool, clip_start_x: Optional[Unit] = None, clip_width: Optional[Unit] = None, ): # If this proves to be a performance bottleneck in the future, # it is very possible to optimize this to create `PathInterface`s # which reuse `QPainterPath`s. resolved_path_elements = self._resolve_path_elements() slice_interface = PathInterface( pos, None if inside_flowable else self.parent.interface_for_children, self.scale, self.rotation, self.transform_origin, self.brush.interface, self.pen.interface, resolved_path_elements, self.background_brush.interface if self.background_brush else None, clip_start_x, clip_width, ) slice_interface.render() self.interfaces.append(slice_interface)
[docs] def render_complete( self, pos: Point, flowable_line: Optional[NewLine] = None, flowable_x: Optional[Unit] = None, ): inside_flowable = bool(flowable_line) self._render_slice(pos, inside_flowable, None, None)
[docs] def render_before_break(self, pos: Point, flowable_line: NewLine, flowable_x: Unit): slice_length = flowable_line.length - (flowable_x - flowable_line.flowable_x) self._render_slice(pos, True, ZERO, slice_length)
[docs] def render_spanning_continuation( self, pos: Point, flowable_line: NewLine, object_x: Unit ): self._render_slice(pos, True, object_x, flowable_line.length)
[docs] def render_after_break(self, pos: Point, flowable_line: NewLine, object_x: Unit): self._render_slice(pos, True, object_x, None)