mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Integration: Art History Brush source sampling, History source, erased patches, silent no-op, Normal mode only (#1610)
- Each tile still copied a source window of (tile +- halo) clipped to the dab bounds, but a rotated read can land up to a dab diameter away: a leftward drag [[110,40],[20,40]] at size 40 read empty pixels at x = 64 (seam), and a large click still copied about the whole dab per tile. The stamp now samples the history state directly (no window, no StampParams::halo), and bilinear reads at pixel centres, so an unrotated dab copies the history pixel instead of a 2x2 blend. - Tolerance compared colour only, so an erased patch (the Eraser keeps the colour, lowers alpha 1 -> 0.6) was skipped at tolerance 25: the difference is now the larger of the mean colour change and the alpha change. - The brush had its own history lookup (row 0 unless `state` was passed): it now uses the History Brush's source (history.setSource, snapshots, redo rows, refusal after a canvas size change). - A stroke that wrote nothing at tolerance 0 (wholly outside the selection, or on Lock Transparency over transparent pixels) failed with "no stroke points"; it is now the same quiet no-op as a stroke under the tolerance (damage null, no History step). - A mode other than Normal (agents only: the options bar sends none) blended each dab with the already-mixed result at full strength, so Multiply with a white source over 0.5 grey fell to 0.33 five pixels from a dab's centre instead of staying 0.5. Other modes are refused until the stroke is buffered, and the wrong path is removed. - TOOL-213-20 is partial (8 of 10 styles, Area scales spacing); docs follow. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
//! Oriented history-state dabs: sample a snapshot patch, pose it along the stroke, and stamp.
|
||||
//!
|
||||
//! Tile processing reads a halo around each destination tile so a dab whose centre sits one pixel
|
||||
//! inside a tile still sees the same source as an untiled pass. Scatter is seeded, so the same
|
||||
//! Tile processing reads each dab's rotated source straight from the history state, so a dab
|
||||
//! that crosses a tile edge paints the same as an untiled pass. Scatter is seeded, so the same
|
||||
//! stroke is replayable.
|
||||
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
@@ -136,7 +136,7 @@ pub fn spacing_px(size: f32, area: f32, style: Style) -> Option<f32> {
|
||||
if s.is_finite() && s > 0.0 { Some(s) } else { None }
|
||||
}
|
||||
|
||||
/// Extra pixels each destination tile must read from the source.
|
||||
/// Reach of a dab around its stroke point: half the dab plus its scatter, plus a margin.
|
||||
pub fn halo_radius(size: f32, style: Style) -> Option<i32> {
|
||||
if !size.is_finite() || size <= 0.0 {
|
||||
return None;
|
||||
@@ -345,8 +345,9 @@ fn sample_bilinear(src: SrcView<'_>, sx: f32, sy: f32, out: &mut [f32]) -> bool
|
||||
if !(sx.is_finite() && sy.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
let lx = sx - src.origin.0 as f32;
|
||||
let ly = sy - src.origin.1 as f32;
|
||||
// `sx, sy` are continuous coordinates; pixel `i` has its centre at `i + 0.5`.
|
||||
let lx = sx - src.origin.0 as f32 - 0.5;
|
||||
let ly = sy - src.origin.1 as f32 - 0.5;
|
||||
if !(lx.is_finite() && ly.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
@@ -435,7 +436,6 @@ pub struct StampParams<'a> {
|
||||
pub tolerance: f32,
|
||||
/// Destination tile edge; `0` processes the whole buffer as one tile.
|
||||
pub tile: i32,
|
||||
pub halo: i32,
|
||||
pub selection: Option<&'a [f32]>,
|
||||
pub lock_transparent: bool,
|
||||
pub alpha: Option<usize>,
|
||||
@@ -478,12 +478,11 @@ fn tile_grid(w: usize, h: usize, origin: (i32, i32), tile: i32) -> Vec<(i32, i32
|
||||
tiles
|
||||
}
|
||||
|
||||
fn stamp_dab_on_rect(p: &mut StampParams<'_>, dab: &Dab, rect: (i32, i32, i32, i32), src_rect: (i32, i32, i32, i32)) -> Result<u32, StampError> {
|
||||
fn stamp_dab_on_rect(p: &mut StampParams<'_>, dab: &Dab, rect: (i32, i32, i32, i32)) -> Result<u32, StampError> {
|
||||
let (x0, y0, x1, y1) = rect;
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let (sx0, sy0, sx1, sy1) = src_rect;
|
||||
let Some(tw) = (x1 as i64 - x0 as i64).try_into().ok().filter(|w: &usize| *w > 0) else {
|
||||
return Err(StampError::TooLarge);
|
||||
};
|
||||
@@ -513,21 +512,8 @@ fn stamp_dab_on_rect(p: &mut StampParams<'_>, dab: &Dab, rect: (i32, i32, i32, i
|
||||
}
|
||||
}
|
||||
|
||||
// Cropped source window (halo). Samples outside it look empty — that is the seam if halo is short.
|
||||
let sw = (sx1 - sx0).max(0) as usize;
|
||||
let sh = (sy1 - sy0).max(0) as usize;
|
||||
let sn = sw.checked_mul(sh).and_then(|m| m.checked_mul(p.ch)).ok_or(StampError::TooLarge)?;
|
||||
let mut src_win = vec![0.0f32; sn];
|
||||
for y in sy0..sy1 {
|
||||
for x in sx0..sx1 {
|
||||
let Some(si) = origin_index(p.src_origin, x, y, p.src_w, p.src_h, p.ch) else { continue };
|
||||
let Some(wi) = origin_index((sx0, sy0), x, y, sw, sh, p.ch) else { continue };
|
||||
if let (Some(d), Some(s)) = (src_win.get_mut(wi..wi + p.ch), p.src.get(si..si + p.ch)) {
|
||||
d.copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The rotated read keeps the distance to the dab centre, so it stays inside the dab's bounds:
|
||||
// sample the source directly, whichever tile the destination pixel is in.
|
||||
let mut sampled = vec![0.0f32; p.ch];
|
||||
let mut written = 0u32;
|
||||
let k_stroke = (p.opacity * p.flow * dab.pressure).clamp(0.0, 1.0);
|
||||
@@ -549,7 +535,7 @@ fn stamp_dab_on_rect(p: &mut StampParams<'_>, dab: &Dab, rect: (i32, i32, i32, i
|
||||
continue;
|
||||
}
|
||||
let (sx, sy) = rotated_source(dab, x, y);
|
||||
if !sample_bilinear(SrcView { data: &src_win, w: sw, h: sh, origin: (sx0, sy0), ch: p.ch }, sx, sy, &mut sampled) {
|
||||
if !sample_bilinear(SrcView { data: p.src, w: p.src_w, h: p.src_h, origin: p.src_origin, ch: p.ch }, sx, sy, &mut sampled) {
|
||||
continue;
|
||||
}
|
||||
let Some(li) = pixel_index(lx, ly, tw, th, p.ch) else { continue };
|
||||
@@ -562,7 +548,7 @@ fn stamp_dab_on_rect(p: &mut StampParams<'_>, dab: &Dab, rect: (i32, i32, i32, i
|
||||
}
|
||||
}
|
||||
|
||||
// Write the dest tile back (not the halo).
|
||||
// Write the dest tile back.
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let lx = (x - x0) as usize;
|
||||
@@ -582,9 +568,10 @@ fn dab_intersects(dab: &Dab, tile: (i32, i32, i32, i32)) -> bool {
|
||||
x0 < tile.2 && x1 > tile.0 && y0 < tile.3 && y1 > tile.1
|
||||
}
|
||||
|
||||
/// Mean `|src − dest|` over the dab's covered pixels and colour channels (working-space peak is 1,
|
||||
/// so a tolerance of 50% is `0.5`). Alpha is left out unless it is the only channel. `None` if
|
||||
/// nothing is covered.
|
||||
/// Mean difference between `src` and `dest` over the dab's covered pixels (working-space peak is
|
||||
/// 1, so a tolerance of 50% is `0.5`). Per pixel it is the larger of the mean colour change and the
|
||||
/// alpha change: an opaque pixel's unchanged alpha must not water a colour change down, and the
|
||||
/// Eraser keeps the colour and lowers only the alpha. `None` if nothing is covered.
|
||||
fn dab_mean_diff(p: &StampParams<'_>, dab: &Dab) -> Option<f32> {
|
||||
let (x0, y0, x1, y1) = dab_bounds(dab)?;
|
||||
let mut sampled = vec![0.0f32; p.ch];
|
||||
@@ -602,17 +589,21 @@ fn dab_mean_diff(p: &StampParams<'_>, dab: &Dab) -> Option<f32> {
|
||||
continue;
|
||||
}
|
||||
let nch = p.ch.min(dst.len()).min(sampled.len());
|
||||
let skip_alpha = p.alpha.filter(|&a| a < nch && nch > 1);
|
||||
let ncolour = nch - usize::from(skip_alpha.is_some());
|
||||
if ncolour == 0 {
|
||||
if nch == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut mad = 0.0f32;
|
||||
for i in (0..nch).filter(|&i| Some(i) != skip_alpha) {
|
||||
let (mut colour, mut colours, mut alpha) = (0.0f32, 0u32, 0.0f32);
|
||||
for i in 0..nch {
|
||||
let Some((&s, &d)) = sampled.get(i).zip(dst.get(i)) else { continue };
|
||||
mad += (s - d).abs();
|
||||
if p.alpha == Some(i) {
|
||||
alpha = (s - d).abs();
|
||||
} else {
|
||||
colour += (s - d).abs();
|
||||
colours += 1;
|
||||
}
|
||||
}
|
||||
sum += mad / ncolour as f32;
|
||||
let colour = if colours == 0 { 0.0 } else { colour / colours as f32 };
|
||||
sum += colour.max(alpha);
|
||||
count = count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
@@ -623,7 +614,7 @@ fn dab_mean_diff(p: &StampParams<'_>, dab: &Dab) -> Option<f32> {
|
||||
mean.is_finite().then_some(mean)
|
||||
}
|
||||
|
||||
/// Stamp posed dabs into `dest`, reading `src` with a halo around each destination tile.
|
||||
/// Stamp posed dabs into `dest`, tile by tile, reading `src` at each dab's rotated positions.
|
||||
///
|
||||
/// Sequential dabs overlap: dab *n* sees dab *n − 1*. Returns how many destination pixels were
|
||||
/// written (tolerance-skipped dabs count as zero).
|
||||
@@ -640,7 +631,6 @@ pub fn stamp(p: &mut StampParams<'_>) -> Result<u32, StampError> {
|
||||
return Err(StampError::TooLarge);
|
||||
}
|
||||
let tiles = tile_grid(p.dest_w, p.dest_h, p.dest_origin, p.tile);
|
||||
let halo = p.halo.max(0);
|
||||
let mut written = 0u32;
|
||||
let dabs: Vec<Dab> = p.dabs.to_vec();
|
||||
for dab in &dabs {
|
||||
@@ -657,14 +647,8 @@ pub fn stamp(p: &mut StampParams<'_>) -> Result<u32, StampError> {
|
||||
// here so overlapping dabs stay deterministic.
|
||||
for tile in overlapping {
|
||||
let (bx0, by0, bx1, by1) = bounds;
|
||||
let src_rect = (
|
||||
tile.0.saturating_sub(halo).max(bx0.saturating_sub(1)),
|
||||
tile.1.saturating_sub(halo).max(by0.saturating_sub(1)),
|
||||
tile.2.saturating_add(halo).min(bx1.saturating_add(1)),
|
||||
tile.3.saturating_add(halo).min(by1.saturating_add(1)),
|
||||
);
|
||||
let dest_rect = (tile.0.max(bx0), tile.1.max(by0), tile.2.min(bx1), tile.3.min(by1));
|
||||
written = written.saturating_add(stamp_dab_on_rect(p, dab, dest_rect, src_rect)?);
|
||||
written = written.saturating_add(stamp_dab_on_rect(p, dab, dest_rect)?);
|
||||
}
|
||||
}
|
||||
Ok(written)
|
||||
@@ -737,7 +721,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn stamp_run(w: usize, h: usize, ch: usize, src: &[f32], dest: &mut [f32], dab: &Dab, tile: i32, halo: i32) -> u32 {
|
||||
fn stamp_run(w: usize, h: usize, ch: usize, src: &[f32], dest: &mut [f32], dab: &Dab, tile: i32) -> u32 {
|
||||
stamp_dabs(w, h, ch, src, dest, std::slice::from_ref(dab), tile)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn stamp_dabs(w: usize, h: usize, ch: usize, src: &[f32], dest: &mut [f32], dabs: &[Dab], tile: i32) -> u32 {
|
||||
let mut p = StampParams {
|
||||
dest,
|
||||
dest_w: w,
|
||||
@@ -748,13 +737,12 @@ mod tests {
|
||||
src_h: h,
|
||||
src_origin: (0, 0),
|
||||
ch,
|
||||
dabs: std::slice::from_ref(dab),
|
||||
dabs,
|
||||
hardness: 1.0,
|
||||
opacity: 1.0,
|
||||
flow: 1.0,
|
||||
tolerance: 0.0,
|
||||
tile,
|
||||
halo,
|
||||
selection: None,
|
||||
lock_transparent: false,
|
||||
alpha: (ch > 1).then_some(ch - 1),
|
||||
@@ -779,12 +767,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tiled_stamp_matches_untiled_at_a_tile_seam() {
|
||||
let halo = halo_radius(40.0, Style::LooseMedium).unwrap();
|
||||
let (w, h, src, dest0, dab) = fixture(4);
|
||||
let mut tiled = dest0.clone();
|
||||
let mut flat = dest0.clone();
|
||||
let n_t = stamp_run(w, h, 4, &src, &mut tiled, &dab, 64, halo);
|
||||
let n_f = stamp_run(w, h, 4, &src, &mut flat, &dab, 0, halo);
|
||||
let n_t = stamp_run(w, h, 4, &src, &mut tiled, &dab, 64);
|
||||
let n_f = stamp_run(w, h, 4, &src, &mut flat, &dab, 0);
|
||||
assert!(n_t > 0 && n_f > 0, "wrote {n_t} tiled / {n_f} flat");
|
||||
assert_eq!(max_u8(&tiled, &flat), 0, "8-bit seam");
|
||||
assert!(max_abs(&tiled, &flat) <= 1.0 / 32_767.0, "16-bit seam {}", max_abs(&tiled, &flat));
|
||||
@@ -793,37 +780,39 @@ mod tests {
|
||||
let (w, h, src, dest0, dab) = fixture(1);
|
||||
let mut tiled = dest0.clone();
|
||||
let mut flat = dest0;
|
||||
stamp_run(w, h, 1, &src, &mut tiled, &dab, 64, halo);
|
||||
stamp_run(w, h, 1, &src, &mut flat, &dab, 0, halo);
|
||||
stamp_run(w, h, 1, &src, &mut tiled, &dab, 64);
|
||||
stamp_run(w, h, 1, &src, &mut flat, &dab, 0);
|
||||
assert_eq!(max_u8(&tiled, &flat), 0, "gray 8-bit seam");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_halo_is_clipped_without_changing_pixels() {
|
||||
for ch in [1, 4, 5] {
|
||||
let (w, h, src, dest0, dab) = fixture(ch);
|
||||
let mut reference = dest0.clone();
|
||||
let halo = halo_radius(40.0, Style::LooseMedium).unwrap();
|
||||
let expected = stamp_run(w, h, ch, &src, &mut reference, &dab, 0, halo);
|
||||
assert!(expected > 0);
|
||||
for tile in [0, 64] {
|
||||
let mut dest = dest0.clone();
|
||||
let written = stamp_run(w, h, ch, &src, &mut dest, &dab, tile, i32::MAX);
|
||||
assert_eq!(written, expected);
|
||||
assert_eq!(dest, reference);
|
||||
}
|
||||
fn leftward_and_curl_strokes_match_untiled_across_a_tile_edge() {
|
||||
let (w, h, ch) = (128usize, 96usize, 4);
|
||||
let src = fill(w, h, ch, |x, y, c| if c == 3 { 1.0 } else { ((x * 13 + y * 7 + c as i32 * 3).rem_euclid(251) as f32) / 255.0 });
|
||||
let dest0 = fill(w, h, ch, |_, _, c| if c == 3 { 1.0 } else { 0.1 });
|
||||
for style in [Style::TightMedium, Style::TightCurl, Style::LooseCurl] {
|
||||
let dabs = place_dabs(&[[110.0, 40.0], [20.0, 40.0]], 40.0, 50.0, style, RNG_SEED).unwrap();
|
||||
let mut tiled = dest0.clone();
|
||||
let mut flat = dest0.clone();
|
||||
stamp_dabs(w, h, ch, &src, &mut tiled, &dabs, 64);
|
||||
stamp_dabs(w, h, ch, &src, &mut flat, &dabs, 0);
|
||||
assert_eq!(max_abs(&tiled, &flat), 0.0, "{} seam at x = 64", style.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
/// A dab at angle 0 copies the history pixel under each covered pixel, not a blend of it
|
||||
/// and its lower-right neighbours.
|
||||
#[test]
|
||||
fn short_halo_disagrees_with_untiled_reference() {
|
||||
let (w, h, src, dest0, dab) = fixture(4);
|
||||
let halo = halo_radius(40.0, Style::LooseMedium).unwrap();
|
||||
let mut short = dest0.clone();
|
||||
let mut full = dest0;
|
||||
stamp_run(w, h, 4, &src, &mut short, &dab, 64, 1);
|
||||
stamp_run(w, h, 4, &src, &mut full, &dab, 0, halo);
|
||||
assert!(max_abs(&short, &full) > 1.0e-3, "a 1 px halo must miss the rotated read");
|
||||
fn unrotated_dab_restores_the_source_pixel() {
|
||||
let (w, h, ch) = (48usize, 48usize, 4);
|
||||
let src = fill(w, h, ch, |x, y, c| if c == 3 { 1.0 } else { ((x * 37 + y * 11 + c as i32 * 5).rem_euclid(97) as f32) / 96.0 });
|
||||
let mut dest = fill(w, h, ch, |_, _, c| if c == 3 { 1.0 } else { 0.5 });
|
||||
let dab = Dab { x: 24.0, y: 24.0, theta: 0.0, radius: 8.0, pressure: 1.0 };
|
||||
stamp_run(w, h, ch, &src, &mut dest, &dab, 0);
|
||||
for (x, y) in [(24usize, 24usize), (20, 22), (27, 26)] {
|
||||
let i = (y * w + x) * ch;
|
||||
assert_eq!(&dest[i..i + ch], &src[i..i + ch], "pixel {x},{y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -861,7 +850,6 @@ mod tests {
|
||||
flow: 1.0,
|
||||
tolerance: 1.0,
|
||||
tile: 0,
|
||||
halo: 4,
|
||||
selection: None,
|
||||
lock_transparent: false,
|
||||
alpha: Some(3),
|
||||
@@ -893,7 +881,6 @@ mod tests {
|
||||
flow: 1.0,
|
||||
tolerance: 0.0,
|
||||
tile: 0,
|
||||
halo: 4,
|
||||
selection: None,
|
||||
lock_transparent: false,
|
||||
alpha: Some(3),
|
||||
@@ -929,7 +916,6 @@ mod tests {
|
||||
flow: 1.0,
|
||||
tolerance,
|
||||
tile: 0,
|
||||
halo: 4,
|
||||
selection: None,
|
||||
lock_transparent: false,
|
||||
alpha: Some(3),
|
||||
@@ -939,6 +925,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Eraser keeps the colour and lowers only the alpha: an erased patch differs from the
|
||||
/// history state and is painted back with a tolerance above 0.
|
||||
#[test]
|
||||
fn tolerance_sees_an_erased_patch() {
|
||||
let (w, h, ch) = (48usize, 48usize, 4);
|
||||
let src = fill(w, h, ch, |_, _, c| if c == 0 || c == 3 { 1.0 } else { 0.0 });
|
||||
let mut dest = fill(w, h, ch, |_, _, c| match c {
|
||||
0 => 1.0,
|
||||
3 => 0.6,
|
||||
_ => 0.0,
|
||||
});
|
||||
let dab = Dab { x: 24.0, y: 24.0, theta: 0.0, radius: 8.0, pressure: 1.0 };
|
||||
let mut p = StampParams {
|
||||
dest: &mut dest,
|
||||
dest_w: w,
|
||||
dest_h: h,
|
||||
dest_origin: (0, 0),
|
||||
src: &src,
|
||||
src_w: w,
|
||||
src_h: h,
|
||||
src_origin: (0, 0),
|
||||
ch,
|
||||
dabs: std::slice::from_ref(&dab),
|
||||
hardness: 1.0,
|
||||
opacity: 1.0,
|
||||
flow: 1.0,
|
||||
tolerance: 0.25,
|
||||
tile: 0,
|
||||
selection: None,
|
||||
lock_transparent: false,
|
||||
alpha: Some(3),
|
||||
};
|
||||
assert!(stamp(&mut p).unwrap() > 0, "alpha 1 -> 0.6 is over tolerance 0.25");
|
||||
let i = (24 * w + 24) * ch;
|
||||
assert!((dest[i + 3] - 1.0).abs() < 1.0e-5, "alpha restored, got {}", dest[i + 3]);
|
||||
}
|
||||
#[test]
|
||||
fn empty_or_mismatched_buffers_error() {
|
||||
let mut dest = vec![0.0f32; 16];
|
||||
@@ -960,7 +982,6 @@ mod tests {
|
||||
flow: 1.0,
|
||||
tolerance: 0.0,
|
||||
tile: 0,
|
||||
halo: 1,
|
||||
selection: None,
|
||||
lock_transparent: false,
|
||||
alpha: None,
|
||||
@@ -984,7 +1005,6 @@ mod tests {
|
||||
flow: 1.0,
|
||||
tolerance: 0.0,
|
||||
tile: 0,
|
||||
halo: 1,
|
||||
selection: None,
|
||||
lock_transparent: false,
|
||||
alpha: None,
|
||||
@@ -1019,7 +1039,6 @@ mod tests {
|
||||
flow: 1.0,
|
||||
tolerance: 0.0,
|
||||
tile: 64,
|
||||
halo: halo_radius(12.0, Style::LooseCurl).unwrap(),
|
||||
selection: None,
|
||||
lock_transparent: false,
|
||||
alpha: Some(3),
|
||||
|
||||
@@ -1687,7 +1687,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: brush_params!(
|
||||
r#","state":index=0 (oldest held snapshot),"style":"tightShort|tightMedium|tightLong|looseMedium|looseLong|dab|tightCurl|looseCurl"="tightMedium","area":0..100=50,"tolerance":0..100=0"#
|
||||
r#","state":row (a History panel row, as history.setSource),"snapshot":id (a history.newSnapshot id; default: the History panel's source, as paint.historyBrush; a source of another canvas size is an error),"style":"tightShort|tightMedium|tightLong|looseMedium|looseLong|dab|tightCurl|looseCurl"="tightMedium","area":0..100=50,"tolerance":0..100=0"#
|
||||
),
|
||||
enabled: has_pixel_layer,
|
||||
run: art_history::run,
|
||||
|
||||
@@ -5,7 +5,6 @@ use photocraft_color::BlendMode;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_paint::MAX_BRUSH_SIZE;
|
||||
use photocraft_paint::retouch::{Region, alpha_index};
|
||||
use photocraft_raster::{from_rgba_into, to_rgba};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
@@ -85,25 +84,6 @@ fn style_of(p: &Value) -> Result<Style> {
|
||||
}
|
||||
}
|
||||
|
||||
fn history_source(s: &Session, p: &Value) -> Result<(photocraft_doc::Document, usize)> {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let past = st.history.past_len();
|
||||
let idx = match p.get("state") {
|
||||
None | Some(Value::Null) => 0usize,
|
||||
Some(v) => {
|
||||
let Some(i) = v.as_u64() else {
|
||||
return Err(err("invalid number"));
|
||||
};
|
||||
usize::try_from(i).map_err(|_| err(format!("no history state {i} (0..={past})")))?
|
||||
}
|
||||
};
|
||||
let doc = match idx {
|
||||
i if i == past => st.doc.clone(),
|
||||
i => st.history.state(i).ok_or_else(|| err(format!("no history state {i} (0..={past})")))?,
|
||||
};
|
||||
Ok(((*doc).clone(), idx))
|
||||
}
|
||||
|
||||
fn union_bounds(dabs: &[Dab]) -> Result<Rect> {
|
||||
let mut r = Rect::EMPTY;
|
||||
for d in dabs {
|
||||
@@ -141,19 +121,13 @@ fn stamp_region(
|
||||
opacity: f32,
|
||||
flow: f32,
|
||||
tolerance: f32,
|
||||
halo: i32,
|
||||
sel: Option<&[f32]>,
|
||||
lock: bool,
|
||||
alpha: Option<usize>,
|
||||
blend: BlendMode,
|
||||
fmt: photocraft_color::PixelFormat,
|
||||
) -> Result<u32> {
|
||||
if dest.ch != src.ch {
|
||||
return Err(err("source and destination do not match"));
|
||||
}
|
||||
if blend != BlendMode::Normal {
|
||||
return stamp_blended(dest, src, dabs, hardness, opacity, flow, tolerance, sel, lock, alpha, blend, fmt);
|
||||
}
|
||||
let dest_w = dest.width();
|
||||
let dest_h = dest.height();
|
||||
let dest_origin = (dest.rect.x0, dest.rect.y0);
|
||||
@@ -177,7 +151,6 @@ fn stamp_region(
|
||||
flow,
|
||||
tolerance,
|
||||
tile: 64,
|
||||
halo,
|
||||
selection: sel,
|
||||
lock_transparent: lock,
|
||||
alpha,
|
||||
@@ -190,87 +163,6 @@ fn stamp_region(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn stamp_blended(
|
||||
dest: &mut Region,
|
||||
src: &Region,
|
||||
dabs: &[Dab],
|
||||
hardness: f32,
|
||||
opacity: f32,
|
||||
flow: f32,
|
||||
tolerance: f32,
|
||||
sel: Option<&[f32]>,
|
||||
lock: bool,
|
||||
alpha: Option<usize>,
|
||||
blend: BlendMode,
|
||||
fmt: photocraft_color::PixelFormat,
|
||||
) -> Result<u32> {
|
||||
let mut dummy = dest.clone();
|
||||
let dest_w = dummy.width();
|
||||
let dest_h = dummy.height();
|
||||
let dest_origin = (dummy.rect.x0, dummy.rect.y0);
|
||||
let src_w = src.width();
|
||||
let src_h = src.height();
|
||||
let src_origin = (src.rect.x0, src.rect.y0);
|
||||
let ch = dummy.ch;
|
||||
let mut p = StampParams {
|
||||
dest: &mut dummy.data,
|
||||
dest_w,
|
||||
dest_h,
|
||||
dest_origin,
|
||||
src: &src.data,
|
||||
src_w,
|
||||
src_h,
|
||||
src_origin,
|
||||
ch,
|
||||
dabs,
|
||||
hardness,
|
||||
opacity,
|
||||
flow,
|
||||
tolerance,
|
||||
// Dest/src already include the halo; one tile is the whole buffer.
|
||||
tile: 0,
|
||||
halo: 0,
|
||||
selection: sel,
|
||||
lock_transparent: lock,
|
||||
alpha,
|
||||
};
|
||||
match art_history::stamp(&mut p) {
|
||||
Ok(0) => return Ok(0),
|
||||
Ok(_) => {}
|
||||
Err(StampError::Empty) => return Err(err("no stroke points")),
|
||||
Err(StampError::Mismatched) => return Err(err("source and destination do not match")),
|
||||
Err(StampError::TooLarge) => return Err(err("stroke too large")),
|
||||
}
|
||||
let n = dest.ch;
|
||||
let mut written = 0u32;
|
||||
for y in dest.rect.y0..dest.rect.y1 {
|
||||
for x in dest.rect.x0..dest.rect.x1 {
|
||||
let i = dest.index(x, y) * n;
|
||||
let (Some(before), Some(after)) = (dest.data.get(i..i + n), dummy.data.get(i..i + n)) else { continue };
|
||||
if before == after {
|
||||
continue;
|
||||
}
|
||||
let d = to_rgba(&fmt, before);
|
||||
let s = to_rgba(&fmt, after);
|
||||
let mut o = photocraft_color::blend::composite(blend, d, s, 1.0);
|
||||
if lock {
|
||||
o[3] = d[3];
|
||||
}
|
||||
let mut enc = [0.0f32; 8];
|
||||
let m = from_rgba_into(&fmt, o, &mut enc);
|
||||
if m != n {
|
||||
return Err(err("source and destination do not match"));
|
||||
}
|
||||
if let Some(slot) = dest.data.get_mut(i..i + n) {
|
||||
slot.copy_from_slice(&enc[..n]);
|
||||
}
|
||||
written = written.saturating_add(1);
|
||||
}
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Run `paint.artHistoryBrush`.
|
||||
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let _ = object(p)?;
|
||||
@@ -295,15 +187,18 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Percent of the channel range; 0 (paint everywhere) is the default, as in Photoshop.
|
||||
let tolerance = ranged(p, "tolerance", 0.0, 0.0, 100.0)? / 100.0;
|
||||
let style = style_of(p)?;
|
||||
let mode = blend_param(p, CMD)?;
|
||||
// The stamp mixes the source in by coverage, so only Normal is right; other modes need a
|
||||
// stroke buffer (blending each dab with the already-mixed result rings every dab edge).
|
||||
if blend_param(p, CMD)? != BlendMode::Normal {
|
||||
return Err(err("only the Normal mode is supported"));
|
||||
}
|
||||
let (stroke, id) = parse_brush(s, p, CMD)?;
|
||||
let _ = stroke;
|
||||
let (mut source_doc, state_idx) = history_source(s, p)?;
|
||||
let src_surface = crate::channel_cmds::target_surface(&mut source_doc, id, p)
|
||||
.map(|(surf, _)| surf.clone())
|
||||
.map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into()))?;
|
||||
|
||||
let seed = RNG_SEED ^ (state_idx as u32);
|
||||
// The same source as the History Brush: the `state` or `snapshot` passed, else the History
|
||||
// panel's source, refused after a canvas size change.
|
||||
let src_surface = super::history_source(s, id, p, CMD)?;
|
||||
// Truncating a huge row index only changes the seed.
|
||||
let seed = RNG_SEED ^ p.get("state").and_then(Value::as_u64).map_or(0, |i| i as u32);
|
||||
let dabs = art_history::place_dabs(&pts, size, area, style, seed).map_err(|e| match e {
|
||||
StampError::TooLarge => err("stroke too large"),
|
||||
_ => err("no stroke points"),
|
||||
@@ -311,18 +206,18 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if dabs.is_empty() {
|
||||
return Err(err("no stroke points"));
|
||||
}
|
||||
let halo = art_history::halo_radius(size, style).ok_or_else(|| err("stroke too large"))?;
|
||||
let bounds = union_bounds(&dabs)?;
|
||||
// The stamp reads each dab's bounds plus one pixel for bilinear sampling,
|
||||
// so only that margin counts against the coverage limit, not the halo.
|
||||
// so only that margin counts against the coverage limit.
|
||||
let outer = bounds.inflate(1);
|
||||
let area_px = (outer.width() as u64).saturating_mul(outer.height() as u64);
|
||||
if outer.is_empty() || area_px > MAX_COVERAGE {
|
||||
return Err(err("stroke too large"));
|
||||
}
|
||||
|
||||
// Every dab under the tolerance: a no-op stroke, with no history step.
|
||||
let mut all_skipped = false;
|
||||
// Nothing written (every dab under the tolerance, outside the selection or on locked
|
||||
// transparency): a no-op stroke, with no history step.
|
||||
let mut nothing_written = false;
|
||||
let dmg = run_stroke(s, "Art History Brush", id, p, |_, surf, sel, lock| {
|
||||
let fmt = surf.format();
|
||||
let src = if src_surface.format() == fmt { src_surface.clone() } else { src_surface.convert(fmt) };
|
||||
@@ -335,19 +230,16 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(err("source and destination do not match"));
|
||||
}
|
||||
let sel_mask = selection_mask(sel, outer);
|
||||
let n = stamp_region(&mut dest, &src_reg, &dabs, hardness, opacity, flow, tolerance, halo, sel_mask.as_deref(), lock, alpha_index(&fmt), mode, fmt)?;
|
||||
let n = stamp_region(&mut dest, &src_reg, &dabs, hardness, opacity, flow, tolerance, sel_mask.as_deref(), lock, alpha_index(&fmt))?;
|
||||
if n == 0 {
|
||||
if tolerance <= 0.0 {
|
||||
return Err(err("no stroke points"));
|
||||
}
|
||||
all_skipped = true;
|
||||
return Err(err("every dab was under the tolerance"));
|
||||
nothing_written = true;
|
||||
return Err(err("the stroke changed no pixel"));
|
||||
}
|
||||
dest.write(surf);
|
||||
Ok(bounds)
|
||||
});
|
||||
let dmg = match dmg {
|
||||
Err(_) if all_skipped => return Ok(json!({ "damage": Value::Null })),
|
||||
Err(_) if nothing_written => return Ok(json!({ "damage": Value::Null })),
|
||||
r => r?,
|
||||
};
|
||||
Ok(json!({ "damage": damage_json(dmg) }))
|
||||
@@ -471,6 +363,73 @@ mod tests {
|
||||
assert!(p[0] > 0.9 && p[1] < 0.2, "state 1 is red, got {p:?}");
|
||||
}
|
||||
|
||||
/// With no `state`, the brush paints from the History panel's source, like the History Brush.
|
||||
#[test]
|
||||
fn paints_from_the_history_panel_source() {
|
||||
let mut s = session(40, 24, 8, "rgb");
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
|
||||
s.execute("history.setSource", json!({"state": 1})).unwrap();
|
||||
s.execute("paint.artHistoryBrush", json!({"points": [[4, 12], [30, 12]], "size": 10, "hardness": 100})).unwrap();
|
||||
let p = rgba(&s, 12, 12);
|
||||
assert!(p[0] > 0.9 && p[1] < 0.2, "the source row is red, got {p:?}");
|
||||
}
|
||||
|
||||
/// A source of another canvas size is refused, not painted shifted.
|
||||
#[test]
|
||||
fn a_source_of_another_canvas_size_is_refused() {
|
||||
let mut s = session(40, 24, 8, "rgb");
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("image.canvasSize", json!({"width": 60, "height": 30, "anchor": "center"})).unwrap();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
let e = stroke(&mut s, json!({"state": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("canvas size"), "{e}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
|
||||
/// A stroke that writes nothing (wholly outside the selection, or on transparent pixels
|
||||
/// with Lock Transparency) does nothing, as with the tolerance: no error, no History step.
|
||||
#[test]
|
||||
fn a_stroke_that_writes_nothing_is_a_quiet_no_op() {
|
||||
let mut s = session(48, 32, 8, "rgb");
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
let r = stroke(&mut s, json!({"state": 1})).unwrap();
|
||||
assert!(r["damage"].is_null(), "{r}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
assert!(rgba(&s, 24, 16)[2] > 0.9, "still blue");
|
||||
|
||||
let mut s = session(48, 32, 8, "rgb");
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.edit("lock", |doc, active| {
|
||||
doc.layer_mut(active.unwrap()).unwrap().locks.transparency = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
// Row 1: the new, transparent layer.
|
||||
let r = stroke(&mut s, json!({"state": 1})).unwrap();
|
||||
assert!(r["damage"].is_null(), "{r}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_normal_mode_paints() {
|
||||
let mut s = session(48, 32, 8, "rgb");
|
||||
paint_layer(&mut s, |_, _| [1.0, 1.0, 1.0, 1.0]);
|
||||
paint_layer(&mut s, |_, _| [0.5, 0.5, 0.5, 1.0]);
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
// Multiply by a white source would leave the grey as it is, but blending each dab with the
|
||||
// already-mixed result darkened every dab edge: refused until a stroke buffer exists.
|
||||
assert!(stroke(&mut s, json!({"state": 1, "mode": "multiply"})).is_err());
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
assert!((rgba(&s, 24, 16)[0] - 0.5).abs() < 0.01);
|
||||
stroke(&mut s, json!({"state": 1, "mode": "normal"})).unwrap();
|
||||
assert!(rgba(&s, 24, 16)[0] > 0.9, "{:?}", rgba(&s, 24, 16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_errors() {
|
||||
let mut s = session(32, 24, 8, "rgb");
|
||||
|
||||
@@ -96,7 +96,8 @@ tablets against Photoshop side by side.
|
||||
| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers, Current Brush Load with Load Brush and Clean Brush, automatic Load and Clean) | partial | No live result while dragging, no Alt-click loading (#2247) |
|
||||
| Clone Stamp (opacity, flow, aligned, sample, overlay) | partial | Clone Source W/H link and Alt target cursor (TOOL-213-8) |
|
||||
| Pattern Stamp (aligned, impressionist) | done | |
|
||||
| History Brush | done | Art History Brush missing |
|
||||
| History Brush | done | |
|
||||
| Art History Brush (styles, Area, Tolerance) | partial | 8 of 10 styles (no Tight Curl Long / Loose Curl Long); Area scales spacing, not Photoshop's coverage; no Mode/Opacity on the options bar, no pen pressure (TOOL-213-20) |
|
||||
| Spot Healing, Healing | partial | grain not matched on flat colour (#2104), "horrible in general" (#1092), stops responding after switching (#1751) |
|
||||
| Blur, Sharpen, Smudge (premultiplied, Sample All Layers, spacing) | done | BUG-207-2/3, #2171 |
|
||||
| Dodge, Burn, Sponge | done | |
|
||||
@@ -118,6 +119,7 @@ tablets against Photoshop side by side.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | minor | Art History Brush row added as partial (8 of 10 styles, TOOL-213-20, #1610). No brush-feel estimate changed. |
|
||||
| 2026-10-14 | minor | Brush Pose tilt overrides are independent end to end (#3825 partial); ABR flags, legacy JSON/commands, presets and three pixel depths covered; no readiness percentage or estimate changed |
|
||||
| 2026-10-13 | minor | Stale "missing tool" row corrected against main: Color Replacement (`paint.colorReplacement`) is a live toolbar tool; no brush-feel estimate changed |
|
||||
| 2026-10-11 | minor | Pattern Stamp and Background Eraser draw live (#3754); live retouch row now partial (Mixer Brush left) |
|
||||
|
||||
+3
-1
@@ -61,7 +61,8 @@ what an agent can't finish alone.
|
||||
scripted check there) is the only real oracle.
|
||||
|
||||
### G6. Missing tools
|
||||
- **Missing (1 of 68):** Art History Brush. Convert Point landed (#3204). Freeform
|
||||
- **Missing (0 of 68):** Art History Brush landed as partial (TOOL-213-20: 8 of 10 styles, Area
|
||||
scales spacing, no Mode/Opacity on the options bar). Convert Point landed (#3204). Freeform
|
||||
Pen and Curvature Pen landed (#1054); Color Sampler landed (#1046, `view.colorSamplers.*`); Horizontal and Vertical Type Mask landed (#1055,
|
||||
`type.createSelection`); Perspective Crop landed (#1043, `image.perspectiveCrop`); Frame landed
|
||||
(#2914).
|
||||
@@ -358,6 +359,7 @@ what an agent can't finish alone.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | minor | G6: Art History Brush landed as partial (TOOL-213-20, #1610), so no toolbar tool is missing; no percentage or estimate changed |
|
||||
| 2026-10-22 | minor | G17: Help › Check for Updates and an opt-out check once per launch (#5485), DIST-220-1 partial; estimate unchanged |
|
||||
| 2026-10-22 | minor | G1: 32-bit Hue/Saturation unclipped with Photoshop's saturation factor, bright colours keep their hue, Colorize's saturation scaled by 1 − \|Lightness\|; photoshop rgb32 hue-saturation and hue-saturation-colorize pass; Photoshop-oracle floor 188 → 190 of 258 (#5781) |
|
||||
| 2026-10-21 | minor | G9: adjustment clipping remains visible in compact Properties (#5231); no readiness percentage or estimate changed |
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
|---|---:|---:|---:|---|
|
||||
| [Performance](#performance) | 3 | 22 | 30 | 64 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 26 not run, 4 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 9 | 13 | 5 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 37 | 8 | 0 | |
|
||||
| [Tools](#tools) | 36 | 9 | 0 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 20 of 177 (target 0) |
|
||||
| [Type](#type) | 5 | 5 | 0 | |
|
||||
| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) |
|
||||
@@ -182,7 +182,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu
|
||||
| TOOL-213-19 | Remove tool: paint over an object (or circle it) and it is filled from its surroundings | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Options bar: Sample All Layers, Create a new layer (the fill on a new layer above, `newLayer`), Remove after each stroke off (strokes collect as a tint and ↩ applies them as one `strokes` job, Esc drops them). Missing: generative AI mode, live preview of the result |
|
||||
| TOOL-2259-1 | Arrow-key nudges of one thing (layers, the selection outline, the Free Transform box) that follow each other are one History state, with a preference to turn it off | done | [#2259](https://github.com/storytold/photocraft/issues/2259) | nudge_bundle.rs: a run shares one `coalesce` key while each nudge comes within Tools > Nudge Bundle Pause (1 s) and nothing else touched the document (an edit, Undo/Redo, a layer selection, a save end it); move_mods.rs runs layer.translate and select.transformSelection through it, transform_tool.rs folds box nudges into one session step. Tools > Bundle Arrow-Key Nudges off gives every press its own state. Selected pixels float until dropped, which is one state already; ⌥-nudges (a copy each) never join a run |
|
||||
| TOOL-1045-1 | Color Replacement tool on the toolbar | done | [#1045](https://github.com/storytold/photocraft/issues/1045) | Tool::ColorReplacement in the Brush (B) flyout after the Mixer Brush; Mode/Sampling/Limits/Tolerance/Anti-alias options drive paint.colorReplacement with the foreground colour; live preview (LiveDab with the stroke's finishing dab), one History step; color_replacement_tests.rs |
|
||||
| TOOL-213-20 | Art History Brush: Y-group, styles, Area, Tolerance, paints from a history state | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.artHistoryBrush + Tool::ArtHistoryBrush in the Y flyout; Tight/Loose/Dab/Curl styles, Area, Tolerance |
|
||||
| TOOL-213-20 | Art History Brush: Y-group, styles, Area, Tolerance, paints from a history state | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.artHistoryBrush + Tool::ArtHistoryBrush in the Y flyout; 8 of 10 styles (no Tight Curl Long / Loose Curl Long); Area scales spacing, not coverage; rotated-patch look; no Mode/Opacity on the options bar, no pen pressure |
|
||||
|
||||
## Workspace and UI
|
||||
|
||||
|
||||
@@ -444,7 +444,7 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s
|
||||
| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/it/corpus.rs` and `crates/engine/tests/it/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. |
|
||||
| Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. |
|
||||
| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. |
|
||||
| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp, Rotate View and Art History Brush are toolbar tools. No toolbar tool is missing now: Art History Brush landed (`paint.artHistoryBrush`, TOOL-213-20), Frame landed (#2914), Red Eye is a live toolbar tool (`paint.redEye`, TOOL-213-17) and Perspective Crop landed (2026-10-10, #1043, `image.perspectiveCrop`) | low–medium | Art History Brush is the Y-group companion to History Brush (`paint.artHistoryBrush`: Tight/Loose/Dab/Curl styles, Area, Tolerance). Patch has a live healing preview. Content-Aware Move is done (`TOOL-213-4`): Move/Extend, Structure/Color, Sample All Layers, Transform On Drop, and a live result preview for every selection the command takes (coarse first for large ones, then exact; 2026-10-16, #6680). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. |
|
||||
| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp, Rotate View and Art History Brush are toolbar tools. Every toolbar tool is present: Art History Brush is in the Y flyout (`paint.artHistoryBrush`, TOOL-213-20, partial: 8 of 10 styles, Area scales spacing), Frame landed (#2914), Red Eye is a live toolbar tool (`paint.redEye`, TOOL-213-17) and Perspective Crop landed (2026-10-10, #1043, `image.perspectiveCrop`) | low–medium | Art History Brush paints stylized dabs from the History Brush source (Tight/Loose/Dab/Curl styles, Area, Tolerance); the two Curl Long styles, Photoshop's Area coverage, Mode/Opacity on the options bar and pen pressure are open. Patch has a live healing preview. Content-Aware Move is done (`TOOL-213-4`): Move/Extend, Structure/Color, Sample All Layers, Transform On Drop, and a live result preview for every selection the command takes (coarse first for large ones, then exact; 2026-10-16, #6680). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. |
|
||||
| Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. |
|
||||
| Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. |
|
||||
| Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. |
|
||||
|
||||
+2
-1
@@ -36,7 +36,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
|
||||
| Retouch | Remove, Spot Healing, Healing, Patch, Content-Aware Move, Red Eye | |
|
||||
| Brush | Brush, Pencil, Mixer Brush | |
|
||||
| Stamp | Clone Stamp, Pattern Stamp | |
|
||||
| History | History Brush | Art History Brush |
|
||||
| History | History Brush, Art History Brush (partial: 8 of 10 styles, TOOL-213-20) | |
|
||||
| Eraser | Eraser, Background Eraser, Magic Eraser | |
|
||||
| Fill | Gradient, Paint Bucket | |
|
||||
| Focus, toning | Blur, Sharpen, Smudge, Dodge, Burn, Sponge | |
|
||||
@@ -244,6 +244,7 @@ Paragraph; the rest open as in-app floating windows.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | minor | Art History Brush is in the History group of the Y flyout (partial, TOOL-213-20, #1610) |
|
||||
| 2026-10-22 | minor | Crop default mode: a drag inside an upright box snaps the frame's edges and centre to the document, layers, guides and grid, as a Classic Mode frame's move does (#8580); no percentage changed |
|
||||
| 2026-10-21 | minor | Adjustment clipping moved above Properties editors to remain visible in compact docks (#5231); scope, undo/redo and 8/16/32-bit pixel regressions; readiness estimate unchanged |
|
||||
| 2026-10-19 | minor | A guide being moved or dragged out of a ruler shows an `X:` / `Y:` position readout beside the pointer, from the zero point in the ruler unit (#7680) |
|
||||
|
||||
@@ -316,6 +316,6 @@ note = "Tool::ColorReplacement in the Brush (B) flyout after the Mixer Brush; Mo
|
||||
[[item]]
|
||||
id = "TOOL-213-20"
|
||||
target = "Art History Brush: Y-group, styles, Area, Tolerance, paints from a history state"
|
||||
status = "done"
|
||||
status = "partial"
|
||||
issue = 213
|
||||
note = "paint.artHistoryBrush + Tool::ArtHistoryBrush in the Y flyout; Tight/Loose/Dab/Curl styles, Area, Tolerance"
|
||||
note = "paint.artHistoryBrush + Tool::ArtHistoryBrush in the Y flyout; 8 of 10 styles (no Tight Curl Long / Loose Curl Long); Area scales spacing, not coverage; rotated-patch look; no Mode/Opacity on the options bar, no pen pressure"
|
||||
|
||||
Reference in New Issue
Block a user