diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 9583996..be8373d 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -1105,6 +1105,86 @@ impl DevelopSession { } } + /// Edge transition half-width, in fractions of the shorter edge. + pub fn set_mask_feather(&mut self, id: &str, feather: f32) { + if let Some(layer) = self.graph.masks_mut().get_mut(id) { + layer.feather = feather.clamp(0.0, 1.0); + self.history.record(&self.graph, Edit::Op(OpId("mask-feather"))); + } + } + + pub fn set_mask_falloff(&mut self, id: &str, index: usize) { + use dr_pipeline::mask::Falloff; + let Some(&falloff) = Falloff::ALL.get(index) else { + return; + }; + if let Some(layer) = self.graph.masks_mut().get_mut(id) { + layer.falloff = falloff; + self.history.record(&self.graph, Edit::Discrete); + } + } + + pub fn set_mask_morphology(&mut self, id: &str, index: usize) { + use dr_pipeline::mask::Morphology; + let Some(&morphology) = Morphology::ALL.get(index) else { + return; + }; + if let Some(layer) = self.graph.masks_mut().get_mut(id) { + layer.morphology = morphology; + // Picking an operation with no amount set would appear to do + // nothing, and the user would reasonably conclude it is broken. + if morphology != Morphology::None && layer.morph_radius <= 0.0 { + layer.morph_radius = 0.006; + } + self.history.record(&self.graph, Edit::Discrete); + } + } + + pub fn set_mask_morph_radius(&mut self, id: &str, radius: f32) { + if let Some(layer) = self.graph.masks_mut().get_mut(id) { + layer.morph_radius = radius.clamp(0.0, 1.0); + self.history.record(&self.graph, Edit::Op(OpId("mask-morph"))); + } + } + + /// Which falloff a layer uses, as an index into `Falloff::ALL`. + pub fn mask_falloff(&self, id: &str) -> usize { + use dr_pipeline::mask::Falloff; + self.graph.masks().get(id).map_or(0, |l| { + Falloff::ALL.iter().position(|&f| f == l.falloff).unwrap_or(0) + }) + } + + pub fn mask_morphology(&self, id: &str) -> usize { + use dr_pipeline::mask::Morphology; + self.graph.masks().get(id).map_or(0, |l| { + Morphology::ALL + .iter() + .position(|&m| m == l.morphology) + .unwrap_or(0) + }) + } + + pub fn mask_feather(&self, id: &str) -> f32 { + self.graph.masks().get(id).map_or(0.0, |l| l.feather) + } + + pub fn mask_morph_radius(&self, id: &str) -> f32 { + self.graph.masks().get(id).map_or(0.0, |l| l.morph_radius) + } + + /// Whether the edge controls apply to this layer. + /// + /// Only sources that go through the distance field. A gradient carries its + /// own falloff in its geometry, so offering a second one would be two + /// controls fighting over the same edge. + pub fn mask_is_shapeable(&self, id: &str) -> bool { + use dr_pipeline::mask::MaskSource; + self.graph.masks().get(id).is_some_and(|l| { + matches!(l.source, MaskSource::Subject { .. } | MaskSource::Regions { .. }) + }) + } + pub fn set_mask_opacity(&mut self, id: &str, opacity: f32) { if let Some(layer) = self.graph.masks_mut().get_mut(id) { layer.opacity = opacity.clamp(0.0, 1.0); diff --git a/ui/dr-ui/src/masks_ui.rs b/ui/dr-ui/src/masks_ui.rs index 8eff319..97810f6 100644 --- a/ui/dr-ui/src/masks_ui.rs +++ b/ui/dr-ui/src/masks_ui.rs @@ -47,6 +47,11 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc panel-visible: true; mask-opacity-changed(id, v) => { root.mask-opacity-changed(id, v); } + mask-feather-changed(id, v) => { + root.mask-feather-changed(id, v); + } + mask-falloff-picked(id, i) => { + root.mask-falloff-picked(id, i); + } + mask-morphology-picked(id, i) => { + root.mask-morphology-picked(id, i); + } + mask-morph-radius-changed(id, v) => { + root.mask-morph-radius-changed(id, v); + } add-gradient(radial) => { root.add-gradient-mask(radial); } add-subject(i) => { root.add-subject-mask(i); } } diff --git a/ui/dr-ui/ui/masks.slint b/ui/dr-ui/ui/masks.slint index 408ad0e..fed949c 100644 --- a/ui/dr-ui/ui/masks.slint +++ b/ui/dr-ui/ui/masks.slint @@ -8,7 +8,7 @@ import { Theme } from "theme.slint"; import { Button, Caption, IconButton, Label, PanelHeading, Value } from "widgets.slint"; -import { SliderRow } from "controls.slint"; +import { Segmented, SliderRow } from "controls.slint"; /// One layer in the stack. export struct MaskRow { @@ -22,6 +22,17 @@ export struct MaskRow { selected: bool, inverted: bool, opacity: float, + /// Edge transition half-width, as a fraction of the frame's shorter edge. + feather: float, + /// Index into `Falloff::ALL`. + falloff: int, + /// Index into `Morphology::ALL`. + morphology: int, + morph-radius: float, + /// Whether the edge controls apply. A gradient has its own geometry and + /// does not go through the distance field, so showing them would offer + /// controls that do nothing. + shapeable: bool, /// Its region ids came from a different segmentation than the one loaded, /// so applying it would produce a confidently wrong shape. stale: bool, @@ -49,6 +60,10 @@ component MaskEntry inherits Rectangle { callback toggled(bool); callback invert-toggled(bool); callback opacity-changed(float); + callback feather-changed(float); + callback falloff-picked(int); + callback morphology-picked(int); + callback morph-radius-changed(float); height: layout.preferred-height; background: root.data.selected ? Theme.surface-raised : transparent; @@ -155,6 +170,46 @@ component MaskEntry inherits Rectangle { emphasised: invert.has-hover || root.data.inverted; } } + + // The edge. All of it reads one signed distance field, so these + // are free to move — none of them recomputes anything except a + // compound morphology. + if root.data.shapeable: SliderRow { + label: "Feather"; + hint: "How far the edit fades across the edge."; + value: root.data.feather * 1000; + default-value: 4; + minimum: 0; + maximum: 60; + changed(v) => { root.feather-changed(v / 1000); } + reset => { root.feather-changed(0.004); } + } + + if root.data.shapeable: Segmented { + label: "Falloff"; + options: ["Hard", "Linear", "Smooth", "Gaussian", "Expo"]; + selected: root.data.falloff; + picked(i) => { root.falloff-picked(i); } + } + + if root.data.shapeable: Segmented { + label: "Edge"; + hint: "Grow or shrink the outline the model drew. Close fills " + + "pinholes; open removes specks."; + options: ["None", "Grow", "Shrink", "Close", "Open"]; + selected: root.data.morphology; + picked(i) => { root.morphology-picked(i); } + } + + if root.data.shapeable && root.data.morphology != 0: SliderRow { + label: "Amount"; + value: root.data.morph-radius * 1000; + default-value: 0; + minimum: 0; + maximum: 40; + changed(v) => { root.morph-radius-changed(v / 1000); } + reset => { root.morph-radius-changed(0); } + } } } } @@ -199,6 +254,10 @@ export component MaskPanel inherits Rectangle { callback mask-toggled(string, bool); callback mask-invert-toggled(string, bool); callback mask-opacity-changed(string, float); + callback mask-feather-changed(string, float); + callback mask-falloff-picked(string, int); + callback mask-morphology-picked(string, int); + callback mask-morph-radius-changed(string, float); callback add-gradient(bool); callback add-subject(int); @@ -373,6 +432,10 @@ export component MaskPanel inherits Rectangle { toggled(on) => { root.mask-toggled(mask.id, on); } invert-toggled(on) => { root.mask-invert-toggled(mask.id, on); } opacity-changed(v) => { root.mask-opacity-changed(mask.id, v); } + feather-changed(v) => { root.mask-feather-changed(mask.id, v); } + falloff-picked(i) => { root.mask-falloff-picked(mask.id, i); } + morphology-picked(i) => { root.mask-morphology-picked(mask.id, i); } + morph-radius-changed(v) => { root.mask-morph-radius-changed(mask.id, v); } } // The one thing this panel has to say about the adjust panel below it,