diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index d847edb85..b0382d07b 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -156,6 +156,32 @@ thread_local! { static VIEW_CAM: std::cell::Cell> = const { std::cell::Cell::new(None) }; } +/// Region of interest handles on screen: corners (0–3, clockwise from top-left) then edge +/// midpoints (4 top, 5 right, 6 bottom, 7 left). +fn roi_handles(r: Rect) -> [Pos2; 8] { + [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.center_top(), r.right_center(), r.center_bottom(), r.left_center()] +} + +/// A drag starting on a region of interest handle: the ROI redrawn from the opposite corner (or +/// edge, with the other axis kept). +fn roi_drag(app: &EffectcraftApp, map: &ViewerMap, comp_rect: Rect, zoom: f32, press: Pos2) -> Option { + let [rx, ry, rw, rh] = app.session.state.region_of_interest?; + let r = Rect::from_min_size(comp_rect.min + vec2(rx as f32 * zoom, ry as f32 * zoom), vec2(rw as f32 * zoom, rh as f32 * zoom)); + let i = roi_handles(r).iter().position(|h| h.distance(press) < 6.0)?; + let (x0, y0, x1, y1) = (rx, ry, rx + rw, ry + rh); + let _ = map; + Some(match i { + 0 => ov::Drag::Roi { start: [x1, y1], keep: [None, None] }, + 1 => ov::Drag::Roi { start: [x0, y1], keep: [None, None] }, + 2 => ov::Drag::Roi { start: [x0, y0], keep: [None, None] }, + 3 => ov::Drag::Roi { start: [x1, y0], keep: [None, None] }, + 4 => ov::Drag::Roi { start: [x1, y1], keep: [Some(x0), None] }, + 5 => ov::Drag::Roi { start: [x0, y0], keep: [None, Some(y1)] }, + 6 => ov::Drag::Roi { start: [x0, y0], keep: [Some(x1), None] }, + _ => ov::Drag::Roi { start: [x1, y0], keep: [None, Some(y1)] }, + }) +} + /// The camera the viewer shows (view camera or the comp's active camera). fn cam_state(ctx: &EvalCtx) -> CameraState { VIEW_CAM.with(|c| c.get()).unwrap_or_else(|| three_d::active_camera(ctx)) @@ -464,11 +490,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some([rx, ry, rw, rh]) = app.session.state.region_of_interest { let r = Rect::from_min_size(comp_rect.min + vec2(rx as f32 * zoom, ry as f32 * zoom), vec2(rw as f32 * zoom, rh as f32 * zoom)); painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle); - for c in [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] { - painter.rect_filled(Rect::from_center_size(c, vec2(5.0, 5.0)), 0.0, Color32::WHITE); + // Resize handles: corners and edge midpoints (drag with any tool). + for (i, c) in roi_handles(r).iter().enumerate() { + painter.rect_filled(Rect::from_center_size(*c, vec2(5.0, 5.0)), 0.0, Color32::WHITE); + app.auto.add(&format!("viewer.regionOfInterest.handle.{i}"), Rect::from_center_size(*c, vec2(8.0, 8.0)), "Resize the region of interest"); } app.auto.add("viewer.regionOfInterest", r, "Region of interest"); } + // 3D Reference Axes (grid and guide options): the world axes as seen by the view's camera. + if app.session.prefs.three_d.show_reference_axes && comp.layers.iter().any(|l| l.is_3d()) { + let cam = cam_state(&ectx); + let o = pos2(area.min.x + 40.0, area.max.y - 40.0); + for (axis, col, name) in [ + (effectcraft_engine::geom::vec3(1.0, 0.0, 0.0), Color32::from_rgb(0xe0, 0x40, 0x40), "X"), + (effectcraft_engine::geom::vec3(0.0, 1.0, 0.0), Color32::from_rgb(0x50, 0xd0, 0x50), "Y"), + (effectcraft_engine::geom::vec3(0.0, 0.0, 1.0), Color32::from_rgb(0x50, 0x80, 0xf0), "Z"), + ] { + let v = cam.view.apply_vec(axis); + let tip = o + vec2(v.x as f32, v.y as f32) * 26.0; + painter.line_segment([o, tip], Stroke::new(2.0, col)); + painter.text(tip + (tip - o).normalized() * 7.0, Align2::CENTER_CENTER, name, Tokens::ui(10.0), col); + } + app.auto.add("viewer.referenceAxes", Rect::from_center_size(o, vec2(64.0, 64.0)), "3D Reference Axes"); + } // Overlays + interaction. let selected = app.session.state.selected_layers.clone(); @@ -699,7 +743,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let g = match tool { Tool::Hand => Some(Gesture::Pan { start_pan: app.ui.viewer.pan }), // Region of Interest armed: the drag draws it. - _ if app.ui.viewer.roi_draw => Some(Gesture::Overlay(ov::Drag::Roi { start: map.to_comp(press) })), + _ if app.ui.viewer.roi_draw => Some(Gesture::Overlay(ov::Drag::Roi { start: map.to_comp(press), keep: [None, None] })), + // A region of interest handle: resize it from the opposite corner / edge. + _ if roi_drag(app, &map, comp_rect, zoom, press).is_some() => roi_drag(app, &map, comp_rect, zoom, press).map(Gesture::Overlay), Tool::Selection if ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).is_some() => { ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).map(Gesture::Overlay) } @@ -738,7 +784,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { layer: l, start_anchor: ectx.v3(layer, tr, "anchor", [0.0; 3]), start_pos: ectx.v3(layer, tr, "position", [0.0; 3]), - start: cpt, + // From where the button went down (the drag starts a few points later). + start: map.to_comp(press), inv: l2c.inverse().unwrap_or(Mat3::IDENTITY), l2p, }) @@ -910,6 +957,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge})); } Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p } => { + // Pan Behind snaps the anchor point (to layer features, guides and the grid). + let anchor_c = inv.inverse().map(|m| m.apply(gv2(start_anchor[0], start_anchor[1]))).unwrap_or(gv2(start[0], start[1])); + let want = [anchor_c.x + cpt[0] - start[0], anchor_c.y + cpt[1] - start[1]]; + let sc = vt::snap(app, &ctx, &ectx, &map, &[layer], &[want], mods); + let cpt = [cpt[0] + sc[0], cpt[1] + sc[1]]; let a = inv.apply(gv2(start[0], start[1])); let b = inv.apply(gv2(cpt[0], cpt[1])); let dl = gv2(b.x - a.x, b.y - a.y); @@ -954,8 +1006,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let pos_v = if vertical { cpt[0] } else { cpt[1] }.round(); let _ = app.session.execute("view.moveGuide", json!({"index": index, "position": pos_v, "merge": merge})); } - ov::Drag::Roi { start } => { - let r = Rect::from_two_pos(map.to_screen(start), pos); + ov::Drag::Roi { start, keep } => { + let e = [keep[0].unwrap_or(cpt[0]), keep[1].unwrap_or(cpt[1])]; + let r = Rect::from_two_pos(map.to_screen(start), map.to_screen(e)); painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle); } d => { @@ -1011,8 +1064,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let end = map.to_comp(pos); create_shape(app, tool, start, end, mods.shift); } - Gesture::Overlay(ov::Drag::Roi { start }) => { - let end = map.to_comp(pos); + Gesture::Overlay(ov::Drag::Roi { start, keep }) => { + let c = map.to_comp(pos); + let end = [keep[0].unwrap_or(c[0]), keep[1].unwrap_or(c[1])]; let (x0, y0) = (start[0].min(end[0]).max(0.0), start[1].min(end[1]).max(0.0)); let (x1, y1) = (start[0].max(end[0]).min(comp.width as f64), start[1].max(end[1]).min(comp.height as f64)); if x1 - x0 >= 2.0 && y1 - y0 >= 2.0 { diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 38358c011..5424ed83f 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -433,8 +433,8 @@ pub(crate) enum Drag { Feather { layer: LayerId, mask: u64, index: usize, inv: Mat3 }, /// A guide dragged in the viewer. Guide { index: usize, vertical: bool }, - /// Drawing the region of interest. - Roi { start: [f64; 2] }, + /// Drawing the region of interest from `start`; `keep` pins the end's x / y (edge handles). + Roi { start: [f64; 2], keep: [Option; 2] }, } /// Start a motion-path drag at `press` (keys before tangents), selecting the key. diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 79688e193..cea240f84 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -569,6 +569,8 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, ("Grid".to_string(), v.grid), ("Guides".to_string(), v.guides), ("Rulers".to_string(), v.rulers), + ("-".to_string(), false), + ("3D Reference Axes".to_string(), app.session.prefs.three_d.show_reference_axes), ]; if let Some(i) = popup(app, ui, "vw-grid-pop", gr, &items, "gridItem") { let v = &mut app.ui.viewer; @@ -577,7 +579,9 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, 1 => v.proportional_grid = !v.proportional_grid, 2 => v.grid = !v.grid, 3 => v.guides = !v.guides, - _ => v.rulers = !v.rulers, + 4 => v.rulers = !v.rulers, + 6 => app.session.prefs.three_d.show_reference_axes = !app.session.prefs.three_d.show_reference_axes, + _ => {} } } // Show Channel and Color Management Settings. diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index bdab3d929..157c5a3a3 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -333,3 +333,70 @@ fn mask_feather_tool_adds_and_drags_feather_points() { let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap(); assert!(pts["points"][0]["radius"].as_f64().unwrap() > 0.0); } + +#[test] +fn region_of_interest_resizes_by_its_handles() { + let mut h = harness(); + h.state_mut().session.execute("view.setRegionOfInterest", json!({"rect": [100, 50, 200, 200]})).unwrap(); + h.run_steps(3); + // Bottom-right corner (handle 2) to (400, 300): the top-left stays. + let c = rect(&h, "viewer.regionOfInterest.handle.2").center(); + let to = screen(&h, [400.0, 300.0]); + drag(&mut h, c, to); + let r = h.state().session.state.region_of_interest.expect("roi"); + assert!((r[0] - 100.0).abs() <= 1.0 && (r[1] - 50.0).abs() <= 1.0 && (r[2] - 300.0).abs() <= 2.0 && (r[3] - 250.0).abs() <= 2.0, "{r:?}"); + // The left edge (handle 7) only moves x. + h.run_steps(2); + let c = rect(&h, "viewer.regionOfInterest.handle.7").center(); + let to = pos2(screen(&h, [150.0, 0.0]).x, c.y + 40.0); + drag(&mut h, c, to); + let r = h.state().session.state.region_of_interest.expect("roi"); + assert!((r[0] - 150.0).abs() <= 1.0 && (r[1] - 50.0).abs() <= 1.0 && (r[2] - 250.0).abs() <= 2.0 && (r[3] - 250.0).abs() <= 2.0, "{r:?}"); +} + +#[test] +fn pan_behind_snaps_the_anchor_point() { + let mut h = harness(); + let box_id = h.state().session.active_comp().unwrap().layers[0].id; + h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0]})).unwrap(); + h.state_mut().ui.tool = Tool::PanBehind; + h.run_steps(2); + // Drag the anchor (at the box centre) to 3 px from the comp centre: it snaps there, and the + // position follows so the box doesn't move. + let from = screen(&h, [100.0, 100.0]); + let to = screen(&h, [323.0, 182.0]); + // (The gesture applies the pointer of the previous frame: hold the end point a frame.) + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=10 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i.min(8) as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone(); + let p = l.props.prop("transform/position").unwrap().value.as_vec3(); + let a = l.props.prop("transform/anchor").unwrap().value.as_vec3(); + assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "anchor point snapped to the comp centre: {p:?}"); + assert!((a[0] - 260.0).abs() < 0.01 && (a[1] - 120.0).abs() < 0.01, "{a:?}"); +} + +#[test] +fn reference_axes_toggle_from_the_grid_menu() { + let mut h = harness(); + // A 3D layer makes the axes relevant. + let box_id = h.state().session.active_comp().unwrap().layers[0].id; + h.state_mut().session.execute("layer.setSwitch", json!({"layers": [box_id.0], "switch": "threeD", "value": true})).unwrap(); + h.run_steps(3); + assert!(h.state().auto.find("viewer.referenceAxes").is_some(), "on by default (Settings ▸ 3D)"); + let g = rect(&h, "viewer.grid").center(); + click(&mut h, g); + h.run_steps(2); + let item = rect(&h, "viewer.gridItem.6").center(); + click(&mut h, item); + h.run_steps(3); + assert!(!h.state().session.prefs.three_d.show_reference_axes); + assert!(h.state().auto.find("viewer.referenceAxes").is_none()); +}