diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/commands/animation.rs index fb2d80c9..c700123d 100644 --- a/crates/engine/src/commands/animation.rs +++ b/crates/engine/src/commands/animation.rs @@ -5,7 +5,8 @@ use std::collections::BTreeMap; use effectcraft_keyframe::{Interp, Keyframe, Value as KV}; -use effectcraft_project::{GroupKind, LayerId, Node, PropGroup, Property, Uid}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project, PropGroup, Property, Uid}; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -293,6 +294,9 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [ match (group, pr.match_id.as_str()) { ("transform", "anchor") => !near(&v.as_vec2(), &default_anchor), ("transform", "position") => !near(&v.as_vec2(), &comp_center) || v.as_vec3()[2].abs() > 1e-6, + // Separate Dimensions: X and Y Position default to the comp centre. + ("transform", "positionX") => (v.as_f64() - comp_center[0]).abs() > 1e-6, + ("transform", "positionY") => (v.as_f64() - comp_center[1]).abs() > 1e-6, ("transform", "scale") => !near(&v.as_vec3(), &[100.0; 3]), ("transform", "opacity") => (v.as_f64() - 100.0).abs() > 1e-6, ("transform", "orientation") => !near(&v.as_vec3(), &[0.0; 3]), @@ -301,6 +305,50 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [ } } +/// The group a fresh layer of `l`'s kind (`fresh`) has in place of `top`, for the groups whose +/// defaults are fixed: Audio, Material / Geometry / Camera / Light Options, a text layer's Path +/// and More Options, and a camera's or light's Transform (whose defaults aren't a 2D layer's). +fn default_group(top: &PropGroup, l: &Layer, fresh: &Layer) -> Option { + let mut next = 0; + let mut ids = Ids(&mut next); + let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. }); + let mut g = match top.match_id.as_str() { + "transform" if !camera_or_light => return None, + // Added to a layer after it is made (a source with audio, an Advanced 3D comp). + "audio" => build::audio(&mut ids), + "geometryOptions" if matches!(l.source, LayerSource::Text | LayerSource::Shape) => build::extrusion_geometry_options(&mut ids), + "transform" | "cameraOptions" | "lightOptions" | "materialOptions" | "geometryOptions" | "text" => fresh.props.sub(&top.match_id)?.clone(), + _ => return None, + }; + match top.match_id.as_str() { + // Source Text and the animators are the layer's content, not options with a default. + "text" => g.children.retain(|c| matches!(c.match_id(), "pathOptions" | "moreOptions")), + // A model's scale and clip come from its file. + "geometryOptions" if matches!(l.source, LayerSource::Model { .. }) => g.children.retain(|c| !matches!(c.match_id(), "unitScale" | "animation")), + _ => {} + } + Some(g) +} + +/// Static properties of `g` whose value differs from the same property of `default` (matched by +/// match id; properties `default` lacks have no known default and are left out). +fn changed_from(g: &PropGroup, default: &PropGroup, out: &mut Vec) { + for c in &g.children { + match c { + Node::Prop(pr) => { + if default.get(&pr.match_id).is_some_and(|d| d.value != pr.value) && !out.contains(&pr.uid) { + out.push(pr.uid); + } + } + Node::Group(sg) => { + if let Some(d) = default.sub(&sg.match_id) { + changed_from(sg, d, out); + } + } + } + } +} + fn reveal(s: &mut Session, p: &Value) -> Result { let kind = str_p(p, "kind").unwrap_or("keyframes").to_string(); let cid = super::comp_id(s, p)?; @@ -324,10 +372,15 @@ fn reveal(s: &mut Session, p: &Value) -> Result { out.push(json!({"layer": lid.0, "prop": pr.uid})); } } + // Modified: a fresh layer of the same kind gives the defaults to compare against. + let fresh = (kind == "modified").then(|| build::layer(&mut Project::default(), comp, "", l.source.clone(), (w, h), None)); + let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. }); for top in l.props.groups() { let hit = |pr: &Property| match kind.as_str() { "keyframes" => pr.is_animated(), "animation" => pr.is_animated() || pr.has_expression(), + // A camera's or light's Transform is compared with a fresh one's below. + _ if camera_or_light && top.match_id == "transform" => pr.is_animated() || pr.expr.is_some(), _ => modified(pr, &top.match_id, anchor, center), }; let mut found = vec![]; @@ -347,6 +400,23 @@ fn reveal(s: &mut Session, p: &Value) -> Result { found.push(g.uid); } } + if let Some(fresh) = &fresh { + if let Some(default) = default_group(top, l, fresh) { + changed_from(top, &default, &mut found); + } + // Every layer style counts in full, like an effect; so does each overridden + // Essential Property. + if top.match_id == effectcraft_project::styles::GROUP { + found.extend(top.groups().filter(|g| g.match_id != effectcraft_project::styles::BLENDING).map(|g| g.uid)); + } + if let GroupKind::Essential { overridden } = &top.kind { + for u in overridden { + if !found.contains(u) && (top.find(*u).is_some() || top.find_group(*u).is_some()) { + found.push(*u); + } + } + } + } out.extend(found.into_iter().map(|u| json!({"layer": lid.0, "prop": u}))); } } diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 0af232b1..26a7e0d6 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -731,6 +731,70 @@ fn reveal_shortcuts_take_in_time_remap() { } } +/// UU on a layer nothing was changed on shows nothing, whatever its kind: cameras, lights, text +/// and nulls have their own Transform defaults, and Separate Dimensions keeps the comp centre. +#[test] +fn modified_reveal_is_empty_for_untouched_layers() { + let (mut s, solid) = setup(); + let mut layers = vec![("solid", solid)]; + for (cmd, params) in [ + ("layer.newText", json!({"text": "Hi"})), + ("layer.newShape", json!({})), + ("layer.newNull", json!({})), + ("layer.newCamera", json!({})), + ("layer.newLight", json!({})), + ("layer.newLight", json!({"type": "ambient"})), + ] { + let r = s.execute(cmd, params.clone()).unwrap_or_else(|e| panic!("{cmd} {params}: {e}")); + layers.push((cmd, r["layer"].as_u64().unwrap())); + } + s.execute("prop.separateDimensions", json!({"layer": solid})).unwrap(); + for (what, l) in layers { + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + let r = s.execute("anim.reveal", json!({"kind": "modified"})).unwrap(); + let paths: Vec = r["props"].as_array().unwrap().iter().filter_map(|p| p["prop"].as_u64()).map(|u| path_of(&s, l, u)).collect(); + assert!(paths.is_empty(), "{what}: {paths:?}"); + } +} + +/// Match-id path of property or group `uid` of `layer`. +fn path_of(s: &Session, layer: u64, uid: u64) -> String { + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap(); + let mut out = String::new(); + for n in l.props.node_chain(uid).unwrap_or_default() { + out.push('/'); + out.push_str(n.match_id()); + } + out +} + +/// UU takes in static changes outside Transform: Material, Camera and Light Options, a +/// separated position, and layer styles in full. +#[test] +fn modified_reveal_takes_in_options_and_layer_styles() { + let (mut s, solid) = setup(); + let modified = |s: &mut Session, l: u64| -> Vec { + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + let r = s.execute("anim.reveal", json!({"kind": "modified"})).unwrap(); + r["props"].as_array().unwrap().iter().filter_map(|p| p["prop"].as_u64()).map(|u| path_of(s, l, u)).collect() + }; + s.execute("prop.set", json!({"layer": solid, "path": "materialOptions/diffuse", "value": 80})).unwrap(); + assert_eq!(modified(&mut s, solid), ["/materialOptions/diffuse"]); + s.execute("prop.separateDimensions", json!({"layer": solid})).unwrap(); + s.execute("prop.set", json!({"layer": solid, "path": "transform/positionX", "value": 10})).unwrap(); + assert_eq!(modified(&mut s, solid), ["/transform/positionX", "/materialOptions/diffuse"]); + s.execute("layer.style.dropShadow", json!({"layers": [solid]})).unwrap(); + assert!(modified(&mut s, solid).contains(&"/layerStyles/dropShadow".to_string()), "{:?}", modified(&mut s, solid)); + + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/blurLevel", "value": 50})).unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 15})).unwrap(); + assert_eq!(modified(&mut s, cam), ["/transform/rotation", "/cameraOptions/blurLevel"]); + let light = s.execute("layer.newLight", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": light, "path": "lightOptions/intensity", "value": 40})).unwrap(); + assert_eq!(modified(&mut s, light), ["/lightOptions/intensity"]); +} + /// Enable Time Remapping on a layer extended past its source's end (its last frame frozen) puts /// the end key at the source's end, not at the extended out point (#290). #[test]