mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 12:37:51 -06:00
M6: move radial masks by dragging inside their ellipses
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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…
|
||||
|
||||
Reference in New Issue
Block a user