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) <noreply@anthropic.com>
This commit is contained in:
Andrew Frahn
2026-10-12 04:04:01 +11:00
co-authored by Claude Opus 5.5
parent 76b5377868
commit 32819d4900
22 changed files with 294 additions and 36 deletions
+14 -1
View File
@@ -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<f32>,
}
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() }
}
}
+17 -1
View File
@@ -223,7 +223,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Point> = p
@@ -233,6 +233,7 @@ pub fn specs() -> Vec<CommandSpec> {
.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<CommandSpec> {
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<Vec<f32>> {
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::<Option<Vec<f32>>>()
.ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1"))
}
+17
View File
@@ -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();
+2 -1
View File
@@ -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::<usize>() > 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::<usize>() > 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)
+89 -4
View File
@@ -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<f32>,
}
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<f32>, 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<Point> = Vec::new();
let mut pressure: Vec<f32> = 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<f32>| {
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);
+2 -2
View File
@@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh
let flat: Vec<f32> = 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;
}
}
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "ペットの目のキャッチライト",
+2
View File
@@ -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",
+2
View File
@@ -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": "Производительность",
+2
View File
@@ -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": "Радіальний",
+2
View File
@@ -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 = 自动)",
+2
View File
@@ -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 = 自動)",
+44 -13
View File
@@ -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<Point>,
/// 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<TextureHandle>,
@@ -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<LiveStroke>, ctx: &egui::Context, painter: &egui::Painter, params: LiveParams, points: &[Pos2]) {
pub fn paint(slot: &mut Option<LiveStroke>, 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<LiveStroke>, 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()));
}
}
+70 -7
View File
@@ -19,6 +19,9 @@ use crate::widgets::register;
pub enum Gesture {
Brush {
points: Vec<Point>,
/// 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<f32>,
},
Spot {
points: Vec<Point>,
@@ -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<Pos2> = 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<f32> {
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<f32>, index: usize, force: Option<f32>) {
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));
}
}
+8
View File
@@ -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),
+3
View File
@@ -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,
+3 -2
View File
@@ -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 |
+4 -3
View File
@@ -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 |
+3 -2
View File
@@ -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 |