Merge origin/main into fix/9754-color-lookup-lut-folder (#9827)

Keep both contributors/people.toml entries.

Co-Authored-By: Luis-0508 <Luis-0508@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed 2026-11-09 19:03:37 +01:00
commit a1a122e20e
223 files changed
+13463 -1897

No files matched your search

+1 -1
View File
@@ -42,7 +42,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`, `history-brush.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`, `crop-overlay-*.svg`, `vertical-align-*.svg` (115 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/*.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`, `history-brush.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`, `crop-overlay-*.svg`, `vertical-align-*.svg` (116 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 |
Generated
+15 -28
View File
@@ -1406,17 +1406,17 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1439,7 +1439,7 @@ dependencies = [
[[package]]
name = "craft-jpeg"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"thiserror 2.0.21",
]
@@ -1457,7 +1457,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"bytemuck",
"hayro",
@@ -1469,7 +1469,7 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"craft-winprint-gdi",
"log",
@@ -1483,7 +1483,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1504,7 +1504,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"chrono",
"dunce",
@@ -1530,7 +1530,7 @@ dependencies = [
[[package]]
name = "craft-winprint-gdi"
version = "0.1.0"
source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312"
source = "git+https://github.com/storytold/winprint.rs?rev=64e7afd4d1483512385012c340ecaaaf0f3beb7f#64e7afd4d1483512385012c340ecaaaf0f3beb7f"
dependencies = [
"windows-sys 0.61.2",
]
@@ -2964,7 +2964,7 @@ dependencies = [
[[package]]
name = "hayro"
version = "0.7.1"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"bytemuck",
"hayro-interpret",
@@ -2994,7 +2994,7 @@ dependencies = [
[[package]]
name = "hayro-interpret"
version = "0.7.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"bitflags 2.13.2",
"hayro-cmap",
@@ -3012,7 +3012,7 @@ dependencies = [
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"fearless_simd",
"hayro-ccitt",
@@ -3036,7 +3036,7 @@ checksum = "bc2d80da85f31c3fdc0366431f44cfd645a9da1e242ed1f4a61f213d9765660f"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"flate2",
"hayro-ccitt",
@@ -5767,7 +5767,7 @@ dependencies = [
"photocraft-psd",
"photocraft-raster",
"rayon",
"skrifa 0.44.0",
"skrifa 0.47.0",
]
[[package]]
@@ -9473,7 +9473,7 @@ dependencies = [
[[package]]
name = "winprint"
version = "0.2.1"
source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49"
source = "git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4#51934db382f225454cf152a8657eb03789be63c4"
dependencies = [
"flate2",
"fmt-derive",
@@ -10006,7 +10006,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
@@ -10021,18 +10020,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.4.12"
+10 -10
View File
@@ -10,7 +10,7 @@ license = "MIT OR Apache-2.0"
rust-version = "1.95"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["serde"] }
# internal
photocraft-geom = { path = "crates/geom" }
photocraft-color = { path = "crates/color" }
@@ -39,13 +39,13 @@ photocraft-tablet = { path = "crates/tablet" }
photocraft-ipp = { path = "crates/ipp" }
# external
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["raster"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["raster"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
craft-pcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "b8f7d93b00dfb8f172938f3d2d796ae62dcfe123" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
@@ -205,7 +205,7 @@ egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc7
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
# craft-pdf's vendored Hayro trees: consumers patch the same craft-libs revision (crates/pdf README).
hayro = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
hayro = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
+2 -1
View File
@@ -68,7 +68,7 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
("DPX and Cineon", &["dpx", "cin"]),
("Amiga IFF", &["iff", "lbm", "ilbm"]),
("DICOM", &["dcm", "dicom", "dc3"]),
("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
("RAW images", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
("Brushes", &["abr"]),
("Gradients", &["grd"]),
("Styles", &["asl"]),
@@ -763,6 +763,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
beep: Some(platform_beep),
check_updates: Some(crate::updates::service()),
last_update: Some(crate::updates::last_update),
clipboard_set_image: Some({
let clip = clip.clone();
guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| {
+3 -1
View File
@@ -218,7 +218,9 @@ fn open_filters_keep_literal_extensions_on_windows_and_macos() {
#[test]
fn native_services_provide_update_transport() {
assert!(native(None).check_updates.is_some());
let services = native(None);
assert!(services.check_updates.is_some());
assert!(services.last_update.is_some());
}
#[test]
+13
View File
@@ -20,6 +20,19 @@ pub fn service() -> CheckFn {
})
}
/// What the last self-update came to, once: `craft-update` removes its records as it reads them.
pub fn last_update() -> Option<Result<String, String>> {
use craft_update::self_update::{Outcome, current_version, take_outcome};
let app = &photocraft_ui_egui::UPDATE_APP;
let paths = craft_update::Paths::for_app(app).ok()?;
let version = photocraft_engine::build_info::VERSION;
let running = current_version(app, version).map_or_else(|| version.to_string(), |v| v.to_string());
match take_outcome(&paths, &running)? {
Outcome::Installed(installed) => Some(Ok(installed.to_string())),
Outcome::Failed(reason) => Some(Err(reason)),
}
}
fn check() -> CheckResult {
let http = craft_update::Http::new("PhotoCraft/Update")?;
let found = craft_update::check_for_update(&photocraft_ui_egui::UPDATE_APP, &http, photocraft_engine::build_info::VERSION)?;
+14
View File
@@ -0,0 +1,14 @@
<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"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" />
</svg>

After

Width:  |  Height:  |  Size: 284 B

+3
View File
@@ -319,6 +319,9 @@ sync_github_name = true
[people.Luis-0508]
sync_github_name = true
[people.Kaedo17]
sync_github_name = true
[people.rmsff]
real_name = "Serikov Roman"
sync_github_name = false
+135
View File
@@ -0,0 +1,135 @@
//! Average changes colour, never coverage. Keep the existing RGB conversion and selection
//! weighting; Photoshop RGB8 observations are documented in docs/average-filter.md.
use photocraft_color::{PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::{Interrupt, Surface, from_rgba_into, to_rgba};
use crate::{Ctx, Image};
#[derive(Default)]
struct Mean {
sum: [f64; 3],
weight: f64,
}
impl Mean {
fn add(&mut self, fmt: &PixelFormat, px: &[f32], coverage: f32) {
let rgba = to_rgba(fmt, px);
let w = f64::from(coverage);
for (sum, value) in self.sum.iter_mut().zip(rgba) {
*sum += f64::from(value) * w;
}
self.weight += w;
}
fn colour(&self) -> Option<[f32; 3]> {
(self.weight > 0.0).then(|| self.sum.map(|s| (s / self.weight) as f32))
}
}
fn mix(fmt: &PixelFormat, px: &mut [f32], colour: [f32; 3], coverage: f32) {
if coverage <= 0.0 {
return;
}
let mut rgba = to_rgba(fmt, px);
for (value, mean) in rgba.iter_mut().zip(colour) {
*value += (mean - *value) * coverage;
}
from_rgba_into(fmt, rgba, px);
}
// Rect's public coordinates can span more than i32::MAX. Reject these before raster helpers
// do their signed coordinate arithmetic, and cap a single operation's sample count.
fn valid_area(area: Rect, channels: usize) -> bool {
area.width() <= i32::MAX as u32
&& area.height() <= i32::MAX as u32
&& u64::from(area.width()).saturating_mul(u64::from(area.height())).saturating_mul(channels as u64) <= 512 * 1024 * 1024
}
pub(crate) fn apply(surface: &Surface, area: Rect, selection: Option<&Surface>, ctl: &Interrupt<'_>) -> Option<Surface> {
let fmt = surface.format();
let n = fmt.channels();
if !valid_area(area, n) || ctl.cancelled() {
return None;
}
if area.is_empty() {
return Some(surface.clone());
}
let mut raw = surface.try_read_region(area).ok()?;
let width = area.width() as usize;
let coverage = |i: usize| selection.map_or(1.0, |s| s.sample_channel(area.x0 + (i % width) as i32, area.y0 + (i / width) as i32, 0));
let mut mean = Mean::default();
for (i, px) in raw.chunks_exact(n).enumerate() {
if i % width == 0 && ctl.cancelled() {
return None;
}
mean.add(&fmt, px, coverage(i));
}
let Some(colour) = mean.colour() else { return Some(surface.clone()) };
for (i, px) in raw.chunks_exact_mut(n).enumerate() {
if i % width == 0 && ctl.cancelled() {
return None;
}
mix(&fmt, px, colour, coverage(i));
}
let mut out = surface.clone();
if ctl.cancelled() {
return None;
}
out.try_write_region(area, &raw).ok()?;
out.prune();
ctl.progress(1.0);
Some(out)
}
// The direct kernel API has no selection. Its mean is over the reference bounds, even when
// the requested output is only a tile; the surface dispatcher above also handles selections.
pub(crate) fn kernel(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let fmt = PixelFormat::new(ctx.mode, SampleType::F32, ctx.alpha);
let n = fmt.channels();
if src.ch != n || !valid_area(src.rect, n) || !valid_area(out, n) {
return Vec::new();
}
let read = |x: i32, y: i32| -> Option<&[f32]> {
if !src.rect.contains(x, y) {
return None;
}
let i = (i64::from(y) - i64::from(src.rect.y0)) as usize;
let j = (i64::from(x) - i64::from(src.rect.x0)) as usize;
let start = i.checked_mul(src.rect.width() as usize)?.checked_add(j)?.checked_mul(n)?;
src.data.get(start..start.checked_add(n)?)
};
let bounds = ctx.bounds.intersect(&src.rect);
let mut mean = Mean::default();
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
if let Some(px) = read(x, y) {
mean.add(&fmt, px, 1.0);
}
}
}
let len = (out.width() as usize).checked_mul(out.height() as usize).and_then(|s| s.checked_mul(n));
let Some(len) = len else { return Vec::new() };
let mut result = Vec::new();
if result.try_reserve_exact(len).is_err() {
return result;
}
let colour = mean.colour();
for y in out.y0..out.y1 {
for x in out.x0..out.x1 {
let mut px = [0.0; 5];
let Some(pixel) = px.get_mut(..n) else { return Vec::new() };
if let Some(source) = read(x, y) {
pixel.copy_from_slice(source);
}
if let Some(colour) = colour {
mix(&fmt, pixel, colour, 1.0);
}
result.extend_from_slice(pixel);
}
}
result
}
#[cfg(test)]
mod tests;
+59
View File
@@ -0,0 +1,59 @@
use super::*;
use photocraft_color::ColorMode;
#[test]
fn preserves_alpha_and_averages_hidden_rgb_at_each_depth() {
let bounds = Rect::new(0, 0, 2, 1);
for sample in SampleType::ALL {
let fmt = PixelFormat::new(ColorMode::Rgb, sample, true);
let mut surface = Surface::new(fmt);
surface.write_region(bounds, &[1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0]);
let out = apply(&surface, bounds, None, &Interrupt::NONE).unwrap();
for (i, p) in out.read_region(bounds).as_chunks::<4>().0.iter().enumerate() {
assert!((p[0] - 0.5).abs() <= 1.0 / 255.0);
assert_eq!(p[1], 0.0);
assert!((p[2] - 0.5).abs() <= 1.0 / 255.0);
assert_eq!(p[3], if i == 0 { 1.0 } else { 0.0 });
}
}
}
#[test]
fn direct_kernel_uses_reference_bounds_for_each_output_tile() {
let bounds = Rect::new(-1, 0, 1, 1);
let src = Image { rect: bounds, ch: 4, data: vec![1.0, 0.0, 0.0, 0.25, 0.0, 0.0, 1.0, 0.75] };
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
assert_eq!(kernel(&src, Rect::new(-1, 0, 0, 1), &ctx), vec![0.5, 0.0, 0.5, 0.25]);
assert_eq!(kernel(&src, Rect::new(0, 0, 1, 1), &ctx), vec![0.5, 0.0, 0.5, 0.75]);
}
#[test]
fn rejects_extreme_geometry_and_honours_pre_cancel() {
let surface = Surface::new(PixelFormat::RGBA8);
let huge = Rect::new(i32::MIN, 0, i32::MAX, 1);
assert!(apply(&surface, huge, None, &Interrupt::NONE).is_none());
assert!(apply(&surface, Rect::new(0, 0, 5, 5), None, &Interrupt::cancel_only(&|| true)).is_none());
assert_eq!(surface.tile_count(), 0);
}
#[test]
fn soft_selection_weights_the_mean_and_output_mix() {
let bounds = Rect::new(0, 0, 2, 1);
for sample in SampleType::ALL {
let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
surface.write_region(bounds, &[1.0, 0.0, 0.0, 0.25, 0.0, 0.0, 1.0, 0.75]);
let before = surface.read_region(bounds);
let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
selection.write_region(bounds, &[0.25, 1.0]);
let out = apply(&surface, bounds, Some(&selection), &Interrupt::NONE).unwrap();
// Unequal coverage makes the mean [0.2, 0, 0.8]. Each pixel then mixes
// toward it using its own coverage, independently of its original alpha.
for (i, expected) in [[0.8, 0.0, 0.2], [0.2, 0.0, 0.8]].iter().enumerate() {
let pixel = out.pixel(i as i32, 0);
for (actual, expected) in pixel.iter().zip(expected) {
assert!((actual - expected).abs() <= 1.0 / 255.0, "{sample:?}: {pixel:?}");
}
assert_eq!(pixel[3], before[i * 4 + 3]);
}
}
}
+13 -3
View File
@@ -21,6 +21,7 @@
pub mod art_history;
mod artistic;
mod artistic_fx;
mod average;
mod blur;
mod blur2;
pub mod camera_raw;
@@ -208,6 +209,8 @@ pub enum PolarMode {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "filter", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum FilterParams {
/// Mean colour over the selected area, retaining each pixel's alpha.
Average,
/// Radius = Gaussian standard deviation in pixels (0.1–1000).
GaussianBlur {
radius: f32,
@@ -592,7 +595,8 @@ impl FilterParams {
FilterParams::Emboss { height, .. } => Halo::Radius(height.abs().ceil() as i32 + 2),
FilterParams::FindEdges => Halo::Radius(1),
FilterParams::AddNoise { .. } | FilterParams::Solarize | FilterParams::Invert | FilterParams::Desaturate => Halo::Radius(0),
FilterParams::RadialBlur { .. }
FilterParams::Average
| FilterParams::RadialBlur { .. }
| FilterParams::Offset { .. }
| FilterParams::Twirl { .. }
| FilterParams::Pinch { .. }
@@ -634,14 +638,16 @@ impl FilterParams {
}
/// Whether the filter places and sizes its pattern by the canvas even with a selection
/// (Clouds, Fibers): its bounds are then the canvas, and the selection only masks the result.
/// (Clouds, Fibers), or limits its working area to the canvas (Average). The output
/// area is still clipped to the selection; Average also weights its mean by that coverage.
pub fn sizes_by_canvas(&self) -> bool {
matches!(self, FilterParams::Clouds { .. } | FilterParams::Fibers { .. })
matches!(self, FilterParams::Average | FilterParams::Clouds { .. } | FilterParams::Fibers { .. })
}
/// Human-readable name.
pub fn label(&self) -> &'static str {
match self {
FilterParams::Average => "Average",
FilterParams::GaussianBlur { .. } => "Gaussian Blur",
FilterParams::BoxBlur { .. } => "Box Blur",
FilterParams::MotionBlur { .. } => "Motion Blur",
@@ -766,6 +772,7 @@ pub struct Ctx {
/// the halo, or the bounds for global filters). Returns interleaved samples.
pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
match params {
FilterParams::Average => average::kernel(src, out, ctx),
FilterParams::GaussianBlur { radius } => blur::gaussian(src, out, ctx, *radius),
FilterParams::BoxBlur { radius } => blur::boxed(src, out, ctx, *radius),
FilterParams::MotionBlur { angle, distance } => blur::motion(src, out, ctx, *angle, *distance),
@@ -1141,6 +1148,9 @@ fn apply_tiled_with_impl(
if area.is_empty() {
return Some(out);
}
if matches!(params, FilterParams::Average) {
return average::apply(surface, extent.map_or(area, |e| area.intersect(&e)), selection, ctl);
}
#[cfg(not(target_arch = "wasm32"))]
if prepare_fast_paths && let Some(result) = sharpen::fft::apply(surface, params, area, selection, extent, ctl) {
return result;
+182 -19
View File
@@ -112,7 +112,15 @@ fn d_rate() -> f64 {
}
/// One brush stroke: the tool, its brush options and the points it passed through
/// (`[x, y]` or `[x, y, pressure 0..1]`, document pixels).
/// (`[x, y]`, `[x, y, pressure 0..1]` or `[x, y, pressure 0..1, ms]`, document pixels).
///
/// A fourth number makes the point *live*: the milliseconds since the previous point, as the
/// interactive dialog records them. A segment ending on a live point follows the pointer exactly
/// (Forward Warp and Push Left also show the motion since their last dab, as a provisional dab
/// taken back before the next point, instead of waiting for the next dab spacing) and the
/// stationary tools work by elapsed time as well as distance, so the brush keeps working at the
/// same speed when it moves slowly or the frame rate changes. Points without it (older strokes,
/// scripts) replay exactly as they always have.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiquifyStroke {
@@ -144,20 +152,28 @@ impl LiquifyStroke {
LiquifyStroke { tool, size, density: d_density(), pressure: d_pressure(), rate: d_rate(), points: Vec::new(), amount: None }
}
/// Validates the stroke (finite numbers, 2 or 3 per point).
/// Validates the stroke (finite numbers, 2 to 4 per point).
pub fn validate(&self) -> Result<(), String> {
if !(self.size.is_finite() && self.size >= 1.0 && self.size <= 15000.0) {
return Err(format!("brush size {} out of range 1..15000", self.size));
}
for p in &self.points {
if !(2..=3).contains(&p.len()) || p.iter().any(|v| !v.is_finite()) {
return Err("stroke points are [x, y] or [x, y, pressure]".into());
if !(2..=4).contains(&p.len()) || p.iter().any(|v| !v.is_finite()) {
return Err("stroke points are [x, y], [x, y, pressure] or [x, y, pressure, ms]".into());
}
}
Ok(())
}
}
/// Milliseconds of holding a stationary tool still that one full-strength dab stands for (the
/// 30 dabs a second the dialog has always used).
const HELD_DAB_MS: f64 = 1000.0 / 30.0;
/// Most elapsed time one live point counts for: after a stall (a slow frame, the app in the
/// background) the brush catches up by this much at most instead of lurching.
pub const MAX_LIVE_MS: f64 = 250.0;
/// The displacement field plus the freeze mask, over `bounds` (document pixels).
#[derive(Clone, Debug)]
pub struct LiquifyField {
@@ -172,6 +188,21 @@ pub struct LiquifyField {
pub freeze: Vec<f32>,
/// Where the brush stroke in progress stands, so dab spacing runs on across its segments.
cursor: Option<StrokeCursor>,
/// The provisional dab a live Forward Warp / Push Left segment ended with, to take back
/// before the stroke goes on (see [`Self::stroke_segment_live`]).
tail: Option<LiveTail>,
}
/// What a live segment's closing dab overwrote: the displacements `saved` of the nodes in columns
/// `i0..=i1`, one row after another from row `j0`, as they were, and the document rectangle the
/// dab touched.
#[derive(Clone, Debug)]
struct LiveTail {
i0: usize,
j0: usize,
i1: usize,
saved: Vec<[f32; 2]>,
dirty: Rect,
}
/// Two fields are equal when their displacement and freeze mask are: the stroke cursor is
@@ -230,7 +261,7 @@ impl LiquifyField {
cell *= 2.0;
(w, h) = nodes(cell);
}
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None, tail: None }
}
/// Document position of node (i, j) (node 0 sits on the first pixel centre).
@@ -245,6 +276,27 @@ impl LiquifyField {
((x - f64::from(self.bounds.x0) - 0.5) / self.cell, (y - f64::from(self.bounds.y0) - 0.5) / self.cell)
}
/// The field nodes a dab of radius `r` at `c` can touch, as `(i0, j0, i1, j1)` inclusive;
/// `None` when it touches none (off the grid, or a NaN centre).
fn node_span(&self, c: [f64; 2], r: f64) -> Option<(usize, usize, usize, usize)> {
let (gx0, gy0) = self.to_grid(c[0] - r, c[1] - r);
let (gx1, gy1) = self.to_grid(c[0] + r, c[1] + r);
// Decide the overlap in f64, before any conversion to an index: a far-off centre saturated
// `as usize` to `usize::MAX`, which read back as `-1` passed the overlap check. Written so
// that NaN, which fails every comparison, touches nothing (`max`/`min` would hide it).
let (wmax, hmax) = (self.w as f64 - 1.0, self.h as f64 - 1.0);
if !(gx1.ceil() >= 0.0 && gy1.ceil() >= 0.0 && gx0.floor() <= wmax && gy0.floor() <= hmax) {
return None;
}
let (x0, y0) = (gx0.floor().max(0.0), gy0.floor().max(0.0));
let (x1, y1) = (gx1.ceil().min(wmax), gy1.ceil().min(hmax));
if !(x0 <= x1 && y0 <= y1) {
return None;
}
// Now 0 <= x0 <= x1 <= w - 1 (and likewise in y): whole numbers that convert exactly.
Some((x0 as usize, y0 as usize, x1 as usize, y1 as usize))
}
pub fn is_identity(&self) -> bool {
self.d.iter().all(|v| v[0] == 0.0 && v[1] == 0.0)
}
@@ -274,6 +326,7 @@ impl LiquifyField {
/// Applies one stroke. Returns the document rectangle whose output changed.
pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect {
self.cursor = None;
self.tail = None;
match s.tool {
LiquifyTool::LassoMask => return self.apply_lasso(s),
LiquifyTool::ReconstructAll => {
@@ -293,11 +346,18 @@ impl LiquifyField {
if s.tool.is_global() {
return self.bounds;
}
let pts: Vec<[f64; 3]> = s.points.iter().filter(|p| p.len() >= 2).map(|p| [p[0], p[1], p.get(2).copied().unwrap_or(1.0)]).collect();
let Some(&first) = pts.first() else { return Rect::EMPTY };
// Each point with its live time (the fourth number), if it has one.
let pts: Vec<([f64; 3], Option<f64>)> =
s.points.iter().filter(|p| p.len() >= 2).map(|p| ([p[0], p[1], p.get(2).copied().unwrap_or(1.0)], p.get(3).copied())).collect();
let Some(&(first, _)) = pts.first() else { return Rect::EMPTY };
let mut dirty = self.stroke_begin(s, first);
for w in pts.windows(2) {
dirty = dirty.union(&self.stroke_segment(s, w[0], w[1]));
let ((a, _), (b, ms)) = (w[0], w[1]);
let d = match ms {
Some(ms) => self.stroke_segment_live(s, a, b, ms),
None => self.stroke_segment(s, a, b),
};
dirty = dirty.union(&d);
}
dirty
}
@@ -360,6 +420,7 @@ impl LiquifyField {
/// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what
/// [`Self::apply_stroke`] does with the whole point list.
pub fn stroke_begin(&mut self, s: &LiquifyStroke, p: [f64; 3]) -> Rect {
self.tail = None;
self.cursor = Some(StrokeCursor { end: [p[0], p[1]], last_dab: [p[0], p[1]], carry: 0.0 });
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0), 1.0) } else { Rect::EMPTY }
}
@@ -370,7 +431,118 @@ impl LiquifyField {
/// stationary tool held still dabs once at `b`. When `a` is not where the previous segment
/// ended, the stroke starts afresh at `a`.
pub fn stroke_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
self.walk_segment(s, a, b, true)
let undone = self.undo_tail();
undone.union(&self.walk_segment(s, a, b, true))
}
/// Continues a stroke from `a` to a live point `b` reached `ms` milliseconds after `a` (see
/// [`LiquifyStroke`]), which is what the interactive dialog records for every pointer event.
///
/// Forward Warp and Push Left walk the segment as [`Self::stroke_segment`] does, so the dabs
/// fall every dab spacing of distance travelled however finely the pointer was sampled. Then
/// what is left of the motion since the last dab is applied as one shorter, *provisional* dab,
/// so the image follows the pointer on every event. That dab is taken back (see
/// [`Self::undo_tail`]) before the next segment, so only the regular dabs stay and a slow and
/// a fast pointer give the same warp, apart from the final partial dab.
///
/// The stationary tools (Twirl, Pucker, Bloat, Reconstruct, Smooth, Freeze, Thaw) apply the
/// larger of what the elapsed time is worth ([`HELD_DAB_MS`] per full dab, at most
/// [`MAX_LIVE_MS`] per point) and what the distance travelled is worth (as a moving stroke
/// always has), spread evenly along the segment in dabs of at most full strength. Held still,
/// slow or fast, at any frame rate, the brush works continuously at one speed.
pub fn stroke_segment_live(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3], ms: f64) -> Rect {
self.walk_segment_live(s, a, b, ms, true)
}
/// Takes back the provisional closing dab of the last live segment, if any. Returns the
/// document rectangle that changed.
fn undo_tail(&mut self) -> Rect {
let Some(t) = self.tail.take() else { return Rect::EMPTY };
let n = t.i1 - t.i0 + 1;
for (row, saved) in t.saved.chunks_exact(n).enumerate() {
let at = (t.j0 + row) * self.w + t.i0;
self.d[at..at + n].copy_from_slice(saved);
}
t.dirty
}
/// [`Self::stroke_segment_live`]; `skip` as in [`Self::walk_segment`].
fn walk_segment_live(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3], ms: f64, skip: bool) -> Rect {
let mut dirty = self.undo_tail();
if !s.tool.is_stationary() {
dirty = dirty.union(&self.walk_segment(s, a, b, skip));
// What the dab spacing left over: show it now, and take it back on the next event.
if let Some(cur) = self.cursor
&& cur.end == [b[0], b[1]]
{
let delta = [b[0] - cur.last_dab[0], b[1] - cur.last_dab[1]];
let pressure = b[2].clamp(0.0, 1.0);
// A dab with no strength changes nothing, so there is nothing to take back.
if (delta[0] != 0.0 || delta[1] != 0.0) && pressure > 0.0 && s.pressure > 0.0 {
let r = (s.size / 2.0).max(0.5);
let span = self.node_span(cur.last_dab, r);
let saved = span.map(|(i0, j0, i1, j1)| {
let mut v = Vec::with_capacity((i1 - i0 + 1) * (j1 - j0 + 1));
for j in j0..=j1 {
v.extend_from_slice(&self.d[j * self.w + i0..=j * self.w + i1]);
}
v
});
let tail = self.dab(s, cur.last_dab, delta, pressure, 1.0);
if let (Some((i0, j0, i1, _)), Some(saved)) = (span, saved) {
self.tail = Some(LiveTail { i0, j0, i1, saved, dirty: tail });
}
dirty = dirty.union(&tail);
}
}
return dirty;
}
// A live stroke keeps no spacing state for these tools; the cursor only marks continuity.
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: [b[0], b[1]], carry: 0.0 });
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len = dx.hypot(dy);
if !len.is_finite() || !b[0].is_finite() || !b[1].is_finite() {
self.cursor = None;
return dirty;
}
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
let r = (s.size / 2.0).max(0.5);
let spacing = dab_spacing(r);
let ms = if ms.is_finite() { ms.clamp(0.0, MAX_LIVE_MS) } else { 0.0 };
// The same strength per distance a moving stationary tool has always had: `moving_weight`
// per dab spacing.
let by_time = ms / HELD_DAB_MS;
let by_distance = len / spacing * moving_weight(r);
let total = by_time.max(by_distance);
if total <= 0.0 {
return dirty;
}
// Enough dabs that none is stronger than a full one or further apart than the spacing.
let n = total.ceil().max((len / spacing).ceil()).max(1.0);
let weight = total / n;
let at = |k: f64| {
let t = (k + 1.0) / n;
([a[0] + dx * t, a[1] + dy * t], pa + (pb - pa) * t)
};
let (mut k0, mut k1) = (0.0f64, n - 1.0);
if skip && len > 0.0 {
// Only dabs near the part of the segment that can reach a node (#937).
match self.reachable_span(a, [dx, dy], len, r) {
Some((d0, d1)) => {
k0 = k0.max((d0 / len * n).floor() - 2.0);
k1 = k1.min((d1 / len * n).ceil() + 1.0);
}
None => return dirty,
}
}
if k0 > k1 {
return dirty;
}
for k in (k0 as u64)..=(k1 as u64) {
let (c, p) = at(k as f64);
dirty = dirty.union(&self.dab(s, c, [0.0, 0.0], p, weight));
}
dirty
}
/// [`Self::stroke_segment`]; `skip` leaves out the dabs that cannot reach a field node (they
@@ -479,16 +651,7 @@ impl LiquifyField {
if strength <= 0.0 {
return Rect::EMPTY;
}
let (gx0, gy0) = self.to_grid(c[0] - r, c[1] - r);
let (gx1, gy1) = self.to_grid(c[0] + r, c[1] + r);
let i0 = gx0.floor().max(0.0) as usize;
let j0 = gy0.floor().max(0.0) as usize;
let i1 = (gx1.ceil().max(-1.0) as isize).min(self.w as isize - 1);
let j1 = (gy1.ceil().max(-1.0) as isize).min(self.h as isize - 1);
if i1 < i0 as isize || j1 < j0 as isize {
return Rect::EMPTY;
}
let (i1, j1) = (i1 as usize, j1 as usize);
let Some((i0, j0, i1, j1)) = self.node_span(c, r) else { return Rect::EMPTY };
// Snapshot of the affected nodes plus a margin (sampling reads around them).
let m = ((delta[0].abs().max(delta[1].abs()) + r * 0.2) / self.cell).ceil() as usize + 2;
let (si0, sj0) = (i0.saturating_sub(m), j0.saturating_sub(m));
+260
View File
@@ -138,6 +138,52 @@ fn a_segment_to_a_far_off_point_returns() {
f.stroke_segment(&LiquifyStroke::new(LiquifyTool::Bloat, 30.0), [5.0, -1e15, 1.0], [5.0, 1e15, 1.0]);
}
/// A timed (live) stroke far off the field leaves it unchanged instead of panicking. The
/// provisional closing dab used to take its node span from a far-off centre: the lower index
/// saturated to `usize::MAX`, read as `-1` it passed the overlap check, and the snapshot then
/// subtracted past zero (debug) or sliced out of range (release).
#[test]
fn a_timed_stroke_far_off_the_field_leaves_it_unchanged() {
let far = [
// The review's case: valid points, both far off on the right.
vec![vec![1e30, 3.0, 1.0], vec![1e30, 3.25, 1.0, 16.0]],
vec![vec![3.0, 1e30, 1.0], vec![3.25, 1e30, 1.0, 16.0]],
vec![vec![1e30, 1e30, 1.0], vec![1e30, 1e30 + 1e15, 1.0, 16.0]],
vec![vec![-1e30, 3.0, 1.0], vec![-1e30, 3.25, 1.0, 16.0]],
vec![vec![3.0, -1e30, 1.0], vec![3.25, -1e30, 1.0, 16.0]],
vec![vec![f64::MAX, 3.0, 1.0], vec![f64::MAX, 3.25, 1.0, 16.0]],
];
use LiquifyTool::*;
for tool in [ForwardWarp, Reconstruct, Smooth, TwirlCw, TwirlCcw, Pucker, Bloat, PushLeft, Freeze, Thaw] {
for points in &far {
let mut s = LiquifyStroke::new(tool, 30.0);
s.points = points.clone();
s.validate().unwrap();
let mut f = LiquifyField::new(Rect::new(0, 0, 24, 16), 2.0);
f.apply_stroke(&s);
let fresh = LiquifyField::new(Rect::new(0, 0, 24, 16), 2.0);
assert!(f.d == fresh.d && f.freeze == fresh.freeze, "{tool:?} {points:?} changed the field");
}
}
}
/// `node_span` decides off the grid before converting to indices, on every side.
#[test]
fn node_span_rejects_spans_off_the_grid() {
let f = LiquifyField::new(Rect::new(0, 0, 24, 16), 2.0);
for c in [[1e30, 3.0], [3.0, 1e30], [-1e30, 3.0], [3.0, -1e30], [f64::MAX, f64::MAX], [-f64::MAX, 3.0], [f64::NAN, 3.0], [3.0, f64::INFINITY]] {
assert_eq!(f.node_span(c, 15.0), None, "{c:?}");
}
// On the grid, and partly off it, the span is the nodes inside it.
let (w, h) = (f.w - 1, f.h - 1);
assert_eq!(f.node_span([12.0, 8.0], 1e6), Some((0, 0, w, h)));
let Some((i0, j0, i1, j1)) = f.node_span([12.0, 8.0], 3.0) else { panic!("a dab on the field touches it") };
assert!(i0 <= i1 && i1 <= w && j0 <= j1 && j1 <= h, "{:?}", (i0, j0, i1, j1));
let Some((i0, _, i1, _)) = f.node_span([-10.0, 8.0], 12.0) else { panic!("a dab reaching the left edge touches it") };
assert_eq!(i0, 0);
assert!(i1 <= w);
}
fn sample(st: SampleType) -> Surface {
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, st, true));
for y in 0..64 {
@@ -530,3 +576,217 @@ fn proxy_matches_the_full_render_roughly() {
}
assert!(bad < 96 * 64 / 50, "{bad}");
}
// ---- Live strokes: the brush follows the pointer in real time ----
/// `path` as live points, one event per `step` px along each leg, `ms` apart.
fn live(path: &[[f64; 2]], step: f64, ms: f64) -> Vec<Vec<f64>> {
let mut out = vec![vec![path[0][0], path[0][1], 1.0, 0.0]];
for w in path.windows(2) {
let (a, b) = (w[0], w[1]);
let n = ((b[0] - a[0]).hypot(b[1] - a[1]) / step).ceil().max(1.0) as usize;
for k in 1..=n {
let t = k as f64 / n as f64;
out.push(vec![a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, 1.0, ms]);
}
}
out
}
fn max_diff(a: &LiquifyField, b: &LiquifyField) -> f32 {
a.d.iter().zip(&b.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max)
}
/// The user's report: moving the mouse slowly, Liquify only changed the image once every dab
/// spacing (a twentieth of the brush radius, 10 px for a 400 px brush), not with every movement.
/// A live point now applies all the motion up to it, so every pointer event moves the image.
#[test]
fn a_slow_live_drag_moves_the_image_on_every_pointer_event() {
for tool in [LiquifyTool::ForwardWarp, LiquifyTool::PushLeft] {
let s = LiquifyStroke::new(tool, 200.0); // spacing 5 px
let mut f = LiquifyField::new(Rect::new(0, 0, 300, 200), 2.0);
let mut legacy = f.clone();
let start = [100.0, 100.0, 1.0];
f.stroke_begin(&s, start);
legacy.stroke_begin(&s, start);
let (mut prev, mut moves, mut legacy_moves) = (start, 0, 0);
// A crawl: half a pixel per event, 20 events (10 px, two spacings).
for k in 1..=20 {
let p = [100.0 + k as f64 * 0.5, 100.0, 1.0];
let (before, legacy_before) = (f.d.clone(), legacy.d.clone());
f.stroke_segment_live(&s, prev, p, 16.0);
legacy.stroke_segment(&s, prev, p);
moves += usize::from(f.d != before);
legacy_moves += usize::from(legacy.d != legacy_before);
prev = p;
}
assert_eq!(moves, 20, "{tool:?}: every event moves the image");
assert!(legacy_moves <= 3, "{tool:?}: the legacy walk only moved it once per spacing ({legacy_moves})");
// And all the motion is there: the centre has been pushed (almost) as far as the brush went.
let d = f.sample(105.0, 100.0);
let shift = if tool == LiquifyTool::ForwardWarp { -d[0] } else { d[1].abs() };
assert!(shift > 7.0, "{tool:?}: pushed {shift} px for a 10 px drag");
}
}
/// Following the pointer exactly must not bring back #2606's problem (a slow and a fast pointer
/// giving different warps): the two differ by a small fraction of the displacement.
#[test]
fn live_drags_sampled_finely_or_coarsely_agree_closely() {
let path = [[40.0, 40.0], [180.0, 60.0], [200.0, 150.0], [60.0, 160.0]];
for (tool, size) in [(LiquifyTool::ForwardWarp, 60.0), (LiquifyTool::ForwardWarp, 200.0), (LiquifyTool::PushLeft, 80.0)] {
let field = |step: f64| {
let mut s = LiquifyStroke::new(tool, size);
s.points = live(&path, step, 16.0);
LiquifyField::from_strokes(Rect::new(0, 0, 260, 220), 2.0, &[s])
};
let (slow, fast) = (field(0.7), field(25.0));
let deep = slow.max_displacement();
let dd = max_diff(&slow, &fast);
assert!(deep > 10.0 && dd < deep * 0.1, "{tool:?} {size}px: slow and fast differ by {dd} px of {deep}");
}
}
/// Holding a stationary tool still works by elapsed time, not by how often frames come: one
/// second at 30, 60 or 144 frames a second does the same.
#[test]
fn held_stationary_tools_work_at_the_same_speed_at_any_frame_rate() {
for tool in [LiquifyTool::TwirlCw, LiquifyTool::Bloat, LiquifyTool::Pucker] {
let field = |fps: f64| {
let mut s = LiquifyStroke::new(tool, 80.0);
let frames = fps as usize; // one second
s.points = std::iter::once(vec![100.0, 100.0, 1.0, 0.0]).chain((0..frames).map(|_| vec![100.0, 100.0, 1.0, 1000.0 / fps])).collect();
LiquifyField::from_strokes(Rect::new(0, 0, 200, 200), 2.0, &[s])
};
let (a, b, c) = (field(30.0), field(60.0), field(144.0));
let deep = a.max_displacement();
assert!(deep > 3.0, "{tool:?}: a second's hold does something ({deep} px)");
for (fps, f) in [(60, &b), (144, &c)] {
let dd = max_diff(&a, f);
assert!(dd < deep * 0.08, "{tool:?}: {fps} fps differs from 30 fps by {dd} px of {deep}");
}
}
}
/// The other half of the report: a stationary tool crept along slowly did nothing at all (the
/// still-brush timer kept resetting and the distance never reached a dab spacing). Live, it works
/// at least as fast as held still.
#[test]
fn a_creeping_stationary_tool_keeps_working() {
let s = LiquifyStroke::new(LiquifyTool::TwirlCw, 120.0); // spacing 3 px
let mut f = LiquifyField::new(Rect::new(0, 0, 240, 200), 2.0);
f.stroke_begin(&s, [100.0, 100.0, 1.0]);
let mut prev = [100.0, 100.0, 1.0];
let mut changed = 0;
for k in 1..=30 {
let p = [100.0 + k as f64 * 0.2, 100.0, 1.0];
let before = f.d.clone();
f.stroke_segment_live(&s, prev, p, 16.7);
changed += usize::from(f.d != before);
prev = p;
}
assert_eq!(changed, 30, "every frame twirls a little more");
assert!(f.max_displacement() > 3.0, "half a second of creeping twirls visibly ({})", f.max_displacement());
}
/// Live strokes replay to exactly the field the interactive calls built (what OK relies on), and
/// a stall is capped instead of lurching.
#[test]
fn live_strokes_replay_exactly_and_stalls_are_capped() {
for tool in [LiquifyTool::ForwardWarp, LiquifyTool::TwirlCcw, LiquifyTool::Smooth, LiquifyTool::Freeze, LiquifyTool::Reconstruct] {
let mut s = LiquifyStroke::new(tool, 50.0);
s.points = live(&[[30.0, 30.0], [90.0, 70.0], [40.0, 90.0]], 1.3, 9.0);
let mut warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 60.0);
warp.points = vec![vec![40.0, 40.0], vec![80.0, 70.0]];
let mut f = LiquifyField::from_strokes(bounds(), 2.0, std::slice::from_ref(&warp));
let pts: Vec<[f64; 3]> = s.points.iter().map(|p| [p[0], p[1], p[2]]).collect();
f.stroke_begin(&s, pts[0]);
for (k, w) in pts.windows(2).enumerate() {
f.stroke_segment_live(&s, w[0], w[1], s.points[k + 1][3]);
}
assert_eq!(f, LiquifyField::from_strokes(bounds(), 2.0, &[warp, s]), "{tool:?}");
}
let s = LiquifyStroke::new(LiquifyTool::Bloat, 60.0);
let (mut stall, mut capped) = (LiquifyField::new(bounds(), 2.0), LiquifyField::new(bounds(), 2.0));
stall.stroke_segment_live(&s, [50.0, 50.0, 1.0], [50.0, 50.0, 1.0], 60_000.0);
capped.stroke_segment_live(&s, [50.0, 50.0, 1.0], [50.0, 50.0, 1.0], MAX_LIVE_MS);
assert_eq!(stall, capped);
// Hostile values neither panic nor hang.
let mut f = LiquifyField::new(Rect::new(0, 0, 24, 16), 2.0);
for tool in [LiquifyTool::ForwardWarp, LiquifyTool::Bloat, LiquifyTool::Freeze] {
let s = LiquifyStroke::new(tool, 30.0);
f.stroke_segment_live(&s, [0.0, 0.0, 1.0], [1e15, 0.0, 1.0], 16.0);
f.stroke_segment_live(&s, [5.0, -1e15, 1.0], [5.0, 1e15, 1.0], f64::NAN);
f.stroke_segment_live(&s, [-1e308, 5.0, 1.0], [1e308, 5.0, 1.0], -5.0);
f.stroke_segment_live(&s, [f64::NAN, 3.0, 1.0], [4.0, f64::INFINITY, 1.0], f64::INFINITY);
}
let mut bad = LiquifyStroke::new(LiquifyTool::ForwardWarp, 30.0);
bad.points = vec![vec![1.0, 2.0, 1.0, 16.0, 9.0]];
assert!(bad.validate().is_err());
bad.points = vec![vec![1.0, 2.0, 1.0, 16.0]];
assert!(bad.validate().is_ok());
}
/// Old strokes (no times) replay bit for bit as before: the live path is opt-in per point.
#[test]
fn strokes_without_times_replay_as_before() {
let path = [[20.0, 20.0, 1.0], [62.0, 26.0, 0.6], [70.0, 50.0, 0.9]];
for tool in [LiquifyTool::ForwardWarp, LiquifyTool::TwirlCw, LiquifyTool::Freeze] {
let mut s = LiquifyStroke::new(tool, 30.0);
s.points = sampled(&path, 7);
let mut f = LiquifyField::new(bounds(), 2.0);
let pts: Vec<[f64; 3]> = s.points.iter().map(|p| [p[0], p[1], p[2]]).collect();
f.stroke_begin(&s, pts[0]);
for w in pts.windows(2) {
f.stroke_segment(&s, w[0], w[1]);
}
assert_eq!(f, LiquifyField::from_strokes(bounds(), 2.0, &[s]), "{tool:?}");
}
}
/// A live segment with no strength changes nothing, so it leaves no provisional dab to take back;
/// with strength it does. No strength is a zero pressure at the point or a zero stroke pressure.
#[test]
fn a_live_segment_without_strength_leaves_nothing_to_take_back() {
for (point_pressure, stroke_pressure, shown) in [(0.0, 100.0, false), (1.0, 0.0, false), (1.0, 100.0, true)] {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 200.0);
s.pressure = stroke_pressure;
let mut f = LiquifyField::new(Rect::new(0, 0, 300, 200), 2.0);
f.stroke_begin(&s, [100.0, 100.0, point_pressure]);
f.stroke_segment_live(&s, [100.0, 100.0, point_pressure], [100.5, 100.0, point_pressure], 16.0);
let case = format!("point pressure {point_pressure}, stroke pressure {stroke_pressure}");
assert_eq!(f.tail.is_some(), shown, "{case}");
assert_eq!(f.is_identity(), !shown, "{case}");
}
}
/// The provisional dab of one live segment is taken back before the next: a field reused across two
/// segments has a tail after the first, and none after a second segment that has no strength, which
/// also reports the taken-back area as changed so the caller repaints it. A second segment with
/// strength takes it back too: the result is the same as one segment straight to the same point.
#[test]
fn the_next_live_segment_takes_back_the_provisional_dab() {
let s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 200.0);
let field = || {
let mut f = LiquifyField::new(Rect::new(0, 0, 300, 200), 2.0);
f.stroke_begin(&s, [100.0, 100.0, 1.0]);
let first = f.stroke_segment_live(&s, [100.0, 100.0, 1.0], [100.5, 100.0, 1.0], 16.0);
(f, first)
};
let (mut f, first) = field();
assert!(f.tail.is_some(), "the first segment leaves a provisional dab");
assert!(!f.is_identity());
let undone = f.stroke_segment_live(&s, [100.5, 100.0, 1.0], [100.5, 100.0, 0.0], 16.0);
assert!(!undone.is_empty(), "the taken-back dab is reported as changed");
assert_eq!(undone.union(&first), undone, "and its area covers what the first segment repainted");
assert!(f.tail.is_none(), "the next segment took it back");
assert!(f.is_identity(), "and nothing of it is left in the field");
let (mut g, _) = field();
g.stroke_segment_live(&s, [100.5, 100.0, 1.0], [101.0, 100.0, 1.0], 16.0);
assert!(g.tail.is_some(), "the second segment leaves its own provisional dab");
let mut direct = LiquifyField::new(Rect::new(0, 0, 300, 200), 2.0);
direct.stroke_begin(&s, [100.0, 100.0, 1.0]);
direct.stroke_segment_live(&s, [100.0, 100.0, 1.0], [101.0, 100.0, 1.0], 16.0);
assert!(g == direct, "the first segment's dab was taken back before the second was applied");
}
+38 -3
View File
@@ -299,6 +299,27 @@ impl Region {
}
}
/// How a wand-style fill measures how far an 8-bit colour is from the clicked one.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ColorDistance {
/// The largest difference in any one of R, G and B.
LargestChannel,
/// The spread of `{0, dR, dG, dB}`: changes in the same direction count once, opposite
/// changes add up. Only opaque colours were measured, so a partly transparent seed or pixel
/// uses [`Self::LargestChannel`].
Spread,
}
impl ColorDistance {
fn rgb(self, p: [u8; 4], seed: [u8; 4]) -> i32 {
let d = [0, 1, 2].map(|c| p[c] as i32 - seed[c] as i32);
match self {
Self::Spread if p[3] == 255 && seed[3] == 255 => d.iter().fold(0, |m, v| m.max(*v)) - d.iter().fold(0, |m, v| m.min(*v)),
Self::LargestChannel | Self::Spread => d.iter().fold(0, |m, v| m.max(v.abs())),
}
}
}
/// Magic wand on 8-bit pixels: a scanline flood fill (or a global match) that only allocates a
/// byte mark per pixel, then anti-aliases just the edges inside the touched bounding box.
/// Matches [`magic_wand`] for 8-bit sources. `None` if the seed is outside `area`.
@@ -318,6 +339,21 @@ pub fn wand_region_within(
contiguous: bool,
anti_alias: bool,
within: Option<&[f32]>,
) -> Option<Region> {
wand_region_by(img, area, seed, tolerance, contiguous, anti_alias, within, ColorDistance::LargestChannel)
}
/// [`wand_region_within`] measuring how far a colour is from the clicked one with `distance`.
#[allow(clippy::too_many_arguments)]
pub fn wand_region_by(
img: &[[u8; 4]],
area: Rect,
seed: (i32, i32),
tolerance: f32,
contiguous: bool,
anti_alias: bool,
within: Option<&[f32]>,
distance: ColorDistance,
) -> Option<Region> {
if !area.contains(seed.0, seed.1) {
return None;
@@ -335,7 +371,7 @@ pub fn wand_region_within(
let tol = (tolerance + 1e-3).floor().max(0.0) as i32;
// Copying a selection can leave RGB under zero alpha. Those invisible colours must not
// split a transparent region into pieces, as if the copied source were still visible.
let similar = |p: [u8; 4]| (target[3] == 0 && p[3] == 0) || (0..4).all(|c| (p[c] as i32 - target[c] as i32).abs() <= tol);
let similar = |p: [u8; 4]| (target[3] == 0 && p[3] == 0) || (distance.rgb(p, target) <= tol && (p[3] as i32 - target[3] as i32).abs() <= tol);
let mut marks = vec![0u8; w * h];
let (mut bx0, mut by0, mut bx1, mut by1) = (usize::MAX, usize::MAX, 0usize, 0usize);
if contiguous {
@@ -414,8 +450,7 @@ pub fn wand_region_within(
// it gets a fringe by how far its colour is past the tolerance.
if anti_alias && v == 0 && touches_region(x, y, w, h, |nx, ny| at(nx, ny) != 0) {
let p = img[y * w + x];
let d = (0..3).map(|c| (p[c] as i32 - target[c] as i32).abs()).max().unwrap_or(0);
*o = fringe_coverage((d - tol) as f32, f32::from(p[3].min(target[3])));
*o = fringe_coverage((distance.rgb(p, target) - tol) as f32, f32::from(p[3].min(target[3])));
}
}
};
+66
View File
@@ -218,6 +218,72 @@ pub fn quad_size(q: &[[f64; 2]; 4]) -> (f64, f64) {
((len(tl, tr) + len(bl, br)) / 2.0, (len(tl, bl) + len(tr, br)) / 2.0)
}
/// How much the depth must change across the quad, as a fraction, for a pair of sides to count as
/// converging. Below this the recovered proportions are dominated by a pixel of click error.
const MIN_FORESHORTENING: f64 = 0.005;
/// How many times larger or smaller than the quad's longest edge an estimated side may be.
const MAX_ESTIMATE_RATIO: f64 = 4.0;
/// How far, as a fraction, one pixel of error on one corner may move the recovered proportions.
/// Quads from a camera model move about 1 %; the issue's measured skewed quad moves 5.3 %.
const MAX_ASPECT_SWING: f64 = 0.1;
/// The free output size of a Perspective Crop of `q` (TL, TR, BR, BL) in an image centred on
/// `center`: the rectangle the quad shows in perspective, or the quad's longest edges when its
/// sides are too close to parallel to recover that, the estimate would not survive a pixel of
/// click error, or it is implausible.
pub fn quad_free_size(q: &[[f64; 2]; 4], center: [f64; 2]) -> (f64, f64) {
let len = |a: [f64; 2], b: [f64; 2]| (b[0] - a[0]).hypot(b[1] - a[1]);
let [tl, tr, br, bl] = *q;
let (edge_w, edge_h) = (len(tl, tr).max(len(bl, br)), len(tl, bl).max(len(tr, br)));
let near = |v: f64, edge: f64| v.is_finite() && v >= edge / MAX_ESTIMATE_RATIO && v <= edge * MAX_ESTIMATE_RATIO;
match perspective_size(q, center) {
Some((w, h)) if near(w, edge_w) && near(h, edge_h) => (w, h),
_ => (edge_w, edge_h),
}
}
/// The size [`perspective_estimate`] gives `q`, or `None` when moving any corner one pixel along
/// either axis loses the estimate or swings its proportions by more than [`MAX_ASPECT_SWING`].
fn perspective_size(q: &[[f64; 2]; 4], center: [f64; 2]) -> Option<(f64, f64)> {
let (width, aspect) = perspective_estimate(q, center)?;
for corner in 0..4 {
for axis in 0..2 {
for step in [-1.0, 1.0] {
let mut nudged = *q;
nudged[corner][axis] += step;
let (_, other) = perspective_estimate(&nudged, center)?;
if (other / aspect - 1.0).abs() > MAX_ASPECT_SWING {
return None;
}
}
}
}
Some((width, width / aspect))
}
/// The width and the aspect ratio of the rectangle `q` shows. The width is the largest horizontal
/// scale at a corner of the unit-square-to-quad map, so the nearest part of the quad keeps its
/// detail. The aspect ratio is recovered with the principal point at `center` (Zhang and He,
/// "Whiteboard scanning and image enhancement", 2007). `None` when two sides are nearly parallel,
/// which leaves the focal length undefined.
fn perspective_estimate(q: &[[f64; 2]; 4], [cx, cy]: [f64; 2]) -> Option<(f64, f64)> {
let Homography([a, b, _, d, e, _, g, h, _]) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], *q)?;
if g.abs().min(h.abs()) < MIN_FORESHORTENING {
return None;
}
let (ux, uy, vx, vy) = (a - g * cx, d - g * cy, b - h * cx, e - h * cy);
let focal2 = -(ux * vx + uy * vy) / (g * h);
if !focal2.is_finite() || focal2 <= 0.0 {
return None;
}
let focal = focal2.sqrt();
let aspect = ux.hypot(uy).hypot(g * focal) / vx.hypot(vy).hypot(h * focal);
let scale_at = |[s, t]: [f64; 2], [x, y]: [f64; 2]| (a - x * g).hypot(d - y * g) / (1.0 + g * s + h * t);
let [tl, tr, br, bl] = *q;
let width = [scale_at([0.0, 0.0], tl), scale_at([1.0, 0.0], tr), scale_at([1.0, 1.0], br), scale_at([0.0, 1.0], bl)].into_iter().fold(0.0, f64::max);
Some((width, aspect))
}
/// The homography taking the quad `q` (TL, TR, BR, BL) onto the rectangle (0, 0)–(`w`, `h`), corner
/// to corner: the inverse of the unit-square-to-quad map (Heckbert, "Fundamentals of Texture
/// Mapping and Image Warping", 1989, §2.2.3) scaled to the rectangle. `None` when `q` isn't a
+176 -17
View File
@@ -1,6 +1,6 @@
use super::content::{render_content, surface_to_buffer};
use super::ctx::Ctx;
use super::{BlendMode, Document, Layer, LayerContent, Rect, advanced, bounds, effects, masks, shape_split};
use super::{BlendMode, Document, Layer, LayerContent, Rect, adjustment_quantum, advanced, bounds, effects, masks, shape_split};
/// Bounds of a layer's own pixels (union over group children; the canvas
/// for fill layers; an artboard's board).
@@ -379,11 +379,16 @@ pub fn composite_bounds(layer: &Layer, canvas: Rect) -> Option<Rect> {
if !transparent_outside(layer) {
return None;
}
let m = if effects::has_effects(layer) { effects::margin(layer) } else { 0 };
let own = match &layer.content {
LayerContent::Group(g) if g.artboard.is_none() => {
let mut acc = frame_stroke_bounds(layer, canvas);
// A shapeless group's effects cover the whole layer: `layer_bounds` gives the canvas.
LayerContent::Group(g) if g.artboard.is_none() && !advanced::shapeless(layer) => {
// Children up to its effects' reach off the canvas still cast them onto it (group
// effects are built past the canvas).
let near = canvas.inflate(m);
let mut acc = frame_stroke_bounds(layer, near);
for c in g.children.iter().filter(|c| c.visible) {
let b = composite_bounds(c, canvas)?;
let b = composite_bounds(c, near)?;
if !b.is_empty() {
acc = if acc.is_empty() { b } else { acc.union(&b) };
}
@@ -396,7 +401,6 @@ pub fn composite_bounds(layer: &Layer, canvas: Rect) -> Option<Rect> {
if own.is_empty() {
return Some(Rect::EMPTY);
}
let m = if effects::has_effects(layer) { effects::margin(layer) } else { 0 };
Some(own.inflate(m).intersect(&canvas))
}
@@ -424,23 +428,47 @@ pub fn change_bounds(layer: &Layer, canvas: Rect) -> Option<Rect> {
composite_bounds(layer, canvas)
}
/// [`change_bounds`] of `layers[k]` in its sibling list (bottom→top), which also bounds a clipped
/// adjustment layer: it changes only its clip base's pixels, so it reaches as far as showing,
/// hiding or restyling that base does (#6535). Still `None` when there is no base below it (the
/// compositor then draws it as an ordinary adjustment) or the base is an adjustment itself
/// (layers clipped to one composite atop the adjusted backdrop, everywhere). `outer` is the list
/// holding the group whose children `layers` are, and that group's index there (`None` at the
/// top level): a pass-through group that is a clip base renders isolated while it holds a visible
/// adjustment, so showing or hiding one inside can change the whole group.
pub fn change_bounds_in(layers: &[Layer], k: usize, outer: Option<(&[Layer], usize)>, canvas: Rect) -> Option<Rect> {
/// The lists holding the groups around a sibling list, outermost first: each list, and the
/// group's index there (empty at the top level).
pub type Ancestors<'a> = [(&'a [Layer], usize)];
/// The union of two bounds, either of which may be empty.
fn join(a: Rect, b: Rect) -> Rect {
if a.is_empty() {
b
} else if b.is_empty() {
a
} else {
a.union(&b)
}
}
/// [`change_bounds`] of `layers[k]` in its sibling list (bottom→top), which also bounds:
/// - a clipped adjustment layer: it changes only its clip base's pixels, so it reaches as far as
/// showing, hiding or restyling that base does (#6535). Still `None` when there is no base below
/// it (the compositor then draws it as an ordinary adjustment) or the base is an adjustment
/// itself (layers clipped to one composite atop the adjusted backdrop, everywhere). A
/// pass-through group that is a clip base renders isolated while it holds a visible
/// adjustment, so showing or hiding one inside can change the whole group.
/// - an unclipped adjustment layer, and a pass-through group holding one (see
/// [`adjustment_reach`]).
///
/// `ancestors` are the lists holding the groups around `layers`, outermost first; `depth` is the
/// document's, which decides whether adjustments round the backdrop ([`adjustment_quantum`]).
///
/// The result is an **in-place** bound: what changes if the layer changes where it is. For an
/// adjustment it covers the layers beneath it (or its clip base), which don't move with it, so it
/// must never be translated, as a Move drag preview would. Use the context-free [`change_bounds`],
/// which moves with the layer, for that.
pub fn change_bounds_in(layers: &[Layer], k: usize, ancestors: &Ancestors, depth: photocraft_color::SampleType, canvas: Rect) -> Option<Rect> {
let layer = layers.get(k)?;
if layer.clipped && matches!(layer.content, LayerContent::Adjustment(_)) {
let base = layers.get(..k)?.iter().rev().find(|l| !l.clipped)?;
if matches!(base.content, LayerContent::Adjustment(_)) {
return None;
}
if let Some((outer, g)) = outer
&& let Some(group) = outer.get(g)
if let Some((outer, g)) = ancestors.last()
&& let Some(group) = outer.get(*g)
&& group.blend == BlendMode::PassThrough
&& !group.clipped
&& outer.get(g + 1).is_some_and(|above| above.clipped)
@@ -450,7 +478,138 @@ pub fn change_bounds_in(layers: &[Layer], k: usize, outer: Option<(&[Layer], usi
}
return change_bounds(base, canvas);
}
change_bounds(layer, canvas)
match &layer.content {
LayerContent::Adjustment(_) => adjustment_reach(layers, k, ancestors, depth, canvas),
LayerContent::Group(g) if layer.blend == BlendMode::PassThrough && !layer.clipped && change_bounds(layer, canvas).is_none() => {
// Its own pixels, plus where each adjustment inside reaches.
let mut acc = composite_bounds(layer, canvas)?;
let mut inner = ancestors.to_vec();
inner.push((layers, k));
for (j, c) in g.children.iter().enumerate() {
if matches!(c.content, LayerContent::Adjustment(_)) || matches!(c.content, LayerContent::Group(_)) && c.blend == BlendMode::PassThrough {
acc = join(acc, change_bounds_in(&g.children, j, &inner, depth, canvas)?);
}
}
Some(acc)
}
_ => change_bounds(layer, canvas),
}
}
/// Where showing, hiding or restyling the unclipped adjustment `layers[k]` can change the
/// document, or `None` for anywhere. It changes only pixels already drawn beneath it in its
/// isolation scope: the visible layers below it, and inside a pass-through group the layers below
/// the group too, up to the top level or the first isolated group (a pass-through group the
/// compositor isolates reaches less, so this still covers it). Adjustments draw no alpha of their
/// own, so what is transparent there stays so.
///
/// In a 32-bit document it also changes only where its layer mask lets it through (an enabled
/// mask that hides by default at full density, grown by the feather). An integer document rounds
/// the whole backdrop to its levels after every visible adjustment layer, masked or not (#9033),
/// so there the mask bounds nothing. Layers clipped to an adjustment composite atop the adjusted
/// backdrop wherever they draw, and a knockout reveals what lies beneath it: both stay `None`.
pub fn adjustment_reach(layers: &[Layer], k: usize, ancestors: &Ancestors, depth: photocraft_color::SampleType, canvas: Rect) -> Option<Rect> {
let layer = layers.get(k)?;
if layers.get(k + 1).is_some_and(|above| above.clipped) || layer.advanced.knockout != photocraft_doc::Knockout::None {
return None;
}
let mask = adjustment_quantum(depth).is_none().then(|| adjustment_mask_bounds(layer)).flatten();
let below = beneath(layers, k, ancestors, canvas);
let reach = match (mask, below) {
(Some(m), Some(b)) => m.intersect(&b).intersect(&canvas),
(Some(m), None) => m.intersect(&canvas),
(None, b) => b?,
};
// A pass-through group that is a clip base renders isolated while it holds a visible
// adjustment, so showing or hiding the last one switches how the whole group composites: the
// two agree outside `beneath` only up to float rounding. The clipped branch of
// [`change_bounds_in`] covers the group the same way.
if let Some((outer, g)) = ancestors.last()
&& let Some(group) = outer.get(*g)
&& group.blend == BlendMode::PassThrough
&& !group.clipped
&& outer.get(g + 1).is_some_and(|above| above.clipped)
{
return Some(join(reach, composite_bounds(group, canvas)?));
}
Some(reach)
}
/// Where an adjustment's layer mask can let it through: an enabled mask that hides by default at
/// full density, so outside its painted tiles (grown by its feather, or a vector mask's, since
/// the two multiply) it hides the layer.
fn adjustment_mask_bounds(layer: &Layer) -> Option<Rect> {
let m = layer.mask.as_ref().filter(|m| m.enabled && m.density >= 1.0)?;
if !m.surface.default_pixel().first().is_some_and(|v| *v <= 0.0) {
return None;
}
let vector = layer.vector_mask.as_ref().filter(|v| v.enabled).map_or(0.0, |v| photocraft_vector::feather::feather_sigma(v.feather));
let sigma = photocraft_vector::feather::feather_sigma(m.feather).max(vector);
let tiles = m.surface.tile_bounds();
Some(if sigma > 0.0 { photocraft_vector::feather::grow(tiles, sigma) } else { tiles })
}
/// The union of [`composite_bounds`] of the visible layers below `layers[k]`, continuing below a
/// pass-through group into the list holding it; `None` when any may draw anywhere.
fn beneath(layers: &[Layer], k: usize, ancestors: &Ancestors, canvas: Rect) -> Option<Rect> {
let mut acc = Rect::EMPTY;
for l in layers.get(..k)?.iter().filter(|l| l.visible) {
acc = join(acc, composite_bounds(l, canvas)?);
}
if let Some((&(outer, g), rest)) = ancestors.split_last()
&& let Some(group) = outer.get(g)
&& group.blend == BlendMode::PassThrough
{
if group.clipped {
return None;
}
// An artboard's background lies beneath its layers.
if let LayerContent::Group(gr) = &group.content
&& let Some(ab) = gr.artboard.as_ref().filter(|ab| ab.background.rgba().is_some())
{
acc = join(acc, ab.rect.intersect(&canvas));
}
acc = join(acc, beneath(outer, g, rest, canvas)?);
}
Some(acc)
}
/// Where a layer sits: its sibling list, its index there, and the lists around it ([`Ancestors`]).
pub type Placement<'a> = (&'a [Layer], usize, Vec<(&'a [Layer], usize)>);
/// Where layer `id` sits in `doc` ([`Placement`]).
pub fn siblings_of(doc: &Document, id: photocraft_doc::LayerId) -> Option<Placement<'_>> {
fn find<'a>(layers: &'a [Layer], id: photocraft_doc::LayerId, path: &mut Vec<(&'a [Layer], usize)>) -> Option<(&'a [Layer], usize)> {
for (k, l) in layers.iter().enumerate() {
if l.id == id {
return Some((layers, k));
}
if let Some(c) = l.children() {
path.push((layers, k));
if let Some(hit) = find(c, id, path) {
return Some(hit);
}
path.pop();
}
}
None
}
let mut path = Vec::new();
let (layers, k) = find(&doc.layers, id, &mut path)?;
Some((layers, k, path))
}
/// [`change_bounds_in`] of layer `id` where it sits in `doc`, grown by the reach of the effects
/// of the groups around it (a parent's drop shadow), or `None` for anywhere (or no such layer).
/// An in-place bound: never translate it (see [`change_bounds_in`]).
pub fn layer_change_bounds(doc: &Document, id: photocraft_doc::LayerId) -> Option<Rect> {
let (layers, k, ancestors) = siblings_of(doc, id)?;
let reach = ancestors.iter().filter_map(|(ls, g)| ls.get(*g)).filter(|l| effects::has_effects(l)).fold(0i32, |r, l| r.saturating_add(effects::margin(l)));
// Bounded within that reach of the canvas, not on it: a layer just off the canvas still
// moves a parent's shadow onto it (group effects are built past the canvas).
let canvas = doc.bounds();
let r = change_bounds_in(layers, k, &ancestors, doc.depth, canvas.inflate(reach))?;
Some(if r.is_empty() { r } else { r.inflate(reach).intersect(&canvas) })
}
/// Whether the layer's content is transparent outside its bounds (every surface it draws from
+2 -2
View File
@@ -65,8 +65,8 @@ use ctx::{Ctx, prepare_effects};
use ctx::{LayerKeys, PREPARE_DEPTH, effect_layers, prepare_tones};
use extents::mask_vals;
pub use extents::{
change_bounds, change_bounds_in, composite_bounds, effect_outline, effect_source_bounds, effects_frame, hard_shape_bounds, layer_bounds, paint_bounds,
relayout_damage, shape_bounds, soft_shape_frame, transparent_outside,
Ancestors, Placement, adjustment_reach, change_bounds, change_bounds_in, composite_bounds, effect_outline, effect_source_bounds, effects_frame,
hard_shape_bounds, layer_bounds, layer_change_bounds, paint_bounds, relayout_damage, shape_bounds, siblings_of, soft_shape_frame, transparent_outside,
};
#[cfg(test)]
use extents::{mask_k, shapeless_stroke_bounds, trim_to_painted};
+225 -11
View File
@@ -794,7 +794,7 @@ fn clipped_adjustment_changes_only_its_base() {
adj.clipped = true;
d.layers.push(adj);
assert_eq!(change_bounds(&d.layers[3], canvas), None);
assert_eq!(change_bounds_in(&d.layers, 3, None, canvas), Some(base_rect));
assert_eq!(change_bounds_in(&d.layers, 3, &[], SampleType::U8, canvas), Some(base_rect));
// Every pixel the toggle changes lies inside those bounds.
let shown = render(&d, canvas);
d.layers[3].visible = false;
@@ -810,24 +810,24 @@ fn clipped_adjustment_changes_only_its_base() {
}
assert_eq!(changed, base_rect);
// Other layers keep their own bounds.
assert_eq!(change_bounds_in(&d.layers, 2, None, canvas), Some(Rect::new(3, 2, 4, 3)));
assert_eq!(change_bounds_in(&d.layers, 9, None, canvas), None);
assert_eq!(change_bounds_in(&d.layers, 2, &[], SampleType::U8, canvas), Some(Rect::new(3, 2, 4, 3)));
assert_eq!(change_bounds_in(&d.layers, 9, &[], SampleType::U8, canvas), None);
// Unclipped, or with no base beneath it, it reaches everything.
let mut free = d.layers.clone();
free[3].clipped = false;
assert_eq!(change_bounds_in(&free, 3, None, canvas), None);
assert_eq!(change_bounds_in(&free, 3, &[], SampleType::U8, canvas), Some(canvas));
let orphan = vec![d.layers[3].clone()];
assert_eq!(change_bounds_in(&orphan, 0, None, canvas), None);
assert_eq!(change_bounds_in(&orphan, 0, &[], SampleType::U8, canvas), None);
// Clipped layers on an adjustment composite atop the adjusted backdrop.
let on_adj = vec![d.layers[0].clone(), Layer::new("lv", LayerContent::Adjustment(Adjustment::Invert)), d.layers[3].clone()];
assert_eq!(change_bounds_in(&on_adj, 2, None, canvas), None);
assert_eq!(change_bounds_in(&on_adj, 2, &[], SampleType::U8, canvas), None);
// A pass-through group holding an adjustment is isolated only while it has visible clipped
// layers, so they change whether its adjustment reaches the layers beneath.
let mut g = Layer::group("g", vec![solid_layer("in", base_rect, [0.2, 0.4, 0.6, 1.0]), Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
g.blend = BlendMode::PassThrough;
let on_group = vec![d.layers[0].clone(), g, d.layers[3].clone()];
assert_eq!(change_bounds_in(&on_group, 2, None, canvas), None);
assert_eq!(change_bounds_in(&on_group, 2, &[], SampleType::U8, canvas), None);
}
/// #6535: a clipped adjustment with no base beneath it, inside a pass-through group, composites
@@ -848,12 +848,12 @@ fn orphan_clipped_adjustment_in_pass_through_base_reaches_everything() {
let shown = px(&d, 0, 0);
d.layers[2].visible = false;
assert!(!close4(shown, px(&d, 0, 0)), "the toggle changes pixels outside the group");
assert_eq!(change_bounds_in(&d.layers, 2, None, canvas), None);
assert_eq!(change_bounds_in(&d.layers, 2, &[], SampleType::U8, canvas), None);
assert_eq!(change_bounds(&d.layers[1], canvas), None);
// With a base beneath it the clipped adjustment stays inside the group.
let LayerContent::Group(g) = &mut d.layers[1].content else { panic!("group") };
g.children.swap(0, 1);
assert_eq!(change_bounds_in(&d.layers, 2, None, canvas), Some(Rect::new(2, 1, 4, 3)));
assert_eq!(change_bounds_in(&d.layers, 2, &[], SampleType::U8, canvas), Some(Rect::new(2, 1, 4, 3)));
}
/// A pass-through group that is a clip base renders isolated while it holds a visible
@@ -880,11 +880,225 @@ fn clipped_adjustment_in_pass_through_clip_base_reaches_its_group() {
assert!(!close4(shown, px(&d, 7, 5)), "hiding A changes Q, outside A's base");
let group = Rect::new(2, 1, 8, 6);
let LayerContent::Group(g) = &d.layers[2].content else { panic!("group") };
assert_eq!(change_bounds_in(&g.children, 1, Some((&d.layers, 2)), canvas), Some(group));
assert_eq!(change_bounds_in(&g.children, 1, &[(&d.layers, 2)], SampleType::U8, canvas), Some(group));
// Not a clip base: the group stays pass-through either way, and A changes only its base.
d.layers.truncate(3);
let LayerContent::Group(g) = &d.layers[2].content else { panic!("group") };
assert_eq!(change_bounds_in(&g.children, 1, Some((&d.layers, 2)), canvas), Some(Rect::new(2, 1, 4, 3)));
assert_eq!(change_bounds_in(&g.children, 1, &[(&d.layers, 2)], SampleType::U8, canvas), Some(Rect::new(2, 1, 4, 3)));
}
/// The composite of `after` equals `before`'s outside `damage`, exactly (as the engine's
/// `unchanged_outside` oracle checks a step's damage).
fn unchanged_outside(before: &Document, after: &Document, damage: Rect) -> bool {
let (x, y) = (render(before, before.bounds()), render(after, after.bounds()));
let w = before.bounds().width() as i32;
x.px.iter().zip(&y.px).enumerate().all(|(i, (p, q))| p == q || damage.contains(i as i32 % w, i as i32 / w))
}
/// `d` with `layers[k]` (or, with `child`, that child of the group there) hidden.
fn hidden_at(d: &Document, k: usize, child: Option<usize>) -> Document {
let mut h = d.clone();
match child {
None => h.layers[k].visible = false,
Some(c) => h.layers[k].children_mut().unwrap()[c].visible = false,
}
h
}
/// An Invert adjustment whose layer mask reveals only `r`.
fn masked_invert(r: Rect) -> Layer {
let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
let mut m = LayerMask::hide_all();
m.surface.fill_rect(r, &[1.0]);
adj.mask = Some(m);
adj
}
/// A layer at 37 % opacity, so the backdrop it leaves falls between an integer document's
/// levels (which an adjustment above rounds away).
fn faint_layer(r: Rect) -> Layer {
let mut l = solid_layer("faint", r, [0.2, 0.4, 0.6, 1.0]);
l.opacity = 0.37;
l
}
/// #9329: in a 32-bit document a masked adjustment changes only under its mask (grown by its
/// feather), even over an opaque Background.
#[test]
fn a_masked_adjustment_changes_only_under_its_mask() {
let canvas = Rect::new(0, 0, 800, 600);
let reveal = Rect::new(520, 330, 560, 370);
for feather in [0.0, 6.0] {
let mut d = Document::with_background("t", Size::new(800, 600), ColorMode::Rgb, SampleType::F32, Color::WHITE);
d.layers.push(faint_layer(Rect::new(100, 100, 700, 500)));
let mut adj = masked_invert(reveal);
adj.mask.as_mut().unwrap().feather = feather;
d.layers.push(adj);
let b = change_bounds_in(&d.layers, 2, &[], SampleType::F32, canvas).expect("bounded by the mask");
assert!(b.contains_rect(&reveal) && b.width() < canvas.width() && b.height() < canvas.height(), "{b:?}");
let h = hidden_at(&d, 2, None);
assert_ne!(render(&d, canvas), render(&h, canvas));
assert!(unchanged_outside(&d, &h, b), "feather {feather}");
// At full density only: a lower density lets the adjustment through everywhere.
let mut thin = d.clone();
thin.layers[2].mask.as_mut().unwrap().density = 0.5;
assert_eq!(change_bounds_in(&thin.layers, 2, &[], SampleType::F32, canvas), Some(canvas));
}
}
/// #9329: with no Background, an unmasked adjustment reaches only the layers beneath it, in an
/// integer document too (rounding the backdrop leaves transparent pixels transparent).
#[test]
fn an_adjustment_over_small_layers_reaches_only_them() {
let canvas = Rect::new(0, 0, 40, 30);
let mut d = Document::new("t", Size::new(40, 30), ColorMode::Rgb, SampleType::U8);
let (p, q) = (Rect::new(2, 3, 10, 9), Rect::new(20, 12, 30, 25));
d.layers.push(solid_layer("p", p, [0.2, 0.4, 0.6, 1.0]));
d.layers.push(faint_layer(q));
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
let k = d.layers.len() - 1;
let b = change_bounds_in(&d.layers, k, &[], SampleType::U8, canvas);
assert_eq!(b, Some(p.union(&q)));
let h = hidden_at(&d, k, None);
assert_ne!(render(&d, canvas), render(&h, canvas));
assert!(unchanged_outside(&d, &h, b.unwrap()));
}
/// #9329: over an opaque Background an adjustment reaches the canvas. In an integer document
/// even a masked one does: the backdrop is rounded to the document's levels after every visible
/// adjustment layer (#9033), outside its mask too.
#[test]
fn an_adjustment_over_a_background_reaches_the_canvas() {
let canvas = Rect::new(0, 0, 800, 600);
let reveal = Rect::new(520, 330, 560, 370);
let mut d = doc_white(800, 600);
d.layers.push(faint_layer(Rect::new(100, 100, 700, 500)));
d.layers.push(masked_invert(reveal));
assert_eq!(change_bounds_in(&d.layers, 2, &[], SampleType::U8, canvas), Some(canvas));
let h = hidden_at(&d, 2, None);
assert!(!unchanged_outside(&d, &h, reveal), "hiding it re-rounds pixels outside its mask");
let unmasked = {
let mut u = d.clone();
u.layers[2].mask = None;
u
};
assert_eq!(change_bounds_in(&unmasked.layers, 2, &[], SampleType::U8, canvas), Some(canvas));
assert_eq!(change_bounds_in(&unmasked.layers, 2, &[], SampleType::F32, canvas), Some(canvas));
}
/// #9329: inside a pass-through group an adjustment reaches the group's layers beneath it and
/// the layers beneath the group; so does the group itself.
#[test]
fn an_adjustment_in_a_pass_through_group_reaches_below_the_group() {
let canvas = Rect::new(0, 0, 40, 30);
let (p, q) = (Rect::new(2, 3, 10, 9), Rect::new(20, 12, 30, 25));
let mut d = Document::new("t", Size::new(40, 30), ColorMode::Rgb, SampleType::U8);
d.layers.push(faint_layer(p));
let mut g = Layer::group("g", vec![solid_layer("q", q, [0.2, 0.4, 0.6, 1.0]), Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
g.blend = BlendMode::PassThrough;
d.layers.push(g);
let LayerContent::Group(gc) = &d.layers[1].content else { panic!("group") };
let b = change_bounds_in(&gc.children, 1, &[(&d.layers, 1)], SampleType::U8, canvas);
assert_eq!(b, Some(p.union(&q)));
let h = hidden_at(&d, 1, Some(1));
assert_ne!(render(&d, canvas), render(&h, canvas));
assert!(unchanged_outside(&d, &h, b.unwrap()));
// The group: its own pixels and its adjustment's reach.
assert_eq!(change_bounds(&d.layers[1], canvas), None);
let gb = change_bounds_in(&d.layers, 1, &[], SampleType::U8, canvas);
assert_eq!(gb, Some(p.union(&q)));
assert!(unchanged_outside(&d, &hidden_at(&d, 1, None), gb.unwrap()));
}
/// #9329: inside an isolated (Normal) group an adjustment reaches only the group's layers
/// beneath it.
#[test]
fn an_adjustment_in_an_isolated_group_stops_at_the_group() {
let canvas = Rect::new(0, 0, 40, 30);
let (p, q) = (Rect::new(2, 3, 10, 9), Rect::new(20, 12, 30, 25));
let mut d = Document::new("t", Size::new(40, 30), ColorMode::Rgb, SampleType::U8);
d.layers.push(faint_layer(p));
let mut g = Layer::group("g", vec![faint_layer(q), Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
g.blend = BlendMode::Normal;
d.layers.push(g);
let LayerContent::Group(gc) = &d.layers[1].content else { panic!("group") };
let b = change_bounds_in(&gc.children, 1, &[(&d.layers, 1)], SampleType::U8, canvas);
assert_eq!(b, Some(q));
let h = hidden_at(&d, 1, Some(1));
assert_ne!(render(&d, canvas), render(&h, canvas));
assert!(unchanged_outside(&d, &h, q));
}
/// #9329: inside a pass-through artboard an adjustment also reaches the artboard's background,
/// which lies beneath its layers.
#[test]
fn an_adjustment_in_a_pass_through_artboard_reaches_its_background() {
let canvas = Rect::new(0, 0, 40, 30);
let board = Rect::new(4, 3, 24, 20);
let mut d = Document::new("t", Size::new(40, 30), ColorMode::Rgb, SampleType::U8);
let mut ab = Layer::group("board", vec![Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
let LayerContent::Group(g) = &mut ab.content else { panic!("group") };
g.artboard = Some(photocraft_doc::Artboard::new(board));
d.layers.push(ab);
let LayerContent::Group(gc) = &d.layers[0].content else { panic!("group") };
let b = change_bounds_in(&gc.children, 0, &[(&d.layers, 0)], SampleType::U8, canvas);
assert_eq!(b, Some(board));
let h = hidden_at(&d, 0, Some(0));
assert_ne!(render(&d, canvas), render(&h, canvas));
assert!(unchanged_outside(&d, &h, board));
}
/// A layer just off the left edge of a 40 × 30 canvas, in a group whose drop shadow reaches
/// onto it.
fn shadowed_group_off_the_canvas() -> Layer {
let mut g = Layer::group("g", vec![solid_layer("off", Rect::new(-6, 8, -1, 16), [0.2, 0.4, 0.6, 1.0])]);
g.effects.items.push(Effect::default_drop_shadow());
g
}
/// #9329: an adjustment above a group whose pixels lie just off the canvas reaches the group's
/// drop shadow on the canvas.
#[test]
fn an_adjustment_over_a_shadowed_group_off_the_canvas_reaches_the_shadow() {
let canvas = Rect::new(0, 0, 40, 30);
let mut d = Document::new("t", Size::new(40, 30), ColorMode::Rgb, SampleType::U8);
d.layers.push(shadowed_group_off_the_canvas());
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
let b = change_bounds_in(&d.layers, 1, &[], SampleType::U8, canvas).expect("bounded");
let h = hidden_at(&d, 1, None);
assert_ne!(render(&d, canvas), render(&h, canvas), "the shadow reaches the canvas");
assert!(unchanged_outside(&d, &h, b), "{b:?}");
}
/// #9329: a layer just off the canvas in a group with a drop shadow changes the shadow on the
/// canvas: [`layer_change_bounds`] grows by the reach of the groups around it.
#[test]
fn a_layer_off_the_canvas_in_a_shadowed_group_bounds_the_shadow() {
let canvas = Rect::new(0, 0, 40, 30);
let mut d = Document::new("t", Size::new(40, 30), ColorMode::Rgb, SampleType::U8);
d.layers.push(shadowed_group_off_the_canvas());
let id = d.layers[0].children().unwrap()[0].id;
let b = layer_change_bounds(&d, id).expect("bounded");
let h = hidden_at(&d, 0, Some(0));
assert_ne!(render(&d, canvas), render(&h, canvas), "the shadow reaches the canvas");
assert!(unchanged_outside(&d, &h, b), "{b:?}");
}
/// #9329: a group with Transparency Shapes Layer off takes the whole layer as its effects'
/// shape, so it draws over the whole canvas, also while it holds an adjustment.
#[test]
fn a_shapeless_group_holding_an_adjustment_reaches_the_canvas() {
let canvas = Rect::new(0, 0, 40, 30);
let q = Rect::new(20, 12, 30, 25);
let mut d = Document::new("t", Size::new(40, 30), ColorMode::Rgb, SampleType::U8);
let mut g = Layer::group("g", vec![solid_layer("q", q, [0.2, 0.4, 0.6, 1.0]), Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
g.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::BLACK });
g.advanced.transparency_shapes = false;
d.layers.push(g);
let h = hidden_at(&d, 0, None);
assert!(!unchanged_outside(&d, &h, q.inflate(effects::margin(&d.layers[0]))), "the overlay draws past its layers");
assert_eq!(composite_bounds(&d.layers[0], canvas), Some(canvas));
assert_eq!(change_bounds_in(&d.layers, 0, &[], SampleType::U8, canvas), Some(canvas));
}
#[test]
+13 -4
View File
@@ -71,6 +71,8 @@ pub mod contour_lib_cmds;
mod count_delete_group_tests;
#[cfg(test)]
mod count_history_names_tests;
#[cfg(test)]
mod count_record_label_tests;
pub mod cutout_cmds;
pub mod distort_cmds;
pub mod document_preset_cmds;
@@ -206,9 +208,14 @@ pub fn list_entry(s: &Session, c: &CommandSpec, reason: Option<String>) -> Value
/// Other names a command is looked for by, beyond its id and label: the Photoshop tools and
/// options it stands for (#5060). `select.rect` is also the Elliptical and Single Row/Column
/// Marquee, which an agent searches for as "ellipse" or "marquee".
pub(crate) const SEARCH_KEYWORDS: &[(&str, &str)] =
&[("select.rect", "Rectangular Marquee, Elliptical Marquee (\"ellipse\":true), ellipse, oval, circle, Single Row/Column Marquee (\"line\")")];
/// Marquee, which an agent searches for as "ellipse" or "marquee". The RAW Develop filter and
/// RAW Defaults keep Photoshop's "Camera Raw" name as a search alias only: the menus and windows
/// say "RAW Develop" (#12403).
pub(crate) const SEARCH_KEYWORDS: &[(&str, &str)] = &[
("select.rect", "Rectangular Marquee, Elliptical Marquee (\"ellipse\":true), ellipse, oval, circle, Single Row/Column Marquee (\"line\")"),
("filter.cameraRaw", "Camera Raw Filter"),
("edit.preferences.rawDefaults", "Camera Raw Defaults"),
];
/// [`SEARCH_KEYWORDS`] of command `id`, if it has any.
pub fn search_keywords(id: &str) -> Option<&'static str> {
@@ -580,6 +587,7 @@ fn build() -> Vec<CommandSpec> {
let add_copy = layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true);
// The dialog offers Duplicate Layer's own name: only a different one was typed (#9023).
let mut typed = None;
let mut shift = (0, 0);
let nid = s.edit("Duplicate Layer", |doc, active| {
let mut dup = layer_copy(doc, id, add_copy)?;
if let Some(name) = name.as_ref().filter(|n| **n != dup.name) {
@@ -590,11 +598,12 @@ fn build() -> Vec<CommandSpec> {
// A lone copy joins its original's link group, as in the reference application (#11217).
crate::layer_multi_cmds::relink_copies(doc, &[nid])?;
if !in_place {
crate::artboard_cmds::place_copy(doc, nid)?;
shift = crate::artboard_cmds::place_copy(doc, nid)?;
}
*active = Some(nid);
Ok(nid)
})?;
crate::artboard_cmds::follow_shift(s, shift);
if let Some(name) = typed {
crate::type_cmds::note_typed_name(s, nid, &name);
}
+7 -3
View File
@@ -266,7 +266,7 @@ pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess
Ok(v)
}
Err(e) => {
st.doc = before;
let failed = std::mem::replace(&mut st.doc, before);
st.active_layer = prior.active;
st.selected_layers = prior.selected;
st.layer_anchor = anchor;
@@ -279,7 +279,9 @@ pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess
if was_clean {
st.saved_revision = st.revision;
}
st.last_damage = None;
// This revision's damage; `Session::dispatch` reports the command's whole span (nothing,
// when the inner commands had changed pixels and the restore took them back).
st.last_damage = crate::layer_multi_cmds::step_damage(&failed, &st.doc);
Err(e)
}
};
@@ -1099,7 +1101,9 @@ fn record(s: &mut Session, p: &Value) -> Result<Value> {
other => return Err(bad(CMD, format!("unknown source `{other}` (auto|selection|ruler|count)"))),
};
// Numbered only once the rows exist, so a failed measurement does not use up a label.
let label = format!("Measurement {}", s.analysis.next_measurement + 1);
// A count's row is "Count N" (#11775); the number is the shared one.
let word = if source == "count" { "Count" } else { "Measurement" };
let label = format!("{word} {}", s.analysis.next_measurement + 1);
let now = p.get("dateTime").and_then(Value::as_str).map(str::to_string).unwrap_or_else(now_iso);
let factor = d.measurement.scale.factor();
let mut rows: Vec<Map<String, Value>> = Vec::new();
+43 -21
View File
@@ -152,9 +152,11 @@ fn xywh(r: Rect) -> [i64; 4] {
/// it puts it right of the board, a [`GAP`] away, sliding further right past any board in the
/// way. Moves the copy, layer `id` (just inserted on top of its original), there with its
/// contents and grows the canvas to show it; anything that isn't an artboard is left alone.
pub(crate) fn place_copy(doc: &mut Document, id: LayerId) -> Result<()> {
/// Returns [`fit_canvas`]'s shift (a board already past the origin, as a PSD can hold, re-bases the
/// document), for [`follow_shift`].
pub(crate) fn place_copy(doc: &mut Document, id: LayerId) -> Result<(i32, i32)> {
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let Some(r) = l.artboard().map(|a| a.rect) else { return Ok(()) };
let Some(r) = l.artboard().map(|a| a.rect) else { return Ok((0, 0)) };
let name = l.name.clone();
let boards: Vec<Rect> = doc.artboards().iter().filter(|b| b.0 != id).map(|b| b.2.rect).collect();
let (y0, y1, w) = (i64::from(r.y0), i64::from(r.y1), i64::from(r.width()));
@@ -180,8 +182,7 @@ pub(crate) fn place_copy(doc: &mut Document, id: LayerId) -> Result<()> {
let copy = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
crate::commands::translate_layer(&snapshot, copy, dx, 0);
crate::vector_cmds::translate_vectors(&snapshot, copy, f64::from(dx), 0.0);
fit_canvas(doc);
Ok(())
Ok(fit_canvas(doc))
}
/// Record a Duplicate's `inPlace` only when a copy (the selection after it) is an artboard: for
@@ -203,8 +204,11 @@ pub(crate) fn recorded_duplicate(s: &Session, mut p: Value) -> Value {
/// contents, guides, paths…) right/down by its overhang, then the canvas grows to show it. An
/// axis whose boards span more than the largest canvas, or a shift that would push pixels past
/// the 32-bit range, stays unshifted: the board stays partly off-canvas, as one past the size
/// limit does.
pub(crate) fn fit_canvas(doc: &mut Document) {
/// limit does. Returns the shift, for [`follow_shift`]: `translate_doc` moves the document's own
/// geometry, but the session's Color Sampler points and painting symmetry stay behind unless the
/// caller passes it on.
#[must_use = "pass the shift to `follow_shift`, or the Color Samplers and symmetry stay behind (#9302)"]
pub(crate) fn fit_canvas(doc: &mut Document) -> (i32, i32) {
let max = i64::from(crate::document_preset_cmds::MAX_DIMENSION);
let boards: Vec<Rect> = doc.artboards().iter().map(|b| b.2.rect).collect();
// The canvas and every board, per axis (`lo` ≤ 0 is the canvas origin or a board past it).
@@ -226,6 +230,20 @@ pub(crate) fn fit_canvas(doc: &mut Document) {
// shapes against it, leaving them missing or clipped (#2389).
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes);
}
(sx, sy)
}
/// Keep the Color Sampler points and the painting symmetry on their pixels after an edit shifted
/// the document by `(dx, dy)` to show a board past the origin ([`fit_canvas`]), as Canvas Size's
/// sampler map does (#9302). View state: no history step. The canvas only grew, so every point
/// stays on it.
pub(crate) fn follow_shift(s: &mut Session, (dx, dy): (i32, i32)) {
let Some(st) = s.active_mut().filter(|_| (dx, dy) != (0, 0)) else { return };
for p in &mut st.color_samplers {
*p = [p[0] + f64::from(dx), p[1] + f64::from(dy)];
}
let a = photocraft_geom::Affine::translate(f64::from(dx), f64::from(dy));
st.symmetry = st.symmetry.take().and_then(|sym| sym.transform(&a));
}
fn next_artboard_name(doc: &Document) -> String {
@@ -294,7 +312,7 @@ fn new_artboard(s: &mut Session, p: &Value) -> Result<Value> {
None => false,
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "`takeBackground` must be true or false"))?,
};
let id = s.edit("New Artboard", |doc, active| {
let (id, shift) = s.edit("New Artboard", |doc, active| {
let mut g = Layer::group(name.unwrap_or_else(|| next_artboard_name(doc)), vec![]);
set_artboard(&mut g, Artboard { rect, background, preset });
// Only the first board takes the Background in (decided before the board exists).
@@ -303,10 +321,11 @@ fn new_artboard(s: &mut Session, p: &Value) -> Result<Value> {
if take {
move_background_into(doc, id);
}
fit_canvas(doc);
let shift = fit_canvas(doc);
*active = Some(id);
Ok(id)
Ok((id, shift))
})?;
follow_shift(s, shift);
crate::layer_multi_cmds::set_selection(s, vec![id], Some(id), Some(id))?;
Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]}))
}
@@ -344,17 +363,18 @@ fn artboard_from_group(s: &mut Session, p: &Value) -> Result<Value> {
}
let canvas = d.doc.bounds();
let background = if apply_background { background_param(p, cmd)?.unwrap_or_default() } else { ArtboardBackground::White };
let rect = s.edit("Artboard from Group", |doc, active| {
let (rect, shift) = s.edit("Artboard from Group", |doc, active| {
if !doc.layers.iter().any(|t| t.id == id && t.is_group() && t.artboard().is_none()) {
return Err(EngineError::Other("artboards are made from a top-level layer group".into()));
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let rect = crate::layer_multi_cmds::layer_bounds(l).unwrap_or(canvas);
set_artboard(l, Artboard { rect, background, preset });
fit_canvas(doc);
let shift = fit_canvas(doc);
*active = Some(id);
Ok(rect)
Ok((rect, shift))
})?;
follow_shift(s, shift);
crate::layer_multi_cmds::set_selection(s, vec![id], Some(id), Some(id))?;
Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]}))
}
@@ -388,7 +408,7 @@ fn artboard_from_layers(s: &mut Session, p: &Value) -> Result<Value> {
// A size of the user's own (a post, a story…) from the layers' top left; 0 or none keeps
// their bounds on that axis (#9087).
let size = [int(p, "width").filter(|v| *v != 0), int(p, "height").filter(|v| *v != 0)];
let (gid, rect) = s.edit("Artboard from Layers", |doc, active| {
let (gid, rect, shift) = s.edit("Artboard from Layers", |doc, active| {
let ids = crate::layer_multi_cmds::top_level(doc, &sel);
if ids.is_empty() || ids.iter().any(|id| doc.artboard_of(*id).is_some()) {
return Err(EngineError::Other("select layers that are not in an artboard".into()));
@@ -403,10 +423,11 @@ fn artboard_from_layers(s: &mut Session, p: &Value) -> Result<Value> {
set_artboard(&mut g, Artboard { rect, background, preset: String::new() });
let gid = doc.insert_above(None, g);
crate::layer_multi_cmds::check_group_depth(doc, "Artboard from Layers")?;
fit_canvas(doc);
let shift = fit_canvas(doc);
*active = Some(gid);
Ok((gid, rect))
Ok((gid, rect, shift))
})?;
follow_shift(s, shift);
crate::layer_multi_cmds::set_selection(s, vec![gid], Some(gid), Some(gid))?;
Ok(json!({"layer": gid.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]}))
}
@@ -445,7 +466,7 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
));
}
};
s.edit("Edit Artboard", |doc, _| {
let shift = s.edit("Edit Artboard", |doc, _| {
if move_contents && (dx, dy) != (0, 0) {
let snapshot = doc.clone();
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
@@ -467,9 +488,9 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
None if rect.width() != old.rect.width() || rect.height() != old.rect.height() => a.preset.clear(),
None => {}
}
fit_canvas(doc);
Ok(())
Ok(fit_canvas(doc))
})?;
follow_shift(s, shift);
Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]}))
}
@@ -546,7 +567,7 @@ fn add_beside(s: &mut Session, p: &Value) -> Result<Value> {
let grown = |len: u32, by: i32| (i64::from(len) + i64::from(by)).clamp(1, 300_000) as u32;
let label = if duplicate { "Duplicate Artboard" } else { "New Artboard" };
let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true);
let (nid, rect) = s.edit(label, |doc, active| {
let (nid, rect, (fx, fy)) = s.edit(label, |doc, active| {
if (sx, sy) != (0, 0) {
crate::image_cmds::translate_doc(doc, cmd, sx, sy)?;
doc.size = Size::new(grown(doc.size.width, sx), grown(doc.size.height, sy));
@@ -575,10 +596,11 @@ fn add_beside(s: &mut Session, p: &Value) -> Result<Value> {
set_artboard(&mut g, Artboard { rect, background: src.background, preset: src.preset.clone() });
doc.insert_above(Some(id), g)
};
fit_canvas(doc);
let fitted = fit_canvas(doc);
*active = Some(nid);
Ok((nid, rect))
Ok((nid, rect, fitted))
})?;
follow_shift(s, (sx.saturating_add(fx), sy.saturating_add(fy)));
crate::layer_multi_cmds::set_selection(s, vec![nid], Some(nid), Some(nid))?;
Ok(json!({"layer": nid.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]}))
}
@@ -723,6 +723,87 @@ fn moving_an_artboard_left_of_or_above_the_origin_grows_the_canvas_there() {
}
}
#[test]
fn a_board_past_the_origin_moves_the_color_samplers_and_symmetry_with_the_document() {
// #9302: the re-based document moved everything but the Color Sampler points and the
// painting symmetry, which then sat on other pixels. They follow, as with Canvas Size.
let samplers = |s: &Session| s.active().unwrap().color_samplers.clone();
let center = |s: &Session| s.active().unwrap().symmetry.as_ref().and_then(|y| y.preset()).map(|p| p.center);
for (cmd, p) in [("layer.translate", json!({"dx": -60, "dy": -30})), ("layer.artboard.set", json!({"x": -50, "y": -20}))] {
let mut s = session(8);
let gid = board_with_square(&mut s);
s.execute("view.colorSamplers.add", json!({"x": 15, "y": 15})).unwrap();
s.execute("paint.setSymmetry", json!({"mode": "vertical", "center": [70.0, 20.0]})).unwrap();
s.execute("layer.select", json!({"layer": gid.0})).unwrap();
s.execute(cmd, p).unwrap();
// The board lands at (0, 0) after the document shifted right 50 and down 20; the sampler
// is pinned to the canvas, not the board, so it shifts with the document only.
assert_eq!(board_rect(&s, gid.0), Rect::new(0, 0, 40, 40), "{cmd}");
assert_eq!(samplers(&s), vec![[65.0, 35.0]], "{cmd}");
assert_eq!(center(&s), Some([120.0, 40.0]), "{cmd}: the symmetry stays on its pixels");
}
// A new board left of the canvas shifts the photo right by its overhang.
let mut s = session(8);
add_square(&mut s, Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]);
s.execute("view.colorSamplers.add", json!({"x": 15, "y": 15})).unwrap();
s.execute("layer.new.artboard", json!({"rect": [-200, 0, 100, 80]})).unwrap();
assert_eq!(samplers(&s), vec![[215.0, 15.0]]);
assert!(red_at(&s, 215, 15));
// A board made from layers (or a group) left of the canvas shifts by their overhang.
for cmd in ["layer.new.artboardFromLayers", "layer.new.artboardFromGroup"] {
let mut s = session(8);
add_square(&mut s, Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]);
s.execute("view.colorSamplers.add", json!({"x": 15, "y": 15})).unwrap();
add_square(&mut s, Rect::new(-30, 40, -10, 60), [0.0, 0.0, 1.0, 1.0]);
if cmd.ends_with("Group") {
s.execute("layer.groupLayers", json!({})).unwrap();
}
s.execute(cmd, json!({})).unwrap();
assert_eq!(samplers(&s), vec![[45.0, 15.0]], "{cmd}");
assert!(red_at(&s, 45, 15), "{cmd}");
}
// The "+" button left of a board at the origin shifts by the board and the gap.
let mut s = session(8);
let gid = board_with_square(&mut s);
s.execute("layer.artboard.set", json!({"x": 0, "y": 0})).unwrap();
s.execute("view.colorSamplers.add", json!({"x": 5, "y": 5})).unwrap();
s.execute("layer.artboard.addBeside", json!({"layer": gid.0, "side": "left"})).unwrap();
let by = 40 + GAP;
assert_eq!(board_rect(&s, gid.0), Rect::new(by, 0, by + 40, 40));
assert_eq!(samplers(&s), vec![[f64::from(by) + 5.0, 5.0]]);
assert!(red_at(&s, by + 5, 5));
// A board right of the canvas shifts nothing.
s.execute("layer.artboard.addBeside", json!({"layer": gid.0, "side": "right"})).unwrap();
assert_eq!(samplers(&s), vec![[f64::from(by) + 5.0, 5.0]]);
}
#[test]
fn duplicating_a_board_beside_one_past_the_origin_moves_the_color_samplers_with_the_document() {
// #9302 review: Layer › Duplicate puts the copy right of its board, but `fit_canvas` still
// re-bases a document whose boards were already past the origin (as a PSD can hold them).
// Single (`layer`) and several selected layers (`duplicate_selected`) both follow the shift.
for multi in [false, true] {
let mut s = session(8);
let gid = board_with_square(&mut s);
s.edit("past the origin", |doc, _| {
doc.layer_mut(gid).and_then(|l| l.artboard_mut()).unwrap().rect = Rect::new(-40, 10, 0, 50);
Ok(())
})
.unwrap();
let sq = add_square(&mut s, Rect::new(60, 10, 80, 30), [1.0, 0.0, 0.0, 1.0]);
s.execute("view.colorSamplers.add", json!({"x": 65, "y": 15})).unwrap();
if multi {
crate::layer_multi_cmds::set_selection(&mut s, vec![gid, sq], Some(gid), Some(gid)).unwrap();
s.execute("layer.duplicate", json!({})).unwrap();
} else {
s.execute("layer.duplicate", json!({"layer": gid.0})).unwrap();
}
assert_eq!(board_rect(&s, gid.0), Rect::new(0, 10, 40, 50), "multi {multi}: the document shifted right 40");
assert_eq!(s.active().unwrap().color_samplers, vec![[105.0, 15.0]], "multi {multi}");
assert!(red_at(&s, 105, 15), "multi {multi}: the sampler is still on the square");
}
}
#[test]
fn add_beside_places_a_board_on_each_side_and_alt_duplicates_contents() {
// #3033: the Artboard tool's "+" buttons. Board 40×40 at (10, 10) holding a red square.
+4
View File
@@ -50,6 +50,7 @@ mod steps_layer;
mod steps_layer_style;
mod steps_lookup;
mod steps_make;
mod steps_measurement;
mod steps_path;
mod steps_perspective_warp;
mod steps_type_path;
@@ -187,6 +188,9 @@ mod layer_props_tests;
#[cfg(test)]
mod stylize_tests;
#[cfg(test)]
mod measurement_tests;
#[cfg(test)]
mod selection_tests;
+4
View File
@@ -616,6 +616,9 @@ pub(super) const EVENTS: &[Event] = &[
Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to },
// What Photoshop needs in a written step (process version, the full settings set) is
// unverified, so only an imported step saves back, as its recorded descriptor while unedited.
// The label is interchange data, not a display name: Save Actions writes it into the step's
// dictionary (convert.rs), so it stays Photoshop's wording and a loaded step round-trips. The
// menu item and window title come from `lens_cmds` and read "RAW Develop Filter".
Event { code: "", name: "Adobe Camera Raw Filter", label: "Camera Raw Filter", ids: &[crate::lens_cmds::RAW], from: camera_raw_from, to: camera_raw_to },
// Video, Other, Average and matting (#10607).
Event {
@@ -893,6 +896,7 @@ pub(super) const EVENTS: &[Event] = &[
name: "set",
label: "Set",
ids: &[
super::steps_measurement::SET_SCALE,
"select.all",
"select.deselect",
"select.reselect",
@@ -0,0 +1,174 @@
//! #10723: Image › Analysis › Set Measurement Scale › Custom steps, recorded as a Set of the
//! document's `measurementScale` property (`docs/atn.md`, "Recorded through the menus").
use super::*;
const SET_SCALE: &str = "image.analysis.setMeasurementScale";
const SOURCE: &str = "_atnMeasurementScaleDescriptor";
/// The `T ` object of the measured step, built here independently of the converter.
fn scale(class: &str, pixels: i32, logical: f64, units: &str) -> Descriptor {
Descriptor::new(class)
.with("pixelLength", D::Integer(pixels))
.with("logicalLength", D::Double(logical))
.with("logicalUnits", D::Text(UnicodeString::new_nul(units)))
}
/// The measured Set: `null` is the `measurementScale` property of the target document.
fn recorded(t: Descriptor) -> Descriptor {
let target = D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: Id::new("measurementScale") },
ReferenceItem::Enumerated { class: class("Dcmn"), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
]);
Descriptor::new("null").with("null", target).with("T ", D::Descriptor(t))
}
fn set_item(d: Descriptor) -> AtnItem {
AtnItem { event: AtnEvent::Code(*b"setd"), dictionary: b"Set".to_vec(), descriptor: Some(d), ..Default::default() }
}
/// Load `items` as action "A" through `actions.load`.
fn load(items: Vec<AtnItem>) -> (Session, Value) {
let file = AtnFile {
name: UnicodeString::new_nul("Analysis"),
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
..Default::default()
};
let mut s = Session::new();
let r = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&atn::write(&file).unwrap())})).unwrap();
(s, r)
}
/// Action "A" written by `actions.save` and parsed back.
fn saved(s: &mut Session) -> Vec<AtnItem> {
let r = s.execute("actions.save", json!({"action": "A"})).unwrap();
assert_eq!(r["skipped"], json!([]), "{r}");
atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap().actions.remove(0).items
}
/// The recorded step loads as a custom scale and saves back byte for byte, whatever class its
/// `T ` object was recorded with and in whatever key order.
#[test]
fn a_recorded_scale_step_loads_and_saves_back_unchanged() {
let mut reordered = scale("measurementScale", 50, 2.5, "cm");
reordered.items.reverse();
for (t, pixels, logical, units) in
[(scale("measurementScale", 120, 3.0, "mm"), 120, 3.0, "mm"), (scale("null", 100, 25.4, "inches"), 100, 25.4, "inches"), (reordered, 50, 2.5, "cm")]
{
let d = recorded(t);
let (mut s, r) = load(vec![set_item(d.clone())]);
assert_eq!(r["unsupported"], json!([]), "{r}");
let (id, p) = &s.actions.list[0].steps[0];
assert_eq!(id, SET_SCALE);
assert_eq!(
(&p["preset"], p["pixelLength"].as_f64(), p["logicalLength"].as_f64(), &p["units"]),
(&json!("custom"), Some(f64::from(pixels)), Some(logical), &json!(units)),
"{p}"
);
let back = saved(&mut s);
assert_eq!((&back[0].event, back[0].descriptor.as_ref().map(Descriptor::to_bytes)), (&AtnEvent::Code(*b"setd"), Some(d.to_bytes())));
}
}
/// A loaded step sets the document's scale, as one undoable edit.
#[test]
fn a_loaded_scale_step_plays_and_undoes() {
let (mut s, _) = load(vec![set_item(recorded(scale("measurementScale", 120, 3.0, "mm")))]);
s.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert_eq!(played["ran"], 1, "{played}");
let read = s.execute(SET_SCALE, json!({})).unwrap();
assert_eq!(
(&read["pixelLength"], &read["logicalLength"], &read["units"], &read["default"]),
(&json!(120.0), &json!(3.0), &json!("mm"), &json!(false)),
"{read}"
);
s.execute("history.undo", json!({})).unwrap();
assert_eq!(s.execute(SET_SCALE, json!({})).unwrap()["default"], json!(true));
}
/// The step PhotoCraft writes for a native custom scale is the measured form, with `T ` of
/// class `measurementScale`, and it loads back without a stored descriptor.
#[test]
fn a_native_scale_saves_the_measured_form() {
let p = json!({"preset": "custom", "pixelLength": 120, "logicalLength": 3, "units": "mm"});
let d = export_item(SET_SCALE, &p, "S").and_then(|item| item.descriptor).unwrap();
assert_eq!(d, recorded(scale("measurementScale", 120, 3.0, "mm")));
let (s, _) = load(vec![set_item(d)]);
assert_eq!(s.actions.list[0].steps[0].1, p);
// `logicalUnits` is the command's other spelling of `units`, and the preset defaults to custom.
let other = json!({"pixelLength": 120, "logicalLength": 3, "logicalUnits": "mm"});
assert_eq!(export_item(SET_SCALE, &other, "S").and_then(|item| item.descriptor), Some(recorded(scale("measurementScale", 120, 3.0, "mm"))));
}
/// A native step the event can't hold is refused: reading the scale, the default preset, a
/// value left to the current scale, a fractional pixel length (the event stores a long), blank
/// units, or another key.
#[test]
fn settings_the_event_cannot_hold_are_refused() {
for p in [
json!({}),
json!({"preset": "default"}),
json!({"preset": "query"}),
json!({"preset": "custom", "pixelLength": 120, "logicalLength": 3}),
json!({"preset": "custom", "logicalLength": 3, "units": "mm"}),
json!({"preset": "custom", "pixelLength": 120.5, "logicalLength": 3, "units": "mm"}),
json!({"preset": "custom", "pixelLength": 0, "logicalLength": 3, "units": "mm"}),
json!({"preset": "custom", "pixelLength": 120, "logicalLength": -1, "units": "mm"}),
json!({"preset": "custom", "pixelLength": 120, "logicalLength": 3, "units": " "}),
json!({"preset": "custom", "pixelLength": 120, "logicalLength": 3, "units": "mm", "layer": 2}),
] {
assert!(export_item(SET_SCALE, &p, "S").is_none(), "{p}");
}
}
/// Any other form keeps the reference step: another target, a missing or extra key, values of
/// another type, or values the native command refuses.
#[test]
fn other_forms_keep_the_photoshop_step() {
let with = |key: &str, v: D| {
let mut t = scale("measurementScale", 120, 3.0, "mm");
if let Some(slot) = t.items.iter_mut().find(|(k, _)| k.is(key)) {
slot.1 = v;
}
recorded(t)
};
let mut missing = scale("measurementScale", 120, 3.0, "mm");
missing.items.pop();
let mut layer_target = recorded(scale("measurementScale", 120, 3.0, "mm"));
layer_target.items[0].1 = D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: Id::new("measurementScale") },
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
]);
let forms = [
recorded(missing),
recorded(scale("measurementScale", 120, 3.0, "mm").with("Scl ", D::Double(1.0))),
recorded(scale("measurementScale", 120, 3.0, "mm")).with("Rslt", D::Double(72.0)),
layer_target,
with("pixelLength", D::Double(120.0)),
with("logicalLength", D::Integer(3)),
with("logicalLength", D::Double(0.0)),
with("pixelLength", D::Integer(-4)),
with("logicalUnits", D::Text(UnicodeString::new_nul(" "))),
with("logicalUnits", D::Integer(1)),
];
for d in forms {
let (mut s, r) = load(vec![set_item(d.clone())]);
assert_eq!(r["unsupported"].as_array().map(Vec::len), Some(1), "{d:?}");
assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP, "{d:?}");
assert_eq!(saved(&mut s)[0].descriptor.as_ref().map(Descriptor::to_bytes), Some(d.to_bytes()));
}
}
/// A loaded step whose scale was changed saves the new scale, not the stale recording; one whose
/// recording is damaged still saves its settings.
#[test]
fn changed_or_damaged_steps_save_their_settings() {
let (s, _) = load(vec![set_item(recorded(scale("null", 100, 25.4, "inches")))]);
let mut p = s.actions.list[0].steps[0].1.clone();
assert!(p.get(SOURCE).is_some(), "{p}");
p["units"] = json!("cm");
assert_eq!(export_item(SET_SCALE, &p, "S").and_then(|item| item.descriptor), Some(recorded(scale("measurementScale", 100, 25.4, "cm"))));
p[SOURCE] = json!("not base64!");
assert_eq!(export_item(SET_SCALE, &p, "S").and_then(|item| item.descriptor), Some(recorded(scale("measurementScale", 100, 25.4, "cm"))));
}
@@ -1168,6 +1168,8 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped {
(Some("Chnl"), Target::Property(p)) if p == "fsel" => selection_from(d),
// Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off.
(Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})),
// Image › Analysis › Set Measurement Scale › Custom (#10723).
(Some("Prpr"), Target::Property(p)) if p == "measurementScale" => super::steps_measurement::from(d),
// Layer › New › Layer From Background (Photoshop records the New Layer dialog's values
// as a `set` of the Background); values other than the defaults then apply to the layer.
(Some("Lyr"), Target::Property(p)) if p == "Bckg" => background_from(d),
@@ -1272,6 +1274,7 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
let sel = |t: D| desc("null").with("null", fsel.clone()).with("T ", t);
Some(match id {
"path.set" => return super::steps_path::to(p),
super::steps_measurement::SET_SCALE => return super::steps_measurement::to(p),
"select.all" => sel(enm("Ordn", "Al ")),
"select.deselect" => sel(enm("Ordn", "None")),
"select.reselect" => sel(enm("Ordn", "Prvs")),
@@ -0,0 +1,100 @@
//! Image › Analysis › Set Measurement Scale › Custom, recorded as a Set of the document's
//! `measurementScale` property, as `docs/atn.md` ("Recorded through the menus") measures it:
//! `T ` holds `pixelLength` (long), `logicalLength` (double) and `logicalUnits` (text) (#10723).
use super::descriptors::{class, desc, jstr, text};
use super::fields::{Mapped, step};
use super::{D, Descriptor, Id, ReferenceItem, Value, json};
/// The native command the step plays as.
pub(super) const SET_SCALE: &str = "image.analysis.setMeasurementScale";
/// The recorded descriptor, kept when it differs from the one [`to`] would write (the class
/// of `T `, key order or ID encodings), so a load and save keeps the step unchanged.
const SOURCE: &str = "_atnMeasurementScaleDescriptor";
/// The custom scale the step sets.
#[derive(Debug, PartialEq)]
struct Scale {
pixels: i32,
logical: f64,
units: String,
}
/// `null`: the `measurementScale` property of the target document.
fn target() -> D {
D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: Id::new("measurementScale") },
ReferenceItem::Enumerated { class: class("Dcmn"), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
])
}
/// Whether `d` has each of `keys` exactly once and nothing else.
fn exactly(d: &Descriptor, keys: &[&str]) -> bool {
d.items.len() == keys.len() && keys.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() == 1)
}
/// The measured form only. A length the native command refuses (not above 0, not finite) or
/// blank units keep the step as recorded, so playback can't fail on a step that loaded.
fn recorded(d: &Descriptor) -> Option<Scale> {
if !exactly(d, &["null", "T "]) || d.get("null")? != &target() {
return None;
}
let D::Descriptor(t) = d.get("T ")? else { return None };
if !exactly(t, &["pixelLength", "logicalLength", "logicalUnits"]) {
return None;
}
let (D::Integer(pixels), D::Double(logical), D::Text(units)) = (t.get("pixelLength")?, t.get("logicalLength")?, t.get("logicalUnits")?) else {
return None;
};
let units = String::from_utf16(units.0.strip_suffix(&[0]).unwrap_or(&units.0)).ok()?;
(*pixels > 0 && logical.is_finite() && *logical > 0.0 && !units.trim().is_empty()).then_some(Scale { pixels: *pixels, logical: *logical, units })
}
/// The descriptor a step recorded in PhotoCraft saves as; `T ` takes the property's class.
fn canonical(scale: &Scale) -> Descriptor {
let t = desc("measurementScale")
.with("pixelLength", D::Integer(scale.pixels))
.with("logicalLength", D::Double(scale.logical))
.with("logicalUnits", text(&scale.units));
desc("null").with("null", target()).with("T ", D::Descriptor(t))
}
/// Map the measured form; any other stays a complete kept step.
pub(super) fn from(d: &Descriptor) -> Mapped {
let Some(scale) = recorded(d) else { return Mapped::Unsupported { harmless: false } };
let mut p = json!({"preset": "custom", "pixelLength": scale.pixels, "logicalLength": scale.logical, "units": scale.units});
if *d != canonical(&scale) {
p[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into();
}
step(SET_SCALE, p)
}
/// A native step the event holds: a custom scale with all three values given, a whole pixel
/// length (the event stores a long) and units that aren't blank. A step that reads the scale
/// (no params), resets it to the default or leaves a value to the document's current scale
/// refuses export: the event can't say any of those.
fn native(p: &Value) -> Option<Scale> {
let m = p.as_object()?;
if m.keys().any(|key| !["preset", "pixelLength", "logicalLength", "units", "logicalUnits", SOURCE].contains(&key.as_str())) {
return None;
}
if !matches!(m.get("preset"), None | Some(Value::Null)) && jstr(p, "preset") != Some("custom") {
return None;
}
let pixels = m.get("pixelLength")?.as_f64().filter(|v| v.is_finite() && *v > 0.0 && v.fract() == 0.0 && *v <= f64::from(i32::MAX))?;
let logical = m.get("logicalLength")?.as_f64().filter(|v| v.is_finite() && *v > 0.0)?;
// The native command reads `units`, else `logicalUnits`.
let units = m.get("units").or_else(|| m.get("logicalUnits")).and_then(Value::as_str).filter(|u| !u.trim().is_empty())?;
Some(Scale { pixels: pixels as i32, logical, units: units.to_string() })
}
/// Save the recorded descriptor while the step still sets the same scale, otherwise the
/// canonical form of the step's settings.
pub(super) fn to(p: &Value) -> Option<Descriptor> {
let scale = native(p)?;
let source = jstr(p, SOURCE)
.and_then(photocraft_paint::tile::b64_decode)
.and_then(|bytes| Descriptor::from_bytes(&bytes).ok())
.filter(|d| recorded(d).as_ref() == Some(&scale));
Some(source.unwrap_or_else(|| canonical(&scale)))
}
+7 -3
View File
@@ -858,6 +858,7 @@ fn every_mapped_command_exports_and_imports_back() {
("type.loadDefaultTypeStyles", json!({})),
("type.saveDefaultTypeStyles", json!({})),
("image.analysis.recordMeasurements", json!({})),
("image.analysis.setMeasurementScale", json!({"preset": "custom", "pixelLength": 120, "logicalLength": 2.5, "units": "mm"})),
("layer.videoLayers.insertBlankFrame", json!({})),
("layer.videoLayers.duplicateFrame", json!({})),
("layer.videoLayers.deleteFrame", json!({})),
@@ -3832,13 +3833,16 @@ fn loaded_camera_raw_filter_steps_save_back_unchanged() {
assert_eq!(s.actions.list[0].steps[0].0, "filter.cameraRaw");
let saved_step = |r: &Value| {
let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
saved.actions[0].items.first().map(|i| (i.event.clone(), i.descriptor.clone()))
saved.actions[0].items.first().map(|i| (i.event.clone(), i.dictionary.clone(), i.descriptor.clone()))
};
// Strict save, and the Actions panel's save (which skips what can't be written).
// Strict save, and the Actions panel's save (which skips what can't be written). The step's
// dictionary is stored action data, so it keeps Photoshop's wording and not the menu label
// ("RAW Develop Filter"): a loaded step reads the same in Photoshop's Actions panel.
for skip in [false, true] {
let r = s.execute("actions.save", json!({"action": "A", "skipUnsupported": skip})).unwrap();
assert_eq!(r["skipped"], json!([]), "{r}");
assert_eq!(saved_step(&r), Some((AtnEvent::name("Adobe Camera Raw Filter"), Some(recorded.clone()))));
let expected = (AtnEvent::name("Adobe Camera Raw Filter"), b"Camera Raw Filter".to_vec(), Some(recorded.clone()));
assert_eq!(saved_step(&r), Some(expected));
}
// It plays.
s.execute("file.new", json!({"width": 16, "height": 12})).unwrap();
@@ -0,0 +1,52 @@
//! #11775: Record Measurements labels a Count tool row "Count N"; selection and ruler rows keep
//! their labels, and N is the number the row gets today (the shared counter).
use serde_json::{Value, json};
use crate::Session;
fn five_counts() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
for i in 0..5 {
s.execute("count.add", json!({"x": 10 + 10 * i, "y": 10})).unwrap();
}
s
}
fn record(s: &mut Session, source: &str) -> Value {
let r = s.execute("image.analysis.recordMeasurements", json!({"source": source})).unwrap();
let rows = r["rows"].as_array().unwrap();
assert_eq!(rows.len(), 1, "{source}: {r}");
rows[0]["values"].clone()
}
#[test]
fn a_count_row_is_labelled_count_n() {
for source in ["count", "auto"] {
let mut s = five_counts();
let row = record(&mut s, source);
assert_eq!(row["label"], json!("Count 1"), "{source}: {row}");
assert_eq!(row["count"], json!(5), "{source}: {row}");
assert_eq!(row["source"], json!("Count Tool"), "{source}: {row}");
}
}
#[test]
fn other_rows_keep_their_labels_and_the_counter_is_shared() {
let mut s = five_counts();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
let selection = record(&mut s, "selection");
assert_eq!(selection["label"], json!("Measurement 1"), "{selection}");
let count = record(&mut s, "count");
assert_eq!(count["label"], json!("Count 2"), "{count}");
// `auto` picks the selection over the counts, and keeps its label.
let auto = record(&mut s, "auto");
assert_eq!(auto["source"], json!("Selection"), "{auto}");
assert_eq!(auto["label"], json!("Measurement 3"), "{auto}");
s.execute("image.analysis.rulerTool", json!({"start": [0, 0], "end": [3, 4]})).unwrap();
let ruler = record(&mut s, "ruler");
assert_eq!(ruler["label"], json!("Measurement 4"), "{ruler}");
let count = record(&mut s, "count");
assert_eq!(count["label"], json!("Count 5"), "{count}");
}
+1 -1
View File
@@ -435,7 +435,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":%? (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 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?,ms?]…] (ms: milliseconds since the previous point; a point with it follows the pointer exactly and times the stationary tools in real time, as the dialog records them),"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![
+7 -1
View File
@@ -9,6 +9,7 @@
//! one undo step per click / stroke.
use photocraft_algo::selection as sel;
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, LayerContent, LayerId};
use photocraft_geom::Rect;
use photocraft_paint::Stroke;
@@ -106,7 +107,10 @@ fn magic_eraser(s: &mut Session, p: &Value) -> Result<Value> {
let surf = d.doc.layer(id).and_then(Layer::surface).ok_or(EngineError::NoLayer(id))?;
(d.doc.bounds(), sel::rgba8_image(surf, d.doc.bounds()))
};
let region = sel::wand_region(&img, area, (x, y), tolerance, contiguous, anti_alias);
// The reference's colour distance is measured for 8-bit RGB only (#11995).
let rgb8 = s.active().is_some_and(|d| d.doc.mode == ColorMode::Rgb && d.doc.depth == SampleType::U8);
let distance = if rgb8 { sel::ColorDistance::Spread } else { sel::ColorDistance::LargestChannel };
let region = sel::wand_region_by(&img, area, (x, y), tolerance, contiguous, anti_alias, None, distance);
drop(img);
let Some(region) = region else { return Ok(damage(s, Rect::EMPTY, false)) };
let bg = s.tools.background;
@@ -283,6 +287,8 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
#[cfg(test)]
mod magic_eraser_tolerance_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
@@ -0,0 +1,239 @@
//! #11995: on 8-bit RGB the Magic Eraser measures Tolerance by the spread of `{0, dR, dG, dB}`, so
//! opposite changes in two channels count double. The expected values are the reference
//! application's measurements from the issue; the stripes document is drawn at a tenth of the size.
use photocraft_algo::selection as sel;
use photocraft_geom::Rect;
use serde_json::{Value, json};
use crate::Session;
const SEED: [u8; 3] = [200, 40, 40];
/// `d` of the patch grid's columns.
const GRID: [i32; 8] = [20, 50, 80, 110, 140, 170, 200, 230];
fn hex([r, g, b]: [u8; 3]) -> String {
format!("#{r:02x}{g:02x}{b:02x}")
}
fn document(w: i32, h: i32, depth: u32, background: [u8; 3]) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h, "mode": "rgb", "depth": depth, "background": hex(background)})).unwrap();
s
}
fn fill(s: &mut Session, [x, y, w, h]: [i32; 4], color: [u8; 3]) {
s.execute("select.rect", json!({"x": x, "y": y, "width": w, "height": h, "antiAlias": false})).unwrap();
s.execute("edit.fill", json!({"color": hex(color)})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
}
fn erase(s: &mut Session, x: i32, y: i32, options: Value) {
let mut p = json!({"x": x, "y": y, "antiAlias": false});
p.as_object_mut().unwrap().extend(options.as_object().unwrap().clone());
s.execute("paint.magicEraser", p).unwrap();
}
fn rgba(s: &Session, x: i32, y: i32) -> [f32; 4] {
let d = s.active().unwrap();
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)
}
fn erased(s: &Session, x: i32, y: i32) -> bool {
rgba(s, x, y)[3] == 0.0
}
/// Grey (100, 100, 100) with ten-pixel patches along the top: same-direction and opposite changes.
fn grey_patches(depth: u32) -> (Session, [(i32, [u8; 3], i32); 4]) {
let patches = [(10, [130, 100, 70], 60), (30, [130, 130, 100], 30), (50, [70, 130, 100], 60), (70, [70, 70, 70], 30)];
let mut s = document(90, 30, depth, [100, 100, 100]);
for (x, color, _) in patches {
fill(&mut s, [x, 10, 10, 10], color);
}
(s, patches)
}
/// Opposite changes count double and same-direction changes once (Tolerance 29, 30, 59, 60).
#[test]
fn opposite_channel_changes_count_double_around_a_grey_seed() {
for tolerance in [29, 30, 59, 60] {
let (mut s, patches) = grey_patches(8);
erase(&mut s, 0, 0, json!({"tolerance": tolerance, "contiguous": false}));
for (x, color, metric) in patches {
assert_eq!(erased(&s, x + 5, 15), tolerance >= metric, "tolerance {tolerance}, patch {color:?}");
}
}
}
fn grid_colour(row: usize, d: i32) -> [u8; 3] {
let [r, g, b] = SEED.map(i32::from);
let [r, g, b] = match row {
0 => [r - d, g, b],
1 => [r + d, g, b],
2 => [r, g + d, b],
3 => [r, g + d, b + d],
_ => [r - d, g + d, b + d],
};
[r, g, b].map(|v| v.clamp(0, 255) as u8)
}
/// The issue's patch grid around (200, 40, 40): `0` is erased and `x` kept, one row per Tolerance.
#[test]
fn the_patch_grid_matches_the_measured_table() {
const TABLE: [(i32, [&str; 5]); 7] = [
(30, ["0xxxxxxx", "0xxxxxxx", "0xxxxxxx", "0xxxxxxx", "xxxxxxxx"]),
(40, ["0xxxxxxx", "0xxxxxxx", "0xxxxxxx", "0xxxxxxx", "0xxxxxxx"]),
(60, ["00xxxxxx", "00000000", "00xxxxxx", "00xxxxxx", "0xxxxxxx"]),
(100, ["000xxxxx", "00000000", "000xxxxx", "000xxxxx", "00xxxxxx"]),
(150, ["00000xxx", "00000000", "00000xxx", "00000xxx", "00xxxxxx"]),
(200, ["00000000", "00000000", "0000000x", "0000000x", "000xxxxx"]),
(255, ["00000000", "00000000", "00000000", "00000000", "0000xxxx"]),
];
for (tolerance, rows) in TABLE {
let mut s = document(80, 60, 8, SEED);
for row in 0..5 {
for (col, d) in GRID.into_iter().enumerate() {
fill(&mut s, [col as i32 * 10, 10 + row as i32 * 10, 10, 10], grid_colour(row, d));
}
}
erase(&mut s, 0, 0, json!({"tolerance": tolerance, "contiguous": false}));
for (row, want) in rows.into_iter().enumerate() {
let got: String = (0..8).map(|col| if erased(&s, col * 10 + 5, 15 + row as i32 * 10) { '0' } else { 'x' }).collect();
assert_eq!(got, want, "tolerance {tolerance}, row {row}");
}
}
}
const RED: [u8; 3] = [220, 30, 30];
const GREEN: [u8; 3] = [30, 180, 60];
const BLUE: [u8; 3] = [40, 60, 220];
const PINK: [u8; 3] = [240, 120, 120];
const UPPER_RED: (i32, i32) = (10, 5);
const LOWER_RED: (i32, i32) = (10, 22);
const WHITE_GAP: (i32, i32) = (10, 15);
/// The stripes over eight blocks (200, 40 + 20·i, 40), with a white gap cutting the red stripe.
fn stripes() -> Session {
let mut s = document(80, 60, 8, [255, 255, 255]);
for (i, color) in [RED, GREEN, BLUE, PINK].into_iter().enumerate() {
fill(&mut s, [i as i32 * 20, 0, 20, 30], color);
}
fill(&mut s, [0, 14, 20, 2], [255, 255, 255]);
for i in 0..8u8 {
fill(&mut s, [i32::from(i) * 10, 30, 10, 30], [200, 40 + 20 * i, 40]);
}
s
}
fn erased_blocks(s: &Session) -> usize {
(0..8).take_while(|i| erased(s, i * 10 + 5, 45)).count()
}
/// Contiguous from block 0: the blocks go one by one, and green and blue survive even at 255.
#[test]
fn stripes_contiguous_from_block_zero() {
for (tolerance, blocks) in
[(0, 1), (10, 1), (19, 1), (20, 2), (21, 2), (39, 2), (40, 3), (41, 3), (60, 4), (100, 6), (140, 8), (160, 8), (200, 8), (255, 8)]
{
let mut s = stripes();
erase(&mut s, 5, 45, json!({"tolerance": tolerance}));
assert_eq!(erased_blocks(&s), blocks, "tolerance {tolerance}");
assert_eq!(8 - (0..8).filter(|i| !erased(&s, i * 10 + 5, 45)).count(), blocks, "tolerance {tolerance}: the erased blocks are consecutive");
assert_eq!(erased(&s, 70, 15), tolerance >= 140, "tolerance {tolerance}: pink, reached through blocks 6 and 7");
assert_eq!(erased(&s, UPPER_RED.0, UPPER_RED.1), tolerance == 255, "tolerance {tolerance}: upper red, reached through the white gap");
if tolerance == 41 || tolerance >= 60 {
assert!(erased(&s, LOWER_RED.0, LOWER_RED.1), "tolerance {tolerance}: lower red");
}
if tolerance < 30 {
assert!(!erased(&s, LOWER_RED.0, LOWER_RED.1), "tolerance {tolerance}: lower red");
}
assert!(!erased(&s, 30, 15), "tolerance {tolerance}: green is kept");
assert!(!erased(&s, 50, 15), "tolerance {tolerance}: blue is kept");
}
}
/// Non-contiguous from block 0: white goes from Tolerance 220; green and blue never do.
#[test]
fn stripes_non_contiguous_from_block_zero() {
let mut s = stripes();
erase(&mut s, 5, 45, json!({"tolerance": 150, "contiguous": false}));
assert_eq!(erased_blocks(&s), 8);
assert!(erased(&s, UPPER_RED.0, UPPER_RED.1) && erased(&s, LOWER_RED.0, LOWER_RED.1), "both reds");
assert!(erased(&s, 70, 15), "pink");
assert!(!erased(&s, WHITE_GAP.0, WHITE_GAP.1), "the white gap (metric 215) is kept");
assert!(!erased(&s, 30, 15) && !erased(&s, 50, 15), "green and blue");
let mut s = stripes();
erase(&mut s, 5, 45, json!({"tolerance": 220, "contiguous": false}));
assert!(erased(&s, WHITE_GAP.0, WHITE_GAP.1), "white is within 220");
assert!(!erased(&s, 30, 15) && !erased(&s, 50, 15), "green and blue");
}
/// The fringe uses the same distance: a neighbour the spread rejects is not fully erased, even
/// though every one of its channels is within the Tolerance.
#[test]
fn anti_alias_does_not_erase_a_neighbour_the_spread_rejects() {
let (mut s, _) = grey_patches(8);
erase(&mut s, 0, 0, json!({"tolerance": 32, "contiguous": false, "antiAlias": true}));
assert!(rgba(&s, 15, 15)[3] == 1.0, "the patch's inside is kept");
assert!(rgba(&s, 10, 15)[3] > 0.0, "(130, 100, 70) has spread 60, past Tolerance 32");
}
/// Depths the rule was not measured for keep comparing each channel on its own.
#[test]
fn sixteen_bit_keeps_the_per_channel_limit() {
let (mut s, patches) = grey_patches(16);
erase(&mut s, 0, 0, json!({"tolerance": 30, "contiguous": false}));
for (x, color, _) in patches {
assert!(erased(&s, x + 5, 15), "patch {color:?}");
}
}
/// The Magic Wand and the Paint Bucket share the fill and keep the per-channel limit.
#[test]
fn the_magic_wand_and_paint_bucket_keep_the_per_channel_limit() {
let (mut s, _) = grey_patches(8);
s.execute("select.magicWand", json!({"x": 0, "y": 0, "tolerance": 30, "contiguous": false, "antiAlias": false})).unwrap();
let selected = sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), Rect::new(0, 0, 90, 30));
assert_eq!(selected[15 * 90 + 15], 1.0, "the wand takes (130, 100, 70) at 30");
let (mut s, _) = grey_patches(8);
s.execute("paint.bucket", json!({"x": 0, "y": 0, "tolerance": 30, "contiguous": false, "antiAlias": false, "color": "#ff00ff"})).unwrap();
let [r, g, b, _] = rgba(&s, 15, 15);
assert_eq!([r, g, b], [1.0, 0.0, 1.0], "the bucket fills (130, 100, 70) at 30");
}
/// A transparent seed still matches every transparent pixel, and alpha still limits the match.
#[test]
fn the_spread_keeps_the_transparent_and_alpha_handling() {
let area = Rect::new(0, 0, 3, 1);
let run = |img: [[u8; 4]; 3], tolerance| sel::wand_region_by(&img, area, (0, 0), tolerance, false, false, None, sel::ColorDistance::Spread).unwrap();
let region = run([[10, 20, 30, 0], [200, 0, 90, 0], [9, 9, 9, 255]], 0.0);
assert_eq!((region.at(1, 0), region.at(2, 0)), (1.0, 0.0), "hidden colours don't matter; visible pixels don't match");
let half = [[100, 100, 100, 200], [100, 100, 100, 100], [100, 100, 100, 170]];
let region = run(half, 30.0);
assert_eq!((region.at(1, 0), region.at(2, 0)), (0.0, 1.0), "alpha differences stay limited by the Tolerance");
}
/// The spread was measured on opaque colours only: a partly transparent seed or pixel keeps the
/// per-channel limit, in the match and in the anti-alias fringe.
#[test]
fn partly_transparent_colours_keep_the_per_channel_limit() {
let area = Rect::new(0, 0, 3, 1);
let run = |img: &[[u8; 4]; 3], distance, anti_alias| sel::wand_region_by(img, area, (0, 0), 30.0, false, anti_alias, None, distance).unwrap();
let opaque = [[100, 100, 100, 255], [130, 100, 70, 255], [9, 9, 9, 255]];
assert_eq!(run(&opaque, sel::ColorDistance::Spread, false).at(1, 0), 0.0, "opaque colours use the spread");
for img in [
[[100, 100, 100, 128], [130, 100, 70, 128], [160, 100, 40, 128]],
[[100, 100, 100, 255], [130, 100, 70, 250], [160, 100, 40, 250]],
[[100, 100, 100, 250], [130, 100, 70, 255], [160, 100, 40, 255]],
] {
assert_eq!(run(&img, sel::ColorDistance::Spread, false).at(1, 0), 1.0, "{img:?}: every channel is within 30");
assert!(run(&img, sel::ColorDistance::Spread, true).at(2, 0) > 0.0, "{img:?}: the third pixel gets a fringe");
assert_eq!(run(&img, sel::ColorDistance::Spread, true), run(&img, sel::ColorDistance::LargestChannel, true), "{img:?}: same fringe");
}
}
+5 -32
View File
@@ -74,13 +74,9 @@ pub(crate) fn step_key(s: &Session, what: &str) -> String {
/// area, the pixels, and the selection coverage per pixel; results are blended back by coverage,
/// and transparency stays locked when the layer locks it. When `p` targets an alpha channel, the
/// Quick Mask or the layer mask (`Session::execute` fills that in from the Channels panel), that
/// grayscale surface is rewritten instead, as the core filters do (#935).
fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result<Value> {
map_pixels_in(s, label, p, false, f)
}
/// [`map_pixels`], or with `whole` the whole canvas: `f` still gets the selection coverage, and
/// every pixel takes its result in full.
/// grayscale surface is rewritten instead, as the core filters do (#935). With `whole` the whole
/// canvas is rewritten: `f` still gets the selection coverage, and every pixel takes its result
/// in full.
fn map_pixels_in(s: &mut Session, label: &str, p: &Value, whole: bool, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result<Value> {
let id = layer_param(s, p)?;
let channel = crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels;
@@ -275,27 +271,6 @@ fn equalize(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Filter › Blur › Average: the selection (or layer) filled with its mean colour.
fn average(s: &mut Session, p: &Value) -> Result<Value> {
map_pixels(s, "Average", p, |_, px, cover| {
let (mut sum, mut wsum, mut asum, mut n) = ([0f64; 3], 0f64, 0f64, 0f64);
for (p, &k) in px.iter().zip(cover) {
let w = (p[3] * k) as f64;
for c in 0..3 {
sum[c] += p[c] as f64 * w;
}
wsum += w;
asum += (p[3] * k) as f64;
n += k as f64;
}
if wsum <= 0.0 {
return;
}
let avg = [(sum[0] / wsum) as f32, (sum[1] / wsum) as f32, (sum[2] / wsum) as f32, (asum / n.max(1e-9)) as f32];
px.fill(avg);
})
}
// ---------- transform presets ----------
/// The presets' params: like Free Transform, they follow the Channels and Layers panel target.
@@ -402,7 +377,6 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result<Value> {
let key = step_key(s, "pasteInto");
params["coalesce"] = json!(key);
params["target"] = json!("pixels");
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
let r = s.execute("edit.paste", params)?;
let id = layer_param(s, &Value::Null)?;
s.execute("layer.layerMask.revealAll", json!({"layer": id.0, "coalesce": key}))?;
@@ -413,9 +387,9 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result<Value> {
Ok(())
});
s.coalesce_request = None;
// Three revisions: `Session::dispatch` reports what the whole command changed, so only where
// the masked paste draws is recomposited (#2901).
r2?;
// Only where the masked paste draws needs recompositing (#2901).
crate::layer_multi_cmds::note_insert(s, &before);
Ok(r)
}
@@ -1119,7 +1093,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_layer,
show_only
),
spec!("filter.blur.average", "Average", &["Filter", "Blur"], None, "{}", has_pixels, average),
]
}
+63 -1
View File
@@ -24,7 +24,7 @@ fn equalize_average_and_clouds_edit_the_targeted_channel() {
s.tools.foreground = [0.0, 0.0, 0.0, 1.0];
s.tools.background = [1.0, 1.0, 1.0, 1.0];
s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap();
let p = match target {
let mut p = match target {
"quickMask" => {
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
json!({"seed": 1})
@@ -39,6 +39,10 @@ fn equalize_average_and_clouds_edit_the_targeted_channel() {
json!({"seed": 1, "target": "mask"})
}
};
// Average has no seed parameter; only Clouds uses it.
if cmd == "filter.blur.average" {
p.as_object_mut().unwrap().remove("seed");
}
// Two levels in the target, so Equalize and Average have something to change.
let edit_target = json!({ "target": if target == "alpha" { json!({"channel": 0}) } else { json!(target) } });
s.edit("levels", |doc, active| {
@@ -65,6 +69,18 @@ fn equalize_average_and_clouds_edit_the_targeted_channel() {
let case = format!("{cmd} on {target} at {depth}");
assert_eq!(layer1, layer0, "{case}: the layer is untouched");
assert_ne!(target1, target0, "{case}: the target changed");
if cmd == "filter.blur.average" {
// The equal-sized halves contribute equally, using their stored sample values.
let mean = (f64::from(target0[0]) + f64::from(target0[20])) / 2.0;
let tolerance = match depth {
8 => 1.0 / 255.0,
16 => 1.0 / 65535.0,
_ => 1e-6,
};
for value in &target1 {
assert!((f64::from(*value) - mean).abs() <= tolerance, "{case}: {value}, expected {mean}");
}
}
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{case}");
s.undo();
assert_eq!(read(&s).1, target0, "{case}: undo restores the target");
@@ -556,6 +572,52 @@ fn clouds_in_a_selection_are_the_canvas_clouds() {
}
}
#[test]
fn average_preserves_transparency_and_averages_hidden_colours() {
// Photoshop 26.6.1, RGB8 PNG with opaque red and transparent blue halves:
// Average produces (128, 0, 128), retaining each pixel's original alpha.
for depth in [8, 16, 32] {
let mut s = session(depth);
paint_square(&mut s, Rect::new(0, 0, 20, 30), [1.0, 0.0, 0.0, 1.0]);
paint_square(&mut s, Rect::new(20, 0, 40, 30), [0.0, 0.0, 1.0, 0.0]);
let bounds = Rect::new(0, 0, 40, 30);
let before = active(&s).surface().unwrap().read_region(bounds);
let steps = s.active().unwrap().history.past_len();
s.execute("filter.blur.average", json!({})).unwrap();
let after = active(&s).surface().unwrap().read_region(bounds);
for (old, new) in before.as_chunks::<4>().0.iter().zip(after.as_chunks::<4>().0) {
assert_eq!(new[3], old[3], "depth {depth}: Average must preserve alpha");
assert!((new[0] - 0.5).abs() < 0.003 && new[1] == 0.0 && (new[2] - 0.5).abs() < 0.003, "{depth}: {new:?}");
}
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
s.undo();
assert_eq!(active(&s).surface().unwrap().read_region(bounds), before);
s.redo();
assert_eq!(active(&s).surface().unwrap().read_region(bounds), after);
}
}
#[test]
fn average_preserves_partial_alpha_and_pixels_outside_selection() {
for depth in [8, 16, 32] {
let mut s = session(depth);
paint_square(&mut s, Rect::new(0, 0, 20, 30), [1.0, 0.0, 0.0, 0.25]);
paint_square(&mut s, Rect::new(20, 0, 40, 30), [0.0, 0.0, 1.0, 0.75]);
s.execute("select.rect", json!({"x": 10, "y": 0, "width": 15, "height": 30})).unwrap();
let before: Vec<_> = (0..40).map(|x| pixel(&s, x, 15)).collect();
s.execute("filter.blur.average", json!({})).unwrap();
for x in 0..40 {
let p = pixel(&s, x, 15);
assert_eq!(p[3], before[x as usize][3], "depth {depth}, x {x}");
if (10..25).contains(&x) {
assert!((p[0] - 2.0 / 3.0).abs() < 0.003 && (p[2] - 1.0 / 3.0).abs() < 0.003, "{depth}: {p:?}");
} else {
assert_eq!(p, before[x as usize]);
}
}
}
}
#[test]
fn average_and_clouds() {
let mut s = session(8);
@@ -45,10 +45,10 @@ fn edits_preserve_mask_pixels_and_layer_at_all_depths_with_undo_redo() {
assert_eq!(s.active().unwrap().active_layer, Some(background));
assert_eq!(s.active().unwrap().history.entries().len(), count + 1);
// Properties invalidate the composite as far as their undo would (the masked layer is
// empty here; the adjustment reaches everything).
// empty here, and the adjustment reaches only what lies beneath it: nothing, #9329).
let damage = s.active().unwrap().last_damage;
assert_eq!(damage, crate::layer_multi_cmds::step_damage(&before, &s.active().unwrap().doc));
assert_eq!(damage.is_none(), adjustment);
assert_eq!(damage, Some(Rect::EMPTY), "adjustment: {adjustment}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.active().unwrap().doc, *before);
s.execute("edit.redo", json!({})).unwrap();
+2 -2
View File
@@ -20,9 +20,9 @@ mod selection;
mod structure;
use align::{align, distribute};
pub(crate) use damage::grow_hint;
#[cfg(any(test, debug_assertions))]
pub(crate) use damage::hint_misses;
pub(crate) use damage::note_insert;
pub(crate) use damage::{hint_misses, small_change_pct};
pub use damage::{layer_bounds, same_pixels, selection_only_change, step_damage};
use moves::link_groups;
pub(crate) use moves::shift_surface;
+270 -54
View File
@@ -1,6 +1,7 @@
use photocraft_color::{PixelFormat, SampleType};
use photocraft_compose::Ancestors;
use super::{Document, Layer, LayerContent, Rect, Session, VideoData};
use super::{Document, Layer, LayerContent, LayerId, Rect, VideoData};
/// Calls `f` with the rect of every tile that differs between two surfaces of the same format and
/// default pixel: a tile only one side has, or a different copy-on-write tile pointer at the same
@@ -237,7 +238,8 @@ fn doc_pixels_equal(a: &Document, b: &Document) -> bool {
clipping_path: _,
quick_mask,
quick_mask_unselected: _,
patterns,
// Compared by `patterns_pixels_equal`.
patterns: _,
color_table,
duotone,
layer_comps: _,
@@ -264,11 +266,57 @@ fn doc_pixels_equal(a: &Document, b: &Document) -> bool {
(Some(x), Some(y)) => alpha_channels_equal(x, y),
_ => false,
}
&& *patterns == b.patterns
&& patterns_pixels_equal(a, b)
&& *color_table == b.color_table
&& *duotone == b.duotone
}
/// Whether `a`'s and `b`'s pattern libraries composite the same. Layers draw a pattern only
/// through a reference that `photocraft_doc::pattern::find` resolves (fill and shape layers,
/// Pattern Overlay, pattern strokes and glows, Bevel textures), so patterns appended after an
/// unchanged prefix change nothing unless some reference now resolves differently: to an
/// appended pattern where it resolved to an earlier one or to none. A same-named addition that
/// loses to an existing id match changes nothing. Pattern Stamp copies its pattern into the
/// document this way on first use; the pixels it paints are a raster change, diffed by tile like
/// any other.
fn patterns_pixels_equal(a: &Document, b: &Document) -> bool {
use photocraft_doc::effects::{Effect, FxPaint};
use photocraft_doc::{Fill, Pattern, pattern::find};
if a.patterns == b.patterns {
return true;
}
if a.patterns.len() >= b.patterns.len() || !b.patterns.starts_with(a.patterns.as_slice()) {
return false;
}
// The index a reference resolves to; the prefix is equal, so equal indices mean equal patterns.
let at = |ps: &[Pattern], id: &str, name: &str| find(ps, id, name).and_then(|p| ps.iter().position(|q| std::ptr::eq(q, p)));
let hits = |id: &str, name: &str| at(&a.patterns, id, name) != at(&b.patterns, id, name);
let fill = |f: &Fill| matches!(f, Fill::Pattern { id, name, .. } if hits(id, name));
let paint = |p: &FxPaint| matches!(p, FxPaint::Pattern { id, name, .. } if hits(id, name));
// Past a nesting depth no real document reaches, assume a reference (unequal), as
// `pixels_equal_with` does.
fn any_layer(layers: &[Layer], depth: usize, refers: &dyn Fn(&Layer) -> bool) -> bool {
depth >= 256 || layers.iter().any(|l| refers(l) || matches!(&l.content, LayerContent::Group(g) if any_layer(&g.children, depth + 1, refers)))
}
let refers = |l: &Layer| {
let effect = l.effects.items.iter().any(|e| match e {
Effect::OuterGlow(g) | Effect::InnerGlow(g) => paint(&g.paint),
Effect::Stroke(s) => paint(&s.paint),
Effect::PatternOverlay { id, name, .. } => hits(id, name),
Effect::BevelEmboss(b) => b.texture.as_ref().is_some_and(|t| hits(&t.id, &t.name)),
Effect::DropShadow(_) | Effect::InnerShadow(_) | Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } | Effect::Satin(_) => false,
});
effect
|| match &l.content {
LayerContent::Fill(f) => fill(f),
LayerContent::Shape(s) => s.fill.as_ref().is_some_and(fill) || s.stroke.as_ref().is_some_and(|st| fill(&st.paint)),
// A smart object draws its cache; its own document holds its own patterns.
LayerContent::Raster(_) | LayerContent::Group(_) | LayerContent::Adjustment(_) | LayerContent::Text(_) | LayerContent::Smart(_) => false,
}
};
!any_layer(&b.layers, 0, &refers)
}
/// Whether `a` and `b` composite to the same pixels: they differ at most in fields the
/// compositor never reads (selection, guides, paths, names, …), by the same classification as
/// [`step_damage`] but without computing any bounds. Cheap: pixels compare by tile pointer.
@@ -384,9 +432,9 @@ pub fn selection_only_change(a: &Document, b: &Document) -> bool {
/// [`one_sided_bounds`]); one that draws nothing ([`inert_at`]) changes nothing. `None`
/// (composite everything) when the step touched anything the layer walk cannot bound:
/// document-level fields the compositor reads (canvas size, mode, depth, profile, global light,
/// patterns, channels, palettes), layer order and kind changes, layers added or removed that
/// reach outside their own bounds, layer properties that do (clipping, excluded channels —
/// what `layer.setProps` already reports as unbounded), and video layers. Inserting a copy of a
/// patterns, channels, palettes), kind changes, reorders of clipped layers, layers added or
/// removed that reach outside their own bounds, clipping or channel exclusion on a group, and
/// video layers. A reorder damages the moved layers at both ends ([`moved_ids`]). Inserting a copy of a
/// 12 MP layer used to recomposite every layer of the document (#2883, P8/P9 in
/// `perf/budgets.toml`).
///
@@ -397,8 +445,6 @@ pub fn step_damage(a: &Document, b: &Document) -> Option<Rect> {
if !doc_pixels_equal(a, b) {
return None;
}
/// The list holding a sibling list's parent group, and the group's index there.
type Outer<'d> = Option<(&'d [Layer], usize)>;
/// What the groups around a sibling list add to a change inside it: the summed reach of
/// their effects, and whether any lays effects out over its frame.
#[derive(Clone, Copy)]
@@ -406,21 +452,67 @@ pub fn step_damage(a: &Document, b: &Document) -> Option<Rect> {
reach: i32,
spans_frame: bool,
}
fn walk(xs: &[Layer], ys: &[Layer], (ox, oy): (Outer, Outer), around: Around, a: &Document, b: &Document, out: &mut Rect) -> bool {
fn add(out: &mut Rect, r: Rect) {
if !r.is_empty() {
*out = if out.is_empty() { r } else { out.union(&r) };
}
}
fn walk<'d>(
xs: &'d [Layer],
ys: &'d [Layer],
(px, py): (&Ancestors<'d>, &Ancestors<'d>),
around: Around,
a: &Document,
b: &Document,
out: &mut Rect,
) -> bool {
let depth = b.depth;
// The effects of the groups around reach past a change inside them (a parent's drop shadow).
let grow = |r: Rect, canvas: Rect| if r.is_empty() { r } else { r.inflate(around.reach).intersect(&canvas) };
// So bound a change within that reach of the canvas, not on it: a layer just off the
// canvas still moves a parent's shadow onto it (group effects are built past the canvas).
let near = |canvas: Rect| canvas.inflate(around.reach);
// Pair the layers by id, stepping over layers only one side has: one that draws nothing
// changes no pixels (a new empty layer or group, #1771), any other only where it draws.
let one_sided = |layers: &[Layer], k: usize, outer: Outer, canvas: Rect, out: &mut Rect| {
let Some(r) = one_sided_bounds(layers, k, outer, canvas) else { return false };
if !r.is_empty() {
*out = if out.is_empty() { r } else { out.union(&r) };
}
let one_sided = |layers: &[Layer], k: usize, path: &Ancestors, canvas: Rect, out: &mut Rect| {
let Some(r) = one_sided_bounds(layers, k, path, depth, near(canvas)) else { return false };
add(out, grow(r, canvas));
true
};
// A reorder: the layers that moved change pixels only where they draw (or, for an
// adjustment, reach) at each end, as hiding them there and showing them here would.
let Some(moved) = moved_ids(xs, ys) else { return false };
for &id in &moved {
let (Some(xk), Some(yk)) = (xs.iter().position(|l| l.id == id), ys.iter().position(|l| l.id == id)) else { return false };
let (x, y) = (&xs[xk], &ys[yk]);
// A clipped layer changes base when it moves, and a layer moving to or from below
// clipped layers changes theirs.
let clips = |ls: &[Layer], k: usize| ls[k].clipped || ls.get(k + 1).is_some_and(|above| above.clipped);
if clips(xs, xk) || clips(ys, yk) || !contained(x, 0) || !contained(y, 0) {
return false;
}
let (Some(bx), Some(by)) = (
photocraft_compose::change_bounds_in(xs, xk, px, depth, near(a.bounds())),
photocraft_compose::change_bounds_in(ys, yk, py, depth, near(b.bounds())),
) else {
return false;
};
add(out, grow(bx, a.bounds()));
add(out, grow(by, b.bounds()));
}
let (mut i, mut j) = (0, 0);
loop {
let (x, y) = match (xs.get(i), ys.get(j)) {
(None, None) => return true,
(Some(x), Some(y)) if x.id == y.id => (x, y),
(Some(x), _) if moved.contains(&x.id) => {
i += 1;
continue;
}
(_, Some(y)) if moved.contains(&y.id) => {
j += 1;
continue;
}
_ if inert_at(ys, j) => {
j += 1;
continue;
@@ -430,20 +522,20 @@ pub fn step_damage(a: &Document, b: &Document) -> Option<Rect> {
continue;
}
(_, Some(y)) if xs.iter().all(|x| x.id != y.id) => {
if !one_sided(ys, j, oy, b.bounds(), out) {
if !one_sided(ys, j, py, b.bounds(), out) {
return false;
}
j += 1;
continue;
}
(Some(x), _) if ys.iter().all(|y| y.id != x.id) => {
if !one_sided(xs, i, ox, a.bounds(), out) {
if !one_sided(xs, i, px, a.bounds(), out) {
return false;
}
i += 1;
continue;
}
// Both sides have the layer, in another order.
// Both sides have the layer, in another order (`moved_ids` pairs the rest).
_ => return false,
};
let (xk, yk) = (i, j);
@@ -452,21 +544,39 @@ pub fn step_damage(a: &Document, b: &Document) -> Option<Rect> {
if x.video.is_some() || y.video.is_some() {
return false;
}
// Clipping and channel exclusion change how *other* layers render, outside this
// layer's own bounds; they have no bounded damage.
if x.clipped != y.clipped || x.excluded_channels != y.excluded_channels {
// Clipping and channel exclusion on a group change how it composites its children;
// they have no bounded damage. Channel exclusion on any other layer is per pixel where
// it draws, which its change bounds below cover.
if (x.clipped != y.clipped || x.excluded_channels != y.excluded_channels) && (x.children().is_some() || y.children().is_some()) {
return false;
}
if x.clipped != y.clipped {
// The clipped layers directly above change base too (from this layer to the one
// beneath it, or back). A group base composites differently with clipped layers
// (a pass-through one holding an adjustment then renders isolated), so not that.
let base_is_group = |ls: &[Layer], k: usize| ls[..k].iter().rev().find(|l| !l.clipped).is_some_and(|l| l.children().is_some());
if base_is_group(xs, xk) || base_is_group(ys, yk) {
return false;
}
for (ls, k, path, d) in [(xs, xk, px, a), (ys, yk, py, b)] {
for c in (k + 1..ls.len()).take_while(|&c| ls[c].clipped) {
let Some(r) = photocraft_compose::change_bounds_in(ls, c, path, depth, near(d.bounds())) else { return false };
add(out, grow(r, d.bounds()));
}
}
}
let tiles = tile_damage(x, y);
if tiles != Some(Rect::EMPTY) {
// Both sides' extents: a move changes pixels where it left and where it landed.
// A clipped adjustment reaches as far as its base (#6535).
let (Some(bx), Some(by)) =
(photocraft_compose::change_bounds_in(xs, xk, ox, a.bounds()), photocraft_compose::change_bounds_in(ys, yk, oy, b.bounds()))
else {
// A clipped adjustment reaches as far as its base (#6535). Grown by what the
// groups around reach, as any change inside them (a parent's drop shadow).
let (Some(bx), Some(by)) = (
photocraft_compose::change_bounds_in(xs, xk, px, depth, near(a.bounds())),
photocraft_compose::change_bounds_in(ys, yk, py, depth, near(b.bounds())),
) else {
return false;
};
let whole = bx.union(&by);
let whole = grow(bx, a.bounds()).union(&grow(by, b.bounds()));
// Only pixels changed: the changed tiles, grown by what the groups around reach,
// never more than the whole extents. Effects laid out over a frame repaint it
// everywhere when it moves (#4053), so then the whole extents.
@@ -474,19 +584,33 @@ pub fn step_damage(a: &Document, b: &Document) -> Option<Rect> {
|| (photocraft_compose::effects::spans_frame(y)
&& photocraft_compose::effects_frame(x, a.bounds()) != photocraft_compose::effects_frame(y, b.bounds()));
let d = match tiles {
Some(t) if !frame_moved => t.inflate(around.reach).intersect(&whole.inflate(around.reach)).intersect(&b.bounds()),
Some(t) if !frame_moved => t.inflate(around.reach).intersect(&whole).intersect(&b.bounds()),
_ => whole,
};
*out = out.union(&d);
}
match (x.children(), y.children()) {
(Some(xc), Some(yc)) => {
let reach = |l: &Layer| if photocraft_compose::effects::has_effects(l) { photocraft_compose::effects::margin(l) } else { 0 };
// A change inside that moves the group's frame (a layer added, removed or
// hidden at its edge) lays effects spanning that frame out again over the
// whole group, beyond the change (#4053).
let spans = photocraft_compose::effects::spans_frame;
if (spans(x) || spans(y)) && photocraft_compose::effects_frame(x, a.bounds()) != photocraft_compose::effects_frame(y, b.bounds()) {
let (Some(bx), Some(by)) = (
photocraft_compose::change_bounds_in(xs, xk, px, depth, near(a.bounds())),
photocraft_compose::change_bounds_in(ys, yk, py, depth, near(b.bounds())),
) else {
return false;
};
add(out, grow(bx, a.bounds()));
add(out, grow(by, b.bounds()));
}
let inner = Around {
reach: around.reach.saturating_add(reach(x).max(reach(y))),
reach: around.reach.saturating_add(effects_reach(x).max(effects_reach(y))),
spans_frame: around.spans_frame || photocraft_compose::effects::spans_frame(x) || photocraft_compose::effects::spans_frame(y),
};
if !walk(xc, yc, (Some((xs, xk)), Some((ys, yk))), inner, a, b, out) {
let (qx, qy): (Vec<_>, Vec<_>) = (px.iter().copied().chain([(xs, xk)]).collect(), py.iter().copied().chain([(ys, yk)]).collect());
if !walk(xc, yc, (&qx, &qy), inner, a, b, out) {
return false;
}
}
@@ -496,7 +620,49 @@ pub fn step_damage(a: &Document, b: &Document) -> Option<Rect> {
}
}
let mut out = Rect::EMPTY;
if walk(&a.layers, &b.layers, (None, None), Around { reach: 0, spans_frame: false }, a, b, &mut out) { Some(out) } else { None }
if walk(&a.layers, &b.layers, (&[], &[]), Around { reach: 0, spans_frame: false }, a, b, &mut out) { Some(out) } else { None }
}
/// The layers both sibling lists hold that changed places (`None` when the lists are too long to
/// compare): those outside a longest common subsequence of the ids both sides have, so a bring
/// forward moves one layer, not every layer it passes.
fn moved_ids(xs: &[Layer], ys: &[Layer]) -> Option<Vec<LayerId>> {
use std::collections::HashSet;
if xs.len() == ys.len() && xs.iter().zip(ys).all(|(x, y)| x.id == y.id) {
return Some(Vec::new());
}
let in_y: HashSet<LayerId> = ys.iter().map(|l| l.id).collect();
let cx: Vec<LayerId> = xs.iter().map(|l| l.id).filter(|id| in_y.contains(id)).collect();
let in_x: HashSet<LayerId> = cx.iter().copied().collect();
let cy: Vec<LayerId> = ys.iter().map(|l| l.id).filter(|id| in_x.contains(id)).collect();
if cx == cy {
return Some(Vec::new());
}
let (n, m) = (cx.len(), cy.len());
// An O(n·m) table: past a few million cells, call it unbounded instead.
if n.saturating_mul(m) > 4_000_000 {
return None;
}
let w = m + 1;
let mut t = vec![0u32; (n + 1) * w];
for i in (0..n).rev() {
for j in (0..m).rev() {
t[i * w + j] = if cx[i] == cy[j] { t[(i + 1) * w + j + 1] + 1 } else { t[(i + 1) * w + j].max(t[i * w + j + 1]) };
}
}
let (mut i, mut j, mut kept) = (0, 0, HashSet::new());
while i < n && j < m {
if cx[i] == cy[j] {
kept.insert(cx[i]);
i += 1;
j += 1;
} else if t[(i + 1) * w + j] >= t[i * w + j + 1] {
i += 1;
} else {
j += 1;
}
}
Some(cx.into_iter().filter(|id| !kept.contains(id)).collect())
}
/// How many tiles that differ between `before` and `after` (in the surfaces of layers both have)
@@ -514,9 +680,54 @@ pub(crate) fn hint_misses(before: &Document, after: &Document, hint: Rect) -> us
misses
}
/// `hint`, a command's own rect for pixels it changed in one of `doc`'s layers, grown as far as
/// [`step_damage`] grows a change there ([`grow_in_layer`], then by the effects of the groups
/// around the layer), for whichever layer that was: the hint doesn't say. A brush dab on a
/// feathered mask changes a soft ring around the dab; cut to the bare dab, the ring stayed stale.
/// Layers the compositor doesn't draw are skipped as it skips them (a hidden layer, a clipped
/// layer over a hidden base, and everything inside a hidden group): a change there shows nowhere,
/// and growing by a hidden layer's effects widened every dab elsewhere.
pub(crate) fn grow_hint(doc: &Document, hint: Rect) -> Rect {
fn walk(layers: &[Layer], reach: i32, hint: Rect, out: &mut Rect) {
let mut base_visible = true;
for l in layers {
if !l.clipped {
base_visible = l.visible;
}
if !l.visible || !base_visible {
continue;
}
let feather = l.mask.as_ref().map_or(0.0, |m| m.feather);
*out = out.union(&grow_in_layer(l, hint, feather).inflate(reach));
if let Some(children) = l.children() {
walk(children, reach.saturating_add(effects_reach(l)), hint, out);
}
}
}
let mut out = hint;
if !hint.is_empty() {
walk(&doc.layers, 0, hint, &mut out);
}
out
}
/// How far layer `l`'s own effects carry a change to its pixels: 0 without effects.
fn effects_reach(l: &Layer) -> i32 {
if photocraft_compose::effects::has_effects(l) { photocraft_compose::effects::margin(l) } else { 0 }
}
/// `r`, changed pixels of a surface layer `l` draws from, grown as far as the change can show in
/// `l`'s own render: by the `feather` (in pixels) the compositor blurs the surface by, then by
/// `l`'s effects.
fn grow_in_layer(l: &Layer, r: Rect, feather: f32) -> Rect {
let r = if feather > 0.0 { photocraft_vector::feather::grow(r, photocraft_vector::feather::feather_sigma(feather)) } else { r };
let margin = effects_reach(l);
if r.is_empty() || margin == 0 { r } else { r.inflate(margin) }
}
/// The pixels a change from layer `a` to layer `b` (the same layer on either side of a step) can
/// have changed when only the pixels of its surfaces changed: the tiles that differ
/// ([`surface_damage`]), a mask's grown by its feather, all grown by the layer's own effects.
/// ([`surface_damage`]), grown as far as each shows in the layer's render ([`grow_in_layer`]).
/// Empty when the layers render identically. `None` when anything else changed (a property, the
/// content kind, a surface's format or default pixel): the caller then takes the layer's whole
/// extents. Children of groups are not compared (the caller walks them).
@@ -524,34 +735,33 @@ fn tile_damage(a: &Layer, b: &Layer) -> Option<Rect> {
let mut tiles = Rect::EMPTY;
let only_pixels = layer_pixels_cmp(a, b, &mut |x, y, feather| {
let Some(r) = surface_damage(x, y) else { return false };
let r = if feather > 0.0 { photocraft_vector::feather::grow(r, photocraft_vector::feather::feather_sigma(feather)) } else { r };
tiles = tiles.union(&r);
// Effects are equal on both sides here (they are compared before any surface).
tiles = tiles.union(&grow_in_layer(b, r, feather));
true
});
if !only_pixels {
return None;
}
// Effects are equal on both sides here (they are compared before any surface).
let margin = if photocraft_compose::effects::has_effects(b) { photocraft_compose::effects::margin(b) } else { 0 };
Some(if tiles.is_empty() || margin == 0 { tiles } else { tiles.inflate(margin) })
only_pixels.then_some(tiles)
}
/// Neither `l` nor anything inside it is a video layer or excludes channels.
fn contained(l: &Layer, depth: usize) -> bool {
depth < 256 && l.video.is_none() && l.excluded_channels == 0 && l.children().is_none_or(|c| c.iter().all(|c| contained(c, depth + 1)))
}
/// The pixels adding or removing `layers[k]` can change, when that is bounded: its change bounds
/// (`photocraft_compose::change_bounds_in`: not for an unclipped adjustment, or a pass-through
/// group holding one; a clipped adjustment's are its base's), provided it isn't the clipping base
/// (`photocraft_compose::change_bounds_in`: an unclipped adjustment's cover the layers beneath it
/// in its isolation scope; a clipped adjustment's are its base's), provided it isn't the clipping base
/// of clipped layers above it (they would change base), and neither it nor anything inside it is
/// a video layer or excludes channels (both reach outside the layer's bounds, see
/// [`step_damage`]).
fn one_sided_bounds(layers: &[Layer], k: usize, outer: Option<(&[Layer], usize)>, canvas: Rect) -> Option<Rect> {
fn contained(l: &Layer, depth: usize) -> bool {
depth < 256 && l.video.is_none() && l.excluded_channels == 0 && l.children().is_none_or(|c| c.iter().all(|c| contained(c, depth + 1)))
}
/// a video layer (it reaches outside the layer's bounds, see [`step_damage`]) or excludes
/// channels. Channel exclusion is per pixel where the layer draws, so it doesn't actually reach
/// further (`step_damage` bounds a property change of it that way); here it stays unbounded only
/// to be conservative.
fn one_sided_bounds(layers: &[Layer], k: usize, ancestors: &Ancestors, depth: photocraft_color::SampleType, canvas: Rect) -> Option<Rect> {
let l = layers.get(k)?;
let new_base = !l.clipped && layers.get(k + 1).is_some_and(|above| above.clipped);
if new_base || !contained(l, 0) {
return None;
}
photocraft_compose::change_bounds_in(layers, k, outer, canvas)
photocraft_compose::change_bounds_in(layers, k, ancestors, depth, canvas)
}
/// Whether `layers[k]` draws nothing wherever it sits, so adding or removing it leaves the
@@ -576,12 +786,18 @@ fn inert_at(layers: &[Layer], k: usize) -> bool {
&& (l.clipped || layers.get(k + 1).is_none_or(|above| !above.clipped))
}
/// Report the pixels a command made of more than one edit changed since `before` ([`step_damage`]),
/// so the canvas recomposites only where its new layers draw instead of the whole document.
/// A single [`Session::edit`] already reports its own damage: don't call this after one (#5695).
pub(crate) fn note_insert(s: &mut Session, before: &Document) {
let Some(st) = s.active_mut() else { return };
st.last_damage = step_damage(before, &st.doc);
/// How much of the document (percent, rounded down) changed between `a` and `b`, when that is
/// some but under 25 %: what [`step_damage`] finds, which a command that reported "redraw
/// everything" (`None`) should have reported instead. `None` when nothing changed (a view-only
/// change), when it's 25 % or more, or when `step_damage` can't bound it (a canvas-size change).
#[cfg(any(test, debug_assertions))]
pub(crate) fn small_change_pct(a: &Document, b: &Document) -> Option<u64> {
let d = step_damage(a, b).filter(|d| !d.is_empty())?;
let doc = b.bounds();
let whole = u64::from(doc.width()) * u64::from(doc.height());
let changed = d.intersect(&doc);
let pct = u64::from(changed.width()) * u64::from(changed.height()) * 100 / whole.max(1);
(pct < 25).then_some(pct)
}
/// Content bounds of a layer's pixels as stored (type and shape layers: their cached render), or
@@ -152,6 +152,7 @@ pub fn translate(s: &mut Session, p: &Value) -> Result<Value> {
}
let mask_target = p.get("target").and_then(Value::as_str) == Some("mask");
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
let mut shift = (0, 0);
let nested = s.edit("Move", |doc, _| {
let (alone, rest): (Vec<LayerId>, Vec<LayerId>) = roots.iter().partition(|id| mask_target && doc.layer(**id).is_some_and(moves_mask_alone));
let mut ids = move_targets(doc, &rest);
@@ -175,7 +176,7 @@ pub fn translate(s: &mut Session, p: &Value) -> Result<Value> {
}
// A board moved past any canvas edge grows the canvas, as a new one does (#1531, #3070).
if ids.iter().any(|id| doc.layer(*id).is_some_and(|l| l.artboard().is_some())) {
crate::artboard_cmds::fit_canvas(doc);
shift = crate::artboard_cmds::fit_canvas(doc);
}
// The Move tool carries the selection along when it moves several layers whole, as
// Photoshop does (#7538): one step, so Undo takes both back.
@@ -184,6 +185,7 @@ pub fn translate(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(nested)
})?;
crate::artboard_cmds::follow_shift(s, shift);
if nested.is_empty() {
return Ok(Value::Null);
}
@@ -272,6 +272,7 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul
let sel = selected(s);
let old_active = s.active().and_then(|d| d.active_layer);
let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true);
let mut shift = (0i32, 0i32);
let (copies, active) = s.edit(label, |doc, active| {
let mut copies = Vec::new();
let mut new_active = None;
@@ -281,7 +282,9 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul
let dup = crate::commands::layer_copy(doc, id, add_copy)?;
let nid = doc.insert_above(Some(id), dup);
if !in_place {
crate::artboard_cmds::place_copy(doc, nid)?;
// Each copy's shift moved the whole document, so they add up.
let (dx, dy) = crate::artboard_cmds::place_copy(doc, nid)?;
shift = (shift.0.saturating_add(dx), shift.1.saturating_add(dy));
}
if Some(id) == old_active {
new_active = Some(nid);
@@ -293,6 +296,7 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul
*active = a;
Ok((copies, a))
})?;
crate::artboard_cmds::follow_shift(s, shift);
let out = copies.iter().map(|l| l.0).collect::<Vec<_>>();
reselect(s, copies, active);
Ok(json!({"layers": out, "layer": active.map(|l| l.0)}))
+358 -14
View File
@@ -176,13 +176,13 @@ fn moves_and_layer_props_report_only_their_area() {
assert_eq!(damage(&s), Some(Rect::new(15, 7, 25, 17)));
s.execute("layer.setProps", json!({"layer": a.0, "opacity": 0.5, "blend": "Multiply"})).unwrap();
assert_eq!(damage(&s), Some(Rect::new(15, 7, 25, 17)));
// Clipping changes reach the layers around: everything.
// Clipping a pixel layer with nothing clipped to it changes only where it draws (#9329).
s.execute("layer.setProps", json!({"layer": a.0, "clipped": true})).unwrap();
assert_eq!(damage(&s), None);
// An adjustment layer changes everything beneath it.
assert_eq!(damage(&s), Some(Rect::new(15, 7, 25, 17)));
// An adjustment layer changes everything beneath it: here the Background, the canvas.
let adj = s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setProps", json!({"layer": adj, "visible": false})).unwrap();
assert_eq!(damage(&s), None);
assert_eq!(damage(&s), Some(doc(&s).bounds()));
// The Background (opaque everywhere): everything.
let bg = doc(&s).layers[0].id;
s.execute("layer.setProps", json!({"layer": bg.0, "opacity": 0.5})).unwrap();
@@ -269,11 +269,11 @@ fn new_layers_that_change_clipping_recomposite_everything() {
based.layers.insert(under, l);
assert_eq!(step_damage(&before, &based), None);
assert_eq!(step_damage(&based, &before), None);
// An adjustment reaches every pixel below it.
// An adjustment reaches every pixel below it: here the Background, the canvas.
let mut adjusted = before.clone();
adjusted.insert_above(None, Layer::new("inv", LayerContent::Adjustment(photocraft_doc::Adjustment::Invert)));
assert_eq!(step_damage(&before, &adjusted), None);
assert_eq!(step_damage(&adjusted, &before), None);
assert_eq!(step_damage(&before, &adjusted), Some(before.bounds()));
assert_eq!(step_damage(&adjusted, &before), Some(before.bounds()));
// Empty layers draw nothing, in any blend mode.
let mut styled = before.clone();
let mut l = Layer::raster("styled", styled.pixel_format());
@@ -343,11 +343,11 @@ fn toggling_a_clipped_adjustment_damages_only_its_base() {
s.execute("layer.setProps", json!({"layer": adj.0, "visible": false})).unwrap();
assert_eq!(damage(&s), Some(base_rect));
assert_eq!(patched(&s, &shown), hidden);
// Unclipped, the adjustment still reaches the whole canvas.
// Unclipped over the Background, the adjustment still reaches the whole canvas.
s.execute("layer.setProps", json!({"layer": adj.0, "visible": true})).unwrap();
s.execute("layer.releaseClippingMask", json!({"layer": adj.0})).unwrap();
s.execute("layer.hideLayers", json!({"layer": adj.0})).unwrap();
assert_eq!(damage(&s), None);
assert_eq!(damage(&s), Some(doc(&s).bounds()));
}
}
@@ -395,6 +395,49 @@ fn clipped_adjustment_in_pass_through_clip_base_damages_its_group() {
assert_eq!(step_damage(&before, &after), Some(Rect::new(2, 1, 4, 3)));
}
/// #9329: hiding the last visible unclipped adjustment in a pass-through group that is a clip
/// base switches the group from isolated to pass-through rendering, which agrees with the old
/// pixels outside the layers beneath only up to float rounding, so it damages the group too.
#[test]
fn unclipped_adjustment_in_pass_through_clip_base_damages_its_group() {
use photocraft_doc::{Adjustment, BlendMode};
let solid = |name: &str, r: Rect, blend: BlendMode| {
let mut l = Layer::raster(name, PixelFormat::RGBA8);
l.surface_mut().unwrap().fill_rect(r, &[0.5, 0.3, 0.7, 0.6]);
l.blend = blend;
l
};
let mut before = Document::with_background(
"t",
photocraft_geom::Size::new(10, 8),
photocraft_doc::ColorMode::Rgb,
photocraft_doc::SampleType::U8,
photocraft_color::Color::WHITE,
);
before.layers.clear();
before.layers.push(solid("bg", Rect::new(0, 0, 2, 2), BlendMode::Normal));
let a = Layer::new("A", LayerContent::Adjustment(Adjustment::Invert));
let mut g = Layer::group("G", vec![solid("P", Rect::new(2, 1, 4, 3), BlendMode::Normal), a, solid("Q", Rect::new(6, 4, 8, 6), BlendMode::Multiply)]);
g.blend = BlendMode::PassThrough;
before.layers.push(g);
let mut x = solid("X", Rect::new(2, 1, 7, 5), BlendMode::Normal);
x.clipped = true;
before.layers.push(x);
let mut after = before.clone();
let LayerContent::Group(g) = &mut after.layers[1].content else { panic!("group") };
g.children[1].visible = false;
// The layers beneath A (0..4 × 0..3) and the group (2..8 × 1..6).
let r = Rect::new(0, 0, 8, 6);
assert_eq!(step_damage(&before, &after), Some(r));
assert_eq!(step_damage(&after, &before), Some(r));
assert!(unchanged_outside(&before, &after, r));
// Without the layer clipped to the group it stays pass-through: just the layers beneath.
before.layers.truncate(2);
after.layers.truncate(2);
assert_eq!(step_damage(&before, &after), Some(Rect::new(0, 0, 4, 3)));
assert!(unchanged_outside(&before, &after, Rect::new(0, 0, 4, 3)));
}
/// #2883 (P8/P9): a duplicate shares the source's tiles (no pixel copy) and recomposites only
/// where it draws; undo brings back exactly the old pixels. A copy that becomes the clipping
/// base of the layers above still recomposites everything.
@@ -1224,13 +1267,314 @@ fn undo_of_clipping_or_channel_exclusion_composites_everything() {
let _ = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
let b = rect_layer(&mut s, Rect::new(12, 12, 30, 30));
select_all(&mut s, &[b]);
// Clipping reaches the layers clipped above, outside this layer's bounds.
s.execute("layer.setProps", json!({"clipped": true})).unwrap();
// #9329: on a pixel layer both are bounded: clipping by the layer (and the clipped layers
// above it), channel exclusion by the layer (per pixel where it draws).
for p in [json!({"clipped": true}), json!({"channels": [true, false, true]})] {
let before = doc(&s).clone();
s.execute("layer.setProps", p.clone()).unwrap();
let after = doc(&s).clone();
s.undo();
let r = damage(&s).unwrap_or_else(|| panic!("{p} is bounded"));
assert!(r.width() < 100 && unchanged_outside(&after, &before, r), "{p}: {r:?}");
}
// On a group they change how it composites its children: everything.
s.execute("layer.groupLayers", json!({})).unwrap();
let g = s.active().unwrap().active_layer.unwrap();
assert!(doc(&s).layer(g).unwrap().children().is_some());
s.execute("layer.setProps", json!({"layer": g.0, "clipped": true})).unwrap();
s.undo();
assert_eq!(damage(&s), None, "a clipping change recomposites everything");
s.execute("layer.setProps", json!({"channels": [true, false, true]})).unwrap();
assert_eq!(damage(&s), None, "a group's clipping change recomposites everything");
s.execute("layer.setProps", json!({"layer": g.0, "channels": [true, false, true]})).unwrap();
s.undo();
assert_eq!(damage(&s), None, "an excluded-channel change recomposites everything");
assert_eq!(damage(&s), None, "a group's excluded-channel change recomposites everything");
}
/// A new adjustment layer of `kind` (made while `s`'s selection is active, so it masks it).
fn adjustment(s: &mut Session, kind: &str) -> LayerId {
LayerId(s.execute(&format!("layer.newAdjustmentLayer.{kind}"), json!({})).unwrap()["layer"].as_u64().unwrap())
}
/// Run `id` with `p`, then undo and redo it: each step's damage is bounded, smaller than the
/// canvas, and covers every pixel it changed (`unchanged_outside`). Returns the step's damage.
fn bounded_step(s: &mut Session, id: &str, p: serde_json::Value) -> Rect {
let before = doc(s).clone();
s.execute(id, p).unwrap();
let after = doc(s).clone();
let canvas = after.bounds();
let check = |s: &Session, from: &Document, to: &Document, what: &str| {
let r = damage(s).unwrap_or_else(|| panic!("{id} {what}: bounded"));
assert!(r.width() * r.height() < canvas.width() * canvas.height(), "{id} {what}: {r:?}");
assert!(unchanged_outside(from, to, r), "{id} {what}: {r:?} misses changed pixels");
r
};
let r = check(s, &before, &after, "");
s.undo();
check(s, &after, &before, "undo");
s.redo();
check(s, &before, &after, "redo");
r
}
/// #9329, #6533: in a 32-bit document hiding a masked adjustment damages only its mask, over
/// an opaque Background too. An integer document rounds the whole backdrop after every
/// visible adjustment layer (#9033), so there it still damages the canvas.
#[test]
fn hiding_a_masked_adjustment_damages_only_its_mask() {
for depth in [32, 8] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 800, "height": 600, "depth": depth})).unwrap();
let c = rect_layer(&mut s, Rect::new(50, 50, 700, 500));
s.execute("layer.setProps", json!({"layer": c.0, "opacity": 0.37})).unwrap();
let reveal = Rect::new(520, 330, 560, 370);
s.execute("select.rect", json!({"x": reveal.x0, "y": reveal.y0, "width": 40, "height": 40})).unwrap();
let adj = adjustment(&mut s, "invert");
assert!(doc(&s).layer(adj).unwrap().mask.is_some());
if depth == 32 {
let r = bounded_step(&mut s, "layer.setProps", json!({"layer": adj.0, "visible": false}));
assert!(r.contains_rect(&reveal), "{r:?}");
} else {
s.execute("layer.setProps", json!({"layer": adj.0, "visible": false})).unwrap();
assert_eq!(damage(&s), Some(doc(&s).bounds()));
}
}
}
/// #9329, #6528: Show Only This Layer, in a document whose only layer that could reach
/// everywhere was an adjustment, is bounded by where the layers draw.
#[test]
fn show_only_with_an_adjustment_is_bounded() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 80, "depth": 8, "background": "transparent"})).unwrap();
let a = rect_layer(&mut s, Rect::new(10, 10, 30, 30));
let b = rect_layer(&mut s, Rect::new(40, 20, 60, 50));
s.execute("layer.setProps", json!({"layer": b.0, "opacity": 0.37})).unwrap();
let _ = adjustment(&mut s, "invert");
let r = bounded_step(&mut s, "layer.showOnly", json!({"layer": a.0}));
assert_eq!(r, Rect::new(10, 10, 60, 50));
}
/// #9329: Bring Forward damages only where the moved layer draws, not the whole canvas.
#[test]
fn bring_forward_damages_only_the_moved_layer() {
let mut s = session(8);
let a = rect_layer(&mut s, Rect::new(10, 10, 30, 30));
let _b = rect_layer(&mut s, Rect::new(20, 20, 50, 50));
let _c = rect_layer(&mut s, Rect::new(60, 60, 70, 70));
select_all(&mut s, &[a]);
let r = bounded_step(&mut s, "layer.arrange.bringForward", json!({}));
assert_eq!(r, Rect::new(10, 10, 30, 30));
}
/// #9329: Send Backward, its undo and its redo each damage only the moved layer; an
/// adjustment moving past a layer reaches as far as the layers beneath it at either end.
#[test]
fn send_backward_undo_and_redo_are_bounded() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 80, "depth": 8, "background": "transparent"})).unwrap();
let _a = rect_layer(&mut s, Rect::new(10, 10, 30, 30));
let b = rect_layer(&mut s, Rect::new(20, 20, 50, 50));
select_all(&mut s, &[b]);
// Either layer of the swapped pair is the one that moved.
let r = bounded_step(&mut s, "layer.arrange.sendBackward", json!({}));
assert!(r == Rect::new(20, 20, 50, 50) || r == Rect::new(10, 10, 30, 30), "{r:?}");
let _c = rect_layer(&mut s, Rect::new(60, 60, 70, 70));
let adj = adjustment(&mut s, "invert");
select_all(&mut s, &[adj]);
bounded_step(&mut s, "layer.arrange.sendBackward", json!({}));
}
/// #9329: a moved group holding a video layer has no bounded damage, as the video layer itself
/// has none.
#[test]
fn bring_forward_on_a_group_holding_a_video_layer_is_unbounded() {
let mut s = session(8);
let v = rect_layer(&mut s, Rect::new(10, 10, 30, 30));
s.edit("video", |doc, _| {
let l = doc.layer_mut(v).unwrap();
let frame = l.surface().unwrap().clone();
l.video = Some(photocraft_doc::VideoData::new(vec![frame.clone(), frame], 24.0));
Ok(())
})
.unwrap();
s.execute("layer.select", json!({"layer": v.0})).unwrap();
let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap());
let b = rect_layer(&mut s, Rect::new(20, 20, 50, 50));
assert_eq!(doc(&s).layers.iter().map(|l| l.id).skip(1).collect::<Vec<_>>(), vec![g, b]);
select_all(&mut s, &[g]);
s.execute("layer.arrange.bringForward", json!({})).unwrap();
assert_eq!(doc(&s).layers.iter().map(|l| l.id).skip(1).collect::<Vec<_>>(), vec![b, g]);
assert_eq!(damage(&s), None);
}
/// #9329: reordering inside a group with a drop shadow damages the shadow too. The moved layer
/// knocks out the one beneath it, so the group's shape, and with it the shadow, changes.
#[test]
fn a_reorder_inside_a_group_with_a_drop_shadow_damages_the_shadow() {
let mut s = session(8);
let k = rect_layer(&mut s, Rect::new(30, 30, 50, 50));
let b = rect_layer(&mut s, Rect::new(20, 20, 50, 50));
s.edit("knockout", |doc, _| {
let l = doc.layer_mut(k).unwrap();
l.advanced.knockout = photocraft_doc::Knockout::Shallow;
l.fill_opacity = 0.0;
Ok(())
})
.unwrap();
select_all(&mut s, &[k, b]);
let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap());
s.edit("fx", |doc, _| {
doc.layer_mut(g).unwrap().effects.items.push(photocraft_doc::Effect::default_drop_shadow());
Ok(())
})
.unwrap();
assert_eq!(doc(&s).layer(g).unwrap().children().unwrap().iter().map(|l| l.id).collect::<Vec<_>>(), vec![k, b]);
select_all(&mut s, &[k]);
let r = bounded_step(&mut s, "layer.arrange.bringForward", json!({}));
assert!(r.contains_rect(&Rect::new(30, 30, 50, 50)), "{r:?}");
}
/// #9329: deleting a layer inside a group with a drop shadow, and adding it back on undo,
/// damages the shadow too, which reaches past the layer.
#[test]
fn adding_or_deleting_a_layer_inside_a_group_with_a_drop_shadow_damages_the_shadow() {
let mut s = session(8);
let k = rect_layer(&mut s, Rect::new(20, 20, 30, 30));
let b = rect_layer(&mut s, Rect::new(50, 40, 60, 50));
select_all(&mut s, &[k, b]);
let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap());
s.edit("fx", |doc, _| {
doc.layer_mut(g).unwrap().effects.items.push(photocraft_doc::Effect::default_drop_shadow());
Ok(())
})
.unwrap();
let r = bounded_step(&mut s, "layer.delete", json!({"layer": k.0}));
assert!(r.contains_rect(&Rect::new(20, 20, 30, 30)), "{r:?}");
}
/// A group holding `layers`, with a drop shadow that reaches about 8 px down and right of them.
fn shadowed_group(s: &mut Session, layers: &[LayerId]) -> LayerId {
select_all(s, layers);
let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap());
s.edit("fx", |doc, _| {
doc.layer_mut(g).unwrap().effects.items.push(photocraft_doc::Effect::default_drop_shadow());
Ok(())
})
.unwrap();
g
}
/// [`bounded_step`] for a step that must change canvas pixels, so it can't pass vacuously.
fn bounded_visible_step(s: &mut Session, id: &str, p: serde_json::Value) -> Rect {
let before = photocraft_compose::render(doc(s), doc(s).bounds());
let r = bounded_step(s, id, p);
assert_ne!(photocraft_compose::render(doc(s), doc(s).bounds()).px, before.px, "{id}: no canvas pixel changed");
r
}
/// #9329: deleting a layer just off the canvas inside a group whose drop shadow reaches onto
/// it, and adding it back on undo, damages the shadow on the canvas.
#[test]
fn deleting_an_off_canvas_layer_damages_its_group_shadow_on_the_canvas() {
let mut s = session(8);
let k = rect_layer(&mut s, Rect::new(-10, 20, -1, 30));
let b = rect_layer(&mut s, Rect::new(50, 40, 60, 50));
shadowed_group(&mut s, &[k, b]);
bounded_visible_step(&mut s, "layer.delete", json!({"layer": k.0}));
}
/// #9329: reordering a layer just off the canvas inside a group whose drop shadow reaches onto
/// it damages the shadow on the canvas. The moved layer knocks out the one beneath it, so the
/// group's shape, and with it the shadow, changes.
#[test]
fn reordering_an_off_canvas_layer_damages_its_group_shadow_on_the_canvas() {
let mut s = session(8);
let k = rect_layer(&mut s, Rect::new(-9, 25, -1, 35));
let b = rect_layer(&mut s, Rect::new(-14, 20, -1, 40));
s.edit("knockout", |doc, _| {
let l = doc.layer_mut(k).unwrap();
l.advanced.knockout = photocraft_doc::Knockout::Shallow;
l.fill_opacity = 0.0;
Ok(())
})
.unwrap();
shadowed_group(&mut s, &[k, b]);
select_all(&mut s, &[k]);
bounded_visible_step(&mut s, "layer.arrange.bringForward", json!({}));
}
/// #9329: unclipping a layer inside a group with a drop shadow exposes its pixels outside its
/// old base, which moves the group's shadow past the layer; clipping it back, on undo and redo,
/// too.
#[test]
fn clipping_and_unclipping_inside_a_shadowed_group_damages_the_shadow() {
let mut s = session(8);
let a = rect_layer(&mut s, Rect::new(20, 20, 30, 30));
let b = rect_layer(&mut s, Rect::new(25, 25, 45, 45));
s.execute("layer.setProps", json!({"layer": b.0, "clipped": true})).unwrap();
shadowed_group(&mut s, &[a, b]);
bounded_visible_step(&mut s, "layer.setProps", json!({"layer": b.0, "clipped": false}));
bounded_visible_step(&mut s, "layer.setProps", json!({"layer": b.0, "clipped": true}));
}
/// #9329: a property change on a layer inside a group with a drop shadow changes the shadow,
/// which reaches past the layer.
#[test]
fn a_property_change_inside_a_shadowed_group_damages_the_shadow() {
let mut s = session(8);
let a = rect_layer(&mut s, Rect::new(20, 20, 30, 30));
let b = rect_layer(&mut s, Rect::new(50, 40, 60, 50));
shadowed_group(&mut s, &[a, b]);
bounded_visible_step(&mut s, "layer.setProps", json!({"layer": a.0, "opacity": 0.3}));
}
/// #9329: hiding or deleting a layer at the edge of a group with a Gradient Overlay shrinks the
/// group's frame, which lays the gradient out again over the rest of the group (#4053).
#[test]
fn hiding_or_deleting_a_layer_in_a_group_with_a_gradient_overlay_damages_the_group() {
use photocraft_doc::{Effect, FxCommon, Gradient};
let mut s = session(8);
let a = rect_layer(&mut s, Rect::new(10, 10, 30, 30));
let b = rect_layer(&mut s, Rect::new(60, 40, 80, 60));
select_all(&mut s, &[a, b]);
let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap());
s.edit("fx", |doc, _| {
doc.layer_mut(g).unwrap().effects.items.push(Effect::GradientOverlay {
common: FxCommon::new(photocraft_doc::BlendMode::Normal, 1.0),
gradient: Gradient::default(),
dither: false,
interpolation: None,
});
Ok(())
})
.unwrap();
let r = bounded_visible_step(&mut s, "layer.setProps", json!({"layer": b.0, "visible": false}));
assert!(r.contains_rect(&Rect::new(10, 10, 30, 30)), "{r:?}");
s.undo();
let r = bounded_visible_step(&mut s, "layer.delete", json!({"layer": b.0}));
assert!(r.contains_rect(&Rect::new(10, 10, 30, 30)), "{r:?}");
}
/// #9329: clipping a layer to the one beneath damages it and the layers clipped to it
/// (they change base), on undo and redo too.
#[test]
fn clipping_a_layer_damages_it_and_the_clipped_run_above() {
let mut s = session(8);
let _a = rect_layer(&mut s, Rect::new(10, 10, 40, 40));
let b = rect_layer(&mut s, Rect::new(20, 20, 60, 50));
let c = rect_layer(&mut s, Rect::new(30, 15, 70, 45));
s.execute("layer.setProps", json!({"layer": c.0, "clipped": true})).unwrap();
let r = bounded_step(&mut s, "layer.setProps", json!({"layer": b.0, "clipped": true}));
assert_eq!(r, Rect::new(20, 15, 70, 50));
}
/// #9329: excluding a channel from a pixel layer's blend damages only where it draws.
#[test]
fn excluding_a_channel_damages_only_the_layer() {
let mut s = session(8);
let _a = rect_layer(&mut s, Rect::new(10, 10, 40, 40));
let b = rect_layer(&mut s, Rect::new(20, 20, 60, 50));
let r = bounded_step(&mut s, "layer.setProps", json!({"layer": b.0, "channels": [true, false, true]}));
assert_eq!(r, Rect::new(20, 20, 60, 50));
}
#[test]
@@ -173,23 +173,71 @@ fn tile_damage_grows_by_the_layer_effect_margin() {
assert!(unchanged_outside(&before, &after, d));
}
/// The default drop shadow, `distance` px away and `size` px soft.
fn drop_shadow(distance: f32, size: f32) -> photocraft_doc::Effect {
match photocraft_doc::Effect::default_drop_shadow() {
photocraft_doc::Effect::DropShadow(sh) => photocraft_doc::Effect::DropShadow(photocraft_doc::Shadow { distance, size, ..sh }),
e => e,
}
}
/// A styled layer inside a styled group: the group's shadow is cast by the child's shadow too, so
/// a dab's change reaches as far as both shadows together, not just the farther one.
#[test]
fn a_dab_inside_a_group_with_a_drop_shadow_damages_the_shadow() {
let mut s = session(1024, 768);
let child = layer_with(&mut s, &[(Rect::new(600, 400, 700, 500), [1.0, 0.0, 0.0, 1.0])]);
let child = layer_with(&mut s, &[(Rect::new(200, 400, 300, 500), [1.0, 0.0, 0.0, 1.0])]);
s.execute("layer.select", json!({"layer": child.0})).unwrap();
let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap());
s.edit("fx", |doc, _| {
doc.layer_mut(g).unwrap().effects.items.push(photocraft_doc::Effect::default_drop_shadow());
for id in [child, g] {
doc.layer_mut(id).unwrap().effects.items.push(drop_shadow(40.0, 2.0));
}
Ok(())
})
.unwrap();
assert!(photocraft_compose::effects::margin(doc(&s).layer(g).unwrap()) > 0);
let dab = Rect::new(640, 440, 660, 460);
let (before, after) = paint(&mut s, child, dab, [0.0, 0.0, 1.0, 1.0]);
assert!(photocraft_compose::effects::margin(doc(&s).layer(g).unwrap()) > 40);
// A brush dab (a hinted edit) in empty space left of a tile's right edge. Each shadow falls
// about 20 px right and 35 px down, so the group's shadow of the child's shadow lands about
// 40 px right and 70 px down, in the next tile and past either shadow's reach alone.
s.execute("layer.select", json!({"layer": child.0})).unwrap();
let before = doc(&s);
s.execute("paint.stroke", json!({"points": [[TILE_SIZE - 30, 300]], "size": 10, "hardness": 1.0, "color": "#0000ff", "layer": child.0})).unwrap();
assert_eq!(s.damage_misses, 0);
let d = damage(&s).expect("a bounded dab");
assert!(d.contains_rect(&dab), "{d:?}");
assert!(unchanged_outside(&before, &after, d), "the group's shadow changed outside {d:?}");
assert!(d.x0 < TILE_SIZE && d.x1 > TILE_SIZE, "{d:?}");
assert!(unchanged_outside(&before, &doc(&s), d), "the group's shadow changed outside {d:?}");
}
/// A layer the compositor doesn't draw adds nothing to a dab elsewhere: neither a hidden layer's
/// effects nor those of layers inside a hidden group grow the hint.
#[test]
fn a_brush_hint_ignores_the_effects_of_hidden_layers() {
let mut s = session(1024, 768);
let plain = full_layer(&mut s);
let hidden = layer_with(&mut s, &[(Rect::new(800, 600, 900, 700), [1.0, 0.0, 0.0, 1.0])]);
let child = layer_with(&mut s, &[(Rect::new(50, 600, 150, 700), [0.0, 1.0, 0.0, 1.0])]);
s.execute("layer.select", json!({"layer": child.0})).unwrap();
let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap());
s.edit("fx", |doc, _| {
for id in [hidden, child] {
let l = doc.layer_mut(id).unwrap();
l.effects.items.push(drop_shadow(40.0, 40.0));
}
doc.layer_mut(hidden).unwrap().visible = false;
doc.layer_mut(g).unwrap().visible = false;
Ok(())
})
.unwrap();
assert!(photocraft_compose::effects::margin(doc(&s).layer(hidden).unwrap()) > 40);
// A dab in the middle of a tile, far from every tile edge.
s.execute("layer.select", json!({"layer": plain.0})).unwrap();
let before = doc(&s);
s.execute("paint.stroke", json!({"points": [[384, 384]], "size": 10, "hardness": 1.0, "color": "#0000ff", "layer": plain.0})).unwrap();
assert_eq!(s.damage_misses, 0);
let d = damage(&s).expect("a bounded dab");
assert!(d.contains(384, 384) && d.width() <= 20 && d.height() <= 20, "grown by a hidden layer: {d:?}");
assert!(unchanged_outside(&before, &doc(&s), d));
}
#[test]
@@ -210,6 +258,12 @@ fn painting_a_clip_base_under_a_styled_clipped_layer_is_covered() {
let (before, after) = paint(&mut s, base, Rect::new(640, 440, 660, 460), [0.0, 0.0, 0.0, 0.0]);
let d = damage(&s).expect("a bounded base edit");
assert!(unchanged_outside(&before, &after, d), "the clipped layer changed outside {d:?}");
// At a tile's edge too: the clipped layer's shadow is built from its own shape, and the base
// only masks it pixel by pixel, so a base edit changes nothing past its own tiles.
let (before, after) = paint(&mut s, base, Rect::new(3 * TILE_SIZE - 6, 440, 3 * TILE_SIZE, 460), [0.0, 0.0, 0.0, 0.0]);
let d = damage(&s).expect("a bounded base edit");
assert!(d.x1 <= 3 * TILE_SIZE, "{d:?}");
assert!(unchanged_outside(&before, &after, d), "the clipped layer changed outside {d:?}");
}
/// #4053: effects laid out over the layer's frame repaint it everywhere when it grows.
@@ -269,6 +323,41 @@ fn a_hint_that_misses_a_changed_tile_is_counted() {
assert_eq!(damage(&s), Some(Rect::new(600, 400, 620, 420)));
}
/// A brush hint is the bare dab; on a feathered mask the dab changes a soft ring around it, and
/// the hint must not cut that off.
#[test]
fn a_brush_hint_on_a_feathered_mask_keeps_the_ring() {
let mut s = session(1024, 768);
let id = full_layer(&mut s);
s.edit("mask", |doc, _| {
doc.layer_mut(id).unwrap().mask = Some(photocraft_doc::LayerMask { feather: 10.0, ..photocraft_doc::LayerMask::reveal_all() });
Ok(())
})
.unwrap();
let before = doc(&s);
s.execute("paint.stroke", json!({"points": [[500, 400]], "size": 10, "hardness": 1.0, "color": "#000000", "target": "mask", "layer": id.0})).unwrap();
assert_eq!(s.damage_misses, 0);
let d = damage(&s).expect("a bounded dab");
assert!(unchanged_outside(&before, &doc(&s), d), "the feathered mask changed outside {d:?}");
}
/// The same for a layer's effects: the dab's drop shadow lands outside the dab.
#[test]
fn a_brush_hint_on_a_layer_with_a_drop_shadow_keeps_the_shadow() {
let mut s = session(1024, 768);
let id = layer_with(&mut s, &[(Rect::new(600, 400, 700, 500), [1.0, 0.0, 0.0, 1.0])]);
s.edit("fx", |doc, _| {
doc.layer_mut(id).unwrap().effects.items.push(photocraft_doc::Effect::default_drop_shadow());
Ok(())
})
.unwrap();
let before = doc(&s);
s.execute("paint.stroke", json!({"points": [[300, 300]], "size": 10, "hardness": 1.0, "color": "#0000ff", "layer": id.0})).unwrap();
assert_eq!(s.damage_misses, 0);
let d = damage(&s).expect("a bounded dab");
assert!(unchanged_outside(&before, &doc(&s), d), "the shadow changed outside {d:?}");
}
#[test]
fn painting_command_hints_cover_their_changed_tiles() {
let mut s = session(600, 400);
@@ -319,3 +408,195 @@ fn painting_command_hints_cover_their_changed_tiles() {
assert!(unchanged_outside(&before, &doc(&s), d), "{cmd}: pixels changed outside {d:?}");
}
}
// PR C (#9329): damage spans a command, not a revision.
/// The damage since the command began: measured from the revision before it, bounded, and
/// covering every pixel the command changed.
fn command_damage(s: &mut Session, cmd: &str, p: serde_json::Value) -> (std::sync::Arc<Document>, Rect) {
let (before, rev) = (doc(s), s.active().unwrap().revision);
s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
let st = s.active().unwrap();
assert!(st.revision - rev >= 2, "{cmd}: several revisions ({} → {})", rev, st.revision);
assert_eq!(st.damage_from(), rev, "{cmd}: measured from before the command");
let d = st.last_damage.unwrap_or_else(|| panic!("{cmd}: bounded damage"));
assert!(unchanged_outside(&before, &st.doc, d), "{cmd}: pixels changed outside {d:?}");
(before, d)
}
#[test]
fn paste_into_reports_damage_since_the_command_began() {
let mut s = session(1024, 768);
layer_with(&mut s, &[(Rect::new(0, 0, 1024, 768), [0.2, 0.4, 0.6, 1.0]), (Rect::new(40, 40, 120, 120), [0.9, 0.1, 0.1, 1.0])]);
s.execute("select.rect", json!({"x": 40, "y": 40, "width": 100, "height": 100})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("select.rect", json!({"x": 700, "y": 500, "width": 120, "height": 90})).unwrap();
let (_, d) = command_damage(&mut s, "edit.pasteSpecial.pasteInto", json!({}));
assert!(!d.intersect(&Rect::new(700, 500, 820, 590)).is_empty(), "the pasted area: {d:?}");
assert!(u64::from(d.width()) * u64::from(d.height()) * 4 < 1024 * 768, "the pasted area, not the canvas: {d:?}");
}
#[test]
fn stroke_path_damage_spans_every_subpath() {
let mut s = session(1024, 768);
layer_with(&mut s, &[(Rect::new(0, 0, 1024, 768), [0.5, 0.5, 0.5, 1.0])]);
let path = json!({"subpaths": [{"knots": [[20, 20], [80, 20], [80, 80]]}, {"knots": [[800, 600], [900, 600], [900, 700]]}]});
s.execute("path.set", json!({ "path": path })).unwrap();
// A tool with its own `paint.*` command runs once per subpath, one revision each.
let (_, d) = command_damage(&mut s, "path.stroke", json!({"tool": "burn", "size": 16}));
assert!(d.contains(50, 20) && d.contains(850, 600), "both subpaths: {d:?}");
assert_eq!(s.whole_redraws, 0);
}
/// Pattern Stamp copies its pattern into the document on first use: a pattern no layer
/// refers to draws nothing, so a multi-subpath Stroke Path stays bounded.
#[test]
fn a_new_unreferenced_pattern_keeps_stroke_path_bounded() {
let mut s = session(1024, 768);
layer_with(&mut s, &[(Rect::new(0, 0, 1024, 768), [0.5, 0.5, 0.5, 1.0])]);
let path = json!({"subpaths": [{"knots": [[20, 20], [80, 20], [80, 80]]}, {"knots": [[800, 600], [900, 600], [900, 700]]}]});
s.execute("path.set", json!({ "path": path })).unwrap();
let n = doc(&s).patterns.len();
let p = json!({"tool": "patternStamp", "size": 16, "hardness": 100, "pattern": "Checkerboard", "scale": 100, "aligned": true});
let (_, d) = command_damage(&mut s, "path.stroke", p);
assert!(doc(&s).patterns.len() > n, "the stamp added its pattern");
assert!(d.contains(50, 20) && d.contains(850, 600), "both subpaths: {d:?}");
assert_eq!(s.whole_redraws, 0);
}
/// A pattern that an existing layer refers to (here a fill layer whose pattern was missing)
/// changes that layer's pixels when it is added: not bounded by the tile diff.
#[test]
fn a_new_pattern_a_layer_refers_to_is_unbounded() {
let mut s = session(400, 300);
s.execute("layer.newFillLayer.pattern", json!({"pattern": "Checkerboard"})).unwrap();
// Without its pixel cache, so both sides render the fill from the library.
let mut with = (*doc(&s)).clone();
with.layers.iter_mut().for_each(|l| l.fill_cache = None);
let mut without = with.clone();
without.patterns.clear();
assert!(!same_pixels(&without, &with), "the fill layer draws only with its pattern");
assert_eq!(step_damage(&without, &with), None);
// Unchanged libraries, and an unreferenced addition, stay pixel-equal.
assert_eq!(step_damage(&with, &with), Some(Rect::EMPTY));
let plain = doc(&session(400, 300));
let mut more = (*plain).clone();
more.patterns = with.patterns.clone();
assert_eq!(step_damage(&plain, &more), Some(Rect::EMPTY));
assert!(unchanged_outside(&plain, &more, Rect::EMPTY));
}
/// An appended pattern sharing its name with one a layer already resolves by id loses to that id
/// match (`pattern::find` tries ids first), so the layer draws as before: bounded, not a whole
/// redraw. A cross-document layer copy can bring such a pattern along.
#[test]
fn a_same_named_new_pattern_a_reference_still_resolves_past_is_bounded() {
let mut s = session(400, 300);
s.execute("layer.newFillLayer.pattern", json!({"pattern": "Checkerboard"})).unwrap();
let mut with = (*doc(&s)).clone();
with.layers.iter_mut().for_each(|l| l.fill_cache = None);
let refers_by_id = with.layers.iter().any(|l| matches!(&l.content, LayerContent::Fill(photocraft_doc::Fill::Pattern { id, .. }) if !id.is_empty()));
assert!(refers_by_id, "the fill layer refers to its pattern by id");
let mut twin = with.patterns[0].clone();
twin.id = "another-id".into();
twin.surface.fill_rect(Rect::new(0, 0, twin.width as i32, twin.height as i32), &[1.0, 0.0, 1.0, 1.0]);
let mut more = with.clone();
more.patterns.push(twin);
assert_eq!(step_damage(&with, &more), Some(Rect::EMPTY));
assert!(unchanged_outside(&with, &more, Rect::EMPTY));
}
#[test]
fn a_failed_compound_reports_no_change() {
let mut s = session(600, 400);
let id = layer_with(&mut s, &[(Rect::new(0, 0, 50, 50), [1.0, 0.0, 0.0, 1.0])]);
let (before, rev) = (doc(&s), s.active().unwrap().revision);
let r: Result<()> = crate::analysis_cmds::compound(&mut s, "Both", |s| {
paint(s, id, Rect::new(300, 200, 340, 240), [0.0, 1.0, 0.0, 1.0]);
paint(s, id, Rect::new(10, 300, 40, 340), [0.0, 0.0, 1.0, 1.0]);
Err(EngineError::Other("the second step failed".into()))
});
assert!(r.is_err());
// What `Session::dispatch` does after any inline command.
s.note_command_damage("test.compound", &before, rev);
let st = s.active().unwrap();
assert!(same_pixels(&before, &st.doc), "rolled back");
assert_eq!((st.damage_from(), st.last_damage), (rev, Some(Rect::EMPTY)), "nothing changed since it began");
assert_eq!(s.whole_redraws, 0);
}
#[test]
fn select_then_edit_in_one_command_is_bounded() {
let mut s = session(1024, 768);
let id = layer_with(&mut s, &[(Rect::new(0, 0, 1024, 768), [0.2, 0.4, 0.6, 1.0])]);
let steps = vec![
("layer.select".to_string(), json!({"layer": id.0})),
("select.rect".to_string(), json!({"x": 300, "y": 300, "width": 50, "height": 40})),
("edit.fill".to_string(), json!({"color": "#ff0000"})),
("select.deselect".to_string(), json!({})),
];
s.actions.list.push(crate::actions_cmds::Action::new("A", steps));
let (_, d) = command_damage(&mut s, "actions.play", json!({"action": "A"}));
assert!(d.contains(300, 300) && d.contains(349, 339), "{d:?}");
assert!(u64::from(d.width()) * u64::from(d.height()) * 4 < 1024 * 768, "the filled tiles, not the canvas: {d:?}");
assert_eq!(s.whole_redraws, 0);
}
#[test]
fn whole_document_redraws_of_small_edits_are_counted() {
let mut s = session(1000, 1000);
let id = layer_with(&mut s, &[(Rect::new(0, 0, 1000, 1000), [0.2, 0.4, 0.6, 1.0])]);
// A synthetic command that paints 1 % and reports "everything".
let rev = s.active().unwrap().revision;
let (before, _) = paint(&mut s, id, Rect::new(10, 10, 110, 110), [1.0, 0.0, 0.0, 1.0]);
s.active_mut().unwrap().last_damage = None;
s.note_command_damage("test.wholeRedraw", &before, rev);
assert_eq!(s.whole_redraws, 1);
// A canvas-size change really does redraw everything: not counted.
s.execute("image.canvasSize", json!({"width": 1200, "height": 900})).unwrap();
assert_eq!(s.active().unwrap().last_damage, None);
assert_eq!(s.whole_redraws, 1);
}
#[test]
fn no_command_redraws_the_whole_document_for_a_small_edit() {
let mut s = session(1024, 768);
let base = layer_with(&mut s, &[(Rect::new(0, 0, 1024, 768), [0.2, 0.4, 0.6, 1.0])]);
let small = layer_with(&mut s, &[(Rect::new(100, 100, 160, 160), [0.9, 0.1, 0.1, 1.0])]);
let other = layer_with(&mut s, &[(Rect::new(130, 130, 190, 190), [0.1, 0.9, 0.1, 1.0])]);
let path = json!({"subpaths": [{"knots": [[20, 20], [80, 20]]}, {"knots": [[800, 600], [900, 600]]}]});
let steps: Vec<(&str, serde_json::Value)> = vec![
("layer.select", json!({"layer": base.0})),
("select.rect", json!({"x": 300, "y": 300, "width": 50, "height": 40})),
("edit.fill", json!({"color": "#ff0000"})),
("filter.blur.gaussianBlur", json!({"radius": 2})),
("edit.copy", json!({})),
("select.rect", json!({"x": 600, "y": 400, "width": 80, "height": 60})),
("edit.pasteSpecial.pasteInto", json!({})),
("edit.undo", json!({})),
("edit.redo", json!({})),
("layer.select", json!({"layer": base.0})),
("paint.stroke", json!({"points": [[100, 400], [200, 420]], "size": 20, "color": "#2040ff"})),
("edit.undo", json!({})),
("edit.redo", json!({})),
("path.set", json!({ "path": path })),
("path.stroke", json!({"tool": "pencil", "size": 4, "color": "#0000ff", "opacity": 100})),
("path.stroke", json!({"tool": "burn", "size": 16})),
("layer.select", json!({"layer": small.0})),
("layer.setProps", json!({"layer": small.0, "opacity": 0.5})),
("layer.setProps", json!({"layer": small.0, "visible": false})),
("layer.setProps", json!({"layer": small.0, "visible": true})),
("layer.translate", json!({"layer": small.0, "dx": 20, "dy": 10})),
// Show Only hides the full-canvas base: a large change, so not counted either way.
("layer.showOnly", json!({"layer": small.0})),
("layer.showOnly", json!({"layer": small.0})),
("layer.arrange.bringForward", json!({})),
("layer.arrange.sendBackward", json!({})),
("layer.setProps", json!({"layer": other.0, "clipped": true})),
("layer.new.layer", json!({})),
];
for (cmd, p) in steps {
s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
assert_eq!(s.whole_redraws, 0, "{cmd}: redrew the whole document for a small edit");
}
}
+12 -4
View File
@@ -276,7 +276,7 @@ fn raw_enabled(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
match d.doc.mode {
ColorMode::Rgb | ColorMode::Grayscale => Ok(()),
m => Err(format!("Camera Raw Filter needs an RGB or Grayscale document (this one is {m:?})")),
m => Err(format!("RAW Develop Filter needs an RGB or Grayscale document (this one is {m:?})")),
}
}
@@ -412,12 +412,13 @@ fn camera_raw_cmd(s: &mut Session, p: &Value) -> Result<Value> {
if raw_pixels(area).is_none() {
return Err(bad(
RAW,
format!("the image is too large for Camera Raw ({}×{} px; max {} megapixels)", area.width(), area.height(), MAX_RAW_PIXELS / 1_000_000),
format!("the image is too large for RAW Develop ({}×{} px; max {} megapixels)", area.width(), area.height(), MAX_RAW_PIXELS / 1_000_000),
));
}
}
let t0 = Stopwatch::start();
let id = run_filter(s, RAW, "Camera Raw Filter", p, p.clone(), false, &|surf, canvas| camera_raw_surface(surf, canvas.union(&surf.content_bounds()), &cr))?;
let id =
run_filter(s, RAW, "RAW Develop Filter", p, p.clone(), false, &|surf, canvas| camera_raw_surface(surf, canvas.union(&surf.content_bounds()), &cr))?;
Ok(json!({"layer": id.0, "identity": cr.is_identity(), "ms": t0.ms()}))
}
@@ -469,7 +470,7 @@ pub fn specs() -> Vec<CommandSpec> {
},
CommandSpec {
id: RAW,
label: "Camera Raw Filter…",
label: "RAW Develop Filter…",
menu: &["Filter"],
shortcut: Some("Cmd+Shift+A"),
params: r##"{"temperature":-100..100=0,"tint":-100..100=0,"exposure":-5..5=0,"contrast":-100..100=0,"highlights":-100..100=0,"shadows":-100..100=0,"whites":-100..100=0,"blacks":-100..100=0,"texture":-100..100=0,"clarity":-100..100=0,"dehaze":-100..100=0,"vibrance":-100..100=0,"saturation":-100..100=0,"curveHighlights":-100..100=0,"curveLights":-100..100=0,"curveDarks":-100..100=0,"curveShadows":-100..100=0,"curveSplits":[25,50,75],"pointCurve":[[in,out]],"pointCurveRed":[[in,out]],"pointCurveGreen":[[in,out]],"pointCurveBlue":[[in,out]],"hslHue":[8],"hslSat":[8],"hslLum":[8],"gradeShadows":{"hue":deg,"sat":0..100,"lum":-100..100},"gradeMidtones":{},"gradeHighlights":{},"gradeGlobal":{},"gradeBlending":0..100=50,"gradeBalance":-100..100=0,"shadowTint":-100..100=0,"calibrationHue":[red,green,blue] -100..100,"calibrationSat":[red,green,blue] -100..100 (the Calibration panel: Shadows tint and the primaries' hue and saturation),"sharpenAmount":0..150=0,"sharpenRadius":0.5..3=1,"sharpenDetail":0..100=25,"sharpenMasking":0..100=0,"noiseLuminance":0..100=0,"noiseLuminanceDetail":0..100=50,"noiseColor":0..100=0,"noiseColorDetail":0..100=50,"grainAmount":0..100=0,"grainSize":0..100=25,"grainRoughness":0..100=50,"vignetteAmount":-100..100=0,"vignetteMidpoint":0..100=50,"vignetteRoundness":-100..100=0,"vignetteFeather":0..100=50,"vignetteHighlights":0..100=0,"vignetteStyle":"highlightPriority|colorPriority|paintOverlay","seed":u32=0}"##,
@@ -492,6 +493,13 @@ pub fn specs() -> Vec<CommandSpec> {
#[cfg(test)]
mod tests {
#[test]
fn raw_develop_filter_keeps_its_automation_id() {
let command = super::specs().into_iter().find(|command| command.id == "filter.cameraRaw").expect("existing RAW command");
assert_eq!(command.label, "RAW Develop Filter…");
}
use super::*;
fn session(depth: u32) -> Session {
+2 -1
View File
@@ -152,7 +152,8 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("all").and_then(Value::as_bool) == Some(true) {
let n = s.active().map_or(0, |d| d.doc.notes.len());
s.edit("Delete All Notes", |d, _| {
// Photoshop's name for the step, with a lower-case "all" (#11791).
s.edit("Delete all Notes", |d, _| {
d.notes.clear();
Ok(())
})?;
+12 -6
View File
@@ -12,7 +12,7 @@
//! editable equivalent for them. Vector masks, paths, notes, slices and guides follow the affine
//! map closest to the homography (exact when the quad is a parallelogram).
use photocraft_algo::transform::{Homography, Interp, convex_quad, quad_size, quad_to_rect, warp_surface_clipped};
use photocraft_algo::transform::{Homography, Interp, convex_quad, quad_free_size, quad_size, quad_to_rect, warp_surface_clipped};
use photocraft_color::PixelFormat;
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Size};
use photocraft_geom::{Affine, Rect};
@@ -60,8 +60,9 @@ fn corners(p: &Value) -> Result<[[f64; 2]; 4]> {
}
/// Output size: `width`/`height` in whole px; one alone keeps the quad's proportions; neither
/// takes the quad's mean side lengths ([`quad_size`]).
fn out_size(p: &Value, q: &[[f64; 2]; 4]) -> Result<(u32, u32)> {
/// takes the rectangle the quad shows in perspective ([`quad_free_size`], `center` being the
/// document's centre).
fn out_size(p: &Value, q: &[[f64; 2]; 4], center: [f64; 2]) -> Result<(u32, u32)> {
let side = |key: &str| -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
@@ -77,7 +78,7 @@ fn out_size(p: &Value, q: &[[f64; 2]; 4]) -> Result<(u32, u32)> {
(Some(w), Some(h)) => (w, h),
(Some(w), None) => (w, w * qh / qw),
(None, Some(h)) => (h * qw / qh, h),
(None, None) => (qw, qh),
(None, None) => quad_free_size(q, center),
};
let px = |v: f64| -> Result<u32> {
let v = v.round().max(1.0);
@@ -216,7 +217,8 @@ fn map_samplers(points: &[[f64; 2]], h: &Homography, (w, ht): (u32, u32)) -> Vec
fn perspective_crop(s: &mut Session, p: &Value) -> Result<Value> {
let q = corners(p)?;
let (w, h) = out_size(p, &q)?;
let center = s.active().map_or([0.0; 2], |d| [f64::from(d.doc.size.width) / 2.0, f64::from(d.doc.size.height) / 2.0]);
let (w, h) = out_size(p, &q, center)?;
let res = resolution(p)?;
let interp = interpolation(p)?;
let d = s.active().ok_or(EngineError::NoDocument)?;
@@ -259,13 +261,17 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Perspective Crop",
menu: &[],
shortcut: None,
params: r##"{"corners":[[x,y],[x,y],[x,y],[x,y]] (top-left, top-right, bottom-right, bottom-left; a convex quad, document px),"width":px,"height":px,"resolution":ppi,"interpolation":"bicubic|bilinear|nearest"="bicubic"} → {width,height,resolution,corners} (the quad is mapped onto an upright width x height canvas, all layers, one undo step; no width/height: the quad's mean side lengths, one alone keeps its proportions; type, shape, fill and smart object layers are rasterized)"##,
params: r##"{"corners":[[x,y],[x,y],[x,y],[x,y]] (top-left, top-right, bottom-right, bottom-left; a convex quad, document px),"width":px,"height":px,"resolution":ppi,"interpolation":"bicubic|bilinear|nearest"="bicubic"} → {width,height,resolution,corners} (the quad is mapped onto an upright width x height canvas, all layers, one undo step; no width/height: the rectangle the quad shows in perspective, one alone keeps its proportions; type, shape, fill and smart object layers are rasterized)"##,
enabled: has_doc,
run: perspective_crop,
journal: true,
}]
}
#[cfg(test)]
#[path = "perspective_crop_size_tests.rs"]
mod perspective_crop_size_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,146 @@
//! #11798: with W and H empty, a Perspective Crop takes the size of the rectangle the quad shows
//! in perspective. The expected sizes are the reference application's measurements from the issue.
use photocraft_algo::transform::{convex_quad, quad_free_size};
use serde_json::{Value, json};
use crate::Session;
const SKEWED: [[f64; 2]; 4] = [[100.0, 50.0], [700.0, 80.0], [760.0, 450.0], [40.0, 420.0]];
const PAST_CANVAS: [[f64; 2]; 4] = [[100.0, 50.0], [872.0, 50.0], [872.0, 550.0], [100.0, 450.0]];
const RECTANGLE: [[f64; 2]; 4] = [[100.0, 50.0], [700.0, 50.0], [700.0, 450.0], [100.0, 450.0]];
const CENTER: [f64; 2] = [400.0, 250.0];
/// The size an 800 x 500 document commits to.
fn commit(corners: [[f64; 2]; 4], options: Value) -> (u32, u32) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 800, "height": 500, "background": "white"})).unwrap();
let mut p = json!({"corners": corners});
p.as_object_mut().unwrap().extend(options.as_object().unwrap().clone());
s.execute("image.perspectiveCrop", p).unwrap();
let size = s.active().unwrap().doc.size;
(size.width, size.height)
}
fn assert_near((w, h): (u32, u32), (want_w, want_h): (u32, u32)) {
assert!(w.abs_diff(want_w) <= 1 && h.abs_diff(want_h) <= 1, "{w} x {h}, want {want_w} x {want_h} (within 1 px)");
}
/// The size comes from the perspective, so it is larger than the skewed quad's edges.
#[test]
fn a_skewed_quad_takes_the_measured_size() {
assert_near(commit(SKEWED, json!({})), (731, 588));
}
/// One pair of parallel sides leaves the perspective undefined: the longest edges are measured.
#[test]
fn a_quad_with_parallel_sides_past_the_canvas_takes_the_measured_size() {
assert_near(commit(PAST_CANVAS, json!({})), (778, 500));
}
#[test]
fn a_rectangular_quad_keeps_its_own_size() {
assert_eq!(commit(RECTANGLE, json!({})), (600, 400));
}
/// A size set on the options bar is not affected, for either kind of quad.
#[test]
fn width_and_height_are_still_exact() {
for corners in [SKEWED, RECTANGLE] {
assert_eq!(commit(corners, json!({"width": 400, "height": 300, "resolution": 144})), (400, 300));
}
}
/// One side alone still follows the proportions of the quad's mean edges.
#[test]
fn one_side_alone_keeps_the_mean_edge_proportions() {
assert_eq!(commit(SKEWED, json!({"width": 661})), (661, 375));
assert_eq!(commit(SKEWED, json!({"height": 375})), (661, 375));
}
/// A click or two off a rectangle must not swing the proportions (the estimate is unstable there).
#[test]
fn a_nearly_rectangular_quad_stays_near_its_edges() {
for e in [0.25, 0.5, 1.0, 2.0] {
for q in [
[[100.0, 50.0], [700.0 + e, 50.0], [700.0, 450.0 + e], [100.0 - e, 450.0]],
[[100.0, 50.0], [700.0 + e, 50.0 + e], [700.0, 450.0 + e], [100.0 - e, 450.0]],
] {
let (w, h) = quad_free_size(&q, CENTER);
assert!((w - 600.0).abs() <= 2.0 * e + 1.0 && (h - 400.0).abs() <= 2.0 * e + 1.0, "offset {e}: {w} x {h}");
}
}
}
fn longest_edges([tl, tr, br, bl]: [[f64; 2]; 4]) -> (f64, f64) {
let len = |a: [f64; 2], b: [f64; 2]| (b[0] - a[0]).hypot(b[1] - a[1]);
(len(tl, tr).max(len(bl, br)), len(tl, bl).max(len(tr, br)))
}
/// One pixel on one corner used to take this quad across the parallel-sides limit, and the output
/// from 602 x 400 to 719 x 289. Both sides of that limit now keep the edges.
#[test]
fn a_pixel_of_click_error_cannot_swing_the_proportions() {
for x in [697.0, 698.0] {
let q = [[100.0, 50.0], [700.0, 50.0], [x, 384.0], [100.0, 450.0]];
assert_eq!(quad_free_size(&q, CENTER), longest_edges(q), "bottom right at x = {x}");
}
}
/// A 600 x 400 rectangle turned by `yaw` and `pitch` degrees, as a camera of focal length `focal`
/// centred on the image sees it.
fn photographed(focal: f64, yaw: f64, pitch: f64) -> [[f64; 2]; 4] {
let ((sy, cy), (sp, cp)) = (yaw.to_radians().sin_cos(), pitch.to_radians().sin_cos());
[[-300.0, -200.0], [300.0, -200.0], [300.0, 200.0], [-300.0, 200.0]].map(|[x, y]: [f64; 2]| {
let (px, py, pz) = (cy * x, sy * sp * x + cp * y, -sy * cp * x + sp * y);
let k = focal / (pz + focal * 1.2);
[CENTER[0] + px * k, CENTER[1] + py * k]
})
}
/// Where the sides converge enough to measure, a known rectangle's proportions come back exactly;
/// where they barely converge, the longest edges are used instead.
#[test]
fn a_photographed_rectangle_keeps_its_proportions() {
for focal in [600.0, 1000.0, 1600.0] {
for (yaw, pitch) in [(20.0, 15.0), (30.0, 10.0), (-25.0, 20.0), (15.0, -30.0), (3.0, 2.0)] {
let (w, h) = quad_free_size(&photographed(focal, yaw, pitch), CENTER);
assert!((w / h - 1.5).abs() < 1e-3, "focal {focal}, {yaw} / {pitch} degrees: {w} x {h}");
}
let barely = photographed(focal, 1.0, 1.0);
assert_eq!(quad_free_size(&barely, CENTER), longest_edges(barely), "focal {focal}");
}
}
/// Parallelograms, trapezoids, wild perspective and huge coordinates give a finite, positive size
/// no more than four times the quad's longest edges.
#[test]
fn awkward_quads_get_a_plausible_size() {
let offsets = [-390.0, -120.0, -3.0, 0.0, 0.004, 3.0, 120.0, 390.0];
let mut checked = 0;
for a in offsets {
for b in offsets {
for c in offsets {
for d in offsets {
for scale in [1.0, 1e-2, 1e4] {
let q = [
[(400.0 + a) * scale, 50.0 * scale],
[(1200.0 + b) * scale, 50.0 * scale],
[(1200.0 + c) * scale, (450.0 + d) * scale],
[(400.0 - c) * scale, (450.0 - d) * scale],
];
if !convex_quad(&q) {
continue;
}
checked += 1;
let (w, h) = quad_free_size(&q, CENTER);
let (edge_w, edge_h) = longest_edges(q);
assert!(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0, "{q:?}: {w} x {h}");
assert!(w <= edge_w * 4.0 + 1e-6 && h <= edge_h * 4.0 + 1e-6, "{q:?}: {w} x {h}");
}
}
}
}
}
assert!(checked > 1000, "only {checked} convex quads");
}
+6 -18
View File
@@ -533,9 +533,8 @@ fn stroke_with_command(s: &mut Session, p: &Value, cmd: &str, lines: &[vector::P
params.remove(k);
}
let journal = s.journal.len();
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
let r = crate::analysis_cmds::compound(s, "Stroke Path", |s| {
// The union of what each subpath's command changed; None when one of them didn't say.
let mut dmg = Some(Rect::EMPTY);
for pl in lines {
let pts = stroke_points(&pl.pts, pl.closed, simulate);
if pts.is_empty() {
@@ -544,25 +543,14 @@ fn stroke_with_command(s: &mut Session, p: &Value, cmd: &str, lines: &[vector::P
let mut call = params.clone();
call.insert("points".into(), Value::Array(pts.iter().map(|q| json!([q.x, q.y, q.pressure])).collect()));
s.execute(cmd, Value::Object(call))?;
let r = s.active().and_then(|d| d.last_damage);
dmg = dmg.zip(r).map(|(a, b)| {
if a.is_empty() {
b
} else if b.is_empty() {
a
} else {
a.union(&b)
}
});
}
Ok(dmg)
Ok(())
});
s.journal.truncate(journal);
let dmg = r?;
if let Some(st) = s.active_mut() {
st.last_damage = dmg;
}
let dmg = dmg.unwrap_or_else(|| s.active().map_or(Rect::EMPTY, |d| d.doc.bounds()));
r?;
// One subpath keeps its command's damage; several are one span, which `Session::dispatch`
// reports. The result says what changed either way.
let dmg = s.active().and_then(|st| crate::layer_multi_cmds::step_damage(&before, &st.doc).or(Some(st.doc.bounds()))).unwrap_or(Rect::EMPTY);
Ok(json!({ "damage": [dmg.x0, dmg.y0, dmg.width(), dmg.height()] }))
}
+48 -23
View File
@@ -59,14 +59,20 @@ pub fn warp_bounds(doc: &Document, layer: &Layer) -> Rect {
crate::transform_cmds::transform_bounds(doc, layer)
}
/// A smart object's stored warp expressed in document space (for editing), if it has one.
/// A smart object's stored warp expressed in document space (for editing), if it has one. A
/// cylinder under a placement that only scales and moves stays a cylinder, with its seven values
/// in document space, so Warp mode shows its own handles; under any other placement it is its
/// mesh, a custom warp, like every other stored warp.
pub fn smart_warp_doc_space(layer: &Layer) -> Option<Warp> {
let LayerContent::Smart(sm) = &layer.content else { return None };
let w = sm.warp.as_ref()?;
let t = crate::smart_cmds::placement(sm);
let mesh = w.to_mesh(1, 1).map_points(|p| hom_apply(&t, p));
let corners = [[w.bounds[0], w.bounds[1]], [w.bounds[2], w.bounds[1]], [w.bounds[2], w.bounds[3]], [w.bounds[0], w.bounds[3]]].map(|p| hom_apply(&t, p));
let b = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]);
if let Some(c) = cylinder_through(w, &t, b) {
return Some(c);
}
let mesh = w.to_mesh(1, 1).map_points(|p| hom_apply(&t, p));
Some(Warp::custom(mesh, b))
}
@@ -128,41 +134,60 @@ const CYLINDER_VALUES: &str =
"a cylinder needs `values`: seven numbers [left, bottom, right, top, top depth, bottom depth, wrap 0..1], the right silhouette right of the left one";
const CYLINDER_SHAPE: &str = "a cylinder is horizontal, with no hDistort or vDistort";
/// A document-space cylinder `w` stored on a smart object, whose placement's inverse is `inv` and
/// whose warp box `w.bounds` lands on `sb` in source space. A placement that only scales and
/// moves keeps it a cylinder (written to PSD as one): the silhouettes and edges map, and the
/// ellipses' depth, in units of the half axis, follows the change of aspect. Otherwise (rotated,
/// skewed, flipped, in perspective) it becomes its mesh, a custom warp.
fn cylinder_in_source(w: &Warp, inv: &Homography, sb: [f64; 4]) -> Option<Warp> {
let m = inv.0;
/// The cylinder `w` carried through `h` over the box `b` (its warp box's image), when `h` only
/// scales and moves: the silhouettes and edges map, and the ellipses' depth, in units of the half
/// axis, follows the change of aspect. `None` for any other map (rotated, skewed, flipped, in
/// perspective), or when `w` is not a cylinder: the mesh is the only way to carry it then.
fn cylinder_through(w: &Warp, h: &Homography, b: [f64; 4]) -> Option<Warp> {
let m = h.0;
let axis_aligned = m[8] != 0.0 && m[1] == 0.0 && m[3] == 0.0 && m[6] == 0.0 && m[7] == 0.0;
let (sx, sy) = (m[0] / m[8], m[4] / m[8]);
if axis_aligned && sx > 0.0 && sy > 0.0 && w.values.len() == 7 {
let v = &w.values;
let (x, y) = (|v: f64| sx * v + m[2] / m[8], |v: f64| sy * v + m[5] / m[8]);
let k = sy / sx;
if let Some(c) = Warp::cylinder(&[x(v[0]), y(v[1]), x(v[2]), y(v[3]), v[4] * k, v[5] * k, v[6]], sb) {
return Some(c);
}
if w.style != WarpStyle::Cylinder || !axis_aligned || !(sx > 0.0 && sy > 0.0) {
return None;
}
let v: [f64; 7] = w.values.as_slice().try_into().ok()?;
let (x, y) = (|v: f64| sx * v + m[2] / m[8], |v: f64| sy * v + m[5] / m[8]);
let k = sy / sx;
Warp::cylinder(&[x(v[0]), y(v[1]), x(v[2]), y(v[3]), v[4] * k, v[5] * k, v[6]], b)
}
/// A document-space cylinder `w` stored on a smart object, whose placement's inverse is `inv` and
/// whose warp box `w.bounds` lands on `sb` in source space. A placement that only scales and
/// moves keeps it a cylinder (written to PSD as one, [`cylinder_through`]). Otherwise (rotated,
/// skewed, flipped, in perspective) it becomes its mesh, a custom warp.
fn cylinder_in_source(w: &Warp, inv: &Homography, sb: [f64; 4]) -> Option<Warp> {
if let Some(c) = cylinder_through(w, inv, sb) {
return Some(c);
}
let mesh = w.to_mesh(1, 1).map_points(|p| hom_apply(inv, p));
Some(Warp::custom(mesh, sb))
}
/// Whether the custom warp `w` is exactly what Warp mode shows for `layer`'s stored cylinder
/// ([`smart_warp_doc_space`]: its mesh over the same box), so committing it untouched changes
/// nothing. Stored as a custom warp instead, the cylinder would be lost, as a PSD keeps it only
/// as `warpCylinder` (#4112).
/// Whether `w` is exactly what Warp mode shows for `layer`'s stored cylinder
/// ([`smart_warp_doc_space`]): the same cylinder over the same box, or its mesh (what a rotated
/// placement shows, and what Warp mode showed before it had a cylinder editor), so committing it
/// untouched changes nothing. Stored as a custom warp instead, the cylinder would be lost, as a
/// PSD keeps it only as `warpCylinder` (#4112).
fn is_stored_cylinder(layer: &Layer, w: &Warp) -> bool {
let LayerContent::Smart(sm) = &layer.content else { return false };
if w.style != WarpStyle::Custom || sm.warp.as_ref().is_none_or(|s| s.style != WarpStyle::Cylinder) {
if sm.warp.as_ref().is_none_or(|s| s.style != WarpStyle::Cylinder) {
return false;
}
let (Some(shown), Some(m)) = (smart_warp_doc_space(layer), w.mesh.as_ref()) else { return false };
let Some(shown) = smart_warp_doc_space(layer) else { return false };
let Some(o) = shown.mesh.as_ref() else { return false };
let tol = 1e-6 * (1.0 + o.points.iter().flatten().chain(&shown.bounds).fold(0.0f64, |a, v| a.max(v.abs())));
let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol);
o.us == m.us && o.vs == m.vs && near(&shown.bounds, &w.bounds) && near(o.points.as_flattened(), m.points.as_flattened())
// A cylinder's depths and wrap are dimensionless: at a large document offset the coordinate
// tolerance would pass off a change of wrap as none.
let near_values =
|a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).enumerate().all(|(i, (x, y))| (x - y).abs() <= if i < 4 { tol } else { 1e-9 });
match (w.style, w.mesh.as_ref()) {
(WarpStyle::Cylinder, _) => shown.style == WarpStyle::Cylinder && near(&shown.bounds, &w.bounds) && near_values(&shown.values, &w.values),
(WarpStyle::Custom, Some(m)) => {
o.us == m.us && o.vs == m.vs && near(&shown.bounds, &w.bounds) && near(o.points.as_flattened(), m.points.as_flattened())
}
_ => false,
}
}
/// A Background is turned into a normal layer by the caller first
+57 -14
View File
@@ -166,9 +166,11 @@ fn smart_object_cylinder_is_stored_in_source_space() {
assert_eq!(w.mesh.as_ref().map(|m| (m.nx(), m.ny())), Some((13, 4)));
}
/// Warp mode shows a stored cylinder as its mesh, and Enter commits that mesh untouched: the
/// smart object keeps its cylinder (else the next PSD save writes no warp), while a moved
/// point still makes it a custom warp.
/// Warp mode shows a stored cylinder as a cylinder in document space (its handles edit the
/// seven values), and Enter commits it untouched: the smart object keeps its cylinder (else the
/// next PSD save writes no warp). The same holds for its mesh, which is what a rotated placement
/// shows and what Warp mode showed before it had a cylinder editor. A changed value stays a
/// cylinder; a moved mesh point makes it a custom warp.
#[test]
fn untouched_cylinder_committed_from_warp_mode_stays_a_cylinder() {
for scale in [100, 150] {
@@ -188,30 +190,71 @@ fn untouched_cylinder_committed_from_warp_mode_stays_a_cylinder() {
let cylinder = stored(&s);
assert_eq!(cylinder.style, WarpStyle::Cylinder, "{scale}");
let shown = smart_warp_doc_space(s.active().unwrap().doc.layer(id).unwrap()).unwrap();
assert_eq!(shown.style, WarpStyle::Custom);
s.execute("select.all", json!({})).unwrap();
let (sel, steps) = (s.active().unwrap().doc.selection.clone(), s.active().unwrap().history.past_len());
let r = s.execute("edit.transform.warp", json!({"layer": id.0, "rect": shown.bounds, "warp": shown, "interpolation": "bicubic"})).unwrap();
assert_eq!(r["changed"], false, "{scale}");
assert_eq!(stored(&s), cylinder, "{scale}: untouched");
let st = s.active().unwrap();
assert!(st.doc.selection == sel && st.history.past_len() == steps, "{scale}: no step, the selection stays");
assert_eq!(shown.style, WarpStyle::Cylinder, "{scale}: shown as a cylinder");
for (v, sv) in values.iter().zip(&shown.values) {
assert!((v - sv).abs() < 1e-6, "{scale}: the values come back in document space: {values:?} vs {:?}", shown.values);
}
let as_mesh = Warp::custom(shown.to_mesh(1, 1), shown.bounds);
for (what, untouched) in [("cylinder", shown.clone()), ("mesh", as_mesh.clone())] {
s.execute("select.all", json!({})).unwrap();
let (sel, steps) = (s.active().unwrap().doc.selection.clone(), s.active().unwrap().history.past_len());
let r = s.execute("edit.transform.warp", json!({"layer": id.0, "rect": untouched.bounds, "warp": untouched, "interpolation": "bicubic"})).unwrap();
assert_eq!(r["changed"], false, "{scale} {what}");
assert_eq!(stored(&s), cylinder, "{scale} {what}: untouched");
let st = s.active().unwrap();
assert!(st.doc.selection == sel && st.history.past_len() == steps, "{scale} {what}: no step, the selection stays");
}
// The same mesh over another box is another warp.
let mut wider = shown.clone();
let mut wider = as_mesh.clone();
wider.bounds[2] += 10.0;
s.execute("edit.transform.warp", json!({"layer": id.0, "rect": wider.bounds, "warp": wider})).unwrap();
assert_eq!(stored(&s).style, WarpStyle::Custom, "{scale}: other box");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(stored(&s), cylinder);
let mut moved = shown.clone();
let mut moved = as_mesh.clone();
if let Some(m) = moved.mesh.as_mut() {
m.points[20][1] += 5.0;
}
s.execute("edit.transform.warp", json!({"layer": id.0, "rect": moved.bounds, "warp": moved})).unwrap();
assert_eq!(stored(&s).style, WarpStyle::Custom, "{scale}: edited");
assert_eq!(stored(&s).style, WarpStyle::Custom, "{scale}: edited mesh");
s.execute("edit.undo", json!({})).unwrap();
// A value changed by a handle commits as a cylinder with that value, one step.
let steps = s.active().unwrap().history.past_len();
let mut deeper = shown.values.clone();
deeper[4] = 0.45;
let r = s.execute("edit.transform.warp", json!({"layer": id.0, "rect": shown.bounds, "warp": Warp::cylinder(&deeper, shown.bounds).unwrap()})).unwrap();
assert_eq!(r["changed"], true, "{scale}: edited cylinder");
let after = stored(&s);
assert_eq!(after.style, WarpStyle::Cylinder, "{scale}: still a cylinder");
assert!((after.values[4] - 0.45 * cylinder.values[4] / 0.3).abs() < 1e-9, "{scale}: the depth follows, in source units: {:?}", after.values);
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{scale}: one step");
}
}
/// Under a rotated placement a stored cylinder has no document-space values: Warp mode shows its
/// mesh, and committing that mesh untouched still changes nothing.
#[test]
fn a_rotated_cylinder_is_shown_as_its_mesh() {
let mut s = session(8);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
s.execute("layer.smartObjects.warp", json!({"style": "cylinder", "values": CYL, "rect": [10, 10, 60, 40]})).unwrap();
s.execute("edit.transform", json!({"rotate": 30})).unwrap();
let stored = |s: &Session| {
let st = s.active().unwrap();
let LayerContent::Smart(sm) = &st.doc.layer(id).unwrap().content else { panic!("smart") };
sm.warp.clone().unwrap()
};
assert_eq!(stored(&s).style, WarpStyle::Cylinder, "the placement turned, the source warp stays");
let shown = smart_warp_doc_space(s.active().unwrap().doc.layer(id).unwrap()).unwrap();
assert_eq!(shown.style, WarpStyle::Custom);
assert!(shown.values.is_empty());
let steps = s.active().unwrap().history.past_len();
let r = s.execute("edit.transform.warp", json!({"layer": id.0, "rect": shown.bounds, "warp": shown})).unwrap();
assert_eq!(r["changed"], false);
assert_eq!(s.active().unwrap().history.past_len(), steps);
}
#[test]
fn identity_and_bend_zero_are_no_ops_at_every_depth() {
for depth in [8u64, 16, 32] {
+1 -1
View File
@@ -123,7 +123,7 @@ pub(crate) fn refusal(s: &Session, id: &str, p: &Value) -> Option<String> {
return Some("Move and transforms on several colour channels are not implemented yet".into());
}
if id == "filter.cameraRaw" {
return Some("Camera Raw requires the composite or one colour channel".into());
return Some("RAW Develop requires the composite or one colour channel".into());
}
None
}
+16 -1
View File
@@ -24,8 +24,13 @@ pub struct DocState {
pub pcraft_cache: photocraft_io::PcraftExportCache,
/// How much of `revision` came from view-only changes (channel eyes, targeting): pixel caches can ignore those.
pub view_revision: u64,
/// Area changed by the latest revision (None = assume everything changed).
/// Area changed since revision [`DocState::damage_from`] (None = assume everything changed):
/// the latest revision's, or a whole command's when it made several.
pub last_damage: Option<photocraft_geom::Rect>,
/// `(from, to)`: the command that moved `revision` from `from` to `to` in more than one step,
/// whose whole span `last_damage` covers. Written only by `Session::dispatch`; any later
/// revision makes it stale, so [`DocState::damage_from`] falls back to one revision.
pub(crate) damage_span: Option<(u64, u64)>,
/// Coalescing key of the latest history step (see [`Session::execute`]'s `coalesce` param).
pub coalesce: Option<String>,
/// Channels panel: targeted channel and eye toggles (view state, not history).
@@ -108,6 +113,7 @@ impl DocState {
pcraft_cache: Default::default(),
view_revision: 0,
last_damage: None,
damage_span: None,
coalesce: None,
channel_view,
isolated_layers: Vec::new(),
@@ -144,6 +150,15 @@ impl DocState {
pub(crate) fn keeps_clean(&self, before: &Document) -> bool {
self.saved_revision == self.revision && layer_multi_cmds::selection_only_change(before, &self.doc)
}
/// The revision [`DocState::last_damage`] is measured from: the revision before the command
/// that made it, or the previous revision. A canvas that showed any revision from this one
/// up to (not including) `revision` needs only `last_damage` redrawn.
pub fn damage_from(&self) -> u64 {
match self.damage_span {
Some((from, to)) if to == self.revision => from,
_ => self.revision.saturating_sub(1),
}
}
/// Move to the next revision; `keep_clean` (from [`Self::keeps_clean`]) moves the saved
/// revision with it.
pub(crate) fn bump_revision(&mut self, keep_clean: bool) {
+2
View File
@@ -84,6 +84,7 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
return Some(fp);
}
Some(match id {
"filter.blur.average" => FilterParams::Average,
"filter.blur.gaussianBlur" => FilterParams::GaussianBlur { radius: f(p, "radius", 1.0).clamp(0.1, 1000.0) },
"filter.blur.boxBlur" => FilterParams::BoxBlur { radius: f(p, "radius", 1.0).clamp(1.0, 2000.0) },
"filter.blur.motionBlur" => FilterParams::MotionBlur { angle: f(p, "angle", 0.0), distance: f(p, "distance", 10.0).clamp(1.0, 2000.0) },
@@ -471,6 +472,7 @@ macro_rules! filter_cmd {
pub fn specs() -> Vec<CommandSpec> {
vec![
filter_cmd!("filter.blur.gaussianBlur", "Gaussian Blur…", ["Filter", "Blur"], r##"{"radius":0.1..1000=1}"##),
filter_cmd!("filter.blur.average", "Average", ["Filter", "Blur"], "{}"),
filter_cmd!("filter.blur.blur", "Blur", ["Filter", "Blur"], "{}"),
filter_cmd!("filter.blur.blurMore", "Blur More", ["Filter", "Blur"], "{}"),
filter_cmd!("filter.sharpen.sharpen", "Sharpen", ["Filter", "Sharpen"], "{}"),
+2 -1
View File
@@ -94,7 +94,8 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> {
("vertical", Number(f64::MIN, f64::MAX)),
("undefinedAreas", Choice("wrap|repeat|transparent")),
],
"filter.blur.blur"
"filter.blur.average"
| "filter.blur.blur"
| "filter.blur.blurMore"
| "filter.sharpen.sharpen"
| "filter.sharpen.sharpenMore"
+30 -2
View File
@@ -1790,7 +1790,7 @@ impl Session {
// action it plays (a script step) must not flush the steps it ran so far into the action
// being recorded, nor move the recording mark past the entries it is about to replace.
let recording = if spec.journal && crate::actions_cmds::replayable(id) { self.actions.recording.take() } else { None };
let before = self.active().map(|st| st.doc.clone());
let before = self.active().map(|st| (st.doc.clone(), st.revision));
let docs_before: Vec<_> = self.docs.iter().map(|st| st.doc.id).collect();
// Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an
// error instead of taking the app down. `edit` only commits a document after its closure
@@ -1811,6 +1811,11 @@ impl Session {
self.hint_before = None;
}
let pending = self.jobs.pending.take();
if pending.is_none()
&& let Some((doc, revision)) = &before
{
self.note_command_damage(id, doc, *revision);
}
// Shape Blur by an installed shape's id or name records the geometry it ran with, as the
// filter's own prepared params carry it: the journal (Last Filter, recorded actions)
// replays where that shape is not installed. The run resolved it already, so it can't fail.
@@ -1837,7 +1842,7 @@ impl Session {
Ok(Started::Job(jid))
}
(Ok(v), None) => {
if let Some(before) = &before {
if let Some((before, _)) = &before {
self.add_relayout_damage(before);
}
if let Some((doc_id, layer_id)) = new_fx_target
@@ -1866,6 +1871,29 @@ impl Session {
out
}
/// The one place a command's redraw area spans more than one revision. A command that moved
/// the active document's revision more than once (Paste Into, Stroke Path, a compound, a
/// script) reports what changed since it began, [`layer_multi_cmds::step_damage`] of the
/// document before and after, measured from the revision before it. A single-revision command
/// keeps the damage it reported. Debug and test builds also count a whole-document redraw of
/// a small change ([`Session::whole_redraws`]).
pub(crate) fn note_command_damage(&mut self, id: &str, before: &Arc<Document>, from: u64) {
let Some(st) = self.active_mut().filter(|st| st.doc.id == before.id && st.revision > from) else { return };
if st.revision - from > 1 {
st.last_damage = crate::layer_multi_cmds::step_damage(before, &st.doc);
st.damage_span = Some((from, st.revision));
}
#[cfg(any(test, debug_assertions))]
if st.last_damage.is_none()
&& let Some(pct) = crate::layer_multi_cmds::small_change_pct(before, &st.doc)
{
eprintln!("warning: {id}: redraws the whole document but changed {pct}% of it");
self.whole_redraws += 1;
}
#[cfg(not(any(test, debug_assertions)))]
let _ = id;
}
/// Grows the damage a command reported for the pixels it changed by what effects laid out
/// over a layer's frame repaint when it moved ([`photocraft_compose::relayout_damage`]): a
/// stroke on a layer with a Gradient Overlay reports the stroke, but the gradient is laid out
+3
View File
@@ -338,6 +338,9 @@ pub struct Session {
/// command reporting too narrow a redraw area (#4053). Counted in debug and test builds only;
/// tests read it.
pub damage_misses: usize,
/// Commands that left the whole document to redraw (`last_damage` None) while changing under
/// 25 % of it ([`Session::dispatch`]). Counted in debug and test builds only; tests read it.
pub whole_redraws: usize,
}
#[cfg(test)]
+1 -1
View File
@@ -180,7 +180,7 @@ pub fn specs() -> Vec<CommandSpec> {
section!("edit.preferences.plugIns", "Plugins…"),
section!("edit.preferences.type", "Type…"),
section!("edit.preferences.enhancedControls", "Enhanced Controls…"),
section!("edit.preferences.rawDefaults", "Camera Raw…"),
section!("edit.preferences.rawDefaults", "RAW Defaults…"),
section!("edit.preferences.integrations", "Integrations…"),
spec!(
"edit.keyboardShortcuts",
+1 -1
View File
@@ -24,7 +24,7 @@ pub const SECTIONS: [(&str, &str); 18] = [
("plugIns", "Plugins"),
("type", "Type"),
("enhancedControls", "Enhanced Controls"),
("rawDefaults", "Camera Raw Defaults"),
("rawDefaults", "RAW Defaults"),
("integrations", "Integrations"),
];
+5 -2
View File
@@ -354,8 +354,10 @@ impl Session {
/// A command's own rect for the pixels its latest [`Session::edit`] changed: sub-tile precise
/// for a brush dab, where the edit's own damage ([`layer_multi_cmds::step_damage`]) has tile
/// precision. The redraw area becomes the intersection of the two, so a hint can only narrow
/// it (or bound a change `step_damage` can't). In debug and test builds, a hint that misses a
/// tile the edit changed is counted in [`Session::damage_misses`] and logged; it never panics.
/// it (or bound a change `step_damage` can't). The hint is first grown as `step_damage` grows
/// a change (by a mask's feather and by effects, `grow_hint`), so the intersection keeps a
/// dab's soft ring and shadow. In debug and test builds, a hint that misses a tile the edit
/// changed is counted in [`Session::damage_misses`] and logged; it never panics.
pub fn hint_damage(&mut self, rect: photocraft_geom::Rect) {
#[cfg(any(test, debug_assertions))]
if let Some(before) = self.hint_before.take()
@@ -370,6 +372,7 @@ impl Session {
}
}
let Some(st) = self.active_mut() else { return };
let rect = layer_multi_cmds::grow_hint(&st.doc, rect);
st.last_damage = Some(st.last_damage.map_or(rect, |d| d.intersect(&rect)));
}
}
+21
View File
@@ -966,6 +966,27 @@ fn command_list_filter_finds_the_marquees_by_keyword() {
assert!(found("no command is called this").is_empty());
}
/// The rename to "RAW Develop" is display only: the filter and RAW Defaults are still found by
/// Photoshop's "Camera Raw" name, which their keywords carry (#12403).
#[test]
fn command_list_filter_finds_the_raw_commands_by_their_photoshop_name() {
let mut s = Session::new();
let list = s.execute("command.list", json!({})).unwrap();
let rows = list.as_array().unwrap();
let found = |needle: &str| -> Vec<String> {
rows.iter().filter(|c| crate::commands::list_entry_matches(c, needle)).map(|c| c["id"].as_str().unwrap().to_string()).collect()
};
for needle in ["camera raw", "Camera Raw", "CAMERA RAW"] {
let ids = found(needle);
assert!(ids.contains(&"filter.cameraRaw".to_string()), "{needle}: {ids:?}");
assert!(ids.contains(&"edit.preferences.rawDefaults".to_string()), "{needle}: {ids:?}");
}
// What the menus show is the new name, and the keywords are not a label.
let label = |id: &str| rows.iter().find(|c| c["id"] == id).unwrap()["label"].as_str().unwrap().to_string();
assert_eq!(label("filter.cameraRaw"), "RAW Develop Filter…");
assert_eq!(label("edit.preferences.rawDefaults"), "RAW Defaults…");
}
/// Every keyword entry names a real command, so a renamed id can't leave one behind.
#[test]
fn search_keywords_name_real_commands() {
+124
View File
@@ -0,0 +1,124 @@
//! Exercise Average through the smart-filter renderer, not an imported cache.
use photocraft_doc::LayerContent;
use photocraft_engine::{Session, filters, smart_cmds};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::json;
fn image(s: &Session) -> Surface {
let d = s.active().unwrap();
let l = d.doc.layer(d.active_layer.unwrap()).unwrap();
match &l.content {
LayerContent::Raster(r) => r.clone(),
LayerContent::Smart(sm) => sm.cache.clone().unwrap(),
_ => panic!("expected pixels"),
}
}
fn input(depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let d = s.active_mut().unwrap();
let l = std::sync::Arc::make_mut(&mut d.doc).layer_mut(d.active_layer.unwrap()).unwrap();
let surf = l.surface_mut().unwrap();
for y in 0..16 {
for x in 0..16 {
surf.write_region(Rect::new(x, y, x + 1, y + 1), if x < 8 { &[1.0, 0.0, 0.0, 1.0] } else { &[0.0, 0.0, 1.0, 1.0] });
}
}
s
}
fn purple(surf: &Surface) {
for p in surf.read_region(Rect::new(0, 0, 16, 16)).as_chunks::<4>().0 {
assert!((p[0] - 0.5).abs() <= 1.0 / 255.0, "{p:?}");
assert_eq!(p[1], 0.0);
assert!((p[2] - 0.5).abs() <= 1.0 / 255.0, "{p:?}");
assert_eq!(p[3], 1.0);
}
}
#[test]
fn average_stored_filter_executes() {
for depth in [8, 16, 32] {
let s = input(depth);
let bounds = s.active().unwrap().doc.bounds();
let out = filters::apply_filter_to_surface("filter.blur.average", &json!({}), &image(&s), bounds, None, bounds)
.unwrap()
.expect("Average must be executable, not treated as an unknown filter");
purple(&out);
}
}
#[test]
fn average_smart_command_rerenders_and_undoes() {
for depth in [8, 16, 32] {
let mut s = input(depth);
s.execute("filter.convertForSmartFilters", json!({})).unwrap();
let before = image(&s);
let steps = s.active().unwrap().history.past_len();
assert!(s.is_enabled("filter.blur.average"));
s.execute("filter.blur.average", json!({})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
purple(&image(&s));
assert!(s.undo());
assert_eq!(image(&s), before);
assert!(s.redo());
purple(&image(&s));
s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": false})).unwrap();
assert_eq!(image(&s), before);
s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": true})).unwrap();
let d = s.active_mut().unwrap();
let snapshot = d.doc.clone();
let layer = std::sync::Arc::make_mut(&mut d.doc).layer_mut(d.active_layer.unwrap()).unwrap();
if let LayerContent::Smart(sm) = &mut layer.content {
assert_eq!(sm.smart_filters.len(), 1);
assert_eq!(sm.smart_filters[0].command, "filter.blur.average");
sm.cache = None;
}
smart_cmds::purge_stack_cache();
assert!(smart_cmds::refresh_layer(&snapshot, layer).unwrap());
purple(&image(&s));
}
}
#[test]
fn average_rejects_bad_params_without_an_edit() {
let mut s = input(8);
let before = image(&s);
let steps = s.active().unwrap().history.past_len();
for params in [json!("bad"), json!({"unknown": 1}), json!({"layer": -1}), json!({"target": false})] {
assert!(s.execute("filter.blur.average", params).is_err());
assert_eq!(image(&s), before);
assert_eq!(s.active().unwrap().history.past_len(), steps);
}
}
/// Optional local oracle: generate a 16×16 red/blue Smart Object in Photoshop, apply Average,
/// save as PSD, then set PHOTOCRAFT_AVERAGE_PSD to that file. Never accept cached pixels as proof.
#[test]
#[ignore = "requires a locally generated Photoshop Average PSD; see docs/average-filter.md"]
fn average_photoshop_psd_rerenders_from_source() {
let path = std::env::var("PHOTOCRAFT_AVERAGE_PSD").expect("set PHOTOCRAFT_AVERAGE_PSD");
let bytes = std::fs::read(&path).unwrap();
let mut doc = photocraft_io::import(&path, &bytes).unwrap().document;
let snapshot = doc.clone();
let ids: Vec<_> = snapshot
.walk()
.into_iter()
.filter_map(|(_, _, l)| match &l.content {
LayerContent::Smart(sm) if sm.smart_filters.iter().any(|f| f.command == "filter.blur.average") => Some(l.id),
_ => None,
})
.collect();
assert_eq!(ids.len(), 1);
let layer = doc.layer_mut(ids[0]).unwrap();
if let LayerContent::Smart(sm) = &mut layer.content {
sm.cache = None;
}
smart_cmds::purge_stack_cache();
assert!(smart_cmds::refresh_layer(&snapshot, layer).unwrap());
let LayerContent::Smart(sm) = &layer.content else { panic!("expected smart object") };
purple(sm.cache.as_ref().unwrap());
}
@@ -67,7 +67,8 @@ fn a_gradient_overlay_is_laid_out_again_after_each_stroke() {
fn a_stroke_inside_the_layer_keeps_its_own_damage() {
let mut s = session("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0}));
s.execute("paint.stroke", json!({"points": [[10, 10], [150, 70]], "size": 12, "hardness": 1.0, "color": GREY})).unwrap();
// Within the layer's bounds: the gradient's frame stays, so only the stroke repaints.
// Within the layer's bounds: the gradient's frame stays, so only the stroke repaints (grown
// by the effects' margin, as any change to the layer's pixels is).
let r = s.execute("paint.stroke", json!({"points": [[60, 40], [70, 40]], "size": 6, "hardness": 1.0, "color": GREY})).unwrap();
let d = &r["damage"];
let reported = photocraft_geom::Rect::new(
@@ -76,7 +77,9 @@ fn a_stroke_inside_the_layer_keeps_its_own_damage() {
(d[0].as_i64().unwrap() + d[2].as_i64().unwrap()) as i32,
(d[1].as_i64().unwrap() + d[3].as_i64().unwrap()) as i32,
);
assert_eq!(s.active().unwrap().last_damage, Some(reported));
let st = s.active().unwrap();
let margin = photocraft_compose::effects::margin(st.doc.layer(st.active_layer.unwrap()).unwrap());
assert_eq!(st.last_damage, Some(reported.inflate(margin)));
}
#[test]
+1
View File
@@ -28,6 +28,7 @@ mod atn_stroke_width;
mod atn_type_path;
mod atn_v12;
mod atn_video_frames;
mod average_smart;
mod background_and_mask_depth;
mod background_layer_consistency;
mod brush_smoothing;
+3 -2
View File
@@ -67,8 +67,9 @@ impl WarpStyle {
WarpStyle::Twist,
];
/// The styles the Warp menus list: none, custom and the presets. [`WarpStyle::Cylinder`] is
/// read from PSDs and taken by commands, but has no editor yet.
/// The styles every Warp menu lists: none, custom and the presets. [`WarpStyle::Cylinder`] is
/// not one of them: Transform Warp's menu adds it after these (it has its own editor there),
/// while Warp Text has no cylinder.
pub fn all() -> impl Iterator<Item = WarpStyle> {
[WarpStyle::None, WarpStyle::Custom].into_iter().chain(Self::PRESETS)
}
+5 -5
View File
@@ -75,7 +75,7 @@ impl CameraRawDevelop {
pub fn new(device: &wgpu::Device) -> Result<Self, Unsupported> {
match crate::first_error(device, || Self::build(device)) {
(dev, None) => Ok(dev),
(_, Some(e)) => Err(Unsupported(format!("couldn't build the Camera Raw pipeline: {e}"))),
(_, Some(e)) => Err(Unsupported(format!("couldn't build the RAW Develop pipeline: {e}"))),
}
}
@@ -246,7 +246,7 @@ impl CameraRawDevelop {
Some(e) => {
// A half-done upload must not be taken for the cached source next time.
self.source = None;
Err(Unsupported(format!("GPU Camera Raw develop failed: {e}")))
Err(Unsupported(format!("GPU RAW Develop failed: {e}")))
}
None => result,
}
@@ -270,7 +270,7 @@ impl CameraRawDevelop {
let bytes = u64::from(row) * u64::from(h);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_camera_raw") });
self.encode(device, queue, &mut encoder, source, key, size, stages)?;
let Some(target) = self.target.as_ref().map(|t| &t.texture) else { return Err(Unsupported("no Camera Raw target".into())) };
let Some(target) = self.target.as_ref().map(|t| &t.texture) else { return Err(Unsupported("no RAW Develop target".into())) };
let buffer = match self.readback.take() {
Some(b) if b.size() == bytes => b,
_ => device.create_buffer(&wgpu::BufferDescriptor {
@@ -316,10 +316,10 @@ impl CameraRawDevelop {
fn check_size(device: &wgpu::Device, source: &[[f32; 4]], [w, h]: [u32; 2]) -> Result<(), Unsupported> {
let max = device.limits().max_texture_dimension_2d;
if w == 0 || h == 0 || w > max || h > max {
return Err(Unsupported(format!("a {w}×{h} Camera Raw preview doesn't fit the GPU's {max} px textures")));
return Err(Unsupported(format!("a {w}×{h} RAW Develop preview doesn't fit the GPU's {max} px textures")));
}
if source.len() as u64 != u64::from(w) * u64::from(h) {
return Err(Unsupported(format!("{} Camera Raw preview pixels for {w}×{h}", source.len())));
return Err(Unsupported(format!("{} RAW Develop preview pixels for {w}×{h}", source.len())));
}
Ok(())
}
+1 -1
View File
@@ -128,7 +128,7 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result<
.filter(|(_, v)| *v != 0.0)
.map(|(k, v)| format!(", {k} {v:+.0}"))
.collect::<String>();
let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic{cropped})", tuning.exposure, tuning.demosaic.id());
let summary = format!("developed in RAW Develop (exposure {:+.2} EV{tone}, {wb}, {} demosaic{cropped})", tuning.exposure, tuning.demosaic.id());
Ok((developed_document(name, bytes, format, dev, &summary)?, wb_applied))
}
+1 -1
View File
@@ -18,7 +18,7 @@ photocraft-psd = { workspace = true }
# No `system` feature: fontique's system backend links fontconfig on Linux. We scan font
# directories ourselves (native) and always register the bundled fonts (native + web).
parley = { version = "0.11", default-features = false, features = ["std"] }
skrifa = "0.44"
skrifa = "0.47"
flate2 = { workspace = true }
# ICU dictionary data for Japanese word boundaries and complex-script (Thai, Lao, Khmer, Burmese)
+888
View File
@@ -0,0 +1,888 @@
//! Colour glyphs: COLR v0 layers and v1 paint graphs, coloured from the font's CPAL palette.
//! Emoji and other colour fonts draw in their own colours (#7711); the text colour fills only
//! what the font leaves to it (palette entry 0xFFFF), and its opacity applies to the whole glyph.
//!
//! skrifa walks the paint graph and calls a [`ColorPainter`]; [`Painter`] paints into a window of
//! the text raster in premultiplied sRGB, [`OutlineBounds`] only collects where it would paint.
use photocraft_color::blend::{BlendMode, blend_rgb};
use photocraft_doc::text::AntiAlias;
use skrifa::GlyphId;
use skrifa::color::{Brush, ColorPainter, ColorStop, CompositeMode, Extend, Transform};
use skrifa::raw::types::BoundingBox;
use crate::raster::{Bounds, Coverage, Xform};
use crate::render::shape_antialias_coverage;
/// Palette entry standing for the text colour.
const FOREGROUND: u16 = 0xFFFF;
fn xform(t: &Transform) -> Xform {
Xform([t.xx as f64, t.yx as f64, t.xy as f64, t.yy as f64, t.dx as f64, t.dy as f64])
}
/// What a colour glyph clips to: a glyph outline or a COLR clip box, both in font units through
/// the paint transform. The caller draws either one the same way (bent with the type, for
/// warped text), so a clip box follows the glyph it crops.
#[derive(Clone, Copy)]
pub(crate) enum Shape {
Glyph(GlyphId),
Box(BoundingBox<f32>),
}
/// A painter that ignores everything, to check that a colour glyph's paint graph can be walked
/// before the glyph is drawn in colour ([`crate::render`] draws its outline otherwise).
pub(crate) struct NoPaint;
impl ColorPainter for NoPaint {
fn push_transform(&mut self, _: Transform) {}
fn pop_transform(&mut self) {}
fn push_clip_glyph(&mut self, _: GlyphId) {}
fn push_clip_box(&mut self, _: BoundingBox<f32>) {}
fn pop_clip(&mut self) {}
fn fill(&mut self, _: Brush<'_>) {}
fn push_layer(&mut self, _: CompositeMode) {}
}
/// Collects where a colour glyph paints, for the ink bounds: every fill adds the intersection of
/// the clips around it, so a large clip box around small artwork does not widen the bounds.
/// `outline` draws a [`Shape`] (through the given paint transform) into a [`Bounds`] in sink
/// space. A fill outside every clip is unbounded and adds nothing.
pub(crate) struct OutlineBounds<'a, F: FnMut(Shape, &Xform, &mut Bounds)> {
pub sink: &'a mut Bounds,
pub outline: F,
xforms: Vec<Xform>,
/// The intersection of the clips so far; `None` when it is empty.
clips: Vec<Option<[f64; 4]>>,
}
impl<'a, F: FnMut(Shape, &Xform, &mut Bounds)> OutlineBounds<'a, F> {
pub fn new(sink: &'a mut Bounds, outline: F) -> Self {
Self { sink, outline, xforms: Vec::new(), clips: Vec::new() }
}
fn current(&self) -> Xform {
self.xforms.last().copied().unwrap_or(Xform::IDENTITY)
}
fn push(&mut self, shape: Shape) {
let mut b = Bounds::default();
let m = self.current();
(self.outline)(shape, &m, &mut b);
let r = match (self.clips.last(), b.rect) {
(_, None) => None,
(None, r) => r,
(Some(None), _) => None,
(Some(Some(o)), Some(r)) => {
let i = [o[0].max(r[0]), o[1].max(r[1]), o[2].min(r[2]), o[3].min(r[3])];
(i[0] <= i[2] && i[1] <= i[3]).then_some(i)
}
};
self.clips.push(r);
}
}
impl<F: FnMut(Shape, &Xform, &mut Bounds)> ColorPainter for OutlineBounds<'_, F> {
fn push_transform(&mut self, t: Transform) {
self.xforms.push(self.current().mul(&xform(&t)));
}
fn pop_transform(&mut self) {
self.xforms.pop();
}
fn push_clip_glyph(&mut self, id: GlyphId) {
self.push(Shape::Glyph(id));
}
fn push_clip_box(&mut self, b: BoundingBox<f32>) {
self.push(Shape::Box(b));
}
fn pop_clip(&mut self) {
self.clips.pop();
}
fn fill(&mut self, _: Brush<'_>) {
if let Some(Some([x0, y0, x1, y1])) = self.clips.last().copied() {
self.sink.add((x0, y0));
self.sink.add((x1, y1));
}
}
fn push_layer(&mut self, _: CompositeMode) {}
}
/// A gradient's colour line: premultiplied stops in offset order.
struct ColorLine {
stops: Vec<(f32, [f32; 4])>,
extend: Extend,
}
impl ColorLine {
fn at(&self, t: f32) -> [f32; 4] {
let (Some(first), Some(last)) = (self.stops.first(), self.stops.last()) else { return [0.0; 4] };
// Repeat and Reflect run over the stops' own span, which COLR doesn't pin to 0..1; Pad is
// the first/last stop beyond it.
let (t0, span) = (first.0, last.0 - first.0);
let t = match self.extend {
Extend::Repeat if span > 0.0 => t0 + (t - t0).rem_euclid(span),
Extend::Reflect if span > 0.0 => {
let m = (t - t0).rem_euclid(2.0 * span);
t0 + if m > span { 2.0 * span - m } else { m }
}
_ => t,
};
if t.is_nan() || t < first.0 {
return first.1;
}
if t >= last.0 {
return last.1;
}
// The last stop at or before `t`: where several stops share an offset, the last of them
// holds from that offset on (OpenType COLR, colour lines), so a pixel landing exactly on
// a hard transition takes the colour after it. The next stop then lies strictly after.
let i = self.stops.partition_point(|s| s.0 <= t);
let (Some(&(o0, c0)), Some(&(o1, c1))) = (i.checked_sub(1).and_then(|i| self.stops.get(i)), self.stops.get(i)) else { return last.1 };
let k = if o1 > o0 { (t - o0) / (o1 - o0) } else { 1.0 };
std::array::from_fn(|i| c0[i] + (c1[i] - c0[i]) * k)
}
}
/// What a fill paints, resolved to premultiplied colours.
enum Paint {
Solid([f32; 4]),
Linear { p0: (f32, f32), d: (f32, f32), len2: f32, line: ColorLine },
Radial { c0: (f32, f32), r0: f32, cd: (f32, f32), dr: f32, line: ColorLine },
Sweep { c: (f32, f32), start: f32, end: f32, line: ColorLine },
}
impl Paint {
/// The colour at a point in paint space (font units); `None` where a radial gradient is
/// undefined (the point lies outside every circle of the cone).
fn at(&self, u: f32, v: f32) -> Option<[f32; 4]> {
match self {
Paint::Solid(c) => Some(*c),
Paint::Linear { p0, d, len2, line } => Some(line.at(((u - p0.0) * d.0 + (v - p0.1) * d.1) / len2)),
Paint::Radial { c0, r0, cd, dr, line } => {
// The largest t with |p - c(t)| = r(t) and r(t) >= 0 (two-point conical gradient).
let (px, py) = (u - c0.0, v - c0.1);
let a = cd.0 * cd.0 + cd.1 * cd.1 - dr * dr;
let b = px * cd.0 + py * cd.1 + r0 * dr;
let c = px * px + py * py - r0 * r0;
let ok = |t: f32| t.is_finite() && r0 + t * dr >= 0.0;
if a.abs() < 1e-6 {
if b == 0.0 && c == 0.0 {
// Every circle passes through p (the shared point of touching circles),
// so the largest t wins: where shrinking circles reach radius 0, or the
// end of the colour line when they grow. Equal circles paint nothing.
return match dr.partial_cmp(&0.0) {
Some(std::cmp::Ordering::Less) => Some(line.at(-r0 / dr)),
Some(std::cmp::Ordering::Greater) => line.stops.last().map(|s| s.1),
_ => None,
};
}
let t = c / (2.0 * b);
return ok(t).then(|| line.at(t));
}
let disc = b * b - a * c;
if disc < 0.0 {
return None;
}
let s = disc.sqrt();
let (t1, t2) = ((b + s) / a, (b - s) / a);
let (hi, lo) = if t1 >= t2 { (t1, t2) } else { (t2, t1) };
[hi, lo].into_iter().find(|t| ok(*t)).map(|t| line.at(t))
}
Paint::Sweep { c, start, end, line } => {
// skrifa hands the angles clockwise (360° minus the font's counter-clockwise ones).
let ccw = (v - c.1).atan2(u - c.0).to_degrees();
// skrifa's start and end may lie outside 0..360 (a sweep from 270° to 450°
// crosses the positive x axis): take the turn of the angle that falls inside them.
let mut cw = (360.0 - ccw).rem_euclid(360.0);
let turn = cw + 360.0 * ((start - cw) / 360.0).ceil();
if turn.is_finite() && turn <= *end {
cw = turn;
}
let span = end - start;
let t = if span.abs() > 1e-6 {
(cw - start) / span
} else if cw >= *start {
1.0
} else {
0.0
};
Some(line.at(t))
}
}
}
}
/// Premultiplied Porter-Duff or blend-mode composite of `s` over `b`.
fn composite(mode: CompositeMode, b: [f32; 4], s: [f32; 4]) -> [f32; 4] {
let (ab, as_) = (b[3], s[3]);
let blend = match mode {
CompositeMode::Screen => Some(BlendMode::Screen),
CompositeMode::Overlay => Some(BlendMode::Overlay),
CompositeMode::Darken => Some(BlendMode::Darken),
CompositeMode::Lighten => Some(BlendMode::Lighten),
CompositeMode::ColorDodge => Some(BlendMode::ColorDodge),
CompositeMode::ColorBurn => Some(BlendMode::ColorBurn),
CompositeMode::HardLight => Some(BlendMode::HardLight),
CompositeMode::SoftLight => Some(BlendMode::SoftLight),
CompositeMode::Difference => Some(BlendMode::Difference),
CompositeMode::Exclusion => Some(BlendMode::Exclusion),
CompositeMode::Multiply => Some(BlendMode::Multiply),
CompositeMode::HslHue => Some(BlendMode::Hue),
CompositeMode::HslSaturation => Some(BlendMode::Saturation),
CompositeMode::HslColor => Some(BlendMode::Color),
CompositeMode::HslLuminosity => Some(BlendMode::Luminosity),
_ => None,
};
if let Some(m) = blend {
if ab <= 0.0 || as_ <= 0.0 {
return std::array::from_fn(|i| s[i] + b[i] * (1.0 - as_));
}
let mixed = blend_rgb(m, [b[0] / ab, b[1] / ab, b[2] / ab], [s[0] / as_, s[1] / as_, s[2] / as_]);
let mut out = [0.0; 4];
for i in 0..3 {
out[i] = s[i] * (1.0 - ab) + b[i] * (1.0 - as_) + as_ * ab * mixed[i];
}
out[3] = as_ + ab - as_ * ab;
return out;
}
let (fs, fb) = match mode {
CompositeMode::Clear => (0.0, 0.0),
CompositeMode::Src => (1.0, 0.0),
CompositeMode::Dest => (0.0, 1.0),
CompositeMode::DestOver => (1.0 - ab, 1.0),
CompositeMode::SrcIn => (ab, 0.0),
CompositeMode::DestIn => (0.0, as_),
CompositeMode::SrcOut => (1.0 - ab, 0.0),
CompositeMode::DestOut => (0.0, 1.0 - as_),
CompositeMode::SrcAtop => (ab, 1.0 - as_),
CompositeMode::DestAtop => (1.0 - ab, as_),
CompositeMode::Xor => (1.0 - ab, 1.0 - as_),
CompositeMode::Plus => (1.0, 1.0),
_ => (1.0, 1.0 - as_),
};
std::array::from_fn(|i| (s[i] * fs + b[i] * fb).min(1.0))
}
/// What one render may still spend on colour glyphs, shared by all of them: `bytes` of buffers
/// live at once (the window layers and clip masks), and `work`, pixels touched by fills, clips
/// and composites, against the same cumulative budget as the plain colour passes. A glyph that
/// would overrun either one fails, and the text renders nothing, as with the other budgets.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Limits {
pub bytes: u64,
pub work: u64,
}
/// The colours a glyph paints with.
pub(crate) struct Ink<'a> {
pub palette: &'a [skrifa::color::Color],
/// The text colour, straight sRGB and alpha.
pub foreground: [f32; 4],
}
/// A window-sized layer and how many clips were already pushed when it was opened: clips from
/// outside a layer do not shape what is painted into it, they apply once when it is merged down.
struct Layer {
px: Vec<[f32; 4]>,
clip_base: usize,
}
/// Paints one colour glyph into a `w × h` window, premultiplied sRGB. `outline` adds a
/// [`Shape`]'s coverage (through the given paint transform) to a window-sized [`Coverage`];
/// `to_window` maps font units into the window (gradients).
pub(crate) struct Painter<'a, F: FnMut(Shape, &Xform, &mut Coverage)> {
w: usize,
h: usize,
outline: F,
to_window: Xform,
ink: Ink<'a>,
antialias: AntiAlias,
limits: Limits,
xforms: Vec<Xform>,
clips: Vec<Vec<f32>>,
layers: Vec<Layer>,
/// A buffer could not be reserved or a budget ran out: [`Painter::finish`] returns `None`.
/// Placeholders (empty buffers) keep pushes and pops balanced and paint nothing.
failed: bool,
}
/// `len` copies of `v`, or `None` when its bytes cannot be reserved.
fn filled<T: Clone>(len: usize, v: T) -> Option<Vec<T>> {
let mut out = Vec::new();
out.try_reserve_exact(len).ok()?;
out.resize(len, v);
Some(out)
}
impl<'a, F: FnMut(Shape, &Xform, &mut Coverage)> Painter<'a, F> {
pub fn new(w: usize, h: usize, outline: F, to_window: Xform, ink: Ink<'a>, antialias: AntiAlias, limits: Limits) -> Self {
let mut p = Self { w, h, outline, to_window, ink, antialias, limits, xforms: Vec::new(), clips: Vec::new(), layers: Vec::new(), failed: false };
let base = p.new_layer().unwrap_or_default();
p.layers.push(Layer { px: base, clip_base: 0 });
p
}
/// The pixel work left after this glyph; its buffers' bytes come back once it is done.
pub fn work_left(&self) -> u64 {
self.limits.work
}
/// The painted glyph, premultiplied sRGB, row-major, or `None` when a buffer could not be
/// reserved or a budget ran out.
pub fn finish(mut self) -> Option<Vec<[f32; 4]>> {
if self.failed {
return None;
}
self.layers.truncate(1);
self.layers.pop().map(|l| l.px)
}
fn pixels(&self) -> Option<u64> {
(self.w as u64).checked_mul(self.h as u64)
}
/// Takes `bytes` of buffers and, with `work`, one window's worth of pixel work from the
/// budgets; `false` (and the painter failed) when they don't fit or it failed already.
fn spend(&mut self, bytes: Option<u64>, work: bool) -> bool {
let work = if work { self.pixels() } else { Some(0) };
match (bytes.and_then(|b| self.limits.bytes.checked_sub(b)), work.and_then(|n| self.limits.work.checked_sub(n))) {
(Some(b), Some(w)) if !self.failed => {
self.limits = Limits { bytes: b, work: w };
true
}
_ => {
self.failed = true;
false
}
}
}
/// Returns the bytes of `len` elements of `each` bytes to the budget.
fn give_back(&mut self, len: usize, each: usize) {
let b = (len as u64).saturating_mul(each as u64);
self.limits.bytes = self.limits.bytes.saturating_add(b);
}
/// A transparent window-sized layer, its bytes taken from the budget.
fn new_layer(&mut self) -> Option<Vec<[f32; 4]>> {
let bytes = self.pixels().and_then(|n| n.checked_mul(size_of::<[f32; 4]>() as u64));
if !self.spend(bytes, false) {
return None;
}
let v = self.w.checked_mul(self.h).and_then(|n| filled(n, [0.0; 4]));
self.failed |= v.is_none();
v
}
fn current(&self) -> Xform {
self.xforms.last().copied().unwrap_or(Xform::IDENTITY)
}
/// The innermost clip pushed inside the current layer, if any.
fn layer_clip(&self) -> Option<&Vec<f32>> {
let base = self.layers.last().map_or(0, |l| l.clip_base);
if self.clips.len() > base { self.clips.last() } else { None }
}
/// Rasterises `shape` into a clip mask, intersected with the clip around it in this layer:
/// the smaller coverage per pixel, so a glyph clipped by its own outline again (or by a box
/// around it) keeps its anti-aliased edge instead of squaring it.
fn push_shape(&mut self, shape: Shape) {
// A mask is kept at one f32 per pixel; while it is built, the coverage accumulator and
// the finished vector are live side by side.
let mask = self.pixels().and_then(|n| n.checked_mul(size_of::<f32>() as u64));
let cov = if self.spend(mask.and_then(|m| m.checked_mul(2)), true) { Coverage::try_new(self.w, self.h) } else { None };
let Some(mut cov) = cov else {
self.failed = true;
self.clips.push(Vec::new());
return;
};
let m = self.current();
(self.outline)(shape, &m, &mut cov);
let c = cov.finish();
// The accumulator is gone again: its half of the reservation comes back.
self.give_back(self.w.saturating_mul(self.h), size_of::<f32>());
let Some(mut c) = c else {
self.failed = true;
self.clips.push(Vec::new());
return;
};
let mode = self.antialias;
c.iter_mut().for_each(|v| *v = shape_antialias_coverage(*v, mode));
if let Some(outer) = self.layer_clip() {
c.iter_mut().zip(outer).for_each(|(v, o)| *v = v.min(*o));
}
self.clips.push(c);
}
/// A palette entry (or the text colour), premultiplied, times `alpha`.
fn color(&self, index: u16, alpha: f32) -> [f32; 4] {
let [r, g, b, a] = if index == FOREGROUND {
self.ink.foreground
} else {
match self.ink.palette.get(usize::from(index)) {
Some(c) => [c.red, c.green, c.blue, c.alpha].map(|v| f32::from(v) / 255.0),
None => return [0.0; 4],
}
};
let a = (a * alpha).clamp(0.0, 1.0);
[r * a, g * a, b * a, a]
}
fn line(&self, stops: &[ColorStop], extend: Extend) -> ColorLine {
let mut stops: Vec<(f32, [f32; 4])> = stops.iter().filter(|s| s.offset.is_finite()).map(|s| (s.offset, self.color(s.palette_index, s.alpha))).collect();
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
ColorLine { stops, extend }
}
fn paint(&self, brush: Brush<'_>) -> Option<Paint> {
let pt = |p: skrifa::raw::types::Point<f32>| (p.x, p.y);
Some(match brush {
Brush::Solid { palette_index, alpha } => Paint::Solid(self.color(palette_index, alpha)),
Brush::LinearGradient { p0, p1, color_stops, extend } => {
let d = (p1.x - p0.x, p1.y - p0.y);
let len2 = d.0 * d.0 + d.1 * d.1;
if !len2.is_normal() || color_stops.is_empty() {
return None;
}
Paint::Linear { p0: pt(p0), d, len2, line: self.line(color_stops, extend) }
}
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend } => {
if color_stops.is_empty() {
return None;
}
Paint::Radial { c0: pt(c0), r0, cd: (c1.x - c0.x, c1.y - c0.y), dr: r1 - r0, line: self.line(color_stops, extend) }
}
Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend } => {
if color_stops.is_empty() {
return None;
}
Paint::Sweep { c: pt(c0), start: start_angle, end: end_angle, line: self.line(color_stops, extend) }
}
})
}
}
impl<F: FnMut(Shape, &Xform, &mut Coverage)> ColorPainter for Painter<'_, F> {
fn push_transform(&mut self, t: Transform) {
self.xforms.push(self.current().mul(&xform(&t)));
}
fn pop_transform(&mut self) {
self.xforms.pop();
}
fn push_clip_glyph(&mut self, id: GlyphId) {
self.push_shape(Shape::Glyph(id));
}
fn push_clip_box(&mut self, b: BoundingBox<f32>) {
self.push_shape(Shape::Box(b));
}
fn pop_clip(&mut self) {
if let Some(c) = self.clips.pop() {
self.give_back(c.len(), size_of::<f32>());
}
}
fn fill(&mut self, brush: Brush<'_>) {
if !self.spend(Some(0), true) {
return;
}
let Some(paint) = self.paint(brush) else { return };
let to_paint = self.to_window.mul(&self.current()).invert();
let w = self.w;
let base = self.layers.last().map_or(0, |l| l.clip_base);
let clip = if self.clips.len() > base { self.clips.last() } else { None };
let Some(layer) = self.layers.last_mut() else { return };
for (i, px) in layer.px.iter_mut().enumerate() {
let k = clip.map_or(1.0, |c| c.get(i).copied().unwrap_or(0.0));
if k <= 0.0 {
continue;
}
let src = match (&paint, &to_paint) {
(Paint::Solid(c), _) => Some(*c),
(_, Some(inv)) => {
let (u, v) = inv.apply((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
paint.at(u as f32, v as f32)
}
(_, None) => None,
};
let Some(s) = src else { continue };
let keep = 1.0 - s[3] * k;
for j in 0..4 {
px[j] = s[j] * k + px[j] * keep;
}
}
}
fn push_layer(&mut self, _: CompositeMode) {
// No fixed depth: skrifa stops at 64 nested paints, and the byte budget bounds the rest.
let px = self.new_layer().unwrap_or_default();
self.layers.push(Layer { px, clip_base: self.clips.len() });
}
fn pop_layer_with_mode(&mut self, mode: CompositeMode) {
if self.layers.len() < 2 {
return;
}
let Some(src) = self.layers.pop() else { return };
self.give_back(src.px.len(), size_of::<[f32; 4]>());
if !self.spend(Some(0), true) {
return;
}
// The clips around the merged layer (inside the one below) apply once, to the result.
let base = self.layers.last().map_or(0, |l| l.clip_base);
let clip = if self.clips.len() > base { self.clips.last() } else { None };
let Some(dst) = self.layers.last_mut() else { return };
for (i, (b, s)) in dst.px.iter_mut().zip(&src.px).enumerate() {
let k = clip.map_or(1.0, |c| c.get(i).copied().unwrap_or(0.0));
if k <= 0.0 {
continue;
}
let c = composite(mode, *b, *s);
*b = std::array::from_fn(|j| b[j] + (c[j] - b[j]) * k);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::raster::rect_to;
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
fn line(extend: Extend) -> ColorLine {
ColorLine { stops: vec![(0.0, RED), (1.0, BLUE)], extend }
}
#[test]
fn colour_line_extends_pad_repeat_and_reflect() {
let mid = |c: [f32; 4]| (c[0] - 0.5).abs() < 1e-6 && (c[2] - 0.5).abs() < 1e-6;
assert!(mid(line(Extend::Pad).at(0.5)));
assert_eq!(line(Extend::Pad).at(-3.0), RED);
assert_eq!(line(Extend::Pad).at(7.0), BLUE);
assert_eq!(line(Extend::Pad).at(f32::NAN), RED);
assert!(mid(line(Extend::Repeat).at(1.5)));
assert!((line(Extend::Repeat).at(1.25)[0] - 0.75).abs() < 1e-6);
assert!((line(Extend::Reflect).at(1.25)[0] - 0.25).abs() < 1e-6);
assert_eq!(ColorLine { stops: Vec::new(), extend: Extend::Pad }.at(0.5), [0.0; 4]);
}
#[test]
fn colour_line_extends_over_its_stops_not_over_0_to_1() {
// COLR repeats and reflects over [first stop, last stop], and stops may lie outside 0..1.
let inner = |extend| ColorLine { stops: vec![(0.25, RED), (0.75, BLUE)], extend };
let red_share = |c: [f32; 4]| c[0];
// Period 0.5: 0.875 repeats 0.375 (a quarter of the way to blue).
assert!((red_share(inner(Extend::Repeat).at(0.875)) - 0.75).abs() < 1e-5);
// Reflect: 0.875 mirrors 0.625 (three quarters of the way to blue).
assert!((red_share(inner(Extend::Reflect).at(0.875)) - 0.25).abs() < 1e-5);
// Pad reaches stops outside 0..1.
let wide = ColorLine { stops: vec![(-0.5, RED), (1.5, BLUE)], extend: Extend::Pad };
assert!((red_share(wide.at(0.0)) - 0.75).abs() < 1e-5);
assert_eq!(wide.at(1.5), BLUE);
}
const RED_PAL: [skrifa::color::Color; 2] =
[skrifa::color::Color { red: 255, green: 0, blue: 0, alpha: 255 }, skrifa::color::Color { red: 0, green: 0, blue: 255, alpha: 255 }];
const PLENTY: Limits = Limits { bytes: u64::MAX, work: u64::MAX };
fn ink() -> Ink<'static> {
Ink { palette: &RED_PAL, foreground: [0.0, 0.0, 0.0, 1.0] }
}
/// Draws every shape as given, glyph `n` as the rectangle 0..n x 0..8 (window pixels).
fn shapes(shape: Shape, m: &Xform, c: &mut Coverage) {
match shape {
Shape::Glyph(id) => rect_to(c, m, 0.0, 0.0, f64::from(id.to_u32()) * 0.5, 8.0),
Shape::Box(b) => rect_to(c, m, f64::from(b.x_min), f64::from(b.y_min), f64::from(b.x_max), f64::from(b.y_max)),
}
}
fn solid(index: u16) -> Brush<'static> {
Brush::Solid { palette_index: index, alpha: 1.0 }
}
#[test]
fn clip_boxes_are_drawn_the_way_the_glyphs_are() {
// Clip boxes go through the same drawing as the clip glyphs, so bent type bends its clip
// boxes too instead of dropping them. A stand-in "bend" here shifts everything 4 px right.
let bent = |shape: Shape, m: &Xform, c: &mut Coverage| shapes(shape, &Xform([1.0, 0.0, 0.0, 1.0, 4.0, 0.0]).mul(m), c);
let mut p = Painter::new(8, 8, bent, Xform::IDENTITY, ink(), AntiAlias::Smooth, PLENTY);
p.push_clip_box(BoundingBox { x_min: 0.0, y_min: 0.0, x_max: 2.0, y_max: 2.0 });
p.push_clip_glyph(GlyphId::new(16));
p.fill(solid(0));
p.pop_clip();
p.pop_clip();
let px = p.finish().unwrap_or_default();
// The box moved with the shapes: 4..6 is painted, 0..2 and the rest are not.
assert_eq!(px.get(8 + 5).map(|c| c[3]), Some(1.0));
assert_eq!(px.get(8 + 1).map(|c| c[3]), Some(0.0));
assert_eq!(px.get(5 * 8 + 5).map(|c| c[3]), Some(0.0));
}
#[test]
fn a_window_too_large_to_reserve_paints_nothing() {
// Coverage buffers are fallible on main (#4979); a colour glyph whose window cannot be
// reserved gives `None` instead of aborting, and its clips stay balanced.
let mut p = Painter::new(usize::MAX / 2, 4, shapes, Xform::IDENTITY, ink(), AntiAlias::Smooth, PLENTY);
p.push_clip_box(BoundingBox { x_min: 0.0, y_min: 0.0, x_max: 2.0, y_max: 2.0 });
p.push_clip_glyph(GlyphId::new(1));
p.push_layer(CompositeMode::SrcOver);
p.fill(solid(0));
p.pop_layer_with_mode(CompositeMode::SrcOver);
p.pop_clip();
p.pop_clip();
assert!(p.finish().is_none());
}
#[test]
fn layers_and_clips_count_against_the_byte_budget() {
// An 8x8 window: 1 KiB per layer, 256 bytes per clip mask (512 while it is built).
let run = |bytes: u64| {
let mut p = Painter::new(8, 8, shapes, Xform::IDENTITY, ink(), AntiAlias::Smooth, Limits { bytes, work: u64::MAX });
p.push_clip_glyph(GlyphId::new(16));
for _ in 0..3 {
p.push_layer(CompositeMode::SrcOver);
}
p.fill(solid(0));
for _ in 0..3 {
p.pop_layer_with_mode(CompositeMode::SrcOver);
}
p.pop_clip();
p.finish()
};
// Base layer, mask and three layers live at once.
assert!(run(4 * 1024 + 512).is_some());
assert!(run(4 * 1024 + 255).is_none());
// Buffers come back when popped: one more layer later on fits in the same budget.
let mut p = Painter::new(8, 8, shapes, Xform::IDENTITY, ink(), AntiAlias::Smooth, Limits { bytes: 2 * 1024, work: u64::MAX });
for _ in 0..5 {
p.push_layer(CompositeMode::SrcOver);
p.fill(solid(0));
p.pop_layer_with_mode(CompositeMode::SrcOver);
}
assert!(p.finish().is_some());
}
#[test]
fn colour_glyph_work_is_capped() {
// Each fill, clip and merge touches the whole 8x8 window (64 px of work).
let run = |work: u64| {
let mut p = Painter::new(8, 8, shapes, Xform::IDENTITY, ink(), AntiAlias::Smooth, Limits { bytes: u64::MAX, work });
for _ in 0..3 {
p.fill(solid(0));
}
let left = p.work_left();
(p.finish().is_some(), left)
};
assert_eq!(run(3 * 64), (true, 0));
assert!(!run(3 * 64 - 1).0);
}
#[test]
fn deep_composites_keep_their_modes() {
// Past what used to be an eight-layer cap, a nested Clear still clears: the red fill
// underneath must not show.
let mut p = Painter::new(4, 4, shapes, Xform::IDENTITY, ink(), AntiAlias::Smooth, PLENTY);
for _ in 0..9 {
p.push_layer(CompositeMode::SrcOver);
}
p.fill(solid(0));
p.push_layer(CompositeMode::Clear);
p.fill(solid(1));
p.pop_layer_with_mode(CompositeMode::Clear);
for _ in 0..9 {
p.pop_layer_with_mode(CompositeMode::SrcOver);
}
let px = p.finish().unwrap_or_default();
assert_eq!(px.len(), 16);
assert!(px.iter().all(|c| c[3] == 0.0), "{:?}", px.first());
}
#[test]
fn a_glyph_clip_applies_once_to_a_composite_inside_it() {
// A PaintGlyph around a SrcIn composite: the edge pixel half covered by the glyph ends
// up half opaque, not a quarter (the clip on both the backdrop and the source).
let mut p = Painter::new(
2,
1,
|s: Shape, m: &Xform, c: &mut Coverage| {
if let Shape::Glyph(_) = s {
rect_to(c, m, 0.0, 0.0, 1.5, 1.0);
}
},
Xform::IDENTITY,
ink(),
AntiAlias::Smooth,
PLENTY,
);
p.push_clip_glyph(GlyphId::new(1));
p.push_layer(CompositeMode::SrcOver);
p.fill(solid(0));
p.push_layer(CompositeMode::SrcIn);
p.fill(solid(1));
p.pop_layer_with_mode(CompositeMode::SrcIn);
p.pop_layer_with_mode(CompositeMode::SrcOver);
p.pop_clip();
let px = p.finish().unwrap_or_default();
assert_eq!(px.first().map(|c| c[3]), Some(1.0));
let edge = px.get(1).copied().unwrap_or_default();
assert!((edge[3] - 0.5).abs() < 1e-5 && (edge[2] - 0.5).abs() < 1e-5, "{edge:?}");
}
#[test]
fn a_composite_leaves_pixels_outside_its_clip_alone() {
// Red everywhere, then a Src composite of blue inside a clip over the left pixel only:
// Src replaces what is below, but only where the clip lets it.
let mut p = Painter::new(
2,
1,
|s: Shape, m: &Xform, c: &mut Coverage| {
if let Shape::Glyph(_) = s {
rect_to(c, m, 0.0, 0.0, 1.0, 1.0);
}
},
Xform::IDENTITY,
ink(),
AntiAlias::Smooth,
PLENTY,
);
p.fill(solid(0));
p.push_clip_glyph(GlyphId::new(1));
p.push_layer(CompositeMode::Src);
p.fill(solid(1));
p.pop_layer_with_mode(CompositeMode::Src);
p.pop_clip();
let px = p.finish().unwrap_or_default();
assert_eq!(px, vec![BLUE, RED]);
}
#[test]
fn colour_glyph_edges_follow_the_anti_alias_mode() {
// A clip covering 0.5 of a pixel: Smooth keeps 0.5, Sharp thins it as the plain passes do.
let paint = |mode| {
let mut p = Painter::new(1, 1, |_: Shape, m: &Xform, c: &mut Coverage| rect_to(c, m, 0.0, 0.0, 0.5, 1.0), Xform::IDENTITY, ink(), mode, PLENTY);
p.push_clip_glyph(GlyphId::new(1));
p.fill(solid(0));
p.pop_clip();
p.finish().unwrap_or_default().first().map_or(0.0, |c| c[3])
};
assert!((paint(AntiAlias::Smooth) - 0.5).abs() < 1e-5);
assert!((paint(AntiAlias::Sharp) - shape_antialias_coverage(0.5, AntiAlias::Sharp)).abs() < 1e-5);
assert!(paint(AntiAlias::Sharp) < 0.3);
assert_eq!(paint(AntiAlias::None), 1.0);
}
#[test]
fn ink_bounds_take_the_intersection_of_the_clips() {
// A small glyph inside a huge clip box: the bounds are the glyph's, not the box's.
let mut b = Bounds::default();
let to = |s: Shape, m: &Xform, b: &mut Bounds| match s {
Shape::Glyph(_) => rect_to(b, m, 0.0, 0.0, 8.0, 8.0),
Shape::Box(r) => rect_to(b, m, f64::from(r.x_min), f64::from(r.y_min), f64::from(r.x_max), f64::from(r.y_max)),
};
let mut o = OutlineBounds::new(&mut b, to);
o.push_clip_box(BoundingBox { x_min: -1e6, y_min: -1e6, x_max: 1e6, y_max: 1e6 });
o.push_clip_glyph(GlyphId::new(1));
o.fill(solid(0));
o.pop_clip();
// Clips that don't overlap paint nothing.
o.push_clip_box(BoundingBox { x_min: 100.0, y_min: 100.0, x_max: 200.0, y_max: 200.0 });
o.push_clip_glyph(GlyphId::new(1));
o.fill(solid(0));
o.pop_clip();
o.pop_clip();
o.pop_clip();
assert_eq!(b.rect, Some([0.0, 0.0, 8.0, 8.0]));
}
#[test]
fn coincident_stops_switch_at_their_offset() {
// A hard red-to-blue step at 0.5: exactly at 0.5 the colour after the step holds.
let step = ColorLine { stops: vec![(0.0, RED), (0.5, RED), (0.5, BLUE), (1.0, BLUE)], extend: Extend::Pad };
assert_eq!(step.at(0.5), BLUE);
assert_eq!(step.at(0.499), RED);
assert_eq!(step.at(0.0), RED);
// Several stops at the very start: the last of them holds from there.
let start = ColorLine { stops: vec![(0.0, RED), (0.0, BLUE), (1.0, BLUE)], extend: Extend::Pad };
assert_eq!(start.at(0.0), BLUE);
assert_eq!(start.at(-1.0), RED);
}
#[test]
fn sweep_angles_past_360_reach_the_positive_x_axis() {
// skrifa's 270°..450° (clockwise) crosses the positive x axis at 360°, the midpoint.
let p = Paint::Sweep { c: (0.0, 0.0), start: 270.0, end: 450.0, line: line(Extend::Pad) };
let mid = p.at(1.0, 0.0).unwrap_or_default();
assert!((mid[0] - 0.5).abs() < 1e-4 && (mid[2] - 0.5).abs() < 1e-4, "{mid:?}");
// A sweep starting below 0°: 350° is -10°, a sixth of the way through -30°..30°.
let p = Paint::Sweep { c: (0.0, 0.0), start: -30.0, end: 30.0, line: line(Extend::Pad) };
let ccw10 = 10f32.to_radians();
let c = p.at(ccw10.cos(), ccw10.sin()).unwrap_or_default();
assert!((c[2] - 1.0 / 3.0).abs() < 1e-3, "{c:?}");
}
#[test]
fn radial_gradient_is_the_two_point_cone() {
// Concentric circles, radius 0 to 10: t is the distance over 10, red at the centre.
let p = Paint::Radial { c0: (0.0, 0.0), r0: 0.0, cd: (0.0, 0.0), dr: 10.0, line: line(Extend::Pad) };
assert_eq!(p.at(0.0, 0.0), Some(RED));
let c = p.at(3.0, 4.0).unwrap_or_default();
assert!((c[0] - 0.5).abs() < 1e-5, "{c:?}");
assert_eq!(p.at(30.0, 0.0), Some(BLUE));
// Two equal circles side by side: no cone covers a point far above them.
let p = Paint::Radial { c0: (0.0, 0.0), r0: 1.0, cd: (4.0, 0.0), dr: 0.0, line: line(Extend::Pad) };
assert_eq!(p.at(2.0, 50.0), None);
assert!(p.at(2.0, 0.5).is_some());
}
#[test]
fn touching_circles_paint_their_shared_point() {
// Circles grow from radius 0 at the origin to the right, all through the origin: there
// the largest t is unbounded, the end of the colour line (it used to divide 0 by 0).
let p = Paint::Radial { c0: (0.0, 0.0), r0: 0.0, cd: (1.0, 0.0), dr: 1.0, line: line(Extend::Pad) };
assert_eq!(p.at(0.0, 0.0), Some(BLUE));
assert!(p.at(1.0, 0.0).is_some());
// Shrinking circles, all through (2, 0): the largest t is where the radius reaches 0.
let p = Paint::Radial { c0: (0.0, 0.0), r0: 2.0, cd: (2.0, 0.0), dr: -2.0, line: line(Extend::Pad) };
assert_eq!(p.at(2.0, 0.0), Some(BLUE));
// Equal circles in the same place: no cone, nothing painted, not even on the circle.
let p = Paint::Radial { c0: (0.0, 0.0), r0: 1.0, cd: (0.0, 0.0), dr: 0.0, line: line(Extend::Pad) };
assert_eq!(p.at(1.0, 0.0), None);
}
#[test]
fn a_glyph_clipped_by_its_own_outline_keeps_its_edge() {
// The same half-covered outline twice: the edge stays half opaque, not a quarter.
let mut p =
Painter::new(1, 1, |_: Shape, m: &Xform, c: &mut Coverage| rect_to(c, m, 0.0, 0.0, 0.5, 1.0), Xform::IDENTITY, ink(), AntiAlias::Smooth, PLENTY);
p.push_clip_glyph(GlyphId::new(1));
p.push_clip_glyph(GlyphId::new(1));
p.fill(solid(0));
p.pop_clip();
p.pop_clip();
let a = p.finish().unwrap_or_default().first().map_or(0.0, |c| c[3]);
assert!((a - 0.5).abs() < 1e-5, "{a}");
}
#[test]
fn sweep_gradient_turns_clockwise_from_the_start_angle() {
// skrifa's angles: clockwise, i.e. 360° minus the font's counter-clockwise ones.
let p = Paint::Sweep { c: (0.0, 0.0), start: 0.0, end: 360.0, line: line(Extend::Pad) };
// Font space is y up: (0, -1) is 90° counter-clockwise below, 90° clockwise.
let below = p.at(0.0, -1.0).unwrap_or_default();
assert!((below[2] - 0.25).abs() < 1e-4, "{below:?}");
let above = p.at(0.0, 1.0).unwrap_or_default();
assert!((above[2] - 0.75).abs() < 1e-4, "{above:?}");
}
#[test]
fn layers_composite_with_porter_duff_and_blend_modes() {
let half_red = [0.5, 0.0, 0.0, 0.5];
assert_eq!(composite(CompositeMode::SrcOver, BLUE, half_red), [0.5, 0.0, 0.5, 1.0]);
assert_eq!(composite(CompositeMode::DestOver, BLUE, half_red), BLUE);
assert_eq!(composite(CompositeMode::SrcIn, [0.0; 4], RED), [0.0; 4]);
assert_eq!(composite(CompositeMode::DestOut, BLUE, RED), [0.0; 4]);
assert_eq!(composite(CompositeMode::Clear, BLUE, RED), [0.0; 4]);
assert_eq!(composite(CompositeMode::Plus, RED, BLUE), [1.0, 0.0, 1.0, 1.0]);
// Multiply of red and blue on opaque pixels is black.
assert_eq!(composite(CompositeMode::Multiply, BLUE, RED), [0.0, 0.0, 0.0, 1.0]);
assert_eq!(composite(CompositeMode::Screen, BLUE, RED), [1.0, 0.0, 1.0, 1.0]);
}
}
+394
View File
@@ -0,0 +1,394 @@
//! Colour glyphs (COLR v0 and v1 with CPAL) draw in the font's colours (#7711). The fonts are
//! Inter with a COLR and a CPAL table spliced in, so no colour font has to be bundled.
use std::sync::Arc;
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::TextLayer;
use photocraft_doc::text::{AntiAlias, CharStyle, TextRun};
use photocraft_geom::Affine;
use skrifa::{FontRef, MetadataProvider};
use crate::render::{ink_rect, rasterize};
use crate::{TextEngine, TextLayout, fonts};
/// `font` with extra tables, the table directory rebuilt in tag order.
fn with_tables(font: &[u8], extra: &[([u8; 4], Vec<u8>)]) -> Vec<u8> {
let be16 = |o: usize| u16::from_be_bytes([font[o], font[o + 1]]) as usize;
let be32 = |o: usize| u32::from_be_bytes([font[o], font[o + 1], font[o + 2], font[o + 3]]) as usize;
let mut tables: Vec<([u8; 4], Vec<u8>)> = (0..be16(4))
.map(|i| {
let r = 12 + 16 * i;
let (off, len) = (be32(r + 8), be32(r + 12));
([font[r], font[r + 1], font[r + 2], font[r + 3]], font[off..off + len].to_vec())
})
.collect();
tables.extend(extra.iter().cloned());
tables.sort_by_key(|t| t.0);
let n = tables.len();
let mut out = font[..4].to_vec();
let pow = 1usize << (usize::BITS - 1 - n.leading_zeros());
for v in [n, pow * 16, pow.trailing_zeros() as usize, n * 16 - pow * 16] {
out.extend_from_slice(&(v as u16).to_be_bytes());
}
let offset = 12 + 16 * n;
let mut data = Vec::new();
for (tag, body) in &tables {
out.extend_from_slice(tag);
out.extend_from_slice(&0u32.to_be_bytes());
out.extend_from_slice(&((offset + data.len()) as u32).to_be_bytes());
out.extend_from_slice(&(body.len() as u32).to_be_bytes());
data.extend_from_slice(body);
data.resize(data.len().next_multiple_of(4), 0);
}
out.extend_from_slice(&data);
out
}
/// A CPAL table with one palette of straight RGBA bytes.
fn cpal(colors: &[[u8; 4]]) -> Vec<u8> {
let mut t = Vec::new();
for v in [0u16, colors.len() as u16, 1, colors.len() as u16] {
t.extend_from_slice(&v.to_be_bytes());
}
t.extend_from_slice(&14u32.to_be_bytes());
t.extend_from_slice(&0u16.to_be_bytes());
for [r, g, b, a] in colors {
t.extend_from_slice(&[*b, *g, *r, *a]);
}
t
}
/// A COLR v0 table: `base` drawn as `layers` (glyph, palette entry), bottom first.
fn colr_v0(base: u16, layers: &[(u16, u16)]) -> Vec<u8> {
let mut t = Vec::new();
t.extend_from_slice(&0u16.to_be_bytes());
t.extend_from_slice(&1u16.to_be_bytes());
t.extend_from_slice(&14u32.to_be_bytes());
t.extend_from_slice(&20u32.to_be_bytes());
t.extend_from_slice(&(layers.len() as u16).to_be_bytes());
for v in [base, 0, layers.len() as u16] {
t.extend_from_slice(&v.to_be_bytes());
}
for (g, p) in layers {
t.extend_from_slice(&g.to_be_bytes());
t.extend_from_slice(&p.to_be_bytes());
}
t
}
/// A COLR v1 table: `base` is its own outline filled with a linear gradient from palette entry 0
/// at x = `x0` to entry 1 at x = `x1` (font units).
fn colr_v1_gradient(base: u16, x0: i16, x1: i16) -> Vec<u8> {
let mut t = Vec::new();
// Header: version 1, no v0 records, then the BaseGlyphList right after the 34-byte header.
t.extend_from_slice(&1u16.to_be_bytes());
t.extend_from_slice(&0u16.to_be_bytes());
t.extend_from_slice(&0u32.to_be_bytes());
t.extend_from_slice(&0u32.to_be_bytes());
t.extend_from_slice(&0u16.to_be_bytes());
t.extend_from_slice(&34u32.to_be_bytes());
for _ in 0..4 {
t.extend_from_slice(&0u32.to_be_bytes());
}
// BaseGlyphList: one record, its paint 10 bytes in.
t.extend_from_slice(&1u32.to_be_bytes());
t.extend_from_slice(&base.to_be_bytes());
t.extend_from_slice(&10u32.to_be_bytes());
// PaintGlyph (format 10): the child paint 6 bytes in, clipped to `base`.
t.extend_from_slice(&[10, 0, 0, 6]);
t.extend_from_slice(&base.to_be_bytes());
// PaintLinearGradient (format 4): colour line 16 bytes in; p0, p1, p2 (p2 above p0).
t.extend_from_slice(&[4, 0, 0, 16]);
for v in [x0, 0, x1, 0, x0, 1000] {
t.extend_from_slice(&v.to_be_bytes());
}
// ColorLine: pad, two stops (offset, palette entry, alpha), F2Dot14.
t.push(0);
t.extend_from_slice(&2u16.to_be_bytes());
for (offset, entry) in [(0i16, 0u16), (1 << 14, 1)] {
t.extend_from_slice(&offset.to_be_bytes());
t.extend_from_slice(&entry.to_be_bytes());
t.extend_from_slice(&(1i16 << 14).to_be_bytes());
}
t
}
fn gid(font: &[u8], c: char) -> u16 {
let f = FontRef::new(font).expect("Inter parses");
f.charmap().map(c).map_or(0, |g| g.to_u32() as u16)
}
const RED: [u8; 4] = [255, 0, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
/// `text` laid out in Inter at 72 dpi, then drawn with `font` (Inter plus colour tables).
fn layout_with(text: &str, font: Vec<u8>, color: Color) -> TextLayout {
let mut e = TextEngine::new();
let style = CharStyle { size_pt: 64.0, color, ..Default::default() };
let layer = TextLayer { text: text.into(), font_family: "Inter".into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() };
let mut l = e.layout(&layer, 72.0);
for f in &mut l.faces {
f.font = parley::FontData::new(parley::fontique::Blob::new(Arc::new(font.clone())), 0);
}
l
}
/// Straight RGBA of every covered pixel, as RGB whatever the format.
fn pixels(l: &TextLayout, format: PixelFormat) -> Vec<[f32; 4]> {
let r = rasterize(l, &Affine::IDENTITY, format, AntiAlias::Sharp);
let data = r.surface.read_region(r.rect);
let n = format.channels();
data.chunks_exact(n)
.map(|p| {
let a = p[n - 1];
let rgb = match format.mode {
ColorMode::Grayscale => [p[0]; 3],
_ => [p[0], p[1], p[2]],
};
[rgb[0], rgb[1], rgb[2], a]
})
.collect()
}
fn depths() -> [PixelFormat; 3] {
[PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F]
}
#[test]
fn colr_v0_layers_take_their_palette_colours_not_the_text_colour() {
let inter = fonts::INTER_REGULAR.as_slice();
let (h, i) = (gid(inter, 'H'), gid(inter, 'I'));
// H is red; an I on top takes the text colour (palette entry 0xFFFF).
let font = with_tables(inter, &[(*b"COLR", colr_v0(h, &[(h, 0), (i, 0xFFFF)])), (*b"CPAL", cpal(&[RED]))]);
let green = Color::rgb(0.0, 1.0, 0.0);
for format in depths() {
let px = pixels(&layout_with("H", font.clone(), green), format);
let solid: Vec<_> = px.iter().filter(|p| p[3] > 0.99).collect();
assert!(!solid.is_empty(), "{format:?}: nothing drawn");
let red = solid.iter().filter(|p| p[0] > 0.98 && p[1] < 0.02 && p[2] < 0.02).count();
let fg = solid.iter().filter(|p| p[1] > 0.98 && p[0] < 0.02 && p[2] < 0.02).count();
assert!(red > 50, "{format:?}: {red} red pixels");
assert!(fg > 20, "{format:?}: {fg} pixels in the text colour");
// The rest are the I's anti-aliased edge over the red: mixes of the two, nothing else.
assert!(solid.len() - red - fg < 20, "{format:?}: {} mixed pixels", solid.len() - red - fg);
assert!(solid.iter().all(|p| p[2] < 0.02 && (p[0] + p[1] - 1.0).abs() < 0.03), "{format:?}: a colour that is neither");
}
// Without the colour tables the same glyph is all text colour.
let px = pixels(&layout_with("H", inter.to_vec(), green), PixelFormat::RGBA8);
assert!(px.iter().filter(|p| p[3] > 0.99).all(|p| p[1] > 0.98 && p[0] < 0.02));
}
#[test]
fn colour_glyph_takes_the_text_colours_opacity_and_any_colour_mode() {
let inter = fonts::INTER_REGULAR.as_slice();
let h = gid(inter, 'H');
let font = with_tables(inter, &[(*b"COLR", colr_v0(h, &[(h, 0)])), (*b"CPAL", cpal(&[RED]))]);
let px = pixels(&layout_with("H", font.clone(), Color::rgba(0.0, 0.0, 0.0, 0.5)), PixelFormat::RGBA8);
let top = px.iter().map(|p| p[3]).fold(0.0, f32::max);
assert!((top - 0.5).abs() < 0.01, "max alpha {top}");
assert!(px.iter().filter(|p| p[3] > 0.1).all(|p| p[0] > 0.98 && p[1] < 0.02), "red at half opacity");
// Grayscale documents get the red's grey.
let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U16, true);
let px = pixels(&layout_with("H", font, Color::BLACK), gray);
let solid: Vec<_> = px.iter().filter(|p| p[3] > 0.99).collect();
assert!(!solid.is_empty() && solid.iter().all(|p| p[0] > 0.1 && p[0] < 0.6), "{:?}", solid.first());
}
#[test]
fn colour_glyph_ink_covers_its_layers() {
let inter = fonts::INTER_REGULAR.as_slice();
let (i, w) = (gid(inter, 'I'), gid(inter, 'W'));
// A narrow I drawn as a wide W: the layer's ink is the glyph's ink.
let font = with_tables(inter, &[(*b"COLR", colr_v0(i, &[(w, 0)])), (*b"CPAL", cpal(&[RED]))]);
let plain = ink_rect(&layout_with("I", inter.to_vec(), Color::BLACK), &Affine::IDENTITY);
let colour = ink_rect(&layout_with("I", font.clone(), Color::BLACK), &Affine::IDENTITY);
assert!(colour.width() > plain.width() * 3, "{plain:?} vs {colour:?}");
let px = pixels(&layout_with("I", font, Color::BLACK), PixelFormat::RGBA8);
assert!(px.iter().filter(|p| p[3] > 0.99).count() > 400);
}
#[test]
fn colr_v1_gradient_runs_across_the_glyph() {
let inter = fonts::INTER_REGULAR.as_slice();
let h = gid(inter, 'H');
let upem =
FontRef::new(inter).map(|f| f.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em).unwrap_or(2048);
let font = with_tables(inter, &[(*b"COLR", colr_v1_gradient(h, 0, upem as i16 * 3 / 4)), (*b"CPAL", cpal(&[RED, BLUE]))]);
for format in depths() {
let l = layout_with("H", font.clone(), Color::BLACK);
let r = rasterize(&l, &Affine::IDENTITY, format, AntiAlias::Sharp);
let w = r.rect.width() as usize;
let data = r.surface.read_region(r.rect);
let px: Vec<[f32; 4]> = data.as_chunks::<4>().0.to_vec();
// The leftmost opaque column is mostly red, the rightmost mostly blue, with no green.
let opaque: Vec<(usize, [f32; 4])> = px.iter().enumerate().filter(|(_, p)| p[3] > 0.99).map(|(i, p)| (i % w, *p)).collect();
let (lo, hi) = (opaque.iter().map(|o| o.0).min().unwrap_or(0), opaque.iter().map(|o| o.0).max().unwrap_or(0));
assert!(hi > lo + 10, "{format:?}: drawn {lo}..{hi}");
for (x, p) in &opaque {
assert!(p[1] < 0.02, "{format:?}: green at {x}");
if *x == lo {
assert!(p[0] > 0.8 && p[2] < 0.2, "{format:?}: left {p:?}");
}
if *x == hi {
assert!(p[2] > 0.8 && p[0] < 0.2, "{format:?}: right {p:?}");
}
}
}
}
/// A COLR v1 table whose `base` paints itself (PaintColrGlyph), a cycle skrifa refuses to walk.
fn colr_v1_cycle(base: u16) -> Vec<u8> {
let mut t = Vec::new();
for v in [1u16, 0] {
t.extend_from_slice(&v.to_be_bytes());
}
t.extend_from_slice(&0u32.to_be_bytes());
t.extend_from_slice(&0u32.to_be_bytes());
t.extend_from_slice(&0u16.to_be_bytes());
t.extend_from_slice(&34u32.to_be_bytes());
for _ in 0..4 {
t.extend_from_slice(&0u32.to_be_bytes());
}
t.extend_from_slice(&1u32.to_be_bytes());
t.extend_from_slice(&base.to_be_bytes());
t.extend_from_slice(&10u32.to_be_bytes());
// PaintColrGlyph (format 11) of itself.
t.push(11);
t.extend_from_slice(&base.to_be_bytes());
t
}
#[test]
fn a_colour_glyph_skrifa_cannot_walk_draws_its_outline() {
// A paint graph that loops back on itself fails in skrifa; the glyph keeps its plain
// outline in the text colour instead of vanishing.
let inter = fonts::INTER_REGULAR.as_slice();
let h = gid(inter, 'H');
let font = with_tables(inter, &[(*b"COLR", colr_v1_cycle(h)), (*b"CPAL", cpal(&[RED]))]);
let green = Color::rgb(0.0, 1.0, 0.0);
let px = pixels(&layout_with("H", font, green), PixelFormat::RGBA8);
let solid: Vec<_> = px.iter().filter(|p| p[3] > 0.99).collect();
assert!(solid.len() > 100, "{} pixels", solid.len());
assert!(solid.iter().all(|p| p[1] > 0.98 && p[0] < 0.02), "{:?}", solid.first());
}
#[test]
fn a_colour_glyph_without_a_base_outline_still_paints() {
// The base glyph has no outline of its own (no glyf entry); its colour layer does.
let inter = fonts::INTER_REGULAR.as_slice();
let h = gid(inter, 'H');
let base = 60000u16;
let font = with_tables(inter, &[(*b"COLR", colr_v0(base, &[(h, 0)])), (*b"CPAL", cpal(&[RED]))]);
assert!(FontRef::new(&font).is_ok_and(|f| f.outline_glyphs().get(skrifa::GlyphId::new(base.into())).is_none()));
let mut l = layout_with("H", font, Color::BLACK);
for g in &mut l.glyphs {
g.id = base.into();
}
let px = pixels(&l, PixelFormat::RGBA8);
let red = px.iter().filter(|p| p[3] > 0.99 && p[0] > 0.98 && p[1] < 0.02).count();
assert!(red > 100, "{red} red pixels");
}
#[test]
fn colour_glyphs_keep_extended_text_colours_in_float_documents() {
// A layer in the text colour (palette entry 0xFFFF) keeps HDR and signed components in a
// float document, as plain glyphs do, and stays bounded in integer ones.
let inter = fonts::INTER_REGULAR.as_slice();
let h = gid(inter, 'H');
let font = with_tables(inter, &[(*b"COLR", colr_v0(h, &[(h, 0xFFFF)])), (*b"CPAL", cpal(&[RED]))]);
let color = Color::rgba(2.5, -0.25, 0.5, 1.0);
let float = pixels(&layout_with("H", font.clone(), color), PixelFormat::RGBA32F);
let covered: Vec<_> = float.iter().filter(|p| p[3] > 0.01).collect();
assert!(covered.len() > 100);
assert!(covered.iter().all(|p| (p[0] - 2.5).abs() < 1e-4 && (p[1] + 0.25).abs() < 1e-4), "{:?}", covered.first());
let int = pixels(&layout_with("H", font, color), PixelFormat::RGBA8);
assert!(int.iter().filter(|p| p[3] > 0.01).all(|p| p[0] == 1.0 && p[1] == 0.0));
}
/// A COLR v1 table whose `base` paints `paint` (format bytes, offsets relative to each table).
fn colr_v1(base: u16, paint: &[u8]) -> Vec<u8> {
let mut t = Vec::new();
for v in [1u16, 0] {
t.extend_from_slice(&v.to_be_bytes());
}
t.extend_from_slice(&0u32.to_be_bytes());
t.extend_from_slice(&0u32.to_be_bytes());
t.extend_from_slice(&0u16.to_be_bytes());
t.extend_from_slice(&34u32.to_be_bytes());
for _ in 0..4 {
t.extend_from_slice(&0u32.to_be_bytes());
}
t.extend_from_slice(&1u32.to_be_bytes());
t.extend_from_slice(&base.to_be_bytes());
t.extend_from_slice(&10u32.to_be_bytes());
t.extend_from_slice(paint);
t
}
/// `depth` PaintComposites, each with the next one as both its source and its backdrop, over
/// `base` filled with palette entry `entry`: 2^depth fills from a few dozen bytes.
fn fan_out(base: u16, depth: usize, entry: u16) -> Vec<u8> {
let mut p = Vec::new();
for _ in 0..depth {
// PaintComposite (format 32): source 8 bytes in, SrcOver, backdrop 8 bytes in.
p.extend_from_slice(&[32, 0, 0, 8, 3, 0, 0, 8]);
}
// PaintGlyph (format 10) of `base`, then PaintSolid (format 2), alpha 1.
p.extend_from_slice(&[10, 0, 0, 6]);
p.extend_from_slice(&base.to_be_bytes());
p.push(2);
p.extend_from_slice(&entry.to_be_bytes());
p.extend_from_slice(&(1i16 << 14).to_be_bytes());
p
}
#[test]
fn a_colour_glyph_that_fans_out_draws_its_outline() {
// A small graph that reuses both children of every composite would be walked 65536 times
// over (for the bounds, the ink and the paint); skrifa stops after a fixed number of paints
// and the glyph keeps its plain outline, as with a cycle.
let inter = fonts::INTER_REGULAR.as_slice();
let h = gid(inter, 'H');
let font = with_tables(inter, &[(*b"COLR", colr_v1(h, &fan_out(h, 16, 0))), (*b"CPAL", cpal(&[RED]))]);
let green = Color::rgb(0.0, 1.0, 0.0);
let px = pixels(&layout_with("H", font, green), PixelFormat::RGBA8);
let solid: Vec<_> = px.iter().filter(|p| p[3] > 0.99).collect();
assert!(solid.len() > 100, "{} pixels", solid.len());
assert!(solid.iter().all(|p| p[1] > 0.98 && p[0] < 0.02), "{:?}", solid.first());
}
#[test]
fn a_hidden_colour_glyph_does_not_spend_the_visible_texts_budget() {
// A huge colour H at opacity 0 whose paint alone would overrun the pixel budget, then a
// plain I in green: the H paints nothing anyway, and the I still shows.
let inter = fonts::INTER_REGULAR.as_slice();
let h = gid(inter, 'H');
let font = with_tables(inter, &[(*b"COLR", colr_v1(h, &fan_out(h, 11, 0))), (*b"CPAL", cpal(&[RED]))]);
let style = |color| CharStyle { size_pt: 600.0, color, ..Default::default() };
let runs = vec![TextRun { len: 1, style: style(Color::TRANSPARENT) }, TextRun { len: 1, style: style(Color::rgb(0.0, 1.0, 0.0)) }];
let layer = TextLayer { text: "HI".into(), font_family: "Inter".into(), runs, ..Default::default() };
let mut l = TextEngine::new().layout(&layer, 72.0);
for f in &mut l.faces {
f.font = parley::FontData::new(parley::fontique::Blob::new(Arc::new(font.clone())), 0);
}
let px = pixels(&l, PixelFormat::RGBA8);
let green = px.iter().filter(|p| p[3] > 0.99 && p[1] > 0.98 && p[0] < 0.02).count();
assert!(green > 1000, "{green} green pixels");
assert!(px.iter().all(|p| p[3] < 0.01 || p[0] < 0.02), "the hidden H showed");
}
#[test]
fn colour_glyph_layers_in_the_text_colour_keep_its_cmyk_components() {
// In a CMYK document a K-only text colour stays K only in a 0xFFFF layer, as in plain text,
// instead of coming back from sRGB as a four-colour black.
let inter = fonts::INTER_REGULAR.as_slice();
let h = gid(inter, 'H');
let font = with_tables(inter, &[(*b"COLR", colr_v0(h, &[(h, 0xFFFF)])), (*b"CPAL", cpal(&[RED]))]);
let k = Color { mode: ColorMode::Cmyk, c: [0.0, 0.0, 0.0, 1.0], alpha: 1.0 };
let r = rasterize(&layout_with("H", font, k), &Affine::IDENTITY, PixelFormat::CMYKA8, AntiAlias::Sharp);
let data = r.surface.read_region(r.rect);
let solid: Vec<&[f32; 5]> = data.as_chunks::<5>().0.iter().filter(|p| p[4] > 0.99).collect();
assert!(solid.len() > 100, "{} pixels", solid.len());
assert!(solid.iter().all(|p| p[0] < 0.01 && p[1] < 0.01 && p[2] < 0.01 && p[3] > 0.99), "{:?}", solid.first());
}
+3
View File
@@ -19,6 +19,7 @@
pub mod area;
pub mod cjk;
mod color_glyphs;
pub mod craft_fonts;
mod embolden;
pub mod engine_data;
@@ -326,6 +327,8 @@ mod warm {
}
}
#[cfg(test)]
mod color_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
+10
View File
@@ -17,6 +17,16 @@ impl Xform {
let [oa, ob, oc, od, oe, of] = o.0;
Xform([a * oa + c * ob, b * oa + d * ob, a * oc + c * od, b * oc + d * od, a * oe + c * of + e, b * oe + d * of + f])
}
/// The inverse map, `None` when singular or not finite.
pub fn invert(&self) -> Option<Xform> {
let [a, b, c, d, e, f] = self.0;
let det = a * d - b * c;
if !det.is_finite() || det.abs() < 1e-12 {
return None;
}
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
Some(Xform([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
}
/// Largest scale factor (for flattening tolerance).
pub fn max_scale(&self) -> f64 {
let [a, b, c, d, ..] = self.0;
+220 -28
View File
@@ -1,6 +1,6 @@
//! Rasterizes a [`TextLayout`] into a document-space [`Surface`] of any pixel format.
use photocraft_color::{Color, PixelFormat, SampleType};
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::text::AntiAlias;
use photocraft_geom::{Affine, Rect};
use photocraft_raster::Surface;
@@ -8,6 +8,7 @@ use skrifa::instance::{LocationRef, NormalizedCoord, Size};
use skrifa::outline::DrawSettings;
use skrifa::{GlyphId, MetadataProvider};
use crate::color_glyphs::{Ink, Limits, NoPaint, OutlineBounds, Painter, Shape};
use crate::layout::{DecorationRect, GlyphOrient, PlacedGlyph, TextLayout};
use crate::raster::{Bounds, Coverage, LineSink, Pen, Xform, rect_to};
use crate::warp::{Placement, Warp};
@@ -17,10 +18,15 @@ use rayon::prelude::*;
/// Faux-italic slant in degrees (Photoshop-like).
pub const FAUX_ITALIC_DEG: f32 = 12.0;
/// How far faux bold grows the outline on every side, as a fraction of the font size
/// ([`crate::embolden`]): stems get twice this heavier, close to the weight of the earlier
/// faux bold, which stamped the glyph at offsets of this radius.
pub const FAUX_BOLD_RADIUS: f32 = 0.036;
/// How far faux bold grows the outline on every side, in pixels (`crate::embolden`): stems get
/// twice this heavier. Photoshop thickens by about `size/72`; the flat 3.6% of the size used
/// before overshot its ink by 1.3–1.5× from 50 px up. So the ring follows the size, with a pixel
/// floor that fades in below ~32 px (Photoshop's small type reads heavier than `size/72`) and a
/// cap so a sub-pixel glyph does not grow into a blob.
pub fn faux_bold_radius(size_px: f32) -> f32 {
(size_px / 72.0).max((size_px / 40.0).min(0.45))
}
/// Largest raster we produce (pixels), as a guard against absurd sizes.
const MAX_PIXELS: u64 = 256 * 1024 * 1024;
/// Cumulative coverage-pixel work budget across the colour passes of one render.
@@ -61,7 +67,7 @@ fn nothing(format: PixelFormat) -> Rendered {
/// Maps raw area-coverage to the opacity Photoshop's anti-alias modes imply. Exponents above 1 thin
/// the fringe (sharper); below 1 widen it (stronger smoothing).
fn shape_antialias_coverage(cv: f32, mode: AntiAlias) -> f32 {
pub(crate) fn shape_antialias_coverage(cv: f32, mode: AntiAlias) -> f32 {
if !cv.is_finite() {
return 0.0;
}
@@ -123,14 +129,15 @@ fn glyph_is_placed(layout: &TextLayout, g: &PlacedGlyph, warp: Option<&Warp>) ->
let Ok(font) = skrifa::FontRef::from_index(face.font.data.as_ref(), face.font.index) else {
return false;
};
if font.outline_glyphs().get(GlyphId::new(g.id)).is_none() {
let coords: Vec<NormalizedCoord> = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect();
// A colour glyph paints its layers, so it counts even when its base glyph has no outline.
if font.outline_glyphs().get(GlyphId::new(g.id)).is_none() && color_glyph(&font, GlyphId::new(g.id), &coords).is_none() {
return false;
}
if let Some(w) = warp {
let coords: Vec<NormalizedCoord> = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect();
if matches!(w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)), Placement::Hidden) {
return false;
}
if let Some(w) = warp
&& matches!(w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)), Placement::Hidden)
{
return false;
}
true
}
@@ -185,7 +192,7 @@ pub(crate) fn visible_color_passes(layout: &TextLayout, warp: Option<&Warp>) ->
/// Draws the glyphs and decorations of `layout` in `only` (or all of them when it is `None`) into `sink`, through `transform` (text space → sink space), bending
/// the outlines with `warp` first (in text space). Faux bold glyphs are drawn from their outline
/// thickened by [`FAUX_BOLD_RADIUS`] ([`crate::embolden`]).
/// thickened by [`faux_bold_radius`] ([`crate::embolden`]).
fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only: Option<&Pass>, warp: Option<&Warp>) {
let glyphs: Box<dyn Iterator<Item = &PlacedGlyph>> = match only {
Some(p) => Box::new(p.glyphs.iter().filter_map(|&i| layout.glyphs.get(i))),
@@ -197,23 +204,29 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only:
let Ok(font) = skrifa::FontRef::from_index(face.font.data.as_ref(), face.font.index) else {
continue;
};
let coords: Vec<NormalizedCoord> = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect();
// Colour glyphs paint in their own colours ([`paint_color_glyphs`]); only their extent
// counts here.
if let Some(color) = color_glyph(&font, GlyphId::new(g.id), &coords) {
if only.is_none()
&& let Some((glyph, bend)) = place(layout, g, &font, face.size_px, &coords, warp)
{
let unit = font_units(&font, face.size_px, &coords, glyph);
let outline = |shape: Shape, m: &Xform, s: &mut Bounds| shape_into(&font, &coords, shape, unit.mul(m), transform, bend, s);
let mut b = Bounds::default();
let _ = color.paint(LocationRef::new(&coords), &mut OutlineBounds::new(&mut b, outline));
if let Some([x0, y0, x1, y1]) = b.rect {
rect_to(sink, &Xform::IDENTITY, x0, y0, x1, y1);
}
}
continue;
}
let Some(outline) = font.outline_glyphs().get(GlyphId::new(g.id)) else {
continue;
};
let coords: Vec<NormalizedCoord> = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect();
let mut glyph = glyph_xform(layout, g, face.skew_deg);
// Type on a path moves each glyph rigidly: turned to the curve at its centre.
let mut bend = warp;
if let Some(w) = warp {
match w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)) {
Placement::Bend => {}
Placement::Hidden => continue,
Placement::Frame(m) => {
glyph = Xform(m).mul(&glyph);
bend = None;
}
}
}
let Some((glyph, bend)) = place(layout, g, &font, face.size_px, &coords, warp) else {
continue;
};
let settings = || DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords));
// Faux bold thickens the outline in font units, before faux italic and scaling, so the
// slant and proportions apply to the bold shape as they do to a real bold face. Other
@@ -224,7 +237,7 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only:
if outline.draw(settings(), &mut shape).is_err() {
continue;
}
shape.embolden(f64::from(face.size_px * FAUX_BOLD_RADIUS));
shape.embolden(f64::from(faux_bold_radius(face.size_px)));
bold = Some(shape);
}
let settings = settings();
@@ -251,6 +264,178 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only:
}
}
/// Where a placed glyph goes: its outline map (px-scaled font units, y up → text space) and the
/// warp still to bend it with. `None` when type on a path hides it.
fn place<'w>(
layout: &TextLayout,
g: &PlacedGlyph,
font: &skrifa::FontRef,
size_px: f32,
coords: &[NormalizedCoord],
warp: Option<&'w Warp>,
) -> Option<(Xform, Option<&'w Warp>)> {
let face_skew = layout.faces.get(g.face as usize).map_or(0.0, |f| f.skew_deg);
let glyph = glyph_xform(layout, g, face_skew);
// Type on a path moves each glyph rigidly: turned to the curve at its centre.
match warp.map(|w| w.place_glyph(glyph_center(layout, g, font, size_px, coords))) {
None | Some(Placement::Bend) => Some((glyph, warp)),
Some(Placement::Hidden) => None,
Some(Placement::Frame(m)) => Some((Xform(m).mul(&glyph), None)),
}
}
/// `glyph` (see [`place`]) for unscaled font units, as colour glyphs paint in.
fn font_units(font: &skrifa::FontRef, size_px: f32, coords: &[NormalizedCoord], glyph: Xform) -> Xform {
let upem = font.metrics(Size::unscaled(), LocationRef::new(coords)).units_per_em;
let k = if upem > 0 { f64::from(size_px) / f64::from(upem) } else { 0.0 };
glyph.mul(&Xform([k, 0.0, 0.0, k, 0.0, 0.0]))
}
/// Draws `outline` through `local` (font space → text space), bent by `bend`, then mapped by
/// `transform` into `sink`.
fn draw_outline(
outline: &skrifa::outline::OutlineGlyph,
settings: DrawSettings,
local: Xform,
transform: &Xform,
bend: Option<&Warp>,
sink: &mut impl LineSink,
) {
match bend {
Some(w) => {
let mut ws = WarpSink { inner: sink, warp: w, post: *transform };
let mut pen = Pen::new(&mut ws, local);
if outline.draw(settings, &mut pen).is_ok() {
skrifa::outline::OutlinePen::close(&mut pen);
}
}
None => {
let mut pen = Pen::new(sink, transform.mul(&local));
if outline.draw(settings, &mut pen).is_ok() {
skrifa::outline::OutlinePen::close(&mut pen);
}
}
}
}
/// The colour glyph `id` paints as, when the font has one and skrifa can walk its paint graph.
/// A graph it cannot walk (too deep, a cycle, a broken offset) leaves the glyph to its plain
/// outline in the text colour instead of dropping it.
fn color_glyph<'a>(font: &skrifa::FontRef<'a>, id: GlyphId, coords: &[NormalizedCoord]) -> Option<skrifa::color::ColorGlyph<'a>> {
let color = font.color_glyphs().get(id)?;
color.paint(LocationRef::new(coords), &mut NoPaint).is_ok().then_some(color)
}
/// A colour glyph's clip [`Shape`], unscaled font units through `local` (font units → text
/// space), into `sink`: a glyph's outline, or a clip box, bent by `bend` like the outlines so it
/// still crops what it was meant to.
fn shape_into(
font: &skrifa::FontRef,
coords: &[NormalizedCoord],
shape: Shape,
local: Xform,
transform: &Xform,
bend: Option<&Warp>,
sink: &mut impl LineSink,
) {
match shape {
Shape::Glyph(id) => {
if let Some(outline) = font.outline_glyphs().get(id) {
draw_outline(&outline, DrawSettings::unhinted(Size::unscaled(), LocationRef::new(coords)), local, transform, bend, sink);
}
}
Shape::Box(b) => {
let (x0, y0, x1, y1) = (f64::from(b.x_min), f64::from(b.y_min), f64::from(b.x_max), f64::from(b.y_max));
match bend {
Some(w) => rect_to(&mut WarpSink { inner: sink, warp: w, post: *transform }, &local, x0, y0, x1, y1),
None => rect_to(sink, &transform.mul(&local), x0, y0, x1, y1),
}
}
}
}
/// Paints the colour glyphs of `layout` in their own colours over the premultiplied accumulator
/// (`w × h`, `format`'s colour channels then alpha): their palette's sRGB converted to the
/// document's colour model, times the text colour's opacity. `None` when a glyph's buffers could
/// not be reserved or `limits` ran out (see [`Limits`]).
#[allow(clippy::too_many_arguments)]
fn paint_color_glyphs(
layout: &TextLayout,
xf: &Xform,
warp: Option<&Warp>,
format: &PixelFormat,
antialias: AntiAlias,
acc: &mut [f32],
(w, h): (usize, usize),
mut limits: Limits,
) -> Option<()> {
let n = format.mode.color_channels();
let stride = n + 1;
let opaque = PixelFormat { alpha: false, ..*format };
// Float documents keep extended (HDR, signed) colours, as the plain passes do.
let keep_range = |v: f32| if format.sample == SampleType::F32 && v.is_finite() { v } else { v.clamp(0.0, 1.0) };
for g in &layout.glyphs {
let (Some(st), Some(face)) = (layout.styles.get(g.style as usize), layout.faces.get(g.face as usize)) else { continue };
// A glyph the text colour's opacity hides paints nothing; walking it would only spend the
// budget the visible text needs (as the plain passes skip it, `visible_color_passes`).
let opacity = st.color.alpha.clamp(0.0, 1.0);
if opacity.is_nan() || opacity <= 0.0 {
continue;
}
let Ok(font) = skrifa::FontRef::from_index(face.font.data.as_ref(), face.font.index) else { continue };
let coords: Vec<NormalizedCoord> = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect();
let Some(color) = color_glyph(&font, GlyphId::new(g.id), &coords) else { continue };
let Some((glyph, bend)) = place(layout, g, &font, face.size_px, &coords, warp) else { continue };
let unit = font_units(&font, face.size_px, &coords, glyph);
// The window the glyph can paint: its fills within their clips.
let mut b = Bounds::default();
let outline = |shape: Shape, m: &Xform, s: &mut Bounds| shape_into(&font, &coords, shape, unit.mul(m), xf, bend, s);
if color.paint(LocationRef::new(&coords), &mut OutlineBounds::new(&mut b, outline)).is_err() {
continue;
}
let Some([x0, y0, x1, y1]) = b.rect else { continue };
let clamp = |v: f64, hi: usize| v.clamp(0.0, hi as f64) as usize;
let (wx0, wy0, wx1, wy1) = (clamp(x0.floor() - 1.0, w), clamp(y0.floor() - 1.0, h), clamp(x1.ceil() + 1.0, w), clamp(y1.ceil() + 1.0, h));
if wx1 <= wx0 || wy1 <= wy0 {
continue;
}
let (ww, wh) = (wx1 - wx0, wy1 - wy0);
let to_window = Xform([1.0, 0.0, 0.0, 1.0, -(wx0 as f64), -(wy0 as f64)]).mul(xf);
let palettes = font.color_palettes();
let palette = palettes.get(0);
let [r, gr, bl] = st.color.to_rgb();
// Pixels in the text colour alone keep its own components, as plain glyphs do (a CMYK
// black stays K only instead of coming back from sRGB as a four-colour black).
let native = (format.mode != ColorMode::Rgb).then(|| color_in(format, &st.color));
let outline = |shape: Shape, m: &Xform, s: &mut Coverage| shape_into(&font, &coords, shape, unit.mul(m), &to_window, bend, s);
let ink = Ink { palette: palette.as_ref().map_or(&[][..], |p| p.colors()), foreground: [r, gr, bl, 1.0] };
let mut painter = Painter::new(ww, wh, outline, to_window.mul(&unit), ink, antialias, limits);
if color.paint(LocationRef::new(&coords), &mut painter).is_err() {
continue;
}
// Compositing the window into the text is one more pass over it.
limits.work = painter.work_left().checked_sub((ww as u64).checked_mul(wh as u64)?)?;
for (i, s) in painter.finish()?.iter().enumerate() {
let a = s[3] * opacity;
let o = ((wy0 + i / ww) * w + wx0 + i % ww) * stride;
let (true, Some(px)) = (a > 0.0, acc.get_mut(o..o + stride)) else { continue };
let straight = [s[0] / s[3], s[1] / s[3], s[2] / s[3]];
let rgb = straight.map(keep_range);
let comps = match &native {
None => rgb.to_vec(),
Some(c) if straight.iter().zip([r, gr, bl]).all(|(v, f)| (v - f).abs() < 1e-5) => c.iter().copied().map(keep_range).collect(),
Some(_) => photocraft_raster::from_rgba(&opaque, [rgb[0], rgb[1], rgb[2], 1.0]),
};
let keep = 1.0 - a;
for (p, c) in px.iter_mut().zip(&comps).take(n) {
*p = c * a + *p * keep;
}
px[n] = a + px[n] * keep;
}
}
Some(())
}
/// One glyph into `pen`: its thickened `bold` outline when there is one, else the font's own.
fn draw_glyph(
outline: &skrifa::outline::OutlineGlyph,
@@ -438,6 +623,13 @@ pub(crate) fn rasterize_passes(
let a = c.alpha.clamp(0.0, 1.0);
composite(&mut acc, w, sub, &cov, stride, &comps, a, antialias);
}
// Colour glyphs get what the plain passes left of both budgets: the bytes beside the
// accumulator and the output surface ([`fits_raster_budget`]), and the pixel work.
let deep = (w as u64).saturating_mul(h as u64).saturating_mul(stride as u64).saturating_mul(size_of::<f32>() as u64 + format.sample.bytes() as u64);
let limits = Limits { bytes: MAX_RASTER_BYTES.saturating_sub(deep), work: MAX_PASS_PIXELS.saturating_sub(pass_pixels) };
if paint_color_glyphs(layout, &xf, warp, &format, antialias, &mut acc, (w, h), limits).is_none() {
return nothing(format);
}
// Unpremultiply.
unpremultiply(&mut acc, n, stride, format.sample);
surface.write_region_par(rect, &acc);
+64 -6
View File
@@ -573,14 +573,68 @@ fn faux_bold_keeps_antialiased_edges_at_small_sizes() {
let (plain_ink, plain_partial) = stats(&mut e, base.clone());
let (bold_ink, bold_partial) = stats(&mut e, CharStyle { faux_bold: true, ..base });
assert!(bold_ink > plain_ink, "bold must add ink");
// Weight: the old summed-coverage faux bold (before #4130) measured 282.7 total ink for this
// text at 12 px (plain 138.0). The antialiased version must stay close to that weight.
const MAIN_BOLD_INK: f64 = 282.7;
assert!(bold_ink >= MAIN_BOLD_INK * 0.85, "stems too thin: {bold_ink} vs old {MAIN_BOLD_INK}");
assert!(bold_ink <= MAIN_BOLD_INK * 1.15, "stems too heavy: {bold_ink} vs old {MAIN_BOLD_INK}");
// Weight: the ring is now `size/72` with a pixel floor (#9008), which is Photoshop's weight,
// so this guards the antialiased edges rather than the old, heavier 3.6% of the size.
const MAIN_BOLD_INK: f64 = 222.2;
assert!(bold_ink >= MAIN_BOLD_INK * 0.9, "stems too thin: {bold_ink} vs {MAIN_BOLD_INK}");
assert!(bold_ink <= MAIN_BOLD_INK * 1.1, "stems too heavy: {bold_ink} vs {MAIN_BOLD_INK}");
assert!(bold_partial * 10 >= plain_partial * 8, "edges stay antialiased: {plain_partial} -> {bold_partial}");
}
/// #9008: the faux-bold ring is Photoshop's `size/72`, with a pixel floor that fades in below
/// ~32 px (Photoshop's small type reads heavier than that) and a cap so a glyph smaller than a
/// pixel does not grow into a blob.
#[test]
fn faux_bold_radius_has_a_pixel_floor_that_fades_for_tiny_type() {
let r = crate::render::faux_bold_radius;
assert!((r(300.0) - 300.0 / 72.0).abs() < 1e-3, "large type: {}", r(300.0));
assert!((r(72.0) - 1.0).abs() < 1e-3, "72 px: {}", r(72.0));
assert!((r(24.0) - 0.45).abs() < 1e-3, "the floor takes over below 32 px: {}", r(24.0));
assert!((r(4.0) - 0.1).abs() < 1e-3, "and fades out for tiny type: {}", r(4.0));
assert!(r(0.5) < 0.02, "sub-pixel type keeps a sub-pixel ring: {}", r(0.5));
// Monotonic: heavier text never grows a thinner ring as it gets larger.
assert!((1..400).all(|px| r(px as f32) <= r(px as f32 + 1.0)));
}
/// #9008: at display sizes the ring is `size/72`, so the extra ink is the outline's perimeter times
/// `2 * size/72` — not the flat 7.2% of the size that made a PSD's faux-bold layer jump heavier as
/// soon as clicking it re-rendered the text.
#[test]
fn faux_bold_at_display_sizes_grows_the_outline_by_size_over_72() {
let mut e = TextEngine::new();
// Display sizes, including the two ends the issue reported on (75-125 px layers) and the
// sizes where the pixel floor hands over to `size/72` (32 px) and where the ring is 1 px
// (72 px). The flat 3.6% ring this replaces grew the outline by 0.072 * size instead, so a
// regression to it fails the width check at every size but 12 px.
let mut ratios = Vec::new();
for px in [12.0f32, 32.0, 72.0, 120.0, 200.0] {
let ink = |e: &mut TextEngine, s: CharStyle| {
let (_, r) = e.render(&TextLayer { transform: Affine::translate(10.0, f64::from(px) * 1.4), ..styled("Hi", s) }, 72.0, PixelFormat::RGBA8);
(alpha_sum(&r.surface, r.rect), r.rect)
};
let base = CharStyle { size_pt: px, ..Default::default() };
let (plain, prect) = ink(&mut e, base.clone());
let (bold, brect) = ink(&mut e, CharStyle { faux_bold: true, ..base });
// The outline grows by the ring on every side, so the ink rectangle grows by twice it.
let ring = crate::render::faux_bold_radius(px);
let grew = (brect.width() - prect.width()) as f32;
assert!((grew - 2.0 * ring).abs() <= 1.5, "{px} px: width grew {grew}, expected about {}", 2.0 * ring);
// Heavier by the perimeter's worth of ink. Pinned per size, because a thicker ring here is
// exactly the jump a PSD's faux-bold layer shows when a click re-renders it.
ratios.push(bold / plain);
}
// Plain text's ink grows with the size, so the bold/plain ratio falls as the ring does relative
// to the stems: 1.9x at 12 px (the floor), about 1.35x at 120 px, 1.24x at 200 px.
// The ring is a fixed fraction of the size above the pixel floor, so the ratio does not move
// with the size: 1.35x at 32, 72, 120 and 200 px (the flat 3.6% ring measured 1.95x). At 12 px
// the floor is 3.75% of the size, so the type reads a little heavier, as Photoshop's does.
for (px, r) in [32.0f32, 72.0, 120.0, 200.0].iter().zip(&ratios[1..]) {
assert!((1.30..=1.40).contains(r), "{px} px: {r:.2}x, expected about 1.35x");
}
assert!((1.55..=1.75).contains(&ratios[0]), "12 px: {:.2}x, expected about 1.64x", ratios[0]);
assert!(ratios[0] > ratios[4], "the pixel floor fades out: {:.2}x at 12 px, {:.2}x at 200 px", ratios[0], ratios[4]);
}
/// #3705: Faux bold dilation must scale with horizontal and vertical glyph scaling.
#[test]
fn faux_bold_scales_with_glyph_scaling() {
@@ -3343,7 +3397,11 @@ fn per_colour_passes_match_full_size_passes() {
px.chunks_exact(ch).flat_map(|p| p[..ch - 1].iter().map(|c| c * p[ch - 1]).chain([p[ch - 1]]).collect::<Vec<_>>()).collect::<Vec<_>>()
};
let worst = shown(&a).iter().zip(&shown(&b)).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max);
assert!(worst <= 1.0 / 255.0, "case {k} {format:?}: largest difference {worst}");
// Colour channels are 8-bit samples, so one step is 1/255. Coverage is accumulated in
// f32 from a buffer whose origin differs between the two paths, and a pixel sitting on
// a rounding tie then lands a step further out (measured 1.65/255 on the warped
// faux-bold case), so allow two steps.
assert!(worst <= 2.0 / 255.0, "case {k} {format:?}: largest difference {worst}");
}
}
}
+204 -2
View File
@@ -1562,8 +1562,77 @@ fn full_layer_doc(w: u32, h: u32, depth: u32) -> Res<(Session, u64)> {
Ok((s, layer))
}
/// #9329 PR A: the redraw area of edits on a full-canvas layer, where redrawing the whole
/// document is the expensive cliff. Each row checks it hits that path and prints `refresh px`.
/// The index of `layer` among the document's top-level layers (bottom first).
fn top_index(s: &Session, layer: u64) -> Res<usize> {
let doc = &s.active().ok_or("no document")?.doc;
doc.layers.iter().position(|l| l.id.0 == layer).ok_or_else(|| "layer is not top-level".to_string())
}
/// The composite pixel at (`x`, `y`).
fn composite_px(s: &Session, x: i32, y: i32) -> Res<Vec<f32>> {
let doc = &s.active().ok_or("no document")?.doc;
Ok(photocraft_compose::render(doc, Rect::new(x, y, x + 1, y + 1)).px.first().ok_or("empty render")?.to_vec())
}
/// #9329 PR B: the layered document with a Curves above panel 2, masked to a small rect inside
/// the panel. Returns the session, the Curves and a probe point inside the mask.
fn masked_curves_doc(w: u32, h: u32) -> Res<(Session, u64, (i32, i32))> {
let (mut s, _) = layered_doc(w, h)?;
let panel = s.active().ok_or("no document")?.doc.layers.iter().find(|l| l.name == "panel 2").map(|l| l.id.0).ok_or("no panel 2")?;
exec(&mut s, "layer.select", json!({"layer": panel}))?;
let (x, y, mw, mh) = (2 * w / 3 + w / 12, h / 8, w / 10, h / 10);
exec(&mut s, "select.rect", json!({"x": x, "y": y, "width": mw, "height": mh}))?;
let curves =
exec(&mut s, "layer.newAdjustmentLayer.curves", json!({"points": [[0, 0], [90, 170], [255, 255]]}))?["layer"].as_u64().ok_or("no curves id")?;
let doc = s.active().ok_or("no document")?.doc.clone();
let above = doc.layers.len() - 1 - top_index(&s, curves)?;
if above < 4 {
return Err(format!("path check: {above} layers above the Curves, not >= 4 (the prefix cache would cover it)"));
}
let mask = doc.layer(photocraft_doc::LayerId(curves)).and_then(|l| l.mask.as_ref()).ok_or("path check: the Curves has no mask")?;
let share = coverage(&doc, mask.surface.content_bounds());
if mask.value(0, 0) != 0.0 || share > 0.05 {
return Err(format!("path check: the mask reveals {:.2} % of the canvas, not <= 5 %", share * 100.0));
}
let probe = ((x + mw / 2) as i32, (y + mh / 2) as i32);
let shown = composite_px(&s, probe.0, probe.1)?;
exec(&mut s, "layer.setProps", json!({"layer": curves, "visible": false}))?;
let hidden = composite_px(&s, probe.0, probe.1)?;
exec(&mut s, "layer.setProps", json!({"layer": curves, "visible": true}))?;
if shown == hidden {
return Err("path check: toggling the Curves changes no pixel inside its mask".into());
}
Ok((s, curves, probe))
}
/// #9329 PR B: the many-layer document with a small layer added directly above `mid`,
/// overlapping it. Returns the session, `mid` and the neighbour.
fn reorder_doc(w: u32, h: u32, n: usize) -> Res<(Session, u64, u64)> {
let (mut s, mid) = many_layers_doc(w, h, n)?;
let mb = layer_bounds(&s, mid)?;
exec(&mut s, "layer.select", json!({"layer": mid}))?;
exec(&mut s, "layer.new.layer", json!({"name": "neighbour"}))?;
let next = active_layer(&s)?;
exec(&mut s, "select.rect", json!({"x": mb.x0 + mb.size().width as i32 / 3, "y": mb.y0 + mb.size().height as i32 / 3, "width": w / 10, "height": h / 10}))?;
exec(&mut s, "edit.fill", json!({"color": "#3050d0"}))?;
exec(&mut s, "select.deselect", json!({}))?;
exec(&mut s, "layer.select", json!({"layer": mid}))?;
let doc = s.active().ok_or("no document")?.doc.clone();
let share = coverage(&doc, mb);
if share > 0.02 {
return Err(format!("path check: the layer covers {:.2} % of the canvas, not <= 2 %", share * 100.0));
}
if top_index(&s, next)? != top_index(&s, mid)? + 1 {
return Err("path check: the neighbour is not directly above the layer".into());
}
if layer_bounds(&s, next)?.intersect(&mb).is_empty() {
return Err("path check: the layer does not overlap the neighbour it passes".into());
}
Ok((s, mid, next))
}
/// #9329: the redraw area of edits where redrawing the whole document is the expensive cliff
/// (PR A: full-canvas layers; PR B: a masked adjustment mid-stack and a reorder). Each row checks it hits that path and prints `refresh px`.
fn redraw_area_scenarios(b: &mut Bench, sz: &Sizes) {
let undo = "undo a brush dab on a full-canvas 16-bit 15000x10000 layer + refresh";
if b.wanted(undo) {
@@ -1630,4 +1699,137 @@ fn redraw_area_scenarios(b: &mut Bench, sz: &Sizes) {
}
}
}
let toggle = "toggle a masked adjustment mid-stack in the layered document + refresh";
if b.wanted(toggle) {
b.current = Some(toggle.to_string());
let (w, h) = sz.layered;
match masked_curves_doc(w, h) {
Err(e) => b.errors.push((toggle.to_string(), e)),
Ok((mut s, curves, _)) => {
let _ = b.refresh(&s, true);
let reps = b.reps;
b.time(toggle, &mut s, reps, true, |b, s, i| {
let t = Instant::now();
exec(s, "layer.setProps", json!({"layer": curves, "visible": i % 2 == 1}))?;
let took = ms(t) + b.refresh(s, false)?;
b.px.borrow_mut().push(b.last_px.get());
Ok(took)
});
}
}
}
let reorder = "bring forward / send backward a small layer in the many-layer document + refresh";
if b.wanted(reorder) {
b.current = Some(reorder.to_string());
let (w, h) = sz.layered;
match reorder_doc(w, h, sz.many_layers) {
Err(e) => b.errors.push((reorder.to_string(), e)),
Ok((mut s, mid, _)) => {
let _ = b.refresh(&s, true);
let reps = b.reps;
b.time(reorder, &mut s, reps, true, |b, s, i| {
let before = top_index(s, mid)?;
let cmd = if i % 2 == 0 { "layer.arrange.bringForward" } else { "layer.arrange.sendBackward" };
let t = Instant::now();
exec(s, cmd, json!({"layer": mid}))?;
let took = ms(t) + b.refresh(s, false)?;
b.px.borrow_mut().push(b.last_px.get());
if top_index(s, mid)? == before {
return Err("path check: the layer's index did not change".into());
}
Ok(took)
});
}
}
}
let paste = "app frame after Paste Into on 24 MP (CPU canvas)";
if b.wanted(paste) {
let (w, h) = sz.big_photo;
paste_into_frame_scenario(b, paste, w, h);
}
}
/// #9329 PR C: the whole app (kittest harness, CPU canvas) with a 24 MP photo. Each sample runs
/// Paste Into a 5 % selection (untimed, after a frame that shows the selection), then times the
/// next frame, then undoes the paste and the selection (untimed). Paste Into is a compound command (several
/// revisions), so the frame's refresh goes through the real `damage_since` path.
fn paste_into_frame_scenario(b: &mut Bench, name: &str, w: u32, h: u32) {
// 0.224² ≈ 5 % of the canvas.
let sel = json!({"x": w / 2, "y": h / 2, "width": w * 224 / 1000, "height": h * 224 / 1000});
let built = open_photo(w, h).and_then(|mut s| {
exec(&mut s, "select.rect", json!({"x": w / 10, "y": h / 10, "width": w / 5, "height": h / 5}))?;
exec(&mut s, "edit.copy", json!({}))?;
Ok(s)
});
let mut s = match built {
Ok(s) => s,
Err(e) => return b.errors.push((name.into(), e)),
};
let doc = std::mem::take(&mut s);
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_max_steps(4).build_eframe(move |cc| {
photocraft_ui_egui::PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
photocraft_ui_egui::PhotocraftApp::new(doc, photocraft_ui_egui::Services::default())
});
// Warm up: first frames build the canvas cache of the 24 MP document.
harness.run_steps(6);
b.current = Some(name.to_string());
b.sampler.reset();
b.px.borrow_mut().clear();
let mut samples = Vec::new();
let run = (|| -> Res<()> {
let layers = |h: &egui_kittest::Harness<'_, photocraft_ui_egui::PhotocraftApp>| h.state().session.active().map(|d| d.doc.layers.len());
let base_layers = layers(&harness);
let past = |h: &egui_kittest::Harness<'_, photocraft_ui_egui::PhotocraftApp>| h.state().session.active().map(|d| d.history.past_len());
let base_past = past(&harness);
for i in 0..=b.reps.max(1) {
let rev = |h: &egui_kittest::Harness<'_, photocraft_ui_egui::PhotocraftApp>| h.state().session.active().map(|d| d.revision);
// Untimed: Paste Into turns the selection into the pasted layer's
// mask, so each sample re-creates the same ~5 % selection (the
// clipboard is not consumed), and shows it for a frame, as the app
// does between a selection and the next command. Without that frame
// the canvas would last have shown the revision before `select.rect`,
// two commands back, and redraw everything whatever Paste Into reports.
harness.state_mut().run("select.rect", sel.clone())?;
harness.step();
let before = rev(&harness).ok_or("no document")?;
harness.state_mut().run("edit.pasteSpecial.pasteInto", json!({}))?;
let after = rev(&harness).ok_or("no document")?;
// The path claim: a multi-revision command.
if after < before + 2 {
return Err(format!("path check: Paste Into moved revision by {}, not >= 2", after - before));
}
harness.state_mut().perf.last_refresh_px = 0;
let t = Instant::now();
harness.step();
let took = ms(t);
let px = harness.state().perf.last_refresh_px;
if px == 0 {
return Err("path check: the frame after Paste Into recorded no refresh".into());
}
// Untimed: undo the paste and then the selection (one history step each), so every
// sample pastes onto the same document rather than onto the layers earlier samples
// pasted, and starts from the same history rather than one more selection state per
// sample. The next sample's selection frame shows the undone document before its timer.
harness.state_mut().run("edit.undo", json!({}))?;
if layers(&harness) != base_layers {
return Err("path check: undoing Paste Into did not restore the layer stack".into());
}
harness.state_mut().run("edit.undo", json!({}))?;
if past(&harness) != base_past {
return Err("path check: undoing Paste Into and the selection did not restore the history".into());
}
// Sample 0 is the warm-up.
if i > 0 {
samples.push(took);
b.px.borrow_mut().push(px);
}
}
Ok(())
})();
// The rows' document handle: the harness's session.
let st = std::mem::take(&mut harness.state_mut().session);
b.record(name, &st, &samples, &[], run);
}
+11 -5
View File
@@ -8,13 +8,19 @@ impl PhotocraftApp {
}
/// Close this frame's stage record (`frame_stages`): keep it if it was slow, and hand it to
/// the stroke trace. `ui_ms` is the UI build's time.
pub(crate) fn finish_frame(&mut self, ui_ms: f64) {
let mut stages = std::mem::take(&mut self.perf.stage);
stages.ui_ms = ui_ms;
/// the stroke trace. `ui_ms` is this pass's UI build. A pass egui will run again
/// (`request_discard`, such as a sizing pass) leaves the record open, so a frame is one
/// record across all its passes.
pub(crate) fn finish_frame(&mut self, ctx: &egui::Context, ui_ms: f64) {
self.perf.stage.ui_ms += ui_ms;
self.perf.stage.stroking |= self.live_stroke.is_some();
if ctx.will_discard() {
return;
}
let stages = std::mem::take(&mut self.perf.stage);
let stages = self.slow_frames.finish(stages, gpu_canvas::now_ms(), self.perf.gpu, self.perf.gpu_ms);
let info = &self.perf.gpu_info;
self.stroke_trace.frame(&stages, self.live_stroke.is_some(), || {
self.stroke_trace.frame(&stages, stages.stroking, || {
let gpu = format!("{} {}", info.backend, info.adapter).trim().to_string();
if info.present.is_empty() { gpu } else { format!("{gpu}, {}", info.present) }
});
+34 -10
View File
@@ -75,6 +75,7 @@ impl PhotocraftApp {
vector_ui::finish_pen_if_left(self);
transform_tool::end_if_left(self);
shape_stroke_ui::sync_editor(self, ctx);
crate::dock::persist_pending(self, ctx);
prefs_ui::tick(self, ctx);
updates_ui::tick(self, ctx);
monitor_status::poll_frame(self, ctx, frame);
@@ -182,7 +183,7 @@ impl PhotocraftApp {
// No editor was drawn this pass: end its IME context rather than keep a stale owner.
type_tool::sync_ime(self, &ctx);
// The frame's stage record was started with its input: close it here too.
self.finish_frame(0.0);
self.finish_frame(&ctx, 0.0);
return;
}
let t0 = gpu_canvas::now_ms();
@@ -205,7 +206,7 @@ impl PhotocraftApp {
self.perf.record_gpu(t);
}
self.perf.frame(ui_ms);
self.finish_frame(ui_ms);
self.finish_frame(&ctx, ui_ms);
if !drawn {
return;
}
@@ -300,21 +301,44 @@ impl PhotocraftApp {
if chrome && self.ui.panels.status_bar && !home {
panels::status_bar(self, ui);
}
if ((chrome && self.ui.panels.toolbar) || tabbed_in) && !home {
panels::toolbar(self, ui);
}
let toolbar_shown = ((chrome && self.ui.panels.toolbar) || tabbed_in) && !home;
// craft-ui's layout holds the canvas as its protected `canvas` panel.
let docking = ((chrome && self.ui.panels.dock) || tabbed_in) && !home;
if docking {
panels::dock_area(self, ui);
} else {
let toolbar_side = self.ui.panels.toolbar_side.resolve();
let toolbar_right = toolbar_side == crate::state::ToolbarSide::Right;
// The compact layout stacks the dock, rail and toolbar on the dock's physical edge.
let dock_right = !crate::direction::is_rtl();
let toolbar_at_dock_edge = toolbar_right == dock_right;
// The dock-edge layout drops the canvas from craft-ui's split, which is only safe while
// nothing else is docked beside it. A panel already beside the canvas keeps the standard
// layout so it stays visible and dockable. The layout must not change once a drag starts:
// craft-ui records the areas on the press, and swapping the shape cancels the drag.
let dock_edge_layout = toolbar_shown && docking && toolbar_at_dock_edge && crate::panel_docking::canvas_alone(self);
if dock_edge_layout {
// Dock outermost, then its rail, then the toolbox, then the canvas: the toolbox sits
// between the canvas and the dock, not outside the dock at the window edge.
panels::dock_panel(self, ui);
panels::toolbar(self, ui);
let t = theme::Tokens::get(&ctx);
let backdrop = if chrome { prefs_ui::pasteboard_color(self).unwrap_or(t.canvas) } else { egui::Color32::BLACK };
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(backdrop)).show(ui, |ui| {
canvas::document_area(self, ui);
});
if !home {
panels::properties_window(self, &ctx);
} else {
if toolbar_shown {
panels::toolbar(self, ui);
}
if docking {
panels::dock_area(self, ui);
} else {
let t = theme::Tokens::get(&ctx);
let backdrop = if chrome { prefs_ui::pasteboard_color(self).unwrap_or(t.canvas) } else { egui::Color32::BLACK };
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(backdrop)).show(ui, |ui| {
canvas::document_area(self, ui);
});
if !home {
panels::properties_window(self, &ctx);
}
}
}
// The floating editor windows belong to the editor: Home covers them (dialogs and notices
@@ -166,16 +166,17 @@ fn wheel_steps_in_the_open_list_keep_the_chosen_mode_in_view() {
let mut h = harness(pixel_layer);
let at = dropdown(&h).center();
click(&mut h, at);
// Unscrolled, the list shows its first entry at the top and is at most 420 pt tall.
let (top, darken) = (entry(&h, "Normal").top(), entry(&h, "Darken").center());
// The open list, unscrolled, shows its first entry at the top.
let list = h.ctx.memory(|m| m.layer_ids().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id))).unwrap();
let darken = entry(&h, "Darken").center();
for _ in 0..22 {
wheel(&mut h, darken, -1.0);
}
h.run_steps(30);
assert_eq!(blend(&h), BlendMode::Divide);
let in_view = |r: Rect| r.top() >= top - 0.5 && r.bottom() <= top + 420.0 + 0.5;
let in_view = |r: Rect| list.contains_rect(r);
let chosen = entry(&h, "Divide");
assert!(in_view(chosen), "{chosen:?} in view below {top}");
assert!(in_view(chosen), "{chosen:?} in view in {list:?}");
// The wheel over the button scrolls the open list along too.
for _ in 0..21 {
wheel(&mut h, at, 1.0);
@@ -183,7 +184,7 @@ fn wheel_steps_in_the_open_list_keep_the_chosen_mode_in_view() {
h.run_steps(30);
assert_eq!(blend(&h), BlendMode::Dissolve);
let chosen = entry(&h, "Dissolve");
assert!(in_view(chosen), "{chosen:?} in view below {top}");
assert!(in_view(chosen), "{chosen:?} in view in {list:?}");
}
/// The mode the canvas previews on the active layer, if any (#970).
@@ -212,3 +213,32 @@ fn a_wheel_step_ends_the_hover_preview_until_the_pointer_moves() {
h.run_steps(3);
assert_eq!(previewed(&mut h), Some(BlendMode::Multiply), "a moved pointer previews again");
}
/// The open list's rect (the one foreground area: no other popup is open).
fn open_list(h: &Harness<'_, PhotocraftApp>) -> Option<Rect> {
h.ctx.memory(|m| m.layer_ids().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id)))
}
/// A plain click on the Layers panel's Blend Mode box, released where it was pressed (after one
/// frame, or after a few as a person's click takes), opens the list beside the box and chooses
/// nothing (#5495 review): the full-height list doesn't fit below the box low in the panel, and
/// it must not open over the box, where the release would pick the row under the pointer.
#[test]
fn a_click_on_the_blend_mode_box_opens_the_list_beside_it_and_changes_nothing() {
for held in [6, 1] {
let mut h = harness(pixel_layer);
let undo = steps(&h);
let button = dropdown(&h);
let at = button.center();
h.hover_at(at);
h.run_steps(1);
for (pressed, frames) in [(true, held), (false, 4)] {
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
h.run_steps(frames);
}
assert_eq!((blend(&h), steps(&h)), (BlendMode::Normal, undo), "held {held}: the click chose nothing");
let Some(list) = open_list(&h) else { panic!("held {held}: the list is open") };
assert!(!list.intersects(button.shrink(1.0)), "held {held}: the list {list:?} leaves the box {button:?} free");
assert!(list.top() >= 0.0 && list.bottom() <= 900.0, "held {held}: the list {list:?} is in the window");
}
}
@@ -0,0 +1,208 @@
//! The Brush Preset picker's gear menu: Photoshop's entries in its order with its separators, the
//! picker's defaults (Brush Tip off, Show Search Bar on) and which entries are greyed (#11819).
use egui::accesskit::Role;
use egui::vec2;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
use super::*;
use crate::state::Tool;
/// Photoshop's gear menu, measured top to bottom; "―" is a separator.
const MEASURED: &[&str] = &[
"New Brush Preset…",
"New Brush Group…",
"―",
"Rename Brush…",
"Delete Brush…",
"―",
"Brush Name",
"Brush Stroke",
"Brush Tip",
"―",
"Show Additional Preset Info",
"―",
"Show Search Bar",
"Show Recent Brushes",
"―",
"Append Default Brushes…",
"Import Brushes…",
"Export Selected Brushes…",
"―",
"Get More Brushes…",
"―",
"Converted Legacy Tool Presets",
"Legacy Brushes",
];
/// The entries a click works on with a preset selected; every other one is greyed.
const LIVE: &[&str] = &["New Brush Preset…", "Rename Brush…", "Delete Brush…", "Brush Name", "Brush Stroke", "Brush Tip", "Show Search Bar", "Import Brushes…"];
/// The entries that act on the selected preset.
const NEEDS_PRESET: &[&str] = &["Rename Brush…", "Delete Brush…", "Export Selected Brushes…"];
fn harness() -> Harness<'static, PhotocraftApp> {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.ui.tool = Tool::Brush;
app.sync_views();
let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui));
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(4);
h
}
/// [`harness`] with the first preset picked, as after a click on it.
fn harness_with_preset() -> Harness<'static, PhotocraftApp> {
let mut h = harness();
let first = h.state().session.tools.presets.first().map(|p| p.name.clone()).expect("the default presets");
h.state_mut().run("tools.setBrush", json!({ "preset": first })).unwrap();
h.run_steps(2);
h
}
/// Open the picker near the canvas's top left (room below for the whole menu), then its gear.
fn open_gear(h: &mut Harness<'static, PhotocraftApp>) {
let r = h.state().last_canvas_rect;
h.state_mut().ui.brush_picker = Some([r.left() + 20.0, r.top() + 10.0]);
h.run_steps(2);
h.get_by_label("Brush Preset Options").click();
h.run_steps(2);
}
fn entries() -> Vec<&'static str> {
GEAR_MENU.iter().filter_map(|e| e.map(Gear::label)).collect()
}
#[test]
fn the_gear_menu_lists_the_reference_entries_in_order_with_its_separators() {
let menu: Vec<&str> = GEAR_MENU.iter().map(|e| e.map_or("―", Gear::label)).collect();
assert_eq!(menu, MEASURED);
// The menu drawn: every entry once, top to bottom in that order.
let mut h = harness();
open_gear(&mut h);
let tops: Vec<f32> = entries().iter().map(|l| h.get_by_label(l).rect().top()).collect();
assert!(tops.windows(2).all(|w| w[0] < w[1]), "drawn top to bottom in the measured order: {tops:?}");
assert!(h.query_all_by_label("Delete Brush").next().is_none(), "Delete Brush… has its ellipsis");
}
#[test]
fn the_picker_starts_without_the_brush_tip_and_with_the_search_bar() {
let h = harness();
let st = &h.state().ui.brush_picker_list;
assert!(st.show_name && st.show_stroke && !st.show_tip, "Brush Name and Brush Stroke on, Brush Tip off");
assert!(st.show_search, "Show Search Bar is on");
// The Brushes panel keeps its own default.
assert!(h.state().ui.brushes_panel.show_tip);
// The menu shows the same, and the placeholders keep Photoshop's checks.
let mut h = harness();
open_gear(&mut h);
for (label, on) in [
("Brush Name", true),
("Brush Stroke", true),
("Brush Tip", false),
("Show Additional Preset Info", true),
("Show Search Bar", true),
("Show Recent Brushes", true),
] {
let checked = h.get_by_label(label).accesskit_node().toggled() == Some(egui::accesskit::Toggled::True);
assert_eq!(checked, on, "{label}");
}
}
#[test]
fn a_saved_brush_tip_still_wins_and_an_empty_card_gets_the_defaults_back() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.prefs.edit(|p| p.brush_picker = json!({"showTip": true, "showSearch": false}));
restore(&mut app);
assert!(app.ui.brush_picker_list.show_tip && !app.ui.brush_picker_list.show_search);
app.session.prefs.edit(|p| p.brush_picker = json!({"showName": false, "showStroke": false, "showTip": false}));
app.ui.brush_picker_list = list_state();
restore(&mut app);
let st = &app.ui.brush_picker_list;
assert_eq!((st.show_name, st.show_stroke, st.show_tip), (true, true, false), "the at-least-one-part rule restores the defaults");
}
#[test]
fn show_search_bar_hides_the_search_field_and_is_remembered() {
let mut h = harness();
open_gear(&mut h);
let fields = |h: &Harness<'static, PhotocraftApp>| h.query_all_by_role(Role::TextInput).count();
let shown = fields(&h);
h.state_mut().ui.brush_picker_list.filter = "zzz".into();
h.get_by_label("Show Search Bar").click();
h.run_steps(3);
assert!(!h.state().ui.brush_picker_list.show_search);
assert_eq!(fields(&h), shown - 1, "the search field goes");
assert!(h.state().ui.brush_picker_list.filter.is_empty(), "a hidden search filters nothing");
let ctx = h.ctx.clone();
persist(h.state_mut(), &ctx);
assert_eq!(h.state().session.prefs().brush_picker["showSearch"], json!(false));
h.get_by_label("Show Search Bar").click();
h.run_steps(3);
assert!(h.state().ui.brush_picker_list.show_search);
assert_eq!(fields(&h), shown, "and comes back");
}
#[test]
fn entries_without_a_function_and_preset_entries_without_a_preset_are_greyed() {
let mut h = harness_with_preset();
open_gear(&mut h);
for label in entries() {
assert_eq!(!h.get_by_label(label).accesskit_node().is_disabled(), LIVE.contains(&label), "{label} with a preset selected");
}
// A placeholder does nothing when clicked.
let before = (h.state().session.tools.presets.len(), h.state().ui.dialogs.len());
h.get_by_label("New Brush Group…").click();
h.run_steps(2);
assert_eq!((h.state().session.tools.presets.len(), h.state().ui.dialogs.len()), before);
// With no preset selected the preset's entries grey too.
let mut h = harness();
assert!(h.state().session.tools.current_preset.is_none(), "a fresh session has no preset picked");
open_gear(&mut h);
for label in entries() {
let live = LIVE.contains(&label) && !NEEDS_PRESET.contains(&label);
assert_eq!(!h.get_by_label(label).accesskit_node().is_disabled(), live, "{label} with no preset selected");
}
}
#[test]
fn delete_brush_still_deletes_the_selected_preset() {
let mut h = harness_with_preset();
let name = h.state().session.tools.current_preset.clone().expect("a preset is selected");
open_gear(&mut h);
h.get_by_label("Delete Brush…").click();
h.run_steps(3);
assert!(!h.state().session.tools.presets.iter().any(|p| p.name == name), "{name} is deleted");
}
#[test]
fn two_clicks_in_one_frame_still_leave_one_card_part_on() {
let mut h = harness();
open_gear(&mut h);
// Brush Tip is off; both remaining parts get an assistive-technology click in the same frame
// (kittest's own clicks each run a frame of their own, so the events go in by hand).
let clicks: Vec<_> = ["Brush Name", "Brush Stroke"]
.into_iter()
.map(|label| {
let (target_node, target_tree) = h.get_by_label(label).accesskit_node().locate();
egui::Event::AccessKitActionRequest(egui::accesskit::ActionRequest { target_node, target_tree, action: egui::accesskit::Action::Click, data: None })
})
.collect();
h.input_mut().events.extend(clicks);
h.run_steps(3);
let st = &h.state().ui.brush_picker_list;
assert_eq!((st.show_name, st.show_stroke, st.show_tip), (false, true, false), "the last part on stays on");
}
+149 -82
View File
@@ -63,8 +63,10 @@ pub mod zoom_window_fit;
#[cfg(test)]
pub(crate) use cursors::eyedropper_ring_colors;
pub(crate) use cursors::{alt_picks, alt_samples, composite_color, paint_bucket_cursor, pen_cursor, pencil_cursor_rect, pencil_tip_is_round, pipette_cursor};
use cursors::{brush_tip_centre, eyedropper_ring, precise_other_cursor, preview_locator_cursor, visible_crosshair};
pub(crate) use cursors::{
alt_picks, alt_samples, composite_color, paint_bucket_cursor, pen_cursor, pencil_cursor_rect, pencil_tip_is_round, pipette_cursor, precise_other_cursor,
};
use cursors::{brush_tip_centre, eyedropper_ring, preview_locator_cursor, visible_crosshair};
pub(crate) use display::GPU_OUTPUT;
use display::texture_key;
pub(crate) use display::{cache_key, canvas_display, display_doc, display_image};
@@ -883,6 +885,7 @@ struct CanvasToolInput<'a> {
hand_pan: bool,
ppp: f32,
under_dialog: bool,
menu_dismiss_press: bool,
zoom_out: &'a dyn Fn(bool) -> bool,
display_zoom: f32,
}
@@ -902,9 +905,22 @@ fn handle_primary_canvas_input(app: &mut PhotocraftApp, ui: &mut egui::Ui, view:
// (Preferences › Tools, `paint_mouse`).
crate::paint_mouse::sync_tool_brush(app);
let mut buttons = crate::paint_mouse::canvas_buttons(app, response, tool);
if input.menu_dismiss_press {
// Navigation and drags read `buttons` directly (zoom holds and drags, Rotate View):
// starve them. The release click stays armed, so the click handling below consumes
// it through the flag.
buttons.started = false;
buttons.dragged = false;
buttons.stopped = false;
}
// The Crop tool's frame is edited from the press (before egui's drag threshold), so the
// pixels past the canvas show at once, as in Photoshop.
if tool == Tool::Crop && response.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down()) && crate::crop_ui::press(app) {
if tool == Tool::Crop
&& !input.menu_dismiss_press
&& response.is_pointer_button_down_on()
&& ui.input(|i| i.pointer.primary_down())
&& crate::crop_ui::press(app)
{
ctx.request_repaint();
}
// Capture intent at the press, before egui's drag threshold or click replay on release:
@@ -913,6 +929,7 @@ fn handle_primary_canvas_input(app: &mut PhotocraftApp, ui: &mut egui::Ui, view:
tool.is_type() || matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee | Tool::ObjectSelection);
let tool_press = egui::Id::unique(("tool-pointer-press-modifiers", idx));
if capture_press
&& !input.menu_dismiss_press
&& let Some((p, press_mods)) = ui.input(|i| {
i.events.iter().find_map(|e| match e {
egui::Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers } if rect.contains(*pos) => Some((*pos, *modifiers)),
@@ -932,6 +949,17 @@ fn handle_primary_canvas_input(app: &mut PhotocraftApp, ui: &mut egui::Ui, view:
handle_canvas_tool_navigation(app, ui, view, input, &mut buttons);
handle_canvas_tool_stroke(app, ui, input, mods, capture_press, tool_press, &mut buttons);
handle_canvas_tool_clicks(app, ui, view, input, mods, tool_press, &buttons);
// Clear the menu-dismissal flag on the gesture's pointer release, even when the release
// occurs outside the canvas and produces no `buttons.clicked` event. It sits below (not
// above) the click handling, so a release over the canvas in a later frame is first
// consumed by the flag check there: clearing before it would let that release zoom
// (CodeRabbit, #9841). The all-buttons-up check also covers a release that landed in a
// frame this canvas didn't render.
if app.ui.canvas_tool_menu_dismissed
&& (ui.input(|i| i.pointer.primary_released()) || ui.input(|i| i.pointer.secondary_released()) || !ui.input(|i| i.pointer.any_down()))
{
app.ui.canvas_tool_menu_dismissed = false;
}
}
fn handle_canvas_tool_menus(app: &mut PhotocraftApp, input: &CanvasToolInput<'_>, mods: egui::Modifiers, buttons: &mut crate::paint_mouse::Buttons) {
@@ -958,8 +986,11 @@ fn handle_canvas_tool_menus(app: &mut PhotocraftApp, input: &CanvasToolInput<'_>
};
// The Lasso (and the Freeform Pen, which draws the same way) reads its own presses and
// drops the moves of a drag it didn't start, so an open Free Transform box takes the
// usual drag path instead (#2153).
if crate::lasso_ui::owns(tool) && !transform_owns_pointer {
// usual drag path instead (#2153). It reads the raw pointer events straight from the
// context, so the `buttons` starvation above never reaches it: a press that dismisses the
// canvas tool menu would draw a selection. Gate the dispatcher itself, as the Crop press
// above is gated (#9841).
if crate::lasso_ui::owns(tool) && !transform_owns_pointer && !input.menu_dismiss_press {
crate::lasso_ui::canvas_input(app, ctx, xf, response);
(buttons.started, buttons.dragged, buttons.stopped, buttons.clicked) = (false, false, false, false);
}
@@ -1047,10 +1078,11 @@ fn handle_canvas_tool_navigation(
(buttons.started, buttons.dragged, buttons.stopped, buttons.clicked) = (false, false, false, false);
}
// Animated Zoom: a Zoom press held still zooms continuously (zoom_hold.rs); while it does,
// it owns the press: no scrub, and its release is no click step.
// it owns the press: no scrub, and its release is no click step. A menu-dismissal press
// must not arm it: holding the dismissal click would zoom (CodeRabbit, #9841).
if tool == Tool::Zoom && !transform_owns_pointer {
let out = zoom_out(ui.input(|i| i.modifiers.alt));
let on_canvas = !input.under_dialog && !over_compass && response.is_pointer_button_down_on();
let on_canvas = !input.menu_dismiss_press && !input.under_dialog && !over_compass && response.is_pointer_button_down_on();
let hold = crate::zoom_hold::hold_id(idx, primary, ctx.viewport_id());
if crate::zoom_hold::update(ctx, view, xf, app.session.prefs().general.animated_zoom, out, ppp, on_canvas, hold) {
(buttons.started, buttons.dragged, buttons.stopped, buttons.clicked) = (false, false, false, false);
@@ -1092,6 +1124,7 @@ fn handle_canvas_tool_stroke(
let right_down = ui.input(|i| i.pointer.secondary_down()) || physical_right;
if strokes_live(tool)
&& !right_down
&& !input.menu_dismiss_press
&& app.drag.is_none()
&& response.is_pointer_button_down_on()
&& ui.input(|i| i.pointer.primary_pressed())
@@ -1111,6 +1144,7 @@ fn handle_canvas_tool_stroke(
buttons.started = false;
}
if buttons.started
&& !input.menu_dismiss_press
&& let Some(p) = ui.input(|i| i.pointer.press_origin()).filter(|p| rect.contains(*p)).or(response.interact_pointer_pos())
{
// A press the controls refused (the Background among the layers) goes nowhere else (#11101).
@@ -1125,7 +1159,7 @@ fn handle_canvas_tool_stroke(
// recognises a drag (past its click distance): otherwise the first few points are lost
// and the stroke starts with a straight segment and no pressure ramp.
let press_stroke_moving = press_stroke && app.drag.is_some();
if buttons.dragged || buttons.stopped || press_stroke_moving {
if !input.menu_dismiss_press && (buttons.dragged || buttons.stopped || press_stroke_moving) {
// Feed every pointer move the OS delivered this frame, not just the latest position, so
// a fast stroke is sampled densely and renders as a smooth curve instead of a coarse
// polyline. egui-winit pushes one `PointerMoved` per `CursorMoved`, and they accumulate
@@ -1183,7 +1217,7 @@ fn handle_canvas_tool_stroke(
feed_live_stroke(app);
crate::magnetic_lasso_ui::flush(app);
}
if buttons.stopped {
if buttons.stopped && !input.menu_dismiss_press {
// The live painting press already created the stroke and this release commits it.
// egui may also report clicked on the same release; replaying that click paints a
// second full-pressure dab over the real (possibly light-pressure) pen dab.
@@ -1208,7 +1242,7 @@ fn handle_canvas_tool_stroke(
}
// A stroke started on the press ends with the release, whether egui saw a drag, a click,
// or neither (a long press that never moved).
if press_stroke && !buttons.stopped && ui.input(|i| i.pointer.primary_released()) {
if press_stroke && !buttons.stopped && !input.menu_dismiss_press && ui.input(|i| i.pointer.primary_released()) {
buttons.clicked = false;
let p = response.interact_pointer_pos().map(|p| xf.to_doc(p)).or_else(|| app.drag.as_ref().and_then(|d| d.points.last().map(|q| [q[0], q[1]])));
if let Some(d) = p
@@ -1241,66 +1275,17 @@ fn handle_canvas_tool_clicks(
let ppp = input.ppp;
let zoom_out = input.zoom_out;
let display_zoom = input.display_zoom;
if buttons.clicked
&& let Some(p) = response.interact_pointer_pos()
// Magnetic Lasso: between clicks the border follows the pointer with the button up. This is a
// pointer-only gesture — `canvas_buttons` makes `clicked` false on frames without a button
// event — so it must stay outside the click-only branch below, or live edge tracking and
// automatic fastening freeze until the next click. A menu-dismissal gesture is inert, so the
// hover is suppressed with the same flag that starves the rest of the input pass (#9841).
if tool == Tool::MagneticLasso
&& app.ui.magnetic.active()
&& !input.menu_dismiss_press
&& !(buttons.started || buttons.dragged || buttons.stopped)
&& response.hovered()
{
let click_button = if app.secondary_erase { PointerButton::Secondary } else { PointerButton::Primary };
let click_mods = ui.input(|i| pointer_button_modifiers(&i.events, click_button)).unwrap_or(mods);
let click_mods = if tool == Tool::ObjectSelection && click_button == PointerButton::Primary {
ctx.data(|d| d.get_temp(tool_press)).unwrap_or(click_mods)
} else {
click_mods
};
let d = xf.to_doc(p);
match tool {
Tool::Zoom if !transform_owns_pointer => {
let nz = crate::zoom_levels::step(view.zoom, if zoom_out(click_mods.alt) { -1 } else { 1 }, view.doc_size);
let center = app.session.prefs().tools.zoom_clicked_point_to_center;
let doc_anchor = xf.to_doc(p);
let screen_anchor = p;
zoom_about(view, xf, p, nz, center, ppp);
view.request_zoom_animation(app.session.prefs().general.animated_zoom && nz != display_zoom);
view.zoom_anchor = (view.zoom_animating && !center).then_some((doc_anchor, [screen_anchor.x, screen_anchor.y]));
}
// A click with the (temporary) Hand does nothing, never the tool underneath.
Tool::Hand | Tool::RotateView => {}
// Double-clicking closes the polygonal lasso: the first click placed the last
// vertex (or already closed it), so the second never starts a new polygon. egui
// reports it as a triple click when a vertex went down shortly before.
Tool::PolygonLasso if response.double_clicked() || response.triple_clicked() => commit_polygon(app),
// The same for the Magnetic Lasso: along the edges, or straight with ⌥.
Tool::MagneticLasso if response.double_clicked() || response.triple_clicked() => crate::magnetic_lasso_ui::close(app, mods.alt),
// The first click placed (or grabbed) the point; the second toggles it.
Tool::CurvaturePen if response.double_clicked() => crate::vector_ui::curvature_toggle(app, d[0], d[1]),
// Double-clicking a guide opens Preferences › Guides, Grid & Slices, as in Photoshop.
Tool::Move if response.double_clicked() && crate::rulers::guide_at(app, d[0], d[1]).is_some() => {
if let Err(e) = crate::menus::invoke(app, ctx, "edit.preferences.guidesGridAndSlices", json!({})) {
app.ui.status = e;
}
}
// A click the transform controls refused (the Background among the layers) goes
// nowhere else (#11101).
_ if tool == Tool::Move && app.ui.transform.is_none() && begin_transform_controls_at(app, ctx, xf, p) == ControlsPress::Refused => {}
_ => {
// If this tool was not handled on pointer-down, fall back to a
// click-sized gesture while preserving pen pressure (mouse stays at 1).
tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, click_mods);
tap_at_peak_pressure(app);
tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, click_mods);
}
}
}
// A Frame drag uses a temporary Free Transform for its image/both target. That owns
// the release, so the generic tool-click gate can be false even for a double click.
if tool == Tool::Frame
&& response.contains_pointer()
&& ctx.input(|i| i.pointer.button_double_clicked(PointerButton::Primary))
&& let Some(p) = response.interact_pointer_pos().or_else(|| ctx.pointer_hover_pos())
{
crate::frame_tool::double_click(app, xf.to_doc(p));
}
// Magnetic Lasso: between clicks the border follows the pointer with the button up.
if tool == Tool::MagneticLasso && app.ui.magnetic.active() && !(buttons.started || buttons.dragged || buttons.stopped) && response.hovered() {
let moves: Vec<Pos2> = ui.input(|i| i.events.iter().filter_map(|e| if let egui::Event::PointerMoved(p) = e { Some(*p) } else { None }).collect());
app.defer_live_stroke = true;
for p in moves.into_iter().filter(|p| rect.contains(*p)) {
@@ -1310,17 +1295,87 @@ fn handle_canvas_tool_clicks(
app.defer_live_stroke = false;
crate::magnetic_lasso_ui::flush(app);
}
// Move tool: a double-click on type edits it, as in Photoshop (#1132). Only the selected
// Move tool, not ⌘ held over another tool; an open transform box keeps its double-click
// (commit, below).
if tool == Tool::Move
&& app.ui.tool == Tool::Move
&& app.ui.transform.is_none()
&& response.double_clicked()
if buttons.clicked
&& let Some(p) = response.interact_pointer_pos()
{
let d = xf.to_doc(p);
crate::type_tool::edit_at(app, d[0], d[1]);
// If an outside press dismissed the canvas tool menu, consume this release click
// so it doesn't also trigger a tool action (Photoshop behavior). A click outside a
// still-open menu dismisses it and is consumed the same way (CodeRabbit, #9841).
if app.ui.canvas_tool_menu_dismissed {
app.ui.canvas_tool_menu_dismissed = false;
} else if let Some(menu_rect) = crate::canvas_tool_menu::menu_rect_if_open(app, ctx)
&& !menu_rect.contains(p)
{
// The click is outside the open menu: it dismisses the menu, no tool action.
} else {
let click_button = if app.secondary_erase { PointerButton::Secondary } else { PointerButton::Primary };
let click_mods = ui.input(|i| pointer_button_modifiers(&i.events, click_button)).unwrap_or(mods);
let click_mods = if tool == Tool::ObjectSelection && click_button == PointerButton::Primary {
ctx.data(|d| d.get_temp(tool_press)).unwrap_or(click_mods)
} else {
click_mods
};
let d = xf.to_doc(p);
match tool {
Tool::Zoom if !transform_owns_pointer => {
let nz = crate::zoom_levels::step(view.zoom, if zoom_out(click_mods.alt) { -1 } else { 1 }, view.doc_size);
let center = app.session.prefs().tools.zoom_clicked_point_to_center;
let doc_anchor = xf.to_doc(p);
let screen_anchor = p;
zoom_about(view, xf, p, nz, center, ppp);
view.request_zoom_animation(app.session.prefs().general.animated_zoom && nz != display_zoom);
view.zoom_anchor = (view.zoom_animating && !center).then_some((doc_anchor, [screen_anchor.x, screen_anchor.y]));
}
// A click with the (temporary) Hand does nothing, never the tool underneath.
Tool::Hand | Tool::RotateView => {}
// Double-clicking closes the polygonal lasso: the first click placed the last
// vertex (or already closed it), so the second never starts a new polygon. egui
// reports it as a triple click when a vertex went down shortly before.
Tool::PolygonLasso if response.double_clicked() || response.triple_clicked() => commit_polygon(app),
// The same for the Magnetic Lasso: along the edges, or straight with ⌥.
Tool::MagneticLasso if response.double_clicked() || response.triple_clicked() => crate::magnetic_lasso_ui::close(app, mods.alt),
// The first click placed (or grabbed) the point; the second toggles it.
Tool::CurvaturePen if response.double_clicked() => crate::vector_ui::curvature_toggle(app, d[0], d[1]),
// Double-clicking a guide opens Preferences › Guides, Grid & Slices, as in Photoshop.
Tool::Move if response.double_clicked() && crate::rulers::guide_at(app, d[0], d[1]).is_some() => {
if let Err(e) = crate::menus::invoke(app, ctx, "edit.preferences.guidesGridAndSlices", json!({})) {
app.ui.status = e;
}
}
// A click the transform controls refused (the Background among the layers) goes
// nowhere else (#11101).
_ if tool == Tool::Move && app.ui.transform.is_none() && begin_transform_controls_at(app, ctx, xf, p) == ControlsPress::Refused => {}
_ => {
// If this tool was not handled on pointer-down, fall back to a
// click-sized gesture while preserving pen pressure (mouse stays at 1).
tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, click_mods);
tap_at_peak_pressure(app);
tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, click_mods);
}
}
}
// Move tool: a double-click on type edits it, as in Photoshop (#1132). Only the selected
// Move tool, not ⌘ held over another tool; an open transform box keeps its double-click
// (commit, below).
if tool == Tool::Move
&& app.ui.tool == Tool::Move
&& app.ui.transform.is_none()
&& response.double_clicked()
&& let Some(p) = response.interact_pointer_pos()
{
let d = xf.to_doc(p);
crate::type_tool::edit_at(app, d[0], d[1]);
}
}
// A Frame drag uses a temporary Free Transform for its image/both target. That owns
// the release, so the generic tool-click gate can be false even for a double click.
if tool == Tool::Frame
&& response.contains_pointer()
&& ctx.input(|i| i.pointer.button_double_clicked(PointerButton::Primary))
&& let Some(p) = response.interact_pointer_pos().or_else(|| ctx.pointer_hover_pos())
{
crate::frame_tool::double_click(app, xf.to_doc(p));
}
// A double-click commits an open Free Transform only on the box (handles included); outside
// it does nothing (#11100).
@@ -2138,6 +2193,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// box is only drawn and driven on the primary canvas.
let transform_owns_pointer = primary && app.ui.transform.is_some() && temporary.is_none() && !middle;
let hand_pan = tool == Tool::Hand && !transform_owns_pointer;
// A press outside an open canvas tool menu dismisses it (`canvas_tool_menu::show`, after this
// input pass) and the whole gesture is consumed until its release, as in Photoshop: no tool
// press, stroke, drag, pan or zoom step. The menu closes mid-frame (after this input pass), so
// the open-menu hit test covers the dismissal frame, and the flag persists the block for the
// frames after it, until the release clear below the click handling. It gates the Hand and
// flick pans below too: they read `response.dragged()` directly and never see `buttons`
// (CodeRabbit, #9841).
let menu_dismiss_press = app.ui.canvas_tool_menu_dismissed
|| crate::canvas_tool_menu::menu_rect_if_open(app, &ctx).is_some_and(|r| ui.input(|i| i.pointer.press_origin()).is_some_and(|p| !r.contains(p)));
// The Hand pans with a drag of any button, but the selected Hand's right button is its view
// menu, as in Photoshop: a drag with that button alone is a right-click that moved, which
// opens the menu instead, until it strays past a right-click's reach and pans after all
@@ -2164,9 +2228,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// other shown views follow this one.
let scroll_all = hand_pan && crate::scroll_all_windows::active(app, crate::workspace_ui::sticky_mods(app, ctx.input(|i| i.modifiers)).shift);
let scroll_source = crate::scroll_all_windows::source(app, idx);
// A glide keeps the drag's choice after Space, Shift or the button is let go.
let glide_scroll_all = crate::scroll_all_windows::glide_choice(&ctx, flick_id.with("scroll-all"), hand_drag && !stroke_dismissal, scroll_all);
if let Some(d) = crate::flick_pan::frame(&ctx, flick_id, hand_drag && !stroke_dismissal, response.drag_delta(), flick_on) {
// A glide keeps the drag's choice after Space, Shift or the button is let go; a menu-dismissal
// press is no pan, so it neither arms the glide nor moves the other views.
let glide_scroll_all =
crate::scroll_all_windows::glide_choice(&ctx, flick_id.with("scroll-all"), hand_drag && !stroke_dismissal && !menu_dismiss_press, scroll_all);
if let Some(d) = crate::flick_pan::frame(&ctx, flick_id, hand_drag && !stroke_dismissal && !menu_dismiss_press, response.drag_delta(), flick_on) {
let d = xf.unmap_vec(d) / (view.zoom / ppp);
let before = view.center;
if d.is_finite() {
@@ -2184,7 +2250,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
// A scroll or zoom while the right button is held moves the image under its press: no click.
crate::paint_mouse::track_right_press_view(&response, &view, flip);
if hand_drag && !stroke_dismissal {
if hand_drag && !stroke_dismissal && !menu_dismiss_press {
let d = xf.unmap_vec(response.drag_delta()) / (view.zoom / ppp);
let before = view.center;
view.center[0] -= d.x;
@@ -2209,6 +2275,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
hand_pan,
ppp,
under_dialog,
menu_dismiss_press,
zoom_out: &zoom_out,
display_zoom: display_view.zoom,
};
@@ -55,6 +55,10 @@ pub struct CanvasToolMenu {
/// becomes active, the index may name a different sampler, so the menu closes and Delete greys.
#[serde(default)]
pub sampler_at: Option<(usize, [f64; 2])>,
/// The Eyedropper's menu (#11747): the composite colour under the right-click, averaged with
/// the Sample Size, which its Copy Color rows put on the clipboard. `None` over transparency.
#[serde(default)]
pub color: Option<[f32; 3]>,
}
/// A misspelled word in the type layer being edited (`edit.checkSpelling`'s `list` entry).
@@ -314,6 +318,7 @@ pub fn open_crop(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32;
crop: Some(users),
sampler: None,
sampler_at: None,
color: None,
});
crate::canvas_popup::mark_open(ctx, menu_id());
true
@@ -430,6 +435,43 @@ pub const NO_SELECTION_MENU: &[Row] = &[
Some(("Load Selection…", "select.loadSelection")),
];
/// The Eyedropper's copy rows (#11747).
pub const EYEDROPPER_COPY_HTML: &str = "canvasMenu.eyedropper.copyHtml";
pub const EYEDROPPER_COPY_HEX: &str = "canvasMenu.eyedropper.copyHex";
/// The Eyedropper's canvas menu, in the reference application's order (#11747): the Sample Sizes
/// (the current one checked), then the two Copy Color rows for the colour under the pointer.
pub const EYEDROPPER_MENU: &[Row] = &[
Some(("Point Sample", "canvasMenu.eyedropper.size.1")),
Some(("3 by 3 Average", "canvasMenu.eyedropper.size.3")),
Some(("5 by 5 Average", "canvasMenu.eyedropper.size.5")),
Some(("11 by 11 Average", "canvasMenu.eyedropper.size.11")),
Some(("31 by 31 Average", "canvasMenu.eyedropper.size.31")),
Some(("51 by 51 Average", "canvasMenu.eyedropper.size.51")),
Some(("101 by 101 Average", "canvasMenu.eyedropper.size.101")),
None,
Some(("Copy Color as HTML", EYEDROPPER_COPY_HTML)),
Some(("Copy Color's Hex Code", EYEDROPPER_COPY_HEX)),
];
/// The Sample Size an Eyedropper menu row sets.
fn eyedropper_size(command: &str) -> Option<u32> {
command.strip_prefix("canvasMenu.eyedropper.size.")?.parse().ok()
}
/// Is `menu` the Eyedropper's own menu (not the pasteboard, guide, transform or crop menu it can
/// also open)?
fn is_eyedropper(menu: &CanvasToolMenu) -> bool {
menu.tool == Tool::Eyedropper
&& menu.crop.is_none()
&& !menu.guide
&& !menu.pasteboard
&& !menu.transform
&& menu.anchor.is_none()
&& !menu.text
&& !menu.type_layer
}
/// Photoshop's Pen context menu order. Rows whose operation is not applicable to the current
/// path or layer stay visible and disabled.
pub const PEN_MENU: &[Row] = &[
@@ -524,6 +566,8 @@ pub fn applies(tool: Tool) -> bool {
// selection menu, as in Photoshop (#7268).
| Tool::QuickSelection
| Tool::ColorSampler
// Sample Size and Copy Color (#11747).
| Tool::Eyedropper
) || path_menu(tool)
}
@@ -575,6 +619,8 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] {
PEN_MENU
} else if sampler_menu(menu) {
SAMPLER_MENU
} else if menu.tool == Tool::Eyedropper {
EYEDROPPER_MENU
} else {
selection_rows(menu.has_selection)
}
@@ -601,6 +647,9 @@ pub(crate) fn mode_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command:
if menu.text || menu.type_layer {
return type_checked(app, command);
}
if is_eyedropper(menu) {
return eyedropper_size(command) == Some(app.ui.tool_options.eyedropper_size);
}
menu.transform
&& matches!(command, "edit.freeTransform" | "edit.transform.rotate" | "edit.transform.skew" | "edit.transform.distort" | "edit.transform.perspective")
&& app.ui.transform.as_ref().is_some_and(|t| t.mode == crate::state::TransformMode::for_command(command))
@@ -716,6 +765,7 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) ->
crop: None,
sampler: None,
sampler_at: None,
color: None,
});
crate::canvas_popup::mark_open(ctx, menu_id());
true
@@ -799,6 +849,7 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos:
crop: None,
sampler: None,
sampler_at: None,
color: None,
});
crate::canvas_popup::mark_open(ctx, menu_id());
true
@@ -832,6 +883,7 @@ pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui
crop: None,
sampler: None,
sampler_at: None,
color: None,
});
crate::canvas_popup::mark_open(ctx, menu_id());
true
@@ -866,6 +918,9 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str)
if menu.pasteboard {
return PASTEBOARD_MENU.iter().flatten().any(|(_, id)| *id == command);
}
if is_eyedropper(menu) {
return eyedropper_size(command).is_some() || (matches!(command, EYEDROPPER_COPY_HTML | EYEDROPPER_COPY_HEX) && menu.color.is_some());
}
// Warp needs a layer, selection or path box: a lone mask or channel keeps the plain box.
if menu.transform && command == "edit.transform.warp" {
return app.ui.transform.as_ref().is_some_and(|t| t.warp.is_none() && t.target.is_none());
@@ -950,6 +1005,9 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]
let spelling = if text { doc.and_then(|d| spelling_at(app, d)) } else { None };
let sampler = if tool == Tool::ColorSampler { doc.and_then(|d| crate::analysis_ui::sampler_at(app, d)) } else { None };
let sampler_at = sampler.and_then(|i| Some((app.session.active_index()?, *app.session.active()?.color_samplers.get(i)?)));
// The Eyedropper reads the colour now, under the right-click: all layers, with the Sample
// Size, whatever its Sample setting (#11747). Picking a row later copies this colour.
let color = if tool == Tool::Eyedropper && !text && !type_layer { doc.and_then(|d| crate::canvas::composite_color(app, d[0], d[1])) } else { None };
app.ui.canvas_tool_menu = Some(CanvasToolMenu {
pos,
tool,
@@ -966,6 +1024,7 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]
crop: None,
sampler,
sampler_at,
color,
});
crate::canvas_popup::mark_open(ctx, menu_id());
true
@@ -995,6 +1054,7 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32;
crop: None,
sampler: None,
sampler_at: None,
color: None,
});
crate::canvas_popup::mark_open(ctx, menu_id());
true
@@ -1054,6 +1114,8 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) {
} else if sampler_menu(&menu) {
let (id, params) = engine_call(app, &menu, command);
app.run(&id, params).map(|_| ())
} else if is_eyedropper(&menu) {
choose_eyedropper(app, ctx, &menu, command)
} else if command == "select.toWorkPath" {
// Photoshop's Make Work Path… asks for the tolerance first.
spec_dialog(app, command, "Make Work Path", r##"{"tolerance":0.5..10=2}"##, serde_json::Map::new());
@@ -1067,6 +1129,20 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) {
}
}
/// An Eyedropper row (#11747): a size sets the Sample Size (shared with the options bar); a Copy
/// Color row puts the colour read at the right-click on the clipboard, as the Color panel's menu
/// does. Neither touches the foreground colour or the history.
fn choose_eyedropper(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, command: &str) -> Result<(), String> {
if let Some(size) = eyedropper_size(command) {
app.ui.tool_options.eyedropper_size = size;
return Ok(());
}
let rgb = menu.color.ok_or("No colour under the pointer")?;
let text = if command == EYEDROPPER_COPY_HTML { crate::color_panel_ui::html_color(rgb) } else { crate::color_panel_ui::hex_code(rgb) };
ctx.copy_text(text);
Ok(())
}
/// A spelling row: replace the word under the pointer (the caret lands after the replacement), or
/// add it to the user dictionary.
fn choose_spelling(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> Result<(), String> {
@@ -1312,9 +1388,25 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) {
choose(app, ctx, &command);
} else if crate::canvas_popup::outside_pressed(ctx, id, area.response.rect) {
app.ui.canvas_tool_menu = None;
// A fast click can press and release in one frame: the canvas input above already
// suppressed its action through the open-menu hit test, and the deferred release clear
// (in `canvas_view`, below the click handling) would never fire after this frame. Don't
// arm the flag then, or it would eat the next ordinary click (CodeRabbit, #9841).
app.ui.canvas_tool_menu_dismissed = !ctx.input(|i| i.pointer.any_released());
}
}
/// Returns the menu rect if the canvas tool menu is currently open, for hit-testing
/// outside-click dismissal in the click handler.
pub fn menu_rect_if_open(app: &PhotocraftApp, ctx: &Context) -> Option<egui::Rect> {
let menu = app.ui.canvas_tool_menu.as_ref()?;
let id = menu_id();
let screen = ctx.content_rect();
let size = ctx.memory(|m| m.area_rect(id)).map_or(egui::vec2(210.0, 140.0), |r| r.size());
let pos = egui::pos2(menu.pos[0].min(screen.right() - size.x).max(screen.left()), menu.pos[1].min(screen.bottom() - size.y).max(screen.top()));
Some(egui::Rect::from_min_size(pos, size))
}
#[cfg(test)]
mod tests;
@@ -550,6 +550,143 @@ fn zoom_and_hand_right_click_opens_the_view_menu() {
}
}
/// A zoom harness with a fixed camera: zoom 1.0 centred on the 400×300 document. `animated` is
/// Preferences › General › Animated Zoom; each step is 0.1 s of egui time, so a hold can pass
/// `zoom_hold::DELAY` within a few steps.
fn zoom_harness(animated: bool) -> Harness<'static, PhotocraftApp> {
let mut app = zoom_app(400, 300);
app.ui.tool = Tool::Zoom;
app.run("prefs.set", json!({"values": {"general.animatedZoom": animated, "tools.zoomClickedPointToCenter": false, "tools.overscroll": true}})).unwrap();
app.sync_views();
app.ui.extras.rulers = false;
let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.1).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui));
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default());
h.run_steps(4);
let view = &mut h.state_mut().ui.views[0];
view.zoom = 1.0;
view.center = [200.0, 150.0];
view.fit_pending = false;
view.fill_pending = false;
h.run_steps(2);
h
}
/// The menu opens at `[300, 100]` (doc points); this press lands 250 doc px to its left: outside
/// the 200 px-wide menu, on the canvas.
fn dismissal_point(h: &Harness<'static, PhotocraftApp>) -> Pos2 {
screen_of(h.state(), [50.0, 250.0])
}
/// #9832: a left press outside the open view menu dismisses it, and the whole gesture is
/// consumed — the press, the held press, and the release never zoom, and the next ordinary
/// click zooms again.
#[test]
fn dismissing_the_view_menu_consumes_the_whole_gesture() {
let mut h = zoom_harness(false);
let zoom = h.state().ui.views[0].zoom;
let open_at = screen_of(h.state(), [300.0, 100.0]);
right_click(&mut h, open_at, Modifiers::NONE);
assert!(h.state().ui.canvas_tool_menu.is_some(), "right-click opens the view menu");
// The dismissal press: the menu goes, the zoom does not.
let far = dismissal_point(&h);
h.event(egui::Event::PointerMoved(far));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: far, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
assert!(h.state().ui.canvas_tool_menu.is_none(), "the outside press dismisses the menu");
assert_eq!(h.state().ui.views[0].zoom, zoom, "the dismissal press does not zoom");
// Holding it (a long press releases as a click) still does nothing…
h.run_steps(2);
assert_eq!(h.state().ui.views[0].zoom, zoom, "a held dismissal press does not zoom");
// …and the release over the canvas is consumed: no zoom step.
h.event(egui::Event::PointerButton { pos: far, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().ui.views[0].zoom, zoom, "the dismissal release does not zoom");
// The next ordinary click zooms again: the gesture flag did not stick.
h.event(egui::Event::PointerButton { pos: far, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: far, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().ui.views[0].zoom, crate::zoom_levels::step(zoom, 1, h.state().ui.views[0].doc_size), "the next ordinary click zooms");
}
/// #9832: a menu-dismissal press held past `zoom_hold::DELAY` must not arm the animated zoom
/// hold: holding the dismissal click would otherwise zoom continuously.
#[test]
fn a_held_menu_dismissal_press_never_arms_the_zoom_hold() {
let mut h = zoom_harness(true);
let zoom = h.state().ui.views[0].zoom;
let open_at = screen_of(h.state(), [300.0, 100.0]);
right_click(&mut h, open_at, Modifiers::NONE);
assert!(h.state().ui.canvas_tool_menu.is_some(), "right-click opens the view menu");
let far = dismissal_point(&h);
h.event(egui::Event::PointerMoved(far));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: far, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
assert!(h.state().ui.canvas_tool_menu.is_none(), "the outside press dismisses the menu");
// 0.8 s held still, well past the 0.3 s hold delay: every frame stays at the start zoom.
for i in 0..8 {
h.run_steps(1);
assert_eq!(h.state().ui.views[0].zoom, zoom, "held dismissal press frame {i} must not zoom");
}
h.event(egui::Event::PointerButton { pos: far, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().ui.views[0].zoom, zoom, "the dismissal release does not zoom either");
}
/// #9832 (CodeRabbit): while the dismissal press stays down, the temporary Hand (Space) and the
/// selected Hand must not pan either — the consumed gesture is inert until its release, and the
/// pans read `response.dragged()` directly, so they need the gate up front.
#[test]
fn a_held_menu_dismissal_press_cannot_pan_the_view() {
fn key(h: &mut Harness<'static, PhotocraftApp>, k: egui::Key, pressed: bool) {
h.event(egui::Event::Key { key: k, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
}
let mut h = zoom_harness(false);
let (zoom, center0) = (h.state().ui.views[0].zoom, h.state().ui.views[0].center);
let open_at = screen_of(h.state(), [300.0, 100.0]);
right_click(&mut h, open_at, Modifiers::NONE);
assert!(h.state().ui.canvas_tool_menu.is_some(), "right-click opens the view menu");
// The dismissal press: the menu goes, and the button stays down.
let far = dismissal_point(&h);
h.event(egui::Event::PointerMoved(far));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: far, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
assert!(h.state().ui.canvas_tool_menu.is_none(), "the outside press dismisses the menu");
// Hold Space: the temporary Hand arms, but dragging must not pan the view.
key(&mut h, egui::Key::Space, true);
assert_eq!(crate::input::hold_keys::held_tool(h.state(), &h.ctx), Some(crate::input::hold_keys::Temporary::Hand), "Space arms the temporary hand");
h.event(egui::Event::PointerMoved(far + vec2(120.0, -60.0)));
h.run_steps(2);
h.event(egui::Event::PointerMoved(far + vec2(240.0, -120.0)));
h.run_steps(2);
assert_eq!(h.state().ui.views[0].center, center0, "a held dismissal press must not pan the view");
assert_eq!(h.state().ui.views[0].zoom, zoom);
// Release both: an ordinary Hand drag pans again, the flag did not stick.
h.event(egui::Event::PointerButton { pos: far, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
key(&mut h, egui::Key::Space, false);
h.state_mut().ui.tool = Tool::Hand;
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: far, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(egui::Event::PointerMoved(far + vec2(100.0, 50.0)));
h.run_steps(2);
h.event(egui::Event::PointerButton { pos: far, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_ne!(h.state().ui.views[0].center, center0, "an ordinary Hand drag pans the view again");
}
#[test]
fn view_menu_zoom_in_keeps_the_view_centre() {
let mut app = zoom_app(400, 300);
+1 -1
View File
@@ -489,7 +489,7 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, display:
let st = app.session.documents().get(idx)?;
let view = st.channel_view.clone();
let active = st.active_layer;
let (revision, last_damage) = (st.revision, st.last_damage);
let (revision, last_damage) = (st.revision, (st.damage_from(), st.last_damage));
let content = revision.wrapping_sub(st.view_revision);
let id = st.doc.id.0;
if view.is_plain(&st.doc) {
+1 -1
View File
@@ -121,7 +121,7 @@ pub(crate) fn alt_samples(tool: Tool, mods: egui::Modifiers) -> bool {
/// Preferences › Cursors › Other Cursors asks for the precise crosshair, or Caps Lock swaps the
/// standard cursor for it (and back), as in Photoshop (#4639).
pub(super) fn precise_other_cursor(app: &PhotocraftApp) -> bool {
pub(crate) fn precise_other_cursor(app: &PhotocraftApp) -> bool {
(app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise) != app.caps_lock
}
+2
View File
@@ -223,6 +223,8 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
return None;
}
let all = Rect::new(i32::MIN / 2, i32::MIN / 2, i32::MAX / 2, i32::MAX / 2);
// The context-free bound: it moves with the layer. `change_bounds_in` is an in-place bound
// (the layers beneath an adjustment, or a clip base, stay put), so it can't be translated.
let bounds = ids.iter().try_fold(Rect::EMPTY, |acc, id| Some(acc.union(&photocraft_compose::change_bounds(doc.layer(*id)?, all)?)));
let canvas = doc.bounds();
let moving = with_descendants(&doc, &ids);
@@ -110,6 +110,50 @@ fn dragging_a_shape_past_the_canvas_edge_leaves_no_trail() {
}
}
/// #9329: an adjustment's mask slides over layers that stay put, so a Move drag of it changes
/// pixels away from where its bound in place sits. Every pixel that differs between two drag
/// frames' full composites must be inside the preview's damage (`None` redraws everything).
#[test]
fn dragging_a_masked_adjustment_redraws_the_layers_its_mask_slides_over() {
for depth in [8, 32] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 64, "height": 64, "background": "transparent", "depth": depth})).unwrap();
app.sync_views();
// A small static layer at (8..16)², and an Invert masked to x 24..56, clear of it.
app.session.execute("layer.new.layer", json!({})).unwrap();
app.session.execute("select.rect", json!({"x": 8, "y": 8, "width": 8, "height": 8})).unwrap();
app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.session.execute("select.rect", json!({"x": 24, "y": 0, "width": 32, "height": 64})).unwrap();
app.session.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap();
app.ui.tool = Tool::Move;
app.ui.tool_options.move_auto_select = false;
app.ui.extras.snap = false;
app.ui.view.show.smart_guides = false;
let m = egui::Modifiers::NONE;
crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 32.0, pressure: 1.0 }, m);
let mut frames = Vec::new();
// At -2 the mask (22..54) misses the small layer; at -20 (4..36) it covers it.
for x in [38.0, 20.0] {
crate::canvas::tool_event(&mut app, ToolEvent::Move { x, y: 32.0, pressure: 1.0 }, m);
frames.push(super::display_doc(&mut app, 0).unwrap());
}
let st = app.session.active().unwrap();
let r = super::damage(&app, st.doc.id, st.revision, frames[0].1, frames[1].1);
let canvas = st.doc.bounds();
let (a, b) = (photocraft_compose::render(&frames[0].0, canvas), photocraft_compose::render(&frames[1].0, canvas));
let w = canvas.width() as usize;
let mut changed = 0;
for (i, (pa, pb)) in a.px.iter().zip(&b.px).enumerate() {
if pa != pb {
changed += 1;
let (x, y) = (canvas.x0 + (i % w) as i32, canvas.y0 + (i / w) as i32);
assert!(r.is_none_or(|r| r.contains(x, y)), "{depth}-bit: ({x}, {y}) changed outside the redrawn {r:?}");
}
}
assert!(changed > 0, "{depth}-bit: the mask sliding over the small layer changes pixels");
}
}
/// Dragging with an unlinked mask targeted moves the mask alone, shown live and committed;
/// with the mask linked (or the pixels targeted) the layer moves.
#[test]
+4 -4
View File
@@ -75,8 +75,8 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
return ensure_texture(app, ctx, idx, app.session.color.main_display).map(|(t, _)| t);
}
// Keyed by the document snapshot, not its revision: selecting a layer changes no pixels.
let (snapshot, id, revision, last_damage) =
app.session.documents().get(idx).map(|st| (std::sync::Arc::downgrade(&st.doc), st.doc.id, st.revision, st.last_damage))?;
let (snapshot, id, revision, damage_from, last_damage) =
app.session.documents().get(idx).map(|st| (std::sync::Arc::downgrade(&st.doc), st.doc.id, st.revision, st.damage_from(), st.last_damage))?;
let (doc, preview_key) = display_doc(app, idx);
let (display, display_key) = canvas_display(app, &doc, None);
let preview_key = preview_key ^ display_key;
@@ -102,13 +102,13 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
return Some(t.id());
}
let t0 = crate::gpu_canvas::now_ms();
// One edit since the cached image, with the area it changed: update just that part (nothing
// One edit (or command) since the cached image, with the area it changed: update just that part (nothing
// for a rename, a selection or a new empty layer). A thumbnail composite of the whole
// document on every edit cost a full-resolution CPU composite on the UI thread.
let size = photocraft_compose::thumbnail_size(&doc, NAVIGATOR_SIDE);
if let Some((_, p, rev, t)) = &cached
&& *p == preview_key
&& rev.checked_add(1) == Some(revision)
&& (damage_from..revision).contains(rev)
&& let Some(d) = last_damage
&& t.size() == [size.0 as usize, size.1 as usize]
{
@@ -423,11 +423,15 @@ fn picker_gear_menu_saves_and_names_a_new_preset() {
assert_eq!(h.state().session.tools.current_preset.as_deref(), Some("Inky"));
h.state_mut().ui.brush_picker = Some([c.x, c.y]);
h.run_steps(2);
// The gear's boxes pick the card parts; the last one on can't be turned off.
// The gear's boxes pick the card parts; the last one on can't be turned off. Brush Tip
// starts off (#11819).
h.get_by_label("Brush Preset Options").click();
h.run_steps(2);
h.get_by_label("Brush Tip").click();
h.run_steps(2);
assert!(h.state().ui.brush_picker_list.show_tip);
h.get_by_label("Brush Tip").click();
h.run_steps(2);
assert!(!h.state().ui.brush_picker_list.show_tip);
h.get_by_label("Brush Stroke").click();
h.run_steps(2);
+59 -5
View File
@@ -2304,7 +2304,7 @@ fn brush_drag_shows_the_real_stroke_and_commits_it() {
assert_eq!(alpha(&app.session.documents()[0].doc, 40, 30), 0.0, "not committed yet");
// The canvas redraws only what the stroke touched.
let dk = canvas_display(&app, &app.session.documents()[0].doc, None).1;
let d = damage_since(&app, 0, (rev, dk), (rev, key), dk, None).unwrap();
let d = damage_since(&app, 0, (rev, dk), (rev, key), dk, (rev, None)).unwrap();
assert!(d.contains(40, 30) && !d.contains(40, 2) && d.height() < 60, "{d:?}");
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 30.0 }, m);
let doc = app.session.documents()[0].doc.clone();
@@ -2312,6 +2312,60 @@ fn brush_drag_shows_the_real_stroke_and_commits_it() {
assert_eq!((alpha(&doc, 40, 30), alpha(&doc, 40, 38)), (alpha(&shown, 40, 30), alpha(&shown, 40, 38)), "commit matches the preview");
}
/// #9329 PR C: Paste Into is three revisions; the canvas redraws what the whole command
/// changed (the pasted area), not the whole document as when only one revision counted.
#[test]
fn paste_into_refreshes_only_the_pasted_area() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.background_jobs = false;
let ctx = egui::Context::default();
app.run("file.new", json!({"width": 800, "height": 600})).unwrap();
app.run("select.rect", json!({"x": 40, "y": 40, "width": 100, "height": 100})).unwrap();
app.run("edit.copy", json!({})).unwrap();
app.run("select.rect", json!({"x": 500, "y": 400, "width": 90, "height": 60})).unwrap();
ensure_texture(&mut app, &ctx, 0, None).expect("a texture");
let rev = app.session.active().unwrap().revision;
app.perf.last_refresh_px = 0;
app.run("edit.pasteSpecial.pasteInto", json!({})).unwrap();
assert!(app.session.active().unwrap().revision >= rev + 2, "several revisions");
ensure_texture(&mut app, &ctx, 0, None).expect("a texture");
let px = app.perf.last_refresh_px;
assert!(px > 0 && px < 800 * 600 / 4, "refreshed {px} px of {}", 800 * 600);
}
/// The Move tool's Auto-Select pick is its own revision that changes no pixels: the drag
/// that follows still redraws only where the layer was and is.
#[test]
fn auto_select_move_is_still_bounded() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.background_jobs = false;
let ctx = egui::Context::default();
app.run("file.new", json!({"width": 800, "height": 600})).unwrap();
for (x, color) in [(100, "#ff0000"), (500, "#0000ff")] {
app.run("layer.new.layer", json!({})).unwrap();
app.run("select.rect", json!({"x": x, "y": 100, "width": 40, "height": 40})).unwrap();
app.run("edit.fill", json!({"color": color})).unwrap();
}
app.run("select.deselect", json!({})).unwrap();
app.ui.tool = Tool::Move;
app.ui.tool_options.move_auto_select = true;
app.ui.extras.snap = false;
app.ui.view.show.smart_guides = false;
let active = app.session.active().unwrap().active_layer;
ensure_texture(&mut app, &ctx, 0, None).expect("a texture");
let rev = app.session.active().unwrap().revision;
let m = egui::Modifiers::NONE;
tool_event(&mut app, ToolEvent::Down { x: 120.0, y: 120.0, pressure: 1.0 }, m);
assert_ne!(app.session.active().unwrap().active_layer, active, "Auto-Select picked the red layer");
assert_eq!(app.session.active().unwrap().revision, rev + 1, "the pick is one revision");
tool_event(&mut app, ToolEvent::Move { x: 150.0, y: 130.0, pressure: 1.0 }, m);
app.perf.last_refresh_px = 0;
ensure_texture(&mut app, &ctx, 0, None).expect("a texture");
let px = app.perf.last_refresh_px;
assert!(px > 0 && px < 800 * 600 / 4, "refreshed {px} px of {}", 800 * 600);
tool_event(&mut app, ToolEvent::Up { x: 150.0, y: 130.0 }, m);
}
#[test]
fn live_stroke_uses_pen_tilt_and_updates_a_reduced_texture_partially() {
// Pen tilt drives the size; the preview must use the tilt the commit gets. The document
@@ -3305,14 +3359,14 @@ fn free_transform_redraws_only_the_moving_layer_when_the_box_opens_and_closes()
let (_, key) = display_doc(&mut app, 0);
assert_eq!(key, crate::transform_tool::preview_key(app.transform_preview.as_ref().unwrap().session));
let dk = canvas_display(&app, &app.session.documents()[0].doc, None).1;
let opened = damage_since(&app, 0, (rev, dk), (rev, key), dk, None).expect("opening the box has a damage area");
let opened = damage_since(&app, 0, (rev, dk), (rev, key), dk, (rev, None)).expect("opening the box has a damage area");
assert!(opened.contains(20, 20) && opened.contains(59, 49), "{opened:?}");
assert!(opened.width() < 400 && opened.height() < 200, "not the whole canvas: {opened:?}");
let canceled = damage_since(&app, 0, (rev, key ^ dk), (rev, 0), dk, None).expect("canceling has one too");
let canceled = damage_since(&app, 0, (rev, key ^ dk), (rev, 0), dk, (rev, None)).expect("canceling has one too");
assert_eq!(canceled, opened);
// A commit (one revision later) adds what it changed; anything else redraws everything.
let moved = photocraft_geom::Rect::new(100, 20, 140, 50);
let committed = damage_since(&app, 0, (rev, key ^ dk), (rev + 1, 0), dk, Some(moved)).expect("committing too");
let committed = damage_since(&app, 0, (rev, key ^ dk), (rev + 1, 0), dk, (rev, Some(moved))).expect("committing too");
assert!(committed.contains(20, 20) && committed.contains(139, 49), "{committed:?}");
assert!(damage_since(&app, 0, (rev, key ^ dk), (rev + 2, 0), dk, Some(moved)).is_none(), "a later revision isn't ours");
assert!(damage_since(&app, 0, (rev, key ^ dk), (rev + 2, 0), dk, (rev + 1, Some(moved))).is_none(), "a later revision isn't ours");
}
+22 -13
View File
@@ -122,7 +122,7 @@ pub(crate) fn ensure_texture_in_view(
let output = display;
let (revision, last_damage, id) = {
let st = app.session.documents().get(idx)?;
(st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id)
(st.revision, (st.damage_from(), st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) })), st.doc.id)
};
let (mut doc, mut preview_key) = display_doc(app, idx);
// A filter dialog's live preview: the GPU canvas draws it from its own texture, but this CPU
@@ -317,9 +317,17 @@ fn render_detail(
}
/// What changed since a canvas cache showed (`revision`, `preview key`) `seen`, when only a
/// rectangle did: the last edit's damage, or the live stroke's dabs since then. Cached keys have
/// the colour display's key folded in (`^ display_key`); `now`'s is still raw.
pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), now: (u64, u64), display_key: u64, last_damage: Option<DRect>) -> Option<DRect> {
/// rectangle did: the document's damage since revision `damage_from` (one revision, or a whole
/// command's span: [`photocraft_engine::DocState::damage_from`]), or the live stroke's dabs since
/// then. Cached keys have the colour display's key folded in (`^ display_key`); `now`'s is still raw.
pub(crate) fn damage_since(
app: &PhotocraftApp,
idx: usize,
seen: (u64, u64),
now: (u64, u64),
display_key: u64,
(damage_from, last_damage): (u64, Option<DRect>),
) -> Option<DRect> {
// A brush stroke committed with the pixels its live preview already showed: only the steps
// this cache hadn't shown yet (usually nothing), not the stroke's whole bounding box.
if seen.0 + 1 == now.0
@@ -336,7 +344,9 @@ pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), no
};
return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) });
}
if seen.1 == now.1 ^ display_key && seen.0 + 1 == now.0 {
// `seen` is within the span the document's damage covers.
let in_span = seen.0 >= damage_from && seen.0 < now.0;
if seen.1 == now.1 ^ display_key && in_span {
return last_damage;
}
// Opening, canceling or committing a Free Transform box: the moving layer and the layers
@@ -367,9 +377,9 @@ pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), no
{
return Some(r);
}
// Between a Move drag's offsets: where the moving layers were and are (Auto-Select's pick
// at the press changes no pixels).
if (seen.0 == now.0 || (seen.0 + 1 == now.0 && last_damage.is_some_and(|r| r.is_empty())))
// Between a Move drag's offsets: where the moving layers were and are. Revisions that changed
// no pixels since `seen` (Auto-Select's pick at the press) count as the same document.
if (seen.0 == now.0 || (in_span && last_damage.is_some_and(|r| r.is_empty())))
&& let Some(st) = app.session.documents().get(idx)
&& let Some(r) = crate::move_ui::damage(app, st.doc.id, now.0, seen.1 ^ display_key, now.1)
{
@@ -464,11 +474,10 @@ pub(crate) fn effect_reach(layers: &[photocraft_doc::Layer]) -> i32 {
/// Returns false if there is no GPU canvas.
pub(super) fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: DRect) -> bool {
let Some(gpu) = app.gpu.clone() else { return false };
let Some((revision, last_damage, id)) = app
.session
.documents()
.get(idx)
.map(|st| (st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id))
let Some((revision, last_damage, id)) =
app.session.documents().get(idx).map(|st| {
(st.revision, (st.damage_from(), st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) })), st.doc.id)
})
else {
return false;
};
+1 -1
View File
@@ -145,7 +145,7 @@ fn filter_event_before_tool(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::
if crate::distort_ui::pointer(app, ev, mods) {
return None;
}
if crate::perspective_crop_ui::pointer(app, ev) {
if crate::perspective_crop_ui::pointer(app, ev, mods) {
return None;
}
// Window › Modifier Keys: sticky Shift/⌘/⌥ act as held keys.
+8 -6
View File
@@ -609,12 +609,14 @@ pub(super) fn show(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, xf:
if app.ui.view.pattern_preview || gpu.fault().is_some() {
return false;
}
let Some((revision, last_damage, id, size)) = app
.session
.documents()
.get(idx)
.map(|st| (st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id, st.doc.size))
else {
let Some((revision, last_damage, id, size)) = app.session.documents().get(idx).map(|st| {
(
st.revision,
(st.damage_from(), st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) })),
st.doc.id,
st.doc.size,
)
}) else {
return false;
};
let size = [size.width, size.height];
+6 -1
View File
@@ -614,6 +614,10 @@ fn crop_fill_param(p: &Value) -> std::result::Result<Option<bool>, String> {
/// keeps a pin and `{"pinned": true}` keeps a dragged position (#10890).
fn panels_patch(app: &PhotocraftApp, patch: Option<&Value>) -> std::result::Result<Option<Value>, String> {
let Some(mut patch) = patch.cloned() else { return Ok(None) };
// Saved panels read an unknown side as the default; a control request is still refused.
if let Some(side) = patch.get("toolbar_side") {
serde_json::from_value::<crate::state::ToolbarSide>(side.clone()).map_err(|e| format!("panels.toolbar_side: {e}"))?;
}
if let Some(obj) = patch.as_object_mut()
&& let Some(bar) = obj.get("task_bar")
{
@@ -725,11 +729,12 @@ impl PanelSet {
}
}
/// Applies the Color panel state and queues the requested end-dock width for the next frame.
fn apply_color_and_width(&mut self, app: &mut PhotocraftApp, ctx: &egui::Context) {
if let Some(v) = self.color_panel.take() {
app.ui.color_panel = v;
}
// Right dock width in points (clamped to the dock's 250..=520 range), applied next frame.
// End dock width in points, clamped to its window-dependent range and applied next frame.
if let Some(w) = self.dock_width {
crate::panels::request_dock_width(ctx, w as f32);
}
+6 -6
View File
@@ -138,7 +138,7 @@ fn perf_output_includes_slow_frames() {
assert_eq!(call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["perf"]["slowFrames"], json!([]));
app.slow_frames.set_threshold_ms(0.0);
app.run("file.new", json!({"width": 64, "height": 32})).unwrap();
app.finish_frame(1.0);
app.finish_frame(&ctx, 1.0);
let frames = call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["perf"]["slowFrames"].clone();
let last = frames.as_array().and_then(|f| f.last()).expect("a slow frame").clone();
assert_eq!(last["command"], "file.new", "{last}");
@@ -156,7 +156,7 @@ fn perf_output_includes_slow_frames() {
let played = app.run("actions.play", json!({"action": "Zoom"})).unwrap();
assert_eq!(played["ran"], 2, "{played}");
let outer_ms = app.perf.command_ms;
app.finish_frame(1.0);
app.finish_frame(&ctx, 1.0);
let frames = call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["perf"]["slowFrames"].clone();
let last = frames.as_array().and_then(|f| f.last()).expect("a slow frame").clone();
assert_eq!(last["command"], "actions.play", "{last}");
@@ -1116,7 +1116,7 @@ fn ui_set_opens_the_brush_preset_picker_and_sets_its_cards() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
let list = &app.ui.brush_picker_list;
assert!(list.show_name && list.show_stroke && list.show_tip, "every card part is on by default");
assert!(list.show_name && list.show_stroke && !list.show_tip, "the cards show the name and the stroke by default");
let r = call(&mut app, &ctx, "ui.set", json!({"tool": "brush", "brushPicker": [120, 80], "brushPickerStroke": false}));
assert_eq!(r["ok"], true, "{r}");
assert_eq!(app.ui.brush_picker, Some([120.0, 80.0]));
@@ -1160,11 +1160,11 @@ fn ui_inspect_tracks_brush_picker_open_and_closed() {
assert!(open["result"]["brushPicker"]["pos"].is_array(), "{open}");
assert_eq!(open["result"]["brushPicker"]["list"]["showName"], true);
assert_eq!(open["result"]["brushPicker"]["list"]["showStroke"], true);
assert_eq!(open["result"]["brushPicker"]["list"]["showTip"], true);
assert_eq!(open["result"]["brushPicker"]["list"]["showTip"], false);
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"brushPickerTip": false}))["ok"], true);
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"brushPickerTip": true}))["ok"], true);
let changed = call(&mut app, &ctx, "ui.inspect", json!({}));
assert_eq!(changed["result"]["brushPicker"]["list"]["showTip"], false);
assert_eq!(changed["result"]["brushPicker"]["list"]["showTip"], true);
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"brushPicker": null}))["ok"], true);
let closed_again = call(&mut app, &ctx, "ui.inspect", json!({}));
+8 -3
View File
@@ -277,13 +277,16 @@ pub(crate) fn close_box(ui: &mut egui::Ui, rect: egui::Rect) -> bool {
/// it. Returns `Some(true)` / `Some(false)` when the body itself answered OK / Cancel.
/// The body of a command dialog: the first handler whose marker the fields carry. The prompts and
/// small editors come first; the rest are in `command_body_more`.
/// The delete confirmations: a layer from the Layers panel's trash, or a note (#11788).
/// The delete confirmations: a layer from the Layers panel's trash, a note (#11788), or all notes
/// (#11791).
fn delete_prompt(fields: &Fields) -> bool {
crate::delete_layer_prompt::owns(fields) || crate::analysis_ui::owns_delete_note(fields)
crate::delete_layer_prompt::owns(fields) || crate::analysis_ui::owns_delete_note(fields) || crate::analysis_ui::owns_clear_notes(fields)
}
fn delete_prompt_body(ui: &mut egui::Ui, fields: &mut Fields) {
if crate::analysis_ui::owns_delete_note(fields) {
if crate::analysis_ui::owns_clear_notes(fields) {
crate::analysis_ui::clear_notes_body(ui);
} else if crate::analysis_ui::owns_delete_note(fields) {
crate::analysis_ui::delete_note_body(ui, fields);
} else {
crate::delete_layer_prompt::body(ui, fields);
@@ -885,6 +888,7 @@ const ROUTES: &[fn(&Fields) -> bool] = &[
crate::shape_dialog::owns,
crate::delete_layer_prompt::owns,
crate::analysis_ui::owns_delete_note,
crate::analysis_ui::owns_clear_notes,
crate::disk_change_prompt::owns,
crate::actions::owns_stop_message,
crate::actions::owns_insert_stop,
@@ -1120,6 +1124,7 @@ fn confirm_command_more(app: &mut PhotocraftApp, d: Dialog) -> Result<Value, Str
match d.kind {
_ if merge_channels_ui::owns(&d.fields) => merge_channels_ui::confirm(app, &d.fields),
_ if crate::analysis_ui::owns_delete_note(&d.fields) => crate::analysis_ui::confirm_delete_note(app, &d.fields),
_ if crate::analysis_ui::owns_clear_notes(&d.fields) => crate::analysis_ui::confirm_clear_notes(app, &d.fields),
_ if crate::file_ui::owns(&d.fields) => crate::file_ui::confirm(app, &d.fields),
_ if crate::new_guide_ui::owns(&d.fields) => crate::new_guide_ui::confirm(app, &d.fields),
_ if crate::color_picker_ui::owns(&d.fields) => {
@@ -180,38 +180,33 @@ pub(super) fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> E
label(ui, tl!("Preset:"));
let mut picked = None;
let (press, close) = crate::press_menu::PressCombo::before(ui, cx.mem.with("gm-preset"));
let combo = craft_ui::ComboBox::from_id_salt(cx.mem.with("gm-preset"))
.selected_text(tl!("Custom"))
.width(ui.available_width().min(190.0))
.height(420.0)
.icon(widgets::chevron_icon)
.close_behavior(close)
.show_ui(ui, |ui| {
let gear = crate::icons::button(ui, "settings", 24.0, false, tl!("Gradient Preset Options"));
egui::containers::menu::SubMenu::new().show(ui, &gear, |ui| {
if ui.button(tl!("Import .GRD")).clicked() {
e.import_gradients = true;
ui.close();
}
});
ui.separator();
for g in &cx.gradients {
ui.label(RichText::new(&g.name).color(t.text_faint));
for p in &g.items {
ui.horizontal(|ui| {
// A map has no transparency: colour stops only, shown opaque.
let rgba: Vec<(f32, [f32; 4])> = p.resolve(fg, bg).into_iter().map(|(at, c)| (at, [c[0], c[1], c[2], 1.0])).collect();
let (r, swatch) = ui.allocate_exact_size(vec2(48.0, 16.0), Sense::click());
crate::preset_panels::paint_gradient(ui, r, &rgba);
// Both, not short-circuited: the name is drawn either way.
let name = ui.selectable_label(false, &p.name);
if press.chosen(ui, &swatch) | press.chosen(ui, &name) {
picked = Some(rgba.iter().map(|(at, c)| (*at, [c[0], c[1], c[2]])).collect::<Vec<Stop>>());
}
});
}
let width = ui.available_width().min(190.0);
let combo = widgets::combo_list(ui, cx.mem.with("gm-preset"), tl!("Custom"), width, close, |ui| {
let gear = crate::icons::button(ui, "settings", 24.0, false, tl!("Gradient Preset Options"));
egui::containers::menu::SubMenu::new().show(ui, &gear, |ui| {
if ui.button(tl!("Import .GRD")).clicked() {
e.import_gradients = true;
ui.close();
}
});
ui.separator();
for g in &cx.gradients {
ui.label(RichText::new(&g.name).color(t.text_faint));
for p in &g.items {
ui.horizontal(|ui| {
// A map has no transparency: colour stops only, shown opaque.
let rgba: Vec<(f32, [f32; 4])> = p.resolve(fg, bg).into_iter().map(|(at, c)| (at, [c[0], c[1], c[2], 1.0])).collect();
let (r, swatch) = ui.allocate_exact_size(vec2(48.0, 16.0), Sense::click());
crate::preset_panels::paint_gradient(ui, r, &rgba);
// Both, not short-circuited: the name is drawn either way.
let name = ui.selectable_label(false, &p.name);
if press.chosen(ui, &swatch) | press.chosen(ui, &name) {
picked = Some(rgba.iter().map(|(at, c)| (*at, [c[0], c[1], c[2]])).collect::<Vec<Stop>>());
}
});
}
}
});
press.after(&combo.response);
let _ = combo.response.accessible_name(tl!("Preset:"));
if let Some(p) = picked {
@@ -75,7 +75,7 @@ impl DetailPreview {
let result = match rx.try_recv() {
Ok(result) => result,
Err(TryRecvError::Empty) => return,
Err(TryRecvError::Disconnected) => Err("Camera Raw detail preview worker stopped".into()),
Err(TryRecvError::Disconnected) => Err("RAW Develop detail preview worker stopped".into()),
};
self.pending = None;
match result {
@@ -104,7 +104,7 @@ impl DetailPreview {
// The engine's full-image pipeline needs multiple temporary float buffers. Never start
// an unbounded allocation just because a huge sparse document was zoomed into.
if self.area.width() as u64 * self.area.height() as u64 > 64_000_000 {
self.error = Some("Full-resolution Camera Raw preview is limited to 64 megapixels; the proxy remains available".into());
self.error = Some("Full-resolution RAW Develop preview is limited to 64 megapixels; the proxy remains available".into());
self.failed_revision = Some(revision);
return;
}
@@ -124,7 +124,7 @@ impl DetailPreview {
let result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| develop(&source, raw_curve.as_deref().map(Vec::as_slice), area, &params, &coverage)))
.map(|surface| (revision, surface))
.map_err(|_| "Camera Raw detail preview failed; the proxy remains available".into());
.map_err(|_| "RAW Develop detail preview failed; the proxy remains available".into());
if !cancel.load(Ordering::Relaxed) {
let _ = tx.send(result);
ctx.request_repaint();
@@ -141,7 +141,7 @@ impl DetailPreview {
}) {
Ok(_) => self.pending = Some((revision, rx)),
Err(e) => {
self.error = Some(format!("Cannot start Camera Raw preview: {e}"));
self.error = Some(format!("Cannot start RAW Develop preview: {e}"));
self.failed_revision = Some(revision);
}
}
@@ -152,7 +152,7 @@ impl DetailPreview {
// instead of blocking its UI with the full-image engine pipeline.
drop(work);
drop(rx);
self.error = Some("Full-resolution filtered preview requires a native Camera Raw worker; the web preview keeps the proxy".into());
self.error = Some("Full-resolution filtered preview requires a native RAW Develop worker; the web preview keeps the proxy".into());
self.failed_revision = Some(revision);
}
}
+8 -8
View File
@@ -32,11 +32,11 @@ impl Pass {
return false;
}
if adapter.get_info().device_type == wgpu::DeviceType::Cpu {
log::info!("Camera Raw develops on the CPU: software graphics adapter");
log::info!("RAW Develop develops on the CPU: software graphics adapter");
return false;
}
if !CameraRawDevelop::supported(adapter) {
log::info!("Camera Raw develops on the CPU: the adapter can't render Rgba32Float");
log::info!("RAW Develop develops on the CPU: the adapter can't render Rgba32Float");
return false;
}
true
@@ -53,7 +53,7 @@ impl Pass {
let (on, out) = (device.clone(), slot.clone());
let spawned = std::thread::Builder::new().name("camera-raw-pipeline".into()).spawn(move || {
let pass = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Self::build_on(&on))).unwrap_or_else(|_| {
log::warn!("building the Camera Raw pipeline panicked; Camera Raw develops on the CPU");
log::warn!("building the RAW Develop pipeline panicked; RAW Develop develops on the CPU");
None
});
let _ = out.set(pass);
@@ -70,7 +70,7 @@ impl Pass {
match CameraRawDevelop::new(device) {
Ok(pass) => Some(Pass(Arc::new(Mutex::new(pass)))),
Err(e) => {
log::warn!("{e}; Camera Raw develops on the CPU");
log::warn!("{e}; RAW Develop develops on the CPU");
None
}
}
@@ -140,7 +140,7 @@ impl CameraRawGpu {
match pass.develop(&self.device, &self.queue, Some(&self.health), px, key, size, &stages) {
Ok(out) => Some(out),
Err(e) => {
log::warn!("{}; Camera Raw develops on the CPU", e.0);
log::warn!("{}; RAW Develop develops on the CPU", e.0);
self.failed.store(true, Ordering::Relaxed);
None
}
@@ -215,7 +215,7 @@ mod tests {
let _gpu = photocraft_testkit::gpu_lock();
let Some((mut app, ctx)) = gpu_app(8) else { return };
let canvas = app.gpu.clone().expect("GPU canvas");
assert!(!canvas.camera_raw_started(), "no Camera Raw pipeline at launch (#6419)");
assert!(!canvas.camera_raw_started(), "no RAW Develop pipeline at launch (#6419)");
crate::camera_raw_ui::open(&mut app, &ctx).unwrap();
assert!(canvas.camera_raw_started(), "built (or refused) from the first open");
assert_eq!(app.camera_raw.as_ref().map(|d| d.develop_backend), Some("cpu"), "the first frame doesn't wait for the pipeline");
@@ -233,7 +233,7 @@ mod tests {
force_pass(&app);
crate::camera_raw_ui::open(&mut app, &ctx).unwrap();
if app.camera_raw.as_ref().is_some_and(|d| d.gpu.is_none()) {
eprintln!("skipping: the device can't run the Camera Raw pass");
eprintln!("skipping: the device can't run the RAW Develop pass");
return;
}
let per_pixel = CameraRaw {
@@ -272,7 +272,7 @@ mod tests {
force_pass(&app);
crate::camera_raw_ui::open(&mut app, &ctx).unwrap();
if app.camera_raw.as_ref().is_some_and(|d| d.gpu.is_none()) {
eprintln!("skipping: the device can't run the Camera Raw pass");
eprintln!("skipping: the device can't run the RAW Develop pass");
return;
}
let (backend, _, _) = developed(&mut app, &ctx, CameraRaw { exposure: 0.5, ..Default::default() });
@@ -56,7 +56,7 @@ impl CameraRawPreviewState {
/// Validate with the rest of the Camera Raw request before changing anything.
pub(crate) fn prepare(current: CameraRawPreviewState, value: &Value) -> Result<CameraRawPreviewState, String> {
let obj = value.as_object().ok_or("Camera Raw view must be an object")?;
let obj = value.as_object().ok_or("RAW Develop view must be an object")?;
let mut next = current;
for (key, value) in obj {
match key.as_str() {
@@ -65,22 +65,22 @@ pub(crate) fn prepare(current: CameraRawPreviewState, value: &Value) -> Result<C
let z = value
.as_f64()
.filter(|z| z.is_finite() && (MIN_ZOOM as f64..=MAX_ZOOM as f64).contains(z))
.ok_or("Camera Raw zoom must be 'fit' or 0.0001–16 (1 = 100%)")?;
.ok_or("RAW Develop zoom must be 'fit' or 0.0001–16 (1 = 100%)")?;
next.zoom = Some(z as f32);
}
"center" => {
let a = value.as_array().filter(|a| a.len() == 2).ok_or("Camera Raw center must be [u,v]")?;
let a = value.as_array().filter(|a| a.len() == 2).ok_or("RAW Develop center must be [u,v]")?;
let coordinate = |i| {
a.get(i)
.and_then(Value::as_f64)
.filter(|v| v.is_finite() && (0.0..=1.0).contains(v))
.map(|v| v as f32)
.ok_or("Camera Raw center must be finite coordinates in 0–1")
.ok_or("RAW Develop center must be finite coordinates in 0–1")
};
next.center = [coordinate(0)?, coordinate(1)?];
}
"hand" => next.hand = value.as_bool().ok_or("Camera Raw hand must be a boolean")?,
_ => return Err(format!("unknown Camera Raw view property {key}")),
"hand" => next.hand = value.as_bool().ok_or("RAW Develop hand must be a boolean")?,
_ => return Err(format!("unknown RAW Develop view property {key}")),
}
}
if obj.get("zoom").and_then(Value::as_str) == Some("fit") {
+2 -2
View File
@@ -247,7 +247,7 @@ impl CameraRawDialog {
let (tx, rx) = std::sync::mpsc::channel::<Result<ProxyFrame, String>>();
let repaint = ctx.clone();
let spawned = std::thread::Builder::new().name("camera-raw-proxy".into()).spawn(move || {
let frame = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job.run())).map_err(|_| "Camera Raw preview failed".to_string());
let frame = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job.run())).map_err(|_| "RAW Develop preview failed".to_string());
// The dialog may have closed or rendered on its own meanwhile; then nobody listens.
if tx.send(frame).is_ok() {
repaint.request_repaint();
@@ -273,7 +273,7 @@ impl CameraRawDialog {
let frame = match rx.try_recv() {
Ok(frame) => frame,
Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(()),
Err(std::sync::mpsc::TryRecvError::Disconnected) => Err("Camera Raw preview worker stopped".to_string()),
Err(std::sync::mpsc::TryRecvError::Disconnected) => Err("RAW Develop preview worker stopped".to_string()),
};
self.pending = None;
self.publish(ctx, frame?);
@@ -49,7 +49,7 @@ fn history_keys(d: &mut CameraRawDialog, ctx: &egui::Context) {
}
pub(super) fn commit(app: &mut PhotocraftApp) -> Result<Value, String> {
let d = app.camera_raw.as_ref().ok_or(tl!("Camera Raw isn't open"))?;
let d = app.camera_raw.as_ref().ok_or(tl!("RAW Develop isn't open"))?;
let result = match d.target {
Target::NewFilter => {
let mut p = d.command_params();
@@ -225,12 +225,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
}
impl CameraRawDialog {
/// "Camera Raw (name)" for the open-time dialog, "Camera Raw Filter (layer)" otherwise.
/// "RAW Develop (name)" for the open-time dialog, "RAW Develop Filter (layer)" otherwise.
fn window_title(&self) -> String {
if self.target == Target::OpenRaw {
crate::i18n::fmt(tl!("Camera Raw ({name})"), &[("name", &self.layer_name)])
crate::i18n::fmt(tl!("RAW Develop ({name})"), &[("name", &self.layer_name)])
} else {
crate::i18n::fmt(tl!("Camera Raw Filter ({layer})"), &[("layer", &self.layer_name)])
crate::i18n::fmt(tl!("RAW Develop Filter ({layer})"), &[("layer", &self.layer_name)])
}
}
}
@@ -56,11 +56,11 @@ pub(super) fn preview_area(canvas: Rect, surf: &photocraft_raster::Surface) -> R
let width = area.x1.checked_sub(area.x0).filter(|n| (1..=1_048_576).contains(n));
let height = area.y1.checked_sub(area.y0).filter(|n| (1..=1_048_576).contains(n));
let (Some(w), Some(h)) = (width, height) else {
return Err("Camera Raw preview bounds are too large".into());
return Err("RAW Develop preview bounds are too large".into());
};
let (w, h) = (w as usize, h as usize);
if w.checked_mul(h).is_none_or(|pixels| pixels > 512_000_000) {
return Err("Camera Raw preview bounds are too large".into());
return Err("RAW Develop preview bounds are too large".into());
}
Ok((area, w, h))
}
@@ -213,7 +213,7 @@ pub(super) fn done(app: &mut PhotocraftApp) -> Value {
return json!({"done": false, "saved": saved});
}
app.ui.status = if !saved.is_empty() {
crate::i18n::fmt(tl!("Saved Camera Raw settings to {file}"), &[("file", &saved.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "))])
crate::i18n::fmt(tl!("Saved RAW Develop settings to {file}"), &[("file", &saved.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "))])
} else if stored.iter().all(|s| *s == Stored::Defaults) {
tl!("Nothing to save: these raws are at their default settings").to_string()
} else {
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more