Merge pull request #79 from justjohn12345/pr/layer-corner-scaling
graphics: all eight layer handles scale towards the pointer
This commit is contained in:
commit
2bfcd512c2
|
|
@ -8,7 +8,8 @@
|
|||
//! Shift-selection, ⌘A, ⌘C/⌘X/⌘V, Return (new line) and Esc (stop editing) work as in a text
|
||||
//! field. Double-clicking a text layer with the Selection tool also edits it.
|
||||
//! - **Selection tool:** click selects a layer (bounding box with handles); drag moves it; drag a
|
||||
//! corner handle scales it.
|
||||
//! handle scales it towards the pointer with the opposite side held in place (a corner scales
|
||||
//! both axes alike, an edge handle only its own).
|
||||
//! - **Rectangle / Ellipse tools:** drag out a shape. **Pen tool:** click points; click the first
|
||||
//! point again (or press Return) to close the path; Esc cancels.
|
||||
//!
|
||||
|
|
@ -150,8 +151,8 @@ fn next_boundary(s: &str, i: usize) -> usize {
|
|||
#[derive(Clone, Copy, Debug)]
|
||||
enum DragKind {
|
||||
Move,
|
||||
/// A corner handle: scales about the layer's anchor.
|
||||
Scale,
|
||||
/// Handle `n` of the layer's box (see [`handle_points`]): scales it with the opposite side held in place.
|
||||
Scale(usize),
|
||||
NewShape,
|
||||
TextSelect,
|
||||
}
|
||||
|
|
@ -164,6 +165,54 @@ struct DragState {
|
|||
start: Pos2,
|
||||
}
|
||||
|
||||
/// The eight handles of a layer's box `quad` (TL, TR, BR, BL): the corners (0–3), then the middles
|
||||
/// of the top, right, bottom and left edges (4–7).
|
||||
fn handle_points(quad: &[Pos2; 4]) -> [Pos2; 8] {
|
||||
let mid = |i: usize| quad[i] + (quad[(i + 1) % 4] - quad[i]) * 0.5;
|
||||
[quad[0], quad[1], quad[2], quad[3], mid(0), mid(1), mid(2), mid(3)]
|
||||
}
|
||||
|
||||
/// Dragging handle `n` of `quad` from `start` to `cur`: the box grows towards the pointer while
|
||||
/// the opposite corner / edge stays put. A corner scales both axes alike; an edge only its own.
|
||||
struct HandleScale {
|
||||
/// The handle opposite the dragged one, which does not move.
|
||||
pin: Pos2,
|
||||
/// Unit vectors along the box's width and height on screen.
|
||||
ux: egui::Vec2,
|
||||
uy: egui::Vec2,
|
||||
fx: f32,
|
||||
fy: f32,
|
||||
}
|
||||
|
||||
impl HandleScale {
|
||||
fn new(quad: &[Pos2; 4], n: usize, start: Pos2, cur: Pos2) -> Self {
|
||||
let pts = handle_points(quad);
|
||||
let n = n.min(7);
|
||||
let pin = if n < 4 { pts[(n + 2) % 4] } else { pts[4 + (n - 4 + 2) % 4] };
|
||||
let (ux, uy) = ((quad[1] - quad[0]).normalized(), (quad[3] - quad[0]).normalized());
|
||||
// the factor along `dir`: how far the pointer is from the pinned side, relative to the start
|
||||
let along = |dir: egui::Vec2| {
|
||||
let from = (start - pin).dot(dir);
|
||||
if from.abs() < 1.0 { 1.0 } else { ((cur - pin).dot(dir) / from).max(0.01) }
|
||||
};
|
||||
let (fx, fy) = match n {
|
||||
0..=3 => {
|
||||
let f = along((start - pin).normalized());
|
||||
(f, f)
|
||||
}
|
||||
4 | 6 => (1.0, along(uy)),
|
||||
_ => (along(ux), 1.0),
|
||||
};
|
||||
Self { pin, ux, uy, fx, fy }
|
||||
}
|
||||
|
||||
/// Where a screen point of the layer ends up.
|
||||
fn apply(&self, p: Pos2) -> Pos2 {
|
||||
let w = p - self.pin;
|
||||
self.pin + self.ux * (w.dot(self.ux) * self.fx) + self.uy * (w.dot(self.uy) * self.fy)
|
||||
}
|
||||
}
|
||||
|
||||
const TEXT_EDIT_ID: &str = "gfx-text-edit";
|
||||
|
||||
fn end_edit(app: &mut FilmcraftApp, ui: &egui::Ui) {
|
||||
|
|
@ -203,9 +252,10 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
|||
painter.rect_stroke(hr, 0.0, Stroke::new(1.0, accent), StrokeKind::Middle);
|
||||
app.auto.add(&format!("program.layer.{}.{}.handle.{n}", v.clip.0, v.layer), hr.expand(3.0), "scale handle");
|
||||
}
|
||||
for i in 0..4 {
|
||||
let m = q[i] + (q[(i + 1) % 4] - q[i]) * 0.5;
|
||||
painter.rect_filled(Rect::from_center_size(m, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
|
||||
for (n, m) in handle_points(&q).iter().enumerate().skip(4) {
|
||||
let hr = Rect::from_center_size(*m, vec2(5.0, 5.0));
|
||||
painter.rect_filled(hr, 0.0, Color32::WHITE);
|
||||
app.auto.add(&format!("program.layer.{}.{}.handle.{n}", v.clip.0, v.layer), hr.expand(4.0), "scale handle");
|
||||
}
|
||||
// anchor point
|
||||
let a = sp(&v.to_screen, v.spec.transform.anchor.x as f32, v.spec.transform.anchor.y as f32);
|
||||
|
|
@ -276,9 +326,9 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
|||
let handle = views
|
||||
.iter()
|
||||
.filter(|v| Some(v.clip) == sel_clip && sel_layers.contains(&v.layer))
|
||||
.find_map(|v| v.quad().iter().position(|c| (*c - p).length() <= 7.0).map(|_| (v.clip, v.layer)));
|
||||
if let Some((c, l)) = handle {
|
||||
drag = Some(DragState { kind: DragKind::Scale, clip: c, layer: l, start: p });
|
||||
.find_map(|v| handle_points(&v.quad()).iter().position(|c| (*c - p).length() <= 7.0).map(|n| (v.clip, v.layer, n)));
|
||||
if let Some((c, l, n)) = handle {
|
||||
drag = Some(DragState { kind: DragKind::Scale(n), clip: c, layer: l, start: p });
|
||||
} else if tool == Tool::Selection
|
||||
&& let Some(v) = views.iter().find(|v| v.hit(p))
|
||||
{
|
||||
|
|
@ -324,11 +374,10 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
|||
crate::panels::monitor_view::draw_snap_lines(&painter, pic, &snap_off.1);
|
||||
}
|
||||
}
|
||||
DragKind::Scale => {
|
||||
DragKind::Scale(n) => {
|
||||
if let Some(v) = v {
|
||||
let a = sp(&v.to_screen, v.spec.transform.anchor.x as f32, v.spec.transform.anchor.y as f32);
|
||||
let f = ((cur - a).length() / (d.start - a).length().max(1.0)).max(0.01);
|
||||
painter.add(egui::Shape::closed_line(v.quad().iter().map(|q| a + (*q - a) * f).collect(), Stroke::new(1.0, Color32::WHITE)));
|
||||
let h = HandleScale::new(&v.quad(), n, d.start, cur);
|
||||
painter.add(egui::Shape::closed_line(v.quad().iter().map(|q| h.apply(*q)).collect(), Stroke::new(1.0, Color32::WHITE)));
|
||||
}
|
||||
}
|
||||
DragKind::NewShape => {
|
||||
|
|
@ -367,15 +416,25 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
|||
));
|
||||
}
|
||||
}
|
||||
DragKind::Scale if moved => {
|
||||
DragKind::Scale(n) if moved => {
|
||||
if let Some(v) = v {
|
||||
// the layer scales about its anchor, so it also moves to keep the pinned side still
|
||||
let a = sp(&v.to_screen, v.spec.transform.anchor.x as f32, v.spec.transform.anchor.y as f32);
|
||||
let f = ((cur - a).length() / (d.start - a).length().max(1.0)).max(0.01) as f64;
|
||||
let s = v.spec.transform.scale;
|
||||
actions.push((
|
||||
"graphics.set".into(),
|
||||
json!({"clip": v.clip.0, "layer": v.layer, "props": {"scale": s.y * 100.0 * f, "scale_width": s.x * 100.0 * f}}),
|
||||
));
|
||||
let h = HandleScale::new(&v.quad(), n, d.start, cur);
|
||||
let off = h.apply(a) - a;
|
||||
let lin = Affine { e: 0.0, f: 0.0, ..v.canvas_to_screen };
|
||||
let delta = lin.inverse().map(|i| i.apply(Vec2::new(off.x as f64, off.y as f64))).unwrap_or_default();
|
||||
let (s, p0) = (v.spec.transform.scale, v.spec.transform.position);
|
||||
let mut props = json!({
|
||||
"scale": s.y * 100.0 * h.fy as f64,
|
||||
"scale_width": s.x * 100.0 * h.fx as f64,
|
||||
"position": [p0.x + delta.x, p0.y + delta.y],
|
||||
});
|
||||
if n >= 4 {
|
||||
// an edge stretches one axis only
|
||||
props["uniform_scale"] = json!(false);
|
||||
}
|
||||
actions.push(("graphics.set".into(), json!({"clip": v.clip.0, "layer": v.layer, "props": props})));
|
||||
}
|
||||
}
|
||||
DragKind::NewShape if moved => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
//! Headless UI tests of handle scaling of graphic layers in the Program monitor.
|
||||
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use filmcraft_ui_egui::control::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Driver {
|
||||
harness: Harness<'static, FilmcraftApp>,
|
||||
tx: Sender<ControlRequest>,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
fn new() -> Self {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let (tx, rx) = channel();
|
||||
let app = FilmcraftApp::new(s).with_control(rx);
|
||||
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app);
|
||||
let mut d = Driver { harness, tx };
|
||||
d.frames(4);
|
||||
d
|
||||
}
|
||||
|
||||
fn frames(&mut self, n: usize) {
|
||||
for _ in 0..n {
|
||||
// the control channel's clicks and keys enter through the input hook
|
||||
let ctx = self.harness.ctx.clone();
|
||||
let mut raw = std::mem::take(self.harness.input_mut());
|
||||
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
|
||||
*self.harness.input_mut() = raw;
|
||||
self.harness.step();
|
||||
}
|
||||
}
|
||||
|
||||
fn call(&mut self, method: &str, params: Value) -> Value {
|
||||
let (req, reply) = ControlRequest::new(method, params.clone());
|
||||
self.tx.send(req).unwrap();
|
||||
for _ in 0..600 {
|
||||
self.frames(1);
|
||||
if let Ok(v) = reply.try_recv() {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
panic!("no reply to {method} {params}");
|
||||
}
|
||||
|
||||
fn ok(&mut self, method: &str, params: Value) -> Value {
|
||||
let v = self.call(method, params.clone());
|
||||
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
|
||||
v["result"].clone()
|
||||
}
|
||||
|
||||
fn exec(&mut self, command: &str, params: Value) -> Value {
|
||||
self.ok("engine.execute", json!({"command": command, "params": params}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
fn rect(&mut self, id: &str) -> [f64; 4] {
|
||||
let v = self.ok("ui.elements", json!({"prefix": id}));
|
||||
let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap_or_else(|| panic!("no element {id}")).clone();
|
||||
let r = &e["rect"];
|
||||
[r[0].as_f64().unwrap(), r[1].as_f64().unwrap(), r[2].as_f64().unwrap(), r[3].as_f64().unwrap()]
|
||||
}
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
/// A selected text layer; returns the id prefix of its handles (0–3 corners TL, TR, BR, BL;
|
||||
/// 4–7 the top, right, bottom and left edges).
|
||||
fn text_layer(&mut self) -> String {
|
||||
let r = self.exec("graphics.newText", json!({"text": "Title", "position": [600, 500], "size": 120}));
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
self.exec("graphics.selectLayer", json!({"clip": clip, "layers": [0]}));
|
||||
self.ok("ui.set", json!({"tool": "Selection"}));
|
||||
self.frames(4);
|
||||
format!("program.layer.{clip}.0.handle.")
|
||||
}
|
||||
|
||||
/// Centre of handle `n`.
|
||||
fn handle(&mut self, pre: &str, n: usize) -> (f64, f64) {
|
||||
let r = self.rect(&format!("{pre}{n}"));
|
||||
(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)
|
||||
}
|
||||
|
||||
fn drag_handle(&mut self, pre: &str, n: usize, dx: f64, dy: f64) {
|
||||
let (x, y) = self.handle(pre, n);
|
||||
self.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + dx, "y": y + dy}}));
|
||||
self.frames(4);
|
||||
}
|
||||
}
|
||||
|
||||
fn near(a: (f64, f64), b: (f64, f64)) -> bool {
|
||||
(a.0 - b.0).abs() < 1.5 && (a.1 - b.1).abs() < 1.5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_handles_scale_away_from_the_opposite_corner() {
|
||||
let mut d = Driver::new();
|
||||
let pre = d.text_layer();
|
||||
// bottom-right: grows down and to the right, the top-left stays
|
||||
let (tl, br) = (d.handle(&pre, 0), d.handle(&pre, 2));
|
||||
d.drag_handle(&pre, 2, 60.0, 30.0);
|
||||
let (tl2, br2) = (d.handle(&pre, 0), d.handle(&pre, 2));
|
||||
assert!(near(tl, tl2), "top-left stays put: {tl:?} -> {tl2:?}");
|
||||
assert!(br2.0 > br.0 + 20.0 && br2.1 > br.1 + 5.0, "bottom-right follows the pointer: {br:?} -> {br2:?}");
|
||||
// bottom-left: grows down and to the left, the top-right stays
|
||||
let (tr, bl) = (d.handle(&pre, 1), d.handle(&pre, 3));
|
||||
d.drag_handle(&pre, 3, -60.0, 30.0);
|
||||
let (tr2, bl2) = (d.handle(&pre, 1), d.handle(&pre, 3));
|
||||
assert!(near(tr, tr2), "top-right stays put: {tr:?} -> {tr2:?}");
|
||||
assert!(bl2.0 < bl.0 - 20.0 && bl2.1 > bl.1 + 5.0, "bottom-left follows the pointer: {bl:?} -> {bl2:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_handles_stretch_one_axis() {
|
||||
let mut d = Driver::new();
|
||||
let pre = d.text_layer();
|
||||
// bottom edge: taller downwards, the top edge and the width stay
|
||||
let (tl, tr, bottom) = (d.handle(&pre, 0), d.handle(&pre, 1), d.handle(&pre, 6));
|
||||
d.drag_handle(&pre, 6, 0.0, 40.0);
|
||||
let (tl2, tr2, bottom2) = (d.handle(&pre, 0), d.handle(&pre, 1), d.handle(&pre, 6));
|
||||
assert!(near(tl, tl2) && near(tr, tr2), "top edge stays put: {tl:?} {tr:?} -> {tl2:?} {tr2:?}");
|
||||
assert!((bottom2.1 - bottom.1 - 40.0).abs() < 2.0 && (bottom2.0 - bottom.0).abs() < 1.5, "bottom follows the pointer: {bottom:?} -> {bottom2:?}");
|
||||
// right edge: wider to the right, the left edge and the height stay
|
||||
let (tl, bl, right) = (d.handle(&pre, 0), d.handle(&pre, 3), d.handle(&pre, 5));
|
||||
d.drag_handle(&pre, 5, 50.0, 0.0);
|
||||
let (tl2, bl2, right2) = (d.handle(&pre, 0), d.handle(&pre, 3), d.handle(&pre, 5));
|
||||
assert!(near(tl, tl2) && near(bl, bl2), "left edge stays put: {tl:?} {bl:?} -> {tl2:?} {bl2:?}");
|
||||
assert!((right2.0 - right.0 - 50.0).abs() < 2.0 && (right2.1 - right.1).abs() < 1.5, "right follows the pointer: {right:?} -> {right2:?}");
|
||||
// top edge: taller upwards, the bottom edge stays
|
||||
let (bl, top) = (d.handle(&pre, 3), d.handle(&pre, 4));
|
||||
d.drag_handle(&pre, 4, 0.0, -30.0);
|
||||
let (bl2, top2) = (d.handle(&pre, 3), d.handle(&pre, 4));
|
||||
assert!(near(bl, bl2), "bottom edge stays put: {bl:?} -> {bl2:?}");
|
||||
assert!((top2.1 - top.1 + 30.0).abs() < 2.0, "top follows the pointer: {top:?} -> {top2:?}");
|
||||
}
|
||||
Loading…
Reference in New Issue