Put the repair tool on the photograph

A third chip beside Crop and Local, and the mode strip's own comment
predicted the shape: a mode that arms a gesture on the canvas and scopes
the column. Click a mark to cover it, drag the disc to move the repair,
drag the source circle to say where the patch comes from, Delete to remove
it. The source starts two and a half radii towards the middle of the
frame, which is FR-DEV-8's automatic placement in its cheap form — dust
sits on skies and skies are smooth, so it is usually right and always one
drag from fixed.

Two things are drawn deliberately. The circles are the size the repairs
actually are, because whether a disc covers a speck is the whole judgement
being made and a fixed-size dot would say nothing about it; the reach
around them is padded to a touch target so a spot on a dust mark can still
be picked up on a phone. And only the selected repair shows its source: a
dusty sky carries a dozen, and two dozen circles with nothing saying which
belongs to which is less information rather than more.

The panel edits what is stored while the canvas draws what is mapped, and
the two are pushed separately for that reason — a slider deriving its
value from the drawn radius would move differently at different zoom
levels. It is also the one panel built from SliderRow rather than a live
track: a repair has no OpId to coalesce a drag under, so a row that fires
once per gesture is what keeps undo one step per decision.

Verified as far as this environment allows: the strip renders and the
column re-scopes, photographed under XWayland. Synthetic clicks do not
reach this application, so the gestures are as-written rather than
as-felt, and docs/spot-removal.md says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 20:58:13 +02:00
co-authored by Claude Opus 5
parent 2958444835
commit 8ab9440190
9 changed files with 1088 additions and 54 deletions
+239
View File
@@ -533,6 +533,15 @@ pub struct DevelopSession {
/// "make these three subjects a stop darker" is one gesture rather than
/// three. Order is insertion order and nothing reads it, only membership.
active_masks: Vec<String>,
/// TRACES: FR-DEV-8
/// Which repair the panel is describing, if any.
///
/// Interface state and not part of the edit, exactly as `active_masks` is:
/// it changes no pixel, it is not in the sidecar, and it is not on the undo
/// stack. One at a time rather than a set — a repair is eight numbers and
/// there is no gesture that usefully moves several at once, where three
/// masked layers really can share a slider drag.
selected_spot: Option<String>,
/// Whether to draw the false-coloured region overlay.
show_overlay: bool,
/// Which attribute the panel is filtered to, or all of them.
@@ -619,6 +628,7 @@ impl DevelopSession {
subjects: None,
subject_key: 0,
active_masks: Vec::new(),
selected_spot: None,
show_overlay: false,
active_tab: None,
curve_channel: 0,
@@ -1897,6 +1907,235 @@ impl DevelopSession {
self.history.record(&self.graph, Edit::Discrete);
}
// --- repairs (FR-DEV-8) ------------------------------------------------
/// TRACES: FR-DEV-8
/// Cover what is at `(x, y)`, in fractions of the shown image.
///
/// The click arrives in *output* coordinates — where the photograph
/// currently sits on screen — and a repair is stored against the
/// photograph, so it goes through `Framing::source_at`: the same map the
/// shader applies, run backwards. Anything less would put the repair where
/// the pointer was rather than where the mark is, and the two agree only at
/// fit-to-window with no crop.
///
/// Returns the new repair's id, or `None` when the set is full. Selecting
/// it is deliberate: the control that changes its size is in the column,
/// and a photographer who has just placed a spot too small should find that
/// control already pointed at it.
pub fn place_spot(&mut self, x: f32, y: f32) -> Option<String> {
let (sw, sh) = self.demosaiced.size();
let centre = self.graph.framing().source_at((x, y), sw, sh);
// Outside the photograph entirely — the letterbox margin, or a drag
// that ended off the edge. Placing a repair there would put a disc
// somewhere the user cannot see and cannot pick up again.
if !(0.0..=1.0).contains(&centre.0) || !(0.0..=1.0).contains(&centre.1) {
return None;
}
let aspect = sw.max(1) as f32 / sh.max(1) as f32;
let radius = dr_pipeline::spot::DEFAULT_RADIUS;
let offset = dr_pipeline::Spot::default_offset(centre, radius, aspect);
let id = self
.graph
.spots_mut()
.place(dr_pipeline::Spot::new(centre, offset, radius))?;
self.selected_spot = Some(id.clone());
self.history.record(&self.graph, Edit::Discrete);
Some(id)
}
/// TRACES: FR-DEV-8 | FR-UI-3
/// Every repair as a circle on the shown image, plus the source circle of
/// the selected one.
///
/// # Why only the selected repair shows its source
///
/// A dusty sky carries a dozen repairs. Two dozen circles with nothing
/// saying which source belongs to which disc is not more information, it is
/// less — and there is no room on a phone for a connector between each
/// pair. The selection is what disambiguates them, which is also why a
/// press on a repair selects it before the drag begins.
///
/// Recomputed per redraw rather than cached, for the reason
/// [`Self::gradient_handles`] gives: the answer changes with the *view*,
/// and a pan moves every circle while touching no edit.
pub fn spot_handles(&self) -> Vec<crate::SpotHandle> {
let (sw, sh) = self.demosaiced.size();
let framing = self.graph.framing();
let aspect = sw.max(1) as f32 / sh.max(1) as f32;
// The shown image's own shape, which is not the source's once the frame
// has been cropped or turned. A radius is reported against its height,
// so this is what converts the x half of the mapped offset.
let (ow, oh) = self.graph.output_size(sw, sh);
let shown_aspect = ow.max(1) as f32 / oh.max(1) as f32;
let mut handles = Vec::new();
for spot in self.graph.spots().spots() {
let selected = self.selected_spot.as_deref() == Some(spot.id.as_str());
let centre = framing.output_at(spot.centre, sw, sh);
// The radius, mapped rather than scaled: a point one radius above
// the centre goes through the same map, and the distance between
// the two answers is the radius as drawn. The x half is multiplied
// by the shown aspect because the two axes are normalised by
// different lengths, and a circle measured in mixed units is an
// ellipse.
let rim = framing.output_at((spot.centre.0, spot.centre.1 + spot.radius), sw, sh);
let radius = ((rim.0 - centre.0) * shown_aspect).hypot(rim.1 - centre.1);
handles.push(crate::SpotHandle {
id: spot.id.clone().into(),
role: crate::SpotRole::Destination,
x: centre.0,
y: centre.1,
radius,
selected,
enabled: spot.enabled,
});
if selected {
let source = framing.output_at(spot.source(aspect), sw, sh);
handles.push(crate::SpotHandle {
id: spot.id.clone().into(),
role: crate::SpotRole::Source,
x: source.0,
y: source.1,
radius,
selected: true,
enabled: spot.enabled,
});
}
}
handles
}
/// TRACES: FR-DEV-8
/// Drag one circle of one repair, from `press` to `now`, both in fractions
/// of the shown image.
///
/// Dragging the disc moves the whole repair and carries its source along —
/// what a photographer means by nudging a spot. Dragging the source moves
/// the source alone, which is the override FR-DEV-8 asks for over the
/// automatic placement.
///
/// `origin` is the repair as it stood when the gesture began; the caller
/// holds it for the duration and hands it back, so a drag is applied to
/// that rather than accumulated frame by frame — the rule
/// [`Self::drag_gradient_handle`] states, for the same reasons.
pub fn drag_spot(
&mut self,
id: &str,
role: crate::SpotRole,
origin: Option<&dr_pipeline::Spot>,
press: (f32, f32),
now: (f32, f32),
) -> Option<dr_pipeline::Spot> {
let (sw, sh) = self.demosaiced.size();
let framing = *self.graph.framing();
let aspect = sw.max(1) as f32 / sh.max(1) as f32;
let start = match origin {
Some(spot) => spot.clone(),
None => self.graph.spots().get(id)?.clone(),
};
// The displacement in source coordinates. Affine, so a movement is a
// movement: the map may be run on the two endpoints and subtracted,
// which is what makes a drag on a rotated photograph move the repair in
// the direction the finger went.
let from = framing.source_at(press, sw, sh);
let to = framing.source_at(now, sw, sh);
let moved = (to.0 - from.0, to.1 - from.1);
let spot = self.graph.spots_mut().get_mut(id)?;
match role {
crate::SpotRole::Destination => {
spot.set_centre((start.centre.0 + moved.0, start.centre.1 + moved.1));
}
// In frame units, because that is what an offset is stored in — and
// the x half of a normalised displacement is short by the aspect.
crate::SpotRole::Source => {
spot.set_offset((start.offset.0 + moved.0 * aspect, start.offset.1 + moved.1));
}
}
// Nothing recorded here: a drag delivers a pointer event a frame, and
// one history step apiece would make undo walk the gesture back pixel
// by pixel. Recorded once, on release.
Some(start)
}
/// A repair's drag finished: one history step for the whole gesture.
pub fn commit_spot_drag(&mut self) {
self.history.record(&self.graph, Edit::Discrete);
}
/// Which repair the column is describing.
pub fn selected_spot(&self) -> Option<&dr_pipeline::Spot> {
let id = self.selected_spot.as_deref()?;
self.graph.spots().get(id)
}
pub fn selected_spot_id(&self) -> Option<&str> {
self.selected_spot.as_deref()
}
/// Choose a repair, or `None` to describe none.
///
/// An id the graph no longer holds selects nothing rather than being kept:
/// the circle that offered it is stale by the time the press lands, and a
/// selection pointing at a deleted repair would leave the column describing
/// something that is not on the photograph.
pub fn select_spot(&mut self, id: Option<&str>) {
self.selected_spot = id
.filter(|id| self.graph.spots().get(id).is_some())
.map(str::to_string);
}
/// TRACES: FR-DEV-8
/// Take a repair off the photograph, returning whether one went.
pub fn remove_spot(&mut self, id: &str) -> bool {
if self.graph.spots_mut().remove(id).is_none() {
return false;
}
if self.selected_spot.as_deref() == Some(id) {
self.selected_spot = None;
}
self.history.record(&self.graph, Edit::Discrete);
true
}
/// TRACES: FR-DEV-8
/// Change one of the selected repair's settings.
///
/// Coalesced under [`Edit::Discrete`] like the drags are, rather than under
/// a parameter key: a repair is not an operation and has no `OpId` to
/// coalesce by, so a slider drag over it records a step per movement unless
/// the caller debounces. `SliderRow` fires once per completed gesture,
/// which is what makes that acceptable here and is why this is the one
/// panel in the application built from that row rather than from a live
/// track.
pub fn set_selected_spot<F>(&mut self, change: F) -> bool
where
F: FnOnce(&mut dr_pipeline::Spot),
{
let Some(id) = self.selected_spot.clone() else {
return false;
};
let Some(spot) = self.graph.spots_mut().get_mut(&id) else {
return false;
};
change(spot);
self.history.record(&self.graph, Edit::Discrete);
true
}
/// How many repairs this photograph carries.
pub fn spot_count(&self) -> usize {
self.graph.spots().len()
}
/// The selected layer's mask rule, for a caller that has to remember what
/// a gesture started from.
pub fn active_mask_source(&self) -> Option<MaskSource> {