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
@@ -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); g@@ -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]