M6: move radial masks by dragging inside their ellipses

This commit is contained in:
Hanmo
2026-10-09 13:35:02 +08:00
parent 3e76713c1d
commit 40652fc5f9
4 changed files with 105 additions and 2 deletions
+23 -1
View File
@@ -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
+72
View File
@@ -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};
+5 -1
View File
@@ -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 |
+5
View File
@@ -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…