# Shapes The graphical elements are divided in 4 categories: * [Shapes](#shape) that define the actual curves but have no styling information * [Grouping](#grouping), used to organize collections of graphic elements * [Styles](#shape-style), that define the visual appearance of shapes * [Modifiers](#modifier) alter the curves of the shapes ## Shape Rendering Model ### Grouping and Ordering Rules * **Shapes** are rendered in reverse order, bottom->top. Shapes at the beginning of the array are rendered on top of shapes with larger indices. * **Groups** offer a scoping mechanism for transforms, styles, modifiers, and shapes. All group children, including sub-groups and their children, are considered part of the group's scope. * **Transforms** adjust the coordinate system for all elements within their group, and transitively for all other group-nested elements. * **Styles** and **modifiers** apply to all preceding shapes within the current scope, including subgroup-nested shapes. * When **multiple styles** apply to the same shape, the shape is rendered repeatedly for each style, in reverse order. * When **multiple modifiers** apply to the same shape, they are composed in reverse order (e.g. $Trim(Trim(shape))$). * When **multiple transforms** apply to the same shape due to scope nesting, they compose in group nesting order (transforms are additive). * **Group opacity** (property of the group transform) applies atomically to all elements in scope - i.e. opacity applies to the result of compositing all group content, and not to individual elements. More formally: * for each $(shape, style)$ tuple, where $Index(shape) < Index(style)$ and $shape \in Scope(style)$: * for each $modifier$, in increasing index order, where $Index(shape) < Index(modifier)$ and $shape \in Scope(modifier)$: * $shape = modifier(shape)$ * compute the total shape transformation by composing all transforms within the shape scope chain: $T_{shape} = \prod_{n=0}^{Scope(shape)} Transform(scope_n)$ * compute the total style transformation by composing all transforms within the style scope chain: $T_{style} = \prod_{n=0}^{Scope(style)} Transform(scope_n)$ * $Render(shape \times T_{shape}, style \times T_{style})$ ### Notes 1. Certain modifier operations (e.g. sequential $Trim$) may require information about shapes from different groups, thus $Render()$ calls cannot always be issued based on single-pass local knowledge. 2. Transforms can affect both shapes and styles (e.g. stroke width). For a given $(shape, style)$, the shape and style transforms are not necessarily equal. 3. Shapes without an applicable style are not rendered. 4. This rendering model is based on AfterEffects' Shape Layer semantics. ## Rendering Convention Shapes defined in this section contain rendering instructions. These instructions are used to generate the path as a bezier curve. Implementations MAY use different algorithms or primitives to render the shapes but the result MUST be equivalent to the paths defined here. Some instructions define named values for clarity and illustrative purposes, implementations are not required to have them explicitly defined in their rendering process. When referencing animated properties, the rendering instruction will use the same name as in the JSON but it's assumed they refer to their value at a given point in time rather than the property itself. For {link:values/vector} values, $value.x$ and $value.y$ in the instructions are equivalent to `value[0]` and `value[1]` respectively. All paths MUST be closed unless specified otherwise in the rendering instructions. When instructions call for an equality comparison between two values, implementations MAY consider similar values to be equal to overcome numerical instability. ### Bezier Conversions This documents includes algorithms to convert parametric shapes into bezier curves. Implementations MAY use different implementations than the algorithms provided here but the output shape MUST be visually indistinguishable from the output of these algorithms. Furthermore, when drawing individual shapes the stroke order and direction is not importand but implementations of Trim Path MUST follow the stroke order as defined by these algorithms. Drawing instructions will contain the following commands: * _Add vertex_: Adds a vertex to the bezier shape in global coordinates * _Set in tangent_: Sets the cubic tangent to the last added vertex, with coordinates relative to it. If omitted, tangents MUST be $(0, 0)$. * _Set out tangent_: Sets the cubic tangent from the last added vertex, with coordinates relative to it. If omitted, tangents MUST be $(0, 0)$. ### Approximating Ellipses with Cubic Bezier An elliptical quadrant can be approximated by a cubic bezier segment with tangents of length $radius * E_t$. Where $$E_t \approx 0.5519150244935105707435627$$ See [this article](https://spencermortensen.com/articles/bezier-circle/) for the math behind it. When implementations render elliptical arcs using bezier curves, they SHOULD use this constant, a similar approximation, or elliptical arc drawing primitives.

Graphic Element

{schema_string:shapes/graphic-element/description} {schema_object:shapes/graphic-element} The `ty` property defines the specific element type based on the following values: {schema_subtype_table:shapes/all-graphic-elements:ty} Hidden shapes (`hd: True`) are ignored, and do not contribute to rendering nor modifier operations.

Shapes

{schema_string:shapes/shape/description} {schema_object:shapes/shape}

Ellipse

{schema_string:shapes/ellipse/description} {schema_object:shapes/ellipse} Example
lottie.layers[0].shapes[0].it[0]
def ellipse(shape: Bezier, p: Vector2D, s: Vector2D): # An ellipse is drawn from the top quadrant point going clockwise: radius = s / 2 tangent = radius * ELLIPSE_CONSTANT x = p.x y = p.y shape.closed = True shape.add_vertex(Vector2D(x, y - radius.y)) shape.set_in_tangent(Vector2D(-tangent.x, 0)) shape.set_out_tangent(Vector2D(tangent.x, 0)) shape.add_vertex(Vector2D(x + radius.x, y)) shape.set_in_tangent(Vector2D(0, -tangent.y)) shape.set_out_tangent(Vector2D(0, tangent.y)) shape.add_vertex(Vector2D(x, y + radius.y)) shape.set_in_tangent(Vector2D(tangent.x, 0)) shape.set_out_tangent(Vector2D(-tangent.x, 0)) shape.add_vertex(Vector2D(x - radius.x, y)) shape.set_in_tangent(Vector2D(0, tangent.y)) shape.set_out_tangent(Vector2D(0, -tangent.y)) Implementations MAY use elliptical arcs to render an ellipse. ![Ellipse rendering guide](../static/img/ellipse-guide.svg)

Rectangle

{schema_string:shapes/rectangle/description} {schema_object:shapes/rectangle} Example
lottie.layers[0].shapes[0].it[0]
Rendering algorithm: def rectangle(shape: Bezier, p: Vector2D, s: Vector2D, r: float): left: float = p.x - s.x / 2 right: float = p.x + s.x / 2 top: float = p.y - s.y / 2 bottom: float = p.y + s.y / 2 shape.closed = True if r <= 0: # The rectangle is rendered from the top-right going clockwise shape.add_vertex(Vector2D(right, top)) shape.add_vertex(Vector2D(right, bottom)) shape.add_vertex(Vector2D(left, bottom)) shape.add_vertex(Vector2D(left, top)) else: # Rounded corners must be taken into account rounded: float = min(s.x/2, s.y/2, r) tangent: float = rounded * ELLIPSE_CONSTANT shape.add_vertex(Vector2D(right, top + rounded)) shape.set_in_tangent(Vector2D(0, -tangent)) shape.add_vertex(Vector2D(right, bottom - rounded)) shape.set_out_tangent(Vector2D(0, tangent)) shape.add_vertex(Vector2D(right - rounded, bottom)) shape.set_in_tangent(Vector2D(tangent, 0)) shape.add_vertex(Vector2D(left + rounded, bottom)) shape.set_out_tangent(Vector2D(-tangent, 0)) shape.add_vertex(Vector2D(left, bottom - rounded)) shape.set_in_tangent(Vector2D(0, tangent)) shape.add_vertex(Vector2D(left, top + rounded)) shape.set_out_tangent(Vector2D(0, -tangent)) shape.add_vertex(Vector2D(left + rounded, top)) shape.set_in_tangent(Vector2D(-tangent, 0)) shape.add_vertex(Vector2D(right - rounded, top)) shape.set_out_tangent(Vector2D(tangent, 0)) ![Rectangle rendering guide](../static/img/rect-guide.svg)

Path

{schema_string:shapes/path/description} {schema_object:shapes/path} Example
lottie.layers[0].shapes[0].it[0]

PolyStar

{schema_string:shapes/polystar/description} {schema_object:shapes/polystar} Example
star-type
lottie.layers[0].shapes[0].it[0]
def polystar(shape: Bezier, p: Vector2D, pt: float, r: float, or_: float, os: float, sy: int, ir: float, is_: float): points: int = int(round(pt)) alpha: float = -r * math.pi / 180 - math.pi / 2 theta: float = -math.pi / points tan_len_out: float = (2 * math.pi * or_) / (4 * points) * (os / 100) tan_len_in: float = (2 * math.pi * ir) / (4 * points) * (is_ / 100) shape.closed = True for i in range(points): beta: float = alpha + i * theta * 2 v_out: Vector2D = Vector2D(or_ * math.cos(beta), or_ * math.sin(beta)) shape.add_vertex(p + v_out) if os != 0 and or_ != 0: # We need to add bezier tangents tan_out: Vector2D = v_out * tan_len_out / or_ shape.set_in_tangent(Vector2D(-tan_out.y, tan_out.x)) shape.set_out_tangent(Vector2D(tan_out.y, -tan_out.x)) if sy == 1: # We need to add a vertex towards the inner radius to make a star v_in: Vector2D = Vector2D(ir * math.cos(beta + theta), ir * math.sin(beta + theta)) shape.add_vertex(p + v_in) if is_ != 0 and ir != 0: # We need to add bezier tangents tan_in = v_in * tan_len_in / ir shape.set_in_tangent(Vector2D(-tan_in.y, tan_in.x)) shape.set_out_tangent(Vector2D(tan_in.y, -tan_in.x))

Grouping

Group

{schema_string:shapes/group/description} {schema_object:shapes/group} A group defines a [[render stack]], elements within a group MUST be rendered in reverse order (the first object in the list will appear on top of elements further down). 1. Apply the transform 1. Render Styles and child groups in the transformed [[local coordinates|coordinate system]].

Transform

{schema_string:shapes/transform/description} {schema_object:shapes/transform} Transform shapes MUST always be present in the group and they MUST be the last item in the `it` array. They modify the group's [[local coordinates|coordinate system]] the same way as Layer {link:helpers/transform}.

Style

{schema_string:shapes/shape-style/description} {schema_object:shapes/shape-style} Shapes styles MUST apply their style to the [[collected shapes]] that come before them in [[stacking order]].

Fill

{schema_string:shapes/fill/description} {schema_object:shapes/fill} Example
fill-rule
lottie.layers[0].shapes[0].it[1]

Stroke

{schema_string:shapes/stroke/description} {schema_object:shapes/stroke} Example
line-cap line-join
lottie.layers[0].shapes[2]

Stroke Dashes

{schema_string:shapes/stroke-dash/description} A stroke dash array consists of `n` dash entries, `[n-1,n]` gap entries and `[0-1]` offset entries. Dash and gap entries MUST all be in a continuous order and alternate between dash and gap, starting with dash. If there are an odd number of dashes + gaps, the sequence will repeat with dashes and gaps reversed. For example a sequence of `[4d, 8g, 16d]` MUST be rendered as `[4d, 8g, 16d, 4g, 8d, 16g]`. If any of the dash or gap entries are less than 0, or if the total sum of all their values is 0, the whole dash array is ignored, and the stroke MUST be treated as if the dash was not set. Offset entry, if present, MUST be at the end of the array. {schema_object:shapes/stroke-dash} Example
line-cap line-join
lottie.layers[0].shapes[2]

Gradient Fill

{schema_string:shapes/gradient-fill/description} {schema_object:shapes/gradient-fill} Example
gradient-type
lottie.layers[1].shapes[0].it[1]

Gradient Stroke

{schema_string:shapes/gradient-stroke/description} {schema_object:shapes/gradient-stroke} Example
gradient-type
lottie.layers[1].shapes[1]

Modifiers

{schema_string:shapes/modifier/description} Modifiers replace shapes in the [[render stack]] by applying operating on the bezier path of to the [[collected shapes]] that come before it in [[stacking order]].

Trim Path

{schema_string:shapes/trim-path/description} {schema_object:shapes/trim-path}
trim-multiple-shapes
lottie.layers[0].shapes[4]
When rendering trim path, the order of bezier points MUST be the same as rendering instructions given for each shape in this section. Rendering trim path can be rather complex. Given $$ \begin{align*} offset & = \begin{cases} \frac{o}{360} - \lfloor \frac{o}{360} \rfloor & o \ge 0 \\ \frac{o}{360} - \lceil \frac{o}{360} \rceil & o < 0 \end{cases} \\ start & = offset + \min\left(1, \max\left(0, \frac{\min(s, e)}{100}\right)\right) \\ end & = offset + \min\left(1, \max\left(0, \frac{\max(s, e)}{100}\right)\right) \\ \end{align*} $$ If $s$ and $e$ are equal, implementations MUST NOT render any shapes. If $s = 0$ and $e = 1$, the input shape MUST be rendered as-is. To render trim path, implementations MUST consider the actual length of each shape (they MAY use approximations). Once the shapes are collected, the segment to render is given by the percentages $start$ and $end$. When trim path is applied to multiple shapes, the `m` property MUST be considered when applying the modifier: * When `m` has a value of `1` (Parallel), each shape MUST be considered separately, $start$ and $end$ being applied to each shape. * When `m` has a value of `2` (Sequential), all the shapes MUST be considered as following each other in render order. $start$ and $end$ refer to the whole length created by concatenating each shape.

Rounded Corners

{schema_string:shapes/rounded-corners/description} {schema_object:shapes/rounded-corners} This modifier looks at the underlying shape, at each vertex it checks if the two segments before and after the vertex are linear. A segment is linear if the tangents are lay on the line between the two vertices. Implementations MUST take into account floating point inaccuracies when making this determination. If both bezier segmets around the vertex are linear, the initial vertex is replaced by two vertices `r` distance away from the original along the corresponding segment. These new vertices MUST merge with existing vertices if the line segments are shorter than the `r` distance. The new vertices have the tangents towards the old initial vertex whose lengths are the distance to said vertex multiplied by the elliptical constant $E_t$. The first and last vertex in an open path MUST NOT be affected. If `r` is 0, the modifier MUST keep the target shapes unchanged. Follows a guide on how the modifier works: ![Rounded corners rendering guide](../static/img/rounded-corners-guide.svg) Note that $a - v_i$ is generally equal to `r` but it might not be in the case where the line segment is shorter than `r`.
lottie.layers[0].shapes[0].it[1]

Pucker / Bloat

{schema_string:shapes/pucker-bloat/description} {schema_object:shapes/pucker-bloat} When `a` is `0`, nothing changes.
When `a` is positive, the vertices are pulled towards the center, with `100` being at the center. And the tangents are pushed away.
When `a` is negative the vertices are pushed away from the center with `100` being twice far away from the center. And the tangents are pulled towards the center.
The center is defined as the mean of the bezier vertices.
lottie.layers[0].shapes[0].it[1]
def pucker_bloat(shape: Bezier, a: float) -> Bezier: shape_out: Bezier = Bezier() amount: float = a / 100 if len(shape) == 0: return shape # Find the center (arithmetic mean of all the points) center: Vector2D = Vector2D(0, 0) for point in shape: center += point.vertex center /= len(shape) # Move the points for point in shape: vertex: Vector2D = lerp(point.vertex, center, amount) shape_out.add_vertex(vertex) shape_out.set_in_tangent(lerp(point.absolute.in_tangent, center, -amount) - vertex) shape_out.set_out_tangent(lerp(point.absolute.out_tangent, center, -amount) - vertex) shape_out.closed = shape.closed return shape_out