From 53dc9032c5388a9ac5f2b8b302ae8c1f729c830c Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Mon, 2 Nov 2026 20:38:05 +0200 Subject: [PATCH] Viewer: the Rotation tool measures its angle in parent space (#2870) Co-Authored-By: Claude Opus 5.5 --- crates/render/src/eval.rs | 9 +- crates/ui-egui/src/panels/viewer.rs | 49 +++--- crates/ui-egui/src/panels/viewer/geometry.rs | 54 +++++++ crates/ui-egui/src/panels/viewer/gesture.rs | 12 +- crates/ui-egui/src/panels/viewer/tests.rs | 49 ++++++ crates/ui-egui/src/panels/viewer_overlays.rs | 13 +- crates/ui-egui/tests/it/ui_viewer.rs | 149 +++++++++++++++++++ 7 files changed, 289 insertions(+), 46 deletions(-) diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 81fe45d6c..1aaf02d76 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -214,7 +214,14 @@ impl<'a> EvalCtx<'a> { /// Layer space → comp (world) space, including parents. pub fn world_matrix(&self, layer: &Layer) -> Mat4 { - let mut m = self.local_matrix_par(layer, self.par_ratio(layer)); + self.parent_matrix(layer) * self.local_matrix_par(layer, self.par_ratio(layer)) + } + + /// The space a layer's Position and Rotation live in → comp (world) space: its parents' + /// transforms, without their pixel aspect, which children don't inherit (identity without a + /// parent). + pub fn parent_matrix(&self, layer: &Layer) -> Mat4 { + let mut m = Mat4::IDENTITY; let mut cur = layer.parent; let mut guard = 0; while let Some(pid) = cur { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index bb7b2d075..52dd47567 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -565,10 +565,8 @@ fn phase_layers(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: continue; } // Cameras, lights, empty layers: just the anchor. - if let Some(tr) = l.transform() { - let pos = ectx.v3(l, tr, "position", [0.0; 3]); - let s = map.to_screen([pos[0], pos[1]]); - painter.circle_stroke(s, 6.0, Stroke::new(1.0, col)); + if let Some(c) = anchor_in_comp(ectx, l) { + painter.circle_stroke(map.to_screen(c), 6.0, Stroke::new(1.0, col)); } continue; }; @@ -1126,22 +1124,15 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, } 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() - .and_then(|l| l.transform().map(|tr| ectx.v3(l, tr, "position", [0.0; 3]))) - .map(|p| map.to_screen([p[0], p[1]])) - .unwrap_or(pos); + let sign = layer.as_ref().map_or(1.0, |l| rotation_sign(ectx, l)); + let others = selected_others(app, comp, l) + .into_iter() + .filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0), rotation_sign(ectx, o) * sign))) + .collect(); + let (c2p, pivot) = layer.as_ref().and_then(|l| rotate_frame(ectx, l)).unwrap_or((Mat3::IDENTITY, cpt)); 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, - last_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(), - turned: 0.0, - start_rot: rot, - others, - } + Gesture::Rotate { layer: l, c2p, pivot, last_angle: pointer_angle(&c2p, pivot, cpt), turned: 0.0, start_rot: rot, others } }), Tool::PanBehind => pick(app, ectx, cpt, false).and_then(|l| { let layer = comp.layer(l)?; @@ -1399,18 +1390,18 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge})); } } - Gesture::Rotate { layer, center, last_angle, turned, start_rot, others } => { + Gesture::Rotate { layer, c2p, pivot, last_angle, turned, start_rot, others } => { // Count the turn incrementally: the absolute angle wraps at ±180°, so writing it // straight would jump a revolution back each time the pointer went round. - let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(); + let ang = pointer_angle(&c2p, pivot, cpt); let turned = turned + turn_delta(last_angle, ang); - ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Rotate { layer, center, last_angle: ang, turned, start_rot, others: others.clone() })); + ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Rotate { layer, c2p, pivot, last_angle: ang, turned, start_rot, others: others.clone() })); // Shift snaps the accumulated value (so 405° snaps to 1x +45°, not to 45°). let r = rotate_value(start_rot, turned, mods.shift); 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})); + for (lid, r0, sign) in others { + let v = r0 + sign * (r - start_rot); + let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/rotation", "value": v, "merge": merge})); } } Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own, anchor_only } => { @@ -1901,13 +1892,13 @@ mod view; use frame::{empty_state, rect_of, show_frame}; pub(crate) use frame::{extended_region, minify_factor, snap_to_pixels, styled_line, zoom_sampler, zoom_texture_options}; +use geometry::{ + anchor_in_comp, cam_state, drag_ratio, handle_axes, handle_cursor, handle_point, one_axis, pointer_angle, project_segment, quad_intersects_box, + rotate_frame, rotate_value, rotation_sign, selected_others, snap_handle, turn_delta, +}; #[cfg(test)] use geometry::{box_corners, project_clipped}; -use geometry::{ - cam_state, drag_ratio, handle_axes, handle_cursor, handle_point, one_axis, project_segment, quad_intersects_box, rotate_value, selected_others, - snap_handle, turn_delta, -}; -pub(crate) use geometry::{from_effect_space, fx2c, l2c, layer_outline, layer_quad}; +pub(crate) use geometry::{from_effect_space, fx2c, l2c, layer_outline, layer_quad, parent_to_comp}; use gesture::{Gesture, HANDLE, ROTO_HINT, pen_id, roi_drag, roi_handles, vertex_grab_radius}; use shapes::{create_shape, draw_shape_ghost, shape_drag}; pub(crate) use shapes::{layer_at, layer_at_including_locked, layers_at, pick, selectable_layers}; diff --git a/crates/ui-egui/src/panels/viewer/geometry.rs b/crates/ui-egui/src/panels/viewer/geometry.rs index 847332bcf..39809e247 100644 --- a/crates/ui-egui/src/panels/viewer/geometry.rs +++ b/crates/ui-egui/src/panels/viewer/geometry.rs @@ -120,6 +120,60 @@ pub(crate) fn layer_outline(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> { project_clipped(&l2c(ctx, layer).0, &box_corners(b)) } +/// Parent space → comp matrix of a 2D layer at the context time. +pub(crate) fn parent_to_comp(ctx: &EvalCtx, layer: &Layer) -> Mat3 { + let w = ctx.parent_matrix(layer).0; + Mat3([[w[0][0], w[0][1], w[0][3]], [w[1][0], w[1][1], w[1][3]], [0.0, 0.0, 1.0]]) +} + +/// Where a layer's anchor point shows in the viewer (comp pixels), through the current 3D view for +/// a 3D layer. None without a transform, or when it is at or behind the camera. +pub(super) fn anchor_in_comp(ctx: &EvalCtx, layer: &Layer) -> Option<[f64; 2]> { + let a = ctx.v3(layer, layer.transform()?, "anchor", [0.0; 3]); + let p = ctx.world_matrix(layer).apply(a.into()); + if layer.is_3d() { + let pc = cam_state(ctx).projection(ctx.comp.width as f64, ctx.comp.height as f64); + return project_segment(&pc, p, p).map(|[q, _]| q); + } + Some([p.x, p.y]) +} + +/// −1 when a layer's Rotation turns it counter-clockwise on screen: its parent is flipped or, for +/// a 3D layer, the view sees its parent's XY plane from behind. Otherwise 1. +pub(super) fn rotation_sign(ctx: &EvalCtx, layer: &Layer) -> f64 { + let plane = match layer.transform() { + Some(tr) if layer.is_3d() => { + let at = effectcraft_engine::geom::Mat4::translate(ctx.position(layer, tr).into()); + (cam_state(ctx).projection(ctx.comp.width as f64, ctx.comp.height as f64) * ctx.parent_matrix(layer) * at).plane_to_mat3() + } + _ => parent_to_comp(ctx, layer), + }; + if plane.determinant() < 0.0 { -1.0 } else { 1.0 } +} + +/// The space the Rotation tool measures the pointer's angle in: comp → that space, and the layer's +/// pivot there. A 2D layer turns in parent space about its Position, so it follows the pointer +/// through any parent (a flip or skew needs no special case). A 3D layer, or a parent that collapses +/// the plane, measures on screen about the anchor, mirrored when the turn is reversed. +pub(super) fn rotate_frame(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [f64; 2])> { + if !layer.is_3d() + && let Some(c2p) = parent_to_comp(ctx, layer).inverse() + { + let p = ctx.position(layer, layer.transform()?); + return Some((c2p, [p[0], p[1]])); + } + let a = anchor_in_comp(ctx, layer)?; + let c2p = Mat3::scale(gv2(rotation_sign(ctx, layer), 1.0)); + let p = c2p.apply(gv2(a[0], a[1])); + Some((c2p, [p.x, p.y])) +} + +/// The pointer's angle (degrees, as atan2 gives it) about `pivot`, in the space of [`rotate_frame`]. +pub(super) fn pointer_angle(c2p: &Mat3, pivot: [f64; 2], cpt: [f64; 2]) -> f64 { + let p = c2p.apply(gv2(cpt[0], cpt[1])); + (p.y - pivot[1]).atan2(p.x - pivot[0]).to_degrees() +} + /// 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. pub(super) fn handle_axes(handle: usize) -> [bool; 2] { diff --git a/crates/ui-egui/src/panels/viewer/gesture.rs b/crates/ui-egui/src/panels/viewer/gesture.rs index db024e720..797828618 100644 --- a/crates/ui-egui/src/panels/viewer/gesture.rs +++ b/crates/ui-egui/src/panels/viewer/gesture.rs @@ -34,15 +34,19 @@ pub(super) enum Gesture { }, Rotate { layer: LayerId, - center: Pos2, - /// The pointer's angle about the centre on the last frame (degrees, as atan2 gives it). + /// Comp → the space the pointer's angle is measured in, and the pivot there (see + /// `rotate_frame`): comp pixels, not screen points, so zooming mid-drag doesn't turn it. + c2p: Mat3, + pivot: [f64; 2], + /// The pointer's angle about the pivot on the last frame (degrees, as atan2 gives it). last_angle: f64, /// The turn so far, summed frame by frame so it keeps counting past a full revolution in /// either direction (After Effects: 1x +0.0°, −1x −90.0°…) instead of wrapping at ±180°. turned: f64, start_rot: f64, - /// The other selected layers and their rotations: they turn by the same angle. - others: Vec<(LayerId, f64)>, + /// The other selected layers, their rotations and turn direction relative to this layer + /// (−1 when one parent is flipped): they turn by the same angle on screen. + others: Vec<(LayerId, f64, f64)>, }, Anchor { layer: LayerId, diff --git a/crates/ui-egui/src/panels/viewer/tests.rs b/crates/ui-egui/src/panels/viewer/tests.rs index da56dc008..591e02f86 100644 --- a/crates/ui-egui/src/panels/viewer/tests.rs +++ b/crates/ui-egui/src/panels/viewer/tests.rs @@ -140,6 +140,55 @@ fn gizmo_segments_clip_at_the_near_plane() { assert!(project_segment(&pc, vec3(0.0, 0.0, -200.0), vec3(0.0, 0.0, -300.0)).is_none()); } +/// A 640×360 comp whose Box (anchor 40,40) is a child of Parent (anchor 50,50) at (100, 60). +fn parented_session(parent: serde_json::Value) -> (effectcraft_engine::Session, u64) { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 1})).unwrap(); + let id = |v: serde_json::Value| v["layer"].as_u64().unwrap(); + let p = id(s.execute("layer.newSolid", json!({"name": "Parent", "color": "#000000", "width": 100, "height": 100})).unwrap()); + let c = id(s.execute("layer.newSolid", json!({"name": "Box", "color": "#000000", "width": 80, "height": 80})).unwrap()); + for (path, value) in parent.as_object().unwrap() { + s.execute("prop.set", json!({"layer": p, "path": format!("transform/{path}"), "value": value})).unwrap(); + } + s.execute("layer.setParent", json!({"layers": [c], "parent": p, "compensate": false})).unwrap(); + s.execute("prop.set", json!({"layer": c, "path": "transform/position", "value": [100.0, 60.0]})).unwrap(); + (s, c) +} + +fn with_ctx(s: &effectcraft_engine::Session, f: impl FnOnce(&EvalCtx) -> T) -> T { + let comp = s.active_comp().unwrap(); + f(&EvalCtx::new(&s.project, comp.id, comp, Tick::ZERO)) +} + +/// #2870: a parent scaled to nothing has no inverse: the Rotation tool measures on screen about +/// where the anchor collapsed to, with no panic and no NaN. +#[test] +fn rotate_frame_falls_back_on_screen_when_the_parent_collapses() { + let (s, c) = parented_session(json!({"position": [400.0, 200.0], "scale": [0.0, 100.0]})); + with_ctx(&s, |ctx| { + let l = ctx.comp.layer(LayerId(c)).unwrap(); + let (c2p, pivot) = rotate_frame(ctx, l).unwrap(); + assert_eq!(c2p, Mat3::IDENTITY); + assert_eq!(pivot, [400.0, 210.0]); + assert_eq!(pointer_angle(&c2p, pivot, [400.0, 310.0]), 90.0); + }); +} + +/// #2870: the marker and pivot of a child follow its parent, through the camera for a 3D layer +/// and none behind it. +#[test] +fn anchor_in_comp_follows_the_parent_and_the_camera() { + let (mut s, c) = parented_session(json!({"position": [400.0, 200.0]})); + with_ctx(&s, |ctx| assert_eq!(anchor_in_comp(ctx, ctx.comp.layer(LayerId(c)).unwrap()), Some([450.0, 210.0]))); + s.execute("layer.setSwitch", json!({"layers": [c], "switch": "threeD", "value": true})).unwrap(); + with_ctx(&s, |ctx| { + let a = anchor_in_comp(ctx, ctx.comp.layer(LayerId(c)).unwrap()).unwrap(); + assert!((a[0] - 450.0).abs() < 1e-6 && (a[1] - 210.0).abs() < 1e-6, "{a:?}"); + }); + s.execute("prop.set", json!({"layer": c, "path": "transform/position", "value": [100.0, 60.0, -1e6]})).unwrap(); + with_ctx(&s, |ctx| assert_eq!(anchor_in_comp(ctx, ctx.comp.layer(LayerId(c)).unwrap()), None)); +} + /// #999: a vertex drag pans the view only near or past the edge, toward it, faster further out. #[test] fn edge_pan_speed_moves_toward_the_edge() { diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 00c07425d..ba16abcc8 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -369,17 +369,6 @@ pub(crate) fn motion_frame_at(frames: &[PathFrame], hits: &[KeyHit], pos: Pos2) (!hits.iter().any(|h| h.tangent.is_none() && h.layer == near.layer && h.prop == near.prop && h.time == near.time)).then_some(*near) } -/// Parent space → comp matrix of a 2D layer at the context time. -pub(crate) fn parent_to_comp(ectx: &EvalCtx, l: &Layer) -> Mat3 { - match l.parent.and_then(|p| ectx.comp.layer(p)) { - Some(p) => { - let w = ectx.world_matrix(p).0; - Mat3([[w[0][0], w[0][1], w[0][3]], [w[1][0], w[1][1], w[1][3]], [0.0, 0.0, 1.0]]) - } - None => Mat3::IDENTITY, - } -} - /// Draw the position motion path of a selected 2D layer: frame dots along the path, keyframe /// squares (filled when selected) and spatial Bezier tangent handles. Works through parents. #[allow(clippy::too_many_arguments)] @@ -410,7 +399,7 @@ pub(crate) fn motion_path( Some((x, y)) => separated_value(x, y, t), None => pos.value_at(t).as_vec3(), }; - let p2c = parent_to_comp(ectx, l); + let p2c = super::viewer::parent_to_comp(ectx, l); let to_scr = |v: [f64; 3]| { let c = p2c.apply(gv2(v[0], v[1])); map.to_screen([c.x, c.y]) diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index f0086d28a..cfd44dc1a 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -2890,6 +2890,155 @@ fn rotate_tool_counts_revolutions_both_ways() { assert_eq!(h.state().session.history.undo.len(), undo_before + 1, "one drag, one undo step"); } +/// A harness whose Box is a child of Plate (anchor 320,180) with `plate` set on Plate first, at +/// parent-space position (200, 120). Returns the two layers' ids. +fn rotate_child_harness(plate: serde_json::Value) -> (Harness<'static, EffectcraftApp>, u64, u64) { + let mut h = harness(); + let (parent, child) = (layer_id(&h, "Plate"), layer_id(&h, "Box")); + let s = &mut h.state_mut().session; + for (path, value) in plate.as_object().unwrap() { + s.execute("prop.set", json!({"layer": parent, "path": format!("transform/{path}"), "value": value})).unwrap(); + } + s.execute("layer.setParent", json!({"layers": [child], "parent": parent, "compensate": false})).unwrap(); + s.execute("prop.set", json!({"layer": child, "path": "transform/position", "value": [200.0, 120.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [child]})).unwrap(); + h.state_mut().ui.tool = Tool::Rotate; + h.run_steps(2); + (h, parent, child) +} + +/// Goes round comp point `pivot` with the Rotation tool, 10° clockwise a frame for 400°, starting +/// inside the 80 px box to its right. The rotations of `layers` on each frame, from the pointer's +/// first move on. +fn rotate_round(h: &mut Harness<'static, EffectcraftApp>, pivot: [f32; 2], layers: &[u64]) -> Vec> { + let center = screen(h, pivot); + let r = (screen(h, [pivot[0] + 30.0, pivot[1]]) - center).x; + let at = |deg: f64| center + vec2((deg.to_radians().cos() as f32) * r, (deg.to_radians().sin() as f32) * r); + let press = center + vec2(r * 0.3, 0.0); + h.input_mut().events.push(Event::PointerMoved(press)); + h.input_mut().events.push(Event::PointerButton { pos: press, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + let mut seen = vec![]; + for i in 0..=40 { + h.input_mut().events.push(Event::PointerMoved(at(i as f64 * 10.0))); + h.step(); + let comp = h.state().session.active_comp().unwrap(); + seen.push(layers.iter().map(|l| comp.layer(LayerId(*l)).unwrap().props.prop("transform/rotation").unwrap().value.as_f64()).collect()); + } + let end = at(400.0); + h.input_mut().events.push(Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + seen +} + +/// Layer `i` of `rotate_round` turned by `step`° on every frame once the drag was under way. +fn assert_turns_by(seen: &[Vec], i: usize, step: f64) { + let v: Vec = seen.iter().map(|f| f[i]).collect(); + let live: Vec = v.iter().copied().skip_while(|x| *x == v[0]).collect(); + assert!(live.len() > 30, "layer {i} hardly turned: {v:?}"); + assert!(live.windows(2).all(|w| (w[1] - w[0] - step).abs() < 0.5), "layer {i} should turn {step}° a frame: {v:?}"); +} + +/// #2870: a child of a moved parent turns about its anchor where it shows, not about its raw +/// Position read as comp pixels. +#[test] +fn rotate_tool_turns_a_child_of_a_moved_parent_smoothly() { + let (mut h, _, child) = rotate_child_harness(json!({"position": [420.0, 240.0]})); + assert_turns_by(&rotate_round(&mut h, [300.0, 180.0], &[child]), 0, 10.0); +} + +/// #2870: under a mirrored parent the value runs opposite to the pointer, so the layer on screen +/// still follows it. +#[test] +fn rotate_tool_follows_the_pointer_under_a_flipped_parent() { + let (mut h, _, child) = rotate_child_harness(json!({"scale": [-100.0, 100.0]})); + assert_turns_by(&rotate_round(&mut h, [440.0, 120.0], &[child]), 0, -10.0); +} + +/// #2870: a parent's rotation offsets the angle but not the turn. +#[test] +fn rotate_tool_turns_a_child_of_a_rotated_parent_smoothly() { + let (mut h, _, child) = rotate_child_harness(json!({"rotation": 90.0})); + assert_turns_by(&rotate_round(&mut h, [380.0, 60.0], &[child]), 0, 10.0); +} + +/// #2870: under a parent scaled unevenly the angle is the one in parent space, so the layer's edge +/// stays under the pointer, which covers uneven degrees per frame. +#[test] +fn rotate_tool_measures_in_parent_space_under_a_stretched_parent() { + let (mut h, _, child) = rotate_child_harness(json!({"scale": [200.0, 100.0]})); + let seen = rotate_round(&mut h, [80.0, 120.0], &[child]); + let mut turned = 0.0_f64; + let mut last = 0.0_f64; + let want: Vec = (0..=40) + .map(|i| { + let a = (i as f64 * 10.0).to_radians(); + let ang = a.sin().atan2(a.cos() / 2.0).to_degrees(); + turned += (ang - last + 180.0).rem_euclid(360.0) - 180.0; + last = ang; + turned + }) + .collect(); + let end = seen.last().unwrap()[0]; + assert!((end - want[40]).abs() < 1.0, "the layer follows the pointer in parent space: {end}, want {}", want[40]); + assert!(seen.windows(2).all(|w| (w[1][0] - w[0][0]).abs() < 30.0), "no jump: {seen:?}"); +} + +/// #2870: a parent's pixel aspect isn't inherited by its child, so it doesn't move the pivot either. +#[test] +fn rotate_tool_ignores_a_parents_pixel_aspect() { + let mut h = harness(); + let child = layer_id(&h, "Box"); + let s = &mut h.state_mut().session; + let parent = s.execute("layer.newSolid", json!({"name": "Wide", "color": "#000000", "width": 100, "height": 100, "pixelAspect": 2.0})).unwrap()["layer"] + .as_u64() + .unwrap(); + s.execute("layer.setSwitch", json!({"layers": [parent], "switch": "video", "value": false})).unwrap(); + s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [500.0, 300.0]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [child], "parent": parent, "compensate": false})).unwrap(); + s.execute("prop.set", json!({"layer": child, "path": "transform/position", "value": [100.0, 60.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [child]})).unwrap(); + h.state_mut().ui.tool = Tool::Rotate; + h.run_steps(2); + assert_turns_by(&rotate_round(&mut h, [550.0, 310.0], &[child]), 0, 10.0); +} + +/// #2870: every selected layer turns the same way on screen, whichever way its own parent is +/// flipped: the value of a layer whose parent is mirrored relative to the grabbed one's runs opposite. +#[test] +fn rotate_tool_turns_a_mixed_selection_the_same_way_on_screen() { + let (mut h, plate, child) = rotate_child_harness(json!({"scale": [-100.0, 100.0]})); + let s = &mut h.state_mut().session; + let new = |s: &mut Session, name: &str| { + s.execute("layer.newSolid", json!({"name": name, "color": "#00ff00", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap() + }; + let (free, sibling) = (new(s, "Free"), new(s, "Sibling")); + s.execute("layer.setParent", json!({"layers": [sibling], "parent": plate, "compensate": false})).unwrap(); + s.execute("prop.set", json!({"layer": free, "path": "transform/rotation", "value": 15.0})).unwrap(); + s.execute("layer.select", json!({"layers": [child, free, sibling]})).unwrap(); + h.run_steps(2); + let seen = rotate_round(&mut h, [440.0, 120.0], &[child, free, sibling]); + assert_turns_by(&seen, 0, -10.0); + assert_turns_by(&seen, 1, 10.0); + assert_turns_by(&seen, 2, -10.0); +} + +/// #2870: a 3D child turns about its anchor as the camera shows it, and with its value running +/// opposite under a flipped parent. +#[test] +fn rotate_tool_turns_a_3d_child_about_its_anchor_through_the_camera() { + let (mut h, _, child) = rotate_child_harness(json!({"scale": [-100.0, 100.0]})); + let s = &mut h.state_mut().session; + s.execute("layer.setSwitch", json!({"layers": [child], "switch": "threeD", "value": true})).unwrap(); + let cam = s.execute("layer.newCamera", json!({"name": "Cam", "type": "oneNode"})).unwrap()["layer"].as_u64().unwrap(); + let at = s.active_comp().unwrap().layer(LayerId(cam)).unwrap().props.prop("transform/position").unwrap().value.as_vec3(); + // One comp-plane unit to the right moves everything in the comp plane one pixel to the left. + s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [at[0] + 100.0, at[1], at[2]]})).unwrap(); + s.execute("layer.select", json!({"layers": [child]})).unwrap(); + h.run_steps(2); + assert_turns_by(&rotate_round(&mut h, [340.0, 120.0], &[child]), 0, -10.0); +} + /// Mask vertices multi-select with the Selection tool: Shift-click adds a vertex to the /// selection and Shift-clicking it again removes it; a plain click collapses to one (#1004). #[test]