Merge pull request #79 from justjohn12345/pr/layer-corner-scaling

graphics: all eight layer handles scale towards the pointer
This commit is contained in:
Brandon Thomas 2026-10-07 05:57:19 -04:00 committed by GitHub
commit 2bfcd512c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 219 additions and 20 deletions

View File

@ -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 => {

View File

@ -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:?}");
}