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:
2026-09-24 21:52:03 -04:00
parent fc0ea8824d
commit c3d1f83b96
9 changed files with 432 additions and 46 deletions
+168
View File
@@ -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());
}
}
+5
View File
@@ -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)