LR-CROP-OVERLAY + KEY-CROP-CONSTRAIN: diagonal / triangle / spiral guides, A locks the aspect
- The Diagonal crop overlay was offered but drew nothing. Guides are now polylines from one testable module (panels/crop_overlay.rs): thirds, grid, golden ratio, 45° diagonals, harmonic triangle, golden spiral; triangle and spiral mirror (view.cropOverlayOrientation). - While cropping: O cycles the overlay, ⇧O its orientation, A locks the crop's current shape or unlocks it (crop.aspect "current" / "toggle", no refit). Crop panel lists all overlays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b9f7ceb2d7
commit
e7c8adf36e
|
|
@ -400,12 +400,27 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Crop Aspect",
|
||||
[],
|
||||
None,
|
||||
"{aspect: \"original\"|\"free\"|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h]}",
|
||||
"{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h]}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "crop.aspect")?;
|
||||
let (w, h) = crop_dims(s, id);
|
||||
let locked = s.develop_of(id).is_some_and(|d| d.crop.aspect.is_some());
|
||||
let lock_current = match p.get("aspect").and_then(Value::as_str) {
|
||||
Some("current") => true,
|
||||
Some("toggle") => !locked,
|
||||
_ => false,
|
||||
};
|
||||
if lock_current {
|
||||
// lock to the rectangle as it is: no refit
|
||||
return edit(s, "crop.aspect", "Lock Crop Aspect", |d| {
|
||||
let r = d.crop.geometry.rect_px(w, h);
|
||||
d.crop.aspect = Some(((r.width() * 100.0).round().max(1.0) as u32, (r.height() * 100.0).round().max(1.0) as u32));
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let aspect = match p.get("aspect") {
|
||||
Some(Value::String(a)) if a == "toggle" => None,
|
||||
Some(Value::String(a)) if a == "free" => None,
|
||||
Some(Value::String(a)) if a == "original" => Some(((w * 100.0) as u32, (h * 100.0) as u32)),
|
||||
Some(Value::String(a)) => {
|
||||
|
|
|
|||
|
|
@ -339,3 +339,21 @@ fn presets_versions_history_and_select_by() {
|
|||
assert!(s.execute("library.selectBy", &json!({})).is_err());
|
||||
assert!(s.execute("library.selectBy", &json!({"label": "mauve"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_aspect_lock_current_and_toggle() {
|
||||
let mut s = demo();
|
||||
s.execute("crop.set", &json!({"rect": [0.1, 0.2, 0.7, 0.6]})).unwrap();
|
||||
let rect = active_dev(&s).crop.geometry.rect;
|
||||
s.execute("crop.aspect", &json!({"aspect": "toggle"})).unwrap();
|
||||
let d = active_dev(&s);
|
||||
assert_eq!(d.crop.geometry.rect, rect, "locking keeps the rectangle");
|
||||
let (aw, ah) = d.crop.aspect.expect("locked");
|
||||
let id = s.active().unwrap();
|
||||
let p = s.catalog.photo(id).unwrap();
|
||||
let (w, h) = if d.orientation.swaps_axes() { (p.height as f64, p.width as f64) } else { (p.width as f64, p.height as f64) };
|
||||
let want = (rect.x1 - rect.x0) * w / ((rect.y1 - rect.y0) * h);
|
||||
assert!((aw as f64 / ah as f64 - want).abs() < 0.01, "{aw}:{ah} vs {want}");
|
||||
s.execute("crop.aspect", &json!({"aspect": "toggle"})).unwrap();
|
||||
assert!(active_dev(&s).crop.aspect.is_none(), "toggle unlocks");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -591,6 +591,29 @@ mod tests {
|
|||
assert!(last["files"][0]["width"].as_u64().is_some_and(|w| w <= 64));
|
||||
}
|
||||
|
||||
/// While cropping: O cycles the guides, ⇧O mirrors them, A locks / unlocks the aspect.
|
||||
#[test]
|
||||
fn crop_keys() {
|
||||
let mut h = demo([1200.0, 800.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail"}), t);
|
||||
h.request("engine.execute", json!({"command": "panel.crop"}), t);
|
||||
assert_eq!(h.app.ui.crop_overlay, crate::state::CropOverlay::Thirds);
|
||||
let mask_overlay = h.app.ui.mask_overlay;
|
||||
h.request("ui.key", json!({"key": "O"}), t);
|
||||
assert_eq!(h.app.ui.crop_overlay, crate::state::CropOverlay::Grid);
|
||||
h.request("ui.key", json!({"key": "O", "shift": true}), t);
|
||||
assert_eq!(h.app.ui.crop_overlay_orient, 1);
|
||||
assert_eq!(h.app.ui.mask_overlay, mask_overlay, "O doesn't toggle the mask overlay while cropping");
|
||||
let locked = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().crop.aspect.is_some();
|
||||
assert!(!locked(&h));
|
||||
h.request("ui.key", json!({"key": "A"}), t);
|
||||
assert!(locked(&h));
|
||||
h.request("ui.key", json!({"key": "A"}), t);
|
||||
assert!(!locked(&h));
|
||||
assert!(!h.app.ui.visualize_spots);
|
||||
}
|
||||
|
||||
/// ⌘Q (File → Quit LightCraft) closes the window.
|
||||
#[test]
|
||||
fn cmd_q_quits() {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
("view.maskPins", "Show Mask Pins", None, "View"),
|
||||
("view.visualizeSpots", "Visualize Spots", Some("A"), "View"),
|
||||
("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"),
|
||||
("view.cropOverlayOrientation", "Cycle Crop Overlay Orientation", None, "View"),
|
||||
("view.back", "Back to Grid", Some("Escape"), ""),
|
||||
("view.filterBar", "Filter Bar", Some("Shift+F"), "View"),
|
||||
("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"),
|
||||
|
|
@ -380,10 +381,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
|||
Thirds => Grid,
|
||||
Grid => Golden,
|
||||
Golden => Diagonal,
|
||||
Diagonal => None,
|
||||
Diagonal => Triangle,
|
||||
Triangle => Spiral,
|
||||
Spiral => None,
|
||||
None => Thirds,
|
||||
};
|
||||
Ok(Value::Null)
|
||||
Ok(json!({"overlay": app.ui.crop_overlay}))
|
||||
}
|
||||
"view.cropOverlayOrientation" => {
|
||||
app.ui.crop_overlay_orient = (app.ui.crop_overlay_orient + 1) % 4;
|
||||
Ok(json!({"orientation": app.ui.crop_overlay_orient}))
|
||||
}
|
||||
"view.filterBar" => {
|
||||
app.ui.filter_bar = !app.ui.filter_bar;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
//! Crop guide overlays as polylines in crop-relative coordinates (`(u, v)` in `0..=1`, `u` across
|
||||
//! the crop's width, `v` down its height). The crop tool maps them onto the (rotated) frame.
|
||||
//! Composition guides are classic geometry: thirds, a grid, golden-section lines, 45° diagonals
|
||||
//! from the corners, the harmonic triangle and the golden spiral.
|
||||
|
||||
use crate::state::CropOverlay;
|
||||
|
||||
/// Guide lines for `kind` on a `w × h` (pixels, any unit) crop. `orient` (0..4) mirrors the
|
||||
/// asymmetric guides (triangle, spiral): bit 0 across, bit 1 down.
|
||||
pub fn paths(kind: CropOverlay, orient: u8, w: f32, h: f32) -> Vec<Vec<(f32, f32)>> {
|
||||
let (w, h) = (w.max(1e-3), h.max(1e-3));
|
||||
let lines = |fracs: &[f32]| fracs.iter().flat_map(|&f| [vec![(f, 0.0), (f, 1.0)], vec![(0.0, f), (1.0, f)]]).collect();
|
||||
let out: Vec<Vec<(f32, f32)>> = match kind {
|
||||
CropOverlay::None => vec![],
|
||||
CropOverlay::Thirds => lines(&[1.0 / 3.0, 2.0 / 3.0]),
|
||||
CropOverlay::Grid => lines(&(1..8).map(|i| i as f32 / 8.0).collect::<Vec<_>>()),
|
||||
CropOverlay::Golden => lines(&[0.382, 0.618]),
|
||||
// 45° (in pixels) from each corner until it meets the far side
|
||||
CropOverlay::Diagonal => {
|
||||
let (du, dv) = (w.min(h) / w, w.min(h) / h);
|
||||
vec![
|
||||
vec![(0.0, 0.0), (du, dv)],
|
||||
vec![(1.0, 0.0), (1.0 - du, dv)],
|
||||
vec![(0.0, 1.0), (du, 1.0 - dv)],
|
||||
vec![(1.0, 1.0), (1.0 - du, 1.0 - dv)],
|
||||
]
|
||||
}
|
||||
// the main diagonal and the perpendiculars to it from the other two corners
|
||||
CropOverlay::Triangle => {
|
||||
let foot = |px: f32, py: f32| {
|
||||
let n = (w * w + h * h).sqrt();
|
||||
let (dx, dy) = (w / n, h / n);
|
||||
let t = px * dx + py * dy;
|
||||
(t * dx / w, t * dy / h)
|
||||
};
|
||||
vec![vec![(0.0, 0.0), (1.0, 1.0)], vec![(1.0, 0.0), foot(w, 0.0)], vec![(0.0, 1.0), foot(0.0, h)]]
|
||||
}
|
||||
CropOverlay::Spiral => spiral(w, h),
|
||||
};
|
||||
let (fu, fv) = (orient & 1 == 1, orient & 2 == 2);
|
||||
let flip = |(u, v): (f32, f32)| (if fu { 1.0 - u } else { u }, if fv { 1.0 - v } else { v });
|
||||
match kind {
|
||||
CropOverlay::Triangle | CropOverlay::Spiral => out.into_iter().map(|l| l.into_iter().map(flip).collect()).collect(),
|
||||
_ => out,
|
||||
}
|
||||
}
|
||||
|
||||
/// The golden spiral: squares carved off a `w × h` rectangle (left, top, right, bottom, …), a
|
||||
/// quarter circle in each, joined into one curve; plus the square boundaries.
|
||||
fn spiral(w: f32, h: f32) -> Vec<Vec<(f32, f32)>> {
|
||||
// work landscape; transpose back for portrait crops
|
||||
let portrait = h > w;
|
||||
let (rw, rh) = if portrait { (h, w) } else { (w, h) };
|
||||
let (mut x, mut y, mut cw, mut ch) = (0.0f32, 0.0f32, rw, rh);
|
||||
let mut curve = Vec::new();
|
||||
let mut squares = Vec::new();
|
||||
for k in 0..10 {
|
||||
let s = cw.min(ch);
|
||||
if s < rw.min(rh) * 0.01 {
|
||||
break;
|
||||
}
|
||||
// square corner origin, arc centre, start angle (degrees, y down)
|
||||
let (sx, sy, cx, cy, a0) = match k % 4 {
|
||||
0 => (x, y, x + s, y + s, 180.0f32),
|
||||
1 => (x, y, x, y + s, 270.0),
|
||||
2 => (x + cw - s, y, x + cw - s, y, 0.0),
|
||||
_ => (x, y + ch - s, x + s, y + ch - s, 90.0),
|
||||
};
|
||||
for i in 0..=12 {
|
||||
let a = (a0 + 90.0 * i as f32 / 12.0).to_radians();
|
||||
curve.push((cx + s * a.cos(), cy + s * a.sin()));
|
||||
}
|
||||
squares.push(vec![(sx, sy), (sx + s, sy), (sx + s, sy + s), (sx, sy + s), (sx, sy)]);
|
||||
match k % 4 {
|
||||
0 => {
|
||||
x += s;
|
||||
cw -= s;
|
||||
}
|
||||
1 => {
|
||||
y += s;
|
||||
ch -= s;
|
||||
}
|
||||
2 => cw -= s,
|
||||
_ => ch -= s,
|
||||
}
|
||||
}
|
||||
let norm = |(px, py): (f32, f32)| if portrait { (py / rh, px / rw) } else { (px / rw, py / rh) };
|
||||
squares.push(curve);
|
||||
squares.into_iter().map(|l| l.into_iter().map(norm).collect()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn in_unit(paths: &[Vec<(f32, f32)>]) -> bool {
|
||||
paths.iter().flatten().all(|&(u, v)| (-1e-4..=1.0001).contains(&u) && (-1e-4..=1.0001).contains(&v))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_overlay_stays_inside_the_crop() {
|
||||
for kind in [CropOverlay::Thirds, CropOverlay::Grid, CropOverlay::Golden, CropOverlay::Diagonal, CropOverlay::Triangle, CropOverlay::Spiral] {
|
||||
for (w, h) in [(3.0, 2.0), (2.0, 3.0), (1.0, 1.0), (16.0, 9.0)] {
|
||||
for o in 0..4 {
|
||||
let p = paths(kind, o, w, h);
|
||||
assert!(!p.is_empty() && in_unit(&p), "{kind:?} {w}×{h} {o}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(paths(CropOverlay::None, 0, 3.0, 2.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagonals_are_45_degrees_and_triangle_feet_are_perpendicular() {
|
||||
let (w, h) = (3.0, 2.0);
|
||||
for l in paths(CropOverlay::Diagonal, 0, w, h) {
|
||||
let (a, b) = (l[0], l[1]);
|
||||
assert!((((b.0 - a.0) * w).abs() - ((b.1 - a.1) * h).abs()).abs() < 1e-4, "{l:?}");
|
||||
}
|
||||
let t = paths(CropOverlay::Triangle, 0, w, h);
|
||||
for l in &t[1..] {
|
||||
let (a, b) = (l[0], l[1]);
|
||||
// perpendicular to the main diagonal (w, h) in pixels
|
||||
let dot = (b.0 - a.0) * w * w + (b.1 - a.1) * h * h;
|
||||
assert!(dot.abs() < 1e-3, "{l:?}");
|
||||
}
|
||||
// orientation mirrors the triangle
|
||||
assert_eq!(paths(CropOverlay::Triangle, 1, w, h)[0], vec![(1.0, 0.0), (0.0, 1.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spiral_is_one_continuous_curve() {
|
||||
for (w, h) in [(1.618, 1.0), (1.0, 1.618), (3.0, 2.0)] {
|
||||
let p = paths(CropOverlay::Spiral, 0, w, h);
|
||||
let curve = p.last().unwrap();
|
||||
for pair in curve.windows(2) {
|
||||
let d = ((pair[1].0 - pair[0].0) * w).hypot((pair[1].1 - pair[0].1) * h);
|
||||
assert!(d < 0.2 * w.max(h), "jump {d} in {w}×{h}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ use serde_json::json;
|
|||
|
||||
use crate::LightcraftApp;
|
||||
use crate::render::Slot;
|
||||
use crate::state::{BeforeAfter, CropOverlay, RightPanel, Zoom};
|
||||
use crate::state::{BeforeAfter, RightPanel, Zoom};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
|
|
@ -586,15 +586,11 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
|
|||
// overlay guides
|
||||
let lerp = |a: Pos2, b: Pos2, t: f32| a + (b - a) * t;
|
||||
let guide = Stroke::new(0.8, Color32::from_white_alpha(150));
|
||||
let fracs: Vec<f32> = match app.ui.crop_overlay {
|
||||
CropOverlay::Thirds => vec![1.0 / 3.0, 2.0 / 3.0],
|
||||
CropOverlay::Grid => (1..8).map(|i| i as f32 / 8.0).collect(),
|
||||
CropOverlay::Golden => vec![0.382, 0.618],
|
||||
CropOverlay::Diagonal | CropOverlay::None => vec![],
|
||||
};
|
||||
for f in fracs {
|
||||
p.line_segment([lerp(pts[0], pts[1], f), lerp(pts[3], pts[2], f)], guide);
|
||||
p.line_segment([lerp(pts[0], pts[3], f), lerp(pts[1], pts[2], f)], guide);
|
||||
// (u, v) across / down the (rotated) crop frame
|
||||
let at = |(u, v): (f32, f32)| pts[0] + (pts[1] - pts[0]) * u + (pts[3] - pts[0]) * v;
|
||||
let (cw, ch) = (pts[0].distance(pts[1]), pts[0].distance(pts[3]));
|
||||
for line in super::crop_overlay::paths(app.ui.crop_overlay, app.ui.crop_overlay_orient, cw, ch) {
|
||||
p.add(egui::Shape::line(line.into_iter().map(at).collect(), guide));
|
||||
}
|
||||
// handles: 0..3 corners, 4..7 edges (top, right, bottom, left)
|
||||
let handles: Vec<Pos2> = (0..8).map(|i| if i < 4 { pts[i] } else { lerp(pts[i - 4], pts[(i - 3) % 4], 0.5) }).collect();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
pub mod bottombar;
|
||||
pub mod compare;
|
||||
pub mod crop_overlay;
|
||||
pub mod detail;
|
||||
pub mod dialogs;
|
||||
pub mod edit;
|
||||
|
|
|
|||
|
|
@ -132,12 +132,24 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
|||
ui.label("Overlay");
|
||||
{
|
||||
use crate::state::CropOverlay as O;
|
||||
let opts = [(O::Thirds, "Thirds", "thirds"), (O::Grid, "Grid", "grid"), (O::Golden, "Golden", "golden"), (O::None, "None", "none")];
|
||||
let opts = [
|
||||
(O::Thirds, "Thirds", "thirds"),
|
||||
(O::Grid, "Grid", "grid"),
|
||||
(O::Golden, "Golden", "golden"),
|
||||
(O::Diagonal, "Diagonal", "diagonal"),
|
||||
(O::Triangle, "Triangle", "triangle"),
|
||||
(O::Spiral, "Spiral", "spiral"),
|
||||
(O::None, "None", "none"),
|
||||
];
|
||||
let items: Vec<(&str, &str)> = opts.iter().map(|(_, l, k)| (*l, *k)).collect();
|
||||
let active = opts.iter().position(|(o, _, _)| *o == app.ui.crop_overlay);
|
||||
if let Some(i) = crate::widgets::segmented(ui, "cropOverlay", &items, active, 4) {
|
||||
app.ui.crop_overlay = opts[i].0;
|
||||
}
|
||||
if matches!(app.ui.crop_overlay, O::Triangle | O::Spiral) && text_button(ui, "cropOverlayOrient", "Flip Overlay (⇧O)", false).clicked()
|
||||
{
|
||||
let _ = app.run("view.cropOverlayOrientation", json!({}));
|
||||
}
|
||||
}
|
||||
});
|
||||
divider(ui);
|
||||
|
|
|
|||
|
|
@ -208,6 +208,19 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
let _ = app.run("view.maskOverlayMode", json!({}));
|
||||
continue;
|
||||
}
|
||||
// while cropping: O cycles the guides, Shift+O their orientation, A locks the aspect
|
||||
if app.ui.right == crate::state::RightPanel::Crop {
|
||||
let crop_key = match f.as_str() {
|
||||
"view.maskOverlay" => Some(("view.cropOverlay", json!({}))),
|
||||
"view.cropOverlay" => Some(("view.cropOverlayOrientation", json!({}))),
|
||||
"view.visualizeSpots" => Some(("crop.aspect", json!({"aspect": "toggle"}))),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((cmd, p)) = crop_key {
|
||||
let _ = app.run(cmd, p);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let _ = app.run(&f, json!({}));
|
||||
match f.as_str() {
|
||||
"photo.pick" => app.toast(ctx, "Flagged as Pick"),
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ pub enum CropOverlay {
|
|||
Grid,
|
||||
Golden,
|
||||
Diagonal,
|
||||
Triangle,
|
||||
Spiral,
|
||||
None,
|
||||
}
|
||||
|
||||
|
|
@ -196,6 +198,8 @@ pub struct UiState {
|
|||
pub mask_overlay_color: [u8; 3],
|
||||
pub mask_overlay_opacity: f32,
|
||||
pub mask_pins: bool,
|
||||
/// Mirroring of the triangle / spiral crop guides (0..4).
|
||||
pub crop_overlay_orient: u8,
|
||||
pub crop_overlay: CropOverlay,
|
||||
pub show_filenames: bool,
|
||||
pub search: String,
|
||||
|
|
@ -403,6 +407,7 @@ impl Default for UiState {
|
|||
mask_overlay_opacity: 50.0,
|
||||
mask_pins: true,
|
||||
crop_overlay: CropOverlay::Thirds,
|
||||
crop_overlay_orient: 0,
|
||||
show_filenames: true,
|
||||
search: String::new(),
|
||||
focus_search: false,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
| E. Metadata (META) | 3 | 2 | 1 | 0 | 2/2 (100%) | 1/2 (50%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 40 | 2 | 6 | 1 | 28/28 (100%) | 12/14 (86%) |
|
||||
| G. Profiles (PROF) | 6 | 0 | 4 | 0 | 3/3 (100%) | 2/3 (67%) |
|
||||
| H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) |
|
||||
| H. Crop & rotate (CROP) | 8 | 1 | 0 | 1 | 6/6 (100%) | 2/3 (67%) |
|
||||
| I. Remove / healing (REM) | 6 | 1 | 3 | 2 | 4/4 (100%) | 2/3 (67%) |
|
||||
| J. Red eye (EYE) | 1 | 0 | 1 | 0 | — | 1/1 (100%) |
|
||||
| K. Masking (MASK) | 11 | 5 | 7 | 0 | 8/8 (100%) | 3/5 (60%) |
|
||||
|
|
@ -46,9 +46,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
| W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 11 | 2 | 4 | 1 | 8/8 (100%) | 3/5 (60%) |
|
||||
| Y. Menus | 65 | 9 | 11 | 8 | 46/47 (98%) | 17/23 (74%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 59 | 12 | 9 | 1 | 49/52 (94%) | 10/23 (43%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 61 | 12 | 7 | 1 | 49/52 (94%) | 12/23 (52%) |
|
||||
| Lightroom Classic extras | 7 | 19 | 53 | 9 | — | 6/21 (29%) |
|
||||
| **Total** | 292 | 65 | 143 | 37 | 192/198 (97%) | 90/144 (63%) |
|
||||
| **Total** | 295 | 64 | 141 | 37 | 192/198 (97%) | 93/144 (65%) |
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
|
|
@ -274,7 +274,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle` | |
|
||||
| LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | |
|
||||
| LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | |
|
||||
| LR-CROP-OVERLAY | Crop overlays | P1 | 🟡 | `cmd:view.cropOverlay` | thirds, grid, golden ratio, diagonal; no triangle / spiral / aspect overlays or orientation cycle |
|
||||
| LR-CROP-OVERLAY | Crop overlays | P1 | ✅ | `cmd:view.cropOverlay`, `cmd:view.cropOverlayOrientation`, `crates/ui-egui/src/panels/crop_overlay.rs` | thirds, grid, golden ratio, diagonal, triangle, golden spiral (mirrored with ⇧O while cropping); no aspect-ratio overlays |
|
||||
| LR-CROP-ZOOM | Zoom while cropping | P1 | 🟡 | | unverified |
|
||||
| LR-CROP-GENEXPAND | Generative expand | OOS | 🚫 | | |
|
||||
|
||||
|
|
@ -662,9 +662,9 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
|
|||
| KEY-RESETALL | Reset all — ⇧⌘R | P0 | ✅ | `cmd:develop.reset` | |
|
||||
| KEY-GENAI | Generative toggles / variations — ⌥⇧G, ⌥←/→ | OOS | 🚫 | | |
|
||||
| KEY-DETECT | Detect objects — ⌥⇧O | P2 | ⬜ | | |
|
||||
| KEY-CROP-CONSTRAIN | Lock crop aspect — A | P1 | ⬜ | | |
|
||||
| KEY-CROP-CONSTRAIN | Lock crop aspect — A | P1 | ✅ | `cmd:crop.aspect` (`toggle`, `current`) | A while cropping locks the current shape / unlocks |
|
||||
| KEY-CROP-SWAP | Swap crop orientation — X | P0 | ✅ | `cmd:crop.rotateAspect` | |
|
||||
| KEY-CROP-OVERLAYORIENT | Crop overlay orientation — ⇧O | P1 | ⬜ | | |
|
||||
| KEY-CROP-OVERLAYORIENT | Crop overlay orientation — ⇧O | P1 | ✅ | `cmd:view.cropOverlayOrientation` | while cropping: O cycles the overlay, ⇧O its orientation |
|
||||
| KEY-CROP-RESET | Reset crop — ⌥⌘R | P0 | ✅ | `cmd:crop.reset` | |
|
||||
| KEY-STRAIGHTEN | Straighten while held — ⌘ drag | P1 | ⬜ | | |
|
||||
| KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | |
|
||||
|
|
|
|||
Loading…
Reference in New Issue