diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index a53cc9ea7..15d7972c2 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -150,6 +150,54 @@ fn masks_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec Vec { + let Some(&first) = to.points.first() else { return vec![to] }; + let same = |a: f64, b: f64| (a - b).abs() <= 1e-9; + if same(last.size, to.size) && same(last.feather, to.feather) && same(last.flow, to.flow) { + let mut points = Vec::with_capacity(to.points.len() + 1); + points.push(from); + points.extend(to.points.iter().copied()); + // (the joining point takes the pen pressure of the stroke's first one) + let pressure = + if to.pressure.is_empty() { Vec::new() } else { std::iter::once(to.pressure_at(0)).chain(to.pressure.iter().copied()).collect() }; + return vec![BrushStroke { points, pressure, ..to }]; + } + // about 5% of size, or 2 points of feather or flow, per piece + let ratio = if last.size > 0.0 && to.size > 0.0 { (to.size / last.size).ln().abs() / 1.05f64.ln() } else { 0.0 }; + let needed = [ratio, (to.feather - last.feather).abs() / 2.0, (to.flow - last.flow).abs() / 2.0] + .into_iter() + .filter(|v| v.is_finite()) + .fold(1.0f64, f64::max); + let steps = (needed.ceil() as usize).clamp(1, MAX_TAPER_STEPS); + let lerp = |a: f64, b: f64, t: f64| a + (b - a) * t; + let at = |t: f64| Point::new(lerp(from.x, first.x, t), lerp(from.y, first.y, t)); + let mut out: Vec = (0..steps) + .map(|i| { + let (t0, t1) = (i as f64 / steps as f64, (i + 1) as f64 / steps as f64); + BrushStroke { + points: vec![at(t0), at(t1)], + size: lerp(last.size, to.size, t1), + feather: lerp(last.feather, to.feather, t1), + flow: lerp(last.flow, to.flow, t1), + // (one pressure for the whole piece: the stroke's first point's) + pressure: if to.pressure.is_empty() { Vec::new() } else { vec![to.pressure_at(0)] }, + ..to.clone() + } + }) + .collect(); + if to.points.len() > 1 { + out.push(to); + } + out +} + fn mask_id(p: &Value, active: Option, c: &str) -> Result { p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)")) } @@ -233,7 +281,7 @@ pub fn specs() -> Vec { "Brush Stroke", [], None, - "{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, pressure?: [p,…] pen pressure 0..1 per point (scales each dab's size and flow; omitted: full)}", + "{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, pressure?: [p,…] pen pressure 0..1 per point (scales each dab's size and flow; omitted: full), connect?: bool — start from the last point of the mask's last stroke (⇧-click), size / feather / flow tapering from that stroke's to these} → {activeMask, connected}", has_active, |s, p| { let pts: Vec = p @@ -255,10 +303,12 @@ pub fn specs() -> Vec { auto_mask: bool_or(p, "autoMask", false), pressure: pressure_list(p.get("pressure"), pts_len)?, }; + let connect = bool_or(p, "connect", false); + let mut connected = false; let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1); let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask); let adjust = super::mask_effects::new_mask_adjust(s, "brush"); - masks_edit(s, "mask.brushStroke", "Brush", |masks, active| { + let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| { let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) { Some(i) => i, None => { @@ -271,7 +321,20 @@ pub fn specs() -> Vec { if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) = m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. })) { - strokes.push(stroke); + // the previous stroke's end and its brush values (not its points) + let anchor = strokes.last().filter(|_| connect).and_then(|l| { + Some(( + *l.points.last()?, + BrushStroke { points: Vec::new(), size: l.size, feather: l.feather, flow: l.flow, ..BrushStroke::default() }, + )) + }); + match anchor { + Some((from, last)) => { + strokes.extend(connected_strokes(from, &last, stroke)); + connected = true; + } + None => strokes.push(stroke), + } } else { m.components.push(MaskComponent { name: None, @@ -281,7 +344,11 @@ pub fn specs() -> Vec { }); } Ok(()) - }) + })?; + if let Some(o) = out.as_object_mut() { + o.insert("connected".into(), json!(connected)); + } + Ok(out) } ), cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 05ac3ee05..b51028b5c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -313,6 +313,44 @@ fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() { assert_eq!(s.catalog.to_snapshot(), snapshot); } +/// `mask.brushStroke {connect: true}` (a ⇧-click) starts from the last point of the mask's last stroke: one straight +/// stroke when the brush is unchanged, pieces whose size, feather and flow step from the last stroke's to the new +/// values when it changed, and a plain dab when there is nothing to connect to. +#[test] +fn connected_brush_strokes_start_where_the_last_one_ended() { + use lightcraft_develop::MaskShape; + use lightcraft_geom::Point; + let mut s = demo(); + let id = s.active().unwrap(); + let r = s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.5]], "size": 0.02, "connect": true})).unwrap(); + assert_eq!(r["connected"], false, "nothing to connect to: a dab"); + let strokes = |s: &Session| { + let d = s.develop_of(id).unwrap(); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + strokes.clone() + }; + assert_eq!(strokes(&s)[0].points.len(), 1); + let r = s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.5]], "size": 0.02, "connect": true})).unwrap(); + assert_eq!(r["connected"], true); + let line = strokes(&s); + assert_eq!(line.len(), 2); + assert_eq!(line[1].points, vec![Point::new(0.2, 0.5), Point::new(0.6, 0.5)]); + // a bigger, softer brush: the segment tapers from the last stroke's values to the new ones + let r = s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.9]], "size": 0.04, "feather": 80, "connect": true})).unwrap(); + assert_eq!(r["connected"], true); + let all = strokes(&s); + let taper = &all[2..]; + assert!(taper.len() > 4, "{} pieces", taper.len()); + assert_eq!(taper[0].points[0], Point::new(0.6, 0.5), "from the last stroke's end"); + assert_eq!(*taper.last().unwrap().points.last().unwrap(), Point::new(0.6, 0.9), "to the click"); + assert!(taper.windows(2).all(|w| w[1].size >= w[0].size && w[1].feather >= w[0].feather && w[0].points[1] == w[1].points[0])); + assert!((taper.last().unwrap().size - 0.04).abs() < 1e-12 && (taper.last().unwrap().feather - 80.0).abs() < 1e-9); + assert!(taper[0].size > 0.02 && taper[0].size < 0.04); + // all of it is one undo step + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(strokes(&s).len(), 2); +} + #[test] fn brush_strokes_keep_pen_pressure_and_refuse_bad_lists() { let mut s = demo(); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 8c4ee5d02..09dae8575 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2106,11 +2106,14 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons app.brush_live = None; let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); // holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing - let erase = app.ui.brush_erase != resp.ctx.input(|i| i.modifiers.alt); + let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift)); + let erase = app.ui.brush_erase != alt; + // a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom + let connect = shift && resp.clicked() && pts.len() == 1; let _ = app.run( "mask.brushStroke", json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, - "erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure}), + "erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}), ); } return; diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 280cb303e..bbea5356e 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -343,6 +343,32 @@ fn holding_the_brush_still_does_not_harden_the_spot() { h.settle(SETTLE); } +/// A ⇧-click with the brush paints a straight line from where the last stroke ended (issue #106 of the Classic +/// fork), one undo step; a plain click only paints a dab where it lands. +#[test] +fn shift_click_paints_a_straight_line_from_the_last_stroke() { + let mut h = detail("panel.masking"); + exec(&mut h, "tool.brush", json!({})); + brush_at(&mut h, 0.2, 0.5); + brush_at(&mut h, 0.8, 0.5); + assert!(mask_coverage(&h, 0, 50, 50) < 0.01, "two plain clicks leave the space between them alone"); + let r = h.request("ui.pointer", json!({"events": [{"kind": "down", "x": 0.5, "y": 0.2}, {"kind": "up", "x": 0.5, "y": 0.2}], "shift": true}), T); + assert_eq!(r["ok"], true, "{r}"); + let d = develop(&h); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + assert_eq!(strokes.len(), 3, "{strokes:?}"); + assert_eq!(strokes[2].points.len(), 2, "from the last stroke's end to the click: {strokes:?}"); + for (x, y) in [(65, 35), (58, 27)] { + assert!(mask_coverage(&h, 0, x, y) > 0.5, "the line from (0.8, 0.5) to (0.5, 0.2) passes ({x}, {y})"); + } + assert!(mask_coverage(&h, 0, 35, 35) < 0.01, "and nowhere else"); + exec(&mut h, "edit.undo", json!({})); + let d = develop(&h); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + assert_eq!(strokes.len(), 2, "one undo step takes the line back"); + h.settle(SETTLE); +} + #[test] fn brush_strokes_carry_auto_mask() { let mut h = detail("panel.masking"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ee05a4f55..ebea20cd7 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (catalog import: persistent result, offline Local promotion and library-switch dismissal; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-MASK-BRUSH: ⇧-click draws a straight stroke from the last one; catalog import: persistent result, offline Local promotion and library-switch dismissal; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry; LR-IMP-FORMATS: preview-only raws rechecked after an update, #662) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-VIEW-ZOOM: full-size slider-drag drafts on the GPU, timed with their frame, issue #330; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 @@ -297,7 +297,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic | | 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`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); 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; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | +| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `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, respecting section eyes and the requested HDR/SDR rendition), Pick button, Refine slider | @@ -804,6 +804,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-14 | patch | LR-META-XMP: automatic sidecar writes that fail (folder offline or read-only) wait and are written again once the files are reachable, with a notice (`cmd:library.retrySidecars`); status unchanged | | 2026-10-13 | minor | LR-BEHAV-WINDOW (new, 🟡): the desktop app's window opens at the size it was left at, zoomed / maximized if it was; full screen and the place on the desktop are not remembered | | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Nikon D600, D610 and D5000 NEFs drop the filler and noise columns on the right (a band), and the D600 and D610 measure their white level inside the image (#1627); status unchanged (🟡) | +| 2026-10-14 | patch | LR-MASK-BRUSH: ⇧-click with the brush draws a straight stroke from where the last stroke ended, tapering when the brush changed in between (`cmd:mask.brushStroke` `connect`); status unchanged | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 89a3b9672..5257de130 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -117,6 +117,12 @@ "Canon CRX adaptive QP value 172" or "175". They now decode from the sensor data. Photos imported before this switch over with Photo ▸ Reload from Disk. +### Straight brush strokes with ⇧-click +- With the brush, click once, then ⇧-click somewhere else: the brush paints a straight line between the two, and + further ⇧-clicks carry on from the last point, as in Lightroom. Change the brush size, feather or flow before the + ⇧-click and the line tapers from the old values to the new ones. The whole line is one undo step. + `mask.brushStroke {connect: true}` does the same for agents. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.