From 32819d4900806083f9ede6f2bae2f4a9ebf4c036 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 04:04:01 +1100 Subject: [PATCH] LR-MASK-BRUSH: pen pressure scales brush size and flow Brush strokes record a pen pressure (0..1) per point from egui touch force while Brush > Pen Pressure is on (default). Each dab's radius goes from a tenth to all of the brush, and its flow is proportional to the pressure, in the committed stroke and the live preview alike. A mouse stroke stores no pressure, so documents and renders are unchanged. The GPU mask kernel leaves pressure strokes to the CPU. mask.brushStroke takes `pressure`; bad lists are refused. Also satisfies clippy 1.99's chunks_exact_to_as_chunks in spots.rs. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/develop/src/settings.rs | 15 ++++- crates/engine/src/cmd/masks.rs | 18 +++++- crates/engine/src/tests.rs | 17 +++++ crates/gpu/src/render.rs | 3 +- crates/pipeline/src/masks.rs | 93 ++++++++++++++++++++++++++-- crates/pipeline/src/spots.rs | 4 +- crates/ui-egui/locales/de.json | 2 + crates/ui-egui/locales/es.json | 2 + crates/ui-egui/locales/fr.json | 2 + crates/ui-egui/locales/ja.json | 2 + crates/ui-egui/locales/pt-br.json | 2 + crates/ui-egui/locales/ru.json | 2 + crates/ui-egui/locales/uk.json | 2 + crates/ui-egui/locales/zh-hans.json | 2 + crates/ui-egui/locales/zh-hant.json | 2 + crates/ui-egui/src/brush_live.rs | 57 +++++++++++++---- crates/ui-egui/src/panels/detail.rs | 77 ++++++++++++++++++++--- crates/ui-egui/src/panels/masking.rs | 8 +++ crates/ui-egui/src/state.rs | 3 + docs/gaps.md | 5 +- docs/hardware-parity.md | 7 ++- docs/parity-checklist.md | 5 +- 22 files changed, 294 insertions(+), 36 deletions(-) diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 1895776b3..a40f094c2 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -900,11 +900,24 @@ pub struct BrushStroke { pub density: f64, pub erase: bool, pub auto_mask: bool, + /// Pen pressure (0..1) at each of `points`, recorded when the brush's pressure switch is on: it scales each dab's + /// radius and flow. Empty (the default, a mouse or the switch off) is full pressure everywhere; a point past the + /// end of a shorter list takes the last value. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub pressure: Vec, +} + +impl BrushStroke { + /// The pressure at point `i` (0..1; 1 without pressure data, a hostile value clamped, NaN as 1). + pub fn pressure_at(&self, i: usize) -> f32 { + let p = self.pressure.get(i).or(self.pressure.last()).copied().unwrap_or(1.0); + if p.is_nan() { 1.0 } else { p.clamp(0.0, 1.0) } + } } impl Default for BrushStroke { fn default() -> Self { - Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false } + Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false, pressure: Vec::new() } } } diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index ae8b862b6..c6a8eb286 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -223,7 +223,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}", + "{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)}", has_active, |s, p| { let pts: Vec = p @@ -233,6 +233,7 @@ pub fn specs() -> Vec { .iter() .filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))) .collect(); + let pts_len = pts.len(); let d = BrushStroke::default(); let stroke = BrushStroke { points: pts, @@ -242,6 +243,7 @@ pub fn specs() -> Vec { density: f64_or(p, "density", d.density), erase: bool_or(p, "erase", false), auto_mask: bool_or(p, "autoMask", false), + pressure: pressure_list(p.get("pressure"), pts_len)?, }; 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); @@ -999,3 +1001,17 @@ fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments { c(&mut a.amount, 0.0, 200.0); a } + +/// `mask.brushStroke`'s `pressure`: one number 0..1 per point (clamped), at most one per point; absent or `null` +/// is no pressure data. +fn pressure_list(v: Option<&Value>, points: usize) -> Result> { + let Some(v) = v.filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; + let list = v.as_array().ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1"))?; + if list.len() > points { + return Err(bad("mask.brushStroke", "pressure has more values than points")); + } + list.iter() + .map(|q| q.as_f64().filter(|f| f.is_finite()).map(|f| f.clamp(0.0, 1.0) as f32)) + .collect::>>() + .ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1")) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6aad89b31..9090bf5c5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -280,6 +280,23 @@ fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() { assert_eq!(s.catalog.to_snapshot(), snapshot); } +#[test] +fn brush_strokes_keep_pen_pressure_and_refuse_bad_lists() { + let mut s = demo(); + s.execute("mask.add", &json!({"kind": "brush"})).unwrap(); + s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.2], [0.3, 0.3]], "pressure": [0.25, 7.0]})).unwrap(); + let lightcraft_develop::MaskShape::Brush { strokes } = &active_dev(&s).masks[0].components[0].shape else { panic!("a brush") }; + assert_eq!(strokes[0].pressure, vec![0.25, 1.0], "kept, clamped to 0..1"); + for bad in [json!([0.5, 0.5, 0.5]), json!("hard"), json!([0.5, "x"])] { + assert!(s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.2], [0.3, 0.3]], "pressure": bad})).is_err(), "{bad}"); + } + s.execute("mask.brushStroke", &json!({"points": [[0.4, 0.4]]})).unwrap(); + let lightcraft_develop::MaskShape::Brush { strokes } = &active_dev(&s).masks[0].components[0].shape else { panic!("a brush") }; + assert!(strokes[1].pressure.is_empty(), "a mouse stroke has no pressure data"); + let json = serde_json::to_string(&strokes[1]).unwrap(); + assert!(!json.contains("pressure"), "and stores none: {json}"); +} + #[test] fn masks_crop_and_render() { let mut s = demo(); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 44458350f..594e5068f 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -987,7 +987,8 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho } MaskShape::Brush { strokes } => { let dabs: Vec<_> = strokes.iter().map(|st| lightcraft_pipeline::masks::brush_dabs(st, frame, w, h)).collect(); - if dabs.iter().map(|d| d.dabs.len()).sum::() > MAX_DABS { + // pen pressure varies radius and flow per dab, which the kernel doesn't take: the CPU rasterizes it + if dabs.iter().map(|d| d.dabs.len()).sum::() > MAX_DABS || dabs.iter().any(|d| !d.pressure.is_empty()) { None } else if dabs.iter().any(|d| d.auto) { // Auto Mask: stroke by stroke (each one refined before it's combined) diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 0628a6a6f..984591fe1 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -438,6 +438,44 @@ pub struct BrushDabs { pub erase: bool, /// Auto Mask: paint only pixels like the one under each dab, then snap to edges. pub auto: bool, + /// Pen pressure (0..1) of each dab, parallel to `dabs`; empty when the stroke has no pressure data (full pressure). + pub pressure: Vec, +} + +impl BrushDabs { + /// Radius, hard core and flow of dab `i`, pen pressure applied ([`pressure_radius`], [`pressure_flow`]). + pub fn dab(&self, i: usize) -> (f64, f64, f32) { + match self.pressure.get(i) { + Some(&p) => { + let r = pressure_radius(self.r, p); + (r, if self.r > 0.0 { self.hard * r / self.r } else { self.hard }, pressure_flow(self.flow, p)) + } + None => (self.r, self.hard, self.flow), + } + } +} + +/// The radius of a dab of a brush of radius `r` (pixels) painted at pen pressure `p` (0..1): from a tenth of the +/// brush at the lightest touch to the full brush at full pressure, never below half a pixel. +#[inline] +pub fn pressure_radius(r: f64, p: f32) -> f64 { + let p = if p.is_nan() { 1.0 } else { f64::from(p.clamp(0.0, 1.0)) }; + (r * (0.1 + 0.9 * p)).max(0.5).min(r.max(0.5)) +} + +/// The flow (0..1) of a dab painted at pen pressure `p` (0..1): proportional to the pressure. +#[inline] +pub fn pressure_flow(flow: f32, p: f32) -> f32 { + let p = if p.is_nan() { 1.0 } else { p.clamp(0.0, 1.0) }; + flow * p +} + +/// Pressures for the `added` dabs [`push_dabs`] just laid from a sample at pressure `from` to one at `to`: evenly +/// interpolated, the last one at `to`, as the dabs themselves are placed. +pub fn push_pressures(pressure: &mut Vec, added: usize, from: f32, to: f32) { + for k in 1..=added { + pressure.push(from + (to - from) * k as f32 / added as f32); + } } /// Output pixels a brush sample must move from the last dab to count as movement: below this the @@ -454,11 +492,22 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa // long the button is held: a sample where the pointer hasn't moved (a stationary hold records // one per frame) lays no dab, so waiting in place adds nothing (#518). let mut dabs: Vec = Vec::new(); + let mut pressure: Vec = Vec::new(); + let with_pressure = !s.pressure.is_empty(); let mut prev = None; - for p in &s.points { + let mut prev_p = 1.0; + for (i, p) in s.points.iter().enumerate() { let q = to_out.apply(*p); + let before = dabs.len(); if push_dabs(&mut dabs, prev, q, r) { + let pq = s.pressure_at(i); + if with_pressure { + // the first dab of the stroke has nothing to interpolate from + let from = if prev.is_some() { prev_p } else { pq }; + push_pressures(&mut pressure, dabs.len() - before, from, pq); + } prev = Some(q); + prev_p = pq; } } BrushDabs { @@ -469,6 +518,7 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa density: (s.density / 100.0).clamp(0.0, 1.0) as f32, erase: s.erase, auto: s.auto_mask, + pressure, } } @@ -594,9 +644,11 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i return; } for s in strokes { - let BrushDabs { dabs, r, hard, flow, density: dens, erase, auto } = brush_dabs(s, frame, w, h); + let bd = brush_dabs(s, frame, w, h); + let (dens, erase, auto) = (bd.density, bd.erase, bd.auto); let mut stroke_alpha = Plane::new(w, h); - for d in &dabs { + for (k, d) in bd.dabs.iter().enumerate() { + let (r, hard, flow) = bd.dab(k); let refc = auto.then(|| { let j = dab_pixel(*d, w, h); (log_l.data[j], chromaticity(img.data[j])) @@ -621,7 +673,7 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i } } if auto { - let (sigma, eps) = auto_refine(r); + let (sigma, eps) = auto_refine(bd.r); let q = guided_cross(log_l, &stroke_alpha, sigma, eps); // the refinement fills gaps along edges; it never takes paint away stroke_alpha = stroke_alpha.zip_map(&q, f32::max); @@ -792,6 +844,39 @@ mod tests { assert!(v.len() <= 1_000_002, "{}", v.len()); } + /// Pen pressure scales each dab's radius and flow; a stroke without pressure data paints as before, and hostile + /// pressures (NaN, out of range, a short list) neither panic nor paint more than full pressure. + #[test] + fn pen_pressure_scales_dab_size_and_flow() { + let (w, h) = (400, 100); + let f = frame(w, h); + let img = Rgb32f::new(w, h); + let l = Plane::new(w, h); + let line = vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)]; + let alpha = |pressure: Vec| { + let s = BrushStroke { points: line.clone(), size: 0.05, feather: 0.0, flow: 100.0, pressure, ..Default::default() }; + shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) + }; + let full = alpha(vec![]); + assert_eq!(full.data, alpha(vec![1.0, 1.0]).data, "full pressure is no pressure"); + // light at the start, full at the end: thin and faint on the left, as wide as the brush on the right + let ramp = alpha(vec![0.1, 1.0]); + let width = |a: &Plane, x: usize| (0..h).filter(|y| a.get(x, *y) > 0.01).count(); + assert!(width(&ramp, 60) < width(&full, 60) / 2, "{} vs {}", width(&ramp, 60), width(&full, 60)); + assert!(width(&ramp, 350) + 2 >= width(&full, 350)); + assert!(ramp.get(60, 50) < full.get(60, 50) + 1e-6); + let faint = alpha(vec![0.2]); + assert!(faint.data.iter().zip(&full.data).all(|(a, b)| *a <= *b + 1e-6), "never more than full pressure"); + assert!(faint.get(200, 50) > 0.0 && faint.get(200, 50) < full.get(200, 50)); + for hostile in [vec![f32::NAN, f32::INFINITY], vec![-3.0], vec![0.5; 10_000]] { + let a = alpha(hostile); + assert!(a.data.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + } + assert_eq!(pressure_radius(10.0, f32::NAN), 10.0); + assert_eq!(pressure_radius(10.0, 0.0), 1.0); + assert_eq!(pressure_flow(0.8, 2.0), 0.8); + } + #[test] fn brush_and_subtract() { let f = frame(200, 100); diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0ed..494a17c6b 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -25,6 +25,7 @@ "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Ein Filter blendet die Fotos dieser Ansicht aus: Ändere ihn oder lösche ihn (Ansicht → Filter löschen)", "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Eine Bibliothek ist nur in einem Programm gleichzeitig geöffnet (Issue #99). Wenn LightCraft oder `lightcraft-cli` sie bereits geöffnet hat,", "A one-time download of about": "Ein einmaliger Download von etwa", + "A pen's pressure sets the size and flow of the brush": "Der Druck eines Stifts bestimmt Größe und Fluss des Pinsels", "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Ein teilweise fehlgeschlagener Speichervorgang durch einen vollen Datenträger oder eine getrennte Netzwerkfreigabe hinterlässt keinen scheinbar beschädigten Katalog", "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Ein schlafendes NAS, eine getrennte Netzwerkfreigabe oder ein anlaufendes USB-Laufwerk blockieren das Fenster nicht mehr (Issue #104).", "AI Mask Model Status": "Status des KI-Maskenmodells", @@ -1030,6 +1031,7 @@ "Paste from Previous": "Vom vorherigen Foto einfügen", "Paste pages": "Seiten einfügen", "Pastel Haze": "Pastelldunst", + "Pen Pressure": "Stiftdruck", "People": "Personen", "Percent": "Prozent", "Percentage": "Prozentsatz", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ff672e88d..cc6c62589 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -13,6 +13,7 @@ "A blank template keeps the original names.": "Una plantilla vacía conserva los nombres originales.", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Un filtro oculta las fotos de esta vista: cambiar el filtro o borrarlo (Vista → Borrar filtros)", "A one-time download of about": "Una única descarga de aproximadamente", + "A pen's pressure sets the size and flow of the brush": "La presión de un lápiz determina el tamaño y el flujo del pincel", "AI Mask Model Status": "Estado del modelo de máscaras con IA", "About": "Acerca de", "About LightCraft": "Acerca de LightCraft", @@ -986,6 +987,7 @@ "Paste from Previous": "Pegar de la anterior", "Paste pages": "Pegar páginas", "Pastel Haze": "Bruma pastel", + "Pen Pressure": "Presión del lápiz", "People": "Personas", "Percent": "Porcentaje", "Percentage": "Porcentaje", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f7c469ffd..98dcbf96b 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -13,6 +13,7 @@ "A blank template keeps the original names.": "Un modèle vide conserve les noms d'origine.", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Un filtre masque les photos de cette vue : modifiez-le ou effacez-le (Affichage → Effacer les filtres)", "A one-time download of about": "Un téléchargement unique d'environ", + "A pen's pressure sets the size and flow of the brush": "La pression d'un stylet règle la taille et le flux du pinceau", "AI Mask Model Status": "État du modèle de masques IA", "About": "À propos", "About LightCraft": "À propos de LightCraft", @@ -981,6 +982,7 @@ "Paste from Previous": "Coller depuis la précédente", "Paste pages": "Coller les pages", "Pastel Haze": "Brume pastel", + "Pen Pressure": "Pression du stylet", "People": "Personnes", "Percent": "Pourcentage", "Percentage": "Pourcentage", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9b9ae4c35..b0e742b65 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -15,6 +15,7 @@ "A blank template keeps the original names.": "テンプレートが空の場合は元の名前のままです", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "フィルターで写真が非表示になっています。条件を変更するか、「表示 → フィルターを解除」を選んでください", "A one-time download of about": "1回限りのダウンロード:約", + "A pen's pressure sets the size and flow of the brush": "ペンの筆圧でブラシのサイズと流量が変わります", "AI Mask Model Status": "AIマスクモデルの状態", "About": "LightCraftについて", "About LightCraft": "LightCraftについて", @@ -636,6 +637,7 @@ "Paste Settings from Previous": "前の写真の設定を貼り付け", "Paste from Previous": "前の写真から貼り付け", "Pastel Haze": "パステルヘイズ", + "Pen Pressure": "筆圧", "People": "人物", "Person": "人物", "Pet Eye Catchlight": "ペットの目のキャッチライト", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9c16f1110..de2f44788 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -15,6 +15,7 @@ "A blank template keeps the original names.": "Um modelo em branco mantém os nomes originais.", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Um filtro está ocultando as fotos desta exibição: altere-o ou limpe-o (Exibir → Limpar filtros)", "A one-time download of about": "Um download único de cerca de", + "A pen's pressure sets the size and flow of the brush": "A pressão de uma caneta define o tamanho e o fluxo do pincel", "AI Mask Model Status": "Status do modelo de máscaras de IA", "About": "Sobre", "About LightCraft": "Sobre o LightCraft", @@ -996,6 +997,7 @@ "Paste from Previous": "Colar da anterior", "Paste pages": "Colar páginas", "Pastel Haze": "Névoa pastel", + "Pen Pressure": "Pressão da caneta", "People": "Pessoas", "Percent": "Porcentagem", "Percentage": "Porcentagem", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a84297a05..1b1913ef7 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -4,6 +4,7 @@ "0: nothing is rejected.": "0: ничего не отклоняется.", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Фильтр скрывает фото в этом представлении: измените его или сбросьте (Вид → Сбросить фильтры)", "A one-time download of about": "Однократная загрузка около", + "A pen's pressure sets the size and flow of the brush": "Нажим пера задаёт размер и поток кисти", "About LightCraft": "О программе LightCraft", "Activity": "Активность", "Actual size": "Реальный размер", @@ -824,6 +825,7 @@ "Paste Settings from Previous": "Вставить настройки из предыдущего", "Paste pages": "Вставить страницы", "Pastel Haze": "Пастельная дымка", + "Pen Pressure": "Нажим пера", "Percent": "Проценты", "Percentage": "Процент", "Performance": "Производительность", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 839de65f7..95b545315 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1,5 +1,6 @@ { "% of all commits": "% усіх комітів", + "A pen's pressure sets the size and flow of the brush": "Натиск пера задає розмір і потік пензля", "About": "Про програму", "Add": "Додати", "Add to": "Додати до", @@ -121,6 +122,7 @@ "No contributor data was built into this copy.": "У цю копію не вбудовано дані про учасників.", "No matching commands": "Немає відповідних команд", "No model credits were built into this copy.": "У цю копію не вбудовано дані про використані моделі.", + "Pen Pressure": "Натиск пера", "Press keys…": "Натисніть клавіші…", "Preview unavailable": "Перегляд недоступний", "Radial": "Радіальний", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2b48b8e9b..e7ec79d67 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -15,6 +15,7 @@ "A blank template keeps the original names.": "模板留空则保留原始名称。", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "筛选器正在隐藏此视图中的照片:请修改筛选条件,或清除筛选(视图 → 清除筛选)", "A one-time download of about": "一次性下载,大小约", + "A pen's pressure sets the size and flow of the brush": "笔的压力决定画笔的大小和流量", "AI Mask Model Status": "AI 蒙版模型状态", "About": "关于", "About LightCraft": "关于 LightCraft", @@ -592,6 +593,7 @@ "Panoramas": "全景", "Paste Settings": "粘贴设置", "Paste from Previous": "粘贴上一张照片的设置", + "Pen Pressure": "笔压", "People": "人物", "Person": "人物", "Photos per bracket (0 = auto)": "每组包围曝光的照片数(0 = 自动)", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0e81c6e92..b4485eecb 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -15,6 +15,7 @@ "A blank template keeps the original names.": "範本留空時保留原始名稱。", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "篩選條件隱藏了此檢視的照片:請變更條件,或選擇「檢視 → 清除篩選條件」", "A one-time download of about": "一次性下載,大小約", + "A pen's pressure sets the size and flow of the brush": "筆的壓力決定筆刷的大小和流量", "AI Mask Model Status": "AI 遮色片模型狀態", "About": "關於", "About LightCraft": "關於 LightCraft", @@ -592,6 +593,7 @@ "Panoramas": "全景", "Paste Settings": "貼上設定", "Paste from Previous": "貼上前一張照片的設定", + "Pen Pressure": "筆壓", "People": "人物", "Person": "人物", "Photos per bracket (0 = auto)": "每組包圍曝光的照片數(0 = 自動)", diff --git a/crates/ui-egui/src/brush_live.rs b/crates/ui-egui/src/brush_live.rs index 614b5bed3..83f6e186b 100644 --- a/crates/ui-egui/src/brush_live.rs +++ b/crates/ui-egui/src/brush_live.rs @@ -9,7 +9,7 @@ use egui::{Color32, ColorImage, Pos2, Rect, TextureHandle, TextureOptions, pos2}; use lightcraft_geom::Point; -use lightcraft_pipeline::masks::{accumulate, dab_alpha, push_dabs}; +use lightcraft_pipeline::masks::{accumulate, dab_alpha, pressure_flow, pressure_radius, push_dabs, push_pressures}; /// The largest live-stroke buffer edge (pixels); a bigger canvas is painted at a lower resolution. const MAX_SIDE: usize = 4096; @@ -42,6 +42,8 @@ pub struct LiveStroke { /// Samples stamped so far, and the last one (buffer pixels). placed: usize, last: Option, + /// The pen pressure of the last stamped sample. + last_pressure: f32, /// Buffer pixels painted since the last upload: (x0, y0, x1, y1), exclusive ends. dirty: Option<(usize, usize, usize, usize)>, texture: Option, @@ -52,7 +54,7 @@ impl LiveStroke { let scale = buffer_scale(params.clip, params.scale); let params = LiveParams { scale, ..params }; let (w, h) = buffer_size(params.clip, scale); - LiveStroke { params, w, h, alpha: vec![0.0; w * h], placed: 0, last: None, dirty: None, texture: None } + LiveStroke { params, w, h, alpha: vec![0.0; w * h], placed: 0, last: None, last_pressure: 1.0, dirty: None, texture: None } } /// Stroke coverage (0..1) at buffer pixel `(x, y)`; 0 outside. @@ -69,8 +71,9 @@ impl LiveStroke { } /// Stamp the dabs of `points` (screen positions of the stroke's samples so far) not yet - /// painted. Returns `true` when anything new was painted. - pub fn stamp(&mut self, points: &[Pos2]) -> bool { + /// painted, at the pen pressures `pressure` (one per point; empty: full pressure), as the + /// pipeline scales them. Returns `true` when anything new was painted. + pub fn stamp(&mut self, points: &[Pos2], pressure: &[f32]) -> bool { let Some(new) = points.get(self.placed..) else { return false }; if new.is_empty() { return false; @@ -78,17 +81,28 @@ impl LiveStroke { let LiveParams { clip, scale, r, hard, flow, density, .. } = self.params; let (r, hard) = ((r * scale) as f64, (hard * scale) as f64); let mut dabs = Vec::new(); - for q in new { + let mut pressures = Vec::new(); + let first = self.placed; + for (k, q) in new.iter().enumerate() { let q = Point::new(((q.x - clip.left()) * scale) as f64, ((q.y - clip.top()) * scale) as f64); + let before = dabs.len(); if push_dabs(&mut dabs, self.last, q, r) { + let pq = pressure.get(first + k).or(pressure.last()).copied().unwrap_or(1.0); + let from = if self.last.is_some() { self.last_pressure } else { pq }; + push_pressures(&mut pressures, dabs.len() - before, from, pq); self.last = Some(q); + self.last_pressure = pq; } } self.placed = points.len(); - for d in dabs { + let base = (r, hard); + for (d, p) in dabs.into_iter().zip(pressures) { if !(d.x.is_finite() && d.y.is_finite()) { continue; } + let r = pressure_radius(base.0, p); + let hard = if base.0 > 0.0 { base.1 * r / base.0 } else { base.1 }; + let flow = pressure_flow(flow, p); let x0 = (d.x - r).floor().clamp(0.0, self.w as f64) as usize; let x1 = (d.x + r).ceil().clamp(0.0, self.w as f64) as usize; let y0 = (d.y - r).floor().clamp(0.0, self.h as f64) as usize; @@ -168,7 +182,7 @@ fn buffer_size(clip: Rect, scale: f32) -> (usize, usize) { /// Paint the live stroke whose samples so far are `points` (screen positions) into `painter`, /// keeping its buffer in `slot` (a new stroke, or new parameters, start it afresh). -pub fn paint(slot: &mut Option, ctx: &egui::Context, painter: &egui::Painter, params: LiveParams, points: &[Pos2]) { +pub fn paint(slot: &mut Option, ctx: &egui::Context, painter: &egui::Painter, params: LiveParams, points: &[Pos2], pressure: &[f32]) { let fresh = match slot { Some(s) => { // the same stroke when painted with the same parameters (at the scale it settled on) @@ -181,7 +195,7 @@ pub fn paint(slot: &mut Option, ctx: &egui::Context, painter: &egui: *slot = Some(LiveStroke::new(params)); } let Some(s) = slot else { return }; - s.stamp(points); + s.stamp(points, pressure); let clip = s.params.clip; // the buffer's pixels may overhang the canvas by a fraction of a pixel: map them exactly let (w, h, scale) = (s.w as f32, s.h as f32, s.params.scale); @@ -214,8 +228,8 @@ mod tests { #[test] fn a_fast_segment_is_painted_continuously() { let mut s = LiveStroke::new(params()); - s.stamp(&[pos2(20.0, 100.0)]); - s.stamp(&[pos2(20.0, 100.0), pos2(380.0, 100.0)]); + s.stamp(&[pos2(20.0, 100.0)], &[]); + s.stamp(&[pos2(20.0, 100.0), pos2(380.0, 100.0)], &[]); for x in 20..380 { assert!(s.coverage(x, 100) > 0.99, "a gap at x = {x}: {}", s.coverage(x, 100)); } @@ -231,10 +245,10 @@ mod tests { fn incremental_stamping_matches_painting_the_whole_path() { let pts = [pos2(30.0, 40.0), pos2(200.0, 150.0), pos2(210.0, 152.0), pos2(390.0, 20.0)]; let mut once = LiveStroke::new(LiveParams { flow: 0.4, ..params() }); - once.stamp(&pts); + once.stamp(&pts, &[]); let mut steps = LiveStroke::new(LiveParams { flow: 0.4, ..params() }); for n in 1..=pts.len() { - steps.stamp(&pts[..n]); + steps.stamp(&pts[..n], &[]); } assert_eq!(once.alpha, steps.alpha); } @@ -242,10 +256,27 @@ mod tests { #[test] fn hostile_input_paints_nothing_and_does_not_panic() { let mut s = LiveStroke::new(params()); - s.stamp(&[pos2(f32::NAN, 3.0), pos2(f32::INFINITY, -1e30), pos2(-5000.0, 9000.0)]); + s.stamp(&[pos2(f32::NAN, 3.0), pos2(f32::INFINITY, -1e30), pos2(-5000.0, 9000.0)], &[]); assert!(s.alpha.iter().all(|a| *a == 0.0)); // an enormous canvas is painted at a capped resolution let big = LiveStroke::new(LiveParams { clip: Rect::from_min_size(Pos2::ZERO, vec2(1e6, 10.0)), scale: 2.0, ..params() }); assert!(big.w <= MAX_SIDE && big.h >= 1); } + + /// The live stroke scales its dabs by pen pressure as the committed one does: a light touch paints a thinner, + /// fainter line, and a pressure list shorter than the points (or hostile values) is handled. + #[test] + fn pen_pressure_thins_and_fades_the_live_stroke() { + let pts = [pos2(20.0, 100.0), pos2(380.0, 100.0)]; + let mut full = LiveStroke::new(params()); + full.stamp(&pts, &[]); + let mut light = LiveStroke::new(params()); + light.stamp(&pts, &[0.2, 0.2]); + let rows = |s: &LiveStroke| (0..s.h).filter(|y| s.coverage(200, *y) > 0.01).count(); + assert!(rows(&light) < rows(&full), "{} vs {}", rows(&light), rows(&full)); + assert!(light.coverage(200, 100) < full.coverage(200, 100)); + let mut odd = LiveStroke::new(params()); + odd.stamp(&pts, &[f32::NAN]); + assert!(odd.alpha.iter().all(|v| v.is_finite())); + } } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..09b475c25 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -19,6 +19,9 @@ use crate::widgets::register; pub enum Gesture { Brush { points: Vec, + /// Pen pressure (0..1) at each point; empty when no pen pressure was reported (a mouse) or the + /// brush's Pen Pressure switch is off. + pressure: Vec, }, Spot { points: Vec, @@ -1831,16 +1834,24 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons && (resp.drag_started() || resp.dragged() || resp.clicked()) { let n = map.norm(q); + let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None }; match &mut app.gesture { // a held, unmoving pointer records nothing: coverage builds by passes, not time (#518) - Some(Gesture::Brush { points }) if points.last() == Some(&n) => {} - Some(Gesture::Brush { points }) => points.push(n), - _ => app.gesture = Some(Gesture::Brush { points: vec![n] }), + Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {} + Some(Gesture::Brush { points, pressure }) => { + record_pressure(pressure, points.len(), force); + points.push(n); + } + _ => { + let mut pressure = Vec::new(); + record_pressure(&mut pressure, 0, force); + app.gesture = Some(Gesture::Brush { points: vec![n], pressure }); + } } } // the stroke being painted (the render shows it once it's committed), with the committed // stroke's dab spacing, feather and flow (issue #517) - if let Some(Gesture::Brush { points }) = &app.gesture { + if let Some(Gesture::Brush { points, pressure }) = &app.gesture { let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; let params = crate::brush_live::LiveParams { @@ -1853,12 +1864,12 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons color: app.ui.mask_overlay_color, opacity: opacity.clamp(0.0, 1.0), }; - crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen); + crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure); } else { app.brush_live = None; } if (resp.drag_stopped() || resp.clicked()) - && let Some(Gesture::Brush { points }) = app.gesture.take() + && let Some(Gesture::Brush { points, pressure }) = app.gesture.take() { app.brush_live = None; let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); @@ -1867,7 +1878,7 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons 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}), + "erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure}), ); } return; @@ -2510,3 +2521,55 @@ mod wheel_zoom_tests { assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, f32::NAN, m)]), 0.0); } } + +/// The pen pressure (0..1) reported this frame: the force of the latest touch or pen event that carries one (winit +/// reports a pen's pressure as a touch force, on the platforms that give it), `None` for a mouse. +pub(crate) fn pen_force(i: &egui::InputState) -> Option { + i.events.iter().rev().find_map(|e| match e { + egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)), + _ => None, + }) +} + +/// Record the pressure of the stroke's point `index`: `force` when the pen reported one this frame, else the last +/// known one. Points before the first reported force take it too; a stroke without any keeps no pressure list. +pub(crate) fn record_pressure(pressure: &mut Vec, index: usize, force: Option) { + match (force, pressure.last().copied()) { + (Some(f), _) if pressure.is_empty() => pressure.extend(std::iter::repeat_n(f, index + 1)), + (Some(f), _) => pressure.push(f), + (None, Some(last)) => pressure.push(last), + (None, None) => {} + } +} + +#[cfg(test)] +mod pen_tests { + use super::*; + + #[test] + fn pen_pressure_is_recorded_per_point_and_absent_for_a_mouse() { + let mut p = Vec::new(); + record_pressure(&mut p, 0, None); + record_pressure(&mut p, 1, None); + assert!(p.is_empty(), "a mouse: no pressure list"); + // the first force covers the points before it, a frame without one repeats the last + record_pressure(&mut p, 2, Some(0.4)); + record_pressure(&mut p, 3, None); + record_pressure(&mut p, 4, Some(0.9)); + assert_eq!(p, vec![0.4, 0.4, 0.4, 0.4, 0.9]); + + let mut i = egui::InputState::default(); + assert_eq!(pen_force(&i), None); + let touch = |force| egui::Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(1), + phase: egui::TouchPhase::Move, + pos: egui::pos2(1.0, 1.0), + force, + }; + i.events = vec![touch(Some(0.3)), touch(Some(f32::NAN)), touch(None)]; + assert_eq!(pen_force(&i), Some(0.3), "the latest usable force"); + i.events = vec![touch(Some(4.0))]; + assert_eq!(pen_force(&i), Some(1.0)); + } +} diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 640bc4f11..055388715 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -831,6 +831,14 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) { { app.ui.brush_auto_mask = auto; } + let mut pressure = app.ui.brush_pressure; + if ui + .checkbox(&mut pressure, crate::i18n::tr("Pen Pressure")) + .on_hover_text(crate::i18n::tr("A pen's pressure sets the size and flow of the brush")) + .changed() + { + app.ui.brush_pressure = pressure; + } }); for (id, label, min, max, get) in [ ("ui.brushSize", "Size", 1.0, 100.0, (app.ui.brush_size * 400.0) as f64), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b76066d55..62d72af37 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -659,6 +659,8 @@ pub struct UiState { pub brush_erase: bool, /// Brush Auto Mask: dabs stick to areas like the one under the brush centre. pub brush_auto_mask: bool, + /// Brush Pen Pressure: a pen's pressure scales each dab's size and flow (a mouse paints at full pressure). + pub brush_pressure: bool, /// Remove tool brush: size (fraction of the long edge), feather and opacity (0..100). pub remove_size: f32, pub remove_feather: f32, @@ -1100,6 +1102,7 @@ impl Default for UiState { brush_flow: 60.0, brush_erase: false, brush_auto_mask: false, + brush_pressure: true, remove_size: 0.02, remove_feather: 50.0, remove_opacity: 100.0, diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..cc01a6b22 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 22: pen pressure drives brush size and flow where winit reports it); previously minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -36,7 +36,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) | | 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) | | 21 | **Localization**: 9 of 15 shipped languages partial (74–99% of catalog messages), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 55–90 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) | -| 22 | **Pen pressure**, OS monitor profiles, follow-window colour | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | +| 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), OS monitor profiles, follow-window colour | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | | 23 | **Accessibility** audit with screen readers; canvas tools keyboard-reachable | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 10–20 | [ui-parity](ui-parity.md) | | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | | 25 | **File writes**: lossy DNG, Render to DNG; AVIF import (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 21–37 | [file-format-parity](file-format-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 22: pen pressure scales brush size and flow (Brush ▸ Pen Pressure) where winit reports a pen force | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 3f1defc57..11b11855f 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code, gpu-pipeline.md and the parity checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (pen pressure for brush masks); previously major (created from the code, gpu-pipeline.md and the parity checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 What Lightroom does with the machine's hardware, per platform, and what LightCraft does. Sources: [gpu-pipeline.md](gpu-pipeline.md), [display-profiles.md](display-profiles.md), [denoise.md](denoise.md), @@ -14,7 +14,7 @@ What Lightroom does with the machine's hardware, per platform, and what LightCra | HDR (EDR) display | ✅ (macOS EDR, Windows HDR) | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ (LR-VIEW-HDR-DISPLAY) | | Monitor profile | automatic per display, follows the window | 🟡 picked by hand | 🟡 | 🟡 | — | 🟡 OS profile not read; no follow across monitors | | Second window / second monitor | ✅ (Classic) | ✅ (⌘F11) | ✅ | ✅ | — | ✅ (Classic secondary-window keys missing) | -| Pen pressure / tilt (brush masks) | ✅ pressure for size / flow | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | +| Pen pressure / tilt (brush masks) | ✅ pressure for size / flow | ⬜ winit reports no tablet pressure on macOS | 🟡 pen force from winit touch events (untested on a tablet) | ⬜ winit has no X11 / Wayland tablet input | 🟡 pointer-event force where winit passes it | 🟡 brush size and flow follow pen pressure wherever egui gets a touch force (Brush ▸ Pen Pressure); no tilt; macOS / Linux tablets wait on winit | | Trackpad gestures | pinch zoom, pan | ✅ pinch, two-finger pan | 🟡 (winit) | 🟡 | 🟡 | ✅ on macOS | | Card readers / cameras as import sources | ✅ (devices panel, eject after import) | ✅ mounted volumes with DCIM | ✅ | ✅ | — | 🟡 no eject-after-import check, no MTP / PTP phones | | Tethered capture | ✅ Classic (Canon, Nikon, Sony, Leica) | ⬜ | ⬜ | ⬜ | — | ⬜ | @@ -36,7 +36,7 @@ correctness is proven on Apple GPUs; other vendors rely on user reports. |---|---:|---| | HDR display (EDR on macOS, Windows HDR, Linux where possible) | 10–20 | an HDR display to verify (human) | | OS monitor profile, follow window across displays | 5–10 | multi-monitor setup | -| Pen pressure for brush masks (winit tablet events or a pure-Rust tablet layer) | 6–12 | a tablet | +| Pen pressure for brush masks: done where winit reports a touch force; macOS and Linux tablets need winit tablet events (or a pure-Rust tablet layer) | 3–8 | a tablet | | Remaining stages on GPU (defringe, spot removal, masks) | 10–20 | | | Tethered capture (PTP over USB in pure Rust, Canon / Nikon / Sony first) | 25–45 | cameras (human) | | Printing with colour management | 15–25 | printers (human) | @@ -47,4 +47,5 @@ correctness is proven on Apple GPUs; other vendors rely on user reports. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Pen pressure: strokes record per-point pressure from egui touch force, scaling dab size and flow; per-platform status | | 2026-10-10 | major | Created | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..6db8e85d9 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-11 · **Change:** minor (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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-MASK-BRUSH: pen pressure); previously minor (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 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -292,7 +292,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`, `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, shows the model folder 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 | +| 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-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), Pick button, Refine slider | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-MASK-BRUSH: pen pressure scales brush size and flow | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |