mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 06:27:11 -06:00
M13.5: ROI resize handles, Pan Behind snapping, 3D reference axes
Viewer: the region of interest has corner and edge handles that resize it from the opposite corner/edge; Pan Behind snaps the anchor point (layer features, guides, grid) and measures from the press point (it lagged by the drag threshold); Choose Grid and Guide Options > 3D Reference Axes toggles a world-axis triad seen through the view's camera (Settings > 3D). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b2b8e08ed7
commit
5b4e936e9d
@@ -156,6 +156,32 @@ thread_local! {
|
||||
static VIEW_CAM: std::cell::Cell<Option<CameraState>> = 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<ov::Drag> {
|
||||
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 {
|
||||
|
||||
@@ -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<f64>; 2] },
|
||||
}
|
||||
|
||||
/// Start a motion-path drag at `press` (keys before tangents), selecting the key.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user