//! Spot removal (FR-DEV-8): placing, dragging and clearing heal/clone spots. use dr_pipeline::Edit; use crate::labels; use super::session::DevelopSession; impl DevelopSession { // --- 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 { 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(¢re.0) || !(0.0..=1.0).contains(¢re.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::Action(labels::step::SPOT_PLACED)); 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 { 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 { 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::Action(labels::step::SPOT_MOVED)); } /// 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::Action(labels::step::SPOT_REMOVED)); true } /// TRACES: FR-DEV-8 /// Change one of the selected repair's settings. /// /// Recorded as a named [`Edit::Action`] rather than under a parameter key: /// a repair is not an operation and has no `OpId` to name or 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(&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::Action(labels::step::SPOT)); true } /// How many repairs this photograph carries. pub fn spot_count(&self) -> usize { self.graph.spots().len() } }