mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-31 21:37:08 -06:00
Merge pull request #10328 from davidmann4/fix/10204-reference-app-followup
Follow-up to #10204: match the reference application in ten places (#10327)
This commit is contained in:
+1
-1
@@ -34,7 +34,7 @@ next to them.
|
||||
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (114 files; `star-filled.svg` is `star.svg` with a solid fill, `rotate-ccw.svg` is `rotate-cw.svg` mirrored) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9c-2.52 0-4.93 1-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 296 B |
@@ -1385,7 +1385,9 @@ mod tests {
|
||||
let fp = FilterParams::FilterGallery { effects: vec![e.clone()] };
|
||||
apply_tiled(&s, &fp, b, b, None, tile, Some(b)).read_region(b)
|
||||
};
|
||||
for f in [G::RoughPastels, G::AngledStrokes, G::Cutout] {
|
||||
// Cutout has its own test below: its tone bands are of the channel mean, which this
|
||||
// pattern keeps nearly constant.
|
||||
for f in [G::RoughPastels, G::AngledStrokes] {
|
||||
let base = run(&GalleryEffect::new(f), 256);
|
||||
for prm in f.params() {
|
||||
let crate::GalleryParamKind::Range { min, max, .. } = prm.kind else { continue };
|
||||
@@ -1402,6 +1404,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutout_responds_to_every_slider() {
|
||||
// A radial gradient (curved band edges, tones to quantize) with a 10 px square in it.
|
||||
let rows: Vec<Vec<f32>> = (0..96)
|
||||
.map(|y| {
|
||||
(0..96)
|
||||
.map(|x| {
|
||||
if (70..80).contains(&x) && (14..24).contains(&y) {
|
||||
return 0.05;
|
||||
}
|
||||
let d = (((x as f32 - 40.0).powi(2) + (y as f32 - 52.0).powi(2)).sqrt() / 60.0).min(1.0);
|
||||
1.0 - d
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let base = cutout_on(&rows, &[]);
|
||||
for (key, v) in
|
||||
[("numberOfLevels", 2.0), ("numberOfLevels", 8.0), ("edgeSimplicity", 0.0), ("edgeSimplicity", 10.0), ("edgeFidelity", 1.0), ("edgeFidelity", 3.0)]
|
||||
{
|
||||
assert_ne!(cutout_on(&rows, &[(key, v)]), base, "cutout {key} = {v} changed nothing");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one Cutout over a grey image given as rows of values; returns the red channel.
|
||||
fn cutout_on(rows: &[Vec<f32>], set: &[(&str, f32)]) -> Vec<Vec<f32>> {
|
||||
let (w, h) = (rows[0].len() as i32, rows.len() as i32);
|
||||
@@ -1437,31 +1463,32 @@ mod tests {
|
||||
let soft = out[y].iter().filter(|&&v| (v - lo).abs() > 0.01 && (v - hi).abs() > 0.01).count();
|
||||
assert!(soft <= 2, "row {y}: {soft} edge pixels");
|
||||
}
|
||||
// A ramp becomes `numberOfLevels` flat steps (the palette is the image's own colours, so
|
||||
// one may go to the wide flat margins and merge as noise).
|
||||
let ramp: Vec<Vec<f32>> = (0..12).map(|_| (0..144).map(|x| ((x as f32 - 24.0) / 95.0).clamp(0.0, 1.0)).collect()).collect();
|
||||
for levels in [2.0, 5.0, 8.0] {
|
||||
let row = &cutout_on(&ramp, &[("numberOfLevels", levels)])[6];
|
||||
let mut tones: Vec<f32> = Vec::new();
|
||||
for win in row.windows(3) {
|
||||
if (win[0] - win[2]).abs() < 1e-4 && tones.last().is_none_or(|t| (t - win[1]).abs() > 1e-3) {
|
||||
tones.push(win[1]);
|
||||
}
|
||||
}
|
||||
assert!(tones.len() <= levels as usize && tones.len() + 1 >= levels as usize, "levels {levels}: tones {tones:?}");
|
||||
assert!(tones.windows(2).all(|t| t[0] < t[1]), "levels {levels}: tones {tones:?}");
|
||||
// A grey ramp becomes `numberOfLevels` equal-width bands, each its own mean tone, as in the
|
||||
// reference application (0–255 over 256 px at 4 levels: steps at 64, 128 and 192, the
|
||||
// upper bands 96, 160 and 224).
|
||||
let ramp: Vec<Vec<f32>> = (0..64).map(|_| (0..256).map(|x| x as f32 / 255.0).collect()).collect();
|
||||
let row = &cutout_on(&ramp, &[("numberOfLevels", 4.0), ("edgeSimplicity", 0.0), ("edgeFidelity", 3.0)])[32];
|
||||
for (x, want) in [(100, 95.5), (160, 159.5), (230, 223.5)] {
|
||||
assert!((row[x] * 255.0 - want).abs() < 0.6, "band at {x}: {} vs {want}", row[x] * 255.0);
|
||||
}
|
||||
for step in [64usize, 128, 192] {
|
||||
assert!(row[step - 2] < row[step + 1] - 0.2, "a step at {step}: {} / {}", row[step - 2], row[step + 1]);
|
||||
}
|
||||
// Edge Simplicity drops small shapes by their size alone (measured in the reference
|
||||
// application: 6 px squares stay at 0–2 and 4 px go; 12 px stay at 4 and 8 px go).
|
||||
let square = |n: usize| -> Vec<Vec<f32>> {
|
||||
(0..48).map(|y| (0..48).map(|x| if (20..20 + n).contains(&x) && (20..20 + n).contains(&y) { 0.9 } else { 0.2 }).collect()).collect()
|
||||
};
|
||||
for (n, simplicity, kept) in [(6, 0.0, true), (4, 0.0, false), (6, 2.0, true), (12, 4.0, true), (8, 4.0, false), (12, 10.0, false)] {
|
||||
let out = cutout_on(&square(n), &[("edgeSimplicity", simplicity)]);
|
||||
let c = 20 + n / 2;
|
||||
assert_eq!(out[c][c] - out[2][2] > 0.5, kept, "{n} px square at simplicity {simplicity}");
|
||||
}
|
||||
// Edge Simplicity drops specks.
|
||||
let speck: Vec<Vec<f32>> = (0..32).map(|y| (0..32).map(|x| if (15..18).contains(&x) && (15..18).contains(&y) { 0.9 } else { 0.2 }).collect()).collect();
|
||||
let detailed = cutout_on(&speck, &[("edgeSimplicity", 0.0)]);
|
||||
let simple = cutout_on(&speck, &[("edgeSimplicity", 10.0)]);
|
||||
assert!(detailed[16][16] - detailed[2][2] > 0.5, "the speck should survive at simplicity 0");
|
||||
assert!((simple[16][16] - simple[2][2]).abs() < 1e-4, "the speck should go at simplicity 10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutout_keeps_the_tone_of_surviving_islands_at_low_fidelity() {
|
||||
for (size, simplicity) in [(2, 0.0), (3, 2.0), (4, 0.0), (4, 2.0), (6, 2.0)] {
|
||||
for (size, simplicity) in [(6, 0.0), (6, 2.0), (8, 0.0), (12, 4.0)] {
|
||||
let rows: Vec<Vec<f32>> =
|
||||
(0..32).map(|y| (0..32).map(|x| if (12..12 + size).contains(&x) && (12..12 + size).contains(&y) { 0.9 } else { 0.2 }).collect()).collect();
|
||||
let reference = cutout_on(&rows, &[("edgeSimplicity", simplicity), ("edgeFidelity", 2.0)]);
|
||||
@@ -1469,7 +1496,7 @@ mod tests {
|
||||
for fidelity in [1.0, 1.5, 2.0] {
|
||||
let out = cutout_on(&rows, &[("edgeSimplicity", simplicity), ("edgeFidelity", fidelity)]);
|
||||
assert!(
|
||||
size == 2 || (out[centre][centre] - reference[centre][centre]).abs() < 0.02,
|
||||
(out[centre][centre] - reference[centre][centre]).abs() < 0.02,
|
||||
"{size}x{size}, simplicity {simplicity}, fidelity {fidelity}: {} vs {}",
|
||||
out[centre][centre],
|
||||
reference[centre][centre]
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
//! Colour and palette: Oklab, edge-keeping smoothing, and the picture's own palette.
|
||||
|
||||
use super::Px;
|
||||
use super::util::transpose;
|
||||
use crate::par;
|
||||
use crate::quantize::{kmeans, median_cut};
|
||||
|
||||
/// Oklab (Björn Ottosson, 2020) of an sRGB colour.
|
||||
pub(super) fn oklab(p: Px) -> Px {
|
||||
let lin = |v: f32| {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
};
|
||||
let (r, g, b) = (lin(p[0]), lin(p[1]), lin(p[2]));
|
||||
let l = (0.412_221_46 * r + 0.536_332_55 * g + 0.051_445_995 * b).cbrt();
|
||||
let m = (0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b).cbrt();
|
||||
let s = (0.088_302_46 * r + 0.281_718_85 * g + 0.629_978_7 * b).cbrt();
|
||||
[
|
||||
0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s,
|
||||
1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s,
|
||||
0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s,
|
||||
]
|
||||
}
|
||||
|
||||
/// sRGB of an Oklab colour, or `None` outside the sRGB gamut.
|
||||
pub(super) fn srgb(lab: Px) -> Option<Px> {
|
||||
let l = (lab[0] + 0.396_337_78 * lab[1] + 0.215_803_76 * lab[2]).powi(3);
|
||||
let m = (lab[0] - 0.105_561_346 * lab[1] - 0.063_854_17 * lab[2]).powi(3);
|
||||
let s = (lab[0] - 0.089_484_18 * lab[1] - 1.291_485_5 * lab[2]).powi(3);
|
||||
let lin = [
|
||||
4.076_741_7 * l - 3.307_711_6 * m + 0.230_969_94 * s,
|
||||
-1.268_438 * l + 2.609_757_4 * m - 0.341_319_38 * s,
|
||||
-0.004_196_086_3 * l - 0.703_418_6 * m + 1.707_614_7 * s,
|
||||
];
|
||||
if lin.iter().any(|v| !(-1e-4..=1.0001).contains(v)) {
|
||||
return None;
|
||||
}
|
||||
let enc = |v: f32| {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
|
||||
};
|
||||
Some(lin.map(enc))
|
||||
}
|
||||
|
||||
/// sRGB at Oklab lightness `l`, hue/strength `a`, `b`; out of gamut it loses strength, not hue.
|
||||
pub(super) fn in_gamut(l: f32, a: f32, b: f32) -> Px {
|
||||
if let Some(c) = srgb([l, a, b]) {
|
||||
return c;
|
||||
}
|
||||
let (mut ok, mut bad) = (0.0f32, 1.0f32);
|
||||
for _ in 0..10 {
|
||||
let t = (ok + bad) / 2.0;
|
||||
if srgb([l, a * t, b * t]).is_some() { ok = t } else { bad = t }
|
||||
}
|
||||
srgb([l, a * ok, b * ok]).unwrap_or([l, l, l])
|
||||
}
|
||||
|
||||
/// Squared distance between two colours.
|
||||
pub(super) fn dist2(a: Px, b: Px) -> f32 {
|
||||
(a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)
|
||||
}
|
||||
|
||||
/// Bilateral smoothing in Oklab, `rounds` times along rows then columns.
|
||||
pub(super) fn smooth(mut lab: Vec<Px>, w: usize, h: usize, rounds: usize) -> Vec<Px> {
|
||||
for _ in 0..rounds {
|
||||
bilateral_rows(&mut lab, w);
|
||||
let mut t = transpose(&lab, w, h);
|
||||
bilateral_rows(&mut t, h);
|
||||
lab = transpose(&t, h, w);
|
||||
}
|
||||
lab
|
||||
}
|
||||
|
||||
/// One bilateral pass along every row of `buf` (rows of `w` pixels), seven pixels wide.
|
||||
fn bilateral_rows(buf: &mut [Px], w: usize) {
|
||||
const R: isize = 3;
|
||||
// Colours this far apart (Oklab) count half as much as equal ones (a Cauchy weight: as
|
||||
// edge-keeping as a Gaussian one and much cheaper).
|
||||
const HALF: f32 = 0.06;
|
||||
let k = 1.0 / (HALF * HALF);
|
||||
let spatial: [f32; 7] = std::array::from_fn(|i| (-((i as f32 - 3.0).powi(2)) / 8.0).exp());
|
||||
par::chunks_mut(buf, w, true, |_, row| {
|
||||
let src = row.to_vec();
|
||||
for (x, out) in row.iter_mut().enumerate() {
|
||||
let c = src[x];
|
||||
let (mut acc, mut wsum) = ([0.0f32; 3], 0.0f32);
|
||||
for d in -R..=R {
|
||||
let q = src[(x as isize + d).clamp(0, w as isize - 1) as usize];
|
||||
let wt = spatial[(d + R) as usize] / (1.0 + dist2(q, c) * k);
|
||||
acc = [acc[0] + q[0] * wt, acc[1] + q[1] * wt, acc[2] + q[2] * wt];
|
||||
wsum += wt;
|
||||
}
|
||||
*out = [acc[0] / wsum, acc[1] / wsum, acc[2] / wsum];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// At most `levels` colours of the image's own; colours closer than noise are merged.
|
||||
pub(super) fn palette(lab: &[Px], levels: usize) -> Vec<Px> {
|
||||
const SAMPLES: usize = 1 << 16;
|
||||
const SAME: f32 = 0.045;
|
||||
// Hue centres (Oklab a, b) this far apart are different colours, not one colour's spread.
|
||||
const HUES_APART: f32 = 0.08;
|
||||
let step = lab.len().div_ceil(SAMPLES).max(1);
|
||||
let pts: Vec<(Px, u32)> = lab.iter().step_by(step).map(|p| (*p, 1)).collect();
|
||||
let mut centres = median_cut(&pts, levels);
|
||||
kmeans(&pts, &mut centres, 10);
|
||||
// Number of Levels counts tones: a palette colour whose pixels are two clearly different hues
|
||||
// of that tone (a blue and a pink) splits in two, or touching areas of both would be one
|
||||
// shape of their grey average.
|
||||
let mut split = Vec::with_capacity(centres.len() * 2);
|
||||
for (k, c) in centres.iter().enumerate() {
|
||||
let members: Vec<(Px, u32)> = pts.iter().filter(|(p, _)| nearest(¢res, *p) as usize == k).map(|(p, _)| ([0.0, p[1], p[2]], 1)).collect();
|
||||
let mut hues = median_cut(&members, 2);
|
||||
kmeans(&members, &mut hues, 6);
|
||||
match hues.as_slice() {
|
||||
[a, b] if dist2(*a, *b).sqrt() > HUES_APART => split.extend([[c[0], a[1], a[2]], [c[0], b[1], b[2]]]),
|
||||
_ => split.push(*c),
|
||||
}
|
||||
}
|
||||
let mut centres = split;
|
||||
kmeans(&pts, &mut centres, 4);
|
||||
let mut weight = vec![0u32; centres.len()];
|
||||
for (p, _) in &pts {
|
||||
if let Some(wt) = weight.get_mut(nearest(¢res, *p) as usize) {
|
||||
*wt += 1;
|
||||
}
|
||||
}
|
||||
let mut cs: Vec<(Px, f32)> = centres.into_iter().zip(weight).filter(|c| c.1 > 0).map(|(c, wt)| (c, wt as f32)).collect();
|
||||
loop {
|
||||
let mut best: Option<(usize, usize, f32)> = None;
|
||||
for i in 0..cs.len() {
|
||||
for j in i + 1..cs.len() {
|
||||
let d = dist2(cs[i].0, cs[j].0).sqrt();
|
||||
if d < SAME && best.is_none_or(|b| d < b.2) {
|
||||
best = Some((i, j, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((i, j, _)) = best else { break };
|
||||
let (a, b) = (cs[i], cs[j]);
|
||||
let t = a.1 + b.1;
|
||||
cs[i] = (std::array::from_fn(|k| (a.0[k] * a.1 + b.0[k] * b.1) / t), t);
|
||||
cs.remove(j);
|
||||
}
|
||||
if cs.is_empty() {
|
||||
return vec![[0.5, 0.0, 0.0]];
|
||||
}
|
||||
cs.into_iter().map(|c| c.0).collect()
|
||||
}
|
||||
|
||||
/// Index of the palette colour closest to `p`.
|
||||
pub(super) fn nearest(palette: &[Px], p: Px) -> u8 {
|
||||
let mut best = (0usize, f32::MAX);
|
||||
for (i, c) in palette.iter().enumerate() {
|
||||
let d = dist2(*c, p);
|
||||
if d < best.1 {
|
||||
best = (i, d);
|
||||
}
|
||||
}
|
||||
best.0 as u8
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::photo_util::hash01;
|
||||
|
||||
/// Oklab pixels of `w`×`h`: `colour(x)` plus noise of ±`noise` in each channel.
|
||||
fn noisy(w: usize, h: usize, noise: f32, colour: impl Fn(usize) -> Px) -> Vec<Px> {
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as i64, (i / w) as i64);
|
||||
let c = colour(i % w);
|
||||
std::array::from_fn(|k| c[k] + (hash01(x, y, k as u64 + 1) - 0.5) * 2.0 * noise)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oklab_round_trips_and_marks_the_gamut() {
|
||||
let white = oklab([1.0, 1.0, 1.0]);
|
||||
assert!((white[0] - 1.0).abs() < 1e-3 && white[1].abs() < 1e-3 && white[2].abs() < 1e-3, "{white:?}");
|
||||
assert!(oklab([0.0, 0.0, 0.0])[0].abs() < 1e-6);
|
||||
for p in [[0.2, 0.5, 0.9], [0.95, 0.1, 0.3], [0.5, 0.5, 0.5], [0.0, 1.0, 0.0]] {
|
||||
let back = srgb(oklab(p)).unwrap_or([-1.0; 3]);
|
||||
assert!(dist2(back, p) < 1e-6, "{p:?} came back as {back:?}");
|
||||
}
|
||||
// Very light and very colourful: no sRGB colour is that.
|
||||
assert_eq!(srgb([0.95, 0.3, 0.0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_gamut_colours_lose_strength_not_hue() {
|
||||
let (l, a, b) = (0.95, 0.3, 0.1);
|
||||
let c = in_gamut(l, a, b);
|
||||
let lab = oklab(c);
|
||||
assert!((lab[0] - l).abs() < 0.01, "lightness kept: {lab:?}");
|
||||
let chroma = (lab[1] * lab[1] + lab[2] * lab[2]).sqrt();
|
||||
assert!(chroma > 0.01 && chroma < (a * a + b * b).sqrt(), "weaker but still coloured: {chroma}");
|
||||
assert!((lab[2] / lab[1] - b / a).abs() < 0.05, "same hue: {lab:?}");
|
||||
// In gamut it is just the conversion.
|
||||
assert_eq!(in_gamut(0.6, 0.02, -0.03), srgb([0.6, 0.02, -0.03]).unwrap_or_default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoothing_flattens_noise_and_keeps_edges() {
|
||||
let (w, h) = (40usize, 20usize);
|
||||
let lab = noisy(w, h, 0.02, |x| if x < 20 { [0.3, 0.0, 0.0] } else { [0.8, 0.0, 0.0] });
|
||||
let out = smooth(lab.clone(), w, h, 2);
|
||||
let spread = |v: &[Px], xs: std::ops::Range<usize>| {
|
||||
let l: Vec<f32> = (0..h).flat_map(|y| xs.clone().map(move |x| y * w + x)).map(|i| v[i][0]).collect();
|
||||
let mean = l.iter().sum::<f32>() / l.len() as f32;
|
||||
(l.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / l.len() as f32).sqrt()
|
||||
};
|
||||
assert!(spread(&out, 2..16) < spread(&lab, 2..16) / 2.0, "noise kept: {} vs {}", spread(&out, 2..16), spread(&lab, 2..16));
|
||||
// The step stays a step: the pixels either side of it keep most of their difference.
|
||||
let step = (0..h).map(|y| out[y * w + 20][0] - out[y * w + 19][0]).sum::<f32>() / h as f32;
|
||||
assert!(step > 0.4, "edge blurred to {step}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_palette_finds_the_pictures_own_colours() {
|
||||
let (w, h) = (60usize, 20usize);
|
||||
let (dark, light) = ([0.25, 0.02, -0.03], [0.75, -0.04, 0.05]);
|
||||
let lab = noisy(w, h, 0.01, |x| if x < 30 { dark } else { light });
|
||||
// Asked for up to six, it gives two: colours only noise apart are one.
|
||||
let p = palette(&lab, 6);
|
||||
assert_eq!(p.len(), 2, "{p:?}");
|
||||
for c in [dark, light] {
|
||||
assert!(p.iter().any(|q| dist2(*q, c).sqrt() < 0.02), "{c:?} not in {p:?}");
|
||||
}
|
||||
assert_eq!(nearest(&p, dark), nearest(&p, [0.27, 0.0, -0.02]));
|
||||
assert_ne!(nearest(&p, dark), nearest(&p, light));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tone_of_two_different_hues_splits_in_two() {
|
||||
// A dark tone and a light one, the light one half blue and half pink: two levels give
|
||||
// three colours, or the blue and pink areas would be one shape of their grey average.
|
||||
let (w, h) = (60usize, 20usize);
|
||||
let (dark, blue, pink) = ([0.2, 0.0, 0.0], [0.7, -0.03, -0.1], [0.7, 0.12, 0.0]);
|
||||
let lab = noisy(w, h, 0.005, |x| {
|
||||
if x < 30 {
|
||||
dark
|
||||
} else if x < 45 {
|
||||
blue
|
||||
} else {
|
||||
pink
|
||||
}
|
||||
});
|
||||
let p = palette(&lab, 2);
|
||||
assert_eq!(p.len(), 3, "{p:?}");
|
||||
for c in [dark, blue, pink] {
|
||||
assert!(p.iter().any(|q| dist2(*q, c).sqrt() < 0.03), "{c:?} not in {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_image_still_has_a_palette() {
|
||||
assert_eq!(palette(&[], 4).len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Curves: each chain fitted once, with its corners kept, its runs simplified (Douglas–Peucker)
|
||||
//! and drawn as Catmull-Rom curves.
|
||||
//! Outlines: each chain fitted once into a straight-sided polyline, with its corners kept and its
|
||||
//! runs simplified (Douglas–Peucker).
|
||||
|
||||
use super::{Pt, Settings};
|
||||
|
||||
@@ -13,7 +13,7 @@ fn len(a: Pt) -> f32 {
|
||||
(a[0] * a[0] + a[1] * a[1]).sqrt()
|
||||
}
|
||||
|
||||
/// Fits one chain: corners kept, runs simplified and smoothed. Its ends stay where they are, so
|
||||
/// Fits one chain: corners kept, runs simplified to straight segments. Its ends stay where they are, so
|
||||
/// chains meeting at a junction still meet.
|
||||
pub(super) fn fit(pts: &[Pt], closed: bool, s: &Settings) -> Vec<Pt> {
|
||||
let n = pts.len();
|
||||
@@ -93,7 +93,11 @@ pub(super) fn fit(pts: &[Pt], closed: bool, s: &Settings) -> Vec<Pt> {
|
||||
}
|
||||
let v: Vec<Pt> = verts.iter().map(|&i| p[i]).collect();
|
||||
let corner: Vec<bool> = verts.iter().map(|&i| is_corner[i]).collect();
|
||||
let out = curves(&v, &corner, closed);
|
||||
let _ = &corner;
|
||||
let mut out = v.clone();
|
||||
if closed {
|
||||
out.extend(v.first().copied());
|
||||
}
|
||||
if closed {
|
||||
let before = signed_area(original);
|
||||
let after = signed_area(&out);
|
||||
@@ -187,54 +191,6 @@ fn simplify(p: &[Pt], a: usize, b: usize, tol: f32, marks: &mut [bool]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Catmull-Rom curves through `v` (one-sided at corners), flattened to a polyline.
|
||||
fn curves(v: &[Pt], corner: &[bool], closed: bool) -> Vec<Pt> {
|
||||
let m = v.len();
|
||||
if m < 2 {
|
||||
return v.to_vec();
|
||||
}
|
||||
let at = |i: isize| v[i.rem_euclid(m as isize) as usize];
|
||||
let sharp = |i: isize| (!closed && (i <= 0 || i >= m as isize - 1)) || corner[i.rem_euclid(m as isize) as usize];
|
||||
let mut out = vec![v[0]];
|
||||
let segs = if closed { m } else { m - 1 };
|
||||
for i in 0..segs as isize {
|
||||
let (p0, p1) = (at(i), at(i + 1));
|
||||
let d = len(sub(p1, p0));
|
||||
let (s0, s1) = (sharp(i), sharp(i + 1));
|
||||
if s0 && s1 || d < 1e-6 {
|
||||
out.push(p1);
|
||||
continue;
|
||||
}
|
||||
// Tangents scaled to this segment, so short and long neighbours don't overshoot.
|
||||
let t0 = if s0 {
|
||||
sub(p1, p0)
|
||||
} else {
|
||||
let dp = len(sub(p0, at(i - 1)));
|
||||
let k = d / (dp + d).max(1e-6);
|
||||
let t = sub(p1, at(i - 1));
|
||||
[t[0] * k, t[1] * k]
|
||||
};
|
||||
let t1 = if s1 {
|
||||
sub(p1, p0)
|
||||
} else {
|
||||
let dn = len(sub(at(i + 2), p1));
|
||||
let k = d / (d + dn).max(1e-6);
|
||||
let t = sub(at(i + 2), p0);
|
||||
[t[0] * k, t[1] * k]
|
||||
};
|
||||
let c1 = [p0[0] + t0[0] / 3.0, p0[1] + t0[1] / 3.0];
|
||||
let c2 = [p1[0] - t1[0] / 3.0, p1[1] - t1[1] / 3.0];
|
||||
let steps = ((d / 1.5).ceil() as usize).clamp(2, 24);
|
||||
for k in 1..=steps {
|
||||
let t = k as f32 / steps as f32;
|
||||
let u = 1.0 - t;
|
||||
let (b0, b1, b2, b3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
out.push([b0 * p0[0] + b1 * c1[0] + b2 * c2[0] + b3 * p1[0], b0 * p0[1] + b1 * c1[1] + b2 * c2[1] + b3 * p1[1]]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bounding box `[x0, y0, x1, y1]` of `pts`.
|
||||
pub(super) fn bounds(pts: &[Pt]) -> [f32; 4] {
|
||||
pts.iter().fold([f32::MAX, f32::MAX, f32::MIN, f32::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])])
|
||||
@@ -330,18 +286,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curves_pass_through_their_vertices() {
|
||||
let v = [[0.0, 0.0], [4.0, 3.0], [8.0, 0.0], [12.0, 4.0]];
|
||||
let out = curves(&v, &[false; 4], false);
|
||||
for p in v {
|
||||
assert!(out.iter().any(|q| close(*q, p)), "{p:?} missed: {out:?}");
|
||||
}
|
||||
assert!(out.len() > 8, "flattened into steps: {}", out.len());
|
||||
// Between two corners a segment is straight.
|
||||
assert_eq!(curves(&v, &[true; 4], false), v.to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_keeps_small_islands_and_their_winding() {
|
||||
use super::super::edges::follow_edges;
|
||||
|
||||
@@ -1,61 +1,63 @@
|
||||
//! Cutout (Filter Gallery › Artistic): the image as flat sheets of cut paper.
|
||||
//!
|
||||
//! The image is traced into shapes, and the shapes are filled back in:
|
||||
//! What it does follows the reference application, measured on grey ramps, hue sweeps, squares of
|
||||
//! known size and a portrait:
|
||||
//!
|
||||
//! 1. **Smooth** within tones, edges kept: a bilateral filter (C. Tomasi and R. Manduchi,
|
||||
//! "Bilateral Filtering for Gray and Color Images", 1998) in Oklab, so a photo's noise doesn't
|
||||
//! decide pixel by pixel which of two colours a pixel gets.
|
||||
//! 2. **Palette**: median cut (Heckbert 1982) then k-means over the smoothed colours, Number of
|
||||
//! Levels tones of the picture's own; a tone made of two clearly different hues splits in two,
|
||||
//! and colours only noise apart are merged.
|
||||
//! 3. **Regions**: 4-connected areas of one palette colour. Regions smaller than the Edge
|
||||
//! Simplicity area, or thin slivers (the blends along an edge between two other colours), are
|
||||
//! merged smallest first into the neighbour they share most border with. Each region is filled
|
||||
//! with the mean colour of its own pixels.
|
||||
//! 4. **Edges**: the cracks between regions are linked into chains from junction to junction
|
||||
//! 1. **Tone bands**: each pixel's band is the plain mean of its three channels, cut into Number of
|
||||
//! Levels equal steps (four levels: below 64, 128, 192 and the rest, on 0–255). Colours of
|
||||
//! different hue but the same mean share a band.
|
||||
//! 2. **Regions**: 4-connected areas of one band. Regions smaller than the Edge Simplicity area
|
||||
//! merge, smallest first, into the neighbour they share most border with. The area depends on
|
||||
//! the slider alone, not on the image size. Each region is filled with the mean colour of its own
|
||||
//! pixels, so a region mixing hues comes out as their average.
|
||||
//! 3. **Edges**: the cracks between regions are linked into chains from junction to junction
|
||||
//! (where three regions meet, or the canvas edge). Each crack is moved along its normal to where
|
||||
//! the pixels on either side say the edge is (an anti-aliased pixel is part one colour, part the
|
||||
//! other), so outlines are right to a fraction of a pixel. A chain is fitted once, so two
|
||||
//! neighbouring shapes share one and the same edge.
|
||||
//! 5. **Curves**: turns sharper than Edge Fidelity allows are kept as corners (squared back where
|
||||
//! the pixels rounded them); the runs between are simplified to the Edge Simplicity tolerance
|
||||
//! (Douglas–Peucker) and drawn as Catmull-Rom curves.
|
||||
//! 6. **Fill**: each region is scan-converted with exact area coverage, so edges are anti-aliased
|
||||
//! other). A chain is fitted once, so two neighbouring shapes share one and the same edge.
|
||||
//! 4. **Outlines**: straight-sided polygons. Sharp bends stay corners; the runs between are
|
||||
//! simplified (Douglas–Peucker) to a tolerance that grows with Edge Simplicity and shrinks with
|
||||
//! Edge Fidelity.
|
||||
//! 5. **Fill**: each region is scan-converted with exact area coverage, so edges are anti-aliased
|
||||
//! and the coverages of shapes that share an edge sum to one: no seams.
|
||||
//!
|
||||
//! Tracing reads the whole image and runs once; filling runs per tile and gives the same pixels
|
||||
//! whatever the tiling.
|
||||
//!
|
||||
//! Each stage is its own module: [`colour`] (steps 1–2), [`regions`] (3), [`edges`] (4),
|
||||
//! [`curves`] (5) and [`fill`] (6); [`trace`] runs them in turn.
|
||||
//! Each stage is its own module: [`regions`] (1–2), [`edges`] (3), [`curves`] (4) and [`fill`]
|
||||
//! (5); [`trace`] runs them in turn.
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::par;
|
||||
|
||||
mod colour;
|
||||
mod curves;
|
||||
mod edges;
|
||||
mod fill;
|
||||
mod regions;
|
||||
mod util;
|
||||
|
||||
use colour::{nearest, oklab, palette, smooth};
|
||||
use curves::{bounds, fit};
|
||||
use edges::follow_edges;
|
||||
use fill::line_clipped;
|
||||
use regions::{components, local_paper, merge_regions, paper_colours};
|
||||
use regions::{components, merge_regions};
|
||||
|
||||
type Px = [f32; 3];
|
||||
type Pt = [f32; 2];
|
||||
|
||||
/// The tone band of a pixel: the mean of its three channels in `levels` equal steps.
|
||||
fn band(p: Px, levels: usize) -> u8 {
|
||||
let v = (p[0] + p[1] + p[2]) / 3.0;
|
||||
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
((v * levels as f32) as usize).min(levels - 1) as u8
|
||||
}
|
||||
|
||||
/// Region id of the area outside the image.
|
||||
const OUT: u32 = u32::MAX;
|
||||
|
||||
/// The filter's three sliders.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Settings {
|
||||
/// Number of Levels (2–8): the most colours the palette has.
|
||||
/// Number of Levels (2–8): how many tone bands.
|
||||
pub levels: usize,
|
||||
/// Edge Simplicity (0–10): smaller shapes merge and outlines simplify more.
|
||||
pub simplicity: f32,
|
||||
@@ -63,19 +65,27 @@ pub(crate) struct Settings {
|
||||
pub fidelity: f32,
|
||||
}
|
||||
|
||||
/// Linear interpolation in a per-step table over Edge Simplicity 0–10.
|
||||
fn by_simplicity(table: &[f32; 11], simplicity: f32) -> f32 {
|
||||
let s = if simplicity.is_finite() { simplicity.clamp(0.0, 10.0) } else { 0.0 };
|
||||
let i = (s.floor() as usize).min(9);
|
||||
let t = s - i as f32;
|
||||
table[i] + (table[i + 1] - table[i]) * t
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Regions with fewer pixels merge into a neighbour.
|
||||
/// Regions with fewer pixels merge into a neighbour. Measured in the reference application
|
||||
/// with squares of 2–96 px: the smallest that survive are 6 px wide at Simplicity 0–2, 12 px
|
||||
/// at 4, 48 px at 6 and 96 px at 7, the same on a 256 px and a 600 px canvas.
|
||||
fn min_area(&self) -> u32 {
|
||||
(1.0 + self.simplicity.clamp(0.0, 10.0)).powi(2).round() as u32
|
||||
const AREA: [f32; 11] = [25.0, 25.0, 25.0, 50.0, 100.0, 400.0, 1500.0, 5000.0, 10000.0, 16000.0, 30000.0];
|
||||
by_simplicity(&AREA, self.simplicity).round() as u32
|
||||
}
|
||||
/// Regions thinner than this (twice the area over the perimeter, in pixels) merge too.
|
||||
fn sliver(&self) -> f32 {
|
||||
0.6 + 0.1 * self.simplicity.clamp(0.0, 10.0)
|
||||
}
|
||||
/// Douglas–Peucker tolerance of the outlines, in pixels: looser with less Edge Fidelity
|
||||
/// (twice as loose at 1, half at 3).
|
||||
/// Douglas–Peucker tolerance of the straight outlines, in pixels: coarser with more Edge
|
||||
/// Simplicity, finer with more Edge Fidelity.
|
||||
fn tolerance(&self) -> f32 {
|
||||
(0.5 + 0.12 * self.simplicity.clamp(0.0, 10.0)) * 2f32.powf(2.0 - self.fidelity.clamp(1.0, 3.0))
|
||||
const TOL: [f32; 11] = [0.5, 0.5, 0.6, 0.8, 1.0, 1.5, 2.5, 4.0, 7.0, 12.0, 20.0];
|
||||
by_simplicity(&TOL, self.simplicity) * 2f32.powf(2.0 - self.fidelity.clamp(1.0, 3.0))
|
||||
}
|
||||
/// Below Edge Fidelity 2 the outline is rounded off before it is fitted: points averaged with
|
||||
/// this many neighbours either side (3 at fidelity 1).
|
||||
@@ -86,10 +96,6 @@ impl Settings {
|
||||
fn corner_turn(&self) -> f32 {
|
||||
(100.0 - 22.5 * self.fidelity.clamp(1.0, 3.0)).to_radians()
|
||||
}
|
||||
/// Rounds of edge-preserving smoothing.
|
||||
fn smooth_rounds(&self) -> usize {
|
||||
2 + (self.simplicity.clamp(0.0, 10.0) / 4.0) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// A traced image: the shapes and their colours, ready to fill any tile.
|
||||
@@ -102,11 +108,6 @@ pub(crate) struct Traced {
|
||||
/// Per chain, its bounding box `[x0, y0, x1, y1]`.
|
||||
chain_boxes: Vec<[f32; 4]>,
|
||||
regions: Vec<Region>,
|
||||
/// Width of the traced window, each pixel's region, and its paper colour (the region's
|
||||
/// lightness with the hue of its own pixels nearby).
|
||||
width: usize,
|
||||
reg: Vec<u32>,
|
||||
field: Vec<Px>,
|
||||
}
|
||||
|
||||
/// One shape: its flat colour, bounding box and outline.
|
||||
@@ -120,23 +121,25 @@ struct Region {
|
||||
/// Traces `c` (sRGB colour of every pixel of `r`, row-major).
|
||||
pub(crate) fn trace(c: &[Px], r: Rect, s: &Settings) -> Traced {
|
||||
let (w, h) = (r.width() as usize, r.height() as usize);
|
||||
let empty = Traced { x0: r.x0, y0: r.y0, chains: Vec::new(), chain_boxes: Vec::new(), regions: Vec::new(), width: 0, reg: Vec::new(), field: Vec::new() };
|
||||
let empty = Traced { x0: r.x0, y0: r.y0, chains: Vec::new(), chain_boxes: Vec::new(), regions: Vec::new() };
|
||||
if w == 0 || h == 0 || c.len() != w * h {
|
||||
return empty;
|
||||
}
|
||||
let mut lab = vec![[0.0f32; 3]; w * h];
|
||||
par::chunks_mut(&mut lab, w, true, |y, row| {
|
||||
for (o, p) in row.iter_mut().zip(&c[y * w..(y + 1) * w]) {
|
||||
*o = oklab(*p);
|
||||
}
|
||||
});
|
||||
let original = lab.clone();
|
||||
let lab = smooth(lab, w, h, s.smooth_rounds());
|
||||
let palette = palette(&lab, s.levels.clamp(2, 8));
|
||||
// A non-finite sample (32-bit documents can hold them) would make its region's mean colour,
|
||||
// and so its edges and fill, non-finite: count it as 0 from here on.
|
||||
let clean = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
let owned: Vec<Px>;
|
||||
let c: &[Px] = if c.iter().flatten().all(|v| v.is_finite()) {
|
||||
c
|
||||
} else {
|
||||
owned = c.iter().map(|p| p.map(clean)).collect();
|
||||
&owned
|
||||
};
|
||||
let levels = s.levels.clamp(2, 8);
|
||||
let mut labels = vec![0u8; w * h];
|
||||
par::chunks_mut(&mut labels, w, true, |y, row| {
|
||||
for (o, p) in row.iter_mut().zip(&lab[y * w..(y + 1) * w]) {
|
||||
*o = nearest(&palette, *p);
|
||||
for (o, p) in row.iter_mut().zip(&c[y * w..(y + 1) * w]) {
|
||||
*o = band(*p, levels);
|
||||
}
|
||||
});
|
||||
let (comp, n) = components(&labels, w, h);
|
||||
@@ -144,10 +147,8 @@ pub(crate) fn trace(c: &[Px], r: Rect, s: &Settings) -> Traced {
|
||||
let (chains, sides) = follow_edges(®, &colours, c, w, h);
|
||||
let chains: Vec<Vec<Pt>> = par::map(chains.len(), true, |i| fit(&chains[i].0, chains[i].1, s));
|
||||
let chain_boxes: Vec<[f32; 4]> = chains.iter().map(|ch| bounds(ch)).collect();
|
||||
let paint = paper_colours(®, colours.len(), &original);
|
||||
let field = local_paper(®, &paint, &original, w, h, 3.0 + 1.2 * s.simplicity.clamp(0.0, 10.0));
|
||||
let mut regions: Vec<Region> =
|
||||
paint.into_iter().map(|colour| Region { colour, bbox: [f32::MAX, f32::MAX, f32::MIN, f32::MIN], edges: Vec::new() }).collect();
|
||||
colours.into_iter().map(|colour| Region { colour, bbox: [f32::MAX, f32::MAX, f32::MIN, f32::MIN], edges: Vec::new() }).collect();
|
||||
for (k, &(left, right)) in sides.iter().enumerate() {
|
||||
for (side, rev) in [(left, false), (right, true)] {
|
||||
if let Some(g) = regions.get_mut(side as usize) {
|
||||
@@ -157,7 +158,7 @@ pub(crate) fn trace(c: &[Px], r: Rect, s: &Settings) -> Traced {
|
||||
}
|
||||
}
|
||||
}
|
||||
Traced { x0: r.x0, y0: r.y0, chains, chain_boxes, regions, width: w, reg, field }
|
||||
Traced { x0: r.x0, y0: r.y0, chains, chain_boxes, regions }
|
||||
}
|
||||
|
||||
impl Traced {
|
||||
@@ -173,9 +174,7 @@ impl Traced {
|
||||
let mut acc = vec![0.0f32; stride * h];
|
||||
let (ox, oy) = ((self.x0 - r.x0) as f32, (self.y0 - r.y0) as f32);
|
||||
let (wf, hf) = (w as f32, h as f32);
|
||||
let (dx, dy) = ((r.x0 - self.x0) as i64, (r.y0 - self.y0) as i64);
|
||||
let th = self.reg.len() / self.width.max(1);
|
||||
for (gi, g) in self.regions.iter().enumerate() {
|
||||
for g in &self.regions {
|
||||
let b = [g.bbox[0] + ox, g.bbox[1] + oy, g.bbox[2] + ox, g.bbox[3] + oy];
|
||||
if b[2] <= 0.0 || b[0] >= wf || b[3] <= 0.0 || b[1] >= hf {
|
||||
continue;
|
||||
@@ -203,14 +202,7 @@ impl Traced {
|
||||
let cov = sum.abs().min(1.0);
|
||||
if cov > 0.0 {
|
||||
let i = y * w + x;
|
||||
// Inside its own pixels a shape takes the local colour; where it reaches
|
||||
// into a neighbour's edge pixel, its own flat colour.
|
||||
let (tx, ty) = (x as i64 + dx, y as i64 + dy);
|
||||
let t = (tx >= 0 && ty >= 0 && (tx as usize) < self.width && (ty as usize) < th).then(|| ty as usize * self.width + tx as usize);
|
||||
let paint = match t {
|
||||
Some(t) if self.reg[t] as usize == gi => self.field[t],
|
||||
_ => g.colour,
|
||||
};
|
||||
let paint = g.colour;
|
||||
col[i] = [col[i][0] + paint[0] * cov, col[i][1] + paint[1] * cov, col[i][2] + paint[2] * cov];
|
||||
weight[i] += cov;
|
||||
}
|
||||
@@ -231,7 +223,9 @@ impl Traced {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use colour::dist2;
|
||||
fn dist2(a: Px, b: Px) -> f32 {
|
||||
(a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)
|
||||
}
|
||||
|
||||
fn settings(levels: usize, simplicity: f32, fidelity: f32) -> Settings {
|
||||
Settings { levels, simplicity, fidelity }
|
||||
@@ -286,10 +280,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_fidelity_changes_the_outlines() {
|
||||
// It used to set only which bends count as corners: on smooth shapes 0.5% of pixels
|
||||
// changed from fidelity 1 to 2 and 2.8% from 2 to 3 (now about 9% and 5%).
|
||||
// Large smooth shapes, as in a photo: few corners, long curved outlines.
|
||||
fn edge_fidelity_changes_the_outlines_a_little() {
|
||||
// In the reference application Edge Fidelity nudges the outlines: on a portrait 1.9% of
|
||||
// pixels change from fidelity 1 to 2 and 1.2% from 2 to 3. Large smooth shapes here.
|
||||
let (w, h) = (160usize, 120usize);
|
||||
let pal = [[0.1, 0.1, 0.1], [0.9, 0.3, 0.2], [0.3, 0.6, 0.9]];
|
||||
let c: Vec<Px> = (0..w * h)
|
||||
@@ -303,8 +296,10 @@ mod tests {
|
||||
let out = |f: f32| trace(&c, r, &settings(3, 2.0, f)).render(r);
|
||||
let changed = |a: &[Px], b: &[Px]| a.iter().zip(b).filter(|(p, q)| dist2(**p, **q) > 0.01).count() as f32 / a.len() as f32;
|
||||
let (loose, normal, tight) = (out(1.0), out(2.0), out(3.0));
|
||||
assert!(changed(&loose, &normal) > 0.04, "fidelity 1 vs 2: {}", changed(&loose, &normal));
|
||||
assert!(changed(&normal, &tight) > 0.04, "fidelity 2 vs 3: {}", changed(&normal, &tight));
|
||||
for (name, a, b) in [("1 vs 2", &loose, &normal), ("2 vs 3", &normal, &tight)] {
|
||||
let f = changed(a, b);
|
||||
assert!(f > 0.002 && f < 0.06, "fidelity {name}: {f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -313,7 +308,6 @@ mod tests {
|
||||
let t = trace(&[], r, &settings(4, 2.0, 1.0));
|
||||
assert!(t.regions.is_empty());
|
||||
assert!(t.chains.is_empty());
|
||||
assert!(t.field.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,13 +330,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A NaN or infinite sample must not reach a region's colour, its edges or the fill.
|
||||
#[test]
|
||||
fn noise_merges_into_neighbours_and_noise_apart_colours_are_one() {
|
||||
fn non_finite_samples_give_finite_covered_output() {
|
||||
let (w, h) = (24usize, 16usize);
|
||||
let mut c: Vec<Px> = (0..w * h).map(|i| if i % w < 12 { [0.1, 0.1, 0.1] } else { [0.9, 0.8, 0.7] }).collect();
|
||||
c[3 * w + 4] = [f32::NAN, 0.5, 0.5];
|
||||
c[8 * w + 15] = [f32::INFINITY, f32::NEG_INFINITY, 0.2];
|
||||
c[12 * w + 12] = [f32::NAN; 3];
|
||||
let r = Rect::new(0, 0, w as i32, h as i32);
|
||||
for simplicity in [0.0, 4.0] {
|
||||
let t = trace(&c, r, &settings(4, simplicity, 2.0));
|
||||
assert!(t.regions.iter().all(|g| g.colour.iter().all(|v| v.is_finite())), "region colours finite");
|
||||
assert!(t.chains.iter().flatten().flatten().all(|v| v.is_finite()), "outlines finite");
|
||||
let out = t.render(r);
|
||||
assert!(out.iter().flatten().all(|v| v.is_finite()), "pixels finite");
|
||||
let worst = coverage(&t, r).iter().map(|v| (v - 1.0).abs()).fold(0.0, f32::max);
|
||||
assert!(worst < 1e-3, "coverage off by {worst}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_merges_into_neighbours_and_a_six_pixel_square_survives_at_simplicity_0() {
|
||||
let (w, h) = (32usize, 32usize);
|
||||
let c: Vec<Px> = (0..w * h)
|
||||
.map(|i| {
|
||||
let v = 0.3 + crate::photo_util::hash01((i % w) as i64, (i / w) as i64, 5) * 0.06;
|
||||
if (11..14).contains(&(i % w)) && (11..14).contains(&(i / w)) { [0.9, 0.9, 0.9] } else { [v, v, v] }
|
||||
// Noise inside one band (1/6 to 2/6 at six levels).
|
||||
let v = 0.2 + crate::photo_util::hash01((i % w) as i64, (i / w) as i64, 5) * 0.06;
|
||||
if (11..17).contains(&(i % w)) && (11..17).contains(&(i / w)) { [0.9, 0.9, 0.9] } else { [v, v, v] }
|
||||
})
|
||||
.collect();
|
||||
let r = Rect::new(0, 0, w as i32, h as i32);
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
//! Regions: connected areas of one palette colour, the merge of small ones and slivers, and the
|
||||
//! paper colour of each region.
|
||||
//! Regions: connected areas of one tone band, and the merge of small ones into their neighbours.
|
||||
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BinaryHeap, HashMap};
|
||||
use std::hash::BuildHasherDefault;
|
||||
|
||||
use super::colour::{in_gamut, oklab, srgb};
|
||||
use super::util::{FxHasher, find, transpose, transpose_u32};
|
||||
use super::util::{FxHasher, find};
|
||||
use super::{Px, Settings};
|
||||
use crate::par;
|
||||
|
||||
/// 4-connected components of equal labels, numbered in raster order of their first pixel.
|
||||
pub(super) fn components(labels: &[u8], w: usize, h: usize) -> (Vec<u32>, usize) {
|
||||
@@ -57,9 +54,8 @@ pub(super) fn merge_regions(comp: &[u32], n: usize, c: &[Px], w: usize, h: usize
|
||||
area[k] += 1;
|
||||
sum[k] = [sum[k][0] + f64::from(p[0]), sum[k][1] + f64::from(p[1]), sum[k][2] + f64::from(p[2])];
|
||||
}
|
||||
// Shared border lengths (in cracks) and perimeters, the canvas edge included.
|
||||
// Shared border lengths, in cracks.
|
||||
let mut shared: HashMap<u64, u32, BuildHasherDefault<FxHasher>> = HashMap::default();
|
||||
let mut perim = vec![0u32; n];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let a = comp[y * w + x];
|
||||
@@ -71,7 +67,6 @@ pub(super) fn merge_regions(comp: &[u32], n: usize, c: &[Px], w: usize, h: usize
|
||||
}
|
||||
}
|
||||
}
|
||||
perim[a as usize] += u32::from(x == 0) + u32::from(x + 1 == w) + u32::from(y == 0) + u32::from(y + 1 == h);
|
||||
}
|
||||
}
|
||||
let mut adj: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n];
|
||||
@@ -81,16 +76,14 @@ pub(super) fn merge_regions(comp: &[u32], n: usize, c: &[Px], w: usize, h: usize
|
||||
let (a, b) = ((key >> 32) as u32, key as u32);
|
||||
adj[a as usize].push((b, len));
|
||||
adj[b as usize].push((a, len));
|
||||
perim[a as usize] += len;
|
||||
perim[b as usize] += len;
|
||||
}
|
||||
let (min_area, sliver) = (s.min_area(), s.sliver());
|
||||
let mergeable = |a: u32, p: u32| a < min_area || 2.0 * a as f32 / (p.max(1) as f32) < sliver;
|
||||
let min_area = s.min_area();
|
||||
let mergeable = |a: u32| a < min_area;
|
||||
let mut parent: Vec<u32> = (0..n as u32).collect();
|
||||
let mut heap: BinaryHeap<Reverse<(u32, u32)>> = (0..n).filter(|&i| mergeable(area[i], perim[i])).map(|i| Reverse((area[i], i as u32))).collect();
|
||||
let mut heap: BinaryHeap<Reverse<(u32, u32)>> = (0..n).filter(|&i| mergeable(area[i])).map(|i| Reverse((area[i], i as u32))).collect();
|
||||
while let Some(Reverse((a, id))) = heap.pop() {
|
||||
let i = id as usize;
|
||||
if find(&mut parent, id) != id || area[i] != a || !mergeable(a, perim[i]) {
|
||||
if find(&mut parent, id) != id || area[i] != a || !mergeable(a) {
|
||||
continue;
|
||||
}
|
||||
// The neighbours as they are now, with their total shared border.
|
||||
@@ -104,7 +97,7 @@ pub(super) fn merge_regions(comp: &[u32], n: usize, c: &[Px], w: usize, h: usize
|
||||
false
|
||||
}
|
||||
});
|
||||
let Some(&(t, len)) = nb.iter().max_by(|a, b| a.1.cmp(&b.1).then(b.0.cmp(&a.0))) else {
|
||||
let Some(&(t, _)) = nb.iter().max_by(|a, b| a.1.cmp(&b.1).then(b.0.cmp(&a.0))) else {
|
||||
adj[i] = nb;
|
||||
continue;
|
||||
};
|
||||
@@ -112,10 +105,9 @@ pub(super) fn merge_regions(comp: &[u32], n: usize, c: &[Px], w: usize, h: usize
|
||||
parent[i] = t;
|
||||
area[ti] += area[i];
|
||||
sum[ti] = [sum[ti][0] + sum[i][0], sum[ti][1] + sum[i][1], sum[ti][2] + sum[i][2]];
|
||||
perim[ti] = (perim[ti] + perim[i]).saturating_sub(2 * len);
|
||||
// Appended as they are; resolved and summed when `t` itself merges.
|
||||
adj[ti].extend(nb.into_iter().filter(|&(k, _)| k != t));
|
||||
if mergeable(area[ti], perim[ti]) {
|
||||
if mergeable(area[ti]) {
|
||||
heap.push(Reverse((area[ti], t)));
|
||||
}
|
||||
}
|
||||
@@ -134,132 +126,6 @@ pub(super) fn merge_regions(comp: &[u32], n: usize, c: &[Px], w: usize, h: usize
|
||||
(reg, colours)
|
||||
}
|
||||
|
||||
/// The paper colour of every region. A plain average of a region's pixels greys it (a face of
|
||||
/// pink and cream patches averages to beige, hues cancel), and a whole image of averages sits in
|
||||
/// the middle tones. So in Oklab each region keeps its average hue at the average *strength* of
|
||||
/// its pixels' colour, and lightness is stretched over the image's regions so the darkest paper is
|
||||
/// near black and the lightest near white. Out-of-gamut colours lose chroma, not hue.
|
||||
pub(super) fn paper_colours(reg: &[u32], n: usize, lab: &[Px]) -> Vec<Px> {
|
||||
let mut acc = vec![[0.0f64; 5]; n];
|
||||
for (&r, p) in reg.iter().zip(lab) {
|
||||
if let Some(a) = acc.get_mut(r as usize) {
|
||||
let chroma = (p[1] * p[1] + p[2] * p[2]).sqrt();
|
||||
*a = [a[0] + f64::from(p[0]), a[1] + f64::from(p[1]), a[2] + f64::from(p[2]), a[3] + f64::from(chroma), a[4] + 1.0];
|
||||
}
|
||||
}
|
||||
let mean: Vec<(Px, f32, f64)> = acc
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let k = a[4].max(1.0);
|
||||
([(a[0] / k) as f32, (a[1] / k) as f32, (a[2] / k) as f32], (a[3] / k) as f32, a[4])
|
||||
})
|
||||
.collect();
|
||||
// The lightness range of the image's paper, by area (ignoring the extreme 1% at each end).
|
||||
let mut by_l: Vec<(f32, f64)> = mean.iter().map(|m| (m.0[0], m.2)).collect();
|
||||
by_l.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let total: f64 = by_l.iter().map(|m| m.1).sum();
|
||||
let at = |q: f64| {
|
||||
let mut run = 0.0;
|
||||
for &(l, a) in &by_l {
|
||||
run += a;
|
||||
if run >= q * total {
|
||||
return l;
|
||||
}
|
||||
}
|
||||
by_l.last().map_or(0.5, |m| m.0)
|
||||
};
|
||||
let (lo, hi) = (at(0.01), at(0.99));
|
||||
const STRETCH: f32 = 0.75;
|
||||
mean.iter()
|
||||
.map(|(m, chroma, _)| {
|
||||
let ab = (m[1] * m[1] + m[2] * m[2]).sqrt();
|
||||
let k = if ab > 1e-5 { chroma / ab } else { 1.0 };
|
||||
let stretched = if hi - lo > 0.05 { m[0] + (0.04 + (m[0] - lo) / (hi - lo) * 0.88 - m[0]) * STRETCH } else { m[0] };
|
||||
let stretched = stretched.clamp(0.0, 1.0);
|
||||
let colour = |l: f32, t: f32| srgb([l, m[1] * k * t, m[2] * k * t]);
|
||||
if let Some(c) = colour(stretched, 1.0) {
|
||||
return c;
|
||||
}
|
||||
// Colour comes before contrast: a strong colour the stretch took out of gamut (a
|
||||
// bright blue pushed towards white) moves back towards its own lightness first.
|
||||
let own = m[0].clamp(0.0, 1.0);
|
||||
if colour(own, 1.0).is_some() {
|
||||
let (mut ok, mut bad) = (own, stretched);
|
||||
for _ in 0..12 {
|
||||
let l = (ok + bad) / 2.0;
|
||||
if colour(l, 1.0).is_some() { ok = l } else { bad = l }
|
||||
}
|
||||
return colour(ok, 1.0).unwrap_or([ok, ok, ok]);
|
||||
}
|
||||
// Even its own lightness can't hold it: keep the stretch, lose chroma (not hue).
|
||||
let (mut ok, mut bad) = (0.0f32, 1.0f32);
|
||||
for _ in 0..12 {
|
||||
let t = (ok + bad) / 2.0;
|
||||
if colour(stretched, t).is_some() { ok = t } else { bad = t }
|
||||
}
|
||||
colour(stretched, ok).unwrap_or([stretched, stretched, stretched])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Each pixel's paper colour: its region's lightness (flat across the shape) with the average
|
||||
/// hue, at the average strength, of the region's own pixels within about `reach` pixels. Colour
|
||||
/// can drift gently across a large shape (pink cheeks on a cream face) but never crosses a cut.
|
||||
/// `paper` is each region's colour (from [`paper_colours`]).
|
||||
pub(super) fn local_paper(reg: &[u32], paper: &[Px], lab: &[Px], w: usize, h: usize, reach: f32) -> Vec<Px> {
|
||||
let mut v: Vec<Px> = lab.iter().map(|p| [p[1], p[2], (p[1] * p[1] + p[2] * p[2]).sqrt()]).collect();
|
||||
let regt = transpose_u32(reg, w, h);
|
||||
let a = (-1.0 / reach.max(0.5)).exp();
|
||||
for _ in 0..2 {
|
||||
same_region_rows(&mut v, reg, w, a);
|
||||
let mut t = transpose(&v, w, h);
|
||||
same_region_rows(&mut t, ®t, h, a);
|
||||
v = transpose(&t, h, w);
|
||||
}
|
||||
let light: Vec<f32> = paper.iter().map(|c| oklab(*c)[0]).collect();
|
||||
let mut out = vec![[0.0f32; 3]; w * h];
|
||||
par::chunks_mut(&mut out, w, true, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let i = y * w + x;
|
||||
let (q, l) = (v[i], light.get(reg[i] as usize).copied().unwrap_or(0.5));
|
||||
let ab = (q[0] * q[0] + q[1] * q[1]).sqrt();
|
||||
let k = if ab > 1e-5 { q[2] / ab } else { 1.0 };
|
||||
*o = in_gamut(l, q[0] * k, q[1] * k);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Exponential smoothing along each row (both ways) that restarts at every change of region.
|
||||
fn same_region_rows(v: &mut [Px], reg: &[u32], w: usize, a: f32) {
|
||||
let mut chunks: Vec<(&mut [Px], &[u32])> = v.chunks_mut(w).zip(reg.chunks(w)).collect();
|
||||
let f = |(row, rg): &mut (&mut [Px], &[u32])| {
|
||||
let n = row.len();
|
||||
let src = row.to_vec();
|
||||
let mut fwd = vec![([0.0f32; 3], 0.0f32); n];
|
||||
for x in 0..n {
|
||||
let (p, d) = if x > 0 && rg[x] == rg[x - 1] { fwd[x - 1] } else { ([0.0; 3], 0.0) };
|
||||
fwd[x] = ([src[x][0] + a * p[0], src[x][1] + a * p[1], src[x][2] + a * p[2]], 1.0 + a * d);
|
||||
}
|
||||
let mut back = ([0.0f32; 3], 0.0f32);
|
||||
for x in (0..n).rev() {
|
||||
if x + 1 < n && rg[x] != rg[x + 1] {
|
||||
back = ([0.0; 3], 0.0);
|
||||
}
|
||||
back = ([src[x][0] + a * back.0[0], src[x][1] + a * back.0[1], src[x][2] + a * back.0[2]], 1.0 + a * back.1);
|
||||
let den = fwd[x].1 + back.1 - 1.0;
|
||||
row[x] = std::array::from_fn(|c| (fwd[x].0[c] + back.0[c] - src[x][c]) / den);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
chunks.par_iter_mut().for_each(f);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
chunks.iter_mut().for_each(f);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -298,60 +164,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn small_regions_merge_into_the_neighbour_they_share_most_border_with() {
|
||||
let rows = ["aaaaaaaa", "aaaaaaaa", "aacaaaaa", "aaccbbbb", "aaaabbbb", "aaaabbbb", "aaaabbbb"];
|
||||
// At Edge Simplicity 0 every region is kept.
|
||||
assert_eq!(merged(&rows, 0.0).1.len(), 3);
|
||||
// At 1 (regions under four pixels merge) the three `c`s go into the `a`s, which they share seven
|
||||
// cracks with (one with the `b`s).
|
||||
let (reg, colours) = merged(&rows, 1.0);
|
||||
let rows = ["aaaaaaaaaa", "aaaaaaaaaa", "aacaaaaaaa", "aaccbbbbbb", "aaaabbbbbb", "aaaabbbbbb", "aaaabbbbbb", "aaaabbbbbb"];
|
||||
// At Edge Simplicity 0 a region needs 25 pixels: the three `c`s go into the `a`s, which
|
||||
// they share six cracks with (one with the `b`s); the 30 `b`s stay.
|
||||
let (reg, colours) = merged(&rows, 0.0);
|
||||
assert_eq!(colours.len(), 2);
|
||||
assert_eq!(reg[2 * 8 + 2], reg[0]);
|
||||
assert_ne!(reg[3 * 8 + 4], reg[0]);
|
||||
// A region's colour is the mean of its pixels: 37 `a`s (0.0) and 3 `c`s (0.5).
|
||||
assert_eq!(reg[2 * 10 + 2], reg[0]);
|
||||
assert_ne!(reg[3 * 10 + 4], reg[0]);
|
||||
// A region's colour is the mean of its pixels: 47 `a`s (0.0) and 3 `c`s (0.5).
|
||||
let a = colours[reg[0] as usize][0];
|
||||
assert!((a - 1.5 / 40.0).abs() < 1e-6, "{a}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slivers_merge_though_they_are_large() {
|
||||
// A line one pixel wide between two areas: the blend along an edge, not a shape.
|
||||
let rows: Vec<String> = (0..40).map(|_| format!("{}c{}", "a".repeat(10), "b".repeat(9))).collect();
|
||||
let rows: Vec<&str> = rows.iter().map(String::as_str).collect();
|
||||
assert_eq!(merged(&rows, 0.0).1.len(), 3, "kept at Edge Simplicity 0");
|
||||
let (_, colours) = merged(&rows, 4.0);
|
||||
assert_eq!(colours.len(), 2, "40 pixels is over the 25 that merge at 4, but it is a sliver");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_is_stretched_towards_black_and_white_and_keeps_its_hue() {
|
||||
// Three regions: a dark and a light grey, and a mid-tone orange.
|
||||
let lab = [[0.35, 0.0, 0.0], [0.65, 0.0, 0.0], [0.5, 0.06, 0.05]];
|
||||
let reg: Vec<u32> = (0..300).map(|i| (i / 100) as u32).collect();
|
||||
let px: Vec<Px> = reg.iter().map(|&r| lab[r as usize]).collect();
|
||||
let paper: Vec<Px> = paper_colours(®, 3, &px).into_iter().map(oklab).collect();
|
||||
assert!(paper[0][0] < 0.25 && paper[1][0] > 0.75, "{paper:?}");
|
||||
assert!((paper[2][0] - 0.5).abs() < 0.05, "a middle tone stays in the middle: {paper:?}");
|
||||
assert!((paper[2][2] / paper[2][1] - 0.05 / 0.06).abs() < 0.05, "hue kept: {paper:?}");
|
||||
assert!(paper[0][1].abs() < 1e-3 && paper[0][2].abs() < 1e-3, "grey stays grey: {paper:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_colour_drifts_within_a_shape_but_never_crosses_a_cut() {
|
||||
// One light region, pink on the left and cream on the right, next to a blue one.
|
||||
let (w, h) = (30usize, 4usize);
|
||||
let reg: Vec<u32> = (0..w * h).map(|i| u32::from(i % w >= 20)).collect();
|
||||
let lab: Vec<Px> = (0..w * h)
|
||||
.map(|i| match i % w {
|
||||
0..10 => [0.8, 0.08, 0.02],
|
||||
10..20 => [0.8, 0.01, 0.04],
|
||||
_ => [0.4, -0.02, -0.12],
|
||||
})
|
||||
.collect();
|
||||
let paper = [srgb([0.8, 0.04, 0.03]).unwrap_or_default(), srgb([0.4, -0.02, -0.12]).unwrap_or_default()];
|
||||
let out: Vec<Px> = local_paper(®, &paper, &lab, w, h, 3.0).into_iter().map(oklab).collect();
|
||||
let (left, right, edge) = (out[w + 2], out[w + 17], out[w + 19]);
|
||||
assert!(left[1] > right[1] + 0.03, "the pink side is pinker: {left:?} vs {right:?}");
|
||||
assert!((left[0] - right[0]).abs() < 0.01, "one lightness across the shape: {left:?} vs {right:?}");
|
||||
assert!(edge[2] > 0.0, "no blue from the next shape at the cut: {edge:?}");
|
||||
assert!((a - 1.5 / 50.0).abs() < 1e-6, "{a}");
|
||||
// At 4 a region needs 100 pixels, so the `b`s merge too.
|
||||
assert_eq!(merged(&rows, 4.0).1.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,32 +2,6 @@
|
||||
|
||||
use std::hash::Hasher;
|
||||
|
||||
use super::Px;
|
||||
use crate::par;
|
||||
|
||||
/// Row-major `w`×`h` colours as `h`×`w` (columns become rows), so a pass along rows can run
|
||||
/// along columns too.
|
||||
pub(super) fn transpose(buf: &[Px], w: usize, h: usize) -> Vec<Px> {
|
||||
let mut out = vec![[0.0f32; 3]; w * h];
|
||||
par::chunks_mut(&mut out, h, true, |x, col| {
|
||||
for (y, p) in col.iter_mut().enumerate() {
|
||||
*p = buf[y * w + x];
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// [`transpose`] for region ids.
|
||||
pub(super) fn transpose_u32(buf: &[u32], w: usize, h: usize) -> Vec<u32> {
|
||||
let mut out = vec![0u32; w * h];
|
||||
for (x, col) in out.chunks_mut(h).enumerate() {
|
||||
for (y, p) in col.iter_mut().enumerate() {
|
||||
*p = buf[y * w + x];
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Union-find root of `i`, halving the path on the way.
|
||||
pub(super) fn find(parent: &mut [u32], mut i: u32) -> u32 {
|
||||
while parent[i as usize] != i {
|
||||
@@ -60,18 +34,6 @@ impl Hasher for FxHasher {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn transposing_twice_gives_the_image_back() {
|
||||
let (w, h) = (5usize, 3usize);
|
||||
let c: Vec<Px> = (0..w * h).map(|i| [i as f32, 0.0, 0.0]).collect();
|
||||
let t = transpose(&c, w, h);
|
||||
assert_eq!(t[1], c[w], "the second value of the first column is the first of the second row");
|
||||
assert_eq!(transpose(&t, h, w), c);
|
||||
let ids: Vec<u32> = (0..(w * h) as u32).collect();
|
||||
assert_eq!(transpose_u32(&ids, w, h), t.iter().map(|p| p[0] as u32).collect::<Vec<_>>());
|
||||
assert_eq!(transpose_u32(&transpose_u32(&ids, w, h), h, w), ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_returns_the_root_and_shortens_the_path() {
|
||||
// 4 -> 3 -> 2 -> 1 -> 0.
|
||||
|
||||
@@ -33,6 +33,9 @@ pub enum LiquifyTool {
|
||||
Reconstruct,
|
||||
Smooth,
|
||||
TwirlCw,
|
||||
/// Twirl Clockwise with Alt (Option) held: a stroke's direction, not a tool of its own. The
|
||||
/// reference application's Liquify has Twirl Clockwise only; strokes may still name
|
||||
/// `twirlCcw` (Alt-strokes record it, and older scripts and saved strokes use it).
|
||||
TwirlCcw,
|
||||
Pucker,
|
||||
Bloat,
|
||||
@@ -49,12 +52,13 @@ pub enum LiquifyTool {
|
||||
}
|
||||
|
||||
impl LiquifyTool {
|
||||
pub const ALL: [LiquifyTool; 11] = [
|
||||
/// The tool strip, in order. Twirl Counterclockwise is not on it: it is Twirl Clockwise with
|
||||
/// Alt (Option) held.
|
||||
pub const ALL: [LiquifyTool; 10] = [
|
||||
LiquifyTool::ForwardWarp,
|
||||
LiquifyTool::Reconstruct,
|
||||
LiquifyTool::Smooth,
|
||||
LiquifyTool::TwirlCw,
|
||||
LiquifyTool::TwirlCcw,
|
||||
LiquifyTool::Pucker,
|
||||
LiquifyTool::Bloat,
|
||||
LiquifyTool::PushLeft,
|
||||
|
||||
+53
-77
@@ -35,10 +35,11 @@ pub struct RefineParams {
|
||||
pub feather: f32,
|
||||
/// 0–100.
|
||||
pub contrast: f32,
|
||||
/// −100–100. 100% moves the edge by its soft width: `max(radius, feather, 2 × smooth σ)`,
|
||||
/// at least 1 px, so a hard edge moves a pixel (Photoshop's Shift Edge moves soft borders;
|
||||
/// a band of several pixels around hard line art fills it in solid). Values between whole
|
||||
/// pixels blend the two neighbouring shifts, so the slider does not jump.
|
||||
/// −100–100. A curve on the refined coverage, applied last: each pixel's odds (`a / (1 − a)`)
|
||||
/// are multiplied by `49^(shift / 100)`, so at −100% a half-covered pixel drops to 2% and at
|
||||
/// +100% it rises to 98%. Fully selected and unselected pixels never change, so Shift Edge
|
||||
/// moves only soft borders and a hard edge stays put (measured against the reference
|
||||
/// application: the fit holds within ±0.05 in log-odds at feathers of 2, 5 and 10 px).
|
||||
pub shift_edge: f32,
|
||||
}
|
||||
|
||||
@@ -62,20 +63,10 @@ impl RefineParams {
|
||||
fn feather_sigma(&self) -> f32 {
|
||||
self.feather.max(0.0) / 2.0
|
||||
}
|
||||
/// Signed shift in pixels, fractional. 100% is the edge's soft width (at least 1 px),
|
||||
/// clamped to ±64.
|
||||
fn shift_amount(&self) -> f32 {
|
||||
let soft = self.radius.max(self.feather).max(2.0 * self.smooth_sigma()).max(1.0);
|
||||
let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * soft;
|
||||
if px.is_finite() { px.clamp(-64.0, 64.0) } else { 0.0 }
|
||||
}
|
||||
/// How many pixels the mask can move: the farther of the two integer radii a fractional
|
||||
/// shift blends. Whole-pixel amounts stay exact (`ceil(4) == 4`).
|
||||
fn shift_px(&self) -> i32 {
|
||||
let px = self.shift_amount();
|
||||
let reach = px.abs().ceil();
|
||||
let signed = if px.is_sign_negative() { -reach } else { reach };
|
||||
if signed.is_finite() { signed as i32 } else { 0 }
|
||||
/// The factor Shift Edge multiplies each pixel's odds by: `49^(shift / 100)`, 1 at 0%.
|
||||
fn shift_odds(&self) -> f32 {
|
||||
let s = self.shift_edge.clamp(-100.0, 100.0) / 100.0;
|
||||
if s.is_finite() { 49f32.powf(s) } else { 1.0 }
|
||||
}
|
||||
fn uses_image(&self) -> bool {
|
||||
self.radius > 0.0
|
||||
@@ -88,14 +79,13 @@ impl RefineParams {
|
||||
} else {
|
||||
0
|
||||
};
|
||||
edge + (3.0 * self.smooth_sigma()).ceil() as i32 + (3.0 * self.feather_sigma()).ceil() as i32 + self.shift_px().abs() + 2
|
||||
edge + (3.0 * self.smooth_sigma()).ceil() as i32 + (3.0 * self.feather_sigma()).ceil() as i32 + 2
|
||||
}
|
||||
/// How far the refined mask can extend beyond the original.
|
||||
pub fn spread(&self) -> i32 {
|
||||
(if self.uses_image() { self.radius.ceil() as i32 } else { 0 })
|
||||
+ (3.0 * self.smooth_sigma()).ceil() as i32
|
||||
+ (3.0 * self.feather_sigma()).ceil() as i32
|
||||
+ self.shift_px().max(0)
|
||||
+ 1
|
||||
}
|
||||
}
|
||||
@@ -311,7 +301,7 @@ pub fn edge_width(img: &RgbImage, r: usize) -> Vec<f32> {
|
||||
|
||||
/// Refines a soft mask buffer `m` (`w × h`) with `img` as guide (required when `radius > 0`).
|
||||
pub fn refine_buffer(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams) -> Vec<f32> {
|
||||
refine_buffer_with(img, m, w, h, p, None, morph)
|
||||
refine_buffer_with(img, m, w, h, p, None)
|
||||
}
|
||||
|
||||
/// The Refine Edge Brush over one buffer: signed coverage (`w × h`, see [`EdgeBrush`]) and the
|
||||
@@ -322,10 +312,7 @@ struct BrushBuf<'a> {
|
||||
window: usize,
|
||||
}
|
||||
|
||||
fn refine_buffer_with<F>(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, brush: Option<BrushBuf>, morphology: F) -> Vec<f32>
|
||||
where
|
||||
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
|
||||
{
|
||||
fn refine_buffer_with(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, brush: Option<BrushBuf>) -> Vec<f32> {
|
||||
let mut a = m.to_vec();
|
||||
if let (true, Some(img)) = (p.uses_image(), img) {
|
||||
let bin: Vec<bool> = m.iter().map(|v| *v >= 0.5).collect();
|
||||
@@ -385,36 +372,23 @@ where
|
||||
*v = ((*v - 0.5) * gain + 0.5).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
let amount = p.shift_amount();
|
||||
if amount.abs() >= 1.0e-4 {
|
||||
a = shift_mask(&a, w, h, amount, morphology);
|
||||
let k = p.shift_odds();
|
||||
if (k - 1.0).abs() >= 1.0e-6 {
|
||||
for v in &mut a {
|
||||
*v = shift_coverage(*v, k);
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Grow or shrink `src` by a fractional pixel distance. Whole pixels use one morphology;
|
||||
/// the remainder blends that result with the next pixel, so 0.3 px is not the same step as 1 px.
|
||||
fn shift_mask<F>(src: &[f32], w: usize, h: usize, amount: f32, morphology: F) -> Vec<f32>
|
||||
where
|
||||
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
|
||||
{
|
||||
let mag = amount.abs();
|
||||
if !mag.is_finite() || mag < 1.0e-4 {
|
||||
return src.to_vec();
|
||||
/// Shift Edge on one coverage value: its odds times `k`. 0 and 1 stay exact.
|
||||
fn shift_coverage(v: f32, k: f32) -> f32 {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
if v <= 0.0 || v >= 1.0 {
|
||||
return v;
|
||||
}
|
||||
let grow = amount > 0.0;
|
||||
let whole = mag.floor() as usize;
|
||||
let frac = mag - whole as f32;
|
||||
if frac < 1.0e-4 {
|
||||
return morphology(src, w, h, whole, grow);
|
||||
}
|
||||
let lo = if whole == 0 { src.to_vec() } else { morphology(src, w, h, whole, grow) };
|
||||
let hi = morphology(src, w, h, whole + 1, grow);
|
||||
let n = w.saturating_mul(h);
|
||||
if lo.len() < n || hi.len() < n || lo.len() != hi.len() {
|
||||
return hi;
|
||||
}
|
||||
lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect()
|
||||
let shifted = k * v / (k * v + (1.0 - v));
|
||||
if shifted.is_finite() { shifted.clamp(0.0, 1.0) } else { v }
|
||||
}
|
||||
|
||||
/// Most pixels one refine should cover. Its result is a dense [`Region`] over [`refine_area`]
|
||||
@@ -437,7 +411,7 @@ pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option<
|
||||
/// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image
|
||||
/// (only read when `radius > 0`). Returns `None` when nothing remains selected.
|
||||
pub fn refine_mask(sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec<f32> + Sync), content: Rect, canvas: Rect, p: &RefineParams) -> Option<Region> {
|
||||
refine_mask_with(sampler, mask, content, canvas, p, None, morph)
|
||||
refine_mask_with(sampler, mask, content, canvas, p, None)
|
||||
}
|
||||
|
||||
/// [`refine_mask`] with the Refine Edge Brush: where `brush` adds, coverage comes from the image
|
||||
@@ -451,21 +425,17 @@ pub fn refine_mask_brushed(
|
||||
p: &RefineParams,
|
||||
brush: Option<&EdgeBrush>,
|
||||
) -> Option<Region> {
|
||||
refine_mask_with(sampler, mask, content, canvas, p, brush, morph)
|
||||
refine_mask_with(sampler, mask, content, canvas, p, brush)
|
||||
}
|
||||
|
||||
fn refine_mask_with<F>(
|
||||
fn refine_mask_with(
|
||||
sampler: &dyn Sampler,
|
||||
mask: &(dyn Fn(Rect) -> Vec<f32> + Sync),
|
||||
content: Rect,
|
||||
canvas: Rect,
|
||||
p: &RefineParams,
|
||||
brush: Option<&EdgeBrush>,
|
||||
morphology: F,
|
||||
) -> Option<Region>
|
||||
where
|
||||
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy + Sync + Send,
|
||||
{
|
||||
) -> Option<Region> {
|
||||
let bbox = refine_area(content, canvas, p, brush);
|
||||
if bbox.is_empty() {
|
||||
return None;
|
||||
@@ -505,7 +475,7 @@ where
|
||||
(Some(v), Some(b)) => Some(BrushBuf { v, window: b.window() }),
|
||||
_ => None,
|
||||
};
|
||||
let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb, morphology);
|
||||
let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb);
|
||||
(*t, core(&out))
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -769,24 +739,30 @@ mod tests {
|
||||
assert!(f[5 * w + 29] > 0.5 && f[5 * w + 29] < 0.8 && f[5 * w + 31] > 0.1);
|
||||
let c = refine_buffer(None, &f, w, h, &RefineParams { contrast: 100.0, ..Default::default() });
|
||||
assert!(c[5 * w + 29] == 1.0 && c[5 * w + 31] == 0.0);
|
||||
// A hard edge (no radius, feather or smooth) moves 1 px at 100%, so 50% is half a
|
||||
// pixel. The pixel just outside fades in instead of snapping on.
|
||||
let grown = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 100.0, ..Default::default() });
|
||||
assert_eq!(m[5 * w + 30], 0.0);
|
||||
assert_eq!(grown[5 * w + 30], 1.0);
|
||||
assert_eq!(grown[5 * w + 31], 0.0);
|
||||
let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -100.0, ..Default::default() });
|
||||
assert_eq!(shrunk[5 * w + 28], 1.0);
|
||||
assert_eq!(shrunk[5 * w + 29], 0.0);
|
||||
let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 50.0, ..Default::default() });
|
||||
assert!((half[5 * w + 30] - 0.5).abs() < 0.02, "half pixel {}", half[5 * w + 30]);
|
||||
assert_eq!(half[5 * w + 31], 0.0, "the blend must not jump a second pixel");
|
||||
let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() });
|
||||
assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]);
|
||||
// A softer edge moves farther: 100% is the feather, here 4 px.
|
||||
let wide = refine_buffer(None, &m, w, h, &RefineParams { feather: 4.0, contrast: 100.0, shift_edge: 100.0, ..Default::default() });
|
||||
assert_eq!(wide[5 * w + 33], 1.0);
|
||||
assert_eq!(wide[5 * w + 34], 0.0);
|
||||
// Shift Edge leaves a hard edge where it is, at any amount: only soft coverage moves.
|
||||
for shift_edge in [-100.0, -50.0, 50.0, 100.0] {
|
||||
let s = refine_buffer(None, &m, w, h, &RefineParams { shift_edge, ..Default::default() });
|
||||
assert_eq!(s, m, "Shift Edge {shift_edge}% moved a hard edge");
|
||||
}
|
||||
// Coverage before → after, read off the reference application's feathered edges.
|
||||
for (shift_edge, before, after) in [
|
||||
(-100.0, 0.898, 0.153),
|
||||
(-100.0, 0.612, 0.031),
|
||||
(-100.0, 0.965, 0.357),
|
||||
(-50.0, 0.537, 0.141),
|
||||
(-50.0, 0.898, 0.557),
|
||||
(100.0, 0.102, 0.847),
|
||||
(100.0, 0.388, 0.969),
|
||||
] {
|
||||
let flat = vec![before; w * h];
|
||||
let s = refine_buffer(None, &flat, w, h, &RefineParams { shift_edge, ..Default::default() });
|
||||
assert!((s[5 * w + 30] - after).abs() < 0.01, "{shift_edge}%: {before} became {}, expected {after}", s[5 * w + 30]);
|
||||
}
|
||||
// So a feathered edge moves: at −100% its half-covered point lands where it was 98%.
|
||||
let soft = refine_buffer(None, &m, w, h, &RefineParams { feather: 6.0, ..Default::default() });
|
||||
let pulled = refine_buffer(None, &m, w, h, &RefineParams { feather: 6.0, shift_edge: -100.0, ..Default::default() });
|
||||
let half = |v: &[f32]| (0..w).find(|x| v[5 * w + x] < 0.5).unwrap_or(w);
|
||||
assert!(half(&pulled) < half(&soft), "−100% did not pull the soft edge in");
|
||||
let sm = refine_buffer(None, &m, w, h, &RefineParams { smooth: 50.0, ..Default::default() });
|
||||
assert!(sm[5 * w + 10] == 1.0 && sm[5 * w + 50] == 0.0);
|
||||
}
|
||||
|
||||
@@ -230,65 +230,6 @@ fn octagonal_morphology_release_comparison() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "release comparison of the full tiled Select and Mask CPU path"]
|
||||
#[allow(clippy::assertions_on_constants)]
|
||||
fn tiled_refinement_release_comparison() {
|
||||
use crate::{
|
||||
matting::{RefineParams, refine_mask_with, region_reader},
|
||||
segment::{ImageSampler, RgbImage, trim_region},
|
||||
selection::Region,
|
||||
};
|
||||
use photocraft_geom::Rect;
|
||||
use std::{hint::black_box, time::Instant};
|
||||
assert!(!cfg!(debug_assertions), "run with --release");
|
||||
for (w, h) in [(6000usize, 4000usize), (7200, 5000)] {
|
||||
let image = RgbImage::from_fn(w, h, |x, y| {
|
||||
let t = x as f32 / w as f32;
|
||||
let u = y as f32 / h as f32;
|
||||
[0.2 + 0.5 * t, 0.15 + 0.55 * u, 0.3 + 0.15 * ((x / 17 + y / 23) % 3) as f32]
|
||||
});
|
||||
let sampler = ImageSampler { img: &image, origin: (0, 0) };
|
||||
let canvas = Rect::new(0, 0, w as i32, h as i32);
|
||||
let mask: Vec<_> = (0..w * h)
|
||||
.map(|i| {
|
||||
let dx = (i % w) as f64 - w as f64 * 0.51;
|
||||
let dy = (i / w) as f64 - h as f64 * 0.48;
|
||||
let d = (dx * dx + dy * dy).sqrt();
|
||||
((0.5 + (w.min(h) as f64 * 0.27 - d) / 7.0).clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
})
|
||||
.collect();
|
||||
let region = trim_region(Region { bbox: canvas, mask }).unwrap();
|
||||
let reader = region_reader(®ion);
|
||||
let p = RefineParams { radius: 64.0, shift_edge: 100.0, ..Default::default() };
|
||||
let old = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, repeated).unwrap();
|
||||
let new = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, morph).unwrap();
|
||||
assert_eq!(new.bbox, old.bbox);
|
||||
assert_eq!(new.mask, old.mask);
|
||||
let (mut old_ms, mut new_ms) = (Vec::new(), Vec::new());
|
||||
for run in 0..3 {
|
||||
let mut measure = |fast: bool| {
|
||||
let start = Instant::now();
|
||||
let out = if fast {
|
||||
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, morph)
|
||||
} else {
|
||||
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, repeated)
|
||||
}
|
||||
.unwrap();
|
||||
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
|
||||
assert_eq!(out.bbox, old.bbox);
|
||||
assert_eq!(out.mask, old.mask);
|
||||
if fast { new_ms.push(elapsed) } else { old_ms.push(elapsed) }
|
||||
};
|
||||
measure(run % 2 == 0);
|
||||
measure(run % 2 != 0);
|
||||
}
|
||||
old_ms.sort_by(f64::total_cmp);
|
||||
new_ms.sort_by(f64::total_cmp);
|
||||
println!("REFINE {w}x{h} radius=64 shift=100 old_ms={old_ms:?} new_ms={new_ms:?} speedup={:.2}", old_ms[1] / new_ms[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "release comparison on a dense fractional 24 MP matte"]
|
||||
#[allow(clippy::assertions_on_constants)]
|
||||
|
||||
@@ -286,7 +286,7 @@ fn sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) {
|
||||
}
|
||||
|
||||
/// Median cut over `pts` (feature vectors + weights) into at most `n` boxes; returns box means.
|
||||
pub(crate) fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
|
||||
fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
|
||||
if pts.is_empty() || n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -334,7 +334,7 @@ pub(crate) fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
|
||||
}
|
||||
|
||||
/// Lloyd refinement of `centers` over weighted points.
|
||||
pub(crate) fn kmeans(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) {
|
||||
fn kmeans(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) {
|
||||
if centers.is_empty() {
|
||||
// No room left (the forced colours fill the palette): nothing to refine.
|
||||
return;
|
||||
|
||||
@@ -41,9 +41,9 @@ mod paint;
|
||||
pub use bevel::{BEVEL_H_EPS, BevelGeom, BevelPaint, TextureCtx, bevel_chisel_h, bevel_geom, satin_distance};
|
||||
#[cfg(test)]
|
||||
use bevel::{bevel_maps, satin_map};
|
||||
pub use blur::tent_kernel;
|
||||
#[cfg(test)]
|
||||
use blur::{blur, box_line, dilate, tent, tent_direct};
|
||||
pub use blur::{softer_glow_tail_amplitude, softer_glow_tail_width, tent_kernel};
|
||||
pub(crate) use composite::{VectorStroke, build_maps_prepared, composite_with_effects_prepared, map_count};
|
||||
pub use composite::{build_maps, composite_with_effects, outline_share, premerges_layer, shadow_see_through, stroke_widths};
|
||||
pub use distance::{FieldKind, STROKE_CHAMFER_MAX, bevel_distance_fields, distance_field, stroke_outside_exact};
|
||||
@@ -89,14 +89,7 @@ pub fn margin(layer: &Layer) -> i32 {
|
||||
for e in layer.effects.items.iter().filter(|e| e.enabled()) {
|
||||
let r = match e {
|
||||
Effect::DropShadow(s) | Effect::InnerShadow(s) => s.distance + s.size,
|
||||
Effect::OuterGlow(g) => {
|
||||
if g.technique == GlowTechnique::Softer {
|
||||
g.size + softer_glow_tail_width(g.size)
|
||||
} else {
|
||||
g.size
|
||||
}
|
||||
}
|
||||
Effect::InnerGlow(g) => g.size,
|
||||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.size,
|
||||
Effect::Stroke(s) => s.size,
|
||||
Effect::Satin(s) => satin_distance(s) + s.size,
|
||||
Effect::BevelEmboss(b) => b.size + b.soften,
|
||||
|
||||
@@ -210,14 +210,3 @@ fn tent_fast(m: &mut Map, w: f32) {
|
||||
rows_par(&t, &mut b, mh, twice);
|
||||
m.v = transpose_map(&b, mh, mw);
|
||||
}
|
||||
|
||||
/// Raw coverage at the Size boundary, scaled through the glow's Range to a subtle one-percent tail.
|
||||
pub fn softer_glow_tail_amplitude(range: f32) -> f32 {
|
||||
let range = if range.is_finite() { range.clamp(0.01, 1.0) } else { 1.0 };
|
||||
0.005 * range
|
||||
}
|
||||
|
||||
/// Width of the added fading tail on a Softer outer glow, capped so large settings stay local.
|
||||
pub fn softer_glow_tail_width(size: f32) -> f32 {
|
||||
if size.is_finite() && size > 0.0 { (size * 0.1).clamp(1.0, 6.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::blur::{blur, dilate, softer_glow_tail_amplitude, softer_glow_tail_width};
|
||||
use super::blur::{blur, dilate};
|
||||
use super::distance::{dist_inside, dist_outside};
|
||||
use super::{
|
||||
BlendMode, Buffer, Contour, Effect, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Map, Placement, PreparedPatterns, Rect,
|
||||
@@ -303,9 +303,6 @@ pub(super) fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -
|
||||
let (r, bw) = spread_split(g.size, g.spread);
|
||||
let mut m = dilate(&src, r);
|
||||
blur(&mut m, bw);
|
||||
if !inner {
|
||||
add_softer_glow_tail(&mut m, &src, g.size, g.range);
|
||||
}
|
||||
m
|
||||
}
|
||||
};
|
||||
@@ -405,25 +402,3 @@ pub(super) fn paint_glow(dst: &mut Buffer, m: &Map, g: &Glow, shape_bounds: Rect
|
||||
let strength = Map { w: m.w, h: m.h, v: m.v.iter().map(|v| (v * gain).min(1.0)).collect() };
|
||||
paint(dst, &strength, |i| prepared.sample(1.0 - m.v.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0)), g.common.blend, g.common.opacity);
|
||||
}
|
||||
|
||||
fn add_softer_glow_tail(map: &mut Map, shape: &Map, size: f32, range: f32) {
|
||||
let width = softer_glow_tail_width(size);
|
||||
if width == 0.0 {
|
||||
return;
|
||||
}
|
||||
let amp = softer_glow_tail_amplitude(range);
|
||||
let distance = dist_outside(shape);
|
||||
for (coverage, distance) in map.v.iter_mut().zip(distance) {
|
||||
let t = (distance + 0.5 - (size - width)) / width;
|
||||
let tail = if (0.0..=1.0).contains(&t) {
|
||||
amp * t * t * (3.0 - 2.0 * t)
|
||||
} else if (1.0..=2.0).contains(&t) {
|
||||
let u = t - 1.0;
|
||||
let remaining = 1.0 - u;
|
||||
amp * remaining * remaining * (1.0 + 2.0 * u)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
*coverage = coverage.max(tail);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,56 +858,6 @@ fn glows_match_photoshop() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn softer_outer_glow_keeps_a_fading_tail_past_its_size() {
|
||||
let mut shape = Map::new(220, 220, 0.0);
|
||||
for y in 0..shape.h {
|
||||
for x in 20..100 {
|
||||
shape.v[y * shape.w + x] = 1.0;
|
||||
}
|
||||
}
|
||||
let glow = Glow {
|
||||
common: photocraft_doc::FxCommon::new(BlendMode::Screen, 1.0),
|
||||
paint: FxPaint::Color(photocraft_color::Color::WHITE),
|
||||
technique: GlowTechnique::Softer,
|
||||
spread: 0.0,
|
||||
size: 60.0,
|
||||
contour: Contour::Linear,
|
||||
anti_alias: false,
|
||||
range: 0.5,
|
||||
jitter: 0.0,
|
||||
noise: 0.0,
|
||||
source: GlowSource::Edge,
|
||||
};
|
||||
let m = glow_map(&shape, &glow, false, (0, 0));
|
||||
let row = shape.h / 2;
|
||||
let at = |distance: usize| m.v[row * m.w + 100 + distance];
|
||||
assert!(at(50) > 0.024, "the softer shoulder should stay visible: {}", at(50));
|
||||
assert!(at(60) > 1.0 / 255.0, "the glow must retain a visible tail at its Size: {}", at(60));
|
||||
assert!(at(62) > 0.5 / 255.0, "the glow must extend beyond its Size: {}", at(62));
|
||||
assert!(at(66) < 1e-6, "the added tail should fade to zero: {}", at(66));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn softer_outer_glow_margin_includes_its_tail_support() {
|
||||
let glow = Glow {
|
||||
common: photocraft_doc::FxCommon::new(BlendMode::Screen, 1.0),
|
||||
paint: FxPaint::Color(photocraft_color::Color::WHITE),
|
||||
technique: GlowTechnique::Softer,
|
||||
spread: 0.0,
|
||||
size: 60.0,
|
||||
contour: Contour::Linear,
|
||||
anti_alias: false,
|
||||
range: 0.5,
|
||||
jitter: 0.0,
|
||||
noise: 0.0,
|
||||
source: GlowSource::Edge,
|
||||
};
|
||||
let mut layer = Layer::raster("glow", photocraft_color::PixelFormat::RGBA8);
|
||||
layer.effects.items.push(Effect::OuterGlow(glow));
|
||||
assert_eq!(margin(&layer), 68);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_follow_light() {
|
||||
let (dx, dy) = offset(120.0, 10.0);
|
||||
|
||||
@@ -4602,3 +4602,42 @@ fn adjustment_blend_if_judges_the_result_before_opacity() {
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1), "black {black} white {white}: {got:?} vs Photoshop {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference application, measured: a 240 × 40 black document, an opaque white layer over
|
||||
/// its left 100 px (full height), Outer Glow Softer, white, Normal, 100 %, Spread 0, Noise 0,
|
||||
/// Size 60, Range 50 % and 100 % alike. Sampled at row 20, `d` px right of the edge, the glow
|
||||
/// reads 4 at 50 px, 1 at 54 and 0 from 56 to 70: it fades out just before Size, with no tail
|
||||
/// beyond it (#8039 added one; reverted). On black, the red value × 255 is the glow alpha × 255,
|
||||
/// compared as 8-bit levels. The tent gives 4, 1, 0, 0, 0 at Range 50 % and 2, 1, 0, 0, 0 at 100 %.
|
||||
#[test]
|
||||
fn softer_outer_glow_fades_out_before_its_size() {
|
||||
let mut d = Document::with_background("t", Size::new(240, 40), ColorMode::Rgb, SampleType::U8, Color::BLACK);
|
||||
for range in [0.5, 1.0] {
|
||||
let mut l = solid_layer("shape", Rect::new(0, 0, 100, 40), [1.0; 4]);
|
||||
l.effects.items = vec![Effect::OuterGlow(Glow {
|
||||
common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
|
||||
paint: FxPaint::Color(Color::WHITE),
|
||||
technique: GlowTechnique::Softer,
|
||||
spread: 0.0,
|
||||
size: 60.0,
|
||||
contour: photocraft_doc::Contour::Linear,
|
||||
anti_alias: false,
|
||||
range,
|
||||
jitter: 0.0,
|
||||
noise: 0.0,
|
||||
source: GlowSource::Edge,
|
||||
})];
|
||||
d.layers.truncate(1);
|
||||
d.layers.push(l);
|
||||
let got: Vec<(i32, f32)> = [50, 54, 56, 60, 70].iter().map(|&dist| (dist, (255.0 * px(&d, 100 + dist, 20)[0]).round())).collect();
|
||||
for &(dist, v) in &got {
|
||||
let want = match dist {
|
||||
50 => 4.0,
|
||||
54 => 1.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
let tolerance = if dist >= 56 { 0.0 } else { 2.0 };
|
||||
assert!((v - want).abs() <= tolerance, "range {range}, {dist} px: {v} vs the reference application's {want} ({got:?})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,46 @@ pub(crate) fn set_resolution(doc: &mut Document, dpi: f32) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Moves the type of `doc` (scratch copies on their way to another document) to `dpi` keeping its
|
||||
/// pixels, as the reference application does when a type layer is copied between documents of
|
||||
/// different resolutions: every size in points is rewritten by old / new (24 pt at 72 ppi is
|
||||
/// 5.76 pt at 300 ppi), so `pt x dpi / 72` pixels stay the same and the layer gets no
|
||||
/// compensating transform. Box, path and transform are in pixels and stay; tracking and kerning
|
||||
/// (1/1000 em) and auto leading (a multiple of the size) follow the size.
|
||||
pub(crate) fn retarget_type_resolution(doc: &mut Document, dpi: f32) {
|
||||
// The text engine lays out a resolution that isn't a positive number at 72 ppi.
|
||||
let effective = |d: f32| if d.is_finite() && d > 0.0 { f64::from(d) } else { 72.0 };
|
||||
let old = std::mem::replace(&mut doc.resolution_dpi, dpi);
|
||||
let k = effective(old) / effective(dpi);
|
||||
if !(k.is_finite() && k > 0.0) || k == 1.0 {
|
||||
return;
|
||||
}
|
||||
crate::image_cmds::for_each_layer(&mut doc.layers, &mut |l| {
|
||||
let LayerContent::Text(t) = &mut l.content else { return };
|
||||
t.size_pt = point_size(t.size_pt, k);
|
||||
for r in &mut t.runs {
|
||||
let st = &mut r.style;
|
||||
st.size_pt = point_size(st.size_pt, k);
|
||||
st.leading_pt = st.leading_pt.map(|v| point_size(v, k));
|
||||
st.baseline_shift_pt = grown(st.baseline_shift_pt, k);
|
||||
}
|
||||
for p in &mut t.paragraphs {
|
||||
let st = &mut p.style;
|
||||
for v in [&mut st.first_line_indent_pt, &mut st.start_indent_pt, &mut st.end_indent_pt, &mut st.space_before_pt, &mut st.space_after_pt] {
|
||||
*v = grown(*v, k);
|
||||
}
|
||||
}
|
||||
retag_text(dpi, t);
|
||||
});
|
||||
}
|
||||
|
||||
/// A positive size in points times `k`, kept finite and above zero (a tiny ratio must not make
|
||||
/// the text vanish).
|
||||
fn point_size(v: f32, k: f64) -> f32 {
|
||||
let out = grown(v, k);
|
||||
if v > 0.0 && out <= 0.0 { f32::MIN_POSITIVE } else { out }
|
||||
}
|
||||
|
||||
/// `v x k` kept finite (a huge box from a file must not become infinite).
|
||||
fn grown(v: f32, k: f64) -> f32 {
|
||||
(f64::from(v) * k).clamp(-f64::from(f32::MAX), f64::from(f32::MAX)) as f32
|
||||
|
||||
@@ -381,7 +381,7 @@ fn perspective(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
const L: &str = r##"{"strokes":[{"tool":"forwardWarp|reconstruct|smooth|twirlCw|twirlCcw|pucker|bloat|pushLeft|freeze|thaw|lassoMask|reconstructAll","size":px=100,"density":0-100=50,"pressure":0-100=100,"rate":0-100=80,"points":[[x,y,pressure?]…],"amount":%? (reconstructAll; lassoMask: 1 freezes the polygon in points, 0 thaws it)}…],"meshSize":px? (field resolution, px per node; default 2, or 4 above 4 MP),"layer":id?} — strokes replay in order on a fresh field; a selection limits the effect; on a smart object it becomes a smart filter"##;
|
||||
const L: &str = r##"{"strokes":[{"tool":"forwardWarp|reconstruct|smooth|twirlCw|twirlCcw|pucker|bloat|pushLeft|freeze|thaw|lassoMask|reconstructAll","size":px=100,"density":0-100=50,"pressure":0-100=100,"rate":0-100=80,"points":[[x,y,pressure?]…],"amount":%? (twirlCcw: twirlCw reversed, what an Alt-drag with Twirl Clockwise records; reconstructAll; lassoMask: 1 freezes the polygon in points, 0 thaws it)}…],"meshSize":px? (field resolution, px per node; default 2, or 4 above 4 MP),"layer":id?} — strokes replay in order on a fresh field; a selection limits the effect; on a smart object it becomes a smart filter"##;
|
||||
const P: &str = r##"{"pins":[{"src":[x,y],"dst":[x,y],"rotate":deg?,"depth":n?}…],"mode":"rigid|normal|distort"="normal","density":"fewer|normal|more"="normal","expansion":px=2,"interpolation":"bicubic|bilinear|nearest","layer":id?} — mesh over the opaque region, as-rigid-as-possible; on a smart object it becomes a smart filter"##;
|
||||
const Q: &str = r##"{"planes":[{"src":[[x,y]×4],"dst":[[x,y]×4]}…] (corners clockwise from top-left; corners that coincide in src are linked),"straighten":"horizontal|vertical|auto"?,"interpolation":"bicubic|bilinear|nearest","layer":id?}"##;
|
||||
vec![
|
||||
|
||||
@@ -118,10 +118,10 @@ pub(crate) fn adapt_copies(s: &Session, copies: &mut Document, dest: &Document)
|
||||
*surf = surf.convert(f);
|
||||
});
|
||||
}
|
||||
// Type keeps its size in pixels at the destination's resolution, as an Image Size resolution
|
||||
// change keeps it (Photoshop adapts the point size: 24 pt at 72 ppi is 5.76 pt at 300 ppi).
|
||||
// Left at the source's, a copy re-laid out at the destination's on its next edit (#9560).
|
||||
crate::canvas_geom::set_resolution(copies, dest.resolution_dpi);
|
||||
// Type keeps its pixels and takes the destination's point size, as in the reference
|
||||
// application (24 pt at 72 ppi becomes 5.76 pt at 300 ppi, with no transform). Left at the
|
||||
// source's, a copy re-laid out at the destination's resolution on its next edit (#9560).
|
||||
crate::canvas_geom::retarget_type_resolution(copies, dest.resolution_dpi);
|
||||
let new_canvas = copies.bounds() != dest.bounds();
|
||||
prepare_shapes(&mut copies.layers, dest, new_canvas);
|
||||
Ok(())
|
||||
|
||||
@@ -370,25 +370,133 @@ fn a_copied_group_cannot_nest_the_destination_past_the_group_depth_limit() {
|
||||
assert_eq!(*doc(&s, 1), before, "nothing was copied");
|
||||
}
|
||||
|
||||
/// #9560: a type layer copied between documents of different resolutions keeps its size in
|
||||
/// pixels, also after its next text edit (it used to re-lay out at the destination's resolution:
|
||||
/// a 24 pt line from 72 ppi grew about 4.2 times in a 300 ppi document). Photoshop adapts the
|
||||
/// point size instead (24 pt at 72 ppi is 5.76 pt at 300 ppi); the pixels are the same.
|
||||
#[test]
|
||||
fn copied_type_keeps_its_pixel_size_across_resolutions_after_an_edit() {
|
||||
for (from, to) in [(72, 300), (300, 72)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": from})).unwrap();
|
||||
let t = LayerId(s.execute("type.create", json!({"text": "H", "size": 24, "x": 128, "y": 100})).unwrap()["layer"].as_u64().unwrap());
|
||||
let original = bounds(&s, 0, t);
|
||||
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": to})).unwrap();
|
||||
s.set_active(0);
|
||||
s.execute("layer.select", json!({"layer": t.0})).unwrap();
|
||||
let id = copied(&s.execute("layer.copyToDocument", json!({"document": 1})).unwrap());
|
||||
assert_eq!(bounds(&s, 1, id).size(), original.size(), "{from}→{to}: the copy");
|
||||
s.execute("type.edit", json!({"layer": id.0, "text": "M"})).unwrap();
|
||||
s.execute("type.edit", json!({"layer": id.0, "text": "H"})).unwrap();
|
||||
let after = bounds(&s, 1, id);
|
||||
assert_eq!(after.size(), original.size(), "{from}→{to}: after an edit, {after:?} vs {original:?}");
|
||||
/// The type layer `id` of document `i`.
|
||||
fn text(s: &Session, i: usize, id: LayerId) -> &photocraft_doc::TextLayer {
|
||||
match &doc(s, i).layer(id).unwrap().content {
|
||||
LayerContent::Text(t) => t,
|
||||
other => panic!("not type: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds and summed alpha of a layer's rendered pixels.
|
||||
fn ink(s: &Session, i: usize, id: LayerId) -> (Rect, f64) {
|
||||
let surf = doc(s, i).layer(id).unwrap().surface().unwrap();
|
||||
let b = surf.content_bounds();
|
||||
let a = surf.read_region(b).chunks(surf.channels()).map(|px| f64::from(*px.last().unwrap())).sum();
|
||||
(b, a)
|
||||
}
|
||||
|
||||
/// Document 0 at `from` ppi with a 24 pt "H" (leading 30 pt, baseline shift 2 pt, first-line
|
||||
/// indent 6 pt) and document 1 at `to` ppi; the type layer is selected in document 0.
|
||||
fn type_docs(from: u32, to: u32) -> (Session, LayerId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": from})).unwrap();
|
||||
let t = LayerId(
|
||||
s.execute("type.create", json!({"text": "H", "size": 24, "leading": 30, "baselineShift": 2, "x": 128, "y": 100})).unwrap()["layer"].as_u64().unwrap(),
|
||||
);
|
||||
s.execute("type.setStyle", json!({"layer": t.0, "firstLineIndent": 6})).unwrap();
|
||||
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": to})).unwrap();
|
||||
s.set_active(0);
|
||||
s.execute("layer.select", json!({"layer": t.0})).unwrap();
|
||||
(s, t)
|
||||
}
|
||||
|
||||
fn close(a: f32, b: f32) -> bool {
|
||||
(a - b).abs() <= 1e-4 * b.abs().max(1.0)
|
||||
}
|
||||
|
||||
/// Checks a copy of `src` (document 0) as `dst` (document 1) at `pt` points (the reference
|
||||
/// application's rewritten size for 24 pt), then that an edit keeps that size and the pixels.
|
||||
fn check_rewritten(s: &mut Session, src: LayerId, dst: LayerId, pt: f32, label: &str) {
|
||||
let k = pt / 24.0;
|
||||
let (from, to) = (text(s, 0, src), text(s, 1, dst));
|
||||
assert_eq!(to.transform, from.transform, "{label}: no compensating transform");
|
||||
assert!(close(to.size_pt, pt), "{label}: {} pt", to.size_pt);
|
||||
let run = &to.runs[0].style;
|
||||
assert!(close(run.size_pt, pt), "{label}: run {} pt", run.size_pt);
|
||||
assert!(close(run.leading_pt.unwrap(), 30.0 * k), "{label}: leading {:?}", run.leading_pt);
|
||||
assert!(close(run.baseline_shift_pt, 2.0 * k), "{label}: baseline shift {}", run.baseline_shift_pt);
|
||||
assert_eq!(run.tracking, from.runs[0].style.tracking, "{label}: tracking is in 1/1000 em");
|
||||
assert!(close(to.paragraphs[0].style.first_line_indent_pt, 6.0 * k), "{label}: indent {}", to.paragraphs[0].style.first_line_indent_pt);
|
||||
let (b0, a0) = ink(s, 0, src);
|
||||
assert_eq!(ink(s, 1, dst).0, b0, "{label}: the copy's pixels");
|
||||
// Re-laid out at the destination's resolution, the copy renders as the source does.
|
||||
s.execute("type.edit", json!({"layer": dst.0, "text": "M"})).unwrap();
|
||||
s.execute("type.edit", json!({"layer": dst.0, "text": "H"})).unwrap();
|
||||
assert!(close(text(s, 1, dst).size_pt, pt), "{label}: an edit keeps {} pt", text(s, 1, dst).size_pt);
|
||||
let (b1, a1) = ink(s, 1, dst);
|
||||
assert_eq!(b1, b0, "{label}: after an edit, {b1:?} vs {b0:?}");
|
||||
assert!((a1 - a0).abs() <= a0 * 0.005, "{label}: coverage {a1} vs {a0}");
|
||||
}
|
||||
|
||||
/// #9560: a type layer copied between documents of different resolutions keeps its pixels and
|
||||
/// takes the destination's point size, as in the reference application (measured: 24 pt from
|
||||
/// 72 ppi is 5.76 pt at 300 ppi, from 300 ppi 100 pt at 72 ppi, with no transform; later edits
|
||||
/// keep that size). It used to keep 24 pt and re-lay out about 4.2 times larger or smaller on
|
||||
/// its next edit, and then kept 24 pt behind a compensating transform.
|
||||
#[test]
|
||||
fn copied_type_takes_the_destinations_point_size_and_keeps_its_pixels() {
|
||||
for (from, to, pt) in [(72, 300, 5.76), (300, 72, 100.0)] {
|
||||
let (mut s, t) = type_docs(from, to);
|
||||
let id = copied(&s.execute("layer.copyToDocument", json!({"document": 1})).unwrap());
|
||||
check_rewritten(&mut s, t, id, pt, &format!("copy {from}→{to}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The same through Copy and Paste in Place of the whole type layer.
|
||||
#[test]
|
||||
fn pasted_type_takes_the_destinations_point_size_and_keeps_its_pixels() {
|
||||
for (from, to, pt) in [(72, 300, 5.76), (300, 72, 100.0)] {
|
||||
let (mut s, t) = type_docs(from, to);
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.set_active(1);
|
||||
let id = LayerId(s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
check_rewritten(&mut s, t, id, pt, &format!("paste {from}→{to}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph text keeps its box (in pixels) and its wrapping: only the point sizes change.
|
||||
#[test]
|
||||
fn copied_paragraph_type_keeps_its_box() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 72})).unwrap();
|
||||
let t =
|
||||
LayerId(s.execute("type.create", json!({"text": "Hello wrapping world", "size": 24, "box": [20, 20, 120, 150]})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 300})).unwrap();
|
||||
s.set_active(0);
|
||||
s.execute("layer.select", json!({"layer": t.0})).unwrap();
|
||||
let id = copied(&s.execute("layer.copyToDocument", json!({"document": 1})).unwrap());
|
||||
assert_eq!(text(&s, 1, id).shape, text(&s, 0, t).shape);
|
||||
assert!(close(text(&s, 1, id).size_pt, 5.76));
|
||||
let (b0, _) = ink(&s, 0, t);
|
||||
s.execute("type.edit", json!({"layer": id.0, "text": "Hello wrapping world"})).unwrap();
|
||||
assert_eq!(ink(&s, 1, id).0, b0, "the same lines after a re-layout");
|
||||
}
|
||||
|
||||
/// Equal resolutions change nothing; one that isn't a positive number lays out at 72 ppi, so it
|
||||
/// is treated as 72 rather than producing an infinite or zero size.
|
||||
#[test]
|
||||
fn retargeting_type_survives_bad_resolutions() {
|
||||
let mut d = Document::new("", photocraft_geom::Size::new(10, 10), ColorMode::Rgb, SampleType::U8);
|
||||
d.layers.push(photocraft_doc::Layer::new("t", LayerContent::Text(photocraft_doc::TextLayer { text: "H".into(), size_pt: 24.0, ..Default::default() })));
|
||||
let size = |d: &Document| match &d.layers[0].content {
|
||||
LayerContent::Text(t) => t.size_pt,
|
||||
_ => panic!("not type"),
|
||||
};
|
||||
d.resolution_dpi = 72.0;
|
||||
crate::canvas_geom::retarget_type_resolution(&mut d, 72.0);
|
||||
assert_eq!(size(&d), 24.0);
|
||||
for bad in [0.0, -5.0, f32::NAN, f32::INFINITY] {
|
||||
crate::canvas_geom::retarget_type_resolution(&mut d, bad);
|
||||
assert_eq!(size(&d), 24.0, "{bad}");
|
||||
d.resolution_dpi = bad;
|
||||
crate::canvas_geom::retarget_type_resolution(&mut d, 72.0);
|
||||
assert_eq!(size(&d), 24.0, "from {bad}");
|
||||
}
|
||||
// Absurd ratios stay finite and positive.
|
||||
d.resolution_dpi = f32::MAX;
|
||||
crate::canvas_geom::retarget_type_resolution(&mut d, f32::MIN_POSITIVE);
|
||||
assert!(size(&d).is_finite() && size(&d) > 0.0, "{}", size(&d));
|
||||
crate::canvas_geom::retarget_type_resolution(&mut d, f32::MAX);
|
||||
assert!(size(&d).is_finite() && size(&d) > 0.0, "{}", size(&d));
|
||||
}
|
||||
|
||||
@@ -451,9 +451,15 @@ fn refine_edge_outputs_to_a_new_document() {
|
||||
#[test]
|
||||
fn refine_edge_shift_and_contrast() {
|
||||
let mut s = blurred_edge_selected();
|
||||
// A hard selection moves 1 px at 100%: no band of several pixels.
|
||||
s.execute("select.refineEdge", json!({"shiftEdge": 100})).unwrap();
|
||||
assert!(cov(&s, 40, 20) > 0.99 && cov(&s, 41, 20) < 0.01);
|
||||
// Shift Edge leaves a hard selection where it is (selected x < 40), at either end.
|
||||
for shift in [100, -100] {
|
||||
s.execute("select.refineEdge", json!({"shiftEdge": shift})).unwrap();
|
||||
assert!(cov(&s, 39, 20) > 0.99 && cov(&s, 40, 20) < 0.01, "Shift Edge {shift}% moved a hard edge");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
// A feathered one moves: -100% pulls its half-covered point inside the old edge.
|
||||
s.execute("select.refineEdge", json!({"feather": 4, "shiftEdge": -100})).unwrap();
|
||||
assert!(cov(&s, 37, 20) < 0.5 && cov(&s, 30, 20) > 0.99);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("select.refineEdge", json!({"feather": 8, "contrast": 100})).unwrap();
|
||||
assert!(cov(&s, 38, 20) == 1.0 && cov(&s, 42, 20) == 0.0);
|
||||
@@ -661,8 +667,9 @@ fn refine_edge_edits_match_the_tools_and_reach_the_outputs() {
|
||||
// Refinement runs on the edited selection: Shift Edge contracts the brushed bar too.
|
||||
let mut s = two_regions(8);
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("select.refineEdge", json!({"shiftEdge": -100, "edits": [{"tool": "brush", "points": [[30, 80], [100, 80]], "size": 20}]})).unwrap();
|
||||
assert!(cov(&s, 60, 80) >= 0.5 && cov(&s, 60, 89) < 0.5 && cov(&s, 60, 70) < 0.5, "the hard 20 px bar shrank by one pixel at -100% Shift Edge");
|
||||
s.execute("select.refineEdge", json!({"feather": 4, "shiftEdge": -100, "edits": [{"tool": "brush", "points": [[30, 80], [100, 80]], "size": 20}]}))
|
||||
.unwrap();
|
||||
assert!(cov(&s, 60, 80) >= 0.5 && cov(&s, 60, 87) < 0.5 && cov(&s, 60, 72) < 0.5, "the feathered 20 px bar did not shrink at -100% Shift Edge");
|
||||
// Layer mask and new-layer outputs take the edited selection.
|
||||
let mut s = blurred_edge_selected();
|
||||
let r = s.execute("select.refineEdge", json!({"output": "layerMask", "edits": [{"tool": "invert"}]})).unwrap();
|
||||
|
||||
@@ -174,3 +174,19 @@ fn merge_visible_separates_through_the_document_profile() {
|
||||
assert!(d <= ROUND_TRIP, "{depth}-bit Merge Visible: inks moved by {d}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterize Layer Style bakes the layer through the RGB composite and separates the result
|
||||
/// back: through the document's profile, the one the layer was read with (#3778), so the inks
|
||||
/// under the effect-free area come back within the profile's round trip. The drop shadow falls
|
||||
/// off the canvas, so every pixel checked is the layer's own.
|
||||
#[test]
|
||||
fn rasterize_layer_style_separates_through_the_document_profile() {
|
||||
for depth in [8, 16] {
|
||||
let d = drift(depth, true, 0, |s| {
|
||||
s.execute("layer.new.layerFromBackground", json!({})).unwrap();
|
||||
s.execute("layer.layerStyle.dropShadow", json!({"distance": 40, "size": 0})).unwrap();
|
||||
s.execute("layer.rasterize.layerStyle", json!({})).unwrap();
|
||||
});
|
||||
assert!(d <= ROUND_TRIP, "{depth}-bit Rasterize Layer Style: inks moved by {d}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1449,18 +1449,6 @@ fn fs_mblur(in: VOut) -> @location(0) vec4<f32> {
|
||||
@fragment
|
||||
fn fs_mglow(in: VOut) -> @location(0) vec4<f32> {
|
||||
let d = textureLoad(tex_a, local(in.pos), 0).r + 0.5;
|
||||
if (op.p0.w > 0.5) {
|
||||
let t = (d - op.p0.x) / op.p0.y;
|
||||
var tail = 0.0;
|
||||
if (t >= 0.0 && t <= 1.0) {
|
||||
tail = op.p0.z * t * t * (3.0 - 2.0 * t);
|
||||
} else if (t > 1.0 && t <= 2.0) {
|
||||
let u = t - 1.0;
|
||||
let remaining = 1.0 - u;
|
||||
tail = op.p0.z * remaining * remaining * (1.0 + 2.0 * u);
|
||||
}
|
||||
return mout(tail);
|
||||
}
|
||||
var m = 0.5 * clamp(1.0 - (d - op.p0.x) / op.p0.y, 0.0, 1.0);
|
||||
if (d <= op.p0.x) { m = 1.0; }
|
||||
return mout(m);
|
||||
@@ -1472,11 +1460,7 @@ fn fs_mglow(in: VOut) -> @location(0) vec4<f32> {
|
||||
fn fs_mfinish(in: VOut) -> @location(0) vec4<f32> {
|
||||
let p = local(in.pos);
|
||||
var v = textureLoad(tex_a, p, 0).r;
|
||||
if (op.p0.x > 1.5) {
|
||||
v = max(v, textureLoad(tex_b, p, 0).r);
|
||||
} else if (op.p0.x > 0.5) {
|
||||
v = abs(v - textureLoad(tex_b, p, 0).r);
|
||||
}
|
||||
if (op.p0.x > 0.5) { v = abs(v - textureLoad(tex_b, p, 0).r); }
|
||||
if (op.p0.y > 0.5 && op.p0.y < 1.5) { v = 1.0 - v; }
|
||||
if (op.p0.z > 0.5) { v = lut(0, v); }
|
||||
if (op.p0.y > 1.5) { v = 1.0 - v; }
|
||||
|
||||
@@ -226,24 +226,6 @@ impl B {
|
||||
let Some(k) = blur_kernel(size) else { return src };
|
||||
self.conv(src, k)
|
||||
}
|
||||
fn max(&mut self, a: In, b: In) -> In {
|
||||
self.push(stage(Kernel::MFinish, Some(a), Some(b), [2.0, 0.0, 0.0, 0.0], 0))
|
||||
}
|
||||
fn softer_glow_tail(&mut self, blur: In, size: f32, range: f32) -> In {
|
||||
let width = photocraft_compose::effects::softer_glow_tail_width(size);
|
||||
if width == 0.0 {
|
||||
return blur;
|
||||
}
|
||||
let distance = self.field(FieldKind::Outside, size + width);
|
||||
let tail = self.push(stage(
|
||||
Kernel::MGlow,
|
||||
Some(distance),
|
||||
None,
|
||||
[size - width, width, photocraft_compose::effects::softer_glow_tail_amplitude(range), 1.0],
|
||||
0,
|
||||
));
|
||||
self.max(blur, tail)
|
||||
}
|
||||
/// Separable convolution with a symmetric kernel `(radius, weights)` (two `MBlur` passes).
|
||||
fn conv(&mut self, src: In, (r, w): (i32, Vec<f32>)) -> In {
|
||||
if r <= 0 {
|
||||
|
||||
@@ -55,8 +55,7 @@ impl B {
|
||||
GlowTechnique::Softer => {
|
||||
// Inner glows spread 1 - alpha.
|
||||
let src = if inner { self.shift(In::Shape, 0.0, 0.0, 0.0, true) } else { In::Shape };
|
||||
let m = self.spread_blur(src, (g.size, g.spread), inner, 0.0, 0.0);
|
||||
if inner { m } else { self.softer_glow_tail(m, g.size, g.range) }
|
||||
self.spread_blur(src, (g.size, g.spread), inner, 0.0, 0.0)
|
||||
}
|
||||
};
|
||||
self.finish_glow(m, fx::glow_lut(g), inner, center);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
use photocraft_color::BlendMode;
|
||||
use photocraft_doc::{FxCommon, GlowSource, GlowTechnique};
|
||||
use photocraft_doc::FxCommon;
|
||||
|
||||
/// Filled-shape identity survives integer moves, including fractional anchors and handles.
|
||||
#[test]
|
||||
@@ -51,36 +51,6 @@ fn programs_key_on_geometry_not_colour() {
|
||||
assert_ne!(a.key, program(&Effect::DropShadow(s), &light, false).key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn softer_outer_glow_program_keeps_the_tent_and_adds_a_distance_tail() {
|
||||
let effect = Effect::OuterGlow(photocraft_doc::Glow {
|
||||
common: FxCommon::new(BlendMode::Screen, 1.0),
|
||||
paint: photocraft_doc::FxPaint::Color(photocraft_color::Color::WHITE),
|
||||
technique: GlowTechnique::Softer,
|
||||
spread: 0.0,
|
||||
size: 60.0,
|
||||
contour: Contour::Linear,
|
||||
anti_alias: false,
|
||||
range: 0.5,
|
||||
jitter: 0.0,
|
||||
noise: 0.0,
|
||||
source: GlowSource::Edge,
|
||||
});
|
||||
let kernel = blur_kernel(60.0).unwrap();
|
||||
let p = program(&effect, &GlobalLight::default(), false);
|
||||
let blur: Vec<_> = p.stages.iter().filter(|s| s.kernel == Kernel::MBlur).collect();
|
||||
assert_eq!(blur.len(), 2);
|
||||
for stage in blur {
|
||||
assert_eq!(stage.radius, kernel.0);
|
||||
assert_eq!(stage.lut.as_deref().unwrap(), kernel.1.as_slice());
|
||||
}
|
||||
let tail = p.stages.iter().find(|s| s.kernel == Kernel::MGlow).unwrap();
|
||||
assert_eq!(tail.a, Some(In::Field(FieldKind::Outside)));
|
||||
assert_eq!(tail.p0, [54.0, 6.0, 0.0025, 1.0]);
|
||||
assert!(p.stages.iter().any(|s| s.kernel == Kernel::MFinish && s.p0[0] == 2.0));
|
||||
assert!(p.fields.contains(&(FieldKind::Outside, reach_for(66.0))));
|
||||
}
|
||||
|
||||
/// A styled group's effect shape follows its own vector mask and its children's Blend If and
|
||||
/// Advanced Blending, so the cached shape must be rebuilt when any of them changes.
|
||||
#[test]
|
||||
|
||||
@@ -1,12 +1,64 @@
|
||||
//! Brush-only Shift+right-click Mode picker. Shares the options bar's painting modes.
|
||||
//! The canvas Shift+right-click menu of the painting tools, as in the reference application: Edit
|
||||
//! Brush… (the Brush Preset picker a plain right-click opens) and the tool's own Mode list from
|
||||
//! its options bar (blend modes, the Eraser's Brush / Pencil / Block, the Dodge and Burn Range,
|
||||
//! the Sponge's mode). Every row sets exactly what the tool's options-bar control sets, through
|
||||
//! the same command (`tools.setBrush` for a brush-based tool's mode) or tool option.
|
||||
|
||||
use egui::{Context, Id};
|
||||
use photocraft_doc::BlendMode;
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{PhotocraftApp, state::Tool, theme::Tokens};
|
||||
|
||||
/// The options bar and canvas picker use the same order, including painting-only Clear.
|
||||
/// One row of the menu.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Item {
|
||||
/// Opens the Brush Preset picker at the click point.
|
||||
EditBrush,
|
||||
Separator,
|
||||
/// The tool's blend mode.
|
||||
Blend(BlendMode),
|
||||
/// Painting-only: paint behind the layer's pixels (Brush and Pencil).
|
||||
Behind,
|
||||
/// Painting-only: paint transparency (Brush, Pencil, Paint Bucket).
|
||||
Clear,
|
||||
/// The Eraser's Mode: `brush`, `pencil` or `block`.
|
||||
Eraser(&'static str),
|
||||
/// The Dodge and Burn Range: `shadows`, `midtones` or `highlights`.
|
||||
Range(&'static str),
|
||||
/// The Sponge's mode: `desaturate` or `saturate`.
|
||||
Sponge(&'static str),
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// The English label (translated when drawn); `None` for a separator.
|
||||
pub(crate) fn label(self) -> Option<&'static str> {
|
||||
Some(match self {
|
||||
Item::EditBrush => "Edit Brush…",
|
||||
Item::Separator => return None,
|
||||
Item::Blend(m) => m.label(),
|
||||
Item::Behind => "Behind",
|
||||
Item::Clear => "Clear",
|
||||
Item::Eraser(m) => match m {
|
||||
"pencil" => "Pencil",
|
||||
"block" => "Block",
|
||||
_ => "Brush",
|
||||
},
|
||||
Item::Range(r) => match r {
|
||||
"shadows" => "Shadows",
|
||||
"highlights" => "Highlights",
|
||||
_ => "Midtones",
|
||||
},
|
||||
Item::Sponge(m) => match m {
|
||||
"saturate" => "Saturate",
|
||||
_ => "Desaturate",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The Brush's and Pencil's Mode list in the options bar's order, Behind and Clear after Dissolve:
|
||||
/// `(mode, behind)`, `(None, false)` being Clear.
|
||||
pub(crate) fn choices() -> Vec<((Option<BlendMode>, bool), &'static str)> {
|
||||
let mut choices: Vec<_> = BlendMode::LAYER_MODES.iter().map(|m| ((Some(*m), false), m.label())).collect();
|
||||
let at = choices.iter().position(|o| o.0 == (Some(BlendMode::Dissolve), false)).map_or(choices.len(), |i| i + 1);
|
||||
@@ -15,8 +67,103 @@ pub(crate) fn choices() -> Vec<((Option<BlendMode>, bool), &'static str)> {
|
||||
choices
|
||||
}
|
||||
|
||||
/// The blend modes in their menu groups (a separator before Darken, Lighten, Overlay, Difference
|
||||
/// and Hue), with Behind and/or Clear after Dissolve.
|
||||
fn blend_items(behind: bool, clear: bool) -> Vec<Item> {
|
||||
let mut out = Vec::new();
|
||||
for m in BlendMode::LAYER_MODES {
|
||||
if matches!(m, BlendMode::Darken | BlendMode::Lighten | BlendMode::Overlay | BlendMode::Difference | BlendMode::Hue) {
|
||||
out.push(Item::Separator);
|
||||
}
|
||||
out.push(Item::Blend(m));
|
||||
if m == BlendMode::Dissolve {
|
||||
if behind {
|
||||
out.push(Item::Behind);
|
||||
}
|
||||
if clear {
|
||||
out.push(Item::Clear);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `tool`'s menu, top to bottom. Empty for a tool without one (its Shift+right-click keeps its
|
||||
/// other route). Only what the tool's options bar offers is listed: the reference application's
|
||||
/// Behind for the stamps, History Brush, Gradient and Paint Bucket, the Blur, Sharpen and Smudge
|
||||
/// modes, the Spot Healing modes and the Healing Brush's source and Replace have no setting here.
|
||||
pub(crate) fn items(tool: Tool) -> Vec<Item> {
|
||||
let with_brush = |rest: Vec<Item>| std::iter::once(Item::EditBrush).chain(std::iter::once(Item::Separator)).chain(rest).collect::<Vec<_>>();
|
||||
match tool {
|
||||
Tool::Brush | Tool::Pencil => with_brush(blend_items(true, true)),
|
||||
Tool::CloneStamp | Tool::PatternStamp | Tool::HistoryBrush => with_brush(blend_items(false, false)),
|
||||
Tool::MixerBrush | Tool::ColorReplacement | Tool::BackgroundEraser | Tool::Blur | Tool::Sharpen | Tool::Smudge => vec![Item::EditBrush],
|
||||
Tool::Eraser => ["brush", "pencil", "block"].into_iter().map(Item::Eraser).collect(),
|
||||
Tool::Dodge | Tool::Burn => with_brush(["shadows", "midtones", "highlights"].into_iter().map(Item::Range).collect()),
|
||||
Tool::Sponge => with_brush(["desaturate", "saturate"].into_iter().map(Item::Sponge).collect()),
|
||||
Tool::Healing => crate::blend_keys::HEALING_MODES.into_iter().map(Item::Blend).collect(),
|
||||
Tool::Gradient => blend_items(false, false),
|
||||
Tool::PaintBucket => blend_items(false, true),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows' labels, separators left out (what `ui.inspect` reports as `choices`).
|
||||
pub(crate) fn labels(tool: Tool) -> Vec<&'static str> {
|
||||
items(tool).into_iter().filter_map(Item::label).collect()
|
||||
}
|
||||
|
||||
/// Is a row available? Behind and Clear need transparency: greyed on the Background layer.
|
||||
pub(crate) fn enabled(app: &PhotocraftApp, item: Item) -> bool {
|
||||
match item {
|
||||
Item::Separator => false,
|
||||
Item::Behind | Item::Clear => !app
|
||||
.session
|
||||
.active()
|
||||
.is_some_and(|st| st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| crate::doc_props_ui::is_background(&st.doc, l))),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The checked row: `tool`'s current mode, as its options bar shows it.
|
||||
pub(crate) fn current(app: &PhotocraftApp, tool: Tool) -> Option<Item> {
|
||||
let o = &app.ui.tool_options;
|
||||
let brush = &app.session.tools.brush;
|
||||
let painting = |clear: bool| {
|
||||
if clear {
|
||||
Item::Clear
|
||||
} else if brush.behind {
|
||||
Item::Behind
|
||||
} else {
|
||||
Item::Blend(brush.mode)
|
||||
}
|
||||
};
|
||||
let item = match tool {
|
||||
Tool::Brush => painting(o.brush_clear),
|
||||
Tool::Pencil => painting(o.pencil_clear),
|
||||
Tool::CloneStamp | Tool::PatternStamp | Tool::HistoryBrush | Tool::Healing => Item::Blend(crate::blend_keys::brush_tool_mode(tool, brush.mode)),
|
||||
Tool::Gradient => Item::Blend(o.gradient_blend_mode),
|
||||
Tool::PaintBucket if o.bucket_clear => Item::Clear,
|
||||
Tool::PaintBucket => Item::Blend(o.bucket_blend_mode),
|
||||
_ => return items(tool).into_iter().find(|i| ui_value(app, *i).is_some_and(|v| v)),
|
||||
};
|
||||
Some(item)
|
||||
}
|
||||
|
||||
/// Whether a tool-option row (Eraser, Range, Sponge) is the current value; `None` for other rows.
|
||||
fn ui_value(app: &PhotocraftApp, item: Item) -> Option<bool> {
|
||||
let o = &app.ui.tool_options;
|
||||
Some(match item {
|
||||
Item::Eraser(m) => o.eraser_mode == m,
|
||||
Item::Range(r) => o.tone_range == r,
|
||||
Item::Sponge(m) => o.sponge_mode == m,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Does the active tool have a menu for this gesture (Shift, without Alt, Ctrl or Command)?
|
||||
pub(crate) fn is_gesture(tool: Tool, mods: egui::Modifiers) -> bool {
|
||||
tool == Tool::Brush && mods.shift && !mods.alt && !mods.ctrl && !mods.command && !mods.mac_cmd
|
||||
mods.shift && !mods.alt && !mods.ctrl && !mods.command && !mods.mac_cmd && !items(tool).is_empty()
|
||||
}
|
||||
|
||||
/// The modifiers that decide a right-button gesture: the ones held when the button went down.
|
||||
@@ -47,17 +194,31 @@ pub(crate) fn menu_id() -> Id {
|
||||
Id::unique("canvas-brush-mode-menu")
|
||||
}
|
||||
|
||||
/// Opens the active tool's menu at screen point `pos`.
|
||||
pub(crate) fn open(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) {
|
||||
if !pos.iter().all(|v| v.is_finite()) {
|
||||
if !pos.iter().all(|v| v.is_finite()) || items(app.ui.tool).is_empty() {
|
||||
return;
|
||||
}
|
||||
// The checked row reads the tool's own brush.
|
||||
crate::paint_mouse::sync_tool_brush(app);
|
||||
app.ui.brush_picker = None;
|
||||
app.ui.layer_menu = None;
|
||||
app.ui.canvas_tool_menu = None;
|
||||
app.ui.brush_mode_menu = Some(pos);
|
||||
app.ui.brush_mode_menu_tool = Some(app.ui.tool);
|
||||
crate::canvas_popup::mark_open(ctx, menu_id());
|
||||
}
|
||||
|
||||
fn close(app: &mut PhotocraftApp) {
|
||||
app.ui.brush_mode_menu = None;
|
||||
app.ui.brush_mode_menu_tool = None;
|
||||
}
|
||||
|
||||
/// Is the open menu still the active tool's? A tool change closes it.
|
||||
fn current_tool(app: &PhotocraftApp) -> bool {
|
||||
app.ui.brush_mode_menu_tool == Some(app.ui.tool)
|
||||
}
|
||||
|
||||
/// A press outside dismisses the popup, and consumes that entire canvas gesture through Up.
|
||||
pub(crate) fn blocks_canvas(app: &PhotocraftApp, ctx: &Context) -> bool {
|
||||
let key = menu_id().with("dismiss-pointer");
|
||||
@@ -78,7 +239,8 @@ pub(crate) fn blocks_canvas(app: &PhotocraftApp, ctx: &Context) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Document-coordinate control pointers dismiss; menu rows are chosen with screen-space ui.click.
|
||||
/// Document-coordinate control pointers dismiss; menu rows are chosen with screen-space ui.click
|
||||
/// or `ui.context.choose`.
|
||||
pub(crate) fn blocks_control(app: &mut PhotocraftApp, ctx: &Context, ev: crate::canvas::ToolEvent) -> bool {
|
||||
use crate::canvas::ToolEvent;
|
||||
let key = menu_id().with("control-dismiss");
|
||||
@@ -90,12 +252,12 @@ pub(crate) fn blocks_control(app: &mut PhotocraftApp, ctx: &Context, ev: crate::
|
||||
return true;
|
||||
}
|
||||
if app.ui.brush_mode_menu.is_some() {
|
||||
if app.ui.tool != Tool::Brush {
|
||||
app.ui.brush_mode_menu = None;
|
||||
if !current_tool(app) {
|
||||
close(app);
|
||||
return false;
|
||||
}
|
||||
if matches!(ev, ToolEvent::Down { .. }) {
|
||||
app.ui.brush_mode_menu = None;
|
||||
close(app);
|
||||
ctx.data_mut(|d| d.insert_temp(key, true));
|
||||
}
|
||||
return true;
|
||||
@@ -103,39 +265,91 @@ pub(crate) fn blocks_control(app: &mut PhotocraftApp, ctx: &Context, ev: crate::
|
||||
false
|
||||
}
|
||||
|
||||
fn choose(app: &mut PhotocraftApp, mode: (Option<BlendMode>, bool)) {
|
||||
// Clear is the existing painting-only UI flag; keep the underlying blend mode just as the
|
||||
// options bar does. Journal the choice through the same brush command.
|
||||
let selected = if mode.1 { BlendMode::Normal } else { mode.0.unwrap_or(app.session.tools.brush.mode) };
|
||||
match app.run("tools.setBrush", json!({"brush": {"mode": selected,"behind":mode.1}})) {
|
||||
Ok(_) => app.ui.tool_options.brush_clear = mode == (None, false),
|
||||
Err(e) => app.ui.status = e,
|
||||
/// The engine command a row runs, if any: a brush-based tool's mode goes through
|
||||
/// `tools.setBrush`, as its options-bar dropdown does. The Brush and Pencil also set or clear
|
||||
/// Behind (Clear keeps the underlying mode, as the options bar does).
|
||||
pub(crate) fn engine_call(app: &PhotocraftApp, tool: Tool, item: Item) -> Option<(&'static str, Value)> {
|
||||
let brush = match (tool, item) {
|
||||
(Tool::Brush | Tool::Pencil, Item::Blend(m)) => json!({"mode": m, "behind": false}),
|
||||
(Tool::Brush | Tool::Pencil, Item::Behind) => json!({"mode": BlendMode::Normal, "behind": true}),
|
||||
(Tool::Brush | Tool::Pencil, Item::Clear) => json!({"mode": app.session.tools.brush.mode, "behind": false}),
|
||||
(Tool::CloneStamp | Tool::PatternStamp | Tool::HistoryBrush | Tool::Healing, Item::Blend(m)) => json!({"mode": m}),
|
||||
_ => return None,
|
||||
};
|
||||
Some(("tools.setBrush", json!({ "brush": brush })))
|
||||
}
|
||||
|
||||
/// The row of the open menu whose English label is `label`, if it can be chosen now.
|
||||
pub(crate) fn find(app: &PhotocraftApp, label: &str) -> Result<Item, String> {
|
||||
if app.ui.brush_mode_menu.is_none() || !current_tool(app) {
|
||||
return Err("no Shift+right-click menu is open".into());
|
||||
}
|
||||
let item = items(app.ui.tool).into_iter().find(|i| i.label() == Some(label)).ok_or_else(|| format!("the menu has no row `{label}`"))?;
|
||||
if !enabled(app, item) {
|
||||
return Err(format!("`{label}` is unavailable"));
|
||||
}
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
/// Chooses `item` for the active tool and closes the menu.
|
||||
pub(crate) fn choose(app: &mut PhotocraftApp, ctx: &Context, item: Item) {
|
||||
let tool = app.ui.tool;
|
||||
let pos = app.ui.brush_mode_menu;
|
||||
close(app);
|
||||
if !enabled(app, item) {
|
||||
return;
|
||||
}
|
||||
crate::paint_mouse::sync_tool_brush(app);
|
||||
if let Some((id, params)) = engine_call(app, tool, item) {
|
||||
match app.run(id, params) {
|
||||
Ok(_) => match tool {
|
||||
Tool::Brush => app.ui.tool_options.brush_clear = item == Item::Clear,
|
||||
Tool::Pencil => app.ui.tool_options.pencil_clear = item == Item::Clear,
|
||||
_ => {}
|
||||
},
|
||||
Err(e) => app.ui.status = e,
|
||||
}
|
||||
return;
|
||||
}
|
||||
match item {
|
||||
Item::EditBrush => {
|
||||
if let Some(at) = pos {
|
||||
crate::brush_picker::open(&mut app.ui, ctx, at);
|
||||
}
|
||||
}
|
||||
Item::Eraser(m) => app.ui.tool_options.eraser_mode = m.into(),
|
||||
Item::Range(r) => app.ui.tool_options.tone_range = r.into(),
|
||||
Item::Sponge(m) => app.ui.tool_options.sponge_mode = m.into(),
|
||||
Item::Blend(m) if tool == Tool::Gradient => crate::blend_keys::set_gradient_mode(app, m),
|
||||
Item::Blend(m) if tool == Tool::PaintBucket => {
|
||||
app.ui.tool_options.bucket_clear = false;
|
||||
app.ui.tool_options.bucket_blend_mode = m;
|
||||
}
|
||||
Item::Clear if tool == Tool::PaintBucket => app.ui.tool_options.bucket_clear = true,
|
||||
_ => {}
|
||||
}
|
||||
app.ui.brush_mode_menu = None;
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut PhotocraftApp, ctx: &Context) {
|
||||
let Some([x, y]) = app.ui.brush_mode_menu else { return };
|
||||
if app.ui.tool != Tool::Brush || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.brush_mode_menu = None;
|
||||
if !current_tool(app) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
let same_tool = current_tool(app);
|
||||
close(app);
|
||||
// Escape also consumes the release of a right-button gesture still in flight.
|
||||
if app.ui.tool == Tool::Brush && ctx.input(|i| i.pointer.secondary_down()) {
|
||||
if same_tool && ctx.input(|i| i.pointer.secondary_down()) {
|
||||
ctx.data_mut(|d| d.insert_temp(menu_id().with("dismiss-pointer"), true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let choices = choices();
|
||||
let current = if app.ui.tool_options.brush_clear {
|
||||
(None, false)
|
||||
} else if app.session.tools.brush.behind {
|
||||
(None, true)
|
||||
} else {
|
||||
(Some(app.session.tools.brush.mode), false)
|
||||
};
|
||||
crate::paint_mouse::sync_tool_brush(app);
|
||||
let tool = app.ui.tool;
|
||||
let rows: Vec<(Item, bool)> = items(tool).into_iter().map(|i| (i, enabled(app, i))).collect();
|
||||
let current = current(app, tool);
|
||||
let screen = crate::work_area::visible_rect(ctx);
|
||||
let id = menu_id();
|
||||
let margin = ctx.global_style().spacing.menu_margin.sum() + egui::Vec2::splat(2.0);
|
||||
let height = (choices.len() as f32 * 22.0).min((screen.height() - margin.y).max(0.0));
|
||||
let content: f32 = rows.iter().map(|(i, _)| if *i == Item::Separator { 7.0 } else { 22.0 }).sum();
|
||||
let height = content.min((screen.height() - margin.y).max(0.0));
|
||||
let width = (screen.width() - margin.x).clamp(0.0, 190.0);
|
||||
let size = ctx.memory(|m| m.area_rect(id)).map_or(egui::vec2(width + margin.x, height + margin.y), |r| r.size());
|
||||
let pos = egui::pos2(x.min(screen.right() - size.x).max(screen.left()), y.min(screen.bottom() - size.y).max(screen.top()));
|
||||
@@ -146,20 +360,25 @@ pub(crate) fn show(app: &mut PhotocraftApp, ctx: &Context) {
|
||||
ui.set_width(width);
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
crate::widgets::menu_scroll(ui, |ui| {
|
||||
for (mode, label) in choices {
|
||||
let selected = mode == current;
|
||||
for (item, on) in rows {
|
||||
let Some(label) = item.label() else {
|
||||
ui.separator();
|
||||
continue;
|
||||
};
|
||||
let selected = Some(item) == current;
|
||||
let color = if selected && t.bevel { t.accent_text } else { t.text };
|
||||
let row = egui::Button::selectable(selected, egui::RichText::new(tl!(label)).color(color)).min_size(egui::vec2(width, 22.0));
|
||||
if ui.add(row).clicked() {
|
||||
picked = Some(mode);
|
||||
let text = if on { egui::RichText::new(tl!(label)).color(color) } else { egui::RichText::new(tl!(label)) };
|
||||
let row = egui::Button::selectable(selected, text).min_size(egui::vec2(width, 22.0));
|
||||
if ui.add_enabled(on, row).clicked() {
|
||||
picked = Some(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(mode) = picked {
|
||||
choose(app, mode);
|
||||
if let Some(item) = picked {
|
||||
choose(app, ctx, item);
|
||||
} else if crate::canvas_popup::outside_pressed(ctx, id, area.response.rect) {
|
||||
app.ui.brush_mode_menu = None;
|
||||
close(app);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,9 @@ fn shift_right_mode_picker_cancels_and_other_gestures_keep_their_routes() {
|
||||
h.state_mut().ui.tool = Tool::Pencil;
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.brush_mode_menu.is_none());
|
||||
// Spot Healing has no Shift+right-click menu here: its right-click keeps the picker.
|
||||
h.state_mut().ui.tool = Tool::SpotHealing;
|
||||
h.run_steps(1);
|
||||
mode_right_click(&mut h, Modifiers::SHIFT);
|
||||
assert!(h.state().ui.brush_mode_menu.is_none() && h.state().ui.brush_picker.is_some());
|
||||
h.state_mut().ui.brush_picker = None;
|
||||
@@ -690,3 +693,162 @@ fn one_frame_feeds_every_pointer_move_not_just_the_latest() {
|
||||
press(&mut h, end, PointerButton::Primary, false);
|
||||
assert_committed_points(&h, &queued);
|
||||
}
|
||||
|
||||
/// Shift+right-click with `tool` and the labels of the menu it opened (empty: none opened).
|
||||
fn open_tool_menu(h: &mut Harness<'static, PhotocraftApp>, tool: Tool) -> Vec<&'static str> {
|
||||
h.state_mut().ui.tool = tool;
|
||||
h.run_steps(1);
|
||||
mode_right_click(h, Modifiers::SHIFT);
|
||||
if h.state().ui.brush_mode_menu.is_none() { Vec::new() } else { crate::brush_mode_menu::labels(tool) }
|
||||
}
|
||||
|
||||
fn choose_row(h: &mut Harness<'static, PhotocraftApp>, label: &str) {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
h.get_by_label(label).click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.brush_mode_menu.is_none(), "choosing {label} closes the menu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_right_menu_lists_each_tools_own_rows_in_order() {
|
||||
use crate::brush_mode_menu::{Item, items};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let blend: Vec<&'static str> = photocraft_doc::BlendMode::LAYER_MODES.iter().map(|m| m.label()).collect();
|
||||
let with = |extra: &[&'static str]| {
|
||||
let mut v = blend.clone();
|
||||
for (i, l) in extra.iter().enumerate() {
|
||||
v.insert(2 + i, l);
|
||||
}
|
||||
v
|
||||
};
|
||||
let edit = |rest: Vec<&'static str>| std::iter::once("Edit Brush…").chain(rest).collect::<Vec<_>>();
|
||||
for (tool, want) in [
|
||||
(Tool::Brush, edit(with(&["Behind", "Clear"]))),
|
||||
(Tool::Pencil, edit(with(&["Behind", "Clear"]))),
|
||||
(Tool::CloneStamp, edit(with(&[]))),
|
||||
(Tool::Eraser, vec!["Brush", "Pencil", "Block"]),
|
||||
(Tool::Dodge, edit(vec!["Shadows", "Midtones", "Highlights"])),
|
||||
(Tool::Sponge, edit(vec!["Desaturate", "Saturate"])),
|
||||
(Tool::PaintBucket, with(&["Clear"])),
|
||||
(Tool::MixerBrush, vec!["Edit Brush…"]),
|
||||
(Tool::Healing, vec!["Normal", "Multiply", "Screen", "Darken", "Lighten", "Color", "Luminosity"]),
|
||||
(Tool::SpotHealing, vec![]),
|
||||
(Tool::MagicEraser, vec![]),
|
||||
] {
|
||||
let mut h = harness(None);
|
||||
let journal = h.state().session.journal.len();
|
||||
assert_eq!(open_tool_menu(&mut h, tool), want, "{tool:?}");
|
||||
assert_eq!(h.state().session.journal.len(), journal, "{tool:?}: opening changes nothing");
|
||||
if want.is_empty() {
|
||||
continue;
|
||||
}
|
||||
assert!(h.state().ui.brush_picker.is_none(), "{tool:?}");
|
||||
// A separator after Edit Brush…, and the blend modes in their groups.
|
||||
let rows = items(tool);
|
||||
if rows.first() == Some(&Item::EditBrush) && rows.len() > 1 {
|
||||
assert_eq!(rows.get(1), Some(&Item::Separator), "{tool:?}");
|
||||
}
|
||||
if want.contains(&"Darken") && want.contains(&"Dissolve") {
|
||||
let at = rows.iter().position(|i| *i == Item::Blend(photocraft_doc::BlendMode::Darken)).unwrap();
|
||||
assert_eq!(rows[at - 1], Item::Separator, "{tool:?}");
|
||||
}
|
||||
// The rows the menu shows first are drawn, top to bottom, in that order.
|
||||
let tops: Vec<f32> = want.iter().take(4).map(|l| h.get_by_label(l).rect().top()).collect();
|
||||
assert!(tops.windows(2).all(|w| w[0] < w[1]), "{tool:?}: {tops:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_right_menu_rows_set_what_the_options_bar_sets() {
|
||||
use photocraft_doc::BlendMode;
|
||||
let mut h = harness(None);
|
||||
// Pencil: Clear is the Pencil's own flag, Behind its brush's, both through tools.setBrush.
|
||||
open_tool_menu(&mut h, Tool::Pencil);
|
||||
choose_row(&mut h, "Clear");
|
||||
assert!(h.state().ui.tool_options.pencil_clear && !h.state().ui.tool_options.brush_clear);
|
||||
open_tool_menu(&mut h, Tool::Pencil);
|
||||
choose_row(&mut h, "Behind");
|
||||
assert!(!h.state().ui.tool_options.pencil_clear && h.state().session.tools.brush.behind);
|
||||
assert_eq!(h.state().session.journal.last().unwrap(), &("tools.setBrush".into(), json!({"brush": {"mode": "Normal", "behind": true}})));
|
||||
// Clone Stamp: its own brush's mode, as the options bar's Mode dropdown.
|
||||
open_tool_menu(&mut h, Tool::CloneStamp);
|
||||
choose_row(&mut h, "Multiply");
|
||||
assert_eq!(h.state().session.tools.brush.mode, BlendMode::Multiply);
|
||||
assert_eq!(h.state().session.journal.last().unwrap(), &("tools.setBrush".into(), json!({"brush": {"mode": "Multiply"}})));
|
||||
assert_eq!(crate::blend_keys::tool_mode(h.state_mut()), Some(BlendMode::Multiply));
|
||||
h.state_mut().ui.tool = Tool::Brush;
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().session.tools.brush.mode, BlendMode::Normal, "the Brush keeps its own mode");
|
||||
// Healing Brush: the short list.
|
||||
open_tool_menu(&mut h, Tool::Healing);
|
||||
choose_row(&mut h, "Luminosity");
|
||||
assert_eq!(h.state().session.tools.brush.mode, BlendMode::Luminosity);
|
||||
// Tool options: Eraser Mode, Dodge Range, Sponge Mode, Paint Bucket and Gradient modes.
|
||||
open_tool_menu(&mut h, Tool::Eraser);
|
||||
choose_row(&mut h, "Block");
|
||||
assert_eq!(h.state().ui.tool_options.eraser_mode, "block");
|
||||
open_tool_menu(&mut h, Tool::Dodge);
|
||||
choose_row(&mut h, "Highlights");
|
||||
assert_eq!(h.state().ui.tool_options.tone_range, "highlights");
|
||||
open_tool_menu(&mut h, Tool::Sponge);
|
||||
choose_row(&mut h, "Saturate");
|
||||
assert_eq!(h.state().ui.tool_options.sponge_mode, "saturate");
|
||||
open_tool_menu(&mut h, Tool::PaintBucket);
|
||||
choose_row(&mut h, "Clear");
|
||||
assert!(h.state().ui.tool_options.bucket_clear);
|
||||
open_tool_menu(&mut h, Tool::PaintBucket);
|
||||
choose_row(&mut h, "Screen");
|
||||
assert!(!h.state().ui.tool_options.bucket_clear);
|
||||
assert_eq!(h.state().ui.tool_options.bucket_blend_mode, BlendMode::Screen);
|
||||
open_tool_menu(&mut h, Tool::Gradient);
|
||||
choose_row(&mut h, "Overlay");
|
||||
assert_eq!(h.state().ui.tool_options.gradient_blend_mode, BlendMode::Overlay);
|
||||
// The checked row is the current one.
|
||||
open_tool_menu(&mut h, Tool::Eraser);
|
||||
assert_eq!(crate::brush_mode_menu::current(h.state(), Tool::Eraser), Some(crate::brush_mode_menu::Item::Eraser("block")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_brush_opens_the_brush_preset_picker_at_the_click() {
|
||||
for tool in [Tool::Brush, Tool::CloneStamp, Tool::MixerBrush, Tool::Dodge] {
|
||||
let mut h = harness(None);
|
||||
open_tool_menu(&mut h, tool);
|
||||
let at = h.state().ui.brush_mode_menu.unwrap();
|
||||
let journal = h.state().session.journal.len();
|
||||
choose_row(&mut h, "Edit Brush…");
|
||||
assert_eq!(h.state().ui.brush_picker, Some(at), "{tool:?}");
|
||||
assert_eq!(h.state().session.journal.len(), journal, "{tool:?}");
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.brush_picker.is_some(), "{tool:?}: the picker stays open");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behind_and_clear_are_greyed_on_the_background_layer() {
|
||||
use crate::brush_mode_menu::{Item, enabled, find};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = harness(None);
|
||||
let (bg, top) = {
|
||||
let doc = &h.state().session.active().unwrap().doc;
|
||||
(doc.layers[0].id, doc.layers[1].id)
|
||||
};
|
||||
h.state_mut().session.active_mut().unwrap().active_layer = Some(bg);
|
||||
for tool in [Tool::Brush, Tool::Pencil, Tool::PaintBucket] {
|
||||
open_tool_menu(&mut h, tool);
|
||||
assert!(!enabled(h.state(), Item::Clear), "{tool:?}");
|
||||
assert!(find(h.state(), "Clear").is_err(), "{tool:?}");
|
||||
assert!(enabled(h.state(), Item::Blend(photocraft_doc::BlendMode::Multiply)));
|
||||
let journal = h.state().session.journal.len();
|
||||
h.get_by_label("Clear").click();
|
||||
h.run_steps(2);
|
||||
let o = &h.state().ui.tool_options;
|
||||
assert!(!o.brush_clear && !o.pencil_clear && !o.bucket_clear, "{tool:?}");
|
||||
assert_eq!(h.state().session.journal.len(), journal, "{tool:?}: a greyed row does nothing");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
}
|
||||
assert!(!enabled(h.state(), Item::Behind));
|
||||
// On a normal layer they are available again.
|
||||
h.state_mut().session.active_mut().unwrap().active_layer = Some(top);
|
||||
assert!(enabled(h.state(), Item::Behind) && enabled(h.state(), Item::Clear));
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ fn engine_methods(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlReq
|
||||
|
||||
fn context_choose(app: &mut PhotocraftApp, ctx: &egui::Context, p: &Value) -> Outcome {
|
||||
let Some(id) = str_param(p, "id") else { return err("missing `id`") };
|
||||
if app.ui.canvas_tool_menu.is_none() && app.ui.brush_mode_menu.is_some() {
|
||||
return mode_menu_choose(app, ctx, id);
|
||||
}
|
||||
let Some(menu) = app.ui.canvas_tool_menu.as_ref() else { return err("no canvas context menu is open") };
|
||||
if !crate::canvas_tool_menu::available(app, menu, id) {
|
||||
return err("context action is unavailable");
|
||||
@@ -66,6 +69,22 @@ fn context_choose(app: &mut PhotocraftApp, ctx: &egui::Context, p: &Value) -> Ou
|
||||
ok(json!({"command": id, "dialog": app.ui.dialogs.last().map(|d| d.id)}))
|
||||
}
|
||||
|
||||
/// A row of the painting tools' Shift+right-click menu, by its English label (`brushModeMenu.choices`).
|
||||
fn mode_menu_choose(app: &mut PhotocraftApp, ctx: &egui::Context, label: &str) -> Outcome {
|
||||
let item = match crate::brush_mode_menu::find(app, label) {
|
||||
Ok(item) => item,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
if let Some((command, params)) = crate::brush_mode_menu::engine_call(app, app.ui.tool, item)
|
||||
&& let Some(authorize) = app.services.automation_command.as_ref()
|
||||
&& let Err(error) = authorize(command, ¶ms)
|
||||
{
|
||||
return err(error);
|
||||
}
|
||||
crate::brush_mode_menu::choose(app, ctx, item);
|
||||
ok(json!({"choice": label, "brushPicker": app.ui.brush_picker.is_some()}))
|
||||
}
|
||||
|
||||
fn execute_command(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||||
let p = &req.params;
|
||||
let Some(id) = str_param(p, "command").or(str_param(p, "id")) else { return err("missing `command`") };
|
||||
|
||||
@@ -41,11 +41,21 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
|
||||
"camTransform": app.ui.cam_transform,
|
||||
"layerMenu": app.ui.layer_menu,
|
||||
"layerPicker": app.ui.layer_picker,
|
||||
"brushModeMenu": app.ui.brush_mode_menu.map(|pos| json!({
|
||||
"pos": pos,
|
||||
"mode": if app.ui.tool_options.brush_clear { "Clear" } else { app.session.tools.brush.mode.label() },
|
||||
"choices": crate::brush_mode_menu::choices().iter().map(|(_, label)| *label).collect::<Vec<_>>(),
|
||||
})),
|
||||
"brushModeMenu": app.ui.brush_mode_menu.map(|pos| {
|
||||
use crate::brush_mode_menu as m;
|
||||
let tool = app.ui.tool;
|
||||
let current = m::current(app, tool);
|
||||
json!({
|
||||
"pos": pos,
|
||||
"tool": tool,
|
||||
"mode": current.and_then(m::Item::label),
|
||||
"choices": m::labels(tool),
|
||||
"items": m::items(tool).into_iter().map(|i| match i.label() {
|
||||
Some(label) => json!({"label": label, "enabled": m::enabled(app, i), "checked": Some(i) == current}),
|
||||
None => json!({"separator": true}),
|
||||
}).collect::<Vec<_>>(),
|
||||
})
|
||||
}),
|
||||
"brushPicker": app.ui.brush_picker.map(|pos| json!({
|
||||
"pos": pos,
|
||||
// The picker's content size once its corner grip was dragged (null: the default).
|
||||
|
||||
@@ -66,7 +66,9 @@ fn brush_mode_control_routes_modifiers_and_reports_choices_without_history() {
|
||||
("brush", json!({"shift": true, "alt": true}), "erase"),
|
||||
("brush", json!({"shift": true, "ctrl": true}), if cfg!(target_os = "macos") { "erase" } else { "layer" }),
|
||||
("brush", json!({"shift": true, "command": true}), "layer"),
|
||||
("pencil", json!({"shift": true}), "preset"),
|
||||
("pencil", json!({"shift": true}), "mode"),
|
||||
("eraser", json!({"shift": true}), "mode"),
|
||||
("spotHealing", json!({"shift": true}), "preset"),
|
||||
] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap();
|
||||
@@ -85,7 +87,7 @@ fn brush_mode_control_routes_modifiers_and_reports_choices_without_history() {
|
||||
assert!(app.drag.is_none() && !app.secondary_erase);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), history);
|
||||
let inspect = inspect(&app, &ctx);
|
||||
assert_eq!(inspect["brushModeMenu"]["choices"], json!(crate::brush_mode_menu::choices().iter().map(|(_, label)| *label).collect::<Vec<_>>()));
|
||||
assert_eq!(inspect["brushModeMenu"]["choices"], json!(crate::brush_mode_menu::labels(app.ui.tool)));
|
||||
let journal = app.session.journal.len();
|
||||
call(
|
||||
&mut app,
|
||||
@@ -2237,3 +2239,62 @@ fn ui_set_mask_target_ends_a_filter_mask_view() {
|
||||
let photocraft_doc::LayerContent::Smart(sm) = &l.content else { panic!("smart object") };
|
||||
assert_eq!(sm.filter_mask.as_ref().unwrap().value(20, 20), 1.0, "not into the filter mask");
|
||||
}
|
||||
|
||||
fn shift_right(app: &mut PhotocraftApp, ctx: &egui::Context, tool: &str) {
|
||||
call(
|
||||
app,
|
||||
ctx,
|
||||
"ui.pointer",
|
||||
json!({"tool": tool, "button": "right", "shift": true,
|
||||
"events": [{"kind": "down", "x": 20, "y": 20}, {"kind": "up", "x": 20, "y": 20}]}),
|
||||
);
|
||||
}
|
||||
|
||||
/// `ui.context.choose` picks a Shift+right-click row by label; a bad or greyed one is an error
|
||||
/// that changes nothing.
|
||||
#[test]
|
||||
fn shift_right_menu_choose_rejects_bad_choices_and_sets_the_tool() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap();
|
||||
// Nothing open yet.
|
||||
assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id": "Multiply"}))["ok"], false);
|
||||
shift_right(&mut app, &ctx, "brush");
|
||||
let menu = inspect(&app, &ctx)["brushModeMenu"].clone();
|
||||
assert_eq!(menu["tool"], json!("Brush"));
|
||||
assert_eq!(menu["mode"], json!("Normal"));
|
||||
assert_eq!(menu["items"][0], json!({"label": "Edit Brush…", "enabled": true, "checked": false}));
|
||||
assert_eq!(menu["items"][1], json!({"separator": true}));
|
||||
// The Background has no transparency: Behind and Clear are greyed.
|
||||
assert!(menu["items"].as_array().unwrap().iter().any(|i| *i == json!({"label": "Clear", "enabled": false, "checked": false})));
|
||||
let journal = app.session.journal.len();
|
||||
for bad in [json!({"id": "Bogus"}), json!({"id": "Clear"}), json!({"id": "Behind"}), json!({"id": 3}), json!({}), json!({"id": "Desaturate"})] {
|
||||
let reply = call(&mut app, &ctx, "ui.context.choose", bad.clone());
|
||||
assert_eq!(reply["ok"], false, "{bad}: {reply}");
|
||||
assert!(app.ui.brush_mode_menu.is_some(), "{bad}: a bad choice leaves the menu open");
|
||||
}
|
||||
assert_eq!(app.session.journal.len(), journal, "bad choices change nothing");
|
||||
assert!(!app.ui.tool_options.brush_clear && !app.session.tools.brush.behind);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id": "Multiply"}))["ok"], true);
|
||||
assert!(app.ui.brush_mode_menu.is_none());
|
||||
assert_eq!(app.session.tools.brush.mode, photocraft_doc::BlendMode::Multiply);
|
||||
assert_eq!(app.session.journal.last().unwrap(), &("tools.setBrush".into(), json!({"brush": {"mode": "Multiply", "behind": false}})));
|
||||
// Another tool's rows.
|
||||
shift_right(&mut app, &ctx, "eraser");
|
||||
assert_eq!(inspect(&app, &ctx)["brushModeMenu"]["choices"], json!(["Brush", "Pencil", "Block"]));
|
||||
assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id": "Multiply"}))["ok"], false);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id": "Pencil"}))["ok"], true);
|
||||
assert_eq!(app.ui.tool_options.eraser_mode, "pencil");
|
||||
shift_right(&mut app, &ctx, "cloneStamp");
|
||||
let reply = call(&mut app, &ctx, "ui.context.choose", json!({"id": "Edit Brush…"}));
|
||||
assert_eq!(reply["ok"], true, "{reply}");
|
||||
assert!(app.ui.brush_picker.is_some() && app.ui.brush_mode_menu.is_none());
|
||||
// A tool change closes the menu: its rows are no longer chosen.
|
||||
shift_right(&mut app, &ctx, "dodge");
|
||||
app.ui.tool = crate::state::Tool::Sponge;
|
||||
assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id": "Saturate"}))["ok"], false);
|
||||
assert_eq!(app.ui.tool_options.sponge_mode, "desaturate");
|
||||
// Magic Eraser has no menu.
|
||||
shift_right(&mut app, &ctx, "magicEraser");
|
||||
assert!(inspect(&app, &ctx)["brushModeMenu"].is_null());
|
||||
}
|
||||
|
||||
@@ -127,7 +127,12 @@ impl LiquifyOpts {
|
||||
};
|
||||
}
|
||||
if let Some(t) = v.get("tool") {
|
||||
self.tool = serde_json::from_value(t.clone()).map_err(|e| format!("bad tool: {e}"))?;
|
||||
let tool: LiquifyTool = serde_json::from_value(t.clone()).map_err(|e| format!("bad tool: {e}"))?;
|
||||
// Strokes can twirl counterclockwise, but no tool does: hold Alt with Twirl Clockwise.
|
||||
if !LiquifyTool::ALL.contains(&tool) {
|
||||
return Err(format!("{t} is not a Liquify tool: choose twirlCw and hold Alt (Option) to twirl counterclockwise"));
|
||||
}
|
||||
self.tool = tool;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -653,8 +658,9 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result<Value, String> {
|
||||
Ok(d.describe())
|
||||
}
|
||||
|
||||
/// Photoshop's Alt (Option) variant of a Liquify brush: the twirls reverse direction and Pucker
|
||||
/// and Bloat swap. Other tools are unchanged.
|
||||
/// The Alt (Option) variant of a Liquify brush: Twirl Clockwise twirls counterclockwise (the
|
||||
/// reference application has no separate counterclockwise tool) and Pucker and Bloat swap.
|
||||
/// Other tools are unchanged.
|
||||
fn alt_tool(t: LiquifyTool) -> LiquifyTool {
|
||||
match t {
|
||||
LiquifyTool::TwirlCw => LiquifyTool::TwirlCcw,
|
||||
@@ -794,8 +800,6 @@ fn tool_icon(t: LiquifyTool) -> &'static str {
|
||||
LiquifyTool::Reconstruct => "brush-cleaning",
|
||||
LiquifyTool::Smooth => "droplet",
|
||||
LiquifyTool::TwirlCw => "rotate-cw",
|
||||
// Twirl Clockwise's arrow mirrored: `undo-2` read as Undo (#9547).
|
||||
LiquifyTool::TwirlCcw => "rotate-ccw",
|
||||
LiquifyTool::Pucker => "scan",
|
||||
LiquifyTool::Bloat => "maximize-2",
|
||||
LiquifyTool::PushLeft => "chevrons-left",
|
||||
|
||||
@@ -154,21 +154,62 @@ fn brush_settings_are_remembered_between_uses() {
|
||||
assert!(open(&mut app, &ctx).is_ok());
|
||||
}
|
||||
|
||||
/// Follow-up to #1494 / #1484: Alt is captured at stroke-down and stays recorded
|
||||
/// through undo, redo and deterministic Liquify field reconstruction.
|
||||
/// #9547: Twirl Counterclockwise draws Twirl Clockwise's arrow mirrored, not the Undo arrow,
|
||||
/// and every Liquify tool's icon exists.
|
||||
/// The reference application's Liquify has Twirl Clockwise only: no Twirl Counterclockwise
|
||||
/// tool on the strip or a shortcut, and a script or saved setting naming it is refused with a
|
||||
/// clear error instead of selecting it (Alt with Twirl Clockwise twirls counterclockwise).
|
||||
#[test]
|
||||
fn twirl_counterclockwise_has_its_own_mirrored_icon() {
|
||||
assert_eq!(tool_icon(LiquifyTool::TwirlCcw), "rotate-ccw");
|
||||
let svg = |name: &str| crate::icon_data::ICONS.iter().find(|(n, _)| *n == name).map(|(_, b)| String::from_utf8_lossy(b).into_owned());
|
||||
let (cw, ccw) = (svg("rotate-cw").unwrap(), svg("rotate-ccw").expect("the rotate-ccw icon"));
|
||||
assert!(ccw.contains("M3 12a9 9 0 1 0 9-9") && cw.contains("M21 12a9 9 0 1 1-9-9"), "mirror images");
|
||||
for t in [LiquifyTool::ForwardWarp, LiquifyTool::TwirlCw, LiquifyTool::TwirlCcw, LiquifyTool::Pucker, LiquifyTool::Bloat] {
|
||||
assert!(svg(tool_icon(t)).is_some(), "{t:?}: {}", tool_icon(t));
|
||||
fn the_tool_strip_has_no_twirl_counterclockwise() {
|
||||
assert!(!LiquifyTool::ALL.contains(&LiquifyTool::TwirlCcw));
|
||||
assert!(LiquifyTool::ALL.contains(&LiquifyTool::TwirlCw));
|
||||
for t in LiquifyTool::ALL {
|
||||
assert_ne!(tl!(t.label()), tl!("Twirl Counterclockwise"), "{t:?}");
|
||||
let svg = crate::icon_data::ICONS.iter().any(|(n, _)| *n == tool_icon(t));
|
||||
assert!(svg, "{t:?}: {}", tool_icon(t));
|
||||
}
|
||||
assert_eq!(shortcut(LiquifyTool::TwirlCw), "C");
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_layer();
|
||||
open(&mut app, &ctx).unwrap();
|
||||
control(&mut app, &json!({"tool": "pucker"})).unwrap();
|
||||
let err = control(&mut app, &json!({"tool": "twirlCcw"})).unwrap_err();
|
||||
assert!(err.contains("twirlCw") && err.contains("Alt"), "{err}");
|
||||
assert_eq!(app.distort.liquify.as_ref().unwrap().opts.tool, LiquifyTool::Pucker, "the tool stays");
|
||||
// A preference saved by an older version that names it opens, and on another tool.
|
||||
cancel(&mut app);
|
||||
app.session.prefs.edit(|p| p.dialogs.insert(REMEMBERED.into(), json!({"tool": "twirlCcw", "density": 70})));
|
||||
open(&mut app, &ctx).unwrap();
|
||||
let o = &app.distort.liquify.as_ref().unwrap().opts;
|
||||
assert_ne!(o.tool, LiquifyTool::TwirlCcw);
|
||||
assert_eq!(o.density, 70.0, "the rest of the saved settings still apply");
|
||||
}
|
||||
|
||||
/// Alt held with Twirl Clockwise twirls the other way: at the same point, the displacement a
|
||||
/// dab leaves points the opposite way from one without Alt.
|
||||
#[test]
|
||||
fn alt_with_twirl_clockwise_twirls_counterclockwise() {
|
||||
let ctx = egui::Context::default();
|
||||
let displacement = |mods: egui::Modifiers| {
|
||||
let mut app = app_with_layer();
|
||||
open(&mut app, &ctx).unwrap();
|
||||
control(&mut app, &json!({"tool": "twirlCw", "size": 40, "rate": 80})).unwrap();
|
||||
pointer(&mut app, ToolEvent::Down { x: 60.0, y: 40.0, pressure: 1.0 }, mods);
|
||||
for _ in 0..4 {
|
||||
pointer(&mut app, ToolEvent::Move { x: 60.0, y: 40.0, pressure: 1.0 }, mods);
|
||||
}
|
||||
pointer(&mut app, ToolEvent::Up { x: 60.0, y: 40.0 }, mods);
|
||||
let d = app.distort.liquify.as_ref().unwrap();
|
||||
assert_eq!(d.opts.tool, LiquifyTool::TwirlCw);
|
||||
// Right of the centre: clockwise (y down) moves content down, so it samples from above.
|
||||
d.field.sample(70.0, 40.0)
|
||||
};
|
||||
let (plain, alt) = (displacement(egui::Modifiers::NONE), displacement(egui::Modifiers::ALT));
|
||||
assert!(plain[1] < -0.1, "clockwise samples from above: {plain:?}");
|
||||
assert!(alt[1] > 0.1, "Alt twirls counterclockwise and samples from below: {alt:?}");
|
||||
assert!((plain[1] + alt[1]).abs() < 0.05 * plain[1].abs(), "the same twirl, mirrored: {plain:?} vs {alt:?}");
|
||||
}
|
||||
|
||||
/// Follow-up to #1494 / #1484: Alt is captured at stroke-down and stays recorded
|
||||
/// through undo, redo and deterministic Liquify field reconstruction.
|
||||
#[test]
|
||||
fn alt_reversed_twirl_survives_replay_undo_and_redo() {
|
||||
let ctx = egui::Context::default();
|
||||
@@ -374,7 +415,6 @@ fn alt_reverses_twirl_and_swaps_pucker_and_bloat() {
|
||||
};
|
||||
for (tool, plain, alt) in [
|
||||
("twirlCw", LiquifyTool::TwirlCw, LiquifyTool::TwirlCcw),
|
||||
("twirlCcw", LiquifyTool::TwirlCcw, LiquifyTool::TwirlCw),
|
||||
("pucker", LiquifyTool::Pucker, LiquifyTool::Bloat),
|
||||
("bloat", LiquifyTool::Bloat, LiquifyTool::Pucker),
|
||||
("forwardWarp", LiquifyTool::ForwardWarp, LiquifyTool::ForwardWarp),
|
||||
|
||||
@@ -3555,3 +3555,4 @@ curves Smooth تنعيم
|
||||
|
||||
The path was kept as the Work Path تم الاحتفاظ بالمسار كمسار عمل
|
||||
The path was discarded تم تجاهل المسار
|
||||
Edit Brush… تحرير الفرشاة…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4259,3 +4259,4 @@ cameraRaw Dehaze Odstranění oparu
|
||||
Select and Mask was closed: its document is no longer the active one Vybrat a maskovat bylo zavřeno: jeho dokument již není aktivní
|
||||
Select and Mask was closed: another layer was selected Vybrat a maskovat bylo zavřeno: byla vybrána jiná vrstva
|
||||
Drag to move the selected path Tažením přesunete vybranou cestu
|
||||
Edit Brush… Upravit štětec…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4779,3 +4779,4 @@ cameraRaw White Balance: As Shot Hvidbalance: Som fotograferet
|
||||
Path operations Stihandlinger
|
||||
The path was discarded Stien blev kasseret
|
||||
The path was kept as the Work Path Stien blev bevaret som arbejdssti
|
||||
Edit Brush… Rediger pensel…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3532,3 +3532,4 @@ curves Smooth Glätten
|
||||
Refine Hair needs hair detection, which isn't available yet Haare verbessern benötigt eine Haarerkennung, die noch nicht verfügbar ist
|
||||
Select and Mask was closed: its document is no longer the active one Auswählen und maskieren wurde geschlossen: Das zugehörige Dokument ist nicht mehr aktiv
|
||||
Select and Mask was closed: another layer was selected Auswählen und maskieren wurde geschlossen: Es wurde eine andere Ebene ausgewählt
|
||||
Edit Brush… Pinsel bearbeiten…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4213,3 +4213,4 @@ curves Smooth Εξομάλυνση
|
||||
Select and Mask was closed: its document is no longer the active one Η Επιλογή και μάσκα έκλεισε: το έγγραφό της δεν είναι πλέον το ενεργό
|
||||
Select and Mask was closed: another layer was selected Η Επιλογή και μάσκα έκλεισε: επιλέχθηκε άλλο επίπεδο
|
||||
Select Layer Under Cursor Επιλογή επιπέδου κάτω από τον δρομέα
|
||||
Edit Brush… Επεξεργασία πινέλου…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 47 and column 53.
|
@@ -4425,3 +4425,4 @@ navigator Zoom Zoom
|
||||
Select and Mask was closed: another layer was selected Se cerró Seleccionar y aplicar máscara: se seleccionó otra capa
|
||||
Select Layer Under Cursor Seleccionar capa bajo el cursor
|
||||
Drag to move the selected path Arrastre para mover el trazado seleccionado
|
||||
Edit Brush… Editar pincel…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -4780,3 +4780,4 @@ cameraRaw White Balance: As Shot Valkotasapaino: kuvauksen mukainen
|
||||
Path operations Polkutoiminnot
|
||||
The path was discarded Polku hylättiin
|
||||
The path was kept as the Work Path Polku säilytettiin työpolkuna
|
||||
Edit Brush… Muokkaa sivellintä…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4549,3 +4549,4 @@ shape Appearance Apparence
|
||||
Refine Hair needs hair detection, which isn't available yet Améliorer les cheveux nécessite une détection des cheveux, pas encore disponible
|
||||
Select and Mask was closed: its document is no longer the active one Sélectionner et masquer a été fermé : son document n’est plus le document actif
|
||||
Select and Mask was closed: another layer was selected Sélectionner et masquer a été fermé : un autre calque a été sélectionné
|
||||
Edit Brush… Modifier le pinceau…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -584,3 +584,4 @@ cameraRaw Yellow पीला
|
||||
Refine Hair needs hair detection, which isn't available yet बाल परिशोधित करने के लिए बालों की पहचान चाहिए, जो अभी उपलब्ध नहीं है
|
||||
Select and Mask was closed: its document is no longer the active one चयन और मास्क बंद कर दिया गया: उसका दस्तावेज़ अब सक्रिय नहीं है
|
||||
Select and Mask was closed: another layer was selected चयन और मास्क बंद कर दिया गया: दूसरी लेयर चुनी गई
|
||||
Edit Brush… ब्रश संपादित करें…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4424,3 +4424,4 @@ cameraRaw Yellow Sárga
|
||||
Select and Mask was closed: its document is no longer the active one A Kijelölés és maszk bezárult: a dokumentuma már nem az aktív
|
||||
Select and Mask was closed: another layer was selected A Kijelölés és maszk bezárult: másik réteg lett kijelölve
|
||||
Drag to move the selected path Húzza a kijelölt útvonal mozgatásához
|
||||
Edit Brush… Ecset szerkesztése…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3533,3 +3533,4 @@ curves Smooth Haluskan
|
||||
Select and Mask was closed: its document is no longer the active one Seleksi dan Mask ditutup: dokumennya tidak lagi aktif
|
||||
Select and Mask was closed: another layer was selected Seleksi dan Mask ditutup: layer lain dipilih
|
||||
Select Layer Under Cursor Pilih Lapisan di Bawah Kursor
|
||||
Edit Brush… Edit Kuas…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -4625,3 +4625,4 @@ shape Appearance Aspetto
|
||||
Refine Hair needs hair detection, which isn't available yet Migliora capelli richiede il rilevamento dei capelli, non ancora disponibile
|
||||
Select and Mask was closed: its document is no longer the active one Seleziona e maschera è stato chiuso: il suo documento non è più quello attivo
|
||||
Select and Mask was closed: another layer was selected Seleziona e maschera è stato chiuso: è stato selezionato un altro livello
|
||||
Edit Brush… Modifica pennello…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 79 and column 53.
|
@@ -4189,3 +4189,4 @@ curves Smooth スムーズ
|
||||
Select and Mask was closed: its document is no longer the active one 「選択とマスク」を閉じました: 対象のドキュメントがアクティブではなくなりました
|
||||
Select and Mask was closed: another layer was selected 「選択とマスク」を閉じました: 別のレイヤーが選択されました
|
||||
Drag to move the selected path ドラッグして選択したパスを移動
|
||||
Edit Brush… ブラシを編集…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3732,3 +3732,4 @@ curves Smooth 매끄럽게
|
||||
Select and Mask was closed: another layer was selected 선택 및 마스크가 닫혔습니다: 다른 레이어가 선택되었습니다
|
||||
Select Layer Under Cursor 커서 아래 레이어 선택
|
||||
Drag to move the selected path 드래그하여 선택한 패스 이동
|
||||
Edit Brush… 브러시 편집…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 63 and column 53.
|
@@ -4779,3 +4779,4 @@ cameraRaw White Balance: As Shot Hvitbalanse: som fotografert
|
||||
Path operations Baneoperasjoner
|
||||
The path was discarded Banen ble forkastet
|
||||
The path was kept as the Work Path Banen ble beholdt som arbeidsbane
|
||||
Edit Brush… Rediger pensel…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4270,3 +4270,4 @@ curves Smooth Vloeiend maken
|
||||
Select and Mask was closed: its document is no longer the active one Selecteren en maskeren is gesloten: het document is niet meer het actieve document
|
||||
Select and Mask was closed: another layer was selected Selecteren en maskeren is gesloten: er is een andere laag geselecteerd
|
||||
Select Layer Under Cursor Laag onder cursor selecteren
|
||||
Edit Brush… Penseel bewerken…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -4131,3 +4131,4 @@ curves Smooth Wygładź
|
||||
Select and Mask was closed: its document is no longer the active one Zamknięto Zaznacz i maskuj: jego dokument nie jest już aktywny
|
||||
Select and Mask was closed: another layer was selected Zamknięto Zaznacz i maskuj: wybrano inną warstwę
|
||||
Select Layer Under Cursor Zaznacz warstwę pod kursorem
|
||||
Edit Brush… Edytuj pędzel…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4440,3 +4440,4 @@ layerLabel No Color Sem cor
|
||||
Select and Mask was closed: its document is no longer the active one Selecionar e mascarar foi fechado: o documento dele não está mais ativo
|
||||
Select and Mask was closed: another layer was selected Selecionar e mascarar foi fechado: outra camada foi selecionada
|
||||
Drag to move the selected path Arraste para mover o demarcador selecionado
|
||||
Edit Brush… Editar pincel…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4710,3 +4710,4 @@ shape Appearance Оформление
|
||||
Refine Hair needs hair detection, which isn't available yet Для уточнения волос нужно распознавание волос, которое пока недоступно
|
||||
Select and Mask was closed: its document is no longer the active one Окно «Выделение и маска» закрыто: его документ больше не активен
|
||||
Select and Mask was closed: another layer was selected Окно «Выделение и маска» закрыто: выбран другой слой
|
||||
Edit Brush… Изменить кисть…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4583,3 +4583,4 @@ shape Appearance Izgled
|
||||
Select and Mask was closed: another layer was selected Izaberi i maskiraj je zatvoren: izabran je drugi sloj
|
||||
Select Layer Under Cursor Izaberi sloj ispod kursora
|
||||
Drag to move the selected path Prevucite da pomerite izabranu putanju
|
||||
Edit Brush… Uredi četkicu…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4777,3 +4777,4 @@ cameraRaw White Balance: As Shot Vitbalans: som tagen
|
||||
Path operations Banåtgärder
|
||||
The path was discarded Banan kasserades
|
||||
The path was kept as the Work Path Banan behölls som arbetsbana
|
||||
Edit Brush… Redigera pensel…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4238,3 +4238,4 @@ cameraData Lens Lens
|
||||
Current Layer: Geçerli Katman:
|
||||
Underlying Layer: Alttaki Katman:
|
||||
Drag to move the selected path Seçili yolu taşımak için sürükleyin
|
||||
Edit Brush… Fırçayı Düzenle…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -4223,3 +4223,4 @@ curves Smooth Згладити
|
||||
Refine Hair needs hair detection, which isn't available yet Для уточнення волосся потрібне розпізнавання волосся, яке ще недоступне
|
||||
Select and Mask was closed: its document is no longer the active one Вікно «Виділити та маскувати» закрито: його документ більше не активний
|
||||
Select and Mask was closed: another layer was selected Вікно «Виділити та маскувати» закрито: вибрано інший шар
|
||||
Edit Brush… Редагувати пензель…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 152 and column 53.
|
@@ -4010,3 +4010,4 @@ curves Smooth Làm mượt
|
||||
Select and Mask was closed: its document is no longer the active one Đã đóng Chọn và Mặt nạ: tài liệu của nó không còn là tài liệu đang hoạt động
|
||||
Select and Mask was closed: another layer was selected Đã đóng Chọn và Mặt nạ: một lớp khác đã được chọn
|
||||
Select Layer Under Cursor Chọn lớp dưới con trỏ
|
||||
Edit Brush… Chỉnh sửa cọ…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3195,3 +3195,4 @@ curves Smooth 平滑
|
||||
Select and Mask was closed: its document is no longer the active one 已关闭“选择与蒙版”:其文档不再是当前文档
|
||||
Select and Mask was closed: another layer was selected 已关闭“选择与蒙版”:已选择另一个图层
|
||||
Drag to move the selected path 拖动以移动选中的路径
|
||||
Edit Brush… 编辑画笔…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 63 and column 53.
|
@@ -3151,3 +3151,4 @@ curves Smooth 平滑
|
||||
Select and Mask was closed: its document is no longer the active one 已關閉「選取並遮住」:其文件已不是使用中的文件
|
||||
Select and Mask was closed: another layer was selected 已關閉「選取並遮住」:已選取另一個圖層
|
||||
Drag to move the selected path 拖曳以移動選取的路徑
|
||||
Edit Brush… 編輯筆刷…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -127,7 +127,6 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
||||
("rectangle-horizontal", include_bytes!("../../../assets/icons/rectangle-horizontal.svg")),
|
||||
("rectangle-vertical", include_bytes!("../../../assets/icons/rectangle-vertical.svg")),
|
||||
("redo-2", include_bytes!("../../../assets/icons/redo-2.svg")),
|
||||
("rotate-ccw", include_bytes!("../../../assets/icons/rotate-ccw.svg")),
|
||||
("rotate-cw", include_bytes!("../../../assets/icons/rotate-cw.svg")),
|
||||
("ruler", include_bytes!("../../../assets/icons/ruler.svg")),
|
||||
("sampler", include_bytes!("../../../assets/icons/sampler.svg")),
|
||||
|
||||
@@ -210,7 +210,7 @@ fn set_tool_mode(app: &mut PhotocraftApp, mode: Option<BlendMode>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_gradient_mode(app: &mut PhotocraftApp, mode: BlendMode) {
|
||||
pub(crate) fn set_gradient_mode(app: &mut PhotocraftApp, mode: BlendMode) {
|
||||
let before = app.ui.tool_options.clone();
|
||||
app.ui.tool_options.gradient_blend_mode = mode;
|
||||
// Live mode: a selected gradient fill layer follows, as with the dropdown.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Window › Brush Settings (F5) and Window › Brushes: Photoshop's floating brush panel.
|
||||
//!
|
||||
//! Brush Settings: the section list with enable boxes on the left (selecting a controls section
|
||||
//! shows its options without enabling it; the last five rows only toggle), the selected section's controls on the right (see [`crate::brush_sections`]),
|
||||
//! Brush Settings: the section list with enable boxes on the left (clicking a controls section
|
||||
//! shows and enables it; the last five rows only toggle), the selected section's controls on the right (see [`crate::brush_sections`]),
|
||||
//! and a live stroke preview strip below, rendered with the real brush engine and cached on the
|
||||
//! settings (it re-renders only when they change). Brushes: the presets in collapsible groups with
|
||||
//! tip thumbnails and stroke previews, a size slider and a search field.
|
||||
@@ -242,7 +242,7 @@ pub(crate) fn full_uv() -> egui::Rect {
|
||||
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0))
|
||||
}
|
||||
|
||||
/// Selecting a controls section shows its settings without enabling it; the final five rows toggle, keeping
|
||||
/// Clicking a controls section shows and enables it; the final five rows only toggle, keeping
|
||||
/// the current controls open. The lock on the right is a separate hit target.
|
||||
fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -284,7 +284,6 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||||
}
|
||||
let mut x = r.left() + 6.0;
|
||||
let mut box_clicked = false;
|
||||
if *has_box && let Some(flag) = section_flag(b, i) {
|
||||
let br = egui::Rect::from_center_size(egui::pos2(x + 6.0, r.center().y), vec2(13.0, 13.0));
|
||||
// A checkbox-only row is one accessible checkbox across its whole width. A
|
||||
@@ -297,14 +296,15 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
|
||||
resp
|
||||
});
|
||||
let on_lock = resp.interact_pointer_pos().is_some_and(|p| p.x > r.right() - 22.0);
|
||||
// For controls sections, only the checkbox toggles the setting; a row
|
||||
// click selects the settings without enabling them (#5136).
|
||||
let pressed = box_resp.as_ref().is_some_and(|b| b.is_pointer_button_down_on()) || (toggle_only && resp.is_pointer_button_down_on() && !on_lock);
|
||||
// A press on the row (but not its lock) presses the box too: a release there
|
||||
// turns the section on, or toggles a checkbox-only row (#4476).
|
||||
let pressed = box_resp.as_ref().is_some_and(|b| b.is_pointer_button_down_on()) || (resp.is_pointer_button_down_on() && !on_lock);
|
||||
let hovered = box_resp.as_ref().is_some_and(|b| b.hovered()) || (toggle_only && resp.hovered());
|
||||
widgets::check_box(ui.painter(), br, *flag, hovered, pressed, &t);
|
||||
box_clicked = box_resp.as_ref().is_some_and(|resp| resp.clicked());
|
||||
if box_clicked || (toggle_only && resp.clicked() && !on_lock) {
|
||||
if box_resp.is_some_and(|resp| resp.clicked()) || (toggle_only && resp.clicked() && !on_lock) {
|
||||
*flag = !*flag;
|
||||
} else if !toggle_only && resp.clicked() && !on_lock {
|
||||
*flag = true;
|
||||
}
|
||||
x += 20.0;
|
||||
} else {
|
||||
@@ -338,7 +338,7 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
|
||||
lock_clicked = true;
|
||||
}
|
||||
}
|
||||
if !toggle_only && resp.clicked() && !lock_clicked && !box_clicked {
|
||||
if !toggle_only && resp.clicked() && !lock_clicked {
|
||||
app.ui.brush_section = i;
|
||||
}
|
||||
if i == 0 {
|
||||
|
||||
@@ -31,48 +31,6 @@ fn sections_with_boxes_have_flags() {
|
||||
assert!(b.texture.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_a_controls_section_keeps_its_enabled_flag_and_brush_history() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
for (section, name) in [(1, "Shape Dynamics"), (2, "Scattering"), (3, "Texture")] {
|
||||
let mut h = harness(0, 0);
|
||||
let before = h.state().session.tools.brush.clone();
|
||||
let journal = h.state().session.journal.len();
|
||||
|
||||
h.get_by_label(name).click();
|
||||
h.run_steps(3);
|
||||
|
||||
assert_eq!(h.state().ui.brush_section, section, "{name} settings are visible");
|
||||
assert_eq!(h.state().session.tools.brush, before, "{name} remains disabled");
|
||||
assert_eq!(h.state().session.journal.len(), journal, "selecting {name} is not a brush edit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_section_checkbox_toggles_without_selecting_its_settings() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
let mut h = harness(0, 0);
|
||||
let row = h.get_by_label("Scattering").rect();
|
||||
let checkbox = egui::pos2(row.left() + 12.0, row.center().y);
|
||||
let journal = h.state().session.journal.len();
|
||||
|
||||
for enabled in [true, false] {
|
||||
h.hover_at(checkbox);
|
||||
h.run_steps(1);
|
||||
h.drag_at(checkbox);
|
||||
h.run_steps(1);
|
||||
h.drop_at(checkbox);
|
||||
h.run_steps(3);
|
||||
|
||||
assert_eq!(h.state().session.tools.brush.scattering.enabled, enabled);
|
||||
assert_eq!(h.state().ui.brush_section, 0, "the checkbox does not select Scattering");
|
||||
assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"scattering": {"enabled": enabled}}));
|
||||
}
|
||||
assert_eq!(h.state().session.journal.len(), journal + 2);
|
||||
}
|
||||
|
||||
/// What each section's controls edit, set to non-default values.
|
||||
fn edit_section(i: usize, b: &mut BrushSettings) {
|
||||
let dynamic = |jitter: f32, control: Control, minimum: f32| paint::Dynamic { jitter, control, fade_steps: 40, minimum, ..Default::default() };
|
||||
@@ -1466,20 +1424,22 @@ fn f5_edits_the_default_brush_with_every_section() {
|
||||
for (name, _) in SECTIONS {
|
||||
assert!(h.query_all_by_label(name).next().is_some(), "{name} listed");
|
||||
}
|
||||
// Clicking a section's name shows its settings without changing its enable flag.
|
||||
// Clicking a section's name shows it and turns it on: one tools.setBrush.
|
||||
let n = h.state().session.journal.len();
|
||||
let shape_enabled = h.state().session.tools.brush.shape_dynamics.enabled;
|
||||
h.get_by_label("Shape Dynamics").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().ui.brush_section, 1);
|
||||
assert_eq!(h.state().session.tools.brush.shape_dynamics.enabled, shape_enabled);
|
||||
assert_eq!(h.state().session.journal.len(), n);
|
||||
assert!(h.state().session.tools.brush.shape_dynamics.enabled);
|
||||
assert_eq!(h.state().session.journal.len(), n + 1);
|
||||
let (id, p) = last_journal(h.state()).unwrap();
|
||||
assert_eq!(id, "tools.setBrush");
|
||||
assert_eq!(p["brush"]["shapeDynamics"]["enabled"], json!(true));
|
||||
// A section's box toggles it without showing it.
|
||||
h.get_by_label("Enable Transfer").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().session.tools.brush.transfer.enabled);
|
||||
assert_eq!(h.state().ui.brush_section, 1);
|
||||
assert_eq!(h.state().session.journal.len(), n + 1);
|
||||
assert_eq!(h.state().session.journal.len(), n + 2);
|
||||
// The rest of the brush is untouched.
|
||||
let b = &h.state().session.tools.brush;
|
||||
assert_eq!((b.size, b.hardness, &b.tip), (fresh.size, fresh.hardness, &fresh.tip));
|
||||
@@ -1847,20 +1807,12 @@ fn section_boxes_show_a_pressed_state() {
|
||||
let row = h.get_by_label("Shape Dynamics").rect();
|
||||
assert!(!h.state().session.tools.brush.shape_dynamics.enabled);
|
||||
assert_eq!(box_fill(&h, boxr), Some(widgets::check_box_fill(false, false, &t)));
|
||||
// Selecting the controls row leaves its checkbox untouched, including while held.
|
||||
// Held on the row's name: the unchecked box darkens; the release turns the section on.
|
||||
let at = egui::pos2(row.center().x, row.center().y);
|
||||
h.drag_at(at);
|
||||
h.run_steps(2);
|
||||
assert_eq!(box_fill(&h, boxr), Some(widgets::check_box_fill(false, false, &t)), "row press leaves checkbox untouched");
|
||||
h.drop_at(at);
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().session.tools.brush.shape_dynamics.enabled);
|
||||
assert_eq!(box_fill(&h, boxr), Some(widgets::check_box_fill(false, false, &t)));
|
||||
// The checkbox itself still shows its pressed state and toggles the section.
|
||||
h.drag_at(boxr.center());
|
||||
h.run_steps(2);
|
||||
assert_eq!(box_fill(&h, boxr), Some(widgets::check_box_fill(false, true, &t)), "pressed, unchecked");
|
||||
h.drop_at(boxr.center());
|
||||
h.drop_at(at);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().session.tools.brush.shape_dynamics.enabled);
|
||||
assert_eq!(box_fill(&h, boxr), Some(widgets::check_box_fill(true, false, &t)));
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Photoshop's HUD Color Picker (#6098): with a painting tool, Shift+Alt+right-click on the
|
||||
//! canvas opens a floating hue strip and saturation/value square anchored under the pointer.
|
||||
//! canvas opens a floating saturation/value square with a hue strip to its right, placed so the
|
||||
//! current colour sits under the pointer (as the reference application does).
|
||||
//! While the right button stays down, moving the pointer over the HUD picks the foreground colour
|
||||
//! live (`tools.setColors` calls sharing one `coalesce` key, so the gesture is one history step,
|
||||
//! Rule 1); releasing the button keeps the colour and closes the HUD, Escape restores the colour
|
||||
//! the gesture started from. A left press closes it too, which is how the control channel ends a
|
||||
//! gesture it opened with `ui.pointer`.
|
||||
//! Rule 1); releasing the button or pressing Escape closes the HUD and keeps the colour picked so
|
||||
//! far, as in the reference application (Undo takes it back). A left press closes it too, which is
|
||||
//! how the control channel ends a gesture it opened with `ui.pointer`.
|
||||
//!
|
||||
//! The HUD is drawn as plain meshes and hit-tested from the raw pointer position rather than with
|
||||
//! interactive widgets: the selection follows the pointer while the *right* button is held, which
|
||||
@@ -26,19 +27,32 @@ use crate::state::UiState;
|
||||
const HUE_W: f32 = 16.0;
|
||||
/// Side of the saturation/value square, and the strip's height.
|
||||
const SQUARE: f32 = 128.0;
|
||||
/// Gap between the strip and the square.
|
||||
/// Gap between the square and the strip.
|
||||
const GAP: f32 = 8.0;
|
||||
/// The HUD's content size (the frame's chrome sits around it).
|
||||
pub const SIZE: [f32; 2] = [HUE_W + GAP + SQUARE, SQUARE];
|
||||
pub const SIZE: [f32; 2] = [SQUARE + GAP + HUE_W, SQUARE];
|
||||
|
||||
/// The square (left) and the hue strip (right) inside the HUD's content rect.
|
||||
fn layout(content: Rect) -> (Rect, Rect) {
|
||||
let square = Rect::from_min_size(content.min, vec2(SQUARE, SQUARE));
|
||||
let strip = Rect::from_min_size(content.min + vec2(SQUARE + GAP, 0.0), vec2(HUE_W, SQUARE));
|
||||
(strip, square)
|
||||
}
|
||||
|
||||
/// Where the current colour (saturation, value) sits inside the content rect, from its top left.
|
||||
fn colour_offset(hsv: [f32; 3]) -> Vec2 {
|
||||
let f = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
vec2(f(hsv[1]) * SQUARE, (1.0 - f(hsv[2])) * SQUARE)
|
||||
}
|
||||
|
||||
/// The HUD Color Picker while it is open.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Hud {
|
||||
/// Where the gesture began, in screen points: the HUD anchors just below it.
|
||||
/// Where the gesture began, in screen points: the HUD opens with the starting colour here.
|
||||
pub anchor: [f32; 2],
|
||||
/// The colour being picked: hue 0..360, saturation and value 0..1.
|
||||
pub hsv: [f32; 3],
|
||||
/// The foreground colour when the gesture began; Escape restores it.
|
||||
/// The foreground colour when the gesture began: where the HUD opens, and the alpha it keeps.
|
||||
pub start: [f32; 4],
|
||||
/// The `coalesce` key of this gesture's `tools.setColors` calls.
|
||||
pub key: u64,
|
||||
@@ -162,31 +176,25 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
i.pointer.hover_pos(),
|
||||
)
|
||||
});
|
||||
if esc {
|
||||
let start = hud.start;
|
||||
let changed = app.session.tools.foreground != start;
|
||||
close(&mut app.ui);
|
||||
if changed {
|
||||
set_foreground(app, start, hud.key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (hud.hold && released) || pressed_left {
|
||||
if esc || (hud.hold && released) || pressed_left {
|
||||
close(&mut app.ui);
|
||||
return;
|
||||
}
|
||||
// Keep the HUD on screen: anchored just under the press point, pulled in at the edges.
|
||||
// The starting colour's spot in the square sits under the press point, as in the reference
|
||||
// application; near a screen edge the HUD is pulled in so it stays whole.
|
||||
let screen = ctx.content_rect();
|
||||
let style = ctx.global_style();
|
||||
let size = Vec2::from(SIZE) + style.spacing.menu_margin.sum() + egui::Vec2::splat(2.0 * style.visuals.window_stroke().width);
|
||||
let at = egui::pos2(hud.anchor[0], hud.anchor[1] + 4.0);
|
||||
let m = style.spacing.menu_margin;
|
||||
let stroke = style.visuals.window_stroke().width;
|
||||
let size = Vec2::from(SIZE) + m.sum() + egui::Vec2::splat(2.0 * stroke);
|
||||
let start = rgb_to_hsb([hud.start[0], hud.start[1], hud.start[2]]);
|
||||
let at = egui::pos2(hud.anchor[0], hud.anchor[1]) - colour_offset(start) - vec2(f32::from(m.left) + stroke, f32::from(m.top) + stroke);
|
||||
let pos = egui::pos2(at.x.min(screen.right() - size.x).max(screen.left()), at.y.min(screen.bottom() - size.y).max(screen.top()));
|
||||
let area = egui::Area::new(hud_id()).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style())
|
||||
.show(ui, |ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(SIZE[0], SIZE[1]), egui::Sense::hover());
|
||||
let strip = Rect::from_min_size(rect.min, vec2(HUE_W, SQUARE));
|
||||
let square = Rect::from_min_max(rect.min + vec2(HUE_W + GAP, 0.0), rect.max);
|
||||
let (strip, square) = layout(rect);
|
||||
let p = ui.painter();
|
||||
paint_hue_strip(p, strip);
|
||||
paint_sv_square(p, square, hud.hsv[0]);
|
||||
@@ -203,9 +211,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
.inner
|
||||
});
|
||||
let (strip, square) = area.inner;
|
||||
// Near a screen edge the clamped HUD can appear under the stationary pointer: picking starts
|
||||
// only once the pointer has moved from where the gesture began, so the opening press alone
|
||||
// never changes the colour.
|
||||
// The HUD opens under the stationary pointer: picking starts only once the pointer has moved
|
||||
// from where the gesture began, so the opening press alone never changes the colour.
|
||||
if hud.just_opened && hover.is_some_and(|p| p != egui::pos2(hud.anchor[0], hud.anchor[1])) {
|
||||
hud.just_opened = false;
|
||||
}
|
||||
@@ -285,8 +292,7 @@ mod tests {
|
||||
let w = style.visuals.window_stroke().width;
|
||||
let inner =
|
||||
Rect::from_min_max(area.min + vec2(f32::from(m.left) + w, f32::from(m.top) + w), area.max - vec2(f32::from(m.right) + w, f32::from(m.bottom) + w));
|
||||
let strip = Rect::from_min_size(inner.min, vec2(HUE_W, SQUARE));
|
||||
(strip, Rect::from_min_max(inner.min + vec2(HUE_W + GAP, 0.0), inner.max))
|
||||
layout(inner)
|
||||
}
|
||||
|
||||
fn foreground(h: &Harness<'static, PhotocraftApp>) -> [f32; 4] {
|
||||
@@ -304,10 +310,13 @@ mod tests {
|
||||
assert!(hud.hold);
|
||||
assert_eq!(hud.start, [0.0, 0.0, 1.0, 1.0], "seeded from the foreground");
|
||||
assert_eq!(h.state().ui.brush_picker, None, "the Brush Preset picker stays closed");
|
||||
// The HUD shows under the pointer, on screen.
|
||||
// The square is left of the strip, and the pointer sits on the current colour: blue is
|
||||
// full saturation and value, the square's top-right corner.
|
||||
let (strip, square) = rects(&h);
|
||||
assert!(strip.height() > 100.0 && square.width() > 100.0);
|
||||
assert!(square.right() < h.ctx.content_rect().right());
|
||||
assert!(square.right() <= strip.left(), "square {square:?} left of strip {strip:?}");
|
||||
assert!(strip.right() < h.ctx.content_rect().right());
|
||||
assert!((square.right_top() - c).length() < 1.0, "the current colour {:?} is under the pointer {c:?}", square.right_top());
|
||||
// Nothing painted.
|
||||
assert!(!h.state().session.journal.iter().any(|(id, _)| id == "paint.stroke"));
|
||||
}
|
||||
@@ -330,26 +339,43 @@ mod tests {
|
||||
assert!(h.state().ui.canvas_tool_menu.is_some(), "the selection menu opens, once");
|
||||
}
|
||||
|
||||
/// A press near the bottom-right edge clamps the HUD under the still pointer: the opening
|
||||
/// press alone must not pick — the colour changes only once the pointer moves (#6098).
|
||||
/// The HUD opens with the current colour under the still pointer: the opening press alone
|
||||
/// must not pick, the colour changes only once the pointer moves (#6098). Near the
|
||||
/// bottom-right edge the HUD is pulled in to stay on screen, and still picks nothing.
|
||||
#[test]
|
||||
fn a_hud_clamped_under_the_still_pointer_picks_nothing_until_it_moves() {
|
||||
fn the_opening_press_picks_nothing_until_the_pointer_moves() {
|
||||
let mut h = harness_size(1200, 800);
|
||||
let near = egui::pos2(h.state().last_canvas_rect.max.x - 10.0, h.state().last_canvas_rect.max.y - 10.0);
|
||||
h.event(egui::Event::PointerMoved(near));
|
||||
h.state_mut().run("tools.setColors", json!({"foreground": [0.4, 0.3, 0.6, 1.0]})).unwrap();
|
||||
let start = foreground(&h);
|
||||
let c = h.state().last_canvas_rect.center();
|
||||
h.event(egui::Event::PointerMoved(c));
|
||||
h.run_steps(1);
|
||||
button(&mut h, near, true, SHIFT_ALT);
|
||||
assert!(h.state().ui.color_hud.is_some_and(|hud| hud.just_opened), "the HUD opened under the pointer");
|
||||
button(&mut h, c, true, SHIFT_ALT);
|
||||
assert!(h.state().ui.color_hud.is_some_and(|hud| hud.just_opened), "the HUD opened");
|
||||
let (_strip, square) = rects(&h);
|
||||
assert!(square.contains(near), "the clamped HUD sits under the pointer: {near:?} in {square:?}");
|
||||
let hsv = rgb_to_hsb([start[0], start[1], start[2]]);
|
||||
let spot = square.min + colour_offset([hsv[0], hsv[1], hsv[2]]);
|
||||
assert!(square.contains(c) && (spot - c).length() < 1.0, "the current colour {spot:?} is under the pointer {c:?}");
|
||||
h.run_steps(4);
|
||||
assert_eq!(foreground(&h), [0.0, 0.0, 1.0, 1.0], "the stationary opening pointer picks nothing");
|
||||
assert_eq!(foreground(&h), start, "the stationary opening pointer picks nothing");
|
||||
// Moving inside the HUD now picks, as during any other gesture.
|
||||
let to = square.min + vec2(1.0, square.height() - 1.0);
|
||||
h.event(egui::Event::PointerMoved(to));
|
||||
h.run_steps(2);
|
||||
assert_ne!(foreground(&h), [0.0, 0.0, 1.0, 1.0], "the move picks");
|
||||
assert_ne!(foreground(&h), start, "the move picks");
|
||||
button(&mut h, to, false, SHIFT_ALT);
|
||||
// At the bottom-right corner the HUD stays whole on screen, and the press alone picks nothing.
|
||||
let near = egui::pos2(h.state().last_canvas_rect.max.x - 10.0, h.state().last_canvas_rect.max.y - 10.0);
|
||||
let start = foreground(&h);
|
||||
h.event(egui::Event::PointerMoved(near));
|
||||
h.run_steps(1);
|
||||
button(&mut h, near, true, SHIFT_ALT);
|
||||
let (strip, square) = rects(&h);
|
||||
let screen = h.ctx.content_rect();
|
||||
assert!(screen.contains_rect(square.union(strip)), "on screen: {square:?} {strip:?} in {screen:?}");
|
||||
h.run_steps(4);
|
||||
assert_eq!(foreground(&h), start, "the press near the edge picks nothing");
|
||||
button(&mut h, near, false, SHIFT_ALT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -392,8 +418,10 @@ mod tests {
|
||||
assert_eq!(foreground(&h), kept);
|
||||
}
|
||||
|
||||
/// The reference application's Escape ends the pick where it is: the HUD closes, the colour
|
||||
/// picked so far stays, and moving afterwards changes nothing.
|
||||
#[test]
|
||||
fn escape_restores_the_starting_colour() {
|
||||
fn escape_closes_the_hud_and_keeps_the_picked_colour() {
|
||||
let mut h = harness();
|
||||
let c = h.state().last_canvas_rect.center();
|
||||
h.event(egui::Event::PointerMoved(c));
|
||||
@@ -402,11 +430,19 @@ mod tests {
|
||||
let (_strip, square) = rects(&h);
|
||||
h.event(egui::Event::PointerMoved(square.center()));
|
||||
h.run_steps(2);
|
||||
assert_ne!(foreground(&h), [0.0, 0.0, 1.0, 1.0], "the pick is live");
|
||||
let picked = foreground(&h);
|
||||
assert_ne!(picked, [0.0, 0.0, 1.0, 1.0], "the pick is live");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.color_hud, None);
|
||||
assert_eq!(foreground(&h), [0.0, 0.0, 1.0, 1.0], "Escape restores the colour");
|
||||
assert_eq!(foreground(&h), picked, "Escape keeps the picked colour");
|
||||
h.event(egui::Event::PointerMoved(square.min + vec2(2.0, 2.0)));
|
||||
h.run_steps(2);
|
||||
assert_eq!(foreground(&h), picked, "moving after Escape picks nothing");
|
||||
button(&mut h, square.min + vec2(2.0, 2.0), false, SHIFT_ALT);
|
||||
assert_eq!(h.state().ui.color_hud, None);
|
||||
assert_eq!(h.state().ui.brush_picker, None, "releasing the button afterwards opens nothing");
|
||||
assert_eq!(foreground(&h), picked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -490,13 +526,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pick_at_maps_the_rects_and_ignores_the_gaps() {
|
||||
let strip = Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(HUE_W, SQUARE));
|
||||
let square = Rect::from_min_max(vec2(HUE_W + GAP, 0.0).to_pos2(), Pos2::new(HUE_W + GAP + SQUARE, SQUARE));
|
||||
let (strip, square) = layout(Rect::from_min_size(Pos2::ZERO, Vec2::from(SIZE)));
|
||||
assert_eq!(pick_at([120.0, 0.5, 0.5], strip, square, square.center()), Some([120.0, 0.5, 0.5]));
|
||||
let p = pick_at([120.0, 0.5, 0.5], strip, square, square.left_top() + vec2(1.0, 1.0)).expect("in the square");
|
||||
assert!(p[0] == 120.0 && p[1] < 0.02 && p[2] > 0.98, "top-left is saturated and bright: {p:?}");
|
||||
assert_eq!(pick_at([0.0, 0.5, 0.5], strip, square, strip.center()), Some([180.0, 0.5, 0.5]));
|
||||
assert_eq!(pick_at([0.0, 0.5, 0.5], strip, square, Pos2::new(HUE_W + GAP / 2.0, 10.0)), None, "the gap picks nothing");
|
||||
assert_eq!(pick_at([0.0, 0.5, 0.5], strip, square, Pos2::new(SQUARE + GAP / 2.0, 10.0)), None, "the gap picks nothing");
|
||||
assert_eq!(pick_at([0.0, 0.5, 0.5], strip, square, Pos2::new(1e9, -1e9)), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,20 +405,19 @@ fn select_row_on_click(app: &mut PhotocraftApp, ui: &egui::Ui, rc: &RowCtx<'_>,
|
||||
let mask = if v.filter { "filter" } else { "layer" };
|
||||
actions.push((photocraft_engine::mask_view_cmds::ID.into(), json!({"layer": l.id.0, "mode": "off", "mask": mask})));
|
||||
}
|
||||
let adjustment = matches!(l.content, LayerContent::Adjustment(_));
|
||||
let fill_mask = matches!(l.content, LayerContent::Fill(_)) && l.mask.is_some();
|
||||
let content_less = matches!(l.content, LayerContent::Adjustment(_) | LayerContent::Fill(_));
|
||||
if on_vector {
|
||||
// The vector mask thumbnail targets the vector mask: the path tools edit it and the
|
||||
// Paths panel selects the layer's path.
|
||||
app.ui.selected_path = Some("layer".into());
|
||||
actions.push(("ui.vectorMaskTarget".into(), json!(true)));
|
||||
} else if on_mask || (fill_mask && !on_thumb) {
|
||||
} else if on_mask || (content_less && l.mask.is_some() && !on_thumb) {
|
||||
if on_mask {
|
||||
// The mask thumbnail also switches Properties to the mask (#3369).
|
||||
app.ui.properties_layer = None;
|
||||
}
|
||||
actions.push(("ui.maskTarget".into(), json!(true)));
|
||||
} else if on_thumb || adjustment || !row.primary {
|
||||
} else if on_thumb || !row.primary {
|
||||
actions.push(("ui.maskTarget".into(), json!(false)));
|
||||
}
|
||||
// ⌥-click the layer thumbnail: fit the layer on screen, as Photoshop does.
|
||||
|
||||
@@ -806,8 +806,7 @@ fn a_new_adjustment_or_fill_layer_shows_its_settings_until_the_mask_thumbnail_is
|
||||
// Clicking the row (not a thumbnail) keeps the settings.
|
||||
let row = h.get_by_label(&layer(&h, id).name).rect().center();
|
||||
click_with(&mut h, row, Modifiers::NONE);
|
||||
assert_eq!(h.state().ui.mask_target, command.starts_with("layer.newFillLayer."));
|
||||
assert!(!h.state().ui.vector_mask_target);
|
||||
assert!(h.state().ui.mask_target);
|
||||
assert!(h.query_all_by_label("Density").next().is_none(), "{command}: a row click keeps the settings");
|
||||
// The mask thumbnail switches Properties to the mask.
|
||||
let r = mask_rect(&h, id, MaskKind::Pixel);
|
||||
@@ -841,39 +840,32 @@ fn a_new_adjustment_layer_brings_properties_to_the_front() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjustment_names_target_contents_instead_of_masks() {
|
||||
fn selecting_an_adjustment_layer_from_another_layer_targets_its_mask() {
|
||||
// The reference application: an adjustment layer picked by its name targets its mask; a
|
||||
// masked pixel layer targets its contents; the content thumbnail still targets contents.
|
||||
for command in ["layer.newAdjustmentLayer.hueSaturation", "layer.newAdjustmentLayer.curves"] {
|
||||
for ppp in [1.0, 2.0] {
|
||||
let (mut s, _, other) = session();
|
||||
s.execute(command, json!({})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap().0;
|
||||
let path = json!({"subpaths": [{"knots": [[20, 10], [120, 10], [120, 90], [20, 90]]}]});
|
||||
s.execute("layer.vectorMask.add", json!({"layer": id, "path": path})).unwrap();
|
||||
let mut h = harness(s, 0, ppp, 360.0);
|
||||
for kind in [MaskKind::Pixel, MaskKind::Vector] {
|
||||
let at = mask_rect(&h, id, kind).center();
|
||||
click_with(&mut h, at, Modifiers::NONE);
|
||||
assert_eq!(h.state().ui.mask_target, kind == MaskKind::Pixel);
|
||||
assert_eq!(h.state().ui.vector_mask_target, kind == MaskKind::Vector);
|
||||
let name = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == id).unwrap().name.unwrap().center();
|
||||
click_with(&mut h, name, Modifiers::NONE);
|
||||
assert!(!h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command} @{ppp}x after {kind:?}");
|
||||
let thumb = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == id).unwrap().thumbnail.unwrap();
|
||||
assert!(near(super::brackets(&h.ctx).unwrap(), thumb.expand(3.0)), "the content thumbnail is selected");
|
||||
assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, id);
|
||||
}
|
||||
let name = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == other).unwrap().name.unwrap().center();
|
||||
click_with(&mut h, name, Modifiers::NONE);
|
||||
let name = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == id).unwrap().name.unwrap().center();
|
||||
click_with(&mut h, name, Modifiers::NONE);
|
||||
assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, id);
|
||||
assert!(!h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command} @{ppp}x from another layer");
|
||||
assert_eq!(crate::canvas::paint_target(h.state()), json!("mask"));
|
||||
let content = layer(&h, id).content;
|
||||
h.state_mut().run("edit.fill", json!({"contents": "black"})).unwrap();
|
||||
assert_eq!(layer(&h, id).content, content, "filling must preserve the adjustment or fill content");
|
||||
assert_eq!(layer(&h, id).mask.unwrap().surface.sample_channel(50, 50, 0), 0.0, "Fill edits the mask");
|
||||
assert!(!h.state().ui.mask_target && !h.state().ui.vector_mask_target, "painting leaves the content thumbnail selected");
|
||||
}
|
||||
let (mut s, masked, other) = session();
|
||||
s.execute(command, json!({})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap().0;
|
||||
let mut h = harness(s, 0, 1.0, 360.0);
|
||||
assert!(layer(&h, id).mask.is_some(), "{command}: the adjustment layer has a mask");
|
||||
let name_of =
|
||||
|h: &Harness<'_, PhotocraftApp>, l: u64| crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == l).unwrap().name.unwrap().center();
|
||||
let at = name_of(&h, other);
|
||||
click_with(&mut h, at, Modifiers::NONE);
|
||||
let at = name_of(&h, id);
|
||||
click_with(&mut h, at, Modifiers::NONE);
|
||||
assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, id);
|
||||
assert!(h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command}: a name click targets the mask");
|
||||
assert_eq!(crate::canvas::paint_target(h.state()), json!("mask"));
|
||||
// The content thumbnail targets the contents.
|
||||
let thumb = layer_thumb_center(&h, id);
|
||||
click_with(&mut h, thumb, Modifiers::NONE);
|
||||
assert!(!h.state().ui.mask_target, "{command}: the content thumbnail targets contents");
|
||||
// A masked pixel layer picked by its name targets its contents.
|
||||
let at = name_of(&h, masked);
|
||||
click_with(&mut h, at, Modifiers::NONE);
|
||||
assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, masked);
|
||||
assert!(!h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command}: a pixel layer targets its contents");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,9 +284,13 @@ pub struct UiState {
|
||||
/// its screen position (points).
|
||||
#[serde(default)]
|
||||
pub brush_picker: Option<[f32; 2]>,
|
||||
/// Brush Mode menu opened by Shift+right-click: screen position in points.
|
||||
/// The painting tools' Shift+right-click menu (Edit Brush… and the tool's Mode list,
|
||||
/// `crate::brush_mode_menu`): screen position in points.
|
||||
#[serde(default)]
|
||||
pub brush_mode_menu: Option<[f32; 2]>,
|
||||
/// The tool whose Shift+right-click menu is open: another tool closes it.
|
||||
#[serde(default)]
|
||||
pub brush_mode_menu_tool: Option<Tool>,
|
||||
/// The Brush Preset picker's preset list: search, collapsed groups, view, a rename in progress.
|
||||
#[serde(default = "crate::brush_picker::list_state")]
|
||||
pub brush_picker_list: crate::brush_panel::BrushesPanelState,
|
||||
@@ -532,6 +536,7 @@ impl Default for UiState {
|
||||
properties_layer: None,
|
||||
brush_picker: None,
|
||||
brush_mode_menu: None,
|
||||
brush_mode_menu_tool: None,
|
||||
brush_picker_list: crate::brush_picker::list_state(),
|
||||
brush_picker_size: None,
|
||||
color_hud: None,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Brush and painting parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Brush Shift+right-click Mode picker and Escape dismissal; Art History Brush row added as partial, 8 of 10 styles, TOOL-213-20, #1610; hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; Mode for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; named, stateful Brush Settings locks and visible keyboard focus; independent Brush Pose tilt-axis overrides; Transfer Wetness/Mix tool applicability; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Shift+right-click menu for every painting tool: Edit Brush… and the tool's own Mode list; Brush Shift+right-click Mode picker and Escape dismissal; Art History Brush row added as partial, 8 of 10 styles, TOOL-213-20, #1610; hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; Mode for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; named, stateful Brush Settings locks and visible keyboard focus; independent Brush Pose tilt-axis overrides; Transfer Wetness/Mix tool applicability; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Painting is where Photoshop users notice differences first, so it gets its own checklist: the
|
||||
brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI
|
||||
@@ -76,7 +76,7 @@ tablets against Photoshop side by side.
|
||||
| ⇧-click draws a straight line from the last point | done | |
|
||||
| Hold `~` with the Brush or Pencil to erase with its brush: Clear mode while held (rebindable under Tools › Temporary) | done | `hold_keys.rs`, #3439 |
|
||||
| Clear painting mode in the Brush and Pencil Mode menus | done | `paint_mode_dropdown` in `panels.rs`, `"mode": "clear"`, #3439 |
|
||||
| Shift+right-click with Brush opens its Mode menu, including Clear; Alt and Command/Ctrl keep their existing gestures | done | #4929; shared options-bar choices, `tools.setBrush`, canvas/control interaction regressions and synthetic offscreen checks |
|
||||
| Shift+right-click opens the painting tool's menu: Edit Brush… and its own Mode list (Brush/Pencil painting modes with Behind and Clear, stamps and History Brush blend modes, Eraser Brush/Pencil/Block, Dodge/Burn Range, Sponge mode, Healing Brush, Gradient and Paint Bucket modes); Behind and Clear greyed on the Background; Alt and Command/Ctrl keep their existing gestures | partial | #4929, `brush_mode_menu.rs`; rows set the options-bar state (`tools.setBrush` or the tool option); kittest and control regressions. Missing because the options bar lacks the setting: Behind for the stamps, History Brush, Gradient and Paint Bucket; the Blur/Sharpen/Smudge modes; the Spot Healing modes (no menu); the Healing Brush's Sampled/Pattern source and Replace; the History Brush source |
|
||||
| Right-click opens the Brush Preset picker | done | [`context-menu-parity.md`](context-menu-parity.md); XP-Pen right-click not (#1980), macOS issue #2360 |
|
||||
| Precise / normal / full-size tip cursors, crosshair in the tip | partial | Caps Lock precise cursor not audited; round Pencil cursor requested (#2662, #1057) |
|
||||
| Brush choice kept per tool (Brush, Eraser, Blur each their own) | partial | #2542 |
|
||||
@@ -137,3 +137,4 @@ tablets against Photoshop side by side.
|
||||
| 2026-10-14 | minor | Options-bar Mode for Clone Stamp, Pattern Stamp, History Brush and Healing (kept in each tool's brush) and Paint Bucket (with Clear); each tool's first brush starts in Normal. No brush-feel estimate changed. |
|
||||
| 2026-10-13 | minor | Dragging a brush preset shows its cached tip thumbnail and name in the Brushes panel and preset picker (#4225, partial). No brush-feel estimate changed. |
|
||||
| 2026-10-23 | minor | Escape also consumes a held right-button release after a rapid second press, so the Brush Mode picker stays dismissed without painting or changing history (#4929). |
|
||||
| 2026-10-30 | minor | Shift+right-click works for every painting tool as in the reference application: Edit Brush… opens the Brush Preset picker, then the tool's own Mode list from its options bar; Behind and Clear greyed on the Background. Row marked partial for the settings the options bar lacks. No brush-feel estimate changed. |
|
||||
|
||||
@@ -4,7 +4,7 @@ Click the color swatch in the Type options bar or Character panel to open the fu
|
||||
|
||||
## HUD Color Picker
|
||||
|
||||
With a painting tool, **Shift+Alt+right-click** on the canvas opens Photoshop's HUD Color Picker under the pointer: a hue strip and a saturation/value square. While the right button stays down, moving the pointer over the HUD sets the foreground color live; releasing the button keeps it. **Esc** restores the color the gesture started from. The whole gesture is one coalesced `tools.setColors` history step. The agents' equivalent is `ui.pointer` with `"button": "right"`, `"shift": true, "alt": true` (the down opens the HUD, the up closes it).
|
||||
With a painting tool, **Shift+Alt+right-click** on the canvas opens the HUD Color Picker: a saturation/value square with a hue strip to its right, placed so the current color sits under the pointer. While the right button stays down, moving the pointer over the HUD sets the foreground color live; releasing the button keeps it. **Esc** closes the HUD and keeps the color picked so far (Undo takes it back). The whole gesture is one coalesced `tools.setColors` history step. The agents' equivalent is `ui.pointer` with `"button": "right"`, `"shift": true, "alt": true` (the down opens the HUD, the up closes it).
|
||||
|
||||
The existing canvas eyedropper continues to sample the document while the Color Picker is open, including canvas zoom and pan. This works without operating-system screen-capture permission.
|
||||
|
||||
|
||||
@@ -81,11 +81,11 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing.
|
||||
- Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type.
|
||||
- `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows
|
||||
- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. For Brush Shift+right-click, see the Mode menu exception below. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point.
|
||||
- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. For a painting tool's Shift+right-click, see the Mode menu exception below. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point.
|
||||
- Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape.
|
||||
- Image › Adjustments › Curves on top, no eyedropper armed (#7325): a `down` with `command: true` adds a point on the curve the dialog shows (its Channel popup), at the pixel's tone (RGB: Rec. 709 luma; Gray: the mean) and at the curve's current output there, so the preview does not change; with `shift: true` as well, one point on each of Red, Green and Blue at that channel's value. It samples the merged image like the eyedroppers, never moves an existing point (a point already within 2 levels is selected instead, a 16-point curve takes no more), and the tool is not driven. Nothing is added with a pencil-drawn curve, in Lab or CMYK, or with a mask, saved channel or Quick Mask targeted.
|
||||
- Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it.
|
||||
- With Brush, Shift+right-click (without Alt, Ctrl or Command) opens the Mode menu even when right-click painting is set to Erase. `ui.inspect.brushModeMenu` reports its screen position, current mode and choices. Pick a row with `ui.click`; the choice uses `tools.setBrush`. Clear has the same painting-only erase meaning as the options bar. Escape, an outside press or a tool change closes the menu without changing the brush or document.
|
||||
- With a painting tool, Shift+right-click (without Alt, Ctrl or Command) opens that tool's menu, as in the reference application, even when right-click painting is set to Erase: Edit Brush… (opens the Brush Preset picker at the click) and then the tool's own Mode list from its options bar. Brush and Pencil: Edit Brush…, the painting modes with Behind and Clear; Clone Stamp, Pattern Stamp, History Brush: Edit Brush…, the blend modes; Mixer Brush, Color Replacement, Background Eraser, Blur, Sharpen, Smudge: Edit Brush… only; Eraser: Brush / Pencil / Block; Dodge and Burn: Edit Brush…, Shadows / Midtones / Highlights; Sponge: Edit Brush…, Desaturate / Saturate; Healing Brush: its blend modes; Gradient: the blend modes; Paint Bucket: the blend modes with Clear. Other tools (Spot Healing Brush, Magic Eraser, non-painting tools) keep their plain right-click route. `ui.inspect.brushModeMenu` reports its screen position, `tool`, current `mode` (the checked row's label), `choices` (the row labels, in order) and `items` (`{label, enabled, checked}` or `{separator: true}`); Behind and Clear are disabled on the Background layer. Pick a row with `ui.click`, or with `ui.context.choose {id}` where `id` is the row's English label from `choices` (an unknown or disabled row is an error that changes nothing). A row sets what the tool's options-bar control sets: a brush-based tool's blend mode goes through `tools.setBrush`; the Eraser, Range, Sponge, Gradient and Paint Bucket rows set the tool option. Clear has the same painting-only erase meaning as the options bar. Escape, an outside press or a tool change closes the menu without changing the brush or document.
|
||||
- `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing
|
||||
|
||||
- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`. `{"key": "AltLeft"}` is a tap on Alt, which puts the keyboard on the in-window menu bar (then `ArrowRight`, `ArrowDown`, `Enter`, `Escape` or a menu's letter drive it); `{"key": "F", "alt": true}` opens the menu with that mnemonic (#4988)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
# Exact octagonal mask shifts
|
||||
|
||||
Select and Mask applies Shift Edge as flat grayscale dilation or erosion. Previously,
|
||||
> **2026-10-30:** Shift Edge no longer uses this morphology. Measured against the reference
|
||||
> application, it is a curve on each pixel's coverage (odds times `49^(shift / 100)`), so it
|
||||
> leaves hard edges in place; see `RefineParams::shift_edge`. `matting::morph` remains for the
|
||||
> Select and Mask view and Select › Modify, and the kernel numbers below still hold. The full
|
||||
> refinement comparison further down timed the old Shift Edge and is kept as history.
|
||||
|
||||
Select and Mask applied Shift Edge as flat grayscale dilation or erosion. Previously,
|
||||
`matting::morph` ran one full 3x3 pass for each radius step, alternating a square and a
|
||||
cross. A 64 px shift needed 64 image passes. The filter supports fractional coverage.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user