Put the edge controls where the mask is

Feather, falloff, grow/shrink/close/open and their amount, on the selected
layer. All four read the one distance field, so all four are live — nothing
recomputes except a compound morphology, and the session keys on that
separately so a feather drag rebuilds nothing.

Shown only for sources that go through the distance field. A gradient
carries its own falloff in its geometry, and offering a second one would be
two controls fighting over the same edge.

Picking an operation seeds a small amount if none is set. Selecting "Grow"
and seeing nothing happen would read as a broken control rather than as a
radius of zero.
This commit is contained in:
2026-08-22 08:39:17 +02:00
parent ec713585a5
commit 6163b63895
4 changed files with 223 additions and 1 deletions
+80
View File
@@ -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);
+63
View File
@@ -47,6 +47,11 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
kind: s.mask_kind(&id).into(),
inverted: s.mask_inverted(&id),
opacity: s.mask_opacity(&id),
feather: s.mask_feather(&id),
falloff: s.mask_falloff(&id) as i32,
morphology: s.mask_morphology(&id) as i32,
morph_radius: s.mask_morph_radius(&id),
shapeable: s.mask_is_shapeable(&id),
id: id.into(),
label: label.into(),
enabled,
@@ -280,6 +285,64 @@ pub(crate) fn wire(
});
}
// --- the edge treatment -------------------------------------------------
//
// All four read one distance field, so all four are live: nothing here
// rebuilds anything except a compound morphology, which `develop` keys on
// separately.
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_feather_changed(move |id, value| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_feather(&id, value);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_falloff_picked(move |id, index| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_falloff(&id, index.max(0) as usize);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_morphology_picked(move |id, index| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_morphology(&id, index.max(0) as usize);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_mask_morph_radius_changed(move |id, value| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_morph_radius(&id, value);
}
sync(&w, &session);
redraw(&w);
});
}
// --- adding layers ------------------------------------------------------
{
let weak = window.as_weak();
+16
View File
@@ -836,6 +836,10 @@ export component AppWindow inherits Window {
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-mask(bool);
callback add-subject-mask(int);
@@ -1922,6 +1926,18 @@ in property <bool> 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); }
}
+64 -1
View File
@@ -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,