Say so when a crop leaves a mask outside the frame
Cropping tighter past a mask layer made it invisible without a word: the layer stayed in the panel and the sidecar, and its adjustment went on landing on pixels nobody would see again. When a crop is let go, develop now measures what the gesture did to the mask stack (dr_pipeline::orphan) and, if any layer is now entirely or mostly outside the frame, shows a notice over the photograph: how many layers, their names, "Undo crop" and "Keep crop". The crop is already applied and nothing waits on the answer. The crop overlay gains a release callback carrying the rect the press began from, so the measurement runs once per gesture and never on the drag's per-frame changes. Choosing a ratio is measured the same way, being a crop committed in one click. "Undo crop" is the ordinary undo, and the notice is tied to the history revision it was raised at: the redraw that follows any history move clears it, so the crop and its warning go back as one step. A second drag folded into the same step is measured from where that step began. A crop that strands nothing shows nothing.
This commit is contained in:
@@ -130,7 +130,122 @@ fn rotate_crop(rect: CropRect, turns: i32) -> CropRect {
|
||||
r.normalised()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-17
|
||||
/// The mask layers a committed crop took out of the frame.
|
||||
///
|
||||
/// **Tied to a position in the history, not to a timer or a click.** The
|
||||
/// notice speaks about the step on top of the stack, and it is only true
|
||||
/// while that step is on top: an undo takes the crop back, a later edit puts
|
||||
/// something else there, and either way the notice has nothing left to say.
|
||||
/// So it carries the revision it was raised at, and
|
||||
/// [`DevelopSession::crop_notice`] stops returning it the moment the history
|
||||
/// moves — which is what makes taking the crop back and taking the warning
|
||||
/// away one step rather than two.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CropNotice {
|
||||
/// [`DevelopSession::history_revision`] when the crop was let go.
|
||||
revision: u64,
|
||||
/// The framing before the crop, kept so a second drag folded into the
|
||||
/// same step is measured from where the step began rather than from the
|
||||
/// already-hidden state the first drag left.
|
||||
from: dr_pipeline::Framing,
|
||||
/// Each hidden layer's name, in stack order.
|
||||
names: Vec<String>,
|
||||
}
|
||||
|
||||
impl CropNotice {
|
||||
pub fn names(&self) -> &[String] {
|
||||
&self.names
|
||||
}
|
||||
}
|
||||
|
||||
impl DevelopSession {
|
||||
/// The framing as it stands — what a gesture that is about to change the
|
||||
/// crop hands back to [`Self::notice_hidden_masks`] once it is let go.
|
||||
pub fn framing(&self) -> dr_pipeline::Framing {
|
||||
*self.graph.framing()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-17
|
||||
/// Measure the crop just committed against `before`, and raise a notice
|
||||
/// for the mask layers it took out of the frame. Returns the notice, or
|
||||
/// `None` when nothing was stranded — the common case, which says nothing.
|
||||
///
|
||||
/// Called when a crop is *let go*, never per frame of the drag: a handle
|
||||
/// passing over a mask on its way somewhere else is not an event, and the
|
||||
/// measurement samples every layer, which is work for a commit rather than
|
||||
/// a pointer move.
|
||||
///
|
||||
/// A model's selection is measured from the raster the live segmentation
|
||||
/// holds when the layer carries none of its own — the session keeps its
|
||||
/// model output outside the graph until a save folds it in.
|
||||
pub fn notice_hidden_masks(&mut self, before: &dr_pipeline::Framing) -> Option<&CropNotice> {
|
||||
use dr_pipeline::mask::{MaskPart, MaskSource};
|
||||
use dr_pipeline::orphan::{hidden_by_crop, Raster};
|
||||
use std::borrow::Cow;
|
||||
|
||||
let revision = self.history.revision();
|
||||
// Still standing on the step a notice was raised for: a second drag
|
||||
// folded into that step is measured from where the step began.
|
||||
let from = match &self.crop_notice {
|
||||
Some(n) if n.revision == revision => n.from,
|
||||
_ => *before,
|
||||
};
|
||||
|
||||
let source = self.demosaiced.size();
|
||||
let seg = self.segmentation.as_ref();
|
||||
let model = |part: &MaskPart| -> Option<Raster<'_>> {
|
||||
let seg = seg?;
|
||||
if part.is_stale(seg.signature()) {
|
||||
return None;
|
||||
}
|
||||
let values = match &part.source {
|
||||
MaskSource::Subject { index, .. } => {
|
||||
Cow::Borrowed(seg.instance_mask(*index as usize)?)
|
||||
}
|
||||
MaskSource::Category { name, .. } => seg.category_mask_at(name, part.refine)?,
|
||||
_ => return None,
|
||||
};
|
||||
let (width, height) = seg.proxy_size();
|
||||
Some(Raster {
|
||||
values,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
};
|
||||
let names: Vec<String> = hidden_by_crop(
|
||||
self.graph.masks(),
|
||||
&from,
|
||||
self.graph.framing(),
|
||||
source,
|
||||
&model,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|layer| layer.display_name().to_string())
|
||||
.collect();
|
||||
|
||||
self.crop_notice = (!names.is_empty()).then_some(CropNotice {
|
||||
revision,
|
||||
from,
|
||||
names,
|
||||
});
|
||||
self.crop_notice.as_ref()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-17
|
||||
/// The notice for the crop on top of the history, if it is still on top.
|
||||
pub fn crop_notice(&self) -> Option<&CropNotice> {
|
||||
self.crop_notice
|
||||
.as_ref()
|
||||
.filter(|n| n.revision == self.history.revision())
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-17
|
||||
/// Keep the crop: the photographer has read the notice and meant it.
|
||||
pub fn dismiss_crop_notice(&mut self) {
|
||||
self.crop_notice = None;
|
||||
}
|
||||
|
||||
/// The sensor's own dimensions, before framing.
|
||||
///
|
||||
/// What a crop overlay needs: its handles are placed against the full
|
||||
@@ -990,4 +1105,57 @@ mod tests {
|
||||
assert!(rotate_crop(CropRect::default(), 1).is_full());
|
||||
assert!(rotate_crop(CropRect::default(), -3).is_full());
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-17
|
||||
/// The acceptance, end to end through the session: a crop that strands a
|
||||
/// layer raises a notice naming it, a crop that does not says nothing,
|
||||
/// and one undo takes back the crop and the notice together.
|
||||
#[test]
|
||||
fn a_crop_that_strands_a_mask_is_noticed_and_undone_as_one_step() {
|
||||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||||
log::warn!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let rgba = vec![128u8; 64 * 64 * 4];
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
// A radial in the middle of the frame.
|
||||
session.add_gradient_mask(true).expect("a radial layer");
|
||||
|
||||
// Trimmed, not stranded: no notice on the common path.
|
||||
let before = session.framing();
|
||||
session.set_crop(CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.8,
|
||||
height: 0.8,
|
||||
});
|
||||
assert!(session.notice_hidden_masks(&before).is_none());
|
||||
assert!(session.crop_notice().is_none());
|
||||
|
||||
// Into a corner the radial does not reach.
|
||||
let before = session.framing();
|
||||
std::thread::sleep(dr_pipeline::history::COALESCE_WINDOW);
|
||||
session.set_crop(CropRect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 0.12,
|
||||
height: 0.12,
|
||||
});
|
||||
let names = session
|
||||
.notice_hidden_masks(&before)
|
||||
.map(|n| n.names().to_vec())
|
||||
.expect("the radial was stranded");
|
||||
assert_eq!(names, vec!["radial".to_string()]);
|
||||
assert!(session.crop_notice().is_some());
|
||||
|
||||
// The crop was applied, not refused.
|
||||
assert!(session.crop().width < 0.2);
|
||||
|
||||
// One undo: the crop goes back and the notice goes with it.
|
||||
assert!(session.undo());
|
||||
assert!((session.crop().width - 0.8).abs() < 1e-6);
|
||||
assert!(session.crop_notice().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,10 @@ pub struct DevelopSession {
|
||||
/// comparison is held. Viewing state: nothing about the edit changes,
|
||||
/// and it goes down with the session.
|
||||
pub(super) compared_snapshot: Option<String>,
|
||||
/// TRACES: FR-DEV-17
|
||||
/// The layers the last committed crop took out of the frame, while the
|
||||
/// history still stands on that crop. See [`super::framing::CropNotice`].
|
||||
pub(super) crop_notice: Option<super::framing::CropNotice>,
|
||||
pub(super) demosaiced: Arc<DemosaicedImage>,
|
||||
pub(super) adjust: AdjustPass,
|
||||
/// TRACES: FR-DSP-7
|
||||
@@ -373,6 +377,7 @@ impl DevelopSession {
|
||||
snapshots: Vec::new(),
|
||||
removed_snapshots: Vec::new(),
|
||||
compared_snapshot: None,
|
||||
crop_notice: None,
|
||||
demosaiced: Arc::new(demosaiced),
|
||||
adjust: AdjustPass::new(ctx),
|
||||
histogram: HistogramPass::new(ctx)
|
||||
|
||||
Reference in New Issue
Block a user