mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Merge upstream/main into fix/3490-move-align-bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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, ¶ms, 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(())
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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]]];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 } => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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))) =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, ¶ms, 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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 => {}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
+365
-355
File diff suppressed because it is too large
Load Diff
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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")),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!(¶ms[0].kind, crate::filter_dialog::Kind::Choice(c) if c[0] == "srgb" && !c.iter().any(|v| v.contains('/'))));
|
||||
assert!(
|
||||
matches!(¶ms[0].kind, crate::filter_dialog::Kind::Choice { options, .. } if options[0] == "srgb" && !options.iter().any(|v| v.contains('/')))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ¬ice.title), "El arrastrar y soltar archivos de forma nativa no está disponible en Wayland");
|
||||
assert_eq!(crate::i18n::tr(es, ¬ice.title), "Arrastrar y soltar archivos de forma nativa no está disponible en Wayland");
|
||||
assert_ne!(crate::i18n::tr(es, ¬ice.lines[0]), notice.lines[0]);
|
||||
assert_eq!(crate::i18n::tr(crate::i18n::Lang::EN, ¬ice.title), "Native file drag-and-drop is unavailable");
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user