Merge branch 'worktree-agent-a2255b65f530fbf80'
This commit is contained in:
commit
afd69fb97c
|
|
@ -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<Value> {
|
||||
|
|
@ -249,7 +252,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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<CommandSpec> {
|
|||
"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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<LayerId>)> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let sel: Vec<LayerId> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<f64> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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),
|
||||
]
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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...",
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<u8> =
|
||||
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<Vec<u64>> =
|
||||
(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<CommandSpec> {
|
||||
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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -86,7 +86,7 @@ fn new_mask(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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<Value> {
|
|||
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<Value> {
|
|||
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<us
|
|||
sp.vertices.insert(at, m);
|
||||
sp.in_tangents.insert(at, [d[0] - m[0], d[1] - m[1]]);
|
||||
sp.out_tangents.insert(at, [e[0] - m[0], e[1] - m[1]]);
|
||||
sp.feather_after_insert(at, Some(t));
|
||||
Some(at)
|
||||
}
|
||||
|
||||
/// Nearest (segment, t, distance) of a path to a point (layer space), sampled per segment.
|
||||
pub(crate) fn nearest_on_path(sp: &ShapePath, q: [f64; 2]) -> 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<usize> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>()}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Add Vertex tool: insert a vertex on segment `segment` at parameter `t` (default 0.5).
|
||||
fn insert_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seg =
|
||||
|
|
@ -370,6 +562,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ pub mod essential;
|
|||
pub mod expr_tools;
|
||||
mod file;
|
||||
pub(crate) mod file_more;
|
||||
mod focus;
|
||||
mod frontend;
|
||||
mod help;
|
||||
mod key_labels;
|
||||
mod key_transform;
|
||||
mod layer;
|
||||
mod layer_menu;
|
||||
|
|
@ -38,6 +40,7 @@ mod query;
|
|||
mod render_queue;
|
||||
pub mod roto_cmds;
|
||||
mod settings;
|
||||
mod shape_stroke;
|
||||
mod stubs;
|
||||
mod styles;
|
||||
mod text_anim;
|
||||
|
|
@ -133,6 +136,9 @@ 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(focus::specs());
|
||||
v.extend(key_labels::specs());
|
||||
v.extend(key_transform::specs());
|
||||
v.extend(file_more::specs());
|
||||
v.extend(lottie::specs());
|
||||
|
|
|
|||
|
|
@ -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<Uid> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -17,7 +17,6 @@ macro_rules! stub {
|
|||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
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).
|
||||
|
|
@ -29,9 +28,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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"], "{}"),
|
||||
|
|
@ -43,7 +39,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
// Keyframes / text / tracking.
|
||||
stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"),
|
||||
stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"),
|
||||
stub!("text.animatorFontAxes", "Variable Font Axes", ["Animation", "Animate Text"], "{}"),
|
||||
// Workspaces and panels that don't exist yet.
|
||||
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -372,6 +372,60 @@ fn list_presets(_: &mut Session, _: &Value) -> Result<Value> {
|
|||
Ok(Value::Array(presets().iter().map(|p| json!({"id": p.get("id"), "name": p.get("name"), "description": p.get("description")})).collect()))
|
||||
}
|
||||
|
||||
/// Animation ▸ Animate Text ▸ Variable Font Axes: the axes of the layer's font, or (with `axis`)
|
||||
/// a new animator — or a property on `animator` — that offsets that axis (user units, weighted
|
||||
/// by the selectors, added to the axis default).
|
||||
fn font_axes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.animatorFontAxes";
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?;
|
||||
let Some(KV::Text(doc)) = layer.props.prop("text/sourceText").map(|pr| pr.value_at(layer.layer_time(s.time()))) else {
|
||||
return Err(bad(C, "not a text layer"));
|
||||
};
|
||||
let face = effectcraft_text::layout_doc(&doc).glyphs.first().map(|g| g.face).unwrap_or_else(|| effectcraft_text::resolve(&doc.font, &doc.style).face);
|
||||
let axes = effectcraft_text::variable::font_axes(face);
|
||||
let list = || json!(axes.iter().map(|a| json!({"tag": a.tag, "name": a.name, "min": a.min, "default": a.default, "max": a.max})).collect::<Vec<_>>());
|
||||
let Some(want) = str_p(p, "axis") else { return Ok(json!({"font": doc.font, "axes": list()})) };
|
||||
if axes.is_empty() {
|
||||
return Err(bad(C, format!("{} {} has no variation axes (not a variable font)", doc.font, doc.style)));
|
||||
}
|
||||
let axis = axes
|
||||
.iter()
|
||||
.find(|a| a.tag == want || a.name.eq_ignore_ascii_case(want))
|
||||
.ok_or_else(|| bad(C, format!("no axis `{want}`; the font has {}", list())))?
|
||||
.clone();
|
||||
let target = match p.get("animator") {
|
||||
Some(_) => Some(animator_uid(s, &layer, p, C)?),
|
||||
None => None,
|
||||
};
|
||||
let uid = s.edit("Add Variable Font Axis", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
let prop = ids
|
||||
.prop(&format!("{}{}", effectcraft_render::text::AXIS_PREFIX, axis.tag.trim_end()), &axis.name, KV::Scalar(0.0))
|
||||
.with_ui(effectcraft_project::ParamUi::Number);
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let uid = match target {
|
||||
Some(a) => {
|
||||
let g = l.props.find_group_mut(a).and_then(|g| g.sub_mut("properties")).ok_or_else(|| bad(C, "no such animator"))?;
|
||||
g.children.push(prop.into());
|
||||
a
|
||||
}
|
||||
None => {
|
||||
let anims = l.props.group_mut("text/animators").ok_or_else(|| bad(C, "not a text layer"))?;
|
||||
let name = format!("Animator {}", anims.children.len() + 1);
|
||||
let g = build::text_animator(&mut ids, &name, vec![prop]);
|
||||
let uid = g.uid;
|
||||
anims.children.push(g.into());
|
||||
uid
|
||||
}
|
||||
};
|
||||
proj.next_id = next;
|
||||
Ok(uid)
|
||||
})?;
|
||||
Ok(json!({"animator": uid, "axis": axis.tag}))
|
||||
}
|
||||
|
||||
fn list_kinds(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(build::TEXT_ANIMATOR_KINDS.iter().filter(|(k, _)| *k != "-").map(|(k, l)| json!({"property": k, "label": l})).collect()))
|
||||
}
|
||||
|
|
@ -415,6 +469,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_text_layer,
|
||||
apply_preset
|
||||
),
|
||||
cmd!(
|
||||
"text.animatorFontAxes",
|
||||
"Variable Font Axes",
|
||||
["Animation", "Animate Text"],
|
||||
None,
|
||||
"{layer?, axis?: tag|name (omit to list the font's axes), animator?: uid|index}",
|
||||
has_text_layer,
|
||||
font_axes
|
||||
),
|
||||
query!("text.presets", "Text Animation Presets", "{}", list_presets),
|
||||
query!("text.animatorProperties", "Text Animator Properties", "{}", list_kinds),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
*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]));
|
||||
|
|
|
|||
|
|
@ -717,6 +717,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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -677,6 +677,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
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Optio
|
|||
let mut ids = Ids(&mut proj.next_id);
|
||||
for (k, sp) in vm.subpaths.iter().enumerate() {
|
||||
let n = sp.knots.len();
|
||||
let mut path = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: sp.closed };
|
||||
let mut path = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: sp.closed, feather: Vec::new() };
|
||||
for kn in &sp.knots {
|
||||
let v = [kn[1][0] - origin[0], kn[1][1] - origin[1]];
|
||||
path.vertices.push(v);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,357 @@
|
|||
//! 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::<Vec<_>>()
|
||||
};
|
||||
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<Value> = (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<f32> = (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);
|
||||
}
|
||||
|
||||
#[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 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 variable_font_axes_animator() {
|
||||
let mut s = comp(400, 200);
|
||||
let t = s.execute("layer.newText", json!({"text": "HH", "size": 60})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [t]})).unwrap();
|
||||
assert!(s.is_enabled("text.animatorFontAxes"));
|
||||
// Inter (bundled) is static.
|
||||
assert_eq!(s.execute("text.animatorFontAxes", json!({})).unwrap()["axes"], json!([]));
|
||||
assert!(s.execute("text.animatorFontAxes", json!({"axis": "wght"})).is_err());
|
||||
let Some((face, axes)) = effectcraft_text::variable::find_variable_face() else {
|
||||
eprintln!("no variable font installed: animation check skipped");
|
||||
return;
|
||||
};
|
||||
let info = effectcraft_text::fonts::face(face).info.clone();
|
||||
s.execute("layer.setText", json!({"layer": t, "font": info.family, "style": info.style})).unwrap();
|
||||
let axis = axes.iter().find(|a| a.max > a.min).unwrap().clone();
|
||||
let listed = s.execute("text.animatorFontAxes", json!({})).unwrap();
|
||||
if listed["axes"].as_array().is_none_or(|a| a.is_empty()) {
|
||||
eprintln!("the variable face isn't reachable by family/style: skipped");
|
||||
return;
|
||||
}
|
||||
let tag = listed["axes"][0]["tag"].as_str().unwrap().to_string();
|
||||
let r = s.execute("text.animatorFontAxes", json!({"axis": tag})).unwrap();
|
||||
let anim = r["animator"].as_u64().unwrap();
|
||||
let area = |s: &Session| {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time());
|
||||
let l = comp.layer(LayerId(t)).unwrap();
|
||||
effectcraft_render::text::glyph_paths(&ctx, l).iter().map(|(p, _)| effectcraft_text::kurbo::Shape::area(p).abs()).sum::<f64>()
|
||||
};
|
||||
let before = area(&s);
|
||||
let l = s.active_comp().unwrap().layer(LayerId(t)).unwrap().clone();
|
||||
let pg = l.props.find_group(anim).unwrap().sub("properties").unwrap().props().next().unwrap().uid;
|
||||
let range = (axis.max - axis.min) as f64;
|
||||
s.execute("prop.set", json!({"layer": t, "prop": pg, "value": range})).unwrap();
|
||||
let after = area(&s);
|
||||
assert!((after - before).abs() > 1.0, "{} axis moved the outline: {before} → {after}", axis.tag);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!((area(&s) - before).abs() < 1e-6);
|
||||
let _ = info;
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -482,7 +482,13 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
|
|||
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()),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<bool, Stri
|
|||
}
|
||||
"kerning" => 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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<FeatherPoint>,
|
||||
}
|
||||
|
||||
/// 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<f64>) {
|
||||
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()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ pub(crate) fn to_path(j: &Json) -> Option<Value> {
|
|||
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}));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -700,3 +701,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,6 +171,14 @@ pub(crate) fn export_text(ex: &mut Ex, layer: &Layer, fonts: &mut Vec<Json>) ->
|
|||
}
|
||||
}
|
||||
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>) ->
|
|||
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<Json> {
|
||||
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") {
|
||||
|
|
|
|||
|
|
@ -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<f64>,
|
||||
seg_tables: Vec<Vec<(f64, f64)>>,
|
||||
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<f64> = 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<f64> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<P>| (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.
|
||||
|
|
|
|||
|
|
@ -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<ShapePath> {
|
|||
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>, f64)>,
|
||||
/// Stroke Taper (variable width at the ends).
|
||||
pub taper: Option<varstroke::Taper>,
|
||||
/// Stroke Wave (periodic width).
|
||||
pub wave: Option<varstroke::Wave>,
|
||||
}
|
||||
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -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<f64>,
|
||||
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<Point>,
|
||||
s: Vec<f64>,
|
||||
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<Poly> {
|
||||
let mut out: Vec<Poly> = Vec::new();
|
||||
let mut cur: Vec<Point> = Vec::new();
|
||||
let mut closed = false;
|
||||
let flush = |cur: &mut Vec<Point>, closed: bool, out: &mut Vec<Poly>| {
|
||||
let mut pts: Vec<Point> = 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<f64> = 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<BezPath> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -195,7 +195,12 @@ pub struct Comp {
|
|||
pub markers: Vec<Marker>,
|
||||
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).
|
||||
|
|
@ -231,6 +236,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 {
|
||||
|
|
@ -247,6 +256,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,
|
||||
|
|
|
|||
|
|
@ -529,6 +529,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);
|
||||
|
|
|
|||
|
|
@ -1045,13 +1045,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<Vec<effectcraft_geom::Vec2>> = 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
|
||||
|
|
@ -1061,7 +1099,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);
|
||||
|
|
@ -1096,7 +1134,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 };
|
||||
|
|
@ -1457,6 +1495,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<StrokeStyle>,
|
||||
/// 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<B
|
|||
end: ctx.v2(layer, g, "end", [100.0, 0.0]),
|
||||
rule: rule(ctx, layer, g),
|
||||
stroke,
|
||||
highlight: (ctx.f(layer, g, "highlightLength", 0.0), ctx.f(layer, g, "highlightAngle", 0.0)),
|
||||
},
|
||||
slots: live.clone(),
|
||||
xf: Mat3::IDENTITY,
|
||||
|
|
@ -359,6 +392,54 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
|
|||
(draws, live)
|
||||
}
|
||||
|
||||
/// Gradient position of a point: linear along start→end, radial by distance from the start over
|
||||
/// the radius |end − start|. A radial Highlight shifts the focal point towards
|
||||
/// `highlight_length` % of the radius at `highlight_angle` from the start→end axis (a focal
|
||||
/// two-point gradient: t is measured from the focal point to the circle along the ray).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct GradientRamp {
|
||||
radial: bool,
|
||||
s: [f64; 2],
|
||||
d: [f64; 2],
|
||||
len2: f64,
|
||||
focal: Option<[f64; 2]>,
|
||||
}
|
||||
|
||||
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))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,8 +53,33 @@ pub struct CharXf {
|
|||
pub char_range: u32,
|
||||
pub char_align: u32,
|
||||
pub blur: [f64; 2],
|
||||
/// Variable Font Axes offsets (axis tag, user units), the first `n_axes` used.
|
||||
pub axes: [([u8; 4], f64); 4],
|
||||
pub n_axes: u8,
|
||||
}
|
||||
|
||||
impl CharXf {
|
||||
/// The variable-font axis offsets that move the outline.
|
||||
pub fn axis_deltas(&self) -> Vec<(String, f32)> {
|
||||
self.axes[..self.n_axes as usize]
|
||||
.iter()
|
||||
.filter(|(_, d)| d.abs() > 1e-6)
|
||||
.map(|(t, d)| (String::from_utf8_lossy(t).trim_end().to_string(), *d as f32))
|
||||
.collect()
|
||||
}
|
||||
fn add_axis(&mut self, tag: [u8; 4], d: f64) {
|
||||
if let Some(a) = self.axes[..self.n_axes as usize].iter_mut().find(|a| a.0 == tag) {
|
||||
a.1 += d;
|
||||
} else if (self.n_axes as usize) < self.axes.len() {
|
||||
self.axes[self.n_axes as usize] = (tag, d);
|
||||
self.n_axes += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Animator property match id of a Variable Font Axes property (`axis_wght`).
|
||||
pub const AXIS_PREFIX: &str = "axis_";
|
||||
|
||||
impl Default for CharXf {
|
||||
fn default() -> Self {
|
||||
CharXf {
|
||||
|
|
@ -86,6 +111,8 @@ impl Default for CharXf {
|
|||
char_range: 0,
|
||||
char_align: 1,
|
||||
blur: [0.0; 2],
|
||||
axes: [([0; 4], 0.0); 4],
|
||||
n_axes: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -245,6 +272,17 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
|
|||
let char_range = props.get("characterRange").map(|p| ctx.value(layer, p).as_enum());
|
||||
let char_align = props.get("characterAlignment").map(|p| ctx.value(layer, p).as_enum());
|
||||
let blur = props.get("blur").map(|p| ctx.value(layer, p).as_vec2());
|
||||
let axes: Vec<([u8; 4], f64)> = props
|
||||
.props()
|
||||
.filter_map(|p| {
|
||||
let t = p.match_id.strip_prefix(AXIS_PREFIX)?;
|
||||
let mut tag = [b' '; 4];
|
||||
for (i, c) in t.bytes().take(4).enumerate() {
|
||||
tag[i] = c;
|
||||
}
|
||||
Some((tag, ctx.value(layer, p).as_f64()))
|
||||
})
|
||||
.collect();
|
||||
for (gi, k3) in sel.iter().enumerate() {
|
||||
let k = k3[0];
|
||||
if k3.iter().all(|x| *x == 0.0) {
|
||||
|
|
@ -342,6 +380,9 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
|
|||
c.blur[0] += (b[0] * k3[0]).abs();
|
||||
c.blur[1] += (b[1] * k3[1]).abs();
|
||||
}
|
||||
for (tag, v) in &axes {
|
||||
c.add_axis(*tag, v * k);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
|
|
@ -404,6 +445,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 +509,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<Mat3> {
|
||||
let Some(geom) = text_geom(ctx, layer) else { return vec![Mat3::IDENTITY; chars + 1] };
|
||||
let mut by_char: Vec<Option<Mat3>> = 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<TextGeom> {
|
||||
let doc = source_text(ctx, layer)?;
|
||||
|
|
@ -612,7 +682,11 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
};
|
||||
(kurbo::Affine::translate((dx, 0.0)) * p, g.advance)
|
||||
}
|
||||
None => (g.path.clone(), g.advance),
|
||||
// Variable Font Axes: the outline redrawn at the animated design-space position.
|
||||
None => match x.axis_deltas() {
|
||||
d if !d.is_empty() => (effectcraft_text::variable::char_outline_varied(g, &d).unwrap_or_else(|| g.path.clone()), g.advance),
|
||||
_ => (g.path.clone(), g.advance),
|
||||
},
|
||||
};
|
||||
if outline.elements().is_empty() {
|
||||
continue;
|
||||
|
|
@ -642,7 +716,18 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
|||
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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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::<Vec<_>>();
|
||||
assert!(qt.y > glyph_c[1] && qt.y < glyph_c[2], "between the 2nd and 3rd characters: {} in {glyph_c:?}", qt.y);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ fn depth_of_field_blurs_out_of_focus_surfaces() {
|
|||
let m: f32 = im.data.iter().map(|p| p[0]).sum::<f32>() / im.data.len() as f32;
|
||||
im.data.iter().map(|p| (p[0] - m).powi(2)).sum::<f32>() / 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);
|
||||
|
|
|
|||
|
|
@ -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<Vec<[f32; 4]>> {
|
||||
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::<f32>();
|
||||
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<f32> = (0..n).map(|i| lo + (hi - lo) * i as f32 / (n - 1) as f32).collect();
|
||||
let blurred: Vec<Image> = 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<f32> = (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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Geo> = 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<f32> {
|
||||
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<Buf>) -> Arc<Buf> {
|
||||
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<Arc<Vec<f32>>> {
|
||||
let tm = layer.track_matte?;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<i64> = (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::<f32>();
|
||||
assert!(sum(&lit) > sum(&dec) * 1.5, "{} vs {}", sum(&lit), sum(&dec));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- views
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 { .. })),
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) {
|
|||
},
|
||||
miter: st.miter,
|
||||
dash: st.dash.clone(),
|
||||
taper: None,
|
||||
wave: None,
|
||||
};
|
||||
let cov = effectcraft_path::stroke_coverage(std::slice::from_ref(&path), &style, &mat3(m), w, h);
|
||||
paint(dst, &cov, &st.paint, (opacity * st.opacity) as f32, m, bbox);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ pub mod layout;
|
|||
pub mod path_text;
|
||||
pub mod selectors;
|
||||
pub mod sfnt;
|
||||
pub mod variable;
|
||||
|
||||
pub use kurbo;
|
||||
|
||||
|
|
@ -24,7 +25,7 @@ use skrifa::instance::{LocationRef, Size};
|
|||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
|
||||
struct Pen(BezPath);
|
||||
pub(crate) struct Pen(pub(crate) BezPath);
|
||||
|
||||
impl OutlinePen for Pen {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
|
|
@ -70,10 +71,15 @@ pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
|
|||
/// horizontal / vertical scale and faux italic.
|
||||
pub fn glyph_outline(g: &Glyph) -> BezPath {
|
||||
let Some(units) = outline_units(g.face, g.id) else { return BezPath::new() };
|
||||
glyph_affine(g) * (*units).clone()
|
||||
}
|
||||
|
||||
/// Font units → the glyph's outline in pixels (size, horizontal / vertical scale, faux italic).
|
||||
pub fn glyph_affine(g: &Glyph) -> Affine {
|
||||
let k = g.size as f64 / fonts::face(g.face).units_per_em() as f64;
|
||||
let slant = if g.synth_italic { 0.21 } else { 0.0 };
|
||||
let (hs, vs) = (g.h_scale as f64, g.v_scale as f64);
|
||||
Affine::new([k * hs, 0.0, slant * k * vs * hs, -k * vs, 0.0, 0.0]) * (*units).clone()
|
||||
Affine::new([k * hs, 0.0, slant * k * vs * hs, -k * vs, 0.0, 0.0])
|
||||
}
|
||||
|
||||
/// One character of laid-out text.
|
||||
|
|
@ -97,6 +103,10 @@ pub struct CharGlyph {
|
|||
pub size: f64,
|
||||
/// Index into [`TextLayout::styles`].
|
||||
pub run: usize,
|
||||
/// Face and glyph id, and font units → `path` (for variable-font redraws).
|
||||
pub face: FaceId,
|
||||
pub gid: u32,
|
||||
pub outline_xf: Affine,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
|
@ -170,8 +180,9 @@ pub fn paragraph_style(p: &ParaStyle, width: Option<f32>) -> 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 +193,15 @@ pub fn paragraph_style(p: &ParaStyle, width: Option<f32>) -> 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,22 +263,56 @@ 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<usize> = 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(' ');
|
||||
let st = styles.get(g.run);
|
||||
let ch = if st.is_some_and(|s| s.all_caps) { src.to_uppercase().next().unwrap_or(src) } else { src };
|
||||
let mut path = glyph_outline(g);
|
||||
let mut outline_xf = glyph_affine(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;
|
||||
let t = Affine::translate((rc.x - c.x, rc.y - c.y));
|
||||
path = t * path;
|
||||
outline_xf = t * outline_xf;
|
||||
} else {
|
||||
// Roman characters turn on their side with the column.
|
||||
path = ROT90 * path;
|
||||
outline_xf = ROT90 * outline_xf;
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
|
@ -278,6 +332,9 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
|
|||
synth_bold: g.synth_bold,
|
||||
size: g.size as f64,
|
||||
run: g.run,
|
||||
face: g.face,
|
||||
gid: g.id,
|
||||
outline_xf,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -438,6 +495,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() };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
//! Variable fonts: a face's variation axes and glyph outlines at a point of its design space
|
||||
//! (Animate Text ▸ Variable Font Axes).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use kurbo::BezPath;
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::DrawSettings;
|
||||
use skrifa::{GlyphId, MetadataProvider, Tag};
|
||||
|
||||
use crate::fonts::{self, FaceId};
|
||||
use crate::{CharGlyph, Pen};
|
||||
|
||||
/// One variation axis of a face, in user units.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FontAxis {
|
||||
/// Four-letter tag (`wght`, `wdth`, `opsz`, `slnt`, `ital` or a custom one).
|
||||
pub tag: String,
|
||||
pub name: String,
|
||||
pub min: f32,
|
||||
pub default: f32,
|
||||
pub max: f32,
|
||||
}
|
||||
|
||||
/// The variation axes of a face (empty for static fonts).
|
||||
pub fn font_axes(face: FaceId) -> Vec<FontAxis> {
|
||||
let f = fonts::face(face);
|
||||
let Some(font) = f.font() else { return Vec::new() };
|
||||
font.axes()
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let tag = String::from_utf8_lossy(&a.tag().to_be_bytes()).to_string();
|
||||
let name = font.localized_strings(a.name_id()).english_or_first().map(|s| s.to_string()).unwrap_or_else(|| tag.clone());
|
||||
FontAxis { tag, name, min: a.min_value(), default: a.default_value(), max: a.max_value() }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A tag string as a [`Tag`] (padded with spaces, at most four characters).
|
||||
fn tag_of(s: &str) -> Tag {
|
||||
let mut b = [b' '; 4];
|
||||
for (i, c) in s.bytes().take(4).enumerate() {
|
||||
b[i] = c;
|
||||
}
|
||||
Tag::new(&b)
|
||||
}
|
||||
|
||||
type Key = (FaceId, u32, Vec<(String, i32)>);
|
||||
|
||||
/// A glyph's outline in font units at the given axis values (user units, unspecified axes at
|
||||
/// their defaults).
|
||||
pub fn outline_units_at(face: FaceId, gid: u32, coords: &[(String, f32)]) -> Option<Arc<BezPath>> {
|
||||
if coords.is_empty() {
|
||||
return crate::outline_units(face, gid);
|
||||
}
|
||||
static C: OnceLock<Mutex<HashMap<Key, Option<Arc<BezPath>>>>> = OnceLock::new();
|
||||
let c = C.get_or_init(Default::default);
|
||||
// Quantise to 1/100 of a unit so animation frames reuse outlines.
|
||||
let key: Key = (face, gid, coords.iter().map(|(t, v)| (t.clone(), (v * 100.0).round() as i32)).collect());
|
||||
if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return v.clone();
|
||||
}
|
||||
let f = fonts::face(face);
|
||||
let v = f.font().and_then(|font| {
|
||||
let loc = font.axes().location(coords.iter().map(|(t, v)| (tag_of(t), *v)));
|
||||
let g = font.outline_glyphs().get(GlyphId::new(gid))?;
|
||||
let mut pen = Pen(BezPath::new());
|
||||
g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::from(&loc)), &mut pen).ok()?;
|
||||
Some(Arc::new(pen.0))
|
||||
});
|
||||
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if m.len() > 20_000 {
|
||||
m.clear();
|
||||
}
|
||||
m.insert(key, v.clone());
|
||||
v
|
||||
}
|
||||
|
||||
/// A laid-out character's outline (same placement as [`CharGlyph::path`]) with its face's axes
|
||||
/// moved by `deltas` (tag, user-unit offset from the axis default).
|
||||
pub fn char_outline_varied(g: &CharGlyph, deltas: &[(String, f32)]) -> Option<BezPath> {
|
||||
let axes = font_axes(g.face);
|
||||
let coords: Vec<(String, f32)> =
|
||||
deltas.iter().filter_map(|(t, d)| axes.iter().find(|a| a.tag == *t).map(|a| (t.clone(), (a.default + d).clamp(a.min, a.max)))).collect();
|
||||
if coords.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let units = outline_units_at(g.face, g.gid, &coords)?;
|
||||
Some(g.outline_xf * (*units).clone())
|
||||
}
|
||||
|
||||
/// The first installed face with variation axes (system fonts are scanned), for tests and
|
||||
/// demos: (face, its axes).
|
||||
pub fn find_variable_face() -> Option<(FaceId, Vec<FontAxis>)> {
|
||||
fonts::scan_system();
|
||||
(0..fonts::all_faces().len()).map(|f| (f, font_axes(f))).find(|(_, a)| a.iter().any(|x| x.max > x.min))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kurbo::Shape;
|
||||
|
||||
#[test]
|
||||
fn static_fonts_have_no_axes_and_variable_outlines_move() {
|
||||
let inter = crate::resolve("Inter", "Regular").face;
|
||||
assert!(font_axes(inter).is_empty());
|
||||
// A variable system font, when one is installed (macOS ships several): its outline
|
||||
// changes along an axis. Skipped on machines without one.
|
||||
let Some((face, axes)) = find_variable_face() else {
|
||||
eprintln!("no variable font installed: outline check skipped");
|
||||
return;
|
||||
};
|
||||
let a = axes.iter().find(|x| x.max > x.min).unwrap();
|
||||
let gid = fonts::face(face).glyph('H').or_else(|| fonts::face(face).glyph('A')).unwrap_or(1);
|
||||
let lo = outline_units_at(face, gid, &[(a.tag.clone(), a.min)]).unwrap();
|
||||
let hi = outline_units_at(face, gid, &[(a.tag.clone(), a.max)]).unwrap();
|
||||
assert_ne!(lo.area(), hi.area(), "{} {}..{}", a.tag, a.min, a.max);
|
||||
// The default location is the static outline.
|
||||
let d = outline_units_at(face, gid, &[(a.tag.clone(), a.default)]).unwrap();
|
||||
let s = crate::outline_units(face, gid).unwrap();
|
||||
assert!((d.area() - s.area()).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.).
|
||||
|
|
|
|||
|
|
@ -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 ▸ <style> from the menu adds the style and opens the dialog on it.
|
||||
if crate::panels::layer_styles_dialog::route(app, id, ¶ms)? {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// Legacy per-panel / per-workspace ids (`window.panel.Project`, `window.workspace.default`).
|
||||
if let Some(rest) = id.strip_prefix("window.panel.") {
|
||||
return frontend(app, ctx, "window.panel", json!({"panel": rest}));
|
||||
|
|
@ -452,6 +456,10 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
|||
app.dialog = Some(crate::Dialog::About);
|
||||
Value::Null
|
||||
}
|
||||
"layer.style.options" => {
|
||||
crate::panels::layer_styles_dialog::open(app, &p)?;
|
||||
Value::Null
|
||||
}
|
||||
"app.settings" => {
|
||||
let page = p.get("page").and_then(Value::as_str).unwrap_or("general");
|
||||
crate::panels::settings::open(app, effectcraft_engine::prefs::page_id(page).unwrap_or("general"));
|
||||
|
|
|
|||
|
|
@ -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<serde_js
|
|||
ui.label("Samples Per Frame:");
|
||||
ui.add(egui::DragValue::new(&mut d.samples).range(2..=64));
|
||||
ui.end_row();
|
||||
ui.label("Adaptive Sample Limit:");
|
||||
ui.add(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256));
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
Tab::Renderer => {
|
||||
|
|
@ -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" },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ pub struct DialogState {
|
|||
pub marker: super::markers_ui::MarkerDraft,
|
||||
/// Pre-compose dialog.
|
||||
pub precompose: super::precomp::PrecomposeDraft,
|
||||
/// Layer Style dialog.
|
||||
pub layer_style: super::layer_styles_dialog::LayerStyleState,
|
||||
}
|
||||
|
||||
pub fn open_new_comp(app: &mut EffectcraftApp) {
|
||||
|
|
@ -66,6 +68,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()
|
||||
};
|
||||
|
|
@ -123,6 +126,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
Dialog::TrackOptions => super::tracker::options_dialog(app, ctx, &t),
|
||||
Dialog::Marker => super::markers_ui::dialog(app, ctx, &t),
|
||||
Dialog::Precompose => super::precomp::dialog(app, ctx, &t),
|
||||
Dialog::LayerStyles => super::layer_styles_dialog::show(app, ctx, &t),
|
||||
Dialog::TrackTarget => super::tracker::target_dialog(app, ctx, &t),
|
||||
Dialog::TrackApply => super::tracker::apply_dialog(app, ctx, &t),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,21 +187,59 @@ pub(crate) fn transform_box(
|
|||
}
|
||||
}
|
||||
|
||||
/// Graph Editor ▸ Snap: a dragged key time (comp s) snaps to the current time or another key's
|
||||
/// time within 6 points.
|
||||
/// Graph Editor ▸ Snap: a dragged key time (comp s) snaps to the current time, another key's
|
||||
/// time, a layer's in or out point, a comp or layer marker or the work area within 6 points.
|
||||
pub(crate) fn snap_time(app: &EffectcraftApp, tm: TMap, t: f64, others: &[f64]) -> f64 {
|
||||
if !app.ui.timeline.graph_snap {
|
||||
return t;
|
||||
}
|
||||
let cti = app.session.time().seconds();
|
||||
let mut cands = vec![app.session.time().seconds()];
|
||||
cands.extend_from_slice(others);
|
||||
if let Some(c) = app.session.active_comp() {
|
||||
cands.extend(comp_snap_times(c));
|
||||
}
|
||||
snap_px(tm, t, &cands, 6.0)
|
||||
}
|
||||
|
||||
/// Snap targets of a comp besides keys: work area, markers, and every layer's in / out points
|
||||
/// and markers (comp seconds).
|
||||
pub fn comp_snap_times(c: &Comp) -> Vec<f64> {
|
||||
let mut v = vec![c.work_area.0.seconds(), c.work_area.1.seconds()];
|
||||
v.extend(c.markers.iter().map(|m| m.time.seconds()));
|
||||
for l in &c.layers {
|
||||
v.push(l.in_point.seconds());
|
||||
v.push(l.out_point.seconds());
|
||||
v.extend(l.markers.iter().map(|m| l.comp_time(m.time).seconds()));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The candidate nearest `t` within `tol` screen points, else `t`.
|
||||
pub(crate) fn snap_px(tm: TMap, t: f64, cands: &[f64], tol: f32) -> f64 {
|
||||
let x = tm.x(t);
|
||||
std::iter::once(cti)
|
||||
.chain(others.iter().copied())
|
||||
.map(|c| (c, (tm.x(c) - x).abs()))
|
||||
.filter(|(_, d)| *d < 6.0)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(c, _)| c)
|
||||
.unwrap_or(t)
|
||||
cands.iter().copied().map(|c| (c, (tm.x(c) - x).abs())).filter(|(_, d)| *d < tol).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(c, _)| c).unwrap_or(t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod snap_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn graph_snap_targets_markers_and_layer_ends() {
|
||||
use effectcraft_engine::Session;
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "S", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 10, "height": 10})).unwrap();
|
||||
s.execute("comp.addMarker", json!({"time": 1.5})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
let cands = comp_snap_times(c);
|
||||
assert!(cands.contains(&1.5), "{cands:?}");
|
||||
assert!(cands.iter().any(|t| (*t - c.layers[0].out_point.seconds()).abs() < 1e-9));
|
||||
let tm = TMap { x0: 0.0, start: 0.0, pps: 100.0 };
|
||||
// 1.53 s is 3 points from the marker: snaps; 1.6 s (10 points) doesn't.
|
||||
assert_eq!(snap_px(tm, 1.53, &cands, 6.0), 1.5);
|
||||
assert_eq!(snap_px(tm, 1.6, &[1.5], 6.0), 1.6);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- timeline Alt-drag scaling
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
//! Layer ▸ Layer Styles ▸ Layer Style Options… (and choosing a style from that menu): a Layer
|
||||
//! Style dialog with the style list on the left (a check box per style, Blending Options first)
|
||||
//! and the selected style's controls on the right. Every control edits the ordinary
|
||||
//! `layerStyles/<style>/<property>` property, so the viewer previews live; Cancel rolls the
|
||||
//! edits made in the dialog back, OK keeps them (one undo step per control drag).
|
||||
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
use effectcraft_engine::project::styles::{BLENDING, STYLES};
|
||||
use effectcraft_engine::project::{Layer, LayerId, ParamUi, Property};
|
||||
use egui::{Align2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
/// Dialog state: layer, selected list entry and the undo depth when it opened.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LayerStyleState {
|
||||
pub layer: u64,
|
||||
pub selected: String,
|
||||
pub undo_mark: usize,
|
||||
}
|
||||
|
||||
/// Open the dialog on the first selected (or given) layer, showing `style` (a style id;
|
||||
/// Blending Options when none).
|
||||
pub fn open(app: &mut EffectcraftApp, params: &Value) -> Result<(), String> {
|
||||
let comp = app.session.active_comp().ok_or("no composition is open")?;
|
||||
let layer = params
|
||||
.get("layer")
|
||||
.and_then(Value::as_u64)
|
||||
.or_else(|| app.session.state.selected_layers.first().map(|l| l.0))
|
||||
.filter(|l| comp.layer(LayerId(*l)).is_some())
|
||||
.ok_or("select a layer first")?;
|
||||
let style = params.get("style").and_then(Value::as_str).unwrap_or(BLENDING).to_string();
|
||||
if style != BLENDING && !STYLES.iter().any(|s| s.0 == style) {
|
||||
return Err(format!("unknown layer style `{style}`"));
|
||||
}
|
||||
app.dialog_state.layer_style = LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len() };
|
||||
app.dialog = Some(Dialog::LayerStyles);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Menu routing: choosing one of the Layer ▸ Layer Styles ▸ <style> items (no parameters) adds
|
||||
/// the style as usual and opens the dialog on it.
|
||||
pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool, String> {
|
||||
let Some(style) = id.strip_prefix("layer.style.").filter(|s| STYLES.iter().any(|x| x.0 == *s)) else { return Ok(false) };
|
||||
if !params.as_object().is_none_or(|m| m.is_empty()) {
|
||||
return Ok(false);
|
||||
}
|
||||
let mark = app.session.history.undo.len();
|
||||
app.session.execute(id, json!({})).map_err(|e| e.to_string())?;
|
||||
open(app, &json!({"style": style}))?;
|
||||
// Cancel also removes the style just added.
|
||||
app.dialog_state.layer_style.undo_mark = mark;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn layer(app: &EffectcraftApp) -> Option<Layer> {
|
||||
app.session.active_comp()?.layer(LayerId(app.dialog_state.layer_style.layer)).cloned()
|
||||
}
|
||||
|
||||
/// One control row for a property. Returns the new value when it changed.
|
||||
fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option<Value> {
|
||||
match (v, &p.ui) {
|
||||
(KV::Bool(b), _) => {
|
||||
let mut on = *b;
|
||||
ui.checkbox(&mut on, "").changed().then(|| json!(on))
|
||||
}
|
||||
(KV::Enum(i), ParamUi::Popup { options }) => {
|
||||
let mut sel = *i as usize;
|
||||
egui::ComboBox::from_id_salt(("ls-popup", p.uid)).selected_text(options.get(sel).cloned().unwrap_or_default()).show_ui(ui, |ui| {
|
||||
for (k, o) in options.iter().enumerate() {
|
||||
ui.selectable_value(&mut sel, k, o);
|
||||
}
|
||||
});
|
||||
(sel != *i as usize).then(|| json!(sel))
|
||||
}
|
||||
(KV::Scalar(x), ui_kind) => {
|
||||
let mut y = *x;
|
||||
let r = match ui_kind {
|
||||
ParamUi::Slider { slider_min, slider_max, decimals, .. } => {
|
||||
ui.add(egui::Slider::new(&mut y, *slider_min..=*slider_max).clamping(egui::SliderClamping::Never).max_decimals(*decimals as usize))
|
||||
}
|
||||
ParamUi::Percent => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("%")),
|
||||
ParamUi::Angle => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("°")),
|
||||
ParamUi::Pixels => ui.add(egui::DragValue::new(&mut y).speed(0.25).suffix(" px")),
|
||||
_ => ui.add(egui::DragValue::new(&mut y).speed(0.1)),
|
||||
};
|
||||
(r.changed() && y != *x).then(|| json!(y))
|
||||
}
|
||||
(KV::Color(c), _) => {
|
||||
let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32);
|
||||
let changed = egui::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::color_picker::Alpha::Opaque).changed();
|
||||
changed.then(|| json!([rgba.r(), rgba.g(), rgba.b()]))
|
||||
}
|
||||
(KV::Gradient(_), _) => {
|
||||
ui.label(egui::RichText::new("Edit in the Timeline").weak());
|
||||
None
|
||||
}
|
||||
_ => {
|
||||
ui.label(egui::RichText::new(format!("{v:?}").chars().take(24).collect::<String>()).weak());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let Some(l) = layer(app) else {
|
||||
app.dialog = None;
|
||||
return;
|
||||
};
|
||||
let lid = l.id.0;
|
||||
let styles = l.layer_styles().cloned();
|
||||
let sel = app.dialog_state.layer_style.selected.clone();
|
||||
let time = app.session.time();
|
||||
let lt = l.layer_time(time);
|
||||
let mut actions: Vec<(String, Value)> = Vec::new();
|
||||
let mut close: Option<bool> = None;
|
||||
super::dialogs::modal(ctx, &format!("Layer Style — {}", l.name), vec2(720.0, 520.0), t, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
// Style list.
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(200.0);
|
||||
let mut entries = vec![(BLENDING, "Blending Options")];
|
||||
entries.extend(STYLES.iter().copied());
|
||||
for (id, name) in entries {
|
||||
ui.horizontal(|ui| {
|
||||
if id != BLENDING {
|
||||
let g = styles.as_ref().and_then(|s| s.sub(id));
|
||||
let mut on = g.is_some_and(|g| g.enabled);
|
||||
if ui.checkbox(&mut on, "").changed() {
|
||||
if g.is_none() {
|
||||
actions.push(("layer.style.add".into(), json!({"layers": [lid], "style": id})));
|
||||
} else {
|
||||
actions.push(("layer.style.toggle".into(), json!({"layer": lid, "style": id, "value": on})));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ui.add_space(24.0);
|
||||
}
|
||||
let r = ui.selectable_label(sel == id, name);
|
||||
if r.clicked() {
|
||||
app.dialog_state.layer_style.selected = id.to_string();
|
||||
}
|
||||
let rect = r.rect;
|
||||
app.auto.add(&format!("dialog.layerStyle.list.{id}"), rect, name);
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
// Controls of the selected style.
|
||||
ui.vertical(|ui| {
|
||||
let name = if sel == BLENDING { "Blending Options" } else { STYLES.iter().find(|s| s.0 == sel).map(|s| s.1).unwrap_or("") };
|
||||
let head = ui.label(egui::RichText::new(name).font(Tokens::semibold(13.0)));
|
||||
app.auto.add("dialog.layerStyle.page", head.rect, &sel);
|
||||
ui.add_space(6.0);
|
||||
let Some(g) = styles.as_ref().and_then(|s| s.sub(&sel)) else {
|
||||
ui.label(egui::RichText::new("Not applied: tick it in the list to add it.").weak());
|
||||
return;
|
||||
};
|
||||
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
|
||||
egui::Grid::new("ls-grid").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
|
||||
g.walk("", &mut |_, p| {
|
||||
if matches!(p.ui, ParamUi::Hidden) {
|
||||
return;
|
||||
}
|
||||
let lbl = ui.label(&p.name);
|
||||
let v = p.value_at(lt);
|
||||
if let Some(nv) = control(ui, p, &v) {
|
||||
actions.push((
|
||||
"prop.set".into(),
|
||||
json!({"layer": lid, "prop": p.uid, "value": nv, "merge": format!("layer-style-dialog-{}", p.uid)}),
|
||||
));
|
||||
}
|
||||
let path = g.match_path_of(p.uid).unwrap_or_default();
|
||||
app.auto.add(&format!("dialog.layerStyle.prop.{sel}/{path}"), lbl.rect, &p.name);
|
||||
ui.end_row();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let ok = ui.button("OK");
|
||||
app.auto.add("dialog.layerStyle.ok", ok.rect, "OK");
|
||||
if ok.clicked() {
|
||||
close = Some(true);
|
||||
}
|
||||
let cancel = ui.button("Cancel");
|
||||
app.auto.add("dialog.layerStyle.cancel", cancel.rect, "Cancel");
|
||||
if cancel.clicked() {
|
||||
close = Some(false);
|
||||
}
|
||||
let _ = Align2::LEFT_CENTER;
|
||||
});
|
||||
});
|
||||
for (id, p) in actions {
|
||||
if let Err(e) = app.session.execute(&id, p) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
match close {
|
||||
Some(true) => finish(app, true),
|
||||
Some(false) => finish(app, false),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show another entry of the list (`blendingOptions` or a style id).
|
||||
pub fn select(app: &mut EffectcraftApp, style: &str) {
|
||||
app.dialog_state.layer_style.selected = style.to_string();
|
||||
}
|
||||
|
||||
/// Close the dialog: OK keeps the edits, Cancel undoes everything done since it opened.
|
||||
pub fn finish(app: &mut EffectcraftApp, ok: bool) {
|
||||
if !ok {
|
||||
let mark = app.dialog_state.layer_style.undo_mark;
|
||||
while app.session.history.undo.len() > mark {
|
||||
if !app.session.undo() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
app.dialog = None;
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ pub mod home;
|
|||
pub mod info;
|
||||
pub mod key_dialogs;
|
||||
pub mod layer_panel;
|
||||
pub mod layer_styles_dialog;
|
||||
pub mod markers_ui;
|
||||
pub mod misc;
|
||||
pub mod paint_panels;
|
||||
|
|
|
|||
|
|
@ -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::<f32>();
|
||||
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![];
|
||||
|
|
@ -390,7 +425,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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -727,7 +727,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)));
|
||||
|
|
@ -936,8 +942,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);
|
||||
|
|
@ -948,7 +956,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, "#");
|
||||
|
|
@ -971,7 +979,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")] {
|
||||
|
|
@ -1044,8 +1052,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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1431,6 +1446,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
|
||||
|
|
@ -1705,7 +1721,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));
|
||||
|
|
@ -1742,6 +1767,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;
|
||||
|
|
@ -1876,6 +1918,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 });
|
||||
|
|
@ -2087,6 +2145,37 @@ fn walk_groups(g: &PropGroup, out: &mut Vec<u64>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
|
@ -2135,6 +2224,19 @@ fn text_anim_popups(
|
|||
opts.push(l.to_string());
|
||||
acts.push(if *k == "-" { (String::new(), json!(null)) } else { ("layer.addTextAnimator".into(), json!({"layer": layer.id.0, "property": k})) });
|
||||
}
|
||||
// Variable Font Axes of the layer's font.
|
||||
if let Some(effectcraft_engine::keyframe::Value::Text(doc)) = layer.props.prop("text/sourceText").map(|p| p.value.clone()) {
|
||||
let face = effectcraft_engine::text::resolve(&doc.font, &doc.style).face;
|
||||
let axes = effectcraft_engine::text::variable::font_axes(face);
|
||||
if !axes.is_empty() {
|
||||
opts.push("-".into());
|
||||
acts.push((String::new(), json!(null)));
|
||||
}
|
||||
for a in axes {
|
||||
opts.push(format!("Variable Font Axes: {}", a.name));
|
||||
acts.push(("text.animatorFontAxes".into(), json!({"layer": layer.id.0, "axis": a.tag})));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (kind, l) in [("range", "Selector: Range"), ("wiggly", "Selector: Wiggly"), ("expression", "Selector: Expression")] {
|
||||
opts.push(l.into());
|
||||
|
|
|
|||
|
|
@ -156,6 +156,32 @@ thread_local! {
|
|||
static VIEW_CAM: std::cell::Cell<Option<CameraState>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Region of interest handles on screen: corners (0–3, clockwise from top-left) then edge
|
||||
/// midpoints (4 top, 5 right, 6 bottom, 7 left).
|
||||
fn roi_handles(r: Rect) -> [Pos2; 8] {
|
||||
[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.center_top(), r.right_center(), r.center_bottom(), r.left_center()]
|
||||
}
|
||||
|
||||
/// A drag starting on a region of interest handle: the ROI redrawn from the opposite corner (or
|
||||
/// edge, with the other axis kept).
|
||||
fn roi_drag(app: &EffectcraftApp, map: &ViewerMap, comp_rect: Rect, zoom: f32, press: Pos2) -> Option<ov::Drag> {
|
||||
let [rx, ry, rw, rh] = app.session.state.region_of_interest?;
|
||||
let r = Rect::from_min_size(comp_rect.min + vec2(rx as f32 * zoom, ry as f32 * zoom), vec2(rw as f32 * zoom, rh as f32 * zoom));
|
||||
let i = roi_handles(r).iter().position(|h| h.distance(press) < 6.0)?;
|
||||
let (x0, y0, x1, y1) = (rx, ry, rx + rw, ry + rh);
|
||||
let _ = map;
|
||||
Some(match i {
|
||||
0 => ov::Drag::Roi { start: [x1, y1], keep: [None, None] },
|
||||
1 => ov::Drag::Roi { start: [x0, y1], keep: [None, None] },
|
||||
2 => ov::Drag::Roi { start: [x0, y0], keep: [None, None] },
|
||||
3 => ov::Drag::Roi { start: [x1, y0], keep: [None, None] },
|
||||
4 => ov::Drag::Roi { start: [x1, y1], keep: [Some(x0), None] },
|
||||
5 => ov::Drag::Roi { start: [x0, y0], keep: [None, Some(y1)] },
|
||||
6 => ov::Drag::Roi { start: [x0, y0], keep: [Some(x1), None] },
|
||||
_ => ov::Drag::Roi { start: [x1, y0], keep: [None, Some(y1)] },
|
||||
})
|
||||
}
|
||||
|
||||
/// The camera the viewer shows (view camera or the comp's active camera).
|
||||
fn cam_state(ctx: &EvalCtx) -> CameraState {
|
||||
VIEW_CAM.with(|c| c.get()).unwrap_or_else(|| three_d::active_camera(ctx))
|
||||
|
|
@ -467,11 +493,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if let Some([rx, ry, rw, rh]) = app.session.state.region_of_interest {
|
||||
let r = Rect::from_min_size(comp_rect.min + vec2(rx as f32 * zoom, ry as f32 * zoom), vec2(rw as f32 * zoom, rh as f32 * zoom));
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
|
||||
for c in [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] {
|
||||
painter.rect_filled(Rect::from_center_size(c, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
|
||||
// Resize handles: corners and edge midpoints (drag with any tool).
|
||||
for (i, c) in roi_handles(r).iter().enumerate() {
|
||||
painter.rect_filled(Rect::from_center_size(*c, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
|
||||
app.auto.add(&format!("viewer.regionOfInterest.handle.{i}"), Rect::from_center_size(*c, vec2(8.0, 8.0)), "Resize the region of interest");
|
||||
}
|
||||
app.auto.add("viewer.regionOfInterest", r, "Region of interest");
|
||||
}
|
||||
// 3D Reference Axes (grid and guide options): the world axes as seen by the view's camera.
|
||||
if app.session.prefs.three_d.show_reference_axes && comp.layers.iter().any(|l| l.is_3d()) {
|
||||
let cam = cam_state(&ectx);
|
||||
let o = pos2(area.min.x + 40.0, area.max.y - 40.0);
|
||||
for (axis, col, name) in [
|
||||
(effectcraft_engine::geom::vec3(1.0, 0.0, 0.0), Color32::from_rgb(0xe0, 0x40, 0x40), "X"),
|
||||
(effectcraft_engine::geom::vec3(0.0, 1.0, 0.0), Color32::from_rgb(0x50, 0xd0, 0x50), "Y"),
|
||||
(effectcraft_engine::geom::vec3(0.0, 0.0, 1.0), Color32::from_rgb(0x50, 0x80, 0xf0), "Z"),
|
||||
] {
|
||||
let v = cam.view.apply_vec(axis);
|
||||
let tip = o + vec2(v.x as f32, v.y as f32) * 26.0;
|
||||
painter.line_segment([o, tip], Stroke::new(2.0, col));
|
||||
painter.text(tip + (tip - o).normalized() * 7.0, Align2::CENTER_CENTER, name, Tokens::ui(10.0), col);
|
||||
}
|
||||
app.auto.add("viewer.referenceAxes", Rect::from_center_size(o, vec2(64.0, 64.0)), "3D Reference Axes");
|
||||
}
|
||||
|
||||
// Overlays + interaction.
|
||||
let selected = app.session.state.selected_layers.clone();
|
||||
|
|
@ -702,7 +746,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let g = match tool {
|
||||
Tool::Hand => Some(Gesture::Pan { start_pan: app.ui.viewer.pan }),
|
||||
// Region of Interest armed: the drag draws it.
|
||||
_ if app.ui.viewer.roi_draw => Some(Gesture::Overlay(ov::Drag::Roi { start: map.to_comp(press) })),
|
||||
_ if app.ui.viewer.roi_draw => Some(Gesture::Overlay(ov::Drag::Roi { start: map.to_comp(press), keep: [None, None] })),
|
||||
// A region of interest handle: resize it from the opposite corner / edge.
|
||||
_ if roi_drag(app, &map, comp_rect, zoom, press).is_some() => roi_drag(app, &map, comp_rect, zoom, press).map(Gesture::Overlay),
|
||||
Tool::Selection if ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).is_some() => {
|
||||
ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).map(Gesture::Overlay)
|
||||
}
|
||||
|
|
@ -741,7 +787,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
layer: l,
|
||||
start_anchor: ectx.v3(layer, tr, "anchor", [0.0; 3]),
|
||||
start_pos: ectx.v3(layer, tr, "position", [0.0; 3]),
|
||||
start: cpt,
|
||||
// From where the button went down (the drag starts a few points later).
|
||||
start: map.to_comp(press),
|
||||
inv: l2c.inverse().unwrap_or(Mat3::IDENTITY),
|
||||
l2p,
|
||||
})
|
||||
|
|
@ -914,6 +961,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge}));
|
||||
}
|
||||
Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p } => {
|
||||
// Pan Behind snaps the anchor point (to layer features, guides and the grid).
|
||||
let anchor_c = inv.inverse().map(|m| m.apply(gv2(start_anchor[0], start_anchor[1]))).unwrap_or(gv2(start[0], start[1]));
|
||||
let want = [anchor_c.x + cpt[0] - start[0], anchor_c.y + cpt[1] - start[1]];
|
||||
let sc = vt::snap(app, &ctx, &ectx, &map, &[layer], &[want], mods);
|
||||
let cpt = [cpt[0] + sc[0], cpt[1] + sc[1]];
|
||||
let a = inv.apply(gv2(start[0], start[1]));
|
||||
let b = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
let dl = gv2(b.x - a.x, b.y - a.y);
|
||||
|
|
@ -958,8 +1010,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let pos_v = if vertical { cpt[0] } else { cpt[1] }.round();
|
||||
let _ = app.session.execute("view.moveGuide", json!({"index": index, "position": pos_v, "merge": merge}));
|
||||
}
|
||||
ov::Drag::Roi { start } => {
|
||||
let r = Rect::from_two_pos(map.to_screen(start), pos);
|
||||
ov::Drag::Roi { start, keep } => {
|
||||
let e = [keep[0].unwrap_or(cpt[0]), keep[1].unwrap_or(cpt[1])];
|
||||
let r = Rect::from_two_pos(map.to_screen(start), map.to_screen(e));
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
|
||||
}
|
||||
d => {
|
||||
|
|
@ -1015,8 +1068,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let end = map.to_comp(pos);
|
||||
create_shape(app, tool, start, end, mods.shift);
|
||||
}
|
||||
Gesture::Overlay(ov::Drag::Roi { start }) => {
|
||||
let end = map.to_comp(pos);
|
||||
Gesture::Overlay(ov::Drag::Roi { start, keep }) => {
|
||||
let c = map.to_comp(pos);
|
||||
let end = [keep[0].unwrap_or(c[0]), keep[1].unwrap_or(c[1])];
|
||||
let (x0, y0) = (start[0].min(end[0]).max(0.0), start[1].min(end[1]).max(0.0));
|
||||
let (x1, y1) = (start[0].max(end[0]).min(comp.width as f64), start[1].max(end[1]).min(comp.height as f64));
|
||||
if x1 - x0 >= 2.0 && y1 - y0 >= 2.0 {
|
||||
|
|
|
|||
|
|
@ -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<PathIn
|
|||
let (m, _) = super::viewer::l2c(ectx, l);
|
||||
let is_mask = matches!(g.kind, GroupKind::Mask { .. });
|
||||
let m = if is_mask { m } else { m * effectcraft_engine::render::shapes::item_matrix(ectx, l, uid).unwrap_or(Mat3::IDENTITY) };
|
||||
Some(PathInfo { layer, uid, m, sp, is_mask })
|
||||
Some(PathInfo { layer, uid, m, sp, is_mask, feather: Vec::new() })
|
||||
}
|
||||
|
||||
fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec<Pos2> {
|
||||
|
|
@ -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,12 +429,12 @@ 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.
|
||||
Roi { start: [f64; 2] },
|
||||
/// Drawing the region of interest from `start`; `keep` pins the end's x / y (edge handles).
|
||||
Roi { start: [f64; 2], keep: [Option<f64>; 2] },
|
||||
}
|
||||
|
||||
/// Start a motion-path drag at `press` (keys before tangents), selecting the key.
|
||||
|
|
@ -436,13 +483,26 @@ pub(crate) fn begin_ft_drag(f: &FtBox, press: Pos2, map: &ViewerMap) -> Option<D
|
|||
None
|
||||
}
|
||||
|
||||
/// Mask Feather tool: start on a mask path.
|
||||
pub(crate) fn begin_feather(app: &EffectcraftApp, ectx: &EvalCtx, paths: &[PathInfo], map: &ViewerMap, press: Pos2) -> Option<Drag> {
|
||||
let (layer, uid, _, _) = segment_at(&paths.iter().filter(|p| p.is_mask).cloned().collect::<Vec<_>>(), 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<Drag> {
|
||||
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<PathInfo> = 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, .. } => {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ struct Edited {
|
|||
/// Layer → comp.
|
||||
m: Mat3,
|
||||
inv: Mat3,
|
||||
/// Per caret position: static layout → animated / on-path character placement.
|
||||
carets: Vec<Mat3>,
|
||||
}
|
||||
|
||||
impl Edited {
|
||||
|
|
@ -111,7 +113,8 @@ fn edited(app: &EffectcraftApp, ectx: &EvalCtx, lid: LayerId) -> Option<Edited>
|
|||
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<String> = ui.data(|d| d.get_temp(egui::Id::new("viewer-text-preedit")));
|
||||
|
|
|
|||
|
|
@ -585,6 +585,8 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
|
|||
("Grid".to_string(), v.grid),
|
||||
("Guides".to_string(), v.guides),
|
||||
("Rulers".to_string(), v.rulers),
|
||||
("-".to_string(), false),
|
||||
("3D Reference Axes".to_string(), app.session.prefs.three_d.show_reference_axes),
|
||||
];
|
||||
if let Some(i) = popup(app, ui, "vw-grid-pop", gr, &items, "gridItem") {
|
||||
let v = &mut app.ui.viewer;
|
||||
|
|
@ -593,7 +595,9 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
|
|||
1 => v.proportional_grid = !v.proportional_grid,
|
||||
2 => v.grid = !v.grid,
|
||||
3 => v.guides = !v.guides,
|
||||
_ => v.rulers = !v.rulers,
|
||||
4 => v.rulers = !v.rulers,
|
||||
6 => app.session.prefs.three_d.show_reference_axes = !app.session.prefs.three_d.show_reference_axes,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Show Channel and Color Management Settings.
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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<u64>,
|
||||
/// 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(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
//! 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());
|
||||
}
|
||||
|
||||
fn styles(h: &mut Harness<'_, EffectcraftApp>) -> Vec<String> {
|
||||
let v = h.state_mut().session.execute("layer.style.list", json!({})).unwrap();
|
||||
v["styles"].as_array().unwrap().iter().map(|s| s["style"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_style_dialog_adds_previews_and_cancels() {
|
||||
let mut h = harness(1400.0);
|
||||
let lid = h.state().session.active_comp().unwrap().layers[0].id.0;
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": [lid]})).unwrap();
|
||||
let ctx = h.ctx.clone();
|
||||
// Layer ▸ Layer Styles ▸ Drop Shadow (from the menu): adds the style and opens the dialog on it.
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.dropShadow", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LayerStyles));
|
||||
assert_eq!(styles(&mut h), ["dropShadow"]);
|
||||
assert!(h.state().auto.find("dialog.layerStyle.prop.dropShadow/distance").is_some(), "the style's controls are shown");
|
||||
// Live preview: the controls edit the real property (here through the same command).
|
||||
h.state_mut().session.execute("prop.set", json!({"layer": lid, "path": "layerStyles/dropShadow/distance", "value": 25, "merge": "x"})).unwrap();
|
||||
// Pick Stroke in the list (its page: not applied), then Cancel: everything done in the
|
||||
// dialog is rolled back. (Dialog buttons are driven through the dialog's functions:
|
||||
// kittest pointer events don't reach Foreground areas here.)
|
||||
effectcraft_ui_egui::panels::layer_styles_dialog::select(h.state_mut(), "stroke");
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().auto.find("dialog.layerStyle.page").unwrap().label, "stroke");
|
||||
assert!(h.state().auto.find("dialog.layerStyle.prop.dropShadow/distance").is_none());
|
||||
assert!(h.state().auto.find("dialog.layerStyle.cancel").is_some());
|
||||
effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), false);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert!(styles(&mut h).is_empty(), "Cancel removes the style added from the menu");
|
||||
// Layer Style Options… on Stroke, add it (the list check box runs layer.style.add), OK keeps it.
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.options", json!({"style": "stroke"})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find("dialog.layerStyle.prop.stroke/size").is_none(), "not applied yet");
|
||||
h.state_mut().session.execute("layer.style.add", json!({"style": "stroke"})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find("dialog.layerStyle.prop.stroke/size").is_some(), "its controls appear");
|
||||
effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), true);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert_eq!(styles(&mut h), ["stroke"]);
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
|
@ -295,3 +295,108 @@ 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_of_interest_resizes_by_its_handles() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("view.setRegionOfInterest", json!({"rect": [100, 50, 200, 200]})).unwrap();
|
||||
h.run_steps(3);
|
||||
// Bottom-right corner (handle 2) to (400, 300): the top-left stays.
|
||||
let c = rect(&h, "viewer.regionOfInterest.handle.2").center();
|
||||
let to = screen(&h, [400.0, 300.0]);
|
||||
drag(&mut h, c, to);
|
||||
let r = h.state().session.state.region_of_interest.expect("roi");
|
||||
assert!((r[0] - 100.0).abs() <= 1.0 && (r[1] - 50.0).abs() <= 1.0 && (r[2] - 300.0).abs() <= 2.0 && (r[3] - 250.0).abs() <= 2.0, "{r:?}");
|
||||
// The left edge (handle 7) only moves x.
|
||||
h.run_steps(2);
|
||||
let c = rect(&h, "viewer.regionOfInterest.handle.7").center();
|
||||
let to = pos2(screen(&h, [150.0, 0.0]).x, c.y + 40.0);
|
||||
drag(&mut h, c, to);
|
||||
let r = h.state().session.state.region_of_interest.expect("roi");
|
||||
assert!((r[0] - 150.0).abs() <= 1.0 && (r[1] - 50.0).abs() <= 1.0 && (r[2] - 250.0).abs() <= 2.0 && (r[3] - 250.0).abs() <= 2.0, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_behind_snaps_the_anchor_point() {
|
||||
let mut h = harness();
|
||||
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
|
||||
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
|
||||
h.state_mut().ui.tool = Tool::PanBehind;
|
||||
h.run_steps(2);
|
||||
// Drag the anchor (at the box centre) to 3 px from the comp centre: it snaps there, and the
|
||||
// position follows so the box doesn't move.
|
||||
let from = screen(&h, [100.0, 100.0]);
|
||||
let to = screen(&h, [323.0, 182.0]);
|
||||
// (The gesture applies the pointer of the previous frame: hold the end point a frame.)
|
||||
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..=10 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i.min(8) 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);
|
||||
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
|
||||
let p = l.props.prop("transform/position").unwrap().value.as_vec3();
|
||||
let a = l.props.prop("transform/anchor").unwrap().value.as_vec3();
|
||||
assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "anchor point snapped to the comp centre: {p:?}");
|
||||
assert!((a[0] - 260.0).abs() < 0.01 && (a[1] - 120.0).abs() < 0.01, "{a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_axes_toggle_from_the_grid_menu() {
|
||||
let mut h = harness();
|
||||
// A 3D layer makes the axes relevant.
|
||||
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
|
||||
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [box_id.0], "switch": "threeD", "value": true})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find("viewer.referenceAxes").is_some(), "on by default (Settings ▸ 3D)");
|
||||
let g = rect(&h, "viewer.grid").center();
|
||||
click(&mut h, g);
|
||||
h.run_steps(2);
|
||||
let item = rect(&h, "viewer.gridItem.6").center();
|
||||
click(&mut h, item);
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().session.prefs.three_d.show_reference_axes);
|
||||
assert!(h.state().auto.find("viewer.referenceAxes").is_none());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,16 +115,16 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
|
|||
| Audio | 85% | 0.5 | audio to keyframes |
|
||||
| Import | 88% | 2.0 | AI/EPS/PDF vector footage, PSD smart objects and 3D layers (PSD as footage/composition/retain layer sizes, SVG footage and Create Shapes from Vector Layer landed) |
|
||||
| Automation | ≈ 90% | 0.8 | scripting covers the documented core object model (AUT-2, M14.4: `app`, project items, comps, layers, properties and keyframes, text documents, markers, render queue, Script Console, `effectcraft-cli script`, MCP `run_script`); still missing: ScriptUI panels/dialogs, `.jsxbin`, sockets |
|
||||
| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) |
|
||||
| Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) |
|
||||
| Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' rare controls (font menus, mirrored properties) (the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
|
||||
| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) |
|
||||
| Text | ≈ 86% | 1.3 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no extruded strokes (extruded, bevelled text landed in M7.6); per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) |
|
||||
| Animation | 67% | 10.0 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
|
||||
| Text | ≈ 91% | 0.8 | no OpenType feature panel, no extruded strokes, variable-axis animation changes outlines but not advances (vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
|
||||
| Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) |
|
||||
| 3D | 76% | 10.0 | multi-view layouts, Advanced 3D motion blur and blend modes, cameras/lights from models; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) landed in M7.4–M7.6 |
|
||||
| 3D | 78% | 9.3 | multi-view layouts, Advanced 3D motion blur and blend modes (and iris shapes in Advanced 3D's depth of field), cameras/lights from models; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 |
|
||||
| Effects | ≈ 80% | 4.5 | GPU versions of more effects and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
|
||||
| Interface | 70% | 6.0 | Timeline horizontal scrolling of many columns, a richer Learn area (native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
| Interface | 74% | 5.0 | a richer Learn area (Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
| Project | ≈ 60% | 6.5 | 8/16/32-bit pipeline and colour management, auto-save, folder moves (proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) |
|
||||
| Masks & roto | 68% | 6.0 | variable-width mask feather points, Roto Brush's learned (3.0) segmentation model (mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
|
||||
| Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
|
||||
| Preview | 62% | 5.0 | GPU 3D and adjustment layers (persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
|
||||
| Tracking | ≈ 85% | 2.5 | face tracking, Subspace Warp's mesh warp, lens distortion in the camera solve (Rolling Shutter Repair landed in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker landed in M6.x / M12.5 / M12.6) |
|
||||
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
|
||||
|
|
@ -152,6 +152,21 @@ Mocha and Cineware are third-party and not counted.
|
|||
| Utility | Color Profile Converter | Our colour spaces and ACES; rendering intents (perceptual gamut compression, relative / absolute colorimetric, saturation). |
|
||||
| Matte | Mocha shape | Mocha's export format is not public: reads a documented JSON shape format instead. |
|
||||
|
||||
## Update: M13.5 long-tail polish
|
||||
|
||||
Stroke Taper / Wave / multi-segment dashes and the radial gradient highlight (SHP-2);
|
||||
variable-width mask feather points (MSK-1; mask motion blur verified by tests); the Adaptive
|
||||
Sample Limit driving per-layer motion-blur samples (LYR-10); Classic 3D depth of field with
|
||||
the camera's iris shape, rotation, roundness, aspect ratio, diffraction fringe and highlights,
|
||||
progressive blur on tilted layers, and Link Focus Distance to Point of Interest / to Layer and
|
||||
Set Focus Distance to Layer (3D-1); Timeline outline and Project panel column scrolling;
|
||||
keyframe colour labels and Select Keyframe Label Group (ANM-1); vertical Roman text,
|
||||
Tate-Chu-Yoko, forced LTR paragraphs, the editing caret on animated / path text and Lottie
|
||||
style runs; the Layer Style dialog (LYR-9); Graph Editor snapping to markers and layer ends
|
||||
(ANM-4; the reference graph existed); viewer ROI resize handles, Pan Behind snapping and 3D
|
||||
Reference Axes; Animate Text ▸ Variable Font Axes. Remaining in these rows: Lottie can't carry
|
||||
taper/wave, Advanced 3D's DOF has no iris shapes, and variable axes don't change advances.
|
||||
|
||||
## Highest-value gaps, in order
|
||||
|
||||
1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10).
|
||||
|
|
|
|||
Loading…
Reference in New Issue