mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 11:17:12 -04:00
Merge branch 'fix/f7': community issue fixes
This commit is contained in:
17 files changed
+779
-82
No files matched your search
+62
-11
@@ -159,7 +159,8 @@ impl BlendMode {
|
||||
|
||||
/// Modes whose formulas are defined for over-range (HDR, 32 bpc) values. The others clamp
|
||||
/// their inputs to 0..1 before blending, as After Effects documents for 32 bpc projects
|
||||
/// (in 8/16 bpc everything is already in range).
|
||||
/// (in 8/16 bpc everything is already in range). Overlay, Exclusion, Luminosity and the
|
||||
/// Color Burns keep their plain formula above 1 in After Effects (#494).
|
||||
pub fn supports_hdr(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
@@ -168,6 +169,8 @@ impl BlendMode {
|
||||
| BlendMode::DancingDissolve
|
||||
| BlendMode::Darken
|
||||
| BlendMode::Multiply
|
||||
| BlendMode::ColorBurn
|
||||
| BlendMode::ClassicColorBurn
|
||||
| BlendMode::LinearBurn
|
||||
| BlendMode::DarkerColor
|
||||
| BlendMode::Add
|
||||
@@ -177,6 +180,9 @@ impl BlendMode {
|
||||
| BlendMode::LighterColor
|
||||
| BlendMode::Difference
|
||||
| BlendMode::ClassicDifference
|
||||
| BlendMode::Overlay
|
||||
| BlendMode::Exclusion
|
||||
| BlendMode::Luminosity
|
||||
| BlendMode::Subtract
|
||||
| BlendMode::Divide
|
||||
| BlendMode::StencilAlpha
|
||||
@@ -188,6 +194,17 @@ impl BlendMode {
|
||||
)
|
||||
}
|
||||
|
||||
/// The source colour a classic mode leaves the backdrop unchanged with. After Effects fades
|
||||
/// the source toward it by the layer's opacity before applying the classic formula at full
|
||||
/// strength, rather than mixing the full-strength result by opacity (#494).
|
||||
fn classic_neutral(self) -> Option<f32> {
|
||||
match self {
|
||||
BlendMode::ClassicColorDodge | BlendMode::ClassicDifference => Some(0.0),
|
||||
BlendMode::ClassicColorBurn => Some(1.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(self) -> BlendMode {
|
||||
let i = BlendMode::ALL.iter().position(|m| *m == self).unwrap_or(0);
|
||||
BlendMode::ALL[(i + 1) % BlendMode::ALL.len()]
|
||||
@@ -242,11 +259,8 @@ fn separable(mode: BlendMode, cb: f32, cs: f32) -> f32 {
|
||||
BlendMode::Multiply => cb * cs,
|
||||
BlendMode::ColorBurn => burn(cb, cs),
|
||||
BlendMode::ClassicColorBurn => {
|
||||
if cs <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(1.0 - (1.0 - cb) / cs).max(0.0)
|
||||
}
|
||||
// (negative results are kept, as in After Effects)
|
||||
if cs <= 0.0 { 0.0 } else { 1.0 - (1.0 - cb) / cs }
|
||||
}
|
||||
BlendMode::LinearBurn => (cb + cs - 1.0).max(0.0),
|
||||
BlendMode::Add | BlendMode::LinearDodge => cb + cs,
|
||||
@@ -401,11 +415,19 @@ pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4], noise: f32) ->
|
||||
cs = cs.map(|v| v.clamp(0.0, 1.0));
|
||||
cb = cb.map(|v| v.clamp(0.0, 1.0));
|
||||
}
|
||||
let b = non_separable(mode, cb, cs);
|
||||
let ao = sa + da - sa * da;
|
||||
let mut o = [0.0f32; 4];
|
||||
for i in 0..3 {
|
||||
o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i];
|
||||
if let Some(neutral) = mode.classic_neutral() {
|
||||
// Fade the source toward the neutral colour by its coverage, then apply the formula.
|
||||
let b = non_separable(mode, cb, cs.map(|v| neutral + sa * (v - neutral)));
|
||||
for i in 0..3 {
|
||||
o[i] = (1.0 - da) * src[i] + da * b[i];
|
||||
}
|
||||
} else {
|
||||
let b = non_separable(mode, cb, cs);
|
||||
for i in 0..3 {
|
||||
o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i];
|
||||
}
|
||||
}
|
||||
o[3] = ao;
|
||||
o
|
||||
@@ -469,12 +491,41 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hdr_modes() {
|
||||
// Add and Screen keep over-range values (32 bpc); Overlay clamps its inputs.
|
||||
// Add and Screen keep over-range values (32 bpc); Soft Light clamps its inputs.
|
||||
let d = [2.0, 2.0, 2.0, 1.0];
|
||||
let s = [0.5, 0.5, 0.5, 1.0];
|
||||
assert!((blend_pixel(BlendMode::Add, d, s, 0.5)[0] - 2.5).abs() < 1e-6);
|
||||
assert!((blend_pixel(BlendMode::Screen, d, s, 0.5)[0] - 1.5).abs() < 1e-6);
|
||||
assert!(blend_pixel(BlendMode::Overlay, d, s, 0.5)[0] <= 1.0);
|
||||
assert!(blend_pixel(BlendMode::SoftLight, d, s, 0.5)[0] <= 1.0);
|
||||
}
|
||||
|
||||
/// #494: After Effects' 32 bpc formulas, measured by the reporter. b = bottom, o = top,
|
||||
/// k = opacity (premultiplied into the source, as the compositor passes it).
|
||||
#[test]
|
||||
fn after_effects_32bpc_formulas() {
|
||||
let at = |m, b: f32, o: f32, k: f32| blend_pixel(m, opaque(b), [o * k, o * k, o * k, k], 0.5)[0];
|
||||
let close = |a: f32, e: f32| (a - e).abs() < 1e-5;
|
||||
// Classic modes fade the source toward their neutral colour first.
|
||||
for (b, o) in [(0.4, 0.5), (0.8, 0.9), (0.1, 0.3)] {
|
||||
let k = 0.15;
|
||||
assert!(close(at(BlendMode::ClassicColorDodge, b, o, k), (b / (1.0 - k * o)).min(1.0)));
|
||||
assert!(close(at(BlendMode::ClassicDifference, b, o, k), (b - k * o).abs()));
|
||||
assert!(close(at(BlendMode::ClassicColorBurn, b, o, k), 1.0 - (1.0 - b) / (1.0 - k * (1.0 - o))));
|
||||
}
|
||||
// At full opacity the classic formulas are unchanged.
|
||||
assert!(close(at(BlendMode::ClassicDifference, 0.6, 0.3, 1.0), 0.3));
|
||||
// Over-range top layers keep the plain formulas.
|
||||
let (b, o) = (0.7, 3.0);
|
||||
assert!(close(at(BlendMode::Exclusion, b, o, 1.0), b + o - 2.0 * b * o));
|
||||
let s = 2.0 * b - 1.0;
|
||||
assert!(close(at(BlendMode::Overlay, b, o, 1.0), o + s - o * s));
|
||||
assert!(close(at(BlendMode::ColorBurn, 0.0, 1.35, 0.15), 0.15 * (1.0 - 1.0 / 1.35)));
|
||||
assert!(close(at(BlendMode::ClassicColorBurn, 0.0, 0.1, 1.0), -9.0), "negative results are kept");
|
||||
// Luminosity: SetLum + ClipColor on the unclamped top layer.
|
||||
let l = blend_pixel(BlendMode::Luminosity, [0.2, 0.4, 0.6, 1.0], [2.0, 2.0, 2.0, 1.0], 0.5);
|
||||
let e = set_lum([0.2, 0.4, 0.6], 2.0);
|
||||
assert!((0..3).all(|i| close(l[i], e[i])), "{l:?} {e:?}");
|
||||
assert!((l[0] - 1.0).abs() > 0.1, "not the white of a top layer clamped to 1: {l:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -165,6 +165,145 @@ fn property_reply(p: &Value, layer: &Layer, pr: &Property, time: Tick, value: Va
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- several selected layers
|
||||
|
||||
/// With `selected: true` (the Timeline, Properties and Effect Controls panels pass it): the same
|
||||
/// property (by match path, `transform/position`, `effects/<effect>#n/<param>`) of every other
|
||||
/// selected, unlocked layer of the comp whose value is of the same kind, as (layer, uid). Empty
|
||||
/// when `selected` is not set or the edited layer isn't selected. After Effects applies a value
|
||||
/// edit, the stopwatch and the keyframe button to all selected layers (#355, #491).
|
||||
fn selected_twins(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(LayerId, Uid)>> {
|
||||
if b_p(p, "selected") != Some(true) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let (cid, lid, uid) = prop_ref(s, p, cmd)?;
|
||||
let Some(comp) = s.project.comp(cid) else { return Ok(vec![]) };
|
||||
let Some(layer) = comp.layer(lid) else { return Ok(vec![]) };
|
||||
let (Some(path), Some(pr)) = (layer.props.match_path_of(uid), layer.props.find(uid)) else { return Ok(vec![]) };
|
||||
if !s.state.selected_layers.contains(&lid) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let kind = (pr.value.kind_name(), pr.value.components().len());
|
||||
Ok(s.state
|
||||
.selected_layers
|
||||
.iter()
|
||||
.filter(|o| **o != lid)
|
||||
.filter_map(|o| comp.layer(*o))
|
||||
.filter(|l| !l.switches.locked)
|
||||
.filter_map(|l| l.props.prop(&path).filter(|q| (q.value.kind_name(), q.value.components().len()) == kind).map(|q| (l.id, q.uid)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// `p` aimed at property `uid` of layer `lid` instead.
|
||||
fn twin_params(p: &Value, lid: LayerId, uid: Uid) -> Value {
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("path");
|
||||
o.remove("selected");
|
||||
o.remove("offset");
|
||||
}
|
||||
q["layer"] = json!(lid.0);
|
||||
q["prop"] = json!(uid);
|
||||
q
|
||||
}
|
||||
|
||||
/// The value of property `uid` of `lid` at the CTI as numbers (Position with Separate
|
||||
/// Dimensions: its X / Y / Z Position).
|
||||
fn current_components(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option<Vec<f64>> {
|
||||
let l = s.project.comp(cid)?.layer(lid)?;
|
||||
let lt = l.layer_time(s.time_of(cid));
|
||||
if let Some(tr) = l.transform()
|
||||
&& tr.get("position").is_some_and(|q| q.uid == uid)
|
||||
&& tr.get("positionX").is_some()
|
||||
{
|
||||
return Some(["positionX", "positionY", "positionZ"].iter().filter_map(|m| tr.get(m)).map(|q| q.value_at(lt).as_f64()).collect());
|
||||
}
|
||||
Some(l.props.find(uid)?.value_at(lt).components())
|
||||
}
|
||||
|
||||
/// A JSON number or array of numbers.
|
||||
fn json_numbers(v: &Value) -> Option<Vec<f64>> {
|
||||
match v {
|
||||
Value::Number(n) => Some(vec![n.as_f64()?]),
|
||||
Value::Array(a) => a.iter().map(Value::as_f64).collect(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with
|
||||
/// `offset` (a scrub) the same change from each layer's own value. One undo step (with `merge`,
|
||||
/// one that a continuing scrub keeps folding into).
|
||||
fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let twins = selected_twins(s, p, "prop.set")?;
|
||||
if twins.is_empty() {
|
||||
return set(s, p);
|
||||
}
|
||||
let (cid, lid, uid) = prop_ref(s, p, "prop.set")?;
|
||||
let new = p.get("value").and_then(json_numbers);
|
||||
let delta = match (b_p(p, "offset"), &new, current_components(s, cid, lid, uid)) {
|
||||
(Some(true), Some(n), Some(c)) => Some(n.iter().zip(c.iter().chain(std::iter::repeat(&0.0))).map(|(a, b)| a - b).collect::<Vec<f64>>()),
|
||||
_ => None,
|
||||
};
|
||||
let run = |s: &mut Session| -> Result<Value> {
|
||||
let out = set(s, p)?;
|
||||
for (olid, ouid) in twins {
|
||||
let mut q = twin_params(p, olid, ouid);
|
||||
if let Some(d) = &delta
|
||||
&& let Some(c) = current_components(s, cid, olid, ouid)
|
||||
{
|
||||
let v: Vec<f64> = c.iter().enumerate().map(|(i, x)| x + d.get(i).copied().unwrap_or(0.0)).collect();
|
||||
q["value"] = if p.get("value").is_some_and(Value::is_number) { json!(v.first().copied().unwrap_or(0.0)) } else { json!(v) };
|
||||
}
|
||||
// (a layer that can't take it, e.g. a clamped or static property, keeps its value)
|
||||
let _ = set(s, &q);
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
if merge_p(p).is_some() { run(s) } else { super::app_more::grouped(s, "Change Property", run) }
|
||||
}
|
||||
|
||||
/// `prop.toggleAnimation`; with `selected`, the other selected layers' same property follows.
|
||||
fn toggle_anim_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let twins = selected_twins(s, p, "prop.toggleAnimation")?;
|
||||
if twins.is_empty() {
|
||||
return toggle_anim(s, p);
|
||||
}
|
||||
let run = |s: &mut Session| -> Result<Value> {
|
||||
let on = toggle_anim(s, p)?;
|
||||
for (olid, ouid) in twins {
|
||||
let mut q = twin_params(p, olid, ouid);
|
||||
q["value"] = on.clone();
|
||||
let _ = toggle_anim(s, &q);
|
||||
}
|
||||
Ok(on)
|
||||
};
|
||||
// (a stopwatch drag over several rows keeps folding into the step its `merge` names)
|
||||
if merge_p(p).is_some() { run(s) } else { super::app_more::grouped(s, "Toggle Animation", run) }
|
||||
}
|
||||
|
||||
/// `prop.toggleKey`; with `selected`, the other selected layers' same property gets (or loses)
|
||||
/// its keyframe at the current time too.
|
||||
fn toggle_key_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let twins = selected_twins(s, p, "prop.toggleKey")?;
|
||||
if twins.is_empty() {
|
||||
return toggle_key(s, p);
|
||||
}
|
||||
let cid = prop_ref(s, p, "prop.toggleKey")?.0;
|
||||
super::app_more::grouped(s, "Add/Remove Keyframe", |s| {
|
||||
let added = toggle_key(s, p)?;
|
||||
let want = added.as_bool().unwrap_or(true);
|
||||
for (olid, ouid) in twins {
|
||||
let _ = with_prop(s, "Add/Remove Keyframe", None, cid, olid, ouid, |pr, lt| {
|
||||
if key_at(&pr.keys, lt).is_some() != want {
|
||||
toggle_key_at(pr, lt);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
Ok(added)
|
||||
})
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = prop_ref(s, p, "prop.set")?;
|
||||
// Position with Separate Dimensions: write the X/Y/Z Position properties instead.
|
||||
@@ -1001,13 +1140,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Property Value",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?}",
|
||||
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change)}",
|
||||
has_layers,
|
||||
set
|
||||
set_selected
|
||||
),
|
||||
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?}", has_layers, toggle_anim),
|
||||
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?, selected?}", has_layers, toggle_anim_selected),
|
||||
cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time? (layer s by default), timeBase?: layer|comp, value?}", has_layers, add_key),
|
||||
cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key),
|
||||
cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop, selected?}", has_layers, toggle_key_selected),
|
||||
cmd!(
|
||||
"keys.toggleTransform",
|
||||
"Add or Remove Transform Keyframe",
|
||||
|
||||
@@ -831,3 +831,51 @@ fn toggle_transform_key_covers_selected_layers_separated_position_and_3d_rotatio
|
||||
}
|
||||
assert!(s.execute("keys.toggleTransform", json!({"prop": "skew"})).is_err());
|
||||
}
|
||||
|
||||
/// #355, #491: with several layers selected, the stopwatch, the keyframe button and value edits
|
||||
/// (`selected: true`, as the panels send them) apply to every selected layer in one undo step; a
|
||||
/// scrub (`offset`) moves each layer by the same amount, a typed value sets them all.
|
||||
#[test]
|
||||
fn property_edits_apply_to_every_selected_layer() {
|
||||
let (mut s, a) = setup();
|
||||
let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [10, 20]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
|
||||
let pos = |s: &Session, l: u64| prop(s, l, "transform/position").value_at(effectcraft_time::Tick::ZERO).components();
|
||||
let uid = prop(&s, a, "transform/position").uid;
|
||||
|
||||
// Stopwatch on all selected layers, one undo step; the unselected layer is untouched.
|
||||
let undo = s.history.undo.len();
|
||||
s.execute("prop.toggleAnimation", json!({"layer": a, "prop": uid, "selected": true})).unwrap();
|
||||
assert!(prop(&s, a, "transform/position").is_animated() && prop(&s, b, "transform/position").is_animated());
|
||||
assert!(!prop(&s, c, "transform/position").is_animated());
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(!prop(&s, a, "transform/position").is_animated() && !prop(&s, b, "transform/position").is_animated());
|
||||
|
||||
// A scrub moves both by the same change (merged into one step while it continues).
|
||||
let (pa, pb) = (pos(&s, a), pos(&s, b));
|
||||
for k in 1..=3 {
|
||||
let v = [pa[0] + 5.0 * k as f64, pa[1]];
|
||||
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": v, "selected": true, "offset": true, "merge": "scrub-1"})).unwrap();
|
||||
}
|
||||
assert_eq!(pos(&s, a)[..2], [pa[0] + 15.0, pa[1]]);
|
||||
assert_eq!(pos(&s, b)[..2], [pb[0] + 15.0, pb[1]]);
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step for the scrub");
|
||||
// A typed value sets them all.
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 40, "selected": true})).unwrap();
|
||||
assert_eq!(prop(&s, b, "transform/opacity").value.as_f64(), 40.0);
|
||||
assert_eq!(prop(&s, c, "transform/opacity").value.as_f64(), 100.0);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, a, "transform/opacity").value.as_f64(), 100.0);
|
||||
assert_eq!(prop(&s, b, "transform/opacity").value.as_f64(), 100.0);
|
||||
|
||||
// The keyframe button adds a key on each selected layer.
|
||||
s.execute("prop.toggleKey", json!({"layer": a, "path": "transform/scale", "selected": true})).unwrap();
|
||||
assert_eq!(prop(&s, a, "transform/scale").keys.len(), 1);
|
||||
assert_eq!(prop(&s, b, "transform/scale").keys.len(), 1);
|
||||
// Without `selected` only the given layer changes.
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap();
|
||||
assert_eq!(prop(&s, b, "transform/rotation").value.as_f64(), 0.0);
|
||||
}
|
||||
@@ -235,7 +235,7 @@ fn separable(mode: u32, cb: f32, cs: f32) -> f32 {
|
||||
if (cs <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
return max(1.0 - (1.0 - cb) / cs, 0.0);
|
||||
return 1.0 - (1.0 - cb) / cs;
|
||||
}
|
||||
case 7u: { return max(cb + cs - 1.0, 0.0); }
|
||||
case 9u, 14u: { return cb + cs; }
|
||||
@@ -343,7 +343,7 @@ fn non_separable(mode: u32, cb: vec3<f32>, cs: vec3<f32>) -> vec3<f32> {
|
||||
|
||||
fn supports_hdr(mode: u32) -> bool {
|
||||
switch mode {
|
||||
case 0u, 1u, 2u, 3u, 4u, 7u, 8u, 9u, 10u, 11u, 14u, 15u, 23u, 24u, 26u, 27u, 32u, 33u, 34u, 35u, 36u, 37u: { return true; }
|
||||
case 0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u, 11u, 14u, 15u, 16u, 23u, 24u, 25u, 26u, 27u, 31u, 32u, 33u, 34u, 35u, 36u, 37u: { return true; }
|
||||
default: { return false; }
|
||||
}
|
||||
}
|
||||
@@ -388,8 +388,18 @@ fn blend_pixel(mode: u32, dst: vec4<f32>, src: vec4<f32>, noise: f32) -> vec4<f3
|
||||
cs = clamp(cs, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
cb = clamp(cb, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
}
|
||||
let b = non_separable(mode, cb, cs);
|
||||
let ao = sa + da - sa * da;
|
||||
// Classic Color Dodge / Difference (neutral 0) and Classic Color Burn (neutral 1): the
|
||||
// source fades toward the neutral colour by its coverage, then the formula applies.
|
||||
if (mode == 13u || mode == 24u || mode == 6u) {
|
||||
var neutral = 0.0;
|
||||
if (mode == 6u) {
|
||||
neutral = 1.0;
|
||||
}
|
||||
let bc = non_separable(mode, cb, neutral + sa * (cs - neutral));
|
||||
return vec4<f32>((1.0 - da) * src.xyz + da * bc, ao);
|
||||
}
|
||||
let b = non_separable(mode, cb, cs);
|
||||
return vec4<f32>((1.0 - da) * src.xyz + (1.0 - sa) * dst.xyz + sa * da * b, ao);
|
||||
}
|
||||
|
||||
|
||||
@@ -233,26 +233,44 @@ struct SpatialSeg {
|
||||
|
||||
const ARC_N: usize = 96;
|
||||
|
||||
/// 5-point Gauss-Legendre nodes and weights on -1..1.
|
||||
const GAUSS: [(f64, f64); 5] = [
|
||||
(0.0, 0.568_888_888_888_888_9),
|
||||
(-0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
|
||||
(0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
|
||||
(-0.906_179_845_938_664, 0.236_926_885_056_189_1),
|
||||
(0.906_179_845_938_664, 0.236_926_885_056_189_1),
|
||||
];
|
||||
|
||||
impl SpatialSeg {
|
||||
fn new(p: [[f64; 3]; 4]) -> SpatialSeg {
|
||||
let mut table = Vec::with_capacity(ARC_N + 1);
|
||||
table.push(0.0);
|
||||
let mut prev = Self::point(&p, 0.0);
|
||||
let mut acc = 0.0;
|
||||
for i in 1..=ARC_N {
|
||||
let q = Self::point(&p, i as f64 / ARC_N as f64);
|
||||
acc += ((q[0] - prev[0]).powi(2) + (q[1] - prev[1]).powi(2) + (q[2] - prev[2]).powi(2)).sqrt();
|
||||
acc += Self::arc(&p, (i - 1) as f64 / ARC_N as f64, i as f64 / ARC_N as f64);
|
||||
table.push(acc);
|
||||
prev = q;
|
||||
}
|
||||
SpatialSeg { p, table }
|
||||
}
|
||||
fn point(p: &[[f64; 3]; 4], u: f64) -> [f64; 3] {
|
||||
[bez(p[0][0], p[1][0], p[2][0], p[3][0], u), bez(p[0][1], p[1][1], p[2][1], p[3][1], u), bez(p[0][2], p[1][2], p[2][2], p[3][2], u)]
|
||||
}
|
||||
/// |dB/du|.
|
||||
fn tangent_len(p: &[[f64; 3]; 4], u: f64) -> f64 {
|
||||
(0..3).map(|k| bez_d(p[0][k], p[1][k], p[2][k], p[3][k], u).powi(2)).sum::<f64>().sqrt()
|
||||
}
|
||||
/// Arc length between `a` and `b` (Gauss-Legendre; exact enough on one table cell).
|
||||
fn arc(p: &[[f64; 3]; 4], a: f64, b: f64) -> f64 {
|
||||
let (m, h) = ((a + b) * 0.5, (b - a) * 0.5);
|
||||
GAUSS.iter().map(|(x, w)| w * Self::tangent_len(p, m + h * x)).sum::<f64>() * h
|
||||
}
|
||||
fn length(&self) -> f64 {
|
||||
*self.table.last().unwrap_or(&0.0)
|
||||
}
|
||||
/// The point at arc length `s`. The table cell's linear guess is refined with Newton steps
|
||||
/// on the true arc length, so the position (and the speed graph, its derivative) is smooth
|
||||
/// along curved paths instead of piecewise linear per cell (#456).
|
||||
fn at_length(&self, s: f64) -> [f64; 3] {
|
||||
let len = self.length();
|
||||
if len <= 0.0 {
|
||||
@@ -260,9 +278,21 @@ impl SpatialSeg {
|
||||
}
|
||||
let s = s.clamp(0.0, len);
|
||||
let i = self.table.partition_point(|&v| v < s).clamp(1, ARC_N);
|
||||
let (a, b) = (self.table[i - 1], self.table[i]);
|
||||
let f = if b > a { (s - a) / (b - a) } else { 0.0 };
|
||||
Self::point(&self.p, (i as f64 - 1.0 + f) / ARC_N as f64)
|
||||
let (Some(&a), Some(&b)) = (self.table.get(i - 1), self.table.get(i)) else { return self.p[0] };
|
||||
let (u0, u1) = ((i - 1) as f64 / ARC_N as f64, i as f64 / ARC_N as f64);
|
||||
let mut u = u0 + if b > a { (s - a) / (b - a) } else { 0.0 } * (u1 - u0);
|
||||
for _ in 0..4 {
|
||||
let d = Self::tangent_len(&self.p, u);
|
||||
if d <= 1e-12 {
|
||||
break;
|
||||
}
|
||||
let e = a + Self::arc(&self.p, u0, u) - s;
|
||||
u = (u - e / d).clamp(u0, u1);
|
||||
if e.abs() <= 1e-12 * len.max(1.0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Self::point(&self.p, u)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,6 +794,24 @@ mod tests {
|
||||
assert!((p[0] - 150.0).abs() < 0.5 && (p[1] - 200.0).abs() < 0.5, "{p:?}");
|
||||
}
|
||||
|
||||
/// #456: along a curved spatial path with linear (constant-speed) keys the speed graph is
|
||||
/// flat, not a sawtooth from a piecewise-linear arc-length table.
|
||||
#[test]
|
||||
fn spatial_curved_path_has_smooth_constant_speed() {
|
||||
let mut a = Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0]));
|
||||
let mut b = Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0]));
|
||||
a.spatial_auto = false;
|
||||
b.spatial_auto = false;
|
||||
a.spatial_out = [50.0, -300.0, 0.0];
|
||||
b.spatial_in = [80.0, 250.0, 0.0];
|
||||
let keys = vec![a, b];
|
||||
let len = spatial_segment_length(&keys, 0);
|
||||
let speeds: Vec<f64> = (1..400).map(|i| speed(&keys, s(i as f64 * 0.005), true)).collect();
|
||||
for v in &speeds {
|
||||
assert!((v - len / 2.0).abs() < 1e-3 * len, "speed {v} vs {}", len / 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_auto_bezier_passes_through_keys() {
|
||||
let keys = vec![
|
||||
|
||||
@@ -161,9 +161,20 @@ fn picture_layer<'a>(aux: &AuxChannels, layers: &[&'a str]) -> Option<&'a str> {
|
||||
/// that is not a data pass (never Cryptomatte, depth or normals, #412), else the first channel
|
||||
/// as grey. Linear, straight RGBA as the file stores it. `None` when it is not a readable EXR.
|
||||
pub fn layered_image(bytes: &[u8]) -> Option<image::DynamicImage> {
|
||||
let aux = read_exr_channels(bytes)?;
|
||||
picture_of(&read_exr_channels(bytes)?)
|
||||
}
|
||||
|
||||
/// [`layered_image`] of the EXR at `path` from the channel cache, so the picture and EXtractoR /
|
||||
/// Cryptomatte share one decode of the file (#474).
|
||||
pub(crate) fn cached_layered_image(path: &str, bytes: Arc<[u8]>) -> Option<image::DynamicImage> {
|
||||
let aux = cached(path, || Some(bytes))?;
|
||||
picture_of(&aux)
|
||||
}
|
||||
|
||||
/// The picture of decoded channels (see [`layered_image`]).
|
||||
fn picture_of(aux: &AuxChannels) -> Option<image::DynamicImage> {
|
||||
let layers = aux.layers();
|
||||
let colour = picture_layer(&aux, &layers)?;
|
||||
let colour = picture_layer(aux, &layers)?;
|
||||
let names = aux.layer_rgba(colour);
|
||||
// A layer without R, G and B channels shows its first channel as grey.
|
||||
let first = aux.names().into_iter().find(|n| split_channel(n).0 == colour).unwrap_or_default();
|
||||
@@ -254,6 +265,27 @@ mod tests {
|
||||
/// #295: a multi-layer EXR whose colour is only in named layers (`diffuse.R`, as Blender and
|
||||
/// Nuke write them) failed to import ("does not contain non-deep rgb channels"); it now
|
||||
/// shows its colour layer and keeps every channel for EXtractoR.
|
||||
/// #474: the picture and EXtractoR / Cryptomatte share one decode of a multi-layer file:
|
||||
/// after the frame is decoded, the aux channels come from the cache without reading the file.
|
||||
#[test]
|
||||
fn picture_and_aux_channels_share_one_decode() {
|
||||
use exr::prelude::*;
|
||||
let (w, h) = (2usize, 2usize);
|
||||
let ch = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h]));
|
||||
let channels = AnyChannels::sort(vec![ch("Image.R", 0.5), ch("Image.G", 0.5), ch("Image.B", 0.5), ch("GlossDir.R", 0.25)].into());
|
||||
let image = Image::from_layer(Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels));
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
image.write().to_buffered(&mut bytes).unwrap();
|
||||
let b: Arc<[u8]> = bytes.into_inner().into();
|
||||
let path = "/474-shared-decode.exr";
|
||||
let pool = crate::MediaPool::new();
|
||||
pool.add_bytes(path, b.clone());
|
||||
let f = crate::probe_bytes(path, b).unwrap();
|
||||
pool.frame_at(&f, effectcraft_time::Tick::ZERO).unwrap();
|
||||
let aux = cached(path, || panic!("the frame's decode is reused, the file is not read again")).unwrap();
|
||||
assert_eq!(aux.get("GlossDir.R").and_then(|p| p.first()).copied(), Some(0.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layered_exr_without_an_unnamed_rgb_layer_imports() {
|
||||
use exr::prelude::*;
|
||||
|
||||
@@ -741,7 +741,7 @@ impl Inner {
|
||||
let img = match image::load_from_memory(&bytes) {
|
||||
Ok(img) => img,
|
||||
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
|
||||
Err(e) => crate::exr_channels::layered_image(&bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?,
|
||||
Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?,
|
||||
};
|
||||
Ok(dynamic_to_image(&img, op))
|
||||
}
|
||||
|
||||
+130
-29
@@ -168,15 +168,66 @@ pub enum SplitSize {
|
||||
FixedB(f32),
|
||||
}
|
||||
|
||||
/// One panel in a [`DockNode::Stack`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
/// One frame in a [`DockNode::Stack`]: a header with one tab per panel (a panel dropped onto a
|
||||
/// stacked panel joins its frame as a tab, as in After Effects).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(from = "StackEntryRepr")]
|
||||
pub struct StackEntry {
|
||||
pub panel: PanelKind,
|
||||
pub panels: Vec<PanelKind>,
|
||||
/// The shown tab.
|
||||
pub active: usize,
|
||||
pub open: bool,
|
||||
/// Content height when open (set by dragging the gaps); `None` = share the remaining height.
|
||||
pub height: Option<f32>,
|
||||
}
|
||||
|
||||
/// A saved [`StackEntry`]: workspaces saved before stacked frames held tabs name one `panel`.
|
||||
#[derive(Deserialize)]
|
||||
struct StackEntryRepr {
|
||||
#[serde(default)]
|
||||
panel: Option<PanelKind>,
|
||||
#[serde(default)]
|
||||
panels: Vec<PanelKind>,
|
||||
#[serde(default)]
|
||||
active: usize,
|
||||
open: bool,
|
||||
#[serde(default)]
|
||||
height: Option<f32>,
|
||||
}
|
||||
|
||||
impl From<StackEntryRepr> for StackEntry {
|
||||
fn from(r: StackEntryRepr) -> StackEntry {
|
||||
let mut panels = r.panels;
|
||||
if let Some(p) = r.panel
|
||||
&& !panels.contains(&p)
|
||||
{
|
||||
panels.insert(0, p);
|
||||
}
|
||||
let active = r.active.min(panels.len().saturating_sub(1));
|
||||
StackEntry { panels, active, open: r.open, height: r.height }
|
||||
}
|
||||
}
|
||||
|
||||
impl StackEntry {
|
||||
/// A frame showing just `panel`.
|
||||
pub fn new(panel: PanelKind, open: bool, height: Option<f32>) -> StackEntry {
|
||||
StackEntry { panels: vec![panel], active: 0, open, height }
|
||||
}
|
||||
pub fn contains(&self, p: PanelKind) -> bool {
|
||||
self.panels.contains(&p)
|
||||
}
|
||||
/// The frame's shown panel.
|
||||
pub fn shown(&self) -> Option<PanelKind> {
|
||||
self.panels.get(self.active).or(self.panels.first()).copied()
|
||||
}
|
||||
/// Show `p` (one of its tabs).
|
||||
fn show(&mut self, p: PanelKind) {
|
||||
if let Some(i) = self.panels.iter().position(|x| *x == p) {
|
||||
self.active = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DockNode {
|
||||
/// `vertical`: children stacked top/bottom; else side by side.
|
||||
@@ -202,7 +253,7 @@ fn tabs(p: &[PanelKind], active: usize) -> DockNode {
|
||||
DockNode::Tabs { panels: p.to_vec(), active }
|
||||
}
|
||||
fn stack(e: &[(PanelKind, bool, Option<f32>)]) -> DockNode {
|
||||
DockNode::Stack { entries: e.iter().map(|&(panel, open, height)| StackEntry { panel, open, height }).collect() }
|
||||
DockNode::Stack { entries: e.iter().map(|&(panel, open, height)| StackEntry::new(panel, open, height)).collect() }
|
||||
}
|
||||
fn hsplit(size: SplitSize, a: DockNode, b: DockNode) -> DockNode {
|
||||
DockNode::Split { vertical: false, size, a: Box::new(a), b: Box::new(b) }
|
||||
@@ -401,7 +452,7 @@ impl DockNode {
|
||||
b.panels(out);
|
||||
}
|
||||
DockNode::Tabs { panels, .. } => out.extend(panels.iter().copied()),
|
||||
DockNode::Stack { entries } => out.extend(entries.iter().map(|e| e.panel)),
|
||||
DockNode::Stack { entries } => out.extend(entries.iter().flat_map(|e| e.panels.iter().copied())),
|
||||
}
|
||||
}
|
||||
pub fn contains(&self, p: PanelKind) -> bool {
|
||||
@@ -421,8 +472,9 @@ impl DockNode {
|
||||
false
|
||||
}
|
||||
}
|
||||
DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.panel == p) {
|
||||
DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.contains(p)) {
|
||||
Some(e) => {
|
||||
e.show(p);
|
||||
e.open = true;
|
||||
true
|
||||
}
|
||||
@@ -430,13 +482,15 @@ impl DockNode {
|
||||
},
|
||||
}
|
||||
}
|
||||
/// Expand or collapse a stacked panel. Returns false if `p` is not in a stack.
|
||||
/// Expand or collapse a stacked panel's frame (showing `p`). Returns false if `p` is not in
|
||||
/// a stack.
|
||||
pub fn toggle_stacked(&mut self, p: PanelKind) -> bool {
|
||||
match self {
|
||||
DockNode::Split { a, b, .. } => a.toggle_stacked(p) || b.toggle_stacked(p),
|
||||
DockNode::Tabs { .. } => false,
|
||||
DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.panel == p) {
|
||||
DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.contains(p)) {
|
||||
Some(e) => {
|
||||
e.show(p);
|
||||
e.open = !e.open;
|
||||
true
|
||||
}
|
||||
@@ -448,7 +502,7 @@ impl DockNode {
|
||||
match self {
|
||||
DockNode::Split { a, b, .. } => a.is_visible(p) || b.is_visible(p),
|
||||
DockNode::Tabs { panels, active } => panels.get(*active) == Some(&p),
|
||||
DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p && e.open),
|
||||
DockNode::Stack { entries } => entries.iter().any(|e| e.open && e.shown() == Some(p)),
|
||||
}
|
||||
}
|
||||
/// Close a panel (remove its tab; empty groups collapse their split).
|
||||
@@ -471,7 +525,12 @@ impl DockNode {
|
||||
panels.retain(|x| *x != p);
|
||||
*active = shown.and_then(|shown| panels.iter().position(|x| *x == shown)).unwrap_or((*active).min(panels.len().saturating_sub(1)));
|
||||
} else if let DockNode::Stack { entries } = self {
|
||||
entries.retain(|e| e.panel != p);
|
||||
for e in entries.iter_mut() {
|
||||
let shown = e.shown();
|
||||
e.panels.retain(|x| *x != p);
|
||||
e.active = shown.and_then(|shown| e.panels.iter().position(|x| *x == shown)).unwrap_or(e.active.min(e.panels.len().saturating_sub(1)));
|
||||
}
|
||||
entries.retain(|e| !e.panels.is_empty());
|
||||
}
|
||||
}
|
||||
/// Add a panel as a tab next to `near` (or into the first group).
|
||||
@@ -492,9 +551,9 @@ impl DockNode {
|
||||
false
|
||||
}
|
||||
}
|
||||
DockNode::Stack { entries } => match entries.iter().position(|e| e.panel == near) {
|
||||
DockNode::Stack { entries } => match entries.iter().position(|e| e.contains(near)) {
|
||||
Some(i) => {
|
||||
entries.insert(i + 1, StackEntry { panel: p, open: true, height: None });
|
||||
entries.insert(i + 1, StackEntry::new(p, true, None));
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
@@ -510,7 +569,7 @@ impl DockNode {
|
||||
true
|
||||
}
|
||||
DockNode::Stack { entries } if !entries.is_empty() => {
|
||||
entries.push(StackEntry { panel: p, open: true, height: None });
|
||||
entries.push(StackEntry::new(p, true, None));
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
@@ -586,8 +645,10 @@ pub struct Floating {
|
||||
|
||||
impl DockNode {
|
||||
/// Insert panel `p` relative to the group that holds `anchor`: as a tab next to it
|
||||
/// (Center) or in a new group split off to one side. `p` is removed from its old place
|
||||
/// first. Returns false (tree unchanged) if `anchor` isn't docked or is `p` itself alone.
|
||||
/// (Center) or in a new group split off to one side. In a stack, the group is `anchor`'s
|
||||
/// frame: Center adds a tab to that frame and Top / Bottom add a frame above or below it
|
||||
/// (#302); Left / Right split the column. `p` is removed from its old place first. Returns
|
||||
/// false (tree unchanged) if `anchor` isn't docked or is `p` itself alone.
|
||||
pub fn dock_panel(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool {
|
||||
if p == anchor || !self.contains(anchor) {
|
||||
return false;
|
||||
@@ -598,7 +659,7 @@ impl DockNode {
|
||||
let here = match n {
|
||||
DockNode::Split { a, b, .. } => return rec(a, p, anchor, zone) || rec(b, p, anchor, zone),
|
||||
DockNode::Tabs { panels, .. } => panels.contains(&anchor),
|
||||
DockNode::Stack { entries } => entries.iter().any(|e| e.panel == anchor),
|
||||
DockNode::Stack { entries } => entries.iter().any(|e| e.contains(anchor)),
|
||||
};
|
||||
if !here {
|
||||
return false;
|
||||
@@ -610,8 +671,16 @@ impl DockNode {
|
||||
*active = i;
|
||||
}
|
||||
(Zone::Center, DockNode::Stack { entries }) => {
|
||||
let i = entries.iter().position(|e| e.panel == anchor).map_or(entries.len(), |i| i + 1);
|
||||
entries.insert(i, StackEntry { panel: p, open: true, height: None });
|
||||
let Some(e) = entries.iter_mut().find(|e| e.contains(anchor)) else { return false };
|
||||
let i = e.panels.iter().position(|x| *x == anchor).map_or(e.panels.len(), |i| i + 1);
|
||||
e.panels.insert(i, p);
|
||||
e.active = i;
|
||||
e.open = true;
|
||||
}
|
||||
(Zone::Top | Zone::Bottom, DockNode::Stack { entries }) => {
|
||||
let Some(i) = entries.iter().position(|e| e.contains(anchor)) else { return false };
|
||||
let at = if zone == Zone::Top { i } else { i + 1 };
|
||||
entries.insert(at, StackEntry::new(p, true, None));
|
||||
}
|
||||
(z, node) => {
|
||||
let new = tabs(&[p], 0);
|
||||
@@ -648,9 +717,13 @@ impl DockNode {
|
||||
*active = i;
|
||||
true
|
||||
}
|
||||
DockNode::Stack { entries } if entries.iter().any(|e| e.panel == anchor) => {
|
||||
let i = before.and_then(|b| entries.iter().position(|e| e.panel == b)).unwrap_or(entries.len());
|
||||
entries.insert(i, StackEntry { panel: p, open: true, height: None });
|
||||
DockNode::Stack { entries } => {
|
||||
// A tab of the anchor's frame.
|
||||
let Some(e) = entries.iter_mut().find(|e| e.contains(anchor)) else { return false };
|
||||
let i = before.and_then(|b| e.panels.iter().position(|x| *x == b)).unwrap_or(e.panels.len());
|
||||
e.panels.insert(i, p);
|
||||
e.active = i;
|
||||
e.open = true;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
@@ -670,7 +743,7 @@ impl DockNode {
|
||||
match n {
|
||||
DockNode::Split { a, b, .. } => rec(a, p, &format!("{path}a")).or_else(|| rec(b, p, &format!("{path}b"))),
|
||||
DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| path.to_string()),
|
||||
DockNode::Stack { entries } => entries.iter().position(|e| e.panel == p).map(|i| format!("{path}s{i}")),
|
||||
DockNode::Stack { entries } => entries.iter().position(|e| e.contains(p)).map(|i| format!("{path}s{i}")),
|
||||
}
|
||||
}
|
||||
rec(self, p, "")
|
||||
@@ -886,7 +959,7 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
|
||||
drag = Some((above, below, d));
|
||||
}
|
||||
}
|
||||
out.push(Group { path: group, rect: r, content, panels: vec![e.panel], active: 0, stacked: Some(e.open) });
|
||||
out.push(Group { path: group, rect: r, content, panels: e.panels.clone(), active: e.active, stacked: Some(e.open) });
|
||||
y = r.max.y + g;
|
||||
}
|
||||
if let Some((above, below, d)) = drag {
|
||||
@@ -1184,7 +1257,9 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
if g.stacked.is_some() {
|
||||
// A stacked frame's shown tab expands / collapses it; another tab shows that
|
||||
// panel (expanding the frame).
|
||||
if g.stacked.is_some() && (k == ai || entries.len() == 1) {
|
||||
out.actions.push(DockAction::ToggleStacked(*p));
|
||||
} else {
|
||||
out.actions.push(activate(e));
|
||||
@@ -1197,9 +1272,9 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
|
||||
x += w + 8.0;
|
||||
}
|
||||
// A stacked panel's whole header bar expands/collapses it, not just its name (as in After
|
||||
// Effects). A stack group holds one panel, so the rest of the strip is unambiguous.
|
||||
// Effects): the frame with its shown tab.
|
||||
if g.stacked.is_some()
|
||||
&& let Some(e) = entries.first()
|
||||
&& let Some(e) = entries.get(ai).or(entries.first())
|
||||
{
|
||||
let rest = Rect::from_min_max(pos2((x - 8.0).max(strip.min.x), strip.min.y), strip.max);
|
||||
if rest.width() > 0.0 {
|
||||
@@ -1269,7 +1344,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn entry(panel: PanelKind, open: bool, height: Option<f32>) -> StackEntry {
|
||||
StackEntry { panel, open, height }
|
||||
StackEntry::new(panel, open, height)
|
||||
}
|
||||
|
||||
fn close_to(a: &[f32], b: &[f32]) -> bool {
|
||||
@@ -1471,16 +1546,42 @@ mod tests {
|
||||
assert!(!d.dock_panel(Audio, Audio, Zone::Left));
|
||||
assert!(!d.dock_panel(Audio, Tracker, Zone::Left));
|
||||
assert_eq!(d, before);
|
||||
// Into a stack.
|
||||
// Into a stack: a tab of the anchor's frame (#302).
|
||||
let mut s = workspace("Default");
|
||||
let props = s.path_of(Properties).unwrap();
|
||||
assert!(s.dock_panel(Tracker, Properties, Zone::Center));
|
||||
assert!(s.path_of(Tracker).unwrap().contains('s'));
|
||||
assert_eq!(s.path_of(Tracker), Some(props.clone()));
|
||||
assert!(s.is_visible(Tracker) && !s.is_visible(Properties));
|
||||
assert!(s.activate(Properties) && s.is_visible(Properties));
|
||||
// Top / Bottom: a frame of its own above / below the anchor's, not a split column.
|
||||
assert!(s.dock_panel(Info, Properties, Zone::Bottom));
|
||||
let below = s.path_of(Info).unwrap();
|
||||
assert_ne!(below, props);
|
||||
assert_eq!(s.path_of(Properties), Some(props.clone()));
|
||||
assert!(s.dock_panel(Info, Properties, Zone::Top));
|
||||
assert_eq!(s.path_of(Info), Some(props.clone()), "above Properties: its frame's index");
|
||||
// Closing a tab keeps the frame; closing the frame's last tab removes it.
|
||||
s.close(Tracker);
|
||||
assert!(s.contains(Properties) && !s.contains(Tracker));
|
||||
// A tab dragged onto a stacked frame's tab strip joins that frame.
|
||||
let mut t = workspace("Default");
|
||||
assert!(t.insert_tab(Info, Preview, None));
|
||||
assert_eq!(t.path_of(Info), t.path_of(Preview));
|
||||
// Moving the only panel of a group collapses its split.
|
||||
let mut m = hsplit(SplitSize::Ratio(0.5), tabs(&[Project], 0), tabs(&[Composition], 0));
|
||||
assert!(m.dock_panel(Project, Composition, Zone::Center));
|
||||
assert_eq!(m, tabs(&[Composition, Project], 1));
|
||||
}
|
||||
|
||||
/// #302: workspaces saved when each stacked frame held one `panel` still load.
|
||||
#[test]
|
||||
fn old_stack_entries_load() {
|
||||
let e: StackEntry = serde_json::from_str(r#"{"panel":"Preview","open":true,"height":46.0}"#).unwrap();
|
||||
assert_eq!(e, StackEntry::new(PanelKind::Preview, true, Some(46.0)));
|
||||
let s = serde_json::to_string(&workspace("Default")).unwrap();
|
||||
assert_eq!(serde_json::from_str::<DockNode>(&s).unwrap(), workspace("Default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_layout_round_trips() {
|
||||
use PanelKind::*;
|
||||
|
||||
@@ -405,6 +405,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Select a command to change its shortcut.", "ショートカットを変更するコマンドを選択してください。"),
|
||||
("Select a layer to see its effects", "レイヤーを選択するとエフェクトが表示されます"),
|
||||
("Select a layer to see its properties", "レイヤーを選択するとプロパティが表示されます"),
|
||||
("({}) Selected Objects", "({}) 個の選択されたオブジェクト"),
|
||||
(
|
||||
"Select a layer with a mask or shape path (or the path property), or Puppet pins.",
|
||||
"マスクまたはシェイプパスのあるレイヤー(またはパスプロパティ)か、パペットピンを選択してください。",
|
||||
@@ -840,6 +841,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Select a command to change its shortcut.", "选择一条命令以更改其快捷键。"),
|
||||
("Select a layer to see its effects", "选择图层以查看其效果"),
|
||||
("Select a layer to see its properties", "选择图层以查看其属性"),
|
||||
("({}) Selected Objects", "({}) 个选定对象"),
|
||||
(
|
||||
"Select a layer with a mask or shape path (or the path property), or Puppet pins.",
|
||||
"请选择带蒙版或形状路径(或路径属性)的图层,或带有人偶定位点的图层。",
|
||||
@@ -1364,6 +1366,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Select a command to change its shortcut.", "選取一條命令以更改其快速鍵。"),
|
||||
("Select a layer to see its effects", "選取圖層以查看其效果"),
|
||||
("Select a layer to see its properties", "選取圖層以查看其屬性"),
|
||||
("({}) Selected Objects", "({}) 個選取的物件"),
|
||||
(
|
||||
"Select a layer with a mask or shape path (or the path property), or Puppet pins.",
|
||||
"請選取帶遮色片或形狀路徑(或路徑屬性)的圖層,或帶有人偶釘選點的圖層。",
|
||||
@@ -1882,6 +1885,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("Select a command to change its shortcut.", "Виберіть команду, щоб змінити її клавіатурне скорочення."),
|
||||
("Select a layer to see its effects", "Виділіть шар, щоб переглянути його ефекти"),
|
||||
("Select a layer to see its properties", "Виділіть шар, щоб переглянути його властивості"),
|
||||
("({}) Selected Objects", "Вибрано об’єктів: ({})"),
|
||||
(
|
||||
"Select a layer with a mask or shape path (or the path property), or Puppet pins.",
|
||||
"Виділіть шар із маскою або контуром фігури (або властивість контуру) чи опорні точки деформації.",
|
||||
|
||||
@@ -180,7 +180,7 @@ fn key_navigator(
|
||||
match what {
|
||||
"prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))),
|
||||
"next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))),
|
||||
_ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid}))),
|
||||
_ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,7 +241,7 @@ fn prop_row(
|
||||
);
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.stopwatch"), swr, &prop.name);
|
||||
if resp.clicked() {
|
||||
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid})));
|
||||
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true})));
|
||||
}
|
||||
}
|
||||
if prop.is_animated() {
|
||||
@@ -273,8 +273,11 @@ fn prop_row(
|
||||
ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click());
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name);
|
||||
let merge = format!("ec-{uid}");
|
||||
let set =
|
||||
|actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge})));
|
||||
// With several layers selected the edit applies to the same effect property of each (#491).
|
||||
let scrub = ui.ctx().dragged_id().is_some();
|
||||
let set = |actions: &mut Actions, v: serde_json::Value| {
|
||||
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub})))
|
||||
};
|
||||
let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| {
|
||||
let open = app.ui.fx_slider_open.contains(&uid);
|
||||
let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0));
|
||||
|
||||
@@ -72,6 +72,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let mut y = top;
|
||||
let mut actions: Actions = vec![];
|
||||
|
||||
// Several layers selected: "(3) Selected Objects", as in After Effects. The rows show the
|
||||
// first layer; the stopwatch, keyframe button and value edits apply to all of them (#355).
|
||||
let selected = app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some()).count();
|
||||
if selected > 1 {
|
||||
let n = selected.to_string();
|
||||
let label = crate::i18n::tr_args("({}) Selected Objects", &[&n]);
|
||||
p.text(pos2(x0, y + ROW_H / 2.0), Align2::LEFT_CENTER, &label, Tokens::semibold(12.0), t.text);
|
||||
app.auto.add("properties.selectedObjects", Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)), &label);
|
||||
y += ROW_H + 4.0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Layer Transform
|
||||
if let Some(tr) = layer.transform().cloned() {
|
||||
section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform");
|
||||
@@ -218,7 +229,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
match what {
|
||||
"prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))),
|
||||
"next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))),
|
||||
_ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid}))),
|
||||
_ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid, "selected": true}))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,13 +239,18 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
icons::paint(p, swr, Icon::Stopwatch, if resp.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add(&format!("{base}.stopwatch"), swr, &pr.name);
|
||||
if resp.clicked() {
|
||||
actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid})));
|
||||
actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid, "selected": true})));
|
||||
}
|
||||
}
|
||||
p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text);
|
||||
let vx = r.min.x + (r.width() * 0.5).max(130.0);
|
||||
let merge = format!("props-{uid}");
|
||||
let set = |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge})));
|
||||
// With several layers selected the edit applies to all: a scrub by the same change, a typed
|
||||
// value as it is.
|
||||
let scrub = ui.ctx().dragged_id().is_some();
|
||||
let set = |actions: &mut Actions, v: serde_json::Value| {
|
||||
actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub})))
|
||||
};
|
||||
let value = ectx.value(layer, pr);
|
||||
let is_scale = pr.name == "Scale";
|
||||
let pct = is_scale || pr.name == "Opacity";
|
||||
|
||||
@@ -2403,7 +2403,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
|
||||
}
|
||||
if ui.interact(mid, egui::Id::new(("kmid", uid)), Sense::click()).clicked() {
|
||||
actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid})));
|
||||
actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true})));
|
||||
}
|
||||
app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe");
|
||||
}
|
||||
@@ -2426,10 +2426,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid})));
|
||||
}
|
||||
} else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) {
|
||||
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge})));
|
||||
actions
|
||||
.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge, "selected": true})));
|
||||
} else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
|
||||
// Keyboard or accessibility activation.
|
||||
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid})));
|
||||
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true})));
|
||||
}
|
||||
}
|
||||
let name_x = indent + 12.0;
|
||||
@@ -3510,8 +3511,11 @@ fn value_editor(
|
||||
let uid = prop.uid;
|
||||
let mut dims = vec![];
|
||||
let merge = format!("scrub-{uid}");
|
||||
// With several layers selected the edit applies to all of them (#491): a scrub by the same
|
||||
// change, a typed value as it is.
|
||||
let scrub = ui.ctx().dragged_id().is_some();
|
||||
let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| {
|
||||
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge})))
|
||||
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub})))
|
||||
};
|
||||
let is_3d = layer.is_3d();
|
||||
let mut x = at.x;
|
||||
|
||||
@@ -84,12 +84,17 @@ enum Gesture {
|
||||
/// Layer pixels → comp as the drag began, and its inverse.
|
||||
l2c: Mat3,
|
||||
inv: Mat3,
|
||||
/// The other selected layers and their scales as the drag began: they scale by the same
|
||||
/// ratio about their own anchor points, as in After Effects (#491).
|
||||
others: Vec<(LayerId, [f64; 3])>,
|
||||
},
|
||||
Rotate {
|
||||
layer: LayerId,
|
||||
center: Pos2,
|
||||
start_angle: f64,
|
||||
start_rot: f64,
|
||||
/// The other selected layers and their rotations: they turn by the same angle.
|
||||
others: Vec<(LayerId, f64)>,
|
||||
},
|
||||
Anchor {
|
||||
layer: LayerId,
|
||||
@@ -258,17 +263,97 @@ pub(crate) fn fx2c(ctx: &EvalCtx, layer: &Layer) -> Mat3 {
|
||||
from_effect_space(ctx, layer, l2c(ctx, layer).0)
|
||||
}
|
||||
|
||||
/// Layer → comp matrix and its bounds quad (in comp pixels).
|
||||
/// The selected, unlocked layers of `comp` other than `layer` (when `layer` is one of them): a
|
||||
/// handle or Rotation tool drag on one changes them all.
|
||||
fn selected_others<'a>(app: &EffectcraftApp, comp: &'a Comp, layer: LayerId) -> Vec<&'a Layer> {
|
||||
let sel = &app.session.state.selected_layers;
|
||||
if !sel.contains(&layer) {
|
||||
return vec![];
|
||||
}
|
||||
sel.iter().filter(|id| **id != layer).filter_map(|id| comp.layer(*id)).filter(|l| !l.switches.locked && l.transform().is_some()).collect()
|
||||
}
|
||||
|
||||
/// The least homogeneous depth (`w`: the distance in front of a perspective camera in pixels, 1
|
||||
/// for 2D layers and orthographic views) a viewer outline is drawn at: points at or behind the
|
||||
/// camera would flip through infinity and draw long lines across the viewer (#572).
|
||||
const NEAR_W: f64 = 0.5;
|
||||
|
||||
/// `p` through the homography `m` before the perspective divide: (x, y, w).
|
||||
fn homogeneous(m: &Mat3, p: [f64; 2]) -> [f64; 3] {
|
||||
let r = |i: usize| m.0.get(i).map_or(0.0, |row| row[0] * p[0] + row[1] * p[1] + row[2]);
|
||||
[r(0), r(1), r(2)]
|
||||
}
|
||||
|
||||
/// The closed polygon `poly` (layer pixels) through `m`, clipped to the part in front of the
|
||||
/// camera's near plane (comp pixels; empty when none of it is).
|
||||
fn project_clipped(m: &Mat3, poly: &[[f64; 2]]) -> Vec<[f64; 2]> {
|
||||
let h: Vec<[f64; 3]> = poly.iter().map(|p| homogeneous(m, *p)).collect();
|
||||
let mut out = vec![];
|
||||
for (i, a) in h.iter().enumerate() {
|
||||
let Some(b) = h.get((i + 1) % h.len()) else { continue };
|
||||
let (ina, inb) = (a[2] >= NEAR_W, b[2] >= NEAR_W);
|
||||
if ina {
|
||||
out.push(*a);
|
||||
}
|
||||
if ina != inb {
|
||||
let t = (NEAR_W - a[2]) / (b[2] - a[2]);
|
||||
out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, NEAR_W]);
|
||||
}
|
||||
}
|
||||
out.iter().map(|q| [q[0] / q[2], q[1] / q[2]]).filter(|q| q[0].is_finite() && q[1].is_finite()).collect()
|
||||
}
|
||||
|
||||
/// The segment a → b (world space) through the projection `pc`, clipped at the near plane.
|
||||
fn project_segment(pc: &effectcraft_engine::geom::Mat4, a: effectcraft_engine::geom::Vec3, b: effectcraft_engine::geom::Vec3) -> Option<[[f64; 2]; 2]> {
|
||||
let h = |p: effectcraft_engine::geom::Vec3| {
|
||||
let r = |i: usize| pc.0.get(i).map_or(0.0, |row| row[0] * p.x + row[1] * p.y + row[2] * p.z + row[3]);
|
||||
[r(0), r(1), r(3)]
|
||||
};
|
||||
let (mut ha, mut hb) = (h(a), h(b));
|
||||
if ha[2] < NEAR_W && hb[2] < NEAR_W {
|
||||
return None;
|
||||
}
|
||||
let cut = |p: [f64; 3], q: [f64; 3]| {
|
||||
let t = (NEAR_W - p[2]) / (q[2] - p[2]);
|
||||
[p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, NEAR_W]
|
||||
};
|
||||
if ha[2] < NEAR_W {
|
||||
ha = cut(ha, hb);
|
||||
} else if hb[2] < NEAR_W {
|
||||
hb = cut(hb, ha);
|
||||
}
|
||||
let d = |q: [f64; 3]| [q[0] / q[2], q[1] / q[2]];
|
||||
let s = [d(ha), d(hb)];
|
||||
s.iter().flatten().all(|v| v.is_finite()).then_some(s)
|
||||
}
|
||||
|
||||
/// The content box corners (layer pixels) of `layer`.
|
||||
fn box_corners(b: [f64; 4]) -> [[f64; 2]; 4] {
|
||||
[[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]
|
||||
}
|
||||
|
||||
/// Layer → comp matrix and its bounds quad (in comp pixels). None for a 3D layer reaching to or
|
||||
/// behind the camera (its quad would flip; [`layer_outline`] draws the part in front).
|
||||
pub(crate) fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64; 4])> {
|
||||
let b = effectcraft_engine::render::content_bounds(ctx, layer)?;
|
||||
let (m, _) = l2c(ctx, layer);
|
||||
let pts = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| {
|
||||
let corners = box_corners(b);
|
||||
if corners.iter().any(|p| homogeneous(&m, *p)[2] < NEAR_W) {
|
||||
return None;
|
||||
}
|
||||
let pts = corners.map(|p| {
|
||||
let q = m.apply(gv2(p[0], p[1]));
|
||||
[q.x, q.y]
|
||||
});
|
||||
Some((m, pts, b))
|
||||
}
|
||||
|
||||
/// The outline of a layer's content box in comp pixels, clipped at the camera's near plane.
|
||||
pub(crate) fn layer_outline(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
|
||||
let Some(b) = effectcraft_engine::render::content_bounds(ctx, layer) else { return vec![] };
|
||||
project_clipped(&l2c(ctx, layer).0, &box_corners(b))
|
||||
}
|
||||
|
||||
/// Which axes a bounding-box handle scales: corners (0–3) both; the top and bottom edges (4, 6)
|
||||
/// y; the right and left edges (5, 7) x.
|
||||
fn handle_axes(handle: usize) -> [bool; 2] {
|
||||
@@ -836,6 +921,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
let Some((m, q, _)) = layer_quad(&ectx, l) else {
|
||||
// A 3D layer reaching past the camera: the part of its box in front of it.
|
||||
let outline = layer_outline(&ectx, l);
|
||||
if outline.len() > 2 {
|
||||
painter.add(egui::Shape::closed_line(outline.iter().map(|p| map.to_screen(*p)).collect(), Stroke::new(1.0, col)));
|
||||
continue;
|
||||
}
|
||||
// Cameras, lights, empty layers: just the anchor.
|
||||
if let Some(tr) = l.transform() {
|
||||
let pos = ectx.v3(l, tr, "position", [0.0; 3]);
|
||||
@@ -868,15 +959,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let w = ectx.world_matrix(l);
|
||||
let pc = cam_state(&ectx).projection(cw as f64, ch as f64) * w;
|
||||
let a3 = effectcraft_engine::geom::vec3(a[0], a[1], a[2]);
|
||||
let o = pc.apply(a3);
|
||||
let len = 60.0 / zoom as f64;
|
||||
for (axis, col) in [
|
||||
(effectcraft_engine::geom::vec3(len, 0.0, 0.0), Color32::from_rgb(0xe0, 0x50, 0x50)),
|
||||
(effectcraft_engine::geom::vec3(0.0, len, 0.0), Color32::from_rgb(0x60, 0xd0, 0x60)),
|
||||
(effectcraft_engine::geom::vec3(0.0, 0.0, len), Color32::from_rgb(0x50, 0x8c, 0xf0)),
|
||||
] {
|
||||
let e = pc.apply(a3 + axis);
|
||||
painter.arrow(map.to_screen([o.x, o.y]), map.to_screen([e.x, e.y]) - map.to_screen([o.x, o.y]), Stroke::new(2.0, col));
|
||||
// (clipped at the camera's near plane, #572)
|
||||
if let Some([o, e]) = project_segment(&pc, a3, a3 + axis) {
|
||||
painter.arrow(map.to_screen(o), map.to_screen(e) - map.to_screen(o), Stroke::new(2.0, col));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -888,7 +980,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let has_3d = comp.layers.iter().any(|l| l.is_3d() && l.is_active_at(time));
|
||||
if has_3d && app.session.prefs.three_d.extended_viewer {
|
||||
for l in comp.layers.iter().filter(|l| l.is_3d() && l.is_active_at(time) && l.has_video()) {
|
||||
if let Some((_, q, _)) = layer_quad(&ectx, l) {
|
||||
let q = layer_outline(&ectx, l);
|
||||
if q.len() > 2 {
|
||||
let sq: Vec<Pos2> = q.iter().map(|p| map.to_screen(*p)).collect();
|
||||
let outside = sq.iter().any(|p| !comp_rect.contains(*p));
|
||||
if outside {
|
||||
@@ -1154,6 +1247,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay),
|
||||
Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| {
|
||||
let others = selected_others(app, &comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect();
|
||||
let layer = comp.layer(l).cloned();
|
||||
let center = layer
|
||||
.as_ref()
|
||||
@@ -1161,7 +1255,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
.map(|p| map.to_screen([p[0], p[1]]))
|
||||
.unwrap_or(pos);
|
||||
let rot = layer.as_ref().and_then(|l| l.transform().map(|tr| ectx.f(l, tr, "rotation", 0.0))).unwrap_or(0.0);
|
||||
Gesture::Rotate { layer: l, center, start_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(), start_rot: rot }
|
||||
Gesture::Rotate {
|
||||
layer: l,
|
||||
center,
|
||||
start_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(),
|
||||
start_rot: rot,
|
||||
others,
|
||||
}
|
||||
}),
|
||||
Tool::PanBehind => pick(app, &ectx, cpt, false).and_then(|l| {
|
||||
let layer = comp.layer(l)?;
|
||||
@@ -1271,6 +1371,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
axes: handle_axes(hi),
|
||||
l2c,
|
||||
inv,
|
||||
others: selected_others(app, &comp, lid)
|
||||
.into_iter()
|
||||
.filter_map(|o| Some((o.id, ectx.v3(o, o.transform()?, "scale", [100.0; 3]))))
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
} else if let Some(l) = pick(app, &ectx, cpt, mods.shift) {
|
||||
@@ -1339,23 +1443,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge}));
|
||||
}
|
||||
}
|
||||
Gesture::Scale { layer, start_scale, start_local, handle, anchor, axes, l2c, inv } => {
|
||||
Gesture::Scale { layer, start_scale, start_local, handle, anchor, axes, l2c, inv, others } => {
|
||||
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
let uniform = mods.shift && axes == [true, true];
|
||||
let f = drag_ratio(start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, uniform);
|
||||
// The handle snaps (to the comp's and other layers' edges, corners and centres),
|
||||
// so a layer scales exactly to them, e.g. to the comp's size.
|
||||
let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, &ectx, &map, &[layer], &[p], mods));
|
||||
let v = [start_scale[0] * f[0], start_scale[1] * f[1], start_scale[2]];
|
||||
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": v, "merge": merge}));
|
||||
for (lid, s0) in std::iter::once((layer, start_scale)).chain(others) {
|
||||
let v = [s0[0] * f[0], s0[1] * f[1], s0[2]];
|
||||
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge}));
|
||||
}
|
||||
}
|
||||
Gesture::Rotate { layer, center, start_angle, start_rot } => {
|
||||
Gesture::Rotate { layer, center, start_angle, start_rot, others } => {
|
||||
let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees();
|
||||
let mut r = start_rot + (ang - start_angle);
|
||||
if mods.shift {
|
||||
r = (r / 45.0).round() * 45.0;
|
||||
}
|
||||
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge}));
|
||||
for (lid, r0) in others {
|
||||
let _ =
|
||||
app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/rotation", "value": r0 + (r - start_rot), "merge": merge}));
|
||||
}
|
||||
}
|
||||
Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own, anchor_only } => {
|
||||
// Pan Behind snaps the anchor point (to its own layer's box, other layers' features,
|
||||
@@ -2228,3 +2338,38 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use effectcraft_engine::geom::{Mat4, vec3};
|
||||
|
||||
/// A perspective-like homography: w = 100 + y (the plane crosses the camera at y = −100).
|
||||
fn tilted() -> Mat3 {
|
||||
Mat3([[100.0, 0.0, 0.0], [0.0, 100.0, 0.0], [0.0, 1.0, 100.0]])
|
||||
}
|
||||
|
||||
/// #572: a box reaching behind the camera is clipped at the near plane instead of flipping
|
||||
/// through infinity into long lines.
|
||||
#[test]
|
||||
fn outlines_clip_at_the_near_plane() {
|
||||
let m = tilted();
|
||||
let front = project_clipped(&m, &box_corners([-50.0, -50.0, 50.0, 50.0]));
|
||||
assert_eq!(front.len(), 4);
|
||||
let crossing = project_clipped(&m, &box_corners([-50.0, -150.0, 50.0, 50.0]));
|
||||
assert_eq!(crossing.len(), 4, "{crossing:?}");
|
||||
// Every point is in front: none flipped to the other side (y < 0 here means behind).
|
||||
assert!(crossing.iter().all(|p| p[0].is_finite() && p[1].abs() < 1e4 && p[0].abs() < 1e4), "{crossing:?}");
|
||||
assert!(project_clipped(&m, &box_corners([-50.0, -400.0, 50.0, -200.0])).is_empty(), "wholly behind");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gizmo_segments_clip_at_the_near_plane() {
|
||||
// w = 100 + z.
|
||||
let pc = Mat4([[100.0, 0.0, 0.0, 0.0], [0.0, 100.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 1.0, 100.0]]);
|
||||
let s = project_segment(&pc, vec3(10.0, 0.0, 0.0), vec3(10.0, 0.0, -300.0)).unwrap();
|
||||
assert_eq!(s[0], [10.0, 0.0]);
|
||||
assert!(s[1][0] > 10.0 && s[1][0].is_finite(), "toward the camera it grows, never flips: {s:?}");
|
||||
assert!(project_segment(&pc, vec3(0.0, 0.0, -200.0), vec3(0.0, 0.0, -300.0)).is_none());
|
||||
}
|
||||
}
|
||||
@@ -348,3 +348,53 @@ fn graph_editor_buttons_that_do_not_fit_move_to_the_more_menu() {
|
||||
click(&mut h, pos2(item[0] + item[2] / 2.0, item[1] + item[3] / 2.0));
|
||||
assert_eq!(h.state().ui.timeline.graph_transform_box, !before, "the menu item runs the button");
|
||||
}
|
||||
|
||||
/// #355, #491: with three layers selected the Properties panel heads "(3) Selected Objects"; its
|
||||
/// stopwatch animates Position on all three (one undo step) and scrubbing X moves all three by
|
||||
/// the same amount.
|
||||
#[test]
|
||||
fn properties_panel_edits_every_selected_layer() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Three", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let mut ids = vec![];
|
||||
for (i, c) in ["#e04020", "#20e040", "#2040e0"].iter().enumerate() {
|
||||
let l = s.execute("layer.newSolid", json!({"color": c, "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [60.0 + 80.0 * i as f64, 90.0]})).unwrap();
|
||||
ids.push(l);
|
||||
}
|
||||
s.execute("layer.select", json!({"layers": ids})).unwrap();
|
||||
let first = s.state.selected_layers[0];
|
||||
let uid = s.active_comp().unwrap().layer(first).unwrap().props.prop("transform/position").unwrap().uid;
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::Properties);
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().auto.find("properties.selectedObjects").map(|e| e.label.clone()), Some("(3) Selected Objects".into()));
|
||||
let pos = |h: &Harness<'_, EffectcraftApp>, l: u64| {
|
||||
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/position").unwrap().clone()
|
||||
};
|
||||
let at = |h: &Harness<'_, EffectcraftApp>, id: &str| {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
|
||||
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
|
||||
};
|
||||
let sw = at(&h, &format!("properties.prop.{uid}.stopwatch"));
|
||||
h.input_mut().events.push(Event::PointerMoved(sw));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: sw, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: sw, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(3);
|
||||
assert!(ids.iter().all(|l| pos(&h, *l).is_animated()), "every selected layer's stopwatch");
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(ids.iter().all(|l| !pos(&h, *l).is_animated()), "one undo step");
|
||||
// Scrub X: each layer moves by the same amount.
|
||||
let before: Vec<f64> = ids.iter().map(|l| pos(&h, *l).value.components()[0]).collect();
|
||||
let x = at(&h, &format!("properties.prop.{uid}.value.0"));
|
||||
drag(&mut h, x, x + egui::vec2(30.0, 0.0), Default::default());
|
||||
let after: Vec<f64> = ids.iter().map(|l| pos(&h, *l).value.components()[0]).collect();
|
||||
let d = after[0] - before[0];
|
||||
assert!(d.abs() > 1.0, "the scrub moved X: {before:?} → {after:?}");
|
||||
for (a, b) in after.iter().zip(&before) {
|
||||
assert!((a - b - d).abs() < 1e-6, "same change on every layer: {before:?} → {after:?}");
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ fn group_of(n: &DockNode, p: PanelKind) -> Option<Vec<PanelKind>> {
|
||||
match n {
|
||||
DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)),
|
||||
DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()),
|
||||
DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p).then(|| entries.iter().map(|e| e.panel).collect()),
|
||||
DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.panels.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ fn group_of(n: &DockNode, p: PanelKind) -> Option<Vec<PanelKind>> {
|
||||
match n {
|
||||
DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)),
|
||||
DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()),
|
||||
DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p).then(|| entries.iter().map(|e| e.panel).collect()),
|
||||
DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.panels.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ fn stacked_panel_gaps_resize_the_panels() {
|
||||
match n {
|
||||
DockNode::Split { a, b, .. } => height(a, p).or_else(|| height(b, p)),
|
||||
DockNode::Tabs { .. } => None,
|
||||
DockNode::Stack { entries } => entries.iter().find(|e| e.panel == p).map(|e| e.height),
|
||||
DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.height),
|
||||
}
|
||||
}
|
||||
let (preview, props) = (rect(&h, "panel.Preview"), rect(&h, "panel.Properties"));
|
||||
@@ -345,3 +345,23 @@ fn a_stacked_panels_whole_header_bar_toggles_it() {
|
||||
click_n(&mut h, menu.center(), 1);
|
||||
assert!(open(&h), "the panel menu button doesn't toggle the panel");
|
||||
}
|
||||
|
||||
/// #302: a panel dropped onto a stacked panel becomes a tab in that panel's frame (it was
|
||||
/// inserted as a stacked panel of its own below it); its tabs switch the frame's panel.
|
||||
#[test]
|
||||
fn a_tab_dropped_on_a_stacked_panel_joins_its_frame() {
|
||||
let (mut h, _, _) = harness();
|
||||
let dock = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.clone();
|
||||
let fx = rect(&h, "panel.tab.EffectControls");
|
||||
let props = rect(&h, "panel.Properties");
|
||||
drag(&mut h, fx.center(), props.center());
|
||||
assert_eq!(group_of(&dock(&h), PanelKind::Properties), Some(vec![PanelKind::Properties, PanelKind::EffectControls]));
|
||||
assert!(dock(&h).is_visible(PanelKind::EffectControls) && !dock(&h).is_visible(PanelKind::Properties));
|
||||
// The frame's other tab shows Properties again, the frame stays open.
|
||||
let tab = rect(&h, "panel.tab.Properties");
|
||||
click_n(&mut h, tab.center(), 1);
|
||||
assert!(dock(&h).is_visible(PanelKind::Properties) && !dock(&h).is_visible(PanelKind::EffectControls));
|
||||
// The layout (saved workspaces) round-trips.
|
||||
let s = serde_json::to_string(&dock(&h)).unwrap();
|
||||
assert_eq!(serde_json::from_str::<DockNode>(&s).unwrap(), dock(&h));
|
||||
}
|
||||
@@ -1707,6 +1707,32 @@ fn handle_drags_scale_about_the_anchor_and_follow_the_pointer() {
|
||||
assert!(close(scale(&h), [162.5, 162.5]), "{:?}", scale(&h));
|
||||
}
|
||||
|
||||
/// #491: with several layers selected, a handle drag scales all of them by the same ratio, each
|
||||
/// about its own anchor point, in one undo step.
|
||||
#[test]
|
||||
fn handle_drags_scale_every_selected_layer() {
|
||||
let mut h = harness();
|
||||
let layers = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect::<Vec<_>>();
|
||||
let (box_id, plate) = (layers[0], layers[1]);
|
||||
let scale = |h: &Harness<'_, EffectcraftApp>, id: LayerId| {
|
||||
let l = h.state().session.active_comp().unwrap().layer(id).unwrap().clone();
|
||||
l.props.prop("transform/scale").unwrap().value.as_vec3()
|
||||
};
|
||||
h.state_mut().session.execute("prop.set", json!({"layer": plate.0, "path": "transform/scale", "value": [50, 50, 100]})).unwrap();
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0, plate.0]})).unwrap();
|
||||
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
|
||||
h.run_steps(2);
|
||||
let undo0 = h.state().session.history.undo.len();
|
||||
// The box's bottom-right corner (360, 220) to (400, 260): twice the box's size.
|
||||
let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center();
|
||||
let to = screen(&h, [400.0, 260.0]);
|
||||
drag_path(&mut h, &[corner, to], Default::default());
|
||||
let (b, p) = (scale(&h, box_id), scale(&h, plate));
|
||||
assert!((b[0] - 200.0).abs() < 1.0 && (b[1] - 200.0).abs() < 1.0, "{b:?}");
|
||||
assert!((p[0] - 100.0).abs() < 1.0 && (p[1] - 100.0).abs() < 1.0, "the other layer doubles too: {p:?}");
|
||||
assert_eq!(h.state().session.history.undo.len(), undo0 + 1, "one undo step");
|
||||
}
|
||||
|
||||
/// #252: with snapping on, a dragged handle snaps to the comp's corners and edges (and other
|
||||
/// layers'), so a layer scales exactly to the comp; Shift keeps the proportions and still lands
|
||||
/// on the comp's size.
|
||||
|
||||
Reference in new issue
Block a user