collective

Browse / github.com/storytold/effectcraft / patch

Lottie: import every Dash/Gap pair of a stroke, not only the first dash and the last gap

Lottie: a stroke's second and third Dash/Gap pairs are lost on import, and Gap takes the last gap A shape stroke with more than one Dash/Gap pair (added with the Dashes "+" button) does not survive a Lottie export and re-import: Dash 2/Gap 2 and Dash 3/Gap 3 disappear, and Gap comes back with the value of the last gap in the list. The same happens with any Lottie file that has several pairs in a stroke's `d` array (`d`, `g`, `d`, `g`, `o`), as bodymovin writes them. Repro: on a shape layer's stroke set Dash 10, Gap 5, click "+" and set Dash 2 20, Gap 2 3. Export as Lottie and import the file. The stroke comes back with Dash 10, Gap 3 and no Dash 2/Gap 2. Expected: the four values as they

Status
open
Score
0.00
Adopters
0 other operators
Author
marcoarnulfo (imported from pull request #2302, open)
Base
eb5108786b90bf994a6abe4e6c0b2614c6aeaa29
Size
+51 −12 in 2 files
Review
pending
Shared
2026-10-28
Id
283f045c5062c1e43da0ec86

How it went

No other operator has reported using it yet.

Diff

raw · crates/lottie/src/shapes.rs crates/lottie/src/tests.rs

diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs--- a/crates/lottie/src/shapes.rs+++ b/crates/lottie/src/shapes.rs@@ -3,8 +3,8 @@  use effectcraft_color::BlendMode; use effectcraft_keyframe::{Gradient, ShapePath, Value};-use effectcraft_project::build::{self, Ids};-use effectcraft_project::{Node, PropGroup};+use effectcraft_project::build::{self, EXTRA_DASHES, Ids};+use effectcraft_project::{Node, ParamUi, PropGroup}; use serde_json::{Map, Value as Json, json};  use crate::anim::{self, Ex, Im, export_prop, import_prop};@@ -356,7 +356,7 @@ fn import_blend(j: &Json, g: &mut PropGroup) {     } } -fn import_stroke(im: &Im, j: &Json, g: &mut PropGroup) {+fn import_stroke(im: &Im, ids: &mut Ids, j: &Json, g: &mut PropGroup) {     set(im, g, "width", j.get("w"), &anim::to_scalar);     if let Some(lc) = int(j, "lc") {         set_enum(g, "cap", lc.saturating_sub(1).clamp(0, 2) as u32);@@ -374,17 +374,32 @@ fn import_stroke(im: &Im, j: &Json, g: &mut PropGroup) {     if let Some(list) = j.get("d").and_then(Json::as_array)         && let Some(d) = g.sub_mut("dashes")     {-        let mut seen_dash = false;+        // The n-th `d` and `g` are the n-th Dash/Gap pair; the extra pairs are created as the Dashes "+" does.+        let (mut dashes, mut gaps) = (0, 0);         for e in list {-            let m = match e.get("n").and_then(Json::as_str) {-                Some("d") if !seen_dash => {-                    seen_dash = true;-                    "dash"+            let (n, pair) = match e.get("n").and_then(Json::as_str) {+                Some("d") => (&mut dashes, 0),+                Some("g") => (&mut gaps, 1),+                Some("o") => {+                    set(im, d, "offset", e.get("v"), &anim::to_scalar);+                    continue;                 }-                Some("g") => "gap",-                Some("o") => "offset",                 _ => continue,             };+            let k = *n;+            *n += 1;+            let m = match k {+                0 => ["dash", "gap"][pair],+                _ => {+                    let Some(&(dm, gm)) = EXTRA_DASHES.get(k - 1) else { continue };+                    if d.get(dm).is_none() {+                        let at = d.children.iter().position(|c| c.match_id() == "offset").unwrap_or(d.children.len());+                        d.children.insert(at, ids.prop(gm, &format!("Gap {}", k + 1), Value::Scalar(0.0)).with_ui(ParamUi::Pixels).into());+                        d.children.insert(at, ids.prop(dm, &format!("Dash {}", k + 1), Value::Scalar(0.0)).with_ui(ParamUi::Pixels).into());+                    }+                    [dm, gm][pair]+                }+            };             set(im, d, m, e.get("v"), &anim::to_scalar);         }     }@@ -474,7 +489,7 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr                 let mut g = build::shape_stroke(ids, [1.0, 1.0, 1.0, 1.0], 2.0);                 set(im, &mut g, "color", j.get("c"), &anim::to_color);                 set(im, &mut g, "opacity", j.get("o"), &anim::to_scalar);-                import_stroke(im, j, &mut g);+                import_stroke(im, ids, j, &mut g);                 import_blend(j, &mut g);                 g             }@@ -501,7 +516,7 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr                         set_enum(&mut g, "rule", 1);                     }                 } else {-                    import_stroke(im, j, &mut g);+                    import_stroke(im, ids, j, &mut g);                 }                 import_blend(j, &mut g);                 gdiff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs--- a/crates/lottie/src/tests.rs+++ b/crates/lottie/src/tests.rs@@ -407,6 +407,30 @@ fn offset_paths_miter_uses_animatable_lottie_value() {     assert_eq!(second.get("miter").unwrap().value, Value::Scalar(6.0)); } +#[test]+fn extra_dash_gap_pairs_survive_a_roundtrip() {+    let (mut p, cid) = setup();+    let l = shape_layer(&mut p, cid, |ids| {+        let mut stroke = build::shape_stroke(ids, [0.0, 0.0, 1.0, 1.0], 3.0);+        let d = stroke.sub_mut("dashes").unwrap();+        d.get_mut("dash").unwrap().value = Value::Scalar(10.0);+        d.get_mut("gap").unwrap().value = Value::Scalar(5.0);+        let at = d.children.iter().position(|n| n.match_id() == "offset").unwrap();+        d.children.insert(at, ids.prop("gap2", "Gap 2", Value::Scalar(3.0)).into());+        d.children.insert(at, ids.prop("dash2", "Dash 2", Value::Scalar(20.0)).into());+        let rect = build::shape_rect(ids, [60.0, 40.0], [0.0, 0.0], 0.0);+        vec![build::shape_group(ids, "G", vec![rect, stroke])]+    });+    push(&mut p, cid, l);+    let (q, nc, _, _) = roundtrip(&p, cid, &ExportOptions::default());+    let c = q.comp(nc).unwrap();+    let g = c.layers[0].props.sub("contents").unwrap().groups().next().unwrap();+    let stroke = g.sub("contents").unwrap().groups().find(|g| g.match_id == "stroke").unwrap();+    let d = stroke.sub("dashes").unwrap();+    let v = |m: &str| d.get(m).map(|p| p.value.as_f64());+    assert_eq!((v("dash"), v("gap"), v("dash2"), v("gap2")), (Some(10.0), Some(5.0), Some(20.0), Some(3.0)));+}+ // ---------------------------------------------------------------- layers  #[test]