mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
LR-MASK-PINS: Object and Describe masks show a pin that selects them (#3049)
With Show Pins on, Object and Describe masks had no pin on the photo, so they could only be picked in the Masking panel. An Object component's pin now sits at its first click and a Describe component's in the middle of what its segmentation selected. Clicking the pin selects the mask; dragging it changes nothing and adds no History step. Subject, Sky, Background, People, Landscape and the range masks still have no pin.
This commit is contained in:
@@ -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<Point> {
|
||||
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<Point> {
|
||||
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);
|
||||
|
||||
@@ -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<f32> = (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::<Vec<_>>())
|
||||
};
|
||||
// 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.
|
||||
|
||||
@@ -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 | ⬜ | | |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user