from __future__ import annotations
import math
from collections.abc import Iterator
from typing import TYPE_CHECKING, Any, List, Optional, Set, Type, cast
from backports.cached_property import cached_property
from neoscore.core import neoscore
from neoscore.core.point import ORIGIN, Point, PointDef
from neoscore.core.units import ZERO, Unit
from neoscore.interface.invisible_object_interface import InvisibleObjectInterface
from neoscore.interface.positioned_object_interface import PositionedObjectInterface
if TYPE_CHECKING:
# Used in type annotations, imported here to avoid cyclic imports
from neoscore.core.flowable import Flowable
from neoscore.core.layout_controllers import NewLine
[docs]class render_cached_property(cached_property): # noqa
"""A property annotation for fields which can be cached at render time.
You can annotate any ``PositionedObject`` property to get this behavior, including
on inheriting classes. ::
class Example(PositionedObject):
@render_cached_property
def some_expensive_computed_property(self):
...
Such properties must be immutable during rendering. Typical ``@property`` setters
are not supported.
"""
# Note that this class extends `cached_property`, but this is just a hack to make
# Sphinx and other tools treat it like a property.
[docs] def __init__(self, func):
"""
:meta private:
"""
self.func = func
self.attrname = None
self.__doc__ = func.__doc__
def __get__(self, obj, cls): # noqa
if obj is None:
return self
result = self.func(obj)
if not getattr(obj, "_currently_rendering", None):
return result
property_name = self.func.__name__
value = obj.__dict__[property_name] = result
obj._render_cached_properties.add(property_name) # noqa
return value
def __set__(self, obj, val):
raise AttributeError(f"can't set attribute '{self.func.__name__}'")
[docs]class PositionedObject:
"""An object positioned in the scene
This is the base class of all objects in the neoscore scene tree.
A single ``PositionedObject`` can have multiple graphical representations derived at
render-time. If the object's ancestor is a :obj:`.Flowable`, it will be rendered as a
flowable object, capable of being wrapped around lines.
The position of this object is relative to that of its parent. Each ``PositionedObject``
has another ``PositionedObject`` for a parent, except :obj:`.Page` objects, whose parent is
always the global root :obj:`.Document`.
For convenience, the parent may be initialized to ``None`` to indicate the first page of
the document.
To place objects directly in the scene on pages other than the first, simply set the
parent to the desired page, accessed through the global document with
``neoscore.document.pages[n]``
"""
[docs] def __init__(
self,
pos: PointDef,
parent: Optional[PositionedObject],
):
"""
Args:
pos: The position of the object relative to its parent
parent: The parent object. Defaults to the document's first page.
"""
self.pos = pos
self._children: List[PositionedObject] = []
self._parent = PositionedObject._resolve_parent(parent)
self._set_parent_and_register_self(parent)
self._render_cached_properties: Set[str] = set()
self._currently_rendering = False
self._interfaces = []
self._interface_for_children = None
self._scale = 1.0
self._rotation = 0.0
self.transform_origin = ORIGIN
@property
def pos(self) -> Point:
"""The position of the object relative to its parent."""
return self._pos
@pos.setter
def pos(self, value: PointDef):
self._pos = Point.from_def(value)
@property
def scale(self) -> float:
"""A scale factor to be applied to the rendered object.
Outside flowable contexts, scaling is inherited by children.
Scaling occurs relative to ``self.transform_origin``, which is by default the
local origin.
"""
return self._scale
@scale.setter
def scale(self, value: float):
self._scale = value
@property
def rotation(self) -> float:
"""A rotation angle in degrees.
Outside flowable contexts, rotation is inherited by children.
Rotation occurs relative to ``self.transform_origin``, which is by default the
local origin.
"""
return self._rotation
@rotation.setter
def rotation(self, value: float):
self._rotation = value
@property
def transform_origin(self) -> Point:
"""The origin point for rotation and scaling transforms"""
return self._transform_origin
@transform_origin.setter
def transform_origin(self, value: PointDef):
self._transform_origin = Point.from_def(value)
@property
def x(self) -> Unit:
"""The x position of the object relative to its parent."""
return self.pos.x
@x.setter
def x(self, value: Unit):
self.pos = Point(value, self.y)
@property
def y(self) -> Unit:
"""The y position of the object relative to its parent."""
return self.pos.y
@y.setter
def y(self, value: Unit):
self.pos = Point(self.x, value)
@property
def breakable_length(self) -> Unit:
"""The breakable length of the object.
This is used to determine how and where rendering cuts should be made. A length
of zero indicates that no rendering cuts will be made.
The default implementation of this method returns ``ZERO``. Subclasses which
want to support flowable line breaks should override this method.
"""
return ZERO
@property
def parent(self) -> PositionedObject:
"""The parent object.
If this is set to None, it defaults to the first page of the document.
"""
return self._parent
@parent.setter
def parent(self, value: Optional[PositionedObject]):
self._parent._unregister_child(self)
self._set_parent_and_register_self(value)
@property
def children(self) -> List[PositionedObject]:
"""All direct children of this object."""
return self._children
@children.setter
def children(self, value: List[PositionedObject]):
self._children = value
@property
def descendants(self) -> Iterator[PositionedObject]:
"""All the objects in the children subtree.
This recursively searches all the object's children
(and their children, etc.) and provides an iterator over them.
The current implementation performs a simple recursive DFS over
the tree, and has the potential to be rather slow.
"""
for child in self.children:
for subchild in child.descendants:
yield subchild
yield child
[docs] @render_cached_property
def flowable(self) -> Optional[Flowable]:
"""The flowable this object belongs in."""
return cast(
Any, self.first_ancestor_with_attr("_neoscore_flowable_type_marker") # noqa
)
@property
def interfaces(self) -> List[PositionedObjectInterface]:
"""The graphical backend binding interfaces for this object
Interface objects are created and stored here upon calling :obj:`.render`.
Typically each ``PositionedObject`` will have at most one interface for each
flowable line it appears in.
"""
return self._interfaces
@property
def interface_for_children(self) -> Optional[PositionedObjectInterface]:
"""The low level object interface to be used by children objects.
Outside flowable contexts, interface classes utilize a parenting scheme much
like core classes. The interfaces of child objects should use this field as
their parent for proper position and transform inheritance.
Users should rarely, if ever, have to deal with this field.
"""
return self._interface_for_children
[docs] def descendants_of_class_or_subclass(
self, graphic_object_class: Type[PositionedObject]
) -> Iterator[PositionedObject]:
"""Yield all child descendants with a given class or its subclasses."""
for descendant in self.descendants:
if isinstance(descendant, graphic_object_class):
yield descendant
[docs] def descendants_of_exact_class(
self, graphic_object_class: Type[PositionedObject]
) -> Iterator[PositionedObject]:
"""Yield all child descendants of a given class, excluding sublcasses"""
for descendant in self.descendants:
if type(descendant) == graphic_object_class:
yield descendant
[docs] def descendants_with_attribute(self, attribute: str) -> Iterator[PositionedObject]:
"""Yield all child descendants which has a given attribute.
This is useful for searching descendants for duck-typing matches.
"""
for descendant in self.descendants:
if hasattr(descendant, attribute):
yield descendant
@property
def ancestors(self) -> Iterator[PositionedObject]:
"""All ancestors of this object.
Follows the chain of parents up to and including the :obj:`.Document` root.
The order begins with ``self.parent`` and traverses upward in the document tree.
"""
ancestor = self.parent
while True:
yield ancestor
if not hasattr(ancestor, "parent"):
# Document root found
break
ancestor = ancestor.parent
[docs] def first_ancestor_with_attr(self, attr: str) -> Optional[PositionedObject]:
"""Find this object's closest ancestor with an attribute"""
return next(
(item for item in self.ancestors if hasattr(item, attr)),
None,
)
[docs] def descendant_pos(self, descendant: PositionedObject) -> Point:
"""Find the position of a descendant relative to this object.
Raises:
ValueError:
If ``descendant`` is not a descendant of this object.
"""
pos = descendant.pos
for parent in descendant.ancestors:
if parent == self:
return pos
pos += parent.pos
raise ValueError(f"{self} is not an ancestor of {descendant}")
[docs] def descendant_pos_x(self, descendant: PositionedObject) -> Unit:
"""Find the x position of a descendant relative to this object.
This is a specialized version of ``descendant_pos`` provided for optimization.
Raises:
ValueError:
If ``descendant`` is not a descendant of this object.
"""
pos_x = descendant.pos.x
for parent in descendant.ancestors:
if parent == self:
return pos_x
pos_x += parent.pos.x
raise ValueError(f"{self} is not an ancestor of {descendant}")
[docs] def map_to(self, dst: PositionedObject) -> Point:
"""Find an object's logical position relative to this one
This calculates the position in *logical* space, which differs from canvas space
in that it doesn't account for repositioning of objects inside :obj:`.Flowable`
containers. For example, this function will return the same relative position
for two objects in a ``Flowable`` container whether they are separated by
a line break.
"""
# When changing this method be sure to make the equivalent change in `map_x_to`
# Handle easy cases
if self == dst:
return ORIGIN
if self.parent == dst.parent:
return dst.pos - self.pos
if dst.parent == self:
return dst.pos
if self.parent == dst:
return -self.pos
# Start by collecting all ancestor using IDs because they're hashable
self_ancestor_ids = set(id(obj) for obj in self.ancestors)
relative_dst_pos = dst.pos
for dst_ancestor in dst.ancestors:
if hasattr(dst_ancestor, "parent"):
relative_dst_pos += dst_ancestor.pos
if id(dst_ancestor) in self_ancestor_ids:
# Now find relative_self_pos and return relative_dst_pos - relative_self_pos
relative_self_pos = self.pos
for self_ancestor in self.ancestors:
if hasattr(self_ancestor, "parent"):
relative_self_pos += self_ancestor.pos
if self_ancestor == dst_ancestor:
return relative_dst_pos - relative_self_pos
# Since we've already determined there is a common
# ancestor, this should never happen
assert False, "Unreachable"
raise ValueError(f"{self} and {dst} have no common ancestor")
[docs] def map_x_to(self, dst: PositionedObject) -> Unit:
"""Like :obj:`.map_to`, but only return the X distance from to ``dst``."""
# This implementation is copied from `map_to` and tweaked to only handle the X
# axis. This is a very common operation, so this optimization is useful.
# Handle easy cases
if self == dst:
return ZERO
if self.parent == dst.parent:
return dst.x - self.x
if dst.parent == self:
return dst.x
if self.parent == dst:
return -self.x
# Start by collecting all ancestor using IDs because they're hashable
self_ancestor_ids = set(id(obj) for obj in self.ancestors)
relative_dst_x = dst.x
for dst_ancestor in dst.ancestors:
if hasattr(dst_ancestor, "parent"):
relative_dst_x += dst_ancestor.x
if id(dst_ancestor) in self_ancestor_ids:
# Now find relative_self_x and return relative_dst_x - relative_self_x
relative_self_x = self.x
for self_ancestor in self.ancestors:
if hasattr(self_ancestor, "parent"):
relative_self_x += self_ancestor.x
if self_ancestor == dst_ancestor:
return relative_dst_x - relative_self_x
# Since we've already determined there is a common
# ancestor, this should never happen
assert False, "Unreachable"
raise ValueError(f"{self} and {dst} have no common ancestor")
[docs] def distance_to(self, obj: PositionedObject, offset: Point = ORIGIN) -> Unit:
"""Find the distance to a given object, with an optional extra offset.
Like :obj:`.map_to`, this works in *logical* coordinates.
"""
relative_dst_pos = self.map_to(obj) + offset
distance = Unit(
math.sqrt(
(relative_dst_pos.x.base_value**2)
+ (relative_dst_pos.y.base_value**2)
)
)
return type(self.x)(distance)
[docs] def canvas_pos(self) -> Point:
"""Find the document-space position of this object.
For objects in :obj:`.Flowable`\ s, this should only be accessed at render time,
when flowable layouts are available.
"""
pos = ORIGIN
current = self
while hasattr(current, "parent"):
pos += current.pos
current = current.parent
if hasattr(current, "map_to_canvas"):
# Parent appears to be a flowable,
# so let it decide where the point goes.
return cast(Any, current).map_to_canvas(pos)
return pos
[docs] def remove(self):
"""Remove this object from the document tree."""
if self.parent:
self.parent.children.remove(self)
[docs] def pre_render_hook(self):
"""Run code once just before document rendering begins.
Implementations *must* call the superclass function as well.
"""
self._currently_rendering = True
[docs] def post_render_hook(self):
"""Run code once after document rendering completes.
Implementations *must* call the superclass function as well.
"""
for cached_property in self._render_cached_properties:
del self.__dict__[cached_property]
self._render_cached_properties.clear()
self._currently_rendering = False
[docs] def render(self):
"""Render the object and all its children.
This and other render methods should generally not be called directly.
"""
if self.flowable is not None:
self.render_in_flowable()
else:
self._interface_for_children = InvisibleObjectInterface(
self.pos,
# Hack because root document obj lacks this property
getattr(self.parent, "interface_for_children", None),
self.scale,
self.rotation,
self.transform_origin,
)
self._interface_for_children.render()
self.render_complete(self.pos)
for child in self.children:
child.render()
[docs] def render_in_flowable(self):
"""Render the object to the scene, dispatching partial rendering calls
when needed if an object flows across a break in the flowable.
This and other render methods should generally not be called directly.
"""
# Calculate position within flowable
assert self.flowable is not None
pos_in_flowable = self.flowable.descendant_pos(self)
first_line_i = self.flowable.last_break_index_at(pos_in_flowable.x)
first_line = self.flowable.lines[first_line_i]
first_line_length = (
first_line.flowable_x + first_line.length - pos_in_flowable.x
)
remaining_x = self.breakable_length - first_line_length
if remaining_x <= ZERO:
self.render_complete(self.canvas_pos(), first_line, pos_in_flowable.x)
return
# Render before break
if first_line_length < Unit(1):
# If a break-spanning object starts very close to its first line end,
# skip that line.
first_line_i += 1
first_line = self.flowable.lines[first_line_i]
first_line_length = (
first_line.flowable_x + first_line.length - pos_in_flowable.x
)
remaining_x = self.breakable_length - first_line_length
line_pos = first_line.canvas_pos()
render_start_pos = Point(
line_pos.x + (pos_in_flowable.x - first_line.flowable_x),
line_pos.y + pos_in_flowable.y,
)
self.render_before_break(render_start_pos, first_line, pos_in_flowable.x)
# Iterate through remaining length
for current_line_i in range(first_line_i + 1, len(self.flowable.lines)):
current_line = self.flowable.lines[current_line_i]
line_pos = current_line.canvas_pos()
render_start_pos = Point(line_pos.x, line_pos.y + pos_in_flowable.y)
local_object_x = self.breakable_length - remaining_x
if remaining_x > current_line.length:
# Render spanning continuation
self.render_spanning_continuation(
render_start_pos, current_line, local_object_x
)
remaining_x -= current_line.length
else:
# Render end
self.render_after_break(render_start_pos, current_line, local_object_x)
break
[docs] def render_complete(
self,
pos: Point,
flowable_line: Optional[NewLine] = None,
flowable_x: Optional[Unit] = None,
):
"""Render the entire object.
This is used to render all objects outside flowables, as well as those inside
flowables when they fit completely in one line of the flowable.
By default, this is a no-op. Subclasses with rendered appearances should
override this.
This method behaves differently inside and outside of flowables. Whether this
object is inside a flowable can be determined by whether a ``flowable_line`` is
given. When inside a flowable, the given position is in global document
coordinates, and created interfaces (or higher level classes) must not be
assigned a parent. When not inside a flowable, the given position is relative to
``self.parent`` and created interfaces (or higher level classes) must be
assigned a parent. In this case, created interfaces should use
``self.parent.interface_for_children`` as their parent.
This and other render methods should generally not be called directly.
Args:
pos: The rendering position. If outside a flowable, this is relative to
the parent. Otherwise, it is in document coordinates.
flowable_line: If in a ``Flowable``, the line in which this object appears
flowable_x: If in a ``Flowable``, the flowable x position of this render
"""
[docs] def render_before_break(self, pos: Point, flowable_line: NewLine, flowable_x: Unit):
"""Render the beginning of the object up to a stopping point.
For use in flowable containers when rendering an object that crosses a line or
page break. This function should render the beginning portion of the object up
to the break.
By default, this is a no-op. Subclasses with rendered appearances should
override this.
Created interfaces and higher level objects should not be assigned a parent.
This and other render methods should generally not be called directly.
Args:
pos: The rendering position in document space for drawing.
flowable_line: The line in which this object appears
flowable_x: The flowable x position of this render
"""
[docs] def render_spanning_continuation(
self, pos: Point, flowable_line: NewLine, object_x: Unit
):
"""
Render the continuation of an object after a break and before another.
For use in flowable containers when rendering an object that crosses
two breaks. This function should render the portion of the object
surrounded by breaks on either side.
By default, this is a no-op. Subclasses with rendered appearances should
override this.
Created interfaces and higher level objects should not be assigned a parent.
This and other render methods should generally not be called directly.
Args:
pos: The rendering position in document space for drawing.
flowable_line: The line in which this object appears
object_x: The local object x position of the line's start.
"""
[docs] def render_after_break(self, pos: Point, flowable_line: NewLine, object_x: Unit):
"""Render the continuation of an object after a break.
For use in flowable containers when rendering an object that crosses a line or
page break. This function should render the ending portion of an object after a
break.
By default, this is a no-op. Subclasses with rendered appearances should
override this.
Created interfaces and higher level objects should not be assigned a parent.
This and other render methods should generally not be called directly.
Args:
pos: The rendering position in document space for drawing.
flowable_line: The line in which this object appears
object_x: The local object x position of the line's start.
"""
@staticmethod
def _resolve_parent(value: Optional[PositionedObject]) -> PositionedObject:
if value is None:
return neoscore.document.pages[0]
return value
def _set_parent_and_register_self(self, value: Optional[PositionedObject]):
if value is None:
value = neoscore.document.pages[0]
self._parent = value
if hasattr(self._parent, "_register_child"):
self._parent._register_child(self)
def _register_child(self, child: PositionedObject):
"""Add an object to ``self.children``."""
self.children.append(child)
def _unregister_child(self, child: PositionedObject):
"""Remove an object from ``self.children``."""
self.children.remove(child)