diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index ea3d782b5..2e708da66 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1434,6 +1434,22 @@ fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &Canvas } } +/// Hit the geometric ellipse, independently of feather/inversion. Radii use long-edge units, +/// so undo the same scaling and rotation used to draw its outline before testing containment. +fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool { + let MaskShape::Radial { center, rx, ry, angle, .. } = shape else { return false }; + if !rx.is_finite() || !ry.is_finite() || *rx <= 0.0 || *ry <= 0.0 { + return false; + } + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let (s, co) = angle.to_radians().sin_cos(); + let x = (dx * co + dy * s) / rx; + let y = (-dx * s + dy * co) / ry; + x * x + y * y <= 1.0 +} + /// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask /// component (click selects its mask, drag moves the component), and brush painting. The mask /// itself shows as a rendered overlay ([`view_overlay`]). @@ -1608,8 +1624,14 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) { let grip = hit(q).map(|(m, c, h, _)| (m, c, h)).or_else(|| { - // anywhere on the photo drags the selected linear gradient let m = d.masks.iter().find(|m| Some(m.id) == active)?; + // Handles take precedence; otherwise grab the selected radial component under + // the press, including components after the first in compound masks. + let at = map.norm(q); + if let Some(ci) = m.components.iter().position(|c| radial_body_contains(&c.shape, at, map)) { + return Some((m.id, ci, 0)); + } + // anywhere on the photo drags the selected linear gradient matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0)) }); if let Some((mask, comp, handle)) = grip diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 0729a854f..6a5f59e17 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -39,6 +39,78 @@ fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings { (*h.app.session.develop_of(id).unwrap_or_default()).clone() } +#[test] +fn radial_body_drag_moves_rotated_components_and_undoes_once() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "luminanceRange"})); + exec(&mut h, "mask.addComponent", json!({"kind": "radial", "op": "add", "center": [0.45, 0.5], "rx": 0.22, "ry": 0.1, "angle": 40})); + let before = develop(&h); + // Well away from the pin and edge/rotation grips, inside a rotated ellipse. + pointer( + &mut h, + json!([{"kind": "down", "x": 0.51, "y": 0.56}, {"kind": "drag", "x": 0.54, "y": 0.58}, + {"kind": "drag", "x": 0.61, "y": 0.66}, {"kind": "up", "x": 0.61, "y": 0.66}]), + ); + let after = develop(&h); + let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &after.masks[0].components[1].shape else { panic!("radial") }; + assert!((center.x - 0.55).abs() < 0.005 && (center.y - 0.6).abs() < 0.005, "{center:?}"); + let MaskShape::Radial { rx: old_rx, ry: old_ry, angle: old_angle, feather: old_feather, invert: old_invert, .. } = + &before.masks[0].components[1].shape + else { + panic!("radial") + }; + assert_eq!((rx, ry, angle, feather, invert), (old_rx, old_ry, old_angle, old_feather, old_invert)); + assert_eq!(after.masks[0].components[0], before.masks[0].components[0], "only the hit component moves"); + exec(&mut h, "edit.undo", json!({})); + assert_eq!(develop(&h), before, "one undo reverses the entire drag"); + exec(&mut h, "edit.redo", json!({})); + assert_eq!(develop(&h), after); + // The bounding-box corner is outside the ellipse, even for an inverted radial mask. + exec(&mut h, "mask.component", json!({"component": 1, "action": "invert"})); + let outside_before = develop(&h); + pointer( + &mut h, + json!([{"kind": "down", "x": 0.35, "y": 0.75}, {"kind": "drag", "x": 0.4, "y": 0.8}, + {"kind": "up", "x": 0.4, "y": 0.8}]), + ); + assert_eq!(develop(&h), outside_before, "outside the ellipse must not move it"); + // Hiding pins does not disable dragging the selected ellipse body. + exec(&mut h, "view.maskPins", json!({"show": false})); + pointer( + &mut h, + json!([{"kind": "down", "x": 0.61, "y": 0.66}, {"kind": "drag", "x": 0.66, "y": 0.71}, + {"kind": "up", "x": 0.66, "y": 0.71}]), + ); + let MaskShape::Radial { center, .. } = develop(&h).masks[0].components[1].shape.clone() else { panic!("radial") }; + assert!((center.x - 0.6).abs() < 0.005 && (center.y - 0.65).abs() < 0.005, "{center:?}"); +} + +#[test] +fn radial_body_drag_keeps_resize_and_rotation_handles() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.18, "ry": 0.1})); + for handle in [1, 5] { + let widget = format!("maskHandle:1:0:{handle}"); + let rect = h.app.widgets.iter().find(|(id, _)| *id == widget).unwrap().1; + let image = h.app.image_rect.unwrap(); + let q = rect.center(); + let x = (q.x - image.left()) / image.width(); + let y = (q.y - image.top()) / image.height(); + pointer( + &mut h, + json!([{"kind": "down", "x": x, "y": y}, {"kind": "drag", "x": x + 0.03, "y": y + 0.03}, + {"kind": "up", "x": x + 0.03, "y": y + 0.03}]), + ); + let MaskShape::Radial { center, rx, angle, .. } = develop(&h).masks[0].components[0].shape.clone() else { panic!("radial") }; + assert!((center.x - 0.5).abs() < 0.005 && (center.y - 0.5).abs() < 0.005, "a handle must not translate the mask"); + if handle == 1 { + assert!(rx > 0.19, "resize grip changes radius"); + } else { + assert!(angle.abs() > 1.0, "rotation grip changes angle"); + } + } +} + #[test] fn mask_overlay_keys_and_pins() { use lightcraft_pipeline::{MaskView, Overlay}; diff --git a/docs/parity.md b/docs/parity.md index 066bd45a9..df9d35f09 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -59,6 +59,10 @@ Ordered by user impact, then tier, then effort. The checklist above counts featu that decide whether a photographer can switch (see the honest assessment in [ROADMAP.md](../ROADMAP.md#where-we-stand)). Take the first one nobody is working on. +Radial-gradient interaction: the selected ellipse can be moved by dragging anywhere inside its +outline (including a radial component of a compound mask); resize/rotation grips take precedence. +Regression coverage includes rotated ellipses, outside clicks, hidden pins and one-step undo. + 1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF and Panasonic RW2 now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW and NEF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0); Nikon CropArea and untagged-preview ColorSpace metadata are respected; measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. 2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, Nikon 1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW, NEF and RAF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0; RAF: 23 of 24 references accepted with bundled X-H2S/X-T4 profiles; 150 withheld-file tests improve mean ΔE slightly over per-file fitting); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. @@ -354,7 +358,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | | LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | -| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update` | | +| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested | | LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in), Pick button, Refine slider | | LR-MASK-LUMRANGE | Luminance range | P1 | ✅ | `cmd:mask.add` (`luminanceRange`), `cmd:mask.update`, `crates/ui-egui/src/panels/masking.rs` (`range_controls`) | range bar with two handles (one undo step per drag), Smoothness, Show Luminance Map (B&W photo with the range tinted) | | LR-MASK-DEPTHRANGE | Depth range | P2 | ⬜ | | shape exists, needs depth data | diff --git a/docs/whats-new.md b/docs/whats-new.md index 133770dcd..54cb8a97b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## October 2026 +### Masking +- Drag anywhere inside the selected radial gradient's ellipse to move it, including rotated + ellipses and radial components in compound masks. Edge and rotation handles keep their existing + functions, and one Undo restores the entire drag. + ### Albums tree - Album folders fold with the same disclosure triangle as Local, By Date, Folders and Keywords. - Right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder…