mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 00:11:46 +00:00
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:
commit
a1a122e20e
223 files changed
+13463
-1897
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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" }
|
||||
@@ -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]| {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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])));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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![
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}))
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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");
|
||||
}
|
||||
@@ -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()] }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"], "{}"),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) }
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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, ¶ms, &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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user