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)