diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index 365ae2311..0ab7bb5ee 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -187,16 +187,36 @@ pub(super) fn region_overlay( // ------------------------------------------------------------------------ masks -/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo. +/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo: an +/// Object at its first click, a Describe mask on what its segmentation found (issue #3049). pub(crate) fn component_pin(shape: &MaskShape) -> Option { match shape { MaskShape::Radial { center, .. } => Some(*center), MaskShape::Linear { start, end } => Some(Point::new((start.x + end.x) / 2.0, (start.y + end.y) / 2.0)), MaskShape::Brush { strokes } => strokes.iter().find(|s| !s.erase).and_then(|s| s.points.first()).copied(), + MaskShape::Object { hint, seg, .. } => hint.first().copied().or_else(|| seg.as_ref().and_then(seg_center)), + MaskShape::Prompt { seg, .. } => seg.as_ref().and_then(seg_center), _ => None, } } +/// The middle of what a segmentation selects (the mean of its cells inside the mask), else of the +/// part of the image it covers; `None` when it is damaged. +fn seg_center(seg: &lightcraft_develop::SegMask) -> Option { + let [x0, y0, x1, y1] = seg.bounds()?; + let logits = seg.logits()?; + let side = seg.side as usize; + let (mut sx, mut sy, mut n) = (0.0, 0.0, 0usize); + for (i, _) in logits.iter().enumerate().filter(|(_, l)| **l > 0.0) { + sx += (i % side) as f64 + 0.5; + sy += (i / side) as f64 + 0.5; + n += 1; + } + let cells = n as f64 * side as f64; + let (u, v) = if n == 0 { (0.5, 0.5) } else { (sx / cells, sy / cells) }; + Some(Point::new(x0 + u * (x1 - x0), y0 + v * (y1 - y0))) +} + /// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`. fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &CanvasMap, alt: bool) -> MaskShape { let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y); diff --git a/crates/ui-egui/src/tests/masking.rs b/crates/ui-egui/src/tests/masking.rs index c8d6d6c2e..444096d15 100644 --- a/crates/ui-egui/src/tests/masking.rs +++ b/crates/ui-egui/src/tests/masking.rs @@ -363,6 +363,51 @@ fn mask_overlay_keys_and_pins() { h.settle(SETTLE); } +/// Issue #3049: Show Pins draws a pin for an Object mask (at its first click) and a Describe +/// mask (on what its segmentation found). Clicking one selects its mask; dragging it leaves the +/// mask and the History alone, since neither can be moved. +#[test] +fn object_and_describe_masks_show_pins_that_select_but_do_not_move() { + use lightcraft_develop::SegMask; + use lightcraft_geom::Point; + let mut h = detail("panel.masking"); + // a 16 × 16 segmentation found in columns 2..=5, rows 10..=13: centred on (0.25, 0.75) + let blob: Vec = (0..16 * 16).map(|i| if (2..=5).contains(&(i % 16)) && (10..=13).contains(&(i / 16)) { 8.0 } else { -8.0 }).collect(); + let seg = SegMask::from_logits(16, &blob); + let object = MaskShape::Object { hint: vec![Point::new(0.6, 0.5)], exclude: vec![], seg: Some(seg.clone()), detail: vec![], edge: 0.0 }; + let prompt = MaskShape::Prompt { text: "the boat".into(), seg: Some(seg), detail: vec![], edge: 0.0 }; + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.1, "ry": 0.1})); + exec(&mut h, "mask.update", json!({"id": 1, "shape": object})); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.1, "ry": 0.1})); + exec(&mut h, "mask.update", json!({"id": 2, "shape": prompt})); + assert_eq!(h.app.session.active_mask, Some(2)); + // pins shown without the pointer over the photo + exec(&mut h, "view.toolOverlay", json!({"mode": "always"})); + h.settle(SETTLE); + let pin_at = |h: &Headless, id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| r.center()); + let (Some(object_pin), Some(prompt_pin)) = (pin_at(&h, "maskPin:1:0"), pin_at(&h, "maskPin:2:0")) else { + panic!("both masks have a pin: {:?}", h.app.widgets.iter().filter(|(w, _)| w.starts_with("maskPin:")).collect::>()) + }; + // the Describe pin sits on what was found, right of and below the Object's + assert!(prompt_pin.x < object_pin.x && prompt_pin.y > object_pin.y, "{object_pin:?} {prompt_pin:?}"); + // clicking a pin selects its mask + pointer(&mut h, json!([{"kind": "down", "x": 0.6, "y": 0.5}, {"kind": "up", "x": 0.6, "y": 0.5}])); + assert_eq!(h.app.session.active_mask, Some(1)); + pointer(&mut h, json!([{"kind": "down", "x": 0.25, "y": 0.75}, {"kind": "up", "x": 0.25, "y": 0.75}])); + assert_eq!(h.app.session.active_mask, Some(2)); + // dragging one changes nothing and adds no History step + let id = h.app.session.active().expect("active photo"); + let before = h.app.session.catalog.photo(id).expect("photo").clone(); + pointer( + &mut h, + json!([{"kind": "down", "x": 0.6, "y": 0.5}, {"kind": "drag", "x": 0.65, "y": 0.55}, {"kind": "drag", "x": 0.8, "y": 0.7}, {"kind": "up", "x": 0.8, "y": 0.7}]), + ); + assert_eq!(h.app.session.active_mask, Some(1), "the press still selects the mask"); + let after = h.app.session.catalog.photo(id).expect("photo").clone(); + assert_eq!((after.develop.clone(), after.history.len()), (before.develop.clone(), before.history.len())); + h.settle(SETTLE); +} + /// #2210: Ctrl+Shift+O (the all-masks overlay) draws the white outline of every visible /// Radial/Linear mask, not just the selected one — but only the selected mask registers /// `maskHandle:` elements, so nothing else becomes draggable. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..1c3abd736 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -310,7 +310,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-EFFECTS | Local adjustment (effect / brush) presets | P1 | ✅ | `cmd:mask.effects`, `cmd:mask.applyEffect`, `cmd:mask.saveEffect`, `cmd:mask.updateEffect`, `cmd:mask.renameEffect`, `cmd:mask.duplicateEffect`, `cmd:mask.deleteEffect`, `cmd:mask.favoriteEffect`, `cmd:mask.setEffectGroup`, `cmd:mask.renameEffectGroup`, `cmd:mask.toolEffect`, `cmd:mask.importEffects`, `cmd:mask.exportEffects`, `cmd:dialog.saveMaskEffect`, `cmd:dialog.manageMaskEffects`, `cmd:file.exportMaskEffects`, `crates/engine/src/cmd/mask_effects.rs`, `crates/engine/src/cmd/mask_effect_files.rs`, `crates/ui-egui/src/panels/mask_effects.rs` | Effect menu above the mask sliders: grouped (folders, ★ Favorites first), searchable, scrolls, ↑ ↓ Return, ✓ on the matching effect (else Custom), resting on an effect previews it on the photo; right-click: apply to all masks / to the same-named mask on the selected photos (one undo step), update, rename, duplicate, star, move to group, delete (asks first); Reset Sliders; New Effect Preset dialog: which sliders (All / None / Changed Only), Amount, brush size/feather/flow; any mask type; Manage Effect Presets window (search, inline rename, update, duplicate, stars, groups, export, delete with confirmation); a new mask starts with the effect last applied to a mask of its kind (per tool, kept with the library); imports Lightroom Classic local `.lrtemplate` presets, Camera Raw local-correction `.xmp` presets, `.lceffect` and `.zip` bundles, through File ▸ Import Profiles & Presets… and drag & drop too; folders and LrC divider presets (`10 ----- DETAIL ----- 10`) become groups; settings with no local slider here (`grain`, Point Color `pointColors`, `toningLuminance`) are reported and kept with the effect for export, not applied (🟡 for those); brush size/feather/flow aren't in LrC presets (it keeps them per brush), so only `.lceffect` carries them; no built-in effects | | LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until O / the eye shows it again or a mask is created, picked, duplicated or added to (issue #1800); shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests/preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | | LR-MASK-OVERLAYALL | Show all mask overlays | P1 | ✅ | `cmd:view.maskOverlayAll`, `crates/ui-egui/src/panels/masking.rs`, `crates/pipeline/src/visualize.rs` | one diagnostic tint over every visible mask at once (CPU + GPU): the masks still stack their adjustments as before — this only paints each mask's region together, so you can see all masked areas at a glance (⌘⇧O / Ctrl+Shift+O) | -| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `cmd:view.toolOverlay`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/tests/tool_overlay.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; Show Pins on follows the Tool Overlay mode (Auto: while the pointer is over the photo, hidden while brushing; Always; Selected: the selected mask's; Never; ⇧H cycles, #224); no pins for range/AI components | +| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `cmd:view.toolOverlay`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/tests/tool_overlay.rs`, `crates/ui-egui/src/tests/masking.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; Object (at its first click) and Describe (on what it found) components have pins too: click selects, drag changes nothing (issue #3049); Show Pins on follows the Tool Overlay mode (Auto: while the pointer is over the photo, hidden while brushing; Always; Selected: the selected mask's; Never; ⇧H cycles, #224); no pins for range components or Subject, Sky, Background, People and Landscape | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | | LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | | | LR-MASK-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..9583209df 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-29 + +### Object and Describe masks show their pins +- With Show Pins on, an Object mask now has a pin where you first clicked the object, and a Describe mask has one in + the middle of what its description found. Before, these masks had no pin on the photo, so they couldn't be picked + there. Clicking a pin selects its mask; dragging it changes nothing, as these masks follow what they selected. + Subject, Sky, Background and the range masks still have no pin. + ## 2026-10-24 ### Relative-date filters and smart albums follow the clock