From bbaee1fa0fcb537a6811e21002df2021552c76b9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 23:22:53 -0400 Subject: [PATCH 01/10] M13.5: stroke taper/wave/multi-dashes, radial gradient highlight, variable-width mask feather, adaptive motion-blur samples Shapes (SHP-2): Stroke Taper (pixels/percent start/end length, width, ease) and Wave (amount, pixels/cycles, wavelength, phase) rendered as a variable-width outline; Dash 2/Gap 2/Dash 3/Gap 3 (shape.dashes.add/remove, timeline +/- buttons); radial gradient Highlight Length/Angle as a focal gradient. Masks (MSK-1): mask feather points on the Mask Path (mask.featherPoint.*, Mask Feather tool add/drag), per-point signed radius and tension, rendered from the signed distance to the path; carried through vertex edits. Motion blur (LYR-10): Adaptive Sample Limit comp setting; 2D layers take one sample per pixel of shutter travel between Samples Per Frame and the limit. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/comp.rs | 7 +- crates/engine/src/commands/mask.rs | 214 +++++++++- crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/shape_stroke.rs | 221 ++++++++++ crates/engine/src/commands/viewer_cmds.rs | 2 +- crates/engine/src/lib.rs | 2 + crates/engine/src/mask_track.rs | 1 + crates/engine/src/tests_tail.rs | 221 ++++++++++ crates/engine/src/tests_viewer.rs | 1 + crates/expr/src/runtime.rs | 8 +- crates/keyframe/src/lib.rs | 2 +- crates/keyframe/src/value.rs | 90 ++++- crates/lottie/src/shapes.rs | 9 +- crates/lottie/src/tests.rs | 1 + crates/path/src/feather.rs | 268 +++++++++++++ crates/path/src/interp.rs | 15 +- crates/path/src/lib.rs | 26 +- crates/path/src/varstroke.rs | 399 +++++++++++++++++++ crates/project/src/build.rs | 29 ++ crates/project/src/lib.rs | 10 + crates/project/src/props.rs | 7 + crates/render/src/lib.rs | 62 ++- crates/render/src/masks.rs | 13 +- crates/render/src/shapes.rs | 95 ++++- crates/render/src/shapes/tests.rs | 84 ++++ crates/render/src/text/tests.rs | 2 +- crates/script/src/matchnames.rs | 2 + crates/script/src/model.rs | 1 + crates/ui-egui/src/panels/comp_settings.rs | 7 +- crates/ui-egui/src/panels/dialogs.rs | 1 + crates/ui-egui/src/panels/timeline.rs | 32 ++ crates/ui-egui/src/panels/viewer_overlays.rs | 93 ++++- crates/ui-egui/tests/ui_viewer.rs | 38 ++ 33 files changed, 1915 insertions(+), 50 deletions(-) create mode 100644 crates/engine/src/commands/shape_stroke.rs create mode 100644 crates/engine/src/tests_tail.rs create mode 100644 crates/path/src/feather.rs create mode 100644 crates/path/src/varstroke.rs diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index a33fd574..dec74406 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -87,6 +87,9 @@ fn apply_settings(c: &mut Comp, p: &Value) { if let Some(n) = p.get("motionBlurSamples").and_then(Value::as_u64) { c.motion_blur_samples = (n as u32).clamp(2, 64); } + if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) { + c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256); + } } fn new_comp(s: &mut Session, p: &Value) -> Result { @@ -249,7 +252,7 @@ pub fn specs() -> Vec { "New Composition...", ["Composition"], Some("Cmd+N"), - "{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, renderer? classic3D|advanced3D, anchor?, open?}", + "{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), renderer? classic3D|advanced3D, anchor?, open?}", always, new_comp ), @@ -258,7 +261,7 @@ pub fn specs() -> Vec { "Composition Settings...", ["Composition"], Some("Cmd+K"), - "{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, pixelAspect?, renderer? classic3D|advanced3D}", + "{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), pixelAspect?, renderer? classic3D|advanced3D}", has_comp, settings ), diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs index 39894b58..da1e9c33 100644 --- a/crates/engine/src/commands/mask.rs +++ b/crates/engine/src/commands/mask.rs @@ -86,7 +86,7 @@ fn new_mask(s: &mut Session, p: &Value) -> Result { let mut outs = pts(p.get("outTangents")); ins.resize(n, [0.0; 2]); outs.resize(n, [0.0; 2]); - let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) }; + let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() }; let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add); let uid = s.edit("New Mask", None, |proj, st| { let mut next = proj.next_id; @@ -114,6 +114,7 @@ fn add_vertex(s: &mut Session, p: &Value) -> Result { sp.vertices.insert(i, point); sp.in_tangents.insert(i, tin); sp.out_tangents.insert(i, tout); + sp.feather_after_insert(i, None); Ok(i) })?; s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }]; @@ -241,6 +242,7 @@ fn delete_vertices(s: &mut Session, p: &Value) -> Result { sp.vertices.remove(i); sp.in_tangents.remove(i); sp.out_tangents.remove(i); + sp.feather_after_remove(i); } } if sp.vertices.len() < 3 { @@ -324,9 +326,199 @@ pub(crate) fn split_segment(sp: &mut ShapePath, seg: usize, t: f64) -> Option Option<(usize, f64, f64)> { + let n = sp.vertices.len(); + let mut best: Option<(usize, f64, f64)> = None; + for seg in 0..sp.segment_count() { + let j = (seg + 1) % n; + let p0 = sp.vertices[seg]; + let p3 = sp.vertices[j]; + let o = sp.out_tangents.get(seg).copied().unwrap_or([0.0; 2]); + let i = sp.in_tangents.get(j).copied().unwrap_or([0.0; 2]); + let (p1, p2) = ([p0[0] + o[0], p0[1] + o[1]], [p3[0] + i[0], p3[1] + i[1]]); + let at = |t: f64| { + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + [a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]] + }; + let dist = |t: f64| { + let p = at(t); + (p[0] - q[0]).hypot(p[1] - q[1]) + }; + let (mut bt, mut bd) = (0.0, f64::INFINITY); + for k in 0..=64 { + let t = k as f64 / 64.0; + let d = dist(t); + if d < bd { + (bt, bd) = (t, d); + } + } + // Refine by bisection-style narrowing. + let mut step = 1.0 / 64.0; + for _ in 0..20 { + step *= 0.5; + for t in [bt - step, bt + step] { + let t = t.clamp(0.0, 1.0); + let d = dist(t); + if d < bd { + (bt, bd) = (t, d); + } + } + } + if best.is_none_or(|b| bd < b.2) { + best = Some((seg, bt, bd)); + } + } + best +} + +/// Whether a layer-space point is inside a closed path. +fn inside_path(sp: &ShapePath, q: [f64; 2]) -> bool { + sp.closed && { + let cov = effectcraft_path::fill_coverage( + &[effectcraft_path::to_kurbo(sp)], + &effectcraft_geom::Mat3::translate(effectcraft_geom::vec2(-q[0] + 0.5, -q[1] + 0.5)), + 1, + 1, + effectcraft_path::FillRule::NonZero, + ); + cov.data[0] >= 0.5 + } +} + +/// Point of segment `seg` at parameter `t`. +fn point_on(sp: &ShapePath, seg: usize, t: f64) -> Option<[f64; 2]> { + let n = sp.vertices.len(); + if seg >= sp.segment_count() { + return None; + } + let j = (seg + 1) % n; + let (p0, p3) = (sp.vertices[seg], sp.vertices[j]); + let o = sp.out_tangents.get(seg).copied().unwrap_or([0.0; 2]); + let i = sp.in_tangents.get(j).copied().unwrap_or([0.0; 2]); + let (p1, p2) = ([p0[0] + o[0], p0[1] + o[1]], [p3[0] + i[0], p3[1] + i[1]]); + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + Some([a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]]) +} + +fn feather_index(p: &Value, sp: &ShapePath, c: &str) -> Result { + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing `index` (feather point, 0-based)"))? as usize; + if i >= sp.feather.len() { + return Err(bad(c, format!("no feather point {i} (the mask has {})", sp.feather.len()))); + } + Ok(i) +} + +/// Mask Feather tool: add a feather point on the path, by `segment`/`t` or at the path position +/// nearest `point` (layer space). Without `radius`, a `point` off the path sets it from its +/// distance (outside the mask = outer feather, inside = inner). +fn feather_add(s: &mut Session, p: &Value) -> Result { + let c = "mask.featherPoint.add"; + let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize); + let t = super::f_p(p, "t").map(|v| v.clamp(0.0, 1.0)); + let q = pt(p.get("point")); + let radius = super::f_p(p, "radius"); + let tension = super::f_p(p, "tension").unwrap_or(0.0).clamp(0.0, 100.0) / 100.0; + let (uid, i) = edit_path(s, p, c, |sp| { + let (seg, t, r) = match (seg, q) { + (Some(seg), _) => { + if seg >= sp.segment_count() { + return Err(bad(c, format!("no segment {seg}"))); + } + (seg, t.unwrap_or(0.5), radius.unwrap_or(0.0)) + } + (None, Some(q)) => { + let (seg, t, d) = nearest_on_path(sp, q).ok_or_else(|| bad(c, "the path has no segments"))?; + (seg, t, radius.unwrap_or(if inside_path(sp, q) { -d } else { d })) + } + (None, None) => return Err(bad(c, "give `segment` (and `t`) or `point`")), + }; + sp.feather.push(effectcraft_keyframe::FeatherPoint { segment: seg, t, radius: r, tension }); + Ok(sp.feather.len() - 1) + })?; + Ok(json!({"mask": uid, "index": i})) +} + +/// Drag a feather point: move it along the path (`segment`/`t`, or nearest `point`) and/or set +/// its `radius` (signed: negative = inner) and `tension` (%). +fn feather_set(s: &mut Session, p: &Value) -> Result { + let c = "mask.featherPoint.set"; + let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize); + let t = super::f_p(p, "t").map(|v| v.clamp(0.0, 1.0)); + let q = pt(p.get("point")); + let radius = super::f_p(p, "radius"); + let tension = super::f_p(p, "tension").map(|v| v.clamp(0.0, 100.0) / 100.0); + let toward = pt(p.get("toward")); + let (_, out) = edit_path(s, p, c, |sp| { + let i = feather_index(p, sp, c)?; + if let Some(q) = q { + let (sg, tt, _) = nearest_on_path(sp, q).ok_or_else(|| bad(c, "the path has no segments"))?; + sp.feather[i].segment = sg; + sp.feather[i].t = tt; + } + if let Some(sg) = seg { + if sg >= sp.segment_count() { + return Err(bad(c, format!("no segment {sg}"))); + } + sp.feather[i].segment = sg; + } + if let Some(t) = t { + sp.feather[i].t = t; + } + if let Some(r) = radius { + sp.feather[i].radius = r; + } + // Dragging the radius handle: the distance from the point's path position to `toward`, + // outer outside the mask, inner inside. + if let Some(w) = toward { + let f = sp.feather[i]; + let at = point_on(sp, f.segment, f.t).ok_or_else(|| bad(c, "the feather point is off the path"))?; + let d = (w[0] - at[0]).hypot(w[1] - at[1]); + sp.feather[i].radius = if inside_path(sp, w) { -d } else { d }; + } + if let Some(k) = tension { + sp.feather[i].tension = k; + } + Ok(serde_json::to_value(sp.feather[i]).unwrap_or(Value::Null)) + })?; + Ok(out) +} + +fn feather_remove(s: &mut Session, p: &Value) -> Result { + let c = "mask.featherPoint.remove"; + let all = super::b_p(p, "all").unwrap_or(false); + let (_, n) = edit_path(s, p, c, |sp| { + if all { + sp.feather.clear(); + } else { + let i = feather_index(p, sp, c)?; + sp.feather.remove(i); + } + Ok(sp.feather.len()) + })?; + Ok(json!({"remaining": n})) +} + +fn feather_list(s: &mut Session, p: &Value) -> Result { + let c = "mask.featherPoint.list"; + let (cid, lid) = layer_p(s, p, c)?; + let key = p.get("mask").cloned().ok_or_else(|| bad(c, "missing `mask`"))?; + let t = s.time(); + let mut props = s.project.comp(cid).and_then(|cc| cc.layer(lid)).ok_or(EngineError::NoComp)?.props.clone(); + let lt = s.project.comp(cid).and_then(|cc| cc.layer(lid)).map(|l| l.layer_time(t)).unwrap_or(t); + let g = path_group(&mut props, &key).ok_or_else(|| bad(c, "no such mask"))?; + let Some(KV::Path(sp)) = g.get("path").map(|pr| pr.value_at(lt)) else { return Err(bad(c, "mask has no path")) }; + Ok( + json!({"points": sp.feather.iter().map(|f| json!({"segment": f.segment, "t": f.t, "radius": f.radius, "tension": f.tension * 100.0})).collect::>()}), + ) +} + /// Add Vertex tool: insert a vertex on segment `segment` at parameter `t` (default 0.5). fn insert_vertex(s: &mut Session, p: &Value) -> Result { let seg = @@ -370,6 +562,26 @@ pub fn specs() -> Vec { cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices), cmd!("mask.convertVertex", "Convert Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), index, smooth?}", has_comp, convert_vertex), cmd!("mask.insertVertex", "Add Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), segment, t?: 0..1}", has_comp, insert_vertex), + cmd!( + "mask.featherPoint.add", + "Add Mask Feather Point", + [], + None, + "{layer?, mask, segment?, t?: 0..1, point?: [x,y] (layer space), radius? (px, negative = inner), tension? (%)} → {index}", + has_comp, + feather_add + ), + cmd!( + "mask.featherPoint.set", + "Move Mask Feather Point", + [], + None, + "{layer?, mask, index, segment?, t?, point? (slide along the path), radius?, toward?: [x,y] (radius from the drag point, layer space), tension? (%), merge?}", + has_comp, + feather_set + ), + cmd!("mask.featherPoint.remove", "Delete Mask Feather Point", [], None, "{layer?, mask, index? | all?}", has_comp, feather_remove), + crate::query!("mask.featherPoint.list", "Mask Feather Points", "{layer?, mask}", feather_list), cmd!("mask.remove", "Remove Mask", [], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)), cmd!("mask.removeAll", "Remove All Masks", [], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)), ] diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index f7584407..2ab44c09 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -33,6 +33,7 @@ mod query; mod render_queue; pub mod roto_cmds; mod settings; +mod shape_stroke; mod stubs; mod styles; mod text_anim; @@ -127,6 +128,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(animation::specs()); v.extend(view::specs()); v.extend(viewer_cmds::specs()); + v.extend(shape_stroke::specs()); v.extend(key_transform::specs()); v.extend(file_more::specs()); v.extend(lottie::specs()); diff --git a/crates/engine/src/commands/shape_stroke.rs b/crates/engine/src/commands/shape_stroke.rs new file mode 100644 index 00000000..29b945a7 --- /dev/null +++ b/crates/engine/src/commands/shape_stroke.rs @@ -0,0 +1,221 @@ +//! Shape Stroke options: the Dashes "+" / "−" buttons (Dash 2 / Gap 2, Dash 3 / Gap 3), Taper and +//! Wave. Taper and Wave are ordinary properties of the stroke (`…/stroke/taper/startLength`); the +//! commands here set several of them in one undo step and add the groups to strokes saved before +//! they existed. + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::build::{self, EXTRA_DASHES, Ids}; +use effectcraft_project::{ItemId, LayerId, PropGroup, Uid}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, has_layers, layer_mut, layer_p, merge_p}; +use crate::{EngineError, Result, Session, cmd}; + +fn is_stroke(g: &PropGroup) -> bool { + matches!(g.match_id.as_str(), "stroke" | "gstroke") +} + +/// `{layer?, prop?: stroke uid}` → the stroke; without `prop`, the first stroke of the layer (a +/// selected stroke property wins). +fn stroke_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { + let (cid, lid) = layer_p(s, p, cmd)?; + let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; + if let Some(uid) = p.get("prop").and_then(Value::as_u64) { + let g = l.props.find_group(uid).ok_or_else(|| bad(cmd, format!("no property group @{uid}")))?; + if !is_stroke(g) { + return Err(bad(cmd, format!("`{}` is not a Stroke or Gradient Stroke", g.name))); + } + return Ok((cid, lid, uid)); + } + let contents = l.props.sub("contents").ok_or_else(|| bad(cmd, "not a shape layer"))?; + // A selected stroke (or a property inside one), else the first stroke in the contents. + let mut found = None; + for (sl, su) in &s.state.selected_props { + if *sl == lid + && let Some(g) = find_stroke_containing(contents, *su) + { + found = Some(g); + break; + } + } + if found.is_none() { + let mut first = None; + walk_groups(contents, &mut |g| { + if first.is_none() && is_stroke(g) { + first = Some(g.uid); + } + }); + found = first; + } + found.map(|u| (cid, lid, u)).ok_or_else(|| bad(cmd, "the layer has no stroke")) +} + +fn walk_groups(g: &PropGroup, f: &mut impl FnMut(&PropGroup)) { + for sub in g.groups() { + f(sub); + walk_groups(sub, f); + } +} + +fn find_stroke_containing(contents: &PropGroup, uid: Uid) -> Option { + let mut out = None; + walk_groups(contents, &mut |g| { + if out.is_none() && is_stroke(g) && (g.uid == uid || g.find(uid).is_some() || g.find_group(uid).is_some()) { + out = Some(g.uid); + } + }); + out +} + +fn dashes_add(s: &mut Session, p: &Value) -> Result { + let c = "shape.dashes.add"; + let (cid, lid, uid) = stroke_ref(s, p, c)?; + s.edit("Add Dash", None, |proj, _| { + let mut next = proj.next_id; + let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?; + let d = g.sub_mut("dashes").ok_or_else(|| bad(c, "the stroke has no Dashes group"))?; + // Before the first "+", Dash is 0 (no dashes): the first "+" turns it on. + let dash = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(0.0); + let mut ids = Ids(&mut next); + let added = if dash <= 0.0 { + if let Some(p) = d.get_mut("dash") { + p.value = KV::Scalar(10.0); + } + "dash" + } else { + let Some((dm, gm)) = EXTRA_DASHES.iter().find(|(dm, _)| d.get(dm).is_none()) else { + return Err(bad(c, "a stroke has at most three dash/gap pairs")); + }; + let k = if *dm == "dash2" { 2 } else { 3 }; + // Insert before Offset, which stays last. + let at = d.children.iter().position(|n| n.match_id() == "offset").unwrap_or(d.children.len()); + let dv = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(10.0); + let gv = d.get("gap").map(|p| p.value.as_f64()).filter(|g| *g > 0.0).unwrap_or(dv); + d.children.insert(at, ids.prop(gm, &format!("Gap {k}"), KV::Scalar(gv)).with_ui(effectcraft_project::ParamUi::Pixels).into()); + d.children.insert(at, ids.prop(dm, &format!("Dash {k}"), KV::Scalar(dv)).with_ui(effectcraft_project::ParamUi::Pixels).into()); + dm + }; + proj.next_id = next; + Ok(json!({"added": added})) + }) +} + +fn dashes_remove(s: &mut Session, p: &Value) -> Result { + let c = "shape.dashes.remove"; + let (cid, lid, uid) = stroke_ref(s, p, c)?; + s.edit("Remove Dash", None, |proj, _| { + let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?; + let d = g.sub_mut("dashes").ok_or_else(|| bad(c, "the stroke has no Dashes group"))?; + for (dm, gm) in EXTRA_DASHES.iter().rev() { + if d.get(dm).is_some() { + d.children.retain(|n| n.match_id() != *dm && n.match_id() != *gm); + return Ok(json!({"removed": dm})); + } + } + // The last "−" turns dashes off. + if let Some(p) = d.get_mut("dash") { + p.value = KV::Scalar(0.0); + p.keys.clear(); + } + Ok(json!({"removed": "dash"})) + }) +} + +/// Set `fields` (`param name → (match id, kind)`) of the stroke's `group`, creating the group. +fn set_group(s: &mut Session, p: &Value, c: &str, group: &str, fields: &[(&str, &str, bool)]) -> Result { + let (cid, lid, uid) = stroke_ref(s, p, c)?; + let t = s.time(); + let label = if group == "taper" { "Stroke Taper" } else { "Stroke Wave" }; + s.edit(label, merge_p(p), |proj, _| { + let mut next = proj.next_id; + { + let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?; + if g.sub(group).is_none() { + let mut ids = Ids(&mut next); + let ng = if group == "taper" { build::stroke_taper(&mut ids) } else { build::stroke_wave(&mut ids) }; + g.children.push(ng.into()); + } + } + proj.next_id = next; + let l = layer_mut(proj, cid, lid)?; + let lt = l.layer_time(t); + let g = l.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?; + let sub = g.sub_mut(group).ok_or_else(|| bad(c, "group vanished"))?; + let mut out = serde_json::Map::new(); + for (param, m, is_enum) in fields { + let Some(v) = p.get(*param) else { continue }; + let pr = sub.get_mut(m).ok_or_else(|| bad(c, format!("no {m}")))?; + let nv = if *is_enum { + let opts: &[&str] = if *m == "units" && group == "taper" { &["pixels", "percent"] } else { &["pixels", "cycles"] }; + let i = match v { + Value::String(s) => { + opts.iter().position(|o| o.eq_ignore_ascii_case(s)).ok_or_else(|| bad(c, format!("`{param}` is one of {opts:?}")))? as u32 + } + Value::Number(n) => n.as_u64().unwrap_or(0).min(opts.len() as u64 - 1) as u32, + _ => return Err(bad(c, format!("bad `{param}`"))), + }; + KV::Enum(i) + } else { + let x = v.as_f64().ok_or_else(|| bad(c, format!("`{param}` must be a number")))?; + let x = if let effectcraft_project::ParamUi::Slider { min, max, .. } = pr.ui { x.clamp(min, max) } else { x.max(0.0) }; + KV::Scalar(x) + }; + pr.set_value_at(lt, nv.clone()); + out.insert(param.to_string(), nv.to_json()); + } + Ok(json!({"stroke": uid, group: Value::Object(out)})) + }) +} + +fn taper(s: &mut Session, p: &Value) -> Result { + set_group( + s, + p, + "shape.stroke.taper", + "taper", + &[ + ("units", "units", true), + ("startLength", "startLength", false), + ("endLength", "endLength", false), + ("startWidth", "startWidth", false), + ("endWidth", "endWidth", false), + ("startEase", "startEase", false), + ("endEase", "endEase", false), + ], + ) +} + +fn wave(s: &mut Session, p: &Value) -> Result { + set_group( + s, + p, + "shape.stroke.wave", + "wave", + &[("amount", "amount", false), ("units", "units", true), ("wavelength", "wavelength", false), ("cycles", "cycles", false), ("phase", "phase", false)], + ) +} + +pub fn specs() -> Vec { + vec![ + cmd!("shape.dashes.add", "Add Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_add), + cmd!("shape.dashes.remove", "Remove Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_remove), + cmd!( + "shape.stroke.taper", + "Stroke Taper", + [], + None, + "{layer?, prop?: stroke uid, units?: pixels|percent, startLength?, endLength?, startWidth? (%), endWidth? (%), startEase? (%), endEase? (%)}", + has_layers, + taper + ), + cmd!( + "shape.stroke.wave", + "Stroke Wave", + [], + None, + "{layer?, prop?: stroke uid, amount? (%), units?: pixels|cycles, wavelength?, cycles?, phase? (°)}", + has_layers, + wave + ), + ] +} diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs index c0161510..768733b4 100644 --- a/crates/engine/src/commands/viewer_cmds.rs +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -224,7 +224,7 @@ fn new_path(s: &mut Session, p: &Value) -> Result { *t = [r.x, r.y]; } } - let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) }; + let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() }; let fill = (!matches!(p.get("fill"), Some(Value::Null) | Some(Value::Bool(false)))).then(|| rgba(p.get("fill"), [0.25, 0.55, 1.0, 1.0])); let width = f_p(p, "strokeWidth").unwrap_or(2.0); let stroke = (width > 0.0).then(|| rgba(p.get("stroke"), [1.0, 1.0, 1.0, 1.0])); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3716958f..4a22562f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -634,6 +634,8 @@ mod tests_stubs; #[cfg(test)] mod tests_styles; #[cfg(test)] +mod tests_tail; +#[cfg(test)] mod tests_text; #[cfg(test)] mod tests_text_edit; diff --git a/crates/engine/src/mask_track.rs b/crates/engine/src/mask_track.rs index 1cdeda1e..3a6face7 100644 --- a/crates/engine/src/mask_track.rs +++ b/crates/engine/src/mask_track.rs @@ -105,6 +105,7 @@ pub fn transform_path(p: &ShapePath, h: &trk::Homography) -> ShapePath { in_tangents: p.vertices.iter().zip(&p.in_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(), out_tangents: p.vertices.iter().zip(&p.out_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(), closed: p.closed, + feather: p.feather.clone(), } } diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs new file mode 100644 index 00000000..c395a85d --- /dev/null +++ b/crates/engine/src/tests_tail.rs @@ -0,0 +1,221 @@ +//! M13.5 long-tail parity: stroke taper/wave/dashes, and the other small gaps (commands, undo, +//! serde). + +use serde_json::{Value, json}; + +use crate::Session; +use effectcraft_project::{LayerId, Project}; + +pub(crate) fn comp(w: u32, h: u32) -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Tail", "width": w, "height": h, "frameRate": 30, "duration": 2})).unwrap(); + s +} + +pub(crate) fn roundtrip(s: &Session) -> Project { + Project::from_json(&s.project.to_json()).unwrap() +} + +/// A horizontal open path 160 px long across a 200×100 comp, 10 px red stroke and no fill. +fn line_layer(s: &mut Session) -> (u64, u64) { + let r = s.execute("shape.newPath", json!({"vertices": [[20, 50], [180, 50]], "fill": false, "stroke": [1, 0, 0], "strokeWidth": 10})).unwrap(); + let lid = r["layer"].as_u64().unwrap(); + let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap(); + let stroke = l.props.find_group(r["group"].as_u64().unwrap()).unwrap().sub("contents").unwrap().sub("stroke").unwrap().uid; + (lid, stroke) +} + +fn column(s: &Session, x: u32) -> f32 { + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default()); + (0..img.height).map(|y| img.data[(y * img.width + x) as usize][3]).sum() +} + +#[test] +fn stroke_taper_wave_commands_render_undo_and_serde() { + let mut s = comp(200, 100); + let (lid, stroke) = line_layer(&mut s); + assert!((column(&s, 30) - 10.0).abs() < 0.6); + let r = s.execute("shape.stroke.taper", json!({"layer": lid, "startLength": 40, "startWidth": 0})).unwrap(); + assert_eq!(r["stroke"], json!(stroke)); + assert!((column(&s, 30) - 2.5).abs() < 0.8, "tapered start: {}", column(&s, 30)); + // Percent units. + s.execute("shape.stroke.taper", json!({"layer": lid, "units": "percent", "startLength": 25})).unwrap(); + assert!((column(&s, 30) - 2.5).abs() < 0.8, "25 % of 160 px = 40 px: {}", column(&s, 30)); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!((column(&s, 30) - 10.0).abs() < 0.6, "undo restores the plain stroke"); + s.execute("edit.redo", json!({})).unwrap(); + assert!((column(&s, 30) - 2.5).abs() < 0.8); + // Wave in cycles. + s.execute("shape.stroke.wave", json!({"layer": lid, "amount": 100, "units": "cycles", "cycles": 4})).unwrap(); + assert!(column(&s, 120) < 0.6, "trough at 100 px (2.5 cycles): {}", column(&s, 120)); + // Values are ordinary animatable properties that survive save/load. + let back = roundtrip(&s); + let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap(); + let g = l.props.find_group(stroke).unwrap(); + assert_eq!(g.prop("wave/cycles").unwrap().value.as_f64(), 4.0); + assert_eq!(g.prop("taper/startLength").unwrap().value.as_f64(), 40.0); + assert!(s.execute("shape.stroke.wave", json!({"layer": lid, "units": "furlongs"})).is_err()); +} + +#[test] +fn dashes_plus_minus_buttons() { + let mut s = comp(200, 100); + let (lid, stroke) = line_layer(&mut s); + let add = |s: &mut Session| s.execute("shape.dashes.add", json!({"layer": lid, "prop": stroke})).unwrap()["added"].as_str().unwrap().to_string(); + assert_eq!(add(&mut s), "dash"); + s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "dash"), "value": 20})).unwrap(); + assert_eq!(add(&mut s), "dash2"); + assert_eq!(add(&mut s), "dash3"); + assert!(s.execute("shape.dashes.add", json!({"layer": lid})).is_err(), "three pairs at most"); + let names = |s: &Session| { + let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap(); + l.props.find_group(stroke).unwrap().sub("dashes").unwrap().children.iter().map(|n| n.match_id().to_string()).collect::>() + }; + assert_eq!(names(&s), ["dash", "gap", "dash2", "gap2", "dash3", "gap3", "offset"]); + // Dash 20, Gap 20 (= dash), Dash 2 20, Gap 2 20…: set Dash 2 to 5. + s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "gap"), "value": 10})).unwrap(); + s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "dash2"), "value": 5})).unwrap(); + s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "gap2"), "value": 15})).unwrap(); + s.execute("shape.dashes.remove", json!({"layer": lid})).unwrap(); + assert_eq!(names(&s), ["dash", "gap", "dash2", "gap2", "offset"]); + // Pattern 20 on, 10 off, 5 on, 15 off from x = 20 (butt caps). + assert!(column(&s, 30) > 9.0 && column(&s, 45) < 0.1 && column(&s, 52) > 9.0 && column(&s, 60) < 0.1 && column(&s, 80) > 9.0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(names(&s).len(), 7); + let back = roundtrip(&s); + let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap(); + assert_eq!(l.props.find_group(stroke).unwrap().prop("dashes/dash2").unwrap().value.as_f64(), 5.0); +} + +fn dash_uid(s: &Session, lid: u64, stroke: u64, m: &str) -> Value { + let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap(); + json!(l.props.find_group(stroke).unwrap().sub("dashes").unwrap().get(m).unwrap().uid) +} + +/// A white 200×200 solid in a 200×200 comp with a 100×100 rectangular mask (50…150). +fn masked_solid(s: &mut Session) -> (u64, u64) { + let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap(); + let r = s.execute("mask.new", json!({"layer": l, "vertices": [[50, 50], [150, 50], [150, 150], [50, 150]], "closed": true})).unwrap(); + (l, r["mask"].as_u64().unwrap()) +} + +fn render_at(s: &Session, t: f64) -> effectcraft_raster::Image { + let cid = s.active_comp_id().unwrap(); + s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), Default::default()) +} + +fn alpha_at(s: &Session, x: u32, y: u32, t: f64) -> f32 { + let img = render_at(s, t); + img.data[(y * img.width + x) as usize][3] +} + +#[test] +fn variable_mask_feather_points() { + let mut s = comp(200, 200); + let (l, m) = masked_solid(&mut s); + s.execute("layer.mask.featherFalloff", json!({"layer": l, "mode": "linear"})).unwrap(); + assert!(alpha_at(&s, 100, 45, 0.0) < 0.01); + // Mask Feather tool: click on the path and drag out above the top edge → an outer point of 20 px. + let r = s.execute("mask.featherPoint.add", json!({"layer": l, "mask": m, "point": [100, 30]})).unwrap(); + assert_eq!(r["index"], json!(0)); + let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap(); + let p0 = &pts["points"][0]; + assert_eq!(p0["segment"], json!(0)); + assert!((p0["t"].as_f64().unwrap() - 0.5).abs() < 1e-3 && (p0["radius"].as_f64().unwrap() - 20.0).abs() < 1e-3, "{pts}"); + let soft = alpha_at(&s, 100, 39, 0.0); + assert!((soft - 0.475).abs() < 0.06, "{soft}"); + // An inner point dragged inside the bottom edge. + s.execute("mask.featherPoint.add", json!({"layer": l, "mask": m, "point": [100, 130], "tension": 100})).unwrap(); + let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap(); + assert!((pts["points"][1]["radius"].as_f64().unwrap() + 20.0).abs() < 1e-3, "{pts}"); + assert!((alpha_at(&s, 100, 140, 0.0) - 0.475).abs() < 0.08, "{}", alpha_at(&s, 100, 140, 0.0)); + assert!(alpha_at(&s, 100, 152, 0.0) < 0.01); + // Drag the radius; undo; redo. + s.execute("mask.featherPoint.set", json!({"layer": l, "mask": m, "index": 0, "radius": 40})).unwrap(); + assert!(alpha_at(&s, 100, 39, 0.0) > 0.7); + s.execute("edit.undo", json!({})).unwrap(); + assert!((alpha_at(&s, 100, 39, 0.0) - soft).abs() < 0.08); + s.execute("edit.redo", json!({})).unwrap(); + // Inserting a vertex keeps the points where they were on the path. + let before = alpha_at(&s, 100, 39, 0.0); + s.execute("mask.insertVertex", json!({"layer": l, "mask": m, "segment": 0, "t": 0.25})).unwrap(); + let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap(); + assert_eq!(pts["points"][0]["segment"], json!(1)); + assert!((pts["points"][0]["t"].as_f64().unwrap() - 1.0 / 3.0).abs() < 1e-6); + assert!((alpha_at(&s, 100, 39, 0.0) - before).abs() < 0.02); + // Serde: the points travel in the Mask Path value. + let back = roundtrip(&s); + let lay = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(l)).unwrap(); + let effectcraft_keyframe::Value::Path(sp) = &lay.props.find_group(m).unwrap().get("path").unwrap().value else { panic!() }; + assert_eq!(sp.feather.len(), 2); + s.execute("mask.featherPoint.remove", json!({"layer": l, "mask": m, "all": true})).unwrap(); + assert!(alpha_at(&s, 100, 39, 0.0) < 0.01); + assert!(s.execute("mask.featherPoint.set", json!({"layer": l, "mask": m, "index": 0, "radius": 1})).is_err()); +} + +#[test] +fn mask_motion_blur_ramps_across_the_motion() { + let mut s = comp(200, 200); + let (l, m) = masked_solid(&mut s); + s.execute("comp.settings", json!({"shutterAngle": 360, "shutterPhase": 0})).unwrap(); + s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "on"})).unwrap(); + s.execute("prop.addKey", json!({"layer": l, "path": "masks/#1/path", "time": 0.0})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + // 30 px in 30 frames: 1 px per frame; with a 360° shutter at frame 15 the right edge sweeps + // 165…166, so pixel 165 is half covered. + let verts: Vec = (0..4).map(|i| json!({"layer": l, "mask": m, "index": i})).collect(); + s.execute("mask.moveVertices", json!({"vertices": verts, "delta": [30, 0]})).unwrap(); + let a = alpha_at(&s, 165, 100, 0.5); + assert!(a > 0.3 && a < 0.7, "{a}"); + s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "off"})).unwrap(); + let hard = alpha_at(&s, 165, 100, 0.5); + assert!(!(0.3..0.7).contains(&hard), "{hard}"); +} + +#[test] +fn adaptive_sample_limit_drives_layer_samples() { + assert_eq!(effectcraft_render::adaptive_samples(0.5, 16, 128), 16); + assert_eq!(effectcraft_render::adaptive_samples(100.0, 16, 128), 100); + assert_eq!(effectcraft_render::adaptive_samples(400.0, 16, 128), 128); + assert_eq!(effectcraft_render::adaptive_samples(400.0, 16, 9999), 256); + let mut s = comp(400, 100); + s.execute("comp.settings", json!({"shutterAngle": 360, "shutterPhase": 0})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": true})).unwrap(); + // 100 px per frame (keys 3 frames apart, 300 px). + s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.0, "value": [50, 50]})).unwrap(); + s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.1, "value": [350, 50]})).unwrap(); + let max_step = |s: &Session| { + let img = render_at(s, 1.0 / 30.0); + let row: Vec = (0..img.width).map(|x| img.data[(50 * img.width + x) as usize][3]).collect(); + row.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max) + }; + s.execute("comp.settings", json!({"adaptiveSampleLimit": 16})).unwrap(); + let coarse = max_step(&s); + s.execute("comp.settings", json!({"adaptiveSampleLimit": 256})).unwrap(); + let fine = max_step(&s); + assert!(coarse > 0.05, "16 samples over 100 px leave visible steps: {coarse}"); + assert!(fine < 0.03, "≈100 samples give a smooth ramp: {fine}"); + assert_eq!(s.active_comp().unwrap().motion_blur_adaptive_limit, 256); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().motion_blur_adaptive_limit, 16); + assert_eq!(roundtrip(&s).comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 16); + // Older projects without the field read the After Effects default. + fn strip(v: &mut Value) { + match v { + Value::Object(o) => { + o.remove("motion_blur_adaptive_limit"); + o.values_mut().for_each(strip); + } + Value::Array(a) => a.iter_mut().for_each(strip), + _ => {} + } + } + let mut v: Value = serde_json::from_str(&s.project.to_json()).unwrap(); + strip(&mut v); + let json = v.to_string(); + assert!(!json.contains("motion_blur_adaptive_limit")); + assert_eq!(Project::from_json(&json).unwrap().comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 128); +} diff --git a/crates/engine/src/tests_viewer.rs b/crates/engine/src/tests_viewer.rs index d6072c58..973324a9 100644 --- a/crates/engine/src/tests_viewer.rs +++ b/crates/engine/src/tests_viewer.rs @@ -292,6 +292,7 @@ fn split_segment_keeps_curve_shape() { in_tangents: vec![[0.0; 2], [0.0, -50.0]], out_tangents: vec![[30.0, 60.0], [0.0; 2]], closed: false, + feather: Vec::new(), }; let seg = |sp: &ShapePath, i: usize, t: f64| { let (a, d) = (sp.vertices[i], sp.vertices[i + 1]); diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index af7ed9b6..5d230685 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -469,7 +469,13 @@ pub fn to_value(out: &Out, current: &Value) -> Result { v.resize(n, [0.0; 2]); v }; - Value::Path(effectcraft_keyframe::ShapePath { vertices: points.clone(), in_tangents: fit(ins), out_tangents: fit(outs), closed: *closed }) + Value::Path(effectcraft_keyframe::ShapePath { + vertices: points.clone(), + in_tangents: fit(ins), + out_tangents: fit(outs), + closed: *closed, + feather: Vec::new(), + }) } _ => return Err("Error: expression result must be a path".into()), }, diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 731e167c..7d9d35ee 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -14,7 +14,7 @@ pub mod value; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, Kerning, ParaStyle, StyleRun}; -pub use value::{Gradient, Justify, ShapePath, TextDoc, Value}; +pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value}; /// Temporal interpolation on one side of a keyframe. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs index abf112fc..2c683647 100644 --- a/crates/keyframe/src/value.rs +++ b/crates/keyframe/src/value.rs @@ -10,15 +10,79 @@ pub struct ShapePath { pub in_tangents: Vec<[f64; 2]>, pub out_tangents: Vec<[f64; 2]>, pub closed: bool, + /// Variable-width mask feather points (Mask Feather tool). They travel with the path, so a + /// Mask Path keyframe carries them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub feather: Vec, +} + +/// A mask feather point: a position on the path (segment `segment`, from vertex `segment` to the +/// next, at Bezier parameter `t`), a signed feather `radius` in pixels (positive = outer +/// feather, negative = inner) and a `tension` 0–1 (0: the radius blends smoothly to the +/// neighbouring points, 1: linearly). +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct FeatherPoint { + pub segment: usize, + pub t: f64, + pub radius: f64, + #[serde(default)] + pub tension: f64, } impl ShapePath { pub fn polygon(points: &[[f64; 2]], closed: bool) -> ShapePath { - ShapePath { vertices: points.to_vec(), in_tangents: vec![[0.0; 2]; points.len()], out_tangents: vec![[0.0; 2]; points.len()], closed } + ShapePath { + vertices: points.to_vec(), + in_tangents: vec![[0.0; 2]; points.len()], + out_tangents: vec![[0.0; 2]; points.len()], + closed, + feather: Vec::new(), + } } pub fn len(&self) -> usize { self.vertices.len() } + /// Number of segments (vertex to next vertex, the closing one included). + pub fn segment_count(&self) -> usize { + let n = self.vertices.len(); + if self.closed { n } else { n.saturating_sub(1) } + } + /// Keep feather points in place after a vertex was inserted at `i` (segments from `i` on + /// shift by one; a split of segment `i - 1` at parameter `split` re-parameterises its points). + pub fn feather_after_insert(&mut self, i: usize, split: Option) { + for f in &mut self.feather { + if i > 0 && f.segment == i - 1 { + if let Some(t) = split.filter(|t| *t > 0.0 && *t < 1.0) { + if f.t < t { + f.t /= t; + } else { + f.segment += 1; + f.t = (f.t - t) / (1.0 - t); + } + } + } else if f.segment >= i { + f.segment += 1; + } + } + } + /// Keep feather points after vertex `i` was removed: its two segments merge. + pub fn feather_after_remove(&mut self, i: usize) { + let segs = self.segment_count(); + self.feather.retain_mut(|f| { + if i == 0 && f.segment == 0 && !self.closed { + return false; + } + if i > 0 && f.segment == i - 1 { + f.t *= 0.5; + } else if f.segment == i && i > 0 { + f.segment = i - 1; + f.t = 0.5 + f.t * 0.5; + } else if f.segment > i || (i == 0 && f.segment > 0) { + f.segment -= 1; + } + f.segment < segs.max(1) + }); + } pub fn is_empty(&self) -> bool { self.vertices.is_empty() } @@ -36,6 +100,7 @@ impl ShapePath { in_tangents: vec![[-rx * K, 0.0], [0.0, -ry * K], [rx * K, 0.0], [0.0, ry * K]], out_tangents: vec![[rx * K, 0.0], [0.0, ry * K], [-rx * K, 0.0], [0.0, -ry * K]], closed: true, + feather: Vec::new(), } } fn lerp(&self, o: &ShapePath, t: f64) -> ShapePath { @@ -48,6 +113,29 @@ impl ShapePath { in_tangents: l(&self.in_tangents, &o.in_tangents), out_tangents: l(&self.out_tangents, &o.out_tangents), closed: self.closed, + // Feather points interpolate when both keys have the same points. + feather: if self.feather.len() == o.feather.len() { + self.feather + .iter() + .zip(&o.feather) + .map(|(a, b)| FeatherPoint { + segment: if t < 1.0 { a.segment } else { b.segment }, + t: if a.segment == b.segment { + a.t + (b.t - a.t) * t + } else if t < 1.0 { + a.t + } else { + b.t + }, + radius: a.radius + (b.radius - a.radius) * t, + tension: a.tension + (b.tension - a.tension) * t, + }) + .collect() + } else if t < 1.0 { + self.feather.clone() + } else { + o.feather.clone() + }, } } } diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index 77d22101..82746cb0 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -49,6 +49,7 @@ pub(crate) fn to_path(j: &Json) -> Option { out_tangents: fit(pts("o")), vertices, closed: o.get("c").and_then(Json::as_bool).unwrap_or(false), + feather: Vec::new(), })) } @@ -137,11 +138,17 @@ fn stroke_fields(o: &mut Out) { o.raw("lc", json!(o.e("cap") + 1)); o.raw("lj", json!(o.e("join") + 1)); o.raw("ml", json!(o.f("miter"))); + let used = |g: Option<&PropGroup>, m: &str| g.and_then(|g| g.get(m)).is_some_and(|p| p.is_animated() || p.value.as_f64().abs() > 1e-9); + let (taper, wave) = (o.g.sub("taper"), o.g.sub("wave")); + if used(taper, "startLength") || used(taper, "endLength") || used(wave, "amount") { + let msg = format!("{}: stroke Taper and Wave have no Lottie equivalent (uniform width exported)", o.label); + o.ex.warn(msg); + } if let Some(d) = o.g.sub("dashes") { let dash = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(0.0); if dash > 0.0 || d.any_animated() { let mut list = vec![]; - for (n, m) in [("d", "dash"), ("g", "gap"), ("o", "offset")] { + for (n, m) in [("d", "dash"), ("g", "gap"), ("d", "dash2"), ("g", "gap2"), ("d", "dash3"), ("g", "gap3"), ("o", "offset")] { if let Some(p) = d.get(m) { let v = export_prop(o.ex, p, &anim::num, &format!("{} ▸ Dashes ▸ {}", o.label, p.name)); list.push(json!({"n": n, "nm": p.name, "v": v})); diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 5140133c..0641883f 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -280,6 +280,7 @@ fn shape_contents_roundtrip() { in_tangents: vec![[0.0, 0.0], [-10.0, 0.0], [0.0, 0.0]], out_tangents: vec![[0.0, 0.0], [10.0, 0.0], [0.0, 0.0]], closed: true, + feather: Vec::new(), }, ); let grad = Gradient { diff --git a/crates/path/src/feather.rs b/crates/path/src/feather.rs new file mode 100644 index 00000000..e8e0fc4a --- /dev/null +++ b/crates/path/src/feather.rs @@ -0,0 +1,268 @@ +//! Variable-width mask feather (After Effects' Mask Feather tool). +//! +//! Feather points sit on the mask path with a signed radius (outer > 0, inner < 0) and a +//! tension. Between points the outer and inner radii are interpolated along the path's arc +//! length (smoothly at tension 0, linearly at tension 1; closed paths wrap around). Every pixel +//! near the path takes the feather width at its nearest path position: outside the path the +//! opacity falls from 1 at the edge to 0 at the outer radius, inside it rises from 0 at the +//! edge to 1 at the inner radius. Where no radius applies the edge stays hard (anti-aliased). + +use effectcraft_geom::{Mat3, vec2}; +use effectcraft_keyframe::{FeatherPoint, ShapePath}; +use effectcraft_raster::Mask; + +use crate::{FillRule, fill_coverage, to_kurbo}; + +/// A flattened path in buffer space: points, their arc lengths, and per segment the +/// `(parameter, arc length)` table used to place feather points. +struct Flat { + pts: Vec<[f64; 2]>, + s: Vec, + seg_tables: Vec>, + len: f64, +} + +fn cubic(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] { + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + [a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]] +} + +fn flatten(sp: &ShapePath, m: &Mat3) -> Flat { + let n = sp.vertices.len(); + let tr = |p: [f64; 2]| { + let r = m.apply(vec2(p[0], p[1])); + [r.x, r.y] + }; + let mut pts: Vec<[f64; 2]> = Vec::new(); + let mut s: Vec = Vec::new(); + let mut seg_tables = Vec::new(); + let segs = sp.segment_count(); + let mut acc = 0.0; + for i in 0..segs { + let j = (i + 1) % n; + let v0 = sp.vertices[i]; + let v1 = sp.vertices[j]; + let o = sp.out_tangents.get(i).copied().unwrap_or([0.0; 2]); + let it = sp.in_tangents.get(j).copied().unwrap_or([0.0; 2]); + let (p0, p1, p2, p3) = (tr(v0), tr([v0[0] + o[0], v0[1] + o[1]]), tr([v1[0] + it[0], v1[1] + it[1]]), tr(v1)); + let est = ((p1[0] - p0[0]).hypot(p1[1] - p0[1]) + (p2[0] - p1[0]).hypot(p2[1] - p1[1]) + (p3[0] - p2[0]).hypot(p3[1] - p2[1])).max(1.0); + let k = ((est / 1.5).ceil() as usize).clamp(4, 1024); + let mut table = Vec::with_capacity(k + 1); + if pts.is_empty() { + pts.push(p0); + s.push(0.0); + } + table.push((0.0, acc)); + let mut prev = p0; + for q in 1..=k { + let t = q as f64 / k as f64; + let p = cubic(p0, p1, p2, p3, t); + acc += (p[0] - prev[0]).hypot(p[1] - prev[1]); + pts.push(p); + s.push(acc); + table.push((t, acc)); + prev = p; + } + seg_tables.push(table); + } + Flat { pts, s, seg_tables, len: acc } +} + +impl Flat { + /// Arc length of a feather point. + fn arc_of(&self, f: &FeatherPoint) -> Option { + let table = self.seg_tables.get(f.segment)?; + let t = f.t.clamp(0.0, 1.0); + let i = table.partition_point(|e| e.0 <= t).clamp(1, table.len() - 1); + let (a, b) = (table[i - 1], table[i]); + let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; + Some(a.1 + (b.1 - a.1) * u) + } +} + +/// Outer / inner feather radius (buffer pixels) along the path, from the feather points. +struct Profile { + /// (arc length, outer radius, inner radius, tension), sorted by arc length. + pts: Vec<(f64, f64, f64, f64)>, + len: f64, + closed: bool, +} + +impl Profile { + fn at(&self, u: f64) -> (f64, f64) { + let p = &self.pts; + if p.is_empty() { + return (0.0, 0.0); + } + if p.len() == 1 { + return (p[0].1, p[0].2); + } + let blend = |a: &(f64, f64, f64, f64), b: &(f64, f64, f64, f64), x: f64| { + let x = x.clamp(0.0, 1.0); + let smooth = x * x * (3.0 - 2.0 * x); + let tension = ((a.3 + b.3) * 0.5).clamp(0.0, 1.0); + let w = smooth + (x - smooth) * tension; + (a.1 + (b.1 - a.1) * w, a.2 + (b.2 - a.2) * w) + }; + let first = p[0]; + let last = p[p.len() - 1]; + if u < first.0 || u >= last.0 { + if !self.closed { + return if u < first.0 { (first.1, first.2) } else { (last.1, last.2) }; + } + // Wrap from the last point to the first through the path's start. + let span = (self.len - last.0) + first.0; + let x = if u >= last.0 { u - last.0 } else { u + self.len - last.0 }; + return blend(&last, &first, if span > 1e-9 { x / span } else { 0.0 }); + } + let i = p.partition_point(|e| e.0 <= u).clamp(1, p.len() - 1); + let (a, b) = (&p[i - 1], &p[i]); + let span = b.0 - a.0; + blend(a, b, if span > 1e-9 { (u - a.0) / span } else { 0.0 }) + } +} + +/// Coverage of a mask path with feather points, transformed by `m` (translate + uniform scale) +/// into a `w × h` mask. `expansion` (layer pixels) grows (>0) or shrinks the mask first; +/// `linear` selects the Linear feather falloff instead of Smooth. +pub fn variable_feather_coverage(sp: &ShapePath, m: &Mat3, w: u32, h: u32, expansion: f64, linear: bool) -> Mask { + let scale = m.mean_scale(); + let path = to_kurbo(sp); + let hard = fill_coverage(std::slice::from_ref(&path), m, w, h, FillRule::NonZero); + let flat = flatten(sp, m); + if flat.pts.len() < 2 { + return hard; + } + let mut pts: Vec<(f64, f64, f64, f64)> = sp + .feather + .iter() + .filter_map(|f| { + let u = flat.arc_of(f)?; + let r = f.radius * scale; + Some((u, r.max(0.0), (-r).max(0.0), f.tension)) + }) + .collect(); + pts.sort_by(|a, b| a.0.total_cmp(&b.0)); + let prof = Profile { pts, len: flat.len, closed: sp.closed }; + let rmax = prof.pts.iter().map(|p| p.1.max(p.2)).fold(0.0, f64::max); + let exp = expansion * scale; + let reach = rmax + exp.abs() + 2.0; + let (wu, hu) = (w as usize, h as usize); + let mut dist = vec![f32::INFINITY; wu * hu]; + let mut arc = vec![0.0f32; wu * hu]; + for k in 0..flat.pts.len() - 1 { + let (a, b) = (flat.pts[k], flat.pts[k + 1]); + let (sa, sb) = (flat.s[k], flat.s[k + 1]); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l2 = dx * dx + dy * dy; + let x0 = ((a[0].min(b[0]) - reach).floor().max(0.0)) as usize; + let x1 = ((a[0].max(b[0]) + reach).ceil().min(w as f64 - 1.0)).max(-1.0) as isize; + let y0 = ((a[1].min(b[1]) - reach).floor().max(0.0)) as usize; + let y1 = ((a[1].max(b[1]) + reach).ceil().min(h as f64 - 1.0)).max(-1.0) as isize; + if x1 < 0 || y1 < 0 { + continue; + } + for y in y0..=y1 as usize { + for x in x0..=x1 as usize { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let t = if l2 > 0.0 { (((px - a[0]) * dx + (py - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let (qx, qy) = (a[0] + dx * t - px, a[1] + dy * t - py); + let d = (qx * qx + qy * qy).sqrt() as f32; + let i = y * wu + x; + if d < dist[i] { + dist[i] = d; + arc[i] = (sa + (sb - sa) * t) as f32; + } + } + } + } + let ramp = |x: f64| { + let x = x.clamp(0.0, 1.0); + if linear { x } else { x * x * (3.0 - 2.0 * x) } + }; + let mut out = Mask::new(w, h, 0.0); + for (i, o) in out.data.iter_mut().enumerate() { + let inside = hard.data[i] >= 0.5; + let d = dist[i] as f64; + if !d.is_finite() || d > reach { + // Farther from the path than any feather or expansion reaches. + *o = if inside { 1.0 } else { 0.0 }; + continue; + } + // Signed distance to the expanded edge (negative inside). + let sd = if inside { -d } else { d } - exp; + let (ro, ri) = prof.at(arc[i] as f64); + let a = if sd > 0.0 { + if ro > 0.5 { ramp(1.0 - sd / ro) } else { (0.5 - sd).clamp(0.0, 1.0) } + } else if ri > 0.5 { + ramp(-sd / ri) + } else { + (0.5 - sd).clamp(0.0, 1.0) + }; + *o = a as f32; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square() -> ShapePath { + // 100×100 square from (50,50) to (150,150), clockwise: top, right, bottom, left segments. + ShapePath::rect([100.0, 100.0], 100.0, 100.0) + } + + fn at(m: &Mask, x: u32, y: u32) -> f32 { + m.data[(y * m.width + x) as usize] + } + + #[test] + fn no_points_is_the_hard_mask() { + let m = variable_feather_coverage(&square(), &Mat3::IDENTITY, 200, 200, 0.0, false); + assert!(at(&m, 100, 100) > 0.99 && at(&m, 40, 100) < 0.01); + } + + #[test] + fn single_outer_point_feathers_all_around() { + let mut sp = square(); + sp.feather = vec![FeatherPoint { segment: 0, t: 0.5, radius: 20.0, tension: 0.0 }]; + let m = variable_feather_coverage(&sp, &Mat3::IDENTITY, 200, 200, 0.0, true); + // Above the top edge: 1 at the edge falling linearly to 0 at 20 px. + assert!((at(&m, 100, 39) - 0.475).abs() < 0.06, "{}", at(&m, 100, 39)); + assert!(at(&m, 100, 28) < 0.01); + assert!(at(&m, 100, 60) > 0.99, "inside stays opaque"); + // One point applies to the whole closed path. + assert!((at(&m, 161, 100) - 0.475).abs() < 0.06, "{}", at(&m, 161, 100)); + } + + #[test] + fn outer_and_inner_points_vary_along_the_path() { + let mut sp = square(); + // Outer 20 px in the middle of the top edge, inner 20 px in the middle of the bottom edge. + sp.feather = vec![FeatherPoint { segment: 0, t: 0.5, radius: 20.0, tension: 1.0 }, FeatherPoint { segment: 2, t: 0.5, radius: -20.0, tension: 1.0 }]; + let m = variable_feather_coverage(&sp, &Mat3::IDENTITY, 200, 200, 0.0, true); + // Top: soft outside, hard inside. + assert!(at(&m, 100, 45) > 0.6 && at(&m, 100, 45) < 0.8, "{}", at(&m, 100, 45)); + assert!(at(&m, 100, 52) > 0.99); + // Bottom: hard outside, soft inside (0 at the edge, 1 at 20 px in). + assert!(at(&m, 100, 152) < 0.01); + assert!((at(&m, 100, 140) - 0.475).abs() < 0.06, "{}", at(&m, 100, 140)); + // Half way round (middle of the right edge): both radii are half way, 10 px. + assert!((at(&m, 154, 100) - 0.55).abs() < 0.05, "{}", at(&m, 154, 100)); + assert!((at(&m, 145, 100) - 0.45).abs() < 0.05, "{}", at(&m, 145, 100)); + } + + #[test] + fn scale_and_expansion() { + let mut sp = square(); + sp.feather = vec![FeatherPoint { segment: 0, t: 0.5, radius: 10.0, tension: 0.0 }]; + // Half resolution: the 10 px feather is 5 buffer pixels. + let m = variable_feather_coverage(&sp, &Mat3::scale(vec2(0.5, 0.5)), 100, 100, 0.0, true); + assert!(at(&m, 50, 22) > 0.4 && at(&m, 50, 22) < 0.6, "{}", at(&m, 50, 22)); + let e = variable_feather_coverage(&sp, &Mat3::IDENTITY, 200, 200, 10.0, true); + assert!(at(&e, 100, 41) > 0.99, "expanded by 10 px: {}", at(&e, 100, 41)); + assert!((at(&e, 100, 34) - 0.45).abs() < 0.05, "{}", at(&e, 100, 34)); + } +} diff --git a/crates/path/src/interp.rs b/crates/path/src/interp.rs index a3fe075a..4087b26b 100644 --- a/crates/path/src/interp.rs +++ b/crates/path/src/interp.rs @@ -191,7 +191,7 @@ fn from_cubics(cs: &[[P; 4]], closed: bool) -> ShapePath { v[j] = c[3]; } } - ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed } + ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed, feather: Vec::new() } } /// Exactly the same outline with `total` vertices (≥ the current count), the extra vertices @@ -272,7 +272,13 @@ fn rotate(p: &ShapePath, k: usize) -> ShapePath { return p.clone(); } let r = |v: &Vec

| (0..n).map(|i| v[(i + k) % n]).collect(); - ShapePath { vertices: r(&p.vertices), in_tangents: r(&p.in_tangents), out_tangents: r(&p.out_tangents), closed: p.closed } + ShapePath { + vertices: r(&p.vertices), + in_tangents: r(&p.in_tangents), + out_tangents: r(&p.out_tangents), + closed: p.closed, + feather: p.feather.iter().map(|f| effectcraft_keyframe::FeatherPoint { segment: (f.segment + n - k % n) % n, ..*f }).collect(), + } } /// The same path traversed the other way (closed paths keep vertex 0 first). @@ -287,6 +293,8 @@ pub fn reversed(p: &ShapePath) -> ShapePath { in_tangents: idx.iter().map(|i| p.out_tangents[*i]).collect(), out_tangents: idx.iter().map(|i| p.in_tangents[*i]).collect(), closed: p.closed, + // Feather points sit on segments that reversal renumbers: they are not carried. + feather: Vec::new(), } } @@ -447,6 +455,7 @@ pub fn interpolate(a: &ShapePath, b: &ShapePath, u: f64, o: &InterpOpts) -> Shap in_tangents: lin(&a.in_tangents, &b.in_tangents), out_tangents: lin(&a.out_tangents, &b.out_tangents), closed: a.closed, + feather: a.feather.clone(), }; } // Procrustes: b ≈ s R (a − ca) + cb. @@ -468,7 +477,7 @@ pub fn interpolate(a: &ShapePath, b: &ShapePath, u: f64, o: &InterpOpts) -> Shap let (th_u, s_u) = (theta * u, s.max(1e-9).powf(u)); let c_u = lerp(ca, cb, u); let w = (o.bending_resistance / 100.0).clamp(0.0, 1.0); - let mut out = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: a.closed }; + let mut out = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: a.closed, feather: a.feather.clone() }; for i in 0..n { // Rigid path: a's vertex carried by the interpolated similarity, plus the residual // (b − fully transformed a) blended in linearly. diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index d57f857e..b4216e88 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -6,10 +6,12 @@ //! (for Merge Paths) in [`boolean`] and curve offsetting in [`offset`]. pub mod boolean; +pub mod feather; mod fit; pub mod interp; pub mod offset; pub mod ops; +pub mod varstroke; use effectcraft_geom::Mat3; use effectcraft_keyframe::ShapePath; @@ -73,7 +75,13 @@ pub fn from_kurbo(path: &BezPath) -> Vec { match *el { PathEl::MoveTo(p) => { flush(&mut cur, &mut out); - cur = Some(ShapePath { vertices: vec![[p.x, p.y]], in_tangents: vec![[0.0; 2]], out_tangents: vec![[0.0; 2]], closed: false }); + cur = Some(ShapePath { + vertices: vec![[p.x, p.y]], + in_tangents: vec![[0.0; 2]], + out_tangents: vec![[0.0; 2]], + closed: false, + feather: Vec::new(), + }); last = p; } PathEl::LineTo(p) => { @@ -251,11 +259,22 @@ pub struct StrokeStyle { pub miter: f64, /// Dash pattern (dash, gap, …) and offset. pub dash: Option<(Vec, f64)>, + /// Stroke Taper (variable width at the ends). + pub taper: Option, + /// Stroke Wave (periodic width). + pub wave: Option, +} + +impl StrokeStyle { + /// Whether the width varies along the path (Taper or Wave active). + pub fn is_variable(&self) -> bool { + self.taper.as_ref().is_some_and(|t| t.is_active()) || self.wave.as_ref().is_some_and(|w| w.is_active()) + } } impl Default for StrokeStyle { fn default() -> Self { - StrokeStyle { width: 1.0, cap: Cap::Butt, join: Join::Miter, miter: 4.0, dash: None } + StrokeStyle { width: 1.0, cap: Cap::Butt, join: Join::Miter, miter: 4.0, dash: None, taper: None, wave: None } } } @@ -264,6 +283,9 @@ pub fn stroke_outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) -> if style.width <= 0.0 { return None; } + if style.is_variable() { + return varstroke::outline(paths, style, res_scale); + } let mut all = BezPath::new(); for p in paths { all.extend(p.elements().iter().copied()); diff --git a/crates/path/src/varstroke.rs b/crates/path/src/varstroke.rs new file mode 100644 index 00000000..61e6a1ab --- /dev/null +++ b/crates/path/src/varstroke.rs @@ -0,0 +1,399 @@ +//! Variable-width strokes: the shape Stroke's **Taper** (start/end length, width and ease) and +//! **Wave** (amount, wavelength or cycles, phase), with dashes cut along the same arc length. +//! +//! The stroke is built as a union of positively-wound primitives: one trapezoid per flattened +//! segment (its two ends at the local widths), a disc at every bend (round joins) and the caps. +//! Filled with the non-zero rule the union is exact, whatever the width profile does. + +use kurbo::{BezPath, PathEl, Point, Shape, Vec2}; + +use crate::{Cap, StrokeStyle}; + +/// Stroke Taper. Lengths are pixels, or fractions of each path's length when `percent`; widths and +/// eases are fractions (0–1). A width of 0 tapers to a point. +#[derive(Clone, Debug, PartialEq)] +pub struct Taper { + pub percent: bool, + pub start_len: f64, + pub end_len: f64, + pub start_width: f64, + pub end_width: f64, + pub start_ease: f64, + pub end_ease: f64, +} + +impl Taper { + pub fn is_active(&self) -> bool { + (self.start_len > 0.0 && self.start_width < 1.0) || (self.end_len > 0.0 && self.end_width < 1.0) + } +} + +/// Stroke Wave: the width oscillates between `1 - amount` and 1 of the stroke width. +#[derive(Clone, Debug, PartialEq)] +pub struct Wave { + /// 0–1. + pub amount: f64, + /// Units: `Some(cycles)` = cycles along each path, else `wavelength` in pixels. + pub cycles: Option, + pub wavelength: f64, + /// Degrees. + pub phase: f64, +} + +impl Wave { + pub fn is_active(&self) -> bool { + self.amount > 0.0 && self.cycles.map_or(self.wavelength > 0.0, |c| c > 0.0) + } +} + +/// Ease profile from the tip (`u = 0`) to full width (`u = 1`): linear at ease 0, a quarter +/// circle (bulging outwards) at ease 1. +fn ease(u: f64, e: f64) -> f64 { + let u = u.clamp(0.0, 1.0); + let round = (1.0 - (1.0 - u) * (1.0 - u)).max(0.0).sqrt(); + u + (round - u) * e.clamp(0.0, 1.0) +} + +/// Width factor (0–1) at arc length `s` of a path `len` long. +pub fn width_factor(taper: Option<&Taper>, wave: Option<&Wave>, s: f64, len: f64) -> f64 { + let mut k = 1.0; + if let Some(t) = taper.filter(|t| t.is_active()) { + let (mut ls, mut le) = if t.percent { (t.start_len * len, t.end_len * len) } else { (t.start_len, t.end_len) }; + ls = ls.max(0.0); + le = le.max(0.0); + // Overlapping tapers share the path in proportion. + if ls + le > len && ls + le > 0.0 { + let f = len / (ls + le); + ls *= f; + le *= f; + } + let sw = t.start_width.clamp(0.0, 1.0); + let ew = t.end_width.clamp(0.0, 1.0); + if ls > 0.0 && s < ls { + k = f64::min(k, sw + (1.0 - sw) * ease(s / ls, t.start_ease)); + } + if le > 0.0 && s > len - le { + k = f64::min(k, ew + (1.0 - ew) * ease((len - s) / le, t.end_ease)); + } + } + if let Some(w) = wave.filter(|w| w.is_active()) { + let lambda = match w.cycles { + Some(c) => len / c, + None => w.wavelength, + }; + if lambda > 0.0 { + let ph = std::f64::consts::TAU * s / lambda + w.phase.to_radians(); + k *= 1.0 - w.amount.clamp(0.0, 1.0) * (0.5 - 0.5 * ph.cos()); + } + } + k +} + +/// A flattened subpath: points and their cumulative arc lengths. +struct Poly { + pts: Vec, + s: Vec, + closed: bool, +} + +impl Poly { + fn len(&self) -> f64 { + self.s.last().copied().unwrap_or(0.0) + } + /// Point at arc length `t` (clamped), and the index of the segment it lies on. + fn at(&self, t: f64) -> (Point, usize) { + let i = self.s.partition_point(|&x| x <= t).clamp(1, self.pts.len() - 1); + let (a, b) = (self.s[i - 1], self.s[i]); + let u = if b > a { ((t - a) / (b - a)).clamp(0.0, 1.0) } else { 0.0 }; + (self.pts[i - 1].lerp(self.pts[i], u), i) + } +} + +/// Flatten each subpath, then subdivide so no segment is longer than `step`. +fn flatten(path: &BezPath, tol: f64, step: f64) -> Vec { + let mut out: Vec = Vec::new(); + let mut cur: Vec = Vec::new(); + let mut closed = false; + let flush = |cur: &mut Vec, closed: bool, out: &mut Vec| { + let mut pts: Vec = Vec::with_capacity(cur.len()); + for &p in cur.iter() { + if pts.last().is_none_or(|q: &Point| q.distance(p) > 1e-9) { + pts.push(p); + } + } + if closed && pts.len() > 2 && pts[0].distance(pts[pts.len() - 1]) > 1e-9 { + pts.push(pts[0]); + } + cur.clear(); + if pts.len() < 2 { + return; + } + // Subdivide. + let mut fine = vec![pts[0]]; + for w in pts.windows(2) { + let d = w[0].distance(w[1]); + let n = (d / step).ceil().max(1.0) as usize; + for k in 1..=n { + fine.push(w[0].lerp(w[1], k as f64 / n as f64)); + } + } + let mut s = Vec::with_capacity(fine.len()); + let mut acc = 0.0; + s.push(0.0); + for w in fine.windows(2) { + acc += w[0].distance(w[1]); + s.push(acc); + } + out.push(Poly { pts: fine, s, closed }); + }; + kurbo::flatten(path.elements().iter().copied(), tol, |el| match el { + PathEl::MoveTo(p) => { + flush(&mut cur, closed, &mut out); + closed = false; + cur.push(p); + } + PathEl::LineTo(p) => cur.push(p), + PathEl::ClosePath => { + closed = true; + flush(&mut cur, closed, &mut out); + closed = false; + } + _ => {} + }); + flush(&mut cur, closed, &mut out); + out +} + +/// Arc-length intervals that are "on" for a dash pattern on a path `len` long. +pub fn dash_intervals(pattern: &[f64], offset: f64, len: f64) -> Vec<(f64, f64)> { + let pat: Vec = pattern.iter().map(|v| v.max(0.0)).collect(); + let pat = if pat.len() % 2 == 1 { [pat.clone(), pat].concat() } else { pat }; + let period: f64 = pat.iter().sum(); + if pat.len() < 2 || period <= 1e-9 || len <= 0.0 { + return vec![(0.0, len)]; + } + // Pattern position at s = 0. + let mut pos = offset.rem_euclid(period); + let mut idx = 0; + while pos >= pat[idx] { + pos -= pat[idx]; + idx = (idx + 1) % pat.len(); + } + let mut s = 0.0; + let mut out = Vec::new(); + let mut guard = 0usize; + while s < len && guard < 1_000_000 { + guard += 1; + let seg = pat[idx] - pos; + let e = (s + seg).min(len); + if idx % 2 == 0 && e >= s { + out.push((s, e)); + } + s += seg; + pos = 0.0; + idx = (idx + 1) % pat.len(); + } + out +} + +/// Append `p` to `out`, wound positively. +fn push_pos(out: &mut BezPath, p: BezPath) { + let p = if p.area() < 0.0 { p.reverse_subpaths() } else { p }; + out.extend(p.elements().iter().copied()); +} + +fn quad(out: &mut BezPath, pts: [Point; 4]) { + let mut p = BezPath::new(); + p.move_to(pts[0]); + p.line_to(pts[1]); + p.line_to(pts[2]); + p.line_to(pts[3]); + p.close_path(); + if p.area().abs() > 1e-12 { + push_pos(out, p); + } +} + +fn disc(out: &mut BezPath, c: Point, r: f64) { + if r > 1e-6 { + push_pos(out, kurbo::Circle::new(c, r).to_path(0.05)); + } +} + +/// Outline of a tapered / waved stroke (local space; `res_scale` = device/local scale). +pub fn outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) -> Option { + if style.width <= 0.0 { + return None; + } + let rs = res_scale.max(0.01); + let mut out = BezPath::new(); + let half = style.width / 2.0; + for path in paths { + // Fine enough for the width profile: ½ device pixel, at most ~20 000 samples per path. + let rough = flatten(path, 0.1 / rs, f64::INFINITY); + for poly0 in rough { + let len0 = poly0.len(); + if len0 <= 0.0 { + continue; + } + let step = (0.5 / rs).max(len0 / 20_000.0); + let mut bp = BezPath::new(); + bp.move_to(poly0.pts[0]); + for p in &poly0.pts[1..] { + bp.line_to(*p); + } + let poly = flatten(&bp, 0.1 / rs, step).pop()?; + let poly = Poly { closed: poly0.closed, ..poly }; + let len = poly.len(); + let wf = |s: f64| half * width_factor(style.taper.as_ref(), style.wave.as_ref(), s, len); + let intervals = match &style.dash { + Some((d, off)) if d.iter().any(|v| *v > 0.0) => dash_intervals(d, *off, len), + _ => vec![(0.0, len)], + }; + let whole = intervals.len() == 1 && intervals[0].0 <= 0.0 && intervals[0].1 >= len; + for (a, b) in intervals { + // The piece's points: interpolated ends plus every vertex strictly inside. + let (pa, ia) = poly.at(a); + let (pb, ib) = poly.at(b); + let mut pts = vec![(pa, a)]; + for i in ia..ib { + pts.push((poly.pts[i], poly.s[i])); + } + pts.push((pb, b)); + pts.dedup_by(|x, y| x.0.distance(y.0) < 1e-9); + if pts.len() < 2 { + // A zero-length dash: only its caps show. + if matches!(style.cap, Cap::Round) { + disc(&mut out, pa, wf(a)); + } + continue; + } + let closed = whole && poly.closed; + let dir = |i: usize| (pts[i + 1].0 - pts[i].0).normalize(); + for i in 0..pts.len() - 1 { + let d = dir(i); + let n = Vec2::new(-d.y, d.x); + let (p0, s0) = pts[i]; + let (p1, s1) = pts[i + 1]; + let (w0, w1) = (wf(s0), wf(s1)); + quad(&mut out, [p0 + n * w0, p1 + n * w1, p1 - n * w1, p0 - n * w0]); + // Joins: a disc where the direction turns. + if i > 0 && dir(i - 1).dot(d) < 0.9999 { + disc(&mut out, p0, w0); + } + } + if closed { + disc(&mut out, pts[0].0, wf(0.0)); + continue; + } + // Caps. + let last = pts.len() - 1; + let ends = [(pts[0].0, wf(pts[0].1), -dir(0)), (pts[last].0, wf(pts[last].1), dir(last - 1))]; + for (p, w, outward) in ends { + match style.cap { + Cap::Butt => {} + Cap::Round => disc(&mut out, p, w), + Cap::Square => { + let n = Vec2::new(-outward.y, outward.x); + let q = p + outward * w; + quad(&mut out, [p + n * w, q + n * w, q - n * w, p - n * w]); + } + } + } + } + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FillRule, fill_coverage}; + use effectcraft_geom::Mat3; + + fn line(len: f64) -> BezPath { + let mut p = BezPath::new(); + p.move_to((10.0, 50.0)); + p.line_to((10.0 + len, 50.0)); + p + } + + fn column(m: &effectcraft_raster::Mask, x: usize) -> f32 { + (0..m.height as usize).map(|y| m.data[y * m.width as usize + x]).sum() + } + + #[test] + fn taper_width_profile() { + let t = Taper { percent: false, start_len: 50.0, end_len: 0.0, start_width: 0.0, end_width: 1.0, start_ease: 0.0, end_ease: 0.0 }; + assert!(width_factor(Some(&t), None, 0.0, 200.0).abs() < 1e-9); + assert!((width_factor(Some(&t), None, 25.0, 200.0) - 0.5).abs() < 1e-9); + assert!((width_factor(Some(&t), None, 100.0, 200.0) - 1.0).abs() < 1e-9); + // Ease bulges the profile outwards. + let e = Taper { start_ease: 1.0, ..t.clone() }; + assert!(width_factor(Some(&e), None, 25.0, 200.0) > 0.8); + // Percent units and overlapping tapers. + let p = Taper { percent: true, start_len: 0.75, end_len: 0.75, start_width: 0.0, end_width: 0.0, ..t }; + assert!((width_factor(Some(&p), None, 100.0, 200.0) - 1.0).abs() < 1e-9, "meet in the middle at full width"); + assert!(width_factor(Some(&p), None, 50.0, 200.0) < 0.6); + } + + #[test] + fn wave_width_profile() { + let w = Wave { amount: 1.0, cycles: None, wavelength: 40.0, phase: 0.0 }; + assert!((width_factor(None, Some(&w), 0.0, 200.0) - 1.0).abs() < 1e-9); + assert!(width_factor(None, Some(&w), 20.0, 200.0).abs() < 1e-9); + let c = Wave { cycles: Some(5.0), ..w.clone() }; + assert!(width_factor(None, Some(&c), 20.0, 200.0).abs() < 1e-9, "5 cycles over 200 px = 40 px"); + let ph = Wave { phase: 180.0, ..w }; + assert!(width_factor(None, Some(&ph), 0.0, 200.0).abs() < 1e-9); + } + + #[test] + fn tapered_line_coverage_grows_along_the_path() { + let t = Taper { percent: false, start_len: 100.0, end_len: 0.0, start_width: 0.0, end_width: 1.0, start_ease: 0.0, end_ease: 0.0 }; + let st = StrokeStyle { width: 20.0, taper: Some(t), ..Default::default() }; + let o = outline(&[line(150.0)], &st, 1.0).unwrap(); + let m = fill_coverage(&[o], &Mat3::IDENTITY, 200, 100, FillRule::NonZero); + let (c1, c2, c3) = (column(&m, 30), column(&m, 70), column(&m, 140)); + assert!((c1 - 4.0).abs() < 1.0, "20% along the taper → 4 px: {c1}"); + assert!((c2 - 12.0).abs() < 1.0, "{c2}"); + assert!((c3 - 20.0).abs() < 0.6, "{c3}"); + let area: f32 = m.data.iter().sum(); + assert!((area - (100.0 * 10.0 + 50.0 * 20.0)).abs() < 40.0, "{area}"); + } + + #[test] + fn dash_intervals_multi_segment() { + let iv = dash_intervals(&[10.0, 5.0, 2.0, 3.0], 0.0, 45.0); + assert_eq!(iv, vec![(0.0, 10.0), (15.0, 17.0), (20.0, 30.0), (35.0, 37.0), (40.0, 45.0)]); + let off = dash_intervals(&[10.0, 10.0], 5.0, 30.0); + assert_eq!(off, vec![(0.0, 5.0), (15.0, 25.0)]); + } + + #[test] + fn waved_dashed_stroke_has_gaps() { + let st = StrokeStyle { + width: 10.0, + dash: Some((vec![20.0, 10.0], 0.0)), + wave: Some(Wave { amount: 0.5, cycles: None, wavelength: 30.0, phase: 0.0 }), + ..Default::default() + }; + let o = outline(&[line(150.0)], &st, 1.0).unwrap(); + let m = fill_coverage(&[o], &Mat3::IDENTITY, 200, 100, FillRule::NonZero); + assert!(column(&m, 41) > 9.0, "dash at full width (wave crest): {}", column(&m, 41)); + assert!(column(&m, 35) < 0.01, "gap"); + assert!(column(&m, 55) > 4.0 && column(&m, 55) < 6.5, "wave trough ≈ half width: {}", column(&m, 55)); + } + + #[test] + fn closed_tapered_path_is_continuous() { + let r = crate::rect([60.0, 60.0], [50.0, 50.0], 0.0); + let st = StrokeStyle { width: 6.0, wave: Some(Wave { amount: 0.2, cycles: Some(4.0), wavelength: 0.0, phase: 0.0 }), ..Default::default() }; + let o = outline(&[r], &st, 1.0).unwrap(); + let m = fill_coverage(&[o], &Mat3::IDENTITY, 100, 100, FillRule::NonZero); + assert!(m.data.iter().all(|v| *v <= 1.0001)); + let area: f32 = m.data.iter().sum(); + assert!(area > 240.0 * 6.0 * 0.8 && area < 240.0 * 6.0 * 1.05, "{area}"); + } +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index a201879d..931a846b 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -438,8 +438,35 @@ pub fn shape_stroke(ids: &mut Ids, color: [f64; 4], width: f64) -> PropGroup { .with(ids.prop("gap", "Gap", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)) .with(ids.prop("offset", "Offset", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)), ) + .with(stroke_taper(ids)) + .with(stroke_wave(ids)) } +/// Stroke ▸ Taper (After Effects 17.1+): width ramps at the start and end of each path. +pub fn stroke_taper(ids: &mut Ids) -> PropGroup { + ids.group("taper", "Taper") + .with(ids.prop("units", "Length Units", Value::Enum(0)).with_ui(popup(&["Pixels", "Percent"]))) + .with(ids.prop("startLength", "Start Length", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)) + .with(ids.prop("endLength", "End Length", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)) + .with(ids.prop("startWidth", "Start Width", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1))) + .with(ids.prop("endWidth", "End Width", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1))) + .with(ids.prop("startEase", "Start Ease", Value::Scalar(0.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1))) + .with(ids.prop("endEase", "End Ease", Value::Scalar(0.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1))) +} + +/// Stroke ▸ Wave: the width oscillates along the path. +pub fn stroke_wave(ids: &mut Ids) -> PropGroup { + ids.group("wave", "Wave") + .with(ids.prop("amount", "Amount", Value::Scalar(0.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1))) + .with(ids.prop("units", "Units", Value::Enum(0)).with_ui(popup(&["Pixels", "Cycles"]))) + .with(ids.prop("wavelength", "Wavelength", Value::Scalar(20.0)).with_ui(ParamUi::Pixels)) + .with(ids.prop("cycles", "Cycles", Value::Scalar(10.0)).with_ui(slider(0.0, 1000.0, 0.0, 50.0, 1))) + .with(ids.prop("phase", "Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) +} + +/// Match ids of the optional Dash/Gap pairs After Effects adds with the Dashes "+" button. +pub const EXTRA_DASHES: [(&str, &str); 2] = [("dash2", "gap2"), ("dash3", "gap3")]; + pub fn shape_gradient_fill(ids: &mut Ids, radial: bool, start: [f64; 2], end: [f64; 2], g: Gradient) -> PropGroup { indexed(ids.group("gfill", "Gradient Fill 1")) .with(ids.prop("blend", "Blend Mode", Value::Enum(0)).with_ui(ParamUi::Hidden)) @@ -475,6 +502,8 @@ pub fn shape_gradient_stroke(ids: &mut Ids, radial: bool, start: [f64; 2], end: .with(ids.prop("gap", "Gap", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)) .with(ids.prop("offset", "Offset", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)), ) + .with(stroke_taper(ids)) + .with(stroke_wave(ids)) } pub fn shape_trim(ids: &mut Ids, start: f64, end: f64, offset: f64) -> PropGroup { diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 4a00c413..c4e622f4 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -194,7 +194,12 @@ pub struct Comp { pub markers: Vec, pub shutter_angle: f64, pub shutter_phase: f64, + /// Samples Per Frame: the minimum motion-blur samples (and the count for 3D layers). pub motion_blur_samples: u32, + /// Adaptive Sample Limit (Composition Settings ▸ Advanced): the most samples a 2D layer gets + /// when it moves far within the shutter. + #[serde(default = "adaptive_limit_default")] + pub motion_blur_adaptive_limit: u32, #[serde(default)] pub renderer: Renderer, /// Comp switches (timeline toolbar). @@ -227,6 +232,10 @@ fn yes() -> bool { true } +fn adaptive_limit_default() -> u32 { + 128 +} + impl Comp { pub fn new(width: u32, height: u32, frame_rate: FrameRate, duration: Tick) -> Comp { Comp { @@ -243,6 +252,7 @@ impl Comp { shutter_angle: 180.0, shutter_phase: -90.0, motion_blur_samples: 16, + motion_blur_adaptive_limit: 128, renderer: Renderer::Classic3D, hide_shy: false, enable_motion_blur: true, diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index c1a3e3b9..afc1ef26 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -522,6 +522,13 @@ impl PropGroup { }) } /// Parent group of the node with `uid`. + pub fn parent_of(&self, uid: Uid) -> Option<&PropGroup> { + if self.children.iter().any(|c| c.uid() == uid) { + return Some(self); + } + self.groups().find_map(|g| g.parent_of(uid)) + } + /// Parent group of the node with `uid`. pub fn parent_of_mut(&mut self, uid: Uid) -> Option<&mut PropGroup> { if self.children.iter().any(|c| c.uid() == uid) { return Some(self); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 64e2684b..224ebb89 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -941,13 +941,51 @@ impl<'a> Renderer<'a> { * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1])) } - /// Motion-blur sub-samples for a layer (1 = no motion blur). - fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer) -> usize { - if self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur { - if self.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize } - } else { - 1 + /// Whether a layer is motion blurred. + fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool { + self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur + } + + /// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D + /// layer gets about one sample per output pixel its corners travel during the shutter, + /// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that + /// does not move within the shutter is drawn once. + fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> usize { + if !self.mb_on(ctx, layer) { + return 1; } + let travel = self.shutter_travel(ctx, layer, buf); + if travel < 1e-3 { + return 1; + } + if self.opts.draft { + return 4; + } + adaptive_samples(travel, ctx.comp.motion_blur_samples, ctx.comp.motion_blur_adaptive_limit) + } + + /// Largest distance (output pixels) a corner of the layer buffer moves during the shutter, + /// measured along the path at a few points. + fn shutter_travel(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> f64 { + let fd = ctx.comp.frame_duration().seconds(); + let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0); + let (w, h) = (buf.img.width as f64, buf.img.height as f64); + let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h), (w * 0.5, h * 0.5)]; + const PROBES: usize = 8; + let mut prev: Option> = None; + let mut travel = vec![0.0f64; corners.len()]; + for i in 0..PROBES { + let f = phase + angle * i as f64 / (PROBES - 1) as f64; + let m = self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)), layer, buf); + let pts: Vec<_> = corners.iter().map(|&(x, y)| m.apply(vec2(x, y))).collect(); + if let Some(p) = &prev { + for (k, (a, b)) in p.iter().zip(&pts).enumerate() { + travel[k] += (b.x - a.x).hypot(b.y - a.y); + } + } + prev = Some(pts); + } + travel.into_iter().fold(0.0, f64::max) } /// Canvas pixels that compositing `buf` for `layer` can change: the transformed buffer's @@ -957,7 +995,7 @@ impl<'a> Renderer<'a> { /// A quantised buffer placed pixel-exactly (integer translation, Normal, 100%) over a blank /// canvas leaves exactly its own (quantised) pixels: nothing to re-quantise. fn footprint(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, opacity: f32, blank: bool) -> Region { - if layer.blend_mode.is_stencil() || self.mb_samples(ctx, layer) > 1 { + if layer.blend_mode.is_stencil() || self.mb_samples(ctx, layer, buf) > 1 { return Region::Full; } let m = self.buf_matrix(ctx, layer, buf); @@ -992,7 +1030,7 @@ impl<'a> Renderer<'a> { /// How a processed layer buffer lands in the output: one buffer → output matrix, or one per /// motion-blur sub-sample (averaged), with the layer's sampling filter and dissolve seed. pub fn placement(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Placement { - let samples = self.mb_samples(ctx, layer); + let samples = self.mb_samples(ctx, layer, buf); let (sampling, seed) = (self.sampling(layer), layer.id.0 as u32); if samples <= 1 { return Placement { matrices: vec![self.buf_matrix(ctx, layer, buf)], sampling, seed }; @@ -1302,6 +1340,14 @@ pub fn blend_frames(mode: FrameBlend, a: &Image, b: &Image, w: f32) -> Image { } /// Convenience: render one frame of a comp with no footage source. +/// Adaptive motion-blur sample count for a layer whose corners travel `travel` output pixels +/// during the shutter: one sample per pixel, clamped to Samples Per Frame … Adaptive Sample Limit. +pub fn adaptive_samples(travel: f64, per_frame: u32, limit: u32) -> usize { + let lo = per_frame.clamp(2, 64) as f64; + let hi = (limit.clamp(16, 256) as f64).max(lo); + travel.ceil().clamp(lo, hi) as usize +} + pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image { Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t) } diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 1693e4d3..8fcb39b7 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -54,9 +54,17 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, let path = effectcraft_path::to_kurbo(&sp); let (w, h) = (buf.img.width, buf.img.height); let m = Mat3::translate(vec2(buf.offset[0], buf.offset[1])) * Mat3::scale(vec2(buf.scale, buf.scale)); - let mut cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero); let exp = ctx.f(layer, g, "expansion", 0.0); - if exp.abs() > 0.01 { + let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. }); + // Variable-width feather (Mask Feather tool points): expansion and the per-point feather + // are applied together from the signed distance to the path. + let variable = !sp.feather.is_empty(); + let mut cov = if variable { + effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear) + } else { + effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero) + }; + if exp.abs() > 0.01 && !variable { let ring = effectcraft_path::stroke_coverage( std::slice::from_ref(&path), &StrokeStyle { width: exp.abs() * 2.0, join: effectcraft_path::Join::Round, ..Default::default() }, @@ -71,7 +79,6 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, let feather = ctx.v2(layer, g, "feather", [0.0; 2]); if feather[0] > 0.0 || feather[1] > 0.0 { let img = cov.to_image(); - let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. }); let b = if linear { // One box pass: a straight ramp `feather` pixels wide across the edge. let r = |f: f64| ((f * buf.scale / 2.0).round() as usize).max(if f > 0.0 { 1 } else { 0 }); diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index 7cb59135..f03c3c12 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -12,7 +12,7 @@ use effectcraft_color::{BlendMode, blend_pixel}; use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, vec2}; use effectcraft_keyframe::{Gradient, Value}; -use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops}; +use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops, varstroke}; use effectcraft_project::{Layer, Node, PropGroup}; use effectcraft_raster::{Image, Mask}; use rayon::prelude::*; @@ -37,6 +37,8 @@ enum Paint { end: [f64; 2], rule: FillRule, stroke: Option, + /// Radial gradients: Highlight Length (−100…100 %) and Highlight Angle (degrees). + highlight: (f64, f64), }, } @@ -102,7 +104,35 @@ fn stroke_style(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> StrokeStyle { let dash = g.sub("dashes").and_then(|d| { let dash = ctx.f(layer, d, "dash", 0.0); let gap = ctx.f(layer, d, "gap", 0.0); - (dash > 0.0).then(|| (vec![dash, if gap > 0.0 { gap } else { dash }], ctx.f(layer, d, "offset", 0.0))) + if dash <= 0.0 { + return None; + } + let mut pat = vec![dash, if gap > 0.0 { gap } else { dash }]; + // Dash 2 / Gap 2, Dash 3 / Gap 3 (added with the Dashes "+" button). + for (dm, gm) in effectcraft_project::build::EXTRA_DASHES { + if d.get(dm).is_none() { + break; + } + let dv = ctx.f(layer, d, dm, 0.0); + pat.push(dv); + pat.push(if d.get(gm).is_some() { ctx.f(layer, d, gm, 0.0) } else { dv }); + } + Some((pat, ctx.f(layer, d, "offset", 0.0))) + }); + let taper = g.sub("taper").map(|t| varstroke::Taper { + percent: ctx.e(layer, t, "units") == 1, + start_len: ctx.f(layer, t, "startLength", 0.0) / if ctx.e(layer, t, "units") == 1 { 100.0 } else { 1.0 }, + end_len: ctx.f(layer, t, "endLength", 0.0) / if ctx.e(layer, t, "units") == 1 { 100.0 } else { 1.0 }, + start_width: ctx.f(layer, t, "startWidth", 100.0) / 100.0, + end_width: ctx.f(layer, t, "endWidth", 100.0) / 100.0, + start_ease: ctx.f(layer, t, "startEase", 0.0) / 100.0, + end_ease: ctx.f(layer, t, "endEase", 0.0) / 100.0, + }); + let wave = g.sub("wave").map(|w| varstroke::Wave { + amount: ctx.f(layer, w, "amount", 0.0) / 100.0, + cycles: (ctx.e(layer, w, "units") == 1).then(|| ctx.f(layer, w, "cycles", 10.0)), + wavelength: ctx.f(layer, w, "wavelength", 20.0), + phase: ctx.f(layer, w, "phase", 0.0), }); StrokeStyle { width: ctx.f(layer, g, "width", 2.0), @@ -110,6 +140,8 @@ fn stroke_style(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> StrokeStyle { join: [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize], miter: ctx.f(layer, g, "miter", 4.0), dash, + taper: taper.filter(|t| t.is_active()), + wave: wave.filter(|w| w.is_active()), } } @@ -220,6 +252,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec, +} + +impl GradientRamp { + pub(crate) fn new(radial: bool, start: [f64; 2], end: [f64; 2], highlight: (f64, f64)) -> Self { + let d = [end[0] - start[0], end[1] - start[1]]; + let len2 = (d[0] * d[0] + d[1] * d[1]).max(1e-9); + let (hl, ha) = highlight; + let focal = (radial && hl.abs() > 1e-6).then(|| { + let r = len2.sqrt(); + let a = d[1].atan2(d[0]) + ha.to_radians(); + let dist = r * (hl / 100.0).clamp(-0.99, 0.99); + [start[0] + dist * a.cos(), start[1] + dist * a.sin()] + }); + GradientRamp { radial, s: start, d, len2, focal } + } + + pub(crate) fn t(&self, x: f64, y: f64) -> f64 { + let (vx, vy) = (x - self.s[0], y - self.s[1]); + if !self.radial { + return (vx * self.d[0] + vy * self.d[1]) / self.len2; + } + let Some(f) = self.focal else { return ((vx * vx + vy * vy) / self.len2).sqrt() }; + // Ray from the focal point through p meets the circle (centre s, radius r) at λ. + let (px, py) = (x - f[0], y - f[1]); + let dist = (px * px + py * py).sqrt(); + if dist < 1e-12 { + return 0.0; + } + let (ux, uy) = (px / dist, py / dist); + let (cx, cy) = (f[0] - self.s[0], f[1] - self.s[1]); + let b = ux * cx + uy * cy; + let c = cx * cx + cy * cy - self.len2; + let lambda = -b + (b * b - c).max(0.0).sqrt(); + if lambda <= 1e-12 { 1.0 } else { dist / lambda } + } +} + /// Pixel rectangle of the layer buffer (`x0, y0, width, height`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct PxRect { @@ -486,16 +567,12 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf for ((d, _, r), cov) in jobs[i..j].iter().zip(&covs) { match &d.paint { Paint::Fill { color, .. } | Paint::Stroke { color, .. } => paint(&mut img, *r, cov, d.opacity, d.blend, |_, _| *color), - Paint::Gradient { g, radial, start, end, .. } => { + Paint::Gradient { g, radial, start, end, highlight, .. } => { let to_local = (m * d.xf).inverse().unwrap_or(Mat3::IDENTITY); - let (sx, sy) = (start[0], start[1]); - let (dx, dy) = (end[0] - sx, end[1] - sy); - let len2 = (dx * dx + dy * dy).max(1e-9); + let ramp = GradientRamp::new(*radial, *start, *end, *highlight); paint(&mut img, *r, cov, d.opacity, d.blend, |x, y| { let p = to_local.apply(vec2(x as f64 + 0.5, y as f64 + 0.5)); - let (vx, vy) = (p.x - sx, p.y - sy); - let t = if *radial { ((vx * vx + vy * vy) / len2).sqrt() } else { (vx * dx + vy * dy) / len2 }; - g.sample(t) + g.sample(ramp.t(p.x, p.y)) }); } } diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs index 7f96a32e..09cffc3f 100644 --- a/crates/render/src/shapes/tests.rs +++ b/crates/render/src/shapes/tests.rs @@ -340,3 +340,87 @@ fn shape_perf_repeater_60_copies_1080p() { let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64; println!("shape_perf: empty shape layer comp (compositor baseline): {ms:.2} ms"); } + +fn line_path(ids: &mut Ids, from: [f64; 2], to: [f64; 2]) -> PropGroup { + let sp = effectcraft_keyframe::ShapePath { + vertices: vec![from, to], + in_tangents: vec![[0.0; 2]; 2], + out_tangents: vec![[0.0; 2]; 2], + closed: false, + feather: Vec::new(), + }; + build::shape_path(ids, sp) +} + +fn column_alpha(img: &Image, x: i64) -> f32 { + (0..img.height as i64).map(|y| alpha(img, x, y)).sum() +} + +#[test] +fn stroke_taper_narrows_the_ends() { + // A horizontal line from x = 20 to 180 (layer −80…80), 20 px wide, tapered over 40 px at both ends. + let (p, cid) = shape_comp(|ids| { + let mut st = build::shape_stroke(ids, RED, 20.0); + set(&mut st, "taper/startLength", Value::Scalar(40.0)); + set(&mut st, "taper/endLength", Value::Scalar(40.0)); + set(&mut st, "taper/startWidth", Value::Scalar(0.0)); + set(&mut st, "taper/endWidth", Value::Scalar(50.0)); + vec![line_path(ids, [-80.0, 0.0], [80.0, 0.0]), st] + }); + let img = frame(&p, cid); + let near_start = column_alpha(&img, 30); // 10 px along → 25 % of 20 px + let mid = column_alpha(&img, 100); + let near_end = column_alpha(&img, 169); // 10 px before the end → (50 % + 50 % × 25 %) of 20 px + assert!((near_start - 5.0).abs() < 1.0, "{near_start}"); + assert!((mid - 20.0).abs() < 0.6, "{mid}"); + assert!((near_end - 12.5).abs() < 1.0, "{near_end}"); +} + +#[test] +fn stroke_wave_and_multi_dashes() { + let (p, cid) = shape_comp(|ids| { + let mut st = build::shape_stroke(ids, RED, 10.0); + set(&mut st, "wave/amount", Value::Scalar(100.0)); + set(&mut st, "wave/wavelength", Value::Scalar(40.0)); + vec![line_path(ids, [-80.0, 0.0], [80.0, 0.0]), st] + }); + let img = frame(&p, cid); + assert!(column_alpha(&img, 40) < 0.5, "trough (20 px along): {}", column_alpha(&img, 40)); + assert!(column_alpha(&img, 60) > 9.0, "crest (40 px along): {}", column_alpha(&img, 60)); + + // Dash 20, Gap 10, Dash 2 = 5, Gap 2 = 15: on 0–20, 30–35, 50–70… + let (p, cid) = shape_comp(|ids| { + let mut st = build::shape_stroke(ids, RED, 10.0); + set(&mut st, "dashes/dash", Value::Scalar(20.0)); + set(&mut st, "dashes/gap", Value::Scalar(10.0)); + let d = st.sub_mut("dashes").unwrap(); + d.children.insert(2, ids.prop("gap2", "Gap 2", Value::Scalar(15.0)).into()); + d.children.insert(2, ids.prop("dash2", "Dash 2", Value::Scalar(5.0)).into()); + vec![line_path(ids, [-80.0, 0.0], [80.0, 0.0]), st] + }); + let img = frame(&p, cid); + let on = |s: i64| column_alpha(&img, 20 + s) > 9.0; + assert!(on(10) && !on(25) && on(32) && !on(40) && on(60), "{:?}", (0..80).map(|s| on(s) as u8).collect::>()); +} + +#[test] +fn radial_gradient_highlight_moves_the_focal_point() { + let build_with = |hl: f64| { + shape_comp_sized(200, 200, move |ids| { + let mut gf = build::shape_gradient_fill(ids, true, [0.0, 0.0], [80.0, 0.0], Gradient::default()); + set(&mut gf, "highlightLength", Value::Scalar(hl)); + set(&mut gf, "highlightAngle", Value::Scalar(90.0)); + vec![build::shape_rect(ids, [200.0, 200.0], [0.0, 0.0], 0.0), gf] + }) + }; + let (p0, c0) = build_with(0.0); + let (p1, c1) = build_with(50.0); + let (a, b) = (frame(&p0, c0), frame(&p1, c1)); + // Without a highlight the centre is white; a 50 % highlight at 90° (down the +y axis, 90° from + // the start→end axis) puts the white focus 40 px below the centre and darkens the centre. + assert!(a.get(100, 100)[0] > 0.98); + assert!(b.get(100, 140)[0] > 0.97, "focal point: {:?}", b.get(100, 140)); + assert!(b.get(100, 100)[0] < 0.8, "{:?}", b.get(100, 100)); + // The outer circle is unchanged: points just outside it are black in both. + assert!(a.get(100, 182)[0] < 0.03 && b.get(100, 182)[0] < 0.03 && b.get(100, 17)[0] < 0.03); +} diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index ab334067..9d7effa0 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -228,7 +228,7 @@ fn anchor_grouping_line_rotates_around_line_centre() { fn mask(p: &mut Project, l: &mut Layer, pts: &[[f64; 2]], closed: bool) { let mut next = p.next_id; let mut ids = Ids(&mut next); - let sp = ShapePath { vertices: pts.to_vec(), in_tangents: vec![[0.0; 2]; pts.len()], out_tangents: vec![[0.0; 2]; pts.len()], closed }; + let sp = ShapePath { vertices: pts.to_vec(), in_tangents: vec![[0.0; 2]; pts.len()], out_tangents: vec![[0.0; 2]; pts.len()], closed, feather: Vec::new() }; let g = build::mask(&mut ids, "Mask 1", sp, MaskMode::None, [255, 255, 0]); p.next_id = next; let masks = l.props.group_mut("masks").unwrap(); diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index 623027c1..03a3090e 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -162,6 +162,8 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[ ("stroke", "join", "ADBE Vector Stroke Line Join"), ("stroke", "miter", "ADBE Vector Stroke Miter Limit"), ("stroke", "dashes", "ADBE Vector Stroke Dashes"), + ("stroke", "taper", "ADBE Vector Stroke Taper"), + ("stroke", "wave", "ADBE Vector Stroke Wave"), ("trim", "start", "ADBE Vector Trim Start"), ("trim", "end", "ADBE Vector Trim End"), ("trim", "offset", "ADBE Vector Trim Offset"), diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index d1dd4768..03e80bb5 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -130,6 +130,7 @@ fn item(s: &Session, a: &J) -> R { "hideShyLayers": c.hide_shy, "motionBlur": c.enable_motion_blur, "frameBlending": c.enable_frame_blending, "draft3d": c.draft_3d, "shutterAngle": c.shutter_angle, "shutterPhase": c.shutter_phase, "motionBlurSamplesPerFrame": c.motion_blur_samples, + "motionBlurAdaptiveSampleLimit": c.motion_blur_adaptive_limit, "renderer": match c.renderer { effectcraft_project::Renderer::Classic3D => "ADBE Classic 3D", effectcraft_project::Renderer::Advanced3D => "ADBE Advanced 3d" }, "open": s.state.open_comps.contains(&cid), "active": s.active_comp_id() == Some(cid), "hasVideo": true, "hasAudio": c.layers.iter().any(|l| l.switches.audio && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. })), diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index d46b3612..369acf08 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -85,6 +85,7 @@ pub struct CompDraft { pub shutter_angle: f64, pub shutter_phase: f64, pub samples: u32, + pub adaptive_limit: u32, pub advanced_3d: bool, pub tab: Tab, /// Timecode text being edited (start, duration). @@ -108,6 +109,7 @@ impl Default for CompDraft { shutter_angle: 180.0, shutter_phase: -90.0, samples: 16, + adaptive_limit: 128, advanced_3d: false, tab: Tab::Basic, start_tc: None, @@ -294,6 +296,9 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option { @@ -325,7 +330,7 @@ pub fn params(d: &CompDraft) -> serde_json::Value { json!({ "name": d.name, "width": d.width, "height": d.height, "pixelAspect": d.pixel_aspect, "frameRate": d.fps, "duration": d.duration, "startTime": d.start, "background": [d.bg[0], d.bg[1], d.bg[2]], "anchor": d.anchor, - "shutterAngle": d.shutter_angle, "shutterPhase": d.shutter_phase, "motionBlurSamples": d.samples, + "shutterAngle": d.shutter_angle, "shutterPhase": d.shutter_phase, "motionBlurSamples": d.samples, "adaptiveSampleLimit": d.adaptive_limit, "renderer": if d.advanced_3d { "advanced3D" } else { "classic3D" }, }) } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 5aea865e..5db2d5ae 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -66,6 +66,7 @@ pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> { shutter_angle: c.shutter_angle, shutter_phase: c.shutter_phase, samples: c.motion_blur_samples, + adaptive_limit: c.motion_blur_adaptive_limit, advanced_3d: c.renderer == effectcraft_engine::project::Renderer::Advanced3D, ..Default::default() }; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b9289e77..ae43c4ac 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1393,6 +1393,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text); text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions); + dash_buttons(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions); // Mask mode + inverted inline. if let Some(g) = layer.props.find_group(*uid) && let GroupKind::Mask { mode, inverted, .. } = g.kind @@ -2002,6 +2003,37 @@ fn walk_groups(g: &PropGroup, out: &mut Vec) { } } +/// A stroke's Dashes "+" / "−" buttons (add or remove a Dash/Gap pair). +#[allow(clippy::too_many_arguments)] +fn dash_buttons( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + uid: u64, + x: f32, + cy: f32, + actions: &mut Vec<(String, serde_json::Value)>, +) { + if !matches!(layer.source, LayerSource::Shape) || layer.props.find_group(uid).is_none_or(|g| g.match_id != "dashes") { + return; + } + let Some(stroke) = layer.props.parent_of(uid).map(|g| g.uid) else { return }; + let t = app.tokens; + for (i, (sym, id, tip)) in + [("+", "shape.dashes.add", "Add a Dash or Gap"), ("\u{2212}", "shape.dashes.remove", "Remove a Dash or Gap")].into_iter().enumerate() + { + let r = Rect::from_center_size(pos2(x + 12.0 + i as f32 * 20.0, cy), vec2(16.0, 16.0)); + let resp = ui.interact(r, egui::Id::new(("tl-dash", uid, i)), Sense::click()).on_hover_text(tip); + p.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); + p.text(r.center(), Align2::CENTER_CENTER, sym, Tokens::ui(12.0), if resp.hovered() { t.text } else { t.text_dim }); + app.auto.add(&format!("timeline.group.{uid}.{}", if i == 0 { "addDash" } else { "removeDash" }), r, tip); + if resp.clicked() { + actions.push((id.into(), json!({"layer": layer.id.0, "prop": stroke}))); + } + } +} + /// The Text group's "Animate:" and an animator's "Add:" pop-up menus (AE's twirl-down menus). #[allow(clippy::too_many_arguments)] fn text_anim_popups( diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 08cc32b5..38358c01 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -41,6 +41,8 @@ pub(crate) struct PathInfo { pub m: Mat3, pub sp: ShapePath, pub is_mask: bool, + /// Mask feather points' radius handles on screen (feather point index, handle position). + pub feather: Vec<(usize, Pos2)>, } fn cubic(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] { @@ -93,7 +95,7 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option Vec { @@ -178,10 +180,55 @@ pub(crate) fn draw_paths( { painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6))); } - paths.push(PathInfo { layer: l.id, uid, m: pm, sp, is_mask }); + let feather = if is_mask { draw_feather_points(painter, map, &sp, &pm, mc) } else { Vec::new() }; + for (i, h) in &feather { + app.auto.add(&format!("viewer.mask.{uid}.feather.{i}"), Rect::from_center_size(*h, vec2(8.0, 8.0)), &name); + } + paths.push(PathInfo { layer: l.id, uid, m: pm, sp, is_mask, feather }); } } +/// Mask feather points: a hollow dot on the path and a radius handle along the normal (outwards for +/// outer feather, inwards for inner), joined by a line. Returns the handles' screen positions. +fn draw_feather_points(painter: &egui::Painter, map: &ViewerMap, sp: &ShapePath, pm: &Mat3, col: Color32) -> Vec<(usize, Pos2)> { + let mut out = Vec::new(); + let scr = |p: [f64; 2]| { + let c = pm.apply(gv2(p[0], p[1])); + map.to_screen([c.x, c.y]) + }; + let inside = |q: [f64; 2]| { + // Even-odd test against the vertex polygon (good enough to orient the handle). + let v = &sp.vertices; + let mut c = false; + for i in 0..v.len() { + let (a, b) = (v[i], v[(i + 1) % v.len()]); + if (a[1] > q[1]) != (b[1] > q[1]) && q[0] < (b[0] - a[0]) * (q[1] - a[1]) / (b[1] - a[1]) + a[0] { + c = !c; + } + } + c + }; + for (i, f) in sp.feather.iter().enumerate() { + let Some(c) = segment(sp, f.segment) else { continue }; + let at = cubic(c[0], c[1], c[2], c[3], f.t); + let (t0, t1) = ((f.t - 1e-3).max(0.0), (f.t + 1e-3).min(1.0)); + let (a, b) = (cubic(c[0], c[1], c[2], c[3], t0), cubic(c[0], c[1], c[2], c[3], t1)); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len = dx.hypot(dy).max(1e-9); + let mut n = [-dy / len, dx / len]; + if inside([at[0] + n[0] * 2.0, at[1] + n[1] * 2.0]) { + n = [-n[0], -n[1]]; + } + let h = [at[0] + n[0] * f.radius, at[1] + n[1] * f.radius]; + let (ps, hs) = (scr(at), scr(h)); + painter.line_segment([ps, hs], Stroke::new(1.0, col)); + painter.circle_stroke(ps, 3.5, Stroke::new(1.0, col)); + painter.circle_filled(hs, 3.0, col); + out.push((i, hs)); + } + out +} + // ---------------------------------------------------------------- motion paths /// A keyframe dot or spatial tangent handle on a motion path. @@ -382,8 +429,8 @@ pub(crate) enum Drag { KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 }, /// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`. Ft { layer: LayerId, path: u64, mode: u8, anchor: [f64; 2], last: [f64; 2], inv: Mat3, axes: [bool; 2] }, - /// Mask Feather tool: drag away from the path to feather the mask. - Feather { layer: LayerId, prop: u64, start: [f64; 2], press: Pos2 }, + /// Mask Feather tool: drag a feather point's radius handle (comp → layer matrix `inv`). + Feather { layer: LayerId, mask: u64, index: usize, inv: Mat3 }, /// A guide dragged in the viewer. Guide { index: usize, vertical: bool }, /// Drawing the region of interest. @@ -436,13 +483,26 @@ pub(crate) fn begin_ft_drag(f: &FtBox, press: Pos2, map: &ViewerMap) -> Option Option { - let (layer, uid, _, _) = segment_at(&paths.iter().filter(|p| p.is_mask).cloned().collect::>(), map, press, 10.0)?; - let l = ectx.comp.layer(layer)?; - let pr = l.masks()?.find_group(uid)?.get("feather")?; - let _ = app; - Some(Drag::Feather { layer, prop: pr.uid, start: ectx.value(l, pr).as_vec2(), press }) +/// Mask Feather tool: press on a feather point's handle to drag it, or on a mask path to add a +/// feather point there and drag its radius out. +pub(crate) fn begin_feather(app: &mut EffectcraftApp, ectx: &EvalCtx, paths: &[PathInfo], map: &ViewerMap, press: Pos2) -> Option { + let _ = ectx; + for p in paths.iter().filter(|p| p.is_mask) { + if let Some((i, _)) = p.feather.iter().find(|(_, h)| h.distance(press) < 7.0) { + return Some(Drag::Feather { layer: p.layer, mask: p.uid, index: *i, inv: p.m.inverse()? }); + } + } + let masks: Vec = paths.iter().filter(|p| p.is_mask).cloned().collect(); + let (layer, uid, seg, t) = segment_at(&masks, map, press, 10.0)?; + let inv = masks.iter().find(|p| p.layer == layer && p.uid == uid)?.m.inverse()?; + let r = app.session.execute("mask.featherPoint.add", json!({"layer": layer.0, "mask": uid, "segment": seg, "t": t, "radius": 0})); + match r { + Ok(v) => Some(Drag::Feather { layer, mask: uid, index: v["index"].as_u64()? as usize, inv }), + Err(e) => { + app.ui.status = e.to_string(); + None + } + } } /// Advance an overlay drag to the pointer (`cpt` comp, `pos` screen). `snap` corrects a comp @@ -513,10 +573,13 @@ pub(crate) fn update( } Some(Drag::Ft { layer, path, mode, anchor, last: cur, inv, axes }) } - Drag::Feather { layer, prop, start, press } => { - let amt = (pos.distance(press) / zoom.max(1e-3)) as f64; - let v = json!([(start[0] + amt).max(0.0), (start[1] + amt).max(0.0)]); - let _ = app.session.execute("prop.set", json!({"layer": layer.0, "prop": prop, "value": v, "merge": merge})); + Drag::Feather { layer, mask, index, inv } => { + let p = inv.apply(gv2(cpt[0], cpt[1])); + let _ = (pos, zoom); + let q = json!({"layer": layer.0, "mask": mask, "index": index, "toward": [p.x, p.y], "merge": merge}); + if let Err(e) = app.session.execute("mask.featherPoint.set", q) { + app.ui.status = e.to_string(); + } None } Drag::Guide { index, .. } => { diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index db739954..bdab3d92 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -295,3 +295,41 @@ fn timeline_alt_drag_scales_a_key_group_in_time() { assert_eq!(ts[0], 0.0); assert!((ts[2] - 3.0).abs() < 0.1 && (ts[1] - 1.5).abs() < 0.1, "{ts:?}"); } + +#[test] +fn mask_feather_tool_adds_and_drags_feather_points() { + let mut h = harness(); + // A mask on the Box (80×80 at the comp centre, comp 280…360 × 140…220): layer 10…70. + let (box_id, mask) = { + let s = &mut h.state_mut().session; + let b = s.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id; + let m = s.execute("mask.new", json!({"layer": b.0, "vertices": [[10, 10], [70, 10], [70, 70], [10, 70]], "closed": true})).unwrap()["mask"] + .as_u64() + .unwrap(); + s.execute("layer.select", json!({"layers": [b.0]})).unwrap(); + (b, m) + }; + h.state_mut().ui.tool = Tool::MaskFeather; + h.run_steps(3); + // Press on the top edge (comp 320,150) and drag 12 px up: an outer feather point of ≈12 px. + let from = screen(&h, [320.0, 150.0]); + let z = rect(&h, "viewer.comp").width() / 640.0; + drag(&mut h, from, from - vec2(0.0, 12.0 * z)); + let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap(); + let r = pts["points"][0]["radius"].as_f64().unwrap(); + assert!((r - 12.0).abs() < 1.5, "{pts}"); + assert_eq!(pts["points"][0]["segment"], json!(0)); + // The handle is registered; dragging it inwards makes it an inner feather point. + h.run_steps(2); + let handle = rect(&h, &format!("viewer.mask.{mask}.feather.0")).center(); + let to = screen(&h, [320.0, 160.0]); + drag(&mut h, handle, to); + let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap(); + let r = pts["points"][0]["radius"].as_f64().unwrap(); + assert!((r + 10.0).abs() < 1.5, "{pts}"); + assert_eq!(pts["points"].as_array().unwrap().len(), 1); + // One drag = one undo step each. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap(); + assert!(pts["points"][0]["radius"].as_f64().unwrap() > 0.0); +} From 85c645f33782ccdcd026bf82f6b87426c2b4efb2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 23:35:03 -0400 Subject: [PATCH 02/10] M13.5: camera iris bokeh depth of field and focus-link commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3D-1: Classic 3D depth of field renders the camera's Iris Shape (Fast Rectangle box or Triangle…Decagon polygon), Iris Rotation, Roundness, Aspect Ratio and Diffraction Fringe as a span-kernel gather blur, with Highlight Gain/Threshold/Saturation boosting highlights first; tilted layers blur progressively by per-pixel camera depth. Layer > Camera > Link Focus Distance to Point of Interest / to Layer (expressions) and Set Focus Distance to Layer replace the stubs. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/focus.rs | 105 ++++++++ crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/stubs.rs | 3 - crates/engine/src/tests_tail.rs | 30 +++ crates/host/src/lib.rs | 44 ++++ crates/render/src/three_d/adv/tests.rs | 2 +- crates/render/src/three_d/bokeh.rs | 336 +++++++++++++++++++++++++ crates/render/src/three_d/camera.rs | 18 ++ crates/render/src/three_d/compose.rs | 63 +++-- crates/render/src/three_d/mod.rs | 1 + crates/render/src/three_d/tests.rs | 57 +++++ 11 files changed, 638 insertions(+), 23 deletions(-) create mode 100644 crates/engine/src/commands/focus.rs create mode 100644 crates/render/src/three_d/bokeh.rs diff --git a/crates/engine/src/commands/focus.rs b/crates/engine/src/commands/focus.rs new file mode 100644 index 00000000..013672a3 --- /dev/null +++ b/crates/engine/src/commands/focus.rs @@ -0,0 +1,105 @@ +//! Layer ▸ Camera focus commands: Link Focus Distance to Point of Interest, Link Focus Distance to +//! Layer (both add an expression to Focus Distance, as After Effects does) and Set Focus Distance +//! to Layer (a one-off value). + +use effectcraft_geom::vec3; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{Comp, Expression, ItemId, LayerId}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, comp_id, layer_mut, resolve_layer}; +use crate::{EngineError, Result, Session, cmd}; + +/// (comp, camera, target?) from `{camera?, layer?}` or the selection (a camera plus another layer). +fn refs(s: &Session, p: &Value, cmd: &str, need_target: bool) -> Result<(ItemId, LayerId, Option)> { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let sel: Vec = s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect(); + let is_cam = |c: &Comp, l: LayerId| c.layer(l).is_some_and(|l| l.is_camera()); + let cam = match p.get("camera") { + Some(v) => resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?, + None => sel.iter().copied().find(|l| is_cam(comp, *l)).ok_or_else(|| bad(cmd, "select a camera layer"))?, + }; + if !is_cam(comp, cam) { + return Err(bad(cmd, "`camera` is not a camera layer")); + } + let target = match p.get("layer") { + Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?), + None => sel.iter().copied().find(|l| *l != cam), + }; + if need_target && target.is_none() { + return Err(bad(cmd, "select the camera and the layer to focus on")); + } + Ok((cid, cam, target)) +} + +fn set_focus_expr(s: &mut Session, cid: ItemId, cam: LayerId, label: &str, text: String) -> Result { + s.edit(label, None, |proj, _| { + let l = layer_mut(proj, cid, cam)?; + let pr = l.props.prop_mut("cameraOptions/focusDistance").ok_or_else(|| bad("camera", "the camera has no Focus Distance"))?; + pr.expr = Some(Expression { text: text.clone(), enabled: true }); + Ok(json!({"expression": text})) + }) +} + +fn link_poi(s: &mut Session, p: &Value) -> Result { + let (cid, cam, _) = refs(s, p, "camera.linkFocusToPoi", false)?; + set_focus_expr(s, cid, cam, "Link Focus Distance to Point of Interest", "length(transform.pointOfInterest, transform.position)".into()) +} + +/// The focus expression for a target layer: its anchor point's distance along the camera's view +/// axis. +fn layer_expr(name: &str) -> String { + let n = serde_json::to_string(name).unwrap_or_else(|_| "\"\"".into()); + format!( + "var m = thisComp.layer({n});\nvar c = toWorld([0, 0, 0]);\nvar f = normalize(sub(toWorld([0, 0, 1]), c));\ndot(sub(m.toWorld(m.transform.anchorPoint), c), f)" + ) +} + +fn link_layer(s: &mut Session, p: &Value) -> Result { + let (cid, cam, target) = refs(s, p, "camera.linkFocusToLayer", true)?; + let name = s.project.comp(cid).and_then(|c| c.layer(target?)).map(|l| l.name.clone()).ok_or(EngineError::NoComp)?; + set_focus_expr(s, cid, cam, "Link Focus Distance to Layer", layer_expr(&name)) +} + +/// Camera depth of `target`'s anchor point at the current time. +pub(crate) fn focus_depth(s: &Session, cid: ItemId, cam: LayerId, target: LayerId) -> Option { + let comp = s.project.comp(cid)?; + let mut ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time_of(cid)); + ctx.expr = s.expr.as_deref(); + let c = ctx.comp.layer(cam)?; + let t = ctx.comp.layer(target)?; + let cs = effectcraft_render::three_d::camera::layer_camera(&ctx, c); + let a = t.transform().map(|tr| ctx.v3(t, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]); + Some(cs.depth(ctx.world_matrix(t).apply(vec3(a[0], a[1], a[2])))) +} + +fn set_to_layer(s: &mut Session, p: &Value) -> Result { + let c = "camera.setFocusToLayer"; + let (cid, cam, target) = refs(s, p, c, true)?; + let d = focus_depth(s, cid, cam, target.ok_or_else(|| bad(c, "no layer"))?).ok_or_else(|| bad(c, "can't evaluate the layers"))?; + if d <= 0.0 { + return Err(bad(c, "the layer is behind the camera")); + } + let t = s.time(); + s.edit("Set Focus Distance to Layer", None, |proj, _| { + let l = layer_mut(proj, cid, cam)?; + let lt = l.layer_time(t); + let pr = l.props.prop_mut("cameraOptions/focusDistance").ok_or_else(|| bad(c, "the camera has no Focus Distance"))?; + pr.set_value_at(lt, KV::Scalar(d)); + Ok(json!({"focusDistance": d})) + }) +} + +fn camera_selected(s: &Session) -> std::result::Result<(), String> { + let c = s.active_comp().ok_or("no composition is open")?; + if s.state.selected_layers.iter().any(|l| c.layer(*l).is_some_and(|l| l.is_camera())) { Ok(()) } else { Err("select a camera layer".into()) } +} + +pub fn specs() -> Vec { + vec![ + cmd!("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", ["Layer", "Camera"], None, "{comp?, camera?}", camera_selected, link_poi), + cmd!("camera.linkFocusToLayer", "Link Focus Distance to Layer", ["Layer", "Camera"], None, "{comp?, camera?, layer?}", camera_selected, link_layer), + cmd!("camera.setFocusToLayer", "Set Focus Distance to Layer", ["Layer", "Camera"], None, "{comp?, camera?, layer?}", camera_selected, set_to_layer), + ] +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 2ab44c09..ca16122a 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -10,6 +10,7 @@ mod edit; mod effect; mod file; mod file_more; +mod focus; mod frontend; mod help; mod key_transform; @@ -129,6 +130,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(view::specs()); v.extend(viewer_cmds::specs()); v.extend(shape_stroke::specs()); + v.extend(focus::specs()); v.extend(key_transform::specs()); v.extend(file_more::specs()); v.extend(lottie::specs()); diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 11239de3..6ae25090 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -36,9 +36,6 @@ pub fn specs() -> Vec { stub!("camera.stereoRig", "Create Stereo 3D Rig", ["Layer", "Camera"], "{}"), stub!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], "{}"), stub!("camera.fromModel", "Create Cameras from 3D Model", ["Layer", "Camera"], "{}"), - stub!("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", ["Layer", "Camera"], "{}"), - stub!("camera.linkFocusToLayer", "Link Focus Distance to Layer", ["Layer", "Camera"], "{}"), - stub!("camera.setFocusToLayer", "Set Focus Distance to Layer", ["Layer", "Camera"], "{}"), stub!("light.fromModel", "Create Lights from 3D Model", ["Layer", "Light"], "{}"), stub!("light.controlWithCamera", "Control Light with Camera", ["Layer", "Light"], "{}"), stub!("light.environmentBackground", "Create Environment Light Background Layer", ["Layer", "Light"], "{}"), diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index c395a85d..9b3eb407 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -219,3 +219,33 @@ fn adaptive_sample_limit_drives_layer_samples() { assert!(!json.contains("motion_blur_adaptive_limit")); assert_eq!(Project::from_json(&json).unwrap().comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 128); } + +#[test] +fn camera_focus_commands_undo_and_serde() { + let mut s = comp(400, 300); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 500]})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + assert!(s.is_enabled("camera.setFocusToLayer")); + let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap(); + assert!((d - before - 500.0).abs() < 1e-6, "the default camera focuses on z = 0: {before} → {d}"); + s.execute("camera.linkFocusToLayer", json!({})).unwrap(); + let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap(); + assert!(e["expression"].as_str().unwrap().contains("thisComp.layer("), "{e}"); + let back = roundtrip(&s); + assert!(back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().expr.is_some()); + s.execute("edit.undo", json!({})).unwrap(); + let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap(); + assert!(e["expression"].is_null()); + s.execute("edit.undo", json!({})).unwrap(); + let v = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + assert!((v - before).abs() < 1e-9); + s.execute("edit.redo", json!({})).unwrap(); + // A camera alone can't link to a layer, but can link to its point of interest. + s.execute("layer.select", json!({"layers": [cam]})).unwrap(); + assert!(s.execute("camera.linkFocusToLayer", json!({})).is_err()); + assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok()); +} diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index aa94b558..98148492 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -66,6 +66,50 @@ pub fn session() -> Session { mod tests { use serde_json::json; + /// Layer ▸ Camera ▸ Link Focus Distance to Point of Interest / to Layer: the expressions + /// evaluate to the camera's focus on the target. + #[test] + fn focus_link_expressions_track_the_target() { + use effectcraft_engine::geom::vec3; + let mut s = super::session(); + s.execute("comp.new", json!({"name": "F", "width": 400, "height": 300, "duration": 2})).unwrap(); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [260, 120, 400]})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + let cid = s.active_comp_id().unwrap(); + let focus = |s: &effectcraft_engine::Session| { + let comp = s.project.comp(cid).unwrap(); + let mut ctx = effectcraft_engine::render::EvalCtx::new(&s.project, cid, comp, s.time()); + ctx.expr = s.expr.as_deref(); + let l = comp.layer(effectcraft_engine::project::LayerId(cam)).unwrap(); + ctx.value(l, l.props.prop("cameraOptions/focusDistance").unwrap()).as_f64() + }; + let depth = |s: &effectcraft_engine::Session| { + let comp = s.project.comp(cid).unwrap(); + let ctx = effectcraft_engine::render::EvalCtx::new(&s.project, cid, comp, s.time()); + let c = comp.layer(effectcraft_engine::project::LayerId(cam)).unwrap(); + let tl = comp.layer(effectcraft_engine::project::LayerId(t)).unwrap(); + let cs = effectcraft_engine::render::three_d::camera::layer_camera(&ctx, c); + cs.depth(ctx.world_matrix(tl).apply(vec3(25.0, 25.0, 0.0))) + }; + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + let set = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap(); + assert!((set - depth(&s)).abs() < 1e-6 && (focus(&s) - set).abs() < 1e-6); + s.execute("camera.linkFocusToLayer", json!({})).unwrap(); + // Move the target: the linked focus follows. + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 900]})).unwrap(); + assert!((focus(&s) - depth(&s)).abs() < 1e-3, "{} vs {}", focus(&s), depth(&s)); + s.execute("camera.linkFocusToPoi", json!({"camera": cam})).unwrap(); + let poi = { + let l = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(cam)).unwrap(); + let p = l.props.prop("transform/position").unwrap().value.as_vec3(); + let q = l.props.prop("transform/poi").unwrap().value.as_vec3(); + ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt() + }; + assert!((focus(&s) - poi).abs() < 1e-6, "{} vs {poi}", focus(&s)); + } + #[test] fn wired_session_renders_demo_with_expressions() { let mut s = super::session(); diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 47ad6178..193ca247 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -283,7 +283,7 @@ fn depth_of_field_blurs_out_of_focus_surfaces() { let m: f32 = im.data.iter().map(|p| p[0]).sum::() / im.data.len() as f32; im.data.iter().map(|p| (p[0] - m).powi(2)).sum::() / im.data.len() as f32 }; - let dof = crate::three_d::camera::Dof { focus: 1000.0, aperture: 40.0, blur_level: 1.0 }; + let dof = crate::three_d::camera::Dof { focus: 1000.0, aperture: 40.0, blur_level: 1.0, iris: Default::default(), highlight: Default::default() }; let sharp = depth_of_field(&img, &vec![1000.0; 64 * 64], &dof, 1.0); assert!((var(&sharp) - var(&img)).abs() < 1e-6, "in focus: unchanged"); let blurred = depth_of_field(&img, &vec![3000.0; 64 * 64], &dof, 1.0); diff --git a/crates/render/src/three_d/bokeh.rs b/crates/render/src/three_d/bokeh.rs new file mode 100644 index 00000000..0252ecb0 --- /dev/null +++ b/crates/render/src/three_d/bokeh.rs @@ -0,0 +1,336 @@ +//! Classic 3D depth of field: bokeh-shaped gather blur. +//! +//! The camera's Iris Shape (Fast Rectangle, or a regular polygon from Triangle to Decagon), +//! Iris Rotation, Roundness and Aspect Ratio define the blur kernel; Iris Diffraction Fringe +//! brightens its rim. Highlight Gain / Threshold / Saturation boost the bright parts of the layer +//! before blurring so they bloom into visible bokeh shapes. A layer whose depth varies across it +//! (tilted towards or away from the camera) is blurred progressively: the buffer is blurred at a +//! few kernel sizes and every pixel blends the two that bracket its own circle of confusion. + +use effectcraft_raster::Image; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Camera iris (Camera Options ▸ Iris …). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Iris { + /// 0 = Fast Rectangle, else the number of sides (3–10). + pub sides: u32, + /// Degrees. + pub rotation: f64, + /// −1…1: 1 = circular, negative pinches the sides inwards. + pub roundness: f64, + /// Width / height of the aperture. + pub aspect: f64, + /// Rim brightness: 0 = none, 1 = the rim is twice as bright (Iris Diffraction Fringe / 100). + pub fringe: f64, +} + +impl Default for Iris { + fn default() -> Self { + Iris { sides: 0, rotation: 0.0, roundness: 0.0, aspect: 1.0, fringe: 0.0 } + } +} + +/// Highlight boost before the blur (Camera Options ▸ Highlight …), all 0–1. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Highlight { + pub gain: f64, + pub threshold: f64, + pub saturation: f64, +} + +impl Iris { + /// Iris Shape popup index → sides (0 Fast Rectangle, 1 Triangle … 8 Decagon). + pub fn sides_of_shape(shape: u32) -> u32 { + if shape == 0 { 0 } else { (shape + 2).min(10) } + } + + /// Normalised boundary distance (1 = circumscribed circle) of the iris at angle `a`. + fn boundary(&self, a: f64) -> f64 { + let n = self.sides.max(3) as f64; + let seg = std::f64::consts::TAU / n; + let rel = (a - self.rotation.to_radians()).rem_euclid(seg) - seg * 0.5; + let poly = (std::f64::consts::PI / n).cos() / rel.cos(); + let r = self.roundness.clamp(-1.0, 1.0); + // Positive: towards the circumscribed circle; negative: the sides bow inwards (the + // vertices stay put, the edge midpoints move in). + if r >= 0.0 { poly + (1.0 - poly) * r } else { (poly + (1.0 - poly) * r * 2.0).max(0.05) } + } + + /// Whether the kernel offset (dx, dy) lies inside an iris of radius `r`. + fn inside(&self, r: f64, dx: f64, dy: f64) -> bool { + let a = self.aspect.clamp(0.01, 100.0).sqrt(); + let (u, v) = (dx / a, dy * a); + let d = u.hypot(v); + d < 1e-9 || d <= self.boundary(v.atan2(u)) * r + } + + /// The kernel as horizontal spans: for every row offset `dy`, the (fractional) x extent + /// `[x0, x1]` of the iris of radius `r` at that height. The iris is convex for roundness ≥ 0; + /// a pinched iris keeps its outermost extent per row. + pub fn spans(&self, r: f64) -> Vec<(i32, f64, f64)> { + let a = self.aspect.clamp(0.01, 100.0).sqrt(); + let (ext_x, ext_y) = (r * a + 1.0, (r / a).ceil() as i32 + 1); + let mut out = Vec::new(); + for dy in -ext_y..=ext_y { + let y = dy as f64; + // Coarse scan, then bisection of both ends. + let step = 0.5; + let mut first = None; + let mut last = None; + let mut x = -ext_x; + while x <= ext_x { + if self.inside(r, x, y) { + first.get_or_insert(x); + last = Some(x); + } + x += step; + } + let (Some(f), Some(l)) = (first, last) else { continue }; + let refine = |inside_x: f64, outside_x: f64| { + let (mut i, mut o) = (inside_x, outside_x); + for _ in 0..12 { + let m = (i + o) * 0.5; + if self.inside(r, m, y) { i = m } else { o = m } + } + (i + o) * 0.5 + }; + out.push((dy, refine(f, f - step), refine(l, l + step))); + } + out + } +} + +/// Prefix sums of each row (per channel): `p[x]` = sum of pixels `0..x`. +fn row_prefix(img: &Image) -> Vec> { + let w = img.width as usize; + img.data + .par_chunks(w.max(1)) + .map(|row| { + let mut acc = [0.0f32; 4]; + let mut v = Vec::with_capacity(w + 1); + v.push(acc); + for p in row { + for c in 0..4 { + acc[c] += p[c]; + } + v.push(acc); + } + v + }) + .collect() +} + +/// Continuous prefix: sum over [0, t) with linear interpolation inside a pixel. +#[inline] +fn prefix_at(p: &[[f32; 4]], t: f64) -> [f32; 4] { + let n = p.len() - 1; + if t <= 0.0 { + return [0.0; 4]; + } + if t >= n as f64 { + return p[n]; + } + let i = t.floor() as usize; + let f = (t - i as f64) as f32; + let (a, b) = (p[i], p[i + 1]); + [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f, a[3] + (b[3] - a[3]) * f] +} + +/// Gather with span kernels: `(weight, spans)` pairs summed, then normalised by `norm`. +fn span_gather(img: &Image, kernels: &[(f32, Vec<(i32, f64, f64)>)], norm: f32) -> Image { + let pre = row_prefix(img); + let (w, h) = (img.width as usize, img.height as i64); + let mut out = Image::new(img.width, img.height); + let inv = 1.0 / norm; + out.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let mut acc = [0.0f32; 4]; + for (wt, spans) in kernels { + for &(dy, x0, x1) in spans { + let sy = y as i64 + dy as i64; + if sy < 0 || sy >= h { + continue; + } + let pr = &pre[sy as usize]; + let (hi, lo) = (prefix_at(pr, x as f64 + x1 + 0.5), prefix_at(pr, x as f64 + x0 + 0.5)); + for c in 0..4 { + acc[c] += (hi[c] - lo[c]) * wt; + } + } + } + *o = acc.map(|v| v * inv); + } + }); + out +} + +/// Boost the highlights of a premultiplied image in place. +pub fn boost_highlights(img: &mut Image, h: &Highlight) { + if h.gain <= 0.0 { + return; + } + let thr = h.threshold.clamp(0.0, 1.0) as f32; + img.data.par_iter_mut().for_each(|p| { + if p[3] <= 0.0 { + return; + } + let a = p[3]; + let (r, g, b) = (p[0] / a, p[1] / a, p[2] / a); + let l = effectcraft_color::luminance(r, g, b); + if l < thr || (thr >= 1.0 && l < 1.0) { + return; + } + let over = if thr < 1.0 { ((l - thr) / (1.0 - thr)).clamp(0.0, 1.0) } else { 1.0 }; + let k = 1.0 + h.gain as f32 * 4.0 * over.max(0.25); + let sat = 1.0 + h.saturation as f32; + let c = [r, g, b].map(|c| (l + (c - l) * sat).max(0.0) * k); + *p = [c[0] * a, c[1] * a, c[2] * a, a]; + }); +} + +/// Gather blur of a premultiplied image with a bokeh kernel of radius `r` (pixels). Pixels +/// beyond the image are transparent. +pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image { + if r < 0.3 { + return img.clone(); + } + if iris.sides == 0 { + // Fast Rectangle: a separable box of the iris' width and height. + let a = iris.aspect.clamp(0.01, 100.0).sqrt(); + let rx = (r * a).round().max(0.0) as usize; + let ry = (r / a).round().max(0.0) as usize; + return effectcraft_raster::box_blur(img, rx, ry, 1, false); + } + // The iris as row spans (prefix-sum gather: cost ∝ kernel height, not area). The + // diffraction fringe adds the rim: the full iris minus a shrunken one. + let outer = iris.spans(r); + let area = |sp: &[(i32, f64, f64)]| sp.iter().map(|s| (s.2 - s.1) as f32).sum::(); + let mut kernels = vec![(1.0f32, outer)]; + if iris.fringe > 0.0 { + let rim = (r * 0.15).max(1.0); + let inner = if r - rim > 0.5 { iris.spans(r - rim) } else { Vec::new() }; + let f = iris.fringe as f32; + kernels[0].0 = 1.0 + f; + kernels.push((-f, inner)); + } + let norm: f32 = kernels.iter().map(|(wt, sp)| wt * area(sp)).sum(); + if norm <= 0.0 { + return img.clone(); + } + span_gather(img, &kernels, norm) +} + +/// Depth-varying blur: `radius` gives each pixel's blur radius. The image is blurred at up to +/// eight radii spanning the range and each pixel interpolates between the two levels around +/// its own radius. +pub fn progressive_blur(img: &Image, iris: &Iris, radius: &[f32]) -> Image { + let (lo, hi) = radius.iter().fold((f32::INFINITY, 0.0f32), |(a, b), &r| (a.min(r), b.max(r))); + if !lo.is_finite() || hi < 0.3 { + return img.clone(); + } + if hi - lo < 0.75 { + return bokeh_blur(img, iris, ((lo + hi) * 0.5) as f64); + } + let n = (((hi - lo) / 2.0).ceil() as usize + 1).clamp(2, 8); + let levels: Vec = (0..n).map(|i| lo + (hi - lo) * i as f32 / (n - 1) as f32).collect(); + let blurred: Vec = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect(); + let mut out = Image::new(img.width, img.height); + out.data.par_iter_mut().enumerate().for_each(|(i, o)| { + let r = radius[i]; + let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32); + let k = (f.floor() as usize).min(n - 2); + let t = f - k as f32; + let (a, b) = (blurred[k].data[i], blurred[k + 1].data[i]); + *o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t); + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A single bright point in the middle of a transparent image. + fn dot(size: u32) -> Image { + let mut img = Image::new(size, size); + let c = (size / 2) as usize; + img.data[c * size as usize + c] = [1.0, 1.0, 1.0, 1.0]; + img + } + + fn lit(img: &Image, x: i32, y: i32) -> bool { + let c = img.width as i32 / 2; + img.data[((c + y) * img.width as i32 + c + x) as usize][3] > 1e-4 + } + + #[test] + fn kernel_shapes() { + // A hexagon with a vertex on +x (rotation 0): the point at 0.97 r on +x is inside, the + // point at 0.97 r at 30° (an edge midpoint at cos 30° = 0.866 r) is outside. + let hex = Iris { sides: 6, ..Default::default() }; + let b = bokeh_blur(&dot(81), &hex, 20.0); + assert!(lit(&b, 19, 0)); + let (x, y) = ((19.4f64 * 30f64.to_radians().cos()).round() as i32, (19.4f64 * 30f64.to_radians().sin()).round() as i32); + assert!(!lit(&b, x, y), "edge midpoint beyond the apothem"); + // Rotating by 30° swaps them. + let rot = Iris { rotation: 30.0, ..hex }; + let b = bokeh_blur(&dot(81), &rot, 20.0); + assert!(!lit(&b, 19, 0) && lit(&b, x, y)); + // Full roundness: a circle. + let round = Iris { roundness: 1.0, ..hex }; + let b = bokeh_blur(&dot(81), &round, 20.0); + assert!(lit(&b, 19, 0) && lit(&b, x, y)); + // Triangle: far less area than a decagon of the same radius. + let area = |sides| { + let b = bokeh_blur(&dot(81), &Iris { sides, ..Default::default() }, 20.0); + b.data.iter().filter(|p| p[3] > 1e-4).count() + }; + assert!(area(3) * 2 < area(10), "{} vs {}", area(3), area(10)); + // Aspect ratio 4 makes it twice as wide and half as tall. + let wide = Iris { sides: 8, aspect: 4.0, ..Default::default() }; + let b = bokeh_blur(&dot(121), &wide, 15.0); + assert!(lit(&b, 28, 0) && !lit(&b, 0, 10)); + // Energy is preserved. + let sum: f32 = b.data.iter().map(|p| p[3]).sum(); + assert!((sum - 1.0).abs() < 1e-3, "{sum}"); + } + + #[test] + fn diffraction_fringe_brightens_the_rim() { + let plain = bokeh_blur(&dot(81), &Iris { sides: 8, ..Default::default() }, 20.0); + let fringe = bokeh_blur(&dot(81), &Iris { sides: 8, fringe: 2.0, ..Default::default() }, 20.0); + let at = |img: &Image, x: i32| img.data[(40 * 81 + 40 + x) as usize][3]; + assert!((at(&plain, 0) - at(&plain, 17)).abs() < 1e-6, "flat disc"); + assert!(at(&fringe, 18) > at(&fringe, 0) * 1.5, "{} vs {}", at(&fringe, 18), at(&fringe, 0)); + } + + #[test] + fn highlights_boost_only_above_threshold() { + let mut img = Image::new(2, 1); + img.data = vec![[0.9, 0.9, 0.9, 1.0], [0.3, 0.3, 0.3, 1.0]]; + boost_highlights(&mut img, &Highlight { gain: 0.5, threshold: 0.8, saturation: 0.0 }); + assert!(img.data[0][0] > 1.2 && img.data[1][0] == 0.3); + let mut c = Image::new(1, 1); + c.data = vec![[1.0, 0.8, 0.8, 1.0]]; + boost_highlights(&mut c, &Highlight { gain: 0.1, threshold: 0.5, saturation: 1.0 }); + assert!(c.data[0][0] - c.data[0][1] > 0.3, "more saturated: {:?}", c.data[0]); + } + + #[test] + fn progressive_blur_follows_the_radius_map() { + // A vertical line, blurred 0 px at the top rising to 8 px at the bottom. + let (w, h) = (41u32, 41u32); + let mut img = Image::new(w, h); + for y in 0..h as usize { + img.data[y * w as usize + 20] = [1.0; 4]; + } + let radius: Vec = (0..(w * h) as usize).map(|i| (i / w as usize) as f32 / 40.0 * 8.0).collect(); + let out = progressive_blur(&img, &Iris { sides: 6, roundness: 1.0, ..Default::default() }, &radius); + let spread = |y: usize| (0..w as usize).filter(|&x| out.data[y * w as usize + x][3] > 0.03).count(); + assert!(spread(1) <= 3, "top stays sharp: {}", spread(1)); + assert!(spread(39) >= 13, "bottom is wide: {}", spread(39)); + assert!(spread(20) > spread(1) && spread(20) < spread(39)); + } +} diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index fb307b8c..626a7685 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -26,6 +26,12 @@ pub struct Dof { pub aperture: f64, /// Blur Level (1.0 = 100%). pub blur_level: f64, + /// Iris Shape, Rotation, Roundness, Aspect Ratio and Diffraction Fringe. + #[serde(default)] + pub iris: super::bokeh::Iris, + /// Highlight Gain, Threshold and Saturation. + #[serde(default)] + pub highlight: super::bokeh::Highlight, } impl Dof { @@ -294,6 +300,18 @@ pub fn layer_camera(ctx: &EvalCtx, cam: &Layer) -> CameraState { focus: ctx.f(cam, g, "focusDistance", zoom), aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0), blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0, + iris: super::bokeh::Iris { + sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")), + rotation: ctx.f(cam, g, "irisRotation", 0.0), + roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0, + aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01), + fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0, + }, + highlight: super::bokeh::Highlight { + gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0, + threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0, + saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0, + }, }); CameraState { view: basis_view(eye, fwd, down), eye, zoom, ortho: false, dof } } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 2647fadd..85b60a76 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -206,26 +206,10 @@ fn prepare_with<'a>( let cam = camera_for(r, ctx); let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world); let world = outer * ctx.world_matrix(layer) * local; - // Depth of field: blur the layer by its circle of confusion at its centre. + // Depth of field: a bokeh blur by each buffer pixel's circle of confusion (constant for a + // layer facing the camera, progressive for a tilted one). if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) { - let b = buf_bounds(&buf); - let centre = world.apply(vec3((b[0] + b[2]) * 0.5, (b[1] + b[3]) * 0.5, 0.0)); - let z = cam.depth(centre); - let coc = dof.coc(z); - // Comp pixels on screen per layer pixel at that depth. - let k = cam.zoom / z.max(NEAR) * world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9); - let radius = coc * 0.5 / k * buf.scale; - if radius > 0.3 { - let sigma = radius / 1.5; - let pad = (sigma * 3.0).ceil() as u32 + 1; - let padded = buf.img.padded(pad); - // The (cached, shared) layer buffer stays untouched; the blurred copy is per frame. - buf = Arc::new(Buf { - img: effectcraft_raster::gaussian_blur(&padded, sigma, sigma, false), - offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], - scale: buf.scale, - }); - } + buf = dof_blur(&cam, &dof, world, buf); } let n = if in_run { mb_samples(r, ctx, layer) } else { 1 }; let geos: Vec = if n <= 1 { @@ -269,6 +253,47 @@ fn prepare_with<'a>( }) } +/// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from +/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there. +fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec { + let s = buf.scale.max(1e-9); + let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9); + let (w, h) = (buf.img.width as usize, buf.img.height as usize); + let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - buf.offset[0]) / s, (y - buf.offset[1]) / s, 0.0))); + // Camera depth is affine across a plane. + let z0 = at(0.5, 0.5); + let (zx, zy) = (at(1.5, 0.5) - z0, at(0.5, 1.5) - z0); + let mut out = vec![0.0f32; w * h]; + out.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let z = z0 + zx * x as f64 + zy * y as f64; + if z <= super::camera::NEAR { + continue; + } + // coc · ½ / (zoom / z · sx) · scale + *o = (dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s) as f32; + } + }); + out +} + +/// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth. +fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc) -> Arc { + let radii = dof_radii(cam, dof, world, &buf); + let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; + if rmax <= 0.3 { + return buf; + } + let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt()); + let pad = (rmax * a).ceil() as u32 + 2; + let mut padded = buf.img.padded(pad); + super::bokeh::boost_highlights(&mut padded, &dof.highlight); + let pbuf = Buf { img: padded, offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], scale: buf.scale }; + let pr = dof_radii(cam, dof, world, &pbuf); + // The (cached, shared) layer buffer stays untouched; the blurred copy is per frame. + Arc::new(Buf { img: super::bokeh::progressive_blur(&pbuf.img, &dof.iris, &pr), offset: pbuf.offset, scale: pbuf.scale }) +} + /// Screen-space track matte factor for a layer, or None. fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Option>> { let tm = layer.track_matte?; diff --git a/crates/render/src/three_d/mod.rs b/crates/render/src/three_d/mod.rs index e97c67fe..7faaa499 100644 --- a/crates/render/src/three_d/mod.rs +++ b/crates/render/src/three_d/mod.rs @@ -6,6 +6,7 @@ //! groups, as in After Effects). pub mod adv; +pub mod bokeh; pub mod camera; pub(crate) mod compose; pub mod light; diff --git a/crates/render/src/three_d/tests.rs b/crates/render/src/three_d/tests.rs index 01e0399d..9f1dbb34 100644 --- a/crates/render/src/three_d/tests.rs +++ b/crates/render/src/three_d/tests.rs @@ -423,6 +423,63 @@ fn depth_of_field_blurs_out_of_focus_layers() { assert!(b.get(66, 50)[3] > 0.01); } +/// Pixels in column `x` whose alpha is strictly between 0.05 and 0.95. +fn soft_px(img: &effectcraft_raster::Image, x: i64) -> usize { + (0..img.height as i64).filter(|&y| (0.05..0.95).contains(&img.get(x, y)[3])).count() +} + +#[test] +fn tilted_layer_blurs_progressively_with_depth() { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(80.0)); + set(&mut cam, "cameraOptions/irisShape", Value::Enum(4)); // Hexagon + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 160, 40, [100.0, 50.0, 0.0]); + set(&mut s, "transform/rotationY", Value::Scalar(60.0)); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let img = render(&p, cid); + // The centre column sits on the focal plane: a hard top edge. The ends are 70 px in front of + // and behind it: soft. + let xs: Vec = (0..W as i64).filter(|&x| img.get(x, 50)[3] > 0.5).collect(); + let (x0, x1) = (xs[0], *xs.last().unwrap()); + let at = |f: f64| x0 + ((x1 - x0) as f64 * f) as i64; + let (centre, left, right) = (soft_px(&img, 100), soft_px(&img, at(0.1)), soft_px(&img, at(0.9))); + assert!(centre <= 2, "centre {centre}"); + assert!(left >= 4 && right >= 4, "ends {left} {right}"); +} + +#[test] +fn iris_shape_and_highlights_shape_the_bokeh() { + // A small bright square far out of focus: its blur takes the iris' shape. + let spot = |shape: u32, gain: f64| { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(120.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom() * 0.5)); + set(&mut cam, "cameraOptions/irisShape", Value::Enum(shape)); + set(&mut cam, "cameraOptions/highlightGain", Value::Scalar(gain)); + set(&mut cam, "cameraOptions/highlightThreshold", Value::Scalar(200.0)); + let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 2, 2, [100.0, 50.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + render(&p, cid) + }; + let area = |img: &effectcraft_raster::Image| img.data.iter().filter(|q| q[3] > 1e-4).count(); + let (tri, dec, rect) = (spot(1, 0.0), spot(8, 0.0), spot(0, 0.0)); + assert!(area(&tri) * 3 < area(&dec) * 2, "triangle {} vs decagon {}", area(&tri), area(&dec)); + // Fast Rectangle: a box. + let (x0, x1) = (0..W as i64).filter(|&x| rect.get(x, 50)[3] > 1e-4).fold((i64::MAX, 0), |(a, b), x| (a.min(x), b.max(x))); + let y_extent = (0..H as i64).filter(|&y| rect.get(100, y)[3] > 1e-4).count() as i64; + assert!(((x1 - x0 + 1) - y_extent).abs() <= 2, "square box {} vs {}", x1 - x0 + 1, y_extent); + // Highlight Gain brightens the blurred highlight. + let lit = spot(8, 100.0); + let sum = |img: &effectcraft_raster::Image| img.data.iter().map(|q| q[0]).sum::(); + assert!(sum(&lit) > sum(&dec) * 1.5, "{} vs {}", sum(&lit), sum(&dec)); +} + // ---------------------------------------------------------------- views #[test] From b03af6a9798fc50e96672130c5bb4dd524c69130 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 23:48:33 -0400 Subject: [PATCH 03/10] M13.5: timeline/project column scrolling, keyframe label groups Interface: Timeline outline columns wider than the pane scroll horizontally (Shift+wheel/trackpad, scroll bar above the footer; headers clipped); the Project panel's optional columns scroll under the frozen Name/Label columns. ANM-1: keyframe colour labels (keys.setLabel, Label submenu, tinted key icons) and Edit > Select Keyframe Label Group on selected/all layers and visible keyframes (keys.selectLabelGroup replaces the stub). Alt-drag time scaling of key groups already existed. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/key_labels.rs | 85 ++++++++++++++++++++++++ crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/stubs.rs | 1 - crates/engine/src/tests_tail.rs | 40 +++++++++++ crates/keyframe/src/lib.rs | 8 +++ crates/ui-egui/src/panels/project.rs | 42 +++++++++++- crates/ui-egui/src/panels/timeline.rs | 73 +++++++++++++++++--- crates/ui-egui/src/state.rs | 8 +++ crates/ui-egui/tests/ui_tail.rs | 79 ++++++++++++++++++++++ 9 files changed, 327 insertions(+), 11 deletions(-) create mode 100644 crates/engine/src/commands/key_labels.rs create mode 100644 crates/ui-egui/tests/ui_tail.rs diff --git a/crates/engine/src/commands/key_labels.rs b/crates/engine/src/commands/key_labels.rs new file mode 100644 index 00000000..4fc9e964 --- /dev/null +++ b/crates/engine/src/commands/key_labels.rs @@ -0,0 +1,85 @@ +//! Keyframe color labels: Label a keyframe (Keyframe ▸ Label ▸ colour) and Edit ▸ Select Keyframe +//! Label Group (On Selected Layers / On All Layers / Visible Keyframes on …). + +use effectcraft_color::Label; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, has_comp, has_keys, str_p}; +use crate::{EngineError, KeyRef, Result, Session, cmd}; + +fn label_p(p: &Value, c: &str) -> Result { + match p.get("label") { + Some(Value::String(s)) => { + let l = if s.eq_ignore_ascii_case("none") { Some(Label::None) } else { Label::from_name(s) }; + let l = l.ok_or_else(|| bad(c, format!("unknown label `{s}`")))?; + Ok(Label::ALL.iter().position(|x| *x == l).unwrap_or(0) as u8) + } + Some(Value::Number(n)) => Ok(n.as_u64().filter(|i| (*i as usize) < Label::ALL.len()).ok_or_else(|| bad(c, "label index 0–16"))? as u8), + _ => Err(bad(c, "missing `label` (name or 0–16)")), + } +} + +fn set_label(s: &mut Session, p: &Value) -> Result { + let c = "keys.setLabel"; + let label = label_p(p, c)?; + let n = super::prop::edit_keys(s, "Keyframe Label", None, |keys, i, _| { + keys[i].label = label; + true + })?; + Ok(json!({"label": Label::ALL[label as usize].name(), "keys": n})) +} + +/// Select every keyframe that shares a label with a selected keyframe, on the selected or all +/// layers. `visible` (property uids revealed in the Timeline) limits the "Visible Keyframes" +/// scopes; without it every property counts as visible. +fn select_label_group(s: &mut Session, p: &Value) -> Result { + let c = "keys.selectLabelGroup"; + let scope = str_p(p, "scope").unwrap_or("selected"); + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let labels: Vec = + s.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer)?.props.find(k.prop)?.keys.iter().find(|x| x.time == k.time).map(|x| x.label)).collect(); + if labels.is_empty() { + return Err(bad(c, "select a keyframe first")); + } + let only_selected = match scope { + "selected" | "visibleSelected" => true, + "all" | "visibleAll" => false, + _ => return Err(bad(c, "scope: selected|all|visibleSelected|visibleAll")), + }; + let visible: Option> = + (scope.starts_with("visible")).then(|| p.get("visible").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect())).flatten(); + let key_layers: Vec<_> = s.state.selected_keys.iter().map(|k| k.layer).collect(); + let mut sel = Vec::new(); + for l in &comp.layers { + if only_selected && !s.state.selected_layers.contains(&l.id) && !key_layers.contains(&l.id) { + continue; + } + l.props.walk("", &mut |_, pr| { + if visible.as_ref().is_some_and(|v| !v.contains(&pr.uid)) { + return; + } + for k in &pr.keys { + if labels.contains(&k.label) { + sel.push(KeyRef { layer: l.id, prop: pr.uid, time: k.time }); + } + } + }); + } + s.state.selected_keys = sel; + Ok(json!({"keys": s.state.selected_keys.len()})) +} + +pub fn specs() -> Vec { + vec![ + cmd!("keys.setLabel", "Keyframe Label", [], None, "{label: name|none|0–16}", has_keys, set_label), + cmd!( + "keys.selectLabelGroup", + "Select Keyframe Label Group", + [], + None, + "{scope: selected|all|visibleSelected|visibleAll, visible?: [prop uid]}", + has_comp, + select_label_group + ), + ] +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index ca16122a..930a794b 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -13,6 +13,7 @@ mod file_more; mod focus; mod frontend; mod help; +mod key_labels; mod key_transform; mod layer; mod layer_menu; @@ -131,6 +132,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(viewer_cmds::specs()); v.extend(shape_stroke::specs()); v.extend(focus::specs()); + v.extend(key_labels::specs()); v.extend(key_transform::specs()); v.extend(file_more::specs()); v.extend(lottie::specs()); diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 6ae25090..49160762 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -23,7 +23,6 @@ pub fn specs() -> Vec { stub!("file.setProxy", "File...", ["File", "Set Proxy"], "{path}"), stub!("file.setProxyNone", "None", ["File", "Set Proxy"], "{}"), stub!("file.interpretProxy", "Proxy...", ["File", "Interpret Footage"], "{}"), - stub!("keys.selectLabelGroup", "Select Keyframe Label Group", [], "{scope}"), // Layer ▸ New (3D / content-aware fill). stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"), // Mask options not in the mask model yet; pen / vertex editing (timeline milestone). diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index 9b3eb407..7da7319f 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -220,6 +220,46 @@ fn adaptive_sample_limit_drives_layer_samples() { assert_eq!(Project::from_json(&json).unwrap().comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 128); } +#[test] +fn keyframe_label_groups() { + let mut s = comp(200, 100); + let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap(); + for l in [a, b] { + for t in [0.0, 0.5, 1.0] { + s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t})).unwrap(); + } + } + let uid = |s: &Session, l: u64| s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().uid; + let (ua, ub) = (uid(&s, a), uid(&s, b)); + // Label a's first key and b's last key Blue. + s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}, {"layer": b, "prop": ub, "time": 1.0}]})).unwrap(); + s.execute("keys.setLabel", json!({"label": "Blue"})).unwrap(); + s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.5}]})).unwrap(); + s.execute("keys.setLabel", json!({"label": "Green"})).unwrap(); + // On selected layers: from a's first key, only a's Blue key. + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}]})).unwrap(); + assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "selected"})).unwrap()["keys"], json!(1)); + // On all layers: both Blue keys. + assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap()["keys"], json!(2)); + assert!(s.state.selected_keys.iter().any(|k| k.layer == LayerId(b) && (k.time.seconds() - 1.0).abs() < 1e-6)); + // Visible keyframes: only the revealed properties count. + s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}]})).unwrap(); + assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "visibleAll", "visible": [ua]})).unwrap()["keys"], json!(1)); + // Unlabelled keys group together too (label None). + s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 1.0}]})).unwrap(); + assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap()["keys"], json!(3)); + // Undo, serde. + let lab = |s: &Session| s.active_comp().unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[1].label; + assert_eq!(lab(&s), 9); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(lab(&s), 0); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(roundtrip(&s).comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[1].label, 9); + assert!(s.execute("keys.setLabel", json!({"label": "Mauve"})).is_err()); +} + #[test] fn camera_focus_commands_undo_and_serde() { let mut s = comp(400, 300); diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 7d9d35ee..ec2b4754 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -84,6 +84,13 @@ pub struct Keyframe { pub spatial_continuous: bool, #[serde(default)] pub roving: bool, + /// Keyframe color label: index into the label list (0 = None). + #[serde(default, skip_serializing_if = "is_zero")] + pub label: u8, +} + +fn is_zero(v: &u8) -> bool { + *v == 0 } fn yes() -> bool { @@ -106,6 +113,7 @@ impl Keyframe { spatial_auto: true, spatial_continuous: false, roving: false, + label: 0, } } pub fn hold(mut self) -> Keyframe { diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 3254a878..ed185087 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -210,16 +210,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let name_w = (rect.width() * 0.40).clamp(120.0, 260.0); let label_x = rect.min.x + name_w + 14.0; let mut cols: Vec<(&'static str, &'static str, f32, f32)> = vec![("name", "Name", rect.min.x + 26.0, name_w - 26.0), ("label", "", label_x - 8.0, 18.0)]; - let mut x = label_x + 16.0; + // Optional columns wider than the panel scroll horizontally under the frozen Name and Label + // columns (Shift+wheel / trackpad over the list, or the scroll bar under it). + let opt_x0 = label_x + 16.0; + let natural_end = opt_x0 + app.ui.project_columns.iter().filter_map(|k| COLUMNS.iter().find(|c| c.0 == k)).map(|c| c.2 + 4.0).sum::(); + let overflow = (natural_end - rect.max.x + 4.0).max(0.0); + app.ui.project_hscroll = app.ui.project_hscroll.clamp(0.0, overflow); + let hscroll = app.ui.project_hscroll; + let mut x = opt_x0 - hscroll; for key in app.ui.project_columns.clone() { if let Some((k, l, w)) = COLUMNS.iter().find(|c| c.0 == key) { cols.push((k, l, x, *w)); x += w + 4.0; } } + let opt_clip = |r: Rect| Rect::from_min_max(pos2(r.min.x.max(opt_x0 - 4.0), r.min.y), r.max); let hp = p.with_clip_rect(hdr); for (key, label, x, w) in &cols { let hr = Rect::from_min_size(pos2(x - 4.0, hdr.min.y), vec2(*w, hdr.height())); + let hr = if matches!(*key, "name" | "label") { hr } else { opt_clip(hr) }; + if hr.width() <= 0.0 { + continue; + } let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click()); hp.with_clip_rect(hr).text(pos2(*x, hdr.center().y), Align2::LEFT_CENTER, *label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim }); if *key == "label" { @@ -252,6 +264,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let footer_h = 28.0; let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h)); let lp = p.with_clip_rect(list); + if overflow > 0.0 && ui.rect_contains_pointer(list) { + let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift)); + let d = if dx.abs() > 0.0 { + dx + } else if shift { + dy + } else { + 0.0 + }; + app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow); + } + if overflow > 0.0 { + let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0)); + let tw = (track.width() * track.width() / (track.width() + overflow)).max(16.0); + let thumb = Rect::from_min_size(pos2(track.min.x + (track.width() - tw) * (hscroll / overflow), track.min.y), vec2(tw, track.height())); + let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("proj-hscroll"), Sense::drag()); + p.rect_filled(track, 2.0, t.field_bg); + p.rect_filled(thumb, 2.0, if sresp.hovered() || sresp.dragged() { t.text_dim } else { t.text_faint }); + app.auto.add("project.hscroll", track, &format!("{hscroll}/{overflow}")); + if sresp.dragged() { + app.ui.project_hscroll = (hscroll + sresp.drag_delta().x * overflow / (track.width() - tw).max(1.0)).clamp(0.0, overflow); + } + } let mut y = list.min.y; let query = app.ui.project_search.to_lowercase(); let mut rows: Vec<(ItemId, usize)> = vec![]; @@ -372,7 +407,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Optional columns. for (key, _, cx, w) in cols.iter().skip(2) { - let cell = Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list); + let cell = opt_clip(Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list)); + if cell.width() <= 0.0 { + continue; + } let cp = lp.with_clip_rect(cell); let txt = match *key { "type" => it.type_name().to_string(), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ae43c4ac..f9f6cd9c 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -714,7 +714,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let footer_h = 24.0; let vis = Vis::of(&app.ui.timeline); let left_w = (vis.fixed() + 190.0).clamp(420.0, (rect.width() * 0.72).max(420.0)); - let cw = cols(rect.min.x, left_w, vis); + // Columns wider than the pane scroll horizontally (Shift+wheel / trackpad over the outline, + // or the scroll bar above the footer); the name column keeps its minimum width. + let natural_w = vis.fixed() + 190.0; + let overflow = (natural_w - left_w).max(0.0); + app.ui.timeline.outline_scroll = app.ui.timeline.outline_scroll.clamp(0.0, overflow); + let oscroll = app.ui.timeline.outline_scroll; + let cw = cols(rect.min.x - oscroll, left_w + overflow, vis); let graph_x0 = rect.min.x + left_w + 1.0; let graph_x1 = rect.max.x - 10.0; ctx.data_mut(|d| d.insert_temp(egui::Id::new("timeline-graph-area"), (graph_x0, graph_x1 - graph_x0))); @@ -898,8 +904,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.rect_filled(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)), 0.0, t.panel_bg); p.line_segment([pos2(rect.min.x, ch.max.y), pos2(rect.max.x, ch.max.y)], Stroke::new(1.0, t.separator)); let hy = ch.center().y; - let hic = |icon: Icon, x: f32| icons::paint(&p, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim); - let htext = |x: f32, s: &str| p.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim); + // Column headers scroll with the outline and never spill into the time ruler. + let hp = p.with_clip_rect(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y))); + let hic = |icon: Icon, x: f32| icons::paint(&hp, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim); + let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim); if vis.av { hic(Icon::Eye, cw.av + 10.0); hic(Icon::Speaker, cw.av + 28.0); @@ -910,7 +918,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { htext(cw.keys + 6.0, "Keys"); } if vis.label { - icons::paint(&p, Rect::from_center_size(pos2(cw.label + 10.0, hy), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim); + icons::paint(&hp, Rect::from_center_size(pos2(cw.label + 10.0, hy), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim); } if vis.num { htext(cw.num + 6.0, "#"); @@ -933,7 +941,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { htext(cw.trkmat + 4.0, "Track Matte"); } if vis.parent { - icons::paint(&p, Rect::from_center_size(pos2(cw.parent + 10.0, hy), vec2(12.0, 12.0)), Icon::PickWhip, t.text_dim); + icons::paint(&hp, Rect::from_center_size(pos2(cw.parent + 10.0, hy), vec2(12.0, 12.0)), Icon::PickWhip, t.text_dim); htext(cw.parent + 20.0, "Parent & Link"); } for (on, x, s) in [(vis.in_, cw.in_, "In"), (vis.out, cw.out, "Out"), (vis.duration, cw.duration, "Duration"), (vis.stretch, cw.stretch, "Stretch")] { @@ -1006,8 +1014,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if zoom && dy.abs() > 0.0 { zoom_at(app, &comp, tm, (dy as f64 / 200.0).exp(), ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0)); } else { - app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll); - if dx.abs() > 0.0 && app.ui.timeline.pps.is_some() { + let over_outline = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.x < graph_x0); + let shift = ui.input(|i| i.modifiers.shift); + if over_outline && overflow > 0.0 && (dx.abs() > 0.0 || (shift && dy.abs() > 0.0)) { + let d = if dx.abs() > 0.0 { dx } else { dy }; + app.ui.timeline.outline_scroll = (app.ui.timeline.outline_scroll - d).clamp(0.0, overflow); + } else { + app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll); + } + if dx.abs() > 0.0 && app.ui.timeline.pps.is_some() && !over_outline { app.ui.timeline.start = (app.ui.timeline.start - dx as f64 / pps).max(0.0); } } @@ -1623,7 +1638,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ks = sel_keys.contains(&kref); let icon = k.icon(); let c = pos2(x, cy); - icons::keyframe(&gp, c, 11.0, icon.left, icon.right, if ks { t.keyframe_selected } else { t.keyframe }, Color32::from_black_alpha(200)); + // Labelled keys take their label colour (brighter when selected). + let kcol = match effectcraft_engine::color::Label::ALL.get(k.label as usize).filter(|_| k.label > 0) { + Some(l) => { + let c = t.label(*l); + if ks { c } else { c.gamma_multiply(0.75) } + } + None if ks => t.keyframe_selected, + None => t.keyframe, + }; + icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol, Color32::from_black_alpha(200)); let kr = Rect::from_center_size(c, vec2(12.0, 14.0)); let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag()); app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name)); @@ -1660,6 +1684,23 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ("Select All Keyframes", "keys.selectAll", json!({})), ("Delete", "keys.delete", json!({})), ] { + if lbl == "Select All Keyframes" { + // Keyframe colour labels. + ui.menu_button("Label", |ui| { + for (i, l) in effectcraft_engine::color::Label::ALL.iter().enumerate() { + if ui.add(egui::Button::new(l.name()).selected(k.label as usize == i)).clicked() { + if !ks { + actions.push(( + "keys.select".into(), + json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}), + )); + } + actions.push(("keys.setLabel".into(), json!({"label": i}))); + ui.close(); + } + } + }); + } if lbl == "-" { ui.separator(); continue; @@ -1794,6 +1835,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let footer = Rect::from_min_max(pos2(rect.min.x, rect.max.y - footer_h), rect.max); p.rect_filled(footer, 0.0, t.panel_bg); p.line_segment([footer.left_top(), footer.right_top()], Stroke::new(1.0, t.separator)); + if overflow > 0.0 { + // Outline scroll bar along the top of the footer, under the columns. + let track = Rect::from_min_max(pos2(rect.min.x + 2.0, footer.min.y + 1.0), pos2(graph_x0 - 3.0, footer.min.y + 5.0)); + let frac = left_w / natural_w; + let tw = (track.width() * frac).max(20.0); + let tx = track.min.x + (track.width() - tw) * (oscroll / overflow); + let thumb = Rect::from_min_size(pos2(tx, track.min.y), vec2(tw, track.height())); + p.rect_filled(track, 2.0, t.field_bg); + let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("tl-outline-scroll"), Sense::drag()); + p.rect_filled(thumb, 2.0, if sresp.dragged() || sresp.hovered() { t.text_dim } else { t.text_faint }); + app.auto.add("timeline.outlineScroll", track, &format!("{oscroll}/{overflow}")); + if sresp.dragged() { + let k = overflow / (track.width() - tw).max(1.0); + app.ui.timeline.outline_scroll = (oscroll + sresp.drag_delta().x * k).clamp(0.0, overflow); + } + } let tgl = Rect::from_min_size(pos2(footer.min.x + 10.0, footer.min.y + 3.0), vec2(150.0, 18.0)); let tresp = ui.interact(tgl, egui::Id::new("tl-toggle-modes"), Sense::click()); p.text(tgl.left_center(), Align2::LEFT_CENTER, "Toggle Switches / Modes", Tokens::ui(11.0), if tresp.hovered() { t.text } else { t.text_dim }); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 2ead3c6a..adffbbeb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -340,6 +340,9 @@ pub struct TimelineState { pub scroll_y: f32, /// Left column area width. pub columns_w: f32, + /// Horizontal scroll of the outline columns (pixels) when they are wider than the pane. + #[serde(default)] + pub outline_scroll: f32, pub show_modes: bool, pub graph_editor: bool, pub search: String, @@ -403,6 +406,7 @@ impl Default for TimelineState { pps: None, scroll_y: 0.0, columns_w: 560.0, + outline_scroll: 0.0, show_modes: false, graph_editor: false, search: String::new(), @@ -545,6 +549,9 @@ pub struct UiState { /// `duration`, `path`, `comment`); toggled from the column header's context menu. #[serde(default = "default_project_columns")] pub project_columns: Vec, + /// Horizontal scroll of the Project panel's optional columns (Name and Label stay put). + #[serde(default)] + pub project_hscroll: f32, /// Effect Controls twirl state (group uids that are collapsed). pub fx_closed: BTreeSet, /// Slider params whose slider row is twirled open (AE hides sliders by default). @@ -631,6 +638,7 @@ impl Default for UiState { project_open_folders: BTreeSet::new(), project_sort: default_project_sort(), project_sort_desc: false, + project_hscroll: 0.0, project_columns: default_project_columns(), fx_closed: BTreeSet::new(), fx_slider_open: BTreeSet::new(), diff --git a/crates/ui-egui/tests/ui_tail.rs b/crates/ui-egui/tests/ui_tail.rs new file mode 100644 index 00000000..b113f115 --- /dev/null +++ b/crates/ui-egui/tests/ui_tail.rs @@ -0,0 +1,79 @@ +//! Headless checks for the M13.5 long-tail UI: horizontal scrolling of the Timeline outline and +//! Project panel columns (egui_kittest, UI logic only). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness(w: f32) -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Tail", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(w, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +#[test] +fn timeline_outline_columns_scroll_horizontally() { + let mut h = harness(1100.0); + // Every column on: wider than the outline pane. + let ctx = h.ctx.clone(); + for c in ["keys", "comment", "modes", "parent", "in", "out", "duration", "stretch"] { + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.column", json!({"column": c, "visible": true})).unwrap(); + } + h.run_steps(3); + let bar = rect(&h, "timeline.outlineScroll"); + let name0 = rect(&h, "timeline.header.name").min.x; + assert_eq!(h.state().ui.timeline.outline_scroll, 0.0); + drag(&mut h, pos2(bar.min.x + 8.0, bar.center().y), pos2(bar.max.x + 50.0, bar.center().y)); + h.run_steps(2); + let s = h.state().ui.timeline.outline_scroll; + assert!(s > 50.0, "{s}"); + let name1 = rect(&h, "timeline.header.name").min.x; + assert!((name0 - name1 - s).abs() < 1.0, "the columns moved by the scroll: {name0} → {name1} ({s})"); + // The scroll is clamped to the overflow and resets when the columns fit again. + for c in ["keys", "comment", "modes", "parent", "in", "out", "duration", "stretch"] { + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.column", json!({"column": c, "visible": false})).unwrap(); + } + h.run_steps(3); + assert_eq!(h.state().ui.timeline.outline_scroll, 0.0); + assert!(h.state().auto.find("timeline.outlineScroll").is_none()); +} + +#[test] +fn project_panel_columns_scroll_horizontally() { + let mut h = harness(1400.0); + for c in ["type", "size", "duration", "fps", "path", "comment"] { + effectcraft_ui_egui::panels::project::set_column(&mut h.state_mut().ui.project_columns, c, true); + } + h.run_steps(3); + let bar = rect(&h, "project.hscroll"); + let x0 = rect(&h, "project.sort.comment").min.x; + let name0 = rect(&h, "project.sort.name").min.x; + drag(&mut h, pos2(bar.min.x + 4.0, bar.center().y), pos2(bar.max.x + 40.0, bar.center().y)); + h.run_steps(2); + let s = h.state().ui.project_hscroll; + assert!(s > 20.0, "{s}"); + assert!(rect(&h, "project.sort.comment").min.x < x0 - 20.0); + assert_eq!(rect(&h, "project.sort.name").min.x, name0, "Name stays frozen"); +} From 1ebfef77c49956961c37f3616a67c82a9116df5a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 00:01:24 -0400 Subject: [PATCH 04/10] M13.5: vertical Roman, tate-chu-yoko, forced LTR, animated caret, Lottie style runs Text: vertical type turns Roman characters on their side (CJK stays upright) unless Standard Vertical Roman Alignment is on; Tate-Chu-Yoko sets selected characters horizontally in a one-em cell of the column (layer.setText tateChuYoko / verticalRomanUpright, Character panel toggles). A Left-to-Right paragraph is now forced LTR instead of auto-detected. The editing caret follows animators and text on a path (render::text::caret_maps). Lottie export writes per-character style runs as index-unit range animators. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_tail.rs | 22 +++++ crates/keyframe/src/text_doc.rs | 20 +++- crates/lottie/src/tests.rs | 24 +++++ crates/lottie/src/text.rs | 64 +++++++++++++ crates/render/src/text.rs | 42 ++++++++- crates/render/src/text/tests.rs | 36 ++++++++ crates/text/src/lib.rs | 111 +++++++++++++++++++++-- crates/ui-egui/src/panels/text_panels.rs | 16 ++++ crates/ui-egui/src/panels/viewer_text.rs | 12 ++- 9 files changed, 335 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index 7da7319f..17f2a6b6 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -260,6 +260,28 @@ fn keyframe_label_groups() { assert!(s.execute("keys.setLabel", json!({"label": "Mauve"})).is_err()); } +#[test] +fn tate_chu_yoko_via_set_text_with_undo_and_serde() { + let mut s = comp(400, 400); + let t = s.execute("layer.newText", json!({"text": "A12B", "size": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setText", json!({"layer": t, "vertical": true})).unwrap(); + s.execute("layer.setText", json!({"layer": t, "range": [1, 3], "tateChuYoko": true})).unwrap(); + let doc = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap(); + assert!(doc.style_at(1).tate_chu_yoko && doc.style_at(2).tate_chu_yoko && !doc.style_at(0).tate_chu_yoko); + let lay = effectcraft_text::layout_doc(&doc); + assert!((lay.glyphs[1].origin.y - lay.glyphs[2].origin.y).abs() < 1e-6, "the digits share a row"); + let back = roundtrip(&s); + let effectcraft_keyframe::Value::Text(d) = &back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(t)).unwrap().props.prop("text/sourceText").unwrap().value + else { + panic!() + }; + assert!(d.style_at(1).tate_chu_yoko); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().style_at(1).tate_chu_yoko); + s.execute("layer.setText", json!({"layer": t, "verticalRomanUpright": true})).unwrap(); + assert!(crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().vertical_roman_upright); +} + #[test] fn camera_focus_commands_undo_and_serde() { let mut s = comp(400, 300); diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs index 9652b91a..2e9d5c6e 100644 --- a/crates/keyframe/src/text_doc.rs +++ b/crates/keyframe/src/text_doc.rs @@ -172,6 +172,11 @@ pub struct CharStyle { pub baseline: BaselineOption, pub kerning: Kerning, pub ligatures: bool, + /// Vertical type: set these characters horizontally within the column (Tate-Chu-Yoko). + pub tate_chu_yoko: bool, + /// Vertical type: keep Roman (half-width) characters upright instead of turning them on + /// their side (Character panel menu ▸ Standard Vertical Roman Alignment). + pub vertical_roman_upright: bool, } impl Default for CharStyle { @@ -250,6 +255,9 @@ pub struct TextDoc { pub kerning: Kerning, #[serde(default = "yes")] pub ligatures: bool, + /// Base Tate-Chu-Yoko and Standard Vertical Roman Alignment (see [`CharStyle`]). + pub tate_chu_yoko: bool, + pub vertical_roman_upright: bool, pub indent_left: f64, pub indent_right: f64, pub indent_first: f64, @@ -299,6 +307,8 @@ impl Default for TextDoc { baseline: BaselineOption::Normal, kerning: Kerning::Metrics, ligatures: true, + tate_chu_yoko: false, + vertical_roman_upright: false, indent_left: 0.0, indent_right: 0.0, indent_first: 0.0, @@ -383,6 +393,8 @@ impl TextDoc { baseline: self.baseline, kerning: self.kerning, ligatures: self.ligatures, + tate_chu_yoko: self.tate_chu_yoko, + vertical_roman_upright: self.vertical_roman_upright, } } @@ -408,6 +420,8 @@ impl TextDoc { self.baseline = s.baseline; self.kerning = s.kerning; self.ligatures = s.ligatures; + self.tate_chu_yoko = s.tate_chu_yoko; + self.vertical_roman_upright = s.vertical_roman_upright; } /// The base (first paragraph's) settings. @@ -792,6 +806,8 @@ pub const CHAR_ATTRS: &[&str] = &[ "subscript", "kerning", "ligatures", + "tateChuYoko", + "verticalRomanUpright", ]; /// Paragraph attribute keys of `layer.setText`. @@ -851,6 +867,8 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result s.kerning = Kerning::parse(v).ok_or("kerning: expected metrics|optical|number")?, "ligatures" => s.ligatures = flag(key, v)?, + "tateChuYoko" => s.tate_chu_yoko = flag(key, v)?, + "verticalRomanUpright" => s.vertical_roman_upright = flag(key, v)?, _ => return Ok(false), } Ok(true) @@ -896,7 +914,7 @@ pub fn char_style_json(s: &CharStyle) -> J { "tracking": s.tracking, "leading": s.leading.map_or(J::from("auto"), J::from), "baselineShift": s.baseline_shift, "hScale": s.h_scale, "vScale": s.v_scale, "tsume": s.tsume, "fauxBold": s.faux_bold, "fauxItalic": s.faux_italic, "allCaps": s.all_caps, "smallCaps": s.small_caps, "baseline": s.baseline.key(), "kerning": s.kerning.to_json(), - "ligatures": s.ligatures, + "ligatures": s.ligatures, "tateChuYoko": s.tate_chu_yoko, "verticalRomanUpright": s.vertical_roman_upright, }) } diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 0641883f..7a3ce4e5 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -700,3 +700,27 @@ fn fixture_legacy_keyframes_and_masks() { assert!(matches!(masks[1].kind, GroupKind::Mask { mode: MaskMode::Subtract, inverted: true, .. })); assert!(not_blank(&q, c, s(0.9))); } + +#[test] +fn text_style_runs_export_as_range_animators() { + let (mut p, cid) = setup(); + let c = comp(&p, cid); + let mut l = build::layer(&mut p, &c, "Runs", LayerSource::Text, (c.width, c.height), None); + let mut doc = TextDoc { text: "Hello".into(), size: 30.0, fill: [1.0, 1.0, 1.0, 1.0], ..Default::default() }; + doc.apply_style(1..3, |st| { + st.fill = [1.0, 0.0, 0.0, 1.0]; + st.baseline_shift = 6.0; + }); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(doc)); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([30.0, 60.0, 0.0]); + push(&mut p, cid, l); + let (q, nc, _, json) = roundtrip(&p, cid, &ExportOptions::default()); + let a = &json["layers"][0]["t"]["a"][0]; + assert_eq!(a["s"]["r"], json!(2), "{a}"); + assert_eq!(a["s"]["s"]["k"], json!(1)); + assert_eq!(a["s"]["e"]["k"], json!(3)); + assert_eq!(a["a"]["fc"]["k"], json!([1.0, 0.0, 0.0, 1.0])); + assert_eq!(a["a"]["p"]["k"], json!([0.0, -6.0, 0.0])); + // Played back as an animator, it looks the same. + compare_renders(&p, cid, &q, nc, 2.0 / 255.0); +} diff --git a/crates/lottie/src/text.rs b/crates/lottie/src/text.rs index 59fe6fa9..75489688 100644 --- a/crates/lottie/src/text.rs +++ b/crates/lottie/src/text.rs @@ -171,6 +171,14 @@ pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec) -> } } let mut animators = vec![]; + // Per-character styles: Lottie text documents have one style, so every run that differs + // from the base becomes a text animator whose range selector covers exactly its characters + // (index units). Fonts and faux styles per run can't be expressed. + if let Some(st) = tg.get("sourceText") + && let Value::Text(d) = &st.value + { + animators.extend(style_run_animators(ex, d, label)); + } if let Some(list) = tg.sub("animators") { for a in list.groups() { animators.push(export_animator(ex, a, &format!("{label} ▸ {}", a.name))); @@ -189,6 +197,62 @@ pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec) -> json!({"d": {"k": docs}, "a": animators, "m": m, "p": {}}) } +/// Text animators reproducing the style runs of `d` (fill, stroke, stroke width, size, +/// tracking, baseline shift) over their character ranges. +fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec { + let base = d.base_style(); + let mut out = vec![]; + let mut start = 0usize; + let mut warned = false; + for run in d.runs() { + let (a, b) = (start, start + run.len); + start = b; + let s = &run.style; + if *s == base || run.len == 0 { + continue; + } + let k = |v: Json| json!({"a": 0, "k": v}); + let mut props = Map::new(); + if s.apply_fill && s.fill != base.fill { + props.insert("fc".into(), k(json!([s.fill[0] as f64, s.fill[1] as f64, s.fill[2] as f64, 1.0]))); + } + if s.apply_stroke && s.stroke != base.stroke { + props.insert("sc".into(), k(json!([s.stroke[0] as f64, s.stroke[1] as f64, s.stroke[2] as f64, 1.0]))); + } + if (s.stroke_width - base.stroke_width).abs() > 1e-9 { + props.insert("sw".into(), k(json!(s.stroke_width - base.stroke_width))); + } + if (s.size - base.size).abs() > 1e-9 && base.size > 0.0 { + let p = s.size / base.size * 100.0; + props.insert("s".into(), k(json!([p, p, 100.0]))); + } + if (s.tracking - base.tracking).abs() > 1e-9 { + // Lottie tracking is in 1/1000 em like the Character panel's. + props.insert("t".into(), k(json!(s.tracking - base.tracking))); + } + if (s.baseline_shift - base.baseline_shift).abs() > 1e-9 { + props.insert("p".into(), k(json!([0.0, -(s.baseline_shift - base.baseline_shift), 0.0]))); + } + let lost = s.font != base.font || s.style != base.style || s.faux_bold != base.faux_bold || s.faux_italic != base.faux_italic; + if lost && !warned { + ex.warn(format!("{label}: per-character fonts and faux styles are not part of Lottie text (base font used)")); + warned = true; + } + if props.is_empty() { + continue; + } + out.push(json!({ + "nm": format!("Style {a}–{b}"), + "s": { + "t": 0, "xe": k(json!(0)), "ne": k(json!(0)), "a": k(json!(100)), "b": 1, "rn": 0, "sh": 1, + "r": 2, "m": k(json!(1)), "s": k(json!(a)), "e": k(json!(b)), "o": k(json!(0)), + }, + "a": Json::Object(props), + })); + } + out +} + fn export_animator(ex: &mut Ex, a: &PropGroup, label: &str) -> Json { let mut props = Map::new(); if let Some(pg) = a.sub("properties") { diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 37b0f775..a5399dc6 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -404,6 +404,11 @@ pub struct PlacedGlyph { pub stroke_width: f64, /// The character's style fills it (Character panel fill on). pub apply_fill: bool, + /// Index of the character in the text. + pub char_index: usize, + /// Where the pivot sits in the unanimated layout (layer space): `m · (p − rest)` carries a + /// point of the static layout to where the animation puts this character. + pub rest: [f64; 2], } impl PlacedGlyph { @@ -463,6 +468,30 @@ fn text_path(ctx: &EvalCtx, layer: &Layer, text: &PropGroup) -> Option<(PathMeas Some((pm, perpendicular, ctx.b(layer, po, "forceAlignment"), ctx.f(layer, po, "firstMargin", 0.0), ctx.f(layer, po, "lastMargin", 0.0))) } +/// For every caret position `0..=chars` of the layer's text, the map from the static layout +/// (layer space, what [`TextLayout::caret`] returns) to where animators and Path Options put +/// the characters: the caret follows the character before it (or the first one). +pub fn caret_maps(ctx: &EvalCtx, layer: &Layer, chars: usize) -> Vec { + let Some(geom) = text_geom(ctx, layer) else { return vec![Mat3::IDENTITY; chars + 1] }; + let mut by_char: Vec> = vec![None; chars + 1]; + for g in &geom.glyphs { + if let Some(slot) = by_char.get_mut(g.char_index) { + *slot = Some(g.m2() * Mat3::translate(effectcraft_geom::vec2(-g.rest[0], -g.rest[1]))); + } + } + let first = by_char.iter().flatten().next().copied().unwrap_or(Mat3::IDENTITY); + let mut out = Vec::with_capacity(chars + 1); + let mut last = first; + for ci in 0..=chars { + // Caret ci sits after character ci − 1. + if let Some(Some(m)) = ci.checked_sub(1).map(|p| by_char[p]) { + last = m; + } + out.push(if ci == 0 { by_char[0].unwrap_or(first) } else { last }); + } + out +} + /// Lay out and animate a text layer. pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let doc = source_text(ctx, layer)?; @@ -642,7 +671,18 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let fill = adjust_color(st.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity); let stroke = adjust_color(st.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity); let base_stroke_w = if st.apply_stroke { st.stroke_width } else { 0.0 }; - glyphs.push(PlacedGlyph { local, m, xf: x, fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0), apply_fill: st.apply_fill }); + let rest = [pivot[0] - (o[0] - g.origin.x), pivot[1] - (o[1] - g.origin.y)]; + glyphs.push(PlacedGlyph { + local, + m, + xf: x, + fill, + stroke, + stroke_width: (base_stroke_w + x.stroke_width).max(0.0), + apply_fill: st.apply_fill, + char_index: g.char_index, + rest, + }); } Some(TextGeom { doc, glyphs, fill_stroke, blend }) } diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 9d7effa0..b7073667 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -416,3 +416,39 @@ fn keyframed_selector_animates() { assert_eq!(selection(&p, cid, 0.5, 1), vec![0.0, 0.0, 1.0, 1.0]); assert_eq!(selection(&p, cid, 1.0, 1), vec![0.0; 4]); } + +#[test] +fn caret_follows_animators_and_path_text() { + let (mut p, cid, comp) = setup(400, 400); + let mut l = text_layer(&mut p, &comp, doc("ABCD", 30.0)); + let a = animator(&mut p, &mut l, &["position"], &["range"]); + set(&mut l, &format!("text/animators/#{a}/properties/position"), Value::Vec3([0.0, 50.0, 0.0])); + p.comp_mut(cid).unwrap().layers.push(l); + let c = ctx(&p, cid, 0.0); + let ly = &p.comp(cid).unwrap().layers[0]; + let lay = layout_doc(&source_text(&c, ly).unwrap()); + let maps = caret_maps(&c, ly, lay.chars); + assert_eq!(maps.len(), 5); + for (ci, m) in maps.iter().enumerate() { + let (top, _) = lay.caret(ci); + let q = m.apply(vec2(top.x, top.y)); + assert!((q.x - top.x).abs() < 1e-6 && (q.y - top.y - 50.0).abs() < 1e-6, "caret {ci}: {top:?} → {q:?}"); + } + // On a vertical path (x = 100, running down) the caret turns with the characters and sits + // on the path. + let (mut p, cid, comp) = setup(400, 400); + let mut l = text_layer(&mut p, &comp, doc("PATH", 30.0)); + mask(&mut p, &mut l, &[[100.0, 20.0], [100.0, 380.0]], false); + set(&mut l, "text/pathOptions/path", Value::Enum(1)); + p.comp_mut(cid).unwrap().layers.push(l); + let c = ctx(&p, cid, 0.0); + let ly = &p.comp(cid).unwrap().layers[0]; + let lay = layout_doc(&source_text(&c, ly).unwrap()); + let maps = caret_maps(&c, ly, lay.chars); + let (t, b) = lay.caret(2); + let (qt, qb) = (maps[2].apply(vec2(t.x, t.y)), maps[2].apply(vec2(b.x, b.y))); + assert!((qt.y - qb.y).abs() < 1.0 && (qt.x - qb.x).abs() > 20.0, "caret across the path: {qt:?} {qb:?}"); + assert!(qb.x < 100.5 && qt.x > 100.0, "baseline end on the path, top to its right: {qt:?} {qb:?}"); + let glyph_c = glyph_paths(&c, ly).iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().y).collect::>(); + assert!(qt.y > glyph_c[1] && qt.y < glyph_c[2], "between the 2nd and 3rd characters: {} in {glyph_c:?}", qt.y); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 1bd93920..229b7636 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -170,8 +170,9 @@ pub fn paragraph_style(p: &ParaStyle, width: Option) -> ParagraphStyle { justify_last, leading: 0.0, width, - // Left-to-right paragraphs keep the Unicode default (first strong character). - rtl: (p.direction == Direction::Rtl).then_some(true), + // The chosen direction is the paragraph's base direction (a left-to-right paragraph + // starting with Arabic or Hebrew still runs left to right, as in After Effects). + rtl: Some(p.direction == Direction::Rtl), indent_start: p.indent_left as f32, indent_end: p.indent_right as f32, indent_first: p.indent_first as f32, @@ -182,6 +183,15 @@ pub fn paragraph_style(p: &ParaStyle, width: Option) -> ParagraphStyle { } } +/// Characters set upright in vertical type: CJK ideographs, kana, hangul, full-width forms and +/// CJK punctuation. Everything else (Roman) turns on its side unless asked to stay upright. +pub fn is_cjk(c: char) -> bool { + matches!(c as u32, + 0x1100..=0x11FF | 0x2E80..=0x303F | 0x3040..=0x30FF | 0x3100..=0x31FF | 0x3200..=0x4DBF | 0x4E00..=0x9FFF + | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xF900..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFF00..=0xFFEF + | 0x20000..=0x3FFFF) +} + /// Vertical type: the horizontal layout turned a quarter clockwise (lines become columns /// running right to left), characters kept upright. const ROT90: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]); @@ -243,7 +253,13 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { for (li, line) in lay.lines.iter().enumerate() { let o = to_layer * Point::new(line.x as f64, line.baseline as f64); out.line_boxes.push([o.x, o.y, line.width as f64, (line.ascent + line.descent) as f64]); - for gi in line.glyphs.clone() { + // Vertical type: Tate-Chu-Yoko groups (consecutive glyphs of tcy runs) take one em of + // the column; everything after them moves up by the rest of their width. + let tcy = |gi: usize| doc.vertical && styles.get(lay.glyphs[gi].run).is_some_and(|s| s.tate_chu_yoko); + let mut shift = 0.0f64; + let mut group: Option<(f64, f64, f64)> = None; // (start x, width, em) in layout space + let line_glyphs: Vec = line.glyphs.clone().collect(); + for (k, gi) in line_glyphs.iter().copied().enumerate() { let g = &lay.glyphs[gi]; let ci = char_of_byte.get(g.cluster).copied().unwrap_or(0); let src = text[g.cluster..].chars().next().unwrap_or(' '); @@ -252,13 +268,37 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { let mut path = glyph_outline(g); let next_x = lay.glyphs.get(gi + 1).filter(|_| line.glyphs.contains(&(gi + 1))).map(|n| n.x).unwrap_or(line.x + line.width); let advance = (next_x - g.x) as f64; - if doc.vertical { - // Keep the character upright around its centre. - let c = Point::new(advance / 2.0, -0.35 * g.size as f64); - let rc = ROT90 * c; - path = Affine::translate((rc.x - c.x, rc.y - c.y)) * path; + let mut origin = to_layer * Point::new(g.x as f64 - shift, g.y as f64); + if doc.vertical && tcy(gi) { + // Start of a group: measure it. + if group.is_none() { + let mut w = 0.0; + for &gj in line_glyphs[k..].iter().take_while(|&&gj| tcy(gj)) { + let nx = lay.glyphs.get(gj + 1).filter(|_| line.glyphs.contains(&(gj + 1))).map(|n| n.x).unwrap_or(line.x + line.width); + w += (nx - lay.glyphs[gj].x) as f64; + } + group = Some((g.x as f64, w, g.size as f64)); + } + let (x0, w, em) = group.unwrap_or((g.x as f64, advance, g.size as f64)); + // Upright and horizontal, centred across the column in an em-high cell. + let centre = to_layer * Point::new(x0 - shift + em / 2.0, g.y as f64 - 0.35 * em); + origin = Point::new(centre.x - w / 2.0 + (g.x as f64 - x0), centre.y + 0.35 * em); + if line_glyphs.get(k + 1).is_none_or(|&n| !tcy(n)) { + shift += w - em; + group = None; + } + } else if doc.vertical { + let upright = is_cjk(src) || st.is_some_and(|s| s.vertical_roman_upright); + if upright { + // Keep the character upright around its centre. + let c = Point::new(advance / 2.0, -0.35 * g.size as f64); + let rc = ROT90 * c; + path = Affine::translate((rc.x - c.x, rc.y - c.y)) * path; + } else { + // Roman characters turn on their side with the column. + path = ROT90 * path; + } } - let origin = to_layer * Point::new(g.x as f64, g.y as f64); if let Some(r) = (!path.elements().is_empty()).then(|| kurbo::Shape::bounding_box(&path)) { b[0] = b[0].min(origin.x + r.x0); b[1] = b[1].min(origin.y + r.y0); @@ -438,6 +478,59 @@ mod tests { assert_eq!(v.hit(p0.midpoint(p1)), 1); } + #[test] + fn vertical_roman_rotates_and_tate_chu_yoko_sets_upright_groups() { + let wide = |g: &CharGlyph| { + let r = kurbo::Shape::bounding_box(&g.path); + (r.width(), r.height()) + }; + // "H" sideways in a vertical column: wider than tall (it is taller than wide upright). + let doc = TextDoc { text: "HI".into(), size: 40.0, vertical: true, ..Default::default() }; + let v = layout_doc(&doc); + let (w, h) = wide(&v.glyphs[0]); + assert!(w > h, "rotated: {w}×{h}"); + // Standard Vertical Roman Alignment keeps it upright. + let mut up = doc.clone(); + up.apply_style_all(|s| s.vertical_roman_upright = true); + let (w, h) = wide(&layout_doc(&up).glyphs[0]); + assert!(h > w, "upright: {w}×{h}"); + // CJK stays upright. + let cjk = layout_doc(&TextDoc { text: "日本".into(), size: 40.0, vertical: true, ..Default::default() }); + assert!(cjk.glyphs.iter().all(|g| g.origin.x.is_finite())); + // Tate-Chu-Yoko on "12" in "A12B": the digits sit side by side on one row of the column + // (same y, increasing x) and the group takes one em of the column. + let mut t = TextDoc { text: "A12B".into(), size: 40.0, vertical: true, ..Default::default() }; + let plain = layout_doc(&t); + t.apply_style(1..3, |s| s.tate_chu_yoko = true); + let tcy = layout_doc(&t); + let (one, two) = (&tcy.glyphs[1], &tcy.glyphs[2]); + assert!((one.origin.y - two.origin.y).abs() < 1e-6, "{:?} {:?}", one.origin, two.origin); + assert!(two.origin.x > one.origin.x + 5.0); + let (w, h) = wide(one); + assert!(h > w, "digits upright"); + let w = one.advance + two.advance; + let moved = tcy.glyphs[3].origin.y - plain.glyphs[3].origin.y; + // (Within kerning, which no longer applies across the run boundaries.) + assert!((moved - (40.0 - w)).abs() < 2.5, "the group takes one em: moved {moved}, width {w}"); + // The group is centred across the column (x of the column centre line). + let mid = (one.origin.x + two.origin.x + two.advance) / 2.0; + assert!((mid - plain.glyphs[0].origin.x - 0.35 * 40.0).abs() < 3.0, "{mid}"); + } + + #[test] + fn ltr_paragraph_direction_is_forced() { + // Arabic first: Left-to-Right keeps a left-to-right base (the Latin word stays left). + let text = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627} abc"; + let ltr = layout_doc(&TextDoc { text: text.into(), size: 30.0, ..Default::default() }); + let mut r = TextDoc { text: text.into(), size: 30.0, ..Default::default() }; + r.direction = effectcraft_keyframe::text_doc::Direction::Rtl; + let rtl = layout_doc(&r); + let x_of = |l: &TextLayout, c: char| l.glyphs.iter().find(|g| g.ch == c).map(|g| g.origin.x).unwrap(); + let first_ar = '\u{0645}'; + assert!(x_of(<r, 'a') > x_of(<r, first_ar), "LTR: Arabic run first, then abc"); + assert!(x_of(&rtl, 'a') < x_of(&rtl, first_ar), "RTL: abc on the left"); + } + #[test] fn centered_point_text_straddles_origin() { let doc = TextDoc { text: "CENTER".into(), size: 80.0, justify: Justify::Center, ..Default::default() }; diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index edb1132f..48c762cb 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -232,6 +232,22 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add("character.ligatures", lr, "Ligatures"); p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, "Ligatures", Tokens::ui(11.5), t.text_dim); + // Vertical type: Tate-Chu-Yoko and Standard Vertical Roman Alignment (Character panel menu). + if doc.vertical { + y += 22.0; + for (i, (key, label, on)) in + [("tateChuYoko", "Tate-Chu-Yoko", doc.tate_chu_yoko), ("verticalRomanUpright", "Standard Vertical Roman", doc.vertical_roman_upright)] + .into_iter() + .enumerate() + { + let cr = Rect::from_min_size(pos2(x0 + i as f32 * 120.0, y), vec2(14.0, 14.0)); + if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-vert", key))).clicked() && enabled { + actions.push(json!({key: !on})); + } + app.auto.add(&format!("character.{key}"), cr, label); + p.text(pos2(cr.max.x + 6.0, y + 7.0), Align2::LEFT_CENTER, label, Tokens::ui(11.0), t.text_dim); + } + } match &target { None => { p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint); diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs index e97f5eda..80ee1209 100644 --- a/crates/ui-egui/src/panels/viewer_text.rs +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -54,6 +54,8 @@ struct Edited { /// Layer → comp. m: Mat3, inv: Mat3, + /// Per caret position: static layout → animated / on-path character placement. + carets: Vec, } impl Edited { @@ -111,7 +113,8 @@ fn edited(app: &EffectcraftApp, ectx: &EvalCtx, lid: LayerId) -> Option let lay = layout_doc(&doc); let m = super::viewer::l2c(ectx, &layer).0; let inv = m.inverse()?; - Some(Edited { layer, doc, lay, m, inv }) + let carets = effectcraft_engine::render::text::caret_maps(ectx, &layer, lay.chars); + Some(Edited { layer, doc, lay, m, inv, carets }) } /// The topmost text layer whose text is under screen point `s`. @@ -494,7 +497,14 @@ fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, ma } } // Caret (blinking) and IME composition. + // The caret follows the animated layout and text on a path. let (a, b) = e.lay.caret(caret); + let cm = e.carets.get(caret).copied().unwrap_or(Mat3::IDENTITY); + let anim = |p: Point| { + let q = cm.apply(gv2(p.x, p.y)); + Point::new(q.x, q.y) + }; + let (a, b) = (anim(a), anim(b)); let (sa, sb) = (to_s(a), to_s(b)); let now = ui.input(|i| i.time); let preedit: Option = ui.data(|d| d.get_temp(egui::Id::new("viewer-text-preedit"))); From d2ccf737a8808404fca28fd256d509f74ca4c784 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 00:11:19 -0400 Subject: [PATCH 05/10] M13.5: Layer Style dialog LYR-9: Layer > Layer Styles > Layer Style Options... (layer.style.options) and choosing a style from that menu open a Layer Style dialog: style list with check boxes (Blending Options first) and the selected style's controls built from its layerStyles properties, edited live through prop.set (one undo step per control); Cancel rolls back everything done since it opened. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/frontend.rs | 1 + crates/engine/src/menus.rs | 2 + crates/engine/src/tests_tail.rs | 3 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 8 + crates/ui-egui/src/panels/dialogs.rs | 3 + .../ui-egui/src/panels/layer_styles_dialog.rs | 226 ++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/tests/ui_tail.rs | 43 ++++ 9 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/panels/layer_styles_dialog.rs diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index f0068ae9..168e77a5 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -16,6 +16,7 @@ pub fn specs() -> Vec { vec![ // Application. fe!("app.about", "About EffectCraft...", [], None, "{}", always), + fe!("layer.style.options", "Layer Style Options...", ["Layer", "Layer Styles"], None, "{layer?, style?: blendingOptions|dropShadow|…}", has_layers), fe!( "app.settings", "Settings...", diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 0191c6ea..a110fca7 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -674,6 +674,8 @@ Layer Matte with Layer Above | layer.trackMatte {"op":"above"} Matte with Layer Below | layer.trackMatte {"op":"below"} Layer Styles + Layer Style Options... | layer.style.options + --- Convert to Editable Styles | layer.style.convertToEditable Show All | layer.style.showAll Remove All | layer.style.removeAll diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index 17f2a6b6..4bd2bc5a 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -271,7 +271,8 @@ fn tate_chu_yoko_via_set_text_with_undo_and_serde() { let lay = effectcraft_text::layout_doc(&doc); assert!((lay.glyphs[1].origin.y - lay.glyphs[2].origin.y).abs() < 1e-6, "the digits share a row"); let back = roundtrip(&s); - let effectcraft_keyframe::Value::Text(d) = &back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(t)).unwrap().props.prop("text/sourceText").unwrap().value + let effectcraft_keyframe::Value::Text(d) = + &back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(t)).unwrap().props.prop("text/sourceText").unwrap().value else { panic!() }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 996d9cba..a9763d30 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -71,6 +71,8 @@ pub enum Dialog { Marker, /// Layer ▸ Pre-compose. Precompose, + /// Layer ▸ Layer Styles ▸ Layer Style Options… + LayerStyles, } /// Host hooks provided by the native app (file pickers etc.). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 482bf4cd..4b7366cb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -153,6 +153,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V if crate::panels::dialogs_3d::route(app, id, ¶ms)? { return Ok(Value::Null); } + // Layer ▸ Layer Styles ▸