Files
2026-03-01 21:36:54 +08:00

848 lines
22 KiB
Python

import math
from .base import LottieObject, LottieProp, LottieEnum, NVector
from .properties import Value, MultiDimensional, GradientColors, ShapeProperty, Bezier, ColorValue
from .color import Color
from .helpers import Transform
class BoundingBox:
"""!
Shape bounding box
"""
def __init__(self, x1=None, y1=None, x2=None, y2=None):
self.x1 = x1
self.y1 = y1
self.x2 = x2
self.y2 = y2
def include(self, x, y):
"""!
Expands the box to include the point at x, y
"""
if x is not None:
if self.x1 is None or self.x1 > x:
self.x1 = x
if self.x2 is None or self.x2 < x:
self.x2 = x
if y is not None:
if self.y1 is None or self.y1 > y:
self.y1 = y
if self.y2 is None or self.y2 < y:
self.y2 = y
def expand(self, other):
"""!
Expands the bounding box to include another bounding box
"""
self.include(other.x1, other.y1)
self.include(other.x2, other.y2)
def center(self):
"""!
Center point of the bounding box
"""
return NVector((self.x1 + self.x2) / 2, (self.y1 + self.y2) / 2)
def isnull(self):
"""!
Whether the box is default-initialized
"""
return self.x1 is None or self.y2 is None
def __repr__(self):
return "<BoundingBox [%s, %s] - [%s, %s]>" % (self.x1, self.y1, self.x2, self.y2)
@property
def width(self):
if self.isnull():
return 0
return self.x2 - self.x1
@property
def height(self):
if self.isnull():
return 0
return self.y2 - self.y1
def size(self):
return NVector(self.width, self.height)
## @ingroup Lottie
class ShapeElement(LottieObject):
"""!
Base class for all elements of ShapeLayer and Group
"""
_props = [
#LottieProp("match_name", "mn", str, False),
LottieProp("hidden", "hd", bool, False),
LottieProp("name", "nm", str, False),
LottieProp("type", "ty", str, False),
LottieProp("property_index", "cix", int, False),
LottieProp("bm", "bm", int, False),
]
## %Shape type.
type = None
_shape_classses = None
def __init__(self):
# After Effect's Match Name. Used for expressions.
#self.match_name = ""
## After Effect's Name. Used for expressions.
self.name = None
## Property index
self.property_index = None
## Hide element
self.hidden = None
## @todo figure out?
self.bm = None
def bounding_box(self, time=0):
"""!
Bounding box of the shape element at the given time
"""
return BoundingBox()
@classmethod
def _load_get_class(cls, lottiedict):
if not ShapeElement._shape_classses:
ShapeElement._shape_classses = {}
ShapeElement._load_sub(ShapeElement._shape_classses)
return ShapeElement._shape_classses[lottiedict["ty"]]
@classmethod
def _load_sub(cls, dict):
for sc in cls.__subclasses__():
if sc.type:
dict[sc.type] = sc
sc._load_sub(dict)
def __str__(self):
return self.name or super().__str__()
## @ingroup Lottie
class Shape(ShapeElement):
"""!
Drawable shape
"""
_props = [
LottieProp("direction", "d", float, False),
]
def __init__(self):
ShapeElement.__init__(self)
## After Effect's Direction. Direction how the shape is drawn. Used for trim path for example.
self.direction = 1
def to_bezier(self):
"""!
Returns a Path corresponding to this Shape
"""
raise NotImplementedError()
## @ingroup Lottie
class Rect(Shape):
"""!
A simple rectangle shape
"""
_props = [
LottieProp("position", "p", MultiDimensional, False),
LottieProp("size", "s", MultiDimensional, False),
LottieProp("rounded", "r", Value, False),
]
## %Shape type.
type = "rc"
def __init__(self, pos=None, size=None, rounded=0):
Shape.__init__(self)
## Rect's position
self.position = MultiDimensional(pos or NVector(0, 0))
## Rect's size
self.size = MultiDimensional(size or NVector(0, 0))
## Rect's rounded corners
self.rounded = Value(rounded)
def bounding_box(self, time=0):
pos = self.position.get_value(time)
sz = self.size.get_value(time)
return BoundingBox(
pos[0] - sz[0]/2,
pos[1] - sz[1]/2,
pos[0] + sz[0]/2,
pos[1] + sz[1]/2,
)
def to_bezier(self):
"""!
Returns a Shape corresponding to this rect
"""
shape = Path()
kft = set()
if self.position.animated:
kft |= set(kf.time for kf in self.position.keyframes)
if self.size.animated:
kft |= set(kf.time for kf in self.size.keyframes)
if self.rounded.animated:
kft |= set(kf.time for kf in self.rounded.keyframes)
if not kft:
shape.shape.value = self._bezier_t(0)
else:
for time in sorted(kft):
shape.shape.add_keyframe(time, self._bezier_t(time))
return shape
def _bezier_t(self, time):
bezier = Bezier()
bb = self.bounding_box(time)
rounded = self.rounded.get_value(time)
tl = NVector(bb.x1, bb.y1)
tr = NVector(bb.x2, bb.y1)
br = NVector(bb.x2, bb.y2)
bl = NVector(bb.x1, bb.y2)
if not self.rounded.animated and rounded == 0:
bezier.add_point(tl)
bezier.add_point(tr)
bezier.add_point(br)
bezier.add_point(bl)
else:
hh = NVector(rounded/2, 0)
vh = NVector(0, rounded/2)
hd = NVector(rounded, 0)
vd = NVector(0, rounded)
bezier.add_point(tl+vd, outp=-vh)
bezier.add_point(tl+hd, -hh)
bezier.add_point(tr-hd, outp=hh)
bezier.add_point(tr+vd, -vh)
bezier.add_point(br-vd, outp=vh)
bezier.add_point(br-hd, hh)
bezier.add_point(bl+hd, outp=-hh)
bezier.add_point(bl-vd, vh)
bezier.close()
return bezier
## @ingroup Lottie
class StarType(LottieEnum):
Star = 1
Polygon = 2
## @ingroup Lottie
class Star(Shape):
"""!
Star shape
"""
_props = [
LottieProp("position", "p", MultiDimensional, False),
LottieProp("inner_radius", "ir", Value, False),
LottieProp("inner_roundness", "is", Value, False),
LottieProp("outer_radius", "or", Value, False),
LottieProp("outer_roundness", "os", Value, False),
LottieProp("rotation", "r", Value, False),
LottieProp("points", "pt", Value, False),
LottieProp("star_type", "sy", StarType, False),
]
## %Shape type.
type = "sr"
def __init__(self):
Shape.__init__(self)
## Star's position
self.position = MultiDimensional(NVector(0, 0))
## Star's inner radius. (Star only)
self.inner_radius = Value()
## Star's inner roundness. (Star only)
self.inner_roundness = Value()
## Star's outer radius.
self.outer_radius = Value()
## Star's outer roundness.
self.outer_roundness = Value()
## Star's rotation.
self.rotation = Value()
## Star's number of points.
self.points = Value(5)
## Star's type. Polygon or Star.
self.star_type = StarType.Star
def bounding_box(self, time=0):
pos = self.position.get_value(time)
r = self.outer_radius.get_value(time)
return BoundingBox(
pos[0] - r,
pos[1] - r,
pos[0] + r,
pos[1] + r,
)
def to_bezier(self):
"""!
Returns a Shape corresponding to this star
"""
shape = Path()
kft = set()
if self.position.animated:
kft |= set(kf.time for kf in self.position.keyframes)
if self.inner_radius.animated:
kft |= set(kf.time for kf in self.inner_radius.keyframes)
if self.inner_roundness.animated:
kft |= set(kf.time for kf in self.inner_roundness.keyframes)
if self.points.animated:
kft |= set(kf.time for kf in self.points.keyframes)
if self.rotation.animated:
kft |= set(kf.time for kf in self.rotation.keyframes)
# TODO inner_roundness / outer_roundness
if not kft:
shape.shape.value = self._bezier_t(0)
else:
for time in sorted(kft):
shape.shape.add_keyframe(time, self._bezier_t(time))
return shape
def _bezier_t(self, time):
bezier = Bezier()
pos = self.position.get_value(time)
r1 = self.inner_radius.get_value(time)
r2 = self.outer_radius.get_value(time)
rot = -(self.rotation.get_value(time)) * math.pi / 180 + math.pi
p = self.points.get_value(time)
halfd = -math.pi / p
for i in range(int(p)):
main_angle = rot + i * halfd * 2
dx = r2 * math.sin(main_angle)
dy = r2 * math.cos(main_angle)
bezier.add_point(NVector(pos.x + dx, pos.y + dy))
if self.star_type == StarType.Star:
dx = r1 * math.sin(main_angle+halfd)
dy = r1 * math.cos(main_angle+halfd)
bezier.add_point(NVector(pos.x + dx, pos.y + dy))
bezier.close()
return bezier
## @ingroup Lottie
class Ellipse(Shape):
"""!
Ellipse shape
"""
_props = [
LottieProp("position", "p", MultiDimensional, False),
LottieProp("size", "s", MultiDimensional, False),
]
## %Shape type.
type = "el"
def __init__(self, position=None, size=None):
Shape.__init__(self)
## Ellipse's position
self.position = MultiDimensional(position or NVector(0, 0))
## Ellipse's size
self.size = MultiDimensional(size or NVector(0, 0))
def bounding_box(self, time=0):
pos = self.position.get_value(time)
sz = self.size.get_value(time)
return BoundingBox(
pos[0] - sz[0]/2,
pos[1] - sz[1]/2,
pos[0] + sz[0]/2,
pos[1] + sz[1]/2,
)
def to_bezier(self):
"""!
Returns a Shape corresponding to this ellipse
"""
shape = Path()
kft = set()
if self.position.animated:
kft |= set(kf.time for kf in self.position.keyframes)
if self.size.animated:
kft |= set(kf.time for kf in self.size.keyframes)
if not kft:
shape.shape.value = self._bezier_t(0)
else:
for time in sorted(kft):
shape.shape.add_keyframe(time, self._bezier_t(time))
return shape
def _bezier_t(self, time):
from ..utils.ellipse import Ellipse as EllipseConverter
bezier = Bezier()
position = self.position.get_value(time)
radii = self.size.get_value(time) / 2
el = EllipseConverter(position, radii, 0)
points = el.to_bezier(0, math.pi*2)
for point in points[1:]:
bezier.add_point(point.vertex, point.in_tangent, point.out_tangent)
bezier.close()
return bezier
## @ingroup Lottie
class Path(Shape):
"""!
Animatable Bezier curve
"""
_props = [
LottieProp("shape", "ks", ShapeProperty, False),
LottieProp("index", "ind", int, False),
]
## %Shape type.
type = "sh"
def __init__(self, bezier=None):
Shape.__init__(self)
## Shape's vertices
self.shape = ShapeProperty(bezier or Bezier())
## @todo Index?
self.index = None
def bounding_box(self, time=0):
pos = self.shape.get_value(time)
bb = BoundingBox()
for v in pos.vertices:
bb.include(*v)
return bb
def to_bezier(self):
return self.clone()
## @ingroup Lottie
class Group(ShapeElement):
"""!
ShapeElement that can contain other shapes
@note Shapes inside the same group will create "holes" in other shapes
"""
_props = [
LottieProp("number_of_properties", "np", float, False),
LottieProp("shapes", "it", ShapeElement, True),
]
## %Shape type.
type = "gr"
def __init__(self):
ShapeElement.__init__(self)
## Group number of properties. Used for expressions.
self.number_of_properties = None
## Group list of items
self.shapes = [TransformShape()]
@property
def transform(self):
return self.shapes[-1]
def bounding_box(self, time=0):
bb = BoundingBox()
for v in self.shapes:
bb.expand(v.bounding_box(time))
if not bb.isnull():
mat = self.transform.to_matrix(time)
points = [
mat.apply(NVector(bb.x1, bb.y1)),
mat.apply(NVector(bb.x1, bb.y2)),
mat.apply(NVector(bb.x2, bb.y2)),
mat.apply(NVector(bb.x2, bb.y1)),
]
x1 = min(p.x for p in points)
x2 = max(p.x for p in points)
y1 = min(p.y for p in points)
y2 = max(p.y for p in points)
return BoundingBox(x1, y1, x2, y2)
return bb
def add_shape(self, shape):
self.shapes.insert(-1, shape)
return shape
def insert_shape(self, index, shape):
self.shapes.insert(index, shape)
return shape
@classmethod
def load(cls, lottiedict):
object = ShapeElement.load(lottiedict)
shapes = []
transform = None
for obj in object.shapes:
if isinstance(obj, TransformShape):
if not transform:
transform = obj
else:
shapes.append(obj)
object.shapes = shapes
object.shapes.append(transform)
return object
## @ingroup Lottie
class FillRule(LottieEnum):
NonZero = 1
EvenOdd = 2
## @ingroup Lottie
class Fill(ShapeElement):
"""!
Solid fill color
"""
_props = [
LottieProp("opacity", "o", Value, False),
LottieProp("color", "c", ColorValue, False),
LottieProp("fill_rule", "r", FillRule, False),
]
## %Shape type.
type = "fl"
def __init__(self, color=None):
ShapeElement.__init__(self)
## Fill Opacity
self.opacity = Value(100)
## Fill Color
self.color = ColorValue(color or Color(1, 1, 1))
## Fill rule
self.fill_rule = None
## @ingroup Lottie
class GradientType(LottieEnum):
Linear = 1
Radial = 2
## @ingroup Lottie
class Gradient(LottieObject):
_props = [
LottieProp("start_point", "s", MultiDimensional, False),
LottieProp("end_point", "e", MultiDimensional, False),
LottieProp("gradient_type", "t", GradientType, False),
LottieProp("highlight_length", "h", Value, False),
LottieProp("highlight_angle", "a", Value, False),
LottieProp("colors", "g", GradientColors, False),
]
def __init__(self, colors=[]):
## Fill Opacity
self.opacity = Value(100)
## Gradient Start Point
self.start_point = MultiDimensional(NVector(0, 0))
## Gradient End Point
self.end_point = MultiDimensional(NVector(0, 0))
## Gradient Type
self.gradient_type = GradientType.Linear
## Gradient Highlight Length. Only if type is Radial
self.highlight_length = Value()
## Highlight Angle. Only if type is Radial
self.highlight_angle = Value()
## Gradient Colors
self.colors = GradientColors(colors)
## @ingroup Lottie
class GradientFill(ShapeElement, Gradient):
"""!
Gradient fill
"""
_props = [
LottieProp("opacity", "o", Value, False),
LottieProp("fill_rule", "r", FillRule, False),
]
## %Shape type.
type = "gf"
def __init__(self, colors=[]):
ShapeElement.__init__(self)
Gradient.__init__(self, colors)
## Fill Opacity
self.opacity = Value(100)
## Fill rule
self.fill_rule = None
## @ingroup Lottie
class LineJoin(LottieEnum):
Miter = 1
Round = 2
Bevel = 3
## @ingroup Lottie
class LineCap(LottieEnum):
Butt = 1
Round = 2
Square = 3
## @ingroup Lottie
class StrokeDashType(LottieEnum):
Dash = "d"
Gap = "g"
Offset = "o"
## @ingroup Lottie
class StrokeDash(LottieObject):
_props = [
LottieProp("name", "nm", str, False),
LottieProp("type", "n", StrokeDashType, False),
LottieProp("length", "v", Value, False),
]
def __init__(self, length=0, type=StrokeDashType.Dash):
self.name = type.name.lower()
self.type = type
self.length = Value(length)
def __str__(self):
return self.name or super().__str__()
## @ingroup Lottie
class BaseStroke(LottieObject):
_props = [
LottieProp("line_cap", "lc", LineCap, False),
LottieProp("line_join", "lj", LineJoin, False),
LottieProp("miter_limit", "ml", float, False),
LottieProp("opacity", "o", Value, False),
LottieProp("width", "w", Value, False),
LottieProp("dashes", "d", StrokeDash, True),
]
def __init__(self, width=1):
## Stroke Line Cap
self.line_cap = LineCap.Round
## Stroke Line Join
self.line_join = LineJoin.Round
## Stroke Miter Limit. Only if Line Join is set to Miter.
self.miter_limit = 0
## Stroke Opacity
self.opacity = Value(100)
## Stroke Width
self.width = Value(width)
## Dashes
self.dashes = None
## @ingroup Lottie
class Stroke(ShapeElement, BaseStroke):
"""!
Solid stroke
"""
_props = [
LottieProp("color", "c", MultiDimensional, False),
]
## %Shape type.
type = "st"
def __init__(self, color=None, width=1):
ShapeElement.__init__(self)
BaseStroke.__init__(self, width)
## Stroke Color
self.color = ColorValue(color or Color(0, 0, 0))
## @ingroup Lottie
class GradientStroke(ShapeElement, BaseStroke, Gradient):
"""!
Gradient stroke
"""
## %Shape type.
type = "gs"
def __init__(self, stroke_width=1):
ShapeElement.__init__(self)
BaseStroke.__init__(self, stroke_width)
Gradient.__init__(self)
def bounding_box(self, time=0):
return BoundingBox()
## @ingroup Lottie
class TransformShape(ShapeElement, Transform):
"""!
Group transform
"""
## %Shape type.
type = "tr"
def __init__(self):
ShapeElement.__init__(self)
Transform.__init__(self)
self.anchor_point = MultiDimensional(NVector(0, 0))
## @ingroup Lottie
class Composite(LottieEnum):
Above = 1
Below = 2
## @ingroup Lottie
class RepeaterTransform(Transform):
_props = [
LottieProp("start_opacity", "so", Value, False),
LottieProp("end_opacity", "eo", Value, False),
]
def __init__(self):
Transform.__init__(self)
self.start_opacity = Value(100)
self.end_opacity = Value(100)
## @ingroup Lottie
class Modifier(ShapeElement):
pass
## @ingroup Lottie
class TrimMultipleShapes(LottieEnum):
Simultaneously = 1
Individually = 2
## @ingroup Lottie
## @todo Implement SIF Export
class Trim(Modifier):
"""
Trims shapes into a segment
"""
_props = [
LottieProp("start", "s", Value, False),
LottieProp("end", "e", Value, False),
LottieProp("offset", "o", Value, False),
LottieProp("multiple", "m", TrimMultipleShapes, False),
]
## %Shape type.
type = "tm"
def __init__(self):
ShapeElement.__init__(self)
## Start of the segment, as a percentage
self.start = Value(0)
## End of the segment, as a percentage
self.end = Value(100)
## start/end offset, as an angle (0, 360)
self.offset = Value(0)
## @todo?
self.multiple = None
## @ingroup Lottie
class Repeater(Modifier):
"""
Duplicates previous shapes in a group
"""
_props = [
LottieProp("copies", "c", Value, False),
LottieProp("offset", "o", Value, False),
LottieProp("composite", "m", Composite, False),
LottieProp("transform", "tr", RepeaterTransform, False),
]
## %Shape type.
type = "rp"
def __init__(self, copies=1):
Modifier.__init__(self)
## Number of Copies
self.copies = Value(copies)
## Offset of Copies
self.offset = Value()
## Composite of copies
self.composite = Composite.Above
## Transform values for each repeater copy
self.transform = RepeaterTransform()
## @ingroup Lottie
## @todo Implement SIF Export
class RoundedCorners(Modifier):
"""
Rounds corners of other shapes
"""
_props = [
LottieProp("radius", "r", Value, False),
]
## %Shape type.
type = "rd"
def __init__(self):
Modifier.__init__(self)
## Rounded Corner Radius
self.radius = Value()
## @ingroup Lottie
## @ingroup LottieCheck
## @note marked as unsupported by lottie
class Merge(ShapeElement):
_props = [
LottieProp("merge_mode", "mm", float, False),
]
## %Shape type.
type = "mm"
def __init__(self):
ShapeElement.__init__(self)
## Merge Mode
self.merge_mode = 1
## @ingroup Lottie
## @note marked as unsupported by lottie
class Twist(ShapeElement):
_props = [
LottieProp("angle", "a", Value, False),
LottieProp("center", "c", MultiDimensional, False),
]
## %Shape type.
type = "tw"
def __init__(self):
ShapeElement.__init__(self)
self.angle = Value(0)
self.center = MultiDimensional(NVector(0, 0))
class ZigZag(ShapeElement):
"""
Zig Zag shape modifier
"""
_props = [
LottieProp("frequency", "r", Value, False),
LottieProp("amplitude", "s", Value, False),
LottieProp("point_type", "pt", Value, False),
]
## %Shape type.
type = "zz"
def __init__(self):
ShapeElement.__init__(self)
## Number of ridges per segment
self.frequency = Value(5)
## Distance between peaks and troughs
self.amplitude = Value(10)
## Point type (1 = corner, 2 = smooth)
self.point_type = Value(1)