From 5f0b7ac799c08225fd43a7a4eff198ac4cc5cfeb Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 24 Sep 2026 20:32:07 -0400 Subject: [PATCH] Offer intersection in the mask panel: an Intersect button and a third chip state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipeline could now keep only where two selections agree, but the panel had no way to ask for it: the part row's chip flipped between + and -, and the buttons under the parts joined an added or a subtracted correction. An "∩ Intersect" button joins a painted part that intersects, and the chip on a part row cycles + -> - -> ∩ and round, so an existing part can be turned into an intersection without being repainted. Both go through the same session calls as before, indexing Join::ALL, whose first two entries kept their places. The chip is now a tagged gesture, so it is in the gesture book. --- ui/dr-ui/src/develop/masks.rs | 4 +++- ui/dr-ui/src/masks_ui.rs | 25 +++++++++++++++++++++++++ ui/dr-ui/ui/masks.slint | 35 ++++++++++++++++++++++++++++------- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/ui/dr-ui/src/develop/masks.rs b/ui/dr-ui/src/develop/masks.rs index 927fee8..f570345 100644 --- a/ui/dr-ui/src/develop/masks.rs +++ b/ui/dr-ui/src/develop/masks.rs @@ -543,7 +543,9 @@ impl DevelopSession { .record(&self.graph, Edit::Action(labels::step::MASK_PART_REMOVED)); } - /// Change how a part joins: added to the mask, or taken out of it. + /// TRACES: FR-DEV-19a + /// Change how a part joins: added to the mask, taken out of it, or kept + /// only where the mask already was. `join` indexes `Join::ALL`. pub fn set_mask_part_join(&mut self, id: &str, index: usize, join: usize) { use dr_pipeline::mask::Join; let Some(&join) = Join::ALL.get(join) else { diff --git a/ui/dr-ui/src/masks_ui.rs b/ui/dr-ui/src/masks_ui.rs index 457733f..2fc23a4 100644 --- a/ui/dr-ui/src/masks_ui.rs +++ b/ui/dr-ui/src/masks_ui.rs @@ -1534,6 +1534,31 @@ mod tests { assert_eq!(s.mask_feather(&id), 0.03, "and the base was left alone"); } + /// TRACES: FR-DEV-19a + /// The panel's third join reaches the layer: the "∩ Intersect" button + /// joins an intersecting part, the part row reports it as the chip's third + /// state, and cycling the chip on from there comes back round to a union. + #[test] + fn the_intersect_button_joins_a_part_that_intersects() { + use dr_pipeline::mask::Join; + let Some(mut s) = session() else { + eprintln!("no adapter; skipping"); + return; + }; + let id = s.add_gradient_mask(true).expect("radial"); + s.set_active_mask(Some(&id)); + + let index = s.add_mask_part(&id, 2).expect("a part was joined"); + let join = |s: &DevelopSession| { + s.masks().get(&id).expect("layer").part(index).expect("part").join + }; + assert_eq!(join(&s), Join::Intersect); + assert_eq!(s.mask_parts(&id)[index].2, 2, "the chip shows ∩"); + + s.set_mask_part_join(&id, index, (2 + 1) % 3); + assert_eq!(join(&s), Join::Union, "and the chip cycles back to +"); + } + /// TRACES: FR-DEV-3 | FR-UI-1 /// The fault this pass exists for, stated as a test. /// diff --git a/ui/dr-ui/ui/masks.slint b/ui/dr-ui/ui/masks.slint index dcd1da3..61dc991 100644 --- a/ui/dr-ui/ui/masks.slint +++ b/ui/dr-ui/ui/masks.slint @@ -89,7 +89,8 @@ export struct PartRow { id: string, /// What this part selects — "brush", "subject", "radial". label: string, - /// 0 adds to the mask, 1 takes away from it. Meaningless on the first. + /// 0 adds to the mask, 1 takes away from it, 2 keeps only where both + /// are (the order of `Join::ALL`). Meaningless on the first. join: int, /// Whether the brush and the edge controls point at this part. selected: bool, @@ -178,7 +179,7 @@ component MaskEntry inherits Rectangle { callback part-join-picked(int, int); callback part-hidden-toggled(int, bool); callback part-removed(int); - /// Join a fresh painted correction: 0 adds, 1 subtracts. + /// Join a fresh painted correction: 0 adds, 1 subtracts, 2 intersects. callback part-added(int); /// TRACES: FR-DEV-19c callback shown-toggled(bool); @@ -362,20 +363,30 @@ component MaskEntry inherits Rectangle { padding-right: Theme.gap-sm; spacing: Theme.gap-sm; - // One chip, two states, because there are two ways to - // join and a photographer flips between them while - // looking at the picture rather than at a menu. + // GESTURE: Change how a part joins its mask + // where: Develop + // touch: Tap the + / − / ∩ chip on the part's row + // pointer: Click the + / − / ∩ chip on the part's row + // why: A chip that cycles rather than a menu, because + // a photographer flips a join while looking at + // the picture, not at the panel: add, take + // away, keep only where both agree, and round. + // + // TRACES: FR-DEV-19a join := TouchArea { width: Theme.touch-target; mouse-cursor: part.base ? MouseCursor.default : MouseCursor.pointer; enabled: root.enabled && !part.base; - clicked => { root.part-join-picked(i, part.join == 0 ? 1 : 0); } + clicked => { root.part-join-picked(i, Math.mod(part.join + 1, 3)); } Label { // The first part joins nothing — a chip saying // "adds" on it would be a control that moves and // changes no pixel. - text: part.base ? "◆" : (part.join == 0 ? "+" : "−"); + text: part.base ? "◆" + : part.join == 0 ? "+" + : part.join == 1 ? "−" + : "∩"; emphasised: join.has-hover || part.selected; horizontal-alignment: center; vertical-alignment: center; @@ -448,6 +459,16 @@ component MaskEntry inherits Rectangle { enabled: root.enabled; clicked => { root.part-added(1); } } + + // TRACES: FR-DEV-19a + // Keep only what this part also covers: paint where the mask + // should survive and everything else falls away — the sky + // that is also bright, the subject that is also skin. + Button { + text: "∩ Intersect"; + enabled: root.enabled; + clicked => { root.part-added(2); } + } } // TRACES: FR-DEV-19b