Merge pull request #1627

This commit is contained in:
Brandon Thomas
2026-10-18 09:35:34 -04:00
2 changed files with 150 additions and 11 deletions
+66 -11
View File
@@ -976,6 +976,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let selected = app.session.state.selected_layers.clone();
// Bounding-box handles: (layer, handle, screen point, its resize cursor).
let mut handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)> = Vec::new();
// 3D gizmo arrows: (layer, grabbable screen segment, position change per comp px in x and y).
let mut axis_hits: Vec<(LayerId, [Pos2; 2], [f64; 3], [f64; 3])> = Vec::new();
let mut vertex_hits: Vec<VertexHit> = Vec::new();
let mut vertex_marks: Vec<ov::VertexMark> = Vec::new();
let mut key_hits: Vec<ov::KeyHit> = Vec::new();
@@ -1078,14 +1080,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
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 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 pinv = parent_world_inverse(&ectx, l);
for (axis, col, name) in [
(effectcraft_engine::geom::vec3(len, 0.0, 0.0), Color32::from_rgb(0xe0, 0x50, 0x50), "x"),
(effectcraft_engine::geom::vec3(0.0, len, 0.0), Color32::from_rgb(0x60, 0xd0, 0x60), "y"),
(effectcraft_engine::geom::vec3(0.0, 0.0, len), Color32::from_rgb(0x50, 0x8c, 0xf0), "z"),
] {
// (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));
let Some([o, e]) = project_segment(&pc, a3, a3 + axis) else { continue };
// An axis pointing at the camera (Z in the default front view) projects to
// a dot: draw it as a short diagonal stub so it can still be grabbed.
let mut s = [e[0] - o[0], e[1] - o[1]];
if (s[0] * s[0] + s[1] * s[1]).sqrt() * (zoom as f64) < 12.0 {
let k = 24.0 / zoom as f64;
s = [k, -k];
}
let (so, se) = (map.to_screen(o), map.to_screen([o[0] + s[0], o[1] + s[1]]));
painter.arrow(so, se - so, Stroke::new(2.0, col));
if !l.switches.locked {
let dir = pinv.apply_vec(w.apply_vec(axis));
let (ax, ay) = axis_drag([dir.x, dir.y, dir.z], s);
// The inner part stays on the anchor point, which drags the layer freely.
axis_hits.push((l.id, [so + (se - so) * 0.35, se], ax, ay));
app.auto.add(&format!("viewer.gizmo.{}.{name}", l.id.0), Rect::from_center_size(se, vec2(12.0, 12.0)), "3D axis");
}
}
}
@@ -1357,6 +1374,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
} else if vertex_at(hp, true).or_else(|| vertex_at(hp, false)).is_some() {
// A path point under a box handle is grabbed first: no resize cursor.
egui::CursorIcon::PointingHand
} else if axis_at(&axis_hits, hp).is_some() {
egui::CursorIcon::Move
} else if let Some((.., cursor)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(hp) < HANDLE) {
*cursor
} else {
@@ -1542,7 +1561,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// far enough to count as a drag it may have left a small handle.
Tool::Selection => {
let cpt = map.to_comp(press);
if let Some((lid, hi, ..)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(press) < HANDLE + 2.0).cloned() {
// A gizmo arrow is drawn over the box handles, so it is grabbed first: move along
// that axis only, without snapping.
if let Some((lid, _, ax, ay)) = axis_at(&axis_hits, press) {
let p0 = comp.layer(lid).and_then(|l| Some(ectx.v3(l, l.transform()?, "position", [0.0; 3])));
p0.map(|p0| Gesture::Move { layers: vec![(lid, p0, ax, ay)], start: cpt, snap_src: None })
} else if let Some((lid, hi, ..)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(press) < HANDLE + 2.0).cloned() {
let layer = comp.layer(lid).cloned();
layer.and_then(|layer| {
let tr = layer.transform()?;
@@ -2050,6 +2074,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// The position change per comp pixel of drag (x, y) that moves a layer by `dir` when the
/// pointer covers the axis' on-screen vector `s` (comp px); drag across the axis is ignored.
fn axis_drag(dir: [f64; 3], s: [f64; 2]) -> ([f64; 3], [f64; 3]) {
let n = (s[0] * s[0] + s[1] * s[1]).max(1e-12);
(dir.map(|v| v * s[0] / n), dir.map(|v| v * s[1] / n))
}
/// The gizmo arrow under screen point `p`.
fn axis_at(hits: &[(LayerId, [Pos2; 2], [f64; 3], [f64; 3])], p: Pos2) -> Option<(LayerId, [Pos2; 2], [f64; 3], [f64; 3])> {
let dist = |[a, b]: [Pos2; 2]| {
let ab = b - a;
let t = ((p - a).dot(ab) / ab.length_sq().max(1e-6)).clamp(0.0, 1.0);
p.distance(a + ab * t)
};
hits.iter().filter(|h| dist(h.1) < 6.0).min_by(|a, b| dist(a.1).total_cmp(&dist(b.1))).copied()
}
/// World to parent space for a 3D layer (identity without a parent).
fn parent_world_inverse(ctx: &EvalCtx, l: &Layer) -> effectcraft_engine::geom::Mat4 {
match l.parent.and_then(|p| ctx.comp.layer(p)) {
Some(p) => ctx.world_matrix(p).inverse().unwrap_or_default(),
None => effectcraft_engine::geom::Mat4::IDENTITY,
}
}
/// Parent-space position change per comp pixel of drag (x and y) for a layer: 2D layers map
/// through the parent transform; 3D layers move in the view plane at their depth.
fn drag_axes(ctx: &EvalCtx, l: &Layer) -> ([f64; 3], [f64; 3]) {
@@ -2063,10 +2112,7 @@ fn drag_axes(ctx: &EvalCtx, l: &Layer) -> ([f64; 3], [f64; 3]) {
let anchor = l.transform().map(|tr| ctx.v3(l, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]);
let wp = ctx.world_matrix(l).apply(anchor.into());
let k = if cam.ortho { 1.0 / cam.zoom.max(1e-9) } else { cam.depth(wp).max(1.0) / cam.zoom.max(1e-9) };
let pinv = match l.parent.and_then(|p| ctx.comp.layer(p)) {
Some(p) => ctx.world_matrix(p).inverse().unwrap_or_default(),
None => effectcraft_engine::geom::Mat4::IDENTITY,
};
let pinv = parent_world_inverse(ctx, l);
let a = pinv.apply_vec(cam.right() * k);
let b = pinv.apply_vec(cam.down() * k);
([a.x, a.y, a.z], [b.x, b.y, b.z])
@@ -2842,6 +2888,15 @@ mod tests {
assert!(project_clipped(&m, &box_corners([-50.0, -400.0, 50.0, -200.0])).is_empty(), "wholly behind");
}
#[test]
fn axis_drag_follows_the_arrow_and_ignores_drag_across_it() {
let (ax, ay) = axis_drag([0.0, 0.0, 60.0], [24.0, -24.0]);
let at = |d: [f64; 2]| [0, 1, 2].map(|i| ax[i] * d[0] + ay[i] * d[1]);
assert_eq!(at([24.0, -24.0]), [0.0, 0.0, 60.0]);
assert_eq!(at([-12.0, 12.0]), [0.0, 0.0, -30.0]);
assert_eq!(at([10.0, 10.0]), [0.0, 0.0, 0.0]);
}
#[test]
fn gizmo_segments_clip_at_the_near_plane() {
// w = 100 + z.
+84
View File
@@ -265,3 +265,87 @@ fn each_layout_pane_chooses_its_own_view() {
settle(&mut h);
assert!(present(&h, "activeCamera") && !has_camera(&h, "activeCamera"));
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: egui::Pos2, to: egui::Pos2) {
h.event(egui::Event::PointerMoved(from));
h.step();
h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.step();
for k in 1..=8 {
h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 8.0)));
h.step();
}
h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(3);
}
/// A selected 3D solid (`w` x `h`) in an empty 640x360 comp seen by the default camera.
fn gizmo_harness(w: u32, h: u32) -> (Harness<'static, EffectcraftApp>, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap();
let solid = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": w, "height": h})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layer": solid, "switch": "threeD", "value": true})).unwrap();
s.execute("layer.select", json!({"layers": [solid]})).unwrap();
let mut harness = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
settle(&mut harness);
(harness, solid)
}
fn transform(h: &mut Harness<'_, EffectcraftApp>, layer: u64, prop: &str) -> Vec<f64> {
let v = h.state_mut().session.execute("prop.get", json!({"layer": layer, "path": format!("transform/{prop}")})).unwrap()["value"].clone();
serde_json::from_value(v).unwrap()
}
/// The centre of the automation element `id`.
fn centre(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Pos2 {
let [x, y, w, hh] = h.state().auto.find(id).unwrap_or_else(|| panic!("{id} is registered")).rect;
egui::pos2(x + w / 2.0, y + hh / 2.0)
}
/// #1618: the gizmo's arrows drag a 3D layer along one axis. Z, which points at the default
/// camera, still has a grabbable arrow and pushes the layer in depth, as one undo step.
#[test]
fn gizmo_arrows_move_a_3d_layer_along_one_axis() {
let (mut h, solid) = gizmo_harness(200, 200);
let p0 = transform(&mut h, solid, "position");
let z = centre(&h, &format!("viewer.gizmo.{solid}.z"));
drag(&mut h, z, z + egui::vec2(30.0, -30.0));
let p1 = transform(&mut h, solid, "position");
assert_eq!(&p1[..2], &p0[..2], "x and y stay put");
assert!(p1[2] > p0[2] + 10.0, "pushed back in depth: {p0:?} -> {p1:?}");
settle(&mut h);
let x = centre(&h, &format!("viewer.gizmo.{solid}.x"));
drag(&mut h, x, x + egui::vec2(40.0, 25.0));
let p2 = transform(&mut h, solid, "position");
assert!(p2[0] > p1[0] + 10.0, "moved along x: {p1:?} -> {p2:?}");
assert_eq!(&p2[1..], &p1[1..], "only x changes");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(transform(&mut h, solid, "position"), p1, "the x drag is one undo step");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(transform(&mut h, solid, "position"), p0, "the z drag is one undo step");
}
/// #1627 review: an arrow drawn over a box handle is what the pointer grabs (it scaled the
/// layer). At 100% a 120 px wide layer has its right-edge handle under the X arrow's tip.
#[test]
fn a_gizmo_arrow_wins_over_the_box_handle_under_it() {
let (mut h, solid) = gizmo_harness(120, 200);
h.state_mut().ui.viewer.zoom = Some(1.0);
settle(&mut h);
let x = centre(&h, &format!("viewer.gizmo.{solid}.x"));
assert!(x.distance(centre(&h, &format!("viewer.handle.{solid}.5"))) < 2.0, "the right-edge handle is under the X tip");
h.event(egui::Event::PointerMoved(x));
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move, "hover shows the move cursor, not resize");
let p0 = transform(&mut h, solid, "position");
drag(&mut h, x, x + egui::vec2(30.0, 0.0));
let p1 = transform(&mut h, solid, "position");
assert_eq!(transform(&mut h, solid, "scale"), vec![100.0, 100.0, 100.0], "not scaled");
assert!((p1[0] - p0[0] - 30.0).abs() < 1e-6, "moved 30 px along x: {p0:?} -> {p1:?}");
assert_eq!(&p1[1..], &p0[1..]);
}