//! On-canvas handles for the gradient masks (FR-DEV-3, FR-UI-3). //! //! A linear or radial mask could be created and then not moved: it had no //! handles, so a radial sat at the centre of the frame at its default size for //! ever. These are the first controls in the application designed to be //! dragged on the photograph rather than in a panel, and two things follow //! from that which do not apply to a slider. //! //! # The geometry is stored where the mask is, not where the pointer is //! //! A gradient's geometry is in **normalised source coordinates**, because that //! is where the mask is rasterised and it is what makes the mask survive a //! crop, a zoom, a pan and an export at another size. The pointer arrives in //! **output** coordinates — fractions of the photograph as it currently sits //! on screen. Everything here is the journey between those two, and it goes //! through [`dr_pipeline::Framing::source_at`], which is the same map the //! shader applies. Anything less — the crop alone, say — would put a handle //! on the mask at one zoom level and beside it at every other. //! //! # A drag is a displacement, not a destination //! //! Each handle answers to the *movement* of the pointer since the press, not //! to where the pointer now is. Snapping the handle to the pointer instead //! would jerk it by up to half a touch target the instant it was grabbed — //! and the target is finger-sized (FR-UI-3), so that jerk is about 20 pixels //! on a first press. The map is affine, so a displacement in output space is //! exactly a displacement in source space and nothing is lost by working this //! way. //! //! # Frame units //! //! Positions are fractions of each axis; **distances and angles are in the //! frame's isotropic units**, where y spans `0..1` and x spans `0..aspect`. //! See [`dr_pipeline::mask::MaskSource::Linear`] for why. Converting between //! the two is [`to_frame`] and [`to_uv`], and they are the only place it //! happens on this side. use std::f32::consts::FRAC_PI_2; use dr_pipeline::mask::MaskSource; use dr_pipeline::Framing; use crate::{GradientHandle, HandleRole}; /// How far the rotation handle stands off the shape it turns, in frame units. /// /// Far enough that turning is a comfortable lever and near enough that it is /// still on the photograph at a middling zoom. const ROT_ARM: f32 = 0.22; /// The closest a size handle is ever *drawn* to the centre, in frame units. /// /// A hard-edged ramp has zero width and a radial can be dragged very small, /// and a handle drawn at its true position would then sit underneath the /// centre handle where it could never be grabbed again — the size would be a /// one-way trip. So the drawn offset has a floor while the stored value does /// not. The handle lies by at most this much, and only when the shape is /// already smaller than a fingertip. const MIN_ARM: f32 = 0.06; /// The largest a gradient may be dragged, in frame units. Well past the /// diagonal of any frame, so it bounds nothing a user would do and does bound /// a drag flung off the edge of the screen. const MAX_EXTENT: f32 = 4.0; /// The smallest semi-axis a radial may be dragged to. Not zero: a radial with /// a zero axis covers nothing and looks broken rather than small. const MIN_RADIUS: f32 = 0.005; /// Where every handle for `source` currently sits, in normalised **output** /// coordinates. /// /// Empty for a mask that is not a gradient — a subject's outline is the /// model's and has nothing to drag. /// /// A position outside `0..1` is returned rather than clamped: the caller draws /// the handles inside the photograph's own rect and lets them clip, so a /// handle panned off screen is absent instead of pinned to the edge claiming /// the mask is somewhere it is not. pub(crate) fn handles( source: &MaskSource, framing: &Framing, src: (u32, u32), ) -> Vec { let aspect = aspect_of(src); points(source, aspect) .into_iter() .map(|(role, uv)| { let (x, y) = framing.output_at(uv, src.0, src.1); GradientHandle { role, x, y } }) .collect() } /// The gradient `source` becomes when `role` is dragged from `press` to `now`, /// both in normalised output coordinates. /// /// `source` must be the geometry as it stood **when the press began**, so that /// a drag is applied once rather than accumulated frame by frame. The caller /// captures it on the way down for the same reason the crop handles capture /// the rect they started from. pub(crate) fn drag( source: &MaskSource, role: HandleRole, press: (f32, f32), now: (f32, f32), framing: &Framing, src: (u32, u32), ) -> MaskSource { let aspect = aspect_of(src); let from = framing.source_at(press, src.0, src.1); let to = framing.source_at(now, src.0, src.1); let delta = (to.0 - from.0, to.1 - from.1); // Where this handle was, moved by what the pointer did. Everything below // reads the field back out of that one moved point, so a handle cannot // disagree with the shape it is drawn on. let Some((_, start)) = points(source, aspect).into_iter().find(|(r, _)| *r == role) else { return source.clone(); }; let moved = (start.0 + delta.0, start.1 + delta.1); match source { MaskSource::Linear { centre, angle, width, } => { if role == HandleRole::Centre { return MaskSource::Linear { centre: moved, angle: *angle, width: *width, }; } let d = frame_delta(moved, *centre, aspect); match role { // The projection onto the ramp direction, doubled: `width` is // the whole distance from full effect to none and the handle // sits at half of it. Projected rather than measured, so // dragging sideways changes the width by nothing — the // rotation handle is what turns a ramp. HandleRole::Edge => MaskSource::Linear { centre: *centre, angle: *angle, width: (2.0 * dot(d, direction(*angle))).clamp(0.0, MAX_EXTENT), }, HandleRole::Rotate => MaskSource::Linear { centre: *centre, // The arm lies along the ramp *line*, a quarter turn from // the direction coverage increases in. angle: bearing(d).map_or(*angle, |b| b - FRAC_PI_2), width: *width, }, _ => source.clone(), } } MaskSource::Radial { centre, radii, angle, feather, } => { if role == HandleRole::Centre { return MaskSource::Radial { centre: moved, radii: *radii, angle: *angle, feather: *feather, }; } let d = frame_delta(moved, *centre, aspect); match role { // The major axis, length *and* direction. Dragging outward // resizes and dragging round turns, which is the one gesture // an ellipse's own axis affords — and the reason there is no // separate rotation handle. HandleRole::Edge => MaskSource::Radial { centre: *centre, radii: (length(d).clamp(MIN_RADIUS, MAX_EXTENT), radii.1), angle: bearing(d).unwrap_or(*angle), feather: *feather, }, // The minor axis, length only. Projected onto the axis it // owns, so the ellipse keeps the angle the major handle set // rather than the two fighting over it. HandleRole::Cross => { let minor = { let major = direction(*angle); (-major.1, major.0) }; MaskSource::Radial { centre: *centre, radii: (radii.0, dot(d, minor).abs().clamp(MIN_RADIUS, MAX_EXTENT)), angle: *angle, feather: *feather, } } _ => source.clone(), } } // A subject or a region has no geometry of its own — its outline is // the model's, and the edge controls in the panel are how it is // shaped. A painted mask will be the same answer for a different // reason: its geometry is the strokes, and a stroke is made by // painting rather than by moving a handle. _ => source.clone(), } } /// Every handle's position in normalised **source** coordinates. /// /// The single description both directions read: [`handles`] maps these onto /// the screen, and [`drag`] moves one of them. Two lists would be two things /// to keep in step, and the symptom of them disagreeing is a handle that /// grabs at a distance. fn points(source: &MaskSource, aspect: f32) -> Vec<(HandleRole, (f32, f32))> { match source { MaskSource::Linear { centre, angle, width, } => { let along = direction(*angle); let across = (-along.1, along.0); vec![ ( HandleRole::Edge, offset(*centre, along, (width * 0.5).max(MIN_ARM), aspect), ), (HandleRole::Rotate, offset(*centre, across, ROT_ARM, aspect)), (HandleRole::Centre, *centre), ] } // **Three handles, and no separate rotation.** The major-axis handle // *is* the major axis, so where it is put says both how long the axis // is and which way it points — the ellipse needs no fourth control to // say the same thing twice. // // A rotation arm was tried and taken out: standing off the shape by a // fixed distance, it began outside the photograph at the size a new // radial is created at, so the first thing the user saw was a handle // they could not reach without first shrinking the mask. MaskSource::Radial { centre, radii, angle, .. } => { let major = direction(*angle); let minor = (-major.1, major.0); vec![ ( HandleRole::Edge, offset(*centre, major, radii.0.max(MIN_ARM), aspect), ), ( HandleRole::Cross, offset(*centre, minor, radii.1.max(MIN_ARM), aspect), ), (HandleRole::Centre, *centre), ] } _ => Vec::new(), } } /// The frame's aspect, which is what separates a fraction of the width from a /// fraction of the height. fn aspect_of(src: (u32, u32)) -> f32 { src.0.max(1) as f32 / src.1.max(1) as f32 } fn to_frame(uv: (f32, f32), aspect: f32) -> (f32, f32) { (uv.0 * aspect, uv.1) } fn to_uv(q: (f32, f32), aspect: f32) -> (f32, f32) { (q.0 / aspect, q.1) } /// `centre` displaced by `distance` frame units along the unit vector `dir`, /// returned in normalised coordinates. fn offset(centre: (f32, f32), dir: (f32, f32), distance: f32, aspect: f32) -> (f32, f32) { let q = to_frame(centre, aspect); to_uv((q.0 + dir.0 * distance, q.1 + dir.1 * distance), aspect) } /// The vector from `centre` to `point`, in frame units. fn frame_delta(point: (f32, f32), centre: (f32, f32), aspect: f32) -> (f32, f32) { let a = to_frame(point, aspect); let b = to_frame(centre, aspect); (a.0 - b.0, a.1 - b.1) } fn direction(angle: f32) -> (f32, f32) { (angle.cos(), angle.sin()) } fn dot(a: (f32, f32), b: (f32, f32)) -> f32 { a.0 * b.0 + a.1 * b.1 } fn length(v: (f32, f32)) -> f32 { (v.0 * v.0 + v.1 * v.1).sqrt() } /// Which way `d` points, or `None` when it is too short to have a direction. /// /// A drag that lands on the centre would otherwise send the angle somewhere /// arbitrary, and a gradient that spins when the pointer passes through its /// middle is the sort of thing that makes a control feel broken. fn bearing(d: (f32, f32)) -> Option { ((d.0 * d.0 + d.1 * d.1) > 1e-8).then(|| d.1.atan2(d.0)) } #[cfg(test)] mod tests { use super::*; use dr_pipeline::framing::CropRect; /// A 3:2 sensor. Square would hide every aspect fault in this file. const SRC: (u32, u32) = (6000, 4000); fn linear() -> MaskSource { MaskSource::Linear { centre: (0.5, 0.5), angle: FRAC_PI_2, width: 0.3, } } fn radial() -> MaskSource { MaskSource::Radial { centre: (0.5, 0.5), radii: (0.35, 0.25), angle: 0.0, feather: 0.5, } } fn centre_of(source: &MaskSource) -> (f32, f32) { match source { MaskSource::Linear { centre, .. } | MaskSource::Radial { centre, .. } => *centre, _ => panic!("not a gradient"), } } fn spot(handles: &[GradientHandle], role: HandleRole) -> (f32, f32) { let h = handles .iter() .find(|h| h.role == role) .unwrap_or_else(|| panic!("no {role:?} handle")); (h.x, h.y) } fn close(a: (f32, f32), b: (f32, f32), what: &str) { assert!( (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3, "{what}: {a:?} != {b:?}" ); } /// A view that is cropped, zoomed, panned, straightened and turned — the /// composition, because a handle that is only ever tested unedited is /// tested in the one state where the map is the identity. fn moved_view() -> Framing { let mut f = Framing::new(); f.set_crop(CropRect { x: 0.1, y: 0.15, width: 0.7, height: 0.6, }); f.set_view(CropRect { x: 0.2, y: 0.3, width: 0.5, height: 0.5, }); f.set_param(dr_pipeline::framing::ANGLE, 6.0); f.rotate_quarters(1); f } #[test] fn a_centre_drag_lands_where_the_pointer_did() { // The whole point of the feature, and the thing that breaks silently: // the handle must end up under the pointer, not under where the // pointer would have been at some other zoom level. let f = Framing::new(); let dragged = drag( &linear(), HandleRole::Centre, (0.5, 0.5), (0.3, 0.8), &f, SRC, ); close(centre_of(&dragged), (0.3, 0.8), "unzoomed"); close( f.output_at(centre_of(&dragged), SRC.0, SRC.1), (0.3, 0.8), "and reads back to the same place on screen", ); } #[test] fn a_centre_drag_lands_where_the_pointer_did_after_the_view_moves() { // The regression this exists for. With the view moved, an output // fraction and a source fraction are different numbers, so a drag that // forgot the framing map would put the mask somewhere the pointer // never was — and the further the user had zoomed, the further off. let f = moved_view(); let start = f.output_at(centre_of(&linear()), SRC.0, SRC.1); let target = (0.62, 0.28); let dragged = drag(&linear(), HandleRole::Centre, start, target, &f, SRC); close( f.output_at(centre_of(&dragged), SRC.0, SRC.1), target, "the centre is under the pointer", ); assert!( (centre_of(&dragged).0 - target.0).abs() > 0.05, "and it is *not* simply the output fraction stored raw, which is \ the mistake this guards: {:?}", centre_of(&dragged) ); } #[test] fn dragging_a_handle_onto_another_gradients_handle_produces_that_gradient() { // The contract for every handle at once, and the sharpest way to state // it: put a handle where some other gradient's matching handle sits and // the mask must *become* that gradient. // // Sharper than "the handle lands under the pointer", which is only // true of the centre — the size handles project onto the axis they // control and the rotation handle keeps its arm's length, so all three // deliberately land somewhere other than the pointer. This holds for // all of them, and it is what closes the loop between the two // directions of the map: the position is computed one way, the field // is recovered the other, and they have to be inverses. // // Through a moved view, because that is where a one-legged map hides: // when the framing is neutral the two directions are both the // identity and any pair of them agrees. let f = moved_view(); // Each pair differs in exactly the field the named handle controls, so // a handle that moved something else fails as well as one that landed // in the wrong place. let cases: Vec<(HandleRole, MaskSource, MaskSource)> = vec![ ( HandleRole::Centre, linear(), MaskSource::Linear { centre: (0.31, 0.62), angle: FRAC_PI_2, width: 0.3, }, ), ( HandleRole::Edge, linear(), MaskSource::Linear { centre: (0.5, 0.5), angle: FRAC_PI_2, // Both widths well past twice `MIN_ARM`, or the drawn // offset is the floor rather than the width and the test // would be measuring the floor. width: 0.52, }, ), ( HandleRole::Rotate, linear(), MaskSource::Linear { centre: (0.5, 0.5), angle: 0.9, width: 0.3, }, ), ( HandleRole::Centre, radial(), MaskSource::Radial { centre: (0.4, 0.34), radii: (0.35, 0.25), angle: 0.0, feather: 0.5, }, ), ( HandleRole::Edge, radial(), MaskSource::Radial { centre: (0.5, 0.5), radii: (0.52, 0.25), angle: 0.0, feather: 0.5, }, ), ( HandleRole::Cross, radial(), MaskSource::Radial { centre: (0.5, 0.5), radii: (0.35, 0.41), angle: 0.0, feather: 0.5, }, ), // The major-axis handle carries the angle as well as the length, // so a turned ellipse is reached through `Edge` and there is no // `Rotate` case for a radial to check. ( HandleRole::Edge, radial(), MaskSource::Radial { centre: (0.5, 0.5), radii: (0.35, 0.25), angle: 0.55, feather: 0.5, }, ), ]; for (role, from, want) in cases { let press = spot(&handles(&from, &f, SRC), role); let target = spot(&handles(&want, &f, SRC), role); let got = drag(&from, role, press, target, &f, SRC); assert_eq!( describe(&got), describe(&want), "{role:?} on a {}", from.kind() ); } } /// A gradient rounded to three places, so two of them can be compared /// without floating-point noise deciding the outcome. fn describe(source: &MaskSource) -> String { let r = |v: f32| (v * 1000.0).round() as i32; match source { MaskSource::Linear { centre, angle, width, } => format!( "linear c=({},{}) a={} w={}", r(centre.0), r(centre.1), r(*angle), r(*width) ), MaskSource::Radial { centre, radii, angle, feather, } => format!( "radial c=({},{}) r=({},{}) a={} f={}", r(centre.0), r(centre.1), r(radii.0), r(radii.1), r(*angle), r(*feather) ), other => other.kind().to_string(), } } #[test] fn the_size_handle_reads_the_projection_and_not_the_distance() { // Dragging a width handle sideways must change nothing. Reading the // raw distance instead would make every rotation of the pointer widen // the ramp, so a user turning a gradient would find it growing. let f = Framing::new(); let before = handles(&linear(), &f, SRC); let edge = spot(&before, HandleRole::Edge); // The ramp runs down the frame, so sideways is along x. let sideways = drag( &linear(), HandleRole::Edge, edge, (edge.0 + 0.2, edge.1), &f, SRC, ); match sideways { MaskSource::Linear { width, .. } => { assert!((width - 0.3).abs() < 1e-3, "width moved to {width}") } _ => panic!("kind changed"), } } #[test] fn a_hard_edged_ramp_keeps_a_reachable_size_handle() { // Width zero is a legitimate mask — a hard edge — and its handle sits // exactly on the centre. Drawn there it would be under the centre // handle and the width could never be raised again, so the *drawn* // offset has a floor while the stored width does not. let hard = MaskSource::Linear { centre: (0.5, 0.5), angle: 0.0, width: 0.0, }; let f = Framing::new(); let hs = handles(&hard, &f, SRC); let (cx, cy) = spot(&hs, HandleRole::Centre); let (ex, ey) = spot(&hs, HandleRole::Edge); let apart = ((ex - cx).powi(2) + (ey - cy).powi(2)).sqrt(); assert!(apart > 0.02, "the two handles are on top of each other"); // And dragging it outward still sets a real width rather than one // measured from the place the handle was drawn at. let widened = drag(&hard, HandleRole::Edge, (ex, ey), (ex + 0.1, ey), &f, SRC); match widened { MaskSource::Linear { width, .. } => assert!(width > 0.0, "width stayed {width}"), _ => panic!("kind changed"), } } #[test] fn turning_a_ramp_leaves_its_centre_and_width_alone() { // Three fields, three handles, and each must move only its own — a // rotation that also nudged the centre would make aiming a gradient a // negotiation. let f = Framing::new(); let rot = spot(&handles(&linear(), &f, SRC), HandleRole::Rotate); let turned = drag(&linear(), HandleRole::Rotate, rot, (0.9, 0.2), &f, SRC); match turned { MaskSource::Linear { centre, angle, width, } => { close(centre, (0.5, 0.5), "centre"); assert!((width - 0.3).abs() < 1e-6, "width moved to {width}"); assert!( (angle - FRAC_PI_2).abs() > 0.1, "the angle did not actually turn: {angle}" ); } _ => panic!("kind changed"), } } #[test] fn each_radial_handle_owns_one_semi_axis() { // Two axes and one handle each. Scaling both together would make an // ellipse unreachable, which is the shape a face wants. let f = Framing::new(); let hs = handles(&radial(), &f, SRC); let edge = spot(&hs, HandleRole::Edge); let wider = drag( &radial(), HandleRole::Edge, edge, (edge.0 + 0.1, edge.1), &f, SRC, ); match wider { MaskSource::Radial { radii, .. } => { assert!(radii.0 > 0.35, "the major axis grew: {radii:?}"); assert!( (radii.1 - 0.25).abs() < 1e-6, "the minor did not: {radii:?}" ); } _ => panic!("kind changed"), } // And the minor handle keeps the angle the major one set, rather than // the two contradicting each other about which way the ellipse lies. let turned = MaskSource::Radial { centre: (0.5, 0.5), radii: (0.35, 0.25), angle: 0.7, feather: 0.5, }; let cross = spot(&handles(&turned, &f, SRC), HandleRole::Cross); let taller = drag( &turned, HandleRole::Cross, cross, (cross.0 + 0.06, cross.1), &f, SRC, ); match taller { MaskSource::Radial { radii, angle, .. } => { assert!((angle - 0.7).abs() < 1e-6, "the angle moved to {angle}"); assert!((radii.0 - 0.35).abs() < 1e-6, "the major moved: {radii:?}"); } _ => panic!("kind changed"), } } #[test] fn a_new_radials_handles_are_all_on_the_photograph() { // The first thing anyone sees. A handle placed by a fixed standoff // from the shape starts outside the frame at the size a radial is // created at, and a control you have to shrink the mask to reach is // one nobody finds — a tablet has no hover to hint at it and no // modifier to summon it (FR-UI-7). let radial = MaskSource::Radial { // What `add_gradient_mask` creates. centre: (0.5, 0.5), radii: (0.35, 0.35), angle: 0.0, feather: 0.5, }; for h in handles(&radial, &Framing::new(), SRC) { assert!( (0.0..=1.0).contains(&h.x) && (0.0..=1.0).contains(&h.y), "{:?} is off the picture at ({}, {})", h.role, h.x, h.y ); } // The linear's rotation arm is measured from the centre rather than // from an edge, so it has the same obligation and much more room. let linear = MaskSource::Linear { centre: (0.5, 0.5), angle: FRAC_PI_2, width: 0.3, }; for h in handles(&linear, &Framing::new(), SRC) { assert!( (0.0..=1.0).contains(&h.x) && (0.0..=1.0).contains(&h.y), "{:?} is off the picture at ({}, {})", h.role, h.x, h.y ); } } #[test] fn a_subject_mask_offers_nothing_to_drag() { // Its outline is the model's. Offering handles would suggest the // shape can be moved, and the edge controls in the panel are what // actually shapes one. let subject = MaskSource::Subject { signature: 1, index: 0, class: "person".into(), score: 0.9, }; assert!(handles(&subject, &Framing::new(), SRC).is_empty()); } }