#853: Shift constrains pen mask segments to 45° steps

Problem: the pen mask tool ignored Shift, so straight horizontal,
vertical or diagonal mask edges could not be drawn precisely (Premiere
26.0 constrains mask lines with Shift). Separately, path_from_json
accepted the `{"p","in","out"}` vertex form through serde first, which
silently dropped the `in`/`out` tangents, so a pen mask's dragged-out
curves were committed as corners.

Fix: with Shift held, a new pen point snaps onto the nearest 45° ray
from the previous point (in screen space), the rubber-band preview
follows, and a Shift-drag constrains the Bézier handle it pulls out.
path_from_json now parses the vertex forms before falling back to the
serialized MaskPath.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-11 02:09:49 +02:00
parent 2ff3f5e266
commit 1edcaa4e78
5 changed files with 115 additions and 8 deletions

View File

@ -91,9 +91,12 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
/// A mask path from JSON: the serialized [`MaskPath`] object, an array of vertices
/// (`{"p":[x,y],"in":[x,y]?,"out":[x,y]?}`), or an array of `[x, y]` / `{"x","y"}` points.
pub fn path_from_json(v: &Value) -> Option<MaskPath> {
if let Ok(p) = serde_json::from_value::<MaskPath>(v.clone()) {
return Some(p);
}
// The vertex forms first: serde also accepts `{"p","in","out"}` vertices (the `masks.list` /
// `masks.add` form) but silently drops the unknown `in` / `out` tangents.
vertex_path(v).or_else(|| serde_json::from_value::<MaskPath>(v.clone()).ok())
}
fn vertex_path(v: &Value) -> Option<MaskPath> {
let arr = v.as_array().or_else(|| v.get("vertices").and_then(Value::as_array))?;
let closed = v.get("closed").and_then(Value::as_bool).unwrap_or(true);
let mut vertices = Vec::new();

View File

@ -380,3 +380,22 @@ fn rounding_an_animated_mask_rounds_every_keyframe_and_only_the_chosen_corners()
assert_eq!(v.len(), 6);
assert!((v[0]["p"][0].as_f64().unwrap() - 450.0).abs() < 1e-6, "{}", v[0]);
}
/// `path_to_json` → `path_from_json` keeps the Bézier tangents (they used to come back as zero, so
/// a pen mask's dragged-out curves were committed as corners; #853).
#[test]
fn path_json_round_trip_keeps_tangents() {
use filmcraft_geom::Vec2;
use filmcraft_project::mask::{MaskPath, MaskVertex};
let path = MaskPath {
vertices: vec![
MaskVertex { p: Vec2::new(10.0, 20.0), t_in: Vec2::new(-5.0, 0.0), t_out: Vec2::new(5.0, 0.0) },
MaskVertex::corner(Vec2::new(80.0, 20.0)),
MaskVertex { p: Vec2::new(40.0, 90.0), t_in: Vec2::new(3.0, -4.0), t_out: Vec2::new(-3.0, 4.0) },
],
closed: true,
};
assert_eq!(crate::masks::path_from_json(&crate::masks::path_to_json(&path)), Some(path.clone()));
// the serialized project form still parses
assert_eq!(crate::masks::path_from_json(&serde_json::to_value(&path).unwrap()), Some(path));
}

View File

@ -464,6 +464,12 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32
let painter = ui.painter().with_clip_rect(pic);
let to_c = |p: Pos2| to_clip.apply(Vec2::new(p.x as f64, p.y as f64));
let mut d = draft.clone();
// Shift constrains the segment from the previous point (and a dragged-out handle) to 45° steps
let shift = ui.input(|i| i.modifiers.shift);
let snap = |from: Option<&[f64; 4]>, p: Pos2| match from {
Some(q) if shift => constrain_45(sp(&to_screen, Vec2::new(q[0], q[1])), p),
_ => p,
};
let mut commit = false;
let pressed = resp.drag_started() || (resp.clicked() && !ui.data(|m| m.get_temp::<bool>(egui::Id::new("mask-pen-placed")).unwrap_or(false)));
if pressed && let Some(p) = resp.interact_pointer_pos() {
@ -471,7 +477,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32
if d.points.len() >= 3 && near_first {
commit = true;
} else {
let c = to_c(p);
let c = to_c(snap(d.points.last(), p));
d.points.push([c.x, c.y, 0.0, 0.0]);
ui.data_mut(|m| m.insert_temp(egui::Id::new("mask-pen-placed"), resp.drag_started()));
}
@ -483,7 +489,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32
&& !commit
&& let (Some(p), Some(last)) = (resp.interact_pointer_pos(), d.points.last_mut())
{
let c = to_c(p);
let c = to_c(snap(Some(last), p));
let tan = Vec2::new(c.x - last[0], c.y - last[1]);
if tan.length() > 2.0 {
last[2] = tan.x;
@ -504,7 +510,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32
painter.add(egui::Shape::line(pts, Stroke::new(1.5, col)));
}
if let (Some(h), Some(l)) = (resp.hover_pos(), d.points.last()) {
painter.line_segment([sp(&to_screen, Vec2::new(l[0], l[1])), h], Stroke::new(1.0, col.gamma_multiply(0.5)));
painter.line_segment([sp(&to_screen, Vec2::new(l[0], l[1])), snap(Some(l), h)], Stroke::new(1.0, col.gamma_multiply(0.5)));
}
for (i, q) in d.points.iter().enumerate() {
let p = sp(&to_screen, Vec2::new(q[0], q[1]));
@ -549,6 +555,19 @@ fn open_polyline(path: &MaskPath, to_screen: &Affine) -> Vec<Pos2> {
out
}
/// `to` moved onto the nearest 45° ray from `from` (horizontal, vertical or diagonal on screen),
/// keeping its distance along that ray; `to` itself when it coincides with `from` or is not finite.
fn constrain_45(from: Pos2, to: Pos2) -> Pos2 {
let v = to - from;
if !(v.x.is_finite() && v.y.is_finite()) || v.length_sq() == 0.0 {
return to;
}
let step = std::f32::consts::FRAC_PI_4;
let a = (v.y.atan2(v.x) / step).round() * step;
let dir = egui::Vec2::new(a.cos(), a.sin());
from + dir * v.dot(dir)
}
/// The Mask Path row's value area: mask tracking (backward continuously / one frame, forward one
/// frame / continuously; clicking while a track runs stops it) and the tracking-method menu.
pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effect: usize, mask: Option<usize>, actions: &mut Vec<(String, Value)>) {
@ -607,3 +626,24 @@ pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effec
ui.ctx().request_repaint();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constrain_45_snaps_to_the_nearest_eighth_turn() {
let o = pos2(10.0, 20.0);
let near = |a: Pos2, b: Pos2| (a - b).length() < 1e-3;
// nearly horizontal / vertical: the off-axis component goes
assert!(near(constrain_45(o, pos2(110.0, 30.0)), pos2(110.0, 20.0)));
assert!(near(constrain_45(o, pos2(5.0, -80.0)), pos2(10.0, -80.0)));
// nearly diagonal: projected onto the 45° ray
let d = constrain_45(o, pos2(60.0, 66.0));
assert!(((d.x - o.x) - (d.y - o.y)).abs() < 1e-3, "{d:?}");
// degenerate input comes back unchanged
assert_eq!(constrain_45(o, o), o);
let nan = pos2(f32::NAN, 1.0);
assert!(constrain_45(o, nan).x.is_nan());
}
}

View File

@ -216,6 +216,50 @@ fn create_and_edit_masks_on_the_monitor() {
d.shot("mask-tracked");
}
/// Shift held while placing pen points snaps each new segment to 45° steps from the previous
/// point; a Shift-drag also constrains the Bézier handle it pulls out (#853).
#[test]
fn shift_constrains_pen_mask_segments_to_45_degrees() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace": "Effects"}));
let seq = d.exec("sequence.inspect", json!({}));
let clip = &seq["video"][0]["items"][0];
let (id, start, dur) = (clip["clip"].as_u64().unwrap(), clip["start"].as_i64().unwrap(), clip["duration"].as_i64().unwrap());
d.exec("playhead.set", json!({"time": start + dur / 2}));
d.exec("timeline.select", json!({"clips": [id]}));
d.frames(3);
d.ok("ui.click", json!({"id": "effectControls.opacity.mask.pen"}));
d.frames(2);
let pic = d.rect("program.picture");
let at = |fx: f64, fy: f64| json!({"x": pic[0] + pic[2] * fx, "y": pic[1] + pic[3] * fy});
// hold Shift (as the OS reports a held key) across the clicks
d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT));
d.frames(1);
for (fx, fy) in [(0.3, 0.3), (0.6, 0.34), (0.63, 0.6)] {
d.ok("ui.click", at(fx, fy));
d.frames(2);
}
// a Shift-drag places the fourth point and pulls out its handle
d.ok("ui.drag", json!({"from": at(0.32, 0.62), "to": at(0.42, 0.64), "steps": 6, "modifiers": {"shift": true}}));
d.frames(2);
d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
d.frames(1);
d.ok("ui.click", at(0.3, 0.3));
d.frames(3);
let m = d.masks();
let pen = m.iter().find(|x| x["effectId"] == "opacity").unwrap_or_else(|| panic!("opacity mask: {m:?}"));
assert_eq!(pen["path"]["vertices"].as_array().unwrap().len(), 4, "{pen}");
let (p0, p1, p2, p3) = (v0(pen, 0), v0(pen, 1), v0(pen, 2), v0(pen, 3));
assert!((p1.1 - p0.1).abs() < 1e-6, "segment 0→1 horizontal: {p0:?} {p1:?}");
assert!(p1.0 > p0.0 + 50.0, "{p0:?} {p1:?}");
assert!((p2.0 - p1.0).abs() < 1e-6, "segment 1→2 vertical: {p1:?} {p2:?}");
assert!(p2.1 > p1.1 + 50.0, "{p1:?} {p2:?}");
assert!((p3.1 - p2.1).abs() < 1e-6, "segment 2→3 horizontal: {p2:?} {p3:?}");
let out = &pen["path"]["vertices"][3]["out"];
let (ox, oy) = (out[0].as_f64().unwrap(), out[1].as_f64().unwrap());
assert!(ox > 10.0 && oy.abs() < 1e-6, "handle constrained horizontal: {pen}");
}
#[test]
fn presets_bin_drag_and_save_dialog() {
let mut d = Driver::demo();

View File

@ -235,13 +235,14 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
- **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut has no face tracking
(and occlusion edges are soft), Lumetri HDR-aware maths, multicam paging beyond 16 angles,
redesigned 26.0 mask tools (constrained lines; an on-monitor corner-roundness handle).
redesigned 26.0 mask tools (Shift-constrained vertex and whole-mask drags; an on-monitor
corner-roundness handle).
- **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense
coarse-to-fine Lucas–Kanade, CPU; #780); Morph Cut morphs the subject along that optical flow
instead of cross-dissolving (CPU; #805); Transform shutter angle motion blur (#415); Ultra Key
Setting applied as a parameter preset (#458); rectangle and polygon masks round their corners
with circular fillets (`masks.add` `cornerRadius`, `masks.roundCorners`, every path keyframe;
#813).
#813); Shift snaps pen mask segments and dragged-out Bézier handles to 45° steps (#853).
- **Estimate:** 60–110 h.
## G19. Speech to text off in release builds