Say which photograph the sliders are pointed at
Selecting a mask layer silently re-points about thirty controls at that layer's chain. Same panel, same order, same sliders, different meaning — and the only thing that said so was a sentence in the panel above, which a photographer reaching for the exposure slider has no reason to read. An exposure change lands on the whole frame when it was meant for a face, or the reverse; both are silent, and both are discovered later. `ui-navigation.md` §1.1 calls it the dangerous one and it is: the others in that document cost time, this one costs work. The remedy is the classic one for a modal fault — make the mode visible — and the application already had the pattern. Crop arms a canvas interaction, draws an overlay, gives the column one job and is left by the control that entered it. Local masking is the same animal built as a peer panel, and that is what created the ambiguity. So `crop-mode` stops being a bare boolean and becomes one value of a three-state mode, which is the point: two modes could both be on before, and now that is not a state the interface can be in rather than one it is tested against. **One strip, not two.** The mode control was going to sit beside the group strip that filters the adjustments, which is two controls above one column answering the same question — what am I working on. They are one control now, `Crop · Local │ All · Light · Colour`, which is the shape Lightroom Mobile's bottom strip has for the same reason. The two halves are different kinds of state and are drawn differently: a mode is a chip that fills with the accent when it is on, a group is a word with a rule under it. That difference is what lets both be read at once, which they routinely are — picking Light while a mask is selected filters *that layer's* chain and does not leave the mode. Dropping the scope on a group press would be the same fault coming back from the other end, and would make Light mean two things depending on where it was pressed. The strip stays pinned above the develop column rather than moving to the top of the canvas as the document proposed. The half that filters the column belongs to the column, and the photograph is the subject. The canvas keeps one button, which now names the mode it leaves rather than saying "Done" — that was unambiguous with one mode and would not be with two — because the column can be closed on a narrow window and no mode may be inescapable. Entering a mode is a side effect, so Rust owns it rather than the strip writing the property: crop drops the zoom, local turns the overlay on, and leaving clears the selection. That last one is the fix. The "Overlay" and "Select" toggles are gone because they armed things that are simply what the mode *is* — a mode that has to be switched on separately is one you can enter and have do nothing. Escape and the Android back gesture join `back_step` as one `LeaveMode` rather than a second exit concept, and the mode is left before the zoom is: it was entered later, and it is the bigger step back. The heading is where the scope goes. Not a caption beside the panel, the heading *of* the panel that changed — `ADJUST` becomes the layer's name, the same string the selected row in the stack shows. That is the difference between describing a hazard and removing it. **Handles on the photograph.** A linear or radial mask could be created and then not moved, so a radial sat at the centre of the frame at its default size for ever. Three faults stood in the way of drawing one. The first is that a gradient did not render at all until the model had run. The rasteriser was built on the way out of `segment` and the array's size was read *off* the segmentation, so a gradient added to an unsegmented photograph produced nothing — silently, in the same way exports and thumbnails once did: the shader still emits the layer's block and the empty placeholder multiplies it by zero. The proxy size is a property of the photograph. Both are derived from it now, and deliberately at the same size rather than by coincidence, because a subject's distance field is sampled against that array. The second is hit-testing. A handle is drawn in output coordinates and stored in source ones, and between them lie the crop, the zoom, the pan, the straightening and the turns. `Framing::source_at` is `wgsl_prologue` evaluated on the CPU, kept in that file beside it so that keeping the two in step is one file's problem — a handle mapped through anything less drifts off the mask the moment the view moves, which is exactly what masks are rasterised in source space to avoid. The third is that a drag is a displacement, not a destination. Each handle answers to the movement of the pointer since the press, applied to where the mask was when the press landed. Snapping the handle to the pointer instead jerks it by up to half a touch target on the first press, and the target is finger-sized because a tablet has no hover to reveal a control and no modifier to qualify it. A ramp gets three handles — centre, width, angle. An ellipse gets three too: centre and one per semi-axis, the major one carrying the direction as well as the length, because where an axis is put says both. It had a fourth, and it is gone: standing off the shape by a fixed distance, the rotation arm began outside the photograph at the size a new radial is created at, so the first thing anyone saw was a control they could not reach without first shrinking the mask. Two faults here were found by looking at the screen rather than at the source, both of the kind that cannot be found any other way. A `1px` rule with a size and no position is *centred* by Slint, so the seam between the photograph and the column was a hairline down the middle of the panel, through the histogram and every slider under it — twice, once in `app.slint` and once in `AdjustPanel`. And handing Slint a fresh model for the handles on every pointer event made the repeater rebuild its items, taking the `TouchArea` holding the gesture with them: the handle jumped once and then went dead under a finger that was still down. `develop.rs` carries the same warning about the parameter rows, where it broke slider drags; the model is rewritten in place now. The tests worth having are the ones about ambiguity and about the map. That the same row reads the frame's value, then the layer's, then the frame's again is §1.1 in one assertion. That dragging a handle onto another gradient's matching handle *produces* that gradient closes the loop between the two directions of the framing map, through a view that is cropped, zoomed, panned, straightened and quarter-turned at once — a one-legged map is invisible when the framing is neutral, because then both legs are the identity. Not done here: the histogram still reports the whole frame while the sliders edit a layer. That disagreement is real and is N3's, which this unblocks. The strip has room for a Brush entry beside Crop and Local when the painted masks land in the core, and it needs nothing here but the canvas interaction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,765 @@
|
||||
//! 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<GradientHandle> {
|
||||
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<f32> {
|
||||
((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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user