Files

848 lines
22 KiB
Python
Raw Permalink Normal View History

2026-03-01 21:36:54 +08:00
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)