Merge upstream/main into fix/3490-move-align-bar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-11 17:33:05 +02:00
96 changed files with 4229 additions and 556 deletions
+2 -1
View File
@@ -20,9 +20,10 @@ next to them.
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `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`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.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` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
+2 -2
View File
@@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<table>
<tr>
<td width="33%" valign="top">
<h4>🧰 65 tools</h4>
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
<h4>🧰 66 tools</h4>
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
</td>
<td width="33%" valign="top">
<h4>🖌️ A real brush engine</h4>
+4 -3
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: full re-measure against Photoshop 2026 27.11.0) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
@@ -19,7 +19,7 @@ About 80% of the work parallelizes.
| | Value | Kind |
|---|---|---|
| **Feature breadth** | **~76%** | partly measured: menus 628/628, tools 53/68, panels 30/35, formats 13+4 of 31 |
| **Feature breadth** | **~76%** | partly measured: menus 628/628, tools 56/68, panels 30/35, formats 13+4 of 31 |
| **Ready for real work** | **~45%** (40–50%) | estimated: weighted by dimension below |
| Mainstream practitioner | ~43% (38–48%) | estimated: weekly areas of a typical pro, with discounts ([method](docs/target-app-parity.md#mainstream-practitioner-43)) |
| Essentials user | ~61% (55–67%) | estimated: core features only ([method](docs/target-app-parity.md#essentials-user-61)) |
@@ -114,7 +114,7 @@ Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dut
| 2 | Performance budgets: the 22 over-budget scenarios and the 150-layer GPU crash | 200–350 h |
| 3 | User bug backlog to under 100 open | 250–400 h |
| 4 | Brush and transform feel (brush-parity, ui-parity rows) | 160–290 h |
| 5 | The 15 missing tools | 60–100 h |
| 5 | The 12 missing tools | 60–100 h |
Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
@@ -135,6 +135,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
| 2026-10-10 | minor | Readiness by audience with hours per tier |
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
| 2026-10-10 | minor | Alpha gate result (passes; stays alpha) in the stage explanation |
+35
View File
@@ -267,6 +267,14 @@ pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
}
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
/// crashes on them on old Intel Macs (Iris Pro, GPUFamily 1 on Monterey), so no mode could start
/// (#3107). Only Intel Macs skip them: PhotoCraft issues no indirect draws or dispatches, but every
/// other GPU keeps wgpu's default. `WGPU_VALIDATION_INDIRECT_CALL=1` still wins.
fn skips_indirect_validation(os: Os, intel: bool) -> bool {
os == Os::Mac && intel
}
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
@@ -275,6 +283,9 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
if let Some(b) = backends(plan, os) {
create.instance_descriptor.backends = b;
}
if skips_indirect_validation(os, cfg!(target_arch = "x86_64")) {
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
}
if os == Os::Windows {
let exe = std::env::current_exe().ok();
let env = std::env::var("WGPU_DX12_COMPILER").ok();
@@ -797,4 +808,28 @@ mod tests {
assert!(safe, "{:?}", create.instance_descriptor.backend_options.dx12.shader_compiler);
}
}
#[test]
fn intel_macs_skip_the_indirect_validation_shaders() {
// Metal's compiler crashed on them on Intel Iris Pro, losing the device in every mode (#3107).
assert!(skips_indirect_validation(Os::Mac, true));
// Apple silicon, Windows and Linux keep wgpu's validation.
for (os, intel) in [(Os::Mac, false), (Os::Windows, true), (Os::Other, true), (Os::Other, false)] {
assert!(!skips_indirect_validation(os, intel), "{os:?} intel={intel}");
}
for (os, safe_gpu) in [(Os::Windows, false), (Os::Other, true), (Os::Other, false)] {
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") };
let before = before.instance_descriptor.flags;
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched");
}
if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<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"
>
<!-- PhotoCraft's own Convert Point glyph: a corner point, an angle with a square anchor on its apex. -->
<rect x="9.75" y="4.75" width="4.5" height="4.5" />
<path d="M10.5 9.25 4 19" />
<path d="M13.5 9.25 20 19" />
</svg>

After

Width:  |  Height:  |  Size: 433 B

+143
View File
@@ -0,0 +1,143 @@
//! Full filter timing, including tile reads, filtering, writeback and pruning.
//! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_algorithms -- extrude 6000 4000 u8 3 output.bin
//! Optional final argument after output.bin: rgb (default), gray, cmyk or lab.
//! Run the same example at the base commit and the PR commit, then compare output.bin with cmp.
use photocraft_algo::{FilterParams, apply_in};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use std::io::Write;
use std::time::Instant;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
let case = args.first().map(String::as_str).unwrap_or("extrude");
let parse = |i: usize, fallback: i32| -> Result<i32, Box<dyn std::error::Error>> {
Ok(match args.get(i) {
Some(s) => s.parse()?,
None => fallback,
})
};
let (w, h, repeats) = (parse(1, 6000)?, parse(2, 4000)?, parse(4, 3)?);
if !(1..=10000).contains(&w) || !(1..=10000).contains(&h) || !(1..=9).contains(&repeats) {
return Err("dimensions must be 1..10000, repeats 1..9".into());
}
let depth = match args.get(3).map(String::as_str).unwrap_or("u8") {
"u8" => SampleType::U8,
"u16" => SampleType::U16,
"f32" => SampleType::F32,
_ => return Err("depth must be u8, u16 or f32".into()),
};
let extrude = |size, depth| FilterParams::Extrude {
kind: photocraft_algo::ExtrudeType::Blocks,
size,
depth,
level_based: false,
solid_front: false,
mask_incomplete: false,
seed: 17,
};
let params = match case {
"extrude" => extrude(30.0, 30.0),
"extrude-dense" => extrude(8.0, 128.0),
"extrude-small" => extrude(8.0, 64.0),
"wind" => FilterParams::Wind { method: photocraft_algo::WindMethod::Wind, from_right: false, seed: 17 },
"blast" => FilterParams::Wind { method: photocraft_algo::WindMethod::Blast, from_right: false, seed: 17 },
"blast-right" => FilterParams::Wind { method: photocraft_algo::WindMethod::Blast, from_right: true, seed: 17 },
"spin" => FilterParams::SpinBlur {
pins: vec![photocraft_algo::SpinPin { radius_x: 1.0, radius_y: 1.0, blur_angle: 30.0, angle: 17.0, ..Default::default() }],
},
"spin-default" => FilterParams::SpinBlur { pins: vec![Default::default()] },
"lens" | "lens-ca" | "trap" | "trap-wide" | "trap-default" => FilterParams::Invert,
_ => return Err("unknown case".into()),
};
let mode = match args.get(6).map(String::as_str).unwrap_or("rgb") {
"rgb" => ColorMode::Rgb,
"gray" => ColorMode::Grayscale,
"cmyk" => ColorMode::Cmyk,
"lab" => ColorMode::Lab,
_ => return Err("mode must be rgb, gray, cmyk or lab".into()),
};
if case.starts_with("trap") && mode != ColorMode::Cmyk {
return Err("Trap needs CMYK samples".into());
}
let format = PixelFormat::new(mode, depth, true);
let n = format.channels();
let bounds = Rect::new(-17, -29, w - 17, h - 29);
let mut source = Surface::new(format);
for tile in bounds.tiles() {
let r = tile.rect().intersect(&bounds);
let mut pixels = Vec::with_capacity(r.width() as usize * r.height() as usize * n);
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let seed = (x as u32).wrapping_mul(0x9e37_79b9) ^ (y as u32).wrapping_mul(0x85eb_ca6b);
for c in 0..n {
let v = seed.rotate_left(c as u32 * 7).wrapping_add(c as u32 * 71) % 65536;
pixels.push(if c + 1 == n && seed.is_multiple_of(5) { 0.0 } else { v as f32 / 65535.0 });
}
}
}
source.write_region(r, &pixels);
}
let run_filter = |area: Rect| match case {
"lens" | "lens-ca" => photocraft_algo::lens::correct(
&source,
area,
&photocraft_algo::lens::LensCorrection {
distortion: 12.0,
angle: 2.5,
vignette_amount: 20.0,
red_cyan: if case == "lens-ca" { 12.0 } else { 0.0 },
blue_yellow: if case == "lens-ca" { -8.0 } else { 0.0 },
..Default::default()
},
),
"trap" | "trap-wide" | "trap-default" => {
let mut data = source.read_region(area);
photocraft_algo::trap::trap(
&mut data,
area.width() as usize,
area.height() as usize,
n,
if case == "trap-wide" {
50
} else if case == "trap-default" {
1
} else {
10
},
);
let mut result = source.clone();
result.write_region(area, &data);
result.prune();
result
}
_ => apply_in(&source, &params, area, bounds, None, bounds),
};
let warm = Rect::new(bounds.x0, bounds.y0, bounds.x0 + w.min(128), bounds.y0 + h.min(128));
drop(run_filter(warm));
let mut times = Vec::new();
for run in 0..repeats {
let start = Instant::now();
let result = run_filter(bounds);
let seconds = start.elapsed().as_secs_f64();
println!("{case} {w}x{h} {depth:?} {mode:?} run={} seconds={seconds:.6}", run + 1);
times.push(seconds);
if run + 1 == repeats
&& let Some(path) = args.get(5)
{
let mut file = std::io::BufWriter::new(std::fs::File::create(path)?);
for tile in bounds.tiles() {
for sample in result.read_region(tile.rect().intersect(&bounds)) {
file.write_all(&sample.to_bits().to_le_bytes())?;
}
}
file.flush()?;
}
}
times.sort_by(f64::total_cmp);
if let Some(median) = times.get(times.len() / 2) {
println!("MEDIAN {case} {w}x{h} {depth:?} {mode:?} seconds={median:.6}");
}
Ok(())
}
+26 -4
View File
@@ -8,6 +8,8 @@
use photocraft_geom::Rect;
mod spin_plan;
use crate::fxutil::{MAXC, add_sample_premul, gauss_blur_n, gauss_box_radii, premul_window, smoothstep, unpremul_px, xy};
use crate::image::{Image, premultiply};
use crate::{BlurPath, Ctx, FieldPin, IrisPin, SpinPin};
@@ -264,7 +266,18 @@ pub(crate) fn field(src: &Image, out: Rect, ctx: &Ctx, pins: &[FieldPin]) -> Vec
/// Spin Blur: content inside each ellipse is blurred along arcs about its
/// centre (`blur_angle` degrees of rotation), feathered at the rim.
pub(crate) fn spin(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin]) -> Vec<f32> {
let n = src.ch;
match src.ch {
1 => spin_impl::<1>(src, out, ctx, pins, true),
2 => spin_impl::<2>(src, out, ctx, pins, true),
3 => spin_impl::<3>(src, out, ctx, pins, true),
4 => spin_impl::<4>(src, out, ctx, pins, true),
5 => spin_impl::<5>(src, out, ctx, pins, true),
_ => spin_impl::<0>(src, out, ctx, pins, true),
}
}
fn spin_impl<const N: usize>(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin], prepare: bool) -> Vec<f32> {
let n = if N == 0 { src.ch } else { N };
let b = ctx.bounds;
let ss = short_side(b);
let mut res = src.crop(out);
@@ -272,11 +285,12 @@ pub(crate) fn spin(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin]) -> Vec<f
if clip.is_empty() {
return res;
}
let plans = prepare.then(|| spin_plan::prepare(pins)).flatten();
let mut acc = [0.0f32; MAXC];
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
for p in pins {
for (pi, p) in pins.iter().enumerate() {
let (e, u, v) = pin_distance(fx, fy, b, p.x, p.y, p.radius_x, p.radius_y, p.angle, 2.0);
if !e.is_finite() || e >= 1.0 {
continue;
@@ -288,15 +302,20 @@ pub(crate) fn spin(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin]) -> Vec<f
let (cx, cy) = (b.x0 as f32 + p.x * b.width() as f32, b.y0 as f32 + p.y * b.height() as f32);
let angle_deg = if p.angle.is_finite() { p.angle } else { 0.0 };
let (sa, ca) = angle_deg.to_radians().sin_cos();
let rotations = plans.as_ref().and_then(|plans| plans.get(pi)).and_then(|plan| plan.rotations(m));
acc[..n].fill(0.0);
for j in 0..m {
let phi = theta * (j as f32 / (m - 1) as f32 - 0.5);
let (sp, cp) = phi.sin_cos();
let (sp, cp) = rotations.and_then(|table| table.get(j)).copied().unwrap_or_else(|| phi.sin_cos());
// Rotate in the ellipse's normalized space, then map back.
let (ru, rv) = (u * cp - v * sp, u * sp + v * cp);
let (lu, lv) = (ru * p.radius_x * ss, rv * p.radius_y * ss);
let (sx, sy) = (cx + lu * ca - lv * sa, cy + lu * sa + lv * ca);
add_sample_premul(src, sx, sy, clip, ctx.alpha, &mut acc[..n]);
if N == 0 {
add_sample_premul(src, sx, sy, clip, ctx.alpha, &mut acc[..n]);
} else {
spin_plan::sample::<N>(src, sx, sy, clip, ctx.alpha, &mut acc);
}
}
for v in acc.iter_mut().take(n) {
*v /= m as f32;
@@ -376,3 +395,6 @@ pub(crate) fn path(src: &Image, out: Rect, ctx: &Ctx, paths: &[BlurPath]) -> Vec
}
res
}
#[cfg(test)]
mod prepared_spin_tests;
@@ -0,0 +1,59 @@
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
#[test]
fn all_rotation_tables_keep_the_direct_bits() {
for angle in [0.0, 0.1, 15.0, 73.7, 180.0, 360.0, f32::NAN, f32::INFINITY] {
let p = SpinPin { blur_angle: angle, ..Default::default() };
let plans = spin_plan::prepare(&[p]).unwrap();
let theta = angle.clamp(0.0, 360.0).to_radians();
for count in 2..=64 {
for j in 0..count {
let phi = theta * (j as f32 / (count - 1) as f32 - 0.5);
let (a, b) = phi.sin_cos();
let (c, d) = plans[0].rotation(count, j).unwrap();
assert_eq!((a.to_bits(), b.to_bits()), (c.to_bits(), d.to_bits()));
}
}
}
assert!(spin_plan::prepare(&vec![SpinPin::default(); 121]).is_none());
}
#[test]
fn prepared_spin_matches_the_original_kernel() {
let bounds = Rect::new(-17, -11, 58, 45);
for mode in [
ColorMode::Rgb,
ColorMode::Grayscale,
ColorMode::Cmyk,
ColorMode::Lab,
ColorMode::Bitmap,
ColorMode::Duotone,
ColorMode::Indexed,
ColorMode::Multichannel,
] {
for alpha in [false, true] {
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
let ctx = Ctx { bounds, mode, alpha };
let mut src = Image::new(bounds, n);
for (i, v) in src.data.iter_mut().enumerate() {
*v = ((i * 71 + i / n * 107) % 65536) as f32 / 65535.0;
}
for (i, px) in src.data.chunks_exact_mut(n).enumerate() {
if i % 11 == 0 {
px[n - 1] = 0.0;
}
}
for angle in [0.0, 0.1, 15.0, 91.0, 360.0, f32::NAN] {
let pins = [
SpinPin { x: 0.2, y: 0.3, radius_x: 0.25, radius_y: 0.5, angle: 31.0, blur_angle: angle },
SpinPin { x: 0.7, y: 0.6, radius_x: 0.8, radius_y: 0.6, angle: -15.0, blur_angle: angle },
];
let out = Rect::new(-19, -8, 55, 49);
let a = spin(&src, out, &ctx, &pins);
let b = spin_impl::<0>(&src, out, &ctx, &pins, false);
let first = a.iter().zip(&b).position(|(a, b)| a.to_bits() != b.to_bits());
assert_eq!(first, None, "{mode:?} alpha {alpha} angle {angle}");
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
//! All possible sample counts share a bounded rotation table per pin.
use crate::SpinPin;
pub(super) struct Plan {
rotations: Vec<(f32, f32)>,
}
impl Plan {
#[cfg(test)]
pub(super) fn rotation(&self, count: usize, sample: usize) -> Option<(f32, f32)> {
self.rotations(count)?.get(sample).copied()
}
pub(super) fn rotations(&self, count: usize) -> Option<&[(f32, f32)]> {
if !(2..=64).contains(&count) {
return None;
}
let start = count.checked_mul(count - 1)?.checked_div(2)?.checked_sub(1)?;
self.rotations.get(start..start.checked_add(count)?)
}
}
pub(super) fn prepare(pins: &[SpinPin]) -> Option<Vec<Plan>> {
// Under 2 MiB, even for 120 overlapping pins. Larger lists keep direct sampling.
if pins.len() > 120 {
return None;
}
let mut plans = Vec::new();
plans.try_reserve_exact(pins.len()).ok()?;
for p in pins {
let theta = p.blur_angle.clamp(0.0, 360.0).to_radians();
let mut rotations = Vec::new();
rotations.try_reserve_exact(2079).ok()?;
for count in 2..=64 {
for j in 0..count {
let phi = theta * (j as f32 / (count - 1) as f32 - 0.5);
rotations.push(phi.sin_cos());
}
}
plans.push(Plan { rotations });
}
Some(plans)
}
/// Fixed channels keep the bilinear accumulators in registers. Tap and multiply order
/// stay identical to add_sample_premul; alpha is still multiplied by the tap weight first.
pub(super) fn sample<const N: usize>(src: &crate::image::Image, x: f32, y: f32, clip: photocraft_geom::Rect, alpha: bool, acc: &mut [f32; 8]) {
if !x.is_finite() || !y.is_finite() || clip.is_empty() {
return;
}
let fx = (x - 0.5).clamp(clip.x0 as f32, (clip.x1 - 1) as f32);
let fy = (y - 0.5).clamp(clip.y0 as f32, (clip.y1 - 1) as f32);
let (x0, y0) = (fx.floor() as i32, fy.floor() as i32);
let (x1, y1) = ((x0 + 1).min(clip.x1 - 1), (y0 + 1).min(clip.y1 - 1));
let (ax, ay) = (fx - x0 as f32, fy - y0 as f32);
let w = src.rect.width() as usize;
let idx = |xx: i32, yy: i32| ((yy - src.rect.y0) as usize * w + (xx - src.rect.x0) as usize) * N;
for (i, k) in [(idx(x0, y0), (1.0 - ax) * (1.0 - ay)), (idx(x1, y0), ax * (1.0 - ay)), (idx(x0, y1), (1.0 - ax) * ay), (idx(x1, y1), ax * ay)] {
if k <= 0.0 {
continue;
}
let Some(px) = src.data.get(i..i + N).and_then(|s| s.first_chunk::<N>()) else {
return;
};
let a = if alpha { px.get(N - 1).copied().unwrap_or(0.0) * k } else { k };
if alpha {
for (sum, value) in acc.iter_mut().zip(px).take(N - 1) {
*sum += value * a;
}
if let Some(sum) = acc.get_mut(N - 1) {
*sum += a;
}
} else {
for (sum, value) in acc.iter_mut().zip(px).take(N) {
*sum += value * a;
}
}
}
}
+51 -2
View File
@@ -269,6 +269,10 @@ const TILE: i32 = 128;
/// to: samples clamp to it, and pixels mapping outside it take the edge mode. `map` returns where
/// each output pixel centre samples (`None` = outside).
pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(dyn Fn(f64, f64) -> Option<Sample> + Sync)) -> Surface {
remap_impl(src, frame, out_area, edge, map, true)
}
fn remap_impl(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(dyn Fn(f64, f64) -> Option<Sample> + Sync), shared: bool) -> Surface {
let sfmt = src.format();
let needs_alpha = matches!(edge, EdgeMode::Transparency) && !sfmt.alpha;
let ofmt = if needs_alpha { PixelFormat::new(sfmt.mode, sfmt.sample, true) } else { sfmt };
@@ -363,7 +367,14 @@ pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(
let fy = ((s.p[1] - fy0).min(fy1 - s.p[1]) + 0.5).clamp(0.0, 1.0);
(fx * fy) as f32
};
let alpha = if sfmt.alpha { bicubic(n_in - 1, s.p).clamp(0.0, 1.0) } else { 1.0 };
// Color channels and coverage usually sample the same location. Prepare the 16
// tap addresses and weights once; each channel keeps its original summation order.
let center = if shared && (!rgb || (s.pr == s.p && s.pb == s.p)) { sample_channels(&buf, win, frame, n_in, s.p) } else { None };
let sample = |c: usize, p: [f64; 2]| {
let cached = if p == s.p { center.as_ref() } else { None };
cached.and_then(|v| v.get(c)).copied().unwrap_or_else(|| bicubic(c, p))
};
let alpha = if sfmt.alpha { sample(n_in - 1, s.p).clamp(0.0, 1.0) } else { 1.0 };
let mut col = [0.0f32; 8];
for (c, v) in col.iter_mut().enumerate().take(cc) {
let p = if rgb && c == 0 {
@@ -373,7 +384,7 @@ pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(
} else {
s.p
};
let pm = bicubic(c, p);
let pm = sample(c, p);
let straight = if sfmt.alpha { if alpha > 1e-6 { pm / alpha } else { 0.0 } } else { pm };
let g = s.gain;
*v = if subtractive {
@@ -418,6 +429,41 @@ pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(
out
}
fn sample_channels(buf: &[f32], win: Rect, frame: Rect, n: usize, p: [f64; 2]) -> Option<[f32; 8]> {
match n {
1 => sample_channels_impl::<1>(buf, win, frame, p),
2 => sample_channels_impl::<2>(buf, win, frame, p),
3 => sample_channels_impl::<3>(buf, win, frame, p),
4 => sample_channels_impl::<4>(buf, win, frame, p),
5 => sample_channels_impl::<5>(buf, win, frame, p),
8 => sample_channels_impl::<8>(buf, win, frame, p),
_ => None,
}
}
fn sample_channels_impl<const N: usize>(buf: &[f32], win: Rect, frame: Rect, p: [f64; 2]) -> Option<[f32; 8]> {
if N == 0 || N > 8 || win.is_empty() || frame.is_empty() || !p.iter().all(|v| v.is_finite()) {
return None;
}
let x = (p[0] - 0.5).clamp(frame.x0 as f64, frame.x1 as f64 - 1.0);
let y = (p[1] - 0.5).clamp(frame.y0 as f64, frame.y1 as f64 - 1.0);
let (xi, yi) = (x.floor(), y.floor());
let (wx, wy) = (catmull_rom((x - xi) as f32), catmull_rom((y - yi) as f32));
let mut acc = [0.0f32; 8];
for (j, wyv) in wy.iter().enumerate() {
let yy = ((yi as i32 + j as i32 - 1).clamp(win.y0, win.y1 - 1) - win.y0) as usize;
for (i, wxv) in wx.iter().enumerate() {
let xx = ((xi as i32 + i as i32 - 1).clamp(win.x0, win.x1 - 1) - win.x0) as usize;
let start = yy.checked_mul(win.width() as usize)?.checked_add(xx)?.checked_mul(N)?;
let px = buf.get(start..start.checked_add(N)?)?.first_chunk::<N>()?;
let weight = wxv * wyv;
for (a, v) in acc.iter_mut().zip(px) {
*a += weight * *v;
}
}
}
Some(acc)
}
/// Applies a lens correction to `src` over `frame` (normally the canvas).
pub fn correct(src: &Surface, frame: Rect, lc: &LensCorrection) -> Surface {
let map = LensMap::new(lc, frame);
@@ -522,3 +568,6 @@ mod tests {
assert!(out.pixel(0, 0)[0] < 0.4 && (out.pixel(20, 20)[0] - 0.5).abs() < 0.02, "{:?}", out.pixel(0, 0));
}
}
#[cfg(test)]
mod shared_sample_tests;
@@ -0,0 +1,54 @@
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
#[test]
fn shared_interpolation_matches_original_at_all_depths_and_edges() {
let bounds = Rect::new(-13, -9, 39, 26);
for mode in [
ColorMode::Rgb,
ColorMode::Grayscale,
ColorMode::Cmyk,
ColorMode::Lab,
ColorMode::Bitmap,
ColorMode::Duotone,
ColorMode::Indexed,
ColorMode::Multichannel,
] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [false, true] {
let fmt = PixelFormat::new(mode, sample, alpha);
let n = fmt.channels();
let mut s = Surface::new(fmt);
let data: Vec<f32> = (0..bounds.width() as usize * bounds.height() as usize * n)
.map(|i| if i % 17 == 0 { 0.0 } else { ((i * 103 + i / n * 71) % 65536) as f32 / 65535.0 })
.collect();
s.write_region(bounds, &data);
for edge in [EdgeMode::Transparency, EdgeMode::Extension, EdgeMode::Color([0.2, 0.5, 0.7, 0.4])] {
for ca in [false, true] {
let map = |x: f64, y: f64| {
if (x as i32 + y as i32) % 13 == 0 {
return None;
}
let p = [x * 0.83 - 3.7, y * 1.14 + 1.3];
Some(Sample { p, pr: if ca { [p[0] - 0.9, p[1] + 0.3] } else { p }, pb: if ca { [p[0] + 0.7, p[1] - 0.4] } else { p }, gain: 1.17 })
};
let out = bounds.inflate(3);
let a = remap_impl(&s, bounds, out, edge, &map, true).read_region(out);
let b = remap_impl(&s, bounds, out, edge, &map, false).read_region(out);
assert_eq!(
a.iter().zip(&b).position(|(a, b)| a.to_bits() != b.to_bits()),
None,
"{mode:?} {sample:?} alpha {alpha} edge {edge:?} ca {ca}"
);
}
}
}
}
}
}
#[test]
fn shared_sampler_rejects_bad_windows_and_nonfinite_points() {
let win = Rect::new(0, 0, 3, 3);
assert!(sample_channels(&[], win, win, 4, [1.0, 1.0]).is_none());
assert!(sample_channels(&[0.0; 36], win, win, 4, [f64::NAN, 1.0]).is_none());
assert!(sample_channels(&[], Rect::EMPTY, win, 4, [1.0, 1.0]).is_none());
}
+122
View File
@@ -16,6 +16,13 @@ use photocraft_raster::{Cancelled, Interrupt};
/// Energy added per unit of protection: far above any gradient magnitude.
const PROTECT_ENERGY: f32 = 1.0e4;
/// Keep full horizontal detail while reducing the height used to find vertical seams. At a
/// 24 MP working size this cuts the dynamic-programming work by about four times without
/// quantising the output pixels or changing the requested output dimensions.
const SEAM_SEARCH_MAX_HEIGHT: usize = 512;
const PROXY_SHRINK_MIN_HEIGHT: usize = 2_048;
const PROXY_SHRINK_MIN_SEAMS: usize = 64;
/// Gradient-magnitude energy (the paper's e1, `|∂x| + |∂y|` summed over channels). Forward and
/// backward differences are both counted so one-pixel lines (whose central difference is zero)
/// carry energy too.
@@ -248,6 +255,9 @@ pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Opt
}
let seams = new_w.abs_diff(w).max(1) as f32;
if new_w < w {
if h >= PROXY_SHRINK_MIN_HEIGHT && w - new_w >= PROXY_SHRINK_MIN_SEAMS {
return carve_width_proxy_height(w, h, ch, img, protect, new_w, ctl);
}
let mut carver = Carver::new(w, h, ch, img.to_vec(), protect.map(<[f32]>::to_vec), false);
while carver.cw > new_w {
ctl.check()?;
@@ -310,6 +320,89 @@ pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Opt
Ok(cur)
}
/// Find large-image seams on vertically averaged rows, then apply those paths to the original
/// pixels. The proxy has the same width, so each seam still removes exactly one source pixel per
/// row. Keeping output pixels at full resolution avoids the quality loss of resizing the result;
/// only the seam decisions use the coarser vertical signal. Small operations retain the exact
/// full-resolution algorithm above.
fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, ctl: &Interrupt) -> Result<Vec<f32>, Cancelled> {
let step = h.div_ceil(SEAM_SEARCH_MAX_HEIGHT);
let proxy_h = h.div_ceil(step);
let row_len = w * ch;
let proxy_row_len = proxy_h * row_len;
let mut proxy = vec![0.0; proxy_row_len];
for (py, out_row) in proxy.chunks_exact_mut(row_len).enumerate() {
let y0 = py * step;
let y1 = (y0 + step).min(h);
let divisor = (y1 - y0) as f32;
for y in y0..y1 {
let in_row = &img[y * row_len..(y + 1) * row_len];
for (out, &sample) in out_row.iter_mut().zip(in_row) {
*out += sample / divisor;
}
}
}
let proxy_protect = protect.map(|mask| {
let mut reduced = vec![0.0; proxy_h * w];
for (py, out_row) in reduced.chunks_exact_mut(w).enumerate() {
let y0 = py * step;
let y1 = (y0 + step).min(h);
let divisor = (y1 - y0) as f32;
for y in y0..y1 {
let in_row = &mask[y * w..(y + 1) * w];
for (out, &sample) in out_row.iter_mut().zip(in_row) {
*out += sample / divisor;
}
}
}
reduced
});
let seam_count = w - new_w;
let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, true);
let mut removed = Vec::with_capacity(seam_count);
while carver.cw > new_w {
ctl.check()?;
removed.push(carver.remove_seam());
ctl.progress(removed.len() as f32 / seam_count as f32);
}
let mut removed_by_row = Vec::with_capacity(proxy_h);
for proxy_y in 0..proxy_h {
let mut columns: Vec<usize> = removed.iter().map(|path| path[proxy_y]).collect();
columns.sort_unstable();
removed_by_row.push(columns);
}
let mut out = Vec::with_capacity(new_w * h * ch);
let mut columns = vec![0; seam_count];
for y in 0..h {
ctl.check()?;
let proxy_y = (y / step).min(proxy_h - 1);
let next_proxy_y = (proxy_y + 1).min(proxy_h - 1);
let blend = (y % step) as f32 / step as f32;
let lower = &removed_by_row[proxy_y];
let upper = &removed_by_row[next_proxy_y];
let mut min_x = 0;
for (i, x) in columns.iter_mut().enumerate() {
let interpolated = lower[i] as f32 + (upper[i] as f32 - lower[i] as f32) * blend;
let max_x = w - (seam_count - i);
*x = (interpolated.round() as usize).clamp(min_x, max_x);
min_x = x.saturating_add(1);
}
let mut next = 0;
let row = &img[y * row_len..(y + 1) * row_len];
for (x, pixel) in row.chunks_exact(ch).enumerate() {
if columns.get(next) == Some(&x) {
next += 1;
} else {
out.extend_from_slice(pixel);
}
}
}
ctl.progress(1.0);
Ok(out)
}
/// Content-aware resize of `img` to `new_w × new_h` (width first, then height by transposing).
pub fn carve(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, new_h: usize) -> Vec<f32> {
// Never cancelled; the fallback is unreachable.
@@ -639,6 +732,35 @@ mod tests {
assert!(red_columns(24, h, &plain) < 4);
}
#[test]
fn tall_proxy_shrink_preserves_full_resolution_salient_and_protected_pixels() {
let (w, h, ch, new_w) = (512, PROXY_SHRINK_MIN_HEIGHT, 3, 384);
let mut img = bar(w, h);
let mut protect = vec![0.0; w * h];
for y in 0..h {
let i = (y * w + 256) * ch;
img[i..i + ch].copy_from_slice(&[0.03, 0.87, 0.42]);
protect[y * w + 256] = 1.0;
}
let out = carve_width_with(w, h, ch, &img, Some(&protect), new_w, &Interrupt::NONE).unwrap();
assert_eq!(out.len(), new_w * h * ch);
assert_eq!(red_columns(new_w, h, &out), 4, "the full-resolution bar survives");
for y in 0..h {
let row = &out[y * new_w * ch..(y + 1) * new_w * ch];
assert!(row.chunks_exact(ch).any(|px| px == [0.03, 0.87, 0.42]), "protected mark survives row {y}");
}
}
#[test]
fn tall_proxy_shrink_observes_cancellation_before_modifying_source() {
let (w, h, ch) = (128, PROXY_SHRINK_MIN_HEIGHT, 3);
let img = vec![0.5; w * h * ch];
let cancelled = || true;
assert_eq!(carve_width_with(w, h, ch, &img, None, 64, &Interrupt::cancel_only(&cancelled)), Err(Cancelled));
}
#[test]
fn enlarging_duplicates_flat_areas() {
let (w, h) = (30, 8);
+29 -3
View File
@@ -73,7 +73,8 @@ fn stat(v: &mut [f32], s: Stat) -> f32 {
}
}
/// Combines `layers` (each `len` pixels) with statistic `s`.
/// Combines `layers` (each `len` pixels) with statistic `s`. Colour results retain their float
/// range; quantisation/clipping belongs to the destination surface, not the statistic.
pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
let len = layers.iter().map(Vec::len).max().unwrap_or(0);
let mut out = vec![[0.0f32; 4]; len];
@@ -97,7 +98,7 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
continue;
}
for c in 0..3 {
o[c] = stat(&mut samples[c], s).clamp(0.0, 1.0);
o[c] = stat(&mut samples[c], s);
}
o[3] = alpha;
}
@@ -121,7 +122,7 @@ mod tests {
assert_eq!(at(Stat::Minimum), 0.1);
assert_eq!(at(Stat::Maximum), 0.9);
assert!((at(Stat::Range) - 0.8).abs() < 1e-6);
assert_eq!(at(Stat::Summation), 1.0); // clamped
assert!((at(Stat::Summation) - 1.6).abs() < 1e-6);
let var = at(Stat::Variance);
assert!((at(Stat::StandardDeviation) - var.sqrt()).abs() < 1e-6);
assert!(at(Stat::Skewness) > 0.5, "right-skewed");
@@ -129,6 +130,31 @@ mod tests {
assert!(at(Stat::Kurtosis) > 0.0);
}
#[test]
fn statistics_preserve_hdr_and_signed_samples() {
let layers = vec![px(-2.0), px(4.0)];
for (mode, expected) in [
(Stat::Mean, 1.0),
(Stat::Median, 1.0),
(Stat::Minimum, -2.0),
(Stat::Maximum, 4.0),
(Stat::Range, 6.0),
(Stat::Summation, 2.0),
(Stat::Variance, 9.0),
(Stat::StandardDeviation, 3.0),
] {
assert_eq!(combine(&layers, mode)[0], [expected, expected, expected, 1.0], "{mode:?}");
}
for mode in [Stat::Mean, Stat::Median] {
assert_eq!(combine(&[px(2.0), px(4.0)], mode)[0], [3.0, 3.0, 3.0, 1.0]);
assert_eq!(combine(&[px(-2.0), px(-4.0)], mode)[0], [-3.0, -3.0, -3.0, 1.0]);
}
// These display-oriented statistics still apply their own normalisation.
for mode in [Stat::Entropy, Stat::Skewness, Stat::Kurtosis] {
assert!((0.0..=1.0).contains(&combine(&layers, mode)[0][0]), "{mode:?}");
}
}
#[test]
fn transparent_samples_are_ignored() {
let layers = vec![vec![[0.5, 0.5, 0.5, 1.0]], vec![[1.0, 1.0, 1.0, 0.0]]];
+14
View File
@@ -2,6 +2,9 @@
use photocraft_geom::Rect;
mod extrude;
mod wind;
use crate::fxutil::{MAXC, luma, native, ncol, subtractive, xy};
use crate::image::Image;
use crate::noise::hash01;
@@ -86,6 +89,10 @@ pub(crate) fn extrude_reach(depth: f32) -> f32 {
/// height, with shaded sides); Pyramids raise four shaded triangular faces.
/// Heights are random or follow the cell's brightness.
pub(crate) fn extrude(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> Vec<f32> {
extrude::blocks(src, out, ctx, spec).unwrap_or_else(|| extrude_direct(src, out, ctx, spec))
}
fn extrude_direct(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> Vec<f32> {
let n = src.ch;
let cs = spec.size.clamp(2.0, 255.0).round() as i32;
let b = ctx.bounds;
@@ -358,6 +365,10 @@ pub(crate) fn wind_reach(method: WindMethod) -> f32 {
/// Wind: streaks trail downwind from edges (Blast: longer and denser);
/// Stagger shifts random row segments along the wind.
pub(crate) fn wind(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_right: bool, seed: u32) -> Vec<f32> {
wind::streaks(src, out, ctx, method, from_right, seed).unwrap_or_else(|| wind_direct(src, out, ctx, method, from_right, seed))
}
fn wind_direct(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_right: bool, seed: u32) -> Vec<f32> {
let n = src.ch;
let lmax = wind_reach(method) as i32;
// Streaks move downwind: from the left they extend to the right (+x).
@@ -406,3 +417,6 @@ pub(crate) fn wind(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_r
}
res
}
#[cfg(test)]
mod tests;
+159
View File
@@ -0,0 +1,159 @@
//! Rasterize each swept block only where it can hit, in the original cell order.
use super::{ExtrudeSpec, extrude_reach, read_px};
use crate::fxutil::{MAXC, luma, ncol, subtractive, unpremul_px};
use crate::image::Image;
use crate::noise::hash01;
use crate::{Ctx, ExtrudeType};
use photocraft_geom::Rect;
const MAX_PIXELS: usize = 4_194_304;
const MAX_CELLS: i64 = 262_144;
// Beyond this, f32 pixel centres can lose enough precision to widen the sweep bounds.
const MAX_COORD: i32 = 1 << 22;
pub(super) fn blocks(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> Option<Vec<f32>> {
if spec.kind != ExtrudeType::Blocks || src.ch == 0 || src.ch > MAXC || !spec.size.is_finite() || !spec.depth.is_finite() {
return None;
}
let b = ctx.bounds;
if [b.x0, b.y0, b.x1, b.y1, out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > MAX_COORD as u32) {
return None;
}
let n = src.ch;
let w = out.width() as usize;
let count = w.checked_mul(out.height() as usize)?;
if count > MAX_PIXELS {
return None;
}
let mut heights = Vec::new();
heights.try_reserve_exact(count).ok()?;
heights.resize(count, f32::NEG_INFINITY);
let mut res = src.crop(out);
let area = out.intersect(&b);
if area.is_empty() {
return Some(res);
}
let cs = spec.size.clamp(2.0, 255.0).round() as i32;
let reach = extrude_reach(spec.depth);
let k = (reach / cs as f32).ceil() as i32 + 1;
let (gx0, gy0) = ((area.x0 - b.x0).div_euclid(cs), (area.y0 - b.y0).div_euclid(cs));
let (gx1, gy1) = ((area.x1 - 1 - b.x0).div_euclid(cs), (area.y1 - 1 - b.y0).div_euclid(cs));
let (cx0, cy0) = ((gx0 - k).max(0), (gy0 - k).max(0));
let (cx1, cy1) = ((gx1 + k).min((b.width() as i32 - 1) / cs), (gy1 + k).min((b.height() as i32 - 1) / cs));
if i64::from(cx1 - cx0 + 1).checked_mul(i64::from(cy1 - cy0 + 1))? > MAX_CELLS {
return None;
}
let (bcx, bcy) = ((b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0);
let half_diag = ((b.width() as f32).hypot(b.height() as f32) / 2.0).max(1.0);
let half = cs as f32 / 2.0;
let cc = ncol(ctx, n);
let sub = subtractive(ctx);
// Ascending gy, then gx, is the tie order of every pixel's old candidate search.
for cy in cy0..=cy1 {
for cx in cx0..=cx1 {
let r = Rect::new(b.x0 + cx * cs, b.y0 + cy * cs, b.x0 + (cx + 1) * cs, b.y0 + (cy + 1) * cs);
let clipped = r.intersect(&b);
if spec.mask_incomplete && clipped != r {
continue;
}
let mut col = [0.0f32; MAXC];
let mut cnt = 0.0;
let step = (cs / 8).max(1) as usize;
for yy in (clipped.y0..clipped.y1).step_by(step) {
for xx in (clipped.x0..clipped.x1).step_by(step) {
let a = if ctx.alpha { src.get(xx, yy, n - 1) } else { 1.0 };
for (c, v) in col.iter_mut().enumerate().take(n) {
let s = src.get(xx, yy, c);
*v += if ctx.alpha && c < n - 1 { s * a } else { s };
}
cnt += 1.0;
}
}
if cnt > 0.0 {
for v in col.iter_mut().take(n) {
*v /= cnt;
}
}
unpremul_px(&mut col[..n], ctx.alpha);
let h = if spec.level_based { luma(ctx, &col[..n]).clamp(0.0, 1.0) } else { hash01(cx, cy, 21, spec.seed) };
if !h.is_finite() {
return None;
}
let (mx, my) = (r.x0 as f32 + half, r.y0 as f32 + half);
let off = reach * h / half_diag;
let (ox, oy) = ((mx - bcx) * off, (my - bcy) * off);
// Two extra pixels conservatively cover rounding in the unchanged interval test.
let sweep = Rect::new(
(mx.min(mx + ox) - half).floor() as i32 - 2,
(my.min(my + oy) - half).floor() as i32 - 2,
(mx.max(mx + ox) + half).ceil() as i32 + 2,
(my.max(my + oy) + half).ceil() as i32 + 2,
)
.intersect(&area);
for y in sweep.y0..sweep.y1 {
let gy = (y - b.y0).div_euclid(cs);
if (cy - gy).abs() > k {
continue;
}
let fy = y as f32 + 0.5;
let Some(iy) = interval(fy, my, oy, half) else { continue };
for x in sweep.x0..sweep.x1 {
if (cx - (x - b.x0).div_euclid(cs)).abs() > k {
continue;
}
let i = (y - out.y0) as usize * w + (x - out.x0) as usize;
let height = heights.get_mut(i)?;
if *height >= h {
continue;
}
let fx = x as f32 + 0.5;
let Some(ix) = interval(fx, mx, ox, half) else { continue };
let (lo, hi) = (ix.0.max(iy.0), ix.1.min(iy.1));
if lo > hi {
continue;
}
*height = h;
let face = if hi >= 1.0 {
0
} else if ix.1 < iy.1 {
if ox > 0.0 { 1 } else { 2 }
} else if oy > 0.0 {
3
} else {
4
};
let px = res.get_mut(i * n..(i + 1) * n)?;
if face == 0 && !spec.solid_front {
let (sx, sy) = ((fx - (mx - bcx) * off).floor() as i32, (fy - (my - bcy) * off).floor() as i32);
read_px(src, sx.clamp(r.x0, r.x1 - 1), sy.clamp(r.y0, r.y1 - 1), px);
} else {
let shade = match face {
0 => 1.0,
1 => 0.85,
2 => 0.55,
3 => 0.95,
_ => 0.45,
};
for (v, c) in px.iter_mut().zip(&col).take(cc) {
*v = if sub { 1.0 - (1.0 - *c) * shade } else { *c * shade }.clamp(0.0, 1.0);
}
if ctx.alpha {
*px.last_mut()? = col.get(n - 1).copied()?;
}
}
}
}
}
}
Some(res)
}
fn interval(p: f32, m: f32, o: f32, half: f32) -> Option<(f32, f32)> {
if o.abs() < 1e-6 {
return ((p - m).abs() <= half).then_some((0.0, 1.0));
}
let (a, b) = ((p - m - half) / o, (p - m + half) / o);
let (lo, hi) = (a.min(b).max(0.0), a.max(b).min(1.0));
(lo <= hi).then_some((lo, hi))
}
+190
View File
@@ -0,0 +1,190 @@
use super::*;
use crate::{FilterParams, apply_tiled};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::Surface;
fn image(rect: Rect, n: usize, kind: usize) -> Image {
let mut img = Image::new(rect, n);
let mut s = 0x2545_f491u32;
for (i, v) in img.data.iter_mut().enumerate() {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
*v = match kind {
0 => (s % 256) as f32 / 255.0,
1 => ((i / n) % 5) as f32 / 4.0,
2 => 0.0,
3 => (s % 65536) as f32 / 65535.0,
_ => [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, -2.0, 3.5, 0.5][s as usize % 7],
};
}
img
}
fn bits(v: Vec<f32>) -> Vec<u32> {
v.into_iter().map(f32::to_bits).collect()
}
const MODELS: [ColorMode; 8] =
[ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Bitmap, ColorMode::Duotone, ColorMode::Indexed, ColorMode::Multichannel];
#[test]
fn swept_blocks_match_the_pixel_search() {
let bounds = Rect::new(-13, -9, 31, 26);
for mode in MODELS {
for alpha in [false, true] {
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
let ctx = Ctx { bounds, mode, alpha };
for kind in 0..5 {
let src = image(bounds.inflate(20), n, kind);
for (size, depth) in [(2.0, 0.0), (2.0, 255.0), (3.5, 190.0), (8.0, 64.0), (22.0, 255.0), (255.0, 100.0)] {
for flags in 0..8 {
let spec = ExtrudeSpec {
kind: ExtrudeType::Blocks,
size,
depth,
level_based: flags & 1 != 0,
solid_front: flags & 2 != 0,
mask_incomplete: flags & 4 != 0,
seed: 17,
};
let out = Rect::new(-17, -2, 24, 29);
assert_eq!(
bits(extrude(&src, out, &ctx, &spec)),
bits(extrude_direct(&src, out, &ctx, &spec)),
"{mode:?} alpha {alpha} kind {kind} size {size} depth {depth} flags {flags}"
);
}
}
}
}
}
}
#[test]
fn streak_scatter_matches_the_pixel_search() {
let bounds = Rect::new(-17, -9, 39, 14);
for mode in MODELS {
for alpha in [false, true] {
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
let ctx = Ctx { bounds, mode, alpha };
for kind in 0..5 {
let src = image(bounds, n, kind);
for method in [WindMethod::Wind, WindMethod::Blast, WindMethod::Stagger] {
for right in [false, true] {
for seed in [0, 1, 777, u32::MAX] {
let out = bounds.inflate(3);
assert_eq!(
bits(wind(&src, out, &ctx, method, right, seed)),
bits(wind_direct(&src, out, &ctx, method, right, seed)),
"{mode:?} alpha {alpha} kind {kind} {method:?} right {right} seed {seed}"
);
}
}
}
}
}
}
}
#[test]
fn thin_and_partial_windows_match() {
for bounds in [Rect::new(0, 0, 1, 1), Rect::new(-5, -6, -4, 31), Rect::new(3, 8, 43, 9)] {
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
for src in [image(bounds, 4, 0), image(bounds.inflate(2), 4, 1)] {
let out = bounds.inflate(2);
for right in [false, true] {
assert_eq!(bits(wind(&src, out, &ctx, WindMethod::Blast, right, 17)), bits(wind_direct(&src, out, &ctx, WindMethod::Blast, right, 17)));
}
for kind in [ExtrudeType::Blocks, ExtrudeType::Pyramids] {
let spec = ExtrudeSpec { kind, size: 8.0, depth: 255.0, level_based: true, solid_front: false, mask_incomplete: false, seed: 17 };
assert_eq!(bits(extrude(&src, out, &ctx, &spec)), bits(extrude_direct(&src, out, &ctx, &spec)));
}
}
}
}
#[test]
fn block_bounds_cover_float_rounding_and_ties() {
for offset in [0, -(1 << 22), (1 << 22) - 80] {
let bounds = Rect::new(offset, offset, offset + 64, offset + 49);
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
let src = image(bounds, 4, 1);
for size in [2.0, 7.0, 19.0] {
let spec = ExtrudeSpec { kind: ExtrudeType::Blocks, size, depth: 255.0, level_based: true, solid_front: true, mask_incomplete: false, seed: 42 };
assert!(extrude::blocks(&src, bounds, &ctx, &spec).is_some());
assert_eq!(bits(extrude(&src, bounds, &ctx, &spec)), bits(extrude_direct(&src, bounds, &ctx, &spec)));
}
}
}
#[test]
fn optional_scratch_rejects_large_and_unsupported_geometry() {
let bounds = Rect::new(0, 0, 1, 1);
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
let src = image(bounds, 4, 0);
let spec = ExtrudeSpec { kind: ExtrudeType::Blocks, size: 2.0, depth: 255.0, level_based: false, solid_front: true, mask_incomplete: false, seed: 42 };
assert!(extrude::blocks(&src, Rect::new(0, 0, 4096, 4096), &ctx, &spec).is_none());
assert!(wind::streaks(&src, Rect::new(0, 0, 1_048_577, 1), &ctx, WindMethod::Blast, false, 0).is_none());
let far = Ctx { bounds: Rect::new(10_000_000, 0, 10_000_010, 10), ..ctx };
assert!(extrude::blocks(&src, bounds, &far, &spec).is_none());
}
#[test]
fn storage_selection_and_tile_sizes_match_original_kernels() {
let bounds = Rect::new(-11, -7, 32, 24);
let area = Rect::new(-9, -5, 29, 21);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let fmt = PixelFormat::new(mode, sample, true);
let ctx = Ctx { bounds, mode, alpha: true };
let img = image(bounds, fmt.channels(), 3);
let mut s = Surface::new(fmt);
s.write_region(bounds, &img.data);
let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
let mask: Vec<f32> = (0..bounds.width() as usize * bounds.height() as usize).map(|i| (i % 5) as f32 / 4.0).collect();
selection.write_region(bounds, &mask);
let cases = [
FilterParams::Extrude {
kind: ExtrudeType::Blocks,
size: 7.0,
depth: 170.0,
level_based: true,
solid_front: false,
mask_incomplete: false,
seed: 17,
},
FilterParams::Wind { method: WindMethod::Blast, from_right: true, seed: 17 },
];
for p in cases {
let halo = match p {
FilterParams::Extrude { .. } => 70,
_ => 40,
};
let src = Image::read_clamped(&s, area.inflate(halo), bounds);
let mut raw = match p {
FilterParams::Extrude { kind, size, depth, level_based, solid_front, mask_incomplete, seed } => {
extrude_direct(&src, area, &ctx, &ExtrudeSpec { kind, size, depth, level_based, solid_front, mask_incomplete, seed })
}
FilterParams::Wind { method, from_right, seed } => wind_direct(&src, area, &ctx, method, from_right, seed),
_ => continue,
};
let original = s.read_region(area);
for (i, px) in raw.chunks_exact_mut(fmt.channels()).enumerate() {
let (x, y) = (area.x0 + (i % area.width() as usize) as i32, area.y0 + (i / area.width() as usize) as i32);
let k = selection.sample_channel(x, y, 0);
if k >= 1.0 {
continue;
}
for (c, v) in px.iter_mut().enumerate() {
*v = original[i * fmt.channels() + c] + (*v - original[i * fmt.channels() + c]) * k;
}
}
let mut want = s.clone();
want.write_region(area, &raw);
for tile in [1, 7, 17, 256] {
let got = apply_tiled(&s, &p, area, bounds, Some(&selection), tile, Some(bounds));
assert_eq!(bits(got.read_region(bounds)), bits(want.read_region(bounds)), "{mode:?} {sample:?} {} tile {tile}", p.label());
}
}
}
}
}
+73
View File
@@ -0,0 +1,73 @@
//! Scatter each source streak once, retaining the old nearest-source tie rule.
use super::{read_px, wind_reach};
use crate::fxutil::{MAXC, luma};
use crate::image::Image;
use crate::noise::hash01;
use crate::{Ctx, WindMethod};
use photocraft_geom::Rect;
const MAX_ROW: usize = 1_048_576;
pub(super) fn streaks(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_right: bool, seed: u32) -> Option<Vec<f32>> {
if method == WindMethod::Stagger || src.ch == 0 || src.ch > MAXC {
return None;
}
let n = src.ch;
let w = out.width() as usize;
if w == 0 || w > MAX_ROW || out.is_empty() {
return None;
}
let lmax = wind_reach(method) as i32;
let d = if from_right { -1 } else { 1 };
// Include the upstream halo and one more source for the edge difference.
let start = if from_right { out.x1.checked_add(lmax - 1)? } else { out.x0.checked_sub(lmax)? };
start.checked_sub(d)?;
let sources = w.checked_add(lmax as usize)?;
let mut best = Vec::new();
best.try_reserve_exact(w).ok()?;
best.resize(w, (0.0f32, lmax + 1));
let mut res = src.crop(out);
let mut tmp = [0.0f32; MAXC];
let strength = if method == WindMethod::Blast { 8.0 } else { 4.0 };
for y in out.y0..out.y1 {
best.fill((0.0, lmax + 1));
read_px(src, start - d, y, &mut tmp[..n]);
let mut prev = luma(ctx, &tmp[..n]);
for step in 0..sources {
let s = i32::try_from(i64::from(start) + i64::from(d) * i64::try_from(step).ok()?).ok()?;
read_px(src, s, y, &mut tmp[..n]);
let lum = luma(ctx, &tmp[..n]);
let edge = ((lum - prev).abs() * strength).min(1.0);
prev = lum;
if edge <= 0.0 {
continue;
}
let len = (hash01(s, y, 44, seed).powi(2) * lmax as f32).max(1.0);
for k in 1..=(len.floor() as i32).min(lmax) {
let x = s.checked_add(d * k)?;
if x < out.x0 || x >= out.x1 {
continue;
}
let weight = edge * (1.0 - k as f32 / (len + 1.0));
let hit = best.get_mut((x - out.x0) as usize)?;
// Sources are visited downwind; equal weights prefer the smallest original k.
if weight > hit.0 || (weight == hit.0 && k < hit.1) {
*hit = (weight, k);
}
}
}
let row = (y - out.y0) as usize * w * n;
for (x, &(weight, k)) in best.iter().enumerate() {
if weight > 0.0 {
let sx = i32::try_from(i64::from(out.x0) + x as i64 - i64::from(d * k)).ok()?;
read_px(src, sx, y, &mut tmp[..n]);
let px = res.get_mut(row + x * n..row + (x + 1) * n)?;
for (v, s) in px.iter_mut().zip(&tmp) {
*v += (*s - *v) * weight;
}
}
}
}
Some(res)
}
+140 -3
View File
@@ -8,8 +8,82 @@
use crate::photo_util::par_rows;
/// Separable grayscale dilation (max filter) of one interleaved channel, radius `r` px.
/// Prefix/suffix block maxima, with clipped edge windows and the original zero floor.
/// One comparison per sample in each direction, independent of the trap width.
fn max_line(out: &mut [f32], radius: usize, get: impl Fn(usize) -> f32) {
let len = out.len();
if len == 0 {
return;
}
let r = radius.min(len - 1);
let mut suffix = Vec::new();
if r <= 2 || suffix.try_reserve_exact(len).is_err() {
for (x, v) in out.iter_mut().enumerate() {
*v = (x.saturating_sub(r)..=x.saturating_add(r).min(len - 1)).fold(0.0f32, |m, i| m.max(get(i)));
}
return;
}
suffix.resize(len, 0.0f32);
let block = r.saturating_mul(2).saturating_add(1);
let mut acc = 0.0f32;
let mut negative_zero = false;
for (i, v) in out.iter_mut().enumerate() {
if i % block == 0 {
acc = 0.0;
}
let value = get(i);
negative_zero |= value.to_bits() == (-0.0f32).to_bits();
acc = acc.max(value);
*v = acc;
}
// f32::max may choose either sign of equal zero depending on operand order.
// Preserve the original left-to-right fold for these uncommon float lines.
if negative_zero {
for (x, v) in out.iter_mut().enumerate() {
*v = (x.saturating_sub(r)..=x.saturating_add(r).min(len - 1)).fold(0.0f32, |m, i| m.max(get(i)));
}
return;
}
acc = 0.0;
for (i, v) in suffix.iter_mut().enumerate().rev() {
if i == len - 1 || (i + 1) % block == 0 {
acc = 0.0;
}
acc = acc.max(get(i));
*v = acc;
}
for x in 0..len {
let (lo, hi) = (x.saturating_sub(r), x.saturating_add(r).min(len - 1));
let value = if lo == 0 {
out.get(hi).copied().unwrap_or(0.0)
} else if hi == len - 1 && lo / block == hi / block {
suffix.get(lo).copied().unwrap_or(0.0)
} else {
suffix.get(lo).copied().unwrap_or(0.0).max(out.get(hi).copied().unwrap_or(0.0))
};
if let Some(v) = out.get_mut(x) {
*v = value;
}
}
}
/// A channel's square dilation, transposed so each vertical scan writes a contiguous row.
fn dilate_channel(src: &[f32], w: usize, h: usize, ch: usize, c: usize, r: usize) -> Vec<f32> {
if r <= 2 {
return dilate_channel_direct(src, w, h, ch, c, r);
}
let mut tmp = vec![0.0f32; w * h];
par_rows(&mut tmp, w, 1, |y, row| {
max_line(row, r, |x| src.get((y * w + x) * ch + c).copied().unwrap_or(0.0));
});
let mut out = vec![0.0f32; w * h];
par_rows(&mut out, h, 1, |x, column| {
max_line(column, r, |y| tmp.get(y * w + x).copied().unwrap_or(0.0));
});
out
}
fn dilate_channel_direct(src: &[f32], w: usize, h: usize, ch: usize, c: usize, r: usize) -> Vec<f32> {
let get = |buf: &[f32], x: usize, y: usize| buf[(y * w + x) * ch + c];
// Horizontal pass.
let mut tmp = vec![0.0f32; w * h];
@@ -44,14 +118,14 @@ fn dilate_channel(src: &[f32], w: usize, h: usize, ch: usize, c: usize, r: usize
/// spread; any alpha channel (`ch > 4`) is untouched. A pixel keeps its original ink where it has
/// no ink at all (paper), so only internal colour edges gain a trap band.
pub fn trap(px: &mut [f32], w: usize, h: usize, ch: usize, width: usize) {
if width == 0 || w == 0 || h == 0 || ch < 4 {
if width == 0 || w == 0 || h == 0 || ch < 4 || w.checked_mul(h).and_then(|n| n.checked_mul(ch)) != Some(px.len()) {
return;
}
let inks = 4.min(ch);
let dil: Vec<Vec<f32>> = (0..inks).map(|c| dilate_channel(px, w, h, ch, c, width)).collect();
par_rows(px, w, ch, |y, row| {
for (x, pixel) in row.chunks_exact_mut(ch).enumerate() {
let i = y * w + x;
let i = if width <= 2 { y * w + x } else { x * h + y };
// Printed where any ink is present.
let inked = (0..inks).any(|c| pixel[c] > 1e-4);
if !inked {
@@ -123,3 +197,66 @@ mod tests {
assert_eq!(px, orig);
}
}
#[cfg(test)]
mod linear_tests {
use super::*;
#[test]
fn all_clipped_windows_match_the_direct_maximum() {
let mut seed = 1234567u32;
for len in 1..90 {
let values: Vec<f32> = (0..len)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
[f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, 0.0, -2.0, 0.25, 1.0, 2.0][seed as usize % 9]
})
.collect();
for radius in [0, 1, 2, 3, 5, 16, 50, 128, usize::MAX] {
let mut out = vec![0.0; len];
max_line(&mut out, radius, |i| values[i]);
for (x, v) in out.iter().enumerate() {
let want = (x.saturating_sub(radius)..=x.saturating_add(radius).min(len - 1)).fold(0.0f32, |m, i| m.max(values[i]));
assert_eq!(v.to_bits(), want.to_bits(), "len {len} radius {radius} x {x}");
}
}
}
}
#[test]
fn trap_preserves_paper_alpha_and_spare_channels_exactly() {
for (w, h, ch) in [(1, 31, 5), (47, 1, 4), (37, 29, 4), (37, 29, 5), (23, 19, 8)] {
for radius in [1, 3, 10, 128] {
let src: Vec<f32> = (0..w * h * ch).map(|i| if (i / ch) % 5 == 0 { 0.0 } else { ((i * 71 + i / ch * 103) % 65536) as f32 / 65535.0 }).collect();
let mut want = src.clone();
for y in 0..h {
for x in 0..w {
let index = (y * w + x) * ch;
if !(0..4).any(|c| src[index + c] > 1e-4) {
continue;
}
for c in 0..4 {
let mut m = 0.0f32;
for yy in y.saturating_sub(radius)..=y.saturating_add(radius).min(h - 1) {
for xx in x.saturating_sub(radius)..=x.saturating_add(radius).min(w - 1) {
m = m.max(src[(yy * w + xx) * ch + c]);
}
}
want[index + c] = src[index + c].max(m);
}
}
}
let mut got = src;
trap(&mut got, w, h, ch, radius);
assert_eq!(got.iter().map(|v| v.to_bits()).collect::<Vec<_>>(), want.iter().map(|v| v.to_bits()).collect::<Vec<_>>());
}
}
}
#[test]
fn malformed_buffer_is_left_alone() {
let mut pixels = vec![0.5; 20];
let original = pixels.clone();
trap(&mut pixels, usize::MAX, 2, 5, 10);
assert_eq!(pixels, original);
}
}
+40 -4
View File
@@ -48,12 +48,14 @@ impl Transfer {
fn decode(self, v: f32) -> f32 {
match self {
Transfer::Srgb => photocraft_color::convert::srgb_to_linear(v.max(0.0)),
Transfer::Gamma(1.0) => v.max(0.0),
Transfer::Gamma(g) => v.max(0.0).powf(g),
}
}
fn encode(self, v: f32) -> f32 {
match self {
Transfer::Srgb => photocraft_color::convert::linear_to_srgb(v.max(0.0)),
Transfer::Gamma(1.0) => v.max(0.0),
Transfer::Gamma(g) => v.max(0.0).powf(1.0 / g),
}
}
@@ -116,17 +118,39 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
let m = 2f32.powf(*exposure);
let g = gamma.max(0.01);
let transfer = transfer.for_exposure();
let hdr = depth == Some(SampleType::F32);
map_rgb(buf, |c| {
c.map(|v| {
let lin = (transfer.decode(v) * m + offset).max(0.0).powf(1.0 / g);
if hdr {
// Float documents store linear samples. Photoshop's 32-bit merged
// Exposure oracle also retains negative results with signed gamma.
let lin = v * m + offset;
return if g == 1.0 { lin } else { lin.signum() * lin.abs().powf(1.0 / g) };
}
let lin = (transfer.decode(v) * m + offset).max(0.0);
let lin = if g == 1.0 { lin } else { lin.powf(1.0 / g) };
transfer.encode(lin).clamp(0.0, 1.0)
})
})
}
Adjustment::Levels { master, per_channel, space: ToneSpace::Rgb, .. } if depth == Some(SampleType::F32) => {
// A 0..1 lookup table cannot represent signed, unbounded float samples.
let master_identity = *master == LevelsChannel::default();
let channel_identity = per_channel.each_ref().map(|ch| *ch == LevelsChannel::default());
if !master_identity || channel_identity.iter().any(|&v| !v) {
map_rgb(buf, |c| {
std::array::from_fn(|i| {
let v = if channel_identity[i] { c[i] } else { levels_float(&per_channel[i], c[i]) };
if master_identity { v } else { levels_float(master, v) }
})
});
}
}
Adjustment::Levels { space, .. } | Adjustment::Curves { space, .. } => {
let luts = tone_luts_depth(adj, depth);
let identity = float_tone_identity(adj, depth);
match space {
ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| lut(&luts[i], c[i]))),
ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| if identity[i] { c[i] } else { lut(&luts[i], c[i]) })),
ToneSpace::Cmyk | ToneSpace::Lab => map_rgb(buf, |c| tone_in_space(*space, &luts, c)),
}
}
@@ -222,6 +246,17 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
}
}
/// Float RGB curve channels with no edit must preserve samples outside the LUT domain.
/// Edited curves retain the existing spline/LUT behaviour until HDR extrapolation has an oracle.
pub fn float_tone_identity(adj: &Adjustment, depth: Option<SampleType>) -> [bool; 3] {
match adj {
Adjustment::Curves { master, per_channel, space: ToneSpace::Rgb, .. } if depth == Some(SampleType::F32) => {
std::array::from_fn(|i| photocraft_doc::adjust::is_identity_curve(master) && photocraft_doc::adjust::is_identity_curve(&per_channel[i]))
}
_ => [false; 3],
}
}
/// Per-channel LUTs of a Levels or Curves adjustment: each channel's record first, then the
/// master (composite) record, as Photoshop does (psd-tools levels_rgb.psd and curves_rgb.psd,
/// #975). Four rows: the three `per_channel` channels then black
@@ -251,7 +286,8 @@ pub fn tone_luts_q(adj: &Adjustment, quantum: Option<f32>) -> [Vec<f32>; 4] {
}
/// [`tone_luts_q`] in a document of `depth` (`None`: unrounded). 32-bit Levels use
/// [`levels_float`], sampled on 0..1 and kept in 0..1 like every adjustment result here.
/// [`levels_float`], sampled on 0..1 and kept in 0..1 for LUT consumers. Float RGB rendering
/// evaluates the formula directly instead, so samples and results outside that domain survive.
pub fn tone_luts_depth(adj: &Adjustment, depth: Option<SampleType>) -> [Vec<f32>; 4] {
match (adj, depth) {
(Adjustment::Levels { master, per_channel, space: ToneSpace::Rgb, .. }, Some(SampleType::F32)) => {
@@ -648,7 +684,7 @@ pub fn levels_q(ch: &LevelsChannel, v: f32, quantum: Option<f32>) -> f32 {
/// the clipped curve).
pub fn levels_float(ch: &LevelsChannel, v: f32) -> f32 {
let t = (v - ch.in_black) / (ch.in_white - ch.in_black).max(1e-6);
let t = t.signum() * t.abs().powf(1.0 / ch.gamma.max(0.01));
let t = if ch.gamma == 1.0 { t } else { t.signum() * t.abs().powf(1.0 / ch.gamma.max(0.01)) };
ch.out_black + t * (ch.out_white - ch.out_black)
}
+96 -1
View File
@@ -1529,9 +1529,11 @@ fn levels_dont_clip_in_32_bit() {
adjust::apply_depth(&adj, &mut b, adjust::Transfer::Srgb, Some(depth));
(b.px[0][0] * 255.0).round()
};
for (v, want) in [(0.0, 0.0), (0.0497, 3.0), (0.0976, 32.0), (0.4508, 140.0), (0.9473, 255.0), (1.0, 255.0)] {
for (v, want) in [(0.0497, 3.0), (0.0976, 32.0), (0.4508, 140.0), (0.9473, 255.0)] {
assert_eq!(level(SampleType::F32, v), want, "{v}");
}
assert!(level(SampleType::F32, 0.0) < 0.0);
assert!(level(SampleType::F32, 1.0) > 255.0);
assert_eq!(level(SampleType::U8, 0.0), 10.0);
assert_eq!(level(SampleType::U8, 1.0), 245.0);
}
@@ -1564,6 +1566,99 @@ fn exposure_scales_32_bit_samples_as_stored() {
}
}
#[test]
fn float_adjustments_preserve_hdr_range() {
let identity = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 1.0 }];
let levels = LevelsChannel { in_black: 0.25, in_white: 0.75, gamma: 2.0, out_black: -0.1, out_white: 1.5 };
let adjustments = [
(Adjustment::Exposure { exposure: 0.0, offset: 0.0, gamma: 1.0 }, [4.0, 2.0, 0.5]),
(Adjustment::Exposure { exposure: 1.0, offset: 0.0, gamma: 1.0 }, [8.0, 4.0, 1.0]),
(
Adjustment::Levels { master: Default::default(), per_channel: Default::default(), space: Default::default(), black: Default::default() },
[4.0, 2.0, 0.5],
),
(
Adjustment::Levels { master: levels, per_channel: Default::default(), space: Default::default(), black: Default::default() },
[4.0, 2.0, 0.5].map(|v| -0.1 + ((v - 0.25_f32) / 0.5).sqrt() * 1.6),
),
(
Adjustment::Curves {
master: identity.clone(),
per_channel: std::array::from_fn(|_| identity.clone()),
space: Default::default(),
black: Vec::new(),
},
[4.0, 2.0, 0.5],
),
(Adjustment::Curves { master: Vec::new(), per_channel: Default::default(), space: Default::default(), black: Vec::new() }, [4.0, 2.0, 0.5]),
];
for (adj, want) in adjustments {
let mut d = Document::new("HDR", Size::new(1, 1), ColorMode::Rgb, SampleType::F32);
let mut l = Layer::raster("pixels", PixelFormat::RGBA32F);
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[4.0, 2.0, 0.5, 0.75]);
d.layers = vec![l, Layer::new("adjustment", LayerContent::Adjustment(adj.clone()))];
let got = px(&d, 0, 0);
for (a, b) in got[..3].iter().zip(want) {
assert!((a - b).abs() < 1e-5, "{adj:?}: {got:?} vs {want:?}");
}
assert_eq!(got[3], 0.75);
}
}
#[test]
fn float_tone_adjustments_preserve_negative_and_untouched_channels() {
let identity = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 1.0 }];
let edited = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 0.5 }];
for (adj, want) in [
(
Adjustment::Levels { master: Default::default(), per_channel: Default::default(), space: Default::default(), black: Default::default() },
[-0.1, 4.0, 0.5],
),
(
Adjustment::Curves { master: identity.clone(), per_channel: [identity.clone(), Vec::new(), edited], space: Default::default(), black: Vec::new() },
[-0.1, 4.0, 0.25],
),
] {
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [-0.1, 4.0, 0.5, 0.75]);
adjust::apply_depth(&adj, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
for (a, b) in b.px[0][..3].iter().zip(want) {
assert!((a - b).abs() < 1e-5, "{adj:?}: {:?} vs {want:?}", b);
}
assert_eq!(b.px[0][3], 0.75);
}
}
#[test]
fn float_exposure_stops_round_trip_without_losing_highlights() {
let input = [4.0, -2.0, 0.5, 0.75];
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), input);
for exposure in [1.0, -1.0] {
adjust::apply_depth(&Adjustment::Exposure { exposure, offset: 0.0, gamma: 1.0 }, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
}
assert_eq!(b.px[0], input);
}
#[test]
fn float_exposure_preserves_signed_gamma_and_offset() {
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [-0.5, 0.0, 4.0, 0.75]);
adjust::apply_depth(&Adjustment::Exposure { exposure: 1.0, offset: -0.25, gamma: 2.0 }, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
for (got, want) in b.px[0].iter().zip([-1.25_f32.sqrt(), -0.5, 7.75_f32.sqrt(), 0.75]) {
assert!((got - want).abs() < 1e-6, "{:?}", b.px[0]);
}
}
#[test]
fn float_levels_apply_channel_before_master_outside_unit_range() {
let channel = LevelsChannel { in_black: 0.5, in_white: 1.5, ..Default::default() };
let master = LevelsChannel { out_black: -0.1, out_white: 1.5, ..Default::default() };
let adj = Adjustment::Levels { master, per_channel: std::array::from_fn(|_| channel.clone()), space: Default::default(), black: Default::default() };
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [4.0, 2.0, -0.1, 1.0]);
adjust::apply_depth(&adj, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
for (got, want) in b.px[0].iter().zip([5.5, 2.3, -1.06, 1.0]) {
assert!((got - want).abs() < 1e-6, "{:?}", b.px[0]);
}
}
// A 30° Reflected gradient fill on a 4 × 4 canvas renders as Photoshop's (its end point snaps to
// the corner: t = |x − y| / 4) at every depth.
#[test]
+1 -1
View File
@@ -38,7 +38,7 @@ pub(crate) fn copy(s: &mut Session) -> Result<Option<Value>> {
let canvas = doc.bounds();
// The surface the values come from, and which of its samples is the channel.
let (src, k) = match d.channel_view.target {
ChannelTarget::Composite => return Ok(None),
ChannelTarget::Composite | ChannelTarget::LayerMask | ChannelTarget::VectorMask => return Ok(None),
ChannelTarget::Color(k) => {
let id = d.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let surf = doc.layer(id).and_then(Layer::surface).ok_or_else(|| EngineError::Other("the active layer has no pixels".into()))?;
+52 -17
View File
@@ -40,6 +40,10 @@ pub enum ChannelTarget {
Color(usize),
/// An alpha or spot channel (index into [`Document::channels`]).
Alpha(usize),
/// The active layer's layer mask.
LayerMask,
/// The active layer's vector mask.
VectorMask,
}
/// Per-document Channels panel state: the target and the eye toggles. Not part of history.
@@ -121,6 +125,12 @@ pub(crate) fn fix_view(st: &mut DocState) {
match v.target {
ChannelTarget::Alpha(i) if i >= n => v.target = ChannelTarget::Composite,
ChannelTarget::Color(k) if k >= colors || colors < 2 => v.target = ChannelTarget::Composite,
ChannelTarget::LayerMask if !st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.mask.is_some()) => {
v.target = ChannelTarget::Composite;
}
ChannelTarget::VectorMask if !st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.vector_mask.is_some()) => {
v.target = ChannelTarget::Composite;
}
_ => {}
}
}
@@ -615,6 +625,7 @@ pub(crate) fn inject_target(s: &Session, id: &str, params: Value) -> Value {
let t = match st.channel_view.target {
ChannelTarget::Alpha(i) if i < st.doc.channels.len() => json!({ "channel": i }),
ChannelTarget::Composite if st.doc.quick_mask.is_some() => json!("quickMask"),
ChannelTarget::LayerMask => json!("mask"),
// Viewing the active layer's mask (⌥-click): pixel commands edit the mask.
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() => json!("mask"),
_ => return params,
@@ -1148,23 +1159,25 @@ fn move_channel(s: &mut Session, p: &Value) -> Result<Value> {
/// Make `t` the edit target and show it the way Photoshop does when its row is clicked.
fn target(s: &mut Session, t: ChannelTarget) {
let Some(st) = s.active_mut() else { return };
let colors = color_count(&st.doc);
let n = st.doc.channels.len();
let v = &mut st.channel_view;
v.target = t;
match t {
ChannelTarget::Composite => v.color_hidden.clear(),
ChannelTarget::Color(k) => {
v.color_hidden = (0..colors).map(|c| c != k).collect();
v.alpha_visible.clear();
}
ChannelTarget::Alpha(i) => {
v.color_hidden = vec![true; colors];
v.alpha_visible = (0..n).map(|c| c == i).collect();
if let Some(st) = s.active_mut() {
let colors = color_count(&st.doc);
let n = st.doc.channels.len();
let v = &mut st.channel_view;
v.target = t;
match t {
ChannelTarget::Composite | ChannelTarget::LayerMask | ChannelTarget::VectorMask => v.color_hidden.clear(),
ChannelTarget::Color(k) => {
v.color_hidden = (0..colors).map(|c| c != k).collect();
v.alpha_visible.clear();
}
ChannelTarget::Alpha(i) => {
v.color_hidden = vec![true; colors];
v.alpha_visible = (0..n).map(|c| c == i).collect();
}
}
fix_view_now(st);
}
fix_view_now(st);
s.tools.target_mask(t == ChannelTarget::LayerMask);
}
fn parse_target(s: &Session, p: &Value, cmd: &str) -> Result<ChannelTarget> {
@@ -1173,7 +1186,21 @@ fn parse_target(s: &Session, p: &Value, cmd: &str) -> Result<ChannelTarget> {
Some(ChanRef::Composite) => Ok(ChannelTarget::Composite),
Some(ChanRef::Color(k)) if k < color_count(doc) && color_count(doc) > 1 => Ok(ChannelTarget::Color(k)),
Some(ChanRef::Alpha(i)) if i < doc.channels.len() => Ok(ChannelTarget::Alpha(i)),
_ => Err(bad(cmd, "`channel` must be \"composite\", a colour channel name or an alpha channel")),
Some(ChanRef::LayerMask) => {
let active = crate::active_layer_of(s).map_err(|e| bad(cmd, e))?;
if active.mask.is_none() {
return Err(bad(cmd, "the active layer has no layer mask"));
}
Ok(ChannelTarget::LayerMask)
}
Some(ChanRef::VectorMask) => {
let active = crate::active_layer_of(s).map_err(|e| bad(cmd, e))?;
if active.vector_mask.is_none() {
return Err(bad(cmd, "the active layer has no vector mask"));
}
Ok(ChannelTarget::VectorMask)
}
_ => Err(bad(cmd, "`channel` must be \"composite\", \"mask\", \"vectorMask\", a colour channel name or an alpha channel")),
}
}
@@ -1671,7 +1698,15 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("channel.delete", "Delete Channel", [], None, r##"{"channel":index|name?=targeted}"##, has_channels, delete_channel),
spec!("channel.rename", "Rename Channel", [], None, r##"{"channel":index|name,"name":str}"##, has_channels, rename_channel),
spec!("channel.move", "Reorder Channel", [], None, r##"{"channel":index|name,"to":index}"##, has_channels, move_channel),
spec!("channel.target", "Target Channel", [], None, r##"{"channel":"composite"|"red|green|blue|…"|index|name="composite"}"##, has_doc, target_cmd),
spec!(
"channel.target",
"Target Channel",
[],
None,
r##"{"channel":"composite"|"mask"|"vectorMask"|"red|green|blue|…"|index|name="composite"}"##,
has_doc,
target_cmd
),
spec!(
"channel.setVisible",
"Channel Visibility",
+43
View File
@@ -565,6 +565,49 @@ fn apply_image_and_calculations_fail_gracefully_never_panic() {
assert!(s.execute("image.applyImage", json!({"source": {"channel": "composite"}})).is_ok());
}
#[test]
fn channel_target_layer_mask_and_vector_mask() {
let mut s = session();
// Initially the layer has no mask: targeting mask or vectorMask fails.
let err_mask = s.execute("channel.target", json!({"channel": "mask"})).unwrap_err().to_string();
assert!(err_mask.contains("no layer mask"), "{err_mask}");
let err_vec = s.execute("channel.target", json!({"channel": "vectorMask"})).unwrap_err().to_string();
assert!(err_vec.contains("no vector mask"), "{err_vec}");
// Add a layer mask.
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
// Target the mask explicitly via channel.target.
let j = s.execute("channel.target", json!({"channel": "mask"})).unwrap();
assert_eq!(j["target"]["kind"], "layerMask");
assert_eq!(s.active().unwrap().channel_view.target, ChannelTarget::LayerMask);
assert!(s.tools.mask_targeted);
// Commands that follow target (invert, paint) edit the mask without explicit target.
s.execute("image.adjustments.invert", json!({})).unwrap();
let l_id = s.active().unwrap().active_layer.unwrap();
let mask_sf = &s.active().unwrap().doc.layer(l_id).unwrap().mask.as_ref().unwrap().surface;
assert_eq!(mask_sf.pixel(10, 10)[0], 0.0, "inverted mask is black");
// Target composite again.
let j2 = s.execute("channel.target", json!({"channel": "composite"})).unwrap();
assert_eq!(j2["target"]["kind"], "composite");
assert_eq!(s.active().unwrap().channel_view.target, ChannelTarget::Composite);
assert!(!s.tools.mask_targeted);
// Issue #2458 repro: new adjustment layer then channel.target {"channel": "mask"}
s.execute("layer.newAdjustmentLayer.curves", json!({})).unwrap();
let j_adj = s.execute("channel.target", json!({"channel": "mask"})).unwrap();
assert_eq!(j_adj["target"]["kind"], "layerMask");
assert_eq!(s.active().unwrap().channel_view.target, ChannelTarget::LayerMask);
// Vector mask targeting.
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("layer.vectorMask.revealAll", json!({})).unwrap();
let j_vec = s.execute("channel.target", json!({"channel": "vectorMask"})).unwrap();
assert_eq!(j_vec["target"]["kind"], "vectorMask");
assert_eq!(s.active().unwrap().channel_view.target, ChannelTarget::VectorMask);
}
#[test]
fn clear_with_a_colour_channel_targeted_clears_only_that_channel() {
let near = |a: &[f32], b: &[f32]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3);
+1 -1
View File
@@ -43,7 +43,7 @@ pub struct ProofSetup {
impl Default for ProofSetup {
fn default() -> Self {
ProofSetup {
name: Builtin::CoatedCmyk.id().into(),
name: "working-cmyk".into(),
profile: Arc::new(Builtin::CoatedCmyk.profile().clone()),
intent: Intent::RelativeColorimetric,
bpc: true,
+7
View File
@@ -421,12 +421,18 @@ impl Session {
pub fn set_active(&mut self, index: usize) -> bool {
if index < self.docs.len() {
self.active = Some(index);
self.sync_tools_mask_target();
true
} else {
false
}
}
pub fn sync_tools_mask_target(&mut self) {
let mask = self.active().is_some_and(|st| st.channel_view.target == crate::channel_cmds::ChannelTarget::LayerMask);
self.tools.target_mask(mask);
}
/// Move the document at `from` to tab position `to` (clamped to the last), keeping the active
/// document active. Returns its new index; `None` when `from` is out of range.
pub fn move_document(&mut self, from: usize, to: usize) -> Option<usize> {
@@ -595,6 +601,7 @@ impl Session {
st.active_layer = Some(id);
st.selected_layers = vec![id];
st.layer_anchor = Some(id);
channel_cmds::fix_view(st);
// Selecting a layer is not an edit: keep a clean document clean.
let clean = st.saved_revision == st.revision;
st.revision += 1;
+21 -2
View File
@@ -48,7 +48,7 @@ const F_STROKE_OUT: u32 = 1024u; // effect paint: outside stroke band
const F_FIRST: u32 = 2048u; // outside strokes: nothing accumulated yet
const F_CHANNELS: u32 = 4096u; // lerp: per-channel weights in p0 (channel restrictions)
const F_LAB: u32 = 65536u; // Lab document: Normal mixes in CIELAB
const F_HDR: u32 = 262144u; // 32-bit float document: Add / Divide and Hue/Saturation/Color/Luminosity don't clip at 1
const F_HDR: u32 = 262144u; // 32-bit float document: HDR blends and Exposure retain values above 1
const F_QUANT: u32 = 32768u; // lerp: A rounded to p0.x steps (adjustment results, integer docs)
const F_ADD_DIFF: u32 = 16384u; // lerp: A + (B - C) premultiplied (clips on pass-through groups)
const F_TEXT_GAMMA: u32 = 8192u; // blend / atop / fx merge: type layer, mix coverage at gamma p4.w
@@ -552,10 +552,28 @@ fn selective_color(c: vec3<f32>, relative: bool) -> vec3<f32> {
// Exposure on one channel (p = exposure scale, offset, gamma, transfer gamma). Per channel, not a
// loop over `c[i]`: FXC aborts on that inside `adjust`'s switch.
fn exposure(v: f32, p: vec4<f32>) -> f32 {
if ((op.flags & F_HDR) != 0u) {
let lin = v * p.x + p.y;
if (p.z == 1.0) { return lin; }
return sign(lin) * pow(abs(lin), 1.0 / p.z);
}
let lin = pow(max(t_decode(v, p.w) * p.x + p.y, 0.0), 1.0 / p.z);
return clamp(t_encode(lin, p.w), 0.0, 1.0);
}
// Mirrors compose::adjust::levels_float; coefficients are master, R, G, B in LUT row 0.
fn levels_float(record: i32, v: f32) -> f32 {
let base = record * 5;
let t = (v - lut_at(base)) / max(lut_at(base + 1) - lut_at(base), 0.000001);
let mapped = sign(t) * pow(abs(t), 1.0 / lut_at(base + 2));
return lut_at(base + 3) + mapped * (lut_at(base + 4) - lut_at(base + 3));
}
fn tone_channel(row: i32, v: f32, identity: f32) -> f32 {
if (identity > 0.5) { return v; }
return lut(row, v);
}
// Modern Brightness curve: line of slope 1.375^(b/50) rolled off to (1,1) by a v^P white anchor.
// Mirrors compose::adjust::modern_brightness.
fn mbright(v: f32, b: f32) -> f32 {
@@ -598,7 +616,8 @@ fn adjust(c: vec3<f32>) -> vec3<f32> {
return vec3(mcontrast(mbright(c.r, b), ct), mcontrast(mbright(c.g, b), ct), mcontrast(mbright(c.b, b), ct));
}
case 6: { return vec3(exposure(c.r, p0), exposure(c.g, p0), exposure(c.b, p0)); } // Exposure
case 7: { return vec3(lut(0, c.r), lut(1, c.g), lut(2, c.b)); } // Levels / Curves
case 7: { return vec3(tone_channel(0, c.r, p0.x), tone_channel(1, c.g, p0.y), tone_channel(2, c.b, p0.z)); } // Levels / Curves
case 17: { return vec3(levels_float(0, levels_float(1, c.r)), levels_float(0, levels_float(2, c.g)), levels_float(0, levels_float(3, c.b))); }
case 8: { // Hue/Saturation
let hsl = rgb_to_hsl(c);
var hh: f32;
+15 -2
View File
@@ -380,8 +380,8 @@ pub const F_FIRST: u32 = 2048;
pub const F_CHANNELS: u32 = 4096;
/// Lab document: Normal blending mixes in CIELAB (`psblend::LAB_MIX`).
pub const F_LAB: u32 = 65536;
/// 32-bit float document: Linear Dodge (Add) and Divide don't clip at 1, and the non-separable
/// modes clip only below 0 (`psblend::HDR`).
/// 32-bit float document: Add, Divide and Exposure retain values above 1; the non-separable
/// blend modes clip only below 0 (`psblend::HDR`).
pub const F_HDR: u32 = 262144;
/// `Lerp`: A rounded to `p0.x` steps per unit (adjustment results on integer documents).
pub const F_QUANT: u32 = 32768;
@@ -1557,8 +1557,21 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf
p[0] = [2f32.powf(*exposure), *offset, gamma.max(0.01), transfer_exponent(transfer.for_exposure())];
(6, p, None)
}
Adjustment::Levels { master, per_channel, space: ToneSpace::Rgb, .. } if depth == photocraft_color::SampleType::F32 => {
// Twenty coefficients exceed the sixteen uniform parameters. Store four records
// in LUT row 0, read texel-exact by the analytic float Levels kernel.
let mut row = [0.0f32; 4096];
for (i, ch) in std::iter::once(master).chain(per_channel).enumerate() {
let coefficients = [ch.in_black, ch.in_white, ch.gamma.max(0.01), ch.out_black, ch.out_white];
row[i * 5..i * 5 + 5].copy_from_slice(&coefficients);
}
(17, p, Some(vec![row]))
}
// RGB space only (see `adjustment_on_gpu`); the rows are the CPU's channel∘master LUTs.
Adjustment::Levels { .. } | Adjustment::Curves { .. } => {
for (v, identity) in p[0].iter_mut().zip(adjust::float_tone_identity(adj, Some(depth))) {
*v = if identity { 1.0 } else { 0.0 };
}
(7, p, Some(adjust::tone_luts_depth(adj, Some(depth)).iter().take(3).map(|t| to_row(t)).collect()))
}
Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
+35
View File
@@ -29,6 +29,40 @@ struct Gpu {
/// Simulated texture limit (pages and chunks of 256 px).
const PAGED_LIMIT: u32 = 256;
#[test]
fn hdr_adjustments_preserve_range_and_cpu_parity() {
let Some(mut g) = gpu() else { return };
let identity = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }];
let channel = LevelsChannel { in_black: 0.25, in_white: 0.75, gamma: 2.0, out_black: -0.1, out_white: 1.5 };
for adj in [
Adjustment::Exposure { exposure: 0.0, offset: 0.0, gamma: 1.0 },
Adjustment::Exposure { exposure: 1.0, offset: 0.1, gamma: 1.3 },
Adjustment::Exposure { exposure: 0.8, offset: -0.02, gamma: 1.2 },
Adjustment::Levels { master: Default::default(), per_channel: Default::default(), space: Default::default(), black: Default::default() },
Adjustment::Levels {
master: channel.clone(),
per_channel: [channel.clone(), Default::default(), channel],
space: Default::default(),
black: Default::default(),
},
Adjustment::Curves { master: identity.clone(), per_channel: std::array::from_fn(|_| identity.clone()), space: Default::default(), black: Vec::new() },
Adjustment::Curves {
master: Vec::new(),
per_channel: [Vec::new(), identity.clone(), vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 0.5 }]],
space: Default::default(),
black: Vec::new(),
},
] {
let mut d = Document::new("HDR adjustments", Size::new(3, 1), ColorMode::Rgb, SampleType::F32);
let mut layer = Layer::raster("pixels", PixelFormat::RGBA32F);
layer.surface_mut().unwrap().write_region(Rect::new(0, 0, 3, 1), &[4.0, 2.0, 0.5, 1.0, -0.1, 3.0, 0.25, 0.75, 0.0, 0.0, 0.0, 1.0]);
d.layers = vec![layer, Layer::new("adjustment", LayerContent::Adjustment(adj.clone()))];
let cpu = photocraft_compose::flatten(&d);
assert!(cpu.px[0][0] > 1.0, "HDR reference unexpectedly clipped: {adj:?}");
check(&mut g, &d, &format!("HDR {adj:?}"));
}
}
fn gpu() -> Option<Gpu> {
let lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let instance = wgpu::Instance::default();
@@ -40,6 +74,7 @@ fn gpu() -> Option<Gpu> {
}
};
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
eprintln!("GPU parity backend: {:?}", adapter.get_info().backend);
// Exact parity needs 32-bit float targets. Adapters without them (e.g. GL software
// rasterizers) use the CPU compositor in the app, so there is nothing to compare there.
if Compositor::preferred_acc_format(&adapter) != wgpu::TextureFormat::Rgba32Float {
+10 -7
View File
@@ -245,11 +245,9 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m
}
if is_psd(bytes) {
let file = PsdFile::from_bytes(bytes)?;
// Nesting past the document model's cap could never be saved (.pcraft refuses it) and
// would overflow the importer's recursion; reject the file with the actionable limit.
if psd_import::group_depth(&file) > photocraft_doc::MAX_GROUP_DEPTH {
return Err(IoError::Unsupported(format!("layer groups nested deeper than {}", photocraft_doc::MAX_GROUP_DEPTH)));
}
// Nesting past the document model's cap could never be saved; reject the file with the
// actionable limit.
psd_import::check_group_depth(&file)?;
ctl.check().map_err(|_| IoError::Cancelled)?;
ctl.progress(0.05);
let (mut document, warnings) = psd_import::psd_to_document_with(&file, ctl).ok_or(IoError::Cancelled)?;
@@ -376,7 +374,9 @@ pub fn merged_composite(file: &PsdFile) -> Result<Vec<[f32; 4]>, IoError> {
})
.collect());
}
let img = file.composite_rgba8().ok();
// The RGBA8 preview clips signed/HDR values. Decode float merged images at their
// original depth through the model path below; they are the rendering oracle.
let img = if h.depth == 32 { None } else { file.composite_rgba8().ok() };
// CMYK goes through the colour-managed model conversion (the PSD crate's RGBA preview is a
// naive, profile-free conversion).
if let (Some(img), false) = (img, matches!(h.color_mode, photocraft_psd::ColorMode::Lab | photocraft_psd::ColorMode::Cmyk)) {
@@ -398,7 +398,10 @@ pub fn merged_composite(file: &PsdFile) -> Result<Vec<[f32; 4]>, IoError> {
.collect());
}
// Generic path (Lab, CMYK and others) via the raster model conversion.
let (doc, _) = psd_to_document(&PsdFile { layer_info: None, ..file.clone() });
// Keep the merged-alpha marker while discarding layers: otherwise an extra
// spot/alpha channel in an opaque composite is mistaken for transparency.
let layer_info = Some(photocraft_psd::LayerInfo { merged_alpha: file.merged_has_alpha(), layers: Vec::new(), padding: None });
let (doc, _) = psd_to_document(&PsdFile { layer_info, ..file.clone() });
// Multichannel documents keep their channels apart (no layer): composite them.
if doc.layers.is_empty() && doc.mode == photocraft_color::ColorMode::Multichannel {
return Ok(photocraft_compose::flatten(&doc).px);
+12 -1
View File
@@ -482,6 +482,15 @@ fn apply_link_groups(nodes: &[LayerNode], layers: &mut [Layer], ids: &[u16]) {
rec(nodes, layers, ids, 0);
}
/// Rejects a file whose groups nest deeper than the document model's cap, with the actionable
/// limit error. Nesting past the cap could never be saved (.pcraft refuses it).
pub(crate) fn check_group_depth(file: &PsdFile) -> Result<(), crate::IoError> {
if group_depth(file) > photocraft_doc::MAX_GROUP_DEPTH {
return Err(crate::IoError::Unsupported(format!("layer groups nested deeper than {}", photocraft_doc::MAX_GROUP_DEPTH)));
}
Ok(())
}
/// Deepest group nesting of the file's layer records (a layer inside this many groups is the
/// deepest), computed like `layer_tree`'s stack — iteratively, so no file can make it recurse.
pub(crate) fn group_depth(file: &PsdFile) -> usize {
@@ -690,7 +699,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
cx.warn(format!("Multichannel documents have no layers: {} layer records were not imported", file.layers().len()));
}
} else if layered {
let tree = file.layer_tree();
// Capped like `build` (#2940): a group past the document's nesting cap keeps no children,
// so no file can make this tree, or anything walking or dropping it, recurse deeper.
let tree = file.layer_tree_max_depth(photocraft_doc::MAX_GROUP_DEPTH);
doc.layers = cx.build(&tree, 0);
// Layer › Link Layers: resource 1026 holds one group id per layer record (0 = unlinked).
if let Some(Ok(photocraft_psd::resources::ResourceData::LayerGroupInfo(groups))) =
+2
View File
@@ -105,6 +105,8 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result<I
let data = planar_big_endian(&img, color_channels, mode == PsdMode::Cmyk)?;
let image_data = ImageData { compression: Compression::Raw, data };
let file = isd.into_psd(header, resources, image_data);
// The same nesting limit as opening a PSD (#2940).
crate::psd_import::check_group_depth(&file)?;
let (mut doc, w) = psd_to_document(&file);
warnings.extend(w);
doc.name = name.to_string();
+43
View File
@@ -160,3 +160,46 @@ fn photoshop_32bit_corpus_round_trip() {
assert!(d <= 1e-4, "{name}: render differs by {d}");
}
}
// Supply independent float merged planes: using the RGBA8 preview as the oracle
// silently clips these samples, even though Photoshop stores the full range.
#[test]
fn merged_oracle_preserves_signed_hdr_samples() {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for alpha in [1.0, 0.5] {
let doc = Document::new("float merged", Size::new(3, 1), mode, SampleType::F32);
let mut file = PsdFile::from_bytes(&export_psd(&doc)).unwrap();
let color_channels = if mode == ColorMode::Rgb { 3 } else { 1 };
file.layer_info = None;
file.header.channels = color_channels + u16::from(alpha != 1.0);
file.image_data.compression = Compression::Raw;
// Photoshop's merged pixels are matted against white when alpha is present.
file.image_data.data =
(0..color_channels).flat_map(|_| [-0.125_f32, 0.123456, 4.0].into_iter().flat_map(|v| (v * alpha + 1.0 - alpha).to_be_bytes())).collect();
if alpha != 1.0 {
file.image_data.data.extend((0..3).flat_map(|_| alpha.to_be_bytes()));
}
let pixels = merged_composite(&file).unwrap();
for (p, value) in pixels.iter().zip([-0.125, 0.123456, 4.0]) {
for (got, want) in p.iter().zip([value, value, value, alpha]) {
assert!((got - want).abs() < 1e-6, "{mode:?}, alpha {alpha}: {p:?}");
}
}
}
}
}
#[test]
fn merged_oracle_does_not_treat_extra_channels_as_transparency() {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
let doc = Document::new("extra channel", Size::new(1, 1), mode, SampleType::F32);
let mut file = PsdFile::from_bytes(&export_psd(&doc)).unwrap();
let color_channels = if mode == ColorMode::Rgb { 3 } else { 1 };
file.header.channels = color_channels + 1;
file.layer_info = Some(photocraft_psd::LayerInfo { merged_alpha: false, layers: Vec::new(), padding: None });
file.image_data.compression = Compression::Raw;
file.image_data.data = (0..color_channels).flat_map(|_| 2.0_f32.to_be_bytes()).chain(0.0_f32.to_be_bytes()).collect();
assert!(!file.merged_has_alpha());
assert_eq!(merged_composite(&file).unwrap(), vec![[2.0, 2.0, 2.0, 1.0]], "{mode:?}");
}
}
+28
View File
@@ -360,3 +360,31 @@ fn default_export_options_write_a_flat_tiff() {
assert_eq!(tags(&r.bytes), (None, None));
assert_eq!(import("x.tif", &r.bytes).unwrap().document.layers.len(), 1);
}
/// #2940: layered TIFF data whose groups nest past the document's cap is rejected with the same
/// limit error as a PSD, instead of being converted.
#[test]
fn layered_tiff_nested_past_the_cap_is_rejected() {
use photocraft_psd::{GroupSpec, LayerSpec, PixelData, PsdBuilder, PsdFile};
let tiff_nested = |levels: usize| {
let mut b = PsdBuilder::new(4, 4);
for _ in 0..levels {
b.begin_group(GroupSpec::new("g"));
}
b.push_layer(LayerSpec::new("deep", 0, 0, 1, 1, PixelData::Rgba8(vec![1, 2, 3, 4])));
for _ in 0..levels {
b.end_group().unwrap();
}
let file = PsdFile::from_bytes(&b.to_bytes().unwrap()).unwrap();
let order = if photocraft_codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big };
let layers = ImageSourceData::from_psd(&file).to_bytes(order).unwrap().0;
let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS);
let mut img = photocraft_codecs::decode(&tiff(&d, false).bytes).unwrap();
img.meta.photoshop_layers = Some(layers);
photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &photocraft_codecs::EncodeOptions::default()).unwrap()
};
let err = import("deep.tif", &tiff_nested(101)).unwrap_err();
assert!(err.to_string().contains("nested deeper than 100"), "{err}");
let r = import("hundred.tif", &tiff_nested(100)).unwrap();
assert_eq!(r.document.max_group_depth(), 100);
}
+2 -1
View File
@@ -28,6 +28,7 @@ PsdFile::layers() -> &[LayerRecord] // file order = bottom-most first
PsdFile::layers_mut() -> &mut Vec<LayerRecord>
PsdFile::layer(i) -> Option<Layer<'_>>; iter_layers()
PsdFile::layer_tree() -> Vec<LayerNode> // nested groups, children bottom-to-top
PsdFile::layer_tree_max_depth(n) -> Vec<LayerNode> // same, groups nested n deep keep no children
PsdFile::decode_merged() -> Result<Vec<u8>> // planar big-endian samples
PsdFile::composite_rgba8() -> Result<RgbaImage>
PsdFile::resource(id), global_block(key), icc_profile(), resolution(),
@@ -98,7 +99,7 @@ testgen::{all_cases, merged_only, layered, small, sample_descriptor, pattern_pla
| Layer masks | ✓ | ✓ | ✓ | 20 / 36-byte forms, mask parameters, real mask; unparseable masks stay raw |
| Channel ids -1 / -2 / -3 | ✓ | ✓ | ✓ | per-channel rect via `channel_rect` |
| Raw / RLE / ZIP / ZIP+prediction | ✓ | ✓ | – | prediction at 8/16/32 bits (32: byte-plane shuffle + delta); RLE counts are u16 in PSD, u32 in PSB |
| Merged image data | ✓ | ✓ | – | validated at parse time (sizes, RLE counts, zlib stream incl. checksum) |
| Merged image data | ✓ | ✓ | – | validated at parse time (sizes, RLE counts, zlib stream incl. checksum); a zlib stream declaring more than `MAX_DECODED_BYTES` is kept verbatim without inflating it, and decoding it reports the limit |
| Tagged blocks (raw passthrough) | ✓ | ✓ | – | keeps `8BIM` / `8B64` and exact padding |
| PSB 8-byte-length keys | ✓ | ✓ | – | LMsk Lr16 Lr32 Layr Mt16 Mt32 Mtrn Alph FMsk lnk2 FEid FXid PxSD |
| Lr16 / Lr32 / Layr layer info | ✓ | ✓ | ✓ | lifted into `layer_info`; re-inserted at its original position |
+25
View File
@@ -244,6 +244,13 @@ pub fn encode_planes(compression: Compression, decoded: &[u8], layout: &PlaneLay
}
/// Validates encoded sizes and, for ZIP, the complete stream and checksum.
///
/// A ZIP stream whose declared size is over [`MAX_DECODED_BYTES`] is not
/// decompressed at all (#2926): [`decode_planes`] refuses that layout with
/// `LimitExceeded` anyway, so inflating it here would only be work on an
/// untrusted file before the same error. The bytes are kept verbatim, like an
/// unknown compression method, so a layered file whose merged composite is
/// too large still opens.
pub(crate) fn validate_planes(compression: Compression, data: &[u8], layout: &PlaneLayout) -> Result<()> {
let total = layout.total_bytes()?;
match compression {
@@ -266,6 +273,7 @@ pub(crate) fn validate_planes(compression: Compression, data: &[u8], layout: &Pl
}
Ok(())
}
Compression::Zip | Compression::ZipPrediction if total > MAX_DECODED_BYTES => Ok(()),
Compression::Zip | Compression::ZipPrediction => inflate_checked(data, total, false).map(|_| ()),
Compression::Unknown(_) => Ok(()),
}
@@ -708,6 +716,23 @@ mod tests {
}
}
/// #2926: a ZIP stream declaring more than `MAX_DECODED_BYTES` is not inflated at parse
/// time. Garbage zlib data proves it: decompressing it would fail, skipping it passes, and
/// decoding the layout still reports the size limit.
#[test]
fn zip_over_budget_is_not_decompressed_at_parse() {
let l = layout(4, 300_000, 300_000, 8, Version::Psb);
assert!(l.total_bytes().unwrap() > MAX_DECODED_BYTES);
let garbage = [0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03];
for c in [Compression::Zip, Compression::ZipPrediction] {
validate_planes(c, &garbage, &l).unwrap();
assert!(matches!(decode_planes(c, &garbage, &l), Err(PsdError::LimitExceeded(_))), "{c:?}");
}
// Within the budget the stream is still checked.
let small = layout(1, 4, 4, 8, Version::Psd);
assert!(validate_planes(Compression::Zip, &garbage, &small).is_err());
}
#[test]
fn raw_short_fails() {
let l = layout(2, 3, 3, 8, Version::Psd);
+13
View File
@@ -338,6 +338,19 @@ mod write_tests {
}
}
/// #2926: a PSB whose ZIP merged image declares more than `MAX_DECODED_BYTES` opens without
/// inflating that stream (its layers are still usable); decoding the composite reports the limit.
#[test]
fn over_budget_zip_merged_image_opens_without_inflating() {
let mut bytes = testgen::small(Version::Psb, Compression::Zip).to_bytes().unwrap();
// Header: height at 14, width at 18 (big-endian u32).
bytes[14..18].copy_from_slice(&300_000u32.to_be_bytes());
bytes[18..22].copy_from_slice(&300_000u32.to_be_bytes());
let file = PsdFile::from_bytes(&bytes).unwrap();
assert!(!file.layers().is_empty());
assert!(matches!(file.decode_merged(), Err(PsdError::LimitExceeded(_))));
}
#[test]
fn global_layer_info_serialization_propagates_layer_limit_errors() {
let mut file = testgen::small(Version::Psd, Compression::Raw);
+24 -9
View File
@@ -58,23 +58,38 @@ impl PsdFile {
/// Malformed nesting is tolerated: a folder without a matching divider
/// becomes an empty group; unclosed dividers at the end are dropped and
/// their children are hoisted to the enclosing level.
///
/// The tree can be as deep as the file nests its groups (up to ~16000
/// levels). Code that walks it recursively should use
/// [`layer_tree_max_depth`](Self::layer_tree_max_depth).
pub fn layer_tree(&self) -> Vec<LayerNode> {
// Stack of (divider index, children).
self.layer_tree_max_depth(usize::MAX)
}
/// [`layer_tree`](Self::layer_tree) with at most `max_depth` levels of
/// groups: a group nested `max_depth` or more groups deep (a root node is
/// at depth 0) keeps its node but not its children. Built iteratively, so
/// no file can make it recurse, and the result is no deeper than
/// `max_depth + 1` levels, so walking or dropping it recursively is bounded.
pub fn layer_tree_max_depth(&self, max_depth: usize) -> Vec<LayerNode> {
// Stack of (divider index, children); frame `k` holds the nodes at depth `k`. Frames
// past `max_depth` are tracked (to match dividers to folders) but collect nothing.
let mut stack: Vec<(Option<usize>, Vec<LayerNode>)> = vec![(None, Vec::new())];
let push = |stack: &mut Vec<(Option<usize>, Vec<LayerNode>)>, node: LayerNode| {
if stack.len() <= max_depth.saturating_add(1)
&& let Some(top) = stack.last_mut()
{
top.1.push(node);
}
};
for (i, rec) in self.layers().iter().enumerate() {
match rec.section_type() {
SectionType::BoundingDivider => stack.push((Some(i), Vec::new())),
SectionType::OpenFolder | SectionType::ClosedFolder => {
let (divider, children) = if stack.len() > 1 { stack.pop().unwrap_or_default() } else { (None, Vec::new()) };
if let Some(top) = stack.last_mut() {
top.1.push(LayerNode::Group { index: i, divider, children });
}
}
_ => {
if let Some(top) = stack.last_mut() {
top.1.push(LayerNode::Layer { index: i });
}
push(&mut stack, LayerNode::Group { index: i, divider, children });
}
_ => push(&mut stack, LayerNode::Layer { index: i }),
}
}
while stack.len() > 1 {
+2 -1
View File
@@ -114,10 +114,11 @@ fn huge_declared_dimensions_small_data() {
f.header.height = 300_000;
let b = f.to_bytes().unwrap();
assert!(PsdFile::from_bytes(&b).is_err());
// ZIP over `MAX_DECODED_BYTES` is not inflated at parse time (#2926); decoding refuses it.
f.image_data.compression = Compression::Zip;
f.image_data.data = compression::zip_compress(&[0; 100]);
let b = f.to_bytes().unwrap();
assert!(PsdFile::from_bytes(&b).is_err());
assert!(matches!(PsdFile::from_bytes(&b).and_then(|f| f.decode_merged()), Err(PsdError::LimitExceeded(_))));
}
#[test]
+14
View File
@@ -123,6 +123,20 @@ fn deeply_nested() {
assert_eq!(depth, 51);
}
/// #2940: the capped tree keeps a group at the cap but not its children, so its depth is bounded
/// whatever the file nests.
#[test]
fn layer_tree_max_depth_drops_deeper_children() {
let mut v = vec![rec("</Layer group>", END), rec("</Layer group>", END), rec("</Layer group>", END), rec("x", None)];
v.extend([rec("g2", OPEN), rec("g1", OPEN), rec("g0", OPEN), rec("top", None)]);
let f = file_with(v);
assert_eq!(names(&f, &f.layer_tree()), "g0[g1[g2[x]]],top");
assert_eq!(names(&f, &f.layer_tree_max_depth(usize::MAX)), "g0[g1[g2[x]]],top");
assert_eq!(names(&f, &f.layer_tree_max_depth(2)), "g0[g1[g2[]]],top");
assert_eq!(names(&f, &f.layer_tree_max_depth(1)), "g0[g1[]],top");
assert_eq!(names(&f, &f.layer_tree_max_depth(0)), "g0[],top");
}
#[test]
fn folder_without_divider_is_empty_group() {
let f = file_with(vec![rec("a", None), rec("G", OPEN), rec("b", None)]);
+18 -2
View File
@@ -1,5 +1,5 @@
//! ⌥-click through the real canvas: the Brush's temporary Eyedropper (#1659) and the Clone
//! Stamp's source point and painting (#1658).
//! Stamp's source point and painting (#1658), the Zoom tool's Zoom Out (#3137).
use egui::{Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
@@ -179,7 +179,7 @@ fn grabbed_alt_click(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, re
#[test]
fn an_alt_click_the_window_manager_took_says_so() {
for tool in [Tool::Brush, Tool::Pencil, Tool::CloneStamp, Tool::Healing] {
for tool in [Tool::Brush, Tool::Pencil, Tool::CloneStamp, Tool::Healing, Tool::Zoom] {
let mut h = harness(two_colours());
h.state_mut().ui.tool = tool;
grabbed_alt_click(&mut h, 50.0, 50.0, true);
@@ -229,3 +229,19 @@ fn a_latched_alt_samples_and_sets_the_clone_source_without_the_key() {
click(&mut h, 150.0, 50.0, Modifiers::NONE);
assert!(h.state().session.presets.clone.active().source.is_some());
}
#[test]
fn alt_click_with_the_zoom_tool_zooms_out_held_or_latched() {
for (sticky, scrubby, m) in [(false, true, Modifiers::ALT), (true, true, Modifiers::NONE), (true, false, Modifiers::NONE)] {
let mut h = harness(two_colours());
h.state_mut().ui.tool = Tool::Zoom;
h.state_mut().ui.tool_options.zoom_scrubby = scrubby;
h.state_mut().ui.shell.sticky_alt = sticky;
click(&mut h, 50.0, 50.0, m);
assert_eq!(h.state().ui.views[0].zoom, 2.0 / 3.0, "sticky={sticky} scrubby={scrubby}");
// Without ⌥ the click zooms in again.
h.state_mut().ui.shell.sticky_alt = false;
click(&mut h, 50.0, 50.0, Modifiers::NONE);
assert_eq!(h.state().ui.views[0].zoom, 1.0, "sticky={sticky} scrubby={scrubby}");
}
}
+4 -4
View File
@@ -2,8 +2,8 @@
//!
//! Many Linux desktops move a window with Alt+drag: Cinnamon (Linux Mint's default), MATE, Xfce,
//! and KDE before Plasma 6. Their window manager grabs every Alt+left press, so the click never
//! reaches PhotoCraft: the Brush's ⌥ Eyedropper samples nothing and the Clone Stamp gets no source
//! point, while the ⌥ cursor still shows (the keyboard is not grabbed until the press). Nothing in
//! reaches PhotoCraft: the Brush's ⌥ Eyedropper samples nothing, the Clone Stamp gets no source
//! point and the Zoom tool does not zoom out (#3137), while the ⌥ cursor still shows (the keyboard is not grabbed until the press). Nothing in
//! the app can win that grab back, but its signature is clear: the pointer "leaves" the window from
//! well inside the canvas while ⌥ is held with a tool whose ⌥-click matters (X11 and Wayland report
//! a grab as the pointer leaving), and comes back to a window that still has the keyboard focus
@@ -27,9 +27,9 @@ struct Watch {
pending: bool,
}
/// Tools whose ⌥-click does something: the ⌥ Eyedropper and the clone source point.
/// Tools whose ⌥-click does something: the ⌥ Eyedropper, the clone source point and Zoom Out.
pub(crate) fn alt_click_tool(tool: Tool, mods: egui::Modifiers) -> bool {
crate::canvas::alt_samples(tool, mods) || (mods.alt && !mods.ctrl && matches!(tool, Tool::CloneStamp | Tool::Healing))
crate::canvas::alt_samples(tool, mods) || (mods.alt && !mods.ctrl && matches!(tool, Tool::CloneStamp | Tool::Healing | Tool::Zoom))
}
/// The status-bar message for an ⌥-click the desktop took.
+2 -2
View File
@@ -48,8 +48,8 @@ fn both_tools_join_the_pen_group() {
let group: Vec<Tool> = Tool::ALL.iter().copied().filter(|t| t.key() == 'P').collect();
assert_eq!(
group,
vec![Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint],
"P cycles Pen → Freeform → Curvature → Add → Delete"
vec![Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint, Tool::ConvertPoint],
"P cycles Pen → Freeform → Curvature → Add → Delete → Convert Point"
);
let mut app = app(Tool::Brush);
app.ui.tool = Tool::from_name("addAnchorPoint").unwrap();
+34 -8
View File
@@ -1403,7 +1403,14 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e
}
let cmd = d.fields.get("__command")?.as_str()?.to_string();
// Previews edit what the command will: a targeted layer mask included (#780).
let params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(&d.fields));
let mut params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(&d.fields));
// Select and Mask previews in its view mode; holding X shows the image without one.
if crate::select_mask_view::owns(&d.fields) {
if crate::select_mask_view::hides_view(&d.fields) {
return None;
}
params = crate::select_mask_view::request_params(&d.fields, params);
}
let st = app.session.documents().get(idx)?;
// Filters whose features don't scale with a proxy preview at full resolution (#2063).
let k = crate::filter_dialog::preview_factor(&cmd, &params, crate::proxy::preview_factor(&st.doc, crate::proxy::reduced_previews(app)));
@@ -2636,13 +2643,21 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// in (`layer_edges_rect`), so a press that Auto-Selects a layer outlines that layer.
let layer_edges = painter.add(egui::Shape::Noop);
// Select and Mask outlines its refined selection (on its preview proxy, reduced by `k`) in
// the Marching Ants view and hides the ants in the others.
let refined = crate::select_mask_view::ants(app, idx);
let (outlined, k) = match &refined {
None => (doc.selection.as_ref(), 1),
Some(None) => (None, 1),
Some(Some((proxy, k))) => (proxy.selection.as_ref(), (*k).max(1)),
};
// Selection outline: true boundary, animated marching ants (cached per revision).
if let Some(sel) = doc.selection.as_ref().filter(|_| app.ui.view.shows(app.ui.view.show.selection_edges) && !polygon_replaces_selection(app)) {
if let Some(sel) = outlined.filter(|_| app.ui.view.shows(app.ui.view.show.selection_edges) && !polygon_replaces_selection(app)) {
// Trace at display resolution over the visible part only; key by the mask's tile identity
// (not the document revision) so unrelated edits don't re-trace it. The step is in
// device pixels per document pixel (`View::zoom`, not the point zoom), so a 1 px
// selection still traces at 100% on a scaled display.
let step = (1.0 / view.zoom.max(1e-3)).log2().floor().exp2().clamp(1.0, 64.0) as u32;
let step = (1.0 / (view.zoom * k as f32).max(1e-3)).log2().floor().exp2().clamp(1.0, 64.0) as u32;
let corners = [rect.min, pos2(rect.max.x, rect.min.y), rect.max, pos2(rect.min.x, rect.max.y)];
let docs = [xf.to_doc(corners[0]), xf.to_doc(corners[1]), xf.to_doc(corners[2]), xf.to_doc(corners[3])];
let mut x0 = docs[0][0];
@@ -2655,6 +2670,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
x1 = x1.max(d[0]);
y1 = y1.max(d[1]);
}
let (x0, y0, x1, y1) = (x0 / f64::from(k), y0 / f64::from(k), x1 / f64::from(k), y1 / f64::from(k));
let q = 256 * step as i32; // quantise the region so small pans reuse the cache
// The view centre can be any finite number (`ui.set`, #980). Past ±2³⁰ there is no document
// to outline, and the padding below (at most 2 · 256 · 64) can't overflow i32 from there.
@@ -2671,15 +2687,22 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
^ (vis.x0 as u64) << 8
^ (vis.y0 as u64) << 24
^ (vis.x1 as u64) << 36
^ (vis.y1 as u64) << 48;
^ (vis.y1 as u64) << 48
^ u64::from(k).rotate_left(5);
let fresh = matches!(&app.outline_cache, Some((d, k, _)) if *d == doc.id && *k == key);
if !fresh {
let t0 = crate::gpu_canvas::now_ms();
let b = app.cached_bounds(u64::MAX - doc.id.0, sel).intersect(&vis);
let bounds_key = if k > 1 { u64::MAX / 2 - doc.id.0 } else { u64::MAX - doc.id.0 };
let b = app.cached_bounds(bounds_key, sel).intersect(&vis);
// Filters can occupy every worker in Rayon's global pool. A parallel scan here would
// block the UI thread waiting for those workers, preventing job cancellation (#1554).
let segs =
let mut segs =
if app.session.has_jobs() { crate::outline::outline_scaled_serial(sel, b, step) } else { crate::outline::outline_scaled(sel, b, step) };
if k > 1 {
let s = i32::try_from(k).unwrap_or(1);
let up = |p: [i32; 2]| [p[0].saturating_mul(s), p[1].saturating_mul(s)];
segs.iter_mut().for_each(|(a, b)| (*a, *b) = (up(*a), up(*b)));
}
app.perf.span("outline", crate::gpu_canvas::now_ms() - t0);
app.outline_cache = Some((doc.id, key, std::sync::Arc::new(segs)));
}
@@ -2770,11 +2793,13 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// ⌘ held: this frame's gestures go to the Move tool (`PhotocraftApp::active_tool`). The Hand
// and Zoom never reach `tool_event`, so only the Move needs the state machine to know.
app.tool_override = (temporary == Some(crate::hold_keys::Temporary::Move)).then_some(Tool::Move);
// Zoom direction: the temporary zoom key decides, else ⌥ (Zoom tool).
// Zoom direction: the temporary zoom key decides, else ⌥ (Zoom tool), held or latched in
// Window › Modifier Keys (the way around a desktop that takes Alt+click, `alt_grab`).
let sticky_alt = app.ui.shell.sticky_alt;
let zoom_out = |alt: bool| match temporary {
Some(crate::hold_keys::Temporary::ZoomOut) => true,
Some(crate::hold_keys::Temporary::ZoomIn) => false,
_ => alt,
_ => alt || sticky_alt,
};
if under_dialog {
@@ -4972,6 +4997,7 @@ pub fn paint_target(app: &PhotocraftApp) -> serde_json::Value {
match st.channel_view.target {
ChannelTarget::Alpha(i) if i < st.doc.channels.len() => return json!({ "channel": i }),
ChannelTarget::Composite if st.doc.quick_mask.is_some() => return json!("quickMask"),
ChannelTarget::LayerMask => return json!("mask"),
_ => {}
}
let has_mask = st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.mask.is_some());
+211 -8
View File
@@ -1,5 +1,5 @@
//! Canvas context menus for the selection tools, the Pen, an active Free Transform box and the
//! pasteboard (outside the image).
//! Canvas context menus for the selection tools, the Pen, an active Free Transform box, text being
//! edited with a Type tool and the pasteboard (outside the image).
use egui::Context;
use photocraft_doc::LayerContent;
@@ -24,6 +24,9 @@ pub struct CanvasToolMenu {
/// Opened outside the image: the pasteboard colour instead of the tool's actions.
#[serde(default)]
pub pasteboard: bool,
/// Opened while a Type tool edits text: the text and type rows instead of the tool's actions.
#[serde(default)]
pub text: bool,
}
/// One menu row: label and command id. `None` is a separator.
@@ -66,6 +69,53 @@ pub const PASTEBOARD_MENU: &[Row] = &[
Some(("Select Custom Color…", PASTEBOARD_PICK)),
];
/// Photoshop's right-click menu inside text being edited: the edit rows act on the text (its
/// selection, the OS clipboard), the type rows on the layer, as the Type menu's do. Faux Bold and
/// Faux Italic are the Character panel's toggles (this menu's own ids, run as `type.setStyle`).
/// Convert to Paragraph Text and Convert to Point Text are both listed; the one that doesn't apply
/// greys out. The exact Photoshop placement is inferred (docs/context-menu-parity.md).
pub const TYPE_EDIT_MENU: &[Row] = &[
Some(("Undo", "edit.undo")),
None,
Some(("Cut", "edit.cut")),
Some(("Copy", "edit.copy")),
Some(("Paste", "edit.paste")),
Some(("Select All", "select.all")),
None,
Some(("Check Spelling…", "edit.checkSpelling")),
Some(("Find and Replace Text…", "edit.findAndReplaceText")),
None,
Some(("Faux Bold", FAUX_BOLD)),
Some(("Faux Italic", FAUX_ITALIC)),
None,
Some(("Rasterize Type", "type.rasterizeTypeLayer")),
Some(("Create Work Path", "type.createWorkPath")),
Some(("Convert to Shape", "type.convertToShape")),
None,
Some(("Horizontal", "type.orientation.horizontal")),
Some(("Vertical", "type.orientation.vertical")),
None,
Some(("None", "type.antiAlias.none")),
Some(("Sharp", "type.antiAlias.sharp")),
Some(("Crisp", "type.antiAlias.crisp")),
Some(("Strong", "type.antiAlias.strong")),
Some(("Smooth", "type.antiAlias.smooth")),
Some(("Windows LCD", "type.antiAlias.windowsLcd")),
Some(("Windows", "type.antiAlias.windows")),
None,
Some(("Convert to Paragraph Text", "type.convertToParagraphText")),
Some(("Convert to Point Text", "type.convertToPointText")),
Some(("Warp Text…", "type.warpText")),
];
/// The type edit menu's Faux Bold and Faux Italic rows.
pub const FAUX_BOLD: &str = "type.fauxBold";
pub const FAUX_ITALIC: &str = "type.fauxItalic";
/// Type edit rows that act on the whole layer (it may stop being type): the edit ends first.
const ENDS_TYPE_EDIT: [&str; 5] =
["type.rasterizeTypeLayer", "type.createWorkPath", "type.convertToShape", "type.convertToParagraphText", "type.convertToPointText"];
/// The pasteboard menu's Select Custom Color… row.
pub const PASTEBOARD_PICK: &str = "pasteboard.selectCustomColor";
@@ -171,6 +221,8 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] {
PASTEBOARD_MENU
} else if menu.transform {
TRANSFORM_MENU
} else if menu.text {
TYPE_EDIT_MENU
} else if menu.tool == Tool::Pen {
PEN_MENU
} else {
@@ -190,11 +242,32 @@ pub(crate) fn mode_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command:
if menu.pasteboard {
return pasteboard_choice(command) == Some(app.session.prefs().interface.canvas_color);
}
if menu.text {
return type_checked(app, command);
}
menu.transform
&& matches!(command, "edit.freeTransform" | "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))
}
/// The type edit menu's checkmarks: the faux styles at the selection, the layer's orientation and
/// anti-aliasing.
fn type_checked(app: &PhotocraftApp, command: &str) -> bool {
use photocraft_doc::text::Orientation;
let Some(t) = crate::type_tool::target_text(app) else { return false };
match command {
FAUX_BOLD | FAUX_ITALIC => crate::type_tool::styles_at(app).is_some_and(|(c, _)| if command == FAUX_BOLD { c.faux_bold } else { c.faux_italic }),
"type.orientation.horizontal" => t.orientation == Orientation::Horizontal,
"type.orientation.vertical" => t.orientation == Orientation::Vertical,
c => c.strip_prefix("type.antiAlias.") == Some(photocraft_engine::type_extra_cmds::aa_name(t.antialias)),
}
}
/// Is the Type tool editing text, so that a right-click opens the type edit menu?
pub fn editing_text(app: &PhotocraftApp, tool: Tool) -> bool {
tool.is_type() && app.ui.text_edit.is_some()
}
/// Right-click over a Free Transform box: its modes.
pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> bool {
if !pos.iter().all(|v| v.is_finite()) {
@@ -202,8 +275,16 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) ->
}
app.ui.brush_picker = None;
app.ui.layer_menu = None;
app.ui.canvas_tool_menu =
Some(CanvasToolMenu { pos, tool: app.ui.tool, has_selection: false, has_path: false, path_name: None, transform: true, pasteboard: false });
app.ui.canvas_tool_menu = Some(CanvasToolMenu {
pos,
tool: app.ui.tool,
has_selection: false,
has_path: false,
path_name: None,
transform: true,
pasteboard: false,
text: false,
});
crate::canvas_popup::mark_open(ctx, menu_id());
true
}
@@ -220,6 +301,7 @@ pub fn engine_call(menu: &CanvasToolMenu, command: &str) -> (String, Value) {
match pasteboard_choice(command) {
Some(c) if menu.pasteboard => ("prefs.set".into(), json!({"values": {"interface.canvasColor": c.name()}})),
_ if menu.pasteboard => ("prefs.set".into(), json!({})),
_ if menu.text && matches!(command, FAUX_BOLD | FAUX_ITALIC) => ("type.setStyle".into(), json!({})),
_ => (command.into(), json!({})),
}
}
@@ -241,7 +323,8 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos:
}
app.ui.brush_picker = None;
app.ui.layer_menu = None;
app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, has_selection: false, has_path: false, path_name: None, transform: false, pasteboard: true });
app.ui.canvas_tool_menu =
Some(CanvasToolMenu { pos, tool, has_selection: false, has_path: false, path_name: None, transform: false, pasteboard: true, text: false });
crate::canvas_popup::mark_open(ctx, menu_id());
true
}
@@ -265,6 +348,12 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str)
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() && t.path.is_none());
}
if menu.text {
return match command {
FAUX_BOLD | FAUX_ITALIC => app.ui.text_edit.is_some(),
c => TYPE_EDIT_MENU.iter().flatten().any(|(_, id)| *id == c) && crate::menus::is_enabled(app, c),
};
}
if menu.transform || menu.tool != Tool::Pen {
return crate::menus::is_enabled(app, command);
}
@@ -304,7 +393,8 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str)
}
pub fn open(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]) -> bool {
if !applies(tool) || !pos.iter().all(|v| v.is_finite()) {
let text = editing_text(app, tool);
if !(applies(tool) || text) || !pos.iter().all(|v| v.is_finite()) {
return false;
}
app.ui.brush_picker = None;
@@ -312,7 +402,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]) -
let has_selection = app.session.active().is_some_and(|s| s.doc.selection.is_some());
let has_path = crate::vector_ui::active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2);
let path_name = crate::vector_ui::active_path_name(app);
app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false });
app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false, text });
crate::canvas_popup::mark_open(ctx, menu_id());
true
}
@@ -324,6 +414,8 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) {
}
let result = if menu.pasteboard {
choose_pasteboard(app, &menu, command)
} else if menu.text {
choose_type(app, ctx, command)
} else if menu.tool == Tool::Pen && !menu.transform {
choose_pen(app, ctx, &menu, command)
} else if command == "select.toWorkPath" {
@@ -339,6 +431,27 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) {
}
}
/// A type edit row. Undo, Cut, Copy, Paste and Select All run as their Edit and Select menu items,
/// which act on the text while it is being edited (Paste asks the window for the clipboard text).
fn choose_type(app: &mut PhotocraftApp, ctx: &Context, command: &str) -> Result<(), String> {
match command {
FAUX_BOLD | FAUX_ITALIC => {
let bold = command == FAUX_BOLD;
let on = !type_checked(app, command);
let mut p = crate::type_tool::formatting_params(app).ok_or("No type is being edited")?;
p[if bold { "fauxBold" } else { "fauxItalic" }] = json!(on);
app.run("type.setStyle", p)?;
}
_ => {
if ENDS_TYPE_EDIT.contains(&command) {
crate::type_tool::commit(app);
}
crate::menus::invoke(app, ctx, command, json!({}))?;
}
}
Ok(())
}
/// A parameter dialog for `command` following `spec` (registry notation), prefilled with `fields`.
fn spec_dialog(app: &mut PhotocraftApp, command: &str, label: &str, spec: &str, mut fields: serde_json::Map<String, serde_json::Value>) {
fields.insert("__command".into(), json!(command));
@@ -412,7 +525,11 @@ fn menu_id() -> egui::Id {
pub fn show(app: &mut PhotocraftApp, ctx: &Context) {
let Some(menu) = app.ui.canvas_tool_menu.clone() else { return };
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) || app.ui.tool != menu.tool || (menu.transform && app.ui.transform.is_none()) {
if ctx.input(|i| i.key_pressed(egui::Key::Escape))
|| app.ui.tool != menu.tool
|| (menu.transform && app.ui.transform.is_none())
|| (menu.text && app.ui.text_edit.is_none())
{
app.ui.canvas_tool_menu = None;
return;
}
@@ -530,6 +647,92 @@ mod tests {
assert!(h.state().ui.canvas_tool_menu.is_none(), "command right-click belongs to layer picker");
}
/// A type layer reading "Hello", being edited with every character selected.
fn editing_type(app: &mut PhotocraftApp) -> u64 {
app.ui.tool = Tool::Type;
let id = app.run("type.create", json!({"text": "Hello", "size": 12, "x": 4, "y": 20})).unwrap()["layer"].as_u64().unwrap();
crate::menus::invoke(app, &Context::default(), "type.editText", json!({})).unwrap();
id
}
fn text_of(app: &PhotocraftApp, id: u64) -> Option<photocraft_doc::TextLayer> {
match &app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content {
LayerContent::Text(t) => Some(t.clone()),
_ => None,
}
}
#[test]
fn right_click_while_typing_opens_the_type_edit_menu_and_esc_keeps_the_edit() {
let mut app = app();
editing_type(&mut app);
app.sync_views();
let mut h = Harness::builder().with_size(vec2(1000.0, 700.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;
}
// The app's order: shortcuts (inline type's keys among them), then the canvas.
crate::shortcuts::handle(app, &ctx);
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 center = h.state().last_canvas_rect.center();
right_click(&mut h, center, Modifiers::NONE);
let menu = h.state().ui.canvas_tool_menu.clone().expect("right-click in a type edit opens a menu");
assert!(menu.text && !menu.pasteboard && !menu.transform);
assert_eq!(rows(&menu), TYPE_EDIT_MENU);
let edit = h.state().ui.text_edit.clone().expect("the edit goes on under the menu");
assert_eq!((edit.anchor, edit.caret), (0, 5), "the right-click keeps the selection for Cut and Copy");
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert!(h.state().ui.canvas_tool_menu.is_none(), "Esc closes the menu");
assert!(h.state().ui.text_edit.is_some(), "and only the menu: the edit isn't committed");
}
#[test]
fn type_edit_menu_rows_act_on_the_text_and_the_layer() {
let mut app = app();
let id = editing_type(&mut app);
let ctx = Context::default();
let reopen = |app: &mut PhotocraftApp| assert!(open(app, &Context::default(), Tool::Type, [10.0, 10.0]));
// Faux Bold toggles the style of the selected characters, and shows checked.
reopen(&mut app);
let menu = app.ui.canvas_tool_menu.clone().unwrap();
assert!(!mode_checked(&app, &menu, FAUX_BOLD) && mode_checked(&app, &menu, "type.antiAlias.smooth"));
choose(&mut app, &ctx, FAUX_BOLD);
assert!(text_of(&app, id).unwrap().char_runs().iter().all(|r| r.style.faux_bold));
assert!(mode_checked(&app, &menu, FAUX_BOLD));
// Cut takes the selection out of the text; with nothing selected Copy and Cut grey out.
reopen(&mut app);
choose(&mut app, &ctx, "edit.cut");
assert_eq!(text_of(&app, id).unwrap().text, "");
reopen(&mut app);
let menu = app.ui.canvas_tool_menu.clone().unwrap();
assert!(!entry_enabled(&app, &menu, "edit.copy") && !entry_enabled(&app, &menu, "edit.cut"));
app.ui.canvas_tool_menu = None;
crate::menus::invoke(&mut app, &ctx, "edit.undo", json!({})).unwrap();
assert_eq!(text_of(&app, id).unwrap().text, "Hello");
// Select All selects every character, not the canvas.
reopen(&mut app);
choose(&mut app, &ctx, "select.all");
let edit = app.ui.text_edit.clone().unwrap();
assert_eq!((edit.anchor, edit.caret), (0, 5));
assert!(app.session.active().unwrap().doc.selection.is_none());
// Warp Text… opens its dialog; Rasterize Type ends the edit and rasterizes the layer.
reopen(&mut app);
choose(&mut app, &ctx, "type.warpText");
assert!(!app.ui.dialogs.is_empty(), "Warp Text… asks for its settings");
app.ui.dialogs.clear();
reopen(&mut app);
choose(&mut app, &ctx, "type.rasterizeTypeLayer");
assert!(app.ui.text_edit.is_none() && text_of(&app, id).is_none(), "the type layer is pixels now");
}
#[test]
fn right_click_outside_the_image_picks_the_pasteboard_color() {
let mut app = app();
+11 -6
View File
@@ -65,7 +65,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// The active layer's mask (Photoshop lists it, in italics, below the colour channels).
let masked = active_layer.and_then(|id| doc.layer(id)).filter(|l| l.mask.is_some()).cloned();
let mask_tex = masked.as_ref().and_then(|l| Some(app.mask_thumb(&ctx, &doc, l.id, l.mask.as_ref()?)));
let mask_targeted = masked.is_some() && app.ui.mask_target && view.target == ChannelTarget::Composite && !quick;
let mask_targeted = masked.is_some()
&& (app.ui.mask_target || view.target == ChannelTarget::LayerMask)
&& (view.target == ChannelTarget::Composite || view.target == ChannelTarget::LayerMask)
&& !quick;
// Multichannel images are their ink channels only (no composite or colour rows).
let multichannel = doc.mode == photocraft_doc::ColorMode::Multichannel;
let mut rows = if multichannel { Vec::new() } else { vec![Row::Composite] };
@@ -195,7 +198,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
} else if row == Row::LayerMask {
// Targets the mask, as clicking its thumbnail in the Layers panel does.
mask_click = Some(true);
actions.push(("channel.target".into(), json!({ "channel": "composite" })));
actions.push(("channel.target".into(), json!({ "channel": "mask" })));
} else {
if matches!(row, Row::Composite | Row::Color(_)) {
mask_click = Some(false);
@@ -276,6 +279,8 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ChannelTarget::Alpha(i) => json!(i),
ChannelTarget::Color(k) => json!({ "color": k }),
ChannelTarget::Composite => json!("composite"),
ChannelTarget::LayerMask => json!("mask"),
ChannelTarget::VectorMask => json!("vectorMask"),
};
if icons::button(ui, "trash", 26.0, false, tl!("Delete current channel")).clicked() {
actions.push(("channel.delete".into(), json!({})));
@@ -291,10 +296,6 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
});
ctx.data_mut(|d| d.insert_temp(thumbs_id(), drawn));
if let Some(on) = mask_click {
app.ui.mask_target = on;
app.sync_mask_targets();
}
for (id, p) in actions {
if id == "ui.renameChannel" {
if let Some(i) = p.as_u64().and_then(|i| doc.channels.get(i as usize)) {
@@ -305,6 +306,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
let _ = app.run(&id, p);
}
if let Some(on) = mask_click {
app.ui.mask_target = on;
app.sync_mask_targets();
}
}
fn thumbs_id() -> egui::Id {
+1 -1
View File
@@ -959,7 +959,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
}
continue;
}
if crate::canvas_tool_menu::applies(app.ui.tool) {
if crate::canvas_tool_menu::applies(app.ui.tool) || crate::canvas_tool_menu::editing_text(app, app.ui.tool) {
if down {
crate::canvas_tool_menu::open(app, ctx, app.ui.tool, screen_point(app, x, y));
}
+9 -3
View File
@@ -254,9 +254,15 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
// Long parameter lists (Flame, Lighting Effects) scroll, so the title and the
// OK / Cancel buttons stay inside a small window.
let room = (ctx.content_rect().height() - DIALOG_CHROME).max(120.0);
first_field_focused(ui, id, |ui| {
egui::ScrollArea::vertical().id_salt(id.with("body")).max_height(room).show(ui, |ui| crate::filter_dialog::body(ui, &mut fields));
});
let body = |ui: &mut egui::Ui, fields: &mut serde_json::Map<String, Value>| {
egui::ScrollArea::vertical().id_salt(id.with("body")).max_height(room).show(ui, |ui| crate::filter_dialog::body(ui, fields));
};
// Select and Mask leaves the keyboard to its view keys (F, X, O, V…).
if crate::select_mask_view::owns(&fields) {
body(ui, &mut fields);
} else {
first_field_focused(ui, id, |ui| body(ui, &mut fields));
}
}
DialogKind::Command if fields.contains_key("__form") => crate::view_cmds::form_body(ui, &mut fields),
DialogKind::Command => {}
+40 -16
View File
@@ -1,13 +1,15 @@
//! Direct Selection tool (A, #790), and the Pen's ⌘/Ctrl (Direct Selection) and ⌥/Alt (Convert
//! Point) modifiers, as in Photoshop. It edits the paths the canvas shows: the targeted vector
//! mask or the active shape layer's path, and the work path.
//! Direct Selection tool (A, #790), the Convert Point tool (#3204), and the Pen's ⌘/Ctrl (Direct
//! Selection) and ⌥/Alt (Convert Point) modifiers, as in Photoshop. It edits the paths the canvas
//! shows: the targeted vector mask or the active shape layer's path, and the work path.
//!
//! * Click an anchor to select it (⇧ adds or removes, ⌥ selects its whole subpath), drag on empty
//! canvas to marquee-select anchors, click empty canvas to deselect.
//! * Drag selected anchors to move them, a direction handle to turn it (a smooth point keeps its
//! handles collinear; ⌥ breaks them apart), or a segment to bend it (straight segments move).
//! * Pen + ⌥: click an anchor to make it a corner, drag from it to pull out smooth handles, drag a
//! handle to move it alone.
//! * Convert Point (the tool, or Pen + ⌥): click an anchor to make it a corner, drag from it to
//! pull out smooth handles, drag a handle to move it alone. The tool selects the anchor it
//! converts, so its handles show; a press on empty canvas or a segment does nothing, and ⌘/Ctrl
//! is Direct Selection, as with the Pen.
//!
//! A drag previews with `photocraft_vector::edit` and commits once on release through the engine
//! (`path.moveAnchors`, `path.moveHandle`, `path.bendSegment`, `path.convertPoint`): one history
@@ -104,16 +106,29 @@ impl Edit {
}
}
/// Is Direct Selection what the pointer does now: its own tool, or the Pen or an anchor point
/// tool with ⌘/Ctrl held.
/// Is Direct Selection what the pointer does now: its own tool, or the Pen, an anchor point tool
/// or Convert Point with ⌘/Ctrl held.
fn direct(tool: Tool, mods: Modifiers) -> bool {
tool == Tool::DirectSelection || (matches!(tool, Tool::Pen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint) && mods.command)
tool == Tool::DirectSelection || (matches!(tool, Tool::Pen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint) && mods.command)
}
/// Is Convert Point what the pointer does now: its own tool, or the Pen with ⌥ held while no path
/// is being drawn. ⌘/Ctrl wins over both (Direct Selection).
fn converting(tool: Tool, mods: Modifiers, pen_idle: bool) -> bool {
!mods.command && (tool == Tool::ConvertPoint || (tool == Tool::Pen && mods.alt && pen_idle))
}
/// The tools that take every pointer event on the canvas, not just a press that hits a path.
fn owns(tool: Tool) -> bool {
matches!(tool, Tool::DirectSelection | Tool::ConvertPoint)
}
/// Does the canvas draw Direct Selection's overlay instead of the plain path outlines? The Add /
/// Delete Anchor Point tools show it too: they edit the same paths.
pub fn shows(app: &PhotocraftApp, mods: Modifiers) -> bool {
direct(app.ui.tool, mods) || app.ui.direct_selection.drag.is_some() || matches!(app.ui.tool, Tool::AddAnchorPoint | Tool::DeleteAnchorPoint)
direct(app.ui.tool, mods)
|| app.ui.direct_selection.drag.is_some()
|| matches!(app.ui.tool, Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint)
}
/// The paths Direct Selection edits, front first: the targeted vector mask or the active shape
@@ -154,7 +169,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
ToolEvent::Down { x, y, .. } => {
let pen_idle = app.ui.pen.is_none();
// Pen + ⌥ is Convert Point only while no path is being drawn.
let convert = tool == Tool::Pen && mods.alt && !mods.command && pen_idle;
let convert = converting(tool, mods, pen_idle);
if !(direct(tool, mods) || convert) {
return false;
}
@@ -172,7 +187,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
*preview = edit.preview(base, *start, *end);
}
Some(Drag::Marquee { end, .. }) => *end = [x, y],
None => return tool == Tool::DirectSelection,
None => return owns(tool),
}
true
}
@@ -185,14 +200,15 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
marquee(app, start, [x, y], add);
true
}
None => tool == Tool::DirectSelection,
None => owns(tool),
},
}
}
/// A press: pick what's under the pointer and start a drag. False lets the Pen handle a ⌥-press
/// that hit nothing.
/// that hit nothing; the Convert Point tool has nothing else to do, so it keeps the press.
fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) -> bool {
let own_tool = app.ui.tool == Tool::ConvertPoint;
let tol = 6.0 / f64::from(app.point_zoom().max(0.01));
let ds = &app.ui.direct_selection;
let found = candidates(app).into_iter().find_map(|(r, path)| {
@@ -202,7 +218,7 @@ fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) -
let ds = &mut app.ui.direct_selection;
let Some((target, base, hit)) = found else {
if convert {
return false;
return own_tool;
}
ds.drag = Some(Drag::Marquee { start: p, end: p, add: mods.shift });
return true;
@@ -214,9 +230,17 @@ fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) -
Hit::Handle(k, h) => {
let kn = base.subpaths.get(k[0]).and_then(|s| s.knots.get(k[1]));
let from = kn.map_or(Point::new(p[0], p[1]), |kn| if h == Handle::In { kn.in_ctrl } else { kn.out_ctrl });
Edit::Handle(k, h, from, mods.alt)
// Converting moves a handle on its own, as ⌥ does with Direct Selection.
Edit::Handle(k, h, from, mods.alt || convert)
}
Hit::Anchor(k) if convert => {
if own_tool {
// The tool selects the anchor it works on, so the anchor's handles show.
ds.segment = None;
ds.anchors = vec![k];
}
Edit::Convert(k)
}
Hit::Anchor(k) if convert => Edit::Convert(k),
// Pen + ⌥ on a segment does nothing.
Hit::Segment(..) if convert => return true,
Hit::Anchor(k) => {
+66 -1
View File
@@ -1,5 +1,6 @@
//! #790: paths can be reshaped after they're drawn, as in Photoshop: the Direct Selection tool
//! (A), and the Pen with ⌘/Ctrl (Direct Selection) or ⌥/Alt (Convert Point).
//! (A), and the Pen with ⌘/Ctrl (Direct Selection) or ⌥/Alt (Convert Point). #3204: Convert Point
//! is a tool of its own, in the Pen's flyout.
use egui::Modifiers;
use photocraft_doc::vector::Path;
@@ -160,3 +161,67 @@ fn shape_layer_paths_are_edited_too() {
let b: [i64; 4] = serde_json::from_value(info["bounds"].clone()).unwrap();
assert!(b[0] + b[2] >= 100 && b[1] + b[3] >= 90, "the shape re-rendered: {b:?}");
}
#[test]
fn the_convert_point_tool_converts_points_without_a_modifier() {
let mut app = app(Tool::ConvertPoint);
with_path(&mut app);
let n = steps(&app);
click(&mut app, [120.0, 40.0], Modifiers::NONE);
assert_eq!(work(&app).subpaths[0].knots[1], photocraft_doc::Knot::corner(120.0, 40.0), "a click makes a corner");
assert_eq!(steps(&app), n + 1);
assert_eq!(app.ui.direct_selection.anchors, vec![[0, 1]], "the converted anchor is selected");
drag(&mut app, [40.0, 120.0], [40.0, 140.0], Modifiers::NONE);
let k = work(&app).subpaths[0].knots[3];
assert_eq!((k.in_ctrl, k.out_ctrl, k.smooth), (Point::new(40.0, 100.0), Point::new(40.0, 140.0), true), "a drag pulls out smooth handles");
assert_eq!(steps(&app), n + 2, "one history step per press");
assert_eq!(app.ui.direct_selection.anchors, vec![[0, 3]]);
assert!(app.ui.pen.is_none(), "the tool never starts a pen path");
assert_eq!(app.ui.tool, Tool::ConvertPoint);
assert!(app.session.undo() && app.session.undo());
assert!(work(&app).subpaths[0].knots[1].smooth, "undo restores the smooth point");
}
#[test]
fn the_convert_point_tool_moves_a_handle_alone_and_ignores_empty_canvas_and_segments() {
let mut app = app(Tool::DirectSelection);
with_path(&mut app);
// Select the smooth anchor with Direct Selection so its handles show.
click(&mut app, [120.0, 40.0], Modifiers::NONE);
app.ui.tool = Tool::ConvertPoint;
drag(&mut app, [140.0, 40.0], [140.0, 70.0], Modifiers::NONE);
let k = work(&app).subpaths[0].knots[1];
assert_eq!((k.out_ctrl, k.in_ctrl, k.smooth), (Point::new(140.0, 70.0), Point::new(100.0, 40.0), false), "the handle moved without its twin");
// Nothing happens on empty canvas or on a segment, and the tool keeps the event.
let (before, n) = (work(&app), steps(&app));
click(&mut app, [80.0, 80.0], Modifiers::NONE);
drag(&mut app, [120.0, 80.0], [120.0, 100.0], Modifiers::NONE);
assert!(app.ui.direct_selection.drag.is_none(), "no marquee");
assert_eq!(app.ui.direct_selection.anchors, vec![[0, 1]], "the selection stays");
assert_eq!((work(&app), steps(&app)), (before, n));
assert!(app.ui.pen.is_none() && app.drag.is_none(), "the press was not handed to another tool");
}
#[test]
fn the_convert_point_tool_with_command_is_direct_selection() {
let mut app = app(Tool::ConvertPoint);
with_path(&mut app);
drag(&mut app, [40.5, 120.5], [50.5, 130.5], Modifiers::COMMAND);
assert_eq!(anchor(&app, 3), Point::new(50.0, 130.0), "⌘-drag moves the anchor");
assert!(work(&app).subpaths[0].knots[3].in_ctrl == work(&app).subpaths[0].knots[3].anchor, "and does not pull out handles");
// A handle drag with ⌘ turns the smooth pair together, like Direct Selection.
click(&mut app, [120.0, 40.0], Modifiers::COMMAND);
assert_eq!(app.ui.direct_selection.anchors, vec![[0, 1]]);
drag(&mut app, [140.0, 40.0], [120.0, 60.0], Modifiers::COMMAND);
let k = work(&app).subpaths[0].knots[1];
assert!((k.in_ctrl.x - 120.0).abs() < 1e-9 && (k.in_ctrl.y - 20.0).abs() < 1e-9, "the twin handle turned with it: {:?}", k.in_ctrl);
}
#[test]
fn the_convert_point_tool_shows_the_path_overlay() {
let mut app = app(Tool::ConvertPoint);
assert!(super::shows(&app, Modifiers::NONE), "anchors are drawn before any press");
app.ui.tool = Tool::Pen;
assert!(!super::shows(&app, Modifiers::NONE));
assert!(super::shows(&app, Modifiers::COMMAND));
}
+187 -13
View File
@@ -18,7 +18,10 @@ pub enum Kind {
max: f32,
default: f32,
},
Choice(Vec<String>),
Choice {
options: Vec<String>,
default: Option<String>,
},
Bool(bool),
Int {
default: i64,
@@ -80,8 +83,22 @@ pub fn parse_spec(spec: &str) -> Vec<Param> {
}
let v = v.trim();
let kind = if let Some(choices) = v.strip_prefix('"') {
let body = choices.split('"').next().unwrap_or("");
Kind::Choice(body.split('|').map(str::to_string).collect())
let (body, rest) = choices.split_once('"').unwrap_or((choices, ""));
let options: Vec<String> = body.split('|').map(str::to_string).collect();
let default = if let Some((_, def_part)) = rest.split_once('=') {
let trimmed = def_part.trim();
let def = if let Some(unquoted) = trimmed.strip_prefix('"') {
unquoted.split('"').next().unwrap_or("")
} else if let Some(unquoted) = trimmed.strip_prefix('\'') {
unquoted.split('\'').next().unwrap_or("")
} else {
trimmed.split(|c: char| c.is_whitespace() || c == '(' || c == ')' || c == ',' || c == '}').next().unwrap_or("")
};
if !def.is_empty() && options.iter().any(|opt| opt == def) { Some(def.to_string()) } else { None }
} else {
None
};
Kind::Choice { options, default }
} else if let Some(rest) = v.strip_prefix("bool") {
Kind::Bool(rest.trim_start_matches('=').trim() == "true")
} else if v == "text" {
@@ -278,7 +295,7 @@ pub fn has_dialog(command: &str) -> bool {
fn rememberable(kind: &Kind, value: &Value) -> bool {
match kind {
Kind::Range { min, max, .. } => value.as_f64().is_some_and(|n| n.is_finite() && n >= f64::from(*min) && n <= f64::from(*max)),
Kind::Choice(choices) => value.as_str().is_some_and(|v| choices.iter().any(|choice| choice == v)),
Kind::Choice { options, .. } => value.as_str().is_some_and(|v| options.iter().any(|choice| choice == v)),
Kind::Bool(_) => value.is_boolean(),
Kind::Int { .. } => value.as_i64().is_some(),
_ => false,
@@ -313,14 +330,18 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
fields.insert("__command".into(), json!(command));
fields.insert("__label".into(), json!(spec.label));
fields.insert("__filter".into(), json!(true));
if command.starts_with("filter.") || PREVIEWED.contains(&command) {
// Select and Mask previews in its view modes (`select_mask_view`), not as the command's result.
if command.starts_with("filter.") || PREVIEWED.contains(&command) || command == crate::select_mask_view::COMMAND {
fields.insert("__preview".into(), json!(true));
}
for p in parse_spec(spec.params) {
let v = match &p.kind {
Kind::Range { default, .. } if command == "image.mode.indexedColor" && p.key == "colors" => json!(default.round().clamp(2.0, 256.0) as u32),
Kind::Range { default, .. } => json!(default),
Kind::Choice(c) => json!(c.first().cloned().unwrap_or_default()),
Kind::Choice { options, default } => {
let initial = default.as_deref().or_else(|| options.first().map(String::as_str)).unwrap_or("");
json!(initial)
}
Kind::Bool(default) => json!(default),
Kind::Int { default } => json!(default),
Kind::Text => json!(""),
@@ -347,6 +368,9 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
if command == "edit.definePattern" {
fields.insert("name".into(), json!(photocraft_engine::pattern_cmds::default_name(&app.session)));
}
if command == "view.proofSetup" {
proof_setup_defaults(app, &mut fields);
}
if parse_spec(spec.params).iter().any(|p| p.kind == Kind::Document) {
// The document picker lists every open document (params refer to them by index).
let names: Vec<String> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect();
@@ -390,6 +414,16 @@ fn warp_defaults(app: &PhotocraftApp, fields: &mut Map<String, Value>) {
fields.insert("orientation".into(), json!(if w.horizontal { "horizontal" } else { "vertical" }));
}
/// Proof Setup reflects the active document's current proofing condition, never remembered values.
fn proof_setup_defaults(app: &PhotocraftApp, fields: &mut Map<String, Value>) {
let Some(st) = app.session.active() else { return };
let pv = app.session.color.proof(st.doc.id);
fields.insert("profile".into(), json!(pv.setup.name));
fields.insert("intent".into(), json!(pv.setup.intent.id()));
fields.insert("bpc".into(), json!(pv.setup.bpc));
fields.insert("simulatePaper".into(), json!(pv.setup.simulate_paper));
}
/// A filter dialog with live preview for `command` whose parameters follow `spec` (registry
/// notation) instead of the command's own; `fixed` params (e.g. a plug-in id) are passed through.
pub fn open_with_spec(app: &mut PhotocraftApp, command: &str, label: &str, spec: &str, fixed: Map<String, Value>) -> u64 {
@@ -402,7 +436,10 @@ pub fn open_with_spec(app: &mut PhotocraftApp, command: &str, label: &str, spec:
for p in parse_spec(spec) {
let v = match &p.kind {
Kind::Range { default, .. } => json!(default),
Kind::Choice(c) => json!(c.first().cloned().unwrap_or_default()),
Kind::Choice { options, default } => {
let initial = default.as_deref().or_else(|| options.first().map(String::as_str)).unwrap_or("");
json!(initial)
}
Kind::Bool(default) => json!(default),
Kind::Int { default } => json!(default),
Kind::Color(Some(rgb)) => json!(crate::color_picker_ui::hex(*rgb)),
@@ -453,6 +490,9 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
None => return,
},
};
if crate::select_mask_view::owns(f) {
crate::select_mask_view::controls(ui, f);
}
for p in parse_spec(&spec) {
match p.kind {
Kind::Range { min, max, default } => {
@@ -493,10 +533,11 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let scale = if max - min <= 10.0 { 100.0 } else { 10.0 };
f.insert(p.key, json!((v * scale).round() / scale));
}
Kind::Choice(options) => {
Kind::Choice { options, default } => {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim));
let mut cur = f.get(&p.key).and_then(Value::as_str).unwrap_or(&options[0]).to_string();
let initial = default.as_deref().or_else(|| options.first().map(String::as_str)).unwrap_or("");
let mut cur = f.get(&p.key).and_then(Value::as_str).unwrap_or(initial).to_string();
let labels: Vec<String> = options.iter().map(|o| choice_label(o)).collect();
let opts: Vec<(String, &str)> = options.iter().cloned().zip(labels.iter().map(String::as_str)).collect();
crate::widgets::dropdown(ui, &format!("flt-{cmd}-{}", p.key), &mut cur, &opts, 170.0);
@@ -622,6 +663,9 @@ pub fn preview_document_with(
k: u32,
cancel: Option<&photocraft_engine::jobs::JobCtx>,
) -> Option<Document> {
if command == crate::select_mask_view::COMMAND {
return crate::select_mask_view::preview_document(doc, active, params, k, cancel);
}
// Warp Text renders the type again from the layer model (font size, position), which a
// reduced proxy doesn't scale: it runs at full size and the result is reduced (#218).
let (run_k, reduce_k) = if command == "type.warpText" { (1, k) } else { (k, 1) };
@@ -721,11 +765,13 @@ mod tests {
fn parses_registry_notation() {
let p = parse_spec(r#"{"radius":0.1..1000=1,"method":"spin|zoom","monochromatic":bool,"seed":u32=0,"horizontal":px=0}"#);
assert_eq!(p[0], Param { key: "radius".into(), kind: Kind::Range { min: 0.1, max: 1000.0, default: 1.0 } });
assert_eq!(p[1].kind, Kind::Choice(vec!["spin".into(), "zoom".into()]));
assert_eq!(p[1].kind, Kind::Choice { options: vec!["spin".into(), "zoom".into()], default: None });
assert_eq!(p[2].kind, Kind::Bool(false));
assert_eq!(p[3].kind, Kind::Int { default: 0 });
assert_eq!(p[4].kind, Kind::Int { default: 0 });
assert!(parse_spec("{}").is_empty());
let p_choice = parse_spec(r#"{"quality":"draft|good|best"="good"}"#);
assert_eq!(p_choice[0].kind, Kind::Choice { options: vec!["draft".into(), "good".into(), "best".into()], default: Some("good".into()) });
let p = parse_spec(r#"{"lighting":bool=true,"kernel":int[25],"pins":json,"points":[[0,0],[1,0]],"mapPath":text,"mapDocument":doc,"scale":1..9999=1}"#);
let kinds: Vec<&Kind> = p.iter().map(|p| &p.kind).collect();
assert_eq!(
@@ -744,6 +790,92 @@ mod tests {
assert!(!params_of(&f).as_object().unwrap().contains_key("reference"), "{f:?}");
}
/// View › Proof Setup (#3055): Custom Proof Setup opens with the active document's current
/// proof condition, preserving it on unchanged OK, ignoring stale remembered dialog values,
/// tracking document switching, applying edits, and leaving the document unchanged on Cancel.
#[test]
fn proof_setup_dialog_reflects_active_document_proof_condition() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let ctx = egui::Context::default();
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
let doc1_id = app.session.active().unwrap().doc.id;
// 0. A fresh document defaults to working-cmyk.
let d_fresh = crate::menus::invoke(&mut app, &ctx, "view.proofSetup.custom", Value::Null).unwrap()["dialog"].as_u64().unwrap();
assert_eq!(app.ui.dialog_mut(d_fresh).unwrap().fields["profile"], json!("working-cmyk"));
crate::dialogs::confirm(&mut app, d_fresh).unwrap();
assert_eq!(app.session.color.proof(doc1_id).setup.name, "working-cmyk");
// 1. Preset sets sRGB and turns Proof Colors on.
crate::menus::invoke(&mut app, &ctx, "view.proofSetup.internetStandardRgb", Value::Null).unwrap();
assert_eq!(app.session.color.proof(doc1_id).setup.name, "srgb");
assert!(app.session.color.proof(doc1_id).enabled);
// 2. Open Custom Proof Setup: starts with the active document's condition (sRGB).
let d_id = crate::menus::invoke(&mut app, &ctx, "view.proofSetup.custom", Value::Null).unwrap()["dialog"].as_u64().unwrap();
let fields = &app.ui.dialog_mut(d_id).unwrap().fields;
assert_eq!(fields["profile"], json!("srgb"));
assert_eq!(fields["intent"], json!("relative"));
assert_eq!(fields["bpc"], json!(true));
assert_eq!(fields["simulatePaper"], json!(false));
// 3. Unchanged OK preserves the document's condition and leaves Proof Colors enabled.
crate::dialogs::confirm(&mut app, d_id).unwrap();
let pv = app.session.color.proof(doc1_id);
assert_eq!(pv.setup.name, "srgb");
assert!(pv.enabled);
// 4. Stale remembered values do not override the active document.
app.session.prefs.edit(|prefs| {
prefs.dialogs.insert(
"view.proofSetup".into(),
json!({
"profile": "display-p3",
"intent": "perceptual",
"bpc": false,
"simulatePaper": true,
}),
);
});
let d_id = open(&mut app, "view.proofSetup").unwrap();
let fields = &app.ui.dialog_mut(d_id).unwrap().fields;
assert_eq!(fields["profile"], json!("srgb"), "reads active document, not remembered");
assert_eq!(fields["intent"], json!("relative"));
assert_eq!(fields["bpc"], json!(true));
assert_eq!(fields["simulatePaper"], json!(false));
// 5. Cancel preserves the document.
app.ui.close_dialog(d_id);
assert_eq!(app.session.color.proof(doc1_id).setup.name, "srgb");
// 6. Multiple documents: each document initializes the dialog to its own condition.
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
let doc2_id = app.session.active().unwrap().doc.id;
assert_ne!(doc1_id, doc2_id);
app.run("view.proofSetup", json!({"profile": "display-p3", "intent": "perceptual", "bpc": false, "simulatePaper": true})).unwrap();
assert_eq!(app.session.color.proof(doc2_id).setup.name, "display-p3");
let d_id2 = open(&mut app, "view.proofSetup").unwrap();
let fields2 = &app.ui.dialog_mut(d_id2).unwrap().fields;
assert_eq!(fields2["profile"], json!("display-p3"));
assert_eq!(fields2["intent"], json!("perceptual"));
assert_eq!(fields2["bpc"], json!(false));
assert_eq!(fields2["simulatePaper"], json!(true));
app.ui.close_dialog(d_id2);
// Switch back to doc1: dialog still reflects doc1's sRGB.
assert!(app.session.set_active(0));
let d_id1 = open(&mut app, "view.proofSetup").unwrap();
assert_eq!(app.ui.dialog_mut(d_id1).unwrap().fields["profile"], json!("srgb"));
// 7. Modifying the dialog updates the active document.
app.ui.dialog_mut(d_id1).unwrap().fields.insert("profile".into(), json!("adobe-rgb-compat"));
crate::dialogs::confirm(&mut app, d_id1).unwrap();
assert_eq!(app.session.color.proof(doc1_id).setup.name, "adobe-rgb-compat");
// doc2 is untouched
assert_eq!(app.session.color.proof(doc2_id).setup.name, "display-p3");
}
#[test]
fn indexed_counts_are_integer_json_while_dither_amounts_keep_their_decimals() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
@@ -815,12 +947,12 @@ mod tests {
continue;
}
check(source_label(&p.key));
if let Kind::Choice(choices) = p.kind {
for choice in choices {
if let Kind::Choice { options, .. } = &p.kind {
for choice in options {
// Only symbolic options are UI choices; registry docs also contain
// colour syntax and array notation, which are not translatable names.
if choice.chars().all(|c| c.is_ascii_alphanumeric()) {
check(source_label(&choice));
check(source_label(choice));
}
}
}
@@ -1454,4 +1586,46 @@ mod tests {
}
}
}
#[test]
fn schema_dialogs_respect_explicit_dropdown_defaults() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
// 1. Radial Blur has explicit default "good", not the first option ("draft").
open(&mut app, "filter.blur.radialBlur").unwrap();
let fields = &app.ui.dialogs.last().unwrap().fields;
assert_eq!(fields["quality"], json!("good"), "Radial Blur must select explicit default 'good'");
assert_eq!(fields["method"], json!("spin"), "Radial Blur method has no explicit default, retains first option");
let params = params_of(fields);
assert_eq!(params["quality"], json!("good"));
// 2. Convert to Profile has explicit default "relative", not first option ("perceptual").
open(&mut app, "edit.convertToProfile").unwrap();
let fields = &app.ui.dialogs.last().unwrap().fields;
assert_eq!(fields["intent"], json!("relative"), "Convert to Profile must select explicit default 'relative'");
let params = params_of(fields);
assert_eq!(params["intent"], json!("relative"));
// 3. Proof Setup has explicit default "relative".
open(&mut app, "view.proofSetup").unwrap();
let fields = &app.ui.dialogs.last().unwrap().fields;
assert_eq!(fields["intent"], json!("relative"), "Proof Setup must select explicit default 'relative'");
let params = params_of(fields);
assert_eq!(params["intent"], json!("relative"));
// 4. Remembered values still override initial defaults.
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.session.prefs.edit(|prefs| {
prefs.dialogs.insert("filter.blur.radialBlur".into(), json!({"quality": "best"}));
});
open(&mut app, "filter.blur.radialBlur").unwrap();
let fields = &app.ui.dialogs.last().unwrap().fields;
assert_eq!(fields["quality"], json!("best"), "Remembered choice must override explicit default");
// 5. Explicit default that is the first option (e.g. "cw" in arbitrary rotation).
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
open(&mut app, "image.rotation.arbitrary").unwrap();
let fields = &app.ui.dialogs.last().unwrap().fields;
assert_eq!(fields["direction"], json!("cw"), "Explicit default matching first option is preserved");
}
}
+7 -3
View File
@@ -52,7 +52,10 @@ fn default_params(f: GalleryFilter) -> Map<String, Value> {
for p in parse_spec(f.params_doc()) {
let v = match p.kind {
Kind::Range { default, .. } => json!(default),
Kind::Choice(c) => json!(c[0]),
Kind::Choice { options, default } => {
let initial = default.as_deref().or_else(|| options.first().map(String::as_str)).unwrap_or("");
json!(initial)
}
Kind::Bool(b) => json!(b),
_ => continue,
};
@@ -685,8 +688,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
widgets::slider_row(ui, &label, &mut v, min..=max, "", None);
e.params.insert(p.key, json!(v.round()));
}
Kind::Choice(options) => {
let mut c = e.params.get(&p.key).and_then(Value::as_str).unwrap_or(&options[0]).to_string();
Kind::Choice { options, default } => {
let initial = default.as_deref().or_else(|| options.first().map(String::as_str)).unwrap_or("");
let mut c = e.params.get(&p.key).and_then(Value::as_str).unwrap_or(initial).to_string();
let labels: Vec<String> = options.iter().map(|o| crate::filter_dialog::label(o)).collect();
let opts: Vec<(String, &str)> = options.iter().cloned().zip(labels.iter().map(String::as_str)).collect();
ui.horizontal(|ui| {
+1
View File
@@ -87,6 +87,7 @@ pub fn cmd_moves(t: Tool) -> bool {
| Tool::CurvaturePen
| Tool::AddAnchorPoint
| Tool::DeleteAnchorPoint
| Tool::ConvertPoint
| Tool::RectMarquee
| Tool::EllipseMarquee
| Tool::Lasso
+10
View File
@@ -1720,6 +1720,7 @@
Pen Pressure Přítlak pera
Pen Tilt Náklon pera
Pen Tool Nástroj pero
Convert Point Tool Nástroj převést bod
Photo Fotografie
PhotoCraft website Web PhotoCraft
Picas Pica
@@ -2562,4 +2563,13 @@ cameraData Lens Objektiv
Snap to Origin Přichytit k počátku
Search settings… Hledat nastavení…
No matching settings Žádná odpovídající nastavení
# Select and Mask view modes (#1754)
Onion Skin Cibulová slupka
Marching Ants Pochodující mravenci
On Black Na černé
On White Na bílé
On Layers Na vrstvách
Show Edge Zobrazit okraj
Show Original Zobrazit originál
F cycles the views; hold X to see the image F přepíná zobrazení; podržením X uvidíte obraz
Show rulers Zobrazit pravítka
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -1695,6 +1695,7 @@
Pen Pressure Stiftdruck
Pen Tilt Stiftneigung
Pen Tool Stift-Werkzeug
Convert Point Tool Punkt-umwandeln-Werkzeug
Photo Foto
PhotoCraft website PhotoCraft-Website
Picas Pica
@@ -2593,4 +2594,13 @@ cameraData Lens Objektiv
Snap to Origin An Ursprung ausrichten
Search settings… Einstellungen durchsuchen…
No matching settings Keine passenden Einstellungen
# Select and Mask view modes (#1754)
Onion Skin Zwiebelschicht
Marching Ants Laufende Ameisen
On Black Auf Schwarz
On White Auf Weiß
On Layers Auf Ebenen
Show Edge Kante anzeigen
Show Original Original anzeigen
F cycles the views; hold X to see the image F wechselt die Ansicht; X gedrückt halten zeigt das Bild
Show rulers Lineale anzeigen
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -3287,6 +3287,7 @@ cameraRaw Yellow Κίτρινο
Click a segment to add an anchor point · {alt}-click an anchor to delete it Κάντε κλικ σε ένα τμήμα για να προσθέσετε σημείο αγκύρωσης · {alt}-κλικ σε σημείο αγκύρωσης για διαγραφή
Click an anchor point to delete it · {alt}-click a segment to add one Κάντε κλικ σε ένα σημείο αγκύρωσης για να το διαγράψετε · {alt}-κλικ σε τμήμα για προσθήκη
Pen Tool Πένα (Pen Tool)
Convert Point Tool Μετατροπή σημείου (Convert Point Tool)
Polygon Tool Πολύγωνο (Polygon Tool)
Polygonal Lasso Tool Πολυγωνικό λάσο (Polygonal Lasso Tool)
Quick Selection Tool Γρήγορη επιλογή (Quick Selection Tool)
@@ -3654,4 +3655,13 @@ cameraData Lens Φακός
Snap to Origin Προσκόλληση στην αρχή
Search settings… Αναζήτηση ρυθμίσεων…
No matching settings Δεν βρέθηκαν ρυθμίσεις
# Select and Mask view modes (#1754)
Onion Skin Φλούδα κρεμμυδιού
Marching Ants Κινούμενο περίγραμμα
On Black Σε μαύρο
On White Σε λευκό
On Layers Σε επίπεδα
Show Edge Εμφάνιση άκρης
Show Original Εμφάνιση αρχικού
F cycles the views; hold X to see the image Το F εναλλάσσει τις προβολές· κρατήστε το X για να δείτε την εικόνα
Show rulers Εμφάνιση χαράκων
Can't render this file because it contains an unexpected character in line 991 and column 18.
File diff suppressed because it is too large Load Diff
+10
View File
@@ -1681,6 +1681,7 @@
Pen Pressure Pression du stylet
Pen Tilt Inclinaison du stylet
Pen Tool Outil Plume
Convert Point Tool Outil Conversion de point
Photo Photo
PhotoCraft website Site web de PhotoCraft
Picas Picas
@@ -2971,4 +2972,13 @@ cameraData Lens Objectif
Snap to Origin Aligner sur l'origine
Search settings… Rechercher des réglages…
No matching settings Aucun réglage correspondant
# Select and Mask view modes (#1754)
Onion Skin Pelure d'oignon
Marching Ants Fourmis en marche
On Black Sur fond noir
On White Sur fond blanc
On Layers Sur les calques
Show Edge Afficher le bord
Show Original Afficher l'original
F cycles the views; hold X to see the image F fait défiler les vues ; maintenez X pour voir l'image
Show rulers Afficher les règles
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -1675,6 +1675,7 @@
Pen Pressure Tekanan Pena
Pen Tilt Kemiringan Pena
Pen Tool Alat Pen
Convert Point Tool Alat Convert Point
Photo Foto
PhotoCraft website Situs web PhotoCraft
Picas Pica
@@ -2958,4 +2959,13 @@ cameraData Lens Lensa
Snap to Origin Jepret ke Titik Asal
Search settings… Cari pengaturan…
No matching settings Tidak ada pengaturan yang cocok
# Select and Mask view modes (#1754)
Onion Skin Kulit Bawang
Marching Ants Semut Berbaris
On Black Di Atas Hitam
On White Di Atas Putih
On Layers Di Atas Lapisan
Show Edge Tampilkan Tepi
Show Original Tampilkan Asli
F cycles the views; hold X to see the image F berganti tampilan; tahan X untuk melihat gambar
Show rulers Tampilkan Penggaris
Can't render this file because it contains an unexpected character in line 4 and column 47.
+10
View File
@@ -1702,6 +1702,7 @@
Pen Pressure Pressione penna
Pen Tilt Inclinazione penna
Pen Tool Strumento Penna
Convert Point Tool Strumento Converti punto
Photo Foto
PhotoCraft website Sito web PhotoCraft
Picas Pica
@@ -2625,4 +2626,13 @@ cameraData Lens Obiettivo
Snap to Origin Aggancia all'origine
Search settings… Cerca impostazioni…
No matching settings Nessuna impostazione corrispondente
# Select and Mask view modes (#1754)
Onion Skin Velina
Marching Ants Formiche in marcia
On Black Su nero
On White Su bianco
On Layers Su livelli
Show Edge Mostra bordo
Show Original Mostra originale
F cycles the views; hold X to see the image F scorre le viste; tieni premuto X per vedere l'immagine
Show rulers Mostra righelli
Can't render this file because it contains an unexpected character in line 2574 and column 53.
+10
View File
@@ -1704,6 +1704,7 @@
Pen Pressure ペンの筆圧
Pen Tilt ペンの傾き
Pen Tool ペンツール
Convert Point Tool アンカーポイントの変換ツール
Photo 写真
PhotoCraft website PhotoCraft の Web サイト
Picas パイカ
@@ -3568,4 +3569,13 @@ cameraData Lens レンズ
Snap to Origin 原点にスナップ
Search settings… 設定を検索…
No matching settings 一致する設定はありません
# Select and Mask view modes (#1754)
Onion Skin オニオンスキン
Marching Ants 点線の境界
On Black 黒地
On White 白地
On Layers レイヤー上
Show Edge エッジを表示
Show Original 元の状態を表示
F cycles the views; hold X to see the image F で表示を切り替え、X を押している間は画像を表示
Show rulers 定規を表示
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -1673,6 +1673,7 @@
Pen Pressure 펜 압력
Pen Tilt 펜 기울기
Pen Tool 펜 도구
Convert Point Tool 기준점 변환 도구
Photo 사진
PhotoCraft website PhotoCraft 웹사이트
Picas 파이카
@@ -3137,4 +3138,13 @@ cameraData Lens 렌즈
Snap to Origin 원점에 스냅
Search settings… 설정 검색…
No matching settings 일치하는 설정 없음
# Select and Mask view modes (#1754)
Onion Skin 어니언 스킨
Marching Ants 점선 윤곽
On Black 검정 배경
On White 흰색 배경
On Layers 레이어 위
Show Edge 가장자리 표시
Show Original 원본 표시
F cycles the views; hold X to see the image F로 보기 전환, X를 누르고 있으면 이미지 표시
Show rulers 눈금자 표시
Can't render this file because it contains an unexpected character in line 3084 and column 53.
+10
View File
@@ -1682,6 +1682,7 @@
Pen Pressure Pendruk
Pen Tilt Penkanteling
Pen Tool Pengereedschap
Convert Point Tool Punt-converteergereedschap
Photo Foto
PhotoCraft website Website van PhotoCraft
Picas Pica's
@@ -3714,6 +3715,15 @@ cameraData Lens Lens
Search settings… Instellingen zoeken…
No matching settings Geen overeenkomende instellingen
PhotoCraft — Screen Color Picker PhotoCraft — Schermkleurkiezer
# Select and Mask view modes (#1754)
Onion Skin Uienschil
Marching Ants Mierenmars
On Black Op zwart
On White Op wit
On Layers Op lagen
Show Edge Rand tonen
Show Original Origineel tonen
F cycles the views; hold X to see the image F wisselt de weergaven; houd X ingedrukt om de afbeelding te zien
Show rulers Linialen tonen
Frame Tool Kadergereedschap
Create a new rectangular frame Nieuw rechthoekig kader maken
Can't render this file because it contains an unexpected character in line 8 and column 34.
+10
View File
@@ -1697,6 +1697,7 @@
Pen Pressure Nacisk pióra
Pen Tilt Nachylenie pióra
Pen Tool Narzędzie Pióro
Convert Point Tool Narzędzie Konwertuj punkt
Photo Zdjęcie
PhotoCraft website Strona internetowa PhotoCraft
Picas Pikasy
@@ -3569,4 +3570,13 @@ cameraData Lens Obiektyw
Snap to Origin Przyciągnij do początku
Search settings… Szukaj ustawień…
No matching settings Brak pasujących ustawień
# Select and Mask view modes (#1754)
Onion Skin Skórka cebuli
Marching Ants Maszerujące mrówki
On Black Na czarnym
On White Na białym
On Layers Na warstwach
Show Edge Pokaż krawędź
Show Original Pokaż oryginał
F cycles the views; hold X to see the image F przełącza widoki; przytrzymaj X, aby zobaczyć obraz
Show rulers Pokaż linijki
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -1679,6 +1679,7 @@
Pen Pressure Pressão da caneta
Pen Tilt Inclinação da caneta
Pen Tool Ferramenta Caneta
Convert Point Tool Ferramenta Converter ponto
Photo Foto
PhotoCraft website Site do PhotoCraft
Picas Paicas
@@ -2978,4 +2979,13 @@ cameraData Lens Lente
Snap to Origin Ajustar à origem
Search settings… Pesquisar configurações…
No matching settings Nenhuma configuração encontrada
# Select and Mask view modes (#1754)
Onion Skin Papel vegetal
Marching Ants Formigas marchando
On Black Sobre preto
On White Sobre branco
On Layers Sobre camadas
Show Edge Mostrar borda
Show Original Mostrar original
F cycles the views; hold X to see the image F alterna as visualizações; segure X para ver a imagem
Show rulers Mostrar réguas
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -1588,6 +1588,7 @@
Quick Selection Tool Быстрое выделение
Object Selection Tool Выделение объектов
Pen Tool Перо
Convert Point Tool Преобразование узла
Path Selection Tool Выделение контура
Direct Selection Tool Выделение узла
Add Anchor Point Tool Добавить узел
@@ -2894,6 +2895,15 @@ cameraData Lens Объектив
Rasterize Layer Style Растрировать стиль слоя
Search settings… Поиск настроек…
No matching settings Нет подходящих настроек
# Select and Mask view modes (#1754)
Onion Skin Калька
Marching Ants Бегущие муравьи
On Black На черном
On White На белом
On Layers На слоях
Show Edge Показать край
Show Original Показать исходное
F cycles the views; hold X to see the image F переключает виды; удерживайте X, чтобы увидеть изображение
Show rulers Показать линейки
New Crop Preset… Новый набор параметров кадрирования…
Delete Crop Preset… Удалить набор параметров кадрирования…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -1569,6 +1569,7 @@
Quick Selection Tool Інструмент «Швидке виділення»
Object Selection Tool Інструмент «Виділення об'єкта»
Pen Tool Інструмент «Перо»
Convert Point Tool Інструмент «Перетворення вузла»
Path Selection Tool Інструмент «Виділення контуру»
Direct Selection Tool Інструмент «Пряме виділення»
Add Anchor Point Tool Інструмент «Додати вузол»
@@ -3644,6 +3645,15 @@ cameraData Lens Об'єктив
Search settings… Пошук налаштувань…
No matching settings Немає відповідних налаштувань
PhotoCraft — Screen Color Picker PhotoCraft — Піпетка кольору екрана
# Select and Mask view modes (#1754)
Onion Skin Калька
Marching Ants Мурашки
On Black На чорному
On White На білому
On Layers На шарах
Show Edge Показати край
Show Original Показати початкове
F cycles the views; hold X to see the image F перемикає види; утримуйте X, щоб побачити зображення
Show rulers Показати лінійки
Frame Tool Інструмент «Рамка»
Create a new rectangular frame Створити прямокутну рамку
Can't render this file because it contains an unexpected character in line 1348 and column 18.
+10
View File
@@ -1718,6 +1718,7 @@
Pen Pressure 钢笔压力
Pen Tilt 钢笔倾斜
Pen Tool 钢笔工具
Convert Point Tool 转换点工具
Photo 照片
PhotoCraft website PhotoCraft 网站
Picas 派卡
@@ -2563,6 +2564,15 @@ cameraData Lens 镜头
Snap to Origin 贴紧原点
Search settings… 搜索设置…
No matching settings 没有匹配的设置
# Select and Mask view modes (#1754)
Onion Skin 洋葱皮
Marching Ants 蚂蚁线
On Black 黑底
On White 白底
On Layers 图层上
Show Edge 显示边缘
Show Original 显示原始
F cycles the views; hold X to see the image 按 F 切换视图;按住 X 查看图像
Show rulers 显示标尺
New Crop Preset… 新建裁剪预设…
Delete Crop Preset… 删除裁剪预设…
Can't render this file because it contains an unexpected character in line 2516 and column 53.
+10
View File
@@ -1710,6 +1710,7 @@
Pen Pressure 筆壓
Pen Tilt 筆傾斜
Pen Tool 筆型工具
Convert Point Tool 轉換錨點工具
Photo 相片
PhotoCraft website PhotoCraft 網站
Picas 派卡
@@ -2533,6 +2534,15 @@ cameraData Lens 鏡頭
Snap to Origin 靠齊原點
Search settings… 搜尋設定…
No matching settings 沒有符合的設定
# Select and Mask view modes (#1754)
Onion Skin 洋蔥皮
Marching Ants 螞蟻線
On Black 黑底
On White 白底
On Layers 圖層上
Show Edge 顯示邊緣
Show Original 顯示原始
F cycles the views; hold X to see the image 按 F 切換檢視;按住 X 檢視影像
Show rulers 顯示尺標
New Crop Preset… 新增裁切預設集…
Delete Crop Preset… 刪除裁切預設集…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -46,6 +46,8 @@ pub static ICONS: &[(&str, &[u8])] = &[
("cloud", include_bytes!("../../../assets/icons/cloud.svg")),
("compass", include_bytes!("../../../assets/icons/compass.svg")),
("contrast", include_bytes!("../../../assets/icons/contrast.svg")),
// PhotoCraft original: the Convert Point tool glyph.
("convert-point", include_bytes!("../../../assets/icons/convert-point.svg")),
("copy", include_bytes!("../../../assets/icons/copy.svg")),
("crop", include_bytes!("../../../assets/icons/crop.svg")),
("diamond", include_bytes!("../../../assets/icons/diamond.svg")),
+1
View File
@@ -139,6 +139,7 @@ pub fn tool_icon(t: Tool) -> &'static str {
Tool::CurvaturePen => "pen-curvature",
Tool::AddAnchorPoint => "pen-add-anchor",
Tool::DeleteAnchorPoint => "pen-delete-anchor",
Tool::ConvertPoint => "convert-point",
Tool::PathSelection => "mouse-pointer-2",
Tool::DirectSelection => "direct-select",
Tool::Rectangle => "rectangle-horizontal",
+12 -4
View File
@@ -150,6 +150,7 @@ pub mod rotate_view;
pub mod rulers;
pub mod screen_picker;
pub mod scrollbars;
pub mod select_mask_view;
pub mod served_fonts;
pub mod shape_dialog;
pub(crate) mod shape_fill_ui;
@@ -1700,12 +1701,13 @@ impl PhotocraftApp {
/// as the engine routes those itself.
pub fn layer_mask_targeted(&self) -> bool {
let Some(st) = self.session.active() else { return false };
let composite = st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::Composite && st.doc.quick_mask.is_none();
let layer_mask = st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::LayerMask;
let composite = (st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::Composite || layer_mask) && st.doc.quick_mask.is_none();
let has_mask = st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.mask.is_some());
if !composite || !has_mask {
return false;
}
if self.ui.mask_target {
if self.ui.mask_target || layer_mask {
return true;
}
let colors_off = st.channel_view.visible_colors(photocraft_engine::channel_cmds::color_count(&st.doc)) == 0;
@@ -1717,10 +1719,13 @@ impl PhotocraftApp {
/// active layer (a shape layer's path is its content, not a mask). Targeting a mask or the
/// pixels brings back that target's foreground/background pair, as in Photoshop (#2166).
pub(crate) fn sync_mask_targets(&mut self) {
if let Some(st) = self.session.active() {
if photocraft_engine::mask_view_cmds::current(st).is_some() {
if let Some(st) = self.session.active_mut() {
if photocraft_engine::mask_view_cmds::current(st).is_some() || st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::LayerMask {
self.ui.mask_target = true;
self.ui.vector_mask_target = false;
} else if st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::VectorMask {
self.ui.vector_mask_target = true;
self.ui.mask_target = false;
}
if self.ui.vector_mask_target && !mask_thumbs_ui::has_vector_mask(st) {
self.ui.vector_mask_target = false;
@@ -1728,6 +1733,9 @@ impl PhotocraftApp {
if self.ui.mask_target && st.active_layer.and_then(|id| st.doc.layer(id)).is_none_or(|l| l.mask.is_none()) {
self.ui.mask_target = false;
}
if !self.ui.mask_target && st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::LayerMask {
st.channel_view.target = photocraft_engine::channel_cmds::ChannelTarget::Composite;
}
}
let mask = self.layer_mask_targeted();
self.session.tools.target_mask(mask);
+3 -1
View File
@@ -1912,7 +1912,9 @@ mod tests {
assert!(invoke(&mut app, &ctx, "view.proofSetup.custom", Value::Null).unwrap()["dialog"].is_u64());
let spec = photocraft_engine::commands::find("edit.convertToProfile").unwrap();
let params = crate::filter_dialog::parse_spec(spec.params);
assert!(matches!(&params[0].kind, crate::filter_dialog::Kind::Choice(c) if c[0] == "srgb" && !c.iter().any(|v| v.contains('/'))));
assert!(
matches!(&params[0].kind, crate::filter_dialog::Kind::Choice { options, .. } if options[0] == "srgb" && !options.iter().any(|v| v.contains('/')))
);
}
}
+1 -1
View File
@@ -266,7 +266,7 @@ mod tests {
// the current language); `tr` with an explicit language leaves the process-wide language,
// which other tests running in parallel draw with, untouched
let es = crate::i18n::Lang::from_code("es").unwrap();
assert_eq!(crate::i18n::tr(es, &notice.title), "El arrastrar y soltar archivos de forma nativa no está disponible en Wayland");
assert_eq!(crate::i18n::tr(es, &notice.title), "Arrastrar y soltar archivos de forma nativa no está disponible en Wayland");
assert_ne!(crate::i18n::tr(es, &notice.lines[0]), notice.lines[0]);
assert_eq!(crate::i18n::tr(crate::i18n::Lang::EN, &notice.title), "Native file drag-and-drop is unavailable");
}
+1 -1
View File
@@ -35,7 +35,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[
&[Tool::Dodge, Tool::Burn, Tool::Sponge],
],
&[
&[Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint],
&[Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint, Tool::ConvertPoint],
&[Tool::Type, Tool::VerticalType, Tool::VerticalTypeMask, Tool::HorizontalTypeMask],
&[Tool::PathSelection, Tool::DirectSelection],
&[Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape],
+15 -37
View File
@@ -341,30 +341,20 @@ fn presets_store(app: &mut PhotocraftApp) {
/// Resolve an absolute pixels-per-point preference without multiplying the OS DPI twice.
/// Monitor dimensions are physical pixels, independent of window size and UI zoom.
fn display_scale(pref: prefs::UiScale, native: Option<f32>, monitor_px: Option<egui::Vec2>) -> f32 {
fn display_scale(pref: prefs::UiScale, native: Option<f32>) -> f32 {
let native = native.filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0);
match pref {
prefs::UiScale::Auto => {
// Follow the system's scale, fractional ones included (125%, 150%, 175%: #1072).
// Only when the system reports no scaling at all (100%) does a 4K display fall back
// to 200%, since unscaled 4K controls are unreadably small (#225). Overriding a scale
// the user picked in their desktop settings left only "too small" or "too big".
let unscaled = (native - 1.0).abs() < 0.01;
let is_4k = monitor_px.is_some_and(|s| s.x.is_finite() && s.y.is_finite() && s.x.min(s.y) >= 2160.0 && s.x.max(s.y) >= 3840.0);
if unscaled && is_4k { 2.0 } else { native }
}
// The system's scale, fractional ones included (125%, 150%, 175%: #1072), and 100% on a
// large 4K display run unscaled (#1278); 200% there is a fixed choice.
prefs::UiScale::Auto => native,
fixed => fixed.name().parse::<f32>().map_or(1.0, |pct| pct / 100.0),
}
}
fn sync_display_scale(app: &PhotocraftApp, ctx: &egui::Context) {
let native = ctx.native_pixels_per_point();
// ViewportInfo uses egui points, including the current UI zoom. Converting back to
// physical pixels avoids oscillating between 100% and 200% on successive frames.
// logic() can receive new viewport DPI before InputState::pixels_per_point updates.
let native_scale = native.filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0);
let monitor_px = ctx.input(|i| i.viewport().monitor_size).map(|s| s * (native_scale * ctx.zoom_factor()));
let scale = display_scale(app.session.prefs().interface.ui_scale, native, monitor_px);
let scale = display_scale(app.session.prefs().interface.ui_scale, native);
ctx.set_zoom_factor(scale / native_scale);
}
@@ -2463,28 +2453,16 @@ mod tests {
}
#[test]
fn auto_scale_detects_4k_and_preserves_system_scale() {
fn auto_scale_preserves_system_scale() {
use prefs::UiScale::Auto;
for size in [vec2(3840.0, 2160.0), vec2(4096.0, 2160.0), vec2(2160.0, 3840.0)] {
assert_eq!(display_scale(Auto, Some(1.0), Some(size)), 2.0);
// A fractional system scale is the user's choice and is kept as is (#1072).
for dpi in [1.25, 1.5, 1.75, 2.0, 2.5] {
assert_eq!(display_scale(Auto, Some(dpi), Some(size)), dpi);
}
// Whatever the monitor, 4K included: an unscaled desktop stays at 100% (#1278), and a
// fractional or below-100% system scale is the user's choice (#1072).
for dpi in [0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0] {
assert_eq!(display_scale(Auto, Some(dpi)), dpi);
}
// Below 100% is a system choice too, even on 4K.
assert_eq!(display_scale(Auto, Some(0.75), Some(vec2(3840.0, 2160.0))), 0.75);
for size in [vec2(1920.0, 1080.0), vec2(2560.0, 1440.0)] {
for dpi in [1.25, 1.5, 1.75] {
assert_eq!(display_scale(Auto, Some(dpi), Some(size)), dpi);
}
}
assert_eq!(display_scale(Auto, Some(3.0), Some(vec2(3840.0, 2160.0))), 3.0);
for size in [vec2(1920.0, 1080.0), vec2(2560.0, 1440.0), vec2(3840.0, 1080.0)] {
assert_eq!(display_scale(Auto, Some(1.0), Some(size)), 1.0);
}
assert_eq!(display_scale(Auto, None, None), 1.0);
assert_eq!(display_scale(Auto, Some(f32::NAN), Some(vec2(f32::INFINITY, 2160.0))), 1.0);
assert_eq!(display_scale(Auto, None), 1.0);
assert_eq!(display_scale(Auto, Some(f32::NAN)), 1.0);
assert_eq!(display_scale(Auto, Some(-2.0)), 1.0);
}
#[test]
@@ -2507,13 +2485,13 @@ mod tests {
assert!((ctx.pixels_per_point() - expected).abs() < 1e-4);
};
for _ in 0..4 {
step(vec2(3840.0, 2160.0), 1.0, 2.0);
step(vec2(3840.0, 2160.0), 1.0, 1.0);
}
step(vec2(1920.0, 1080.0), 1.0, 1.0);
step(vec2(3840.0, 2160.0), 1.5, 1.5);
step(vec2(3840.0, 2160.0), 1.25, 1.25);
step(vec2(2560.0, 1440.0), 1.75, 1.75);
step(vec2(3840.0, 2160.0), 1.0, 2.0);
step(vec2(3840.0, 2160.0), 1.0, 1.0);
}
for (pref, expected) in [("200", 2.0), ("125", 1.25), ("150", 1.5), ("100", 1.0), ("95", 0.95), ("90", 0.9), ("85", 0.85), ("80", 0.8), ("auto", 1.5)] {
app.run("prefs.set", json!({"values": {"interface.uiScale": pref}})).unwrap();
+488
View File
@@ -0,0 +1,488 @@
//! Select and Mask view modes (#1754): how the live preview shows the refined selection.
//!
//! Photoshop's View Mode list: Onion Skin (O), Marching Ants (M), Overlay (V), On Black (A),
//! On White (T), Black & White (K) and On Layers (Y), with an Opacity for the first four that
//! veil the unselected area. F cycles the views, holding X shows the image without one, J toggles
//! Show Edge (the band the edge detection radius refines) and P Show Original (the selection
//! before refinement).
//!
//! The view state lives in the dialog's fields (`__view`, `__opacity`, `__showEdge`,
//! `__showOriginal`, `__hideView`), so `ui.dialog.set` drives it like any other field. The preview
//! runs `select.refineEdge` on the proxy document with `output: selection`, then builds the view
//! from that proxy (veil fill layers, a layer mask, the refined selection for the ants) and the
//! normal filter-preview path composites it.
use std::sync::Arc;
use photocraft_color::{Color, PixelFormat};
use photocraft_doc::{Document, Fill, Layer, LayerContent, LayerId, LayerMask};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::{Map, Value, json};
use crate::PhotocraftApp;
use crate::theme::Tokens;
/// The engine command behind Select › Select and Mask….
pub const COMMAND: &str = "select.refineEdge";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
OnionSkin,
MarchingAnts,
Overlay,
OnBlack,
OnWhite,
BlackWhite,
OnLayers,
}
impl Mode {
/// In menu order, which is also the order F cycles through.
pub const ALL: [Mode; 7] = [Mode::OnionSkin, Mode::MarchingAnts, Mode::Overlay, Mode::OnBlack, Mode::OnWhite, Mode::BlackWhite, Mode::OnLayers];
pub fn id(self) -> &'static str {
match self {
Mode::OnionSkin => "onionSkin",
Mode::MarchingAnts => "marchingAnts",
Mode::Overlay => "overlay",
Mode::OnBlack => "onBlack",
Mode::OnWhite => "onWhite",
Mode::BlackWhite => "blackWhite",
Mode::OnLayers => "onLayers",
}
}
pub fn from_id(id: &str) -> Option<Mode> {
Mode::ALL.into_iter().find(|m| m.id() == id)
}
fn label(self) -> &'static str {
match self {
Mode::OnionSkin => tl!("Onion Skin"),
Mode::MarchingAnts => tl!("Marching Ants"),
Mode::Overlay => tl!("Overlay"),
Mode::OnBlack => tl!("On Black"),
Mode::OnWhite => tl!("On White"),
Mode::BlackWhite => tl!("Black & White"),
Mode::OnLayers => tl!("On Layers"),
}
}
/// The single-letter key that picks this view.
fn key(self) -> egui::Key {
match self {
Mode::OnionSkin => egui::Key::O,
Mode::MarchingAnts => egui::Key::M,
Mode::Overlay => egui::Key::V,
Mode::OnBlack => egui::Key::A,
Mode::OnWhite => egui::Key::T,
Mode::BlackWhite => egui::Key::K,
Mode::OnLayers => egui::Key::Y,
}
}
/// The view's opacity (percent) until it is changed; `None` for views without one.
fn default_opacity(self) -> Option<f32> {
match self {
Mode::OnionSkin | Mode::Overlay => Some(50.0),
Mode::OnBlack | Mode::OnWhite => Some(100.0),
Mode::MarchingAnts | Mode::BlackWhite | Mode::OnLayers => None,
}
}
fn next(self) -> Mode {
let i = Mode::ALL.iter().position(|m| *m == self).unwrap_or(0);
Mode::ALL.get(i + 1).copied().unwrap_or(Mode::OnionSkin)
}
}
/// The view the dialog shows (Onion Skin, Photoshop's default, until another is picked).
pub fn mode(fields: &Map<String, Value>) -> Mode {
fields.get("__view").and_then(Value::as_str).and_then(Mode::from_id).unwrap_or(Mode::OnionSkin)
}
fn flag(fields: &Map<String, Value>, key: &str) -> bool {
fields.get(key).and_then(Value::as_bool).unwrap_or(false)
}
/// The opacity (percent) of `mode`: each view keeps its own, as in Photoshop.
fn opacity(fields: &Map<String, Value>, mode: Mode) -> f32 {
let set = fields.get("__opacity").and_then(|o| o.get(mode.id())).and_then(Value::as_f64).filter(|v| v.is_finite());
set.map(|v| v as f32).or(mode.default_opacity()).unwrap_or(100.0).clamp(0.0, 100.0)
}
fn set_opacity(fields: &mut Map<String, Value>, mode: Mode, value: f32) {
let mut all = fields.get("__opacity").and_then(Value::as_object).cloned().unwrap_or_default();
all.insert(mode.id().into(), json!(value));
fields.insert("__opacity".into(), Value::Object(all));
}
/// Holding X shows the image without the view.
pub fn hides_view(fields: &Map<String, Value>) -> bool {
flag(fields, "__hideView")
}
/// Whether `fields` belong to the Select and Mask dialog.
pub fn owns(fields: &Map<String, Value>) -> bool {
fields.get("__command").and_then(Value::as_str) == Some(COMMAND)
}
/// A view key pressed in the dialog: a view letter picks that view, F the next one, J toggles
/// Show Edge and P Show Original. Returns whether the key was one of them.
pub fn apply_key(fields: &mut Map<String, Value>, key: egui::Key) -> bool {
if key == egui::Key::F {
fields.insert("__view".into(), json!(mode(fields).next().id()));
} else if let Some(m) = Mode::ALL.into_iter().find(|m| m.key() == key) {
fields.insert("__view".into(), json!(m.id()));
} else if key == egui::Key::J || key == egui::Key::P {
let field = if key == egui::Key::J { "__showEdge" } else { "__showOriginal" };
fields.insert(field.into(), json!(!flag(fields, field)));
} else {
return false;
}
true
}
/// The view keys, unless a text field or a popup (the View dropdown) has the keyboard.
fn keys(ctx: &egui::Context, fields: &mut Map<String, Value>) {
if ctx.text_edit_focused() || egui::Popup::is_any_open(ctx) {
fields.insert("__hideView".into(), json!(false));
return;
}
let (pressed, hold_x) = ctx.input(|i| {
let plain = !i.modifiers.command && !i.modifiers.ctrl && !i.modifiers.alt;
let keys: Vec<egui::Key> = i
.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, repeat: false, .. } if plain => Some(*key),
_ => None,
})
.collect();
(keys, plain && i.key_down(egui::Key::X))
});
for key in pressed {
apply_key(fields, key);
}
fields.insert("__hideView".into(), json!(hold_x));
}
/// The View Mode controls at the top of the Select and Mask dialog.
pub fn controls(ui: &mut egui::Ui, fields: &mut Map<String, Value>) {
keys(ui.ctx(), fields);
let t = Tokens::get(ui.ctx());
let mut current = mode(fields);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("View")).color(t.text_dim));
let options: Vec<(Mode, &str)> = Mode::ALL.iter().map(|m| (*m, m.label())).collect();
crate::widgets::dropdown(ui, "select-mask-view", &mut current, &options, 170.0);
});
fields.insert("__view".into(), json!(current.id()));
if current.default_opacity().is_some() {
let mut v = opacity(fields, current);
crate::widgets::slider_row(ui, tl!("Opacity"), &mut v, 0.0..=100.0, "%", None);
set_opacity(fields, current, v.round());
}
for (field, label) in [("__showEdge", tl!("Show Edge")), ("__showOriginal", tl!("Show Original"))] {
let mut on = flag(fields, field);
crate::widgets::checkbox(ui, &mut on, label);
fields.insert(field.into(), json!(on));
}
ui.label(egui::RichText::new(tl!("F cycles the views; hold X to see the image")).color(t.text_dim).small());
ui.add_space(4.0);
crate::widgets::hairline(ui);
ui.add_space(6.0);
}
/// The preview request: the command's params plus the view (`__view`), so a view change
/// recomputes the preview like a parameter change.
pub fn request_params(fields: &Map<String, Value>, mut params: Value) -> Value {
let m = mode(fields);
if let Some(o) = params.as_object_mut() {
o.insert(
"__view".into(),
json!({
"mode": m.id(),
"opacity": opacity(fields, m) / 100.0,
"showEdge": flag(fields, "__showEdge"),
"showOriginal": flag(fields, "__showOriginal"),
}),
);
}
params
}
/// What the canvas outlines while Select and Mask previews `idx`: `None` = the document's own
/// selection (no Select and Mask preview); `Some(None)` = nothing (every view but Marching Ants
/// hides the ants, as in Photoshop); `Some(Some((proxy, k)))` = the refined selection of the
/// preview proxy, reduced by `k`.
pub(crate) fn ants(app: &PhotocraftApp, idx: usize) -> Option<Option<(Arc<Document>, u32)>> {
if app.session.active_index() != Some(idx) {
return None;
}
let d = app.ui.dialogs.iter().find(|d| d.fields.contains_key("__filter"))?;
if !owns(&d.fields) || !flag(&d.fields, "__preview") || hides_view(&d.fields) {
return None;
}
if mode(&d.fields) != Mode::MarchingAnts {
return Some(None);
}
let p = app.filter_preview.as_ref().filter(|p| p.key.command == COMMAND && p.key.dialog == d.id)?;
Some(Some((p.result.clone()?, p.key.k)))
}
/// A one-channel coverage over the canvas: `values` inside `area`, `outside` elsewhere.
struct Coverage {
area: Rect,
values: Vec<f32>,
outside: f32,
}
impl Coverage {
fn empty() -> Coverage {
Coverage { area: Rect::EMPTY, values: Vec::new(), outside: 0.0 }
}
/// `s` (a selection or a layer mask) read over the part of `canvas` that isn't its default.
fn read(s: Option<&Surface>, canvas: Rect) -> Coverage {
let Some(s) = s else { return Coverage::empty() };
let outside = s.default_pixel().first().copied().unwrap_or(0.0).clamp(0.0, 1.0);
let area = if outside == 0.0 { s.content_bounds().intersect(&canvas) } else { canvas };
Coverage { area, values: photocraft_algo::selection::mask_from_surface(Some(s), area), outside }
}
/// The band `r` px either side of the edge of `s`: where grown and shrunk coverage differ.
fn edge_band(s: Option<&Surface>, canvas: Rect, r: usize) -> Coverage {
let Some(s) = s else { return Coverage::empty() };
let reach = i32::try_from(r).unwrap_or(i32::MAX).saturating_add(1);
let outside = s.default_pixel().first().copied().unwrap_or(0.0);
let area = if outside == 0.0 { s.content_bounds().inflate(reach).intersect(&canvas) } else { canvas };
let (w, h) = (area.width() as usize, area.height() as usize);
let values = photocraft_algo::selection::mask_from_surface(Some(s), area);
let grown = photocraft_algo::matting::morph(&values, w, h, r, true);
let shrunk = photocraft_algo::matting::morph(&values, w, h, r, false);
if grown.len() != values.len() || shrunk.len() != values.len() {
return Coverage::empty();
}
Coverage { area, values: grown.iter().zip(&shrunk).map(|(g, s)| (g - s).clamp(0.0, 1.0)).collect(), outside: 0.0 }
}
/// A GRAY8 mask surface holding `f(coverage)`.
fn surface(&self, f: impl Fn(f32) -> f32) -> Surface {
let g = |v: f32| f(v).clamp(0.0, 1.0);
let mut s = Surface::with_default(PixelFormat::GRAY8, &[g(self.outside)]);
if !self.area.is_empty() && self.values.len() == self.area.width() as usize * self.area.height() as usize {
let bytes: Vec<u8> = self.values.iter().map(|v| (g(*v) * 255.0).round() as u8).collect();
s.write_interleaved(self.area, &bytes);
}
s
}
}
/// The view parameters a preview request carries (see [`request_params`]).
struct View {
mode: Mode,
opacity: f32,
show_edge: bool,
show_original: bool,
}
impl View {
fn of(params: &Value) -> View {
let v = params.get("__view");
let b = |key: &str| v.and_then(|v| v.get(key)).and_then(Value::as_bool).unwrap_or(false);
View {
mode: v.and_then(|v| v.get("mode")).and_then(Value::as_str).and_then(Mode::from_id).unwrap_or(Mode::OnionSkin),
opacity: v.and_then(|v| v.get("opacity")).and_then(Value::as_f64).filter(|o| o.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0) as f32,
show_edge: b("showEdge"),
show_original: b("showOriginal"),
}
}
}
/// A pixel length param (`radius`, `feather`) of `params`, reduced by `k`.
fn length(params: &Value, key: &str, k: u32) -> f64 {
params.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0) / f64::from(k.max(1))
}
/// The preview of the Select and Mask dialog for `doc`: the proxy reduced by `k`, shown in the
/// requested view. `None` when the refinement fails or is cancelled.
pub fn preview_document(doc: &Document, active: Option<LayerId>, params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Option<Document> {
let view = View::of(params);
let k = k.max(1);
let mut proxy = crate::proxy::proxy_document(doc, k);
let active = active.filter(|id| proxy.layer(*id).is_some());
// What Select and Mask starts from: the selection, else the active layer's mask.
let original = proxy.selection.clone().or_else(|| active.and_then(|id| proxy.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| m.surface.clone()));
let canvas = Rect::new(0, 0, i32::try_from(proxy.size.width).unwrap_or(i32::MAX), i32::try_from(proxy.size.height).unwrap_or(i32::MAX));
let coverage = if view.show_edge {
Coverage::edge_band(original.as_ref(), canvas, length(params, "radius", k).round().clamp(1.0, 250.0) as usize)
} else if view.show_original {
Coverage::read(original.as_ref(), canvas)
} else {
Coverage::read(refine(&proxy, active, params, k, cancel)?.as_ref(), canvas)
};
show(&mut proxy, active, &view, &coverage);
Some(proxy)
}
/// `select.refineEdge` on the proxy, its output forced to the selection: the refined coverage.
fn refine(proxy: &Document, active: Option<LayerId>, params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Option<Option<Surface>> {
let mut s = photocraft_engine::Session::new();
s.set_inline_job_ctx(cancel.cloned());
s.add_document(proxy.clone(), None);
if let Some(id) = active {
s.select_layer(id).ok()?;
}
let mut p = params.as_object().cloned().unwrap_or_default();
p.remove("__view");
p.insert("output".into(), json!("selection"));
p.insert("decontaminate".into(), json!(false));
for key in ["radius", "feather"] {
if p.contains_key(key) {
p.insert(key.into(), json!(length(params, key, k)));
}
}
s.execute(COMMAND, Value::Object(p)).ok()?;
Some(s.active()?.doc.selection.clone())
}
/// A solid fill layer named for the view.
fn veil(name: &str, color: Color, opacity: f32, mask: Option<Surface>) -> Layer {
let mut l = Layer::new(name, LayerContent::Fill(Fill::Solid(color)));
l.opacity = opacity;
l.mask = mask.map(|surface| LayerMask { surface, ..LayerMask::reveal_all() });
l
}
/// Turn `doc` into `view` of `coverage`.
fn show(doc: &mut Document, active: Option<LayerId>, view: &View, coverage: &Coverage) {
let o = view.opacity;
doc.selection = Some(coverage.surface(|v| v));
match view.mode {
Mode::MarchingAnts => {}
Mode::Overlay => doc.layers.push(veil("Overlay", Color::rgb(1.0, 0.0, 0.0), o, Some(coverage.surface(|v| 1.0 - v)))),
Mode::OnBlack => doc.layers.push(veil("On Black", Color::BLACK, o, Some(coverage.surface(|v| 1.0 - v)))),
Mode::OnWhite => doc.layers.push(veil("On White", Color::WHITE, o, Some(coverage.surface(|v| 1.0 - v)))),
Mode::BlackWhite => {
doc.layers.push(veil("Black", Color::BLACK, 1.0, None));
doc.layers.push(veil("White", Color::WHITE, 1.0, Some(coverage.surface(|v| v))));
}
Mode::OnLayers | Mode::OnionSkin => {
let Some(id) = active else { return };
if view.mode == Mode::OnionSkin
&& let Some(path) = doc.path_of(id)
{
// Only the layer being masked (with its groups and contents) stays visible.
let others: Vec<LayerId> =
doc.walk().into_iter().filter(|(p, _, _)| !path.starts_with(p) && !p.starts_with(&path)).map(|(_, _, l)| l.id).collect();
for other in others {
if let Some(l) = doc.layer_mut(other) {
l.visible = false;
}
}
}
let keep = if view.mode == Mode::OnionSkin { 1.0 - o } else { 0.0 };
let surface = coverage.surface(|v| v + (1.0 - v) * keep);
if let Some(l) = doc.layer_mut(id) {
l.mask = Some(LayerMask { surface, ..LayerMask::reveal_all() });
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::Size;
/// A white 40×20 document with its left half selected.
fn half_selected() -> Document {
let mut doc = Document::with_background("d", Size::new(40, 20), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(Rect::new(0, 0, 20, 20), &[1.0]);
doc.selection = Some(sel);
doc
}
fn view(mode: Mode) -> Value {
json!({"radius": 0, "__view": {"mode": mode.id(), "opacity": 0.5, "showEdge": false, "showOriginal": false}})
}
fn pixel(doc: &Document, x: i32, y: i32) -> Vec<f32> {
let s = photocraft_compose::flatten_to_surface(doc, PixelFormat::RGBA8, None);
s.pixel(x, y)
}
/// Every view shows the selected half as the image and marks the rest its own way.
#[test]
fn each_view_marks_the_unselected_area() {
let doc = half_selected();
let active = doc.layers.first().map(|l| l.id);
let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.02);
let cases = [
(Mode::Overlay, [1.0, 0.5, 0.5, 1.0]),
(Mode::OnBlack, [0.5, 0.5, 0.5, 1.0]),
(Mode::OnWhite, [1.0, 1.0, 1.0, 1.0]),
(Mode::BlackWhite, [0.0, 0.0, 0.0, 1.0]),
(Mode::OnLayers, [0.0, 0.0, 0.0, 0.0]),
(Mode::OnionSkin, [1.0, 1.0, 1.0, 0.5]),
(Mode::MarchingAnts, [1.0, 1.0, 1.0, 1.0]),
];
for (mode, unselected) in cases {
// Through the filter dialog's preview, which every Select and Mask preview takes.
let shown = crate::filter_dialog::preview_document(&doc, active, COMMAND, &view(mode), 1).expect("a preview");
let inside = pixel(&shown, 5, 10);
assert!(near(&inside, &[1.0, 1.0, 1.0, 1.0]), "{mode:?}: selected area {inside:?}");
let outside = pixel(&shown, 35, 10);
assert!(near(&outside, &unselected), "{mode:?}: unselected area {outside:?}, want {unselected:?}");
// The refined selection rides along for the canvas's marching ants.
assert!(shown.selection.as_ref().is_some_and(|s| s.pixel(5, 10)[0] > 0.99 && s.pixel(35, 10)[0] < 0.01), "{mode:?}");
}
}
#[test]
fn view_keys_pick_cycle_and_toggle() {
let mut f = Map::new();
assert_eq!(mode(&f), Mode::OnionSkin, "Photoshop's default view");
assert!(apply_key(&mut f, egui::Key::V));
assert_eq!(mode(&f), Mode::Overlay);
for want in [Mode::OnBlack, Mode::OnWhite, Mode::BlackWhite, Mode::OnLayers, Mode::OnionSkin, Mode::MarchingAnts] {
apply_key(&mut f, egui::Key::F);
assert_eq!(mode(&f), want, "F cycles");
}
apply_key(&mut f, egui::Key::J);
apply_key(&mut f, egui::Key::P);
assert!(flag(&f, "__showEdge") && flag(&f, "__showOriginal"));
apply_key(&mut f, egui::Key::J);
assert!(!flag(&f, "__showEdge"));
assert!(!apply_key(&mut f, egui::Key::Q), "other keys are left alone");
// Each view keeps its own opacity; hostile values fall back to the default.
set_opacity(&mut f, Mode::Overlay, 80.0);
assert_eq!((opacity(&f, Mode::Overlay), opacity(&f, Mode::OnBlack)), (80.0, 100.0));
f.insert("__opacity".into(), json!({"overlay": "x"}));
assert_eq!(opacity(&f, Mode::Overlay), 50.0);
// The view travels with the preview request, and a hostile one still previews.
let p = request_params(&f, json!({"radius": 2}));
assert_eq!(p["__view"]["mode"], json!("marchingAnts"));
let doc = half_selected();
let bad = json!({"radius": -5, "feather": "x", "__view": {"mode": 7, "opacity": "nan"}});
assert!(preview_document(&doc, None, &bad, 3, None).is_some());
}
/// Show Edge shows the band around the selection's edge; Show Original the unrefined selection.
#[test]
fn show_edge_and_show_original() {
let doc = half_selected();
let active = doc.layers.first().map(|l| l.id);
let edge = json!({"radius": 3, "smooth": 100, "__view": {"mode": "blackWhite", "opacity": 0.5, "showEdge": true}});
let shown = preview_document(&doc, active, &edge, 1, None).expect("a preview");
assert!(pixel(&shown, 20, 10)[0] > 0.99, "on the edge");
assert!(pixel(&shown, 5, 10)[0] < 0.01 && pixel(&shown, 35, 10)[0] < 0.01, "away from it");
let original = json!({"radius": 3, "shiftEdge": 100, "__view": {"mode": "blackWhite", "showOriginal": true}});
let shown = preview_document(&doc, active, &original, 1, None).expect("a preview");
assert!(pixel(&shown, 19, 10)[0] > 0.99 && pixel(&shown, 21, 10)[0] < 0.01, "the selection as it was");
}
}
+3 -2
View File
@@ -268,8 +268,9 @@ fn delete_key_command(app: &PhotocraftApp, id: String) -> String {
let colors_off = st.channel_view.visible_colors(photocraft_engine::channel_cmds::color_count(&st.doc)) == 0;
let alpha_on = (0..st.doc.channels.len()).any(|i| st.channel_view.alpha_shown(i));
let solo_mask = colors_off && has_mask && !alpha_on && st.doc.quick_mask.is_none();
let mask = (app.ui.mask_target || solo_mask) && has_mask;
if mask && composite {
let layer_mask = st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::LayerMask;
let mask = (app.ui.mask_target || solo_mask || layer_mask) && has_mask;
if mask && (composite || layer_mask) {
let cmd = if st.doc.selection.is_some() { "edit.fillBackground" } else { "layer.layerMask.delete" };
return cmd.into();
}
+5
View File
@@ -318,6 +318,11 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
if app.ui.stroke_editor.is_some() && !ctx.input(|i| i.modifiers.command || i.modifiers.ctrl) {
return;
}
// So does the right-click menu of text being edited: Esc closes only it, and keys typed while
// it is open don't reach the text beneath.
if app.ui.canvas_tool_menu.as_ref().is_some_and(|m| m.text) {
return;
}
let symmetry_editing = app.ui.symmetry_transform.is_some();
crate::symmetry_ui::track(app, ctx);
if symmetry_editing && (app.ui.symmetry_transform.is_none() || egui::Popup::is_any_open(ctx)) {
+12 -3
View File
@@ -120,6 +120,9 @@ pub enum Tool {
CurvaturePen,
AddAnchorPoint,
DeleteAnchorPoint,
/// Converts an anchor between smooth and corner, and pulls handles out of a corner
/// (Photoshop's Convert Point tool; also the Pen's ⌥ mode, `direct_select.rs`).
ConvertPoint,
PathSelection,
DirectSelection,
Rectangle,
@@ -134,7 +137,7 @@ pub enum Tool {
}
impl Tool {
pub const ALL: [Tool; 65] = [
pub const ALL: [Tool; 66] = [
Tool::Move,
Tool::Artboard,
Tool::RectMarquee,
@@ -189,6 +192,7 @@ impl Tool {
Tool::CurvaturePen,
Tool::AddAnchorPoint,
Tool::DeleteAnchorPoint,
Tool::ConvertPoint,
Tool::PathSelection,
Tool::DirectSelection,
Tool::Rectangle,
@@ -261,6 +265,7 @@ impl Tool {
Tool::CurvaturePen => "Curvature Pen Tool",
Tool::AddAnchorPoint => "Add Anchor Point Tool",
Tool::DeleteAnchorPoint => "Delete Anchor Point Tool",
Tool::ConvertPoint => "Convert Point Tool",
Tool::PathSelection => "Path Selection Tool",
Tool::DirectSelection => "Direct Selection Tool",
Tool::Rectangle => "Rectangle Tool",
@@ -332,7 +337,7 @@ impl Tool {
Tool::SingleRowMarquee | Tool::SingleColumnMarquee => '\0',
Tool::Dodge | Tool::Burn | Tool::Sponge => 'O',
Tool::QuickSelection | Tool::ObjectSelection => 'W',
Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint => 'P',
Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => 'P',
Tool::PathSelection | Tool::DirectSelection => 'A',
Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => 'U',
}
@@ -1300,11 +1305,15 @@ mod tests {
assert!(!Tool::RedEye.is_brushlike());
assert_eq!(Tool::from_name("patternStamp"), Some(Tool::PatternStamp));
assert_eq!(Tool::from_name("Pattern Stamp Tool"), Some(Tool::PatternStamp));
assert_eq!(Tool::ALL.len(), 65);
assert_eq!(Tool::ALL.len(), 66);
assert_eq!(Tool::from_name("Vertical Type Mask Tool"), Some(Tool::VerticalTypeMask));
assert_eq!(Tool::from_name("addAnchorPoint"), Some(Tool::AddAnchorPoint));
assert_eq!(Tool::from_name("Delete Anchor Point Tool"), Some(Tool::DeleteAnchorPoint));
assert_eq!(Tool::DeleteAnchorPoint.key(), 'P');
assert_eq!(Tool::from_name("convertPoint"), Some(Tool::ConvertPoint));
assert_eq!(Tool::from_name("Convert Point Tool"), Some(Tool::ConvertPoint));
assert_eq!(Tool::ConvertPoint.key(), 'P', "it shares the Pen's key");
assert!(!Tool::ConvertPoint.is_brushlike());
assert_eq!(Tool::from_name("Remove Tool"), Some(Tool::Remove));
assert_eq!(Tool::Remove.key(), 'J');
assert!(Tool::Remove.is_brushlike());
+1
View File
@@ -181,6 +181,7 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) {
| Tool::SliceSelect
| Tool::AddAnchorPoint
| Tool::DeleteAnchorPoint
| Tool::ConvertPoint
| Tool::Artboard
| Tool::Frame => {}
}
@@ -50,6 +50,7 @@ fn type_layers_offer_free_transform_but_not_distort_or_perspective() {
path_name: None,
transform: false,
pasteboard: false,
text: false,
};
assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.freeTransform"));
assert!(!crate::canvas_tool_menu::available(&app, &menu, "edit.transform.distort"));
+1 -1
View File
@@ -1395,7 +1395,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
/// Character and paragraph style at the target (selection start, else the layer's first run).
fn styles_at(app: &PhotocraftApp) -> Option<(photocraft_doc::text::CharStyle, photocraft_doc::text::ParagraphStyle)> {
pub(crate) fn styles_at(app: &PhotocraftApp) -> Option<(photocraft_doc::text::CharStyle, photocraft_doc::text::ParagraphStyle)> {
let (layer, range) = target(app)?;
let st = app.session.active()?;
let t = text_layer(&st.doc, LayerId(layer))?;
+2 -1
View File
@@ -462,7 +462,8 @@ enum PathTarget {
pub(crate) fn targeted_vector_mask(app: &PhotocraftApp) -> Option<(u64, Path)> {
let st = app.session.active()?;
let l = st.active_layer.and_then(|id| st.doc.layer(id))?;
(app.ui.vector_mask_target && !matches!(l.content, LayerContent::Shape(_))).then_some(())?;
let targeted = app.ui.vector_mask_target || st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::VectorMask;
(targeted && !matches!(l.content, LayerContent::Shape(_))).then_some(())?;
l.vector_mask.as_ref().map(|m| (l.id.0, m.path.clone()))
}
+2 -1
View File
@@ -70,7 +70,8 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View
if b.stopped {
ctx.data_mut(|d| d.remove::<ZoomDrag>(id()));
if let Some(to) = z.rect_to {
zoom_to_rect(view, xf, Rect::from_two_pos(z.anchor, to), z.anchor, ctx.input(|i| i.modifiers.alt), app.ppp);
let out = crate::workspace_ui::sticky_mods(app, ctx.input(|i| i.modifiers)).alt;
zoom_to_rect(view, xf, Rect::from_two_pos(z.anchor, to), z.anchor, out, app.ppp);
}
}
true
+1 -1
View File
@@ -27,7 +27,7 @@ The “PhotoCraft at branch point” column records behavior before this PR. Pic
| Ruler, Note, Count | Unit / measurement, note edit/delete, count-group operations **(inferred)** | No tool menu | P2 |
| Brush, Pencil, Eraser, Background Eraser, Spot Healing, Healing, Clone Stamp, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge | Brush preset, size and hardness where applicable | Picker | P0; [Adobe Brush Settings](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html). Alt+secondary-drag on Windows and Control+Option-drag on macOS adjust size/hardness ([Adobe cursor shortcuts](https://helpx.adobe.com/in/photoshop/desktop/get-started/settings-and-preferences/change-tool-pointers.html)) |
| Gradient, Paint Bucket | Gradient preset/type/mode or fill source/tolerance **(inferred context placement)** | No tool menu | P1 |
| Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | No tool menu | P1; separate editing and nonediting states; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) |
| Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Spelling suggestions for the word under the pointer and the non-editing type-layer menu remain open | P1; separate editing and nonediting states; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) |
| Hand, Zoom | Fit on Screen, 100%, 200%, Print Size; zoom step choices **(inferred context placement)** | No tool menu | P1; view actions stay out of document history |
| Pen, Path Selection | Path/point actions, Make Selection, Fill/Stroke Path, Free Transform Path **(inferred context placement)** | Pen canvas right-click follows the user-provided 20-row Photoshop capture below. Paths rows offer Make Selection, Fill and Stroke. Path Selection's own canvas menu remains open. | P1; distinguish node, segment, path, and blank canvas |
| Rectangle, Ellipse Shape, Triangle, Polygon, Line, Custom Shape | Shape/path operations, fill/stroke settings, transform **(inferred context placement)** | No tool menu | P2 |
+646
View File
@@ -0,0 +1,646 @@
{
"base_commit": "7722172585a01cbdb93c06f0f5ff2634fcb17999",
"binary_sha256": {
"before": "bb14e16ad044ecee5a6355e2393030e1a1a7c9f42eab774932790417291eac5b",
"after": "b30d377aba15113382d2f7d9d17415caaa59eb00558fdbef584f25da9063e050"
},
"workers": 16,
"runs_per_case": 3,
"cases": [
{
"case": "extrude",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
2.064125,
2.0476,
2.079877
],
"after_seconds": [
0.329602,
0.331145,
0.326371
],
"before_median_seconds": 2.064125,
"after_median_seconds": 0.329602,
"speedup": 6.262477169434652,
"output_sha256": [
"fd2d0948da70b40e73140752bb9a6c031a56b90b5cb45674c193e120fb3847e7",
"fd2d0948da70b40e73140752bb9a6c031a56b90b5cb45674c193e120fb3847e7",
"fd2d0948da70b40e73140752bb9a6c031a56b90b5cb45674c193e120fb3847e7"
]
},
{
"case": "extrude-dense",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
161.319687,
159.928418,
160.300969
],
"after_seconds": [
0.389538,
0.368512,
0.386554
],
"before_median_seconds": 160.300969,
"after_median_seconds": 0.386554,
"speedup": 414.69230430935914,
"output_sha256": [
"21cb6ba46bdcb57483d35215bd2c9d4037c869d1f707485744c47a96cb5e71ab",
"21cb6ba46bdcb57483d35215bd2c9d4037c869d1f707485744c47a96cb5e71ab",
"21cb6ba46bdcb57483d35215bd2c9d4037c869d1f707485744c47a96cb5e71ab"
]
},
{
"case": "extrude",
"width": 7200,
"height": 5000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
3.318431,
3.118615,
3.153926
],
"after_seconds": [
0.495448,
0.511174,
0.486668
],
"before_median_seconds": 3.153926,
"after_median_seconds": 0.495448,
"speedup": 6.3658063005603,
"output_sha256": [
"187fa5c5ce673080385953f2b19f0c210b3a21c620f10611904f2f9ac43835fb",
"187fa5c5ce673080385953f2b19f0c210b3a21c620f10611904f2f9ac43835fb",
"187fa5c5ce673080385953f2b19f0c210b3a21c620f10611904f2f9ac43835fb"
]
},
{
"case": "extrude",
"width": 6000,
"height": 4000,
"depth": "u16",
"mode": "rgb",
"before_seconds": [
2.145204,
2.153517,
2.151485
],
"after_seconds": [
0.400436,
0.394881,
0.405546
],
"before_median_seconds": 2.151485,
"after_median_seconds": 0.400436,
"speedup": 5.372856086865317,
"output_sha256": [
"c991f02f91ae4a1110cebac56c10799e5c40f52c0dbc386dc34a687c575fd47d",
"c991f02f91ae4a1110cebac56c10799e5c40f52c0dbc386dc34a687c575fd47d",
"c991f02f91ae4a1110cebac56c10799e5c40f52c0dbc386dc34a687c575fd47d"
]
},
{
"case": "extrude",
"width": 6000,
"height": 4000,
"depth": "f32",
"mode": "rgb",
"before_seconds": [
2.231126,
2.254061,
2.296168
],
"after_seconds": [
0.476943,
0.481711,
0.477403
],
"before_median_seconds": 2.254061,
"after_median_seconds": 0.477403,
"speedup": 4.721505729959803,
"output_sha256": [
"7495bee1ce54d961e780139010d4bb5604a379ab09d0bfc269eeade232dca27e",
"7495bee1ce54d961e780139010d4bb5604a379ab09d0bfc269eeade232dca27e",
"7495bee1ce54d961e780139010d4bb5604a379ab09d0bfc269eeade232dca27e"
]
},
{
"case": "blast",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
1.427601,
1.484093,
1.441316
],
"after_seconds": [
0.408616,
0.427326,
0.411401
],
"before_median_seconds": 1.441316,
"after_median_seconds": 0.411401,
"speedup": 3.503433389807025,
"output_sha256": [
"902cbdc3491e45d7a795371c5d9f2649f31d0466371ab407b921815c6b1c9512",
"902cbdc3491e45d7a795371c5d9f2649f31d0466371ab407b921815c6b1c9512",
"902cbdc3491e45d7a795371c5d9f2649f31d0466371ab407b921815c6b1c9512"
]
},
{
"case": "blast",
"width": 7200,
"height": 5000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
2.251381,
2.134731,
2.164608
],
"after_seconds": [
0.65199,
0.616526,
0.632713
],
"before_median_seconds": 2.164608,
"after_median_seconds": 0.632713,
"speedup": 3.421153034630235,
"output_sha256": [
"2bbc9893dfdb35bc6cf88050c898c6fcb9280de4bd0d48a4c949489bb8c4d9ff",
"2bbc9893dfdb35bc6cf88050c898c6fcb9280de4bd0d48a4c949489bb8c4d9ff",
"2bbc9893dfdb35bc6cf88050c898c6fcb9280de4bd0d48a4c949489bb8c4d9ff"
]
},
{
"case": "blast",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "cmyk",
"before_seconds": [
6.125603,
6.230961,
6.299123
],
"after_seconds": [
0.708941,
0.709191,
0.742445
],
"before_median_seconds": 6.230961,
"after_median_seconds": 0.709191,
"speedup": 8.786012512849148,
"output_sha256": [
"57fbc0141bbc01343ea160d77fe00bc177ca1821276c1eb03d466f6451f7f1c0",
"57fbc0141bbc01343ea160d77fe00bc177ca1821276c1eb03d466f6451f7f1c0",
"57fbc0141bbc01343ea160d77fe00bc177ca1821276c1eb03d466f6451f7f1c0"
]
},
{
"case": "blast-right",
"width": 6000,
"height": 4000,
"depth": "u16",
"mode": "rgb",
"before_seconds": [
1.502531,
1.515667,
1.504332
],
"after_seconds": [
0.483179,
0.48207,
0.480633
],
"before_median_seconds": 1.504332,
"after_median_seconds": 0.48207,
"speedup": 3.120567552430145,
"output_sha256": [
"b0a197677513fae949ad37bfa1fdeb868b2bd8b49b6ebcaefe545d6138cc1065",
"b0a197677513fae949ad37bfa1fdeb868b2bd8b49b6ebcaefe545d6138cc1065",
"b0a197677513fae949ad37bfa1fdeb868b2bd8b49b6ebcaefe545d6138cc1065"
]
},
{
"case": "wind",
"width": 6000,
"height": 4000,
"depth": "f32",
"mode": "rgb",
"before_seconds": [
0.766914,
0.76776,
0.773241
],
"after_seconds": [
0.482883,
0.486564,
0.506624
],
"before_median_seconds": 0.76776,
"after_median_seconds": 0.486564,
"speedup": 1.5779219177744346,
"output_sha256": [
"c08e7ab142a84d8e2bdeecdcb22bb58d0bad159c89075a6ba65679aa9d0164a2",
"c08e7ab142a84d8e2bdeecdcb22bb58d0bad159c89075a6ba65679aa9d0164a2",
"c08e7ab142a84d8e2bdeecdcb22bb58d0bad159c89075a6ba65679aa9d0164a2"
]
},
{
"case": "wind",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "cmyk",
"before_seconds": [
2.627367,
2.582852,
2.524798
],
"after_seconds": [
0.693205,
0.656818,
0.627828
],
"before_median_seconds": 2.582852,
"after_median_seconds": 0.656818,
"speedup": 3.932370915535201,
"output_sha256": [
"a46653d72bbf346a7595a66486a79882f899b160cdb57ea797e2bdb0fe9ae6e1",
"a46653d72bbf346a7595a66486a79882f899b160cdb57ea797e2bdb0fe9ae6e1",
"a46653d72bbf346a7595a66486a79882f899b160cdb57ea797e2bdb0fe9ae6e1"
]
},
{
"case": "spin",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
6.250012,
6.330716,
6.585078
],
"after_seconds": [
3.967455,
3.909264,
3.891597
],
"before_median_seconds": 6.330716,
"after_median_seconds": 3.909264,
"speedup": 1.619413782236247,
"output_sha256": [
"5d372066a16f19e0c2caeffe80fb020f372c0da8a2bea9075ff2b4b51b2c037e",
"5d372066a16f19e0c2caeffe80fb020f372c0da8a2bea9075ff2b4b51b2c037e",
"5d372066a16f19e0c2caeffe80fb020f372c0da8a2bea9075ff2b4b51b2c037e"
]
},
{
"case": "spin-default",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
1.596144,
1.590946,
1.63901
],
"after_seconds": [
1.132313,
1.112719,
1.120303
],
"before_median_seconds": 1.596144,
"after_median_seconds": 1.120303,
"speedup": 1.4247431275288918,
"output_sha256": [
"858c74f4adbf59f7cbcd23341f4728bada0bbfb682a387e976c4df5f99b7cbfb",
"858c74f4adbf59f7cbcd23341f4728bada0bbfb682a387e976c4df5f99b7cbfb",
"858c74f4adbf59f7cbcd23341f4728bada0bbfb682a387e976c4df5f99b7cbfb"
]
},
{
"case": "spin",
"width": 7200,
"height": 5000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
9.433136,
9.43879,
9.453744
],
"after_seconds": [
5.895634,
5.867902,
5.86936
],
"before_median_seconds": 9.43879,
"after_median_seconds": 5.86936,
"speedup": 1.608146373710251,
"output_sha256": [
"551be3b93c55a209c08e0b044a1c49ce05ebeed7c622dea1a375970db3c92c5f",
"551be3b93c55a209c08e0b044a1c49ce05ebeed7c622dea1a375970db3c92c5f",
"551be3b93c55a209c08e0b044a1c49ce05ebeed7c622dea1a375970db3c92c5f"
]
},
{
"case": "spin",
"width": 6000,
"height": 4000,
"depth": "u16",
"mode": "rgb",
"before_seconds": [
6.27807,
6.306617,
6.322252
],
"after_seconds": [
3.954162,
3.961668,
3.989054
],
"before_median_seconds": 6.306617,
"after_median_seconds": 3.961668,
"speedup": 1.5919095189198085,
"output_sha256": [
"b58cd212eab11c55667d38725deef4e12195ff2186e6f40afdb9b0120d19e092",
"b58cd212eab11c55667d38725deef4e12195ff2186e6f40afdb9b0120d19e092",
"b58cd212eab11c55667d38725deef4e12195ff2186e6f40afdb9b0120d19e092"
]
},
{
"case": "spin",
"width": 6000,
"height": 4000,
"depth": "f32",
"mode": "rgb",
"before_seconds": [
6.445198,
6.374566,
6.3749
],
"after_seconds": [
4.061227,
4.061895,
4.086393
],
"before_median_seconds": 6.3749,
"after_median_seconds": 4.061895,
"speedup": 1.569439879662079,
"output_sha256": [
"c05682c67fade43cd7f05a9d7cb624cad6ea17df100c766df9b65707296459d9",
"c05682c67fade43cd7f05a9d7cb624cad6ea17df100c766df9b65707296459d9",
"c05682c67fade43cd7f05a9d7cb624cad6ea17df100c766df9b65707296459d9"
]
},
{
"case": "lens",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
0.617702,
0.6242,
0.620756
],
"after_seconds": [
0.446379,
0.444779,
0.444774
],
"before_median_seconds": 0.620756,
"after_median_seconds": 0.444779,
"speedup": 1.3956504241432262,
"output_sha256": [
"dde9489970cce66f360d9ad48af37ad4e27070cf84f28b5d3e9a9880ad753a6c",
"dde9489970cce66f360d9ad48af37ad4e27070cf84f28b5d3e9a9880ad753a6c",
"dde9489970cce66f360d9ad48af37ad4e27070cf84f28b5d3e9a9880ad753a6c"
]
},
{
"case": "lens",
"width": 6000,
"height": 4000,
"depth": "u16",
"mode": "cmyk",
"before_seconds": [
0.806734,
0.812071,
0.807998
],
"after_seconds": [
0.564873,
0.567843,
0.562198
],
"before_median_seconds": 0.807998,
"after_median_seconds": 0.564873,
"speedup": 1.4304064807487702,
"output_sha256": [
"8d7236617b986dd3b6aa8873b6ce2d79694ea11d330e952a7990a4886ef72c01",
"8d7236617b986dd3b6aa8873b6ce2d79694ea11d330e952a7990a4886ef72c01",
"8d7236617b986dd3b6aa8873b6ce2d79694ea11d330e952a7990a4886ef72c01"
]
},
{
"case": "lens",
"width": 6000,
"height": 4000,
"depth": "f32",
"mode": "rgb",
"before_seconds": [
0.681554,
0.695372,
0.684666
],
"after_seconds": [
0.511379,
0.510318,
0.508948
],
"before_median_seconds": 0.684666,
"after_median_seconds": 0.510318,
"speedup": 1.341645797326373,
"output_sha256": [
"d78838902900d89a95811fd22e2bbcc7487d0d7d8442bde36e896579d62bbebb",
"d78838902900d89a95811fd22e2bbcc7487d0d7d8442bde36e896579d62bbebb",
"d78838902900d89a95811fd22e2bbcc7487d0d7d8442bde36e896579d62bbebb"
]
},
{
"case": "lens-ca",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
0.618384,
0.621923,
0.621404
],
"after_seconds": [
0.62899,
0.646063,
0.631199
],
"before_median_seconds": 0.621404,
"after_median_seconds": 0.631199,
"speedup": 0.9844819145784451,
"output_sha256": [
"537a709a3f841fe8938f977fc47b896d342771df27dc386d78f151b6c24e0926",
"537a709a3f841fe8938f977fc47b896d342771df27dc386d78f151b6c24e0926",
"537a709a3f841fe8938f977fc47b896d342771df27dc386d78f151b6c24e0926"
]
},
{
"case": "trap",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "cmyk",
"before_seconds": [
0.970982,
0.968794,
0.97437
],
"after_seconds": [
0.808246,
0.80402,
0.834011
],
"before_median_seconds": 0.970982,
"after_median_seconds": 0.808246,
"speedup": 1.201344640121943,
"output_sha256": [
"20e01beda3d74c49b6000376cdf246ab4546731d03acdf2d3352386abf05c8ea",
"20e01beda3d74c49b6000376cdf246ab4546731d03acdf2d3352386abf05c8ea",
"20e01beda3d74c49b6000376cdf246ab4546731d03acdf2d3352386abf05c8ea"
]
},
{
"case": "trap-wide",
"width": 7200,
"height": 5000,
"depth": "u16",
"mode": "cmyk",
"before_seconds": [
4.641605,
4.626547,
4.62479
],
"after_seconds": [
1.333131,
1.323794,
1.337324
],
"before_median_seconds": 4.626547,
"after_median_seconds": 1.333131,
"speedup": 3.470436888797875,
"output_sha256": [
"836270556943a6a9a30e1fc0e2d862d7cf39e31e1899526b73269e480460f074",
"836270556943a6a9a30e1fc0e2d862d7cf39e31e1899526b73269e480460f074",
"836270556943a6a9a30e1fc0e2d862d7cf39e31e1899526b73269e480460f074"
]
},
{
"case": "trap",
"width": 6000,
"height": 4000,
"depth": "f32",
"mode": "cmyk",
"before_seconds": [
1.192386,
1.177692,
1.203718
],
"after_seconds": [
1.027262,
1.023985,
1.043099
],
"before_median_seconds": 1.192386,
"after_median_seconds": 1.027262,
"speedup": 1.1607418555344207,
"output_sha256": [
"33a7e49036067ef4d38ee9a24c4e6c1c9b22490d587d18e08ff6721e45732361",
"33a7e49036067ef4d38ee9a24c4e6c1c9b22490d587d18e08ff6721e45732361",
"33a7e49036067ef4d38ee9a24c4e6c1c9b22490d587d18e08ff6721e45732361"
]
},
{
"case": "trap-default",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "cmyk",
"before_seconds": [
0.637037,
0.636293,
0.632893
],
"after_seconds": [
0.650605,
0.64445,
0.652568
],
"before_median_seconds": 0.636293,
"after_median_seconds": 0.650605,
"speedup": 0.9780020135105018,
"output_sha256": [
"eb3c498a1511f6a57de1ea50a7b7e2b64af537a42fd82c670ad7920d5c6b8dd8",
"eb3c498a1511f6a57de1ea50a7b7e2b64af537a42fd82c670ad7920d5c6b8dd8",
"eb3c498a1511f6a57de1ea50a7b7e2b64af537a42fd82c670ad7920d5c6b8dd8"
]
}
],
"measured_at": "2026-10-10",
"after_commit": "849b8ff4eec0fa212b67f9b3c93a18dc792cd53c",
"instance": "AWS m7i.4xlarge",
"cpu": "Intel Xeon Platinum 8488C",
"ram_gib": 61,
"os": "Ubuntu 24.04.5",
"rustc": "1.99.0 (b940084d7 2026-09-28)",
"pair_orders": [
[
"before",
"after"
],
[
"after",
"before"
],
[
"before",
"after"
]
],
"exact_output_pairs": 72,
"equivalent_after_commit": "8782e1c0997fcc340e0463c6f9279855ad1819f5",
"rebased_onto": "3e000bc1d5f946fdba8c325532cb7e976f8f0538",
"unchanged_build_inputs_after_rebase": [
"crates/algo",
"crates/raster",
"crates/color",
"crates/geom",
"crates/cms",
"Cargo.toml",
"Cargo.lock",
".cargo"
],
"algo_tree": "65e87dd4d19d5aa8f715ca4ad734ddfe3cb82047"
}
+152
View File
@@ -0,0 +1,152 @@
# Five imaging algorithms: measured CPU performance
Measured on 2026-10-10. These changes remove repeated work from Extrude Blocks,
Wind/Blast, Spin Blur, Lens Correction and prepress Trap. They preserve the existing
output and parameter behavior. Measurements compare the full CPU operation against
`7722172585a01cbdb93c06f0f5ff2634fcb17999`, not just the inner kernel.
## What changed
- **Extrude Blocks:** visit each cell once, calculate its color and height once, then
rasterize its swept bounds. Keep the original candidate-range checks, intersection
tests and cell tie order. The default 30 px blocks benefit too, not only dense blocks.
- **Wind/Blast:** scatter each actual source streak once. Reuse source luminance and
hash work, retain the nearest-source tie rule, then blend once per output pixel.
- **Spin Blur:** prepare all 2..64 sample-count rotation tables once per pin. Fixed
channel samplers keep bilinear accumulators in registers without changing tap or
premultiplied-alpha arithmetic order.
- **Lens Correction:** share Catmull-Rom coordinates and weights across channels
sampling the same point, using fixed channel accumulators. RGB chromatic-aberration
offsets still use independent samples.
- **Trap:** replace wide-window ink scans with separable prefix/suffix block maxima,
independent of radius. Transpose the intermediate output for contiguous column
writes. Widths 1 and 2 retain the original path.
## Method
AWS m7i.4xlarge, 16 vCPUs, Intel Xeon Platinum 8488C, 61 GiB RAM, Ubuntu 24.04.5.
Rust `1.99.0 (b940084d7 2026-09-28)`, release profile, `RAYON_NUM_THREADS=16`.
Builds used independent target directories. No build or test ran during timing.
The final rebase adds upstream canvas and automation changes; the timed algorithm
source, fixture, dependencies and Cargo configuration are unchanged. The raw results
record both the measured revision and its equivalent rebased revision.
The identical committed `bench_algorithms` example runs at both revisions. It creates
native RGB or CMYK tiles at 8-bit, 16-bit or 32-bit float depth, with deterministic
65536-level busy texture, variable alpha and roughly 20% zero-alpha pixels. Bounds begin at
(-17, -29). A 128 x 128 operation warms each process before timing.
Each case has three independent before/after pairs, alternating process order:
before/after, after/before, before/after. The table reports the median of each set
of three, with speedup computed from unrounded medians. Fixture construction,
warmup, output serialization and result destruction are outside the timer.
For filters, the timer includes `apply_in`, tile reads, processing, writeback and
pruning. Lens times the full `lens::correct` call. Trap includes surface reads,
ink dilation, clone, writeback and pruning. Document flattening, undo bookkeeping
and UI rendering are outside these CPU measurements.
Every pair serializes every output sample as its normalized f32 bits in tile order.
All 72 before/after SHA-256 comparisons match exactly, including alpha. The
[raw results](five-algorithm-performance.json) contain all times, medians and hashes.
Extrude uses random-depth Blocks, seed 17, size/depth 30/30 px, or 8/128 px for
`extrude-dense`; solid-front, level-based and incomplete-cell masking are off.
Wind/Blast use seed 17; `blast-right` changes direction. `spin` uses one full-frame
elliptical pin, 30 degree blur and 17 degree orientation; `spin-default` uses the
unchanged default pin. Lens uses distortion 12, rotation 2.5 degrees and vignette 20;
`lens-ca` also enables red/cyan 12 and blue/yellow -8. Trap widths are 10, 50
(`trap-wide`) and 1 (`trap-default`) pixels.
## Full-operation results
| Case | Dimensions | Mode/depth | Before (s) | After (s) | Speedup |
|---|---|---|---:|---:|---:|
| `extrude` | 6000 x 4000 | RGB/u8 | 2.0641 | 0.3296 | 6.26x |
| `extrude-dense` | 6000 x 4000 | RGB/u8 | 160.3010 | 0.3866 | 414.69x |
| `extrude` | 7200 x 5000 | RGB/u8 | 3.1539 | 0.4954 | 6.37x |
| `extrude` | 6000 x 4000 | RGB/u16 | 2.1515 | 0.4004 | 5.37x |
| `extrude` | 6000 x 4000 | RGB/f32 | 2.2541 | 0.4774 | 4.72x |
| `blast` | 6000 x 4000 | RGB/u8 | 1.4413 | 0.4114 | 3.50x |
| `blast` | 7200 x 5000 | RGB/u8 | 2.1646 | 0.6327 | 3.42x |
| `blast` | 6000 x 4000 | CMYK/u8 | 6.2310 | 0.7092 | 8.79x |
| `blast-right` | 6000 x 4000 | RGB/u16 | 1.5043 | 0.4821 | 3.12x |
| `wind` | 6000 x 4000 | RGB/f32 | 0.7678 | 0.4866 | 1.58x |
| `wind` | 6000 x 4000 | CMYK/u8 | 2.5829 | 0.6568 | 3.93x |
| `spin` | 6000 x 4000 | RGB/u8 | 6.3307 | 3.9093 | 1.62x |
| `spin-default` | 6000 x 4000 | RGB/u8 | 1.5961 | 1.1203 | 1.42x |
| `spin` | 7200 x 5000 | RGB/u8 | 9.4388 | 5.8694 | 1.61x |
| `spin` | 6000 x 4000 | RGB/u16 | 6.3066 | 3.9617 | 1.59x |
| `spin` | 6000 x 4000 | RGB/f32 | 6.3749 | 4.0619 | 1.57x |
| `lens` | 6000 x 4000 | RGB/u8 | 0.6208 | 0.4448 | 1.40x |
| `lens` | 6000 x 4000 | CMYK/u16 | 0.8080 | 0.5649 | 1.43x |
| `lens` | 6000 x 4000 | RGB/f32 | 0.6847 | 0.5103 | 1.34x |
| `lens-ca` | 6000 x 4000 | RGB/u8 | 0.6214 | 0.6312 | 0.98x |
| `trap` | 6000 x 4000 | CMYK/u8 | 0.9710 | 0.8082 | 1.20x |
| `trap-wide` | 7200 x 5000 | CMYK/u16 | 4.6265 | 1.3331 | 3.47x |
| `trap` | 6000 x 4000 | CMYK/f32 | 1.1924 | 1.0273 | 1.16x |
| `trap-default` | 6000 x 4000 | CMYK/u8 | 0.6363 | 0.6506 | 0.98x |
## Correctness and bounded work
407 algorithm tests passed, with 14 existing ignored tests. New direct-reference
comparisons cover all eight color models, alpha on/off, all three depths, edge
modes, unusual tile sizes and soft selections where applicable. They also exercise
ties, partial cells, extreme coordinates, nonfinite samples and fallback paths.
Spin rotation tables match the original trigonometric results bit for bit.
Trap tests include clipped windows, paper preservation, extra channels and signed zero.
Extrude caps its additional winner plane at 4,194,304 pixels (16 MiB), cell visits
at 262,144 and coordinates at +/-2^22; normal tiled execution fits these bounds.
Wind caps winner scratch at 1,048,576 entries per row (8 MiB). Spin tables are
under 2 MiB for up to 120 pins. These scratch allocations are fallible and use the
original direct implementation if preparation fails or bounds are exceeded.
Extrude Pyramids and Wind Stagger keep their original implementations. Trap's new
per-line suffix buffer is fallible, with a direct fallback; it adds no full-image
plane beyond the existing dilation workspace. Lines containing negative zero use
the original scan to retain float bit patterns.
`cargo fmt --all -- --check`, algorithm clippy with all targets and denied warnings,
`cargo xtask layers`, full `cargo xtask wasm`, `cargo xtask perf --quick`, and
scorecard generation/check passed. The quick performance run used llvmpipe and
still reports existing over-budget UI scenarios. These operation measurements do
not change those budget claims or establish Photoshop pixel parity.
## Reproduce
From the PR checkout, create a base worktree and copy only the benchmark fixture:
```sh
base=7722172585a01cbdb93c06f0f5ff2634fcb17999
git worktree add --detach ../photocraft-five-base "$base"
cp crates/algo/examples/bench_algorithms.rs ../photocraft-five-base/crates/algo/examples/
CARGO_TARGET_DIR="$PWD/target/five-after" cargo build --release -p photocraft-algo --example bench_algorithms
(cd ../photocraft-five-base && CARGO_TARGET_DIR="$PWD/target/five-before" cargo build --release -p photocraft-algo --example bench_algorithms)
before="$PWD/../photocraft-five-base/target/five-before/release/examples/bench_algorithms"
after="$PWD/target/five-after/release/examples/bench_algorithms"
export RAYON_NUM_THREADS=16
# Each invocation prints its full-operation time. Keep the machine otherwise idle.
# Use the case names, dimensions, depth and mode from the table.
case=extrude-dense
w=6000
h=4000
depth=u8
mode=rgb
for pair in 1 2 3; do
if [ "$pair" = 2 ]; then
"$after" "$case" "$w" "$h" "$depth" 1 after.bin "$mode"
"$before" "$case" "$w" "$h" "$depth" 1 before.bin "$mode"
else
"$before" "$case" "$w" "$h" "$depth" 1 before.bin "$mode"
"$after" "$case" "$w" "$h" "$depth" 1 after.bin "$mode"
fi
cmp before.bin after.bin || exit 1
sha256sum before.bin after.bin
done
```
Take the median of the three times for each binary. Output files can reach 720 MB
per side for 36 MP CMYK+alpha; reuse the two filenames between cases. Hardware,
worker count and image content affect the times, so preserve the fixture when
comparing revisions.
+4 -4
View File
@@ -54,8 +54,8 @@ what an agent can't finish alone.
scripted check there) is the only real oracle.
### G6. Missing tools
- **Missing (6 of 68):** Color Sampler (#1046), Color Replacement (engine command exists), Art
History Brush, Add Anchor Point, Delete Anchor Point, Convert Point (only as Pen + ⌥). Freeform
- **Missing (5 of 68):** Color Sampler (#1046), Color Replacement (engine command exists), Art
History Brush, Add Anchor Point, Delete Anchor Point. Convert Point landed (#3204). Freeform
Pen and Curvature Pen landed (#1054); Horizontal and Vertical Type Mask landed (#1055,
`type.createSelection`); Perspective Crop landed (#1043, `image.perspectiveCrop`); Frame landed
(#2914).
@@ -65,8 +65,8 @@ what an agent can't finish alone.
- **Estimate:** 60–100 h (2–9 h each, per the calibration).
### G7. Workspaces shown as parameter dialogs
- **Missing:** Select and Mask workspace with view modes, Refine Edge brush and outputs
(TOOL-214-1, Refine Hair #2671); Content-Aware Fill workspace with a paintable sampling area
- **Missing:** Select and Mask workspace layout, Refine Edge brush and outputs
(TOOL-214-1, Refine Hair #2671; its view modes preview live since #1754); Content-Aware Fill workspace with a paintable sampling area
(TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Scale through the
transform box (TOOL-214-7); Puppet Warp mesh following the outline (TOOL-214-6); Content-Aware
Move's Transform On Drop (TOOL-213-4).
+67
View File
@@ -0,0 +1,67 @@
# Spanish terminology
This note goes with the review of the Spanish catalog
(`crates/ui-egui/src/i18n/es.tsv`). The catalog already covered every measured key; the review
aligned its wording with the terminology a Spanish-speaking Photoshop user already knows, fixed
mistranslations, and made the register consistent. No proprietary translation resources, fonts,
screenshots or user documents are included: the terms are the ones a Spanish Photoshop user sees
in the product and its public help, and each was checked against the meaning of PhotoCraft's own
English label.
## Register and style
- **Formal "usted"**, as in Photoshop's Spanish UI: «Haga clic», «Seleccione», «¿Desea guardar…?».
The catalog mixed «usted» and «tú»; every user-facing instruction now uses «usted».
- **Sentence case** after the first word, except panel and tool names, which keep their capital
as in Photoshop: «Panel Carácter», «Herramienta Pluma».
- Quotation marks are «angulares»: «{name}».
- Neutral Spanish, matching Adobe's single Spanish catalog (for example «coma flotante»).
## Terminology
| English source | Spanish UI | Was |
|---|---|---|
| Artboard | Mesa de trabajo | Lienzo (clashed with *Canvas*) |
| Pattern (fill, overlay, stamp) | Motivo | Patrón |
| Preset | Ajuste preestablecido | Preajuste |
| Keyboard Shortcuts | Métodos abreviados de teclado | Atajos de teclado |
| Grid | Cuadrícula | Rejilla |
| Slice | Sector | Sección |
| Plug-ins | Plugins | Complementos |
| Scratch Disks | Discos de memoria virtual | Discos temporales |
| Puppet Warp | Deformación de posición libre | Deformación de clavos |
| Pin (Puppet Warp) | Chincheta | Pin |
| Dodge / Burn (tools) | Sobreexponer / Subexponer | **swapped** |
| Color Dodge / Color Burn | Sobreexposición / Subexposición de color | **swapped** |
| Linear Dodge (Add) / Linear Burn | Sobreexposición lineal (añadir) / Subexposición lineal | |
| Hard Light / Pin Light / Hard Mix | Luz fuerte / Luz focal / Mezcla definida | Luz dura / Luz puntual / Mezcla dura |
| Pass Through (group) | Pasar a través | Herencia |
| Clone Stamp / Pattern Stamp | Tampón de clonar / Tampón de motivo | Sello… |
| Feather | Calar | Desvanecer (clashed with *Fade*) |
| Fade | Transición | Desvanecer |
| Color Range | Gama de colores | Intervalo de colores |
| Select and Mask | Seleccionar y aplicar máscara | Selección y máscara |
| Selective Color | Corrección selectiva | Color selectivo |
| Shadows/Highlights; Highlights | Sombras/iluminaciones; Iluminaciones | Sombras/luces; Luces |
| Color Lookup | Consulta de colores | Búsqueda de color |
| Content-Aware Fill / Scale / Move | Relleno / Escala según el contenido; Mover con detección de contenido | Relleno de contenido… |
| Adaptive Wide Angle | Gran angular adaptable | Ángulo ancho adaptativo |
| Constrain proportions | Restringir proporciones | **Construir** proporciones |
| Render (filter menu) | Interpretar | Renderizar |
| Smart Sharpen | Enfoque suavizado | Enfoque inteligente |
| Blend If | Fusionar si | Combinar si |
| Knockout (blending options) | Extraer | Cobertura |
| Lights / Darks (Camera Raw) | Claros / Oscuros | Tonos claros / Tonos oscuros |
| Master (Hue/Saturation) | Todos | General |
Menu names that are also actions keep Photoshop's distinction: **Vista** (the *View* menu, not
«Ver»), **Volver** (*Revert*), **Borrar** (*Clear*), **Cortar** (*Trim*, unlike *Crop* «Recortar»),
**Descubrir** (*Reveal*), **Purgar** (*Purge*). Product and technology names (Photomerge, Camera
Raw, droplet, OpenType, sRGB, Wayland) are not translated.
## Out of scope
Engine error, status and parameter-help messages are still English in every language (see
[`localization-parity.md`](localization-parity.md)); this review does not change that. The review
was AI-assisted, row by row against Photoshop's Spanish terminology; a review by a native
speaker is still welcome.
+1 -1
View File
@@ -3,7 +3,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (right-to-left shell layout) · **Target:** Adobe Photoshop 2026
Per-language status of PhotoCraft's UI. How catalogs work and how to add one:
[`localization.md`](localization.md). Language notes: [Korean](localization-ko.md),
[`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md),
[Simplified Chinese](localization-zh-hans.md).
**How measured.** `cargo xtask i18n-coverage` (`xtask/src/i18n_coverage.rs`) defines the key
+2 -2
View File
@@ -145,7 +145,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | done | [#213](https://github.com/storytold/photocraft/issues/213) | Seam, hard-edge and zero-scatter tests in brush_tests.rs; ABR tip colour test in abr_map/tests.rs |
| TOOL-213-14 | Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS |
| TOOL-213-18 | Pattern Stamp tool: sibling of Clone Stamp in the S flyout | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::PatternStamp in the S group; paint.patternStamp stamps the Patterns-panel tile with Aligned and Impressionist (an approximation: it jitters each dab's tile phase, not Photoshop's smeared impressionist look); engine tests cover depths, seams, hostile input |
| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Opens the generic select.refineEdge dialog (menus.rs:308) |
| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); no workspace layout or Refine Edge brush |
| TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs |
| TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Fuzziness-0 tests cover midtones/shadows only (selection_cmds.rs:444-447) |
| TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; double-click fit and drag to a tab open |
@@ -190,7 +190,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w
| UI-217-6 | Document tabs: overflow list, context menu (Close/Others/All/Reveal), drag to reorder | done | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs: Close/Others/All menu with unsaved prompt; Reveal reuses file.revealInFinder (engine layer_menu_cmds::reveal); the » chevron (tab_strip) lists overflow; dragging a tab reorders via document.move |
| UI-217-7 | Command palette shows recently run commands on an empty query | done | [#217](https://github.com/storytold/photocraft/issues/217) | palette.rs: note_recent from menus::invoke keeps the last 8 commands (dedup, newest first); an empty query lists them via recent_hits; the_palette_records_and_replays_recent_commands_on_an_empty_query covers record -> empty query -> replay |
| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; spelling suggestions (edit.checkSpelling suggest) not in the text-field menu yet |
| UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale and only jumps to 200 percent on an unscaled 4K display, #1072/#225) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting |
| UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting |
| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No such prefs; start screen (canvas.rs:825) has no recents |
| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has twelve themes (the four Pro brightness levels, Studio and Studio Light, Classic, Adwaita and Adwaita Dark, Solarized Dark, Breeze Light and Breeze Dark); no Soft dark or Soft light, and no outlined knobs in the light themes |
| UI-217-12 | Save custom New Document presets; a real Recent category | partial | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent remains a static list plus Clipboard |
+13 -1
View File
@@ -61,7 +61,7 @@ architecture and the PSD writer are the serial parts.
| Component | Weight | Ours / Photoshop | % | Kind |
|---|---:|---|---:|---|
| Menu commands | 30% | 628 / 628 catalog items dispatch a live command | 100% | measured (`cargo xtask parity`) |
| Toolbox tools | 15% | 58 / 68 (Photoshop's 67 `.kys` tools plus Remove, Triangle, Curvature Pen; minus legacy Rounded Rectangle and Targeted Adjustment) | 85% | measured (`Tool` enum in `ui-egui/src/state.rs`, `TOOL_SECTIONS` in `panels.rs`) |
| Toolbox tools | 15% | 59 / 68 (Photoshop's 67 `.kys` tools plus Remove, Triangle, Curvature Pen; minus legacy Rounded Rectangle and Targeted Adjustment) | 87% | measured (`Tool` enum in `ui-egui/src/state.rs`, `TOOL_SECTIONS` in `panels.rs`) |
| Panels | 10% | 30 / 35 Window-menu panels | 86% | measured (`view_cmds.rs`, panel modules) |
| Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) |
| File formats (usage-weighted) | 15% | 13 of 31 Photoshop formats supported in Photoshop's directions, 4 partial; the common ones (PSD/PSB, TIFF, JPEG, PNG, WebP) all present | ~80% (48% by count) | measured list, estimated weights ([file-format-parity.md](file-format-parity.md)) |
@@ -514,6 +514,18 @@ Bounded halo tiles reduce the large-mask workspace allocation bound from 192/288
to about 115/163 MB, including output and 16 workers' scratch, excluding the source.
See [method and results](octagonal-mask-performance.md).
2026-10-10: five CPU imaging algorithms now reuse source work and interpolation.
On an m7i.4xlarge (16 vCPUs), three alternating paired release runs measured
dense Extrude Blocks (8/128 px, 24 MP RGB8) 160.301 s -> 0.387 s (414.69x);
Blast (24 MP CMYK8) 6.231 s -> 0.709 s (8.79x);
Spin Blur (36 MP RGB8) 9.439 s -> 5.869 s (1.61x);
Lens Correction (24 MP CMYK16) 0.808 s -> 0.565 s (1.43x);
Trap (50 px, 36 MP CMYK16) 4.627 s -> 1.333 s (3.47x).
Default 30/30 px Extrude is 6.26x faster at 24 MP. All 72 full-operation output
comparisons match exactly, including alpha, across 24 cases and 8/16/32-bit depths.
Timers include surface assembly but exclude UI, undo and Trap document flattening.
See [method, raw measurements and reproduction](five-algorithm-performance.md).
### Where we're going (priority order)
1. **Ship the fixes:** cut 0.2.1 once the current batch lands, so users validate them.
+3 -5
View File
@@ -164,11 +164,9 @@ change the current window's decorations; the next launch reads the saved choice.
Edit → Preferences → Interface → UI Scale applies immediately. Auto follows the operating
system's display scale, including fractional scales such as 125%, 150% and 175% (on Linux,
Wayland's fractional scale or X11's `Xft.dpi`). Only when the system reports no scaling (100%) on
a 4K or larger monitor does Auto use 200%, so text and controls remain readable. Detection uses the current monitor,
including portrait displays, and updates when the window moves between monitors. If monitor
size is unavailable, Auto follows system DPI. The fixed choices (75% to 300%) set an absolute UI
scale, allowing large 4K displays to use smaller controls when desired. Canvas zoom shortcuts
Wayland's fractional scale or X11's `Xft.dpi`), and updates when the window moves between
monitors. A 4K display the system runs at 100% stays at 100% (#1278). The fixed choices (75% to
300%) set an absolute UI scale, for example 200% on an unscaled 4K display. Canvas zoom shortcuts
continue to control the document independently of UI scaling.
Interface → UI Font Size changes interface text independently of UI Scale and canvas zoom.
+1 -1
View File
@@ -40,7 +40,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
| Eraser | Eraser, Background Eraser, Magic Eraser | |
| Fill | Gradient, Paint Bucket | |
| Focus, toning | Blur, Sharpen, Smudge, Dodge, Burn, Sponge | |
| Pen | Pen, Freeform Pen, Curvature Pen (#1054) | Add Anchor Point, Delete Anchor Point, Convert Point (⌥ with Pen only) |
| Pen | Pen, Freeform Pen, Curvature Pen (#1054), Convert Point (#3204; also ⌥ with Pen) | Add Anchor Point, Delete Anchor Point |
| Type | Horizontal, Vertical, Vertical Type Mask, Horizontal Type Mask | |
| Path selection | Path Selection, Direct Selection | |
| Shapes | Rectangle, Ellipse, Triangle, Polygon, Line, Custom Shape | (Star is a Polygon option in Photoshop) |
+1 -1
View File
@@ -115,7 +115,7 @@ id = "TOOL-214-1"
target = "Select and Mask workspace: view modes, Refine Edge brush, 6 outputs"
status = "partial"
issue = 214
note = "Opens the generic select.refineEdge dialog (menus.rs:308)"
note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); no workspace layout or Refine Edge brush"
[[item]]
id = "TOOL-214-2"
+1 -1
View File
@@ -66,7 +66,7 @@ id = "UI-217-9"
target = "UI scale presets 100/115/125/150/200 % through zoom_factor, remembered"
status = "done"
issue = 217
note = "prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale and only jumps to 200 percent on an unscaled 4K display, #1072/#225) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting"
note = "prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting"
[[item]]
id = "UI-217-10"